From d246abad3a6cd90916f8627845d3566f4bdf1160 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 28 Sep 2026 16:56:16 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=A0=84=ED=8C=8C=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=EB=84=88=20=EC=A0=84=EC=9A=A9=20executor=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 6 +++++- docker-compose.yml | 12 ++++++++++-- .../GameBoardMembersChangedEventListener.java | 2 +- .../GameCourtsManagedEventListener.java | 2 +- .../demo/global/config/AsyncConfig.java | 18 ++++++++++++++++++ src/main/resources/application.yml | 6 ++++++ .../demo/global/config/AsyncConfigTest.java | 8 ++++++++ 7 files changed, 49 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index e5fb2a7bc..e2bcbb551 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -77,7 +77,8 @@ jobs: CORS_ALLOWED_ORIGINS_PROD,CORS_ALLOWED_ORIGINS_STAGING,COOKIE_DOMAIN,NGINX_PROD_SERVER_NAME,NGINX_STAGING_SERVER_NAME, ASYNC_CHAT_CORE_SIZE,ASYNC_CHAT_MAX_SIZE,ASYNC_CHAT_QUEUE_CAPACITY, ASYNC_NOTIFICATION_INGRESS_CORE_SIZE,ASYNC_NOTIFICATION_INGRESS_MAX_SIZE,ASYNC_NOTIFICATION_INGRESS_QUEUE_CAPACITY, - ASYNC_NOTIFICATION_PUSH_CORE_SIZE,ASYNC_NOTIFICATION_PUSH_MAX_SIZE,ASYNC_NOTIFICATION_PUSH_QUEUE_CAPACITY + ASYNC_NOTIFICATION_PUSH_CORE_SIZE,ASYNC_NOTIFICATION_PUSH_MAX_SIZE,ASYNC_NOTIFICATION_PUSH_QUEUE_CAPACITY, + ASYNC_GAME_CORE_SIZE,ASYNC_GAME_MAX_SIZE,ASYNC_GAME_QUEUE_CAPACITY script: | chmod +x /home/ubuntu/cockple/scripts/deploy.sh bash /home/ubuntu/cockple/scripts/deploy.sh \ @@ -109,6 +110,9 @@ jobs: ASYNC_NOTIFICATION_PUSH_CORE_SIZE: ${{ vars.ASYNC_NOTIFICATION_PUSH_CORE_SIZE }} ASYNC_NOTIFICATION_PUSH_MAX_SIZE: ${{ vars.ASYNC_NOTIFICATION_PUSH_MAX_SIZE }} ASYNC_NOTIFICATION_PUSH_QUEUE_CAPACITY: ${{ vars.ASYNC_NOTIFICATION_PUSH_QUEUE_CAPACITY }} + ASYNC_GAME_CORE_SIZE: ${{ vars.ASYNC_GAME_CORE_SIZE }} + ASYNC_GAME_MAX_SIZE: ${{ vars.ASYNC_GAME_MAX_SIZE }} + ASYNC_GAME_QUEUE_CAPACITY: ${{ vars.ASYNC_GAME_QUEUE_CAPACITY }} # 미설정 시 빈 값 → docker-compose 기본값(현재 도메인) 사용(no-op). CORS_ALLOWED_ORIGINS_PROD: ${{ vars.CORS_ALLOWED_ORIGINS_PROD }} CORS_ALLOWED_ORIGINS_STAGING: ${{ vars.CORS_ALLOWED_ORIGINS_STAGING }} diff --git a/docker-compose.yml b/docker-compose.yml index 8f9ae293e..3515b639e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: LOG_PATH: /logs CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS_PROD:-http://localhost:5173,https://cockple.site,https://www.cockple.site,https://staging.cockple.site,https://cockple-fe.vercel.app,https://cockple-fe.vercel.app/} COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} - # 스레드풀 튜닝(운영) 미설정 시 앱 기본값(chat 8/16/500, noti-ingress 2/4/500, noti-push 2/8/500) + # 스레드풀 튜닝(운영) 미설정 시 앱 기본값(chat 8/16/500, noti-ingress 2/4/500, noti-push 2/8/500, game 2/4/500) ASYNC_CHAT_CORE_SIZE: ${ASYNC_CHAT_CORE_SIZE:-8} ASYNC_CHAT_MAX_SIZE: ${ASYNC_CHAT_MAX_SIZE:-16} ASYNC_CHAT_QUEUE_CAPACITY: ${ASYNC_CHAT_QUEUE_CAPACITY:-500} @@ -71,6 +71,10 @@ services: ASYNC_NOTIFICATION_PUSH_CORE_SIZE: ${ASYNC_NOTIFICATION_PUSH_CORE_SIZE:-2} ASYNC_NOTIFICATION_PUSH_MAX_SIZE: ${ASYNC_NOTIFICATION_PUSH_MAX_SIZE:-8} ASYNC_NOTIFICATION_PUSH_QUEUE_CAPACITY: ${ASYNC_NOTIFICATION_PUSH_QUEUE_CAPACITY:-500} + # 게임판 실시간 전파 풀 + ASYNC_GAME_CORE_SIZE: ${ASYNC_GAME_CORE_SIZE:-2} + ASYNC_GAME_MAX_SIZE: ${ASYNC_GAME_MAX_SIZE:-4} + ASYNC_GAME_QUEUE_CAPACITY: ${ASYNC_GAME_QUEUE_CAPACITY:-500} DB_PASSWORD: ${DB_PASSWORD} GCS_BUCKET: ${GCS_BUCKET} KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} @@ -105,7 +109,7 @@ services: OTEL_URL: ${OTEL_URL:-} OTEL_AUTH: ${OTEL_AUTH:-} OTLP_STEP: ${OTLP_STEP:-1m} - # 스레드풀 튜닝(부하테스트용) 미설정 시 앱 기본값(chat 8/16/500, noti-ingress 2/4/500, noti-push 2/8/500) + # 스레드풀 튜닝(부하테스트용) 미설정 시 앱 기본값(chat 8/16/500, noti-ingress 2/4/500, noti-push 2/8/500, game 2/4/500) ASYNC_CHAT_CORE_SIZE: ${ASYNC_CHAT_CORE_SIZE:-8} ASYNC_CHAT_MAX_SIZE: ${ASYNC_CHAT_MAX_SIZE:-16} ASYNC_CHAT_QUEUE_CAPACITY: ${ASYNC_CHAT_QUEUE_CAPACITY:-500} @@ -117,6 +121,10 @@ services: ASYNC_NOTIFICATION_PUSH_CORE_SIZE: ${ASYNC_NOTIFICATION_PUSH_CORE_SIZE:-2} ASYNC_NOTIFICATION_PUSH_MAX_SIZE: ${ASYNC_NOTIFICATION_PUSH_MAX_SIZE:-8} ASYNC_NOTIFICATION_PUSH_QUEUE_CAPACITY: ${ASYNC_NOTIFICATION_PUSH_QUEUE_CAPACITY:-500} + # 게임판 실시간 전파 풀 + ASYNC_GAME_CORE_SIZE: ${ASYNC_GAME_CORE_SIZE:-2} + ASYNC_GAME_MAX_SIZE: ${ASYNC_GAME_MAX_SIZE:-4} + ASYNC_GAME_QUEUE_CAPACITY: ${ASYNC_GAME_QUEUE_CAPACITY:-500} DB_PASSWORD: ${DB_PASSWORD} GCS_BUCKET: ${GCS_BUCKET} KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} diff --git a/src/main/java/umc/cockple/demo/domain/game/service/listener/GameBoardMembersChangedEventListener.java b/src/main/java/umc/cockple/demo/domain/game/service/listener/GameBoardMembersChangedEventListener.java index 481977aae..443a685f2 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/listener/GameBoardMembersChangedEventListener.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/listener/GameBoardMembersChangedEventListener.java @@ -35,7 +35,7 @@ public class GameBoardMembersChangedEventListener { private final GameBoardMapper gameBoardMapper; private final GameBoardBroadcaster gameBoardBroadcaster; - @Async + @Async("gameExecutor") @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleMembersChanged(GameBoardMembersChangedEvent event) { GameBoardMemberDTO.Response membersDto; diff --git a/src/main/java/umc/cockple/demo/domain/game/service/listener/GameCourtsManagedEventListener.java b/src/main/java/umc/cockple/demo/domain/game/service/listener/GameCourtsManagedEventListener.java index 035784634..02443f3b5 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/listener/GameCourtsManagedEventListener.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/listener/GameCourtsManagedEventListener.java @@ -25,7 +25,7 @@ public class GameCourtsManagedEventListener { private final GameBoardMapper gameBoardMapper; private final GameBoardBroadcaster gameBoardBroadcaster; - @Async + @Async("gameExecutor") @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleCourtsManaged(GameCourtsManagedEvent event) { try { diff --git a/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java b/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java index 4464a27fd..293c0844a 100644 --- a/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java +++ b/src/main/java/umc/cockple/demo/global/config/AsyncConfig.java @@ -18,6 +18,7 @@ * 지연 특성별로 벌크헤드(bulkhead) 격리 * {@code chatExecutor} — 실시간/저지연 * {@code notificationPushExecutor} — 외부 FCM HTTP 호출 + * {@code gameExecutor} — 게임판 실시간 전파 * {@code applicationTaskExecutor} — qualifier 없는 @Async의 기본 executor */ @Configuration @@ -79,6 +80,23 @@ public ThreadPoolTaskExecutor notificationPushExecutor( awaitTerminationSeconds, mdcTaskDecorator, logAndDiscardPolicy()); } + /** + * 게임판 실시간 전파 풀. 작업마다 짧은 읽기 트랜잭션으로 DB 커넥션을 잡으므로 + * 동시 실행 수를 작게 두어 Hikari 커넥션 경쟁자를 늘리지 않음 + */ + @Bean("gameExecutor") + public ThreadPoolTaskExecutor gameExecutor( + @Value("${async.game.core-size:2}") int coreSize, + @Value("${async.game.max-size:4}") int maxSize, + @Value("${async.game.queue-capacity:500}") int queueCapacity, + @Value("${async.game.await-termination-seconds:30}") int awaitTerminationSeconds, + TaskDecorator mdcTaskDecorator + ) { + // 포화 시 호출 스레드에서 실행해 백프레셔 + return buildExecutor("cockple-game-", coreSize, maxSize, queueCapacity, + awaitTerminationSeconds, mdcTaskDecorator, new ThreadPoolExecutor.CallerRunsPolicy()); + } + /** * 큐/스레드 포화로 거부된 작업을 예외 전파 없이 버림 */ diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 89556f1c3..13f18e805 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -119,6 +119,12 @@ async: max-size: 8 queue-capacity: 500 await-termination-seconds: 30 + # 게임판 실시간 전파 풀 (커밋 후 명단/보드 조회 + 구독 세션 fan-out) + game: + core-size: 2 + max-size: 4 + queue-capacity: 500 + await-termination-seconds: 30 fcm: fake-latency-ms: 0 diff --git a/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java b/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java index a86847d52..635ae41e9 100644 --- a/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java +++ b/src/test/java/umc/cockple/demo/global/config/AsyncConfigTest.java @@ -53,6 +53,14 @@ void notificationPushExecutorPropagatesMdcAndPreventsLeak() throws Exception { assertPropagatesMdcAndPreventsLeak(executor); } + @Test + @DisplayName("gameExecutor는 MDC를 비동기 작업에 전파하고 다음 작업으로 누수하지 않는다") + void gameExecutorPropagatesMdcAndPreventsLeak() throws Exception { + ThreadPoolTaskExecutor executor = config.gameExecutor(1, 1, 1, 1, config.mdcTaskDecorator()); + + assertPropagatesMdcAndPreventsLeak(executor); + } + /** * 풀에서 도는 작업에 requestId(MDC)가 전파되고, 스레드 재사용 시 이전 컨텍스트가 누수되지 않는지 검증한다. */ From 394046821530b8728c455294d6ac9ef30ec81519 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 28 Sep 2026 17:06:44 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EC=A1=B0=EB=A6=BD=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/query/GameBoardQueryService.java | 70 +------------- .../assembler/GameBoardResultAssembler.java | 93 +++++++++++++++++++ .../query/GameBoardQueryServiceTest.java | 8 +- .../GameBoardResultAssemblerTest.java | 79 ++++++++++++++++ 4 files changed, 181 insertions(+), 69 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/domain/game/service/support/assembler/GameBoardResultAssembler.java create mode 100644 src/test/java/umc/cockple/demo/domain/game/service/support/assembler/GameBoardResultAssemblerTest.java diff --git a/src/main/java/umc/cockple/demo/domain/game/service/query/GameBoardQueryService.java b/src/main/java/umc/cockple/demo/domain/game/service/query/GameBoardQueryService.java index 297c9da2e..96056b421 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/query/GameBoardQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/query/GameBoardQueryService.java @@ -3,27 +3,18 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import umc.cockple.demo.domain.file.service.ImageUrlResolver; import umc.cockple.demo.domain.game.domain.Court; import umc.cockple.demo.domain.game.domain.Game; import umc.cockple.demo.domain.game.domain.GameBoard; -import umc.cockple.demo.domain.game.domain.GameBoardMember; -import umc.cockple.demo.domain.game.domain.GamePlayer; -import umc.cockple.demo.domain.game.enums.CourtStatus; import umc.cockple.demo.domain.game.enums.GameStatus; import umc.cockple.demo.domain.game.repository.CourtRepository; import umc.cockple.demo.domain.game.repository.GameRepository; import umc.cockple.demo.domain.game.service.query.result.GameBoardResult; +import umc.cockple.demo.domain.game.service.support.assembler.GameBoardResultAssembler; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; import umc.cockple.demo.domain.game.service.support.validator.GameBoardAccessValidator; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.domain.ProfileImg; -import java.util.Comparator; import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; @Service @Transactional(readOnly = true) @@ -36,7 +27,7 @@ public class GameBoardQueryService { private final CourtRepository courtRepository; private final GameRepository gameRepository; private final GameBoardAccessValidator gameBoardAccessValidator; - private final ImageUrlResolver imageUrlResolver; + private final GameBoardResultAssembler gameBoardResultAssembler; /** * 코트 보드 조회. 조회 자체는 인증된 회원이면 누구나 가능 @@ -51,61 +42,6 @@ public GameBoardResult getBoard(Long memberId, Long gameBoardId) { List activeGames = gameRepository.findByGameBoardIdAndStatusInWithPlayers( gameBoard.getId(), ACTIVE_STATUSES); - Map playingGameByCourtId = activeGames.stream() - .filter(game -> game.getStatus() == GameStatus.PLAYING && game.getCourt() != null) - .collect(Collectors.toMap(game -> game.getCourt().getId(), Function.identity(), (a, b) -> a)); - - List courtViews = courts.stream() - .map(court -> toCourtView(court, playingGameByCourtId.get(court.getId()))) - .toList(); - - List waitingViews = activeGames.stream() - .filter(game -> game.getStatus() == GameStatus.WAITING) - .sorted(Comparator.comparing(Game::getWaitingOrder, - Comparator.nullsLast(Comparator.naturalOrder()))) - .map(this::toWaitingView) - .toList(); - - return new GameBoardResult(isGameHost, courts.size(), courtViews, waitingViews); - } - - private GameBoardResult.CourtView toCourtView(Court court, Game playingGame) { - boolean playing = playingGame != null; - return new GameBoardResult.CourtView( - court.getId(), - court.getCourtNo(), - court.getCourtName(), - playing ? CourtStatus.PLAYING : CourtStatus.EMPTY, - playing ? toGameView(playingGame) : null); - } - - private GameBoardResult.GameView toGameView(Game game) { - return new GameBoardResult.GameView(game.getId(), game.getStartedAt(), toPlayerViews(game)); - } - - private GameBoardResult.WaitingView toWaitingView(Game game) { - return new GameBoardResult.WaitingView(game.getId(), game.getWaitingOrder(), toPlayerViews(game)); - } - - private List toPlayerViews(Game game) { - return game.getPlayers().stream() - .sorted(Comparator.comparingInt(GamePlayer::getPlayerOrder)) - .map(player -> { - GameBoardMember gameBoardMember = player.getGameBoardMember(); - return new GameBoardResult.PlayerView( - gameBoardMember.getId(), - gameBoardMember.getName(), - resolveProfileImageUrl(gameBoardMember), - gameBoardMember.getLevel(), - player.getPlayerOrder()); - }) - .toList(); - } - - private String resolveProfileImageUrl(GameBoardMember gameBoardMember) { - Member member = gameBoardMember.getMember(); - return member == null - ? null - : imageUrlResolver.resolve(member.getProfileImg(), ProfileImg::getImgKey); + return gameBoardResultAssembler.assemble(isGameHost, courts, activeGames); } } diff --git a/src/main/java/umc/cockple/demo/domain/game/service/support/assembler/GameBoardResultAssembler.java b/src/main/java/umc/cockple/demo/domain/game/service/support/assembler/GameBoardResultAssembler.java new file mode 100644 index 000000000..8da2a49a8 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/service/support/assembler/GameBoardResultAssembler.java @@ -0,0 +1,93 @@ +package umc.cockple.demo.domain.game.service.support.assembler; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.file.service.ImageUrlResolver; +import umc.cockple.demo.domain.game.domain.Court; +import umc.cockple.demo.domain.game.domain.Game; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.domain.GamePlayer; +import umc.cockple.demo.domain.game.enums.CourtStatus; +import umc.cockple.demo.domain.game.enums.GameStatus; +import umc.cockple.demo.domain.game.service.query.result.GameBoardResult; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.ProfileImg; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 이미 조회된 코트/활성 게임으로 코트 보드 결과를 조립, DB조회 X + */ +@Component +@RequiredArgsConstructor +public class GameBoardResultAssembler { + + private final ImageUrlResolver imageUrlResolver; + + /** + * @param courts 게임판의 코트 목록(courtNo 오름차순) + * @param activeGames PLAYING/WAITING 게임, 순서 무관 + */ + public GameBoardResult assemble(boolean isGameHost, List courts, List activeGames) { + Map playingGameByCourtId = activeGames.stream() + .filter(game -> game.getStatus() == GameStatus.PLAYING && game.getCourt() != null) + .collect(Collectors.toMap(game -> game.getCourt().getId(), Function.identity(), (a, b) -> a)); + + List courtViews = courts.stream() + .map(court -> toCourtView(court, playingGameByCourtId.get(court.getId()))) + .toList(); + + List waitingViews = activeGames.stream() + .filter(game -> game.getStatus() == GameStatus.WAITING) + .sorted(Comparator.comparing(Game::getWaitingOrder, + Comparator.nullsLast(Comparator.naturalOrder()))) + .map(this::toWaitingView) + .toList(); + + return new GameBoardResult(isGameHost, courts.size(), courtViews, waitingViews); + } + + private GameBoardResult.CourtView toCourtView(Court court, Game playingGame) { + boolean playing = playingGame != null; + return new GameBoardResult.CourtView( + court.getId(), + court.getCourtNo(), + court.getCourtName(), + playing ? CourtStatus.PLAYING : CourtStatus.EMPTY, + playing ? toGameView(playingGame) : null); + } + + private GameBoardResult.GameView toGameView(Game game) { + return new GameBoardResult.GameView(game.getId(), game.getStartedAt(), toPlayerViews(game)); + } + + private GameBoardResult.WaitingView toWaitingView(Game game) { + return new GameBoardResult.WaitingView(game.getId(), game.getWaitingOrder(), toPlayerViews(game)); + } + + private List toPlayerViews(Game game) { + return game.getPlayers().stream() + .sorted(Comparator.comparingInt(GamePlayer::getPlayerOrder)) + .map(player -> { + GameBoardMember gameBoardMember = player.getGameBoardMember(); + return new GameBoardResult.PlayerView( + gameBoardMember.getId(), + gameBoardMember.getName(), + resolveProfileImageUrl(gameBoardMember), + gameBoardMember.getLevel(), + player.getPlayerOrder()); + }) + .toList(); + } + + private String resolveProfileImageUrl(GameBoardMember gameBoardMember) { + Member member = gameBoardMember.getMember(); + return member == null + ? null + : imageUrlResolver.resolve(member.getProfileImg(), ProfileImg::getImgKey); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/game/service/query/GameBoardQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/query/GameBoardQueryServiceTest.java index 6efc8485b..3aeb54ae2 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/query/GameBoardQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/query/GameBoardQueryServiceTest.java @@ -4,7 +4,6 @@ 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 umc.cockple.demo.domain.file.service.ImageUrlResolver; @@ -16,6 +15,7 @@ import umc.cockple.demo.domain.game.repository.CourtRepository; import umc.cockple.demo.domain.game.repository.GameRepository; import umc.cockple.demo.domain.game.service.query.result.GameBoardResult; +import umc.cockple.demo.domain.game.service.support.assembler.GameBoardResultAssembler; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; import umc.cockple.demo.domain.game.service.support.validator.GameBoardAccessValidator; import umc.cockple.demo.domain.member.domain.Member; @@ -42,7 +42,7 @@ class GameBoardQueryServiceTest { @Mock private GameBoardAccessValidator gameBoardAccessValidator; @Mock private ImageUrlResolver imageUrlResolver; - @InjectMocks private GameBoardQueryService gameBoardQueryService; + private GameBoardQueryService gameBoardQueryService; private static final Long MEMBER_ID = 100L; private static final Long BOARD_ID = 1L; @@ -50,6 +50,10 @@ class GameBoardQueryServiceTest { @BeforeEach void setUp() { + // 조립 규칙까지 함께 검증하도록 실제 assembler를 사용한다. + gameBoardQueryService = new GameBoardQueryService( + gameBoardReader, courtRepository, gameRepository, gameBoardAccessValidator, + new GameBoardResultAssembler(imageUrlResolver)); board = GameFixture.gameBoard(BOARD_ID); } diff --git a/src/test/java/umc/cockple/demo/domain/game/service/support/assembler/GameBoardResultAssemblerTest.java b/src/test/java/umc/cockple/demo/domain/game/service/support/assembler/GameBoardResultAssemblerTest.java new file mode 100644 index 000000000..ea6534bde --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/game/service/support/assembler/GameBoardResultAssemblerTest.java @@ -0,0 +1,79 @@ +package umc.cockple.demo.domain.game.service.support.assembler; + +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 umc.cockple.demo.domain.file.service.ImageUrlResolver; +import umc.cockple.demo.domain.game.domain.Court; +import umc.cockple.demo.domain.game.domain.Game; +import umc.cockple.demo.domain.game.domain.GameBoard; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.enums.CourtStatus; +import umc.cockple.demo.domain.game.service.query.result.GameBoardResult; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.GameFixture; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GameBoardResultAssembler") +class GameBoardResultAssemblerTest { + + @Mock private ImageUrlResolver imageUrlResolver; + + @InjectMocks private GameBoardResultAssembler assembler; + + private GameBoard board; + + @BeforeEach + void setUp() { + board = GameFixture.gameBoard(1L); + } + + @Test + @DisplayName("활성 게임 입력 순서와 무관하게 PLAYING은 해당 코트에, WAITING은 waitingOrder 순으로 배치한다") + void assemble_placesGamesRegardlessOfInputOrder() { + // given + Court court1 = GameFixture.court(10L, board, 1, "1번"); + Court court2 = GameFixture.court(11L, board, 2, "2번"); + GameBoardMember member = GameFixture.member(7L, board, "선수A", Level.A); + Game playingOnCourt2 = GameFixture.playingGame(50L, board, court2, LocalDateTime.now(), + GameFixture.player(member, 0)); + Game waitingFirst = GameFixture.waitingGame(51L, board, 1); + // 새로 생성된 게임이 목록 끝에 붙는 경우(명령 트랜잭션 스냅샷)를 가정 + Game newlyCreated = GameFixture.waitingGame(52L, board, 2); + + // when + GameBoardResult result = assembler.assemble( + true, List.of(court1, court2), List.of(newlyCreated, playingOnCourt2, waitingFirst)); + + // then + assertThat(result.isGameHost()).isTrue(); + assertThat(result.courtCount()).isEqualTo(2); + assertThat(result.courts()).extracting(GameBoardResult.CourtView::status) + .containsExactly(CourtStatus.EMPTY, CourtStatus.PLAYING); + assertThat(result.courts().get(1).game().gameId()).isEqualTo(50L); + assertThat(result.waitings()).extracting(GameBoardResult.WaitingView::gameId) + .containsExactly(51L, 52L); + } + + @Test + @DisplayName("코트와 활성 게임이 없으면 빈 보드를 반환한다") + void assemble_emptyBoard() { + // when + GameBoardResult result = assembler.assemble(false, List.of(), List.of()); + + // then + assertThat(result.isGameHost()).isFalse(); + assertThat(result.courtCount()).isZero(); + assertThat(result.courts()).isEmpty(); + assertThat(result.waitings()).isEmpty(); + } +} From 94602d22f8ce46b7602f0613ac483a44970fe906 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 28 Sep 2026 17:14:39 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=9E=84=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EB=82=B4=EC=97=90=EC=84=9C=20=EB=B3=B4=EB=93=9C=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../realtime/GameRealtimeDomainHandler.java | 23 +++++----- .../service/command/GameCommandService.java | 22 ++++++++-- .../command/result/GameCreateResult.java | 15 +++++++ .../integration/GameFlowIntegrationTest.java | 17 ++++--- .../command/GameCommandServiceTest.java | 44 +++++++++++++++++++ 5 files changed, 99 insertions(+), 22 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/domain/game/service/command/result/GameCreateResult.java diff --git a/src/main/java/umc/cockple/demo/domain/game/presentation/realtime/GameRealtimeDomainHandler.java b/src/main/java/umc/cockple/demo/domain/game/presentation/realtime/GameRealtimeDomainHandler.java index 40c69b760..674261c6b 100644 --- a/src/main/java/umc/cockple/demo/domain/game/presentation/realtime/GameRealtimeDomainHandler.java +++ b/src/main/java/umc/cockple/demo/domain/game/presentation/realtime/GameRealtimeDomainHandler.java @@ -19,6 +19,7 @@ import umc.cockple.demo.domain.game.service.command.model.GameDeleteCommand; import umc.cockple.demo.domain.game.service.command.model.GameStartCommand; import umc.cockple.demo.domain.game.service.command.model.GameToWaitingCommand; +import umc.cockple.demo.domain.game.service.command.result.GameCreateResult; import umc.cockple.demo.domain.game.service.command.result.GameDeleteResult; import umc.cockple.demo.domain.game.service.query.GameBoardQueryService; import umc.cockple.demo.domain.game.service.query.result.GameBoardResult; @@ -142,11 +143,10 @@ private void handleCreateGame( Long gameBoardId = requireGameBoardId(payload); GameCreateCommand command = new GameCreateCommand(gameBoardId, payload.gameBoardMemberIds()); - Long gameId = gameCommandService.createGame(context.memberId(), command); + GameCreateResult result = gameCommandService.createGame(context.memberId(), command); - // 호출자에게는 생성된 gameId + 최신 보드를, 나머지 구독자에게는 보드 갱신을 전달한다. - GameBoardDTO.Response boardDto = loadBoardDto(context, gameBoardId); - responder.send(GameRealtimeProtocol.TYPE_GAME_CREATED, new GameCreatedAck(gameId, boardDto)); + GameBoardDTO.Response boardDto = gameBoardMapper.toResponse(result.board()); + responder.send(GameRealtimeProtocol.TYPE_GAME_CREATED, new GameCreatedAck(result.gameId(), boardDto)); gameBoardBroadcaster.broadcastBoardUpdate(gameBoardId, toBroadcastBoard(boardDto), context.sessionId()); } @@ -202,7 +202,7 @@ private void respondAndBroadcastBoard( GameBoardDTO.Response boardDto = loadBoardDto(context, gameBoardId); responder.send(GameRealtimeProtocol.TYPE_BOARD_UPDATED, boardDto); - // 변경을 일으킨 세션은 위에서 직접 응답을 받았으므로 브로드캐스트 대상에서 제외한다. + // 변경을 일으킨 세션은 위에서 직접 응답을 받았으므로 브로드캐스트 대상에서 제외 gameBoardBroadcaster.broadcastBoardUpdate(gameBoardId, toBroadcastBoard(boardDto), context.sessionId()); } @@ -212,11 +212,8 @@ private GameBoardDTO.Response loadBoardDto(RealtimeRequestContext context, Long } /** - * isGameHost 는 "요청자 자신이 게임 진행자인지"를 뜻하는 개인 값이다. - * 보드 DTO 는 변경을 일으킨 요청자 기준으로 조회되므로, 그대로 브로드캐스트하면 - * 진행자가 일으킨 변경 때 다른 구독자에게도 isGameHost=true 가 새어나간다. - * 따라서 브로드캐스트 본문에서는 항상 false 로 내리고, 각 클라이언트는 - * 자신의 구독 응답으로 받은 값을 유지하도록 한다. + * 브로드캐스트 본문에서는 항상 false 로 내리고, 각 클라이언트는 + * 자신의 구독 응답으로 받은 값을 유지 */ private GameBoardDTO.Response toBroadcastBoard(GameBoardDTO.Response board) { return board.forBroadcast(); @@ -230,21 +227,21 @@ private Long requireGameBoardId(GameRealtimePayload payload) { } private void requireMoveCourtPayload(GameRealtimePayload payload) { - // MOVE_COURT: 원본 코트 ID와 목적지 코트 번호가 모두 필요하다. + // MOVE_COURT: 원본 코트 ID와 목적지 코트 번호가 모두 필요 if (payload.courtId() == null || payload.targetCourtNo() == null) { throw new GameException(GameErrorCode.INVALID_REALTIME_PAYLOAD); } } private void requireStartGamePayload(GameRealtimePayload payload) { - // START_GAME: 시작할 대기 게임 ID와 목적지 코트 ID가 모두 필요하다. + // START_GAME: 시작할 대기 게임 ID와 목적지 코트 ID가 모두 필요 if (payload.gameId() == null || payload.courtId() == null) { throw new GameException(GameErrorCode.INVALID_REALTIME_PAYLOAD); } } private void requireGameId(GameRealtimePayload payload) { - // DELETE_GAME/MOVE_TO_WAITING/COMPLETE_GAME: 대상 게임 ID가 필요하다. + // DELETE_GAME/MOVE_TO_WAITING/COMPLETE_GAME: 대상 게임 ID가 필요 if (payload.gameId() == null) { throw new GameException(GameErrorCode.INVALID_REALTIME_PAYLOAD); } diff --git a/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java b/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java index 16869c7a8..98f67978e 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java @@ -28,11 +28,15 @@ import umc.cockple.demo.domain.game.service.command.model.GameDeleteCommand; import umc.cockple.demo.domain.game.service.command.model.GameStartCommand; import umc.cockple.demo.domain.game.service.command.model.GameToWaitingCommand; +import umc.cockple.demo.domain.game.service.command.result.GameCreateResult; import umc.cockple.demo.domain.game.service.command.result.GameDeleteResult; +import umc.cockple.demo.domain.game.service.query.result.GameBoardResult; +import umc.cockple.demo.domain.game.service.support.assembler.GameBoardResultAssembler; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; import umc.cockple.demo.domain.game.service.support.validator.GameBoardAccessValidator; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -54,15 +58,16 @@ public class GameCommandService { private final ExerciseRepository exerciseRepository; private final GameBoardAccessValidator gameBoardAccessValidator; private final GameBoardMemberAvailabilityPolicy availabilityPolicy; + private final GameBoardResultAssembler gameBoardResultAssembler; private final ApplicationEventPublisher eventPublisher; /** * 게임 대기 생성 * * @param memberId 요청자 - * @return 생성된 게임 ID + * @return 생성된 게임 ID + 생성 직후 보드 스냅샷 */ - public Long createGame(Long memberId, GameCreateCommand command) { + public GameCreateResult createGame(Long memberId, GameCreateCommand command) { GameBoard gameBoard = gameBoardReader.readForUpdate(command.gameBoardId()); gameBoardAccessValidator.validateGameHost(gameBoard.getId(), memberId); @@ -94,10 +99,21 @@ public Long createGame(Long memberId, GameCreateCommand command) { } Game savedGame = gameRepository.save(game); + GameBoardResult board = assembleBoardAfterCreate(gameBoard.getId(), activeGames, savedGame); publishMembersChanged(gameBoard.getId(), memberId); log.info("게임 대기 생성 - gameBoardId: {}, gameId: {}, 인원: {}", gameBoard.getId(), savedGame.getId(), command.gameBoardMemberIds().size()); - return savedGame.getId(); + return new GameCreateResult(savedGame.getId(), board); + } + + /** + * 검증 단계에서 조회한 활성 게임에 새 게임을 더해 보드 조립 + */ + private GameBoardResult assembleBoardAfterCreate(Long gameBoardId, List activeGames, Game savedGame) { + List courts = courtRepository.findByGameBoardIdOrderByCourtNoAsc(gameBoardId); + List gamesAfterCreate = new ArrayList<>(activeGames); + gamesAfterCreate.add(savedGame); + return gameBoardResultAssembler.assemble(true, courts, gamesAfterCreate); } /** diff --git a/src/main/java/umc/cockple/demo/domain/game/service/command/result/GameCreateResult.java b/src/main/java/umc/cockple/demo/domain/game/service/command/result/GameCreateResult.java new file mode 100644 index 000000000..45d4bc15d --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/service/command/result/GameCreateResult.java @@ -0,0 +1,15 @@ +package umc.cockple.demo.domain.game.service.command.result; + +import umc.cockple.demo.domain.game.service.query.result.GameBoardResult; + +/** + * 게임 대기 생성 결과 + * + * @param gameId 생성된 게임 ID + * @param board 생성 트랜잭션 안에서 조립한 최신 보드 스냅샷 + */ +public record GameCreateResult( + Long gameId, + GameBoardResult board +) { +} diff --git a/src/test/java/umc/cockple/demo/domain/game/integration/GameFlowIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/game/integration/GameFlowIntegrationTest.java index 9190e7819..865d4bbba 100644 --- a/src/test/java/umc/cockple/demo/domain/game/integration/GameFlowIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/integration/GameFlowIntegrationTest.java @@ -24,6 +24,7 @@ import umc.cockple.demo.domain.game.service.command.model.GameDeleteCommand; import umc.cockple.demo.domain.game.service.command.model.GameStartCommand; import umc.cockple.demo.domain.game.service.command.model.GameToWaitingCommand; +import umc.cockple.demo.domain.game.service.command.result.GameCreateResult; import umc.cockple.demo.domain.game.service.command.result.GameDeleteResult; import umc.cockple.demo.domain.game.service.query.GameBoardQueryService; import umc.cockple.demo.domain.game.service.query.result.GameBoardResult; @@ -91,7 +92,9 @@ void gameFlow_createStartViewMove() { List memberIds = saveMembers(board, "김A", "김B", "김C", "김D"); // --- #8 게임 대기 생성 --- - Long gameId = gameCommandService.createGame(actor, new GameCreateCommand(board.getId(), memberIds)); + GameCreateResult createResult = gameCommandService.createGame( + actor, new GameCreateCommand(board.getId(), memberIds)); + Long gameId = createResult.gameId(); Game created = gameRepository.findById(gameId).orElseThrow(); assertThat(created.getStatus()).isEqualTo(GameStatus.WAITING); @@ -106,6 +109,8 @@ void gameFlow_createStartViewMove() { assertThat(afterCreate.waitings()) .extracting(GameBoardResult.WaitingView::gameId) .containsExactly(gameId); + // 생성 트랜잭션에서 만든 보드 스냅샷은 재조회한 보드와 같아야 한다. + assertThat(createResult.board()).isEqualTo(afterCreate); // --- #4 게임 시작: 1번 코트에 배치 --- gameCommandService.startGame(actor, new GameStartCommand(board.getId(), gameId, court1.getId())); @@ -135,8 +140,8 @@ void deleteWaitingGame_resequencesAndRestores() { List members1 = saveMembers(board, "김A", "김B"); List members2 = saveMembers(board, "김C", "김D"); - Long game1 = gameCommandService.createGame(actor, new GameCreateCommand(board.getId(), members1)); // 대기 1번 - Long game2 = gameCommandService.createGame(actor, new GameCreateCommand(board.getId(), members2)); // 대기 2번 + Long game1 = gameCommandService.createGame(actor, new GameCreateCommand(board.getId(), members1)).gameId(); // 대기 1번 + Long game2 = gameCommandService.createGame(actor, new GameCreateCommand(board.getId(), members2)).gameId(); // 대기 2번 // --- #6 대기 1번(game1) 삭제 + 복원 --- GameDeleteResult result = gameCommandService.deleteGame( @@ -164,8 +169,8 @@ void moveToWaiting_returnsSameGameToFrontWithoutRecording() { List membersB = saveMembers(board, "김B1", "김B2"); List membersA = saveMembers(board, "김A1", "김A2"); - Long gameB = gameCommandService.createGame(actor, new GameCreateCommand(board.getId(), membersB)); // 대기 1번 - Long gameA = gameCommandService.createGame(actor, new GameCreateCommand(board.getId(), membersA)); // 대기 2번 + Long gameB = gameCommandService.createGame(actor, new GameCreateCommand(board.getId(), membersB)).gameId(); // 대기 1번 + Long gameA = gameCommandService.createGame(actor, new GameCreateCommand(board.getId(), membersA)).gameId(); // 대기 2번 gameCommandService.startGame(actor, new GameStartCommand(board.getId(), gameA, court1.getId())); // A 진행 (B는 대기 1번) // --- #7 대기열 이동: A를 기록 없이 대기열 맨 앞으로 --- @@ -206,7 +211,7 @@ void completeGame_emptiesCourtAndIncrementsCount() { Court court1 = courtRepository.findByGameBoardIdOrderByCourtNoAsc(board.getId()).get(0); List membersA = saveMembers(board, "김A1", "김A2"); - Long gameA = gameCommandService.createGame(actor, new GameCreateCommand(board.getId(), membersA)); + Long gameA = gameCommandService.createGame(actor, new GameCreateCommand(board.getId(), membersA)).gameId(); gameCommandService.startGame(actor, new GameStartCommand(board.getId(), gameA, court1.getId())); // --- 게임 완료 --- diff --git a/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java index d8370f214..a2a6f7bcb 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java @@ -33,7 +33,10 @@ import umc.cockple.demo.domain.game.service.command.model.GameDeleteCommand; import umc.cockple.demo.domain.game.service.command.model.GameStartCommand; import umc.cockple.demo.domain.game.service.command.model.GameToWaitingCommand; +import umc.cockple.demo.domain.game.service.command.result.GameCreateResult; import umc.cockple.demo.domain.game.service.command.result.GameDeleteResult; +import umc.cockple.demo.domain.game.service.query.result.GameBoardResult; +import umc.cockple.demo.domain.game.service.support.assembler.GameBoardResultAssembler; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; import umc.cockple.demo.domain.game.service.support.validator.GameBoardAccessValidator; import umc.cockple.demo.global.enums.Level; @@ -47,6 +50,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.*; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -62,6 +67,7 @@ class GameCommandServiceTest { @Mock private ExerciseRepository exerciseRepository; @Mock private GameBoardAccessValidator gameBoardAccessValidator; @Mock private GameBoardMemberAvailabilityPolicy availabilityPolicy; + @Mock private GameBoardResultAssembler gameBoardResultAssembler; @Mock private ApplicationEventPublisher eventPublisher; @InjectMocks private GameCommandService gameCommandService; @@ -336,6 +342,44 @@ void createGame_createsWaitingGameWithOrderedPlayers() { .publishEvent(GameBoardMembersChangedEvent.membersOnly(BOARD_ID, MEMBER_ID)); } + @Test + @DisplayName("기존 활성 게임에 새 게임을 더해 보드를 조립하고, 활성 게임은 다시 조회하지 않는다") + void createGame_returnsBoardAssembledWithinTransaction() { + // given + GameBoardMember m7 = GameFixture.member(7L, board, "선수7", Level.A); + Game existingWaiting = GameFixture.waitingGame(40L, board, 1); + GameBoardResult assembledBoard = new GameBoardResult(true, 1, List.of(), List.of()); + given(gameBoardReader.readForUpdate(BOARD_ID)).willReturn(board); + given(gameBoardMemberRepository.findByGameBoardIdAndIdIn(BOARD_ID, List.of(7L))) + .willReturn(List.of(m7)); + given(gameRepository.findByGameBoardIdAndStatusInWithPlayers(eq(BOARD_ID), anyList())) + .willReturn(List.of(existingWaiting)); + given(gameRepository.countByGameBoardIdAndStatus(BOARD_ID, GameStatus.WAITING)).willReturn(1L); + given(gameRepository.save(any(Game.class))).willAnswer(inv -> inv.getArgument(0)); + given(courtRepository.findByGameBoardIdOrderByCourtNoAsc(BOARD_ID)).willReturn(List.of(court)); + given(gameBoardResultAssembler.assemble(eq(true), eq(List.of(court)), anyList())) + .willReturn(assembledBoard); + + // when + GameCreateResult result = gameCommandService.createGame( + MEMBER_ID, new GameCreateCommand(BOARD_ID, List.of(7L))); + + // then + assertThat(result.board()).isSameAs(assembledBoard); + + @SuppressWarnings("unchecked") + ArgumentCaptor> gamesCaptor = ArgumentCaptor.forClass(List.class); + then(gameBoardResultAssembler).should().assemble(eq(true), eq(List.of(court)), gamesCaptor.capture()); + List assembledGames = gamesCaptor.getValue(); + assertThat(assembledGames).hasSize(2); + assertThat(assembledGames.get(0)).isSameAs(existingWaiting); + assertThat(assembledGames.get(1).getStatus()).isEqualTo(GameStatus.WAITING); + assertThat(assembledGames.get(1).getWaitingOrder()).isEqualTo(2); + + then(gameRepository).should(times(1)) + .findByGameBoardIdAndStatusInWithPlayers(eq(BOARD_ID), anyList()); + } + @Test @DisplayName("게임 진행자가 아니면 GAME_BOARD_ACCESS_DENIED 예외로 게임을 만들지 않는다") void createGame_deniesNonHost() { From f216866c13e233029fee066fa234263adf6c7445 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 28 Sep 2026 17:27:18 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EC=97=90=20?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../events/GameBoardMembersChangedEvent.java | 21 +++++++++++++++---- .../GameBoardMembersChangedEventListener.java | 6 ++++-- ...eBoardMembersChangedEventListenerTest.java | 18 ++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/main/java/umc/cockple/demo/domain/game/events/GameBoardMembersChangedEvent.java b/src/main/java/umc/cockple/demo/domain/game/events/GameBoardMembersChangedEvent.java index 8a7c4c2d4..b6e5459fe 100644 --- a/src/main/java/umc/cockple/demo/domain/game/events/GameBoardMembersChangedEvent.java +++ b/src/main/java/umc/cockple/demo/domain/game/events/GameBoardMembersChangedEvent.java @@ -1,24 +1,37 @@ package umc.cockple.demo.domain.game.events; +import umc.cockple.demo.domain.game.service.query.result.GameBoardResult; + +import java.util.Objects; + /** * 명단 정보, 운동 참가자, 활성 게임 상태 변경으로 * 게임판 명단 projection이 바뀌었음을 알리는 이벤트. - * 트랜잭션 커밋 후 필요한 snapshot을 구독자에게 전파하는 데 사용한다. + * 트랜잭션 커밋 후 필요한 snapshot을 구독자에게 전파하는 데 사용 * * @param gameBoardId 변경된 게임판 * @param actorMemberId 변경 요청자 * @param includeBoardSnapshot 명단과 함께 게임판 snapshot도 전파할지 여부 + * @param boardSnapshot 변경 트랜잭션 안에서 이미 조립한 게임판 snapshot. 있으면 커밋 후 재조회 없이 그대로 전파하고, + * null 이면 리스너가 커밋 후 게임판을 조회 */ public record GameBoardMembersChangedEvent( Long gameBoardId, Long actorMemberId, - boolean includeBoardSnapshot + boolean includeBoardSnapshot, + GameBoardResult boardSnapshot ) { public static GameBoardMembersChangedEvent membersAndBoard(Long gameBoardId, Long actorMemberId) { - return new GameBoardMembersChangedEvent(gameBoardId, actorMemberId, true); + return new GameBoardMembersChangedEvent(gameBoardId, actorMemberId, true, null); + } + + public static GameBoardMembersChangedEvent membersWithBoard( + Long gameBoardId, Long actorMemberId, GameBoardResult boardSnapshot) { + Objects.requireNonNull(boardSnapshot, "boardSnapshot은 null일 수 없습니다."); + return new GameBoardMembersChangedEvent(gameBoardId, actorMemberId, true, boardSnapshot); } public static GameBoardMembersChangedEvent membersOnly(Long gameBoardId, Long actorMemberId) { - return new GameBoardMembersChangedEvent(gameBoardId, actorMemberId, false); + return new GameBoardMembersChangedEvent(gameBoardId, actorMemberId, false, null); } } diff --git a/src/main/java/umc/cockple/demo/domain/game/service/listener/GameBoardMembersChangedEventListener.java b/src/main/java/umc/cockple/demo/domain/game/service/listener/GameBoardMembersChangedEventListener.java index 443a685f2..44a524fca 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/listener/GameBoardMembersChangedEventListener.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/listener/GameBoardMembersChangedEventListener.java @@ -60,8 +60,10 @@ public void handleMembersChanged(GameBoardMembersChangedEvent event) { GameBoardDTO.Response boardDto; try { - GameBoardResult board = gameBoardQueryService.getBoard( - event.actorMemberId(), event.gameBoardId()); + // 변경 트랜잭션이 snapshot을 실어 보냈으면 재조회 X + GameBoardResult board = event.boardSnapshot() != null + ? event.boardSnapshot() + : gameBoardQueryService.getBoard(event.actorMemberId(), event.gameBoardId()); boardDto = gameBoardMapper.toResponse(board); } catch (Exception e) { log.error("게임판 snapshot 조회 실패 - gameBoardId: {}", event.gameBoardId(), e); diff --git a/src/test/java/umc/cockple/demo/domain/game/service/listener/GameBoardMembersChangedEventListenerTest.java b/src/test/java/umc/cockple/demo/domain/game/service/listener/GameBoardMembersChangedEventListenerTest.java index 3b82174ae..a01fcb03e 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/listener/GameBoardMembersChangedEventListenerTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/listener/GameBoardMembersChangedEventListenerTest.java @@ -85,6 +85,24 @@ void handleMembersChanged_continuesBoardBroadcastAfterMembersFailure() { .broadcastBoardUpdate(GAME_BOARD_ID, boardDto, null); } + @Test + @DisplayName("이벤트에 게임판 snapshot이 실려 있으면 재조회하지 않고 그대로 전파한다") + void handleMembersChanged_usesCarriedBoardSnapshotWithoutQuery() { + GameBoardResult carriedBoard = new GameBoardResult(true, 2, List.of(), List.of()); + GameBoardDTO.Response carriedBoardDto = new GameBoardDTO.Response(true, 2, List.of(), List.of()); + given(gameBoardMapper.toResponse(carriedBoard)).willReturn(carriedBoardDto); + event = GameBoardMembersChangedEvent.membersWithBoard(GAME_BOARD_ID, ACTOR_MEMBER_ID, carriedBoard); + + listener.handleMembersChanged(event); + + then(gameBoardQueryService).shouldHaveNoInteractions(); + then(gameBoardBroadcaster).should() + .broadcastMembersUpdate(GAME_BOARD_ID, membersDto, null); + // isGameHost는 개인화 값이므로 브로드캐스트 본문에서는 false로 내린다. + then(gameBoardBroadcaster).should() + .broadcastBoardUpdate(GAME_BOARD_ID, carriedBoardDto.forBroadcast(), null); + } + @Test @DisplayName("게임/운동 상태 변경은 명단 snapshot만 전파한다") void handleMembersChanged_membersOnlySkipsBoardSnapshot() { From 7c2a91a6ddd4a6b9495fd06dd7922aa25bf6d381 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 28 Sep 2026 17:35:25 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=9E=84=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B3=B4=EB=93=9C=20=EB=B8=8C=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EC=BA=90=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=ED=9B=84=20=EB=B9=84=EB=8F=99=EA=B8=B0=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../realtime/GameRealtimeDomainHandler.java | 1 - .../service/command/GameCommandService.java | 7 ++++--- .../command/GameCommandServiceTest.java | 18 ++++++++++++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/main/java/umc/cockple/demo/domain/game/presentation/realtime/GameRealtimeDomainHandler.java b/src/main/java/umc/cockple/demo/domain/game/presentation/realtime/GameRealtimeDomainHandler.java index 674261c6b..72c1a06df 100644 --- a/src/main/java/umc/cockple/demo/domain/game/presentation/realtime/GameRealtimeDomainHandler.java +++ b/src/main/java/umc/cockple/demo/domain/game/presentation/realtime/GameRealtimeDomainHandler.java @@ -147,7 +147,6 @@ private void handleCreateGame( GameBoardDTO.Response boardDto = gameBoardMapper.toResponse(result.board()); responder.send(GameRealtimeProtocol.TYPE_GAME_CREATED, new GameCreatedAck(result.gameId(), boardDto)); - gameBoardBroadcaster.broadcastBoardUpdate(gameBoardId, toBroadcastBoard(boardDto), context.sessionId()); } private void handleMoveToWaiting( diff --git a/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java b/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java index 98f67978e..13e3c28c2 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/command/GameCommandService.java @@ -100,7 +100,9 @@ public GameCreateResult createGame(Long memberId, GameCreateCommand command) { Game savedGame = gameRepository.save(game); GameBoardResult board = assembleBoardAfterCreate(gameBoard.getId(), activeGames, savedGame); - publishMembersChanged(gameBoard.getId(), memberId); + // 커밋 후 리스너가 이 snapshot을 재조회 없이 전파 + eventPublisher.publishEvent( + GameBoardMembersChangedEvent.membersWithBoard(gameBoard.getId(), memberId, board)); log.info("게임 대기 생성 - gameBoardId: {}, gameId: {}, 인원: {}", gameBoard.getId(), savedGame.getId(), command.gameBoardMemberIds().size()); return new GameCreateResult(savedGame.getId(), board); @@ -211,8 +213,7 @@ public GameDeleteResult deleteGame(Long memberId, GameDeleteCommand command) { /** * 대기열 이동 - * 진행 중인 게임을 완료 기록 없이 같은 인원 그대로 대기열 맨 앞으로 되돌리고 코트를 비운다. - * 경기 기록으로 남지 않으며 게임횟수도 증가시키지 않는다. + * 진행 중인 게임을 완료 기록 없이 같은 인원 그대로 대기열 맨 앞으로 되돌리고 코트를 비움 * * @param memberId 요청자 */ diff --git a/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java index a2a6f7bcb..9fcad42fe 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/command/GameCommandServiceTest.java @@ -50,6 +50,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.*; @@ -314,6 +315,15 @@ void startGame_courtAlreadyInUse() { @DisplayName("createGame (#8 게임 대기 생성)") class CreateGame { + private final GameBoardResult assembledBoard = new GameBoardResult(true, 1, List.of(), List.of()); + + @BeforeEach + void setUpBoardAssembly() { + // 생성 성공 케이스에서만 보드를 조립하므로 예외 케이스에서 쓰이지 않아도 되도록 lenient로 둔다. + lenient().when(gameBoardResultAssembler.assemble(anyBoolean(), anyList(), anyList())) + .thenReturn(assembledBoard); + } + @Test @DisplayName("선택 멤버로 대기 게임을 만들고, 배열 순서를 playerOrder로 대기열 맨 뒤에 붙인다") void createGame_createsWaitingGameWithOrderedPlayers() { @@ -339,7 +349,7 @@ void createGame_createsWaitingGameWithOrderedPlayers() { .extracting(p -> p.getGameBoardMember().getId(), GamePlayer::getPlayerOrder) .containsExactly(tuple(8L, 0), tuple(7L, 1)); then(eventPublisher).should() - .publishEvent(GameBoardMembersChangedEvent.membersOnly(BOARD_ID, MEMBER_ID)); + .publishEvent(GameBoardMembersChangedEvent.membersWithBoard(BOARD_ID, MEMBER_ID, assembledBoard)); } @Test @@ -348,7 +358,6 @@ void createGame_returnsBoardAssembledWithinTransaction() { // given GameBoardMember m7 = GameFixture.member(7L, board, "선수7", Level.A); Game existingWaiting = GameFixture.waitingGame(40L, board, 1); - GameBoardResult assembledBoard = new GameBoardResult(true, 1, List.of(), List.of()); given(gameBoardReader.readForUpdate(BOARD_ID)).willReturn(board); given(gameBoardMemberRepository.findByGameBoardIdAndIdIn(BOARD_ID, List.of(7L))) .willReturn(List.of(m7)); @@ -357,8 +366,6 @@ void createGame_returnsBoardAssembledWithinTransaction() { given(gameRepository.countByGameBoardIdAndStatus(BOARD_ID, GameStatus.WAITING)).willReturn(1L); given(gameRepository.save(any(Game.class))).willAnswer(inv -> inv.getArgument(0)); given(courtRepository.findByGameBoardIdOrderByCourtNoAsc(BOARD_ID)).willReturn(List.of(court)); - given(gameBoardResultAssembler.assemble(eq(true), eq(List.of(court)), anyList())) - .willReturn(assembledBoard); // when GameCreateResult result = gameCommandService.createGame( @@ -378,6 +385,9 @@ void createGame_returnsBoardAssembledWithinTransaction() { then(gameRepository).should(times(1)) .findByGameBoardIdAndStatusInWithPlayers(eq(BOARD_ID), anyList()); + // 커밋 후 리스너가 재조회 없이 전파하도록 같은 snapshot을 이벤트에 싣는다. + then(eventPublisher).should() + .publishEvent(GameBoardMembersChangedEvent.membersWithBoard(BOARD_ID, MEMBER_ID, assembledBoard)); } @Test From 7970500924a70ec26c711a8bb961bc70a9cc97e6 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 28 Sep 2026 23:39:00 +0900 Subject: [PATCH 6/6] =?UTF-8?q?test:=20=EA=B2=8C=EC=9E=84=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GameRealtimeDomainHandlerTest.java | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/test/java/umc/cockple/demo/domain/game/presentation/realtime/GameRealtimeDomainHandlerTest.java diff --git a/src/test/java/umc/cockple/demo/domain/game/presentation/realtime/GameRealtimeDomainHandlerTest.java b/src/test/java/umc/cockple/demo/domain/game/presentation/realtime/GameRealtimeDomainHandlerTest.java new file mode 100644 index 000000000..6f4ea51e5 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/game/presentation/realtime/GameRealtimeDomainHandlerTest.java @@ -0,0 +1,179 @@ +package umc.cockple.demo.domain.game.presentation.realtime; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import umc.cockple.demo.domain.game.exception.GameErrorCode; +import umc.cockple.demo.domain.game.exception.GameException; +import umc.cockple.demo.domain.game.presentation.dto.GameBoardDTO; +import umc.cockple.demo.domain.game.presentation.mapper.GameBoardMapper; +import umc.cockple.demo.domain.game.realtime.GameRealtimeProtocol; +import umc.cockple.demo.domain.game.service.command.GameCommandService; +import umc.cockple.demo.domain.game.service.command.GameCourtCommandService; +import umc.cockple.demo.domain.game.service.command.model.GameCreateCommand; +import umc.cockple.demo.domain.game.service.command.model.GameToWaitingCommand; +import umc.cockple.demo.domain.game.service.command.result.GameCreateResult; +import umc.cockple.demo.domain.game.service.query.GameBoardQueryService; +import umc.cockple.demo.domain.game.service.query.result.GameBoardResult; +import umc.cockple.demo.domain.game.service.websocket.broadcast.GameBoardBroadcaster; +import umc.cockple.demo.domain.game.service.websocket.subscription.GameBoardSubscriptionService; +import umc.cockple.demo.global.realtime.routing.RealtimeRequestContext; +import umc.cockple.demo.global.realtime.routing.RealtimeResponder; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verifyNoInteractions; + +@DisplayName("GameRealtimeDomainHandler") +class GameRealtimeDomainHandlerTest { + + private static final Long MEMBER_ID = 100L; + private static final Long GAME_BOARD_ID = 1L; + private static final String SESSION_ID = "session-1"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private GameCommandService gameCommandService; + private GameBoardQueryService gameBoardQueryService; + private GameBoardMapper gameBoardMapper; + private GameBoardBroadcaster gameBoardBroadcaster; + private RealtimeResponder responder; + private GameRealtimeDomainHandler handler; + + @BeforeEach + void setUp() { + gameCommandService = mock(GameCommandService.class); + gameBoardQueryService = mock(GameBoardQueryService.class); + gameBoardMapper = mock(GameBoardMapper.class); + gameBoardBroadcaster = mock(GameBoardBroadcaster.class); + responder = mock(RealtimeResponder.class); + handler = new GameRealtimeDomainHandler( + objectMapper, + mock(GameBoardSubscriptionService.class), + mock(GameCourtCommandService.class), + gameCommandService, + gameBoardQueryService, + gameBoardMapper, + gameBoardBroadcaster + ); + } + + @Nested + @DisplayName("CREATE_GAME") + class CreateGame { + + @Test + @DisplayName("생성 트랜잭션이 만든 보드로 요청자에게만 응답하고, 보드 재조회나 동기 브로드캐스트를 하지 않는다") + void respondsWithBoardFromCreateResultWithoutRequeryOrBroadcast() { + // given + GameBoardResult board = new GameBoardResult(true, 2, List.of(), List.of()); + GameBoardDTO.Response boardDto = new GameBoardDTO.Response(true, 2, List.of(), List.of()); + given(gameCommandService.createGame(eq(MEMBER_ID), any(GameCreateCommand.class))) + .willReturn(new GameCreateResult(50L, board)); + given(gameBoardMapper.toResponse(board)).willReturn(boardDto); + + // when + handler.handle(context("CREATE_GAME"), createPayload(List.of(8L, 7L)), responder); + + // then + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(GameCreateCommand.class); + then(gameCommandService).should().createGame(eq(MEMBER_ID), commandCaptor.capture()); + assertThat(commandCaptor.getValue().gameBoardId()).isEqualTo(GAME_BOARD_ID); + assertThat(commandCaptor.getValue().gameBoardMemberIds()).containsExactly(8L, 7L); + + ArgumentCaptor ackCaptor = ArgumentCaptor.forClass(Object.class); + then(responder).should().send(eq(GameRealtimeProtocol.TYPE_GAME_CREATED), ackCaptor.capture()); + JsonNode ack = objectMapper.valueToTree(ackCaptor.getValue()); + assertThat(ack.get("gameId").asLong()).isEqualTo(50L); + assertThat(ack.get("board").get("courtCount").asInt()).isEqualTo(2); + + // 구독자 전파는 커밋 후 비동기 리스너 몫이다. + verifyNoInteractions(gameBoardQueryService, gameBoardBroadcaster); + } + + @Test + @DisplayName("게임 생성이 도메인 예외로 실패하면 오류로 응답하고 성공 응답을 보내지 않는다") + void respondsErrorWhenCreateFails() { + // given + given(gameCommandService.createGame(eq(MEMBER_ID), any(GameCreateCommand.class))) + .willThrow(new GameException(GameErrorCode.UNAVAILABLE_GAME_PLAYER)); + + // when + handler.handle(context("CREATE_GAME"), createPayload(List.of(7L)), responder); + + // then + then(responder).should().sendError( + GameErrorCode.UNAVAILABLE_GAME_PLAYER.getCode(), + GameErrorCode.UNAVAILABLE_GAME_PLAYER.getMessage()); + then(responder).should(never()).send(anyString(), any()); + verifyNoInteractions(gameBoardQueryService, gameBoardBroadcaster); + } + + @Test + @DisplayName("gameBoardId가 없으면 게임을 만들지 않고 GAME_BOARD_ID_REQUIRED 오류로 응답한다") + void rejectsMissingGameBoardId() { + // given + ObjectNode payload = objectMapper.createObjectNode(); + payload.putArray("gameBoardMemberIds").add(7L); + + // when + handler.handle(context("CREATE_GAME"), payload, responder); + + // then + then(responder).should().sendError( + GameErrorCode.GAME_BOARD_ID_REQUIRED.getCode(), + GameErrorCode.GAME_BOARD_ID_REQUIRED.getMessage()); + verifyNoInteractions(gameCommandService); + } + } + + @Nested + @DisplayName("보드 재조회 후 응답·브로드캐스트하는 액션 (현재 동작 고정)") + class RespondAndBroadcastBoard { + + @Test + @DisplayName("MOVE_TO_WAITING은 요청자에게 BOARD_UPDATED로 응답하고, 요청 세션을 제외해 isGameHost=false 보드를 브로드캐스트한다") + void moveToWaitingRespondsAndBroadcastsExcludingRequester() { + // given + GameBoardResult board = new GameBoardResult(true, 1, List.of(), List.of()); + GameBoardDTO.Response boardDto = new GameBoardDTO.Response(true, 1, List.of(), List.of()); + given(gameBoardQueryService.getBoard(MEMBER_ID, GAME_BOARD_ID)).willReturn(board); + given(gameBoardMapper.toResponse(board)).willReturn(boardDto); + ObjectNode payload = objectMapper.createObjectNode() + .put("gameBoardId", GAME_BOARD_ID) + .put("gameId", 50L); + + // when + handler.handle(context("MOVE_TO_WAITING"), payload, responder); + + // then + then(gameCommandService).should() + .moveGameToWaiting(MEMBER_ID, new GameToWaitingCommand(GAME_BOARD_ID, 50L)); + then(responder).should().send(GameRealtimeProtocol.TYPE_BOARD_UPDATED, boardDto); + then(gameBoardBroadcaster).should() + .broadcastBoardUpdate(GAME_BOARD_ID, boardDto.forBroadcast(), SESSION_ID); + } + } + + private RealtimeRequestContext context(String action) { + return new RealtimeRequestContext(MEMBER_ID, SESSION_ID, "request-1", "GAME", action); + } + + private ObjectNode createPayload(List gameBoardMemberIds) { + ObjectNode payload = objectMapper.createObjectNode().put("gameBoardId", GAME_BOARD_ID); + gameBoardMemberIds.forEach(payload.putArray("gameBoardMemberIds")::add); + return payload; + } +}