11. 순서보장 채널 + 비순서 채널 혼용 시 상태 정합성 (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 생성/부활 금지), 결정적 전이는 신뢰 채널.
- 예상 질문:
- "전역 lastSeq 가 좌표를 끊는 이유는?" → 엔티티 간 seq 교차 오염. 엔티티별 분리.
- "ushort seq 를 <= 비교하면?" → wrap 에서 역전. (short)(a-b)>0.
- "죽은 엔티티가 다시 움직이는 경로는?" → 디스폰/사망 후 늦은 POS 가 부활. find+alive+gen.
- "사망을 비신뢰로 보내면?" → 유실 시 영구 불일치. 신뢰·순서+멱등.
해설 — 순서보장 채널 + 비순서 채널 혼용 시 상태 정합성 (C++)
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
신뢰·순서 채널과 비신뢰·비순서 채널을 함께 쓰면서, 두 채널 사이의 순서 보장이 없다는
사실을 코드가 거의 모두 무시한다. 결함이 다섯 갈래로 겹친다. (A)(E) 좌표 최신성 판단을
모든 엔티티/타입 공통의 전역 lastSeq_ 로 해서, 한 엔티티의 최신 패킷이 다른 엔티티의
seq 를 올려버려 정상 좌표가 stale 로 오판·폐기된다. (B) seq 비교가 <= 단순 비교라
uint16 wrap 시점에 역전(65535→0)되어 최신 패킷을 버린다. (C) POS 가 엔티티가 없으면
유령 엔티티를 생성하고, 스폰보다 먼저 온 좌표/디스폰 후 늦게 온 좌표가 죽은 엔티티를
부활시킨다(채널 간 인과 역전). (D) 사망(DEATH) 같은 결정적 이벤트를 비신뢰 채널로
보내 유실되면 클라 상태가 영구히 어긋난다. 핵심: "중요 상태는 신뢰·순서 채널,
좌표는 비신뢰 채널, 단 엔티티별 wrap-safe 시퀀스/세대(generation)로 최신성·인과성을
복원" 한다. 채널은 전송 특성일 뿐, 정합성은 애플리케이션 시퀀싱으로 보장한다.
문제점
(A)+(E) 전역 lastSeq_ 로 최신 판단 — 교차 오염 (정확성) ★간판
- 증상: 엔티티 A 의 최신 좌표 패킷(seq 큰 값)이 들어오면
lastSeq_가 올라가, 직후 도착한 엔티티 B 의 정상 좌표가 "오래됨" 으로 폐기된다. 엔티티가 많을수록 좌표가 드문드문 끊긴다(스터터). - 재현 조건: 서버가 비신뢰 채널 하나로 여러 엔티티의 좌표를 보내며 seq 를 채널 단위로
증가. 클라가 엔티티 구분 없이 전역
lastSeq_로 비교. - 근본 원인: 최신성은 (엔티티, 스트림)별로 판단해야 한다. 좌표 스트림이 엔티티마다 논리적으로 다른데 하나의 카운터로 묶었다.
(B) wrap 미고려 seq 비교 — 65536 주기 역전 (정확성)
- 증상: 65536 개 패킷마다 한 번씩 최신 패킷이 통째로 버려지거나 옛 패킷이 적용된다.
- 재현 조건:
lastSeq_=65535인데 다음 seq=0(정상적 다음 값).0 <= 65535참 → 최신 패킷을 stale 로 오판해 폐기. 이후 한참 패킷이 막힌다. - 근본 원인: 부호 없는 순환 수열은 wrap-safe 비교(차이의 부호,
(int16_t)(a-b)>0)로 해야 한다.<=직접 비교는 경계에서 깨진다(protocol_version/problem4 의 seq 와 동일 원리).
(C) POS 가 엔티티 생성/부활 — 채널 간 인과 역전 (정확성/보안) ★핵심
- 증상: ① 스폰(신뢰)보다 좌표(비신뢰)가 먼저 도착하면
entities_[id]가 불완전한 유령 엔티티(alive 기본값/스폰 정보 없음)로 생성된다. ② 디스폰/사망 후 늦게 도착한 좌표가operator[]로 엔티티를 되살린다(죽은 적이 다시 움직임). ③ 변조된 entityId 로 임의 엔티티를 만들 수 있다. - 근본 원인: 두 채널 간 순서가 없으므로 "스폰 → 좌표" 인과가 보장되지 않는데, POS 가
존재/생사 상태를 신뢰 채널의 권위 없이 바꾼다.
operator[]의 묵시적 삽입도 한몫.
(D) DEATH 가 비신뢰 채널 — 결정적 이벤트 유실 (정확성/설계) ★핵심2
- 증상: 사망 패킷이 유실되면 클라는 적이 영원히 살아있다고 믿는다(영구 불일치). 중복되면 두 번 죽는 처리.
- 근본 원인: "한 번 놓치면 복구 불가한 상태 전이" 는 신뢰·순서 채널 + 멱등 처리로 보내야 한다. 전송 채널 선택이 메시지 의미와 어긋났다.
(보조) unordered_map 동시성 / operator[] (메모리)
- 수신이 여러 스레드면
entities_무보호 동시 접근은 UB.operator[]는 없는 키에 기본 엔티티를 삽입(부활/유령의 직접 원인).find로 존재 검증.
수정안
핵심: ① 중요 상태(스폰/디스폰/사망/장비)는 신뢰·순서 채널 + 멱등, ② 좌표는 비신뢰 채널이되 (엔티티별) wrap-safe 시퀀스로만 최신 적용, ③ POS 는 이미 살아있는 엔티티에만 적용(생성/부활 금지), ④ 스폰/디스폰에 세대(generation) 를 실어 늦은 좌표를 거르기.
struct EntityView {
uint32_t id; float x, y; bool alive;
uint16_t gen; // 스폰마다 증가하는 세대
uint16_t lastPosSeq; // 엔티티별 좌표 시퀀스
bool hasPos;
};
static inline bool SeqNewer(uint16_t a, uint16_t b) { // a 가 b 보다 최신?
return static_cast<int16_t>(a - b) > 0; // wrap-safe
}
void ClientWorld::OnReliable(uint16_t seq, uint8_t type, const Payload& p) {
switch (type) {
case SPAWN: {
auto& e = entities_[p.entityId];
e = EntityView{ p.entityId, p.x, p.y, true,
static_cast<uint16_t>(e.gen + 1), 0, false };
break;
}
case DESPAWN:
case DEATH: // 사망은 신뢰 채널로 이동
if (auto it = entities_.find(p.entityId); it != entities_.end())
it->second.alive = false; // erase 는 정책에 따라(시신 표시 등)
break;
}
}
void ClientWorld::OnUnreliable(uint16_t seq, uint8_t type,
const Payload& p, uint16_t pktGen) {
if (type != POS) return; // 비신뢰 채널엔 중요 이벤트를 두지 않는다
auto it = entities_.find(p.entityId);
if (it == entities_.end()) return; // 스폰 안 된 엔티티 좌표는 무시(유령 방지)
EntityView& e = it->second;
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) 를 함께 실어, 재스폰(gen 증가) 이전의 늦은 좌표를 거른다. 최신성은 엔티티별
lastPosSeq로, 비교는 wrap-safe. POS 는 살아있는 기존 엔티티에만 적용해 유령/부활을 차단. 다중 스레드 수신이면entities_를 락/샤딩으로 보호.
더 나은 설계
1) 채널 의미 ↔ 전송 특성 정렬
- "잃으면 안 되는 상태 전이" = 신뢰·순서, "최신만 중요한 고빈도 값" = 비신뢰. 메시지를 설계 단계에서 분류표로 못박는다. 좌표도 정밀 보정이 필요하면 주기적 신뢰 스냅샷으로 보강.
2) 스냅샷 + 단조 틱(authoritative tick)
- 좌표를 개별 이벤트가 아니라 서버 틱 번호가 박힌 스냅샷으로 보내고, 클라는 틱이 증가할 때만 적용(델타 스냅샷, protocol_version/problem10 과 연계). 인과/최신성을 틱 하나로 통일. 트레이드오프: 대역폭 vs 단순·견고한 정합성.
3) 인과 버퍼링/재정렬
- 채널 간 인과가 꼭 필요하면 좌표를 잠깐 버퍼링하다 스폰이 도착하면 적용(또는 스폰에 초기 좌표 포함). 베이스라인은 신뢰 채널, 보간은 비신뢰 채널.
4) 멱등 이벤트 + ack
- 사망/디스폰 등은 멱등(여러 번 적용해도 결과 동일)으로 설계하고, 신뢰 채널의 순서/재전송에 맡긴다. 필요 시 클라 상태 해시로 주기적 정합성 검증·복구.
면접 포인트
- 면접관이 듣고 싶은 핵심: "채널은 전송 특성일 뿐, 정합성은 앱 레이어 시퀀싱" 이라는 원칙. 그리고 (1) 최신성은 엔티티별 wrap-safe seq, (2) 채널 간 인과는 보장되지 않으니 POS 가 생성/부활하면 안 되고, (3) 결정적 상태 전이는 신뢰 채널로.
- 예상 질문:
- "전역 lastSeq 가 왜 좌표를 끊기게 하나?" → 엔티티 간 seq 가 섞여 한 엔티티 패킷이 다른 엔티티를 stale 로 만든다. 엔티티별 분리.
- "uint16 seq 를 <= 로 비교하면?" → wrap(65535→0)에서 역전. (int16_t)(a-b)>0 로 wrap-safe 비교.
- "죽은 엔티티가 다시 움직이는 경로는?" → 디스폰/사망 후 늦게 온 POS 가 operator[] 로 부활. find+alive+gen 체크로 차단.
- "사망을 비신뢰로 보내면?" → 유실 시 영구 불일치. 신뢰·순서 채널 + 멱등으로.