14. 부동소수점 좌표 직렬화와 NaN/Inf·결정론 방어 (C#)
난이도 중해설 — 부동소수점 좌표 직렬화와 NaN/Inf·결정론 방어 (C#)
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
코드는 수신 float 를 아무 검증 없이 시뮬레이션에 적용하고, 직렬화 시 BitConverter
의 호스트 엔디안을 그대로 와이어에 노출한다. 핵심 결함은 ① NaN/Inf 미검증: NaN
좌표는 모든 비교가 false 라 경계검사·정렬·충돌·AoI 를 조용히 무력화하고 연산으로 전파되어
엔티티가 사라지거나 물리가 폭주한다(악성 클라가 좌표에 NaN/Inf 를 심어 중계 공격 가능).
② 유한성/범위 미검증: 맵 밖·Inf 좌표가 그대로 반영. ③ 엔디안 가정: BitConverter
는 BitConverter.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. - 예상 질문:
- "범위검사로 막으면 되지 않나?" → NaN 은 모든 비교 false 라 통과.
IsFinite먼저. - "
BitConverter가 왜 문제?" → 호스트 엔디안 의존. 이기종 서버에서 깨질 수 있음.BinaryPrimitives...LittleEndian으로 고정. - "결정론까지 필요하면?" → 양자화 정수/고정소수점, 동일 런타임·연산 경로.
- "범위검사로 막으면 되지 않나?" → NaN 은 모든 비교 false 라 통과.
변별 메모: protocol7(엔디안/정렬 가정)은 정수/구조체 레이아웃이 축, 본 문제는 부동소수점 특수값 검증 + 결정론이 축. C++ 트윈은
memcpy직렬화·isfinite, C# 은BitConverter호스트 엔디안·float.IsFinite로 같은 본질을 언어별 API 차이로 보인다.
해설 — 부동소수점 좌표 직렬화와 NaN/Inf·결정론 방어 (C++)
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
코드는 float 들을 메모리 그대로 복사·해석(memcpy(&pos, ...))하고 아무 검증 없이
시뮬레이션에 적용한다. 세 축의 결함이 있다. ① NaN/Inf 미검증: 수신 좌표에 NaN 이
섞이면 모든 비교가 false 라 경계검사·정렬·충돌·AoI 가 조용히 오작동하고 NaN 이 전파되어
엔티티가 "사라지거나" 물리가 폭주한다(악성 클라가 좌표에 NaN/Inf 를 심어 중계시키는 공격
가능). ② 유한성/범위 미검증: 맵 밖·Inf 좌표가 그대로 반영. ③ 플랫폼 의존 표현:
엔디안/struct 패딩/float 표현을 송수신 서버가 같다고 가정 — 이기종 서버 간 결정론이
깨진다(특히 lockstep/리플레이). 정답 한 줄: 고정 엔디안으로 비트 단위 직렬화하고, 수신
즉시 isfinite + 도메인 범위로 sanitize(거부 또는 클램프)한 뒤에만 시뮬레이션에 넣는다.
문제점
(B)+(C) 수신 float 무검증 — NaN/Inf 오염 (검증/보안) ★간판
- 증상: 수신 비트가 NaN 이면
pos.x == anything이 전부 false. 맵 경계검사if (x < min || x > max)가 통과해버려(둘 다 false) 무효 좌표가 살아남는다. NaN 은 연산으로 전파되어(x + v*dt = NaN) 엔티티가 충돌/AoI 격자에서 빠지고, 거리 비교가 깨져 타깃팅/판정이 망가진다. +Inf 좌표는 AABB/공간 분할 자료구조를 폭주시킨다. - 재현 조건: 악성/버그 클라가 좌표에 NaN(예:
0x7FC00000)·Inf 를 넣고, 서버 A 가 검증 없이 B 로 중계. 또는 손상된 패킷. - 근본 원인: 역직렬화가 비트→float 만 하고 의미 검증(유한·범위) 을 안 한다. 신뢰 경계를 넘는 부동소수점은 항상 sanitize 해야 한다.
(A) 메모리 그대로 직렬화 — 엔디안/패딩/표현 가정 (호환성/결정론)
- 증상:
memcpy(&pos, sizeof(pos))는 바이트 순서(엔디안)와struct레이아웃을 송수신이 동일하다고 가정. 빅/리틀 혼재나 다른 패딩이면 좌표가 깨진다. 컴파일러/옵션이 다르면 float 연산 결과까지 달라져 lockstep/리플레이 결정론이 무너진다. - 근본 원인: 플랫폼 종속 표현을 와이어 포맷으로 노출. 고정 엔디안 + 명시적 필드
직렬화(IEEE-754 비트를
uint32로) 필요.
길이 검증만 있고 개수/구조 검증 부족 — 견고성
- 증상:
Read가 최소 길이만 보고 잔여 바이트/배열 개수 검증이 빈약. 배치 스냅샷에서 개수 필드를 신뢰하면 over-read 위험(이 코드의 확장 시). - 근본 원인: 경계 기반 안전 리더로 모든 read 를 길이검사와 함께.
-0.0 / subnormal 등 특수값 정책 부재 — 정확성(부수)
- 증상: -0.0, 비정규수가 비교·해시·정렬에서 미묘한 불일치를 유발. 결정론 요구 시스템에서 문제.
- 근본 원인: 정규화(
+0.0통일, subnormal flush) 정책 미정의.
수정안
핵심: ① IEEE-754 비트를 고정(리틀) 엔디안 uint32 로 직렬화, ② 수신 즉시 isfinite +
도메인 범위 sanitize, ③ 경계 기반 안전 리더.
#include <cmath>
#include <cstring>
#include <optional>
static void PutF32(std::vector<uint8_t>& out, float f) {
uint32_t bits;
std::memcpy(&bits, &f, sizeof(bits)); // float → 비트 (UB 회피: memcpy)
for (int i = 0; i < 4; ++i) // 고정 리틀 엔디안
out.push_back(static_cast<uint8_t>((bits >> (i * 8)) & 0xFF));
}
static bool GetF32(const uint8_t* p, size_t len, size_t& off, float& f) {
if (off + 4 > len) return false;
uint32_t bits = 0;
for (int i = 0; i < 4; ++i)
bits |= static_cast<uint32_t>(p[off + i]) << (i * 8);
off += 4;
std::memcpy(&f, &bits, sizeof(f));
return true;
}
// 도메인 sanitize: 유한 + 범위. 위반은 거부(또는 호출자 정책에 따라 클램프).
static bool SanitizeCoord(float v, float lo, float hi) {
if (!std::isfinite(v)) return false; // NaN/Inf 거부
if (v < lo || v > hi) return false; // NaN 이면 여긴 안 옴(이미 거부)
return true;
}
bool SnapshotCodec::Read(const uint8_t* buf, size_t len, EntitySnapshot& out) {
size_t off = 0;
if (off + 8 > len) return false;
std::memcpy(&out.entityId, buf + off, 8); off += 8; // (정수도 고정엔디안 권장)
float* dst[] = { &out.pos.x,&out.pos.y,&out.pos.z, &out.vel.x,&out.vel.y,&out.vel.z };
for (float* d : dst)
if (!GetF32(buf, len, off, *d)) return false;
// 수신 즉시 sanitize — 통과 못 하면 스냅샷 거부
const float MAP_LO = -100000.f, MAP_HI = 100000.f, V_MAX = 5000.f;
if (!SanitizeCoord(out.pos.x, MAP_LO, MAP_HI) ||
!SanitizeCoord(out.pos.y, MAP_LO, MAP_HI) ||
!SanitizeCoord(out.pos.z, MAP_LO, MAP_HI) ||
!SanitizeCoord(out.vel.x, -V_MAX, V_MAX) ||
!SanitizeCoord(out.vel.y, -V_MAX, V_MAX) ||
!SanitizeCoord(out.vel.z, -V_MAX, V_MAX))
return false;
return true;
}
핵심: 검증을 통과한 유한·범위내 값만
Apply로 흘려보낸다. NaN 비교의 함정을 피하려면isfinite를 먼저 통과시키는 순서가 중요(NaN 은 범위검사 자체가 무의미).
더 나은 설계
1) 좌표를 고정소수점/양자화 정수로
- 위치를 mm 단위
int32(또는 양자화uint16격자)로 보내면 NaN/Inf 자체가 표현 불가능해지고 대역폭도 준다. 결정론·압축 둘 다 이득. 트레이드오프: 정밀도/범위 사전 설계 필요, float 물리와의 변환 비용.
2) 결정론이 필요하면 부동소수점 자체를 와이어/시뮬레이션에서 배제
- lockstep 류는 컴파일러/FPU 차이로 float 결과가 갈린다. 고정소수점 결정론 수학 또는
엄격한 컴파일 플래그(
-ffp-contract=off등) + 동일 바이너리 강제.
3) 신뢰 경계마다 sanitize 일원화
- "외부에서 온 모든 부동소수점은 한 곳의 검증기를 통과"라는 규칙을 코덱 계층에 못박아 중계 서버가 오염값을 전파하지 못하게 한다(서버 A→B 중계 공격 차단).
4) 스키마/버전 + 배치 안전 리더
- 필드 추가 대비 버전 태그, 배치 스냅샷은 개수×요소크기 ≤ 잔여 길이 선검증.
면접 포인트
- 면접관이 듣고 싶은 핵심: NaN 의 비교 의미론(모든 비교 false)이 왜 경계검사를 무력화하는지, 그리고 신뢰 경계의 float sanitize + 고정 엔디안/양자화.
- 예상 질문:
- "
if (x < min || x > max) reject;면 충분한가?" → NaN 이면 둘 다 false 라 통과.isfinite를 먼저. 순서가 핵심. - "
memcpy직렬화가 왜 문제?" → 엔디안·패딩·표현 플랫폼 종속. 이기종 서버 결정론 붕괴. 비트를 고정 엔디안으로 명시 직렬화. - "결정론까지 필요하면?" → float 대신 고정소수점/양자화, 동일 바이너리·FP 플래그.
- "
변별 메모: protocol7(엔디안/정렬/레이아웃 가정)은 정수/구조체 바이너리 레이아웃이 축이고, 본 문제는 부동소수점 특수값(NaN/Inf)의 의미 검증 + 결정론이 축이다. protocol13(VarInt 악성 길이)은 정수 가변인코딩의 종료/오버플로가 축으로, 본 문제의 "값 자체의 유효성(유한·범위) sanitize" 와 결이 다르다.