이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • BindingResult가 담고 있는 오류 정보는 어떤 종류로 나뉘는가?
  • HTML 폼과 REST API에서 검증 오류 처리 전략은 왜 달라지는가?
  • 검증 오류 응답을 클라이언트가 오래 안정적으로 쓰게 하려면 어떤 필드를 설계해야 하는가?

개요

검증 실패 처리는 사용자 경험과 API 계약이 만나는 지점이다. Controller에서 검증 오류를 발견하는 것만으로는 부족하다. 사용자가 어떤 값을 고쳐야 하는지, 클라이언트가 어떤 필드를 강조해야 하는지, 서버 로그에는 어떤 정보가 남아야 하는지까지 고려해야 한다.

BindingResult는 바인딩과 검증의 실패 정보를 담는다. Spring MVC의 폼 처리에서는 이 객체를 View에 넘겨 필드별 메시지를 표시한다. REST API에서는 동일한 정보를 공통 예외 처리에서 구조화된 JSON으로 바꾼다. Spring 6 계열에서는 ErrorResponseProblemDetail 흐름도 함께 제공되므로, 팀의 API 오류 형식과 Spring 기본 오류 표현을 어떻게 연결할지 정해야 한다.

오류 정보의 구조

BindingResult에는 FieldErrorObjectError가 들어갈 수 있다. FieldError는 특정 필드에 붙은 오류이고, ObjectError는 객체 전체의 조합 규칙이나 글로벌 오류다.

예를 들어 email이 비어 있으면 필드 오류다. 반면 startDateendDate보다 늦다는 규칙은 두 필드의 관계이므로 객체 오류로 다루거나 별도 코드로 표현하는 편이 자연스럽다. 모든 오류를 필드 오류로만 밀어 넣으면 클라이언트가 조합 규칙을 오해하기 쉽다.

오류 코드도 중요하다. 메시지는 사용자 언어와 화면 맥락에 따라 바뀔 수 있지만, 오류 코드는 클라이언트 분기와 테스트의 기준이 된다. NotBlank.signupRequest.email처럼 Spring의 메시지 코드 체계를 활용할 수도 있고, API 계약용으로 MEMBER_EMAIL_REQUIRED 같은 코드를 별도로 둘 수도 있다.

코드 예제

public record ValidationErrorResponse(
        String code,
        String message,
        List<FieldViolation> fieldErrors
) {
}
 
public record FieldViolation(
        String field,
        String code,
        String message,
        Object rejectedValue
) {
}

응답 모델을 먼저 정해 두면 예외 타입이 달라도 같은 형식으로 변환할 수 있다. MethodArgumentNotValidException, BindException, HandlerMethodValidationException, message conversion 실패처럼 실패 경로가 달라도 클라이언트는 같은 큰 구조를 받는다. 다만 각 예외가 담고 있는 오류 정보의 모양은 다르므로 변환 코드는 분리해 두는 편이 안전하다.

@RestControllerAdvice
public class ApiValidationAdvice {
 
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<ValidationErrorResponse> handle(MethodArgumentNotValidException ex) {
        List<FieldViolation> fields = ex.getBindingResult().getFieldErrors().stream()
                .map(error -> new FieldViolation(
                        error.getField(),
                        error.getCode(),
                        error.getDefaultMessage(),
                        error.getRejectedValue()))
                .toList();
 
        return ResponseEntity.badRequest().body(new ValidationErrorResponse(
                "VALIDATION_FAILED",
                "요청 값이 올바르지 않습니다.",
                fields));
    }
}

이 코드는 기본 방향을 보여 주는 예제다. 실제 서비스에서는 거절된 값에 비밀번호나 토큰 같은 민감 정보가 들어갈 수 있으므로 그대로 노출하지 않도록 필터링해야 한다.

HTML 폼과 API의 차이

HTML 폼에서는 사용자가 입력한 값을 유지하고 같은 화면으로 돌아가는 것이 중요하다. 그래서 Controller가 BindingResult를 직접 확인하고 View 이름을 반환하는 방식이 자연스럽다.

API에서는 클라이언트가 화면을 직접 그린다. 서버는 어떤 필드가 왜 실패했는지 안정적인 JSON 계약으로 알려 주면 된다. Controller마다 직접 ResponseEntity.badRequest()를 만들기 시작하면 오류 응답이 쉽게 흩어진다. 공통 예외 처리에서 한 번에 관리하는 편이 변경에 강하다.

실전 팁

  • 오류 응답의 최상위 코드와 필드별 코드를 분리한다.
  • Spring 기본 ProblemDetail을 그대로 쓸지, 팀 표준 오류 DTO로 감쌀지 API 계약 차원에서 먼저 정한다.
  • rejectedValue는 디버깅에 유용하지만 민감 정보 노출 위험이 있으므로 필드별 마스킹 기준을 둔다.
  • 사용자를 위한 메시지와 개발자를 위한 로그 메시지를 분리한다.
  • ObjectError를 무시하지 않는다. 필드 하나로 표현하기 어려운 조합 규칙은 별도로 보여야 한다.
  • 클라이언트가 의존할 값은 메시지가 아니라 오류 코드가 되게 설계한다.

위험 신호!

  • 모든 검증 실패를 문자열 하나로만 내려준다.
  • MethodArgumentNotValidException만 처리하고 HandlerMethodValidationException이나 바인딩/변환 실패를 빠뜨린다.
  • 비밀번호, 인증 토큰, 주민번호 같은 값이 rejectedValue에 그대로 담긴다.
  • Controller마다 다른 필드명과 다른 오류 코드 체계를 쓴다.
  • 글로벌 오류가 필요한 규칙을 억지로 특정 필드 오류로만 표현한다.
  • 예외 메시지를 그대로 사용자에게 노출한다.

확인 질문

확인 질문

  • FieldErrorObjectError의 차이는 무엇인가?
    • FieldError는 특정 필드의 오류이고, ObjectError는 객체 전체나 여러 필드의 조합 규칙 오류다.
  • API 검증 오류 응답에서 메시지보다 안정적으로 유지해야 하는 값은 무엇인가?
    • 오류 코드다.
  • rejectedValue를 응답에 넣을 때 조심해야 하는 이유는 무엇인가?
    • 민감 정보가 그대로 클라이언트에 노출될 수 있기 때문이다.

참고 문서