프로젝트를 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)가 책임집니다.
이 원칙을 지키면 각 계층이 자신의 역할에만 집중하게 되고, 비즈니스 규칙은 도메인 계층 안에 응집됩니다. 그리고 도메인은 어떤 외부 변화에도 흔들리지 않는 견고한 핵심이 됩니다.
참고 문헌: 만들면서 배우는 클린 아키텍처, 톰 홈버그