콘텐츠로 이동

DDD (Domain-Driven Design) Basics

분류: Layer 9 - 아키텍처 & 설계 패턴

DDD(Domain-Driven Design)는 복잡한 비즈니스 규칙을 코드의 중심에 두기 위해 도메인을 명확한 경계로 나누고, 그 경계 안에서 같은 언어와 모델을 쓰게 만드는 설계 방법론이다. 핵심은 멋진 용어를 많이 붙이는 것이 아니라, “주문”, “결제”, “배송” 같은 업무 개념이 시간이 지나도 코드 안에서 서로 섞이지 않게 하는 것이다.

Clean Architecture가 “비즈니스 규칙이 DB, 프레임워크, HTTP에 의존하지 않게 하라”는 의존성 규칙을 제공했다면, DDD는 “그 비즈니스 규칙을 어떤 업무 경계와 언어로 나눌 것인가”를 다룬다. 그래서 두 주제는 같이 쓰이지만 역할이 다르다.

선수지식: clean-architecture.mdx(의존성 규칙, Use Case, Domain Layer). 후속: cqrs-event-sourcing.mdx, saga-pattern.mdx, msa-patterns.mdx.

2. DDD는 어떤 문제 때문에 등장했는가

섹션 제목: “2. DDD는 어떤 문제 때문에 등장했는가”

작은 서비스에서는 Order, Customer, Product 같은 모델을 하나씩 만들고 여러 기능에서 공유해도 큰 문제가 없어 보인다. 하지만 서비스가 커지면 같은 단어가 팀마다 다른 의미를 갖기 시작한다.

예를 들어 이커머스에서 “주문”은 다음처럼 다르게 보인다.

영역같은 단어실제 관심사
주문Order사용자가 담은 상품, 수량, 주문 확정 가능 여부
결제Order결제 승인 금액, 승인 번호, 환불 가능 금액
배송Order출고 가능 여부, 수령인, 주소, 송장 상태
정산Order매출 인식 시점, 수수료, 세금계산서

초기에는 하나의 Order 모델에 paymentTxId, shippingAddress, invoiceStatus, campaignCode를 계속 붙이면 빠르다. 문제는 시간이 지난 뒤다. 배송팀이 주소 형식을 바꾸면 결제 테스트가 깨지고, 정산팀이 세금 필드를 필수로 만들면 주문 생성 API가 실패한다. 모델을 공유했지만 실제로는 서로 다른 업무 규칙을 하나의 객체에 강제로 넣은 것이다.

DDD는 이 문제를 “모델이 부족해서”가 아니라 “경계가 없어서” 생긴 문제로 본다. 하나의 거대한 모델을 더 정교하게 만들기보다, 같은 단어가 다른 의미를 갖는 지점을 찾아 경계를 나누고 각 경계 안에서만 모델을 일관되게 만든다.

선행 기술의 한계 — DDD 등장 이유

섹션 제목: “선행 기술의 한계 — DDD 등장 이유”

Clean Architecture만 적용해도 도메인 객체가 ORM이나 Controller에 의존하지 않게 만들 수 있다. 하지만 orders/domain/Order.ts 하나가 주문, 결제, 배송, 정산 규칙을 모두 담고 있다면 의존성 방향은 깨끗해도 모델 자체는 여전히 비대하다. 레이어가 깔끔해도 업무 경계가 흐리면 변경 비용은 줄지 않는다.

DDD의 철학은 “코드는 조직이 쓰는 업무 언어의 압축본이어야 한다”는 쪽에 가깝다. 도메인 전문가가 “주문을 확정하면 결제 요청이 가능해진다”고 말한다면, 코드도 order.confirm()OrderConfirmed처럼 그 문장을 따라가야 한다. order.setStatus("CONFIRMED")처럼 기술적 상태값만 바꾸는 코드는 처음에는 간단하지만, 시간이 지나면 “확정”이 뜻하는 비즈니스 규칙을 여러 서비스와 if문에 흩뿌린다.

3. 전략적 설계: 먼저 경계를 나눈다

섹션 제목: “3. 전략적 설계: 먼저 경계를 나눈다”

DDD에서 먼저 잡아야 하는 것은 클래스 이름이 아니라 경계다. 경계를 잘못 잡으면 뒤에서 아무리 Entity, Value Object, Repository를 잘 만들어도 복잡도가 줄지 않는다.

Ubiquitous Language: 모두가 같은 뜻으로 쓰는 업무 언어

섹션 제목: “Ubiquitous Language: 모두가 같은 뜻으로 쓰는 업무 언어”

Ubiquitous Language(유비쿼터스 언어)는 도메인 전문가, 기획자, 개발자가 한 경계 안에서 같은 뜻으로 쓰는 언어다. 단순한 용어집이 아니라 코드에도 강제되는 언어다.

주문 경계 안에서 “확정”이 “상품이 있고, 가격이 계산됐고, 이후에는 상품을 추가할 수 없는 상태로 전환한다”는 뜻이라면 코드도 그 의미를 드러내야 한다.

// 의미가 약하다: 상태값만 바꾸는지, 규칙 검증도 하는지 알기 어렵다.
order.setStatus("CONFIRMED");
// 의미가 선명하다: 주문 확정이라는 도메인 행동을 호출한다.
order.confirm();

첫 번째 코드는 상태 저장에는 편하지만 언어가 빈약하다. 두 번째 코드는 “확정”이라는 업무 행동이 어디에 있는지 보여 주고, 그 안에서 불변식 검증과 도메인 이벤트 발행을 모을 수 있다.

Bounded Context: 모델이 같은 뜻을 유지하는 경계

섹션 제목: “Bounded Context: 모델이 같은 뜻을 유지하는 경계”

Bounded Context(경계 컨텍스트)는 특정 모델과 언어가 일관된 뜻을 갖는 범위다. “Customer”라는 단어도 주문 경계에서는 구매자, 배송 경계에서는 수령인, 정산 경계에서는 세금 정보를 가진 지급 주체일 수 있다. DDD는 이 차이를 억지로 하나의 Customer로 합치지 않는다.

[ 주문 Context ] [ 배송 Context ] [ 정산 Context ]
Buyer Recipient Payer
Order Shipment Invoice
OrderLine DeliveryAddress TaxProfile

같은 사람을 가리킬 수는 있지만, 각 Context가 알고 싶은 규칙은 다르다. 주문은 “구매 가능한가”를 보고, 배송은 “어디로 보낼 수 있는가”를 보고, 정산은 “어떤 세금 처리가 필요한가”를 본다.

Worked Example: 공유 Product 모델이 실패하는 과정

섹션 제목: “Worked Example: 공유 Product 모델이 실패하는 과정”

상품을 예로 들어 보자. 처음에는 모든 팀이 아래 모델을 공유한다.

Product {
id,
name,
price
}

3개월 뒤 요구사항이 늘어난다.

Product {
id,
name,
price,
displayName, // 카탈로그 화면 노출명
searchKeywords, // 검색팀 관심사
stockQuantity, // 재고팀 관심사
warehouseLocation, // 물류팀 관심사
settlementCategory, // 정산팀 관심사
taxCode // 세금 처리 관심사
}

이 모델은 재사용된 것이 아니라 여러 경계의 요구를 한 객체에 쌓은 것이다. 카탈로그팀이 displayName을 다국어 구조로 바꾸면 재고 배치가 영향을 받고, 정산팀이 taxCode를 필수로 만들면 상품 등록 화면이 깨진다. DDD식으로 나누면 모델은 작아지고 변경 이유가 분리된다.

Catalog Context: Product { id, displayName, description, searchKeywords }
Inventory Context: StockItem { productId, quantity, warehouseLocation }
Pricing Context: SellableItem { productId, basePrice, discountPolicy }
Settlement Context: SettlementItem { productId, taxCode, category }

경계 분리는 중복을 허용한다. 여러 Context에 productId가 있어도 괜찮다. 중요한 것은 같은 데이터를 한 곳에 모으는 것이 아니라, 변경 이유가 다른 규칙을 한 모델에 묶지 않는 것이다.

Context Map: 경계 사이의 관계를 드러낸다

섹션 제목: “Context Map: 경계 사이의 관계를 드러낸다”

Context Map은 Bounded Context 사이의 의존 관계와 번역 방식을 표시한 지도다. 주문 Context가 결제 Context의 내부 모델을 직접 import하면 경계를 나눈 의미가 약해진다. 보통은 포트, API, 이벤트, Anti-Corruption Layer(외부 모델을 내부 언어로 번역하는 보호 계층)를 둔다.

주문 Context -- OrderConfirmed 이벤트 --> 배송 Context
주문 Context -- PaymentPort -----------> 결제 Context
주문 Context -- ACL -------------------> 외부 PG 시스템

이 지도는 마이크로서비스 설계도가 아니다. 한 프로세스 안의 모듈러 모놀리스에서도 Context Map은 필요하다. 먼저 코드 경계를 검증하고, 배포 독립성이 정말 필요해졌을 때 서비스로 분리하는 편이 보통 더 안전하다.

4. 전술적 설계: 경계 안에서 규칙을 보호한다

섹션 제목: “4. 전술적 설계: 경계 안에서 규칙을 보호한다”

전략적 설계가 “어디까지가 한 업무 언어인가”를 정한다면, 전술적 설계는 그 경계 안에서 객체가 규칙을 지키게 만드는 방법이다.

DDD 전술 패턴 핵심 용어

**Entity**

고유한 ID로 식별되는 객체. 속성이 바뀌어도 같은 대상으로 본다.

예시: `Order(id="o-1")`는 상태가 PENDING에서 CONFIRMED로 바뀌어도 같은 주문이다.

**Value Object**

ID가 없고 값의 조합으로 동일성을 판단하는 객체. 보통 불변으로 만든다.

예시: `Money(1000, "KRW")`, `DeliveryAddress(...)`.

**Aggregate**

함께 일관성을 지켜야 하는 객체 묶음. 하나의 트랜잭션 경계가 된다.

`Order`와 여러 `OrderLine`을 함께 저장해야 주문 금액 불변식을 지킬 수 있다.

**Aggregate Root**

Aggregate 외부에서 접근할 수 있는 유일한 진입점.

`OrderLine`을 직접 바꾸지 않고 `Order.addLine()`을 통해서만 바꾼다.

**Domain Event**

도메인에서 이미 발생한 의미 있는 사건.

`OrderConfirmed`, `PaymentApproved`, `ShipmentRequested`.

**Invariant**

어떤 상황에서도 깨지면 안 되는 비즈니스 규칙.

확정된 주문에는 상품을 추가할 수 없다. 주문 총액은 음수가 될 수 없다.

Entity는 “누구인가”가 중요하다. 주문 o-1은 주소가 바뀌거나 상태가 바뀌어도 같은 주문이다. 그래서 Entity에는 ID가 있고, 생명주기 동안 상태가 바뀔 수 있다.

Value Object는 “값이 같은가”가 중요하다. Money(1000, "KRW") 두 개는 같은 값이면 같은 돈 표현이다. 배송 주소도 시스템 요구에 따라 Value Object가 될 수 있다. 주소 자체의 ID를 추적하기보다 주소 값이 주문에 복사되어 주문 시점의 배송지를 보존하는 것이 중요하다면 Value Object로 보는 편이 자연스럽다.

export class Money {
constructor(
readonly amount: number,
readonly currency: "KRW" | "USD",
) {
if (amount < 0) throw new Error("금액은 0 이상이어야 합니다");
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("다른 통화끼리는 더할 수 없습니다");
}
return new Money(this.amount + other.amount, this.currency);
}
}

이 예제에서 Money는 DB 저장 방식이나 NestJS 데코레이터를 모른다. Clean Architecture의 Domain Layer 안에 두기 좋고, DDD 관점에서는 “돈끼리는 같은 통화만 더할 수 있다”는 작은 도메인 규칙을 담는다.

Aggregate(애그리게이트)는 함께 일관성을 지켜야 하는 객체 묶음이다. Aggregate Root(애그리게이트 루트)는 그 묶음의 대표 객체이며, 외부 코드는 Root를 통해서만 내부 객체를 바꾼다.

주문 Aggregate의 불변식을 예로 들면 다음과 같다.

  • 주문 총액은 주문 라인 금액의 합과 같아야 한다.
  • 확정된 주문에는 상품을 추가할 수 없다.
  • 수량은 1 이상이어야 한다.
  • 빈 주문은 확정할 수 없다.

외부 코드가 order.lines[0].quantity = -5처럼 내부를 직접 바꿀 수 있으면 이 규칙은 쉽게 깨진다. Root가 모든 변경을 받으면 검증, 계산, 이벤트 생성을 한곳에 모을 수 있다.

export class Order {
private status: "DRAFT" | "CONFIRMED" = "DRAFT";
private lines: OrderLine[] = [];
addLine(productId: string, price: Money, quantity: number): void {
if (this.status !== "DRAFT") {
throw new Error("확정된 주문에는 상품을 추가할 수 없습니다");
}
if (quantity <= 0) {
throw new Error("수량은 1 이상이어야 합니다");
}
this.lines.push(new OrderLine(productId, price, quantity));
}
confirm(): OrderConfirmed {
if (this.lines.length === 0) {
throw new Error("빈 주문은 확정할 수 없습니다");
}
this.status = "CONFIRMED";
return new OrderConfirmed(this.totalPrice());
}
private totalPrice(): Money {
return this.lines.reduce(
(sum, line) => sum.add(line.subtotal()),
new Money(0, "KRW"),
);
}
}

이 코드는 완전한 NestJS 예제가 아니라 개념을 보여 주기 위한 최소 코드다. 중요한 점은 OrderLine 저장소를 따로 열어 직접 수정하지 않는다는 것이다. 주문의 핵심 불변식은 Order가 책임진다.

호출 흐름을 손으로 따라가면 Aggregate Root의 역할이 더 분명하다.

order.addLine("book", 10_000 KRW, 2)
-> lines에 2개 수량이 추가되고 totalPrice 후보는 20_000 KRW
order.confirm()
-> 빈 주문이 아니므로 status = CONFIRMED
-> OrderConfirmed(totalPrice=20_000 KRW) 반환
order.addLine("pen", 1_000 KRW, 1)
-> 이미 CONFIRMED라 예외 발생

이처럼 외부 코드가 내부 배열을 직접 만지는 대신 Root의 메서드를 지나가면, 상태 변경과 이벤트 생성이 같은 규칙 아래에 묶인다.

Domain Event: 상태 변경 뒤에 남기는 도메인 사건

섹션 제목: “Domain Event: 상태 변경 뒤에 남기는 도메인 사건”

Domain Event(도메인 이벤트)는 “무언가를 해 달라”는 명령이 아니라 “도메인에서 이미 중요한 일이 발생했다”는 기록이다. OrderConfirmed는 주문이 확정됐다는 과거형 사건이고, 배송 Context는 이 사건을 받아 ShipmentRequested를 만들 수 있다.

Order.confirm()
-> OrderConfirmed(orderId, totalPrice, confirmedAt)
-> 배송 Context가 수신해 출고 준비를 시작
-> 결제 Context가 수신해 결제 승인 흐름을 시작

Domain Event를 쓰면 Context 간 직접 호출을 줄일 수 있다. 하지만 이벤트는 일관성 모델을 바꾼다. 주문 확정과 배송 생성이 반드시 같은 DB 트랜잭션 안에서 끝나야 한다면 이벤트 분리는 맞지 않을 수 있다. 반대로 “주문 확정은 즉시 완료하고, 배송 준비는 몇 초 뒤 따라와도 된다”면 이벤트가 경계를 느슨하게 만든다.

5. Aggregate를 작게 유지해야 하는 이유

섹션 제목: “5. Aggregate를 작게 유지해야 하는 이유”

Aggregate는 “관련 있어 보이는 객체를 모두 넣는 상자”가 아니다. 한 트랜잭션 안에서 반드시 함께 검증해야 하는 규칙의 경계다. 너무 크게 잡으면 규칙은 모인 것처럼 보이지만 저장과 동시성 비용이 커진다.

Counterexample: 너무 큰 Order Aggregate

섹션 제목: “Counterexample: 너무 큰 Order Aggregate”

아래처럼 Order 하나가 결제, 배송, 리뷰까지 모두 포함한다고 가정하자.

Order Aggregate
- OrderLine[]
- Payment[]
- Shipment
- DeliveryTracking[]
- Review
- RefundRequest[]

이 구조는 “주문과 관련된 모든 것”을 한곳에 둔 것처럼 보여서 처음에는 편하다. 하지만 실제 쓰기 패턴은 다르다.

변경빈도반드시 주문 확정과 같은 트랜잭션이어야 하는가
주문 상품 추가checkout 중 몇 번예, 주문 총액 불변식과 연결
결제 승인 상태 갱신결제 재시도마다대개 아니오, 결제 Context 규칙
배송 추적 이벤트 추가배송 중 여러 번아니오, 배송 Context 규칙
리뷰 작성구매 후 1회아니오, 리뷰 Context 규칙

모두 한 Aggregate에 넣으면 배송 추적 이벤트가 추가될 때도 거대한 주문 행이나 관련 묶음을 잠그게 된다. checkout RPS가 50이고 배송 추적 업데이트가 초당 200건 들어오는 환경이라면, 서로 다른 업무 흐름이 같은 Aggregate 저장 경합을 만든다. p95 저장 시간이 갑자기 100ms, 300ms처럼 튀는 숫자는 절대 기준이 아니라 “경계가 쓰기 패턴과 맞지 않는다”는 관찰 신호로 봐야 한다. 이 숫자는 튜닝 기준이 아니라 경계 감각을 주는 예시다. DB lock 내부 구현은 몰라도 되고, 서로 다른 업무가 같은 저장 묶음을 기다리게 되는 상황만 잡으면 된다. 핵심은 단순하다. 서로 다른 업무가 같은 주문 묶음을 자주 저장하려고 기다리면, 실제로는 관련 없는 변경까지 한 줄로 서게 된다.

분리 기준은 간단하다.

  • 같은 불변식을 지키기 위해 한 트랜잭션 안에서 같이 바뀌어야 하면 같은 Aggregate 후보
  • 단지 화면에서 함께 보인다는 이유라면 같은 Aggregate가 아니다
  • 다른 팀이나 다른 유스케이스가 독립적으로 자주 갱신하면 분리 후보
  • 분리 뒤 필요한 연결은 직접 참조보다 ID와 Domain Event로 시작한다

따라서 위 예시는 보통 Order, Payment, Shipment, Review를 별도 Aggregate로 나누고 OrderConfirmed, PaymentApproved, ShipmentDelivered 같은 이벤트로 연결한다.

6. NestJS에서 어떻게 놓을 것인가

섹션 제목: “6. NestJS에서 어떻게 놓을 것인가”

NestJS 적용의 목표는 폴더를 DDD스럽게 꾸미는 것이 아니라 경계와 의존성을 보이게 만드는 것이다. Clean Architecture 문서의 구조를 그대로 확장하면 한 Bounded Context를 하나의 Nest module로 둘 수 있다.

src/
orders/ # 주문 Bounded Context
domain/
order.ts # Entity / Aggregate Root
money.ts # Value Object
events/order-confirmed.ts # Domain Event
order.repository.ts # Repository 인터페이스
application/
confirm-order.use-case.ts # Use Case
payment.port.ts # 다른 Context로 나가는 포트
infrastructure/
typeorm-order.repository.ts # DB 구현체
typeorm-outbox.repository.ts # Domain Event outbox 구현체
typeorm-order-unit-of-work.ts # 같은 DB transaction을 묶는 어댑터
payment-http.adapter.ts # 결제 Context/외부 PG 어댑터
orders.module.ts # DI 조립부

Use Case는 Aggregate를 불러와 도메인 행동을 호출하고 저장한다. 결제나 배송의 내부 테이블을 직접 조회하지 않고 포트나 이벤트로 연결한다.

접근 방식판단
OrderRepository.findById(orderId)로 주문 Aggregate를 가져온다좋음. 주문 Context 내부 모델을 사용한다.
주문 Use Case가 payments 테이블을 직접 조회한다나쁨. 결제 Context의 내부 저장 구조를 침범한다.
PaymentPort.requestApproval(orderId, amount)를 호출한다좋음. 결제 경계와 약속을 통해 연결한다.
export class ConfirmOrderUseCase {
constructor(private readonly unitOfWork: OrderUnitOfWork) {}
async execute(orderId: string): Promise<void> {
await this.unitOfWork.transaction(async (tx) => {
const order = await tx.orders.findById(orderId);
if (!order) throw new Error("주문을 찾을 수 없습니다");
const event = order.confirm();
await tx.orders.save(order);
await tx.outbox.append(event);
});
}
}

이 포트의 TypeORM adapter는 다음처럼 DataSource.transaction()이 제공한 manager를 모든 쓰기에 공유한다.

export class TypeOrmOrderUnitOfWork implements OrderUnitOfWork {
constructor(private readonly dataSource: DataSource) {}
transaction<T>(work: (tx: OrderTransaction) => Promise<T>): Promise<T> {
return this.dataSource.transaction((manager) =>
work({
orders: new TypeOrmOrderRepository(manager),
outbox: new TypeOrmOutboxRepository(manager),
}),
);
}
}

이 예제에서 ConfirmOrderUseCase는 TypeORM, Kafka, HTTP client를 몰라도 된다. Unit of Work는 여러 repository 쓰기를 하나의 커밋 경계로 묶는 포트이고, OrderUnitOfWork adapter는 주문 변경과 domain event의 outbox 기록을 같은 DB 트랜잭션에 넣는다. 원자성의 구체적인 불변식은 callback 안의 ordersoutbox가 모두 같은 transactional EntityManager만 사용한다는 것이다. 어느 한 adapter라도 전역 manager나 전역 repository를 사용하면 transaction 밖에서 쓰기가 실행될 수 있어 보장이 깨진다. 통합 테스트에서는 outbox 저장을 실패시킨 뒤 주문 변경도 rollback되는지 확인한다.

별도 relayer가 커밋된 outbox를 broker로 전달한다. 저장 후 외부 event bus를 바로 호출하면 DB와 broker 사이의 dual-write가 되므로, 그런 bus를 쓰려면 프로세스 내부 알림으로만 한정하고 외부 전달 보장과 혼동하지 않아야 한다. 어떤 저장소와 relayer를 쓸지는 orders.module.ts의 DI 조립부에서 결정한다. DDD는 여기서 “confirm이라는 도메인 행동과 OrderConfirmed라는 사건”을 선명하게 만들고, Clean Architecture는 “그 행동이 인프라 구현에 의존하지 않게” 만든다.

DDD는 모든 프로젝트에 기본값으로 넣는 장식이 아니다. CRUD가 대부분이고 업무 규칙이 단순한 관리자 화면에는 Entity, Aggregate, Domain Event를 억지로 늘리는 편이 더 비싸다. 반대로 여러 팀이 같은 단어를 다르게 쓰고, 규칙 변경이 자주 일어나며, 한 기능 변경이 다른 기능의 테스트를 반복해서 깨뜨린다면 DDD가 비용을 회수할 가능성이 커진다.

자주 생기는 오해는 다음과 같다.

오해바로잡기
Bounded Context는 곧 마이크로서비스다Bounded Context는 개념 경계다. 한 프로세스 안의 모듈로도 충분히 시작할 수 있다.
Ubiquitous Language는 용어집이다용어집은 보조 산출물이고, 핵심은 코드의 클래스·메서드·이벤트 이름까지 같은 언어를 쓰는 것이다.
Aggregate는 관련 객체를 모두 담는 그래프다Aggregate는 트랜잭션 일관성과 불변식의 경계다. 화면 조회 모델과 다르다.
Domain Event는 Kafka 메시지다Domain Event는 도메인 의미를 가진 사건이고, Kafka는 전달 수단 중 하나다.
Repository는 모든 쿼리 모음이다Repository는 Aggregate 저장과 조회의 추상화다. 복잡한 조회는 별도 read model이 더 적합할 수 있다.

DDD가 잘못 적용되면 문서와 코드가 오히려 무거워진다. 긴 복구 런북보다 먼저 봐야 할 신호는 다음이다.

실패 신호의미먼저 의심할 것
Customer, Order, Product 필드가 계속 늘고 여러 팀 변경이 한 파일에 몰린다같은 단어가 여러 의미를 담고 있다Bounded Context 누락
setStatus, updateType 같은 메서드가 많고 업무 동사가 없다도메인 행동이 상태 변경으로 흩어졌다Ubiquitous Language 부재
하나의 Aggregate 저장이 느려지고 row lock 대기가 늘어난다너무 많은 쓰기 패턴을 한 트랜잭션에 묶었다Aggregate 과대 설계
Context 사이 동기 호출 체인이 길다경계는 나눴지만 런타임 장애가 함께 전파된다분산 모놀리스, 이벤트/비동기 경계 검토
shared 패키지가 비즈니스 타입으로 커진다공유 커널이 사실상 공용 도메인 모델이 됐다Shared Kernel 비대화
Domain Service가 대부분의 규칙을 들고 Entity는 getter/setter만 있다객체가 규칙을 보호하지 못한다Anemic Domain Model

진단 순서는 단순하게 잡는다. 먼저 같은 단어가 다른 의미로 쓰이는지 확인하고, 그 다음 불변식이 어느 Aggregate Root에서 보호되는지 본다. 마지막으로 경계 간 통신이 직접 DB 조회나 동기 호출 체인으로 굳어졌는지 확인한다.

9. 모듈러 모놀리스에서 MSA로 가는 경로

섹션 제목: “9. 모듈러 모놀리스에서 MSA로 가는 경로”

DDD는 “처음부터 서비스를 잘게 쪼개라”는 말이 아니다. 경계가 아직 검증되지 않았는데 MSA로 시작하면 네트워크, 배포, 관측, 데이터 정합성 비용이 먼저 생긴다. 실무에서는 모듈러 모놀리스 안에서 Bounded Context를 코드 경계로 먼저 검증한 뒤, 독립 배포가 실제로 필요한 Context만 서비스로 분리하는 흐름이 현실적이다.

[1단계] 일반 모놀리스
src/orders, src/payments, src/shared가 경계 없이 섞임
[2단계] 모듈러 모놀리스
orders Context, payments Context, shipping Context가 모듈로 분리됨
다른 Context의 DB 테이블을 직접 조회하지 않음
포트, 이벤트, ACL로만 연결함
[3단계] MSA
쓰기 부하, 팀 독립 배포, 장애 격리 요구가 확인된 Context만 서비스로 추출

이 경로에서 중요한 판단 기준은 배포 단위가 아니라 변경 이유다. 주문 정책과 결제 승인 정책이 서로 다른 속도로 바뀌고 장애 격리 요구도 다르다면 분리 후보가 된다. 반대로 한 팀이 한 릴리스로 같이 바꾸고 같은 트랜잭션 일관성이 중요하다면 물리적으로 나누는 것이 오히려 비용일 수 있다.

AB차이점
EntityValue ObjectEntity는 ID로 식별하고 생명주기를 가진다. Value Object는 값으로 동일성을 판단하고 보통 불변이다.
AggregateEntityAggregate는 일관성을 지켜야 하는 객체 묶음이고, Entity는 그 안팎에 있을 수 있는 식별 가능한 객체다.
Aggregate Root모든 EntityRoot만 외부 접근 진입점이다. 내부 Entity를 직접 수정하지 않는다.
Bounded Context마이크로서비스Bounded Context는 언어와 모델의 경계, 마이크로서비스는 배포 경계다.
Ubiquitous Language도메인 용어 사전Ubiquitous Language는 코드와 대화에 동시에 적용되는 언어다. 용어집은 보조 도구다.
Domain Event일반 메시지Domain Event는 도메인 의미가 있는 과거형 사건이고, 메시지 브로커는 전달 수단이다.
Domain ServiceApplication ServiceDomain Service는 특정 Entity에 넣기 어려운 도메인 규칙, Application Service는 Use Case 흐름 조율이다.

DDD Basics 체크

  • 공유 Order/Product 모델이 시간이 지나며 왜 변경 충돌을 만드는지 설명할 수 있는가?
  • Bounded Context를 마이크로서비스가 아니라 언어와 모델의 경계로 설명할 수 있는가?
  • Ubiquitous Language가 코드 메서드 이름과 이벤트 이름에 어떻게 반영되는지 예를 들 수 있는가?
  • Entity, Value Object, Aggregate, Aggregate Root, Domain Event, Invariant를 각각 한 문장으로 구분할 수 있는가?
  • Order Aggregate가 확정 이후 상품 추가 금지 같은 불변식을 어떻게 보호하는지 설명할 수 있는가?
  • 너무 큰 Aggregate가 lock 경쟁과 큰 트랜잭션을 만드는 반례를 들 수 있는가?
  • Aggregate를 나눌 때 같은 트랜잭션 불변식, 쓰기 빈도, 팀/유스케이스 변경 이유를 기준으로 판단할 수 있는가?
  1. DDD의 출발점: 복잡한 업무에서 같은 단어가 다른 의미를 갖기 시작하면 공유 모델이 변경 비용을 키운다.
  2. Bounded Context: 모델과 언어가 같은 뜻을 유지하는 경계다. 마이크로서비스보다 먼저 잡아야 하는 개념 경계다.
  3. Ubiquitous Language: 도메인 전문가의 언어가 코드의 클래스, 메서드, 이벤트 이름까지 이어져야 한다.
  4. Aggregate Root: 한 트랜잭션 안에서 지켜야 하는 불변식을 보호하는 진입점이다.
  5. 경계 실패 신호: 거대한 공유 모델, 과대 Aggregate, 동기 호출 체인, 비대한 shared 패키지는 DDD가 깨졌다는 신호다.