[Daily morning study] 도메인 주도 설계 (Domain-Driven Design) 핵심 개념

#daily morning study

Image


도메인 주도 설계 (DDD)란

DDD는 복잡한 소프트웨어를 개발할 때 비즈니스 도메인을 중심에 놓고 설계하는 방법론이다. Eric Evans가 2003년 저서 “Domain-Driven Design”에서 제시했으며, 기술보다 도메인 전문가와의 협업과 도메인 지식을 코드에 녹여내는 데 초점을 맞춘다.

핵심 아이디어는 간단하다. 개발자와 도메인 전문가가 같은 언어로 소통하고, 그 언어가 코드에도 그대로 반영되어야 한다는 것이다.


유비쿼터스 언어 (Ubiquitous Language)

팀 전체(개발자 + 도메인 전문가)가 공유하는 공통 언어다.

  • 코드의 클래스명, 메서드명, 변수명이 도메인 전문가가 사용하는 용어와 일치해야 한다
  • 용어가 일치하지 않으면 번역 과정에서 오해가 생기고 버그로 이어진다
// 나쁜 예 - 개발자식 명명
public class OrderProcessor {
    public void processData(int userId, List<Item> items) { ... }
}

// 좋은 예 - 유비쿼터스 언어 반영
public class Order {
    public void place(Customer customer, List<OrderLine> orderLines) { ... }
}

전략적 설계 (Strategic Design)

바운디드 컨텍스트 (Bounded Context)

도메인을 명확한 경계를 가진 하위 영역으로 나누는 개념이다. 같은 단어라도 컨텍스트마다 의미가 다를 수 있다.

예를 들어 “고객(Customer)”이라는 개념은:

  • 주문 컨텍스트: 주문자 정보, 배송지
  • 마케팅 컨텍스트: 구매 이력, 관심사, 세그먼트
  • 결제 컨텍스트: 결제 수단, 신용 한도

각 바운디드 컨텍스트 안에서는 유비쿼터스 언어가 일관되게 유지된다.

컨텍스트 맵 (Context Map)

바운디드 컨텍스트 사이의 관계를 나타낸 지도다. 주요 패턴:

패턴설명
공유 커널 (Shared Kernel)두 팀이 도메인 모델 일부를 공유
고객-공급자 (Customer-Supplier)상류 팀이 하류 팀의 요구를 수용
순응주의자 (Conformist)하류 팀이 상류 모델을 그대로 따름
부패 방지 계층 (Anti-Corruption Layer)외부 시스템으로부터 내부 모델을 보호하는 변환 레이어
오픈 호스트 서비스 (Open Host Service)공개 프로토콜로 다른 컨텍스트에 서비스 제공

전술적 설계 (Tactical Design)

엔티티 (Entity)

고유한 식별자(ID)를 가지며, 생명주기 동안 상태가 변해도 동일 객체로 취급된다.

public class Order {
    private final OrderId id;  // 식별자
    private OrderStatus status;
    private List<OrderLine> orderLines;

    // ID로 동등성 비교
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Order)) return false;
        Order order = (Order) o;
        return id.equals(order.id);
    }
}

값 객체 (Value Object)

식별자가 없고 속성 값으로 동등성을 판단한다. 불변(immutable)이어야 한다.

public class Money {
    private final BigDecimal amount;
    private final Currency currency;

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }

    // 속성 값으로 동등성 비교
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Money)) return false;
        Money money = (Money) o;
        return amount.equals(money.amount) && currency.equals(money.currency);
    }
}

애그리거트 (Aggregate)

연관된 엔티티와 값 객체의 묶음으로, 하나의 루트 엔티티(Aggregate Root)를 통해서만 외부에서 접근할 수 있다.

Order (Aggregate Root)
├── OrderLine (Entity)
│   └── Money (Value Object)
├── ShippingAddress (Value Object)
└── OrderStatus (Value Object)

규칙:

  • 외부에서는 반드시 애그리거트 루트를 통해서만 내부 객체에 접근
  • 하나의 트랜잭션에서 하나의 애그리거트만 수정 (원칙)
  • 애그리거트 경계는 일관성 경계이기도 함
public class Order {  // Aggregate Root
    private List<OrderLine> orderLines;

    // 외부에서 직접 orderLine을 조작하지 않고 Order를 통해서만 접근
    public void addProduct(Product product, int quantity) {
        OrderLine line = new OrderLine(product.getId(), product.getPrice(), quantity);
        this.orderLines.add(line);
    }

    public Money totalAmount() {
        return orderLines.stream()
            .map(OrderLine::subtotal)
            .reduce(Money.ZERO, Money::add);
    }
}

도메인 서비스 (Domain Service)

특정 엔티티나 값 객체에 속하지 않는 도메인 로직을 처리한다. 상태를 갖지 않는다(stateless).

public class TransferService {
    // 송금은 Account 하나에 속하지 않고 두 Account 사이의 연산
    public void transfer(Account from, Account to, Money amount) {
        from.withdraw(amount);
        to.deposit(amount);
    }
}

도메인 이벤트 (Domain Event)

도메인에서 발생한 중요한 사실을 나타낸다. 과거형으로 명명한다.

public class OrderPlaced {
    private final OrderId orderId;
    private final CustomerId customerId;
    private final Money totalAmount;
    private final Instant occurredAt;
}

이벤트를 발행하면 다른 바운디드 컨텍스트가 이를 구독해서 처리할 수 있다. 예를 들어 OrderPlaced 이벤트가 발생하면:

  • 결제 컨텍스트: 결제 처리 시작
  • 재고 컨텍스트: 재고 차감
  • 알림 컨텍스트: 주문 확인 이메일 발송

리포지토리 (Repository)

애그리거트의 영속성을 담당한다. 컬렉션처럼 동작하도록 인터페이스를 설계한다.

public interface OrderRepository {
    Order findById(OrderId id);
    void save(Order order);
    void remove(Order order);
}

// 구현체는 인프라 레이어에 위치 (JPA, MyBatis 등)
public class JpaOrderRepository implements OrderRepository {
    @Override
    public Order findById(OrderId id) {
        return em.find(OrderEntity.class, id.value()).toDomain();
    }
}

레이어드 아키텍처와 DDD

DDD는 보통 다음 레이어 구조와 함께 사용한다.

Presentation Layer   ─── UI, API Controller
        │
Application Layer    ─── Use Case, Application Service (트랜잭션 경계)
        │
Domain Layer         ─── Entity, Value Object, Aggregate, Domain Service
        │
Infrastructure Layer ─── Repository 구현체, DB, 외부 API
  • 도메인 레이어는 다른 레이어에 의존하지 않는다
  • 애플리케이션 서비스는 도메인 객체를 조합해 유스케이스를 실현한다
  • 인프라 의존성은 DIP(Dependency Inversion Principle)로 역전시킨다
// Application Service 예시
public class PlaceOrderService {
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;

    public OrderId placeOrder(PlaceOrderCommand command) {
        Customer customer = customerRepository.findById(command.customerId());
        List<Product> products = productRepository.findByIds(command.productIds());

        Order order = Order.place(customer, products);
        orderRepository.save(order);

        return order.getId();
    }
}

DDD가 적합한 상황

DDD는 복잡한 도메인 로직을 다룰 때 빛을 발한다. 단순 CRUD 시스템에는 오버엔지니어링이 될 수 있다.

적합한 경우부적합한 경우
복잡한 비즈니스 규칙이 많을 때단순 CRUD 위주 시스템
도메인 전문가와 지속적 협업이 필요할 때빠른 프로토타이핑이 목적일 때
장기적으로 유지보수할 시스템단기 프로젝트
MSA 설계의 서비스 경계를 나눌 때팀이 소규모이고 도메인이 단순할 때

정리

  • 유비쿼터스 언어: 팀 전체가 공유하는 도메인 언어, 코드에도 반영
  • 바운디드 컨텍스트: 도메인을 명확한 경계로 구분, 컨텍스트 내에서 언어 일관성 보장
  • 엔티티: ID로 동일성 판단, 가변 상태
  • 값 객체: 속성으로 동등성 판단, 불변
  • 애그리거트: 일관성 경계, 루트를 통해서만 외부 접근
  • 도메인 서비스: 여러 엔티티에 걸친 도메인 로직
  • 도메인 이벤트: 도메인에서 발생한 사실, 컨텍스트 간 통신에 활용
  • 리포지토리: 애그리거트 영속성, 컬렉션처럼 사용