핵심 한 줄
“주문 총액 = 라인 합”이라는 불변식을 서비스에서
for로 더하는 순간 불변식은 서비스로 샌다. 총액 계산과 그걸 지키는 규칙을 한 경계 안에 넣는 진입점이 애그리거트 루트다.Order가OrderLine들을 소유하고, 방어적 복사·불변·생성 검증으로 경계의 벽을 세운다.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
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. 배경 — 총액을 어디서 더하나
주문은 라인 여러 개로 이뤄진다. 사과 3개, 우유 2개. 그리고 주문에는 총액이 있다. 자연스러운 첫 구현은 이렇다 — 서비스가 라인을 돌며 더한다.
// 서비스 어딘가
Money total = Money.won(0);
for (OrderLine line : lines) {
total = total.plus(line.subTotal());
}동작한다. 문제는 이 코드가 한 군데가 아니라는 것이다. 주문 생성 서비스, 결제 서비스, 정산 배치가 각자 이 루프를 다시 쓴다. “총액 = 라인 합”이라는 규칙이 코드베이스 여기저기에 복제된다.
2. 문제 — 불변식이 서비스로 샌다
총액 = 모든 라인의 subTotal 합. 이건 주문이라는 데이터가 항상 지켜야 하는 불변식이다. 그런데 그 불변식을 지키는 로직이 서비스에 있으면 무슨 일이 벌어지나.
- 계산이 갈린다. 한 서비스는 배송비를 더하고, 다른 서비스는 안 더한다. 같은 “총액”이 호출처마다 다른 값이 된다.
- 누가 라인을 몰래 바꾼다. 서비스가 라인 리스트를 들고 있다가 나중에 하나를 추가하면, 이미 계산한 총액은 틀어진다. 데이터와 그 데이터의 규칙이 따로 논다.
불변식을 가진 데이터(라인들)와 그 불변식을 지키는 로직(합산)이 서로 다른 곳에 있는 게 근본 원인이다. 데이터는 도메인에, 규칙은 서비스에 흩어져 있다.
3. 결정 — 애그리거트 루트가 경계를 진다
애그리거트 루트(aggregate root)는 일관성 경계를 지키는 진입점 엔티티다. 서문에서 말한 L2의 질문 — “경계는 어디인가?” — 에 대한 답이다.
규칙은 단순하다. 불변식을 가진 데이터와 그 불변식을 지키는 로직을 한 경계 안에 넣는다. 그리고 외부는 그 경계 안으로 직접 못 들어가고, 루트를 통해서만 접근한다.
Order가OrderLine들을 소유한다.- 총액 계산은
Order.totalAmount()하나뿐. 계산의 유일한 소유자. - 외부는 라인 리스트를 직접 만지지 못한다.
Order를 통해서만 묻는다.
이제 “총액이 뭐냐”는 질문의 답이 코드베이스에 딱 하나 있다.
4. 코드 근거 — 경계의 벽 세 겹
경계를 선언만 해선 안 샌다는 보장이 없다. Order가 실제로 벽을 세우는 방식은 세 가지다.
public class Order {
private final List<OrderLine> orderLine;
public Order(List<OrderLine> orderlines) {
if (orderlines.size() <= 0) {
throw new IllegalArgumentException("orderline must greater than 0");
}
this.orderLine = List.copyOf(orderlines); // 방어적 복사
}
public Money totalAmount() {
Money result = Money.won(0);
for (OrderLine orderLine : this.orderLine) {
result = result.plus(orderLine.subTotal());
}
return result; // 합산의 유일한 소유자
}
}- 생성 검증. 라인 0개인 주문은 존재할 수 없다. 생성자에서 막는다. 잘못된 상태의
Order는 애초에 태어나지 않는다. - 방어적 복사.
List.copyOf로 넘어온 리스트를 복사한다. 외부가 원본 리스트를 나중에 바꿔도Order내부는 불변이다. 경계 밖의 손이 안쪽에 닿지 못한다. - setter 없음. 라인을 갈아끼울 통로 자체가 없다.
orderLine은final. 만들어진 뒤엔 아무도 못 바꾼다.
라인 자신도 불변이다. OrderLine은 필드가 전부 final이고, 생성 시 quantity > 0을 가드하며, subTotal()은 value.times(quantity)를 스스로 계산한다. 경계 안의 부품 하나하나가 자기를 방어한다.
5. 원리 — 데이터와 규칙을 한 경계에
이 편의 결정을 한 문장으로 줄이면, 불변식을 가진 데이터와 그 불변식을 지키는 로직을 같은 경계 안에 둔다는 것이다. 방어적 복사·불변·생성 검증은 그 경계를 실제로 닫아거는 자물쇠다.
L1(1편)에서 Money가 자기 자신을 방어했다면, L2는 그 값들이 모인 덩어리의 일관성을 누가 지키느냐를 묻는다. 답은 서비스가 아니라 루트다. 서비스는 루트에게 “총액 얼마?”라고 묻지, 직접 더하지 않는다.
6. 여기까지는 한 스레드
지금 Order는 조용한 세계에 산다. 한 번에 한 명이, 한 스레드에서 주문을 만든다. 이 경계는 완벽해 보인다.
하지만 커머스는 조용하지 않다. 같은 재고 하나를 동시에 두 명이 사면, 각자 자기 Order를 만들고 각자 재고를 깎는다. 방어적 복사도 불변도 이 상황은 못 막는다 — 문제가 한 객체 안이 아니라 객체 밖, 두 스레드 사이에 있기 때문이다. 여기서부터 L3, 진짜가 시작된다.