💡 글에서 사용한 코드: 깃헙
이번 글에서는 spring-validation의 다양한 기능들을 정리하고 사용해보는 시간을 가져보도록 하겠다.
바로 드간다 🔥
Spring validation의 근본 기능은 멤버 변수에 붙어서 해당 변수를 검증하는 것이다. 어떠한 Validation들이 가능한지 다양한 Input 검증용 어노테이션들을 알아보도록 하자.
import jakarta.validation.constraints.NotNull;
public class Post {
@NotNull
String title;
@NotNull(message = "Null 노노")
String content;
}
null
여부를 검증할 수 있는 @NotNull
어노테이션이다. 위와 같은 식으로 사용할 수 있고, 어노테이션이 붙은 변수가 nulll
일 경우 에러를 뱉는다. message
에 작성한 문구는 검증에 걸리게 되면 에러 메세지로 던져지게 된다.
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
public class Post {
@NotEmpty
String title;
@NotBlank
String content;
}
문자열이 빈 문자열인지 공백인지 검증할 수 있는 어노테이션들이다.
@NotNull
과 동일하게 동작하지만 각각 빈 문자열 여부와 공백 문자열 여부를 검증한다. @NotEmpty
는 null
뿐만 아니라 빈 문자열
도 에러를 뱉고, @NotBlank
는 null
, 빈 문자열
, 공백 문자열
을 걸러낸다.
public class TemporaryObject {
@NotBlank
@Size(min = 10, max = 100)
String value1;
@NotNull
@Size(min = 1, message = "최소 1개")
List<String> value2;
@Min(1)
Integer value3;
@Max(10)
Integer value4;
}
값의 크기를 검증할 수 있는 어노테이션들은 @Size
, @Min
, @Max
가 있다. @Size
는 문자열의 길이와 리스트 또는 배열의 크기도 검증할 수 있다.
public class TemporaryObject {
@AssertTrue
Boolean value1;
@AssertFalse
Boolean value2;
}
해당 값이 true
인지 false
인지 검증할 수 있다.
어노테이션 이름만 봐도 어떤 검증을 하는지 감을 잡을 수 있으므로, 예시 코드는 굳이 작성하지 않겠다.
@Postive
@PostiveOrZero
@Negative
@NegativeOrZero
이 어노테이션들로, 숫자가 음수인지 양수인지 검증할 수 있다.
@Pattern
: 정규식을 통해서 값의 패턴을 검증할 수 있다.@Email
: 이메일 형식의 값인지 검증한다.@Digits
: 정수와 소수의 자릿수를 제한하고 검증할 수 있다.@Past
: 과거 날짜인지 검증한다.@PastOrPresent
: 현재를 포함한 과거 날짜인지 검증한다.@Future
: 미래 날짜인지 검증한다.@FutureOrPresent
: 현재를 포함한 미래 날짜인지 검증한다.그 외에는 이러한 어노테이션들이 있다.
먼저, @Valid
는 JSR-303에 명시된 Java의 표준 스펙이다. 메서드와 멤버 변수에 붙여서 사용할 수 있지만, 그룹 Validation은 지원하지 않는다.
💡 Group validation을 지원한다는 것은 Validation되는 항목들을 그룹화할 수 있고, 그에 따라서 조건부로 Validation을 수행할 수 있다는 것을 의미한다.
@Valid
를 사용하면, Validation은 Spring의 ArgumentResolver
에 의해서 처리된다. 즉, 근.본.이라고 할 수 있다.
@Valid
와 달리 그룹 Validation을 지원하고, Java의 표준 스펙이 아닌 spring-validation
에서 제공하는 어노테이션이다. 또, ArgumentResolver
에서 처리하지 않고, AOP 방식으로 객체에 대한 Validation을 처리한다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
나는 Spring web, Spring validation을 설치했고, 편의를 위해서 Lombok도 설치해줬다.
public interface PostTitleValidationGroup {}
public interface PostContentValidationGroup {}
@Validated
의 그룹 Validation을 테스트하기 위한 인터페이스를 2개 만들어준다.
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import lombok.Getter;
@Getter
public class PostRequest {
@NotBlank
String title;
@NotBlank
@Size(min = 10)
String content;
@NotNull
@Size(min = 1, message = "최소 1개 입력")
List<String> tags;
}
@Valid
를 테스트하기 위한 객체를 하나 만들었다.
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import lombok.Getter;
import study.spring_validation_demo.exceptions.validationgroup.PostContentValidationGroup;
import study.spring_validation_demo.exceptions.validationgroup.PostTitleValidationGroup;
@Getter
public class PostRequest2 {
@NotBlank(groups = {PostTitleValidationGroup.class})
String title;
@NotBlank(groups = {PostContentValidationGroup.class})
@Size(min = 10, groups = {PostContentValidationGroup.class})
String content;
@NotNull
@Size(min = 1, message = "최소 1개 입력")
List<String> tags;
}
이번엔 @Validated
를 테스트하기 위한 객체이다. title
과 content
에는 위에서 만든 인터페이스로 그룹을 지어주었다.
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import study.spring_validation_demo.exceptions.validationgroup.PostTitleValidationGroup;
@Slf4j
@RestController
@RequestMapping("/api/post")
public class PostController {
@PostMapping("/valid")
public PostRequest valid(@Valid @RequestBody PostRequest postRequest) {
return postRequest;
}
@PostMapping("/validated")
public PostRequest2 validated(@Validated(PostTitleValidationGroup.class) @RequestBody PostRequest2 postRequest) {
return postRequest;
}
}
특별한 건 없지만, 두 번째 API에는 @Validated
를 사용했고 그룹은 PostTitleValidationGroup
로 지정해주었다. 즉, PostRequest2
객체에서 title
만 Validation이 동작해야한다.
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class CustomExceptionHandler {
@ExceptionHandler({MethodArgumentNotValidException.class})
protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException exception) {
FieldError fieldError = exception.getFieldErrors().get(0);
String field = fieldError.getField();
String message = fieldError.getDefaultMessage();
return new ResponseEntity<>(field + " : " + message, HttpStatus.BAD_REQUEST);
}
}
반드시 필요한 것은 아니지만, 좀 더 깔끔하게 에러 메세지를 보기 위해서 커스텀 ExceptionHandler
를 만들어주자. Validation에 걸리면, MethodArgumentNotValidException
을 던지므로 이 예외에 대한 처리를 정의해주었다.
@Valid
에 걸리기(?)content
의 길이 제약 조건에 걸리게 해서 Validation에 걸리게 해보았다. Validation이 잘 동작하는 것을 확인할 수 있다.
@Validated
에 걸리기(?)title
에 공백 문자열을 넘겨서 Validation에 걸리는 것을 확인했다.
content
에 null
을 넘겼지만, 현재 /api/post/validated
API는 PostTitleValidationGroup
으로 Validation 그룹이 지정되어 있어서 content
에 대한 검증은 수행되지 않았다. 이로써 그룹 Validation도 잘 동작하는 것을 확인했다.
package jakarta.validation;
import java.lang.annotation.Annotation;
public interface ConstraintValidator<A extends Annotation, T> {
default void initialize(A constraintAnnotation) {}
boolean isValid(T var1, ConstraintValidatorContext var2);
}
ConstraintValidator
는 Spring validation에서 제공하는 간단한 인터페이스로 initialize()
와 isValid()
라는 메서드를 제공한다.
initialize()
는 어노테이션을 붙인 객체 또는 필드의 정보를 가져와서 원하는 초기화 작업을 수행할 수 있다. default
함수가 구현되어 있으므로, 선택적으로 Overide할 수 있다.
핵심은 isValid()
인데, 검증의 대상이 되는 값을 가져와서 검증작업을 수행하는 로직을 구현하면 된다.
ConstraintVlidator
를 사용하지 않아도 검증 기능은 당연히 구현할 수 있다. 그런데, 왜 ConstrintValidator
로 검증 기능을 구현할까?
그 이유는 여러가지가 있을 수 있고, 아래와 같다.
ConstraintValidator
를 사용하면, 어노테이션과 검증 클래스의 쌍(Pairi)으로 검증 기능을 구현하게 되는데, 이는 코드 관리의 용이성을 증가시키고 비지니스 로직에서 검증로직을 분리하기도 용이해진다. 즉, 관심사 별로 코드를 분리할 수 있고 이는 좀 더 객체지향적이 개발을 가능하게 해준다. (물론, AOP와 같은 기능을 사용해서 검증 기능을 직접 구현해도, 객체지향적으로 구조를 만들 수 있다.)1. 커스텀 어노테이션 만들기
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import study.spring_validation_demo.exceptions.validator.CustomTitleValidator;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Constraint(validatedBy = CustomTitleValidator.class)
public @interface CustomTitleValidation {
String message() default "커스텀 검증이다.";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
먼저, 멤버 변수 또는 메서드의 파라미터에 붙여서 사용할 수 있는 어노테이션을 하나 만들어준다. 그리고 @Constraint
어노테이션을 사용해서 이 어노테이션이 붙은 녀석의 검증은 CustomValidator
가 수행하도록 한다.
groups
와 payload
는 사용하지 않더라도 위와 같이 선언은 해주어야한다.
2. Validator 클래스 구현하기
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.springframework.util.StringUtils;
import study.spring_validation_demo.exceptions.annotation.CustomTitleValidation;
public class CustomTitleValidator implements ConstraintValidator<CustomTitleValidation, String> {
@Override
public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
return StringUtils.hasText(s) && "제목".equals(s);
}
}
ConstraintValidator
를 구현할 때는 두개의 제네릭 클래스를 받는데, 첫번째 클래스는 검증 대상이될 어노테이션이고, 두번째 클래스는 검증 대상의 타입을 나타내는 클래스이다.
initialize()
는 선택적으로 구현해도 되므로, 나는 isValid()
만 구현했고, "제목"이라는 글자가 아니면 검증에 실패하는 기이한(?) 검증 로직을 구현해보았다.
3. Request 객체 만들기
import lombok.Getter;
import study.spring_validation_demo.exceptions.annotation.CustomTitleValidation;
@Getter
public class PostRequest3 {
@CustomTitleValidation
String title;
}
검증을 테스트하기 위해서 클라이언트에서 전송해줄 객체를 만들었다.
4. API 만들기
...
@PostMapping("/custom")
public PostRequest3 custom(@Valid @RequestBody PostRequest3 postRequest) {
return postRequest;
}
...
위에서 만든 클래스에 이런 엔드포인트를 하나 추가해주었다.
이제 직접 만든 검증 기능이 잘 동작하는지 알아보자.
1. 빈 문자열 검증
먼저 빈 문자열을 넘겨서 .hasText()
걸리도록 해보았다. 정상적으로 에러 메세지가 출력됐고,
디버거로 확인해도 검증이 잘 되는 것을 확인할 수 있다.
2. 아무 글자 검증
.hasText()
를 통과할 수 있는 아무 글자나 넘겨주었고, "제목"이라는 글자가 아니므로 검증에 통과하지 못한 것을 확인할 수 있다.
디버거에서도 정상적으로 검증이 이루어지는 것을 확인할 수 있다.
3. 검증 통과 확인
이번에는 "제목"이라는 글자를 넘겨서 검증에 통과하는지 확인해 보았다.
디버거에서도 검증이 잘 이루어진 것을 확인할 수 있다.
이번 글에서는 Spring validation을 좀 더 잘 활용하기 위해서 다양한 기능과 사용법들을 정리해보았다. 아는 내용들도 많았지만, 정리를 하면서 내 머릿속에도 좀 더 잘 정리된 것 같다 ㅎㅎ
그럼 이 글이 도움이 되길 바라며 마친다.
바이 🙏