@Valid 하나 붙이면 검증이 끝난다고 생각하기 쉽다. 그런데 실제로 코드를 열어보면 검증이 여러 군데에 흩어져 있다. CreateOrderRequest의 @NotBlank, OrderDomain 생성자의 Objects.requireNonNull, Entity인 OrderEntity의 @Column(nullable = false)(그 컬럼은 비어 있으면 안 된다는 DB 쪽 제약), 그리고 Service 어딘가의 if 문까지.
Swagger 문서화 표준에서 “하나의 정보는 한 곳에만 적는다”고 정했는데, 이건 그 원칙을 어기고 있는 걸까? 결론부터 말하면 아니다. 저것들은 중복이 아니라 서로 다른 종류의 검증이다. 이 글은 그 종류를 가르는 기준과, 각각이 어느 계층에 살아야 하는지를 정리한다.
가르는 기준: 도메인 지식이 필요한가
두 검증을 비교해보자.
- “상품명이 비어 있으면 안 된다”
- “재고보다 많이 주문할 수 없다”
앞의 것은 이 시스템이 주문을 다루는지 게시글을 다루는지 몰라도 판단할 수 있다. 뒤의 것은 재고라는 개념과 그 규칙을 알아야 판단할 수 있고, 심지어 DB를 조회해봐야 한다.
이 차이가 계층을 가르는 선이다.
| 검증 종류 | 판단 기준 | 위치 |
|---|---|---|
| 형식 검증 | 도메인 지식 없이 판단 가능 | Controller (XxxRequest) |
| 비즈니스 검증 | 도메인 규칙을 알아야 판단 가능 | Service |
| 데이터 기반 검증 | DB에서 가져온 상태를 봐야 판단 가능 | Domain |
형식 검증 (@Valid)"] B -->|위반| E["400
errors 배열 반환"] B -->|통과| C["OrderService
비즈니스 규칙 검증"] C --> D["StockDomain
조회된 상태로 규칙 판단"] D -->|위반| F["409
ErrorCodeException"] D -->|통과| G["정상 처리"] style B fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0 style C fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style D fill:#1a202c,stroke:#ed8936,stroke-width:3px,color:#e2e8f0 style E fill:#2d3748,stroke:#f56565,stroke-width:2px,color:#e2e8f0 style F fill:#2d3748,stroke:#f56565,stroke-width:2px,color:#e2e8f0 style G fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0
그림을 읽는 법은 이렇다. HTTP 요청이 들어오면 CreateOrderRequest가 먼저 형식을 검사하는데, 여기서 걸리면 400(보낸 값 자체가 잘못됐다는 뜻)과 함께 errors 배열을 돌려준다. 통과하면 OrderService로 넘어가 비즈니스 규칙을 검증하고, 다시 StockDomain이 지금 가진 상태(재고 수량 같은 DB 조회 결과)로 규칙을 판단한다. 여기서 걸리면 409(요청 자체는 멀쩡한데 지금 상태와 충돌한다는 뜻)로 막힌다. 둘 다 통과해야 정상 처리로 간다.
원칙 1. Controller는 형식만 검증한다
XxxRequest에서 검증하는 것은 비즈니스 로직이 전혀 개입하지 않는 것들이다. 필수값이 들어왔는지, 공백은 아닌지, 길이와 형식은 맞는지.
@Getter
public class CreateOrderRequest {
@NotBlank
@Size(max = 50)
@Schema(description = "주문할 상품의 표시명", example = "무선 마우스")
private final String productName;
@NotNull
@Min(1)
@Schema(description = "주문 수량 (낱개 기준)", example = "3")
private final Integer quantity;
@JsonCreator
public CreateOrderRequest(
@JsonProperty("productName") String productName,
@JsonProperty("quantity") Integer quantity
) {
this.productName = productName;
this.quantity = quantity;
}
public CreateOrderCommand toCommand() {
return CreateOrderCommand.of(productName, quantity);
}
}
한 줄씩 보면, @ 로 시작하는 것들은 모두 애너테이션이다 — 클래스나 필드에 붙여서 “이렇게 다뤄 달라”고 표시만 해 두면, 스프링이나 Jackson 같은 도구가 그 표시를 보고 실제 동작을 대신해 준다. @Getter는 Lombok이 각 필드의 getter 메서드를 자동으로 만들어 주는 표시다. @NotBlank·@Size는 Bean Validation이 “공백이면 안 된다”, “길이가 몇 자 이하여야 한다”를 검사하게 거는 규칙이다. @Schema는 코드 동작에는 영향을 주지 않고, description과 example에 적은 설명과 예시 값을 Swagger 문서에 그대로 보여주는 표시다. @JsonCreator·@JsonProperty는 JSON을 이 생성자로 되돌릴 때 어떤 JSON 키가 어떤 생성자 인자와 짝인지 Jackson에게 알려준다.
여기서 검증 어노테이션은 Swagger 문서화 표준에 따라 제약 조건의 단일 출처 역할도 겸한다. 검증과 문서가 같은 어노테이션에서 나오므로 둘이 어긋날 수 없다.
필수값을 받을 필드는 박싱 타입을 쓴다
int quantity로 선언하면 JSON에 quantity 필드가 아예 없어도 0이 들어간 채로 통과한다. @NotNull이 붙어 있어도 소용없다. 원시 타입에는 null이 존재할 수 없으므로 Jackson(자바 객체와 JSON을 서로 바꿔 주는 도구)이 기본값 0을 채우고, 검증기 입장에서는 “값이 있는” 상태이기 때문이다.
// ❌ 필드 누락을 감지하지 못한다
@NotNull
private int quantity; // 요청에 없으면 0 → 검증 통과
// ✅ 누락되면 null이 되어 @NotNull이 잡아낸다
@NotNull
private Integer quantity;
“필수 데이터가 들어왔는지”를 검사하는 것이 이 계층의 역할이므로, 누락을 감지할 수 있는 타입을 선택해야 한다.
null을 허용하는 필드는 @Nullable로 명시한다
검증 어노테이션을 붙이지 않은 필드는 null이 그대로 통과한다. 문제는 그것이 의도한 선택 항목인지, @NotNull을 빠뜨린 실수인지 코드만 봐서는 구분되지 않는다는 점이다. 판단을 어노테이션의 “부재”에 맡기게 된다.
Null-free 객체 설계에서는 이 문제를 orNull 접미사로 해결했다. 하지만 Request DTO에는 그대로 적용할 수 없다. 필드명이 곧 JSON 키이자 API 스펙이기 때문이다. 이름을 바꾸면 클라이언트가 보내야 할 키가 바뀐다.
@JsonProperty로 필드명과 JSON 키를 분리하는 방법도 있지만, 그러면 다른 곳이 깨진다. MethodArgumentNotValidException(@Valid 검증이 실패하면 스프링이 던지는 예외. 어느 필드가 왜 실패했는지가 이 예외 안에 담긴다)의 FieldError는 자바 필드명을 담기 때문에, 원칙 4에서 정한 errors 배열의 field 값이 클라이언트가 보낸 JSON 키와 어긋난다.
// 클라이언트는 couponCode를 보냈는데 에러는 다른 이름으로 온다
{ "field": "couponCodeOrNull", "message": "..." }
위 중괄호로 된 것이 JSON 한 덩어리다 — "키": 값 모양으로 적어, 필드 하나(field)와 메시지 하나(message)를 담고 있다는 뜻이다. 그래서 Request DTO에서는 이름 대신 @Nullable로 표기한다.
@Getter
public class CreateOrderRequest {
@NotBlank
@Size(max = 50)
@Schema(description = "주문할 상품의 표시명", example = "무선 마우스")
private final String productName;
@Nullable
@Schema(description = "적용할 쿠폰 코드 (선택 항목)", example = "WELCOME10")
private final String couponCode;
@JsonCreator
public CreateOrderRequest(
@JsonProperty("productName") String productName,
@JsonProperty("couponCode") String couponCode
) {
this.productName = productName;
this.couponCode = couponCode;
}
public CreateOrderCommand toCommand() {
return CreateOrderCommand.of(productName, couponCode);
}
}
orNull이 필요했던 이유는 자바에 nullable을 표현할 문법이 없어서였다. Request DTO에는 어노테이션이라는 수단이 이미 있으므로, 이건 규칙의 예외가 아니라 같은 원칙을 그 계층에 맞는 방식으로 구현한 것이다. IDE가 @Nullable 필드를 null 체크 없이 사용할 때 경고를 띄워준다는 이점도 따라온다.
JSON 계약을 벗어나는 순간부터는 다시 이름 규칙을 따른다
@Nullable이 통하는 것은 JSON 키가 계약인 Request와 Response다. Command·Query·Domain처럼 Java 호출부에서 읽는 타입은 orNull 접미사를 쓴다. 다른 도메인에 내보내는 Snapshot도 같은 규칙이다. 전체 경계는 경계 DTO의 null 표기에서 정리한다.
@Getter
public class CreateOrderCommand {
private final String productName;
private final String couponCodeOrNull;
private CreateOrderCommand(String productName, String couponCodeOrNull) {
this.productName = productName;
this.couponCodeOrNull = couponCodeOrNull;
}
public static CreateOrderCommand of(String productName, String couponCodeOrNull) {
return new CreateOrderCommand(productName, couponCodeOrNull);
}
}
기준은 그 이름이 JSON 키가 되는가와 Java 호출부까지 위험이 따라가야 하는가다. Request의 필드는 자기 자신의 toCommand() 안에서만 읽히고 Response는 JSON 스키마가 주된 계약이므로 이름 대신 어노테이션을 쓴다. 반면 Command와 Domain, Snapshot은 여러 Java 파일에서 읽히기 때문에 이름이 호출부까지 따라가야 한다. 그리고 이쪽은 JSON 계약이 없으니 이름을 자유롭게 지을 수 있다.
거꾸로 말하면, Request의 필드를 toCommand() 밖에서 꺼내 쓰고 있다면 그건 계층이 새고 있다는 신호다.
null-free 철학의 시스템 경계 버전
이 규칙은 Null-free 객체 설계의 연장선에 있다. 그 글의 전제는 “애플리케이션 내부에는 null이 없다”였고, 그러기 위해 생성 시점에 완전한 상태를 보장했다.
Controller의 형식 검증은 같은 일을 시스템 경계에서 하는 것이다. 외부에서 들어오는 값 중 null과 빈 값을 입구에서 막아버리면, 그 뒤의 Command와 Domain은 null 걱정 없이 자기 로직에만 집중할 수 있다.
원칙 2. 비즈니스 규칙은 Service가 검증한다
도메인 지식이 필요한 규칙은 Controller에 올리지 않는다. Controller가 그걸 알게 되면 비즈니스 로직이 HTTP 계층으로 새어나온다.
// ❌ 비즈니스 규칙이 Request로 올라왔다
@Getter
public class CreateOrderRequest {
@NotBlank
private final String productName;
@NotNull
private final LocalDate deliveryDate;
// 생성자 생략
@AssertTrue(message = "당일 배송은 오전 11시 이전 주문만 가능합니다.")
public boolean isDeliveryDateValid() {
// 이 규칙이 왜 Request DTO에 있어야 하는가?
return !deliveryDate.isEqual(LocalDate.now()) || LocalTime.now().isBefore(LocalTime.of(11, 0));
}
}
“당일 배송 마감 시각”은 언제든 바뀔 수 있는 비즈니스 정책이다. 이런 규칙이 Request DTO에 있으면 정책이 바뀔 때 API 스펙 클래스를 고쳐야 하고, 같은 규칙을 배치나 내부 호출에서 재사용할 수도 없다.
// ✅ 비즈니스 규칙은 Service에서
@Service
@RequiredArgsConstructor
class OrderService implements OrderCommandUseCase {
private final OrderPolicy orderPolicy;
public ServiceResult<OrderDomain> createOrder(CreateOrderCommand command) {
orderPolicy.validateDeliveryDate(command.getDeliveryDate());
// ...
}
}
원칙 3. DB를 봐야 하는 검증은 Domain이 판단한다
가장 까다로운 경우다. “재고가 충분한가”, “이미 가입된 이메일인가” 같은 검증은 DB를 조회해야만 판단할 수 있다.
Bean Validation의 커스텀 ConstraintValidator로 만들면 @Valid 한 번에 처리되어 편하다. 하지만 그러려면 Validator에 Repository를 주입해야 하고, 그 Validator는 Controller의 @Valid 시점에 실행된다. 결국 Controller 계층의 검증이 DB를 알게 된다. DTO 표준에서 세운 계층 경계가 여기서 무너진다.
// ❌ Controller 계층 검증이 Repository를 의존한다
public class StockAvailableValidator implements ConstraintValidator<StockAvailable, CreateOrderRequest> {
private final StockRepository stockRepository; // Request 검증기가 DB를?
@Override
public boolean isValid(CreateOrderRequest request, ConstraintValidatorContext context) {
return stockRepository.findQuantity(request.getProductName()) >= request.getQuantity();
}
}
한 줄씩 보면, ConstraintValidator<StockAvailable, CreateOrderRequest>를 구현한다는 것은 “@StockAvailable이라는 커스텀 검증 애너테이션이 어떤 클래스에 붙으면, 이 클래스가 그 검증을 실제로 수행한다”는 뜻이다. 생성자로 받은 stockRepository 필드는 재고를 조회하기 위한 것인데, 바로 이 필드가 문제다 — Request를 검증하는 클래스가 Repository를, 즉 DB를 직접 의존하게 된다. isValid 메서드는 @Valid가 이 Request를 검사할 때 자동으로 불려서, 재고 수량이 주문 수량 이상인지(>=)를 그 자리에서 DB까지 조회해 판단하고 참·거짓을 돌려준다.
그래서 책임을 둘로 나눈다. 조회는 Service가, 규칙 판단은 Domain이 한다.
// ✅ Service는 무엇을 조회할지 알고
@Service
@RequiredArgsConstructor
class OrderService implements OrderCommandUseCase {
private final StockRepository stockRepository;
private final OrderRepository orderRepository;
public ServiceResult<OrderDomain> createOrder(CreateOrderCommand command) {
StockDomain stock = stockRepository.findByProductName(command.getProductName())
.orElseThrow(() -> ErrorCodeException.of(ErrorCode.PRODUCT_NOT_FOUND));
stock.validateOrderable(command.getQuantity()); // 규칙 판단은 Domain에 위임
OrderDomain order = OrderDomain.create(command.getProductName(), command.getQuantity(), stock.getUnitPrice());
return ServiceResult.of(orderRepository.save(order));
}
}
한 줄씩 보면, findByProductName(...)이 돌려준 Optional(값이 있을 수도 없을 수도 있다는 걸 타입으로 드러낸 상자)이 비어 있으면 orElseThrow가 그 자리에서 PRODUCT_NOT_FOUND 예외를 던진다. orElseThrow(() -> ...)의 () -> ErrorCodeException.of(...)는 람다다 — 인자를 받지 않고(괄호 안이 비어 있다) 화살표 뒤의 코드 한 줄을 실행하는 함수를 그 자리에서 바로 만든 것이다. orElseThrow는 값이 없을 때만 이 함수를 불러 예외를 만들어 던지므로, 값이 있을 때는 예외 객체를 만드는 비용조차 들지 않는다. 값이 있으면 stock.validateOrderable(...)을 불러 재고 판단을 Domain에 맡기고, 통과하면 OrderDomain.create(...)로 주문을 만들어 저장한다.
// ✅ Domain은 그 상태가 규칙에 맞는지를 안다
public final class StockDomain {
private final String productName;
private final int availableQuantity;
private final BigDecimal unitPrice;
public void validateOrderable(int requestedQuantity) {
if (availableQuantity < requestedQuantity) {
throw ErrorCodeException.of(
ErrorCode.STOCK_SHORTAGE,
"재고가 부족합니다. 요청 %d개, 주문 가능 %d개"
.formatted(requestedQuantity, availableQuantity));
}
}
public BigDecimal getUnitPrice() {
return unitPrice;
}
}
한 줄씩 보면, validateOrderable은 요청한 수량(requestedQuantity)이 가진 재고(availableQuantity)보다 많으면 예외를 던진다. 메시지의 "재고가 부족합니다. 요청 %d개, 주문 가능 %d개".formatted(requestedQuantity, availableQuantity)에서 %d는 정수 값이 들어갈 자리를 표시해 둔 것이고, .formatted(...)는 그 자리들을 괄호 안에 적은 값으로 순서대로 채워 완성된 문자열을 만든다 — 여기서는 첫 번째 %d에 requestedQuantity가, 두 번째 %d에 availableQuantity가 들어간다.
이렇게 나누면 Domain은 여전히 영속성(프로그램이 꺼져도 데이터가 사라지지 않고 DB에 남는 성질)을 모른다. Repository가 어떻게 데이터를 가져왔는지, JPA인지 MyBatis인지 알 필요가 없다. Domain이 아는 것은 “내가 지금 가진 상태로 이 요청이 가능한가” 하나뿐이다.
또 하나의 이득은 규칙이 Service 메서드 안에 흩어지지 않는다는 것이다. 재고 판단 규칙이 StockDomain 한 곳에만 있으므로, 주문 생성이든 예약이든 어디서 호출하든 같은 규칙이 적용된다.
원칙 4. 검증 실패는 errors로 필드별로 내려준다
반증: 세 가지 검증 실패가 Advice 의 한 핸들러로 모이는가 — Boot 3.5.16 (가)본문객체 400/manv/MethodArgumentNotValidException (나)파라미터제약 400/cv/ConstraintViolationException (다)폼객체 400/manv/MethodArgumentNotValidException · Boot 4.1.1 (가)본문객체 400/manv/MethodArgumentNotValidException (나)파라미터제약 400/cv/ConstraintViolationException (다)폼객체 400/manv/MethodArgumentNotValidException — 증명
인용줄 읽는 법. (가)·(나)·(다)는 비교한 상황 세 가지에 붙인 이름표이고, 그 뒤에 상황 이름(예: 본문객체)을 적는다. 슬래시(/)로 이어지는 값은 “상태 코드 / 예외 이름의 줄임말 / 실제 예외 클래스 이름” 순서다 — manv는 MethodArgumentNotValidException을, cv는 ConstraintViolationException을 줄인 것이다. Boot 버전이 여럿이면 가운데 점(·)으로 묶어 결과를 나란히 보여준다. 위 인용줄의 Advice는 @RestControllerAdvice를 줄여 부른 이름이다 — 모든 Controller의 예외를 한곳에서 받아 처리하는 클래스에 붙이는 표시로, 뒤에서 자세히 다룬다.
@Valid 실패는 어느 필드가 왜 틀렸는지를 클라이언트가 알아야 화면에 표시할 수 있다. 그래서 DTO 표준의 HttpApiResponse<T>에 errors 필드를 추가한다.
@Getter
public class HttpApiResponse<T> {
private final boolean success;
private final int status;
private final int code;
private final String message;
private final T data;
private final List<FieldError> errors;
private HttpApiResponse(
boolean success, int status, int code, String message, T data, List<FieldError> errors) {
this.success = success;
this.status = status;
this.code = code;
this.message = message;
this.data = data;
this.errors = errors;
}
public static <T> HttpApiResponse<T> ok(T data) {
return success(200, "OK", data);
}
public static HttpApiResponse<Void> ok() {
return success(200, "OK", null);
}
public static <T> HttpApiResponse<T> created(T data) {
return success(201, "Created", data);
}
public static <T> HttpApiResponse<T> accepted(T data) {
return success(202, "Accepted", data);
}
private static <T> HttpApiResponse<T> success(int status, String message, T data) {
int code = status * 1_000_000;
return new HttpApiResponse<>(true, status, code, message, data, List.of());
}
public static HttpApiResponse<Void> error(ErrorCode errorCode, String message) {
return new HttpApiResponse<>(
false, errorCode.getStatus(), errorCode.getCode(), message, null, List.of());
}
public static HttpApiResponse<Void> validationError(List<FieldError> errors) {
ErrorCode errorCode = ErrorCode.INVALID_REQUEST;
return new HttpApiResponse<>(
false, errorCode.getStatus(), errorCode.getCode(), errorCode.getDefaultMessage(), null, errors);
}
@Getter
public static class FieldError {
private final String field;
private final String message;
private FieldError(String field, String message) {
this.field = field;
this.message = message;
}
public static FieldError of(String field, String message) {
return new FieldError(field, message);
}
}
}
한 줄씩 보면, ok·created·accepted는 전부 정적 제네릭 메서드다 — <T>는 그 메서드를 부를 때 넘긴 데이터 타입을 그대로 받아, 결과 타입도 HttpApiResponse<T>가 되게 한다. 셋 다 내부적으로 success(status, message, data)를 불러 success 필드를 true로, HTTP 상태 번호와 메시지만 다르게 채운 객체를 만든다. error()와 validationError()는 반대로 success를 false로 두고, errors 목록까지 채워 돌려준다.
성공 응답에서도 errors는 null이 아니라 빈 리스트다. 클라이언트가 필드 존재 여부로 분기하지 않고 항상 같은 방식으로 파싱할 수 있게 하기 위해서다. 이것도 null-free 철학의 연장이다.
검증 실패를 응답으로 바꾸는 일은 @RestControllerAdvice에서 처리한다. 다만 핸들러 하나로는 부족하다.
오래 「한 핸들러에서 받는다」고 적어뒀는데 실측에서 갈렸다. 값이 어디에 실려 오는지에 따라 예외 타입이 다르다(Boot 3.5.16·4.1.1 동일). 값이 들어오는 방식은 세 가지다. “본문 객체”는 @RequestBody로 받은 JSON을 통째로 한 객체에 담아 @Valid로 검사하는 경우고, “폼·쿼리 객체”는 @ModelAttribute(주소의 폼 값·쿼리 파라미터를 객체 하나로 모아 담는 표시)를 쓰는 경우다. 이 둘은 값이 객체 하나에 모이므로 같은 예외가 난다. “파라미터 제약”은 메서드 파라미터 하나에 직접 @Min 같은 규칙을 붙이고 클래스에는 @Validated(객체가 아니라 파라미터 하나하나도 검사하게 해 주는 스프링의 표시)를 붙이는 경우인데, 객체를 거치지 않아 다른 예외가 난다.
본문 객체 @RequestBody @Valid → MethodArgumentNotValidException
폼·쿼리 객체 @ModelAttribute @Valid → MethodArgumentNotValidException
파라미터 제약 @Validated + @Min → ConstraintViolationException ← 갈린다
상태는 셋 다 400이라 눈으로는 티가 안 난다. 핸들러를 하나만 두면 파라미터 제약 위반만 errors 형식이 아닌 채로 나간다.
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<HttpApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
List<HttpApiResponse.FieldError> errors = e.getBindingResult().getFieldErrors().stream()
.map(error -> HttpApiResponse.FieldError.of(error.getField(), error.getDefaultMessage()))
.toList();
return ResponseEntity
.status(ErrorCode.INVALID_REQUEST.getStatus())
.body(HttpApiResponse.validationError(errors));
}
// 파라미터 제약(@Validated + @Min 등)은 위 핸들러에 안 걸린다
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<HttpApiResponse<Void>> handleConstraint(ConstraintViolationException e) {
List<HttpApiResponse.FieldError> errors = e.getConstraintViolations().stream()
.map(v -> HttpApiResponse.FieldError.of(lastNode(v.getPropertyPath()), v.getMessage()))
.toList();
return ResponseEntity
.status(ErrorCode.INVALID_REQUEST.getStatus())
.body(HttpApiResponse.validationError(errors));
}
@ExceptionHandler(ErrorCodeException.class)
public ResponseEntity<HttpApiResponse<Void>> handleErrorCode(ErrorCodeException e) {
ErrorCode errorCode = e.getErrorCode();
return ResponseEntity
.status(errorCode.getStatus())
.body(HttpApiResponse.error(errorCode, e.getMessage()));
}
}
클래스에 붙은 @RestControllerAdvice는 이 클래스 안의 메서드들이 모든 Controller에서 터지는 예외를 받게 등록한다. 각 메서드 위의 @ExceptionHandler(XxxException.class)는 “괄호 안 타입의 예외(또는 그 하위 타입)가 Controller 어디서든 잡히지 않고 올라오면 이 메서드를 대신 부른다”는 연결 표시다. 그래서 메서드를 직접 호출하는 코드가 어디에도 없어도, 해당 예외가 던져지는 순간 스프링이 타입을 보고 맞는 메서드를 찾아 실행한다.
한 줄씩 보면, 첫 번째 메서드는 MethodArgumentNotValidException(본문·폼 객체 검증 실패)을 받아 e.getBindingResult().getFieldErrors()로 어떤 필드가 왜 틀렸는지 꺼낸다. 두 번째 메서드는 ConstraintViolationException을 받는데, 이건 객체가 아니라 파라미터 하나하나에 붙은 제약이 깨졌을 때 나는 예외라 꺼내는 방법도 다르다 — e.getConstraintViolations()를 거쳐 v.getPropertyPath()(어떤 파라미터인지 가리키는 경로)에서 마지막 이름만 뽑아 쓴다. 세 번째는 이미 우리가 만든 ErrorCodeException을 처리한다. 세 메서드 모두 반환 타입이 ResponseEntity인데, 이건 응답 본문뿐 아니라 HTTP 상태 번호까지 함께 담아 돌려주는 상자다 — .status(...)로 상태 번호를, .body(...)로 본문을 채운다.
상태 코드를 하드코딩하지 않고 ErrorCode에서 가져온다는 점이 중요하다. 그래야 “존재하지 않는 주문”은 404로, “재고 부족”은 409로 각각 나갈 수 있다. ErrorCode 설계와 로깅 정책은 예외 처리 표준에서 다룬다.
응답은 다음과 같은 모양이 된다.
{
"success": false,
"status": 400,
"code": 400000001,
"message": "요청 값이 유효하지 않습니다.",
"data": null,
"errors": [
{ "field": "productName", "message": "공백일 수 없습니다" },
{ "field": "quantity", "message": "1 이상이어야 합니다" }
]
}
형식 검증 실패는 400에 필드별 상세를, 비즈니스 규칙 위반은 그에 맞는 상태 코드와 메시지를 담는다. 어느 계층에서 걸렸는지가 상태 코드로 드러난다.
원칙 5. 검증은 컨트롤러 메서드에 들어가기 전에 끝난다
@Valid가 정확히 언제 실행되는지 알아야 앞의 규칙들이 왜 그런지 이해된다. HTTP 요청이 들어오면 DispatcherServlet이 그 요청을 처리할 Controller의 메서드를 찾아 직접 호출한다 — 내가 그 메서드를 부르는 코드를 쓴 적이 없어도 요청이 올 때마다 자동으로 실행된다는 뜻이다. 그 호출 전에 거치는 순서는 이렇다.
- Jackson이 요청 본문을 객체로 만든다. 이 시점에는 아직 검증이 돌지 않았다. 필수 필드가 빠져 있으면 그 자리에 null이 들어간 객체가 잠시 존재한다.
- 곧바로 Bean Validation이 실행된다.
RequestResponseBodyMethodProcessor(요청 본문을 객체로 만들고 검증까지 이어서 처리하는 스프링 내부 부품)가 파라미터에@Valid가 붙어 있는지 확인하고 검증기를 돌린다. - 실패하면
MethodArgumentNotValidException을 던진다. 이때 컨트롤러 메서드는 호출조차 되지 않는다.
@PostMapping("/orders")
public ResponseEntity<HttpApiResponse<OrderCreatedResponse>> createOrder(
@RequestBody @Valid CreateOrderRequest request) {
// 검증을 통과한 요청만 여기에 도달한다. toCommand()도 그 이후에 실행된다.
}
메서드 위의 @PostMapping("/orders")는 “HTTP POST 방식으로 /orders 주소에 온 요청이 오면 이 메서드를 부른다”를 정하는 표시다. 앞서 1~3단계에서 본 Jackson 변환과 Bean Validation은 이 메서드가 불리기 직전에 끼어드는 과정이라, 검증을 통과하지 못하면 이 메서드 본문은 아예 실행되지 않는다.
여기서 세 가지 규칙이 따라 나온다.
Request DTO의 생성자에서는 검증하지 않는다
Domain은 생성자에서 완전성을 보장하지만, Request DTO는 정반대로 생성자를 비워둔다. 역직렬화가 검증보다 먼저 일어나기 때문이다.
// ❌ Bean Validation이 돌기 전에 터진다
@Getter
public class CreateOrderRequest {
private final String productName;
private final Integer quantity;
@JsonCreator
public CreateOrderRequest(
@JsonProperty("productName") String productName,
@JsonProperty("quantity") Integer quantity
) {
this.productName = Objects.requireNonNull(productName, "productName");
this.quantity = quantity;
}
}
이렇게 쓰면 1단계(Jackson 역직렬화)에서 예외가 발생하므로, 우리가 설계한 errors 배열 400 응답이 아니라 HttpMessageNotReadableException이 나간다. Request DTO의 검증은 전적으로 어노테이션에 맡긴다.
중첩 객체는 필드에도 @Valid를 붙인다
파라미터의 @Valid는 한 겹만 검증한다. 안쪽 객체까지 내려가려면 필드에도 붙여야 한다.
@Getter
public class CreateOrderRequest {
@NotBlank
private final String ordererName;
@Valid // 이게 없으면 items 내부는 검증되지 않는다
@NotEmpty
private final List<OrderItemRequest> items;
// 생성자 생략
}
역직렬화 실패 핸들러도 함께 등록한다
"quantity": "세개"처럼 타입이 맞지 않으면 1단계에서 실패하므로 MethodArgumentNotValidException이 아니다. 이 핸들러가 없으면 응답 형식이 깨진다.
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<HttpApiResponse<Void>> handleNotReadable(HttpMessageNotReadableException e) {
ErrorCode errorCode = ErrorCode.MALFORMED_REQUEST_BODY;
return ResponseEntity
.status(errorCode.getStatus())
.body(HttpApiResponse.error(errorCode, errorCode.getDefaultMessage()));
}
가장 큰 구멍은 @Valid 누락이다
이 구조의 안전성은 @Valid가 붙어 있다는 사실에 전적으로 의존한다. DTO에 검증 어노테이션이 아무리 꼼꼼히 붙어 있어도, 컨트롤러에서 @Valid 하나가 빠지면 검증기가 아예 실행되지 않고 null이 그대로 toCommand()로 흘러간다.
Domain이라면 생성자가 막아줬을 상황인데 여기서는 안 막힌다. Request DTO의 널 안전성은 객체 자신이 아니라 파이프라인이 보장하는 것이고, 그래서 @Valid는 빠뜨리면 안 되는 유일한 지점이다.
원칙 6. 대량 처리의 진입점은 JobParameters 하나다
원칙 2에서 “Service는 들어온 값이 이미 형식 검증을 통과했다고 전제한다”고 쓰면서 HTTP가 아닌 진입점도 자기 방식으로 검증을 마쳐야 한다고 덧붙였다. 그런데 Repository 설계 표준에서 정한 배치 경로는 Service도 Domain도 거치지 않는다. 검증할 자리가 어디인지 정한 적이 없었다.
대량 데이터를 안전하게 처리하는 틀인 Spring Batch로 작업을 시작할 때, 그 작업에 넘기는 입력값들을 JobParameters라고 부른다 — cutoff 날짜처럼 사람이 실행 시점에 정해 주는 값이다.
대량 처리 경로로 들어오는 것을 세어보니 두 가지인데, 검증 대상은 하나뿐이었다.
| 들어오는 것 | 검증 대상인가 |
|---|---|
JobParameters (cutoff 날짜, 대상 상태 등) |
그렇다. 사람이 스케줄러·CLI에서 넣는 값이다 |
ItemReader가 DB에서 읽는 행 |
아니다. 이미 우리 DB에 있고, 저장될 때 검증을 통과했다 |
두 번째를 다시 검증하는 건 같은 값을 두 번 믿지 않는 것이 아니라 그냥 중복이다. 우리 DB에 있는 데이터를 우리가 못 믿는다면 그건 검증 문제가 아니라 저장 경로의 문제다.
그래서 대량 처리의 방어선은 JobParameters 한 곳이고, Spring Batch에 이미 그 자리가 있다.
@Bean
JobParametersValidator expireOrderValidator() {
return parameters -> {
String cutoff = parameters.getString("cutoff");
if (cutoff == null) {
throw new JobParametersInvalidException("cutoff는 필수다");
}
if (LocalDate.parse(cutoff).isAfter(LocalDate.now())) {
throw new JobParametersInvalidException("cutoff는 과거 날짜여야 한다");
}
};
}
한 줄씩 보면, @Bean은 “이 메서드가 돌려주는 객체를 빈으로 등록해 달라”는 표시다 — expireOrderValidator() 메서드가 반환하는 JobParametersValidator 객체가 스프링이 관리하는 빈이 된다. return parameters -> { ... }은 람다다 — JobParametersValidator가 메서드 하나짜리 인터페이스라, 그 메서드를 구현하는 클래스를 따로 만드는 대신 parameters라는 이름으로 입력을 받아 중괄호 안의 동작을 실행하는 함수를 그 자리에서 바로 만들어 넘긴 것이다. 이 함수는 cutoff 파라미터가 없거나 미래 날짜면 JobParametersInvalidException을 던진다.
JobParametersValidator는 Job이 시작되기 전에 실행되고, 실패하면 Job이 아예 뜨지 않는다. Controller의 @Valid와 같은 자리다.
assert로는 안 된다. assert 표준에서 “운영에서는 -ea를 켜지 않는다”고 정했다. 배치는 운영에서 도는 코드이므로 assert에 검증을 맡기면 운영에서 한 줄도 실행되지 않는다. 이건 그 표준이 금지한 “외부 입력 검증에 assert를 쓰지 않는다”에 그대로 해당한다.
벌크로 해도 되는 일의 경계
여기서 더 중요한 걸 정해야 한다. 벌크는 Domain을 거치지 않으므로 비즈니스 규칙을 검증할 자리가 구조적으로 없다. 이건 성능을 얻기 위해 받아들인 대가인데, 적어두지 않으면 규칙 판단이 필요한 연산까지 벌크로 밀어넣게 된다.
"PENDING이 30일 지나면 EXPIRED" → 벌크 OK. 판단이 필요 없다. 조건이 곧 규칙이다
"등급별 할인을 재산정" → 벌크 금지. Domain이 판단할 정책이 있다
기준은 “이 연산에 도메인의 판단이 개입하는가”다. 개입한다면 벌크로 하면 안 되고, 느려도 Domain을 하나씩 거쳐야 한다. 느린 게 문제라면 애그리거트 경계나 정책 자체를 다시 봐야 할 신호다 — 검증을 건너뛰어 속도를 사는 것은 답이 아니다.
그 “하나씩 거치는” 경로도 결국 대량이라 Spring Batch로 돈다. 그쪽은 XxxUseCase를 호출하므로 검증이 Service와 Domain에 그대로 남아 있고, 이 원칙이 걱정한 공백이 생기지 않는다. 패키지와 예외 규칙은 배치 실행 표준에서 다룬다.
판단 기준 정리
| 검증 내용 | 위치 | 수단 | 실패 응답 |
|---|---|---|---|
| 필수값 누락, 공백 | XxxRequest |
@NotNull, @NotBlank |
400 + errors |
| 길이, 숫자 범위, 형식 | XxxRequest |
@Size, @Min, @Pattern |
400 + errors |
| 비즈니스 정책 (마감 시각, 할인 조건 등) | Service | 정책 클래스 또는 Service 메서드 | 409 + message |
| DB 상태 기반 규칙 (재고, 중복) | Domain | Domain의 validateXxx() |
409 + message |
| 객체 불변조건 | Domain 생성자 | Objects.requireNonNull, 검증 메서드 |
500 (도달하면 버그) |
| 배치 실행 파라미터 | Job 조립 시점 | JobParametersValidator |
Job이 시작되지 않는다 |
마지막 줄이 중요하다. Domain 생성자의 검증은 정상 흐름에서는 절대 실패하지 않아야 한다. 앞 단계에서 이미 다 걸렀기 때문이다. 여기서 예외가 터졌다면 그건 사용자 입력 문제가 아니라 프로그래머의 실수이고, assert 글에서 정리한 내부 검증의 영역이다.
이 표준을 정하기까지
출발점은 “검증이 중복 아닌가?”라는 의심이었다. Request에 @NotBlank가 있고, Domain 생성자에 requireNonNull이 있고, Entity에 @Column(nullable = false)가 있다. 스웨거 표준에서 “하나의 정보는 한 곳에만”이라고 정해놓고 이건 세 군데에 있으니 원칙을 어긴 것처럼 보였다.
그런데 하나씩 뜯어보니 목적이 전부 달랐다. Request의 검증은 외부 입력을 막는 것이고, Domain 생성자의 검증은 객체가 깨진 상태로 존재하지 못하게 하는 것이고, Entity의 제약은 DB 스키마의 선언이다. 같은 필드를 보고 있을 뿐 답해야 하는 질문이 다르다. 중복이 아니라는 결론이 나왔다.
그러면 무엇을 어디서 검증할 것인가. 여기서 기준이 필요했는데, 정리해보니 하나로 수렴했다 — 그 규칙을 판단하는 데 도메인 지식이 필요한가. “공백이면 안 된다”는 도메인을 몰라도 판단할 수 있으니 Controller의 몫이고, “재고보다 많이 못 산다”는 도메인을 알아야 하니 Service의 몫이다. 이 기준은 애매한 경우에도 잘 작동했다.
Controller 검증의 성격도 이 과정에서 분명해졌다. 필수값과 공백을 입구에서 막는 것은 결국 null-free 철학을 시스템 경계까지 밀어붙인 것이다. 그 글에서 생성자로 객체의 완전성을 보장했다면, 여기서는 API 입구에서 애플리케이션 전체의 입력 완전성을 보장한다. 그래야 안쪽에서 null 체크가 사라진다.
가장 오래 고민한 건 DB 조회가 필요한 검증이었다. 커스텀 ConstraintValidator를 쓰면 @Valid 한 번으로 중복 이메일까지 잡히고 필드 에러 응답도 일관되게 나온다. 편의성만 보면 이쪽이 압도적이다. 그런데 그러려면 검증기가 Repository를 의존해야 하고, 그건 Controller 계층 검증이 DB를 아는 구조다. DTO 표준에서 계층 경계를 그렇게 공들여 세워놓고 여기서 뚫을 이유가 없었다.
결국 조회와 판단을 나눴다. Service는 “무엇을 조회할지”를 알고, Domain은 “그 상태로 이게 가능한지”를 안다. 이 분리의 부수 효과가 꽤 컸는데, 재고 판단 규칙이 StockDomain 한 곳에 모이면서 Service 메서드마다 흩어지던 if 문이 사라졌다. Domain은 여전히 영속성을 모르고, 규칙은 한 곳에만 존재한다.
응답 형태도 손봐야 했다. 기존 HttpApiResponse에는 message 하나뿐이라 “어느 필드가 틀렸는지”를 담을 자리가 없었다. data에 에러 목록을 넣는 방법도 있었지만 그러면 data의 의미가 상황마다 달라진다. 그래서 errors 필드를 따로 추가했고, 성공 응답에서도 빈 리스트로 항상 존재하게 했다 — 클라이언트가 분기 없이 파싱하도록.
“이게 정말 널 세이프한가”라는 질문이 남았다. 검증이 언제 도는지 확인해보니, Jackson이 객체를 먼저 만들고 그다음에 검증기가 돈다. 즉 null이 들어간 Request 객체가 잠깐 존재하는 구간이 있다. 다만 그 구간에서는 내가 쓴 코드가 아무것도 실행되지 않으니 실질적으로는 안전하다. 여기서 Domain과의 차이가 분명해졌다 — Domain은 객체 자신이 완전성을 보장하고, Request는 파이프라인이 보장한다. 그래서 @Valid 하나가 빠지면 전부 무너진다는 것도 같이 드러났다.
마지막은 nullable을 어떻게 표기할 것인가였다. orNull 접미사를 Request에도 쓰려니 필드명이 곧 JSON 키라서 API 스펙이 바뀌어버렸다. @JsonProperty로 분리해봤더니 이번엔 FieldError가 자바 필드명을 내보내서 방금 만든 errors 계약이 깨졌다. 선택 항목을 아예 없애고 빈 문자열로 받는 방법도 있었지만, 그건 “안 보냄”과 “빈 값”을 구분 못 하게 만들고 숫자 타입에서는 매직 값을 불러들인다.
배치 경로는 Repository 설계 표준을 정한 뒤에 덧붙였다. 원칙 2에 “HTTP가 아닌 진입점도 자기 방식으로 검증을 마쳐야 한다”고 써두고 정작 그 진입점이 어디인지는 정하지 않았다는 걸 발견했다. 배치로 들어오는 걸 세어보니 JobParameters와 Reader가 읽는 행 둘인데, 후자는 이미 우리 DB에 있는 데이터라 검증 대상이 아니었다. 진입점이 하나로 좁혀지자 자리도 정해졌다 — JobParametersValidator. assert를 쓰려다 “운영에서 -ea를 켜지 않는다”에 걸려서 접었고, 그 과정에서 배치는 Domain을 안 거치니 규칙을 검증할 자리가 아예 없다는 게 드러나 “도메인 판단이 필요한 연산은 배치로 하지 않는다”는 경계까지 같이 정했다.
결국 계약마다 다른 수단을 쓰기로 했다. JSON 키가 계약인 Request와 Response는 @Nullable 어노테이션, Java 호출 계약인 Command·Query·Domain·Snapshot은 orNull 이름을 쓴다. 처음엔 규칙의 예외처럼 보여서 꺼려졌는데, 따져보니 기준이 있었다 — 그 이름이 어디까지 따라가는가. Java 호출부에는 이름으로 위험을 운반하고, JSON 계약에서는 null 처리 방식 때문에 업무 필드명을 바꾸지 않는다. 자세한 출력 규칙은 나중에 경계 DTO의 null 표기에서 닫았다.
정리
- 가르는 기준은 “도메인 지식이 필요한가”이다. 필요 없으면 Controller, 필요하면 Service
- Controller는 형식만 검증한다. 필수값, 공백, 길이, 형식 — 비즈니스 로직이 개입하지 않는 것들
- 필수값을 받을 필드는 박싱 타입을 쓴다.
int는 누락을 감지하지 못한다 - 비즈니스 규칙을 Request DTO에 올리지 않는다. 정책은 바뀌고, API 스펙은 그것 때문에 바뀌면 안 된다
- DB를 봐야 하는 검증은 Service가 조회하고 Domain이 판단한다. 검증기에 Repository를 주입하지 않는다
- 검증 실패는
HttpApiResponse.errors로 필드별로 내려준다. 성공 응답에서도 빈 리스트로 존재한다 - nullable은 Request·Response에서
@Nullable, Command·Query·Domain·Snapshot에서orNull로 표기한다. JSON 키인지 Java 호출 이름인지가 기준이다 - 검증은 컨트롤러 메서드에 들어가기 전에 끝난다. Request DTO 생성자에서 검증하지 않고,
@Valid를 빠뜨리지 않는다 - 여러 곳의 검증은 중복이 아니다. 답해야 하는 질문이 서로 다르다
- 대량 처리의 진입점은
JobParameters하나다.JobParametersValidator에서 막고, Reader가 읽은 행은 재검증하지 않는다 - 도메인의 판단이 개입하는 연산은 벌크로 하지 않는다. 벌크에는 규칙을 검증할 자리가 없다
자신만의 철학을 만들어가는 중입니다.
댓글남기기