컴퓨터공학 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, SQL numeric)으로.

직접 해 보기

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 로 다룬다. 아주 큰 카운터의 작은 증가분이 반올림에 묻힐 수 있다는 점을 알고 있으면 이상한 그래프를 덜 의심하게 된다.

확인 문제

  1. binary64 의 지수 필드 비트 수와 바이어스는 얼마인가.
  2. 0.5 와 0.1 중 double 로 정확히 표현되는 것은 무엇이고 왜 그런가.
  3. nan == nan 이 거짓인 것은 버그인가.
  4. 2^53 + 1 을 double 로 저장하면 어떻게 되는가.
  5. 금액 합계를 float 으로 계산하면 안 되는 이유를 한 문장으로 말하라.

풀이

  1. 11비트, 바이어스 1023.
  2. 0.5 = 2^−1 이라 정확히 표현된다. 0.1 은 분모에 5 가 있어 2진 소수가 끝나지 않는다.
  3. 아니다. 표준이 NaN 과의 비교를 순서 없음(unordered)으로 정했다. isnan 을 쓴다.
  4. 그 구간의 간격이 2 라 가장 가까운 표현 가능 값 2^53 으로 반올림된다.
  5. 대부분의 10진 금액이 2진으로 정확히 담기지 않아 반올림 오차가 누적되기 때문이다.

더 읽을거리 (References)