← 문제로

11. 순서보장 채널 + 비순서 채널 혼용 시 상태 정합성 (C#)

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — 순서보장 채널 + 비순서 채널 혼용 시 상태 정합성 (C#)

난이도: 최상

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

두 채널 사이의 순서 보장이 없다는 사실을 코드가 무시한다. (A)(E) 좌표 최신성을 전역 _lastSeq 로 판단해, 한 엔티티의 패킷이 다른 엔티티의 좌표를 stale 로 폐기시키는 교차 오염. (B) seq <= _lastSeq 단순 비교가 ushort wrap(65535→0) 에서 역전. (C) POS 가 없는 엔티티를 생성하고 죽은/디스폰된 엔티티를 부활시킨다(채널 간 인과 역전). (D) 사망(Death)을 비신뢰 채널로 보내 유실되면 영구 불일치. 핵심: 중요 상태 전이는 신뢰·순서 채널 + 멱등, 좌표는 비신뢰 채널이되 엔티티별 wrap-safe 시퀀스 + 세대(gen) 로 최신성·인과를 복원한다. 채널은 전송 특성일 뿐, 정합성은 앱 레이어 시퀀싱으로 보장.


문제점

(A)+(E) 전역 _lastSeq 로 최신 판단 — 교차 오염 (정확성) ★간판

  • 증상: 엔티티 A 의 최신 좌표가 _lastSeq 를 올리면 직후 도착한 B 의 정상 좌표가 "오래됨" 으로 폐기 → 좌표가 드문드문 끊김(스터터).
  • 재현 조건: 비신뢰 채널 하나로 여러 엔티티 좌표를 채널 단위 seq 로 전송, 클라가 엔티티 구분 없이 전역 비교.
  • 근본 원인: 최신성은 (엔티티, 스트림)별로 판단해야 한다. 논리적으로 다른 좌표 스트림을 하나의 카운터로 묶었다.

(B) wrap 미고려 seq 비교 — 65536 주기 역전 (정확성)

  • 증상: 65536 패킷마다 최신 패킷이 버려지거나 옛 패킷이 적용된다.
  • 재현 조건: _lastSeq=65535, 다음 seq=0 → 0 <= 65535 참 → 최신을 stale 로 오판.
  • 근본 원인: 순환 수열은 wrap-safe 비교((short)(a-b) > 0)로. 직접 비교는 경계 붕괴(protocol_version/problem4 seq 와 동일 원리).

(C) POS 가 엔티티 생성/부활 — 채널 간 인과 역전 (정확성/보안) ★핵심

  • 증상: ① 스폰(신뢰)보다 좌표(비신뢰)가 먼저 오면 불완전한 유령 엔티티(Alive=false 기본값) 생성. ② 디스폰/사망 후 늦게 온 좌표가 엔티티를 되살림. ③ 변조 EntityId 로 임의 엔티티 생성.
  • 근본 원인: 채널 간 순서 미보장인데 POS 가 존재/생사 권위를 신뢰 채널 없이 바꾼다. _entities[id] = new ... 의 묵시적 생성도 원인.

(D) Death 가 비신뢰 채널 — 결정적 이벤트 유실 (정확성/설계) ★핵심2

  • 증상: 사망 유실 시 적이 영원히 살아있다고 믿음(영구 불일치), 중복 시 두 번 처리.
  • 근본 원인: "한 번 놓치면 복구 불가한 상태 전이" 는 신뢰·순서 채널 + 멱등으로. 전송 채널 선택이 메시지 의미와 어긋났다.

(보조) 동시성 / 묵시적 생성 (정확성)

  • 수신이 멀티스레드면 _entities 무보호 동시 접근으로 손상. 묵시적 키 생성이 부활/유령의 직접 원인 — TryGetValue 후 "있을 때만" 적용.

수정안

핵심: ① 중요 상태(스폰/디스폰/사망/장비)는 신뢰·순서 채널 + 멱등, ② 좌표는 엔티티별 wrap-safe 시퀀스로만 최신 적용, ③ POS 는 살아있는 기존 엔티티에만(생성/부활 금지), ④ 스폰/좌표에 세대(gen) 를 실어 재스폰 이전 늦은 좌표 폐기.

public class EntityView
{
    public uint Id; public float X, Y; public bool Alive;
    public ushort Gen;          // 스폰마다 증가
    public ushort LastPosSeq;   // 엔티티별 좌표 시퀀스
    public bool   HasPos;
}

static bool SeqNewer(ushort a, ushort b) => (short)(a - b) > 0;   // wrap-safe

public void OnReliable(ushort seq, MsgType type, Payload p)
{
    switch (type)
    {
        case MsgType.Spawn:
            if (_entities.TryGetValue(p.EntityId, out var prev))
                _entities[p.EntityId] = new EntityView { Id = p.EntityId, X = p.X, Y = p.Y,
                    Alive = true, Gen = (ushort)(prev.Gen + 1) };
            else
                _entities[p.EntityId] = new EntityView { Id = p.EntityId, X = p.X, Y = p.Y,
                    Alive = true, Gen = 1 };
            break;
        case MsgType.Despawn:
        case MsgType.Death:                  // 사망은 신뢰 채널로 이동
            if (_entities.TryGetValue(p.EntityId, out var e)) e.Alive = false;
            break;
    }
}

public void OnUnreliable(ushort seq, MsgType type, Payload p, ushort pktGen)
{
    if (type != MsgType.Pos) return;        // 비신뢰 채널엔 중요 이벤트를 두지 않는다
    if (!_entities.TryGetValue(p.EntityId, out var e)) return;  // 유령 방지
    if (!e.Alive) return;                   // 부활 금지
    if (pktGen != e.Gen) return;            // 재스폰 이전의 늦은 좌표 폐기
    if (e.HasPos && !SeqNewer(seq, e.LastPosSeq)) return;       // 엔티티별 wrap-safe

    e.LastPosSeq = seq; e.HasPos = true;
    e.X = p.X; e.Y = p.Y;
}

좌표에 세대(gen)를 실어 재스폰 이전 좌표를 거르고, 최신성은 엔티티별 wrap-safe 비교로. POS 는 살아있는 기존 엔티티에만 적용. 멀티스레드 수신이면 _entities 를 락/샤딩으로 보호. (정책 메모) 위 수정안은 Despawn/Death 를 원본의 Remove 대신 Alive=false 톰스톤으로 바꿨다. 이는 "디스폰 후 늦게 도착한 POS 가 Remove 된 엔티티를 다시 만들어 부활시키는" 경로를 막기 위함이다(엔티티를 즉시 제거하면 늦은 POS 가 유령으로 재생성). 일정 시간 뒤 톰스톤을 정리(GC)하는 정책을 함께 둔다. 즉시 제거가 꼭 필요하면 "최근 제거 id 집합"을 잠시 유지해 늦은 POS 를 거른다.


더 나은 설계

1) 채널 의미 ↔ 전송 특성 정렬

  • "잃으면 안 되는 상태 전이" = 신뢰·순서, "최신만 중요한 고빈도 값" = 비신뢰. 메시지를 설계 단계 분류표로 못박는다. 좌표 정밀 보정은 주기적 신뢰 스냅샷으로 보강.

2) 스냅샷 + 단조 틱

  • 좌표를 서버 틱 번호가 박힌 스냅샷으로 보내 클라가 틱 증가 시에만 적용(델타 스냅샷, problem10 연계). 인과/최신성을 틱 하나로 통일. 트레이드오프: 대역폭 vs 견고함.

3) 인과 버퍼링

  • 채널 간 인과가 꼭 필요하면 좌표를 잠깐 버퍼링하다 스폰 도착 시 적용(또는 스폰에 초기 좌표 포함). 베이스라인=신뢰, 보간=비신뢰.

4) 멱등 이벤트 + 상태 해시 검증

  • 사망/디스폰을 멱등으로 설계해 신뢰 채널 재전송에 맡기고, 주기적 클라 상태 해시로 정합성 검증·복구.

면접 포인트

  • 핵심: "채널은 전송 특성, 정합성은 앱 레이어 시퀀싱". 엔티티별 wrap-safe seq, 채널 간 인과 미보장(POS 생성/부활 금지), 결정적 전이는 신뢰 채널.
  • 예상 질문:
    1. "전역 lastSeq 가 좌표를 끊는 이유는?" → 엔티티 간 seq 교차 오염. 엔티티별 분리.
    2. "ushort seq 를 <= 비교하면?" → wrap 에서 역전. (short)(a-b)>0.
    3. "죽은 엔티티가 다시 움직이는 경로는?" → 디스폰/사망 후 늦은 POS 가 부활. find+alive+gen.
    4. "사망을 비신뢰로 보내면?" → 유실 시 영구 불일치. 신뢰·순서+멱등.