- Published on
이벤트스토밍 후 바운디드 컨텍스트 쪼개기 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
이벤트스토밍(Event Storming)을 끝내고 나면 보드에는 도메인 이벤트, 커맨드, 애그리게잇, 정책, 외부 시스템, 핫스팟이 한가득 남습니다. 문제는 그 다음입니다.
- 어디까지를 한 바운디드 컨텍스트(Bounded Context)로 묶을지
- 어떤 경계는 나누고 어떤 경계는 유지할지
- 나눈 뒤에는 통신, 데이터 소유권, 트랜잭션을 어떻게 설계할지
이 글은 이벤트스토밍 산출물을 “그럴듯한 그림”에서 “실제로 배포 가능한 경계”로 바꾸는 과정, 즉 바운디드 컨텍스트를 쪼개는 기준과 절차를 실전 관점에서 정리합니다.
이벤트스토밍 산출물을 컨텍스트 후보로 변환하기
이벤트스토밍 보드에서 바운디드 컨텍스트 후보를 뽑는 가장 현실적인 출발점은 다음 3가지입니다.
- 이벤트 흐름의 덩어리(업무 시나리오 단위): 주문 생성부터 결제 승인까지처럼, 한 시나리오의 이벤트들이 촘촘히 연결된 구간
- 용어(유비쿼터스 언어)의 일관성: 같은 단어가 다른 의미로 쓰이는 지점이 경계 후보
- 규칙(정책)과 책임의 소유자: 누가 이 규칙을 결정하고 변경하는가(팀/조직/권한)
이때 핵심은 “애그리게잇 경계”와 “바운디드 컨텍스트 경계”를 혼동하지 않는 것입니다.
- 애그리게잇은 일관성(Consistency) 경계
- 바운디드 컨텍스트는 모델(언어/규칙/데이터 소유권) 경계
애그리게잇이 여러 개여도 한 컨텍스트가 될 수 있고, 애그리게잇 하나가 컨텍스트 전체가 되기도 합니다.
쪼개기 전, 먼저 확인해야 할 5가지 신호
이벤트스토밍 보드에서 “여기는 나눠야 한다”는 신호는 꽤 명확합니다.
1) 같은 단어가 다른 의미로 쓰인다
예: Order가 어떤 곳에서는 “고객 주문서”이고, 다른 곳에서는 “출고 지시”로 쓰이는 경우
- 해결: 의미가 달라지는 지점에서 컨텍스트를 분리하고, 필요하면 컨텍스트 간에는 번역(ACL, Anti-Corruption Layer)을 둡니다.
2) 비즈니스 규칙 변경 주기가 다르다
예: 프로모션/쿠폰 정책은 매주 바뀌는데, 정산 정책은 분기 단위로 바뀌는 경우
- 변경 주기가 다른 규칙을 한 컨텍스트에 묶으면 배포/검증 비용이 함께 증가합니다.
3) 트랜잭션 경계가 과하게 넓다
이벤트스토밍에서 커맨드가 연쇄적으로 이어지며 “한 번에 다 성공해야 한다”는 요구가 커지는 구간이 있습니다.
- 정말로 원자성이 필요한지, 아니면 최종 일관성으로 풀 수 있는지 먼저 검증해야 합니다.
- 최종 일관성으로 풀 수 있다면 컨텍스트 분리가 쉬워집니다.
4) 데이터 소유권이 애매하다
예: Customer 정보가 주문, 배송, 마케팅에서 모두 수정 가능해 보이는 경우
- 컨텍스트 분리는 결국 데이터의 단일 소유자를 정하는 과정입니다.
5) 장애/성능 요구가 다르다
예: 결제 승인 API는 지연에 민감하고, 추천/마케팅은 배치로도 충분한 경우
- 서로 다른 SLO를 한 덩어리에 묶으면 가장 까다로운 요구가 전체를 끌고 갑니다.
바운디드 컨텍스트 쪼개기: 7단계 절차
아래 절차는 “이벤트스토밍 보드에서 선을 긋는” 수준을 넘어, 실제 서비스 분리까지 이어지도록 설계했습니다.
1단계: 이벤트를 업무 능력(Capability)별로 1차 클러스터링
보드의 이벤트를 보고 다음과 같이 묶어봅니다.
- 주문 생성/변경/취소
- 결제 승인/취소/환불
- 재고 예약/차감/복구
- 배송 생성/상태 변경
- 정산/세금계산
여기서 중요한 점은 “기술 계층”이 아니라 “업무 능력”으로 묶는 것입니다.
2단계: 각 클러스터의 유비쿼터스 언어 사전을 만든다
클러스터마다 핵심 엔티티/상태/규칙을 10줄 내로 정의해보면 경계가 선명해집니다.
예:
- 주문 컨텍스트의
Order는 고객이 구매 의사를 표현한 문서 - 배송 컨텍스트의
Shipment는 물류 실행 단위
같은 단어가 다른 정의로 등장하면, 그 지점이 분리 후보입니다.
3단계: “강한 일관성”이 필요한 규칙만 애그리게잇으로 잠근다
이벤트스토밍에서 커맨드와 규칙을 보며 “동시에 지켜져야 하는 불변식”을 찾습니다.
- 예:
Order의 총액과 라인 아이템 합은 항상 일치해야 한다 - 예: 재고는 0 미만으로 내려가면 안 된다
이 불변식은 애그리게잇 내부에서 트랜잭션으로 지키고, 그 외의 연쇄는 이벤트로 풀어 컨텍스트 분리를 가능하게 합니다.
4단계: 컨텍스트 간 계약(Contract)을 이벤트로 정한다
컨텍스트를 나누는 순간, “누가 누구에게 무엇을 알려야 하는가”가 계약이 됩니다.
- 주문이 생성되면 결제는 무엇을 알아야 하나
- 결제가 승인되면 배송은 무엇을 알아야 하나
이때 이벤트는 “상태 변경 사실”로 유지하고, 다른 컨텍스트의 내부 모델을 노출하지 않도록 주의합니다.
예: PaymentApproved는 결제 컨텍스트의 사실이지, 주문 컨텍스트의 상태 모델을 대신 설명하는 이벤트가 아닙니다.
5단계: 쿼리 요구를 분리해 읽기 모델을 설계한다
많은 팀이 컨텍스트 분리 후에 가장 먼저 부딪히는 게 “조인”입니다.
- 관리자 화면에서 주문/결제/배송을 한 화면에 보여줘야 한다
이 문제를 해결하는 전형적인 방식은 다음 중 하나입니다.
- 읽기 전용 뷰(Projection) 서비스
- CQRS 기반 조회 모델(머티리얼라이즈드 뷰)
- 데이터 복제(이벤트 기반)
즉, 쓰기 모델 경계를 지키면서 조회는 별도로 최적화합니다.
6단계: 사가(Saga)로 교차 컨텍스트 트랜잭션을 모델링한다
컨텍스트를 나누면 분산 트랜잭션이 사라지는 대신, “보상”이 필요해집니다.
- 주문 생성
- 결제 승인 실패 시 주문 취소(보상)
- 재고 예약 실패 시 결제 취소(보상)
사가 디버깅은 운영 난이도를 크게 좌우합니다. 실제로 보상 트랜잭션이 꼬이면 장애 분석이 어려워지므로, 초기에 관측 가능성(로그 상관관계, 이벤트 추적)을 포함해 설계해야 합니다.
관련해서 사가 보상 실패를 어떻게 디버깅하는지 정리한 글도 같이 보면 도움이 됩니다: MSA 사가 패턴 보상 트랜잭션 실패 디버깅
7단계: 팀 토폴로지와 배포 단위를 맞춘다
바운디드 컨텍스트는 코드만의 경계가 아니라 “의사결정 경계”입니다.
- 한 팀이 독립적으로 배포할 수 있는가
- 장애가 났을 때 책임이 명확한가
- 스키마 변경을 다른 팀 승인 없이 할 수 있는가
컨텍스트를 쪼개놓고도 배포가 강결합이면(공유 DB, 릴리즈 열차) 효과가 반감됩니다.
예시: 커머스 도메인에서 이벤트스토밍 후 경계 쪼개기
이벤트스토밍 보드가 다음 이벤트 흐름을 보여준다고 가정합니다.
OrderPlacedPaymentRequestedPaymentApproved또는PaymentDeclinedStockReservedShipmentCreatedOrderCompleted
여기서 흔한 실수는 “주문 서비스 하나에 다 넣기” 또는 반대로 “이벤트 하나당 서비스 하나”입니다.
현실적인 분리 예시는 다음과 같습니다.
- 주문 컨텍스트: 주문 생성/취소/상태
- 결제 컨텍스트: 승인/취소/환불
- 재고 컨텍스트: 예약/차감/복구
- 배송 컨텍스트: 출고/배송 상태
그리고 교차 흐름은 이벤트와 사가로 엮습니다.
이벤트 계약 예시(JSON)
아래처럼 컨텍스트 간 이벤트 페이로드는 최소화하고, 소유권이 있는 키만 전달합니다.
{
"eventType": "PaymentApproved",
"eventId": "9b7f2a3d-2f2e-4f8b-9f2b-1e6d6c2c9f11",
"occurredAt": "2026-02-24T09:00:00Z",
"data": {
"orderId": "ORD-20260224-0001",
"paymentId": "PAY-000099",
"approvedAmount": 129000,
"currency": "KRW"
}
}
- 주문 컨텍스트는
paymentId를 참조로만 보관하고, 결제의 내부 상태 머신을 복제하지 않습니다.
사가 오케스트레이션 의사코드
오케스트레이션 기반 사가를 예로 들면 다음과 같습니다.
type State =
| "START"
| "ORDER_PLACED"
| "PAYMENT_APPROVED"
| "STOCK_RESERVED"
| "SHIPMENT_CREATED"
| "COMPLETED"
| "FAILED";
async function checkoutSaga(orderId: string) {
let state: State = "START";
try {
await command("PlaceOrder", { orderId });
state = "ORDER_PLACED";
await command("RequestPayment", { orderId });
await waitEvent("PaymentApproved", { orderId });
state = "PAYMENT_APPROVED";
await command("ReserveStock", { orderId });
await waitEvent("StockReserved", { orderId });
state = "STOCK_RESERVED";
await command("CreateShipment", { orderId });
await waitEvent("ShipmentCreated", { orderId });
state = "SHIPMENT_CREATED";
await command("CompleteOrder", { orderId });
state = "COMPLETED";
} catch (e) {
// 보상 트랜잭션
if (state === "STOCK_RESERVED" || state === "SHIPMENT_CREATED") {
await command("ReleaseStock", { orderId });
}
if (state === "PAYMENT_APPROVED" || state === "STOCK_RESERVED" || state === "SHIPMENT_CREATED") {
await command("CancelPayment", { orderId });
}
if (state === "ORDER_PLACED") {
await command("CancelOrder", { orderId });
}
state = "FAILED";
throw e;
}
}
사가를 도입하면 “컨텍스트 간 원자성” 대신 “관측 가능하고 되돌릴 수 있는 흐름”을 확보합니다. 이때 운영에서 자주 만나는 이슈가 메시지 중복, 순서 뒤바뀜, 재처리로 인한 Exactly-Once 깨짐입니다. 이벤트 기반 연동을 한다면 아래 글도 함께 참고할 만합니다.
쪼갠 뒤에 꼭 해야 하는 4가지 체크리스트
1) 데이터베이스 공유를 끊고 소유권을 문서화한다
최소한 다음을 문서로 남깁니다.
- 각 컨텍스트의 시스템 오브 레코드(SoR)
- 다른 컨텍스트가 참조할 수 있는 식별자
- 동기 조회가 필요한 경우의 API 계약
공유 DB는 컨텍스트 분리를 무력화하는 가장 흔한 지름길입니다.
2) 컨텍스트 간 동기 호출은 “필수”만 남긴다
동기 호출이 많아지면 장애 전파가 쉬워지고, 타임아웃/데드라인 설계가 중요해집니다.
- 데드라인 전파
- 재시도 정책
- 서킷 브레이커
gRPC를 쓰는 조직이라면 데드라인 초과의 전형적인 원인을 미리 알아두는 게 좋습니다: Go gRPC 데드라인 초과 원인 7가지와 해결
3) 이벤트 스키마 버저닝 규칙을 정한다
이벤트는 배포 단위가 분리될수록 “계약”으로서의 성격이 강해집니다.
- 필드 추가는 하위 호환으로
- 필드 삭제/의미 변경은 새 이벤트 타입으로
- 소비자별 처리 가능 버전 명시
4) 장애 시나리오를 이벤트스토밍 보드에 다시 덧그린다
운영 관점에서 다음 질문에 답해야 합니다.
- 결제 승인 이벤트가 지연되면 주문은 어떤 상태로 보이나
- 배송 생성이 실패하면 재시도는 누가 하나
- 중복 이벤트가 오면 멱등성은 어디서 보장하나
이 과정은 “컨텍스트를 나누는 것”이 아니라 “나눈 뒤에도 시스템이 예측 가능하게 동작하도록 만드는 것”에 가깝습니다.
과하게 쪼갰는지, 덜 쪼갰는지 판단하는 기준
과하게 쪼갠 신호
- 컨텍스트 간 동기 호출이 너무 많다(사실상 분산 모놀리스)
- 이벤트가 업무 의미보다 기술적 중계에 가깝다
- 같은 데이터를 여러 컨텍스트가 동시에 수정하려 한다
대응:
- 읽기 모델을 분리해 조인 요구를 흡수
- 이벤트를 “사실” 중심으로 재정의
- 소유권을 재조정해 쓰기 책임을 한 곳으로 모음
덜 쪼갠 신호
- 배포 한 번에 너무 많은 이해관계자 승인이 필요하다
- 특정 규칙 변경이 전체 회귀 테스트를 유발한다
- 한 서비스의 도메인 모델이 지나치게 비대해진다
대응:
- 변경 주기와 책임을 기준으로 경계를 재분리
- 애그리게잇을 재조정해 트랜잭션 경계를 축소
마무리: 이벤트스토밍의 끝은 “선 긋기”가 아니라 “계약 만들기”
이벤트스토밍 후 바운디드 컨텍스트를 쪼개는 핵심은 예쁜 아키텍처 다이어그램이 아니라,
- 언어(모델) 경계가 명확하고
- 데이터 소유권이 단일하며
- 교차 트랜잭션은 사가로 관측 가능하고
- 조회 요구는 별도 모델로 흡수되고
- 팀이 독립적으로 배포 가능한
상태를 만드는 것입니다.
보드에서 선을 긋는 순간부터는 “컨텍스트 간 계약”이 설계의 중심이 됩니다. 이벤트 스키마, 멱등성, 재시도, 타임아웃, 보상 트랜잭션까지 포함해 현실적으로 운영 가능한 수준으로 다듬으면, 이벤트스토밍은 워크숍이 아니라 실제 시스템 분리로 이어집니다.