목차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.5 | 2.5 | −1.6 | 특징 |
|---|---|---|---|---|---|
| 0 쪽으로(버림) | 남는 자리를 잘라 낸다 | 1 | 2 | −1 | 단순하지만 오차가 크고 0 쪽으로 치우친다 |
| 0에서 먼 쪽 반올림 | 절반 이상이면 올린다(학교식) | 2 | 3 | −2 | 오차는 작지만 0에서 먼 쪽으로 치우친다 |
| 짝수 쪽 반올림(은행가 반올림) | 정확히 절반이면 결과가 짝수가 되는 쪽으로 | 2 | 2 | −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, Pythonmath.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) - 1 | expm1(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
- 원문 (새 창) floating-point-gui.de/
- 원문 (새 창) floating-point-gui.de/errors/rounding/
- 원문 (새 창) floating-point-gui.de/errors/comparison/
- 원문 (새 창) floating-point-gui.de/errors/propagation/
- 원문 (새 창) floating-point-gui.de/formats/exact/
- 원문 (새 창) floating-point-gui.de/formats/integer/
- 원문 (새 창) docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html
- 원문 (새 창) developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sumPrecise