본문 바로가기
Spring/experience

Java 부동소수점 계산시 정확한 계산을 위해 왜 BigDesimal을 사용해야하는가?

by include_hoany 2025. 2. 10.

 
오늘은 부동소수점 계산을 정확하게 하려면 왜 BigDesimal을 사용해야 하는지에 대해 한번 생각해 보겠습니다.
부동소수점 정확도를 고민하게된건 회사에서 결제 서비스를 도입할 때였습니다.

고객이 사용한 금액에 대해 수수료율을 계산하여 결제할 금액을 결정할 때 고객이 사용한 금액이 10원 단위로 딱 떨어지지도 않고 수수료율도 22%였기 때문에 double 자료형으로 수수료율을 계산하게 된다면 나머지가 남게 되어 순환소수가 발생해 버려 근삿값 2진수로 처리되어 오차가 발생할 여지 때문이었습니다.

물론 큰 오차는 아니지만 회계, 세법 처리에 있어서 결제금액이 커질수록 작아 보이는 오차도  큰 금액이 되기 때문에 정확한 계산이 필요했었습니다.

그래서 정확한 소수점계산을 위해 Java5에서 IBM의 기부로 완성된 BigDesimal 자료형을 사용해서 정확한 수수료 계산 결제 서비스를 구현할 수 있게 되었습니다.

그러면 간단하게 double이 왜 오차가 발생하고 BigDesimal은 어떤 방식으로 정확한 계산을 할 수 있었는지 원리를 파악해 보겠습니다.

가장 먼저 우리가 너무나 잘 알고 있는 double자료형 구조를 파악해 보겠습니다. 
double자료형은 부호(1bit), 지수(11bit), 가수(52bit)로 총 64비트 구조를 가지고 있습니다.
double형은 IEEE 754 표준에 따라 53번째 비트를 반올림하여 가수비트를 결정하게 됩니다.

왜 53번째 비트를 반올림할까 자료를 조사해 보니 가수 표현 가능한 범위 내에서 최대한 정확한 근삿값을 얻기 위함이고 대부분의 소수(decimal fraction)를 계산할 때는 충분한 정밀도 제공한다고 나와있더군요..

double feeRate = 0.1;

0.1 -> 부호: 0 지수: 01111111011 가수: 1001100110011001100110011001100110011001100110011010

 

0.1을 double에 저장 후  비트 구조를 살펴보면 다음과 같습니다. 0.1을 2진수로 변환하게 된다면 순환 소수가 발생하게 됩니다. IEEE 754 표준에 따라 53번째 비트를 반올림하려 끝자리는 캐리가 발생하여 52번째 자리는 0 51번째 자리가 1비트가 됩니다. 따라서 정확한 0.1이 아닌 근삿값이 저장되게 됩니다.

 

0.1 -> 부호: 0 지수: 01111111011 가수: 1001100110011001100110011001100110011001100110011010
0.1 * 2 -> 부호: 0 지수: 01111111100 가수: 1001100110011001100110011001100110011001100110011010
0.1 * 3 -> 부호: 0 지수: 01111111101 가수: 0011001100110011001100110011001100110011001100110100
0.3 -> 부호: 0 지수: 01111111101 가수: 0011001100110011001100110011001100110011001100110011
(feeRate * 3) 

((feeRate * 3) == 0.3); // -> false

이러한 근사값이 어떠한 문제를 야기시키게 되냐면 0.1 * 3의 결과는 0.3이지만 리터럴 0.3과 비교를 하면 false결괏값이 나오게 됩니다. 이러한 이유는 위 0.1 * 3 결과값 비트와 0.3을 저장한 비트를 비교해 보면 크 차이를 확인하실 수 있습니다.
 

0.125 -> 부호: 0 지수: 01111111100 가수: 0000000000000000000000000000000000000000000000000000
0.125 * 2 -> 부호: 0 지수: 01111111101 가수: 0000000000000000000000000000000000000000000000000000
0.125 * 3 부호: 0 지수: 01111111101 가수: 1000000000000000000000000000000000000000000000000000
0.375 -> 부호: 0 지수: 01111111101 가수: 1000000000000000000000000000000000000000000000000000
(twoFeeRate * 3) == 0.375; // -> true

그런데 소수를 2진수로 변환했을 때 순환소수가 발생하지 않는 수라면? 찾아보니 0.125는 2진수로 변환했을때 순환소수가 발생하지 않는 수라서 테스트를 해보았습니다. 0.125 * 3을 했을 때와 0.375를 비교해 봤을 때 정확하게 비트도 동일한 결과가 나와 단순하게 (twoFeeRate * 3) == 0.375 비교해도 올바른 결괏값이 나오게 됩니다.
 

BigDecimal bigDecimalFeeRate = BigDecimal.valueOf(0.1);  
BigDecimal bigDecimalThree = BigDecimal.valueOf(3);  
bigDecimalFeeRate.multiply(bigDecimalThree).compareTo(BigDecimal.valueOf(0.3)); // -> true

그러면 BigDesimal은 어떻게 이러한 오차를 제거하고 정확한 소수 계산을 할 수 있게 되었나? 구조를 확인해 보면 크게 intVal, scale값을 통해 정확한 소수 계산을 진행할 수 있도록 고안되었습니다. 보기에는 소수를 처리하는 것 같지만 정수로 모든 소수를 처리하며 intVal(실제 값), scale(소수점이하 자릿수)로 정수로만 관리하게 됩니다. 
따라서 intVal이 11이고 scale이 1이면 1.1이라는 값이 도출되게 됩니다.   1.25라는 숫자를 BigDesimal 처리한다면 100을 곱해서 intVal 125 scale 2로 처리하는 거죠!

소수를 정확하게 계산할 때는 BigDesimal을 사용한다라고 알고는 있었지만 비트단위까지 확인하면서 그 이유를 확인해보지는 않았었는데 이번 기회로 정확하게 왜 double에서 소수 오차값이 발생할 수 있는지 확실하게 알 수 있었던 시간이었습니다!

그러면 다들 자바에서 정확한 소수 계산을 필요로 하실 때는 꼭 BigDesimal을 사용하세요!
 

// BigDecimal 비교시 주의점
// equals
BigDecimal tempBigDecimal = new BigDecimal("0.300");
BigDecimal tempTwoDecimal = BigDecimal.valueOf(0.3);
tempBigDecimal.equals(tempTwoDecimal); // -> false
tempBigDecimal.compareTo(tempTwoDecimal) == 0; // -> true

끝내기 전에 마지막으로 BigDesimal 비교 시에 주의점을 하나 알려드리자면 equals로 비교하게 된다면 0.300과 0.3은 다르다고 판단하게 됩니다. 꼭 BigDecimal을 비교하실 때는 compareTo를 사용하셔야 하고 값을 초기화할 때는 되도록 BigDecimal.valueOf()를 사용하시길 바랍니다!