들어가며
얼마 전 금요일 저녁, 기획자가 "주문 취소 후 환불까지 걸리는 리드타임을 절반으로 줄여달라"고 요청했습니다. 3년 동안 쌓인 OrderService 클래스를 열어보니 1,800줄이 넘었고, 환불 로직이 어느 메서드에 흩어져 있는지 파악하는 데만 20분이 걸렸습니다. 결국 수정은 2시간이었는데, "어디를 고쳐야 할지" 결정하는 데 반나절을 써야 했습니다.
익숙한 장면 아닌가요? 서비스 계층은 해가 갈수록 비대해지고, 새 요구사항 하나를 넣으려면 먼저 다른 5개 메서드가 뭘 하는지 해독해야 합니다. 비즈니스 로직이 Service에 있는지 Util에 있는지, 아니면 JPA Entity의 setter 뒤에 숨어 있는지조차 불분명해집니다. 팀에 들어온 지 6개월 된 동료에게 "이 도메인 설명 좀 해달라"고 했을 때, 아무도 명쾌하게 답하지 못하는 상황도 겪어보셨을 겁니다.
2003년 Eric Evans가 이 문제를 처음 체계화한 것이 DDD(Domain-Driven Design)입니다. 20년이 흐른 지금, DDD는 "이론적으로는 아는데 실제 코드에 어떻게 녹여야 할지 모르겠는" 대표적인 주제가 됐습니다. 현업에서 부딪히는 질문은 언제나 구체적입니다. Entity와 Value Object의 경계는 어디에 긋는가, Aggregate는 어느 범위까지 묶어야 하는가, Repository 인터페이스는 도메인에 둘 것인가 인프라에 둘 것인가.
이 글은 DDD 이론을 요약하는 글이 아닙니다. 실제 주문 도메인을 예로 들어, 전략적 설계(Bounded Context, Context Map)부터 전술적 구현(Entity, VO, Aggregate, Repository, Domain Event)까지 "코드 레벨에서 어떤 결정을 내려야 하는지"를 다룹니다. 읽고 나면 다음번 레거시 리팩토링에서 어떤 클래스부터 손대야 할지, 그리고 Service에 뭉쳐 있는 로직을 어느 객체로 옮겨야 할지 감이 잡힐 것입니다.
전략적 설계 (Strategic Design)
전략적 설계는 큰 시스템을 의미 있는 경계로 나누는 것입니다. MSA 아키텍처 완벽 가이드 마이크로서비스 분리의 기준이 되기도 합니다. MSA 환경에서 API Gateway 패턴 활용하기
Ubiquitous Language (보편 언어)
DDD의 핵심 원칙 중 하나입니다. 개발자와 비즈니스 전문가가 동일한 용어를 사용해야 합니다.
| 잘못된 예 | Ubiquitous Language 적용 |
|---|---|
| UserVO, OrderDTO | Customer, Order |
| processData() | placeOrder(), cancelOrder() |
| flag = 1이면 완료 | OrderStatus.COMPLETED |
| item_tbl, user_tbl | 도메인 용어 그대로 테이블명 |
코드에서 사용하는 클래스명, 메서드명, 변수명이 비즈니스 용어와 일치해야 합니다. "이 메서드가 뭘 하는지" 비즈니스 담당자에게 코드를 보여줘도 이해할 수 있어야 합니다.
Bounded Context (경계 컨텍스트)
같은 단어라도 맥락에 따라 의미가 다릅니다. Bounded Context는 이 맥락의 경계를 명시적으로 정의합니다.
// 주문 컨텍스트에서의 "상품"
public class OrderItem {
private Long productId;
private String productName;
private BigDecimal price; // 주문 시점의 가격
private int quantity;
// 주문에 필요한 정보만 포함
}
// 상품 카탈로그 컨텍스트에서의 "상품"
public class Product {
private Long id;
private String name;
private String description;
private BigDecimal listPrice; // 정가
private BigDecimal salePrice; // 판매가
private Category category;
private List<ProductImage> images;
private int stockQuantity;
// 상품 관리에 필요한 풍부한 정보
}
// 배송 컨텍스트에서의 "상품"
public class ShippingItem {
private Long productId;
private String productName;
private int quantity;
private double weight; // 무게
private Dimension dimension; // 크기
// 배송에 필요한 물리적 정보
}
같은 "상품"이지만 각 컨텍스트에서 필요한 속성과 행위가 전혀 다릅니다. 이것을 하나의 Product 클래스로 통합하면 거대한 God Object가 됩니다.
Context Map (컨텍스트 맵)
Bounded Context 간의 관계를 정의합니다. 대표적인 관계 유형은 다음과 같습니다.
| 관계 유형 | 설명 | 예시 |
|---|---|---|
| Shared Kernel | 두 컨텍스트가 공통 모델 공유 | 공통 도메인 라이브러리 |
| Customer-Supplier | 공급자가 고객의 요구를 반영 | 주문 → 결제 서비스 |
| Conformist | 하류가 상류 모델을 그대로 따름 | 외부 API 연동 |
| Anti-Corruption Layer | 외부 모델의 침투를 방지하는 계층 | 레거시 시스템 통합 |
| Open Host Service | 잘 정의된 프로토콜로 서비스 제공 | REST API |
| Published Language | 공유된 언어로 통신 | JSON Schema, Protobuf |
Anti-Corruption Layer 예시
// 레거시 시스템의 모델 (변경 불가)
public class LegacyOrderData {
private String ord_no; // 주문번호
private String cust_cd; // 고객코드
private int ord_amt; // 주문금액 (int)
private String ord_stat_cd; // 상태코드 ("01", "02", "03")
private String reg_dtm; // 등록일시 (yyyyMMddHHmmss)
}
// Anti-Corruption Layer
@Component
public class OrderAntiCorruptionLayer {
public Order translate(LegacyOrderData legacy) {
return Order.builder()
.orderNumber(new OrderNumber(legacy.getOrd_no()))
.customerId(new CustomerId(legacy.getCust_cd()))
.totalAmount(Money.of(legacy.getOrd_amt()))
.status(translateStatus(legacy.getOrd_stat_cd()))
.orderedAt(parseDateTime(legacy.getReg_dtm()))
.build();
}
private OrderStatus translateStatus(String code) {
return switch (code) {
case "01" -> OrderStatus.PLACED;
case "02" -> OrderStatus.PAID;
case "03" -> OrderStatus.SHIPPED;
case "09" -> OrderStatus.CANCELLED;
default -> throw new IllegalArgumentException(
"Unknown legacy status: " + code);
};
}
private LocalDateTime parseDateTime(String dtm) {
return LocalDateTime.parse(dtm,
DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
}
}
전술적 설계 (Tactical Design)
전술적 설계는 Bounded Context 내부의 도메인 모델을 구현하는 구체적인 패턴입니다.
Entity (엔티티)
고유한 식별자(Identity)를 갖는 도메인 객체입니다. 식별자가 같으면 같은 객체입니다.
public class Order {
private final OrderId id;
private CustomerId customerId;
private OrderStatus status;
private List<OrderLine> orderLines;
private Money totalAmount;
private LocalDateTime orderedAt;
// 비즈니스 로직을 엔티티 안에 캡슐화
public void addOrderLine(Product product, int quantity) {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException(
"확정된 주문에는 상품을 추가할 수 없습니다");
}
OrderLine line = new OrderLine(product.getId(),
product.getPrice(), quantity);
orderLines.add(line);
recalculateTotal();
}
public void place() {
if (orderLines.isEmpty()) {
throw new IllegalStateException(
"주문 항목이 비어있습니다");
}
this.status = OrderStatus.PLACED;
this.orderedAt = LocalDateTime.now();
// 도메인 이벤트 등록
registerEvent(new OrderPlacedEvent(this.id, this.customerId,
this.totalAmount));
}
public void cancel(String reason) {
if (!status.isCancellable()) {
throw new IllegalStateException(
"현재 상태에서는 취소할 수 없습니다: " + status);
}
this.status = OrderStatus.CANCELLED;
registerEvent(new OrderCancelledEvent(this.id, reason));
}
private void recalculateTotal() {
this.totalAmount = orderLines.stream()
.map(OrderLine::getSubTotal)
.reduce(Money.ZERO, Money::add);
}
// equals/hashCode는 id 기반
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order order)) return false;
return id.equals(order.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}
Value Object (값 객체)
식별자 없이 속성 값으로 동등성을 판단하는 불변 객체입니다. DDD에서 가장 많이 사용됩니다.
// Money 값 객체 (할인 연산으로 일시적 음수가 될 수 있으므로 생성자에서는 음수 허용,
// 외부 노출 시점에만 isNegative() 체크 후 0으로 처리)
public record Money(BigDecimal amount, Currency currency) {
public static final Money ZERO = new Money(BigDecimal.ZERO, Currency.KRW);
public Money {
Objects.requireNonNull(amount, "금액은 필수입니다");
Objects.requireNonNull(currency, "통화는 필수입니다");
}
public static Money of(long amount) {
return new Money(BigDecimal.valueOf(amount), Currency.KRW);
}
public Money add(Money other) {
validateSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money subtract(Money other) {
validateSameCurrency(other);
return new Money(this.amount.subtract(other.amount), this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amount.multiply(
BigDecimal.valueOf(quantity)), this.currency);
}
public boolean isNegative() {
return this.amount.compareTo(BigDecimal.ZERO) < 0;
}
private void validateSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException(
"다른 통화끼리 연산할 수 없습니다");
}
}
}
// Address 값 객체
public record Address(
String zipCode,
String city,
String street,
String detail
) {
public Address {
if (zipCode == null || zipCode.isBlank()) {
throw new IllegalArgumentException("우편번호는 필수입니다");
}
}
public String fullAddress() {
return String.format("%s %s %s", city, street, detail);
}
}
Aggregate (애그리거트)
관련된 Entity와 Value Object를 하나의 일관성 경계로 묶는 것입니다. Aggregate는 DDD에서 가장 중요하고 가장 어려운 개념입니다.
// Order Aggregate
// Order가 Aggregate Root
// OrderLine은 Order 없이 존재할 수 없는 하위 엔티티
/*
Aggregate 설계 원칙:
1. Aggregate Root를 통해서만 내부 객체를 수정
2. 하나의 트랜잭션에서 하나의 Aggregate만 수정
3. 다른 Aggregate는 ID로만 참조
4. Aggregate 경계는 비즈니스 불변식(invariant) 기준
*/
// ✅ 올바른 설계: ID로 참조
public class Order {
private OrderId id;
private CustomerId customerId; // Customer Aggregate의 ID만 참조
private List<OrderLine> orderLines; // Order Aggregate 내부에 포함
}
// ❌ 잘못된 설계: 직접 참조
public class Order {
private OrderId id;
private Customer customer; // 다른 Aggregate를 직접 참조
private List<OrderLine> orderLines;
}
Repository (리포지토리)
Aggregate의 영속성을 담당합니다. Aggregate Root 단위로만 Repository를 만듭니다.
// 도메인 계층의 Repository 인터페이스
public interface OrderRepository {
Order findById(OrderId id);
OrderId save(Order order);
void delete(Order order);
List<Order> findByCustomerId(CustomerId customerId);
}
// 인프라 계층의 구현
@Repository
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpaRepository;
private final OrderMapper mapper;
@Override
public Order findById(OrderId id) {
OrderEntity entity = jpaRepository.findById(id.getValue())
.orElseThrow(() -> new OrderNotFoundException(id));
return mapper.toDomain(entity); // JPA Entity → 도메인 객체 변환
}
@Override
public OrderId save(Order order) {
OrderEntity entity = mapper.toEntity(order);
OrderEntity saved = jpaRepository.save(entity);
return new OrderId(saved.getId());
}
}
Domain Event (도메인 이벤트)
도메인에서 발생한 의미 있는 사건을 표현합니다. Aggregate 간 통신의 핵심 메커니즘입니다. CQRS와 이벤트 소싱 패턴 자세히 보기
// 도메인 이벤트
public record OrderPlacedEvent(
OrderId orderId,
CustomerId customerId,
Money totalAmount,
LocalDateTime occurredAt
) {
public OrderPlacedEvent(OrderId orderId, CustomerId customerId,
Money totalAmount) {
this(orderId, customerId, totalAmount, LocalDateTime.now());
}
}
// Aggregate Root에 이벤트 등록 기능 추가
public abstract class AggregateRoot {
@Transient
private final List<Object> domainEvents = new ArrayList<>();
protected void registerEvent(Object event) {
domainEvents.add(event);
}
public List<Object> getDomainEvents() {
return Collections.unmodifiableList(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}
// Spring Data의 @DomainEvents 활용
@Entity
public class Order extends AbstractAggregateRoot<Order> {
public void place() {
this.status = OrderStatus.PLACED;
registerEvent(new OrderPlacedEvent(
this.id, this.customerId, this.totalAmount));
}
// AbstractAggregateRoot가 save() 시 자동으로 이벤트 발행
}
Domain Service (도메인 서비스)
특정 Entity나 Value Object에 속하지 않지만, 도메인 로직에 해당하는 연산을 담당합니다.
// 도메인 서비스: 두 Aggregate에 걸친 비즈니스 로직
@DomainService
public class OrderPricingService {
public Money calculateFinalPrice(
Order order,
CustomerGrade customerGrade,
List<Coupon> coupons) {
Money basePrice = order.getTotalAmount();
// 등급별 할인
Money gradeDiscount = applyGradeDiscount(basePrice, customerGrade);
// 쿠폰 할인 (최대 할인 쿠폰 1개만 적용)
Money couponDiscount = coupons.stream()
.map(c -> c.calculateDiscount(basePrice))
.max(Comparator.naturalOrder())
.orElse(Money.ZERO);
// 최종 가격 (최소 0원)
Money finalPrice = basePrice
.subtract(gradeDiscount)
.subtract(couponDiscount);
return finalPrice.isNegative() ? Money.ZERO : finalPrice;
}
}
DDD vs 전통 레이어드 아키텍처
| 관점 | 전통 레이어드 | DDD |
|---|---|---|
| 도메인 객체 | 빈약한 도메인 모델 (getter/setter만) | 풍부한 도메인 모델 (행위 포함) |
| 비즈니스 로직 위치 | Service 계층에 집중 | 도메인 객체 내부에 분산 |
| 의존 방향 | 상위 → 하위 (Service → Repository) | 도메인이 중심, 인프라가 도메인에 의존 |
| 데이터 모델 | DB 테이블 기반 설계 | 도메인 모델 기반 설계 |
| 명명 규칙 | 기술 중심 (XxxVO, XxxDTO) | 비즈니스 용어 (Order, Payment) |
| 테스트 | DB 의존적 통합 테스트 위주 | 도메인 단위 테스트 용이 Testcontainers로 통합 테스트 자동화 |
빈약한 도메인 모델 vs 풍부한 도메인 모델
// ❌ 빈약한 도메인 모델 (Anemic Domain Model)
public class Order {
private Long id;
private String status;
private int totalAmount;
// getter, setter만 존재
}
public class OrderService {
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId);
if (!"PLACED".equals(order.getStatus())
&& !"PAID".equals(order.getStatus())) {
throw new RuntimeException("취소 불가");
}
order.setStatus("CANCELLED");
// 모든 비즈니스 로직이 Service에...
}
}
// ✅ 풍부한 도메인 모델 (Rich Domain Model)
public class Order {
private OrderId id;
private OrderStatus status;
private Money totalAmount;
public void cancel(String reason) {
if (!status.isCancellable()) {
throw new OrderCannotBeCancelledException(id, status);
}
this.status = OrderStatus.CANCELLED;
registerEvent(new OrderCancelledEvent(id, reason));
}
}
public class OrderApplicationService {
// Application Service는 도메인 로직 없이 흐름만 조율
@Transactional
public void cancelOrder(CancelOrderCommand command) {
Order order = orderRepository.findById(command.getOrderId());
order.cancel(command.getReason()); // 도메인 객체에 위임
orderRepository.save(order);
}
}
패키지 구조 예제
com.example.order/
├── application/ # 응용 서비스 (유스케이스 조율)
│ ├── OrderApplicationService.java
│ ├── command/
│ │ ├── PlaceOrderCommand.java
│ │ └── CancelOrderCommand.java
│ └── query/
│ └── OrderQueryService.java
├── domain/ # 도메인 모델 (핵심)
│ ├── model/
│ │ ├── Order.java # Aggregate Root
│ │ ├── OrderLine.java # Entity
│ │ ├── OrderId.java # Value Object
│ │ ├── OrderStatus.java # Enum
│ │ └── Money.java # Value Object
│ ├── repository/
│ │ └── OrderRepository.java # 인터페이스만
│ ├── service/
│ │ └── OrderPricingService.java
│ └── event/
│ ├── OrderPlacedEvent.java
│ └── OrderCancelledEvent.java
├── infrastructure/ # 인프라 구현
│ ├── persistence/
│ │ ├── JpaOrderRepository.java
│ │ ├── OrderEntity.java # JPA 전용
│ │ └── OrderMapper.java
│ └── messaging/
│ └── KafkaOrderEventPublisher.java
└── interfaces/ # 외부 인터페이스
├── rest/
│ ├── OrderController.java
│ └── dto/
│ ├── OrderRequest.java
│ └── OrderResponse.java
└── event/
└── OrderEventHandler.java
DDD 도입 시 주의사항
- 모든 프로젝트에 DDD가 필요하지 않습니다. 단순 CRUD 위주의 시스템에는 과도한 설계입니다. 복잡한 비즈니스 로직이 있는 핵심 도메인에만 적용하세요.
- Aggregate 경계를 작게 유지하세요. 큰 Aggregate는 동시성 문제와 성능 문제를 일으킵니다.
- 기술적 관심사와 도메인 관심사를 분리하세요. JPA 애노테이션이 도메인 모델에 침투하지 않도록 Entity/도메인 객체를 분리하는 것을 고려하세요.
- 점진적으로 도입하세요. 전체 시스템을 한꺼번에 DDD로 전환하는 것이 아니라, 핵심 도메인부터 시작하세요.
- 도메인 전문가와의 소통이 핵심입니다. 코드 패턴만 따라 하면 형식적인 DDD가 됩니다. 비즈니스 이해가 먼저입니다.
마치며
DDD는 단순한 아키텍처 패턴이 아니라 복잡한 비즈니스를 코드로 표현하는 사고방식입니다. 전략적 설계로 시스템의 경계를 올바르게 나누고, 전술적 설계로 각 경계 안의 모델을 풍부하게 구현하면, 변경에 강하고 이해하기 쉬운 소프트웨어를 만들 수 있습니다.
처음에는 빈약한 도메인 모델에서 풍부한 도메인 모델로 전환하는 것부터 시작하세요. Service에 있는 비즈니스 로직을 Entity와 Value Object로 이동하는 것만으로도 코드 품질이 크게 향상됩니다. 그리고 Bounded Context를 식별하여 시스템을 분리하는 것은 마이크로서비스 전환의 가장 좋은 출발점이 됩니다. 헥사고날 아키텍처로 포트와 어댑터 구현하기
'Architecture' 카테고리의 다른 글
| API Gateway 패턴 심화 - Spring Cloud Gateway와 Rate Limiting 구현 (0) | 2026.04.08 |
|---|---|
| 헥사고날 아키텍처 완벽 가이드 - 포트와 어댑터로 클린 아키텍처 구현 (0) | 2026.04.08 |
| MSA 관측 가능성(Observability) 완벽 가이드 (0) | 2026.03.31 |
| MSA 관측 가능성(Observability) 완벽 가이드 (0) | 2026.03.30 |
| CQRS & 이벤트 소싱 패턴 - 언제 왜 사용하는가 (0) | 2026.03.27 |