0.1 + 0.2가 0.3과 다를 때: isclose와 Decimal 선택

이 카테고리 글 목록

전체글 보기

float 계산 결과의 근접성을 판단하려면 허용오차를 정해 math.isclose로 비교하고, 입력한 10진수와 소수 단위의 반올림 규칙이 중요하면 문자열에서 Decimal을 만든다. 화면에 짧게 표시하는 일, 두 수를 비슷하다고 판정하는 일, 계산 자료형을 고르는 일은 서로 다르다.

Python에서 0.1 + 0.2 == 0.3은 False다. 이를 해결한다며 모든 비교를 반올림하거나 모든 숫자를 Decimal로 바꿀 필요는 없다. 계산 대상과 허용되는 차이를 먼저 정해야 한다. 숫자처럼 생겼어도 계산 대상이 아닌 식별자의 처리는 식별자의 선행 0·긴 번호 보존처럼 별도 문제다.

표시를 0.3으로 바꿔도 저장된 float는 그대로다

Python의 부동소수점 설명는 0.1 같은 소수가 유한한 이진 분수로 정확히 표현되지 않는 이유를 설명한다. 아래 출력에서 덧셈 결과의 repr은 0.30000000000000004이고, 한 자리 표시 문자열은 0.3이다. 문자열 포맷을 적용한 뒤 원래 value를 비교하면 여전히 False다.

필요한 판단 선택 정해야 할 조건
화면에서 짧게 보여주기 표시 형식 몇 자리로 표시할지
근사 계산 결과가 충분히 가까운지 float + isclose 상대·절대 허용오차와 단위
입력한 10진수와 정해진 소수 단위 유지 문자열 + Decimal 연산 정밀도·반올림 위치와 방식

isclose의 허용오차는 업무의 판정 규칙이다

math.isclose 공식 문서에서 설명하는 유한한 입력·유효한 허용오차의 근접 판정은 다음 조건으로 이해할 수 있다. 둘 중 더 큰 허용폭 안에 들어오면 True가 된다.

abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)

rel_tol은 값의 규모에 비례하고, abs_tol은 비교하는 값과 같은 단위의 절대 차이다. 예를 들어 값의 단위가 mm라면 abs_tol도 mm로 해석한다. 아래 1e-9는 동작을 보여주기 위한 값이며 센서·점수·금액에 공통으로 적용할 권고값이 아니다.

기본 abs_tol은 0이다. 0과 비교할 때 작은 수라는 이유만으로 가까운 것으로 판정되지 않는 이유가 여기에 있다. 또 무한대는 같은 무한대와 가깝다고 판정되고 NaN은 자기 자신과도 가깝지 않다. 유한한 값만 허용하는 계산이라면 비교에 앞서 math.isfinite 등의 입력 규칙을 적용해야 한다.

코드는 CPython 3.14.3·Windows 11에서 실행했다. 설명은 Python 3.14 계열 공식 문서와 대조했으며, 문서 사이트의 현재 패치 판본은 실행한 인터프리터와 다를 수 있다.

import math
from decimal import Decimal, InvalidOperation, localcontext
from decimal import ROUND_HALF_EVEN, ROUND_HALF_UP

value = 0.1 + 0.2
print("repr:", repr(value))
print("equal:", value == 0.3)
print("display:", f"{value:.1f}")
print("after-display-equal:", value == 0.3)
print("isclose:", math.isclose(value, 0.3))
print("near-zero-default:", math.isclose(1e-12, 0.0))
print("near-zero-absolute:", math.isclose(1e-12, 0.0, abs_tol=1e-9))
print("relative-example:", math.isclose(1000.0, 1000.5, rel_tol=0.001))
print("infinite-pair:", math.isclose(math.inf, math.inf))
print("nan-pair:", math.isclose(math.nan, math.nan))

print("decimal-text-sum:", Decimal("0.1") + Decimal("0.2") == Decimal("0.3"))
print("decimal-from-float:", Decimal(0.1))
with localcontext() as ctx:
    ctx.prec = 6  # 소수점 아래 6자리가 아니라 유효숫자 6자리다.
    literal = Decimal("1.234567")
    print("literal:", literal)
    print("six-digit-product:", literal * Decimal("1"))
    print("one-third:", Decimal("1") / Decimal("3"))

with localcontext() as ctx:
    ctx.prec = 12
    unit = Decimal("0.01")
    print("half-even:", Decimal("2.665").quantize(unit, rounding=ROUND_HALF_EVEN))
    print("half-up:", Decimal("2.665").quantize(unit, rounding=ROUND_HALF_UP))

with localcontext() as ctx:
    ctx.prec = 2
    try:
        Decimal("123.45").quantize(Decimal("0.01"))
    except InvalidOperation:
        print("too-small-precision: InvalidOperation")

앞부분의 출력은 equal: False, display: 0.3, after-display-equal: False, isclose: True다. 0 근처 예는 기본 설정에서 False, 양의 abs_tol을 지정하면 True다. 큰 값 예는 0.1%의 상대 허용오차를 선택했으므로 True다. 이것들은 합성 수치이며 제품 정확도를 측정한 결과가 아니다.

Decimal은 입력과 연산을 나눠 본다

Decimal 공식 문서에서 설명하듯 Decimal(0.1)은 이미 만들어진 float의 값을 정확히 변환한다. 원래 의도한 짧은 소수 0.1로 고쳐주는 함수가 아니다. 코드의 긴 decimal-from-float 출력과 Decimal("0.1")을 비교하면 차이가 드러난다. 처음부터 10진 입력이 있다면 float를 거치지 않고 그 문자열을 사용한다.

문자열로 정확히 만들었다고 모든 이후 연산이 무한 정밀도가 되지는 않는다. 예제의 prec=6은 소수점 아래 여섯 자리가 아니라 유효숫자 여섯 자리다. literal은 1.234567 그대로지만 1을 곱한 결과는 1.23457, 1/3은 0.333333이 된다. 연산에는 현재 문맥의 정밀도와 반올림 규칙이 적용된다.

반올림할 자리와 동점 처리도 함께 명시한다

합성 예 2.665를 0.01 단위로 quantize하면, 코드에서 HALF_EVEN은 2.66, HALF_UP은 2.67이다. 어느 방식이 ‘항상 맞다’고 정하지 말고 소비자가 요구하는 규칙과 반올림 시점을 맞춘다. 중간 단계마다 반올림하는 계산과 마지막에 한 번 반올림하는 계산도 같은 계약이 아니다.

마지막 블록은 정밀도를 너무 작게 잡으면 quantize가 InvalidOperation을 낼 수 있음을 보여준다. 별도 실행한 예제는 기본 InvalidOperation trap을 유지한다. trap을 끄는 문맥에서는 결과와 처리 방식도 달라질 수 있다. Decimal을 도입할 때는 자료형 이름만 바꾸지 말고 입력 경로·연산 정밀도·단위·반올림 방식·예외 처리를 함께 시험해야 한다. isclose의 True도 오차가 없어졌다는 뜻이 아니라 선택한 허용오차를 만족했다는 뜻이다.

이 카테고리의 다른 글

전체글 보기

댓글 남기기