0.1 + 0.2 가 0.3 이 아닌 이유: 부동소수점 기초
0.1 + 0.2 를 파이썬·자바스크립트·PHP 등 9개 실행 환경에서 돌려 double 이 실제로 담은 값, 출력에서 오차가 숨는 이유, 허용 오차 비교법, 부가세 역산에서 1원이 틀어지는 예까지 확인합니다.
시리즈: 숫자·금액 계산 강좌 1/6 (초급). 로그나 테스트, 면접에서 0.30000000000000004 를 보고 "부동소수점은 원래 이상해" 하고 넘어갔던 신입 개발자를 위한 글입니다. 다 읽으면 double 이 무엇을 저장하는지 설명하고, 그 정확한 값을 출력해 보고, 두 실수를 == 없이 비교할 수 있습니다.
한국 서비스는 원화에 소수점이 없으니 실수 문제와는 상관없다고 생각하기 쉽습니다. 그런데 부가세 10% 를 계산하려고 total / 1.1 이나 price * 1.1 을 쓰는 순간 부동소수점이 끼어듭니다. 부가세 포함 110원에서 공급가액을 역산해 원 단위 절사를 했더니 99원이 나오고, 테스트의 total == 0.3 이 깨지고, 4.35 달러를 센트로 바꿨더니 434 가 됩니다. 이 강좌는 그 원인을 한 대의 Mac 에서 9개 실행 환경으로 직접 확인하고, 제대로 비교하는 방법을 익힙니다. 돈 계산 자체(반올림 방식, 10원·100원 단위 절사)는 3회와 4회에서 다루고, 이번에는 기초를 다집니다.
아래는 모두 Apple Silicon Mac(M1 Pro), macOS 26 에서 실행한 결과입니다. Python 3.9.6(비교용 3.12.14), macOS 기본 자바스크립트 엔진인 JavaScriptCore 의 jsc, PHP 8.5.6, OpenJDK 26, .NET 10(SDK 10.0.403), Swift 6.4.2, Dart 3.11.5, Apple clang 21, sqlite 3.51.0 을 썼습니다. 경로는 ~/demo/ 로 바꿨습니다.
5분 만에 첫 "어긋남" 보기
다음 내용을 first.py 로 저장하고 python3 first.py 로 실행합니다.
a = 0.1 + 0.2
print(a)
print(a == 0.3)
print(0.1 * 3, 4.35 * 100, int(4.35 * 100))
$ python3 first.py
0.30000000000000004
False
0.30000000000000004 434.99999999999994 434
네 줄에 놀랄 일이 세 가지입니다. 합이 0.3 이 아니고, 그래서 비교가 False 이고, 4.35 에 100 을 곱해 int() 하면 434 가 되어 1센트가 사라집니다. 자바스크립트도 같습니다. jsc 는 모든 Mac 의 /System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/Helpers/jsc 에 있습니다(alias 를 만들어 두면 편합니다). print 는 있지만 console 은 없습니다(typeof console 이 undefined 였습니다).
$ jsc -e 'print(0.1 + 0.2); print(0.1 + 0.2 === 0.3)'
0.30000000000000004
false
파이썬이나 자바스크립트의 버그가 아닙니다. 거의 모든 컴퓨터가 소수를 이렇게 저장하기 때문이고, 저장된 값을 보면 동작을 예측할 수 있습니다.
double 이 실제로 저장하는 값
먼저 용어입니다.
- 부동소수점: 수를 "가수 × 기수^지수" 로 나타내는 방식. 과학적 표기법(3.0 × 10^8)과 같은 발상이라 0.000001 도 10^300 도 한 형식으로 다룹니다.
- IEEE 754 배정밀도(double, binary64): C/Java/C# 의
double, 파이썬의float, 자바스크립트의Number, Swift/Dart 의Double/double. 64비트를 부호 1, 지수 11, 가수 52비트로 나누고 숨은 맨 앞 1 을 더해 53비트 가수를 가집니다. 10진수로 약 15~17자리입니다. - 2진 소수: 소수점 아래 자리가 1/2, 1/4, 1/8… 을 뜻합니다. 0.5, 0.25, 0.75 는 정확하지만 0.1 은 2진수로 0.000110011001100… 이 끝없이 반복됩니다. 10진수에서 1/3 이 0.333… 이 되는 것과 같습니다.
- ulp(unit in the last place): 어떤 double 과 그다음 double 사이의 간격. 수가 클수록 넓어집니다.
0.1 이라고 쓰면 파서는 0.1 에 가장 가까운 double 을 고릅니다. 파이썬으로 그 정확한 값을 세 가지 방법으로 볼 수 있습니다.
from decimal import Decimal
from fractions import Fraction
print(Decimal(0.1)) # 저장된 값을 10진수로 정확하게
print((0.1).hex()) # 비트를 16진수로
print(Fraction(0.1)) # 저장된 값을 분수로
0.1000000000000000055511151231257827021181583404541015625
0x1.999999999999ap-4
3602879701896397/36028797018963968
분모 36028797018963968 은 2^55 입니다. 모든 double 은 "정수 ÷ 2의 거듭제곱" 입니다. 이제 0.1 + 0.2 전체가 보입니다.
| 값 | float.hex() |
저장된 정확한 값(Decimal) |
|---|---|---|
0.1 |
0x1.999999999999ap-4 |
0.1000000000000000055511151231257827021181583404541015625 |
0.2 |
0x1.999999999999ap-3 |
0.200000000000000011102230246251565404236316680908203125 |
0.3 |
0x1.3333333333333p-2 |
0.299999999999999988897769753748434595763683319091796875 |
0.1 + 0.2 |
0x1.3333333333334p-2 |
0.3000000000000000444089209850062616169452667236328125 |
저장된 0.1 과 0.2 는 참값보다 조금 크고, 0.3 은 조금 작습니다. 합은 가장 가까운 double 로 반올림되는데, 그게 0.3 의 double 바로 위 이웃입니다. 16진 끝자리 ...333 과 ...334 를 보세요. 정확히 1 ulp 차이이고, (0.1 + 0.2) - 0.3 은 5.551115123125783e-17, 즉 math.ulp(0.3) 과 같았습니다.
struct 로 64비트를 쪼개 봐도 같습니다.
0.3 0 01111111101 0011001100110011001100110011001100110011001100110011
0.30000000000000004 0 01111111101 0011001100110011001100110011001100110011001100110100
부호, 지수, 가수 대부분이 같고 끝부분만 다릅니다.
같은 덧셈을 9개 실행 환경에서
아래 언어는 모두 같은 IEEE 754 double 을 쓰므로 저장되는 결과는 같습니다. 다른 것은 출력 방식입니다.
| 실행 환경 | 코드 | 출력 | == 0.3 |
|---|---|---|---|
| Python 3.9 / 3.12 | print(0.1 + 0.2) |
0.30000000000000004 |
False |
| jsc | print(0.1 + 0.2) |
0.30000000000000004 |
false |
| PHP 8.5 | var_dump(0.1 + 0.2) |
float(0.30000000000000004) |
false |
| PHP 8.5 | echo 0.1 + 0.2 |
0.3 |
false |
| Java 26 | System.out.println(0.1 + 0.2) |
0.30000000000000004 |
false |
| C# (.NET 10) | Console.WriteLine(0.1 + 0.2) |
0.30000000000000004 |
False |
| Swift 6.4 | print(0.1 + 0.2) |
0.30000000000000004 |
false |
| Dart 3.11 | print(0.1 + 0.2) |
0.30000000000000004 |
false |
| C (clang 21) | printf("%g", x) / "%.17g" |
0.3 / 0.30000000000000004 |
0 |
| sqlite 3.51 | SELECT 0.1+0.2 |
0.3 |
0 |
세 줄은 0.3 으로 출력되지만 비교는 모두 실패합니다. 화면과 비교 결과가 어긋나는 것, 이게 가장 위험한 상태입니다.
어느 언어에나 정확하게 계산하는 방법이 있습니다. 아래는 모두 0.3 을 출력했습니다.
Python Decimal("0.1") + Decimal("0.2")
Java new BigDecimal("0.1").add(new BigDecimal("0.2"))
C# 0.1m + 0.2m
Swift Decimal(string: "0.1")! + Decimal(string: "0.2")!
PHP bcadd("0.1", "0.2", 1)
문자열로 넘긴다는 점에 주의하세요. 자바 new BigDecimal(0.1) 은 0.1000000000000000055511151231257827021181583404541015625 를 출력합니다. BigDecimal 에 닿기 전에 이미 double 로 반올림됐기 때문입니다. 이 함정은 4회에서 다룹니다.
출력에서 오차가 숨는 이유
double 을 문자열로 바꾸는 방법은 하나가 아닙니다.
- 최단 왕복 표기(파이썬
repr, 자바스크립트, Java, C#, Swift, Dart, PHPvar_dump): 다시 읽었을 때 같은 double 이 되는 가장 짧은 자릿수로 출력합니다.0.1이0.1로 나오는 이유입니다. 합은 17자리0.30000000000000004가 필요합니다.0.3이라고 쓰면 다른 double 로 읽히기 때문입니다. 긴 꼬리는 "이건 0.3 의 double 이 아니다" 라고 정직하게 알려 주는 것입니다. - 고정 정밀도(C
%g는 유효 6자리, PHPecho는precision=14, sqlite 셸은 15자리): N자리로 반올림하고 꼬리를 버립니다. 오차가 화면에서 사라집니다.
sqlite 에는 함정이 하나 더 있습니다. 내장 printf 는 ! 플래그가 없으면 유효 16자리에서 멈춥니다.
sqlite> SELECT printf('%.20f', 0.1+0.2), printf('%!.20f', 0.1+0.2), (0.1+0.2) - 0.3;
0.30000000000000000000|0.3000000000000000445|5.55111512312578e-17
C 의 float(32비트, 가수 24비트)에서는 0.1f + 0.2f 가 %.9g 로 0.300000012 였고 f == 0.3f 는 참이었습니다. 단정밀도에서 우연히 반올림이 맞아떨어졌을 뿐 규칙이 아닙니다. double 의 0.1 + 0.7 은 0.7999999999999999 입니다.
디버깅 원칙: 부동소수점이 의심되면 전체 자릿수로 찍어 보거나(파이썬 repr(), C %.17g, PHP var_dump, sqlite printf('%!.20f')) 16진 표기(float.hex(), C %a, 자바 Double.toHexString)를 보세요.
비교는 허용 오차로: 절대 오차와 상대 오차
계산해서 나온 실수를 == 로 비교하는 것은 위험하니 허용 오차("충분히 가깝다")로 비교합니다. 두 종류가 있고, 초보자는 대개 틀린 쪽을 고릅니다.
- 절대 허용 오차:
abs(a - b) <= 1e-9. 1 근처의 수에서는 되지만 큰 수에서 실패합니다. 10^9 근처 double 의 간격은math.ulp(1e9)=1.1920928955078125e-07로 이미 1e-9 보다 큽니다. 반대로 작은 수에는 너무 관대해서,abs(1e-12 - 1.1e-12) <= 1e-9는 10% 나 다른데도True입니다. - 상대 허용 오차: "더 큰 값의 1e-9 배 이내". 파이썬
math.isclose(a, b)가 기본rel_tol=1e-9로 이렇게 합니다. 약점은 0 이고, 0 에 상대적으로 가까운 수는 없습니다.
import math
a, b = (0.1 + 0.2) * 1e8, 0.3 * 1e8
print(repr(a), repr(b))
print(abs(a - b) <= 1e-9, math.isclose(a, b))
print(math.isclose(1e-10, 0.0), math.isclose(1e-10, 0.0, abs_tol=1e-9))
30000000.000000004 30000000.0
False True
False True
자바스크립트에는 isclose 가 없습니다. 흔히 보는 Math.abs(a - b) < Number.EPSILON 은 Number.EPSILON(2.220446049250313e-16)이 1.0 의 ulp 라서 1 근처에서만 맞습니다. 위와 같은 a, b 로 jsc 는 false 를 돌려줬습니다. 다섯 줄짜리 isclose 로 해결됩니다.
function isClose(x, y, rel = 1e-9, abs = 0) {
return Math.abs(x - y) <= Math.max(rel * Math.max(Math.abs(x), Math.abs(y)), abs);
}
print(isClose((0.1 + 0.2) * 1e8, 0.3 * 1e8), isClose(1e-12, 0), isClose(1e-12, 0, 1e-9, 1e-9));
// true false true
clang 은 실수 == 에 경고를 낼 수 있습니다. -Wfloat-equal 은 -Wall -Wextra 에 들어 있지 않으니 따로 켭니다.
fp.c:7:22: warning: comparing floating point with == or != is unsafe [-Wfloat-equal]
7 | printf("%d\n", x == 0.3);
| ~ ^ ~~~
고르는 법:
| 상황 | 쓸 것 | 이유 |
|---|---|---|
| 금액, 소수 자릿수가 정해진 수량 | 정수(원·센트)나 decimal 형 | 오차를 처음부터 만들지 않는다 |
| 일반적인 계산 결과 | math.isclose(a, b)(상대) |
수의 크기에 맞춰 늘고 준다 |
| 기대값이 0 일 수 있음 | isclose(a, b, abs_tol=…) |
상대 오차만으로는 0 과 같다고 안 나온다 |
| 2^53 미만 정수를 double 에 담은 값 | == 가능 |
정확히 표현된다(2회) |
| 단위 테스트 기대값 | pytest.approx, assertAlmostEqual, JUnit assertEquals(expected, actual, delta) |
같은 발상이 내장돼 있다 |
마지막 줄의 테스트 도우미는 문서를 바탕으로 한 설명이고 여기서 실행하지는 않았습니다. 테스트 작성은 첫 단위 테스트를 보세요.
부가세 역산에서 1원이 모자라는 이유
한국 실무에서 자주 보는 계산입니다. 부가세 포함 금액에서 공급가액을 total / 1.1 로 구하고 원 단위 절사(floor)를 하면, 부동소수점 오차가 그대로 1원이 됩니다. 1원부터 100,000원까지 모든 합계 금액에 대해 math.floor(total / 1.1) 과 정수만으로 계산한 total * 10 // 11 을 비교했습니다.
110 / 1.1 -> 99.99999999999999 -> 절사하면 99원 (정답 100원)
1100 / 1.1 -> 999.9999999999999 -> 절사하면 999원
33 / 1.1 -> 29.999999999999996 -> 절사하면 29원
11000 / 1.1 -> 10000.0 -> 이 금액은 우연히 맞음
- 100,000개 중 4,174개(약 4.2%)에서 1원이 모자랐습니다(33, 55, 66, 99, 110, 121원…). 1~10,000원 범위만 보면 511개입니다.
- 같은 범위에서
round(total / 1.1)은 0개였습니다. 반올림은 오차를 흡수하지만, 절사·절상은 경계에 걸린 오차를 그대로 드러냅니다. - 반대 방향인
floor(price * 0.1)(부가세 절사)은 1~100,000원에서 어긋남이 0개였습니다. 오차가 우연히 위쪽이었을 뿐이니 믿으면 안 됩니다.
결론은 "절사·절상 전에 정수로 계산한다" 입니다. total * 10 // 11 이나 price // 10 은 오차가 없습니다. 공공요금이나 급여 계산에서 흔한 10원·100원 단위 절사는 3회에서 일반적인 형태로 설명합니다. 실제 적용 기준은 회사 규정이나 세무 전문가에게 확인하세요.
첫 주에 하기 쉬운 실수
모두 실행했고 출력을 그대로 옮겼습니다.
| 실수 | 실제로 일어나는 일 | 고치는 법 |
|---|---|---|
if total == 0.3: |
0.1 + 0.2 에서 False |
math.isclose, 또는 정수로 |
int(4.35 * 100) 로 센트 변환 |
434(곱은 434.99999999999994) |
round(4.35 * 100) 는 435. 제대로는 "4.35" 를 decimal 로 읽기 |
0.01 을 100번 더하기 |
파이썬·jsc·PHP 모두 1.0000000000000007 |
정수로 더하고 출력할 때 한 번만 나누기 |
0.1 을 1,000번 더하기 |
99.9999999999986 |
math.fsum 은 정확히 반올림된 합을 준다(6회) |
SQL 에서 WHERE price = 0.1 + 0.2 |
셸은 0.3 이라고 보여 주는데 0행 |
금액은 INTEGER 로 |
| float 를 dict 키로 | {0.1+0.2: 'a', 0.3: 'b'} 에 키가 2개 남음 |
반올림 후 키로 쓰거나 정수·문자열로 |
| float 를 PHP 배열 키로 | Deprecated: Implicit conversion from float 0.30000000000000004 to int loses precision, 키는 0 |
float 를 키로 쓰지 않기 |
Math.abs(a-b) < Number.EPSILON |
3×10^7 근처 값에서 false |
상대 허용 오차 |
echo 나 %g 출력을 믿기 |
PHP 도 C 도 0.3 출력 |
디버깅할 때는 전체 자릿수로 |
PHP 의 Deprecated 는 PHP 8.1 에서 추가됐고 8.5.6 에서 확인했습니다. 언어별 타입 기초는 파이썬 타입, 자바스크립트 타입과 동등 비교, C 타입과 printf에 있습니다.
측정: 소수 덧셈은 얼마나 자주 어긋나나
0.1 + 0.2 는 드문 예외일까요? 세어 봤습니다. 0.1~0.9 의 소수 한 자리 쌍과 0.00~0.99 의 센트 금액 쌍을 모두 만들어 i/100 + j/100 과 답을 직접 쓴 (i+j)/100 을 비교합니다.
bad = sum(1 for i in range(100) for j in range(100)
if i/100 + j/100 != (i+j)/100)
print(bad) # 2106
- 소수 한 자리: 순서 있는 81쌍 중 16쌍(19.8%)이 어긋났습니다. 조합으로는 0.1+0.2, 0.1+0.7, 0.2+0.4, 0.2+0.7, 0.3+0.6, 0.4+0.8, 0.6+0.7, 0.8+0.9(
1.7000000000000002) 입니다. - 센트: 10,000쌍 중 2,106쌍(21.1%).
jsc에서도 똑같이 2,106 이었습니다. 같은 IEEE 754 이니 당연합니다. math.isclose로 비교하면 실패는 0개.
"깔끔한" 소수 합의 약 5개 중 1개가 1 ulp 어긋납니다. 그래서 규칙은 "0.1 + 0.2 를 조심하라" 가 아니라 "계산한 실수를 == 로 비교하지 않는다" 입니다.
안전한 비교의 비용도 쟀습니다. 난수(seed 42) double 1,000,000쌍 비교, Apple Silicon Mac(M1 Pro), 7회 중앙값입니다.
| 방법 | Python 3.9.6 | Python 3.12.14 |
|---|---|---|
x == y |
39.8 ms | 29.8 ms |
abs(x - y) <= 1e-9 |
80.7 ms | 44.1 ms |
math.isclose(x, y) |
55.9 ms | 39.0 ms |
3.9 에서 isclose 는 비교 1회당 약 16 ns 늘 뿐이고, 두 버전 모두 손으로 쓴 절대 오차 검사보다 빨랐습니다. 또 1e-12 만큼 어긋나게 만든 쌍 중 332쌍은 isclose 로 불일치였습니다. 값이 약 0.001 미만이어서 1e-12 가 상대 1e-9 를 넘었기 때문입니다. 허용 오차는 데이터에 맞춰 고릅니다.
솔직한 한계와 버전 메모
- 9개 실행 환경 모두 이 Mac 에서 기본 실수형으로 IEEE 754 binary64 를 씁니다. IEEE 가 아닌 하드웨어나 언어는 시험하지 않았습니다.
- 최단 왕복 표기는 파이썬은 3.1 부터
repr에, .NET 은 Core 3.0 부터 기본ToString()에 들어갔습니다. 실행한 것은 위에 적은 버전뿐입니다. - PHP 의
precision=14와serialize_precision=-1은ini_get으로 읽은 기본값입니다. 서버의php.ini는 다를 수 있습니다. - sqlite
printf의!플래그와 16자리 기본값은 3.51.0 의 동작입니다. - 부가세 예시는 계산 원리 설명이며 세무 조언이 아닙니다.
- 시간은 한 대에서 잰 값이고 파이썬 루프 오버헤드가 대부분입니다. 나노초보다 비율을 보세요.
연습 문제
1. 실행하기 전에 파이썬에서 어느 것이 True 인지 예상하고, 그다음 실행해 보세요.
print(0.5 + 0.25 == 0.75)
print(0.1 + 0.7 == 0.8)
print(sum([0.1] * 10) == 1.0)
2. 동료가 자바스크립트로 공급가액을 Math.floor(total / 1.1) 로 구합니다. total = 110 이면 몇 원이 나오나요? 안전한 한 줄은?
3. 상대 1e-9 이내면 같고, 1e-12 는 0 과 같다고 보는 비교를 써 보세요.
정답
1. 첫 번째만 True 로, True, False, False 입니다. 0.5 와 0.25 는 2진수로 정확(1/2, 1/4)해서 합도 정확합니다. 0.1 + 0.7 은 0.7999999999999999, sum([0.1] * 10) 은 0.9999999999999999 였습니다.
2. 99원입니다. jsc 에서 110 / 1.1 은 99.99999999999999 였습니다. 정수로 먼저 계산해 Math.floor(total * 10 / 11) 로 쓰면 1100 / 11 이 정확히 100 이 됩니다. jsc 에서 1~100,000원을 정확한 정수 결과와 비교하니 Math.floor(total / 1.1) 은 파이썬과 같은 4,174개가 틀렸고, Math.floor(total * 10 / 11) 은 0개였습니다.
3. math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-12). 이번 실행에서 (1e-12, 0) 과 (0.1 + 0.2, 0.3) 모두 True 였습니다. abs_tol 없는 math.isclose(1e-10, 0.0) 은 False 입니다. abs_tol 은 데이터 잡음의 크기에 맞춰 정합니다.
다음 회
2회는 정수입니다. C, Java, C#, Swift, Dart, PHP, 파이썬에서 INT_MAX + 1 이 어떻게 되는지, API 에서 온 19자리 ID 가 JSON.parse 를 거치면 왜 다른 숫자가 되는지 봅니다.
- 다음: 숫자·금액 계산 강좌 2/6: 정수 오버플로·BigInt·안전한 한계
- 그다음: 반올림 방식과 부가세, 정수와 decimal 로 하는 금액 계산, 합계 오차와 NaN
- 전체 속 위치: 프로그래밍 학습 로드맵
자주 묻는 질문
0.1 + 0.2 가 0.30000000000000004 로 나오는 이유는?
double 은 2진 소수로 값을 담기 때문에 0.1, 0.2, 0.3 은 어느 것도 정확히 표현되지 않습니다. 리터럴마다 가장 가까운 double 로 반올림되고, 반올림된 0.1 과 0.2 의 합은 반올림된 0.3 보다 한 칸(1 ulp) 위의 double 이 됩니다. 파이썬 Decimal(0.1+0.2) 로 실제 값 0.3000000000000000444089209850062616169452667236328125 를 볼 수 있습니다.
부가세 포함 금액에서 공급가액을 total / 1.1 로 구하면 왜 1원이 틀리나요?
110 / 1.1 이 99.99999999999999 가 되어서 절사하면 99원이 됩니다. 1원부터 100,000원까지 세어 보니 4,174개 금액에서 1원이 모자랐습니다. total * 10 // 11 처럼 정수로 계산한 뒤 절사해야 합니다.
두 실수는 어떻게 비교해야 하나요?
== 를 쓰지 말고 상대 허용 오차를 씁니다. 파이썬 math.isclose(a, b) 는 기본 rel_tol=1e-9 입니다. 한쪽이 0 일 수 있으면 abs_tol 도 줘야 합니다. 상대 오차만으로는 어떤 수도 0 에 가깝다고 판정되지 않기 때문입니다. 돈은 아예 실수를 쓰지 말고 정수나 decimal 형을 씁니다.