목차
계산기로는 맞는 식이 코드에서는 틀린다. 가장 유명한 예부터 보고 시작한다.
0.1 더하기 0.2는 0.3이 아니다
거의 모든 언어에서 같은 결과가 나온다.
0.1 + 0.2 // 0.30000000000000004
0.1 + 0.2 === 0.3 // false언어 버그가 아니다. 파이썬도, 자바도, C도 같다. 소수를 담는 방식 자체가 그렇게 생겼다.
절반짜리 추로는 0.1을 못 만든다
양팔 저울에 추를 올려 무게를 맞춘다고 하자. 그런데 가진 추가 1/2kg, 1/4kg, 1/8kg, 1/16kg처럼 절반씩 작아지는 것들뿐이다.
이 추로 0.5kg은 정확히 맞춘다. 0.75kg도 맞는다(1/2 + 1/4). 그럼 0.1kg은?
절반씩 작아지는 추로는 0.1에 다가갈 수는 있어도 올라서지는 못한다. 컴퓨터가 소수를 담는 방식이 이 저울이다.
1/16을 올리면 모자라고, 1/8을 올리면 넘친다. 더 작은 추를 계속 얹어 다가갈 수는 있지만 정확히 올라서지는 못한다. 아무리 추를 잘게 나눠도 마찬가지다.
컴퓨터가 소수를 담는 방식이 이 저울이다. 소수점 아래를 1/2, 1/4, 1/8… 로 쪼개 담기 때문에, 2로 계속 나눠서 도달할 수 있는 값만 정확하다. 0.5·0.25·0.125는 되고 0.1·0.2·0.3은 안 된다.
우리에게 1/3을 십진 소수로 못 적는 것과 똑같은 사정이다. 0.333...을 어디서 끊든 정확한 1/3이 아니다. 어느 진법이든 못 적는 수가 생긴다. 다만 우리가 일상에서 쓰는 수들이 하필 십진법에 맞춰져 있어서, 컴퓨터 쪽만 이상해 보일 뿐이다.
그런데 왜 화면에는 0.1로 보이나
여기서 자연스러운 의문이 생긴다. 정확히 못 담는다면서 0.1을 출력하면 왜 0.1이 나올까.
보여 줄 때 반올림하기 때문이다. 저장된 실제 값은 0.1000000000000000055511...인데, 출력할 때 “이 정도면 사람이 0.1이라고 쓴 그 수겠지” 하고 다듬어 보여 준다.
그래서 오차는 평소에 숨어 있다가 계산이 겹칠 때 드러난다. 0.1 + 0.2는 각각의 미세한 오차가 합쳐지면서 반올림으로도 덮이지 않는 자리까지 밀려 나온 것이다.
큰 수로 갈수록 눈금이 성겨진다
부동소수점의 두 번째 성질은 덜 알려져 있는데 실무에서는 이쪽이 더 아프다. 표현할 수 있는 값의 간격이 일정하지 않다.
담을 수 있는 값의 간격은 일정하지 않다. 커질수록 성겨져서 더한 값이 아무 눈금에도 안 걸리는 지점이 온다.
0 근처에서는 눈금이 촘촘해 아주 작은 차이도 담는다. 그런데 수가 커지면 같은 자리 수로 훨씬 넓은 범위를 덮어야 하니 눈금 사이가 벌어진다. 어느 지점을 넘으면 +1을 해도 값이 그대로다. 더할 값이 눈금 사이에 끼어 버려서다.
이름이 “부동”소수점인 이유가 이것이다. 소수점의 위치가 고정되어 있지 않고 값의 크기에 따라 움직인다. 큰 수를 담을 때는 소수점을 오른쪽으로 밀어 정수 쪽 자리를 늘리고, 작은 수를 담을 때는 왼쪽으로 민다. 자리 수는 그대로인 채 어디를 정밀하게 볼지만 바꾸는 것이다.
앞 글에서 본 자바스크립트의 큰 수 문제가 여기서 나온다. 정수 타입 없이 이 방식으로 수를 담으니, 약 9천조를 넘어가면 눈금 간격이 1보다 커져 정수조차 정확히 못 센다.
같은지 비교하면 안 된다
이 두 성질에서 실무 규칙 하나가 곧장 나온다. 부동소수점 값을 ==로 비교하지 않는다.
// 위험: 거의 언제나 false
if (total == 0.3) { ... }
// 안전: 충분히 가까우면 같다고 본다
if (Math.abs(total - 0.3) < 1e-9) { ... }기준값(1e-9)은 다루는 값의 크기에 맞춰 정한다. 앞서 본 대로 큰 수에서는 눈금 자체가 성기니, 아주 작은 기준을 두면 영원히 같지 않다고 나온다.
반복문 조건에 쓰면 더 위험하다. for (double x = 0; x != 1.0; x += 0.1)은 1.0을 정확히 밟지 못해 영원히 돌 수 있다. 이런 자리에는 정수 카운터를 쓴다.
돈은 여기에 담지 않는다
가장 중요한 실무 규칙이다. 금액을 부동소수점으로 다루지 않는다.
이유는 지금까지 본 그대로다. 0.1이 정확하지 않은데 1,900원이니 10원 단위 할인이니 하는 계산을 백만 번 누적하면 오차가 눈에 보이는 자리까지 올라온다. 회계는 1원이 안 맞으면 안 맞는 것이다.
방법은 둘이다.
- 최소 단위 정수로 담는다. 원 단위, 센트 단위처럼 더 쪼갤 필요 없는 단위를 정하고 정수로 센다. 가장 단순하고 빠르다.
- 십진 소수 타입을 쓴다. 자바의
BigDecimal, DB의numeric·decimal처럼 십진법 그대로 담는 타입이다. 느리지만 우리가 종이에 쓰는 것과 같은 방식으로 계산한다.
DB 컬럼 타입에서도 같은 판단이 필요하다. float·double은 빠르고 작지만 금액을 담으면 안 되고, numeric은 그 반대다.
실무에서: 어디서 새는가
부동소수점 오차는 대개 한 번의 계산이 아니라 누적과 왕복에서 드러난다.
- 합계 - 작은 값을 수십만 번 더하면 오차가 쌓인다. 큰 값에 아주 작은 값을 계속 더하는 경우가 특히 나쁘다. 앞에서 본 대로 눈금 사이에 끼어 아예 반영되지 않을 수 있다.
- 왕복 - JSON으로 주고받으면 숫자가 십진 글자로 바뀌었다가 다시 이진 소수로 돌아온다. 양쪽 언어의 처리가 다르면 미세하게 어긋난다.
- 집계와 정렬 - 평균·분산 같은 값은 계산 순서만 바뀌어도 결과가 미묘하게 달라진다. 그 결과로 정렬 순서가 흔들리면 페이지를 나눠 줄 때 항목이 중복되거나 사라진다.
정리하면 실무에서 물어야 할 것은 하나다. 이 값이 정확해야 하는 값인가, 근사해도 되는 값인가. 좌표·물리량·머신러닝 가중치는 근사로 충분하다. 돈·수량·식별자는 아니다.
정리
- 컴퓨터는 소수를 절반씩 작아지는 추로 만든다. 그래서
0.1처럼 2로 나눠 못 닿는 값은 정확하지 않다. - 평소에 안 보이는 건 출력할 때 반올림하기 때문이다. 계산이 겹치면 드러난다.
- 표현 가능한 값의 간격이 일정하지 않다. 큰 수로 갈수록 성겨져서, 어느 지점부터는
+1도 반영되지 않는다. ==로 비교하지 않는다. 허용 오차를 두거나 정수로 센다.- 돈은 최소 단위 정수나 십진 타입으로 담는다. 이건 취향이 아니라 규칙이다.
- 판단 기준은 하나다. 정확해야 하는 값인가, 근사해도 되는 값인가.
여기까지가 수를 담는 이야기다. 다음 글부터는 글자로 돌아간다. 세상의 모든 글자에 번호를 매기는 일이 어떻게 시작됐는지 본다.