Search
🏗️

도메인 주도 설계의 사실과 오해 2부 (3) 모델의 경계를 긋고, 핵심에 집중하라

Tags
Architcture
DDD
Last edited time
2026/04/12 08:14
2 more properties
Search
도메인 주도 설계의 사실과 오해 2부 (3) 모델의 경계를 긋고, 핵심에 집중하라
도메인 주도 설계의 사실과 오해 2부 (3) 모델의 경계를 긋고, 핵심에 집중하라

1. 들어가기 앞서

이 글은 조영호 강사님의 "도메인 주도 설계의 사실과 오해" 강의 2부를 정리한 시리즈의 세 번째이자 마지막 포스팅이다.
Eric Evans의 DDD 원서는 4개의 파트로 구성되어 있다. 1편에서 소개한 전체 맵을 다시 한번 조감해 보자.
파트
제목
핵심 주제
파트 1
동작하는 도메인 모델 만들기
1~3장
지식 탐구, 유비쿼터스 언어, 모델 주도 설계
파트 2
모델 주도 설계의 빌딩블록
4~7장
레이어 아키텍처, 엔티티/값 객체/서비스, 애그리게이트/리포지토리
파트 3
더 심층적인 통찰력을 향한 리팩터링
8~13장
도약, 암시적 개념 명확화, 유연한 설계, 분석 패턴
파트 4
전략적 설계
14~17장
바운디드 컨텍스트, 디스틸레이션, 대규모 구조
1~2편에서는 파트 3을 다뤘다. 신디케이트 론 도메인에서 Investment, LoanInvestment, LoanAdjustment 세 클래스에 흩어져 있던 암시적 개념이 Share(지분)라는 이름을 얻고 코드의 중심으로 올라오는 과정. 페인트 혼합 예제에서 의도를 드러내는 인터페이스, 부수 효과가 없는 함수, 단언 같은 유연한 설계 패턴을 하나씩 적용하는 과정. 도약(Breakthrough), 심층 모델(Deep Model), 유연한 설계(Supple Design)를 코드와 함께 체험했다. 하나의 도메인 모델을 반복적으로 리팩터링하면서 깊이를 더해가는 이야기였다.
이번 편의 주제는 파트 4 "전략적 설계"다. 파트 4의 부제는 "개발과 정치가 만나는 곳"이다. 파트 3이 하나의 도메인 모델을 깊게 파고드는 이야기였다면, 파트 4는 여러 모델이 공존하는 현실에서 경계를 긋고, 핵심을 식별하고, 전체를 구조화하는 이야기다. 순수한 기술 문제가 아니라 조직 구조, 팀 간 커뮤니케이션, 권한과 책임이 설계에 직접적으로 영향을 미치는 영역이다.
파트 4는 파트 3과 대비된다. 파트 3이 "모델을 어떻게 깊게 만들 것인가"의 문제라면, 파트 4는 "모델을 어떻게 나누고 연결할 것인가"의 문제다. DDD를 모든 영역에 다 적용하는 것은 비현실적이므로, 리소스와 시간이 한정된 상황에서 어디에 집중할지를 결정하는 것도 전략적 설계의 핵심이다.
강사님은 파트 4에 대해 이렇게 말한다.
"파트 3가 제일 중요하고, 파트 4는 바운디드 컨텍스트만 알아주시면 됩니다."
그 말대로 이 글에서도 바운디드 컨텍스트에 가장 큰 비중을 둔다. 핸드폰 요금 계산이라는 새로운 도메인 예제를 통해, 시스템이 성장하면서 모델이 깨지는 과정과 그 해결책을 코드와 함께 따라간다. 이어서 컨텍스트 간 관계를 정의하는 컨텍스트 맵, 핵심 도메인에 집중하는 디스틸레이션, 그리고 상속에서 합성으로의 전환을 거쳐, 마지막으로 대규모 구조를 간략히 짚는다.
파트 4가 다루는 전략적 설계는 크게 네 가지 축으로 구성된다.
1.
바운디드 컨텍스트: 모델이 유효한 범위를 정의
2.
컨텍스트 맵: 바운디드 컨텍스트 간의 관계와 변환 방식을 문서화
3.
디스틸레이션: 핵심 도메인을 식별하고 분리하여 집중
4.
대규모 구조: 시스템 전체를 이해할 수 있는 언어 제공
이 글에서는 이 네 축을 순서대로 다루되, 강사님의 조언대로 바운디드 컨텍스트에 가장 큰 비중을 두고, 대규모 구조는 간략히 처리한다. 예제 코드는 1~2편의 신디케이트 론과 페인트 혼합 대신, 핸드폰 요금 계산이라는 새로운 도메인을 사용한다. 이 도메인이 6단계에 걸쳐 진화하는 과정(01-rateplan-start → 06-distillation)을 코드와 함께 따라간다.
1편을 읽지 않았어도 이 글만으로 독립적으로 이해할 수 있도록 구성했다.

2. 하나의 거대한 모델이라는 환상

"전체를 포괄하는 도메인 모델은 의미없다. 찢어라."
강사님의 표현은 강렬하다. 하나의 커다란 도메인 모델로 모든 것을 표현하겠다는 것은 환상이라는 이야기다.
강사님도 같은 메시지를 전한다.
"커다란 도메인 모델이라고 하는 건 비현실적이다. 모든 사람이 이렇게 달라붙어서 코드를 짜면 결과적으로 나중에는 그게 커진단 말이에요."
"도메인 모델을 하나만 가지고 가지 말고... 우리 시스템의 도메인 모델은 하나야 이런 오해를 한단 말이죠. 그게 아니라 편의에 따라 나누는 거예요."
시스템이 작을 때는 괜찮다. 도메인 모델 하나로 충분히 관리할 수 있다. 그런데 시스템이 커지고 사람이 늘어나면 문제가 시작된다.
복잡성 증가: 요구사항이 늘어날수록 지식 탐구의 복잡성이 기하급수적으로 커진다.
코드 충돌: 여러 팀이 같은 모델을 수정하면서 충돌이 빈번해진다.
사이드 이펙트: 한쪽의 변경이 예상치 못한 곳에 영향을 미친다.
협업 오버헤드: 릴리즈 일정 협의, 코드 리뷰 범위 조율에 시간이 소모된다.
의미적 충돌: 같은 단어가 컨텍스트에 따라 다른 의미를 가진다.
이 중 가장 치명적인 것은 마지막, 의미적 충돌이다. Ticket 클래스를 예로 들어보자.
public class Ticket { public Money price() { ... } public Order buy() { ... } public Seat position() { ... } public boolean isStanding() { ... } public void deliver() { ... } public void print() { ... } }
Java
복사
판매팀에게 Ticket은 "판매 상품"이다. price()buy()가 핵심이다. 배송팀에게 Ticket은 "배송 물품"이다. deliver()가 핵심이다. 출력팀에게 Ticket은 "출력물"이다. print()가 핵심이다. 같은 단어, 다른 의미. 이들을 하나의 클래스에 억지로 합쳐놓으면 아무도 만족하지 못하는 괴물이 탄생한다.
해결책은 컨텍스트별로 분리하는 것이다.
판매 컨텍스트:
public class Ticket { public Money price() { ... } public Order buy() { ... } public Seat position() { ... } public boolean isStanding() { ... } }
Java
복사
배송 컨텍스트:
public class Ticket { public Seat position() { ... } public boolean isStanding() { ... } public void deliver() { ... } }
Java
복사
출력 컨텍스트:
public class Ticket { public void print() { ... } }
Java
복사
각 컨텍스트의 Ticket은 해당 영역에서 필요한 것만 담고 있다. 이름은 같지만 각각 독립적인 모델이다.
Eric Evans는 원서에서 이렇게 말한다.
"모놀리식 형태의 전체를 포괄하는 도메인 모델은 비대해서 다루기 힘들고 미묘한 중복과 모순된 부분을 포함할 것이다. 다른 한편으로 규모가 작고 또렷이 구분되는 하위 시스템이 임시방편적인 인터페이스를 토대로 결합된다면 기업 차원의 문제를 해결하기가 힘들어지고 통합이 이뤄지는 모든 곳에서 일관성 문제가 발생할 것이다." — Eric Evans, Domain-Driven Design
Evans가 제시하는 방향은 양 극단의 회피다. 하나의 거대한 모델도, 아무 연결 없는 파편화도 답이 아니다. 해법은 모델이 유효한 범위를 명시적으로 정의하고, 범위 간의 통합을 체계적으로 관리하는 것이다.
"전략적 설계 원칙은 설계 의사결정이 중요한 상호운용성과 시너지를 잃지 않으면서 각 부분 간의 상호의존성을 줄이고 명확성을 향상시키게끔 이끌어야 한다." — Eric Evans, Domain-Driven Design
전략적 설계의 출발점은 이 인식이다. 하나의 거대한 모델이라는 환상을 버리되, 잘 나누면서 잘 통합하는 것. 이것이 바운디드 컨텍스트(Bounded Context)다.

3. 바운디드 컨텍스트

3.1. 바운디드 컨텍스트란 무엇인가

바운디드 컨텍스트(Bounded Context)란 특정한 도메인 모델이 적용되는 범위다. 같은 바운디드 컨텍스트 안에서는 모델의 통합성을 유지하되, 서로 다른 바운디드 컨텍스트 사이에서는 통합성에 신경 쓰지 않는다.
"모델이 적용되는 컨텍스트를 명시적으로 정의하라. 컨텍스트의 경계를 팀 조직, 애플리케이션의 특정 부분에서의 사용법, 코드 기반이나 데이터베이스 스키마와 같은 물리적 형태의 관점에서 명시적으로 설정하라. 이 경계 내에서 모델을 엄격하게 일관된 상태로 유지하고 경계 바깥의 이슈 때문에 초점이 흐려지거나 혼란스러워져서는 안 된다." — Eric Evans, Domain-Driven Design
강사님은 더 직관적으로 설명한다.
"바운디드 컨텍스트라고 보시면 선을 그어버리는 거야. 이건 우리 거, 이건 우리 거. 니네는 쓰지 마. 나는 이 안에서만 책임질 거예요."
바운디드 컨텍스트를 나누는 1차 기준은 유비쿼터스 언어의 경계다. 같은 단어가 같은 의미로 통하는 범위가 곧 하나의 바운디드 컨텍스트다. 그리고 현실적으로 이 언어의 경계는 조직 단위와 겹치는 경우가 많다.
"현실적으로는 조직이 그렇잖아요. 조직 단위로 바운디드 컨텍스트를 나눌 수밖에 없어요. 왜냐면 커뮤니케이션이 그 안에서만 흐르니까."
같은 팀 안에서는 유비쿼터스 언어가 자연스럽게 공유된다. 하지만 다른 팀과는 같은 단어라도 다른 의미로 사용된다. 다만 조직 단위가 절대적 기준은 아니다. 한 팀이 여러 바운디드 컨텍스트를 관리할 수도 있고, 도메인의 의미적 경계가 팀 경계와 일치하지 않을 수도 있다. 조직 단위는 유용한 휴리스틱이지 정의 그 자체는 아니다. "같은 상품이지만 상품을 등록하는 영역에서의 상품과 배송에서의 상품은 완전 다른 얘기"인 것이다. 유비쿼터스 언어의 경계가 곧 바운디드 컨텍스트의 경계다.
잘 나누되, 잘 통합하는 것이 핵심이다.
"잘 나누되, 잘 인터렉션할 수 있도록 구성하라."
이제 실제 코드로 이 개념이 어떻게 적용되는지 핸드폰 요금 계산 도메인을 통해 살펴보자.

3.2. 핸드폰 요금 계산 도메인 소개

핸드폰 요금 계산 시스템을 도메인으로 사용한다. 강사님이 강의에서 사용한 예제인데, 이렇게 덧붙인다.
"엄청 복잡한 시스템이에요라고 생각해 주셔야 돼요. 지금 제가 강의니까 심플하게 만들었지만 이게 굉장히 복잡한 거야."
핵심 구조는 이렇다.
핸드폰으로 통화를 하면 콜 레코드(CallRecord)가 생성된다. 세션 ID, 발신번호, 수신번호, 상태(시작/종료/실패), 시간이 기록된다.
동일 세션 ID를 공유하는 시작/종료 레코드 한 쌍이 하나의 통화를 구성한다.
핸드폰마다 요금제(RatePlan)가 있고, 계약(Contract)이 핸드폰과 요금제를 묶는다.
요금제는 두 종류다.
일반 요금제(RegularRatePlan): 하루 종일 동일 요금 (예: 10초당 5원)
심야 할인 요금제(NightRatePlan): 주간(6:00~22:00)과 심야(22:00~6:00) 요금이 다르다. 통화 시작 시간 기준으로 판단.
요금 계산의 핵심 코드를 보자.
public abstract class RatePlan extends AggregateRoot<RatePlan, Long> { public Money calculateFee(Collection<CallRecord[]> calls) { return calls.stream() .map(call -> this.calculateCallFee(call[0], call[1])) .reduce(Money.ZERO, Money::plus); } protected abstract Money calculateCallFee(CallRecord started, CallRecord completed); }
Java
복사
RatePlan은 추상 클래스다. calculateFee()가 콜 레코드 쌍의 컬렉션을 받아서 각각의 통화 요금을 계산하고 합산한다. 구체적인 요금 계산 로직은 하위 클래스에 위임한다.
일반 요금제의 구현:
public class RegularRatePlan extends RatePlan { private Money amount; // 단위 금액 private Duration duration; // 단위 시간 @Override protected Money calculateCallFee(CallRecord started, CallRecord completed) { return amount.times( TimeInterval.of(started.getOccurredTime(), completed.getOccurredTime()) .duration().dividedBy(duration)); } }
Java
복사
통화 시간을 단위 시간으로 나누고 단위 금액을 곱한다. 단순하다.
심야 할인 요금제는 시간대에 따라 다른 단가를 적용한다.
public class NightRatePlan extends RatePlan { private static final TimeInterval<LocalTime> NIGHT = TimeInterval.of(LocalTime.of(22, 0), LocalTime.of(6, 0)); private Money nightAmount; // 심야 단위 금액 private Money dayAmount; // 주간 단위 금액 private Duration duration; // 단위 시간 @Override protected Money calculateCallFee(CallRecord started, CallRecord completed) { Duration callDuration = TimeInterval.of( started.getOccurredTime(), completed.getOccurredTime()).duration(); if (NIGHT.include(started.getOccurredTime().toLocalTime())) { return nightAmount.times(callDuration.dividedBy(duration)); } return dayAmount.times(callDuration.dividedBy(duration)); } }
Java
복사
통화 시작 시간이 심야 구간(22:00~06:00)에 해당하면 nightAmount, 아니면 dayAmount를 적용한다.
전체 요금 계산 흐름은 PhoneBillService에서 조율한다.
@Service @AllArgsConstructor public class PhoneBillService { private ContractRepository contractRepository; private RatePlanRepository ratePlanRepository; private CallRecordRepository callRecordRepository; private PhoneBillRepository phoneBillRepository; @Transactional public void calculate(Long contractId) { Contract contract = contractRepository.findById(contractId) .orElseThrow(IllegalArgumentException::new); RatePlan ratePlan = ratePlanRepository.findById(contract.getRatePlanId()) .orElseThrow(IllegalArgumentException::new); Collection<CallRecord[]> callRecords = callRecordRepository.findCallRecordsToBill(contract.getPhoneNumber()); PhoneBill bill = new PhoneBill(contractId, contract.getPhone(), ratePlan.calculateFee(callRecords)); phoneBillRepository.save(bill); } }
Java
복사
흐름을 따라가 보자. 계약 ID로 계약을 찾고, 계약에 연결된 요금제를 찾고, 해당 번호의 콜 레코드를 조회하고, 요금제에 위임하여 요금을 계산하고, 명세서를 저장한다.
이 시점에서 모든 도메인 객체는 하나의 패키지에 있다. CallRecord, RatePlan, Contract, PhoneBill 모두 org.eternity.phone.domain 아래에 위치한다. 시스템이 작을 때는 이것으로 충분하다.

3.3. 성장하는 시스템, 깨지는 모델

시스템이 커지면서 인력이 늘어나고, 팀이 분리된다. 통화관리팀계약관리팀. 패키지도 나눈다. tracking 모듈에는 CallRecord, contract 모듈에는 RatePlan, Contract, PhoneBill.
org.eternity.phone +-- contract/ # 계약관리팀 | +-- domain/ # Contract, Phone, RatePlan, PhoneBill | +-- service/ # PhoneBillService +-- tracking/ # 통화관리팀 | +-- domain/ # CallRecord, CallSession, CallPhase, CallStatus | +-- service/ # CallService
Plain Text
복사
패키지는 나눴지만 같은 코드베이스를 공유하는 모노리식 구조다. 그러던 어느 날, 통화관리팀이 CallStatus를 리팩터링한다.
기존에 CallStatus에는 STARTED, COMPLETED, FAILED 세 가지 값이 있었다. 통화의 "단계"와 "결과"가 하나의 enum에 섞여 있었던 것이다. 통화관리팀은 이를 두 개의 enum으로 분리한다.
CallPhase: STARTED, COMPLETED, FAILED (통화 단계)
CallStatus: IN_PROGRESS, NORMAL_CLEARING, BUSY, CALL_REJECTED, NO_ANSWER, NETWORK_FAILURE (통화 결과 상태)
합리적인 리팩터링이다. 하지만 문제가 생긴다. 계약관리팀의 PhoneBillService는 여전히 status = 'STARTED'로 콜 레코드를 쿼리하고 있었다. 통화관리팀은 이 사실을 몰랐다. 장애가 발생한다.
"코드는 나눠져 있지만 같은 거거든. 같은 모델을 우리가 지금 같은 모델로 생각하는 거예요. 얘네도 얘가 우리 거야라고 지금 쓰고 있단 말이죠. 근데 통보가 없었어요."
코드를 보면 문제가 명확하다.
// contract/domain/RatePlan.java import org.eternity.phone.tracking.domain.CallRecord; // 바운디드 컨텍스트 침범! protected abstract Money calculateCallFee(CallRecord started, CallRecord completed);
Java
복사
계약 도메인의 핵심 클래스인 RatePlan이 통화 추적 도메인의 CallRecord를 직접 참조하고 있다. 패키지는 나눴지만, 의존 방향이 정리되지 않은 것이다.
강사님은 실무 경험담을 덧붙인다.
"실제로 웹 운영하고 있는 회사는 다 발생하는 거예요. 저쪽 팀이 우리 쪽 DB에 컬럼을 쓰고 있다고 하는데 뭘 쓰는지 몰라요. 우리 거에 DB 테이블에서 우리는 안 쓰는 컬럼... 못 지웁니다. 어디서 쓰는지 모르니까."
"공유할 거면 명확하게 공유하고, 나눌 거면 명확하게 나눴어야 돼요. 지금은 애매한 거죠."
핵심은 이것이다. 패키지를 나누는 것만으로는 부족하다. 모델의 경계를 명시적으로 선언하고, 경계를 넘는 의존을 통제해야 한다. 단, 앞서 Evans가 경고한 것처럼 "규모가 작고 또렷이 구분되는 하위 시스템이 임시방편적인 인터페이스를 토대로 결합된다면" 그것도 문제다. 분리에는 비용이 따른다. 번역 계층을 만들고 유지해야 하고, 컨텍스트 간 통신의 복잡성이 늘어난다. 시스템 규모와 팀 상황에 따라 "지금 분리할 때가 맞는가"를 판단해야 한다.
"서로 조율하지 않고 얘를 직접 쓰는 경우 때문에 문제가 생기는 거예요. 못 쓰게 하면 돼요."
"조직 간에는 커뮤니케이션을 최소화해야 돼요. 대신에 명확한 인터페이스로."

3.4. 바운디드 컨텍스트 분리: CallRecord에서 Call로

해결 과정을 단계별로 따라가 보자.
1단계: contract 컨텍스트에 자체 모델을 만든다. 계약관리팀이 통화에 대해 알아야 할 것은 딱 두 가지다. 전화번호와 통화 시간. 이것만 담은 Call을 정의한다.
// contract/domain/Call.java public record Call(String phoneNumber, TimeInterval<LocalDateTime> period) { public Duration duration() { return period.duration(); } public LocalTime startTime() { return period.getStart().toLocalTime(); } }
Java
복사
Call은 contract 컨텍스트의 자체 모델이다. CallRecord가 세션 ID, 발신/수신번호, 상태, 시간 등 풍부한 정보를 가진 반면, Call은 요금 계산에 필요한 최소한의 정보만 담고 있다. 더 이상 CallRecord를 알 필요가 없다.
2단계: tracking 컨텍스트에 Facade를 추가하여 내부 구현을 캡슐화한다.
// tracking/service/CallRecordFacade.java @Component @AllArgsConstructor public class CallRecordFacade { private CallRecordRepository callRecordRepository; public Collection<CallRecordPair> getCallRecords(String phoneNumber) { Collection<CallRecord[]> result = callRecordRepository.findCallRecordsToBill(phoneNumber); return result.stream() .map(call -> new CallRecordPair(call[0], call[1])) .toList(); } }
Java
복사
3단계: 두 컨텍스트 사이의 변환 계층을 만든다. CallTranslator 인터페이스를 contract 도메인에 정의하고, 구현은 서비스 계층에서 tracking의 CallRecordRepository를 사용하여 콜 레코드를 조회한 뒤 contract의 Call로 변환한다.
// contract/domain/CallTranslator.java public interface CallTranslator { Collection<Call> translate(String phoneNumber); }
Java
복사
// contract/service/CallRecordToCallTranslator.java @Component @AllArgsConstructor public class CallRecordToCallTranslator implements CallTranslator { private CallRecordRepository callRecordRepository; public Collection<Call> translate(String phoneNumber) { Collection<CallRecord[]> callRecords = callRecordRepository.findCallRecordsToBill(phoneNumber); return callRecords.stream() .map(pair -> new Call( pair[0].getCallingNumber(), TimeInterval.of(pair[0].getOccurredTime(), pair[1].getOccurredTime()))) .collect(Collectors.toUnmodifiableSet()); } }
Java
복사
4단계: RatePlanCall을 사용하도록 변경한다.
// 변경 전 (02-integrity-error) protected abstract Money calculateCallFee(CallRecord started, CallRecord completed); // 변경 후 (03-bounded-context) protected abstract Money calculateCallFee(Call call);
Java
복사
RegularRatePlan도 간결해진다.
protected Money calculateCallFee(Call call) { return amount.times(call.duration().dividedBy(duration)); }
Java
복사
CallRecordgetOccurredTime()을 꺼내서 TimeInterval을 직접 구성하던 코드가 call.duration() 한 줄로 바뀌었다. contract 컨텍스트의 관점에서 필요한 것만 Call에 담았기 때문이다.
"니네는 니네 거 쓰고, 우리는 우리 거 쓸 테니. 대신에 우리 인터페이스만 맞추면 돼. 이 얘기인 거죠."
변경 전후를 비교해 보자.
항목
02-integrity-error
03-bounded-context
RatePlan 파라미터
CallRecord[] (tracking 도메인)
Call (contract 자체 모델)
의존 방향
contract → tracking (직접 참조)
contract → CallTranslator 인터페이스
번역 계층
없음
CallTranslator + CallRecordToCallTranslator
tracking 공개 API
JPA Repository 직접 사용
CallRecordFacade (퍼사드)
모델 결합
CallRecord 변경 시 RatePlan에 파급
Call은 독립적, tracking 변경에 무관
핵심 변화는 의존 방향의 정리다. contract 도메인은 더 이상 tracking 도메인의 모델에 직접 의존하지 않는다. tracking 쪽에서 CallStatus를 아무리 리팩터링해도 contract 쪽에는 영향이 없다. CallTranslator의 변환 로직만 적절히 수정하면 된다.
주목할 점은 Call이 Java의 record로 정의되었다는 것이다. 전화번호와 통화 시간이라는 두 가지 값만 가진 불변 값 객체다. CallRecord가 ID, 세션 ID, 발신/수신번호, 통화 단계, 통화 상태, 시간 등 풍부한 정보를 가진 엔티티인 반면, Call은 contract 컨텍스트에서 필요한 최소한의 정보만 추출한 가벼운 객체다. 각 컨텍스트가 자신에게 필요한 모델만 가지고 있다.
"성공적인 모델은 규모와 상관없이 모순되거나 정의가 겹치지 않고 처음부터 끝까지 논리적인 일관성을 지녀야 한다. 그러므로 모델이 적용되는 BOUNDED CONTEXT를 분명하게 정의하고, 필요하다면 다른 컨텍스트와의 관계를 정의해서 모델의 품질을 유지할 수 있다." — Eric Evans, Domain-Driven Design

4. 컨텍스트 맵

4.1. 컨텍스트 맵이란

바운디드 컨텍스트를 정의했다면, 다음 질문은 "컨텍스트 간에 어떻게 통합할 것인가"다. 이 관계의 전체 지도를 컨텍스트 맵(Context Map)이라 한다.
"BOUNDED CONTEXT 간에 코드를 재사용하는 것은 위험하므로 피해야 한다. 기능과 데이터는 변환 과정을 거쳐 통합해야 한다. 프로젝트 상의 유효한 모델을 식별하고 각 BOUNDED CONTEXT를 정의하라. BOUNDED CONTEXT에 이름을 부여하고 이 이름을 UBIQUITOUS LANGUAGE의 일부로 포함시켜라." — Eric Evans, Domain-Driven Design
"MAP을 어떤 형식으로 작성하건 프로젝트에 속한 모든 사람들은 MAP을 이해하고 공유해야 한다." — Eric Evans, Domain-Driven Design
강사님은 더 실용적으로 설명한다.
"바운디드 컨텍스트 사이에 어쨌든 연동을 위해서 데이터를 주고받아야 되는 그게 뭔지를 명확하게 정리하는 게 컨텍스트 맵이에요."
"핵심은 각자 알아서 해야 되는 영역을 정의하고, 서로 맞춰야 되는 거는 조율하고."
컨텍스트 맵의 본질은 기술적 문서가 아니라 조직적 합의다. 관련된 조직이 모델의 무결성을 유지할 범위를 인식하고(바운디드 컨텍스트), 협력을 통해 모델의 변환 방식을 공유하는 것(컨텍스트 맵)이다.

4.2. 팀 간 관계를 정의하는 패턴들

컨텍스트 맵에는 바운디드 컨텍스트 간의 관계를 정의하는 7가지 패턴이 있다. 각 패턴은 결국 조직 간의 권력 관계와 커뮤니케이션 방식을 반영한다.
패턴
핵심
적용 시점
Shared Kernel (공유 커널)
두 팀이 도메인 모델의 일부를 공유
모델 통합이 불가피할 때
Customer-Supplier (고객-공급자)
상류(공급자)가 하류(고객)에 데이터 제공, 단방향 흐름
상류/하류 관계가 명확할 때
Conformist (순응자)
상대 팀의 모델을 그대로 사용
변환이 불필요하거나 비용이 클 때
Anti-Corruption Layer (부패 방지 계층)
변환 계층으로 외부 모델에서 자신의 모델을 보호
레거시/외부 시스템과 통합할 때
Separate Ways (각자의 길)
통합 자체를 하지 않음
통합의 혜택이 적을 때
Open Host Service (공개 호스트 서비스)
표준 API를 공개하여 여러 팀에 일괄 제공
여러 시스템과 통합해야 할 때
Published Language (공표된 언어)
표준 데이터 포맷(JSON, XML 등)으로 교환
공통 표준 모델이 필요할 때
이 7가지 패턴은 결합도의 강약에 따라 스펙트럼을 형성한다. Shared Kernel이 가장 강한 결합이고, Separate Ways가 가장 약한 결합(통합 없음)이다. 나머지 패턴들은 그 사이에 위치한다. 어떤 패턴을 선택할지는 두 컨텍스트 사이의 조직적 관계, 변경 빈도, 통합 비용에 따라 결정된다.
각 패턴에 대한 강사님의 설명을 들어보자.
Shared Kernel에 대해:
"안 좋은 거긴 해요. 어쩔 수 없을 때만 써라."
두 팀 사이의 모델 통합이 용이하지 않을 때, 도메인 모델의 부분 집합을 공유한다. 한 팀이 공유 부분을 잘못 수정하면 다른 팀이 다 망한다. 양팀이 조율해야 하고, 양 팀에서 작성한 테스트로 공유 부분의 무결성을 유지해야 한다. 가능하면 피하는 것이 좋다.
Customer-Supplier에 대해:
"커스터머가 된다는 얘기는 고객이니까 고객의 요청을 받아줘야 되는 거예요."
Upstream(공급자)이 데이터를 주는 쪽, Downstream(고객)이 데이터를 받는 쪽이다. 상류 컴포넌트가 하류 컴포넌트에 데이터를 제공하는 단방향 흐름 관계다. 고객 팀이 필요한 것을 요청하면, 공급 팀이 리소스를 할당하여 제공한다. 양 팀이 인수 테스트를 공동 작성하여, 상류의 변경이 하류를 깨뜨리지 않음을 보장한다. 조직 차원에서 역할과 책임(RnR)을 정하는 것이다.
Conformist에 대해:
"걔네가 주는 모델 그대로 우리가 쓰자."
상류/하류 관계에 있는 두 팀이 같은 조직의 지시를 받지 않아 상류 팀의 동기 부여가 어려운 경우에 적용된다. 변환할 필요가 없거나, 변환 비용이 너무 클 때 사용한다. 상대 팀의 모델을 그대로 따르므로 유비쿼터스 언어를 공유하게 되고, 번역 복잡성이 제거된다. 다만, 상대 모델에 종속되므로 자율성은 떨어진다.
Anti-Corruption Layer에 대해:
"저쪽에서 뭔가를 우리 요구 사항을 못 들어줘요. 그러니 받아서 우리 쪽에 맞게 변환해서 쓰는 거죠."
레거시 시스템이나 통제할 수 없는 외부 시스템에서 오는 데이터를 우리 모델에 맞게 변환하는 방어막이다. 우리 모델이 "부패되지 않도록" 격리 계층을 세우는 것이다. 원서에서는 이렇게 설명한다. "클라이언트 고유의 도메인 모델 측면에서 기능을 제공할 수 있는 격리 계층을 추가하라." 양방향 번역을 수행하며, 외부 시스템의 변경이 내부 모델에 파급되지 않도록 차단한다.
Separate Ways에 대해:
통합 자체를 하지 않는 선택이다. "니네들이랑 나는 관계없다." 통합의 혜택이 적다면 각자의 바운디드 컨텍스트가 다른 것과 아무런 관계도 맺지 않도록 선언한다. 불필요한 통합은 복잡성만 늘린다.
Open Host Service에 대해:
"우리 니네 팀을 위해서 커스터마이즈 못해주니까 이거 줘. 가져가라."
여러 팀이 한 팀에 요청하는 상황에서, 각 팀의 개별 요구사항을 다 들어줄 수 없을 때 표준 API를 공개한다. 하위 시스템과 관련된 프로토콜을 일련의 서비스로 정의하여 공개하는 것이다.
Published Language에 대해:
Open Host Service와 함께 사용되는 경우가 많다. 어느 한쪽의 도메인 모델을 기준으로 번역하기보다는, 필요한 도메인 정보를 표현할 수 있는 공유 언어(JSON, XML, YAML 등의 표준 포맷)를 정의한다. "이 포맷으로 줄게"라고 딱 정해놓은 것이다.
실무에서 OHS + Published Language 조합은 흔하다. REST API를 JSON 포맷으로 공개하는 것이 전형적인 예다. 표준 API와 표준 포맷을 제공하면, 각 팀은 자체 ACL을 만들어서 자기 모델에 맞게 변환하면 된다. Customer-Supplier처럼 각 팀의 요구사항을 일일이 들어줄 필요가 없다.
핸드폰 요금 도메인에의 적용
앞서 살펴본 핸드폰 요금 도메인에서는 Customer-Supplier 관계가 적용된다. 통화관리팀이 Upstream(Supplier)이고, 계약관리팀이 Downstream(Customer)이다. 요금 계산이 비즈니스의 핵심이므로 계약관리팀의 요구에 맞춰 통화관리팀이 데이터를 제공하는 구조다. 원서의 표현을 빌리면, "하류 팀이 상류 팀에 고객 역할을 수행하고, 인수 테스트를 공동 작성"하는 관계다.
그리고 섹션 3.4에서 구현한 CallTranslator가 바로 Anti-Corruption Layer 패턴이다. tracking 컨텍스트의 CallRecord를 contract 컨텍스트의 Call로 변환하는 방어막. contract 도메인이 tracking 도메인의 모델 변경에 "부패"되지 않도록 보호하는 계층이다. CallRecordFacade는 tracking 컨텍스트의 Open Host Service 역할을 한다. tracking 내부의 구현을 캡슐화하고, 외부에 일관된 API를 제공한다.
이처럼 하나의 시스템에서도 여러 패턴이 동시에 적용된다. 정리해 보면 핸드폰 요금 도메인의 컨텍스트 맵은 이렇다.
tracking(통화관리팀)과 contract(계약관리팀)은 Customer-Supplier 관계다. tracking이 Upstream(공급자), contract가 Downstream(고객). 계약관리팀이 필요한 통화 데이터를 통화관리팀에 요청하고, 통화관리팀이 그에 맞는 데이터를 제공한다.
contract 쪽의 CallTranslatorAnti-Corruption Layer다. tracking의 CallRecord를 contract의 Call로 변환한다. tracking 모델의 변경이 contract 모델을 "부패"시키지 않도록 방어한다.
tracking 쪽의 CallRecordFacadeOpen Host Service다. tracking의 내부 구현(CallRecord[] 배열, JPA Repository)을 캡슐화하고, CallRecordPair라는 일관된 API를 외부에 제공한다.
중요한 것은 이 패턴들이 각각 독립적인 기술 선택이 아니라, 조직 간 관계의 반영이라는 점이다.
"실무에서 되게 많이 나와요. 회사가 갑자기 급성장해서 사람이 많이 들어오기 시작하면 장애가 엄청나게 발생하는 시기가 있어요. 그때 협의가 잘 안 돼서. 그리고 새로 들어온 사람은 기존에 저런 히스토리를 알지 못해요."
강사님의 이 말은 컨텍스트 맵이 왜 필요한지를 정확히 보여준다. 시스템이 커지고 사람이 늘어나면, 누가 누구에게 데이터를 주는지, 모델의 변경을 누가 통보해야 하는지, 변환 책임이 어디에 있는지가 불명확해진다. 컨텍스트 맵은 이 관계를 명시적으로 문서화하여, 프로젝트에 속한 모든 사람이 이해하고 공유할 수 있게 만드는 것이다.
"협의된 경로를 통해서 의존된 모델을 사용."
컨텍스트 맵의 패턴들은 기술적 결정이 아니라 조직적 역학의 반영이다. 어떤 패턴을 선택할지는 팀 간의 관계, 권한, 커뮤니케이션 비용에 따라 달라진다.
패턴 선택에 영향을 미치는 현실적 요소들을 정리하면 이렇다.
같은 조직, 충분한 커뮤니케이션: Customer-Supplier가 자연스럽다. 양 팀이 인수 테스트를 공동 작성하고, 상류의 변경이 하류를 깨뜨리지 않음을 보장한다.
같은 조직이지만 커뮤니케이션 비용이 높을 때: Shared Kernel을 최소화하고, ACL로 각자의 모델을 보호한다.
다른 조직이거나 외부 시스템: Conformist(변환 비용이 불필요할 때) 또는 ACL(우리 모델을 보호해야 할 때)을 선택한다.
통합의 혜택이 없을 때: Separate Ways. 통합 자체를 하지 않는 것이 최선일 때도 있다.
여러 팀에 동시에 데이터를 제공해야 할 때: OHS + Published Language. 표준 API와 표준 포맷으로 일괄 제공한다.
주의할 점이 있다. 이 패턴들은 고정된 것이 아니다. 조직이 변하면 패턴도 변한다. 처음에는 같은 팀이라 Shared Kernel을 사용하다가, 팀이 분리되면서 Customer-Supplier로 전환하고, 외부 서비스화되면서 OHS + Published Language로 진화할 수 있다. 컨텍스트 맵은 현재 상태를 반영하는 스냅샷이지, 영원한 설계 문서가 아니다.

5. 디스틸레이션

5.1. 모든 도메인에 동일한 에너지를 쏟지 마라

바운디드 컨텍스트로 모델의 경계를 그었다면, 다음 질문은 "어디에 집중할 것인가"다. 모든 영역을 다 잘 만드는 것은 불가능하다. 리소스와 시간이 한정되어 있기 때문이다.
"모든 걸 다 DDD를 적용할 필요가 없는 거예요. 이 사람도 그렇게 얘기해요. 중요한 거에 집중하세요."
"모든 개발자들이 다 모든 코드를 다 잘 만드는 건 안 되니까. 우선순위 매기자. 우리 회사 제일 중요한 게 뭐냐. 그 코어 도메인을 하나 잡아요."
이것이 디스틸레이션(Distillation)이다. 핵심 도메인을 식별하고 분리하여, 거기에 역량을 집중하는 전략이다. "대충해라"가 아니라, 우선순위를 매겨서 핵심에 역량을 쏟으라는 것이다.
Eric Evans는 원서에서 디스틸레이션의 목적을 다섯 가지로 정리한다.
1.
팀원들이 시스템의 전체 설계와 해당 설계가 어떻게 함께 조화될지 파악하게끔 돕는다.
2.
유비쿼터스 언어의 일부가 될 수 있게 관리 가능한 크기의 핵심 모델을 식별해서 의사소통을 촉진한다.
3.
리팩터링을 이끈다.
4.
가장 중요한 모델 영역의 업무에 초점을 맞춘다.
5.
아웃소싱, 기성 컴포넌트의 활용, 할당에 관한 의사결정을 돕는다.
코어 도메인(Core Domain)에 대해 Evans는 이렇게 말한다.
"현실의 가혹한 측면은 설계의 모든 부분이 모두 동일하게 정제되지는 않는다는 것이다. 그러므로 각 설계 측면에 우선 순위를 매겨야 한다." — Eric Evans, Domain-Driven Design
"CORE DOMAIN은 시스템에서 가장 큰 가치가 더해지는 곳이다." — Eric Evans, Domain-Driven Design
"CORE DOMAIN을 찾아 그것을 지원하는 다수의 모델과 코드로부터 쉽게 구별할 수 있는 수단을 제공하라. CORE DOMAIN에 가장 재능 있는 인력을 할당하고 그에 따라 인력을 채용하라. CORE에 노력을 쏟아라." — Eric Evans, Domain-Driven Design
강사님은 더 직접적으로 설명한다.
"코어가 딴 거랑 섞여 있으면 뭐가 중요한지 몰라요. 코드 자체가 뒤죽박죽이거든요. 그러니까 얘를 싹 발라내서 얘가 중요해라고 딱 찍어놓는 거예요."
코어를 식별했으면, 다음 단계는 물리적으로 분리하는 것이다. 분리된 핵심(Segregated Core)이란 코어 도메인을 비핵심 로직에서 물리적으로 분리하는 것이다.
"CORE 요소들은 일반화된 요소와 긴밀하게 결합돼 있을지도 모른다. CORE의 개념적 응집성은 뚜렷이 나타나지 않거나 드러나지 않을지도 모른다. 설계자가 가장 중요한 관계를 분명하게 볼 수 없다면 취약한 설계로 이어지는 결과가 나타난다." — Eric Evans, Domain-Driven Design
"보조적인 역할로부터 CORE의 개념을 분리되게끔 모델을 리팩터링하고 CORE와 다른 코드와의 결합은 줄이면서 CORE의 응집력은 강화하라." — Eric Evans, Domain-Driven Design
"코어를 분리해. 다른 거랑 분리해서 이거는 되게 중요한 거니까 여기에 포커스를 두세요라고 해요."

5.2. 코어 도메인 식별: 요금제가 핵심이다

여기서 중요한 구분이 하나 있다. 문제 공간(Problem Space)과 솔루션 공간(Solution Space)이다. 문제 공간은 서브도메인으로 구성된다. 도메인 전문가와 개발자가 함께 유비쿼터스 언어를 정의하고, 도메인 지식을 정리하고, 디스틸레이션을 통해 핵심 도메인과 서브도메인들을 식별하는 영역이다. 솔루션 공간은 바운디드 컨텍스트로 구성된다. 식별된 서브도메인을 실제 코드와 시스템으로 구현하는 영역이다. 하나의 서브도메인이 하나의 바운디드 컨텍스트에 대응될 수도 있고, 여러 바운디드 컨텍스트에 걸쳐 있을 수도 있다.
디스틸레이션에서는 도메인을 세 가지 유형으로 분류한다.
코어 도메인(Core Domain): 시스템에서 가장 큰 가치가 더해지는 곳. 이 회사만의 차별화 포인트. 여기에 가장 재능 있는 인력을 할당하고, DDD의 원칙을 집중 투입한다.
지원 서브도메인(Supporting Subdomain): 코어를 지원하는 영역. 중요하지만 그 자체로는 차별화 요소가 아니다. 적절한 수준의 품질이면 충분하다.
일반 서브도메인(Generic Subdomain): 도메인에 특화되지 않은 범용적인 영역. 기성 솔루션이나 아웃소싱으로 해결할 수 있는 부분이다.
핸드폰 요금 도메인에서 코어 도메인은 무엇인가? 요금 계산 로직이다. 계약 체결이나 청구서 발행은 지원 기능이다. 이 회사의 경쟁력은 "어떤 요금제를 어떻게 계산하느냐"에 있다.
contract 바운디드 컨텍스트 내부를 3개의 서브도메인으로 분리한다.
contract/ enrollment/ → 지원 서브도메인 (Contract, Phone) rateplan/ → 코어 도메인 (RatePlan, RegularRatePlan, NightRatePlan) billing/ → 지원 서브도메인 (PhoneBill, Call, CallTranslator, PhoneBillService)
Plain Text
복사
enrollment(가입): Contract, Phone. 계약 체결과 핸드폰 관리. 지원 서브도메인. 중요하지만 핵심 경쟁력은 아니다. 어떤 통신사든 계약 관리는 비슷하다.
rateplan(요금제): RatePlan, RegularRatePlan, NightRatePlan. 요금 정책 정의. 코어 도메인. 이 회사의 비즈니스 차별화 포인트. 경쟁사와의 차이를 만드는 곳이다.
billing(청구): PhoneBill, Call, CallTranslator, PhoneBillService. 요금 계산 실행과 명세서 발행. 지원 서브도메인. 요금제를 적용하는 절차적 역할.
코어 도메인을 패키지로 명시적으로 분리함으로써, 새로 합류한 개발자도 "여기가 가장 중요한 곳"이라는 것을 한눈에 알 수 있다.
왜 이 분리가 중요한가? 분리하기 전에는 RatePlan, Contract, Phone, PhoneBill, Call, PhoneBillService가 모두 같은 수준에 놓여 있었다. 어떤 코드가 비즈니스의 핵심이고, 어떤 코드가 지원 기능인지 코드만 봐서는 알 수 없다. 코어 도메인을 별도 패키지로 분리하면, 코드 구조 자체가 "요금 계산이 이 시스템의 핵심입니다"라고 말하게 된다.
통화 내역 관리나 계약 관리는 서포팅 도메인이므로, 이 영역에 DDD의 모든 원칙을 완벽하게 적용하는 데 에너지를 쏟을 필요가 없다. 대신 코어 도메인인 rateplan에 파트 3에서 배운 원칙들(심층 모델, 리팩터링, 유연한 설계)을 집중 투입하는 것이 디스틸레이션의 요점이다.
Evans는 이 연결을 분명히 한다.
"디스틸레이션은 더 심층적인 통찰력을 향한 리팩터링을 지속적으로 수행함으로써 심층적이고 유연한 설계로 이끄는 하위 도메인, 특히 CORE DOMAIN을 정제하는 것을 의미하기도 한다." — Eric Evans, Domain-Driven Design
"심층 모델로의 도약은 그러한 도약이 일어나는 모든 곳에 가치를 제공하지만 전체 프로젝트의 궤도를 변경할 수 있는 것은 바로 CORE DOMAIN이다." — Eric Evans, Domain-Driven Design
파트 3의 도약(Breakthrough)이 가장 큰 가치를 발휘하는 곳이 바로 코어 도메인이라는 이야기다. 모든 곳에서 도약을 추구할 수는 없으니, 가장 중요한 곳에 집중하라.
실무적으로 이 메시지는 강력하다. 리소스가 한정된 스타트업이나 소규모 팀이라면, 모든 영역에 DDD를 완벽하게 적용하려고 애쓸 필요가 없다. enrollment(가입)이나 billing(청구)은 적절한 수준의 품질이면 충분하다. 핵심은 "우리 회사의 제일 중요한 게 뭐냐"를 명확히 하고, 거기에 가장 뛰어난 개발자를 배치하고, 반복적인 리팩터링을 통해 심층 모델을 추구하는 것이다.
"다른 것도 대충해라가 아니라, 우선순위를 매겨서 핵심에 역량을 쏟으라는 것."
코어 도메인 식별의 기준은 단순하다. "이 로직이 없으면 이 회사가 존재할 이유가 없는가?" 핸드폰 요금 도메인에서 요금 계산 로직이 없으면 통신사가 아니다. 하지만 계약 관리 시스템이 없어도 통신사의 본질은 변하지 않는다. 계약 관리는 중요하지만, 그 자체가 차별화 포인트는 아니다. 어떤 통신사든 계약 관리는 비슷하기 때문이다.
반면 요금제 설계(어떤 시간대에 어떤 단가를 적용하고, 어떤 할인을 제공하고, 어떤 조합을 허용하느냐)는 경쟁사와의 차이를 만드는 곳이다. 이 영역의 모델이 도메인의 본질을 제대로 반영하고 있느냐가 비즈니스 경쟁력과 직결된다.
그런데 이 코어 도메인에는 아직 문제가 있다.

6. 도메인이 말하는 구조: 상속에서 합성으로

6.1. 부가 정책이라는 새로운 요구사항

기존에는 일반 요금제와 심야 할인 요금제, 두 가지 기본 정책만 있었다. 여기에 새로운 요구사항이 들어온다. 부가 정책의 도입이다.
세금 정책: 기본 요금 계산 후 세금 부과 (예: 4%)
기본 요금 할인 정책: 기본 요금 계산 후 일정 금액 할인 (예: 1,000원)
부가 정책에는 4가지 규칙이 있다.
1.
기본 정책 수행 결과에 적용된다.
2.
선택적으로 적용할 수 있다 (적용할 수도, 하지 않을 수도 있다).
3.
조합이 가능하다 (세금만, 할인만, 둘 다 가능).
4.
적용 순서를 변경할 수 있다 (세금 먼저 또는 할인 먼저).

6.2. 상속의 함정: 클래스 폭발

가장 자연스러운 접근은 상속이다. RatePlanafterCalculated() 훅 메서드를 추가하고, 부가 정책을 하위 클래스로 구현한다.
public abstract class RatePlan extends AggregateRoot<RatePlan, Long> { public Money calculateFee(Collection<Call> calls) { Money fee = calls.stream() .map(call -> this.calculateCallFee(call)) .reduce(Money.ZERO, Money::plus); return afterCalculated(fee); // 부가 정책 적용 훅 } protected Money afterCalculated(Money fee) { return fee; } // 기본: 아무것도 안 함 protected abstract Money calculateCallFee(Call call); }
Java
복사
일반 요금제 + 세금 정책:
public class RegularRateAndTaxablePlan extends RegularRatePlan { private double taxRate; @Override protected Money afterCalculated(Money fee) { return super.afterCalculated(fee).plus(fee.times(taxRate)); } }
Java
복사
이 방식으로 모든 조합을 구현하면 어떻게 되는가?
RatePlan (abstract) +-- RegularRatePlan | +-- RegularRateAndTaxablePlan (세금만) | | +-- RegularRateAndTaxableAndDiscountablePlan (세금 먼저 + 할인) | +-- RegularRateAndDiscountablePlan (할인만) | +-- RegularRateAndDiscountableAndTaxablePlan (할인 먼저 + 세금) +-- NightRatePlan +-- NightRateAndTaxablePlan (세금만) | +-- NightRateAndTaxableAndDiscountablePlan (세금 먼저 + 할인) +-- NightRateAndDiscountablePlan (할인만) +-- NightRateAndDiscountableAndTaxablePlan (할인 먼저 + 세금)
Plain Text
복사
2개의 기본 정책과 2개의 부가 정책, 모든 조합을 나열하면 10개 클래스가 된다.
여기에 새로운 기본 정책(고정 요금제)을 추가하면? 6개 클래스가 더 필요하다. 새로운 부가 정책(약정 할인)을 추가하면? 기존 클래스 수의 3배 이상으로 폭발적 증가.
"클래스를 하나 뭔가 요구사항이 하나 추가가 됐을 때 클래스가 와장창 늘어나는 문제. 이러면 뭔가 내가 상속을 잘못 썼구나라고 생각하시면 돼요."
그리고 세금 정책 코드가 4개 클래스에, 할인 정책 코드도 4개 클래스에 중복된다. 중복은 뭔가 잘못되었다는 신호다.

6.3. 도약: 기본 정책과 부가 정책의 분리

"중복이 있다는 얘기는 내가 적합한 추상화를 발라내지 못한 거예요."
이 문제의 핵심을 들여다보자. 도메인 전문가는 이미 "기본 정책"과 "부가 정책"을 구분해서 이야기한다. "일반 요금제에 세금을 붙이고 할인을 적용해 주세요." 기본 정책과 부가 정책은 도메인에 이미 존재하는 개념이다.
그런데 코드를 보면 이 구분이 없다. RatePlan 상속 트리에 기본 정책과 부가 정책이 뒤섞여 있다. 도메인이 말하는 구조와 코드의 구조가 일치하지 않는 것이다.
"도메인의 개념은 이미 있고(기본 정책 + 부가 정책), 근데 현재 코드에는 그런 게 없어요."
1~2편에서 다룬 도약(Breakthrough)과 동일한 패턴이다. 1편에서 Investment, LoanInvestment, LoanAdjustment 세 클래스가 Share 하나로 통합되었던 것처럼, 여기서도 도메인 개념의 발견이 구조를 단순화한다. "기본 정책과 부가 정책은 서로 다른 축(axis)의 개념이므로 상속이 아니라 합성으로 조합해야 한다"는 통찰이 도약의 순간이다. 이 구조는 GoF의 Decorator 패턴에 해당한다. 기본 정책이라는 축에서는 여전히 상속을 사용하고, 부가 정책이라는 독립된 축에서도 상속을 사용하되, 두 축 사이는 합성으로 연결한다. 핵심은 "상속 대신 합성"이라는 이분법이 아니라, 변화의 축을 분리하는 것이다.

6.4. 합성으로 구현하기

기본 정책과 부가 정책을 분리하여 합성(Composition)으로 조합한다.
부가 정책의 추상 클래스:
public abstract class AdditionalRatePlan { private AdditionalRatePlan next; // 체인 연결 public Money calculate(Money fee) { if (next == null) { return apply(fee); } return next.calculate(apply(fee)); // 현재 적용 후 다음으로 전달 } protected abstract Money apply(Money fee); }
Java
복사
AdditionalRatePlannext 필드를 통해 체인을 구성한다. 현재 부가 정책을 적용(apply)한 뒤, 다음 부가 정책이 있으면 전달한다. 세금을 먼저 적용하고 할인을 적용하고 싶으면 세금 → 할인 순서로 체인을 연결하면 된다.
구체 부가 정책:
public class TaxableRatePlan extends AdditionalRatePlan { private double taxRate; @Override protected Money apply(Money fee) { return fee.plus(fee.times(taxRate)); } } public class DiscountableRatePlan extends AdditionalRatePlan { private Money discountAmount; @Override protected Money apply(Money fee) { return fee.minus(discountAmount); } }
Java
복사
변경된 RatePlan:
public abstract class RatePlan { private AdditionalRatePlan additionalRatePlan; // 부가 정책 체인 public Money calculateFee(Collection<Call> calls) { Money fee = calls.stream() .map(call -> this.calculateCallFee(call)) .reduce(Money.ZERO, Money::plus); if (additionalRatePlan != null) { return additionalRatePlan.calculate(fee); // 부가 정책 적용 } return fee; } protected abstract Money calculateCallFee(Call call); }
Java
복사
RatePlan은 더 이상 afterCalculated() 훅을 사용하지 않는다. 부가 정책은 additionalRatePlan 필드에 조합으로 연결된다.
조합 사용 예시:
// 일반 요금제 + 할인 + 세금 (체이닝) RegularRatePlan plan = new RegularRatePlan( Money.won(10), Duration.ofSeconds(10), new DiscountableRatePlan(Money.won(1), new TaxableRatePlan(0.1))); Money fee = plan.calculateFee(calls);
Java
복사
새로운 클래스를 만들 필요가 없다. 기존 객체를 조합하기만 하면 된다.
"변경 전에는 새로운 클래스를 만들어야 돼. 변경 후에는 그냥 조합하면 돼요."
변경 전후를 비교해 보자.
항목
05-core-domain (상속)
06-distillation (합성)
rateplan 클래스 수
10개+ (조합 폭발)
5개 (핵심만 유지)
부가 정책 확장 방식
상속 (클래스 추가)
조합 (체이닝)
새 기본 정책 추가 시
6개 클래스 추가
1개 클래스만 추가
새 부가 정책 추가 시
기존 클래스 수 x 3배 이상
1개 클래스만 추가
적용 순서 표현
클래스 이름으로 인코딩
체인 연결 순서로 표현
새로운 기본 정책(고정 요금제, FixedRatePlan)을 추가한다고 해보자. RatePlan을 상속하는 클래스 하나만 만들면 된다. 기존의 모든 부가 정책 조합(세금, 할인, 세금+할인, 할인+세금)은 체이닝으로 자동 적용된다. 새로운 부가 정책(약정 할인, ContractRatePlan)을 추가하는 것도 마찬가지다. AdditionalRatePlan을 상속하는 클래스 하나만 만들면, 기존의 모든 기본 정책과 조합이 가능하다.
이것이 디스틸레이션의 실제 적용이다. 코어 도메인(요금 계산)을 식별하고, 그 영역의 설계를 도메인 개념에 맞게 정제하는 과정. 파트 3에서 배운 원칙들(심층 모델, 유연한 설계)을 코어 도메인에 집중 투입한 결과, 도메인 전문가가 이야기하는 "기본 정책 + 부가 정책"이라는 구조가 코드에 그대로 반영되었다.
1편에서 Investment, LoanInvestment, LoanAdjustment 세 클래스가 Share 하나로 통합되었던 것과 같은 패턴이다. 도메인의 본질적 개념을 발견하면, 복잡한 구조가 단순해진다. 상속 트리의 10개 클래스가 합성 구조의 5개 클래스로 줄어든 것은 도메인 개념("기본 정책과 부가 정책은 서로 다른 축이다")을 코드에 반영한 결과다.

7. 대규모 구조

DDD 원서 파트 4의 마지막 주제는 대규모 구조(Large-Scale Structure)다. 시스템 전체를 조감할 수 있는 패턴을 제시한다.
패턴
핵심 아이디어
강사님 평가
System Metaphor
시스템 전체를 하나의 비유로 설명
"점점 사양됨"
Knowledge Level
메타 구조와 운영 구조를 분리
"극단적 유연성 + 극단적 복잡성"
Pluggable Component Framework
도메인 로직의 프레임워크화
"사실상 불가. 요구사항이 회사마다 다름"
Responsibility Layer
도메인 레이어를 역할별로 계층화
유일하게 긍정적 평가
Evolving Order
구조를 지속적으로 발견하고 정제
"한 번 정했다고 고정시키지 마라"
이 중 유일하게 실무에서 의미 있는 것으로 평가된 Responsibility Layer(책임 계층)를 짧게 부연한다.
"모델이 존재하는 개념적 의존성과 도메인의 여러 부분에 대한 다양한 변화율과 변화의 근원을 검토하라. 도메인에서 자연적인 층을 식별하면 그것을 광범위한 추상적 책임으로 간주하라." — Eric Evans, Domain-Driven Design
도메인 레이어 안에서도 역할에 따라 수평적 계층을 나눌 수 있다는 아이디어다. 우리가 익숙한 기술 레이어(Presentation / Domain / Persistence)와는 다른 축이다. 도메인 내부의 개념적 계층이다.
핸드폰 요금 도메인에 적용하면 이렇다.
계층
모듈
역할
변화율
Policy(정책)
rateplan
요금 정책 정의
느린 변화
Operational(운영)
billing
실제 요금 계산 수행
신속한 변화
Capability(역량)
enrollment
계약/핸드폰 관리
적정한 변화
실제 코드에서의 의존 방향을 보면, billing(Operational)이 rateplan(Policy)과 enrollment(Capability) 양쪽에 의존한다. PhoneBillServiceRatePlanContract를 모두 참조하기 때문이다. 반면 rateplan은 enrollment에 의존하지 않는다. Operational 계층이 통합 지점(integration point)인 셈이다. 이 계층 관계는 단일 바운디드 컨텍스트(contract) 내부뿐 아니라, 바운디드 컨텍스트 사이에서도 적용할 수 있다. tracking 바운디드 컨텍스트는 Operational 계층에 해당한다.
"도메인 레이어 안에서도 디펜던시 레이어를 줄 수 있다. 바운디드 컨텍스트 사이에서도 레이어를 줄 수 있다."
Evolving Order(발전하는 질서)는 대규모 구조를 다루는 태도에 관한 것이다.
"이러한 개념적인 대규모 구조가 애플리케이션과 함께 발전하게 해서 발전 과정에서 전혀 다른 형식의 구조로도 변화할 수 있게 하라. 반드시 세부적인 지식을 토대로 내려야 할 세부적인 설계 및 모델과 관련된 의사결정을 과도하게 제약해서는 안 된다." — Eric Evans, Domain-Driven Design
한 번 정했다고 고정시키지 말고, 계속 발전시켜야 한다는 원칙이다. 그리고 Evans는 대규모 구조 자체에 대해 중요한 단서를 달아놓는다.
"CONTEXT MAP과 달리 대규모 구조는 선택사항이다. 사실 MODULE로 분해했을 때 충분히 이해할 수 있을 정도로 시스템이 간단하다면 대규모 구조가 필요하지 않다. 맞지 않는 구조는 차라리 없느니만 못하기 때문에 ... 적을수록 더 많은 법이다." — Eric Evans, Domain-Driven Design
강사님은 솔직하게 평가한다.
"대규모 구조는 거의 지금 얘기를 안 해요. DDD의 범주에 이 대규모 구조라고 하는 거를 거의 얘기하지 않는다."
"솔직히 제가 봐도 없어도 되는 부분이라 몰라도 되는 부분이에요."
DDD 책의 모든 내용이 현재도 유효한 것은 아니다. 대규모 구조 패턴 대부분은 구체적인 "어떻게"가 존재하지 않는 가이드 수준에 머물러 있고, 실무에서는 대부분 사장되었다. 이 점을 인식하고 넘어가는 것이 균형 잡힌 시각이다.

8. 마무리

이번 포스팅에서 다룬 내용을 정리하면 이렇다.
바운디드 컨텍스트: 모델이 유효한 범위를 명시적으로 선언하고, 컨텍스트 간 통합은 명확한 인터페이스(CallTranslator)로 처리한다. 패키지를 나누는 것만으로는 부족하다. 의존 방향을 정리하고 변환 계층을 두어야 한다. 현실적으로 바운디드 컨텍스트의 경계는 조직 단위다. 커뮤니케이션이 그 안에서만 흐르기 때문이다.
컨텍스트 맵: 바운디드 컨텍스트 간의 관계를 조직적 역학에 맞게 정의한다. Shared Kernel, Customer-Supplier, Conformist, ACL, Separate Ways, OHS, Published Language. 이 패턴들은 기술적 결정이 아니라 조직 간 권력 관계와 커뮤니케이션 방식의 반영이다.
디스틸레이션: 코어 도메인을 식별하고 분리하여 역량을 집중한다. 모든 영역에 동일한 에너지를 쏟지 마라. 요금제가 핵심이고, 가입과 청구는 지원이다. 코어 도메인이야말로 파트 3의 도약이 가장 큰 가치를 발휘하는 곳이다.
상속에서 합성으로: 도메인이 말하는 구조("기본 정책 + 부가 정책")를 코드에 반영하면 10개 클래스의 상속 폭발이 5개 클래스의 합성 구조로 해소된다. 디스틸레이션의 실제 적용이며, 코어 도메인에 심층 모델과 유연한 설계를 집중 투입한 결과다.
대규모 구조: 대부분 사장된 개념이다. "적을수록 더 많은 법." 맞지 않는 구조는 차라리 없는 편이 낫다.
이 시리즈 전체를 관통하는 메시지로 마무리하자.
1편에서는 DDD 원서 파트 3의 핵심 개념을 짚었다. 도약(Breakthrough), 심층 모델(Deep Model), 유연한 설계(Supple Design). 그리고 신디케이트 론 도메인에서 Investment, LoanInvestment, LoanAdjustment 세 클래스에 흩어져 있던 암시적 개념이 Share(지분)라는 이름을 얻고 코드의 중심으로 올라오는 과정을 다뤘다. 핵심 메시지는 "코드를 개선하면서 스스로 깨달을 수밖에 없다"였다.
2편에서는 그 Share를 실제 코드에 구현하면서 유연한 설계의 패턴들을 체험했다. 의도를 드러내는 인터페이스, 부수 효과가 없는 함수, 단언, 명령-쿼리 분리, 독립형 클래스, 닫힌 연산, 개념적 윤곽. 핵심 메시지는 "작은 걸음으로 진행하라"였다. 한 번에 한 단계씩, 반복적인 리팩터링을 통해 요구사항이 추가되어도 구조가 요동치지 않는 순간에 도달하는 것.
3편(이번 편)에서는 여러 모델이 공존하는 현실을 다뤘다. 모델의 경계를 명시적으로 긋고(바운디드 컨텍스트), 경계 간 관계를 정의하고(컨텍스트 맵), 핵심 도메인을 식별하고 분리하여(디스틸레이션), 거기에 파트 3의 원칙들을 집중 투입하는 과정이었다. 핸드폰 요금 계산 도메인이 6단계에 걸쳐 01-rateplan-start(모놀리식)에서 06-distillation(합성 기반 유연한 구조)까지 진화하는 과정을 코드와 함께 따라갔다. 핵심 메시지는 "모델의 경계를 긋고, 핵심에 집중하라"였다.
강사님은 파트 4의 핵심을 이렇게 정리한다.
"파트 4 얘기는 바운디드 컨텍스트와 디스틸레이션. 컨텍스트 맵의 내용만 알아주시면 돼요."
그리고 세 편을 관통하는 공통 메시지는 하나다.
"한 번에는 못해요. 시간이 필요합니다. 오랜 시간 내가 도메인을 분석하고... 회사도 어느 정도 이 도메인에 대한 성숙도가 필요해."
"한 번에 하면 됩니다? 한 번에 하면 사이즈로 어렵지만 현실적으로도 우리가 시간이 지나면서 깨우치는 것들이 있습니다."
처음부터 완벽한 모델을 설계할 수는 없다. 처음부터 완벽한 경계를 그을 수도 없다. 요구사항을 받고, 코드를 수정하고, 도메인을 더 깊이 이해하는 과정을 반복하면서 모델과 경계가 함께 성장한다.
1편에서 Share를 발견하기까지, 2편에서 SharePie를 도입하기까지, 3편에서 기본 정책과 부가 정책을 분리하기까지. 모두 한 번에 이루어진 것이 아니었다. 반복적인 리팩터링을 통해, 한 단계씩, 도메인의 본질에 가까워지는 과정이었다.
전략적 설계의 핵심 메시지를 요약하면 이렇다. 모델을 하나로 유지하려는 환상을 버려라. 경계를 긋고(바운디드 컨텍스트), 경계 간 관계를 정의하고(컨텍스트 맵), 핵심을 식별하여 분리하고(디스틸레이션), 핵심 영역에 파트 3의 원칙들을 집중 투입하라. 나머지는 적절한 수준이면 충분하다.
그리고 이 리팩터링은 코드만의 문제가 아니다. 3편에서 다룬 바운디드 컨텍스트와 컨텍스트 맵은 조직 구조와 팀 간 커뮤니케이션의 문제이기도 하다. 전략적 설계의 부제가 "개발과 정치가 만나는 곳"인 이유다. 기술적으로 완벽한 경계를 그어도, 조직이 그 경계를 존중하지 않으면 의미가 없다. 반대로 조직이 분리되어 있어도, 모델의 경계가 명시적이지 않으면 섹션 3.3에서 본 것처럼 장애가 발생한다.
한 번에는 안 되더라도, 시간을 들여 리팩터링을 반복하면 도약의 순간이 온다. 모델과 경계가 도메인과 함께 성장한다.
DDD는 결국 "도메인을 이해하는 과정"이다. 기술적 테크닉의 모음이 아니라, 도메인 전문가와 개발자가 함께 유비쿼터스 언어를 정의하고, 반복적인 리팩터링을 통해 모델을 정제하고, 조직 구조에 맞게 경계를 긋고, 핵심에 집중하는 여정이다.
파트 3과 파트 4는 동전의 양면이다. 파트 3이 "어떻게 깊이 파고들 것인가"(전술적 설계)라면, 파트 4는 "어디에, 어떤 범위로 파고들 것인가"(전략적 설계)다. 디스틸레이션이 이 둘을 연결한다. 코어 도메인을 식별하고(전략), 거기에 심층 모델과 유연한 설계를 집중 투입하는 것(전술). 전략 없는 전술은 방향이 없고, 전술 없는 전략은 실행이 없다.
완벽한 출발점은 없다. 하지만 방향은 분명하다.
"코드를 봤을 때 이해하기 쉽고, 단순하고 명확한 설계가 목표다. 코드 변경 없이 확장할 수 있는 구조가 아니라, 변경에 따른 부수효과를 파악할 수 있는 이해하기 쉬운 설계."
이것으로 "도메인 주도 설계의 사실과 오해" 시리즈를 마친다.
DDD 원서의 4개 파트 중, 파트 3(더 심층적인 통찰력을 향한 리팩터링)과 파트 4(전략적 설계)를 중심으로 다뤘다. 가장 많이 인용되지만 가장 적게 실천되는 파트 2(빌딩블록)가 아니라, 가장 적게 언급되지만 가장 본질적인 파트 3을 먼저 다루고, 그 원칙들을 "어디에 집중할 것인가"라는 전략적 물음과 연결한 것이 이 시리즈의 구성이었다.
강사님이 첫 시간에 했던 말을 다시 한번 빌리며 마무리한다.
"DDD에서 뭐 하나만 보라고 할 거야라고 하면 파트 3. 두세 챕터 정도는 곱씹어 보시는 게 되게 좋습니다."

9. Reference

Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
조영호, "도메인 주도 설계의 사실과 오해" 강의 Part 2