이전 글에서는 Spring Validation을 쓰지 않고서 회원가입 입력 값을 검증해봤다. 몇가지 불편한 점과 문제점을 간단히 요약하자면
1. 타입 오류 문제
2. 복잡한 컨트롤러 구현부
3. MemberJoinDto
클래스 맴버변수에 입력값이 아닌 errors 포함
4. 모든 검증 로직 직접 개발
1번 문제를 해결하려면 HttpServletRequest
객체에서 직접 값을 꺼내서 타입 체크를 해주면 될 것 같다. 하지만 이렇게 하면 지금보다 컨트롤러 구현부가 더 복잡해질 것이다. 지금은 모든 타입이 String
이므로 문제가 발생할 일은 없다.
2, 3, 4번 문제는 spring validation이 수고를 덜어줄 것이다.
앞으로는 SSR이 아닌 CSR로 개발해보려고 한다. 굳이 화면을 또 그려가면서 개발해야하나 생각이 들기도 했고, 만약 프론트엔드 개발자가 있다면 API로 데이터를 주고받아야 하니까 협업한다고 생각하고 해보기로 했다. 그리고 이를 위해서 API 문서를 편하게 만들수 있는 Swagger를 도입했다.
이제 Spring Validation을 이용해서 검증 프로세스를 구현해보자.
@Operation(summary = "회원 가입 페이지 요청", description = "화원 가입을 요청합니다.")
@PostMapping("/join")
public ResponseEntity<JoinResponseDto> join(@Validated @RequestBody MemberJoinDto memberJoinDto, BindingResult bindingResult) {
if (bindingResult.hasFieldErrors()) {
return new ResponseEntity<>(badResponseWithErrors(bindingResult), HttpStatus.BAD_REQUEST);
}
return new ResponseEntity<>(JoinResponseDto.of(true, null,null), HttpStatus.OK);
}
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(joinValidator);
}
Spring Validation 을 적용하고 나서 컨트롤러 메서드가 이렇게 간단해졌다. 간단하게 동작 방식을 살펴보면 @Validated
에노테이션이 붙은 MemberJoinDto
에 대한 검증이 실행된 후에 발생한 에러들이(여기서는 FieldError
들만) BindingResult
객체에 담기게 되고, 이를 컨트롤러 구현부에서 꺼내서 사용하면 된다. 여기서는 순서가 중요한데, 무조건 검증하려는 객체 뒤에 BindingResult
가 있어야한다.
@Validated
는 @ModelAttribute
와 @RequestBody
에 모두 적용이 가능한데, 둘 사이에는 큰 차이점이 존재한다,
@ModelAttribute
각각의 맴버변수 필드 단위로 바인딩되기 때문에 특정 필드가 바인딩되지 않아도 나머지 필드는 정상적으로 바인딩되고, 그 후에 Validator
를 사용한 검증도 적용할 수 있다.
하지만 @RequestBody
는 보통 JSON
데이터를 한번에 객체로 바인딩한다. 따라서 JSON
과 객체의 형식이 안맞거나 타입 불일치로 인해 객체로 변경하지 못하면 예외가 발생하고 Validator
도 적용되지 않는다.
따라서 컨트롤러에서 발생하는 예외를 받아서 처리하는 @RestControllerAdvice
을 사용한 핸들러를 따로 만들어서 객체가 생성되지 않았을때 응답해줄 데이터를 따로 설정하여 보내줘야한다. 하지만 지금 우리 서비스의 회원가입 필드들은 모두 String
이므로 타입 오류가 발생할 일이 없어서 따로 만들지 않았다.
@Getter
@Setter
public class MemberJoinDto {
@NotBlank(message = "{NotBlank.loginEmail}")
@Email(message = "{Email.memberJoinDto.loginEmail}")
private String loginEmail;
@NotBlank(message = "{NotBlank.loginPassword}")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!?@#$%^&*+=/])[a-zA-Z\\d!?@#$%^&*+=/]{10,20}$",
message = "{Pattern.loginPassword}")
private String loginPassword;
@NotBlank(message = "{NotBlank.loginPasswordVerify}")
private String loginPasswordVerify;
@NotBlank(message = "{NotBlank.companyName}")
@Size(min = 1, max = 20, message = "{Size.memberJoinDto.companyName}")
private String companyName;
}
이렇게 Bean Validation 에서 제공하는 애노테이션을 적용하여 이미 구현되어 있는 검증 로직을 가져와서 사용할 수 있다.
애노테이션에 대한 자세한 설명은 아래 블로그를 참고했다.
https://jyami.tistory.com/55
Bean Validation 공식문서
https://docs.jboss.org/hibernate/beanvalidation/spec/2.0/api/
@Component
@RequiredArgsConstructor
@PropertySource("classpath:errors.properties")
public class JoinValidator implements Validator {
private final Environment environment;
@Override
public boolean supports(Class<?> clazz) {
return MemberJoinDto.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
MemberJoinDto memberJoinDto = (MemberJoinDto) target;
if (!Objects.equals(memberJoinDto.getLoginPassword(), memberJoinDto.getLoginPasswordVerify())) {
errors.rejectValue("loginPasswordVerify", "InCorrectPassword",
environment.getProperty("InCorrectPassword.memberJoinDto.loginPasswordVerify"));
}
// TODO - DB조회, 중복 체크
if (Objects.equals(memberJoinDto.getLoginEmail(), "wjsdj2008@gmail.com")) {
errors.rejectValue("loginEmail","DuplicateEmail",
environment.getProperty("DuplicateEmail.memberJoinDto.loginEmail"));
}
}
}
복합적인 검증이 필요한 부분들은 스프링에서 제공하는 Validator
인터페이스를 구현해서 WebDataBinder
에 등록하는 방식으로 따로 분리했다.
여기까지는 김영한님 스프링 MVC2 강의에서 공부한 내용으로 구현할 수 있었다, 그런데 여기서 WebDataBinder
가 무슨 역할을 하는지 궁금해서 한번 알아봤다.
WebDataBinder
는 2가지 역할을 한다.
1. 쿼리 스트링, Form 필드를 자바 객체로 바인딩
2. 객체에 대한 검증 수행
알고보니 객체 앞에 @ModelAttribute
가 붙어 있으면 WebDataBinder
가 자바 객체로 바인딩 해주는 것이었다. 반대로 @RequestBody
는 HttpMessageConverter
가 바인딩 해준다.
그렇다면 검증은 어떻게 이루어질까?
검증은 성공적으로 객체가 만들어진 후에 해당 객체에 대해서 이루어진다. 그래서 객체 자체를 만들지 못했을 경우에는 검증이 이루어질 수 없었던 것이다.
WebDataBinder
가 검증하는 구체적인 순서는 아래와 같다.
(spring boot validation 라이브러리가 등록되어 있다는 전제이다.)
implementation 'org.springframework.boot:spring-boot-starter-validation'
HTTP
요청WebDataBinder
객체 생성매 HTTP 요청마다 해당 컨트롤러에 대한 WebDataBinder
객체를 생성하여 처리한다.
ValidatorAdaptor
등록(초기화)spring boot validation 라이브러리를 등록해두면, 위와 같이 우리가 따로 설정하지 않아도
@Empty
와 같은 애노테이션 기반 검증을 처리하는 ValidatorAdaptor
어뎁터가 최초에 Validator
로 등록된다.
@InitBinder
메서드 실행 addValidators(joinValidator)
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(joinValidator);
}
컨트롤러에 @InitBinder
가 있다면 매 HTTP 요청마다 해당 메서드가 실행된다. 나는 여기서 JoinValidator
를 WebDataBinder
의 Validator
로 등록하도록 작성했다.
다시 말하지만 @InitBinder
이 붙은 메서드는 해당 컨트롤러로 향한 요청마다 실행되므로, 요청이 있기 전에는 JoinValidator
가 등록되어있지 않다.
target = MemberJoinDto
를 supports()
하는 Validator
만 등록하지만 addValidators(joinValidator)
를 실행한다고 해서 joinValidator
가 바로 Validator
로 등록되는 것이 아니다.
지금 HTTP 요청의 target 객체를 joinValidator
가 처리할 수 있는지 확인한 다음 Validator
로 등록한다.
디버깅을 찍어보면
addValidators()
는 등록하기 전에 먼저 assertValidators()
를 호출하고 그 안에서 validator.supports(target.getClass())
로 해당 target 클래스를 처리할 수 있는지 먼저 확인한다.
우리가 등록한 joinValidator
는 현재 target인 MemberJoinDto
를 지원하도록 작성해두었다.
@Override
public boolean supports(Class<?> clazz) {
return MemberJoinDto.class.isAssignableFrom(clazz);
}
Validator
에 대해서 validate()
실행그 후에 등록된 모든
Validator
에 대해서 validate()
가 실행된다. 현재 ValidatorAdapter
와 JoinValidator
총 2개가 등록되어 있는 모습이다. 이렇게 차례대로 검증을 하면서 발생한 오류를 BindingResult
에 담으면 검증이 완료된다.
ValidatorAdaptor
가 BindingResult
객체에 오류를 추가하는 코드까지 찾아보았다 (위 그림의 클래스는 SpringValidatorAdaptor
이다). 객체 필드에 각각 달려있는 애노테이션들이 어떻게 작동하는지 찾아보고 싶었지만 거기까진 찾지 못했다.
여기까지 Spring Validation이 어떻게 검증 프로세스를 제공하는지 알아보고 적용해보았다. 핵심은 WebDataBinder
가 모든 검증 프로세스를 담당하고 있고, 그 중에서 최초에 등록되는 ValidatorAdaptor
이 애노테이션 기반 검증을 처리한다는 것이다.
MockMvc로 테스트 코드를 작성한 과정도 블로그에 담고 싶었지만 블로깅에 너무 많은 시간이 들어가는 것 같아서 생략했다. 대신 깃허브에 전체 소스 코드를 올려놓은 것으로 대체했다.
이번 포스팅 소스 코드 링크
https://github.com/JP-company/smartwire-backend2/tree/01-2.join_validation
썸네일 출처
https://velog.io/@oneook/썸네일-메이커Thumbnail-Maker-Toy-Project