diff --git a/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java b/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java index d8579951..1df1ccaa 100644 --- a/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java +++ b/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java @@ -3,19 +3,21 @@ import io.f1.backend.domain.admin.dto.AdminPrincipal; import io.f1.backend.domain.user.dto.UserPrincipal; -public record CurrentUserAndAdminResponse(Long id, String name, String role) { +public record CurrentUserAndAdminResponse(Long id, String name, String role, String providerId) { public static CurrentUserAndAdminResponse from(UserPrincipal userPrincipal) { return new CurrentUserAndAdminResponse( userPrincipal.getUserId(), userPrincipal.getUserNickname(), - UserPrincipal.ROLE_USER); + UserPrincipal.ROLE_USER, + userPrincipal.getName()); } public static CurrentUserAndAdminResponse from(AdminPrincipal adminPrincipal) { return new CurrentUserAndAdminResponse( adminPrincipal.getAuthenticationAdmin().adminId(), adminPrincipal.getUsername(), - AdminPrincipal.ROLE_ADMIN); + AdminPrincipal.ROLE_ADMIN, + null); } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java index 78222d84..4c2d2010 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java @@ -32,7 +32,7 @@ @RequiredArgsConstructor public class GameService { - private static final int START_DELAY = 5; + public static final int START_DELAY = 5; private final MessageSender messageSender; private final TimerService timerService; diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java b/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java index 4056356e..eb5ae614 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java @@ -1,5 +1,6 @@ package io.f1.backend.domain.game.app; +import static io.f1.backend.domain.game.app.GameService.START_DELAY; import static io.f1.backend.domain.game.mapper.RoomMapper.ofPlayerEvent; import static io.f1.backend.domain.game.mapper.RoomMapper.toGameSetting; import static io.f1.backend.domain.game.mapper.RoomMapper.toGameSettingResponse; @@ -11,6 +12,7 @@ import static io.f1.backend.domain.game.mapper.RoomMapper.toRoomSetting; import static io.f1.backend.domain.game.mapper.RoomMapper.toRoomSettingResponse; import static io.f1.backend.domain.game.websocket.WebSocketUtils.getDestination; +import static io.f1.backend.domain.quiz.mapper.QuizMapper.toGameStartResponse; import static io.f1.backend.global.util.SecurityUtils.getCurrentUserId; import static io.f1.backend.global.util.SecurityUtils.getCurrentUserNickname; @@ -27,6 +29,7 @@ import io.f1.backend.domain.game.dto.response.RoomSettingResponse; import io.f1.backend.domain.game.dto.response.SystemNoticeResponse; import io.f1.backend.domain.game.event.RoomCreatedEvent; +import io.f1.backend.domain.game.model.ConnectionState; import io.f1.backend.domain.game.model.GameSetting; import io.f1.backend.domain.game.model.Player; import io.f1.backend.domain.game.model.Room; @@ -70,8 +73,6 @@ public class RoomService { private static final int CONTINUE_DELAY = 3; - private static final String PENDING_SESSION_ID = "PENDING_SESSION_ID"; - public RoomCreateResponse saveRoom(RoomCreateRequest request) { QuizMinData quizMinData = quizService.getQuizMinData(); @@ -88,7 +89,7 @@ public RoomCreateResponse saveRoom(RoomCreateRequest request) { Room room = new Room(newId, roomSetting, gameSetting, host); - room.getUserIdSessionMap().put(host.id, PENDING_SESSION_ID); + room.addValidatedUserId(getCurrentUserId()); roomRepository.saveRoom(room); @@ -111,7 +112,7 @@ public void enterRoom(RoomValidationRequest request) { } int maxUserCnt = room.getRoomSetting().maxUserCount(); - int currentCnt = room.getUserIdSessionMap().size(); + int currentCnt = room.getCurrentUserCnt(); if (maxUserCnt == currentCnt) { throw new CustomException(RoomErrorCode.ROOM_USER_LIMIT_REACHED); } @@ -121,7 +122,7 @@ public void enterRoom(RoomValidationRequest request) { throw new CustomException(RoomErrorCode.WRONG_PASSWORD); } - room.getUserIdSessionMap().put(getCurrentUserId(), PENDING_SESSION_ID); + room.addValidatedUserId(getCurrentUserId()); } } @@ -131,19 +132,7 @@ public void initializeRoomSocket(Long roomId, String sessionId, UserPrincipal pr Player player = createPlayer(principal); - Map playerSessionMap = room.getPlayerSessionMap(); - Map userIdSessionMap = room.getUserIdSessionMap(); - - if (room.isHost(player.getId())) { - player.toggleReady(); - } - - playerSessionMap.put(sessionId, player); - String existingSession = userIdSessionMap.get(player.getId()); - /* 정상 흐름 or 재연결 */ - if (existingSession.equals(PENDING_SESSION_ID) || !existingSession.equals(sessionId)) { - userIdSessionMap.put(player.getId(), sessionId); - } + room.addPlayer(sessionId, player); RoomSettingResponse roomSettingResponse = toRoomSettingResponse(room); @@ -175,8 +164,10 @@ public void exitRoom(Long roomId, String sessionId, UserPrincipal principal) { Player removePlayer = getRemovePlayer(room, sessionId, principal); + String destination = getDestination(roomId); + /* 방 삭제 */ - if (isLastPlayer(room, sessionId)) { + if (room.isLastPlayer(sessionId)) { removeRoom(room); return; } @@ -194,8 +185,6 @@ public void exitRoom(Long roomId, String sessionId, UserPrincipal principal) { PlayerListResponse playerListResponse = toPlayerListResponse(room); - String destination = getDestination(roomId); - messageSender.send(destination, MessageType.PLAYER_LIST, playerListResponse); messageSender.send(destination, MessageType.SYSTEM_NOTICE, systemNoticeResponse); } @@ -280,6 +269,61 @@ public void chat(Long roomId, String sessionId, ChatMessage chatMessage) { } } + public void reconnectSession( + Long roomId, String oldSessionId, String newSessionId, UserPrincipal principal) { + Room room = findRoom(roomId); + room.reconnectSession(oldSessionId, newSessionId); + + String destination = getDestination(roomId); + + messageSender.send( + destination, + MessageType.SYSTEM_NOTICE, + ofPlayerEvent(principal.getUserNickname(), RoomEventType.RECONNECT)); + + if (room.isPlaying()) { + // todo 랭킹 리스트 추가 + messageSender.send( + destination, MessageType.GAME_START, toGameStartResponse(room.getQuestions())); + messageSender.send( + destination, + MessageType.QUESTION_START, + toQuestionStartResponse(room, START_DELAY)); + } else { + RoomSettingResponse roomSettingResponse = toRoomSettingResponse(room); + + Long quizId = room.getGameSetting().getQuizId(); + + Quiz quiz = quizService.getQuizWithQuestionsById(quizId); + + GameSettingResponse gameSettingResponse = + toGameSettingResponse(room.getGameSetting(), quiz); + + PlayerListResponse playerListResponse = toPlayerListResponse(room); + + messageSender.send(destination, MessageType.ROOM_SETTING, roomSettingResponse); + messageSender.send(destination, MessageType.GAME_SETTING, gameSettingResponse); + messageSender.send(destination, MessageType.PLAYER_LIST, playerListResponse); + } + } + + public void changeConnectedStatus(Long roomId, String sessionId, ConnectionState newState) { + Room room = findRoom(roomId); + room.updatePlayerConnectionState(sessionId, newState); + } + + public boolean isExit(String sessionId, Long roomId) { + Room room = findRoom(roomId); + return room.isExit(sessionId); + } + + public void exitIfNotPlaying(Long roomId, String sessionId, UserPrincipal principal) { + Room room = findRoom(roomId); + if (!room.isPlaying()) { + exitRoom(roomId, sessionId, principal); + } + } + private Player getRemovePlayer(Room room, String sessionId, UserPrincipal principal) { Player removePlayer = room.getPlayerSessionMap().get(sessionId); if (removePlayer == null) { @@ -303,11 +347,6 @@ private Room findRoom(Long roomId) { .orElseThrow(() -> new CustomException(RoomErrorCode.ROOM_NOT_FOUND)); } - private boolean isLastPlayer(Room room, String sessionId) { - Map playerSessionMap = room.getPlayerSessionMap(); - return playerSessionMap.size() == 1 && playerSessionMap.containsKey(sessionId); - } - private void removeRoom(Room room) { Long roomId = room.getId(); roomRepository.removeRoom(roomId); @@ -319,8 +358,10 @@ private void changeHost(Room room, String hostSessionId) { Map playerSessionMap = room.getPlayerSessionMap(); Optional nextHostSessionId = - playerSessionMap.keySet().stream() - .filter(key -> !key.equals(hostSessionId)) + playerSessionMap.entrySet().stream() + .filter(entry -> !entry.getKey().equals(hostSessionId)) + .filter(entry -> entry.getValue().getState() == ConnectionState.CONNECTED) + .map(Map.Entry::getKey) .findFirst(); Player nextHost = @@ -335,5 +376,6 @@ private void changeHost(Room room, String hostSessionId) { private void removePlayer(Room room, String sessionId, Player removePlayer) { room.removeUserId(removePlayer.getId()); room.removeSessionId(sessionId); + room.removeValidatedUserId(removePlayer.getId()); } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/RoomEventType.java b/backend/src/main/java/io/f1/backend/domain/game/dto/RoomEventType.java index 3c0d3e91..f9cdce92 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/RoomEventType.java +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/RoomEventType.java @@ -6,7 +6,8 @@ public enum RoomEventType { START(null), END(null), CORRECT_ANSWER(SystemNoticeMessage.CORRECT_ANSWER), - TIMEOUT(SystemNoticeMessage.TIMEOUT); + TIMEOUT(SystemNoticeMessage.TIMEOUT), + RECONNECT(SystemNoticeMessage.RECONNECT); private final SystemNoticeMessage systemMessage; diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/SystemNoticeMessage.java b/backend/src/main/java/io/f1/backend/domain/game/dto/SystemNoticeMessage.java index 73908549..636e5bf1 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/SystemNoticeMessage.java +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/SystemNoticeMessage.java @@ -4,7 +4,8 @@ public enum SystemNoticeMessage { ENTER(" 님이 입장하셨습니다"), EXIT(" 님이 퇴장하셨습니다"), CORRECT_ANSWER(" 님 정답입니다 !"), - TIMEOUT("땡 ~ ⏰ 제한 시간 초과!"); + TIMEOUT("땡 ~ ⏰ 제한 시간 초과!"), + RECONNECT(" 님이 재연결 되었습니다."); private final String message; diff --git a/backend/src/main/java/io/f1/backend/domain/game/mapper/RoomMapper.java b/backend/src/main/java/io/f1/backend/domain/game/mapper/RoomMapper.java index ae23f1dc..a05a097f 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/mapper/RoomMapper.java +++ b/backend/src/main/java/io/f1/backend/domain/game/mapper/RoomMapper.java @@ -90,9 +90,8 @@ public static SystemNoticeResponse ofPlayerEvent(String nickname, RoomEventType return new SystemNoticeResponse(roomEventType.getMessage(nickname), Instant.now()); } - public static QuestionResultResponse toQuestionResultResponse( - String correctUser, String answer) { - return new QuestionResultResponse(correctUser, answer); + public static QuestionResultResponse toQuestionResultResponse(String nickname, String answer) { + return new QuestionResultResponse(nickname, answer); } public static RankUpdateResponse toRankUpdateResponse(Room room) { diff --git a/backend/src/main/java/io/f1/backend/domain/game/model/Player.java b/backend/src/main/java/io/f1/backend/domain/game/model/Player.java index 4d054c8c..9d20b212 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/model/Player.java +++ b/backend/src/main/java/io/f1/backend/domain/game/model/Player.java @@ -27,4 +27,8 @@ public void toggleReady() { public void increaseCorrectCount() { correctCount++; } + + public void updateState(ConnectionState newState) { + state = newState; + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/model/Room.java b/backend/src/main/java/io/f1/backend/domain/game/model/Room.java index 8a449879..fd581102 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/model/Room.java +++ b/backend/src/main/java/io/f1/backend/domain/game/model/Room.java @@ -1,13 +1,17 @@ package io.f1.backend.domain.game.model; import io.f1.backend.domain.question.entity.Question; +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.RoomErrorCode; import lombok.Getter; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -30,7 +34,7 @@ public class Room { private Map playerSessionMap = new ConcurrentHashMap<>(); - private Map userIdSessionMap = new ConcurrentHashMap<>(); + private final Set validatedUserIds = new HashSet<>(); private final LocalDateTime createdAt = LocalDateTime.now(); @@ -47,6 +51,26 @@ public Room(Long id, RoomSetting roomSetting, GameSetting gameSetting, Player ho this.host = host; } + public void addValidatedUserId(Long userId) { + validatedUserIds.add(userId); + } + + public int getCurrentUserCnt() { + return validatedUserIds.size(); + } + + public void addPlayer(String sessionId, Player player) { + Long userId = player.getId(); + if (!validatedUserIds.contains(userId)) { + throw new CustomException(RoomErrorCode.ROOM_ENTER_REQUIRED); + } + + if (isHost(userId)) { + player.toggleReady(); + } + playerSessionMap.put(sessionId, player); + } + public boolean isHost(Long id) { return this.host.getId().equals(id); } @@ -67,14 +91,18 @@ public void updateTimer(ScheduledFuture timer) { this.timer = timer; } - public void removeUserId(Long id) { - this.userIdSessionMap.remove(id); - } - public void removeSessionId(String sessionId) { this.playerSessionMap.remove(sessionId); } + public void removeValidatedUserId(Long userId) { + validatedUserIds.remove(userId); + } + + public void removeUserId(Long userId) { + validatedUserIds.remove(userId); + } + public void increasePlayerCorrectCount(String sessionId) { this.playerSessionMap.get(sessionId).increaseCorrectCount(); } @@ -83,11 +111,34 @@ public Question getCurrentQuestion() { return questions.get(currentRound - 1); } - public Boolean isPlaying() { + public boolean isPlaying() { return state == RoomState.PLAYING; } public void increaseCurrentRound() { currentRound++; } + + public void reconnectSession(String oldSessionId, String newSessionId) { + Player player = playerSessionMap.get(oldSessionId); + removeSessionId(oldSessionId); + player.updateState(ConnectionState.CONNECTED); + playerSessionMap.put(newSessionId, player); + } + + public void updatePlayerConnectionState(String sessionId, ConnectionState newState) { + playerSessionMap.get(sessionId).updateState(newState); + } + + public boolean isExit(String sessionId) { + return playerSessionMap.get(sessionId) == null; + } + + public boolean isLastPlayer(String sessionId) { + long connectedCount = + playerSessionMap.values().stream() + .filter(player -> player.getState() == ConnectionState.CONNECTED) + .count(); + return connectedCount == 1 && playerSessionMap.containsKey(sessionId); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/WebSocketUtils.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/WebSocketUtils.java index 5edbc007..b615f6ff 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/websocket/WebSocketUtils.java +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/WebSocketUtils.java @@ -22,4 +22,9 @@ public static UserPrincipal getSessionUser(Message message) { public static String getDestination(Long roomId) { return "/sub/room/" + roomId; } + + public static String getRoomSubscriptionDestination(Message message) { + StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message); + return headerAccessor.getDestination(); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/GameSocketController.java similarity index 72% rename from backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java rename to backend/src/main/java/io/f1/backend/domain/game/websocket/controller/GameSocketController.java index 57856e86..90a2bb3a 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/GameSocketController.java @@ -1,4 +1,4 @@ -package io.f1.backend.domain.game.websocket; +package io.f1.backend.domain.game.websocket.controller; import static io.f1.backend.domain.game.websocket.WebSocketUtils.getSessionId; import static io.f1.backend.domain.game.websocket.WebSocketUtils.getSessionUser; @@ -7,6 +7,7 @@ import io.f1.backend.domain.game.app.RoomService; import io.f1.backend.domain.game.dto.ChatMessage; import io.f1.backend.domain.game.dto.request.DefaultWebSocketRequest; +import io.f1.backend.domain.game.websocket.service.SessionService; import io.f1.backend.domain.user.dto.UserPrincipal; import lombok.RequiredArgsConstructor; @@ -22,6 +23,7 @@ public class GameSocketController { private final RoomService roomService; private final GameService gameService; + private final SessionService sessionService; @MessageMapping("/room/initializeRoomSocket/{roomId}") public void initializeRoomSocket(@DestinationVariable Long roomId, Message message) { @@ -33,6 +35,25 @@ public void initializeRoomSocket(@DestinationVariable Long roomId, Message me roomService.initializeRoomSocket(roomId, websocketSessionId, principal); } + @MessageMapping("/room/reconnect/{roomId}") + public void reconnect(@DestinationVariable Long roomId, Message message) { + String websocketSessionId = getSessionId(message); + + UserPrincipal principal = getSessionUser(message); + Long userId = principal.getUserId(); + + if (!sessionService.hasOldSessionId(userId)) { + return; + } + + String oldSessionId = sessionService.getOldSessionId(userId); + + /* room 재연결 대상인지 아닌지 판별 */ + if (!roomService.isExit(oldSessionId, roomId)) { + roomService.reconnectSession(roomId, oldSessionId, websocketSessionId, principal); + } + } + @MessageMapping("/room/exit/{roomId}") public void exitRoom(@DestinationVariable Long roomId, Message message) { diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/eventlistener/WebsocketEventListener.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/eventlistener/WebsocketEventListener.java new file mode 100644 index 00000000..15139da9 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/eventlistener/WebsocketEventListener.java @@ -0,0 +1,65 @@ +package io.f1.backend.domain.game.websocket.eventlistener; + +import static io.f1.backend.domain.game.websocket.WebSocketUtils.getRoomSubscriptionDestination; +import static io.f1.backend.domain.game.websocket.WebSocketUtils.getSessionId; +import static io.f1.backend.domain.game.websocket.WebSocketUtils.getSessionUser; + +import io.f1.backend.domain.game.websocket.service.SessionService; +import io.f1.backend.domain.user.dto.UserPrincipal; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.context.event.EventListener; +import org.springframework.messaging.Message; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionConnectEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WebsocketEventListener { + + private final SessionService sessionService; + + @EventListener + public void handleConnectListener(SessionConnectEvent event) { + Message message = event.getMessage(); + + String sessionId = getSessionId(message); + UserPrincipal user = getSessionUser(message); + + sessionService.addSession(sessionId, user.getUserId()); + } + + @EventListener + public void handleSubscribeListener(SessionSubscribeEvent event) { + + Message message = event.getMessage(); + + String sessionId = getSessionId(message); + + String destination = getRoomSubscriptionDestination(message); + + // todo 인덱스 길이 유효성 추가 + String[] subscribeType = destination.split("/"); + + if (subscribeType[2].equals("room")) { + Long roomId = Long.parseLong(subscribeType[3]); + sessionService.addRoomId(roomId, sessionId); + } + } + + @EventListener + public void handleDisconnectedListener(SessionDisconnectEvent event) { + + Message message = event.getMessage(); + + String sessionId = getSessionId(message); + UserPrincipal principal = getSessionUser(message); + + sessionService.handleUserDisconnect(sessionId, principal); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/service/SessionService.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/service/SessionService.java new file mode 100644 index 00000000..ec40f46f --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/service/SessionService.java @@ -0,0 +1,86 @@ +package io.f1.backend.domain.game.websocket.service; + +import io.f1.backend.domain.game.app.RoomService; +import io.f1.backend.domain.game.model.ConnectionState; +import io.f1.backend.domain.user.dto.UserPrincipal; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class SessionService { + + private final RoomService roomService; + private final Map sessionIdUser = new ConcurrentHashMap<>(); + private final Map sessionIdRoom = new ConcurrentHashMap<>(); + private final Map userIdSession = new ConcurrentHashMap<>(); + private final Map userIdLatestSession = new ConcurrentHashMap<>(); + + // todo 부하테스트 후 스레드 풀 변경 + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); + + public void addSession(String sessionId, Long userId) { + sessionIdUser.put(sessionId, userId); + userIdSession.put(userId, sessionId); + } + + public void addRoomId(Long roomId, String sessionId) { + sessionIdRoom.put(sessionId, roomId); + } + + public boolean hasOldSessionId(Long userId) { + return userIdLatestSession.get(userId) != null; + } + + public String getOldSessionId(Long userId) { + return userIdLatestSession.get(userId); + } + + public void handleUserDisconnect(String sessionId, UserPrincipal principal) { + + Long roomId = sessionIdRoom.get(sessionId); + Long userId = sessionIdUser.get(sessionId); + + /* 정상 동작*/ + if (roomService.isExit(sessionId, roomId)) { + removeSession(sessionId, userId); + return; + } + + userIdLatestSession.put(userId, sessionId); + + roomService.changeConnectedStatus(roomId, sessionId, ConnectionState.DISCONNECTED); + + // 5초 뒤 실행 + scheduler.schedule( + () -> { + /* 재연결 실패 */ + if (sessionId.equals(userIdSession.get(userId))) { + roomService.exitIfNotPlaying(roomId, sessionId, principal); + // 메세지 응답 추가 + } + removeSession(sessionId, userId); + }, + 5, + TimeUnit.SECONDS); + } + + public void removeSession(String sessionId, Long userId) { + + if (sessionId.equals(userIdSession.get(userId))) { + userIdSession.remove(userId); + } + sessionIdUser.remove(sessionId); + sessionIdRoom.remove(sessionId); + + userIdLatestSession.remove(userId); + } +} diff --git a/backend/src/main/java/io/f1/backend/global/config/WebSocketConfig.java b/backend/src/main/java/io/f1/backend/global/config/WebSocketConfig.java index 7c3ce063..1a1d475c 100644 --- a/backend/src/main/java/io/f1/backend/global/config/WebSocketConfig.java +++ b/backend/src/main/java/io/f1/backend/global/config/WebSocketConfig.java @@ -26,8 +26,10 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { @Override public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.enableSimpleBroker("/sub"); + registry.enableSimpleBroker("/sub", "/queue"); registry.setApplicationDestinationPrefixes("/pub"); + + registry.setUserDestinationPrefix("/user"); } @Override diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java index 4a9b31ca..0041e450 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java @@ -15,7 +15,8 @@ public enum RoomErrorCode implements ErrorCode { PLAYER_NOT_FOUND("E404007", HttpStatus.NOT_FOUND, "존재하지 않는 플레이어입니다."), SOCKET_SESSION_NOT_FOUND("E404006", HttpStatus.NOT_FOUND, "존재하지 않는 소켓 세션입니다."), GAME_ALREADY_PLAYING("E400015", HttpStatus.BAD_REQUEST, "이미 게임이 진행 중 입니다."), - NOT_ROOM_OWNER("E403005", HttpStatus.FORBIDDEN, "방장만 게임 시작이 가능합니다."); + NOT_ROOM_OWNER("E403005", HttpStatus.FORBIDDEN, "방장만 게임 시작이 가능합니다."), + ROOM_ENTER_REQUIRED("E400014", HttpStatus.NOT_FOUND, "방 입장 후에 소켓 연결이 시도되어야합니다."); private final String code; diff --git a/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java b/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java index 194e310a..38e88730 100644 --- a/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java +++ b/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java @@ -33,7 +33,7 @@ public static UserPrincipal getCurrentUserPrincipal() { && authentication.getPrincipal() instanceof UserPrincipal userPrincipal) { return userPrincipal; } - throw new RuntimeException("E401001: 로그인이 필요합니다."); + throw new CustomException(AuthErrorCode.UNAUTHORIZED); } public static Long getCurrentUserId() { diff --git a/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java b/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java index fc4387e6..7be3f8e5 100644 --- a/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java +++ b/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java @@ -1,6 +1,7 @@ package io.f1.backend.domain.game.app; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import io.f1.backend.domain.game.dto.request.RoomValidationRequest; @@ -99,7 +100,7 @@ void enterRoom_synchronized() throws Exception { }); } countDownLatch.await(); - assertThat(room.getUserIdSessionMap()).hasSize(room.getRoomSetting().maxUserCount()); + assertThat(room.getCurrentUserCnt()).isEqualTo(room.getRoomSetting().maxUserCount()); } @Test @@ -131,7 +132,6 @@ void exitRoom_synchronized() throws Exception { String sessionId = "sessionId" + i; Player player = players.get(i - 1); room.getPlayerSessionMap().put(sessionId, player); - room.getUserIdSessionMap().put(player.getId(), sessionId); } log.info("room.getPlayerSessionMap().size() = {}", room.getPlayerSessionMap().size()); @@ -161,7 +161,7 @@ void exitRoom_synchronized() throws Exception { }); } countDownLatch.await(); - assertThat(room.getUserIdSessionMap()).hasSize(1); + verify(roomRepository).removeRoom(roomId); } private Room createRoom( diff --git a/backend/src/test/java/io/f1/backend/domain/game/websocket/SessionServiceTests.java b/backend/src/test/java/io/f1/backend/domain/game/websocket/SessionServiceTests.java new file mode 100644 index 00000000..df3c5f57 --- /dev/null +++ b/backend/src/test/java/io/f1/backend/domain/game/websocket/SessionServiceTests.java @@ -0,0 +1,234 @@ +package io.f1.backend.domain.game.websocket; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import io.f1.backend.domain.game.app.RoomService; +import io.f1.backend.domain.game.model.ConnectionState; +import io.f1.backend.domain.game.websocket.service.SessionService; +import io.f1.backend.domain.user.dto.UserPrincipal; +import io.f1.backend.domain.user.entity.User; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; + +@ExtendWith(MockitoExtension.class) +class SessionServiceTests { + + @Mock private RoomService roomService; + + @InjectMocks private SessionService sessionService; + + // 테스트를 위한 더미 데이터 + private String sessionId1 = "session1"; + private String sessionId2 = "session2"; + private Long userId1 = 100L; + private Long userId2 = 200L; + private Long roomId1 = 1L; + private Long roomId2 = 2L; + + @BeforeEach + void setUp() { + + ReflectionTestUtils.setField(sessionService, "sessionIdUser", new ConcurrentHashMap<>()); + ReflectionTestUtils.setField(sessionService, "sessionIdRoom", new ConcurrentHashMap<>()); + ReflectionTestUtils.setField(sessionService, "userIdSession", new ConcurrentHashMap<>()); + ReflectionTestUtils.setField( + sessionService, "userIdLatestSession", new ConcurrentHashMap<>()); + + ReflectionTestUtils.setField( + sessionService, "scheduler", Executors.newScheduledThreadPool(2)); + } + + @Test + @DisplayName("addSession: 세션과 사용자 ID가 올바르게 추가되는지 확인") + void addSession_shouldAddSessionAndUser() { + sessionService.addSession(sessionId1, userId1); + + Map sessionIdUser = + (Map) ReflectionTestUtils.getField(sessionService, "sessionIdUser"); + Map userIdSession = + (Map) ReflectionTestUtils.getField(sessionService, "userIdSession"); + + assertEquals(1, sessionIdUser.size()); + assertEquals(1, userIdSession.size()); + + // 값의 정확성 확인 + assertEquals(userId1, sessionIdUser.get(sessionId1)); + assertEquals(sessionId1, userIdSession.get(userId1)); + } + + @Test + @DisplayName("addRoomId: 세션과 룸 ID가 올바르게 추가되는지 확인") + void addRoomId_shouldAddSessionAndRoom() { + sessionService.addRoomId(roomId1, sessionId1); + + Map sessionIdRoom = + (Map) ReflectionTestUtils.getField(sessionService, "sessionIdRoom"); + + assertEquals(1, sessionIdRoom.size()); + assertEquals(roomId1, sessionIdRoom.get(sessionId1)); + } + + @Test + @DisplayName("handleUserDisconnect: 연결 끊김 상태이고 재연결되지 않았으면 exitIfNotPlaying 호출") + void handleUserDisconnect_shouldExitIfNotPlayingIfDisconnected() throws InterruptedException { + // 준비: 맵에 더미 데이터 추가 + sessionService.addRoomId(roomId1, sessionId1); + sessionService.addSession(sessionId1, userId1); + + User user = new User("provider", "providerId", LocalDateTime.now()); + user.setId(userId1); + UserPrincipal principal = new UserPrincipal(user, new HashMap<>()); + + // disconnect 호출 + sessionService.handleUserDisconnect(sessionId1, principal); + + Thread.sleep(5100); // 5초 + 여유 시간 + + // verify: roomService.changeConnectedStatus가 호출되었는지 확인 + verify(roomService, times(1)) + .changeConnectedStatus(roomId1, sessionId1, ConnectionState.DISCONNECTED); + + // verify: roomService.exitIfNotPlaying이 호출되었는지 확인 + verify(roomService, times(1)).exitIfNotPlaying(eq(roomId1), eq(sessionId1), eq(principal)); + + Map userIdLatestSession = + (Map) + ReflectionTestUtils.getField(sessionService, "userIdLatestSession"); + assertEquals(null, userIdLatestSession.get(principal.getUserId())); + } + + @Test + @DisplayName("removeSession: 세션 관련 정보가 올바르게 제거되고 userIdLatestSession에 삭제 확인") + void removeSession_shouldRemoveAndPutLatestSession() { + // 준비: 초기 데이터 추가 + sessionService.addSession(sessionId1, userId1); + sessionService.addRoomId(roomId1, sessionId1); + + // removeSession 호출 + sessionService.removeSession(sessionId1, userId1); + + // 맵의 크기 및 내용 확인 + Map sessionIdUser = + (Map) ReflectionTestUtils.getField(sessionService, "sessionIdUser"); + Map sessionIdRoom = + (Map) ReflectionTestUtils.getField(sessionService, "sessionIdRoom"); + Map userIdSession = + (Map) ReflectionTestUtils.getField(sessionService, "userIdSession"); + Map userIdLatestSession = + (Map) + ReflectionTestUtils.getField(sessionService, "userIdLatestSession"); + + // 제거되었는지 확인 + assertFalse(sessionIdUser.containsKey(sessionId1)); + assertFalse(sessionIdRoom.containsKey(sessionId1)); + assertFalse(userIdSession.containsKey(userId1)); + + // userIdLatestSession에 업데이트되었는지 확인 + assertFalse(userIdLatestSession.containsKey(userId1)); + } + + @Test + @DisplayName("handleUserDisconnect: 연결 끊김 시 userIdLatestSession에 이전 세션 ID가 저장되는지 확인") + void handleUserDisconnect_shouldStoreOldSessionIdInLatestSession() { + // given + sessionService.addSession(sessionId1, userId1); // 유저의 현재 활성 세션 + sessionService.addRoomId(roomId1, sessionId1); + + User user = new User("provider", "providerId", LocalDateTime.now()); + user.setId(userId1); + UserPrincipal principal = new UserPrincipal(user, new HashMap<>()); + + // when + sessionService.handleUserDisconnect(sessionId1, principal); + + // then + Map userIdLatestSession = + (Map) + ReflectionTestUtils.getField(sessionService, "userIdLatestSession"); + + assertTrue(userIdLatestSession.containsKey(userId1)); + assertEquals(sessionId1, userIdLatestSession.get(userId1)); + + // 재연결 상태 변경 검증 + verify(roomService, times(1)) + .changeConnectedStatus(roomId1, sessionId1, ConnectionState.DISCONNECTED); + } + + @Test + @DisplayName( + "handleUserDisconnect 후 5초 내 재연결: userIdLatestSession이 정리되고 exitIfNotPlaying이 호출되지 않음") + void handleUserDisconnect_reconnectWithin5Seconds_shouldCleanLatestSession() + throws InterruptedException { + // given + sessionService.addSession(sessionId1, userId1); // 초기 세션 + sessionService.addRoomId(roomId1, sessionId1); + + User user = new User("provider", "providerId", LocalDateTime.now()); + user.setId(userId1); + UserPrincipal principal = new UserPrincipal(user, new HashMap<>()); + + sessionService.handleUserDisconnect( + sessionId1, principal); // 세션1 끊김, userIdLatestSession에 세션1 저장 + + // 5초 타이머가 실행되기 전에 새로운 세션으로 재연결 시도 (userIdSession 업데이트) + sessionService.addSession(sessionId2, userId1); // userId1의 새 세션은 sessionId2 + sessionService.addRoomId(roomId1, sessionId2); // 새 세션도 룸에 추가 + + // when (5초가 경과했다고 가정) + Thread.sleep(5100); + + // then + Map userIdLatestSession = + (Map) + ReflectionTestUtils.getField(sessionService, "userIdLatestSession"); + Map sessionIdUser = + (Map) ReflectionTestUtils.getField(sessionService, "sessionIdUser"); + Map sessionIdRoom = + (Map) ReflectionTestUtils.getField(sessionService, "sessionIdRoom"); + Map userIdSession = + (Map) ReflectionTestUtils.getField(sessionService, "userIdSession"); + + // userIdLatestSession은 정리되어야 함 + assertFalse(userIdLatestSession.containsKey(userId1)); + assertNull(userIdLatestSession.get(userId1)); + + // roomService.exitIfNotPlaying은 호출되지 않아야 함 (재연결 성공했으므로) + verify(roomService, never()) + .exitIfNotPlaying(anyLong(), anyString(), any(UserPrincipal.class)); + + // 세션 관련 맵들이 올바르게 정리되었는지 확인 + // sessionId1에 대한 정보는 모두 삭제되어야 함 + assertFalse(sessionIdUser.containsKey(sessionId1)); // sessionId1은 sessionIdUser에서 삭제 + assertFalse(sessionIdRoom.containsKey(sessionId1)); // sessionId1은 sessionIdRoom에서 삭제 + + // userIdSession은 sessionId2로 업데이트되어 있어야 함 + assertTrue(userIdSession.containsKey(userId1)); + assertEquals(sessionId2, userIdSession.get(userId1)); + + // sessionId2에 대한 정보는 남아있어야 함 + assertTrue(sessionIdUser.containsKey(sessionId2)); + assertEquals(userId1, sessionIdUser.get(sessionId2)); + assertTrue(sessionIdRoom.containsKey(sessionId2)); + assertEquals(roomId1, sessionIdRoom.get(sessionId2)); + } +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 215917fd..bf62a914 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -35,3 +35,4 @@ spring: file: thumbnail-path : images/thumbnail/ # 이후 배포 환경에서는 바꾸면 될 듯 default-thumbnail-url: /images/thumbnail/default.png +