Skip to content
블로그로 돌아가기
튜토리얼

부동소수점 정밀도: 0.1 + 0.2가 0.3이 아닌 이유

부동소수점 정밀도 때문에 0.1 + 0.2가 어긋나는 이유와 IEEE 754 반올림 규칙, JS·Python에서 안전하게 비교하는 법. 무료 온라인 변환기 제공.

12 분 소요

부동소수점 정밀도: 0.1 + 0.2가 0.3이 아닌 이유

0.1 + 0.20.30000000000000004를 반환하는 이유는 0.10.2도 2진 부동소수점 수 안에는 존재하지 않기 때문입니다. double은 m × 2ⁿ 형태의 값만 담을 수 있는데 10분의 1은 2진법에서 무한히 반복되는 소수라서, 하드웨어는 대신 가장 가까운 표현 가능한 이웃 값을 보관합니다. 0.1 뒤에 실제로 저장된 double은 정확히 0.1000000000000000055511151231257827021181583404541015625입니다. 0.2 뒤의 값은 0.200000000000000011102230246251565404236316680908203125입니다. 이 두 저장값을 더하면 정확한 합은 표현 가능한 두 double 사이에 떨어집니다. IEEE 754는 더 가까운 쪽으로 반올림하고, 그 값이 0.3보다 아주 미세하게 큽니다.

답은 이것이 전부입니다. 부동소수점 정밀도는 유한하고, 십진 소수는 2진 격자에 좀처럼 들어맞지 않습니다. 게다가 연산할 때마다 반올림이 다시 일어납니다.

자바스크립트(JavaScript)만의 별난 동작도, CPU 결함도 아닙니다. 파이썬(Python), 자바(Java), C, Go, Rust, Swift, SQL의 FLOAT 컬럼에서도 같은 결과가 나옵니다. 모두 IEEE 754 위에서 돌아가기 때문입니다. 무료 IEEE 754 부동소수점 변환기에 아무 값이나 붙여넣으면 정확한 비트와 저장된 정확한 값을 자릿수 하나까지 확인할 수 있습니다.

남은 질문은 네 가지입니다. 오차가 어디서 생기는지, 언어가 왜 그것을 감추는지, == 대신 무엇을 써야 하는지, 정밀도가 곧 돈이 되는 곳에서 어떤 수치 타입을 골라야 하는지입니다.

60초로 이해하는 부동소수점 정밀도

세 가지 사실이 거의 모든 의외의 결과를 설명합니다.

  1. 2진 부동소수점은 m × 2ⁿ 형태의 수, 즉 2의 거듭제곱의 합만 표현할 수 있습니다.
  2. 0.1, 0.2, 0.3 같은 십진 소수는 그 형태가 아니므로 저장되는 순간 반올림됩니다.
  3. 모든 산술 연산은 그 결과를 다시 가장 가까운 표현 가능한 값으로 반올림합니다.
십진수정확히 표현 가능?이유
0.52⁻¹
0.252⁻²
0.752⁻¹ + 2⁻²
2.52 + 2⁻¹
1002⁵³ 미만의 모든 정수
0.1아니요1/10 — 분모에 인수 5가 있음
0.2아니요1/5 — 같은 문제
0.3아니요3/10 — 같은 문제

분수를 기약분수로 줄인 뒤 분모가 순수한 2의 거듭제곱이면 그 값은 정확합니다. 인수 5가 하나라도 남아 있으면 근삿값으로 저장됩니다.

0.1을 정확히 저장할 수 없는 이유

2진 소수점 뒤의 비트들은 각각 1/2, 1/4, 1/8, 1/16, 1/32 같은 가중치를 지닙니다. 이 조각들로 0.1을 조립해 보면 끝내 정확히 도달하지 못합니다. 1/16 = 0.0625, 여기에 1/32를 더하면 0.09375, 다시 1/256을 더하면 0.09765625로 매번 가까워지지만 딱 맞아떨어지지는 않습니다. 2진법에서 10분의 1은 0.0001100110011…이고 0011이 영원히 반복됩니다.

십진법도 분수만 다를 뿐 같은 문제를 안고 있습니다. 10진법으로 1/3을 쓰면 0.333…이 되고, 유한한 자릿수로는 절대 딱 떨어지지 않습니다. 아무도 이것을 십진법의 버그라고 부르지는 않습니다. 2진법은 그저 경계선을 다른 자리에 그을 뿐이고, 하필 1/10이 그 경계 바깥에 놓였을 따름입니다. 전개 과정을 더 길게 따라가고 싶다면 파이썬 공식 문서의 Floating Point Arithmetic: Issues and Limitations가 같은 내용을 단계별로 짚어 줍니다.

2진법은 밑이 2인 진법일 뿐이고, 16진법과 8진법도 똑같은 자릿값 산술로 동작합니다. 진법 변환기는 값이 진법 사이를 어떻게 오가는지 보여 주고, 진법 변환 가이드에서는 정수 쪽을 깊이 다룹니다. 2진법이 골치 아파지는 지점은 소수 부분입니다.

double이 실제로 저장하는 것

64비트 double은 부호 비트 1개, 지수 비트 11개, 가수 비트 52개의 세 필드로 나뉩니다. 가수에는 저장되지 않는 암묵적인 선행 1이 붙으므로 실제 유효숫자는 53비트가 되고, 이는 십진수로 약 15.95자리의 부동소수점 정밀도에 해당합니다. 지수는 1023의 바이어스를 적용해 저장됩니다.

어떤 언어든 평소 출력하는 것보다 많은 자릿수를 요구하면 근삿값의 정체가 드러납니다.

>>> 0.1
0.1
>>> f"{0.1:.20f}"
'0.10000000000000000555'
>>> from decimal import Decimal
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
(0.1).toFixed(20);      // '0.10000000000000000555'
(0.1).toPrecision(20);  // '0.10000000000000000555'

Decimal(0.1)이 가장 정직한 시각입니다. 이미 존재하는 double을 다시 반올림하지 않고 그대로 변환해 55자리를 전부 출력합니다. IEEE 754 부동소수점 변환기의 정확도 패널도 입력한 어떤 수에 대해 같은 전개를 출력하고, 그 옆에 입력값 대비 부호 있는 반올림 오차를 나란히 보여 줍니다.

0.30000000000000004 뒤에 숨은 정확한 산술

저장된 두 값을 반올림 없이 정확하게 더하면 다음과 같습니다.

0.1 →  0.1000000000000000055511151231257827021181583404541015625
0.2 →  0.200000000000000011102230246251565404236316680908203125
sum →  0.3000000000000000166533453693773481063544750213623046875

이 합 자체는 표현 가능한 double이 아닙니다. 2진 격자 위의 두 이웃 값 사이에 놓입니다.

below:  0.299999999999999988897769753748434595763683319091796875   (prints as 0.3)
above:  0.3000000000000000444089209850062616169452667236328125     (prints as 0.30000000000000004)

간격을 재 보면 특이한 사실이 드러납니다. 정확한 합은 아래쪽 이웃보다 2⁻⁵⁵2.776e-17만큼 크고, 위쪽 이웃보다 2⁻⁵⁵만큼 작습니다. 어느 쪽에도 더 가깝지 않은, 정확히 한가운데의 동점입니다.

이 상황에서는 가장 가까운 값으로 반올림하는 규칙이 기댈 거리 차이가 없으므로, IEEE 754는 동점 처리 규칙인 **짝수로 반올림(round-half-to-even)**을 적용합니다. 즉 가수의 마지막 비트가 0인 후보를 고릅니다. 아래쪽 이웃의 유효숫자는 5404319552844595로 홀수이고, 위쪽 이웃은 5404319552844596으로 짝수입니다. 짝수 쪽이 이겨서 비트 패턴 0x3FD3333333333334가 되는데, 이는 리터럴 0.3이 대응되는 double보다 1 ULP 큰 값이며 0.30000000000000004로 출력됩니다.

왜 하필 짝수일까요? 정확히 절반인 값을 늘 위로 올리면 긴 덧셈이 한 방향으로 치우칩니다. 짝수 쪽으로 번갈아 붙이면 연산을 아무리 반복해도 그 편차가 0 근처에 머뭅니다. 회계사들이 쓰는 은행가 반올림과 같은 규칙을 하드웨어 표준으로 굳힌 것이고, 인터넷에서 가장 유명한 부동소수점 반올림 오차가 ...04로 끝나는 직접적인 이유이기도 합니다.

반올림이 전혀 필요 없는 덧셈과 비교해 보세요.

0.5 + 0.25 === 0.75;   // true
0.1 + 0.2 === 0.3;     // false

0.5, 0.25, 0.75는 각각 2⁻¹, 2⁻², 2⁻¹ + 2⁻²입니다. 셋 다 정확하고 합도 정확하므로, 등호 비교가 학교에서 배운 산술 그대로 동작합니다. 변환기의 이웃 값 패널은 입력한 값 주변의 이전·다음 표현 가능한 값을 ULP 간격과 함께 보여 주므로, 0.3 주변의 격자를 직접 확인해 볼 수 있습니다.

언어가 오차를 감추기도 하는 이유

0.10.1로 출력되는데 0.1 + 0.2는 17자리로 출력됩니다. 저장 형식은 같은데 출력만 전혀 다릅니다.

최신 런타임은 최단 왕복(shortest round-trip) 포매팅을 사용합니다. 다시 파싱했을 때 동일한 double이 되는 가장 짧은 십진 문자열을 내보내는 방식입니다. "0.1"은 이미 0.1에 대해 저장된 값으로 유일하게 왕복하므로 그대로 보입니다. 합 0.1 + 0.2"0.3"이 대응되는 double과 다른 double이므로, 포매터는 문자열이 모호하지 않을 때까지 자릿수를 계속 붙여야 하고 그러려면 17자리가 전부 필요합니다. 이 계보는 1990년 데이비드 게이(David Gay)의 정확한 반올림 연구에서 시작됐고, Grisu에 이어 Ryu가 모든 표준 라이브러리에 넣을 만큼 속도를 끌어올렸습니다. 언어가 거짓말을 하는 것이 아닙니다. 그 값을 유일하게 식별하는 가장 짧은 표기를 보여 줄 뿐입니다.

언어별 동작

언어기본 실수 타입0.1 + 0.2 출력정확한 십진 대안
JavaScriptnumber (double 전용)0.30000000000000004내장 없음 — 정수 센트 또는 라이브러리
Pythonfloat (double)0.30000000000000004decimal.Decimal, fractions.Fraction
Javadouble0.30000000000000004BigDecimal (문자열로 생성)
C#double0.30000000000000004decimal — 128비트, 10진 기반
Gofloat640.30000000000000004 (변수를 거칠 때)math/big.Rat
Rustf640.30000000000000004rust_decimal 크레이트
SQLFLOAT / DOUBLE PRECISION0.30000000000000004NUMERIC / DECIMAL

이 표에서 두 행에는 함정이 숨어 있습니다.

Go는 상수를 임의 정밀도로 폴딩합니다. 리터럴 식은 컴파일 시점에 정확하게 평가된 뒤에야 변환되므로, 상수 0.1 + 0.2float64가 되기 전에 이미 정확한 0.3이 됩니다.

package main

import "fmt"

func main() {
	fmt.Println(0.1 + 0.2) // 0.3   — constant folded exactly, then converted
	a, b := 0.1, 0.2
	fmt.Println(a + b)      // 0.30000000000000004
	fmt.Println(a+b == 0.3) // false
}

자바의 BigDecimal은 double을 넘기면 그 오차를 고스란히 물려받습니다. 생성자는 넘겨받은 비트를 있는 그대로 충실히 변환합니다.

System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625

System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
// 0.3

SQL은 방언이 아니라 컬럼 타입에 따라 갈립니다.

-- PostgreSQL: a bare decimal literal is NUMERIC, which is exact
SELECT 0.1 + 0.2 = 0.3;                          -- t

-- Cast to binary floating point and equality fails
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8;  -- f

-- SQLite: REAL is a double, and the printed value hides it
SELECT 0.1 + 0.2;        -- 0.3
SELECT 0.1 + 0.2 = 0.3;  -- 0  (false)

위 SQLite 두 줄을 잠시 들여다보세요. 출력된 결과는 0.3이라고 하고 비교 결과는 아니라고 하는데, 둘 다 맞습니다.

부동소수점 비교: ==가 실패하는 이유와 Number.EPSILON이 허용 오차가 아닌 이유

0.1 + 0.2 === 0.3이 false인 이유는 좌변이 다른 double이기 때문입니다. 흔한 조언은 대신 허용 오차를 두고 비교하라는 것인데, 가장 자주 반복되는 형태의 그 조언은 틀렸습니다.

Math.abs(a - b) < Number.EPSILON;   // ⚠️ not a general-purpose comparison

Number.EPSILON2.220446049250313e-16, 즉 2⁻⁵²입니다. MDN의 정의에 따르면 1과 1보다 큰 가장 작은 double의 차이입니다. 다시 한 번 읽어 보세요. 이 값은 1.0 지점의 격자 간격이지 어디서나 통하는 오차 예산이 아닙니다. 부동소수점 간격은 2의 거듭제곱을 지날 때마다 두 배가 되므로, 상수 임계값은 그것을 측정한 구간 말고는 어디서도 맞지 않습니다.

1.0 근처에서는 우연히 잘 동작합니다.

Math.abs((0.1 + 0.2) - 0.3);                  // 5.551115123125783e-17
Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON; // true

같은 계산을 10억 배로 키우면 곧바로 무너집니다.

const a = (0.1 + 0.2) * 1e9;
const b = 0.3 * 1e9;

a === b;                          // false
Math.abs(a - b);                  // 5.960464477539063e-8
Math.abs(a - b) < Number.EPSILON; // false — yet a and b are adjacent doubles

1e9 부근에서 이웃한 double 사이의 간격은 대략 1.19e-7입니다. 1e9는 [2²⁹, 2³⁰) 구간에 들어가므로 그 지점의 ULP는 2²⁹⁻⁵² = 2⁻²³ ≈ 1.19e-7이고, Number.EPSILON보다 약 5억 배 큽니다. 그 크기 대역에서 실제로 생기는 반올림 오차는 임계값을 가볍게 넘어서므로 검사는 언제나 false가 됩니다. 반대로 1e-20 대역에서는 같은 상수가 너무 후해서 명백히 다른 값들을 같다고 판정합니다.

절대 허용 오차, 상대 허용 오차, ULP 거리

쓰임새가 서로 다른 세 가지 방법이 있습니다.

  • 절대 허용 오차|a − b| <= atol로 판정합니다. 값의 크기를 미리 알고 있을 때 적절하고, 0과 비교할 때 유일하게 동작하는 방식입니다. 0 주변에서는 상대 허용 오차가 항상 0이 되기 때문입니다.
  • 상대 허용 오차|a − b| <= rtol × max(|a|, |b|)입니다. 크기 대역이 달라져도 데이터에 맞춰 함께 늘어나지만, 0 근처에서는 무너집니다.
  • ULP 거리는 두 비트 패턴 사이에 표현 가능한 값이 몇 개나 있는지 셉니다. 가장 정밀하지만 가장 읽기 어렵습니다.

앞의 두 가지를 합치면 제대로 된 수치 계산 라이브러리들이 공통으로 쓰는 형태가 나옵니다.

function nearlyEqual(a, b, rtol = 1e-9, atol = 1e-12) {
  if (a === b) return true;            // handles Infinity === Infinity
  const diff = Math.abs(a - b);
  return diff <= Math.max(rtol * Math.max(Math.abs(a), Math.abs(b)), atol);
}

nearlyEqual(0.1 + 0.2, 0.3);                  // true
nearlyEqual((0.1 + 0.2) * 1e9, 0.3 * 1e9);    // true
nearlyEqual(0, 1e-15);                        // true
nearlyEqual(1, 1.0001);                       // false

파이썬은 이 기능을 표준 라이브러리에 담아 두었고, NumPy의 allclose도 같은 공식을 씁니다.

import math
math.isclose(0.1 + 0.2, 0.3, rel_tol=1e-9, abs_tol=1e-12)  # True
math.isclose(1e9, 1e9 + 1e-7, rel_tol=1e-9)                # True

더 엄밀한 판정이 필요하다면 비트를 단조 증가하는 정수 순서로 대응시킨 뒤 빼면 됩니다.

const view = new DataView(new ArrayBuffer(8));

function toOrdinal(x) {
  view.setFloat64(0, x);
  const i = view.getBigInt64(0);
  return i >= 0n ? i : -(1n << 63n) - i;   // make negatives order correctly
}

function ulpDistance(a, b) {
  const d = toOrdinal(a) - toOrdinal(b);
  return d < 0n ? -d : d;
}

ulpDistance(0.1 + 0.2, 0.3);                // 1n
ulpDistance((0.1 + 0.2) * 1e9, 0.3 * 1e9);  // 1n
ulpDistance(-0, 0);                         // 0n

참고로 정확한 ==가 언제나 틀린 것은 아닙니다. 2⁵³ 미만의 정수 값을 담은 double, 대입만 하고 연산한 적 없는 상수, 값이 정확히 0인지 확인하는 경우에는 문제없습니다.

정밀도가 곧 돈일 때: 알맞은 수치 타입 고르기

통화 금액과 2진 부동소수점은 궁합이 나쁩니다. 오차 하나하나는 크지 않지만 체계적이고 재현 가능해서, 감사자가 찾아내기 전까지는 눈에 띄지 않은 채 쌓입니다.

19.99 * 100;              // 1998.9999999999998
Math.round(19.99 * 100);  // 1999

(1.005).toFixed(2);       // '1.00'  — 1.005 is stored as 1.00499999999999989...

두 번째 예제에는 매년 여러 팀이 걸려 넘어집니다. toFixed가 반올림을 틀린 것이 아닙니다. 거기에 넘어간 값이 이미 1.005보다 작았을 뿐입니다.

정수 최소 화폐 단위

달러가 아니라 센트를 저장하세요. 1999가 $19.99를 뜻하고, 산술은 전부 정수 위에서 이뤄집니다. 나눗셈은 화면에 표시하는 순간에만 합니다.

const priceCents = 1999;             // $19.99
const subtotal = priceCents * 3;     // 5997 — exact

const totalCents = 1000;             // $10.00 split three ways
const share = Math.floor(totalCents / 3);   // 333
const remainder = totalCents - share * 3;   // 1 cent to allocate

주의할 점이 두 가지 있습니다. Number.MAX_SAFE_INTEGER(9007199254740991, 즉 2⁵³ − 1)를 넘어서면 자바스크립트 정수는 더 이상 정확하지 않으므로 BigInt를 사용하세요. 그리고 정수를 쓴다고 반올림 정책까지 정해지지는 않습니다. 나눗셈, 백분율, 세금 배분에는 남은 1센트를 어디로 보낼지 정하는 명시적인 규칙이 여전히 필요합니다.

decimal 타입

decimal 타입은 10진 자릿수를 그대로 저장하므로 십진 소수가 정확히 들어맞습니다. 반드시 문자열로 생성하고, 실수형 값을 그대로 넘겨서는 안 됩니다. 그러지 않으면 시작하기도 전에 2진 오차를 물려받습니다.

from decimal import Decimal

Decimal("0.1") + Decimal("0.2") == Decimal("0.3")   # True
Decimal(0.1)                                        # 0.1000000000000000055511151231257827021181583404541015625
Console.WriteLine(0.1m + 0.2m);          // 0.3
Console.WriteLine(0.1m + 0.2m == 0.3m);  // True

자바에는 BigDecimal이, C#에는 네이티브 128비트 decimal이, SQL에는 NUMERIC(12,2)가 있습니다. 모두 십진 소수 문제를 해결합니다. 그러나 어느 것도 1/3은 해결하지 못합니다. decimal 타입 역시 부동소수점 형식이고, 밑을 2에서 10으로 옮겼을 뿐이기 때문입니다.

선택 기준 표

사용 사례권장 타입이유
금액, 청구, 세금정수 최소 화폐 단위 또는 decimal정확한 십진 산술, 감사 가능한 반올림
과학, 물리double (FP64)15~16자리면 충분하고 라이브러리 지원이 가장 좋음
기하, 그래픽float 또는 double + 허용 오차이미 근사값이므로 엡실론으로 비교
ML 학습bfloat16FP32의 범위를 절반 메모리로
ML 추론, 저장FP16값이 정규화돼 있을 때 가수 비트가 더 많음
카운터, ID, 키64비트 정수 또는 BigInt애초에 실수형에 들어갈 값이 아님

오차 누적과 파국적 상쇄

반올림 오차 하나는 1e-17 수준이고 무해합니다. 그런 오차가 1만 개 쌓이면 장애 문의가 됩니다.

let total = 0;
for (let i = 0; i < 10000; i++) total += 0.1;

total;         // 1000.0000000001588
total - 1000;  // 1.588205122970976e-10

편차는 대체로 연산 횟수에 비례해 커집니다. 이 정도 크기의 루프에서는 눈에 띄지 않지만, 수백만 행을 매일 밤 집계하는 작업에서는 사정이 다릅니다.

파국적 상쇄

더 고약한 실패는 뺄셈입니다. 거의 같은 두 수를 빼면 앞자리가 서로 지워지고, 남은 결과는 이미 쌓여 있던 오차가 지배하게 됩니다. 절대 오차는 커지지 않습니다. 폭발하는 것은 상대 오차입니다. 오차 크기는 그대로인데 결과값만 아주 작아졌기 때문입니다.

교과서에 나오는 1패스 분산 공식 E[x²] − (E[x])²가 바로 이 함정으로 걸어 들어갑니다.

const data = [1e9, 1e9 + 1, 1e9 + 2];
const mean = data.reduce((s, x) => s + x, 0) / data.length;

// One-pass: E[x²] − (E[x])²
data.reduce((s, x) => s + x * x, 0) / data.length - mean * mean;  // 0

// Two-pass: centre the data first
data.reduce((s, x) => s + (x - mean) ** 2, 0) / data.length;      // 0.6666666666666666

실제 모분산은 2/3입니다. 1패스 공식은 정확히 0을 반환합니다. 조금 어긋난 정도가 아니라 완전히 다른 답인데, 두 피연산자가 모두 1e18 근처였고 그 차이가 마지막으로 저장된 비트 아래에 놓였기 때문입니다. Welford의 온라인 알고리즘은 평균과 편차 제곱합을 점진적으로 갱신하는 방식으로 이 문제를 피합니다.

근의 공식도 b² ≫ 4ac일 때 같은 약점을 드러내고, 해법의 성격도 같습니다.

const a = 1, b = 1e8, c = 1;
const disc = Math.sqrt(b * b - 4 * a * c);

(-b + disc) / (2 * a);   // -7.450580596923828e-9  — about 25% wrong
(2 * c) / (-b - disc);   // -1e-8                  — correct

두 줄 모두 같은 근을 계산합니다. 첫 줄은 거의 같은 두 수를 빼고, 둘째 줄은 그런 뺄셈이 아예 필요 없도록 식을 정리했습니다. 상쇄와 오차 한계를 다룬 표준 참고 문헌은 지금도 Goldberg의 What Every Computer Scientist Should Know About Floating-Point Arithmetic입니다.

Kahan 합산

Kahan의 보정 합산은 덧셈마다 버려지는 하위 비트를 따로 기록해 두었다가 다음 반복에서 되먹입니다.

function kahanSum(values) {
  let sum = 0;
  let compensation = 0;
  for (const value of values) {
    const y = value - compensation;
    const t = sum + y;
    compensation = (t - sum) - y;   // the bits that fell off
    sum = t;
  }
  return sum;
}

kahanSum(new Array(10000).fill(0.1));   // 1000  — exactly

파이썬에서는 이 기능을 거저 쓸 수 있습니다. math.fsum([0.1] * 10_000)1000.0을 반환하고, CPython 3.12부터는 내장 sum()도 Neumaier 보정을 사용하므로 sum([0.1] * 10_000)은 정확합니다. 반면 명시적인 += 루프는 여전히 1000.0000000001588로 흘러갑니다.

긴 덧셈, 금액 집계, 수치 적분에는 보정 합산을 사용하세요. 값이 몇 개뿐이거나 이미 정확한 타입으로 옮겼다면 굳이 쓸 필요가 없습니다.

가정을 무너뜨리는 특수 값들

IEEE 754는 우리가 기대하는 규칙을 따르지 않는 값들을 위해 별도의 비트 패턴을 예약해 두었습니다.

NaN === NaN;          // false — mandated by the standard
Number.isNaN(NaN);    // true
[NaN].indexOf(NaN);   // -1   (uses ===)
[NaN].includes(NaN);  // true (uses SameValueZero)

-0 === 0;             // true
Object.is(-0, 0);     // false
1 / -0;               // -Infinity

1e308 * 10;           // Infinity — overflow never throws
Infinity - Infinity;  // NaN
Number.MIN_VALUE;     // 5e-324 — the smallest subnormal double

NaN은 설계상 자기 자신을 포함한 모든 값과 같지 않다고 비교되며, 덕분에 실패한 계산이 정상 결과인 척할 수 없습니다. 검사에는 Number.isNaN()이나 파이썬의 math.isnan()을 사용하세요.

오버플로는 더 조용하고 더 위험합니다. ±Infinity를 반환한 뒤 그대로 진행하면서 이후 계산으로 계속 번져 나가고, 결국 누군가 차트가 온통 비어 있는 것을 발견할 때가 되어서야 드러납니다. 음의 0은 별개의 비트 패턴이지만 === 아래에서는 여전히 +0과 같습니다. 음수의 언더플로나 -1 * 0에서 생기며, 나눗셈이나 Object.is로만 그 존재가 드러납니다.

비정규수(subnormal)는 0과 가장 작은 정규 수 사이의 빈틈을 메웁니다. 지수 필드가 전부 0이면 암묵적인 선행 1이 사라지고 가수가 0을 향해 점진적으로 줄어드는데, 덕분에 서로 다른 두 부동소수점의 차가 정확히 0으로 반올림되는 일이 없습니다. 대가는 정밀도 하락이고, 일부 하드웨어에서는 성능이 급격히 떨어지기도 합니다. IEEE 754 부동소수점 변환기의 특수 값 칩을 누르면 ±0, ±Infinity, NaN, 가장 작은 비정규수가 곧바로 입력되므로 각 비트 패턴을 직접 살펴볼 수 있습니다.

float vs double vs FP16 vs bfloat16 비교

같은 표준이지만 범위와 부동소수점 정밀도에 배정하는 예산이 다릅니다.

형식전체 비트지수가수대략적인 십진 자릿수최대 유한값주요 용도
binary16 (FP16)16510~3.365504GPU 추론, 저용량 저장
bfloat161687~2.4~3.39 × 10³⁸ML 학습
binary32 (float)32823~7.2~3.40 × 10³⁸그래픽, 센서, GPU
binary64 (double)641152~15.9~1.80 × 10³⁰⁸그 밖의 모든 곳의 기본값

지수 비트는 범위를 넓히고, 가수 비트는 정밀도를 높입니다. FP16은 예산을 정밀도에 몰아 쓰고 최댓값이 65504에서 끊기는데, 실제 그래디언트 값과 충분히 가까운 범위라 Infinity로 넘치는 오버플로가 일상적인 위험 요소가 됩니다. bfloat16은 반대쪽을 택해 FP32의 지수 비트 8개를 그대로 유지하고 가수 비트 7개로 버팁니다. 덕분에 FP16이라면 넘쳐 버릴 값도 무리 없이 지나가고, 학습에 손실 스케일링이 필요한 경우도 드뭅니다.

기본값은 double로 두세요. 메모리나 대역폭 병목을 측정으로 확인한 뒤에 더 좁은 형식으로 내려가고, 같은 값을 형식별로 변환기에 넣어 폭을 줄일 때 치르는 대가를 직접 비교해 보세요.

부동소수점 정밀도를 다루는 실전 규칙

  • 계산으로 얻은 부동소수점에는 절대 ==를 쓰지 마십시오. 데이터 크기에 맞춘 절대 허용 오차와 상대 허용 오차의 조합을 사용하세요.
  • Number.EPSILON을 임계값으로 취급하지 마십시오. 이 값은 1.0 지점의 격자를 설명할 뿐 그 밖의 어떤 것도 말해 주지 않습니다.
  • 금액은 정수 최소 화폐 단위나 decimal 타입으로 보관하고, decimal은 항상 문자열로 생성하세요.
  • 긴 덧셈은 보정하세요. Kahan, Neumaier 또는 math.fsum을 사용하면 됩니다.
  • 거의 같은 값을 빼는 수식은 허용 오차를 조이지 말고 식 자체를 정리하세요.
  • 사람이 볼 출력은 명시적으로 포매팅하세요. toFixed, f-string, printf 무엇이든 좋습니다. 기본 repr 결과를 그대로 사용자에게 내보내지 마십시오.
  • 서비스 경계를 넘길 때는 숫자를 문자열이나 정수로 보내세요. double을 거치는 JSON 왕복은 소리 없이 값을 잃습니다.
  • 통화 컬럼에는 FLOAT이 아니라 NUMERIC을 고르세요. 출시 후에 이를 바로잡으려면 마이그레이션과 정합성 대사 작업이 따라옵니다.
  • 값이 말이 안 되어 보일 때는 비트 단위로 생각하세요. 같은 습관이 비트 연산 완벽 가이드의 바탕이기도 하고, 이 습관이 있으면 부동소수점의 수수께끼는 직접 검산할 수 있는 산술 문제가 됩니다.

자주 묻는 질문

부동소수점 연산은 망가진 것인가요?

부동소수점 연산은 망가지지 않았습니다. IEEE 754를 정확히 따를 뿐입니다. 이 표준은 실수를 유한한 개수의 2진 자릿수로 표현하는데, 0.1 같은 십진 소수는 정확한 2진 형태가 없으므로 가장 가까운 표현 가능한 값이 저장됩니다. 망가진 쪽은 모든 십진 소수가 딱 들어맞으리라는 기대입니다.

파이썬은 왜 0.1은 0.1로, 0.1 + 0.2는 0.30000000000000004로 출력하나요?

파이썬의 repr는 최단 왕복 포매팅을 사용합니다. 다시 파싱했을 때 동일한 double이 되는 가장 짧은 십진 문자열을 출력하는 방식입니다. "0.1"은 이미 자신의 double을 유일하게 식별합니다. 합 0.1 + 0.2"0.3"이 대응되는 double과 다른 값이므로, 포매터는 모호함을 없애기 위해 17자리를 전부 내보내야 합니다.

두 부동소수점을 비교할 때 Number.EPSILON을 쓰면 안 되는 이유는 무엇인가요?

Number.EPSILON(약 2.22e-16)은 1과 그다음 double 사이의 간격이지 어디서나 통하는 오차 예산이 아닙니다. 부동소수점 간격은 2의 거듭제곱을 지날 때마다 두 배가 되므로, 1e9 부근에서는 이웃한 double이 약 1.19e-7만큼 떨어져 있습니다. 그곳에서 실제로 생기는 반올림 오차는 EPSILON을 넘어서기 때문에 비교가 항상 false가 됩니다. 상대 허용 오차나 조합형 허용 오차를 사용하세요.

마지막에 반올림한다면 금액 계산에 부동소수점을 써도 되나요?

마지막에 반올림한다고 해서 부동소수점 금액 계산이 구제되지는 않습니다. 오차는 덧셈, 곱셈, 여러 단계의 배분을 거치며 쌓이고, 반올림하는 시점이 달라지면 합계도 달라집니다. 19.99 * 100은 이미 1998.9999999999998이 나옵니다. 감사에는 재현 가능한 정확한 산술이 필요합니다. 정수 센트, decimal, BigDecimal, 또는 SQL NUMERIC을 쓰세요.

double에 정확히 저장할 수 있는 십진수는 어떤 값인가요?

형식의 정밀도 안에서 정수 m, n에 대해 m / 2ⁿ로 표현되는 값뿐입니다. 여기에는 0.5, 0.25, 0.125, 0.75, 2.5와 2⁵³ 이하의 모든 정수가 들어갑니다. 먼저 분수를 기약분수로 줄여 보세요. 0.1, 0.2, 0.3, 0.7처럼 분모에 인수 5가 남아 있으면 그 값은 근삿값으로 저장됩니다.

0.1 + 0.2 === 0.3은 false인데 0.5 + 0.25 === 0.75는 true인 이유는 무엇인가요?

0.5, 0.25, 0.75가 각각 2⁻¹, 2⁻², 2⁻¹ + 2⁻²라서 모두 정확히 표현되고, 그 합에도 반올림이 필요 없기 때문입니다. 반면 0.1, 0.2, 0.3은 저마다 근삿값으로 저장되며, 앞의 두 값을 더한 결과를 반올림하면 0.3이 대응되는 double보다 1 ULP 큰 값에 도달합니다.

모든 프로그래밍 언어에서 이런 일이 일어나나요?

IEEE 754 2진 부동소수점을 기반으로 하는 언어라면 모두 같은 동작을 보입니다. 자바스크립트, 파이썬, 자바, C, C++, Go, Rust, Swift, SQL의 FLOAT이 여기에 해당합니다. 차이가 나는 것은 기본 출력 정밀도, 그리고 C#의 decimal이나 파이썬의 decimal 모듈처럼 정확한 십진 타입이 언어에 기본으로 들어 있는지 여부입니다.

태그: floating-point ieee-754 javascript python numbers