문제 상황 소개
최근 내가 운영하고 있는 GDG 독서클럽에서 조영호님의 책 ‘오브젝트’를 읽고 함께 토론하고 있다.
책에 삽입된 코드를 따라 작성하고 거기에 테스트 코드를 추가하는 형식으로 실습을 준비하고 있다.
그러던 도중 예제에서 통화를 표현하기 위해 사용한 Money 클래스의 구현때문에 테스트가 통과하지 못하는 것을 발견했다. Money의 구현은 다음과 같다.
public class Money {
public static final Money ZERO = Money.wons(0);
private final BigDecimal amount;
public static Money wons(long amount) {
return new Money(BigDecimal.valueOf(amount));
}
public static Money wons(double amount) {
return new Money(BigDecimal.valueOf(amount));
}
public Money(BigDecimal amount) {
this.amount = amount;
}
public Money times(double percent) {
return new Money(this.amount.multiply(BigDecimal.valueOf(percent)));
}
.. equals & hashcode
}
예제 도메인은 영화 예매 시스템으로 영화에는 각종 할인 정책과 조건이 붙어있다.
즉, 예매 정보를 생성하기 위해서는 할인 금액을 계산하는 로직이 필요하다.
할인 정책에는 일정 금액 할인 정책과 비율 할인 정책이 존재한다. 이번 문제는 비율 할인 정책의 코드를 테스트하는 과정에서 발생했다.
첫 번째 문제: BigDecimal의 equals 동작 방식
테스트 코드는 다음과 같다.
public class DiscountPolicyTest {
@Nested
@DisplayName("비율 할인 정책")
class PercentDiscountPolicyTest {
@Test
@DisplayName("조건 만족 시 일정 비율만큼 할인한다")
void shouldDiscountPercentWhenConditionIsSatisfied() {
DiscountPolicy policy = new PercentDiscountPolicy(0.05, new SequenceDiscountCondition(1));
Movie movie = new Movie(
"영화 타이틀",
Duration.ofMinutes(120),
Money.wons(10000),
policy);
Screening screening = new Screening(movie, 1, LocalDateTime.now());
Money discountAmount = policy.calculateDiscountAmount(screening);
assertThat(discountAmount).isEqualTo(Money.wons(500));
}
}
}
10000원 짜리 영화에 5% 할인이 적용된 금액을 기대한다. 단순한 테스트라 생각해보면 당연히 맞을 것 같다.

그러나 결과는 그렇지않은데 이제 그 이유를 파헤쳐보자. 우선 왜 틀렸는지 확인해봐야겠다.
브레이크포인트를 걸고 디버깅을 돌려 값을 확인해보자.


음,, 이제 보이는가?
할인금액을 담았던 discoutAmount에 ‘500’이 아닌 ‘500.00’이 들어있다.
- 솔직히 나는 BigDecimal이 equals 비교 과정에서 처리해주는줄 알았다.
다음은 BigDecimal.equals의 주석에서 발췌한 내용이다.
Unlike compareTo, this method considers two BigDecimal objects equal only if they are equal in value and scale. Therefore 2.0 is not equal to 2.00 when compared by this method since the former has [BigInteger, scale] components equal to [20, 1] while the latter has components equal to [200, 2].
즉, BigDecimal의 기본 equals는 값과 scale이 모두 같을 때만 true를 반환한다는 것이다.
assertThat(discountAmount).isEqualTo(Money.wons(500));
500과 500.00을 각각 BigDecimal의 표현으로 나타내면 [500, 0], [50000, 2]가 될 것이다.
따라서 BigDecimal의 equals는 이 둘을 다른 것으로 판단한다.
자, 문제는 알았다. 이제 어떻게 해야할까? 몇 가지 방법이 있다.
첫 번째 해결책: compareTo 사용하기
equals 대신 compareTo를 사용하게 하면된다.
- 이 방법은 가능은 한데.. 라이브러리들도 내부적으로 equals를 사용하는 것들이 많아서 신경쓸게 많다.
- 다른 방법으론 Money 클래스의 equals에서 compareTo를 사용해 비교하는 방법도 있다.
- 이 방법에선 equals와 일관성을 위해 hashCode의 구현을 손봐줘야 한다.
public class Money {
public static final Money ZERO = Money.wons(0);
private final BigDecimal amount;
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Money money = (Money) o;
return amount.compareTo(money.amount) == 0; // compareTo 사용
}
@Override
public int hashCode() {
return Objects.hashCode(amount.stripTrailingZeros()); // 정규화된 값으로 해시코드 계산한다.
}
}

테스트도 성공한다.
두 번째 해결책: scale 정하기
Precision을 정한다. 즉, Money 내부에서 값의 scale을 정한다 (RoundingMode와 함께)
public class Money {
public static final Money ZERO = Money.wons(0);
private static final int DEFAULT_SCALE = 2;
private static final RoundingMode DEFAULT_ROUNDING = RoundingMode.HALF_EVEN;
private final BigDecimal amount;
public static Money wons(long amount) {
return new Money(BigDecimal.valueOf(amount));
}
public static Money wons(double amount) {
return new Money(BigDecimal.valueOf(amount));
}
public Money(BigDecimal amount) {
this.amount = amount.setScale(DEFAULT_SCALE, DEFAULT_ROUNDING);
}
public Money times(double percent) {
return new Money(this.amount.multiply(BigDecimal.valueOf(percent)));
}
.. 기존 equals & hashcode
}
이번엔 scale과 반올림 모드를 사용해보자. 기본 스케일과 기본 반올림 모드를 상수로 선언하고 생성자에서 스케일을 결정한다.
두 번째 문제: 정적 필드 초기화 순서

그러자 테스트가 실패한다. 그런데 테스트 코드에서 수상한 문장이 발견된다.
Cannot read field "oldMode" because "roundingMode" is null
roudingMode가 null이라고? 나는 분명 static 필드로 선언했었는데. null일수가 있나 생각했다.

더군다나 다른 모든 테스트에서 같은 예외가 발생한다. 왜 이런 일이 발생할까?

테스트에서 참조하는 생성자와 정적 팩터리 메서드는 아무런 잘못이 없다.
위에서 정적으로 선언한 필드를 가져다 쓸 뿐이다.
음,, 삽질을 좀 했는데 결국 답은 클래스 로딩과 관련이 있었다. 간단한 문제다.
Money.wons 호출과 함께 문제가 발생했던 건 Money 클래스에 처음 접근하는 순간에 해당 클래스를 로딩하기 때문이다.
JVM을 공부했을 때를 떠올리면, 클래스를 로딩할 때 정적 필드들을 초기화한다는 것을 기억할 수 있을 것이다.
이번 문제는 그 과정에서 발생했다. 위에서 수정한 Money 클래스를 다시 살펴보자.
public class Money {
public static final Money ZERO = Money.wons(0);
private static final int DEFAULT_SCALE = 2;
private static final RoundingMode DEFAULT_ROUNDING = RoundingMode.HALF_EVEN;
}
static으로 선언된 세 필드들이 존재한다. 대강 보기엔 아무 문제없어 보이나 사실 여기에는 숨은 문제가 존재한다.
문제는 Money.wons(0)
을 호출한다는 데에 있다. 정적 팩토리 메서드이니 그냥 호출하는건 별 다른 문제가 안 된다. 진짜 문제는 ‘순서’에 숨어있다. Money.wons(0)
는 다음 코드를 호출한다.
public static Money wons(long amount) {
return new Money(BigDecimal.valueOf(amount));
}
public Money(BigDecimal amount) {
this.amount = amount.setScale(DEFAULT_SCALE, DEFAULT_ROUNDING);
}
잠시 클래스로더의 입장으로 생각해보자.
클래스로더는 새로운 클래스를 로딩해올 때 ‘앞’에서부터 정적 필드를 초기화해나간다. 문제를 알겠나?
public class Money {
public static final Money ZERO = Money.wons(0);
private static final int DEFAULT_SCALE = 2;
private static final RoundingMode DEFAULT_ROUNDING = RoundingMode.HALF_EVEN;
public static Money wons(long amount) {
return new Money(BigDecimal.valueOf(amount));
}
public Money(BigDecimal amount) {
this.amount = amount.setScale(DEFAULT_SCALE, DEFAULT_ROUNDING);
}
}
클래스로더는 이 파일의 정적 변수 중 위에서부터 초기화한다. 즉, 처음에는 필드 ‘ZERO’를 초기화하려고 한다.
‘ZERO’를 초기화하는 과정에서 생성자가 호출된다. 생성자 내부에서는 setScale의 인수로 또 다른 정적 필드인 DEFAULT_SCALE과 DEFAULT_ROUNDING을 넘긴다.
public class BigDecimal ... {
public BigDecimal setScale(int newScale, RoundingMode roundingMode) {
return setScale(newScale, roundingMode.oldMode);
}
}
setScale에서는 roundingMode의 속성을 참조한다. 그리고 여기서 NPE가 발생한다.
즉, ZERO를 초기화하는 시점(생성자를 호출하는 시점)에 DEFAULT_SCALE과 DEFAULT_ROUNDING은 아직 초기화되기 이전이다. (사실, DEFAULT_SCALE은 읽어올 때부터 2일 수 있다.)
따라서 초기화 전 DEFAULT_ROUNDING에는 null 값이 들어있고 이로인해 NPE가 발생한다.
이 문제를 해결하는 방법을 간단하다. 선언 ‘순서’를 바꿔주기만 하면된다.
public class Money {
private static final int DEFAULT_SCALE = 2;
private static final RoundingMode DEFAULT_ROUNDING = RoundingMode.HALF_EVEN;
public static final Money ZERO = Money.wons(0);
private final BigDecimal amount;
public static Money wons(long amount) {
return new Money(BigDecimal.valueOf(amount));
}
public static Money wons(double amount) {
return new Money(BigDecimal.valueOf(amount));
}
public Money(BigDecimal amount) {
this.amount = amount.setScale(DEFAULT_SCALE, DEFAULT_ROUNDING);
}
}

모든 테스트가 정상적으로 동작한다.
정리
BigDecimal을 비교할 때는 주의해야한다. 값을 다룰 때 때 몇 가지 방법이 있는데
- 값만을 비교하고 싶다면 compareTo를 사용하라. equals는 scale까지 비교한다
- compareTo로 equals를 재정의할 때는 hashCode까지 신경써라
- (만들고있는 애플리케이션에 따라) 적절한 scale과 Rouding Mode를 결정하라
정적 필드를 사용할 때에는
- 정적 필드 간의 선언 순서에 유의하라
참고
https://docs.oracle.com/javase/8/docs/api/java/math/BigDecimal.html
https://stackoverflow.com/questions/1359817/using-bigdecimal-to-work-with-currencies?rq=3
댓글