Search
🏗️

도메인 주도 설계의 사실과 오해 2부 (2) 유연한 설계를 향한 리팩터링

Tags
Architcture
DDD
Last edited time
2026/03/29 14:25
2 more properties
Search
도메인 주도 설계의 사실과 오해 2부 (2) 유연한 설계를 향한 리팩터링
도메인 주도 설계의 사실과 오해 2부 (2) 유연한 설계를 향한 리팩터링

1. 들어가기 앞서

1편에서는 DDD 원서 파트 3의 핵심 개념인 도약(Breakthrough), 심층 모델(Deep Model), 유연한 설계(Supple Design)를 정리하고, 신디케이트 론 도메인에서 Share(지분)라는 암시적 개념을 발견하기까지의 과정을 다뤘다.
이번 편에서는 그 Share를 실제 코드에 구현하면서 모델이 어떻게 단순해지는지를 확인하고, 유연한 설계의 패턴들인 Intention-Revealing Interface, Side-Effect-Free Function, Assertion, Standalone Class, Closure of Operation, Conceptual Contour를 페인트 혼합 예제와 신디케이트 론 리팩터링을 통해 하나씩 체험해 본다.

2. 도약: Share를 코드에 구현하다

1편에서 우리는 퍼실리티의 지분과 대출의 지분이 서로 독립적이라는 통찰에 도달했다. 그리고 이 둘을 관통하는 핵심 개념이 Share(지분)라는 것을 발견했다. 이제 그 Share를 실제로 코드에 넣어보자.

2.1. Share 클래스

public class Share extends ValueObject<Share> { private Company company; private Money amount; public Share prorate(Money amount, Money total) { return new Share(company, amount.times(this.amount).divide(total)); } public Share plus(Share other) { return new Share(company, this.amount.plus(other.amount)); } public Share minus(Share other) { return new Share(company, this.amount.minus(other.amount)); } }
Java
복사
Share는 회사(Company)와 금액(amount)을 가진 값 객체(Value Object)다. 핵심 연산은 세 가지다.
prorate(amount, total): 현재 Share의 비율에 따라 금액을 분배한다. 예를 들어 전체 1,000원 중 500원을 가진 Share에 100원을 prorate하면 50원짜리 새 Share가 반환된다.
plus(other): 두 Share를 더한다. A회사 50원 + A회사 100원 = A회사 150원.
minus(other): 두 Share를 뺀다.
주목할 점은 세 연산 모두 기존 객체를 변경하지 않고 새 Share를 반환한다는 것이다. 값 객체답게 불변(immutable)을 유지한다.

2.2. 새로운 클래스 다이어그램

┌──────────────────────────────┐ ┌──────────────────────────────┐ │ <<aggregate>> Facility │ │ <<entity>> Loan │ ├──────────────────────────────┤ ├──────────────────────────────┤ │ takeOutLoan(Money) │───────>│ increase(Map<Company,Share>)│ │ takeOutLoan(Share...) │ │ │ └──────────┬───────────────────┘ └──────────────┬───────────────┘ │ Company │ Company │ │ ▼ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ <<value>> Share │ ├──────────────────────────────────────────────────────────────────────┤ │ company : Company │ │ amount : Money │ ├──────────────────────────────────────────────────────────────────────┤ │ prorate(Money, Money) : Share │ │ plus(Share) : Share │ │ minus(Share) : Share │ └──────────────────────────────────────────────────────────────────────┘
Bash
복사
1편의 클래스 다이어그램과 비교해 보자. Investment, LoanInvestment, LoanAdjustment. 세 개의 클래스가 Share 하나로 통합되었다. Facility도 Loan도 모두 Map<Company, Share>를 가진다. 구조가 대칭적이고 단순해졌다.

2.3. Facility의 Share 기반 대출

public class Facility extends AggregateRoot<Facility, Long> { private Long id; private Map<Company, Share> shares = new HashMap<>(); public void takeOutLoan(Money amount) { if (loan.amount().plus(amount).isGreaterThan(limit())) { throw new IllegalStateException(); } Map<Company, Share> sharesToAdd = new HashMap<>(); for (var owner : shares.keySet()) { sharesToAdd.put(owner, shares.get(owner).prorate(amount, limit())); } loan.increase(sharesToAdd); } }
Java
복사
흐름을 따라가 보자. 1,000원 한도에 A:500원, B:200원, C:300원의 지분. 100원을 대출하면?
1.
A의 Share(500원)에 prorate(100, 1000) → 50원짜리 새 Share
2.
B의 Share(200원)에 prorate(100, 1000) → 20원짜리 새 Share
3.
C의 Share(300원)에 prorate(100, 1000) → 30원짜리 새 Share
4.
이 세 Share를 Loan에 increase()로 전달
Loan은 전달받은 Share를 자신의 Share에 plus()로 더한다. 200원을 추가 대출하면 같은 과정을 반복한다.

2.4. 핵심 변화: 별도의 지분으로 대출하기

1편에서 문제가 되었던 "비율과 무관한 대출"도 이제 자연스럽게 처리된다.
// 퍼실리티의 지분과 무관한 별도 지분으로 대출 public void takeOutLoan(Share... shares) { if (loan.amount().plus(sum(Arrays.stream(shares).toList())) .isGreaterThan(limit())) { throw new IllegalStateException(); } loan.increase( Arrays.stream(shares) .collect(toMap(Share::company, identity()))); }
Java
복사
기존 퍼실리티 비율을 따르는 takeOutLoan(Money)와, 별도 지분을 직접 지정하는 takeOutLoan(Share...)가 공존한다. 로직은 동일하다. Share를 만들어서 Loan에 더하는 것. 차이는 Share를 어디서 가져오느냐뿐이다.

2.5. 테스트로 비교: 리팩터링 전 vs 후

리팩터링 전 (1편의 transfer 방식)
facility.takeOutLoan(Money.won(100)); facility.takeOutLoan(Money.won(200)); // B의 현재 금액 60원에서 2/3에 해당하는 금액 40원을 C로 이동 facility.transfer(companyB, companyC, 2.0 / 3.0);
Java
복사
리팩터링 후 (Share 방식)
// 퍼실리티의 지분에 따라 배당 facility.takeOutLoan(Money.won(100)); // 퍼실리티의 지분을 무시하고 명시적으로 지분 지정 facility.takeOutLoan( new Share(companyA, Money.won(100)), new Share(companyC, Money.won(100)));
Java
복사
두 코드의 결과는 동일하다. A:150원, B:20원, C:130원. 하지만 의미 전달에서 차이가 크다. 리팩터링 후의 코드는 두 가지 시나리오를 명시적으로 구분한다. "퍼실리티 지분대로 분배"와 "A와 C가 100원씩 참여하는 별도 지분으로 분배". 도메인 규칙이 코드에 그대로 드러난다.

2.6. 도약의 결과

이전 모델과 비교하면 어떤 변화가 있었는가? 표로 정리해 보자.
관점
이전 모델 (02-refinement)
도약 이후 (03-breakthrough)
핵심 클래스
Investment, LoanInvestment, LoanAdjustment
Share, SharePie
퍼실리티/대출 지분
종속적 (항상 Investment 참조)
독립적 (각각 SharePie)
비율과 무관한 대출
transfer()로 우회
takeOutLoan(SharePie)으로 직접
원금 상환
구현 불가 (퍼실리티 지분 종속)
estimateRepayments()
수수료 배분
구현 불가
distributeCharge()
불변성
remove/add로 가변
모든 연산이 새 객체 반환
도메인 용어
LoanAdjustment (도메인에 없음)
Share, SharePie (도메인 언어)
에릭 에반스는 원서에서 이렇게 말한다.
"Loan Investment는 사라졌고, 이 시점에 이르러 '대출 투자(Loan Investment)'라는 용어를 은행에서 사용하지 않는다는 사실을 알게 됐다. 사실 업무 전문가들은 대출 투자라는 용어를 이해할 수 없다고 말했고 수차례 말해왔다. (...) 실제로 우리는 도메인을 완전히 이해하지 못한 상태에서 용어를 만들었던 것이다. (...) 그리고 종종 다이어그램이 '너무 기술적'이라고 지적하곤 했던 업무 전문가들은 새로운 모델 다이어그램을 완벽하게 이해할 수 있었다."
개발자가 만들어낸 LoanInvestment는 사라지고, 도메인 전문가가 실제로 사용하는 Share가 그 자리를 차지했다. 코드의 구조가 도메인 전문가의 머릿속 구조와 일치하게 된 것. 이것이 심층 모델(Deep Model)이다.
"기존에 있는 걸 가지고 어떻게 구현하느냐, 아니면 우리가 놓친 게 있는 거 아닌가를 고민하느냐. 이 차이가 도약을 만든다."

3. 유연한 설계(Supple Design): 복잡성을 낮추는 세 가지 방법

Share를 구현해서 심층 모델에 도달했다. 하지만 이것만으로는 부족하다. 도메인에 대한 이해가 깊어져서 모델을 수정하고 싶어도, 코드가 수정하기 어려운 구조라면 리팩터링 자체가 고통이 된다. 심층 모델을 발견해도 코드에 반영하지 못한다.
여기서 유연한 설계(Supple Design)가 필요하다.
"지속적으로 리팩터링을 수행하려면 설계 자체가 변경을 지원해야 한다." — Eric Evans, Domain-Driven Design
유연한 설계는 두 가지 관점을 모두 지원해야 한다.
관점
설명
설계를 사용하는 클라이언트 개발자
모델 요소들을 쉽게 결합해서 도메인의 시나리오를 표현 가능
설계를 변경하는 개발자
이해하기 쉬운 모델 제공. 변경이 필요한 지점을 수정하기 쉽도록 개선
유연한 설계의 목적은 모델을 변경하는 데 방해되는 복잡성을 낮추는 것이다. 복잡성을 낮추는 방법은 세 가지로 정리된다.
방법
관련 패턴
코드 내부를 살펴보지 않고 인터페이스만으로 모델의 행동과 부수효과를 예측 가능하게 만들기
Intention-Revealing Interface, Side-Effect-Free Function, Assertion
모델을 이해하는 데 필요하지 않은 개념 줄이기
Standalone Class
구조를 흔들지 않고 기존 구조의 윤곽을 따라 모델 확장하기
Conceptual Contour, Closure of Operation
이제 페인트 혼합 예제를 통해 하나씩 체험해 보자.

4. 의도를 드러내는 인터페이스(Intention-Revealing Interface)

4.1. 이해하기 어려운 코드

노란색 페인트와 파란색 페인트를 혼합하는 코드를 보자.
public class Paint { private double v; private int r; private int y; private int b; public void paint(Paint paint) { double totalV = v + paint.getV(); r = (int) Math.round((r * v + paint.getR() * paint.getV()) / totalV); y = (int) Math.round((y * v + paint.getY() * paint.getV()) / totalV); b = (int) Math.round((b * v + paint.getB() * paint.getV()) / totalV); v = totalV; } }
Java
복사
v, r, y, b가 뭔지 모르겠고, paint(Paint)가 대체 뭘 하는 건지 알 수 없다. 이름만으로는 의도가 전혀 드러나지 않는다. 코드를 한 줄 한 줄 분석해야 비로소 "아, 두 페인트를 혼합하는 거구나"를 이해할 수 있다.
에릭 에반스는 이렇게 말한다.
"개발자가 컴포넌트를 사용하기 위해 컴포넌트의 구현 세부사항을 고려해야 한다면 캡슐화의 가치는 사라진다. (...) 해당 도메인의 개념을 반영하도록 클래스와 메서드의 이름을 지어야 한다."

4.2. 의도를 드러내도록 개선

public class Paint { private double volume; private int red; private int yellow; private int blue; public void mixin(Paint paint) { double totalVolume = volume + paint.getVolume(); red = (int) Math.round( (red * volume + paint.getRed() * paint.getVolume()) / totalVolume); yellow = (int) Math.round( (yellow * volume + paint.getYellow() * paint.getVolume()) / totalVolume); blue = (int) Math.round( (blue * volume + paint.getBlue() * paint.getVolume()) / totalVolume); volume = totalVolume; } }
Java
복사
vvolume, rred, yyellow, bblue
paint()mixin(). "혼합하다"라는 의도가 명확하게 드러난다.
테스트 코드도 비교해 보자.
// Before: 무슨 뜻인지 모름 yellow.paint(blue); // After: "노란색 페인트에 파란색 페인트를 혼합한다" outPaint.mixin(blue);
Java
복사
"클래스의 메서드 이름은 그 클래스 안에서 짓는 게 아니라, 클라이언트 관점에서 지어야 한다. 이걸 왜 쓰는 거야, 의 관점에서."
이것이 의도를 드러내는 인터페이스(Intention-Revealing Interface)다. 핵심 원칙은 이렇다.
수행 방법에 관해서는 언급하지 말고 결과와 목적만을 표현하도록 클래스와 연산의 이름을 부여하라. (...) 클라이언트 개발자의 관점에서 생각하기 위해 클래스와 연산을 추가하기 전에 행위에 대한 테스트를 먼저 작성하라.

5. 부수 효과가 없는 함수(Side-Effect-Free Function)

5.1. 의도는 드러났지만 예측하기 어렵다

이름은 좋아졌다. 하지만 mixin() 호출 후 내부 상태가 어떻게 바뀌는지는 여전히 코드를 들여다봐야 안다. volume, red, yellow, blue의 네 개 필드가 모두 바뀐다. 이 메서드의 결과를 다른 메서드에서 사용한다면, 계속 내부를 추적해야 한다.
"오퍼레이션을 호출하는 개발자가 결과를 예상하려면 오퍼레이션 자체의 구현뿐 아니라 오퍼레이션이 호출하는 다른 연산의 구현도 이해해야 한다." — Eric Evans, Domain-Driven Design

5.2. 루틴의 분류: 명령과 쿼리

먼저 기본 개념을 정리하자. 루틴(Routine)은 코드를 묶어서 호출 가능하게 만든 것이다. 루틴은 두 가지로 분류된다.
구분
부수효과
반환값
객체에서의 이름
프로시저(Procedure)
있음
없음 (void)
명령(Command)
함수(Function)
없음
있음
쿼리(Query)
이것이 명령-쿼리 분리 원칙(Command-Query Separation, CQS)이다. 버트란드 마이어(Bertrand Meyer)가 정립한 원칙으로, 한 문장으로 요약하면 이렇다.
"질문이 답변을 수정해서는 안된다."
인터페이스 시그니처만으로 부수효과의 유무를 판단할 수 있다.
boolean includes(LocalDate day); // ← 쿼리: 부수효과 없음, 몇 번 호출해도 결과 동일 void schedule(LocalDate day); // ← 명령: 부수효과 발생, 호출할 때마다 상태 변경
Java
복사
boolean을 반환하면 쿼리(안전), void이면 명령(주의). 시그니처만 보고도 알 수 있다.
"내가 코드를 이해하고 싶을 때, 내부를 뒤져봐야 하는 메서드가 얼마나 되느냐. Command만 뒤져보면 된다. Query는 안 봐도 된다."

5.3. 복잡한 로직을 값 객체로 뜯어내기

그러면 페인트 예제를 어떻게 개선할까? 핵심 아이디어는 복잡한 색상 혼합 로직을 값 객체(Value Object)로 뜯어내는 것이다.
public class PigmentColor extends ValueObject<PigmentColor> { private int red; private int yellow; private int blue; public PigmentColor mixin(PigmentColor other, double volume, double otherVolume) { double totalVolume = volume + otherVolume; return new PigmentColor( (int) Math.round((red * volume + other.red * otherVolume) / totalVolume), (int) Math.round((yellow * volume + other.yellow * otherVolume) / totalVolume), (int) Math.round((blue * volume + other.blue * otherVolume) / totalVolume)); } }
Java
복사
PigmentColor는 값 객체다. mixin()은 기존 객체를 변경하지 않고 새로운 PigmentColor를 반환한다. 부수 효과가 없다(Side-Effect-Free). 이제 Paint 클래스는 이렇게 단순해진다.
public class Paint { private double volume; private PigmentColor pigmentColor; public Paint mixin(Paint paint) { return new Paint( volume + paint.getVolume(), pigmentColor.mixin(paint.getPigmentColor(), volume, paint.getVolume())); } }
Java
복사
Paint.mixin()이 극적으로 단순해졌다. 혼합의 복잡한 알고리즘은 PigmentColor에 위임하고, Paint는 "용량을 더하고, 안료를 혼합한다"는 높은 수준의 의도만 표현한다.
"페인트를 혼합하는 게 뭐냐? 두 개의 볼륨을 더하는 것과 두 개의 안료를 혼합하는 것이다."
"가능한 한 많은 양의 프로그램 로직을 관찰 가능한 부수효과 없이 결과를 반환하는 함수 안에 작성하라. 명령(관찰 가능한 상태를 변경하는 메서드)을 도메인 정보를 반환하지 않는 단순한 연산으로 엄격하게 분리하라. 한 걸음 더 나아가 책임에 적합한 어떤 개념이 나타난다면 복잡한 로직을 VALUE OBJECT로 옮겨서 부수효과를 통제하라." — Eric Evans, Domain-Driven Design
강사의 설명이 이 원칙을 실용적으로 정리해 준다.
"복잡한 클래스가 있으면 거기서 사이드 이펙트 프리한 펑션들을 다 뜯어내라. 뜯어내서 최대한 사이드 이펙트 프리 펑션을 많이 만들어라. 그리고 상태가 변화하는 걸 최대한 줄여라."
이 전략은 DDD의 애그리게이트 설계에서도 동일하게 적용된다.
"애그리게이트 루트에 상태 변경 책임을 몰빵하고, 나머지는 사이드 이펙트 프리한 값 객체로 뜯어내라."

6. 단언(Assertion)

부수 효과가 없는 함수를 최대한 만들었지만, 상태를 바꿔야 하는 Command는 여전히 존재한다. 이때 단언(Assertion)이 도움이 된다. 단언은 "이 연산이 실행되면, 이런 조건이 반드시 성립한다"는 것을 명시하는 것이다.
테스트 코드에서 단언을 보자.
@Test public void paint() { Paint outPaint = new Paint(100.0, new PigmentColor(0, 50, 0)); Paint blue = new Paint(100.0, new PigmentColor(0, 0, 50)); Paint mixed = outPaint.mixin(blue); // 혼합 후 용량은 두 페인트의 합이어야 한다 assertThat(mixed.getVolume()).isEqualTo(200.0); assertThat(mixed.getPigmentColor()).isEqualTo(new PigmentColor(0, 25, 25)); // 원본 페인트는 변하지 않아야 한다 assertThat(outPaint.getVolume()).isEqualTo(100.0); assertThat(blue.getVolume()).isEqualTo(100.0); }
Java
복사
여기서 단언이 말하고 있는 도메인 규칙은 두 가지다.
1.
페인트를 혼합하면 전체 용량은 두 페인트 용량의 합이다. (보존 법칙)
2.
원본 페인트는 변하지 않는다. (불변성)
단언을 작성하면서 자연스럽게 "이 연산의 사전 조건은 무엇인가?", "사후 조건은 무엇인가?", "불변식(invariant)은 무엇인가?"를 생각하게 된다. 도메인 규칙을 코드로 명시하는 과정이다.

7. 독립형 클래스(Standalone Class)와 연산의 닫힘(Closure of Operation)

7.1. 독립형 클래스

"모듈 내에서조차 의존성이 증가할수록 설계를 파악하는 데 따르는 어려움이 가파르게 높아진다. 이는 개발자에게 정신적 과부하(mental overload)를 줘서 개발자가 다룰 수 있는 설계의 복잡도를 제한한다." — Eric Evans, Domain-Driven Design
독립형 클래스의 원칙은 간단하다. 현재 상황과 무관한 모든 개념을 제거하라. 모든 의존성을 제거하라는 것이 아니라, 비본질적인 의존성을 제거하는 것이 목표다. 원시 타입이나 표준 라이브러리에 대한 의존은 정신적 부담을 늘리지 않는다. 본질적인 개념(예: PigmentColor)에 대한 의존도 괜찮다.
Share 클래스를 다시 보자. Share는 Company와 Money에만 의존한다. Facility나 Loan을 알지 못한다. Share를 이해하기 위해 다른 도메인 객체를 뒤져볼 필요가 없다.

7.2. 연산의 닫힘(Closure of Operation)

Share.plus(Share)는 Share를 받아서 Share를 반환한다. SharePie.prorate(Money)는 SharePie를 반환한다. SharePie.plus(SharePie)도 SharePie를 반환한다.
"적절한 위치에 반환 타입과 인자 타입이 동일한 연산을 정의하라. (...) 이런 방식으로 정의된 연산은 해당 타입의 인스턴스 집합에 닫혀 있다." — Eric Evans, Domain-Driven Design
인자의 타입과 반환 타입을 구현자의 타입과 동일하게 정의하면, 연산이 그 타입의 집합 안에서 닫힌다. 수학에서 "정수의 덧셈은 정수 위에서 닫혀 있다"는 것과 같은 개념이다.
닫힌 연산의 장점은 조합 가능성(composability)이다.
// SharePie끼리 자유롭게 조합할 수 있다 SharePie result = pieA.plus(pieB).minus(pieC);
Java
복사
새로운 타입을 도입할 필요 없이, 기존 연산을 조합해서 복잡한 계산을 표현할 수 있다.

8. 유연한 설계 적용하기: 신디케이트 론 리팩터링

이론은 충분하다. 이제 유연한 설계 패턴들을 신디케이트 론 도메인에 단계적으로 적용해 보자. 코드 저장소의 08-loan-refactoring 프로젝트가 이 과정을 네 단계로 보여준다.

8.1. Step 1: 원금 상환 로직 추가

새로운 요구사항이 들어온다. 원금 상환이다. 대출 300원(A:50, B:20, C:30) 중 10원을 상환한다. 상환금은 대출 지분에 비례해서 분배한다.
public class Loan extends DomainEntity<Loan, Long> { private Map<Company, Share> shares = new HashMap<>(); public Set<Share> distributePrincipalPayment(Money amount) { Set<Share> result = new HashSet<>(); for (var owner : shares.keySet()) { Share paymentShare = shares.get(owner).prorate(amount, amount()); result.add(paymentShare); shares.put(owner, shares.get(owner).minus(paymentShare)); } return result; } }
Java
복사
테스트로 확인하자.
// 퍼실리티의 지분을 무시하고 명시적으로 지분 지정 facility.takeOutLoan( new Share(companyA, Money.won(60)), new Share(companyB, Money.won(30)), new Share(companyC, Money.won(10))); // 10원 상환 → 대출 지분(60:30:10)에 따라 배분 Set<Share> repays = facility.repay(Money.won(10)); assertThat(repays).contains(new Share(companyA, Money.won(6))); assertThat(repays).contains(new Share(companyB, Money.won(3))); assertThat(repays).contains(new Share(companyC, Money.won(1)));
Java
복사
동작은 한다. 하지만 distributePrincipalPayment() 메서드에 문제가 있다.

8.2. Step 2: 명령과 쿼리 분리(Side-Effect-Free Function)

distributePrincipalPayment()를 다시 보자. 이 메서드는 두 가지 일을 동시에 한다.
1.
shares.put(owner, ...). 대출 지분을 변경하는 명령
2.
return result. 회사별로 분배된 상환금을 반환하는 쿼리
명령과 쿼리가 혼합되어 있다. CQS 원칙에 따라 분리하자.
public class Loan extends DomainEntity<Loan, Long> { private Map<Company, Share> shares = new HashMap<>(); // 쿼리: 상환금 분배 계산 (Side-Effect-Free) public Set<Share> calculateRepayments(Money amount) { Set<Share> result = new HashSet<>(); for (var owner : shares.keySet()) { result.add(shares.get(owner).prorate(amount, amount())); } return result; } // 명령: 대출 지분 변경 public void applyRepayments(Set<Share> paymentShares) { for (var paymentShare : paymentShares) { shares.put( paymentShare.company(), shares.get(paymentShare.company()).minus(paymentShare)); } } }
Java
복사
calculateRepayments()부수 효과가 없는 순수한 쿼리다. 몇 번을 호출해도 같은 결과를 반환한다. 상태를 변경하지 않으니 안심하고 사용할 수 있다. "이 금액을 상환하면 각 회사에 얼마씩 돌아가는지 미리 계산해 봐"라는 용도로도 쓸 수 있다.
applyRepayments()명령이다. 실제로 대출 지분을 차감한다. Facility에서는 이 둘을 조합한다.
public class Facility extends AggregateRoot<Facility, Long> { // 쿼리: 상태 변경 없이 배분된 금액만 반환 public Set<Share> estimateRepayments(Money amount) { return loan.calculateRepayments(amount); } // 명령: 쿼리를 조합해서 실행 public void repay(Money amount) { loan.applyRepayments(loan.calculateRepayments(amount)); } }
Java
복사

8.3. Step 3: SharePie 도입 (Standalone Class + Closure of Operation)

여기서 새로운 통찰이 등장한다. 코드를 보면 Facility.takeOutLoan()Loan.calculateRepayments() 모두 여러 Share를 순회하면서 prorate하는 유사한 로직을 가지고 있다. 중복 코드가 표현하는 암시적 개념은 무엇인가?
지분(Share)이 전체를 구성하는 일부로 서로 관련이 있다는 점. 파이 차트(원형 그래프)처럼 여러 Share가 모여 하나의 전체를 이루는 개념. 이것이 SharePie(지분 총합)라는 새로운 도메인 개념이다.
public class SharePie extends ValueObject<SharePie> { private Map<Company, Share> shares = new HashMap<>(); public SharePie prorate(Money amount) { Map<Company, Share> result = new HashMap<>(); for (var owner : shares.keySet()) { result.put(owner, shares.get(owner).prorate(amount, sum())); } return new SharePie(result); } public SharePie plus(SharePie other) { Map<Company, Share> result = new HashMap<>(); for (Share share : this.shares.values()) { result.put(share.company(), share.plus(other.shareOf(share.company()))); } return new SharePie(result); } public SharePie minus(SharePie other) { Map<Company, Share> result = new HashMap<>(); for (Share share : this.shares.values()) { result.put(share.company(), share.minus(other.shareOf(share.company()))); } return new SharePie(result); } }
Java
복사
주목할 점들.
prorate(Money)SharePie를 반환한다. SharePie에서 SharePie를 만든다.
plus(SharePie)SharePie를 반환한다. SharePie와 SharePie를 더한다.
minus(SharePie)SharePie를 반환한다. SharePie에서 SharePie를 뺀다.
모든 연산이 새 객체를 반환한다. 불변(immutable)이다.
이것이 바로 연산의 닫힘(Closure of Operation)이다. 인자와 반환 타입이 모두 SharePie(또는 Money). SharePie의 세계 안에서 모든 연산이 완결된다. 그리고 SharePie는 Share와 표준 라이브러리에만 의존한다. 독립형 클래스(Standalone Class)다.

8.4. SharePie로 단순해진 Loan과 Facility

Map<Company, Share>를 직접 다루던 Loan이 SharePie 하나로 대체된다.
// Before public class Loan extends DomainEntity<Loan, Long> { private Map<Company, Share> shares = new HashMap<>(); public void increase(Map<Company, Share> sharesToAdd) { for (var owner : sharesToAdd.keySet()) { shares.put(owner, shares.get(owner).plus(sharesToAdd.get(owner))); } } public Set<Share> calculateRepayments(Money amount) { Set<Share> result = new HashSet<>(); for (var owner : shares.keySet()) { result.add(shares.get(owner).prorate(amount, amount())); } return result; } public void applyRepayments(Set<Share> paymentShares) { for (var paymentShare : paymentShares) { shares.put(paymentShare.company(), shares.get(paymentShare.company()).minus(paymentShare)); } } } // After public class Loan extends DomainEntity<Loan, Long> { private SharePie sharePie; public void increase(SharePie other) { this.sharePie = this.sharePie.plus(other); } public SharePie calculateRepayments(Money amount) { return sharePie.prorate(amount); } public void applyRepayments(SharePie repayments) { this.sharePie = this.sharePie.minus(repayments); } }
Java
복사
Facility도 마찬가지로 단순해진다.
public class Facility extends AggregateRoot<Facility, Long> { private Loan loan; private SharePie sharePie; public void takeOutLoan(Money amount) { if (loan.amount().plus(amount).isGreaterThan(sharePie.sum())) { throw new IllegalStateException(); } loan.increase(this.sharePie.prorate(amount)); } public void takeOutLoan(SharePie sharePie) { if (loan.amount().plus(sharePie.sum()).isGreaterThan(this.sharePie.sum())) { throw new IllegalStateException(); } loan.increase(sharePie); } }
Java
복사

8.5. Step 4: 개념적 윤곽(Conceptual Contour) 확인

마지막 단계. 새로운 요구사항 수수료 분배를 추가해 보자. 수수료는 퍼실리티 지분에 비례해서 분배한다.
public class Facility { private Loan loan; private SharePie sharePie; public SharePie distributeCharge(Money charge) { return sharePie.prorate(charge); } }
Java
복사
한 줄이다. sharePie.prorate(charge). 퍼실리티의 SharePie를 기준으로 수수료를 분배하면 끝이다.
테스트로 확인하자.
// 수수료 10원 납부 → 퍼실리티 지분(500:200:300)에 따라 배분 SharePie charge = facility.distributeCharge(Money.won(10)); assertThat(charge.amountOf(companyA)).isEqualTo(Money.won(5)); assertThat(charge.amountOf(companyB)).isEqualTo(Money.won(2)); assertThat(charge.amountOf(companyC)).isEqualTo(Money.won(3));
Java
복사
기능이 추가되었는데 구조는 안정적으로 유지된다. 이것이 개념적 윤곽(Conceptual Contour)이다.
"도메인을 중요 영역으로 나누는 것과 관련된 직관을 감안해서 설계 요소(연산, 인터페이스, 클래스, AGGREGATE)를 응집력 있는 단위로 분해하라. (...) 연속적인 리팩터링이 지역적으로 한정된 범위 안에서만 이뤄지고 넓은 범위의 개념을 흔들지 않는다면 모델이 현재 도메인에 적합해 졌다는 표시다." — Eric Evans, Domain-Driven Design
최종 클래스 다이어그램을 보자.
┌──────────────────────────────┐ ┌──────────────────────────────┐ │ <<aggregate>> Facility │ │ <<entity>> Loan │ ├──────────────────────────────┤ ├──────────────────────────────┤ │ takeOutLoan(Money) │───────>│ increase(SharePie) │ │ takeOutLoan(SharePie) │ │ calculateRepayments(Money) │ │ distributeCharge(Money) │ │ : SharePie │ └──────────┬───────────────────┘ │ applyRepayments(SharePie) │ │ └──────────────┬───────────────┘ │ │ ▼ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ <<value>> SharePie │ ├──────────────────────────────────────────────────────────────────────┤ │ prorate(Money) : SharePie │ │ plus(SharePie) : SharePie │ │ minus(SharePie) : SharePie │ ├──────────────────────────────────────────────────────────────────────┤ │ Company │ │ │ │ │ ▼ │ │ <<value>> Share │ │ company : Company │ │ amount : Money │ └──────────────────────────────────────────────────────────────────────┘
Plain Text
복사

9. 유연한 설계 패턴 정리

지금까지 살펴본 패턴들을 표로 정리한다.
패턴
핵심
적용 예
의도를 드러내는 인터페이스
이름만으로 목적과 동작을 예측 가능하게
paint()mixin(), vvolume
부수 효과가 없는 함수
상태를 변경하지 않고 새 객체를 반환
PigmentColor.mixin() → 새 PigmentColor 반환
단언
연산의 사전/사후 조건과 불변식을 명시
"혼합 후 용량 = 합", "원본 불변"
명령-쿼리 분리
Command는 void, Query는 반환값만
calculateRepayments() vs applyRepayments()
독립형 클래스
불필요한 의존 제거, 단독으로 이해 가능
Share는 Company와 Money에만 의존
닫힌 연산
입출력 타입이 동일, 조합 가능
SharePie.plus(SharePie) → SharePie
개념적 윤곽
도메인의 본질적 경계를 따라 분리
수수료 추가 시 구조 변경 없이 한 줄로 해결
이 패턴들은 개별적으로도 가치가 있지만, 함께 적용될 때 시너지가 극대화된다. 의도를 드러내는 이름을 짓고, 부수 효과를 제거하고, 값 객체로 추출하고, 닫힌 연산으로 구성하면, 도메인의 개념적 윤곽을 따르는 유연한 설계가 자연스럽게 나온다.

10. 마무리

이번 포스팅에서 다룬 내용을 정리하면 이렇다.
Share의 코드 구현: Investment, LoanInvestment, LoanAdjustment 세 클래스가 Share 하나로 통합. 퍼실리티 지분 기준 대출과 별도 지분 대출이 자연스럽게 공존.
심층 모델 달성: 도메인에 없던 LoanInvestment가 사라지고, 도메인 전문가가 사용하는 Share가 코드의 중심에.
유연한 설계의 세 가지 방법: 인터페이스만으로 예측 가능하게 / 개념 줄이기 / 구조의 윤곽을 따라 확장.
Intention-Revealing Interface: paint()mixin(), vvolume. 이름만으로 의도 전달.
Side-Effect-Free Function: 복잡한 로직을 값 객체(PigmentColor)로 뜯어내서 부수효과 통제. 명령과 쿼리 분리(CQS).
Assertion: 도메인 규칙을 단언으로 명시. 사전 조건, 사후 조건, 불변식.
Standalone Class + Closure of Operation: SharePie의 prorate(), plus(), minus(). 모두 SharePie를 반환. 독립적이고 닫힌 연산.
Conceptual Contour: 수수료 분배 추가 시 구조 변경 없이 한 줄로 해결. 도메인의 본질에 기반한 안정적 구조.
핵심 메시지는 1편과 같다. 작은 걸음으로 진행하라. 처음에 Share를 발견하고, Share를 코드에 넣고, 명령과 쿼리를 분리하고, SharePie를 도입하고, 최종적으로 Conceptual Contour에 도달한다. 한 번에 한 단계씩, 반복적인 리팩터링을 통해. 어느 시점부터 요구사항이 추가되어도 구조가 요동치지 않는 순간이 온다. 그것이 도약(Breakthrough)이다.
"코드를 봤을 때 이해하기 쉽고, 단순하고 명확한 설계가 목표다. 코드 변경 없이 확장할 수 있는 구조가 아니라, 변경에 따른 부수효과를 파악할 수 있는 이해하기 쉬운 설계."

11. Reference

Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
조영호, "도메인 주도 설계의 사실과 오해" 강의 Part 2
Bertrand Meyer, Object-Oriented Software Construction (객체지향 소프트웨어 구축)