게임 서버 무점검 패치 아키텍처: 블루/그린 배포와 커넥션 드레이닝(Connection Draining) 완벽 가이드
스테이트풀 게임 서버와 API 게이트웨이 환경에서 무중단 패치를 구현하는 블루/그린 배포 전략과 안전한 세션 마이그레이션을 위한 커넥션 드레이닝 아키텍처 및 구현 코드를 다룹니다.

핵심 요약
게임 서버 무점검 패치의 핵심은 상태 비저장(Stateless) 계층과 상태 유지(Stateful) 세션 계층을 분리하고 신규 인바운드 트래픽을 신규 버전(Green)으로 즉시 라우팅하는 동시에 구버전(Blue) 인스턴스의 기존 세션이 자연스럽게 종료될 때까지 통신을 보장하는 커넥션 드레이닝(Connection Draining / Graceful Shutdown)을 단계적으로 수행하는 데 있습니다.
라이브 서비스를 운영하는 게임 개발사에게 정기 점검으로 인한 서버 다운타임은 유저 이탈과 매출 손실로 직결됩니다. 웹 서비스는 HTTP 요청의 무상태성(Stateless) 덕분에 롤링 업데이트나 블루/그린 배포를 비교적 쉽게 적용할 수 있지만 지속적인 TCP/UDP 연결과 인메모리 세션 상태를 유지해야 하는 실시간 멀티플레이 게임 서버는 배포 아키텍처가 훨씬 복잡합니다.
스테이트풀 게임 서버의 무점검(Zero-Downtime) 패치에 필요한 블루/그린 배포 파이프라인과 커넥션 드레이닝 방식을 정리합니다.
상태 비저장 웹 서버와 스테이트풀 게임 서버는 배포가 왜 다를까?
일반적인 웹/모바일 API 서버는 요청(Request) 단위로 세션이 완결되므로 로드밸런서가 신규 요청을 새 인스턴스로 넘기면 구버전 인스턴스는 수 초 이내에 안전하게 격리할 수 있습니다. 반면 전용 게임 서버(Dedicated Game Server, DGS)나 MMO 월드 서버는 프로세스 메모리에 플레이어 좌표, 룸 상태, 물리 연산 컨텍스트를 직접 들고 있습니다.
| 비교 항목 | 상태 비저장(Stateless) 웹 서버 | 상태 유지(Stateful) 게임 서버 |
|---|---|---|
| 연결 프로토콜 | HTTP/1.1, HTTP/2, REST, GraphQL | 지속적인 TCP 스트림, 커스텀 UDP/KCP, WebSocket |
| 세션 유지 기간 | 수십 밀리초(ms) ~ 수 초 | 수십 분(매치 플레이) ~ 수 시간(MMO 세션) |
| 프로세스 메모리 상태 | 없음 (Redis, DB 등 외부 저장소에 위임) | 실시간 액터 데이터, 월드 상태, 인메모리 틱(Tick) 연산 |
| 배포 시 발생 위험 | 극히 짧은 재시도(Retry)로 복구 가능 | 플레이어 튕김(Disconnect), 롤백, 데이터 유실 |
| 드레이닝 허용 시간 | 5초 ~ 30초 내외 | 10분 ~ 1시간 이상 (게임 룸 수명에 비례) |

따라서 게임 서버를 무점검으로 배포하려면 ‘신규 진입 차단’과 ‘진행 중인 룸/세션의 안전한 완주’를 정밀하게 제어하는 인프라 및 애플리케이션 레벨의 커넥션 드레이닝 프로토콜이 필수적입니다.
블루/그린 배포와 커넥션 드레이닝의 핵심 동작 원리
블루/그린(Blue/Green) 배포는 동일한 프로덕션 인프라에 두 세트의 환경(구버전 Blue, 신버전 Green)을 구성하고 게이트웨이 또는 로드밸런서 라우팅 테이블을 조작해 트래픽을 전환하는 기법입니다.
게임 아키텍처에서는 게이트웨이(또는 매치메이커)가 배포 버전을 인지하여 분기를 처리합니다.
flowchart TD
Client[클라이언트 진입] --> Gateway[API Gateway / Matchmaker]
subgraph Routing[트래픽 분기 계층]
Gateway -->|신규 매칭 / 로그인| Green["Green 인스턴스 (신규 v2.0)"]
Gateway -.->|신규 유입 차단| Blue["Blue 인스턴스 (구버전 v1.0)"]
end
subgraph Draining[커넥션 드레이닝 상태]
Blue -->|기존 진행 세션 유지| SessionActive[인게임 플레이 진행]
SessionActive -->|매치 종료 / 로그아웃| SessionEnd[세션 정상 종료]
SessionEnd --> Terminate[Active Session 0 확인 후 Pod/인스턴스 제거]
end
- Green 환경 프로비저닝: 신규 빌드가 적용된 Green 서버 풀(Pool)을 배포하고 헬스체크(Health Check)를 통과시킵니다.
- 매치메이킹 타깃 전환: 매치메이커 및 세션 디렉터리가 신규 룸 생성을 오직 Green 풀에만 할당하도록 레지스트리를 갱신합니다.
- Blue 커넥션 드레이닝 시작: Blue 서버는 상태 플래그를
Draining으로 변경하고 추가 연결 수락(listen)을 중단하거나 신규 매치 진입을 거부합니다. - 기존 플레이 완주 및 데이터 Flush: Blue 서버에서 진행 중이던 게임 룸이 정상적으로 끝나면 최종 결과 데이터를 DB에 영속화하고 세션을 종료합니다.
- Blue 환경 회수: Blue 서버의 활성 세션 카운트()가 0이 되면 프로세스를 종료(
SIGTERM)하고 인프라 자원을 반환합니다.
단계별 무점검 패치 아키텍처 구축 가이드 (Step-by-Step)
1단계: 게이트웨이 및 매치메이커 트래픽 분기 구성
클라이언트가 게이트웨이에 접속할 때 클라이언트 빌드 버전 헤더(X-Client-Version) 또는 앱 번들 버전을 검사하여 라우팅 대상을 결정합니다.
- 하위 호환 패치 (Data-compatible): 구버전 클라이언트도 신규 서버에 접속 가능하도록 변환 레이어를 적용합니다.
- 메이저 버전 분기 (Breaking Changes): 스토어 업데이트 전까지 구버전 클라이언트는 Blue 서버로 스토어 업데이트를 완료한 Green 클라이언트는 Green 서버로 격리 라우팅합니다.
2단계: 서버 프로세스의 라이프사이클 상태 머신 설계
게임 서버 인스턴스는 반드시 스스로의 드레이닝 상태를 외부(오케스트레이터, 매치메이커)에 알릴 수 있어야 합니다.
Allocated / Busy: 플레이어가 진입하여 정상 서비스 중인 상태Draining: 신규 세션 할당은 받지 않고 기존 세션만 유지하는 상태Terminating: 모든 세션이 비워져 데이터 영속화 후 안전 종료 준비를 마친 상태
3단계: C# / .NET 전용 게임 서버 커넥션 드레이닝 구현 예제
다음은 오케스트레이터(Kubernetes, Agones 등)로부터 종료 신호나 관리자 드레이닝 명령을 받았을 때 활성 세션을 관리하고 안전하게 종료하는 C# 예제 코드입니다.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
public class GameServerManager
{
private readonly ConcurrentDictionary<string, GameSession> _activeSessions = new();
private readonly object _sessionGate = new();
private readonly Dictionary<string, Task> _endingSessions = new();
private bool _isDraining;
private readonly CancellationTokenSource _cts = new();
public bool IsDraining
{
get { lock (_sessionGate) return _isDraining; }
}
public int ActiveSessionCount
{
get { lock (_sessionGate) return _activeSessions.Count; }
}
public void StartConnectionDraining()
{
lock (_sessionGate)
{
if (_isDraining) return;
_isDraining = true;
}
Task.Run(() => MonitorDrainProgressAsync(_cts.Token));
}
public bool TryCreateSession(string sessionId, GameSession session)
{
lock (_sessionGate)
{
if (_isDraining) return false;
return _activeSessions.TryAdd(sessionId, session);
}
}
public Task EndSessionAsync(string sessionId)
{
return EndSessionAsync(sessionId, forceEviction: false);
}
private async Task EndSessionAsync(string sessionId, bool forceEviction)
{
Task completion;
TaskCompletionSource<bool> startFlush = null;
GameSession session = null;
lock (_sessionGate)
{
if (_endingSessions.TryGetValue(sessionId, out completion))
{
// Concurrent callers await the same flush.
}
else if (_activeSessions.TryGetValue(sessionId, out session))
{
startFlush = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
completion = startFlush.Task;
_endingSessions.Add(sessionId, completion);
}
else
{
return;
}
}
if (startFlush != null)
_ = FlushAndRemoveSessionAsync(sessionId, session, startFlush, forceEviction);
await completion;
}
private async Task FlushAndRemoveSessionAsync(
string sessionId,
GameSession session,
TaskCompletionSource<bool> completion,
bool forceEviction)
{
try
{
if (forceEviction)
await session.SaveStateOnEvictionAsync();
else
await session.FlushPlayerDataToDatabaseAsync();
lock (_sessionGate)
{
_activeSessions.TryRemove(sessionId, out _);
_endingSessions.Remove(sessionId);
}
completion.TrySetResult(true);
}
catch (Exception ex)
{
lock (_sessionGate)
_endingSessions.Remove(sessionId);
completion.TrySetException(ex);
}
}
private async Task MonitorDrainProgressAsync(CancellationToken cancellationToken)
{
var maxDrainTimeout = TimeSpan.FromMinutes(30);
var startTime = DateTime.UtcNow;
while (!cancellationToken.IsCancellationRequested)
{
if (ActiveSessionCount == 0)
{
ShutdownServer();
break;
}
if (DateTime.UtcNow - startTime > maxDrainTimeout)
{
await ForceEvacuateAllSessionsAsync();
ShutdownServer();
break;
}
await Task.Delay(5000, cancellationToken);
}
}
private async Task ForceEvacuateAllSessionsAsync()
{
string[] sessionIds;
lock (_sessionGate)
sessionIds = new List<string>(_activeSessions.Keys).ToArray();
foreach (string sessionId in sessionIds)
{
// Await an existing flush instead of racing it with a forced save.
await EndSessionAsync(sessionId, forceEviction: true);
}
}
private void ShutdownServer()
{
Environment.Exit(0);
}
}
실시간 세션 처리 시 반드시 고려해야 할 예외와 데이터 정합성
1. 드레이닝 타임아웃()의 수학적 계산
무한정 Blue 인스턴스를 유지할 수는 없으므로 명확한 최대 드레이닝 허용 시간()을 산정해야 합니다.
일반적인 매치 기반 게임의 최대 드레이닝 타임아웃은 단일 게임의 최대 매치 지속 시간()과 유저 로딩 및 결과 정산 지연()의 합으로 결정됩니다.
만약 MMO 필드 서버처럼 체류 시간이 무제한인 구조라면 플레이어가 포털 이동, 던전 입장, 또는 채널 재접속을 시도할 때 신규 Green 노드로 자연스럽게 유도하는 점진적 핸드오버(Soft Handover) 정책을 사용합니다.

2. 데이터베이스 스키마 마이그레이션 호환성
무점검 배포 시 가장 빈번하게 발생하는 장애는 DB 스키마 불일치입니다. Blue 서버와 Green 서버가 동시에 단일 DB에 접근하는 과도기 구간이 반드시 존재하기 때문입니다.
- Expand and Contract 패턴: 칼럼 삭제나 이름 변경 시, 1단계에서는 신규 칼럼을 추가(Expand)하여 Blue/Green이 모두 동작하게 만든 뒤 2단계에서 구버전 필드를 점진적으로 폐기(Contract)합니다.
- JSON 페이로드 유연성: 패킷 및 NoSQL/Redis 저장 구조에 스키마 버전 필드(
v: 2)를 포함하여 구버전 서버가 신규 필드를 무시하거나 기본값으로 역직렬화할 수 있도록 처리합니다.
자주 묻는 질문 (FAQ)
Q1. 인게임 매치 도중 서버가 배포되면 플레이어가 게임 도중 튕기나요?
아닙니다. 올바르게 구현된 커넥션 드레이닝 아키텍처에서는 배포 명령이 내려져도 기존 룸의 연결이 강제로 끊어지지 않습니다. 구버전 서버(Blue)는 신규 룸 생성만 중단할 뿐 현재 진행 중인 게임이 끝날 때까지 독립 프로세스로 계속 실행되며 게임이 완료된 후 정상 종료됩니다.
Q2. 커넥션 드레이닝 대기 시간이 너무 길어지면 인프라 비용이 증가하지 않나요?
일시적으로 Blue와 Green 인스턴스가 공존하므로 인프라 리소스 점유율이 일시적으로 상승합니다. 그러나 Agones나 Kubernetes HPA(Horizontal Pod Autoscaler) 같은 오케스트레이터를 활용하면 게임이 끝난 Blue Pod 단위로 즉시 자원이 반납되므로 비용 증가 폭은 배포 직후 수십 분 내로 제한됩니다.
Q3. 프로토콜 패킷 구조가 완전히 바뀌는 대규모 패치도 무점검으로 가능한가요?
가능합니다. 클라이언트 빌드 버전에 따라 API 게이트웨이 또는 리버스 프록시(Envoy)에서 라우팅 대상을 v1 클러스터와 v2 클러스터로 엄격히 물리 격리하는 방식을 적용하면 서로 다른 패킷 규격을 가진 클라이언트가 각자의 버전에 맞는 서버 풀에 접속하여 플레이할 수 있습니다.
정리: 안정적인 무점검 패치 체크리스트
- 상태 분리: 세션 생성 권한을 중앙 매치메이커나 게이트웨이에 위임했는가?
- Graceful Shutdown: OS 시그널(
SIGTERM) 수신 시 즉각 종료하지 않고 드레이닝 상태 머신으로 진입하는가? - 데이터 정합성: Blue/Green 동시 실행 상태에서 DB 스키마가 양방향 하위 호환성을 유지하는가?
- 오케스트레이션 연동: Pod 또는 인스턴스의 잔여 세션 수가 0이 되었을 때 안전하게 Terminate 시키는 자동화 훅이 연결되어 있는가?
이 아키텍처를 도입하면 주간 정기 점검 없이도 언제든 안전하게 밸런스 패치와 버그 수정을 프로덕션에 배포할 수 있습니다.



