DDD (Domain-Driven Design)
비유로 시작하기
대형 병원을 떠올려 보세요. 내과, 외과, 약제부, 원무과가 모두 “환자”를 다루지만, 각 부서가 바라보는 “환자”의 의미는 완전히 다릅니다. 원무과의 환자는 결제 정보가 핵심이고, 내과의 환자는 진단 이력이 핵심이며, 약제부의 환자는 처방전 목록이 핵심입니다.
DDD(Domain-Driven Design)는 이처럼 비즈니스 도메인의 복잡성을 소프트웨어에 정확하게 반영하기 위한 설계 방법론입니다. 에릭 에반스(Eric Evans)가 2003년 저서에서 정립했으며, “기술이 아닌 도메인이 설계의 중심”이라는 철학을 가집니다.
DDD란 무엇인가
DDD는 크게 두 가지 레벨로 나뉩니다.
- 전략적 설계(Strategic Design): 큰 그림. 어떻게 도메인을 나누고 팀을 조직할 것인가
- 전술적 설계(Tactical Design): 코드 레벨. 도메인 객체를 어떻게 구현할 것인가
전략적 설계
Ubiquitous Language (유비쿼터스 언어)
개발자와 도메인 전문가가 동일한 용어를 사용하는 것입니다. “주문”이 개발자에게는 Order 테이블이고, 영업팀에게는 “계약서”라면 의사소통 비용이 폭발합니다.
나쁜 예:
- 개발자: "user_product_mapping 테이블에 insert 했어요"
- 기획자: "?????"
좋은 예 (유비쿼터스 언어 적용):
- 개발자: "고객이 상품을 장바구니에 담았습니다"
- 기획자: "네, 그 후 결제하면 주문이 생성되는군요"
유비쿼터스 언어는 회의록, 코드, 문서, 테스트 이름까지 모두 동일하게 사용해야 합니다.
Bounded Context (바운디드 컨텍스트)
동일한 언어가 동일한 의미를 갖는 경계입니다. 병원 예시로 돌아가면, “환자(Patient)”라는 단어는 원무과 컨텍스트와 의료 컨텍스트에서 서로 다른 의미와 속성을 가집니다.
- 배송지
- 주문이력] O_Product[Product
- 가격
- 재고] O_Order[Order] end subgraph MARKETING["마케팅 컨텍스트"] M_Customer[Customer
- 구매패턴
- 선호도] M_Segment[Segment] end subgraph SHIPPING["배송 컨텍스트"] D_Order[Order
- 배송지
- 상태] D_Driver[Driver] end
하나의 마이크로서비스가 하나의 Bounded Context에 대응되는 것이 이상적입니다.
Context Map
여러 Bounded Context 간의 관계를 시각화한 것입니다. 주요 패턴:
| 패턴 | 설명 |
|---|---|
| Shared Kernel | 두 팀이 코드 일부를 공유 |
| Customer-Supplier | 공급자가 소비자 요구에 맞춰 API 제공 |
| Conformist | 소비자가 공급자 모델을 그대로 따름 |
| Anti-Corruption Layer (ACL) | 외부 모델로부터 내 도메인 보호 |
| Open Host Service | 공개 프로토콜 제공 |
| Published Language | 공통 교환 언어 사용 (JSON, XML) |
전술적 설계
Entity (엔티티)
고유 식별자(Identity)를 가지며, 생명주기 동안 상태가 변하는 객체입니다.
@Entity
public class Order {
@Id
private OrderId id; // 식별자
private CustomerId customerId;
private OrderStatus status;
private List<OrderItem> items;
private Money totalAmount;
// 도메인 로직이 엔티티 안에 있어야 한다
public void cancel() {
if (this.status == OrderStatus.SHIPPED) {
throw new OrderAlreadyShippedException("배송 중인 주문은 취소할 수 없습니다");
}
this.status = OrderStatus.CANCELLED;
// 도메인 이벤트 발행
registerEvent(new OrderCancelledEvent(this.id));
}
public void addItem(Product product, int quantity) {
validateOrderStatus();
OrderItem item = new OrderItem(product.getId(), product.getPrice(), quantity);
this.items.add(item);
this.totalAmount = this.totalAmount.add(item.getSubtotal());
}
}
핵심: 식별자가 같으면 동일 객체. 속성이 달라도 id가 같으면 같은 엔티티입니다.
Value Object (값 객체)
식별자가 없고, 속성의 조합으로 동일성을 판단하는 불변 객체입니다.
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("금액은 음수일 수 없습니다");
}
this.amount = amount;
this.currency = currency;
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException("통화가 다릅니다");
}
return new Money(this.amount.add(other.amount), this.currency);
}
// equals/hashCode는 amount + currency 기반
@Override
public boolean equals(Object o) {
if (!(o instanceof Money)) return false;
Money m = (Money) o;
return this.amount.equals(m.amount) && this.currency.equals(m.currency);
}
}
좋은 VO 후보: Money, Address, Email, PhoneNumber, DateRange, Coordinate
Aggregate (집합체)
관련 Entity와 VO의 클러스터로, 하나의 트랜잭션 일관성 경계를 형성합니다. 외부에서는 반드시 Aggregate Root를 통해서만 접근합니다.
= Aggregate Root] OI1[OrderItem 1] OI2[OrderItem 2] OI3[OrderItem 3] ADDR[ShippingAddress
Value Object] OR --> OI1 OR --> OI2 OR --> OI3 OR --> ADDR end External[외부 코드] -->|오직 Root만 접근| OR External -.->|직접 접근 금지| OI1
Aggregate 설계 원칙:
- 하나의 트랜잭션 = 하나의 Aggregate 수정
- 다른 Aggregate 참조는 ID로만
- Aggregate 크기는 작게 유지
// 나쁜 예 - Aggregate 경계 위반
orderItem.setPrice(newPrice); // Root를 거치지 않고 직접 수정
// 좋은 예 - Root를 통해 수정
order.updateItemPrice(orderItemId, newPrice);
Repository (리포지토리)
Aggregate의 영속성을 추상화하는 컬렉션처럼 동작하는 인터페이스입니다.
// 도메인 계층의 인터페이스 (인프라 무관)
public interface OrderRepository {
Order findById(OrderId id);
void save(Order order);
List<Order> findByCustomerId(CustomerId customerId);
void delete(OrderId id);
}
// 인프라 계층의 구현체
@Repository
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpaRepo;
private final OrderMapper mapper;
@Override
public Order findById(OrderId id) {
return jpaRepo.findById(id.getValue())
.map(mapper::toDomain)
.orElseThrow(() -> new OrderNotFoundException(id));
}
@Override
public void save(Order order) {
OrderEntity entity = mapper.toEntity(order);
jpaRepo.save(entity);
}
}
Domain Event (도메인 이벤트)
도메인에서 발생한 중요한 사건을 나타냅니다. Aggregate 간 통신 및 부수 효과를 이벤트 기반으로 처리할 때 사용합니다.
// 이벤트 정의
public class OrderPlacedEvent {
private final OrderId orderId;
private final CustomerId customerId;
private final Money totalAmount;
private final Instant occurredAt;
public OrderPlacedEvent(OrderId orderId, CustomerId customerId, Money totalAmount) {
this.orderId = orderId;
this.customerId = customerId;
this.totalAmount = totalAmount;
this.occurredAt = Instant.now();
}
}
// Aggregate에서 이벤트 발행
public class Order extends AbstractAggregateRoot<Order> {
public void place() {
// 비즈니스 로직 ...
this.status = OrderStatus.PLACED;
registerEvent(new OrderPlacedEvent(this.id, this.customerId, this.totalAmount));
}
}
// 이벤트 핸들러 (다른 Bounded Context)
@Component
public class NotificationEventHandler {
@EventListener
public void handleOrderPlaced(OrderPlacedEvent event) {
notificationService.sendOrderConfirmation(event.getCustomerId(), event.getOrderId());
}
}
Domain Service (도메인 서비스)
단일 Entity나 VO에 속하기 애매한 도메인 로직을 담습니다. 상태를 갖지 않습니다.
// 할인 정책 계산 - 여러 Aggregate가 관여
@DomainService
public class DiscountCalculationService {
public Money calculateDiscount(Order order, Customer customer, List<Coupon> coupons) {
Money baseDiscount = calculateVolumeDiscount(order);
Money memberDiscount = calculateMemberDiscount(customer);
Money couponDiscount = coupons.stream()
.map(c -> c.apply(order.getTotalAmount()))
.reduce(Money.ZERO, Money::add);
return baseDiscount.add(memberDiscount).add(couponDiscount);
}
}
레이어 아키텍처와 DDD
Controller, DTO] APP[Application Layer
UseCase, Application Service] DOM[Domain Layer
Entity, VO, Aggregate, Repository Interface, Domain Service] INFRA[Infrastructure Layer
Repository Impl, External API, DB] UI --> APP APP --> DOM INFRA --> DOM style DOM fill:#f9f,stroke:#333,stroke-width:2px
중요한 점: 도메인 계층은 다른 계층에 의존하지 않습니다. 인프라 의존성은 인터페이스(Repository)로 역전시킵니다.
실무 적용 사례
이커머스 주문 시스템 예시
Bounded Context 분리:
├── 상품 컨텍스트 (Product BC)
│ - 상품 등록/수정, 카탈로그 관리
├── 주문 컨텍스트 (Order BC)
│ - 장바구니, 주문 생성, 결제
├── 배송 컨텍스트 (Delivery BC)
│ - 배송 처리, 추적
├── 정산 컨텍스트 (Settlement BC)
│ - 판매자 정산, 수수료
└── 알림 컨텍스트 (Notification BC)
- 이메일, 푸시, SMS
각 BC 간 통신은 도메인 이벤트(Kafka, RabbitMQ)로 비동기 처리하여 결합도를 낮춥니다.
극한 시나리오
시나리오: 대규모 주문 시스템에서 Aggregate 설계 실수
문제: Order Aggregate에 Customer, Product, Inventory를 모두 포함시킨 경우
결과:
- 주문 처리 시 고객/상품/재고 정보 모두 락(Lock)
- 동시 주문 처리 불가 → 병목
- 트랜잭션 범위 과대 → 데드락 위험
해결: Aggregate는 최소화, ID로만 참조
// 나쁜 설계
class Order {
private Customer customer; // 전체 객체 포함
private List<Product> products; // 전체 객체 포함
}
// 좋은 설계
class Order {
private CustomerId customerId; // ID만 참조
private List<OrderItem> items; // items에 productId, 당시 가격만 보관
}
시나리오: Bounded Context 경계 혼재
주문 컨텍스트가 배송 컨텍스트의 내부 구조를 직접 알게 되면 두 컨텍스트가 사실상 합쳐진 것입니다. ACL(Anti-Corruption Layer)로 격리해야 합니다.
DDD 도입 판단 기준
| 상황 | DDD 적합성 |
|---|---|
| 비즈니스 로직이 복잡하고 자주 변함 | 매우 적합 |
| 도메인 전문가와 협업이 중요 | 매우 적합 |
| CRUD 위주의 단순 앱 | 과도한 복잡성 |
| 팀 규모 2인 이하의 소규모 | 오버엔지니어링 |
| 레거시 리팩토링 | Strangler Fig 패턴과 병행 |
DDD는 도구가 아닌 사고 방식입니다. 전부 적용하려 하지 말고, 핵심 도메인에 집중적으로 적용하는 것이 실무 전략입니다.