본문 바로가기
빌드노트 검색

빌드노트

BindingResult가 있는데도 폼 검증 오류가 400으로 끝난다면

폼 검증 오류가 있는데 BindingResult의 hasErrors() 분기에 도달하지 못하면 인자 순서를 확인한다. Spring MVC 6.2.19에서 인접·분리 배치에 빈 이름과 정상 이름을 보내, 400 예외와 메서드 진입 여부가 달라지는 조건을 비교했다.

@Valid @ModelAttribute로 받은 폼의 값이 잘못됐는데 if (result.hasErrors())에 도달하지 않고 400 응답이 나올 수 있다. 메서드에 BindingResult가 있는지만 보지 말고, 검증할 폼 인자 바로 다음에 있는지 확인한다. 폼과 결과 사이에 HttpServletRequest 같은 다른 인자가 있으면 그 폼의 오류를 메서드 안에서 처리하겠다는 선언으로 인정되지 않는다.

예를 들어 SignupForm form, HttpServletRequest request, BindingResult result 순서라면 result를 form 바로 뒤로 옮긴다. 아래 코드는 그 순서를 보여 준다. @RestController 안의 메서드이며, 두 반환 문자열은 오류 분기와 정상 분기를 구별하기 위한 응답이다. 실제 저장 호출은 없다.

@PostMapping("/signup")
public String submit(@Valid @ModelAttribute("form") SignupForm form,
                     BindingResult result,
                     HttpServletRequest request) {
    if (result.hasErrors()) {
        return "form-error";
    }
    return "saved";
}

@Valid는 jakarta.validation.Valid, HttpServletRequest는 jakarta.servlet.http.HttpServletRequest를 사용한다. BindingResult는 org.springframework.validation.BindingResult다. 기존 코드가 폼 화면을 반환하는 @Controller라면 화면 이름과 오류 분기는 유지하고 인자 순서를 고친다.

Spring MVC 6.2의 @ModelAttribute 문서 (새 탭에서 열림)는 컨트롤러 안에서 오류를 처리하려면 대상 바로 옆에 BindingResult를 두도록 설명한다. 결과 인자의 이름을 formResult로 바꾸는 것으로는 연결 대상을 지정할 수 없다.

빈 이름과 정상 이름을 같은 순서로 보냈다

별도 MockMvc에서 Spring Framework 6.2.19, Hibernate Validator 8.0.6.Final, OpenJDK 17.0.2로 비교했다. 컨트롤러 안에 둔 폼 타입은 다음과 같다. name의 제약만 확인하므로 다른 필드는 없다.

public static class SignupForm {
    @NotBlank
    private String name;

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

@NotBlank는 jakarta.validation.constraints.NotBlank다. 두 메서드는 같은 폼과 오류 분기를 사용한다. 하나는 form, result, request, 다른 하나는 form, request, result 순서다. 요청을 구별할 경로만 각각 /adjacent, /separated로 나눴다. LocalValidatorFactoryBean을 MockMvc의 Validator로 명시적으로 연결했고, 클래스 수준 @Validated, 메서드 인자에 직접 붙인 제약, 별도 예외 처리기는 넣지 않았다.

폼 매개변수를 .param("name", "")로 보낸 뒤, 정상 대조로 .param("name", "Mina")를 보냈다. 아래는 실제 관찰에서 상태·메서드 진입·오류만 추린 결과다.

인접 / name=""   → 200, 진입 1회, name:NotBlank
분리 / name=""   → 400, 진입 0회, MethodArgumentNotValidException
인접 / name=Mina → 200, 진입 1회, 필드 오류 없음
분리 / name=Mina → 200, 진입 1회, 필드 오류 없음

인접한 경우에는 result.getObjectName()이 form이고, result.getTarget()도 전달받은 폼과 같은 객체였다. 빈 이름의 필드 오류 코드는 NotBlank였으며 form-error를 응답했다. 여기서 200은 이 합성 메서드가 정한 응답이다. 검증에 성공했다는 뜻도, 오류 분기에서 반드시 200을 반환해야 한다는 뜻도 아니다.

분리한 경우에도 오류가 없어진 것은 아니었다. MvcResult.getResolvedException()에서 얻은 예외는 정확히 org.springframework.web.bind.MethodArgumentNotValidException이었다. 그 예외의 getBindingResult()에는 똑같이 form의 name:NotBlank가 있었다. 다만 컨트롤러 메서드가 호출되지 않아 그 안의 hasErrors() 분기는 실행되지 않았다.

정상 이름은 잘못된 순서에서도 통과했다. 정상 요청 한 번이 성공했다는 이유로 BindingResult 위치까지 올바르다고 판단하면 이 문제를 놓친다.

메서드를 부르기 전에 바로 다음 인자를 검사한다

이 차이는 컨트롤러 내부 조건문보다 앞에서 생긴다. 6.2.19의 ModelAttributeMethodProcessor (새 탭에서 열림)는 폼을 바인딩하고 검증한 뒤, 오류가 있으면 그 폼의 바로 다음 인자 타입이 Errors 계열인지 확인한다. BindingResult는 이 계열에 속한다.

바로 다음이 HttpServletRequest라면 뒤쪽 인자 목록 전체에서 BindingResult를 찾아 주지 않는다. 이 조건에서는 폼 인자를 준비하던 중 MethodArgumentNotValidException을 던진다. 실험에서는 기본 MVC 예외 처리가 이를 400으로 처리했다. 따라서 이 실패를 무조건 IllegalStateException이라고 설명하거나, 컨트롤러 안에서 예외를 잡으려 해서는 관찰과 맞지 않는다.

한편 ErrorsMethodArgumentResolver (새 탭에서 열림)는 결과 인자의 변수명으로 폼을 찾지 않고 모델에 마지막으로 추가된 BindingResult를 읽는다. 정상 입력에서는 앞 단계에서 예외가 나지 않고 해당 결과가 남아 있어, 이번처럼 요청 인자 하나를 사이에 둬도 메서드가 호출될 수 있었다. 이것을 오류 입력에서도 동작한다는 보장으로 삼을 수는 없다. 폼이 여러 개라면 각 결과를 받을 폼 바로 뒤에 두고 getObjectName()도 함께 확인한다.

수정 뒤에는 실패 입력을 다시 보낸다

이미 spring-test와 Jakarta Bean Validation provider가 연결된 MockMvc 테스트가 있다면, 자기 처리 경로에 빈 값과 정상 값을 차례로 보낸다. 다음 코드는 위 /signup 메서드의 응답 상태와 MVC가 처리한 예외를 출력한다. mvc는 해당 컨트롤러와 Validator가 설정된 MockMvc이며, MvcResult는 org.springframework.test.web.servlet.MvcResult, post는 MockMvcRequestBuilders.post를 정적 import한다.

for (String name : new String[]{"", "Mina"}) {
    MvcResult r = mvc.perform(post("/signup")
            .contentType("application/x-www-form-urlencoded")
            .param("name", name)).andReturn();
    Exception ex = r.getResolvedException();
    System.out.println("name=[" + name + "] status=" + r.getResponse().getStatus()
            + " exception=" + (ex == null ? "-" : ex.getClass().getSimpleName()));
}

위 메서드의 수정 전 순서에서는 빈 값이 status=400 exception=MethodArgumentNotValidException이었고, 수정 후에는 status=200 exception=-였다. 정상 이름은 둘 다 status=200 exception=-였다. 자기 코드에서는 오류 분기에 중단점이나 호출 기록을 두고, 빈 이름일 때 진입하는지와 getFieldError("name")를 함께 확인한다. 응답 본문 모양만으로 판단하지 않는다. 별도 예외 처리기가 있다면 상태나 본문은 이 예제와 달라질 수 있다.

인접하게 고쳤는데 빈 값의 필드 오류가 전혀 없다면 이번 위치 문제와 구분해야 한다. 폼 인자의 @Valid, 필드의 제약, MVC가 사용하는 Validator 연결부터 확인한다. 반대로 예외가 HandlerMethodValidationException이라면 같은 예외라고 취급하지 않는다. 6.2 검증 문서 (새 탭에서 열림)는 메서드 인자에 제약을 직접 선언할 때 적용되는 메서드 검증과 개별 객체 검증을 구분한다. @Valid만으로 메서드 검증이 시작되지는 않으며, 클래스 수준 @Validated를 통한 AOP 검증도 별도 조건이다.

이 비교는 6.2.19의 standalone MockMvc에서 수행했다. MockMvc 설명 (새 탭에서 열림)처럼 실제 서버 대신 모의 요청·응답으로 MVC 요청 처리를 확인한 것이다. 실제 서버의 필터·보안 설정과 오류 페이지, 다른 Spring 버전, 메서드 검증을 추가한 조합은 실행하지 않았다.

← 전체 글 보기