게임 서버 노드 교체 중 플레이 세션을 유지하는 Session Handoff 설계
게임 서버 노드 교체 때 플레이 세션을 끊지 않는 Session Handoff 설계를 다룹니다. 상태 스냅샷, 재접속 토큰, 세대 번호, Redis, 보안 검증으로 안전한 무중단 세션 이전을 구현하는 방법을 설명합니다.

핵심 요약
게임 서버 노드를 교체할 때 플레이 세션을 유지하려면 기존 노드의 권위 있는 게임 상태를 스냅샷으로 저장하고 새 노드가 이를 복원해야 합니다. 클라이언트에는 세션 ID와 짧은 만료 시간을 가진 재접속 토큰을 발급하며 session_epoch와 fencing 검사를 사용해 이전 노드의 늦은 쓰기를 차단합니다.
단 애플리케이션 세션을 유지하는 것과 TCP·UDP 연결 자체를 끊지 않는 것은 다릅니다. 논리적 세션의 무중단 이전은 Session Handoff로 구현할 수 있지만 전송 연결까지 유지하려면 안정적인 게이트웨이와 연결 마이그레이션 계층이 추가로 필요합니다.
Session Handoff는 왜 필요한가?
게임 서버는 패치, 오토스케일링, 장애 대응, 호스트 교체를 위해 실행 중인 노드를 종료해야 합니다. 일반적인 종료 방식은 클라이언트 연결을 끊고 로비나 매치메이킹부터 다시 시작하게 만듭니다. 실시간 대전에서는 이 짧은 공백도 입력 유실, 판정 불일치, 탈주 처리로 이어질 수 있습니다.
Session Handoff는 기존 서버의 세션 소유권을 새 서버로 이전하는 절차입니다. 핵심은 다음 세 가지입니다.
- 기존 노드가 게임 상태를 일관된 시점에 고정한다.
- 상태 스냅샷과 현재 세대 번호를 공유 저장소에 기록한다.
- 새 노드가 스냅샷을 검증하고 세션 소유권을 획득한다.
여기서 중요한 점은 플레이어 인증 정보와 게임 월드 상태를 같은 방식으로 다루지 않는 것입니다. 인증 정보는 외부 세션 저장소에서 재사용할 수 있지만 게임 월드 상태는 틱 순서와 명령 처리 순서까지 보존해야 합니다.
논리적 세션 유지와 연결 유지의 차이
다음 표처럼 목표를 먼저 분리해야 설계 범위를 잘못 잡지 않습니다.
| 목표 | 필요한 구성 요소 | 가능한 결과 |
|---|---|---|
| 논리적 세션 유지 | 상태 스냅샷, 재접속 토큰, 새 노드 복원 | 연결이 잠시 바뀌어도 같은 매치로 복귀 |
| 입력 유실 최소화 | drain 상태, 입력 버퍼, 마지막 확정 틱 | handoff 직전 입력의 재처리 또는 명시적 폐기 |
| TCP 연결 유지 | L4 게이트웨이, 연결 프록시, 세션 고정 | 애플리케이션 노드 교체와 클라이언트 연결 분리 |
| UDP 연결 유지 | 게이트웨이 릴레이, connection ID, 경로 갱신 | 클라이언트 주소가 바뀌어도 논리 연결 유지 |
| 중복 노드 방지 | session_epoch, fencing token | 이전 노드의 지연된 쓰기 차단 |
게임 서버가 직접 클라이언트와 TCP 또는 UDP 연결을 맺는 구조라면 노드 프로세스가 종료되는 순간 전송 연결이 사라집니다. 이 경우 사용자는 연결 끊김을 느낄 수 있지만 세션 데이터와 매치 진행 상태를 보존해 빠르게 복귀시킬 수 있습니다.

권장 아키텍처는 어떻게 구성할까?
예시 스택은 다음과 같습니다. 특정 엔진에 종속되지 않도록 게임 서버는 authoritative server 모델을 사용한다고 가정합니다.
- 게임 서버: C# 또는 TypeScript 기반 authoritative simulation
- 세션 메타데이터: Redis 7
- 영속 상태와 감사 로그: PostgreSQL 16
- 토큰 검증: 게이트웨이 또는 게임 서버의 HMAC-SHA-256 검증기
- 라우팅: 세션 ID를 기준으로 한 L4 또는 L7 게이트웨이
- 스냅샷 직렬화: 버전이 명시된 MessagePack 또는 Protocol Buffers
Redis는 빠른 소유권 변경과 TTL 관리에 적합하지만 유일한 영속 저장소로 사용하면 안 됩니다. 매치 결과, 결제 관련 상태, 보상 지급 상태처럼 복구가 반드시 필요한 데이터는 PostgreSQL의 트랜잭션과 멱등 키를 사용해 별도로 저장합니다.
세션 메타데이터는 다음처럼 최소화할 수 있습니다.
{
"session_id": "match-8f5a",
"owner_node_id": "game-ap-17",
"session_epoch": 42,
"snapshot_version": 108,
"last_committed_tick": 918240,
"handoff_state": "draining",
"snapshot_expires_at": "2026-08-29T12:00:30Z"
}
session_epoch는 노드의 재시작 횟수나 단순한 타임스탬프가 아니라 현재 소유권 세대를 나타내는 단조 증가 값입니다. 모든 상태 쓰기에는 이 값을 포함하고 저장소는 현재 세대와 일치할 때만 쓰기를 허용해야 합니다.
상태 스냅샷에는 무엇을 저장해야 하는가?
스냅샷은 “현재 메모리 덤프”가 아닙니다. 새 노드가 동일한 게임 상태를 재구성할 수 있는 명시적인 도메인 데이터여야 합니다.
| 구분 | 예시 | 저장 여부 |
|---|---|---|
| 월드 상태 | 엔티티 위치, 체력, 인벤토리, 점수 | 저장 |
| 시뮬레이션 상태 | 현재 틱, RNG seed, 날씨 상태, 쿨다운 종료 틱 | 저장 |
| 세션 상태 | 플레이어 목록, 준비 상태, 팀 정보 | 저장 |
| 전송 상태 | 소켓 객체, OS 파일 디스크립터 | 저장하지 않음 |
| 캐시 | 경로 탐색 캐시, 렌더링용 임시 데이터 | 보통 제외 |
| 인증 비밀 | 원문 토큰, 비밀번호, 장기 암호 키 | 저장하지 않음 |
시뮬레이션이 결정론적이라면 last_committed_tick과 RNG seed가 특히 중요합니다. 새 노드가 스냅샷을 복원한 뒤 같은 명령을 재생해야 한다면 직렬화 형식, 부동소수점 연산, 랜덤 생성기 알고리즘도 버전 고정이 필요합니다.
상태는 전체 스냅샷과 증분 로그를 조합할 수 있습니다.
- 전체 스냅샷: 복구 시간이 짧지만 저장 비용이 큼
- 명령 로그: 변경 추적이 쉽지만 재생 시간이 길어질 수 있음
- 하이브리드 방식: 주기적인 전체 스냅샷 이후의 명령 로그만 재생
실시간 매치에서는 일반적으로 마지막 확정 틱을 기준으로 스냅샷을 생성합니다. 스냅샷 생성 도중 객체가 변경되면 부분 상태가 저장될 수 있으므로 월드 업데이트를 잠시 멈추거나 copy-on-write 구조를 사용해야 합니다.
플레이 세션 스냅샷은 어떻게 원자적으로 생성할까?
핸드오프는 다음 단계로 나누는 것이 안전합니다.
1단계: 세션을 draining 상태로 전환한다
기존 노드는 새로운 플레이어 입장을 막고 현재 세션에 대한 소유권을 유지합니다. 이때 기존 클라이언트의 명령은 계속 처리할 수 있지만 스냅샷 기준 틱을 정해야 합니다.
ACTIVE -> DRAINING -> SNAPSHOTTING -> TRANSFERRING -> RELEASED
DRAINING 상태에서는 새로운 쓰기 경로가 추가되지 않도록 해야 합니다. 예를 들어 거래 요청이나 보상 지급 요청은 별도의 명령 큐에 넣고 핸드오프 이후 한 번만 처리합니다.
2단계: 기준 틱을 확정한다
현재 처리 중인 명령을 완료한 뒤 마지막으로 확정된 틱을 T로 기록합니다. 스냅샷에는 T보다 늦은 상태가 섞이면 안 됩니다.
public sealed record SnapshotHeader(
string SessionId,
long SessionEpoch,
long CommittedTick,
int SchemaVersion,
string Checksum
);
스냅샷 본문과 헤더의 session_epoch, committed_tick, schema_version은 서로 다른 저장 경로에서 따로 관리하지 않는 편이 좋습니다. 새 노드가 본문은 최신인데 헤더는 이전 버전인 상태를 읽으면 복원이 불가능하거나 잘못된 상태를 신뢰할 수 있기 때문입니다.
3단계: 임시 키에 기록하고 검증한 뒤 커밋한다
스냅샷을 바로 현재 키에 덮어쓰지 말고 임시 키에 저장합니다. 기록이 끝나면 체크섬을 검증하고 커밋 마커를 원자적으로 작성합니다.
snapshot:{session_id}:staging:{snapshot_version}
snapshot:{session_id}:committed
handoff:{session_id}
committed 레코드는 다음 조건을 만족할 때만 갱신합니다.
- 스냅샷 본문 저장 완료
- 체크섬 검증 완료
- 스키마 버전 확인 완료
session_epoch가 현재 소유권 세대와 일치- 마지막 확정 틱이 메타데이터에 기록됨
Redis를 사용한다면 Lua 스크립트 또는 WATCH와 MULTI/EXEC를 이용해 세대 검사와 커밋 마커 갱신을 하나의 원자적 작업으로 묶을 수 있습니다. 다만 클러스터 구성에서는 키 해시 슬롯과 장애 시 재시도 semantics를 확인해야 합니다.
재접속 토큰은 어떻게 설계해야 하는가?
재접속 토큰은 세션 상태를 담는 저장소가 아니라 새 노드에 복귀할 권한을 제한적으로 증명하는 자격 증명입니다. 토큰 안에 인벤토리나 점수 같은 게임 상태를 넣으면 변조 검증과 개인정보 관리가 어려워집니다.
권장 클레임은 다음과 같습니다.
{
"token_id": "rtk-01J...",
"session_id": "match-8f5a",
"player_id": "player-204",
"session_epoch": 42,
"issued_at": 1788000000,
"expires_at": 1788000030,
"audience": "game-session-handoff",
"nonce": "server-generated-random-value"
}
실제 JSON에는 제어 문자를 넣지 않아야 하므로 token_id는 UUID 또는 URL-safe Base64 값처럼 안전한 문자열을 사용합니다. 토큰 구현 방식은 두 가지가 일반적입니다.
| 방식 | 장점 | 주의점 |
|---|---|---|
| 불투명 토큰 | 서버에 상태를 보관하고 토큰 유출 시 폐기하기 쉬움 | 검증 시 저장소 조회 필요 |
| 서명 토큰 | 저장소 조회 없이 기본 클레임 검증 가능 | 폐기 목록과 재사용 방지 장치 필요 |
보안상 재접속 토큰에는 다음 정책을 적용해야 합니다.
- TTL은 handoff 예상 시간보다 짧게 설정하고 필요할 때만 제한적으로 갱신한다.
player_id,session_id,session_epoch,audience를 검증한다.- nonce 또는
token_id를 저장해 1회 사용을 보장한다. - TLS를 사용하고 URL 쿼리 문자열이나 일반 로그에 토큰을 남기지 않는다.
- 서명 키는 게임 서버 프로세스에 하드코딩하지 않고 키 관리 시스템에서 주입한다.
- 토큰의 권한을 해당 세션 복귀로 한정하고 관리자 API나 일반 로그인 토큰과 분리한다.
서명 토큰을 사용한다면 HMAC-SHA-256 서명 검증 후 세션 메타데이터의 현재 session_epoch와 비교해야 합니다. 서명만 유효하다고 해서 현재 세션에 들어갈 수 있는 것은 아닙니다.
이전 노드의 늦은 쓰기를 어떻게 차단할까?
가장 흔한 장애는 네트워크 지연으로 이전 노드가 핸드오프 완료 후에도 상태를 기록하는 상황입니다. 이를 방지하려면 세션 소유권에 fencing token을 붙여야 합니다.
현재 소유권: session_epoch = 42
새 노드 획득: session_epoch = 43
이전 노드의 요청: epoch = 42 -> 거부
새 노드의 요청: epoch = 43 -> 허용
데이터 저장소의 쓰기 조건은 다음과 같은 형태가 됩니다.
UPDATE game_sessions
SET snapshot_version = @snapshotVersion,
last_committed_tick = @tick,
payload = @payload
WHERE session_id = @sessionId
AND session_epoch = @sessionEpoch;
업데이트된 행의 수가 0이면 소유권이 바뀌었거나 이미 처리된 요청으로 보고 작업을 중단해야 합니다. 서버 메모리의 is_owner 플래그만으로는 이 문제를 해결할 수 없습니다. 프로세스가 멈췄다가 늦게 살아나거나 네트워크가 분리된 상태에서도 외부 저장소가 최종 방어선이 되어야 합니다.
핸드오프 전체 흐름은 어떻게 구현할까?
다음은 논리적 세션 이전을 단순화한 흐름입니다.
flowchart TD
A[운영자가 노드 교체 시작] --> B[기존 노드 DRAINING 전환]
B --> C[현재 명령 처리 완료]
C --> D[기준 틱 T 확정]
D --> E[스냅샷 생성 및 체크섬 검증]
E --> F[스냅샷 커밋 마커 기록]
F --> G[session_epoch 1 증가]
G --> H[새 노드가 소유권 획득]
H --> I[스냅샷 복원 및 상태 검증]
I --> J[재접속 토큰 검증]
J --> K[클라이언트 라우팅 갱신]
K --> L[새 노드에서 입력 처리 재개]
구현 순서는 다음처럼 정리할 수 있습니다.
1. 세션 메타데이터를 읽고 소유권을 잠근다
새 노드는 handoff_state, session_epoch, snapshot_version을 읽습니다. 현재 노드의 lease가 만료됐거나 운영자가 명시적으로 drain을 시작한 경우에만 takeover를 시도합니다.
lease는 단독으로 사용하지 말고 fencing token과 함께 사용해야 합니다. lease 만료 시간만 믿으면 GC 중단이나 일시적인 네트워크 지연 뒤에 두 노드가 동시에 자신을 소유자로 판단할 수 있습니다.
2. 스냅샷을 복원하고 불변 조건을 검사한다
복원 뒤에는 단순히 역직렬화 성공 여부만 확인하지 않습니다.
session_id가 요청한 세션과 같은가?session_epoch가 소유권 메타데이터와 같은가?- 플레이어 ID가 중복되지 않았는가?
- 팀 인원과 매치 규칙이 유효한가?
- 현재 틱과 쿨다운 종료 틱이 역전되지 않았는가?
- 스냅샷 체크섬과 스키마 버전이 일치하는가?
검사에 실패하면 해당 세션을 온라인 상태로 열지 말고 격리 큐에 보냅니다. 손상된 상태를 복원한 뒤 계속 진행하면 오류가 보상 지급이나 전적 데이터로 전파될 수 있습니다.
3. 재접속 토큰을 소비하고 입력 처리를 재개한다
클라이언트의 토큰은 검증과 동시에 소비해야 합니다. 토큰 소비가 성공한 뒤에만 세션 참가를 완료하며 네트워크 재시도로 같은 요청이 반복되어도 한 번만 참가하도록 멱등 키를 사용합니다.
public sealed record ReconnectRequest(
string Token,
string ClientInstanceId,
long LastReceivedTick
);
public async Task<ReconnectResult> ReconnectAsync(ReconnectRequest request)
{
var claims = tokenVerifier.Verify(request.Token);
var session = await sessionStore.GetAsync(claims.SessionId);
if (session is null || session.SessionEpoch != claims.SessionEpoch)
return ReconnectResult.Rejected("stale_session");
var consumed = await tokenStore.ConsumeOnceAsync(claims.TokenId);
if (!consumed)
return ReconnectResult.Rejected("token_reused");
return await sessionRuntime.AttachAsync(
claims.SessionId,
claims.PlayerId,
request.LastReceivedTick);
}
실제 운영 코드에서는 토큰 검증, 현재 세대 확인, 1회 사용 처리, 플레이어 attach를 하나의 명확한 상태 머신으로 관리해야 합니다. 네트워크 호출 사이에 소유권이 바뀔 수 있으므로 마지막 attach 직전에도 epoch를 다시 확인하는 편이 안전합니다.
입력과 이벤트의 유실은 어떻게 처리할까?
스냅샷 기준 틱 이후에 도착한 입력을 어떻게 할지는 게임 장르에 따라 다릅니다.
- 캐주얼 협동 게임: 최근 입력 몇 개를 버리고 복원 공지를 보냄
- 실시간 대전: 입력에 client sequence를 부여하고 마지막 확정 sequence 이후만 재전송
- 턴제 게임: 명령을 영속 큐에 저장하고 새 노드에서 멱등 처리
- 물리 시뮬레이션: 기준 틱 이후 입력 로그를 재생하되 서버 권위 판정을 유지
입력은 다음 필드를 포함하면 재시도와 중복을 구분하기 쉽습니다.
{
"session_id": "match-8f5a",
"player_id": "player-204",
"client_sequence": 1842,
"base_tick": 918240,
"command": "move",
"payload": { "x": 12.5, "y": 3.0 }
}
client_sequence는 플레이어별로 단조 증가해야 하며 이미 처리한 번호는 중복 실행하지 않습니다. base_tick이 서버의 복원 기준과 크게 다르면 입력을 바로 적용하지 말고 현재 상태를 다시 전송하거나 재동기화 절차를 시작합니다.
실패 시나리오와 복구 전략
| 실패 상황 | 감지 방법 | 처리 방법 |
|---|---|---|
| 스냅샷 생성 중 기존 노드 종료 | 커밋 마커 없음 | 이전 유효 스냅샷으로 복원하거나 매치 복구 큐로 이동 |
| 두 노드가 동시에 takeover | epoch 조건부 갱신 | 한 노드만 성공시키고 다른 노드는 즉시 종료 |
| 토큰 재사용 | token_id 소비 결과 확인 | 요청 거부 및 보안 이벤트 기록 |
| 새 노드 복원 실패 | 체크섬·불변 조건 검사 | 세션 격리, 운영자 재처리 |
| 이전 노드의 늦은 쓰기 | epoch 불일치 | 저장소에서 거부하고 경고 로그 기록 |
| 클라이언트 라우팅 지연 | 게이트웨이 route 조회 | 짧은 재시도와 세션 ID 기반 재조회 |
복구 정책은 반드시 비즈니스 요구사항과 연결해야 합니다. 예를 들어 랭크전에서 스냅샷을 잃었다고 매치를 임의로 승패 처리하면 플레이어 신뢰와 보상 시스템에 문제가 생깁니다. 상태를 복구할 수 없을 때는 매치를 무효화하고 보상 트랜잭션을 별도로 기록하는 정책이 더 안전할 수 있습니다.
관측성과 테스트는 무엇을 확인해야 하는가?
핸드오프 성공률만 측정하면 원인을 찾기 어렵습니다. 다음 지표를 세션 ID, 이전 노드 ID, 새 노드 ID, epoch, snapshot version과 함께 추적합니다.
- 스냅샷 생성 시간
- 스냅샷 크기와 압축률
- 복원 시간
- 토큰 검증 실패 수
- 토큰 재사용 탐지 수
- epoch 충돌 수
- 마지막 확정 틱과 재접속 틱의 차이
- 클라이언트 재접속 성공률
- 핸드오프 중 입력 폐기 수
로그에는 원문 토큰과 개인정보를 남기지 않습니다. session_id와 player_id도 운영 환경에서는 해시 또는 내부 식별자로 마스킹할 수 있습니다.
테스트는 정상 경로만 확인해서는 부족합니다.
- 스냅샷 직전 노드 강제 종료
- 스냅샷 저장 직후 네트워크 단절
- 새 노드 두 개의 동시 takeover
- 이전 노드의 지연된 쓰기 재현
- 만료 토큰과 재사용 토큰 검증
- 스키마 버전이 다른 스냅샷 복원
- 복원 중 Redis 장애와 PostgreSQL 지연
- 클라이언트의 중복 재접속 요청
부하 테스트에서는 매치 수보다 동시 스냅샷 생성 수와 저장소 쓰기 집중도를 먼저 확인해야 합니다. 모든 세션이 같은 시각에 이동하면 Redis와 스냅샷 저장소에 순간적인 부하가 생기므로 노드별 drain 비율과 handoff 동시성 제한을 둡니다.
자주 묻는 질문 (FAQ)
Session Handoff만으로 연결 끊김을 완전히 없앨 수 있나요?
아닙니다. Session Handoff는 논리적 세션과 게임 상태를 보존합니다. 기존 프로세스가 직접 보유한 TCP·UDP 연결까지 유지하려면 게이트웨이 릴레이나 연결 마이그레이션 계층이 필요합니다.
Redis에 스냅샷만 저장해도 충분한가요?
짧은 장애 복구용 캐시로는 사용할 수 있지만 영속 데이터의 유일한 저장소로 삼는 것은 위험합니다. 매치 결과와 보상처럼 반드시 보존해야 하는 데이터는 PostgreSQL 같은 영속 저장소와 멱등 트랜잭션으로 관리해야 합니다.
JWT 재접속 토큰이면 서버 조회가 필요 없나요?
서명과 만료 시간은 로컬에서 검증할 수 있지만 현재 세션의 epoch, 토큰 1회 사용 여부, 강제 폐기 상태는 별도 저장소에서 확인해야 합니다. 재접속 토큰은 인증 토큰이 아니라 제한된 복귀 권한으로 설계하는 것이 안전합니다.
정리
안전한 Session Handoff의 핵심은 스냅샷 자체가 아니라 소유권 전환의 일관성입니다. 기준 틱을 명확히 고정하고 버전이 있는 상태 스냅샷을 원자적으로 커밋한 뒤 재접속 토큰과 session_epoch를 함께 검증해야 합니다.
마지막으로 애플리케이션 세션 이전과 네트워크 연결 유지를 분리해 설계해야 합니다. 게임 서버 앞단에 안정적인 게이트웨이를 두고 상태 복원, fencing, 멱등 입력 처리, 토큰 폐기, 장애 관측을 함께 구현하면 노드 교체 중에도 플레이어가 같은 세션으로 빠르게 복귀할 수 있습니다.



