@Valid 하나 붙이면 검증이 끝난다고 생각하기 쉽다. 그런데 실제로 코드를 열어보면 검증이 여러 군데에 흩어져 있다. CreateOrderRequest의 @NotBlank, OrderDomain 생성자의 Objects.requireNonNull, OrderEntity의 @Column(nullable = false), 그리고 Service 어딘가의 if 문까지.
Swagger 문서화 표준에서 “하나의 정보는 한 곳에만 적는다”고 정했는데, 이건 그 원칙을 어기고 있는 걸까? 결론부터 말하면 아니다. 저것들은 중복이 아니라 서로 다른 종류의 검증이다. 이 글은 그 종류를 가르는 기준과, 각각이 어느 계층에 살아야 하는지를 정리한다.
가르는 기준: 도메인 지식이 필요한가
두 검증을 비교해보자.
- “상품명이 비어 있으면 안 된다”
- “재고보다 많이 주문할 수 없다”
앞의 것은 이 시스템이 주문을 다루는지 게시글을 다루는지 몰라도 판단할 수 있다. 뒤의 것은 재고라는 개념과 그 규칙을 알아야 판단할 수 있고, 심지어 DB를 조회해봐야 한다.
이 차이가 계층을 가르는 선이다.
| 검증 종류 | 판단 기준 | 위치 |
|---|---|---|
| 형식 검증 | 도메인 지식 없이 판단 가능 | Controller (XxxRequest) |
| 비즈니스 검증 | 도메인 규칙을 알아야 판단 가능 | Service |
| 데이터 기반 검증 | DB에서 가져온 상태를 봐야 판단 가능 | Domain |
형식 검증 (@Valid)"] B -->|위반| E["400
errors 배열 반환"] B -->|통과| C["OrderService
비즈니스 규칙 검증"] C --> D["StockDomain
조회된 상태로 규칙 판단"] D -->|위반| F["409
BusinessException"] 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
원칙 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);
}
}
여기서 검증 어노테이션은 Swagger 문서화 표준에 따라 제약 조건의 단일 출처 역할도 겸한다. 검증과 문서가 같은 어노테이션에서 나오므로 둘이 어긋날 수 없다.
필수값을 받을 필드는 박싱 타입을 쓴다
int quantity로 선언하면 JSON에 quantity 필드가 아예 없어도 0이 들어간 채로 통과한다. @NotNull이 붙어 있어도 소용없다. 원시 타입에는 null이 존재할 수 없으므로 Jackson이 기본값 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의 FieldError는 자바 필드명을 담기 때문에, 원칙 4에서 정한 errors 배열의 field 값이 클라이언트가 보낸 JSON 키와 어긋난다.
// 클라이언트는 couponCode를 보냈는데 에러는 다른 이름으로 온다
{ "field": "couponCodeOrNull", "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 체크 없이 사용할 때 경고를 띄워준다는 이점도 따라온다.
Service로 넘어가는 순간부터는 다시 이름 규칙을 따른다
@Nullable이 통하는 것은 Request DTO까지다. Command부터는 orNull 접미사를 쓴다.
@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);
}
}
기준은 그 필드를 어디서 읽는가이다. Request의 필드는 자기 자신의 toCommand() 안에서만 읽히므로, 어노테이션과 읽는 코드가 같은 파일에 있다. 반면 Command와 Domain은 Service 곳곳에서 읽히기 때문에 이름이 호출부까지 따라가야 한다. 그리고 이쪽은 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
public class OrderService {
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();
}
}
그래서 책임을 둘로 나눈다. 조회는 Service가, 규칙 판단은 Domain이 한다.
// ✅ Service는 무엇을 조회할지 알고
@Service
@RequiredArgsConstructor
public class OrderService {
private final StockRepository stockRepository;
private final OrderRepository orderRepository;
public ServiceResult<OrderDomain> createOrder(CreateOrderCommand command) {
StockDomain stock = stockRepository.findByProductName(command.getProductName())
.orElseThrow(() -> new BusinessException("등록되지 않은 상품입니다."));
stock.validateOrderable(command.getQuantity()); // 규칙 판단은 Domain에 위임
OrderDomain order = OrderDomain.create(command.getProductName(), command.getQuantity(), stock.getUnitPrice());
return ServiceResult.of(orderRepository.save(order));
}
}
// ✅ 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 new BusinessException(
"재고가 부족합니다. 요청 %d개, 주문 가능 %d개".formatted(requestedQuantity, availableQuantity));
}
}
public BigDecimal getUnitPrice() {
return unitPrice;
}
}
이렇게 나누면 Domain은 여전히 영속성을 모른다. Repository가 어떻게 데이터를 가져왔는지, JPA인지 MyBatis인지 알 필요가 없다. Domain이 아는 것은 “내가 지금 가진 상태로 이 요청이 가능한가” 하나뿐이다.
또 하나의 이득은 규칙이 Service 메서드 안에 흩어지지 않는다는 것이다. 재고 판단 규칙이 StockDomain 한 곳에만 있으므로, 주문 생성이든 예약이든 어디서 호출하든 같은 규칙이 적용된다.
원칙 4. 검증 실패는 errors로 필드별로 내려준다
@Valid 실패는 어느 필드가 왜 틀렸는지를 클라이언트가 알아야 화면에 표시할 수 있다. 그래서 DTO 표준의 HttpApiResponse<T>에 errors 필드를 추가한다.
@Getter
public class HttpApiResponse<T> {
private static final int SUCCESS_CODE = 200000;
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> success(T data) {
return new HttpApiResponse<>(true, 200, SUCCESS_CODE, "OK", data, List.of());
}
public static <T> HttpApiResponse<T> success(int status, T data) {
return new HttpApiResponse<>(true, status, SUCCESS_CODE, "OK", 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);
}
}
}
성공 응답에서도 errors는 null이 아니라 빈 리스트다. 클라이언트가 필드 존재 여부로 분기하지 않고 항상 같은 방식으로 파싱할 수 있게 하기 위해서다. 이것도 null-free 철학의 연장이다.
검증 실패를 응답으로 바꾸는 일은 @RestControllerAdvice 한 곳에서 처리한다.
@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));
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<HttpApiResponse<Void>> handleBusiness(BusinessException e) {
ErrorCode errorCode = e.getErrorCode();
return ResponseEntity
.status(errorCode.getStatus())
.body(HttpApiResponse.error(errorCode, e.getMessage()));
}
}
상태 코드를 하드코딩하지 않고 ErrorCode에서 가져온다는 점이 중요하다. 그래야 “존재하지 않는 주문”은 404로, “재고 부족”은 409로 각각 나갈 수 있다. ErrorCode 설계와 로깅 정책은 예외 처리 표준에서 다룬다.
응답은 다음과 같은 모양이 된다.
{
"success": false,
"status": 400,
"code": 400001,
"message": "요청 값이 유효하지 않습니다.",
"data": null,
"errors": [
{ "field": "productName", "message": "공백일 수 없습니다" },
{ "field": "quantity", "message": "1 이상이어야 합니다" }
]
}
형식 검증 실패는 400에 필드별 상세를, 비즈니스 규칙 위반은 그에 맞는 상태 코드와 메시지를 담는다. 어느 계층에서 걸렸는지가 상태 코드로 드러난다.
원칙 5. 검증은 컨트롤러 메서드에 들어가기 전에 끝난다
@Valid가 정확히 언제 실행되는지 알아야 앞의 규칙들이 왜 그런지 이해된다. 순서는 이렇다.
- Jackson이 요청 본문을 객체로 만든다. 이 시점에는 아직 검증이 돌지 않았다. 필수 필드가 빠져 있으면 그 자리에 null이 들어간 객체가 잠시 존재한다.
- 곧바로 Bean Validation이 실행된다.
RequestResponseBodyMethodProcessor가 파라미터에@Valid가 붙어 있는지 확인하고 검증기를 돌린다. - 실패하면
MethodArgumentNotValidException을 던진다. 이때 컨트롤러 메서드는 호출조차 되지 않는다.
@PostMapping("/orders")
public HttpApiResponse<OrderResponse> createOrder(@RequestBody @Valid CreateOrderRequest request) {
// 검증을 통과한 요청만 여기에 도달한다. toCommand()도 그 이후에 실행된다.
}
여기서 세 가지 규칙이 따라 나온다.
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는 빠뜨리면 안 되는 유일한 지점이다.
판단 기준 정리
| 검증 내용 | 위치 | 수단 | 실패 응답 |
|---|---|---|---|
| 필수값 누락, 공백 | 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 (도달하면 버그) |
마지막 줄이 중요하다. 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 계약이 깨졌다. 선택 항목을 아예 없애고 빈 문자열로 받는 방법도 있었지만, 그건 “안 보냄”과 “빈 값”을 구분 못 하게 만들고 숫자 타입에서는 매직 값을 불러들인다.
결국 계층마다 다른 수단을 쓰기로 했다. Request는 @Nullable 어노테이션, Command부터는 orNull 이름. 처음엔 규칙의 예외처럼 보여서 꺼려졌는데, 따져보니 기준이 있었다 — 그 필드를 어디서 읽는가. Request의 필드는 자기 자신의 toCommand() 안에서만 읽히니 어노테이션과 읽는 코드가 한 화면에 있고, Command부터는 Service 곳곳에서 읽히니 이름이 호출부까지 따라가야 한다. orNull이 애초에 필요했던 이유가 “자바에 nullable을 표현할 문법이 없어서”였다는 걸 생각하면, 어노테이션이라는 수단이 있는 계층에서 그걸 쓰는 건 원칙을 어기는 게 아니라 같은 원칙의 다른 구현이다.
정리
- 가르는 기준은 “도메인 지식이 필요한가”이다. 필요 없으면 Controller, 필요하면 Service
- Controller는 형식만 검증한다. 필수값, 공백, 길이, 형식 — 비즈니스 로직이 개입하지 않는 것들
- 필수값을 받을 필드는 박싱 타입을 쓴다.
int는 누락을 감지하지 못한다 - 비즈니스 규칙을 Request DTO에 올리지 않는다. 정책은 바뀌고, API 스펙은 그것 때문에 바뀌면 안 된다
- DB를 봐야 하는 검증은 Service가 조회하고 Domain이 판단한다. 검증기에 Repository를 주입하지 않는다
- 검증 실패는
HttpApiResponse.errors로 필드별로 내려준다. 성공 응답에서도 빈 리스트로 존재한다 - nullable은 Request에서
@Nullable, Command부터orNull로 표기한다. 필드를 읽는 범위가 다르기 때문이다 - 검증은 컨트롤러 메서드에 들어가기 전에 끝난다. Request DTO 생성자에서 검증하지 않고,
@Valid를 빠뜨리지 않는다 - 여러 곳의 검증은 중복이 아니다. 답해야 하는 질문이 서로 다르다
AI 코드 어시스턴트에 바로 적용하기
Claude Code — .claude/skills/validation-standard/SKILL.md
---
name: validation-standard
description: 계층별 입력 검증 규칙. Request DTO 검증, 비즈니스 규칙 검증, Domain 검증, 검증 실패 응답을 작성하거나 리뷰할 때 반드시 적용한다.
---
# 계층별 검증 표준
무엇을 어디서 검증할지는 **그 규칙을 판단하는 데 도메인 지식이 필요한가**로 가른다.
## Controller (XxxRequest)
- 도메인 지식 없이 판단 가능한 것만 검증한다: 필수값 누락, 공백, 길이, 숫자 범위, 문자열 형식.
- Bean Validation 어노테이션(`@NotNull`, `@NotBlank`, `@Size`, `@Min`, `@Max`, `@Pattern`)으로 표현한다.
- 필수값을 받는 숫자/불리언 필드는 원시 타입이 아니라 박싱 타입(`Integer`, `Long`, `Boolean`)으로 선언한다. 원시 타입은 필드가 누락되어도 기본값이 채워져 `@NotNull`이 동작하지 않는다.
- 비즈니스 정책(마감 시각, 할인 조건, 상태 전이 규칙 등)을 Request DTO에서 검증하지 않는다. `@AssertTrue`로 비즈니스 규칙을 구현하지 않는다.
- `@RequestBody` 파라미터에 `@Valid`를 반드시 붙인다. 이것이 빠지면 검증기가 실행되지 않으므로 가장 중요한 지점이다.
- 중첩 객체나 컬렉션은 필드에도 `@Valid`를 붙여야 내부까지 검증된다.
- Request DTO의 생성자에서 검증하지 않는다. Jackson 역직렬화가 Bean Validation보다 먼저 실행되므로, 생성자에서 던지면 `errors` 응답이 아니라 `HttpMessageNotReadableException`이 된다.
- null을 허용하는 필드는 `@Nullable`을 명시한다. 필드명에 `orNull` 접미사를 붙이지 않는다. Request DTO의 필드명은 JSON 키이자 API 스펙이며, `@JsonProperty`로 분리하면 `FieldError`가 자바 필드명을 내보내 `errors`의 `field` 값이 어긋난다.
- `Command`, `Query`, `Domain`에서는 반대로 `@Nullable`이 아니라 `orNull` 접미사를 쓴다. 이 타입들은 여러 파일에서 읽히므로 이름이 호출부까지 따라가야 한다.
## Service
- 도메인 지식이 필요한 비즈니스 규칙을 검증한다.
- 규칙 위반은 `BusinessException`(또는 프로젝트의 비즈니스 예외)으로 던진다.
- Service는 들어온 값이 이미 형식 검증을 통과했다고 전제한다. 따라서 HTTP가 아닌 진입점(배치, 스케줄러, CLI 등)에서 Command를 만들 때도 그 진입점이 자기 방식으로 형식 검증을 마쳐야 한다.
## Domain
- DB에서 조회한 상태를 근거로 하는 검증은 Domain의 `validateXxx()` 메서드가 판단한다.
- 조회는 Service가 Repository로 수행하고, 판단만 Domain에 위임한다.
- 커스텀 `ConstraintValidator`에 Repository를 주입하지 않는다. Controller 계층 검증이 DB를 알게 되면 안 된다.
- Domain 생성자의 `Objects.requireNonNull`과 불변조건 검증은 유지한다. 이는 사용자 입력 검증이 아니라 객체 완전성 보장이며, 정상 흐름에서는 실패하지 않아야 한다.
## 검증 실패 응답
- `HttpApiResponse<T>`는 `List<FieldError> errors` 필드를 가진다. `FieldError`는 `field`와 `message`를 가진다.
- 성공 응답에서도 `errors`는 null이 아니라 빈 리스트로 채운다.
- `MethodArgumentNotValidException`은 `@RestControllerAdvice`에서 잡아 400과 `errors` 배열로 변환한다.
- `HttpMessageNotReadableException`(JSON 형식 오류, 타입 불일치) 핸들러도 함께 등록해 400 응답 형식을 일관되게 유지한다.
- 비즈니스 규칙 위반은 409와 `message`로 응답한다.
## 판단이 애매할 때
- "이 규칙을 검사하려면 이 시스템이 무엇을 다루는지 알아야 하는가?"를 묻는다. 알아야 하면 Controller가 아니다.
GitHub Copilot — .github/instructions/validation-standard.instructions.md
---
description: 계층별 입력 검증 규칙
applyTo: "**/*.java"
---
- Request DTO에서는 필수값, 공백, 길이, 숫자 범위, 문자열 형식만 검증한다.
- Request DTO에서 비즈니스 규칙을 검증하지 않는다. `@AssertTrue`로 비즈니스 정책을 구현하지 않는다.
- 필수값을 받는 숫자/불리언 필드는 `int`가 아니라 `Integer`처럼 박싱 타입으로 선언한다.
- Controller의 `@RequestBody` 파라미터에는 `@Valid`를 반드시 붙인다.
- 중첩 객체나 컬렉션 필드에도 `@Valid`를 붙여 내부까지 검증한다.
- Request DTO의 생성자에서 검증하지 않는다.
- Request DTO에서 null을 허용하는 필드는 `@Nullable`을 붙인다. 필드명에 `orNull`을 붙이지 않는다.
- `Command`, `Query`, `Domain`에서 null을 허용하는 필드는 `@Nullable` 대신 `orNull` 접미사를 쓴다.
- 도메인 지식이 필요한 비즈니스 규칙은 Service에서 검증하고 `BusinessException`을 던진다.
- HTTP가 아닌 진입점(배치, 스케줄러, CLI)에서 Command를 만들 때도 그 진입점에서 형식 검증을 마친다.
- DB 조회가 필요한 검증은 Service가 Repository로 조회하고, 규칙 판단은 Domain의 `validateXxx()` 메서드에 위임한다.
- 커스텀 `ConstraintValidator`에 Repository를 주입하지 않는다.
- Domain 생성자의 `Objects.requireNonNull`과 불변조건 검증은 사용자 입력 검증이 아니므로 그대로 유지한다.
- `HttpApiResponse<T>`는 `List<FieldError> errors` 필드를 가지며, 성공 응답에서도 빈 리스트로 채운다.
- `MethodArgumentNotValidException`은 `@RestControllerAdvice`에서 400과 `errors` 배열로 변환한다.
- `HttpMessageNotReadableException` 핸들러도 함께 등록한다.
- 비즈니스 규칙 위반은 409와 `message`로 응답한다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기