DDD(Domain-Driven Design) 아키텍처
시작하기에 앞서
프로젝트가 작을 때는 테이블과 화면을 기준으로 코드를 나누어도 크게 불편하지 않다. UserService, OrderService, OrderRepository 정도로 시작해서 기능을 하나씩 붙여도 잘 동작하고 유지보수에도 문제가 없다.
문제는 할인 조건, 주문 상태, 환불 가능 기간처럼 중요 로직이 늘어날 때다. 로직이 컨트롤러와 서비스, SQL 사이에 흩어지면 "주문을 취소할 수 있는 상태"가 무엇인지 코드를 여러 군데 읽어야 알게 된다. 기획자와 개발자가 같은 단어를 쓰지만 서로 다른 의미를 떠올리는 일도 생긴다.
DDD(Domain-Driven Design)는 이때 데이터베이스나 프레임워크보다 도메인 모델을 중심에 두자는 접근이다. 복잡한 문제를 멋진 폴더 구조로 해결하는 방법이라기보다, 소프트웨어가 다루는 업무를 코드로 정확히 표현하려는 시도에 가깝다.
왜 도메인 중심으로 설계할까?
데이터 중심 설계가 나쁘다는 뜻은 아니다. 단순한 조회 화면이나 관리자 기능은 CRUD만으로도 충분히 명확할 수 있다. 다만 규칙이 중요한 영역에서는 데이터만 담는 객체와 모든 판단을 떠안는 서비스가 자주 등장한다.
Controller → OrderService → OrderRepository
├── 할인 가능 여부 판단
├── 결제 금액 검증
├── 주문 상태 변경
└── 취소 가능 기간 판단
이 구조에서 Order는 보통 필드만 가진다. 주문의 로직은 OrderService에 계속 쌓이고, 다른 기능이 필요해질 때마다 같은 로직을 복사하기 쉬워진다.
DDD가 집중하는 대상은 다음과 같다.
업무 언어를 코드에 반영: "결제 완료", "배송 시작", "환불 가능" 같은 표현을 함수와 타입으로 드러내기
로직을 가까이 두기: 주문에 관한 로직은 가능한 한 Order와 주문 모델 근처에 두기
모델의 적용 범위 정하기: 같은 고객이라는 단어라도 문맥이 다르면 하나의 거대한 모델로 합치지 않기
DDD의 핵심은 클래스 수를 늘리는 것이 아니라, 변경될 로직이 어디에 있고 누가 책임지는지 보이게 만드는 것이다.
DDD를 구성하는 몇 가지 핵심 개념
유비쿼터스 언어(Ubiquitous Language)
팀이 대화, 기획 문서, 코드, 테스트에서 같은 단어를 쓰는 것.
기획에서 "주문 확정"이라고 부른다면 코드도
confirm()또는confirmOrder()처럼 표현process(),handle(),update()처럼 의미가 넓은 이름만으로 로직을 감추지 않기용어가 애매하면 코딩 전에 먼저 질문하기
예를 들어 "취소"가 결제 전 주문 삭제인지, 결제 후 환불 요청인지 구분되지 않는다면 함수부터 만들기보다 업무 용어를 나누는 편이 낫다. cancelPendingOrder()와 requestRefund()가 서로 다른 로직을 가진다는 사실도 자연스럽게 드러난다.
바운디드 컨텍스트(Bounded Context)
하나의 모델과 언어가 일관되게 적용되는 경계.
Customer라는 단어는 주문 영역에서는 구매자일 수 있고, 고객 지원 영역에서는 문의 이력과 연락 수단을 가진 사람이 될 수 있다. 두 의미를 처음부터 같은 클래스 하나에 모두 넣으면 주문 코드가 상담 정책을 알게 되는 식으로 경계가 흐려진다.
주문 컨텍스트
└── Customer: 주문자, 배송지, 결제 책임자
고객 지원 컨텍스트
└── Customer: 문의자, 상담 이력, 연락 동의
같은 이름을 반드시 피해야 한다는 규칙은 아니다. 중요한 것은 어느 문맥의 모델인지와 그 모델이 책임지는 로직을 명확하게 정하는 일이다.
엔티티(Entity)와 값 객체(Value Object)
유비쿼터스 언어와 바운디드 컨텍스트가 "어디까지가 하나의 모델인가"를 정하는 이야기였다면, 이제부터는 그 경계 안에서 모델을 실제 코드로 표현하는 도구다.
엔티티: 속성이 바뀌어도 동일성을 유지하는 객체. 주문 번호가 같은 Order는 배송 상태가 바뀌어도 같은 주문
VO: 값 자체로 의미와 동일성을 판단하는 객체.
Money(10000, "KRW")처럼 금액과 통화를 함께 표현
값 객체를 쓰는 이유는 원시 타입만 전달할 때 빠지기 쉬운 의미를 모델에 넣기 위해서다. number 하나만으로는 금액인지 수량인지, 어떤 통화인지 알기 어렵다.
애그리거트(Aggregate)
함께 지켜야 하는 규칙을 한 단위로 묶고, 외부에서 접근하는 대표 엔티티를 정하는 방식.
주문에서는 Order를 애그리거트 루트로 두고 주문 항목과 상태 변경 규칙을 Order를 통해서만 다루는 식이다. 모든 연관 객체를 크게 묶으라는 뜻은 아니다. 한 번에 반드시 지켜야 할 규칙이 무엇인지부터 살펴보는 편이 좋다.
사용 예: 빈약한 모델에서 규칙을 모델로 옮기기
다음 코드는 데이터를 담는 Order와 규칙을 모두 가진 서비스가 분리된 예다.
잘못된 예
type Order = {
id: string;
totalAmount: number;
status: "PENDING" | "PAID" | "CANCELLED";
};
class OrderService {
confirmPayment(order: Order, paidAmount: number) {
if (order.status !== "PENDING") {
throw new Error("결제할 수 없는 주문입니다.");
}
if (order.totalAmount !== paidAmount) {
throw new Error("결제 금액이 주문 금액과 다릅니다.");
}
order.status = "PAID";
}
}
금액이 단순한
number라 통화나 비교 규칙을 표현하지 못함주문 상태 변경 규칙이 서비스에 있음
다른 서비스가
order.status = "PAID"를 직접 실행해도 막기 어려움
올바른 예
class Money {
constructor(
readonly amount: number,
readonly currency: "KRW",
) {
if (amount < 0) throw new Error("금액은 0 이상이어야 합니다.");
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
}
type OrderStatus = "PENDING" | "PAID" | "CANCELLED";
class Order {
private status: OrderStatus = "PENDING";
constructor(
readonly id: string,
private readonly totalAmount: Money,
) {}
confirmPayment(paidAmount: Money): void {
if (this.status !== "PENDING") {
throw new Error("결제할 수 없는 주문입니다.");
}
if (!this.totalAmount.equals(paidAmount)) {
throw new Error("결제 금액이 주문 금액과 다릅니다.");
}
this.status = "PAID";
}
}
Money가 금액의 의미와 비교 방법을 담당하는 값 객체 역할Order가 자신의 상태 전이 규칙을 담당하는 엔티티 역할confirmPayment()라는 이름으로 업무 행위가 코드에 드러남
물론 실제 프로젝트에서는 결제 승인 결과, 주문 항목, 세금과 할인처럼 더 많은 규칙이 필요하다. 이 예시의 목적은 모든 코드를 객체로 감싸는 것이 아니라, 규칙이 있는 곳에 이름과 책임을 함께 두는 것이다.
시작할 때의 작은 기준
처음부터 전술 패턴을 모두 적용하면 오히려 업무를 이해하는 시간보다 구조를 만드는 시간이 길어진다. 다음 순서 정도로 시작하면 부담이 적다.
업무 문장 수집: 기획자나 운영자가 자주 쓰는 문장을 적기. 예: “결제 전 주문만 취소할 수 있다”
불변 조건 찾기: 어떤 경우에도 깨지면 안 되는 규칙을 찾기. 예: 결제 금액과 주문 금액은 같아야 함
경계 나누기: 같은 단어가 다른 의미로 쓰이는 지점을 찾고, 한 모델이 너무 많은 일을 하지 않게 나누기
테스트에 언어 사용:
pendingOrder_canBeCancelled()처럼 업무 규칙이 보이는 테스트 이름 사용
반대로 단순한 조회, 일회성 데이터 이관, 규칙이 거의 없는 내부 도구에까지 복잡한 애그리거트를 만들 필요는 없다. DDD는 범용 의식이 아니라 복잡함이 실제로 있는 곳에 집중하는 도구라고 생각한다.
마무리
DDD를 읽을 때 처음 마주치는 용어는 많지만, 결국 질문은 단순하다. “이 규칙은 누구의 책임인가?”, “우리가 말하는 이 단어는 정확히 무엇인가?”를 코드에서도 답할 수 있는가다.
이번 글에서는 도메인 모델과 경계를 중심으로 정리했다. 다음 글에서는 이렇게 분리한 도메인을 데이터베이스나 외부 API로부터 어떻게 보호할지, **헥사고날 아키텍처(Ports & Adapters)**로 이어서 정리해보려고 한다. 이름이 하나 더 늘어났지만, 목적은 도메인이 바깥 기술에 끌려다니지 않게 하는 것이다.


![[잡담] vive coding](https://cdn.hashnode.com/uploads/covers/68c02f05dc3a532e679042a4/97d8d740-9e88-49d0-9c60-548f7795bacf.jpg)
