Post

DDD 핵심 설계 규칙

불변 조건·트랜잭션 경계·도메인 규칙 위치를 고정해 변경 비용과 경계 누수를 막는 DDD 전술 설계 가이드

DDD 핵심 설계 규칙

이 글의 결론: 이 강의에서의 DDD는 ‘모델링 기법’이 아니라 변경 비용과 트랜잭션 혼란을 제어하는 코드 경계 규칙이다.

DDD는 Entity/VO/Aggregate를 외우기 위한 이론이 아니라, 비즈니스 규칙이 어디에 있어야 하고 어디에 있으면 안 되는지를 강제로 고정해 주는 설계 도구다.


이 강의의 DDD가 해결하려는 문제 결론: 대부분의 실패는 “규칙이 새어나가고, 경계가 흐려지는 것”에서 시작된다.

DDD가 겨냥하는 핵심 문제는 세 가지다.

  1. 변경 비용 폭증

    • 규칙이 컨트롤러/서비스/리포지토리에 흩어져 있으면, 정책 변경 시 수정 지점이 기하급수로 늘어난다.
  2. 비즈니스 규칙 누수

    • “이 상태에서 이 행동이 가능한가?”가 코드 위치마다 다르게 표현된다.
  3. 트랜잭션 경계 혼란

    • 무엇이 한 번에 바뀌어야 하는지 불분명해지고, 분산 환경에서 사고가 난다.

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)
  • OrderId
  • EmailAddress

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);
}

문제:

  • 불변 조건이 어디에도 없음
  • 다른 진입점에서 규칙 우회 가능
  • 테스트는 항상 통합 테스트가 됨

테스트 전략 결론: 목킹이 많아질수록, 경계가 잘못됐을 가능성이 높다.

우선순위

  1. 도메인 규칙 테스트

    • Entity / Domain Service
    • Mock 거의 없음
  2. 유스케이스 테스트

    • Port만 Mock
  3. 통합 테스트

    • 개수 최소화

신호:

  • Domain 테스트에 Mock이 많다 → 로직 위치가 잘못됨
  • UseCase 테스트가 복잡하다 → 규칙이 위로 새어 나옴

개념 정리 표 결론: “이 표로 위치를 틀리면 바로 드러나야 한다.”

개념정의직관코드 위치오해디버깅 포인트
Aggregate불변 조건을 지키는 경계한 번에 바뀌는 단위Domain단순 데이터 묶음트랜잭션 범위
Entity식별자로 구분되는 객체‘이것’DomainJPA 엔티티상태 전이 위치
Value Object값 자체가 의미‘값이면 같다’Domain모든 값은 VO변환 비용
Domain Service규칙 계산규칙 함수Domain로직 다 때려넣기의존성 여부
App Service흐름 조합시나리오Application비즈니스 판단if/else 증가
Domain Event일어난 사실결과 통지Domain비동기 명령발행 시점

실무 판단 기준 7개 (DDD를 쓸지 말지)

  1. 비즈니스 규칙이 자주 바뀌는가
  2. 상태 전이 조건이 문장으로 설명 가능하지만 코드로 흩어지는가
  3. 트랜잭션 경계를 명확히 설명하기 어려운가
  4. “이 로직은 어디에 둬야 하지?”라는 논쟁이 반복되는가
  5. CRUD 이상이 필요한가
  6. 테스트에서 Mock이 비정상적으로 많은가
  7. 팀 내 공통 언어(용어 합의)가 가능한가

7개 중 3개 이상이면 DDD 적용 가치 있음.


흔한 오해 / 실수 3가지

  1. Aggregate = 엔티티 묶음이라는 오해
    • 문제: 연관된 엔티티를 한 클래스에 모아두면 Aggregate라고 착각
    • 결과: 불변 조건이 흩어지고, 트랜잭션 경계가 모호해짐
    • 교정 기준: “이 규칙은 언제나 함께 지켜져야 하는가?”에 Yes면 같은 Aggregate
  2. Application Service에 상태 전이 로직을 두는 실수
    • 문제: if (status == …)가 UseCase에 존재
    • 결과: 규칙이 진입점마다 복제되고, 우회 경로 발생
    • 교정 기준: 상태 변경은 항상 Aggregate 메서드에서만 발생
  3. Value Object 남발
    • 문제: String 하나 감싸는 클래스 무분별 생성
    • 결과: 매핑/직렬화 비용 증가, 가독성 저하
    • 교정 기준: 불변 + 동등성 + 규칙이 붙는 값만 VO로 승격

대표 실패 시나리오 3가지

  1. 컨트롤러에서 상태를 직접 변경하는 경우
    • 상황: 승인/거절 API가 단순 setter 호출
    • 원인: Aggregate Root 규칙 미정의
    • 결과: 규칙 우회, 테스트 불가, 버그 재현 불가
    • 대응: setter 제거 + 의미 있는 상태 전이 메서드만 공개
  2. 한 트랜잭션에서 여러 Aggregate를 동시에 수정
    • 상황: 주문 승인 시 결제/재고를 직접 변경
    • 원인: Aggregate 경계 오판
    • 결과: 분산 환경에서 트랜잭션 붕괴
    • 대응: 한 Aggregate만 변경 + 나머지는 Domain Event/SAGA로 위임
  3. Domain Service가 DB/외부 API를 호출
    • 상황: “규칙 계산”이라는 명목으로 Repository 주입
    • 원인: Domain Service와 Application Service 책임 혼동
    • 결과: 도메인 테스트 불가, 의존성 역전 실패
    • 대응: 데이터 접근은 Application Service에서 조합

실무 체크리스트 (DDD 경계 점검)

  1. Aggregate Root 외부에서 엔티티 상태를 변경할 수 없는가
  2. 모든 상태 전이가 도메인 메서드로 표현돼 있는가
  3. 불변 조건이 코드 한 곳에 고정돼 있는가
  4. Aggregate 트랜잭션 경계를 문장으로 설명할 수 있는가
  5. 한 UseCase에서 수정되는 Aggregate는 하나뿐인가
  6. Value Object가 규칙 없는 래퍼로 전락하지 않았는가
  7. Domain Service가 기술 의존 없이 순수 계산만 하는가
  8. Application Service에 비즈니스 판단 if/else가 없는가
  9. Domain Event가 “사실”만 표현하고 있는가
  10. 이벤트 발행 시점이 상태 변경 이후로 고정돼 있는가
  11. 도메인 테스트에 Mock이 거의 없는가
  12. 테스트 실패 시 “어떤 규칙이 깨졌는지” 바로 드러나는가

This post is licensed under CC BY 4.0 by the author.