IEEE 754 부동소수점은 가수부(mantissa)의 유한한 비트 수 때문에 연산 순서에 따라 결과가 달라진다. 왜 비슷한 크기의 수끼리 먼저 더해야 하는지, 곱셈·나눗셈에서는 어떤 문제가 생기는지, 그리고 실무에서 돈 계산을 어떻게 처리하는지 정리했다.

IEEE 754 구조 복습

64비트 double 기준:

┌──────┬───────────┬──────────────────────────────────────────────────┐
│ 부호 │  지수부   │                   가수부                         │
│ 1bit │  11bit    │                   52bit                          │
└──────┴───────────┴──────────────────────────────────────────────────┘

실제 값은 다음과 같이 결정된다.

$$(-1)^{s} \times 1.m \times 2^{e - 1023}$$

가수부 52비트는 약 15~17자리의 십진수 정밀도를 제공한다. 이 유한한 비트 수가 모든 문제의 출발점이다.

덧셈에서 연산 순서가 중요한 이유

스케일이 다른 수의 덧셈

두 수를 더하려면 지수를 맞춰야 한다. 작은 수의 가수부를 오른쪽으로 시프트하는데, 이때 52비트 밖으로 밀려나는 비트는 버려진다.

  1.0 × 2^53  = 9007199254740992.0
+ 1.0 × 2^0   = 1.0

지수를 맞추면:
  1.0000000000000000000000000000000000000000000000000000  × 2^53
+ 0.0000000000000000000000000000000000000000000000000000(1) × 2^53
                                                          ↑
                                                  52비트 밖 → 소멸

결과: $9007199254740992.0 + 1.0 = 9007199254740992.0$. 1이 완전히 사라진다.

누적 합산의 차이

10만 개의 0.1을 합산하는 상황을 보자.

나쁜 순서: 큰 수에 작은 수를 계속 더하기

total = 0.0
for _ in range(100_000):
    total += 0.1
print(f"{total:.20f}")
# 10000.00000000018189894

누적값이 커질수록 0.1의 하위 비트가 점점 더 많이 잘린다.

좋은 순서: 비슷한 크기끼리 먼저 더하기 (pairwise summation)

import math

values = [0.1] * 100_000

def pairwise_sum(arr):
    if len(arr) <= 256:
        return math.fsum(arr[:1]) if len(arr) == 1 else sum(arr)
    mid = len(arr) // 2
    return pairwise_sum(arr[:mid]) + pairwise_sum(arr[mid:])

print(f"{pairwise_sum(values):.20f}")
# 10000.00000000000000000 에 훨씬 근접

비슷한 크기의 수끼리 더하면 지수 차이가 적어서 시프트로 손실되는 비트가 최소화된다.

Kahan Summation

오차를 보상하는 알고리즘도 있다.

def kahan_sum(values):
    total = 0.0
    compensation = 0.0       # 손실된 하위 비트 누적
    for x in values:
        y = x - compensation  # 이전 오차 보상
        t = total + y
        compensation = (t - total) - y  # 새로 발생한 오차 포착
        total = t
    return total

values = [0.1] * 100_000
print(f"{kahan_sum(values):.20f}")
# 10000.00000000000182077

Python의 math.fsum()은 내부적으로 이보다 더 정밀한 알고리즘을 사용한다.

뺄셈: 재앙적 소거

크기가 비슷한 두 수를 빼면 유효 숫자가 급격히 줄어든다. 이를 catastrophic cancellation(재앙적 소거)이라 한다.

a = 1.0000000000000002  # 1 + 2ε
b = 1.0000000000000000  # 1

c = a - b
# 기대값: 2.220446049250313e-16
# 실제값: 2.220446049250313e-16 (이 경우 정확하지만)

문제는 이 차이를 다시 다른 연산에 사용할 때 발생한다. 유효 숫자가 1~2개만 남은 상태에서 추가 연산을 하면 상대 오차가 폭발한다.

이차방정식의 근의 공식이 대표적인 예시다.

$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$

$b^2 \gg 4ac$이면 $\sqrt{b^2 - 4ac} \approx |b|$가 되어, $-b + \sqrt{b^2-4ac}$에서 재앙적 소거가 발생한다.

import math

a, b, c = 1.0, 1e8, 1.0

# 직접 계산 (재앙적 소거 발생)
discriminant = math.sqrt(b*b - 4*a*c)
x1 = (-b + discriminant) / (2*a)
x2 = (-b - discriminant) / (2*a)
print(f"x1 = {x1:.15e}")   # -7.450580596923828e-09 (오차 큼)
print(f"x2 = {x2:.15e}")   # -1.000000000000000e+08

# 안정적 계산 (비에타 공식 활용)
x2_stable = (-b - discriminant) / (2*a)
x1_stable = c / (a * x2_stable)  # x1 * x2 = c/a 이용
print(f"x1 = {x1_stable:.15e}")  # -1.000000000000000e-08 (정확)

곱셈과 나눗셈의 정밀도 문제

곱셈: 가수부끼리의 곱

두 수를 곱하면 가수부끼리 곱해진다.

$$1.m_1 \times 1.m_2 = 1.m_{result}$$

52비트 × 52비트의 결과는 최대 104비트가 되지만, 저장할 수 있는 건 52비트뿐이다. 나머지는 반올림(round-to-nearest-even)으로 잘린다.

a = 1.0000000000000002  # 1 + 2ε
b = 1.0000000000000003  # 1 + 3ε

# 정확한 값: 1 + 5ε + 6ε²
# 6ε² ≈ 2.96e-32 → double로 표현 불가
print(f"{a * b:.20e}")
# 1.00000000000000044409e+00

한 번의 곱셈에서 발생하는 오차는 작지만, 연쇄 곱셈에서 오차가 누적된다.

연쇄 곱셈의 순서

덧셈처럼 극적이지는 않지만, 곱셈에서도 순서가 영향을 준다. 핵심은 중간 결과의 오버플로우/언더플로우 방지다.

import math

# 큰 수끼리 먼저 곱하면 오버플로우
a = 1e200
b = 1e200
c = 1e-200

print(a * b * c)    # inf × 1e-200 = inf  (오버플로우!)
print(a * c * b)    # 1.0 × 1e200 = 1e200 (정상)

나눗셈에서도 마찬가지다.

a = 1e-300
b = 1e-300
c = 1e300

print(a / b * c)    # 1.0 × 1e300 = 1e300 (정상)
print(a * c / b)    # 1.0 / 1e-300 = 1e300 (정상)
print(a / (b / c))  # 1e-300 / 1e-600 = inf (오버플로우!)

곱셈·나눗셈 가이드라인:

상황전략
큰 수 × 큰 수사이에 작은 수(또는 나눗셈)를 끼워 스케일 유지
작은 수 × 작은 수사이에 큰 수를 끼워 언더플로우 방지
비율 계산 ($a/b \times c/d$)$(a \times c) / (b \times d)$ 대신 $(a/b) \times (c/d)$로 분리

나눗셈 반복의 오차 누적

나눗셈을 반복하면 매 단계마다 반올림 오차가 곱해진다.

x = 1.0
for _ in range(10):
    x /= 3.0
for _ in range(10):
    x *= 3.0

print(f"{x:.20f}")
# 0.99999999999999977796  (1.0이 아님)

10번 나누고 10번 곱해도 원래 값으로 돌아오지 않는다. 이건 $1/3$이 이진 부동소수점으로 정확히 표현되지 않기 때문이다.

실무: 기업은 돈 계산을 어떻게 하는가

부동소수점으로 금액을 계산하면 안 된다. 0.1 + 0.2 ≠ 0.3은 유명한 예시지만, 실제 금융 시스템에서는 이보다 훨씬 심각한 문제가 발생한다.

1. 정수 기반 처리 (가장 보편적)

금액을 최소 화폐 단위의 정수로 저장한다.

// ❌ 절대 하지 말 것
double price = 19.99;
double total = price * 3;  // 59.970000000000006

// ✅ 센트 단위 정수 처리
long priceInCents = 1999;
long total = priceInCents * 3;  // 5997 → $59.97 정확

대부분의 결제 시스템(Stripe, PayPal)이 이 방식을 사용한다. Stripe API는 금액을 센트 단위 정수로 받는다.

{
  "amount": 1999,
  "currency": "usd"
}

원화처럼 소수점이 없는 통화는 원 단위 정수를 그대로 사용하면 된다.

2. 고정소수점 / Decimal 타입

언어에서 제공하는 임의 정밀도 십진 타입을 사용한다.

from decimal import Decimal, ROUND_HALF_UP

price = Decimal("19.99")       # 문자열로 초기화해야 정확
tax_rate = Decimal("0.08")
tax = (price * tax_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
total = price + tax

print(total)  # 21.59 (정확)
// Java의 BigDecimal
BigDecimal price = new BigDecimal("19.99");
BigDecimal taxRate = new BigDecimal("0.08");
BigDecimal tax = price.multiply(taxRate)
    .setScale(2, RoundingMode.HALF_UP);
BigDecimal total = price.add(tax);
// 21.59 (정확)

주의: new BigDecimal(0.1)처럼 double에서 변환하면 이미 오염된 값이 들어간다. 반드시 문자열로 초기화해야 한다.

new BigDecimal(0.1);      // 0.1000000000000000055511151231257827021181583404541015625
new BigDecimal("0.1");    // 0.1

3. 데이터베이스에서의 처리

-- ❌
CREATE TABLE products (
    price FLOAT
);

-- ✅
CREATE TABLE products (
    price DECIMAL(10, 2)    -- 총 10자리, 소수점 이하 2자리
);

-- 또는 정수 저장
CREATE TABLE products (
    price_cents INTEGER     -- 센트 단위
);

MySQL의 DECIMAL, PostgreSQL의 NUMERIC은 내부적으로 BCD(Binary-Coded Decimal)나 유사한 방식으로 십진수를 정확히 표현한다.

4. 반올림 규칙

금융에서는 Banker’s rounding (round half to even)을 사용하는 경우가 많다.

일반 반올림Banker’s Rounding
0.510 (짝수로)
1.522 (짝수로)
2.532 (짝수로)
3.544 (짝수로)

0.5를 항상 올림하면 대량 거래에서 통계적 편향이 발생한다. Banker’s rounding은 이 편향을 제거한다.

IEEE 754의 기본 반올림 모드가 바로 이 round-to-nearest-even이다.

5. 실제 기업 사례

Stripe: 모든 금액을 최소 화폐 단위의 정수로 처리. API에서 float/double 타입을 아예 받지 않는다.

Amazon: 내부적으로 금액을 정수(센트/페니)로 저장하고, 표시할 때만 변환한다. 세금 계산은 항목별로 수행한 뒤 합산한다(합산 후 세금 계산이 아님).

은행권: COBOL의 PACKED-DECIMAL 타입을 수십 년간 사용해왔다. 내부적으로 BCD 인코딩으로 십진수를 정확히 표현한다. 현대 시스템에서도 Java BigDecimal이나 .NET decimal을 사용한다.

거래소: 가격을 정수 틱(tick) 단위로 처리. 예를 들어 주가가 $150.25이면 15025로 저장한다.

정리

문제원인해결
큰 수 + 작은 수 = 큰 수지수 정렬 시 가수부 비트 소멸비슷한 스케일끼리 먼저 연산
비슷한 수의 뺄셈 → 유효 숫자 손실재앙적 소거수식 변환으로 뺄셈 회피
연쇄 곱셈의 오버/언더플로우중간 결과의 스케일 폭발큰 수와 작은 수를 교차 배치
돈 계산의 오차이진수로 십진 소수를 정확히 표현 불가정수 또는 Decimal 타입 사용

핵심은 하나다: 부동소수점의 가수부는 52비트밖에 안 된다. 연산할 때 이 52비트 안에 유효한 정보가 최대한 보존되도록 순서를 설계해야 하고, 정확성이 필수인 금융 계산에서는 아예 부동소수점을 쓰지 않는 것이 정답이다.

참고 자료