파이썬에서 0.1 + 0.2를 출력하면 0.30000000000000004가 나옵니다. 이 파이썬 소수점 오차는 유명하지만, 금액 계산에서 실제로 얼마나 자주 틀리는지는 재 본 적이 없었습니다. 그래서 100원부터 100,000원까지 10원 단위 가격 9,991개에 할인율·세율 9가지를 곱해, 모두 89,919건을 정확한 분수 계산과 비교했습니다. 원 미만을 int()로 버리는 흔한 코드는 3,501건(3.89%)에서 1원씩 적게 나왔습니다. 180원의 35%가 63원이 아니라 62원으로 계산되는 식입니다. 고치려고 Decimal을 썼는데 오히려 29,974건이 틀린 경우도 있었습니다. 환경은 윈도우 11, Python 3.12.10이고 표준 라이브러리만 썼습니다.
왜 0.1+0.2가 0.3이 아닌가
컴퓨터의 float는 2진수로 숫자를 저장합니다. 10진수 0.1은 2진수로 끝없이 이어지는 수라서, 가장 가까운 값으로 잘라서 저장됩니다. Decimal(0.1)로 실제 저장값을 꺼내 보면 바로 보입니다.
0.1 + 0.2 = 0.30000000000000004
0.1 + 0.2 == 0.3 : False
0.1 실제 저장값 : 0.1000000000000000055511151231257827021181583404541015625
0.3 실제 저장값 : 0.299999999999999988897769753748434595763683319091796875
0.1을 10번 더하기 : 0.9999999999999999 False
sum([0.1]*10) : 1.0
round(2.675, 2) : 2.67
round(2.5) : 2
round(114.5) : 114
round(115.5) : 116
1.1 * 3 : 3.3000000000000003
int(19.99 * 100) : 1998
0.1은 조금 크게, 0.3은 조금 작게 저장되어 있어서 둘을 비교하면 False가 됩니다. 몇 가지 더 눈에 띄는 결과가 있습니다.
for루프로 0.1을 10번 더하면0.9999999999999999인데,sum([0.1]*10)은1.0이 나왔습니다. Python 3.12부터sum()이 float를 더할 때 오차를 보정하는 방식을 쓰기 때문입니다. 같은 일을 해도 어떻게 더하느냐에 따라 결과가 다릅니다.round(2.5)는 3이 아니라 2입니다.round()는 정확히 .5일 때 짝수 쪽으로 보내는 “은행가 반올림”을 씁니다. 그래서round(114.5)는 114,round(115.5)는 116입니다.round(2.675, 2)는 2.68이 아니라 2.67입니다. 2.675가 실제로는 2.67499…로 저장되기 때문입니다.int(19.99 * 100)은 1999가 아니라 1998입니다. 달러를 센트로 바꿀 때 자주 쓰는 코드라 위험합니다.
금액 계산에서 얼마나 틀리나 — 89,919건 검사
정수 원 가격에 비율을 곱하는 상황을 만들었습니다. 할인(0.1~0.35), 남은 금액 비율(0.7~0.9), 부가세 포함(1.1)입니다. 정답은 fractions.Fraction으로 계산했습니다. 분수로 계산하므로 오차가 없습니다. 핵심 부분만 줄이면 아래와 같습니다.
from fractions import Fraction
prices = range(100, 100_001, 10) # 100원 ~ 100,000원, 10원 단위
rates = ["0.1", "0.15", "0.2", "0.3", "0.35", "0.7", "0.8", "0.9", "1.1"]
bad = 0
for r in rates:
for p in prices:
got = int(p * float(r)) # 흔히 쓰는 원 미만 버림
want = int(p * Fraction(r)) # 정확한 분수로 계산한 정답
if got != want:
bad += 1
print(bad)
$ python scan.py
검사 89,919건 (가격 9,991개 x 비율 9개), 0.21초
int(가격*비율) 버림이 틀린 건수 : 3,501 (3.89%)
180원 x 0.35 = 62.99999999999999 -> int 62, 정답 63
340원 x 0.35 = 118.99999999999999 -> int 118, 정답 119
660원 x 0.35 = 230.99999999999997 -> int 230, 정답 231
round(가격*비율)이 사사오입과 다른 건수: 5,584 (6.21%)
110원 x 0.15 = 16.5 -> round 16, 사사오입 17
150원 x 0.15 = 22.5 -> round 22, 사사오입 23
검사는 0.21초 만에 끝났습니다. 결과를 비율별로 나눠 보니 의외였습니다. 버림 오류는 0.35에서 1,166건, 0.7에서 2,335건 났고, 나머지 7개 비율에서는 한 건도 나지 않았습니다. 0.1이나 1.1로 몇 번 시험해 보고 “괜찮네” 하고 넘어가기 딱 좋은 구조입니다. 저도 0.1로만 해 봤다면 문제를 못 봤을 겁니다.
round()로 사사오입하는 코드는 5,584건(6.21%)이 달랐습니다. 이쪽은 float 오차보다 은행가 반올림 탓이 큽니다. 110원 x 0.15 = 16.5처럼 정확히 .5로 떨어지는 경우에 17이 아니라 16이 나옵니다.
고치는 방법 5가지를 같은 89,919건에 넣어 보기
흔히 쓰는 해결책을 모두 같은 데이터로 돌려 틀린 건수와 시간을 쟀습니다.
[원 미만 버림] 89,919건
int(p * 0.35) 틀림 3,501 16ms 예: 180원 x 0.35 -> 62 (정답 63)
int(p * 0.35 + 1e-9) 틀림 0 16ms
정수 비율 p*35//100 틀림 0 33ms
Decimal('0.35') 틀림 0 50ms
Decimal(0.35) (float로 만듦) 틀림 29,974 100ms 예: 100원 x 0.15 -> 14 (정답 15)
[원 미만 사사오입] 89,919건
round(p * 0.35) 틀림 5,584 16ms 예: 110원 x 0.15 -> 16 (정답 17)
int(p * 0.35 + 0.5) 틀림 1,169 17ms 예: 170원 x 0.35 -> 59 (정답 60)
정수 (p*35+50)//100 틀림 0 35ms
Decimal('0.35') HALF_UP 틀림 0 50ms
Decimal(0.35) HALF_UP 틀림 9,990 101ms 예: 110원 x 0.15 -> 16 (정답 17)
정답이 정확히 x.5 인 건수: 9,990
| 방법 | 버림 틀림 | 사사오입 틀림 | 시간(89,919건) |
|---|---|---|---|
| float 그대로 | 3,501 | 5,584 (round) | 16ms |
| float + 보정값 | 0 (+1e-9) | 1,169 (+0.5) | 16~17ms |
| 정수로만 계산 | 0 | 0 | 33~35ms |
| Decimal(‘0.35’) 문자열 | 0 | 0 | 50ms |
| Decimal(0.35) float로 생성 | 29,974 | 9,990 | 100ms |
가장 놀란 건 마지막 줄입니다. Decimal(0.35)처럼 float를 넣어 만들면 float의 오차가 그대로 정밀하게 옮겨집니다. Decimal(0.15)는 0.1499999…이므로 100원 x 0.15가 14.999…가 되고, 버리면 14원입니다. 그 결과 그냥 float(3,501건)보다 8배 넘게 틀렸습니다. 사사오입 쪽 9,990건은 정답이 정확히 x.5인 건수 9,990과 똑같았습니다. .5가 전부 .4999…로 바뀐 겁니다. Decimal은 반드시 문자열로 만들어야 합니다.
int(p * 0.35 + 0.5)로 사사오입을 흉내 내는 방법도 1,169건 틀렸습니다. 170원 x 0.35 = 59.5인데 float로는 59.4999…라서 60이 아니라 59가 됩니다.
+1e-9를 더하고 버리는 보정은 이번 시험에서 틀린 건이 없었습니다. 1억 원, 100억 원대 가격으로도 20만 건씩 돌려 봤지만 깨지는 사례는 찾지 못했습니다. 다만 1e-9라는 숫자는 “오차가 이 정도보다 작겠지”라는 짐작으로 고른 값입니다. 왜 맞는지 설명할 수 있는 정수나 Decimal 방식이 더 안심이 됩니다.
합계만 내는 코드는 괜찮았다
곱셈뿐 아니라 덧셈도 확인했습니다. 0.01~99.99달러 무작위 가격 100만 개를 문자열에서 읽어 더했습니다.
가격 1,000,000개 합계 (문자열에서 변환 포함)
정답(센트 정수 합) 49997835.87
float sum 49997835.87 0.091초
정수 센트 4999783587 0.119초
Decimal(str) 49997835.87 0.186초
0.1을 1,000번 더함: 99.9999999999986 (차이 -1.407e-12)
0.1을 1,000,000번 더함: 100000.00000133288 (차이 +1.333e-06)
0.1을 10,000,000번 더함: 999999.9998389754 (차이 -1.610e-04)
예상과 달리 float sum() 합계는 정답과 똑같이 나왔습니다. 단순 합계만으로는 오차가 드러나지 않았다는 뜻입니다. 반면 +=로 0.1을 1,000만 번 누적하면 0.00016만큼 어긋났습니다. 이번 실측에서 실제로 금액이 틀린 곳은 덧셈보다 곱한 뒤 원 미만을 자르거나 반올림하는 순간이었습니다. 속도는 float 0.09초, 정수 0.12초, Decimal 0.19초로 Decimal이 float의 약 2배였지만, 100만 줄에 0.2초라면 대부분의 업무 스크립트에서는 문제가 되지 않는 수준입니다.
바로 쓸 수 있는 금액 계산 함수
실측 결과를 반영해 원 단위 금액용 함수 두 개를 만들었습니다. 비율은 문자열로만 받아서 Decimal(0.35) 실수를 처음부터 막습니다.
from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN
def apply_rate(price, rate, mode="round"):
"""정수 원 금액에 비율(문자열)을 곱하고 원 미만을 처리한다."""
if not isinstance(rate, str):
raise TypeError("비율은 '0.35'처럼 문자열로 넘기세요")
value = Decimal(price) * Decimal(rate)
rounding = ROUND_HALF_UP if mode == "round" else ROUND_DOWN
return int(value.quantize(Decimal("1"), rounding=rounding))
def split(total, n):
"""총액을 n명에게 원 단위로 나눈다. 합계가 항상 total."""
q, rem = divmod(total, n)
return [q + (1 if i < rem else 0) for i in range(n)]
if __name__ == "__main__":
import sys
sys.stdout.reconfigure(encoding="utf-8")
print(apply_rate(180, "0.35", "floor")) # 63
print(apply_rate(110, "0.15")) # 17
print(apply_rate(170, "0.35")) # 60
print(split(10_000, 3))
try:
apply_rate(180, 0.35)
except TypeError as e:
print("TypeError:", e)
$ python won.py
63
17
60
[3334, 3333, 3333]
TypeError: 비율은 '0.35'처럼 문자열로 넘기세요
float에서 틀렸던 세 사례(180원의 35% 버림, 110원의 15% 사사오입, 170원의 35% 사사오입)가 모두 63, 17, 60으로 맞게 나옵니다. 비율을 숫자 0.35로 넘기면 바로 오류가 납니다.
split()은 나눗셈에서 생기는 1원 문제를 위한 함수입니다. 10,000원을 3명이 나눌 때 각자 반올림하면 합계가 9,999원이 되어 1원이 사라집니다. divmod로 몫과 나머지를 구해 나머지를 앞사람부터 1원씩 얹으면 합계가 항상 맞습니다.
10000 / 3 = 3333.3333333333335 -> round 각자 3333 합계 9999
divmod 분배: [3334, 3333, 3333] 합계 10000
주의할 점
- float끼리
==로 비교하지 마세요. 꼭 비교해야 하면math.isclose()를 씁니다. 실측에서math.isclose(0.1 + 0.2, 0.3)은True였습니다. - 화면에 보이는 값을 믿지 마세요.
f"{x:.2f}"는 0.30을 보여주지만 저장된 값은 그대로입니다. 출력 서식은 계산을 고치지 않습니다. - 반올림 규칙을 먼저 정하세요. 원 미만을 버릴지, 사사오입할지, 은행가 반올림을 쓸지는 업무 규칙입니다. 코드는 그 규칙을
ROUND_DOWN,ROUND_HALF_UP처럼 이름으로 드러내는 편이 나중에 읽기 쉽습니다. - CSV·엑셀에서 읽은 금액은 문자열 상태에서 바로 Decimal이나 정수로 바꾸세요. 한 번
float()를 거치면 이미 오차가 들어간 뒤입니다. - 몇 건 손으로 시험해서는 못 잡습니다. 이번 검사에서 9개 비율 중 7개는 오류가 0건이었습니다. 위의
Fraction비교처럼 범위를 넓게 잡아 자동으로 돌리는 편이 확실합니다.
마무리
금액은 정수 원으로 들고 다니고, 비율은 문자열로 Decimal에 넣고, 원 미만 처리 규칙은 이름으로 적는다. 이 세 가지로 89,919건이 전부 맞았습니다.
지금 쓰는 스크립트에 int(가격 * 0.7)이나 round(금액 * 비율) 같은 줄이 있다면, 위의 검사 코드에 그 비율을 넣어 몇 건이 틀리는지 먼저 재 보세요. 0건이면 다행이고, 한 건이라도 나오면 apply_rate()로 바꾸면 됩니다.
다음 글에서는 엑셀 파일 하나에 들어 있는 여러 시트를 CSV로 한 번에 변환하는 스크립트를 다룹니다.