DDD는 왜 경계부터 그리는가
Bounded Context 식별부터 Aggregate 불변식, 이벤트 기반 통합, CQRS 읽기 모델, 레거시 점진적 전환까지 — 전자상거래 도메인으로 DDD 설계 결정을 추적한다.
- 01 DDD는 복잡도를 어떻게 다루는가
- 02 DDD는 어디서 경계를 긋는가
- 03 DDD의 모든 패턴은 하나의 질문에서 나온다
- 04 Domain Event는 어떻게 결합도를 끊는가
- 05 DDD와 JPA는 왜 긴장 관계인가
- 06 DDD는 왜 경계부터 그리는가
- 07 DDD의 모든 실수는 하나의 질문으로 수렴한다
전자상거래 시스템을 만들 때 첫 번째 질문이 “어떤 테이블을 만들까?”라면, 결국 하나의 Product 테이블에 마케팅팀, 물류팀, 운영팀의 관심사가 뒤섞이는 시스템이 탄생한다. DDD는 이 질문을 “이 도메인에는 어떤 개념들이 있고, 그것들이 어떻게 협력하는가?”로 바꾼다. 그 첫 번째 행위가 경계를 긋는 것이다. 왜 경계가 설계의 출발점이 되는가?
같은 단어, 다른 의미
DDD의 Bounded Context는 “이 경계 안에서는 이 단어가 이 의미다”라는 선언이다. 전자상거래에서 “상품(Product)“은 Context마다 완전히 다른 개념이다.
- 카탈로그 Context: 판매 가능한 항목의 공개 명세. 상품명, 이미지, 카테고리.
- 주문 Context: 구매 시점의 스냅샷. 가격이 나중에 바뀌어도 주문은 영향을 받지 않는다.
- 재고 Context: 창고에서 추적하는 물리적 단위. SKU, 위치, 수량.
- 배송 Context: 패키지의 물리적 속성. 무게, 크기, 취급 주의 여부.
같은 “상품”이 Context를 넘는 순간 의미가 달라진다. Context 경계를 결정하는 기준은 세 가지다. 첫째, Ubiquitous Language의 경계 — 같은 단어가 다른 의미로 쓰이기 시작하는 지점. 둘째, 변경 이유와 속도 — 카탈로그는 마케팅팀이 주 단위로, 재고는 창고 시스템이 실시간으로 변경한다. 셋째, 팀 구조(Conway의 법칙) — 상품팀, 물류팀, 결제팀이 각각 독립 배포해야 한다면 그것이 경계다.
전자상거래에서 이 기준으로 도출되는 Context는 여섯 개다. 주문과 상품이 Core Subdomain(경쟁 우위를 만드는 핵심), 재고·배송·결제가 Supporting Subdomain, 회원 인증이 Generic Subdomain(Keycloak 같은 외부 솔루션으로 충분)이다.
Aggregate가 불변식을 지키는 방법
Context 경계를 그었다면, 다음 질문은 “한 Context 안에서 상태 변화를 누가 책임지는가?”다. 전자상거래에서 가장 흔한 버그 중 하나가 “배송 중인 주문이 취소됐다”는 상태 불일치다. 이것은 상태 전이 로직이 여러 Service에 흩어져 있을 때 발생한다.
Aggregate는 이 문제를 “도메인 메서드만 상태를 바꿀 수 있다”는 원칙으로 해결한다. Order Aggregate는 cancel(), ship(), confirmPayment() 같은 메서드만 외부에 열어두고, 각 메서드 안에서 전이 규칙을 강제한다.
private void transitionTo(OrderStatus next) {
if (!this.status.canTransitionTo(next)) {
throw new InvalidOrderStatusTransitionException(
this.id, this.status, next
);
}
this.status = next;
registerEvent(new OrderStatusChanged(this.id, this.status, next));
}
OrderStatus enum의 canTransitionTo()는 허용된 전이만 정의한다. SHIPPED 상태에서 cancel()을 호출하면 transitionTo(CANCELLED)가 실패한다. 이 규칙은 Aggregate 안에 한 번만 정의되므로 어떤 Service가 호출해도 우회할 수 없다.
상태 전이를 Aggregate에 캡슐화하면 불변식 보장이 강해지지만, 부분 취소(“3개 중 2개만 취소”)는 OrderLine 수준의 별도 상태가 필요해 복잡도가 올라간다. 복잡한 상태 머신은 Spring StateMachine 같은 라이브러리 도입을 고려할 수 있지만, 순수 도메인 코드에 외부 의존이 생긴다.
이벤트가 Context 경계를 넘는 방식
“고객이 주문 버튼을 눌렀다.” 이 한 행위가 재고 차감, 배송 예약, 결제 요청, 이메일 발송, 포인트 적립을 연쇄적으로 유발한다. 이것들을 하나의 트랜잭션으로 묶으면, PG사 타임아웃 하나로 전체가 롤백된다.
이벤트 기반 통합은 이 문제를 “핵심만 동기, 나머지는 비동기”로 분리한다. 주문 저장과 OrderPlaced 이벤트의 Outbox 저장은 같은 트랜잭션이다. 이후 재고 차감, 배송 생성, 이메일은 Kafka를 통해 각 Context가 독립적으로 처리한다.
주문 저장 + Outbox 저장 [같은 트랜잭션]
↓
Outbox Relay → Kafka "order.events"
↓
재고 Context: InventoryDecreased
배송 Context: ShipmentCreated
이메일/포인트: 비동기 처리
취소 시의 보상 트랜잭션(Saga)도 같은 원리다. OrderCancelled 이벤트에 재고 Context는 재고를 복원하고, 결제 Context는 환불을 요청한다. 각 Context는 자신의 보상 로직만 책임진다. 소비자 멱등성(DB 유니크 제약으로 중복 이벤트 무시)은 at-least-once 전달 환경에서 안전한 재처리를 보장한다.
읽기 모델은 왜 따로 존재하는가
“내 주문 목록” 화면은 주문 정보, 배송 현황, 상품 이미지, 리뷰 여부를 함께 보여준다. 이를 매번 여러 Context에 동기 호출로 조합하면 응답이 느리고, 하나가 다운되면 화면 전체가 실패한다.
CQRS는 Command(쓰기)와 Query(읽기)의 모델을 분리한다. OrderApplicationService는 Aggregate 불변식 보장과 이벤트 발행만 담당한다. OrderQueryService는 Domain Event로 동기화된 OrderListView 테이블을 단일 쿼리로 조회한다. OrderListView에는 상품 이미지, 배송 현황, 리뷰 여부가 이미 채워져 있다.
동기화 지연(수 초)과 데이터 신선도는 트레이드오프다. 주문 상태는 같은 트랜잭션 이벤트로 즉시 반영되지만, 배송 현황은 Kafka 처리 시간만큼 지연된다. 이 지연을 비즈니스와 합의된 SLA로 명시하고, 정기 검증 배치로 불일치를 감지해 재구성하는 것이 현실적인 운영 방식이다.
레거시를 죽이지 않고 교체하는 법
새 프로젝트에서 DDD를 처음부터 적용하는 경우보다, 이미 돌아가는 Transaction Script를 개선해야 하는 경우가 훨씬 많다. Strangler Fig 패턴은 레거시를 즉시 삭제하지 않고, 새 코드를 옆에 점진적으로 추가해 레거시를 대체한다.
가장 중요한 원칙은 리팩터링 전에 안전망을 먼저 구축하는 것이다. 기존 동작을 통합 테스트로 기록해 “이것이 올바른 동작이다”라는 기준점을 만든 후, 리팩터링 후에도 이 테스트가 모두 통과해야 한다.
단계는 리스크 순으로 설계한다. Value Object 추출(Money, Address, MembershipLevel)은 Service 코드를 최소로 변경하면서 도메인 언어를 코드에 도입한다. 이후 도메인 로직을 Domain Service나 Aggregate 메서드로 이전하고, 마지막으로 이메일·포인트 같은 부수 효과를 이벤트 핸들러로 분리한다. 각 단계의 완료 기준은 명확하다 — 기존 통합 테스트 모두 통과, 새 단위 테스트 추가, 팀 리뷰 완료.
정리
- Context 경계는 Ubiquitous Language가 달라지는 지점, 변경 이유와 속도, 팀 구조로 결정한다.
- Aggregate는 도메인 메서드만 외부에 열어 불변식을 단일 지점에서 강제한다. setter는 없다.
- Context 간 통합은 Outbox Pattern + Kafka 이벤트로. 핵심만 동기, 나머지는 비동기.
- CQRS 읽기 모델은 여러 Context 데이터를 단일 쿼리로 조회하기 위한 이벤트 동기화 뷰다.
- 레거시 전환은 Strangler Fig — 테스트 안전망 먼저, 단계별 이전, 빅뱅 금지.
다음 글에서는 이 구조에서 주문 Aggregate의 불변식과 상태 전이를 코드 수준에서 더 깊이 추적한다.