20. 게임 네트워크 직렬화: 바이너리 프로토콜 설계와 스키마 진화
난이도 중내 답안
모범답안
난이도: 중
모범답안 — 게임 네트워크 직렬화와 스키마 진화
1. 직렬화 포맷 비교
- 텍스트(JSON, XML): 사람이 읽기 쉽고 도구가 풍부하지만 크고 느리다(필드명 반복, 문자열 파싱). 실시간 게임 핫패스에는 부적합. 다만 개발/디버깅, 설정, 저빈도 메타 API(로비, 상점 카탈로그)에는 충분하고 편하다.
- Protocol Buffers(태그드 가변 길이): 각 필드가
(필드번호, 와이어타입)태그 + varint/length-prefix 값. 작고 빠르며 스키마 진화가 강력(아래 2번). 디코딩 시 필드별 파싱이 필요해 완전 제로카피는 아님. 범용 게임 메시지의 기본 선택으로 무난. - FlatBuffers / Cap'n Proto(제로카피): 메시지를 메모리 레이아웃 그대로 직렬화해, 받는 쪽이 파싱 없이 버퍼에서 직접 필드를 읽는다(디코딩 비용 ~0). 큰 읽기 전용 데이터(맵/에셋 메타, 자주 읽는 상태)에 유리. 대신 인코딩이 다소 크고 쓰기가 덜 유연.
- 커스텀 바이너리: 필요한 필드만 손수 팩킹 → 가장 작고 빠를 수 있다. 고빈도 실시간 패킷(입력, 위치)에서 극한 최적화용. 대가는 직접 관리하는 버저닝/검증 부담 과 버그 위험. "핫패스만 커스텀, 나머지는 표준 포맷" 혼용이 실용적.
- 요약: 저빈도·도구성=JSON, 범용=Protobuf, 대형 읽기전용=FlatBuffers, 극한 핫패스=커스텀.
2. 스키마 진화(버저닝)
구버전 클라이언트와 신버전 서버가 공존해도 안 깨지려면:
- 필드를 번호(태그)로 식별하고 이름에 의존하지 않는다. 번호가 계약이다.
- 필드 추가는 새 번호로만. 구버전은 모르는 태그를 만나면 무시(또는 보존) 한다. 신버전이 보낸 새 필드를 구버전이 건너뛸 수 있어야 한다.
- 삭제한 필드 번호는 영구 예약(reserved) 하고 절대 재사용하지 않는다. 재사용하면 구버전이 옛 의미로 해석해 데이터가 오염된다.
- 모든 필드는 optional + 명확한 기본값. 없으면 기본값으로 동작하게 설계(required 강제는 진화를 막는다).
- 타입/의미 변경 금지: 같은 번호의 타입을 바꾸지 말고 새 필드를 추가해 마이그레이션.
- 미지 필드 보존(pass-through): 프록시/중계가 모르는 필드를 지우지 않고 전달하면 중간 노드만 구버전이어도 데이터가 살아남는다.
- 이 규칙 덕에 서버·클라를 독립적으로 롤아웃할 수 있다(무중단 배포의 전제).
3. 엔디안·정렬·가변 길이 인코딩
- 엔디안 고정: 송신/수신 머신의 바이트 순서가 다를 수 있으므로 와이어 포맷의
바이트 순서를 명시(대개 리틀엔디언 또는 네트워크 바이트오더) 하고 양쪽이 그에 맞춰
읽고 쓴다. 구조체를 그대로
memcpy해 보내면 엔디안·패딩·정렬 차이로 깨진다. - varint: 작은 값을 적은 바이트로 인코딩(7비트씩, 최상위 비트=continuation). 대부분의 ID·길이·카운트가 작아 대역폭 절약. 조심할 점: 최대 바이트 수 상한 (64비트→10바이트), 버퍼 경계 검사, 마지막 바이트 잉여 비트 검증을 안 하면 무한 루프·오버플로·위조에 취약(신뢰 못 할 입력에서 특히).
- length-prefix + 경계 검증: 문자열/배열은 앞에 길이를 둔다. 수신 측은 그 길이가 남은 버퍼 안이고 합리적 상한 이하인지 반드시 확인한 뒤 읽는다. 이 검증이 빠지면 OOB read/과다 할당(5번 참조).
4. 델타 압축과 스냅샷
- 위치·HP처럼 매 틱 조금씩 바뀌는 상태를 매번 전체로 보내면 대역폭 낭비다. 대신 직전 확인된 상태(baseline) 대비 변경분(델타)만 전송한다.
- 하지만 UDP 등에서 델타가 유실되면 이후 델타의 기준이 어긋난다. 그래서:
- 클라가 마지막으로 확인(ack)한 baseline을 서버가 추적해, 그 기준으로 델타를 만든다(신뢰성 있는 기준점).
- 주기적(또는 신규 접속·재동기화 시) 풀 스냅샷을 보내 드리프트를 리셋한다.
- 변하지 않은 필드는 비트마스크로 "안 바뀜"을 표시해 통째로 생략.
- 트레이드오프: 델타는 대역폭↓ 지만 상태 추적/복구 로직이 필요. 풀 스냅샷은 단순하고 견고하지만 크다. 델타 + 주기적 스냅샷 조합이 표준.
5. 보안 관점 (신뢰 못 할 입력 역직렬화)
역직렬화기는 원격 공격면 1순위다. 반드시:
- 전체 메시지·필드 길이 상한: 선언된 길이를 믿지 말고 상한과 남은 버퍼로 이중 제한.
- 중첩/재귀 깊이 제한: 중첩 컨테이너로 스택 오버플로·지수 팽창(디컴프레션 폭탄) 유발 가능 → 깊이·전개 크기 상한.
- 할당 상한: "원소 100만 개" 같은 헤더로 거대 할당을 유도하는 공격 방지. 실제 읽을 수 있는 바이트로 상한을 교차 검증.
- 알 수 없는 태그 처리 규칙: 무시할지 거부할지 명확히(2번의 진화 규칙과 일관).
- 오버플로/부호: 길이·카운트의 정수 오버플로, signed/unsigned 혼동으로 검증 우회되지 않게. 실패는 예외 폭주 대신 연결을 정상 종료로 처리.
- 역직렬화기는 퍼징(fuzzing) 으로 임의 바이트를 던져 크래시·행·과다 할당을 상시 점검.
한 줄 요약
포맷은 "빈도·크기·유연성"으로 고르고(범용 Protobuf, 핫패스 커스텀/FlatBuffers), 필드 번호 기반 규칙으로 스키마를 진화시켜 서버·클라를 독립 배포하며, 엔디안 고정과 length-prefix·경계 검증·varint 상한으로 정확성과 보안을 지키고, 실시간 상태는 델타+주기 스냅샷으로 대역폭을 아낀다.