double 이나 float 자료형은 IEEE 754 부동소수점 표준에 따라 값을 2진수로 저장합니다.
이 방식으로는 십진수 0.1을 정확하게 표현할 수 없습니다.
0.1은 2진수로는 무한히 반복되는 소수가 되기 때문에, 유한한 비트 안에 담으려면 가장 가까운 값으로 반올림해야 합니다.
이 글에서는 그 오차가 코드에서 어떻게 드러나는지, 실제 장애로 이어진 사례는 무엇이었는지, 자바에서는 어떤 대안이 있는지 정리합니다.
1. 코드로 확인하는 부동소수점 오차
jshell에서 간단히 확인해볼 수 있습니다.
jshell> System.out.println(0.1 + 0.2);
0.30000000000000004
jshell> System.out.println(0.1 + 0.2 == 0.3);
false
jshell> System.out.println(1.03 - 0.42);
0.6100000000000001
0.1을 열 번 더해도 1.0이 되지 않습니다.
jshell> double sum = 0;
jshell> for (int i = 0; i < 10; i++) { sum += 0.1; }
jshell> System.out.println(sum);
0.9999999999999999
double 리터럴 0.1에 실제로 저장된 값은 BigDecimal 생성자로 확인할 수 있습니다.
jshell> System.out.println(new BigDecimal(0.1));
0.1000000000000000055511151231257827021181583404541015625
0.1이라고 적었지만 저장된 값은 0.1보다 아주 조금 큰 다른 수입니다. 이 표현 방식에 대한 자세한 설명은 IEEE 754 위키백과 문서를 참고할 수 있습니다.
2. 돈 계산에 float/double을 쓰면 안 되는 이유
과학 계산처럼 근사값으로 충분한 영역에서는 부동소수점 오차가 문제되지 않습니다. 그러나 돈 계산은 다릅니다. 금액은 마지막 한 자리까지 정확해야 하고, 오차가 누적되면 장부가 맞지 않습니다. 위 예제처럼 1.03달러에서 0.42달러를 빼는 단순한 계산도 0.6100000000000001달러가 되어, 반올림 처리 없이는 화면에 그대로 노출되거나 비교 연산이 어긋납니다. Never Use Float and Double for Monetary Calculations를 비롯해 많은 글이 같은 이유로 금액 계산에 부동소수점 자료형을 쓰지 말라고 권고합니다.
3. 실제 장애 사례: 2011년 NEIS 성적 처리 오류
부동소수점 계산 오차는 실제로 큰 장애로 이어진 적이 있습니다. 2011년 7월 차세대 교육행정정보시스템(NEIS·나이스)에서 성적 처리 오류가 발생해, 전국 2,300여개 고교에서 1만 7천여명의 학기말 성적이 바뀌었습니다. 대입 수시모집을 앞둔 시점이어서 파장이 컸습니다.
4. 자바에서의 대안
4.1. BigDecimal
자바에서 십진 소수를 정확하게 다루는 기본 수단은 java.math.BigDecimal 입니다.
jshell> new BigDecimal("0.1").add(new BigDecimal("0.2"))
$1 ==> 0.3
jshell> new BigDecimal("1.03").subtract(new BigDecimal("0.42"))
$2 ==> 0.61
한 가지 주의할 점은 생성자에 double 을 넘기면 안 된다는 것입니다.
앞에서 봤듯이 new BigDecimal(0.1) 은 0.1이 아니라 double 에 저장된 근사값을 그대로 옮겨 담습니다.
문자열을 받는 생성자나 BigDecimal.valueOf() 를 사용해야 합니다.
4.2. 정수형 최소 단위 계산
금액을 원(₩)이나 센트 같은 최소 단위의 정수로 다루는 방법도 널리 쓰입니다.
우아한형제들 기술 블로그의 Spock으로 테스트코드를 짜보자에 나오는 예제가 이 방식을 보여줍니다.
반올림 처리에는 BigDecimal 을 쓰지만, 메소드의 입력과 리턴 타입은 long 입니다.
public static long calculate(long amount, float rate, RoundingMode roundingMode) {
return BigDecimal.valueOf(amount * rate * 0.01)
.setScale(0, roundingMode).longValue();
}
금액 자체는 정수로 유지하고, 소수점이 생기는 비율 계산 직후에 반올림 모드를 명시한 BigDecimal 로 다시 정수로 돌아오는 구조입니다.
다만 amount * rate * 0.01 곱셈 자체는 부동소수점 연산으로 수행되므로, 이 오차가 반올림 경계에 걸릴 수 있는 정밀도라면 곱셈까지 BigDecimal 로 옮기는 편이 안전합니다.
4.3. Java Money (JSR 354)
금액과 통화를 함께 다루는 표준 API로 Java Money도 있습니다.
MonetaryAmount 인터페이스로 금액과 통화 단위를 묶어서 표현하고, 참조 구현인 Moneta를 사용합니다.
사용 예제는 Baeldung의 Java Money and the Currency API를 참고할 수 있습니다.
5. BigDecimal이 언제나 정답일까
BigDecimal 에도 비용이 있습니다.
Chronicle Software의 Peter Lawrey는 If BigDecimal is the answer, it must have been a strange question에서 BigDecimal 의 단점을 이렇게 꼽습니다.
-
문법이 자연스럽지 않다. 연산자 대신 메소드 호출을 이어붙여야 해서 수식의 가독성이 떨어진다.
-
double보다 메모리를 많이 사용한다. -
객체를 계속 생성하므로 가비지가 많이 만들어진다.
-
대부분의 연산에서 훨씬 느리다.
Lawrey의 결론은 "`double` 의 반올림을 다루는 법을 모르거나 프로젝트 표준이 BigDecimal 을 강제한다면 BigDecimal 을 쓰되, 선택할 수 있다면 BigDecimal 이 당연히 옳다고 가정하지는 말라"는 것입니다.
지연 시간에 민감한 금융 시스템 중에는 BigDecimal 없이 만들어진 시스템도 많이 돌아가고 있다는 점도 근거로 듭니다.
저는 기본값은 여전히 BigDecimal 이라고 생각합니다.
부동소수점 오차와 반올림을 정확히 통제할 자신이 있는 팀이라면 성능을 위해 double 이나 정수형 계산을 선택할 수는 있습니다.
그러나 조직에는 항상 새로운 사람이 들어오기 때문에 모든 사람이 이 글에서 다룬 부동 소수점 연산의 오차의 특성을 잘 이해하고 있다고 전제하기는 쉽지 않습니다.
따라서 팀과 프로젝트의 표준으로 BigDecimal 사용을 전수하는 것이 더 안전하다고 생각합니다.
Twitter
Facebook
Reddit
LinkedIn
Email