[파이썬 100강] 62강. fractions로 비율 계산을 정확하게 표현하기

[파이썬 100강] 62강. fractions로 비율 계산을 정확하게 표현하기

부동소수점(float)은 빠르고 편하지만, 0.1 같은 값을 내부에서 정확히 표현하지 못해 누적 오차가 생길 수 있습니다. 비율, 배합, 점수 가중치처럼 “정확한 분수 관계”가 중요한 작업에서는 이 오차가 결과 신뢰도를 떨어뜨리곤 합니다. 이번 강의에서는 fractions.Fraction으로 값을 분수 그대로 보존하면서 계산하는 방법을 다룹니다. 서론은 여기까지 하고, 바로 실전에 가까운 예제로 들어가겠습니다.


핵심 개념

  • Fraction은 분자/분모 형태로 유리수를 정확하게 표현한다.
  • Fraction(1, 3)처럼 명시적으로 만들거나 문자열/정수에서 안전하게 변환할 수 있다.
  • float를 그대로 넣으면 “float가 가진 근사값”을 분수화하므로 입력 경로를 설계해야 한다.
  • limit_denominator()를 쓰면 사람이 해석 가능한 근사 분수로 정리할 수 있다.
  • 비율 계산, 단위 환산, 점수 가중치, 레시피 배율처럼 “관계 보존”이 중요한 곳에서 특히 강력하다.

Fraction의 핵심은 “정확도”보다 더 정확히 말하면 “표현의 보존”입니다. 예를 들어 1/3은 float에서 무한소수라 잘려 저장되지만, Fraction(1, 3)은 1/3 자체를 구조로 저장합니다. 그래서 덧셈/곱셈을 반복해도 오차가 누적되는 대신 분자와 분모가 규칙적으로 커지거나 약분됩니다. 이 특성은 테스트 코드에서도 장점입니다. 0.30000000000000004 같은 값 비교를 피하고, 도메인 규칙(예: 2:3 배합)을 그대로 assertion으로 표현할 수 있기 때문입니다. 다만 모든 계산을 Fraction으로 밀어붙이면 성능 비용이 늘 수 있으니, “정확성이 필수인 경계 구간”에서 선택적으로 쓰는 습관이 실무적으로 좋습니다.

기본 사용

예제 1) 분수 생성과 기본 연산

>>> from fractions import Fraction
>>> a = Fraction(1, 3)
>>> b = Fraction(1, 6)
>>> a + b
Fraction(1, 2)
>>> a * 12
Fraction(4, 1)
>>> float(a)
0.3333333333333333

해설:

  • Fraction(1, 3)은 1/3을 정확히 저장합니다.
  • a + b 결과가 정확히 1/2로 떨어지는 점이 중요합니다.
  • 필요할 때만 float()로 변환해 출력/시각화 단계에 넘기면 됩니다.

예제 2) 문자열 입력을 안전하게 분수로 변환

>>> from fractions import Fraction
>>> Fraction("0.1") + Fraction("0.2")
Fraction(3, 10)
>>> float(Fraction("0.1") + Fraction("0.2"))
0.3
>>> Fraction("7/8") * 4
Fraction(7, 2)

해설:

  • 사용자 입력이 문자열일 때 Fraction("0.1")처럼 처리하면 의도한 십진값을 보존할 수 있습니다.
  • Fraction("7/8") 형식도 지원해서 설정 파일(JSON/YAML) 기반 시스템에서 활용하기 쉽습니다.
  • “입력 경로 통일”이 핵심입니다. 문자열/정수 중심으로 받으면 정확도 문제를 크게 줄일 수 있습니다.

예제 3) 배합 비율 계산 미니 케이스

>>> from fractions import Fraction
>>> ratio_water = Fraction(2, 5)
>>> ratio_concentrate = Fraction(3, 5)
>>> total_ml = 750
>>> water_ml = ratio_water * total_ml
>>> conc_ml = ratio_concentrate * total_ml
>>> water_ml, conc_ml
(Fraction(300, 1), Fraction(450, 1))
>>> int(water_ml), int(conc_ml)
(300, 450)

해설:

  • 비율의 합(2/5 + 3/5 = 1)을 분수로 표현하면 검증이 명확해집니다.
  • 용량 계산에서 중간 오차가 없어 배치 단위가 커져도 일관된 결과를 유지합니다.
  • 현업에서는 마지막 출력 지점에서만 정수 반올림 규칙을 적용하세요.

예제 4) 사람이 읽기 쉬운 근사 분수 만들기

>>> from fractions import Fraction
>>> x = Fraction("3.14159265")
>>> x
Fraction(62831853, 20000000)
>>> x.limit_denominator(1000)
Fraction(355, 113)
>>> float(x.limit_denominator(1000))
3.1415929203539825

해설:

  • 원본 분수는 매우 큰 분모를 가질 수 있습니다.
  • limit_denominator(1000)은 해석 가능한 범위에서 좋은 근사를 찾을 때 유용합니다.
  • 리포트 표시나 규칙 추천(예: “대략 355/113”)에 잘 맞습니다.

자주 하는 실수

실수 1) float를 그대로 Fraction에 넣기

>>> from fractions import Fraction
>>> Fraction(0.1)
Fraction(3602879701896397, 36028797018963968)

원인:

  • 0.1 float 자체가 이미 근사값으로 저장되어 있습니다.
  • Fraction(0.1)은 “근사된 binary float”를 충실히 분수화한 결과입니다.

해결:

>>> from fractions import Fraction
>>> Fraction("0.1")
Fraction(1, 10)
>>> Fraction(1, 10)
Fraction(1, 10)
  • 입력은 가능하면 문자열 또는 정수 분자/분모로 받습니다.
  • API 경계에서 변환 정책을 고정하세요(예: 소수 입력은 문자열만 허용).

실수 2) Fraction과 반올림/출력 규칙을 섞어버리기

증상:

  • 계산 중간에 round(float(x), 2)를 반복 적용해서 값이 흔들립니다.

원인:

  • 중간 단계에서 float로 내려가면 Fraction의 장점(정확한 관계 보존)이 사라집니다.

해결:

  • 계산 파이프라인 내부는 Fraction 유지
  • 마지막 출력 단계에서만 반올림/형식화
  • 리포트용 수치와 내부 계산 수치를 분리

실수 3) 분모가 커지는 비용을 무시하기

증상:

  • 대량 데이터 누적 계산에서 처리 속도가 예상보다 느려짐

원인:

  • Fraction 연산은 분자/분모 정수 연산 + 약분이 반복되어 비용이 증가할 수 있음

해결:

>>> from fractions import Fraction
>>> vals = [Fraction(n, 1000) for n in range(1, 1000)]
>>> total = sum(vals)
>>> total.limit_denominator(10000)
Fraction(999, 2)
  • 정확성이 필요한 구간만 Fraction 사용
  • 집계 후 limit_denominator()나 정책적 다운캐스팅 적용
  • 성능 민감 경로는 벤치마크로 확인

실무 패턴

  • 입력 검증 규칙

    • 외부 입력이 소수라면 문자열로 받도록 스키마를 정의합니다.
    • “분모 0 금지”, “음수 허용 여부”, “최대 분모 제한”을 명시합니다.
  • 로그/예외 처리 규칙

    • 변환 실패(ValueError, ZeroDivisionError)는 사용자 입력 오류와 시스템 오류를 분리해 기록합니다.
    • 로그에는 원본 입력 문자열을 남기고, 내부에서는 정제된 Fraction만 전달합니다.
  • 재사용 함수/구조화 팁

    • parse_ratio(value: str) -> Fraction 같은 헬퍼를 만들어 모든 엔드포인트에서 공유합니다.
    • 도메인 객체(예: Recipe, Allocation)에 Fraction 필드를 두면 규칙 테스트가 쉬워집니다.
  • 성능/메모리 체크 포인트

    • 루프 내부에서 불필요한 Fraction(str(x)) 재생성을 줄이고 캐시 가능한 값은 재사용합니다.
    • 최종 저장소(DB/CSV)에는 "numerator/denominator" 또는 decimal 변환 정책을 표준화합니다.
  • 협업 패턴

    • 팀 규약에 “float -> Fraction 직접 변환 금지”를 넣어 리뷰 기준을 명확히 합니다.
    • 테스트에서는 pytest.approx 대신 Fraction 동등성 비교를 우선 사용해 의도를 드러냅니다.

오늘의 결론

한 줄 요약: 비율과 배합처럼 관계가 중요한 계산은 float가 아니라 Fraction으로 모델링하면, 결과보다 먼저 신뢰도를 확보할 수 있습니다.

기억할 것:

  • 정확성이 중요한 구간에서는 입력부터 Fraction 친화적으로 설계한다.
  • 계산 중간에는 Fraction을 유지하고 출력 직전에만 float/문자열로 변환한다.
  • 성능이 걱정되면 범위를 좁혀 적용하고 벤치마크로 검증한다.

연습문제

  1. 문자열 리스트 ['1/3', '1/6', '1/2']를 Fraction으로 변환해 총합이 정확히 1인지 검사하는 코드를 작성해 보세요.
  2. 총 1200ml 용액에서 비율이 7:5:3인 세 성분의 용량을 Fraction으로 계산하고, 최종 출력은 정수 ml로 보여주세요.
  3. Fraction("2.7182818")을 분모 제한 100, 1000으로 각각 근사한 뒤, 원래 값과의 오차(절댓값)를 비교해 보세요.

이전 강의 정답

  1. 소수 누적 오차 없이 총액 계산하기
>>> from decimal import Decimal
>>> prices = [Decimal("19.90"), Decimal("5.10"), Decimal("0.30")]
>>> sum(prices)
Decimal('25.30')
  1. 반올림 규칙(은행가 반올림 대신 HALF_UP) 적용하기
>>> from decimal import Decimal, ROUND_HALF_UP
>>> amount = Decimal("2.345")
>>> amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
Decimal('2.35')
  1. 세율 계산 후 통화 단위 맞추기
>>> from decimal import Decimal, ROUND_HALF_UP
>>> net = Decimal("10000")
>>> tax = (net * Decimal("0.1")).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
>>> gross = net + tax
>>> tax, gross
(Decimal('1000'), Decimal('11000'))

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 주요 표준 라이브러리: fractions
  • 검증 방식: REPL(pycon) 기준으로 예제 실행