실제 비즈니스의 흐름과 로직이 코드에 자연스럽게 녹아들도록 하는 방법론,
도메인 주도 설계(DDD, Domain-Driven Design)에 대해 학습하고 적용해 본 내용을 정리한 글입니다.
1. 등장배경
1. 복잡성 증가
처음에는 단순 CRUD 중심의 설계만으로도 충분했다.
하지만 서비스가 성장하고 사용자와 기능이 늘어나면서 시스템은 점점 복잡해졌다.
2. 기존 개발 한계
과거에는 주로 기술 중심의 개발 방법론이 사용되었습니다.
이런 방법론은 기술적 요구사항을 중점적으로 다루지만, 비즈니스 규칙을 코드에 일관되게 담아내기 어려워졌다.
이 과정에서 문제는 기술 부족이 아니라 도메인을 제대로 이해하고 표현하지 못했다는 점이라는 사실이 드러났다.
3. 필요성
기획자, 운영자, 개발자가 같은 단어를 쓰면서도 서로 다른 의미로 이해하는 상황은 점점 커졌고, 이는 곧 유지보수 비용과 의사소통 비용으로 이어졌다.
이때부터 관점이 바뀌기 시작했다.
“기술보다 도메인을 먼저 이해해야 한다.”
그 필요성 속에서 등장한 접근 방식이 바로 DDD(Domain-Driven Design) 이다
2. DDD 란?
도메인 주도 설계(DDD)는 복잡한 비즈니스 도메인을 코드 중심으로 모델링하는 방법론
여기서 말하는 Domain 이란?
비즈니스 영역, 우리가 해결하려는 비즈니스 문제의 전체 범위를 의미한다.
DDD는 즉 실제 비즈니스가 돌아가는 방식 그대로를 코드에 옮겨놓는 것이다.
그리고 이 방식이 추구하는 최종 목표는 두 가지다.
- Low Coupling — 낮은 결합도
- High Cohesion — 높은 응집도
3. 데이터 주도 vs 도메인 주도
이 두 방식은 처음 던지는 질문이 무엇인가에서 갈린다.
| 구분 | 데이터 주도 설계 (Data-Driven) | 도메인 주도 설계 (Domain-Driven) |
| 첫 고민 | "DB 테이블을 어떻게 만들지?" | "어떤 비즈니스 행위가 발생하지?" |
| 구조 | DB 스키마에 코드가 종속됨 | 도메인 모델이 중심, DB는 거들 뿐 |
| 객체 역할 | 데이터 운반체 (Getter/Setter) | 비즈니스 로직의 주체 (Behavior) |
| 결과 | 서비스 계층이 비대해짐 | 객체지향적인 코드, 높은 응집도 |
이 차이는 시간이 지날수록 극명해진다.
초기에는 두 방식 모두 비슷해 보이지만,
요구사항이 복잡해질수록 구조적 결과가 완전히 달라진다.
3-1. 데이터 중심 설계
객체는 데이터만 가지고 있고, 로직은 Service에 몰려있다.
// Entity: 객체 (Getter/Setter만 존재)
public class Order {
private Long id;
private String status;
// getter, setter...
}
// Service: 비대한 서비스 (모든 로직 처리)
@Service
public class OrderService {
public void cancelOrder(Long id) {
Order order = repository.findById(id);
// 서비스가 비즈니스 규칙을 직접 검사
if ("COMPLETED".equals(order.getStatus())) {
throw new RuntimeException("이미 완료된 주문입니다.");
}
order.setStatus("CANCEL"); // 수동적인 상태 변경
repository.save(order);
}
}
3-2. 도메인 중심 설계
객체가 스스로 상태를 관리하고 행동한다.
// Entity: 객체 (비즈니스 로직 포함)
public class Order {
private Long id;
private OrderStatus status;
public void cancel() {
// 객체 스스로 규칙 검사
if (this.status == OrderStatus.COMPLETED) {
throw new IllegalStateException("이미 완료된 주문입니다.");
}
this.status = OrderStatus.CANCEL;
}
}
// Service: 위임자 (Delegate)
@Service
public class OrderService {
@Transactional
public void cancelOrder(Long id) {
Order order = repository.findById(id)
.orElseThrow(EntityNotFoundException::new);
order.cancel(); // ✅ "취소해!"라고 명령만 내림
}
}
결국 두 방식의 가장 본질적인 차이는 다음 한 줄로 정리된다.
데이터 주도 설계는 "어떻게 저장할까?"를 고민하고,
DDD는 "무엇을 해결해야 하나?"를 고민한다.
즉, DDD는 기술적 구현(How to store)보다 문제의 본질(What is the problem)에 집중하자는게 철학이다.
DDD 방식
DDD는 단순한 코딩 스타일이 아니라, 비즈니스를 바라보고 설계하는 사고 체계다.
그래서 DDD는 두 가지 관점에서 접근한다.
| 단계 | 질문 | 초점 |
| Strategic Design | "도메인을 어떻게 나눌까?" | 시스템 전체 구조, 비즈니스 맥락 |
| Tactical Design | "나눈 도메인을 어떻게 구현할까?" | 코딩 레벨 모델링, 패턴 적용 |
4. 전략적 설계(Strategic Design)
"도메인을 어떻게 나눌까?"
처음부터 모든 걸 코드로 옮기려 하면 실패한다.
DDD의 첫 단계는 비즈니스 세계를 해석하고 경계를 나누는 과정이다.
복잡한 시스템을 하나의 거대한 덩어리(Monolith)로 다루면 다루면 무엇이 문제일까?”
- 작은 수정이 전체에 영향을 준다
- 에러 원인 추적이 어려워진다
- 팀마다 용어가 달라 의사소통 비용 증가
- 기능 확장할수록 복잡성이 폭발
그래서 DDD는 도메인을 분리하는 것부터 시작한다.
비즈니스 중요도에 따라 세 가지로 분류합니다.
도메인 분류 기준
| 분류 | 의미 | 예시 |
| Core Domain | 비즈니스의 본질, 성공의 중심 | 주문 처리, 추천 알고리즘 |
| Supporting Domain | 핵심을 지원하지만 대체 불가 | 재고 관리, 배송 요청 |
| Generic Domain | 어디서나 공통적으로 쓰이는 기능 | 결제(PG), 이메일 전송 |
이 기준은 다음 질문으로 판단할 수 있다.
| 질문 | 분류 |
| “이게 없으면 비즈니스가 성립하는가?” | Core |
| “이 기능을 외부 솔루션으로 대체할 수 있는가?” | Generic |
| “핵심은 아니지만 우리가 직접 구현해야 하는가?” | Supporting |
4-1. Bounded Context & Context Map
도메인을 분리했다면, 이제 그 사이의 의미적 경계를 정의한다.
동일한 단어도 컨텍스트에 따라 의미가 달라진다.
예를 들어, "상품(Product)" 이라는 개념은:
- 주문 컨텍스트에서는 구매 대상
- 배송 컨텍스트에서는 박스에 담긴 물리적 객체
이처럼 맥락에 따라 의미가 달라지기 때문에,
각 컨텍스트는 자신만의 언어(Ubiquitous Language) 를 갖는다.
이를 시각화한 것이 Context Map이다.
┌──────────────┐
│ 주문 Context │
└───────┬──────┘
│ 주문 완료 이벤트
↓
┌──────────────┐
│ 결제 Context │
└───────┬──────┘
│ 결제 완료 이벤트
↓
┌──────────────┐
│ 배송 Context │
└──────────────┘
Context Map 이 필요한 이유
- 도메인 경계 정의: 우리 팀의 책임 범위가 어디까지인지 명확히 한다.
- 협업 명확화: "주문 팀이 API를 변경하면 배송 팀이 영향을 받는가?"를 한눈에 파악한다.
- 언어 일관성 유지: 각 컨텍스트 내부의 보편 언어를 보호하고 침범을 막는다.
잘 정리된 Context Map은 곧 MSA의 청사진이 된다.
4-2. 용어 정리
| 용어 | 설명 | 예시 (쇼핑몰) |
| Domain | 해결해야 하는 비즈니스 문제 영역 전체 | 이커머스 시스템 전체 |
| Subdomain | 도메인을 논리적으로 쪼갠 하위 영역 | 회원, 주문, 배송, 결제, 정산 |
| Bounded Context | 의미가 혼동되지 않는 경계(MSA의 분리 기준) | 주문 컨텍스트, 배송 컨텍스트 |
| Context Map | 컨텍스트 관계도 | 주문 팀과 배송 팀이 어떻게 데이터를 주고받는지(API, Event 등) 그린 그림 |
| Ubiquitous Language | 모든 구성원이 공유하는 언어 | 기획자가 "출고"라고 하면 코드도 ship()이어야 함.(deliveryStart 금지) |
5. Tactical Design
전략적 설계가 끝났다면 다음 질문은 자연스럽다.
“이 도메인을 코드에서는 어떻게 표현해야 할까?”
DDD는 이를 위해 4 Layer Architecture를 사용한다.
| 계층 (Layer) | 역할 및 책임 (Role) | 주요 구성 요소 (Components) | 특징 (Characteristics) |
| 1. Presentation (표현 계층) |
사용자의 요청을 해석하고 응답을 반환 | @Controller, DTO (Request/Response) |
• 비즈니스 로직 없음 • 단순 유효성 검사만 수행 |
| 2. Application (응용 계층) |
업무 흐름(Workflow) 조정, 트랜잭션 관리 |
Application Service (@Service) |
• 로직을 직접 수행하지 않음 • 도메인 객체에게 일을 시키는(위임) 역할 • 다른 계층과 도메인을 연결 |
| 3. Domain (도메인 계층) |
핵심 비즈니스 로직, 상태 변경 및 규칙 처리 |
Entity, VO, Aggregate Repository Interface Domain Service |
• 시스템의 심장 • 기술(DB, Framework)에 의존하지 않는 순수 객체 • Entity가 스스로 로직을 수행 |
| 4. Infrastructure (인프라 계층) |
상위 계층을 위한 기술적 구현체 지원 | Repository Impl (JPA/MyBatis) MailSender, FileStore |
• 실제 DB 접근, 외부 API 호출 등 구체적인 기술 구현 • DIP(의존성 역전)를 통해 도메인 계층을 바라봄 |
핵심은 단 하나:
하위 계층은 상위 계층을 알면 안 된다.
5-1. 도메인 모델 구성 요소
├─ Entity (엔티티)
├─ Value Object (값 객체)
├─ Aggregate (집합체)
├─ Repository (저장소)
├─ Domain Service (도메인 서비스)
└─ Domain Event (도메인 이벤트)
각 요소는 비즈니스를 표현하기 위해 존재한다.
결국 DDD는 팀이 비즈니스를 정확히 이해하며 개발하는 방식이다.
식별자(ID)를 가지며, 값이 바뀌어도 식별자가 같으면 같은 객체로 인식한다.
생명주기(Life Cycle)를 가진다.
예: 회원(ID가 같으면 이름이 개명되어도 같은 사람)
예시:
// 항공편 (Flight)은 Entity
public class Flight {
private FlightId id; // 식별자 (예: KE001)
private FlightStatus status; // 상태가 변함
private LocalDateTime departureTime;
// 출발 시간이 바뀌어도 같은 항공편!
public void delay(Duration delayTime) {
this.departureTime = this.departureTime.plus(delayTime);
}
}
Value Object (VO, 값 객체)
- 식별자가 없고, 값 그 자체로 의미를 가집니다. 불변(Immutable)이어야 한다.
- 예: Money(금액+통화), Address(시+구+동). 10,000원은 그냥 10,000원일 뿐 식별자가 필요 없음.
예시:
// 주소는 Value Object
public class Address {
private final String city;
private final String street;
private final String zipCode;
// 생성자로만 값 설정 (setter 없음!)
public Address(String city, String street, String zipCode) {
this.city = city;
this.street = street;
this.zipCode = zipCode;
}
// 값이 같으면 같은 주소
@Override
public boolean equals(Object o) {
// city, street, zipCode 비교
}
}
Aggregate (애그리거트)
- 연관된 객체들의 묶음이자, 데이터 변경의 단위(Transaction Boundary)이다.
- Root Entity를 통해서만 내부에 접근할 수 있습니다.
- 예: [주문 + 주문항목 + 배송정보]는 하나의 애그리거트. '주문(Root)'이 사라지면 '주문항목'도 의미가 없음.
AggregateRoot (애그리거트 루트)
- Aggregate의 대표 객체
예시:
// Reservation이 Aggregate Root
public class Reservation {
private ReservationId id; // 식별자
private List<Passenger> passengers; // 집합체 내부
private Seat seat; // 집합체 내부
private ReservationStatus status;
// ✅ 외부에서는 Root를 통해서만 접근!
public void assignSeat(Seat newSeat) {
if (this.status != ReservationStatus.CONFIRMED) {
throw new IllegalStateException("확정된 예약만 좌석 배정 가능");
}
this.seat = newSeat;
}
// ❌ 외부에서 passengers.get(0).setName() 같은 직접 수정 금지!
}
Repository
- 단순한 DAO가 아니라, 애그리거트 단위로 도메인 객체를 저장하고 조회하는 컬렉션 개념입니다.
- 예: OrderRepository는 있어도 OrderItemRepository는 만들지 않음 (루트를 통해서만 접근하므로).
예시:
// Repository는 도메인 계층의 인터페이스
public interface ReservationRepository {
// 컬렉션처럼 사용
Reservation findById(ReservationId id);
void save(Reservation reservation);
void delete(Reservation reservation);
// 도메인 언어로 된 메서드명
List<Reservation> findByPassengerIdAndStatus(
PassengerId passengerId,
ReservationStatus status
);
}
Domain Service
- 엔티티나 값 객체에 넣기 애매하지만 도메인 로직으로 분류되는 행위(behavior)를 담당한다.
- 즉, 특정 엔티티에 속하지 않지만 여러 도메인 객체를 협력시켜야 하는 도메인 규칙을 구현하는 역할이다.
- 재사용성과 테스트 용이성이 높으며, 응용 서비스(application service)와 다르다.
(도메인 서비스는 “무엇을 해야 하는가”, 응용 서비스는 “언제 / 요청에 따라 호출할 것인가”에 더 가깝다.) - 예: 여러 금융 계좌 간 송금 같은 로직은 특정 엔티티에 종속되지 않으므로 TransferService로 캡슐화할 수 있다.
예시:
// Domain Service 예시: 송금 처리
public class TransferService {
private final AccountRepository accountRepository;
public TransferService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
// 도메인 규칙: 송금 시 같은 통화 단위여야 함
public void transfer(Money amount, AccountId fromId, AccountId toId) {
Account from = accountRepository.findById(fromId);
Account to = accountRepository.findById(toId);
if (!from.getCurrency().equals(to.getCurrency())) {
throw new IllegalArgumentException("서로 다른 통화로 송금할 수 없습니다.");
}
from.withdraw(amount);
to.deposit(amount);
accountRepository.save(from);
accountRepository.save(to);
}
}
5-2. 설계 팁
Tip 1: 작게 시작하기
1단계: 핵심 Aggregate 1개만 완성
2단계: 주요 시나리오 1개 구현
3단계: 점진적으로 확장
Tip 2: 리팩토링은 자연스럽게
처음부터 완벽할 순 없습니다!
구현하면서 도메인 이해가 깊어지면
자연스럽게 모델을 개선하세요.
Tip 3: 팀과 대화하기
"이 메서드명이 업무를 잘 표현하나요?"
"이 경계가 맞나요?"
계속 질문하고 검증하세요!
6. 그러면, DDD는 언제 적용하는 게 맞을까?
DDD 체크리스트를 통과했다고 해서 무조건 DDD를 적용해야 하는 것은 아니다.
중요한 건 비즈니스 복잡도와 변화의 정도다.
아래 두 가지 프로젝트 유형을 비교해보자.
6-1. 아키텍처 결정 매트릭스
| 구분 | 단순 CRUD 프로젝트 | 복잡한 비즈니스 프로젝트 |
| 특징 | 데이터 입출력이 핵심 (게시판, 관리자 UI) | 상태 변화 및 규칙이 복잡 (이커머스, 금융) |
| 접근 | Database First | Domain First |
| 패턴 | Transaction Script (Service에 로직 집중) | DDD Tactical Pattern (Entity에 행위 집중) |
| 우선순위 | 빠른 개발 속도 | 유지보수 & 변경 유연성 |
즉, 이렇게 판단하면 된다.
6-2. DDD 적합성 질문
| 질문 | YES? |
| 서비스 계층 if문이 폭발하고 있는가? | ✔ |
| 기획자/운영과 용어 충돌로 소통 비용이 높아지는가? | ✔ |
| 비즈니스 정책이 자주 바뀌는 핵심 도메인인가? | ✔ |
| 객체가 단순 데이터 가방(빈약한 모델)인가? | ✔ |
YES가 많을수록 DDD는 필요에 가깝다.
→ 특히 Core Domain 부터 점진적으로 적용하면 된다.
7. 결론
공부하면서 확실히 느낀 점이 있다.
MSA도, DDD도 무조건 적용해야 하는 정답 같은 기술이 아니다.
중요한 건 “멋져 보이니까 쓰는 것”이 아니라,
해결해야 하는 문제와 맥락에 맞는 선택을 하는 것이다.
DDD 역시 기술이 목적이 아니라 비즈니스를 더 잘 표현하기 위한 수단이다.
Entity와 VO를 나누는 것보다 더 중요한 것은,
그 용어가 어떤 컨텍스트에서 어떤 의미를 가지는지 팀 전체가 동일하게 이해하는 과정이다.
8. 참고
'아키텍처' 카테고리의 다른 글
| [아키텍처] MSA 데이터 일관성 문제, SAGA 패턴으로 해결하기 (0) | 2025.11.23 |
|---|---|
| [아키텍처] MSA는 정답이고 Monolithic은 오답일까? (0) | 2025.11.21 |