본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Computer Science

부동소수점 오차: 반올림·비교·누적

작성 2026. 10. 21.추가
목차9개 항목
출처와 참고

「The Floating-Point Guide」와 David Goldberg의 논문 「What Every Computer Scientist Should Know About Floating-Point Arithmetic」(1991)을 바탕으로, Math.sumPrecise는 MDN 문서로 확인해 재구성한 노트다.

IEEE 754 형식(부호·지수·가수 비트, 바이어스, 비정규 수, ±0, Infinity, NaN)은 데이터의 표현에 있다. 이 노트는 그 형식으로 계산할 때 생기는 오차를 다룬다.

왜 0.1 + 0.2 ≠ 0.3인가

0.1 + 0.2          // 0.30000000000000004
0.1 + 0.4          // 0.5  (우연히 오차가 상쇄됐다)

0.1이라고 쓴 순간 이미 2진 형식에서 가장 가까운 값으로 반올림되어 저장된다. 계산 전부터 오차가 있다. 0.1 + 0.4가 맞아 보이는 것은 결과 0.5가 정확히 표현되고 입력의 오차가 서로 상쇄됐기 때문이지 보장된 것이 아니다. 0.1 + 0.3처럼 결과가 정확히 0.4가 아니어도, 언어는 「가장 가까운 부동소수점이 0.4인 가장 짧은 10진 표기」를 출력하므로 맞아 보인다.

부동소수점은 크기가 전혀 다른 수를 같은 상대 정밀도로 다루려는 절충이다. 고속도로 폭(10m 대 10.0001m)과 마이크로칩(0.1mm가 큰 차이)과 물리 상수(빛의 속도 3×10⁸과 중력 상수 6.67×10⁻¹¹)를 한 형식에 담으려면 소수점 위치를 지수로 옮겨야 한다.

반올림 오차의 세 가지 원인

원인뜻예
유효 숫자가 너무 많다가수 자릿수보다 유효 숫자가 많으면 남는 자리를 버린다. 곱셈·나눗셈을 이어 하면 금방 닿는다가수 3자리 형식에서 1000·1110은 그대로, 1001은 반올림
순환 소수기약분수의 분모에 진법의 소인수가 아닌 소인수가 있으면 끝나지 않는다10진법에서 1/3. 2진법에서는 분모에 5가 있는 1/10, 1/5가 끝나지 않는다
무리수어떤 진법으로도 끝나지 않는다π, √2

반올림 모드

모드규칙1.52.5−1.6특징
0 쪽으로(버림)남는 자리를 잘라 낸다12−1단순하지만 오차가 크고 0 쪽으로 치우친다
0에서 먼 쪽 반올림절반 이상이면 올린다(학교식)23−2오차는 작지만 0에서 먼 쪽으로 치우친다
짝수 쪽 반올림(은행가 반올림)정확히 절반이면 결과가 짝수가 되는 쪽으로22−2오차와 치우침이 모두 작다. 회계에 쓰고, IEEE 754의 기본값이다

IEEE 754는 사칙연산과 제곱근이 정확히 반올림(exactly rounded)되도록 요구한다. 무한 정밀도로 계산한 뒤 위의 모드로 한 번 반올림한 것과 같은 결과를 내야 한다. 그래서 같은 연산은 어느 기계에서나 같은 비트를 낸다.

오차를 재는 단위: ulp와 상대 오차

단위뜻
ulp(units in the last place)결과가 마지막 자리 몇 칸만큼 틀렸나. 가장 가까운 값으로 반올림하면 오차는 0.5ulp 이하다
상대 오차(계산값 − 참값) / 참값. 수의 크기에 상관없이 비교할 수 있다
기계 엡실론 ε1과 그다음 표현 가능한 수의 차이. double은 2⁻⁵²(JS Number.EPSILON). 반올림의 상대 오차는 ε/2 이하다

오차가 n ulp면 대략 log₂ n개의 비트가 오염됐다는 뜻이다.

같음 비교: 고정 엡실론을 쓰지 않는다

0.15 + 0.15 === 0.1 + 0.2   // false일 수 있다. >= 비교도 마찬가지다
Math.abs(a - b) < 0.00001   // 틀린 방법

고정된 절대 오차는 수가 아주 작으면 너무 커서 다른 수를 같다고 하고, 수가 아주 크면 반올림 오차보다 작아서 늘 다르다고 한다. 그래서 상대 오차로 비교하고, 0 근처와 무한대를 따로 처리한다.

static boolean nearlyEqual(float a, float b, float epsilon) {
    float absA = Math.abs(a), absB = Math.abs(b), diff = Math.abs(a - b);
    if (a == b) return true;                              // 무한대까지 처리
    if (a == 0 || b == 0 || absA + absB < Float.MIN_NORMAL)
        return diff < epsilon * Float.MIN_NORMAL;         // 0 근처: 상대 오차가 무의미
    return diff / Math.min(absA + absB, Float.MAX_VALUE) < epsilon;
}
  • (a - b) / b만으로는 부족하다. a, b가 모두 0이면 NaN, b만 0이면 무한대, 0을 사이에 둔 아주 작은 두 수는 늘 다르다고 나온다. 또 nearlyEqual(a, b)와 (b, a)가 달라진다.
  • 다른 방법은 ulp 거리로 비교하는 것이다. IEEE 754 비트 패턴은 정수로 읽어도 크기 순서가 유지되므로, 두 값 사이에 표현 가능한 부동소수점이 몇 개인지를 세어 허용 오차로 쓴다. 크기에 따라 허용 오차가 저절로 조정된다.
  • 어떤 허용 오차가 맞는지는 응용에 따라 다르다. 테스트에서는 Jest toBeCloseTo, Python math.isclose(a, b, rel_tol=…, abs_tol=…)처럼 상대·절대 오차를 함께 받는 함수를 쓴다.

오차가 커지는 계산: 흡수와 상쇄

곱셈과 나눗셈은 상대 오차를 조금씩 더할 뿐이라 비교적 안전하다. 덧셈과 뺄셈이 위험하다.

현상상황예
흡수(absorption)크기 차이가 큰 두 수를 더하면 작은 수의 자리가 사라진다단정밀도 100,000,000에 1을 1억 번 더해도 그대로 100,000,000이다
파국적 상쇄(catastrophic cancellation)반올림 오차가 있는 거의 같은 두 수를 빼면 앞자리가 지워지고 오차만 남는다. 뒤에 큰 수를 곱하면 오차가 결과를 덮는다2차 방정식 근의 공식의 b² − 4ac, 매우 납작한 삼각형의 헤론 공식
양성 상쇄(benign cancellation)오차 없는 정확한 두 수를 빼는 것은 안전하다(가드 숫자가 있으면 상대 오차 2ε 미만)-

파국적 상쇄는 식을 바꿔 양성 상쇄로 돌릴 수 있다.

위험한 식안전한 식이유
x*x - y*y(x - y) * (x + y)반올림된 두 제곱의 차 대신, 정확한 입력끼리의 차를 먼저 구한다
log(1 + x) (x가 아주 작을 때)log1p(x)1 + x에서 x의 자리가 흡수된다
exp(x) - 1expm1(x)같은 이유

계산 방법에는 오차를 줄이는 안정적인 것과 키우는 불안정한 것이 있고, 같은 문제에 둘 다 있는 경우가 많다. 이를 다루는 분야가 수치 해석의 수치 안정성이다.

많이 더할 때: Kahan 합산과 정밀 합

n개를 순서대로 더하면 오차가 최대 nε까지 쌓인다. Kahan 합산은 매번 잃어버린 하위 비트를 보정값 c에 모아 다음 덧셈에 되돌려 넣어, 오차를 항의 개수와 거의 무관하게(약 2ε) 만든다.

function kahanSum(xs) {
  let sum = 0, c = 0;
  for (const x of xs) {
    const y = x - c;       // 지난번에 잃은 만큼 보정
    const t = sum + y;     // 큰 sum에 더하면 y의 하위 비트가 잘린다
    c = (t - sum) - y;     // 잘린 부분을 복원(부호 반대)
    sum = t;
  }
  return sum;
}

이 기법은 (x + y) + z ≠ x + (y + z)라는 부동소수점의 성질에 기대므로, 컴파일러가 결합 법칙으로 식을 재배열하는 최적화(C의 -ffast-math 등)를 켜면 보정이 사라진다.

JavaScript는 Math.sumPrecise(iterable)(Baseline 2026)로 중간 반올림 없이 정확히 더한 뒤 마지막에 한 번만 반올림한다. Python의 math.fsum도 같다.

[1e20, 0.1, -1e20].reduce((a, b) => a + b)  // 0   (1e20 + 0.1에서 0.1이 흡수됨)
Math.sumPrecise([1e20, 0.1, -1e20])         // 0.1
Math.sumPrecise([0.1, 0.2])                 // 0.30000000000000004 (입력이 이미 부정확하다)

정확해야 할 때: decimal과 정수

돈처럼 마지막 자리까지 맞아야 하는 계산에는 2진 부동소수점을 쓰지 않는다.

방식원리장단점예
제한 정밀도 10진지수를 10의 거듭제곱으로 해석한다(IEEE 754-2008의 decimal 형식)0.1이 정확하다. 비교적 빠르고 작지만 2진보다 느리다C# decimal, SQL DECIMAL(p,s)·NUMERIC
임의 정밀도 10진필요하면 가수 길이를 늘린다정확하지만 자릿수만큼 느려지고 메모리를 쓴다Java BigDecimal, Python decimal, JS의 decimal.js·big.js
기호 계산1/3, √2, π를 기호로 들고 다닌다수학적으로 정확하지만 느리고 복잡하다Mathematica, SymPy
정수(최소 단위)센트·원처럼 최소 단위의 정수로 계산하고 표시할 때만 소수점을 붙인다단순하지만 아래 한계가 있다1034센트 = 10.34달러

정수 방식의 한계는 이렇다.

  • 반올림 모드를 직접 챙겨야 한다(이자·분할 계산).
  • 정수는 범위를 넘으면 조용히 감싸 돈다(최댓값 + 1 → 최솟값). 돈에서는 최악의 동작이다.
  • 소수점 위치가 고정된다. 센트(1/100) 기준으로 저장하면 1/1000 단위인 바레인 디나르를 같은 열에 못 담는다. 통화별 소수 자릿수를 함께 저장해야 하고, 인플레이션으로 통화의 보조 단위가 바뀌면 저장된 값을 모두 옮겨야 한다.

그래서 언어·DB가 decimal 타입을 제공하면 그것을 먼저 쓰고, 없을 때 정수 최소 단위를 쓴다. JavaScript의 예시는 숫자와 Math에 있다.

정리

  • 부동소수점 오차는 버그가 아니라 2진수가 0.1 같은 수를 담지 못해서 생긴다. 입력 시점부터 반올림된다.
  • IEEE 754는 기본으로 짝수 쪽 반올림을 쓰고, 기본 연산을 정확히 반올림하므로 결과는 기계와 무관하게 같다.
  • 같음 비교는 고정 엡실론이 아니라 상대 오차나 ulp 거리로, 0과 무한대를 따로 처리해서 한다.
  • 위험한 것은 덧셈·뺄셈이다. 큰 수와 작은 수의 덧셈(흡수), 비슷한 수의 뺄셈(상쇄)을 피하도록 식을 바꾸고, 많이 더할 때는 Kahan 합산이나 Math.sumPrecise·math.fsum을 쓴다.
  • 돈은 decimal 타입, 없으면 최소 단위 정수로 다룬다.

관련 노트: 데이터의 표현, 컴퓨터 산술 연산, 숫자와 Math, 개발자가 빠지는 함정 모음

원본 기록8

댓글

GitHub 계정으로 댓글을 남길 수 있어요.