Floating Point Number의 진실 in C#

1996년 기아나 프랑스령 해변에서 Ariane 5 로켓이 발사되었다. 발사된지 40초가 채 되지 않아 폭발했다.
70억 달러의 개발 비용, 5억 달러 가치의 짐을 싫은 로켓이 순식 간에 공기 중으로 사라졌다. 

얼마지 않아 엔지니어들은 그 폭발 원인을 밝혀냈다. 그것은 바로 다름 아닌 Integer Overflow로 인한 것이었다.

로켓의 수직 속도를 저장하는 변수가 64 bit floating point number 였는데, 이것을 16 bit Integer로 Assign하는 과정에서 평소 테스트 때와는 다른 실제에서는 32767 을 넘어서게 되었고, 이것으로 인한 overflow가 연료를 극한으로 타도록 하는 Trigger가 되었던 것이었다.

이 경우를 보더라도 컴퓨터가 숫자를 어떻게 저장하고 사용하는가를 아는 것이 프로그래머들에게 꽤나 중요한 것임을 알 수 있다.

오늘은 그 중에서도 Floating Point Number, 부동 소숫점 수에 대해서 좀 생각해 보려고 한다.

Floating Point Number는 IEEE-754 표준으로 정의되는데, 32비트 기준으로 아래와 같은 구조로 저장된다.

S: 부호
M: Mentisa
E: Exponent

즉, 1 비트의 부호, 8 비트의 Exponent(2의 승수), 나머지 23비트의 Mantisa(소숫점 아래의 숫자) 형태로 수를 표현하고 저장한다.

이러한 Float의 저장 형태가 실제로 어떠한 현상들을 만들어내는지 한번 살펴 보자.

위의 변수들을 출력하면 각각 어떤 형태로 출력될까?
결과는 아래와 같다.

특이한 점은 i1 이다.
분명 42.0f와 10.0f를 곱했는데, integer로 type casting한 결과는 41이 나왔다.

왜 그럴까?

이것이 Floating Point Number가 실제로 저장되는 형태를 생각해야 하는 이유이다.
모든 십진수들이 IEEE-754의 Floating Point Number 형태 안에 정의 될 수 없다.
즉, 42는 32bit의 Floating Point Number에 정확하게 저장될 수 없는 수이다.

42가 실제로 메모리에 저장될 때는 아래 값이 저장된다. 참고

그러므로 우리가 4.2f 를 floating에 저장하는 것은 실제로는 4.2를 저장하는 것이 아닌 것이다.  위의 i1 이 42가 아닌, 41이 나온 이유가 바로 여기에 있다.
실제 41.99… 값이 저장되어 있기 때문에 당연히 1o을 곱한 후 integer casting하면 41이 된다.

다른 숫자에 대해서도 좀 더 살펴보자.

위에 Assign하고 있는 상수는 각각 0.3, 0.2, 0.1이 실제로 메모리에 Floating Point Number 형태로 저장되는 값이다. 그러나, 출력은 각각 0.1, 0.2, 0.3 이 출력된다. 즉, 실제 저장되는 값이 아닌 올림이 된 값이 출력된다.
소숫점 9째자리까지 정확하게 출력할 수 있는 방법을 사용하게 되면 아래처럼 출력이 된다.

42도 마찬가지이고, 0.1, 0.2, 0.3도 마찬가지로 에디터에서 모두 실제 저장된 정확한 값을 보여주지 않고 반올림을 한 값을 보여준다.

왜 그럴까?

이것은 C# 에디터에서 ToString 함수를 사용하게 되면 자연스럽게 round-off error를 무시한 반올림이 들어간 Float형태로 보여주기 때문이다. 어떻게 보면 거짓을 말하고 있는 것이라고 볼 수 있다.
이것은 Debug를 돌려서 variable inspector에서 보여지는 값도 마찬가지이다. 해당 디버거가 정확한 값을 보여주기보다 ToString() 의 반올림 값을 보여준다면 4.2, 0.1, 0.2, 0.3 형태로 보여지게 된다.
우린 그동안 에디터와 디버거에게 속고 살았다. OTL

IEEE-754 표준 방식에 따르면, 실제 Float은 9개의 십진수 자릿수(Decimal Digit)까지 표현 가능하다. Double 은 17개 Decimal Digit까지 표현 가능하다.  그 이하의 자리숫 차이는 컴퓨터가 구분해 낼 수 없다.
단적인 예로 아래 코드를 보자. 

위의 코드는 모두  True로써 Debug.Log가 출력이 된다.
왜냐하면 십진수 숫자(Decimal Number)가 부동 소숫점 수(Floating Point Number)로 변환될 때,  가장 가까운 Floating Point Number로 저장되기 때문이고, 또한 9자리 이후는 표현할 수 없는 수이기 때문에 그로인한 차이가 없게 되기 때문이다.

결국 Floating의 연산은 기본적으로 오류를 품고 있는 연산이라고 보아야 한다.
즉, 두 십진수 a, b의 더하기만 보더라도,

아래와 같은 추가적인 3가지 round-off error를 포함하고 있다.

실제 생활에서의 일어날 수 있는 상황에 대해서 살펴보자.
$4.99로 판매하고 있던 물건 17개를 구입했고, 대금을 지불했다고 하자.
계산은 철저하게 사람이 아닌 컴퓨터 코딩으로 했기 때문에 정확하리라고 생각하고 돈을 주며 보냈다.
그런데, 코드를 열어보니 아래와 같은 상황이었다.

$84.83가 지불되어야 했었는데, 실제로는 $84.82999가 지불 된 것이다. 실제 줘야 할 금액보다 적게 준 일이 일어났다. 이런 금액이 커지고 반복적이었다고 생각하면 아찔해진다.

또다른 한 예로 $100 하는 물건이 있는데, 여름 정기 세일로 10% 세일을 했다. 그래서 코딩으로 나온 가격표를 매겨 매장에 붙였다고 하자.

위처럼 $90가 아닌 $89가 나온다. 매장 직원이 욕좀 먹게 생겼다.

이건 어떨까? 16777216, 즉 1677만 7216 개의 판매량을 기록하고, 1개를 더 팔아서 1을 더했는데, 결과는 동일했다. 컴퓨터가 고장이 났다고 던저버렸다면? ㅎ

16777217은2^24 + 1로써 32bit 형태의 floating point number로 표현할 수 없는 숫자이다. 참고

이런 글들을 적다보면, 컴퓨터에게 그동안 속고 있었다는 생각도 들고, 반대로 컴퓨터가 참 바보같다는 생각도 들지 않는가?

Floating의 진실을 알아버린 우리는 어떻게 해야할까?

Floating Point Number의 특징과 한계를 명확하게 이해하고 쓰자.
조금 더 정밀한 숫자 계산이 필요한 것이라면 메모리를 아끼지 말고 double로 하자.
float의 integer casting은 매우 위험한 짓이다.
floating point number의 Equity Test는 무모한 짓이다. ( x == y )