5. 멀티존 게임서버의 프로토콜 버전 게이트웨이 (종합)
난이도 최상해설 — 멀티존 게임서버의 프로토콜 버전 게이트웨이 (종합)
난이도: 최상
답변 프레임워크: 요약 → 버전 불일치의 메커니즘 → 문제 분류/원인 → 수정안 → 더 나은 설계
요약
게이트웨이가 자기 빌드 버전을 기준으로 패킷 헤더/페이로드 포맷을 고른다(E, B). 그러나 와이어 위에 흐르는 패킷의 포맷은 상대(클라)의 버전이 결정한다. 이 "판단 기준의 주체 혼동"이 이 문제의 심장이다. 게다가 협상 결과를 세션에 저장하지 않고(A) 매번 분기하며, 헤더 변경(flags 추가)과 페이로드 의미 변경(int cm → float m)을 버전 가드 없이 적용해 부분 롤아웃 중 모든 조합에서 desync/오파싱이 난다. 마지막으로 게이트웨이가 페이로드를 변환 없이 그대로 존으로 포워딩(F)해, 클라-게이트웨이 버전 협상과 게이트웨이-존 버전이 완전히 분리돼 버린다.
버전 불일치가 왜/어떻게 일어나는가 (이 문제의 본질)
부분/롤링 배포에서는 컴포넌트마다 교체 시점이 달라 여러 버전이 동시에 가동된다. 이 시스템에는 세 개의 독립 버전 축이 있다:
- 클라 ↔ 게이트웨이 와이어 버전 (v1.4 / v1.5 클라 공존)
- 게이트웨이 빌드 버전 (신/구 게이트웨이 인스턴스 공존)
- 게이트웨이 ↔ 존 내부 와이어 버전 (신/구 존 공존)
핵심 오류는 "패킷을 어떤 포맷으로 해석할지"를 자기 빌드 버전(축 2)으로 판단한 것이다(E). 와이어 포맷은 송신자의 버전(축 1)이 결정하므로, 신버전 게이트웨이가 v1.4 클라 패킷을 v1.5 헤더(5바이트)로 해석하면 1바이트씩 밀려 전수 오파싱한다. 즉 "버전을 무엇으로/어디서 판단하는가"를 틀린 것이 곧 버전 불일치 사고다. 올바른 기준은 핸드셰이크에서 협상해 세션에 저장한 버전이며, 그 값으로 모든 후속 패킷을 일관되게 해석해야 한다.
문제점
(E) 패킷 포맷 판단을 "자기 빌드 버전"으로 함 — 프로토콜설계 (핵심)
- 증상:
headerSize를Protocol.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를 쓰면 한동안 두 핸들러를 유지해야 하나, 안전한 롤백/공존을 보장.
면접 포인트
- "패킷을 어떤 포맷으로 파싱할지, 무엇을 기준으로 판단하나? 자기 빌드 버전으로 하면 왜 틀리나?" → 와이어 포맷의 권위는 송신자. 핸드셰이크에서 협상해 세션에 저장한 버전으로 일관 해석.
- "롤링 배포 중 게이트웨이 N+1 ↔ 존 N이 섞인다. C_Move의 int→float 변경을 어떻게 안전히 내보내나?" → expand/contract 2단계 + 게이트웨이 변환 + 가능하면 새 packetId. N/N+1 공존 전제.
- "같은 4바이트인데 int를 float로 바꾸는 변경이 왜 길이 검증으로 안 걸러지나? 어떻게 막나?" → 폭이 같아 길이로 구분 불가. 버전/패킷ID로 의미를 구분해야 한다.
내가 놓친 항목 (복습용)
- [ ] (E) 포맷 판단 기준을 자기 빌드 버전으로(→ 송신자/협상 버전이어야)
- [ ] (A) 협상 버전 세션 저장 부재(단일 권위 없음)
- [ ] (B) int cm→float m 의미 변경 버전 가드/변환 부재(같은 폭이라 미검출)
- [ ] (E-2) flags 헤더 변경의 버전 분기/단일 테이블 부재
- [ ] (F) 게이트웨이 무변환 포워딩(버전 경계 책임 누락, 내부 협상 부재)
- [ ] (C)(D) 화이트리스트(범위 협상 아님) + 사유 없는 거부/좀비 세션
- [ ] 길이/범위 무검증(보안)
해설 — 멀티존 게임서버의 프로토콜 버전 게이트웨이 (종합)
난이도: 최상
답변 프레임워크: 요약 → 버전 불일치의 메커니즘 → 문제 분류/원인 → 수정안 → 더 나은 설계
요약
게이트웨이가 자기 빌드 버전을 기준으로 패킷 헤더/페이로드 포맷을 고른다(E, B). 그러나
와이어 위에 흐르는 패킷의 포맷은 상대(클라)의 버전이 결정한다. 이 "판단 기준의 주체 혼동"이
이 문제의 심장이다. 게다가 협상 결과를 세션에 저장하지 않고(A) 매번 분기하며,
헤더 변경(flags 추가)과 페이로드 의미 변경(int cm → float m)을 버전 가드 없이 적용해
부분 롤아웃 중 모든 조합에서 desync/오파싱이 난다. C++ 특유로 헤더/좌표를 reinterpret_cast +
호스트 엔디안으로 읽어(B, size/id) 비정렬·이기종에서 추가로 깨지고, 길이 검증이 없어
size - headerSize 가 size_t 언더플로우로 거대 오버리드가 난다. 마지막으로 게이트웨이가
페이로드를 변환 없이 그대로 존으로 포워딩(F)해 버전 경계 책임을 방기한다.
버전 불일치가 왜/어떻게 일어나는가 (이 문제의 본질)
부분/롤링 배포에서는 컴포넌트마다 교체 시점이 달라 여러 버전이 동시에 가동된다. 이 시스템에는 세 개의 독립 버전 축이 있다:
- 클라 ↔ 게이트웨이 와이어 버전 (v1.4 / v1.5 클라 공존)
- 게이트웨이 빌드 버전 (신/구 게이트웨이 인스턴스 공존)
- 게이트웨이 ↔ 존 내부 와이어 버전 (신/구 존 공존)
핵심 오류는 "패킷을 어떤 포맷으로 해석할지"를 자기 빌드 버전(축 2)으로 판단한 것이다(E). 와이어 포맷은 송신자의 버전(축 1)이 결정하므로, 신버전 게이트웨이가 v1.4 클라 패킷을 v1.5 헤더(5바이트)로 해석하면 1바이트씩 밀려 전수 오파싱한다. 올바른 기준은 핸드셰이크에서 협상해 세션에 저장한 버전이며, 그 값으로 모든 후속 패킷을 일관되게 해석해야 한다.
문제점
(E) 패킷 포맷 판단을 "자기 빌드 버전"으로 함 — 프로토콜설계 (핵심)
- 증상:
headerSize를Protocol::Version(게이트웨이 빌드)으로 선택. v1.5 게이트웨이는 v1.4 클라 패킷도 5바이트 헤더로 읽어 id/payload가 전부 1바이트 밀림. - 재현조건: v1.5 게이트웨이 ↔ v1.4 클라(롤링 배포 중 흔함).
- 근본원인: 와이어 포맷의 권위는 송신자 버전. 수신자는 협상된 상대 버전으로 해석해야 한다.
(A) 협상 버전을 세션에 저장하지 않음 — 프로토콜설계
- 증상:
ClientVersion은 있으나 "이 세션이 실제로 쓸 합의 버전(negotiated)"이 없다. 분기마다Protocol::Version/ClientVersion을 임의로 섞어 써 일관성이 깨진다. - 근본원인: 협상 결과를 단일 권위 값으로 승격하지 않음. 모든 직렬화/파싱이 참조할 기준 부재.
(B) 페이로드 의미 변경(int cm → float m) + reinterpret_cast 엔디안/정렬 — 정확성/UB
- 증상:
C_Move::Parse가 항상*reinterpret_cast<const float*>. v1.4 클라는 좌표를 int cm로 보낸다. 같은 4바이트를 float로 해석하면 완전히 다른 값(예: 정수 100cm → float로는 1.4e-43) → 순간이동/NaN.- 게다가
reinterpret_cast<const float*>(payload+4)는 비정렬 접근(payload가 홀수 오프셋이면 ARM SIGBUS / strict aliasing UB)이며, 호스트 엔디안을 가정해 이기종 클라와 어긋난다. size/id도*reinterpret_cast<const uint16_t*>로 읽어 같은 엔디안/정렬 문제를 안는다.
- 재현조건: v1.4 클라의 C_Move를 v1.5 파서가 처리; 또는 비정렬/빅엔디안 환경.
- 근본원인: 같은 바이트 폭이라 길이로는 못 거르는 의미 변경 + 와이어를 호스트 메모리 표현과 동일시. 버전 분기/단위 변환/바이트 단위 조립이 모두 필요한데 없음.
(E-2) flags 추가가 헤더 길이를 바꿈 — 스키마 진화/정확성
- 증상: v1.5에서 헤더가 4→5바이트. 협상 버전이 아니라 빌드로 고르면 (E)와 동일하게 밀린다.
- 근본원인: 헤더 포맷 변경은 가장 비싼 변경. "버전→헤더 크기 테이블"이 단일 진실 공급원이어야 한다.
(F) 페이로드 무변환 포워딩 — 프로토콜설계 (게이트웨이의 핵심 책임 누락)
- 증상: 게이트웨이가 클라 버전 페이로드를 그대로 존으로 전달. 존은 자기 버전으로 해석. 클라 v1.4(int) → 존 v1.5(float)면 (B)와 같은 오파싱이 존에서 터진다.
- 근본원인: 게이트웨이가 "버전 경계(version boundary)"여야 하는데 변환/정규화를 안 한다. 내부 프로토콜 버전 협상도 없음(축 3 방치).
(C)(D) 협상이 아니라 단순 화이트리스트 — 프로토콜설계/UX
- 증상:
Supported.find로 통과/거부만. 범위 협상도, "둘 다 이해하는 최고 버전" 합의도 없다. 거부(D)는 사유/안내 없이 조용히return(소켓도 안 닫음 — 좀비 세션 가능). - 근본원인: 화이트리스트는 버전이 늘수록 관리 폭발하고, 다운그레이드 협상/강제 업데이트 안내가 불가.
(부수) 길이/범위 무검증 + size_t 언더플로우 — 보안
raw의 길이(rawLen)를 안 본다.payloadLen = size - headerSize에서size < headerSize면 size_t 부호 없는 언더플로우로 거대값 → 오버리드.raw가 짧으면 헤더 캐스팅에서 오버리드.
수정안 (핵심만)
struct GatewaySession {
uint16_t NegotiatedVersion = 0; // (A) 단일 권위: 모든 파싱/직렬화가 이 값으로 분기
};
void OnClientHandshake(GatewaySession& s, uint16_t clientVer) {
// (C)(D) 범위 협상 + 사유 있는 거부
if (clientVer < kMinSupported) { Reject(s, "UPDATE_REQUIRED"); return; }
if (clientVer > Protocol::Version) { Reject(s, "SERVER_OUTDATED"); return; }
s.NegotiatedVersion = std::min(clientVer, Protocol::Version); // 둘 다 이해하는 최고 버전
}
void OnClientPacket(GatewaySession& s, const uint8_t* raw, size_t rawLen) {
uint16_t ver = s.NegotiatedVersion; // (E) 빌드가 아니라 협상 버전 기준
int headerSize = HeaderSizeForVersion(ver); // 버전→헤더크기 단일 테이블
if (rawLen < (size_t)headerSize) { Disconnect(s); return; }
uint16_t size = uint16_t(raw[0] | (raw[1] << 8)); // (B) 바이트 단위 LE 조립
if (size < (uint16_t)headerSize || size > rawLen) { Disconnect(s); return; } // 길이 검증
uint16_t id = uint16_t(raw[2] | (raw[3] << 8));
const uint8_t* payload = raw + headerSize;
size_t payloadLen = size - headerSize; // 이제 언더플로우 불가
if (id == 2001) {
// (B) 버전별 파싱 + (F) 내부 정규형으로 변환 후 전달
MoveDto move = (ver >= 0x0105)
? ParseMoveFloat(payload, payloadLen) // v1.5: float m (바이트 단위 조립)
: ConvertCmToM(ParseMoveInt(payload, payloadLen)); // v1.4: int cm → 정규형(m)
RouteToZone(s, id, SerializeForZone(s, move)); // 존이 기대하는 내부 버전으로 재직렬화
}
}
- 게이트웨이가 버전 경계가 되어 외부(클라) 버전을 내부 정규형으로 변환 → 존은 단일 포맷만 처리.
- 헤더 크기/페이로드 포맷은 모두
NegotiatedVersion기준. 빌드 버전은 협상 상한으로만 사용. - 멀티바이트 필드는 바이트 단위 LE/BE 조립(엔디안/정렬 무관). float는
uint32비트로 받아memcpy복원.
더 나은 설계
버전 협상 전략
- 핸드셰이크에서 범위 협상 →
negotiated = min(serverMax, clientMax)를 세션에 저장. 이후 어떤 분기도 이 단일 값만 참조(빌드 상수 직접 참조 금지). - 버전 축마다 독립 협상: 클라↔게이트웨이, 게이트웨이↔존을 각각 협상. 게이트웨이가 두 축을 잇는 변환기.
스키마 진화 규칙 (이번 변경에 적용)
- 헤더 변경은 최후의 수단: flags가 필요하면 바디 옵션 블록으로, 또는 버전 게이트로. 헤더 변경 시 "버전→헤더 레이아웃" 테이블을 단일 진실로.
- 같은 폭의 의미 변경(int↔float, 단위 변경)은 금지에 가깝다: 길이로 못 거르므로 새 packetId를 부여하거나 명시적 버전 분기 + 변환을 강제. 이번 C_Move는 새 id 권장.
- 엔디안/정렬 명세 + 바이트 단위 reader/writer: reinterpret_cast로 정렬 타입 직접 역참조 금지.
롤링 배포 안전성
- 항상 N과 N+1이 동시에 산다고 가정하고 변경을 2단계로: ① 신버전은 구포맷을 읽을 수 있게(read-new+old) 먼저 배포, ② 전 인스턴스 전환 후 쓰기 포맷 전환.
- 게이트웨이가 버전 경계: 외부 다양성을 흡수해 내부는 단일/좁은 버전 윈도만 유지.
- 호환성 매트릭스 CI: {클라 v1.4,v1.5} × {게이트웨이 N-1,N} × {존 N-1,N} 라운드트립 자동 검증.
트레이드오프
- 게이트웨이 변환은 CPU/지연을 더하나 존이 단순해지고 버전 폭발을 한 곳에 가둔다.
- 의미 변경에 새 id를 쓰면 한동안 두 핸들러를 유지해야 하나, 안전한 롤백/공존을 보장.
면접 포인트
- "패킷을 어떤 포맷으로 파싱할지, 무엇을 기준으로 판단하나? 자기 빌드 버전으로 하면 왜 틀리나?" → 와이어 포맷의 권위는 송신자. 핸드셰이크에서 협상해 세션에 저장한 버전으로 일관 해석.
- "같은 4바이트인데 int를 float로 바꾸는 변경이 왜 길이 검증으로 안 걸러지나? 어떻게 막나?"
→ 폭이 같아 길이로 구분 불가. 버전/패킷ID로 의미를 구분. 그리고
*(float*)buf는 비정렬/엔디안 UB. - "
size - headerSize가 왜 위험한가?" → size_t 부호 없는 언더플로우로 거대 길이 → 오버리드. 뺄셈 전에size >= headerSize검증.
내가 놓친 항목 (복습용)
- [ ] (E) 포맷 판단 기준을 자기 빌드 버전으로(→ 송신자/협상 버전이어야)
- [ ] (A) 협상 버전 세션 저장 부재(단일 권위 없음)
- [ ] (B) int cm→float m 의미 변경 + reinterpret_cast 엔디안/정렬 UB
- [ ] (E-2) flags 헤더 변경의 버전 분기/단일 테이블 부재
- [ ] (F) 게이트웨이 무변환 포워딩(버전 경계 책임 누락, 내부 협상 부재)
- [ ] (C)(D) 화이트리스트(범위 협상 아님) + 사유 없는 거부/좀비 세션
- [ ] 길이/범위 무검증 + size - headerSize 언더플로우(보안)