6. RPC 메시지 ID ↔ 핸들러 매핑 테이블
난이도 하해설 — RPC 메시지 ID ↔ 핸들러 매핑 테이블
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
[ushort msgId][payload] 패킷을 Dictionary<ushort, handler> 로 디스패치한다.
골격은 평범하지만 (1) 와이어 ID 를 암묵적 enum 순번에 묶어버린 점,
(2) 미등록/미지의 msgId 에서 KeyNotFoundException 으로 죽는 점,
(3) 길이 검증 없이 헤더 2바이트를 읽고 payload 를 매번 복사하는 점,
(4) 중복 등록을 조용히 덮어쓰는 점이 문제다.
핵심은 "와이어에 나가는 ID 의 안정성(stability)" — 머지/재정렬로 의미가 바뀌면
구버전 클라가 보낸 Move 가 서버에선 Chat 으로 디스패치되는 silent corruption 이 난다.
문제점
(A) 와이어 ID 를 암묵적 enum 순번에 의존 — 호환성/정확성
- 증상:
enum MsgId : ushort { Login, Move, Chat, ... }는 값을 명시하지 않아 컴파일러가 선언 순서대로 0,1,2... 를 부여한다. 누군가 중간에enum { Login, Friend, Move, ... }처럼 값을 끼워 넣거나 머지로 순서가 바뀌면Move의 와이어 값이 1 → 2 로 바뀐다. - 재현조건: 두 기능 브랜치가 각각 enum 에 항목을 추가 → 머지 시 선언 순서가 섞임 →
서버는 신 enum, 현장 클라는 구 enum. 구 클라가
Move(=1)를 보내면 신 서버는 그 1 을Friend로 해석한다. 크래시 없이 잘못된 핸들러가 도는 최악의 버그. - 근본원인: 외부로 나가는 식별자를 "코드 선언 순서"라는 휘발성 자원에 바인딩. 와이어 계약(contract)은 코드 리팩터링과 독립이어야 한다.
(D) 미등록/미지의 msgId → KeyNotFoundException — 견고성/보안
- 증상:
_handlers[msgId]인덱서는 키가 없으면 예외를 던진다. 미등록 ID, 변조된 ID, 버전 차로 서버가 모르는 ID 한 방에 세션(혹은 워커)이 터진다. - 재현조건: 구버전이 신규 msgId 를 모르거나, 치터가 임의 ID 를 흘림.
- 근본원인:
TryGetValue없이 인덱서 직접 접근. 신뢰할 수 없는 입력을 테이블 lookup 의 키로 그대로 사용.
(C) 헤더 길이 무검증 + payload 매 호출 할당 — 견고성/성능
- 증상:
packet[0] | packet[1]<<8전에packet.Length >= 2검사가 없다. 1바이트 패킷이 오면IndexOutOfRange. 또 매 수신마다new byte[]+Array.Copy로 payload 를 복사 → GC 압력. - 근본원인: 입력 길이 가정 + 핫패스 불필요 복사.
(B) 중복 Register 를 조용히 덮어쓰기 — 운영/디버깅
- 증상: 두 모듈이 같은 id 로 Register 하면 뒤엣것이 앞엣것을 말없이 교체. ID 충돌(특히 자동 enum 재정렬로 우연히 겹친 경우)을 부팅 시점에 못 잡고 런타임에 "왜 이 핸들러가 안 불리지?" 로 번진다.
- 근본원인: 등록 단계 충돌 검출 부재.
수정안
(A) 와이어 ID 를 명시적·불변으로 고정
public enum MsgId : ushort
{
Login = 1, // 한 번 배포된 값은 영원히 불변. 빈 자리는 비워둔다
Move = 2,
Chat = 3,
UseItem = 4,
// 새 메시지는 "항상 끝에, 새 숫자로만" 추가. 중간 삽입/재사용 금지.
}
규칙: 삭제는 값을 "예약(reserved)"으로 남기고 절대 재사용하지 않는다.
(protobuf 의 reserved 와 같은 원칙.)
(C)(D) 안전한 디스패치
public void OnReceive(ReadOnlySpan<byte> packet)
{
if (packet.Length < 2) { /* 로그 후 무시/끊기 */ return; }
ushort msgId = (ushort)(packet[0] | (packet[1] << 8));
var payload = packet.Slice(2); // 복사 없이 슬라이스
if (!_handlers.TryGetValue(msgId, out var handler))
{
// 미지의 ID: 죽지 말고 메트릭 적재 + (정책상) 무시 또는 세션 종료
OnUnknownMessage(msgId);
return;
}
handler(payload);
}
핸들러 시그니처를 Action<ReadOnlySpan<byte>> 로 바꿔 할당 제거.
(Span 은 비동기 경계를 못 넘으니, 비동기 처리면 ReadOnlyMemory<byte> + 풀링.)
(B) 중복 등록 즉시 실패
public void Register(MsgId id, Action<ReadOnlySpan<byte>> handler)
{
if (!_handlers.TryAdd((ushort)id, handler))
throw new InvalidOperationException($"Duplicate handler for {id}");
}
더 나은 설계
- ID 안정성을 IDL 로 강제: msgId 를 사람이 enum 순서로 관리하지 말고 protobuf/FlatBuffers/직접 IDL 의 명시적 태그 번호로 선언, 코드 생성. 재정렬해도 와이어 값이 안 바뀐다.
- ID 네임스페이스 분할: 상위 바이트를 카테고리(인증/게임/채팅)로 나누면 팀별로 충돌 없이 범위 할당 가능(예: 0x10xx 인증, 0x20xx 게임).
- 부팅 시 정합성 체크: 등록된 핸들러 집합과 "이 빌드가 안다고 선언한 ID 집합"을 대조해 누락/중복을 기동 시 실패시킨다(fail-fast).
- 버전 협상과 결합: 핸드셰이크에서 협상한 프로토콜 버전에 따라 "이 ID 가 유효한가"를 판단. 신규 ID 를 구버전 세션이 보내면 프로토콜 위반으로 처리.
트레이드오프
- 명시적 IDL/코드생성은 빌드 파이프라인이 무거워지지만, 와이어 호환성 사고를 컴파일/생성 단계에서 막는 가치가 압도적으로 크다.
TryGetValue후 "무시 vs 끊기"는 정책 선택: 게임플레이 채널은 끊는 게 안전, 로깅/텔레메트리 채널은 무시가 나을 수 있다.
면접 포인트
- "메시지 ID 를 enum 순번에 맡기면 뭐가 위험한가?" → 와이어 계약이 코드 리팩터링에 종속 → 머지/재정렬로 의미가 silent 하게 바뀜. protobuf 가 왜 명시적 태그 번호 + reserved 를 강제하는지로 연결.
- "미지의 패킷 ID 가 왔다. 무시할까 끊을까?" → 채널/신뢰도에 따라 다름. 인증 후 게임플레이 채널의 미지 ID 는 변조 신호 → 끊고 메트릭. 호환성 위해 "모르면 무시"가 필요한 채널도 있음(확장성).
- "두 팀이 동시에 새 메시지를 추가하는데 ID 충돌을 어떻게 막나?" → ID 범위 네임스페이스 할당, 중앙 레지스트리/IDL, 부팅 시 중복 검출.
내가 놓친 항목 (복습용)
- [ ] (A) 암묵적 enum 값 → 와이어 ID 불안정(머지/재정렬 시 의미 변경)
- [ ] (D) 인덱서 직접 접근 → 미지 ID 에서 예외(KeyNotFound)
- [ ] (C) 헤더 길이 무검증 + payload 매번 할당/복사
- [ ] (B) 중복 Register 조용히 덮어쓰기 → 충돌 은폐
해설 — RPC 메시지 ID ↔ 핸들러 매핑 테이블
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
[uint16 msgId][payload] 패킷을 unordered_map<uint16_t, handler> 로 디스패치한다.
골격은 평범하지만 (1) 와이어 ID 를 암묵적 enum 순번에 묶어버린 점,
(2) 미등록/미지의 msgId 에서 operator[]가 빈 핸들러를 삽입하고 bad_function_call로 죽는 점,
(3) 길이/엔디안 무검증으로 헤더 2바이트를 읽고 payload 를 매번 복사하는 점,
(4) 중복 등록을 조용히 덮어쓰는 점이 문제다.
핵심은 "와이어에 나가는 ID 의 안정성(stability)" — 머지/재정렬로 의미가 바뀌면
구버전 클라가 보낸 Move 가 서버에선 Chat 으로 디스패치되는 silent corruption 이 난다.
문제점
(A) 와이어 ID 를 암묵적 enum 순번에 의존 — 호환성/정확성
- 증상:
enum class MsgId : uint16_t { Login, Move, Chat, ... }는 값을 명시하지 않아 컴파일러가 선언 순서대로 0,1,2... 를 부여한다. 누군가 중간에{ Login, Friend, Move, ... }처럼 값을 끼워 넣거나 머지로 순서가 바뀌면Move의 와이어 값이 1 → 2 로 바뀐다. - 재현조건: 두 기능 브랜치가 각각 enum 에 항목을 추가 → 머지 시 선언 순서가 섞임 →
서버는 신 enum, 현장 클라는 구 enum. 구 클라가
Move(=1)를 보내면 신 서버는 그 1 을Friend로 해석한다. 크래시 없이 잘못된 핸들러가 도는 최악의 버그. - 근본원인: 외부로 나가는 식별자를 "코드 선언 순서"라는 휘발성 자원에 바인딩. 와이어 계약(contract)은 코드 리팩터링과 독립이어야 한다.
(D) 미등록 msgId → operator[] 빈 핸들러 삽입 + bad_function_call — 견고성/보안
- 증상:
_handlers[msgId]는 키가 없으면 기본 생성된 빈std::function을 삽입하고 그 참조를 돌려준다. 이어서handler(payload)호출 시 비어 있는 함수라std::bad_function_call예외 → 세션/워커 크래시. 게다가 미지의 ID 마다 맵에 빈 엔트리가 쌓여 맵이 무한 증식(메모리 누수/DoS)한다. - 재현조건: 구버전이 신규 msgId 를 모르거나, 치터가 임의 ID 를 흘림.
- 근본원인:
find/count없이operator[]직접 접근. C# 인덱서의KeyNotFoundException과 달리 C++ mapoperator[]는 조용히 삽입까지 해 메모리 증식이 추가로 따라온다.
(C) 헤더 길이/엔디안 무검증 + payload 매 호출 복사 — 견고성/성능/UB
- 증상:
*reinterpret_cast<const uint16_t*>(packet)는 비정렬 접근(ARM UB) + 호스트 엔디안 가정. 빅엔디안/이기종 클라와 msgId가 뒤집힌다.len >= 2검사가 없다. 1바이트 패킷이면 헤더 read와packet + 2가 오버리드(len이 0/1이면vector(packet+2, packet+len)이 음수 범위 → UB).- 매 수신마다
std::vector새 할당 + 복사 → 힙 압력.
- 근본원인: 입력 길이 가정 + 와이어를 호스트 표현과 동일시 + 핫패스 불필요 복사.
(B) 중복 Register 를 조용히 덮어쓰기 — 운영/디버깅
- 증상: 두 모듈이 같은 id 로 Register 하면
operator[] =가 뒤엣것으로 말없이 교체. ID 충돌(특히 자동 enum 재정렬로 우연히 겹친 경우)을 부팅 시점에 못 잡고 런타임에 "왜 이 핸들러가 안 불리지?" 로 번진다. - 근본원인: 등록 단계 충돌 검출 부재.
수정안
(A) 와이어 ID 를 명시적·불변으로 고정
enum class MsgId : uint16_t
{
Login = 1, // 한 번 배포된 값은 영원히 불변. 빈 자리는 비워둔다
Move = 2,
Chat = 3,
UseItem = 4,
// 새 메시지는 "항상 끝에, 새 숫자로만" 추가. 중간 삽입/재사용 금지.
};
규칙: 삭제는 값을 "예약(reserved)"으로 남기고 절대 재사용하지 않는다.(protobuf 의 reserved와 동일.)
(C)(D) 안전한 디스패치
void OnReceive(const uint8_t* packet, size_t len)
{
if (len < 2) { /* 로그 후 무시/끊기 */ return; }
// 바이트 단위 조립(엔디안/정렬 무관)
uint16_t msgId = uint16_t(packet[0] | (packet[1] << 8));
auto it = _handlers.find(msgId); // operator[] 금지 — 삽입/예외 방지
if (it == _handlers.end() || !it->second) {
OnUnknownMessage(msgId); // 미지의 ID: 죽지 말고 메트릭 + 무시/끊기
return;
}
// 복사 없이 view 전달 (span)
it->second(std::span<const uint8_t>(packet + 2, len - 2));
}
핸들러 시그니처를 std::function<void(std::span<const uint8_t>)> 로 바꿔 매 호출 할당 제거.
(B) 중복 등록 즉시 실패
void Register(MsgId id, Handler handler)
{
auto [it, inserted] = _handlers.emplace(static_cast<uint16_t>(id), std::move(handler));
if (!inserted)
throw std::logic_error("Duplicate handler for msgId");
}
더 나은 설계
- ID 안정성을 IDL 로 강제: msgId 를 사람이 enum 순서로 관리하지 말고 protobuf/FlatBuffers/직접 IDL 의 명시적 태그 번호로 선언, 코드 생성. 재정렬해도 와이어 값 불변.
- ID 네임스페이스 분할: 상위 바이트를 카테고리(인증/게임/채팅)로 나누면 팀별로 충돌 없이 범위 할당 가능(예: 0x10xx 인증, 0x20xx 게임).
- 부팅 시 정합성 체크: 등록된 핸들러 집합과 "이 빌드가 안다고 선언한 ID 집합"을 대조해 누락/중복을 기동 시 실패시킨다(fail-fast).
- 엔디안 정책: msgId 같은 멀티바이트 필드는
ntohs/바이트 조립으로 통일. struct/캐스팅 금지. - 버전 협상과 결합: 핸드셰이크 협상 버전에 따라 "이 ID 가 유효한가" 판단. 신규 ID 를 구버전 세션이 보내면 프로토콜 위반으로 처리.
트레이드오프
- 명시적 IDL/코드생성은 빌드 파이프라인이 무거워지지만, 와이어 호환성 사고를 컴파일/생성 단계에서 막는 가치가 압도적으로 크다.
find후 "무시 vs 끊기"는 정책 선택: 게임플레이 채널은 끊는 게 안전, 로깅/텔레메트리 채널은 무시가 나을 수 있다.
면접 포인트
- "메시지 ID 를 enum 순번에 맡기면 뭐가 위험한가?" → 와이어 계약이 코드 리팩터링에 종속 → 머지/재정렬로 의미가 silent 하게 바뀜. protobuf 가 왜 명시적 태그 번호 + reserved 를 강제하는지로 연결.
- "C++ map 의
operator[]로 핸들러를 찾으면 C# 인덱서와 무엇이 다른가?" → C#은KeyNotFoundException이지만 C++operator[]는 빈 엔트리를 삽입해 예외(bad_function_call) + 맵 무한 증식까지.find/count로 접근해야 한다. - "두 팀이 동시에 새 메시지를 추가하는데 ID 충돌을 어떻게 막나?" → ID 범위 네임스페이스 할당, 중앙 레지스트리/IDL, 부팅 시 중복 검출.
내가 놓친 항목 (복습용)
- [ ] (A) 암묵적 enum 값 → 와이어 ID 불안정(머지/재정렬 시 의미 변경)
- [ ] (D) operator[] 직접 접근 → 빈 핸들러 삽입(bad_function_call) + 맵 증식
- [ ] (C) 헤더 길이 무검증 + 엔디안/정렬 가정 + payload 매번 할당/복사
- [ ] (B) 중복 Register 조용히 덮어쓰기 → 충돌 은폐