27. 좌표·각도 양자화(quantization) 직렬화의 경계 처리 결함 (C#)
난이도 하내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 좌표·각도 양자화(quantization) 직렬화의 경계 처리 결함 (C#)
난이도: 하
요약
좌표/각도를 16비트로 양자화하면서 입력을 유효 범위로 클램프/정규화하지 않는다. 그래서 월드 경계를 조금만 벗어난 좌표가 정수 변환에서 망가져 정반대 위치로 순간이동하고, 정규화되지 않은 각도는 변환 결과가 미정의/불특정 값이 된다.
문제점
- [경계 미클램프] (A) —
(ushort)(x / WorldSize * 65535f)는x가[0, WorldSize]안에 있다고 가정한다. 그런데 넉백/추락/부동소수 오차로x가 음수이거나WorldSize를 조금 넘으면, 스케일 결과가[0, 65535]밖이 된다.- C#에서 범위를 벗어난
float→ushort변환은 **unchecked 컨텍스트에서 예외 없이 "불특정 결과"**다(checked면 OverflowException). .NET에서 깔끔하게 modulo wrap 되지도 않아, 보통 0이나 잘린 쓰레기 값이 나온다. - 증상:
x = WorldSize + 3처럼 3 유닛만 넘쳐도 복원 좌표가 맵 반대편/원점 근처로 튄다(텔레포트 글리치). 검증 로직이 이 좌표를 신뢰하면 벽 통과·속도핵 오탐/미탐으로 번진다.
- C#에서 범위를 벗어난
- [각도 미정규화] (B) —
(ushort)(radians / 2π * 65535f)는radians ∈ [0, 2π)를 가정한다. 누적 회전으로radians가 음수거나 2π를 넘으면 스케일 결과가 16비트 범위를 벗어나 또 불특정 변환이 된다.- 정수 wrap이 우연히 "각도 modulo 2π"와 비슷해 보일 수 있으나, 이는 구현이 wrap 한다는 보장이 없는 동작에 기댄 것이라 플랫폼/런타임이 바뀌면 깨진다.
- 공통 근본원인: 직렬화 경계에서 도메인 불변식(좌표 범위·각도 정규화)을 강제하지 않음. 양자화는 "정해진 범위를 N비트에 매핑"하는 연산인데 범위를 벗어난 입력의 처리를 정의하지 않았다.
수정안
양자화 전에 입력을 도메인으로 클램프/정규화하고, 정수 도메인에서 한 번 더 클램프한다.
public static QuantPos QuantizePos(float x, float y, float z)
{
return new QuantPos {
X = QuantizeAxis(x), Y = QuantizeAxis(y), Z = QuantizeAxis(z)
};
}
private static ushort QuantizeAxis(float v)
{
float c = Math.Clamp(v, 0f, WorldSize); // 도메인 클램프
int q = (int)MathF.Round(c / WorldSize * 65535f); // 반올림으로 양자화 오차↓
return (ushort)Math.Clamp(q, 0, 65535); // 정수 도메인 클램프
}
public static ushort QuantizeAngle(float radians)
{
float a = radians % TwoPi; // 정규화
if (a < 0) a += TwoPi; // [0, 2π)
int q = (int)MathF.Round(a / TwoPi * 65535f);
return (ushort)Math.Clamp(q, 0, 65535);
}
- 클램프할지 거부(reject)할지는 정책이다. 경계 살짝 초과(부동소수 오차)는 클램프, 명백히 비정상(NaN/Inf/말도 안 되는 값)은 패킷 거부/로깅이 안전하다.
float.IsFinite(v)선검사를 권장.
더 나은 설계
- 양자화기를 범위 명시 타입으로:
RangeQuantizer(min, max, bits)가 클램프·반올림·역양자화를 캡슐화. 좌표/체력/속도 등 모든 양자화가 같은 안전 경로를 타게 한다.- 트레이드오프: 추상화 비용은 작지만, 축마다 다른 범위(예: 높이 z는 다른 스케일)를 쓰려면 파라미터 관리가 필요.
- 각도는 부호 없는 회전 단위로 다루면(예:
ushort자체가 0..65535 = 0..2π) 경계가 자연히 wrap 되어 정규화가 단순해진다. 단 "정수 wrap에 의존"이 아니라 "도메인을 0..2π로 정의"라는 의도를 코드로 드러내야 한다.
면접 포인트
- "양자화는 손실 압축"이다. 핵심 파라미터는 범위(min/max)·비트수·반올림 방식이고, 범위를 벗어난 입력의 처리(clamp/reject)를 반드시 정의해야 한다.
- float→정수 변환의 함정: 범위 밖 변환은 C#에서 unchecked면 불특정 결과(예외 없음), C++에선 미정의 동작. "우연히 동작"에 기대면 안 된다.
해설 · C++
해설 — 좌표·각도 양자화(quantization) 직렬화의 경계 처리 결함 (C++)
난이도: 하
요약
16비트 양자화에서 입력을 유효 범위로 클램프/정규화하지 않아, 월드 경계를 조금 벗어난 좌표가 static_cast<uint16_t> 에서 망가져 정반대 위치로 순간이동한다. 게다가 범위를 벗어난 float→uint16_t 변환은 **C++ 표준상 미정의 동작(UB)**이라, "이 컴파일러에선 잘 wrap 되더라"는 동작에 절대 의존해선 안 된다.
문제점
- [경계 미클램프 + 미정의 변환] (A) —
static_cast<uint16_t>(x / kWorldSize * 65535.0f)는x ∈ [0, kWorldSize]를 가정한다. 넉백/추락/부동소수 오차로 범위를 벗어나면 스케일 결과가[0, 65535]밖이 되고, 그 값을uint16_t로 변환하는 것은 [conv.fpint] 상 미정의 동작(목적 타입으로 표현 불가한 절단값).- 실측(이 환경 g++ -O0/-O2 동일):
x = kWorldSize + 3→ 스케일 65583 →q=47→ 복원 2.94(원점 모서리로 텔레포트).x = -0.5→ 스케일 -7 →q=65529→ 복원 4095.62(맵 반대편 끝). - 주의: 여기서는 스케일 값(65583, -7 등)이 int 범위 안이라 변환이 int 경유 후 하위 16비트로 잘려(wrap) 이 값들이 나온다. 스케일이 int 범위를 넘는 입력/환경(또는 일부 아키텍처)에서는 변환이 포화돼 65535나 0으로 튀기도 한다 — 어느 쪽이든 표준상 UB라 이식성이 없다는 결론은 같다.
- 증상: 단 몇 유닛 초과로 캐릭터가 맵 반대편으로 점프 → AoI/충돌/안티치트 오작동.
- 실측(이 환경 g++ -O0/-O2 동일):
- [각도 미정규화] (B) —
static_cast<uint16_t>(radians / kTwoPi * 65535.0f)는radians ∈ [0, 2π)가정. 누적 회전으로 음수/2π 초과면 또 범위 밖 변환(UB). 실측에선angle=7.0 → q=7475 → 0.717 (≈ 7-2π)처럼 우연히 modulo 2π에 가깝게 나오지만, 이는 UB가 우연히 wrap 한 결과다. 다른 컴파일러/아키텍처(예: ARM의 포화 변환, 일부 환경의 0 반환)에서는 다른 값이 나와 깨진다. - 공통 근본원인: 직렬화 경계에서 도메인 불변식(좌표 범위·각도 정규화)을 강제하지 않음 + 범위 밖 부동소수→정수 변환의 UB.
수정안
양자화 전에 입력을 클램프/정규화하고, 정수 도메인에서도 클램프한다(반올림 권장).
#include <algorithm>
#include <cmath>
inline uint16_t quantizeAxis(float v) {
if (!std::isfinite(v)) v = 0.0f; // NaN/Inf 방어
float c = std::clamp(v, 0.0f, kWorldSize); // 도메인 클램프
long q = std::lround(c / kWorldSize * 65535.0f); // 반올림
return static_cast<uint16_t>(std::clamp<long>(q, 0, 65535));
}
inline uint16_t quantizeAngle(float radians) {
if (!std::isfinite(radians)) radians = 0.0f;
float a = std::fmod(radians, kTwoPi);
if (a < 0.0f) a += kTwoPi; // [0, 2π) 정규화
long q = std::lround(a / kTwoPi * 65535.0f);
return static_cast<uint16_t>(std::clamp<long>(q, 0, 65535));
}
- 핵심은 "정수로 변환되는 부동소수 값이 항상
[0,65535]안"이 되도록 변환 전에 보장하는 것이다. 그러면 캐스트가 더 이상 UB가 아니다. - 클램프 vs 거부는 정책. 부동소수 오차 수준의 초과는 클램프, 명백한 비정상값(NaN/Inf/거대값)은 패킷 거부·로깅·연결 제재가 안전하다.
더 나은 설계
- 범위 명시 양자화기 타입:
struct Quantizer { float min, max; uint8_t bits; }가 클램프·반올림·역양자화를 한곳에 캡슐화. 좌표/체력/속도 모든 필드가 같은 안전 경로를 탄다.- 트레이드오프: 인라인 산식보다 약간의 추상화 비용. 다만 게임 패킷 직렬화에서 UB·텔레포트 버그를 없애는 가치가 훨씬 크다.
- 각도는 0..2π를 16비트 전 범위에 매핑하면 wrap이 자연스럽다. 단 "정수 오버플로 wrap에 의존"이 아니라 "도메인을 명시적으로 정규화"라는 의도를 코드로 드러낸다.
면접 포인트
- C++에서 범위를 벗어난 부동소수→정수 변환은 미정의 동작이다(부호 없는 타입도 예외 아님). "내 PC에선 잘 wrap 됨"은 답이 아니다 — 변환 전 클램프/정규화로 범위를 보장해야 한다.
- 양자화 3요소: 범위(min/max)·비트수·반올림. 그리고 범위 밖 입력 처리(clamp/reject)를 반드시 정의한다. 게임에선 작은 경계 초과가 텔레포트·안티치트 오작동으로 직결된다.