[CS300 #083] 부동소수점 IEEE 754 — 0.1 + 0.2 가 0.3 이 아닌 이유
컴퓨터공학 300 주제 시리즈의 083번째 글이다. 전체 지도는 여기.
한 줄 요약
부동소수점은 실수를 “부호 × 1.가수 × 2^지수” 꼴의 유한한 2진 과학적 표기로 근사하는 방식이고, IEEE 754 는 그 비트 배치와 반올림 규칙, 무한대·NaN 같은 특수값을 정한 표준이다. 대부분의 10진 소수는 2진수로 딱 떨어지지 않으므로 오차는 버그가 아니라 사양이다.
왜 필요한가
>>> 0.1 + 0.2
0.30000000000000004
이 결과를 처음 보면 언어 버그처럼 보인다. 파이썬, 자바, 자바스크립트, C 가 모두 같은 값을 낸다. 같은 하드웨어 표준을 따르기 때문이다. 이 원리를 모르면 다음 실수를 한다.
- 금액을
float으로 저장해 1원 단위가 어긋난다. ==로 실수를 비교해 반복문이 끝나지 않는다.- 자바스크립트에서 64비트 ID 의 끝자리가 바뀌는 이유를 찾지 못한다.
핵심 개념
세 필드
IEEE 754 의 2진 형식은 비트를 세 부분으로 나눈다.
binary64 (double): [부호 1][지수 11][가수(분수부) 52] = 64비트
binary32 (float): [부호 1][지수 8][가수(분수부) 23] = 32비트
정규화된 수의 값은 다음과 같다.
값 = (−1)^부호 × 1.분수부(2진) × 2^(지수필드 − 바이어스)
바이어스: binary64 = 1023, binary32 = 127
맨 앞의 1. 은 저장하지 않는다(숨은 비트). 그래서 binary64 는 52비트를 저장하면서 53비트 정밀도를 얻는다.
예: −2.5
2.5 = 10.1(2) = 1.01(2) × 2^1
부호 = 1, 지수필드 = 1 + 1023 = 1024 = 100 0000 0000
분수부 = 0100 0000 ...
0.1 은 정확히 담기지 않는다
10진수 1/3 이 0.333… 으로 끝나지 않듯, 2진수에서는 분모가 2의 거듭제곱이 아닌 분수가 끝나지 않는다. 0.1 = 1/10 은 분모에 5 가 있다.
0.1(10) = 0.000110011001100110011...(2) (0011 반복)
53비트에서 잘라 반올림한 값은 정확히 0.1000000000000000055511151231257827… 이다. 0.2 도 마찬가지로 살짝 어긋나고, 두 오차를 더해 다시 반올림한 결과가 0.3 에 가장 가까운 double 과 한 칸 차이가 난다.
특수값
| 지수 필드 | 분수부 | 의미 |
|---|---|---|
| 전부 0 | 0 | ±0 (부호가 다른 0 이 둘 있다) |
| 전부 0 | ≠0 | 비정규수(subnormal). 숨은 비트가 0, 0 근처를 촘촘히 메움 |
| 1 ~ 최대−1 | 아무거나 | 정규수 |
| 전부 1 | 0 | ±무한대 |
| 전부 1 | ≠0 | NaN (Not a Number) |
NaN 은 자기 자신과도 같지 않다(nan == nan 이 거짓). 그래서 NaN 검사는 isnan 으로 한다.
간격은 일정하지 않다
부동소수점 수 사이 간격은 크기에 비례한다. 1.0 바로 다음 double 과의 차이가 머신 엡실론 2^−52 ≈ 2.22×10^−16 이다. 2^53 = 9,007,199,254,740,992 를 넘으면 간격이 2 가 되어 홀수 정수를 표현할 수 없다. 자바스크립트의 Number.MAX_SAFE_INTEGER 가 2^53 − 1 인 이유다.
| 형식 | 정밀도(비트) | 대략 10진 자릿수 | 머신 엡실론 |
|---|---|---|---|
| binary32 | 24 | 약 7자리 | 2^−23 ≈ 1.19e−7 |
| binary64 | 53 | 약 15~16자리 | 2^−52 ≈ 2.22e−16 |
반올림과 연산 규칙
IEEE 754 는 기본 반올림 모드로 “가장 가까운 값, 동률이면 짝수 쪽(round half to even)” 을 쓴다. 그리고 덧셈·곱셈·나눗셈·제곱근의 결과는 “정확한 값을 계산한 뒤 한 번 반올림한 것” 과 같아야 한다고 정한다. 그래서 한 번의 연산 오차는 최대 반 칸이다. 문제는 연산이 쌓일 때다. 부동소수점 덧셈은 결합법칙이 성립하지 않는다. (a + b) + c 와 a + (b + c) 가 다를 수 있다.
비교와 합산의 요령
- 같음 비교는 상대 오차로:
math.isclose(a, b). - 많은 수를 더할 땐 오차 보정 합산:
math.fsum. - 크기가 매우 다른 수를 더할 때 작은 수가 사라지는 현상(흡수)에 주의.
- 비슷한 두 수를 빼면 유효 자릿수가 크게 줄어든다(상쇄 오차).
- 돈은 정수(최소 단위)나 10진 소수 타입(
decimal, SQLnumeric)으로.
직접 해 보기
double 의 비트를 직접 꺼내 본다. python3 3.12 로 실행해 확인했다.
import struct, math, sys
def bits64(x):
(u,) = struct.unpack(">Q", struct.pack(">d", x))
s = f"{u:064b}"
return s[0], s[1:12], s[12:]
for x in (1.0, -2.5, 0.1):
s, e, m = bits64(x)
print(x, s, e, m[:16] + "...", "exp =", int(e, 2) - 1023)
print(0.1 + 0.2 == 0.3, 0.1 + 0.2)
print(math.isclose(0.1 + 0.2, 0.3))
print(sys.float_info.epsilon, sys.float_info.max, sys.float_info.min)
print(float(2**53) == float(2**53 + 1))
print(1e308 * 10, -1e308 * 10, float("inf") - float("inf"))
nan = float("nan")
print(nan == nan, math.isnan(nan))
print(5e-324, 5e-324 / 2)
t = 0.0
for _ in range(10):
t += 0.1
print(t, math.fsum([0.1] * 10))
from decimal import Decimal
print(Decimal(0.1))
print(Decimal("0.1") + Decimal("0.2"))
출력:
1.0 0 01111111111 0000000000000000... exp = 0
-2.5 1 10000000000 0100000000000000... exp = 1
0.1 0 01111111011 1001100110011001... exp = -4
False 0.30000000000000004
True
2.220446049250313e-16 1.7976931348623157e+308 2.2250738585072014e-308
True
inf -inf nan
False True
5e-324 0.0
0.9999999999999999 1.0
0.1000000000000000055511151231257827021181583404541015625
0.3
5e-324 는 가장 작은 양의 비정규수이고, 그 절반은 0 으로 떨어진다. Decimal(0.1) 은 double 0.1 이 실제로 담고 있는 정확한 값을 보여 준다. 참고로 파이썬 3.12 부터 내장 sum() 은 float 에 대해 보정 합산을 하므로 sum([0.1]*10) 은 1.0 을 낸다. 위 예제에서 일부러 반복문을 쓴 이유다.
현업에서는
- 금액: 결제·정산 코드는
float/double을 쓰지 않는다. 원 단위 정수(bigint)나numeric/BigDecimal을 쓴다. PostgreSQL 문서도 정확성이 필요한 금액에는numeric을 권한다. - JSON 과 자바스크립트: JSON 숫자를 자바스크립트가 받으면 double 이 된다. 2^53 을 넘는 64비트 ID 는 끝자리가 바뀌므로 문자열로 주고받거나
BigInt를 쓴다. - 머신러닝: 학습에 binary32 대신 16비트 형식(binary16, bfloat16)을 섞어 쓰는 혼합 정밀도가 흔하다. 메모리와 대역폭이 반으로 줄지만 표현 범위·정밀도가 좁아져 손실 스케일링 같은 보정이 따라붙는다.
- 모니터링 수치: 프로메테우스 같은 시계열 DB 는 샘플 값을 double 로 다룬다. 아주 큰 카운터의 작은 증가분이 반올림에 묻힐 수 있다는 점을 알고 있으면 이상한 그래프를 덜 의심하게 된다.
확인 문제
- binary64 의 지수 필드 비트 수와 바이어스는 얼마인가.
- 0.5 와 0.1 중 double 로 정확히 표현되는 것은 무엇이고 왜 그런가.
nan == nan이 거짓인 것은 버그인가.- 2^53 + 1 을 double 로 저장하면 어떻게 되는가.
- 금액 합계를 float 으로 계산하면 안 되는 이유를 한 문장으로 말하라.
풀이
- 11비트, 바이어스 1023.
- 0.5 = 2^−1 이라 정확히 표현된다. 0.1 은 분모에 5 가 있어 2진 소수가 끝나지 않는다.
- 아니다. 표준이 NaN 과의 비교를 순서 없음(unordered)으로 정했다.
isnan을 쓴다. - 그 구간의 간격이 2 라 가장 가까운 표현 가능 값 2^53 으로 반올림된다.
- 대부분의 10진 금액이 2진으로 정확히 담기지 않아 반올림 오차가 누적되기 때문이다.
더 읽을거리 (References)
- IEEE, IEEE Standard for Floating-Point Arithmetic, IEEE Std 754-2019.
- David Goldberg, What Every Computer Scientist Should Know About Floating-Point Arithmetic, ACM Computing Surveys, 1991 (Oracle 재게재본)
- Python 공식 문서, Floating-Point Arithmetic: Issues and Limitations
- MDN, Number.MAX_SAFE_INTEGER