0.1 + 0.2는 왜 0.3이 아닐까: 부동소수점의 원리

JavaScript·Python·Java에서 0.1 + 0.2가 0.30000000000000004가 되는 이유를 IEEE 754 배정밀도 형식으로 설명하고, 비교·반올림·금액 계산에서 피하는 방법을 정리했습니다.

개발숫자

개발자 도구 콘솔에 0.1 + 0.2를 입력하면 0.30000000000000004가 나옵니다. JavaScript만 그런 것이 아닙니다. Python, Java, C#, Go도 같은 결과를 냅니다. 계산이 틀린 것이 아니라, 이 언어들이 숫자를 저장하는 방식이 같기 때문입니다.

컴퓨터는 소수를 2진수로 저장한다

대부분의 언어에서 소수가 있는 숫자는 IEEE 754 배정밀도(binary64) 형식으로 저장됩니다. JavaScript의 Number, Python의 float, Java와 C의 double이 모두 이 형식입니다. 64비트를 셋으로 나눠 씁니다.

부분 비트 수 역할
부호 1 양수·음수
지수 11 2의 몇 제곱을 곱할지
가수(소수부) 52 유효숫자. 맨 앞의 1은 저장하지 않으므로 실제로는 53비트 정밀도

10진수에서 1/3이 0.3333…으로 끝나지 않듯, 2진수에서는 분모가 2의 거듭제곱이 아닌 분수가 끝나지 않습니다. 0.1은 1/10이고 10에는 5가 들어 있으므로, 2진수로 쓰면 0.000110011001100…이 끝없이 반복됩니다. 52비트에서 잘라 반올림할 수밖에 없고, 실제로 저장되는 값은 0.1이 아닙니다.

입력 실제로 저장되는 값
0.1 0.1000000000000000055511151231257827021181583404541015625
0.2 0.200000000000000011102230246251565404236316680908203125
0.3 0.299999999999999988897769753748434595763683319091796875

0.1과 0.2는 조금씩 크게, 0.3은 조금 작게 저장됩니다. 둘을 더한 결과를 다시 가장 가까운 binary64 값으로 반올림하면 0.3의 저장값이 아니라 그다음 값이 됩니다.

0.1 + 0.2 = 0.3000000000000000444089209850062616169452667236328125

화면에 0.30000000000000004로 나오는 것은, 언어가 “이 값을 다시 읽었을 때 같은 값이 되는 가장 짧은 10진수”를 골라 보여 주기 때문입니다. 반대로 0.1이 0.1로 보이는 것도 같은 규칙 덕분일 뿐, 저장된 값이 정확히 0.1이어서가 아닙니다.

정수는 어디까지 정확한가

가수가 53비트이므로 2⁵³(9,007,199,254,740,992)까지의 정수는 모두 정확히 나타낼 수 있습니다. JavaScript의 Number.MAX_SAFE_INTEGER가 9,007,199,254,740,991인 이유입니다. 이보다 커지면 표현할 수 있는 정수 사이에 간격이 생깁니다.

9007199254740992 === 9007199254740993 // true

데이터베이스의 64비트 ID나 트위터(X) 게시물 ID처럼 큰 정수를 JSON으로 주고받을 때 문자열로 보내는 API가 많은 것도 이 때문입니다. JavaScript에서 큰 정수를 계산해야 한다면 BigInt를 씁니다.

실무에서 피하는 방법

1. ==로 비교하지 않는다

부동소수점 결과는 “충분히 가까운가”로 비교합니다.

import math
math.isclose(0.1 + 0.2, 0.3)  # True (기본 상대 오차 1e-9)
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON // true

Number.EPSILON(2⁻⁵², 약 2.22×10⁻¹⁶)은 1 근처에서의 간격입니다. 값이 크면 간격도 커지므로, 큰 수를 비교할 때는 값의 크기에 비례한 허용 오차(상대 오차)를 쓰는 편이 안전합니다.

2. 반올림은 표시할 때 한 번만

Math.round(1.005 * 100) / 100은 1.01이 아니라 1입니다. 1.005 역시 정확히 저장되지 않아 1.00499999…이기 때문입니다. 계산 중간에 반올림을 반복하면 오차가 쌓입니다. 계산은 그대로 두고, 화면에 보여 줄 때 toFixed()나 Intl.NumberFormat으로 한 번만 자릿수를 맞춥니다.

3. 돈은 부동소수점으로 계산하지 않는다

금액처럼 10진수 그대로 정확해야 하는 값은 두 가지 방법 중 하나를 씁니다.

  • 가장 작은 단위의 정수로 저장합니다. 원화는 원 단위, 달러는 센트 단위 정수로 다루면 덧셈·뺄셈에서 오차가 생기지 않습니다.
  • 10진수 전용 타입을 씁니다. Python의 decimal.Decimal, Java의 BigDecimal, 데이터베이스의 DECIMAL/NUMERIC 형입니다.

이때 생성 방법에 주의합니다. Java의 new BigDecimal(0.1)은 이미 오차가 들어간 double을 그대로 옮겨 0.1000000000000000055511…이 됩니다. 문자열로 new BigDecimal("0.1")처럼 만들어야 정확한 0.1입니다. Python도 Decimal("0.1")처럼 문자열을 넘깁니다.

버그가 아니라 약속

부동소수점은 넓은 범위의 수를 64비트에 담기 위해 정밀도를 조금 양보한 형식입니다. 과학 계산, 그래픽, 센서 값처럼 약간의 오차가 문제 되지 않는 곳에는 빠르고 충분히 정확합니다. 오차가 문제 되는 곳(비교, 반올림, 금액)만 알고 피하면 됩니다.

부동소수점 계산기에 숫자를 넣으면 binary64와 binary32로 저장했을 때의 부호·지수·가수 비트와 실제로 저장되는 10진수 값을 볼 수 있습니다. 0.1, 0.3, 9007199254740993을 차례로 넣어 보면 이 글의 내용을 직접 확인할 수 있습니다.

참고

  • IEEE Std 754-2019, IEEE Standard for Floating-Point Arithmetic
  • ECMAScript 명세(ECMA-262), Number 타입과 Number::toString
  • Python 문서, “Floating-Point Arithmetic: Issues and Limitations”

블로그로