빌드노트
BigDecimal.divide가 1/3에서 예외를 내는 이유
문자열로 정확히 만든 BigDecimal도 1/3의 정확한 몫은 담을 수 없다. JShell에서 1/2와 1/3을 비교하고, 10/3으로 scale 2와 precision 2의 차이를 확인한다. UNNECESSARY와 0으로 나누기의 실패도 구별한다.
문자열로 만든 new BigDecimal("1")을 new BigDecimal("3")으로 나눴는데 Non-terminating decimal expansion 예외가 난다면, 먼저 호출한 divide의 인자를 확인한다. 나누는 값만 받는 divide는 정확한 결과를 요구한다. 입력 1과 3이 정확해도 결과인 0.333…은 끝나지 않는다. 근삿값을 허용하려면 필요한 소수 자릿수 또는 유효 숫자와 반올림 방식을 나눗셈에 지정해야 한다.
1/2는 되는데 1/3은 멈추는 이유
다음은 Windows의 OpenJDK 17.0.2에서 JShell로 확인한 비교다. JDK 17의 java와 jshell 명령을 사용할 수 있는 터미널에서 버전을 확인하고 JShell을 연다.
java -version
jshell --no-startup
열린 JShell에 아래 Java 코드를 입력하면, 같은 문자열 생성 방식으로 만든 두 나눗셈의 차이를 확인할 수 있다. 이후 예제도 이 세션에서 이어서 입력한다. 끝내려면 /exit를 입력한다.
import java.math.*;
var one = new BigDecimal("1");
var two = new BigDecimal("2");
var three = new BigDecimal("3");
System.out.println(one.divide(two));
one.divide(three);
출력에서 값과 예외 메시지만 추리면 다음과 같다. JShell의 변수 표시와 스택 추적은 생략했다.
0.5
ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
1/2는 유한한 십진수 0.5로 정확히 나타낼 수 있다. 1/3은 어느 자리에서 끊어도 정확한 몫과 달라진다. Java 17의 divide(BigDecimal) 계약 (새 탭에서 열림)은 정확한 몫이 유한소수가 아니면 ArithmeticException을 던진다고 명시한다. 문자열로 입력을 보존하는 것과 나눗셈 결과를 유한하게 표현하는 것은 별개의 조건이다.
따라서 one.divide(three).setScale(2, RoundingMode.HALF_UP)처럼 뒤에 자릿수 조정을 붙여도 해결되지 않는다. 앞의 divide에서 이미 예외가 나므로 setScale까지 도달하지 못한다. 자릿수와 반올림 방식을 divide에 전달해야 한다.
소수점 아래 두 자리와 유효 숫자 두 개는 다르다
요구사항이 “소수점 아래 두 자리까지 계산하고 HALF_UP으로 반올림한다”라고 정해져 있다고 가정하자. 아래 입력은 scale을 2로 지정하며, 분자만 1에서 10으로 바꿔 비교한다.
var ten = new BigDecimal("10");
System.out.println(one.divide(three, 2, RoundingMode.HALF_UP));
System.out.println(ten.divide(three, 2, RoundingMode.HALF_UP));
실행 결과는 두 값 모두 소수점 아래 두 자리를 가진다.
0.33
3.33
divide(BigDecimal, int, RoundingMode) (새 탭에서 열림)의 두 번째 인자는 결과의 scale이다. 여기처럼 양수인 scale 2는 소수점 오른쪽의 자릿수를 뜻한다. 3.33에는 정수 부분까지 세면 숫자가 세 개 있지만 이 요구사항에는 맞는다.
이번에는 “유효 숫자 두 개까지 계산하고 HALF_UP으로 반올림한다”라는 다른 요구사항을 적용해 보자. 같은 입력에 MathContext를 전달한다.
var twoDigits = new MathContext(2, RoundingMode.HALF_UP);
System.out.println(one.divide(three, twoDigits));
System.out.println(ten.divide(three, twoDigits));
이번 실행에서는 두 번째 값이 달라졌다.
0.33
3.3
MathContext (새 탭에서 열림)의 precision은 연산에 사용할 유효 숫자의 수다. BigDecimal에서는 왼쪽의 첫 0이 아닌 숫자부터 센다. 따라서 0.33의 유효 숫자는 두 개이고, 3.3도 두 개다. 1/3만 비교하면 같아 보이지만 10/3에서는 소수 자릿수와 유효 숫자의 차이가 드러난다. 이 설정들은 반환할 수치 자체를 정하므로 화면 표시만 바꾸는 옵션으로 취급하면 안 된다.
precision 2가 항상 숫자를 두 개 채운다는 뜻도 아니다. 추가로 실행한 one.divide(two, twoDigits)는 0.5였고, one.divide(two, 2, RoundingMode.HALF_UP)는 0.50이었다. 앞의 결과처럼 더 적은 유효 숫자로 반환될 수 있으므로, 소수 자릿수를 고정할 용도로 MathContext를 사용하면 안 된다.
반올림 방식만 추가한 one.divide(three, RoundingMode.HALF_UP)도 주의해야 한다. 이 실행의 결과는 0이었다. 이 두 인자 오버로드 (새 탭에서 열림)는 나눠지는 값의 scale을 사용하며, one의 scale은 0이기 때문이다. 같은 호출에서 분자를 new BigDecimal("1.00")으로 바꾸자 결과는 0.33이었다. 입력 표현에 기대지 않고 소수 자릿수를 정하려면 scale까지 명시한 세 인자 오버로드를 고른다.
여기서 사용한 2와 HALF_UP은 두 방식을 비교하기 위해 정한 조건이다. 실제 코드에서는 필요한 자릿수와 반올림 방식을 요구사항에서 확인해야 한다. 이 비교만으로 특정 계산의 반올림 정책을 정할 수는 없다.
UNNECESSARY 실패와 0으로 나누기도 구별한다
정해진 자릿수에서 값 손실을 허용하지 않아야 한다면 UNNECESSARY로 확인할 수 있다. 아래 입력은 유한소수인 1/8을 두 자리와 세 자리로 각각 요구한 뒤, 같은 세션에서 분모 0을 따로 확인한다.
one.divide(new BigDecimal("8"), 2, RoundingMode.UNNECESSARY);
System.out.println(one.divide(new BigDecimal("8"), 3, RoundingMode.UNNECESSARY));
one.divide(BigDecimal.ZERO, 2, RoundingMode.HALF_UP);
앞과 마찬가지로 값과 예외 메시지만 추린 실제 출력이다. JShell에서는 한 입력에서 예외가 나도 다음 입력을 실행할 수 있다.
ArithmeticException: Rounding necessary
0.125
ArithmeticException: / by zero
1/8의 정확한 몫은 0.125다. 유한소수여도 소수점 아래 두 자리에는 정확히 담기지 않으므로 첫 호출이 실패했다. 세 자리에서는 그대로 반환됐다. UNNECESSARY의 계약 (새 탭에서 열림)은 반올림이 필요한 결과를 거부하는 것이다. 무한소수를 자동으로 잘라 주는 옵션이 아니다. 같은 두 자리 조건에서 1/2는 0.50으로 성공했고 1/3은 Rounding necessary로 실패했다.
마지막의 분모 0은 자릿수를 늘리거나 반올림 방식을 바꿔 해결할 수 있는 입력이 아니다. 정확한 divide로도 따로 실행했으며 그때 메시지는 Division by zero였다. 같은 ArithmeticException이어도 호출 방식에 따라 메시지가 다르므로, 예외를 모두 “정밀도 부족”으로 처리하기 전에 실제 분모와 사용한 오버로드를 확인해야 한다.
자기 코드에서는 정확한 유한 결과가 필요하면 인자 하나의 divide를 유지하고 실패할 입력을 다룬다. 소수점 아래 자릿수가 정해져 있으면 scale과 반올림 방식을, 전체 유효 숫자가 정해져 있으면 MathContext를 명시한다. 이번에 직접 확인한 범위는 OpenJDK 17.0.2의 합성 수치 연산이다. 다른 JDK 버전과 여러 연산을 이어 수행할 때의 누적 반올림 영향은 실행하지 않았다.