요청에 대한 파라미터 혹은 데이터로 Enum 타입을 직접 사용하는 경우가 있습니다.
public record SearchLibraryBookCond(
int page,
int size,
BookCategory bookCategory
) {
}
//---
@GetMapping("/books")
public ApiResponse<SearchLibraryBookResult> searchLibraryBooks(
final SearchLibraryBookCond searchCond
) {
final SearchLibraryBookResult result = libraryService.getLibraryBook(searchCond);
return ApiResponse.from(result);
}
위 핸들러는 아래와 같은 http 요청을 처리할 수 있는데요.
GET "/library/books?page=1&size=30&bookCategory=FICTION"
queryParameter로 전달되는 Enum타입의 값은 일반적으로 대문자로 전달되어야 정상 처리 됩니다.
Jackson라이브러리를 기본으로 사용중이기 때문에, 해당 직렬화 정책을 따라가게 됨
하지만, 진행중인 프로젝트에서는 url로 오는 value에 대해선 대소문자 구분을 하지 않기로 정의하였는데요.
GET "/library/books?page=1&size=30&bookCategory=FICTION"
GET "/library/books?page=1&size=30&bookCategory=fiction"
모두 허용하기로 했습니다.
String으로 받아 Enum 요청 처리
가장 간단하게 생각한 방식은, String 값을 직접 받아 Enum 타입을 생성하는 것이었습니다.
Sample
public record SearchLibraryBookCond(
int page,
int size,
String bookCategory
) {
public SearchLibraryBookQuery() {
return new SearchLibraryBookQuery(
page,
size,
BookCategory.from(bookCategory)
);
}
}
@GetMapping("/books")
public String searchLibraryBooks(
final SearchLibraryBookCond searchCond
) {
final SearchLibraryBookQuery searchQuery = searchCond.toSearchQuery();
libraryService.getLibraryBook(searchQuery);
return ApiResponse.from(result);
}
String 타입으로 요청을 받고, 지정된 Enum 타입(BookCategory)로 변환하는 로직이 추가됬습니다.
단점
위 방식은 구현이 간단하지만 여러 단점이 쉽게 발견됩니다.
우선, 위 코드처럼 변환 로직이 모든 요청에 대해 필요하게 됩니다.
String -> Enum 으로 타입만 다른 또 하나의 데이터 객체가 생성되기도 하죠.
또, 웹 요청이 반영된 코드가 침투 할 수도 있습니다.
String -> Enum을 변환하는 과정에서 아래 코드를 사용하였는데요.
BookCategory.from(bookCategory)
public enum BookCategory {
FICTION,
NON_FICTION,
// ...
TECHNOLOGY,
PROGRAMMING,
SOFTWARE,
;
public static BookCategory from(final String source) {
return BookCategory.valueOf(source.toUpperCase());
}
}
해당 Enum에 외부 요청에 대한 로직(대소문자 구분 X)이 침투하게 됩니다.
Conversion 사용 하기
요청 타입을 Enum으로 정의해도 성공적으로 바인딩 된 이유는, Spring core에서 String->Enum Converter 가 등록되어 있기 때문입니다.
웹 요청이 들어오면, handler가 지정되고, handler 요청에 필요한 타입으로 변환해 주는 작업이 이루어집니다.
package org.springframework.core.convert.support;
final class StringToEnumConverterFactory implements ConverterFactory<String, Enum> {
@Override
public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) {
return new StringToEnum(ConversionUtils.getEnumType(targetType));
}
private static class StringToEnum<T extends Enum> implements Converter<String, T> {
private final Class<T> enumType;
StringToEnum(Class<T> enumType) {
this.enumType = enumType;
}
@Override
@Nullable
public T convert(String source) {
if (source.isEmpty()) {
// It's an empty enum identifier: reset the enum value to null.
return null;
}
return (T) Enum.valueOf(this.enumType, source.trim());
}
}
}
Override한 메서드, T convert()에서 타입 전환이 이루어지기 때문에 따로 정의하지 않아도 알아서 Enum타입으로 변환되고 있었습니다.
Spring에서는 Converter와 관련한 SPI(Service Provider Interface)를 제공하여 확장성 있게 저만의 정책을 반영하여 구현할 수 있습니다.
Spring conversion doc - https://github.com/spring-projects/spring-framework/blob/v6.1.6/framework-docs/modules/ROOT/pages/core/validation/convert.adoc
Converter로 구현 하기
공식문서에 제공 되는 것처럼, Converter를 직접 구현해서 타입 변환 로직을 담아낼 수 있습니다.
Converter<S, T>
package org.springframework.core.convert.converter;
@FunctionalInterface
public interface Converter<S, T> {
@Nullable
T convert(S source);
}
BookCategoryConveter
public class BookCategoryConverter implements Converter<String, BookCategory> {
@Override
public BookCategory convert(final String source) {
return Arrays.stream(BookCategory.values())
.filter(bookCategory -> bookCategory.name().equalsIgnoreCase(source))
.findAny()
.orElseThrow(() -> new IllegalArgumentException("Invalid book category"));
}
}
대소문자 관계없이 타입 변환 외에도 원하는 예외를 사용할 수 있습니다.
이후, WebMvcConfiguter를 통해 구현한 Converter를 등록합니다.
WebConverterConfig
@Configuration
public class WebConverterConfig implements WebMvcConfigurer {
@Override
public void addFormatters(final FormatterRegistry registry) {
registry.addConverter(new BookCategoryConverter());
}
}
해당 로직을 검증하는 테스트도 잘 통과하는 것을 확인할 수 있습니다.
ConversionService에도 구현한 Converter가 등록되었고, 해당 converter의 구현 메서드가 실행됨
지정한 타입에 대해서 원하는 로직을 담아 타입 변환을 구현할 수 있었는데요.
만약, 동일한 로직의 변환이 필요한 타입이 10개라면 모두 구현해주어야 합니다.
@Configuration
public class WebConverterConfig implements WebMvcConfigurer {
@Override
public void addFormatters(final FormatterRegistry registry) {
registry.addConverter(new BookCategoryConverter());
registry.addConverter(new AuthorCategoryConverter());
registry.addConverter(new PublisherCategoryConverter());
registry.addConverter(new InteresetCategoryConverter());
// ...
}
}
공통적인 변환 로직이 있다면, ConverterFactory를 활용할 수 있습니다.
ConverterFactory로 구현하기
Using ConverterFactory
When you need to centralize the conversion logic for an entire class hierarchy (for example, when converting from String to Enum objects), you can implement ConverterFactory, as the following example shows:
- https://docs.spring.io/spring-framework/reference/core/validation/convert.html
공식 문서에 따르면, 전역적(중앙 집중적)으로 변환 로직을 사용해야 한다면 ConverterFactory를 사용할 수 있다고 하는데요.
(주어진 예시도 String -> Enum 입니다)
이를 활용해서 StringToEnumCustomConverterFactory를 구현해보았습니다.
public class StringToEnumCustomConverterFactory implements ConverterFactory<String, Enum<?>> {
public <T extends Enum<?>> Converter<String, T> getConverter(final Class<T> targetType) {
return new StringToEnumCustomConverter<>(targetType);
}
private record StringToEnumCustomConverter<T extends Enum<?>>(
Class<T> targetType
) implements Converter<String, T> {
@Override
public T convert(String source) {
return Arrays.stream(targetType.getEnumConstants())
.filter(enumConstant -> enumConstant.name().equalsIgnoreCase(source)).findAny()
.orElseThrow(() -> new IllegalArgumentException("Invalid value"));
}
}
}
ConvertFactory는 각각에 맞는 Converter를 반환하여, 변환하려는 타입에 맞는 convert를 사용할 수 있는데요.
위 코드에 대해 간단히 설명하자면,
- 먼저 ConverterFactory<S, T> 를 구현합니다. Enum 전역을 대상으로 할 예정이기에 <String, Enum<?>>를 사용했습니다.
- 변환하려는 타입, 제네릭 타입인 T(targetType)에 동적으로 결정됩니다.
- T 타입에 해당하는 converter를 구현합니다. (record StringToEnumCustomConverter<T extends Enum<?>>)
- getConverter가 호출되면 변환하려는 타입에 맞게 동적으로 정의되는 converter가 반환 됩니다.
위 코드는 모든 Enum에 대해 적용되고 있는데요.
Interface를 통해 로직이 반영될 Enum에 구현하고, wildcard의 타입을 제한하여 로직을 집중시킬 수도 있습니다.
Sample
public interface InsensitiveEnum {}
public enum BookCategory implements InsensitiveEnum {}
public class StringToEnumCustomConverterFactory implements ConverterFactory<String, Enum<? extends InsensitiveEnum>> {
// ...
}
등록은 아래와 같이 할 수 있습니다.
@Configuration
public class WebConverterConfig implements WebMvcConfigurer {
@Override
public void addFormatters(final FormatterRegistry registry) {
final StringToEnumCustomConverterFactory converterFactory = new StringToEnumCustomConverterFactory();
registry.addConverterFactory(converterFactory);
}
}
테스트를 위해 Enum을 하나 추가하고 진행해보았습니다.
public enum TestEnum {
PRIVATE,
PUBLIC,
;
}
public record SearchLibraryBookCond(
int page,
int size,
BookCategory bookCategory,
TestEnum testEnum
) {
}
추가적으로 타겟 타입에 대한 Converter를 등록하지 않아도, 정의한 Enum에 대해 대소문자 구분 없이 변환하는 로직을 적용 할 수 있었습니다.
마무리
ConverterFactory를 통해 공통적으로 변환 로직을 적용할 수 있었습니다.
중복 작업을 줄였다고 할 순 있지만, 정말 중복되는 로직인지에 대해 깊게 고민할 필요가 있습니다.
만약 특정 Enum타입은 대소문자를 구분한다는 정책이 추가되면 공통 로직은 더이상 사용할 수 없는 로직이 됩니다.
이를 고려해서 InsensitiveEnum과 같은 선택적으로 적용하는 방법도 고안했지만, 웹 요청과 관련한 로직이 도메인으로 정의한 Enum에 침범하는 경우도 있을 수 있다는 점을 고려해야 합니다.