0.1 은 2진수로 끝나지 않는다
앞 글까지가 정수 이야기였습니다. 3, -42 처럼 소수점이 없는 수요.
정수는 담을 수 있는 값 안에서는 정확했습니다. int 에 5를 넣으면 정확히 5고,
문제는 범위를 벗어날 때만 생겼습니다.
이번에는 실수입니다. 0.5, 3.14, -0.001 처럼 소수점이 붙는 수입니다.
거리 · 속도 · 확률처럼 딱 떨어지지 않는 값에 쓰고, C# 에서는 float 과 double 로 담습니다.
앞에서 본 대로 float 은 4바이트, double 은 8바이트입니다.
그때 표에서 실수만 “범위” 가 아니라 “믿을 수 있는 자릿수” 라고 적어 뒀습니다. 그 이유가 이 글의 내용입니다.
실수는 정수와 다릅니다.
Console.WriteLine(0.1 + 0.2); // 0.30000000000000004
Console.WriteLine(0.1 + 0.2 == 0.3); // False
버그가 아닙니다. C# 만 그런 것도 아닙니다. 파이썬도 자바스크립트도 자바도 똑같이 나옵니다.
0.1 은 처음부터 정확하게 담기지 않았습니다.
실수는 값을 그대로 담지 않는다
정수는 비트에 값을 그대로 적었습니다. 실수는 그럴 수가 없습니다. 소수점이 어디에 오는지가 값마다 다르기 때문입니다.
그래서 과학적 표기법을 씁니다. 1억 2천만을 1.2 × 10⁸ 으로 적는 그 방식입니다.
2진수 버전으로 바꾸면 이렇게 됩니다.
0.15625 = 1.01 × 2⁻³
│ │
가수 지수
float 은 32비트를 이렇게 나눠 씁니다.
double 은 64비트에 같은 구조입니다. 지수 11비트, 가수 52비트로 넓어질 뿐입니다.
여기서 문제가 나옵니다. 가수는 23칸뿐입니다. 담을 숫자가 그보다 길면 나머지는 버려집니다.
0.1 은 2진수로 끝나지 않는다
10진수에서 1/3 을 소수로 적어보면 이렇습니다.
1/3 = 0.3333333333...
끝나지 않습니다. 어디선가 끊어 적을 수밖에 없고, 끊은 만큼이 오차가 됩니다.
2진수에서 0.1 에 똑같은 일이 일어납니다.
값을 바꿔가며 눌러보세요. 어떤 수는 2진수로 딱 떨어지고 어떤 수는 안 떨어집니다.
0.5 는 2진수로 0.1 입니다. 한 자리에서 끝나니 정확합니다.
0.25 는 0.01, 0.75 는 0.11 입니다. 전부 끝납니다.
0.1 은 이렇습니다.
0.1 (10진수) = 0.00011001100110011001100110011... (2진수)
0011 이 끝없이 반복됩니다. 10진수의 1/3 과 같은 상황입니다.
그런데 가수는 23칸(또는 52칸)뿐이니 거기서 잘립니다.
그래서 double 에 0.1 을 넣으면 실제로 들어가는 값은 이것입니다.
0.1000000000000000055511151231257827021182
0.1 이 아닙니다. 0.1 에 가장 가까운, 2진수로 표현 가능한 값입니다.
그래서 이런 일이 생긴다
조금씩 어긋난 값끼리 계산하면 어긋남이 드러납니다.
Console.WriteLine(0.1 + 0.2); // 0.30000000000000004
0.1 도 조금 크고 0.2 도 조금 큽니다. 둘을 더하니 0.3 보다 조금 커졌습니다.
그런데 0.3 자체도 정확하지 않아서(0.29999999999999998...) 두 값이 다릅니다.
더할수록 벌어집니다.
double sum = 0;
for (int i = 0; i < 10; i++) sum += 0.1;
Console.WriteLine(sum); // 0.9999999999999999
Console.WriteLine(sum == 1.0); // False
0.1을 열 번 더해도 1이 되지 않습니다.
== 로 비교하지 않는다
그래서 실수는 같은지를 == 로 묻지 않습니다.
차이가 충분히 작은지를 묻습니다.
EPS(엡실론)는 “이 정도 차이는 같은 것으로 치겠다” 는 기준입니다.
값의 크기에 따라 적절한 값이 달라지므로 상황에 맞게 정합니다.
게임에서 위치를 비교한다면 0.0001 정도면 충분한 경우가 많습니다.
float.Epsilon은 이 용도가 아닙니다. 그건 0에 가장 가까운 값이라 비교 기준으로 쓰기에는 너무 작습니다. 기준은 직접 정하세요.
float 과 double 중 무엇을 쓰나
| 크기 | 믿을 수 있는 자릿수 | |
|---|---|---|
float |
4바이트 | 약 7자리 |
double |
8바이트 | 약 15~16자리 |
C# 에서 소수점 리터럴의 기본은 double 입니다. float 에 넣으려면
5.75f 처럼 f 를 붙여야 하는 이유가 이것입니다.
그런데 유니티는 좌표도 시간도 대부분 float 입니다. 메모리와 GPU 때문입니다.
그래서 이런 성질을 알아두면 좋습니다.
float 의 정밀도는 값이 커질수록 나빠집니다. 가수가 23칸으로 고정인데
지수가 커지면 한 칸이 나타내는 간격이 그만큼 벌어지기 때문입니다.
| 좌표 | 표현할 수 있는 가장 가까운 두 위치의 간격 |
|---|---|
| 1 | 0.00000012 |
| 1,000 | 0.00006 |
| 10,000 | 0.00098 |
| 100,000 | 0.0078 |
| 1,000,000 | 0.0625 |
원점에서 100만 단위까지 나가면 6cm 단위로만 위치를 표현할 수 있습니다. 넓은 맵에서 멀리 나갔을 때 물체가 미세하게 떠는 현상이 이것입니다. 해결책은 정밀도를 올리는 게 아니라 원점을 옮기는 것입니다.
돈은 실수로 계산하지 않는다
금액은 float 이나 double 로 다루면 안 됩니다.
0.1 원이 정확하지 않은 통화 계산은 언젠가 1원이 어긋납니다.
C# 에는 이를 위한 타입이 따로 있습니다.
decimal price = 0.1m;
Console.WriteLine(price + 0.2m == 0.3m); // True
decimal 은 16바이트를 쓰고 2진수가 아니라 10진수 기반입니다.
그래서 0.1 이 정확합니다. 대신 느리고 범위가 좁습니다.
돈과 회계에만 쓰고, 좌표나 물리에는 쓰지 않습니다.
정리
- 실수는 값을 그대로 담지 않는다. 부호 · 지수 · 가수 세 조각으로 나눠 담는다.
- 가수 칸이 정해져 있어서, 2진수로 끝나지 않는 수는 거기서 잘린다.
0.1은 2진수로 끝나지 않는다. 10진수의1/3과 같은 상황이다.- 분모가 2의 거듭제곱인 수(
0.5·0.25·0.75)만 정확하다. - 실수는
==대신 차이가 충분히 작은지로 비교한다. float은 값이 커질수록 정밀도가 나빠진다. 멀리 나가면 원점을 옮긴다.- 돈은
decimal.