[Javascript] 소수점 반올림, 그리고 부동소수점 보정 - Math.round(1.005 * 100)/100 == 1.01 은 true 인가?

부동소수점의 이해

자바스크립트에서 다음 계산 결과는 얼마일까요?

0.2 - 0.3 + 0.1

유사한 결과를 표시하는 계산식이 많이 있지만 의도하는 결과는 동일합니다.

답부터 말하면 다음과 같습니다.

2.7755575615628914e-17

지수로 표현되어 있어서 숫자가 커보이지만 아주 아주 작은 숫자, 정확하게는 아주 작은(0에 가까운) 부동소수점 숫자입니다.

우리의 상식으로는 0이어야 하지만 0이 아닙니다.

왜 0이 아닌가에 대한 의문이 생겼다면, 컴퓨터가 표현하는 2진수 부동소수점 숫자에 대한 이해가 없기 때문입니다.

앞의 계산식을 순서를 바꿔서 계산을 하면 어떻게 될까?

0.2 + 0.1 - 0.3

우리의 상식은 0이거나, 또는 0이 아니라면 앞의 계산식 결과와 같아야 하지만 실제 계산 결과는 다음과 같습니다.

5.551115123125783e-17

아주 작은 부동소수점 숫자지만 앞의 계산식 결과와는 또 다른 결과가 됩니다.

이 오차는 자바스크립트 부동소수점 숫자에서만 발생하는 현상은 아닙니다. 모든 프로그래밍 언어의 부동소수점 숫자 연산에서 공통적으로 발생하는 오차입니다. 그러니까, 자바스크립트가 이상한 게 아니라 원래 부동소수점 숫자 표현과 연산은 이런 아주 작은 숫자만큼의 오차가 발생합니다.

자바스크립트는 소수점이 있는 실수 표현을 할 때 부동소수점 실수를 사용(double 형)하며, IEE754 표준에 따라 배정도 실수 표현(Double-precision Floating-point Format)을 사용합니다. 자바스크립트에서 구현이 이상하게 된게 아니라 부동소수점 표현에 대한 구현 표준 가이드를 따라 구현된 것입니다.

아주 작은 소수점 오차가 발생하는 원인은 배정도 부동소수점 숫자 표현을 할 때, 제한된 길이의 데이터(64bit) 안에서 아주 긴 소수점 표현을 가능하게 하기 위해서 구현을 해다보니 근삿값을 사용할 수밖에 없는 것입니다.

수학적으로 설명을 하면 10진수를 2진수로 표현을 할 때, 10진수 숫자가 2로 딱 나누어 떨어지지 않음으로 인해서 발생하는 숫자 표현의 오차입니다.

부동 소수점 표현을 할 때 오차가 발생하는 원인은 2가지입니다.

  • 소수점 15자리를 넘어서면 오차가 발생합니다. 부동소수점 표현의 가수부 범위 최대는 10진수 소수점 15자리까지입니다.
  • 2진수로 표현을 할 때 2로 나누어 떨어지지 않는 경우입니다. 즉, 무한 소수가 돼서 한없이 소수점 자리가 길어집니다.

이런 경우 근사한 값으로 소수점 10진수 값이 저장되고, 이 값이 우리가 연산 결과로 보게 되는 앞서의 0이 아닌 소수점 결괏값이 되는 것입니다.

입실론(EPSILON)과 소수점 근삿값

먼저 입실론은 자바스크립트의 Number 객체에 저장된 상수값입니다.

Number.EPSILON으로 값을 얻을 수 있고, 실제 값은 다음과 같습니다.

0.0000000000000002220446049250313(2.220446049250313e-16)

입실론의 정의는 0보다 큰 가장 작은 부동소수점 값입니다.

그러니까 2진수로 표현할 수 있는 0보다 큰 가장 작은 소수점 값입니다.

바꾸어 말하면 2진수로는 0과 입실론 사이의 소수점 값은 표현할 수도 계산할 수도 없다는 뜻입니다.

아무리 작은 숫자여도 입실론보다는 커야 합니다.

앞서의 예에서 0.1은 이진수로는 무한히 나누어지는 무한 소수입니다. 따라서 소수점 15자리 밑으로는 위아래에 근사한 2진수 값 중의 하나로 표현해야 합니다.

여기서 글 제목에 있는 Math.round(1.005 * 100)/100 계산식의 결과를 확인해보겠습니다.

Math.round()는 소수점 반올림 값을 구하는 자바스크립트 함수입니다.

소수점 둘째 자리에서 반올림을 하기 위해 우리가 가장 흔하게 사용하는 방법은 *100을 한 값을 반올림한 후, 다시 /100을 하는 것입니다.

수학을 배운 우리의 예상대로라면 1.005 * 100 -> 100.5 -> Math.round(100.5) -> 101 -> 101/100 = 1.01 이 되어야 합니다.

Math.round(1.005*100)/100

실제 결과 값은 1.01이 아닌 정수 1이 반환됩니다.

결론부터 말하면 Math.round(1.005*100) 결과에서 이미 100이 됩니다. 그리고 그에 앞서 1.005*100은 예상인 100.5와 달리 100.49999999999999 가 됩니다. 앞서 설명한 무한 소수 부동소수점 표현에 따른 결과입니다.

이 아주 작은 숫자의 오차를 줄이기 위해 입실론을 활용합니다.

가장 작은 숫자인 입실론 값을 소수점 값에 더해서 반올림 위의 값이 되도록 보정합니다.

1.005+Number.EPSILON

입실론은 더한 결과 값은 1.0050000000000001 이 됩니다. 이 값은 1.005보다 큰 가장 작은 이진수 표현의 부동소수점 값이 됩니다.

그리고

Math.round((1.005+Number.EPSILON)*100)/100

계산식은 예상한 대로 1.01의 값을 반환합니다.

1.005에 입실론 값을 더하면 1.0050000000000001 이 되면서 1.005보다는 큰 최소 값의 2진수 표현 가능한 실수가 됩니다.

그럴듯하지만, 이 방법은 오류를 발생시킬 가능성을 내포하고 있습니다. 경우에 따라 근사한 값이 입실론이 보정하는 값의 범위를 넘어서는 경우 오히려 없는 보정 오류를 만들어 낼 수 있습니다.

사용하더라도 분명히 문제가 있을 수 있는 사용 방법이라는 것을 알고 사용해야 합니다.

Number.toFixed()로 소수점 자리 수 반올림

*100/100을 이용해서 소수점 둘째자리 반올림을 하는 번거로운 계산을 피할 수 있도록 Number 객체에 toFixed() 메서드가 있습니다. 인자로 반올림하는 자리수를 넣어서 원하는 자리수로의 반올림 결과를 빠르게 얻을 수 있습니다.

123.456.toFixed(2)

결과는 123.46 이 됩니다.

단, 이 메서드도 앞서의 부동소수점 문제가 똑같이 있습니다.

1.005.toFixed(2)

결과는 1.00이 됩니다. 정수가 아닌 부동소수점으로 소수점 2째 자릿수가 정확히 표현되지만, 이 메서드의 내부 계산 방식도 앞서 와 같이 입실론(EPSILON) 문제가 똑같이 있습니다.

Number.toPrecision() 메서드로 이중 반올림하기

toPrecision() 메서드는 숫자의 문자열 표현을 기준으로 파라미터로 정한 길이만큼 길이를 제한해서 반올림 처리를 합니다.

toFixed()가 소수점을 기준으로 자릿수를 고정으로 제한하는 것과는 다릅니다.

123.456.toPresion(4)는 문자열 표현(string)을 기준으로 길이 4만큼 숫자를 잘라서 123.5로 반올림 결과를 반환합니다.

소수점의 경우 0.0123456.toPrecision(4)는 0.01235가 됩니다.

즉, 문자열 표현을 기준으로 앞쪽에 오는 0은 가져오는 자릿수에서 제외합니다.

이때 숫자 표현부 길이가 파라미터에서 정한 길이보다 작으면 toFixed()처럼 남는 자릿수만큼 0으로 채우게 됩니다. 예를 들어 0.012.toPrecision(5)는 0.012000 이 됩니다.

앞서의 1.005 문제는 toPrecision()으로 반올림해서 다음과 같이 해결할 수 있습니다.

Math.round((1.005 * 100).toPrecision(15))/100

결과는 예상한 대로 1.01 이 됩니다. 부동소수점 표현의 오차를 소수점 15 길이만큼인 소수점 위치에서 반올림해서 부동소수점 오차를 제거해주는 것입니다.

복잡하게 계산을 해볼 필요도 없이 1.005.toPrecision(15)를 하면 1.005가 다음과 같이 소수점 15째 자리에서 반올림되어 14자리까지 소수점 값 이하의 숫자들이 0으로 고정된 실수 값을 얻게 됩니다.

1.00500000000000