도메인 주도 설계

실전 도메인 주도 설계 핸드북

문제 공간의 언어를 경계와 모델, 테스트 가능한 코드로 옮기는 단일 페이지 실전 가이드입니다.

업데이트 근거 자료

저자: Jongmin Chung · 원문: 이 한국어 문서

도메인 주도 설계(DDD)는 폴더 이름이나 계층 템플릿이 아니다. 팀이 해결하려는 문제를 같은 언어로 설명하고, 그 언어의 경계를 코드가 지키게 만드는 설계 방식이다. 이 문서는 전략적 설계와 전술적 패턴을 하나의 작업 흐름으로 연결한다.

문제 공간에서 시작하기

기능 목록부터 만들면 사용자의 목적보다 화면과 API가 먼저 굳는다. 먼저 사용자가 달성하려는 결과, 그 결과를 방해하는 제약, 실패가 발생했을 때 누가 어떤 결정을 내려야 하는지 기록한다. 도메인 전문가와 개발자가 같은 문장을 사용하지 못한다면 아직 모델을 만들 때가 아니다.

좋은 탐색 질문

  • 이 작업이 완료됐다고 누가 판단하는가?
  • 어떤 상태 전이는 되돌릴 수 없으며, 누가 승인하는가?
  • 같은 단어가 팀마다 다른 의미로 쓰이는가?
  • 시스템 밖에서 들어오는 값 중 신뢰할 수 없는 것은 무엇인가?

이 답에서 반복되는 명사보다 결정과 규칙을 나타내는 동사를 찾는다. 주문이라는 명사보다 주문을 확정한다, 결제를 승인한다, 재고를 예약한다가 모델의 책임을 더 잘 드러낸다.

보편 언어 만들기

보편 언어는 용어집만으로 완성되지 않는다. 회의, 이슈, 테스트 이름, API와 코드가 같은 표현을 쓸 때 비로소 작동한다. 이름을 정하면 다음 예시와 반례를 함께 남긴다.

용어의미포함하지 않는 것
예약제한된 자원을 일정 시간 다른 요청에서 제외한 상태결제 완료, 소유권 이전
확정필요한 검증과 승인이 끝나 더 이상 임의 변경할 수 없는 상태단순 저장
취소정책에 따라 확정 전후의 효과를 보상하는 명령데이터 행 삭제

용어가 충돌하면 하나를 억지로 통일하지 않는다. 서로 다른 모델이 필요하다는 신호일 수 있다.

경계 컨텍스트 찾기

경계 컨텍스트는 모델과 언어가 일관되게 유지되는 범위다. 조직도나 배포 단위와 반드시 같지 않다. 다음 변화 축을 관찰해 경계를 찾는다.

  1. 같은 데이터가 다른 규칙과 수명주기를 갖는다.
  2. 변경 이유와 배포 속도가 다르다.
  3. 오류를 처리하는 책임자가 다르다.
  4. 한쪽의 정확성이 다른 쪽의 가용성보다 중요하다.

컨텍스트 사이에는 관계를 명시한다. 직접 공유 모델은 가장 강한 결합이다. 가능하면 발행된 언어, 변환 계층, 이벤트 또는 명시적 API를 사용한다. 외부 모델을 내부 타입으로 바로 가져오지 말고 부패 방지 계층에서 검증하고 변환한다.

모델과 불변식

도메인 모델의 핵심은 데이터 보관이 아니라 불가능한 상태를 만들기 어렵게 하는 것이다. 필수값이 빠진 객체를 만든 뒤 setter로 완성하거나, validate() 호출을 호출자의 기억에 맡기지 않는다.

type ConfirmedOrder = Readonly<{
  kind: "confirmed";
  orderId: string;
  confirmedAt: Date;
}>;

type ConfirmationResult =
  | Readonly<{ kind: "accepted"; order: ConfirmedOrder }>
  | Readonly<{ kind: "rejected"; reason: "empty" | "already-confirmed" }>;

상태 조합은 여러 boolean보다 판별 가능한 union으로 표현한다. 복구 가능한 도메인 실패는 반환값으로, 프로그래밍 오류와 깨진 불변식은 빠른 실패로 구분한다.

엔티티와 값 객체

엔티티는 속성이 바뀌어도 식별성이 유지되는 대상이다. 값 객체는 속성 전체로 동일성을 판단하며 가능한 한 불변이어야 한다. 모든 테이블을 엔티티로 만들 필요는 없다.

값 객체가 주는 이점

  • 파싱과 검증을 생성 경계 한곳에 둔다.
  • 단위를 타입으로 구분해 잘못된 연산을 막는다.
  • 동등성 기준을 명확히 한다.
  • 직렬화 형식과 내부 표현을 분리한다.

Money, EmailAddress, DateRange처럼 규칙이 있는 값은 원시 타입보다 값 객체가 적합하다. 반대로 규칙 없는 표시 문자열까지 클래스로 감싸면 모델만 무거워진다.

애그리게이트와 트랜잭션 경계

애그리게이트는 한 트랜잭션에서 반드시 일관되어야 하는 객체 묶음이다. 루트만 외부에서 참조하고, 내부 상태 전이는 루트의 명령을 통해 수행한다. 애그리게이트는 화면 전체나 데이터베이스 조인 결과가 아니다.

경계를 작게 유지하려면 다음 기준을 사용한다.

  • 즉시 지켜야 하는 불변식만 같은 애그리게이트에 둔다.
  • 다른 애그리게이트는 객체 참조 대신 식별자로 연결한다.
  • 결과적 일관성이 허용되는 후속 작업은 도메인 이벤트로 분리한다.
  • 한 요청에서 여러 애그리게이트를 계속 수정한다면 경계 또는 유스케이스를 다시 검토한다.

리포지토리와 도메인 서비스

리포지토리는 애그리게이트 컬렉션처럼 보이는 저장 경계다. SQL이나 HTTP 응답을 그대로 반환하지 않는다. 저장 표현을 검증된 도메인 모델로 변환하고, 조회 실패와 부재를 호출자가 구분할 수 있게 한다.

도메인 서비스는 한 엔티티에 자연스럽게 속하지 않는 도메인 규칙에만 사용한다. 단순 CRUD, 포맷 변환, 시간 읽기를 모두 서비스로 만들면 행동이 다시 빈약해진다. I/O가 필요한 조정은 애플리케이션 서비스에, 순수한 정책은 도메인에 둔다.

이벤트와 통합

도메인 이벤트는 이미 일어난 비즈니스 사실을 과거형으로 표현한다. SendEmail은 명령이고 OrderConfirmed는 사건이다. 이벤트 스키마는 내부 클래스 덤프가 아니라 소비자와 맺는 계약이다.

통합에서는 중복 전달, 순서 변경, 지연, 부분 실패가 정상이라고 가정한다. 이벤트 ID와 멱등성 키를 두고, 발행과 상태 저장이 함께 보장되어야 하면 outbox 패턴을 고려한다. 재시도는 실패를 숨기는 장치가 아니라 관찰 가능한 정책이어야 한다.

애플리케이션 흐름

애플리케이션 계층은 유스케이스의 순서를 조정한다.

  1. 외부 입력을 파싱하고 인증·권한을 확인한다.
  2. 필요한 애그리게이트를 로드한다.
  3. 도메인 명령을 호출한다.
  4. 변경과 이벤트를 저장한다.
  5. 결과를 외부 표현으로 변환한다.

이 계층에 가격 계산이나 상태 전이 규칙이 생기면 도메인으로 옮긴다. 반대로 도메인 객체가 데이터베이스와 메시지 브로커를 직접 호출하면 테스트와 트랜잭션 경계가 흐려진다.

테스트 전략

모델 테스트는 내부 필드가 아니라 관찰 가능한 규칙을 검증한다. 예시 기반 테스트로 대표 시나리오를 설명하고, 경계값과 조합이 많은 불변식은 속성 기반 테스트를 고려한다.

it("확정된 주문은 다시 확정할 수 없다", () => {
  const first = order.confirm(clock.now());
  expect(first.kind).toBe("accepted");

  const second = order.confirm(clock.now());
  expect(second).toEqual({ kind: "rejected", reason: "already-confirmed" });
});

리포지토리 계약 테스트는 메모리 구현과 실제 어댑터가 같은 의미를 갖는지 확인한다. E2E는 모든 조합을 반복하기보다 컨텍스트 경계를 통과하는 핵심 경로를 검증한다.

흔한 실패와 교정

계층만 복제한 빈약한 모델

controller → service → repository를 만들었지만 모든 규칙이 서비스의 긴 조건문에 모이면 DDD가 아니다. 상태 전이와 불변식을 이름 있는 도메인 연산으로 이동한다.

거대한 공유 모델

여러 팀이 하나의 Customer 타입을 공유하면 변경 하나가 전체를 막는다. 각 컨텍스트가 필요한 고객 표현을 소유하고 경계에서 변환한다.

이벤트를 비동기 함수 호출로 사용

누가 소비하는지 모르는 이벤트를 늘리면 흐름을 추적하기 어렵다. 사건의 소유자, 계약, 실패 정책, 관찰 지표를 함께 문서화한다.

모든 것을 패턴으로 만들기

간단한 CRUD 영역에 애그리게이트와 이벤트를 강요하지 않는다. 복잡성과 변화가 집중되는 핵심 도메인에 설계 비용을 사용하고, 지원 영역은 더 단순한 모델을 선택한다.

도입 체크리스트

  • 현재 가장 비싼 오해를 하나 선택했는가?
  • 예시와 반례를 포함한 보편 언어가 코드와 테스트에 반영됐는가?
  • 컨텍스트 사이 소유권과 변환 위치가 명확한가?
  • 불변식이 생성과 상태 전이 API로 보장되는가?
  • 트랜잭션 경계가 필요한 일관성보다 크지 않은가?
  • 통합 실패와 재시도, 멱등성을 관찰할 수 있는가?
  • 모델 변경이 실제 사용자 결과를 개선했는지 확인할 지표가 있는가?

DDD의 결과물은 다이어그램이 아니라 더 나은 결정 속도다. 언어가 분명해지고 잘못된 상태가 줄며 변경 영향이 한 경계 안에 머문다면 모델은 제 역할을 하고 있다.