Python Decimal: 소수 계산보다 먼저 정할 반올림 시점
문자열에서 Decimal을 만들고 항목별 반올림과 합계 반올림이 다른 결과를 내는 이유를 재현합니다.
소수 계산 결과가 기대와 다를 때 float를 Decimal로 바꾸는 것만으로 요구사항이 완성되지는 않습니다. 반올림 자리수와 방식뿐 아니라 어느 단계에서 반올림하는지도 정해야 합니다. 이 글의 숫자는 프로그램 계산 규칙을 설명하는 가상 예제이며 세금이나 결제 서비스의 실제 정산 규칙을 제시하지 않습니다.
입력은 문자열에서 시작하기
많은 십진 소수는 이진 부동소수점으로 정확히 표현되지 않습니다. Decimal(0.1)은 이미 만들어진 float의 값을 가져오므로 처음 의도한 십진수 0.1과 다를 수 있습니다. 텍스트 입력이 있다면 Decimal('0.1')처럼 문자열에서 변환합니다. 이미 float로 잃어버린 의도를 나중에 Decimal이 복구해 주는 것은 아닙니다.
입력이 올바른 숫자라는 것과 허용 가능한 업무 값이라는 것도 다릅니다. 음수 허용 여부, 최대값, 소수 자리수는 별도 검증 대상입니다. Decimal은 NaN과 무한대도 표현하므로 외부 입력을 받을 때 is_finite()를 확인할 수 있습니다.
항목별 반올림과 합계 반올림 비교
아래는 가상 단위 가격 0.335짜리 항목 두 개를 소수 둘째 자리까지 처리하는 예입니다. 특정 통화에 이 자리수를 적용하라는 뜻은 아닙니다.
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, localcontext
D = Decimal
def parse_amount(text):
try:
value = D(text)
except InvalidOperation:
raise ValueError('숫자 형식이 아닙니다') from None
if not value.is_finite() or value < 0 or value > D('1000000'):
raise ValueError('허용 범위를 벗어났습니다')
return value
with localcontext() as context:
context.prec = 28
items = [parse_amount('0.335'), parse_amount('0.335')]
unit = D('0.01')
per_item = sum((x.quantize(unit, rounding=ROUND_HALF_UP) for x in items), D('0'))
on_total = sum(items, D('0')).quantize(unit, rounding=ROUND_HALF_UP)
assert per_item == D('0.68')
assert on_total == D('0.67')
print(per_item, on_total)
assert D('0.1') + D('0.2') == D('0.3')
for invalid in ('NaN', 'Infinity', '-1', 'not-a-number'):
try:
parse_amount(invalid)
except ValueError:
pass
else:
raise AssertionError(invalid)
출력은 0.68 0.67입니다. 각 0.335를 0.34로 반올림하면 합이 0.68입니다. 먼저 합하면 0.670이고 마지막에 반올림한 값은 0.67입니다. 어느 쪽이 맞는지는 코드 취향이 아니라 서비스의 정산 계약이 결정합니다.
규칙을 테스트 문장으로 바꾸기
- 입력은 문자열로 받고 허용 범위 밖의 값은 거절한다.
- 항목별로 반올림할지 최종 합계에서 할지 한 곳에 명시한다.
- 정확히 중간인 값, 0, 최대 허용값을 예제에 포함한다.
- 환불·취소처럼 음수가 필요한 경로는 별도 규칙을 둔다.
위 함수는 설명을 위해 음수를 거절합니다. 환불 시스템에 그대로 붙이면 정상 환불도 막을 수 있습니다. 소수점 이하 입력 길이와 전체 문자열 길이 제한도 생략했습니다. 공개 API라면 지나치게 긴 입력을 Decimal로 변환하기 전에 제한해야 합니다.
저장과 화면 표시를 혼동하지 않기
화면에 두 자리로 보인다고 저장된 값도 두 자리인 것은 아닙니다. 계산용 원래 값, 정산 확정 값, 표시 문자열의 역할을 나눕니다. API로 내보낼 때 Decimal을 무심코 float로 바꾸면 처음 피하려던 근사값이 다시 들어올 수 있습니다. 문자열이나 최소 단위 정수 중 무엇을 쓸지 소비자와 계약을 정합니다.
정밀도 28도 무제한 정확도를 뜻하지 않습니다. 곱셈이 길어지거나 수가 커지면 문맥 정밀도에 따른 반올림이 생깁니다. 실무 테스트에는 예상 최대 규모를 넣고, 외부 정산 자료와 대조하는 검증을 추가해야 합니다.
참고 자료
- Python Decimal 공식 문서: 십진수 생성, 문맥, quantize.
- Python 부동소수점 설명: 표현 오차의 배경.
- 함께 읽기: 결과를 검증하는 Python 테스트.