온라인 게임 플랫폼에서는 경기가 종료된 이후에도 서버에서 처리해야 할 작업이 많다.
경기 결과를 데이터베이스에 저장하고, 승리 보상을 지급하며, 플레이어에게 결과 알림을 보내야 한다. 동시에 랭킹과 통계를 갱신하고, 운영 및 장애 분석에 필요한 로그도 기록해야 한다.
이 모든 작업을 하나의 요청 처리 과정에서 동기적으로 실행하면 문제가 발생한다.
- 작업이 많아질수록 응답 시간이 길어진다.
- 외부 서비스나 데이터베이스에 장애가 발생하면 전체 처리가 지연된다.
- 일부 작업만 성공하면 데이터 간 정합성이 깨질 수 있다.
- 트래픽이 급증하면 서버의 처리 용량을 초과할 수 있다.
이러한 문제를 해결하는 대표적인 방법이 비동기 작업 처리(Asynchronous Processing) 와 이벤트 기반 아키텍처(Event-Driven Architecture) 다.
핵심은 모든 작업을 동시에 끝내는 것이 아니라, 반드시 보장해야 하는 작업과 나중에 처리해도 되는 작업을 분리하는 것이다.
게임 플랫폼의 작업 처리 구조
경기 종료를 예로 들어 보자.
플레이어 A가 플레이어 B와 대결한 뒤 승리하면, 서버는 경기 결과를 확정하고 관련 후속 작업을 처리해야 한다.
[경기 종료]
|
v
[게임 서버]
|
v
[결과 확정 및 저장]
|
v
[이벤트 발행]
|
v
[메시지 브로커 / 작업 큐]
|
+------------------+
| | |
v v v
[포인트] [알림] [통계]
[정산] [전송] [집계]
|
v
[비동기 로그 및 모니터링]
위 구조는 개념적인 예시다. 실제 시스템에서는 결과 저장과 이벤트 발행 사이의 유실을 방지하기 위한 Transactional Outbox, 작업별 독립 소비, 재시도 정책, 중복 처리 방지 로직 등을 추가할 수 있다.
작업을 분리하면 알림 서비스가 일시적으로 중단되더라도 경기 결과 저장이나 포인트 정산까지 반드시 함께 중단할 필요는 없다.
다만 작업을 분리했다고 해서 데이터 정합성이 자동으로 보장되는 것은 아니다. 각 작업의 실패와 재처리를 고려한 설계가 필요하다.
경기 결과 저장
경기 결과 저장의 역할
경기 결과는 게임 플랫폼에서 발생하는 여러 후속 작업의 기준 데이터다.
대표적으로 다음과 같은 정보가 포함된다.
- 경기 ID
- 참가 플레이어 ID
- 승자와 패자
- 경기 시작 및 종료 시각
- 최종 점수
- 게임 모드
- 결과 처리 상태
예를 들어 경기 결과는 다음과 같이 표현할 수 있다.
{
"matchId": "match-1001",
"winnerId": "player-101",
"loserId": "player-202",
"winnerScore": 10,
"loserScore": 6,
"status": "COMPLETED"
}
경기 결과는 단순한 기록이 아니다. 포인트 지급, 랭킹 갱신, 전적 조회, 시즌 통계 계산 등 여러 기능이 의존하는 기준 데이터다.
따라서 결과 저장은 신뢰할 수 있는 방식으로 수행해야 한다.
결과 확정과 후속 작업 분리
경기 종료 시점에 결과 저장, 보상 지급, 알림 전송, 통계 갱신을 모두 동기적으로 실행한다고 가정해 보자.
async function finishMatch(result) {
await saveMatchResult(result);
await settlePoints(result);
await sendNotification(result);
await updateStatistics(result);
return { success: true };
}
이 구조에서는 알림 서비스의 응답이 느려지면 경기 종료 처리가 지연된다. 통계 갱신에서 오류가 발생하면 이미 저장한 결과와 포인트 정산 사이에 불일치가 발생할 수도 있다.
작업을 분리하면 다음과 같이 구성할 수 있다.
async function finishMatch(result) {
await saveMatchResult(result);
await publishMatchCompleted({
matchId: result.matchId
});
return { success: true };
}
이후 이벤트 소비자가 각 작업을 처리한다.
async function handleMatchCompleted(event) {
await settlePoints(event.matchId);
await updateStatistics(event.matchId);
await sendNotification(event.matchId);
}
위 코드는 구조를 설명하기 위한 예시다. 실제 구현에서는 이벤트 발행의 내구성, 작업별 독립 소비, 재시도 및 중복 처리 방지를 추가해야 한다.
특히 결과 저장 후 이벤트 발행 전에 서버가 종료되면 후속 작업이 실행되지 않을 수 있다. 이를 방지하려면 결과 데이터와 Outbox 이벤트를 같은 데이터베이스 트랜잭션으로 저장한 뒤 별도의 발행기가 이벤트를 전달하는 Transactional Outbox 패턴을 고려할 수 있다.
결과 저장 시 주의할 점
경기 결과는 중복 저장을 방지해야 한다. 경기 ID에 유니크 제약 조건을 적용하면 동일한 경기가 여러 번 확정되는 문제를 막는 데 도움이 된다.
경기 결과를 확정하는 권한은 서버가 가져야 한다. 클라이언트가 전송한 승패나 점수를 그대로 신뢰하면 결과 조작이 가능하다.
확정된 결과의 변경 정책도 명확하게 정의해야 한다. 결과를 수정할 수 있다면 포인트와 랭킹을 어떻게 보정할지도 함께 설계해야 한다.
또한 경기 결과 저장과 후속 이벤트 발행의 원자성을 고려해야 한다. 데이터베이스에 결과가 저장되었다는 사실만으로 모든 후속 작업이 실행된다고 보장할 수는 없다.
포인트 정산
포인트 정산은 일반적인 비동기 작업과 다르다
알림 전송은 실패하더라도 나중에 다시 보내는 방식으로 복구할 수 있다. 그러나 포인트 정산은 잘못 처리하면 사용자 자산에 직접적인 영향을 준다.
따라서 포인트 정산에서는 정확한 금액 계산, 원자성, 중복 지급 방지, 재처리 가능성을 우선적으로 고려해야 한다.
승리 보상으로 포인트를 지급한다고 가정해 보자.
경기 결과 확정
|
v
포인트 정산 요청
|
v
중복 지급 여부 확인
|
v
포인트 원장 기록 + 잔액 갱신
|
v
정산 완료
중요한 것은 단순히 잔액을 증가시키는 것이 아니라, 어떤 경기의 어떤 보상으로 포인트가 지급되었는지 기록하는 것이다.
포인트 원장과 잔액
포인트 시스템은 원장(Ledger)과 잔액(Balance)을 구분해서 설계할 수 있다.
- 포인트 원장: 포인트가 증가하거나 감소한 이유와 내역을 기록한다.
- 잔액: 사용자가 현재 사용할 수 있는 포인트를 나타낸다.
예를 들어 다음과 같은 원장 기록을 남길 수 있다.
| transaction_id | player_id | type | amount | reference_id |
|---|---|---|---|---|
| tx-001 | player-101 | MATCH_REWARD | +100 | match-1001 |
| tx-002 | player-101 | ITEM_PURCHASE | -50 | order-2001 |
원장은 정산 내역을 추적하고 장애 이후의 복구 및 감사에 활용할 수 있다.
잔액은 빠른 조회를 위해 별도로 관리할 수 있지만, 원장과 잔액이 서로 일치하도록 트랜잭션으로 처리하거나 복구 절차를 마련해야 한다.
중복 지급 방지
메시지 브로커는 재전달이나 소비자 재시작으로 동일한 이벤트를 여러 번 전달할 수 있다. 따라서 이벤트가 한 번만 전달된다고 가정해서는 안 된다.
예를 들어 다음 코드만으로는 충분하지 않다.
async function rewardPlayer(playerId, amount) {
await increaseBalance(playerId, amount);
}
동일한 이벤트가 두 번 처리되면 보상이 중복 지급될 수 있다.
대신 경기 ID와 보상 종류를 조합한 고유 키를 사용할 수 있다.
idempotency_key = match-1001:WIN_REWARD
데이터베이스에서 이 키에 유니크 제약 조건을 적용하면 동일한 보상 거래가 중복 생성되는 것을 막는 데 도움이 된다.
개념적인 처리 순서는 다음과 같다.
BEGIN TRANSACTION
보상 거래의 고유 키 등록
포인트 원장 기록
플레이어 잔액 갱신
COMMIT
고유 키 등록, 원장 기록, 잔액 갱신은 동일한 데이터베이스 트랜잭션 안에서 처리해야 한다. 중간에 실패하면 전체 작업을 롤백한다.
중복 키 오류가 발생하면 기존 거래의 처리 상태를 확인해 이미 완료된 요청인지 판단하는 것이 좋다.
서로 다른 경기가 동일한 플레이어의 잔액을 동시에 변경할 수도 있으므로, 행 잠금이나 원자적 증가 쿼리 등 동시성 제어도 필요하다.
알림 전송
알림을 비동기로 처리하는 이유
경기가 종료된 직후 플레이어에게 결과 알림을 전송할 수 있다.
- 경기 승리 또는 패배 알림
- 포인트 획득 알림
- 친구 접속 알림
- 매칭 완료 알림
- 시즌 종료 및 랭킹 변동 알림
하지만 알림은 경기 결과 확정과 비교하면 상대적으로 긴급하지 않은 경우가 많다.
알림 서비스가 느리거나 외부 푸시 서비스에 장애가 발생하더라도 경기 결과 저장과 포인트 정산을 지연시키지 않는 것이 바람직하다.
따라서 알림을 별도의 작업 큐에서 처리할 수 있다.
async function handleMatchCompleted(event) {
await notificationQueue.add("match-result", {
matchId: event.matchId,
playerId: event.playerId
});
}
이후 알림 작업자가 큐에서 작업을 가져와 실제 전송을 수행한다.
알림 상태 관리
알림 작업은 처리 상태를 관리하면 운영하기 편하다.
| 상태 | 의미 |
|---|---|
| PENDING | 전송 대기 |
| PROCESSING | 전송 처리 중 |
| SENT | 전송 성공 |
| RETRY_WAIT | 재시도 대기 |
| FAILED | 재시도 한도 초과 또는 영구 실패 |
푸시 서비스가 일시적으로 응답하지 않으면 일정 시간 후 재시도할 수 있다.
다만 네트워크 타임아웃이 발생했다고 해서 알림이 반드시 전송되지 않은 것은 아니다. 실제 전송은 성공했지만 응답만 유실되었을 수도 있다.
외부 서비스가 멱등성 키를 지원한다면 이를 활용하고, 지원하지 않는다면 중복 알림 가능성을 고려해야 한다.
또한 알림 서비스는 사용자의 알림 설정, 차단 상태, 수신 채널, 개인정보 보호 정책을 확인해야 한다.
통계 집계
실시간 통계와 배치 통계
게임 플랫폼에서는 다양한 통계를 제공한다.
- 플레이어별 승률
- 경기당 평균 점수
- 일일 및 주간 활성 사용자
- 게임 모드별 이용량
- 평균 경기 시간
- 시즌별 랭킹
- 아이템 구매 전환율
이러한 통계를 경기 결과 저장과 같은 트랜잭션 안에서 모두 계산하면 데이터베이스에 부하가 집중될 수 있다.
경기 결과가 저장될 때마다 전체 경기 기록을 조회해 승률을 다시 계산하는 방식은 데이터가 증가할수록 비효율적이다.
대신 목적에 따라 다음과 같이 나눌 수 있다.
실시간 집계
이벤트가 발생할 때 카운터나 집계 테이블을 갱신한다. 빠른 결과 제공에 적합하지만 중복 이벤트를 고려해야 한다.
배치 집계
일정 시간 간격으로 데이터를 모아 계산한다. 대규모 분석에 적합하지만 통계 반영이 지연될 수 있다.
분석 시스템 활용
이벤트를 데이터 웨어하우스나 분석용 저장소로 전달해 복잡한 집계와 장기 분석을 수행한다.
증분 집계
플레이어의 승리 횟수와 경기 횟수를 관리한다면, 경기 결과가 확정될 때 필요한 값만 증가시킬 수 있다.
UPDATE player_statistics
SET
total_matches = total_matches + 1,
total_wins = total_wins + 1
WHERE player_id = ?;
위 쿼리는 승리한 플레이어를 기준으로 한 단순 예시다. 실제 구현에서는 패배한 플레이어의 경기 수, 무승부, 경기 취소, 결과 정정 등도 처리해야 한다.
이벤트가 중복 소비되면 통계도 중복 증가할 수 있다.
이를 방지하려면 이벤트 ID를 처리 이력 테이블에 기록하거나, 집계 대상 이벤트를 유니크 키로 관리할 수 있다.
집계 갱신과 이벤트 처리 이력 기록을 동일한 트랜잭션으로 묶으면 소비자가 재시작되더라도 중복 집계를 방지하기 쉽다.
집계 데이터와 원본 데이터
집계 데이터는 조회 성능을 높이는 데 유용하지만, 원본 데이터를 대체해서는 안 된다.
통계 처리 로직에 버그가 발생하거나 이벤트가 누락되면 집계 값이 실제 경기 기록과 달라질 수 있다.
따라서 원본 경기 기록을 기준으로 통계를 재계산하고, 일정 주기마다 집계 값과 원본 데이터의 차이를 검증하는 것이 좋다.
집계 버전과 마지막 처리 이벤트를 기록하면 복구 범위를 파악하기 쉬워진다. 결과 정정 시에는 기존 집계를 보정하거나 다시 계산해야 한다.
통계 시스템은 빠른 조회를 담당하고, 원본 경기 기록은 정확한 결과를 보존하는 역할을 담당하도록 분리하는 것이 핵심이다.
비동기 로그 처리
로그 처리의 목적
게임 서버에서는 다양한 로그가 발생한다.
- 경기 시작 및 종료
- 플레이어 연결과 연결 해제
- 인증 실패
- 포인트 정산 결과
- 매칭 및 매치메이킹 실패
- 데이터베이스 오류
- 작업 재시도 및 최종 실패
- 서버 지연과 처리량
로그는 장애 원인 분석과 서비스 품질 개선에 중요하다. 그러나 모든 로그를 외부 저장소에 동기적으로 전송하면 로그 시스템이 게임 서버의 성능에 영향을 줄 수 있다.
이를 줄이기 위해 로그를 버퍼나 큐에 적재한 후 별도 작업자가 전송하는 구조를 사용할 수 있다.
[게임 서버]
|
v
[구조화 로그 생성]
|
v
[버퍼 / 로그 에이전트]
|
v
[로그 수집 시스템]
|
v
[검색 및 대시보드]
Node.js 환경에서는 JSON 형식의 구조화 로그를 생성하고, 로그 수집 에이전트가 이를 중앙 저장소로 전달하도록 구성할 수 있다.
로그 수집 시스템이 일시적으로 중단되어도 게임 서버의 핵심 처리 흐름이 영향을 덜 받도록 설계할 수 있다.
구조화 로그
단순 문자열 로그보다 구조화 로그를 사용하면 검색과 분석이 쉬워진다.
{
"level": "info",
"event": "point_settlement_completed",
"matchId": "match-1001",
"playerId": "player-101",
"amount": 100,
"durationMs": 24
}
이런 로그는 경기 ID, 플레이어 ID, 이벤트 종류, 처리 시간 등을 기준으로 검색할 수 있다.
다만 인증 토큰, 비밀번호, 개인정보 등 민감한 데이터를 로그에 기록해서는 안 된다. 사용자 식별자는 접근 권한과 보존 정책을 고려해 사용해야 한다.
로그 유실과 로그의 우선순위
비동기 로그는 성능을 개선할 수 있지만, 버퍼가 가득 차거나 프로세스가 비정상 종료되면 로그가 유실될 수 있다.
따라서 로그를 모두 동일한 중요도로 취급하지 않는 것이 좋다.
- 디버그 로그: 유실을 어느 정도 허용할 수 있는 상세 진단 정보
- 운영 로그: 서비스 상태와 요청 흐름을 분석하기 위한 정보
- 감사 및 정산 기록: 단순 로그가 아니라 데이터베이스 등 신뢰할 수 있는 저장소에 기록해야 하는 정보
특히 포인트 지급 성공 여부를 일반 로그에만 기록하는 것은 위험하다. 정산 내역은 원장과 트랜잭션으로 보장하고, 로그는 이를 관찰하고 추적하는 보조 수단으로 사용해야 한다.
장애 대응과 재처리
비동기 구조에서 작업 실패는 예외 상황이 아니라 정상적으로 고려해야 하는 상황이다.
경기 결과 이벤트를 소비하던 작업자가 데이터베이스 연결 오류로 종료될 수 있다. 이때 무작정 무한 재시도를 수행하면 장애가 발생한 서비스에 요청이 집중되어 상황을 악화시킬 수 있다.
지수 백오프
재시도 간격을 점차 늘리는 지수 백오프(Exponential Backoff)를 사용할 수 있다.
예를 들어 초기 대기 시간을 짧게 설정한 뒤 재시도할 때마다 대기 시간을 늘린다. 실제 시스템에서는 최대 대기 시간과 무작위 지연(Jitter)을 추가해 여러 작업이 동시에 재시도되는 현상을 줄인다.
Dead Letter Queue
재시도 한도를 초과한 작업은 Dead Letter Queue(DLQ)로 이동시킬 수 있다.
DLQ에 저장된 작업은 다음과 같은 방식으로 처리한다.
- 실패한 이벤트와 오류 원인을 기록한다.
- 운영자가 실패 유형을 분석한다.
- 데이터나 설정 오류를 수정한다.
- 안전성을 검증한 후 작업을 재처리한다.
DLQ는 실패한 작업을 영구 폐기하는 장소가 아니라, 자동 처리에 실패한 작업을 격리하고 복구하기 위한 수단이다.
작업별 독립성
포인트 정산, 알림, 통계를 하나의 소비자에서 순서대로 처리하면 앞선 작업의 실패가 뒤의 모든 작업을 지연시킬 수 있다.
따라서 작업의 중요도와 처리 특성에 따라 소비자를 분리하는 것이 좋다.
포인트 정산은 강한 정합성과 중복 방지가 중요하다. 통계는 재계산 가능성이 중요하며, 알림은 재시도와 사용자 수신 설정이 중요하다.
작업마다 서로 다른 실패 정책을 적용할 수 있도록 구조를 분리해야 한다.
설계 시 고려할 사항
| 항목 | 핵심 설계 원칙 |
|---|---|
| 경기 결과 | 결과 확정의 권한과 중복 저장 방지 |
| 포인트 정산 | 원장, 트랜잭션, 멱등성, 동시성 제어 |
| 알림 전송 | 재시도, 중복 전송 가능성, 사용자 설정 |
| 통계 집계 | 중복 이벤트 방지, 원본 기반 재계산 |
| 비동기 로그 | 버퍼 관리, 로그 유실 정책, 민감 정보 제거 |
| 이벤트 발행 | 결과 저장과 이벤트 유실 방지 |
| 작업 복구 | 지수 백오프, DLQ, 안전한 재처리 |
| 운영 모니터링 | 큐 적체, 처리 지연, 실패율, 재시도 횟수 |
메시지 브로커를 도입했다고 해서 시스템의 모든 문제가 해결되는 것은 아니다. 브로커 자체의 가용성, 메시지 보존 기간, 소비자 확장, 파티션별 순서 보장, 데이터베이스와의 정합성도 함께 고려해야 한다.