← 문제로

2. 캐릭터 정보 패킷 직렬화 / 스키마 진화

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 캐릭터 정보 패킷 직렬화 / 스키마 진화

난이도: 중

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

v1.1에서 GuildId를 추가하며 하위호환을 깨는 직렬화 변경을 했다. 필드를 기존 필드 사이에 끼워 넣어(B) 위치 기반 파싱이 어긋났고, 패킷에 버전 정보가 없어(C) 구버전 클라가 신버전 패킷인지조차 알 수 없다. 결과적으로 v1.0 클라는 GuildIdLevel로 읽고(D), 그 뒤를 밀려 읽어 값 오염 → 길이 필드 오독 → Slice 범위 초과 예외/연결 끊김으로 이어진다. 이것이 "부분 롤아웃 중 버전 공존"이 실제로 사고를 내는 전형적 메커니즘이다.


문제점 (분류: 프로토콜설계/정확성)

(B) 신규 필드를 중간에 삽입 — 위치 기반 직렬화의 호환성 파괴

  • 증상: 바이트 레이아웃이 CharId, Name, Level(v1.0) → CharId, Name, GuildId, Level(v1.1)로 바뀜.
  • 재현조건: v1.1 서버 ↔ v1.0 클라가 붙는 순간(=점진 배포 기간 내내).
  • 근본원인: 위치(순서)로 필드를 식별하는 직렬화에서는 append-only가 절대 규칙인데, 중간 삽입은 그 뒤 모든 필드의 오프셋을 밀어버린다. 끝에 추가했어도 (C) 때문에 안전하지 않다(아래).

(C) 패킷에 버전/스키마 식별 정보가 없음 — 프로토콜설계

  • 증상: 수신 측이 "이 패킷이 v1.0용인가 v1.1용인가"를 판단할 근거가 없다.
  • 근본원인: 버전을 어디에 두고 무엇으로 판단할지가 설계되지 않음. 버전 판단 기준이 없으면 구버전은 "신규 필드가 더 붙었는지" 알 수 없어 설령 append-only여도 남는 바이트를 무시할지/다음 패킷으로 오인할지 결정 못 한다.

(D) 결과: 값 오염 + 캐스케이딩 파싱 실패 — 정확성/안정성

  • 증상: v1.0 클라가 Level 자리에서 실제로는 GuildId를 읽는다. Name 길이/내용이 살짝만 바뀌어도 이후 ReadStringlen이 거대값이 되어 _buf.Slice(Pos, len)ArgumentOutOfRangeException → 세션 끊김.
  • 근본원인: 위치 어긋남이 한 필드에 그치지 않고 뒤따르는 모든 read를 오염시킨다. 특히 가변길이(String)의 길이 필드를 오독하면 즉시 크래시로 번진다.

(부수) Reader/Writer 자체의 무검증 — 보안

  • ReadStringlen을 검증 없이 Slice에 넘긴다. 음수/거대 길이에 무방비.
  • WriteString/WriteInt_buf 잔여 용량을 체크하지 않아 버퍼 오버런 가능.

수정안

1) 패킷 헤더에 버전(또는 스키마) 필드를 둔다 — 무엇으로 판단할지 명확화

// 모든 패킷 공통 헤더: [ushort size][ushort packetId][ushort protocolVer]
// protocolVer 는 핸드셰이크에서 합의한 값. 패킷마다 다시 싣지 않고
// 세션에 저장해 두고 read/write 분기에 쓰는 방식도 흔하다(대역폭 절약).

"패킷 버전을 어디에 두는가"의 두 갈래:

  • 세션 단위: 핸드셰이크에서 한 번 협상 → 세션에 negotiatedVer 저장 → 모든 직렬화가 참조. 대역폭 절약, 일관성↑. 게임서버 주류 방식.
  • 패킷 단위: 패킷마다 버전 필드. 게이트웨이/릴레이가 패킷만 보고 판단해야 할 때 유리하나 오버헤드.

2) append-only + 버전 게이트로 직렬화

public void Serialize(PacketWriter w, ushort ver)
{
    w.WriteInt(CharId);
    w.WriteString(Name);
    w.WriteInt(Level);                 // (B) 기존 순서 유지
    if (ver >= 11)                     // 신규 필드는 항상 끝에, 버전 가드로
        w.WriteInt(GuildId);
}

public void Deserialize(PacketReader r, ushort ver)
{
    CharId = r.ReadInt();
    Name   = r.ReadString();
    Level  = r.ReadInt();
    GuildId = (ver >= 11) ? r.ReadInt() : 0;   // 구버전이면 기본값
}
  • v1.0 클라는 ver < 11로 인지 → 추가 필드를 읽지 않고 멈춤(남는 바이트는 헤더 size로 스킵).
  • v1.1 클라는 끝의 GuildId까지 읽음.

3) Reader 안전화 — 길이 검증

public string ReadString()
{
    int len = ReadInt();
    if (len < 0 || len > _buf.Length - Pos)
        throw new ProtocolException("bad string length");   // 또는 세션 종료
    var s = Encoding.UTF8.GetString(_buf.Slice(Pos, len));
    Pos += len;
    return s;
}

더 나은 설계 — 스키마 진화 규칙

  • 태그 기반(tag/field-number) 직렬화 채택: Protobuf/FlatBuffers처럼 각 필드에 번호를 부여하면 순서/존재 여부에 무관하게 파싱된다. 구버전은 모르는 태그를 건너뛰고(skippable), 없는 태그는 기본값으로 처리 → 중간 삽입/삭제가 안전. 위치 기반 수작업 직렬화의 함정을 근본 제거.
  • 스키마 진화 3원칙 (수작업 바이너리를 고수한다면):
    1. 필드는 끝에만 추가(append-only), 절대 중간 삽입/순서 변경 금지.
    2. 필드 삭제 금지 — 폐기(deprecate)하고 자리(reserved)는 남긴다. 재사용 금지.
    3. 모든 신규 필드는 버전 가드 + 합리적 기본값을 가진다(optional 시맨틱).
  • 버전 협상은 핸드셰이크에서 한 번: 서버/클라가 지원 버전 범위를 교환하고 min(server.max, client.max)로 합의. 이후 세션은 합의된 버전으로만 직렬화.
  • 포워드 호환 테스트를 CI에: "직전 N개 클라 버전 ↔ 현재 서버"의 직렬화 라운드트립을 자동 검증.

트레이드오프

  • 태그 기반은 안전하지만 바이트가 커지고(태그 오버헤드) 코드젠/스키마 관리 필요. 초저지연·초소형 패킷(예: 위치 동기화)은 버전 가드 붙인 수작업 직렬화가 더 쌀 수 있다.
  • 세션 단위 버전은 대역폭 이득이 크지만, 중계 노드가 패킷만으로 버전을 알 수 없다.

면접 포인트

  1. "서버를 먼저 올리고 클라가 천천히 업데이트되는 점진 배포에서, 프로토콜을 어떻게 바꿔야 사고가 안 나나?" → append-only + 버전 가드 + 양방향(forward/backward) 호환 테스트. 신구 동시 가동을 항상 가정.
  2. "패킷 버전을 패킷마다 싣는 것 vs 세션에 한 번 협상하는 것, 무엇을 택하고 왜?" → 대역폭/중계 요구/일관성 트레이드오프로 답.
  3. "필드를 삭제해야 한다. 어떻게?" → 즉시 삭제 금지. deprecate + reserved, 충분한 기간 뒤 정리.

내가 놓친 항목 (복습용)

  • [ ] (B) 중간 필드 삽입 → 위치 어긋남(append-only 위반)
  • [ ] (C) 버전 식별 정보 부재 → 무엇으로 판단할지 없음
  • [ ] (D) 캐스케이딩 파싱 실패(가변길이 길이 오독 → 크래시)
  • [ ] Reader 길이 무검증(보안)
  • [ ] 핸드셰이크 버전 협상 / 호환 테스트 부재