핵심 한 줄
돈을
long이나double로 두면 타입이 아무것도 막아주지 않는다 — 부동소수 오차가 새고, 단위를 헷갈리고, 음수가 슬쩍 들어온다. 그래서 돈을 값 객체Money로 만든다. 값으로 동등하고, 불변이며, 스스로를 방어하는 벽돌 하나. 이게 시리즈의 L1, “이 값이 깨질 수 있나?”에 대한 첫 대답이다.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
00.카타 서문 · 01.돈을 primitive로 두면 언젠가 샌다 · 02.불변식은 누가 지키나 · 03.동시에 사면 초과판매된다 · 04.두 번 취소해도 한 번만 · 05.save가 INSERT가 아니었다 · 06.기다리게 하지 말고 거절하라 · 07.트랜잭션 안에서 HTTP를 부르지 마라 · 08.응답을 못 받았다는 실패가 아니다 · 09.요청 하나에 쿼리 1+N · 10.5만 행을 1행으로 · 11.같은 걸 두 번 묻지 않기 · 12.가짜는 내가 시킨 답만 한다 · 13.분명히 잡았는데 터졌다 · 14.5개 주세요 했는데 5만 개를 읽었다 · 15.순수하게 만들었더니 저장이 사라졌다 · 16.설정 한 줄 바꿨더니 세 번 터졌다 · … 그리고 계속
1. 배경 — double price는 언제 샜나
돈을 다루는 첫 코드는 거의 항상 이렇다.
double price = 1000;
double total = price * 1.1; // 부가세 10%문제는 0.1 + 0.2가 0.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의 함정을 피해서 정의한다. 1000과 1000.00은 equals로는 다르지만(scale이 다르다), compareTo로는 같다. 그래서 값 비교에 compareTo를 쓴다.
} else if (amount.compareTo(other.amount) != 0)
return false;1000원은 어떻게 만들어졌든 1000원과 같다. 값 객체의 “값으로 동등”이 이 한 줄에 들어 있다.
6. 마무리 — 벽돌 하나를 세웠다
값 객체는 서문의 L1 질문, *“이 값이 깨질 수 있나?”*에 답하는 방식이다. 그리고 대답은 명령이 아니라 구조다 — “생성자와 타입이 막는다.” 음수 돈은 만들 수 없고, 돈끼리만 더할 수 있고, 한 번 만든 값은 변하지 않는다. 지켜달라고 부탁하는 게 아니라, 애초에 어길 수 없게 만든다.
이제 벽돌 하나는 튼튼하다. 하지만 벽돌 하나로는 집이 안 된다. 여러 Money와 수량이 모여 주문이 되는 순간, “총액이 항목들의 합과 맞나” 같은 새 불변식이 생긴다. 그 일관성은 누가 지키나 — 벽돌이 아니라 덩어리의 문제로 넘어간다.