0.1 + 1.1 == 1.2 는 True 이다.

편견
이 질문은, 소수점을 다루는 개발자라면 한번씩은 접해보는 문제입니다.
많은 사람들이 말하길, 0.1 + 1.1 == 1.2 는 False 라고 합니다.
그럼, 제가 질문을 살짝 바꿔 다시 여쭤보겠습니다.
0.1 + 1.1 == 1.2 는 False 가 확실한가요?
문제
본문은 Unity 엔진의 C# 문법을 기준으로 설명하겠습니다.
public class Test : MonoBehaviour
{
float a = 0.1f;
float b = 1.1f;
void Start()
{
float c = a + b;
Debug.Log(c == 1.2f); // result is true? false?
}
}잠시 스크롤을 멈추고, 코드를 보고 질문에 답해주시기 바랍니다.
위의 코드에서, Debug.Log(c == 1.2f) 의 출력은 True 와 False 중에 어떤 것으로 나오게 될까요?
5초 정도 시간을 드리겠습니다.
충분히 생각해보셨나요? 이 내용을 아는 분께선 5초라는 시간도 길었을 지도 모르겠습니다.
그럼 직접 실행시켜보겠습니다.
Result is True
그림의 아래쪽을 보니, 콘솔창에 True 라고 적혀있습니다.
최소한 저는 소수 + 소수 는 제대로 동작하지 않는다고 배웠기 때문에, False 라고 예측했습니다.
그렇다면, 컴퓨터와 그렇게 알려준 사람들 중 누가 거짓말을 하고있는걸까요?
저장
이러한 일이 일어난 이유를 알기 위해선, 컴퓨터가 어떤식으로 소수를 저장하는지 먼저 알아야합니다.
소수는 float 라는 자료형으로 값을 저장할 수 있고, 4 Byte 크기를 가집니다.
float는 아래의 그림과 같이 RAM 에 부호, 지수부, 가수부라는 공간으로 나누어 저장합니다.
Ram (4 byte, 32 bit)
32 bit 중 1 bit 는 값의 부호를, 8 bit 는 지수부, 남은 23 bit 는 가수부로 사용되는 모습을 확인할 수 있습니다. 부호는 알지만, 지수부와 가수부라는 어려운 단어를 보니 벌써부터 머리가 아픈 느낌이 듭니다.
머리와 눈이 조금 아플 수 있습니다만, 이것들을 순서대로 하나씩 알아보겠습니다.
예시
11.25 라는 소수가 있습니다. 이 숫자가 float 자료형에 어떻게 저장되는지 살펴보겠습니다.
1. 이진법 변환
현재 11.25 는 십진법으로 표기되어있습니다. 이것을 이진법으로 표현하겠습니다.
2. 정규화
가장 왼쪽에 있는 1 을 기준으로 하여, 바로 뒤에 점을 찍고, 옮긴 칸 수 만큼 지수를 곱합니다.
이 과정을 정규화 Normalization 라고 하며, 앞부분 1.01101 을 가수부, 2³ 를 지수부 라고 부릅니다.
- 가수부(Mantissa) : 실제 수치를 담고 있는 중요한 부분
1.01101 - 지수부(Exponent) : 가수부에 얼만큼 곱할건지 결정하는 부분
2³
3. 값 저장
가수부와 지수부로 분리하였으니, 이것을 램에 저장해보겠습니다.
첫번째 칸의 부호는, 양수일 땐 0 이고 음수일 땐 1 로 표기됩니다.
| 필드 | 계산값 | 저장값 (RAM) |
|---|---|---|
| 부호 (1 bit) | + |
0 |
| 지수부 (8 bit) | 3 |
10000010 |
| 가수부 (23 bit) | 1.01101 |
01101000000000000000000 |
잠깐! 여기서 읽는 것을 멈추시고, 표를 다시 한번 확인해보시기 바랍니다. 제가 숫자를 제대로 기입했나요?
저장된 가수부를 자세히 보면, 앞자리의 1 이 사라진 것을 알 수 있습니다.
지수부는 어떤식으로 계산이 된건지 모르겠지만, 10000010 라는 값으로 저장되어 있습니다.
컴퓨터는 왜 이렇게 저장했을까요?
지수부의 Bias
지수부는 소수점을 얼만큼 이동시킬지 결정하는 부분이라고 했습니다.
소수점을 오른쪽으로 이동하면 양의 지수, 왼쪽으로 이동하면 음의 지수가 필요합니다.
8 bit 로 표현할 수 있는 범위는 0 ~ 255 입니다.
그런데 음의 지수도 표현해야 하는데, 이 범위에는 음수가 없습니다.
그래서 127 을 기준점으로 정하고, 아래와 같이 나누어 사용합니다.
| 저장값 | 의미 |
|---|---|
0 |
특수값 |
1 ~ 126 |
음의 지수 |
127 |
0승 |
128 ~ 254 |
양의 지수 |
255 |
특수값 (∞, NaN) |
0 과 255 는 특수값으로, 지금은 몰라도 됩니다.
핵심은, 실제 지수에 127을 더한 값 이 지수부에 저장된다는 것입니다. 이 기준값 127 을 Bias 라고 부릅니다.
예시를 보면 더 쉽게 이해가 됩니다.
아까 예시에서 지수부에 10000010 이 저장된 이유가 바로 이것입니다.
3 + 127 = 130, 그리고 130 을 2진법으로 변환하면 10000010 이 됩니다.
가수부의 Hidden Bit / Implicit Bit
이번엔 가수부가 1.01101 이 아닌 01101 로 저장된 이유를 알아보겠습니다.
정규화 과정에서 한 가지 패턴이 있습니다. 정규화된 이진수는 항상 1. 로 시작 한다는 것입니다.
맨 앞의 1 은 어떤 수를 정규화해도 반드시 존재합니다. 그렇다면, 굳이 저장할 필요가 없지 않을까요?
그래서 이 1. 부분은 있다고 약속 하고, 실제 메모리에는 소수점 이하만 저장합니다.
CPU 가 값을 읽을 때 자동으로 맨 앞에 1. 을 붙여 계산합니다.
이렇게 숨겨진 1 을 Hidden Bit 또는 Implicit Bit 라고 합니다.
덕분에 가수부는 23 bit 를 가지지만, 숨겨진 1 을 포함하면 실제로는 24 bit 의 정밀도 를 가집니다.
공짜로 1 bit 를 얻은 셈입니다.
계산
그렇다면 저희가 궁금한 0.1 + 1.1 은 실제로 어떻게 계산될까요?
10진법 의 0.1 을 2진법 으로 변환하면, 아래와 같이 무한히 반복되는 순환소수가 됩니다.
float 의 가수부는 24 bit (Hidden Bit 포함) 이므로, 24번째 자리에서 반올림하여 저장됩니다.
이를 다시 10진법으로 변환하면 아래와 같이 됩니다.
같은 방식으로 1.1 도 정확히 표현할 수 없습니다.
이 둘을 더하면 아래와 같이 됩니다.
그런데 생각해보면, 수직선 위의 숫자는 무한히 존재하고, float 는 그 모든 값을 저장할 수 없습니다. 그래서 float 는 내부적으로 특정 값들만 미리 정해두고, 점처럼 띄엄띄엄 사용합니다.
계산 결과인 1.20000002533 은 float 가 정해둔 점들 중 하나가 아닐 수 있습니다.
예를 들어, C# 의 float 에선 1.20000004768 을 그 특정 값 중 하나로 두고 있다고 가정해봅시다.
그럴 경우 컴퓨터는 1.20000002533 에 가장 가까운 점인 1.20000004768 을 찾아서 저장합니다.
이 과정을 Rounding 이라고 합니다.
그리고 1.2f 를 float 에 저장할 때도 동일하게 반올림이 일어납니다.
결국 두 값은 완전히 동일합니다.
우연의 일치?
앞서 살펴봤듯이, 0.1f + 1.1f 의 Rounding 결과는 1.20000004768 입니다.
그런데 공교롭게도, 1.2f 를 float 에 저장할 때 사용하는 값도 1.20000004768 입니다.
정리하면 아래와 같습니다.
0.1f + 1.1f의 실제 계산 결과는1.20000002533입니다.- 이 값이
Rounding되어 가장 가까운float점인1.20000004768이 됩니다. 1.2f의 실제 저장값도1.20000004768입니다.- 결국
c == 1.2f는1.20000004768 == 1.20000004768이므로True입니다.
float a = 0.1f;
float b = 1.1f;
float c = a + b;
// c : 1.20000004768… (1.20000002533… 이 Rounding 됨)
// 1.2f : 1.20000004768… (float 에 저장된 가장 가까운 점)
Debug.Log(c == 1.2f); // True그렇다면, 이번 문단의 제목을 다시 읽어보겠습니다.
이건 정말 우연의 일치 일까요?
IEEE 754
IEEE (Institute of Electrical and Electronics Engineers)
IEEE 부동소수점 연산 표준(IEEE Standard for Floating-Point Arithmetic, IEEE 754)
지금까지 살펴본 float 의 동작방식은 버그나 우연이 아닙니다.
저희가 살펴본 이것들은 전부 IEEE 754 라는 국제 표준에 정의된 의도적이고 논리적인 동작입니다.
IEEE 754 는 1985 년에 제정된 부동소수점 연산 표준입니다.
32 bit를 어떻게 나눌 것인지Bias의 값을 얼마로 할 것인지- 반올림은 어떤 방식으로 할 것인지 (여러가지 방법이 있지만, 머리가 아프니 생략하겠습니다.)
Hidden bit를 사용할 것인지
전세계의 99% 가 이 국제표준을 사용하고 있습니다. 이 표준의 목적은 어떤 언어에서든 동일한 부동소수점 연산에 대해 동일한 결과를 보장하는 것입니다. 그렇다면 C#, Python, C++, JavaScript 등의 언어들에서 float 연산을 하면 동일한 결과가 나오고, 0.1f + 1.1f == 1.2f 는 어느 언어에서든 True 일까요?
안타깝게도 그렇지 않습니다. 표준을 따른다고 해서 모든 환경에서 동일한 결과가 나오는 건 아닙니다. JavaScript 는 모든 숫자를 double (64 bit) 로 처리하기 때문에, float (32 bit) 를 사용하는 C# 과는 정밀도 자체가 다릅니다. 또한 컴파일러 최적화, CPU 아키텍처, 런타임 환경 등에 따라서도 결과가 달라질 수 있습니다.
stackoverflow 에 따르면, 변수에 저장하느냐 안하느냐에 따라서도 결과가 달라질 수 있다고 합니다.
직접 계산식을 비교하면 False 지만, 변수에 저장한 후 비교하면 True 가 나오는 경우가 있다는 것입니다.
결론
여러분께 다시 물어보겠습니다.
0.1 + 1.1 == 1.2 는 False 가 확실한가요?
제가 대신 대답을 해보자면, “일반적으로 False 가 맞다” 라고 말할 것 같습니다.
이 글에서 말하고자 하는 것은, 무조건 False 라고 대답하는 것이 항상 맞는 논리는 아니라는 점입니다.
사람들과 책이 알려주는 것을 한번쯤 의심해보는 자세도 나쁘지 않다고 생각합니다.
결론은, 소수를 쓸 땐 조건문을 조금 더 신경쓰고, 특히 == 연산은 쓰지 않는 것이 좋겠습니다.
Result is false, But this may be a bias.
Bias 는 컴퓨터에선 지수를 양수로 만드는걸 의미하지만, 일반적인 의미는 편견, 편향이라는 뜻.