← 문제로

14. 부동소수점 좌표 직렬화와 NaN/Inf·결정론 방어 (C#)

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

해설 — 부동소수점 좌표 직렬화와 NaN/Inf·결정론 방어 (C#)

난이도: 중상

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

요약

코드는 수신 float 를 아무 검증 없이 시뮬레이션에 적용하고, 직렬화 시 BitConverter호스트 엔디안을 그대로 와이어에 노출한다. 핵심 결함은 ① NaN/Inf 미검증: NaN 좌표는 모든 비교가 false 라 경계검사·정렬·충돌·AoI 를 조용히 무력화하고 연산으로 전파되어 엔티티가 사라지거나 물리가 폭주한다(악성 클라가 좌표에 NaN/Inf 를 심어 중계 공격 가능). ② 유한성/범위 미검증: 맵 밖·Inf 좌표가 그대로 반영. ③ 엔디안 가정: BitConverterBitConverter.IsLittleEndian 에 의존해 빅엔디안 머신과 섞이면 깨진다. 정답 한 줄: 고정 엔디안으로 직렬화하고, 수신 즉시 float.IsFinite + 도메인 범위로 sanitize 한 뒤에만 시뮬레이션에 넣는다.


문제점

(B)+(C) 수신 float 무검증 — NaN/Inf 오염 (검증/보안) ★간판

  • 증상: NaN 좌표는 pos.X == anything, pos.X < min, pos.X > max모두 false. 그래서 if (x < min || x > max) reject; 식 경계검사를 통과해버린다. NaN 은 x + v*dt 같은 연산으로 전파되어 공간 격자/AABB 에서 엔티티가 빠지고, 거리·타깃팅 판정이 망가진다. +Inf 는 공간 분할 자료구조를 폭주시킨다.
  • 재현 조건: 악성/버그 클라가 좌표에 NaN(0x7FC00000)·Inf 를 넣고 서버 A 가 검증 없이 B 로 중계. 또는 손상 패킷.
  • 근본 원인: 역직렬화가 비트→float 만 하고 의미 검증(유한·범위)을 안 한다. 신뢰 경계를 넘는 부동소수점은 항상 sanitize.

(A) BitConverter 호스트 엔디안 노출 — 이기종 깨짐 (호환성)

  • 증상: BitConverter.GetBytes/ToSingle 은 실행 머신 엔디안을 따른다. 송수신 서버 엔디안이 다르면 좌표가 깨진다(대부분 x86/ARM 리틀엔디안이라 잠복하지만 보장 아님).
  • 근본 원인: 와이어 포맷은 플랫폼 독립이어야 한다. BinaryPrimitives.WriteSingleLittleEndian 처럼 엔디안을 명시.

길이/구조 검증의 견고성 — 부수

  • 증상: 최소 길이만 보고 잔여/개수 검증이 빈약. 배치 스냅샷 확장 시 over-read 위험.
  • 근본 원인: 경계 기반 안전 리더(ReadOnlySpan + 길이검사)로.

-0.0/subnormal 결정론 — 부수

  • 증상: -0.0, 비정규수가 해시/정렬/비교에서 미묘한 불일치. 결정론 시스템에서 문제.
  • 근본 원인: 정규화 정책(+0.0 통일 등) 미정의. JIT/플랫폼별 float 연산 차이도 lockstep 결정론을 깬다.

수정안

핵심: ① BinaryPrimitives 로 고정 리틀 엔디안, ② 수신 즉시 IsFinite + 범위 sanitize.

using System.Buffers.Binary;

public void Write(List<byte> outBuf, in EntitySnapshot s)
{
    Span<byte> tmp = stackalloc byte[8];
    BinaryPrimitives.WriteInt64LittleEndian(tmp, s.Id);
    outBuf.AddRange(tmp.ToArray());

    foreach (float f in stackalloc float[]{ s.Pos.X,s.Pos.Y,s.Pos.Z, s.Vel.X,s.Vel.Y,s.Vel.Z })
    {
        Span<byte> b = stackalloc byte[4];
        BinaryPrimitives.WriteSingleLittleEndian(b, f);   // 엔디안 명시
        outBuf.AddRange(b.ToArray());
    }
}

static bool SanitizeCoord(float v, float lo, float hi)
{
    if (!float.IsFinite(v)) return false;     // NaN/Inf 거부 (먼저!)
    return v >= lo && v <= hi;                // 여기 도달 시 NaN 아님
}

public bool Read(ReadOnlySpan<byte> buf, out EntitySnapshot s)
{
    s = default;
    if (buf.Length < 8 + 4 * 6) return false;

    s.Id    = BinaryPrimitives.ReadInt64LittleEndian(buf);            int o = 8;
    s.Pos.X = BinaryPrimitives.ReadSingleLittleEndian(buf.Slice(o)); o += 4;
    s.Pos.Y = BinaryPrimitives.ReadSingleLittleEndian(buf.Slice(o)); o += 4;
    s.Pos.Z = BinaryPrimitives.ReadSingleLittleEndian(buf.Slice(o)); o += 4;
    s.Vel.X = BinaryPrimitives.ReadSingleLittleEndian(buf.Slice(o)); o += 4;
    s.Vel.Y = BinaryPrimitives.ReadSingleLittleEndian(buf.Slice(o)); o += 4;
    s.Vel.Z = BinaryPrimitives.ReadSingleLittleEndian(buf.Slice(o)); o += 4;

    const float MAP_LO = -100000f, MAP_HI = 100000f, V_MAX = 5000f;
    if (!SanitizeCoord(s.Pos.X, MAP_LO, MAP_HI) || !SanitizeCoord(s.Pos.Y, MAP_LO, MAP_HI) ||
        !SanitizeCoord(s.Pos.Z, MAP_LO, MAP_HI) || !SanitizeCoord(s.Vel.X, -V_MAX, V_MAX) ||
        !SanitizeCoord(s.Vel.Y, -V_MAX, V_MAX) || !SanitizeCoord(s.Vel.Z, -V_MAX, V_MAX))
        return false;   // 무효 스냅샷 거부

    return true;
}

순서가 핵심: IsFinite 를 먼저 통과시켜야 한다. NaN 은 범위검사 자체가 무의미(전부 false).


더 나은 설계

1) 좌표를 양자화 정수/고정소수점으로

  • 위치를 mm 단위 int(또는 격자 ushort)로 보내면 NaN/Inf 가 표현 불가능해지고 대역폭도 준다. 트레이드오프: 정밀도/범위 설계 + 변환 비용.

2) 결정론이 필요하면 float 자체를 시뮬레이션/와이어에서 배제

  • C# JIT·플랫폼별 부동소수점 차이로 lockstep 결과가 갈린다. 고정소수점 결정론 수학으로.

3) 신뢰 경계 sanitize 일원화

  • "외부에서 온 모든 float 는 한 곳의 검증기 통과" 규칙을 코덱에 못박아 중계 서버가 오염값을 전파하지 못하게 한다.

4) 스키마 버전 + 배치 안전 리더

  • 필드 추가 대비 버전 태그, 배치는 개수×요소크기 ≤ 잔여 선검증.

면접 포인트

  • 면접관이 듣고 싶은 핵심: NaN 비교 의미론(모두 false)이 경계검사를 무력화하는 이유 + BinaryPrimitives 로 엔디안 명시 + 신뢰경계 sanitize.
  • 예상 질문:
    1. "범위검사로 막으면 되지 않나?" → NaN 은 모든 비교 false 라 통과. IsFinite 먼저.
    2. "BitConverter 가 왜 문제?" → 호스트 엔디안 의존. 이기종 서버에서 깨질 수 있음. BinaryPrimitives...LittleEndian 으로 고정.
    3. "결정론까지 필요하면?" → 양자화 정수/고정소수점, 동일 런타임·연산 경로.

변별 메모: protocol7(엔디안/정렬 가정)은 정수/구조체 레이아웃이 축, 본 문제는 부동소수점 특수값 검증 + 결정론이 축. C++ 트윈은 memcpy 직렬화·isfinite, C# 은 BitConverter 호스트 엔디안·float.IsFinite 로 같은 본질을 언어별 API 차이로 보인다.