한입 CS

숫자는 비트다: 0.1 + 0.2는 왜 0.3이 아닐까

2진 소수, IEEE 754, 안전한 정수(safe integer), 돈 계산

맨 아래 층부터 · 읽는 시간 약 18분

목차 · 진행 0%

    브라우저 콘솔에서 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진수로 바꾸기
    실험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
    출력
     
    1/63단계.
    • 해볼 것
    • 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)10이면 양수, 1이면 음수
    지수(exponent)112의 몇 제곱을 곱할지. 1023을 빼서 읽는다
    가수(fraction)52맨 앞의 1. 뒤에 오는 2진 자리들

    IEEE 754 형식은 10진수의 과학적 표기(scientific notation, 6.02 × 10²³)를 2진수로 옮긴 것이다. 식으로 쓰면 (−1)부호×1.가수×2지수−1023(-1)^{\text{부호}} \times 1.\text{가수} \times 2^{\text{지수} - 1023}이다(읽는 법은 바로 아래에 있다). 앞자리 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비트로 보는 숫자
    실험64비트로 보는 숫자

    JavaScript의 number와 Python의 float는 둘 다 IEEE 754 배정밀도(64비트)입니다. 숫자를 고르거나 비트를 눌러 바꿔 보세요.

    숫자
    부호 1비트 지수 11비트 가수 52비트

    값: 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가 짠 장바구니 합계 코드, 어디서 숫자가 틀어질까?
    함정 찾기AI가 짠 장바구니 합계 코드, 어디서 숫자가 틀어질까?
    내가 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을 열 번 더해 정확히 1 만들기

    0.1을 열 번 더하면 0.9999999999999999가 나온다. 수를 하나씩 더하는 반복문 구조는 그대로 두고, 더할 때마다 반올림으로 떨어져 나간 값을 따로 기억해 다음 덧셈에 돌려주면 오차를 크게 줄일 수 있다. 변수 하나를 더 써서 이 덧셈을 짜 보자.

    그냥 더하기 → 0.9999999999999999
    고친 덧셈  → 1

    이 장을 언급한 노트

    다음1장. 문자는 바이트다: 이모지 하나의 length는 왜 2일까숫자 다음은 글자입니다. 이모지 하나의 length가 왜 2인지 봅니다.

    『한입 CS』 0장. 숫자는 비트다: 0.1 + 0.2는 왜 0.3이 아닐까 · younggeun0. Claude와 함께 썼고, 저자가 아직 전체 내용을 검수하지 않은 초안입니다. 사실 주장은 공식 문서로, 예제 코드는 실제로 실행해 확인했습니다.

    보기 옵션