Software Architecture/DDD & Patterns

DDD Patterns - 02. DDD 전술적 설계

신분증 있는 사람과 동전 — 엔터티, 값 객체, 애그리거트

사람을 식별할 때 신분증을 본다. 같은 이름, 같은 생김새라도 주민번호가 다르면 다른 사람이다. 반면 동전은 식별 번호가 없다 — 100원짜리 두 개는 교환해도 아무도 모른다. 가치가 같으면 같은 것으로 취급한다. 이 둘은 "객체"처럼 보이지만 식별 방식이 근본적으로 다르다.

DDD의 전술적 설계는 도메인을 이루는 객체들을 이렇게 식별 방식과 수명에 따라 나눈다. Evans가 에서 정의한 빌딩 블록이다 — 엔터티, 값 객체, 애그리거트, 도메인 서비스, 도메인 이벤트. 각각 다른 규칙과 책임을 갖는다.

엔터티 — 신분증으로 식별되는 객체

엔터티(entity)는 식별자(identity)로 구별되는 객체다. 두 Order가 같은 내용(같은 상품, 같은 금액)을 가져도 주문번호가 다르면 다른 주문이다. 식별자가 객체의 정체성을 유지한다 — 속성이 바뀌어도(배송지 변경, 상품 추가) 같은 주문이다.

// 엔터티 — 식별자(orderId)로 동등성 판정
public final class Order {
    private final OrderId id;    // 식별자, 불변
    private Money total;
    private OrderStatus status;
    // 속성은 바뀌어도 id가 같으면 같은 주문
    public boolean equals(Object o) {
        return o instanceof Order other && this.id.equals(other.id);
    }
}

엔터티는 수명 주기를 갖는다 — 생성되고, 변경되고, 소멸한다. 식별자는 그 수명 동안 불변이어야 한다. 주문번호가 바뀌면 그건 다른 주문이 된다.

값 객체 — 가치로 식별되는 객체

값 객체(value object)는 으로 구별된다. 동전처럼, 속성이 전부 같으면 같은 것으로 취급한다. Money(1000, "KRW") 두 개는 서로 교환해도 의미가 같다. 식별자가 없고, 식별자가 필요하지 않다.

값 객체는 불변(immutable)이다. 1000원을 2000원으로 "바꾸는" 게 아니라, 1000원 객체를 버리고 2000원 객체를 새로 만든다. 동전에 숫자를 다시 새기지 않듯, 값 객체는 수정하지 않고 교체한다.

// 값 객체 — 불변, 속성으로 동등성 판정, 식별자 없음
public record Money(int amount, String currency) {
    public Money add(Money other) {
        if (!this.currency.equals(other.currency))
            throw new IllegalArgumentException("통화 불일치");
        return new Money(this.amount + other.amount, this.currency);  // 새 객체 반환
    }
}

값 객체를 많이 쓸수록 모델이 풍성해진다. int amount 대신 Money를 쓰면, 통화 변환·음수 방지·통화 불일치 검증이 값 객체 안에 들어간다. primitive 타입(long, String)만 도배된 도메인은 "primitive obsession" 냄새다 — 값 객체로 빼내면 도메인 규칙이 자리를 잡는다.

애그리거트 — 함께 변경되는 객체 묶음

애그리거트(aggregate)는 하나의 트랜잭션에서 함께 변경되는 객체 묶음이다. 주문 애그리거트에는 Order(엔터티)와 OrderLine(주문 항목들)이 함께 속한다. 주문을 생성하면 항목도 함께 생성되고, 주문을 취소하면 항목도 함께 취소된다. 하나의 단위로 다뤄진다.

애그리거트에는 하나의 루트(root)가 있다 — 외부에서 애그리거트에 접근할 때 루트를 통해서만 접근한다. Order가 루트면, 외부에서 OrderLine에 직접 손대지 못하고 order.changeQuantity(lineId, 3)처럼 루트를 거친다. 루트가 애그리거트 전체의 일관성을 책임진다.

도메인 서비스 — 어디에도 속하지 않는 행동

어떤 행동은 한 엔터티에 자연스럽게 들어가지 않는다. "두 계좌 간 이체"는 Account 엔터티 어디에도 깔끔히 들어가지 않는다 — 보내는 쪽과 받는 쪽 모두 관여하니까. 이런 행동은 도메인 서비스(domain service)로 뺀다. 상태를 갖지 않고(무상태), 도메인 규칙만 담는다.

주의할 점이 있다. 서비스 계층을 만들었다고 도메인 서비스가 아니다. 도메인 서비스는 도메인 계층에 있고 도메인 규칙을 담는다. 애플리케이션 서비스(트랜잭션·보안·오케스트레이션)와 다르다 — 혼동하면 도메인 로직이 서비스로 도망가서 빈약한 도메인 모델(05번)이 된다.

도메인 이벤트 — 과거에 일어난 사실

도메인 이벤트(domain event)는 도메인에서 의미 있게 일어난 과거 사실이다. "주문이 접수됨(OrderPlaced)", "결제가 완료됨(PaymentCompleted)". 이벤트는 불변이다 — 이미 일어난 일이므로 바꿀 수 없다. 과거 시제로 명명한다(OrderPlaced, not OrderPlace).

public record OrderPlaced(OrderId orderId, CustomerId customerId, Money total, Instant occurredAt) {}

이벤트는 컨텍스트 간 통합과 이벤트 소싱의 기본 단위다. 한 컨텍스트에서 발생한 사실을 다른 컨텍스트가 알 수 있게 하는 매개체가 도메인 이벤트다.

빌딩 블록은 도구일 뿐이다

flowchart TB
    subgraph Aggregate["애그리거트 (한 트랜잭션)"]
        Entity["엔터티<br/>식별자로 구별<br/>수명 주기 있음"]
        VO["값 객체<br/>값으로 구별<br/>불변"]
        Entity --- VO
    end
    Service["도메인 서비스<br/>무상태<br/>한 엔터티에 안 속함"]
    Event["도메인 이벤트<br/>과거 사실<br/>불변"]
    Aggregate -. 발생 .-> Event
    Service -. 사용 .-> Aggregate

이 다섯 빌딩 블록(엔터티·값 객체·애그리거트·도메인 서비스·도메인 이벤트)은 도구다. 도구를 아는 것과 도구를 언제 쓸지 아는 것은 다르다. 흔한 실수는 모든 것을 엔터티로 만들거나, 모든 것을 값 객체로 만드는 것이다. 식별자가 의미 있는 것은 엔터티, 속성이 전부인 것은 값 객체, 함께 변경되는 것은 애그리거트로 묶는다 — 도메인 자체가 이 분류를 알려준다. 패턴을 도메인에 끼워맞추는 게 아니라, 도메인에서 패턴이 드러나게 하는 것이 전술적 설계의 본질이다.

설계 사례 — 주문 애그리거트의 전체 빌딩 블록

다섯 블록이 한 도메인에서 어떻게 협력하는지, 주문 애그리거트 전체를 코드로 본다. 각 블록이 자기 역할을 가지고 함께 주문이라는 비즈니스를 구성한다.

// (1) 값 객체 — 식별자. 속성으로 동등성 판정, 불변
public record OrderId(String value) {
    public OrderId { if (value == null || value.isBlank()) throw new IllegalArgumentException(); }
}

// (2) 값 객체 — 금액. 통화 불일치 방지, 음수 방지가 객체 안에
public record Money(int amount, String currency) {
    public Money {
        if (amount < 0) throw new IllegalArgumentException("금액은 음수일 수 없습니다");
    }
    public Money add(Money other) {
        if (!currency.equals(other.currency)) throw new IllegalArgumentException("통화 불일치");
        return new Money(amount + other.amount, currency);
    }
}

// (3) 엔터티 — 주문 항목. productId로 식별, 수량·단가를 가짐
public class OrderLine {
    private final ProductId productId;
    private int quantity;
    private final Money unitPrice;

    Money subtotal() { return new Money(unitPrice.amount() * quantity, unitPrice.currency()); }
}

// (4) 엔터티 + 애그리거트 루트 — Order. 하위 객체(OrderLine)를 거쳐서만 접근
public class Order {
    private final OrderId id;
    private final CustomerId customerId;   // 다른 애그리거트는 ID 참조
    private final List<OrderLine> lines = new ArrayList<>();
    private OrderStatus status;

    // 루트가 일관성을 책임짐 — 항목 추가 시 총액 재계산
    public void addLine(ProductId product, int qty, Money price) {
        if (status != OrderStatus.DRAFT) throw new IllegalStateException("임시 주문만 수정 가능");
        lines.add(new OrderLine(product, qty, price));
    }

    // (5) 도메인 이벤트 발생 — 주문 확정 시 과거 사실을 기록
    public OrderPlaced confirm() {
        if (lines.isEmpty()) throw new IllegalStateException("주문 항목이 없습니다");
        if (status != OrderStatus.DRAFT) throw new IllegalStateException("이미 확정된 주문입니다");
        this.status = OrderStatus.PLACED;
        return new OrderPlaced(id, customerId, total(), Instant.now());
    }

    public Money total() {
        return lines.stream().map(OrderLine::subtotal).reduce(Money::add)
                    .orElse(new Money(0, "KRW"));
    }
}

// (5) 도메인 이벤트 — 불변, 과거 시제
public record OrderPlaced(OrderId orderId, CustomerId customerId, Money total, Instant occurredAt) {}

이 코드에서 각 블록이 자기 역할을 한다. Money는 금액 규칙(음수 방지, 통화 일치)을 갖는 값 객체다 — int amount 대신 쓰면 규칙이 객체 안에 들어간다. OrderLine은 식별자(productId)로 구별되는 엔터티지만, Order 애그리거트 안에서만 의미를 갖는 하위 객체다. Order는 루트로서 외부에서 접근을 책임지고, 하위 객체의 일관성(총액 계산, 상태 전환 규칙)을 유지한다. OrderPlaced는 주문 확정이라는 과거 사실을 기록하는 이벤트로, 다른 컨텍스트가 구독할 수 있다.

customerId가 Customer 객체가 아니라 CustomerId 값만 갖는 점에 주목한다 — 다른 애그리거트는 ID로 참조한다. 이 한 줄이 주문 애그리거트와 고객 애그리거트의 독립성을 지킨다.


참고

  • Evans, Eric — (Addison-Wesley, 2003), Ch.5 (전술적 설계 빌딩 블록) — 접근 2026-07-15
  • Vernon, Vaughn — (Addison-Wesley, 2013), Ch.4·5·8·10 — 접근 2026-07-15