https://wayhope.kro.kr/gitlab/wayhope/pokermultiplayer.git
TypeScript 모노레포를 기반으로 REST API, 실시간 게임 서버, 모바일 클라이언트, 인프라까지 구축하는 과정을 정리한다.
단순히 실행되는 포커 게임을 만드는 것이 목표가 아니다. 칩이 오가는 실시간 시스템에서 반드시 지켜야 하는 데이터 정합성, 보안, 동시성 제어, 장애 복구, 운영 안정성에 초점을 맞춘다.
들어가며
포커 게임은 겉보기에는 단순하다. 카드를 섞고, 플레이어에게 나눠 주고, 베팅하고, 승자가 팟을 가져간다. 하지만 이 게임을 여러 사람이 동시에 접속하는 서버로 구현하면 해결해야 할 문제가 급격히 늘어난다.
- 상대방의 홀카드가 네트워크를 통해 노출되지 않는다는 것을 어떻게 보장할 것인가?
- 두 플레이어가 동시에 같은 칩을 사용해 서로 다른 테이블에 입장하려 하면 어떻게 처리할 것인가?
- 핸드가 종료되는 순간 서버가 죽으면 칩은 어떻게 보존할 것인가?
- 동일한 핸드가 두 번 정산되는 상황을 어떻게 방지할 것인가?
- 로그아웃이나 계정 정지 이후에도 연결되어 있는 WebSocket을 어떻게 종료할 것인가?
- 롤링 업데이트를 수행할 때 플레이어의 좌석과 진행 중인 게임을 어떻게 유지할 것인가?
이 프로젝트는 이러한 문제에 구체적인 코드와 아키텍처로 답하는 과정이다.
기술 스택
| 구성 요소 | 기술 |
|---|---|
| 게임 서버 | Colyseus 0.16, WebSocket, 상태 동기화 |
| API 서버 | Fastify, REST API |
| 데이터베이스 | MySQL 8, Prisma ORM |
| 캐시 및 분산 처리 | Redis 7 |
| 모바일 클라이언트 | React Native, Expo SDK 52, expo-router |
| 배포 | Docker Compose, Nginx Proxy Manager, Let’s Encrypt |
| 오케스트레이션 | Kubernetes 매니페스트, HPA 구성 |
| 테스트 | Vitest, 단위 테스트, 통합 테스트, E2E 테스트 |
테스트는 총 109개로 구성했다. 기본 테스트 88개와 실제 MySQL·Redis가 필요한 통합 테스트 21개를 분리해 실행한다.
코드 규모는 도메인 약 1,200줄, 애플리케이션 약 1,100줄, 인프라 어댑터 약 1,200줄, 게임 서버 약 900줄, API 약 150줄, 모바일 앱 약 1,000줄이다.
이 구조에서 주목할 점은 포커 규칙 엔진보다 주변의 안전장치에 더 많은 코드가 필요하다는 것이다. 게임 규칙 자체를 구현하는 것과 그 규칙이 실제 운영 환경에서도 정확하게 유지되도록 만드는 것은 서로 다른 문제다.
제공하는 기능
사용자 기능
- 이메일과 비밀번호를 이용한 회원가입 및 로그인
- 로그아웃, 전체 기기 로그아웃, 비밀번호 변경
- 가입 시 기본 칩 10,000개 지급 및 지갑 조회
- 블라인드, 바이인 범위, 착석 인원, 핸드 진행 여부를 표시하는 실시간 로비
- 테이블 입장, 좌석 선택, 바이인, 자리 비움 및 복귀, 퇴장
- 텍사스 홀덤 전체 규칙 구현
- 폴드, 체크, 콜, 레이즈, 올인 및 사이드 팟 처리
- 프리플랍, 플랍, 턴, 리버, 쇼다운 및 족보 평가
- 턴 제한 시간 20초 및 시간 초과 시 자동 체크·폴드
- 네트워크 연결이 끊어진 경우 60초 동안 좌석 유지
- 자동 재접속 및 기존 좌석 복구
- 핸드 종료 후 덱 공개를 통한 셔플 공정성 검증
운영 기능
/health/live,/health/ready헬스 체크- Prometheus 형식의
/metrics지표 제공 - 접속자 수 기반 Kubernetes HPA 확장 지표
- 서버 종료 시 진행 중인 핸드 처리 및 좌석 유지
- 서버 비정상 종료 시 Redis 스냅샷을 이용한 게임 상태 복구
- 로그인 실패, 계정 잠금, 리프레시 토큰 재사용, WebSocket 인증 거절 등의 보안 감사 기록
전체 아키텍처
시스템 구성
전체 시스템은 모바일 클라이언트, API 서버, Colyseus 게임 서버, MySQL, Redis로 구성한다.
React Native Mobile
|
v
Nginx Proxy Manager
HTTP / HTTPS / WSS
|
+--------------------+
| |
v v
Fastify API Colyseus Proxy
| |
| +-----+-----+
| | |
| v v
| Game 1 Game 2 ...
| | |
+--------------+-----------+
|
+---------+---------+
| |
v v
MySQL Redis
계정 및 원장 캐시 및 분산 상태
외부에 공개하는 포트는 Nginx Proxy Manager의 HTTP·HTTPS 포트뿐이다. MySQL, Redis, API 서버, Colyseus 프록시는 호스트의 루프백 주소에 바인딩해 외부에서 직접 접근하지 못하도록 구성한다.
하나의 도메인에서 REST API와 WebSocket 연결을 모두 처리하기 위해 경로 기반 라우팅을 사용한다.
| 경로 | 전달 대상 |
|---|---|
/api/* | Fastify API 서버. /api 접두사를 제거한 뒤 전달 |
/matchmake/* | Colyseus 프록시 |
| 게임 WebSocket 경로 | Colyseus 프록시를 거쳐 게임 서버로 전달 |
API 요청에서는 X-Forwarded-For를 프록시가 확인한 실제 접속 IP로 덮어써 클라이언트가 임의로 설정한 IP 헤더를 신뢰하지 않도록 한다.
모노레포와 계층 구조
프로젝트는 TypeScript 모노레포로 구성한다.
apps/
api/ Fastify REST API
game/ Colyseus 게임 서버
mobile/ Expo React Native 앱
packages/
domain/ 순수 도메인 모델과 게임 규칙
application/ 유스케이스와 포트 인터페이스
infrastructure/ Prisma, Redis, 인증 구현
contracts/ DTO, Zod 스키마, 메시지 계약
각 계층의 책임은 명확하게 구분한다.
Domain
카드, 족보 평가, 팟 분배, 핸드 진행, 사용자와 세션 등의 도메인 규칙을 담당한다. 네트워크나 데이터베이스 같은 외부 의존성을 갖지 않는다.
Application
IdentityService, TableSession, OutboxDispatcher 등의 유스케이스를 제공한다. 데이터 저장소에 직접 의존하지 않고 WalletLedger, TableSnapshotStore, DistributedLock 같은 포트 인터페이스를 사용한다.
Infrastructure
Application에서 정의한 포트를 Prisma, MySQL, Redis, Argon2id, JWT 등으로 구현한다. 테스트와 로컬 개발을 위한 메모리 구현도 제공한다.
Apps
Fastify 라우트와 Colyseus Room 등 외부 요청을 받아 애플리케이션 계층에 전달하는 역할을 수행한다.
Contracts
서버와 모바일 클라이언트가 공유하는 DTO, Zod 스키마, 메시지 이름, 종료 코드 등을 정의한다.
의존성은 바깥에서 안쪽으로만 흐른다.
Apps → Application → Domain
↑
Infrastructure
Infrastructure는 Application이 정의한 인터페이스를 구현한다. Domain은 외부 시스템을 알지 못하며, Application 역시 특정 데이터베이스나 캐시 제품을 직접 알 필요가 없다.
이 구조의 핵심은 테스트 가능성이다. 게임 규칙은 네트워크 없이 검증하고, 정산 흐름은 메모리 어댑터로 테스트하며, 실제 SQL과 Redis 동작은 별도의 통합 테스트로 확인할 수 있다.
순수 도메인: 홀덤 규칙 엔진
HoldemHand 애그리게이트
HoldemHand는 하나의 포커 핸드에서 발생하는 규칙을 관리하는 애그리게이트다.
턴 진행, 베팅, 올인, 사이드 팟, 쇼다운 등의 규칙을 내부에서 처리한다. 입력은 플레이어의 행동이고, 출력은 게임 상태 변경을 나타내는 이벤트 목록이다.
export interface PlayerAction {
type: "fold" | "check" | "call" | "raise" | "allin";
amount?: number;
}
raise의 amount는 이번 베팅 라운드에서 최종적으로 맞추려는 총 베팅 금액인 raise to를 의미한다.
핸드를 시작할 때는 입력에 대한 불변 조건을 검증한다.
invariant(
deck.length === 52 && new Set(deck).size === 52,
"INVALID_DECK"
);
invariant(players.length >= 2, "NOT_ENOUGH_PLAYERS");
invariant(
Number.isInteger(p.stack) && p.stack > 0,
"INVALID_STACK"
);
덱이 정확히 52장이고 중복이 없는지, 플레이어 스택이 양의 정수인지, 버튼 좌석이 실제 착석자인지 등을 확인한다.
핸드 상태는 HandSnapshot으로 직렬화하고 복원할 수 있다. 이 기능은 서버 장애 이후 게임을 복구하는 기반이 된다.
또한 핸드는 version과 turnSeq를 가진다.
version: 게임 상태가 변경될 때 증가한다. 클라이언트가 보고 있는 상태가 최신인지 확인하는 데 사용한다.turnSeq: 차례가 바뀔 때 증가한다. 이전 차례에 등록한 타이머가 현재 게임에 잘못 적용되지 않도록 한다.
사이드 팟 계산
올인이 발생하면 플레이어마다 베팅할 수 있는 금액이 달라진다. 따라서 전체 베팅 금액을 하나의 팟으로 처리할 수 없으며, 기여 금액의 레벨을 기준으로 팟을 분리해야 한다.
export function buildPots(
contribs: readonly Contribution[]
): Pot[] {
const levels = [
...new Set(
contribs
.filter((c) => !c.folded && c.committed > 0)
.map((c) => c.committed)
),
].sort((a, b) => a - b);
const pots: Pot[] = [];
let prev = 0;
for (const level of levels) {
let amount = 0;
for (const c of contribs) {
amount += Math.max(
0,
Math.min(c.committed, level) -
Math.min(c.committed, prev)
);
}
const eligible = contribs
.filter(
(c) => !c.folded && c.committed >= level
)
.map((c) => c.userId);
if (amount > 0) {
pots.push({ amount, eligible });
}
prev = level;
}
// 잔여 칩과 특수 상황 처리
return pots;
}
사이드 팟 계산에서는 다음 조건을 지켜야 한다.
- 폴드한 플레이어가 낸 칩도 팟에 포함한다.
- 폴드한 플레이어는 해당 팟의 수령 자격에서 제외한다.
- 특정 팟에 수령 자격자가 한 명뿐이면 콜을 받지 못한 초과 베팅을 돌려주는 처리가 필요하다.
- 모든 팟의 합은 플레이어가 기여한 칩의 합과 같아야 한다.
즉, 다음 불변 조건을 항상 만족해야 한다.
sum(pots) === sum(contributions)
이 조건은 단위 테스트와 퍼징 테스트를 통해 검증한다.
족보 평가기
족보 평가기는 플레이어가 보유한 카드 중 최적의 5장을 선택해 점수로 변환한다.
족보 카테고리를 가장 높은 자릿수에 배치하고, 킥커를 포함한 타이브레이크 값을 뒤에 이어 붙여 정수 하나로 비교한다.
let score = category;
for (let i = 0; i < 5; i++) {
score = score * BASE + (tiebreak[i] ?? 0);
}
return score;
점수가 높을수록 강한 패다. 동일한 점수는 동점으로 처리한다.
A-2-3-4-5 형태의 휠 스트레이트처럼 일반적인 순위 비교로 처리하기 어려운 경계 조건은 별도 분기로 다룬다.
최대 7장의 카드에서 5장을 선택하는 조합은 21가지다. 모든 조합을 평가하고 가장 높은 점수를 선택하는 단순한 방식을 사용한다.
이 구현은 불필요한 최적화보다 정확성과 검증 가능성을 우선한다.
공정한 셔플
운영 환경에서는 Fisher–Yates 셔플과 crypto.randomInt를 사용한다.
export class CsprngRng implements Rng {
int(maxExclusive: number) {
return randomInt(maxExclusive);
}
}
Math.random()이나 시드 기반 의사난수 생성기를 운영용 카드 셔플에 사용하지 않는다.
난수 생성기는 Rng 인터페이스 뒤에 배치한다. 테스트에서는 재현 가능한 시드 기반 RNG를 주입하고, 운영 환경에서는 암호학적으로 안전한 난수 생성기를 사용한다.
이렇게 하면 동일한 규칙 엔진을 유지하면서 테스트 재현성과 운영 보안성을 모두 확보할 수 있다.
TableSession: 규칙과 네트워크 사이의 조정자
TableSession은 테이블 하나의 게임 진행을 조정하는 애플리케이션 서비스다.
Colyseus에 직접 의존하지 않으며, 명령을 받아 도메인 로직 실행, 정산, 스냅샷 저장을 조정한 뒤 TableEvent 목록을 반환한다.
Room은 이 이벤트를 네트워크로 전달하는 역할에 집중한다.
테이블당 단일 작성자
Node.js는 싱글 스레드로 동작하지만 비동기 함수의 await 지점에서는 다른 명령이 실행될 수 있다. 따라서 비동기 처리가 포함된 게임 상태 변경은 경쟁 조건을 일으킬 수 있다.
예를 들어 한 플레이어의 정산이 진행되는 도중 다른 플레이어의 액션이 들어오면, 두 작업이 같은 상태를 기준으로 서로 다른 결과를 만들 가능성이 있다.
이를 방지하기 위해 모든 상태 변경 명령을 하나의 Promise 큐에서 순차적으로 실행한다.
run<T>(fn: () => Promise<T>): Promise<T> {
const next = this.queue.then(fn, fn);
this.queue = next.catch(() => undefined);
return next;
}
앞선 명령이 실패하더라도 큐가 영구적으로 막히지 않도록 실패를 처리한다.
이 구조에서는 한 테이블의 상태를 변경하는 명령이 순서대로 실행되므로, 테이블 내부 상태에 대한 작성자가 항상 하나로 유지된다.
단, 이 보장은 하나의 TableSession 인스턴스 안에서만 성립한다. 여러 서버 프로세스가 동일한 테이블을 동시에 처리하지 못하도록 별도의 분산 소유권 제어도 필요하다.
명령 멱등성
모바일 네트워크에서는 요청이 중복 전송될 수 있다. 클라이언트는 모든 명령에 고유한 commandId를 포함하고, 서버는 처리한 명령을 기록한다.
const key = `${userId}:${commandId}`;
if (this.seenCommands.has(key)) {
return { events: [], duplicate: true };
}
사용자 ID와 명령 ID를 조합해 중복 여부를 판단한다.
성공한 명령만 처리 완료로 기록한다. 거절된 명령은 같은 ID로 다시 시도할 수 있어야 하기 때문이다.
다만 메모리 기반 중복 기록은 프로세스가 재시작되면 사라질 수 있다. 여러 서버에서 처리하거나 장애 복구 이후에도 멱등성을 유지해야 하는 작업은 영속 저장소의 고유 제약과 트랜잭션을 함께 사용해야 한다.
낡은 상태의 액션 차단
플레이어의 액션에는 현재 보고 있는 핸드 ID와 버전을 함께 포함한다.
if (
hand.handId !== msg.handId ||
hand.version !== msg.version
) {
throw new AppError(
"CONFLICT",
"STALE_STATE",
"화면이 최신 상태가 아닙니다"
);
}
상대방이 방금 레이즈했지만 클라이언트가 이전 상태를 보고 있는 경우, 오래된 상태를 기준으로 한 액션은 거절된다.
이를 통해 플레이어가 실제로 확인한 금액과 다른 금액으로 베팅하는 문제를 방지한다.
차례에 종속된 타이머
턴 타이머는 등록된 시점의 핸드, 차례 번호, 플레이어를 기억한다.
turnTimeout(userId, handId, turnSeq) {
return this.run(async () => {
const hand = this.table.hand;
if (
!hand ||
hand.isComplete ||
hand.handId !== handId ||
hand.turnSeq !== turnSeq
) {
return { events: [] };
}
if (hand.actorUserId !== userId) {
return { events: [] };
}
// 자동 체크 또는 폴드
});
}
타이머가 실행될 때 해당 차례가 여전히 유효한지 확인한다.
그사이에 플레이어가 정상적으로 행동했거나 차례가 변경됐다면 이전 타이머는 아무 작업도 하지 않는다.
연속으로 타임아웃이 발생하면 해당 플레이어를 자리 비움 상태로 전환하는 정책도 적용한다.
Colyseus Room: 숨겨진 정보와 네트워크 계층
공개 상태와 비공개 정보의 분리
Colyseus의 Schema 상태는 연결된 클라이언트에 동기화된다. 따라서 Schema에는 모든 플레이어가 알아도 되는 정보만 포함해야 한다.
/**
* 모든 클라이언트에 동기화되는 공개 상태만 정의한다.
* 홀카드, 덱 순서, RNG 상태는 포함하지 않는다.
*/
export class PublicSeat extends Schema {
// seat, stack, folded, allIn, connected, shown ...
}
홀카드와 덱 순서, 난수 생성기 상태를 공개 Schema에 넣어서는 안 된다.
- 홀카드는
game:private-cards메시지로 해당 플레이어에게만 전송한다. - 쇼다운에서 공개된 카드는
shown등의 공개 상태로 동기화한다. - 장애 복구용 스냅샷에는 홀카드와 덱이 포함될 수 있으므로 외부에 노출하지 않는다.
핵심은 클라이언트 UI에서 카드를 숨기는 것이 아니다. 상대방에게 공개하면 안 되는 정보 자체를 상대방의 네트워크 메시지에 포함하지 않는 것이다.
E2E 테스트에서도 상대방의 홀카드가 어떤 메시지에도 포함되지 않는지 검증한다.
WebSocket 입장 인증
Colyseus의 onAuth에서 연결 시점의 인증을 수행한다.
override async onAuth(_client, options, context) {
if (serverState.draining || this.draining) {
throw new ServerError(503, "SERVER_DRAINING");
}
// 테이블 ID 확인
const rl = await svc.rateLimiter.consume(
`ws-auth:${ip}`,
60,
60
);
if (!rl.allowed) {
throw new ServerError(429, "TOO_MANY_REQUESTS");
}
const token = context.token;
if (!token) {
throw new ServerError(401, "MISSING_TOKEN");
}
const principal = await svc.identity.authenticate(token);
assertCanJoinTable(principal);
return principal;
}
인증 과정에서는 다음 항목을 검증한다.
- 서버가 종료 준비 상태인지
- 테이블 접근 권한이 있는지
- IP 기준 접속 제한을 초과했는지
- JWT가 유효한지
- 세션이 활성 상태인지
- 계정이 정지되었거나 제한된 상태인지
인증에 실패하면 WS_AUTH_REJECTED 보안 이벤트를 기록한다.
JWT는 URL에 넣지 않고 인증 헤더로 전달한다. URL에 토큰을 포함하면 프록시와 인그레스 접근 로그 등에 민감한 값이 남을 수 있기 때문이다.
메시지 처리 파이프라인
모든 클라이언트 메시지는 공통 검증 절차를 거친다.
인증 주체 확인
↓
토큰 버킷 기반 속도 제한
↓
토큰 만료 확인
↓
Zod 스키마 검증
↓
TableSession 명령 실행
↓
게임 이벤트 적용 및 결과 응답
메시지 처리 정책은 다음과 같다.
- 초당 10건, 버스트 20건을 허용하는 토큰 버킷 적용
- 위반이 누적되면 연결 종료
- 정의되지 않은 메시지도 속도 제한에 포함
- 모든 명령에
game:action-result로 성공 또는 실패 응답 반환 - 중복 요청은
DUPLICATE코드로 구분
클라이언트에서 보낸 값은 신뢰하지 않는다. 게임 상태를 변경하는 최종 판단은 서버가 수행한다.
연결 종료와 폴드의 분리
모바일 환경에서는 네트워크가 불안정하거나 소켓이 조용히 끊어지는 경우가 많다.
따라서 비자발적인 연결 종료를 곧바로 폴드로 처리하지 않고, 60초 동안 좌석을 유지한다.
- 연결이 끊긴 플레이어의 차례에는 최대 4초만 기다린다.
- 차례가 지나면 서버 타이머가 자동 체크 또는 폴드를 처리한다.
- 재접속 유예 시간이 끝나면 좌석을 정리하고 남은 칩을 환급한다.
- 같은 사용자가 다른 기기로 접속하면 기존 연결을 종료 코드
4409로 대체한다.
이렇게 연결 상태와 게임 규칙을 분리하면 일시적인 네트워크 장애가 불필요한 게임 종료로 이어지는 것을 방지할 수 있다.
칩 원장과 정산: 데이터 정합성 보장
지갑 데이터 모델
칩 관리에는 단순한 잔액 필드만 사용하지 않는다.
wallet.balance
사용 가능한 칩
wallet.reserved
테이블에 예약된 칩의 합
ChipReservation
사용자와 테이블별 스택의 DB 사본
LedgerEntry
추가 전용 원장, idempotencyKey 고유 제약
Settlement
핸드당 하나의 정산 기록, handId 기본 키
바이인은 사용 가능한 칩을 예약 칩으로 이동시키는 작업이다. 퇴장할 때는 반대로 처리한다.
핸드 결과는 테이블에 예약된 칩의 증감으로 반영한다. 음수 잔액은 애플리케이션 검증뿐 아니라 데이터베이스의 CHECK 제약으로도 방지한다.
하나의 트랜잭션으로 정산
핸드가 종료되면 settleHand에서 하나의 MySQL 트랜잭션으로 모든 정산 작업을 처리한다.
handId를 기준으로 정산 레코드를 생성한다.- 이미 정산된 핸드라면 중복 처리 없이 종료한다.
- 핸드 기록, 덱 공개 정보, 참가자, 액션, 결과 JSON을 저장한다.
- 플레이어별 예약 칩, 지갑, 원장을 갱신한다.
- 같은 트랜잭션 안에 Outbox 이벤트를 기록한다.
예약 칩을 갱신할 때는 기존 값이 예상한 시작 스택과 일치하는지 확인한다.
const r = await tx.chipReservation.updateMany({
where: {
userId: p.userId,
tableId: s.tableId,
amount: p.startStack,
},
data: {
amount: p.endStack,
},
});
if (r.count !== 1) {
throw new ReservationConflictError(
`reservation mismatch for ${p.userId}`
);
}
이 조건부 업데이트는 읽은 값과 실제로 변경하려는 값이 다를 때 정산을 중단한다.
다른 작업이 예약 칩을 먼저 변경했다면 조건이 일치하지 않으므로 트랜잭션을 롤백한다. 잘못된 상태를 덮어쓰는 대신 충돌을 명시적으로 감지하는 방식이다.
정산 전 불변 조건 검증
데이터베이스에 결과를 반영하기 전에 칩의 이동이 정확한지 검증한다.
if (p.endStack !== p.startStack + net) {
fail(`stack mismatch for ${p.userId}`);
}
if (netSum !== 0) {
fail(`net sum is ${netSum}`);
}
if (startSum !== endSum) {
fail("chips not conserved");
}
if (potTotal !== winTotal) {
fail("pot payout mismatch");
}
핵심 검증 조건은 다음과 같다.
- 시작 스택과 순손익의 합이 종료 스택과 일치해야 한다.
- 플레이어 전체의 순손익 합은 0이어야 한다.
- 전체 칩의 합은 변하지 않아야 한다.
- 팟의 총액과 지급된 칩의 합이 일치해야 한다.
이 조건을 위반하는 상황은 일반적인 재시도로 해결할 문제가 아니다. 테이블을 SETTLEMENT_PENDING 상태로 전환하고 운영자의 확인을 요구한다.
정합성이 깨진 결과를 자동으로 확정하는 것보다 정산을 중단하는 편이 안전하다.
동시성 제어와 잠금 순서
동일한 사용자의 바이인, 환급, 핸드 정산이 동시에 발생할 수 있다.
이를 방지하기 위해 모든 작업에서 잠금 순서를 통일한다.
지갑 행 → 예약 칩
정산에 여러 사용자가 참여하는 경우에는 userId 순서로 행을 잠근다.
async function lockWallet(tx: Tx, userId: string) {
await tx.$queryRaw`
SELECT userId
FROM wallets
WHERE userId = ${userId}
FOR UPDATE
`;
}
일관된 잠금 순서는 교착 상태가 발생할 가능성을 줄인다.
잔액 차감은 UPDATE ... WHERE balance >= amount 형태의 조건부 업데이트로 처리한다. 잔액이 충분하지 않으면 변경이 적용되지 않으므로 음수 잔액을 방지할 수 있다.
멱등 키는 잠금을 획득한 뒤 확인한다. 앞선 트랜잭션이 커밋한 결과를 확인한 후 중복 요청 여부를 판단하기 위해서다.
Transactional Outbox 패턴
정산은 완료됐지만 이벤트 발행에 실패하는 상황을 고려해야 한다.
정산 트랜잭션 안에서 Outbox 이벤트까지 기록하고, 별도의 디스패처가 해당 이벤트를 읽어 Redis Pub/Sub으로 발행한다.
UPDATE outbox_events
SET lockToken = ?, lockedUntil = ?
WHERE status = 'PENDING'
AND nextAttemptAt <= ?
AND (lockedUntil IS NULL OR lockedUntil < ?)
ORDER BY createdAt
LIMIT ?;
하나의 조건부 업데이트로 처리할 이벤트를 선점하므로 여러 인스턴스가 같은 이벤트를 동시에 처리하는 문제를 줄인다.
발행에 실패하면 다음 시도 시각을 조정하고 재시도한다.
Outbox는 데이터베이스 변경과 이벤트 발행 사이의 불일치를 줄여 주지만, 이벤트 소비자에서도 중복 처리를 고려해야 한다. 일반적으로 전달은 최소 한 번 발생할 수 있다고 가정하고 소비자의 멱등성을 보장하는 것이 안전하다.
분산 환경: 테이블 소유권과 장애 복구
테이블 소유권 제어
분산 환경에서는 하나의 테이블을 여러 게임 서버가 동시에 처리하지 못하도록 해야 한다.
두 서버가 같은 테이블을 소유하면 서로 다른 게임 상태를 기준으로 칩을 변경할 수 있기 때문이다.
Redis의 SET NX PX를 이용해 테이블별 분산 락을 획득한다.
this.lease = await svc.lock.tryAcquire(
`table-owner:${config.tableId}`,
OWNER_LOCK_TTL_MS
);
if (!this.lease) {
throw new ServerError(409, "TABLE_ALREADY_HOSTED");
}
구현에서는 다음 정책을 적용한다.
- 락은 일정 시간 후 만료되도록 설정한다.
- 서버는 주기적으로 락을 연장한다.
- 락 해제와 연장은 Lua 스크립트로 처리한다.
- 소유권 토큰이 일치할 때만 락을 변경하거나 해제한다.
- 락을 잃은 Room은 스스로 종료한다.
TableProvisioner가 소유자가 없는 테이블을 찾아 Room 생성을 시도한다.
다만 TTL 기반 락만으로 모든 분산 정합성이 자동 보장되는 것은 아니다. 네트워크 단절이나 일시 정지로 인해 이전 소유자가 락을 잃은 뒤에도 작업을 계속할 수 있는 상황까지 고려해야 한다. 중요한 영속 데이터 변경은 데이터베이스의 조건부 업데이트와 고유 제약으로 보호해야 한다.
Redis 스냅샷을 이용한 복구
TableSession은 명령이 처리될 때마다 테이블 상태를 Redis에 저장한다.
스냅샷에는 진행 중인 핸드, 덱, 홀카드 등 복구에 필요한 정보가 포함된다.
서버가 비정상 종료되면 락이 만료된 이후 다른 서버가 해당 테이블을 열고 스냅샷을 이용해 게임 상태를 복원한다.
복구 시에는 다음 작업을 수행한다.
- 기존 플레이어를 연결이 끊긴 상태로 복원한다.
- 재접속 유예 시간 안에 기존 플레이어가 돌아올 수 있도록 한다.
- 완료됐지만 정산되지 않은 핸드는
settlementPending상태로 재시도한다. - 좌석에는 없지만 예약 칩이 남아 있는 사용자를 찾아 환급 처리한다.
스냅샷 저장 실패가 곧바로 게임을 중단시키지는 않는다. 스냅샷은 빠른 복구를 위한 보조 자료이며, 영속적인 칩 정산의 기준은 MySQL 원장이다.
실제로 SIGKILL로 게임 서버를 종료하는 테스트에서는 락 TTL과 프로비저너 주기에 따라 약 30~40초 뒤 같은 핸드와 홀카드를 복구하고 칩을 보존하는 것을 확인했다.
이 복구 시간은 무중단 복구가 아니라는 한계도 함께 의미한다.
우아한 종료와 롤링 업데이트
Kubernetes는 롤링 업데이트 과정에서 Colyseus Room을 자동으로 다른 Pod로 이전해 주지 않는다. 따라서 서버 종료 절차를 직접 설계해야 한다.
SIGTERM 수신
↓
새 입장 차단 및 Readiness 503
↓
새 핸드 시작 중단
↓
진행 중인 핸드 완료 대기
↓
남은 Room의 스냅샷 저장
↓
다른 프로세스가 Room 복구
개발 과정에서 중요한 문제가 하나 있었다.
Colyseus는 기본 종료 과정에서 연결을 코드 4000으로 닫는다. 이 코드는 자발적인 퇴장으로 해석될 수 있어, 배포할 때마다 플레이어의 좌석이 해제되는 문제가 발생했다.
이를 방지하기 위해 서버 종료 전용 코드 4503을 사용해 비자발적인 연결 종료 경로를 타도록 했다.
override onBeforeShutdown(): void {
void this.disconnect(CloseCode.SERVER_SHUTTING_DOWN);
}
클라이언트는 해당 종료 코드를 확인하고 재접속을 시도한다. 기존 Room이 살아 있으면 재접속 토큰으로 복귀하고, Room이 복구된 경우에는 새 Room에 입장해 사용자 ID를 기준으로 기존 좌석에 다시 연결한다.
이 방식은 단순히 프로세스를 재시작하는 것과 게임 세션을 유지하면서 배포하는 것의 차이를 보여 준다.
보안 설계
인증 흐름
인증은 API 로그인부터 WebSocket 연결, 세션 폐기까지 일관된 흐름으로 설계한다.
회원가입 / 로그인
↓
Argon2id 비밀번호 검증
↓
세션 생성
↓
Access JWT + Refresh Token 발급
↓
Refresh 요청 시 토큰 회전
↓
WebSocket onAuth
↓
JWT 검증 → 세션 확인 → 계정 상태 확인
비밀번호 보호
비밀번호는 평문이나 복호화 가능한 형태로 저장하지 않는다.
Argon2id를 이용해 비밀번호를 검증하고, 다음 설정을 사용한다.
- 메모리 비용: 19 MiB
- 반복 횟수: 2
- 병렬도: 1
비밀번호 정책은 다음과 같다.
- 최소 10자, 최대 128자
- 서로 다른 문자 5종 이상
- 영문 소문자, 대문자, 숫자, 기호 중 최소 2종류 이상
- 일정 길이 이상의 이메일 아이디를 비밀번호에 포함하지 못하도록 제한
존재하지 않는 이메일로 로그인하는 경우에도 더미 해시를 이용해 동일한 수준의 비밀번호 검증 비용을 소비한다.
if (!user) {
await this.burnPasswordCheck(input.password);
throw invalidCredentials();
}
사용자 존재 여부에 따라 응답 시간이나 오류 메시지가 크게 달라지는 것을 방지하기 위한 설계다.
연속 로그인 실패가 5회 발생하면 15분 동안 계정을 잠그고, 로그인에 성공하면 실패 횟수를 초기화한다.
Access Token과 Refresh Token
토큰은 역할과 수명을 구분한다.
| 토큰 | 형태 | 수명 | 저장 위치 |
|---|---|---|---|
| Access Token | JWT, HS256 | 기본 10분 | 클라이언트 메모리 |
| Refresh Token | 32바이트 난수 기반 불투명 토큰 | 기본 30일 | 클라이언트 OS 보안 저장소 |
Access JWT는 iss, aud, exp, sub, sid 등의 클레임을 검증한다.
Refresh Token 원문은 서버에 저장하지 않고 SHA-256 해시만 저장한다. 클라이언트에서는 Keychain 또는 Android Keystore 기반의 보안 저장소를 사용한다.
Access Token은 메모리에만 보관해 디스크에 남지 않도록 하고, Refresh Token은 expo-secure-store를 이용해 저장한다.
이 구조는 Access Token 탈취의 유효 시간을 제한하고, 서버 데이터베이스가 유출되더라도 Refresh Token 원문이 직접 노출되는 것을 방지한다.
Refresh Token 회전과 재사용 탐지
Refresh Token은 사용할 때마다 기존 토큰을 폐기하고 새 토큰을 발급한다.
이미 사용된 토큰이 다시 제출되면 토큰 탈취 가능성을 고려해 세션 전체를 폐기한다.
const won =
await this.d.refreshTokens.markUsedIfUnused(
stored.id,
now
);
if (!won) {
return this.handleReuse(session, ctx);
}
markUsedIfUnused는 다음과 같은 조건부 업데이트를 이용한다.
UPDATE refresh_tokens
SET usedAt = ?
WHERE id = ?
AND usedAt IS NULL;
동시에 같은 토큰으로 요청이 들어오더라도 조건을 먼저 충족한 요청 하나만 성공할 수 있다.
클라이언트에서도 여러 갱신 요청이 동시에 발생하지 않도록 single-flight 패턴을 사용한다.
let refreshing: Promise<AuthResponseDto | null> | null =
null;
export function refreshSession() {
if (refreshing) return refreshing;
refreshing = (async () => {
// 토큰 갱신 처리
})().finally(() => {
refreshing = null;
});
return refreshing;
}
이렇게 하면 여러 API 요청이 동시에 만료된 Access Token을 발견해도 실제 Refresh 요청은 하나만 실행된다.
다만 현재 설계는 응답이 유실되어 클라이언트가 동일한 Refresh Token을 다시 보내는 상황에서도 세션이 폐기될 수 있다. 이를 완화하려면 제한된 재시도 유예 구간이나 토큰 회전 결과의 안전한 재전달 정책이 필요하다.
세션 폐기의 실시간 연결 전파
로그아웃, 비밀번호 변경, 계정 정지 이후에는 기존 WebSocket 연결도 종료해야 한다.
처리 흐름은 다음과 같다.
- 세션을 폐기하고 Redis 세션 캐시를
REVOKED상태로 변경한다. - Redis Pub/Sub으로 폐기 이벤트를 발행한다.
- 각 게임 서버는 해당 사용자에게 연결된 소켓을 종료 코드
4403으로 닫는다. - Pub/Sub 메시지를 놓치는 상황에 대비해 15초마다 세션 상태를 다시 확인한다.
- Access Token이 만료됐는데 갱신 메시지
auth:renew가 오지 않은 연결도 종료한다.
이 구조는 빠른 이벤트 전달과 주기적인 상태 확인을 결합한다.
Pub/Sub은 빠른 전파 수단이며, 세션의 최종 상태는 데이터베이스를 기준으로 판단한다. Redis 캐시는 폐기된 세션을 다시 활성화하지 않도록 조건부 갱신을 적용한다.
Rate Limit과 입력 검증
요청 종류별로 속도 제한을 적용한다.
| 구간 | 제한 |
|---|---|
| API 전역 | 분당 300건 |
| 로그인 | IP당 분당 30회, 계정당 5분에 10회 |
| 회원가입 | IP당 시간당 10회 |
| Refresh | IP당 분당 60회 |
| WebSocket 접속 | IP당 분당 60회 |
| WebSocket 메시지 | 초당 10건, 버스트 20건 |
추가로 다음 방어선을 적용한다.
- Helmet을 이용한 HTTP 보안 헤더
- 허용 출처를 제한하는 CORS
- 요청 본문 최대 16KB
- Zod 기반 입력 검증
- WebSocket
maxPayload16KB - 일관된 오류 응답 형식
- 내부 오류 상세의 외부 노출 방지
오류 응답은 다음 구조로 통일한다.
{
"error": {
"code": "INVALID_REQUEST",
"message": "요청을 처리할 수 없습니다."
}
}
실제 오류 코드는 상황에 따라 달라지지만, 클라이언트가 내부 구현을 알지 못해도 오류를 분류하고 처리할 수 있도록 한다.
게임 공정성 검증: 덱 커밋먼트
플레이어가 게임 종료 후 서버의 셔플 결과를 검증할 수 있도록 덱 커밋먼트를 사용한다.
const deck = createShuffledDeck(rng);
const salt = crypto.randomHex(16);
const commitment = crypto.sha256Hex(
`${deck.join(",")}:${salt}`
);
핸드 시작 시에는 덱 자체가 아니라 해시값인 deckCommitment만 공개한다.
핸드가 종료되면 덱과 솔트를 공개한다. 플레이어는 공개된 값으로 해시를 다시 계산해 시작 시점의 커밋먼트와 일치하는지 확인할 수 있다.
이를 통해 핸드가 진행되는 동안 서버가 공개된 커밋먼트와 다른 덱으로 바꾸지 않았는지 사후 검증할 수 있다.
다만 이 방식만으로 서버의 모든 부정 행위를 증명할 수 있는 것은 아니다. 또한 폴드한 플레이어의 카드까지 공개하는 정책은 별도로 검토해야 한다.
보안 감사 로그
SecurityEvent 테이블에는 다음과 같은 이벤트를 기록한다.
LOGIN_SUCCESSLOGIN_FAILEDACCOUNT_LOCKEDREFRESH_REUSE_DETECTEDLOGOUT_ALLPASSWORD_CHANGEDACCOUNT_SUSPENDEDWS_AUTH_REJECTED
감사 로그 기록에 실패하더라도 로그인 등 주요 사용자 흐름이 불필요하게 중단되지 않도록 처리한다.
단, 보안 사고 조사에 필수적인 이벤트는 단순히 오류를 무시하는 정책만으로 충분하지 않을 수 있다. 운영 환경에서는 이벤트 손실 허용 범위와 별도 영속 큐 사용 여부를 명확히 결정해야 한다.
React Native 모바일 클라이언트
디렉터리 구조
모바일 앱은 Expo와 React Native를 기반으로 구성한다.
app/
login
lobby
table/[tableId]
features/
auth/
AuthProvider
RequireAuth
lobby/
useLobby
poker/
usePokerTable
components
infrastructure/
colyseus/
gameClient
nativeWebSocket
http/
apiClient
secure-storage/
tokenStore
shared/
config
ids
ui
서버의 @poker/contracts 패키지를 그대로 사용해 메시지 이름과 DTO가 서버·클라이언트 사이에서 일치하도록 한다.
게임 화면은 usePokerTable 훅을 통해 연결 상태와 게임 이벤트를 관리한다.
토큰 저장 정책
- Refresh Token:
expo-secure-store를 통해 OS 보안 저장소에 저장 - Access Token: 메모리 변수에만 저장
- 로그아웃: 로컬 토큰 제거와 서버 세션 폐기를 함께 처리
인증 정보의 수명과 저장 위치를 분리해 디스크에 남는 민감한 정보를 최소화한다.
재접속 로직
연결이 끊기면 종료 코드를 확인해 재접속 여부를 판단한다.
room.onLeave((code) => {
if (TERMINAL_CLOSE_CODES.has(code)) {
// 세션 폐기 등: 재접속하지 않고 안내
return;
}
void reconnect(room.reconnectionToken);
});
비정상 종료에서는 지수 백오프 방식으로 재접속을 시도한다.
- 서버의 60초 유예 시간 안에서 재접속을 시도한다.
- 기존 Room이 살아 있으면 재접속 토큰을 이용한다.
- Room이 사라졌다면 새 Room에 입장한다.
- 서버는 사용자 ID를 기준으로 기존 좌석에 다시 연결한다.
- 앱이 백그라운드에서 복귀하면 소켓 상태를 확인한다.
- Access Token 만료 2분 전부터 새 토큰을 받아
auth:renew로 서버에 전달한다.
모바일에서는 앱이 백그라운드에 있는 동안 연결이 조용히 끊길 수 있으므로, 연결 상태를 명시적으로 점검하는 로직이 필요하다.
React Native WebSocket 헤더 호환성
개발 과정에서 React Native의 WebSocket 구현과 Colyseus 클라이언트 사이의 호환성 문제가 발생했다.
Node.js의 WebSocket은 다음 형태를 사용한다.
new WebSocket(url, { headers });
반면 React Native의 WebSocket은 다음 형태를 사용한다.
new WebSocket(url, protocols, { headers });
이 차이 때문에 Authorization 헤더가 전달되지 않고 서버의 onAuth에서 MISSING_TOKEN 오류가 발생했다.
JWT를 URL에 포함하는 방식으로 우회할 수도 있지만, 접근 로그에 토큰이 남을 수 있어 사용하지 않았다.
대신 React Native의 WebSocket 시그니처를 지원하는 얇은 어댑터를 구현했다.
class HeaderAwareWebSocket extends NativeWebSocket {
constructor(url: string, second?: unknown) {
if (
second &&
typeof second === "object" &&
!Array.isArray(second)
) {
const {
headers,
protocols,
} = second as {
headers?: Record<string, string>;
protocols?: string | string[];
};
super(
url,
protocols,
headers ? { headers } : undefined
);
} else {
super(url, second as string | string[] | undefined);
}
}
}
globalThis.WebSocket =
HeaderAwareWebSocket as unknown as typeof WebSocket;
Colyseus 클라이언트가 모듈 로드 시점에 전역 WebSocket을 읽기 때문에 이 어댑터는 해당 클라이언트보다 먼저 import되어야 한다.
이 문제는 서버 인증 로직이 정상이어도 플랫폼별 네트워크 구현 차이 때문에 인증이 실패할 수 있다는 점을 보여 준다.
인프라와 배포
Docker Compose 구성
로컬 및 단일 호스트 배포 환경은 Docker Compose로 구성한다.
| 서비스 | 역할 | 호스트 바인딩 |
|---|---|---|
| MySQL | 계정, 지갑, 원장, 핸드 기록 | 127.0.0.1:3306 |
| Redis | 캐시, 스냅샷, 분산 락 | 127.0.0.1:6380 |
| Migration | Prisma 마이그레이션과 초기 데이터 | 일회성 실행 |
| API | Fastify REST API | 127.0.0.1:3000 |
| Game | Colyseus 게임 서버 두 개 | 내부 네트워크 |
| Proxy | Colyseus 프록시 | 127.0.0.1:2567 |
| Nginx Proxy Manager | HTTPS 및 외부 요청 라우팅 | 80, 443 및 관리 포트 |
MySQL은 mysql:8.0, Redis는 redis:7-alpine을 사용한다. Redis는 AOF를 활성화한다.
게임 서버를 여러 개 실행하고 Redis Presence와 Driver를 이용해 방 목록과 프로세스 간 통신을 공유한다.
호스트의 로컬 Redis가 기본 포트를 사용하고 있는 환경에서는 컨테이너의 호스트 포트를 6380으로 설정해 충돌을 피한다.
SSL 종단과 프록시 설정
Nginx Proxy Manager는 Let’s Encrypt의 HTTP-01 검증을 통해 인증서를 발급하고 자동 갱신한다.
동일한 도메인으로 REST API와 WebSocket을 모두 제공하기 위해 Custom Nginx 설정을 사용한다.
/api/경로는 접두사를 제거한 뒤 Fastify로 전달한다.- 나머지 경로는 WebSocket 업그레이드를 허용해 Colyseus 프록시로 전달한다.
- 실제 접속 IP는 프록시가 확인한 주소로 전달한다.
- 게임 서버가 클라이언트에 알려 주는 공개 주소는
PUBLIC_ADDRESS로 관리한다.
HTTPS와 WSS를 동일한 도메인으로 통합하면 모바일 클라이언트의 네트워크 설정을 단순하게 유지할 수 있다.
Kubernetes와 자동 확장
각 게임 서버는 프로세스 단위 지표를 /metrics에서 노출한다.
poker_game_connected_clients
프로세스에 접속 중인 클라이언트 수
poker_game_rooms
프로세스의 전체 Room 수
poker_game_table_rooms
프로세스의 포커 테이블 Room 수
poker_game_draining
종료 준비 상태 여부
HPA는 poker_game_connected_clients를 기준으로 Pod 수를 조절하도록 구성했다.
- Pod당 평균 접속자 300명을 목표로 설정
- 최소 2개, 최대 10개 Pod
- 확장은 1분에 최대 2개
- 축소는 15분 안정화 이후 10분에 1개씩 진행
terminationGracePeriodSeconds: 120으로 종료 유예 시간 확보
접속자 수가 잠깐 변할 때마다 Pod가 반복적으로 생성·삭제되면 Room 복구와 연결 이전 비용이 커진다. 따라서 확장은 비교적 빠르게, 축소는 보수적으로 수행하도록 설정한다.
중요한 제한 사항: 접속자 300명이라는 값은 실제 부하 측정 결과가 아닌 임시 설정이다. 또한 HPA가 Prometheus 기반 사용자 정의 지표를 읽으려면 Prometheus와 prometheus-adapter 등의 구성이 필요하다. 해당 Kubernetes 매니페스트는 작성했지만 실제 클러스터에 적용해 검증하지 않았다.
개발 방법과 테스트 전략
안쪽에서 바깥쪽으로 개발
개발 순서는 도메인부터 인프라와 클라이언트 방향으로 진행한다.
- Domain: 카드, 족보, 팟 계산, 핸드 규칙을 구현하고 테스트로 고정한다.
- Application: 포트와 유스케이스를 정의하고 메모리 어댑터로 검증한다.
- Infrastructure: Prisma와 Redis 어댑터를 구현하고 실제 데이터베이스로 통합 테스트한다.
- Apps: Fastify 라우트와 Colyseus Room을 연결한다.
- Mobile: 공유 계약을 사용해 화면과 네트워크 로직을 구현한다.
- Deployment: Docker Compose로 전체 스택을 실행하고 종단 간 테스트를 수행한다.
이렇게 개발하면 외부 시스템을 추가하더라도 도메인 규칙이 불필요하게 흔들리지 않는다. 인프라에서 발견한 오류도 내부 계층의 테스트로 재현하고 수정할 수 있다.
테스트 구성
| 테스트 종류 | 검증 대상 |
|---|---|
| 도메인 단위 테스트 | 족보 평가, 사이드 팟, 홀덤 진행 |
| 퍼징 테스트 | 무작위 3,000개 핸드에서 칩 보존 |
| 애플리케이션 테스트 | TableSession, 인증, 정산 재시도 |
| API 테스트 | Fastify 인증 엔드포인트 |
| Room E2E | 실제 Colyseus 서버, 인증, 메시지, 홀카드 비유출 |
| 통합 테스트 | 실제 MySQL·Redis 동시성, Outbox, CHECK 제약 |
| 전체 스택 E2E | Docker Compose, WSS 연결, 서버 종료 및 복구 |
도메인 테스트에서는 휠 스트레이트, 동점, 여러 플레이어의 올인 등 경계 조건을 검증한다.
퍼징 테스트는 무작위로 생성한 3,000개 핸드에서 칩의 합이 보존되는지 확인한다.
Room E2E 테스트에서는 상대방의 홀카드가 노출되지 않는지, 속도 제한이 작동하는지, auth:renew가 처리되는지, 세션 폐기 시 연결이 종료되는지를 검증한다.
실제 인프라가 필요한 통합 테스트 21개는 INTEGRATION=1로 별도 실행한다. 기본 npm test에서는 나머지 88개 테스트를 실행한다.
메모리 어댑터의 역할
PERSISTENCE=memory를 설정하면 MySQL과 Redis 없이도 npm run dev:all 명령으로 API와 게임 서버를 실행할 수 있다.
이 방식은 개발과 단위 테스트 속도를 높여 준다. 그러나 메모리 구현은 실제 SQL 트랜잭션과 행 잠금, 여러 요청 간의 동시성 문제를 완벽하게 재현하지 못한다.
따라서 빠른 개발을 위해 메모리 어댑터를 사용하되, 중요한 동시성 동작은 실제 MySQL과 Redis를 이용한 통합 테스트로 검증해야 한다.
실제 인프라에서 발견한 문제
| 증상 | 원인 | 해결 |
|---|---|---|
| 지갑 동시 요청에서 유니크 제약 위반 | 중복 요청이 동시에 처리되는 구간 | 지갑 행 잠금, 잠금 순서 통일, 멱등 키 확인 |
| 서버 종료 후 테이블 복구 실패 | 죽은 프로세스의 Room이 드라이버에 남음 | 프로비저너의 소유권 판단 개선 |
| 배포할 때 좌석 해제 | 종료 코드가 자발적 퇴장으로 해석됨 | 서버 종료 전용 코드 사용 |
| JWT가 프록시 로그에 남을 위험 | URL을 통한 토큰 전달 | 인증 헤더 사용 |
| 죽은 노드로 요청이 전달되어 응답 지연 | Colyseus 프록시의 동작 | 클라이언트 타임아웃과 재시도 적용 |
모바일에서 MISSING_TOKEN 발생 | React Native WebSocket이 헤더를 무시 | WebSocket 어댑터 구현 |
이러한 문제는 단위 테스트만으로는 발견하기 어려웠다. 실제 MySQL, Redis, Docker, 프록시, 모바일 환경을 함께 실행했을 때 비로소 드러났다.