본문 바로가기

Architecture/DDD

[DDD] 검증은 어디서 해야할까? DTO, Command, Domain의 책임 분리

프로젝트를 DDD로 리팩터링 하며 맞닥뜨린 질문이 있습니다.

"이 검증 로직, 어디에 두는 게 맞지?"

@NotNull을 DTO에 달았는데, 같은 조건을 도메인 객체에도 넣어야 할까요? Command에도 검증 코드를 넣으면 중복이 아닐까요? 이메일 중복 검사는 서비스에서 해야 할까요, 도메인에서 해야 할까요?

 

이 글에서는 "도메인 상태가 필요한가?" 라는 하나의 기준을 축으로, 각 계층의 검증 책임을 명확하게 나눠보겠습니다.

 


핵심 판단 기준 한 줄 요약

도메인 상태가 불필요하면 → DTO / Command
도메인 상태가 필요하면   → Domain Entity / Domain Service

이 기준 하나만 잘 잡아도, 검증 코드의 위치에 대한 고민이 훨씬 단순해집니다.

 


전체 구조 한눈에 보기

 

HTTP 요청이 들어오면 DTO → Command → Application Service(Domain Service / Entity) 순서로 검증 관문을 통과합니다. 앞 단계에서 걸러낼 수 있는 건 최대한 빨리 튕겨내고, 도메인 상태가 반드시 필요한 검증만 뒷단으로 넘기는 것이 핵심입니다.

 


1단계 — DTO: 구문 검증 (Syntax Validation)

역할

DTO(Data Transfer Object)는 HTTP 요청을 받아오는 웹 기술에 종속적인 데이터 바구니입니다. 사용자가 보낸 JSON을 자바 객체로 바인딩하는 역할을 합니다.

여기서의 검증 목적은 단순합니다. 말도 안 되는 데이터가 애플리케이션 안쪽까지 흘러들어오는 걸 컨트롤러 단에서 빠르게 막는 것입니다.

 

검증 내용

  • Null / 빈 문자열 체크
  • 타입, 길이, 범위 확인
  • 스프링(Bean Validation)이 제공하는 애노테이션 적극 활용

 

예제 코드

public class RegisterUserRequest {

    @NotBlank(message = "이름은 필수입니다.")
    private String name;

    @Email(message = "이메일 형식이 아닙니다.")
    @NotBlank(message = "이메일은 필수입니다.")
    private String email;

    @Min(value = 1, message = "주문 수량은 1개 이상이어야 합니다.")
    @Max(value = 999, message = "주문 수량은 999개를 초과할 수 없습니다.")
    private int quantity;
}
@RestController
public class UserController {

    @PostMapping("/users")
    public ResponseEntity<Void> register(@Valid @RequestBody RegisterUserRequest request) {
        // @Valid 가 DTO 검증을 트리거합니다
        // 검증 실패 시 MethodArgumentNotValidException → 400 Bad Request
        ...
    }
}

포인트

DTO의 검증은 "현재 API 스펙과 폼 입력 형식에 맞는가?" 를 묻는 형식적 검증입니다.
UI 정책이 바뀌면 이 검증도 함께 바뀝니다. (예: 할인율 입력 범위를 30%에서 50%로 변경)

 


2단계 — Command: 의미 검증 (Semantic Validation)

역할

Command는 컨트롤러가 DTO에서 데이터를 꺼내 만드는 순수한 비즈니스 명령서입니다. 웹 기술에 전혀 의존하지 않습니다.

여기서의 검증 목적은 DB를 조회하기 전, 요청 자체가 논리적으로 모순인지 확인하는 것입니다.

 

검증 내용

1. 필드 간 복합 검증 (Cross-field Validation)

public class SearchEventCommand {

    private final LocalDate startDate;
    private final LocalDate endDate;

    public SearchEventCommand(LocalDate startDate, LocalDate endDate) {
        if (startDate.isAfter(endDate)) {
            throw new IllegalArgumentException("시작일은 종료일보다 이전이어야 합니다.");
        }
        this.startDate = startDate;
        this.endDate = endDate;
    }
}

단일 필드의 유효성은 DTO가 처리하지만, 두 필드가 엮인 논리적 검증은 Command가 담당합니다.

 

2. 조건부 필수값 검증

public class PayCommand {

    private final PayMethod payMethod;
    private final String cardNumber; // 신용카드일 때만 필수

    public PayCommand(PayMethod payMethod, String cardNumber) {
        if (payMethod == PayMethod.CREDIT_CARD && (cardNumber == null || cardNumber.isBlank())) {
            throw new IllegalArgumentException("신용카드 결제 시 카드번호는 필수입니다.");
        }
        this.payMethod = payMethod;
        this.cardNumber = cardNumber;
    }
}

 

3. Fail-Fast로 리소스 절약

Command 생성 시점에 예외를 던지면, 불필요한 DB 조회 없이 바로 실패합니다.
Repository 접근 비용이 발생하기 전에 잘못된 요청을 걸러낼 수 있습니다.

 

포인트

Command 검증은 "이 요청 데이터의 조합이 문맥과 상관없이 논리적으로 말이 되는가?" 를 묻습니다.
도메인 상태(DB)를 몰라도 판단 가능한 것들이 여기에 해당합니다.

 


3단계 — Entity / Value Object: 비즈니스 상태 검증 (Business State Validation)

역할

가장 핵심적인 검증입니다. 도메인 불변 규칙(Domain Invariant)을 지키는 최후의 보루입니다.

도메인 객체는 웹 컨트롤러뿐 아니라 배치 작업, 이벤트 컨슈머, 내부 서비스 등 다양한 경로로 생성될 수 있습니다. 따라서 도메인 객체 스스로가 어떤 상황에서도 유효한 상태를 유지해야 합니다.

 

예제 코드

public class Account {

    private Money balance;

    public void withdraw(Money amount) {
        if (this.balance.isLessThan(amount)) {
            // 현재 잔액(도메인 상태)을 알아야만 검증 가능합니다
            throw new InsufficientBalanceException("잔액이 부족합니다.");
        }
        this.balance = this.balance.subtract(amount);
    }
}
// Value Object도 스스로를 보호합니다
public class Money {

    private final int amount;

    public Money(int amount) {
        if (amount < 0) {
            throw new IllegalArgumentException("금액은 0 이상이어야 합니다.");
        }
        this.amount = amount;
    }
}

 

DTO와 검증이 겹쳐 보이는 경우

DTO에 @Min(1)을 달고, 도메인 객체 생성자에도 if (amount < 0) 체크가 있으면 중복처럼 보입니다.
하지만 이 둘은 변경되는 이유와 시점이 다릅니다.

계층 질문 변경 시점
DTO 현재 API 스펙에 맞는가? UI/API 정책 변경 시
Domain 비즈니스 정책상 절대 유효한가? 도메인 규칙 변경 시

 

예를 들어, 웹 화면에서는 할인율을 최대 30%까지만 입력받도록 DTO에 @Max(30)을 달았지만, 회사의 절대 비즈니스 규칙은 "할인율은 50%를 넘을 수 없다"라면 도메인 객체에는 if (rate > 50) 규칙이 따로 들어갑니다. 이 둘은 다른 이유로 바뀌기 때문에 의도된 분리입니다.

 

포인트

Entity / Value Object의 검증은 "현재 시스템 상태에서 이 요청을 수락해도 비즈니스 규칙이 깨지지 않는가?" 를 묻습니다.
도메인 상태(DB)를 반드시 알아야 판단 가능한 것들이 여기에 해당합니다.

 


특수 케이스 — Domain Service: 교차 애그리거트 검증

언제 등장하는가?

이메일 중복 검사를 생각해보겠습니다.

  • User 엔티티는 자신의 이메일은 알지만, 다른 회원들의 이메일은 모릅니다.
  • 따라서 User 엔티티 스스로 중복 여부를 판단할 수 없습니다.
  • 그렇다고 이 로직을 애플리케이션 서비스에 두면, 핵심 비즈니스 규칙이 도메인 바깥으로 새어나갑니다.

이처럼 단일 엔티티에 억지로 끼워 넣기 어색한 도메인 로직을 처리하기 위해 Domain Service가 등장합니다.

 

예제 코드

// 도메인 계층 — 도메인 서비스
public class UserRegistrationValidator {

    private final UserRepository userRepository; // 도메인 인터페이스에만 의존합니다

    public void validateEmailUnique(Email email) {
        if (userRepository.existsByEmail(email)) {
            throw new DuplicatedEmailException("이미 사용 중인 이메일입니다.");
        }
    }
}
// 응용 계층 — 애플리케이션 서비스
@Service
public class UserApplicationService {

    private final UserRegistrationValidator validator;
    private final UserRepository userRepository;

    @Transactional
    public void registerUser(RegisterUserCommand command) {
        // 도메인 서비스가 교차 애그리거트 규칙을 검증합니다
        validator.validateEmailUnique(new Email(command.getEmail()));

        // 검증 통과 후 엔티티 생성
        User newUser = User.register(command.getEmail(), command.getName());
        userRepository.save(newUser);
    }
}

 

주의: Domain Service 남용 금지

Domain Service가 편하다 보니 엔티티가 스스로 할 수 있는 일까지 모두 Domain Service로 빼버리는 함정에 빠지기 쉽습니다.

그렇게 되면 엔티티는 getter/setter만 가득한 빈 껍데기가 됩니다.
이를 빈약한 도메인 모델 (Anemic Domain Model) 이라 부르며, DDD에서는 안티패턴으로 간주합니다.

판단 기준: "이 책임을 엔티티가 스스로 가질 수 있는가?"를 먼저 고민하고, 정 안 될 때만 Domain Service를 꺼내세요.

 

 


전체 요약

       
계층 검증 유형 핵심 질문 도메인 상태 필요?
DTO 구문 검증 데이터 형태가 맞는가?
Command 의미 검증 값들의 조합이 논리적으로 말이 되는가?
Entity / VO 비즈니스 상태 검증 현재 상태에서 규칙이 깨지지 않는가?
Domain Service 교차 애그리거트 검증 단일 엔티티가 알 수 없는 전체 상태를 검증해야 하는가?

 


마치며

검증 로직의 위치를 결정할 때 "이 검증을 위해 도메인의 현재 상태가 필요한가?" 라는 질문 하나를 던져보세요.

  • No → DTO나 Command에서 빠르게 처리해 불필요한 DB 접근을 막습니다.
  • Yes → 도메인 객체(Entity, Domain Service)가 책임집니다.

이 원칙을 지키면 각 계층이 자신의 역할에만 집중하게 되고, 비즈니스 규칙은 도메인 계층 안에 응집됩니다. 그리고 도메인은 어떤 외부 변화에도 흔들리지 않는 견고한 핵심이 됩니다.

 

 


참고 문헌: 만들면서 배우는 클린 아키텍처, 톰 홈버그