목차
항목을 고쳤는데 합계가 안 맞는다
주문이 있고 주문 항목이 있다. 항목의 수량을 바꾸는 기능을 만든다.
앞 글들에서는 주문 하나에 상품 하나인 단순한 모양으로 예를 들었다. 여기서부터는 항목이 여럿인 주문을 쓴다. 한 덩어리라는 개념은 객체가 둘 이상 얽힐 때 비로소 드러나기 때문이다.
@Transactional
public void changeQuantity(Long itemId, int quantity) {
OrderItem item = orderItems.findById(itemId); // 항목을 직접 가져온다
item.setQuantity(quantity); // 수량만 바꾼다
}돌아간다. 그런데 주문 화면을 열어보면 합계 금액이 예전 그대로다.
당연하다. 항목만 고쳤으니까. 합계를 맞추려면 주문도 다시 계산해야 하는데, 이 코드는 주문의 존재를 모른다.
고치는 방법이야 있다. 여기서 주문도 불러와 합계를 다시 계산하면 된다. 문제는 항목을 건드리는 자리가 여기 하나가 아니라는 것이다. 항목 추가, 항목 삭제, 옵션 변경, 쿠폰 적용… 그 모든 자리에서 합계 재계산을 잊지 않아야 한다.
하나라도 빠뜨리면 데이터가 조용히 어긋난 채로 저장된다. 에러도 안 난다.
함께 지켜야 하는 것들이 있다
문제의 정체는 이것이다. “항목의 수량 합이 주문 합계와 같아야 한다”는 규칙이 두 객체에 걸쳐 있다.
이렇게 항상 같이 맞아야 하는 조건을 불변식이라고 부른다. 그리고 불변식이 걸쳐 있는 객체들은 따로 놀 수가 없다. 하나를 고치면 다른 하나도 같이 봐야 한다.
그러면 방법은 하나다. 따로 못 만지게 묶는다.
이 덩어리를 Aggregate라고 부르고, 바깥에서 유일하게 접근할 수 있는 객체를 Aggregate Root라고 부른다. 여기서는 Order가 루트다.
이 개념은 에릭 에반스의 Domain-Driven Design(2003)에서 왔다. 뒤에 나올 “다른 덩어리는 id로 가리킨다”와 “한 트랜잭션에 덩어리 하나”는 원전의 정의라기보다, 본 버논이 Implementing DDD(2013)에서 경험칙으로 정리한 것에 가깝다. 규칙이라기보다 어겼을 때 무엇을 잃는지 아는 채로 어기라는 지침이다.
루트를 통해서만 만진다
묶었으면 규칙이 따라온다. 바깥은 루트만 안다. 항목을 고치고 싶어도 루트에게 부탁한다.
public class Order {
private final List<OrderItem> items = new ArrayList<>();
private Money totalAmount;
public void changeItemQuantity(Long itemId, int quantity) {
OrderItem item = findItem(itemId);
item.changeQuantity(quantity);
recalculateTotal(); // 바꿨으면 합계도 여기서 맞춘다
}
public void addItem(Product product, int quantity) {
items.add(new OrderItem(product, quantity));
recalculateTotal();
}
private OrderItem findItem(Long itemId) {
return items.stream()
.filter(i -> i.getId().equals(itemId))
.findFirst()
.orElseThrow(() -> new NoSuchElementException("없는 항목"));
}
private void recalculateTotal() {
this.totalAmount = items.stream()
.map(OrderItem::subtotal) // 항목의 소계 = 단가 × 수량
.reduce(Money.ZERO, Money::plus);
}
/** 밖으로 줄 때는 목록을 못 고치게 준다. 항목을 몰래 추가·삭제하는 길을 막는다. */
public List<OrderItem> getItems() {
return Collections.unmodifiableList(items);
}
}@Transactional
public void changeQuantity(Long orderId, Long itemId, int quantity) {
Order order = orders.findById(orderId); // 루트를 가져와서
order.changeItemQuantity(itemId, quantity); // 루트에게 시킨다
}이 통로로 들어오는 한 합계 재계산을 잊을 수가 없다. 수량을 바꾸는 메서드가 그 계산을 품고 있기 때문이다. 로직을 어디에 둘 것인가에서 본 “규칙을 객체 안에 넣는다”를, 객체 하나가 아니라 덩어리 단위로 한 것이다.
getItems()가 수정 불가 목록을 돌려주는 것도 같은 이유다. 바깥이 목록을 받아 마음대로 add할 수 있으면 루트를 거치라는 규칙이 무의미해진다.
다만 이걸로 다 막았다고 믿으면 안 된다. unmodifiableList가 막는 건 목록의 구조(추가·삭제)이지, 꺼낸 항목 자체가 아니다. getItems().get(0).changeQuantity(99)가 가능하면 루트를 우회해 수량이 바뀌고 합계는 옛 값 그대로 남는다. 맨 앞에서 본 그 증상이 그대로 돌아온다.
그래서 경계를 정말로 닫으려면 항목을 바꾸는 메서드를 바깥에서 못 부르게 해야 한다. 같은 패키지 안에서만 보이게 좁히거나(package-private), 밖으로는 아예 값만 담은 읽기 전용 객체를 주는 식이다. 어느 쪽이든 “루트만 통한다”는 규칙을 언어가 지켜주게 만드는 것이 요점이다.
경계는 불변식이 정한다
여기서 가장 어려운 질문이 나온다. 어디까지 묶어야 하나.
크게 묶으면 안전해 보인다. 주문에 회원도, 상품도, 배송도 다 넣으면 무엇을 고쳐도 정합성이 맞을 것 같다. 하지만 그러면 주문 하나 건드릴 때마다 그 큰 덩어리를 통째로 읽고 통째로 잠그게 된다.
기준은 안전이 아니라 불변식이다.
“이 둘이 항상 동시에 맞아야 하는가?” 그렇다면 한 덩어리. 아니면 다른 덩어리.
주문과 주문 항목을 보자. 항목이 바뀌면 합계도 그 순간 맞아야 한다. 한 덩어리다.
주문과 회원은 어떤가. 회원의 이름이 바뀐다고 주문이 그 즉시 뭔가를 맞춰야 하나. 아니다. 다른 덩어리다.
주문과 재고는 미묘하다. “재고보다 많이 주문할 수 없다”가 항상 지켜져야 하는 규칙이라면 한 덩어리여야 하는데, 그러면 인기 상품 하나에 모든 주문이 줄을 서게 된다. 그래서 재고를 주문과 같은 덩어리에 넣을지는 트래픽이 정한다. 줄이 문제가 되기 전까지는 한 덩어리로 두는 게 단순하고, 문제가 되면 갈라야 한다. 가르면 무엇을 대신 치러야 하는지는 뒤에서 본다.
경계를 정하는 건 결국 “무엇을 즉시 맞출 것이고 무엇을 나중에 맞출 것인가”의 결정이다.
다른 덩어리는 id로 가리킨다
덩어리를 갈랐으면, 서로를 어떻게 참조하나. 객체 참조가 아니라 식별자로 가리킨다.
public class Order {
private Long memberId; // Member 객체가 아니라 id
private List<OrderItem> items = new ArrayList<>();
}private Member member로 두면 편해 보인다. order.getMember().getGrade()처럼 바로 타고 갈 수 있으니까. 그런데 그 순간 경계가 흐려진다.
- 어디까지가 한 덩어리인지 코드에서 안 보인다. 참조를 타고 어디까지 갈 수 있는지 알 수 없다
- 한 트랜잭션에서 회원까지 같이 고치고 싶어진다. 손이 닿으니까
- 주문 하나 읽었는데 회원·등급·쿠폰까지 줄줄이 딸려 온다
id로 두면 이 유혹이 원천적으로 막힌다. 회원 정보가 필요하면 회원 저장소에 따로 물어야 하고, 그 순간 **“이건 다른 덩어리를 만지는 일”**이라는 게 코드에 드러난다.
JPA를 쓰면 @ManyToOne으로 객체 참조를 거는 게 기본처럼 느껴진다. 편의를 위해 그렇게 두는 선택도 흔하고 틀린 것도 아니다. 다만 그때는 “여기부터는 다른 덩어리”라는 선을 팀이 알고 있어야 한다. 도구가 막아주지 않으니 사람이 지켜야 한다.
트랜잭션은 덩어리를 몇 개까지 품나
경계를 정하고 나면 트랜잭션 이야기가 따라온다. 그런데 여기서 앞의 글들과 부딪히는 것처럼 보이는 지점이 하나 있다. 먼저 그것부터 짚는다.
서비스 계층과 Unit of Work는 주문 생성을 예로 들면서 재고 차감과 주문 저장은 같이 되거나 같이 안 돼야 한다고 했다. 재고만 줄고 주문이 없는 상태를 사고로 봤다. 맞는 말이다.
그런데 이 글의 기준으로 보면 주문과 재고는 서로 다른 덩어리다. 그러면 한 트랜잭션에 덩어리 둘을 넣은 셈이 된다. 어느 쪽이 맞나.
둘 다 맞고, 갈리는 건 규모다.
한 DB 안에서 트랜잭션 하나로 묶을 수 있다면 그냥 묶는 게 낫다. 코드가 단순하고 정합성을 DB가 지켜준다. 앞의 두 글이 그 세계를 다뤘다.
문제는 그 묶음이 커질 때 시작된다. 인기 상품 하나에 주문이 몰리면, 주문마다 그 상품 행을 잠그느라 줄을 선다. 주문끼리는 아무 상관이 없는데 재고 한 행 때문에 순서대로 처리된다. 트래픽이 늘수록 이 줄이 길어진다.
그때 쓰는 카드가 묶음을 쪼개는 것이다.
@Transactional
public Long placeOrder(...) {
Order order = Order.create(...);
orders.save(order); // 이 트랜잭션이 책임지는 건 주문 하나
events.publish(new OrderPlaced(order.getId())); // 뒤에 할 일을 기록해둔다
return order.getId();
}주문은 즉시 확정하고, 재고 차감은 그 기록을 보고 뒤이어 처리한다. 그 사이에 아주 짧게 어긋난 상태가 존재하는 것을 받아들이는 것이다.
| 한 트랜잭션에 묶는다 | 덩어리마다 나눈다 | |
|---|---|---|
| 정합성 | DB가 지켜준다 | 코드가 지켜야 한다 |
| 경합 | 인기 있는 행에 줄이 선다 | 줄이 짧아진다 |
| 실패 처리 | 롤백 한 번 | 되돌리는 절차를 직접 만들어야 |
| 언제 | 대부분의 서비스 | 그 줄이 실제로 문제가 될 때 |
순서가 중요하다. 나누는 쪽이 고급이어서 먼저 가는 게 아니다. 묶어서 안 되는 이유가 생겼을 때 나눈다.
나누기로 했다면 “그다음”을 반드시 설계해야 한다. 커밋 직후에 재고 차감을 호출하는 코드를 넣는 것으로는 부족하다. 그 사이에 서버가 죽으면 주문만 남고 재고는 영영 안 줄어든다. 그래서 할 일을 같은 트랜잭션 안에 기록으로 남기고(위 코드의 events.publish가 그 자리다) 별도 처리기가 그 기록을 보고 실행하는 방식을 쓴다. 이 장치 없이 쪼개면 정합성을 얻는 게 아니라 잃는다.
그리고 재고처럼 어긋나면 업무 사고가 되는 것은 조심해야 한다. 초과 판매는 “잠깐 값이 다른” 정도가 아니라 취소·환불·응대가 따라붙는 일이다. 그래서 이런 자리는 대개 주문 전에 재고를 미리 잡아두는(예약) 절차를 따로 둔다. 나눈다는 건 그냥 미루는 게 아니라 미루는 동안 무엇으로 막을지를 같이 정하는 것이다.
“잠깐 어긋나는 것을 받아들인다”는 아이디어 자체는 이미 만나봤다. DB 복제에서 팔로워가 잠시 옛 데이터를 보여주는 것을 조회 단위로 골라 수용했던 것과 같은 종류의 타협이다. 다만 그쪽은 가만히 두면 저절로 수렴하고, 이쪽은 누군가 이어서 처리해야 수렴한다. 그 차이가 위 WARNING이다.
Repository는 덩어리 단위로 둔다
여기서 Repository 패턴과 정확히 맞물린다.
그 글은 “Repository의 개수는 테이블 수가 아니라 도메인의 덩어리 수를 따라간다”고 정의한다. 그 덩어리가 바로 Aggregate다.
public interface OrderRepository {
Order findById(Long id); // 항목까지 딸려서 통째로 온다
void save(Order order); // 항목까지 통째로 저장된다
}
// OrderItemRepository는 만들지 않는다“통째로 저장된다”가 저절로 되는 건 아니다. JPA라면 루트에서 항목으로 저장이 전파되도록 매핑해줘야 하고(cascade), 목록에서 뺀 항목이 실제로 지워지게 하려면 고아 제거도 켜야 한다(orphanRemoval). 이걸 안 해두면 새 항목이 저장되지 않고 예외로 죽거나, 뺀 항목이 DB에 그대로 남는다. 덩어리 단위 저장은 선언해야 얻는 것이지 기본값이 아니다.
OrderItemRepository를 따로 두는 순간, 바깥에서 항목을 직접 꺼내고 직접 저장할 수 있게 된다. 맨 처음 문제 코드가 바로 그것이었다. 루트를 통해서만 만지자고 정해놓고, 저장소가 뒷문을 열어주는 셈이다.
그래서 규칙이 하나로 이어진다.
| 층 | 단위 |
|---|---|
| 도메인 | Aggregate 하나 |
| 저장소 | Aggregate 하나당 Repository 하나 |
| 트랜잭션 | 기본은 Aggregate 하나 |
세 층이 같은 경계를 쓴다. 경계가 하나로 맞아떨어지면 “이 작업은 어디까지 영향을 주나”라는 질문의 답이 항상 명확해진다.
크게 잡으면 락, 작게 잡으면 손이 간다
경계 선택에는 대가가 있고, 양쪽 다 아프다.
크게 잡으면 정합성은 편하다. 한 트랜잭션 안에서 다 맞추면 되니까. 대신 그 덩어리를 통째로 읽고 통째로 잠근다. 주문에 항목이 500개면 수량 하나 바꾸자고 500개를 읽는다. 동시에 같은 덩어리를 건드리는 요청이 있으면 락 경합이 그만큼 잦아진다.
작게 잡으면 읽고 잠그는 범위가 작다. 대신 여러 덩어리에 걸친 정합성을 코드가 책임져야 한다. 재고 차감이 실패하면 주문을 어떻게 할지, 중간에 서버가 죽으면 누가 이어서 맞출지를 전부 설계해야 한다.
시작할 때의 기준은 작게 잡는 쪽이다. 크게 잡으면 그 안에서 참조가 자유롭게 얽히고, 읽고 잠그는 범위가 커져 경합이 먼저 찾아온다. 작게 잡았다가 정합성 보정 코드가 불어나 다시 합치는 경우도 있으니 한쪽이 늘 옳은 건 아니지만, 크게 잡아 얽힌 걸 갈라내는 쪽이 대체로 손이 더 간다.
경계를 정하는 걸 미루면 경계가 없는 채로 굳는다. 모든 객체가 서로를 참조하고, 트랜잭션은 어디까지 잠그는지 알 수 없게 되고, “이거 고치면 뭐가 같이 바뀌지”에 아무도 답을 못 하는 상태가 된다. 완벽한 경계를 처음부터 찾을 필요는 없지만, 어디쯤 선이 있다는 것만은 정해두고 시작해야 한다.
정리
| Aggregate란 | 항상 같이 맞아야 하는 것들의 덩어리 |
| 루트 | 바깥이 아는 유일한 입구. 모든 변경은 여기를 통한다 |
| 경계 기준 | ”이 둘이 항상 동시에 맞아야 하나” - 그렇다면 한 덩어리 |
| 다른 덩어리 | 객체 참조가 아니라 id로 가리킨다 |
| 트랜잭션 | 하나에 덩어리 하나. 나머지는 곧 맞춘다 |
| Repository | 덩어리당 하나. 항목 전용 저장소를 만들면 뒷문이 생긴다 |
| 대가 | 크게 잡으면 락, 작게 잡으면 정합성을 코드가 진다 |
경계를 정하는 것, 그게 도메인 모델링에서 가장 오래 남는 결정이다. 로직의 자리, 재료의 구분, 그리고 경계. 이것들이 도메인 모델링의 뼈대다.