[Spring] DTO 검증 하기
스프링에서는 요청에 포함된 데이터를 DTO 객체로 자동으로 바인딩 해 주는데요. 이 과정에서 제약 조건을 추가해 두면 Spring 이 알아서 검증을 진행해 줍니다.
이번 글에서는 스프링에서 DTO를 검증하는 방법을 설명합니다.
DTO 검증을 하기 위해선 아래 의존성을 추가해 주어야 합니다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
위 의존성을 추가해 주지 않아도 @NotNull 과 같은 어노테이션을 사용할 수는 있습니다. 해당 어노테이션은 jakarta.validation.constraints 의 어노테이션, 즉 어느 프레임워크에 의존하지 않는 자바 자체의 스펙이기 때문이죠.
다만, 어노테이션들이 붙어 있는 필드를 검증하기 위해선 Validator 의 도움을 받아야 하는데요. 위 의존성을 추가해 주지 않으면 아무런 Validator 도 빈으로 등록되지 않기에 필드 검증이 진행이 되지 않습니다.
위 의존성을 추가해 주고 나면 org.springframework.validation.beanvalidation 패키지 아래의 LocalValidatorFactoryBean 이 빈으로 등록되기 때문에 정상적으로 검증을 진행할 수 있습니다.
이제 어노테이션을 사용해 제약 조건을 추가해 보겠습니다.
public record CreateSnippetRequest(
@NotNull(message = "파일 이름이 null 입니다.")
@Size(max = 255, message = "파일 이름은 최대 255자까지 입력 가능합니다.")
String filename,
@NotNull(message = "파일 내용이 null 입니다.")
String content,
@NotNull(message = "스니펫 순서가 null 입니다.")
int ordinal
) {
}
모든 필드에는 null 이 들어가지 못하도록 @NotNull 어노테이션을 붙여 주었습니다. 추가로, filename 필드는 255자를 넘어가지 못하도록 @Size 어노테이션을 사용해 주었습니다.
public record CreateTemplateRequest(
@NotNull(message = "템플릿 이름이 null 입니다.")
@Size(max = 255, message = "템플릿 이름은 최대 255자까지 입력 가능합니다.")
String title,
@NotNull(message = "스니펫 리스트가 null 입니다.")
@Valid
List<CreateSnippetRequest> snippets
) implements ValidatedSnippetsOrdinalRequest {
@Override
public List<Integer> extractSnippetsOrdinal() {
return snippets.stream().map(CreateSnippetRequest::ordinal).toList();
}
}
마찬가지로 CreateTemplateRequest 의 각 필드에도 @NotNull 로 null 검증을 해 주었습니다. 또한, CreateSnippetRequest 의 각 필드 검증이 진행될 수 있도록 snippets 필드에 @Valid 어노테이션을 사용해 주었습니다.
@Valid 어노테이션은 필드로 가지고 있는 객체의 제약조건을 검사하기 위한 목적으로 필드에 사용할 수 있습니다.
각 어노테이션의 message 필드에는 해당 유효성 검증이 실패하였을 시 사용될 에러 메시지를 작성해 줄 수 있습니다.
마지막으로, 해당 DTO 가 binding 되어 파라미터로 들어오는 곳에 @Valid 어노테이션을 사용해 검증을 수행해 줍니다.
@PostMapping
public ResponseEntity<Void> create(@Valid @RequestBody CreateTemplateRequest createTemplateRequest) {
return ResponseEntity.created(
URI.create("/templates/" + templateService.create(createTemplateRequest)))
.build();
}
@Controller 의 메서드 파라미터에 @Valid 어노테이션을 붙여주어야 해당 파라미터의 유효성 검증이 진행됩니다.
때로는 주어진 특정 어노테이션을 필드에 붙이는 방법으로 유효성 검증이 어려운 경우가 존재합니다. 이럴때는 @AssertTrue / @AssertFalse 어노테이션과 getter 를 사용해 유효성 검증을 진행할 수 있습니다.
방법은 간단합니다. @AssertTrue / @AssertFalse 이 붙은 getter 에서 우리가 검증해 줄 로직을 작성해 주면 됩니다.
아래는 content 필드의 값이 65,535 Byte 이하인 것을 검증하는 getter 가 추가된 CreateSnippetRequest 입니다.
public record CreateSnippetRequest(
@NotNull(message = "파일 이름이 null 입니다.")
@Size(max = 255, message = "파일 이름은 최대 255자까지 입력 가능합니다.")
String filename,
@NotNull(message = "파일 내용이 null 입니다.")
String content,
@NotNull(message = "스니펫 순서가 null 입니다.")
int ordinal
) {
@AssertTrue(message = "파일 내용은 최대 65,535 Byte까지 입력 가능합니다.")
public boolean isAcceptableByteLength() {
return this.content
.getBytes(StandardCharsets.UTF_8).length < 65_535;
}
}
위의 CreateSnippetRequest 코드가 조금 마음에 들지 않는데요. 우리에게 필요한 메서드가 아니지만, 검증을 위해 getter 가 추가되어 있습니다. 해당 검증도 어노테이션을 추가하는 방법으로 수행해 줄 수 없을까요?
이번엔 우리가 직접 제약 조건 어노테이션을 만들어 보겠습니다. 마침 제약 조건에 Byte 길이를 검증해 주는 어노테이션이 존재하지 않네요.
먼저, 제약 조건 어노테이션입니다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ByteLengthValidator.class)
public @interface ByteLength {
String message();
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
int max();
int min() default 0;
}
필드에서 사용할 것이므로 @Target(ElementType.FIELD)를, 어플리케이션 구동 중에 해당 어노테이션 정보를 활용할 것이므로 @Retention(RetentionPolicy.RUNTIME) 을 사용해 주었습니다. @Constraint() 어노테이션에선 검증 로직이 작성될 Validator 클래스를 넣어주게 됩니다.
필드 중 message, groups, payload 필드는 제약 조건을 위한 어노테이션이 꼭 가지고 있어야 하는 필드입니다.
message 필드는 에러 메시지에 사용될 메시지를 저장하기 위해 사용됩니다.
groups 필드는 제약조건을 그룹으로 구분하여 특정 상황에서만 해당 제약조건을 확인하도록 구분하는 목적으로 사용됩니다.
즉, 제약조건을 확인할 때 제약조건에서 설정한 groups 로 명시를 해 주어야 해당 제약 조건을 확인합니다.
groups, payload 에 대한 설명은 아래 아티클을 함께 참고해 주세요!
max, min 필드는 각 필드마다 허용할 byte 제한을 다르게 설정하기 위해 추가해 주었습니다.
다음으로는 검증 로직을 포함하는 Validator 를 작성합니다.
public class ByteLengthValidator implements ConstraintValidator<ByteLength, String> {
private int max;
private int min;
@Override
public void initialize(ByteLength constraintAnnotation) {
max = constraintAnnotation.max();
min = constraintAnnotation.min();
}
@Override
public boolean isValid(String target, ConstraintValidatorContext constraintValidatorContext) {
int byteLength = target.getBytes(StandardCharsets.UTF_8).length;
return min <= byteLength && byteLength <= max;
}
}
validator 는 ConstraintValidator<A, T> 를 구현해야 합니다. A 는 검증할 필드, 클래스 등에 사용할 어노테이션을, T 는 검증할 필드, 클래스의 타입을 입력해 줍니다. 우리는 아까 만들었던 ByteLength 어노테이션을 사용할 것이고, String 값을 검증해 줄 것이므로 ConstraintValidator<ByteLength, String> 을 구현해 주겠습니다.
public void initialize(A a) 메서드는 Validator 를 초기화 하는 메서드, public boolean isValid(T t, ConstraintValidatorContext constraintValidatorContext) 메서드는 실제 검증을 진행하는 메서드입니다.
isValid() 메서드에서 ByteLength 의 정보를 가져올 수 없기 때문에 initialize()에서 ByteLength 의 min, max 값을 ByteLengthValidator의 필드에 저장해 둡니다. 이후 isValid(String target) 의 target 을 검증합니다.
마지막으로 DTO의 필드에 어노테이션을 붙여 검증을 합니다.
public record CreateSnippetRequest(
@NotNull(message = "파일 이름이 null 입니다.", groups = NotNullGroup.class)
@Size(max = 255, message = "파일 이름은 최대 255자까지 입력 가능합니다.", groups = {ByteGroups.class})
String filename,
@NotNull(message = "파일 내용이 null 입니다.")
@ByteLength(max = 65_535, message = "파일 내용은 최대 65,535 Byte까지 입력 가능합니다.")
String content,
@NotNull(message = "스니펫 순서가 null 입니다.")
int ordinal
) {
}
참고 자료
- https://medium.com/@saiteja-erwa/spring-boot-dto-validation-using-groups-and-payload-attributes-e2c139f5b1ef
- https://stackoverflow.com/questions/64493818/what-is-the-use-of-groups-and-payload-in-custom-annotation-in-java
- https://kapentaz.github.io/spring/Spring-Boo-Bean-Validation-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%95%8C%EA%B3%A0-%EC%93%B0%EC%9E%90/#
- https://www.baeldung.com/spring-mvc-custom-validator