
Bounded Context는 DDD에서 자주 나오지만, 사실 핵심은 어렵지 않습니다. 같은 단어가 팀과 도메인에 따라 다른 뜻이 되기 시작할 때 그 의미를 어디까지 같은 것으로 볼지 경계를 정하는 개념입니다. 이번 글에서는 주문, 결제, 회원 예시로 왜 이 경계가 필요하고, 그 차이가 왜 코드·API·모델 경계까지 이어지는지 실무적으로 정리합니다.
Bounded Context
Bounded Context는 특정 모델과 용어가 일관된 의미를 가지는 범위입니다. DDD에서는 큰 도메인을 하나의 완벽한 모델로 통합하려 하기보다, 각 맥락 안에서는 일관성을 유지하고 맥락 사이에서는 차이를 인정합니다. 같은 고객, 주문, 상품이라는 말도 맥락이 바뀌면 의미가 달라질 수 있기 때문입니다.
Bounded Context는 같은 단어를 같은 뜻으로 쓸 수 있는 범위를 정하는 일입니다.
왜 갈라질까
실무에서는 같은 단어라도 관심사가 달라지면 필요한 속성, 규칙, 생명주기가 달라집니다. 주문 컨텍스트의 주문은 배송과 취소가 중요하고, 결제 컨텍스트의 결제는 승인과 환불이 중요하며, 회원 컨텍스트의 회원은 계정 상태와 권한이 중요합니다. 이 셋을 하나의 통합 모델로 억지로 묶으면 이름은 같아도 의미가 조금씩 어긋나기 시작합니다.
- 어떤 필드는 주문에서는 핵심이지만 회원에서는 의미가 거의 없다
- 결제 상태값이 주문 로직 안으로 그대로 새어 들어온다
- 같은 member라는 이름인데 API마다 가입자, 결제자, 수령인을 다르게 뜻한다
이런 상태를 오래 방치하면 코드가 복잡해지는 것보다 먼저 대화가 흐려집니다. 문서, 회의, API, 코드가 같은 단어를 서로 다르게 쓰기 시작하기 때문입니다.
주문·결제·회원
주문 안의 회원
주문 컨텍스트에서 회원은 보통 주문을 만든 주체에 가깝습니다. 주문자 ID, 배송지, 취소 권한, 주문 이력이 중요하지, 프로필 편집 이력 전체가 중요한 것은 아닙니다. 즉 주문 입장에서 회원은 주문 흐름에 필요한 정보 묶음입니다.
결제 안의 회원
결제 컨텍스트에서 회원은 돈을 내는 주체에 더 가깝습니다. 본인 인증 여부, 결제 수단, 승인 이력, 환불 가능 상태가 중요할 수 있습니다. 여기서는 배송 메모보다 payer와 승인 결과가 더 중요합니다.
회원 안의 회원
회원 컨텍스트에서 회원은 가입, 로그인, 권한, 약관 동의, 휴면 처리 같은 규칙의 중심입니다. 이 모델을 주문과 결제로 그대로 끌고 가면 오히려 경계가 흐려집니다. 주문은 주문답게, 결제는 결제답게 모델을 가져가야 하기 때문입니다.
어디서 나뉠까
질문이 다를 때
주문팀이 묻는 것은 이 주문을 취소할 수 있는가이고, 결제팀이 묻는 것은 이 결제를 환불할 수 있는가입니다. 같은 회원과 같은 주문을 말해도 중심 질문이 다르면 같은 모델로 보기 어렵습니다.
변경 이유가 다를 때
회원 정책이 바뀌는 이유와 결제 정책이 바뀌는 이유는 대개 다릅니다. 약관 동의 화면이 바뀌었다고 주문 승인 로직이 흔들리면 경계가 섞인 것입니다. PG 정책이 바뀌었다고 회원 모델이 함께 요동쳐도 마찬가지입니다.
규칙이 다를 때
같은 ID를 참조한다고 같은 모델인 것은 아닙니다. 중요한 것은 테이블 키보다 그 값을 둘러싼 의미와 규칙입니다. 데이터 연결보다 규칙 차이가 더 강하면 컨텍스트도 분리해서 봐야 합니다.
코드 경계
Bounded Context는 회의실 용어가 아니라 코드 구조에 바로 영향을 줍니다. 주문 서비스가 결제 서비스의 내부 상태값과 PG 응답 코드를 그대로 알아야 한다면 이미 결제 컨텍스트의 언어가 주문 안으로 들어온 것입니다. 주문은 자신에게 필요한 결과만 알면 되는 경우가 많습니다.
- 주문 코드가 결제용 enum과 에러 코드를 직접 참조한다
- 회원 서비스 DTO가 주문 API 응답에 그대로 노출된다
- 하나의 거대한 User 또는 Order 객체가 모든 계층을 관통한다
- 한 팀의 용어 변경이 다른 팀 코드까지 연쇄 수정으로 번진다
이런 신호가 보이면 컨텍스트 경계를 다시 봐야 합니다. 결국 Bounded Context는 패키지, 모듈, 서비스, 팀 경계를 무엇으로 나눌지 판단하는 기준이 됩니다.
API 경계
API도 마찬가지입니다. 결제 서비스의 내부 응답 구조를 주문 서비스 핵심 모델처럼 그대로 사용하기 시작하면 결제 컨텍스트의 언어가 주문 컨텍스트 안으로 흘러들어옵니다. 어느 컨텍스트의 말을 어느 경계까지 들여올지 정하는 것이 API 설계의 핵심 중 하나입니다.
{
"paymentId": "P-1024",
"payerId": "M-99",
"approvalCode": "A1B2C3",
"status": "APPROVED",
"capturedAmount": 12900
}주문에게 정말 필요한 정보는 더 적을 수 있습니다. 예를 들어 paymentStatus가 PAID인지 FAILED인지만 알면 되는 경우도 많습니다. 이 차이는 DTO 취향이 아니라 컨텍스트 경계의 차이입니다.
{
"orderId": "O-3001",
"paymentStatus": "PAID"
}모델 변환
서로 다른 Bounded Context는 연결되지 않는 섬이 아닙니다. 중요한 것은 경계를 없애는 것이 아니라 경계 사이에서 번역이 일어난다는 사실을 인정하는 것입니다. 주문에서는 ordererId라고 부르지만 결제에서는 payerId라고 부를 수 있고, 회원에서는 memberId라고 부를 수 있습니다.
data class OrderPaymentRequest(
val orderId: String,
val ordererId: String,
val amount: Long
)
data class PaymentCommand(
val paymentId: String,
val payerId: String,
val amount: Long
)
fun OrderPaymentRequest.toPaymentCommand(paymentId: String): PaymentCommand {
return PaymentCommand(
paymentId = paymentId,
payerId = ordererId,
amount = amount
)
}이런 변환 코드는 겉으로는 중복처럼 보여도, 서로 다른 모델을 억지로 하나로 합치면서 생기는 더 큰 혼선을 막아줍니다.
자주 하는 오해
마이크로서비스와 같다
항상 그렇지는 않습니다. 하나의 애플리케이션 안에도 여러 Bounded Context가 있을 수 있고, 하나의 서비스 안에 여러 컨텍스트가 섞여 있을 수도 있습니다. 중요한 것은 배포 단위보다 의미 경계입니다.
DB만 나누면 된다
테이블이 나뉘어도 언어가 섞이면 경계는 여전히 흐립니다. 반대로 저장소를 공유하더라도 모델과 책임을 분리해 경계를 더 선명하게 관리할 수 있습니다.
이름만 바꾸면 된다
User, Customer, Member, Payer라는 이름을 늘리는 것만으로는 부족합니다. 진짜 차이는 필드 이름보다 규칙, 책임, 변경 이유에 있습니다.
감 잡는 질문
- 같은 단어를 두 팀이 다르게 설명하고 있는가
- 어떤 모델 변경이 전혀 관련 없어 보이는 기능까지 흔들고 있는가
- 한 API의 응답 구조가 다른 서비스의 핵심 모델이 되어버렸는가
- 한 객체가 주문, 결제, 회원, 정산, CS의 책임을 다 품고 있는가
- 코드 리뷰에서 이 값이 여기서는 무슨 뜻인지 자주 다시 묻게 되는가
여기에 여러 개가 그렇다면 이미 컨텍스트 경계가 흐려졌을 가능성이 큽니다.
정리
Bounded Context는 같은 단어가 더 이상 같은 뜻이 아니게 되는 순간을 다루는 방법입니다. 주문의 회원, 결제의 회원, 회원의 회원은 비슷해 보여도 관심사와 규칙이 다릅니다. 이 차이를 무시하고 하나의 거대한 모델로 밀어붙이면 코드와 API와 데이터가 서로 다른 의미를 끌고 다니게 됩니다. 반대로 경계를 인정하면 각 모델이 더 단순해지고, 팀 대화가 선명해지고, 코드 경계도 덜 흔들립니다.
같이 읽으면 좋은 글로는 레이어드 아키텍처란 무엇인가, 클린 아키텍처: 무엇을 남길까, 헥사고날 아키텍처란 무엇인가, 인터페이스는 왜 필요할까를 이어서 읽어보면 좋습니다. 외부 참고 자료로는 Martin Fowler의 Bounded Context, Microsoft Learn의 Domain Analysis를 함께 보면 전략 설계 맥락까지 더 잘 잡힙니다.