Table of contents
- 경계를 그은 뒤의 문제
- 전체 Context Map
- Shared Kernel — 공유 핵심
- Customer-Supplier — 상류와 하류
- Conformist — 하류가 상류에 맞춤
- Anti-Corruption Layer — 변환 계층
- Open Host Service / Published Language — 공개 API
- Partnership — 동반 관계
- Separate Ways — 독립
- 패턴 선택 기준
- Context Map을 그리는 시점
- 핵심 정리
경계를 그은 뒤의 문제
2편에서 바운디드 컨텍스트로 시스템의 경계를 나누는 방법을 살펴봤다. 카탈로그, 주문, 배송, 결제 — 각각이 자기 언어와 모델을 가진 독립적인 세계가 되었다.
그런데 현실의 시스템은 섬이 아니다. 주문 컨텍스트는 카탈로그의 상품 정보가 필요하고, 배송 컨텍스트는 주문이 확정되었다는 신호를 받아야 한다. 결제 컨텍스트는 주문 금액을 알아야 결제를 진행할 수 있다. 경계를 나눴으면, 이제 그 경계 사이를 어떻게 연결할 것인가를 결정해야 한다.
이것이 컨텍스트 매핑(Context Mapping)이다. Context Map은 시스템 내 모든 바운디드 컨텍스트 사이의 관계를 시각화한 것이다. 단순히 A가 B를 호출한다 수준이 아니라, 두 컨텍스트 사이의 힘의 관계와 협력 방식을 명시한다.
Eric Evans는 여러 가지 관계 패턴을 정의했다. 각 패턴은 상황에 따라 선택하는 것이지, 어느 하나가 항상 우월한 것은 아니다.
전체 Context Map
먼저 이커머스 시스템의 전체 Context Map을 그려보자. 각 컨텍스트가 어떤 패턴으로 연결되는지 한눈에 볼 수 있다.
flowchart TB
subgraph map["Context Map — 이커머스"]
CAT["카탈로그<br/>Catalog"]
ORD["주문<br/>Ordering"]
PAY["결제<br/>Payment"]
SHIP["배송<br/>Shipping"]
NOTI["알림<br/>Notification"]
AUTH["인증<br/>Identity"]
EXT_PG["외부 PG사"]
EXT_CARRIER["외부 배송사"]
end
CAT -->|"OHS/PL"| ORD
ORD -->|"Customer-Supplier"| SHIP
ORD -->|"Customer-Supplier"| PAY
PAY -->|"ACL"| EXT_PG
SHIP -->|"ACL"| EXT_CARRIER
AUTH -->|"Shared Kernel"| ORD
AUTH -->|"Shared Kernel"| PAY
ORD -.->|"이벤트"| NOTI
SHIP -.->|"이벤트"| NOTI
이 다이어그램에서 실선은 동기적 연결을, 점선은 이벤트 기반의 비동기 연결을 나타낸다. 각 화살표 위의 라벨이 관계 패턴이다. 이제 각 패턴을 하나씩 뜯어보자.
Shared Kernel — 공유 핵심
Shared Kernel(공유 핵심)은 두 개 이상의 컨텍스트가 모델의 일부를 공유하는 패턴이다. 공유하는 부분은 양쪽 팀이 합의해서 관리한다.
가장 전형적인 예가 사용자 식별 정보다. 인증(Identity) 컨텍스트에서 정의한 UserId를 주문 컨텍스트와 결제 컨텍스트가 함께 사용하는 경우를 보자.
// shared-kernel 모듈: 양쪽 컨텍스트가 의존하는 공유 코드
package com.shop.shared.kernel
@JvmInline
value class UserId(val value: Long)
@JvmInline
value class Money(val amount: BigDecimal) {
operator fun plus(other: Money): Money = Money(amount + other.amount)
operator fun times(quantity: Int): Money = Money(amount * quantity.toBigDecimal())
companion object {
val ZERO = Money(BigDecimal.ZERO)
}
}
UserId와 Money는 여러 컨텍스트에서 동일한 의미로 사용된다. 이런 기초적인 값 객체(Value Object)를 각 컨텍스트가 따로 정의하면 변환 코드만 늘어나고 의미는 같다.
Shared Kernel의 핵심 규칙은 이렇다.
- 공유 범위를 최소한으로 유지한다. 공유하는 게 많을수록 결합도가 올라가서, 한쪽의 변경이 다른 쪽을 깨뜨릴 위험이 커진다
- 공유 코드의 변경은 양쪽 팀의 합의가 필요하다. 한 팀이 일방적으로 수정하면 안 된다
- 공유하는 것은 대개 값 객체, ID 타입, 기본 인터페이스 수준이다. 엔티티나 서비스를 공유하면 컨텍스트 경계가 무너진다
Shared Kernel은 팀 간 신뢰와 긴밀한 소통이 전제될 때 효과적이다. 두 팀이 서로의 코드 변경을 빠르게 감지하고 대응할 수 있어야 한다. 팀 사이의 거리가 멀다면 다른 패턴을 고려하는 편이 낫다.
Customer-Supplier — 상류와 하류
Customer-Supplier(고객-공급자)는 한쪽이 상류(Upstream, 공급자), 다른 쪽이 하류(Downstream, 고객)인 관계다. 상류가 API나 이벤트를 제공하고, 하류가 이를 소비한다.
주문 컨텍스트(상류)와 배송 컨텍스트(하류)의 관계가 전형적이다. 주문이 확정되면 배송이 시작되어야 한다. 배송 컨텍스트는 주문 컨텍스트가 제공하는 정보에 의존한다.
// 주문 컨텍스트 (상류): 주문 확정 시 이벤트 발행
package com.shop.ordering.application
class OrderService(
private val orderRepository: OrderRepository,
private val eventPublisher: DomainEventPublisher,
) {
fun confirmOrder(orderId: OrderId) {
val order = orderRepository.findById(orderId)
order.confirm()
orderRepository.save(order)
// 하류 컨텍스트들이 이 이벤트를 구독한다
eventPublisher.publish(
OrderConfirmedEvent(
orderId = order.id,
customerId = order.customerId,
lines = order.snapshotLines(),
totalAmount = order.totalAmount(),
)
)
}
}
배송 컨텍스트(하류)는 이 이벤트를 받아서 자기 모델로 변환한다.
// 배송 컨텍스트 (하류): 주문 확정 이벤트를 수신하여 배송 준비
package com.shop.shipping.application
class ShipmentEventHandler(
private val shipmentRepository: ShipmentRepository,
) {
fun onOrderConfirmed(event: OrderConfirmedEvent) {
val parcels = event.lines.map { line ->
// 주문 항목을 배송 모델로 변환
Parcel(
productId = line.productId,
quantity = line.quantity,
// 무게, 부피 등은 배송 컨텍스트의 상품 마스터에서 조회
)
}
val shipment = Shipment.create(
orderId = event.orderId,
parcels = parcels,
)
shipmentRepository.save(shipment)
}
}
Customer-Supplier 관계에서 핵심은 상류가 하류의 요구를 존중하는가이다. 건강한 관계에서는 상류 팀이 하류 팀의 요구사항을 백로그에 반영하고, API 변경 시 사전에 알린다. 상류가 하류를 무시하면 하류는 고통받게 되는데, 그런 상황에서는 다음에 나올 Conformist나 ACL 패턴이 필요해진다.
Conformist — 하류가 상류에 맞춤
Conformist(순응자)는 하류가 상류의 모델을 그대로 따르는 패턴이다. 상류가 제공하는 API나 데이터 형식에 맞춰서, 하류가 자기 모델을 상류의 것에 종속시킨다.
언제 이 패턴을 선택하는가? 상류가 외부 시스템이거나 다른 조직이어서, 우리가 영향력을 행사할 수 없을 때다.
예를 들어, 사내 결제 컨텍스트가 외부 PG사의 API를 사용한다고 해보자. PG사가 응답으로 보내주는 필드명이 tran_cd, app_no, amt 같은 약어투성이라도, 우리가 “이름 좀 바꿔주세요”라고 요청할 수는 없다.
Conformist를 선택하면 이런 코드가 된다.
// Conformist: 외부 PG사의 응답 형식을 그대로 수용
public class PgPaymentResponse {
private String tran_cd; // PG사 약어 그대로
private String app_no;
private String amt;
private String res_cd;
// PG사 모델을 그대로 쓴다. 변환하지 않는다.
}
이 접근은 단순하다는 장점이 있지만, 외부 모델이 내부 코드에 침투하는 단점이 있다. PG사가 필드명을 바꾸면 내부 코드 전체가 영향받는다. 이것이 불편하다면 다음 패턴이 답이다.
Anti-Corruption Layer — 변환 계층
Anti-Corruption Layer(부패 방지 계층, 이하 ACL)는 외부 모델이 내부 도메인 모델을 오염시키지 않도록 변환 계층을 두는 패턴이다. DDD에서 가장 실용적이고 자주 쓰이는 패턴 중 하나다.
Conformist가 상류의 모델을 그대로 쓴다였다면, ACL은 상류의 모델을 내 언어로 번역한다에 해당한다.
// Anti-Corruption Layer: 외부 PG 응답을 내부 도메인 모델로 변환
package com.shop.payment.infrastructure.pg
class PgAntiCorruptionLayer(
private val pgClient: PgClient,
) {
fun requestPayment(request: PaymentRequest): PaymentResult {
// 1. 내부 모델 → 외부 모델 변환
val pgRequest = PgPaymentRequest(
mchtId = request.merchantId.value,
amt = request.amount.amount.toString(),
goodsNm = request.description,
ordNo = request.orderId.value.toString(),
)
// 2. 외부 시스템 호출
val pgResponse = pgClient.pay(pgRequest)
// 3. 외부 모델 → 내부 모델 변환
return PaymentResult(
transactionId = TransactionId(pgResponse.tran_cd),
approvalNumber = ApprovalNumber(pgResponse.app_no),
amount = Money(BigDecimal(pgResponse.amt)),
status = mapStatus(pgResponse.res_cd),
)
}
private fun mapStatus(resCd: String): PaymentStatus =
when (resCd) {
"0000" -> PaymentStatus.APPROVED
"0001" -> PaymentStatus.PENDING
else -> PaymentStatus.REJECTED
}
}
ACL의 구조를 뜯어보면 세 단계로 나뉜다.
- 내부 모델을 외부 모델로 변환 — 내부의
PaymentRequest를 PG사가 이해하는PgPaymentRequest로 바꾼다 - 외부 시스템 호출 — PG사 API를 호출한다
- 외부 모델을 내부 모델로 변환 — PG사의 응답을 내부의
PaymentResult로 번역한다
이 계층 덕분에 내부 도메인 모델은 외부 시스템의 존재를 모른다. PG사를 교체해도 ACL만 수정하면 된다. tran_cd가 transaction_code로 바뀌어도 도메인 코드는 영향받지 않는다.
ACL은 외부 서드파티뿐 아니라 레거시 시스템과의 통합에서도 자주 쓰인다. 오래된 시스템의 기괴한 데이터 형식이 새 시스템의 도메인 모델을 오염시키지 않도록 경계를 만들어준다.
Open Host Service / Published Language — 공개 API
Open Host Service(OHS, 공개 호스트 서비스)는 상류 컨텍스트가 하류 컨텍스트들을 위해 잘 정의된 API를 공개하는 패턴이다. 여기에 Published Language(PL, 공표된 언어)가 결합되면, API의 데이터 형식까지 표준화한다.
카탈로그 컨텍스트가 여러 하류 컨텍스트(주문, 검색, 추천 등)에게 상품 정보를 제공하는 상황을 생각해보자. 각 하류 컨텍스트마다 별도의 API를 만들면 유지보수가 끔찍해진다. 대신, 카탈로그가 하나의 잘 설계된 API를 공개하고 모든 하류가 이를 사용하게 만든다.
// Open Host Service: 카탈로그 컨텍스트의 공개 API
package com.shop.catalog.api
@RestController
@RequestMapping("/api/catalog")
class CatalogOpenHostService(
private val productQueryService: ProductQueryService,
) {
// Published Language: 표준화된 응답 형식
@GetMapping("/products/{id}")
fun getProduct(@PathVariable id: Long): ProductDto {
val product = productQueryService.findById(ProductId(id))
return ProductDto(
id = product.id.value,
name = product.name,
description = product.description,
price = product.price.amount,
category = product.category.name,
status = product.displayStatus.name,
)
}
}
// Published Language: 외부에 공표하는 데이터 형식
data class ProductDto(
val id: Long,
val name: String,
val description: String,
val price: BigDecimal,
val category: String,
val status: String,
)
OHS/PL의 핵심은 하류 컨텍스트의 요구를 일일이 맞추지 않는다는 것이다. 대신 범용적이고 안정적인 API를 제공하며, 하류가 자기 필요에 맞게 변환해서 사용한다. REST API, gRPC, GraphQL, 이벤트 스키마 모두 Open Host Service의 구현 수단이 될 수 있다.
Partnership — 동반 관계
Partnership(동반 관계)은 두 컨텍스트의 팀이 대등한 위치에서 긴밀하게 협력하는 패턴이다. Customer-Supplier와 달리 상류/하류 구분이 없다. 두 팀이 함께 일정을 맞추고, API 변경을 상호 조율하며, 실패와 성공을 함께 나눈다.
주문 팀과 결제 팀이 새로운 할인 정책을 동시에 구현해야 하는 상황이 예가 된다. 주문 쪽에서 할인 적용 로직을 만들면서 결제 쪽에서 할인된 금액을 처리하는 로직을 함께 만드는 식이다.
이 패턴은 두 팀이 같은 사무실에 있거나, 같은 스프린트 주기를 공유하거나, 자주 얼굴을 맞대는 관계일 때 작동한다. 조직적으로 거리가 있으면 유지하기 어렵다.
Separate Ways — 독립
Separate Ways(독립)는 두 컨텍스트가 아예 연결되지 않는 패턴이다. 통합 비용이 통합으로 얻는 이점보다 클 때 선택한다.
예를 들어, 이커머스의 알림 컨텍스트와 카탈로그 컨텍스트는 직접적인 연결이 필요 없을 수 있다. 카탈로그 변경이 알림에 영향을 주지 않고, 알림 시스템이 카탈로그를 참조할 일도 없다면, 연결하지 않는 것이 가장 단순하다.
Separate Ways는 통합하지 않기로 한 의식적 결정이다. 그냥 연결을 안 한 것과는 다르다. 이 두 컨텍스트는 독립적이며, 연결할 필요가 없다는 것을 명시하는 것 자체에 가치가 있다.
패턴 선택 기준
어떤 패턴을 언제 쓸 것인가? 다음 표는 상황별 가이드다.
| 상황 | 추천 패턴 |
|---|---|
| 양쪽 팀이 긴밀하고 공유할 모델이 작다 | Shared Kernel |
| 상류가 하류의 요구를 수용할 의지가 있다 | Customer-Supplier |
| 상류가 외부 시스템이고 영향력이 없다 | Conformist 또는 ACL |
| 외부 모델이 내부를 오염시킬 위험이 있다 | ACL |
| 여러 하류에게 범용 API를 제공해야 한다 | OHS / PL |
| 두 팀이 대등하게 함께 개발한다 | Partnership |
| 통합 비용이 이점보다 크다 | Separate Ways |
실무에서는 하나의 시스템 안에 여러 패턴이 공존한다. 어떤 경계에는 Shared Kernel을, 다른 경계에는 ACL을, 또 다른 경계에는 Customer-Supplier를 적용하는 것이 자연스럽다. 모든 관계에 같은 패턴을 강제하는 것은 오히려 부자연스럽다.
Context Map을 그리는 시점
Context Map은 프로젝트의 어느 시점에 그리는가?
이상적으로는 프로젝트 초기에 그린다. 바운디드 컨텍스트를 식별하고, 각 컨텍스트 사이의 관계를 먼저 정의해두면 팀 간 인터페이스가 명확해진다. 하지만 현실적으로는 시스템이 어느 정도 구체화된 뒤에야 맥락이 보이기 시작한다.
이미 운영 중인 시스템에 적용할 때는 있는 그대로의 Context Map을 먼저 그려보는 것이 좋다. 현재 어떤 모듈이 어떤 모듈에 의존하고, 그 관계가 건강한지 아닌지를 파악하는 것이 첫 단계다. 현재 상태가 Conformist인데 ACL로 바꿔야 한다는 판단이 나올 수도 있고, 연결이 너무 많아서 Separate Ways로 끊어야 한다는 결론이 나올 수도 있다.
Context Map은 한 번 그려서 벽에 붙여두는 문서가 아니다. 시스템이 진화하면서 함께 갱신되어야 한다. 새로운 컨텍스트가 추가되거나, 팀 구조가 바뀌거나, 외부 시스템이 교체될 때마다 관계 패턴이 달라질 수 있다.
핵심 정리
이 편에서 다룬 내용을 정리하면 이렇다.
- Context Map은 바운디드 컨텍스트 사이의 관계를 시각화한 것이다
- Shared Kernel: 양쪽이 합의하에 모델의 일부를 공유한다. 범위를 최소로 유지해야 한다
- Customer-Supplier: 상류가 API를 제공하고 하류가 소비한다. 상류가 하류의 요구를 존중하는 것이 전제다
- Conformist: 하류가 상류의 모델을 그대로 따른다. 외부 시스템에 영향력이 없을 때 선택한다
- Anti-Corruption Layer: 외부 모델을 내부 모델로 변환하는 계층을 둔다. 가장 실용적인 패턴이다
- Open Host Service / Published Language: 범용 API와 표준 데이터 형식을 공개한다
- Partnership: 두 팀이 대등하게 협력한다
- Separate Ways: 통합하지 않기로 한 의식적 결정이다
다음 편에서는 전술적 설계의 첫 번째 빌딩 블록인 엔티티(Entity)와 값 객체(Value Object)를 다룬다. 식별성으로 구분되는 객체와 값으로만 비교되는 객체의 차이, 그리고 이 구분이 왜 중요한지 살펴보자.




Loading comments...