생각을 정리하는 마인드맵, 풀맵 →
기술 딥다이브마지막 수정

[기술 딥다이브] 0.1 + 0.2 ≠ 0.3인 이유: 부동소수점


한 줄 요약: 0.1 + 0.2가 0.30000000000000004가 되는 이유는 두 수가 2진법에서 무한소수라서, 유한 비트(IEEE 754)에 담는 순간 반올림 오차가 생기기 때문이다.

대부분의 프로그래밍 언어에서 0.1 + 0.2를 계산하면 0.3이 아니라 0.30000000000000004가 나와요. 이것은 버그가 아니라 부동소수점의 본질이에요. 왜 이런 일이 발생하는지 비트 구조와 실제 출력으로 풀어 설명하겠어요.

10진법의 0.1은 2진법으로 딱 떨어지지 않는다

컴퓨터는 수를 2진법으로 저장해요. 0.1을 2진 소수로 변환하면 0.0001100110011...처럼 무한 반복하는 소수가 돼요. 2진법은 1/2, 1/4, 1/8의 합으로 수를 표현하는데, 0.1(= 1/10)은 2의 거듭제곱의 합으로 정확히 표현할 수 없어요. 이것은 10진법에서 1/3 = 0.3333...이 되는 것과 같은 원리예요. 3으로 나누어 떨어지지 않는 것은 10진법의 결함이 아닌 것처럼, 10으로 나누어 떨어지지 않는 것은 2진법의 결함이 아니에요. ’어느 진법으로 쓰느냐’에 따라 유한소수가 되는 수가 달라질 뿐이에요.

IEEE 754: 유한 비트에 무한소수를 담다

컴퓨터는 0.1을 64비트(double) 부동소수점에 담아요. IEEE 754 배정도 형식은 부호 1비트 + 지수 11비트 + 가수 52비트로 구성돼요. 문제는 가수 52비트가 유한하다는 점이에요. 무한히 이어지는 꼬리를 유한한 메모 스티커에 베껴 쓰다 끊기는 것이라, 끊긴 자리에서 반올림이 일어나요. 아래 도식은 이 과정을 보여줘요.

0.1이 IEEE 754에 담기는 과정 0.1의 2진 표현은 무한 반복 소수이고, 64비트 IEEE 754 형식의 가수 52비트에 담는 순간 반올림이 일어나 미세한 오차가 생기는 과정. 0.1을 2진법으로 쓰면 0.0001100110011 00110011 00110011 … 0011이 무한히 반복 — 10진법의 1/3 = 0.3333…와 같은 상황 64비트(double)에 담을 때 부호 1 지수 11비트 가수 52비트 반올림 지점: 무한소수의 꼬리가 여기서 끊긴다 실제 저장된 값: 0.10000000000000000555… 0.1 + 0.2 = 0.30000000000000004 (오차 두 개가 더해져 눈에 보임) IEEE 754 배정도: 부호 1비트 · 지수 11비트 · 가수 52비트 오차는 저장 방식의 태생적 한계이지 계산 실수가 아니에요

실제로 확인해 보기: 숨은 오차를 드러내기

평소 화면에는 0.1로 ‘예쁘게’ 표시되므로 오차가 안 보여요. 소수점 20자리까지 강제로 출력하면 숨어 있던 값이 드러나요. 파이썬에서 실행한 실제 출력이에요.

>>> print(format(0.1, '.20f'))
0.10000000000000000555
>>> print(format(0.2, '.20f'))
0.20000000000000001110
>>> print(format(0.1 + 0.2, '.20f'))
0.30000000000000004441
>>> 0.1 + 0.2 == 0.3
False

0.1로 알고 있던 값은 사실 0.1000…000555이고, 0.2도 0.2000…0001110예요. 둘을 더하면 각자의 오차가 합쳐져 0.3000…0004441이 되고, 표시할 때 반올림되어 0.30000000000000004로 눈에 보이는 것이에요. 0.1 + 0.2 === 0.3이 false를 반환하는 것은 언어의 버그가 아니라 IEEE 754를 준수하는 모든 시스템의 정상적인 동작이에요.

왜 이렇게 설계했는가: 속도와 범용성

부동소수점은 매우 넓은 범위의 값(아주 작은 수부터 아주 큰 수까지)을 고정 비트로 빠르게 처리하기 위해 만든 타협이에요. 십진 정확 계산보다 ‘대략 맞게, 빠르게, 넓은 범위를’ 선택한 것이에요. 그래서 과학 계산과 그래픽에는 적합하지만, 금전 계산에는 부적합해요.

그러면 금전 계산은 어떻게: 십진 타입으로

금액처럼 십진 정확도가 중요한 경우에는 다음 방법을 사용해요: (1) 센트 단위 정수로 계산(예: 1000원 = 100000센트), (2) 십진 전용 타입(Python의 decimal.Decimal, Java의 BigDecimal, JavaScript의 Decimal 라이브러리), (3) 표시할 때만 반올림. 파이썬에서 실제로 비교하면:

>>> from decimal import Decimal
>>> Decimal('0.1') + Decimal('0.2')
Decimal('0.3')          ← 딱 떨어짐

주의할 점이 하나 있어요. Decimal(0.1)처럼 float 값을 그대로 넘기면 앞서 본 오차 있는 값(0.100000…0055…)을 그대로 받아 와요. 정확한 십진 계산이 필요하면 Decimal('0.1')처럼 문자열로 넘겨야 해요. float로 절대 금전을 처리하지 않는다는 원칙과 함께 기억할 두 가지예요.

비교할 때는 ’오차 허용’으로

0.1 + 0.2 == 0.3 대신, Math.abs(a - b) < 1e-9처럼 ’충분히 가까우면 같다’로 비교해요. 이것이 부동소수점 비교의 표준 방식이며, 수치 계산 라이브러리들이 내부적으로 쓰는 방식이기도 해요.

어떻게 연결되는가

한 줄로 요약하면: 0.1과 0.2가 2진 무한소수이므로 유한 비트(IEEE 754)에 담으면 반올림 오차가 발생하고, 둘을 더했을 때 그 오차가 0.30000000000000004로 드러난다는 것이에요. 컴퓨터가 ’10진법’이 아니라 ’2진법’으로 수를 표현한다는 사실 하나에서 비롯된, 가장 유명한 ’버그 같은 정상 동작’예요. 같은 원리의 저장 방식 문제는 문자에도 있어요. 한글이 왜 3바이트가 되는지는 UTF-8 인코딩 글에서 이어져요.

출처: 위키백과: IEEE 754 · 위키백과: 부동소수점 산술 · 0.30000000000000004.com

이미지: Sss41, Wikimedia Commons (CC-BY-SA 3.0) — 원본