DDD**(Domain-Driven Design)**는 "소프트웨어의 복잡성은 도메인 그 자체의 복잡성에서 나온다"는 전제에서 출발한다. 기술 스택을 아무리 정교하게 고르고 아키텍처 계층을 얼마나 깔끔하게 나누든, 비즈니스 규칙이 엉뚱한 곳에 흩뿌려져 있으면 결국 유지보수 비용이 폭증한다. DDD는 복잡한 도메인을 코드로 직접 표현해 변경 비용을 낮추는 데 목적이 있다.
Anemic domain 모델은 엔티티가 getter/setter와 필드만 가진 데이터 구조체에 불과하고, 실제 비즈니스 로직은 OrderService, OrderValidator, OrderHelper 같은 서비스 레이어에 흩어진다. 이 패턴의 문제는 다음과 같다.
order.cancel()이 있어야 할 자리에 OrderService.cancelOrder(order)가 있고, 동일한 취소 로직이 반환/환불/관리자-강제취소 세 군데에 중복된다.order.setStatus(CANCELLED)를 누구든 호출할 수 있으니, 배송 완료 상태에서 취소되는 사고가 발생한다.Rich domain 모델은 엔티티가 자기 책임을 스스로 진다.
// Anemic
public class Order {
private OrderStatus status;
public void setStatus(OrderStatus s) { this.status = s; }
}
public class OrderService {
public void cancel(Order order) {
if (order.getStatus() == DELIVERED) throw ...;
order.setStatus(CANCELLED);
}
}
// Rich
public class Order {
private OrderStatus status;
public void cancel() {
if (status == DELIVERED)
throw new OrderAlreadyDeliveredException(id);
if (status == CANCELLED) return; // 멱등
this.status = CANCELLED;
registerEvent(new OrderCancelledEvent(id));
}
}
변경 비용 관점에서 보면 Rich 모델은 "취소 규칙이 바뀌었다"는 요구에 한 메서드만 수정하면 된다. Anemic 모델은 규칙이 흩어져 있어 회귀가 난다.
Bounded Context는 특정 도메인 모델이 유효한 경계다. 같은 "상품"이라는 단어라도 주문 컨텍스트의 Product(가격, 할인 가능 여부)와 재고 컨텍스트의 Product(SKU, 창고별 수량)는 다른 모델이다. 하나로 합치면 Product 클래스에 필드 50개와 메서드 30개가 붙어 "신 객체"가 탄생한다.
경계를 정하는 힌트:
Bounded Context 내부에서는 기획자, 개발자, DBA가 같은 용어를 쓴다. 기획 문서에 "주문 취소"라고 쓰여 있으면 코드의 메서드명도 cancel()이어야지 updateStatus(4)가 되어서는 안 된다. 언어가 흔들리면 모델이 흔들린다.
컨텍스트 간 관계를 매핑한다.
식별자(ID)로 동일성을 판별하는 객체. Order, Member가 전형이다. 동일 ID면 필드가 달라도 같은 엔티티다.
값 자체로 동일성이 결정되고 불변이다. Money, Address, DateRange.
@Embeddable
public class Money {
@Column(name = "amount", precision = 19, scale = 4)
private BigDecimal amount;
@Column(name = "currency", length = 3)
private String currency;
protected Money() {}
public Money(BigDecimal amount, String currency) {
if (amount == null || amount.signum() < 0)
throw new IllegalArgumentException("amount must be >= 0");
this.amount = amount;
this.currency = currency;
}
public Money add(Money other) {
requireSameCurrency(other);
return new Money(this.amount.add(other.amount), currency);
}
private void requireSameCurrency(Money other) {
if (!this.currency.equals(other.currency))
throw new IllegalArgumentException("currency mismatch");
}
}
VO로 감싸는 이점은 무결성을 타입으로 보장한다는 점이다. 메서드 시그니처가 cancel(BigDecimal refund, String currency)가 아니라 cancel(Money refund)가 되면 파라미터 순서 실수가 원천 차단된다.
Aggregate는 한 트랜잭션에서 일관성을 함께 지켜야 할 엔티티 묶음이다. Aggregate Root를 통해서만 내부 엔티티에 접근한다. Order가 루트이고 OrderLine은 내부 엔티티다. 외부에서 OrderLine을 직접 저장/수정하면 Order의 총액 같은 불변식이 깨진다.
핵심 설계 원칙:
Order가 Member 객체를 필드로 가지지 않고 MemberId만 가진다. 객체 그래프가 폭주하는 것을 막는다.@Entity
@Table(name = "orders")
public class Order {
@Id
private Long id;
@Embedded
private MemberId memberId; // 다른 Aggregate는 ID 참조
@OneToMany(
mappedBy = "order",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
private List<OrderLine> lines = new ArrayList<>();
@Embedded
private Money totalAmount;
private OrderStatus status;
public static Order place(MemberId memberId, List<OrderLineCommand> commands) {
Order order = new Order();
order.memberId = memberId;
order.status = OrderStatus.PLACED;
commands.forEach(order::addLine);
order.recalculateTotal();
return order;
}
private void addLine(OrderLineCommand cmd) {
this.lines.add(new OrderLine(this, cmd.productId(), cmd.quantity(), cmd.unitPrice()));
}
public void cancel() {
if (status == OrderStatus.DELIVERED)
throw new OrderAlreadyDeliveredException(id);
this.status = OrderStatus.CANCELLED;
}
private void recalculateTotal() {
this.totalAmount = lines.stream()
.map(OrderLine::subtotal)
.reduce(Money.ZERO_KRW, Money::add);
}
}
Aggregate Root 단위로만 존재한다. OrderRepository는 있지만 OrderLineRepository는 없다. OrderLine은 Order를 통해서만 접근한다. 이 원칙 하나만 지켜도 아래의 "Repository에서 직접 필드 수정하기" 같은 안티패턴이 줄어든다.
엔티티 하나에 자연스럽게 속할 수 없는 도메인 로직은 Domain Service로 뺀다. 예: 환율 변환, 여러 Aggregate를 조회해 가격을 계산하는 규칙. 주의할 점은 Domain Service는 인프라(DB, 외부 API)가 아니다. 단지 "어느 엔티티에 붙이기 어색한 도메인 규칙"이다.
"무언가가 일어났다"를 표현하는 불변 객체. OrderPlacedEvent, PaymentCompletedEvent. 사이드 이펙트(메일 발송, 재고 차감, 적립금 부여)를 도메인에서 분리해내는 핵심 장치다.
JPA는 DDD를 강제하지 않지만, Aggregate 패턴과 궁합이 좋다.
Aggregate 내부 엔티티는 Root의 생명주기에 종속된다. 따라서:
cascade = CascadeType.ALL로 Root 저장 시 함께 저장orphanRemoval = true로 컬렉션에서 제거된 자식은 DB에서도 삭제@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
반대로 다른 Aggregate와의 관계에서는 cascade를 절대 걸지 않는다. Order가 Member에 cascade를 걸면 주문 삭제가 회원 삭제로 전파된다.
단방향/양방향, EAGER/LAZY, @JoinColumn/@JoinTable 선택은 Aggregate 경계 안에서 이루어진다.
테이블에 필드가 늘어나는 것을 두려워하지 말고, 도메인의 말을 타입으로 옮긴다.
@Embeddable
public class DateRange {
private LocalDateTime start;
private LocalDateTime end;
public boolean contains(LocalDateTime t) {
return !t.isBefore(start) && !t.isAfter(end);
}
}
@Entity
public class Coupon {
@Id private Long id;
@Embedded private DateRange validPeriod;
public boolean isUsableAt(LocalDateTime t) {
return validPeriod.contains(t);
}
}
전형적인 3-layer 아키텍처에서는 Service → Repository → DB로 흐른다. DDD/헥사고날에서는 Repository 인터페이스를 도메인 레이어에 두고 구현체를 인프라 레이어에 둔다.
domain/
order/
Order.java
OrderRepository.java // 인터페이스 (순수 도메인)
infrastructure/
persistence/
JpaOrderRepository.java // Spring Data JPA 구현
OrderRepositoryImpl.java // 혹은 QueryDSL 조합
이유:
OrderRepository 인터페이스만 두고, 인프라 레이어에 interface JpaOrderRepository extends JpaRepository<...>, OrderRepository를 선언해 합치는 방식이 실무적이다.| 구분 | Application Service | Domain Service |
|---|---|---|
| 역할 | 유스케이스 조립 | 순수 도메인 규칙 |
| 트랜잭션 | 여기서 시작·종료 | 없음 |
| 외부 의존 | DB, 이메일, 외부 API | 원칙적으로 없음 |
| 예 | OrderCancelService.cancel(orderId) | DiscountPolicy.apply(order, coupon) |
트랜잭션 경계는 Application Service에 둔다. @Transactional을 도메인 엔티티나 Domain Service에 붙이면 "이 도메인 규칙은 DB에 붙어 있다"는 선언이 되어 응집이 깨진다.
@Service
@RequiredArgsConstructor
public class CancelOrderApplicationService {
private final OrderRepository orderRepository;
private final RefundGateway refundGateway;
private final ApplicationEventPublisher publisher;
@Transactional
public void cancel(OrderId id, String reason) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
order.cancel(); // 도메인 규칙
// 외부 결제 취소는 트랜잭션 커밋 후에
publisher.publishEvent(new OrderCancelledEvent(id, reason));
}
}
public class Order extends AbstractAggregateRoot<Order> {
public void cancel() {
// ...
registerEvent(new OrderCancelledEvent(this.id));
}
}
AbstractAggregateRoot를 상속하면 save() 시점에 등록된 이벤트가 자동 발행된다. 편하지만 Spring Data JPA에 종속된다.
@Component
public class OrderCancelledHandler {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(OrderCancelledEvent event) {
// 커밋 이후에만 실행 — 보상 처리가 더 단순해진다
}
}
AFTER_COMMIT 페이즈가 중요하다. 주문 취소 트랜잭션이 롤백됐는데 환불 메일이 나가는 사고를 막는다. 반대로 이벤트 처리 실패가 본 트랜잭션에 영향을 주지 않음을 이해하고, 유실 대비 아웃박스 패턴을 병행한다.
OliveYoung 류 커머스 도메인을 다음과 같이 자른다.
Order, OrderLine, OrderStatus. 주문 수명주기 관리.Payment, PaymentMethod. PG 연동, 승인/취소.Coupon, CouponPolicy, IssuedCoupon. 발급, 소진, 검증.Stock, Reservation. 재고 차감/복구.이들 사이의 관계:
OrderId가 Payment 쪽 식별자에 참조된다. Payment는 Order 취소 이벤트를 구독해 환불을 트리거.appliedCouponId만 Order에 저장. Coupon 객체 전체를 들고 오지 않는다.// Order Aggregate는 Coupon 객체를 모른다. ID만 안다.
@Entity
public class Order {
@Embedded private CouponId appliedCouponId; // nullable
@Embedded private Money discountAmount; // 적용 시점에 계산된 금액만 저장
}
이 설계의 이점: 쿠폰 정책이 바뀌어도 과거 주문의 할인 금액은 변하지 않는다. "과거의 사실"을 불변으로 보존하는 것이 Aggregate 설계의 중요한 감각이다.
조회 요구가 복잡해지면 명령(Command) 모델과 조회(Query) 모델을 분리한다. Event Sourcing까지 가지 않아도, Read 전용 DTO와 전용 쿼리만 분리해도 효과가 크다.
// 명령 측 — 도메인 엔티티
@Service
public class PlaceOrderService {
@Transactional
public OrderId place(PlaceOrderCommand cmd) { ... }
}
// 조회 측 — 도메인 엔티티를 거치지 않는 플랫한 DTO
public interface OrderQueryDao {
List<OrderListItemDto> findRecentByMember(MemberId memberId, Pageable p);
OrderDetailDto findDetail(OrderId id);
}
OrderDetailDto는 주문, 결제, 쿠폰, 배송 상태를 한 번의 조인 쿼리나 애플리케이션 조합으로 채운다. Aggregate를 억지로 로딩해 DTO로 변환하는 방식보다 훨씬 단순하고 빠르다. 쓰기 모델은 일관성을 위해, 읽기 모델은 성능을 위해 최적화한다는 관점의 분리다.
"풀 리라이트는 실패한다." 현실적 전략은 다음과 같다.
OrderEntity를 새 도메인의 Order로 변환하는 번역 계층.OrderService 2,000줄, 엔티티는 setter만. → Rich 모델로 로직 이동.orderRepository.cancelOrderIfNotDelivered(id) 같은 메서드. Repository는 저장소 추상화이지 정책 판단자가 아니다.public setter는 불변식을 깨는 최단 경로다. 상태 변경은 cancel(), approve() 같은 의도가 드러나는 메서드로.Order가 Member 엔티티를 직접 들고 있음 → 트랜잭션 경계 혼란, 대규모 객체 그래프 로딩. ID 참조로 바꾼다.@OneToMany(fetch = EAGER)는 Aggregate 경계를 의심하게 만든다. 기본은 LAZY, 필요한 유스케이스에서만 fetch join.@Transactional, @CachePut 등이 붙으면 응집이 깨진다. 이런 관심사는 Application 레이어로.후보자가 가진 다음 경험은 DDD 용어로 자연스럽게 번역된다.
DiscountPolicy, SlotSelectionStrategy 같은 Domain Service로 분리한 사례. OCP를 지키면서 정책 교체 비용을 낮춘 것은 전형적인 DDD식 리팩터링이다.CommonUtil.calculate(...)를 엔티티의 calculate() 메서드로 옮긴 경험 → Anemic에서 Rich로의 이동.면접에서는 "전략 패턴을 적용했습니다"보다 "정책 객체를 Domain Service로 분리해 할인 규칙이 엔티티 상태와 분리되도록 설계했습니다"가 훨씬 강하게 들린다.
project/
├── build.gradle
├── docker-compose.yml
└── src/main/java/com/example/shop/
├── order/
│ ├── domain/
│ │ ├── Order.java
│ │ ├── OrderLine.java
│ │ ├── OrderRepository.java
│ │ └── event/OrderCancelledEvent.java
│ ├── application/
│ │ ├── PlaceOrderService.java
│ │ └── CancelOrderService.java
│ └── infrastructure/
│ └── JpaOrderRepository.java
└── coupon/
└── ...
docker-compose로 MySQL 8을 띄운다.
version: "3.8"
services:
mysql:
image: mysql:8.0
ports: ["3306:3306"]
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: shop
application.yml에서 hibernate.jdbc.batch_size=50, order_inserts=true, order_updates=true를 켠다. Aggregate 저장 시 자식 insert가 배치로 묶여 나가는지 show_sql 로그로 확인한다.
Order.place(...) 정적 팩토리로 주문을 생성하고, OrderLine 3개 중 1개를 컬렉션에서 제거해 orphanRemoval 동작을 확인한다.Order.cancel() 안에서 AbstractAggregateRoot.registerEvent를 호출하고, @TransactionalEventListener(AFTER_COMMIT)가 실제로 커밋 후에만 실행되는지 로그로 검증한다. 의도적으로 예외를 던져 롤백 시 이벤트가 발행되지 않는지 확인.OrderRepository 인터페이스를 도메인 패키지에 두고, 인프라 패키지의 JpaOrderRepository가 이를 extends하도록 구성. 도메인 코드에서 jakarta.persistence.* import가 하나도 없는 상태를 만든다.Money VO를 두 번 생성해 equals 결과를 검증. JPA 로딩 후에도 값이 같으면 동일 객체로 취급되는지 확인.Order가 Member 객체 대신 MemberId만 가지도록 리팩터링하고, N+1 문제가 사라지는 것을 SQL 로그로 확인.OrderQueryDao를 별도로 만들어 주문 목록 화면 전용 쿼리를 작성. 명령 모델을 거치지 않는 것을 확인.Q. 도메인 모델링은 어떻게 하시나요?
저는 도메인을 Bounded Context 단위로 먼저 나눕니다. 같은 "상품"이라도 주문과 재고에서 다른 책임을 지니기 때문에 하나의 모델로 합치지 않습니다. 컨텍스트 안에서는 Entity와 Value Object를 구분해, 식별자로 추적해야 할 것은 Entity, 값 자체가 의미인 것은 VO로 표현합니다. 비즈니스 규칙은 서비스가 아니라 엔티티 자신의 메서드로 두어 불변식이 깨지지 않도록 합니다. 예전에 쿠폰 정책 모듈을 리팩터링할 때
CouponService.validate(coupon, order)에 흩어진 규칙을Coupon.isUsableFor(order)로 옮긴 적이 있는데, 이후 새 정책 추가 비용이 반으로 줄었습니다.
Q. Aggregate 경계는 어떻게 정하시나요?
핵심 질문은 "어떤 것들이 한 트랜잭션에서 함께 일관성을 지켜야 하는가"입니다. 주문과 주문 라인은 금액 합계 불변식이 있으니 한 Aggregate, 주문과 결제는 트랜잭션을 분리할 수 있고 실제로 분리해야 동시성이 확보되니 별도 Aggregate로 둡니다. Aggregate는 작게 유지하고, 다른 Aggregate는 객체가 아닌 ID로 참조합니다. 과거에 Order가 Member 엔티티를 직접 참조해 발생한 N+1과 캐스케이드 사고를 겪고 나서 이 원칙을 철저하게 지킵니다.
Q. 레거시 코드에 DDD를 어떻게 도입하시겠습니까?
풀 리라이트는 거의 실패하니, Strangler Fig 방식으로 접근합니다. 변경 빈도가 높은 모듈부터 식별해 ACL로 둘러싸고, 새 기능은 신 도메인 모델에서 구현합니다. 기존 호출부는 ACL을 통해 호환을 유지합니다. 이 과정에서 Bounded Context 경계를 처음에는 느슨하게 긋고, Ubiquitous Language가 안정된 다음에 점점 단단하게 굳혀갑니다.
AFTER_COMMIT 페이즈에서 발행되는가