LabHub

블로그

부동소수점과 IEEE 754 완벽 해부 — 왜 0.1 + 0.2 ≠ 0.3 인가, NaN, 서브노멀, 통화 연산, bfloat16까지 (2025)

들어가며 — "컴퓨터가 계산을 못 한다고요?"

> 0.1 + 0.2
0.30000000000000004

> 0.1 + 0.2 === 0.3
false

개발자 커뮤니티의 영원한 밈이다. 비전공자 친구가 옆에서 이걸 보면 "컴퓨터가 계산을 못 해?"라고 어리둥절해한다. 정답은 컴퓨터가 계산을 못 하는 게 아니라, 우리가 십진수로 생각하는데 컴퓨터는 이진수로 저장한다는 것이다. 하지만 이 설명은 표면일 뿐. 더 깊은 질문들이 기다린다:

이 글은 IEEE 754 부동소수점 표준을 처음부터 해부한다. 비트 단위 구조, 반올림 모드, 특수값, 정밀도 함정, 금융·과학 계산에서의 대안(decimal, arbitrary precision), 그리고 AI 시대의 저정밀도 포맷(bfloat16, FP8, FP4)까지.

"부동소수점은 신비롭다"는 건 숫자를 모르는 게 아니라, 그 규칙이 너무 정교해서 일상적 직관과 충돌하기 때문이다. 이 글을 끝까지 읽으면 0.1 + 0.2 문제의 진짜 이유를 비트 레벨로 설명할 수 있게 될 것이다.


1. 왜 부동소수점인가 — 고정소수점과의 대결

1.1 정수만으로는 부족한 이유

64비트 signed int는 약 ±9.2 × 10^18까지. 크기는 충분해 보이지만 소수점이 없다. 1/3 같은 분수, 0.5 같은 소수, 1e-20 같은 미세 수를 표현 못 한다.

1.2 고정소수점 (Fixed-point)

"항상 소수점이 N번째 자리"라고 약속하는 방식. 예: 32비트 중 상위 16비트는 정수부, 하위 16비트는 소수부.

금융 시스템(돈 단위), 일부 임베디드/DSP에서 여전히 사용.

1.3 부동소수점 (Floating-point)

과학적 기수법에서 영감:

6.022 × 10^23  (아보가드로 수)
1.6 × 10^-19  (전자 전하)

가수(significand, mantissa) × 밑수(base)^지수(exponent) 형태. 밑수를 2로 고정하면:

가수 × 2^지수

같은 저장 공간(32/64비트)로 엄청난 범위와 상대 정밀도를 동시에 얻는다. 이게 IEEE 754의 핵심 아이디어다.


2. IEEE 754 — 1985년의 혁명

2.1 표준 이전의 혼돈

1970년대 각 CPU 제조사가 독자적 부동소수점 포맷을 썼다. IBM 360의 16진수 기반 부동소수점은 Burroughs, DEC, Cray와 다 달랐다. 같은 FORTRAN 코드가 기계마다 다른 결과를 내는 악몽.

2.2 William Kahan의 영웅담

UC Berkeley의 수학자 William Kahan(2003년 튜링상 수상)이 1970년대 후반 Intel 8087 co-processor 설계 자문을 맡았다. 그는 "모든 CPU가 따라야 할 숫자 표준"을 주장했고, 1985년 IEEE 754-1985로 결실을 맺었다. 이후 2008년과 2019년에 갱신.

2.3 핵심 아이디어

모든 숫자는 다음 비트 배치로 표현:

┌─────┬──────────┬──────────────────┐
│ sign│ exponent │   mantissa/significand   │
│ (S) │   (E)    │        (M)        │
└─────┴──────────┴──────────────────┘

값 계산:

(-1)^S × (1.M) × 2^(E - bias)

1.M암묵적 선행 1. 정규화된 수는 항상 1.xxx... 형태라서 1을 저장하지 않고 가수 공간을 1비트 절약한다(숨은 비트).

2.4 float64 (double)의 숫자 0.1 예시

십진수 0.1은 이진수로 무한 반복:

0.1 = 0.0001100110011001100110011... (2진)

float64로 저장하면 52비트에서 잘린다:

sign=0, exponent=01111111011, mantissa=1001100110011001100110011001100110011001100110011010

이걸 다시 십진수로 풀면:

0.1000000000000000055511151231257827021181583404541015625

즉, JavaScript/Python/Java에서 0.1이라고 쓰는 순간 이미 이 숫자가 저장된다. 우리가 본 "0.1"은 출력 시 반올림된 표시다.

2.5 0.1 + 0.2의 진짜 결과

두 숫자 각각 약간씩 "진짜 0.1"보다 커서, 더하면 진짜 0.3보다 약간 큰 값이 된다. JS가 이를 "가장 짧게 십진수로 표시"하면 0.30000000000000004. 표현 가능한 double로 "정확한 0.3"에 가장 가까운 값은 0.299999...8 또는 0.300000...4이고, 더하기 결과는 후자에 더 가까웠다.


3. 비트를 직접 들여다보기

3.1 JavaScript로 float64 비트 보기

function floatToBits(x) {
  const buf = new ArrayBuffer(8)
  new Float64Array(buf)[0] = x
  const view = new BigUint64Array(buf)
  return view[0].toString(2).padStart(64, '0')
}

floatToBits(0.1)
// "0011111110111001100110011001100110011001100110011001100110011010"
// sign=0, exponent=01111111011, mantissa=1001...1010

3.2 float32로 0.1은?

0 01111011 10011001100110011001101

float32는 가수가 23비트뿐이라 더 많이 절단된다. 결과: 0.100000001490116119384....

3.3 Kahan의 교훈

"부동소수점은 십진수의 근사치다. 정확한 값을 기대하지 마라. 대신 상대 오차 bound를 보장하는 설계를 하라."


4. 특수값 — 0, Infinity, NaN

4.1 두 개의 0

부호 비트가 있으므로 +0-0이 따로 존재.

일부 수학 함수에서 부호 0을 다르게 취급: atan2(+0, -0) = π, atan2(-0, -0) = -π.

4.2 Infinity

4.3 NaN — Not a Number

왜 그런가? IEEE 754의 설계 철학: NaN은 "이 계산은 무효"라는 신호. 두 무효한 계산의 결과가 같다고 말할 이유가 없다. 수식에 NaN이 전파되도록 해서 최종에 검사할 수 있게 했다.

4.4 NaN 비트 — Quiet vs Signaling

float64에서 NaN이 될 수 있는 비트 패턴은 수조 개. 임의 2비트로 뭘 뜻하는지 저장하는 "NaN boxing" 기법으로 JS 엔진이 타입과 값을 함께 인코딩하기도 한다(V8, LuaJIT).


5. Subnormal (denormal) — 느려지는 유령

5.1 정의

정규화된 수는 1.xxx × 2^e 형태(선행 1 암묵). 하지만 이 포맷으로는 0과 가장 작은 정규수(float64의 약 2.2 × 10^-308) 사이에 큰 공백이 생긴다.

Subnormal은 그 공백을 채우는 특수 표현:

덕분에 5 × 10^-324까지 점진적 근사가 가능(graceful underflow). 1985년 Kahan의 핵심 논쟁 포인트 중 하나였다.

5.2 왜 느린가

대부분의 CPU는 정규수만 빠른 hardware 경로로 처리하고, subnormal은 마이크로코드 또는 trap으로 처리한다.

해결책: Flush-to-Zero (FTZ) / Denormals-Are-Zero (DAZ)

_mm_setcsr(_mm_getcsr() | 0x8040);  // FTZ + DAZ 활성

작은 수를 바로 0으로 취급. 오디오 DSP, 게임, 물리 시뮬레이션에선 흔한 기본값. 단, 수학적 정확성은 약간 잃는다.

5.3 실전 사례

오디오 DSP에서 필터를 오래 돌리면 값이 자연스럽게 0에 수렴하는데, subnormal 영역에서 연산이 폭주해 audio glitch가 생긴다. 현대 DAW와 오디오 플러그인은 FTZ 필수.


6. 반올림 모드 — 5가지 선택

IEEE 754가 정한 반올림 모드:

모드설명
Round to Nearest, Ties to Even기본값. 0.5는 짝수 쪽으로. "Banker's rounding"
Round to Nearest, Ties Away0.5는 0에서 먼 쪽으로. 학교 수학
Round toward +∞Ceiling
Round toward -∞Floor
Round toward 0Truncation

Ties to Even이 기본인 이유: 여러 번 반올림해도 편향이 누적되지 않는다. "0.5 → 1"로만 올리면 평균이 커지는 경향이 생긴다.

6.1 언어별 기본

>>> round(0.5)  # Python 3
0
>>> round(1.5)
2
>>> round(2.5)
2

처음 보면 이상해 보이지만, 통계적으로는 더 안전하다.


7. 결합 법칙이 깨진다

7.1 더하기의 비결합성

(0.1 + 0.2) + 0.3 // 0.6000000000000001
0.1 + (0.2 + 0.3) // 0.6

수학적으로는 (a+b)+c = a+(b+c)이지만, 부동소수점에서는 중간 반올림 때문에 결과가 달라진다.

7.2 컴파일러 최적화의 위험

-ffast-math (GCC/Clang) 또는 -Ofast는 결합 법칙을 가정해 재배치한다. 성능 5~30% 향상. 하지만:

7.3 병렬 합산의 함정

# 단순 합산 O(N) 시간
s = 0
for x in data: s += x

# Kahan 보정 합산 — 오차 누적을 cancel
s = 0
c = 0
for x in data:
    y = x - c
    t = s + y
    c = (t - s) - y
    s = t

Kahan summation은 오차를 별도 변수에 저장해 다음 라운드에 보정. 백만 개 float32 합산 시 정확도가 훨씬 좋다. NumPy np.sum이 내부적으로 pairwise summation을 써서 비슷한 효과를 낸다.


8. 정수 ↔ float 변환의 함정

8.1 float64가 담을 수 있는 정수

2^53 = 9,007,199,254,740,992까지 모든 정수를 정확히 표현. 그 이상은 짝수만, 그 이상은 4의 배수만 ... JavaScript에서:

9007199254740992 + 1 // 9007199254740992 (같음!)
9007199254740993 // 9007199254740992 (저장 시점에 이미 절단)

그래서 JS는 64비트 정수를 안전하게 다루지 못한다. BigInt가 2020년에 표준화된 이유.

8.2 타임스탬프와 ID

8.3 Ariane 5 로켓 폭발 (1996)

유럽 Ariane 5 로켓이 발사 37초 만에 폭발. 원인: Ariane 4에서 재사용한 관성항법 소프트웨어가 horizontal velocity를 float64 → int16 변환하는데 Ariane 5의 실제 속도(더 빠름)가 int16 범위(±32768)를 초과. 오버플로우로 잘못된 값이 guidance computer에 전달되어 기체가 분해. $370M 손실.

교훈: 타입 변환 시 범위 검증 필수. 언어/타입 체크 수준을 넘어서는 수학적 도메인 체크가 필요.

8.4 Intel Pentium FDIV Bug (1994)

초기 Pentium의 FPU 분할 테이블에서 5개 항목이 누락되어 특정 입력에 대해 소수 5~10자리 이후 오류. 실세계 영향은 미미했으나 대중적 파장이 커져 Intel이 모든 CPU 교환 제공. $475M 회수 비용. 이후 Intel은 "Pentium Pro"부터 분할 알고리즘 재설계.


9. 비교와 동등성 — ==는 믿지 마라

9.1 근사 비교

// ❌
if (a === b) ...

// ✅ 절대 오차
if (Math.abs(a - b) < 1e-9) ...

// ✅ 상대 오차
if (Math.abs(a - b) < Math.abs(a) * 1e-9) ...

// ✅ ULP 기반 (가장 엄격)
// 두 수 사이 표현 가능한 float 개수로 비교

9.2 문제: "얼마나 가까우면 '같다'인가"

9.3 ULP (Units in the Last Place)

float 두 값이 메모리상 인접한지로 측정.

int ulp_distance(double a, double b) {
    if (signbit(a) != signbit(b)) return INT_MAX;
    return abs(*(int64_t*)&a - *(int64_t*)&b);
}

ulp_distance < 4 같은 조건은 "정확히 같거나 거의 인접"을 의미. 테스트 코드에서 근사 비교 표준.


10. 금융/통화 — 절대 float을 쓰지 마라

10.1 왜 float이 금융에 부적합한가

10.2 대안 1: Integer cents

금액을 센트(100분의 1) 단위 정수로. 1달러 = 100 cents.

const price = 1999 // = $19.99
const tax = Math.round(price * 0.0825) // = 164 ($1.64)
const total = price + tax // = 2163 ($21.63)

장점: 단순, 빠름, 정확.
한계: 암호화폐처럼 소수점 8자리 정밀도가 필요하면 int64도 부족 (최대 ±9.2e18).

10.3 대안 2: Decimal (BCD, fixed decimal)

from decimal import Decimal, ROUND_HALF_UP
Decimal('0.1') + Decimal('0.2')  # Decimal('0.3') ✅ 정확

주의: Decimal(0.1)은 float 0.1의 부정확한 값을 그대로 변환. 반드시 문자열로부터 만들어야 한다.

10.4 대안 3: Rational (분수)

Fraction(1, 3) 같이 정수/정수로 표현. Python fractions.Fraction. 완전 정확하지만 분모가 계속 커져서 메모리/성능 문제.

10.5 실전: 가상화폐 거래소

Binance, Upbit 등은 내부적으로 가장 작은 단위의 정수로 저장. 예: 사토시(1e-8 BTC). 표시만 8자리 소수로 변환. float 연산은 절대 쓰지 않는다.


11. 과학 계산 — NumPy, GPU의 세계

11.1 왜 과학 계산은 float을 쓰나

11.2 Catastrophic Cancellation

# sqrt(x+1) - sqrt(x) for large x
import math
x = 1e16
a = math.sqrt(x + 1) - math.sqrt(x)  # 0.0 ← 오차!

# 수식 변형: 분자·분모 × 공액
b = 1 / (math.sqrt(x + 1) + math.sqrt(x))  # 5e-9 ← 정확

비슷한 크기의 두 수를 뺄 때 유의미 자릿수가 소멸. 이차방정식의 판별식, 중심차분 미분 등에서 흔하다.

11.3 Numerical Recipes의 교훈

11.4 Log-sum-exp 트릭

ML에서 softmax 계산 시:

# ❌ exp(1000) 오버플로우
p = exp(x_i) / sum(exp(x))

# ✅ 최대값 빼기
m = max(x)
p = exp(x_i - m) / sum(exp(x - m))

모든 exp 인자가 0 이하가 되어 수치 안정.


12. AI/ML — 저정밀도의 혁명

12.1 왜 저정밀도인가

12.2 float16 (FP16, half precision)

12.3 bfloat16 (Brain Float)

Google Brain이 TPU용으로 설계:

현재 거의 모든 LLM 학습의 주력 포맷. NVIDIA A100/H100, Google TPU, AMD MI300 모두 네이티브 지원.

12.4 FP8 — NVIDIA Hopper 이후

12.5 FP4 — 2024 블랙웰 아키텍처

12.6 INT8 / INT4 양자화

float을 정수로 변환 + scale factor 저장. 학습 후 양자화(PTQ) 또는 학습 중 양자화(QAT). GPTQ, AWQ 같은 기법으로 정확도 유지하며 4비트까지 축소.


13. GPU와 SIMD — 병렬 부동소수점

13.1 Tensor Core

NVIDIA Volta(2017)부터 Tensor Core 도입: 4×4 matrix multiply-accumulate를 한 사이클에.

13.2 TF32 — NVIDIA의 트릭

"TensorFloat-32"는 실제로 19비트. FP32와 같은 지수 8비트 + FP16과 같은 가수 10비트. FP32 연산을 TF32로 "조용히" 수행해 속도 향상. A100의 기본 모드.

13.3 SIMD (AVX, NEON)

CPU의 벡터 레지스터:

한 명령어로 여러 데이터 연산 → 2~16배 가속. NumPy, BLAS가 내부적으로 사용.


14. 언어별 부동소수점 세부

14.1 JavaScript

14.2 Python

14.3 C/C++

14.4 Java

14.5 Go

14.6 Rust


15. 실전 함정 15선

15.1 스위프트/자바스크립트에서 JSON 정수 잘림

JSON 숫자를 JS가 float64로 파싱 → 큰 ID 잘림.

JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992 (잘림!)

대응: ID를 문자열로 주고받기 ("id": "9007199254740993") 또는 JSON 커스텀 파서.

15.2 tan(π/2)의 결과

수학적으로 무한대. 실제로는 π/2 자체가 float으로 근사되어 매우 큰 유한 값이 나옴.

Math.tan(Math.PI / 2) // 16331239353195370

절대 Infinity로 오지 않는다. 수학 공식을 코드에 옮길 때 항상 이런 경계 케이스 테스트.

15.3 이차방정식 판별식

# ❌ b=1e10, a=c=1 → b^2-4ac ≈ b^2이 정확
# 하지만 근 (-b + sqrt(D)) / 2a는 catastrophic cancellation
# ✅ Vieta's formula로 큰 근 먼저 계산

15.4 분산/표준편차 계산

"sum of squares - square of mean" 공식은 수치적으로 나쁘다. Welford's online algorithm 사용.

15.5 확률 곱셈

ML에서 likelihood를 곱하면 underflow. log probability 공간에서 덧셈.

15.6 평균을 "다 더한 뒤 나누기"

# ❌ 많이 더하면 오차 + overflow 가능
avg = sum(xs) / len(xs)

# ✅ Welford 스타일 점진 평균
mean = 0
for i, x in enumerate(xs, 1):
    mean += (x - mean) / i

15.7 거리 계산

3D 벡터의 norm: sqrt(x^2 + y^2 + z^2). 하지만 x가 큰 경우 x^2이 overflow.

# ✅ 최대 성분으로 스케일
m = max(abs(x), abs(y), abs(z))
if m == 0: return 0
return m * sqrt((x/m)**2 + (y/m)**2 + (z/m)**2)

15.8 Hash map 키로 float

{0.1 + 0.2: 'a'} vs {0.3: 'b'} → 다른 키로 취급.
해결: 문자열로 변환 또는 정수로 양자화.

15.9 GUI 좌표 비교

픽셀 좌표 비교에 float === 쓰면 스냅 안 됨. Math.round 후 int 비교.

15.10 통계 라이브러리의 NaN propagation

sum([1, 2, NaN]) → NaN. Pandas 등은 skipna=True 기본이지만 언제 무엇을 무시하는지 정확히 알아야.

15.11 CSV 파싱의 "1e3" 해석

"1e3"을 파서가 숫자 1000으로 자동 변환. 원본이 문자열 의도였는데 스키마가 변형됨.

15.12 날짜 차이를 float 일수로

(date2 - date1) / (1000 * 60 * 60 * 24)는 DST로 23시간짜리 날이 있으면 부정확. ChronoUnit.DAYS.between 사용.

15.13 Python의 == with numpy

np.array([1, 2, 3]) == np.array([1, 2, 3])
# array([True, True, True]) ← 배열!
# if 조건에 쓰면 에러

15.14 SQL AVG(float_col) 정확도

대량 집계 시 DB 엔진마다 다름. PostgreSQL은 numeric으로 내부 승격 옵션. MySQL은 그냥 float 누적.

15.15 JavaScript의 Number 표시

10000000000000000000000000.0.toString() → 과학적 표기법 "1e+25". 사용자에게 보이면 당황.


16. 테스트 전략

16.1 Property-based testing

from hypothesis import given, strategies as st

@given(st.floats(min_value=0, max_value=1000))
def test_sqrt_sq(x):
    assert abs(sqrt(x)**2 - x) < 1e-10 * max(abs(x), 1)

무작위 입력으로 수치 속성 검증. hypothesis(Python), QuickCheck(Haskell), fast-check(JS)가 표준.

16.2 골든 테스트

알려진 입력/출력 쌍을 저장하고 회귀 검사. "왜 이 값이 나오는지는 모르겠지만, 이전과 같다면 통과".

16.3 ULP 기반 assert

assert_float_near(a, b, 4);  // 4 ULP 이내

각 언어의 테스트 프레임워크가 지원(pytest-approx, Catch2, JUnit에 내장).

16.4 수학 라이브러리 벤치

제곱근, 삼각함수 등이 플랫폼/컴파일러 버전마다 1~2 ULP 차이가 난다. 크로스플랫폼 테스트는 "정확히 같다"가 아니라 "가까움" 기준.


17. IEEE 754-2019 최신 기능

17.1 augmented arithmetic

기본 연산의 "두 부분" 버전: a + b = sum, error. 정밀 보정 합산을 빠르게.

17.2 minimum / maximum 개정

NaN 처리에 대한 더 명확한 규칙. minimumNumber(NaN, 1) = 1.

17.3 Decimal floating-point

IEEE 754는 binary뿐 아니라 decimal floating-point(십진 부동소수점)도 정의:


18. 체크리스트 — 부동소수점을 쓸 때

설계 단계

코딩

ML/GPU

테스트

관찰


마무리 — 부동소수점은 "근사의 예술"

0.1 + 0.2의 미스터리로 시작한 이 여정이 어디까지 왔는지 돌아보자. 비트 구조, 특수값, subnormal의 성능 함정, catastrophic cancellation, 금융의 Decimal, 과학의 수치 안정성, AI의 bfloat16/FP8까지. 부동소수점은 단일 주제가 아니라 수십 개 하위 분야의 교차로다.

Kahan이 IEEE 754를 설계하며 한 말을 음미해보자: "컴퓨터는 숫자를 정확히 다룰 수 없다. 우리가 할 수 있는 건 오차를 예측 가능하게 bound하는 것뿐이다." 그의 표준이 혁명적이었던 이유는 "어떤 CPU에서도 같은 결과"를 보장해서가 아니라, "어떤 계산이 어떤 오차를 가지는지 수학적으로 증명 가능하게" 만들었기 때문이다.

이 글을 끝까지 읽은 당신은 이제:

다음 글에서는 GPU 아키텍처와 CUDA — SIMT 모델, warp scheduling, memory hierarchy, tensor core 깊이 분석으로 이어간다. 이번 글에서 본 저정밀도 포맷(FP8/BF16)이 실제 하드웨어에서 어떻게 가속되는지, Ampere → Hopper → Blackwell 세대 별 Tensor Core의 진화가 무엇을 바꿨는지 구체적으로 본다.

부동소수점을 다루는 건 엔지니어링인 동시에 철학적 겸손이다. 컴퓨터가 모든 실수를 담을 수 없다는 사실을 받아들이고, 그 근사의 규칙을 이해하고, 중요한 계산에서는 그 근사를 뛰어넘는 도구(Decimal, Rational, arbitrary precision)를 고르는 것. 이 겸손이 있는 한, 0.1 + 0.2는 더 이상 농담이 아니라 설계 원칙이 된다.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다