너멜엠 NumLM블로그 목록

시즌 3 — 사용자에서 제작자로 · 번외

컴퓨터는 왜 0.1 + 0.2를 0.3이라고 답하지 못할까? — 부동소수점을 그림으로 뜯어보기

스마트폰 계산기는 0.3이라고 답합니다. 그런데 프로그래밍 언어 대부분은 0.30000000000000004를 돌려줘요. 버그가 아니라, 컴퓨터가 수를 저장하는 방식 그 자체입니다.

부동소수점·IEEE 754 · 이진수·11분 읽기

컴퓨터는 왜 0.1 + 0.2를 0.3이라고 답하지 못할까 — 네이비 배경에 0.1 + 0.2 = 0.30000000000000004 수식과 0.1의 이진 전개 0.00011001100110011… 이 반복되는 시즌 3 번외편 대표 이미지

🧪 이 글의 모든 차트는 정지 화면입니다. 본문에 나오는 식을 직접 두드려 보고 정확 십진 모드와 부동소수점 모드를 번갈아 켜 보는 것은 글 끝에 붙여 둔 무료 계산기에서 할 수 있습니다.

⚡ 3분 요약

👉 한 문장으로 — 0.1 + 0.2 ≠ 0.3은 컴퓨터의 고장이 아니라 "2진수로는 0.1을 정확히 적을 수 없다"는 사실의 결과입니다. 그래서 너멜엠(NumLM)은 사칙연산을 분수로 계산하는 무료 계산기를 만들어 두었어요 — 같은 식을 정확 모드와 부동소수점 모드로 나란히 돌려 보면 이 글의 내용이 손에 잡힙니다.

지금 스마트폰을 꺼내 계산기를 열고 0.1 + 0.2를 눌러 보세요. 0.3이 나올 겁니다. 당연하죠. 이번엔 브라우저에서 개발자 도구를 열거나, 파이썬을 켜고 같은 식을 넣어 보세요. 십중팔구 이렇게 나옵니다.

>>> 0.1 + 0.2
0.30000000000000004

처음 보면 계산기가 고장 난 것 같습니다. 그런데 이건 특정 언어의 버그가 아니에요. 파이썬도, 자바스크립트도, 자바도, C도, 엑셀 내부도 똑같습니다. 얼마나 유명하면 0.30000000000000004.com이라는 도메인이 따로 있고, 거기에 언어 수십 종의 출력이 나란히 정리돼 있을 정도예요.

오늘은 이 열일곱 자리 숫자가 어디서 왔는지를 끝까지 따라가 봅니다. 시즌 3의 번외편이지만 주제는 본편과 이어져요 — 컴퓨터가 "수"를 어떻게 다루는지 모르면, 그 위에 얹힌 통계도 인공지능도 결국 남의 이야기가 되니까요.

컴퓨터는 0.1을 모릅니다.
0.1에 가장 가까운 어떤 수를 알 뿐이에요.

STEP 10.5는 되는데 0.1은 안 되는 이유

우리가 10진수로 1/3을 적으면 0.3333…이 끝없이 이어집니다. 3으로 나눈 몫을 10진수 소수로는 정확히 적을 수 없기 때문이에요. 이건 수학의 문제가 아니라 표기법의 문제입니다. 분수 1/3로 적으면 아무 문제 없으니까요.

컴퓨터는 2진수로 적습니다. 10진수 소수 자리가 1/10, 1/100, 1/1000…을 뜻하듯, 2진수 소수 자리는 1/2, 1/4, 1/8, 1/16…을 뜻해요. 그래서 분모가 2의 거듭제곱인 분수만 딱 떨어집니다.

실제로 손으로 바꿔 보면 눈에 보입니다. 소수를 2진수로 바꾸는 방법은 "2를 곱해서 1을 넘으면 1을 적고 1을 빼고, 아니면 0을 적기"를 반복하는 거예요.

10진수 0.1을 2진수로 바꾸면 끝나지 않는다
2를 곱해 정수부를 떼어내는 과정 — 여섯 번째 줄에서 두 번째 줄과 똑같은 상태(0.2)로 되돌아온다
끝나는 수 — 분모가 2의 거듭제곱 0.5 = 1/2 0.1₂ 0.25 = 1/4 0.01₂ 0.75 = 1/2 + 1/4 0.11₂ 2진 소수 자리 = 1/2, 1/4, 1/8, 1/16 … 끝나지 않는 수 — 0.1 = 1/10 (10 = 2 × 5) 0.1 × 2 = 0.20 0.2 × 2 = 0.40 0.4 × 2 = 0.80 0.8 × 2 = 1.61 0.6 × 2 = 1.21 0.2 × 2 = 0.4 0 ← 2번째 줄과 같은 상태로 복귀 = 여기서부터 영원히 반복 0.1₁₀ = 0.0 0011 0011 0011 0011 …₂ 민트색 네 자리가 무한 반복

여섯 번째 줄을 보세요. 0.2가 다시 나왔습니다. 앞에서 이미 지나온 값이니, 그 뒤로는 0011이 영원히 되풀이됩니다. 정리하면 이렇습니다.

0.110진 = 0.0 0011 0011 0011 0011 …2진  (0011이 무한 반복)

10진수를 쓰는 우리가 1/3을 정확히 적지 못하듯, 2진수를 쓰는 컴퓨터는 1/10을 정확히 적지 못합니다. 0.1도, 0.2도, 0.3도, 0.7도 전부 그래요. 우연히도 우리가 일상에서 가장 많이 쓰는 소수들입니다.

STEP 264칸에 욱여넣기 — IEEE 754라는 약속

무한히 반복되는 수를 유한한 메모리에 담을 수는 없습니다. 그래서 전 세계 컴퓨터는 IEEE 754라는 표준(1985년 제정, 현재 2019년판)에 따라 수를 잘라서 저장해요. 가장 널리 쓰이는 배정밀도(double, 64비트)는 64칸을 이렇게 나눠 씁니다.

과학에서 쓰는 3.14 × 108 표기와 같은 구조예요. 알맹이(3.14)와 자릿수(108)를 따로 저장하는 겁니다. 그래서 이름이 "소수점이 떠다니는 수", 부동소수점(floating point)입니다.

여기서 핵심은 알맹이가 52칸뿐이라는 점입니다. 0.1의 무한한 2진 전개는 52칸에서 잘리고, 잘린 자리는 가장 가까운 값으로 반올림돼요. 그 결과 실수 직선 위에서 표현할 수 있는 값이 띄엄띄엄한 눈금이 되고, 0.1은 그 눈금 위에 없습니다.

표현할 수 있는 값 사이에는 틈이 있다 — 0.1은 그 틈에 빠진다
64비트 배정밀도가 실제로 저장할 수 있는 값(회색 눈금)과 진짜 0.1(빨간 점)의 위치
① 0 부터 1 까지 — 눈금은 균일하지 않다 (0 근처가 촘촘) 0 1 0.1은 이 근처 ② 0.1 부근을 1경 배로 확대하면 — 이웃한 눈금 사이 간격 2⁻⁵⁶ ≈ 0.0000000000000000139 저장 가능한 값 저장 가능한 값 진짜 0.1 0.09999999999999999167… 0.10000000000000000555… 더 가까운 오른쪽 눈금으로 반올림 → 이 값이 "0.1"로 저장된다 눈금 간격은 수가 커질수록 벌어진다 — 1 부근 2⁻⁵² ≈ 0.00000000000000022, 4,503,599,627,370,496(2⁵²) 이상에서는 아예 1 이상이 되어 정수조차 건너뛴다.

차트의 숫자를 글로 다시 적어 둡니다. 64비트 double이 0.1이라고 부르는 값의 정확한 십진 전개는 이렇습니다.

0.1 → 0.1000000000000000055511151231257827021181583404541015625
0.2 → 0.200000000000000011102230246251565404236316680908203125

둘 다 우리가 입력한 값보다 아주 조금 큽니다. 파이썬 공식 문서도 튜토리얼 한 절을 통째로 할애해 이 값을 그대로 보여 주며 설명해요 — 화면에 0.1이라고 찍히는 건 "이 긴 수를 짧게 되돌려 쓴 표시"일 뿐이라고요.

한 가지 더. 눈금 간격은 수의 크기에 비례해 벌어집니다. 0.1 부근에서는 약 1.4 × 10-17이지만 1 부근에서는 2.2 × 10-16이고, 252(약 4,503조)를 넘으면 간격이 1보다 커져서 정수마저 건너뜁니다. 큰 수를 다루는 프로그램이 "1을 더했는데 값이 안 변한다"는 현상을 만나는 이유가 이것입니다.

STEP 30.30000000000000004가 만들어지는 네 단계

이제 재료가 다 모였습니다. 0.1 + 0.2 안에서 실제로 벌어지는 일은 네 단계예요.

0.1 + 0.2 안에서 벌어지는 네 단계
입력 → 저장(반올림) → 덧셈 → 다시 반올림 → 표시. 값은 전부 실제 64비트 double의 정확한 십진 전개
① 입력한 0.1과 0.2를 64비트에 담는다 — 둘 다 조금 큰 값으로 반올림된다 0.1 → 0.1000000000000000055511151231257827… 0.2 → 0.2000000000000000111022302462515654… ② 두 값을 더한다 (여기까지는 오차 없이 정확) 0.3000000000000000166533453693773481063544750213623046875 ③ 합도 64비트에 담아야 한다 — 그런데 이 값은 눈금 정확히 한가운데. 규칙(짝수 쪽으로)에 따라 위로 올라간다 아래 눈금 0.2999999999999999888977697537484346… ← 0.3을 입력하면 저장되는 값 위 눈금 0.3000000000000000444089209850062616… ← 0.1+0.2가 도착하는 값 ④ 사람이 읽게 되돌려 쓴다 → 0.3과 다른 값이므로 짧게 못 줄인다 → 0.30000000000000004

③단계가 이 글에서 제일 재미있는 대목입니다. 두 근삿값의 합은 0.30000000000000001665…인데, 이 값은 0.3 아래 눈금과 위 눈금의 정확히 한가운데에 떨어져요. 딱 절반이라 "가까운 쪽으로"라는 규칙만으로는 정할 수 없습니다. 이때 IEEE 754는 가수의 마지막 비트가 짝수인 쪽을 고르라고 정해 두었고(round half to even), 그 짝수 쪽이 하필 위쪽이었습니다. 한 칸 위로 올라간 대가가 뒤에 붙은 4예요.

④단계도 중요합니다. 화면에 0.30000000000000004라고 찍힌 건 그 자체가 저장된 값이라서가 아니라, 저장된 값을 다시 읽었을 때 같은 값으로 돌아오는 가장 짧은 십진수가 그것이기 때문이에요. 0.1을 입력했을 때 화면에 0.1이라고 나오는 것도 같은 규칙 덕분입니다 — 저장된 진짜 값은 앞에서 본 55자리짜리 긴 수인데도요.

🔗 같은 이유로 벌어지는 일들 — sin(30°)가 0.5가 아니라 0.49999999999999994로 나오고, cos(90°)가 0이 아니라 6.12e-17로 나오며, √2 × √2가 2.0000000000000004가 되는 것. 전부 같은 뿌리입니다. 웹 계산기에서 이 값을 그대로 보면 대부분 "사이트가 고장 났나?"라고 생각하지, "IEEE 754 반올림이구나"라고 생각하지 않죠.

STEP 40.0000001초가 28명의 목숨을 앗아간 날

"소수점 열일곱째 자리쯤이야" 싶겠지만, 이 오차는 쌓입니다. 1991년 2월 25일, 사우디아라비아 다란의 미군 막사에 스커드 미사일이 떨어져 28명이 사망했습니다. 요격을 맡은 패트리엇 포대는 그 미사일을 탐지하고도 추적하지 못했어요. 미 회계감사원(GAO)이 이듬해 펴낸 조사 보고서(GAO/IMTEC-92-26)의 제목이 원인을 그대로 말합니다 — "소프트웨어 문제가 시스템 실패로 이어졌다."

보고서에 따르면 패트리엇의 내부 시계는 시간을 1/10초 단위의 정수로 셉니다(…32, 33, 34). 그리고 이 정수를 실수로 바꿔 속도·위치 계산에 쓰는데, 레지스터가 24비트뿐이라 그 변환은 24비트보다 정밀할 수 없다고 GAO는 적었어요. 앞에서 본 대로 1/10은 2진수로 무한소수입니다. 그 정밀도 한계 안에서 시스템이 실제로 들고 있던 1/10은 이 값이었습니다(수치는 미네소타대 더글러스 아널드의 사례 정리).

시스템이 다룬 1/10 = 0.099999904632568359375
한 번 셀 때마다 생기는 오차 = 0.000000095초

1억분의 9.5초. 무시해도 될 것 같지만 시계는 1초에 열 번 셉니다. 한 시간이면 3만 6천 번, 100시간이면 360만 번이에요. 곱해 보면 0.3433초 — GAO 보고서 부록의 오차 표와 소수점 넷째 자리까지 일치합니다(직접 검산).

오차는 쌓인다 — 패트리엇 내부 시계의 누적 오차
수치 출처: GAO/IMTEC-92-26 부록 II 표 · 다란 포대는 100시간 넘게 연속 가동 중이었다
00.10.20.3 시계 오차 (초) 0시간50100시간 100시간 · 0.3433초 매 틱 0.000000095초 × 100시간 = 360만 번 GAO 보고서 부록 II — 연속 가동 시간별 가동 시간 시계 오차 구역 이동 8시간0.0275초55m 20시간0.0687초137m 48시간0.1648초330m 72시간0.2472초494m 100시간0.3433초687m 구역이 50% 이상 어긋나면 추적 불가 (GAO) 사고 당일 다란의 알파 포대는 100시간 넘게 연속 가동 중이었고, 스커드는 마하 5로 날아왔다.

차트의 수치를 글로도 남겨 둡니다. GAO 보고서 부록의 표는 연속 가동 8시간에 0.0275초, 20시간에 0.0687초, 48시간에 0.1648초, 72시간에 0.2472초, 100시간에 0.3433초의 시계 오차를 기록했습니다. 그 옆 칸에는 이 오차가 만드는 거리도 적혀 있어요 — 각각 55m, 137m, 330m, 494m, 그리고 687m입니다.

왜 거리가 문제냐면, 패트리엇은 레이더가 목표를 한 번 발견하면 다음 순간 그 물체가 있을 좁은 구역만 다시 살피기 때문입니다(range gate). 시계가 0.3433초 틀리면 그 구역이 687m 옆의 엉뚱한 하늘을 가리켜요. GAO는 이 구역이 50% 이상 어긋나면 시스템이 스커드를 추적하지 못한다고 적었습니다. 스커드는 마하 5, 시속 3,750마일(초속 약 1,676m)로 날아옵니다. 사고 당일 다란의 알파 포대는 100시간 넘게 꺼지지 않고 돌아가고 있었어요.

더 서늘한 대목은 따로 있습니다. 이 문제는 이미 알려져 있었어요. GAO에 따르면 미군은 1991년 2월 11일 이스라엘 측으로부터 "8시간 연속 가동하면 추적 구역이 20% 어긋난다"는 데이터를 받았고, 2월 21일에는 "너무 오래 연속 가동하면 부정확해질 수 있다"는 취지의 메시지가 각 포대에 발송됐습니다. 그리고 문제를 고친 소프트웨어가 다란에 도착한 것은 2월 26일 — 사고 다음 날이었습니다. 부동소수점 오차 이야기가 교과서에 꼭 등장하는 이유예요. 이건 이론이 아닙니다.

STEP 5직접 만들어 보고 알게 된 것 — 계산기 안에 분수를 넣다

지난 9월 2일, 저희 팀은 웹용 범용 계산기를 하나 만들었습니다. 시작은 단순한 시장 조사였어요. 트래픽 상위 계산기 사이트들을 하나씩 열어 보며 약점을 정리했는데, 목록의 다섯 번째 항목이 이것이었습니다 — "부동소수점 노이즈를 그대로 노출한다." 자바스크립트의 기본 실수 연산을 그대로 쓰면 0.1+0.2 → 0.30000000000000004, sin(30°) → 0.49999999999999994가 화면에 그대로 찍힙니다. 일반 사용자에게 이건 "고장"으로 읽히고요.

그래서 정확 십진 모드를 1순위 차별점으로 잡았는데, 여기서 예상 밖의 벽을 만났습니다. 흔히 쓰는 임의정밀도 라이브러리(BigNumber 계열)를 붙이면 0.1 + 0.2는 해결되지만, 1/3은 여전히 0.3333…3333이더군요. 자릿수를 64자리로 늘려도 순환소수는 순환소수입니다. 자릿수를 늘리는 건 더 촘촘한 눈금일 뿐, 눈금 밖의 수를 담아 주지는 않아요. 이 글의 STEP 2가 말한 그대로였습니다.

결론은 "소수를 더 잘 쓰자"가 아니라 "소수를 쓰지 말자"였습니다. 저희는 외부 라이브러리 없이 BigInt 유리수(분수) 엔진을 직접 구현했어요. 모든 수를 분자 / 분모 꼴의 정수 쌍으로 들고 다니는 방식입니다. 0.1은 1/10으로, 0.2는 1/5로 저장되고, 덧셈은 통분해서 3/10. 화면에는 0.3. 반올림이 끼어들 자리가 아예 없습니다. 1/3도 1/3 그대로 두었고요.

삼각함수는 조금 더 까다로웠습니다. sin·cos는 본질적으로 무리수를 내놓으니 분수로 담을 수 없거든요. 그래서 값 구조체에 π 채널을 하나 더 달았습니다 — 값을 "(분자/분모) × π0 또는 1"로 표현해, π/6을 근삿값 0.5235987…이 아니라 기호 그대로 들고 다니는 겁니다. 그러면 각도가 30°의 배수인지를 정확하게 판정할 수 있고, 그 지점의 값(0, ±1/2, ±1)을 표에서 꺼내 줄 수 있어요. 그래서 이 계산기에서는 sin(30°)도 sin(π/6)도 정확히 0.5, cos(90°)는 정확히 0입니다.

물론 만능은 아닙니다. √2나 ln 2는 어떤 분수로도 적을 수 없으니 그때는 부동소수점으로 계산하고, 값 앞에 ≈ 기호를 붙여 "이건 근사치입니다"라고 화면이 정직하게 말하게 했어요. 그래서 이 계산기의 규칙은 한 문장입니다 — 표시 문자열이 값을 정확히 나타내지 못하면 ≈를 붙이고, 그렇지 않으면 붙이지 않는다.

그리고 사람도 계산을 틀립니다 — QA에서 잡힌 것

구현이 끝나고 검수 단계에서, 스펙 문서에 사람이 손으로 적어 둔 기대값을 하나씩 산술 재검증했습니다. 통계 모드 테스트 항목에 12 15 19 22의 표본표준편차가 √18 ≈ 4.2426으로 적혀 있었는데, 직접 계산해 보니 평균은 17, 편차는 −5·−2·+2·+5, 제곱합은 54가 아니라 58이었습니다. 옳은 값은 √(58/3) ≈ 4.396968652757(표본)과 √(58/4) ≈ 3.807886552932(모집단)였고요. 같은 방식으로 시나리오 예시의 합계(486,000 → 90,000)를 포함해 스펙 문서에서 총 3건의 산술 오류가 나왔습니다. 코드는 전부 맞았고, 틀린 건 사람이 적은 기대값이었어요. 부동소수점 오차를 없애자고 만든 도구였는데, 정작 검산이 필요했던 쪽은 사람이었다는 게 이 프로젝트에서 제일 오래 남은 장면입니다.

STEP 6그래서 어떻게 다뤄야 하나 — 네 가지 처방

부동소수점은 결함이 아니라 거래입니다. 아주 넓은 범위의 수를 아주 빠르게 계산하는 대가로 마지막 몇 자리의 정확성을 내주는 거래예요. 대부분의 계산에서는 남는 장사고요. 문제는 그 거래 조건을 모르고 쓸 때 생깁니다. 상황별 처방은 이렇습니다.

반대로, 모르고 있으면 이 오차는 계산기 화면의 사소한 흠집이 아니라 회계 장부의 1원 차이, 100시간 뒤의 0.3433초가 됩니다. 차이는 정밀도가 아니라 알고 쓰느냐에서 갈립니다.

✅ 30초 복습 — 오늘 배운 것

0.1 + 0.2 = 0.3 이라고 답하는 계산기, 직접 만들어 뒀습니다

너멜엠(NumLM) 도구의 범용 계산기는 사칙연산을 부동소수점이 아니라 분수(유리수)로 계산합니다.
정확 십진 모드에서는 0.1+0.2가 0.3, 1/3은 1/3 그대로, sin(30°)는 정확히 0.5.
수 표시를 부동소수점 모드로 바꾸면 이 글에서 본 0.30000000000000004가 그대로 나옵니다 — 두 모드를 번갈아 보는 것이 이 글의 실습입니다.
가입도 설치도 없고, 계산은 전부 브라우저 안에서만 이뤄집니다.

🧮 계산기 열고 0.1 + 0.2 눌러보기 →

tools.numlm.com/everyday/calculator

한 발 더 들어가고 싶다면

참고 자료

본문의 2진 전개·정확한 십진 전개·누적 오차 값은 전부 직접 계산해 검증했고, 표준과 사건의 사실관계는 다음 원문을 기준으로 썼습니다.

※ 1994년 펜티엄 FDIV 버그는 이 글과 자주 함께 언급되지만 원인이 다릅니다 — 나눗셈 회로의 조회표 항목이 빠진 하드웨어 결함이었지, IEEE 754 표현 오차가 아닙니다(Edelman, SIAM Review 39(1), 1997). 밴쿠버 증권거래소 지수 절사 사건은 원문 확인이 되지 않아 이 글에서 제외했습니다.

마무리 — 계산기를 믿되, 어디까지 믿을지는 알고

0.1 + 0.2 = 0.30000000000000004는 컴퓨터가 서툴러서 생긴 실수가 아닙니다. 유한한 그릇에 무한한 수를 담아야 하는 상황에서 가능한 가장 합리적인 타협이고, 그 타협의 규칙(IEEE 754)은 40년 넘게 전 세계가 공유해 온 약속이에요. 그래서 이 오차는 예측 가능하고, 예측 가능하니 다룰 수 있습니다.

저희가 계산기 하나를 만들며 배운 것도 결국 같은 이야기였습니다. 정확한 값이 필요한 자리(사칙연산·분수·30°의 배수)는 분수로 정확히 계산하고, 정확할 수 없는 자리(√2·로그·일반각 삼각함수)는 근사임을 화면에 정직하게 표시하는 것. 계산기가 할 수 있는 최선은 틀리지 않는 것이 아니라 어디까지가 정확한지를 숨기지 않는 것이더군요.

오차를 없앨 수는 없습니다.
어디에 있는지 아는 것이 그 대신입니다.

너멜엠(NumLM) — 확률부터 LLM까지, AI를 올바르게 이해하고 활용하기 위한 자기주도 학습 플랫폼

🔗 numlm.com

#부동소수점 #IEEE754 #이진수 #계산오차 #프로그래밍기초 #컴퓨터과학 #계산기

※ 이 글은 LLM 도구를 활용해 초안을 작성한 뒤, 본문의 모든 수치는 직접 계산해 검산하고 인용한 표준·보고서는 원문을 확인해 편집했습니다. 본문에 사실과 다른 부분을 발견하시면 댓글로 알려주시면 즉시 반영하겠습니다.