1. 들어가기 앞서
이 글은 조영호님의 "도메인 주도 설계의 사실과 오해" 강의 2부를 정리한 시리즈의 첫 번째 포스팅이다. 2부의 주제는 Eric Evans의 DDD 원서에서 파트 3 "더 심층적인 통찰력을 향한 리팩터링(Refactoring Toward Deeper Insight)"에 해당하는 내용이다.
왜 하필 파트 3인가? 강사님은 이렇게 말했다.
DDD에서 뭐 하나만 보라고 할 거야라고 하면 파트 3. 두세 챕터 정도는 곱씹어 보시는 게 되게 좋습니다. 단순히 DDD뿐만 아니라 내가 설계를 왜 해야 되고, 유지 보수 관점에서 어떤 관점에서 이걸 바라봐야 되고, 커뮤니케이션을 어떻게 해야 되는지가 거의 다 담겨 있어요.
이 포스팅에서는 DDD 원서의 전체 구조에서 파트 3이 왜 중요한지를 짚고, 도약(Breakthrough), 심층 모델(Deep Model), 유연한 설계(Supple Design) 같은 핵심 개념을 정리한 뒤, 신디케이트 론(Syndicated Loan) 도메인 예제를 통해 초기 모델에서 암시적 개념을 발견하기까지의 과정을 코드와 함께 따라가본다.
1부를 읽지 않았어도 이 글만으로 독립적으로 이해할 수 있도록 구성했다.
2. 잊혀진 파트, 하지만 가장 중요한 파트
Eric Evans의 DDD 원서는 4개의 파트로 구성되어 있다. 전체 맵을 한번 조감해 보자.
파트 | 제목 | 장 | 핵심 주제 |
파트 1 | 동작하는 도메인 모델 만들기 | 1~3장 | 지식 탐구, 유비쿼터스 언어, 모델 주도 설계 |
파트 2 | 모델 주도 설계의 빌딩블록 | 4~7장 | 레이어 아키텍처, 엔티티/값 객체/서비스, 애그리게이트/리포지토리 |
파트 3 | 더 심층적인 통찰력을 향한 리팩터링 | 8~13장 | 도약, 암시적 개념 명확화, 유연한 설계, 분석 패턴 |
파트 4 | 전략적 설계 | 14~17장 | 바운디드 컨텍스트, 디스틸레이션, 대규모 구조 |
대부분의 DDD 관련 논의는 파트 2(빌딩블록)와 파트 4(전략적 설계)에 집중되어 있다. "애그리게이트를 어떻게 설계하나요?", "바운디드 컨텍스트를 어떻게 나누나요?" 같은 질문이 주를 이룬다. 파트 3은 거의 잊혀져 있다.
왜 그럴까? 에릭 에반스의 원서 이후로 파트 3을 본격적으로 다룬 후속 서적이 거의 없기 때문이다. 대부분의 DDD 관련 책들은 "자바로 DDD 이렇게 해요", "C#으로 DDD 이렇게 해요" 같은 구현 관점의 이야기를 한다. 파트 3이 다루는 내용은 도메인마다 다르고, 카탈로그화할 수 없으며, 설명하기가 본질적으로 어렵다. 그래서 사람들이 자연스럽게 언급을 피하게 된 거다.
하지만 강의에서 조영호님은 이 파트의 중요성을 강하게 역설했다.
"다른 걸 다 하더라도 파트 3에 빠져 있으면 DDD를 안 한 게 아닐까라는 생각이 들 정도로 되게 중요하다."
형식적으로 애그리게이트를 나누고, 유비쿼터스 언어를 정의하고, 바운디드 컨텍스트를 그려도 파트 3의 핵심인 반복적인 리팩터링을 통한 도약 없이는 DDD의 본질적 가치를 놓치게 된다는 이야기다.
3. DDD의 핵심: 도약(Breakthrough)
파트 3의 핵심 개념은 네 가지로 정리할 수 있다. 도약(Breakthrough), 심층 모델(Deep Model), 유연한 설계(Supple Design), 그리고 이 셋을 연결하는 리팩터링(Refactoring)이다.
3.1. 도약(Breakthrough)
프로젝트 초반에 우리가 짜는 코드는 서툴다. 완벽할 수 없다. 기술적으로, 기계적으로는 괜찮을 수 있지만 도메인 관점에서는 아직 멀다. 그런데 요구사항이 들어오고, 코드를 수정하고, 또 수정하는 과정을 반복하다 보면 어느 순간 특별한 일이 일어난다.
"야금야금 수정하고 리팩터링을 하다 보면... 어느 순간 갑자기 확 올라가는 순간이 온다."
이것이 도약(Breakthrough)이다. Value(가치)를 Y축에, Time/Refactoring을 X축에 놓으면 계단형 그래프가 그려진다. 조금씩 조금씩 올라가다가, 어느 지점에서 가파르게 상승하는 구간이 나타난다.
Value
│
│ ┌────── ← Breakthrough!
│ │
│ │
│ │
│ │
│ ┌────┘
│ ┌────┘
│ ┌────┘
│ ┌────┘
│ ────┘
└──────────────────────────────── Time / Refactoring
Plain Text
복사
도약이 일어나면 어떤 변화가 생기는가? 요구사항이 들어올 때마다 코드를 크게 뒤집어야 했던 상황에서, 어느 시점부터는 지역적으로만 바뀌고 전체 구조는 크게 변하지 않는 순간이 온다. 도메인의 핵심 구조를 제대로 이해하고 코드에 반영했기 때문이다. 이 시점부터 기술적으로도 좋은 설계, 도메인적으로도 좋은 설계가 함께 가게 된다.
3.2. 심층 모델(Deep Model)
도약을 통해 도달하는 첫 번째 목표가 심층 모델(Deep Model)이다. 원서에서는 이렇게 정의한다.
"심층 모델이란 도메인의 피상적인 측면은 배제하고 도메인 전문가의 주요 관심사와 가장 적절한 지식을 알기 쉽게 표현하는 모델이다. (...) 그러한 모델이 공통적으로 지니고 있는 한 가지 특징은 업무 전문가가 즐겨 쓰는 단순하지만 충분히 추상적인 언어가 존재한다는 것이다."
-- Eric Evans, Domain-Driven Design
심층 모델의 핵심 판별법은 간단하다.
"코드를 해석해서 도메인 전문가의 개념으로 번역해야 한다면, 심층 모델이 아니다."
도메인 전문가가 자기 머릿속에 있는 개념과 내가 설명하는 코드의 구조가 자연스럽게 일치하는 방식으로 코드를 구성할 수 있으면 그게 심층 모델이다. 반대로, 코드의 알고리즘을 하나하나 해석해서 "이건 도메인에서 이런 뜻이에요"라고 번역해야 한다면, 아직 심층 모델에 도달하지 못한 거다.
다시 말해, 기계적 알고리즘이나 데이터의 조합이 아니라 도메인 전문가의 언어와 개념으로 코드가 표현되어야 한다.
3.3. 유연한 설계(Supple Design)
도약의 두 번째 목표는 유연한 설계(Supple Design)이다.
"지속적으로 리팩터링을 수행하려면 설계 자체가 변경을 지원해야 한다. (...) 도메인에 대해 새롭게 알게 된 내용을 반영하도록 지속적으로 모델과 코드를 변경하는 실제 행위 자체로 말미암아 여러 부분에 중복될 수 있는 공통적인 부분을 손쉽게 처리하는 동시에 변경이 필요한 적절한 지점에 유연성을 제공할 수 있다."
-- Eric Evans, Domain-Driven Design
여기서 주의할 점이 있다. DDD에서 말하는 "유연한 설계"는 우리가 흔히 떠올리는 "추상화가 많은 복잡한 설계"가 아니다.
"유연한 설계는 복잡한 설계가 아니다. 단순한 설계다."
강의에서 조영호님이 든 비유가 인상적이었다.
"처음에 딱딱한 장갑이 계속 사용하면 관절 부위가 유연해지는 것처럼."
새 가죽장갑은 빳빳하다. 그런데 계속 사용하면 관절 부위가 부드러워진다. 내가 항상 구부리는 곳이 유연해지는 거다. 코드도 마찬가지다. 도메인을 계속 이해하고 수정하다 보면, 어느 부분이 자주 바뀌는지 알게 된다. 그 부분을 잘 바뀔 수 있도록 유연하게 만드는 것이다.
Supple Design은 Flexible Design과 다르다. Flexible은 코드를 안 건드리고 확장하는 개념(플러그인처럼)이지만, Supple은 코드를 건드려도 요동치지 않는 것을 의미한다. 손을 대도 안정적인, 도메인의 본질에 기반한 단순한 설계, 그것이 유연한 설계다.
3.4. 리팩터링: DDD의 심장
도약(Breakthrough)은 저절로 오지 않는다. 심층 모델과 유연한 설계를 향한 반복적인 리팩터링이 있어야 한다. 리팩터링은 크게 세 가지 범주로 나눌 수 있다.
범주 | 대표 서적 | 특징 |
마이크로 리팩터링 | Refactoring 2판 (Martin Fowler) | 코드 수준의 작고 안전한 변경. 한 줄 한 줄 아주 짧게. |
패턴을 향한 리팩터링 | Refactoring to Patterns (Joshua Kerievsky) | 디자인 패턴을 활용한 구조 개선. |
심층 모델을 향한 리팩터링 | DDD (Eric Evans) | 도메인 관점의 리팩터링. 카탈로그화 불가. |
세 번째 범주가 핵심이다. 마이크로 리팩터링이나 패턴을 향한 리팩터링은 기술적이고 기계적인 요소가 크다. "이런 상황에서는 이렇게 해라"라는 카탈로그를 만들 수 있다. 하지만 심층 모델을 향한 리팩터링은 도메인마다 다르기 때문에 카탈로그로 정리할 수가 없다. 그래서 이 부분이 거의 언급되지 않는 거다.
그렇다면 DDD를 한다는 것은 무엇인가?
DDD를 한다? 반복적인 리팩터링이 필요. 이걸 안 한다? 형식만 갖고온 것 (애그리게이트 등...)
-- 강의 내용 발췌
리팩터링을 하려면 전제 조건이 있다. 실수를 용납하는 조직 문화와 투자다. 처음에 잘못 짠 것을 인정하고, 더 좋은 방법을 발견했을 때 고칠 수 있는 시간과 환경이 주어져야 한다. 리팩터링이 두렵거나 하고 싶지 않은 환경에서는 도약이 일어나기 어렵다.
참고로 조영호님은 테스트 코드가 없는 레거시 환경에서의 리팩터링을 위해 레거시 코드 활용 전략(Working Effectively with Legacy Code) 도서를 추천했다.
4. 모델 주도 설계의 두 가지 축
모델 주도 설계(Model-Driven Design)란 모델과 핵심 설계가 서로 영향을 주며 반복을 통해 구체화되는 접근 방식이다. 예를 들어 이런 코드를 보자.
Order order = orderRepository.getOrder(orderId);
Payment payment = order.pay();
Java
복사
"주문"이라는 도메인 모델의 개념이 코드에 그대로 드러난다. 도메인을 분석하는 과정이 곧 코드의 구조를 설정하는 과정과 같다는 것, 이것이 모델 주도 설계의 본질이다.
"도메인 모델이라는 건 현실에 없는 것이다. 내가 코드를 짜기 편한 형태로 재구성한 것."
이 모델 주도 설계에는 두 가지 축이 있다.
1.
심층 모델(Deep Model): 설계에 표현력을 부여해서 도메인을 명확하게 표현한다.
2.
유연한 설계(Supple Design):-개발자가 여러 가지 시도를 할 수 있는, 수정하기 쉬운 설계를 만든다.
이 두 축을 향한 리팩터링이 도약(Breakthrough)을 만든다. 도약의 순간은 이렇게 설명할 수 있다.
"어느 시점이 되면 전체적인 코드가 지역적으로만 바뀌고 전체 구조가 크게 안 변해. 그 순간이 도약이다."
하지만 정답이 없기 때문에 가장 이해하기도, 설명하기도 어려운 부분이다. 도메인마다 다르고, 사람마다 깨닫는 과정이 다르며, 조직 구조에 따라서도 달라진다. 그래서 코드를 직접 보면서 "대충 이런 느낌이구나, 코드가 이렇게 바뀌다 보니 뭔가 단순해지는구나"를 체감하는 것이 중요하다.
이제 그 체감을 위해 신디케이트 론 도메인으로 들어가 보자.
5. 신디케이트 론(Syndicated Loan): 도메인 이해하기
신디케이트 론(Syndicated Loan)은 Eric Evans의 DDD 원서에 등장하는 대표 예제다. 낯선 도메인일 수 있으니, 먼저 핵심 용어를 정리하고 스토리로 풀어보자.
5.1. 유비쿼터스 언어 정리
용어 | 영어 | 의미 |
퍼실리티 | Facility | 돈을 대출해 줄 회사와의 매매 계약 (대출 한도 정의) |
채권은행단 | Syndicate | 대출을 위해 모인 여러 금융 회사 집단 |
투자 | Investment | 퍼실리티 안에서 각 회사별 분담 비율 |
대출 | Loan | 퍼실리티에서 실제로 인출한 금액 |
대출 투자 | Loan Investment | 대출에서 각 회사별 분담 총액 |
원서에서 퍼실리티는 이렇게 설명된다.
"여기서 이야기하는 '퍼실리티'는 어떤 건축물을 의미하지 않는다. 대부분의 프로젝트와 마찬가지로 도메인 전문가가 사용하는 특수한 용어가 어휘집에 편입되어 UBIQUITOUS LANGUAGE의 일부가 된다. 상업 은행 도메인에서 퍼실리티란 돈을 대출해 줄 회사와의 매매 계약을 의미한다."
-- Eric Evans, Domain-Driven Design
5.2. 스토리로 이해하기
어떤 회사가 공장을 짓고 싶다. 건물을 세우고, 설비를 사고... 어마어마한 돈이 필요하다. 이 금액을 어느 한 은행이 혼자 감당하기는 어렵다. 리스크가 너무 크니까. 그래서 여러 금융 회사가 모여서 채권은행단(Syndicate)을 구성한다. "우리가 같이 돈을 빌려주자!"
이 채권은행단과 대출 계약을 체결하는 것이 퍼실리티(Facility)다. 퍼실리티에는 대출 가능한 최대 금액(한도)이 정의된다. 신용카드로 비유하면, "너는 한 달에 500만 원까지 긁을 수 있어"라고 정해놓는 그 계약이 퍼실리티인 거다.
이제 구체적인 숫자로 보자.
•
퍼실리티: 대출 한도 1,000원
•
투자(Investment): A은행 50%, B은행 20%, C은행 30%
100원을 대출하면? 퍼실리티의 투자 비율에 따라 분배한다.
•
A: 50원, B: 20원, C: 30원
추가로 200원을 더 대출하면? 총 300원이 된다.
•
A: 150원(50+100), B: 60원(20+40), C: 90원(30+60)
각 은행이 대출에서 분담하고 있는 총액을 대출 투자(Loan Investment)라고 부른다. 도메인을 이해했으니 이제 코드를 보자.
6. 초기 모델 : 코드로 보는 신디케이트 론
6.1. 클래스 다이어그램
┌─────────────────────────────────┐
│ <<aggregate root>> Facility │
├─────────────────────────────────┤
│ limit: Money │
├─────────────────────────────────┤
│ takeOutLoan(amount) │
└──────────┬────────────────┬─────┘
│ │
│1 │*
▼ ▼
┌──────────────────┐ ┌──────────────────────────┐
│ <<entity>> Loan │ │ <<value>> Investment │
├──────────────────┤ ├──────────────────────────┤
│ amount: Money │ │ investor: Company │
├──────────────────┤ │ percentage: double │
│ increase(amount) │ ├──────────────────────────┤
│ distribute() │ │ calculateShare(amount) │
└────────┬─────────┘ └──────────────────────────┘
│ ▲
│* │ 참조
▼ │
┌────────────────────────────┐ │
│ <<value>> LoanInvestment │──────┘
├────────────────────────────┤
│ /amount: Money │
├────────────────────────────┤
│ distribute(total) │
└────────────────────────────┘
Plain Text
복사
Facility가 애그리게이트 루트(Aggregate Root)이고, Loan은 엔티티(Entity), Investment와 LoanInvestment는 값 객체(Value Object)다. 이제 실제 코드를 보자.
6.2. 핵심 코드 발췌
Facility + Investment - 퍼실리티와 투자 비율 정의
public class Facility extends AggregateRoot<Facility, Long> {
private Long id;
private Money limit; // 대출 한도
private Loan loan;
private Set<Investment> investments = new HashSet<>();
public void takeOutLoan(Money amount) {
if (loan.amount().plus(amount).isGreaterThan(limit)) {
throw new IllegalStateException();
}
loan.increase(amount);
}
}
public class Investment extends ValueObject<Investment> {
private Company company;
private double percentage; // 회사별 비율
public Money calculateShare(Money amount) {
return amount.times(percentage);
}
}
Java
복사
Facility는 대출 한도(limit)와 투자 목록(investments)을 가지고 있다. takeOutLoan()을 호출하면 한도를 넘는지 검증한 뒤, Loan에 금액 증가를 위임한다. Investment는 각 회사가 퍼실리티에서 차지하는 비율(percentage)을 들고 있고, calculateShare()로 금액을 비율에 맞게 계산한다.
Loan + LoanInvestment - 대출 금액과 회사별 분배
public class Loan extends DomainEntity<Loan, Long> {
private Long id;
private Money amount; // 대출 총액
private Set<LoanInvestment> loanInvestments = new HashSet<>();
public void increase(Money amount) {
this.amount = this.amount.plus(amount);
distribute(amount);
}
private void distribute(Money amount) {
this.loanInvestments = loanInvestments.stream()
.map(each -> each.distribute(amount))
.collect(Collectors.toSet());
}
}
public class LoanInvestment extends ValueObject<LoanInvestment> {
private Investment investment;
private Money amount; // 회사별 대출금
public LoanInvestment distribute(Money total) {
return new LoanInvestment(
investment,
this.amount.plus(investment.calculateShare(total))
);
}
}
Java
복사
Loan의 increase() 메서드를 보자. 총액을 갱신하고(this.amount.plus(amount)), distribute()를 호출해서 각 투자 비율에 따라 금액을 분배한다. 여기서 LoanInvestment.distribute()는 기존 객체를 변경하지 않고 새 LoanInvestment를 만들어 반환한다는 점에 주목하자. 값 객체(Value Object)답게 불변(immutable)을 유지하고 있다.
전체 흐름은 이렇다: Facility.takeOutLoan() → Loan.increase() → distribute() → LoanInvestment.distribute()
한도 검증 → 총액 갱신 → 각 투자 비율에 따라 분배. 깔끔하다.
테스트 코드로 검증
@Test
public void 회사별_퍼실러티_대출_금액() {
Company companyA = new Company(1L, "A");
Company companyB = new Company(2L, "B");
Company companyC = new Company(3L, "C");
// 대출 한도와 지분 설정
Facility facility = Facility.crate(
Money.won(1000), // 대출 한도 1,000원
Set.of(
new Investment(companyA, 0.5), // 50%
new Investment(companyB, 0.2), // 20%
new Investment(companyC, 0.3))); // 30%
// 대출 한도 1000원 중에서 총 300원 인출
facility.takeOutLoan(Money.won(100));
facility.takeOutLoan(Money.won(200));
// 회사별 금액
assertThat(facility.investAmountOf(companyA)).isEqualTo(Money.won(150)); // 150원
assertThat(facility.investAmountOf(companyB)).isEqualTo(Money.won(60)); // 60원
assertThat(facility.investAmountOf(companyC)).isEqualTo(Money.won(90)); // 90원
}
Java
복사
1,000원 한도에 A:50%, B:20%, C:30% 비율. 100원을 먼저 인출하고 200원을 추가 인출하면 총 300원. 결과는 A:150원, B:60원, C:90원. 테스트가 깔끔하게 통과한다. 여기까지는 아무 문제가 없다.
7. 미처 파악하지 못했던 요구사항
초기 모델이 잘 동작하는 것처럼 보였다. 그런데 새로운 요구사항이 들어온다.
"비율은 일반적인 가이드라인이고, 비율과 무관하게 대출 가능합니다."
무슨 뜻인가? B은행이 두 번째 대출에 참가하지 않는 경우가 있을 수 있다는 거다. 첫 번째 대출 100원은 기본 비율(50:20:30)대로 분배했지만, 두 번째 대출 200원에서는 B은행이 빠지고 C은행이 B의 몫을 부담한다.
결과: A 150원, B 20원, C 130원. 퍼실리티 비율 50:20:30과 다르다.
기존 모델에서는 투자 비율에 따라 자동으로 분배하는 구조였으니, 이런 케이스를 처리할 수 없다. 여기서 개발자의 선택이 갈린다. 기존 모델을 수정하고 싶지 않은 개발자는 어떻게 할까? 상속을 통한 점진적 확장을 시도한다.
7.1. LoanAdjustment의 등장
기본 비율대로 먼저 계산한 뒤, 금액을 조정하는 방식이다. B의 대출금 60원 중 2/3인 40원을 C로 이전한다.
LoanAdjustment - LoanInvestment를 상속한 "조정" 클래스
public class LoanAdjustment extends LoanInvestment {
private LoanInvestment original;
public LoanAdjustment(LoanInvestment loanInvestment, Money amount) {
super(loanInvestment.investment(), loanInvestment.amount().plus(amount));
this.original = loanInvestment;
}
}
Java
복사
LoanAdjustment는 LoanInvestment를 상속받아서, 원본 참조(original)를 유지하면서 금액을 조정한다.
Facility.transfer() - 대출 지분 이전
// Facility에 추가된 메서드
public void transfer(Company source, Company destination, double percent) {
Money amount = loan.investAmountOf(source).times(percent);
loan.adjustShare(source, amount.negate());
loan.adjustShare(destination, amount);
}
Java
복사
source 회사에서 일정 비율만큼의 금액을 빼고(negate()), destination 회사에 더한다.
Loan.adjustShare() - Set에서 remove/add로 교체
// Loan에 추가된 메서드
public void adjustShare(Company company, Money amount) {
if (loanInvestmentOf(company).isEmpty()) {
throw new IllegalArgumentException();
}
LoanInvestment loanInvestment = loanInvestmentOf(company).get();
LoanAdjustment loanAdjustment = new LoanAdjustment(loanInvestment, amount);
loanInvestments.remove(loanInvestment);
loanInvestments.add(loanAdjustment);
}
Java
복사
기존 LoanInvestment를 찾아서 제거하고(remove), 조정된 LoanAdjustment를 새로 추가한다(add).
테스트로 확인
@Test
public void 회사별_대출_금액_조정() {
Company companyA = new Company(1L, "A");
Company companyB = new Company(2L, "B");
Company companyC = new Company(3L, "C");
Facility facility = Facility.crate(
Money.won(1000),
Set.of(
new Investment(companyA, 0.5),
new Investment(companyB, 0.2),
new Investment(companyC, 0.3)));
facility.takeOutLoan(Money.won(100));
facility.takeOutLoan(Money.won(200));
// B의 현재 금액 60원에서 2/3에 해당하는 금액 40원을 C로 이동
facility.transfer(companyB, companyC, 2.0 / 3.0);
assertThat(facility.investAmountOf(companyA)).isEqualTo(Money.won(150)); // 150원
assertThat(facility.investAmountOf(companyB)).isEqualTo(Money.won(20)); // 20원
assertThat(facility.investAmountOf(companyC)).isEqualTo(Money.won(130)); // 130원
}
Java
복사
A:150원, B:20원, C:130원. 테스트는 통과한다. 동작은 한다. 하지만 이 코드에는 심각한 문제가 숨어 있다.
7.2. 이 코드의 문제점
첫째, 퍼실리티 지분과 대출 지분을 강하게 결합시켰다. 대출의 지분을 계산할 때 반드시 퍼실리티의 투자 비율(Investment.calculateShare())을 거쳐야 한다. 퍼실리티의 지분을 기반으로 대출의 지분을 계산하는 구조인데, 이것은 도메인의 본질과 맞지 않는다.
둘째, ValueObject 상속이 제거되었다. 01-syndicated-loan에서 Investment와 LoanInvestment는 ValueObject를 상속받고 있었다. 그런데 LoanAdjustment가 LoanInvestment를 상속해야 하기 때문에, 더 이상 ValueObject를 상속할 수 없게 되었다. 값 객체에서 일반 클래스로 격하된 거다.
셋째, 불변 패턴이 가변 패턴으로 퇴보했다. 초기 모델에서 distribute()는 map()으로 새로운 Set을 만들었다. 하지만 adjustShare()에서는 remove()와 add()로 기존 Set을 직접 변경한다.
넷째, LoanAdjustment는 도메인에 없는 용어다. 이것이 가장 중요한 신호다.
"우리 코드에 특별한 이유 없이 도메인에 없는 용어를 썼다면 한번 의심해봐라."
도메인 전문가는 "대출 조정(Loan Adjustment)"이라는 말을 쓰지 않는다. 이건 개발자가 기존 코드를 최소한으로 수정하기 위해 만들어낸 인위적인 개념이다. 도메인에 없는 용어가 코드에 등장한다면, 그것은 적합한 추상화를 놓쳤다는 신호다.
8. 도메인의 본질: 서로 다른 두 가지 지분(Share)
8.1. 더 복잡해지는 요구사항들
여기서 끝이 아니다. 요구사항은 계속 들어온다.
원금 상환: 현재 대출 300원(A:150, B:20, C:130)에서 100원을 상환한다. 이 100원을 각 은행에 어떤 비율로 차감할 것인가? 퍼실리티 비율(50:20:30)이 아니다. 대출 지분 비율(약 50:7:43)로 분배한다!
•
A: 50원 차감(150→100), B: 7원 차감(20→13), C: 43원 차감(130→87)
수수료 납입: 10원의 수수료를 각 은행에 지급한다. 이번에는 어떤 비율로? 퍼실리티 지분 비율(50:20:30)로 분배한다!
•
A: 5원, B: 2원, C: 3원
핵심 규칙을 표로 정리해 보자.
상황 | 기준 지분 | 비율 |
대출 출자 | 퍼실리티 지분 (기본) | 50 : 20 : 30 |
원금 상환 분배 | 대출 지분 | 50 : 7 : 43 |
수수료 분배 | 퍼실리티 지분 | 50 : 20 : 30 |
여기서 패턴이 보이기 시작한다. 점점 더 중요해지는 개념이 있다. 지분(Share)과 비율이다.
8.2. 통찰: 암시적 개념의 발견
요구사항을 하나하나 추가하다 보니, 어떤 통찰이 떠오른다.
퍼실리티의 지분과 대출의 지분은 서로 독립적이다!
기존 모델의 근본적인 문제가 여기에 있다. 퍼실리티의 투자 비율(50:20:30)로부터 대출의 분배를 계산하는 구조였는데, 실제로는 대출이 한번 이루어지고 나면 대출의 지분은 퍼실리티의 지분과 독립적으로 존재한다. 원금 상환은 대출의 지분을 기준으로, 수수료는 퍼실리티의 지분을 기준으로 -- 각각 독립적으로 동작한다.
이 독립성을 기존 코드가 표현하지 못하고 있었다. LoanInvestment는 항상 Investment를 참조하고, Investment.calculateShare()를 통해야만 금액을 계산할 수 있었다. 퍼실리티 지분에 종속되어 있었던 거다.
여기서 도메인 전문가와 커뮤니케이션하기 위한 표기법이 등장한다. **파이 차트(원형 그래프)**로 두 지분을 시각적으로 구분하는 것이다.
•
퍼실리티 파이: A:500, B:200, C:300 (한도 1,000원 기준)
•
대출 파이: A:150, B:20, C:130 (대출 300원 기준)
두 개의 파이는 각각 독립적이다. 대출 출자, 원금 상환, 수수료 납입 등 모든 계산은 "어떤 파이를 기준으로 분배할 것인가"의 문제로 귀결된다.
그리고 이 모든 것의 밑바닥에 지분(Share)이라는 단 하나의 암시적 개념이 숨어 있었다.
8.3. 이것이 바로 도약(Breakthrough)
기존 코드에는 Investment, LoanInvestment, LoanAdjustment... 여러 클래스가 각기 다른 방식으로 금액과 비율을 다루고 있었다. 기능별로 따로따로 구현하다 보니 전체를 관통하는 개념이 보이지 않았다.
그런데 요구사항을 반복적으로 받고, 코드를 수정하고, 도메인에 대해 더 깊이 이해하게 되면서 Share(지분)라는 단 하나의 개념이 모든 것을 설명한다는 통찰에 도달한다.
•
퍼실리티도 Share를 가지고 있음 (각 회사의 출자 금액/비율)
•
대출도 Share를 가지고 있음 (각 회사의 대출 분담 금액/비율)
•
대출 출자
◦
퍼실리티의 Share를 기준으로 금액을 분배(prorate)해서 새로운 Share를 만드는 것
•
원금 상환
◦
대출의 Share를 기준으로 금액을 분배하는 것
•
수수료
◦
퍼실리티의 Share를 기준으로 금액을 분배하는 것
Share에서 Share를 만들어내고, Share와 Share를 더하고, Share를 기준으로 금액을 분배하는 것 -- 이것이 도메인의 본질이다.
이것이 바로 암시적 개념을 명확하게 표현하기(Making Implicit Concepts Explicit)다. 코드 속에 흩어져 있던, 이름조차 없던 개념이 Share라는 명시적인 이름을 얻고, 코드의 중심으로 올라오는 순간. 이것이 도약(Breakthrough)이다.
"계속 다양한 요구사항을 받다 보면, 반복적으로 튀어나오는 것들이 있다. 걔는 되게 중요한 애들이다. 걔를 명시적으로 코드에 표현하는 게 좋지 않겠냐."
9. 마무리
이번 포스팅에서 다룬 내용을 정리하면 이렇다.
•
DDD 원서 파트 3의 위치와 중요성
◦
잊혀졌지만 가장 중요한 파트. 리팩터링 없이 형식만 차용하면 DDD가 아니다.
•
도약(Breakthrough)
◦
반복적 리팩터링을 통해 모델의 가치가 급격히 상승하는 순간.
•
심층 모델(Deep Model)
◦
도메인 전문가의 개념과 코드 구조가 일치하는 모델.
◦
코드를 해석해서 번역해야 한다면 심층 모델이 아니다.
•
유연한 설계(Supple Design)
◦
추상화가 많은 복잡한 설계가 아니라, 손을 대도 요동치지 않는 단순한 설계.
•
신디케이트 론 도메인
◦
퍼실리티, 투자, 대출, 대출 투자의 관계를 코드로 구현.
•
초기 모델의 한계
◦
새로운 요구사항(비율과 무관한 대출)이 기존 모델의 문제를 노출.
◦
LoanAdjustment라는 도메인에 없는 용어의 등장.
•
암시적 개념 Share의 발견
◦
퍼실리티의 지분과 대출의 지분은 서로 독립적이다.
◦
이 둘을 관통하는 Share(지분)라는 핵심 개념이 암시적으로 숨어 있었다.
핵심 메시지는 하나다. 코드를 개선하면서 스스로 깨달을 수밖에 없다. 처음부터 완벽한 모델을 만들 수는 없다. 요구사항을 받고, 코드를 수정하고, 도메인을 더 깊이 이해하는 과정을 반복하다 보면 암시적으로 숨어 있던 핵심 개념이 드러나는 순간이 온다.
다음 편에서는 이 Share를 실제로 코드에 구현하면서 유연한 설계(Supple Design)의 패턴들 (Intention-Revealing Interface, Side-Effect-Free Function, Standalone Class, Closure of Operation, Conceptual Contour ) 을 하나씩 체험해 볼 예정이다.
10. Reference
•
Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
•
조영호, "도메인 주도 설계의 사실과 오해" 강의 Part 2
•
Michael Feathers, Working Effectively with Legacy Code (레거시 코드 활용 전략)