Published on

Spring Webflux Functional 기반일 때 Validation 체크

Spring Webflux Functional 기반일 때 Validation 체크

Webflux에서 Functional기반으로 작성을 하면 Spring MVC에서 흔히 사용하던 @Valid를 이용한 유효성 검사를 할 수 없습니다.

물론 어노테이션 기반으로 작성하면 할 수 있지만, 이전에 알아봤듯 Netty 서버를 이용할려면 Functional기반으로 작성해야 하기에 비슷하게나마 할 수 있는 방법에 대해 찾아보았습니다.

공식문서를 보면 차례대로 Validation하는 방법에 대해 알려주고 있지만, 결론적으로 Validation 체크를 할 클래스마다 그에 해당하는 Validation Class를 생성해야 하는 방식이기에 마음에 들지 않았습니다.

Spring MVC에서 처럼 간단하게 @Valid정도만 사용해서 할 수 없을까? 하여 찾아본 결과

커스텀 어노테이션을 만들어 ArgumentResolverConfigurer에 주입하는 방법이 있어 해보았지만 어노테이션 방식에서만 가능한 것이였습니다. (사실 당연하게도 functional 방식에선 handler직접 호출하기에 중간에 끼어들 틈이 없습니다.)

여러가지를 시도해보던 중 @Valid만큼 간단하진 않지만 그래도 하나로 통일하여 만들수 있는 방법을 찾았습니다.

지금부터 그 방법을 적용하며 개선했던 과정에 대해 작성해보도록 하겠습니다.

여기서 Validationspring-boot-starter-validation를 사용하고 있기 때문에 gradle를 통해 다운받아 주셔야 합니다.

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

Validation 처리할 공통 메소드 만들기

@Component
@RequiredArgsConstructor
public class RequestHandler {

    private final Validator validator;

    public <BODY> Mono<ServerResponse> requireValidBody(
            Function<Mono<BODY>, Mono<ServerResponse>> block,
            ServerRequest request, Class<BODY> bodyClass) {

        return request
                .bodyToMono(bodyClass)
                .flatMap(
                        body -> validator.validate(body).isEmpty()
                                ? block.apply(Mono.just(body))
                                : ServerResponse.unprocessableEntity().build()
                );
    }
}
  • block: 유효성 검사가 성공하면 실행될 블럭
  • request: 요청 정보
  • bodyClass: request.bodyToMono를 이용해 객체화 할 클래스

로직을 보면 validator.validate를 통해 유효성을 체크하여 값이 존재하지 않으면(유효성 체크 통과) 넘어온 로직을 실행하고 그렇지 않으면 예외를 반환합니다.

사용방법

@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class PostCreate {

    @NotBlank
    private String markdown;
}
@Component
@RequiredArgsConstructor
public class PostHandler {

    private final ValidationHandler validationHandler;

    public Mono<ServerResponse> createPost(final ServerRequest request) {
        return validationHandler.requireValidBody(postCreateMono -> {
            Mono<Map<String, String>> markdown = postCreateMono.map(postCreate -> Map.of("markdown", postCreate.getMarkdown()));
            return ServerResponse.ok().body(markdown, Map.class);
        }, request, PostCreate.class);
    }
}

라우터를 만들어 사용해보면 유효성 체크 및 로직 실행이 모두 정상적으로 되는것을 확인할 수 있습니다.

조금 더 개선

위의 로직을 예시로 생각해보면 Mono<PostCreate>만 있으면 되는데, ServerRequestPostCreate 모두 파라미터로 넘겨주는 것이 좋지 않다고 생각했습니다. (인수는 적을수록 좋으니까요.)

그래서 Mono<PostCreate>만 넘겨주도록 조금 수정해보았습니다.

public <BODY> Mono<ServerResponse> requireValidBody(
        Function<Mono<BODY>, Mono<ServerResponse>> block,
        Mono<BODY> bodyMono) {

    return bodyMono
            .flatMap(
                    body -> validator.validate(body).isEmpty()
                            ? block.apply(Mono.just(body))
                            : ServerResponse.unprocessableEntity().build()
            );
}
public Mono<ServerResponse> createPost(final ServerRequest request) {
    return validationHandler.requireValidBody(postCreateMono -> {
        Mono<Map<String, String>> markdown = postCreateMono.map(postCreate -> Map.of("markdown", postCreate.getMarkdown()));
        return ServerResponse.ok().body(markdown, Map.class);
    }, request.bodyToMono(PostCreate.class));
}

잘 작동하는 것을 확인할 수 있었습니다.

기본적으로 한 뎁스를 들어간채로 사용하는 방법이 옳을까?

위의 로직을 살펴보면 validationHandler.requireValidBody를 기본적으로 깔고 사용하기 때문에 한 뎁스를 들어가게 됩니다.

모든 유효성 검사가 필요한 곳에 이렇게 작성을 해야한다는 것은 너무 비효율적이라고 생각했습니다.

그렇다면 조금 더 분리할 수 없을까? 하여 조금 더 개선을 해보았습니다.

@Component
@RequiredArgsConstructor
public class ValidationHandler {

    private final Validator validator;

    public <BODY> Mono<BODY> requireValidBody(final Mono<BODY> bodyMono) {
        return bodyMono.flatMap(
                body -> {
                    if(!validator.validate(body).isEmpty()) {
                        return Mono.error(new IllegalStateException("바디 유효성 실패"));
                    }
                    return Mono.just(body);
                }
        );
    }
}

아까처럼 실행에는 관여하지 않고 전달 받은 객체의 유효성만을 체크하고 그대로 반환하도록 수정하였습니다.

error의 경우 어떤 값들이 잘못 되었는지에 대해 조금 더 손을 봐야 합니다.

@Component
@RequiredArgsConstructor
public class PostHandler {

    private final ValidationHandler validationHandler;

    public Mono<ServerResponse> createPost(final ServerRequest request) {
        Mono<PostCreate> postCreateMono = validationHandler.requireValidBody(request.bodyToMono(PostCreate.class));
        final Mono<Map<String, String>> markdown = postCreateMono.map(postCreate -> Map.of("markdown", postCreate.getMarkdown()));
        return ServerResponse.ok().body(markdown, Map.class);
    }
}

이제 한뎁스를 들이지 않고도 사용할 수 있습니다.

모든 Handler가 ServerRequest를 받아 처리하는 것이 맞을까?

어노테이션 방식으로 사용하던 때를 보면 @ModelAttribute@RequestBody등을 통해 요청 정보를 객체에 바로 주입 받아 사용합니다.

그런데 Functional 방식의 예제를 보면 전부 HandlerServerRequest를 주입하여 사용합니다.(특별한 이유가 있는지는 모르겠습니다.)

그래서 저는 Handler = Controller라고 생각(엄밀히 따지면 Router + Handler = Controller 이지만)하고 Handler는 필요한 정보만 받도록 하는 것이 좋을것 같다고 생각하였습니다.

Spring MVCController에 전달되기 전에 값을 객체에 맞게 변환하는 작업 등을 해주므로 저도 유효성 체크Router가 하도록 변경하였습니다.

이렇게 HandlerServerRequest가 아닌 여러 파라미터를 받게 된다면 처음에 나왔었던 방식으로 유효성을 체크할 수 없습니다.

AbstractRouter

모든 라우터에서 사용할 기능이므로 AbstractRouter를 만들어 상속받도록 하였습니다.

@AllArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class AbstractRouter {

    private final ValidationHandler validationHandler;

    protected <T> Mono<T> validationBody(ServerRequest request, Class<T> bodyType) {
        return validationHandler.requireValidBody(
                request.bodyToMono(bodyType)
        );
    }
}

PostRouter

@Configuration
public class PostRouter extends AbstractRouter {

    protected PostRouter(final ValidationHandler validationHandler) {
        super(validationHandler);
    }

    @Bean
    protected RouterFunction<ServerResponse> routerExample(final PostHandler postHandler) {
        return RouterFunctions.route()
                .POST("/posts", request ->
                        postHandler.createPost(validationBody(request, PostCreate.class))
                )
                .build();
    }

}

PostHandler

@Component
@RequiredArgsConstructor
public class PostHandler {
    public Mono<ServerResponse> createPost(final Mono<PostCreate> postCreateMono) {
        final Mono<Map<String, String>> markdown = postCreateMono.map(postCreate -> Map.of("markdown", postCreate.getMarkdown()));
        return ServerResponse.ok().body(markdown, Map.class);
    }
}

마치며

처음에 적용했던 로직보다는 훨씬 코드가 깔끔해졌다고 생각합니다.

여기서 Router가 유효성 체크까지 담당하는 부분이 조금 마음에 걸리기는 합니다.

@Valid처럼 사용하고 싶어 AOP도 적용해보고(제네릭타입을 핸들링 하기가 어려웠음) ArgumentResolverConfigurer에 등록도 해보고 정말 많은 시도를 하면서 공부가 많이 되었습니다. (다 실패 했지만..)

현재는 Body에 대해서만 유효성 체크가 가능한 상태입니다.

ParametersMultiValueMap형식이라 객체로 컨버젼 하는 과정 부터 생각이 많이 필요할 것 같습니다.

컨버젼만 성공한다면 유효성 체크는 간단할 것 같습니다.

다 하고 보니 ValidationHandler도 이제 Handler의 역할이 아니므로 이름을 변경해야 할 것 같습니다.

다음에는 GlobalExceptionHandler를 만드는 방법에 대해 작성해 보도록 하겠습니다.

참고 사이트