브라우저 콘솔에서 0.1 + 0.2를 쳐 보면 0.30000000000000004가 나온다. Python에서 쳐도 똑같다. 두 언어가 같은 실수를 한 것이 아니다. 두 언어 모두 숫자를 같은 방식으로 저장하고, 그 방식에서는 이 값이 정답이다.
이 책은 프론트엔드에서 매일 마주치는 장면에서 출발해 컴퓨터의 한 층 아래로 내려간다. 장마다 질문 하나를 붙잡고, 필요하면 코드를 한 줄씩 실행해 보며 변수가 어떻게 바뀌는지 따라간다. 예제는 TypeScript와 Python 두 가지로 적었다. 코드 위의 언어 버튼으로 바꾸면 이 책의 모든 예제가 고른 언어로 보인다. 장 끝에는 조금 어려운 보너스 문제가 있다. 첫 장은 가장 아래 층인 숫자다. 개념 정리는 부동소수점 노트에 있다. 2진수·거듭제곱 같은 수학이 낯설면 『개발 수학』 Unit 1을 먼저 읽고 와도 된다.
읽는 법 몇 가지. 예측 문제는 모르면 찍어도 된다. 틀린 답과 그 뒤의 해설이 맞힌 답보다 오래 남는다. 답과 진행 기록은 이 브라우저에만 저장되고 누구에게도 보이지 않는다. 시간 제한은 없고, 장은 순서대로 읽지 않아도 된다. 식이 나오면 바로 옆에 말로 읽는 법과 숫자 예가 있으니, 식만 보고 멈추지 말고 그 다음 문장까지 읽는다.
0.1은 2진수로 끝나지 않는다
10진 소수의 자리는 1/10, 1/100, 1/1000이다. 의 자리는 1/2, 1/4, 1/8이다. 그래서 0.011은 1/4 + 1/8 = 0.375다. 10진수에서 1/3은 0.333…으로 끝나지 않는다. 1/10, 1/100 같은 자리를 아무리 더해도 정확히 1/3이 되지 않기 때문이다. 같은 일이 2진수에서는 1/10에 일어난다. 1/2, 1/4, 1/8 같은 자리를 아무리 더해도 정확히 0.1이 되지 않는다. 2진수로 딱 떨어지는 소수는 1/2, 3/4, 3/8처럼 분모가 2를 거듭 곱한 수(2, 4, 8, 16…)인 분수뿐이다. 2진 소수 자체가 낯설면 『개발 수학』 Unit 1의 "음수 지수와 2진 소수"를 먼저 본다.
JavaScript에서 0.5 + 0.25 === 0.75는?
선택지와 해설 보기
실험 10진 소수를 2진수로 바꾸기
2를 곱해 1 이상이 되면 그 자리 비트는 1, 아니면 0입니다. 소수 부분만 남겨 같은 일을 되풀이합니다. 처음 12자리만 구합니다. 아래 칸은 지금까지 구한 비트를 넷씩 끊어 그렸습니다. 초록 칸은 1, 파란 칸은 방금 구한 자리이고, 그 위 표시가 그 자리의 자릿값입니다.
let x = 0.1let bits = ''for (let i = 0; i < 12; i++) { x *= 2 const bit = Math.floor(x) bits += bit x -= bit}console.log('0.' + bits)
- x
- 0.1
- 해볼 것
- 0.375를 끝까지 실행해 남은 값이 0이 되는 것을 본다
- 0.1을 끝까지 실행해 되풀이되는 비트 무늬를 찾는다
0.5 + 0.25 === 0.75는 true다. 0.5·0.25·0.75 모두 분모가 2의 거듭제곱인 분수라 2진수로 정확히 담긴다. 0.375 = 3/8은 2진수로 0.011에서 끝난다. 0.1은 0.0001100110011…로 0011 무늬가 끝없이 되풀이된다. 컴퓨터는 이 무늬를 정해진 자리에서 잘라 반올림해 저장한다. 그래서 메모리 속 0.1은 0.1에 아주 가까운 다른 수다. 0.2도, 0.3도 그렇다. 반올림된 두 수를 더한 결과가 반올림된 0.3과 마지막 자리에서 어긋나는 것이다(Python 튜토리얼: 부동소수점 산술).
64비트 안의 숫자: IEEE 754
JavaScript의 number는 IEEE 754 배정밀도(double precision) 64비트 부동소수점(floating point) 형식이다(MDN: Number). Python의 float도 거의 모든 기기에서 같은 형식이다(Python 튜토리얼). 64비트는 세 부분으로 나뉜다.
| 부분 | 비트 수 | 하는 일 |
|---|---|---|
| 부호(sign) | 1 | 0이면 양수, 1이면 음수 |
| 지수(exponent) | 11 | 2의 몇 제곱을 곱할지. 1023을 빼서 읽는다 |
| 가수(fraction) | 52 | 맨 앞의 1. 뒤에 오는 2진 자리들 |
IEEE 754 형식은 10진수의 과학적 표기(scientific notation, 6.02 × 10²³)를 2진수로 옮긴 것이다. 식으로 쓰면 이다(읽는 법은 바로 아래에 있다). 앞자리 1은 늘 1이라 저장하지 않으므로, 실제 정밀도(precision)는 53비트다. 식은 이렇게 읽는다. 부호가 1이면 음수. 가수는 1. 뒤에 붙는 2진 소수. 는 2를 몇 번 곱할지인데, 11비트로는 0~2047을 적을 수 있고 음수 지수도 필요해서 가운데 1023을 0으로 본다. 그래서 저장된 값에서 1023을 빼고, 1023보다 작으면 그만큼 나눈다. 예를 들어 0.1은 저장된 지수가 1019라 1019 − 1023 = −4, 곧 2로 네 번 나눈다( = 1/2⁴ = 1/16. 음수 지수는 Unit 1). 가수 1001 1001 …을 2진 소수로 읽으면 1/2 + 1/16 + 1/32 + 1/256 + … = 0.6이라 1.가수는 1.6이다. 값은 1.6 × 2⁻⁴ = 1.6 ÷ 16 = 0.1이다.
실험 64비트로 보는 숫자
JavaScript의 number와 Python의 float는 둘 다 IEEE 754 배정밀도(64비트)입니다. 숫자를 고르거나 비트를 눌러 바꿔 보세요.
값: 0.1. 1.가수 × 2^(1019 - 1023) = 1.6 × 2^-4
- 해볼 것
- 0.1을 골라 가수 부분이 어디서 반올림됐는지 본다
- 부호 비트를 눌러 음수로 만든다
- 지수 비트를 모두 1로 바꿔 Infinity나 NaN을 만든다
0.1의 가수는 1001 1001 …로 되풀이되다가 마지막 네 자리가 1010으로 끝난다. 앞 절의 0.0001100110011…과 같은 0011 반복인데, 맨 앞 1 다음부터 네 자리씩 끊어 읽어서 1001로 보인다. 소수점을 맨 앞 1 뒤로 옮겨 적으면 0.0001 1001 1001… = 1.1001 1001… × 2⁻⁴이고, 가수에 저장되는 것은 1. 뒤의 1001 1001…이다. 원래 무늬라면 1001 다음에 1001…이 이어져야 한다. 그 뒤를 잘라 내며 반올림해 올라간 흔적이다. 메모리 속 0.1이 진짜 0.1보다 아주 조금 큰 이유가 여기 있다. 지수 11비트를 모두 1로 채우면 Infinity나 NaN이 된다. 이 둘도 숫자 형식 안에 자리를 정해 둔 특별한 값이다.
안전한 정수는 2⁵³ − 1까지
비트 n개로는 2를 n번 곱한 가짓수, 개의 값을 구별한다. 비트 하나면 2가지, 둘이면 4가지, 53개면 2⁵³가지다. 정밀도가 53비트라는 것은 정수를 2⁵³까지는 정확히 담지만 2⁵³ + 1은 담을 수 없어 2⁵³으로 반올림된다는 뜻이다. 2⁵³보다 작은 정수는 2진수로 53자리 이하라 가수(앞자리 1까지 53비트)에 통째로 들어가고, 2⁵³ + 1은 54자리라 마지막 1이 잘린다. 가수가 3비트뿐인 장난감 형식으로 보면 8 = 1.000 × 2³과 9 = 1.001 × 2³은 구별되지만, 16(1.000 × 2⁴)과 17(1.0001 × 2⁴)은 가수 자리가 하나 모자라 구별하지 못한다. 2⁵³ 자체는 담기지만 바로 옆 2⁵³ + 1과 구별되지 않으므로, 바로 옆 정수와 구별된다고 보장되는 '안전한 정수'(Number.MAX_SAFE_INTEGER)는 2⁵³ − 1 = 9007199254740991까지다(MDN). 그 위로는 표현 가능한 수 사이 간격이 2, 4, …로 벌어져 이웃한 두 정수를 구별하지 못한다(간격 위에 놓인 짝수 같은 수는 여전히 정확히 담긴다). 2 ** 53 + 1 === 2 ** 53이 true다.
실무에서는 큰 id에서 이 한계가 드러난다. 서버가 64비트 정수 id를 JSON 숫자로 보내면 JSON.parse가 그 값을 number로 바꾸며 뒷자리가 조용히 바뀐다. JSON.parse('{"id":9007199254740993}').id는 9007199254740992다. 큰 id는 문자열로 주고받거나, 계산이 필요하면 BigInt를 쓴다.
Python은 사정이 다르다. int는 자릿수 제한이 없는 정수라서 2 ** 53 + 1이 정확히 계산된다(Python 문서: 숫자 타입). 하지만 float로 바꾸는 순간 같은 한계에 걸린다. float(2 ** 53 + 1) == 2 ** 53은 True다. 백엔드가 Python이어도 JSON을 받는 쪽이 JavaScript라면 문제는 그대로다.
돈 계산과 소수 비교
함정 찾기 AI가 짠 장바구니 합계 코드, 어디서 숫자가 틀어질까?
“장바구니 합계에 10% 할인을 적용하고, 27달러면 무료 배송 배지를 띄우고, 금액을 소수 둘째 자리까지 보여 줘. 주문 id도 응답에서 꺼내 줘.”
의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 5개입니다.
그래서 금액은 센트·원 단위 정수로 계산하고 비교하며, 소수점 둘째 자리 표시도 그 정수 값에서 만든다. toFixed는 저장된 2진 값을 기준으로 반올림해 (2.55).toFixed(1)이 '2.5'가 되기 때문이다.
정수로 바꿀 수 없는 계산(이율, 환율)이라면 10진 소수를 그대로 다루는 도구를 쓴다. Python에는 표준 라이브러리 decimal이 있다(Python 문서: decimal). Decimal('0.1') + Decimal('0.2') == Decimal('0.3')은 True다. Decimal은 문자열로 만들어야 한다. Decimal(0.1)처럼 float를 넘기면 이미 반올림된 2진 값을 그대로 옮겨 온다. JavaScript에는 아직 표준 10진 소수 타입이 없어서 라이브러리를 쓰거나 정수 단위로 계산한다.
소수끼리 같은지 볼 때는 "충분히 가까운가"를 묻는다. Python에는 math.isclose(a, b)가 있다. 기본은 상대 오차(relative error) 1e-09(10억분의 1)다(Python 문서: math.isclose). JavaScript에서 흔히 쓰는 Math.abs(a - b) < Number.EPSILON은 1 근처에서만 맞는 기준이다. Number.EPSILON은 1과 그다음 표현 가능한 수의 차이라서, 큰 수에서는 너무 엄격하고 아주 작은 수에서는 너무 느슨하다(MDN: Number.EPSILON). 예를 들어 1e16 근처에서는 이웃한 두 수의 간격이 2라, Number.EPSILON(약 2.2e-16)보다 작은 차이는 아예 존재하지 않는다. 같지 않은 두 수는 늘 false다. 그래서 값의 크기에 비례하는 허용 오차(tolerance)를 쓴다(math.isclose처럼 상대 오차로 비교한다).
확인 문제 장을 덮기 전에 떠올려 보기
1. 2진 부동소수점으로 정확히 저장되는 수는?
2. JavaScript number에서 안전한 정수(바로 옆 정수와 구별된다고 보장되는 정수)의 최댓값은?
3. 장바구니 금액을 다루는 가장 안전한 기본 방법은?
더 풀어 보기(선택) · 0.1을 열 번 더해 정확히 1 만들기
0.1을 열 번 더하면 0.9999999999999999가 나온다. 수를 하나씩 더하는 반복문 구조는 그대로 두고, 더할 때마다 반올림으로 떨어져 나간 값을 따로 기억해 다음 덧셈에 돌려주면 오차를 크게 줄일 수 있다. 변수 하나를 더 써서 이 덧셈을 짜 보자.
그냥 더하기 → 0.9999999999999999 고친 덧셈 → 1
이 장을 언급한 노트
length가 왜 2인지 봅니다.