핵심 한 줄

돈을 long이나 double로 두면 타입이 아무것도 막아주지 않는다 — 부동소수 오차가 새고, 단위를 헷갈리고, 음수가 슬쩍 들어온다. 그래서 돈을 값 객체 Money로 만든다. 값으로 동등하고, 불변이며, 스스로를 방어하는 벽돌 하나. 이게 시리즈의 L1, “이 값이 깨질 수 있나?”에 대한 첫 대답이다.

시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)


1. 배경 — double price는 언제 샜나

돈을 다루는 첫 코드는 거의 항상 이렇다.

double price = 1000;
double total = price * 1.1; // 부가세 10%

문제는 0.1 + 0.20.3이 아니라 0.30000000000000004라는 데서 시작한다. double은 이진 부동소수라 십진 소수를 정확히 담지 못한다. 개당 몇 백 원짜리 계산이 수천 건 쌓이면 반올림 오차가 실제 정산 금액을 어긋나게 만든다. 돈은 “대충 맞으면 되는” 값이 아니다. 1원이 틀리면 그건 버그다.

그래서 long으로 도망친다. 정수니까 오차는 없다. 하지만 이번엔 다른 게 샌다.

2. long이 말해주지 않는 것

long a = 1000; // 원인가, 센트인가?
long b = getUserId();
long c = a + b; // 컴파일된다. 의미는 없다.

long은 그냥 숫자다. 1000인지 센트인지 타입이 말해주지 않는다. 금액과 사용자 ID를 더해도 컴파일러는 통과시킨다 — 둘 다 long이니까. 음수를 넣어도 막지 않는다. 의미 없는 연산이 조용히 컴파일되는 것, 이게 primitive의 진짜 누수다. 오차는 눈에 보이기라도 하지, 이건 런타임까지 숨는다.

타입이 아무 방어도 안 하면, 방어는 전부 사람의 주의력에 떠넘겨진다. 그리고 사람은 언젠가 실수한다.

3. 결정 — 돈을 값 객체로

그래서 돈에게 타입을 준다. Money라는 값 객체다. 원리는 셋뿐이다.

(1) 값으로 동등하다. 같은 금액이면 같은 객체여야 한다. 1000원을 두 번 만들면 둘은 equals여야 한다. 주소가 아니라 값으로 비교한다.

(2) 불변이다. 한 번 만든 Money는 바뀌지 않는다. 필드는 private final, 모든 연산은 자기를 고치는 대신 Money를 반환한다. 공유해도 누가 몰래 바꿀 걱정이 없다.

(3) 스스로를 방어한다. 음수 금액 같은 건 객체 안으로 들어오지도 못하게 생성과 연산에서 막는다.

4. 코드 근거 — 생성부터 통제한다

생성자를 private으로 막고, 팩토리 메서드 하나로만 만들게 한다.

private Money(BigDecimal amount) { this.amount = amount; }
 
public static Money won(long amount) {
    if (amount < 0) throw new IllegalArgumentException("minus not allowed");
    return new Money(BigDecimal.valueOf(amount));
}

Money.won(-500)은 만들어지지 않는다. 음수인 돈이라는 상태가 애초에 존재할 수 없다. 생성 경로가 하나뿐이니 검증도 한 곳에만 있으면 된다.

연산은 전부 새 Money를 돌려준다. 자기 자신은 건드리지 않는다.

public Money minus(Money money) {
    if (this.amount.compareTo(money.amount) < 0)
        throw new IllegalArgumentException("minus not allowed");
    return new Money(this.amount.subtract(money.amount));
}

minus는 결과가 음수가 될 상황 자체를 막는다. 1000원에서 1500원을 뺄 수 없다 — 돈에서 그건 애초에 말이 안 되니까. plus, times, percent도 같은 방식으로 새 객체를 반환한다.

5. 왜 BigDecimal이고, 반올림은 왜 코드에 쓰나

내부 값은 double이 아니라 BigDecimal이다. double을 쓰는 순간 2절의 0.1 + 0.2 문제가 그대로 돌아온다. 돈은 정확해야 하니, 십진수를 정확히 담는 BigDecimal을 쓴다.

정확한 대신 나눗셈에서 딱 떨어지지 않는 경우가 생긴다. 예를 들어 33% 할인. 그래서 반올림 정책을 코드가 명시한다.

public Money percent(int percent) {
    BigDecimal result = amount
        .multiply(BigDecimal.valueOf(percent))
        .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
    return new Money(result);
}

scale 0(소수점 없이 원 단위), HALF_UP(반올림). “이 돈을 어떻게 반올림하는가”가 주석이 아니라 코드에 박혀 있다. 나중에 정책이 바뀌면 여기만 고치면 된다.

동등성도 BigDecimal의 함정을 피해서 정의한다. 10001000.00equals로는 다르지만(scale이 다르다), compareTo로는 같다. 그래서 값 비교에 compareTo를 쓴다.

} else if (amount.compareTo(other.amount) != 0)
    return false;

1000원은 어떻게 만들어졌든 1000원과 같다. 값 객체의 “값으로 동등”이 이 한 줄에 들어 있다.

6. 마무리 — 벽돌 하나를 세웠다

값 객체는 서문의 L1 질문, *“이 값이 깨질 수 있나?”*에 답하는 방식이다. 그리고 대답은 명령이 아니라 구조다 — “생성자와 타입이 막는다.” 음수 돈은 만들 수 없고, 돈끼리만 더할 수 있고, 한 번 만든 값은 변하지 않는다. 지켜달라고 부탁하는 게 아니라, 애초에 어길 수 없게 만든다.

이제 벽돌 하나는 튼튼하다. 하지만 벽돌 하나로는 집이 안 된다. 여러 Money와 수량이 모여 주문이 되는 순간, “총액이 항목들의 합과 맞나” 같은 새 불변식이 생긴다. 그 일관성은 누가 지키나 — 벽돌이 아니라 덩어리의 문제로 넘어간다.

02.불변식은 누가 지키나