← 문제로

6. RPC 메시지 ID ↔ 핸들러 매핑 테이블

난이도 하
내 리뷰 · C#
해설 · C#

해설 — 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 끊기"는 정책 선택: 게임플레이 채널은 끊는 게 안전, 로깅/텔레메트리 채널은 무시가 나을 수 있다.

면접 포인트

  1. "메시지 ID 를 enum 순번에 맡기면 뭐가 위험한가?" → 와이어 계약이 코드 리팩터링에 종속 → 머지/재정렬로 의미가 silent 하게 바뀜. protobuf 가 왜 명시적 태그 번호 + reserved 를 강제하는지로 연결.
  2. "미지의 패킷 ID 가 왔다. 무시할까 끊을까?" → 채널/신뢰도에 따라 다름. 인증 후 게임플레이 채널의 미지 ID 는 변조 신호 → 끊고 메트릭. 호환성 위해 "모르면 무시"가 필요한 채널도 있음(확장성).
  3. "두 팀이 동시에 새 메시지를 추가하는데 ID 충돌을 어떻게 막나?" → ID 범위 네임스페이스 할당, 중앙 레지스트리/IDL, 부팅 시 중복 검출.

내가 놓친 항목 (복습용)

  • [ ] (A) 암묵적 enum 값 → 와이어 ID 불안정(머지/재정렬 시 의미 변경)
  • [ ] (D) 인덱서 직접 접근 → 미지 ID 에서 예외(KeyNotFound)
  • [ ] (C) 헤더 길이 무검증 + payload 매번 할당/복사
  • [ ] (B) 중복 Register 조용히 덮어쓰기 → 충돌 은폐