- Published on
DDD 애그리게이트 경계 깨짐 - 해결 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 책임을 가진 도메인 객체들이 한 트랜잭션 안에서 억지로 묶이기 시작하면, DDD에서 말하는 애그리게이트(Aggregate) 경계는 쉽게 깨집니다. 처음엔 “그냥 한 번에 저장하면 편하잖아?”로 시작하지만, 시간이 지나면 락 경합, 성능 저하, 예측 불가능한 사이드이펙트, 테스트 난이도 증가, 배포 리스크 확대로 돌아옵니다.
이 글은 애그리게이트 경계가 깨지는 대표 패턴을 짚고, 이를 복구하는 7가지 해결책을 코드와 함께 정리합니다. (특히 마이크로서비스/모놀리식 모두에서 적용 가능한 형태로)
애그리게이트 경계가 깨졌다는 신호
다음 중 2~3개 이상 해당되면 경계가 이미 훼손됐을 가능성이 큽니다.
- 한 유스케이스에서
Order,Member,Coupon,Inventory,Payment등을 한 트랜잭션으로 업데이트한다. - 엔티티가 다른 애그리게이트 루트를 직접 참조한다. 예:
Order가Member엔티티를 필드로 가진다. - 리포지토리에서
join fetch로 여러 루트를 한 번에 끌어와 변경한다. - “일관성”을 이유로 모든 것을 즉시 강제하려고 한다(사실은 즉시 일관성 강박).
- 도메인 규칙이 엔티티가 아니라 서비스 계층에 흩어져 있고, 서비스가 여러 리포지토리를 만지며 조율한다.
원칙 리마인드: 애그리게이트의 약속
애그리게이트는 다음을 보장하기 위해 존재합니다.
- 불변식(invariant) 은 애그리게이트 내부에서만 강제한다.
- 애그리게이트 간 연관은 ID 참조로만 표현한다.
- 트랜잭션 경계는 보통 애그리게이트 1개가 기본이다.
- 애그리게이트 간 일관성은 대개 최종 일관성(eventual consistency) 으로 해결한다.
이 약속이 깨질 때, 아래 7가지 처방을 적용합니다.
해결 1) “즉시 일관성”이 필요한 불변식만 남기고 나머지는 분리
가장 흔한 실수는 “비즈니스적으로 관련이 있다”를 “같은 트랜잭션이어야 한다”로 오해하는 것입니다.
- 같은 애그리게이트에 있어야 하는 것: 같은 불변식을 공유하는 것
- 다른 애그리게이트로 분리 가능한 것: 상태 동기화가 가능하고, 약간의 지연을 허용 가능한 것
예를 들어 주문 생성 시 재고 차감과 결제 승인까지 한 번에 묶으면, 주문 애그리게이트가 모든 외부 상태에 종속됩니다. 대신 주문은 “결제 대기/재고 예약 대기” 상태로 두고, 후속 처리는 이벤트로 넘깁니다.
// 주문 애그리게이트는 "주문 자체"의 불변식만 보장
public class Order {
private OrderId id;
private OrderStatus status;
private List<OrderLine> lines;
public void place() {
if (lines == null || lines.isEmpty()) {
throw new IllegalStateException("주문 항목이 필요합니다");
}
this.status = OrderStatus.PLACED;
// 재고/결제는 여기서 직접 처리하지 않음
}
}
해결 2) 애그리게이트 간 참조를 엔티티가 아닌 ID로 바꾸기
경계 깨짐의 “기술적 시작점”은 보통 객체 그래프입니다. Order가 Member 엔티티를 직접 들고 있으면, JPA 영속성 컨텍스트에서 변경 감지가 연쇄적으로 발생하고, 결국 한 트랜잭션에서 다 바꾸는 구조로 굳어집니다.
// 나쁜 예: 다른 애그리게이트 루트를 직접 참조
class Order {
private Member member; // 경계 침범
}
// 좋은 예: ID로만 참조
class Order {
private MemberId memberId;
}
추가로, 읽기 모델에서만 필요한 정보(회원명 등)는 스냅샷 값(denormalized attribute)로 들고 가는 편이 낫습니다.
class Order {
private MemberId memberId;
private String memberNameSnapshot; // 주문 당시 이름
}
해결 3) 도메인 서비스는 “조율”이 아니라 “도메인 연산”만 맡기기
경계가 깨진 시스템에서는 Application Service가 여러 리포지토리를 불러 한 트랜잭션으로 업데이트합니다. 이를 줄이려면:
- Application Service: 유스케이스 흐름(입력 검증, 트랜잭션, 이벤트 발행)을 담당
- Domain Service: 한 애그리게이트에 귀속되지 않는 순수 도메인 계산/정책만 담당
@Service
public class PlaceOrderUseCase {
private final OrderRepository orderRepository;
@Transactional
public OrderId place(PlaceOrderCommand cmd) {
Order order = Order.create(cmd.memberId(), cmd.lines());
order.place();
orderRepository.save(order);
// 다른 애그리게이트 변경은 여기서 직접 하지 않는다.
// 대신 이벤트 발행(해결 4, 5로 연결)
return order.getId();
}
}
해결 4) 애그리게이트 간 동기화는 도메인 이벤트 + 핸들러로 전환
경계를 복구하는 가장 강력한 방법은 “다른 애그리게이트를 즉시 수정”하는 대신, 도메인 이벤트로 의도를 전달하는 것입니다.
- OrderPlaced → 재고 예약, 결제 시도
- PaymentApproved → 주문 상태 PAID
- InventoryReservationFailed → 주문 취소/보류
public record OrderPlacedEvent(String orderId, String memberId) {}
@Component
public class InventoryOnOrderPlacedHandler {
private final InventoryService inventoryService;
@EventListener
public void handle(OrderPlacedEvent e) {
inventoryService.reserve(e.orderId());
}
}
다만 “이벤트 발행 = 곧바로 신뢰성 확보”는 아닙니다. 트랜잭션 커밋과 이벤트 발행 사이의 갭, 중복/순서 문제는 반드시 다뤄야 합니다.
이 지점은 Outbox 패턴이 사실상 표준 해법이며, 자세한 내용은 아래 글이 실전적으로 도움이 됩니다.
해결 5) SAGA(프로세스 매니저)로 장기 트랜잭션을 모델링
주문-결제-재고-배송처럼 여러 단계가 얽히면 “한 트랜잭션”이 아니라 “하나의 비즈니스 프로세스”입니다. 이때 애그리게이트 경계를 억지로 합치지 말고, SAGA(또는 Process Manager) 로 상태 머신을 둡니다.
- 각 단계는 독립 트랜잭션
- 실패 시 보상 트랜잭션(Compensation)
- 프로세스 상태는 별도의 스토어(또는 주문 애그리게이트의 상태)로 관리
public class OrderSaga {
public void on(OrderPlacedEvent e) {
// 1) 재고 예약 커맨드
// 2) 결제 승인 커맨드
// 결과 이벤트에 따라 다음 단계 진행
}
public void on(InventoryReservationFailedEvent e) {
// 보상: 결제 취소 커맨드, 주문 취소 등
}
}
SAGA를 도입하면 “경계를 깨서 즉시 처리”하던 요구를 명시적인 프로세스 모델로 바꿀 수 있어, 복잡도가 코드 여기저기에 퍼지지 않습니다.
해결 6) 조회/명령 분리(CQRS)로 ‘조인 기반 수정’을 제거
경계가 깨진 팀에서 자주 나오는 코드가 이겁니다.
- 화면에 필요한 데이터를 조인으로 한 번에 조회
- 그 조회 결과 객체 그래프를 변경
- 여러 애그리게이트가 한 트랜잭션에서 수정
해결은 단순합니다.
- Command 모델: 애그리게이트 단위로만 로드/수정
- Query 모델: 화면/리포트 최적화된 읽기 전용 뷰(조인/머터리얼라이즈드 뷰/검색 인덱스)
예: 주문 상세 화면은 order_detail_view 같은 읽기 모델에서 조인으로 빠르게 가져오고, 수정은 Order 애그리게이트만 대상으로 수행합니다.
-- 읽기 모델(예시): 화면용 뷰/테이블
SELECT o.id, o.status, o.member_name_snapshot, sum(ol.amount) total
FROM orders o
JOIN order_lines ol ON ol.order_id = o.id
WHERE o.id = :orderId
GROUP BY o.id, o.status, o.member_name_snapshot;
CQRS를 크게 도입하지 않더라도, “조인 조회 결과로 도메인을 수정”하는 습관만 끊어도 경계 복구 효과가 큽니다.
해결 7) 트랜잭션 경계를 재정의하고, 실패 모드를 설계로 흡수
애그리게이트 경계가 깨지는 이유 중 하나는 “실패를 싫어해서”입니다.
- 재고 예약이 실패할 수 있다
- 결제 승인이 지연될 수 있다
- 외부 API가 타임아웃 날 수 있다
이 실패를 애플리케이션이 견디도록 만들지 않으면, 개발자는 본능적으로 “한 번에 처리해서 실패를 없애자”로 갑니다. 하지만 실제로는 실패가 사라지지 않고, 단지 더 큰 트랜잭션과 더 큰 장애로 증폭됩니다.
실전 처방:
- 외부 호출은 트랜잭션 밖에서(또는 비동기)
- 애그리게이트 상태에 “대기/실패/보상중” 같은 중간 상태를 포함
- 멱등성 키(idempotency key)로 중복 처리 방지
- 재시도 정책과 DLQ(Dead Letter Queue)로 운영 가능성 확보
또한 Spring 기반이라면 트랜잭션이 의도대로 걸리지 않아 “한 번에 처리”가 더 위험해지는 경우도 있습니다. 아래 글을 함께 보면, 경계 복구 후에도 트랜잭션이 기대대로 동작하는지 점검하는 데 도움이 됩니다.
// 외부 결제 승인 호출은 트랜잭션 밖/비동기로 분리하는 예시
@Transactional
public void markPaymentRequested(OrderId orderId) {
Order order = orderRepository.getById(orderId);
order.requestPayment();
}
// 비동기 워커/핸들러에서 결제 API 호출
public void callPaymentGateway(OrderId orderId) {
// timeout/재시도/멱등성 고려
}
체크리스트: 경계 복구를 위한 10분 자가진단
- 애그리게이트 루트끼리 엔티티 참조를 하고 있지 않은가? → ID 참조로 변경
- 한 유스케이스에서 리포지토리 3개 이상을 저장하고 있지 않은가? → 이벤트/SAGA 검토
- “조인해서 가져온 객체 그래프”를 수정하고 있지 않은가? → CQRS/읽기 모델 분리
- 외부 API 호출이 트랜잭션 내부에 있지 않은가? → 비동기화/아웃박스
- 불변식이 애그리게이트 밖(서비스 계층)에 흩어져 있지 않은가? → 애그리게이트로 이동
마무리
애그리게이트 경계가 깨진 시스템은 처음엔 개발 속도가 빨라 보이지만, 결국 변경 비용이 폭증합니다. 복구의 핵심은 “연관된 것”과 “같은 트랜잭션이어야 하는 것”을 분리하고, 애그리게이트 간 협업을 이벤트/프로세스(SAGA)/읽기 모델(CQRS) 로 풀어내는 것입니다.
정리하면 7가지 처방은 다음과 같습니다.
- 불변식만 남기고 즉시 일관성 범위를 축소
- 애그리게이트 간 참조를 ID로 제한
- 도메인 서비스의 역할을 순수 도메인 연산으로 축소
- 도메인 이벤트로 동기화 전환(Outbox로 신뢰성 확보)
- SAGA로 장기 비즈니스 프로세스 모델링
- CQRS로 조인 기반 수정을 제거
- 트랜잭션/실패 모드를 설계에 포함(멱등성, 재시도, 중간 상태)
이 7가지를 단계적으로 적용하면, “경계가 깨져서 커진 트랜잭션”을 “명시적이고 운영 가능한 도메인 흐름”으로 되돌릴 수 있습니다.