본문 바로가기
Spring/Spring

스프링 BindingResult 에러 메시지 JSON으로 응답하기

by 델버 2023. 1. 17.
  • 스프링과 Thymeleaf를 사용하는 환경에서 유효성 검사로 @Validated와 BindingResult로 손쉽게 사용할 수 있다. 바인딩할 객체에 @NotEmpty같이 설정해둔 유효성에 대한 에러메시지를 자동으로 BindingResult에 담아주고 Thymeleaf가 이것을 꺼내어 메시지를 찾아준다. 이때 아래와 같이 설정해두면 에러 메시지를 똑똑하게 찾아준다.
# DTO
NotEmpty.userName = 사용자 이름을 입력해주세요
NotEmpty.email = 이메일을 입력해주세요
NotEmpty.password = 비밀번호를 입력해주세요
NotEmpty.passwordConfirm = 비밀번호 확인을 입력해주세요

NotEmpty = 빈 칸을 채워주세요
  • 손쉽게 일관적인 에러 메시지를 보낼 수 있다는 것과 국제화를 할 수 있다는 것이 장점이다. 하지만 Thymeleaf가 아닌 API 환경에서 JSON 형식으로 응답하게 되면 Thymeleaf를 거치지 않게되어 에러 메시지를 찾지 않고 기본 메시지가 나가게 된다!
  • 해결 방법은 BindingResult 안에 에러가 있으면 하나씩 꺼내서 메시지와 국제화를 서버 쪽에서 다 찾아서 JSON으로 넘겨줘야 한다는 것이다. Thymeleaf가 하던 일을 수작업으로 해야 된다는 것인데, 이를 객체로 만들어서 재사용성있게 만들려고 한다.
  • 구글 다 찾아보다가 이 글 덕분에 이 해결방법을 찾았다. 혹시 나와 같이 Thymeleaf 기반을 쓰다가 API로 전환하면서 문제가 생긴 동료들이 있을지몰라 자세히 단계적으로 적어나려 한다.

환경

@PostMapping("/login")
public String login(@ModelAttribute @Validated LoginRequestDto dto,
                    BindingResult bindingResult,
                    HttpServletRequest request) {

    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }
	
    Member member = loginService.login(dto);

    HttpSession session = request.getSession();
    session.setAttribute(SessionConst.LOGIN_MEMBER, member);

    return "home";
}
  • 뷰 템플릿 엔진 Thymeleaf를 사용하고 있었고 로그인하는 매서드이다. 뷰 문자열을 반환하면 Thymeleaf가 알아서 바인딩 메시지를 찾아서 적용시킨다.
@ResponseBody
@PostMapping("/api/login")
public ResponseEntity login(@RequestBody @Validated LoginRequestDto dto,
                    BindingResult bindingResult,
                    HttpServletRequest request) {

    if (bindingResult.hasErrors()) {            
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                            .body(bindingResult);
    }

    Member member = loginService.login(dto);

    HttpSession session = request.getSession();
    session.setAttribute(SessionConst.LOGIN_MEMBER, member);

    Result result = Result.builder().status(HttpStatus.OK).content("OK")
																	.build();
    return ResponseEntity.status(HttpStatus.OK).body(result);
}
  • API로 전환했다. 문자열에서 ResponseEntity를 반환하는데 BindingResult를 HTTP body에 넣어준다. 하지만 이렇게 바로 넣어서 응답할 경우엔 기존 Thymeleaf가 수행하던 message 기능은 실행되지 않고 스프링에서 default로 설정된 기본 메시지가 나간다.
  • 이제 저 BindingResult의 메시지를 하나씩 꺼내서 message를 찾고 국제화를 하는 객체를 만들려고 한다.

ErrorDetail

@Getter
public class ErrorDetail {

    private String objectName;
    private String field;
    private String code;
    private String message;

    @Builder
    public ErrorDetail(FieldError fieldError, MessageSource messageSource, Locale locale) {
        this.objectName = fieldError.getObjectName();
        this.field = fieldError.getField();
        this.code = fieldError.getCode();
        this.message = messageSource.getMessage(fieldError, locale);
    }
}
  • 에러 하나하나가 전환될 객체다. 중요한 부분은 생성 단계에서 messageSource.getMessage()를 통해서 Locale을 받아 국제화를 시켜줘야한다. 이렇게 되면 만약 영문 메시지를 설정했을 때 요청받은 Locale 값으로 메시지 언어를 알맞게 응답할 수 있다. 이 과정이 없으면 앞서 하려던 메시지 값이 기본 값으로 나가게 된다.
  • 그리고 꼭 getter를 만들어줘야 한다.(롬복은 @Getter) 이유는 바로 뒤에 설명하겠다.

ErrorResult

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ErrorResult {

    private List<ErrorDetail> errorDetails;

    @Builder
    public ErrorResult(Errors errors, MessageSource messageSource, Locale locale) {
        this.errorDetails = errors.getFieldErrors()
                .stream()
                .map(error ->
                        ErrorDetail.builder()
                                .fieldError(error)
                                .messageSource(messageSource)
                                .locale(locale)
                                .build()
                ).toList();
    }
}
  • HTTP body에 들어갈 객체다. 앞서 만든 각 오류의 메시지가 전환된 객체를 리스트로 갖고 있고 stream을 이용하여 이 객체를 만들 때 변환된다.
  • getter를 이곳에서 꼭 꼭 만들어줘야 하는데 body에 담길 때 Jackson 라이브러리를 사용하게 되는데 이때 getter를 사용해서 JSON 문자열로 변환하기 때문이다. 만약 JSON 변환이 필요없는 필드나 매서드가 있다면 @JsonIgnore를 사용하면 된다.

사용

  • 이제 BindingResult를 그대로 반환하는 것이 아닌 ErrorResult를 반환하면 된다.
@PostMapping("/api/login")
public ResponseEntity login(@RequestBody @Validated LoginRequestDto dto,
                    BindingResult bindingResult,
                    HttpServletRequest request) {

    if (bindingResult.hasErrors()) {
        ErrorResult errorResult = 
            new ErrorResult(bindingResult, messageSource, Locale.getDefault());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResult);
    }

    Member member = loginService.login(dto);

    HttpSession session = request.getSession();
    session.setAttribute(SessionConst.LOGIN_MEMBER, member);

    Result result = Result.builder().status(HttpStatus.OK).content("OK").build();
    return ResponseEntity.status(HttpStatus.OK).body(result);
}
  • BindingResult는 대부분 사용자의 입력 오류(글자 수 제한, 빈 칸 등)이므로 상태코드 400으로 내려줬다.
  • Locale.getDefault(): 이 부분은 request.getLocale()로 바꾸면 국제화를 적용시켜, 만약 영문 메시지를 설정하고 싶으면 바꾸면 된다.

JS

  • 혹시 몰라 도움 될 사람들이 있을까 Ajax로 처리한 JS도 올려놓는다.
const login = {
    init: function () {
        const _this = this;
        $('#btn-login').on('click', function () {
            _this.login();
        });

    },
    login: function () {
        const data = {
            email: $('#email').val(),
            password: $('#password').val()
        };

        $.ajax({
            type: 'POST',
            url: "/api/login",
            dataType: 'json',
            contentType: 'application/json; charset=utf-8',
            data: JSON.stringify(data)
        }).done(function () {
            alert('로그인 되었습니다.');
            window.location.href = '/';
        }).fail(function (response) {
            console.log(response);
            if (response.status === 400 && response.responseJSON.errorDetails != null) {
                $.each(response.responseJSON.errorDetails, function (index, errorDetails) {
                    let errorSpan =  document.querySelector(".error-" + errorDetails.field);
                    errorSpan.textContent = errorDetails.message;
                });
            } else {
                alert(response.responseJSON.message);
            }
        })
    }

};

login.init();

'Spring > Spring' 카테고리의 다른 글

[JWT] JWT에 대해서  (0) 2023.01.21
스프링 Thymeleaf에서 @ExceptionHandler 활용  (0) 2023.01.17
Interface로 추상화하여 Enum 사용  (0) 2023.01.11
[Spring] spring 버전  (0) 2022.12.20

댓글