← 문제로

5. 멀티존 게임서버의 프로토콜 버전 게이트웨이 (종합)

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — 멀티존 게임서버의 프로토콜 버전 게이트웨이 (종합)

난이도: 최상

답변 프레임워크: 요약 → 버전 불일치의 메커니즘 → 문제 분류/원인 → 수정안 → 더 나은 설계

요약

게이트웨이가 자기 빌드 버전을 기준으로 패킷 헤더/페이로드 포맷을 고른다(E, B). 그러나 와이어 위에 흐르는 패킷의 포맷은 상대(클라)의 버전이 결정한다. 이 "판단 기준의 주체 혼동"이 이 문제의 심장이다. 게다가 협상 결과를 세션에 저장하지 않고(A) 매번 분기하며, 헤더 변경(flags 추가)과 페이로드 의미 변경(int cm → float m)을 버전 가드 없이 적용해 부분 롤아웃 중 모든 조합에서 desync/오파싱이 난다. 마지막으로 게이트웨이가 페이로드를 변환 없이 그대로 존으로 포워딩(F)해, 클라-게이트웨이 버전 협상과 게이트웨이-존 버전이 완전히 분리돼 버린다.


버전 불일치가 왜/어떻게 일어나는가 (이 문제의 본질)

부분/롤링 배포에서는 컴포넌트마다 교체 시점이 달라 여러 버전이 동시에 가동된다. 이 시스템에는 세 개의 독립 버전 축이 있다:

  1. 클라 ↔ 게이트웨이 와이어 버전 (v1.4 / v1.5 클라 공존)
  2. 게이트웨이 빌드 버전 (신/구 게이트웨이 인스턴스 공존)
  3. 게이트웨이 ↔ 존 내부 와이어 버전 (신/구 존 공존)

핵심 오류는 "패킷을 어떤 포맷으로 해석할지"를 자기 빌드 버전(축 2)으로 판단한 것이다(E). 와이어 포맷은 송신자의 버전(축 1)이 결정하므로, 신버전 게이트웨이가 v1.4 클라 패킷을 v1.5 헤더(5바이트)로 해석하면 1바이트씩 밀려 전수 오파싱한다. 즉 "버전을 무엇으로/어디서 판단하는가"를 틀린 것이 곧 버전 불일치 사고다. 올바른 기준은 핸드셰이크에서 협상해 세션에 저장한 버전이며, 그 값으로 모든 후속 패킷을 일관되게 해석해야 한다.


문제점

(E) 패킷 포맷 판단을 "자기 빌드 버전"으로 함 — 프로토콜설계 (핵심)

  • 증상: headerSizeProtocol.Version(게이트웨이 빌드)으로 선택. v1.5 게이트웨이는 v1.4 클라 패킷도 5바이트 헤더로 읽어 id/payload가 전부 1바이트 밀림.
  • 재현조건: v1.5 게이트웨이 ↔ v1.4 클라(롤링 배포 중 흔함).
  • 근본원인: 와이어 포맷의 권위는 송신자 버전. 수신자는 협상된 상대 버전으로 해석해야 한다.

(A) 협상 버전을 세션에 저장하지 않음 — 프로토콜설계

  • 증상: ClientVersion은 있으나 "이 세션이 실제로 쓸 합의 버전(negotiated)"이 없다. 분기마다 Protocol.Version/ClientVersion을 임의로 섞어 써 일관성이 깨진다.
  • 근본원인: 협상 결과를 단일 권위 값으로 승격하지 않음. 모든 직렬화/파싱이 참조할 기준 부재.

(B) 페이로드 의미 변경(int cm → float m)을 버전 가드 없이 — 정확성

  • 증상: C_Move.Parse가 항상 ToSingle(float). v1.4 클라는 좌표를 int cm로 보낸다. 같은 4바이트를 float로 해석하면 완전히 다른 값(예: 정수 100cm → float로는 1.4e-43) → 캐릭터 순간이동/NaN.
  • 재현조건: v1.4 클라의 C_Move를 v1.5 파서가 처리.
  • 근본원인: 같은 바이트 폭이라 길이로는 못 걸러지는 의미 변경. 버전 분기와 단위 변환이 필수인데 없음. (이런 변경은 packetId/버전을 분리하거나 새 패킷 id를 쓰는 게 안전하다.)

(E-2) flags 추가가 헤더 길이를 바꿈 — 스키마 진화/정확성

  • 증상: v1.5에서 헤더가 4→5바이트. 협상 버전이 아니라 빌드로 고르면 (E)와 동일하게 밀린다. 설령 클라 버전으로 골라도, 헤더에 가변 요소를 도입하면 모든 파서가 버전 분기를 타야 한다.
  • 근본원인: 헤더 포맷 변경은 가장 비싼 변경. flags를 헤더 끝에 옵션으로 넣더라도 "버전별 헤더 크기 테이블"이 단일 진실 공급원으로 관리돼야 한다.

(F) 페이로드 무변환 포워딩 — 프로토콜설계 (게이트웨이의 핵심 책임 누락)

  • 증상: 게이트웨이가 클라 버전 페이로드를 그대로 존으로 전달. 존은 자기 버전으로 해석. 클라 v1.4(int) → 존 v1.5(float)면 (B)와 같은 오파싱이 존에서 터진다.
  • 근본원인: 게이트웨이가 "버전 경계(version boundary)"여야 하는데, 변환/정규화를 안 한다. 내부 프로토콜 버전 협상도 없음(축 3 방치).

(C)(D) 협상이 아니라 단순 화이트리스트 — 프로토콜설계/UX

  • 증상: Supported.Contains(clientVer)로 통과/거부만. 범위 협상도, "둘 다 이해하는 최고 버전" 합의도 없다. 거부(D)는 사유/안내 없이 조용히 return(소켓도 안 닫음 — 좀비 세션 가능).
  • 근본원인: 화이트리스트는 버전이 늘수록 관리 폭발하고, 다운그레이드 협상/강제 업데이트 안내가 불가.

(부수) 길이/범위 무검증 — 보안

  • raw[0..3], raw.Slice(headerSize, size - headerSize)에서 raw.Length/size 검증 없음. size < headerSize면 음수 길이 Slice 예외, raw가 짧으면 인덱스 예외(problem1/4와 동일 계열).

수정안 (핵심만)

public class GatewaySession {
    public ushort NegotiatedVersion;   // (A) 단일 권위: 모든 파싱/직렬화가 이 값으로 분기
}

public void OnClientHandshake(GatewaySession s, ushort clientVer) {
    // (C)(D) 범위 협상 + 사유 있는 거부
    if (clientVer < MinSupported) { Reject(s, "UPDATE_REQUIRED", minVer: MinSupported); return; }
    if (clientVer > Protocol.Version) { Reject(s, "SERVER_OUTDATED"); return; }
    s.NegotiatedVersion = Math.Min(clientVer, Protocol.Version);  // 둘 다 이해하는 최고 버전
}

public void OnClientPacket(GatewaySession s, ReadOnlySpan<byte> raw) {
    ushort ver = s.NegotiatedVersion;                  // (E) 빌드가 아니라 협상 버전 기준
    int headerSize = HeaderSizeForVersion(ver);        // 버전→헤더크기 단일 테이블

    if (raw.Length < headerSize) { Disconnect(s); return; }
    ushort size = (ushort)(raw[0] | (raw[1] << 8));
    if (size < headerSize || size > raw.Length) { Disconnect(s); return; }  // 길이 검증
    ushort id = (ushort)(raw[2] | (raw[3] << 8));
    var payload = raw.Slice(headerSize, size - headerSize);

    if (id == 2001) {
        // (B) 버전별 파싱 + (F) 내부 정규형으로 변환 후 전달
        MoveDto move = (ver >= 0x0105)
            ? ParseMoveFloat(payload)                  // v1.5: float m
            : ConvertCmToM(ParseMoveInt(payload));     // v1.4: int cm → 정규형(m)
        RouteToZone(s, id, SerializeForZone(s, move)); // 존이 기대하는 내부 버전으로 재직렬화
    }
}
  • 게이트웨이가 버전 경계가 되어 외부(클라) 버전을 내부 정규형으로 변환 → 존은 단일 포맷만 처리.
  • 헤더 크기/페이로드 포맷은 모두 NegotiatedVersion 기준. 빌드 버전은 협상 상한으로만 사용.
  • 거부는 사유 코드 + 최소버전 안내 후 소켓 종료.

더 나은 설계

버전 협상 전략

  • 핸드셰이크에서 범위 협상 → negotiated = min(serverMax, clientMax)를 세션에 저장. 이후 어떤 분기도 이 단일 값만 참조(빌드 상수 직접 참조 금지). "무엇으로/어디서 판단하나"의 정답.
  • 버전 축마다 독립 협상: 클라↔게이트웨이, 게이트웨이↔존을 각각 협상. 게이트웨이가 두 축을 잇는 변환기.

스키마 진화 규칙 (이번 변경에 적용)

  • 헤더 변경은 최후의 수단: flags가 필요하면 헤더가 아니라 패킷 바디 옵션 블록으로, 또는 버전 게이트로. 헤더 변경 시 "버전→헤더 레이아웃" 테이블을 단일 진실로.
  • 같은 폭의 의미 변경(int↔float, 단위 변경)은 금지에 가깝다: 길이로 못 거르므로 새 packetId를 부여하거나 명시적 버전 분기 + 변환을 강제. 이번 C_Move는 새 id 권장.
  • append-only / optional 기본값 / 삭제 금지(problem2 규칙) 동일 적용.

롤링 배포 안전성

  • 항상 N과 N+1이 동시에 산다고 가정하고 변경을 2단계로: ① 신버전은 구포맷을 읽을 수 있게(read-new+old) 먼저 배포, ② 전 인스턴스 전환 후 쓰기 포맷 전환. (expand/contract, two-phase migration)
  • 게이트웨이가 버전 경계: 외부 다양성을 흡수해 내부는 단일/좁은 버전 윈도만 유지 → 존 복잡도 격감.
  • 호환성 매트릭스 CI: {클라 v1.4,v1.5} × {게이트웨이 N-1,N} × {존 N-1,N} 라운드트립 자동 검증.

트레이드오프

  • 게이트웨이 변환은 CPU/지연을 더하고 변환 코드의 유지보수 부담이 있다. 대신 존이 단순해지고 버전 폭발을 게이트웨이 한 곳에 가둘 수 있어 운영 안정성↑.
  • 의미 변경에 새 id를 쓰면 한동안 두 핸들러를 유지해야 하나, 안전한 롤백/공존을 보장.

면접 포인트

  1. "패킷을 어떤 포맷으로 파싱할지, 무엇을 기준으로 판단하나? 자기 빌드 버전으로 하면 왜 틀리나?" → 와이어 포맷의 권위는 송신자. 핸드셰이크에서 협상해 세션에 저장한 버전으로 일관 해석.
  2. "롤링 배포 중 게이트웨이 N+1 ↔ 존 N이 섞인다. C_Move의 int→float 변경을 어떻게 안전히 내보내나?" → expand/contract 2단계 + 게이트웨이 변환 + 가능하면 새 packetId. N/N+1 공존 전제.
  3. "같은 4바이트인데 int를 float로 바꾸는 변경이 왜 길이 검증으로 안 걸러지나? 어떻게 막나?" → 폭이 같아 길이로 구분 불가. 버전/패킷ID로 의미를 구분해야 한다.

내가 놓친 항목 (복습용)

  • [ ] (E) 포맷 판단 기준을 자기 빌드 버전으로(→ 송신자/협상 버전이어야)
  • [ ] (A) 협상 버전 세션 저장 부재(단일 권위 없음)
  • [ ] (B) int cm→float m 의미 변경 버전 가드/변환 부재(같은 폭이라 미검출)
  • [ ] (E-2) flags 헤더 변경의 버전 분기/단일 테이블 부재
  • [ ] (F) 게이트웨이 무변환 포워딩(버전 경계 책임 누락, 내부 협상 부재)
  • [ ] (C)(D) 화이트리스트(범위 협상 아님) + 사유 없는 거부/좀비 세션
  • [ ] 길이/범위 무검증(보안)