Published on

DDD 애그리게이트 경계 깨짐 - 해결 7가지

Authors

서로 다른 책임을 가진 도메인 객체들이 한 트랜잭션 안에서 억지로 묶이기 시작하면, DDD에서 말하는 애그리게이트(Aggregate) 경계는 쉽게 깨집니다. 처음엔 “그냥 한 번에 저장하면 편하잖아?”로 시작하지만, 시간이 지나면 락 경합, 성능 저하, 예측 불가능한 사이드이펙트, 테스트 난이도 증가, 배포 리스크 확대로 돌아옵니다.

이 글은 애그리게이트 경계가 깨지는 대표 패턴을 짚고, 이를 복구하는 7가지 해결책을 코드와 함께 정리합니다. (특히 마이크로서비스/모놀리식 모두에서 적용 가능한 형태로)

애그리게이트 경계가 깨졌다는 신호

다음 중 2~3개 이상 해당되면 경계가 이미 훼손됐을 가능성이 큽니다.

  • 한 유스케이스에서 Order, Member, Coupon, Inventory, Payment 등을 한 트랜잭션으로 업데이트한다.
  • 엔티티가 다른 애그리게이트 루트를 직접 참조한다. 예: OrderMember 엔티티를 필드로 가진다.
  • 리포지토리에서 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로 바꾸기

경계 깨짐의 “기술적 시작점”은 보통 객체 그래프입니다. OrderMember 엔티티를 직접 들고 있으면, 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가지 처방은 다음과 같습니다.

  1. 불변식만 남기고 즉시 일관성 범위를 축소
  2. 애그리게이트 간 참조를 ID로 제한
  3. 도메인 서비스의 역할을 순수 도메인 연산으로 축소
  4. 도메인 이벤트로 동기화 전환(Outbox로 신뢰성 확보)
  5. SAGA로 장기 비즈니스 프로세스 모델링
  6. CQRS로 조인 기반 수정을 제거
  7. 트랜잭션/실패 모드를 설계에 포함(멱등성, 재시도, 중간 상태)

이 7가지를 단계적으로 적용하면, “경계가 깨져서 커진 트랜잭션”을 “명시적이고 운영 가능한 도메인 흐름”으로 되돌릴 수 있습니다.