diff --git a/build.gradle.kts b/build.gradle.kts index 59746d63..79139679 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.security:spring-security-test") testImplementation("org.testcontainers:testcontainers:1.19.3") + testImplementation("net.ttddyy:datasource-proxy:1.8.1") testImplementation("org.testcontainers:junit-jupiter:1.19.3") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/src/main/java/com/back/domain/chat/room/service/RoomChatService.java b/src/main/java/com/back/domain/chat/room/service/RoomChatService.java index 8999bf65..c938ddf5 100644 --- a/src/main/java/com/back/domain/chat/room/service/RoomChatService.java +++ b/src/main/java/com/back/domain/chat/room/service/RoomChatService.java @@ -27,7 +27,6 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class RoomChatService { private final RoomChatMessageRepository roomChatMessageRepository; @@ -60,6 +59,7 @@ public RoomChatMessage saveRoomChatMessage(RoomChatMessageDto roomChatMessageDto } // 방 채팅 기록 조회 + @Transactional(readOnly = true) public RoomChatPageResponse getRoomChatHistory(Long roomId, int page, int size, LocalDateTime before) { // 방 존재 여부 확인 @@ -125,6 +125,14 @@ public ChatClearedNotification.ClearedByDto clearRoomChat(Long roomId, Long user } } + // 방의 현재 채팅 메시지 수 조회 + @Transactional(readOnly = true) + public int getRoomChatCount(Long roomId) { + return roomChatMessageRepository.countByRoomId(roomId); + } + + // --------------------- 헬퍼 메서드들 --------------------- + // 채팅 관리 권한 확인 (방장 또는 부방장) private boolean canManageChat(RoomRole role) { return role == RoomRole.HOST || role == RoomRole.SUB_HOST; @@ -153,9 +161,4 @@ private RoomChatMessageDto convertToDto(RoomChatMessage message) { ); } - // 방의 현재 채팅 메시지 수 조회 - public int getRoomChatCount(Long roomId) { - return roomChatMessageRepository.countByRoomId(roomId); - } - } \ No newline at end of file diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java index 4f8dd70f..61e4e185 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java @@ -35,6 +35,7 @@ public Page findMessagesByRoomId(Long roomId, Pageable pageable .selectFrom(message) .leftJoin(message.room, room).fetchJoin() // Room 정보 즉시 로딩 .leftJoin(message.user, user).fetchJoin() // User 정보 즉시 로딩 + .leftJoin(user.userProfile).fetchJoin() // UserProfile 정보 즉시 로딩 .where(message.room.id.eq(roomId)) .orderBy(message.createdAt.desc()) // 최신순 정렬 .offset(pageable.getOffset()) @@ -64,6 +65,7 @@ public Page findMessagesByRoomIdBefore(Long roomId, LocalDateTi .selectFrom(message) .leftJoin(message.room, room).fetchJoin() .leftJoin(message.user, user).fetchJoin() + .leftJoin(user.userProfile).fetchJoin() .where(whereClause) .orderBy(message.createdAt.desc()) .offset(pageable.getOffset()) diff --git a/src/main/java/com/back/global/config/WebSocketConfig.java b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java similarity index 99% rename from src/main/java/com/back/global/config/WebSocketConfig.java rename to src/main/java/com/back/global/websocket/config/WebSocketConfig.java index d0c2e13d..ba2ba7f3 100644 --- a/src/main/java/com/back/global/config/WebSocketConfig.java +++ b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java @@ -1,4 +1,4 @@ -package com.back.global.config; +package com.back.global.websocket.config; import com.back.global.security.user.CustomUserDetails; import com.back.global.security.jwt.JwtTokenProvider; diff --git a/src/main/java/com/back/global/websocket/config/WebSocketConstants.java b/src/main/java/com/back/global/websocket/config/WebSocketConstants.java new file mode 100644 index 00000000..102bb1d3 --- /dev/null +++ b/src/main/java/com/back/global/websocket/config/WebSocketConstants.java @@ -0,0 +1,76 @@ +package com.back.global.websocket.config; + +import java.time.Duration; + +public final class WebSocketConstants { + + private WebSocketConstants() { + throw new AssertionError("상수 클래스는 인스턴스화할 수 없습니다."); + } + + // ===== TTL & Timeout 설정 ===== + + /** + * WebSocket 세션 TTL (6분) + * - Heartbeat로 연장됨 + */ + public static final Duration SESSION_TTL = Duration.ofMinutes(6); + + /** + * Heartbeat 권장 간격 (5분) + * - 클라이언트가 이 주기로 Heartbeat 전송 권장 + */ + public static final Duration HEARTBEAT_INTERVAL = Duration.ofMinutes(5); + + // ===== Redis Key 패턴 ===== + + /** + * 사용자 세션 정보 저장 Key + * - 패턴: ws:user:{userId} + * - 값: WebSocketSessionInfo + */ + public static final String USER_SESSION_KEY_PREFIX = "ws:user:"; + + /** + * 세션 → 사용자 매핑 Key + * - 패턴: ws:session:{sessionId} + * - 값: userId (Long) + */ + public static final String SESSION_USER_KEY_PREFIX = "ws:session:"; + + /** + * 방별 참가자 목록 Key + * - 패턴: ws:room:{roomId}:users + * - 값: Set + */ + public static final String ROOM_USERS_KEY_PREFIX = "ws:room:"; + public static final String ROOM_USERS_KEY_SUFFIX = ":users"; + + // ===== Key 빌더 헬퍼 메서드 ===== + + public static String buildUserSessionKey(Long userId) { + return USER_SESSION_KEY_PREFIX + userId; + } + + public static String buildSessionUserKey(String sessionId) { + return SESSION_USER_KEY_PREFIX + sessionId; + } + + public static String buildRoomUsersKey(Long roomId) { + return ROOM_USERS_KEY_PREFIX + roomId + ROOM_USERS_KEY_SUFFIX; + } + + public static String buildUserSessionKeyPattern() { + return USER_SESSION_KEY_PREFIX + "*"; + } + + // ===== API 응답용 ===== + + public static String getSessionTTLDescription() { + return SESSION_TTL.toMinutes() + "분 (Heartbeat 방식)"; + } + + public static String getHeartbeatIntervalDescription() { + return HEARTBEAT_INTERVAL.toMinutes() + "분"; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/controller/WebSocketApiController.java b/src/main/java/com/back/global/websocket/controller/WebSocketApiController.java index e8e08593..7b4b61fe 100644 --- a/src/main/java/com/back/global/websocket/controller/WebSocketApiController.java +++ b/src/main/java/com/back/global/websocket/controller/WebSocketApiController.java @@ -1,6 +1,7 @@ package com.back.global.websocket.controller; import com.back.global.common.dto.RsData; +import com.back.global.websocket.config.WebSocketConstants; import com.back.global.websocket.service.WebSocketSessionManager; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -29,8 +30,8 @@ public ResponseEntity>> healthCheck() { data.put("service", "WebSocket"); data.put("status", "running"); data.put("timestamp", LocalDateTime.now()); - data.put("sessionTTL", "10분 (Heartbeat 방식)"); - data.put("heartbeatInterval", "5분"); + data.put("sessionTTL", WebSocketConstants.getSessionTTLDescription()); + data.put("heartbeatInterval", WebSocketConstants.getHeartbeatIntervalDescription()); data.put("totalOnlineUsers", sessionManager.getTotalOnlineUserCount()); data.put("endpoints", Map.of( "websocket", "/ws", @@ -53,8 +54,8 @@ public ResponseEntity>> getConnectionInfo() { connectionInfo.put("websocketUrl", "/ws"); connectionInfo.put("sockjsSupport", true); connectionInfo.put("stompVersion", "1.2"); - connectionInfo.put("heartbeatInterval", "5분"); - connectionInfo.put("sessionTTL", "10분"); + connectionInfo.put("heartbeatInterval", WebSocketConstants.getHeartbeatIntervalDescription()); + connectionInfo.put("sessionTTL", WebSocketConstants.getSessionTTLDescription()); connectionInfo.put("subscribeTopics", Map.of( "roomChat", "/topic/rooms/{roomId}/chat", "privateMessage", "/user/queue/messages", diff --git a/src/main/java/com/back/global/websocket/service/RoomParticipantService.java b/src/main/java/com/back/global/websocket/service/RoomParticipantService.java new file mode 100644 index 00000000..89f8bd1c --- /dev/null +++ b/src/main/java/com/back/global/websocket/service/RoomParticipantService.java @@ -0,0 +1,100 @@ +package com.back.global.websocket.service; + +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.back.global.websocket.store.RedisSessionStore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Set; + +/** + * 방 참가자 관리 서비스 + * - 방 입장/퇴장 처리 + * - 방별 참가자 목록 관리 + * - 방별 온라인 사용자 통계 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RoomParticipantService { + + private final RedisSessionStore redisSessionStore; + + // 사용자 방 입장 + public void enterRoom(Long userId, Long roomId) { + WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); + + if (sessionInfo == null) { + log.warn("세션 정보가 없어 방 입장 실패 - 사용자: {}, 방: {}", userId, roomId); + throw new CustomException(ErrorCode.WS_SESSION_NOT_FOUND); + } + + if (sessionInfo.currentRoomId() != null) { + exitRoom(userId, sessionInfo.currentRoomId()); + log.debug("기존 방에서 퇴장 처리 완료 - 사용자: {}, 이전 방: {}", + userId, sessionInfo.currentRoomId()); + } + + WebSocketSessionInfo updatedSession = sessionInfo.withRoomId(roomId); + redisSessionStore.saveUserSession(userId, updatedSession); + redisSessionStore.addUserToRoom(roomId, userId); + + log.info("방 입장 완료 - 사용자: {}, 방: {}", userId, roomId); + } + + // 사용자 방 퇴장 + public void exitRoom(Long userId, Long roomId) { + WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); + + if (sessionInfo == null) { + log.warn("세션 정보가 없지만 방 퇴장 처리 계속 진행 - 사용자: {}, 방: {}", userId, roomId); + } else { + WebSocketSessionInfo updatedSession = sessionInfo.withoutRoom(); + redisSessionStore.saveUserSession(userId, updatedSession); + } + + redisSessionStore.removeUserFromRoom(roomId, userId); + log.info("방 퇴장 완료 - 사용자: {}, 방: {}", userId, roomId); + } + + // 사용자의 현재 방 ID 조회 + public Long getCurrentRoomId(Long userId) { + WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); + return sessionInfo != null ? sessionInfo.currentRoomId() : null; + } + + // 방의 온라인 참가자 목록 조회 + public Set getParticipants(Long roomId) { + return redisSessionStore.getRoomUsers(roomId); + } + + // 방의 온라인 참가자 수 조회 + public long getParticipantCount(Long roomId) { + return redisSessionStore.getRoomUserCount(roomId); + } + + // 사용자가 특정 방에 참여 중인지 확인 + public boolean isUserInRoom(Long userId, Long roomId) { + Long currentRoomId = getCurrentRoomId(userId); + return currentRoomId != null && currentRoomId.equals(roomId); + } + + // 모든 방에서 사용자 퇴장 처리 (세션 종료 시 사용) + public void exitAllRooms(Long userId) { + try { + Long currentRoomId = getCurrentRoomId(userId); + + if (currentRoomId != null) { + exitRoom(userId, currentRoomId); + log.info("모든 방에서 퇴장 처리 완료 - 사용자: {}", userId); + } + + } catch (Exception e) { + log.error("모든 방 퇴장 처리 실패 - 사용자: {}", userId, e); + // 에러를 던지지 않고 로그만 남김 (세션 종료는 계속 진행되어야 함) + } + } +} diff --git a/src/main/java/com/back/global/websocket/service/UserSessionService.java b/src/main/java/com/back/global/websocket/service/UserSessionService.java new file mode 100644 index 00000000..bb45a051 --- /dev/null +++ b/src/main/java/com/back/global/websocket/service/UserSessionService.java @@ -0,0 +1,99 @@ +package com.back.global.websocket.service; + +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.back.global.websocket.store.RedisSessionStore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 사용자 세션 관리 서비스 + * - 세션 생명주기 관리 (등록, 종료) + * - Heartbeat 처리 + * - 중복 연결 방지 + * - 연결 상태 조회 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserSessionService { + + private final RedisSessionStore redisSessionStore; + + // 세션 등록 + public void registerSession(Long userId, String sessionId) { + WebSocketSessionInfo existingSession = redisSessionStore.getUserSession(userId); + if (existingSession != null) { + terminateSession(existingSession.sessionId()); + log.info("기존 세션 제거 후 새 세션 등록 - 사용자: {}", userId); + } + + WebSocketSessionInfo newSession = WebSocketSessionInfo.createNewSession(userId, sessionId); + redisSessionStore.saveUserSession(userId, newSession); + redisSessionStore.saveSessionUserMapping(sessionId, userId); + + log.info("WebSocket 세션 등록 완료 - 사용자: {}, 세션: {}", userId, sessionId); + } + + // 세션 종료 + public void terminateSession(String sessionId) { + Long userId = redisSessionStore.getUserIdBySession(sessionId); + + if (userId != null) { + redisSessionStore.deleteUserSession(userId); + redisSessionStore.deleteSessionUserMapping(sessionId); + log.info("WebSocket 세션 종료 완료 - 세션: {}, 사용자: {}", sessionId, userId); + } else { + log.warn("종료할 세션을 찾을 수 없음 - 세션: {}", sessionId); + } + } + + // Heartbeat 처리 (활동 시간 업데이트 및 TTL 연장) + public void processHeartbeat(Long userId) { + WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); + + if (sessionInfo == null) { + log.warn("세션 정보가 없어 Heartbeat 처리 실패 - 사용자: {}", userId); + return; + } + + WebSocketSessionInfo updatedSession = sessionInfo.withUpdatedActivity(); + redisSessionStore.saveUserSession(userId, updatedSession); + + log.debug("Heartbeat 처리 완료 - 사용자: {}, TTL 연장", userId); + } + + // 사용자 연결 상태 확인 + public boolean isConnected(Long userId) { + return redisSessionStore.existsUserSession(userId); + } + + // 사용자 세션 정보 조회 + public WebSocketSessionInfo getSessionInfo(Long userId) { + WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); + + if (sessionInfo == null) { + log.debug("세션 정보 없음 - 사용자: {}", userId); + } + + return sessionInfo; + } + + // 세션ID로 사용자ID 조회 + public Long getUserIdBySessionId(String sessionId) { + return redisSessionStore.getUserIdBySession(sessionId); + } + + // 사용자의 현재 방 ID 조회 + public Long getCurrentRoomId(Long userId) { + WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); + return sessionInfo != null ? sessionInfo.currentRoomId() : null; + } + + // 전체 온라인 사용자 수 조회 + public long getTotalOnlineUserCount() { + return redisSessionStore.getTotalOnlineUserCount(); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java b/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java index 3e770c04..9a29f2fb 100644 --- a/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java +++ b/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java @@ -1,276 +1,87 @@ package com.back.global.websocket.service; -import com.back.global.exception.CustomException; -import com.back.global.exception.ErrorCode; import com.back.global.websocket.dto.WebSocketSessionInfo; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; -import java.time.Duration; -import java.util.LinkedHashMap; import java.util.Set; -import java.util.stream.Collectors; @Slf4j @Service @RequiredArgsConstructor public class WebSocketSessionManager { - private final RedisTemplate redisTemplate; + private final UserSessionService userSessionService; + private final RoomParticipantService roomParticipantService; - // Redis Key 패턴 - private static final String USER_SESSION_KEY = "ws:user:{}"; - private static final String SESSION_USER_KEY = "ws:session:{}"; - private static final String ROOM_USERS_KEY = "ws:room:{}:users"; - - // TTL 설정 - private static final int SESSION_TTL_MINUTES = 6; - - // 사용자 세션 추가 (연결 시 호출) + // 사용자 세션 추가 (WebSocket 연결 시 호출) public void addSession(Long userId, String sessionId) { - try { - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); - - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - String sessionKey = SESSION_USER_KEY.replace("{}", sessionId); - - // 기존 세션이 있다면 제거 (중복 연결 방지) - WebSocketSessionInfo existingSession = getSessionInfo(userId); - if (existingSession != null) { - removeSessionInternal(existingSession.sessionId()); - log.info("기존 세션 제거 후 새 세션 등록 - 사용자: {}", userId); - } + userSessionService.registerSession(userId, sessionId); + } - // 새 세션 등록 (TTL 10분) - redisTemplate.opsForValue().set(userKey, sessionInfo, Duration.ofMinutes(SESSION_TTL_MINUTES)); - redisTemplate.opsForValue().set(sessionKey, userId, Duration.ofMinutes(SESSION_TTL_MINUTES)); + // 세션 제거 (WebSocket 연결 종료 시 호출) + public void removeSession(String sessionId) { + Long userId = userSessionService.getUserIdBySessionId(sessionId); - log.info("WebSocket 세션 등록 완료 - 사용자: {}, 세션: {}, TTL: {}분", - userId, sessionId, SESSION_TTL_MINUTES); + if (userId != null) { + // 1. 모든 방에서 퇴장 + roomParticipantService.exitAllRooms(userId); - } catch (Exception e) { - log.error("WebSocket 세션 등록 실패 - 사용자: {}", userId, e); - throw new CustomException(ErrorCode.WS_CONNECTION_FAILED); + // 2. 세션 종료 + userSessionService.terminateSession(sessionId); + } else { + log.warn("종료할 세션을 찾을 수 없음 - 세션: {}", sessionId); } } // 사용자 연결 상태 확인 public boolean isUserConnected(Long userId) { - try { - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - return Boolean.TRUE.equals(redisTemplate.hasKey(userKey)); - } catch (Exception e) { - log.error("사용자 연결 상태 확인 실패 - 사용자: {}", userId, e); - throw new CustomException(ErrorCode.WS_REDIS_ERROR); - } + return userSessionService.isConnected(userId); } // 사용자 세션 정보 조회 public WebSocketSessionInfo getSessionInfo(Long userId) { - try { - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - Object value = redisTemplate.opsForValue().get(userKey); - - if (value == null) { - return null; - } - - // LinkedHashMap으로 역직렬화된 경우 또는 타입이 맞지 않는 경우 변환 - if (value instanceof LinkedHashMap || !(value instanceof WebSocketSessionInfo)) { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new JavaTimeModule()); - mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - return mapper.convertValue(value, WebSocketSessionInfo.class); - } - - return (WebSocketSessionInfo) value; - - } catch (Exception e) { - log.error("세션 정보 조회 실패 - 사용자: {}", userId, e); - throw new CustomException(ErrorCode.WS_REDIS_ERROR); - } + return userSessionService.getSessionInfo(userId); } - // 세션 제거 (연결 종료 시 호출) - public void removeSession(String sessionId) { - try { - removeSessionInternal(sessionId); - log.info("WebSocket 세션 제거 완료 - 세션: {}", sessionId); - } catch (Exception e) { - log.error("WebSocket 세션 제거 실패 - 세션: {}", sessionId, e); - throw new CustomException(ErrorCode.WS_REDIS_ERROR); - } - } - - // 사용자 활동 시간 업데이트 및 TTL 연장 (Heartbeat 시 호출) + // Heartbeat 처리 (활동 시간 업데이트 및 TTL 연장) public void updateLastActivity(Long userId) { - try { - WebSocketSessionInfo sessionInfo = getSessionInfo(userId); - if (sessionInfo != null) { - // 마지막 활동 시간 업데이트 - WebSocketSessionInfo updatedSessionInfo = sessionInfo.withUpdatedActivity(); - - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - - // TTL 10분으로 연장 - redisTemplate.opsForValue().set(userKey, updatedSessionInfo, Duration.ofMinutes(SESSION_TTL_MINUTES)); + userSessionService.processHeartbeat(userId); + } - log.debug("사용자 활동 시간 업데이트 완료 - 사용자: {}, TTL 연장", userId); - } else { - log.warn("세션 정보가 없어 활동 시간 업데이트 실패 - 사용자: {}", userId); - } - } catch (CustomException e) { - throw e; - } catch (Exception e) { - log.error("사용자 활동 시간 업데이트 실패 - 사용자: {}", userId, e); - throw new CustomException(ErrorCode.WS_ACTIVITY_UPDATE_FAILED); - } + // 전체 온라인 사용자 수 조회 + public long getTotalOnlineUserCount() { + return userSessionService.getTotalOnlineUserCount(); } - // 사용자가 방에 입장 (WebSocket 전용) + // 사용자가 방에 입장 public void joinRoom(Long userId, Long roomId) { - try { - WebSocketSessionInfo sessionInfo = getSessionInfo(userId); - if (sessionInfo != null) { - // 기존 방에서 퇴장 - if (sessionInfo.currentRoomId() != null) { - leaveRoom(userId, sessionInfo.currentRoomId()); - } - - // 새 방 정보 업데이트 - WebSocketSessionInfo updatedSessionInfo = sessionInfo.withRoomId(roomId); - - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - redisTemplate.opsForValue().set(userKey, updatedSessionInfo, Duration.ofMinutes(SESSION_TTL_MINUTES)); - - // 방 참여자 목록에 추가 - String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString()); - redisTemplate.opsForSet().add(roomUsersKey, userId); - redisTemplate.expire(roomUsersKey, Duration.ofMinutes(SESSION_TTL_MINUTES)); - - log.info("WebSocket 방 입장 완료 - 사용자: {}, 방: {}", userId, roomId); - } else { - log.warn("세션 정보가 없어 방 입장 처리 실패 - 사용자: {}, 방: {}", userId, roomId); - } - } catch (CustomException e) { - throw e; - } catch (Exception e) { - log.error("사용자 방 입장 실패 - 사용자: {}, 방: {}", userId, roomId, e); - throw new CustomException(ErrorCode.WS_ROOM_JOIN_FAILED); - } + roomParticipantService.enterRoom(userId, roomId); } - // 사용자가 방에서 퇴장 (WebSocket 전용) + // 사용자가 방에서 퇴장 public void leaveRoom(Long userId, Long roomId) { - try { - WebSocketSessionInfo sessionInfo = getSessionInfo(userId); - if (sessionInfo != null) { - // 방 정보 제거 - WebSocketSessionInfo updatedSessionInfo = sessionInfo.withoutRoom(); - - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - redisTemplate.opsForValue().set(userKey, updatedSessionInfo, Duration.ofMinutes(SESSION_TTL_MINUTES)); - - // 방 참여자 목록에서 제거 - String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString()); - redisTemplate.opsForSet().remove(roomUsersKey, userId); - - log.info("WebSocket 방 퇴장 완료 - 사용자: {}, 방: {}", userId, roomId); - } - } catch (CustomException e) { - throw e; - } catch (Exception e) { - log.error("사용자 방 퇴장 실패 - 사용자: {}, 방: {}", userId, roomId, e); - throw new CustomException(ErrorCode.WS_ROOM_LEAVE_FAILED); - } + roomParticipantService.exitRoom(userId, roomId); } // 방의 온라인 사용자 수 조회 public long getRoomOnlineUserCount(Long roomId) { - try { - String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString()); - Long count = redisTemplate.opsForSet().size(roomUsersKey); - return count != null ? count : 0; - } catch (Exception e) { - log.error("방 온라인 사용자 수 조회 실패 - 방: {}", roomId, e); - throw new CustomException(ErrorCode.WS_REDIS_ERROR); - } + return roomParticipantService.getParticipantCount(roomId); } // 방의 온라인 사용자 목록 조회 public Set getOnlineUsersInRoom(Long roomId) { - try { - String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString()); - Set userIds = redisTemplate.opsForSet().members(roomUsersKey); - - if (userIds != null) { - return userIds.stream() - .map(this::convertToLong) // 안전한 변환 - .collect(Collectors.toSet()); - } - return Set.of(); - } catch (Exception e) { - log.error("방 온라인 사용자 목록 조회 실패 - 방: {}", roomId, e); - throw new CustomException(ErrorCode.WS_REDIS_ERROR); - } - } - - // 전체 온라인 사용자 수 조회 - public long getTotalOnlineUserCount() { - try { - Set userKeys = redisTemplate.keys(USER_SESSION_KEY.replace("{}", "*")); - return userKeys != null ? userKeys.size() : 0; - } catch (Exception e) { - log.error("전체 온라인 사용자 수 조회 실패", e); - return 0; - } + return roomParticipantService.getParticipants(roomId); } // 특정 사용자의 현재 방 조회 public Long getUserCurrentRoomId(Long userId) { - try { - WebSocketSessionInfo sessionInfo = getSessionInfo(userId); - return sessionInfo != null ? sessionInfo.currentRoomId() : null; - } catch (CustomException e) { - log.error("사용자 현재 방 조회 실패 - 사용자: {}", userId, e); - return null; - } - } - - // 내부적으로 세션 제거 처리 - private void removeSessionInternal(String sessionId) { - String sessionKey = SESSION_USER_KEY.replace("{}", sessionId); - Object userIdObj = redisTemplate.opsForValue().get(sessionKey); - - if (userIdObj != null) { - Long userId = convertToLong(userIdObj); // 안전한 변환 - WebSocketSessionInfo sessionInfo = getSessionInfo(userId); - - // 방에서 퇴장 처리 - if (sessionInfo != null && sessionInfo.currentRoomId() != null) { - leaveRoom(userId, sessionInfo.currentRoomId()); - } - - // 세션 데이터 삭제 - String userKey = USER_SESSION_KEY.replace("{}", userId.toString()); - redisTemplate.delete(userKey); - redisTemplate.delete(sessionKey); - } + return roomParticipantService.getCurrentRoomId(userId); } - // Object를 Long으로 안전하게 변환하는 헬퍼 메서드 - private Long convertToLong(Object obj) { - if (obj instanceof Long) { - return (Long) obj; - } else if (obj instanceof Number) { - return ((Number) obj).longValue(); - } else { - throw new IllegalArgumentException("Cannot convert " + obj.getClass() + " to Long"); - } + // 사용자가 특정 방에 참여 중인지 확인 + public boolean isUserInRoom(Long userId, Long roomId) { + return roomParticipantService.isUserInRoom(userId, roomId); } } \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/store/RedisSessionStore.java b/src/main/java/com/back/global/websocket/store/RedisSessionStore.java new file mode 100644 index 00000000..6dc7d93e --- /dev/null +++ b/src/main/java/com/back/global/websocket/store/RedisSessionStore.java @@ -0,0 +1,202 @@ +package com.back.global.websocket.store; + +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.websocket.config.WebSocketConstants; +import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Redis 저장소 계층 + * - Redis CRUD 연산 + * - Key 패턴 관리 + * - TTL 관리 + * - 타입 변환 + */ +@Slf4j +@Component +public class RedisSessionStore { + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public RedisSessionStore(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + this.objectMapper = new ObjectMapper(); + this.objectMapper.registerModule(new JavaTimeModule()); + this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + public void saveUserSession(Long userId, WebSocketSessionInfo sessionInfo) { + try { + String userKey = WebSocketConstants.buildUserSessionKey(userId); + redisTemplate.opsForValue().set(userKey, sessionInfo, WebSocketConstants.SESSION_TTL); + log.debug("사용자 세션 정보 저장 완료 - userId: {}", userId); + } catch (Exception e) { + log.error("사용자 세션 정보 저장 실패 - userId: {}", userId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public void saveSessionUserMapping(String sessionId, Long userId) { + try { + String sessionKey = WebSocketConstants.buildSessionUserKey(sessionId); + redisTemplate.opsForValue().set(sessionKey, userId, WebSocketConstants.SESSION_TTL); + log.debug("세션-사용자 매핑 저장 완료 - sessionId: {}", sessionId); + } catch (Exception e) { + log.error("세션-사용자 매핑 저장 실패 - sessionId: {}", sessionId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public WebSocketSessionInfo getUserSession(Long userId) { + try { + String userKey = WebSocketConstants.buildUserSessionKey(userId); + Object value = redisTemplate.opsForValue().get(userKey); + + if (value == null) { + return null; + } + + if (value instanceof LinkedHashMap || !(value instanceof WebSocketSessionInfo)) { + return objectMapper.convertValue(value, WebSocketSessionInfo.class); + } + + return (WebSocketSessionInfo) value; + + } catch (Exception e) { + log.error("사용자 세션 정보 조회 실패 - userId: {}", userId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public Long getUserIdBySession(String sessionId) { + try { + String sessionKey = WebSocketConstants.buildSessionUserKey(sessionId); + Object value = redisTemplate.opsForValue().get(sessionKey); + + if (value == null) { + return null; + } + + return convertToLong(value); + + } catch (Exception e) { + log.error("세션으로 사용자 조회 실패 - sessionId: {}", sessionId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public void deleteUserSession(Long userId) { + try { + String userKey = WebSocketConstants.buildUserSessionKey(userId); + redisTemplate.delete(userKey); + log.debug("사용자 세션 정보 삭제 완료 - userId: {}", userId); + } catch (Exception e) { + log.error("사용자 세션 정보 삭제 실패 - userId: {}", userId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public void deleteSessionUserMapping(String sessionId) { + try { + String sessionKey = WebSocketConstants.buildSessionUserKey(sessionId); + redisTemplate.delete(sessionKey); + log.debug("세션-사용자 매핑 삭제 완료 - sessionId: {}", sessionId); + } catch (Exception e) { + log.error("세션-사용자 매핑 삭제 실패 - sessionId: {}", sessionId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public boolean existsUserSession(Long userId) { + try { + String userKey = WebSocketConstants.buildUserSessionKey(userId); + return Boolean.TRUE.equals(redisTemplate.hasKey(userKey)); + } catch (Exception e) { + log.error("사용자 세션 존재 여부 확인 실패 - userId: {}", userId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public void addUserToRoom(Long roomId, Long userId) { + try { + String roomUsersKey = WebSocketConstants.buildRoomUsersKey(roomId); + redisTemplate.opsForSet().add(roomUsersKey, userId); + redisTemplate.expire(roomUsersKey, WebSocketConstants.SESSION_TTL); + log.debug("방에 사용자 추가 완료 - roomId: {}, userId: {}", roomId, userId); + } catch (Exception e) { + log.error("방에 사용자 추가 실패 - roomId: {}, userId: {}", roomId, userId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public void removeUserFromRoom(Long roomId, Long userId) { + try { + String roomUsersKey = WebSocketConstants.buildRoomUsersKey(roomId); + redisTemplate.opsForSet().remove(roomUsersKey, userId); + log.debug("방에서 사용자 제거 완료 - roomId: {}, userId: {}", roomId, userId); + } catch (Exception e) { + log.error("방에서 사용자 제거 실패 - roomId: {}, userId: {}", roomId, userId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public Set getRoomUsers(Long roomId) { + try { + String roomUsersKey = WebSocketConstants.buildRoomUsersKey(roomId); + Set userIds = redisTemplate.opsForSet().members(roomUsersKey); + + if (userIds != null) { + return userIds.stream() + .map(this::convertToLong) + .collect(Collectors.toSet()); + } + return Set.of(); + + } catch (Exception e) { + log.error("방 사용자 목록 조회 실패 - roomId: {}", roomId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public long getRoomUserCount(Long roomId) { + try { + String roomUsersKey = WebSocketConstants.buildRoomUsersKey(roomId); + Long count = redisTemplate.opsForSet().size(roomUsersKey); + return count != null ? count : 0; + } catch (Exception e) { + log.error("방 사용자 수 조회 실패 - roomId: {}", roomId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + public long getTotalOnlineUserCount() { + try { + Set userKeys = redisTemplate.keys(WebSocketConstants.buildUserSessionKeyPattern()); + return userKeys != null ? userKeys.size() : 0; + } catch (Exception e) { + log.error("전체 온라인 사용자 수 조회 실패", e); + return 0; + } + } + + private Long convertToLong(Object obj) { + if (obj instanceof Long) { + return (Long) obj; + } else if (obj instanceof Number) { + return ((Number) obj).longValue(); + } else { + throw new IllegalArgumentException("Cannot convert " + obj.getClass() + " to Long"); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java b/src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java index 39852a0c..8caff59c 100644 --- a/src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java +++ b/src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java @@ -3,11 +3,16 @@ import com.back.domain.studyroom.entity.Room; import com.back.domain.studyroom.entity.RoomChatMessage; import com.back.domain.user.entity.User; +import com.back.global.config.DataSourceProxyTestConfig; import com.back.global.config.QueryDslTestConfig; +import com.back.global.util.QueryCounter; +import net.ttddyy.dsproxy.QueryCountHolder; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import org.springframework.context.annotation.Import; @@ -23,7 +28,12 @@ @DataJpaTest @ActiveProfiles("test") -@Import({RoomChatMessageRepositoryImpl.class, QueryDslTestConfig.class}) +@AutoConfigureTestDatabase(replace = Replace.NONE) +@Import({ + RoomChatMessageRepositoryImpl.class, + QueryDslTestConfig.class, + DataSourceProxyTestConfig.class +}) @DisplayName("RoomChatMessageRepository 테스트") class RoomChatMessageRepositoryTest { @@ -66,6 +76,9 @@ void setUp() { createTestMessages(); testEntityManager.flush(); testEntityManager.clear(); + + // 쿼리 카운터 초기화 + QueryCountHolder.clear(); } private void createTestMessages() { @@ -151,23 +164,52 @@ void t3() { } @Test - @DisplayName("N+1 문제 해결 확인") + @DisplayName("N+1 문제 해결 확인 - fetch join 적용") void t4() { + // Given Pageable pageable = PageRequest.of(0, 3); - Page result = roomChatMessageRepository.findMessagesByRoomId(testRoom.getId(), pageable); + // 영속성 컨텍스트 초기화 (캐시 제거) + testEntityManager.flush(); + testEntityManager.clear(); - assertThat(result.getContent()).hasSize(3); + // 쿼리 카운터 초기화 + QueryCounter.clear(); + + // When - 1. 초기 조회 + Page result = roomChatMessageRepository + .findMessagesByRoomId(testRoom.getId(), pageable); + long selectCountAfterQuery = QueryCounter.getSelectCount(); + + // When - 2. 연관 엔티티 접근 for (RoomChatMessage message : result.getContent()) { - // 추가 쿼리 없이 접근 가능 - assertThat(message.getRoom().getTitle()).isNotNull(); - assertThat(message.getUser().getNickname()).isNotNull(); // username을 반환 + String roomTitle = message.getRoom().getTitle(); + String userNickname = message.getUser().getNickname(); - // 연관 엔티티가 제대로 로드되었는지 확인 - assertThat(message.getRoom().getTitle()).isEqualTo("테스트 스터디룸"); - assertThat(message.getUser().getNickname()).isIn("테스터1", "테스터2"); + assertThat(roomTitle).isNotNull(); + assertThat(userNickname).isNotNull(); } + + long selectCountAfterAccess = QueryCounter.getSelectCount(); + + // Then + assertThat(result.getContent()).hasSize(3); + + // fetch join이 제대로 동작하면 SELECT는 2번만 실행되어야 함 + // 1. RoomChatMessage + Room + User 조회 (fetch join) + // 2. 페이징을 위한 count 쿼리 + assertThat(selectCountAfterQuery) + .as("초기 조회 시 2번의 SELECT만 실행되어야 함 (fetch join 1번 + count 1번)") + .isEqualTo(2); + + // 연관 엔티티 접근 시에도 추가 쿼리가 발생하지 않아야 함 + assertThat(selectCountAfterAccess) + .as("연관 엔티티 접근 시 추가 쿼리가 발생하지 않아야 함") + .isEqualTo(selectCountAfterQuery); + + // 쿼리 카운트 출력 + QueryCounter.printQueryCount(); } @Test diff --git a/src/test/java/com/back/global/config/DataSourceProxyTestConfig.java b/src/test/java/com/back/global/config/DataSourceProxyTestConfig.java new file mode 100644 index 00000000..cf7e6856 --- /dev/null +++ b/src/test/java/com/back/global/config/DataSourceProxyTestConfig.java @@ -0,0 +1,32 @@ +package com.back.global.config; + +import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel; +import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +import javax.sql.DataSource; + +@TestConfiguration +public class DataSourceProxyTestConfig { + + @Bean + @Primary + public DataSource dataSource() { + // 실제 DataSource 생성 (테스트용 H2) + DataSource actualDataSource = new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.H2) + .build(); + + // Proxy로 감싸서 쿼리 카운팅 + return ProxyDataSourceBuilder + .create(actualDataSource) + .name("QueryCountDataSource") + .logQueryBySlf4j(SLF4JLogLevel.INFO) + .countQuery() + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/util/QueryCounter.java b/src/test/java/com/back/global/util/QueryCounter.java new file mode 100644 index 00000000..a10c25be --- /dev/null +++ b/src/test/java/com/back/global/util/QueryCounter.java @@ -0,0 +1,30 @@ +package com.back.global.util; + +import net.ttddyy.dsproxy.QueryCount; +import net.ttddyy.dsproxy.QueryCountHolder; + +public class QueryCounter { + + public static void clear() { + QueryCountHolder.clear(); + } + + public static long getSelectCount() { + return QueryCountHolder.getGrandTotal().getSelect(); + } + + public static long getTotalCount() { + return QueryCountHolder.getGrandTotal().getTotal(); + } + + public static void printQueryCount() { + QueryCount queryCount = QueryCountHolder.getGrandTotal(); + System.out.println("\n========== Query Count =========="); + System.out.println("SELECT: " + queryCount.getSelect()); + System.out.println("INSERT: " + queryCount.getInsert()); + System.out.println("UPDATE: " + queryCount.getUpdate()); + System.out.println("DELETE: " + queryCount.getDelete()); + System.out.println("TOTAL: " + queryCount.getTotal()); + System.out.println("=================================\n"); + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/controller/WebSocketApiControllerTest.java b/src/test/java/com/back/global/websocket/controller/WebSocketApiControllerTest.java index 566847c7..a75de320 100644 --- a/src/test/java/com/back/global/websocket/controller/WebSocketApiControllerTest.java +++ b/src/test/java/com/back/global/websocket/controller/WebSocketApiControllerTest.java @@ -58,13 +58,15 @@ void t1() throws Exception { .andExpect(jsonPath("$.data.service").value("WebSocket")) .andExpect(jsonPath("$.data.status").value("running")) .andExpect(jsonPath("$.data.timestamp").exists()) - .andExpect(jsonPath("$.data.sessionTTL").value("10분 (Heartbeat 방식)")) - .andExpect(jsonPath("$.data.heartbeatInterval").value("5분")) + .andExpect(jsonPath("$.data.sessionTTL").exists()) + .andExpect(jsonPath("$.data.heartbeatInterval").exists()) .andExpect(jsonPath("$.data.totalOnlineUsers").value(totalOnlineUsers)) .andExpect(jsonPath("$.data.endpoints").exists()) .andExpect(jsonPath("$.data.endpoints.websocket").value("/ws")) .andExpect(jsonPath("$.data.endpoints.heartbeat").value("/app/heartbeat")) - .andExpect(jsonPath("$.data.endpoints.activity").value("/app/activity")); + .andExpect(jsonPath("$.data.endpoints.activity").value("/app/activity")) + .andExpect(jsonPath("$.data.endpoints.joinRoom").value("/app/rooms/{roomId}/join")) + .andExpect(jsonPath("$.data.endpoints.leaveRoom").value("/app/rooms/{roomId}/leave")); verify(sessionManager).getTotalOnlineUserCount(); } @@ -156,8 +158,8 @@ void t6() throws Exception { .andExpect(jsonPath("$.data.websocketUrl").value("/ws")) .andExpect(jsonPath("$.data.sockjsSupport").value(true)) .andExpect(jsonPath("$.data.stompVersion").value("1.2")) - .andExpect(jsonPath("$.data.heartbeatInterval").value("5분")) - .andExpect(jsonPath("$.data.sessionTTL").value("10분")); + .andExpect(jsonPath("$.data.heartbeatInterval").exists()) + .andExpect(jsonPath("$.data.sessionTTL").exists()); } @Test diff --git a/src/test/java/com/back/global/websocket/service/RoomParticipantServiceTest.java b/src/test/java/com/back/global/websocket/service/RoomParticipantServiceTest.java new file mode 100644 index 00000000..50ed4caf --- /dev/null +++ b/src/test/java/com/back/global/websocket/service/RoomParticipantServiceTest.java @@ -0,0 +1,441 @@ +package com.back.global.websocket.service; + +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.back.global.websocket.store.RedisSessionStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomParticipantService 단위 테스트") +class RoomParticipantServiceTest { + + @Mock + private RedisSessionStore redisSessionStore; + + @InjectMocks + private RoomParticipantService roomParticipantService; + + private Long userId; + private String sessionId; + private Long roomId; + private WebSocketSessionInfo sessionInfo; + + @BeforeEach + void setUp() { + userId = 1L; + sessionId = "test-session-123"; + roomId = 100L; + sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + } + + @Test + @DisplayName("방 입장 - 정상 케이스 (첫 입장)") + void t1() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + roomParticipantService.enterRoom(userId, roomId); + + // then + ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore).saveUserSession(eq(userId), sessionCaptor.capture()); + verify(redisSessionStore).addUserToRoom(roomId, userId); + + WebSocketSessionInfo updatedSession = sessionCaptor.getValue(); + assertThat(updatedSession.currentRoomId()).isEqualTo(roomId); + assertThat(updatedSession.userId()).isEqualTo(userId); + } + + @Test + @DisplayName("방 입장 - 기존 방에서 자동 퇴장 후 새 방 입장") + void t2() { + // given + Long oldRoomId = 200L; + WebSocketSessionInfo sessionWithOldRoom = sessionInfo.withRoomId(oldRoomId); + given(redisSessionStore.getUserSession(userId)) + .willReturn(sessionWithOldRoom) // 첫 번째 호출 (입장 시) + .willReturn(sessionWithOldRoom); // 두 번째 호출 (퇴장 시) + + // when + roomParticipantService.enterRoom(userId, roomId); + + // then + // 기존 방 퇴장 확인 + verify(redisSessionStore).removeUserFromRoom(oldRoomId, userId); + + // 새 방 입장 확인 + verify(redisSessionStore).addUserToRoom(roomId, userId); + + // 세션 업데이트 2번 (퇴장 시 1번, 입장 시 1번) + ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore, times(2)).saveUserSession(eq(userId), sessionCaptor.capture()); + + WebSocketSessionInfo finalSession = sessionCaptor.getAllValues().get(1); + assertThat(finalSession.currentRoomId()).isEqualTo(roomId); + } + + @Test + @DisplayName("방 입장 - 세션 정보 없음 (예외 발생)") + void t3() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when & then + assertThatThrownBy(() -> roomParticipantService.enterRoom(userId, roomId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WS_SESSION_NOT_FOUND); + + verify(redisSessionStore, never()).addUserToRoom(anyLong(), anyLong()); + verify(redisSessionStore, never()).saveUserSession(anyLong(), any()); + } + + @Test + @DisplayName("방 퇴장 - 정상 케이스") + void t4() { + // given + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); + + // when + roomParticipantService.exitRoom(userId, roomId); + + // then + ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore).saveUserSession(eq(userId), sessionCaptor.capture()); + verify(redisSessionStore).removeUserFromRoom(roomId, userId); + + WebSocketSessionInfo updatedSession = sessionCaptor.getValue(); + assertThat(updatedSession.currentRoomId()).isNull(); + } + + @Test + @DisplayName("방 퇴장 - 세션 정보 없음 (퇴장 처리는 계속 진행)") + void t5() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + roomParticipantService.exitRoom(userId, roomId); + + // then + verify(redisSessionStore).removeUserFromRoom(roomId, userId); + verify(redisSessionStore, never()).saveUserSession(anyLong(), any()); + } + + @Test + @DisplayName("현재 방 ID 조회 - 방 있음") + void t6() { + // given + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); + + // when + Long result = roomParticipantService.getCurrentRoomId(userId); + + // then + assertThat(result).isEqualTo(roomId); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("현재 방 ID 조회 - 방 없음") + void t7() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + Long result = roomParticipantService.getCurrentRoomId(userId); + + // then + assertThat(result).isNull(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("현재 방 ID 조회 - 세션 없음") + void t8() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + Long result = roomParticipantService.getCurrentRoomId(userId); + + // then + assertThat(result).isNull(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("방의 참가자 목록 조회") + void t9() { + // given + Set expectedParticipants = Set.of(1L, 2L, 3L); + given(redisSessionStore.getRoomUsers(roomId)).willReturn(expectedParticipants); + + // when + Set result = roomParticipantService.getParticipants(roomId); + + // then + assertThat(result).containsExactlyInAnyOrderElementsOf(expectedParticipants); + verify(redisSessionStore).getRoomUsers(roomId); + } + + @Test + @DisplayName("방의 참가자 목록 조회 - 빈 방") + void t10() { + // given + given(redisSessionStore.getRoomUsers(roomId)).willReturn(Set.of()); + + // when + Set result = roomParticipantService.getParticipants(roomId); + + // then + assertThat(result).isEmpty(); + verify(redisSessionStore).getRoomUsers(roomId); + } + + @Test + @DisplayName("방의 참가자 수 조회") + void t11() { + // given + long expectedCount = 5L; + given(redisSessionStore.getRoomUserCount(roomId)).willReturn(expectedCount); + + // when + long result = roomParticipantService.getParticipantCount(roomId); + + // then + assertThat(result).isEqualTo(expectedCount); + verify(redisSessionStore).getRoomUserCount(roomId); + } + + @Test + @DisplayName("방의 참가자 수 조회 - 빈 방") + void t12() { + // given + given(redisSessionStore.getRoomUserCount(roomId)).willReturn(0L); + + // when + long result = roomParticipantService.getParticipantCount(roomId); + + // then + assertThat(result).isZero(); + verify(redisSessionStore).getRoomUserCount(roomId); + } + + @Test + @DisplayName("사용자가 특정 방에 참여 중인지 확인 - 참여 중") + void t13() { + // given + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); + + // when + boolean result = roomParticipantService.isUserInRoom(userId, roomId); + + // then + assertThat(result).isTrue(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("사용자가 특정 방에 참여 중인지 확인 - 다른 방에 참여 중") + void t14() { + // given + Long differentRoomId = 999L; + WebSocketSessionInfo sessionWithDifferentRoom = sessionInfo.withRoomId(differentRoomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithDifferentRoom); + + // when + boolean result = roomParticipantService.isUserInRoom(userId, roomId); + + // then + assertThat(result).isFalse(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("사용자가 특정 방에 참여 중인지 확인 - 어떤 방에도 없음") + void t15() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + boolean result = roomParticipantService.isUserInRoom(userId, roomId); + + // then + assertThat(result).isFalse(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("사용자가 특정 방에 참여 중인지 확인 - 세션 없음") + void t16() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + boolean result = roomParticipantService.isUserInRoom(userId, roomId); + + // then + assertThat(result).isFalse(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("모든 방에서 퇴장 - 현재 방 있음") + void t17() { + // given + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); + + // when + roomParticipantService.exitAllRooms(userId); + + // then + verify(redisSessionStore, times(2)).getUserSession(userId); + verify(redisSessionStore).removeUserFromRoom(roomId, userId); + verify(redisSessionStore).saveUserSession(eq(userId), any(WebSocketSessionInfo.class)); + } + + @Test + @DisplayName("모든 방에서 퇴장 - 현재 방 없음") + void t18() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + roomParticipantService.exitAllRooms(userId); + + // then + verify(redisSessionStore).getUserSession(userId); + verify(redisSessionStore, never()).removeUserFromRoom(anyLong(), anyLong()); + verify(redisSessionStore, never()).saveUserSession(anyLong(), any()); + } + + @Test + @DisplayName("모든 방에서 퇴장 - 세션 없음") + void t19() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + roomParticipantService.exitAllRooms(userId); + + // then + verify(redisSessionStore).getUserSession(userId); + verify(redisSessionStore, never()).removeUserFromRoom(anyLong(), anyLong()); + } + + @Test + @DisplayName("모든 방에서 퇴장 - 예외 발생해도 에러를 던지지 않음") + void t20() { + // given + given(redisSessionStore.getUserSession(userId)) + .willThrow(new RuntimeException("Redis connection failed")); + + // when & then - 예외가 발생해도 메서드는 정상 종료되어야 함 + assertThatCode(() -> roomParticipantService.exitAllRooms(userId)) + .doesNotThrowAnyException(); + + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("같은 방에 재입장 시도") + void t21() { + // given + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); + + // when + roomParticipantService.enterRoom(userId, roomId); + + // then + // 같은 방이므로 퇴장 처리가 발생함 + verify(redisSessionStore).removeUserFromRoom(roomId, userId); + verify(redisSessionStore).addUserToRoom(roomId, userId); + + // 세션 업데이트는 2번 (퇴장 + 입장) + verify(redisSessionStore, times(2)).saveUserSession(eq(userId), any(WebSocketSessionInfo.class)); + } + + @Test + @DisplayName("방 A → 방 B → 방 C 연속 이동") + void t22() { + // given + Long roomA = 100L; + Long roomB = 200L; + Long roomC = 300L; + + WebSocketSessionInfo session1 = sessionInfo; + WebSocketSessionInfo sessionInA = session1.withRoomId(roomA); + WebSocketSessionInfo sessionInB = sessionInA.withRoomId(roomB); + + given(redisSessionStore.getUserSession(userId)) + .willReturn(session1) // 첫 번째 입장 (방 A) + .willReturn(sessionInA) // 두 번째 입장 (방 B) - 기존 방 A에서 퇴장 + .willReturn(sessionInA) // 방 A 퇴장 처리 + .willReturn(sessionInB) // 세 번째 입장 (방 C) - 기존 방 B에서 퇴장 + .willReturn(sessionInB); // 방 B 퇴장 처리 + + // when + roomParticipantService.enterRoom(userId, roomA); + roomParticipantService.enterRoom(userId, roomB); + roomParticipantService.enterRoom(userId, roomC); + + // then + verify(redisSessionStore).addUserToRoom(roomA, userId); + verify(redisSessionStore).addUserToRoom(roomB, userId); + verify(redisSessionStore).addUserToRoom(roomC, userId); + + verify(redisSessionStore).removeUserFromRoom(roomA, userId); + verify(redisSessionStore).removeUserFromRoom(roomB, userId); + } + + @Test + @DisplayName("방 입장 후 명시적 퇴장") + void t23() { + // given + WebSocketSessionInfo sessionWithoutRoom = sessionInfo; + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + + given(redisSessionStore.getUserSession(userId)) + .willReturn(sessionWithoutRoom) // 입장 시 + .willReturn(sessionWithRoom); // 퇴장 시 + + // when + roomParticipantService.enterRoom(userId, roomId); + roomParticipantService.exitRoom(userId, roomId); + + // then + verify(redisSessionStore).addUserToRoom(roomId, userId); + verify(redisSessionStore).removeUserFromRoom(roomId, userId); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore, times(2)).saveUserSession(eq(userId), captor.capture()); + + // 입장 시 방 ID가 설정됨 + assertThat(captor.getAllValues().get(0).currentRoomId()).isEqualTo(roomId); + // 퇴장 시 방 ID가 null이 됨 + assertThat(captor.getAllValues().get(1).currentRoomId()).isNull(); + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/service/UserSessionServiceTest.java b/src/test/java/com/back/global/websocket/service/UserSessionServiceTest.java new file mode 100644 index 00000000..e853e87b --- /dev/null +++ b/src/test/java/com/back/global/websocket/service/UserSessionServiceTest.java @@ -0,0 +1,337 @@ +package com.back.global.websocket.service; + +import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.back.global.websocket.store.RedisSessionStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserSessionService 단위 테스트") +class UserSessionServiceTest { + + @Mock + private RedisSessionStore redisSessionStore; + + @InjectMocks + private UserSessionService userSessionService; + + private Long userId; + private String sessionId; + private WebSocketSessionInfo sessionInfo; + + @BeforeEach + void setUp() { + userId = 1L; + sessionId = "test-session-123"; + sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + } + + @Test + @DisplayName("새 세션 등록 - 기존 세션 없음") + void t1() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + userSessionService.registerSession(userId, sessionId); + + // then + ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore).saveUserSession(eq(userId), sessionCaptor.capture()); + verify(redisSessionStore).saveSessionUserMapping(eq(sessionId), eq(userId)); + + WebSocketSessionInfo savedSession = sessionCaptor.getValue(); + assertThat(savedSession.userId()).isEqualTo(userId); + assertThat(savedSession.sessionId()).isEqualTo(sessionId); + assertThat(savedSession.currentRoomId()).isNull(); + assertThat(savedSession.connectedAt()).isNotNull(); + assertThat(savedSession.lastActiveAt()).isNotNull(); + } + + @Test + @DisplayName("새 세션 등록 - 기존 세션 있음 (기존 세션 종료 후 등록)") + void t2() { + // given + String oldSessionId = "old-session-456"; + WebSocketSessionInfo oldSession = WebSocketSessionInfo.createNewSession(userId, oldSessionId); + given(redisSessionStore.getUserSession(userId)).willReturn(oldSession); + given(redisSessionStore.getUserIdBySession(oldSessionId)).willReturn(userId); + + // when + userSessionService.registerSession(userId, sessionId); + + // then + // 기존 세션 종료 확인 + verify(redisSessionStore).getUserIdBySession(oldSessionId); + verify(redisSessionStore).deleteUserSession(userId); + verify(redisSessionStore).deleteSessionUserMapping(oldSessionId); + + // 새 세션 등록 확인 + verify(redisSessionStore, times(1)).saveUserSession(eq(userId), any(WebSocketSessionInfo.class)); + verify(redisSessionStore).saveSessionUserMapping(eq(sessionId), eq(userId)); + } + + @Test + @DisplayName("세션 종료 - 정상 케이스") + void t3() { + // given + given(redisSessionStore.getUserIdBySession(sessionId)).willReturn(userId); + + // when + userSessionService.terminateSession(sessionId); + + // then + verify(redisSessionStore).deleteUserSession(userId); + verify(redisSessionStore).deleteSessionUserMapping(sessionId); + } + + @Test + @DisplayName("세션 종료 - 존재하지 않는 세션") + void t4() { + // given + given(redisSessionStore.getUserIdBySession(sessionId)).willReturn(null); + + // when + userSessionService.terminateSession(sessionId); + + // then + verify(redisSessionStore, never()).deleteUserSession(anyLong()); + verify(redisSessionStore, never()).deleteSessionUserMapping(anyString()); + } + + @Test + @DisplayName("Heartbeat 처리 - 정상 케이스") + void t5() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + userSessionService.processHeartbeat(userId); + + // then + ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore).saveUserSession(eq(userId), sessionCaptor.capture()); + + WebSocketSessionInfo updatedSession = sessionCaptor.getValue(); + assertThat(updatedSession.userId()).isEqualTo(userId); + assertThat(updatedSession.sessionId()).isEqualTo(sessionId); + // lastActiveAt이 업데이트되었는지 확인 + assertThat(updatedSession.lastActiveAt()).isAfterOrEqualTo(sessionInfo.lastActiveAt()); + } + + @Test + @DisplayName("Heartbeat 처리 - 세션 정보 없음") + void t6() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + userSessionService.processHeartbeat(userId); + + // then + verify(redisSessionStore, never()).saveUserSession(anyLong(), any()); + } + + @Test + @DisplayName("사용자 연결 상태 확인 - 연결됨") + void t7() { + // given + given(redisSessionStore.existsUserSession(userId)).willReturn(true); + + // when + boolean result = userSessionService.isConnected(userId); + + // then + assertThat(result).isTrue(); + verify(redisSessionStore).existsUserSession(userId); + } + + @Test + @DisplayName("사용자 연결 상태 확인 - 연결 안 됨") + void t8() { + // given + given(redisSessionStore.existsUserSession(userId)).willReturn(false); + + // when + boolean result = userSessionService.isConnected(userId); + + // then + assertThat(result).isFalse(); + verify(redisSessionStore).existsUserSession(userId); + } + + @Test + @DisplayName("세션 정보 조회 - 정상 케이스") + void t9() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + WebSocketSessionInfo result = userSessionService.getSessionInfo(userId); + + // then + assertThat(result).isNotNull(); + assertThat(result.userId()).isEqualTo(userId); + assertThat(result.sessionId()).isEqualTo(sessionId); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("세션 정보 조회 - 세션 없음") + void t10() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + WebSocketSessionInfo result = userSessionService.getSessionInfo(userId); + + // then + assertThat(result).isNull(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("세션ID로 사용자ID 조회 - 정상 케이스") + void t11() { + // given + given(redisSessionStore.getUserIdBySession(sessionId)).willReturn(userId); + + // when + Long result = userSessionService.getUserIdBySessionId(sessionId); + + // then + assertThat(result).isEqualTo(userId); + verify(redisSessionStore).getUserIdBySession(sessionId); + } + + @Test + @DisplayName("세션ID로 사용자ID 조회 - 세션 없음") + void t12() { + // given + given(redisSessionStore.getUserIdBySession(sessionId)).willReturn(null); + + // when + Long result = userSessionService.getUserIdBySessionId(sessionId); + + // then + assertThat(result).isNull(); + verify(redisSessionStore).getUserIdBySession(sessionId); + } + + @Test + @DisplayName("사용자의 현재 방 ID 조회 - 방 있음") + void t13() { + // given + Long roomId = 100L; + WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); + given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); + + // when + Long result = userSessionService.getCurrentRoomId(userId); + + // then + assertThat(result).isEqualTo(roomId); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("사용자의 현재 방 ID 조회 - 방 없음") + void t14() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + + // when + Long result = userSessionService.getCurrentRoomId(userId); + + // then + assertThat(result).isNull(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("사용자의 현재 방 ID 조회 - 세션 없음") + void t15() { + // given + given(redisSessionStore.getUserSession(userId)).willReturn(null); + + // when + Long result = userSessionService.getCurrentRoomId(userId); + + // then + assertThat(result).isNull(); + verify(redisSessionStore).getUserSession(userId); + } + + @Test + @DisplayName("전체 온라인 사용자 수 조회") + void t16() { + // given + long expectedCount = 42L; + given(redisSessionStore.getTotalOnlineUserCount()).willReturn(expectedCount); + + // when + long result = userSessionService.getTotalOnlineUserCount(); + + // then + assertThat(result).isEqualTo(expectedCount); + verify(redisSessionStore).getTotalOnlineUserCount(); + } + + @Test + @DisplayName("전체 온라인 사용자 수 조회 - 사용자 없음") + void t17() { + // given + given(redisSessionStore.getTotalOnlineUserCount()).willReturn(0L); + + // when + long result = userSessionService.getTotalOnlineUserCount(); + + // then + assertThat(result).isZero(); + verify(redisSessionStore).getTotalOnlineUserCount(); + } + + @Test + @DisplayName("중복 세션 등록 시 기존 세션이 완전히 정리됨") + void t18() { + // given + String oldSessionId = "old-session"; + Long oldRoomId = 999L; + WebSocketSessionInfo oldSession = WebSocketSessionInfo.createNewSession(userId, oldSessionId) + .withRoomId(oldRoomId); + + given(redisSessionStore.getUserSession(userId)).willReturn(oldSession); + given(redisSessionStore.getUserIdBySession(oldSessionId)).willReturn(userId); + + // when + userSessionService.registerSession(userId, sessionId); + + // then + // 기존 세션 정리 검증 + verify(redisSessionStore).getUserIdBySession(oldSessionId); + verify(redisSessionStore).deleteUserSession(userId); + verify(redisSessionStore).deleteSessionUserMapping(oldSessionId); + + // 새 세션 생성 검증 + ArgumentCaptor captor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); + verify(redisSessionStore).saveUserSession(eq(userId), captor.capture()); + verify(redisSessionStore).saveSessionUserMapping(eq(sessionId), eq(userId)); + + WebSocketSessionInfo newSession = captor.getValue(); + assertThat(newSession.sessionId()).isEqualTo(sessionId); + assertThat(newSession.currentRoomId()).isNull(); // 새 세션은 방 정보 없음 + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java b/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java index 297a2e2e..380cec49 100644 --- a/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java +++ b/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java @@ -10,453 +10,512 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.SetOperations; -import org.springframework.data.redis.core.ValueOperations; -import java.time.Duration; import java.util.Set; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) -@DisplayName("WebSocketSessionManager 테스트") +@DisplayName("WebSocketSessionManager 단위 테스트") class WebSocketSessionManagerTest { @Mock - private RedisTemplate redisTemplate; + private UserSessionService userSessionService; @Mock - private ValueOperations valueOperations; - - @Mock - private SetOperations setOperations; + private RoomParticipantService roomParticipantService; @InjectMocks private WebSocketSessionManager sessionManager; - private final Long TEST_USER_ID = 123L; - private final String TEST_SESSION_ID = "session-123"; - private final Long TEST_ROOM_ID = 456L; + private Long userId; + private String sessionId; + private Long roomId; @BeforeEach void setUp() { - // Mock 설정은 각 테스트에서 필요할 때만 수행 + userId = 1L; + sessionId = "test-session-123"; + roomId = 100L; } @Test - @DisplayName("사용자 세션 등록 - 성공") - void t1() { - // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get(anyString())).thenReturn(null); // 기존 세션 없음 - + @DisplayName("세션 추가") + void addSession() { // when - assertThatNoException().isThrownBy(() -> - sessionManager.addSession(TEST_USER_ID, TEST_SESSION_ID) - ); + sessionManager.addSession(userId, sessionId); // then - verify(valueOperations, times(2)).set(anyString(), any(), eq(Duration.ofMinutes(6))); + verify(userSessionService).registerSession(userId, sessionId); } @Test - @DisplayName("사용자 세션 등록 - 기존 세션 있을 때 제거 후 등록") - void t2() { + @DisplayName("세션 제거 - 정상 케이스") + void removeSession_Success() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - WebSocketSessionInfo existingSession = WebSocketSessionInfo.createNewSession(TEST_USER_ID, "old-session-123") - .withUpdatedActivity(); // 활동 시간 업데이트 + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); // when - when(valueOperations.get("ws:user:123")).thenReturn(existingSession); - when(valueOperations.get("ws:session:old-session-123")).thenReturn(TEST_USER_ID); - - assertThatNoException().isThrownBy(() -> - sessionManager.addSession(TEST_USER_ID, TEST_SESSION_ID) - ); + sessionManager.removeSession(sessionId); // then - verify(redisTemplate, atLeastOnce()).delete(anyString()); // 기존 세션 삭제 - verify(valueOperations, times(2)).set(anyString(), any(), eq(Duration.ofMinutes(6))); // 새 세션 등록 + verify(userSessionService).getUserIdBySessionId(sessionId); + verify(roomParticipantService).exitAllRooms(userId); + verify(userSessionService).terminateSession(sessionId); } @Test - @DisplayName("사용자 세션 등록 - Redis 오류 시 예외 발생") - void t3() { + @DisplayName("세션 제거 - 존재하지 않는 세션") + void removeSession_NotFound() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get(anyString())).thenThrow(new RuntimeException("Redis connection failed")); + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(null); - // when & then - assertThatThrownBy(() -> sessionManager.addSession(TEST_USER_ID, TEST_SESSION_ID)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WS_CONNECTION_FAILED); + // when + sessionManager.removeSession(sessionId); + + // then + verify(userSessionService).getUserIdBySessionId(sessionId); + verify(roomParticipantService, never()).exitAllRooms(anyLong()); + verify(userSessionService, never()).terminateSession(anyString()); } @Test - @DisplayName("사용자 연결 상태 확인 - 연결됨") - void t4() { + @DisplayName("사용자 연결 상태 확인") + void isUserConnected() { // given - when(redisTemplate.hasKey("ws:user:123")).thenReturn(true); + given(userSessionService.isConnected(userId)).willReturn(true); // when - boolean result = sessionManager.isUserConnected(TEST_USER_ID); + boolean result = sessionManager.isUserConnected(userId); // then assertThat(result).isTrue(); - verify(redisTemplate).hasKey("ws:user:123"); + verify(userSessionService).isConnected(userId); } @Test - @DisplayName("사용자 연결 상태 확인 - 연결되지 않음") - void t5() { + @DisplayName("사용자 세션 정보 조회") + void getSessionInfo() { // given - when(redisTemplate.hasKey("ws:user:123")).thenReturn(false); + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + given(userSessionService.getSessionInfo(userId)).willReturn(sessionInfo); // when - boolean result = sessionManager.isUserConnected(TEST_USER_ID); + WebSocketSessionInfo result = sessionManager.getSessionInfo(userId); // then - assertThat(result).isFalse(); - verify(redisTemplate).hasKey("ws:user:123"); + assertThat(result).isNotNull(); + assertThat(result.userId()).isEqualTo(userId); + verify(userSessionService).getSessionInfo(userId); } @Test - @DisplayName("사용자 연결 상태 확인 - Redis 오류 시 예외 발생") - void t6() { - // given - when(redisTemplate.hasKey(anyString())).thenThrow(new RuntimeException("Redis error")); + @DisplayName("Heartbeat 처리") + void updateLastActivity() { + // when + sessionManager.updateLastActivity(userId); - // when & then - assertThatThrownBy(() -> sessionManager.isUserConnected(TEST_USER_ID)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WS_REDIS_ERROR); + // then + verify(userSessionService).processHeartbeat(userId); } @Test - @DisplayName("세션 정보 조회 - 성공") - void t7() { + @DisplayName("전체 온라인 사용자 수 조회") + void getTotalOnlineUserCount() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); + long expectedCount = 42L; + given(userSessionService.getTotalOnlineUserCount()).willReturn(expectedCount); - // 체이닝으로 세션 정보 생성 - WebSocketSessionInfo expectedSessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID) - .withRoomId(TEST_ROOM_ID); + // when + long result = sessionManager.getTotalOnlineUserCount(); - when(valueOperations.get("ws:user:123")).thenReturn(expectedSessionInfo); + // then + assertThat(result).isEqualTo(expectedCount); + verify(userSessionService).getTotalOnlineUserCount(); + } + @Test + @DisplayName("방 입장") + void joinRoom() { // when - WebSocketSessionInfo result = sessionManager.getSessionInfo(TEST_USER_ID); + sessionManager.joinRoom(userId, roomId); // then - assertThat(result).isNotNull(); - assertThat(result.userId()).isEqualTo(TEST_USER_ID); - assertThat(result.sessionId()).isEqualTo(TEST_SESSION_ID); - assertThat(result.currentRoomId()).isEqualTo(TEST_ROOM_ID); + verify(roomParticipantService).enterRoom(userId, roomId); } @Test - @DisplayName("세션 정보 조회 - 세션이 없음") - void t8() { - // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("ws:user:123")).thenReturn(null); - + @DisplayName("방 퇴장") + void leaveRoom() { // when - WebSocketSessionInfo result = sessionManager.getSessionInfo(TEST_USER_ID); + sessionManager.leaveRoom(userId, roomId); // then - assertThat(result).isNull(); + verify(roomParticipantService).exitRoom(userId, roomId); } @Test - @DisplayName("활동 시간 업데이트 - 성공") - void t9() { + @DisplayName("방의 온라인 사용자 수 조회") + void getRoomOnlineUserCount() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID); - - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + long expectedCount = 10L; + given(roomParticipantService.getParticipantCount(roomId)).willReturn(expectedCount); // when - assertThatNoException().isThrownBy(() -> - sessionManager.updateLastActivity(TEST_USER_ID) - ); + long result = sessionManager.getRoomOnlineUserCount(roomId); // then - verify(valueOperations).set(eq("ws:user:123"), any(WebSocketSessionInfo.class), eq(Duration.ofMinutes(6))); + assertThat(result).isEqualTo(expectedCount); + verify(roomParticipantService).getParticipantCount(roomId); } @Test - @DisplayName("활동 시간 업데이트 - 세션이 없을 때") - void t10() { + @DisplayName("방의 온라인 사용자 목록 조회") + void getOnlineUsersInRoom() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("ws:user:123")).thenReturn(null); + Set expectedUsers = Set.of(1L, 2L, 3L); + given(roomParticipantService.getParticipants(roomId)).willReturn(expectedUsers); - // when & then - assertThatNoException().isThrownBy(() -> - sessionManager.updateLastActivity(TEST_USER_ID) - ); + // when + Set result = sessionManager.getOnlineUsersInRoom(roomId); - // 세션이 없으면 업데이트하지 않음 - verify(valueOperations, never()).set(anyString(), any(), any(Duration.class)); + // then + assertThat(result).containsExactlyInAnyOrderElementsOf(expectedUsers); + verify(roomParticipantService).getParticipants(roomId); } @Test - @DisplayName("방 입장 - 성공") - void t11() { + @DisplayName("사용자의 현재 방 조회") + void getUserCurrentRoomId() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(redisTemplate.opsForSet()).thenReturn(setOperations); - - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID); - - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + given(roomParticipantService.getCurrentRoomId(userId)).willReturn(roomId); // when - assertThatNoException().isThrownBy(() -> - sessionManager.joinRoom(TEST_USER_ID, TEST_ROOM_ID) - ); + Long result = sessionManager.getUserCurrentRoomId(userId); // then - verify(setOperations).add("ws:room:456:users", TEST_USER_ID); - verify(redisTemplate).expire("ws:room:456:users", Duration.ofMinutes(6)); - verify(valueOperations).set(eq("ws:user:123"), any(WebSocketSessionInfo.class), eq(Duration.ofMinutes(6))); + assertThat(result).isEqualTo(roomId); + verify(roomParticipantService).getCurrentRoomId(userId); } @Test - @DisplayName("방 입장 - 기존 방에서 자동 퇴장 후 새 방 입장") - void t12() { + @DisplayName("사용자가 특정 방에 참여 중인지 확인") + void isUserInRoom() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(redisTemplate.opsForSet()).thenReturn(setOperations); - Long previousRoomId = 999L; - - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID) - .withRoomId(previousRoomId); - - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + given(roomParticipantService.isUserInRoom(userId, roomId)).willReturn(true); // when - assertThatNoException().isThrownBy(() -> - sessionManager.joinRoom(TEST_USER_ID, TEST_ROOM_ID) - ); + boolean result = sessionManager.isUserInRoom(userId, roomId); // then - // 이전 방에서 퇴장 - verify(setOperations).remove("ws:room:999:users", TEST_USER_ID); + assertThat(result).isTrue(); + verify(roomParticipantService).isUserInRoom(userId, roomId); + } + + @Test + @DisplayName("전체 플로우: 연결 → 방 입장 → Heartbeat → 방 퇴장 → 연결 종료") + void fullLifecycleFlow() { + // given + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); - // 새 방에 입장 - verify(setOperations).add("ws:room:456:users", TEST_USER_ID); + // when & then + // 1. 연결 + sessionManager.addSession(userId, sessionId); + verify(userSessionService).registerSession(userId, sessionId); + + // 2. 방 입장 + sessionManager.joinRoom(userId, roomId); + verify(roomParticipantService).enterRoom(userId, roomId); + + // 3. Heartbeat + sessionManager.updateLastActivity(userId); + verify(userSessionService).processHeartbeat(userId); + + // 4. 방 퇴장 + sessionManager.leaveRoom(userId, roomId); + verify(roomParticipantService).exitRoom(userId, roomId); + + // 5. 연결 종료 + sessionManager.removeSession(sessionId); + verify(roomParticipantService).exitAllRooms(userId); + verify(userSessionService).terminateSession(sessionId); } @Test - @DisplayName("방 퇴장 - 성공") - void t13() { + @DisplayName("전체 플로우: 연결 → 방 A 입장 → 방 B 이동 → 연결 종료") + void fullLifecycleFlow_RoomTransition() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(redisTemplate.opsForSet()).thenReturn(setOperations); + Long roomA = 100L; + Long roomB = 200L; + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID) - .withRoomId(TEST_ROOM_ID); + // when & then + // 1. 연결 + sessionManager.addSession(userId, sessionId); + verify(userSessionService).registerSession(userId, sessionId); + + // 2. 방 A 입장 + sessionManager.joinRoom(userId, roomA); + verify(roomParticipantService).enterRoom(userId, roomA); + + // 3. 방 B로 이동 (자동으로 방 A 퇴장) + sessionManager.joinRoom(userId, roomB); + verify(roomParticipantService).enterRoom(userId, roomB); + + // 4. 연결 종료 (모든 방에서 퇴장) + sessionManager.removeSession(sessionId); + verify(roomParticipantService).exitAllRooms(userId); + verify(userSessionService).terminateSession(sessionId); + } - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + @Test + @DisplayName("여러 사용자의 동시 세션 관리") + void multipleUsersSessions() { + // given + Long userId1 = 1L; + Long userId2 = 2L; + Long userId3 = 3L; + String sessionId1 = "session-1"; + String sessionId2 = "session-2"; + String sessionId3 = "session-3"; // when - assertThatNoException().isThrownBy(() -> - sessionManager.leaveRoom(TEST_USER_ID, TEST_ROOM_ID) - ); + sessionManager.addSession(userId1, sessionId1); + sessionManager.addSession(userId2, sessionId2); + sessionManager.addSession(userId3, sessionId3); // then - verify(setOperations).remove("ws:room:456:users", TEST_USER_ID); - verify(valueOperations).set(eq("ws:user:123"), any(WebSocketSessionInfo.class), eq(Duration.ofMinutes(6))); + verify(userSessionService).registerSession(userId1, sessionId1); + verify(userSessionService).registerSession(userId2, sessionId2); + verify(userSessionService).registerSession(userId3, sessionId3); } @Test - @DisplayName("방 온라인 사용자 수 조회 - 성공") - void t14() { + @DisplayName("여러 사용자가 같은 방에 입장") + void multipleUsersInSameRoom() { // given - when(redisTemplate.opsForSet()).thenReturn(setOperations); - when(setOperations.size("ws:room:456:users")).thenReturn(5L); + Long userId1 = 1L; + Long userId2 = 2L; + Long userId3 = 3L; // when - long result = sessionManager.getRoomOnlineUserCount(TEST_ROOM_ID); + sessionManager.joinRoom(userId1, roomId); + sessionManager.joinRoom(userId2, roomId); + sessionManager.joinRoom(userId3, roomId); // then - assertThat(result).isEqualTo(5L); - verify(setOperations).size("ws:room:456:users"); + verify(roomParticipantService).enterRoom(userId1, roomId); + verify(roomParticipantService).enterRoom(userId2, roomId); + verify(roomParticipantService).enterRoom(userId3, roomId); } @Test - @DisplayName("방 온라인 사용자 수 조회 - 사용자가 없을 때") - void t15() { + @DisplayName("중복 연결 시도 (기존 세션 종료 후 새 세션 등록)") + void duplicateConnection() { // given - when(redisTemplate.opsForSet()).thenReturn(setOperations); - when(setOperations.size("ws:room:456:users")).thenReturn(null); + String newSessionId = "new-session-456"; // when - long result = sessionManager.getRoomOnlineUserCount(TEST_ROOM_ID); + sessionManager.addSession(userId, sessionId); + sessionManager.addSession(userId, newSessionId); // then - assertThat(result).isEqualTo(0L); + verify(userSessionService).registerSession(userId, sessionId); + verify(userSessionService).registerSession(userId, newSessionId); + // UserSessionService 내부에서 기존 세션 종료 처리 } @Test - @DisplayName("방 온라인 사용자 목록 조회 - 성공") - void t16() { + @DisplayName("비정상 종료 시나리오: 명시적 퇴장 없이 연결 종료") + void abnormalDisconnection() { // given - when(redisTemplate.opsForSet()).thenReturn(setOperations); - Set expectedUserIds = Set.of(123L, 456L, 789L); - when(setOperations.members("ws:room:456:users")).thenReturn(expectedUserIds); + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); // when - Set result = sessionManager.getOnlineUsersInRoom(TEST_ROOM_ID); + sessionManager.addSession(userId, sessionId); + sessionManager.joinRoom(userId, roomId); + // 명시적 leaveRoom 없이 바로 연결 종료 + sessionManager.removeSession(sessionId); // then - assertThat(result).containsExactlyInAnyOrder(123L, 456L, 789L); - verify(setOperations).members("ws:room:456:users"); + verify(roomParticipantService).enterRoom(userId, roomId); + verify(roomParticipantService).exitAllRooms(userId); // 모든 방에서 자동 퇴장 + verify(userSessionService).terminateSession(sessionId); } @Test - @DisplayName("방 온라인 사용자 목록 조회 - 빈 방") - void t17() { + @DisplayName("방 입장 전 상태 조회") + void queryBeforeJoinRoom() { // given - when(redisTemplate.opsForSet()).thenReturn(setOperations); - when(setOperations.members("ws:room:456:users")).thenReturn(null); + given(roomParticipantService.getCurrentRoomId(userId)).willReturn(null); + given(roomParticipantService.isUserInRoom(userId, roomId)).willReturn(false); // when - Set result = sessionManager.getOnlineUsersInRoom(TEST_ROOM_ID); + Long currentRoomId = sessionManager.getUserCurrentRoomId(userId); + boolean isInRoom = sessionManager.isUserInRoom(userId, roomId); // then - assertThat(result).isEmpty(); + assertThat(currentRoomId).isNull(); + assertThat(isInRoom).isFalse(); + verify(roomParticipantService).getCurrentRoomId(userId); + verify(roomParticipantService).isUserInRoom(userId, roomId); } @Test - @DisplayName("전체 온라인 사용자 수 조회 - 성공") - void t18() { + @DisplayName("방 입장 후 상태 조회") + void queryAfterJoinRoom() { // given - Set userKeys = Set.of("ws:user:123", "ws:user:456", "ws:user:789"); - when(redisTemplate.keys("ws:user:*")).thenReturn(userKeys); + given(roomParticipantService.getCurrentRoomId(userId)).willReturn(roomId); + given(roomParticipantService.isUserInRoom(userId, roomId)).willReturn(true); // when - long result = sessionManager.getTotalOnlineUserCount(); + sessionManager.joinRoom(userId, roomId); + Long currentRoomId = sessionManager.getUserCurrentRoomId(userId); + boolean isInRoom = sessionManager.isUserInRoom(userId, roomId); // then - assertThat(result).isEqualTo(3L); + assertThat(currentRoomId).isEqualTo(roomId); + assertThat(isInRoom).isTrue(); + verify(roomParticipantService).enterRoom(userId, roomId); + verify(roomParticipantService).getCurrentRoomId(userId); + verify(roomParticipantService).isUserInRoom(userId, roomId); } @Test - @DisplayName("전체 온라인 사용자 수 조회 - Redis 오류 시 0 반환") - void t19() { - // given - when(redisTemplate.keys("ws:user:*")).thenThrow(new RuntimeException("Redis error")); - + @DisplayName("Heartbeat 여러 번 호출") + void multipleHeartbeats() { // when - long result = sessionManager.getTotalOnlineUserCount(); + sessionManager.updateLastActivity(userId); + sessionManager.updateLastActivity(userId); + sessionManager.updateLastActivity(userId); // then - assertThat(result).isEqualTo(0L); // 예외 대신 0 반환 + verify(userSessionService, times(3)).processHeartbeat(userId); } @Test - @DisplayName("사용자 현재 방 ID 조회 - 성공") - void t20() { + @DisplayName("빈 방의 사용자 목록 조회") + void getOnlineUsersInRoom_EmptyRoom() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID) - .withRoomId(TEST_ROOM_ID); - - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + given(roomParticipantService.getParticipants(roomId)).willReturn(Set.of()); // when - Long result = sessionManager.getUserCurrentRoomId(TEST_USER_ID); + Set result = sessionManager.getOnlineUsersInRoom(roomId); // then - assertThat(result).isEqualTo(TEST_ROOM_ID); + assertThat(result).isEmpty(); + verify(roomParticipantService).getParticipants(roomId); } @Test - @DisplayName("사용자 현재 방 ID 조회 - 방에 입장하지 않음") - void t21() { + @DisplayName("온라인 사용자가 없을 때 전체 수 조회") + void getTotalOnlineUserCount_NoUsers() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - // 방 정보 없는 세션 - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID); // currentRoomId는 null - - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + given(userSessionService.getTotalOnlineUserCount()).willReturn(0L); // when - Long result = sessionManager.getUserCurrentRoomId(TEST_USER_ID); + long result = sessionManager.getTotalOnlineUserCount(); // then - assertThat(result).isNull(); + assertThat(result).isZero(); + verify(userSessionService).getTotalOnlineUserCount(); } @Test - @DisplayName("사용자 현재 방 ID 조회 - 세션이 없음") - void t22() { + @DisplayName("세션 제거 시 모든 정리 작업이 순서대로 실행됨") + void removeSession_VerifyExecutionOrder() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("ws:user:123")).thenReturn(null); + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); // when - Long result = sessionManager.getUserCurrentRoomId(TEST_USER_ID); + sessionManager.removeSession(sessionId); // then - assertThat(result).isNull(); + // InOrder를 사용하여 실행 순서 검증 + var inOrder = inOrder(userSessionService, roomParticipantService); + inOrder.verify(userSessionService).getUserIdBySessionId(sessionId); + inOrder.verify(roomParticipantService).exitAllRooms(userId); + inOrder.verify(userSessionService).terminateSession(sessionId); } @Test - @DisplayName("세션 제거 - 성공") - void t23() { + @DisplayName("방 입장 실패 시 예외 전파") + void joinRoom_ExceptionPropagation() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(redisTemplate.opsForSet()).thenReturn(setOperations); - when(valueOperations.get("ws:session:session-123")).thenReturn(TEST_USER_ID); + willThrow(new CustomException(ErrorCode.WS_SESSION_NOT_FOUND)) + .given(roomParticipantService).enterRoom(userId, roomId); - WebSocketSessionInfo sessionInfo = WebSocketSessionInfo - .createNewSession(TEST_USER_ID, TEST_SESSION_ID) - .withRoomId(TEST_ROOM_ID); - when(valueOperations.get("ws:user:123")).thenReturn(sessionInfo); + // when & then + assertThatThrownBy(() -> sessionManager.joinRoom(userId, roomId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WS_SESSION_NOT_FOUND); - // when - assertThatNoException().isThrownBy(() -> - sessionManager.removeSession(TEST_SESSION_ID) - ); + verify(roomParticipantService).enterRoom(userId, roomId); + } - // then - verify(setOperations).remove("ws:room:456:users", TEST_USER_ID); // 방에서 퇴장 - verify(redisTemplate, times(2)).delete(anyString()); // 세션 데이터 삭제 + @Test + @DisplayName("통합 시나리오: 사용자 A와 B가 같은 방에서 만남") + void integrationScenario_TwoUsersInSameRoom() { + // given + Long userA = 1L; + Long userB = 2L; + String sessionA = "session-A"; + String sessionB = "session-B"; + + given(roomParticipantService.getParticipants(roomId)) + .willReturn(Set.of(userA)) + .willReturn(Set.of(userA, userB)); + + // when & then + // 1. 사용자 A 연결 및 방 입장 + sessionManager.addSession(userA, sessionA); + sessionManager.joinRoom(userA, roomId); + + Set usersAfterA = sessionManager.getOnlineUsersInRoom(roomId); + assertThat(usersAfterA).containsExactly(userA); + + // 2. 사용자 B 연결 및 같은 방 입장 + sessionManager.addSession(userB, sessionB); + sessionManager.joinRoom(userB, roomId); + + Set usersAfterB = sessionManager.getOnlineUsersInRoom(roomId); + assertThat(usersAfterB).containsExactlyInAnyOrder(userA, userB); + + // 3. 검증 + verify(userSessionService).registerSession(userA, sessionA); + verify(userSessionService).registerSession(userB, sessionB); + verify(roomParticipantService).enterRoom(userA, roomId); + verify(roomParticipantService).enterRoom(userB, roomId); + verify(roomParticipantService, times(2)).getParticipants(roomId); } @Test - @DisplayName("세션 제거 - 존재하지 않는 세션") - void t24() { + @DisplayName("통합 시나리오: 네트워크 불안정으로 재연결") + void integrationScenario_Reconnection() { // given - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("ws:session:session-123")).thenReturn(null); + String newSessionId = "new-session-789"; + given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); // when & then - assertThatNoException().isThrownBy(() -> - sessionManager.removeSession(TEST_SESSION_ID) - ); + // 1. 초기 연결 및 방 입장 + sessionManager.addSession(userId, sessionId); + sessionManager.joinRoom(userId, roomId); + + // 2. 갑작스런 연결 끊김 + sessionManager.removeSession(sessionId); + verify(roomParticipantService).exitAllRooms(userId); + + // 3. 재연결 (새 세션 ID로) + sessionManager.addSession(userId, newSessionId); + verify(userSessionService).registerSession(userId, newSessionId); - // 아무것도 삭제하지 않음 - verify(redisTemplate, never()).delete(anyString()); + // 4. 다시 방 입장 + sessionManager.joinRoom(userId, roomId); + verify(roomParticipantService, times(2)).enterRoom(userId, roomId); } } \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/store/RedisSessionStoreTest.java b/src/test/java/com/back/global/websocket/store/RedisSessionStoreTest.java new file mode 100644 index 00000000..1d3406fc --- /dev/null +++ b/src/test/java/com/back/global/websocket/store/RedisSessionStoreTest.java @@ -0,0 +1,424 @@ +package com.back.global.websocket.store; + +import com.back.global.websocket.config.WebSocketConstants; +import com.back.global.websocket.dto.WebSocketSessionInfo; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.Set; +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@Testcontainers +@DisplayName("RedisSessionStore 통합 테스트") +class RedisSessionStoreTest { + + @Container + static GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", redis::getHost); + registry.add("spring.data.redis.port", redis::getFirstMappedPort); + } + + @Autowired + private RedisSessionStore redisSessionStore; + + @Autowired + private RedisTemplate redisTemplate; + + @BeforeEach + void setUp() { + // Redis 초기화 + redisTemplate.getConnectionFactory().getConnection().flushAll(); + } + + @AfterEach + void tearDown() { + // 테스트 후 정리 + redisTemplate.getConnectionFactory().getConnection().flushAll(); + } + + @Test + @DisplayName("사용자 세션 저장 및 조회") + void t1() { + // given + Long userId = 1L; + String sessionId = "test-session-123"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + + // when + redisSessionStore.saveUserSession(userId, sessionInfo); + WebSocketSessionInfo retrieved = redisSessionStore.getUserSession(userId); + + // then + assertThat(retrieved).isNotNull(); + assertThat(retrieved.userId()).isEqualTo(userId); + assertThat(retrieved.sessionId()).isEqualTo(sessionId); + assertThat(retrieved.currentRoomId()).isNull(); + assertThat(retrieved.connectedAt()).isNotNull(); + assertThat(retrieved.lastActiveAt()).isNotNull(); + } + + @Test + @DisplayName("존재하지 않는 사용자 세션 조회 시 null 반환") + void t2() { + // given + Long userId = 999L; + + // when + WebSocketSessionInfo result = redisSessionStore.getUserSession(userId); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("세션-사용자 매핑 저장 및 조회") + void t3() { + // given + String sessionId = "test-session-456"; + Long userId = 2L; + + // when + redisSessionStore.saveSessionUserMapping(sessionId, userId); + Long retrievedUserId = redisSessionStore.getUserIdBySession(sessionId); + + // then + assertThat(retrievedUserId).isEqualTo(userId); + } + + @Test + @DisplayName("존재하지 않는 세션으로 사용자 조회 시 null 반환") + void t4() { + // given + String sessionId = "non-existent-session"; + + // when + Long result = redisSessionStore.getUserIdBySession(sessionId); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("사용자 세션 삭제") + void t5() { + // given + Long userId = 3L; + String sessionId = "test-session-789"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + redisSessionStore.saveUserSession(userId, sessionInfo); + + // when + redisSessionStore.deleteUserSession(userId); + WebSocketSessionInfo result = redisSessionStore.getUserSession(userId); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("세션-사용자 매핑 삭제") + void t6() { + // given + String sessionId = "test-session-delete"; + Long userId = 4L; + redisSessionStore.saveSessionUserMapping(sessionId, userId); + + // when + redisSessionStore.deleteSessionUserMapping(sessionId); + Long result = redisSessionStore.getUserIdBySession(sessionId); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("사용자 세션 존재 여부 확인") + void t7() { + // given + Long userId = 5L; + String sessionId = "test-session-exists"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + + // when & then + assertThat(redisSessionStore.existsUserSession(userId)).isFalse(); + + redisSessionStore.saveUserSession(userId, sessionInfo); + assertThat(redisSessionStore.existsUserSession(userId)).isTrue(); + + redisSessionStore.deleteUserSession(userId); + assertThat(redisSessionStore.existsUserSession(userId)).isFalse(); + } + + @Test + @DisplayName("방에 사용자 추가") + void t8() { + // given + Long roomId = 100L; + Long userId = 6L; + + // when + redisSessionStore.addUserToRoom(roomId, userId); + Set roomUsers = redisSessionStore.getRoomUsers(roomId); + + // then + assertThat(roomUsers).contains(userId); + assertThat(roomUsers).hasSize(1); + } + + @Test + @DisplayName("방에 여러 사용자 추가") + void t9() { + // given + Long roomId = 101L; + Long userId1 = 7L; + Long userId2 = 8L; + Long userId3 = 9L; + + // when + redisSessionStore.addUserToRoom(roomId, userId1); + redisSessionStore.addUserToRoom(roomId, userId2); + redisSessionStore.addUserToRoom(roomId, userId3); + + Set roomUsers = redisSessionStore.getRoomUsers(roomId); + long userCount = redisSessionStore.getRoomUserCount(roomId); + + // then + assertThat(roomUsers).containsExactlyInAnyOrder(userId1, userId2, userId3); + assertThat(userCount).isEqualTo(3); + } + + @Test + @DisplayName("방에서 사용자 제거") + void t10() { + // given + Long roomId = 102L; + Long userId1 = 10L; + Long userId2 = 11L; + redisSessionStore.addUserToRoom(roomId, userId1); + redisSessionStore.addUserToRoom(roomId, userId2); + + // when + redisSessionStore.removeUserFromRoom(roomId, userId1); + Set roomUsers = redisSessionStore.getRoomUsers(roomId); + + // then + assertThat(roomUsers).containsExactly(userId2); + assertThat(roomUsers).doesNotContain(userId1); + } + + @Test + @DisplayName("존재하지 않는 방의 사용자 목록 조회 시 빈 Set 반환") + void t11() { + // given + Long roomId = 999L; + + // when + Set roomUsers = redisSessionStore.getRoomUsers(roomId); + + // then + assertThat(roomUsers).isEmpty(); + } + + @Test + @DisplayName("방의 사용자 수 조회") + void t12() { + // given + Long roomId = 103L; + redisSessionStore.addUserToRoom(roomId, 12L); + redisSessionStore.addUserToRoom(roomId, 13L); + redisSessionStore.addUserToRoom(roomId, 14L); + + // when + long count = redisSessionStore.getRoomUserCount(roomId); + + // then + assertThat(count).isEqualTo(3); + } + + @Test + @DisplayName("존재하지 않는 방의 사용자 수는 0") + void t13() { + // given + Long roomId = 999L; + + // when + long count = redisSessionStore.getRoomUserCount(roomId); + + // then + assertThat(count).isZero(); + } + + @Test + @DisplayName("전체 온라인 사용자 수 조회") + void t14() { + // given + Long userId1 = 15L; + Long userId2 = 16L; + Long userId3 = 17L; + + WebSocketSessionInfo session1 = WebSocketSessionInfo.createNewSession(userId1, "session-1"); + WebSocketSessionInfo session2 = WebSocketSessionInfo.createNewSession(userId2, "session-2"); + WebSocketSessionInfo session3 = WebSocketSessionInfo.createNewSession(userId3, "session-3"); + + // when + redisSessionStore.saveUserSession(userId1, session1); + redisSessionStore.saveUserSession(userId2, session2); + redisSessionStore.saveUserSession(userId3, session3); + + long totalCount = redisSessionStore.getTotalOnlineUserCount(); + + // then + assertThat(totalCount).isEqualTo(3); + } + + @Test + @DisplayName("세션 정보에 방 ID 추가 후 저장 및 조회") + void t15() { + // given + Long userId = 18L; + Long roomId = 200L; + String sessionId = "session-with-room"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + WebSocketSessionInfo withRoom = sessionInfo.withRoomId(roomId); + + // when + redisSessionStore.saveUserSession(userId, withRoom); + WebSocketSessionInfo retrieved = redisSessionStore.getUserSession(userId); + + // then + assertThat(retrieved).isNotNull(); + assertThat(retrieved.currentRoomId()).isEqualTo(roomId); + assertThat(retrieved.userId()).isEqualTo(userId); + assertThat(retrieved.sessionId()).isEqualTo(sessionId); + } + + @Test + @DisplayName("세션 정보에서 방 ID 제거 후 저장 및 조회") + void t16() { + // given + Long userId = 19L; + Long roomId = 201L; + String sessionId = "session-remove-room"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + WebSocketSessionInfo withRoom = sessionInfo.withRoomId(roomId); + redisSessionStore.saveUserSession(userId, withRoom); + + // when + WebSocketSessionInfo withoutRoom = withRoom.withoutRoom(); + redisSessionStore.saveUserSession(userId, withoutRoom); + WebSocketSessionInfo retrieved = redisSessionStore.getUserSession(userId); + + // then + assertThat(retrieved).isNotNull(); + assertThat(retrieved.currentRoomId()).isNull(); + } + + @Test + @DisplayName("활동 시간 업데이트 후 저장 및 조회") + void t17() throws InterruptedException { + // given + Long userId = 20L; + String sessionId = "session-activity-update"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + redisSessionStore.saveUserSession(userId, sessionInfo); + + // when + Thread.sleep(100); // 시간 차이를 만들기 위해 대기 + WebSocketSessionInfo updatedSession = sessionInfo.withUpdatedActivity(); + redisSessionStore.saveUserSession(userId, updatedSession); + WebSocketSessionInfo retrieved = redisSessionStore.getUserSession(userId); + + // then + assertThat(retrieved).isNotNull(); + assertThat(retrieved.lastActiveAt()).isAfter(sessionInfo.lastActiveAt()); + } + + @Test + @DisplayName("중복 사용자를 같은 방에 추가해도 한 번만 저장됨") + void t18() { + // given + Long roomId = 104L; + Long userId = 21L; + + // when + redisSessionStore.addUserToRoom(roomId, userId); + redisSessionStore.addUserToRoom(roomId, userId); + redisSessionStore.addUserToRoom(roomId, userId); + + Set roomUsers = redisSessionStore.getRoomUsers(roomId); + + // then + assertThat(roomUsers).containsExactly(userId); + assertThat(roomUsers).hasSize(1); + } + + @Test + @DisplayName("세션 TTL이 설정되는지 확인") + void t19() { + // given + Long userId = 22L; + String sessionId = "session-ttl-check"; + WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, sessionId); + + // when + redisSessionStore.saveUserSession(userId, sessionInfo); + String userKey = WebSocketConstants.buildUserSessionKey(userId); + Long ttl = redisTemplate.getExpire(userKey); + + // then + assertThat(ttl).isNotNull(); + assertThat(ttl).isGreaterThan(0); + assertThat(ttl).isLessThanOrEqualTo(WebSocketConstants.SESSION_TTL.getSeconds()); + } + + @Test + @DisplayName("방 사용자 키에 TTL이 설정되는지 확인") + void t20() { + // given + Long roomId = 105L; + Long userId = 23L; + + // when + redisSessionStore.addUserToRoom(roomId, userId); + String roomUsersKey = WebSocketConstants.buildRoomUsersKey(roomId); + Long ttl = redisTemplate.getExpire(roomUsersKey); + + // then + assertThat(ttl).isNotNull(); + assertThat(ttl).isGreaterThan(0); + assertThat(ttl).isLessThanOrEqualTo(WebSocketConstants.SESSION_TTL.getSeconds()); + } + + @Test + @DisplayName("Integer 타입 userId도 Long으로 변환하여 조회 가능") + void t21() { + // given + String sessionId = "session-integer-test"; + + // Redis에 Integer로 저장 (실제로는 RedisTemplate이 변환할 수 있음) + String sessionKey = WebSocketConstants.buildSessionUserKey(sessionId); + redisTemplate.opsForValue().set(sessionKey, 24, WebSocketConstants.SESSION_TTL); + + // when + Long retrievedUserId = redisSessionStore.getUserIdBySession(sessionId); + + // then + assertThat(retrievedUserId).isEqualTo(24L); + } +} \ No newline at end of file