유효성 검사

goose_bumps·2025년 5월 31일

SpringBoot

목록 보기
8/9

1. 유효성 검사는 왜 해야하는가?

실제 서비스 배포 시 유효성 검사는 반드시 사용한다.
사용자가 의도치 않게 잘못된 데이터를 입력하거나, 악의적인 사용자가 시스템을 공격하려고 잘못된 데이터를 넣을 수 있다.
이를 미리 차단하지 않으면 서버 오류나 보안 문제, 데이터 무결성 훼손이 발생할 수 있다.

생년월일을 입력해야 하는 필드에 사용자가 "abcd" 이런 식으로 형식에 맞지 않은 전혀 관련 없는 데이터를 입력하면 DB에 비정상적인 값이 저장될 것이다.

유효성 검사를 통해 입력값을 제대로 검사한다면 데이터 무결성뿐만 아니라 보안, 서버를 방어하는 1차 방어선 역할을 할 수 있다.

2. 유효성 검사

클라이언트로부터 데이터를 받아 각 계층에 전송할 때 DTO 객체를 활용하고 있기 때문에 유효성 검사는 DTO 객체를 대상으로 하는 것이 일반적이다.

유효성 검사를 위해 build.gradle에 의존성을 추가해야 한다.

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

1) DTO 클래스에 에너테이션 추가

간단한 예시를 위해 Product 관련해서만 유효성 검사를 할 것이다.
우선, ProductDTO 클래스를 대상으로 유효성 검사를 해야 하기 때문에 코드를 다음과 같이 바꿔보자.

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class ProductDTO {

    //null, "", " "을 허용하지 않음
    @NotBlank
    private String name;

    //1000 이상의 값만 허용
    @Min(value = 1000)
    private Integer price;

    //양수만 허용
    @Positive
    private Integer stock;
}

각 필드마다 에너테이션을 추가했는데 기능은 주석으로 적어놨으니 참고하면 된다.
만약, 클라이언트가 상품의 가격을 500원으로 입력하여 서버로 보낸다면 유효성 검사에서 걸리게 되는 것이다.

그럼 유효성 검사에서 걸리면 어떻게 될까? 그에 대한 설명은 뒤에서 다루겠다.

검증에 사용된 에너테이션들은 jakarta.validation.constraints에서 import했는데

https://jakarta.ee/specifications/bean-validation/3.0/apidocs/jakarta/validation/constraints/package-summary

여기를 참고하면 검증에 필요한 에너테이션들이 어떤 것들이 있는지 알 수 있다.

2) @Valid를 통한 유효성 검사

이제 컨트롤러를 수정해야 하는데 상품을 등록하는 saveProduct를 다음과 같이 수정해보자.

    @PostMapping("/enroll")
    public ResponseEntity<String> saveProduct(@Valid @RequestBody ProductDTO productDTO){
        productService.saveProduct(productDTO);
        return ResponseEntity.status(HttpStatus.OK).body("Product's Info is saved");
    }

기존과 크게 다른 것은 없고 입력매개변수 앞에 @Valid가 추가된 것 뿐이다.
이 에너테이션이 추가되었기 때문에 @RequestBody로 받은 입력 데이터(ProductDTO 필드값)에 대해 유효성 검사가 가능하다.

Swagger UI를 사용하여 잘못된 데이터를 입력하면 서버에서 어떻게 응답하는지 확인해보자.

{
  "name": "Eraser",
  "price": 500,
  "stock": -1
}

가격은 1000이하이고 재고는 음수로 설정하여 서버에 요청을 보내면

400 에러가 발생한다.

지금까지는 서버에 어떤 값을 보내도 DB에 저장되었는데 유효성 검사를 추가함으로써 비정상적인 데이터를 1차적으로 차단하여 데이터 무결성을 보호할 수 있다.

3) @Validated를 통한 유효성 검사

앞에서 사용한 @Valid는 자바에서 지원하는 에너테이션이다. 그래서 import를 보면

import jakarta.validation.Valid;

자바에서 지원하는 것을 알 수 있다.

이 에너테이션 외에도 스프링부트의 별도 에너테이션이 있는데 바로 @Validated다.
@Valid의 기능을 포함하고 있기 때문에 이 에너테이션을 사용하는 것이 권장되고 추가적으로 유효성 검사를 그룹으로 묶어 대상을 특정할 수 있는 기능이 있다.

대상을 특정하는 기능을 사용하려면 선행적으로 그룹으로 사용할 인터페이스가 필요하다.

public interface ValidationGroup1 {
}
public interface ValidationGroup1 {
}

아무 내용 없는 인터페이스를 2개 생성해보자. 다른 기능은 없고 오직 그룹으로 사용하기 위한 인터페이스이다.

그리고 DTO 객체의 필드를 수정하겠다.

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class ProductDTO {

    //null, "", " "을 허용하지 않음
    @NotBlank
    private String name;

    //1000 이상의 값만 허용
    @Min(value = 1000, groups = ValidationGroup1.class)
    private Integer price;

    //양수만 허용
    @Positive(groups = ValidationGroup2.class)
    private Integer stock;
}

이름은 그대로고 가격과 재고에 groups를 추가하여 각각 그룹1,2로 지정해주었다.

이제 컨트롤러를 수정해보자.

    @PostMapping("/enroll")
    public ResponseEntity<String> saveProduct(@Validated(ValidationGroup1.class) @RequestBody ProductDTO productDTO){
        productService.saveProduct(productDTO);
        return ResponseEntity.status(HttpStatus.OK).body("Product's Info is saved");
    }

기존의 에너테이션을 @Validated로 바꾸고 매개변수로 ValidationGroup1.class를 넘겨주었다.

이제 Swagger UI를 사용해 데이터를 서버에 전송할 것인데 가격 외에는 잘못된 데이터를 입력하겠다.

{
  "name": " ",
  "price": 1500,
  "stock": -1
}


분명 조건에 해당하지 않은 잘못된 데이터인데도 서버는 정상적으로 데이터를 DB로 전송하였다.

이는 @Validated의 그룹으로 묶어 대상을 특정하는 기능으로 인해 발생한 것이다.

  • @Validated가 특정 그룹을 설정하는 경우에는 지정된 그룹으로 설정된 필드에 대해서만 유효성 검사 수행
  • @Validated가 특정 그룹을 설정하지 않은 경우에는 groups가 설정되지 않은 필드에 대해 유효성 검사 수행

컨트롤러에서 @Validated가 특정 그룹(ValidationGroup1)을 설정하였기 때문에 DTO 객체의 필드 중 groupsValidationGroup1으로 설정된 필드만 유효성 검사 대상이 된 것이다.

그래서 가격 데이터만 정상인데 통과한 이유도 해당 필드에 대해서만 유효성 검사가 수행되었기 때문이다.

만약, 컨트롤러 매개변수를

@Validated()
@Validated(ValidationGroup2.class)

이와 같이 지정했다면 400 에러가 발생했을 것이다.

3. 커스텀 Validation

유효성 검사를 위해 DTO 객체 필드에 추가했던 에너테이션을 커스텀하여 개발자가 직접 만들 수 있다.
이름이 2자 이상 9자 이하만 유효한 에너테이션을 만들어보자.

우선, 에너테이션 클래스를 만들어야 한다.

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NameValidator.class)
public @interface Name {
    String message() default "이름은 2자 이상 9자 이하이어야 합니다.";
    Class<?>[] groups() default{};
    Class<? extends Payload>[] payload() default {};
}

@Target은 이 에너테이션이 어디서 선언될 수 있는지를 정의한다. ElementType.FIELD로 설정하였기 때문에 @Name 에너테이션은 필드값에서만 선언이 가능하다.
(FIELD 외에도 PACKAGE, CONSTRUCTOR, METHOD 등도 가능)

@Retention은 이 에너테이션이 실제로 적용되고 유지되는 범위를 의미하며 RetentionPolicy.RUNTIME은 컴파일 이후에도 적용되는 것을 말한다.

@Constraint은 매핑을 위해 필요한데 validatedBy = NameValidator.class를 통해 NameValidator 클래스와 매핑한다는 의미이다.

message() 메서드는 유효성 검사에 실패했을 때 클라이언트에게 전달할 기본 오류 메시지이며 나머지 두 메서드도 잘 사용하지 않지만 반드시 있어야 한다.

이제 NameValidator 클래스를 만들어보자.

public class NameValidator implements ConstraintValidator<Name, String> {
    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        if(s.length() <=1 || s.length() >= 10){
            return false;
        }
        return true;
    }
}

위에서 매핑하려는 클래스인데 실질적으로 검사 조건을 이 클래스에서 한다.
ConstraintValidator를 구현하는데 제네릭 타입의 2번째 타입과 동일한 타입이 isValid() 매개변수의 첫 번째 타입으로 설정된다.
isValid()true를 반환하면 유효한 데이터, false를 반환하면 유효하지 않은 데이터로 판단한다.

이제 DTO 객체에 이 에너테이션을 추가해보자.

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class ProductDTO {

    @Name
    private String name;

    //1000 이상의 값만 허용
    @Min(value = 1000, groups = ValidationGroup1.class)
    private Integer price;

    //양수만 허용
    @Positive(groups = ValidationGroup2.class)
    private Integer stock;
}

name에 대해 유효성 검사를 해야 하기 때문에 컨트롤러도 수정해야 한다.

    @PostMapping("/enroll")
    public ResponseEntity<String> saveProduct(@Validated @RequestBody ProductDTO productDTO){
        productService.saveProduct(productDTO);
        return ResponseEntity.status(HttpStatus.OK).body("Product's Info is saved");
    }

@Validated의 그룹을 지정하지 않았기 때문에 name에 대해서만 유효성 검사가 수행된다.

{
  "name": "dasiojdaiodjoidiosj",
  "price": 1500,
  "stock": 2
}

10자가 넘어가는 이름을 데이터로 입력하여 서버로 전송하면

유효성 검사에서 걸려 400 에러가 발생한다.

4. 유효성 검사로 인한 예외

유효성 검사에서 유효하지 않은 값으로 결과가 나오면 MethodArgumentNotValidException를 발생시킨다.
그럼 단순히 예외만 발생시키면 끝인가?
서버는 클라이언트에게 왜 예외가 발생했는지 알려주는 메시지를 보내야 한다.

유효하지 않은 데이터로 인해 예외가 발생했다면 그에 대한 메시지를 클라이언트에게 보내야 한다.

글로벌 예외 처리를 통해 유효성 검사로 인해 발생한 예외에 대해서는 에러 타입과 에러가 발생한 URI, 예외 문구를 보내도록 설계를 해보자.

@RestControllerAdvice
public class ExceptionHandler {

    @org.springframework.web.bind.annotation.ExceptionHandler(value = MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String,String>> validationExceptionHandler(MethodArgumentNotValidException e, HttpServletRequest request){
        HttpHeaders httpHeaders = new HttpHeaders();
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        Map<String, String> map = new HashMap<>();
        map.put("Error Type", httpStatus.getReasonPhrase());
        map.put("URI", request.getRequestURI());
        map.put("message", "유효하지 않은 데이터로 인한 예외 발생"+e.getMessage());
        return new ResponseEntity<>(map,httpHeaders,httpStatus);
    }

}

MethodArgumentNotValidException을 처리하는 메서드를 만들어 글로벌 예외처리 하였다.

{
  "name": "David",
  "price": 1500,
  "stock": -1
}

유효하지 않은 재고 데이터를 서버에 넘기게 되면

map에 저장한 key-value 쌍이 JSON형식으로 출력이 되어 클라이언트가 왜 서버에서 에러가 발생하였는지 알 수 있다.

예외 메시지를 보면

[Field error in object 'productDTO' on field 'stock': rejected value [-1]; codes [Positive.productDTO.stock,Positive.stock,Positive.java.lang.Integer,Positive]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [productDTO.stock,stock]; arguments []; default message [stock]]; default message [0보다 커야 합니다]]

필드값인 stock이 유효하지 않은 값인 -1로 입력되었는데 @Positive로 인해 필드값이 0보다 커야 한다는 것이다.

이 메시지를 보면 클라이언트는 "아, 내가 잘못된 데이터를 입력해서 런타임 에러가 발생했구나"고 생각하여 정상적인 데이터를 입력할 수 있을 것이다.

0개의 댓글