DDD 이론 4: Bounded Context 심화
대규모 시스템을 분할하는 Bounded Context의 개념, 경계 식별, Context 간 관계와 통신 패턴을 심층적으로 탐구합니다.
1. Bounded Context란 무엇인가?
2. 서브도메인 vs Bounded Context
3. Bounded Context 경계 식별하기
4. Context Mapping 패턴 (9가지)
5. Bounded Context 간 통신 패턴
6. Anti-Corruption Layer 심화
7. Bounded Context와 마이크로서비스
8. 모놀리스에서 Context 분리하기
9. 실제 기업 사례 연구
10. 실습: 호텔 예약 시스템 설계
11. 자주 묻는 질문 (FAQ)
- • Bounded Context의 본질(언어적, 모델, 팀 경계)을 깊이 이해한다
- • 서브도메인과 Bounded Context의 관계를 명확히 구분한다
- • 9가지 Context Mapping 패턴을 이해하고 적용할 수 있다
- • Context 간 동기/비동기 통신 패턴을 선택하고 구현할 수 있다
- • Anti-Corruption Layer를 설계하고 구현할 수 있다
- • Bounded Context와 마이크로서비스의 관계를 이해한다
- • 모놀리스에서 점진적으로 Context를 분리하는 전략을 익힌다
- • 실제 시스템에서 Context 경계를 설계할 수 있다
핵심 서적
- • Eric Evans - Domain-Driven Design (Blue Book, 2003)
- • Vaughn Vernon - Implementing DDD (Red Book, 2013)
- • Sam Newman - Building Microservices (2nd Ed, 2021)
- • Sam Newman - Monolith to Microservices (2019)
2024-2025 참고 자료
- • Nick Tune - Architecture Modernization (2024)
- • Shopify Engineering Blog - Modular Monolith
- • Martin Fowler - bliki articles
- • DDD Crew - Context Mapping patterns
1. Bounded Context란 무엇인가?
"Bounded Context는 특정 모델이 정의되고 적용되는 경계를 명시적으로 설정한다. 경계 내에서 모델은 일관성을 유지하고, 경계 밖의 이슈에 의해 혼란스러워지지 않는다."
— Eric Evans, Domain-Driven Design (Blue Book), Chapter 14
1.1 Bounded Context의 본질
Bounded Context는 DDD에서 가장 중요한 전략적 패턴입니다. Eric Evans는 블루북에서 이를 "모델이 적용되는 명시적 경계"라고 정의했습니다. 하지만 20년간의 실무 경험을 통해 이 개념은 단순한 "경계" 이상의 의미를 갖게 되었습니다.
Bounded Context의 세 가지 본질
🗣️ 언어적 경계
유비쿼터스 언어가 일관되게 적용되는 범위. 같은 용어가 같은 의미로 사용되는 영역.
🏛️ 모델 경계
도메인 모델이 일관성을 유지하는 범위. 엔티티, 값 객체, 애그리거트가 정의되는 영역.
👥 팀 경계
하나의 팀이 소유하고 책임지는 범위. Conway의 법칙이 적용되는 조직적 경계.
1.2 언어적 경계로서의 Bounded Context
Bounded Context의 가장 근본적인 역할은 언어적 경계입니다. 같은 용어라도 컨텍스트에 따라 완전히 다른 의미를 가질 수 있습니다.
"Account"의 다양한 의미
🔐 인증 Context
로그인 자격증명, 비밀번호, 2FA 설정
💰 뱅킹 Context
잔액, 거래내역, 이자율, 계좌번호
📊 CRM Context
고객사, 담당자, 계약, 영업 기회
📱 소셜 Context
프로필, 팔로워, 게시물, 설정
핵심: 이 네 가지 "Account"를 하나의 모델로 통합하려 하면 거대하고 복잡한 God Object가 됩니다. 각각의 Context에서 독립적인 모델로 유지해야 합니다.
"하나의 팀이 하나의 Bounded Context를 소유해야 한다. 여러 팀이 하나의 컨텍스트를 공유하면 모델의 일관성이 깨진다."
— Vaughn Vernon, Implementing Domain-Driven Design (Red Book)
1.3 E-Commerce에서의 Bounded Context 예시
"Product"의 컨텍스트별 모델
📦 카탈로그 Context
Product {
id: ProductId
name: string
description: string
images: Image[]
category: Category
specifications: Spec[]
brand: Brand
}관심사: 상품 정보 표시, 검색
📊 재고 Context
StockItem {
sku: SKU
quantity: number
warehouse: Location
reorderPoint: number
reservations: Reservation[]
}관심사: 수량 관리, 입출고
💰 가격 Context
PricedProduct {
productRef: ProductRef
basePrice: Money
discounts: Discount[]
taxes: TaxRule[]
priceHistory: PriceChange[]
}관심사: 가격 정책, 프로모션
🚚 배송 Context
ShippableItem {
itemRef: ItemRef
weight: Weight
dimensions: Dimensions
fragile: boolean
hazardous: boolean
}관심사: 배송 계산, 포장
💡 실무 인사이트
20년간의 경험에서 가장 흔한 실수는 "Product"를 하나의 거대한 테이블/클래스로 만드는 것입니다. 처음에는 간단해 보이지만, 시스템이 성장하면서 수백 개의 필드를 가진 괴물이 됩니다. 각 Context에서 필요한 속성만 가진 독립적인 모델을 유지하세요.
2. 서브도메인 vs Bounded Context: 문제 공간과 솔루션 공간
"서브도메인은 문제 공간에 존재하고, Bounded Context는 솔루션 공간에 존재한다. 이 둘을 혼동하면 설계가 비즈니스 현실과 동떨어지게 된다."
— Vaughn Vernon, Implementing Domain-Driven Design
2.1 문제 공간 vs 솔루션 공간
🌍 문제 공간 (Problem Space)
서브도메인 (Subdomain)
비즈니스가 해결해야 할 문제 영역
- • 비즈니스 관점에서의 분류
- • 소프트웨어와 무관하게 존재
- • 도메인 전문가가 정의
- • "무엇을 해결해야 하는가?"
- • 발견(Discovery)의 대상
💻 솔루션 공간 (Solution Space)
Bounded Context
문제를 해결하기 위한 소프트웨어 경계
- • 기술적 관점에서의 경계
- • 소프트웨어 설계의 결과
- • 개발팀이 설계
- • "어떻게 해결할 것인가?"
- • 설계(Design)의 대상
2.2 서브도메인의 세 가지 유형
⭐ Core Domain (핵심 도메인)
비즈니스의 핵심 경쟁력. 가장 많은 투자와 최고의 인재가 필요한 영역.
예시: 넷플릭스의 추천 알고리즘, 우버의 매칭 시스템, 쿠팡의 로켓배송
전략: 직접 개발, 최고 수준의 DDD 적용, 지속적 개선
🔧 Supporting Subdomain (지원 도메인)
Core Domain을 지원하지만 차별화 요소는 아닌 영역. 비즈니스에 특화되어 있어 구매 불가.
예시: 커스텀 재고 관리, 내부 분석 도구, 특화된 CRM
전략: 적절한 수준의 DDD, 아웃소싱 고려 가능
📦 Generic Subdomain (일반 도메인)
모든 비즈니스에 공통적인 영역. 차별화 요소가 아니며 구매하거나 오픈소스 사용 가능.
예시: 인증/인가, 이메일 발송, 결제 처리, 파일 저장
전략: 구매(SaaS), 오픈소스, 최소한의 커스터마이징
2.3 서브도메인과 Bounded Context의 매핑
이상적 매핑 vs 현실적 매핑
✓ 이상적: 1:1 매핑
주문 서브도메인 → 주문 Context
재고 서브도메인 → 재고 Context
배송 서브도메인 → 배송 Context
그린필드 프로젝트에서 가능. 명확한 경계와 독립적 진화.
⚠ 현실: N:M 매핑
주문+결제 서브도메인 → 레거시 Context
재고 서브도메인 → 재고 Context + ERP Context
배송 서브도메인 → 외부 3PL Context
레거시, 조직 구조, 외부 시스템으로 인한 복잡한 매핑.
"서브도메인은 발견하는 것이고, Bounded Context는 설계하는 것이다. 서브도메인을 먼저 이해하지 않고 Bounded Context를 설계하면 기술적으로는 깔끔하지만 비즈니스와 동떨어진 시스템이 만들어진다."
— Nick Tune, Domain-Driven Design Crew (2024)
2.4 실제 사례: 이커머스 플랫폼
| 서브도메인 | 유형 | Bounded Context | 전략 |
|---|---|---|---|
| 상품 추천 | Core | Recommendation Context | 직접 개발, ML 팀 전담 |
| 주문 처리 | Core | Order Context | 직접 개발, 풍부한 도메인 모델 |
| 재고 관리 | Supporting | Inventory Context | 직접 개발, 적절한 복잡도 |
| 결제 처리 | Generic | Payment Context (+ PG 연동) | 외부 PG사 활용, ACL로 격리 |
| 사용자 인증 | Generic | Identity Context | Auth0/Cognito 등 SaaS 활용 |
| 알림 발송 | Generic | Notification Context | SendGrid/SNS 등 활용 |
💡 실무 인사이트: Core Domain 식별의 중요성
많은 팀이 모든 서브도메인을 동일하게 취급하는 실수를 합니다. Core Domain에 집중하지 않으면 경쟁력 없는 "평범한" 시스템이 됩니다.
질문: "이 기능이 없으면 우리 비즈니스가 경쟁에서 이길 수 없는가?" → Yes라면 Core Domain입니다.
3. Bounded Context 경계 식별하기
Bounded Context의 경계를 올바르게 식별하는 것은 DDD에서 가장 어려운 작업 중 하나입니다. 경계가 너무 크면 Big Ball of Mud가 되고, 너무 작으면 분산 모놀리스가 됩니다.
3.1 경계를 나타내는 신호들
언어의 변화 (Linguistic Boundary)
같은 용어가 다른 의미로 사용되거나, 다른 용어가 같은 것을 지칭할 때
팀/조직 경계 (Team Boundary)
다른 팀이 담당하는 영역, 다른 도메인 전문가가 관여하는 영역
변경 주기의 차이 (Change Cadence)
자주 변경되는 영역과 안정적인 영역이 다를 때
비즈니스 역량 (Business Capability)
독립적으로 가치를 제공할 수 있는 비즈니스 기능 단위
데이터 소유권 (Data Ownership)
특정 데이터의 "진실의 원천(Source of Truth)"이 어디인지
트랜잭션 경계 (Transaction Boundary)
강한 일관성이 필요한 범위와 최종 일관성으로 충분한 범위
3.2 Event Storming을 통한 경계 발견
"Event Storming은 복잡한 비즈니스 도메인을 빠르게 탐색하고 Bounded Context의 경계를 발견하는 가장 효과적인 방법이다."
— Alberto Brandolini, Event Storming 창시자
도메인 이벤트 나열
비즈니스에서 발생하는 모든 이벤트를 주황색 포스트잇에 작성
커맨드와 액터 식별
이벤트를 발생시키는 명령과 그 주체를 파란색으로 표시
애그리거트 식별
관련 이벤트와 커맨드를 그룹화하여 애그리거트 발견
Bounded Context 경계 그리기
언어와 모델이 일관된 영역을 분홍색 선으로 구분
3.3 경계 식별 시 흔한 실수
❌ 기술적 계층으로 분리
UI Context, API Context, Database Context로 나누는 것은 Bounded Context가 아닙니다.
문제: 하나의 기능 변경에 모든 "Context"가 함께 변경되어야 함
❌ 엔티티 단위로 분리
User Context, Product Context, Order Context를 각각 하나의 엔티티만 포함하도록 만드는 것
문제: 너무 세분화되어 모든 작업에 여러 Context 간 통신 필요
❌ CRUD 기준으로 분리
Create Context, Read Context, Update Context로 나누는 것
문제: 비즈니스 의미가 없고, 도메인 로직이 분산됨
❌ 데이터베이스 테이블 기준으로 분리
테이블 하나당 하나의 Context를 만드는 것
문제: 데이터 중심 설계, 비즈니스 로직 누락
✓ 비즈니스 역량 기준
"주문 관리", "재고 관리", "고객 관계 관리" 등 비즈니스가 수행하는 역량 단위
✓ 유비쿼터스 언어 기준
같은 용어가 같은 의미로 사용되는 범위
✓ 팀 자율성 기준
하나의 팀이 독립적으로 개발, 배포, 운영할 수 있는 범위
✓ 데이터 일관성 기준
강한 일관성이 필요한 데이터가 함께 있는 범위
💡 실무 팁: 경계 크기의 균형
너무 큰 Context: 내부 복잡도 증가, 팀 간 충돌, 배포 어려움
너무 작은 Context: 과도한 통신 오버헤드, 분산 트랜잭션 문제, 운영 복잡도
적정 크기: 5-9명의 팀이 소유하고, 2주 스프린트 내에 의미 있는 기능을 배포할 수 있는 크기
4. Bounded Context 간의 관계: Context Mapping 패턴
"Context Map은 프로젝트에 관련된 Bounded Context들과 그들 사이의 관계를 보여주는 문서이다. 이것은 팀 간의 의사소통과 통합 전략을 명확히 한다."
— Eric Evans, Domain-Driven Design
Bounded Context는 독립적으로 존재하지 않습니다. 실제 시스템에서는 여러 Context가 협력하여 비즈니스 가치를 제공합니다. Context 간의 관계를 명확히 정의하는 것이 Context Mapping입니다.
4.1 Context Mapping의 9가지 패턴
1. Partnership (파트너십)
두 팀이 공동의 목표를 위해 긴밀하게 협력. 인터페이스 변경 시 함께 조율.
┌─────────────────┐ Partnership ┌─────────────────┐ │ Order Context │◄──────────────────►│ Inventory Context│ │ (Team A) │ 공동 목표, 동기화 │ (Team A) │ └─────────────────┘ └─────────────────┘
적용 시점: 같은 팀이 두 Context를 소유하거나, 두 팀이 매우 긴밀할 때
2. Shared Kernel (공유 커널)
두 Context가 도메인 모델의 일부를 공유. 변경 시 양쪽 합의 필요.
┌─────────────────┐ ┌─────────────────┐ │ Order Context │ │ Shipping Context │ │ │ │ │ │ ┌───────────┐│ │┌───────────┐ │ │ │ Shared ││◄───────────────────►││ Shared │ │ │ │ Kernel ││ Address, Money ││ Kernel │ │ │ └───────────┘│ │└───────────┘ │ └─────────────────┘ └─────────────────┘
주의: 공유 범위를 최소화하고, 변경 프로세스를 명확히 정의해야 함
3. Customer-Supplier (고객-공급자)
상류(Upstream)가 하류(Downstream)의 요구사항을 수용. 하류가 고객 역할.
┌─────────────────┐ ┌─────────────────┐ │ Product Context │ │ Order Context │ │ (Upstream) │────────────────────►│ (Downstream) │ │ [Supplier] │ API 제공, 요구 수용 │ [Customer] │ └─────────────────┘ └─────────────────┘
핵심: 하류 팀이 상류 팀의 백로그에 요구사항을 추가할 수 있음
4. Conformist (순응자)
하류가 상류의 모델을 그대로 수용. 상류가 하류의 요구를 고려하지 않음.
┌─────────────────┐ ┌─────────────────┐ │ External API │ │ Our Context │ │ (Upstream) │────────────────────►│ (Downstream) │ │ 변경 통보 없음 │ 그대로 따름 │ [Conformist] │ └─────────────────┘ └─────────────────┘
적용 시점: 외부 API, 표준 프로토콜, 변경 불가능한 레거시
5. Anti-Corruption Layer (부패 방지 계층)
하류가 상류의 모델로부터 자신을 보호하는 번역 계층을 구축.
┌─────────────────┐ ┌─────┐ ┌─────────────────┐ │ Legacy System │ │ ACL │ │ New Context │ │ (Upstream) │────────►│ │────────►│ (Downstream) │ │ 복잡한 모델 │ │번역 │ │ 깔끔한 모델 │ └─────────────────┘ └─────┘ └─────────────────┘
핵심: 외부 모델의 복잡성이 내부로 전파되지 않도록 격리
6. Open Host Service (공개 호스트 서비스)
상류가 여러 하류를 위한 표준화된 프로토콜/API를 제공.
┌─────────────────┐
┌───►│ Context A │
┌─────────────────┐ │ └─────────────────┘
│ Order Context │──────┤ ┌─────────────────┐
│ [Open Host] │──────┼───►│ Context B │
│ REST API v1 │ │ └─────────────────┘
└─────────────────┘ │ ┌─────────────────┐
└───►│ Context C │
└─────────────────┘적용 시점: 여러 소비자가 있는 서비스, 플랫폼 API
7. Published Language (공표된 언어)
Context 간 통신을 위한 잘 문서화된 공유 언어/스키마.
┌─────────────────┐ Published Language ┌─────────────────┐ │ Context A │◄────────────────────────►│ Context B │ │ │ JSON Schema, Protobuf │ │ │ │ OpenAPI, AsyncAPI │ │ └─────────────────┘ └─────────────────┘
예시: JSON Schema, Protocol Buffers, Avro, OpenAPI Spec
8. Separate Ways (각자의 길)
통합의 비용이 이점보다 클 때, 의도적으로 통합하지 않음.
┌─────────────────┐ ┌─────────────────┐ │ Context A │ ✕ │ Context B │ │ │ 통합하지 않음 │ │ │ 독립적 구현 │ │ 독립적 구현 │ └─────────────────┘ └─────────────────┘
적용 시점: 중복 비용 < 통합 복잡도, 완전히 다른 비즈니스 영역
9. Big Ball of Mud (진흙 덩어리)
경계가 없는 레거시 시스템. 안티패턴이지만 현실에서 자주 발견됨.
┌─────────────────────────────────────────────────────────┐ │ Big Ball of Mud │ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │ │ │─│ │─│ │─│ │─│ │─│ │─│ │─│ │ │ │ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ │ │ 모든 것이 연결됨, 경계 없음, 변경 영향 예측 불가 │ └─────────────────────────────────────────────────────────┘
대응: ACL로 격리하고, 점진적으로 Context를 추출
4.2 Context Map 시각화
┌─────────────────────────────────────────────────────────┐
│ External Systems │
└─────────────────────────────────────────────────────────┘
│ │
│ Conformist │ ACL
▼ ▼
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Identity │ │ Payment Gateway │ │ Shipping API │
│ (Auth0) │ │ (Stripe) │ │ (FedEx) │
└─────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ OHS │ ACL │ ACL
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Internal Contexts │
│ ┌─────────────┐ Customer ┌─────────────┐ Partnership │
│ │ Catalog │◄───Supplier──│ Order │◄─────────────────┐│
│ │ Context │ │ Context │ ││
│ └─────────────┘ └─────────────┘ ││
│ │ │ ││
│ │ OHS/PL │ Domain Events ││
│ ▼ ▼ ││
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐│
│ │ Inventory │◄──Partnership──│ Shipping │ │ Analytics ││
│ │ Context │ │ Context │ │ Context ││
│ └─────────────┘ └─────────────┘ └─────────────┘│
└─────────────────────────────────────────────────────────────────┘
범례:
──► : 데이터 흐름 방향 (Upstream → Downstream)
OHS : Open Host Service
PL : Published Language
ACL : Anti-Corruption Layer💡 실무 인사이트: Context Map 유지보수
Context Map은 살아있는 문서입니다. 시스템이 진화함에 따라 함께 업데이트되어야 합니다.
- • 분기별로 Context Map 리뷰 세션 진행
- • 새로운 Context 추가 시 관계 패턴 명시적으로 결정
- • 팀 변경 시 소유권과 관계 재검토
5. Bounded Context 간 통신 패턴
"Bounded Context 간의 통신 방식은 시스템의 결합도, 확장성, 복원력을 결정한다. 동기 통신은 단순하지만 취약하고, 비동기 통신은 복잡하지만 견고하다."
— Vaughn Vernon, Implementing Domain-Driven Design
5.1 동기 통신 (Synchronous Communication)
✓ 장점
- • 구현이 단순하고 직관적
- • 즉각적인 응답 확인
- • 디버깅이 쉬움
- • 트랜잭션 처리 용이
✗ 단점
- • 시간적 결합 (Temporal Coupling)
- • 연쇄 장애 위험
- • 확장성 제한
- • 네트워크 지연 누적
코드 예시: Order Context → Inventory Context
// Order Context - 동기 호출
class OrderService {
constructor(private inventoryClient: InventoryClient) {}
async createOrder(request: CreateOrderRequest): Promise<Order> {
// 1. 재고 확인 (동기 호출)
const stockCheck = await this.inventoryClient.checkStock(
request.items.map(i => ({ sku: i.sku, quantity: i.quantity }))
);
if (!stockCheck.allAvailable) {
throw new InsufficientStockError(stockCheck.unavailableItems);
}
// 2. 재고 예약 (동기 호출)
const reservation = await this.inventoryClient.reserveStock({
orderId: generateOrderId(),
items: request.items,
expiresAt: addMinutes(now(), 15)
});
// 3. 주문 생성
const order = Order.create({
...request,
reservationId: reservation.id
});
return this.orderRepository.save(order);
}
}
// Inventory Context - API 제공
@Controller('/api/v1/inventory')
class InventoryController {
@Post('/check-stock')
async checkStock(@Body() request: CheckStockRequest): Promise<StockCheckResult> {
return this.inventoryService.checkAvailability(request.items);
}
@Post('/reserve')
async reserveStock(@Body() request: ReserveStockRequest): Promise<Reservation> {
return this.inventoryService.createReservation(request);
}
}5.2 비동기 통신 (Asynchronous Communication)
✓ 장점
- • 느슨한 결합
- • 높은 확장성
- • 장애 격리
- • 독립적 배포 가능
✗ 단점
- • 최종 일관성 (Eventual Consistency)
- • 디버깅 복잡
- • 이벤트 순서 보장 어려움
- • 인프라 복잡도 증가
코드 예시: 이벤트 기반 통신
// Order Context - 이벤트 발행
class OrderService {
async createOrder(request: CreateOrderRequest): Promise<Order> {
const order = Order.create(request);
// 도메인 이벤트 등록
order.addDomainEvent(new OrderCreatedEvent({
orderId: order.id,
customerId: order.customerId,
items: order.items.map(i => ({
sku: i.sku,
quantity: i.quantity,
price: i.price
})),
totalAmount: order.totalAmount,
occurredAt: new Date()
}));
await this.orderRepository.save(order);
// 이벤트 발행 (Outbox 패턴 권장)
await this.eventPublisher.publishAll(order.domainEvents);
return order;
}
}
// Inventory Context - 이벤트 구독
@EventHandler(OrderCreatedEvent)
class OrderCreatedHandler {
constructor(private inventoryService: InventoryService) {}
async handle(event: OrderCreatedEvent): Promise<void> {
// 재고 차감
await this.inventoryService.decrementStock(
event.items.map(i => ({ sku: i.sku, quantity: i.quantity }))
);
// 처리 완료 이벤트 발행
await this.eventPublisher.publish(new StockDecrementedEvent({
orderId: event.orderId,
items: event.items,
occurredAt: new Date()
}));
}
}
// Shipping Context - 이벤트 구독
@EventHandler(OrderCreatedEvent)
class PrepareShipmentHandler {
async handle(event: OrderCreatedEvent): Promise<void> {
// 배송 준비 시작
await this.shipmentService.prepareShipment({
orderId: event.orderId,
items: event.items
});
}
}
// Notification Context - 이벤트 구독
@EventHandler(OrderCreatedEvent)
class SendOrderConfirmationHandler {
async handle(event: OrderCreatedEvent): Promise<void> {
await this.notificationService.sendOrderConfirmation(
event.customerId,
event.orderId
);
}
}5.3 이벤트 설계 원칙
1. 이벤트는 과거형으로 명명
2. 이벤트는 불변(Immutable)
한번 발행된 이벤트는 수정되지 않습니다. 잘못된 이벤트는 보상 이벤트로 처리합니다.
3. 이벤트는 자기 완결적
이벤트 처리에 필요한 모든 정보를 포함해야 합니다. 추가 조회가 필요하면 안 됩니다.
❌ 나쁜 예
{
"type": "OrderCreated",
"orderId": "123" // ID만 있음
}✓ 좋은 예
{
"type": "OrderCreated",
"orderId": "123",
"customerId": "456",
"items": [...],
"totalAmount": 50000
}4. 버전 관리
이벤트 스키마가 변경될 때 하위 호환성을 유지하거나 버전을 명시합니다.
{
"type": "OrderCreated",
"version": "2.0",
"data": { ... }
}5.4 통신 패턴 선택 가이드
| 상황 | 권장 패턴 | 이유 |
|---|---|---|
| 즉각적인 응답 필요 | 동기 (REST/gRPC) | 사용자가 결과를 기다림 |
| 여러 Context에 알림 | 비동기 (이벤트) | Fan-out, 느슨한 결합 |
| 장시간 처리 작업 | 비동기 (이벤트) | 타임아웃 방지 |
| 데이터 조회 | 동기 (REST/gRPC) | 단순 요청-응답 |
| 상태 변경 전파 | 비동기 (이벤트) | 최종 일관성 허용 |
| 외부 시스템 연동 | ACL + 비동기 | 격리 및 복원력 |
"동기 통신을 기본으로 시작하고, 필요에 따라 비동기로 전환하라. 처음부터 모든 것을 이벤트 기반으로 만들면 불필요한 복잡성이 생긴다."
— Sam Newman, Building Microservices (2nd Edition, 2021)
6. Anti-Corruption Layer (ACL) 심화
"Anti-Corruption Layer는 외부 시스템의 모델이 우리 도메인 모델을 오염시키지 않도록 보호하는 번역 계층이다. 이것은 단순한 어댑터가 아니라 도메인 개념의 번역기다."
— Eric Evans, Domain-Driven Design
6.1 ACL이 필요한 상황
🏚️ 레거시 시스템 통합
- • 오래된 데이터 모델
- • 일관성 없는 명명 규칙
- • 문서화되지 않은 비즈니스 규칙
- • 변경 불가능한 인터페이스
🌐 외부 서비스 연동
- • 서드파티 API (결제, 배송, CRM)
- • 파트너 시스템
- • SaaS 제품
- • 표준 프로토콜 (EDI, SWIFT)
🔄 마이그레이션 중
- • 점진적 시스템 교체
- • Strangler Fig 패턴 적용
- • 병렬 운영 기간
🏢 다른 팀의 Context
- • 다른 유비쿼터스 언어 사용
- • 다른 모델링 철학
- • 변경 주기가 다름
6.2 ACL 구현 패턴
┌─────────────────────────────────────────────────────────────────┐
│ Our Bounded Context │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Domain Layer ││
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││
│ │ │ Order │ │ Customer │ │ Product │ ││
│ │ │ Aggregate │ │ Aggregate │ │ Aggregate │ ││
│ │ └─────────────┘ └─────────────┘ └─────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
│ ▲ │
│ │ Domain Interface │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Anti-Corruption Layer ││
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││
│ │ │ Translator │ │ Adapter │ │ Facade │ ││
│ │ │ (모델 변환) │ │ (프로토콜) │ │ (단순화) │ ││
│ │ └─────────────┘ └─────────────┘ └─────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ External System │
│ (Legacy, Third-party API, Partner System) │
└─────────────────────────────────────────────────────────────────┘// 1. 도메인 인터페이스 (우리 언어로 정의)
interface PaymentGateway {
processPayment(payment: Payment): Promise<PaymentResult>;
refund(paymentId: PaymentId, amount: Money): Promise<RefundResult>;
getPaymentStatus(paymentId: PaymentId): Promise<PaymentStatus>;
}
// 2. 외부 시스템의 모델 (Stripe API)
interface StripeChargeRequest {
amount: number; // cents 단위
currency: string;
source: string;
metadata: Record<string, string>;
capture: boolean;
}
interface StripeChargeResponse {
id: string;
status: 'succeeded' | 'pending' | 'failed';
amount: number;
created: number; // Unix timestamp
failure_code?: string;
failure_message?: string;
}
// 3. ACL - Translator (모델 변환)
class StripePaymentTranslator {
toStripeRequest(payment: Payment): StripeChargeRequest {
return {
amount: payment.amount.toCents(), // Money → cents
currency: payment.amount.currency.toLowerCase(),
source: payment.paymentMethod.token,
metadata: {
orderId: payment.orderId.value,
customerId: payment.customerId.value
},
capture: true
};
}
toPaymentResult(response: StripeChargeResponse): PaymentResult {
return new PaymentResult({
paymentId: new PaymentId(response.id),
status: this.mapStatus(response.status),
processedAt: new Date(response.created * 1000),
failureReason: response.failure_message
? new PaymentFailure(response.failure_code!, response.failure_message)
: undefined
});
}
private mapStatus(stripeStatus: string): PaymentStatus {
const mapping: Record<string, PaymentStatus> = {
'succeeded': PaymentStatus.COMPLETED,
'pending': PaymentStatus.PENDING,
'failed': PaymentStatus.FAILED
};
return mapping[stripeStatus] ?? PaymentStatus.UNKNOWN;
}
}
// 4. ACL - Adapter (프로토콜 변환)
class StripePaymentAdapter implements PaymentGateway {
constructor(
private stripeClient: StripeClient,
private translator: StripePaymentTranslator,
private logger: Logger
) {}
async processPayment(payment: Payment): Promise<PaymentResult> {
try {
// 우리 모델 → Stripe 모델
const stripeRequest = this.translator.toStripeRequest(payment);
// 외부 API 호출
const stripeResponse = await this.stripeClient.charges.create(stripeRequest);
// Stripe 모델 → 우리 모델
return this.translator.toPaymentResult(stripeResponse);
} catch (error) {
// 외부 에러 → 도메인 에러로 변환
this.logger.error('Stripe payment failed', { error, payment });
throw this.translateError(error);
}
}
private translateError(error: unknown): PaymentError {
if (error instanceof StripeCardError) {
return new CardDeclinedError(error.decline_code);
}
if (error instanceof StripeRateLimitError) {
return new PaymentServiceUnavailableError();
}
return new PaymentProcessingError('Unknown payment error');
}
}
// 5. 도메인 서비스에서 사용
class PaymentService {
constructor(private paymentGateway: PaymentGateway) {} // 인터페이스에 의존
async processOrderPayment(order: Order): Promise<PaymentResult> {
const payment = Payment.createForOrder(order);
// ACL을 통해 외부 시스템과 통신
// 도메인 코드는 Stripe를 전혀 모름
const result = await this.paymentGateway.processPayment(payment);
if (result.isSuccessful()) {
order.markAsPaid(result.paymentId);
}
return result;
}
}6.3 ACL 설계 원칙
1. 도메인 언어 우선
ACL의 공개 인터페이스는 우리 도메인의 유비쿼터스 언어로 정의합니다. 외부 시스템의 용어가 도메인 코드에 노출되면 안 됩니다.
2. 양방향 번역
요청(우리→외부)과 응답(외부→우리) 모두 번역이 필요합니다. 에러도 도메인 에러로 변환해야 합니다.
3. 테스트 용이성
ACL은 인터페이스로 추상화되어 있어 테스트 시 Mock으로 대체 가능해야 합니다. 외부 시스템 없이도 도메인 로직을 테스트할 수 있어야 합니다.
4. 장애 격리
외부 시스템의 장애가 도메인 로직에 직접 영향을 주지 않도록 합니다. Circuit Breaker, Retry, Fallback 등의 패턴을 ACL에서 처리합니다.
class ResilientPaymentAdapter implements PaymentGateway {
constructor(
private stripeClient: StripeClient,
private translator: StripePaymentTranslator,
private circuitBreaker: CircuitBreaker,
private retryPolicy: RetryPolicy
) {}
async processPayment(payment: Payment): Promise<PaymentResult> {
// Circuit Breaker로 감싸기
return this.circuitBreaker.execute(async () => {
// Retry 정책 적용
return this.retryPolicy.execute(async () => {
const request = this.translator.toStripeRequest(payment);
const response = await this.stripeClient.charges.create(request);
return this.translator.toPaymentResult(response);
});
}, {
// Fallback: Circuit이 열렸을 때
fallback: () => {
return PaymentResult.deferred(payment.id, 'Payment service temporarily unavailable');
}
});
}
}💡 실무 인사이트: ACL의 위치
ACL은 Infrastructure Layer에 위치하지만, 인터페이스는 Domain Layer에 정의됩니다. 이를 통해 의존성 역전 원칙(DIP)을 지키고, 도메인이 외부에 의존하지 않게 됩니다.
src/ ├── domain/ │ ├── payment/ │ │ ├── Payment.ts │ │ ├── PaymentGateway.ts ← 인터페이스 (도메인 언어) │ │ └── PaymentService.ts ├── infrastructure/ │ ├── payment/ │ │ ├── stripe/ │ │ │ ├── StripePaymentAdapter.ts ← ACL 구현 │ │ │ ├── StripePaymentTranslator.ts │ │ │ └── StripeClient.ts
7. Bounded Context와 마이크로서비스
"마이크로서비스는 Bounded Context의 물리적 구현이 될 수 있다. 하지만 모든 Bounded Context가 별도의 서비스가 될 필요는 없다."
— Sam Newman, Building Microservices
7.1 Bounded Context ≠ 마이크로서비스
📦 Bounded Context
- • 논리적 경계
- • 모델의 일관성 범위
- • 언어적/개념적 분리
- • 배포 단위와 무관
- • 모놀리스 내 모듈로 존재 가능
🔧 마이크로서비스
- • 물리적 경계
- • 독립 배포 단위
- • 프로세스/네트워크 분리
- • 운영 복잡도 증가
- • 인프라 비용 발생
⚠️ 흔한 실수
"마이크로서비스를 도입하면 자동으로 Bounded Context가 생긴다"는 잘못된 생각입니다. 경계 없이 서비스만 분리하면 분산 모놀리스가 됩니다.
7.2 배포 전략 선택
옵션 1: 모듈러 모놀리스 (Modular Monolith)
하나의 배포 단위 내에서 Bounded Context를 모듈로 분리
┌─────────────────────────────────────────────────────┐
│ Modular Monolith │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Order │ │ Inventory │ │ Payment │ │
│ │ Module │ │ Module │ │ Module │ │
│ │ │ │ │ │ │ │
│ │ (Context) │ │ (Context) │ │ (Context) │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ Shared Kernel │ │
│ │ (공통 라이브러리) │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────┘
│
Single Deploy옵션 2: 마이크로서비스
각 Bounded Context를 독립적인 서비스로 배포
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Order │ │ Inventory │ │ Payment │
│ Service │◄──►│ Service │◄──►│ Service │
│ │ │ │ │ │
│ (Context) │ │ (Context) │ │ (Context) │
└───────────┘ └───────────┘ └───────────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│Order DB │ │Inv. DB │ │Pay. DB │
└─────────┘ └─────────┘ └─────────┘
Independent Deploy × 3옵션 3: 하이브리드
관련 Context들을 그룹화하여 서비스로 배포
┌─────────────────────────┐ ┌───────────────┐
│ Commerce Service │ │ Payment Service│
│ ┌───────┐ ┌───────┐ │ │ │
│ │ Order │ │ Cart │ │◄──►│ (Context) │
│ │Context│ │Context│ │ │ │
│ └───────┘ └───────┘ │ └───────────────┘
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Fulfillment Service │
│ ┌───────┐ ┌───────┐ │
│ │Inventory│ │Shipping│ │
│ │Context│ │Context│ │
│ └───────┘ └───────┘ │
└─────────────────────────┘7.3 분리 시점 결정하기
독립적인 확장이 필요할 때
특정 Context만 트래픽이 급증하는 경우
다른 기술 스택이 필요할 때
ML 모델은 Python, 트랜잭션은 Java 등
팀이 독립적으로 배포해야 할 때
배포 주기가 다르고, 서로 기다리면 안 되는 경우
장애 격리가 중요할 때
한 Context의 장애가 전체에 영향을 주면 안 되는 경우
경계가 아직 불명확할 때
도메인 이해가 부족한 초기 단계
팀 규모가 작을 때
5명 이하 팀이 여러 서비스를 운영하기 어려움
강한 일관성이 필요할 때
분산 트랜잭션 비용이 이점보다 클 때
운영 역량이 부족할 때
모니터링, 로깅, 배포 파이프라인이 미성숙
"모놀리스 우선(Monolith First) 전략을 권장한다. 모놀리스 내에서 모듈 경계를 명확히 한 후, 필요에 따라 마이크로서비스로 추출하라."
— Martin Fowler, MonolithFirst (2015)
💡 2024-2025 트렌드: 모듈러 모놀리스의 부활
마이크로서비스의 복잡성을 경험한 많은 기업들이 모듈러 모놀리스로 회귀하고 있습니다. Shopify, Basecamp 등이 대표적인 사례입니다.
핵심: 물리적 분리보다 논리적 경계(Bounded Context)가 더 중요합니다. 잘 설계된 모놀리스는 나중에 필요할 때 쉽게 분리할 수 있습니다.
8. 모놀리스에서 Bounded Context 분리하기
"모놀리스를 마이크로서비스로 분해하기 전에, 먼저 모놀리스 내에서 Bounded Context의 경계를 명확히 하라. 경계 없이 분리하면 분산 모놀리스가 된다."
— Sam Newman, Monolith to Microservices (2019)
8.1 점진적 분리 전략
모듈 경계 식별
기존 코드에서 논리적 경계를 찾아 패키지/네임스페이스로 분리
// Before: 뒤섞인 코드
src/
├── controllers/
├── services/
├── repositories/
└── models/
// After: Context별 분리
src/
├── order/ ← Order Context
│ ├── domain/
│ ├── application/
│ └── infrastructure/
├── inventory/ ← Inventory Context
│ ├── domain/
│ ├── application/
│ └── infrastructure/
└── shared/ ← Shared Kernel
└── kernel/내부 API 정의
모듈 간 직접 참조를 인터페이스 기반 통신으로 전환
// Before: 직접 참조
class OrderService {
createOrder() {
// 다른 모듈의 내부 클래스 직접 사용
const stock = this.inventoryRepository.findBySku(sku);
stock.quantity -= orderQuantity;
this.inventoryRepository.save(stock);
}
}
// After: 인터페이스 통신
class OrderService {
constructor(private inventoryModule: InventoryModuleApi) {}
createOrder() {
// 공개 API를 통해서만 통신
await this.inventoryModule.reserveStock({
sku, quantity: orderQuantity
});
}
}
// 모듈 공개 API
interface InventoryModuleApi {
checkStock(sku: string): Promise<StockInfo>;
reserveStock(request: ReserveRequest): Promise<Reservation>;
}데이터베이스 분리
공유 테이블을 각 Context 전용 테이블로 분리
-- Before: 공유 테이블 SELECT o.*, p.name, i.quantity FROM orders o JOIN products p ON o.product_id = p.id JOIN inventory i ON p.id = i.product_id -- After: Context별 테이블 + 참조 ID -- Order Context SELECT * FROM order_context.orders WHERE id = ? SELECT * FROM order_context.order_items WHERE order_id = ? -- Inventory Context (별도 스키마) SELECT * FROM inventory_context.stock_items WHERE sku = ? -- 필요시 API로 조회 const product = await catalogApi.getProduct(productRef);
물리적 분리 (선택)
필요시 별도 서비스로 추출
// 모듈 API를 HTTP/gRPC로 교체
class InventoryHttpClient implements InventoryModuleApi {
constructor(private httpClient: HttpClient) {}
async reserveStock(request: ReserveRequest): Promise<Reservation> {
return this.httpClient.post('/api/inventory/reserve', request);
}
}
// DI 설정만 변경하면 됨
// 코드 변경 최소화8.2 Strangler Fig 패턴
Strangler Fig(교살자 무화과)는 숙주 나무를 감싸며 자라다가 결국 대체하는 식물입니다. 이 패턴은 레거시 시스템을 점진적으로 새 시스템으로 교체합니다.
Phase 1: Facade 도입
┌─────────────────────────────────────────────────────┐
│ API Gateway │
│ (Facade) │
└─────────────────────────────────────────────────────┘
│
│ 100% 트래픽
▼
┌─────────────────────────────────────────────────────┐
│ Legacy Monolith │
└─────────────────────────────────────────────────────┘
Phase 2: 새 Context 추가
┌─────────────────────────────────────────────────────┐
│ API Gateway │
└─────────────────────────────────────────────────────┘
│ │
│ /orders/* │ 나머지
▼ ▼
┌─────────────────┐ ┌─────────────────────┐
│ New Order │ │ Legacy Monolith │
│ Context │───ACL────►│ │
└─────────────────┘ └─────────────────────┘
Phase 3: 점진적 마이그레이션
┌─────────────────────────────────────────────────────┐
│ API Gateway │
└─────────────────────────────────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Order │ │ Inventory│ │ Payment │ │ Legacy │
│Context │ │ Context │ │ Context │ │(축소중) │
└────────┘ └──────────┘ └──────────┘ └──────────┘
Phase 4: 완료
┌─────────────────────────────────────────────────────┐
│ API Gateway │
└─────────────────────────────────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Order │ │ Inventory│ │ Payment │ │ Shipping │
│Context │ │ Context │ │ Context │ │ Context │
└────────┘ └──────────┘ └──────────┘ └──────────┘
Legacy 제거 완료8.3 데이터 마이그레이션 전략
전략 1: 이중 쓰기 (Dual Write)
새 시스템과 레거시에 동시에 쓰기. 읽기는 점진적으로 전환.
전략 2: CDC (Change Data Capture)
레거시 DB 변경을 캡처하여 새 시스템으로 동기화.
전략 3: 이벤트 소싱 도입
새 Context는 이벤트 소싱으로 구현. 레거시 이벤트를 재생하여 상태 구축.
"Big Bang 마이그레이션은 거의 항상 실패한다. 작은 조각으로 나누어 점진적으로 마이그레이션하고, 각 단계에서 롤백할 수 있어야 한다."
— Michael Feathers, Working Effectively with Legacy Code
💡 실무 인사이트: 분리 우선순위
어떤 Context를 먼저 분리할지 결정하는 기준:
- 1. 변경 빈도가 높은 영역 - 자주 배포해야 하는 기능
- 2. 확장이 필요한 영역 - 트래픽이 급증하는 기능
- 3. 기술 부채가 적은 영역 - 깔끔하게 분리 가능한 부분
- 4. 비즈니스 가치가 높은 영역 - Core Domain
9. 실제 기업 사례 연구
9.1 성공 사례
모놀리스에서 마이크로서비스로 전환 시 Bounded Context를 기반으로 분리
주문 Context
주문 생성/상태 관리
가게 Context
가게 정보/메뉴 관리
배달 Context
라이더 매칭/추적
정산 Context
가게/라이더 정산
성공 요인
- • 각 Context가 독립적인 팀에 의해 소유
- • 이벤트 기반 통신으로 느슨한 결합
- • 점진적 마이그레이션 (2년에 걸쳐 진행)
- • CQRS 패턴으로 읽기/쓰기 분리
700개 이상의 마이크로서비스를 Bounded Context 기반으로 운영
Streaming Context
비디오 인코딩, CDN, 재생
Recommendation Context
ML 기반 추천 (Core Domain)
Subscription Context
구독, 결제, 계정
핵심 전략
- • 각 Context가 자체 데이터 저장소 소유
- • Chaos Engineering으로 장애 격리 검증
- • API Gateway를 통한 통합
- • 강력한 모니터링/관찰성 인프라
마이크로서비스 대신 모듈러 모놀리스를 선택한 대표적 사례
"우리는 마이크로서비스의 복잡성 없이 Bounded Context의 이점을 얻기 위해 모듈러 모놀리스를 선택했다. 300개 이상의 모듈이 하나의 Rails 앱에서 동작한다."
— Shopify Engineering Blog (2024)
구현 방식
- • Packwerk 도구로 모듈 경계 강제
- • 모듈 간 의존성 규칙 자동 검사
- • 공개 API만 통해 모듈 간 통신
- • 필요시 선택적으로 서비스 추출
9.2 실패 사례와 교훈
Context 경계 없이 기술적 계층으로만 서비스를 분리한 결과
문제 상황:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ User Service│ │Order Service│ │Product Svc │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└────────────────┼────────────────┘
│
▼
┌─────────────────┐
│ Shared DB │ ← 모든 서비스가 공유
│ (Single Point │
│ of Failure) │
└─────────────────┘
결과:
• 하나의 테이블 변경 → 모든 서비스 재배포
• 데이터베이스 스키마 변경 불가능
• 서비스 간 암묵적 결합
• 마이크로서비스의 단점만 가진 시스템실패 원인
- • Bounded Context 식별 없이 서비스 분리
- • 데이터베이스 공유로 인한 결합
- • 동기 호출 체인으로 인한 연쇄 장애
- • 분산 트랜잭션 문제 미해결
너무 작은 단위로 서비스를 분리하여 운영 복잡도가 폭발한 사례
증상
- • 단순한 기능에 5개 이상의 서비스 호출 필요
- • 네트워크 지연으로 응답 시간 급증
- • 디버깅에 수 시간 소요
- • 인프라 비용 3배 증가
- • 개발자 생산성 50% 감소
교훈
"마이크로"는 코드 크기가 아니라 책임의 범위를 의미합니다. 하나의 비즈니스 역량을 완전히 수행할 수 있는 크기가 적절합니다.
9.3 2024-2025 트렌드
🔄 모듈러 모놀리스의 부활
마이크로서비스의 복잡성을 경험한 기업들이 모듈러 모놀리스로 회귀. Shopify, Basecamp, GitHub 등이 대표적.
🎯 Cell-Based Architecture
여러 Bounded Context를 "Cell"로 그룹화하여 장애 격리. AWS, Slack 등에서 채택.
📊 Platform Engineering
내부 개발자 플랫폼(IDP)으로 마이크로서비스 운영 복잡도 감소. 각 팀이 Context에 집중할 수 있도록 지원.
🤖 AI-Assisted Domain Modeling
LLM을 활용한 도메인 분석, Event Storming 지원. Bounded Context 경계 식별 자동화 시도.
"2024년의 교훈: 마이크로서비스는 목표가 아니라 도구다. Bounded Context의 논리적 경계가 먼저이고, 물리적 분리는 필요할 때만 한다."
— Nick Tune, Architecture Modernization (2024)
10. 실습: 호텔 예약 시스템 Context 설계
10.1 도메인 분석
- • 고객은 객실을 검색하고 예약할 수 있다
- • 예약 시 결제가 처리되어야 한다
- • 객실 가용성이 실시간으로 관리되어야 한다
- • 고객에게 예약 확인 알림이 발송되어야 한다
- • 멤버십 등급에 따른 할인이 적용된다
- • 체크인/체크아웃 프로세스가 관리되어야 한다
- • 하우스키핑 일정이 관리되어야 한다
10.2 식별된 Bounded Context
🏨 객실 관리 Context
Room Management
Room {
roomNumber: RoomNumber
roomType: RoomType
floor: number
amenities: Amenity[]
status: RoomStatus
// Available|Occupied|
// Maintenance|Cleaning
}
RoomType {
name: string
basePrice: Money
maxOccupancy: number
bedConfiguration: string
}책임: 객실 정보, 시설, 상태 관리
📅 예약 Context
Reservation
Reservation {
id: ReservationId
guestId: GuestRef
roomId: RoomRef
checkIn: Date
checkOut: Date
status: ReservationStatus
// Pending|Confirmed|
// CheckedIn|CheckedOut|
// Cancelled
totalAmount: Money
specialRequests: string[]
}책임: 예약 생성, 변경, 취소
💳 결제 Context
Payment
Payment {
id: PaymentId
reservationRef: ReservationRef
amount: Money
method: PaymentMethod
status: PaymentStatus
// Pending|Authorized|
// Captured|Refunded
transactionId: string
}
Invoice {
charges: Charge[]
taxes: Tax[]
discounts: Discount[]
}책임: 결제 처리, 환불, 청구
👤 고객 Context
Guest
Guest {
id: GuestId
profile: GuestProfile
membership: Membership
preferences: Preferences
stayHistory: StayRef[]
}
Membership {
tier: MembershipTier
// Bronze|Silver|Gold|
// Platinum
points: number
benefits: Benefit[]
}책임: 고객 프로필, 멤버십
🔔 알림 Context
Notification
Notification {
id: NotificationId
recipientId: GuestRef
channel: Channel
// Email|SMS|Push
template: TemplateId
data: Record<string, any>
status: NotificationStatus
sentAt?: Date
}책임: 알림 발송, 템플릿 관리
🧹 하우스키핑 Context
Housekeeping
CleaningTask {
id: TaskId
roomRef: RoomRef
type: CleaningType
// Checkout|Stayover|
// DeepClean
assignedTo: StaffRef
status: TaskStatus
scheduledAt: Date
completedAt?: Date
}책임: 청소 일정, 작업 할당
10.3 Context Map
┌─────────────────────────────────────┐
│ External Systems │
│ ┌─────────┐ ┌─────────┐ │
│ │ Stripe │ │ Twilio │ │
│ │ PG │ │ SMS │ │
│ └────┬────┘ └────┬────┘ │
└───────┼────────────────┼───────────┘
│ ACL │ ACL
▼ ▼
┌────────────────────────────────────────────────────────────────────────┐
│ Hotel System │
│ │
│ ┌─────────────┐ Customer-Supplier ┌─────────────┐ │
│ │ Room │◄────────────────────────│ Reservation │ │
│ │ Management │ │ Context │ │
│ │ Context │ │ │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ │ Domain Events │ Domain Events │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │Housekeeping │ │ Payment │ │
│ │ Context │ │ Context │ │
│ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ┌─────────────┐ OHS/Published Language │ │
│ │ Guest │◄───────────────────────────────┤ │
│ │ Context │ │ │
│ └──────┬──────┘ │ │
│ │ │ │
│ │ Domain Events │ Domain Events │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Notification Context │ │
│ │ (Subscribes to all domain events) │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
Context 관계:
• Room ← Reservation: Customer-Supplier (예약이 객실 가용성 조회)
• Reservation → Payment: Domain Events (예약 생성 시 결제 요청)
• Reservation → Room: Domain Events (예약 확정 시 객실 상태 변경)
• Room → Housekeeping: Domain Events (체크아웃 시 청소 작업 생성)
• Guest → All: OHS (고객 정보 제공)
• All → Notification: Domain Events (모든 이벤트 구독하여 알림 발송)10.4 통신 구현 예시
// Reservation Context - 예약 생성
class ReservationService {
constructor(
private roomAvailabilityClient: RoomAvailabilityClient, // ACL
private guestClient: GuestClient, // ACL
private eventPublisher: DomainEventPublisher
) {}
async createReservation(command: CreateReservationCommand): Promise<Reservation> {
// 1. 객실 가용성 확인 (동기 - Room Context)
const availability = await this.roomAvailabilityClient.checkAvailability({
roomType: command.roomType,
checkIn: command.checkIn,
checkOut: command.checkOut
});
if (!availability.isAvailable) {
throw new RoomNotAvailableError(command.roomType, command.checkIn);
}
// 2. 고객 정보 조회 (동기 - Guest Context)
const guest = await this.guestClient.getGuest(command.guestId);
// 3. 가격 계산 (멤버십 할인 적용)
const pricing = this.calculatePricing(
availability.room,
command.checkIn,
command.checkOut,
guest.membership
);
// 4. 예약 생성
const reservation = Reservation.create({
guestId: command.guestId,
roomId: availability.room.id,
checkIn: command.checkIn,
checkOut: command.checkOut,
totalAmount: pricing.total,
status: ReservationStatus.PENDING
});
// 5. 도메인 이벤트 등록
reservation.addDomainEvent(new ReservationCreatedEvent({
reservationId: reservation.id,
guestId: reservation.guestId,
roomId: reservation.roomId,
checkIn: reservation.checkIn,
checkOut: reservation.checkOut,
totalAmount: reservation.totalAmount
}));
await this.reservationRepository.save(reservation);
await this.eventPublisher.publishAll(reservation.domainEvents);
return reservation;
}
}
// Payment Context - 이벤트 핸들러
@EventHandler(ReservationCreatedEvent)
class ProcessPaymentHandler {
async handle(event: ReservationCreatedEvent): Promise<void> {
// 결제 처리 시작
const payment = await this.paymentService.initiatePayment({
reservationId: event.reservationId,
amount: event.totalAmount,
guestId: event.guestId
});
// 결제 완료 이벤트 발행
await this.eventPublisher.publish(new PaymentCompletedEvent({
paymentId: payment.id,
reservationId: event.reservationId
}));
}
}
// Room Context - 이벤트 핸들러
@EventHandler(PaymentCompletedEvent)
class BlockRoomHandler {
async handle(event: PaymentCompletedEvent): Promise<void> {
const reservation = await this.reservationClient
.getReservation(event.reservationId);
// 객실 예약 블록
await this.roomService.blockRoom({
roomId: reservation.roomId,
checkIn: reservation.checkIn,
checkOut: reservation.checkOut,
reservationId: reservation.id
});
}
}
// Notification Context - 이벤트 핸들러
@EventHandler(PaymentCompletedEvent)
class SendConfirmationHandler {
async handle(event: PaymentCompletedEvent): Promise<void> {
const reservation = await this.reservationClient
.getReservation(event.reservationId);
const guest = await this.guestClient.getGuest(reservation.guestId);
await this.notificationService.send({
recipientId: guest.id,
channel: NotificationChannel.EMAIL,
template: 'reservation-confirmation',
data: {
guestName: guest.profile.name,
reservationId: reservation.id,
checkIn: reservation.checkIn,
checkOut: reservation.checkOut,
roomType: reservation.roomType
}
});
}
}💡 설계 포인트
- • 동기 vs 비동기: 가용성 확인은 동기(즉각 응답 필요), 결제/알림은 비동기(최종 일관성)
- • 데이터 소유권: 각 Context가 자신의 데이터만 소유, 다른 Context 데이터는 참조 ID만 저장
- • 이벤트 설계: 이벤트에 필요한 모든 정보 포함, 추가 조회 최소화
- • 장애 격리: 알림 실패가 예약 프로세스에 영향 주지 않음
11. 자주 묻는 질문 (FAQ)
Q: Bounded Context의 적절한 크기는?
A: 정해진 크기는 없지만, 다음 기준을 참고하세요:
- • 5-9명의 팀이 소유하고 운영할 수 있는 크기
- • 2주 스프린트 내에 의미 있는 기능을 배포할 수 있는 크기
- • 하나의 비즈니스 역량을 완전히 수행할 수 있는 크기
- • 유비쿼터스 언어가 일관되게 유지되는 크기
Q: 하나의 팀이 여러 Bounded Context를 소유해도 되나요?
A: 가능하지만 주의가 필요합니다.
- • 작은 조직에서는 불가피할 수 있음
- • Context 간 경계를 명확히 유지해야 함
- • 코드 리뷰에서 경계 침범을 감시
- • 조직이 성장하면 팀을 분리하는 것이 이상적
Q: Bounded Context와 마이크로서비스는 1:1 매핑해야 하나요?
A: 아니요, 반드시 그럴 필요는 없습니다.
- • Bounded Context는 논리적 경계, 마이크로서비스는 물리적 배포 단위
- • 하나의 서비스에 여러 Context가 있을 수 있음 (모듈러 모놀리스)
- • 하나의 Context가 여러 서비스로 분리될 수도 있음 (드물지만 가능)
- • 물리적 분리는 필요할 때만 (확장성, 독립 배포 등)
Q: Context 간에 데이터를 어떻게 공유하나요?
A: 직접 공유하지 않습니다. 대신:
- • 참조 ID: 다른 Context의 엔티티는 ID로만 참조
- • API 조회: 필요한 데이터는 공개 API로 조회
- • 이벤트 동기화: 이벤트를 통해 필요한 데이터 복제 (CQRS)
- • Shared Kernel: 정말 공유가 필요한 경우 최소한의 공유 모델
주의: 데이터베이스 직접 공유는 안티패턴입니다. 스키마 변경이 여러 Context에 영향을 주게 됩니다.
Q: 트랜잭션이 여러 Context에 걸쳐야 할 때는?
A: 분산 트랜잭션 대신 다음 패턴을 사용하세요:
- • Saga 패턴: 각 Context에서 로컬 트랜잭션 + 보상 트랜잭션
- • 최종 일관성: 이벤트 기반으로 결국 일관성 달성
- • 경계 재검토: 자주 함께 변경되면 같은 Context일 수 있음
// Saga 예시: 주문 생성 1. Order Context: 주문 생성 (PENDING) 2. Payment Context: 결제 처리 - 성공 → Order 확정 이벤트 - 실패 → Order 취소 이벤트 (보상) 3. Inventory Context: 재고 차감 - 실패 → 결제 환불 + Order 취소 (보상)
Q: 레거시 시스템과 어떻게 통합하나요?
A: Anti-Corruption Layer(ACL)를 사용하세요:
- • 레거시 모델이 새 Context를 오염시키지 않도록 번역 계층 구축
- • 새 Context는 깔끔한 도메인 모델 유지
- • ACL에서 모델 변환, 에러 처리, 복원력 패턴 적용
- • 점진적으로 레거시 기능을 새 Context로 마이그레이션
Q: Context 경계를 잘못 설정했다면?
A: 리팩토링하세요. 경계는 고정된 것이 아닙니다:
- • 너무 큰 Context: 내부에서 모듈로 먼저 분리, 필요시 Context 분할
- • 너무 작은 Context: 관련 Context 병합 고려
- • 잘못된 경계: 도메인 전문가와 다시 논의, Event Storming 재실행
팁: 모듈러 모놀리스로 시작하면 경계 조정이 쉽습니다. 마이크로서비스로 분리한 후에는 병합이 매우 어렵습니다.
핵심 요약
이번 세션에서 배운 것
- ✓Bounded Context는 언어적, 모델, 팀의 경계다
- ✓서브도메인은 문제 공간, Bounded Context는 솔루션 공간
- ✓9가지 Context Mapping 패턴으로 관계 정의
- ✓동기/비동기 통신의 적절한 선택 기준
- ✓ACL로 외부 시스템으로부터 도메인 보호
- ✓마이크로서비스 전에 논리적 경계가 먼저
- ✓Strangler Fig 패턴으로 점진적 마이그레이션
- ✓모듈러 모놀리스의 가치와 적용 시점
핵심 원칙
"경계를 명확히 하라. 모호한 경계는 Big Ball of Mud로 이어진다."
"물리적 분리보다 논리적 경계가 먼저다."
"Context 간 통신은 명시적이고 의도적이어야 한다."
다음 세션 예고
다음 세션에서는 Context Mapping 심화를 학습합니다.
- • Context Map 시각화 도구
- • 팀 토폴로지와 Context 매핑
- • 진화하는 Context Map 관리