Published on

Spring Webflux MultiValueMap 객체로 변환

Spring Webflux MultiValueMap 객체로 변환

Spring Webflux에서 Body의 경우 ServerRequest객체의 bodyToMono로 가져올 수 있습니다.

하지만 formDataQueryParamsMultiValueMap으로 되어 있기 때문에 직접 변환을 해주어야 합니다.

ObjectMapper를 통해 간단하게 변환 될 줄 알았지만, MultiValueMapValueList 타입이기 때문에 일반 Map으로 변환 후 ObjectMapper로 변환해 주어야 원하는 객체로 정상적으로 변환이 됩니다.

@Component
@RequiredArgsConstructor
public class MultiValueMapToObjectConverter {

    private final ObjectMapper objectMapper;

    @Override
    public <T> T convert(final MultiValueMap<String, String> queryParams, final Class<T> classType) {

        return objectMapper.convertValue(toMap(queryParams), classType);
    }

    private static Map<String, Object> toMap(final MultiValueMap<String, String> queryParams) {

        final Map<String, Object> result = new HashMap<>();
        queryParams.forEach((key, value) -> result.put(key, isList(value) ? value : value.get(0)));

        return result;
    }

    private static boolean isList(final List<String> value) {

        return value.size() > 1;
    }

}
queryParamsConverterResolver.convert(queryParams, PostCreate.class)

정상적으로 원하는 객체로 변환 되는 것을 확인할 수 있습니다.

특수한 객체도 호환 되도록 모듈화

Spring MVC에서 Controller 파라미터Pageable 객체를 넣어두면 ?page=1&size=1&sort=createdAt,desc의 페이징 정보를 Pageable 객체에 바인딩 해줍니다.

지금은 Pageable를 호환 되게, 추후에는 이와 비슷하게 특수한 형태의 객체로 변환하기 위해 MultiValueMap을 특정 객체로 변환하도록 모듈화를 해보겠습니다.

MultiValueMapToObjectConverterResolver

@Component
public class MultiValueMapToObjectConverterResolver {

    private final Map<Class<?>, AbstractMultiValueMapToObjectConverter> converterMap = new ConcurrentReferenceHashMap<>();

    public <T> T convert(final MultiValueMap<String, String> queryParams, final Class<T> classType) {

        return getObjectConverter(classType).convert(queryParams, classType);
    }

    private AbstractMultiValueMapToObjectConverter getObjectConverter(final Class<?> classType) {

        return converterMap.getOrDefault(classType, getDefaultConverterOrThrow());
    }

    private AbstractMultiValueMapToObjectConverter getDefaultConverterOrThrow() {

        final AbstractMultiValueMapToObjectConverter defaultConverter = converterMap.get(Object.class);
        if(defaultConverter == null) {
            throw new IllegalStateException("Default QueryParamsConverter가 존재하지 않습니다.");
        }

        return defaultConverter;
    }

    public void addConverter(final Class<?> classType, final AbstractMultiValueMapToObjectConverter converter) {

        this.converterMap.put(classType, converter);
    }

}

사용자는 위의 resolverconvertMultiValueMap와 변환할 객체 타입을 해당 타입을 지원하는 Converter가 선택되어 변환을 해줄 것 입니다.

AbstractMultiValueMapToObjectConverter

public abstract class AbstractMultiValueMapToObjectConverter {

    protected AbstractMultiValueMapToObjectConverter(final Class<?> classType,
                                                     final MultiValueMapToObjectConverterResolver multiValueMapToObjectConverterResolver) {

        multiValueMapToObjectConverterResolver.addConverter(classType, this);
    }

    public abstract <T> T convert(final MultiValueMap<String, String> queryParams, final Class<T> classType);

}

모든 MultiValueMapToObjectConverterAbstractMultiValueMapToObjectConverter를 상속받아 구현합니다

이때, 변환을 지원할 객체 타입을 지정하면 자동으로 resolver에 등록 되도록 하였습니다.

저는 Object 타입이면 기본 Converter로 지정하였습니다.

즉, 특별히 지원하는 Converter가 존재하지 않는다면 Object타입을 지원하는 Converter를 사용할 것 입니다.

MultiValueMapToObjectConverter

@Component
public class MultiValueMapToObjectConverter extends AbstractMultiValueMapToObjectConverter {

    private final ObjectMapper objectMapper;

    public MultiValueMapToObjectConverter(final MultiValueMapToObjectConverterResolver multiValueMapToObjectConverterResolver,
                                          final ObjectMapper objectMapper) {

        super(Object.class, multiValueMapToObjectConverterResolver);
        this.objectMapper = objectMapper;
    }

    @Override
    public <T> T convert(final MultiValueMap<String, String> queryParams, final Class<T> classType) {

        return objectMapper.convertValue(toMap(queryParams), classType);
    }

    private static Map<String, Object> toMap(final MultiValueMap<String, String> queryParams) {

        final Map<String, Object> result = new HashMap<>();
        queryParams.forEach((key, value) -> result.put(key, isList(value) ? value : value.get(0)));

        return result;
    }

    private static boolean isList(final List<String> value) {

        return value.size() > 1;
    }

}

기본적인 객체는 위의 Converter를 사용합니다.

MultiValueMapToPageableConverter

@Component
public class MultiValueMapToPageableConverter extends AbstractMultiValueMapToObjectConverter {

    private static final String SIZE = "size";
    private static final String PAGE = "page";
    private static final String SORT = "sort";

    public MultiValueMapToPageableConverter(final MultiValueMapToObjectConverterResolver multiValueMapToObjectConverterResolver) {

        super(PageRequest.class, multiValueMapToObjectConverterResolver);
    }

    @Override
    public <T> T convert(final MultiValueMap<String, String> queryParams, final Class<T> classType) {

        return (T) PageRequest.of(
                getSize(queryParams),
                getPage(queryParams),
                Sort.by(getOrders(queryParams))
        );
    }

    private static int getSize(final MultiValueMap<String, String> queryParams) {

        return parseInt(queryParams, SIZE);
    }

    private static int getPage(final MultiValueMap<String, String> queryParams) {

        return parseInt(queryParams, PAGE);
    }

    private static int parseInt(final MultiValueMap<String, String> queryParams, final String key) {

        try {
            return Integer.parseInt(queryParams.get(key).get(0));
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("%s: 숫자가 아닙니다.".formatted(key), e);
        }
    }

    private static List<Sort.Order> getOrders(final MultiValueMap<String, String> queryParams) {

        return queryParams.get(SORT)
                .stream()
                .map(MultiValueMapToPageableConverter::createOrder)
                .toList();
    }

    private static Sort.Order createOrder(final String sort) {

        return getOrder(sort).equalsIgnoreCase(Sort.Direction.DESC.name()) ?
                Sort.Order.desc(getProperty(sort)) :
                Sort.Order.asc(getProperty(sort));
    }

    private static String getOrder(final String sort) {

        try {
            return sort.split(",")[1];
        } catch (ArrayIndexOutOfBoundsException e) {
            return Sort.Direction.ASC.name();
        }
    }

    private static String getProperty(final String sort) {

        return sort.split(",")[0];
    }

}

PageRequest객체는 Pageable의 구현체 중 하나 입니다.

Spring MVC에서 바인딩 되는 객체도 PageRequest이기 때문에 MultiValueMap에서 값을 꺼내 해당 객체를 만들어 줍니다.

이 처럼 특수한 객체에 맞는 Converter를 구현하면 resolver에 등록이 되고 해당 타입Converter에 매핑되어 변환 될 것입니다.

MultiValueMapToObjectConverterResolver를 사용하여 변환하기

@Component
@RequiredArgsConstructor
public class WebfluxValidator {

    private final Validator validator;
    private final BindingResultCreator bindingResultCreator;
    private final MultiValueMapToObjectConverterResolver multiValueMapToObjectConverterResolver;

    public <T> Mono<T> valid(final Mono<T> bodyMono) {

        return bodyMono.flatMap(body -> Mono.just(valid(body)));
    }

    public <T> T valid(final MultiValueMap<String, String> queryParams, final Class<T> classType) {

        return valid(multiValueMapToObjectConverterResolver.convert(queryParams, classType));
    }

    private  <T> T valid(final T object) {

        final Set<ConstraintViolation<T>> violations = validator.validate(object);
        if (!violations.isEmpty()) {
            throw new BindingException(bindingResultCreator.create(violations));
        }

        return object;
    }
}

이제 MultiValueMapToObjectConverterResolver를 주입받아 convert메소드에 타입을 넘겨 사용하면 됩니다.

마치며

Spring MVC에서 지원하지 않는 특수한 객체 바인딩을 하고 싶으면 AbstractMethodArgumentResolver를 상속 받아 구현하면 됐던 점이 생각나 나름대로 한 번 구현해 보았습니다.

블로그를 만드는데 너무 과하다는 것은 알지만, 저는 단순히 블로그를 만드는 것보다 이런 모듈화 작업을 통해 공부되는 것이 더 많기 때문에 공부 목적으로 하는 것이라 봐주시면 좋겠습니다.