[Spring Boot] Data Validation(Bean Validation + Hibernate Validation)
Spring Boot 에서는 사용자 입력 값에 대한 Validation, 즉 검증 과정을 위한 기능들을 제공한다. 그리고 사용자에게 응답 값으로 전달되는 데이터에 대해서 원하는 값만 전달하도록 Filtering 기능을 제공한다. 이번 포스트에서는 이러한 Validation와 Filtering을 하기 위한 방법에 대해서 알아보도록 한다.
Validation
Spring Boot를 사용해 웹 서버와 같은 애플리케이션을 개발할 때, 데이터를 검증(Validation)해야 하는 상황을 일반적으로 자주 발생하는 일이다. 사용자가 입력하는 이메일, 휴대폰 번호 등 다양한 입력값에 대해서 많은 검증이 필요하다. 검증에서 그치는 것 뿐만 아니라 사용자가 원인을 쉽게 파악하고 이해할 수 있도록 API 응답을 제공해야 한다.
일반적인 방법으로 애플리케이션에서 데이터 검증을 구현하다보면 아래의 문제들이 발생하게 된다.
- 애플리케이션 전체에 검증 코드가 분산되어 있다.
- 코드 중복이 심하게 발생한다.
- 비즈니스 로직에 섞여있어 검사 로직의 추적이 어렵고 애플리케이션이 복잡해진다.
이러한 문제 때문에 데이터 검증 로직에 기능을 추가하거나 수정하기 어렵고, 오류가 발생할 가능성도 커진다.
Java에서는 2009년부터 Bean Validation이라는 데이터 유효성 검사 프레임워크를 제공하고 있다. 다양한 제약(Constraint)를 도메인 모델에 어노테이션을 통해 정의할 수 있도록 했고, 이 제약을 유효성 검사가 필요한 객체제 직접 정의하는 방법으로 기존에 발생할 수 있는 문제를 해결했다.
Bean Validation 사용법
Bean Validation을 사용하기 위해서는 pom.xml에 아래 의존성을 추가해야 한다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
1. 일반적인 사용법
이렇게 의존성을 추가했다면 Bean Validation을 바로 사용할 수 있다. Service나 Bean에서 사용하기 위해서는 @Validated 와 @Valid 어노테이션을 추가해야 한다.
@Validated // 여기에 추가
@Service
public class ContactService {
public void createContact(@Valid CreateContact createContact) { // '@Valid'가 설정된 메서드가 호출될 때 유효성 검사를 진행한다.
// Do Something
}
}
Controller에는 @Validated 어노테이션 없이 @Valid 어노테이션만 추가하면 된다. Constroller에서는 Request body, Path variable, Query parameter 데이터에 @Valid 어노테이션을 추가할 수 있다.
@PostMapping("/contacts")
ublic Response createContact(@Valid CreateContact createContact) { // 메서드 호출 시 유효성 검사 진행
return Response
.builder()
.header(Header
.builder()
.isSuccessful(true)
.resultCode(0)
.resultMessage("success")
.build()).build();
}
이렇게 구성하면 해당 메서드가 호출될 때 해당 사용자 입력 데이터에 대한 검증과정을 거치게 된다.
여기서 주의할 점은 데이터 검증과정을 진행할 때 검사가 중복으로 실행되지 않도록 해야한다는 것이다. 같은 데이터 검증이 여러 번 실행될 경우 애플리케이션의 성능에 영향을 미칠 수 있기 때문이다.
2. 컨테이너(Collection, Map, ...)
Collection, Map과 같은 컨테이너의 요소에 대해서도 데이터 검증이 필요할 때 Bean Validation을 사용할 수 있다. (Bean Validation 2.0부터)
public class DeleteContacts {
@Min(1)
private Collection<@Length(max = 64) @NotBlank String> uids;
}
3. 사용자 정의 제약(Custom Constraint)
당연히 사용자 정의 제약도 지원한다. 사용자 정의 제약이란 ID에 특정 문자열을 입력하지 못하도록 하는 등의 제약을 만들어 데이터 검증을 진행할 수 있도록 하는 것을 말한다.
우선 임의의 제약(Constraint)를 만든다.
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Constraint(validatedBy = NoEmojiValidator.class)
@Documented
public @interface NoEmoji{
String message() default "Emoji is not allowed";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@interface List{
NoEmoji[] value();
}
}
그리고 검증자(Validator)를 만든다.
public class NoEmojiValidator implements ConstraintValidator<NoEmoji, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (StringUtils.isEmpty(value) == true) {
return true;
}
return EmojiParser.parseToAliases(value).equals(value);
}
}
위에서 생성한 사용자 정의 제약은 ID에 Emoji 문자열을 사용하지 못하게 하는 것이다. 위에서 사용했던 것들과 마찬가지로 검사할 속성(Property)에 대해서 생성한 어노테이션(@NoEmoji)를 추가해 사용할 수 있다.
public class CreateContact {
@NoEmoji
@Length(max = 64)
@NotBlank
private String uid;
@NotNull
private ContactType contactType;
@Length(max = 1_600)
private String contact;
}
4. 제약 그룹(Grouping)
제약 그룹은 하나의 도메인 모델(DTO, VO)에서 두 가지 상황에 다른 검증 과정을 거치고 싶을 때 사용할 수 있다. 예를 들어 도메인 모델 Message에는 일반 메시지가 있고 광고 메시지가 있다. 일반 메시지와 다르게 광고 메시지일때는 연락처, 광고 제거 가이드 속성(Property)에 대한 값을 설정해야 한다고 하면 이 두 가지 속성은 광고 메시지일 때만 검증하면 된다. 이러한 경우에 제약 그룹을 활용할 수 있다.
public class Message {
@Length(max = 128)
@NotEmpty
private String title;
@Length(max = 1024)
@NotEmpty
private String body;
@Length(max = 32, groups = Ad.class)
@NotEmpty(groups = Ad.class) // 그룹을 지정할 수 있다. (기본 값은 javax.validation.groups.Default)
private String contact;
@Length(max = 64, groups = Ad.class)
@NotEmpty(groups = Ad.class)
private String removeGuide;
}
이렇게 인터페이스를 생성해야 하는데 마커 인터페이스이기 때문에 메서드를 추가할 필요는 없다.
public interface Ad {
}
이제 특정 제약 그룹에 대한 데이터 검증을 진행하기 위해 @Validated(Ad.class) 어노테이션을 사용할 수 있다.
@Validated
@Service
public class MessageService {
@Validated(Ad.class) // 메서드 호출 시 Ad 그룹이 지정된 제약만 검사한다.
public void sendAdMessage(@Valid Message message) {
// Do Something
}
public void sendNormalMessage(@Valid Message message) {
// Do Something
}
/**
* 주의: 이렇게 호출하면 Spring AOP Proxy 구조상 @Valid를 설정한 메서드가 호출되어도 유효성 검사가 동작하지 않는다.
* Spring의 AOP Proxy 구조에 대한 설명은 다음 링크를 참고하자.
* - https://docs.spring.io/spring/docs/5.2.3.RELEASE/spring-framework-reference/core.html#aop-understanding-aop-proxies
*/
public void sendMessage(Message message, boolean isAd) {
if (isAd) {
sendAdMessage(message);
} else {
sendNormalMessage(message);
}
}
}
5. 클래스 단위 제약(Class Level Constraint)와 조건부 검사(Conditional Validation)
도메인 모델의 속성 값에 따라 데이터 검증을 다르게 해야 하는 경우가 있다. 애플리케이션 실행 중에 특정 값에 따라 데이터 검증을 다르게 한다는 말이다. 예를 들면, isAd 속성의 값이 True일 경우 연락처, 광고 제거 가이드 속성에 대한 데이터 검증을 진행하고 False일 경우 하지 않는 것이다.
다음과 같은 새로운 제약을 구현한다.
@Target({TYPE})
@Retention(RUNTIME)
@Constraint(validatedBy = AdMessageConstraintValidator.class)
@Documented
public @interface AdMessageConstraint {
String message() default "";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
추가로 새로운 검증자(Validator)를 구현한다.
public class AdMessageConstraintValidator implements ConstraintValidator<AdMessageConstraint, Message> {
private Validator validator;
public AdMessageConstraintValidator(Validator validator) {
this.validator = validator;
}
@Override
public boolean isValid(Message value, ConstraintValidatorContext context) {
if (value.isAd()) {
final Set<ConstraintViolation<Object>> constraintViolations = validator.validate(value, Ad.class);
if (CollectionUtils.isNotEmpty(constraintViolations)) {
context.disableDefaultConstraintViolation();
constraintViolations
.stream()
.forEach(constraintViolation -> {
context.buildConstraintViolationWithTemplate(constraintViolation.getMessageTemplate())
.addPropertyNode(constraintViolation.getPropertyPath().toString())
.addConstraintViolation();
});
return false;
}
}
return true;
}
}
이제 이렇게 생성한 사용자 정의 제약(Custom Constraint)를 사용해 속성의 값에 따라 다른 데이터 검증 과정을 진행할 수 있다.
@AdMessageConstraint // 이 커스텀 제약을 구현할 것이다.
public class Message {
@Length(max = 128)
@NotEmpty
private String title;
@Length(max = 1024)
@NotEmpty
private String body;
@Length(max = 32, groups = Ad.class)
@NotEmpty(groups = Ad.class)
private String contact;
@Length(max = 64, groups = Ad.class)
@NotEmpty(groups = Ad.class)
private String removeGuide;
private boolean isAd; // 광고 여부를 설정할 수 있는 속성
}
6. 오류 처리
데이터 검증이 실패할 경우 ConstraintViolationException이 발생된다. ConstraintViolationException은 실패 정보를 담고 있는 ConstraintViolation 객체들을 가지고 있는데 이를 이용해 Exception Handler를 생성할 수 있다.
AOP를 통해 Exception Handler 를 구현하는 방법은 다음 블로그를 통해 확인하자.
실제로 ConstraintViolationException에 대한 Exception Handler 를 구현한 코드 예시이다.
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(value = ConstraintViolationException.class) // 유효성 검사 실패 시 발생하는 예외를 처리
@ResponseBody
protected Response handleException(ConstraintViolationException exception) {
return Response
.builder()
.header(Header
.builder()
.isSuccessful(false)
.resultCode(-400)
.resultMessage(getResultMessage(exception.getConstraintViolations().iterator())) // 오류 응답을 생성
.build())
.build();
}
protected String getResultMessage(final Iterator<ConstraintViolation<?>> violationIterator) {
final StringBuilder resultMessageBuilder = new StringBuilder();
while (violationIterator.hasNext() == true) {
final ConstraintViolation<?> constraintViolation = violationIterator.next();
resultMessageBuilder
.append("['")
.append(getPopertyName(constraintViolation.getPropertyPath().toString())) // 유효성 검사가 실패한 속성
.append("' is '")
.append(constraintViolation.getInvalidValue()) // 유효하지 않은 값
.append("'. ")
.append(constraintViolation.getMessage()) // 유효성 검사 실패 시 메시지
.append("]");
if (violationIterator.hasNext() == true) {
resultMessageBuilder.append(", ");
}
}
return resultMessageBuilder.toString();
}
protected String getPopertyName(String propertyPath) {
return propertyPath.substring(propertyPath.lastIndexOf('.') + 1); // 전체 속성 경로에서 속성 이름만 가져온다.
}
}
이렇게 Exception Handler를 생성할 경우 데이터 검증이 실패할 경우 다음 응답값을 반환한다.
{
"header" : {
"isSuccessful" : false,
"resultCode" : -400,
"resultMessage" : "['title' is 'null'. must not be blank], ['body' is 'null'. must not be null]"
}
}
제일 처음에 설명했듯이 데이터 검증은 검증에 그치는 것이 아니라 사용자가 원인을 쉽게 파악하고 이해할 수 있도록 API 응답을 제공해야 하기 때문에 이와 같은 Exception Handler를 생성하는 것이 좋다.
7. 동적 메시지 생성(Message Interpolation)
Exception Handler 예외 코드에서도 잠깐 나왔듯이 getMessage() 메서드를 사용해 데이터 검증에 대한 메시지를 가져올 수 있다. 이러한 메시지를 동적으로 생성할 수 있다. 예를 들면 "Value must be between 0 and 64" 로 고정하는 것이 아니라 "Value must be between {min} and {max}"로 설정해 어노테이션을 통해 min, max 값이 동적으로 설정될 수 있도록 구성하는 것이다.
이러한 동적 메시지를 구성하기 위한 규칙은 아래와 같다.
- '{}' 혹은 '${}' 로 둘러싼다.
- {, }, \, \$ 는 문자로 취급한다.
- '{' 는 매개변수의 시작, '}' 는 매개변수의 끝, \는 확장문자(Escape), '$'는 표현식 시작으로 취급한다.
여기서 표현식이란 다음과 같은 것을 말한다.
Must be greater than ${inclusive == true ? 'or equal to ' : ''}{value}
기본 제약조건
위에서 살펴봤듯이 사용자 정의 제약을 만들 수 있다. 하지만 기본으로 제공되는 제약조건을 활용할 수 있다면 추가적인 코드 작성 없이 편리하게 사용할 수 있을 것이다. 이번에는 기본으로 제공되는 제약(Constraint)에 대해서 살펴보자.
이러한 제약조건들은 javax.validation.constraints 에 포함되어 있으며, 추가적으로 org.hibernate.validator.constrints 에도 유용한 제약조건들이 다수 존재한다. org.hibernate.validator.constrints 는 Bean Validation 에 대해서 유용한 제약조건들을 구현해놓은 패키지라고 생각하면 된다.
@Min(1)
@Max(10)
@NotBlank
@Pattern(regexp = "^[0-9]{6}$")
@AssertFalse
@AssertTrue
@DecimalMax(value=10)
@DecimalMin(value=10)
@Future
@Past
@NotNull
@Null
@Size(min=10, max=20)
@NotEmpty
@URL
@Length
@CreditCardNumber
@ScriptAssert(lang = "javascript", script = "(_.email != null && _.email.length() > 0) || (_.phoneNumber != null && _.phoneNumber.length() > 0)", alias = "_", message = "이메일 혹은 전화 번호 둘 중 하나는 필수 입니다")
이외에도 다양한 제약 조건들이 존재하니 필요할 때 찾고 그때마다 추가하도록 하자.
출처 :
docs.jboss.org/hibernate/validator/5.1/api/org/hibernate/validator/constraints/package-summary.html