- 들어가며 — 모두를 당황시키는 한 줄
- 컴퓨터는 2진법으로 산다
- IEEE 754 — 실수를 담는 표준 그릇
- 그래서 0.1 + 0.2 는 왜 0.3이 아닌가
- 실수를 정확히 비교하지 마라 — 엡실론
- 돈은 절대 float로 다루지 마라
- NaN, 무한대, 그리고 음수 0
- 누적 오차 — 작은 오차가 쌓일 때
- 실무 규칙 정리
- 마치며
- 참고 자료
들어가며 — 모두를 당황시키는 한 줄
프로그래밍을 조금 해본 사람이라면 누구나 한 번쯤 이 장면을 만납니다. 콘솔을 열고 아주 단순한 덧셈을 시켰을 뿐인데, 결과가 이상합니다.
>>> 0.1 + 0.2
0.30000000000000004
처음 보면 컴퓨터가 고장 났나 싶습니다. 초등학생도 아는 0.1 더하기 0.2가 0.3인데, 이 비싼 기계는 왜 0.30000000000000004 같은 괴상한 답을 낼까요. 파이썬만 그런 것도 아닙니다. 자바스크립트, 자바, C, Go, 루비 등 거의 모든 주류 언어가 똑같은 답을 냅니다.
결론부터 말하면 이것은 버그가 아닙니다. 언어의 결함도, CPU의 오류도 아닙니다. 이것은 컴퓨터가 실수를 저장하는 방식인 IEEE 754 부동소수점의 필연적인 결과입니다. 그리고 그 뿌리는 놀랍도록 단순한 사실 하나로 요약됩니다. 컴퓨터는 2진법으로 수를 담는데, 0.1은 2진법으로 정확히 적을 수가 없습니다. 이 글은 그 이유를 밑바닥부터 파헤칩니다.
컴퓨터는 2진법으로 산다
우리가 10진법을 쓰는 것처럼, 컴퓨터는 2진법을 씁니다. 10진법에서 소수 0.75가 "10분의 7 더하기 100분의 5"이듯, 2진법에서 소수는 "2분의 1, 4분의 1, 8분의 1..."의 합으로 표현됩니다.
10진법 0.75 = 7/10 + 5/100
2진법 0.11 = 1/2 + 1/4 = 0.5 + 0.25 = 0.75 (정확)
어떤 수들은 2진법으로 딱 떨어집니다. 0.5는 2분의 1이니 0.1(2진), 0.25는 4분의 1이니 0.01(2진), 0.75는 0.11(2진)로 정확합니다. 분모가 2의 거듭제곱인 분수는 2진법에서 유한한 자릿수로 표현됩니다.
문제는 그렇지 않은 수입니다. 0.1은 10분의 1인데, 10은 2의 거듭제곱이 아닙니다. 이 수를 2진법으로 적으려 하면 끝나지 않는 무한 소수가 됩니다.
10진법 0.1 을 2진법으로:
0.0001100110011001100110011001100110011... (0011이 영원히 반복)
이것은 10진법에서 3분의 1을 적으려 할 때 0.333333...이 끝나지 않는 것과 정확히 같은 현상입니다. 3분의 1이 10진법으로 유한하게 안 적히듯, 10분의 1은 2진법으로 유한하게 안 적힙니다. 진법이 다를 뿐 원리는 같습니다.
컴퓨터는 무한한 자릿수를 저장할 수 없습니다. 그래서 어딘가에서 잘라내야 합니다. 0.1을 저장하는 순간, 컴퓨터는 진짜 0.1이 아니라 "0.1에 가장 가까운, 유한 자릿수로 표현 가능한 2진수"를 저장합니다. 이 미세한 오차가 모든 이야기의 시작입니다.
IEEE 754 — 실수를 담는 표준 그릇
그렇다면 그 유한한 자릿수를 어떻게 배치할까요. 여기에 IEEE 754라는 표준이 있습니다. 오늘날 거의 모든 하드웨어가 이 방식으로 실수를 저장합니다. 핵심 아이디어는 과학적 표기법(scientific notation)입니다.
우리가 아주 큰 수나 작은 수를 다룰 때 6.022 × 10^23 처럼 적듯이, 부동소수점도 수를 세 부분으로 쪼갭니다. 부호(sign), 가수(mantissa 또는 significand), 지수(exponent)입니다.
값 = (-1)^부호 x 가수 x 2^지수
부호 : 양수인가 음수인가 (1비트)
지수 : 소수점을 어디에 둘까 (자릿수를 옮김)
가수 : 유효 숫자들 (정밀도를 담당)
가장 널리 쓰이는 64비트 배정밀도(double)는 이 64비트를 다음처럼 나눕니다.
64비트 double:
[ 부호 1비트 ][ 지수 11비트 ][ 가수 52비트 ]
32비트 float:
[ 부호 1비트 ][ 지수 8비트 ][ 가수 23비트 ]
여기서 "부동(floating)"이라는 이름의 뜻이 드러납니다. 소수점의 위치가 고정되지 않고, 지수에 따라 둥둥 떠서 움직입니다. 지수를 키우면 아주 큰 수를, 줄이면 아주 작은 수를 같은 비트 수로 표현할 수 있습니다. 이 유연함 덕분에 부동소수점은 원자 크기부터 은하 크기까지 넓은 범위를 다룹니다.
하지만 핵심 제약이 있습니다. 가수의 비트 수가 유한하다는 것입니다. double은 가수가 52비트뿐이라, 유효 숫자를 대략 10진수 15~17자리까지만 담을 수 있습니다. 그 이상의 정밀도는 버려집니다. 그래서 0.1처럼 무한히 이어지는 2진 소수는 52비트에서 잘리고, 그 잘린 값이 저장됩니다.
그래서 0.1 + 0.2 는 왜 0.3이 아닌가
이제 처음의 미스터리를 풀 수 있습니다. 컴퓨터에 0.1을 저장하면, 실제로 저장되는 것은 진짜 0.1보다 아주 조금 큰 값입니다. 0.2도 마찬가지로 아주 조금 다른 값이 저장됩니다.
저장하려는 값 실제로 저장되는 값 (근사)
0.1 -> 0.1000000000000000055511151231257827021181583404541015625
0.2 -> 0.2000000000000000111022302462515654042363166809082031250
이 두 근사값을 더하면, 오차도 함께 더해집니다. 그 합은 진짜 0.3의 근사값과도 미세하게 어긋납니다.
0.1(근사) + 0.2(근사) = 0.3000000000000000444089...
0.3 자체의 근사값 = 0.2999999999999999888977...
두 값이 다르다! 그래서 0.1 + 0.2 == 0.3 은 거짓이다
즉 세 개의 서로 다른 반올림 오차(0.1의 오차, 0.2의 오차, 그리고 그 합을 다시 저장할 때의 오차)가 겹쳐, 눈에 보이는 0.30000000000000004가 나옵니다. 컴퓨터는 완벽하게 정확하게 계산했습니다. 다만 애초에 저장한 재료가 진짜 0.1과 0.2가 아니었을 뿐입니다.
한 가지 위안이 되는 사실은, 이 오차가 무작위가 아니라 결정적(deterministic)이라는 것입니다. 같은 연산은 언제나 같은 오차를 냅니다. 그래서 재현 가능하고, 예측 가능하며, 관리할 수 있습니다. 문제는 오차의 존재 자체가 아니라, 그것을 모르고 코드를 짤 때 생깁니다.
실수를 정확히 비교하지 마라 — 엡실론
부동소수점을 다룰 때 가장 흔한 실수는 두 실수를 ==로 직접 비교하는 것입니다. 위에서 봤듯 0.1 + 0.2는 0.3과 정확히 같지 않으므로, 이런 코드는 예상과 다르게 동작합니다.
if 0.1 + 0.2 == 0.3:
print("같다")
else:
print("다르다") # 실제로 이쪽이 출력된다
올바른 접근은 "정확히 같은가"가 아니라 "충분히 가까운가"를 묻는 것입니다. 두 값의 차이가 아주 작은 허용 오차(엡실론, epsilon)보다 작으면 같다고 간주합니다.
def close_enough(a, b, epsilon=1e-9):
return abs(a - b) < epsilon
print(close_enough(0.1 + 0.2, 0.3)) # True
다만 여기에도 함정이 있습니다. 고정된 엡실론(예: 1e-9)은 값의 크기에 따라 적절하지 않을 수 있습니다. 아주 큰 수들끼리 비교할 때는 그 정도 차이가 오히려 자연스러운 오차보다 작아 실패하고, 아주 작은 수들끼리는 지나치게 관대할 수 있습니다. 그래서 실무에서는 절대 오차와 상대 오차를 함께 고려하는 방식을 씁니다.
절대 오차 비교: |a - b| < eps
작은 수에는 적절, 큰 수에는 부적절
상대 오차 비교: |a - b| <= eps * max(|a|, |b|)
값의 크기에 비례해 허용치를 조정
실무 라이브러리는 둘을 결합한다
(예: 파이썬 math.isclose 는 상대와 절대를 함께 본다)
파이썬의 math.isclose, 넘파이의 numpy.allclose 같은 표준 함수는 이 결합 방식을 이미 구현해 두었습니다. 직접 엡실론을 고르기 어렵다면 이런 검증된 함수를 쓰는 것이 안전합니다.
돈은 절대 float로 다루지 마라
부동소수점 오차가 가장 위험하게 드러나는 곳이 금융 계산입니다. 돈은 정확해야 합니다. 1원의 오차도 회계에서는 용납되지 않고, 그런 오차가 수백만 건 누적되면 실제 손실이 됩니다. 그런데 float로 돈을 다루면 바로 그 오차가 스며듭니다.
# 나쁜 예: float 로 돈 계산
price = 0.1
total = 0.0
for _ in range(10):
total += price
print(total) # 0.9999999999999999 — 1.0 이 아니다!
0.1을 열 번 더하면 1.0이 나와야 하지만, 앞서 본 이유로 미세하게 어긋납니다. 이런 값으로 청구서를 만들거나 잔액을 비교하면 재앙입니다. 해결책은 두 가지 방향입니다.
1. 정수로 다루기 (최소 단위 사용). 금액을 원이 아니라 최소 화폐 단위(예: 원, 또는 통화에 따라 센트)의 정수로 저장합니다. 1,234.56 달러라면 123456센트로 저장하는 식입니다. 정수 연산은 오차가 전혀 없으므로 완벽히 정확합니다. 화면에 표시할 때만 소수점을 넣습니다.
# 좋은 예: 정수(센트)로 다루기
price_cents = 10 # 0.10 달러를 10센트로
total_cents = 0
for _ in range(10):
total_cents += price_cents
print(total_cents / 100) # 1.0 — 정확!
2. 10진 타입 사용 (decimal). 대부분의 언어는 10진법을 있는 그대로 다루는 십진(decimal) 타입을 제공합니다. 파이썬의 decimal.Decimal, 자바의 BigDecimal이 대표적입니다. 이들은 내부적으로 10진 자릿수를 저장하므로, 0.1을 진짜 0.1로 다룹니다.
from decimal import Decimal
a = Decimal("0.1")
b = Decimal("0.2")
print(a + b) # 0.3 — 정확!
print(a + b == Decimal("0.3")) # True
여기서 중요한 세부사항이 있습니다. Decimal을 만들 때 반드시 문자열로 넘겨야 합니다(Decimal("0.1")). 만약 Decimal(0.1)처럼 float를 넘기면, 이미 오차가 섞인 float 값이 그대로 들어가 십진의 이점이 사라집니다. 재료가 오염되면 아무리 정밀한 그릇도 소용없습니다.
정리하면 이렇습니다. 성능이 중요하고 단위가 명확한 대량 계산에는 정수 방식이, 가독성과 임의 정밀도가 중요한 회계 로직에는 decimal 방식이 어울립니다. 어느 쪽이든 float만은 피해야 합니다.
NaN, 무한대, 그리고 음수 0
IEEE 754는 평범한 수 말고도 몇 가지 특별한 값을 정의합니다. 이들을 모르면 뜻밖의 버그를 만납니다.
무한대(Infinity). 표현 가능한 가장 큰 수를 넘어서면(오버플로), 혹은 0이 아닌 수를 0으로 나누면 무한대가 나옵니다. 양의 무한대와 음의 무한대가 따로 있습니다.
print(1e308 * 10) # inf (오버플로)
print(-1e308 * 10) # -inf
NaN (Not a Number). 정의되지 않은 연산의 결과입니다. 0을 0으로 나누거나, 무한대에서 무한대를 빼거나, 음수의 제곱근을 구하면 NaN이 됩니다. NaN의 가장 악명 높은 성질은 자기 자신과도 같지 않다는 것입니다.
nan = float("nan")
print(nan == nan) # False! — NaN 은 그 무엇과도 같지 않다
print(nan != nan) # True
# 그래서 NaN 검사는 == 이 아니라 전용 함수로 한다
import math
print(math.isnan(nan)) # True
이 성질은 처음엔 이상해 보이지만 표준이 그렇게 정한 것입니다. 덕분에 "값이 NaN인가"를 검사하는 관용구가 x != x이기도 합니다. 자기와 다르면 NaN이라는 뜻입니다. 다만 명시적으로 isnan 함수를 쓰는 편이 읽기 좋습니다.
음수 0 (-0.0). 부동소수점에는 양의 0과 음의 0이 따로 있습니다. 둘은 ==로 비교하면 같다고 나오지만, 미세한 상황에서 다르게 동작합니다. 예를 들어 0으로 나눌 때 부호에 따라 양의 무한대와 음의 무한대로 갈립니다.
print(0.0 == -0.0) # True (비교로는 같다)
print(1.0 / 0.0) # inf 로 가려면 별도 처리 필요 (파이썬은 예외)
# C, 자바 등에서는: 1.0/0.0 -> +inf, 1.0/-0.0 -> -inf
음수 0은 대개 신경 쓸 일이 없지만, 부호가 의미를 갖는 수치 계산(예: 극한, 복소수, 특정 물리 시뮬레이션)에서는 미묘한 차이를 만듭니다.
누적 오차 — 작은 오차가 쌓일 때
지금까지 본 오차는 하나하나는 아주 작습니다(대략 소수점 16번째 자리). 그러나 연산을 수백만 번 반복하면, 이 작은 오차들이 쌓여(accumulate) 눈에 띄는 크기로 자라날 수 있습니다. 특히 크기가 아주 다른 수들을 더할 때 심각합니다.
# 큰 수에 작은 수를 계속 더하면 작은 수가 "삼켜진다"
big = 1e16
small = 1.0
print(big + small) # 1e16 — small 이 사라졌다!
print(big + small == big) # True
여기서 무슨 일이 벌어진 걸까요. double의 가수는 유효 숫자 약 16자리뿐입니다. 1e16은 이미 그 정밀도의 끝에 있어서, 여기에 1.0을 더하면 그 1은 표현 가능한 자릿수 아래로 밀려나 반올림되어 사라집니다. 이것을 흡수 오차(absorption)라고 합니다. 큰 수가 작은 수를 삼켜버리는 것입니다.
이 현상은 많은 수의 합을 구할 때 실제 문제가 됩니다. 순진하게 왼쪽부터 차례로 더하면, 합이 커질수록 뒤에 더해지는 작은 값들이 점점 무시됩니다. 이를 완화하는 유명한 기법이 카한 합산(Kahan summation)입니다. 매 단계에서 버려진 오차를 따로 기억해 다음 단계에 보상하는 방식입니다.
def kahan_sum(numbers):
total = 0.0
compensation = 0.0 # 버려진 저차수 비트를 기억
for x in numbers:
y = x - compensation
t = total + y
compensation = (t - total) - y # 이번에 잃은 오차
total = t
return total
카한 합산은 오차 보상을 통해 순진한 합보다 훨씬 정확한 결과를 냅니다. 데이터 분석, 과학 계산, 그래픽스처럼 방대한 실수 연산을 다루는 분야에서는 이런 수치 안정성 기법이 중요합니다. 핵심 교훈은 "오차는 개별적으로는 작아도 반복 속에서 자란다"는 것, 그리고 "연산의 순서와 방법이 정확도에 영향을 준다"는 것입니다.
실무 규칙 정리
부동소수점을 안전하게 다루기 위한 실전 규칙을 압축하면 다음과 같습니다.
- 실수를
==로 비교하지 말 것. 대신 엡실론 기반 근사 비교(math.isclose등)를 쓴다. - 돈은 float로 다루지 말 것. 정수(최소 단위) 또는 decimal 타입을 쓴다.
- decimal은 문자열로 생성할 것.
Decimal("0.1")이지Decimal(0.1)이 아니다. - NaN은 전용 함수로 검사할 것.
x == nan은 언제나 거짓이므로isnan을 쓴다. - 크기가 매우 다른 수의 합을 조심할 것. 필요하면 카한 합산 같은 안정적 기법을 쓴다.
- 표시용 반올림과 계산용 정밀도를 구분할 것. 화면에는 소수 둘째 자리까지 보여도, 내부 계산은 더 높은 정밀도로 유지한다.
- 정밀도가 극단적으로 중요하면 임의 정밀도 라이브러리를 쓸 것. 다만 속도를 희생한다.
이 규칙들의 공통 정신은 "부동소수점은 근사다"라는 사실을 늘 의식하는 것입니다. 근사임을 알고 다루면 강력한 도구이고, 정확한 값이라 착각하면 조용히 틀린 결과를 내는 함정입니다.
마치며
0.1 + 0.2가 0.3이 아닌 것은 컴퓨터의 실수가 아니라, 유한한 비트로 무한한 실수를 표현하려는 근본적 타협의 결과입니다. 컴퓨터는 2진법으로 수를 담는데, 10분의 1 같은 많은 10진 소수가 2진법에서는 무한히 이어지므로 어딘가에서 반올림될 수밖에 없습니다. IEEE 754는 이 타협을 부호·지수·가수로 정교하게 담아, 넓은 범위와 실용적 정밀도를 맞바꿉니다.
이 사실을 이해하면 부동소수점은 더 이상 변덕스러운 마법이 아니라, 규칙을 아는 이에게 예측 가능한 도구가 됩니다. 정확한 비교가 필요하면 엡실론을, 정확한 돈이 필요하면 정수나 decimal을, 안정적인 대량 합산이 필요하면 수치 안정 기법을 쓰면 됩니다. 핵심은 하나입니다. 부동소수점은 실수를 흉내 내는 근사이지, 실수 그 자체가 아니라는 것. 이 한 문장을 기억하면 대부분의 부동소수점 함정을 피할 수 있습니다.
참고 자료
- What Every Computer Scientist Should Know About Floating-Point Arithmetic (David Goldberg): https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html
- IEEE 754 표준 개요 (Wikipedia): https://en.wikipedia.org/wiki/IEEE_754
- Python 공식 문서 — Floating Point Arithmetic: Issues and Limitations: https://docs.python.org/3/tutorial/floatingpoint.html
- 0.30000000000000004.com — 언어별 부동소수점 데모: https://0.30000000000000004.com/
- Python decimal 모듈 문서: https://docs.python.org/3/library/decimal.html
현재 단락 (1/121)
프로그래밍을 조금 해본 사람이라면 누구나 한 번쯤 이 장면을 만납니다. 콘솔을 열고 아주 단순한 덧셈을 시켰을 뿐인데, 결과가 이상합니다.