프로젝트/우테코 6기

[Spring] DTO 검증 하기

zangsu_ 2024. 10. 2. 14:57
 

스프링에서는 요청에 포함된 데이터를 DTO 객체로 자동으로 바인딩 해 주는데요. 이 과정에서 제약 조건을 추가해 두면 Spring 이 알아서 검증을 진행해 줍니다.

이번 글에서는 스프링에서 DTO를 검증하는 방법을 설명합니다.

1. 어노테이션을 활용해 제약조건을 추가하기

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 어노테이션을 붙여주어야 해당 파라미터의 유효성 검증이 진행됩니다.

2. getter 를 사용해 유효성 검증하기

때로는 주어진 특정 어노테이션을 필드에 붙이는 방법으로 유효성 검증이 어려운 경우가 존재합니다. 이럴때는 @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;  
	}  
}

3. custom annotation 을 사용해 검증하기

위의 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

다음으로는 검증 로직을 포함하는 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 검증

마지막으로 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  
) {  
}
 

 


참고 자료