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 afa71e4c..7eebd4b7 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 @@ -45,6 +45,8 @@ import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.RoomErrorCode; import io.f1.backend.global.exception.errorcode.UserErrorCode; +import io.f1.backend.global.lock.DistributedLock; +import io.f1.backend.global.lock.LockExecutor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -68,10 +70,14 @@ public class RoomService { private final UserRoomRepository userRoomRepository; private final AtomicLong roomIdGenerator = new AtomicLong(0); private final ApplicationEventPublisher eventPublisher; - private final Map roomLocks = new ConcurrentHashMap<>(); - private final DisconnectTaskManager disconnectTasks; + private final Map sessionRoomMap = new ConcurrentHashMap<>(); + private final DisconnectTaskManager disconnectTasks; private final MessageSender messageSender; + private final LockExecutor lockExecutor; + + public static final String ROOM_LOCK_PREFIX = "room"; + public static final String USER_LOCK_PREFIX = "user"; public RoomCreateResponse saveRoom(RoomCreateRequest request) { @@ -94,7 +100,10 @@ public RoomCreateResponse saveRoom(RoomCreateRequest request) { roomRepository.saveRoom(room); /* 다른 방 접속 시 기존 방은 exit 처리 - 탭 동시 로그인 시 (disconnected 리스너 작동x) */ - exitIfInAnotherRoom(room, getCurrentUserPrincipal()); + lockExecutor.executeWithLock( + USER_LOCK_PREFIX, + host.getId(), + () -> exitIfInAnotherRoom(room, getCurrentUserPrincipal())); eventPublisher.publishEvent(new RoomCreatedEvent(room, quiz)); @@ -105,37 +114,46 @@ public void enterRoom(RoomValidationRequest request) { Long roomId = request.roomId(); - Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object()); + Room room = findRoom(roomId); - synchronized (lock) { - Room room = findRoom(request.roomId()); + /* 다른 방 접속 시 기존 방은 exit 처리 - 탭 동시 로그인 시 (disconnected 리스너 작동x) */ + lockExecutor.executeWithLock( + USER_LOCK_PREFIX, + getCurrentUserId(), + () -> exitIfInAnotherRoom(room, getCurrentUserPrincipal())); - Long userId = getCurrentUserId(); + lockExecutor.executeWithLock( + ROOM_LOCK_PREFIX, roomId, () -> performEnterRoomLogic(request)); + } + + private void performEnterRoomLogic(RoomValidationRequest request) { + + Long roomId = request.roomId(); - /* 다른 방 접속 시 기존 방은 exit 처리 - 탭 동시 로그인 시 (disconnected 리스너 작동x) */ - exitIfInAnotherRoom(room, getCurrentUserPrincipal()); + Room room = findRoom(roomId); - /* reconnect */ - if (room.hasPlayer(userId)) { - return; - } + Long userId = getCurrentUserId(); - if (room.isPlaying()) { - throw new CustomException(RoomErrorCode.ROOM_GAME_IN_PROGRESS); - } + /* reconnect */ + if (room.hasPlayer(userId)) { + return; + } - int maxUserCnt = room.getRoomSetting().maxUserCount(); - int currentCnt = room.getCurrentUserCnt(); - if (maxUserCnt == currentCnt) { - throw new CustomException(RoomErrorCode.ROOM_USER_LIMIT_REACHED); - } + if (room.isPlaying()) { + throw new CustomException(RoomErrorCode.ROOM_GAME_IN_PROGRESS); + } - if (room.isPasswordIncorrect(request.password())) { - throw new CustomException(RoomErrorCode.WRONG_PASSWORD); - } + int maxUserCnt = room.getRoomSetting().maxUserCount(); + int currentCnt = room.getCurrentUserCnt(); + if (maxUserCnt == currentCnt) { + throw new CustomException(RoomErrorCode.ROOM_USER_LIMIT_REACHED); + } - room.addPlayer(createPlayer()); + if (room.isPasswordIncorrect(request.password())) { + throw new CustomException(RoomErrorCode.WRONG_PASSWORD); } + + room.addPlayer(createPlayer()); } private void exitIfInAnotherRoom(Room room, UserPrincipal userPrincipal) { @@ -143,89 +161,119 @@ private void exitIfInAnotherRoom(Room room, UserPrincipal userPrincipal) { Long joinedRoomId = getRoomIdByUserId(userId); if (joinedRoomId != null && !room.isSameRoom(joinedRoomId)) { - disconnectOrExitRoom(joinedRoomId, userPrincipal); + lockExecutor.executeWithLock( + ROOM_LOCK_PREFIX, + joinedRoomId, + () -> disconnectOrExitRoom(joinedRoomId, userPrincipal)); } } public void initializeRoomSocket(Long roomId, UserPrincipal principal) { - Room room = findRoom(roomId); Long userId = principal.getUserId(); - if (!room.hasPlayer(userId)) { - throw new CustomException(RoomErrorCode.ROOM_ENTER_REQUIRED); - } - - /* 재연결 */ - if (room.isPlayerInState(userId, ConnectionState.DISCONNECTED)) { - changeConnectedStatus(roomId, userId, ConnectionState.CONNECTED); - cancelTask(userId); - reconnectSendResponse(roomId, principal); - return; - } + lockExecutor.executeWithLock( + USER_LOCK_PREFIX, + userId, + () -> { + lockExecutor.executeWithLock( + ROOM_LOCK_PREFIX, + roomId, + () -> { + Room room = findRoom(roomId); + + if (!room.hasPlayer(userId)) { + throw new CustomException(RoomErrorCode.ROOM_ENTER_REQUIRED); + } + + /* 재연결 */ + if (room.isPlayerInState(userId, ConnectionState.DISCONNECTED)) { + changeConnectedStatus( + roomId, userId, ConnectionState.CONNECTED); + cancelTask(userId); + reconnectSendResponse(roomId, principal); + return; + } + + Player player = createPlayer(principal); + + RoomSettingResponse roomSettingResponse = + toRoomSettingResponse(room); + + Long quizId = room.getGameSetting().getQuizId(); + Quiz quiz = quizService.getQuizWithQuestionsById(quizId); + + GameSettingResponse gameSettingResponse = + toGameSettingResponse(room.getGameSetting(), quiz); + + PlayerListResponse playerListResponse = toPlayerListResponse(room); + + SystemNoticeResponse systemNoticeResponse = + ofPlayerEvent(player.getNickname(), RoomEventType.ENTER); + + String destination = getDestination(roomId); + + userRoomRepository.addUser(player, room); + + messageSender.sendPersonal( + getUserDestination(), + MessageType.GAME_SETTING, + gameSettingResponse, + principal.getName()); + + messageSender.sendBroadcast( + destination, MessageType.ROOM_SETTING, roomSettingResponse); + messageSender.sendBroadcast( + destination, MessageType.PLAYER_LIST, playerListResponse); + messageSender.sendBroadcast( + destination, + MessageType.SYSTEM_NOTICE, + systemNoticeResponse); + }); + }); + } - Player player = createPlayer(principal); + public void exitRoomWithLock(Long roomId, UserPrincipal principal) { + lockExecutor.executeWithLock( + USER_LOCK_PREFIX, + principal.getUserId(), + () -> { + lockExecutor.executeWithLock( + ROOM_LOCK_PREFIX, + roomId, + () -> { + exitRoom(roomId, principal); + }); + }); + } - RoomSettingResponse roomSettingResponse = toRoomSettingResponse(room); + private void exitRoom(Long roomId, UserPrincipal principal) { - Long quizId = room.getGameSetting().getQuizId(); - Quiz quiz = quizService.getQuizWithQuestionsById(quizId); + Room room = findRoom(roomId); - GameSettingResponse gameSettingResponse = - toGameSettingResponse(room.getGameSetting(), quiz); + if (!room.hasPlayer(principal.getUserId())) { + throw new CustomException(UserErrorCode.USER_NOT_FOUND); + } - PlayerListResponse playerListResponse = toPlayerListResponse(room); - - SystemNoticeResponse systemNoticeResponse = - ofPlayerEvent(player.getNickname(), RoomEventType.ENTER); + Player removePlayer = createPlayer(principal); String destination = getDestination(roomId); - userRoomRepository.addUser(player, room); + cleanRoom(room, removePlayer); messageSender.sendPersonal( getUserDestination(), - MessageType.GAME_SETTING, - gameSettingResponse, + MessageType.EXIT_SUCCESS, + new ExitSuccessResponse(true), principal.getName()); - messageSender.sendBroadcast(destination, MessageType.ROOM_SETTING, roomSettingResponse); - messageSender.sendBroadcast(destination, MessageType.PLAYER_LIST, playerListResponse); - messageSender.sendBroadcast(destination, MessageType.SYSTEM_NOTICE, systemNoticeResponse); - } - - public void exitRoom(Long roomId, UserPrincipal principal) { - - Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object()); - - synchronized (lock) { - Room room = findRoom(roomId); - - if (!room.hasPlayer(principal.getUserId())) { - throw new CustomException(UserErrorCode.USER_NOT_FOUND); - } - - Player removePlayer = createPlayer(principal); - - String destination = getDestination(roomId); - - cleanRoom(room, removePlayer); - - messageSender.sendPersonal( - getUserDestination(), - MessageType.EXIT_SUCCESS, - new ExitSuccessResponse(true), - principal.getName()); - - SystemNoticeResponse systemNoticeResponse = - ofPlayerEvent(removePlayer.nickname, RoomEventType.EXIT); + SystemNoticeResponse systemNoticeResponse = + ofPlayerEvent(removePlayer.nickname, RoomEventType.EXIT); - PlayerListResponse playerListResponse = toPlayerListResponse(room); + PlayerListResponse playerListResponse = toPlayerListResponse(room); - messageSender.sendBroadcast(destination, MessageType.PLAYER_LIST, playerListResponse); - messageSender.sendBroadcast( - destination, MessageType.SYSTEM_NOTICE, systemNoticeResponse); - } + messageSender.sendBroadcast(destination, MessageType.PLAYER_LIST, playerListResponse); + messageSender.sendBroadcast(destination, MessageType.SYSTEM_NOTICE, systemNoticeResponse); } public RoomListResponse getAllRooms() { @@ -243,6 +291,11 @@ public RoomListResponse getAllRooms() { return new RoomListResponse(roomResponses); } + @DistributedLock(prefix = "room", key = "#roomId") + public void reconnectSendResponseWithLock(Long roomId, UserPrincipal principal) { + reconnectSendResponse(roomId, principal); + } + public void reconnectSendResponse(Long roomId, UserPrincipal principal) { Room room = findRoom(roomId); @@ -301,6 +354,11 @@ public void reconnectSendResponse(Long roomId, UserPrincipal principal) { } } + @DistributedLock(prefix = "room", key = "#roomId") + public void changeConnectedStatusWithLock(Long roomId, Long userId, ConnectionState newState) { + changeConnectedStatus(roomId, userId, newState); + } + public void changeConnectedStatus(Long roomId, Long userId, ConnectionState newState) { Room room = findRoom(roomId); room.updatePlayerConnectionState(userId, newState); @@ -335,10 +393,13 @@ public Room findRoom(Long roomId) { .orElseThrow(() -> new CustomException(RoomErrorCode.ROOM_NOT_FOUND)); } + public boolean existsRoom(Long roomId) { + return roomRepository.findRoom(roomId).isPresent(); + } + private void removeRoom(Room room) { Long roomId = room.getId(); roomRepository.removeRoom(roomId); - roomLocks.remove(roomId); log.info("{}번 방 삭제", roomId); } @@ -357,25 +418,34 @@ private void changeHost(Room room, Player host) { } public void exitRoomForDisconnectedPlayer(Long roomId, Player player) { - - Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object()); - - synchronized (lock) { - // 연결 끊긴 플레이어 exit 로직 타게 해주기 - Room room = findRoom(roomId); - - cleanRoom(room, player); - - String destination = getDestination(roomId); - - SystemNoticeResponse systemNoticeResponse = - ofPlayerEvent(player.nickname, RoomEventType.EXIT); - - messageSender.sendBroadcast( - destination, MessageType.SYSTEM_NOTICE, systemNoticeResponse); - messageSender.sendBroadcast( - destination, MessageType.PLAYER_LIST, toPlayerListResponse(room)); - } + lockExecutor.executeWithLock( + USER_LOCK_PREFIX, + player.getId(), + () -> { + lockExecutor.executeWithLock( + ROOM_LOCK_PREFIX, + roomId, + () -> { + // 연결 끊긴 플레이어 exit 로직 타게 해주기 + Room room = findRoom(roomId); + + cleanRoom(room, player); + + String destination = getDestination(roomId); + + SystemNoticeResponse systemNoticeResponse = + ofPlayerEvent(player.nickname, RoomEventType.EXIT); + + messageSender.sendBroadcast( + destination, + MessageType.SYSTEM_NOTICE, + systemNoticeResponse); + messageSender.sendBroadcast( + destination, + MessageType.PLAYER_LIST, + toPlayerListResponse(room)); + }); + }); } private void cleanRoom(Room room, Player player) { @@ -417,11 +487,29 @@ public void removeUserRepository(Long userId, Long roomId) { userRoomRepository.removeUser(userId, roomId); } + @DistributedLock(prefix = "user", key = "#userId") public boolean isUserInAnyRoom(Long userId) { return userRoomRepository.isUserInAnyRoom(userId); } - public Long getRoomIdByUserId(Long userId) { + private Long getRoomIdByUserId(Long userId) { return userRoomRepository.getRoomId(userId); } + + @DistributedLock(prefix = "user", key = "#userId") + public Long getRoomIdByUserIdWithLock(Long userId) { + return userRoomRepository.getRoomId(userId); + } + + public void addSessionRoomId(String sessionId, Long roomId) { + sessionRoomMap.put(sessionId, roomId); + } + + public Long getRoomIdBySessionId(String sessionId) { + return sessionRoomMap.get(sessionId); + } + + public void removeSessionRoomId(String sessionId) { + sessionRoomMap.remove(sessionId); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/HeartbeatMonitor.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/HeartbeatMonitor.java index 7fbcada3..a3d10332 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/websocket/HeartbeatMonitor.java +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/HeartbeatMonitor.java @@ -1,24 +1,28 @@ package io.f1.backend.domain.game.websocket; +import static io.f1.backend.domain.game.app.RoomService.ROOM_LOCK_PREFIX; +import static io.f1.backend.domain.game.app.RoomService.USER_LOCK_PREFIX; import static io.f1.backend.domain.game.websocket.WebSocketUtils.getUserDestination; import io.f1.backend.domain.game.app.RoomService; import io.f1.backend.domain.game.dto.MessageType; import io.f1.backend.domain.game.dto.response.HeartbeatResponse; +import io.f1.backend.domain.user.dto.UserPrincipal; +import io.f1.backend.global.lock.LockExecutor; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.simp.user.SimpSession; import org.springframework.messaging.simp.user.SimpUser; import org.springframework.messaging.simp.user.SimpUserRegistry; import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.stereotype.Component; +import java.security.Principal; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -@Slf4j @Component @RequiredArgsConstructor public class HeartbeatMonitor { @@ -32,6 +36,7 @@ public class HeartbeatMonitor { private final MessageSender messageSender; private final RoomService roomService; private final SimpUserRegistry simpUserRegistry; + private final LockExecutor lockExecutor; @Scheduled(fixedDelay = HEARTBEAT_CHECK_INTERVAL_MS) public void monitorClientHeartbeat() { @@ -58,25 +63,34 @@ private void handleSessionHeartbeat(SimpUser user, SimpSession session) { new HeartbeatResponse(DIRECTION), user.getName()); - // todo FE 개발 될때까지 주석 처리 - // missedPongCounter.merge(sessionId, 1, Integer::sum); - // int missedCnt = missedPongCounter.get(sessionId); - // - // /* max_missed_heartbeats 이상 pong 이 안왔을때 - disconnect 처리 */ - // if (missedCnt >= MAX_MISSED_HEARTBEATS) { - // - // Principal principal = user.getPrincipal(); - // - // if (principal instanceof UsernamePasswordAuthenticationToken token - // && token.getPrincipal() instanceof UserPrincipal userPrincipal) { - // - // Long userId = userPrincipal.getUserId(); - // Long roomId = roomService.getRoomIdByUserId(userId); - // - // roomService.disconnectOrExitRoom(roomId, userPrincipal); - // } - // cleanSession(sessionId); - // } + missedPongCounter.merge(sessionId, 1, Integer::sum); + int missedCnt = missedPongCounter.get(sessionId); + + /* max_missed_heartbeats 이상 pong 이 안왔을때 - disconnect 처리 */ + if (missedCnt >= MAX_MISSED_HEARTBEATS) { + + Principal principal = user.getPrincipal(); + + if (principal instanceof UsernamePasswordAuthenticationToken token + && token.getPrincipal() instanceof UserPrincipal userPrincipal) { + + Long userId = userPrincipal.getUserId(); + Long roomId = roomService.getRoomIdByUserIdWithLock(userId); + + lockExecutor.executeWithLock( + USER_LOCK_PREFIX, + userId, + () -> { + lockExecutor.executeWithLock( + ROOM_LOCK_PREFIX, + roomId, + () -> { + roomService.disconnectOrExitRoom(roomId, userPrincipal); + }); + }); + } + cleanSession(sessionId); + } } public void resetMissedPongCount(String sessionId) { diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/GameSocketController.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/GameSocketController.java index 4fa86d61..a07842a3 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/GameSocketController.java +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/GameSocketController.java @@ -1,5 +1,6 @@ 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; import io.f1.backend.domain.game.app.ChatService; @@ -33,14 +34,17 @@ public void initializeRoomSocket(@DestinationVariable Long roomId, Message me UserPrincipal principal = getSessionUser(message); roomService.initializeRoomSocket(roomId, principal); + roomService.addSessionRoomId(getSessionId(message), roomId); } @MessageMapping("/room/reconnect/{roomId}") public void reconnect(@DestinationVariable Long roomId, Message message) { UserPrincipal principal = getSessionUser(message); - roomService.changeConnectedStatus(roomId, principal.getUserId(), ConnectionState.CONNECTED); - roomService.reconnectSendResponse(roomId, principal); + roomService.changeConnectedStatusWithLock( + roomId, principal.getUserId(), ConnectionState.CONNECTED); + roomService.reconnectSendResponseWithLock(roomId, principal); + roomService.addSessionRoomId(getSessionId(message), roomId); } @MessageMapping("/room/exit/{roomId}") @@ -48,7 +52,7 @@ public void exitRoom(@DestinationVariable Long roomId, Message message) { UserPrincipal principal = getSessionUser(message); - roomService.exitRoom(roomId, principal); + roomService.exitRoomWithLock(roomId, principal); } @MessageMapping("/room/start/{roomId}") diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/HeartbeatController.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/HeartbeatController.java index f90c0fde..3f59a4e7 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/HeartbeatController.java +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/HeartbeatController.java @@ -20,7 +20,6 @@ public class HeartbeatController { public void handlePong(Message message) { String sessionId = getSessionId(message); - // todo FE 개발 될때까지 주석 처리 - // heartbeatMonitor.resetMissedPongCount(sessionId); + heartbeatMonitor.resetMissedPongCount(sessionId); } } 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 index 192c7ff6..9b25f624 100644 --- 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 @@ -1,5 +1,7 @@ package io.f1.backend.domain.game.websocket.eventlistener; +import static io.f1.backend.domain.game.app.RoomService.ROOM_LOCK_PREFIX; +import static io.f1.backend.domain.game.app.RoomService.USER_LOCK_PREFIX; import static io.f1.backend.domain.game.websocket.WebSocketUtils.getSessionUser; import io.f1.backend.domain.game.app.RoomService; @@ -7,6 +9,7 @@ import io.f1.backend.domain.game.websocket.DisconnectTaskManager; import io.f1.backend.domain.game.websocket.HeartbeatMonitor; import io.f1.backend.domain.user.dto.UserPrincipal; +import io.f1.backend.global.lock.LockExecutor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,6 +27,7 @@ public class WebsocketEventListener { private final RoomService roomService; private final DisconnectTaskManager taskManager; private final HeartbeatMonitor heartbeatMonitor; + private final LockExecutor lockExecutor; @EventListener public void handleDisconnectedListener(SessionDisconnectEvent event) { @@ -32,26 +36,45 @@ public void handleDisconnectedListener(SessionDisconnectEvent event) { UserPrincipal principal = getSessionUser(message); Long userId = principal.getUserId(); + String sessionId = event.getSessionId(); - // todo FE 개발 될때까지 주석 처리 - // heartbeatMonitor.cleanSession(event.getSessionId()); + heartbeatMonitor.cleanSession(sessionId); + Long roomId = roomService.getRoomIdBySessionId(sessionId); + roomService.removeSessionRoomId(sessionId); /* 정상 로직 */ if (!roomService.isUserInAnyRoom(userId)) { return; } - Long roomId = roomService.getRoomIdByUserId(userId); + if (!roomService.existsRoom(roomId)) { + return; + } - roomService.changeConnectedStatus(roomId, userId, ConnectionState.DISCONNECTED); + lockExecutor.executeWithLock( + ROOM_LOCK_PREFIX, + roomId, + () -> + roomService.changeConnectedStatus( + roomId, userId, ConnectionState.DISCONNECTED)); taskManager.scheduleDisconnectTask( userId, () -> { - if (ConnectionState.DISCONNECTED.equals( - roomService.getPlayerState(userId, roomId))) { - roomService.disconnectOrExitRoom(roomId, principal); - } + lockExecutor.executeWithLock( + USER_LOCK_PREFIX, + userId, + () -> { + lockExecutor.executeWithLock( + ROOM_LOCK_PREFIX, + roomId, + () -> { + if (ConnectionState.DISCONNECTED.equals( + roomService.getPlayerState(userId, roomId))) { + roomService.disconnectOrExitRoom(roomId, principal); + } + }); + }); }); } } diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java index 2ec59944..a171ab1c 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java @@ -13,7 +13,8 @@ public enum CommonErrorCode implements ErrorCode { INTERNAL_SERVER_ERROR( "E500001", HttpStatus.INTERNAL_SERVER_ERROR, "서버에러가 발생했습니다. 관리자에게 문의해주세요."), INVALID_JSON_FORMAT("E400008", HttpStatus.BAD_REQUEST, "요청 형식이 올바르지 않습니다. JSON 문법을 확인해주세요."), - LOCK_ACQUISITION_FAILED("E409003", HttpStatus.CONFLICT, "다른 요청이 작업 중입니다. 잠시 후 다시 시도해주세요."); + LOCK_ACQUISITION_FAILED("E409003", HttpStatus.CONFLICT, "다른 요청이 작업 중입니다. 잠시 후 다시 시도해주세요."), + LOCK_INTERRUPTED("E500004", HttpStatus.INTERNAL_SERVER_ERROR, "작업 중 락 획득이 중단되었습니다. 다시 시도해주세요."); private final String code; diff --git a/backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java b/backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java index c5da75e0..c6f39a83 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java +++ b/backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java @@ -5,6 +5,8 @@ import io.f1.backend.global.exception.errorcode.ErrorCode; import io.f1.backend.global.exception.response.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; + import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -28,8 +30,13 @@ public ResponseEntity handleCustomException(CustomException e) { } @ExceptionHandler(Exception.class) - public ResponseEntity handleException(Exception e) { + public ResponseEntity handleException(Exception e, HttpServletRequest request) { log.warn("handleException: {}", e.getMessage()); + + if ("text/event-stream".equals(request.getHeader("Accept"))) { + return ResponseEntity.noContent().build(); + } + CommonErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR; ErrorResponse response = new ErrorResponse(errorCode.getCode(), errorCode.getMessage()); diff --git a/backend/src/main/java/io/f1/backend/global/lock/DistributedLockAspect.java b/backend/src/main/java/io/f1/backend/global/lock/DistributedLockAspect.java index f0a3f96b..a363656e 100644 --- a/backend/src/main/java/io/f1/backend/global/lock/DistributedLockAspect.java +++ b/backend/src/main/java/io/f1/backend/global/lock/DistributedLockAspect.java @@ -20,7 +20,7 @@ @RequiredArgsConstructor public class DistributedLockAspect { - private static final String LOCK_KEY_FORMAT = "lock:%s:{%s}"; + public static final String LOCK_KEY_FORMAT = "lock:%s:{%s}"; private final RedissonClient redissonClient; diff --git a/backend/src/main/java/io/f1/backend/global/lock/LockExecutor.java b/backend/src/main/java/io/f1/backend/global/lock/LockExecutor.java new file mode 100644 index 00000000..b743d438 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/lock/LockExecutor.java @@ -0,0 +1,73 @@ +package io.f1.backend.global.lock; + +import static io.f1.backend.global.lock.DistributedLockAspect.LOCK_KEY_FORMAT; + +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.CommonErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LockExecutor { + + private final RedissonClient redissonClient; + + // 시간단위를 초로 변경 + private static final TimeUnit DEFAULT_TIME_UNIT = TimeUnit.SECONDS; + + // 락 점유를 위한 대기 시간 + private static final long DEFAULT_WAIT_TIME = 5L; + + // 락 점유 시간 + private static final long DEFAULT_LEASE_TIME = 3L; + + public T executeWithLock(String prefix, Object key, Supplier supplier) { + String lockKey = formatLockKey(prefix, key); + RLock rlock = redissonClient.getLock(lockKey); + + boolean acquired = false; + try { + acquired = rlock.tryLock(DEFAULT_WAIT_TIME, DEFAULT_LEASE_TIME, DEFAULT_TIME_UNIT); + + if (!acquired) { + log.warn("[LockExecutor] Lock acquisition failed: {}", key); + throw new CustomException(CommonErrorCode.LOCK_ACQUISITION_FAILED); + } + log.info("[LockExecutor] Lock acquired: {}", key); + + return supplier.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CustomException(CommonErrorCode.LOCK_INTERRUPTED); + } finally { + if (acquired && rlock.isHeldByCurrentThread()) { + rlock.unlock(); + log.info("[LockExecutor] Lock released: {}", key); + } + } + } + + public void executeWithLock(String prefix, Object key, Runnable runnable) { + executeWithLock( + prefix, + key, + () -> { + runnable.run(); + return null; + }); + } + + private String formatLockKey(String prefix, Object value) { + return String.format(LOCK_KEY_FORMAT, prefix, value); + } +} diff --git a/backend/src/test/java/io/f1/backend/domain/game/app/GameFlowTests.java b/backend/src/test/java/io/f1/backend/domain/game/app/GameFlowTests.java index f9ef482c..be8b1b91 100644 --- a/backend/src/test/java/io/f1/backend/domain/game/app/GameFlowTests.java +++ b/backend/src/test/java/io/f1/backend/domain/game/app/GameFlowTests.java @@ -222,7 +222,7 @@ static class TestRoomService extends RoomService { private final Map rooms = new ConcurrentHashMap<>(); public TestRoomService() { - super(null, null, null, null, null, null); + super(null, null, null, null, null, null, null); } @Override 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 64436cb8..7053151c 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,8 +1,13 @@ package io.f1.backend.domain.game.app; -import static org.mockito.Mockito.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import io.f1.backend.domain.game.dto.request.RoomValidationRequest; import io.f1.backend.domain.game.model.GameSetting; import io.f1.backend.domain.game.model.Player; import io.f1.backend.domain.game.model.Room; @@ -12,205 +17,550 @@ import io.f1.backend.domain.game.websocket.DisconnectTaskManager; import io.f1.backend.domain.game.websocket.MessageSender; import io.f1.backend.domain.quiz.app.QuizService; +import io.f1.backend.domain.quiz.dto.QuizMinData; +import io.f1.backend.domain.quiz.entity.Quiz; import io.f1.backend.domain.user.dto.UserPrincipal; import io.f1.backend.domain.user.entity.User; -import io.f1.backend.global.security.util.SecurityUtils; +import io.f1.backend.global.config.RedisTestContainerConfig; +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.RoomErrorCode; +import io.f1.backend.global.lock.LockExecutor; import lombok.extern.slf4j.Slf4j; -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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.util.ReflectionTestUtils; -import java.lang.reflect.Field; import java.time.LocalDateTime; +import java.util.AbstractMap; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; @Slf4j -@ExtendWith(MockitoExtension.class) -class RoomServiceTests { +@SpringBootTest +@Import({RedisTestContainerConfig.class}) // Redis Testcontainers 설정 임포트 +class RoomServiceConcurrentTest { - private RoomService roomService; + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", RedisTestContainerConfig.redisContainer::getHost); + registry.add( + "spring.data.redis.port", + () -> RedisTestContainerConfig.redisContainer.getFirstMappedPort()); + } - @Mock private RoomRepository roomRepository; @Mock private QuizService quizService; - @Mock private UserRoomRepository userRoomRepository; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private DisconnectTaskManager disconnectTasks; @Mock private MessageSender messageSender; - @Mock private DisconnectTaskManager disconnectTaskManager; + + // RoomRepository와 UserRoomRepository는 실제 Map 기반 구현체를 사용 + private RoomRepository roomRepository; + private UserRoomRepository userRoomRepository; + + @Autowired private LockExecutor lockExecutor; + + @InjectMocks private RoomService roomService; @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); // @Mock 어노테이션이 붙은 필드들을 초기화합니다. - - roomService = - new RoomService( - quizService, - roomRepository, - userRoomRepository, - eventPublisher, - disconnectTaskManager, - messageSender); - - SecurityContextHolder.clearContext(); - } + MockitoAnnotations.openMocks(this); + + // RoomRepository 및 UserRoomRepository의 인메모리 구현체 초기화 + this.roomRepository = new InMemoryRoomRepository(); + this.userRoomRepository = new UserRoomRepository(); - @AfterEach - void afterEach() { - SecurityContextHolder.clearContext(); + // RoomService에 실제 구현체 주입 + ReflectionTestUtils.setField(roomService, "roomRepository", roomRepository); + ReflectionTestUtils.setField(roomService, "userRoomRepository", userRoomRepository); + ReflectionTestUtils.setField( + roomService, "roomIdGenerator", new AtomicLong(0)); // ID 생성기 초기화 + ReflectionTestUtils.setField(roomService, "lockExecutor", lockExecutor); + ReflectionTestUtils.setField(roomService, "quizService", quizService); + + Quiz dummyQuiz = mock(Quiz.class); + when(dummyQuiz.getId()).thenReturn(1L); + when(quizService.getQuizWithQuestionsById(anyLong())).thenReturn(dummyQuiz); + when(quizService.getQuizMinData()).thenReturn(new QuizMinData(1L, 10L)); + doNothing().when(eventPublisher).publishEvent(any()); + doNothing().when(disconnectTasks).cancelDisconnectTask(any(Long.class)); + doNothing().when(messageSender).sendPersonal(any(), any(), any(), any()); + doNothing().when(messageSender).sendBroadcast(any(), any(), any()); } - // @Test - // @DisplayName("enterRoom_동시성_테스트") - // void enterRoom_synchronized() throws Exception { - // Long roomId = 1L; - // Long quizId = 1L; - // Long playerId = 1L; - // int maxUserCount = 5; - // String password = "123"; - // boolean locked = true; - // - // Room room = createRoom(roomId, playerId, quizId, password, maxUserCount, locked); - // - // when(roomRepository.findRoom(roomId)).thenReturn(Optional.of(room)); - // - // int threadCount = 10; - // ExecutorService executorService = Executors.newFixedThreadPool(threadCount); - // CountDownLatch countDownLatch = new CountDownLatch(threadCount); - // RoomValidationRequest roomValidationRequest = new RoomValidationRequest(roomId, - // password); - // - // for (int i = 1; i <= threadCount; i++) { - // User user = createUser(i); - // when(userRoomRepository.getRoomId(user.getId())).thenReturn(null); - // executorService.submit( - // () -> { - // try { - // SecurityUtils.setAuthentication(user); - // - // roomService.enterRoom(roomValidationRequest); - // } catch (Exception e) { - // //e.printStackTrace(); - // } finally { - // SecurityContextHolder.clearContext(); - // countDownLatch.countDown(); - // } - // }); - // } - // countDownLatch.await(); - // assertThat(room.getCurrentUserCnt()).isEqualTo(room.getRoomSetting().maxUserCount()); - // } + // --- 테스트 시나리오 시작 --- @Test - @DisplayName("exitRoom_동시성_테스트") - void exitRoom_synchronized() throws Exception { + @DisplayName("다수의 사용자가 동시 입장 시도 시 데드락 없이 정합성 유지") + void enterRoom_concurrently_noDeadlockAndConsistency() throws InterruptedException { + // Given Long roomId = 1L; - Long quizId = 1L; - Long playerId = 1L; - int maxUserCount = 5; - String password = "123"; - boolean locked = true; + int maxUserCount = 4; + Long hostId = 100L; + String hostNickname = "host"; + + // Room 생성 (호스트 포함 1명) + createAndSaveRoom(roomId, 100L, hostNickname, maxUserCount); - /* 방 생성 */ - Room room = createRoom(roomId, playerId, quizId, password, maxUserCount, locked); + int numConcurrentUsers = 10; // 동시 입장 시도할 사용자 수 + ExecutorService executorService = Executors.newFixedThreadPool(numConcurrentUsers); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch finishLatch = new CountDownLatch(numConcurrentUsers); - int threadCount = 10; + AtomicInteger successEntries = new AtomicInteger(0); + AtomicInteger failedEntries = new AtomicInteger(0); - List players = new ArrayList<>(); - for (int i = 1; i <= threadCount; i++) { - Long id = i + 1L; - String nickname = "nickname " + i; + // When + for (int i = 0; i < numConcurrentUsers; i++) { + long userId = (long) i + 101; // 호스트와 겹치지 않게 - Player player = new Player(id, nickname); - players.add(player); + UserPrincipal userPrincipal = createUserPrincipal(userId); + + RoomValidationRequest request = new RoomValidationRequest(roomId, null); + + executorService.submit( + () -> { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userPrincipal, null, Collections.emptyList()); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + + try { + startLatch.await(); // 모든 스레드가 동시에 시작하도록 대기 + roomService.enterRoom(request); + successEntries.incrementAndGet(); + } catch (CustomException e) { + if (e.getErrorCode() == RoomErrorCode.ROOM_USER_LIMIT_REACHED) { + failedEntries.incrementAndGet(); + } else { + log.info( + "Unexpected CustomException in enterRoom for user {}: {}", + userPrincipal.getUserId(), + e.getMessage(), + e); + failedEntries.incrementAndGet(); + } + } catch (Exception e) { + log.info( + "Unhandled Exception in enterRoom for user {}: {}", + userPrincipal.getUserId(), + e.getMessage(), + e); // 여기에 예외 상세 로그 + failedEntries.incrementAndGet(); + } finally { + SecurityContextHolder.clearContext(); + finishLatch.countDown(); + } + }); } - Player host = players.getFirst(); - room.updateHost(host); - /* 방 입장 */ - for (int i = 1; i <= threadCount; i++) { - Player player = players.get(i - 1); - room.getPlayerMap().put(player.id, player); + startLatch.countDown(); // 모든 스레드 시작! + assertThat(finishLatch.await(50, TimeUnit.SECONDS)).isTrue(); // 모든 스레드 완료 대기 + + executorService.shutdown(); + assertThat(executorService.awaitTermination(1, TimeUnit.MINUTES)).isTrue(); // 데드락 방지 확인 + + // Then + // 호스트 1명 + 성공적인 입장 플레이어 = 최대 인원수 + assertThat(successEntries.get()).isEqualTo(maxUserCount - 1); + assertThat(failedEntries.get()).isEqualTo(numConcurrentUsers - (maxUserCount - 1)); + + Room finalRoom = roomRepository.findRoom(roomId).orElseThrow(); + assertThat(finalRoom.getCurrentUserCnt()).isEqualTo(maxUserCount); // 최종 인원수 확인 + assertThat(finalRoom.getPlayerMap().size()).isEqualTo(maxUserCount); // 플레이어 맵 사이즈 확인 + + for (long i = 0; i < numConcurrentUsers; i++) { + long userId = (long) i + 101; + if (userRoomRepository.isUserInAnyRoom(userId)) { + assertThat(userRoomRepository.getRoomId(userId)).isEqualTo(roomId); + } } + } - log.info("room.getPlayerSessionMap().size() = {}", room.getPlayerMap().size()); + @Test + @DisplayName("동일 유저가 여러 탭에서 동시 초기화 요청 시 데드락 없이 처리되고, 최종적으로 하나의 방에만 존재 (가장 마지막에 시도한 방)") + void initializeRoomSocket_concurrently_sameUser_noDeadlockAndConsistency() + throws InterruptedException { + // Given + Long roomId1 = 1L; + Long roomId2 = 2L; + Long testUserId = 1000L; // 테스트 대상 유저 ID + String nickname = "TestUser"; + int maxUserCount = 4; + + UserPrincipal userPrincipal = createUserPrincipal(testUserId); + + // 방 1 생성 + Room room1 = createAndSaveRoom(roomId1, 2000L, "Host1", maxUserCount); + + // 방 2 생성 + Room room2 = createAndSaveRoom(roomId2, 3000L, "Host2", maxUserCount); - when(roomRepository.findRoom(roomId)).thenReturn(Optional.of(room)); + Player testPlayer = createPlayer(testUserId, nickname); + roomRepository.findRoom(roomId1).orElseThrow().addPlayer(testPlayer); + userRoomRepository.addUser(testPlayer, room1); - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); - CountDownLatch countDownLatch = new CountDownLatch(threadCount); + int numAttempts = 5; // 각 방에 대한 동시 초기화 요청 횟수 + // 총 스레드 수는 numAttempts * 2 (room1에 대한 numAttempts, room2에 대한 numAttempts) + ExecutorService executorService = Executors.newFixedThreadPool(numAttempts * 2); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch finishLatch = new CountDownLatch(numAttempts * 2); - /* 방 퇴장 테스트 */ - for (int i = 1; i <= threadCount; i++) { - User user = createUser(i); + AtomicInteger successCount = new AtomicInteger(0); + // 가장 마지막에 성공적으로 초기화된 방의 ID를 추적 + List> successfulInitAttempts = + Collections.synchronizedList(new ArrayList<>()); + + // When + for (int i = 0; i < numAttempts; i++) { + // Room1 초기화 시도 executorService.submit( () -> { + setSecurityContext(userPrincipal); try { - UserPrincipal principal = - new UserPrincipal(user, Collections.emptyMap()); - SecurityUtils.setAuthentication(user); - log.info("room.getHost().getId() = {}", room.getHost().getId()); - roomService.exitRoom(roomId, principal); + startLatch.await(); + long callTimestamp = System.nanoTime(); // 호출 시작 시간 기록 + roomService.initializeRoomSocket(roomId1, userPrincipal); + successCount.incrementAndGet(); + successfulInitAttempts.add( + new AbstractMap.SimpleEntry<>(roomId1, callTimestamp)); } catch (Exception e) { + log.error( + "Room1 init failed for user {} in room {}: {}", + userPrincipal.getUserId(), + roomId1, + e.getMessage(), + e); + } finally { + SecurityContextHolder.clearContext(); + finishLatch.countDown(); + } + }); + // Room2 초기화 시도 + executorService.submit( + () -> { + setSecurityContext(userPrincipal); + try { + startLatch.await(); + long callTimestamp = System.nanoTime(); // 호출 시작 시간 기록 + roomService.initializeRoomSocket(roomId2, userPrincipal); + successCount.incrementAndGet(); + successfulInitAttempts.add( + new AbstractMap.SimpleEntry<>(roomId2, callTimestamp)); + } catch (Exception e) { + log.error( + "Room2 init failed for user {} in room {}: {}", + userPrincipal.getUserId(), + roomId2, + e.getMessage(), + e); } finally { SecurityContextHolder.clearContext(); - countDownLatch.countDown(); + finishLatch.countDown(); } }); } - countDownLatch.await(); - verify(roomRepository).removeRoom(roomId); + + startLatch.countDown(); + assertThat(finishLatch.await(50, TimeUnit.SECONDS)).isTrue(); // 시간 충분히 늘림 + executorService.shutdown(); + assertThat(executorService.awaitTermination(1, TimeUnit.MINUTES)).isTrue(); + + // Then + // 최종적으로 유저는 하나의 방에만 존재해야 함 + assertThat(userRoomRepository.isUserInAnyRoom(testUserId)).isTrue(); + Long finalRoomId = userRoomRepository.getRoomId(testUserId); + assertThat(finalRoomId).isNotNull(); + + // 마지막으로 성공적으로 initializeRoomSocket이 호출된 방을 찾음 + successfulInitAttempts.sort(Comparator.comparing(AbstractMap.SimpleEntry::getValue)); + Long expectedFinalRoomId = null; + if (!successfulInitAttempts.isEmpty()) { + expectedFinalRoomId = + successfulInitAttempts.get(successfulInitAttempts.size() - 1).getKey(); + } + + // 최종적으로 저장된 방 ID가 가장 마지막에 성공적으로 시도된 방 ID와 일치하는지 검증 + assertThat(finalRoomId) + .as("Final room must be the one from the last successful initialization attempt") + .isEqualTo(expectedFinalRoomId); + + // 각 방의 상태 검증 + Room finalRoom1 = roomRepository.findRoom(roomId1).orElse(null); + Room finalRoom2 = roomRepository.findRoom(roomId2).orElse(null); + + // 마지막 방에만 유저가 남아있는지 확인 + if (finalRoomId.equals(roomId1)) { + assertThat(finalRoom1).isNotNull(); + assertThat(finalRoom1.hasPlayer(testUserId)).isTrue(); + // 다른 방에는 유저가 없어야 함 + assertThat(finalRoom2 == null || !finalRoom2.hasPlayer(testUserId)).isTrue(); + // 룸 카운트도 갱신되었는지 확인 (호스트 + 최종 유저) + assertThat(finalRoom1.getCurrentUserCnt()).isEqualTo(2); // 호스트1 + 테스트유저1 + } else if (finalRoomId.equals(roomId2)) { + assertThat(finalRoom2).isNotNull(); + assertThat(finalRoom2.hasPlayer(testUserId)).isTrue(); + // 다른 방에는 유저가 없어야 함 + assertThat(finalRoom1 == null || !finalRoom1.hasPlayer(testUserId)).isTrue(); + // 룸 카운트도 갱신되었는지 확인 (호스트 + 최종 유저) + assertThat(finalRoom2.getCurrentUserCnt()).isEqualTo(2); // 호스트1 + 테스트유저1 + } else { + // 이 경우는 발생해서는 안 됨 (userId가 두 방 중 하나에만 있어야 하므로) + assertThat(false).as("User must be in either room1 or room2").isTrue(); + } } - private Room createRoom( - Long roomId, - Long playerId, - Long quizId, - String password, - int maxUserCount, - boolean locked) { - RoomSetting roomSetting = new RoomSetting("방제목", maxUserCount, locked, password); - GameSetting gameSetting = new GameSetting(quizId, 10, 60); - Player host = new Player(playerId, "nickname"); - - return new Room(roomId, roomSetting, gameSetting, host); + @Test + @DisplayName("다수의 사용자가 동시 입장/나가기 시도 시 데드락 없이 정합성 유지") + void enterAndExit_concurrently_noDeadlockAndConsistency() throws InterruptedException { + // Given + Long roomId = 1L; + Long hostId = 100L; + int maxUserCount = 4; + + // Room 생성 (호스트 포함 1명) + createAndSaveRoom(roomId, hostId, "Host", maxUserCount); + + int numUsers = 5; + ExecutorService executorService = Executors.newFixedThreadPool(numUsers * 2); // 입장과 나가기 스레드 + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch finishLatch = new CountDownLatch(numUsers * 2); + + // When + for (int i = 0; i < numUsers; i++) { + long userId = (long) i + 201; + UserPrincipal userPrincipal = createUserPrincipal(userId); + RoomValidationRequest enterRequest = new RoomValidationRequest(roomId, null); + + // 입장 스레드 + executorService.submit( + () -> { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userPrincipal, null, Collections.emptyList()); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + try { + startLatch.await(); + roomService.enterRoom(enterRequest); + } catch (Exception e) { + // 예상되는 예외: ROOM_USER_LIMIT_REACHED + } finally { + SecurityContextHolder.clearContext(); + finishLatch.countDown(); + } + }); + + // 나가기 스레드 (입장 시도 후 바로 나가기 시도) + executorService.submit( + () -> { + setSecurityContext(userPrincipal); + try { + startLatch.await(); + roomService.exitRoomWithLock(roomId, userPrincipal); + } catch (Exception e) { + // 예상되는 예외: USER_NOT_FOUND (아직 입장하지 못했을 때) + } finally { + SecurityContextHolder.clearContext(); + finishLatch.countDown(); + } + }); + } + + startLatch.countDown(); + assertThat(finishLatch.await(20, TimeUnit.SECONDS)).isTrue(); + executorService.shutdown(); + assertThat(executorService.awaitTermination(1, TimeUnit.MINUTES)).isTrue(); + + // Then + // 최종적으로 호스트만 남아있거나, 소수의 플레이어가 남아있을 수 있음 + // 중요한 것은 데드락 없이 완료되고 시스템이 불안정한 상태가 되지 않는 것 + Room finalRoom = roomRepository.findRoom(roomId).orElseThrow(); + assertThat(finalRoom.hasPlayer(hostId)).isTrue(); // 호스트는 남아있어야 함 + // 추가적인 플레이어의 수는 동시성 상황에 따라 유동적일 수 있음. + // 예를 들어, 어떤 유저가 입장 직후 바로 나가는 데 성공하면 0이 될 수도 있고, + // 어떤 유저가 입장만 성공하고 나가기는 실패할 수도 있음. + // 여기서는 데드락이 없고, 불필요한 예외가 발생하지 않으며, 최소한의 정합성(호스트 존재)만 확인. + System.out.println( + "Final user count in room " + roomId + ": " + finalRoom.getCurrentUserCnt()); + } + + @Test + @DisplayName("연결 끊긴 플레이어 처리 로직과 사용자 직접 나가기 로직 동시 호출 시 데드락 없음") + void disconnectAndExit_concurrently_noDeadlock() throws InterruptedException { + // Given + Long roomId = 1L; + Long hostId = 100L; + Long disconnectedUserId = 200L; + Long exitingUserId = 300L; + + // 방 생성 및 플레이어 추가 + int maxUserCount = 4; + + // Room 생성 + // 방 생성 및 플레이어 추가 - 헬퍼 메서드 사용 + Room room = createAndSaveRoom(roomId, hostId, "Host", maxUserCount); + Player disconnectedPlayer = + createPlayer(disconnectedUserId, "DisconnectedUser"); // 헬퍼 메서드 사용 + Player exitingPlayer = createPlayer(exitingUserId, "ExitingUser"); // 헬퍼 메서드 사용 + + room.addPlayer(disconnectedPlayer); + room.addPlayer(exitingPlayer); + + // UserRoomRepository 매핑 + userRoomRepository.addUser(disconnectedPlayer, room); + userRoomRepository.addUser(exitingPlayer, room); + + // disconnectOrExitRoom 호출 시 changeConnectedStatus를 간접적으로 테스트하기 위해 Mock 설정 + // 이 부분은 room.updatePlayerConnectionState를 직접 호출하므로 Mocking 불필요. + // userRoomRepository.removeUser는 In-memory 구현체를 사용하므로 Mocking 불필요. + + int numConcurrentCalls = 5; + ExecutorService executorService = Executors.newFixedThreadPool(numConcurrentCalls * 2); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch finishLatch = new CountDownLatch(numConcurrentCalls * 2); + + // When + for (int i = 0; i < numConcurrentCalls; i++) { + long userId = (long) i + 201; + UserPrincipal disconnectedUserPrincipal = createUserPrincipal(disconnectedUserId); + // disconnectOrExitRoom (플레이어 연결 끊김 시뮬레이션) + executorService.submit( + () -> { + setSecurityContext(disconnectedUserPrincipal); + try { + startLatch.await(); + roomService.disconnectOrExitRoom(roomId, disconnectedUserPrincipal); + } catch (Exception e) { + System.err.println("Disconnect error: " + e.getMessage()); + } finally { + SecurityContextHolder.clearContext(); + finishLatch.countDown(); + } + }); + + UserPrincipal exitingUserPrincipal = createUserPrincipal(exitingUserId); + + // exitRoomWithLock (사용자 직접 나가기 시뮬레이션) + executorService.submit( + () -> { + setSecurityContext(exitingUserPrincipal); + try { + startLatch.await(); + roomService.exitRoomWithLock(roomId, exitingUserPrincipal); + } catch (Exception e) { + System.err.println("Exit error: " + e.getMessage()); + } finally { + SecurityContextHolder.clearContext(); + finishLatch.countDown(); + } + }); + } + + startLatch.countDown(); + assertThat(finishLatch.await(20, TimeUnit.SECONDS)).isTrue(); + executorService.shutdown(); + assertThat(executorService.awaitTermination(1, TimeUnit.MINUTES)).isTrue(); + + // Then + Room finalRoom = roomRepository.findRoom(roomId).orElseThrow(); + assertThat(finalRoom.hasPlayer(hostId)).isTrue(); // 호스트는 남아있어야 함 + + // disconnectedUser와 exitingUser는 최종적으로 방에서 나가져야 함 + assertThat(finalRoom.hasPlayer(disconnectedUserId)).isFalse(); + assertThat(finalRoom.hasPlayer(exitingUserId)).isFalse(); + + assertThat(userRoomRepository.isUserInAnyRoom(disconnectedUserId)).isFalse(); + assertThat(userRoomRepository.isUserInAnyRoom(exitingUserId)).isFalse(); + assertThat(userRoomRepository.isUserInAnyRoom(hostId)).isTrue(); // 호스트는 남아있어야 함 + + // 최종 방 인원수 확인 (호스트 1명만 남아야 함) + assertThat(finalRoom.getCurrentUserCnt()).isEqualTo(1); } - private User createUser(int i) { - Long userId = i + 1L; - String provider = "provider +" + i; - String providerId = "providerId" + i; - LocalDateTime lastLogin = LocalDateTime.now(); - - User user = - User.builder() - .provider(provider) - .providerId(providerId) - .lastLogin(lastLogin) - .build(); - - try { - Field idField = User.class.getDeclaredField("id"); - idField.setAccessible(true); - idField.set(user, userId); - } catch (Exception e) { - throw new RuntimeException("ID 설정 실패", e); + private Player createPlayer(Long userId, String nickname) { + return new Player(userId, nickname); + } + + private UserPrincipal createUserPrincipal(Long userId) { + User user = new User("kakao", "providerId_" + userId, LocalDateTime.now()); + ReflectionTestUtils.setField(user, "id", userId); + return new UserPrincipal(user, Collections.emptyMap()); + } + + private Room createAndSaveRoom( + Long roomId, Long hostId, String hostNickname, int maxUserCount) { + RoomSetting roomSetting = new RoomSetting("testRoom", maxUserCount, false, null); + Player host = createPlayer(hostId, hostNickname); + Room room = new Room(roomId, roomSetting, new GameSetting(1L, 10, 3), host); + room.addPlayer(host); + roomRepository.saveRoom(room); + userRoomRepository.addUser(host, room); + return room; + } + + private void setSecurityContext(UserPrincipal userPrincipal) { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userPrincipal, null, Collections.emptyList()); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + } + + // --- 인메모리 저장소 구현체 (테스트용) --- + + // RoomRepository의 인메모리 구현체 + private static class InMemoryRoomRepository implements RoomRepository { + private final Map rooms = new ConcurrentHashMap<>(); + + @Override + public void saveRoom(Room room) { + rooms.put(room.getId(), room); + } + + @Override + public Optional findRoom(Long roomId) { + return Optional.ofNullable(rooms.get(roomId)); } - return user; + @Override + public List findAll() { + return rooms.values().stream().toList(); + } + + @Override + public void removeRoom(Long roomId) { + rooms.remove(roomId); + } } } diff --git a/backend/src/test/java/io/f1/backend/global/lock/LockExecutorTest.java b/backend/src/test/java/io/f1/backend/global/lock/LockExecutorTest.java new file mode 100644 index 00000000..f2c71115 --- /dev/null +++ b/backend/src/test/java/io/f1/backend/global/lock/LockExecutorTest.java @@ -0,0 +1,197 @@ +package io.f1.backend.global.lock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.CommonErrorCode; + +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.redisson.api.RLock; +import org.redisson.api.RedissonClient; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +@ExtendWith(MockitoExtension.class) +class LockExecutorTest { + + @Mock private RedissonClient redissonClient; + + @Mock private RLock rlock; + + @InjectMocks private LockExecutor lockExecutor; + + private final String TEST_PREFIX = "room"; + private final Long TEST_ROOM_ID = 1L; + private final long WAIT_TIME = 5L; + private final long LEASE_TIME = 3L; + private final String EXPECTED_LOCK_KEY = "lock:" + TEST_PREFIX + ":{" + TEST_ROOM_ID + "}"; + private final String EXPECTED_RETURN_VALUE = "success"; + + @Test + @DisplayName("락 획득 성공 시 supplier 로직이 실행되고 락 해제됨") + void executeWithLock_successfulLock_supplier() throws Exception { + // given + + when(redissonClient.getLock(anyString())).thenReturn(rlock); + when(rlock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)).thenReturn(true); + when(rlock.isHeldByCurrentThread()).thenReturn(true); + + // when + String result = + lockExecutor.executeWithLock( + TEST_PREFIX, TEST_ROOM_ID, () -> EXPECTED_RETURN_VALUE); + + // then + assertEquals(EXPECTED_RETURN_VALUE, result); + verify(redissonClient).getLock(EXPECTED_LOCK_KEY); + verify(redissonClient, times(1)).getLock(EXPECTED_LOCK_KEY); + verify(rlock).unlock(); + } + + @Test + @DisplayName("락 획득 실패 시 CustomException(LOCK_ACQUISITION_FAILED)이 발생하는지 확인") + void executeWithLock_failToAcquireLock() throws Exception { + + when(redissonClient.getLock(anyString())).thenReturn(rlock); + when(rlock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)).thenReturn(false); + + // when & then + CustomException ex = + assertThrows( + CustomException.class, + () -> + lockExecutor.executeWithLock( + TEST_PREFIX, TEST_ROOM_ID, () -> "SHOULD_NOT_RUN")); + + assertEquals(CommonErrorCode.LOCK_ACQUISITION_FAILED, ex.getErrorCode()); + verify(redissonClient, times(1)).getLock(EXPECTED_LOCK_KEY); + verify(rlock, times(1)).tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS); + verify(rlock, never()).unlock(); + } + + @Test + @DisplayName("InterruptedException 발생 시 CustomException 발생 및 인터럽트 설정") + void executeWithLock_interruptedException() throws Exception { + + when(redissonClient.getLock(anyString())).thenReturn(rlock); + when(rlock.tryLock(5L, 3L, TimeUnit.SECONDS)).thenThrow(new InterruptedException()); + + // when & then + CustomException ex = + assertThrows( + CustomException.class, + () -> + lockExecutor.executeWithLock( + TEST_PREFIX, TEST_ROOM_ID, () -> "SHOULD_NOT_RUN")); + + verify(redissonClient, times(1)).getLock(EXPECTED_LOCK_KEY); + verify(rlock, times(1)).tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS); + assertEquals(CommonErrorCode.LOCK_INTERRUPTED, ex.getErrorCode()); + assertTrue(Thread.currentThread().isInterrupted()); + verify(rlock, never()).unlock(); + } + + @Test + @DisplayName("Runnable 버전 executeWithLock 정상 동작") + void executeWithLock_runnableVersion() throws Exception { + // given + AtomicBoolean executed = new AtomicBoolean(false); + + when(redissonClient.getLock(anyString())).thenReturn(rlock); + when(rlock.tryLock(5L, 3L, TimeUnit.SECONDS)).thenReturn(true); + when(rlock.isHeldByCurrentThread()).thenReturn(true); + + // when + lockExecutor.executeWithLock(TEST_PREFIX, TEST_ROOM_ID, () -> executed.set(true)); + + // then + verify(redissonClient, times(1)).getLock(EXPECTED_LOCK_KEY); + verify(rlock, times(1)).tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS); + assertTrue(executed.get()); + verify(rlock).unlock(); + } + + @Test + @DisplayName("락을 획득했지만 현재 스레드가 소유하지 않은 경우 unlock 하지 않음") + void executeWithLock_notHeldByCurrentThread_shouldNotUnlock() throws Exception { + // given + when(redissonClient.getLock(EXPECTED_LOCK_KEY)).thenReturn(rlock); + when(rlock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)).thenReturn(true); + when(rlock.isHeldByCurrentThread()).thenReturn(false); + + // when + String result = lockExecutor.executeWithLock(TEST_PREFIX, TEST_ROOM_ID, () -> "EXECUTED"); + + // then + verify(redissonClient, times(1)).getLock(EXPECTED_LOCK_KEY); + verify(rlock, times(1)).tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS); + assertEquals("EXECUTED", result); + verify(rlock, never()).unlock(); + } + + @Test + @DisplayName("락을 획득하지 않은 스레드가 unlock하지 않는지 확인") + void executeWithLock_lockNotAcquired_shouldNotUnlock() throws Exception { + // given + when(redissonClient.getLock(EXPECTED_LOCK_KEY)).thenReturn(rlock); + when(rlock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)).thenReturn(false); + + // when & then + CustomException ex = + assertThrows( + CustomException.class, + () -> + lockExecutor.executeWithLock( + TEST_PREFIX, + TEST_ROOM_ID, + () -> { + throw new IllegalStateException( + "Should not be executed"); + })); + + verify(redissonClient, times(1)).getLock(EXPECTED_LOCK_KEY); + verify(rlock, times(1)).tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS); + assertEquals(CommonErrorCode.LOCK_ACQUISITION_FAILED, ex.getErrorCode()); + verify(rlock, never()).unlock(); + } + + @Test + @DisplayName("메서드 실행 중 예외 발생 시에도 락이 정상적으로 해제되는지 확인") + void executeWithLock_exceptionThrown_shouldUnlock() throws Exception { + // given + when(redissonClient.getLock(EXPECTED_LOCK_KEY)).thenReturn(rlock); + when(rlock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)).thenReturn(true); + when(rlock.isHeldByCurrentThread()).thenReturn(true); + + // when & then + RuntimeException ex = + assertThrows( + RuntimeException.class, + () -> + lockExecutor.executeWithLock( + TEST_PREFIX, + TEST_ROOM_ID, + () -> { + throw new RuntimeException("exception"); + })); + + assertEquals("exception", ex.getMessage()); + + verify(redissonClient, times(1)).getLock(EXPECTED_LOCK_KEY); + verify(rlock, times(1)).tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS); + verify(rlock).unlock(); + } +}