DDD 전술 설계 (Tactical Design)
2026.10.02 · 32분
1. 전술 설계란?
전술 설계는 전략 설계로 나눈 하나의 Bounded Context 안에서, 도메인 규칙을 코드로 어떻게 표현할지 정하는 패턴 모음입니다.
전략 설계가 "도시를 구역으로 나누기"라면, 전술 설계는 "그 구역 안에 건물을 짓는 표준 공법"입니다.
| 빌딩 블록 | 한 줄 설명 |
|---|---|
| Entity | 고유 ID로 구별되고, 시간이 지나며 상태가 바뀌는 객체 |
| Value Object | 값 자체로 구별되고, 바뀌지 않는 객체 |
| Aggregate | 함께 일관성을 지켜야 하는 객체 묶음 (→ Aggregate (애그리거트)) |
| Repository | Aggregate를 저장하고 꺼내 오는 창구 |
| Domain Service | 특정 객체에 넣기 어색한 도메인 규칙 |
| Factory | 복잡한 객체 생성 과정을 감추는 장치 |
| Domain Event | 도메인에서 일어난 의미 있는 사건 |
flowchart LR
AS[Application Service] -->|조회/저장| R[Repository]
AS -->|행동 호출| AR
AS -->|생성| F[Factory]
F -->|만든다| AR
R -->|저장/복원| AR
subgraph AG[Aggregate]
AR[Aggregate Root<br/>Entity] --> E[Entity]
AR --> VO1[Value Object]
E --> VO2[Value Object]
end
AS -->|여러 Aggregate에 걸친 규칙| DS[Domain Service]
AR -.->|발생| DE[Domain Event]2. Entity (엔티티)
2-1. 정의
Entity는 고유한 식별자(ID)로 구별되는 객체입니다. 속성이 바뀌어도 ID가 같으면 같은 객체입니다.
사람으로 비유하면, 이름을 바꾸고 이사를 가도 주민등록번호가 같으면 같은 사람입니다.
주문 #1001 (상태: 결제 대기, 금액: 30,000원)
│ 결제 완료
▼
주문 #1001 (상태: 결제 완료, 금액: 30,000원)
│ 상품 하나 부분 취소
▼
주문 #1001 (상태: 부분 취소, 금액: 20,000원)
→ 속성은 계속 바뀌었지만 같은 "주문 #1001"이다2-2. Entity의 특징
| 특징 | 설명 |
|---|---|
| 식별자 | 생성 시점에 ID를 갖고, 평생 바뀌지 않는다 |
| 생명주기 | 생성 → 상태 변경 → 종료(삭제, 보관)를 거친다 |
| 동등성 | ID가 같으면 같은 객체다 |
| 행동 | 상태를 바꾸는 메서드가 규칙을 함께 지킨다 |
2-3. 구현
// ordering/domain/order-id.ts
export class OrderId {
private constructor(readonly value: string) {}
static generate(): OrderId {
return new OrderId(randomUUID());
}
static of(value: string): OrderId {
return new OrderId(value);
}
equals(other: OrderId): boolean {
return this.value === other.value;
}
}// ordering/domain/order.ts
export class Order {
private constructor(
readonly id: OrderId,
readonly customerId: CustomerId,
private status: OrderStatus,
private readonly lines: OrderLine[],
) {}
// 상태 변경은 업무 용어로 된 메서드로만 한다
pay(): void {
if (this.status !== OrderStatus.PENDING) {
throw new InvalidOrderStateException(this.id, this.status, 'pay');
}
this.status = OrderStatus.PAID;
}
cancel(): void {
if (this.status === OrderStatus.SHIPPED || this.status === OrderStatus.DELIVERED) {
throw new CannotCancelShippedOrderException(this.id);
}
this.status = OrderStatus.CANCELLED;
}
equals(other: Order): boolean {
return this.id.equals(other.id);
}
}3. Value Object (값 객체)
3-1. 정의
Value Object는 속성 값 자체로 구별되는 객체입니다. ID가 없고, 값이 같으면 같은 것으로 봅니다. 한번 만들면 바꾸지 않습니다(불변).
지폐로 비유하면, 내 만 원짜리와 친구의 만 원짜리를 바꿔도 아무도 신경 쓰지 않습니다. 금액(값)이 같기 때문입니다. 반면 집(Entity)은 같은 평수라도 주소(ID)가 다르면 다른 집입니다.
| 구분 | Entity | Value Object |
|---|---|---|
| 구별 기준 | ID | 모든 속성 값 |
| 변경 | 상태가 바뀐다 | 바뀌지 않는다. 바꾸려면 새로 만든다 |
| 생명주기 | 있다 | 없다. 소유한 Entity에 딸려 있다 |
| 예 | 주문, 회원, 상품 | 금액, 주소, 이메일, 기간, 수량 |
3-2. 왜 원시 타입 대신 Value Object를 쓸까?
// 원시 타입만 쓴 코드
class Order {
totalAmount: number; // 원? 달러? 음수도 되나?
currency: string; // 'KRW', 'krw', '원' 다 들어갈 수 있다
shippingZip: string; // 형식 검증은 어디서?
}
// 금액 계산이 곳곳에 흩어진다
const total = order.totalAmount + shippingFee; // 통화가 다르면?이런 상태를 Primitive Obsession(원시 타입 집착)이라고 합니다. 값에 딸린 규칙(음수 불가, 통화 일치, 형식 검증)이 사방에 흩어집니다.
4. Domain Service (도메인 서비스)
4-1. 정의
Domain Service는 특정 Entity나 Value Object에 넣기 어색한 도메인 규칙을 담는 객체입니다. 보통 여러 Aggregate를 함께 봐야 하는 규칙이 여기에 해당합니다.
예를 들어 "VIP 회원은 주문 금액의 10%를 할인하되, 쿠폰과 중복 적용하지 않고 더 큰 할인 하나만 적용한다"는 규칙은 Order, Customer, Coupon을 모두 봐야 합니다. 이걸 Order 안에 넣으면 Order가 회원 등급과 쿠폰 정책까지 알아야 해서 어색합니다.
// ordering/domain/discount-policy.service.ts
// 순수 도메인 로직: 프레임워크 의존 없음, 상태 없음
export class DiscountPolicy {
calculate(order: Order, customer: Customer, coupon: Coupon | null): Money {
const total = order.totalAmount();
const gradeDiscount = customer.isVip() ? total.percent(10) : Money.zero();
const couponDiscount = coupon?.isApplicableTo(order)
? coupon.discountFor(total)
: Money.zero();
// 중복 적용 불가: 더 큰 할인 하나만
return gradeDiscount.isGreaterThanOrEqual(couponDiscount)
? gradeDiscount
: couponDiscount;
}
}4-2. Domain Service와 Application Service 구분
이름이 비슷해서 가장 많이 헷갈리는 부분입니다.
| 구분 | Domain Service | Application Service |
|---|---|---|
| 위치 | domain/ | application/ |
5. Repository (리포지토리)
5-1. 정의
Repository는 Aggregate를 마치 메모리 컬렉션처럼 저장하고 꺼내 오게 해 주는 추상화입니다. 도메인 입장에서는 DB가 MySQL인지, MongoDB인지, 메모리인지 모릅니다.
도메인이 보는 것: orders.save(order) / orders.findById(id)
실제로 일어나는 일: INSERT INTO orders ... / SELECT ... JOIN order_lines ...5-2. 인터페이스는 도메인에, 구현은 인프라에
// ordering/domain/order.repository.ts
export const ORDER_REPOSITORY = Symbol('ORDER_REPOSITORY');
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}// ordering/infrastructure/typeorm-order.repository.ts
@Injectable()
export class TypeOrmOrderRepository implements OrderRepository {
constructor(
@InjectRepository(OrderOrmEntity)
private readonly repo: Repository<OrderOrmEntity>,
) {}
async findById(id: OrderId): Promise<Order | null> {
const row = await this.repo.findOne({
where: { id: id.value },
relations: { lines: true },
});
return row ? OrderMapper.toDomain(row) : null;
}
async save(order: Order): Promise<void> {
await this.repo.save(OrderMapper.toOrm(order));
}
}// ordering/ordering.module.ts
@Module({
providers: [
{ provide: ORDER_REPOSITORY, useClass: TypeOrmOrderRepository },
],
})
export class OrderingModule {}TypeScript 인터페이스는 런타임에 사라지기 때문에, Nest.js에서는 Symbol이나 문자열 토큰으로 주입합니다.
인터페이스가 domain/ 안에 있으므로 의존 방향이 뒤집힙니다. 인프라가 도메인을 따르고, 도메인은 인프라를 모릅니다. 이것이 Hexagonal Architecture의 핵심 아이디어이기도 합니다.
5-3. 도메인 객체와 ORM 엔티티 분리
// ordering/infrastructure/order.orm-entity.ts
@Entity('orders')
export class OrderOrmEntity {
@PrimaryColumn('uuid') id: string;
@Column() customerId: string;
@Column() status: string;
@OneToMany(() => OrderLineOrmEntity, (l) => l.order, { cascade: true })
lines: OrderLineOrmEntity[];
}
// ordering/infrastructure/order.mapper.ts
export class OrderMapper {
static toDomain(row: OrderOrmEntity): Order {
return Order.reconstitute({
id: OrderId.of(row.id),
customerId: CustomerId.of(row.customerId),
status: row.status as OrderStatus,
lines: row.lines.map((l) =>
OrderLine.reconstitute(l.productId, Money.of(l.unitPrice), Quantity.of(l.quantity)),
),
});
}
static toOrm(order: Order): OrderOrmEntity {
const snapshot = order.toSnapshot();
const row = new OrderOrmEntity();
row.id = snapshot.id;
row.customerId = snapshot.customerId;
row.status = snapshot.status;
row.lines = snapshot.lines.map((l) => Object.assign(new OrderLineOrmEntity(), l));
return row;
}
}6. Factory (팩토리)
6-1. 정의
Factory는 복잡한 객체 생성 과정과 생성 시점의 규칙을 감추는 장치입니다. 생성자에 검증과 조립이 많아지면 Factory로 분리합니다.
6-2. 정적 팩토리 메서드
가장 흔한 형태는 Aggregate Root의 정적 메서드입니다.
export class Order extends AggregateRoot {
private constructor(/* ... */) {
super();
}
// 새 주문을 만들 때: 생성 규칙을 검사하고 이벤트를 기록한다
static place(customerId: CustomerId, lines: OrderLineInput[]): Order {
if (lines.length === 0) {
throw new EmptyOrderException();
}
if (lines.length > 50) {
throw new TooManyOrderLinesException(lines.length);
}
const order = new Order(
OrderId.generate(),
customerId,
OrderStatus.PENDING,
lines.map((l) => OrderLine.create(l.productId, l.unitPrice, l.quantity)),
);
order.record(new OrderPlaced(order.id, customerId, order.totalAmount()));
return order;
}
// DB에서 복원할 때: 생성 규칙과 이벤트 없이 그대로 조립한다
static reconstitute(props: OrderProps): Order {
return new Order(props.id, props.customerId, props.status, props.lines);
}
}place와 reconstitute를 나누는 이유는 새로 만드는 것과 저장된 것을 다시 불러오는 것이 다른 일이기 때문입니다. 불러올 때마다 "주문됨" 이벤트가 발생하면 안 됩니다.
6-3. 별도 Factory 클래스
생성에 외부 정보(다른 Aggregate, 정책)가 필요하면 별도 클래스로 만듭니다.
// 장바구니로부터 주문을 만든다: Cart와 상품 가격 정보가 필요하다
export class OrderFactory {
createFromCart(cart: Cart, prices: PriceList): Order {
const lines = cart.items.map((item) => ({
productId: item.productId,
unitPrice: prices.priceOf(item.productId), // 주문 시점 가격을 고정
quantity: item.quantity,
}));
return Order.place(cart.customerId, lines);
}
}7. Domain Event (도메인 이벤트)
7-1. 정의
Domain Event는 도메인에서 일어난, 업무적으로 의미 있는 사건입니다. 이름은 항상 과거형으로 짓습니다. 이미 일어난 일이기 때문입니다.
| 좋은 이름 | 나쁜 이름 | 이유 |
|---|---|---|
OrderPlaced | CreateOrder | 명령이 아니라 사실이다 |
PaymentCompleted | PaymentEvent | 무슨 일이 일어났는지 드러나야 한다 |
OrderCancelled | OrderStatusChanged | 업무적 의미가 있어야 한다 |
7-2. 왜 필요할까?
"주문이 완료되면 재고를 차감하고, 포인트를 적립하고, 알림을 보낸다"를 한 메서드에 쓰면 주문 코드가 재고, 포인트, 알림을 모두 알아야 합니다.
// (X) 주문 유스케이스가 모든 후속 작업을 직접 호출한다
async placeOrder(command) {
const order = Order.place(...);
await this.orders.save(order);
await this.inventoryService.decrease(order.lines); // 재고
await this.pointService.accrue(order.customerId); // 포인트
await this.notificationService.send(order); // 알림
}8. 빌딩 블록 정리
ordering/
├── domain/
│ ├── order.ts # Aggregate Root (Entity)
│ ├── order-line.ts # Entity (Aggregate 내부)
│ ├── order-id.ts # Value Object (ID)
│ ├── money.ts # Value Object
│ ├── quantity.ts # Value Object
│ ├── order.repository.ts # Repository 인터페이스
│ ├── order.factory.ts # Factory
│ ├── discount-policy.service.ts # Domain Service
│ └── events/order-placed.event.ts # Domain Event
├── application/
│ └── place-order.service.ts # Application Service
└── infrastructure/
├── typeorm-order.repository.ts # Repository 구현
├── order.orm-entity.ts # ORM 엔티티
└── order.mapper.ts # 도메인 ↔ ORM 변환| 빌딩 블록 | 핵심 질문 | 체크 포인트 |
|---|---|---|
| Entity | 시간이 지나도 같은 것으로 추적해야 하나? | ID가 있다, setter가 없다 |
| Value Object | 값이 같으면 같은 것인가? | 불변이다, 생성 시 검증한다 |
| Domain Service | 어느 한 객체의 책임이 아닌 규칙인가? | 상태가 없다, 인프라를 모른다 |
| Repository | Aggregate를 어떻게 저장/복원하나? | Aggregate당 하나, 인터페이스는 도메인에 |
| Factory | 생성 과정이 복잡한가? | 생성과 복원을 구분한다 |
| Domain Event |
9. 핵심 정리
전술 설계는 Bounded Context 안의 도메인 규칙을 코드로 표현하는 패턴 모음이다. Entity는 ID로 구별되고 상태가 바뀌며, Value Object는 값으로 구별되고 불변이다. 특정 객체에 넣기 어색한 규칙은 Domain Service에, 작업 순서와 트랜잭션은 Application Service에 둔다. Repository는 인터페이스를 도메인에 두어 의존을 뒤집고, Factory는 생성과 복원을 구분하며, Domain Event는 일어난 사실을 과거형으로 알려 다른 영역과 느슨하게 연결한다.