실시간 핫픽스 서버 패치로 패킷 정의를 안전하게 갱신하는 프로세스

멀티플레이 게임에서 패킷 필드와 검증 규칙을 무중단으로 바꾸는 방법을 정리합니다. 버전 협상, 이중 디코더, 점진 배포, 롤백과 관측성으로 호환성 장애를 줄이는 실전 서버 패치 절차를 제공합니다.

✓ 복사 완료!
챕터넷코드 & 상태 동기화 (Client Prediction/Lag)
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
실시간 핫픽스 서버 패치로 패킷 정의를 안전하게 갱신하는 프로세스

게임 서버의 패킷 정의를 핫픽스로 갱신할 때 핵심은 새 패킷을 즉시 강제하지 않는 것이다. 서버는 일정 기간 구·신 버전을 함께 해석하고 접속 시 협상한 프로토콜 버전을 세션에 고정한 뒤 점진적으로 기본 버전을 전환해야 한다. 이 구조를 갖추면 클라이언트 배포 지연, 재접속하지 않은 세션, 롤백 상황에서도 패킷 호환성 장애를 줄일 수 있다.

실시간 핫픽스에서 패킷 정의 갱신이 어려운 이유는 무엇인가?

라이브 멀티플레이 환경에서는 서버만 먼저 배포되는 일이 흔하다. 앱 스토어 심사, CDN 캐시, 지역별 업데이트 시점 차이 때문에 같은 매치메이킹 풀에 서로 다른 클라이언트 빌드가 공존한다. 서버가 패킷 필드를 바꾸는 순간 기존 클라이언트가 새 형식을 모르면 다음 문제가 발생한다.

  • 패킷 길이 또는 필드 번호가 달라져 디코딩에 실패한다.
  • 필수 필드가 추가되었는데 구버전 클라이언트가 값을 보내지 않는다.
  • 권한·재화·전투 판정 규칙을 바꿨지만 이전 요청 구조가 우회 경로가 된다.
  • 핫픽스 롤백 후 새 클라이언트의 요청을 구 서버가 처리하지 못한다.

특히 패킷 ID를 재사용하거나 직렬화 필드 번호를 변경하는 작업은 위험하다. Protocol Buffers를 사용한다면 삭제한 필드 번호를 reserved로 남기고 이미 배포한 필드의 번호와 타입은 바꾸지 않는 것이 기본 원칙이다.

패킷 버전 관리 전략은 어떻게 선택할까?

패킷 호환성은 단순히 version 숫자를 올리는 문제보다 어떤 계층에서 변화와 호환성을 관리할지의 문제에 가깝다.

전략적합한 변화장점주의점
선택 필드 추가선택 정보, 표시용 데이터구 클라이언트가 알 수 없는 필드를 무시할 수 있음새 필드를 항상 신뢰하면 안 됨
새 패킷 ID 추가의미나 흐름이 크게 달라진 요청구·신 처리 경로가 명확함핸들러와 테스트 수가 증가함
공통 Envelope의 프로토콜 버전세션 단위 기능 전환라우팅과 관측이 쉬움버전만으로 권한을 판정하면 안 됨
기능 플래그일부 사용자 대상 단계 배포즉시 중단·롤백이 쉬움플래그 조합이 복잡해질 수 있음

가장 안전한 조합은 호환 가능한 필드 추가 + 세션 프로토콜 협상 + 기능 플래그다. 의미가 달라진 요청은 억지로 기존 패킷을 확장하기보다 새 패킷 ID와 새 핸들러를 추가하는 편이 운영 중 판단이 쉽다.

패킷 정의를 무중단으로 갱신하는 5단계

1. 변경을 호환 가능·불가능으로 분류한다

배포 전에 변경 사항을 분류한다. 다음 항목은 대체로 호환 불가능하므로 새 메시지 또는 새 패킷 ID를 고려해야 한다.

  • 필드 번호 변경
  • 필드 타입 변경 (int32를 string으로 변경 등)
  • 의미가 바뀐 enum 값 재사용
  • 기존 필수 검증 규칙과 충돌하는 요청 구조 변경

예를 들어 이동 요청에 클라이언트 표시용 입력 시각을 추가하는 일은 선택 필드 추가로 처리할 수 있다.

syntax = "proto3";

message MoveRequest {
  uint64 entity_id = 1;
  sint32 target_x = 2;
  sint32 target_y = 3;

  // v2에서 추가. 서버 검증의 근거가 아니라 진단용으로만 사용한다.
  optional uint64 client_input_tick = 4;

  reserved 5, 6;
}

반면 target_x, target_y를 월드 좌표에서 셀 좌표로 바꾸는 변화는 같은 필드에 넣으면 안 된다. MoveRequestV2와 새 패킷 ID를 만들고 서버가 두 요청을 각각 해석한 뒤 내부 명령 모델로 정규화한다.

2. 접속 시 프로토콜 버전과 기능을 협상한다

클라이언트는 로그인 또는 게이트웨이 연결 직후 지원 범위와 기능 비트를 보내고 서버는 이 세션에서 사용할 버전을 응답한다. 이후 게임 서버는 클라이언트가 매 요청에 넣은 임의의 버전 값이 아니라 인증된 세션의 협상 결과를 사용한다.

{
  "clientBuild": "1.8.2",
  "protocolMin": 7,
  "protocolMax": 9,
  "features": ["move_v2", "inventory_delta_v2"]
}
{
  "selectedProtocol": 8,
  "enabledFeatures": ["inventory_delta_v2"],
  "minimumClientBuild": "1.7.0"
}

협상 결과는 세션 저장소 또는 인메모리 세션 객체에 보관한다. Redis를 세션 공유 저장소로 쓴다면 session:{id}:protocol과 session:{id}:features를 함께 기록해 서버 간 이동에도 일관성을 유지한다.

3. 이중 디코더와 단일 도메인 명령을 둔다

프로토콜 버전별 핸들러가 게임 규칙까지 각각 구현하면 시간이 지날수록 규칙이 갈라진다. 외부 패킷은 버전별로 읽되 핸들러는 검증된 내부 명령으로 변환한 후 하나의 도메인 서비스를 호출하는 방식이 좋다.

public sealed record MoveCommand(
    long PlayerId,
    long EntityId,
    int CellX,
    int CellY,
    long? ClientInputTick);

public async Task HandleMoveV1(Session session, MoveRequestV1 packet)
{
    var command = new MoveCommand(
        session.PlayerId,
        (long)packet.EntityId,
        packet.TargetX,
        packet.TargetY,
        null);

    await movementService.ApplyAsync(command, session.CancellationToken);
}

public async Task HandleMoveV2(Session session, MoveRequestV2 packet)
{
    var command = new MoveCommand(
        session.PlayerId,
        (long)packet.EntityId,
        packet.CellX,
        packet.CellY,
        packet.HasClientInputTick ? (long)packet.ClientInputTick : null);

    await movementService.ApplyAsync(command, session.CancellationToken);
}

ApplyAsync 안에서는 패킷 버전과 무관하게 소유권, 이동 가능 거리, 쿨다운, 현재 서버 틱을 검증한다. 클라이언트가 보내는 좌표나 tick은 입력이며 최종 상태는 서버 권한으로 계산해야 한다.

구버전과 신버전 패킷이 단일 도메인 명령으로 정규화되는 서버 처리 흐름

4. 새 정의를 비활성 상태로 먼저 배포한다

첫 배포에서는 새 디코더, 새 응답 직렬화기, 메트릭만 넣고 기본 동작은 구버전으로 유지한다. 이를 흔히 dark launch 또는 dormant path 배포라고 한다.

다음 흐름이면 배포와 활성화를 분리할 수 있다.

flowchart LR
    A[새 패킷 스키마 배포] --> B[구·신 디코더 동시 활성]
    B --> C[기능 플래그로 내부 테스트 그룹 활성]
    C --> D[오류율·지연시간·거부율 관측]
    D -->|정상| E[점진 확대 후 기본 버전 전환]
    D -->|이상 감지| F[플래그 비활성화]
    F --> B

이 단계에서는 새 경로를 실제 사용자에게 응답하지 않더라도 가능하다면 구 요청을 새 내부 변환기로 미러링해 결과 차이를 로그로 비교할 수 있다. 다만 재화 지급, DB 쓰기, 외부 결제처럼 부수 효과가 있는 처리는 미러 실행에서 반드시 차단해야 한다.

5. 점진 배포 후 구버전 제거 시점을 명시한다

활성화 대상은 직원 계정, 카나리 샤드, 지역, 클라이언트 빌드 순으로 넓힌다. 각 단계에서 아래 지표를 기존 버전과 비교한다.

지표확인할 신호권장 대응
디코딩 실패율unknown_packet, decode_error 증가즉시 플래그 비활성화
요청 거부율검증 실패 사유 급증규칙 변경 또는 클라이언트 버그 확인
p95 처리 시간새 변환·DB 조회로 지연 증가핫패스 프로파일링
재접속률특정 빌드·지역의 연결 반복협상 응답과 배포 상태 확인
상태 불일치 수구·신 경로 결과 차이도메인 정규화 로직 점검

구버전 제거일은 “새 버전이 안정화되면”처럼 정하지 않는다. 최소 지원 클라이언트 빌드, 세션 최대 수명, 롤백 보관 기간을 기준으로 결정한다. 예를 들어 세션 TTL이 24시간이고 최소 지원 빌드가 배포된 뒤 14일의 업데이트 유예를 둔다면 구 디코더는 그 이후에도 최소 24시간 이상 유지해야 기존 세션을 안전하게 정리할 수 있다.

핫픽스 롤백은 어떻게 설계해야 할까?

롤백은 새 서버 바이너리를 이전 바이너리로 되돌리는 행위만 의미하지 않는다. 이미 새 클라이언트가 배포되었을 수 있으므로 서버는 새 요청을 일정 기간 계속 받아야 한다.

안전한 롤백 순서는 다음과 같다.

  1. 기능 플래그를 먼저 끄고 새 응답·새 규칙의 신규 사용을 중단한다.
  2. 새 요청을 받는 디코더와 정규화 경로는 유지한다.
  3. 데이터 마이그레이션이 있었다면 읽기 호환성을 확인한 뒤 필요한 경우 이전 표현으로 투영한다.
  4. 오류 원인을 수정한 새 핫픽스를 배포하거나 최소 지원 클라이언트가 충분히 올라온 뒤에만 구 경로를 제거한다.

데이터베이스 스키마도 이 원칙을 따라야 한다. 패킷에서 받은 loadout_id를 새 컬럼으로 저장해야 한다면 먼저 nullable 컬럼 또는 별도 테이블을 추가하고 구·신 코드가 모두 읽을 수 있게 한다. 컬럼 삭제나 제약 조건 강화는 구 서버와 구 클라이언트 경로가 사라진 뒤의 별도 배포로 미룬다.

보안 검증에서 패킷 버전을 신뢰하면 안 되는 이유

protocolVersion >= 9라는 조건은 기능 선택에는 쓸 수 있지만 보안 권한을 부여하는 근거가 될 수 없다. 변조 클라이언트는 버전 값과 기능 비트를 위조할 수 있기 때문이다.

서버는 모든 버전에서 다음을 독립적으로 확인해야 한다.

  • 세션 토큰과 플레이어 ID의 연결 관계
  • 요청 대상 엔티티의 소유권 또는 접근 권한
  • 서버 시간 기준의 쿨다운과 요청 빈도 제한
  • 좌표, 수량, ID 범위 같은 입력값 검증
  • 재화·인벤토리 변경의 멱등성 키와 트랜잭션 처리

재시도 가능한 요청에는 request_id를 도입하고 플레이어 ID와 요청 종류를 포함한 복합 키로 중복 처리를 막는다. 네트워크 타임아웃 뒤의 재전송은 정상적인 상황이므로 중복 요청을 오류로만 취급하면 사용자 경험과 데이터 정합성이 함께 나빠진다.

운영 로그와 알림에는 무엇을 남겨야 하나?

개인정보나 원문 패킷 전체를 무분별하게 저장하지 말고 디버깅에 필요한 구조화된 필드만 남긴다.

{
  "event": "packet_rejected",
  "packetId": 2204,
  "protocolVersion": 8,
  "clientBuild": "1.8.2",
  "reason": "target_out_of_range",
  "serverShard": "kr-03",
  "traceId": "c7a1d94e"
}

packetId, protocolVersion, clientBuild, 거부 사유, 샤드, trace ID는 최소한의 운영 단서가 된다. 대시보드는 전체 평균보다 패킷 ID × 프로토콜 버전 × 클라이언트 빌드 차원으로 분해해 보는 편이 좋다. 특정 안드로이드 빌드에서만 오류가 나는 문제를 전체 오류율 하나로는 찾기 어렵다.

자주 묻는 질문 (FAQ)

필드 하나를 추가할 때도 패킷 버전을 올려야 하나?

선택 필드 추가만으로 구 클라이언트와의 바이너리 호환성이 유지된다면 반드시 올릴 필요는 없다. 다만 새 필드가 새 규칙이나 새 응답 흐름을 활성화한다면 기능 플래그 또는 프로토콜 버전으로 사용 범위를 구분하는 편이 안전하다.

패킷 ID를 재사용해도 되나?

이미 라이브에 배포한 패킷 ID는 재사용하지 않는 것이 원칙이다. 오래된 클라이언트, 지연된 큐 메시지, 캡처된 운영 로그가 남아 있을 수 있으므로 새 의미에는 새 ID를 할당한다.

구버전 핸들러는 언제 삭제할 수 있나?

최소 지원 클라이언트 비율, 최대 세션 수명, 롤백 기간이 모두 지나고 해당 버전의 트래픽이 관측되지 않을 때 삭제한다. 삭제 직전에도 해당 경로의 호출 수가 0인지 메트릭으로 확인해야 한다.

정리

실시간 핫픽스에서 패킷 정의 갱신의 목표는 새 형식을 빨리 배포하는 데 있지 않다. 구·신 패킷을 함께 처리하고 세션에서 협상한 버전과 기능 플래그로 사용 범위를 제어하며 서버 권한 검증과 관측성으로 이상을 빠르게 되돌릴 수 있게 만드는 데 있다.

새 필드는 호환 가능하게 추가하고 의미가 바뀐 요청은 새 패킷 ID로 분리하자. 그 다음 이중 디코더, 단일 도메인 명령, 단계적 활성화, 롤백 가능한 데이터 변경을 기본 배포 규칙으로 두면 패킷 핫픽스가 라이브 장애로 번질 가능성을 크게 낮출 수 있다.