diff --git a/backend/src/main/java/io/f1/backend/domain/game/api/RoomController.java b/backend/src/main/java/io/f1/backend/domain/game/api/RoomController.java index 79a245de..e492d754 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/api/RoomController.java +++ b/backend/src/main/java/io/f1/backend/domain/game/api/RoomController.java @@ -31,10 +31,10 @@ public RoomCreateResponse saveRoom(@RequestBody @Valid RoomCreateRequest request return roomService.saveRoom(request); } - @PostMapping("/validation") + @PostMapping("/enterRoom") @ResponseStatus(HttpStatus.NO_CONTENT) - public void validateRoom(@RequestBody @Valid RoomValidationRequest request) { - roomService.validateRoom(request); + public void enterRoom(@RequestBody @Valid RoomValidationRequest request) { + roomService.enterRoom(request); } @GetMapping 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 723c8355..27c1522c 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 @@ -33,15 +33,19 @@ import io.f1.backend.domain.quiz.entity.Quiz; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.boot.model.naming.IllegalIdentifierException; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +@Slf4j @Service @RequiredArgsConstructor public class RoomService { @@ -50,6 +54,8 @@ public class RoomService { private final RoomRepository roomRepository; private final AtomicLong roomIdGenerator = new AtomicLong(0); private final ApplicationEventPublisher eventPublisher; + private final Map roomLocks = new ConcurrentHashMap<>(); + private static final String PENDING_SESSION_ID = "PENDING_SESSION_ID"; public RoomCreateResponse saveRoom(RoomCreateRequest request) { @@ -66,6 +72,8 @@ public RoomCreateResponse saveRoom(RoomCreateRequest request) { Room room = new Room(newId, roomSetting, gameSetting, host); + room.getUserIdSessionMap().put(host.id, PENDING_SESSION_ID); + roomRepository.saveRoom(room); eventPublisher.publishEvent(new RoomCreatedEvent(room, quiz)); @@ -73,41 +81,53 @@ public RoomCreateResponse saveRoom(RoomCreateRequest request) { return new RoomCreateResponse(newId); } - public void validateRoom(RoomValidationRequest request) { + public void enterRoom(RoomValidationRequest request) { - Room room = - roomRepository - .findRoom(request.roomId()) - .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.-1")); + Long roomId = request.roomId(); - if (room.getState().equals(RoomState.PLAYING)) { - throw new IllegalArgumentException("403 게임이 진행중입니다."); - } + Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object()); - int maxUserCnt = room.getRoomSetting().maxUserCount(); - int currentCnt = room.getPlayerSessionMap().size(); - if (maxUserCnt == currentCnt) { - throw new IllegalArgumentException("403 정원이 모두 찼습니다."); - } + synchronized (lock) { + Room room = findRoom(request.roomId()); + + if (room.getState().equals(RoomState.PLAYING)) { + throw new IllegalArgumentException("403 게임이 진행중입니다."); + } + + int maxUserCnt = room.getRoomSetting().maxUserCount(); + int currentCnt = room.getUserIdSessionMap().size(); + if (maxUserCnt == currentCnt) { + throw new IllegalArgumentException("403 정원이 모두 찼습니다."); + } - if (room.getRoomSetting().locked() - && !room.getRoomSetting().password().equals(request.password())) { - throw new IllegalArgumentException("401 비밀번호가 일치하지 않습니다."); + if (room.getRoomSetting().locked() + && !room.getRoomSetting().password().equals(request.password())) { + throw new IllegalArgumentException("401 비밀번호가 일치하지 않습니다."); + } + + room.getUserIdSessionMap().put(getCurrentUserId(), PENDING_SESSION_ID); } } - public RoomInitialData enterRoom(Long roomId, String sessionId) { + public RoomInitialData initializeRoomSocket(Long roomId, String sessionId) { - Room room = - roomRepository - .findRoom(roomId) - .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.")); + Room room = findRoom(roomId); Player player = createPlayer(); 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); + } RoomSettingResponse roomSettingResponse = toRoomSettingResponse(room); @@ -130,42 +150,36 @@ public RoomInitialData enterRoom(Long roomId, String sessionId) { } public RoomExitData exitRoom(Long roomId, String sessionId) { - Room room = - roomRepository - .findRoom(roomId) - .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.")); - Map playerSessionMap = room.getPlayerSessionMap(); + Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object()); - String destination = getDestination(roomId); + synchronized (lock) { + Room room = findRoom(roomId); - if (playerSessionMap.size() == 1 && playerSessionMap.get(sessionId) != null) { - roomRepository.removeRoom(roomId); - return RoomExitData.builder().destination(destination).removedRoom(true).build(); - } + String destination = getDestination(roomId); - Player removedPlayer = playerSessionMap.remove(sessionId); - if (removedPlayer == null) { - throw new IllegalArgumentException("퇴장 처리 불가 - 404 해당 세션 플레이어는 존재하지않습니다."); - } + Player removePlayer = getRemovePlayer(room, sessionId); - if (room.getHost().getId().equals(removedPlayer.getId())) { - Optional nextHostSessionId = playerSessionMap.keySet().stream().findFirst(); - Player nextHost = - playerSessionMap.get( - nextHostSessionId.orElseThrow( - () -> - new IllegalArgumentException( - "방장 교체 불가 - 404 해당 세션 플레이어는 존재하지않습니다."))); - room.updateHost(nextHost); - } + /* 방 삭제 */ + if (isLastPlayer(room, sessionId)) { + return removeRoom(room, destination); + } - SystemNoticeResponse systemNoticeResponse = - ofPlayerEvent(removedPlayer, RoomEventType.EXIT); + /* 방장 변경 */ + if (room.isHost(removePlayer.getId())) { + changeHost(room, sessionId); + } - PlayerListResponse playerListResponse = toPlayerListResponse(room); + /* 플레이어 삭제 */ + removePlayer(room, sessionId, removePlayer); - return new RoomExitData(destination, playerListResponse, systemNoticeResponse, false); + SystemNoticeResponse systemNoticeResponse = + ofPlayerEvent(removePlayer, RoomEventType.EXIT); + + PlayerListResponse playerListResponse = toPlayerListResponse(room); + + return new RoomExitData(destination, playerListResponse, systemNoticeResponse, false); + } } public RoomListResponse getAllRooms() { @@ -183,11 +197,63 @@ public RoomListResponse getAllRooms() { return new RoomListResponse(roomResponses); } + private Player getRemovePlayer(Room room, String sessionId) { + Player removePlayer = room.getPlayerSessionMap().get(sessionId); + if (removePlayer == null) { + room.removeUserId(getCurrentUserId()); + throw new IllegalIdentifierException("404 세션 없음 비정상적인 퇴장 요청"); + } + return removePlayer; + } + private static String getDestination(Long roomId) { return "/sub/room/" + roomId; } - private static Player createPlayer() { + private Player createPlayer() { return new Player(getCurrentUserId(), getCurrentUserNickname()); } + + private Room findRoom(Long roomId) { + return roomRepository + .findRoom(roomId) + .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.")); + } + + private boolean isLastPlayer(Room room, String sessionId) { + Map playerSessionMap = room.getPlayerSessionMap(); + return playerSessionMap.size() == 1 && playerSessionMap.containsKey(sessionId); + } + + private RoomExitData removeRoom(Room room, String destination) { + Long roomId = room.getId(); + roomRepository.removeRoom(roomId); + roomLocks.remove(roomId); + log.info("{}번 방 삭제", roomId); + return RoomExitData.builder().destination(destination).removedRoom(true).build(); + } + + private void changeHost(Room room, String hostSessionId) { + Map playerSessionMap = room.getPlayerSessionMap(); + + Optional nextHostSessionId = + playerSessionMap.keySet().stream() + .filter(key -> !key.equals(hostSessionId)) + .findFirst(); + + Player nextHost = + playerSessionMap.get( + nextHostSessionId.orElseThrow( + () -> + new IllegalArgumentException( + "방장 교체 불가 - 404 해당 세션 플레이어는 존재하지않습니다."))); + + room.updateHost(nextHost); + log.info("user_id:{} 방장 변경 완료 ", nextHost.getId()); + } + + private void removePlayer(Room room, String sessionId, Player removePlayer) { + room.removeUserId(removePlayer.getId()); + room.removeSessionId(sessionId); + } } 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 3d3230c1..a5b1241c 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 @@ -19,4 +19,8 @@ public Player(Long id, String nickname) { this.id = id; this.nickname = nickname; } + + public void toggleReady() { + this.isReady = !this.isReady; + } } 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 00b7e71b..64310c3f 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 @@ -27,6 +27,8 @@ public class Room { private Map playerSessionMap = new ConcurrentHashMap<>(); + private Map userIdSessionMap = new ConcurrentHashMap<>(); + private final LocalDateTime createdAt = LocalDateTime.now(); public Room(Long id, RoomSetting roomSetting, GameSetting gameSetting, Player host) { @@ -36,7 +38,19 @@ public Room(Long id, RoomSetting roomSetting, GameSetting gameSetting, Player ho this.host = host; } + public boolean isHost(Long id) { + return this.host.getId().equals(id); + } + public void updateHost(Player nextHost) { this.host = nextHost; } + + public void removeUserId(Long id) { + this.userIdSessionMap.remove(id); + } + + public void removeSessionId(String sessionId) { + this.playerSessionMap.remove(sessionId); + } } 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/GameSocketController.java index 9bf2f17f..4cebfc09 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/GameSocketController.java @@ -20,12 +20,13 @@ public class GameSocketController { private final MessageSender messageSender; private final RoomService roomService; - @MessageMapping("/room/enter/{roomId}") - public void roomEnter(@DestinationVariable Long roomId, Message message) { + @MessageMapping("/room/initializeRoomSocket/{roomId}") + public void initializeRoomSocket(@DestinationVariable Long roomId, Message message) { String websocketSessionId = getSessionId(message); - RoomInitialData roomInitialData = roomService.enterRoom(roomId, websocketSessionId); + RoomInitialData roomInitialData = + roomService.initializeRoomSocket(roomId, websocketSessionId); String destination = roomInitialData.destination(); messageSender.send( 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 new file mode 100644 index 00000000..2c9df079 --- /dev/null +++ b/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java @@ -0,0 +1,190 @@ +package io.f1.backend.domain.game.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doNothing; +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; +import io.f1.backend.domain.game.model.RoomSetting; +import io.f1.backend.domain.game.store.RoomRepository; +import io.f1.backend.domain.quiz.app.QuizService; +import io.f1.backend.domain.user.entity.User; +import io.f1.backend.global.util.SecurityUtils; + +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.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Slf4j +@ExtendWith(MockitoExtension.class) +class RoomServiceTests { + + private RoomService roomService; + + @Mock private RoomRepository roomRepository; + @Mock private QuizService quizService; + @Mock private ApplicationEventPublisher eventPublisher; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); // @Mock 어노테이션이 붙은 필드들을 초기화합니다. + roomService = new RoomService(quizService, roomRepository, eventPublisher); + + SecurityContextHolder.clearContext(); + } + + @AfterEach + void afterEach() { + SecurityContextHolder.clearContext(); + } + + @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); + + executorService.submit( + () -> { + try { + SecurityUtils.setAuthentication(user); + roomService.enterRoom(roomValidationRequest); + } catch (Exception e) { + e.printStackTrace(); + } finally { + SecurityContextHolder.clearContext(); + countDownLatch.countDown(); + } + }); + } + countDownLatch.await(); + assertThat(room.getUserIdSessionMap()).hasSize(room.getRoomSetting().maxUserCount()); + } + + @Test + @DisplayName("exitRoom_동시성_테스트") + void exitRoom_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); + + int threadCount = 10; + + List players = new ArrayList<>(); + for (int i = 1; i <= threadCount; i++) { + Long id = i + 1L; + String nickname = "nickname " + i; + + Player player = new Player(id, nickname); + players.add(player); + } + Player host = players.getFirst(); + room.updateHost(host); + + for (int i = 1; i <= threadCount; i++) { + 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()); + + when(roomRepository.findRoom(roomId)).thenReturn(Optional.of(room)); + doNothing().when(roomRepository).removeRoom(roomId); + + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch countDownLatch = new CountDownLatch(threadCount); + + for (int i = 1; i <= threadCount; i++) { + String sessionId = "sessionId" + i; + User user = createUser(i); + executorService.submit( + () -> { + try { + SecurityUtils.setAuthentication(user); + log.info("room.getHost().getId() = {}", room.getHost().getId()); + roomService.exitRoom(roomId, sessionId); + } catch (Exception e) { + e.printStackTrace(); + } finally { + SecurityContextHolder.clearContext(); + countDownLatch.countDown(); + } + }); + } + countDownLatch.await(); + assertThat(room.getUserIdSessionMap()).hasSize(1); + } + + 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); + } + + 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(); + user.setId(userId); + + return user; + } +}