본문 바로가기
Spring Boot

[Spring Boot] @Valid를 이용한 유효성 검사

by byeongoo 2020. 8. 9.

서버에서 데이터를 받을 경우 데이터에 대한 유효성 검사를 하는 방법에 대해서 살펴보겠습니다. 

1. application.properties 옵션 추가

클라이언트에서 서버로 알 수 없는 데이터 필드를 전달할 경우 에러를 발생시키는 설정입니다. 이 설정을 false로 하면 정의 되지 않는 속성이 있어도 무시할 수 있습니다.

#unknown properties가 있으면 실패
spring.jackson.deserialization.fail-on-unknown-properties=true

2. EventDto 정의

EventDto 클래스를 정의해 보았습니다. 이벤트의 이름, 상세 설명, 이벤트 시작일, 이벤트 종료일등의 데이터값은 항상 null이면 안되기 때문에 @NotEmpty, @NotNull 어노테이션을 사용해서 정의하였습니다. 이렇게 선언만한다고 항상 validation을 하는건 아니고 컨트롤러에서 @Valid 어노테이션을 이용하여 유효성 검사를 진행해야합니다.

package me.hoon.demoinflearnrestapi.events;

import lombok.*;

import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
public class EventDto {

    @NotEmpty
    private String name;
    @NotEmpty
    private String description;
    @NotNull
    private LocalDateTime beginEnrollmentDateTime;
    @NotNull
    private LocalDateTime closeEnrollmentDateTime;
    @NotNull
    private LocalDateTime beginEventDateTime;
    @NotNull
    private LocalDateTime endEventDateTime;
    private String location; // (optional) 이게 없으면 온라인 모임
    @Min(0)
    private int basePrice; // (optional)
    @Min(0)
    private int maxPrice; // (optional)
    @Min(0)
    private int limitOfEnrollment;

}

3. Bean Validation 2.0 제공 애노테이션

위의 EventDto에서는 @NotEmpty, @NotNull, @Min 어노테이션만을 이용해보았는데, 좀 더 상세히 알아보겠습니다.

애노테이션 

주요 속성 

설명 

 지원 타입

@AssertTrue

@AssertFalse

 

값이 true인지 또는 false인지 검사한다. null은 유효하다고 판단한다. 

boolean
Boolean

@DecimalMax

@DecimalMin

String value

- 최댓값 또는 최솟값

 

boolean inclusive

- 지정값 포함 여부

- 기본 값 true

지정한 값보다 작거나 같은지 또는 크거나 같은지 검사한다.

inclusive가 false면 value로 지정한 값은 포함하지 않는다.

null은 유효하다고 판단한다.

BigDecimal

BigInteger

CharSequence

byte, short, int, long 및 각 래퍼 타입

@Max

@Min

long value

지정한 값보다 작거나 같은지 또는 크거나 같은지 검사한다.

null은 유효하다고 판단한다.

BigDecimal
BigInteger
byte, short, int, long 및 관련 래퍼 타입

@Digits

int integer

- 허용 가능한 정수 자릿수

 

int fraction

- 허용 가능한 소수점 이하 자릿수

자릿수가 지정한 크기를 넘지 않는지 검사한다.

null은 유효하다고 판단한다.

BigDecimal

BigInteger

CharSequence

byte, short, int, long 및 관련 래퍼 타입

@Size

int min

- 최소 크기

- 기본 값 0

 

int max

- 최대 크기

- 기본 값 

길이나 크기가 지정한 값 범위에 있는지 검사한다.

null은 유효하다고 판단한다.

CharSequence

Collection

Map

배열

@Null

@NotNull

 

값이 null인지 또는 null이 아닌지 검사한다. 

 

@Pattern

String regexp

- 정규표현식 

값이 정규표현식에 일치하는지 검사한다. 

null은 유효하다고 판단한다.

CharSequence

@NotEmpty (2)

 

문자열나 배열의 경우 null이 아니고 길이가 0이 아닌지 검사한다. 콜렉션의 경우 null이 아니고 크기가 0이 아닌지 검사한다.

CharSequence

Collection

Map

배열

@NotBlank (2)

 

null이 아니고 최소한 한 개 이상의 공백아닌 문자를 포함하는지 검사한다.

CharSequence

@Positive (2)

@PositiveOrZero (2)

 

양수인지 검사한다.

OrZero가 붙은 것은 0 또는 양수인지 검사한다.

null은 유효하다고 판단한다.

BigDecimal
BigInteger
byte, short, int, long 및 관련 래퍼 타입

@Negative (2)

@NegativeOrZero (2)

 

음수인지 검사한다.
OrZero가 붙은 것은 0 또는 음수인지 검사한다. 

null은 유효하다고 판단한다.

BigDecimal
BigInteger
byte, short, int, long 및 관련 래퍼 타입

@Email (2)

 

이메일 주소가 유효한지 검사한다. 
null은 유효하다고 판단한다.
CharSequence 

@Future (2)

@FutureOrPresent (2)

 

해당 시간이 미래 시간인지 검사한다.

OrPresent가 붙은 것은 현재 또는 미래 시간인지 검사한다.

null은 유효하다고 판단한다.

시간 관련 타입

@Past (2)

@PastOrPresent (2)

 

해당 시간이 과거 시간인지 검사한다.

OrPresent가 붙은 것은 현재 또는 과거 시간인지 검사한다.
null은 유효하다고 판단한다.

시간 관련 타입 

4. 컨트롤러에서 유효성 체크

우선 스프링이 제공하는 Errors를 변환하는 Serializer를 상속받는 ErrorSerializer를 구현합니다. 

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.springframework.boot.jackson.JsonComponent;
import org.springframework.validation.Errors;

import java.io.IOException;

@JsonComponent
public class ErrorsSerializer extends JsonSerializer<Errors> { //스프링이 제공하는 Errors를 변환하는 Serializer

    @Override
    public void serialize(Errors errors, JsonGenerator gen, SerializerProvider serializerProvider) throws IOException {
        //errors 안에는 에러가 여러개 이기 때문에 startArray와 endArray 사용
        gen.writeStartArray();

        //필드에러
        errors.getFieldErrors().forEach(e -> {
            try{
                gen.writeStartObject();
                gen.writeStringField("field", e.getField());
                gen.writeStringField("objectName", e.getObjectName());
                gen.writeStringField("code", e.getCode());
                gen.writeStringField("defaultMessage", e.getDefaultMessage());

                Object rejectedValue = e.getRejectedValue();
                if(rejectedValue != null){
                    gen.writeStringField("rejectedValue", rejectedValue.toString());
                }
                gen.writeEndObject();
            } catch(IOException e1){
                e1.printStackTrace();
            }

        });

        //글로벌 에러
        errors.getGlobalErrors().forEach(e -> {
            try{
                gen.writeStartObject();
                gen.writeStringField("objectName", e.getObjectName());
                gen.writeStringField("code", e.getCode());
                gen.writeStringField("defaultMessage", e.getDefaultMessage());
                gen.writeEndObject();
            } catch(IOException e1){
                e1.printStackTrace();
            }
        });

        gen.writeEndArray();
    }
}

EventDto에서 정의한 Bean Validation을 적용하기 위해서는 컨트롤러에서 파라미터에 @Valid 어노테이션을 넣어주어야합니다. 만약 이벤트의 이름이 없다면 에러를 반환해줍니다. 

 

createEvent에 Errors 라는 클래스가 보입니다. 해당 클래스는 org.springframework.validation에 속한 인터페이스입니다. eventDto에 필드값이 설정해둔것과 맞지 않는게 있다면 errors에서 에러를 포함하고 있고 badRequest를 response로 반환합니다. 

@Slf4j
@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE)
public class EventController {

    private final EventRepository eventRepository;
    private final ModelMapper modelMapper;
    private final EventValidator eventValidator;

    @Autowired
    public EventController(EventRepository eventRepository, ModelMapper modelMapper, EventValidator eventValidator){
        this.eventRepository = eventRepository;
        this.modelMapper = modelMapper;
        this.eventValidator = eventValidator;
    }

    @PostMapping
    public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors){
        if(errors.hasErrors()){
            return ResponseEntity.badRequest().build();
        }

        eventValidator.validate(eventDto, errors);
        if(errors.hasErrors()){
            return ResponseEntity.badRequest().body(errors);
        }

        Event event = modelMapper.map(eventDto, Event.class);
        event.update();

        Event newEvent = this.eventRepository.save(event);

        return ResponseEntity.created(createdUri).body(newEvent);
    }

}

 조금더 복잡한 로직은 eventValidator 클래스를 만들어서 검사를 진행합니다. 예제 코드는 최소 가격이 최대 가격보다 큰 케이스나 이벤트 종료 시간이 이벤트 시작 시간보다 더 빠른 경우를 검사합니다. 

import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;

import java.time.LocalDateTime;

@Component //빈등록
public class EventValidator {

    public void validate(EventDto eventDto, Errors errors){
        if(eventDto.getBasePrice() > eventDto.getMaxPrice() && eventDto.getMaxPrice() != 0){
            errors.rejectValue("basePrice", "wrongValue", "BasePrice is wrong");
            errors.rejectValue("maxPrice", "wrongValue", "MaxPrice is wrong");
        }

        LocalDateTime endEventDateTime = eventDto.getEndEventDateTime();
        if(endEventDateTime.isBefore(eventDto.getBeginEventDateTime()) ||
            endEventDateTime.isBefore((eventDto.getCloseEnrollmentDateTime())) ||
            endEventDateTime.isBefore(eventDto.getBeginEnrollmentDateTime())){
            errors.rejectValue("endEventDateTime", "wrongValue", "endEventDateTime");
        }
    }

}