배포하면 게임이 끊긴다 — 진행 중인 실시간 매치를 다음 서버로 넘기기

무중단 배포를 해도 WebSocket 게임은 안 끊긴다는 보장이 없다. 구 인스턴스가 "매치 끝날 때까지" 기다리다 10분 유예를 다 태우고 SIGKILL로 죽었다. 이관 단위를 매치 → 라운드 경계로 바꾸고, 응답 유실·중복 방 ID·연속 배포까지 막기까지 — 티츄온라인(Node.js + WebSocket, blue/green) 실제 커밋으로 정리.

TL;DR

  • blue/green 무중단 배포를 붙여도 실시간 게임은 안 끊긴다는 보장이 없다. 구 인스턴스가 room.game === null(매치 전체 종료)을 기다렸는데, 티츄 기본 목표 점수 1000짜리 매치는 10분 유예를 넘긴다. 직전 배포에서 실제로 유예 600초를 꽉 채우고 SIGKILL로 끊겼다.
  • 해결은 이관 단위를 “매치 종료” → “라운드 종료”로 바꾸는 것. 라운드 경계에는 손패·트릭·턴 타이머가 없고, 다음 라운드로 넘어가는 상태가 누적 점수와 좌석 순서뿐이라 엔진 전체를 직렬화하지 않고도 매치를 이어붙일 수 있다. 기다리는 시간이 매치 한 판 → 라운드 한 판으로 줄었다.
  • 직렬화 페이로드의 키는 playerId가 아니라 닉네임이다. 피어에서 재접속하면 id가 새로 발급되기 때문.
  • 진짜 일은 그다음이었다. 응답만 유실된 재시도, blue/green이 같은 방 ID를 발급, 연속 배포로 두 번 이관, 한 명이 먼저 피어로 넘어가 방이 갈라짐 — 전부 “성공했다고 착각하고 원본을 지우는” 형태로 나타난다.
  • 배운 것 하나만 남기자면: 이관은 전송이 아니라 합의다. r.ok는 성공이 아니고, 재시도는 멱등해야 하며, 멱등의 기준은 내용까지 같을 때다.

배경 — 무중단 배포는 이미 있었다

티츄온라인은 Node.js + WebSocket 서버를 blue/green으로 굴린다. 배포 스크립트는 대략 이런 순서다.

  1. inactive 슬롯에 새 이미지 빌드·기동
  2. 헬스체크
  3. nginx upstream을 새 슬롯으로 교체 → reload
  4. 구 슬롯에 SIGTERM → 드레인
  5. 드레인이 끝나면 구 슬롯 정지

이 구조 자체는 v2.3.2에 넣어뒀고, 로비에 있는 유저와 대기 중인 방은 잘 넘어갔다. 문제는 4번이다. 구 슬롯은 새 연결을 안 받는 대신 하던 게임이 끝날 때까지 살아 있어야 하는데, “끝날 때까지”를 매치 종료로 잡아둔 게 화근이었다.

티츄 한 매치는 목표 점수 1000점. 한 라운드에 100점 안팎이 오가니 열 라운드 넘게 간다. 유예 600초로는 어림도 없다. 그래서 배포할 때마다 조용히 이런 일이 벌어지고 있었다.

[drain] waiting for 3 room(s) to finish...
... 600s ...
SIGKILL

게임 중이던 사람들 입장에서는 아무 예고 없이 방이 사라진다.

발상 전환 — 매치가 아니라 라운드 경계

한동안 “게임 상태 전체를 직렬화해서 넘기자”를 붙잡고 있었는데, 게임이 4종(티츄·마이티·스컬킹·러브레터)이고 각각 엔진이 다르다. 손패, 이미 나온 트릭, 턴 타이머, 선언 상태… 넷을 전부 왕복 가능하게 만드는 건 그 자체로 프로젝트다.

그러다 라운드 경계(round_end) 를 보니 답이 이미 거기 있었다.

  • 손패 없음 (라운드가 끝나 다 털렸다)
  • 진행 중인 트릭 없음
  • 턴 타이머 없음
  • 다음 라운드로 넘어가는 상태는 누적 점수와 좌석 순서뿐

즉 라운드 경계에서는 매치를 이어가는 데 필요한 정보가 극도로 작다. 엔진 4종에 getMatchProgress() / resumeMatch() 두 개만 뚫으면 된다.

// serializeRoom: 라운드 중인 방은 계속 거부, round_end 방은 matchProgress 를 실어 보낸다
if (room.game && !room.game.isAtRoundEnd()) return null;
payload.matchProgress = room.game ? room.game.getMatchProgress() : room.matchProgress;

기다리는 시간이 매치 한 판에서 라운드 한 판으로 줄었다. 유예는 넉넉하게 stop_grace_period 10m → 15m, DRAIN_TIMEOUT_SEC 600 → 900으로 올렸다. (이 둘은 항상 같이 움직여야 한다. 하나만 올리면 다른 쪽이 먼저 죽인다.)

함정 1 — 키를 playerId 로 잡으면 안 된다

이관 페이로드를 playerId 기준으로 만들면 피어에서 전부 깨진다. 재접속하면 새 id가 발급되기 때문이다. 그래서 페이로드는 닉네임 키로 왕복시킨다.

이게 은근히 넓게 퍼진다. 점수 내역(history)에도 id가 박혀 있어서, 처음엔 총점만 이월하고 라운드별 내역은 버렸다. 그랬더니 스컬킹·마이티 점수판이 라운드 수를 scoreHistory.length로 표시하는 탓에 총점 400점짜리 판이 “0R”로 보였다. 크래시는 없지만 사용자는 당연히 헷갈린다.

어떤 필드가 id를 담는지는 추측하지 않았다. 각 엔진의 updatePlayerId가 다시 쓰는 필드가 곧 정답이다.

게임id 를 담는 필드
스컬킹scores
마이티scores, declarer, partner, dealMisser
러브레터winner, winners, finalHands
티츄팀 키라 변환 불필요

함정 2 — 재개 시 좌석을 섞으면 안 된다

방을 새로 시작하는 경로를 그대로 타면 좌석 셔플이 돈다. 티츄는 좌석이 곧 팀이라, 셔플하는 순간 이월된 팀 점수가 엉뚱한 팀에 붙는다. 재개 경로에서는 셔플을 건너뛴다. 반대로 로스터가 바뀌었으면 이월 상태를 버리고 새 매치로 시작하는 게 맞다.

함정 3 — 이관 도중 다음 라운드가 깔린다

라운드가 끝나면 3~5초 뒤 자동으로 다음 라운드가 시작된다. 하필 그 사이에 이관이 돌면 넘기는 중에 새 판이 깔린다. 드레인 중에는 라운드 자동 진행을 멈추고 즉시 이관하도록 바꿨다.

”돌아왔는데 시작 버튼을 눌러야 한다”

이관 자체가 되기 시작하자 다음 문제가 보였다. 넘어간 방이 피어에서 대기 상태로 복구되는 바람에, 호스트가 「게임 시작」을 눌러야 매치가 이어졌다. 배포 때문에 끊긴 건데 사용자가 조작해야 하는 건 이상하다.

처음엔 “전원이 돌아오면 5초 뒤 자동 시작, 일부가 안 오면 60초 뒤 시작”으로 만들었다. 그런데 실기기 스모크에서 대가가 드러났다. 한 명이 백그라운드에 있었더니, 먼저 돌아온 사람이 아무 설명 없는 대기실에서 14초를 보내다가 결국 시작 버튼을 눌러버렸다. 배포는 사용자가 앱을 보고 있지 않을 때도 일어나니 드문 상황이 아니다.

그래서 기다리는 걸 아예 없앴다.

진행 중인 라운드도 잠수 중인 사람을 기다리지 않고 자동 플레이로 넘어가는데, 이관 후에만 다른 규칙을 적용할 이유가 없다.

이제 누구든 한 명 돌아오면 다음 라운드를 깐다. 늦게 오는 사람은 미리 등록해둔 세션으로 진행 중인 게임에 합류한다 — 평소 재접속과 똑같다. 설명해야 할 대기가 사라지니 match_resuming 이벤트, 20초 데드라인, 클라이언트 배너까지 만들어둔 걸 도로 걷어냈다.

여기서부터가 진짜 — 이관은 전송이 아니라 합의다

동작하는 이관을 만든 뒤 나온 버그들은 결이 하나다. 전부 “성공했다고 착각하고 원본을 지운다.”

1) r.ok 는 성공이 아니다

방 ID가 room_1부터 프로세스마다 세는 카운터였다. blue/green이 같은 ID를 발급한다. 배포는 nginx를 새 슬롯으로 먼저 돌린 뒤 구 슬롯을 드레인하므로, 그 창(최대 15분) 내내 양쪽이 방을 만든다.

충돌하면 adoptRoom이 null을 반환하는데, 핸들러는 adopted: 0으로도 200을 줬고, 이관측은 r.ok만 보고 성공 처리해 원본을 삭제했다. 진행 중인 매치가 그대로 증발한다.

// ❌ 200 이면 성공이라고 믿었다
const r = await fetch(peerUrl, { method: 'POST', body });
if (r.ok) { closeSockets(room); removeRoom(room.id); }

// ✅ 본문의 adopted 개수까지 검증한다
const r = await fetch(peerUrl, { method: 'POST', body, signal: AbortSignal.timeout(5000) });
const json = await r.json().catch(() => null);
if (!r.ok || json?.adopted !== rooms.length) return; // 방을 그대로 두고 재시도에 맡긴다

방 ID에는 부팅 토큰을 넣어 인스턴스 간 유일하게 만들었다. INSTANCE_NAME만으로는 blue→green→blue 재배포에서 다시 겹친다.

2) 실패했는데 방을 닫아버린다

fetch 실패·403·피어 미기동에서 catch로 로그만 찍고 소켓을 닫고 방을 지우고 있었다. 순간적인 실패 한 번에 라운드 경계의 matchProgress가 사라진다.

이제 실패하면 방을 그대로 둔다. 타이머는 이미 정리돼 있어 방은 라운드 경계에 멈춘 채 남고, 드레인 폴이 2초마다 재시도한다. 끝내 안 되면 stop_grace_period가 백스톱이다. (재시도 로그는 1회차와 15회마다만 찍는다. 안 그러면 로그가 도배된다.)

3) 타임아웃 없는 fetch 하나가 전체를 막는다

피어가 TCP는 받고 응답을 못 주는 상태면 undici 기본값 300초까지 매달린다. SIGTERM 초기 루프는 방마다 순차 await이라 다음 방 이관·로비 유저 정리·드레인 폴 시작까지 전부 막힌다. 드레인 폴도 in-flight 하나 뒤에 직렬화된다.

AbortSignal.timeout(5000)으로 끊고 재시도 경로에 태웠다. 블랙홀 피어(수락 후 무응답)를 만들어 검증했다.

4) 재시도가 영구 409가 된다 — 멱등성

“피어는 받았는데 응답만 유실”이면, 재시도는 중복 ID로 계속 거절된다. 발신측은 성공을 인지 못 해 grace 15분을 통째로 태우고, 그동안 플레이어는 라운드 경계에 멈춘 방에 갇힌다.

페이로드에 migrationOrigin(<instance>:<roomId>) 스탬프를 넣고, 같은 ID의 기존 방이 같은 스탬프를 갖고 있으면 멱등 성공으로 처리한다. 스탬프가 다르거나 없으면 종전대로 거절 — 남의 방을 성공이라고 답하면 발신측이 유일한 사본을 지운다.

5) 멱등의 기준은 “내용까지 같을 때”다

그런데 origin만 비교하면 이런 경로가 열린다.

대기방 스냅샷이 peer 에 adopt 됨
  → 응답 유실
  → 기존 슬롯에서 host 가 게임 시작 (방 내용이 바뀜)
  → 다음 round_end 에 재시도
  → peer 가 "예전 대기방"을 이미 adopt 됐다며 성공 처리
  → 발신측이 게임을 삭제, matchProgress 유실

그래서 페이로드에 내용 해시(migrationFingerprint) 를 함께 실어, origin + 지문이 모두 같을 때만 멱등 성공으로 본다. 지문이 다르면 409다.

더 근본적으로는 스냅샷을 보낸 뒤 방이 변하지 않게 해야 한다. 처음엔 start_game만 막았는데 반쪽이었다 — next_round를 비롯해 스냅샷을 바꾸는 경로가 열려 있었다. 개별 핸들러 대신 handleMessage 진입점에 공통 가드를 뒀다.

// 드레인 중 얼리는 액션
const DRAIN_FROZEN_ACTIONS = new Set([
  'create_room', 'join_room', 'change_room_name', 'toggle_ready', 'change_team',
  'kick_player', 'add_bot', 'block_slot', 'unblock_slot', 'set_random_seating',
  'switch_to_spectator', 'switch_to_player', 'start_game', 'next_round',
]);

의도적으로 열어두는 것도 명시해뒀다. leave_room·leave_game(사람을 방에 가두지 않는다), 채팅, 그리고 진행 중인 라운드의 플레이 액션(그 라운드가 끝나야 이관이 시작되므로).

6) 열어둔 leave 가 방을 가른다

leave가 다음 구멍이었다. 응답 유실 구간에서는 피어가 이미 스냅샷을 갖고 있다. 이때 구 슬롯에서 나가면 우리 사본만 바뀌고, 그 유저가 재접속으로 피어 사본에 붙으면 일부는 피어에, 일부는 구 슬롯에 남는 split이 된다.

그래서 방이 곧 스냅샷인 상태(대기 중이거나 round_end에 정지)에서만 leave를 막는다. 라운드 진행 중 leave는 그대로 허용 — 그 방은 아직 이관 대상이 아니고, 잠수 퇴장도 계속 동작해야 한다. 관전자도 제외. 차단은 방이 넘어갈 때까지, 보통 수 초다.

7) 그리고 그 leave 차단이 사람을 가둔다 — superseded

leave를 막았더니 이런 게 생겼다. 재시도 중에 플레이어 한 명의 연결이 끊기면 nginx가 그를 피어로 보내고(우리 /health는 이미 503), 그는 피어 사본에 들어가 auto-resume까지 걸린다. 그러면 피어 사본은 occupied가 되어 우리 재시도는 영원히 거절되는데, 여기 남은 사람들은 이제 나갈 수도 없어 SIGKILL까지 갇힌다. 방이 갈라진 채로.

피어가 “이미 내가 그 방을 갖고 있고 사람이 들어와 있다”를 응답에 superseded로 명시하고, 발신측은 이걸 재시도 대상이 아니라 종료로 처리해 소켓을 닫고 사본을 버린다. 남은 사람들도 피어의 방으로 따라간다. 피어 미기동·타임아웃 같은 transient 실패는 종전대로 재시도다.

8) 연속 배포 — 재개 전에 또 이관된다

이관된 방은 플레이어가 돌아올 때까지 대기 상태로 있는데, 그 사이 새 인스턴스가 또 드레인되면 방이 다시 이관된다. 이때 room.game이 없어서 serializeRoommatchProgressnull로 내보냈다. 누적 점수가 조용히 사라지고 매치가 0부터 다시 시작된다.

재현까지 확인했다 — 430:275 → 0:0, round 5 → 1. 게임 객체가 없어도 room.matchProgress가 있으면 그대로 통과시키도록 고치고, 테스트에 blue→green→blue 케이스를 추가했다.

배포 스크립트 쪽 함정 둘

서버만의 문제가 아니었다.

active_slot 기록 시점. nginx는 3단계에서 이미 새 슬롯으로 전환되는데 active_slot 파일은 7단계, 즉 최대 15분 드레인이 끝난 뒤에야 갱신됐다. 그 사이 SSH 끊김·CI kill·재부팅으로 스크립트가 죽으면 트래픽은 green인데 파일은 blue가 되고, 다음 배포가 live green을 inactive로 착각해 rebuild/stop한다.

  • nginx reload 직후에 기록한다(트래픽이 실제로 옮겨간 시점).
  • 슬롯 판단의 1순위를 nginx conf의 upstream으로 바꾼다. 실제로 트래픽을 받는 쪽이 진실이고, 파일과 어긋나면 경고를 남긴다.

nginx reload 실패. conf를 먼저 교체하고 nginx -t/reload를 하므로, reload가 실패하거나 그 사이 스크립트가 죽으면 실제 트래픽은 옛 슬롯인데 파일은 새 슬롯을 가리킨다. 교체 전 백업을 두고 EXIT/INT/TERM 트랩에서 원복 + reload 하도록 바꿨다. reload 확인 후에는 가드를 먼저 해제하고 백업을 지운다 — 그 사이에 죽어도 성공한 스왑을 되돌리지 않도록.

그리고 아주 사소하지만 놓치기 쉬운 것: CI의 SSH 타임아웃을 드레인 상한보다 길게 잡아야 한다. 안 그러면 드레인이 정상 동작해도 CI가 먼저 끊는다.

검증은 어떻게 했나

이런 종류는 유닛 테스트만으로는 확신이 안 선다. 세 층으로 나눴다.

  1. 엔진 왕복 테스트 (test_round_migration.js) — 4개 게임 각각, 재접속으로 id가 전부 바뀐 상태에서 그 사람을 계속 가리키는지. 좌석·팀 보존, SK 라운드별 카드 수, 로스터 변경 시 폐기, blue→green→blue.
  2. adopt 시맨틱 유닛 테스트 — 동일 재전송 = 멱등 성공, 미점유 stale = 교체, 점유 후 = 거절, 다른 origin = 거절, 스탬프 없음 = 거절. HTTP 레벨에서도 동일 확인.
  3. 로컬 blue/green 2인스턴스 실드레인 — 러브레터 실경기 중 SIGTERM → 라운드 종료까지 생존 → 이관 → 재접속 → 자동 재개. 4회 연속 통과할 때까지.

특히 블랙홀 피어(수락하고 응답 안 주는 서버)와 중복 ID 방은 손으로 만들어야 나온다. 자연 발생을 기다렸으면 실서버에서 만났을 것이다.

마치며

무중단 배포를 “nginx를 새 슬롯으로 돌리고 구 슬롯을 천천히 죽이는 일”로만 보면, 상태를 들고 있는 연결에서 반드시 걸린다. 실시간 게임은 그 극단이다.

정리하면 이렇다.

  • 이관 단위를 잘 고르면 직렬화가 거의 사라진다. 라운드 경계라는 “상태가 가장 작아지는 지점”을 찾은 게 이 작업의 8할이었다.
  • 네트워크 경계를 넘는 상태 이동은 전송이 아니라 합의다. r.ok는 성공이 아니고, 실패는 재시도 가능해야 하고, 재시도는 멱등해야 하고, 멱등의 기준은 내용까지 같을 때다.
  • 가드를 하나 세우면 그 가드가 만드는 구멍이 생긴다. leave를 막았더니 사람이 갇혔고, 그래서 superseded가 필요해졌다. 한 번에 끝나지 않는다.
  • “성공했다고 착각하고 원본을 지우는” 형태의 버그를 특히 조심할 것. 이관에서 가장 비싼 실패는 실패가 아니라 거짓 성공이다.

관련 릴리즈 노트: 티츄온라인 v2.8.0