2. 캐릭터 정보 패킷 직렬화 / 스키마 진화
난이도 중내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 캐릭터 정보 패킷 직렬화 / 스키마 진화
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
v1.1에서 GuildId를 추가하며 하위호환을 깨는 직렬화 변경을 했다.
필드를 기존 필드 사이에 끼워 넣어(B) 위치 기반 파싱이 어긋났고,
패킷에 버전 정보가 없어(C) 구버전 클라가 신버전 패킷인지조차 알 수 없다.
결과적으로 v1.0 클라는 GuildId를 Level로 읽고(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 길이/내용이 살짝만 바뀌어도 이후ReadString의len이 거대값이 되어_buf.Slice(Pos, len)이 ArgumentOutOfRangeException → 세션 끊김. - 근본원인: 위치 어긋남이 한 필드에 그치지 않고 뒤따르는 모든 read를 오염시킨다. 특히 가변길이(String)의 길이 필드를 오독하면 즉시 크래시로 번진다.
(부수) Reader/Writer 자체의 무검증 — 보안
ReadString이len을 검증 없이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원칙 (수작업 바이너리를 고수한다면):
- 필드는 끝에만 추가(append-only), 절대 중간 삽입/순서 변경 금지.
- 필드 삭제 금지 — 폐기(deprecate)하고 자리(reserved)는 남긴다. 재사용 금지.
- 모든 신규 필드는 버전 가드 + 합리적 기본값을 가진다(optional 시맨틱).
- 버전 협상은 핸드셰이크에서 한 번: 서버/클라가 지원 버전 범위를 교환하고
min(server.max, client.max)로 합의. 이후 세션은 합의된 버전으로만 직렬화. - 포워드 호환 테스트를 CI에: "직전 N개 클라 버전 ↔ 현재 서버"의 직렬화 라운드트립을 자동 검증.
트레이드오프
- 태그 기반은 안전하지만 바이트가 커지고(태그 오버헤드) 코드젠/스키마 관리 필요. 초저지연·초소형 패킷(예: 위치 동기화)은 버전 가드 붙인 수작업 직렬화가 더 쌀 수 있다.
- 세션 단위 버전은 대역폭 이득이 크지만, 중계 노드가 패킷만으로 버전을 알 수 없다.
면접 포인트
- "서버를 먼저 올리고 클라가 천천히 업데이트되는 점진 배포에서, 프로토콜을 어떻게 바꿔야 사고가 안 나나?" → append-only + 버전 가드 + 양방향(forward/backward) 호환 테스트. 신구 동시 가동을 항상 가정.
- "패킷 버전을 패킷마다 싣는 것 vs 세션에 한 번 협상하는 것, 무엇을 택하고 왜?" → 대역폭/중계 요구/일관성 트레이드오프로 답.
- "필드를 삭제해야 한다. 어떻게?" → 즉시 삭제 금지. deprecate + reserved, 충분한 기간 뒤 정리.
내가 놓친 항목 (복습용)
- [ ] (B) 중간 필드 삽입 → 위치 어긋남(append-only 위반)
- [ ] (C) 버전 식별 정보 부재 → 무엇으로 판단할지 없음
- [ ] (D) 캐스케이딩 파싱 실패(가변길이 길이 오독 → 크래시)
- [ ] Reader 길이 무검증(보안)
- [ ] 핸드셰이크 버전 협상 / 호환 테스트 부재
해설 · C++
해설 — 캐릭터 정보 패킷 직렬화 / 스키마 진화
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
v1.1에서 GuildId를 추가하며 하위호환을 깨는 직렬화 변경을 했다.
필드를 기존 필드 사이에 끼워 넣어(B) 위치 기반 파싱이 어긋났고,
패킷에 버전 정보가 없어(C) 구버전 클라가 신버전 패킷인지조차 알 수 없다.
결과적으로 v1.0 클라는 GuildId를 Level로 읽고(D), 그 뒤를 밀려 읽어
값 오염 → 길이 필드 오독 → std::string 거대 할당/오버리드 크래시로 이어진다.
C++ 특유로 Writer/Reader 가 엔디안(W/R)·정렬·길이 무검증까지 안고 있어
이기종 클라에서는 정상 버전 조합에서도 깨질 수 있다.
문제점 (분류: 프로토콜설계/정확성/메모리안전)
(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 길이/내용이 살짝만 바뀌어도 이후ReadString의len이 거대값/음수가 되어std::string(ptr, len)이 거대 할당(bad_alloc) 또는 버퍼 오버리드 → 크래시. - 근본원인: 위치 어긋남이 한 필드에 그치지 않고 뒤따르는 모든 read를 오염시킨다. 특히 가변길이(String)의 길이 필드를 오독하면 즉시 크래시로 번진다.
(R)+(W) 엔디안 + 정렬 가정 — 호환성/UB (C++ 특유)
- 증상:
WriteInt는memcpy(&v, 4)로 호스트 바이트 순서를 그대로 쓴다.ReadInt는*reinterpret_cast<const int32_t*>(...)로 호스트 순서 + 정렬을 가정한다.- x86 클라/서버끼리는 우연히 같지만, 빅엔디안/이기종(콘솔·일부 ARM)에서는 정수가 뒤집힌다.
- 가변길이 필드 뒤 홀수 오프셋에서
int32_t*역참조는 비정렬 접근(ARM SIGBUS) + strict aliasing UB.
- 근본원인: 와이어 포맷을 호스트 메모리 표현과 동일시. 멀티바이트 필드를 바이트 단위로 명세하지 않음.
(부수) Reader/Writer 자체의 무검증 — 보안
ReadString이len(int32, 부호 있음)을 검증 없이 사용. 음수면std::string에서 UB/예외, 거대값이면 오버리드.WriteString/WriteInt도_buf잔여 용량을 체크하지 않아 버퍼 오버런 가능.
수정안
1) 패킷 헤더에 버전(또는 스키마) 필드를 둔다 — 무엇으로 판단할지 명확화
// 모든 패킷 공통 헤더: [uint16 size][uint16 packetId][uint16 protocolVer]
// protocolVer 는 핸드셰이크에서 합의한 값. 세션에 저장해 두고 read/write 분기에 쓰는 방식이 흔하다.
"패킷 버전을 어디에 두는가"의 두 갈래:
- 세션 단위: 핸드셰이크에서 한 번 협상 → 세션에
negotiatedVer저장 → 모든 직렬화가 참조. 대역폭 절약, 일관성↑. 게임서버 주류 방식. - 패킷 단위: 패킷마다 버전 필드. 게이트웨이/릴레이가 패킷만 보고 판단해야 할 때 유리하나 오버헤드.
2) append-only + 버전 게이트로 직렬화
void Serialize(PacketWriter& w, uint16_t ver)
{
w.WriteInt(CharId);
w.WriteString(Name);
w.WriteInt(Level); // (B) 기존 순서 유지
if (ver >= 11) // 신규 필드는 항상 끝에, 버전 가드로
w.WriteInt(GuildId);
}
void Deserialize(PacketReader& r, uint16_t 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 안전화 (길이 검증)
// 바이트 단위로 little-endian 명세 (엔디안/정렬 무관)
void WriteInt(int32_t v) {
_buf[Pos+0]=uint8_t(v); _buf[Pos+1]=uint8_t(v>>8);
_buf[Pos+2]=uint8_t(v>>16); _buf[Pos+3]=uint8_t(v>>24); Pos+=4;
}
int32_t ReadInt() {
int32_t v = int32_t(uint32_t(_buf[Pos]) | (uint32_t(_buf[Pos+1])<<8)
| (uint32_t(_buf[Pos+2])<<16) | (uint32_t(_buf[Pos+3])<<24));
Pos+=4; return v;
}
std::string ReadString() {
int32_t len = ReadInt();
if (len < 0 || len > _len - Pos) // 음수/오버리드 차단
throw std::runtime_error("bad string length"); // 또는 세션 종료
std::string s(reinterpret_cast<const char*>(_buf + Pos), len);
Pos += len;
return s;
}
(Reader 가 버퍼 길이 _len 을 알도록 생성자에 추가.)
더 나은 설계 — 스키마 진화 규칙
- 태그 기반(tag/field-number) 직렬화 채택: Protobuf/FlatBuffers처럼 각 필드에 번호를 부여하면 순서/존재 여부에 무관하게 파싱된다. 구버전은 모르는 태그를 건너뛰고(skippable), 없는 태그는 기본값으로 처리 → 중간 삽입/삭제가 안전. 위치 기반 수작업 직렬화의 함정을 근본 제거.
- 스키마 진화 3원칙 (수작업 바이너리를 고수한다면):
- 필드는 끝에만 추가(append-only), 절대 중간 삽입/순서 변경 금지.
- 필드 삭제 금지 — 폐기(deprecate)하고 자리(reserved)는 남긴다. 재사용 금지.
- 모든 신규 필드는 버전 가드 + 합리적 기본값을 가진다(optional 시맨틱).
- 엔디안/정렬 명세: 와이어는 LE/BE 중 하나로 못박고 바이트 단위 reader/writer로만 접근.
reinterpret_cast로 정렬된 타입 직접 역참조 금지. - 버전 협상은 핸드셰이크에서 한 번:
min(server.max, client.max)로 합의 후 세션에 저장. - 포워드 호환 테스트를 CI에: "직전 N개 클라 버전 ↔ 현재 서버"의 직렬화 라운드트립을 자동 검증.
트레이드오프
- 태그 기반은 안전하지만 바이트가 커지고(태그 오버헤드) 코드젠/스키마 관리 필요. 초저지연·초소형 패킷(예: 위치 동기화)은 버전 가드 붙인 수작업 직렬화가 더 쌀 수 있다.
- 세션 단위 버전은 대역폭 이득이 크지만, 중계 노드가 패킷만으로 버전을 알 수 없다.
면접 포인트
- "서버를 먼저 올리고 클라가 천천히 업데이트되는 점진 배포에서, 프로토콜을 어떻게 바꿔야 사고가 안 나나?" → append-only + 버전 가드 + 양방향(forward/backward) 호환 테스트. 신구 동시 가동을 항상 가정.
- "수작업 바이너리 직렬화에서
memcpy/reinterpret_cast로 정수를 쓰면 무슨 위험이 있나?" → 호스트 엔디안 의존(이기종 클라와 불일치) + 비정렬 역참조 UB. 바이트 단위 LE/BE 명세로 해결. - "필드를 삭제해야 한다. 어떻게?" → 즉시 삭제 금지. deprecate + reserved, 충분한 기간 뒤 정리.
내가 놓친 항목 (복습용)
- [ ] (B) 중간 필드 삽입 → 위치 어긋남(append-only 위반)
- [ ] (C) 버전 식별 정보 부재 → 무엇으로 판단할지 없음
- [ ] (D) 캐스케이딩 파싱 실패(가변길이 길이 오독 → std::string 크래시)
- [ ] (R)(W) 엔디안/정렬 가정(이기종/ARM UB)
- [ ] Reader 길이(음수/거대) 무검증(보안)
- [ ] 핸드셰이크 버전 협상 / 호환 테스트 부재