- 스프링과 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();
댓글