본문으로 건너뛰기

Domain-Driven Design

Eric EvansISBN 9780321125217

머리말 (Preface)

세 프로젝트의 대조 (Contrasting Three Projects)

Eric Evans는 도메인 설계 방식이 개발 결과에 얼마나 극적인 영향을 미치는지를 보여주는 세 가지 실제 프로젝트를 대비한다.

프로젝트 A — 성공 후 실패한 웹 트레이딩 시스템

  • 초기 단순 버전은 설계 없이도 빠르게 출시 성공
  • 2차 버전 개발 시 다음 문제가 발견됨:
    • 도메인 모델 부재
    • 공통 언어(Ubiquitous Language) 부재
    • 비구조적인 설계
  • 결과: 비즈니스 로직의 복잡성에 압도되어 2차 버전 출시 실패
  • 1차 릴리즈가 조기에 경직되어 고비용 레거시로 전락

핵심 인사이트: 단순한 소프트웨어는 설계 없이도 만들 수 있다. 하지만 복잡성이 증가할 때 도메인 모델이 없으면 팀은 한계에 부딪힌다.

프로젝트 B — 지속적으로 가속된 기관 트레이딩 시스템

  • 초기에는 역시 단순한 애플리케이션으로 시작
  • 각 이터레이션마다 이전 릴리즈 기능을 통합하고 확장하는 새로운 옵션이 열림
  • 성공 요인:
    • 예리한 도메인 모델을 반복적으로 정제하여 코드로 표현
    • 개발자 간, 개발자와 도메인 전문가 간 소통 품질 향상
    • 유지보수 부담이 줄고 변경/확장이 쉬워짐
  • 결과: 트레이더들의 기대를 뛰어넘는 유연하고 확장 가능한 시스템 완성

프로젝트 C — 야망으로 시작해 평범함으로 끝난 글로벌 엔터프라이즈 시스템

  • 도메인 모델 기반의 글로벌 엔터프라이즈 시스템을 목표로 시작
  • 도구도 좋고, 비즈니스 이해도도 높고, 모델링에도 공을 들였음
  • 실패 원인:
    • 개발자 역할 분리: 모델링 담당자와 구현 담당자가 분리되어, 설계가 깊은 분석 결과를 반영하지 못함
    • 불균등한 기술 수준: 모델 기반 객체를 실용적인 작동 소프트웨어로 만드는 비공식적 기법을 아는 개발자가 없었음
    • 반복 개발해도 코드 품질이 나아지지 않음
  • 결과: 수년 간의 노력 끝에 초라한 성과물, 초기 야망과 모델 중심 접근 포기

핵심 인사이트: 모델을 진지하게 대한다고 해서 자동으로 선순환이 만들어지지는 않는다. 모델링과 구현이 연결되어야 하고, 팀 전체가 모델 기반 기법을 숙지해야 한다.


복잡성의 도전 (The Challenge of Complexity)

소프트웨어 복잡성의 근원

  • 프로젝트를 탈선시키는 요인: 관료주의, 불명확한 목표, 자원 부족 등
  • 그러나 설계 접근 방식이 소프트웨어의 복잡성 수준을 가장 크게 결정한다
  • 복잡성이 통제를 벗어나면 개발자는 소프트웨어를 더 이상 이해하지 못하고 안전하게 변경할 수 없게 된다

기술적 복잡성 vs 도메인 복잡성

구분 설명
기술적 복잡성 네트워크, 데이터베이스 등. 많은 책과 전문가가 다룸
도메인 복잡성 사용자의 활동/비즈니스 자체. 진짜 핵심 복잡성
  • 도메인 복잡성을 설계에서 다루지 않으면, 기반 기술이 아무리 훌륭해도 소용없다
  • 성공적인 설계는 이 핵심 측면을 체계적으로 다루어야 한다

이 책의 두 가지 전제

  1. 대부분의 소프트웨어 프로젝트에서 1차적 초점은 도메인과 도메인 로직이어야 한다
  2. 복잡한 도메인 설계는 모델을 기반으로 해야 한다

DDD(Domain-Driven Design)는 복잡한 도메인을 다루는 소프트웨어 프로젝트를 가속화하기 위한 사고방식이자 우선순위 체계이다.


설계와 개발 프로세스 (Design Versus Development Process)

설계와 프로세스는 분리할 수 없다

  • 설계 개념은 성공적으로 구현되지 않으면 학문적 논의로 그친다
  • 이 책은 특정 방법론에 묶이지 않지만, 애자일(Agile) 개발 프로세스 계열을 지향한다

DDD 적용을 위한 두 가지 전제 조건

  1. 개발은 반복적(iterative)이다
    • 수십 년간 지지되어 온 방식, 애자일의 초석
    • 참고: Extreme Programming Explained (Beck), Surviving Object-Oriented Projects (Cockburn)
  2. 개발자와 도메인 전문가는 긴밀한 관계를 유지한다
    • DDD는 도메인에 대한 방대한 지식을 핵심 개념에 집중된 모델로 압축한다
    • 이 협업은 프로젝트 전체 생명주기 동안 지속되어야 한다

XP(Extreme Programming)와의 관계

  • XP: Kent Beck, Ward Cunningham 등이 고안한 가장 대표적인 애자일 프로세스
  • XP는 사전 설계를 강하게 거부하고 지속적인 리팩터링을 강조
  • 단, XP는 예리한 설계 감각을 가진 개발자에게 가장 잘 작동한다
  • 탄탄한 설계 원칙 없이 리팩터링하면 → 이해하기 어렵고 변경하기 어려운 코드베이스 = 민첩성의 반대

“과잉 설계 두려움”의 역설

  • 예상치 못한 요구사항에 대한 두려움 → 과잉 엔지니어링
  • 과잉 엔지니어링을 피하려는 시도 → 깊은 설계 사고 자체를 두려워하게 됨
  • DDD와 애자일은 서로를 강화한다. 정교한 도메인 모델링 + 애자일 프로세스 = 개발 가속화

이 책의 구조 (The Structure of This Book)

4개의 주요 파트로 구성

파트 제목 핵심 내용
Part I 도메인 모델을 실제로 활용하기 DDD의 기본 목표 정의, 도메인 모델이 소통과 설계를 이끄는 방식
Part II 모델 주도 설계의 빌딩 블록 OO 도메인 모델링 베스트 프랙티스 — 엔티티, 값 객체, 서비스, 애그리거트, 팩토리, 리포지터리
Part III 더 깊은 통찰을 향한 리팩터링 빌딩 블록 조합, 발견의 과정, 모델 심화, 브레이크스루
Part IV 전략적 설계 복잡한 시스템/대형 조직: 컨텍스트, 증류(Distillation), 대규모 구조
  • 논의는 단순화된 장난감 문제가 아닌 실제 프로젝트에서 가져온 현실적인 예시로 뒷받침됨
  • 책의 상당 부분이 “패턴” 형식으로 작성됨

이 책의 독자 (Who Should Read This Book)

주요 독자층

  • 핵심: 객체 지향 소프트웨어 개발자 (현재 프로젝트에 참여 중이거나, 깊은 경험이 있는 사람)
  • OO 모델링 기초 지식 필요, UML 다이어그램을 기본 수준으로 읽을 수 있어야 함

독자 수준별 가이드

수준 얻을 수 있는 것 추천 읽기 순서
중급 개발자 OO 모델링이 실제 프로젝트에서 어떻게 적용되는지 시각 확보 처음부터 순서대로
고급/전문가 도메인을 다루는 포괄적인 프레임워크, 팀 리딩에 활용 Part I → Part II 빠르게, Part III & IV 집중
분석가/기술 PM 모델-설계 연결, 전략적 설계 원칙 Chapter 1, 2, 9, 14 중심
  • 모든 독자 필수 시작점: Part I 소개 + Chapter 1

도메인 주도 팀 (A Domain-Driven Team)

개인 vs 팀의 DDD 적용

  • 개인 개발자도 DDD로 가치 있는 설계 기법과 관점을 얻을 수 있다
  • 가장 큰 효과는 팀 전체가 함께 DDD 접근법을 적용하고 도메인 모델을 프로젝트 논의의 중심에 놓을 때 나타난다

팀이 DDD를 실천할 때 얻는 것

  • 소통을 풍부하게 하고 소프트웨어와 연결된 공유 언어
  • 모델과 보조를 맞추는 명료한 구현
  • 서로 다른 팀의 설계 작업이 어떻게 관련되는지 공유된 지도
  • 조직에 가장 독특하고 가치 있는 기능에 대한 체계적인 집중

DDD는 어려운 기술적 도전이지만, 대부분의 소프트웨어 프로젝트가 레거시로 굳어지기 시작하는 바로 그 시점에 새로운 기회를 열어준다.


1부: 도메인 모델을 실제로 활용하기 (Part I: Putting the Domain Model to Work)

모델이란 무엇인가

모델의 정의

  • 모든 소프트웨어 프로그램은 사용자의 어떤 활동이나 관심사와 관련된다
  • 사용자가 프로그램을 적용하는 그 주제 영역이 소프트웨어의 **도메인(domain)**이다

모델은 현실을 그대로 재현하는 것이 아니다. 문제 해결에 관련된 측면을 추상화하고 불필요한 세부사항을 제거한 선택적으로 단순화되고 의식적으로 구조화된 지식의 형태이다.

도메인의 유형 예시

  • 물리적 세계: 항공권 예약 시스템 → 실제 사람이 실제 항공기에 탑승
  • 무형: 회계 프로그램 → 돈과 재무
  • 소프트웨어 자체: 소스 코드 제어 시스템 → 소프트웨어 개발

도메인 모델이 아닌 것

  • 특정 다이어그램이 아니다 (다이어그램은 모델을 표현하는 수단일 뿐)
  • 도메인 전문가 머릿속의 지식 전체가 아니다
  • 가능한 한 “현실적인” 모델을 만드는 것이 목표가 아니다
  • 필요한 결과를 내는 소프트웨어 메커니즘만 구성하는 것도 아니다

DDD에서 모델의 세 가지 용도

1. 모델과 설계의 핵심이 서로를 형성한다

  • 모델과 구현 간의 긴밀한 연결이 모델을 관련 있게 만들고, 분석이 최종 제품에 적용됨을 보장한다
  • 코드를 모델을 이해하는 것을 기반으로 해석할 수 있다 → 유지보수와 계속적인 개발에 도움

2. 모델은 모든 팀원이 사용하는 언어의 근간이다

  • 모델과 구현의 바인딩 덕분에 개발자들은 이 언어로 프로그램을 이야기할 수 있다
  • 번역 없이 도메인 전문가와 소통 가능
  • 언어가 모델에 기반하므로 자연스러운 언어 능력이 모델 자체를 정제하는 데 활용된다

3. 모델은 증류된 지식(distilled knowledge)이다

  • 모델은 팀이 도메인 지식을 구조화하고 가장 중요한 요소를 구별하는 합의된 방식이다
  • 개발자와 도메인 전문가가 지식을 이 형태로 씨름하면서 효과적으로 협업

2.3 소프트웨어의 심장

소프트웨어의 심장은 사용자의 도메인 관련 문제를 해결하는 능력이다. 다른 모든 기능은, 아무리 중요해도, 이 기본 목적을 지원하는 것이다.

현실의 문제

  • 대부분의 뛰어난 개발자들은 자신이 일하는 특정 도메인을 배우는 데 큰 관심이 없다
  • 기술 인력은 기술 역량을 발휘하는 정량화 가능한 문제를 즐긴다
  • 도메인 작업은 복잡한 새 지식을 많이 요구하며, 컴퓨터 과학자의 역량을 키워주는 것처럼 보이지 않는다
  • 결과: 기술 인재는 도메인 문제를 기술로 해결하려는 정교한 프레임워크 작업에 집중하고, 도메인 학습과 모델링은 다른 사람에게 떠넘겨진다

John Cleese의 우화 (영화 편집자 일화)

영화 편집자가 기술적 완벽성(코트 소매가 화면에 걸림)에 집착하다 장면의 심장(웃음)을 잃어버린 것처럼, 기술 완벽성에만 집중하는 개발팀은 소프트웨어의 심장인 도메인 문제 해결을 잃어버린다.

도메인 복잡성은 정면으로 맞서야 한다. 그렇지 않으면 무관함의 위험을 감수하는 것이다.


챕터 1: 지식 크런칭 (Chapter One: Crunching Knowledge)

1.1 도입: PCB 설계 도구 개발 경험

배경

  • Evans는 인쇄회로기판(PCB) 설계를 위한 특수 소프트웨어 도구를 설계하는 임무를 맡았다
  • 문제: 전자 하드웨어에 대해 아무것도 몰랐다
  • PCB 설계자들과 대화를 시도했지만, 그들의 소프트웨어 아이디어는 “ASCII 파일 읽어서 정렬하고 주석 달아 내보내기” 수준

첫 번째 도메인 개념 발견: “넷(Net)”

  • 도메인 전문가들이 요청하는 보고서에는 항상 “넷(net)“이 등장했다
  • 넷: PCB의 여러 컴포넌트를 연결하고 전기 신호를 전달하는 와이어 컨덕터
classDiagram
    class Net {
    }
    class Chip {
    }
    Net "*" -- "*" Chip

대화를 통한 모델 발전

Evans가 다이어그램을 그리며 대화를 이어나가는 과정:

발견 내용 세부 사항
컴포넌트 인스턴스 “컴포넌트”가 아닌 “컴포넌트 인스턴스(component instance)”. 같은 컴포넌트의 인스턴스가 여러 개일 수 있음
핀(Pin) 신호는 ref-des(컴포넌트 인스턴스)에 도달하는 것만으로는 부족하고, 어느 인지 알아야 함
핀의 소속 핀은 하나의 컴포넌트 인스턴스에만 속하고, 하나의 넷에만 연결됨
토폴로지 모든 넷은 토폴로지(요소들이 연결되는 방식을 결정하는 배열)를 가짐
classDiagram
    class ComponentInstance {
        signal(hopCount: number)
    }
    class Pin {
        signal(hopCount: number)
    }
    class Net {
        signal(hopCount: number)
    }
    class Topology {
    }
    ComponentInstance "1" o-- "*" Pin
    Pin "*" -- "1" Net
    Net "1" -- "1" Topology

프로브 시뮬레이션(Probe Simulation) 기능

  • 목표: 신호 전파를 추적하여 PCB 설계의 잠재적 문제 지점 감지
  • 핵심 질문: 신호가 넷을 통해 연결된 모든 핀에 전달된 후, 어떻게 더 전파되는가?
    • 답: 컴포넌트가 신호를 통과시킨다(push)
    • 칩 내부의 실제 동작은 모델링하지 않아도 됨 → 어느 핀에서 어느 핀으로 통과시키는지의 푸시 목록(push-through list) 으로 단순화

홉 카운트(Hop Count)

  • 신호 지연 판단 기준: 신호 경로가 2~3홉 초과면 시계 사이클 내 신호 미도달 가능성
  • 넷을 지날 때마다 1홉 증가

컴포넌트 타입과 인스턴스의 구분

  • 푸시스루는 컴포넌트의 모든 인스턴스에서 동일
  • 따라서 푸시스루는 컴포넌트 인스턴스가 아닌 컴포넌트 타입이 보유
classDiagram
    class ComponentType {
        getPushesFromPinNumber(pinNum: number): number[]
    }
    class ComponentInstance {
        signal(hopCount: number)
    }
    class Pin {
        signal(hopCount: number)
    }
    class Net {
        signal(hopCount: number)
    }
    ComponentType "1" <|-- "*" ComponentInstance
    ComponentInstance "1" o-- "*" Pin
    Pin "*" -- "1" Net

프로토타입 제작

  • 이해가 충분해지자 인프라 없이 자동화 테스트 기반의 단순 프로토타입 작성
  • 영속성도, UI도 없이 동작(behavior)에만 집중
  • 더미 데이터로 콘솔에 출력하는 수준이었지만, 실제 경로 길이 계산을 수행
  • 도메인 전문가와 Evans가 공유하는 모델을 반영하는 객체들로 구현
  • 프로토타입의 구체성이 도메인 전문가들에게 모델의 의미와 소프트웨어와의 관계를 명확히 함
  • 이후 모델 논의가 더 상호적으로 변함 (전문가도 코드 피드백으로 자신의 생각 검증 가능)

결과

  • 몇 달 후 PCB 엔지니어들은 기대를 뛰어넘는 풍부한 도구를 갖게 됨
  • 모델에는 동의어 통합, 불필요한 사실 제거(컴포넌트의 실제 디지털 특성 등), 프레임워크가 담겨 있었음

1.2 효과적인 모델링의 구성 요소 (Ingredients of Effective Modeling)

PCB 프로젝트 성공을 이끈 다섯 가지 요소:

1. 모델과 구현을 결합하기 (Binding the Model and the Implementation)

  • 조잡한 프로토타입이 초기에 이 핵심 연결을 형성했고, 이후 모든 이터레이션 동안 유지됨
  • 모델이 코드로 살아있어야 분석 결과가 실제 제품에 적용된다

2. 모델에 기반한 언어 육성하기 (Cultivating a Language Based on the Model)

  • 초반: 엔지니어가 PCB 기초를 설명해주고, Evans는 클래스 다이어그램 표기법을 설명
  • 시간이 지나면서: 누구나 모델에서 용어를 꺼내 모델 구조에 일치하는 문장을 만들어 번역 없이 명확히 이해됨

3. 지식이 풍부한 모델 개발하기 (Developing a Knowledge-Rich Model)

  • 객체들은 동작(behavior)을 가지고 규칙을 강제했다
  • 단순 데이터 스키마가 아닌, 복잡한 문제 해결에 핵심적이고 다양한 종류의 지식을 포착하는 모델

4. 모델 증류하기 (Distilling the Model)

  • 완성도가 높아질수록 중요한 개념이 추가되었지만, 동시에 유용하지 않거나 핵심이 아닌 개념은 제거
  • 불필요한 개념이 필요한 개념과 묶여있으면, 핵심 개념을 분리하는 새 모델을 찾아 나머지를 제거
  • 예: 프로브 시뮬레이션에 토폴로지가 불필요함을 발견 → 해당 기능 구현 시까지 모델에서 제외

5. 브레인스토밍과 실험 (Brainstorming and Experimenting)

  • 모델 기반 언어 + 스케치 + 브레인스토밍 태도 = 수백 가지 실험적 변형을 시도하고 판단하는 모델의 실험실
  • 시나리오를 말로 표현할 때, 발음이 자연스러운지 어색한지로 모델의 타당성을 빠르게 검증

지식 크런칭(knowledge crunching) 이란 브레인스토밍의 창의성과 대규모 실험이, 모델 기반 언어를 통해 레버리지되고, 구현을 통한 피드백 루프로 단련되는 과정이다. 이것이 팀의 지식을 가치 있는 모델로 전환한다.


1.3 지식 크런칭 (Knowledge Crunching)

비유: 금융 분석가의 숫자 크런칭

  • 금융 분석가: 방대한 수치를 걸러내고 재조합하여 실제로 중요한 것을 드러내는 단순한 표현을 찾아냄
  • 도메인 모델러: 정보의 급류에서 관련된 부분을 찾아내고, 조직화 아이디어를 반복 시도하여 혼돈 속의 단순한 관점을 찾아냄

지식 크런칭의 특성

  • 고독한 활동이 아니다 — 개발자와 도메인 전문가 팀이 협업
  • 원자재: 도메인 전문가의 머릿속, 기존 시스템 사용자, 기술 팀의 이전 경험, 레거시 시스템, 프로젝트 문서
  • 초기 버전/프로토타입은 경험을 팀에 피드백하고 해석을 변화시킴

지식 축적에 실패하는 두 가지 접근 방식

방식 문제점
폭포수(Waterfall) 비즈니스 전문가 → 분석가 → 프로그래머로 단방향 전달. 피드백 루프 없음. 지식이 한 방향으로만 흐르고 축적되지 않음
추상화 없는 반복(Iteration without abstraction) 전문가가 원하는 기능을 설명하면 개발자가 만드는 방식 반복. 도메인 원리가 아닌 “무엇을 해야 하는가”만 학습
  • 좋은 프로그래머는 자연스럽게 추상화하고 모델을 개발하지만, 도메인 전문가와의 협업 없이는 개념이 피상적(naive)

협업적 지식 크런칭의 효과

  • 개발자는 비즈니스의 중요한 원리를 학습
  • 도메인 전문가는 필수 요소를 증류하도록 강제받아 자신의 이해를 정제
  • 도메인 전문가는 소프트웨어 프로젝트가 요구하는 개념적 엄밀성을 이해하게 됨
  • 분석가와 프로그래머의 입력 → 모델이 깔끔하게 조직되고 추상화됨
  • 도메인 전문가의 입력 → 모델이 깊은 비즈니스 지식을 반영

선순환 (Virtuous Cycle)

flowchart LR
    A[모델 개선] --> B[요구사항 분석 집중화]
    B --> C[프로그래밍/설계와 상호작용]
    C --> D[팀의 도메인 인사이트 심화]
    D --> E[더 명확한 시야 확보]
    E --> F[모델 추가 정제]
    F --> A

이 모델들은 결코 완벽하지 않다. 진화한다. 도메인을 이해하는 데 실용적이고 유용해야 하며, 애플리케이션을 단순하게 구현하고 이해할 수 있을 만큼 충분히 엄밀해야 한다.


1.4 지속적인 학습 (Continuous Learning)

소프트웨어 개발 시작 시점의 현실

  • 소프트웨어를 작성하기 시작할 때 우리는 절대 충분히 알지 못한다
  • 지식은 여러 사람과 문서에 흩어지고 분산되어 있으며, 어떤 지식이 진짜 필요한지도 모른다
  • 기술적으로 덜 두려워 보이는 도메인도 기만적일 수 있다 — 얼마나 모르는지 모른다

모든 프로젝트는 지식을 잃는다 (Knowledge Leakage)

  • 무언가를 배운 사람이 떠난다
  • 조직 개편으로 팀이 흩어지고 지식이 파편화된다
  • 핵심 서브시스템이 아웃소싱되면 코드는 전달되지만 지식은 전달되지 않는다
  • 일반적인 설계 접근법에서는 코드와 문서가 이 힘들게 얻은 지식을 사용 가능한 형태로 표현하지 못함
  • 구전 전통이 어떤 이유로든 중단되면 지식이 사라진다

고생산성 팀의 해법: 지속적인 학습(Continuous Learning)

  • 기술 지식 향상 + 일반적인 도메인 모델링 기술 향상
  • 자신이 작업하는 특정 도메인에 대한 진지한 학습

자기 교육된 팀원들은 가장 핵심적인 영역과 관련된 개발 작업에 집중하는 안정적인 핵심 인력을 형성한다. 이 핵심 팀의 축적된 지식이 그들을 더 효과적인 지식 크런처로 만든다.

PCB 예제의 교훈

  • Evans는 PCB 엔지니어가 되는 것을 배운 것이 아니라, PCB 전문가와 대화하고, 주요 개념을 이해하고, 무엇을 만들고 있는지 검증하는 법을 배웠다
  • 프로브 시뮬레이션은 결국 낮은 우선순위로 판명되어 기능 자체가 제거됨
  • 하지만 초기 작업은 필수적이었다:
    • 핵심 모델 요소들은 유지됨
    • 지식 크런칭 프로세스가 시작되어 이후 모든 작업의 효율을 높임
    • 팀 전체의 지식 획득, 공유 언어의 시작, 구현을 통한 피드백 루프 형성

1.5 지식이 풍부한 설계 (Knowledge-Rich Design)

“명사 찾기”를 넘어서

  • 모델에 담기는 지식은 단순히 “명사를 찾는 것(find the nouns)“을 넘어선다
  • 비즈니스 활동(activities)과 규칙(rules)은 관련된 엔티티들만큼이나 도메인에서 중심적이다
  • 어떤 도메인이든 다양한 범주의 개념을 가진다
  • 지식 크런칭은 이러한 통찰을 반영하는 모델을 도출한다

도메인 전문가의 암묵적 지식 문제

  • 도메인 전문가들은 자신의 일을 하면서 수많은 규칙들을 탐색하고, 모순을 조화시키고, 상식으로 빈틈을 채운다
  • 이 정신적 과정이 얼마나 복잡한지 스스로 인식하지 못하는 경우가 많다
  • 소프트웨어는 이것을 할 수 없다
  • 소프트웨어 전문가와의 긴밀한 협업 속에서 지식 크런칭을 통해 규칙이 명확해지고, 살이 붙고, 조화되거나 범위에서 제외된다
1.5.1 예제: 숨겨진 개념 추출하기 (Extracting a Hidden Concept)

초기 모델 — 화물 예약 시스템

선박 항해에 화물을 예약하는 애플리케이션의 매우 단순한 도메인 모델:

classDiagram
    class Voyage {
    }
    class Cargo {
    }
    Voyage "1" -- "*" Cargo

초기 구현:

makeBooking(cargo: Cargo, voyage: Voyage): number {
    const confirmation = this.orderConfirmationSequence.next();
    voyage.addCargo(cargo, confirmation);
    return confirmation;
}

비즈니스 규칙의 출현: 초과 예약(Overbooking)

  • 해운 업계 표준 관행: 막판 취소가 항상 있으므로 선박이 실어 나를 수 있는 것보다 더 많은 화물을 수락한다
  • 이를 “초과 예약(overbooking)“이라 한다
  • 단순한 경우: 용량의 110%까지 예약 수락

요구사항 문서의 한 줄:

Allow 10% overbooking.
(10% 초과 예약을 허용한다)

1단계 구현 — 숨겨진 규칙

classDiagram
    class Voyage {
        capacity(): number
    }
    class Cargo {
        size(): number
    }
    Voyage "1" -- "*" Cargo
makeBooking(cargo: Cargo, voyage: Voyage): number {
    const maxBooking = voyage.capacity() * 1.1;
    if ((voyage.bookedCargoSize() + cargo.size()) > maxBooking) {
        return -1;
    }
    const confirmation = this.orderConfirmationSequence.next();
    voyage.addCargo(cargo, confirmation);
    return confirmation;
}

이 구현의 문제점

  1. 비즈니스 전문가는 개발자의 안내를 받아도 이 코드에서 규칙을 읽어내기 어렵다
  2. 기술적이지 않은 사람은 요구사항 문서와 코드를 연결하기 어렵다
  3. 규칙이 더 복잡해지면 문제가 더 커진다
  4. 중요한 비즈니스 규칙이 애플리케이션 메서드의 가드 절(guard clause)로 숨겨져 있다

2단계 구현 — 정책(Policy)으로 명시화

초과 예약 규칙은 **정책(Policy)**이다. 이는 디자인 패턴의 전략(Strategy) 패턴이라고도 불린다.

Policy = DDD 에서 비즈니스 규칙이 명시적인 개념으로 표현된 것. STRATEGY 패턴의 도메인 중심적 표현.

classDiagram
    class Voyage {
        capacity(): number
        bookedCargoSize(): number
    }
    class Cargo {
        size(): number
    }
    class OverbookingPolicy {
        isAllowed(cargo: Cargo, voyage: Voyage): boolean
    }
    Voyage "1" -- "*" Cargo
    OverbookingPolicy ..> Voyage
    OverbookingPolicy ..> Cargo
// BookingService
makeBooking(cargo: Cargo, voyage: Voyage): number {
    if (!this.overbookingPolicy.isAllowed(cargo, voyage)) {
        return -1;
    }
    const confirmation = this.orderConfirmationSequence.next();
    voyage.addCargo(cargo, confirmation);
    return confirmation;
}

// OverbookingPolicy 클래스
class OverbookingPolicy {
    isAllowed(cargo: Cargo, voyage: Voyage): boolean {
        return (cargo.size() + voyage.bookedCargoSize()) <= 
               (voyage.capacity() * 1.1);
    }
}

명시적 설계의 장점

  1. 프로그래머를 포함한 모든 관계자가 초과 예약의 본질을 단순한 계산이 아닌 독립적이고 중요한 비즈니스 규칙으로 이해하게 된다
  2. 프로그래머가 비즈니스 전문가에게 기술적 결과물(코드 포함)을 보여줄 수 있고, 이는 도메인 전문가가 (안내를 받아) 이해할 수 있어 피드백 루프를 닫는다

주의: 이런 정교한 설계를 도메인의 모든 세부사항에 적용하라는 의미가 아니다. 이 예제의 핵심은 도메인 모델과 그에 상응하는 설계가 지식을 확보하고 공유하는 데 사용될 수 있음을 보여주는 것이다.


1.6 깊은 모델 (Deep Models)

표면적 모델의 한계

  • 유용한 모델은 표면에 있는 경우가 드물다
  • 도메인과 애플리케이션의 필요를 파악하면서, 처음에 중요해 보였던 표면적 모델 요소들을 버리거나 관점을 바꾸게 된다
  • 처음에는 떠오르지 않았지만 핵심을 꿰뚫는 미묘한 추상화가 등장한다

화물 선적 시스템의 깊은 모델 발견 사례

초기 모델 (표면적)

  • 화물 예약 → 화물 설명 → 일정(itinerary) 중심의 관점
  • 선적 = 이곳에서 저곳으로 컨테이너를 이동시키는 것

수개월의 지식 크런칭 후 발견된 진실

  • 화물의 물리적 이동(적재/하역)은 대부분 하청업체나 운영 인력이 처리
  • 해운 전문가들의 실제 관점: 당사자들 간의 책임 이전(transfer of responsibility) 연속
    • 화주(shipper) → 지역 운송사 → 다른 운송사 → 수하인(consignee)
    • 창고에서 중요한 절차가 진행되는 동안 화물이 대기
    • 복잡한 물리적 이동 과정은 해운 회사의 비즈니스 결정과 관련 없는 경우가 많음
  • 전면에 부각된 것: 선하증권(bill of lading) 같은 법적 문서와 지불 방출로 이어지는 절차

모델의 심층 변화

구분 초기 관점 깊은 관점
선적의 본질 컨테이너를 장소에서 장소로 이동 엔티티 간 화물 책임 이전
핵심 객체 Itinerary(일정) 책임 이전 프로세스
중심 문서 일정 세부사항 선하증권, 법적 문서
기능 위치 적재 작업에 어색하게 붙어있음 운영과 책임의 관계를 이해하는 모델로 지원
  • Itinerary 객체 자체가 제거된 것이 아니라, 모델이 심층적으로 변화
  • 책임 이전 기능들이 더 이상 적재 작업에 어색하게 붙어있지 않고, 운영과 책임 간의 중요한 관계를 이해하는 모델로 지원됨

핵심 결론: 지식 크런칭은 탐험이다. 어디서 끝날지 알 수 없다.


1.7 핵심 개념 요약표

개념 정의 핵심 포인트
도메인 (Domain) 사용자가 프로그램을 적용하는 주제 영역 소프트웨어 도메인은 보통 컴퓨터와 거의 관련 없다
도메인 모델 (Domain Model) 도메인 지식의 선택적으로 단순화되고 의식적으로 구조화된 형태 다이어그램이 아니라 다이어그램이 전달하려는 아이디어
지식 크런칭 (Knowledge Crunching) 개발자와 도메인 전문가가 함께 지식을 압축하여 모델로 정제하는 과정 반드시 협업이어야 하고, 구현을 통한 피드백이 필수
유비쿼터스 언어 (Ubiquitous Language) 모델에 기반하여 팀 모두가 사용하는 공통 언어 개발자와 도메인 전문가 간 번역 없는 소통 가능
깊은 모델 (Deep Model) 표면적 관점을 넘어 도메인의 핵심을 꿰뚫는 추상화 처음부터 나오지 않고 수개월의 크런칭으로 등장
정책 (Policy) 비즈니스 규칙을 명시적 개념(객체)으로 표현한 것 = Strategy 패턴의 도메인 중심 표현
지속적인 학습 (Continuous Learning) 팀이 기술 지식과 도메인 지식을 지속적으로 쌓는 실천 지식 누수(knowledge leakage)에 대응
  1. 소프트웨어의 근본적인 복잡성은 도메인 자체에 있다 — 기술적 우수성만으로는 부족하다
  2. 모델은 도구다 — 문제에 집중하고 정보를 의미있게 만드는 도구. 완벽한 현실 재현이 목표가 아니다
  3. 지식은 협업을 통해 압축된다 — 개발자 혼자, 또는 도메인 전문가 혼자는 한계가 있다
  4. 모델은 언어다 — 팀의 공통 언어가 되면 소통 오류가 줄고 설계가 명확해진다
  5. 모델과 구현은 분리될 수 없다 — 모델링과 구현이 분리되면 모델은 실제 제품에 반영되지 않는다
  6. 깊은 모델은 시간이 필요하다 — 첫 번째 모델은 거의 항상 표면적이다. 지속적인 학습과 크런칭으로 점점 깊어진다
  7. 명시성이 가치를 만든다 — 비즈니스 규칙이 코드에 명시적으로 드러날 때, 도메인 전문가가 검증할 수 있고 팀 전체가 이해할 수 있다

챕터 2. 의사소통과 언어 사용

도메인 모델은 소프트웨어 프로젝트의 공통 언어의 핵심이 될 수 있다. 모델은 프로젝트 구성원들의 머릿속에 구축된 개념의 집합이며, 도메인에 대한 통찰을 반영하는 용어와 관계로 이루어진다. 이 용어와 상호 관계는 도메인에 맞춰져 있으면서도 기술적 개발에 충분히 정밀한 언어의 의미 체계를 제공한다.

  • 모델 기반 의사소통은 UML 다이어그램에만 국한되지 않는다
  • 모델은 다이어그램, 문서, 코드, 테스트, 대화 등 모든 의사소통 매체에 스며들어야 한다
  • 프로젝트에서 언어의 사용은 미묘하지만 매우 중요하다

2.1 유비쿼터스 언어

풍부한 지식이 담긴 유연한 설계를 만들려면 다재다능하고 팀이 공유하는 언어와 언어에 대한 활발한 실험이 필요하다. 이는 소프트웨어 프로젝트에서 좀처럼 일어나지 않는 일이다.

2.1.1 언어 분열의 문제

소프트웨어 프로젝트에서는 구조적으로 언어가 분열되기 쉽다.

언어 분열의 원인:

  • 도메인 전문가: 자신들의 전문 용어를 사용, 기술적 개발 전문 용어는 이해하지 못함
  • 개발자: 도메인 전문가의 언어가 담고 있는 의미가 빠진 기술적, 기능적 용어로 시스템을 이해하고 논의, 또는 도메인 전문가가 이해하지 못하는 추상화를 생성
  • 개발자들끼리도: 문제의 서로 다른 부분을 담당하는 개발자들이 각자의 설계 개념과 표현 방식을 발전시킴

언어 분열이 초래하는 결과:

  • 개발자는 도메인 전문가를 위해 번역, 도메인 전문가는 개발자와 다른 전문가 사이를 중계
  • 개발자끼리도 서로를 위해 번역해야 하는 상황 발생
  • 번역이 모델 개념을 흐리게 만들어 코드의 파괴적 리팩터링으로 이어짐
  • 간접적 의사소통이 분열(schism)의 형성을 숨김 — 팀원들이 같은 용어를 다른 의미로 사용하면서 인지하지 못함
  • 서로 맞지 않는, 신뢰할 수 없는 소프트웨어 탄생
  • 번역의 수고가 깊은 모델 인사이트로 이어지는 지식과 아이디어의 상호작용을 방해

언어 분열의 세 가지 징후:

징후 설명
용어의 이중성 도메인 전문가는 자신들의 전문 용어를 쓰고, 기술팀은 설계 관점에서 도메인을 논의하는 고유한 언어를 사용
코드와 일상 대화의 단절 일상적 논의의 용어가 코드에 내재된 용어(소프트웨어 프로젝트의 가장 중요한 산출물)와 분리됨
말과 글의 불일치 같은 사람도 말과 글에서 다른 언어를 사용하여, 도메인에 대한 가장 예리한 표현이 코드나 글에 담기지 못하고 사라짐

==번역은 의사소통을 무디게 하고 지식 탐구를 빈약하게 만든다.== ==이 방언들 중 어느 하나도 공통 언어가 될 수 없다. 각각이 모든 필요를 충족하지 못하기 때문이다.==

2.1.2 유비쿼터스 언어의 구성과 운용

정의: 도메인 모델을 공통 언어의 뼈대로 삼아, 팀의 모든 의사소통에서 지속적으로 사용하는 언어

유비쿼터스 언어의 구성 요소:

  • 클래스 이름과 주요 오퍼레이션(operation) 이름
  • 모델에서 명시적으로 만들어진 규칙을 논의하기 위한 용어
  • 컨텍스트 맵(Context Maps), 대규모 구조 등 모델에 부과된 고수준 조직화 원칙의 용어
  • 팀이 도메인 모델에 공통으로 적용하는 패턴의 이름
  • 모델 관계 = 모든 언어가 가진 조합 규칙(combinatory rules)

유비쿼터스 언어의 사용 범위:

  • 개발자 간 시스템 아티팩트, 태스크, 기능 논의에 사용
  • 개발자와 도메인 전문가 간 소통에 사용
  • 도메인 전문가들끼리의 요구사항, 개발 계획, 기능 논의에 사용
  • 다이어그램, 글쓰기, 특히 말하기에서 동일하게 사용

유비쿼터스 언어의 진화 방식:

  1. 지속적인 사용이 모델의 약점을 표면으로 드러냄
  2. 어색한 용어나 조합에 대한 대안을 실험하고 발견
  3. 언어의 빈틈이 발견되면 새 단어가 토론에 등장
  4. 언어 변화 = 도메인 모델 변화 → 클래스 다이어그램 업데이트, 코드의 클래스/메서드 이름 변경, 경우에 따라 동작 변경

책임의 분배:

  • 도메인 전문가: 도메인 이해를 전달하기에 어색하거나 부적절한 용어에 이의를 제기
  • 개발자: 설계를 방해하는 모호함이나 불일치를 파악

모델을 언어의 뼈대로 활용하라. 팀 내 모든 의사소통과 코드에서 그 언어를 지속적으로 사용하도록 팀을 이끌어라. 다이어그램, 글쓰기, 특히 말하기에서 동일한 언어를 사용하라.

대안적 표현을 실험하여 어려운 부분을 해결하라. 그리고 코드를 리팩터링하여 새 모델에 맞게 클래스, 메서드, 모듈 이름을 변경하라.

유비쿼터스 언어의 변화는 모델의 변화다.

2.1.3 예시: 화물 라우터 비교

두 시나리오를 통해 유비쿼터스 언어의 효과를 확인할 수 있다. 핵심 관찰 포인트: 화자들이 비즈니스 의미 대 기술 구현 중 어느 것에 대해 얼마나 이야기하는가, 그리고 서로 같은 언어를 사용하는가.

시나리오 1: 도메인의 최소한의 추상화 — 기술적 언어 중심

개발자가 DB 테이블 행 삭제, Boolean 플래그 등 기술적 구현 관점에서 대화하는 모습:

// 시나리오 1: 기술 구현 언어 중심
// 도메인의 "왜"가 드러나지 않고, "어떻게"만 보임
async function handleCustomsClearanceChange(
  cargoId: string,
  newCustomsClearance: string,
  db: Database,
  routingService: RoutingService
): Promise<void> {
  // "shipment 테이블에서 해당 cargo_id의 모든 행을 삭제"
  const existing = await db.query(
    'SELECT * FROM shipment_table WHERE cargo_id = $1',
    [cargoId]
  );

  if (existing.rows.length > 0) {
    await db.query(
      'DELETE FROM shipment_table WHERE cargo_id = $1',
      [cargoId]
    );
  }

  const cargo = await db.query(
    'SELECT origin, destination FROM cargo WHERE id = $1',
    [cargoId]
  );

  // "Routing Service가 테이블을 다시 채움(populate)"
  const rows = await routingService.populate(
    cargo.rows[0].origin,
    cargo.rows[0].destination,
    newCustomsClearance
  );

  await db.insertMany('shipment_table', rows);
}

시나리오 2: 도메인 모델이 풍부하게 표현된 경우 — 유비쿼터스 언어 중심

RouteSpecification, Itinerary, RoutingService와 같은 도메인 언어로 코드를 작성하는 모습:

// 시나리오 2: 도메인 언어 중심
// 비즈니스 의도가 코드 구조와 이름에 직접 반영됨

interface RouteSpecification {
  readonly origin: string;
  readonly destination: string;
  readonly customsClearance?: string;

  // "Itinerary가 이 Specification을 만족하는지 확인"
  isSatisfiedBy(itinerary: Itinerary): boolean;
}

interface Leg {
  readonly load: string;
  readonly unload: string;
}

interface Itinerary {
  readonly legs: readonly Leg[];
}

class RoutingService {
  // "RouteSpecification을 만족하는 Itinerary를 찾는다"
  findItinerary(spec: RouteSpecification): Itinerary {
    return { legs: [] };
  }
}

class Cargo {
  private routeSpec: RouteSpecification;
  private itinerary?: Itinerary;

  constructor(routeSpec: RouteSpecification) {
    this.routeSpec = routeSpec;
  }

  // "Route Specification이 변경되면 Itinerary를 재생성"
  updateRouteSpecification(
    newSpec: RouteSpecification,
    routingService: RoutingService
  ): void {
    this.routeSpec = newSpec;

    // "Itinerary가 여전히 Specification을 만족하는지 확인하고,
    //  만족하지 않으면 Routing Service가 재생성"
    if (!this.itinerary || !newSpec.isSatisfiedBy(this.itinerary)) {
      this.itinerary = routingService.findItinerary(newSpec);
    }
  }
}

두 시나리오 비교:

구분 시나리오 1 시나리오 2
사용 언어 기술적 구현 언어 (테이블, 행, Boolean) 도메인 언어 (Itinerary, RouteSpecification)
대화 품질 장황하고 오해 발생 가능 간결하고 정확
코드 표현 도메인 의미가 코드에 숨겨짐 도메인 의미가 코드에 직접 반영됨
토론 효율 매번 구현 세부 사항을 설명해야 함 공유된 객체로 정확하게 논의 가능
비즈니스 의도 “행을 삭제하고 다시 채운다” “Itinerary가 Specification을 만족하지 않으면 재생성한다”

두 번째 대화가 도메인 전문가의 의도를 더 잘 전달한다. Itinerary라는 단어는 두 대화 모두에서 등장하지만, 두 번째에서는 두 사람이 정확하고 구체적으로 논의할 수 있는 객체였다. Route Specification도 매번 속성과 절차로 설명하는 대신 명시적으로 논의할 수 있었다.


2.2 소리 내어 모델링하기

말이 다른 형태의 의사소통과 분리되어 있는 것은 특히 큰 손실이다. 우리 인간은 구어(spoken language)에 탁월한 재능을 가지고 있기 때문이다. 안타깝게도 사람들이 말할 때, 그들은 보통 도메인 모델의 언어를 사용하지 않는다.

핵심 관찰
  • 요구사항이나 설계 논의 자리에서 실제로 들리는 것: 비즈니스 전문 용어, 기술적 산출물 이야기, 구체적 기능 이야기
  • 도메인 모델 명사(클래스로 코딩되는 것들)는 언급되지만, 도메인 모델의 관계와 상호작용 관점에서 표현되는 문구는 거의 없음
  • ==모델을 정제하는 가장 좋은 방법 중 하나는 말로 탐구하는 것이다. 어색한 부분은 말로 할 때 쉽게 들린다.==
같은 개념의 세 가지 말하기 방식 비교
표현 방식 유형 특징
“Routing Service에 출발지, 목적지, 도착 시간을 주면 화물이 들를 정류장을 찾아서… DB에 집어넣는다” 모호하고 기술적 구현 세부사항이 드러나고, 도메인 의미가 숨겨짐
“출발지, 목적지 등이 Routing Service로 들어가고, 필요한 모든 것을 담은 Itinerary를 돌려받는다” 완성도 높지만 장황함 도메인 객체가 등장하나 관계가 불명확
“Routing Service가 Route Specification을 만족하는 Itinerary를 찾는다” 간결함 도메인 관계가 명확하게 표현됨
구어를 모델링에 활용하는 이유
  • 인간의 뇌는 구어의 복잡성을 처리하는 데 특화되어 있음 (Steven Pinker, “The Language Instinct” 참조)
  • 서로 다른 언어 배경의 사람들이 공통 언어 없이 모이면 자연스럽게 피진(pidgin)을 만들어 낸다 — 원래 언어만큼 포괄적이지는 않지만 당면 과제에 적합
  • 대화 중 해석의 차이와 단어의 의미 차이를 자연스럽게 발견하고 해결함
  • 말로 사용하는 유비쿼터스 언어를 통해 다이어그램이나 문서로는 도달할 수 없는 방식으로 언어를 공유하게 됨
핵심 실천 원칙

시스템에 대해 이야기할 때 모델을 가지고 놀아라. 모델의 요소와 상호작용을 사용하여 시나리오를 소리 내어 설명하라. 모델이 허용하는 방식으로 개념을 조합하라. 더 쉽게 말할 방법을 찾아라. 그리고 그 새로운 아이디어를 다이어그램과 코드에 다시 반영하라.


2.3 하나의 팀, 하나의 언어

잘못된 관행: 두 가지 언어의 사용

기술 인력이 도메인 전문가를 도메인 모델로부터 “보호”해야 한다고 느끼며 나오는 발언들:

  • “그들에게는 너무 추상적이다.”
  • “그들은 객체를 이해하지 못한다.”
  • “그들의 용어로 요구사항을 수집해야 한다.”

==이것들은 팀에 두 가지 언어를 두어야 한다는 이유로 들었던 몇 가지 말들이다. 잊어버려라.==

올바른 접근법
  • 설계의 기술적 구성 요소 중 도메인 전문가와 무관한 것이 있을 수는 있지만, 모델의 핵심은 반드시 그들의 관심사이어야 한다
  • “너무 추상적이다”고 한다면, 그 추상화가 올바른지 어떻게 아는가? 도메인 전문가만큼 도메인을 깊이 이해하고 있는가?
  • ==정교한 도메인 전문가가 모델을 이해하지 못한다면, 모델에 문제가 있는 것이다.==
  • 초기에는 모델이 없어서 어색할 수 있지만, 개발자와 함께 논의하면서 점차 공유 모델로 정제됨
  • 새 언어가 발전할수록 도메인 전문가도 이를 채택하고 여전히 중요한 기존 문서를 소급 적용하는 노력을 기울여야 함
유비쿼터스 언어를 통한 협업의 이점
  • 도메인 전문가가 이 언어로 논의하면 모델이 그들의 필요에 부적합하거나 잘못된 부분을 빠르게 발견함
  • 모델 기반 언어의 정밀성이 도메인 전문가 자신의 사고에 있는 모순이나 모호함을 드러냄
  • 시나리오 워크스루(walkthrough): 개발자와 사용자 전문가가 모델 객체를 단계별로 함께 사용하면서 모델을 비공식적으로 테스트
  • 거의 모든 토론이 개발자와 사용자 전문가가 함께 모델을 다루고 서로의 이해를 심화하며 개념을 정제하는 기회
  • 도메인 전문가가 모델 언어로 유스케이스를 작성하고, 인수 테스트를 명세하는 데 직접 활용 가능
  • Agile 프로세스에서 요구사항이 진화할 때 정제된 유비쿼터스 언어로 재구성되어야 함
유비쿼터스 언어의 위치
graph TD
    subgraph 개발자_영역["개발자 영역 (개발자만의 확장)"]
        direction LR
        A1["기술적 설계 측면"]
        A2["기술적 용어"]
        A3["기술적 설계 패턴"]
    end

    subgraph 유비쿼터스_언어["유비쿼터스 언어 (공유 영역)"]
        direction LR
        B1["도메인 모델 용어"]
        B2["바운디드 컨텍스트 이름"]
        B3["대규모 구조 용어"]
        B4["패턴 이름"]
    end

    subgraph 비즈니스_영역["비즈니스 영역 (도메인 전문가만의 확장)"]
        direction LR
        C1["개발자가 이해 못하는 비즈니스 용어"]
        C2["설계에 나타나지 않는 공통 비즈니스 용어"]
    end

    개발자_영역 --> 유비쿼터스_언어
    비즈니스_영역 --> 유비쿼터스_언어
  • 개발자와 도메인 전문가 각각의 전용 영역은 유비쿼터스 언어의 **확장(extensions)**이지, 같은 도메인에 대한 별개의 어휘가 아님
  • 이 방언들은 별개의 모델을 반영하는 대안 어휘를 포함해서는 안 됨
  • 언어적 분열은 절대 도메인 전문가와 개발자 사이에서 일어나서는 안 된다.
  • 유비쿼터스 언어를 통해 개발자들 간의 대화, 도메인 전문가들 간의 논의, 코드 자체의 표현이 모두 공유된 도메인 모델에서 파생된 동일한 언어에 기반하게 된다

2.4 문서와 다이어그램

2.4.1 UML의 역할과 한계

UML의 장점:

  • 객체 간 관계 소통에 효과적
  • 상호작용 표현에 어느 정도 유용
  • 단순하고 비공식적인 UML 다이어그램(3~5개 객체)은 토론의 닻(anchor) 역할
  • 모든 참여자가 객체 이름과 관계를 공유하게 해줌
  • 다이어그램은 토론이 진행되면서 변경될 수 있어 말의 유동성을 일부 담음

UML의 한계:

한계 설명
개념 정의 불가 객체의 개념적 정의를 전달하지 못함. 의미는 말이나 대화에서 보완되어야 함
행위/제약 표현 불가 속성과 관계는 객체 모델의 절반에 불과. 행위와 제약은 다이어그램으로 쉽게 표현되지 않음
과도한 완전성 전체 모델을 UML로 담으려 하면 너무 세세하고 동시에 너무 많이 빠진 다이어그램이 됨
목적 전달 불가 인터랙션 다이어그램도 모델 뒤에 있는 목적을 암시할 뿐, 직접 말할 수 없음
코드 생성 부적합 UML 모델링 도구의 코드 생성 기능 활용 시도는 대부분 역효과. UML의 제약에 묶여 모델의 가장 중요한 부분이 빠짐

==UML 다이어그램은 모델의 두 가지 가장 중요한 측면을 전달할 수 없다: 표현하는 개념의 의미, 그리고 객체가 해야 할 일.==

올바른 접근:

  • 다이어그램은 소통과 설명의 수단이며 브레인스토밍을 촉진함
  • 최소화할 때 최대의 효과를 발휘
  • 전체 객체 모델을 포괄하는 종합 다이어그램은 독자를 압도하고 의미를 잃음
  • 다이어그램은 아이디어의 뼈대(skeleton)를 표현
  • 설계의 핵심 세부 사항은 코드에 담김; 잘 작성된 구현 코드는 그 이면의 모델을 드러냄
  • 텍스트 문서에 다이어그램을 주석으로 다는 것이 아니라, 텍스트 문서에 선별된 단순화된 다이어그램을 삽입하는 방식이 더 효과적

==항상 기억하라. 모델은 다이어그램이 아니다. 다이어그램의 목적은 모델을 소통하고 설명하는 것이다. 코드는 설계의 세부 사항을 담는 저장소가 될 수 있다.==

2.4.2 문서 작성 원칙
문서는 코드와 대화를 보완해야 한다

XP(Extreme Programming)의 접근:

  • 추가 설계 문서 없이 코드가 직접 말하게 함
  • 실행 코드는 거짓말하지 않음 (다른 문서는 거짓말할 수 있음)
  • 실행 코드의 동작은 명확(unambiguous)함
  • 주석, 외부 문서, 다이어그램은 프로그램 동작에 영향을 주지 않으므로 활성 코드와 동기화가 어긋나기 쉬움
  • 반면 대화와 화이트보드의 일시적 다이어그램은 오래 남아 혼란을 일으키지 않음

코드만으로 문서화할 때의 한계:

  • 세부 사항에 독자를 압도할 수 있음
  • 동작이 명확해도 자명하지 않을 수 있음
  • 동작 이면의 의미를 전달하기 어려움 (종합 UML 다이어그램과 유사한 기본적 문제)

문서의 올바른 역할:

  • 코드가 이미 잘 하고 있는 것을 중복하지 않음
  • 의미를 밝히고, 대규모 구조에 대한 통찰을 제공하며, 핵심 요소에 집중을 유도
  • 프로그래밍 언어가 개념의 직관적인 구현을 지원하지 않을 때 설계 의도를 명확히 함
문서는 살아서 최신 상태를 유지해야 한다

작성 방식:

  • 모델의 작고, 신중하게 선별된 하위 집합을 다이어그램으로 표현
  • 텍스트로 클래스와 책임을 정의하고 의미의 맥락 안에 배치
  • 다이어그램은 다소 캐주얼해도 됨 — 심지어 손으로 그린 것이라도 괜찮음
  • 손으로 그린 다이어그램은 캐주얼하고 임시적이라는 느낌을 주는데, 이는 모델 아이디어의 실제 속성을 잘 반영함

문서 유효성 판단 기준 (유비쿼터스 언어와의 연동):

  • 문서가 프로젝트에서 사람들이 현재 사용하는 언어로 작성되었는가?
  • 코드에 내재된 언어로 작성되었는가?
  • 문서의 용어가 대화와 코드에 실제로 나타나는가?

문서가 실패하는 신호:

  • 문서의 용어가 대화와 코드에 나타나지 않음 → 문서가 너무 크거나 복잡하거나, 충분히 중요한 주제를 다루지 않음
  • 유비쿼터스 언어는 자연스럽게 변화하는데 문서는 갱신되지 않음 → 문서가 사람들에게 관련성이 없거나 중요하지 않게 느껴짐
  • 활성 상태로 두면 혼란을 야기하고 프로젝트를 해침 → 이력(history)으로 보관하는 것이 안전

핵심 원칙:

  • 최소한의 문서를 유지하며 코드와 대화를 보완하는 데 집중
  • 유비쿼터스 언어와 그 진화를 문서 선택의 기준으로 삼을 것
  • 유비쿼터스 언어는 요구사항 명세를 더 간결하고 명확하게 만들 수 있음 — 모델 뒤에 있는 비즈니스 지식을 전달할 필요가 없어지기 때문
2.4.3 실행 가능한 코드
  • XP 진영의 선택: 실행 코드와 테스트에 거의 전적으로 의존
  • 잘 작성된 코드는 의사소통력이 있지만, 전달하는 메시지가 정확하다는 보장은 없음
// 메서드 이름과 실제 동작/의미 사이의 괴리 예시

// 나쁜 예: 이름이 모호하거나 내부 구현과 다를 수 있음
async function process(id: string, flag: boolean): Promise<void> {
  // 이름만 봐서는 무엇을 하는지, 도메인 어디에 해당하는지 알 수 없음
}

// 좋은 예: 도메인 언어를 그대로 반영
async function regenerateItinerary(
  cargo: Cargo,
  newRouteSpec: RouteSpecification,
  routingService: RoutingService
): Promise<void> {
  // 메서드 이름이 도메인 언어와 일치하고 의도가 명확함
  cargo.updateRouteSpecification(newRouteSpec, routingService);
}

코드 문서화의 현실적 한계:

요소 신뢰도
실행 동작(behavior) 명확하고 반박 불가능
메서드 이름 모호하거나 오해를 유발하거나 내부와 다를 수 있음
테스트의 단언(assertion) 엄격함
변수 이름과 코드 구성이 전달하는 이야기 엄격하지 않음
  • 선언적 설계(Declarative Design): 프로그램 요소의 목적 선언이 실제 동작을 결정하게 함으로써 불일치를 제거하는 것이 핵심 장점
  • UML로부터 프로그램을 생성하려는 시도도 이 문제에서 부분적으로 동기를 얻지만, 일반적으로 잘 작동하지 않음
  • 코드가 다른 문서보다는 실제에 더 가깝지만, 행위, 의도, 메시지를 정렬하는 것은 규율과 특정 설계 사고방식을 요구함

==코드가 효과적으로 소통하려면, 요구사항을 작성하는 데 사용된 언어와 동일한 언어에 기반해야 한다 — 개발자들이 서로, 그리고 도메인 전문가들과 나누는 바로 그 언어.==


2.5 설명 모델

이 책의 핵심 주장: 하나의 모델이 구현, 설계, 팀 의사소통의 기반이 되어야 한다. 이 목적들을 위한 별도의 모델을 두는 것은 위험하다.

설명 모델의 정의와 필요성
  • 설계를 이끄는 모델은 도메인의 한 관점이다
  • 일반적인 도메인 지식을 전달하기 위한 다른 관점이 학습에 도움이 될 수 있다
  • 이러한 용도로만 사용되는 교육적 도구로서 소프트웨어 설계와 무관한 다른 종류의 모델을 그림이나 말로 표현할 수 있다

설명 모델이 필요한 구체적 이유:

  • 범위(Scope) 문제: 소프트웨어 개발을 이끄는 기술 모델은 기능 수행에 필요한 최소한으로 축소되어야 함. 설명 모델은 좁은 기술 모델에 맥락을 제공하는 도메인의 더 넓은 측면을 포함할 수 있음
  • 소통 스타일: 설명 모델은 특정 주제에 맞춰 훨씬 더 소통적인 스타일을 자유롭게 사용할 수 있음
  • 다양한 설명: 도메인 전문가들이 사용하는 시각적 은유가 더 명확한 설명을 제공하고, 다양한 설명 방식이 학습을 도움
설명 모델 사용 시 주의사항
  • 설명 모델이 객체 모델일 필요는 없으며, 그렇지 않은 것이 일반적으로 더 나음
  • 설명 모델에서 UML 사용을 피하는 것이 좋다 → 소프트웨어 설계와의 대응 관계에 대한 잘못된 인상을 방지하기 위함
  • 설명 모델과 설계를 이끄는 모델이 종종 대응되더라도 그 유사성이 정확히 일치하는 경우는 드뭄
  • 혼란을 피하기 위해 모든 팀원이 두 모델의 차이를 인식해야 함
예시: 운송 작업과 경로

화물 운송 회사의 화물을 추적하는 애플리케이션 사례에서의 두 가지 관점 비교:

기술 모델 (클래스 다이어그램):

Voyage                    Route
  |  {ordered}              | {ordered}
  *                         *
Transport Leg  <--------- Leg
  departureTime              load
  arrivalTime                unload
  from   to
   |      |
 Location  Port Operation

초심자에게는 이 클래스 다이어그램이 직관적이지 않을 수 있음.

설명 모델 (비기술적, 교육용 도식):

항구 작업과 해상/육상 운송 구간을 선(line)으로 표현한 도식:

  • 각 선 = 항구 작업(화물 선적/하역), 또는 지상 보관 중인 화물, 또는 운송 중인 선박/열차의 화물
  • 이 도식은 클래스 다이어그램과 세부적으로 일치하지 않지만 도메인의 핵심 포인트를 강화함

두 관점의 시너지:

도구 역할
클래스 다이어그램 (기술 모델) 소프트웨어 구조의 정확한 명세, 구현의 기반
설명 모델 (교육용 도식) 클래스 다이어그램의 의미를 직관적으로 이해하도록 도움
자연 언어 설명 모델이 표현하는 것을 풀어서 설명
  • 설명 모델과 자연 언어 설명을 함께 사용하면 개발자와 도메인 전문가 모두가 더 엄격한 소프트웨어 모델 다이어그램을 이해하는 데 도움이 됨
  • 두 관점을 함께 사용하면 어느 하나만 사용할 때보다 이해하기 쉽다

2.6 핵심 개념 요약표

개념 핵심 원칙
유비쿼터스 언어 도메인 모델을 뼈대로 삼아 팀의 모든 소통(대화, 코드, 다이어그램, 문서)에서 동일하게 사용하는 공유 언어
언어의 진화 유비쿼터스 언어의 변화는 도메인 모델의 변화이며 코드에 즉시 반영되어야 함
소리 내어 모델링 말로 시나리오를 설명하는 것이 모델을 정제하는 가장 효과적인 방법 중 하나
하나의 언어 언어적 분열은 절대 도메인 전문가와 개발자 사이에서 발생해서는 안 됨
다이어그램의 역할 다이어그램은 모델이 아니라 모델을 소통하는 수단이며, 최소화할수록 효과적
문서의 역할 코드와 대화를 보완해야 하며, 유비쿼터스 언어와 연동되어 살아 움직여야 함
코드의 역할 설계 세부 사항의 최종 저장소이며, 도메인 언어와 동일한 언어를 기반으로 작성되어야 함
설명 모델 교육 목적의 별도 모델. UML 사용을 피하고 기술 모델과의 혼동에 주의

챕터 3. 모델과 구현의 연계

Eric Evans - Domain-Driven Design (2003), Chapter 3 학습 노트


도입: 설계와 모델의 단절

사례 1: 정교한 모델, 무용한 코드

  • 수개월에 걸쳐 도메인 전문가와 기술 분석가들이 벽면을 가득 채운 상세한 도메인 모델 구축
  • 모델 내 전형적인 객체는 3~4개의 다른 객체와 복잡한 연관 관계를 맺고 있었으며 자연스러운 경계가 거의 없었음
  • 모델은 일정한 지식을 담고 있었으나 애플리케이션 코드·설계와 전혀 연결되지 않음
  • 개발자들이 구현에 착수하자 곧 발견한 것:
    • 복잡한 연관 관계망이 인간 분석가에게는 탐색 가능했지만 저장·조회 가능하고 트랜잭션 무결성을 가진 단위로 변환되지 않음
    • 해당 프로젝트는 객체 데이터베이스를 사용하고 있어 관계형 테이블 매핑 문제조차 없었는데도 불구하고
    • 근본적으로 모델은 구현의 가이드를 제공하지 못했음
  • 결론: 모델이 “올바르다”는 이유에도 개발자들은 개념 기반 객체가 설계의 기반이 될 수 없다고 판단
    • 일부 클래스명과 속성을 데이터 저장용으로 차용하되 기존 모델과 무관한 애드혹(ad hoc) 설계를 별도로 구축
  • 핵심 질문: 실행 소프트웨어 개발에 직접 기여하지 못하는 종이 위의 모델이 무슨 가치가 있는가?

사례 2: 모델 없는 코드

  • C++ 레거시 앱을 Java로 재작성하는 프로젝트
  • 원 애플리케이션은 객체 모델링에 대한 고려 없이 기능을 쌓아 올린 구조였으며 눈에 띄는 일반화나 추상화가 전혀 없었음

공통된 결말

두 프로젝트(정교한 모델을 가진 쪽과 전혀 모델이 없는 쪽) 모두 동일한 결과에 도달했다.

  • 기능은 동작하지만 비대하고(bloated), 이해하기 어렵고, 결국 유지보수 불가
  • 코드를 읽어도 시스템의 목적을 파악할 수 없음
  • 객체 패러다임을 고급 데이터 구조 이상으로 활용하지 못함

DDD가 요구하는 모델

  • 초기 분석 단계만을 위한 모델이 아니라 설계의 근간 자체가 되는 모델이 필요
  • 이를 위해서는 모델링에 대한 근본적으로 다른 접근 방식 필요

3.1 모델 주도 설계

모델 주도 설계(Model-Driven Design): 코드와 모델을 긴밀하게 결합하여 코드가 곧 모델의 표현이 되게 하는 설계 방식. 코드에 의미를 부여하고 모델의 관련성을 유지한다.

3.1.1 모델과 코드가 분리되는 이유

많은 설계 방법론이 분석 모델(analysis model)설계(design) 를 명시적으로 구분한다.

  • 분석 모델: 소프트웨어 시스템에서 담당할 역할을 일체 고려하지 않고 비즈니스 도메인 개념을 정리한 산출물 (이해를 위한 도구)
  • 설계: 분석 모델과 느슨하게만 연결되며 보통 다른 사람들이 작성
  • 분석 단계에서 얻은 통찰이 코딩 시작과 함께 소실
  • 개발자들은 설계를 위한 새 추상화를 처음부터 발명해야 하며 분석가들의 통찰이 보존된다는 보장이 없음
  • 모델과 느슨하게 연결된 설계 사이의 매핑을 유지하는 것은 비용 효율적이지 않음

3.1.2 분석 모델의 근본적 한계

문제 설명
지식 손실 분석-설계 핸드오프 과정에서 도메인 통찰이 소실됨
예상치 못한 발견 중요한 발견은 대부분 설계·구현 과정에서 비로소 등장
관련성 없는 상세화 중요하지 않은 주제를 깊이 다루고, 중요한 주제는 누락하거나 애플리케이션에 유용하지 않은 방식으로 표현
조기 폐기 코딩 시작 이후 순수 분석 모델은 빠르게 버려짐, 두 번째 작업에서 모델링은 덜 엄격하게 진행됨
도메인 전문가 접근 차단 분석이 별개의 과정으로 인식될 때 관리자는 개발팀의 도메인 전문가 접근을 보장하지 않음

설계의 핵심부가 도메인 모델에 매핑되지 않으면, 그 모델은 거의 가치가 없으며 소프트웨어의 정확성도 의심스럽다. 동시에, 모델과 설계 기능 간의 복잡한 매핑은 이해하기 어렵고 설계가 변경될수록 유지보수가 실질적으로 불가능해진다. 분석과 설계 사이에 치명적인 단절이 생겨, 각 활동에서 얻은 통찰이 다른 쪽으로 흘러들지 못한다.

3.1.3 핵심 사상: 단일 모델

모델 주도 설계는 분석 모델과 설계 모델의 이분법을 폐기하고 두 목적을 모두 충족하는 단일 모델을 추구한다.

  • 설계의 각 객체는 모델에서 설명된 개념적 역할을 담당
  • 모델에는 두 가지 상이한 목적(분석 + 설계)을 동시에 충족해야 하는 요구가 부과됨
  • 모델이 구현에 실용적이지 않으면 새로운 모델을 탐색해야 함
  • 모델이 도메인의 핵심 개념을 충실히 표현하지 못해도 새로운 모델을 탐색해야 함
  • 모델링과 설계 과정은 하나의 반복적 루프가 됨

3.1.4 실천 지침

  • 소프트웨어 시스템의 일부를 도메인 모델을 매우 직접적으로 반영하도록 설계 (매핑이 명확해야 함)
  • 소프트웨어에서 더 자연스럽게 구현되도록, 그리고 도메인에 대한 더 깊은 통찰을 반영하도록 모델을 반복적으로 수정
  • Ubiquitous Language를 지지하는 단일 모델 요구
  • 설계와 책임 할당에 쓸 용어를 모델에서 도출
  • ==코드가 모델의 표현이 되므로 코드 변경은 곧 모델 변경==, 그 영향은 프로젝트 전체 활동에 파급되어야 함
  • 구현을 모델에 충실하게 결합하려면 객체 지향 프로그래밍과 같이 모델링 패러다임을 지원하는 도구·언어가 필요

3.1.5 서브시스템별 모델

  • 서로 다른 서브시스템은 서로 다른 모델을 가질 수 있음 (Chapter 14 참조)
  • 단, 시스템의 특정 부분에는 코드부터 요구사항 분석까지 단 하나의 모델만 적용

3.1.6 단일 모델의 이점

  • 설계가 신중하게 검토된 모델의 직접적 산물이므로 오류 가능성 감소
  • 설계, 심지어 코드 자체가 모델의 의사소통력(communicativeness) 을 가짐
  • 개발이 모델, 설계, 코드를 하나의 활동으로 반복 정제하는 과정이 됨

3.2 모델링 패러다임과 도구 지원

3.2.1 패러다임-모델-설계 순환

모델 주도 설계가 성과를 내려면 모델과 설계의 대응이 정확해야 한다. 이를 위해 모델의 개념과 직접 유사한 것을 생성할 수 있는 소프트웨어 도구와 모델링 패러다임 안에서 작업해야 한다.

graph LR
    M[모델] --> P[패러다임]
    P --> D[설계]
    D --> M

모델, 패러다임, 설계는 서로를 지탱하는 순환 구조를 형성한다.

3.2.2 객체 지향 프로그래밍

OOP가 강력한 이유는 모델링 패러다임에 기반하며 모델 구성요소의 구현을 제공하기 때문이다.

  • 객체는 메모리에 실제로 존재
  • 다른 객체와 연관(association)을 가짐
  • 클래스로 조직화됨
  • 메시징을 통해 행동(behavior)을 제공

많은 개발자가 객체의 기술적 능력만을 코드 구조화에 활용하지만, OOP의 진정한 돌파구는 코드가 모델의 개념을 표현할 때 찾아온다. TypeScript, Java 등 다수의 언어가 개념적 객체 모델과 직접 유사한 객체와 관계 생성을 지원한다.

  • Prolog의 경우: 패러다임이 논리(logic)이며 모델이 논리 규칙과 사실의 집합 → 모델 주도 설계에 자연스럽게 부합

3.2.3 절차적 언어의 한계

C와 같은 순수 절차적 언어에서는 모델 주도 설계의 적용이 제한적이다.

  • 순수 절차적 언어에 대응하는 모델링 패러다임이 없음
  • 프로그램 자체가 컴퓨터에게 따를 일련의 단계를 알려주는 것에 불과
  • 프로그래머가 도메인 개념을 떠올리더라도 프로그램은 데이터의 기술적 조작 시리즈
  • 복잡한 데이터 타입은 정리된 데이터일 뿐, 도메인의 능동적(active) 측면 포착 불가
  • 소프트웨어의 함수들은 도메인 모델의 개념적 연결이 아닌 예상된 실행 경로를 따라 연결됨
  • 비수학적 도메인은 수학 함수나 절차의 단계로 개념화되지 않으므로 적용 더욱 어려움

3.2.4 예제: 절차적 방식에서 모델 주도 방식으로

문제 도메인: PCB 레이아웃 도구

  • PCB(인쇄 회로 기판): 수만 개의 넷(Net) 으로 구성 (넷 = 다양한 부품의 핀을 연결하는 전기 도선)
  • PCB 레이아웃 도구: 넷이 교차·간섭하지 않도록 물리적 배치를 최적화하는 소프트웨어
    • 인간 설계자가 부과하는 방대한 수의 제약을 충족하면서 경로를 최적화
  • 핵심 문제: 각 넷이 자체 레이아웃 규칙 집합을 가짐
    • PCB 엔지니어는 다수의 넷이 동일한 규칙을 공유하는 버스(Bus) 로 묶여야 한다고 인식
    • 예: 8, 16, 256개 단위로 넷을 버스에 묶으면 작업량이 줄고 생산성이 높아지며 오류가 감소
    • 그러나 레이아웃 도구에는 버스 개념이 없음 → 수만 개의 넷에 규칙을 일일이 할당

데이터 파일 형식:

// 넷 목록 파일 (Net List)
Net Name  Component.Pin
--------  -------------
Xyz0      A.0, B.0
Xyz1      A.1, B.1
Xyz2      A.2, B.2

// 규칙 파일 (Rules)
Net Name  Rule Type      Parameters
--------  ---------      ----------
Xyz1      min_linewidth  5
Xyz1      max_delay      15
Xyz2      min_linewidth  5
Xyz2      max_delay      15

절차적 해결 방식과 그 한계

접근법: 레이아웃 도구의 데이터 파일을 파싱하여 규칙을 직접 삽입하는 스크립트 작성

  • 네이밍 컨벤션을 활용: 알파벳 정렬 시 같은 버스의 넷이 연속되도록 명명 (Xyz0, Xyz1, Xyz2…)
  • 파일을 정렬한 뒤 버스명 패턴 매칭으로 해당 넷들을 식별하여 규칙 파일에 추가

절차 요약:

  1. 넷 목록 파일을 넷 이름 기준으로 정렬
  2. 파일을 한 줄씩 읽으며 버스 이름 패턴으로 시작하는 첫 번째 줄 탐색
  3. 매칭되는 줄마다 넷 이름 파싱
  4. 규칙 파일에 넷 이름 + 규칙 텍스트 추가
  5. 줄 시작이 버스 이름과 더 이상 매칭되지 않을 때까지 3번 반복

버스 규칙 입력 Xyz max_vias 3 이 개별 넷 규칙으로 확장된 결과:

Net Name  Rule Type  Parameters
--------  ---------  ----------
Xyz0      max_vias   3
Xyz1      max_vias   3
Xyz2      max_vias   3

절차적 방식의 근본적 한계:

  • 최초 단일 요구사항에는 합리적이나, 실제 운영 환경에서 스크립트가 수십 개로 불어남
  • 파일 포맷이 달라지면 처음부터 재작성 필요 (버스 규칙 개념 자체는 동일한데도)
  • 풍부한 기능이나 상호작용성 추가 시 막대한 비용 발생
  • 버스의 존재를 정렬과 문자열 매칭으로 추론할 뿐, 버스라는 개념을 명시적으로 다루지 않음
  • 스크립트 작성자들이 하려 했던 것: 도구의 도메인 모델에 “버스” 개념을 보완하는 것이었으나 구현에는 그 개념이 없음

모델 주도 설계 적용

도메인 모델:

classDiagram
    class AbstractNet {
        -rules: Set~LayoutRule~
        +assignRule(rule: LayoutRule): void
        +assignedRules(): Set~LayoutRule~
    }
    class Net {
        +name: string
        -bus?: Bus
        +assignedRules(): Set~LayoutRule~
        +getRule(ruleType: RuleType): LayoutRule | undefined
    }
    class Bus {
        +name: string
        -nets: Net[]
        +addNet(net: Net): void
    }
    class LayoutRule {
        +type: RuleType
        +parameter: number
        +create(type: RuleType, param: number): LayoutRule
    }
    AbstractNet <|-- Net
    AbstractNet <|-- Bus
    Net "0..*" --> "0..1" Bus : belongs to
    AbstractNet "1" --> "0..*" LayoutRule : has

TypeScript 구현:

abstract class AbstractNet {
  private rules: Set<LayoutRule> = new Set();

  assignRule(rule: LayoutRule): void {
    this.rules.add(rule);
  }

  assignedRules(): Set<LayoutRule> {
    return new Set(this.rules);
  }
}

class Net extends AbstractNet {
  constructor(
    public readonly name: string,
    private bus?: Bus
  ) {
    super();
  }

  assignedRules(): Set<LayoutRule> {
    return new Set<LayoutRule>([
      ...super.assignedRules(),
      ...(this.bus?.assignedRules() ?? []),
    ]);
  }

  getRule(ruleType: RuleType): LayoutRule | undefined {
    return [...this.assignedRules()].find((r) => r.type === ruleType);
  }
}

assignRule()AbstractNet에 구현되고, NetassignedRules()는 자신의 규칙과 소속 Bus의 규칙을 합산한다. 핵심 기능이 거의 자명한 수준으로 단순해진다.

서비스 및 저장소 구조:

역할 설명
NetListImportService 넷 목록 파일을 읽어 각 항목마다 Net 인스턴스 생성
NetRuleExportService Net 컬렉션을 받아 연결된 모든 규칙을 규칙 파일로 기록
NetRepository 이름으로 Net 접근 제공
InferredBusFactory Net 컬렉션을 받아 네이밍 컨벤션으로 Bus 추론, 인스턴스 생성
BusRepository 이름으로 Bus 접근 제공

Bus는 개념적으로 이름 기반 인식에 의존하지 않으므로 테스트도 그에 의존해서는 안 된다.

애플리케이션 초기화:

const nets = await NetListImportService.read(filePath);
NetRepository.addAll(nets);

const buses = InferredBusFactory.groupIntoBuses(nets);
BusRepository.addAll(buses);

단위 테스트 (Jest):

describe('BusRuleAssignment', () => {
  it('버스에 할당된 규칙이 소속 넷에도 적용되어야 한다', () => {
    // Bus는 개념적으로 이름 기반 인식에 의존하지 않으므로 테스트도 그러해야 함
    const bus = new Bus('a');
    const a0 = new Net('a0', bus);
    const a1 = new Net('a1', bus);
    bus.addNet(a0);
    bus.addNet(a1);

    const minWidth4 = NetRule.create(RuleType.MIN_WIDTH, 4);
    bus.assignRule(minWidth4);

    expect(a0.assignedRules().has(minWidth4)).toBe(true);
    expect(a0.getRule(RuleType.MIN_WIDTH)).toBe(minWidth4);
    expect(a1.getRule(RuleType.MIN_WIDTH)).toBe(minWidth4);
  });
});

퍼사드(Facade):

// UI와 파일 기반 인터페이스 모두를 단순하게 지원
function assignBusRule(
  busName: string,
  ruleType: string,
  parameter: number
): void {
  const bus = BusRepository.getByName(busName);
  bus.assignRule(NetRule.create(ruleType, parameter));
}

익스포트 완료:

// 각 Net에 assignedRules()를 요청한 뒤 완전히 전개하여 기록
await NetRuleExportService.write(outputFileName, NetRepository.allNets());

두 방식 비교

항목 절차적 방식 모델 주도 방식
개념 표현 암묵적 (정렬+문자열 매칭으로 버스 추론) 명시적 (Bus, Net 클래스로 표현)
확장성 수십 개의 스크립트로 폭발 쉽게 확장 가능
파일 형식 변경 처음부터 재작성 필요 임포트/익스포트 서비스만 변경
테스트 가능성 파일 입출력 비교(엔드투엔드)만 가능 구성 요소별 단위 테스트 가능
규칙 조합 제약 구현 어려움 자연스럽게 추가 가능
상호작용성 매 기능마다 큰 비용 퍼사드 패턴으로 자연스럽게 지원

이러한 설계는 한 번에 도출되지 않는다. 도메인의 중요한 개념을 단순하고 명확한 모델로 정제하려면 여러 번의 리팩토링과 지식 정제(knowledge crunching)가 필요하다.


3.3 내부 드러내기: 왜 모델이 사용자에게 중요한가

3.3.1 사용자 모델과 구현 모델의 불일치

  • 이론적으로는 내부 구현과 무관하게 어떤 뷰든 사용자에게 제공 가능
  • 실제로는 모델과 사용자 인터페이스의 불일치가 혼란(최선) 또는 버그(최악) 를 유발
  • 모델 주도 설계에서 분리된 두 모델 쌍은 분석 모델과 설계 모델만이 아님: 사용자 모델과 설계·구현 모델 또한 하나여야 함

3.3.2 사례: Internet Explorer의 즐겨찾기

두 모델의 충돌:

모델 내용
사용자 모델 즐겨찾기 = 세션 간 유지되는 웹사이트 이름 목록
구현 모델 즐겨찾기 = URL을 담은 파일, 파일명이 즐겨찾기 목록에 표시됨

충돌의 결과:

  • 웹 페이지 제목에 Windows 파일명 금지 문자가 포함될 경우 오류 발생
    • 예: 사용자가 즐겨찾기 이름을 "Laziness: The Secret to Happiness" 로 저장하려 하면
    • 오류 메시지: "A filename cannot contain any of the following characters: \/ : * ? " < > |"
    • 사용자 관점에서는 파일명이 아니라 즐겨찾기 이름인데 갑자기 파일명 오류가 등장
  • 웹 페이지 제목에 이미 금지 문자가 포함된 경우 IE가 조용히(silently) 해당 문자를 제거
    • 사용자 모르게 데이터 손실 발생 → 대부분의 애플리케이션에서 완전히 용납 불가

3.3.3 해결 방향

방법 1: 구현 모델을 사용자에게 노출

  • 즐겨찾기가 단축키 파일 컬렉션임을 사용자에게 직접 알림
  • 사용자는 파일 시스템에 대한 자신의 기존 지식을 즐겨찾기에 활용 가능
    • 파일 탐색기로 즐겨찾기 재구성 가능
    • 파일 시스템 어디에나 웹 단축키를 저장하는 유연성 활용 가능
  • 혼란스러운 별도 모델을 제거하는 것만으로 애플리케이션의 기능과 명확성 동시 향상

방법 2: 구현 모델 변경

  • 즐겨찾기를 별도 데이터 파일에 저장하여 자체 규칙 적용
  • 웹 페이지 네이밍 규칙이 즐겨찾기에도 동일하게 적용 → 단일 모델 달성
  • 사용자가 웹 사이트 명명에 대해 아는 모든 것이 즐겨찾기에도 적용됨

3.3.4 원칙

설계가 사용자와 도메인 전문가의 기본 관심사를 반영하는 모델에 기반할 때, 설계의 뼈대를 사용자에게 더 많이 드러낼 수 있다. 모델을 드러내면 사용자는 소프트웨어의 잠재력에 더 많이 접근하고 일관적이며 예측 가능한 동작을 경험한다.


3.4 실천하는 모델러

3.4.1 제조업 은유의 함정

  • 소프트웨어 개발에 흔히 적용되는 제조업 은유:
    • 숙련된 엔지니어가 설계, 미숙련 노동자가 제품을 조립
  • 이 은유는 수많은 프로젝트를 망쳐왔다
  • 이유: ==소프트웨어 개발은 전부 설계(design)다==
  • 팀에 전문화된 역할이 있는 것은 괜찮으나, 분석·모델링·설계·프로그래밍 책임의 과도한 분리는 모델 주도 설계를 방해함

3.4.2 실패 사례: 분리된 모델러

  • 역할: 여러 애플리케이션 팀을 조율하고 설계를 이끌 도메인 모델 개발
  • 경영진의 판단: 모델러는 모델링만 해야 하며 코딩은 그 능력의 낭비 → 프로그래밍 및 개발자 협업 금지
  • 초기에는 도메인 전문가·각 팀 개발 리더와 지식을 정제하여 훌륭한 핵심 모델을 구축
  • 그러나 모델은 두 가지 이유로 끝내 작동하지 않았다

실패 원인 1: 핸드오프 과정의 의도 손실

  • 모델의 전반적 효과는 세부 사항에 매우 민감 (Parts II, III에서 상세 논의)
  • 이런 세부 사항은 UML 다이어그램이나 일반 토론으로는 충분히 전달되지 않음
  • 직접 코드를 작성하고 예시를 제공하며 긴밀한 지원을 제공했다면 팀이 모델의 추상화를 수용하고 발전시킬 수 있었을 것

실패 원인 2: 구현과 기술로부터의 피드백 차단

  • 모델의 일부 측면이 기술 플랫폼에서 극도로 비효율적이었으나 그 전체적 함의가 수개월 후에야 전달됨
  • 비교적 작은 변경으로 해결 가능했지만 이미 늦음
  • 개발자들은 이미 모델 없이 작동하는 소프트웨어를 작성하는 길을 택함
  • 모델은 여전히 사용되는 곳에서 단순한 데이터 구조로 전락
  • 이 모든 상황이 일어났을 때 초기 조건은 비교적 유리했음 (해당 기술 스택에 대한 풍부한 경험, 동일 팀 리드 경험 보유)에도 불구하고 분리가 효과를 무력화함

3.4.3 분리가 야기하는 근본 문제

코드를 작성하는 사람들이 모델에 대한 책임감을 느끼지 못하거나, 모델을 애플리케이션에 작동시키는 방법을 이해하지 못하면, 모델은 소프트웨어와 아무런 관계가 없어진다.

개발자들이 코드 변경이 모델 변경임을 인식하지 못하면, 그들의 리팩토링은 모델을 강화하는 것이 아니라 약화시킨다.

모델러가 구현 과정에서 분리될 때, 그는 구현의 제약에 대한 감각을 결코 습득하거나 빠르게 잃어버린다. 모델 주도 설계의 기본 제약 - 모델이 효과적인 구현을 지원하고 핵심 도메인 지식을 추상화해야 한다는 것 - 이 절반짜리가 된다. 최종적으로, 숙련된 설계자의 지식과 기술은 협업을 방해하는 분업 구조 속에서 다른 개발자에게 전달되지 않는다.

3.4.4 실천 지침

  • 모델에 기여하는 모든 기술 인력은 코드를 접하는 시간을 가져야 함 (프로젝트에서의 1차 역할과 무관하게)
  • 코드 변경을 담당하는 모든 사람은 코드를 통해 모델을 표현하는 방법을 배워야 함
  • 모든 개발자는 어떤 수준에서든 모델에 대한 토론에 참여하고 도메인 전문가와 접촉해야 함
  • 다른 방식으로 기여하는 사람들은 코드를 다루는 사람들과 Ubiquitous Language를 통한 역동적인 모델 아이디어 교환에 의식적으로 참여해야 함

프로그래머는 좋든 싫든 모델러다. 따라서 프로그래머가 훌륭한 모델링 작업을 할 수 있도록 프로젝트를 설정하는 것이 낫다.

3.4.5 역할 분리와 협업의 균형

  • 핸즈온 모델러의 필요성이 팀 구성원의 전문화 역할을 부정하지는 않음
  • XP를 포함한 모든 Agile 방법론은 팀 구성원의 역할을 정의하며, 비공식적 전문화도 자연스럽게 등장
  • 문제는 역할이 아니라 분리: 모델 주도 설계에서 결합되어야 하는 두 작업(모델링과 구현)의 분리가 핵심 문제
  • 대규모 프로젝트에서도 기술 리더는 필요함:
    • 고수준 설계·모델링 조율
    • 가장 어렵거나 중요한 결정에 기여
    • (Part IV “Strategic Design”에서 이런 역할 정의 방식을 다룸)

3.4.6 3장 요약

도메인 주도 설계의 세 기둥이 어떻게 연결되는지:

graph TD
    A["지식 정제 (Knowledge Crunching)"] --> B["실용적 모델"]
    B --> C["모델 주도 설계 (Model-Driven Design)"]
    C --> D["모델과 구현의 긴밀한 연결"]
    E["유비쿼터스 언어 (Ubiquitous Language)"] --> F["개발자 ↔ 도메인 전문가 ↔ 소프트웨어"]
    B --> E
    D --> G["핵심 도메인에 대한 근본적 이해에 기반한 풍부한 기능의 소프트웨어"]
    F --> G
  • 모델 주도 설계의 성공은 세부 설계 결정의 품질과 일관성에 매우 민감함
  • 이후 챕터들은 이 세부 설계 결정들을 다룸

Chapter 4 학습 노트: 도메인 격리


1. Part II: 모델 주도 설계의 구성 요소

핵심 전제

  • 소프트웨어 구현을 모델과 일치(lockstep) 시키려면 모델링과 설계의 모범 사례를 따라야 함
  • DDD는 객체 지향 설계 입문서가 아니며, 급진적인 설계 원칙을 제안하지도 않음
  • 기존 관행의 강조점을 전환(shifts the emphasis) 하는 것이 핵심
  • 모델과 구현을 일치시키는 특정 종류의 결정들이 서로를 강화함
  • 세부 요소 수준의 세심한 설계가 개발자에게 더 큰 규모의 모델링 접근법을 적용할 안정적 기반을 제공

설계 철학의 뿌리

  • 책임 주도 설계(Responsibility-Driven Design) 원칙을 주로 따름 (Wirfs-Brock 1990, 2003)
  • 계약에 의한 설계(Design by Contract) 개념을 차용 (Meyer 1988)
  • 그 외 널리 받아들여지는 객체 지향 설계 모범 사례(Larman 1998)와도 일관성을 유지

패턴 언어로서의 구성

  • 이하 3개 챕터는 패턴 언어(pattern language) 형식으로 구성됨
  • 미묘한 모델 구분과 설계 결정이 DDD 프로세스에 어떤 영향을 미치는지 보여줌
  • 표준 패턴을 팀원들이 공유함으로써 설계에 질서를 부여하고 서로의 작업 이해가 쉬워짐
  • 유비쿼터스 언어(UBIQUITOUS LANGUAGE)에도 기여: 팀 전체가 모델과 설계 결정을 논의하는 공용어 역할

모델 주도 설계 구성 요소 간 관계 (내비게이션 맵)

graph LR
    MDD["MODEL-DRIVEN DESIGN"]

    MDD -->|모델을 표현| SVC["SERVICES"]
    MDD -->|모델을 표현| ENT["ENTITIES"]
    MDD -->|모델을 표현| VO["VALUE OBJECTS"]
    MDD -->|도메인 격리| LA["LAYERED ARCHITECTURE"]
    MDD -->|상호 배타적 선택| SUI["SMART UI"]

    ENT -->|무결성 유지| AGG["AGGREGATES"]
    ENT -->|접근 수단| REPO["REPOSITORIES"]
    AGG -->|접근 수단| REPO
    VO -->|캡슐화| AGG
    AGG -->|캡슐화| FAC["FACTORIES"]
    VO -->|캡슐화| FAC
    LA -->|캡슐화| FAC

    style SUI fill:#f9f,stroke:#333
    style MDD fill:#bbf,stroke:#333

핵심 주장

정교한 모델은 기초에 충분한 주의를 기울일 때에만 복잡성을 뚫고 나갈 수 있다. 그 결과 팀이 자신 있게 조합할 수 있는 상세한 요소들이 만들어진다.


2. 도메인 격리 (Chapter Four. Isolating the Domain)

이 챕터의 핵심 주제

  • 도메인 문제를 해결하는 소프트웨어 부분은 전체 시스템 중 작은 비중을 차지하지만, 중요도는 크기에 비해 압도적으로 높음
  • 모델의 요소들을 하나의 시스템으로 바라보기 위해서는 다른 기술 관심사로부터 도메인 객체를 분리해야 함
  • 도메인 개념이 소프트웨어 기술 개념과 섞이면 모델을 이해하고 유지하기 어려워지며 모델 자체를 놓칠 수 있음

2.1 계층형 아키텍처 (Layered Architecture)

왜 계층이 필요한가: 문제 상황

운송 애플리케이션에서 화물 목적지를 도시 목록에서 선택하는 단순한 기능 하나에도 다음 코드가 모두 필요:

  1. 화면에 위젯을 그린다 (UI)
  2. 가능한 도시 목록을 DB에서 조회한다 (인프라)
  3. 사용자 입력을 해석하고 유효성 검사한다 (애플리케이션/도메인)
  4. 선택된 도시를 화물에 연결한다 (도메인)
  5. 변경 내용을 DB에 커밋한다 (인프라)

이 모든 코드가 하나의 프로그램에 있지만, 배송 비즈니스와 실제 관련된 것은 극히 일부

혼재(혼합)가 일어나는 이유와 그 결과

왜 혼재가 발생하는가:

  • 객체 지향 프로그램에서 UI, DB, 기타 지원 코드가 비즈니스 객체에 직접 작성됨
  • UI 위젯 동작과 DB 스크립트에 추가 비즈니스 로직이 내장됨
  • 단기적으로 가장 쉬운 방법이기 때문

혼재가 초래하는 문제:

  • 도메인 관련 코드가 다른 방대한 코드 속에 흩어지면 파악하거나 추론하기 극도로 어려워짐
  • UI의 사소한 변경이 비즈니스 로직을 바꿀 수 있음
  • 비즈니스 규칙을 바꾸려면 UI 코드, DB 코드 등을 꼼꼼히 추적해야 함
  • 응집력 있는 모델 주도 객체 구현이 사실상 불가능해짐
  • 자동화 테스트가 매우 어색해짐
  • 프로그램을 매우 단순하게 유지하지 않으면 이해 자체가 불가능해짐

해결책: 계층화와 관심사의 분리

복잡한 작업을 처리하는 프로그램을 만들기 위해서는 관심사의 분리(Separation of Concerns) 가 필요:

  • 설계의 각기 다른 부분에 개별적으로 집중할 수 있어야 함
  • 동시에 분리에도 불구하고 시스템 내 복잡한 상호작용은 유지되어야 함

업계는 경험과 관례를 통해 LAYERED ARCHITECTURES 로 수렴했음.

핵심 원칙:

한 계층의 모든 요소는 같은 계층이나 그 아래(beneath) 계층의 요소에만 의존한다. 위 방향 통신은 간접적인 메커니즘을 통해서만 이루어진다.

계층화의 가치:

  • 각 계층이 컴퓨터 프로그램의 특정 측면을 전문화함
  • 각 측면의 설계가 더 응집력 있게 되고 해석하기 쉬워짐
  • 가장 중요한 응집 설계 측면을 격리하는 계층을 선택하는 것이 핵심

4개의 표준 계층

graph TB
    UI["사용자 인터페이스\n(User Interface / Presentation Layer)\n─────────────────\n사용자에게 정보 표시,\n사용자 명령 해석"]
    APP["애플리케이션 계층\n(Application Layer)\n─────────────────\n소프트웨어가 해야 할 작업 정의,\n도메인 객체에 문제 해결 위임 (얇게 유지)"]
    DOMAIN["도메인 계층\n(Domain Layer / Model Layer)\n─────────────────\n비즈니스 개념, 상황 정보, 규칙 표현\n비즈니스 소프트웨어의 핵심"]
    INFRA["인프라스트럭처 계층\n(Infrastructure Layer)\n─────────────────\n메시지 전송, 영속성, 위젯 렌더링 등\n상위 계층을 지원하는 범용 기술 기능"]

    UI --> APP
    APP --> DOMAIN
    DOMAIN --> INFRA

    style DOMAIN fill:#ffffcc,stroke:#333,stroke-width:2px
계층 책임 특징
사용자 인터페이스 사용자에게 정보를 표시하고, 사용자 명령을 해석 외부 행위자는 다른 컴퓨터 시스템일 수도 있음
애플리케이션 계층 소프트웨어가 해야 할 작업을 정의하고, 도메인 객체에게 문제 해결을 위임 얇게(thin) 유지. 비즈니스 규칙 미포함. 작업 조율 및 위임만 담당. 비즈니스 상황의 상태가 아닌 사용자/프로그램 작업 진행 상태만 가짐
도메인 계층 비즈니스의 개념, 상황 정보, 비즈니스 규칙을 표현 비즈니스 소프트웨어의 핵심. 비즈니스 상황을 반영하는 상태를 제어 및 사용. 저장의 기술적 세부사항은 인프라에 위임
인프라스트럭처 계층 상위 계층을 지원하는 범용 기술 기능 제공 메시지 전송, 도메인 영속성, UI 위젯 렌더링 등. 4개 계층 간 상호작용 패턴(아키텍처 프레임워크) 지원

프로젝트에 따라 UI와 애플리케이션 계층을 엄격히 구분하지 않기도 하고, 인프라 계층을 여러 개로 나누기도 함. 하지만 도메인 계층의 분리야말로 MODEL-DRIVEN DESIGN을 가능하게 하는 핵심임

핵심 처방 (Therefore)

복잡한 프로그램을 계층으로 분할하라. 각 계층 안에서는 하위 계층에만 의존하는 응집력 있는 설계를 개발하라. 상위 계층과의 느슨한 결합을 위해 표준 아키텍처 패턴을 따르라. 도메인 모델과 관련된 코드 전부를 한 계층에 집중시키고 UI, 애플리케이션, 인프라 코드로부터 격리하라.

격리가 가져다주는 것:

  • 도메인 객체는 자신을 표시하거나, 저장하거나, 애플리케이션 작업을 관리하는 책임에서 해방
  • 오직 도메인 모델 표현에만 집중 가능
  • 모델이 핵심 비즈니스 지식을 담기에 충분히 풍부하고 명확하게 진화할 수 있음

계층 격리의 부가 이점:

  • 각 계층의 설계가 훨씬 깔끔해짐
  • 격리된 계층은 유지보수 비용이 낮음 (각기 다른 속도로 발전하고, 다른 필요에 반응하기 때문)
  • 분산 시스템에서의 배포 유연성 향상: 다른 계층을 다른 서버/클라이언트에 배치 가능 → 통신 오버헤드 최소화, 성능 향상

2.1.1 예제: 온라인 뱅킹 기능의 계층 분리 (Example: Partitioning Online Banking Functionality into Layers)

계좌 이체 기능을 통한 계층 분리 예시:

  • 사용자가 두 계좌 번호와 금액을 입력하고 이체를 시작하는 기능

계층별 책임 분리:

sequenceDiagram
    participant UI as transferController (UI 계층)
    participant APP as FundsTransferService (애플리케이션 계층)
    participant D1 as a123: Account (도메인 계층)
    participant D2 as a234: Account (도메인 계층)
    participant INFRA as UnitOfWork / O-R Mapper (인프라 계층)

    UI->>APP: transfer(a123, a234, $100)
    APP->>INFRA: beginTransaction()
    APP->>D1: transferTo(a234, $100)
    D1->>D2: credit($100)
    D2->>INFRA: addToUnitOfWork(a234)
    D1->>D1: debit($100)
    D1->>INFRA: addToUnitOfWork(a123)
    APP->>APP: confirm
    APP->>INFRA: commit()
    Note over APP,INFRA: 입금 또는 출금 실패 시 전체 트랜잭션 실패

TypeScript로 표현한 계층 분리:

// =============================================
// 도메인 계층 (Domain Layer)
// 핵심 비즈니스 규칙: "모든 입금에는 그에 상응하는 출금이 있다"
// =============================================
class Account {
  constructor(
    public readonly id: string,
    private balance: number
  ) {}

  credit(amount: number): void {
    // 도메인 규칙: 입금 처리
    this.balance += amount;
  }

  debit(amount: number): void {
    // 도메인 규칙: 출금 처리 (잔액 부족 시 예외)
    if (this.balance < amount) {
      throw new Error("잔액이 부족합니다.");
    }
    this.balance -= amount;
  }

  transferTo(target: Account, amount: number): void {
    // 도메인 규칙: "모든 입금에는 상응하는 출금이 있다"를 도메인 객체가 보장
    this.debit(amount);
    target.credit(amount);
  }

  getBalance(): number {
    return this.balance;
  }
}

// =============================================
// 애플리케이션 계층 (Application Layer)
// 비즈니스 규칙을 직접 구현하지 않음 - 도메인 객체에 위임
// =============================================
class FundsTransferService {
  constructor(
    private readonly accountRepository: AccountRepository,
    private readonly unitOfWork: UnitOfWork
  ) {}

  async transfer(
    sourceAccountId: string,
    targetAccountId: string,
    amount: number
  ): Promise<void> {
    await this.unitOfWork.beginTransaction();
    try {
      const source = await this.accountRepository.findById(sourceAccountId);
      const target = await this.accountRepository.findById(targetAccountId);

      // 비즈니스 규칙 실행은 도메인 객체(Account)가 담당 - 애플리케이션은 위임만 함
      source.transferTo(target, amount);

      await this.unitOfWork.commit();
    } catch (error) {
      await this.unitOfWork.rollback();
      throw error;
    }
  }
}

// =============================================
// 사용자 인터페이스 계층 (User Interface Layer)
// =============================================
class TransferController {
  constructor(private readonly fundsTransferService: FundsTransferService) {}

  // HTTP 요청이든 XML wire request든 이 계층에서만 처리
  // 하위 계층은 요청의 출처를 알 필요 없음
  async handleTransferRequest(req: {
    sourceAccountId: string;
    targetAccountId: string;
    amount: number;
  }): Promise<{ success: boolean }> {
    await this.fundsTransferService.transfer(
      req.sourceAccountId,
      req.targetAccountId,
      req.amount
    );
    return { success: true };
  }
}

// =============================================
// 인프라스트럭처 계층 (Infrastructure Layer) - 인터페이스 정의
// =============================================
interface AccountRepository {
  findById(id: string): Promise<Account>;
  save(account: Account): Promise<void>;
}

interface UnitOfWork {
  beginTransaction(): Promise<void>;
  commit(): Promise<void>;
  rollback(): Promise<void>;
}

핵심 관찰:

  • **도메인 계층(Account)**이 핵심 비즈니스 규칙 담당: "모든 입금에는 상응하는 출금이 있다"
  • 애플리케이션 계층(FundsTransferService)은 이체 요청의 출처를 가정하지 않음
  • UI를 XML wire request로 교체해도 애플리케이션 계층 이하에는 영향 없음
  • 이 분리는 UI 교체가 자주 일어나서가 아니라, 각 계층의 설계를 이해하고 유지하기 쉽게 만들기 때문에 중요함

저자의 반성적 메모: 위 그림(Figure 4.1)에서도 도메인 격리의 부재 문제가 살짝 드러난다. 요청부터 트랜잭션 제어까지 모두 담으려다 보니 전체 상호작용을 단순하게 유지하기 위해 도메인 레이어를 단순화(dumbed down)할 수밖에 없었다. 도메인 계층 설계에만 집중할 수 있었다면 원장(ledger), 입출금 객체, 금전 트랜잭션 객체 등 더 풍부한 모델을 가질 수 있었을 것이다.


2.1.2 계층 간 관계 (Relating the Layers)

계층 분리 후 계층들을 연결하는 방법 - 분리의 이점을 잃지 않으면서 연결해야 함.

방향성 있는 의존 원칙
  • 계층은 의존성이 한 방향으로만 흐르도록 느슨하게 결합되어야 함
  • 상위 계층 → 하위 계층: 공개 인터페이스 호출, 참조 유지 등 일반적인 방식으로 사용 가능
  • 하위 계층 → 상위 계층: 직접 참조 불가. 콜백(Callback) 또는 옵저버 패턴(Observer) 같은 간접 메커니즘 필요
// 하위 계층(도메인)이 상위 계층(애플리케이션)과 소통하는 방법: 도메인 이벤트 패턴
// 도메인은 이벤트를 발행하고, 상위 계층이 이를 구독 (직접 참조 없음)

// 도메인 이벤트 정의 (도메인 계층에 속함)
interface DomainEvent {
  readonly occurredOn: Date;
}

class FundsTransferred implements DomainEvent {
  readonly occurredOn = new Date();
  constructor(
    public readonly sourceAccountId: string,
    public readonly targetAccountId: string,
    public readonly amount: number
  ) {}
}

// 이벤트 발행자 인터페이스 (도메인 계층)
interface DomainEventPublisher {
  publish(event: DomainEvent): void;
}

// 이벤트 핸들러 (애플리케이션 계층) - 도메인이 이를 직접 참조하지 않음
class FundsTransferredNotificationHandler {
  handle(event: FundsTransferred): void {
    // 알림 전송, 로깅 등 애플리케이션 레벨 처리
    console.log(`이체 완료 알림: ${event.sourceAccountId} → ${event.targetAccountId}, ${event.amount}원`);
  }
}
UI 계층 연결 패턴
  • MVC (Model-View-Controller): UI를 애플리케이션/도메인 계층과 연결하는 원조 패턴 (1970년대 Smalltalk에서 유래, 이후 많은 UI 아키텍처에 영감)
  • Model-View Separation Pattern (Larman): 유사한 관심사를 다루는 접근
  • 어떤 방식을 사용하든 도메인 계층의 격리만 유지되면 모두 허용됨
  • 도메인 객체는 어떤 UI가 자신과 상호작용할지 알 필요가 없음
인프라스트럭처 계층의 특성
  • 일반적으로 도메인 계층에서 먼저 행동을 시작하지 않음 (물리적으로 “아래”에 있기 때문)
  • 자신이 서비스하는 도메인에 대한 구체적인 지식 없음
  • 기술적 역량은 대부분 서비스(SERVICES) 형태로 제공됨
// 인프라스트럭처 계층이 서비스로 제공되는 예
// 애플리케이션 계층은 "언제 보낼지"만 알고, "어떻게 보낼지"는 모름
interface MessageSender {
  send(to: string, message: string): Promise<void>;
}

// 인프라스트럭처 계층 구현체 (애플리케이션 계층은 이 구현을 모름)
class EmailMessageSender implements MessageSender {
  async send(to: string, message: string): Promise<void> {
    // SMTP를 통한 이메일 전송
  }
}

class SmsMessageSender implements MessageSender {
  async send(to: string, message: string): Promise<void> {
    // SMS API를 통한 문자 전송
  }
}

// 애플리케이션 계층 - 구체적 구현 방식에 무관
class NotificationService {
  constructor(private readonly messageSender: MessageSender) {}

  async notifyTransferComplete(userId: string, amount: number): Promise<void> {
    await this.messageSender.send(userId, `${amount}원 이체가 완료되었습니다.`);
  }
}

인프라스트럭처 서비스의 인터페이스 범위가 잘 설정되고 인터페이스가 잘 설계되면, 호출자는 서비스 인터페이스가 캡슐화하는 복잡한 동작에 영향받지 않고 느슨하게 결합된 상태를 유지할 수 있음.


2.1.3 아키텍처 프레임워크 (Architectural Frameworks)

프레임워크의 두 가지 형태

서비스 형태 (직관적):

  • 인터페이스를 통해 호출되는 서비스로 인프라스트럭처 제공
  • 계층화 작동 방식과 느슨한 결합 유지 방법이 비교적 명확

침투적 형태 (주의 필요):

  • 여러 인프라스트럭처 요구를 통합하는 프레임워크
  • 다른 계층이 특정 방식으로 구현되도록 강요 (예: 프레임워크 클래스의 서브클래스, 특정 메서드 시그니처)

참고: 서브클래스가 부모 클래스보다 상위 계층에 있는 것이 직관에 반해 보일 수 있지만, 어느 쪽이 다른 쪽에 대한 지식을 더 많이 반영하는지 생각해보면 이해됨

프레임워크의 두 얼굴

장점:

  • 복잡한 기술적 문제 해결
  • 도메인 개발자가 모델 표현에 집중하도록 지원

단점:

  • 너무 많은 가정으로 도메인 설계 선택을 제한할 수 있음
  • 구현이 너무 무거워 개발 속도 저하 가능
  • 너무 많은 프레임워크 기능을 사용하면 비즈니스 객체의 가독성 저하
프레임워크 적용의 모범 사례
  • 목표에 집중: 도메인 모델을 표현하고 중요한 문제를 해결하는 구현 구축
  • 선택적 적용: 어려운 문제 해결에만 선택적으로 적용. “모든 것에 맞는 하나의 해결책” 지양
  • 최소주의(Minimalism): 가장 가치 있는 프레임워크 기능만 신중하게 적용
    • 구현과 프레임워크 간 결합 감소
    • 향후 설계 결정의 유연성 확보
    • 비즈니스 객체의 가독성과 표현력 유지

J2EE의 역사적 교훈:

  • 초기: 모든 도메인 객체를 “entity bean”으로 구현 → 성능과 개발 속도 모두 저하
  • 현재 모범 사례: 대형 객체에만 J2EE 프레임워크 사용, 대부분의 비즈니스 로직은 일반 Java 객체로 구현

경고: 기술적 솔루션에 대한 열정을 경계하라. 정교한 프레임워크가 오히려 애플리케이션 개발자를 옥죄는 족쇄가 될 수 있다.

미래 전망:

  • 프레임워크는 계속 진화하여 더 많은 기술적 측면을 자동화/프리패브화할 것
  • 제대로 이루어진다면 개발자가 핵심 비즈니스 모델링에 더 집중 가능 → 생산성과 품질 향상
  • 하지만 기술적 솔루션에 대한 열정을 경계해야 함

2.2 도메인 레이어가 모델이 사는 곳 (The Domain Layer Is Where the Model Lives)

핵심 관계 정의

개념 정의
도메인 모델 개념들의 집합(set of concepts)
도메인 계층 도메인 모델의 구현체이자 직접 관련된 모든 설계 요소
비즈니스 로직의 위치 도메인 계층에서의 설계와 구현
MODEL-DRIVEN DESIGN 도메인 계층의 소프트웨어 구조가 모델 개념을 거울처럼 반영

계층형 아키텍처와 DDD의 관계

  • LAYERED ARCHITECTURE는 오늘날 대부분의 시스템에서 다양한 형태로 사용됨
  • 많은 개발 스타일이 계층화로부터 이점을 얻을 수 있음
  • 그러나 DDD에서 반드시 존재해야 하는 계층은 오직 하나: 도메인 계층

핵심 결론

도메인 로직이 프로그램의 다른 관심사와 혼합되면, 모델과의 대응 관계를 달성하는 것은 사실상 불가능하다. 도메인 구현의 격리는 도메인 주도 설계의 전제 조건이다.

도메인 계층 격리 미완 → MODEL-DRIVEN DESIGN 불가능

도메인 계층 격리 완료 → MODEL-DRIVEN DESIGN의 기반 확보

2.3 Smart UI “안티패턴” (The Smart UI “Anti-Pattern”)

맥락과 위치

  • UI, 애플리케이션, 도메인의 분리는 자주 시도되지만 좀처럼 달성되지 않음
  • Smart UI는 그 실패의 흔한 패턴으로, DDD와 상호 배타적인 설계 접근법
  • Smart UI 경로를 선택하면 이 책의 대부분이 적용 불가
  • 저자가 “안티패턴”이라 부르는 것은 DDD 맥락에서만: 특정 상황에서는 오히려 합리적인 선택

Smart UI가 선택될 수 있는 맥락

  • 단순 기능 제공, 데이터 입력/표시 위주, 비즈니스 규칙이 거의 없는 프로젝트
  • 팀이 고급 객체 모델링에 익숙하지 않은 경우

비숙련 팀이 MODEL-DRIVEN DESIGN을 시도할 경우 발생하는 문제

  • 복잡한 신기술 습득 필요
  • 객체 모델링 학습 과정에서의 시행착오 (이 책의 도움을 받아도 어려움)
  • 인프라/계층 관리 오버헤드로 단순 작업도 지연됨
  • 단기 일정과 소박한 기대치를 가진 프로젝트는 완성 전 취소될 가능성 높음
  • 더 많은 시간이 주어져도 전문가 도움 없이는 기법 습득 실패 가능성 높음
  • 결국 달성해도 단순한 시스템: 풍부한 기능은 애초에 요청되지 않았음

Smart UI의 처방 (역설적 권고)

모든 비즈니스 로직을 사용자 인터페이스에 넣어라. 애플리케이션을 작은 기능으로 나누고 각각을 별도의 사용자 인터페이스로 구현하여 비즈니스 규칙을 그 안에 내장하라. 관계형 DB를 데이터 공유 저장소로 사용하라. 가장 자동화된 UI 빌딩과 비주얼 프로그래밍 도구를 사용하라.

// Smart UI 안티패턴의 예 (DDD 맥락에서 지양해야 할 코드)
// 비즈니스 로직이 UI/컨트롤러에 직접 포함됨

import { Request, Response } from "express";
import { Pool } from "pg";

const db = new Pool();

// 문제: 비즈니스 규칙("잔액 부족 시 이체 불가"), DB 접근, UI 처리가 모두 한 곳에
async function handleTransfer(req: Request, res: Response): Promise<void> {
  const { sourceId, targetId, amount } = req.body;

  // 인프라 코드 (DB 접근)
  const sourceResult = await db.query(
    "SELECT * FROM accounts WHERE id = $1",
    [sourceId]
  );
  const source = sourceResult.rows[0];

  // 비즈니스 규칙이 UI 핸들러에 직접 작성됨 (Smart UI의 특징)
  if (source.balance < amount) {
    res.status(400).json({ error: "잔액 부족" });
    return;
  }

  // 인프라 코드와 비즈니스 로직이 혼재
  await db.query(
    "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
    [amount, sourceId]
  );
  await db.query(
    "UPDATE accounts SET balance = balance + $1 WHERE id = $2",
    [amount, targetId]
  );

  res.json({ success: true });
}

// 문제점:
// 1. "잔액 부족 시 이체 불가" 규칙이 이 핸들러에만 있음
// 2. 다른 이체 경로(배치, API 등)에 동일 규칙 중복 작성 필요
// 3. 규칙 변경 시 모든 곳을 찾아서 수정해야 함
// 4. 자동화 테스트가 DB 없이는 불가능

2.3.1 장점 (Advantages)

  • 즉각적인 높은 생산성: 단순 애플리케이션에서 생산성 높고 즉각적
  • 낮은 진입 장벽: 낮은 능력의 개발자도 최소한의 교육으로 작업 가능
  • 빠른 프로토타이핑과 수정: 프로토타입을 사용자에게 공개 후 요청에 따라 빠르게 제품 수정 가능 → 요구사항 분석의 결함도 극복 가능
  • 독립적 모듈 배포: 애플리케이션이 서로 분리되어 소규모 모듈 일정을 비교적 정확하게 계획 가능
  • 단순 기능 확장 용이: 추가적인 단순 행동 확장이 쉬움
  • 관계형 DB 통합: 관계형 DB가 잘 작동하며 데이터 수준에서 통합 제공
  • 4GL 도구 호환: 4세대 언어(4GL) 도구와 잘 맞음
  • 유지보수 편의성: 인수 후 유지보수 프로그래머가 이해 못하는 부분을 빠르게 재작성 가능 (변경 영향이 해당 UI에 국소화되기 때문)

2.3.2 단점 (Disadvantages)

  • 애플리케이션 간 통합 어려움: DB를 통한 방법 외에는 통합이 어려움
  • 행동 재사용 없음, 추상화 없음: 비즈니스 문제의 추상화가 없어 비즈니스 규칙을 적용되는 모든 연산에 중복 작성해야 함
  • 리팩토링 한계: 빠른 프로토타이핑과 반복 개발이 자연스러운 한계에 도달. 추상화 부재로 리팩토링 옵션이 제한됨
  • 복잡성 폭증: 복잡성이 빠르게 누적되며, 성장 경로는 오직 추가적인 단순 애플리케이션뿐. 풍부한 기능으로의 우아한 경로 없음

Smart UI 적용 시 중요한 고려사항

  • 의식적으로 선택했다면 많은 오버헤드를 피할 수 있음
  • 흔한 실수 1: 팀이 끝까지 따르지 못할 정교한 설계 접근법을 선택하는 것
  • 흔한 실수 2: 필요 없는 프로젝트에 복잡한 인프라와 강력한 도구를 구축하는 것
  • 범용 언어(Java 등)는 이런 애플리케이션에 과도하고 비용이 많이 듦 → 4GL 스타일 도구가 적합

중요한 경고:

Smart UI 경로를 선택했다면, 나중에 다른 설계 접근법으로 마이그레이션하는 것은 전체 애플리케이션 교체만큼 어렵다. 범용 언어를 사용한다고 해서 나중에 Smart UI를 버릴 수 있는 위치에 있는 것이 아니다. 유연한 언어가 유연한 시스템을 만들지 않지만, 비용이 많이 드는 시스템을 만들 수는 있다.

MODEL-DRIVEN DESIGN을 선택한 팀을 위한 교훈:

  • 처음부터 그 방식으로 설계해야 함
  • 초기에 단순한 기능부터 시작하여 반복적으로 발전시키는 것은 가능하지만
  • 첫 발걸음부터 도메인 계층이 격리된 MODEL-DRIVEN 방식이어야 함
  • 그렇지 않으면 프로젝트는 Smart UI에 갇힐 가능성이 높음

Smart UI vs Layered Architecture 비교

기준 Smart UI Layered Architecture
초기 생산성 높음 낮음 (학습 곡선)
팀 숙련도 요구 낮음 높음
비즈니스 규칙 관리 중복, 분산 단일 도메인 계층에 응집
복잡한 도메인 처리 한계 명확 적합
리팩토링 가능성 낮음 높음
테스트 용이성 어려움 용이
애플리케이션 간 통합 DB 수준만 서비스, API 수준 가능
전환 가능성 전체 교체 필요 점진적 개선 가능
적합한 프로젝트 단순, 단기, 소규모 복잡, 장기, 야심찬

2.4 기타 격리 종류 (Other Kinds of Isolation)

도메인 모델을 오염시키는 다른 요인들

인프라스트럭처와 UI 외에도 섬세한 도메인 모델을 오염시킬 수 있는 요인들:

  • 완전히 통합되지 않은 다른 도메인 컴포넌트와의 상호작용
  • 같은 도메인의 다른 모델을 사용하는 다른 개발 팀과의 협업
  • 이런 요인들이 모델을 흐릿하게 만들고 유용성을 앗아갈 수 있음

추가 격리 패턴 예고

챕터 주제 핵심 패턴
Chapter 14 모델 무결성 유지 (Maintaining Model Integrity) BOUNDED CONTEXT, ANTICORRUPTION LAYER
Chapter 15 증류 (Distillation) 도메인 계층 내에서 핵심 개념을 주변 세부 사항으로부터 분리

이 챕터의 최종 결론

아키텍처가 도메인 관련 코드를 격리하여 시스템의 나머지 부분과 느슨하게 결합된 응집력 있는 도메인 설계를 가능하게 한다면, 그 아키텍처는 도메인 주도 설계를 지원할 수 있다.

중간 지점의 존재:

  • Smart UI와 Layered Architecture 사이의 중간 해법도 존재
  • 예: Transaction Script (Fowler 2002) - UI와 애플리케이션은 분리하나 객체 모델 없음

복잡한 애플리케이션에 대한 강력한 권고:

  • 도메인 설계를 분리하지 않으면 재앙적인 결과 초래 가능
  • MODEL-DRIVEN DESIGN을 선택했다면: 처음부터 전문가의 도움을 받고, Smart UI를 피하라

핵심 요약 (이 챕터 전체)

flowchart TD
    Q1{"프로젝트가 복잡하고\n야심차며 팀에 충분한\n기술이 있는가?"}
    
    Q1 -->|Yes| LA["LAYERED ARCHITECTURE 채택\n도메인 계층 격리"]
    Q1 -->|No| SUI["Smart UI 채택\n(의식적 선택)"]
    
    LA --> D["도메인 계층에\n모델 집중"]
    D --> MDD["MODEL-DRIVEN DESIGN\n가능"]
    
    SUI --> LIM["단순함 유지\n복잡성 한계 수용"]
    LIM --> WARN["경고: 나중에 전환하려면\n전체 재작성 필요"]

    style MDD fill:#ccffcc,stroke:#333
    style WARN fill:#ffcccc,stroke:#333

기억해야 할 핵심 3가지:

  1. 도메인 격리는 DDD의 전제 조건: 도메인 레이어가 격리되지 않으면 MODEL-DRIVEN DESIGN은 불가능
  2. 계층형 아키텍처의 진정한 가치: 도메인 계층을 격리하여 모델이 비즈니스 지식을 풍부하게 담을 수 있도록 하는 것
  3. Smart UI는 합법적 대안이지만 출구가 없음: 단순한 프로젝트에서는 합리적이지만, 시작부터 의식적으로 선택해야 하며 복잡한 프로젝트에는 피해야 함

Domain-Driven Design 스터디 노트

5. 소프트웨어에서 표현되는 모델

챕터 개요

MODEL-DRIVEN DESIGN의 구현에서 타협하면서도 모델의 핵심을 유지하려면 기본기를 재정립해야 함. 모델과 구현의 연결은 세부 레벨에서 이루어져야 한다.

이 챕터에서 다루는 핵심 구성요소:

  • 연관 관계(Associations): 객체 간 관계를 단순화하고 제약하는 방법
  • 엔티티(Entities): 연속성과 정체성으로 정의되는 객체
  • 값 객체(Value Objects): 속성으로 정의되며 정체성이 없는 객체
  • 서비스(Services): 객체에 속하지 않는 도메인 연산
  • 모듈(Modules): 도메인 개념을 반영하는 코드 패키지
  • 모델링 패러다임(Modeling Paradigms): 객체 패러다임의 지배와 혼합 전략

5.1. 연관 관계

모델에서 탐색 가능한 모든 연관 관계에 대해, 소프트웨어에는 동일한 속성을 가진 메커니즘이 존재한다.

핵심 문제: 연관 관계는 개념화하고 다이어그램으로 표현하기는 쉽지만, 구현하면 복잡해지는 함정이 있다.

연관 관계 구현의 두 가지 형태:

  • 인스턴스 변수로서의 컬렉션 (메모리 내 객체 포인터)
  • 데이터베이스 조회를 통해 객체를 생성하는 접근자 메서드

두 형태 모두 동일한 모델을 반영할 수 있다. 중요한 것은 구현 메커니즘의 동작이 모델의 연관 관계와 일관성을 유지하는 것이다.

양방향 연관 관계의 문제점:

  • 현실 세계에는 다대다 양방향 연관 관계가 많고, 초기 모델도 그러한 경향이 있음
  • 이런 일반적인 연관 관계는 구현과 유지보수를 복잡하게 만듦
  • 관계의 본질에 대해 거의 정보를 전달하지 못함

연관 관계를 다루기 쉽게 만드는 세 가지 방법:

  1. 탐색 방향 강제 (Imposing a traversal direction): 단방향으로 제한
  2. 한정자 추가로 다중성 감소 (Adding a qualifier): 관계를 더 구체화
  3. 비필수 연관 관계 제거 (Eliminating nonessential associations): 불필요한 관계 삭제

탐색 방향 결정 원칙:

  • 양방향 연관 관계는 두 객체가 서로 없이는 이해될 수 없음을 의미
  • 애플리케이션 요구사항이 양방향 탐색을 필요로 하지 않는다면, 탐색 방향을 단방향으로 제한하면 상호 의존성을 줄이고 설계를 단순화함
  • 도메인 이해를 통해 자연스러운 방향 편향을 발견할 수 있다

예시: 국가(Country)와 대통령(President) 관계

  • “조지 워싱턴이 어느 나라의 대통령이었나?“보다 “미국의 대통령은 누구였나?“라는 질문이 더 자연스러움
  • 따라서 Country → Person(president) 단방향으로 제한하는 것이 도메인을 더 잘 반영
  • “Person” 클래스가 “President”라는 덜 근본적인 개념과 독립적으로 유지됨

한정자(Qualifier)로 다중성 감소:

  • 더 깊은 이해를 통해 “한정된(qualified)” 관계를 발견할 수 있음
  • 예: “특정 기간(period)에 국가는 한 명의 대통령만 가짐” → 일대다가 일대일로 변환
  • 한정자를 추가하면 중요한 비즈니스 규칙이 모델에 명시적으로 포함됨
  • 다대다 연관 관계의 탐색 방향을 제한하면 구현이 일대다로 감소됨 (훨씬 쉬운 설계)

연관 관계 제약의 효과:

  • 도메인 편향을 반영하여 일관되게 제약하면 나머지 양방향 연관 관계의 의미가 더 명확해짐
  • 나머지 양방향 연관 관계는 도메인에서 진정으로 양방향이 의미 있는 경우에만 유지됨
  • 연관 관계가 필수적이지 않다면 아예 제거하는 것이 궁극적 단순화
5.1.1. 예시: 브로커리지 계좌의 연관 관계

모델 구조:

  • BrokerageAccountCustomer와 연관 (하나의 계좌에 하나의 고객)
  • BrokerageAccountInvestment 컬렉션과 연관 (하나의 계좌에 여러 투자)

구현 방법 1: 메모리 내 객체 참조

class BrokerageAccount {
  private accountNumber: string;
  private customer: Customer;
  private investments: Set<Investment>;

  // 생성자 생략

  getCustomer(): Customer {
    return this.customer;
  }

  getInvestments(): Set<Investment> {
    return this.investments;
  }
}

구현 방법 2: 관계형 DB 조회 (동일한 모델을 다르게 반영)

테이블: BROKERAGE_ACCOUNT (ACCOUNT_NUMBER, CUSTOMER_SS_NUMBER)
테이블: CUSTOMER (SS_NUMBER, NAME)
테이블: INVESTMENT (ACCOUNT_NUMBER, STOCK_SYMBOL, AMOUNT)
class BrokerageAccount {
  private accountNumber: string;
  private customerSocialSecurityNumber: string;

  // 생성자 생략

  async getCustomer(): Promise<Customer> {
    const sqlQuery = `SELECT * FROM CUSTOMER WHERE SS_NUMBER='${this.customerSocialSecurityNumber}'`;
    return QueryService.findSingleCustomerFor(sqlQuery);
  }

  async getInvestments(): Promise<Set<Investment>> {
    const sqlQuery = `SELECT * FROM INVESTMENT WHERE BROKERAGE_ACCOUNT='${this.accountNumber}'`;
    return QueryService.findInvestmentsFor(sqlQuery);
  }
}

QueryService는 설명을 위한 단순화된 예시이며, 실제 프로젝트에서는 좋은 설계가 아닐 수 있다.

한정자 추가: “주식당 하나의 투자만 가능” 규칙 추가 후 모델 개선

// Map을 사용하여 주식 심볼로 투자를 직접 조회 (메모리 내)
class BrokerageAccount {
  private accountNumber: string;
  private customer: Customer;
  private investments: Map<string, Investment>; // key: stockSymbol

  getCustomer(): Customer {
    return this.customer;
  }

  getInvestment(stockSymbol: string): Investment | undefined {
    return this.investments.get(stockSymbol);
  }
}
// DB 조회 버전 (동일한 모델 반영)
class BrokerageAccount {
  private accountNumber: string;
  private customerSocialSecurityNumber: string;

  async getCustomer(): Promise<Customer> {
    const sqlQuery = `SELECT * FROM CUSTOMER WHERE SS_NUMBER='${this.customerSocialSecurityNumber}'`;
    return QueryService.findSingleCustomerFor(sqlQuery);
  }

  async getInvestment(stockSymbol: string): Promise<Investment> {
    const sqlQuery = `
      SELECT * FROM INVESTMENT
      WHERE BROKERAGE_ACCOUNT = '${this.accountNumber}'
      AND STOCK_SYMBOL = '${stockSymbol}'
    `;
    return QueryService.findInvestmentFor(sqlQuery);
  }
}

핵심 인사이트: 연관 관계의 제약 조건이 발견될 때마다 모델과 구현 모두에 포함시켜야 한다. 이는 모델을 더 정확하게 만들고 구현 유지보수를 쉽게 한다.


5.2. 엔티티 (참조 객체)

많은 객체는 근본적으로 속성이 아니라 연속성의 실마리와 정체성으로 정의된다.

도메인 이야기로 이해하기: 동명이인이 잘못 고소를 당한 사례에서, 이름과 주소 같은 속성이 같더라도 다른 사람임을 증명해야 했다. 소프트웨어에서 신원 혼동은 데이터 손상과 프로그램 오류로 이어진다.

엔티티의 본질:

  • 많은 것들은 속성이 아닌 정체성으로 정의됨
  • 사람의 예: 물리적 속성이 변하고, 이름이 바뀌고, 재정적 관계가 변해도 정체성은 지속됨
  • “변하지 않는 단 하나의 속성도 없지만, 정체성은 지속된다”
  • 소프트웨어 사용자가 이 정체성의 연속성을 필요로 하는가가 핵심 질문

엔티티의 정의:

객체가 라이프사이클을 통해 연속성의 실마리를 유지하고, 속성과 무관한 중요한 구분을 가질 때, 그 객체를 엔티티(ENTITY) 라고 한다. 주로 속성이 아니라 누구(who)인가에 따라 클래스 정의, 책임, 속성, 연관 관계가 구성된다.

주의: Java Entity Bean과의 혼동 금지: 모델 엔티티와 Java entity bean은 다르다. 대부분의 엔티티는 평범한 객체로 구현된다.

엔티티의 예: 사람, 도시, 자동차, 복권 티켓, 은행 거래

객체지향 언어 동일성 연산의 한계:

  • TypeScript/Java의 ===, == 연산자는 메모리 주소 기반 동일성을 확인
  • 이 메모리 기반 동일성은 도메인 의미의 정체성과 다름
  • DB에서 가져올 때마다 새 인스턴스 생성 → 이 동일성은 소실됨
  • 정체성은 언어의 자동 기능에 맡길 수 없는 미묘하고 의미 있는 속성

은행 거래 예시로 구분 이해:

  • 같은 날, 같은 금액, 같은 계좌에 두 번의 입금 → 여전히 별개의 거래 = 엔티티
  • 그 거래들의 금액 속성 → 정체성이 없는 Money 객체 = 값 객체
  • 수표 번호: 동일한 정체성을 가진 거래를 매칭하기 위한 고유 식별자

좌석 예약 시스템 예시로 맥락 이해:

상황 좌석의 성격 이유
지정 좌석제 엔티티 좌석 번호가 식별자, 추적 필요
자유석 입장제 엔티티 아님 좌석 구별 불필요, 총 수만 중요

엔티티 설계 원칙:

객체가 속성이 아닌 정체성으로 구별된다면, 모델 정의에서 이를 주요 기준으로 삼아라. 클래스 정의를 단순하게 유지하고 라이프사이클의 연속성과 정체성에 집중하라. 각 객체를 형태나 이력에 관계없이 구별할 수 있는 수단을 정의하라. 모델은 동일한 것이 무엇을 의미하는지 정의해야 한다.

5.2.1. 엔티티 모델링

핵심 원칙: 엔티티를 모델링할 때 속성이나 행동에 집중하지 말고, 객체를 가장 본질적인 특성으로 축소하라.

엔티티 모델링 전략:

  • 식별하거나 매칭하는 데 사용되는 가장 본질적인 특성으로 정의를 축소
  • 개념에 필수적인 행동만 추가하고, 그 행동에 필요한 속성만 포함
  • 나머지 행동과 속성은 핵심 엔티티와 연관된 다른 객체로 이동시킴
    • 일부는 다른 엔티티가 됨
    • 일부는 값 객체가 됨 (다음 절에서 설명)
  • 엔티티는 정체성 이슈를 넘어, 자신이 소유한 객체들의 연산을 조율함으로써 책임을 이행

Customer 엔티티 정제 예시:

속성 초기 위치 정제 후 위치 이유
customerID Customer Customer 유일한 식별자
name Customer Customer 고객 매칭에 사용
contact phone Sales Contact Customer 고객 찾는 데 사용
contact address Sales Contact Customer 고객 찾는 데 사용
average sales volume Customer BusinessLine 식별과 무관
product category Customer BusinessLine 식별과 무관

핵심: 식별과 관련된 속성은 엔티티에 남기고, 나머지는 관련 객체로 분리한다.

5.2.2. 식별 연산 설계

원칙: 각 엔티티는 동일한 설명 속성을 가진 다른 객체와도 구별될 수 있는 식별 방법을 가져야 한다. 식별 속성은 시스템 내에서 고유성이 보장되어야 한다 (분산 환경, 아카이빙 상황 포함).

메모리 기반 동일성의 한계:

  • DB에서 가져올 때마다 새 인스턴스 생성 → 초기 동일성 상실
  • 네트워크로 전송 시 새 인스턴스 생성 → 동일성 상실
  • 분산 DB에서 여러 버전이 동시에 존재하면 더 복잡해짐

식별자 설계 전략:

  1. 자연 키 (Natural Key): 속성이나 속성 조합으로 고유성을 보장하는 경우
    • 예: “신문 이름 + 도시 + 발행 날짜” (단, 특별판과 이름 변경 주의 필요)
  2. 생성된 식별자 (Generated ID): 각 인스턴스에 고유 심볼(숫자나 문자열) 부여
    • 생성 후 불변(immutable)으로 지정 → 절대 변경하지 않음
    • DB에 저장되고 재구성될 때도 보존
    • 동시 처리와 분산 시스템에서 고유성 보장이 과제
  3. 외부 발급 식별자 (External ID): 다른 기관이 발급하는 식별자 사용
    • 예: 주민등록번호, 여권 번호, 병원 간 환자 ID 교환
    • 단점: 모든 사람이 가지지 않을 수 있음, 프라이버시 문제
    • 전화번호 같은 것은 공유되거나 변경되거나 재배정될 수 있어 주의

식별자 가시성:

  • 자동 생성된 ID는 사용자에게 보이지 않아도 됨 (내부 식별용): 연락처 관리 앱에서 동명이인 구별
  • 사용자에게 노출되는 경우: 택배 운송장 번호, 항공권 예약 번호

핵심 통찰:

ID를 붙이거나 인스턴스를 비교하는 연산을 작성하기는 쉽지만, 이것이 도메인의 의미 있는 구분에 대응하지 않으면 혼란만 가중된다. 정체성 정의는 도메인에 대한 이해를 요구한다.

정체성 할당 연산은 종종 사람의 입력을 포함한다 (예: 수표장 대사 소프트웨어에서 사용자가 최종 판단).


5.3. 값 객체

많은 객체는 개념적 정체성이 없다. 이 객체들은 사물의 특성을 기술한다.

마커 비유로 이해하기:

  • 아이가 같은 색상과 모양의 마커 두 개가 있다면 어느 것을 쓰든 상관없음
  • 마커를 잃어버리고 같은 색의 새 마커로 교체해도 개의치 않음
  • 하지만 완성된 그림(아이 작품)은 누구 작품인지 정체성이 있음

모든 객체에 정체성을 부여하는 것의 문제점:

  • 시스템이 모든 추적을 처리해야 함 → 성능 최적화 기회 차단
  • 의미 있는 정체성 정의와 분산 시스템/DB 추적 방법 마련에 분석 노력 낭비
  • 인위적인 정체성은 모델을 혼탁하게 만들고 모든 객체를 같은 틀로 강제함

엔티티의 정체성 추적은 필수적이지만, 다른 객체에 정체성을 부여하면 시스템 성능을 해치고, 분석 작업을 추가하며, 모든 객체가 동일하게 보이도록 만들어 모델을 흐리게 한다.

값 객체의 정의:

개념적 정체성 없이 도메인의 기술적 측면을 나타내는 객체를 값 객체(VALUE OBJECT) 라고 한다. 값 객체는 무엇인지(what they are)에만 관심 있고, 누구인지 또는 어느 것인지(who or which)에는 관심이 없다.

“Address”가 값 객체인가 엔티티인가? - 맥락에 따라 다르다

시스템 Address의 성격 이유
통신판매 회사 소프트웨어 값 객체 룸메이트가 같은 주소에서 주문해도 위치 동일성 불필요
우편 배달 경로 최적화 엔티티 우편 구역 재편 시 해당 주소들이 함께 변경됨
전기 유틸리티 회사 엔티티 or “거주지” 엔티티의 속성(값 객체) 서비스 목적지로서 동일성 필요

값 객체의 다양한 예:

  • 기본 타입: 색상, 문자열, 숫자 (어느 “4”가 있는지 신경 쓰지 않음)
  • 복합 객체: 색상 혼합 프로그램의 향상된 색상 객체 (다른 색상과 결합해 새 값 반환)
  • 집합체: 주택 설계 소프트웨어의 “윈도우 스타일” + “높이와 너비”로 이루어진 “윈도우 객체”
  • 엔티티 참조 가능: 온라인 지도 서비스에서 도로(엔티티)들을 참조하는 경로(값 객체)

값 객체의 특성:

  • 메시지 파라미터로 자주 전달됨
  • 일시적인 경향: 연산을 위해 생성되고 버려짐
  • 엔티티의 속성으로 사용됨 (예: 사람(엔티티)의 이름(값 객체))

모델의 요소를 속성만으로 관심을 가진다면 값 객체로 분류하라. 전달하는 속성의 의미를 표현하고 관련 기능을 부여하라. 값 객체를 불변(immutable)으로 취급하라. 정체성을 부여하지 말고 엔티티를 유지하기 위한 설계 복잡성을 피하라.

값 객체는 개념적 전체를 형성해야 함:

street, city, postalCodePerson 객체의 별도 속성이 아니다. 단일의 완전한 Address로 표현해야 한다.

// 안 좋은 예: 속성이 분산됨
class Person {
  name: string;
  street: string;
  city: string;
  postalCode: string;
}

// 좋은 예: 개념적으로 완전한 값 객체로 분리
class Address {
  readonly street: string;
  readonly city: string;
  readonly postalCode: string;

  constructor(street: string, city: string, postalCode: string) {
    this.street = street;
    this.city = city;
    this.postalCode = postalCode;
  }
}

class Person {
  readonly name: string;
  readonly address: Address; // 완전한 주소 값 객체
}
5.3.1. 값 객체 설계

핵심: 값 객체의 어느 인스턴스를 가지는지 신경 쓰지 않음 → 복사, 공유, 불변성에 대한 설계 자유도 증가

이름(Name) 객체 예시:

  • 두 사람이 같은 이름을 가져도 같은 사람이 아님 (사람 = 엔티티)
  • 그러나 이름 객체 자체는 교환 가능 → 이름의 철자만 중요
  • Name 객체를 첫 번째 Person에서 두 번째 Person으로 복사 가능
  • 심지어 두 Person이 같은 Name 인스턴스를 공유할 수도 있음 (포인터 공유)

공유의 위험성과 불변성 해결책:

  • 공유된 Name 객체의 이름을 변경하면 다른 Person의 이름도 변경됨
  • 방어 방법: 불변(immutable) 으로 만들기 → 전체 교체만 가능
// 불변 값 객체 구현 예
class Money {
  readonly amount: number;
  readonly currency: string;

  constructor(amount: number, currency: string) {
    this.amount = amount;
    this.currency = currency;
  }

  // 값을 "변경"하는 대신 새 객체 반환
  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error('Currency mismatch');
    }
    return new Money(this.amount + other.amount, this.currency);
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

// 사용 예
const price = new Money(100, 'USD');
const tax = new Money(10, 'USD');
const total = price.add(tax); // 새로운 Money 객체 반환, 기존 객체 변경 없음

파라미터로 전달 시의 문제:

  • 객체가 소유자의 통제를 벗어나면 어떤 변경이 일어날지 모름
  • 소유자의 불변성을 위반하는 방식으로 값이 변경될 수 있음
  • 해결책: 전달 객체를 불변으로 만들거나, 복사본을 전달

값 객체가 많을 때의 최적화 - 플라이웨이트(Flyweight) 패턴:

  • 주택 설계 소프트웨어에서 전기 콘센트가 수백 개 → 값 객체로 처리
  • 모든 콘센트가 교환 가능하다면 하나의 인스턴스만 공유 (플라이웨이트)
  • 대형 시스템에서는 수천 배로 증폭 → 시스템 사용성에 결정적 영향

복사 vs 공유 결정 기준:

상황 선호 방식
DB 공간/객체 수 절약이 중요 공유
통신 오버헤드가 낮음 (중앙화된 서버) 공유
공유 객체가 엄격히 불변 공유
분산 시스템 복사 (단일 메시지로 전달, 독립적으로 존재)
5.3.2. 특수 케이스: 가변성을 허용할 때

불변성의 이점: 변경 관리를 단순하게 함 (전체 교체만 존재), 자유로운 공유 가능

그럼에도 성능을 이유로 가변 구현을 허용하는 경우:

  • 값이 자주 변경될 때
  • 객체 생성이나 삭제 비용이 클 때
  • 교체(수정 대신)가 클러스터링을 방해할 때
  • 값의 공유가 많지 않을 때

핵심 규칙: 가변 구현이라면 절대 공유하지 말 것. 가능하면 값 객체를 불변으로 설계하라.

설계 원칙:

  • 불필요한 제약을 모델에 추가하지 않으면 개발자가 순수하게 기술적인 성능 튜닝을 할 수 있음
  • 필수 제약을 명시적으로 정의하면 의미 있는 동작을 변경하지 않으면서 설계를 조정할 수 있음
5.3.3. 예시: 값 객체로 데이터베이스 튜닝

비정규화(Denormalization) 전략:

  • 관계형 DB는 물리적 디스크 위치에 데이터를 저장하고, 물리적 이동 시간이 발생
  • 정교한 DB는 관련 데이터를 클러스터링하여 단일 물리적 연산으로 가져오려 함

문제: 객체가 여러 다른 객체에 의해 참조될 때, 일부는 같은 페이지에 없어 추가 물리적 연산 필요

해결 (값 객체이기 때문에 가능):

  • 공유 참조 대신 복사본을 각 엔티티가 사용하는 페이지에 함께 저장 (비정규화)
  • 동일한 데이터의 여러 복사본 저장 = 비정규화 = 접근 시간 우선, 저장 공간 트레이드오프

분산 시스템 적용:

  • 다른 서버의 값 객체 참조 → 느린 응답 (메시지 왕복 필요)
  • 값 객체의 전체 복사본을 다른 서버로 전달 → 빠른 로컬 접근
  • 이것이 가능한 이유: 값 객체는 자유롭게 복사할 수 있기 때문
5.3.4. 값 객체를 포함한 연관 관계 설계

일반 원칙: 모델의 연관 관계를 가능한 한 적고 단순하게 유지 (엔티티/값 객체 모두 동일하게 적용)

값 객체 간 양방향 연관 관계의 무의미함:

  • 정체성 없이는 “이 값 객체가 나를 가리키는 바로 그 값 객체를 다시 가리킨다”는 말이 무의미
  • 가장 말할 수 있는 것: 동등한(equal) 객체를 가리킨다는 것
  • 이 불변성을 어딘가에서 강제해야 함 → 복잡성 증가

값 객체 간의 양방향 연관 관계를 완전히 제거하라. 만약 이러한 연관 관계가 필요하다면, 값 객체 선언을 재고하라. 아직 명시적으로 인식되지 않은 정체성이 있을 수 있다.


5.4. 서비스

때로는 그냥 사물이 아닌 경우가 있다.

문제 상황:

  • 도메인에는 자연스럽게 어느 객체에도 속하지 않는 중요한 연산들이 존재
  • 이런 연산들은 본질적으로 활동이나 행위이지 사물이 아님
  • 객체 패러다임에 억지로 끼워맞추면 문제 발생

흔한 실수 두 가지:

  • 객체에 행동을 끼워 맞추는 것을 너무 쉽게 포기 → 절차적 프로그래밍으로 퇴행
  • 적합하지 않은 객체에 연산을 강제 → 객체의 개념적 명확성 상실, 이해/리팩토링 어렵게 됨

“매니저” 객체 안티패턴:

  • 서비스가 “모델 객체인 척” 하는 경우
  • 이름이 Manager, Handler, Processor 등으로 끝남
  • 자체 상태 없음, 도메인에서 의미 없음
  • 그래도 이 해결책은 실제 모델 객체를 망치지 않고 별개의 행동에 집을 부여한다는 점에서 최악은 아님

서비스의 정의:

모델에서 독립적으로 존재하는 인터페이스로 제공되는 연산으로, 엔티티나 값 객체처럼 상태를 캡슐화하지 않는 것을 서비스(SERVICE) 라고 한다. 서비스는 기술 프레임워크에서 흔한 패턴이지만 도메인 레이어에도 적용될 수 있다.

서비스는 이름이 활동을 강조:

  • 클라이언트를 위해 무엇을 할 수 있는지로 순수하게 정의됨
  • 명사보다는 동사로 이름을 지음 (예: transferFunds, encryptPassword)
  • 파라미터와 결과는 도메인 객체여야 함
  • 연산 이름은 유비쿼터스 언어에서 나오거나 도입되어야 함

좋은 서비스의 세 가지 특성:

  1. 연산이 엔티티나 값 객체의 자연스러운 부분이 아닌 도메인 개념과 관련됨
  2. 인터페이스가 도메인 모델의 다른 요소들로 정의됨
  3. 연산이 무상태(stateless)

무상태의 의미:

  • 어느 클라이언트도 인스턴스의 개별 이력에 상관없이 특정 서비스의 모든 인스턴스를 사용할 수 있음
  • 서비스 실행은 전역 정보를 사용하거나 변경할 수 있음 (부작용 가능)
  • 그러나 서비스 자체는 자신의 행동에 영향을 미치는 자체 상태를 보유하지 않음

서비스 설계 원칙:

도메인에서 중요한 프로세스나 변환이 엔티티나 값 객체의 자연스러운 책임이 아닐 때, 서비스로 선언된 독립 인터페이스로 모델에 연산을 추가하라. 모델의 언어로 인터페이스를 정의하고 연산 이름이 유비쿼터스 언어의 일부가 되게 하라. 서비스를 무상태로 만들어라.

5.4.1. 서비스와 격리된 도메인 레이어

서비스 분류 및 레이어 귀속:

레이어 서비스 예시 설명
인프라 레이어 이메일 발송 서비스 이메일 시스템을 캡슐화, 비즈니스 의미 없음
도메인 레이어 자금 이체 서비스 비즈니스 규칙 포함, 뱅킹 언어로 의미 있음
애플리케이션 레이어 거래 스프레드시트 내보내기 파일 형식은 뱅킹 도메인에 의미 없음

핵심 구분 원칙:

  • 기술 서비스는 비즈니스 의미가 전혀 없어야 함
  • 도메인 서비스는 비즈니스 로직을 포함하고 도메인 언어로 의미 있음
  • 잔액 임계값 확인은 “account” 객체의 책임 → 서비스 불필요
  • 자금 이체는 두 계좌와 전역 규칙을 포함 → Account 객체에 넣기 어색 → 도메인 서비스 적합
5.4.2. 서비스를 레이어로 분리

자금 이체 예시로 보는 레이어별 서비스 분리:

레이어 서비스 이름 역할
애플리케이션 Funds Transfer App Service XML 등 입력 파싱, 도메인 서비스에 처리 위임, 확인 수신, 인프라 서비스로 알림 지시
도메인 Funds Transfer Domain Service Account/Ledger 객체와 상호작용, 적절한 차/대변 처리, 결과 확인 제공
인프라 Send Notification Service 애플리케이션의 지시에 따라 이메일/편지 등 발송

TypeScript 구현 예시:

// 도메인 서비스: 비즈니스 규칙에 집중
class FundsTransferDomainService {
  async transfer(
    sourceAccount: Account,
    destinationAccount: Account,
    amount: Money
  ): Promise<TransferResult> {
    if (!sourceAccount.hasEnoughFunds(amount)) {
      return TransferResult.insufficientFunds();
    }

    sourceAccount.debit(amount);
    destinationAccount.credit(amount);

    return TransferResult.success();
  }
}

// 애플리케이션 서비스: 흐름 조율에 집중
class FundsTransferAppService {
  constructor(
    private readonly domainService: FundsTransferDomainService,
    private readonly notificationService: NotificationService,
    private readonly accountRepository: AccountRepository
  ) {}

  async executeTransfer(request: TransferRequest): Promise<void> {
    const source = await this.accountRepository.findById(request.sourceAccountId);
    const destination = await this.accountRepository.findById(request.destinationAccountId);
    const amount = new Money(request.amount, request.currency);

    const result = await this.domainService.transfer(source, destination, amount);

    if (result.isSuccess()) {
      await this.notificationService.sendTransferConfirmation(request.userId, result);
    }
  }
}
5.4.3. 세분성

서비스의 또 다른 가치: 도메인 레이어 인터페이스의 세분성(granularity) 제어, 클라이언트를 엔티티/값 객체에서 분리

세분성과 관련된 트레이드오프:

  • 중간 세분성(medium-grained) 무상태 서비스: 단순한 인터페이스 뒤에 중요한 기능을 캡슐화 → 재사용 용이
  • 세밀한 세분성(fine-grained) 객체: 분산 시스템에서 비효율적인 메시징 유발
  • 세밀한 도메인 객체 간 복잡한 상호작용: 도메인 지식이 애플리케이션 레이어로 누출될 위험

지식 누출(knowledge leak) 방지:

  • 복잡한 세밀한 상호작용이 애플리케이션 레이어에서 처리되면 도메인 지식이 UI/애플리케이션 코드로 스며들어 도메인 레이어에서 손실됨
  • 적절한 도메인 서비스 도입이 레이어 간의 명확한 경계를 유지하는 데 도움
5.4.4. 서비스 접근

접근 방법:

  • J2EE, CORBA 같은 분산 시스템 아키텍처는 서비스를 위한 특별한 게시 메커니즘 제공
  • 그러나 이런 프레임워크가 항상 필요한 것은 아님 → 논리적 관심사 분리만이 목적이라면 과잉

핵심 원칙: 서비스 접근 수단보다 특정 책임을 분리하는 설계 결정이 더 중요하다.

간단한 접근 방법들:

  • “doer” 객체가 서비스 인터페이스의 구현으로 충분할 수 있음
  • 간단한 싱글톤으로 접근 제공
  • 코딩 관례로 이 객체들이 서비스 인터페이스의 전달 메커니즘임을 명확히 함

정교한 아키텍처는 시스템을 분산시키거나 프레임워크 기능을 활용해야 할 실제 필요가 있을 때만 사용하라.


5.5. 모듈 (패키지)

모듈은 오래되고 확립된 설계 요소다. 모듈화의 주된 동기는 인지적 과부하이다.

모듈의 두 가지 시각 제공:

  • 전체에 압도되지 않고 모듈 내부의 세부사항을 볼 수 있음
  • 내부 세부사항을 제외한 모듈 간의 관계를 볼 수 있음

모듈에 대한 일반적인 오해:

  • 모두가 모듈을 사용하지만 모델의 완전한 부분으로 취급하는 사람은 거의 없음
  • 코드는 기술 아키텍처나 개발자 작업 할당에 따라 분류됨
  • 많이 리팩토링하는 개발자도 프로젝트 초기에 구상한 모듈에 만족하는 경향

응집도와 결합도의 새로운 이해:

  • 낮은 결합도와 높은 응집도는 기술적 메트릭으로만 이해되는 경향이 있음
  • 그러나 분류되는 것은 코드만이 아니라 개념
  • 한 번에 생각할 수 있는 것의 한계 → 낮은 결합도 필요
  • 비일관된 아이디어 조각들 → 높은 응집도 필요

모듈의 의사소통 역할:

  • 모든 DDD 요소와 마찬가지로 모듈은 의사소통 메커니즘
  • 클래스들을 모듈에 묶으면 다음 개발자에게 “함께 생각하라”고 말하는 것
  • 모델이 이야기라면 모듈은 챕터
  • 모듈 이름은 유비쿼터스 언어에 들어감

모듈 설계 원칙:

시스템의 이야기를 전달하고 응집된 개념들의 집합을 포함하는 모듈을 선택하라. 이는 종종 모듈 간 낮은 결합도를 제공하지만, 그렇지 않다면 개념들을 분리할 모델 변경 방법을 찾거나, 간과된 개념을 찾아라. 유비쿼터스 언어의 일부가 되는 이름을 모듈에 부여하라.

모델-중심 접근의 중요성:

  • 모듈 간 참조가 많아지거나 모듈 변경 시 파급 효과가 생기더라도 개념적 명확성을 우선시하는 것이 좋음
  • 개발자들은 모델이 전달하는 이야기를 이해한다면 이런 문제를 처리할 수 있음
5.5.1. 애자일 모듈

문제: 모듈은 모델의 나머지 부분과 함께 진화해야 하지만, 실제로는 그렇지 않다.

모듈 리팩토링의 어려움:

  • 모듈 변경은 코드 전체적인 업데이트 필요
  • 팀 의사소통에 혼란, 형상 관리 도구에도 문제 유발
  • 결과: 모듈 구조와 이름이 클래스보다 훨씬 초기 형태의 모델을 반영

악순환:

초기 모듈 선택 실수
  → 높은 결합도 발생
    → 리팩토링 어렵고 기피
      → 모듈이 도메인과 멀어짐
        → 더 높은 결합도

해결: 문제 지점을 파악하고 모듈 재구성을 과감히 진행해야 함. 개발 도구도 모듈 리팩토링 작업량을 최소화하는 방향으로 활용해야 한다.

5.5.2. 예시: 패키지 코딩 관례

TypeScript/Node.js에서의 모듈 의존성 표현:

Java의 패키지 임포트 개념을 TypeScript에서는 배럴(barrel) 파일(index.ts)로 표현할 수 있다.

// 안 좋은 예: 개별 클래스를 임포트 (모듈 의존성이 불명확)
import { ClassB1 } from '../moduleB/ClassB1';
import { ClassB2 } from '../moduleB/ClassB2';
import { ClassB3 } from '../moduleB/ClassB3';
import { ClassC1 } from '../moduleC/ClassC1';
import { ClassC2 } from '../moduleC/ClassC2';
// 좋은 예: 배럴(index.ts)을 통해 모듈 단위로 임포트
// moduleB/index.ts 에서 ClassB1, ClassB2, ClassB3 export
import { ClassB1, ClassB2, ClassB3 } from '../moduleB';

// moduleC/index.ts 에서 ClassC1, ClassC2, ClassC3 export
import { ClassC1, ClassC2, ClassC3 } from '../moduleC';

두 스케일(클래스가 모듈에 의존)을 혼용하지만, 특정 모듈에 대한 의존성을 생성하려는 의도 를 더 잘 전달한다.

만약 특정 클래스가 다른 모듈의 특정 클래스에만 의존하고, 해당 로컬 모듈이 개념적으로 다른 모듈에 의존하지 않아 보인다면, 클래스를 이동시키거나 모듈 자체를 재고해야 한다.

5.5.3. 인프라 주도 패키징의 함정

기술 프레임워크의 압력:

  • 유용한 것: 인프라와 UI 코드를 별도 패키지로, 도메인 레이어를 물리적으로 분리
  • 저항 필요: 단일 도메인 객체 책임을 여러 객체로 분산시키는 티어드 아키텍처

J2EE 예시의 문제:

  • entity bean: 데이터와 데이터 접근
  • session bean: 비즈니스 로직
  • → 데이터와 해당 데이터를 처리하는 로직을 캡슐화하는 객체의 근본 개념 파괴

4-티어 아키텍처 사례 연구:

티어 역할
1 데이터 영속성 (DB 매핑과 접근)
2 모든 상황에서 객체에 내재된 행동
3 애플리케이션별 기능 중첩
4 구현과 분리된 공개 인터페이스

각 분리에는 합당한 이유가 있었으나, 프레임워크가 각 티어를 별도 패키지에 배치하도록 강제하는 것이 문제였다.

결과: 개발자들이 하나의 개념 클래스를 구성하는 여러 물리적 객체를 연결하는 데 정신적 용량의 대부분을 소비 → 모델에 대한 사고 용량 부족 → 데이터베이스 접근 요구사항을 기본 충족하는 빈약한 도메인 모델(anemic domain model)

인프라 주도 패키징의 두 가지 비용:

  1. 개념 객체를 구현하는 요소들이 분리되면 코드가 모델을 더 이상 드러내지 않음
  2. 정신이 묶을 수 있는 파티셔닝의 양에 한계가 있음 → 도메인 모델을 의미 있는 청크로 묶는 능력 상실

권고사항:

  • 기술 환경에 필수적이거나 개발을 실제로 돕는 최소한의 기술적 파티셔닝 규칙만 선택
  • 실제로 다른 서버에 코드를 배포할 의도가 없다면, 단일 개념 객체를 구현하는 모든 코드를 같은 모듈에 유지

패키징을 사용하여 도메인 레이어를 다른 코드에서 분리하라. 그 외에는 도메인 개발자가 모델과 설계 선택을 지원하는 방식으로 도메인 객체를 패키지화할 수 있는 최대한의 자유를 남겨라.

모든 도메인 모델 요소가 구현에 반영되어야 함:

  • 엔티티, 값 객체, 연관 관계, 도메인 서비스, 모듈은 구현과 모델 사이의 직접 대응 지점
  • 구현의 객체, 포인터, 검색 메커니즘이 모델 요소에 명확하게 매핑되어야 함
  • 그렇지 않다면: 코드를 정리하고, 모델로 돌아가 변경하거나, 둘 다 해야 함
  • 도메인 객체에 개념과 직접 관련 없는 것을 추가하는 유혹을 뿌리쳐라

5.6. 모델링 패러다임

MODEL-DRIVEN DESIGN은 적용 중인 특정 모델링 패러다임에 맞는 구현 기술을 요구한다.

현재 지배적 패러다임: 객체 지향 설계 (대부분의 복잡한 프로젝트가 선택)

5.6.1. 왜 객체 패러다임이 지배적인가

내재적 강점:

  • 객체 모델링은 단순성과 정교함 사이의 좋은 균형을 달성
  • 패러다임이 너무 난해하면 충분한 개발자가 마스터하지 못함 → 잘못 사용됨
  • 비기술적 팀 구성원도 객체 모델 다이어그램을 이해할 수 있음
  • 도메인 지식을 포착하기에 충분히 풍부함
  • 처음부터 모델을 소프트웨어로 표현할 수 있는 개발 도구와 함께 지원됨

환경적 강점 (성숙도와 광범위한 채택):

  • 성숙한 인프라와 도구 지원 없이는 기술 R&D로 빠져들 수 있음
  • 수천 명의 개발자, 프로젝트 매니저, 전문가 커뮤니티가 이미 이해하고 있음
  • 대부분의 새로운 기술이 인기 있는 객체 지향 플랫폼과 통합 방법 제공

성숙하지 않은 패러다임의 위험성 - 1990년대 OODB 사례 교훈:

  • 테스트 데이터만으로도 DB 용량의 상당 부분 소비
  • 세 가지 문제: 확장되지 않는 기성 인프라, 세밀한 객체 저장 비용 과소평가, 객체 모델의 상호 의존성 얽힘으로 인한 경합
  • 결과: 수개월의 회복 기간 + 전문가 고용 비용 + 프로젝트 보수적 방향 전환
  • 교훈: 검증되지 않은 기술의 한계를 과소평가하지 말 것

현재 상황: 객체 지향 기술은 상대적으로 성숙함. 한계가 잘 알려져 있어 지식 있는 팀이 과도한 기대를 하지 않음.

5.6.2. 객체 세계의 비객체

도메인 모델 != 반드시 객체 모델:

  • Prolog 같은 언어로 구현된 MODEL-DRIVEN DESIGN 존재 (논리 규칙과 사실로 구성)
  • 어떤 도메인은 이산적인 캡슐화된 행동 패킷으로 모델링하기에 자연스럽지 않음
  • 예: 집중적으로 수학적이거나 전역적 논리 추론이 지배하는 도메인

패러다임 혼합의 필요성과 결정 기준:

상황 권고
도메인 대부분이 객체로 잘 표현됨, 일부 어색 어색한 객체 몇 개로 살 수 있음
주요 부분이 다른 패러다임에 맞음 패러다임 전환 또는 혼합 고려
다른 패러다임 부분이 소규모이고 독립적 서브시스템으로 캡슐화 (예: 복잡한 수학 계산)
다른 패러다임 부분이 객체와 밀접히 얽힘 패러다임 혼합, 어려움 증가

혼합 도구의 예: 비즈니스 규칙 엔진, 워크플로 엔진, 관계형 데이터베이스

일관된 모델을 여러 패러다임에 걸쳐 만드는 것의 어려움: 개발자가 소프트웨어에서 일관된 모델을 명확하게 볼 수 없으면 MODEL-DRIVEN DESIGN이 무너짐.

5.6.3. 패러다임 혼합 시 모델 주도 설계 유지

규칙 엔진 예시의 교훈:

  • 풍부한 도메인 모델에는 명시적 규칙 포함 → 객체 패러다임에 전역 규칙 표현 어색
  • 규칙 엔진은 더 자연스럽고 선언적인 규칙 정의 방법을 약속함
  • 하지만 기대를 종종 충족하지 못함

한 가지 흔한 결과: 두 부분으로 쪼개진 애플리케이션

  • 객체를 사용하는 정적 데이터 저장 시스템
  • 객체 모델과의 연결을 거의 잃은 임시방편 규칙 처리 애플리케이션

패러다임 혼합 시 MODEL-DRIVEN DESIGN 유지를 위한 네 가지 규칙:

  1. 구현 패러다임과 싸우지 말라: 도메인에 대해 항상 다른 방식으로 생각할 수 있음. 패러다임에 맞는 모델 개념을 찾아라.
  2. 유비쿼터스 언어에 의존하라: 도구 간 엄격한 연결이 없더라도, 두 환경에서 이름을 일관되게 적용하면 설계 부분들이 분리되는 것을 방지함.
  3. UML에 매몰되지 말라: 도구에 집착하면 쉽게 그릴 수 있는 것에 맞추기 위해 모델을 왜곡할 수 있음. 다른 패러다임의 관례적 표현이나 영어 설명이 더 나을 수 있음.
  4. 회의적이 되어라: 도구가 정말 제 역할을 하고 있나? 규칙 엔진 등의 오버헤드가 정말 필요한가? 여러 패러다임은 복잡성을 엄청나게 증가시킴.

핵심 원칙:

  • 패러다임 혼합을 취하기 전에 지배적 패러다임 내의 옵션을 먼저 소진하라
  • 관계형 패러다임(RDBMS)은 가장 흔한 비객체 기술로 객체 모델과 밀접히 관련됨 (챕터 6에서 자세히 다룸)

MODEL-DRIVEN DESIGN은 반드시 객체 지향일 필요는 없지만, 객체, 규칙, 워크플로 등 모델 구성요소의 표현력 있는 구현에 의존한다. 사용 가능한 도구가 그 표현력을 제공하지 않는다면, 도구 선택을 재고하라. 표현력 없는 구현은 추가 패러다임의 장점을 상쇄한다.


챕터 5 종합 정리

네 가지 핵심 빌딩 블록 요약

패턴 정체성 상태 핵심 설계 원칙
엔티티(Entity) 있음 (연속성) 있음 고유 식별자 필수, 라이프사이클에 집중
값 객체(Value Object) 없음 있음 (불변) 불변으로 설계, 자유롭게 복사/공유
서비스(Service) 없음 없음 (무상태) 도메인 언어로 명명, 3가지 특성 준수
모듈(Module) N/A N/A 낮은 결합도, 높은 응집도, 도메인 반영

패턴 간의 관계

graph TD
  M[모듈 Module] -->|그룹화| E[엔티티 Entity]
  M -->|그룹화| V[값 객체 Value Object]
  M -->|그룹화| S[서비스 Service]
  E -->|속성으로 보유| V
  S -->|조율| E
  S -->|조율| V

MODEL-DRIVEN DESIGN의 핵심 원칙 정리

  • 도메인 모델의 각 개념이 구현 요소에 반영되어야 함
  • 구현과 모델 사이에 명확하고 직접적인 매핑을 유지하라
  • 도메인 객체에 개념과 직접 관련 없는 것을 추가하는 유혹을 뿌리쳐라
  • 인프라 주도 패키징이 도메인 레이어의 응집성을 파괴하지 않도록 저항하라
  • 모델링 패러다임은 도구일 뿐이며, 표현력 있는 구현이 핵심임

Eric Evans - Domain-Driven Design

Chapter 6 학습 노트: 도메인 객체의 생명주기


6. 도메인 객체의 생명주기

핵심 전제

모든 객체에는 생명주기가 있다. 생성되고, 다양한 상태를 거치며, 결국 소멸(삭제 또는 보관)된다.

  • 단순한 객체: 생성자 호출 후 연산에 사용되고 가비지 컬렉터에 버려짐. 특별한 관리 불필요.
  • 복잡한 객체: 활성 메모리 외 공간에서도 생존. 다른 객체와 복잡한 상호의존성을 가짐. 상태 변화에 불변식(invariant)이 적용됨.

복잡한 객체를 관리하는 것은 모델 주도 설계(MODEL-DRIVEN DESIGN) 를 무너뜨릴 수 있는 도전이다.

두 가지 도전 과제

  1. 생명주기 전반에 걸친 무결성 유지
  2. 생명주기 관리 복잡성에 의해 모델이 잠식되는 것 방지

세 가지 패턴으로 대응

패턴 역할 생명주기 단계
애그리거트 (AGGREGATE) 명확한 소유권과 경계로 모델을 정돈, 모든 단계에서 무결성 유지 전 단계
팩토리 (FACTORY) 복잡한 객체와 애그리거트를 생성 및 재구성, 내부 구조 캡슐화 생명주기 시작
리포지토리 (REPOSITORY) 영속 객체를 탐색하고 조회하는 수단 제공 생명주기 중반 및 종료

리포지토리와 팩토리는 도메인 자체에서 비롯되는 개념은 아니지만, 도메인 설계에서 의미 있는 역할을 한다. 이 두 구조물은 모델 객체에 접근 가능한 핸들을 제공함으로써 모델 주도 설계를 완성한다.

flowchart TD
    A([생성]) --> B[Active - 활성]
    B --> C[(Database\nRepresentation)]
    C -- reconstitute --> B
    B -- store --> C
    B -- modify --> B
    B --> D([삭제])
    B --> E[(Database or File\nRepresentation - 보관)]
    E --> D

6.1. 애그리거트 (Aggregates)

문제: 객체 관계의 얽힘

연관 관계를 최소화하도록 설계해도, 대부분의 비즈니스 도메인은 너무 긴밀하게 연결되어 있어 객체 참조를 통한 긴 탐색 경로가 생긴다.

예시 - Person 삭제 문제:

  • Person 객체를 DB에서 삭제할 때 Address는 어떻게 할 것인가?
    • Address를 같이 삭제 → 동일 주소를 참조하던 다른 Person 객체가 죽은 참조를 가지게 됨
    • Address를 남겨둠 → DB에 쓰레기 주소가 쌓임
    • 가비지 컬렉션으로 해결 → 기술적 해결책이지만 근본적인 모델링 문제를 무시함

동시 접근 문제:

  • 여러 클라이언트가 동시에 같은 객체에 접근하면 상호 의존적인 객체들에 대한 동시 변경을 막아야 한다.
  • 범위를 잘못 설정하면 심각한 결과를 초래한다.

복잡한 연관 관계를 가진 모델에서 객체 변경의 일관성을 보장하기 어렵다. 불변식은 밀접하게 관련된 객체 그룹 전체에 걸쳐 유지되어야 한다. 그러나 지나치게 신중한 잠금(locking) 방식은 다수 사용자의 작업을 불필요하게 방해해 시스템을 사용할 수 없게 만든다.

해결책: 애그리거트

애그리거트(AGGREGATE) 는 데이터 변경의 단위로 취급하는 연관된 객체들의 클러스터다.

핵심 구성요소:

  • 경계(Boundary): 애그리거트 내부에 무엇이 포함되는지를 정의
  • 루트(Root): 애그리거트 안에 포함된 단 하나의 특정 엔티티. 외부 객체가 참조를 가질 수 있는 유일한 멤버
graph TD
    subgraph "Car Aggregate"
        direction TB
        Car["Car (Aggregate Root)\nvehicle ID number"]
        Tire["Tire (Entity)\nlocal identity"]
        Wheel["Wheel (Entity)\nlocal ident: LF/LR/RF/RR"]
        Position["Position (Value Object)\ntime period, mileage"]
        Car -->|4| Tire
        Wheel -->|4| Car
        Tire --> Position
    end
    Engine["Engine (Separate Aggregate Root)\nserial number"]
    Customer["Customer (External)"]
    Customer -.->|"DB 조회 또는\n루트(Car)만 참조 가능"| Car
    Customer -. "X: Tire 직접 참조 불가" .-> Tire

자동차 수리점 예시:

  • Car는 전역 식별자를 가지는 애그리거트 루트 (차대번호로 식별)
  • TireCar 컨텍스트 안에서만 식별 필요 → 로컬 식별자를 가지는 내부 엔티티
  • 타이어를 교체하고 폐기하면, 이후로는 해당 타이어의 로테이션 이력에 아무도 관심 없음
  • 반면 Engine은 시리얼 번호로 독립적으로 추적될 수 있으므로 자체 애그리거트의 루트가 될 수 있음
애그리거트 구현 규칙

애그리거트 경계를 구현할 때 모든 트랜잭션에 적용할 규칙들:

  1. 루트 엔티티는 전역 식별자를 가지며 불변식 검사에 최종 책임을 진다.
    • 루트 엔티티: 전역 식별자
    • 경계 내부 엔티티: 애그리거트 안에서만 유일한 로컬 식별자
  2. 외부 객체는 루트 엔티티에 대한 참조만 보유할 수 있다.
    • 루트가 내부 엔티티에 대한 참조를 외부 객체에 건넬 수 있지만, 그 참조는 일시적으로만 사용해야 하며 보관해서는 안 됨
    • 루트가 밸류 오브젝트의 복사본을 다른 객체에 건네는 것은 괜찮음 (밸류이므로 이후 변경이 애그리거트에 영향 없음)
  3. DB 쿼리로 직접 얻을 수 있는 것은 오직 애그리거트 루트뿐이다.
    • 내부 객체는 반드시 연관 관계 탐색을 통해서만 접근 가능
  4. 애그리거트 내부 객체는 다른 애그리거트 루트에 대한 참조를 가질 수 있다.
  5. 삭제 작업은 애그리거트 경계 내의 모든 것을 한 번에 제거해야 한다.
    • 가비지 컬렉션 환경에서는 루트를 삭제하면 나머지는 자동으로 수거됨
  6. 애그리거트 경계 내 어떤 객체에 변경이 커밋될 때, 전체 애그리거트의 모든 불변식이 충족되어야 한다.

불변식과 트랜잭션 범위: 애그리거트를 가로지르는 규칙은 항상 최신 상태일 것이 요구되지 않는다. 이벤트 처리, 배치 처리 등 다른 갱신 메커니즘을 통해 특정 시간 내에 해결할 수 있다. 그러나 애그리거트 내부에 적용되는 불변식은 트랜잭션 완료 시 즉시 강제된다.

설계 지침:

엔티티와 밸류 오브젝트를 애그리거트로 묶고, 각각의 경계를 정의하라. 각 애그리거트에서 루트가 될 하나의 엔티티를 선택하고, 루트를 통해서만 경계 내 객체에 대한 접근을 제어하라. 외부 객체는 루트에 대한 참조만 보유하도록 허용하라. 내부 멤버에 대한 일시적 참조는 단일 오퍼레이션 내에서만 외부로 전달될 수 있다.

// 잘못된 설계 예시: 외부에서 내부 엔티티에 직접 접근
class Car {
  public tires: Tire[]; // 외부에서 직접 접근 가능 - 위험!
}

// 올바른 설계 예시: 루트를 통해서만 접근
class Car {
  private readonly tires: Tire[];
  private readonly vehicleId: string;

  // 루트 엔티티만 내부 접근 제어
  rotateTires(tireWheelPairs: Array<{ tireId: string; wheelPosition: string }>): void {
    // 불변식 검사: 4개의 타이어/휠 쌍이 유효한지 확인
    this.validateRotation(tireWheelPairs);
    // 내부 상태 변경
    this.applyRotation(tireWheelPairs);
  }

  // 일시적 참조만 제공 (복사본 또는 읽기 전용)
  getTireMileage(tireId: string): number {
    const tire = this.tires.find(t => t.id === tireId);
    if (!tire) throw new Error("타이어를 찾을 수 없습니다.");
    return tire.mileage; // 값만 반환, 참조가 아님
  }
}

6.1.1. 예시: 구매 주문 무결성 (Purchase Order Integrity)

도메인 모델:

erDiagram
    PurchaseOrder {
        string id
        decimal approvedLimit
    }
    PurchaseOrderLineItem {
        string itemNumber
        int quantity
        decimal price
    }
    Part {
        string partNumber
        decimal price
    }
    PurchaseOrder ||--o{ PurchaseOrderLineItem : "contains"
    PurchaseOrderLineItem }o--|| Part : "references"

불변식: sum(lineItem.quantity * lineItem.price) <= PO.approvedLimit

기존 구현의 세 가지 문제:

  1. 불변식 강제 미흡: 새 라인 아이템 추가 시 PO가 합계를 확인하지만, 동시 접근 시 보호 불충분
  2. 변경 관리 부재: PO 삭제/보관 시 어디까지 연관 관계를 따라갈지 지침 없음. 부품 가격 변경 시 영향도 불명확
  3. DB 공유 충돌: 다수 사용자의 동시 작업이 DB 경합 문제를 일으킴

시나리오: 동시 편집으로 인한 불변식 위반

초기 상태 (승인 한도: $1,000):

항목 수량 부품 단가 금액
001 3 Guitars $100 $300
002 2 Trombones $200 $400
합계 $700
  • George: 라인 001 수량을 3 → 5로 수정 (자기 뷰의 합계: $900)
  • Amanda: 라인 002 수량을 2 → 3으로 수정 (자기 뷰의 합계: $900)
  • 두 사람 모두 커밋 후 실제 DB 합계: $1,100 → 불변식 위반, 아무도 모름

해결 시도 1 - PO 전체 잠금:

PO 단위로 잠금 → 불변식 강제 가능. 그러나 대형 PO를 여러 명이 동시에 작업하면 병목 발생.

해결 시도 2 - PO + Part 동시 잠금:

부품 가격 변경도 불변식에 영향을 미치므로 Part도 잠금 → 악성 경합(contention) 폭증, 교착 상태(deadlock) 발생.

올바른 해결책 - 도메인 지식 반영 + 애그리거트 재설계:

도메인 지식:

  • 부품(Part)은 많은 PO에서 사용됨 (높은 경합)
  • 부품 변경은 PO 변경보다 드물게 발생
  • 부품 가격 변경이 기존 PO에 반드시 즉시 반영될 필요는 없음 (이미 배송 완료된 PO는 당시 가격을 유지해야 함)

개선된 모델 - 라인 아이템에 가격 복사:

graph TD
    subgraph "PO Aggregate"
        PO["Purchase Order\napprovedLimit"]
        LI["Purchase Order Line Item\nquantity\nprice (copied from Part)"]
        PO -->|"*"| LI
    end
    Part["Part\nprice"] -->|"단방향 참조\n(가격만 복사)"| LI

LineItemPart의 가격을 복사해서 보유함으로써:

  • PO와 라인 아이템 간의 불변식은 애그리거트 내에서 엄격하게 강제됨
  • Part 가격 변경이 기존 LineItem에 즉각적인 영향을 주지 않음
  • Part를 잠글 필요 없어져 경합 해소

가격 변경 일관성은 더 느슨하게 관리 가능 (예: 매일 가격이 변경된 항목 목록을 사용자에게 보여주어 수동으로 검토하게 함). 이것은 항상 강제될 필요가 없는 불변식이다.


6.2. 팩토리 (Factories)

개념

객체나 애그리거트 전체의 생성이 복잡해지거나 내부 구조를 너무 많이 노출시킬 때, 팩토리가 캡슐화를 제공한다.

비유 - 자동차 엔진:

엔진 블록 자체가 피스톤을 집어서 실린더에 끼워 넣도록 설계하는 것은 비현실적이다. 조립 로봇이나 정비사가 그 역할을 한다. 조립하는 역할은 엔진이 완성된 후의 역할(샤프트를 돌리는 것)과는 완전히 무관하다. 자동차는 조립과 주행이 동시에 이루어지지 않는다. 마찬가지로, 복잡한 객체를 조립하는 일은 그 객체가 완성된 후 수행할 역할과 분리하는 것이 최선이다.

클라이언트에게 생성 책임을 넘기면 안 되는 이유
  • 클라이언트가 도메인 객체의 내부 구조를 알아야 함
  • 불변식 강제를 위해 객체의 규칙을 클라이언트가 알아야 함
  • 생성자 호출이 클라이언트를 구체 클래스에 결합시킴
  • 도메인 객체 구현이 변경되면 클라이언트도 변경해야 함
  • 클라이언트가 애플리케이션 레이어라면 책임이 도메인 레이어 밖으로 누출됨

객체 생성은 그 자체로 주요한 작업이 될 수 있지만, 복잡한 조립 작업은 생성되는 객체의 책임에 맞지 않는다. 이러한 책임들을 결합하면 이해하기 어려운 설계가 만들어진다. 클라이언트가 직접 생성을 지시하면 클라이언트 설계가 흐려지고, 조립되는 객체나 애그리거트의 캡슐화를 깨뜨리며, 클라이언트를 생성되는 객체의 구현에 과도하게 결합시킨다.

팩토리의 정의

다른 객체를 생성하는 책임을 지는 프로그램 요소를 팩토리(FACTORY) 라고 한다.

객체의 인터페이스가 구현을 캡슐화하여 클라이언트가 작동 방식을 알 필요 없이 사용할 수 있듯이, 팩토리는 복잡한 객체나 애그리거트를 생성하는 데 필요한 지식을 캡슐화한다.

설계 지침:

복잡한 객체와 애그리거트의 인스턴스 생성 책임을 별도의 객체로 이전하라. 그 객체 자체는 도메인 모델에서 아무 책임도 없을 수 있지만, 여전히 도메인 설계의 일부다. 복잡한 조립 전체를 캡슐화하는 인터페이스를 제공하되, 클라이언트가 인스턴스화될 객체의 구체 클래스를 참조할 필요가 없도록 하라. 애그리거트 전체를 하나의 단위로 생성하고 불변식을 강제하라.

// 팩토리 없는 경우 - 클라이언트가 내부를 알아야 함 (나쁜 예)
const tire1 = new Tire("t1", "arbitrary");
const tire2 = new Tire("t2", "arbitrary");
const tire3 = new Tire("t3", "arbitrary");
const tire4 = new Tire("t4", "arbitrary");
const wheel1 = new Wheel("LF");
const wheel2 = new Wheel("LR");
const wheel3 = new Wheel("RF");
const wheel4 = new Wheel("RR");
// 클라이언트가 불변식도 직접 설정해야 함...
const car = new Car("VIN-123", [tire1, tire2, tire3, tire4], [wheel1, wheel2, wheel3, wheel4]);

// 팩토리 사용 - 클라이언트는 의도만 표현 (좋은 예)
const car = CarFactory.create({ vehicleId: "VIN-123", model: "Sedan" });
// 팩토리 내부에서 Tire, Wheel 생성 및 불변식 보장
좋은 팩토리의 두 가지 기본 요건
  1. 각 생성 메서드는 원자적(atomic)이어야 하고, 생성되는 객체나 애그리거트의 모든 불변식을 강제해야 한다.
    • 팩토리는 일관된 상태의 객체만 생성 가능해야 함
    • 엔티티의 경우: 모든 불변식이 충족된 상태의 전체 애그리거트를 생성 (선택적 요소는 나중에 추가 가능)
    • 불변 밸류 오브젝트의 경우: 모든 속성이 올바른 최종 상태로 초기화
    • 올바르게 생성할 수 없는 요청이 들어오면 예외를 던져야 함
  2. 팩토리는 생성되는 구체 클래스가 아닌, 원하는 타입으로 추상화되어야 한다.

6.2.1. 팩토리와 위치 선정 (Choosing Factories and Their Sites)

팩토리를 어디에 두느냐는 무엇을 숨기고 싶은가, 어디서 제어하고 싶은가에 따라 결정된다. 이 결정은 주로 애그리거트를 중심으로 이루어진다.

패턴 1 - 애그리거트 루트에 팩토리 메서드:

기존 애그리거트에 요소를 추가할 때, 루트에 팩토리 메서드를 만든다. 외부 클라이언트로부터 애그리거트 내부를 숨기면서, 루트가 요소 추가 시 무결성을 보장한다.

class PurchaseOrder {
  private readonly items: PurchaseOrderLineItem[] = [];
  private readonly approvedLimit: number;

  // 팩토리 메서드: 내부 구조 캡슐화 + 불변식 강제
  addItem(part: CatalogPart, quantity: number): PurchaseOrderLineItem {
    const newItem = new PurchaseOrderLineItem(
      this.generateItemNumber(), // 로컬 식별자 생성
      quantity,
      part.price // 가격을 복사해서 보유
    );
    const newTotal = this.calculateTotal() + newItem.amount;
    if (newTotal > this.approvedLimit) {
      throw new Error(`승인 한도(${this.approvedLimit})를 초과합니다.`);
    }
    this.items.push(newItem);
    return newItem;
  }
}

패턴 2 - 관련 객체에 팩토리 메서드 (다른 애그리거트 생성):

class BrokerageAccount {
  private readonly accountNumber: string;
  private readonly customerName: string;

  // Trade Order는 이 애그리거트에 속하지 않지만,
  // Brokerage Account가 생성을 자연스럽게 제어
  newBuy(security: string, numberOfShares: number): TradeOrder {
    // Brokerage Account의 정보가 TradeOrder에 내장됨
    // 허용되는 거래 규칙도 이곳에서 검증
    this.validateTradeAllowed(security, numberOfShares);
    return new TradeOrder({
      orderId: generateId(),
      brokerageAccountId: this.accountNumber,
      type: "BuyOrder",
      security,
      numberOfShares,
    });
  }
}

패턴 3 - 독립(standalone) 팩토리:

숨기고 싶은 것이 있지만 자연스러운 호스트 객체가 없을 때 사용. 일반적으로 전체 애그리거트를 생성하고, 루트에 대한 참조를 반환하며, 불변식이 강제됨을 보장.

// 독립 팩토리 예시
class BrokerageAccountFactory {
  create(customerName: string, accountType: "MARGIN_APPROVED" | "STANDARD"): BrokerageAccount {
    const accountNumber = this.brokerageAccountNumberSequence.next();
    const account = new BrokerageAccount(accountNumber, customerName);
    if (accountType === "MARGIN_APPROVED") {
      account.addMarginAccount(new MarginAccount());
    }
    return account;
  }
}

6.2.2. 생성자만으로 충분한 경우 (When a Constructor Is All You Need)

팩토리 도입은 일반적으로 과소 활용된다. 하지만 다음 상황에서는 간단한 생성자가 최선이다.

  • 클래스가 타입 그 자체인 경우 (흥미로운 계층 구조의 일부가 아니고, 인터페이스를 통한 다형성도 없음)
  • 클라이언트가 구현에 관심이 있는 경우 (전략 패턴 선택 수단으로서)
  • 클라이언트가 모든 속성을 알 수 있어서 생성자 내부에서 다른 객체 생성이 중첩되지 않는 경우
  • 생성 과정이 복잡하지 않은 경우

공개 생성자도 팩토리와 동일한 규칙을 따라야 한다: 생성되는 객체의 모든 불변식을 충족시키는 원자적 작업이어야 한다.

생성자 안에서 다른 클래스의 생성자를 호출하는 것은 피하라. 생성자는 최대한 단순해야 한다. 복잡한 조립, 특히 애그리거트의 경우 팩토리를 사용하라.


6.2.3. 인터페이스 설계 (Designing the Interface)

독립 팩토리든 팩토리 메서드든 메서드 시그니처 설계 시 두 가지를 고려한다.

1. 각 작업은 원자적이어야 한다.

완성된 제품을 만드는 데 필요한 모든 것을 팩토리와 한 번의 상호작용으로 전달해야 한다. 생성 실패 시 어떻게 할지도 결정해야 한다 (예외 던지기 또는 null 반환). 팩토리에서의 실패 처리에 대한 코딩 표준을 채택하는 것이 좋다.

2. 팩토리는 인자와 결합된다.

입력 파라미터 선택에 신중해야 한다. 인자를 제품에 그대로 연결하는 경우는 적당한 결합이지만, 인자에서 일부를 꺼내어 생성에 사용하면 결합이 더 단단해진다.

안전한 파라미터 선택 기준:

  • 더 낮은 설계 레이어에서 온 것 (상위 레이어 객체보다 기본 객체)
  • 모델에서 제품과 밀접하게 관련된 객체 (새로운 의존성을 추가하지 않는 것)

인자에는 구체 클래스가 아닌 추상 타입을 사용하라. 팩토리는 제품의 구체 클래스에 이미 결합되어 있으므로, 파라미터에서도 구체 클래스에 결합될 필요는 없다.


6.2.4. 불변식 로직의 위치 (Where Does Invariant Logic Go?)

팩토리는 생성하는 객체나 애그리거트의 모든 불변식이 충족되도록 보장해야 한다. 그러나 불변식 규칙을 객체 외부로 빼는 것은 항상 신중히 고려해야 한다.

팩토리가 불변식 로직을 갖는 것이 유리한 경우:

  • 제품 객체 내에서는 생성 후 해당 불변식이 다시 적용될 일이 없을 때 (예: 엔티티의 식별자 속성 할당 규칙 - 생성 후 불변)
  • 밸류 오브젝트는 완전히 불변이므로, 활성 수명 동안 다시 적용되지 않을 로직을 굳이 가질 필요 없음
  • 애그리거트 전체에 걸친 규칙 (여러 객체를 아우르는 규칙)

팩토리 메서드(다른 도메인 객체에 붙어있는)에 불변식 로직을 넣는 것은 비적합:

도메인 객체가 팩토리 역할까지 겸하면 단일 책임 원칙에서 벗어난다.


6.2.5. 엔티티 팩토리 vs 밸류 오브젝트 팩토리 (ENTITY Factories Versus VALUE OBJECT Factories)
구분 엔티티 팩토리 밸류 오브젝트 팩토리
완성도 유효한 애그리거트에 필수적인 속성만으로 생성. 필수 불변식이 아닌 세부 사항은 나중에 추가 가능 완전한 최종 형태로 생성되어야 함. 모든 속성이 올바른 최종 상태로 초기화
식별자 식별자 할당 문제가 있음. 자동 생성(DB 시퀀스 등) 또는 외부 입력(전화번호 등)으로 처리 식별자 불필요
불변성 가변적 완전히 불변

식별자가 프로그램에 의해 자동 할당될 때, 팩토리는 고유 추적 ID를 생성하는 데 적합한 위치다. 실제 고유 ID 생성은 DB 시퀀스 등 인프라에서 이루어지더라도, 팩토리가 무엇을 요청하고 어디에 두는지를 알고 있다.


6.2.6. 저장된 객체 재구성 (Reconstituting Stored Objects)

대부분의 객체는 어느 시점에 DB나 네트워크를 통해 전송된다. 현재 대부분의 DB 기술은 객체의 특성을 유지하지 못한다. 따라서 조회 시에는 데이터로부터 살아있는 객체를 재조립하는 복잡한 과정이 필요하다.

재구성용 팩토리와 생성용 팩토리의 두 가지 차이점:

  1. 엔티티 재구성 팩토리는 새로운 추적 ID를 할당하지 않는다. 이전 화신과의 연속성이 끊어지기 때문. 식별 속성이 입력 파라미터의 일부여야 한다.
  2. 불변식 위반 처리 방식이 다르다. 새 객체 생성 시에는 불변식 불충족 시 거부하면 그만이다. 그러나 재구성 시 시스템 어딘가에 이미 존재하는 객체는 무시할 수 없다. 그렇다고 규칙 위반을 무시할 수도 없다. 불일치를 복구하는 전략이 필요하며, 이것이 재구성을 새 객체 생성보다 어렵게 만든다.
// DB에서 Customer 재구성 예시
class SqlCustomerFactory {
  // DB ResultSet을 받아 Customer 엔티티를 재구성
  fromRow(row: { id: string; fname: string; lname: string; /* ... */ }): Customer {
    // 새 ID를 생성하지 않고, 기존 ID를 그대로 사용
    const customer = new Customer(
      row.id, // 기존 추적 ID 유지
      row.fname,
      row.lname
    );
    // 불변식 위반 시 새 객체 생성과 달리 복구 전략 실행
    if (!customer.isValid()) {
      this.repairInconsistency(customer);
    }
    return customer;
  }
}

// XML에서 재구성 예시
class XmlCustomerFactory {
  fromXml(xml: string): Customer {
    const parsed = this.xmlParser.parse(xml);
    return new Customer(
      parsed.custid, // 기존 ID 유지
      parsed.firstName,
      parsed.lastName
    );
  }
}

6.3. 리포지토리 (Repositories)

문제: 객체 참조를 어떻게 얻는가?

연관 관계는 관련 객체를 찾게 해주지만, 생명주기 중간에 있는 엔티티나 밸류를 탐색하려면 시작점이 필요하다.

객체 참조를 얻는 방법:

  1. 생성하기: 생성 작업이 새 객체에 대한 참조를 반환
  2. 연관 관계 탐색: 이미 알고 있는 객체에서 연관 객체를 요청
  3. DB 쿼리: 속성으로 DB에서 검색하고 재구성 ← 이것이 현실

대부분의 객체는 관계형 DB에 저장된다. SQL 쿼리 작성, 인프라 레이어의 쿼리 서비스 호출, 결과 집합에서 정보 추출, 생성자나 팩토리에 전달… 이 과정에서 모델에 대한 집중력이 사라진다. 객체는 쿼리가 제공하는 데이터의 컨테이너처럼 느껴지고, 전체 설계가 데이터 처리 스타일로 흘러버린다.

직접 DB 접근의 문제점:

  • 도메인 로직이 쿼리 코드 속에 묻힘
  • AGGREGATE나 객체 캡슐화를 우회하게 됨
  • 기술적 인프라 복잡성이 클라이언트 코드를 압도함
  • 개발자들이 도메인 레이어를 단순화하도록 유도됨 → 모델이 무의미해짐
접근 범위 축소

아무 객체나 전역 검색할 필요는 없다:

  • 일시적(transient) 객체: 접근 불필요
  • 연관 관계 탐색으로 편리하게 접근할 수 있는 영속 객체: 쿼리 불필요 (예: Person 객체로부터 Address 요청)
  • 애그리거트 내부 객체: 루트를 통한 탐색으로만 접근 (금지)

영속 밸류 오브젝트는 보통 그것을 감싸는 애그리거트의 루트 엔티티를 통해 탐색으로 찾는다. 전역 검색이 필요한 경우는 대부분 엔티티다.

DB 쿼리로 기존 밸류를 찾아야 한다면, 사실 그것은 아직 인식하지 못한 식별자를 가진 엔티티일 가능성을 고려해야 한다.

리포지토리의 정의

전역 접근이 필요한 각 객체 타입에 대해, 해당 타입의 모든 객체를 메모리 내 컬렉션처럼 제공하는 객체를 생성하라. 잘 알려진 전역 인터페이스를 통해 접근을 설정하라. 객체를 추가하고 제거하는 메서드를 제공하여 데이터 저장소에서의 실제 삽입이나 삭제를 캡슐화하라. 특정 기준에 맞는 객체를 선택하고 속성 값이 기준을 충족하는 완전히 인스턴스화된 객체 또는 컬렉션을 반환하는 메서드를 제공하여, 실제 저장 및 쿼리 기술을 캡슐화하라. 직접 접근이 실제로 필요한 애그리거트 루트에 대해서만 리포지토리를 제공하라. 클라이언트가 모델에 집중하도록 하고, 모든 객체 저장과 접근을 리포지토리에 위임하라.

// 리포지토리 인터페이스 (도메인 레이어)
interface TradeOrderRepository {
  findById(trackingId: string): Promise<TradeOrder | null>;
  findByBrokerageAccountId(accountId: string): Promise<TradeOrder[]>;
  add(order: TradeOrder): Promise<void>;
  remove(order: TradeOrder): Promise<void>;
}

// 클라이언트 코드 - 기술적 세부사항 없이 모델 용어로 작업
class TradeApplicationService {
  constructor(private readonly tradeOrderRepo: TradeOrderRepository) {}

  async getOutstandingOrders(accountId: string): Promise<TradeOrder[]> {
    // 클라이언트는 SQL, OR 매핑 등을 전혀 모름
    return this.tradeOrderRepo.findByBrokerageAccountId(accountId);
  }
}
리포지토리의 장점
  • 단순한 모델 제공: 클라이언트에게 영속 객체 획득 및 생명주기 관리를 위한 단순한 모델 제공
  • 기술 분리: 애플리케이션과 도메인 설계를 영속성 기술, 다중 DB 전략, 복수 데이터 소스로부터 분리
  • 설계 의사결정 표현: 객체 접근에 대한 설계 결정을 명확히 전달
  • 테스트 용이성: 더미(in-memory) 구현으로 쉽게 교체 가능

6.3.1. 리포지토리 쿼리 (Querying a Repository)

유형 1 - 하드코딩 쿼리 (단순 리포지토리):

특정 파라미터를 가진 하드코딩 쿼리. 어떤 인프라에서도 구축 가능하고, 많은 투자 없이 클라이언트가 어차피 해야 했을 일을 대신 해준다.

class TradeOrderRepositoryImpl implements TradeOrderRepository {
  async findById(trackingId: string): Promise<TradeOrder | null> {
    const row = await this.db.query(
      "SELECT * FROM TRADE_ORDER WHERE TRACKING_ID = ?",
      [trackingId]
    );
    return row ? this.sqlTradeOrderFactory.fromRow(row) : null;
  }

  async findByBrokerageAccountId(accountId: string): Promise<TradeOrder[]> {
    const rows = await this.db.query(
      "SELECT * FROM TRADE_ORDER WHERE BROKERAGE_ACCOUNT_ID = ?",
      [accountId]
    );
    return rows.map(r => this.sqlTradeOrderFactory.fromRow(r));
  }
}

유형 2 - 명세(SPECIFICATION) 기반 쿼리 (고급 리포지토리):

클라이언트가 어떻게 얻을지는 신경 쓰지 않고, 원하는 것을 명세(specification)로 서술한다. 실제 선택을 수행할 수 있는 객체가 만들어진다. (9장에서 상세 논의)

// 명세 기반 쿼리 예시
const criteria = new TradeOrderCriteria()
  .equalTo(TradeOrder.SECURITY, "WCOM")
  .equalTo(TradeOrder.ACCOUNT, "123");

const matchingOrders = await tradeOrderRepo.matching(criteria);

유연한 쿼리 설계를 가진 리포지토리도 특수 목적 하드코딩 쿼리(자주 사용되는 쿼리, 객체 자체가 아닌 수치 요약을 반환하는 쿼리 등)를 추가할 수 있어야 한다. 이를 허용하지 않는 프레임워크는 도메인 설계를 왜곡시키거나 개발자에 의해 우회된다.


6.3.2. 클라이언트 코드는 리포지토리 구현을 무시하지만, 개발자는 그렇지 않다

캡슐화를 통해 클라이언트 코드가 단순해지지만, 개발자는 내부에서 무슨 일이 벌어지는지 이해해야 한다. 리포지토리를 다른 방식으로 사용하거나 다른 방식으로 작동할 때 성능 영향은 극단적일 수 있다.

실제 사례 (Kyle Brown):

제조 애플리케이션이 프로덕션 배포 후 몇 시간이면 메모리 부족이 발생했다. 원인: 공장 내 모든 항목에 대한 정보를 요약할 때 개발자들이 "모든 객체" 쿼리를 사용했고, 이 쿼리가 모든 객체를 인스턴스화한 후 필요한 부분만 선택했다. 이 코드의 효과는 전체 DB를 한 번에 메모리에 올리는 것이었다. 테스트 데이터가 적어 테스트 시 발견되지 않았다.

인프라가 모델링 선택을 제약한다: 리포지토리의 사용과 쿼리 구현 사이에는 양방향 피드백이 있어야 한다.


6.3.3. 리포지토리 구현 (Implementing a Repository)

구현은 영속성 기술과 인프라에 따라 크게 달라진다. 이상적으로는 모든 내부 작업을 클라이언트로부터 숨겨야 한다. 클라이언트 코드는 데이터가 객체 DB, 관계형 DB, 메모리 중 어디에 저장되든 동일해야 한다.

구현 시 고려할 사항들:

타입 추상화:

리포지토리는 특정 타입의 모든 인스턴스를 “포함”하지만, 클래스마다 하나의 리포지토리가 필요한 것은 아니다. 타입은 계층 구조의 추상 슈퍼클래스이거나 (예: TradeOrderBuyOrderSellOrder일 수 있음), 구현자들이 계층적으로 관련되지 않은 인터페이스일 수도 있다.

// 계층 구조 추상화
interface TradeOrderRepository {
  // BuyOrder와 SellOrder 모두 반환 가능
  findById(id: string): Promise<TradeOrder | null>;
}

// 구현 시 다형성 활용
class TradeOrderRepositoryImpl implements TradeOrderRepository {
  async findById(id: string): Promise<TradeOrder | null> {
    const row = await this.db.findById("TRADE_ORDER", id);
    if (!row) return null;
    // 타입에 따라 적절한 서브클래스 인스턴스 생성
    return row.type === "BUY" 
      ? this.factory.createBuyOrder(row) 
      : this.factory.createSellOrder(row);
  }
}

클라이언트로부터의 분리 활용:

  • 쿼리 기법 변경이나 메모리 캐싱으로 성능 최적화 가능
  • 언제든지 영속성 전략 교체 가능
  • 인메모리 전략으로 테스트 용이

트랜잭션 제어는 클라이언트에게:

리포지토리는 DB에 삽입하고 삭제하지만 커밋하지 않는다. 클라이언트가 작업 단위를 올바르게 시작하고 커밋할 문맥을 가지고 있다. 트랜잭션 관리는 리포지토리가 손을 떼면 단순해진다.

// 잘못된 예: 리포지토리가 트랜잭션 커밋
class BadOrderRepository {
  async save(order: Order): Promise<void> {
    await this.db.insert(order);
    await this.db.commit(); // 리포지토리가 커밋 - 잘못됨
  }
}

// 올바른 예: 클라이언트가 트랜잭션 제어
class OrderApplicationService {
  async placeOrder(command: PlaceOrderCommand): Promise<void> {
    const tx = await this.db.beginTransaction();
    try {
      const order = this.orderFactory.create(command);
      await this.orderRepository.add(order); // 리포지토리는 삽입만
      await this.inventoryRepository.reserve(command.items); // 또 다른 작업
      await tx.commit(); // 클라이언트가 커밋
    } catch (error) {
      await tx.rollback();
      throw error;
    }
  }
}

6.3.4. 프레임워크 내에서 작업하기 (Working Within Your Frameworks)

리포지토리를 구현하기 전에 사용 중인 인프라와 아키텍처 프레임워크를 신중히 검토해야 한다.

  • 프레임워크와 싸우지 말라. 도메인 주도 설계의 기본은 지키되, 프레임워크가 상충할 때는 세부 사항을 양보하라.
  • 도메인 주도 설계의 개념과 프레임워크의 개념 사이의 친화성을 찾아라.
  • 프레임워크가 이미 동등한 패턴을 정의하고 있을 수 있다.

J2EE Entity Bean의 EJB Home처럼, 이미 존재하는 유사 구조를 억지로 리포지토리처럼 꾸미려 하면 다른 문제가 생길 수 있다. 선택의 자유가 있다면, 원하는 설계 스타일과 조화로운 프레임워크나 그 일부를 선택하라.


6.3.5. 팩토리와의 관계 (The Relationship with Factories)

팩토리는 객체 생명의 시작을 담당하고, 리포지토리는 중간과 끝을 관리한다.

DB에서 조회된 데이터는 객체로 재조립되어야 한다. 리포지토리가 데이터를 기반으로 객체를 생성하므로 기술적 관점에서는 팩토리처럼 보이지만, 도메인 주도 관점에서는 구분이 명확하다.

구분 팩토리 리포지토리
역할 새로운 객체를 만든다 기존 객체를 찾는다
클라이언트 관점 새 인스턴스 생성 이미 메모리에 있는 것처럼 보이게 함
개념적 의미 새 개념 객체의 탄생 생명주기 중간의 동일 개념 객체 재접근

협력 방식:

리포지토리가 객체 생성을 팩토리에 위임하는 방식으로 두 관점을 조화시킬 수 있다.

sequenceDiagram
    participant Client
    participant Repository
    participant Database
    participant Factory

    Client->>Repository: findById("t456")
    Repository->>Database: SELECT * FROM TRADE_ORDER WHERE TRACKING_ID = 't456'
    Database-->>Repository: ResultSet
    Repository->>Factory: reconstitute(resultSet)
    Factory-->>Repository: TradeOrder (재구성된 객체)
    Repository-->>Client: TradeOrder

    Note over Factory: 기존 ID 유지, 새 ID 할당 안 함
// 리포지토리가 팩토리에 재구성 위임
class TradeOrderRepositoryImpl implements TradeOrderRepository {
  constructor(
    private readonly db: DatabaseInterface,
    private readonly factory: SqlTradeOrderFactory // 팩토리에 위임
  ) {}

  async findById(trackingId: string): Promise<TradeOrder | null> {
    const row = await this.db.query(
      `SELECT * FROM TRADE_ORDER WHERE TRACKING_ID = ?`,
      [trackingId]
    );
    if (!row) return null;
    // 재구성은 팩토리에게 위임
    return this.factory.fromRow(row);
  }
}

// 클라이언트가 새 객체를 저장할 때: Factory -> Repository
class OrderApplicationService {
  async createOrder(command: CreateOrderCommand): Promise<void> {
    // 1. Factory로 새 객체 생성
    const order = this.brokerageAccount.newBuy(command.security, command.shares);
    // 2. Repository로 저장
    await this.tradeOrderRepository.add(order);
  }
}

“찾거나 생성(find-or-create)” 기능은 피하라.

클라이언트가 원하는 객체를 설명하면 없으면 새로 만들어주는 기능. 피해야 하는 이유:

  • 엔티티와 밸류 오브젝트를 명확히 구분하면 이 기능의 필요성 대부분이 사라짐
  • 도메인에서 새 객체와 기존 객체의 구분은 중요하다
  • 투명하게 이 둘을 합치는 프레임워크는 상황을 오히려 흐릿하게 만든다

6.4. 관계형 데이터베이스를 위한 객체 설계 (Designing Objects for Relational Databases)

주로 객체 지향인 소프트웨어에서 가장 흔한 비객체 컴포넌트는 관계형 DB다. DB는 단순히 객체와 상호작용하는 것이 아니라, 객체를 이루는 데이터의 영속적 형태를 저장한다.

세 가지 일반적 상황
  1. DB가 주로 객체를 위한 저장소인 경우
  2. 다른 시스템을 위해 설계된 DB인 경우
  3. 이 시스템을 위해 설계됐지만 객체 저장 외 역할도 하는 DB인 경우
상황 1: DB가 객체 저장소로 설계될 때

매핑을 단순하게 유지하기 위해 일부 모델 제약을 수용할 가치가 있다.

중요 원칙:

DB를 객체 저장소로 볼 때, 매핑 도구의 능력에 상관없이 데이터 모델과 객체 모델이 크게 벗어나지 않도록 하라. 관계형 모델에 가깝게 유지하기 위해 일부 객체 관계의 풍부함을 희생하라. 객체 매핑을 단순화하는 데 도움이 된다면 정규화 같은 형식적 관계형 표준을 일부 양보하라.

  • 테이블 행 하나는 객체 하나를 포함해야 한다 (경우에 따라 애그리거트 내 보조 객체 포함)
  • 테이블의 외래 키는 다른 엔티티 객체에 대한 참조로 변환되어야 한다
  • 이 단순함에서 벗어날 필요가 있더라도 단순 매핑 원칙 자체를 버리지는 말라
  • 객체 시스템 외부의 프로세스는 이런 객체 저장소에 접근해서는 안 된다. 객체가 강제하는 불변식을 위반할 수 있고, 해당 접근이 데이터 모델을 고착시켜 객체 리팩토링을 어렵게 만든다.

유비쿼터스 언어(UBIQUITOUS LANGUAGE)의 역할:

객체와 관계형 컴포넌트를 하나의 모델에 연결하는 데 유비쿼터스 언어가 도움이 된다. 객체의 이름과 연관 관계는 관계형 테이블의 것과 정밀하게 일치해야 한다. 매핑 도구가 이를 불필요하게 만드는 것처럼 보여도, 관계의 미묘한 차이가 많은 혼란을 야기한다.

상황 2: 레거시 또는 외부 시스템의 DB

한 시스템 안에 두 개의 도메인 모델이 공존하는 상황. 14장 “모델 무결성 유지”에서 상세 다룸. 다른 시스템에 암묵적인 모델을 따르거나, 완전히 별개의 모델을 만드는 것 중 선택.

리팩토링과 DB 설계의 긴장 관계

객체 세계에서의 리팩토링 전통은 관계형 DB 설계에는 거의 영향을 미치지 못했다. 심각한 데이터 마이그레이션 이슈가 잦은 변경을 억제한다. 이것이 객체 모델 리팩토링에 걸림돌이 될 수 있지만, 객체 모델과 DB 모델이 벗어나기 시작하면 투명성이 빠르게 사라진다.

의도적인 분리의 경우

DB 스키마를 객체 모델과 의도적으로 분리하는 것이 합당할 때도 있다:

  • DB가 객체를 인스턴스화하지 않는 다른 소프트웨어에서도 사용될 때
  • DB에 거의 변경이 없지만 객체 행동은 빠르게 진화할 때

단, 이 분리를 의식적으로 선택해야 한다. 팀이 DB를 모델과 맞추지 못해 우발적으로 발생하는 분리는 결국 어색한 DB 스키마로 이어진다.


요약

graph LR
    subgraph "생명주기 패턴"
        AGGREGATE["애그리거트\n- 경계와 루트 정의\n- 불변식 범위 설정\n- 모든 단계 무결성"]
        FACTORY["팩토리\n- 복잡한 생성 캡슐화\n- 불변식 강제\n- 생명주기 시작"]
        REPOSITORY["리포지토리\n- 객체 탐색/조회\n- 저장소 기술 추상화\n- 생명주기 중반~종료"]
    end

    FACTORY -->|"Aggregate 전체 생성"| AGGREGATE
    REPOSITORY -->|"Aggregate Root만 직접 조회"| AGGREGATE
    REPOSITORY -->|"재구성 위임"| FACTORY
개념 핵심 역할 핵심 규칙
애그리거트 변경 단위의 클러스터 정의 외부는 루트만 참조. 삭제는 전체. 트랜잭션마다 불변식 충족
팩토리 복잡한 생성 캡슐화 원자적 생성. 타입으로 추상화. 불변식 강제
리포지토리 영속 객체 탐색 추상화 애그리거트 루트만 제공. 트랜잭션은 클라이언트 제어

Domain-Driven Design — Chapter 7 학습 노트

출처: Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, Addison-Wesley, 2003


7. 언어의 사용: 확장 예제

앞선 4~6장에서 소개된 패턴 언어(엔티티, 값 객체, 애그리게이트, 리포지토리, 팩토리, 서비스, 모듈)를 하나씩이 아닌 조합하여 적용하는 실전 예제. 가상 팀이 요구사항과 구현 이슈를 다루며 MODEL-DRIVEN DESIGN을 점진적으로 발전시키는 과정을 추적한다.

  • 현실 프로젝트보다 훨씬 단순하지만, ==핵심 역학(forces)과 패턴이 실제로 작동하는 방식==을 보여 준다
  • 모델 개선 → 설계 개선 → 다시 모델 개선이 순환하는 반복 과정이 핵심
  • 이 장의 목적: 도메인 모델과 구현을 긴밀하게 연결하는 빌딩 블록 패턴의 실전 적용

7.1. 화물 해운 시스템 소개

초기 요구사항
  1. 고객 화물의 핵심 처리(handling) 추적
  2. 화물 사전 예약
  3. 화물이 처리 과정의 특정 지점 도달 시 자동 청구서 발송
초기 도메인 모델 (Figure 7.1)
classDiagram
    class Cargo {
        +trackingID: string
    }
    class Customer {
        +name: string
        +customerID: string
    }
    class DeliveryHistory {
    }
    class DeliverySpecification {
        +arrivalTime: Date
    }
    class HandlingEvent {
        +completionTime: Date
        +type: string
    }
    class CarrierMovement {
        +scheduleID: string
    }
    class Location {
        +portCode: string
    }

    Customer "*" --> Cargo : role
    Cargo "1" -- "1" DeliveryHistory : handled
    Cargo "1" -- "1" DeliverySpecification : goal
    DeliveryHistory "1" -- "*" HandlingEvent
    HandlingEvent "*" -- "0..1" CarrierMovement
    CarrierMovement --> Location : from
    CarrierMovement --> Location : to
    DeliverySpecification --> Location : destination
모델이 가능하게 하는 언어적 진술
  • “여러 Customer가 각자 다른 **역할(role)**로 하나의 Cargo에 관여한다”
  • Cargo의 배달 목표가 명세(Specification)로 정의된다”
  • DeliverySpecification을 만족하는 일련의 CarrierMovement가 배달 목표를 달성한다”
각 객체의 의미
객체 역할
HandlingEvent 화물 적재, 세관 통과 등 현실 세계의 개별 행위
DeliverySpecification 목적지·도착 시간 등의 배달 목표(Specification 패턴 적용)
Customer 사람 또는 회사; 운송인, 수취인, 지불인 등 역할 구분
CarrierMovement 특정 운송 수단이 A→B로 이동하는 한 번의 여정
DeliveryHistory 실제 발생한 일(역사); DeliverySpecification은 목표
Location 항구 코드로 식별되는 장소
DeliverySpecification 분리의 3가지 이점
  1. Cargo 단순화 — 목표 관련 속성들을 모두 Cargo가 떠안을 필요가 없어진다
  2. 안전한 추상화 — 세부 기준이 숨겨지므로 다이어그램이 간결해지고 변경이 용이하다
  3. 표현력 향상 — “배달 수단은 미정이지만, 목표는 Specification이 정의한다”는 의도를 명확히 드러낸다

7.2. 도메인 격리: 애플리케이션 도입

  • LAYERED ARCHITECTURE 적용으로 도메인 책임을 분리한다
  • 시스템 외부 레이어(UI, 인프라)와 도메인 레이어 간 경계를 명확히 한다
3개의 애플리케이션 레이어 클래스
클래스 역할
TrackingQuery 특정 Cargo의 과거·현재 처리 이력 조회
BookingApplication 새 Cargo 등록 및 시스템 준비
IncidentLoggingApplication 각 처리(handling) 기록 (TrackingQuery에 정보 제공)

핵심 원칙: 애플리케이션 클래스는 코디네이터다. 질문에 대한 답을 직접 계산하지 않고, 그 계산은 도메인 레이어가 담당한다.


7.3. 엔티티와 값 객체 구별

각 객체를 순서대로 검토하여, 추적해야 할 식별성이 있는지 혹은 기본 값을 표현하는지 판별한다.

7.3.1. Customer
  • 사람 또는 회사 → 사용자에게 의미 있는 식별성 존재 → ==ENTITY==
  • 식별자: 기존 고객 DB의 고객 ID (도메인 전문가와 논의로 도출)
    • 세금 ID는 국제 기업에 부적합
    • 회사 전체에서 이미 사용하는 기존 ID를 재사용하면 시스템 간 연속성 확보
    • 초기에는 수동 입력
7.3.2. Cargo
  • 동일한 외관의 화물 상자도 서로 구별되어야 함 → ==ENTITY==
  • 식별자: 자동 생성 추적 ID (예약 시 고객에게 전달)
7.3.3. 처리 이벤트와 운송 이동
  • 개별 사건으로서 현실 세계의 사건을 반영 → 서로 교체 불가 → ==ENTITY==
  • CarrierMovement 식별자: 운송 일정에서 가져온 코드
  • HandlingEvent 식별자: Cargo ID + 완료 시간 + 이벤트 타입 조합
    • 같은 Cargo가 동시에 적재와 하역을 동시에 할 수 없기 때문에 이 조합이 유일성 보장
7.3.4. 위치
  • 같은 이름의 장소도 실제로 다르다 → ==ENTITY==
  • 위도/경도는 이론적 식별자이지만 실용적이지 않음
  • 도메인 특화 지리 모델의 일부가 될 것이므로 자동 생성 내부 식별자로 충분
7.3.5. 배달 이력
  • 서로 교체 불가 → ENTITY이지만, Cargo와 1:1 관계
  • ==자체적인 식별성이 없고 소유 Cargo에서 식별성을 빌려온다==
  • AGGREGATE 경계 모델링 시 이 구조가 명확해진다
7.3.6. 배달 명세
  • Cargo에 종속되지 않는 추상화 — DeliveryHistory의 가상 상태를 표현
  • 같은 목적지를 가진 두 Cargo는 동일한 DeliverySpecification을 공유할 수 있지만, 동일한 DeliveryHistory는 공유 불가 (이력은 각 Cargo마다 고유)
  • → ==VALUE OBJECT==
7.3.7. 역할과 기타 속성
  • Role: 연관관계를 한정하는 속성; 이력이나 연속성 없음 → ==VALUE OBJECT==
    • 서로 다른 Cargo/Customer 연관 간 공유 가능
  • 타임스탬프, 이름 등의 속성: VALUE OBJECT
분류 요약표
객체 분류 식별자 근거
Customer ENTITY 기존 고객 DB ID
Cargo ENTITY 자동 생성 추적 ID
HandlingEvent ENTITY CargoID + 완료시간 + 타입
CarrierMovement ENTITY 운송 일정 코드
Location ENTITY 자동 생성 내부 ID
DeliveryHistory ENTITY Cargo에서 차용
DeliverySpecification VALUE OBJECT
Role VALUE OBJECT

7.4. 해운 도메인의 연관관계 설계

초기 다이어그램에는 순회 방향이 없었다. 양방향 연관은 설계 복잡성을 높이므로 방향을 제한한다. 순회 방향 제한은 도메인에 대한 이해를 심화시킨다.

설계 결정과 근거
연관 방향 결정 이유
Customer → Cargo 단방향 제거 장기 고객은 수많은 Cargo 보유 → 부담; 대신 Repository 쿼리로 조회
HandlingEvent → CarrierMovement 단방향 (Event→Movement) 비즈니스 목적은 Cargo 추적; 반대 방향은 불필요 (다수 방향 제거로 단순 객체 참조 가능)
Cargo ↔ DeliveryHistory 양방향 유지 추적이 Cargo의 핵심이고, DeliveryHistory는 반드시 대상을 참조해야 함
DeliverySpecification → Cargo 단방향 제거 VALUE OBJECT는 소유자를 참조하지 않는 것이 원칙; 개념상 Delivery History에 더 가까움
Location 단방향 수신만 ENTITY가 기본적으로 많은 곳에서 사용되려면 자신의 사용자를 추적하는 부담 없어야 함
순환 참조 처리
  • 순환: CargoDeliveryHistoryHandlingEventCargo
  • 순환 참조는 많은 도메인에서 논리적으로 존재하지만 유지가 까다롭다
  • 초기 프로토타입 전략: DeliveryHistoryHandlingEvent 배열을 직접 보유
  • 이후 전략: 컬렉션 대신 Cargo를 키로 하는 DB 쿼리로 대체 (Repository 섹션 참조)
  • 조회가 드물다면 쿼리 방식이 성능·유지보수·오버헤드 면에서 유리

이 설계 트레이드오프는 모델 자체가 아닌 구현 선택이다. 모델은 순환과 양방향 연관을 그대로 유지한다.


7.5. 애그리게이트 경계

Aggregate Root 후보는 식별성과 독립적 의미를 가진 ENTITY들이다.

독립 Aggregate Root 결정
  • Customer, Location, CarrierMovement: 자신의 식별성 보유 + 다수의 Cargo가 공유 → 각각 자신의 Aggregate Root
  • Cargo: 명확한 Aggregate Root
Cargo Aggregate 경계 내부/외부 판단
객체 포함 여부 이유
DeliveryHistory Aggregate 내부 Cargo 없이 독립적으로 조회할 이유 없음; 식별성이 Cargo에서 파생됨
DeliverySpecification Aggregate 내부 VALUE OBJECT이므로 복잡성 없이 포함 가능
HandlingEvent 별도 Aggregate Root Cargo와 무관하게 의미를 가질 수 있음 (특정 CarrierMovement의 작업 조회 등); 저경합 트랜잭션에서 생성되어야 함
Aggregate 경계 다이어그램 (Figure 7.3)
classDiagram
    class Cargo {
        <<Aggregate Root>>
        +trackingID: string
    }
    class DeliveryHistory {
        <<내부 Entity>>
    }
    class DeliverySpecification {
        <<Value Object>>
        +arrivalTime: Date
    }
    class HandlingEvent {
        <<Aggregate Root>>
        +completionTime: Date
        +type: string
    }
    class Customer {
        <<Aggregate Root>>
        +name: string
        +customerID: string
    }
    class Location {
        <<Aggregate Root>>
        +portCode: string
    }
    class CarrierMovement {
        <<Aggregate Root>>
        +scheduleID: string
    }

    Cargo "1" *-- "1" DeliveryHistory : 경계 내 포함
    Cargo "1" *-- "1" DeliverySpecification : 경계 내 포함
    Cargo "*" --> Customer : role
    DeliveryHistory --> HandlingEvent : 참조
    HandlingEvent "*" --> "0..1" CarrierMovement : 참조
    CarrierMovement --> Location : from
    CarrierMovement --> Location : to
    DeliverySpecification --> Location : destination

HandlingEvent를 별도 Aggregate로 분리하는 두 번째 이유: HandlingEvent 생성은 Cargo를 잠그지 않아야 하는 저경합 운영 트랜잭션이다. 이를 Cargo Aggregate 내부에 두면 트랜잭션 충돌 가능성이 생긴다.


7.6. 리포지토리 선택

Repository는 오직 Aggregate Root에만 허용된다. 5개의 Aggregate Root 중 실제 애플리케이션 요구사항에 근거하여 선택한다.

선택된 Repository와 선택 이유
Repository 선택 이유
CustomerRepository 예약 시 역할별 Customer(운송인, 수취인 등) 선택 필요
LocationRepository 화물 목적지 Location 지정 필요
CarrierMovementRepository 화물 적재 대상 CarrierMovement 조회 필요
CargoRepository 적재된 Cargo 식별 필요
HandlingEvent Repository — 초기에는 미포함
  • 초기 구현에서 DeliveryHistory를 컬렉션으로 구현하기로 결정했기 때문
  • CarrierMovement에 적재된 화물 조회 요구사항이 아직 없음
  • ==이 두 이유 중 하나라도 변경되면 HandlingEvent Repository를 추가하면 된다==
Repository 조회 메서드 예시 (Figure 7.4)
  • CustomerRepository.findByCustomerID(id: string)
  • CustomerRepository.findByName(name: string)
  • CustomerRepository.findByCargoTrackingID(id: string)
  • CargoRepository.findByTrackingID(id: string)
  • CargoRepository.findByCustomerID(id: string)
  • LocationRepository.findByPortCode(code: string)
  • LocationRepository.findByCityName(name: string)
  • CarrierMovementRepository.findByScheduleID(id: string)
  • CarrierMovementRepository.findByFromTo(from: Location, to: Location)

7.7. 시나리오 검토

모든 설계 결정을 검증하기 위해 실제 애플리케이션 문제를 시나리오로 단계별로 추적한다.

7.7.1. 애플리케이션 기능 예시: 화물 목적지 변경

상황: 고객이 “허캔색이 아니라 호보큰으로 보내야 한다”고 연락

해결 방식:

  • DeliverySpecificationVALUE OBJECT이므로
    1. 기존 DeliverySpecification 폐기
    2. DeliverySpecification 생성
    3. Cargo의 setter로 교체
cargo.setDeliverySpecification(
  new DeliverySpecification(newDestination, arrivalDate)
);
  • 핵심: VALUE OBJECT이기 때문에 교체가 단순하다. 이것이 VALUE OBJECT 분리의 실질적 이점이다.
7.7.2. 애플리케이션 기능 예시: 반복 예약

상황: 동일 고객의 반복 예약은 패턴이 유사하므로 기존 Cargo를 프로토타입으로 사용하고 싶다

접근법: PROTOTYPE 패턴 적용 (Gamma et al. 1995)

Cargo 복사 시 Aggregate 경계 내 각 요소 처리:

요소 처리 방식 이유
DeliveryHistory 새 빈 객체 생성 이전 이력은 새 화물과 무관 (내부 ENTITY의 일반적 처리)
CustomerRoles (Map) 컬렉션 복사 (키 포함), Customer 객체 자체는 복사 안 함 같은 역할일 가능성 높음; Customer는 Aggregate 외부 ENTITY이므로 참조만 유지
trackingID 새 ID 발급 기존 Cargo와 구별되어야 함
DeliverySpecification 동일 인스턴스 공유 가능 VALUE OBJECT이므로 공유해도 안전

==핵심 원칙: Aggregate 경계 내부는 복사, 경계 외부 ENTITY는 동일한 참조를 유지하고, Aggregate 경계 밖은 전혀 영향을 주지 않는다.==


7.8. 객체 생성

7.8.1. 화물을 위한 팩토리와 생성자

팩토리가 있더라도 원시 생성자는 반드시 필요하다. ENTITY 생성자는 최소한 식별성이 온전한 객체를 만들어야 한다.

팩토리 옵션 3가지 (TypeScript)

// 옵션 1: Cargo 클래스 내 팩토리 메서드
class Cargo {
  copyPrototype(newTrackingID: string): Cargo { /* ... */ }
}

// 옵션 2: 독립 팩토리 (명시적 ID 전달)
function newCargo(prototype: Cargo, newTrackingID: string): Cargo { /* ... */ }

// 옵션 3: 독립 팩토리 (ID 자동 생성 캡슐화)
function newCargo(prototype: Cargo): Cargo { /* ... */ }

어떤 팩토리든 결과는 동일: ==빈 DeliveryHistory와 null DeliverySpecification을 가진 Cargo==

Cargo-DeliveryHistory 상호 참조 처리:

  • 두 객체는 서로를 가리켜야 완전하므로 반드시 함께 생성되어야 한다
  • Cargo가 Aggregate Root이므로 Cargo 생성자(또는 팩토리)가 DeliveryHistory를 생성한다
  • DeliveryHistory 생성자는 Cargo를 인자로 받는다
class Cargo {
  private trackingID: string;
  private deliveryHistory: DeliveryHistory;
  private customerRoles: Map<string, Customer>;

  constructor(id: string) {
    this.trackingID = id;
    this.deliveryHistory = new DeliveryHistory(this); // DeliveryHistory가 Cargo를 역참조
    this.customerRoles = new Map();
  }
}
  • DeliveryHistory 생성자는 오직 Aggregate Root(Cargo)만 사용 → Cargo 구성이 캡슐화됨
7.8.2. 처리 이벤트 추가

기본 생성자 (ENTITY 식별 속성 필수 포함):

class HandlingEvent {
  private handled: Cargo;
  private type: string;
  private completionTime: Date;
  private carrierMovement?: CarrierMovement;

  constructor(cargo: Cargo, eventType: string, timeStamp: Date) {
    this.handled = cargo;
    this.type = eventType;
    this.completionTime = timeStamp;
  }
}

이벤트 타입별 FACTORY METHOD:

  • 비식별 속성은 나중에 추가 가능하지만, HandlingEvent의 모든 속성은 초기 트랜잭션에서 설정되고 이후 불변
  • 클라이언트가 구현 세부사항을 알 필요 없도록 타입별 팩토리 메서드 제공
class HandlingEvent {
  static readonly LOADING_EVENT = 'LOADING_EVENT';

  static newLoading(
    cargo: Cargo,
    loadedOnto: CarrierMovement,
    timeStamp: Date
  ): HandlingEvent {
    const result = new HandlingEvent(cargo, HandlingEvent.LOADING_EVENT, timeStamp);
    result.carrierMovement = loadedOnto;
    return result;
  }
}
  • HandlingEvent는 다양한 하위 클래스(적재, 하역, 봉인, 보관 등)로 확장될 수 있다
  • FACTORY METHOD가 어떤 클래스를 인스턴스화할지와 초기화 방법을 담당 → 클라이언트는 해방

순환 참조로 인한 생성 복잡성 문제 (Figure 7.5):

EventLoggingApplication
  → new HandlingEvent(cargo)
  → cargo.getHistory()    ← Cargo Aggregate에 접근
  → deliveryHistory.add(handlingEvent)
  ← 트랜잭션 충돌 가능 (다른 사용자가 Cargo 수정 중이면 실패)
  • 백포인터(DeliveryHistory에 HandlingEvent 추가)는 팩토리 내에 캡슐화할 수 있지만
  • ==더 나은 방법은 이 어색한 상호작용 자체를 제거하는 대안 설계==

7.9. 리팩토링: 화물 애그리게이트의 대안 설계

모델링과 설계는 계속 앞으로만 나아가는 과정이 아니다. 새로운 통찰을 반영하여 자주 리팩토링해야 한다.

문제 인식
  • HandlingEvent 추가 시 DeliveryHistory 컬렉션을 업데이트해야 함 → Cargo Aggregate가 트랜잭션에 관여됨
  • 다른 사용자가 Cargo를 수정 중이면 HandlingEvent 트랜잭션 실패 또는 지연
  • HandlingEvent 입력은 빠르고 단순해야 하는 운영 작업 → 중요한 요구사항 충족 실패
해결책: 컬렉션을 쿼리로 교체
  • DeliveryHistoryHandlingEvent 컬렉션 → Repository 쿼리로 대체
  • HandlingEvent 생성이 Cargo Aggregate 내부를 전혀 수정하지 않음
HandlingEvent Repository 추가
interface HandlingEventRepository {
  // 특정 Cargo의 모든 이벤트 조회
  findByCargoidDTypeTime(cargoId: string, date: Date, type: string): HandlingEvent;
  
  // 특정 Cargo의 추적 ID로 조회
  findByCargoTrackingID(trackingId: string): HandlingEvent[];
  
  // 특정 운송 일정 ID로 조회
  findByScheduleID(scheduleId: string): HandlingEvent[];
  
  // 가장 최근 이벤트 조회 (현재 위치 추론용)
  findMostRecentByCargoIDType(cargoId: string, type: string): HandlingEvent;
}
변경 후 효과 (Figure 7.6)
  • DeliveryHistory: 영구 상태 없음 → 필요할 때마다 동적으로 파생
  • Cargo Factory: 빈 DeliveryHistory 생성 불필요로 단순화
  • DB 공간 감소, 영구 객체 수 상당히 감소 (객체 DB에서 중요)
  • 사용자가 화물 도착 전에는 상태를 거의 조회하지 않는다면 불필요한 작업 대폭 감소
트레이드오프 분석
방식 장점 단점 적합한 상황
컬렉션 빠른 접근(객체 DB 순회), 전체 이력 빈번 조회에 유리 경합, 순환 참조 복잡성 조회 빈도 높음, 객체 DB 사용
Repository 쿼리 경합 없음, 추가 단순, 순환 참조 해소 빈번한 전체 이력 조회 시 성능 저하 추가 빈도 높음, 조회 드묾, RDB 사용

==핵심 인사이트: 모델은 동일하다. VALUES, ENTITIES, AGGREGATES로 올바르게 모델링했기 때문에 이런 설계 변경의 영향이 최소화된다. 모든 변경이 Cargo의 Aggregate 경계 내에 캡슐화되며, HandlingEvent 자체의 재설계는 필요 없다.==


7.10. 해운 모델의 모듈

잘못된 모듈화: 패턴별 분류 (Figure 7.7)
잘못된 예:
├── entities/        (Customer, Cargo, Location, ...)
├── value-objects/   (DeliverySpecification, Role, ...)
├── repositories/    (CargoRepository, ...)
└── services/        (...)

문제점:

  • 개념적으로 무관한 객체가 한 모듈에 묶임 → 낮은 응집도
  • 모든 모듈 간에 연관이 뻗어 나감 → 높은 결합도
  • ==패키지가 도메인의 이야기가 아니라 개발자가 읽던 책의 내용을 반영함==
  • 패턴별 분류가 명백한 오류처럼 보이지만, 영구 객체/일시적 객체 분리 등 다른 기계적 분류도 마찬가지로 근거가 없다
올바른 모듈화: 도메인 개념별 분류 (Figure 7.8)
올바른 예:
├── customer/        (Customer, Contact, CustomerAgreement: PricingModel)
├── billing/         (Invoice, PricingModel, Money, Currency)
└── shipping/        (Cargo, RouteSpecification, Itinerary, Leg,
                      HandlingStep, Equipment, TransportSchedule,
                      Location, Router, EquipmentInventory)
모듈명이 팀 언어에 기여하는 방식
  • 회사는 customer를 위해 shipping을 하고 billing을 한다
  • 영업/마케팅팀은 customer를 담당하고 계약을 체결한다
  • 운영팀은 화물을 목적지까지 shipping한다
  • 백오피스는 계약 가격에 따라 billing을 처리한다

모듈명이 UBIQUITOUS LANGUAGE의 일부가 되고 MODEL-DRIVEN DESIGN을 강화한다. 이 분류는 반복적으로 개선될 수 있다.


7.11. 새 기능 도입: 할당량 확인

배경:

  • 영업 부서가 별도의 Sales Management System 사용
  • Yield Management 기능: 화물 유형·출발지·목적지 등 카테고리별로 예약 가능 수량 배정
  • 목적: 수익성 높은 화물이 낮은 수익 화물에 밀리는 것 방지
  • 과소예약(용량 낭비)과 과다예약(고객 관계 악화) 균형

새 요구사항: 예약 접수 시 할당량 대비 자동 검토

정보 흐름 (Figure 7.9):

  • BookingApplication이 두 곳에 질의:
    1. “이 유형의 Cargo가 얼마나 예약되었나?” → CargoRepository
    2. “이 유형의 Cargo를 얼마나 예약할 수 있나?” → Sales Management System
7.11.1. 두 시스템 연결

문제:

  • Sales Management System은 우리 도메인 모델과 다른 모델로 설계됨
  • 직접 연동 시 MODEL-DRIVEN DESIGN 손상, UBIQUITOUS LANGUAGE 혼란

해결: ANTICORRUPTION LAYER (Chapter 14)

  • 두 시스템 사이에 번역 클래스를 만든다
  • 범용 번역 메커니즘이 아닌, 우리 애플리케이션이 필요한 기능만 노출하고 우리 도메인 모델의 언어로 재추상화

명명 전략:

  • SalesManagementInterface (X) — 상대방 시스템을 기준으로 한 기계적 이름
  • AllocationChecker (O) — ==우리 시스템에서의 책임을 기준으로 한 이름==

아키텍처 구성:

flowchart TD
    BA[Booking Application] --> CR[Cargo Repository]
    BA --> AC[Allocation Checker]
    AC --> SMS[Sales Management System]
    
    note1["도메인 레이어"]
    note2["ANTICORRUPTION LAYER"]
    note3["외부 시스템"]
  • 더 낮은 수준의 SalesManagementSystemInterface로 통신 기계장치를 처리할 수 있지만, 이는 도메인 설계에 노출되지 않고 AllocationChecker 뒤에 숨겨진다
7.11.2. 모델 강화: 비즈니스 세분화

문제: Cargo의 “유형”이 우리 도메인 모델에 없음

  • Sales Management System은 카테고리 키워드 문자열 목록 사용
  • 단순히 string[]을 인자로 넘기는 것은 도메인 재추상화 기회를 놓치는 것

해결: Enterprise Segment 패턴 도입

  • 출처: Analysis Patterns (Fowler, 1996)
  • EnterpriseSegment: 비즈니스를 분류하는 차원의 집합 (상품 유형, 출발지/목적지, 월 누적 등)
  • 우리 도메인 모델에 VALUE OBJECT로 추가
// VALUE OBJECT
class EnterpriseSegment {
  readonly origin: string;
  readonly destination: string;
  readonly cargoType: string;
  // 비즈니스 전략에 따라 추가 차원 포함 가능

  constructor(origin: string, destination: string, cargoType: string) {
    this.origin = origin;
    this.destination = destination;
    this.cargoType = cargoType;
  }
}

초기 설계의 두 가지 문제점:

  1. BookingApplication이 예약 허용 규칙을 직접 적용 → 도메인 로직이 애플리케이션 레이어에 있음
    • 규칙: “할당량 > 이미 예약된 양 + 신규 Cargo 크기이면 수락”
  2. BookingApplicationEnterpriseSegment를 도출하는 방법이 불명확

개선: 두 책임을 AllocationChecker로 이전 (Figure 7.11)

interface AllocationChecker {
  // 1단계: Cargo의 EnterpriseSegment 도출
  deriveEnterpriseSegment(cargo: Cargo): EnterpriseSegment;
  
  // 3단계: 예약 허용 여부 판단 (비즈니스 규칙 포함)
  mayAccept(cargo: Cargo, quantityBooked: number): boolean;
}

// BookingApplication의 역할 축소
class BookingApplication {
  async attemptBooking(cargo: Cargo): Promise<boolean> {
    // 1. 세그먼트 도출
    const segment = this.allocationChecker.deriveEnterpriseSegment(cargo);
    // 2. 이미 예약된 양 조회
    const quantityBooked = this.cargoRepository.quantityBooked(segment);
    // 3. 허용 여부 판단 (규칙은 도메인 레이어에)
    return this.allocationChecker.mayAccept(cargo, quantityBooked);
  }
}

AllocationChecker의 역할:

  • EnterpriseSegment와 외부 시스템의 카테고리 이름 간 번역
  • Sales Management System 호출 캡슐화
  • 예약 허용 비즈니스 규칙 적용

CargoRepository의 역할:

  • EnterpriseSegment를 기반으로 한 카운트 쿼리 제공 (인스턴스 컬렉션이 아님)
7.11.3. 성능 튜닝

분산 환경에서의 문제:

  • Sales Management System이 원격 서버에 위치하면 통신 오버헤드 발생
  • 할당량 확인당 메시지 교환 2회 발생
메시지 성격 최적화 가능 여부
deriveEnterpriseSegment 호출 비교적 정적인 데이터(도메인 규칙) 기반 캐싱 가능
실제 할당 결정 호출 Sales Management System의 동적 판단 대안 없음

최적화 전략:

  • EnterpriseSegment 도출 정보를 AllocationChecker 서버에 캐싱
  • 메시지 교환 1회로 감소

트레이드오프:

  • 설계 복잡도 증가
  • 캐시된 중복 데이터 동기화 문제 발생
  • 성능이 중요한 분산 시스템에서는 유연한 배포가 중요한 설계 목표가 된다

7.12. 최종 검토

통합 결과
  • ANTICORRUPTION LAYER (AllocationChecker)
  • SERVICE (할당량 확인 기능)
  • ENTERPRISE SEGMENT (VALUE OBJECT, 비즈니스 세분화 표현)

위 세 가지를 활용하여 Sales Management System을 깔끔하게 통합하고 도메인을 풍부하게 만들었다.

왜 Cargo에 EnterpriseSegment 도출 책임을 주지 않았는가?

표면적으로 매력적인 이유:

  • 도출에 필요한 데이터가 모두 Cargo에 있으므로 파생 속성으로 만들면 우아해 보임

실제 문제:

  • EnterpriseSegment비즈니스 전략에 따라 임의로 정의됨
  • 동일한 ENTITY가 목적에 따라 다르게 세분화될 수 있음
    • 예약 할당 목적: A 세그먼트
    • 세금 회계 목적: B 세그먼트
  • Sales Management System 재설정 시 할당 세그먼트 기준 변경 가능
  • Cargo가 AllocationChecker를 알아야 한다면 Cargo의 개념적 책임 범위 초과
  • Cargo가 세그먼트 도출 메서드들로 가득 차게 됨

==원칙: 세분화 규칙 도출 책임은 “데이터를 보유한 객체”가 아니라 **“규칙을 아는 객체”**에 있다.==

향후 개선 가능 방향:

  • 규칙을 별도 Strategy 객체로 분리하여 Cargo에 주입하는 방식
interface SegmentationStrategy {
  deriveSegment(cargo: Cargo): EnterpriseSegment;
}

class Cargo {
  deriveEnterpriseSegment(strategy: SegmentationStrategy): EnterpriseSegment {
    return strategy.deriveSegment(this);
  }
}
  • 현재 요구사항을 초과하지만, 향후 옵션으로 두되 큰 변화 없이 도입 가능

핵심 인사이트 요약

원칙 이 챕터에서 드러난 방식
VALUE OBJECT의 교체 용이성 목적지 변경이 DeliverySpecification 인스턴스 교체로 단순 해결
Aggregate 경계의 복사 규칙 반복 예약 시 경계 내 복사, 경계 외 참조 유지
저경합 트랜잭션을 위한 설계 HandlingEvent를 별도 Aggregate로 분리하여 경합 제거
모듈은 도메인 개념 기반 패턴별 분류 대신 비즈니스 개념(Customer, Shipping, Billing)별 분류
ANTICORRUPTION LAYER 외부 시스템을 우리 모델의 언어로 재추상화
책임 배치 원칙 규칙을 아는 객체에 책임 부여; 데이터 보유 객체와 반드시 일치하지 않음
설계 트레이드오프는 모델 내에서 컬렉션 vs 쿼리 선택이 모델을 바꾸지 않고 구현 수준에서 결정됨