[DDD] 전략적 설계: Bounded Context와 Context Mapping, 그리고 헥사고날 아키텍처
전략적 설계가 다루는 문제
DDD(Domain-Driven Design)는 복잡한 소프트웨어를 도메인 중심으로 설계하는 방법론이다. 이 방법론은 전략적 설계와 전술적 설계 두 축으로 나뉜다. 전략적 설계는 시스템 전체의 큰 그림을 다루고, 전술적 설계는 한 경계 안 내부 구현을 다룬다. 회사가 가진 비즈니스 영역이 너무 크고 복잡할 때 그것을 어떻게 자를 것인가. 자른 조각들이 같은 단어를 다르게 쓰고 있을 때 그 경계를 어떻게 명시할 것인가. 잘려 나간 조각들이 서로 협력해야 할 때 그 협력 방식을 어떤 패턴으로 표현할 것인가. 그리고 이렇게 그은 경계를 실제 코드가 지키게 하려면 무엇으로 강제할 것인가. 이 네 질문이 각각 도메인과 서브도메인, Bounded Context, Context Mapping, 헥사고날 아키텍처로 이어진다.
| 개념 | 정의 |
|---|---|
| 유비쿼터스 언어(Ubiquitous Language) | 도메인 전문가와 개발자가 공유하는 공통 용어 체계 |
| 서브도메인(Subdomain) | 문제 공간을 분해한 단위. Core, Supporting, Generic 세 유형으로 분류 |
| Bounded Context(BC) | 특정 모델과 언어가 적용되는 해결 공간의 경계 |
| Context Mapping | BC 사이의 협력 관계를 명시하는 9가지 패턴 |
| Anti-Corruption Layer(ACL) | 외부 모델을 자기 도메인 모델로 번역하는 보호 계층 |
유비쿼터스 언어
유비쿼터스 언어는 도메인 전문가와 개발자가 공유하는 공통 언어다. 코드, 문서, 회의 발언, 데이터베이스 컬럼명까지 같은 용어를 쓴다. 이커머스에서 마케팅팀이 구매 요청이라고 부르는 것과 물류팀이 배송 지시서라고 부르는 것이 실은 같은 사건일 때, 통합되지 않은 용어는 그대로 시스템 안에 애매한 사항으로 남는다. 유비쿼터스 언어는 동의이지만 다른 언어들을 없애고, 하나로 합치는 과정이다.
// 잘못된 예: 개발 용어와 도메인 용어가 분리되어 있다
class DataProcessor {
fun updateRecord(id: Long, flag: Int) {
record.setFlag(flag) // flag 가 무엇을 의미하는지 코드만 봐서는 알 수 없다
}
}
// 올바른 예: 유비쿼터스 언어가 그대로 코드에 박힌다
class Order(/* ... */) {
fun cancel(reason: String) {
require(status == OrderStatus.CONFIRMED) { "확정된 주문만 취소할 수 있다" }
status = OrderStatus.CANCELLED
cancellationReason = reason
cancelledAt = ZonedDateTime.now()
registerEvent(OrderCancelledEvent(id, reason))
}
}유비쿼터스 언어는 Bounded Context마다 다를 수 있다. 카탈로그 컨텍스트의 상품과 주문 컨텍스트의 상품은 같은 단어지만 다른 의미를 가진다.
도메인과 서브도메인
도메인은 소프트웨어가 다루는 비즈니스 영역 전체다. 이걸 관리 가능한 크기로 분해한 것이 서브도메인이다. DDD에서 서브도메인은 상대적 용어라, 모든 서브도메인이 그 자체로 도메인이고 대부분의 도메인은 상위 도메인의 서브도메인이다. 서브도메인은 문제 공간(Problem Space)에 속한다. 우리가 풀어야 할 비즈니스 문제 자체를 분류하는 단위다. 이와 대비되는 해결 공간(Solution Space)에 있는 것이 Bounded Context다. 이상적으로는 1 서브도메인 = 1 Bounded Context지만, 복잡한 서브도메인은 여러 BC로 나뉠 수 있고 반대도 가능하다.
| 유형 | 정의 | 투자 방식 | 예시 |
|---|---|---|---|
| 핵심 도메인(Core) | 회사의 경쟁 우위를 만드는 영역 | 최고 인력, DDD 전면 적용, 자체 개발 | 배달 매칭 알고리즘 |
| 지원 서브도메인(Supporting) | Core 를 지원하지만 차별화 요소는 아님 | 적절한 수준, 자체/외주 가능 | 재고 관리, 분쟁 해결 |
| 일반 서브도메인(Generic) | 누구나 같은 방식으로 쓰는 범용 영역 | 기성 솔루션 구매, 오픈소스 활용 | 인증/인가, 결제 게이트웨이 |
같은 서브도메인이 회사에 따라 유형이 달라진다는 점이 중요하다. 스케줄링은 일반 회사에서는 Supporting이지만 스케줄링 SaaS 회사에는 Core Domain이다. 분류는 이 기능이 시장에서 우리를 차별화하는가라는 질문 하나로 정한다.
디스틸레이션
디스틸레이션(Distillation)은 큰 시스템에서 Core Domain을 명확히 식별하고 분리하는 전략적 활동이다. 큰 모델 안에서 비즈니스 가치의 본질이 어디에 있는지를 추출하는 과정이다.
디스틸레이션의 단계는 다음과 같다. 도메인 비전 선언문(Domain Vision Statement)으로 Core Domain의 가치를 1페이지 이내로 정의해 팀 전체가 공유한다. 코드에서 Core 요소를 명시적으로 표시한다(Highlighted Core). 비핵심 요소를 별도 모듈로 추출해 Core의 복잡도를 낮춘다(Segregated Core). P2P 대여 마켓플레이스를 예로 들면, 초기에 모든 로직이 하나의 RentalPlatformService에 뒤섞여 있다가 디스틸레이션 후에는 핵심 세 영역(물품 등록, 대여 거래, 신뢰·평판)이 Core로 분리되고, 검색·예약·분쟁은 Supporting으로, 결제·알림·채팅·회원은 Generic으로 나눌 수 있다.
Bounded Context
Bounded Context(BC)는 특정 도메인 모델과 유비쿼터스 언어가 적용되는 명시적 경계다. 같은 상품(Product)이라는 단어가 카탈로그 컨텍스트에서는 이름, 설명, 카테고리, 정가, 스펙 같은 속성을 가진 풍부한 객체지만, 주문 컨텍스트에서는 상품ID, 확정 가격, 수량만 들어 있는 가벼운 객체다. 배송 컨텍스트에서는 상품ID, 무게, 부피, 파손 위험만 본다. 이 셋을 하나의 거대한 Product로 통합하려는 시도는 매번 실패한다. 각 컨텍스트는 자기 관점에서만 풍부한 모델을 가지고, 컨텍스트 사이에서는 ID와 최소 정보만 오간다.
Bounded Context 식별 휴리스틱
- 언어 경계. 같은 단어가 다른 의미로 쓰이거나, 대화 주제가 바뀌는 지점이 가장 강한 신호다. 그 단어, 여기서는 다른 뜻이에요 라는 말이 나오면 BC 후보다.
- 비즈니스 역량. 독립적인 비즈니스 기능 단위. 이건 우리 팀 소관이 아닙니다 라는 답이 돌아오면 BC 후보다.
- 팀 경계. Conway의 법칙대로 하나의 BC는 하나의 팀이 소유한다. 여러 팀이 한 BC를 수정하면 분리 신호다.
- 데이터 소유권. 이 데이터의 원본(Source of Truth)을 누가 관리하는가라는 질문에 명확한 답이 나오면 그 단위가 BC다.
orders, products, payments 테이블이 있으니 BC도 셋이라는 식으로 DB 테이블 단위로 Context 를 나누는 것이 가장 자주 보이는 오류다. BC는 유비쿼터스 언어의 적용 범위로 정의되지, 영속성 구조나 기술 레이어(API, DB, MQ)로 정의되지 않는다. 같은 products 테이블이 카탈로그 BC의 풍부한 상품 모델과 주문 BC의 가벼운 주문 항목 모델로 동시에 매핑될 수 있다. 처음부터 완벽하게 경계를 나누는 시도보다 빠르게 그어보고 운영 데이터로 수정하는 흐름이 옳다.
Bounded Context와 마이크로서비스
Microsoft Azure 가이드는 Aggregate가 마이크로서비스보다 작거나 같고, 마이크로서비스는 Bounded Context보다 작거나 같아야 한다는 원칙을 제시한다. 마이크로서비스는 최소 1개 Aggregate이상을 포함하고, 최대 1개 BC를 넘지 않게 설계하라는 의미다.
| 매핑 | 의미 |
|---|---|
| 1 BC = 1 마이크로서비스 | 이상적. BC 경계와 서비스 배포 경계가 일치 |
| 1 BC = N 마이크로서비스 | 큰 BC 를 더 잘게 분리 (트래픽 분리, 스케일 분리) |
| N BC = 1 모놀리스 | 모듈러 모놀리스. 같은 프로세스에 여러 BC 가 모듈로 공존 |
처음부터 마이크로서비스로 쪼개기 시작하면 BC 경계가 충분히 명확하지 않은 상태에서 인프라 비용만 무거워진다. 모듈러 모놀리스(Modular Monolith)로 시작해 BC 경계만 코드 모듈로 명확히 두고, 비즈니스적 분리 필요성이 증명되는 시점에 BC 단위로 마이크로서비스로 떼어내는 순서가 안전하다. 모놀리스에서 시작해도 BC 경계만 잘 지키면 나중에 분리하기 쉽다. 반대로 BC 경계를 무시한 채 짠 모놀리스는 분리할 때 어디서부터 잘라야 할지 알 수 없는 상태가 된다.
Context Mapping
여러 Bounded Context가 서로 협력해 시스템을 이룬다. Context Map은 그 협력 관계를 명시적으로 표현한 지도다.
협력적 패턴
Partnership은 두 팀이 대등한 관계로 협력하는 패턴이다. 한쪽의 변경이 다른 쪽에 영향을 주므로 인터페이스를 공동 설계한다. 주문팀과 결제팀이 주문-결제 연동 API를 공동 설계하고, 한쪽이 스펙을 변경할 때 반드시 양측 합의 후 배포하는 식이다. 최고의 협업 품질을 얻지만, 양 팀의 배포 주기가 강하게 결합된다. 팀 규모가 커지면 유지하기 어렵다.
Shared Kernel은 두 BC가 도메인 모델의 일부를 공유하는 패턴이다. 주문 컨텍스트와 배송 컨텍스트가 Address 값 객체를 공유 라이브러리(common-domain)로 관리하는 경우다. 교집합이 되는 부분은 합의 없이 변경할 수 없으므로 이 영역은 최소한으로 유지해야 한다. 공유 라이브러리에 도메인 모델을 넣는 순간 양 팀의 모든 변경에 합의가 필요해진다. 처음에는 깔끔해 보이지만 시간이 지나면 합의 비용이 폭발하고 사실상 모놀리스로 회귀한다. Money, Currency 정도의 정말 안정적인 값 객체만 Shared Kernel에 두는 것이 좋다.
상류-하류 패턴
Customer-Supplier는 상류(Supplier)가 하류(Customer)에게 API를 제공하고, 하류의 요구사항을 상류가 수용할 의향이 있는 패턴이다. 상품 컨텍스트(Supplier)가 상품 정보 API를 제공하고, 전시 컨텍스트(Customer)가 이를 소비한다. 전시팀이 할인율 필드 추가를 요청할 수 있지만 상품팀의 로드맵에 따라 반영 시기가 결정된다. 역할이 명확해 의사결정이 빠르지만, 하위 팀의 긴급 요구가 상위 팀 우선순위에 밀려 병목이 되기도 한다.
Conformist는 하류가 상류 모델을 그대로 따르는 패턴이다. 협상력이 없을 때 쓴다. PG사(토스페이먼츠 등)가 제공하는 결제 응답 모델을 우리 결제 컨텍스트가 그대로 수용하는 경우다. 구현 비용이 가장 낮지만 외부 모델에 완전히 종속되므로, 상위 측이 호환성 깨는 변경을 하면 우리 도메인 전체가 영향을 받는다.
Anti-Corruption Layer는 하류가 번역 계층(Adapter, Translator, Facade)을 두어 자기 모델을 보호하는 패턴이다. 외부 시스템과 통합할 때 외부 모델을 자기 도메인 모델로 변환해주므로 도메인이 오염되지 않는다. 외부 변경으로부터 도메인을 보호하지만, 변환 계층의 개발·유지 비용이 든다.
// 외부 결제 API 의 응답 (외부 모델)
data class PgPaymentResponse(
val txnId: String,
val amt: Long,
val ccy: String,
val stat: Int,
val mthd: String,
)
// ACL: 외부 모델을 내부 도메인 모델로 변환. 아웃바운드 어댑터(~GatewayImpl)
@Component
class PaymentGatewayImpl(
private val pgClient: PgApiClient,
) : PaymentGateway {
override fun requestPayment(order: Order): Payment {
val response = pgClient.pay(
amount = order.totalAmount.value,
currency = order.currency.code,
)
return toDomain(response)
}
private fun toDomain(res: PgPaymentResponse): Payment = Payment(
id = PaymentId.of(res.txnId),
amount = Money.of(res.amt, Currency.of(res.ccy)),
method = PaymentMethod.from(res.mthd),
status = mapStatus(res.stat),
)
private fun mapStatus(stat: Int): PaymentStatus = when (stat) {
0 -> PaymentStatus.PENDING
1 -> PaymentStatus.COMPLETED
2 -> PaymentStatus.FAILED
else -> throw UnknownPaymentStatusException(stat)
}
}ACL 안에서 외부 API 호출이 캡슐화되고, 외부 모델이 내부 도메인 모델로 변환되고, 외부 시스템의 장애가 격리된다. 외부 PG가 토스페이먼츠에서 다른 회사로 바뀌어도 PaymentGateway 인터페이스를 구현한 새 어댑터만 갈아 끼우면 된다. 반대로 Big Ball of Mud처럼 경계가 불분명한 레거시와 통합할 때도 ACL이 방어선이다. 레거시를 곧장 정리하는 것은 어려우므로 신규 BC는 ACL을 통해 레거시와 통신하고 레거시 안에는 새 비즈니스 로직을 넣지 않는다. 시간이 지나며 레거시가 점점 작아지고 결국 ACL 안에서만 살아남는 상태로 수렴시키는 것이 현실적인 접근이다.
Open Host Service는 상류가 공개 프로토콜과 API를 정의해 다수 하류에 제공하는 패턴이다. RESTful API, gRPC 서비스가 여기 속한다. 회원 컨텍스트가 회원 조회 API를 공개하고 주문·배송·마케팅 등 여러 컨텍스트가 동일한 API를 호출하는 경우다. 여러 소비자에게 일관된 인터페이스를 제공하지만, API 버전 관리 부담이 생긴다.
// 회원 컨텍스트가 제공하는 OHS 컨트롤러. 인바운드 어댑터(~ApiController)
@RestController
@RequestMapping("/api/v1/members")
class MemberApiController(
private val memberQueryService: MemberQueryService,
) {
@GetMapping("/{memberId}")
fun getMember(@PathVariable memberId: Long): MemberPublicView {
// 공개 모델은 내부 도메인과 분리해 명시적으로 노출 필드를 통제
return memberQueryService.findPublicView(memberId)
}
}
// 공개 응답 모델은 내부 Member Aggregate 와 다르다
data class MemberPublicView(
val id: Long,
val displayName: String,
val gradeCode: String, // 내부 enum 그대로가 아니라 안정적인 문자열 코드
val verifiedAt: ZonedDateTime?,
// 내부 전용 필드(주민번호, 결제 수단)는 절대 노출하지 않는다
)내부 도메인 모델(Member Aggregate)과 응답 모델(MemberPublicView)을 분리하는 것이 핵심이다. 내부 모델을 직접 직렬화해 노출하면, 도메인 모델의 어떤 작은 변경도 외부 호환성에 직접 영향을 준다. /v1·/v2 같은 URL 버전이나 Accept-Version 헤더 등 버전 관리 정책을 API 설계와 동시에 정한다.
Published Language는 BC 간 데이터 교환을 위한 표준 형식(JSON Schema, Protobuf, Avro 등)을 정의하는 패턴이다. 주문 이벤트를 Kafka로 발행할 때 Avro 스키마를 Schema Registry에 등록하고, 배송·정산 등 소비자 컨텍스트가 동일한 스키마로 역직렬화한다. 표준 형식으로 느슨한 결합을 달성하지만 스키마 진화(evolution) 관리가 필요하다.
# Avro 스키마 예시 (Schema Registry 등록 대상)
type: record
name: RentalApproved
namespace: com.example.rental.v1
fields:
- name: rentalId
type: long
- name: renterId
type: long
- name: productId
type: long
- name: approvedAt
type:
type: long
logicalType: timestamp-millis
- name: schemaVersion
type: int
default: 1schemaVersion같은 필드를 처음부터 두는 것이 스키마 진화를 안전하게 한다. Backward Compatibility(새 소비자가 옛 데이터를 읽을 수 있어야 함)와 Forward Compatibility(옛 소비자가 새 데이터를 읽을 수 있어야 함) 중 어느 쪽을 지킬지를 정해야 한다. 보통 두 가지 모두를 보장하는 Full Compatibility가 Kafka 기반 이벤트에서 권장된다.
비통합 패턴
Separate Ways는 통합하지 않기로 결정하는 패턴이다. 비용 대비 효과가 없을 때 각자 독립적으로 구현한다. 주문 컨텍스트와 CS 컨텍스트 모두 고객 정보가 필요하지만 통합 비용이 크면 각자 자체 고객 테이블을 두는 식이다. 완전한 자율성을 얻지만 데이터 정합성이 깨질 수 있다.
Big Ball of Mud는 경계가 불분명한 혼합 모델이다. 이상적이지 않지만 현실에서 가장 자주 보이는 패턴이다. 새 코드를 여기에 더 얹지 말고 앞서 설명한 ACL로 격리한 뒤 점진적으로 개선할 수 있다.
9가지 패턴 비교
| 패턴 | 결합도 | 적용 신호 | 비고 |
|---|---|---|---|
| Partnership | 매우 높음 | 두 팀이 함께 성공/실패하는 핵심 협력 | 양 팀 배포 주기 결합 |
| Shared Kernel | 높음 | 공유 모델이 정말 작고 안정적일 때 | 합의 비용 큼 |
| Customer-Supplier | 중간 | 의사결정 권한이 명확한 상하 관계 | 우선순위 충돌 가능 |
| Conformist | 낮음 (하류 자율성) | 협상력이 없는 외부 시스템 | 도메인 오염 위험 |
| Anti-Corruption Layer | 낮음 | 외부 모델로부터 도메인 보호 필요 | 가장 자주 쓰임 |
| Open Host Service | 낮음 | 다수 소비자에게 공개 API | 버전 관리 부담 |
| Published Language | 낮음 | 비동기 이벤트 표준 형식 | 스키마 진화 필요 |
| Separate Ways | 없음 | 통합 비용이 가치를 초과 | 정합성 책임은 개별 |
| Big Ball of Mud | 혼돈 | 레거시, 정리 어려운 시스템 | ACL 로 격리 |
Context Mapping을 코드 경계로 옮기기: 헥사고날 아키텍처
Context Map을 그리는 것과 그 경계를 코드로 지키는 것은 별개의 문제다. Context Map이 문서로만 존재하고 패키지 구조는 아무 BC나 서로 import 할 수 있는 상태라면, 문서와 코드는 곧 어긋난다. 헥사고날 아키텍처(Hexagonal Architecture, Ports & Adapters)는 Bounded Context 하나를 헥사곤 하나로 대응시켜 이 어긋남을 컴파일 타임에 막는다. 클린 아키텍처(Robert C. Martin), 어니언 아키텍처(Jeffrey Palermo) 도 같은 아이디어의 변형이다. BC의 경계가 곧 헥사곤의 바깥 경계다. 헥사곤 안쪽에는 그 BC의 도메인 모델과 애플리케이션 로직만 있고, 다른 BC·외부 시스템·HTTP(HyperText Transfer Protocol) 클라이언트·메시지 브로커 같은 바깥 세계와의 접촉은 전부 헥사곤 경계 위의 포트(Port)와 어댑터(Adapter)를 거친다.
레이어와 의존 방향
헥사곤 내부는 네 레이어로 나뉜다. presentation은 application을 부르고, application은 domain 을 부른다. infrastructure는 반대로 domain이 정의한 인터페이스 쪽을 향해 의존한다.
| 레이어 | 책임 | 의존 대상 | 금지 |
|---|---|---|---|
| presentation | Controller, Kafka Consumer/EventListener, 요청을 애플리케이션 형태로 변환 | application | 비즈니스 로직 |
| application | UseCase 단위 오케스트레이션 | domain | Repository·Gateway·DomainEventPublisher 직접 참조 |
| domain | 순수 비즈니스 로직, Repository·Gateway·DomainEventPublisher 인터페이스 정의 | (없음) | infrastructure 참조, 다른 BC 패키지 import |
| infrastructure | domain 인터페이스의 구현체, DB·외부 API·메시지 브로커 어댑터 | domain | 해당 없음 |
domain 레이어는 아무것도 import 하지 않는다. 실제 호출은 domain이 infrastructure의 구현체를 부르는 방향으로 흐르지만, 컴파일 타임 의존(누가 누구를 아는가)은 반대로 infrastructure가 domain을 향한다. 이 역전이 의존성 역전 원칙(Dependency Inversion Principle)이다.
Context Mapping 패턴이 어느 어댑터로 구현되는가
| Context Mapping 패턴 | 헥사고날 대응 | 코드 위치 |
|---|---|---|
| Anti-Corruption Layer | 아웃바운드 어댑터. ~Gateway 포트를 구현한 ~GatewayImpl 이 외부 모델을 도메인 모델로 번역 | infrastructure |
| Open Host Service | 인바운드 어댑터. ~ApiController 가 표준 API 를 공개하고 소비자가 누구인지는 모른다 | presentation |
| Published Language | 메시지 어댑터. 발행은 DomainEventPublisher 구현체, 구독은 ~EventWorker | 발행 infrastructure, 구독 presentation |
| Conformist | 어댑터가 없는 상태. 외부 응답 모델이 번역 없이 domain·application 까지 그대로 흘러든다 | 경계 붕괴 |
| Shared Kernel | 두 헥사곤이 도메인 코드 일부를 포트·어댑터 없이 직접 공유하는 유일한 예외 | domain (예외) |
포트와 어댑터: 이름 규칙과 경계선
| 요소 | 위치 | 이름 규칙 |
|---|---|---|
| Repository (포트) | domain | ~Repository.kt (interface) |
| Gateway (포트) | domain | ~Gateway.kt (interface) |
| DomainEventPublisher (포트) | domain | DomainEventPublisher.kt (interface) |
| Repository 구현 (어댑터) | infrastructure | ~RepositoryImpl.kt |
| Gateway 구현 (어댑터) | infrastructure | ~GatewayImpl.kt |
| Publisher 구현 (어댑터) | infrastructure | KafkaDomainEventPublisher.kt 등 |
| Controller (인바운드 어댑터) | presentation | ~ApiController.kt |
| EventWorker (인바운드 어댑터) | presentation | ~EventWorker.kt |
Repository와 Gateway는 둘 다 아웃바운드 포트지만 향하는 곳이 다르다. Repository는 이 BC 자신의 데이터를 영속화하고, Gateway는 BC 바깥의 시스템을 호출한다. 앞의 PaymentGatewayImpl 이 아웃바운드 어댑터, MemberApiController 가 인바운드 어댑터다.
가장 잘못된 것은 다른 BC를 동기로 호출하면서 그 호출을 Gateway로 감싸 내부 협력을 외부 호출처럼 위장하는 것이다. 결제 BC가 주문·예약·티켓의 확정 로직을 하나의 GatewayImpl 안에서 분기해 부른다면, 이름만 Gateway이고 실제로는 공용 컨텍스트가 자신을 쓰는 컨텍스트를 역참조한다. 의존 방향이 뒤집히고 새 컨텍스트가 추가될 때마다 결제 BC를 고쳐야 한다. Gateway는 진짜 바깥(외부 API·PG·SMS·이메일)에만 쓰고, BC 간 협력은 Published Language로 넘긴다. 결제 BC는 자기 이벤트를 발행만 하고, 각 주문 컨텍스트가 자기 ~EventWorker 로 구독해 스스로 확정한다.
domain은 PgApiClient·PgPaymentResponse 같은 외부 타입을 컴파일 시점에 아예 모른다. 다른 BC의 JPA(Java Persistence API) 엔티티나 PG 응답 DTO를 import 하려는 순간 빌드가 깨진다.
Context Map 사례: P2P 대여 플랫폼
전략적 설계가 실제로 어떻게 적용되는지를 P2P 대여 마켓플레이스 사례로 본다. 일반 유저와 기업 유저가 자유롭게 물건을 등록하고 누구나 대여할 수 있는 마켓플레이스다.
서브도메인 분류
| 유형 | 서브도메인 | 설명 |
|---|---|---|
| Core | 물품 등록(Listing) | 공급량을 좌우하는 진입점. 등록 UX, 카테고리 체계가 경쟁력 |
| Core | 대여 거래(Rental Transaction) | 요청부터 반납까지의 생명주기 |
| Core | 신뢰·평판(Trust & Reputation) | 낯선 사람에게 물건을 맡기는 심리적 장벽을 낮춤 |
| Supporting | 검색·매칭 | 위치·카테고리 기반 탐색 |
| Supporting | 예약·일정 | 가용 기간 관리, 충돌 방지 |
| Supporting | 분쟁 해결 | 파손·미반납 중재 |
| Generic | 회원, 결제·정산, 알림, 채팅 | 외부 솔루션 + ACL 로 처리 |
물품 등록과 신뢰가 Core인 이유는 명확하다. 플랫폼의 가치는 공급(등록 물품)이 풍부해야 성립하고, P2P 거래의 가장 큰 허들은 낯선 사람에 대한 불신이다. 결제·정산은 토스페이먼츠 같은 솔루션 위에 ACL로 구축한다.
같은 물품 모델의 컨텍스트별 차이
| 컨텍스트 | 물품의 의미 | 주요 속성 |
|---|---|---|
| 물품 등록 | 등록자가 올린 대여 상품 | 제목, 설명, 사진, 카테고리, 대여 조건, 보증금 |
| 대여 거래 | 거래 대상 아이템 | 거래 상태, 대여자, 등록자, 대여 기간, 반납 여부 |
| 검색·매칭 | 검색 결과 문서 | 위치(좌표), 가격대, 평점, 가용 여부 |
| 신뢰·평판 | 평가 대상 | 거래 횟수, 평균 평점, 파손 이력 |
이들을 하나의 Item 모델로 합치면 등록자가 설명을 수정할 때 검색 인덱스 로직이 끼어들고, 거래 상태 변경이 평판 계산에 영향을 주는 의존성이 만들어진다.
Context Map 패턴 선택
물품 등록과 검색·매칭은 Partnership이다. 등록 시 검색 인덱스가 갱신되고, 검색 결과가 등록 노출 정책에 영향을 준다. 양방향 조율이 필요하다. 물품 등록과 대여 거래는 Customer-Supplier 다. 대여 거래는 리스팅 정보(가격, 조건)를 소비하지만 등록 컨텍스트가 모델 변경 주도권을 가진다. 대여 거래와 신뢰·평판은 Published Language다. 거래 완료, 파손, 연체 등의 이벤트를 표준 형식(도메인 이벤트)으로 발행하고 평판 컨텍스트가 구독해 점수를 갱신한다. 느슨한 결합으로 거래 흐름에 영향을 주지 않는다. 대여 거래와 결제·정산은 ACL이다. PG사의 에스크로 모델을 대여 도메인의 PaymentResult, SettlementRequest로 변환한다. PG사 교체 시 어댑터만 수정한다. 이 둘을 코드로 옮기면 앞서 정리한 어댑터 매핑 표를 그대로 따른다. 결제·정산 쪽 ACL 은 ~GatewayImpl 하나로, 신뢰·평판 쪽 Published Language 는 DomainEventPublisher 구현체와 평판 쪽 ~EventWorker 로 구현된다.
알림은 Conformist다. 거래 이벤트를 그대로 수용해 알림을 발송한다. 채팅은 Open Host Service 다. 채팅 서비스가 범용 메시징 API를 제공하고, 대여 거래 컨텍스트가 거래 관련 채팅방 생성을 요청한다.
BC 간 통합 방식과 이벤트 설계
동기 호출과 비동기 이벤트
Context Map에서 패턴이 정해지면 그것을 실제로 어떻게 통신할지 결정해야 한다. 동기 호출(REST(Representational State Transfer)/gRPC)과 비동기 이벤트의 두 축이 있다.
| 비교 항목 | 동기 (REST/gRPC) | 비동기 (이벤트) |
|---|---|---|
| 결합도 | 높음 (런타임 의존) | 낮음 |
| 일관성 | 강한 일관성 | 최종 일관성 |
| 장애 전파 | 전파됨 | 격리됨 |
| 구현 난이도 | 낮음 | 높음 |
| 적합한 경우 | 즉시 응답이 필요한 조회 | 느슨한 결합 중시, 부수 효과 |
| 구현 예 | REST API, gRPC, GraphQL | Kafka, RabbitMQ, Domain Event |
권장 방향은 Core 도메인 간에는 비동기 이벤트 기반 통합, 조회 목적이면 동기 호출이다. 동기 호출은 장애 전파를 막을 수 없으므로 핵심 경로에 두지 않는다.
Domain Event와 Integration Event 분리
Domain Event와 Integration Event는 구분된다. Domain Event는 BC 내부에서 발생해 내부 핸들러가 처리한다. Integration Event는 BC 간 통신에 쓰이고 메시지 브로커를 경유한다. 트랜잭션 커밋 후 비동기 발행하는 Transactional Outbox 패턴이 표준이다. 같은 사건이라도 BC 내부에서 쓸 때와 외부로 발행할 때 형태가 다르다. Domain Event는 도메인 객체의 모든 정보를 가져도 되지만, Integration Event는 다른 BC가 이해해야 하므로 안정적인 식별자와 최소 정보만 담는다.
// Domain Event: BC 내부 전용. 내부 핸들러가 풍부한 정보로 부수 효과 처리
data class RentalApprovedDomainEvent(
val rental: Rental, // Aggregate 자체를 들고 다녀도 OK
val occurredAt: ZonedDateTime, // Rental.approve() 내부에서 생성 시점에 명시적으로 전달
) : DomainEvent
// Integration Event: 외부 BC 가 구독. ID 와 최소 정보만
data class RentalApprovedIntegrationEvent(
val rentalId: Long,
val renterId: Long,
val productId: Long,
val approvedAt: ZonedDateTime,
val schemaVersion: Int = 1,
) {
companion object {
fun from(rental: Rental): RentalApprovedIntegrationEvent =
RentalApprovedIntegrationEvent(
rentalId = rental.id,
renterId = rental.renterId,
productId = rental.productId,
approvedAt = ZonedDateTime.now(),
)
}
}Integration Event의 정의는 Published Language다. 한 번 발행한 뒤에는 호환성을 깨지 않도록 schemaVersion으로 진화를 관리한다. Domain Event는 같은 코드베이스 안에서 쓰이므로 자유롭게 리팩토링할 수 있지만, Integration Event는 외부 BC와의 계약이므로 변경에 신중해야 한다. 발행 자체는 domain이 정의한 DomainEventPublisher 포트를 통해서만 이루어지고, Kafka로 내보내는 구현은 infrastructure의 KafkaDomainEventPublisher 가 담당한다. domain은 이벤트가 결국 Kafka로 나가는지, 같은 프로세스 안의 리스너에게만 전달되는지 알지 못한다.