본문 바로가기
빌드노트 검색

빌드노트

BigDecimal에 0.1을 넣었는데 긴 소수가 나오는 이유

new BigDecimal(0.1)은 이미 근사된 double을 정확한 십진수로 옮긴다. 문자열 생성자와 valueOf의 출력·덧셈을 비교하고, double에서 먼저 계산한 0.1 + 0.2는 valueOf로도 원래 십진 입력을 복원하지 못함을 실제 JDK 실행으로 확인한다.

new BigDecimal(0.1)을 출력했더니 0.10000000000000000555…가 나온다. 이때 먼저 확인할 것은 생성자에 전달한 값이다. 숫자 리터럴 0.1은 이미 double로 근사된 뒤 들어가고, BigDecimal(double)은 그 값을 정확한 십진수로 옮긴다. 원래 입력한 십진수 0.1을 보존하려면 new BigDecimal("0.1")처럼 문자열에서 바로 만든다.

Java 17의 BigDecimal(double) 문서 (새 탭에서 열림)도 이 입력을 예로 든다. 0.1은 유한한 이진 소수로 정확히 표현할 수 없어 double에 근삿값이 저장된다. 긴 숫자는 BigDecimal이 임의로 붙인 소수 자릿수가 아니라, 전달받은 double의 실제 값을 보여 준 것이다.

생성 방법과 덧셈 순서를 함께 비교하면

JDK 17 이상이 설치되어 있고 java 명령을 사용할 수 있으면 별도 라이브러리 없이 확인할 수 있다. 빈 폴더에 다음 내용을 DecimalInput.java로 저장한다. 실제 데이터 대신 고정한 숫자 0.1과 0.2만 사용한다.

import java.math.BigDecimal;

public class DecimalInput {
    public static void main(String[] args) {
        BigDecimal a = new BigDecimal(0.1);
        BigDecimal b = new BigDecimal("0.1");
        BigDecimal c = BigDecimal.valueOf(0.1);
        System.out.println("new(double) = " + a.toPlainString());
        System.out.println("new(String) = " + b.toPlainString());
        System.out.println("valueOf(double) = " + c.toPlainString());

        System.out.println("sum/new(double) = "
                + a.add(new BigDecimal(0.2)).toPlainString());
        System.out.println("sum/new(String) = "
                + b.add(new BigDecimal("0.2")).toPlainString());
        System.out.println("sum/valueOf(each) = "
                + c.add(BigDecimal.valueOf(0.2)).toPlainString());

        double sum = 0.1 + 0.2;
        System.out.println("double sum = " + sum);
        System.out.println("valueOf(sum) = " + BigDecimal.valueOf(sum).toPlainString());
    }
}

저장한 폴더의 터미널에서 다음 두 명령을 차례로 실행한다. 두 번째 명령은 소스 파일을 직접 실행한다.

java -version
java DecimalInput.java

Windows의 OpenJDK 17.0.2에서 얻은 출력이다. BigDecimal 값은 지수 표기 없이 읽을 수 있도록 toPlainString()으로 출력했다.

new(double) = 0.1000000000000000055511151231257827021181583404541015625
new(String) = 0.1
valueOf(double) = 0.1
sum/new(double) = 0.3000000000000000166533453693773481063544750213623046875
sum/new(String) = 0.3
sum/valueOf(each) = 0.3
double sum = 0.30000000000000004
valueOf(sum) = 0.30000000000000004

첫 세 줄에서 문자열 생성자와 valueOf(0.1)은 모두 0.1을 만들었다. 다만 입력을 해석하는 방법은 다르다. 문자열 생성자는 전달한 십진 표현을 읽는다. valueOf(double)의 계약 (새 탭에서 열림)은 Double.toString(double)이 만드는 문자열 표현을 사용한다. 이 예제에서는 그 표현이 "0.1"이므로 같은 결과가 나온다. 소수 첫째 자리로 반올림하도록 정해진 메서드는 아니다.

덧셈에서도 차이가 이어진다. double 생성자로 만든 두 값을 더하면 긴 소수가 남고, 문자열로 만든 두 값이나 각 항을 valueOf로 바꾼 값을 더하면 0.3이다. 여기서 사용한 MathContext 없는 add (새 탭에서 열림)는 전달된 BigDecimal 값의 정확한 합을 계산한다. 따라서 첫 덧셈의 차이는 이미 생성된 피연산자의 값에서 온다.

valueOf를 마지막에 호출해도 계산 전 입력은 돌아오지 않는다

마지막 두 줄은 다른 순서다. 0.1 + 0.2를 double에서 먼저 계산하고 그 결과를 valueOf에 전달했다. 이때 결과는 0.30000000000000004였고, 변환 뒤에도 그대로 남았다. valueOf는 원래 어떤 십진 입력을 더하려 했는지 추측하지 않는다. 이미 계산된 double 하나를 받아 그 문자열 표현으로 BigDecimal을 만들 뿐이다.

따라서 BigDecimal.valueOf(0.1).add(BigDecimal.valueOf(0.2))와 BigDecimal.valueOf(0.1 + 0.2)는 같은 수정이 아니다. 십진 입력대로 계산해야 한다면 입력을 BigDecimal로 만드는 시점부터 바꿔야 한다. 입력 문자열이 남아 있다면 double로 파싱하기 전의 문자열을 사용한다.

실제 코드에서 고를 기준은 다음과 같다.

  • 원래 십진 문자열을 보존해 계산하려면 new BigDecimal(text)를 사용한다. 중간에 Double.parseDouble(text)를 거치면 원래 십진 값이 달라질 수 있다.
  • 이미 받은 유한한 double을 Double.toString의 표현에 맞춰 변환하려면 BigDecimal.valueOf(value)를 사용한다. 이전 계산이나 변환에서 잃은 입력 정보까지 복구되지는 않는다.
  • double에 저장된 이진 값 자체를 정확한 십진수로 조사하려면 new BigDecimal(value)가 그 목적에 맞는다.

setScale로 짧게 보이게 만들기 전에

추가 실험에서 new BigDecimal(0.1).setScale(1, RoundingMode.HALF_UP)의 결과는 0.1이었다. 하지만 이는 현재 값을 한 자리로 반올림한 새 BigDecimal이다. setScale의 공식 계약 (새 탭에서 열림)도 자릿수를 줄일 때 값이 바뀔 수 있고 원래 객체는 수정되지 않는다고 명시한다. 이 출력만 보고 원래 입력이 정확히 보존됐다고 판단할 수는 없다. 반올림할 자리와 방식은 입력을 읽는 방법과 별도로 정한다.

자기 코드의 결과가 위와 다르면 먼저 java -version과 실제 생성자 인자를 확인한다. "0.1"을 넘겼는지, 0.1이나 이미 계산한 double 변수를 넘겼는지 구분한다. 화면에서 짧게 보이는 값도 생성 직후 toPlainString()으로 확인하면 표시 형식이 가린 차이를 찾는 데 도움이 된다.

여기서 직접 확인한 범위는 OpenJDK 17.0.2의 합성 값 생성·덧셈·반올림이다. 다른 JDK 버전이나 DB·JSON 변환 경로는 실행하지 않았다. 실제 금융 계산과 금액 데이터도 사용하지 않았다. 입력이 외부 변환을 거친다면 BigDecimal을 만들기 직전까지 문자열과 double 중 어느 타입으로 전달되는지 별도로 확인해야 한다.

← 전체 글 보기