티스토리 뷰

Spring

[Validation] @Valid 사용하기

DevBee 2022. 6. 28. 16:47

1. 설치

Spring Boot Validation Starter를 추가합니다. (Bean Validation 구현체로 Hibernate Validator를 사용합니다.)

Maven 의 경우 아래 내용을 추가하면 됩니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Gradle 의 경우는 아래 내용을 추가합니다.

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

 

2. 기본 제약 설정 및 검사

Spring Boot MVC 패턴의 Controller 에서 @RequestBody 를 통해 객체를 받는 경우, 해당 객체에 대한 유효성 검사는 다음과 같이 할 수 있습니다.

먼저, Controller에서는 파라미터에 @Valid 라는 Annotation을 추가해줍니다.

@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/v1/missions")
public class MissionsRestController {
    private final MissionsService missionsService;
    
    @PostMapping
    public ResponseEntity<CommonResponse> createMission(@RequestBody @Valid MissionCreateRequest request) {
        return CommonResponse.ok(missionsService.createMission(request, member));
    }
}

 

그리고 해당 객체에 다음과 같이 Validation 과 관련된 Annotation 들을 추가해줍니다.

import lombok.Getter;
import javax.validation.constraints.*;

@Getter
public class MissionCreateRequest {

    ...
    
    @NotNull 
    @Positive 
    @Max(value = 99999999)
    private Integer pay;
    
    @NotNull 
    @Size(max = 3000) 
    private String description;
    
    ...
    
}

 

기본 validation annotation에 대해서는 다음을 참고할 수 있습니다.

https://hyeran-story.tistory.com/81

 

3. List 내 객체에 적용

만약 객체 안에 있는 List의 각 객체들에 대해 validation을 적용해야한다면 List<@Valid Object> 형태로 처리할 수 있습니다.

import lombok.Getter;

import javax.validation.Valid;
import javax.validation.constraints.*;
import java.util.List;

@Getter
public class MissionCreateRequest {
    
    ...

    @NotNull 
    @Positive 
    @Max(value = 99999999)
    private Integer pay;

    @NotNull 
    @Size(max = 3000) 
    private String description;

    // 미션 카테고리 정보
    @NotNull
    @Size(min = 1)
    private List<@Valid MissionCategoryRequest> categories;
    
    ...
    
    // 미션 주소 정보
    @NotNull
    private @Valid MissionAddressRequest address;
}

 

MissionCategoryRequest 또는 MissionAddressRequest 와 같은 객체 내에서는 다시 @NotNull 등을 사용하여 각 필드에 validation 을 걸 수 있습니다.

 

4. Enum Validation Check

Enum에 포함된 값들만 들어올 수 있도록 하기 위해서는 custom validation annotation을 생성해야 합니다.

먼저 Annotation을 만들어 줍니다.

import javax.validation.Constraint;
import javax.validation.Payload;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Documented
@Target({ METHOD, FIELD, PARAMETER })
@Retention(RUNTIME)
@Constraint(validatedBy = {EnumValidator.class})
public @interface EnumValid {
    String message() default "Enum에 없는 값입니다.";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };

    Class<? extends java.lang.Enum<?>> enumClass();

    boolean ignoreCase() default false;
}

 

그런 다음 Validator를 생성합니다.

import org.springframework.util.ObjectUtils;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class EnumValidator implements ConstraintValidator<EnumValid, String> {
    private EnumValid annotation;

    @Override
    public void initialize(EnumValid constraintAnnotation) {
        this.annotation = constraintAnnotation;
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (ObjectUtils.isEmpty(value)) return false;

        Object[] enumValues = this.annotation.enumClass().getEnumConstants();
        if (enumValues != null) {
            for (Object enumValue : enumValues) {
                if (value.equals(enumValue.toString())
                        || (this.annotation.ignoreCase() && value.equalsIgnoreCase(enumValue.toString()))) {
                    return true;
                }
            }
        }
        return false;
    }
}

 

이제 원하는 Enum 타입 위에 @EnumValid 애노테이션을 추가하면 validation check 가 가능합니다.

import com.hanwha.insurance.gep.annotation.validation.EnumValid;
import com.hanwha.insurance.gep.constants.common.YesOrNoType;
import lombok.Getter;

import javax.validation.Valid;
import javax.validation.constraints.*;
import java.util.List;

@Getter
@MissionCreateValid
public class MissionCreateRequest {

    ...
    
    @EnumValid(enumClass = YesOrNoType.class)
    private String adjustedYn;

    @NotNull 
    @Positive 
    @Max(value = 99999999)
    private Integer pay;

    @NotNull 
    @Size(max = 3000) 
    private String description;

    // 미션 카테고리 정보
    @NotNull
    @Size(min = 1)
    private List<@Valid MissionCategoryRequest> categories;

    ...

    // 미션 주소 정보
    @NotNull
    private @Valid MissionAddressRequest address;
    
    ...
}

 

5. Object Validation Check

도메인 모델의 속성의 값에 따라 데이터 유효성 검사를 다르게 해야 되는 경우도 있습니다. 즉, 런타임에 속성의 값에 따라 데이터 유효성 검사 방법이 결정되는 경우입니다. 예를 들어 현재 시간 < startAt < endAt 이라는 조건이 있을 경우, startAt에 따라 endAt도 비교를 해야합니다.

 

이럴 때는 도메인 모델 전체에 대한 애노테이션을 생성합니다.

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Documented
@Target({ TYPE })
@Retention(RUNTIME)
@Constraint(validatedBy = {MissionCreateValidator.class})
public @interface MissionCreateValid {
    String message() default "미션 시작 또는 종료 시간이 유효하지 않습니다.";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };
}

 

이후 validator를 작성하는데 isValid 안에 원하는 조건을 넣습니다.

import com.hanwha.insurance.gep.service.missions.request.MissionCreateRequest;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.time.LocalDateTime;

public class MissionCreateValidator implements ConstraintValidator<MissionCreateValid, MissionCreateRequest> {
    @Override
    public boolean isValid(MissionCreateRequest value, ConstraintValidatorContext context) {
        // 미션 시작, 종료 시간만 확인 (현재시간 < 시작시간 < 종료시간)
        return LocalDateTime.now().isBefore(value.getStartAt()) && value.getStartAt().isBefore(value.getEndAt());
    }
}

 

마지막으로 객체에 애노테이션을 달아주면 validation check 가 가능합니다.

import com.fasterxml.jackson.annotation.JsonFormat;
import com.hanwha.insurance.gep.annotation.validation.EnumValid;
import com.hanwha.insurance.gep.annotation.validation.MissionCreateValid;
import com.hanwha.insurance.gep.constants.common.YesOrNoType;
import lombok.Getter;

import javax.validation.Valid;
import javax.validation.constraints.*;
import java.time.LocalDateTime;
import java.util.List;

@Getter
@MissionCreateValid
public class MissionCreateRequest {
    // 미션 정보
    @NotNull
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime startAt;

    @NotNull
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime endAt;

    @EnumValid(enumClass = YesOrNoType.class)
    private String adjustedYn;

    @NotNull 
    @Positive 
    @Max(value = 99999999)
    private Integer pay;

    @NotNull 
    @Size(max = 3000) 
    private String description;

    // 미션 카테고리 정보
    @NotNull
    @Size(min = 1)
    private List<@Valid MissionCategoryRequest> categories;

    ...

    // 미션 주소 정보
    @NotNull
    private @Valid MissionAddressRequest address;

    ...
}

 

6. Error 처리

이렇게 위에서 validation check를 해서 Error가 발생한 경우 MethodArgumentNotValidException 가 발생합니다. 따라서 아래와 같이 에러를 잡아서 처리할 수 있습니다. (@ControllerAdvice@ExceptionHandler 를 통해 처리하면 됩니다.)

@Slf4j
@RequiredArgsConstructor
@ControllerAdvice
public class DefaultExceptionAdvice {

    @ExceptionHandler(Exception.class)
    protected ResponseEntity<Object> handleException(final Exception e) {
        Map<String, Object> result = new HashMap<>();
        ResponseEntity<Object> ret = null;

        if (e instanceof BusinessException) {
            BusinessException businessException = (BusinessException) e;
            result.put(ResponseEntityConstants.SUCCESS_OR_NOT, ResponseEntityConstants.SUCCESS_NO_FLAG);
            result.put(ResponseEntityConstants.STATUS_CODE, businessException.getStatusCode());
            result.put(ResponseEntityConstants.ERROR_MESSAGE, businessException.getMessage());

            ret = new ResponseEntity<>(result, EXPECTATION_FAILED);
        } else if (e instanceof SystemException) {
            ...
        } else if (e instanceof MissingServletRequestParameterException) {
            ...
        } else if (e instanceof MethodArgumentNotValidException) {
            MethodArgumentNotValidException methodArgumentNotValidException = (MethodArgumentNotValidException) e;

            result = handleMethodArgumentNotValidException(methodArgumentNotValidException);

            result.put(ResponseEntityConstants.SUCCESS_OR_NOT, ResponseEntityConstants.SUCCESS_NO_FLAG);
            ret = new ResponseEntity<>(result, EXPECTATION_FAILED);
        } else {
            ...
        }

        return ret;
    }

    private Map<String, Object> handleMethodArgumentNotValidException(final MethodArgumentNotValidException exception) {
        Map<String, Object> result = new HashMap<>();

        List<FieldError> fieldErrorList = exception.getBindingResult().getFieldErrors();

        ...
        
        for (final FieldError element : fieldErrorList) {
            ...
        }
        
        ...

        return result;
    }
}

 

에러를 처리하는 방식은 다양할 수 있으니 참고만 하시기 바랍니다.

 

참고

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함