DDDDomain-Driven DesignArchitectureSoftware Design

도메인 주도 설계(DDD)를 다시 정리하기

Sloth255
Sloth255
·2 min read·440 words

들어가며

"DDD는 대충 알겠는데, 실제로 무엇을 해야 DDD라고 할 수 있을까?"
"Entity와 Value Object의 차이는 결국 뭘까?"
"Repository를 만들면 그게 곧 DDD일까?"

이런 의문을 가진 분들이 많을 것입니다. 이 글에서는 Eric Evans의 Domain-Driven Design, Vaughn Vernon의 _Implementing Domain-Driven Design_을 바탕으로, DDD의 핵심을 실무에서 바로 쓸 수 있는 형태 로 다시 정리해 보겠습니다.

주요 용어 설명은 Eric Evans의 DDD Reference와 Vaughn Vernon의 공식 개요에서 공개된 설명에 최대한 맞추었습니다.

DDD란 무엇인가

도메인 주도 설계(Domain-Driven Design, DDD) 는 복잡한 비즈니스 로직을 가진 소프트웨어를 개발할 때,

소프트웨어의 중심에 "도메인(업무 영역)"을 두고, 도메인의 언어로 코드를 쓰자

라는 생각에 기반한 설계 방식입니다.

핵심은 이것이 프레임워크도, 특정 아키텍처도 아니라는 점입니다. DDD는 사상과 기법의 집합 입니다. 그래서 DDD 라이브러리를 넣었다고 해서 곧바로 "DDD를 했다"고 볼 수는 없습니다.

DDD는 크게 두 층위로 나뉜다

DDD는 크게 다음 두 가지로 나눌 수 있습니다.

분류 별칭 무엇을 하는가
전략적 설계 Strategic Design 도메인을 올바르게 나누고 경계를 긋는다
전술적 설계 Tactical Design 코드 수준에서 도메인을 표현한다

많은 사람이 "DDD = Entity와 Value Object를 만드는 것"이라고 생각하지만, 그것은 전술적 설계의 일부일 뿐입니다. 실제로 더 중요한 것은 오히려 전략적 설계 입니다.

전략적 설계

유비쿼터스 언어 (Ubiquitous Language)

DDD의 출발점이자 가장 중요한 개념입니다.

도메인 전문가(업무를 잘 아는 사람), 개발자, 이해관계자가 같은 단어를 사용한다

예를 들어 이커머스 사이트에서 "주문"이라고 말할 때,

  • 마케팅 부서: "장바구니에 담긴 순간이 주문"
  • 회계 부서: "결제가 끝난 순간이 주문"
  • 물류 부서: "출고 지시가 내려간 순간이 주문"

처럼 사람마다 의미가 다를 수 있습니다. 이런 상태를 그대로 코드에 옮기면 Order 클래스가 무엇을 의미하는지 아무도 알 수 없게 됩니다.

모델을 언어의 등뼈로 삼고, 대화와 코드 양쪽에서 그 언어를 계속 사용하는 것. 이것이 DDD의 기본입니다.

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

유비쿼터스 언어는 조직 전체에서 하나로 통일할 필요가 없고, 실제로도 거의 불가능합니다.

그래서 "이 범위 안에서는 이 단어가 이런 뜻이다"라고 경계를 긋습니다. 이것이 바운디드 컨텍스트 입니다. 이 경계는 대화상의 약속일 뿐 아니라, 팀 구성, 애플리케이션 안에서의 적용 범위, 코드베이스와 데이터 저장소의 분리에도 드러납니다.

flowchart LR
  subgraph Sales["판매 컨텍스트"]
    S["Order =<br/>결제 후의 구매 의사"]
  end
  subgraph Logistics["물류 컨텍스트"]
    L["Order =<br/>출하 지시된 배송 물품"]
  end
  N["같은 Order라도 의미가 달라도 된다"]

  S -.-> N
  L -.-> N

  classDef context fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef note fill:#ffffff,stroke:#94a3b8,color:#334155,stroke-dasharray: 5 5
  class S,L context
  class N note

이 경계들의 지도가 컨텍스트 맵(Context Map) 입니다. 마이크로서비스 경계를 정할 때도 강력한 기준이 됩니다.

요구사항에서 도메인을 어떻게 뽑아낼까

"그래서 결국 도메인은 어떻게 찾는 건가요?"라는 질문은 누구나 거칩니다. 여기서는 현장에서 실제로 도움이 되는 접근을 몇 가지 소개합니다.

1. 인터뷰에서는 "동사"와 "명사"를 줍는다고 생각하자

고객이나 업무 담당자와 이야기할 때는, 나오는 명사 = 모델 후보동사 = 행위 후보 를 분리해서 기록하는 것을 의식하는 것이 좋습니다.

"영업 담당자견적서 를 작성해 고객 에게 전송 하고, 승인 되면 수주 로 바꾼다"

이 한 문장만으로도,

  • 명사: 영업 담당자, 견적서, 고객, 수주 -> Entity / Value Object 후보
  • 동사: 작성하다, 전송하다, 승인하다, 수주로 바꾸다 -> 행위 / 도메인 이벤트 후보

를 뽑아낼 수 있습니다. 업무 담당자가 무의식적으로 쓰는 말이야말로 유비쿼터스 언어의 원석 입니다.

2. EventStorming을 활용하자

복잡한 도메인에서는 화이트보드나 Miro / FigJam 같은 도구 위에 포스트잇으로 이벤트를 나열하는 EventStorming이 매우 효과적입니다.

flowchart LR
  E1["🟧 견적 요청됨"] --> E2["🟧 견적 작성됨"] --> E3["🟧 견적 발송됨"]
  E3 --> C2["🟦 커맨드<br/>견적 승인하기"] --> E4["🟧 견적 승인됨"] --> E5["🟧 주문 접수됨"]
  E3 --> C1["🟦 커맨드<br/>견적 취소하기"] --> E6["🟧 견적 취소됨"]
  A1["🟪 애그리게이트<br/>견적서"] -.이벤트를 일으키는 본체.-> E2
  R1["🟨 규칙<br/>승인된 견적만 주문으로 전환할 수 있다"] -.제약.-> E5

  classDef event fill:#fdba74,stroke:#ea580c,color:#7c2d12
  classDef command fill:#bfdbfe,stroke:#2563eb,color:#1e3a8a
  classDef aggregate fill:#ddd6fe,stroke:#7c3aed,color:#4c1d95
  classDef rule fill:#fde68a,stroke:#ca8a04,color:#713f12
  class E1,E2,E3,E4,E5,E6 event
  class C1,C2 command
  class A1 aggregate
  class R1 rule
  • 🟧 주황 포스트잇: 도메인 이벤트(과거형으로 적는다)
  • 🟦 파란 포스트잇: 커맨드, 즉 누군가의 행동
  • 🟪 보라 포스트잇: 애그리게이트, 이벤트를 일으키는 본체. 예: 견적서
  • 🟨 노란 포스트잇: 비즈니스 규칙 / 제약. 예: "승인된 견적만 주문으로 전환할 수 있다"

예를 들어 "견적 승인하기" 는 누군가가 수행하는 지시이므로 커맨드입니다. 반면 "견적 승인됨" 은 이미 일어난 사실이므로 도메인 이벤트입니다.

업무 담당자와 함께 진행하면 암묵지가 한꺼번에 가시화 됩니다. "아, 여기서 이런 판단이 들어가나요?" "실은 이런 경우는 예외예요" 같은 대화가 자연스럽게 생긴다는 점이 가장 큰 가치입니다.

3. 반드시 "예외 패턴"을 물어보자

요구사항 인터뷰에서 가장 정보량이 많은 것은 예외 흐름 입니다.

질문 끌어낼 수 있는 정보
"이게 안 되는 경우는 언제인가요?" 불변 조건, 비즈니스 규칙
"과거에 문제를 일으킨 사례가 있었나요?" 숨은 제약
"사람이 수작업으로 우회하고 있는 부분이 있나요?" 미구현 비즈니스 규칙
"이 업무는 5년 전과 무엇이 달라졌나요?" 변화하기 쉬운 부분 / 안정적인 부분

행복 경로만 들으면 도메인의 윤곽은 잘 드러나지 않습니다. 진짜 도메인의 본질은 아픈 지점에 숨어 있습니다.

4. 코어, 지원, 범용의 세 종류로 분류하자

추출한 도메인을 모두 같은 무게로 구현할 필요는 없습니다.

분류 설명 전략
코어 도메인 비즈니스 경쟁 우위의 원천 DDD로 정성 들여 자체 구현
지원 서브도메인 코어를 떠받치는 고유 업무 가볍게 자체 구현하거나 외주
범용 서브도메인 어느 회사에나 공통적인 부분(인증, 결제 등) SaaS / OSS 사용

이커머스 사이트를 예로 들면,

  • 코어: 상품 추천, 가격 전략, 재고 할당
  • 지원: 상품 카탈로그 관리, 배송 조정
  • 범용: 인증, 결제, 메일 발송

"전부 다 열심히 하자"고 하면 프로젝트는 거의 확실히 무너집니다. 어디에 힘을 써야 하는지 명확히 하는 것 이 경계 설계의 첫걸음입니다.

5. 컨텍스트 경계를 긋는 판단 기준

경계를 어디에 그을지는 경험이 필요하지만, 다음과 같은 신호를 기준으로 삼으면 판단하기 쉬워집니다.

  • 🚩 같은 단어의 의미가 달라진다 (도입부의 Order 예시)
  • 🚩 담당 조직이나 부서가 달라진다 (Conway의 법칙)
  • 🚩 라이프사이클이 다르다 (주문과 고객의 수명은 다르다)
  • 🚩 변경 빈도가 다르다 (가격 전략은 자주 바뀌지만 주소 관리는 그렇지 않다)
  • 🚩 요구되는 정합성 수준이 다르다 (재고는 강한 정합성, 추천은 최종 정합성으로도 가능)

이런 "어긋남"이 느껴진다면, 거기가 경계 후보입니다.

전술적 설계

이제부터는 코드 이야기입니다.

Value Object (값 객체)

값 객체는 "값 그 자체"를 표현하는 객체이며, 다음 특성을 가집니다.

  • 불변(immutable)
  • 동등성으로 비교된다 (ID가 아니라 내용으로 비교)
  • 부작용이 없다
// ❌ 그냥 문자열
const email: string = "user@example.com";

// ✅ Value Object
class Email {
  constructor(private readonly value: string) {
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      throw new Error("유효하지 않은 이메일 주소입니다");
    }
  }
  equals(other: Email): boolean {
    return this.value === other.value;
  }
  toString(): string {
    return this.value;
  }
}

핵심은 "유효한 이메일 주소다"라는 제약을 타입 안에 가둬 두는 것 입니다. 그러면 검증 로직이 여기저기 흩어지지 않습니다.

Entity (엔티티)

엔티티는 식별자(ID)에 의해 동일성이 결정되는 객체입니다.

class User {
  constructor(
    public readonly id: UserId,
    private name: UserName,
    private email: Email,
  ) {}

  changeEmail(newEmail: Email): void {
    this.email = newEmail;
  }

  equals(other: User): boolean {
    return this.id.equals(other.id);
  }
}

이름이 바뀌어도, 이메일이 바뀌어도, id가 같다면 같은 User입니다. 이것이 Entity의 본질입니다.

Aggregate (애그리게이트)

애그리게이트는 여러 Entity / Value Object를 하나로 묶은 정합성의 경계 입니다.

애그리게이트의 입구가 되는 엔티티를 애그리게이트 루트(Aggregate Root) 라고 부르며, 외부에서는 반드시 애그리게이트 루트를 통해 접근합니다.

// 애그리거트 루트
class Order {
  private items: OrderItem[] = [];

  constructor(
    public readonly id: OrderId,
    private readonly userId: UserId,
  ) {}

  // 애그리거트 내부의 정합성은 애그리거트 루트가 지킨다.
  addItem(product: Product, quantity: number): void {
    if (this.items.length >= 100) {
      throw new Error("주문당 항목 수 제한을 초과했습니다");
    }
    this.items.push(new OrderItem(product.id, quantity, product.price));
  }

  totalPrice(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.subtotal()),
      Money.zero(),
    );
  }
}

기본 원칙은 애그리게이트의 크기는 가능한 한 작게 입니다. 너무 크게 잡으면 동시 업데이트 충돌과 로딩 비용에서 고생하게 됩니다.

Repository (리포지토리)

리포지토리는 애그리게이트의 영속화를 담당하는 추상화입니다. 도메인 계층이 인프라에 의존하지 않도록 사이에 둡니다.

// Domain 계층: 인터페이스만 정의한다.
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

// Infrastructure 계층: 구현은 바깥에 둔다.
class PostgresOrderRepository implements OrderRepository {
  async findById(id: OrderId): Promise<Order | null> { /* ... */ }
  async save(order: Order): Promise<void> { /* ... */ }
}

중요한 점은 Repository가 단순히 "DB에 저장하는 것"이 아니라, 애그리게이트 루트에 대한 전역 접근을 제공하고, 호출 측에서는 컬렉션처럼 다룰 수 있게 해 주는 추상화 라는 것입니다. 즉, "애그리게이트를 보관하고 꺼내는 창구"로 보는 편이 맞습니다.

Domain Service (도메인 서비스)

도메인에서 중요한 프로세스나 변환 중, Entity나 Value Object의 자연스러운 책임으로 넣기 어색한 것을 두는 장소가 Domain Service입니다.

// 이메일 중복 여부는 User 혼자 판단할 수 없다.
class UserDuplicationChecker {
  constructor(private readonly userRepository: UserRepository) {}

  async exists(email: Email): Promise<boolean> {
    return (await this.userRepository.findByEmail(email)) !== null;
  }
}

Entity에 둘까, Service에 둘까 헷갈릴 때의 판단 흐름

구현 중 가장 자주 고민되는 것이 "이 로직을 Entity에 써야 하나, Service에 써야 하나"라는 문제입니다. 다음 흐름으로 판단하면 대부분의 경우를 처리할 수 있습니다.

flowchart TB
  Start["그 로직은..."] --> Q1{"단일 객체의 상태만 바꾸는가?"}
  Q1 -- YES --> E["Entity 메서드"]
  Q1 -- NO --> Q2{"여러 애그리거트를 가로질러 판단하는가?"}
  Q2 -- YES --> D["Domain Service"]
  Q2 -- NO --> Q3{"리포지토리나 외부 서비스가 필요한가?"}
  Q3 -- YES --> D
  Q3 -- NO --> Q4{"명사보다 동사로 표현하는 편이 자연스러운가?"}
  Q4 -- YES --> D
  Q4 -- NO --> V["Entity / Value Object"]

  classDef question fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef answer fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
  class Q1,Q2,Q3,Q4 question
  class E,D,V answer
✅ Entity에 두어야 하는 것

자기 자신의 상태와 불변 조건을 지키는 로직 은 Entity의 책임입니다.

class Order {
  private status: OrderStatus;
  private items: OrderItem[];

  // ✅ 자신의 상태 전이는 스스로 지킨다.
  cancel(): void {
    if (this.status === OrderStatus.SHIPPED) {
      throw new Error("이미 발송된 주문은 취소할 수 없습니다");
    }
    this.status = OrderStatus.CANCELLED;
  }

  // ✅ 자신의 데이터만으로 계산하는 로직
  totalPrice(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.subtotal()),
      Money.zero(),
    );
  }

  // ✅ 자신의 불변 조건을 확인
  addItem(item: OrderItem): void {
    if (this.items.length >= 100) throw new Error("상한을 초과했습니다");
    this.items.push(item);
  }
}

판단 기준은 이렇습니다. "Order야, 너 취소할 수 있어?"라고 의인화해서 물었을 때 자연스럽다면 Entity의 책임 입니다.

✅ Domain Service에 두어야 하는 것

하나의 Entity만으로는 판단할 수 없거나, 외부와의 상호작용이 필요한 로직 은 Domain Service에 둡니다.

// ✅ 두 애그리거트를 가로지르는 "송금" 로직
class TransferService {
  transfer(from: Account, to: Account, amount: Money): void {
    from.withdraw(amount);
    to.deposit(amount);
    // "송금"이라는 개념은 어느 한 계좌에도 속하지 않는다
  }
}

// ✅ 리포지토리가 필요한 "중복 검사"
class UserDuplicationChecker {
  constructor(private readonly userRepository: UserRepository) {}
  async exists(email: Email): Promise<boolean> {
    return (await this.userRepository.findByEmail(email)) !== null;
  }
}

여기서는 "User야, 네 이메일이 다른 사람에게 등록되어 있는지 네가 알 수 있어?"라고 물었을 때 대답이 '그걸 내가 어떻게 알아'라면 Service 라고 보면 됩니다.

❌ 안티패턴: 뭐든지 Service에 넣기
// ❌ 이렇게 되면 안 된다
class OrderService {
  cancel(order: Order): void {
    if (order.status === "SHIPPED") throw new Error(/* ... */);
    order.status = "CANCELLED";  // setter 로 덮어씀
  }
  totalPrice(order: Order): Money { /* items 를 돌며 합계를 계산 */ }
  addItem(order: Order, item: OrderItem): void { /* ... */ }
}
class Order {
  status: string;
  items: OrderItem[];
  // getter / setter 만 있음 ← 빈혈 도메인 모델
}

이것은 전형적인 트랜잭션 스크립트(transaction script) 입니다. Entity가 단순한 데이터 구조가 되어 버렸습니다. 원칙은 간단합니다. "Order를 주어로 써서 표현할 수 있는 처리라면 Order 안에 쓴다."입니다.

정리하면

관점 Entity에 둔다 Domain Service에 둔다
주어 그 객체 자신 동사, 행위
상태 자신의 상태를 바꾼다 상태를 갖지 않는다(stateless)
의존 Repository에 의존하지 않는다 Repository / 외부 의존 가능
범위 단일 애그리게이트 내부 여러 애그리게이트를 넘나듦
order.cancel() transferService.transfer(a, b, money)

Domain Event (도메인 이벤트)

도메인 이벤트는 "도메인에서 일어난 중요한 사건"을 표현하는 객체입니다. 마이크로서비스나 비동기 처리와 궁합이 좋기 때문에 최근 특히 주목받고 있습니다.

class OrderPlaced {
  constructor(
    public readonly orderId: OrderId,
    public readonly userId: UserId,
    public readonly occurredAt: Date,
  ) {}
}

예를 들어 "주문됨 -> 메일 발송", "주문됨 -> 포인트 적립"처럼 부작용을 느슨하게 분리할 수 있습니다.

계층형 아키텍처

DDD는 보통 다음과 같은 층 구조로 구현합니다.

flowchart TB
  P["Presentation 계층<br/>UI / API"]
  A["Application 계층<br/>유스케이스"]
  D["Domain 계층<br/>모델 / 비즈니스 규칙<br/>핵심"]
  I["Infrastructure 계층<br/>DB / 외부 API"]

  P --> A --> D
  I --> D

  classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
  class P,A,I layer
  class D domain

의존 방향은 항상 안쪽을 향합니다. Domain 계층은 다른 어떤 계층에도 의존하지 않습니다. 이를 더 철저히 지키기 위해 Onion Architecture나 Hexagonal Architecture(Ports and Adapters)를 사용하기도 합니다.

각 계층의 책임을 명확히 하자

실제 개발에서 가장 사고가 나기 쉬운 것은 "로직이 여러 계층으로 새어 나가는 문제" 입니다. 각 계층의 책임을 한 문장으로 기억해 두면 좋습니다.

계층 책임 하면 안 되는 일
Presentation 요청을 받고 결과를 보여준다 비즈니스 규칙을 쓴다
Application 유스케이스를 조율한다 비즈니스 규칙을 쓴다
Domain 비즈니스 규칙 그 자체 프레임워크, DB에 의존한다
Infrastructure 영속화와 외부 통신을 구현한다 비즈니스 판단을 한다

핵심 제약은 강력하고 단순합니다. 비즈니스 규칙은 Domain 계층에만 쓴다. 이 원칙을 지키면 프레임워크가 바뀌어도 비즈니스 로직은 살아남습니다.

Presentation 계층의 역할

사용자(또는 다른 시스템)의 입력을 받아 Application 계층으로 넘기고, 그 결과를 되돌려주는 것만 하면 됩니다. 원칙은 가능한 한 얇게 입니다.

// ✅ 좋은 Controller: 그냥 전달만 한다
@Controller("orders")
class OrderController {
  constructor(private readonly placeOrder: PlaceOrderUseCase) {}

  @Post()
  async create(@Body() body: PlaceOrderRequest): Promise<OrderResponse> {
    // 1. 요청 모양을 정리한다(DTO 로 변환)
    const command = new PlaceOrderCommand(
      body.userId,
      body.items.map(i => ({ productId: i.productId, quantity: i.quantity })),
    );

    // 2. 유스케이스에 위임
    const result = await this.placeOrder.execute(command);

    // 3. 응답 모양으로 정리해 반환
    return OrderResponse.from(result);
  }
}

Presentation 계층이 해야 할 일:

  • 요청 파싱과 형식적 검증(필수 항목, 타입, 길이 등)
  • 인증, 인가의 입구 처리
  • 응답 형식으로 변환
  • HTTP 상태 코드 선택

반대로 하면 안 되는 것 은 "재고가 없으면 400을 반환" 같은 업무 판단 입니다. 이것은 Domain 계층의 일입니다. Presentation은 Domain의 결과를 HTTP의 언어로 번역해 줄 뿐입니다.

Application 계층의 역할

DDD에서 가장 자주 오해되는 계층입니다. Application Service는 유스케이스의 조정자 이며, 비즈니스 로직을 갖지 않습니다.

// ✅ 좋은 Application Service: 흐름만 조율한다
class PlaceOrderUseCase {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly productRepository: ProductRepository,
    private readonly orderRepository: OrderRepository,
    private readonly inventoryService: InventoryService,  // Domain Service
    private readonly transactionManager: TransactionManager,
    private readonly eventPublisher: EventPublisher,
  ) {}

  async execute(command: PlaceOrderCommand): Promise<OrderResult> {
    return this.transactionManager.run(async () => {
      // 1. 필요한 애그리거트를 조회
      const user = await this.userRepository.findById(command.userId);
      if (!user) throw new UserNotFoundError(command.userId);

      const products = await this.productRepository.findByIds(
        command.items.map(i => i.productId),
      );

      // 2. 도메인에 처리를 맡긴다(비즈니스 규칙은 Domain 내부)
      const order = Order.place(user, products, command.items);

      // 3. 도메인 서비스를 호출한다(여러 애그리거트를 가로지르는 판단)
      await this.inventoryService.reserve(order);

      // 4. 영속화
      await this.orderRepository.save(order);

      // 5. 도메인 이벤트 발행
      await this.eventPublisher.publishAll(order.pullEvents());

      return OrderResult.from(order);
    });
  }
}

Application Service의 책임은 다음 다섯 가지입니다.

  1. 애그리게이트를 가져온다 (Repository를 호출한다)
  2. 도메인에 위임한다 (Entity / Domain Service가 규칙을 수행하게 한다)
  3. 트랜잭션 경계를 관리한다
  4. 영속화를 지시한다 (Repository에 저장한다)
  5. 도메인 이벤트를 발행한다

반대로 Application Service에 쓰면 안 되는 코드 의 대표적인 예는 다음과 같습니다.

// ❌ 안티패턴: Application 계층으로 비즈니스 규칙이 새고 있다
class PlaceOrderUseCase {
  async execute(command: PlaceOrderCommand) {
    // ❌ 재고 체크를 여기서 판단 → 도메인 지식 유출
    if (product.stock < command.quantity) {
      throw new Error("재고가 부족합니다");
    }
    // ❌ 합계 금액 계산을 여기서 수행 → Order 의 책임
    const total = command.items.reduce((s, i) => s + i.price * i.quantity, 0);
    // ❌ 상태 판단을 여기서 수행 → Order 의 책임
    if (user.status === "BLOCKED") throw new Error("계정이 정지되었습니다");

    await this.orderRepository.save({ /* ... */ });
  }
}

"이 코드를 업무 담당자에게 보여 줬을 때 업무 규칙이 읽히는가?"가 좋은 기준입니다. 여기서 if 문으로 비즈니스 판단을 하고 있다면, 그 로직은 Domain 계층으로 옮겨야 한다는 신호입니다.

각 계층을 관통하는 1개 요청의 흐름

실제 개발에서 "사용자가 주문 버튼을 눌렀다"는 상황의 흐름을 따라가 보겠습니다.

sequenceDiagram
  participant Browser
  participant Presentation as Presentation / OrderController
  participant Application as Application / PlaceOrderUseCase
  participant Domain as Domain / Order.place(...)
  participant Infrastructure as Infrastructure / Repository 구현

  Browser->>Presentation: POST /orders
  Presentation->>Application: PlaceOrderCommand 전달
  Note over Application: 트랜잭션 시작
  Application->>Infrastructure: userRepository.findById()
  Application->>Infrastructure: productRepository.findByIds()
  Infrastructure-->>Application: User / Product 애그리거트
  Application->>Domain: Order.place(user, products, items)
  activate Domain
  Domain->>Domain: 비즈니스 규칙 검증
  Domain->>Domain: 불변 조건 확인
  Domain->>Domain: OrderPlaced 이벤트 생성
  Domain-->>Application: Order 애그리거트와 OrderPlaced
  deactivate Domain
  Application->>Domain: inventoryService.reserve(order)
  Domain-->>Application: 재고 예약 결과
  Application->>Infrastructure: orderRepository.save(order)
  Application->>Infrastructure: eventPublisher.publishAll(...)
  Infrastructure-->>Application: 영속화 완료
  Note over Application: 트랜잭션 커밋
  Application-->>Presentation: OrderResult
  Presentation-->>Browser: 201 Created

이 흐름을 보면 알 수 있듯이, 비즈니스 판단이 발생하는 곳은 Domain 계층 안뿐 입니다. 다른 계층은 Domain이 일을 잘할 수 있도록 준비와 뒷정리만 해 줄 뿐입니다.

DTO와 도메인 객체의 경계

계층을 넘나들 때는 도메인 객체를 그대로 바깥으로 내보내지 않는 것 이 정석입니다.

// Domain 계층
class Order { /* 비즈니스 로직을 가진 애그리거트 */ }

// Application 계층: 입력 / 출력 DTO
class PlaceOrderCommand { /* 요청용 */ }
class OrderResult { /* 응답용 */ }

// Presentation 계층: HTTP 용 타입
class PlaceOrderRequest { /* JSON 스키마 */ }
class OrderResponse { /* 응답 JSON */ }

번거롭게 느껴질 수 있지만, 이 덕분에 다음을 얻습니다.

  • API 형태가 바뀌어도 Domain은 멀쩡하다
  • JSON 직렬화 사정이 도메인을 오염시키지 않는다
  • GraphQL / gRPC 전환도 국소적인 수정으로 끝낼 수 있다

특히 Domain Object에 @Column 같은 ORM 데코레이터나 @Expose 같은 직렬화용 어노테이션을 절대 붙이지 말아야 합니다. 그 순간 Domain이 프레임워크에 의존하게 됩니다.

구현할 때의 권장 순서

마지막으로, 새로운 기능을 DDD로 구현할 때의 추천 작업 순서 입니다.

  1. 유스케이스를 한 문장으로 쓴다: "사용자가 상품을 주문한다"
  2. 등장하는 용어를 추출한다: 사용자 / 상품 / 주문 / 재고
  3. 애그리게이트와 그 경계를 정한다: Order 애그리게이트에 무엇을 포함할지
  4. Domain 계층부터 작성한다: Entity, Value Object, Domain Service
  5. Domain 계층 테스트를 쓴다 (DB 없이 돌아가야 한다)
  6. Application Service를 쓴다: Repository는 interface 상태로 사용
  7. Infrastructure 계층을 구현한다: 실제 Repository 구현, ORM 설정
  8. Presentation 계층을 구현한다: Controller, 요청/응답 타입
  9. E2E 테스트

핵심은 안쪽부터 쓰는 것 입니다. "DB 테이블 설계부터 시작"하는 것은 DDD의 정신과 맞지 않습니다. 도메인 모델이 먼저이고, 테이블은 나중 입니다. 이 원칙만은 양보하면 안 됩니다.

폴더 구조의 베스트 프랙티스

DDD의 폴더 구조에서 가장 먼저 부딪히는 질문이 이것입니다.

레이어별 로 나눌까, 아니면 도메인별(기능별) 로 나눌까?

결론부터 말하면, 도메인별로 먼저 자르고 그 안에서 레이어를 나누는 방식 이 정석입니다.

❌ 레이어 우선(안티패턴에 가깝다)

src/
├── controllers/
│   ├── user.controller.ts
│   ├── order.controller.ts
│   └── product.controller.ts
├── services/
│   ├── user.service.ts
│   ├── order.service.ts
│   └── product.service.ts
├── repositories/
│   ├── user.repository.ts
│   ├── order.repository.ts
│   └── product.repository.ts
└── entities/
    ├── user.entity.ts
    ├── order.entity.ts
    └── product.entity.ts

처음에는 이해하기 쉽지만,

  • 하나의 유스케이스를 바꾸려면 네 개의 폴더를 오가야 한다
  • 도메인의 경계가 보이지 않는다 (전부 평평하게 보인다)
  • 마이크로서비스화할 때 떼어내기 어렵다

규모가 커질수록 급격히 힘들어집니다.

✅ 도메인 우선(권장)

src/
├── contexts/                      ← Bounded Context 단위로 구성
│   ├── ordering/                  ← 주문 컨텍스트
│   │   ├── domain/
│   │   │   ├── order.ts           (Entity / Aggregate Root)
│   │   │   ├── order-item.ts
│   │   │   ├── order-id.ts        (Value Object)
│   │   │   ├── order-status.ts
│   │   │   ├── order.repository.ts(interface 만)
│   │   │   └── events/
│   │   │       └── order-placed.ts
│   │   ├── application/
│   │   │   ├── place-order.usecase.ts
│   │   │   └── cancel-order.usecase.ts
│   │   ├── infrastructure/
│   │   │   ├── postgres-order.repository.ts
│   │   │   └── stripe-payment.gateway.ts
│   │   └── presentation/
│   │       ├── order.controller.ts
│   │       └── dto/
│   │           ├── place-order.request.ts
│   │           └── order.response.ts
│   ├── catalog/                   ← 상품 카탈로그 컨텍스트
│   │   └── (같은 구조)
│   └── shipping/                  ← 배송 컨텍스트
│       └── (같은 구조)
├── shared-kernel/                 ← 모든 컨텍스트가 공유하는 Value Object
│   ├── money.ts
│   ├── email.ts
│   └── user-id.ts
└── shared/                        ← 횡단 기술 기반
    ├── logger.ts
    ├── transaction-manager.ts
    └── event-bus.ts

이 레이아웃의 장점은 크게 세 가지입니다.

  1. 컨텍스트 경계가 물리적으로 보인다. ordering/ 안에서 닫히는 설계가 자연스럽게 생긴다.
  2. 마이크로서비스화가 쉽다. contexts/ordering/를 그대로 별도 리포지토리로 분리할 수 있다.
  3. 코드 리뷰 관점이 좁혀진다. "이 변경이 ordering 안에서만 끝나는가?"를 한눈에 볼 수 있다.

도메인 계층 안에서의 정리

domain/ 아래가 커지기 시작하면, 애그리게이트 단위로 폴더를 나누는 것 이 좋습니다.

ordering/domain/
├── order/
│   ├── order.ts                   (애그리거트 루트)
│   ├── order-item.ts
│   ├── order-id.ts
│   ├── order-status.ts
│   ├── order.repository.ts
│   └── events/
│       ├── order-placed.ts
│       └── order-cancelled.ts
├── customer/
│   ├── customer.ts
│   └── customer.repository.ts
└── services/
    └── pricing.service.ts          (Domain Service)

애그리게이트가 "자기 세계"를 가진 것처럼 정리되면, 의존 관계도 훨씬 따라가기 쉬워집니다.

모노레포에서의 구성

여러 서비스를 하나의 리포지토리로 관리한다면 구조는 대체로 이렇게 됩니다.

monorepo/
├── apps/
│   ├── web/                       (Next.js 프런트엔드)
│   ├── api/                       (백엔드 API)
│   └── admin/                     (관리 화면)
├── packages/
│   ├── ordering-context/          ← 패키지로 묶인 Bounded Context
│   ├── catalog-context/
│   ├── shipping-context/
│   └── shared-kernel/
└── package.json

핵심은 각 바운디드 컨텍스트가 독립된 npm 패키지 라는 점입니다. apps/api는 그것들을 import해서 사용할 뿐이고, 이 구조 덕분에 컨텍스트 간 의존을 패키지 수준에서 강제 할 수 있습니다.

네이밍 규칙 힌트

파일명과 클래스명도 일관성이 있으면 좋습니다. 경험상 잘 맞는 규칙은 다음과 같습니다.

대상 네이밍 예시
Entity / Aggregate Root Order (명사, 단수형)
Value Object Email, Money, OrderId
Repository (interface) OrderRepository (Repository로 끝남)
Repository (구현) PostgresOrderRepository (기술명 + Repository)
Domain Service PricingService, TransferService
Application Service PlaceOrderUseCase, CancelOrderUseCase
Domain Event OrderPlaced, OrderCancelled (과거형)
Command (DTO) PlaceOrderCommand

특히 Domain Event를 과거형으로 만드는 것 은 중요합니다. 이것이 "앞으로 할 일"이 아니라 "이미 일어난 일"임을 분명히 해 줍니다.

UI가 있는 시스템에서의 레이어 설계

여기까지는 주로 백엔드 중심의 이야기였습니다. 그렇다면 UI(웹 프론트엔드) 가 더해지면 레이어 개념은 어떻게 달라질까요?

백엔드의 4계층을 프론트엔드에 그대로 가져오면…

결론부터 말하면, 억지로 그대로 가져오지 않는 편이 좋습니다. 프론트엔드에는 독자적인 관심사가 있고, 서버와 같은 계층 구조를 강제하면 오히려 답답해집니다.

[프런트엔드 고유의 관심사]
- 라우팅
- 화면 상태(로딩, 오류, 선택 중 등)
- 폼 유효성 검사(UX 용)
- 애니메이션
- 반응형 재렌더링

이것들을 모두 Domain 계층에 밀어 넣으면 Domain이 React나 Vue 같은 프레임워크에 의존하게 되어 본말이 전도됩니다.

프론트엔드의 레이어 모델

실용적인 분리는 대략 다음과 같습니다.

flowchart TB
  R["Pages / Routes 계층<br/>Next.js 의 app/, pages/ 등"]
  C["Components 계층<br/>UI 컴포넌트 / 시각적 책임"]
  V["ViewModel / Hooks 계층<br/>화면 상태와 유스케이스 호출"]
  A["Application 계층<br/>유스케이스 / API 클라이언트"]
  D["Domain 계층<br/>프런트엔드의 최소 도메인 모델"]

  R --> C --> V --> A --> D

  classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
  class R,C,V,A layer
  class D domain

각 계층의 책임은 다음과 같습니다.

계층 책임
Pages / Routes URL과 페이지를 연결 /orders/new 페이지
Components 화면 표현과 상호작용 <OrderForm />, <Button />
ViewModel / Hooks 화면 상태 관리, 유스케이스 호출 useOrderForm()
Application API 호출, 데이터 가공 placeOrderUseCase()
Domain 프론트엔드에 필요한 최소 도메인 규칙 Order.canCancel()

React + TypeScript 구현 예시

// Domain 계층: 백엔드와 같은 도메인 규칙을 얇게 유지한다
class Order {
  constructor(
    public readonly id: string,
    public readonly status: OrderStatus,
    public readonly items: OrderItem[],
  ) {}

  // UI 에서 "취소 버튼을 눌러도 되는가?" 판단은 여기에 모은다
  canCancel(): boolean {
    return this.status === "PENDING" || this.status === "CONFIRMED";
  }

  totalPrice(): number {
    return this.items.reduce((s, i) => s + i.price * i.quantity, 0);
  }
}

// Application 계층: API 를 호출하는 유스케이스
class PlaceOrderUseCase {
  constructor(private readonly api: OrderApiClient) {}

  async execute(items: CartItem[]): Promise<Order> {
    const response = await this.api.placeOrder({ items });
    return Order.fromResponse(response);
  }
}

// ViewModel / Hooks 계층: 화면 상태를 관리
function useOrderForm() {
  const [items, setItems] = useState<CartItem[]>([]);
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const placeOrder = useMemo(() => new PlaceOrderUseCase(orderApi), []);

  const submit = async () => {
    setIsSubmitting(true);
    try {
      await placeOrder.execute(items);
    } catch (e) {
      setError(formatError(e));
    } finally {
      setIsSubmitting(false);
    }
  };

  return { items, setItems, isSubmitting, error, submit };
}

// Components 계층: 겉모양에만 집중
function OrderForm() {
  const { items, isSubmitting, error, submit } = useOrderForm();
  return (
    <form onSubmit={(e) => { e.preventDefault(); submit(); }}>
      {/* JSX */}
    </form>
  );
}

핵심은 Component가 오로지 화면 에만 집중할 수 있다는 점입니다. 상태 관리는 Hook에, 비즈니스 규칙(canCancel() 등)은 Domain에, API 호출은 Application에 나뉘어 있습니다.

프론트엔드의 Domain 계층은 어디까지 가져갈까

이것은 프로젝트의 성격 에 따라 달라집니다.

프로젝트의 성격 프론트엔드 Domain 계층
단순 CRUD 화면이 대부분 거의 불필요, API 응답을 바로 써도 된다
UI 쪽에서 복잡한 계산/판단이 필요 가져간다 (가격 재계산, 선택 가능 여부 판정 등)
오프라인 대응 / 모바일 제대로 가져간다 (뒤에서 설명)

또 하나 중요한 점은, 서버와 동일한 도메인 모델을 그대로 복사할 필요는 없다는 것입니다. 프론트엔드에서는 사용자 조작과 관련된 규칙만 남기면 됩니다.

BFF라는 선택지

"프론트와 백엔드의 데이터 형태가 맞지 않는다", "한 화면에서 여러 API를 조합해야 한다" 같은 경우에는 BFF를 사이에 두는 것 도 유효합니다.

flowchart TB
  Browser["브라우저"]
  BFF["BFF<br/>Next.js API Routes / Hono 등<br/>화면에 최적화된 엔드포인트"]
  Ordering["주문 서비스"]
  Catalog["카탈로그 서비스"]
  Inventory["재고 서비스"]
  Response["화면에 맞게 가공한 응답"]

  Browser --> BFF
  BFF --> Ordering
  BFF --> Catalog
  BFF --> Inventory
  Ordering --> BFF
  Catalog --> BFF
  Inventory --> BFF
  BFF --> Response --> Browser

  classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
  classDef service fill:#e0e7ff,stroke:#4f46e5,color:#312e81
  classDef output fill:#ffffff,stroke:#94a3b8,color:#334155
  class Browser,BFF layer
  class Ordering,Catalog,Inventory service
  class Response output

BFF는 도메인을 가지지 않는, 화면만을 위한 얇은 계층 입니다. 여러 애그리게이트를 한 번에 가져오거나, 여러 컨텍스트의 결과를 하나의 JSON으로 가공해 화면에 맞게 반환할 수 있습니다. CQRS의 Query 측으로 기능하게 하는 것도 자연스럽습니다.

모바일 앱에 DDD를 적용할 수 있을까

"DDD는 서버 쪽 이야기 아닌가요?"

이렇게 생각하기 쉽지만, 모바일 앱에도 충분히 적용할 수 있습니다. 오히려 오프라인 대응이나 동기화 처리가 얽히는 경우에는 도메인 모델의 이점이 더 크게 느껴질 수 있습니다.

모바일 앱 특유의 사정

사정 영향
오프라인 대응 로컬 DB가 필요 -> Repository가 빛을 발함
푸시 알림 / 백그라운드 처리 Domain Event와 궁합이 좋음
단말 센서 / 카메라 Infrastructure 계층으로 다룸
앱 라이프사이클 상태 영속화가 더 복잡해짐
버전 호환성 오래된 단말과의 하위 호환이 필요

이것들을 Activity / Fragment / ViewController에 직접 써 버리면, OS 업데이트 한 번에 전체가 무너질 수 있습니다.

네이티브 구현에서의 계층 구성 예시(Swift / Kotlin)

App/
├── Domain/
│   ├── Entities/
│   │   ├── Recipe.swift
│   │   └── Ingredient.swift
│   ├── ValueObjects/
│   │   └── CookingTime.swift
│   ├── Repositories/
│   │   └── RecipeRepository.swift   (protocol / interface)
├── Application/
│   └── UseCases/
│       └── SearchRecipeUseCase.swift
├── Infrastructure/
│   ├── Repositories/
│   │   ├── CoreDataRecipeRepository.swift  (로컬 DB)
│   │   └── ApiRecipeRepository.swift       (원격 API)
│   ├── Storage/
│   │   └── KeyValueStore.swift
│   └── Sync/
│       └── RecipeSyncService.swift
└── Presentation/
    ├── ViewModels/
    │   └── RecipeListViewModel.swift
    └── Views/
        └── RecipeListView.swift            (SwiftUI)

앞 장들에서 정리한 것과 마찬가지로, UseCase는 Application 계층에 두고 Domain 계층은 모델과 규칙에 집중시키는 형태입니다.

오프라인 대응에서 Repository가 살아나는 예

// Domain 계층: 여러 저장소 대상을 추상화
protocol RecipeRepository {
    func find(id: RecipeId) async throws -> Recipe?
    func save(_ recipe: Recipe) async throws
}

// Infrastructure 계층: 로컬 + 원격을 합성
class HybridRecipeRepository: RecipeRepository {
    let local: CoreDataRecipeRepository
    let remote: ApiRecipeRepository

    func find(id: RecipeId) async throws -> Recipe? {
        // 오프라인이어도 로컬에서 가져올 수 있다
        if let cached = try await local.find(id: id) {
            return cached
        }
        let remote = try await remote.find(id: id)
        if let recipe = remote {
          try await local.save(recipe)  // 캐시 갱신
        }
        return remote
    }
}

오프라인 대응 로직이 Repository 구현 안에 닫혀 있기 때문에, ViewModel이나 도메인은 그저 "가져온다"라고만 생각하면 됩니다. 이것이 DDD의 힘입니다.

React Native / Flutter에서의 적용

크로스플랫폼에서도 생각 방식은 같습니다. 오히려 공통 Domain 계층을 Web과 앱에서 공유할 수 있다 는 것이 큰 장점입니다.

shared/
└── domain/                        ← Web 과 Mobile 이 공통 사용
    ├── recipe.ts
    └── recipe.repository.ts
apps/
├── web/
│   └── infrastructure/
│       └── api-recipe.repository.ts
└── mobile/
    └── infrastructure/
        └── sqlite-recipe.repository.ts  (offline first)

Domain이 아무것에도 의존하지 않기 때문에 가능한 구조이며, 말 그대로 DDD의 진가가 드러나는 장면입니다.

다만 "가볍게 유지하기"를 잊지 말자

서버사이드 DDD를 풀세트로 모바일에 들여오는 것은 과합니다. 모바일 앱에서는:

  • 애그리게이트 경계가 서버만큼 엄격할 필요는 없다(최종 정합성은 결국 서버가 보장한다)
  • 도메인 이벤트도 로컬 이벤트 알림 정도면 충분한 경우가 많다
  • CQRS까지 도입할 필요는 없는 경우가 많다

현실적인 균형점은 서버에서 만든 도메인 모델의 클라이언트 버전을 얇게 유지하는 것 입니다.

CRUD, 관리 화면과 DDD의 공존

여기까지 읽고 나면, "우리 앱은 설정 화면이나 관리 화면이 대부분인데, 그래도 DDD인가요?"라고 생각하는 분이 많을 것입니다. 그 위화감은 맞습니다.

DDD는 복잡한 도메인을 위한 것 입니다. 복잡성이 없는 곳에 들여오면 코드만 길어질 뿐입니다.

도메인의 "무게"를 가려내기

하나의 프로젝트 안에서도 기능별 복잡도는 다릅니다.

기능 복잡도의 내용 구현 방침
주문 처리 재고 할당, 가격 계산, 복잡한 상태 전이 DDD로 공들여 구현
재고 관리 예약, 해제, 정합성 유지 DDD로 공들여 구현
상품 마스터 관리 거의 CRUD 경량으로 충분
사용자 설정 단순한 값 저장 CRUD로 충분
관리자 로그 조회 조회해서 보여주기만 함 CRUD로 충분

여기서 중요한 것은 앞서 나온 코어 / 지원 / 범용 분류가 구현의 무게와 그대로 대응된다 는 점입니다.

도메인 분류 구현 스타일
코어 도메인 풀 DDD 수주, 재고, 가격 전략
지원 서브도메인 경량 DDD 또는 트랜잭션 스크립트 상품 마스터, 배송 설정
범용 서브도메인 CRUD 또는 SaaS 인증, 메일 발송, 설정 화면

CRUD 부분은 어떻게 쓸까

가벼운 부분은 솔직하게 트랜잭션 스크립트로 작성해도 됩니다.

// ✅ 설정 화면 갱신은 이 정도면 충분하다
class UpdateSiteSettingsHandler {
  constructor(private readonly db: Database) {}

  async execute(input: UpdateSiteSettingsInput): Promise<void> {
    await this.db.siteSettings.update({
      where: { id: 1 },
      data: input,
    });
  }
}

여기에 억지로 Entity, Repository, Domain Service를 넣어도 코드량만 늘고 얻는 것은 없습니다.

같은 프로젝트에서 둘을 섞어 쓰기

실무에서는 컨텍스트마다 구현 스타일을 바꾸는 것 이 현실적인 해법입니다.

src/contexts/
├── ordering/                ← 풀 DDD
│   ├── domain/
│   ├── application/
│   ├── infrastructure/
│   └── presentation/
├── inventory/               ← 풀 DDD
│   └── (위와 같음)
├── catalog/                 ← 경량 DDD(Entity 는 두되 Service 는 최소)
│   └── (간략판)
└── admin/                   ← CRUD 기반
  ├── handlers/            (얇은 핸들러만)
    └── controllers/

컨텍스트 안에서 일관성만 유지되면 충분합니다. 프로젝트 전체를 하나의 스타일로 통일할 필요는 없습니다.

관리 화면의 읽기 전용 API에는 CQRS가 편리하다

관리 화면에서 자주 나오는 "여러 애그리게이트를 결합해서 목록으로 보여 주고 싶다"는 요구는, 대개 애그리게이트를 넘나드는 조회 가 됩니다. 이것은 DDD의 애그리게이트 원칙과 궁합이 좋지 않습니다.

여기서 유용한 것이 가벼운 CQRS 도입 입니다. 쓰기(Command)는 애그리게이트를 통해 엄격하게 처리하고, 읽기(Query)는 원시 SQL을 써도 된다고 선을 긋는 방식입니다.

// 쓰기 작업은 애그리거트를 거친다(엄격)
class CancelOrderUseCase {
  async execute(orderId: OrderId): Promise<void> {
    const order = await this.orderRepository.findById(orderId);
    order.cancel();                    // 도메인 규칙
    await this.orderRepository.save(order);
  }
}

// 읽기 전용 쿼리는 생 SQL 도 괜찮다(화면 최적화)
class OrderListQuery {
  async findForAdmin(filter: AdminFilter): Promise<OrderListView[]> {
    return this.db.raw(`
      SELECT o.id, o.status, u.name AS user_name, p.name AS product_name, ...
      FROM orders o
      JOIN users u ON ...
      JOIN order_items oi ON ...
      WHERE ...
    `);
  }
}

애그리게이트를 하나씩 꺼내 메모리에서 조인하는 식으로 가지는 마세요. Read에는 Read 도구를 쓰는 것 이 가장 실용적입니다.

CRUD와 DDD를 섞을 때의 원칙

마지막으로, 둘을 혼용할 때의 원칙을 정리하면 다음과 같습니다.

  1. 컨텍스트 경계는 제대로 긋는다 - DDD가 아닌 부분이라도 경계 의식은 유지한다
  2. 코어 도메인에서는 아끼지 않는다 - 여기서 손을 빼면 나중에 반드시 후회한다
  3. CRUD 부분은 담백하게 쓴다 - 억지로 DDD화하지 않는다
  4. 읽기는 별도 경로를 허용한다 - Read Model이나 CQRS Lite를 활용한다
  5. 나중에 복잡해지면 승격한다 - 처음엔 CRUD였어도 복잡해지면 그때 DDD로 옮긴다

DDD는 종교가 아니라 도구입니다. 도메인의 복잡도에 맞는 투자 를 해야 합니다.

흔한 함정

1. 빈혈 도메인 모델

Entity가 그저 getter / setter 묶음이 되고, 로직이 Service로 빠져나간 상태입니다. "행위 없는 객체는 진짜 객체가 아니다" 라는 말을 기억해 두면 좋습니다.

2. 모든 곳에 DDD를 적용하기

설정 화면, 관리 화면, 단순 CRUD. 이런 곳에까지 DDD를 들여오면 개발 속도는 확실히 떨어집니다. 복잡한 도메인이 있는 곳에만 가져와야 합니다. 앞서 살펴본 CRUD, 관리 화면과 DDD의 공존 부분도 함께 참고해 보세요.

3. 전술만 집어먹기

"Entity와 Repository를 만들었으니 DDD다"는 아닙니다. 유비쿼터스 언어와 바운디드 컨텍스트에 대한 논의 없이 전술적 패턴만 가져오면, 기대한 효과를 내기 어렵습니다.

DDD를 시작하기 위한 단계

실무에서 DDD를 도입하고 싶다면, 처음부터 풀스펙으로 가지 말고 단계적으로 접근하는 것을 권합니다.

  1. 업무의 언어를 모은다 - 도메인 전문가와 함께 용어집을 만든다
  2. 경계를 의식한다 - 어디까지가 같은 문맥인지 선을 긋는다
  3. Value Object부터 시작한다 - 원시 타입을 의미 있는 타입으로 바꾼다
  4. 애그리게이트를 식별한다 - 트랜잭션 경계를 의식한다
  5. 이벤트로 느슨하게 연결한다 - 필요하면 도메인 이벤트를 도입한다

마치며

개인적으로 DDD의 본질은 도메인의 복잡성과 코드를 통해 정면으로 마주하는 것 이라고 생각합니다.

패턴을 외우는 것이 목적이 되어 버리면 오히려 코드가 더 복잡해지기 쉽습니다. 팀이 함께 유비쿼터스 언어를 키워 가는 것 이야말로 DDD의 시작점이자, 어떤 의미에서는 도착점이기도 합니다.

내일부터의 코드 리뷰에서, "이 클래스 이름이 업무 담당자에게도 통할까?"라고 한마디 던져 보는 것부터 시작해 보면 어떨까요?

참고 문헌

  • Eric Evans "Domain-Driven Design: Tackling Complexity in the Heart of Software"
  • Eric Evans "DDD Reference: Definitions and Pattern Summaries" https://www.domainlanguage.com/ddd/reference/
  • Vaughn Vernon "Implementing Domain-Driven Design"
  • Masanobu Naruse "도메인 주도 설계 입문"
  • Toru Masuda "현장에서 도움이 되는 시스템 설계의 원칙"
  • Martin Fowler "Patterns of Enterprise Application Architecture"
  • Robert C. Martin "Clean Architecture"
  • Alberto Brandolini "Introducing EventStorming"