Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 }}
Expand Down
12 changes: 10 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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}
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -142,12 +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));
gameBoardBroadcaster.broadcastBoardUpdate(gameBoardId, toBroadcastBoard(boardDto), context.sessionId());
GameBoardDTO.Response boardDto = gameBoardMapper.toResponse(result.board());
responder.send(GameRealtimeProtocol.TYPE_GAME_CREATED, new GameCreatedAck(result.gameId(), boardDto));
}

private void handleMoveToWaiting(
Expand Down Expand Up @@ -202,7 +201,7 @@ private void respondAndBroadcastBoard(
GameBoardDTO.Response boardDto = loadBoardDto(context, gameBoardId);

responder.send(GameRealtimeProtocol.TYPE_BOARD_UPDATED, boardDto);
// 변경을 일으킨 세션은 위에서 직접 응답을 받았으므로 브로드캐스트 대상에서 제외한다.
// 변경을 일으킨 세션은 위에서 직접 응답을 받았으므로 브로드캐스트 대상에서 제외
gameBoardBroadcaster.broadcastBoardUpdate(gameBoardId, toBroadcastBoard(boardDto), context.sessionId());
}

Expand All @@ -212,11 +211,8 @@ private GameBoardDTO.Response loadBoardDto(RealtimeRequestContext context, Long
}

/**
* isGameHost 는 "요청자 자신이 게임 진행자인지"를 뜻하는 개인 값이다.
* 보드 DTO 는 변경을 일으킨 요청자 기준으로 조회되므로, 그대로 브로드캐스트하면
* 진행자가 일으킨 변경 때 다른 구독자에게도 isGameHost=true 가 새어나간다.
* 따라서 브로드캐스트 본문에서는 항상 false 로 내리고, 각 클라이언트는
* 자신의 구독 응답으로 받은 값을 유지하도록 한다.
* 브로드캐스트 본문에서는 항상 false 로 내리고, 각 클라이언트는
* 자신의 구독 응답으로 받은 값을 유지
*/
private GameBoardDTO.Response toBroadcastBoard(GameBoardDTO.Response board) {
return board.forBroadcast();
Expand All @@ -230,21 +226,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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand Down Expand Up @@ -94,10 +99,23 @@ public Long createGame(Long memberId, GameCreateCommand command) {
}

Game savedGame = gameRepository.save(game);
publishMembersChanged(gameBoard.getId(), memberId);
GameBoardResult board = assembleBoardAfterCreate(gameBoard.getId(), activeGames, savedGame);
// 커밋 후 리스너가 이 snapshot을 재조회 없이 전파
eventPublisher.publishEvent(
GameBoardMembersChangedEvent.membersWithBoard(gameBoard.getId(), memberId, board));
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<Game> activeGames, Game savedGame) {
List<Court> courts = courtRepository.findByGameBoardIdOrderByCourtNoAsc(gameBoardId);
List<Game> gamesAfterCreate = new ArrayList<>(activeGames);
gamesAfterCreate.add(savedGame);
return gameBoardResultAssembler.assemble(true, courts, gamesAfterCreate);
}

/**
Expand Down Expand Up @@ -195,8 +213,7 @@ public GameDeleteResult deleteGame(Long memberId, GameDeleteCommand command) {

/**
* 대기열 이동
* 진행 중인 게임을 완료 기록 없이 같은 인원 그대로 대기열 맨 앞으로 되돌리고 코트를 비운다.
* 경기 기록으로 남지 않으며 게임횟수도 증가시키지 않는다.
* 진행 중인 게임을 완료 기록 없이 같은 인원 그대로 대기열 맨 앞으로 되돌리고 코트를 비움
*
* @param memberId 요청자
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading