DDD 실전: Aggregate 경계 설계와 Bounded Context 분리로 모놀리스를 정리하는 법

모놀리스가 무너지는 순간은 갑자기 오지 않는다
우리가 운영하던 커머스 플랫폼은 초창기엔 단 하나의 Spring Boot 애플리케이션이었습니다. 주문, 결제, 회원, 상품, 배송이 하나의 코드베이스에 공존했고, 초기에는 그것이 오히려 장점이었습니다. 그러다 팀이 세 배로 늘고 하루 배포 횟수가 두 자릿수를 넘기 시작하면서 상황이 달라졌습니다.
이 상황의 진짜 원인은 기술 부채가 아니었습니다. 도메인 경계가 코드에 반영되지 않은 것이 문제였습니다. 도메인 주도 설계(DDD)는 이 문제를 소프트웨어 구조 차원에서 풀어내는 방법론입니다.
1. DDD 전략적 설계와 전술적 설계
**전략적 설계(Strategic Design)**는 큰 그림을 그립니다. 비즈니스를 어떤 하위 도메인으로 나눌 것인가, 각 하위 도메인에 어떤 경계를 그을 것인가(Bounded Context).
**전술적 설계(Tactical Design)**는 각 Bounded Context 안을 어떻게 구현할 것인가를 다룹니다. Entity, Value Object, Aggregate, Domain Service, Repository.
| 관심사 | 전략적 설계 | 전술적 설계 |
|---|---|---|
| 핵심 질문 | 어디를 나눌 것인가 | 어떻게 구현할 것인가 |
| 주요 개념 | Bounded Context, Subdomain | Entity, Aggregate, Value Object |
| 산출물 | Context Map | 코드, 클래스 다이어그램 |
| 선후 관계 | 먼저 결정 | 이후 구현 |
Domain Language에서 Eric Evans가 강조하듯, "전략 없는 전술은 전쟁에서 이기지 못한다."
2. Bounded Context 식별 기법
Bounded Context는 특정 도메인 모델이 일관된 의미를 가지는 경계입니다. Martin Fowler는 이렇게 설명합니다. "A Bounded Context is a defined part of software where particular terms, definitions, and rules apply consistently."
예를 들어 '고객'이라는 단어는 마케팅 Context에서는 "세그먼트 분류 대상"이고, 주문 Context에서는 "배송지와 결제 수단을 가진 주문 주체"이며, 정산 Context에서는 "세금계산서 발행 대상"입니다.
이벤트 스토밍: 도메인 이벤트를 나열하고 핫스팟을 찾습니다.
언어 불일치 추적: 같은 단어를 다른 의미로 사용하는 순간이 경계 후보입니다.
변경 빈도 분석: 함께 변경되는 개념은 같은 Context입니다.
팀 소유권: 하나의 팀이 책임지는 단위가 좋은 Bounded Context 크기입니다.
3. Context Map과 팀 토폴로지
Upstream / Downstream: 한 Context가 다른 Context에 영향을 주는 방향입니다.
공유 커널(Shared Kernel): 두 Context가 공통 도메인 모델의 일부를 공유합니다.
고객-공급자: Downstream이 Upstream에 요구사항을 제시하고 Upstream이 이를 수용합니다.
순응자(Conformist): Downstream이 Upstream의 모델을 그대로 따릅니다.
충돌 방지 계층(ACL): 번역 계층을 두어 자신의 도메인 언어로 변환합니다.
Microsoft Azure Architecture Center의 도메인 분석 가이드는 Context Map을 마이크로서비스 분리 전 필수 단계로 제시합니다.
4. Aggregate 루트 설계 원칙
Aggregate는 하나의 트랜잭션으로 일관되게 변경되어야 하는 객체 그룹입니다.
- 불변식이 함께 보호되어야 하는 객체를 묶는다.
- 가능하면 작게 유지한다.
- Aggregate 간 참조는 ID로만 한다.
- Aggregate 경계를 넘는 일관성은 Eventually Consistent로 처리한다.
type OrderStatus = 'DRAFT' | 'PLACED' | 'PAID' | 'SHIPPED' | 'CANCELLED';
interface Money {
readonly amount: number;
readonly currency: 'KRW' | 'USD';
}
export class Order {
private readonly _id: string;
private _status: OrderStatus;
private _items: OrderItem[];
private _totalAmount: Money;
private readonly _customerId: string; // ID 참조만 보유
private readonly _pendingEvents: DomainEvent[] = [];
static create(customerId: string, currency: 'KRW' | 'USD'): Order {
const id = randomUUID();
const order = new Order({ id, customerId, status: 'DRAFT', items: [], totalAmount: { amount: 0, currency } });
order._pendingEvents.push({
type: 'OrderCreated',
aggregateId: id,
occurredAt: new Date().toISOString(),
payload: { customerId, currency },
});
return order;
}
addItem(params: { productId: string; productName: string; unitPrice: Money; quantity: number }): void {
if (this._status !== 'DRAFT') {
throw new OrderDomainError(`주문 상태가 ${this._status}인 경우 항목을 추가할 수 없습니다.`);
}
if (params.quantity <= 0) {
throw new OrderDomainError('수량은 1개 이상이어야 합니다.');
}
this._items = [...this._items, { itemId: randomUUID(), ...params }];
this._recalculateTotalAmount();
}
place(): void {
if (this._status !== 'DRAFT') throw new OrderDomainError(`이미 ${this._status} 상태입니다.`);
if (this._items.length === 0) throw new OrderDomainError('주문 항목이 없습니다.');
this._status = 'PLACED';
this._pendingEvents.push({
type: 'OrderPlaced',
aggregateId: this._id,
occurredAt: new Date().toISOString(),
payload: { customerId: this._customerId, totalAmount: this._totalAmount },
});
}
private _recalculateTotalAmount(): void {
const total = this._items.reduce(
(sum, item) => sum + item.unitPrice.amount * item.quantity, 0,
);
this._totalAmount = { amount: total, currency: this._totalAmount.currency };
}
}
5. 불변식(Invariant) 보호
불변식은 비즈니스 규칙 중에서 "언제나 참이어야 하는 조건"입니다. 불변식 보호는 Aggregate의 핵심 책임입니다.
주문 도메인의 대표적인 불변식들:
- 총액 일관성: 주문 총액은 항상 항목별 (단가 × 수량)의 합과 같아야 한다.
- 상태 전이 규칙: 취소된 주문은 다시 PLACED 상태가 될 수 없다.
- 결제 금액 제한: 결제 금액은 주문 총액을 초과할 수 없다.
불변식을 Application Service나 Controller에 두면 같은 불변식 검사를 여러 진입점에서 중복 구현해야 합니다.
6. Value Object vs Entity
Entity는 고유 식별자(ID)로 동일성을 판단합니다.
Value Object는 속성 전체로 동일성을 판단합니다. 불변(Immutable)이어야 합니다.
export class Money {
private constructor(
private readonly _amount: number,
private readonly _currency: 'KRW' | 'USD',
) {
if (_amount < 0) throw new Error('금액은 0 이상이어야 합니다.');
}
static of(amount: number, currency: 'KRW' | 'USD'): Money {
return new Money(amount, currency);
}
add(other: Money): Money {
this.assertSameCurrency(other);
return new Money(this._amount + other._amount, this._currency);
}
multiply(factor: number): Money {
if (factor < 0) throw new Error('음수 배율은 허용되지 않습니다.');
return new Money(Math.round(this._amount * factor), this._currency);
}
equals(other: Money): boolean {
return this._amount === other._amount && this._currency === other._currency;
}
private assertSameCurrency(other: Money): void {
if (this._currency !== other._currency) {
throw new Error(`통화가 다릅니다.`);
}
}
}
Value Object의 이점: 불변이기 때문에 공유해도 안전합니다. 동등성 비교가 직관적입니다. 도메인 언어가 코드에 그대로 드러납니다.
7. Repository 패턴 구현
// 도메인 계층: Repository 인터페이스 (영속화 기술 독립)
export interface OrderRepository {
findById(orderId: string): Promise<Order | null>;
findByCustomerId(customerId: string): Promise<Order[]>;
save(order: Order): Promise<void>;
exists(orderId: string): Promise<boolean>;
}
export class ConcurrencyConflictError extends Error {
constructor(public readonly aggregateId: string) {
super(`동시성 충돌: 주문 ${aggregateId}`);
this.name = 'ConcurrencyConflictError';
}
}
// 인프라 계층: TypeORM 기반 구현체
import { DataSource, Repository } from 'typeorm';
export class TypeOrmOrderRepository implements OrderRepository {
constructor(private readonly dataSource: DataSource) {}
async findById(orderId: string): Promise<Order | null> {
const entity = await this.dataSource.getRepository(OrderEntity).findOne({
where: { id: orderId },
relations: ['items'],
});
if (!entity) return null;
return OrderMapper.toDomain(entity);
}
async save(order: Order): Promise<void> {
const entity = OrderMapper.toEntity(order);
try {
await this.dataSource.getRepository(OrderEntity).save(entity);
} catch (error) {
if (isOptimisticLockError(error)) {
throw new ConcurrencyConflictError(order.id);
}
throw error;
}
}
}
Repository 인터페이스를 도메인 계층에 두는 이유는 의존성 방향 때문입니다. 도메인이 인프라를 알면 안 됩니다. 인프라가 도메인을 알아야 합니다.
8. Application Service 경계
Application Service는 도메인 객체들을 조율하는 얇은 계층입니다. 비즈니스 규칙을 직접 구현하지 않습니다.
Application Service의 책임:
- Repository에서 Aggregate를 로드합니다.
- 도메인 메서드를 호출합니다.
- 결과를 Repository에 저장합니다.
- 발생한 Domain Event를 발행합니다.
- 트랜잭션 경계를 관리합니다.
CQRS 패턴과 결합하면 Application Service는 Command 처리 전용이 됩니다. 두 패턴이 어떻게 협력하는지는 CQRS와 이벤트 소싱 실전 분리 전략에서 자세히 다루고 있습니다.
9. 모놀리스에서 점진적 Context 분리
모놀리스를 DDD로 정리하는 가장 위험한 방법은 전체를 한 번에 재설계하는 것입니다. 현실적인 접근은 Strangler Fig 패턴입니다.
1단계: 패키지 경계 그리기. 모놀리스 코드베이스 안에서 Bounded Context별 패키지를 만들고 코드를 이동합니다.
2단계: 패키지 간 직접 참조 제거. 인터페이스와 이벤트로 교체합니다.
3단계: 데이터 경계 분리. 가장 어려운 단계입니다. 트랜잭셔널 아웃박스 패턴이 유용합니다. 트랜잭셔널 아웃박스 패턴 실전 가이드에서 다루고 있습니다.
4단계: 독립 배포 단위 분리. 1~3단계 없이 4단계로 바로 가면 분산 모놀리스가 됩니다.
10. 안티패턴
Martin Fowler는 빈약한 도메인 모델을 안티패턴으로 명시했습니다.
| 안티패턴 | 증상 | 해결 방향 |
|---|---|---|
| 빈약한 도메인 모델 | Service 클래스 수백 줄, 도메인 객체는 DTO 수준 | 비즈니스 메서드를 도메인 객체로 이동 |
| 거대 Aggregate | 로드 시간 수백ms, 동시성 충돌 빈발 | 경계 재설정, ID 참조로 전환 |
| Context 오염 | 여러 Context가 같은 Entity 공유 | ACL 도입 |
| 서비스 간 직접 DB 참조 | 같은 DB 테이블 공유 | 이벤트 기반 동기화로 전환 |
거대 Aggregate의 가장 확실한 신호는 Optimistic Lock 충돌 빈도가 높아지는 것입니다.
결론
- 전략 → 전술 순서 준수: Bounded Context와 Context Map을 먼저 정의한 뒤 Aggregate를 적용한다.
- Aggregate 경계는 불변식 단위.
- Setter를 제거하고 도메인 메서드로 교체.
- Repository 인터페이스는 도메인 계층에.
- 점진적 분리 원칙 준수: 패키지 경계 → 데이터 경계 → 서비스 분리 순서를 지킨다.