빌드노트
@Valid를 붙였는데 자식 DTO의 빈 문자열이 통과한 이유
요청 인자의 @Valid와 자식 참조의 @Valid는 검증에서 맡는 위치가 다르다. 빈 이름·null 자식·정상 자식의 오류 경로, MockMvc 상태와 서비스 호출을 비교하고, 자식 객체를 필수로 받을 때 @NotNull을 더할 기준을 설명한다.
Spring MVC 컨트롤러의 요청 인자에 @Valid를 붙였는데 상위 필드의 빈 값만 거절하고 child.name의 빈 문자열은 통과한다면, 부모 DTO가 child를 선언한 곳을 확인한다. 부모에서 자식으로 검증을 이어갈 @Valid가 그 자리에 필요하다. 자식 객체 자체도 필수라면 @NotNull을 별도로 붙인다.
이를 확인하려고 실제 서비스와 분리한 요청 DTO를 만들었다. Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.19, Hibernate Validator 8.0.6.Final, Jakarta Validation API 3.0.2, Jackson 2.18.2에서 실행했다. HTTP 서버를 띄우지 않고 MockMvc로 JSON 변환과 검증을 거쳤으며, 컨트롤러와 합성 저장 서비스의 호출 횟수를 각각 셌다. 아래 결과는 이 별도 실험의 관찰이다.
요청 검증을 시작했다고 자식까지 검사하는 것은 아니다
먼저 자식 참조에 @Valid가 없는 DTO를 사용했다. 컨트롤러 인자는 @Valid @RequestBody NoCascade request로 선언했다.
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
record Child(@NotBlank String name) {}
record NoCascade(@NotBlank String requestId, Child child) {}
@NotBlank는 문자열의 null, 빈 문자열, 공백만 있는 값을 거절하는 제약이다. 하지만 이 제약이 자식 클래스 안에 있다는 것과 부모 검증에서 그곳까지 찾아간다는 것은 별개다. Jakarta Bean Validation 3.0의 그래프 검증 절 (새 탭에서 열림)은 참조에 붙은 @Valid로 검증을 이어가도록 정한다.
동일한 매핑에 다음 본문을 보냈다. 요청의 Content-Type은 application/json이었다.
{"requestId":"r-1","child":{"name":""}}
이때 Validator.validate()가 반환한 위반 목록은 비어 있었다. MockMvc 응답은 성공 시 반환하도록 정한 204였고, 컨트롤러와 서비스는 각각 한 번 호출됐다. 같은 DTO에서 requestId만 ""로 바꾸고 자식 이름은 "Kim"으로 보내자 requestId:NotBlank 오류와 400이 나왔다. 이때 두 호출 횟수는 모두 0이었다.
상위 필드가 거절됐으므로 이 비교에서는 검증 시작점이 작동했다. 자식의 빈 값이 통과한 원인은 부모 검증에서 자식으로 이어지는 선언이 빠진 데 있었다. 요청 인자의 @Valid를 여러 번 확인하는 대신, 부모 DTO의 자식 참조를 살펴볼 근거다.
자식 참조에 @Valid를 붙이면 오류 경로가 달라진다
자식 타입은 그대로 두고 부모를 다음과 같이 바꿨다. 컨트롤러도 이 DTO를 받도록 변경하고 인자의 @Valid는 유지했다.
record Cascade(@NotBlank String requestId, @Valid Child child) {}
앞의 빈 이름 JSON을 다시 보내자 직접 검증의 위반 경로는 child.name, 제약은 NotBlank였다. MockMvc의 FieldError도 child.name:NotBlank였고, 응답은 400, 컨트롤러·서비스 호출은 각각 0회였다. 이름이 " "인 경우도 같은 결과였다. "Kim"은 위반 없이 204, 두 호출은 각각 1회였다.
여기서 두 위치의 역할을 나눠 읽는다. Spring MVC는 @Valid @RequestBody를 보고 변환된 요청 객체를 검증한다. Bean Validation은 그 객체의 @Valid Child child를 보고 자식 제약까지 검사한다. Spring의 요청 본문 검증 설명 (새 탭에서 열림)에 따르면 일반적인 요청 인자 검증 실패는 MethodArgumentNotValidException으로 처리되고 기본 응답은 400이다. 이 실험에서도 그 예외를 확인했다.
반대로 자식의 @Valid와 제약은 남겨 두고 컨트롤러 인자의 @Valid만 제거한 대조군에서는 빈 이름과 빈 requestId가 모두 204로 통과했다. 같은 객체를 Validator.validate()로 직접 검사하면 각각 위반이 나왔다. 제약 선언이 존재해도 MVC가 검증을 시작하지 않으면 이 요청 경로에서는 실행되지 않는다는 차이다.
child가 null이면 child.name을 검사할 대상이 없다
다음 본문은 이름이 빈 문자열인 경우와 다르다.
{"requestId":"r-1","child":null}
Cascade에서는 위반이 없었고, 응답 204에 컨트롤러와 서비스가 각각 한 번 호출됐다. @Valid는 자식의 존재를 요구하지 않는다. 명세 5.7.1절 (새 탭에서 열림)은 객체 그래프를 따라 검증할 때 null 참조를 건너뛰도록 정한다. 이 동작은 Hibernate Validator 8 안내서의 cascade 설명 (새 탭에서 열림)에도 명시돼 있다.
API가 자식을 필수로 요구한다면 부모의 자식 참조에 @NotNull을 더한다.
import jakarta.validation.constraints.NotNull;
record RequiredChild(
@NotBlank String requestId,
@NotNull @Valid Child child
) {}
이 선언으로 child: null을 보내면 오류는 child:NotNull이고 응답은 400이었다. 이름이 빈 경우의 child.name:NotBlank와 경로부터 다르다. 컨트롤러와 서비스는 둘 다 호출되지 않았다. 정상 자식은 여전히 통과했다.
자식을 생략한 {"requestId":"r-1"}도 이 Jackson 설정에서는 child == null로 변환되어 같은 결과를 냈다. 한편 {"requestId":"r-1","child":{"name":null}}은 자식 객체가 있으므로 child.name:NotBlank로 거절됐다. 자식이 없는 경우와 자식 내부 값이 없는 경우를 따로 보내 봐야 두 제약을 구분할 수 있다.
자식 생략을 허용하는 요청이라면 @NotNull을 넣지 않고 @Valid만 두는 판단이 맞을 수 있다. 그 경우에는 자식이 없을 때 서비스가 처리할 의미도 정해야 한다. 빈 이름을 막으려는 수정이 자식 객체 전체를 필수로 만드는 API 변경까지 포함하는지 확인한다.
내 API에서는 응답과 호출 여부를 함께 확인한다
이미 JSON 요청을 받는 Java 17 이상 Spring MVC 프로젝트라면 해당 컨트롤러의 MockMvc 검사에서 위 세 입력을 비교할 수 있다. 상위 값은 정상으로 고정하고 자식을 빈 이름, null, 정상 이름으로 바꾼다. 검증 오류가 난 요청에서는 서비스가 호출되지 않았는지도 확인한다. 응답 본문에 필드 오류를 넣지 않는 앱도 있으므로, 응답 JSON 모양만으로 cascade 여부를 판단하지 않는다.
DTO 제약 자체를 먼저 확인하려면 테스트에 주입된 jakarta.validation.Validator로 다음처럼 경로를 출력한다. Child와 Cascade는 앞의 선언을 사용한다.
validator.validate(new Cascade("r-1", new Child("")))
.forEach(v -> System.out.println(v.getPropertyPath()));
실제 출력은 child.name이었다. 자식 참조의 @Valid를 뺀 NoCascade로 같은 값을 만들면 출력이 없었다. 직접 검증은 실패하는데 HTTP 요청만 통과한다면 DTO 제약을 더 붙이기 전에 MVC 인자의 검증 시작점과 Validator 연결을 확인한다. Spring의 Bean Validation 설정 문서 (새 탭에서 열림)에서 설명하는 LocalValidatorFactoryBean은 실제 provider를 초기화하고 MVC가 쓸 Validator로 연결하는 구성 요소다. 이 실험의 standalone MockMvc에도 이를 명시적으로 설정했다.
반대로 상위의 빈 값도 그대로 통과하면 자식 cascade만의 문제라고 단정할 수 없다. 이 글은 기본 검증 그룹을 사용했고, 컨트롤러에 BindingResult 인자나 별도 예외 처리기, 메서드 검증을 추가하지 않았다. 그런 설정이 있으면 예외 처리와 컨트롤러 진입 여부를 함께 확인해야 한다. 검증 그룹 전환, 컬렉션 속 자식, 다른 Jackson 생성자 설정과 실제 서버의 필터 체인은 이번 실험에서 확인하지 않았다. 이 글의 204는 합성 컨트롤러가 정한 정상 응답이며, 다른 API의 정상 상태 코드까지 뜻하지 않는다.