전편에서 추상화란 현실의 대상에서 목적에 필요한 것만 골라내는 일이고, 그 결과를 타입으로 써내는 순간 데이터에 대한 기술(메타)이 된다고 정리했다. 그런데 골라낸 것이 코드가 되기까지의 사이에는 생각보다 넓은 간격이 있다. 골라낸 것을 업무의 언어로 정리한 결과물이 모델 이고, 모델을 코드나 스키마 같은 구체적인 형식으로 옮겨 적는 일이 표현 이다. 이 글은 “현실에서 소프트웨어로 넘어가기 위해 알아야 하는 것들”의 두 번째 글이다. 추상과 메타를 다룬 첫 번째 글이 생각하는 방법에 대한 것이었다면, 이번에는 생각을 소프트웨어로 옮기는 방법을 다룬다. 모델이라는 말이 실제로는 세 가지 다른 것을 가리킨다는 데서 출발해, 모델을 발견하는 도구들, 모델이 코드가 될 때 무너지기 쉬운 지점과 그 처방, 도메인 모델을 이루는 어휘와 흔한 냄새들, 그리고 같은 모델을 옮겨 적는 표현의 선택지를 살펴본다.
모델이라는 말이 가리키는 세 가지
마틴 파울러는 데이터베이스 설계에서 논리 설계와 물리 설계를 구분하듯, 모델에도 목적이 다른 세 수준의 구분이 필요하다고 말한다. 개념 모델 은 실세계에서 관심 있는 업무 상황을 기술한다. Domain Storytelling이나 Big Picture EventStorming처럼, 시스템이 다루든 말든 현재의 업무를 그려내는 활동이 여기에 해당한다. 사양 모델 은 소프트웨어가 무엇을 해야 하고 어떤 정보를 유지해야 하는지를 정의한다. 구현 모델 은 프로그래밍 언어와 실행 환경의 제약까지 반영해 소프트웨어가 실제로 어떻게 만들어지는지를 기술한다.
도메인 주도 설계는 이 셋을 의도적으로 하나의 연속체로 다뤘다. 업무 이해에서 코드까지 하나의 모델을 관통시키는 것이 목표였기 때문이다. 그 덕에 도메인 모델이라는 말은 널리 퍼졌지만, 그 말이 개념·사양·구현 중 무엇을 가리키는지는 모호해졌다. 실무에서 도메인 주도 설계가 구현 단계에 와서야 동원되는 경우도 많은데, 그러면 모델은 구현 언어의 제약을 강하게 받으면서 업무 이해와의 거리가 벌어진다.
수준을 구분하면 이 벌어짐이 눈에 보인다. 메일 발송 기능의 사양 모델을 데이터와 행동으로 써 보자.
data 메일주소 = 미인증 메일주소 OR 인증된 메일주소
behavior 메일을 보낸다 = 인증된 메일주소 AND 본문 -> 발송 이벤트구현 모델로 넘어가면 이것이 흔히 다음과 같은 형태가 된다.
record EmailAddress(String value, boolean isActivated) {}사양에는 없던 isActivated라는 플래그가 등장했다. 미인증과 인증이라는 별개의 개념이 필드 하나로 눌려 담기면서 생긴 산물이다. 반대 방향의 균열도 있다. 확정 전 주문과 확정된 주문, 발송된 주문은 사양 수준에서 가질 수 있는 값도 할 수 있는 일도 다 른 별개의 개념이지만, 구현에서는 orderStatus 필드 하나를 품은 Order 클래스 하나로 합쳐지기 쉽다. 상태별 규칙은 코드 어딘가에 흩어져 구현될지 몰라도 모델 위에서는 보이지 않게 된다. 전편의 기준으로 말하면, 공통의 데이터와 행동을 목적을 가지고 골라낸 것이 아니므로 이것은 추상이 아니라 그냥 뭉쳐진 덩어리다.
모델을 어떻게 발견하는가
세 수준을 나눴으니, 각 수준을 채울 내용은 어디서 얻는지가 다음 질문이 된다. 모델은 책상에서 발명되는 것이 아니라 업무를 아는 사람들에게서 발견된다. 그 원형은 업무 담당자 인터뷰이고, 아래의 기법들은 인터뷰가 흐르기 쉬운 잡담이 되지 않도록 형식을 입힌 것이다.
개념 수준에서 쓰는 도구가 앞 절에서 이름만 스친 EventStorming과 Domain Storytelling이다. EventStorming은 업무 담당자와 개발자가 한자리에 모여 “주문이 확정되었다” 같은 업무 사건을 시간순으로 벽에 붙여 가며 업무 전체의 흐름을 드러낸다. Domain Storytelling은 담당자가 들려주는 구체적인 업무 사례를 “누가, 무엇을, 무엇으로 하는가”의 그림 언어로 기록한다. 두 도구 모두 산출물 못지않게 과정이 중요하다. 그리는 동안 참여자들이 같은 용어로 말하기 시작하므로, 다음 절에서 다룰 유비쿼터스 언어가 이 자리에서 만들어진다.
사양 수준으로 내려오면 규칙을 캐내는 도구가 필요하다. Example Mapping은 “주말 배송 불가” 같은 규칙 하나를 놓고 그 규칙이 성립하는 예와 성립하지 않는 예를 카드로 모은다. 예를 모으다 보면 “공휴일은 어떻게 되는가”, “도서 지역도 주말로 치는가” 같은 질문이 나오면서 규칙의 정확한 경계가 드러나고, 이렇게 캐낸 규칙이 뒤에서 다룰 타입의 불변조건이 된다.
Event Modeling은 시스템의 동작을 시간축 위에 커맨드(하려는 일), 이벤트(일어난 사실), 뷰(보여줄 것)로 배열해 사양을 그린다. 상태 대신 사건을 중심에 두는 설계라서, 표현 절에서 다룰 이벤트 소싱과 자연스럽게 이어진다.
발견한 개념들에 책임을 나눠 주는 단계에는 CRC 카드라는 오래된 도구가 있다. 카드 한 장에 개념의 이름과 책임, 협력자를 적어 가며 “이 일은 누구의 책임인가”를 손으로 따져 본다. 엄밀히는 발견보다 배분의 도구이지만, 한 카드에 책임이 몰리는 것이 보이면 그것이 개념을 쪼갤 신호가 되므로 발견과 맞물려 돈다.
발견은 킥오프 워크숍 한 번으로 끝나는 행사가 아닌데, 그 이유는 글의 마지막 절에서 다룬다.
모델링과 인코딩이라는 두 작업
이 세 수준 사이의 이동에는 성격이 다른 두 작업이 있다. 업무가 본래 지닌 본질적 복잡성을 어떻게 다룰지 생각하는 일이 모델링 이고, 언어·프레임워크·저장소 같은 도구가 강요하는 우연적 복잡성을 어떻게 다룰지 생각하는 일이 인코딩 이다. 전편에서 추상화라는 설계 판단과 메타화라는 기술 작업이 소프트웨어에서는 거의 동시에 일어난다고 했는데, 모델링과 인코딩도 같은 이유로 뒤섞인다. 그래서 코드가 이상할 때 물어야 할 질문이 두 개로 갈라진다. 업무를 잘못 잘랐는가(모델링의 문제), 아니면 잘 자른 것을 도구의 사정에 맞추다 비틀었는가(인코딩의 문제). 앞의 isActivated 플래그는 인코딩이 모델을 침식한 예이고, 화면의 입력란을 그대로 타입으로 만든 구조는 모델링 자체가 없었던 예다.
인코딩의 침식은 필드 하나에서 끝나지 않는다. 많은 프로젝트에서 테이블이 먼저 만들어지고, 그 테이블을 그대로 본뜬 클래스가 생기고, 그 클래스를 복사한 DTO가 API가 되고, API가 화면이 된다. 이 연쇄가 완성되면 시스템의 모든 층이 저장을 위해 설계된 구조를 따라간다. 데이터베이스는 저장에 맞게 설계된 표현이지 업무 개념을 표현하려고 설계된 것이 아닌데도, 컬럼이 늘면 모델이 늘고 화면이 바뀌면 도메인이 바뀐다. 모델이 표현에 끌려다니는 것이다. 마틴 파울러가 프레젠테이션·도메인·데이터의 분리를 계층화의 기본으로 꼽는 근거가 여기에 있다. 하나의 도메인 로직 위에는 웹, 모바일, API처럼 여러 프레젠테이션이 얹힐 수 있으므로, 화면 하나의 사정이 모델에 스며들면 그 변경은 모델을 공유하는 나머지 표현 전부로 번진다.
한편 모델링이 잘못됐다는 신호는 코드에서 발견할 수 있다. 행동의 이름이 그 하나다. 좋은 행동의 이름은 하나의 책임을 표현한다. 청구서를 계산하고 갱신한다는 식으로 동사 두 개가 들어간 이름이 필요해졌다면, 그 행동에는 이미 두 개의 책임이 섞여 있을 가능성이 높다. 입력의 크기도 신호다. 고객 객체 전체를 받아 놓고 실제로는 이름 하나만 쓰는 행동은 필요한 것보다 큰 덩어리에 결합해 있다(이런 결합을 스탬프 결합이라 부른다). 행동이 정말 필요로 하는 입력만 받도록 서명을 좁히는 것은 구현 기교가 아니라, 그 행동이 업무에서 무엇에 의존하는지를 모델 수준에서 다시 묻는 일이다.
이름의 문제는 행동에 그치지 않는다. 회원, 사용자, 고객, 계정이 같은 개념인지 서로 다른 개념인지가 팀 안에서 정리되어 있지 않으면, 같은 코드를 두고도 사람마다 다른 현실을 떠올린다. 좋은 모델이 만들어지면 이 언어도 함께 정리된다. 도메인 주도 설계가 유비쿼터스 언어 라는 이름으로 업무 전문가와 개발자가 같은 용어를 쓰라고 요구하는 이유가 이것이다. 개념의 이름을 고 르는 일은 네이밍 취향의 문제가 아니라, 팀이 같은 현실을 같은 방식으로 이해하고 있는지 확인하는 일이다.
코드가 된 모델이 앓는 두 가지 병
구현 모델에서 반복적으로 나타나는 병이 두 가지 있다. 첫 번째는 마틴 파울러가 이름 붙인 도메인 모델 빈혈증 으로, 도메인의 데이터를 본뜬 클래스를 만들면서 getter와 setter만 두는 상태를 가리킨다.
class Task {
Long id;
TaskStatus status;
LocalDate dueDate;
int postponeCount;
// 모든 필드의 getter와 setter
}이 Task의 마감일을 어떻게 바꾸든, 상태를 어떻게 뒤집든 사용하는 쪽의 자유다. 하지만 업무에는 규칙이 있다. 연기는 세 번까지만 가능하고 연기할 때마다 마감일이 하루 늘어난다고 하자. 이 지식이 도메인 객체에 없으면 그것을 사용하는 모든 곳이 각자 규칙을 기억해야 한다. 그래서 흔한 처방은 행동을 객체 안으로 옮기는 것이다.
class Task {
static final int POSTPONE_MAX = 3;
// 필드 생략
void postpone() {
if (postponeCount >= POSTPONE_MAX) {
throw new IllegalStateException("최대 연기 횟수를 초과했다");
}
dueDate = dueDate.plusDays(1);
postponeCount++;
}
void done() {
status = TaskStatus.DONE;
}
}빈혈증 설명이 여기서 끝나는 경우가 많지만, 이 코드에는 아직 병이 남아 있다. 완료된 Task에도 postpone과 done을 부를 수 있다. 메서드 안에서 상태를 검사해 예외를 던지면 되지 않느냐고 생각하기 쉽지만, 문제의 뿌리는 검사의 누락이 아니라 가질 수 있는 값과 할 수 있는 일이 다른 두 개념(미완료 태스크와 완료 태스크)을 Task라는 하나의 타입으로 표현한 데 있다. 그래서 타입을 나눈다.
sealed interface Task permits UndoneTask, DoneTask {}
record UndoneTask(TaskId id, LocalDate dueDate, int postponeCount) implements Task {
static final int POSTPONE_MAX = 3;
UndoneTask postpone() {
if (postponeCount >= POSTPONE_MAX) {
throw new IllegalStateException("최대 연기 횟수를 초과했다");
}
return new UndoneTask(id, dueDate.plusDays(1), postponeCount + 1);
}
DoneTask done() {
return new DoneTask(id, LocalDate.now());
}
}
record DoneTask(TaskId id, LocalDate doneDate) implements Task {}완료된 태스크에는 postpone이라는 행동 자체가 존재하지 않으므로 사용하는 쪽이 실수할 여지가 컴파일 시점에 사라지고, 타입 자체가 업무 지식이 된다. 연기 횟수가 남은 태스크와 소진된 태스크를 다시 타입으로 나누는 데까지 밀어붙일 수도 있다. 덧붙이면 postpone과 done이 record의 메서드여야 하는 것도 아니다. 미완료 태스크를 받아 완료 태스크를 돌려주는 함수가 도메인 층 어딘가에 있으면 충분하고, 함수형 언어로 도메인을 모델링할 때는 실제로 그렇게 쓴다. 빈혈증 여부를 가르는 것은 메서드의 소유가 아니라 값과 행동이 다른 데이터를 다른 모델로 정의했는가이다.
두 번째 병은 생성 시점에 있다. 도메인 객체는 만들어지는 순간부터 올바른 상태여야 한다는 원칙을 Always-Valid Domain Model 이라 부른다. 마이너스 금액의 돈이 만들어질 수 있고 그것이 올바른지를 사용하는 쪽이 검사해야 한다면, 검사를 잊은 곳 하나가 시스템 전체에 잘못된 값을 흘려보낸다. 검증 메서드를 객체에 두는 것으로는 부족하다. 그것을 호출할 책임이 여전히 사용하는 쪽에 남기 때문이다. 생성자가 잘못된 값을 거부하게 만들면 책임의 방향이 뒤집힌다.
record Money(BigDecimal amount) {
Money {
if (amount.signum() < 0) {
throw new IllegalArgumentException("금액은 음수일 수 없다");
}
}
}이 관점을 입력 경계까지 밀고 가면 “검증하지 말고 파싱하라”는 원칙이 된다. 외부에서 들어온 문자열을 검증 함수로 확인만 하고 문자열인 채로 흘려보내는 대신, 경계에서 도메인 타입으로 변환해 버리고 내부에서는 그 타입만 다루는 것이다. 검증은 통과 여부라는 정보를 그 자리에서 소비해 버리지만, 파싱은 그 정보를 타입에 실어 이후의 모든 코드에 전달한다. 메일 주소 예로 돌아가면, 미인증 메일주소와 인증된 메일주소를 별개의 타입으로 두고 발송 함수가 인증된 타입만 받게 하면, 미인증 주소로 발송하는 실수는 코드로 쓸 수조차 없게 된다.
두 병의 처방은 결국 같다. 업무 규칙을 사용하는 쪽의 주의력에 맡기지 말고 타입에 맡긴다. 잘못된 상태가 표현될 수 없게 타입을 설계할수록, 규칙 위반은 시스템 깊숙이 들어가기 전에, 이르면 컴파일 시점에 걸러진다.
도메인 모델의 구성 요소
구현 모델의 논의를 더 진행하기 전에, 도메인 주도 설계가 쓰는 기본 어휘를 정리해 두자. 일부는 새 개념이 아니라, Task와 Money처럼 앞의 예제에 이미 등장한 것에 이름을 붙이는 일이다.
- 엔티티: 식별자로 추적되는 객체다. Task에 TaskId를 둔 것은 마감일이 연기되고 상태가 완료로 바뀌어도 같은 태스크임을 식별자가 보장하기 때문이다. 엔티티의 동일성은 속성이 아니라 식별자가 정하며, 주문이나 회원처럼 생명주기를 따라 상태가 변하는 개념이 엔티티가 된다.
- Value Object(값 객체): 식별자가 없고 값으로만 비교되는 객체다. 앞의 Money가 그 예로, 금액이 같으면 같은 돈일 뿐 “어느 돈인가”를 물을 이유가 없다. 값 객체를 불변으로 설계하고 생성자가 잘못된 값을 거부하게 하면, Always-Valid를 지키는 최소 단위가 된다. 주소나 기간처럼 여러 속성이 모여 하나의 값을 이루는 개념도 값 객체로 묶는다.
- 애그리거트: 함께 지켜야 할 불변조건을 공유하는 엔티티와 값 객체를 하나로 묶은 단위다. 주문과 주문 항목은 “주문의 합계는 항목 합계와 같아야 한다”는 규칙을 공유하므로 한 애그리거트가 되고, 바깥에서는 대표 객체(애그리거트 루트)를 통해서만 접근한다. 규칙이 지켜지는 경계이자 트랜잭션의 경계다.
- 도메인 서비스: 어느 한 애그리거트의 책임으로 두기 어려운 업무 로직을 맡는 자리다. 여러 애그리거트에 걸친 조정이 대표적인 예로, 조정 과정에서 각 객체에 자기 몫의 일을 시키되 규 칙 자체는 각 객체가 지키게 한다.
- 도메인 이벤트: 도메인에서 일어난 사실을 알리는 불변 객체다. “주문이 확정되었다”처럼 과거 시제로 이름 짓고, 식별자와 발생 시각 같은 필수 정보만 담는다. 사실의 확정과 그에 따르는 부수효과를 분리하는 데 쓰인다.
여기까지가 모델 안쪽의 어휘라면, 모델이 미치는 범위를 정하는 어휘도 있다.
- 서브도메인: 비즈니스 전체를 문제 영역 단위로 나눈 것이다. 온라인 커머스라면 주문, 배송, 회원, 정산이 각각 서브도메인이 된다. 무엇을 해결해야 하는가에 대한 분할이다.
- 바운디드 컨텍스트: 하나의 모델과 유비쿼터스 언어가 유효한 경계다. 서브도메인이 문제 쪽의 분할이라면 바운디드 컨텍스트는 해결책 쪽의 경계로, 같은 상품이라도 주문 컨텍스트와 배송 컨텍스트에서는 다른 모델이 된다. 전편에서 배송·정산·재고 관점마다 주문의 타입을 따로 둔 것이 바로 컨텍스트마다 모델을 나눈 예다. 도메인 모델은 자신이 속한 컨텍스트 안에서만 유효하며, 이 경계를 무시하고 하나의 모델을 모든 곳에서 쓰려 할 때 모든 목적에 어중간한 거대 모델이 나온다.
애그리거트와 도메인 서비스, 도메인 이벤트가 왜 따로 필요한지는 다음 절에서 업무 규칙이 닫히는 단위를 따라가며 확인한다.

