핵심 한 줄

“주문 총액 = 라인 합”이라는 불변식을 서비스에서 for로 더하는 순간 불변식은 서비스로 샌다. 총액 계산과 그걸 지키는 규칙을 한 경계 안에 넣는 진입점이 애그리거트 루트다. OrderOrderLine들을 소유하고, 방어적 복사·불변·생성 검증으로 경계의 벽을 세운다.

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


1. 배경 — 총액을 어디서 더하나

주문은 라인 여러 개로 이뤄진다. 사과 3개, 우유 2개. 그리고 주문에는 총액이 있다. 자연스러운 첫 구현은 이렇다 — 서비스가 라인을 돌며 더한다.

// 서비스 어딘가
Money total = Money.won(0);
for (OrderLine line : lines) {
    total = total.plus(line.subTotal());
}

동작한다. 문제는 이 코드가 한 군데가 아니라는 것이다. 주문 생성 서비스, 결제 서비스, 정산 배치가 각자 이 루프를 다시 쓴다. “총액 = 라인 합”이라는 규칙이 코드베이스 여기저기에 복제된다.

2. 문제 — 불변식이 서비스로 샌다

총액 = 모든 라인의 subTotal 합. 이건 주문이라는 데이터가 항상 지켜야 하는 불변식이다. 그런데 그 불변식을 지키는 로직이 서비스에 있으면 무슨 일이 벌어지나.

  • 계산이 갈린다. 한 서비스는 배송비를 더하고, 다른 서비스는 안 더한다. 같은 “총액”이 호출처마다 다른 값이 된다.
  • 누가 라인을 몰래 바꾼다. 서비스가 라인 리스트를 들고 있다가 나중에 하나를 추가하면, 이미 계산한 총액은 틀어진다. 데이터와 그 데이터의 규칙이 따로 논다.

불변식을 가진 데이터(라인들)와 그 불변식을 지키는 로직(합산)이 서로 다른 곳에 있는 게 근본 원인이다. 데이터는 도메인에, 규칙은 서비스에 흩어져 있다.

3. 결정 — 애그리거트 루트가 경계를 진다

애그리거트 루트(aggregate root)는 일관성 경계를 지키는 진입점 엔티티다. 서문에서 말한 L2의 질문 — “경계는 어디인가?” — 에 대한 답이다.

규칙은 단순하다. 불변식을 가진 데이터와 그 불변식을 지키는 로직을 한 경계 안에 넣는다. 그리고 외부는 그 경계 안으로 직접 못 들어가고, 루트를 통해서만 접근한다.

  • OrderOrderLine들을 소유한다.
  • 총액 계산은 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 없음. 라인을 갈아끼울 통로 자체가 없다. orderLinefinal. 만들어진 뒤엔 아무도 못 바꾼다.

라인 자신도 불변이다. OrderLine은 필드가 전부 final이고, 생성 시 quantity > 0을 가드하며, subTotal()value.times(quantity)를 스스로 계산한다. 경계 안의 부품 하나하나가 자기를 방어한다.

5. 원리 — 데이터와 규칙을 한 경계에

이 편의 결정을 한 문장으로 줄이면, 불변식을 가진 데이터와 그 불변식을 지키는 로직을 같은 경계 안에 둔다는 것이다. 방어적 복사·불변·생성 검증은 그 경계를 실제로 닫아거는 자물쇠다.

L1(1편)에서 Money가 자기 자신을 방어했다면, L2는 그 값들이 모인 덩어리의 일관성을 누가 지키느냐를 묻는다. 답은 서비스가 아니라 루트다. 서비스는 루트에게 “총액 얼마?”라고 묻지, 직접 더하지 않는다.

6. 여기까지는 한 스레드

지금 Order는 조용한 세계에 산다. 한 번에 한 명이, 한 스레드에서 주문을 만든다. 이 경계는 완벽해 보인다.

하지만 커머스는 조용하지 않다. 같은 재고 하나를 동시에 두 명이 사면, 각자 자기 Order를 만들고 각자 재고를 깎는다. 방어적 복사도 불변도 이 상황은 못 막는다 — 문제가 한 객체 안이 아니라 객체 밖, 두 스레드 사이에 있기 때문이다. 여기서부터 L3, 진짜가 시작된다.

03.동시에 사면 초과판매된다