해당 글은 외부의 도움 없이 Validation을 수행하는 상태에서 Spring의 기능 이것 저것을 다 사용하며 Validation을 수행하는 상태까지 점진적으로 개선해 나가는 강의 같은 형식으로 진행됩니다. Spring에서 하는 방법만 궁금하신 분들은 앞쪽 챕터는 넘기시고 Java Bean Validation 쪽 부터 보시면 됩니다.
validation
은 한글로 번역하면 검증하다, 확인하다 정도로 해석된다. 프로그래밍에서는 현재 상태나 입력값을 확인하는 것을 validation
이라 한다.
하지만 validation
은 검증에서만 끝이 아니라 무엇이 잘못 되었을 때 클라이언트에 현재 요청이 잘못 되었음을 알려줘야 한다. 클라이언트님 요청 그렇게 하는거 아닌데..
즉, validation
은 시스템이 잘못된 요청을 처리하지 못하도록 방지함과 동시에 무엇이 잘못 되었는지 까지 알려야 되는 것이다.
Range Check
는 값의 범위를 검사하는 방식이다.
고기집에서는 한 번에 주문 할 수 있는 양이 1~10인분 까지로 제한되어있다면, 손님이 "여기 삼겹살 -5인분 주세요~" 또는 "여기 삼겹살 243,123,141인분 주세요"라고 하면 "한번에 1~10인분까지만 주문 할 수 있어요"라고 하며 정상적으로 주문이 이루어 지지 않을 것이다.
즉, 입력의 최소값(1)
과 최대값(10)
을 지정해 두고 입력이 해당 범위(range)안에 유효한지 확인하는 방식이다.
Type check
는 입력 값의 유형을 검사하는 방식이다.
돼지고기 집에서는 돼지고기만 주문할 수 있다. 근데 돼지고기 집 가서 "소고기 주세요"라고 하면 "저희 집은 돼지고기만 팔아요"라고 하며 정상적으로 주문이 이루어 지지 않을 것이다.
즉, 입력된 type(소고기)
이 우리가 정해놓은 type(돼지고기)
과 일치하는지 확인하는 방식이다.
Length check
는 입력 값의 길이를 검사하는 방식이다.
고기를 구울 때, 불판의 지름 or 길이는 한정되어 있기 때문에 고기를 해당 불판의 길이에 맞게 잘라서 올려야 된다. 너무 길면 불판의 길이를 벗어나 테이블 위로 올라가버리니 문제가 될것이고, 너무 짧다면 불판 사이로 빠져버리니 문제가 될것이다.
즉, 입력 값의 길이가 예상했던 범위를 벗어나지는 않는지 확인하는 방법이다.
Range check
와 비슷해 보이지만 조금 다르다. Ranage check
는 보통 날짜
or 숫자
유형에 적용하는 방식이고 Length check
는 문자열 유형 적용하는 방식이다.
Presence check
는 값 자체의 존재 유무를 확인하는 방식이다.
고기집에서 벨을 누르면 사장님은 손님이 뭘 주문할지 기다리는데 아무런 말도 하지 않고 있으면 사장님의 상태는 몰?루 상태가 된다. 무슨 말이라도 해주세요 손님..
즉, 입력이 존재 할것으로 예상 되었지만 아무런 입력도 오지 않아 아무것도 입력되지 않는 것을 막는 방식이다.
Check digit
는 숫자 값이 올바르게 입력되었는지 확인하는 방식이다.
전송 중에 값에 변조가 있거나 실수로라도 잘못 된 값이 입력되지 않도록 막는 방식이다. 해당 내용을 다 설명하면 현재 주제인 validation의 범위를 넘어가게 되니 궁금하면 찾아보는 것을 권장한다.
아래와 같은 간단한 회원가입 요청을 받을 수 있는 컨트롤러가 있다고 가정하자.
@Slf4j
@RestController
public class ValidationController {
@PostMapping("/signIn")
public String signIn(@RequestBody Member member){
log.info("회원 가입 성공={}", member);
return "ok";
}
@Data
static class Member{
private String name;
private Integer age;
}
}
해당 컨트롤러로 아래와 같이 요청을 보내면 로그가 찍히히고 "ok"로 응답이 내려가는 간단한 예제이다
{
"name": "날씨는 맑음",
"age": 20
}
=================
회원 가입 성공=ValidationController.Member(name=날씨는 맑음, age=20)
이제 해당 값에 대해서 요구사항을 정의하고 점진적으로 validation
을 개선하며 진행해보자.
Presence Check
). 문자열(Type Check
). 길이는 1~8자로 제한(Length Check
).Type Check
). 범위는 1~100으로 제한(Range Check
).일단 Spring Validation
의 도움을 받지 않고 validation
을 진행해 보자.
@PostMapping("/signIn")
public String signIn(@RequestBody Member member){
if(member.getName() == null){
return "이름을 입력해주세요.";
}
if(member.getName().length() < 1 || member.getName().length() > 8){
return "이름은 1~8자 사이로 입력해주세요.";
}
if (member.getAge() != null && (member.getAge() < 1 || member.getAge() > 100)) {
return "나이는 1~100 사이의 값으로 입력해주세요.";
}
log.info("회원 가입 성공={}", member);
return "ok";
}
Controller의 코드가 지저분하긴 하지만 입력된 값에 대한 검증을 요구사항에 맞게 하고 응답도 잘 내려주고 있다.
하지만 실제로 응답을 받는 쪽에서 보면 HTTP 상태 코드가 200 OK
로 표시되고 있다. HTTP에서는 응답에 대한 상태를 코드로 간단하게 표시해 줄 수 있는데, 분명 요청이 실패 되었지만 HTTP 코드가 200으로 내려오니 수정이 필요해 보인다.
Spring에서는 ResponseEntity
를 이용해서 HTTP 상태 코드
를 정의 할 수 있다. 현재 상태는 클라이언트의 잘못된 요청으로 실패한 경우이다. HTTP 상태 코드
는 맨 앞자리 숫자에 의해서 그룹을 정할 수 있는데, 4xx번
의 코드의 경우 클라이언트 측에서 잘못했을 경우 사용되는 그룹이다. 4xx번
대에서도 현재는 사용자가 잘못된 요청을 보냈으므로 400 Bad Request
를 응답으로 내려주면 적절할 듯 하다. 이제 실패한 경우 HTTP 상태 코드
를 400 Bad Request
으로 응답하도록 수정해보자.
@PostMapping("/signIn")
public ResponseEntity<String> signIn(@RequestBody Member member){
if(member.getName() == null){
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("이름을 입력해주세요.");
}
if(member.getName().length() < 1 || member.getName().length() > 8){
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("이름은 1~8자 사이로 입력해주세요.");
}
if (member.getAge() != null && (member.getAge() < 1 || member.getAge() > 100)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("나이는 1~100 사이의 값으로 입력해주세요.");
}
log.info("회원 가입 성공={}", member);
return ResponseEntity.status(HttpStatus.CREATED)
.body("OK");
}
수정하는 김에 정상적인 동작에도 응답 코드를 201 Created
로 수정했다. 이제 HTTP 상태 코드도 제대로 내려주고 메시지도 제대로 내려준다. 하지만 컨트롤러의 코드가 지저분한 건 해결되지 않았다. 이 부분을 조금 해결해보자.
현재 validaiton에 의해 요청이 실패되면 ResponseEntity
를 바로 반환하도록 하는데 RuntimeException
을 던져서 다른 곳에서 한 번에 처리 할 수 있도록 해보자.
Srping에서는 @ExceptionHandler
를 이용해서 특정 예외를 잡아내서 처리 할 수 있다. validation
에 실패해서 던져지는 RuntimeException
을 잡아서 해당 에러의 메시지를 body에 담아서 보내도록 하자.
@PostMapping("/signIn")
public ResponseEntity<String> signIn(@RequestBody Member member){
if(member.getName() == null){
throw new RuntimeException("이름을 입력해주세요.");
}
if(member.getName().length() < 1 || member.getName().length() > 8){
throw new RuntimeException("이름은 1~8자 사이로 입력해주세요.");
}
if (member.getAge() != null && (member.getAge() < 1 || member.getAge() > 100)) {
throw new RuntimeException("나이는 1~100 사이의 값으로 입력해주세요.");
}
log.info("회원 가입 성공={}", member);
return ResponseEntity.status(HttpStatus.CREATED)
.body("OK");
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<String> invalidException(RuntimeException e){
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(e.getMessage());
}
❗️
@ExceptionHandler
주의점
실제로 사용하실 때에는RuntimeException
을 상속해서 특정 상황에 맞는 예외를 만드는 것을 추천드립니다.@ExceptionHandler
는 해당 에러의 부모 타입까지 다 잡아서 처리 할 수 있는데 Spring에서는 대부분의 에러와Checked Exception
을Unchecked Exception
으로 변환해서RuntimeException
의 하위 타입으로 던지도록 되어 있기 때문에validation
에서 실패해서 던져진 예외가 아닌 다른 계층에서 넘어온 예외까지 잡아서 처리하게 됩니다. 따라서RuntimeExcpetion
을 상속 받아,PropertyInvalidException
같은 예외를 만들어서 던져주는 것이 좋습니다.
이제 validation
코드 자체는 어느정도 괜찮아졌지만 컨트롤러에 validation
하는 코드 자체가 들어와 있는 것이 마음에 들지 않는다. 리팩토링 기법 중에 Extract Method
기법을 이용해서 따로 분리해 내도록 하자. Extract Method
를 쉽게 할 수 있도록 단축키를 제공한다. Mac 기준으로 대상 코드를 드래그 해서 option
+ command
+ m
단축키로 코드를 쉽게 분리 할 수 있다.
@PostMapping("/signIn")
public ResponseEntity<String> signIn(@RequestBody Member member){
validationMember(member);
log.info("회원 가입 성공={}", member);
return ResponseEntity.status(HttpStatus.CREATED)
.body("OK");
}
private void validationMember(Member member) {
if(member.getName() == null){
throw new RuntimeException("이름을 입력해주세요.");
}
if(member.getName().length() < 1 || member.getName().length() > 8){
throw new RuntimeException("이름은 1~8자 사이로 입력해주세요.");
}
if (member.getAge() != null && (member.getAge() < 1 || member.getAge() > 100)) {
throw new RuntimeException("나이는 1~100 사이의 값으로 입력해주세요.");
}
}
이제 컨트롤러 내부에서 validationMember
를 호출만 함으로써 컨트롤러의 로직이 깔끔해졌다. 하지만 아직 validation
로직에서는 조건을 한눈에 파악하기 어렵다. 해당 조건 식을 Member
내부로 옮기고 마무리하도록 하자.
@Slf4j
@RestController
public class ValidationController {
@PostMapping("/signIn")
public ResponseEntity<String> signIn(@RequestBody Member member){
validationMember(member);
log.info("회원 가입 성공={}", member);
return ResponseEntity.status(HttpStatus.CREATED)
.body("OK");
}
private void validationMember(Member member) {
if(member.isNameEmpty()){
throw new RuntimeException("이름을 입력해주세요.");
}
if(member.nameLengthCheck(1, 8)){
throw new RuntimeException("이름은 1~8자 사이로 입력해주세요.");
}
if (member.ageRangeCheck(1, 100)) {
throw new RuntimeException("나이는 1~100 사이의 값으로 입력해주세요.");
}
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<String> invalidException(RuntimeException e){
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(e.getMessage());
}
@Data
static class Member {
private String name;
private Integer age;
public boolean isNameEmpty() {
return getName() == null;
}
public boolean nameLengthCheck(int min, int max){
return getName().length() < min || getName().length() > max;
}
public boolean ageRangeCheck(int min, int max){
return getAge() != null && (getAge() < min || getAge() > max);
}
}
}
이제 각 코드들이 제자리에 잘 찾아가서 역할을 잘 수행하고 있는 것 같다. 이제 해당 코드를 Spring Validation
의 도움을 받아서 수정해보자
스프링에서는 Validator
라는 인터페이스를 제공합니다. 해당 인터페이스를 이용해서 validation을 진행해보겠습니다.
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
supprots(Class<?> clazz)
: 제공된 Class 타입이 해당 Validator가 처리 할 수 있는 타입인지 확인할 때 사용validate(Object target, Errors errors)
: target에 대한 Validation을 진행하고 에러가 있는 경우 Errors에 등록위의 Validator
인터페이스를 구현한 MemberValidator
를 만듭니다.
@Component
public class MemberValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Member.class.equals(clazz);
}
@Override
public void validate(Object target, Errors errors) {
Member member = (Member) target;
if(member.isNameEmpty()){
errors.rejectValue("name", "name.empty","이름을 입력해주세요.");
}
else if(member.nameLengthCheck(1, 8)){
errors.rejectValue("name", "name.length","이름은 1~8자 사이로 입력해주세요.");
}
else if (member.ageRangeCheck(1, 100)) {
errors.rejectValue("age", "age.range", "나이는 1~100 사이의 값으로 입력해주세요.");
}
}
}
이제 위의 Validator
를 Controller
에서 주입받아서 사용해봅시다.
@RequiredArgsConstructor
public class ValidationController {
private final MemberValidator memberValidator;
...
private void validationMember(Member member) {
Errors errors = new DirectFieldBindingResult(member, "Member");
memberValidator.validate(member, errors);
if(errors.hasErrors()){
ObjectError objectError = errors.getAllErrors().get(0);
throw new RuntimeException(objectError.getDefaultMessage());
}
}
}
기존에 직접 구현했던 validation
에서 validationMember()
라는 메소드를 따로 만들어 뒀기 때문에 해당 메소드에서만 MemberValidator
를 가져와 수정하면 됩니다.
Spring에서는 Java Bean Validation에 대한 지원을 제공합니다.
Bean Validation은 constraint declaration과 metadata를 통해 Java applicaion에 대해 일반적인 validation을 제공합니다.
@Constraint
AnnotationBean Validation에는 @Constraint
라는 어노테이션이 존재하고 해당 어노테이션이 붙은 곳에 대해 Validation을 수행 할 수 있습니다.
@Documented
@Target({ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Constraint {
Class<? extends ConstraintValidator<?, ?>>[] validatedBy();
}
@Null // null만 허용
@NotNull // null 허용 안함
@NotEmpty // null 허용 안함. 비어있지 않아야 함. "":X, " ":O
@NotBlack // null 허용 안함. 공백이 아닌 문자 최소 하나 이상을 포함해야 함. "" : X, " " : X
@AssertTrue // must be true, null are valid
@AssertFalse // must be false, null are valid
@Min // 지정된 값 이상만 허용
@Max // 지정된 값 이하만 허용
@DecimalMin // @Min과 같으나 CharSequence 제외
@DecimalMax // @Max와 같으나 CharSequence 제외
@Negative // 음수만 허용. 0 is invalid
@NegativeOrZero // 음수와 0 허용
@Positive // 양수만 허용. 0 is invalid
@PositiveOrZero // 양수와 0 허용
@Size(min= , max= ) // min과 max 사이의 값만 허용
@Digits // 범위 내의 숫자만 허용
@Past // 과거만 허용
@PastOrPresent // 과거 또는 현재만 허용
@Future // 미래만 허용
@FutureOrPresent // 미래 또는 현재만 허용
@Pattern // 정규표현식에 의해 지정
@Email // Email 형식만 허용
Constraint를 이용해 미리 정의된 다양한 어노테이션 들이 있습니다. 아래와 같이 @NotEmpty
라는 타입에 @Constraint
어노테이션이 붙어 있는 것을 확인할 수 있습니다.
@Constraint(validatedBy = {})
...
public @interface NotEmpty {
String message() default "{javax.validation.constraints.NotEmpty.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface List {
NotEmpty[] value();
}
}
이제 요구사항을 이제 Built-in Constraint를 이용해서 Member Class에 적용해 보자.
@Data
public class Member {
@NotBlank(message = "이름을 입력해주세요.")
@Length(min = 1, max = 8, message = "이름은 {min}~{max}자 사이로 입력해주세요.")
private String name;
@Range(min = 1, max = 100, message = "나이는 {min}~{max} 사이의 값으로 입력해주세요.")
private Integer age;
}
이제 적용하기 전에 간단하게 테스트 코드를 통해 제대로 동작하는지 확인해 봅시다.
class MemberTest {
Validator validator;
@BeforeEach
public void setupValidatorInstance(){
validator = Validation.buildDefaultValidatorFactory().getValidator();
}
@Test
public void whenNullName(){
Member member = new Member();
member.setAge(20);
Set<ConstraintViolation<Member>> validate = validator.validate(member);
printResult(validate);
assertThat(validate.size()).isEqualTo(1);
}
private void printResult(Set<ConstraintViolation<Member>> validate) {
validate.forEach(mv -> System.out.println(mv.getMessage()));
}
@Test
public void whenOverLengthName(){
Member member = new Member();
member.setName("날씨는 맑음날씨는 맑음");
member.setAge(20);
Set<ConstraintViolation<Member>> validate = validator.validate(member);
printResult(validate);
assertThat(validate.size()).isEqualTo(1);
}
@Test
public void whenOverRangeAge(){
Member member = new Member();
member.setName("날씨는 맑음");
member.setAge(1000);
Set<ConstraintViolation<Member>> validate = validator.validate(member);
printResult(validate);
assertThat(validate.size()).isEqualTo(1);
}
@Test
public void validation_success(){
Member member = new Member();
member.setName("날씨는 맑음");
member.setAge(20);
Set<ConstraintViolation<Member>> validate = validator.validate(member);
printResult(validate);
assertThat(validate.size()).isEqualTo(0);
}
}
========================
whenNullName() - 이름을 입력해주세요.
whenOverLengthName() - 이름은 1~8자 사이로 입력해주세요.
whenOverRangeAge() - 나이는 1~100 사이의 값으로 입력해주세요.
validation_success()
테스트 결과 모든 요구사항에 대해 제대로 동작하고 메시지도 이전과 같이 만들어진다.
이제 validation
을 적용 할 곳에 @Validated
어노테이션을 붙여주면된다. 우리는 Controller에 validation
을 적용 할 것이니 해당 메소드의 파라미터 타입에 붙여주도록 하자. 그리고 기존에 컨트롤러에서 호출하던 Validation 로직은 지워주도록 하자.
@PostMapping("/signIn")
public ResponseEntity<String> signIn(@Validated @RequestBody Member member){
log.info("회원 가입 성공={}", member);
return ResponseEntity.status(HttpStatus.CREATED)
.body("OK");
}
Spring에서는
@Valid
와@Validated
둘 다 사용이 가능하다.@Valid
는javax.validation
에 정의된 표준이고@Validated
는 그룹화 기능이 추가된 스프링에서 지원하는 기능이다.@Validated
는@Valid
의 기능을 지원하기 때문에 대신 사용 할 수 있다.
이제 Spring에서 지원하는 Validation을 적용하면 컨트롤러에서는 validation과 관련된 로직을 완전히 몰라도 된다. validation도 잘 동작하고 코드도 깔끔해졌지만 사용자에게 에러 메시지를 전달 하지 못한다.
validation을 통과하지 못하게 메시지를 만들어서 보내면 로그에 MethodArgumentNotValidException
이 발생해서 DefaultHandlerExceptionResolver
가 처리했음을 알 수 있다. 우리는 위에서 예외를 잡아서 처리 할 수 있는 @ExceptionHandler
를 알고있다. 하지만 컨트롤러 외부에서 발생했기 때문에 제대로 잡아서 처리하지 못한다.
아까 Validator
인터페이스를 이용해서 BindingResult
에서 에러를 확인하는 방식을 잠깐 봤었다. Controller
에서는 @Validated
어노테이션이 붙은 파라미터 타입 뒤로 validation
결과가 저장된 BindingResult
타입의 파라미터를 받을 수 있다.
@PostMapping("/signIn")
public ResponseEntity<String> signIn(@Validated @RequestBody Member member, BindingResult result) {
if(result.hasErrors()){
ObjectError objectError = result.getAllErrors().get(0);
throw new RuntimeException(objectError.getDefaultMessage());
}
log.info("회원 가입 성공={}", member);
return ResponseEntity.status(HttpStatus.CREATED)
.body("OK");
}
위와 같이 처리할 수 있지만 다시 컨트롤러 내부에 validation과 관련된 로직이 들어오게 된다. Validation의 도움을 받아 로직을 완전히 분리해 냈지만 다시 들어오니 모양새가 영 좋지못하다. 다행히 해결 방법이 하나 더 존재한다.
@ExceptionHandler를 컨트롤러에 선언하면 해당 컨트롤러에서 발생한 예외만 받지만 전역적으로 발생한 예외를 @ControllerAdvice 어노테이션이 붙은 Class를 이용해서 처리 할 수 있다.
@RestControllerAdvice
public class ValidationAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public String invalidException(MethodArgumentNotValidException e){
ObjectError objectError = e.getAllErrors().get(0);
return objectError.getDefaultMessage();
}
}
@Slf4j
@RestController
public class ValidationController {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@PostMapping("/signIn")
public String signIn(@Validated @RequestBody Member member) {
log.info("회원 가입 성공={}", member);
return "OK";
}
}
=============================
@RestControllerAdvice
public class ValidationAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public String invalidException(MethodArgumentNotValidException e){
ObjectError objectError = e.getAllErrors().get(0);
return objectError.getDefaultMessage();
}
}
=============================
@Data
public class Member {
@NotBlank(message = "이름을 입력해주세요.")
@Length(min = 1, max = 8, message = "이름은 {min}~{max}자 사이로 입력해주세요.")
private String name;
@Range(min = 1, max = 100, message = "나이는 {min}~{max} 사이의 값으로 입력해주세요.")
private Integer age;
}
Validation을 수행하기 위해 외부의 도움을 최소한으로 한 상태에서부터 최대한 도움을 받아 깔끔하게 정리하는 상태까지 점진적으로 개선하 나가면서 많은 것을 배웠습니다. 해당 내용을 혼자 공부하기엔 아깝다 생각하여 필요하신 분들이 있을까 싶어 공유한 내용이기 때문에 내용이 정확하지 않을 수 있습니다.
틀린 내용 또는 추가해야 할 내용이 있다면 댓글로 피드백 주시면 최대한 반영해서 수정해 보도록 하겠습니다.
완성된 결과에서 다듬어야 할 부분들이 많지만 코드를 줄이고 이해를 쉽게 하기 위해서 Validation에만 집중해서 코드를 작성했습니다. 추가로 궁금하신 부분은 댓글을 통해 질문 주시면 제 능력 선에서 아는한 답변드리도록 하겠습니다.