빌드노트
같은 금액인데 BigDecimal equals가 false인 이유
문자열로 만든 2.0과 2.00은 숫자는 같지만 scale이 달라 equals가 false다. HashSet 2개·TreeSet 1개라는 실제 결과를 비교하고, 변경 감지와 키에 같은 동등성 규칙을 적용하는 방법 및 반올림 없이 정규화할 조건을 설명한다.
같은 통화의 금액을 2.0과 2.00으로 입력했는데, BigDecimal의 equals로 변경을 감지하면 값이 바뀌었다고 판단한다. HashSet에 넣어도 두 원소가 남는다. equals는 숫자뿐 아니라 scale도 비교하기 때문이다. 금액의 크기만 같으면 되는 비교에는 compareTo의 결과가 0인지 확인한다. 컬렉션 키도 같은 기준을 쓰려면 별도의 정규화 규칙이 필요하다.
두 값을 그대로 집합에 넣어 보면
라이브러리 없이 JDK 17 이상으로 실행할 수 있는 예제다. 빈 작업 폴더에 아래 내용을 EqualityDemo.java로 저장한다. 두 입력은 소수 자릿수를 분명히 하기 위해 문자열 생성자로 만든다.
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.List;
import java.util.TreeSet;
public class EqualityDemo {
public static void main(String[] args) {
BigDecimal a = new BigDecimal("2.0");
BigDecimal b = new BigDecimal("2.00");
System.out.println("scale = " + a.scale() + ", " + b.scale());
System.out.println("equals = " + a.equals(b));
System.out.println("compareTo = " + a.compareTo(b));
System.out.println("hashCode = " + a.hashCode() + ", " + b.hashCode());
System.out.println("HashSet.size = " + new HashSet<>(List.of(a, b)).size());
System.out.println("TreeSet.size = " + new TreeSet<>(List.of(a, b)).size());
}
}
그 폴더에서 다음 두 명령을 실행한다. 첫 명령으로 실제 실행 버전을 확인하고, 두 번째 명령으로 소스 파일을 직접 실행한다.
java -version
java EqualityDemo.java
Windows의 OpenJDK 21.0.1에서 직접 얻은 출력이다. OpenJDK 17.0.2에서도 같은 결과를 확인했다.
scale = 1, 2
equals = false
compareTo = 0
hashCode = 621, 6202
HashSet.size = 2
TreeSet.size = 1
이 입력에서 2.0은 정수 값 20과 scale 1로, 2.00은 정수 값 200과 scale 2로 표현된다. 숫자로는 같지만 소수 자릿수까지 포함한 표현은 다르다. Java 21 BigDecimal 문서 (새 탭에서 열림)는 이 차이를 equals와 compareTo의 계약으로 명시한다. 따라서 여기서 false는 계산 오차가 아니다.
HashSet은 해시를 사용하며 최종 동등성은 equals로 판단한다. BigDecimal의 해시 계산에도 scale이 들어간다. 이번 두 입력의 해시는 달랐지만, scale이 다르면 해시가 항상 다르다고 일반화할 수는 없다. HashSet의 원소 추가 규칙 (새 탭에서 열림)을 적용하면 두 원소가 남는 결과를 설명할 수 있다.
예제의 TreeSet은 별도 비교자를 지정하지 않아 BigDecimal.compareTo를 쓴다. 비교 결과가 0이므로 원소는 하나다. 이는 정렬 순서뿐 아니라 중복 기준도 바뀐 것이다. TreeSet 문서 (새 탭에서 열림)도 비교 결과와 equals가 어긋나면 일반적인 Set 계약을 지키지 못한다고 설명한다. 중복을 줄이려고 컬렉션만 교체하기 전에 어떤 동등성을 원하는지 정해야 한다.
실행 결과가 다르다면 먼저 입력이 정말 new BigDecimal("2.0")과 new BigDecimal("2.00")인지, 출력한 scale이 1과 2인지 확인한다. 집합에 넣기 전에 정규화했거나 사용자 비교자를 넘겼다면 비교 조건이 달라진다. java를 찾지 못하거나 소스 실행을 지원하지 않는 오래된 JDK라면 위 관찰을 재현한 상태가 아니다.
변경 감지와 키의 기준을 맞춘다
같은 금액이면 변경이 없다고 볼 때는, null이 아닌 두 값에 대해 oldAmount.compareTo(newAmount) != 0으로 변경 여부를 판단할 수 있다. 반대로 소수 자릿수의 변경까지 기록해야 한다면 equals의 결과가 그 요구에 맞는다. 다만 equals가 원래 입력 문자열 전체를 보존하는 것은 아니다. 추가 실행에서 문자열 02.00과 2.00은 달랐지만, 각각 만든 BigDecimal끼리는 equals가 true였다. 입력 문구 자체가 필요하면 원문도 따로 보관해야 한다.
비교 한 줄을 바꿔도 HashSet의 규칙은 바뀌지 않는다. 숫자만 같으면 같은 키라는 정책이라면 stripTrailingZeros()로 끝의 0을 제거한 값을 키로 삼는 방법이 있다. 앞 예제의 main 마지막에 다음 코드를 추가할 수 있다.
HashSet<BigDecimal> keys = new HashSet<>();
keys.add(a.stripTrailingZeros());
keys.add(b.stripTrailingZeros());
System.out.println("numeric HashSet.size = " + keys.size());
System.out.println("contains raw 2.00 = " + keys.contains(b));
System.out.println("contains normalized 2.00 = " + keys.contains(b.stripTrailingZeros()));
같은 두 입력을 정규화한 실험에서 다음 결과를 얻었다.
numeric HashSet.size = 1
contains raw 2.00 = false
contains normalized 2.00 = true
저장할 때만 바꾸면 조회할 때 다시 어긋난다. 이 정책을 고르면 추가·조회·삭제에 같은 정규화를 적용해야 한다. 반환된 값을 키로 쓰되, 화면 표시나 입력 이력을 위해 원래 값이 필요하면 따로 유지한다.
정규화 후 scale이 늘 0 이상이라고 가정해서도 안 된다. 추가 실험에서 1000.00은 1E+3, scale은 -3이 됐고, 0.00은 scale 0인 0이 됐다. 지수 없는 표시가 필요하면 toPlainString()을 사용할 수 있다. 실제로 앞의 1E+3에 호출한 결과는 1000이었다. 원래 입력의 소수 자릿수가 복원되는 것은 아니다. 이 동작은 BigDecimal의 stripTrailingZeros·표시 메서드 설명 (새 탭에서 열림)과도 맞는다.
두 자리로 맞춘다는 계약이 있을 때만
금액을 소수 둘째 자리로 정확히 표현할 수 있어야 한다는 입력 계약이 있다면, java.math.RoundingMode를 import하고 amount.setScale(2, RoundingMode.UNNECESSARY)의 반환값을 키로 사용할 수 있다. 실험에서는 2.0, 2.00, 2.000이 모두 scale 2인 2.00이 됐고, 앞의 두 값으로 만든 HashSet도 한 원소만 남겼다. 2.000은 자릿수가 셋이어도 버리는 부분이 0이므로 값 손실 없이 맞출 수 있었다.
반면 2.001은 이 변환에서 ArithmeticException: Rounding necessary가 났다. setScale의 공식 계약 (새 탭에서 열림)에 따르면 UNNECESSARY는 반올림이 필요한 변환을 거절한다. 이 예외는 두 자리 계약에 맞지 않는 입력을 구별하는 신호다.
같은 2.001에 HALF_UP을 사용하면 실행 결과가 2.00이었다. 변환 전에는 2.00과의 compareTo 결과가 1이었는데, 반올림 뒤에는 그 차이가 사라진다. 중복 제거 오류를 없애려고 무조건 반올림하면 실제 금액 차이까지 숨길 수 있다. 두 자리 계약이 없다면 이 자릿수를 임의로 선택할 근거도 없다. 반올림이 필요한 업무라면 반올림할 시점과 방식을 별도 규칙으로 정해야 한다.
여기서 확인한 범위는 문자열로 만든 null 없는 값과 기본 HashSet·TreeSet, 두 정규화 정책이다. DB 저장 과정, JSON 변환, 특정 ORM의 변경 감지는 실행하지 않았다. 실제 코드의 변경 감지 결과가 계속 다르면 비교 직전의 두 값과 scale, 그 코드가 사용하는 비교 방식을 확인해야 한다.