DDD 핵심 설계 규칙
불변 조건·트랜잭션 경계·도메인 규칙 위치를 고정해 변경 비용과 경계 누수를 막는 DDD 전술 설계 가이드
Posted
By okorion
DDD 핵심 설계 규칙
이 글의 결론: 이 강의에서의 DDD는 ‘모델링 기법’이 아니라 변경 비용과 트랜잭션 혼란을 제어하는 코드 경계 규칙이다.
DDD는 Entity/VO/Aggregate를 외우기 위한 이론이 아니라, 비즈니스 규칙이 어디에 있어야 하고 어디에 있으면 안 되는지를 강제로 고정해 주는 설계 도구다.
이 강의의 DDD가 해결하려는 문제 결론: 대부분의 실패는 “규칙이 새어나가고, 경계가 흐려지는 것”에서 시작된다.
DDD가 겨냥하는 핵심 문제는 세 가지다.
변경 비용 폭증
- 규칙이 컨트롤러/서비스/리포지토리에 흩어져 있으면, 정책 변경 시 수정 지점이 기하급수로 늘어난다.
비즈니스 규칙 누수
- “이 상태에서 이 행동이 가능한가?”가 코드 위치마다 다르게 표현된다.
트랜잭션 경계 혼란
- 무엇이 한 번에 바뀌어야 하는지 불분명해지고, 분산 환경에서 사고가 난다.
DDD의 모든 전술 패턴은 이 세 문제를 줄이기 위한 수단이다.
Aggregate Root 결론: Aggregate는 ‘데이터 묶음’이 아니라 불변 조건을 지키는 트랜잭션 경계다.
Invariants(불변 조건)
- Aggregate 내부에서 항상 참이어야 하는 규칙
예:
- 주문은
APPROVED상태에서 다시PENDING으로 돌아갈 수 없다. - 결제 승인 전에는 배송을 시작할 수 없다.
- 주문은
트랜잭션 경계
- 한 Aggregate는 하나의 트랜잭션으로만 변경
- 다른 Aggregate를 동시에 직접 수정 ❌ → 이벤트/SAGA로 연결
“외부에서 엔티티 직접 변경 금지”를 강제하는 방법
- 필드
private - setter 제거
- 상태 변경 메서드만 노출
1
2
3
4
5
6
7
8
9
10
11
public class Order {
private OrderStatus status;
public void approve() {
if (status != OrderStatus.PENDING) {
throw new IllegalStateException("승인 불가 상태");
}
this.status = OrderStatus.APPROVED;
}
}
핵심: 상태 변경은 ‘의미 있는 동사 메서드’로만 허용한다.
Value Object 결론: VO는 편의를 위한 클래스가 아니라 의미·동등성·불변성의 묶음이다.
VO가 필요한 조건
- 동등성: 값이 같으면 같은 것으로 취급 (
equals/hashCode) - 불변성: 생성 이후 값 변경 ❌
- 의미 단위: 단순 타입이 아니라 비즈니스 의미를 가짐
예:
Money(amount, currency)OrderIdEmailAddress
VO 남발의 비용
- 변환 코드 증가
- 직렬화/역직렬화 복잡
- 팀원이 의미를 이해하지 못하면 오히려 가독성 하락
기준:
- 규칙이 붙는 값만 VO로
- 단순 전달/표시용 값은 Primitive 유지
Domain Service vs Application Service 결론: 로직의 위치는 “누가 책임지는가”로 나뉜다.
Domain Service
책임:
- 여러 Aggregate에 걸친 도메인 규칙
- 특정 Entity/VO에 귀속되지 않는 순수 규칙
조건:
- 기술 의존 ❌
- 상태 저장 ❌
Application Service (UseCase)
책임:
- 유스케이스 흐름 조합
- 트랜잭션 시작/종료
- Port 호출
하면 안 되는 것:
- 비즈니스 규칙 판단
- 상태 전이 로직
흔한 실수
- 승인 가능 여부를 UseCase에서 if/else로 판단
- Domain Service가 DB를 조회
Domain Event 결론: 도메인 이벤트는 “상태 변화 이후의 사실”이며, 사이드 이펙트의 트리거다.
의미
- “무엇이 일어났다”를 표현
- 명령(Command)이 아님
발행 시점과 트랜잭션 경계
- Aggregate 상태 변경 직후
- 같은 트랜잭션 안에서 이벤트 수집
- 실제 발행은 Outbox/커밋 이후
주의:
- 이벤트 핸들러에서 Aggregate 직접 수정 ❌
- 이벤트는 결과 공유, 제어 흐름은 SAGA/UseCase
예시 1 결론: 주문 상태 전이 규칙은 반드시 Aggregate 안에 있어야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Order {
private OrderStatus status;
public void approve() {
if (status != OrderStatus.PENDING) {
throw new IllegalStateException();
}
status = OrderStatus.APPROVED;
}
public void reject() {
if (status != OrderStatus.PENDING) {
throw new IllegalStateException();
}
status = OrderStatus.REJECTED;
}
}
- 규칙이 한 곳에 고정됨
- 테스트는 Order 단독으로 가능
예시 2 결론: 컨트롤러에서 상태를 바꾸는 순간, DDD는 붕괴된다.
1
2
3
4
5
6
7
// ❌ 잘못된 예
@PostMapping("/approve")
public void approve(Long orderId) {
Order order = repository.find(orderId);
order.setStatus(APPROVED);
repository.save(order);
}
문제:
- 불변 조건이 어디에도 없음
- 다른 진입점에서 규칙 우회 가능
- 테스트는 항상 통합 테스트가 됨
테스트 전략 결론: 목킹이 많아질수록, 경계가 잘못됐을 가능성이 높다.
우선순위
도메인 규칙 테스트
- Entity / Domain Service
- Mock 거의 없음
유스케이스 테스트
- Port만 Mock
통합 테스트
- 개수 최소화
신호:
- Domain 테스트에 Mock이 많다 → 로직 위치가 잘못됨
- UseCase 테스트가 복잡하다 → 규칙이 위로 새어 나옴
개념 정리 표 결론: “이 표로 위치를 틀리면 바로 드러나야 한다.”
| 개념 | 정의 | 직관 | 코드 위치 | 오해 | 디버깅 포인트 |
|---|---|---|---|---|---|
| Aggregate | 불변 조건을 지키는 경계 | 한 번에 바뀌는 단위 | Domain | 단순 데이터 묶음 | 트랜잭션 범위 |
| Entity | 식별자로 구분되는 객체 | ‘이것’ | Domain | JPA 엔티티 | 상태 전이 위치 |
| Value Object | 값 자체가 의미 | ‘값이면 같다’ | Domain | 모든 값은 VO | 변환 비용 |
| Domain Service | 규칙 계산 | 규칙 함수 | Domain | 로직 다 때려넣기 | 의존성 여부 |
| App Service | 흐름 조합 | 시나리오 | Application | 비즈니스 판단 | if/else 증가 |
| Domain Event | 일어난 사실 | 결과 통지 | Domain | 비동기 명령 | 발행 시점 |
실무 판단 기준 7개 (DDD를 쓸지 말지)
- 비즈니스 규칙이 자주 바뀌는가
- 상태 전이 조건이 문장으로 설명 가능하지만 코드로 흩어지는가
- 트랜잭션 경계를 명확히 설명하기 어려운가
- “이 로직은 어디에 둬야 하지?”라는 논쟁이 반복되는가
- CRUD 이상이 필요한가
- 테스트에서 Mock이 비정상적으로 많은가
- 팀 내 공통 언어(용어 합의)가 가능한가
7개 중 3개 이상이면 DDD 적용 가치 있음.
흔한 오해 / 실수 3가지
- Aggregate = 엔티티 묶음이라는 오해
- 문제: 연관된 엔티티를 한 클래스에 모아두면 Aggregate라고 착각
- 결과: 불변 조건이 흩어지고, 트랜잭션 경계가 모호해짐
- 교정 기준: “이 규칙은 언제나 함께 지켜져야 하는가?”에 Yes면 같은 Aggregate
- Application Service에 상태 전이 로직을 두는 실수
- 문제:
if (status == …)가 UseCase에 존재 - 결과: 규칙이 진입점마다 복제되고, 우회 경로 발생
- 교정 기준: 상태 변경은 항상 Aggregate 메서드에서만 발생
- 문제:
- Value Object 남발
- 문제: String 하나 감싸는 클래스 무분별 생성
- 결과: 매핑/직렬화 비용 증가, 가독성 저하
- 교정 기준: 불변 + 동등성 + 규칙이 붙는 값만 VO로 승격
대표 실패 시나리오 3가지
- 컨트롤러에서 상태를 직접 변경하는 경우
- 상황: 승인/거절 API가 단순 setter 호출
- 원인: Aggregate Root 규칙 미정의
- 결과: 규칙 우회, 테스트 불가, 버그 재현 불가
- 대응: setter 제거 + 의미 있는 상태 전이 메서드만 공개
- 한 트랜잭션에서 여러 Aggregate를 동시에 수정
- 상황: 주문 승인 시 결제/재고를 직접 변경
- 원인: Aggregate 경계 오판
- 결과: 분산 환경에서 트랜잭션 붕괴
- 대응: 한 Aggregate만 변경 + 나머지는 Domain Event/SAGA로 위임
- Domain Service가 DB/외부 API를 호출
- 상황: “규칙 계산”이라는 명목으로 Repository 주입
- 원인: Domain Service와 Application Service 책임 혼동
- 결과: 도메인 테스트 불가, 의존성 역전 실패
- 대응: 데이터 접근은 Application Service에서 조합
실무 체크리스트 (DDD 경계 점검)
- Aggregate Root 외부에서 엔티티 상태를 변경할 수 없는가
- 모든 상태 전이가 도메인 메서드로 표현돼 있는가
- 불변 조건이 코드 한 곳에 고정돼 있는가
- Aggregate 트랜잭션 경계를 문장으로 설명할 수 있는가
- 한 UseCase에서 수정되는 Aggregate는 하나뿐인가
- Value Object가 규칙 없는 래퍼로 전락하지 않았는가
- Domain Service가 기술 의존 없이 순수 계산만 하는가
- Application Service에 비즈니스 판단 if/else가 없는가
- Domain Event가 “사실”만 표현하고 있는가
- 이벤트 발행 시점이 상태 변경 이후로 고정돼 있는가
- 도메인 테스트에 Mock이 거의 없는가
- 테스트 실패 시 “어떤 규칙이 깨졌는지” 바로 드러나는가
This post is licensed under CC BY 4.0 by the author.
