[파이썬 100강] 61강. decimal로 돈 계산 정확하게 처리하기
쇼핑몰 합계, 정산 수수료, 부가세 계산처럼 “돈”이 들어가는 로직에서 float를 그대로 쓰면 소수점 오차가 누적되어 운영 이슈로 번집니다. 이번 강의는 서론은 여기까지, 바로 decimal.Decimal을 실무 방식으로 쓰는 패턴으로 들어가겠습니다. 목표는 단순 문법 암기가 아니라 계산 정확도 + 반올림 정책 + 저장/표시 일관성까지 한 번에 잡는 것입니다.
핵심 개념
Decimal은 10진수 기반 정밀 연산을 제공해 금액 계산에서float오차를 줄입니다.- 돈 계산에서 중요한 건 “숫자 타입”만이 아니라 반올림 규칙(ROUND_HALF_UP 등) 입니다.
- 계산용 내부값과 화면 표시용 문자열을 분리해야 정산/회계 로직이 흔들리지 않습니다.
파이썬의 float는 이진 부동소수점이라 0.1, 0.2 같은 값을 내부에서 정확히 표현하지 못합니다. 그래서 0.1 + 0.2가 사람이 기대하는 딱 0.3이 아니라 미세한 오차를 가진 값이 됩니다. 단순 통계라면 허용될 수 있지만, 돈 계산에서는 “1원 차이”가 결제 취소, 환불 분쟁, 세무 보고 오류로 이어질 수 있습니다. Decimal은 문자열 기반으로 값을 만들고, 반올림 정책을 명시해 예측 가능한 결과를 얻는 데 강합니다. 특히 팀 단위 개발에서는 “누가 계산해도 같은 값”이 나와야 하므로, 컨텍스트(정밀도/반올림), 단위(원/달러/센트), 양자화(quantize) 규칙을 코드로 고정하는 습관이 중요합니다.
기본 사용
예제 1) float와 Decimal 차이 확인
>>> 0.1 + 0.2
0.30000000000000004
>>> from decimal import Decimal
>>> Decimal("0.1") + Decimal("0.2")
Decimal('0.3')
>>> (Decimal("1.10") * 3)
Decimal('3.30')
해설:
Decimal("0.1")처럼 문자열로 생성해야 의도한 10진 값이 유지됩니다.float는 빠르고 편하지만 금액 정산엔 부적합한 경우가 많습니다.- 금액은 처음부터 끝까지
Decimal로 처리하는 편이 안전합니다.
예제 2) 소수점 2자리 반올림
>>> from decimal import Decimal, ROUND_HALF_UP
>>> amount = Decimal("123.455")
>>> amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
Decimal('123.46')
>>> Decimal("123.454").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
Decimal('123.45')
해설:
quantize(Decimal("0.01"))는 “소수 둘째 자리까지”를 의미합니다.- 반올림 정책을 코드에 명시하지 않으면 팀원/환경별 결과가 달라질 수 있습니다.
- 통화별로 소수 자릿수가 다르므로(예: JPY 0자리) 정책 분리가 필요합니다.
예제 3) 장바구니 합계 + 할인 + 세금 계산
>>> from decimal import Decimal, ROUND_HALF_UP
>>> prices = [Decimal("12900"), Decimal("3500"), Decimal("4990")]
>>> subtotal = sum(prices, start=Decimal("0"))
>>> discount_rate = Decimal("0.10")
>>> vat_rate = Decimal("0.10")
>>> discounted = subtotal * (Decimal("1") - discount_rate)
>>> taxed = discounted * (Decimal("1") + vat_rate)
>>> final = taxed.quantize(Decimal("1"), rounding=ROUND_HALF_UP) # 원 단위 반올림
>>> subtotal, final
(Decimal('21390'), Decimal('21176'))
해설:
- 합계 시작값도
Decimal("0")으로 맞춰 타입 일관성을 유지합니다. - 할인/세금을 단계별로 분리하면 디버깅과 감사(audit)가 쉬워집니다.
- “표시용 반올림” 시점과 “내부 계산” 시점을 명확히 분리해야 합니다.
예제 4) 통화별 반올림 단위 전략
>>> from decimal import Decimal, ROUND_HALF_UP
>>> def quantize_money(amount: Decimal, currency: str) -> Decimal:
... scale_map = {
... "KRW": Decimal("1"),
... "USD": Decimal("0.01"),
... "JPY": Decimal("1"),
... "KWD": Decimal("0.001"),
... }
... scale = scale_map.get(currency, Decimal("0.01"))
... return amount.quantize(scale, rounding=ROUND_HALF_UP)
...
>>> quantize_money(Decimal("19.995"), "USD")
Decimal('20.00')
>>> quantize_money(Decimal("1999.5"), "KRW")
Decimal('2000')
해설:
- 통화별 자릿수 규칙을 하드코딩 분산시키지 말고 함수/설정으로 중앙화하세요.
- 정책이 바뀌면 함수 한 곳만 바꾸면 되어 운영 리스크가 줄어듭니다.
자주 하는 실수
실수 1) Decimal에 float를 그대로 넣기
>>> from decimal import Decimal
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
원인:
Decimal(0.1)은 이미 오차가 있는 float 값을 받아오기 때문에 의미가 퇴색됩니다.
해결:
>>> Decimal("0.1")
Decimal('0.1')
핵심:
- 외부 입력이 숫자여도 일단 문자열로 표준화한 뒤
Decimal로 변환하는 습관을 들이세요.
실수 2) 중간 단계마다 반올림해서 누적 오차 만들기
>>> from decimal import Decimal, ROUND_HALF_UP
>>> unit = Decimal("333.335")
>>> # 잘못된 패턴: 건별 반올림 후 합산
>>> wrong = sum((unit.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) for _ in range(3)), Decimal("0"))
>>> # 권장 패턴: 합산 후 최종 반올림
>>> right = (unit * 3).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
>>> wrong, right
(Decimal('1000.02'), Decimal('1000.01'))
원인:
- 단계별 반올림은 거래가 많을수록 편향을 키웁니다.
해결:
- 내부 계산은 가능한 원본 정밀도로 유지하고, 결제 확정/표시 직전에 1회 반올림합니다.
- 회계 규정상 건별 반올림이 필요한 경우 그 근거를 코드 주석/문서에 명확히 남기세요.
실수 3) Decimal과 float/int를 섞어 연산
- 증상: 타입 에러가 나거나, 묵시적 변환으로 정책이 흐려집니다.
- 원인: API 입력 파싱 단계에서 타입 통일을 하지 않음.
- 해결: 서비스 경계(요청 파싱, DB 로드)에서
Decimal로 통일한 뒤 도메인 로직으로 넘깁니다.
실수 4) 화면 표시 문자열을 다시 계산값으로 재사용
- 증상:
"12,340원"같은 값이 역파싱되며 오류/오차가 발생합니다. - 원인: 저장 포맷과 표시 포맷을 분리하지 않음.
- 해결: 저장은
Decimal또는 최소 단위 정수(원/센트), 표시만 포매팅 문자열로 만듭니다.
실무 패턴
- 입력 검증 규칙: 금액 입력은 정규식/스키마로 먼저 검증하고
Decimal(str(value))로 변환합니다. - 반올림 규칙 중앙화:
quantize_money(amount, currency)같은 공통 함수를 만들어 서비스 전체에서 같은 정책을 씁니다. - 저장 전략: DB는
NUMERIC(precision, scale)또는 최소 단위 정수(예: KRW는 원, USD는 cent) 중 하나를 조직 표준으로 고정합니다. - 로그/예외 처리: 계산 전 값, 계산식, 반올림 후 값을 구조화 로그로 남겨 정산 이슈를 역추적 가능하게 합니다.
- 테스트 전략: 경계값(
x.xxx5, 음수 금액, 큰 금액, 통화별 소수 자릿수)을 파라미터 테스트로 고정합니다.
실무에서 가장 많이 쓰는 패턴은 “도메인 Money 객체”를 두는 방식입니다. 예를 들어 Money(amount: Decimal, currency: str)를 만들고, 더하기/곱하기/반올림 규칙을 객체 안에 넣으면 컨트롤러나 서비스가 산만해지지 않습니다. 또 외부 결제사 응답이 문자열인지 정수인지 제각각이므로, 어댑터 계층에서 한 번 정규화한 뒤 내부로 넣어야 계산 버그가 줄어듭니다. 마지막으로 정산 배치에서는 재계산 가능성이 중요합니다. 원본 거래 이벤트 + 동일 반올림 정책만 있으면 언제든 같은 결과를 재현할 수 있어야 장애 대응이 빨라집니다.
오늘의 결론
한 줄 요약: 돈 계산은 float가 아니라 Decimal과 명시적 반올림 정책으로 처리해야 운영에서 흔들리지 않습니다.
기억할 것:
Decimal("문자열")로 생성하고, 타입 혼합을 피하세요.- 반올림은 정책 함수로 중앙화하고 팀 전체가 같은 규칙을 쓰세요.
- 내부 계산값과 화면 표시값을 분리해야 정산/감사 대응이 쉬워집니다.
연습문제
calculate_total(prices, discount_rate, vat_rate, currency)함수를 작성해 통화별 반올림 규칙을 적용한 최종 결제 금액을 반환하세요.Decimal(0.1)과Decimal("0.1")차이를 재현하고, 왜 금액 로직에서 전자가 위험한지 설명하세요.- 건별 반올림 vs 합산 후 반올림 시나리오를 10건 데이터로 만들어 총액 차이를 비교하고, 어떤 정책을 선택할지 근거를 적어보세요.
이전 강의 정답
new_user_public_id()구현
>>> import uuid
>>> def new_user_public_id() -> str:
... return f"usr_{uuid.uuid4().hex}"
...
>>> uid = new_user_public_id()
>>> uid.startswith("usr_"), len(uid) == 36
(True, True)
validate_public_id(value)구현
>>> import uuid
>>> def validate_public_id(value: str) -> bool:
... if not value.startswith("usr_"):
... return False
... body = value[4:]
... try:
... uuid.UUID(hex=body)
... return len(body) == 32
... except ValueError:
... return False
...
>>> validate_public_id("usr_9adf61f21fdb4b86b9f9534ff1e20b33")
True
>>> validate_public_id("usr_not_valid")
False
idempotency_key기반 중복 주문 차단 의사코드
>>> # 핵심 아이디어: (user_id, idempotency_key)에 UNIQUE 제약
>>> # 1) 요청 수신 시 키 유효성 검사
>>> # 2) 기존 주문 조회: 있으면 기존 결과 반환
>>> # 3) 없으면 트랜잭션으로 주문 생성 + 키 저장
>>> # 4) 커밋 후 주문 결과 반환
>>> def create_order_with_idempotency(user_id, idem_key, payload):
... pass
...
실습 환경/재현 정보
- 실행 환경:
condaenvpython100(Python 3.11.14) - 가정한 OS: macOS/Linux 공통
- 사용 모듈:
decimal - 재현 절차:
- 예제 1로
float와Decimal의 표현 차이를 확인합니다. - 예제 2로
quantize반올림 정책을 실험합니다. - 예제 3으로 합계-할인-세금 계산 흐름을 구현합니다.
- 실수 2를 재현해 건별 반올림과 최종 반올림 결과 차이를 비교합니다.
- 통화별 자릿수 함수(
quantize_money)를 만들어 정책을 중앙화합니다.
- 예제 1로
- 검증 포인트:
Decimal생성 시 float 직접 입력을 피했는지- 반올림 시점(중간/최종)이 정책대로 통일됐는지
- 통화별 자릿수 규칙이 코드 한 곳에서 관리되는지