DDD 적용기: Aggregate 경계 설계와 Bounded Context 분리로 모놀리스를 정리하는 법
핵심 요약
이 글에서 확인할 내용
모놀리스가 무너지는 순간은 갑자기 오지 않는다 개발자가 운영하던 커머스 플랫폼은 초창기엔 단 하나의 Spring Boot 애플리케이션이었습니다. 주문, 결제, 회원, 상품, 배송이 하나의 코드베이스에 공존했고, 초기에는 그것이 오히려 장점이었습니다. 트랜잭션 하나로 모든 테이블을 원자적으로 묶을 수 있었고, 새 기능을 붙이는 데 네트워크 호출도, 배포 조율도 필요 없었습니다.
- 1. DDD 전략적 설계와 전술적 설계
- 2. 유비쿼터스 언어: 코드와 대화가 같은 단어를 쓸 때
- 3. 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가 강조하듯, "전략 없는 전술은 전쟁에서 이기지 못한다."
이 순서가 뒤집히면 어떤 일이 벌어지는지는 실무에서 자주 목격합니다. 전술적 패턴부터 도입한 팀은 Aggregate와 Value Object를 성실하게 만들지만, 그것들을 담을 Bounded Context가 없기 때문에 결국 한 덩어리 안에서 잘 정리된 스파게티를 만들게 됩니다. 클래스 이름은 DDD 교과서처럼 보이는데 의존성 그래프는 여전히 모든 것이 모든 것을 참조합니다. 반대로 전략적 설계를 먼저 하면, 아직 Aggregate를 한 줄도 짜지 않았더라도 "결제 팀은 주문 팀의 내부 상태를 직접 읽지 않는다"는 규칙이 먼저 서고, 그 규칙이 이후의 모든 전술적 선택을 제약합니다. 우리가 강조하고 싶은 것은, 전략적 설계는 다이어그램을 그리는 작업이 아니라 팀 간 대화의 규칙과 코드의 의존 방향을 미리 합의하는 정치적 작업에 가깝다는 점입니다. 이 합의가 없으면 아무리 정교한 전술도 오래 버티지 못합니다.
또 한 가지 중요한 구분은 하위 도메인(Subdomain)에도 등급이 있다는 사실입니다. 경쟁 우위의 원천이 되는 핵심 도메인(Core Domain), 필요하지만 차별화 요소는 아닌 지원 도메인(Supporting Domain), 그리고 이미 시장에 성숙한 솔루션이 있는 일반 도메인(Generic Domain)을 구분해야 합니다. 커머스라면 가격·프로모션 엔진이 핵심 도메인일 수 있고, 알림 발송은 지원 도메인, 회계 원장이나 인증은 일반 도메인일 가능성이 큽니다. 이 등급 구분이 중요한 이유는 투자 배분을 결정하기 때문입니다. 핵심 도메인에는 가장 좋은 인력과 가장 정교한 모델링을 투입하고, 일반 도메인은 직접 짜기보다 검증된 라이브러리나 SaaS를 붙이는 편이 낫습니다. 모든 도메인을 똑같이 공들여 모델링하려는 시도가 바로 첫 번째 과설계입니다.
2. 유비쿼터스 언어: 코드와 대화가 같은 단어를 쓸 때
전략적 설계의 출발점은 다이어그램이 아니라 언어입니다. 유비쿼터스 언어(Ubiquitous Language)는 도메인 전문가와 개발자가 같은 개념을 같은 단어로 부르기로 합의한, 그 Bounded Context 안에서만 통용되는 공용어입니다. 여기서 핵심은 "번역하지 않는다"는 원칙입니다. 기획 문서에는 "판매 확정"이라고 적혀 있는데 코드에는 orderComplete, DB 컬럼에는 is_settled, 회의에서는 "정산 처리"라고 부른다면, 그 네 단어 사이의 미묘한 차이를 아는 사람은 아무도 없고 결국 각자 다른 것을 상상하며 일하게 됩니다. 우리가 겪은 대부분의 요구사항 오해는 새로운 기술이 아니라 이렇게 미끄러진 단어에서 시작되었습니다.
유비쿼터스 언어가 실제로 작동하려면 세 곳에서 동시에 같은 이름이 쓰여야 합니다. 도메인 전문가와의 대화, 코드의 클래스·메서드 이름, 그리고 데이터 모델입니다. 도메인 메서드 이름을 updateStatus(2) 같은 기술 용어가 아니라 place(), cancel(), refund()처럼 비즈니스가 실제로 쓰는 동사로 짓는 이유가 여기에 있습니다. 코드를 소리 내어 읽었을 때 도메인 전문가가 고개를 끄덕일 수 있다면 그 언어는 살아 있는 것이고, "그건 우리 쪽 용어가 아닌데요"라는 반응이 나온다면 모델이 현실과 어긋나기 시작했다는 신호입니다.
가장 자주 하는 오해는 유비쿼터스 언어를 전사 표준 용어집으로 착각하는 것입니다. 오히려 반대입니다. 유비쿼터스 언어는 Bounded Context 안에서만 일관될 것을 요구하며, 컨텍스트가 다르면 같은 단어가 다른 뜻을 가지는 것을 자연스럽게 허용합니다. 뒤에서 보겠지만 '고객'이라는 단어는 컨텍스트마다 다른 존재이고, 이를 억지로 하나의 전사 표준 '고객' 정의로 통일하려는 시도가 바로 경계를 무너뜨리는 원인이 됩니다.
3. 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에서는 "세금계산서 발행 대상"입니다. 같은 사람을 가리키지만, 각 컨텍스트가 관심을 두는 속성과 행위는 전혀 다릅니다. 마케팅은 이 고객이 최근 무엇을 클릭했는지에 관심이 있고, 주문은 이 고객의 기본 배송지가 무엇인지에 관심이 있으며, 정산은 사업자등록번호와 세금 유형에만 관심이 있습니다. 이 세 관심사를 하나의 거대한 Customer 클래스에 전부 욱여넣으면, 어느 팀도 그 클래스를 마음 놓고 바꿀 수 없게 됩니다. 필드 40개짜리 Customer는 세 팀의 요구가 충돌하는 전쟁터가 됩니다.
경계를 찾는 실무 기법은 다음과 같습니다.
이벤트 스토밍: 도메인에서 일어나는 사건(주문됨, 결제 승인됨, 배송 시작됨)을 시간 순으로 벽에 붙여 나열하고, 사건의 흐름이 끊기거나 책임 주체가 바뀌는 지점에서 핫스팟을 찾습니다. 이벤트가 자연스럽게 뭉치는 구간이 곧 컨텍스트 후보입니다.
언어 불일치 추적: 같은 단어를 다른 의미로 사용하는 순간이 경계 후보입니다. 회의에서 "그 '상품'은 우리가 말하는 '상품'이랑 달라요"라는 말이 나오는 순간, 우리는 방금 경계 하나를 발견한 것입니다.
변경 빈도 분석: 함께 변경되는 개념은 같은 Context입니다. git 로그를 분석해 늘 같은 커밋에 묶여 바뀌는 파일들을 찾으면, 코드가 이미 알고 있는 응집 구조가 드러납니다.
팀 소유권: 하나의 팀이 책임지는 단위가 좋은 Bounded Context 크기입니다. 한 컨텍스트를 이해하고 바꾸는 데 여러 팀의 승인이 필요하다면 경계가 잘못 그어진 것입니다.
경계를 처음부터 완벽하게 그으려 하지 않는 것도 중요합니다. 초기에는 오히려 컨텍스트를 크게 잡고, 운영하면서 마찰이 생기는 지점을 따라 쪼개 나가는 편이 안전합니다. 잘못 합쳐진 것을 나누기는 비교적 쉽지만, 성급하게 나눠 놓은 경계를 되돌리는 비용은 훨씬 큽니다.
4. Context Map과 팀 토폴로지
Bounded Context를 여러 개 그렸다면, 그다음 질문은 "이 컨텍스트들이 서로 어떻게 관계 맺는가"입니다. Context Map은 컨텍스트 사이의 통합 방식과 힘의 방향을 그린 지도입니다.
Upstream / Downstream: 한 Context가 다른 Context에 영향을 주는 방향입니다. Upstream이 모델을 바꾸면 Downstream이 따라 바뀌어야 합니다.
공유 커널(Shared Kernel): 두 Context가 공통 도메인 모델의 일부를 공유합니다. 강력하지만 그만큼 결합이 생기므로, 정말로 같은 팀이 함께 소유할 때만 씁니다.
고객-공급자: Downstream이 Upstream에 요구사항을 제시하고 Upstream이 이를 수용합니다.
순응자(Conformist): Downstream이 Upstream의 모델을 그대로 따릅니다. 협상력이 없을 때의 선택입니다.
충돌 방지 계층(ACL): 번역 계층을 두어 외부의 모델을 자신의 도메인 언어로 변환합니다.
Microsoft Azure Architecture Center의 도메인 분석 가이드는 Context Map을 마이크로서비스 분리 전 필수 단계로 제시합니다.
우리가 실무에서 가장 자주 쓰고 또 가장 권하는 관계는 충돌 방지 계층(ACL)입니다. 외부 결제 대행사나 레거시 회원 시스템처럼 우리가 통제할 수 없는 모델을 우리 도메인 안으로 그대로 들여오면, 그 외부 모델의 이상한 필드 이름과 상태 코드가 우리 코드 전체에 스며듭니다. ACL은 그 경계에 번역기를 세워, 외부가 어떻게 부르든 우리 안에서는 우리 언어만 쓰도록 지킵니다. 외부 API가 바뀌어도 ACL 한 곳만 고치면 되므로, 변경의 폭발 반경을 그 얇은 계층 안에 가둘 수 있습니다.
Context Map이 조직도와 닮아 보인다면 그것은 우연이 아닙니다. 콘웨이의 법칙은 시스템 구조가 그것을 만든 조직의 소통 구조를 닮는다고 말합니다. 그래서 Context Map을 그릴 때는 코드만 볼 것이 아니라 팀의 소통 경로도 함께 봐야 합니다. 컨텍스트 경계와 팀 경계가 어긋나 있으면, 하나의 기능을 위해 매번 세 팀이 회의를 열어야 하는 구조가 굳어집니다. 반대로 잘 정렬되면 각 팀이 자기 컨텍스트를 독립적으로 배포할 수 있고, 이는 뒤에서 다룰 독립 배포 단위 분리의 전제 조건이 됩니다. 이런 팀·경계 정렬의 문제는 프론트엔드에서도 똑같이 반복되며, 마이크로 프론트엔드 심층 분석에서 다루는 독립 배포 전략과 같은 뿌리를 공유합니다.
5. Aggregate 루트 설계 원칙
전략적 설계로 경계를 그었다면, 이제 각 컨텍스트 안으로 들어갑니다. 전술적 설계의 심장은 Aggregate입니다. 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 };
}
}
이 코드에서 가장 중요한 설계 결정은 눈에 잘 띄지 않는 곳에 있습니다. 첫째, Order가 Customer 객체가 아니라 _customerId라는 문자열만 들고 있다는 점입니다. 이것이 세 번째 원칙의 실체입니다. 둘째, 상태를 바꾸는 모든 경로가 addItem, place 같은 도메인 메서드를 통과하며, 그 메서드마다 진입 조건을 검사한다는 점입니다. 외부에서 order.status = 'PAID'처럼 상태를 직접 대입할 방법이 없으므로, 잘못된 상태 전이가 애초에 컴파일 단계에서 막힙니다.
Aggregate를 작게 유지하라는 두 번째 원칙은 실무에서 가장 자주 어기는 규칙입니다. "주문에는 주문 항목이 있고, 항목에는 리뷰가 달리고, 리뷰에는 댓글이 있으니 다 한 Aggregate로 묶자"는 유혹은 강력하지만, 그렇게 만든 거대 Aggregate는 로드할 때마다 수백 개의 자식 객체를 함께 끌고 오고, 동시에 여러 사용자가 서로 다른 자식을 건드릴 때마다 루트 전체에 락 경합을 일으킵니다. 자식 목록의 크기가 사용자 행동에 따라 무한히 늘어날 수 있다면, 그것은 거의 확실하게 별도 Aggregate로 분리해야 한다는 신호입니다.
6. 불변식(Invariant) 보호와 트랜잭션 경계
불변식은 비즈니스 규칙 중에서 "언제나 참이어야 하는 조건"입니다. 불변식 보호는 Aggregate의 핵심 책임이며, 사실 Aggregate라는 개념이 존재하는 이유 자체가 불변식 때문입니다.
주문 도메인의 대표적인 불변식들:
- 총액 일관성: 주문 총액은 항상 항목별 (단가 × 수량)의 합과 같아야 한다.
- 상태 전이 규칙: 취소된 주문은 다시 PLACED 상태가 될 수 없다.
- 결제 금액 제한: 결제 금액은 주문 총액을 초과할 수 없다.
불변식을 Application Service나 Controller에 두면 같은 불변식 검사를 여러 진입점에서 중복 구현해야 합니다. 관리자 페이지에서 항목을 추가하는 경로, 고객 앱에서 추가하는 경로, 배치로 대량 수정하는 경로가 각각 따로 총액을 다시 계산한다면, 언젠가 그중 한 곳이 검증을 빠뜨리고 총액이 틀어진 주문이 만들어집니다. 불변식을 Aggregate 안에 두면 어떤 경로로 들어오든 같은 규칙을 통과하므로, "이 규칙이 지켜지는가"를 걱정할 곳이 코드에서 딱 한 군데로 줄어듭니다.
여기서 DDD의 가장 실용적인 규칙 하나가 도출됩니다. 트랜잭션 경계는 Aggregate 경계와 일치시킨다. 다시 말해, 하나의 트랜잭션에서는 하나의 Aggregate만 변경합니다. 이 규칙이 왜 중요한지는 어긴 경우를 상상하면 분명해집니다. 한 트랜잭션에서 주문 Aggregate와 재고 Aggregate와 포인트 Aggregate를 동시에 잠그고 수정한다면, 세 Aggregate에 걸친 락이 서로를 기다리다 교착에 빠지기 쉽고, 트랜잭션이 길어지면서 락 경합과 타임아웃이 급증합니다. 반대로 한 트랜잭션이 한 Aggregate만 건드리면 락의 범위가 좁고 짧아, 동시성이 훨씬 좋아집니다. 이 규칙을 지키려면 데이터베이스 연결 자체도 짧고 예측 가능하게 유지되어야 하며, 서버리스처럼 연결 수가 급증하는 환경에서는 PostgreSQL 커넥션 풀링 전략과 맞물려 설계해야 합니다.
그렇다면 여러 Aggregate가 함께 바뀌어야 하는 비즈니스 규칙은 어떻게 처리할까요? 답은 "한 트랜잭션에 몰아넣지 않는다"입니다. 주문이 확정되면 재고가 줄어야 한다는 규칙은, 주문 트랜잭션 안에서 재고를 직접 차감하는 대신 주문이 OrderPlaced 이벤트를 발행하고 재고 컨텍스트가 그 이벤트를 받아 자기 트랜잭션으로 차감하는 방식으로 풉니다. 이것이 네 번째 원칙, 즉 경계를 넘는 일관성을 최종 일관성으로 처리한다는 뜻이며, 바로 다음에 이어질 도메인 이벤트의 세계로 우리를 데려갑니다.
7. Value Object vs Entity
Aggregate 안을 채우는 두 종류의 객체가 있습니다.
Entity는 고유 식별자(ID)로 동일성을 판단합니다. 이름이 같은 두 회원은 서로 다른 사람이고, 배송지를 바꿔도 여전히 같은 회원입니다.
Value Object는 속성 전체로 동일성을 판단합니다. 불변(Immutable)이어야 합니다. 5,000원은 그냥 5,000원이며, 어느 5,000원인지 물을 필요가 없습니다.
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의 이점: 불변이기 때문에 공유해도 안전합니다. 동등성 비교가 직관적입니다. 도메인 언어가 코드에 그대로 드러납니다. 그러나 실무에서 Value Object가 진짜로 값을 발휘하는 지점은 잘못된 상태를 표현할 수 없게 만드는 것입니다. 위 Money는 생성자에서 음수를 거부하므로, 시스템 어디에도 음수 금액이라는 값이 존재할 수 없습니다. 통화가 다른 두 금액을 더하려 하면 예외가 나므로, KRW와 USD가 뒤섞인 계산 버그가 원천적으로 차단됩니다. 원시 타입인 number로 금액을 다뤘다면 이 모든 검증을 금액을 다루는 모든 곳에 흩어 놓아야 했을 것입니다. Value Object는 검증을 값 자신에게 위임함으로써, "이 값이 유효한가"라는 질문을 코드 전체에서 지워 줍니다.
실무 팁 하나. Value Object를 언제 쓸지 헷갈린다면, "이 개념에 고유한 정체성이 필요한가"를 물어보면 됩니다. 두 배송지가 도로명·상세주소·우편번호가 모두 같다면 같은 배송지로 취급해도 되는가? 그렇다면 배송지는 Value Object입니다. 반면 두 주문이 모든 속성이 같더라도 서로 다른 주문으로 구별되어야 한다면, 주문은 Entity입니다. 이 질문 하나가 대부분의 모델링 결정을 깔끔하게 갈라 줍니다.

8. 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 인터페이스를 도메인 계층에 두는 이유는 의존성 방향 때문입니다. 도메인이 인프라를 알면 안 됩니다. 인프라가 도메인을 알아야 합니다. 이렇게 방향을 뒤집으면 도메인 모델은 TypeORM이든 Prisma든 순수 파일 저장소든 무엇으로 갈아 끼워도 한 줄도 바뀌지 않습니다. 테스트에서는 인메모리 구현체를 끼워 데이터베이스 없이 도메인 규칙을 검증할 수 있고, 이것이 도메인 로직에 대한 단위 테스트를 빠르고 가볍게 유지하는 비결입니다.
Repository 설계에서 실무자가 반드시 기억할 규칙이 하나 더 있습니다. Repository는 Aggregate 단위로만 만든다. 즉 OrderRepository는 있어도 OrderItemRepository는 만들지 않습니다. 주문 항목은 주문 Aggregate의 내부이므로, 항목을 저장하고 불러오는 일은 언제나 주문을 통해서만 일어나야 합니다. 자식마다 Repository를 두는 순간 외부 코드가 루트를 우회해 자식을 직접 조작할 수 있게 되고, 그러면 Aggregate가 지키려던 불변식이 뒷문으로 새어 나갑니다. 그리고 save가 Optimistic Lock 충돌을 도메인 예외로 번역하는 부분도 눈여겨볼 만합니다. 동시성 충돌은 인프라의 사정이지만, 그것을 상위 계층이 이해할 수 있는 도메인 언어로 바꿔 던짐으로써 재시도 정책을 도메인 관점에서 결정할 수 있게 됩니다.
9. Application Service 경계
Application Service는 도메인 객체들을 조율하는 얇은 계층입니다. 비즈니스 규칙을 직접 구현하지 않습니다.
Application Service의 책임:
- Repository에서 Aggregate를 로드합니다.
- 도메인 메서드를 호출합니다.
- 결과를 Repository에 저장합니다.
- 발생한 Domain Event를 발행합니다.
- 트랜잭션 경계를 관리합니다.
여기서 "얇다"는 표현이 핵심입니다. Application Service에 if 문으로 된 비즈니스 판단이 쌓이기 시작하면, 그것은 도메인 모델이 비어 있다는 증거이자 곧 다룰 빈약한 도메인 모델의 전형적인 시작점입니다. 건강한 Application Service의 메서드는 대개 "불러오고, 시키고, 저장한다"는 세 줄의 골격을 크게 벗어나지 않습니다. 총액이 맞는지, 상태 전이가 유효한지 같은 판단은 전부 Aggregate 안에서 일어나야 하고, Application Service는 그 판단이 실행되도록 무대를 마련하고 트랜잭션과 이벤트 발행의 타이밍만 통제합니다.
CQRS 패턴과 결합하면 Application Service는 Command 처리 전용이 됩니다. 조회는 도메인 모델을 거치지 않고 읽기 전용 모델로 직접 응답하므로, 복잡한 화면을 위해 Aggregate를 억지로 크게 만들 필요가 사라집니다. 두 패턴이 어떻게 협력하는지는 CQRS와 이벤트 소싱 분리 전략에서 자세히 다루고 있습니다.
10. 도메인 이벤트와 최종 일관성
앞에서 우리는 한 트랜잭션에 하나의 Aggregate만 변경한다는 규칙을 세웠습니다. 그러면 "주문이 확정되면 재고가 줄고 적립금이 쌓인다"처럼 여러 Aggregate가 얽힌 규칙은 무엇이 이어 주는가? 그 접착제가 바로 도메인 이벤트(Domain Event)입니다. 도메인 이벤트는 "도메인 안에서 이미 일어난, 비즈니스적으로 의미 있는 사건"을 과거형으로 기록한 사실입니다. OrderPlaced, PaymentApproved, ShipmentStarted처럼 과거형 이름을 쓰는 이유가 여기에 있습니다. 이벤트는 명령이 아니라 이미 확정된 사실의 통보이므로, 그것을 받는 쪽은 거부할 수 없고 다만 반응할 뿐입니다.
이 구조의 이점은 결합을 끊는다는 것입니다. 주문 컨텍스트는 재고 컨텍스트가 존재하는지조차 몰라도 됩니다. 그저 OrderPlaced라는 사실을 세상에 알릴 뿐이고, 그 사실에 관심 있는 컨텍스트가 알아서 구독합니다. 나중에 "주문 확정 시 마케팅 쿠폰도 지급하자"는 요구가 생기면, 주문 코드는 한 줄도 건드리지 않고 새로운 구독자만 추가하면 됩니다. 이렇게 발행-구독으로 컨텍스트를 이으면, 각 컨텍스트가 독립적으로 진화하고 독립적으로 배포될 수 있는 길이 열립니다.
대신 우리는 대가를 치릅니다. 이벤트를 받아 처리하는 데는 시간이 걸리므로, 주문이 확정된 그 순간과 재고가 실제로 줄어든 순간 사이에는 짧은 틈이 존재합니다. 이 틈 동안 시스템은 일시적으로 "주문은 확정됐는데 재고는 아직 그대로"인 상태를 지납니다. 이것이 최종 일관성(Eventual Consistency)입니다. 강한 일관성에 익숙한 팀은 이 틈을 불편해하지만, 실은 현실 세계가 원래 이렇게 돌아갑니다. 매장에서 계산이 끝났다고 재고 장부가 그 찰나에 갱신되는 것은 아니며, 잠시 후 정리되어도 아무 문제가 없습니다. 중요한 것은 이 틈을 감추는 것이 아니라, 이 틈이 존재한다는 사실을 UX와 정책 안에 명시적으로 담는 것입니다. 재고가 음수가 될 수 있는 순간을 어떻게 보정할지, 이벤트가 순간적으로 두 번 도착하면 어떻게 할지를 미리 정해 두어야 합니다.
여기서 실무의 함정이 하나 있습니다. 도메인 로직에서 이벤트를 만들었는데 DB 트랜잭션은 커밋됐지만 이벤트 발행에 실패하면, 두 세계가 어긋납니다. 반대로 이벤트는 나갔는데 트랜잭션이 롤백되면 존재하지 않는 사실이 세상에 퍼집니다. 이 원자성 문제를 푸는 표준 해법이 트랜잭셔널 아웃박스 패턴으로, 이벤트를 같은 트랜잭션 안의 아웃박스 테이블에 함께 기록한 뒤 별도 프로세스가 그것을 안전하게 발행합니다. 자세한 구현은 트랜잭셔널 아웃박스 패턴 가이드에서 다루고, 발행된 이벤트를 여러 팀이 오랫동안 안전하게 소비하려면 이벤트 스키마가 깨지지 않게 진화해야 하므로 Kafka 스키마 레지스트리 운영의 호환성 전략과 함께 설계하는 것이 좋습니다. 이벤트를 받는 쪽은 같은 이벤트가 두 번 도착해도 결과가 한 번만 반영되도록 멱등하게 처리해야 하며, 이 멱등성이 최종 일관성 시스템의 안전벨트입니다.
11. 모놀리스에서 모듈러 모놀리스로: 점진적 분리
모놀리스를 DDD로 정리하는 가장 위험한 방법은 전체를 한 번에 재설계하는 것입니다. "이참에 마이크로서비스로 다 쪼개자"는 빅뱅 재작성은 대개 몇 달간 기능 개발을 멈추게 하고, 그러는 사이 비즈니스 요구는 옛 시스템에 계속 쌓여, 결국 완성되지 못한 새 시스템과 여전히 살아 있는 옛 시스템을 동시에 유지하는 최악의 상태로 끝납니다. 현실적인 접근은 Strangler Fig 패턴, 즉 옛 구조를 한 번에 걷어내지 않고 새 경계로 조금씩 감싸며 죽여 가는 방식입니다.
여기서 우리가 특히 강조하고 싶은 중간 목적지는 마이크로서비스가 아니라 모듈러 모놀리스(Modular Monolith)입니다. 모듈러 모놀리스는 여전히 하나의 배포 단위지만, 내부는 Bounded Context별 모듈로 엄격히 나뉘어 모듈 간에는 공개된 인터페이스로만 소통하는 구조입니다. 이것이 왜 좋은 목적지인가 하면, 마이크로서비스의 이점 대부분(명확한 경계, 독립적 변경, 낮은 결합)을 네트워크 분산의 비용(지연, 부분 실패, 분산 트랜잭션, 운영 복잡도) 없이 먼저 얻을 수 있기 때문입니다. 경계가 코드 안에서 검증된 뒤에야 네트워크로 가르는 것이 안전합니다. 실제로 상당수 조직은 모듈러 모놀리스에서 멈추는 것이 옳은 선택이며, 트래픽과 팀 규모가 그것을 요구하기 전까지 굳이 분산 시스템으로 나아갈 이유가 없습니다.
1단계: 패키지 경계 그리기. 모놀리스 코드베이스 안에서 Bounded Context별 패키지(모듈)를 만들고 코드를 이동합니다. 아직 데이터베이스는 그대로 두고, 오직 코드 배치만 바꿉니다. 이 단계만으로도 "어떤 코드가 어느 컨텍스트에 속하는가"가 눈에 보이기 시작합니다.
2단계: 패키지 간 직접 참조 제거. 모듈 A가 모듈 B의 내부 클래스를 직접 import하던 것을 공개 인터페이스와 도메인 이벤트로 교체합니다. 이 단계에서 아키텍처 테스트(예: ArchUnit 류의 의존성 규칙 검증)를 CI에 넣어, 누군가 경계를 몰래 넘는 참조를 추가하면 빌드가 깨지도록 만드는 것이 핵심입니다. 규칙은 코드로 강제되지 않으면 반드시 침식됩니다.
3단계: 데이터 경계 분리. 가장 어렵고 가장 미루기 쉬운 단계입니다. 지금까지 모듈들이 같은 테이블을 조인해서 쓰고 있었다면, 그 조인을 끊고 각 컨텍스트가 자기 데이터만 소유하도록 스키마를 나눠야 합니다. 컨텍스트 간에 필요한 데이터는 조인이 아니라 이벤트로 복제하거나 API로 조회합니다. 이 과정에서 트랜잭셔널 아웃박스 패턴이 유용합니다.
4단계: 독립 배포 단위 분리. 여기까지 와서 정말로 필요하다고 판단되는 모듈만 별도 서비스로 떼어냅니다. 1~3단계 없이 4단계로 바로 가면 분산 모놀리스가 됩니다. 즉 서비스는 여럿인데 여전히 서로의 DB를 공유하고 배포는 함께 나가야 하는, 네트워크 지연만 추가된 모놀리스 말입니다. 이 순서를 지키는 일 자체를 점진적 배포·롤백 관점에서 관리하려면 Feature Flag와 점진적 배포 운영법의 Dark Launch·Kill Switch 기법이 큰 도움이 됩니다.
12. 안티패턴과 과설계 함정
Martin Fowler는 빈약한 도메인 모델을 안티패턴으로 명시했습니다. 데이터만 담은 객체와 그 데이터를 주무르는 거대한 Service가 분리된 구조는, 겉으로는 계층이 깔끔해 보이지만 실제로는 절차적 코드에 객체의 탈을 씌운 것에 불과합니다.
| 안티패턴 | 증상 | 해결 방향 |
|---|---|---|
| 빈약한 도메인 모델 | Service 클래스 수백 줄, 도메인 객체는 DTO 수준 | 비즈니스 메서드를 도메인 객체로 이동 |
| 거대 Aggregate | 로드 시간 수백ms, 동시성 충돌 빈발 | 경계 재설정, ID 참조로 전환 |
| Context 오염 | 여러 Context가 같은 Entity 공유 | ACL 도입 |
| 서비스 간 직접 DB 참조 | 같은 DB 테이블 공유 | 이벤트 기반 동기화로 전환 |
거대 Aggregate의 가장 확실한 신호는 Optimistic Lock 충돌 빈도가 높아지는 것입니다. 서로 다른 사용자가 논리적으로 무관한 작업을 하는데도 자꾸 충돌한다면, 그것은 하나의 Aggregate가 너무 많은 것을 끌어안고 있어 서로의 발을 밟고 있다는 뜻입니다.
그런데 실무에서 DDD가 실패하는 더 흔한 원인은 안티패턴이 아니라 과설계입니다. DDD는 강력한 만큼 비용이 큰 방법론이고, 그 비용을 감당할 이유가 없는 곳에 적용하면 순수한 손해가 됩니다. 대표적인 과설계 함정은 다음과 같습니다.
첫째, 모든 곳에 Bounded Context를 나누는 것입니다. 도메인이 단순하고 규칙이 몇 개 없는 CRUD 성격의 기능에까지 Aggregate, Value Object, Repository, ACL을 총동원하면, 원래 다섯 줄이면 되던 저장 로직이 다섯 개 파일로 흩어집니다. 이런 일반 도메인이나 지원 도메인은 그냥 단순한 CRUD로 두는 편이 정직합니다. DDD의 무거운 도구는 규칙이 복잡하게 얽힌 핵심 도메인을 위해 아껴 두어야 합니다.
둘째, 컨텍스트를 실제 조직보다 잘게 나누는 것입니다. 세 명짜리 팀이 열 개의 마이크로서비스를 운영하면, 하나의 기능 변경이 여러 저장소와 여러 배포에 걸쳐 흩어지고 개발 속도는 오히려 느려집니다. 경계의 수는 감당할 수 있는 팀의 수와 균형을 이뤄야 합니다.
셋째, 필요하지 않은데 최종 일관성을 도입하는 것입니다. 한 트랜잭션 안에서 강한 일관성으로 간단히 끝낼 수 있는 일을, 굳이 이벤트로 쪼개 최종 일관성으로 만들면 순서 보장, 멱등 처리, 보정 로직이라는 추가 복잡도를 스스로 짊어지게 됩니다. 최종 일관성은 진짜로 Aggregate나 컨텍스트 경계를 넘어야 할 때만 씁니다.
넷째, 이론을 위한 순수성 집착입니다. Value Object를 위해 원시 타입을 전부 감싸고, 모든 이벤트를 이벤트 소싱으로 저장하고, 완벽한 헥사고날 계층을 세우는 데 몇 주를 쓰지만, 정작 비즈니스가 원한 것은 다음 주에 나갈 기능이었던 경우가 적지 않습니다. DDD의 목적은 아름다운 모델이 아니라 변경하기 쉬운 소프트웨어이며, 그 목적에 기여하지 않는 정교함은 그 자체로 부채입니다.
자주 묻는 질문
작은 프로젝트에도 DDD를 써야 하나요? 전략적 사고(경계를 의식하고 유비쿼터스 언어를 맞추는 것)는 규모와 무관하게 이득이지만, 전술적 패턴 전체를 동원하는 것은 도메인이 복잡할 때만 값을 합니다. 규칙이 단순한 곳에 무거운 전술을 얹는 것이 앞서 말한 과설계입니다.
마이크로서비스로 가야 DDD를 제대로 하는 건가요? 아닙니다. 이 둘은 다른 층위의 문제입니다. 모듈러 모놀리스 하나 안에서도 여러 Bounded Context를 온전히 실현할 수 있고, 오히려 그것이 경계를 검증하기에 더 안전한 무대입니다. 분산은 경계가 검증된 뒤에 필요할 때만 선택하는 배포 전략일 뿐입니다.
Aggregate가 너무 커지면 어떻게 아나요? 로드 시간이 길어지고, 무관한 작업 사이에 Optimistic Lock 충돌이 잦아지며, 자식 컬렉션의 크기가 사용자 행동에 따라 무한히 늘어난다면 분리 신호입니다. 그럴 때는 자식을 별도 Aggregate로 떼고 참조를 ID로 바꾼 뒤, 경계를 넘는 규칙을 도메인 이벤트로 잇습니다.
마치며
- 전략 → 전술 순서 준수: Bounded Context와 Context Map을 먼저 정의한 뒤 Aggregate를 적용한다.
- 유비쿼터스 언어를 코드·대화·데이터에 일관되게 흐르게 한다.
- Aggregate 경계는 불변식 단위이자 트랜잭션 경계다. 한 트랜잭션에 하나의 Aggregate만 바꾼다.
- Aggregate 간 참조는 ID로, 경계를 넘는 일관성은 도메인 이벤트와 최종 일관성으로 잇는다.
- Setter를 제거하고 도메인 메서드로 교체한다.
- Repository 인터페이스는 도메인 계층에, Aggregate 단위로만 둔다.
- 모듈러 모놀리스를 경유하는 점진적 분리 원칙 준수: 패키지 경계 → 데이터 경계 → 서비스 분리 순서를 지킨다.
- 과설계를 경계한다. DDD의 목적은 아름다운 모델이 아니라 변경하기 쉬운 소프트웨어다.