Spring validation 여기 저기 사용하기

🔥Log·2024년 7월 11일
1

스프링

목록 보기
17/18

💡 글에서 사용한 코드: 깃헙

☕ 개요


이번 글에서는 spring-validation의 다양한 기능들을 정리하고 사용해보는 시간을 가져보도록 하겠다.

바로 드간다 🔥



📒 Input 검증과 어노테이션


Spring validation의 근본 기능은 멤버 변수에 붙어서 해당 변수를 검증하는 것이다. 어떠한 Validation들이 가능한지 다양한 Input 검증용 어노테이션들을 알아보도록 하자.

1) Null 검증

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과 동일하게 동작하지만 각각 빈 문자열 여부와 공백 문자열 여부를 검증한다. @NotEmptynull 뿐만 아니라 빈 문자열도 에러를 뱉고, @NotBlanknull, 빈 문자열, 공백 문자열을 걸러낸다.

크기 검증

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 vs @Validated


@Valid

먼저, @Valid는 JSR-303에 명시된 Java의 표준 스펙이다. 메서드와 멤버 변수에 붙여서 사용할 수 있지만, 그룹 Validation은 지원하지 않는다.

💡 Group validation을 지원한다는 것은 Validation되는 항목들을 그룹화할 수 있고, 그에 따라서 조건부로 Validation을 수행할 수 있다는 것을 의미한다.

@Valid를 사용하면, Validation은 Spring의 ArgumentResolver에 의해서 처리된다. 즉, 근.본.이라고 할 수 있다.

@Validated

@Valid와 달리 그룹 Validation을 지원하고, Java의 표준 스펙이 아닌 spring-validation에서 제공하는 어노테이션이다. 또, ArgumentResolver에서 처리하지 않고, AOP 방식으로 객체에 대한 Validation을 처리한다.



💻 Input 검증 예시


1) build.gradle

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도 설치해줬다.

2) 그룹핑용 인터페이스 만들기

public interface PostTitleValidationGroup {}
public interface PostContentValidationGroup {}

@Validated의 그룹 Validation을 테스트하기 위한 인터페이스를 2개 만들어준다.

3) Request 객체 만들기

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를 테스트하기 위한 객체이다. titlecontent에는 위에서 만든 인터페이스로 그룹을 지어주었다.

4) Controller 생성

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이 동작해야한다.

5) ExceptionHandler 생성

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을 던지므로 이 예외에 대한 처리를 정의해주었다.

6) API 호출해보기

  1. @Valid에 걸리기(?)

content의 길이 제약 조건에 걸리게 해서 Validation에 걸리게 해보았다. Validation이 잘 동작하는 것을 확인할 수 있다.

  1. @Validated에 걸리기(?)

title에 공백 문자열을 넘겨서 Validation에 걸리는 것을 확인했다.

  1. 그룹 Validation 확인하기

contentnull을 넘겼지만, 현재 /api/post/validated API는 PostTitleValidationGroup으로 Validation 그룹이 지정되어 있어서 content에 대한 검증은 수행되지 않았다. 이로써 그룹 Validation도 잘 동작하는 것을 확인했다.



🧐 ConstraintValidator


1) ConstraintValidator ?

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()인데, 검증의 대상이 되는 값을 가져와서 검증작업을 수행하는 로직을 구현하면 된다.

2) ConstraintValidator를 사용하는 이유

ConstraintVlidator를 사용하지 않아도 검증 기능은 당연히 구현할 수 있다. 그런데, 왜 ConstrintValidator로 검증 기능을 구현할까?

그 이유는 여러가지가 있을 수 있고, 아래와 같다.

  1. 검증 클래스들의 일관성을 유지할 수 있다.
  2. ConstraintValidator를 사용하면, 어노테이션과 검증 클래스의 쌍(Pairi)으로 검증 기능을 구현하게 되는데, 이는 코드 관리의 용이성을 증가시키고 비지니스 로직에서 검증로직을 분리하기도 용이해진다. 즉, 관심사 별로 코드를 분리할 수 있고 이는 좀 더 객체지향적이 개발을 가능하게 해준다. (물론, AOP와 같은 기능을 사용해서 검증 기능을 직접 구현해도, 객체지향적으로 구조를 만들 수 있다.)

3) ConstraintValidator 사용하기

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가 수행하도록 한다.

groupspayload는 사용하지 않더라도 위와 같이 선언은 해주어야한다.

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;
    }
    
    ...

위에서 만든 클래스에 이런 엔드포인트를 하나 추가해주었다.

4) ConstraintValidator 테스트해보기

이제 직접 만든 검증 기능이 잘 동작하는지 알아보자.

1. 빈 문자열 검증
먼저 빈 문자열을 넘겨서 .hasText() 걸리도록 해보았다. 정상적으로 에러 메세지가 출력됐고,
디버거로 확인해도 검증이 잘 되는 것을 확인할 수 있다.


2. 아무 글자 검증
.hasText()를 통과할 수 있는 아무 글자나 넘겨주었고, "제목"이라는 글자가 아니므로 검증에 통과하지 못한 것을 확인할 수 있다.
디버거에서도 정상적으로 검증이 이루어지는 것을 확인할 수 있다.


3. 검증 통과 확인
이번에는 "제목"이라는 글자를 넘겨서 검증에 통과하는지 확인해 보았다.
디버거에서도 검증이 잘 이루어진 것을 확인할 수 있다.



☕ 마무리


이번 글에서는 Spring validation을 좀 더 잘 활용하기 위해서 다양한 기능과 사용법들을 정리해보았다. 아는 내용들도 많았지만, 정리를 하면서 내 머릿속에도 좀 더 잘 정리된 것 같다 ㅎㅎ
그럼 이 글이 도움이 되길 바라며 마친다.

바이 🙏



🙏 참고


0개의 댓글