[SpringBoot] @Valid, @Validated 를 활용한 유효성 검사 (~ing)

Ogu·2024년 1월 9일
0

SpringBoot

목록 보기
17/17
post-thumbnail

인턴 프로젝트를 진행하면서 조금 더 견고한 서비스를 위해 DTO에 유효성 검사를 추가하기로 결정했습니다.
이후 일반적으로 유효성검사를 많이 적용하는 회원가입 로직에서도 활용해보겠습니다.

우선 이번 글에서는 파라미터에 @Vaild, @Validated 애너테이션을 이용해 유효성 검사하는 방법에 대해 알아보겠습니다.

유효성 검사를 하는 이유

애플리케이션의 비즈니스 로직이 올바르게 동작시키기 위해서는 데이터들에 대해 사전 검증하는 작업이 필요합니다. 올바르지 않은 데이터는 걸러내고, 보안에 있어서도 중요합니다.
따라서 데이터가 유효한지, 또는 조건에는 타당한지 등을 확인합니다.

의존성 추가

기존 스프링 부트의 유효성 검사 기능은 spring-boot-starter-web에 포함돼 있었지만, 스프링 부트 2.3 버전 이후로는 별도의 라이브러리로 제공하고 있습니다. 따라서 build.gradle에 다음과 같이 의존성을 추가합니다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

Validation 어노테이션

유효성 검사는 각 필드에 어노테이션을 선언하여 진행합니다. 각 어노테이션은 유효성 검사를 위한 조건을 설정하며, 대표적인 어노테이션은 다음과 같습니다.

문자열 검증

AnnotationDescription
@Nullnull값만 허용
@NotNullnull을 허용하지 X, "", " "는 허용
@NotEmptynull, "" 허용 X, " "는 허용
@NotBlanknull, "", " " 허용 X

최댓값/최솟값 검증

BigDecimal, BigInteger, int, long 등의 타입을 지원합니다.

AnnotationDescription
@DemicalaMax(value = "$numberString")$numberString보다 작은 값 허용
@DemicalaMin(value = "$numberString")$numberString보다 큰 값 허용
@Min(value = "$number")$number이상의 값 허용
@Max(value = "$number")$number이하의 값 허용

값의 범위 검증

BigDecimal, BigInteger, int, long 등의 타입을 지원합니다.

AnnotationDescription
@Positive양수 허용
@PositiveOrZero0을 포함한 양수 허용
@Negative음수 허용
@NegativeOrZero0을 포함한 음수 허용

시간에 대한 검증

Date, LocalDate, LocalDateTime 등의 타입을 지원합니다.

AnnotationDescription
@Future현재보다 미래의 날짜 허용
@FutureOrPresent현재를 포함한 미래의 날짜 허용
@Past현재보다 과거의 날짜 허용
@PastOrPresent현재를 포함한 과거의 날짜 허용

이메일 검증

AnnotationDescription
@Email이메일 형식을 검사, ""는 허용

자릿수 범위 검증

BigDecimal, BigInteger, int, long 등의 타입을 지원합니다.

AnnotationDescription
@Digits(integer = $number1, fraction = $number2$number1의 정수 자릿수와 $number2의 소수 자릿수 허용

Boolean 검증

AnnotationDescription
@AssertTrueTrue인지 체크, null값은 체크 X
@AssertFalseTrue인지 체크, null값은 체크 X

문자열 길이 검증

AnnotationDescription
@Size(min = $number1, max = $number2)$number1 이상 $number2 이하의 범위 허용

정규식 검증

AnnotationDescription
@Pattern(regexp = "$expression")정규식 검사, 자바의 java.util.regex.Pattern 패키지의 컨벤션을 따름

ex) 휴대전화 번호 형식 검증

  @Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
    String phoneNumber;

🤔 정규식이란?

정규식(Regular Expression)이란, 특정한 규칙을 가진 문자열 집합을 표현하기 위해 쓰이는 형식으로, 주로 전화번호, 주민번호, 이메일 같은 특정 형식 검증에 씅딥니다. 이러한 형식들은 정규식을 통해 쉽게 검증할 수 있습니다.

  • ^ : 문자열의 시작
  • $ : 문자열의 종료
  • . : 임의의 한 문자
  • * : 앞 문자가 없거나 무한정 많음
  • + : 앞 문자가 하나 이상
  • ? : 앞 문자가 업거나 하나 존재
  • [,] : 문자의 집합이나 범위, 두 문자 사이는 ~ 기호로 범위 표현
  • {, } : 횟수 또는 범위
  • (, ) : 괄호 안의 문자를 하나의 문자로 인식
  • | : 패턴 안에서 OR 연산을 수행
  • \ : 정규식에서 역슬래시는 확장문자로 취급하고, 역슬래시 다음에 특수문자가 오면 문자로 인식
  • \b : 단어의 경계
  • \B : 단어가 아닌 것에 대한 경계
  • \A : 입력의 시작 부분
  • \G : 이전 매치의 끝
  • \Z : 종결자가 있는 경우 입력의 끝
  • \z : 입력의 끝
  • \s : 공백 문자
  • \S : 공백 문자가 아닌 나머지 문자(^\s와 동일)
  • \w : 알파벳이나 숫자
  • \W : 알파벳이나 숫자가 아닌 문자(^\w와 동일)
  • \d : 숫자 [0-9]와 동일하게 취급
  • \D : 숫자를 제외한 모든 문자(^0-9와 동일)

정규식은 익숙하지 않은 문자의 조합으로 구성되기 때문에 다음과 같은 사이트에서 직접 정규식을 만들어보며 연습할 수 있습니다.

@Valid와 @Validated

@Valid

@Valid는 JSR-303 표준 스펙(자바 진영 스펙)으로써 빈 검증기(Bean Validator)를 이용해 객체의 제약 조건을 검증하도록 지시하는 어노테이션입니다. JSR 표준의 빈 검증 기술의 특징은 객체의 필드에 달린 어노테이션으로 편리하게 검증을 한다는 것입니다.

@Valid 동작 방식

SpringBoot 에서 모든 요청은 프론트 컨트롤러인 DispatcherServlet 을 통해 Controller 로 전달됩니다.
전달 과정에서는 컨트롤러 메소드의 객체를 만들어주는 ArgumentResolver 가 동작하는데, @Valid 어노테이션도 이 ArgumentResolver 에 의해 처리가 됩니다.

따라서, @Valid 는 기본적으로 Controller(컨트롤러)에서만 동작하며, 다른 계층(ex: service)에서 사용하기 위해서는 @Validated 어노테이션과 결합해서 사용해야 합니다.

@Validated

@Valid는 Java 에서 지원해주는 어노테이션이고, @Validated는 Spring에서 지원해주는 어노테이션입니다.

입력 파라미터의 유효성 검증은 컨트롤러에서 최대한 처리하고 넘겨주는 것이 좋습니다다. 하지만 개발을 하다보면 불가피하게 다른 곳에서 파라미터를 검증해야 할 수 있다.
Spring에서는 이를 위해 AOP 기반으로 메소드의 요청을 가로채서 유효성 검증을 진행해주는 @Validated를 제공합니다. @Validated는 JSR 표준 기술이 아니며 Spring 프레임워크에서 제공하는 어노테이션 및 기능입니다.

@Validated@Valid의 기능을 포함하고 있고, 유효성을 검토할 그룹을 지정할 수 있는 추가 기능이 있습니다.

@ValidMethodArgumentNotValidException 예외를 발생시키지만,
@ValidatedConstraintViolationException 예외를 발생시킵니다.

실제 AOP를 이용한 유효성 검사시, 보통은 클래스에 @Validated를, 유효성을 검증할 메서드의 파라미터에 @Valid 또는 @Validated를 붙입니다.

@Service
@Validated
public class UserService {

	public void addUser(@Valid AddUserRequest addUserRequest) {
		...
	}
}

만약 @Validated에서 에러가 발생하면 다음과 같이 ConstraintViolationException 이 발생합니다.

javax.validation.ConstraintViolationException: getQuizList.category: 널이어서는 안됩니다 
    at org.springframework.validation.beanvalidation.MethodValidationInterceptor.invoke(MethodValidationInterceptor.java:120) ~[spring-context-5.3.14.jar:5.3.14] 
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.14.jar:5.3.14] 
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:753) ~[spring-aop-5.3.14.jar:5.3.14] 
    at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:698) ~[spring-aop-5.3.14.jar:5.3.14] 
    at com.mangkyu.employment.interview.app.quiz.controller.QuizController$$EnhancerBySpringCGLIB$$b23fe1de.getQuizList(<generated>) ~[main/:na] 
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~
출처: https://mangkyu.tistory.com/174 [MangKyu's Diary:티스토리]

@Validated 동작 방식

특정 ArgumnetResolver에 의해 유효성 검사가 진행되었던 @Valid와 달리, @Validated는 AOP 기반으로 메소드 요청을 인터셉터하여 처리됩니다.

@Validated를 클래스 레벨에 선언하면 해당 클래스에 유효성 검증을 위한 AOP의 어드바이스 또는 인터셉터(MethodValidationInterceptor)가 등록됩니다. 그리고 해당 클래스의 메소드들이 호출될 때 AOP의 포인트 컷으로써 요청을 가로채서 유효성 검증을 진행합니다.

이러한 이유로 @Validated를 사용하면 컨트롤러, 서비스, 레포지토리 등의 계층에 무관하게 스프링 빈이라면 유효성 검증을 진행할 수 있습니다.
따라서 클래스에는 유효성 검증 AOP가 적용되도록 @Validated를, 검증을 진행할 메서드에는 @Valid 또는 @Validated를 선언합니다.

  • @Valid에 의한 예외 : MethodArgumentNotValidException
  • @Validated에 의한 예외 : ConstraintViolationException

따라서 정리하면, 단순히 모든 필드를 검증할 때는 @Valid, @Validated중 선택하고, 몇 계층에 걸쳐 검증하거나 일부분만 검증해야 하는 경우 @Validated 어노테이션과 그룹 인터페이스 클래스를 활용해 Group Validation을 적용할 수 있습니다.

🤔 @Validated, @Valid 둘중 어떤 것을 사용하지?
대게 그룹 기능이 필요하면 @Validated를 사용하고, 그렇지 않다면 둘 편한 것을 사용합니다.
딱히 정해진 것이 없어 팀에서 합의를 이뤄 사용합니다.
인프런 유사 질문

Validation 사용 장소와 방법

1. Controller에 request body에 @Valid 또는 @Validated를 붙여 검증

@PostMapping("/validated")
    public ResponseEntity<String> checkValidation(
            @Validated @RequestBody ValidatedRequestDto validatedRequestDto){
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }

2. DTO의 각 필드에 Validation 어노테이션으로 검증

RequestBody에서 대부분 DTO 로 값을 가져옵니다. 이 DTO의 각 세부 유효성 검증은 객체 안에 정의합니다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidatedRequestDto {

    @NotBlank
    private String name;

    @Email
    private String email;

    @Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
    private String phoneNumber;

    @Min(value = 20, groups = ValidationGroup1.class) 
    @Max(value = 40, groups = ValidationGroup1.class)
    int age;
    
    @Size(min = 0, max = 40)
    private String description;
    
    @Positive(groups = ValidationGroup2.class)
    private int count;
    
    @AssertTrue
    private boolean booleanCheck;
    
}

3. 도메인의 각 필드에 검증

@Data
public class Taco {
	
	@NotNull
	@Size(min = 5, message="최소 5개 이상의 문자가 되어야합니다.")
	private String name;
	
	@Size(min=1, message = "최소 1개의 재료를 선택해야합니다.")
	private List<String> ingredients;
}

적용시켜보기

게시글을 등록하는 로직에서 간단한 유효성 검사를 추가해보겠습니다.

Controller

Controller의 Post메서드의 RequestBody(DTO)에 @Valid 어노테이션을 붙입니다.

@Operation(summary = "게시글 등록", description = "제목(title)과 내용(content)을 이용하여 게시물을 신규 등록한다.")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "201", description = "Created", content = @Content(schema = @Schema(implementation = Article.class))),
            @ApiResponse(responseCode = "400", description = "Bad Request"),
            @ApiResponse(responseCode = "500", description = "Internal server error",
                    content = { @Content(mediaType = "application/json",
                            schema = @Schema(implementation = ErrorResponse.class)) })
    })
    @PostMapping("/articles")
    public ResponseEntity<Article> addArticle(@Valid @RequestBody AddArticleRequest request) {
        Article savedArticle = articleService.save(request);

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(savedArticle);
    }

DTO에 validation 추가

DTO에 세부 유효성 검사를 추가합니다.
@NotBlank는 null, "", " " 을 모두 허용하지 않습니다.

@NoArgsConstructor
@AllArgsConstructor
@Getter
public class AddArticleRequest {

    @Schema(description = "제목", nullable = false)
    @NotBlank(message = "제목을 입력해주세요.")
    private String title;
    @Schema(description = "내용", nullable = false)
    @NotBlank(message = "내용을 입력해주세요.")
    private String content;

    public Article toEntity() {
        return Article.builder()
                .title(title)
                .content(content)
                .build();
    }

}

잘못된 요청 확인하기

Postman에서 다음과 같이 타이틀에 ""를 설정하고 POST 요청을 보내보겠습니다.
![]

설정한 규칙에서 벗어나는 값으로 요청을 보내니 다음과 같이 400에러가 발생합니다.

애플리케이션에서 로그를 확인하면 다음과 같이 문제가 발생한 지점을 확인할 수 있습니다.

024-01-10T00:26:20.885+09:00  WARN 18424 --- [nio-8080-exec-4] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public org.springframework.http.ResponseEntity<com.umust.umustbe.article.domain.Article> com.umust.umustbe.article.controller.ArticleApiController.addArticle(com.umust.umustbe.article.dto.AddArticleRequest): [Field error in object 'addArticleRequest' on field 'title': rejected value []; codes [NotBlank.addArticleRequest.title,NotBlank.title,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [addArticleRequest.title,title]; arguments []; default message [title]]; default message [제목을 입력해주세요.]]

~ing

  • 커스텀 Validation
  • 예외 처리

참고

profile
私はゲームと日本が好きなBackend Developer志望生のOguです🐤🐤

0개의 댓글