Skip to content

Commit ee333c6

Browse files
authored
Merge pull request #140 from prgrms-web-devcourse-final-project/Refactor/134
Refactor: 채팅/웹소켓 코드 리팩토링 (#134)
2 parents 4b33ece + 391f94a commit ee333c6

File tree

18 files changed

+2158
-496
lines changed

18 files changed

+2158
-496
lines changed

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ dependencies {
6767
testImplementation("org.springframework.boot:spring-boot-starter-test")
6868
testImplementation("org.springframework.security:spring-security-test")
6969
testImplementation("org.testcontainers:testcontainers:1.19.3")
70+
testImplementation("net.ttddyy:datasource-proxy:1.8.1")
7071
testImplementation("org.testcontainers:junit-jupiter:1.19.3")
7172
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
7273

src/main/java/com/back/domain/chat/room/service/RoomChatService.java

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727

2828
@Service
2929
@RequiredArgsConstructor
30-
@Transactional(readOnly = true)
3130
public class RoomChatService {
3231

3332
private final RoomChatMessageRepository roomChatMessageRepository;
@@ -60,6 +59,7 @@ public RoomChatMessage saveRoomChatMessage(RoomChatMessageDto roomChatMessageDto
6059
}
6160

6261
// 방 채팅 기록 조회
62+
@Transactional(readOnly = true)
6363
public RoomChatPageResponse getRoomChatHistory(Long roomId, int page, int size, LocalDateTime before) {
6464

6565
// 방 존재 여부 확인
@@ -125,6 +125,14 @@ public ChatClearedNotification.ClearedByDto clearRoomChat(Long roomId, Long user
125125
}
126126
}
127127

128+
// 방의 현재 채팅 메시지 수 조회
129+
@Transactional(readOnly = true)
130+
public int getRoomChatCount(Long roomId) {
131+
return roomChatMessageRepository.countByRoomId(roomId);
132+
}
133+
134+
// --------------------- 헬퍼 메서드들 ---------------------
135+
128136
// 채팅 관리 권한 확인 (방장 또는 부방장)
129137
private boolean canManageChat(RoomRole role) {
130138
return role == RoomRole.HOST || role == RoomRole.SUB_HOST;
@@ -153,9 +161,4 @@ private RoomChatMessageDto convertToDto(RoomChatMessage message) {
153161
);
154162
}
155163

156-
// 방의 현재 채팅 메시지 수 조회
157-
public int getRoomChatCount(Long roomId) {
158-
return roomChatMessageRepository.countByRoomId(roomId);
159-
}
160-
161164
}

src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public Page<RoomChatMessage> findMessagesByRoomId(Long roomId, Pageable pageable
3535
.selectFrom(message)
3636
.leftJoin(message.room, room).fetchJoin() // Room 정보 즉시 로딩
3737
.leftJoin(message.user, user).fetchJoin() // User 정보 즉시 로딩
38+
.leftJoin(user.userProfile).fetchJoin() // UserProfile 정보 즉시 로딩
3839
.where(message.room.id.eq(roomId))
3940
.orderBy(message.createdAt.desc()) // 최신순 정렬
4041
.offset(pageable.getOffset())
@@ -64,6 +65,7 @@ public Page<RoomChatMessage> findMessagesByRoomIdBefore(Long roomId, LocalDateTi
6465
.selectFrom(message)
6566
.leftJoin(message.room, room).fetchJoin()
6667
.leftJoin(message.user, user).fetchJoin()
68+
.leftJoin(user.userProfile).fetchJoin()
6769
.where(whereClause)
6870
.orderBy(message.createdAt.desc())
6971
.offset(pageable.getOffset())

src/main/java/com/back/global/config/WebSocketConfig.java renamed to src/main/java/com/back/global/websocket/config/WebSocketConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.back.global.config;
1+
package com.back.global.websocket.config;
22

33
import com.back.global.security.user.CustomUserDetails;
44
import com.back.global.security.jwt.JwtTokenProvider;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.back.global.websocket.config;
2+
3+
import java.time.Duration;
4+
5+
public final class WebSocketConstants {
6+
7+
private WebSocketConstants() {
8+
throw new AssertionError("상수 클래스는 인스턴스화할 수 없습니다.");
9+
}
10+
11+
// ===== TTL & Timeout 설정 =====
12+
13+
/**
14+
* WebSocket 세션 TTL (6분)
15+
* - Heartbeat로 연장됨
16+
*/
17+
public static final Duration SESSION_TTL = Duration.ofMinutes(6);
18+
19+
/**
20+
* Heartbeat 권장 간격 (5분)
21+
* - 클라이언트가 이 주기로 Heartbeat 전송 권장
22+
*/
23+
public static final Duration HEARTBEAT_INTERVAL = Duration.ofMinutes(5);
24+
25+
// ===== Redis Key 패턴 =====
26+
27+
/**
28+
* 사용자 세션 정보 저장 Key
29+
* - 패턴: ws:user:{userId}
30+
* - 값: WebSocketSessionInfo
31+
*/
32+
public static final String USER_SESSION_KEY_PREFIX = "ws:user:";
33+
34+
/**
35+
* 세션 → 사용자 매핑 Key
36+
* - 패턴: ws:session:{sessionId}
37+
* - 값: userId (Long)
38+
*/
39+
public static final String SESSION_USER_KEY_PREFIX = "ws:session:";
40+
41+
/**
42+
* 방별 참가자 목록 Key
43+
* - 패턴: ws:room:{roomId}:users
44+
* - 값: Set<userId>
45+
*/
46+
public static final String ROOM_USERS_KEY_PREFIX = "ws:room:";
47+
public static final String ROOM_USERS_KEY_SUFFIX = ":users";
48+
49+
// ===== Key 빌더 헬퍼 메서드 =====
50+
51+
public static String buildUserSessionKey(Long userId) {
52+
return USER_SESSION_KEY_PREFIX + userId;
53+
}
54+
55+
public static String buildSessionUserKey(String sessionId) {
56+
return SESSION_USER_KEY_PREFIX + sessionId;
57+
}
58+
59+
public static String buildRoomUsersKey(Long roomId) {
60+
return ROOM_USERS_KEY_PREFIX + roomId + ROOM_USERS_KEY_SUFFIX;
61+
}
62+
63+
public static String buildUserSessionKeyPattern() {
64+
return USER_SESSION_KEY_PREFIX + "*";
65+
}
66+
67+
// ===== API 응답용 =====
68+
69+
public static String getSessionTTLDescription() {
70+
return SESSION_TTL.toMinutes() + "분 (Heartbeat 방식)";
71+
}
72+
73+
public static String getHeartbeatIntervalDescription() {
74+
return HEARTBEAT_INTERVAL.toMinutes() + "분";
75+
}
76+
}

src/main/java/com/back/global/websocket/controller/WebSocketApiController.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.back.global.websocket.controller;
22

33
import com.back.global.common.dto.RsData;
4+
import com.back.global.websocket.config.WebSocketConstants;
45
import com.back.global.websocket.service.WebSocketSessionManager;
56
import io.swagger.v3.oas.annotations.Operation;
67
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -29,8 +30,8 @@ public ResponseEntity<RsData<Map<String, Object>>> healthCheck() {
2930
data.put("service", "WebSocket");
3031
data.put("status", "running");
3132
data.put("timestamp", LocalDateTime.now());
32-
data.put("sessionTTL", "10분 (Heartbeat 방식)");
33-
data.put("heartbeatInterval", "5분");
33+
data.put("sessionTTL", WebSocketConstants.getSessionTTLDescription());
34+
data.put("heartbeatInterval", WebSocketConstants.getHeartbeatIntervalDescription());
3435
data.put("totalOnlineUsers", sessionManager.getTotalOnlineUserCount());
3536
data.put("endpoints", Map.of(
3637
"websocket", "/ws",
@@ -53,8 +54,8 @@ public ResponseEntity<RsData<Map<String, Object>>> getConnectionInfo() {
5354
connectionInfo.put("websocketUrl", "/ws");
5455
connectionInfo.put("sockjsSupport", true);
5556
connectionInfo.put("stompVersion", "1.2");
56-
connectionInfo.put("heartbeatInterval", "5분");
57-
connectionInfo.put("sessionTTL", "10분");
57+
connectionInfo.put("heartbeatInterval", WebSocketConstants.getHeartbeatIntervalDescription());
58+
connectionInfo.put("sessionTTL", WebSocketConstants.getSessionTTLDescription());
5859
connectionInfo.put("subscribeTopics", Map.of(
5960
"roomChat", "/topic/rooms/{roomId}/chat",
6061
"privateMessage", "/user/queue/messages",
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package com.back.global.websocket.service;
2+
3+
import com.back.global.exception.CustomException;
4+
import com.back.global.exception.ErrorCode;
5+
import com.back.global.websocket.dto.WebSocketSessionInfo;
6+
import com.back.global.websocket.store.RedisSessionStore;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.stereotype.Service;
10+
11+
import java.util.Set;
12+
13+
/**
14+
* 방 참가자 관리 서비스
15+
* - 방 입장/퇴장 처리
16+
* - 방별 참가자 목록 관리
17+
* - 방별 온라인 사용자 통계
18+
*/
19+
@Slf4j
20+
@Service
21+
@RequiredArgsConstructor
22+
public class RoomParticipantService {
23+
24+
private final RedisSessionStore redisSessionStore;
25+
26+
// 사용자 방 입장
27+
public void enterRoom(Long userId, Long roomId) {
28+
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);
29+
30+
if (sessionInfo == null) {
31+
log.warn("세션 정보가 없어 방 입장 실패 - 사용자: {}, 방: {}", userId, roomId);
32+
throw new CustomException(ErrorCode.WS_SESSION_NOT_FOUND);
33+
}
34+
35+
if (sessionInfo.currentRoomId() != null) {
36+
exitRoom(userId, sessionInfo.currentRoomId());
37+
log.debug("기존 방에서 퇴장 처리 완료 - 사용자: {}, 이전 방: {}",
38+
userId, sessionInfo.currentRoomId());
39+
}
40+
41+
WebSocketSessionInfo updatedSession = sessionInfo.withRoomId(roomId);
42+
redisSessionStore.saveUserSession(userId, updatedSession);
43+
redisSessionStore.addUserToRoom(roomId, userId);
44+
45+
log.info("방 입장 완료 - 사용자: {}, 방: {}", userId, roomId);
46+
}
47+
48+
// 사용자 방 퇴장
49+
public void exitRoom(Long userId, Long roomId) {
50+
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);
51+
52+
if (sessionInfo == null) {
53+
log.warn("세션 정보가 없지만 방 퇴장 처리 계속 진행 - 사용자: {}, 방: {}", userId, roomId);
54+
} else {
55+
WebSocketSessionInfo updatedSession = sessionInfo.withoutRoom();
56+
redisSessionStore.saveUserSession(userId, updatedSession);
57+
}
58+
59+
redisSessionStore.removeUserFromRoom(roomId, userId);
60+
log.info("방 퇴장 완료 - 사용자: {}, 방: {}", userId, roomId);
61+
}
62+
63+
// 사용자의 현재 방 ID 조회
64+
public Long getCurrentRoomId(Long userId) {
65+
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);
66+
return sessionInfo != null ? sessionInfo.currentRoomId() : null;
67+
}
68+
69+
// 방의 온라인 참가자 목록 조회
70+
public Set<Long> getParticipants(Long roomId) {
71+
return redisSessionStore.getRoomUsers(roomId);
72+
}
73+
74+
// 방의 온라인 참가자 수 조회
75+
public long getParticipantCount(Long roomId) {
76+
return redisSessionStore.getRoomUserCount(roomId);
77+
}
78+
79+
// 사용자가 특정 방에 참여 중인지 확인
80+
public boolean isUserInRoom(Long userId, Long roomId) {
81+
Long currentRoomId = getCurrentRoomId(userId);
82+
return currentRoomId != null && currentRoomId.equals(roomId);
83+
}
84+
85+
// 모든 방에서 사용자 퇴장 처리 (세션 종료 시 사용)
86+
public void exitAllRooms(Long userId) {
87+
try {
88+
Long currentRoomId = getCurrentRoomId(userId);
89+
90+
if (currentRoomId != null) {
91+
exitRoom(userId, currentRoomId);
92+
log.info("모든 방에서 퇴장 처리 완료 - 사용자: {}", userId);
93+
}
94+
95+
} catch (Exception e) {
96+
log.error("모든 방 퇴장 처리 실패 - 사용자: {}", userId, e);
97+
// 에러를 던지지 않고 로그만 남김 (세션 종료는 계속 진행되어야 함)
98+
}
99+
}
100+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package com.back.global.websocket.service;
2+
3+
import com.back.global.exception.CustomException;
4+
import com.back.global.exception.ErrorCode;
5+
import com.back.global.websocket.dto.WebSocketSessionInfo;
6+
import com.back.global.websocket.store.RedisSessionStore;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.stereotype.Service;
10+
11+
/**
12+
* 사용자 세션 관리 서비스
13+
* - 세션 생명주기 관리 (등록, 종료)
14+
* - Heartbeat 처리
15+
* - 중복 연결 방지
16+
* - 연결 상태 조회
17+
*/
18+
@Slf4j
19+
@Service
20+
@RequiredArgsConstructor
21+
public class UserSessionService {
22+
23+
private final RedisSessionStore redisSessionStore;
24+
25+
// 세션 등록
26+
public void registerSession(Long userId, String sessionId) {
27+
WebSocketSessionInfo existingSession = redisSessionStore.getUserSession(userId);
28+
if (existingSession != null) {
29+
terminateSession(existingSession.sessionId());
30+
log.info("기존 세션 제거 후 새 세션 등록 - 사용자: {}", userId);
31+
}
32+
33+
WebSocketSessionInfo newSession = WebSocketSessionInfo.createNewSession(userId, sessionId);
34+
redisSessionStore.saveUserSession(userId, newSession);
35+
redisSessionStore.saveSessionUserMapping(sessionId, userId);
36+
37+
log.info("WebSocket 세션 등록 완료 - 사용자: {}, 세션: {}", userId, sessionId);
38+
}
39+
40+
// 세션 종료
41+
public void terminateSession(String sessionId) {
42+
Long userId = redisSessionStore.getUserIdBySession(sessionId);
43+
44+
if (userId != null) {
45+
redisSessionStore.deleteUserSession(userId);
46+
redisSessionStore.deleteSessionUserMapping(sessionId);
47+
log.info("WebSocket 세션 종료 완료 - 세션: {}, 사용자: {}", sessionId, userId);
48+
} else {
49+
log.warn("종료할 세션을 찾을 수 없음 - 세션: {}", sessionId);
50+
}
51+
}
52+
53+
// Heartbeat 처리 (활동 시간 업데이트 및 TTL 연장)
54+
public void processHeartbeat(Long userId) {
55+
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);
56+
57+
if (sessionInfo == null) {
58+
log.warn("세션 정보가 없어 Heartbeat 처리 실패 - 사용자: {}", userId);
59+
return;
60+
}
61+
62+
WebSocketSessionInfo updatedSession = sessionInfo.withUpdatedActivity();
63+
redisSessionStore.saveUserSession(userId, updatedSession);
64+
65+
log.debug("Heartbeat 처리 완료 - 사용자: {}, TTL 연장", userId);
66+
}
67+
68+
// 사용자 연결 상태 확인
69+
public boolean isConnected(Long userId) {
70+
return redisSessionStore.existsUserSession(userId);
71+
}
72+
73+
// 사용자 세션 정보 조회
74+
public WebSocketSessionInfo getSessionInfo(Long userId) {
75+
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);
76+
77+
if (sessionInfo == null) {
78+
log.debug("세션 정보 없음 - 사용자: {}", userId);
79+
}
80+
81+
return sessionInfo;
82+
}
83+
84+
// 세션ID로 사용자ID 조회
85+
public Long getUserIdBySessionId(String sessionId) {
86+
return redisSessionStore.getUserIdBySession(sessionId);
87+
}
88+
89+
// 사용자의 현재 방 ID 조회
90+
public Long getCurrentRoomId(Long userId) {
91+
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);
92+
return sessionInfo != null ? sessionInfo.currentRoomId() : null;
93+
}
94+
95+
// 전체 온라인 사용자 수 조회
96+
public long getTotalOnlineUserCount() {
97+
return redisSessionStore.getTotalOnlineUserCount();
98+
}
99+
}

0 commit comments

Comments
 (0)