스프링 프레임워크에서 요청을 DTO로 변환할 때 Enum으로 받는 경우가 왕왕 있습니다.
application/x-www-form-urlencoded
application/json
두 경우가 가장 빈번합니다.
DTO에서 특정 값을 Enum으로 받는 경우 스프링에서 기본적으로 제공하는 HandlerMethodArgumentResolver
구현에 의해 문자열은 각 Enum 타입으로 변경해 줍니다.
하지만 문자열이 Enum 클래스의 멤버명과 정확히 일치해야만 변환을 해 주고 그렇지 않은 경우에는 오류가 발생하거나(이름이 멤버에 없음), 무시하게 됩니다.
무시하는 경우는 비교적 괜찮지만, application/x-www-form-urlencoded
로 받는 경우 멤버에 없는 이름을 받는 경우 스프링이 만들어낸 예외가 발생하기 때문에 사용자에게 친절하지 않은 메시지가 노출될 가능성이 있습니다.
enum class TestType {
ONE,
TWO,
}
class TestForm {
var type: TestType? = null
}
@RestController
class TestController {
@PostMapping(
path = ["/test"],
consumes = [MediaType.APPLICATION_FORM_URL_ENCODED_VALUE],
)
fun test(
@RequestBody
form: TestForm,
) {
println(form.type)
}
}
application/x-www-form-urlencoded
형식인 경우,
curl -X POST http://localhost:8080/test -d 'type=ONE' -i (O)
curl -X POST http://localhost:8080/test -d 'type=one' -i (X, HTTP/400 반환)
application/json
형식인 경우,
curl -X POST http://localhost:8080/test -d '{"type":"ONE"}' -i (O)
curl -X POST http://localhost:8080/test -d '{"type":"one"}' -i (X, null이 됨)
따라서 Enum 을 직접 변환할 필요가 있습니다.
요구사항으로는
appliation/json
형식의 경우 Enum 클래스 내부에 @JsonCreator
애노테이션을 가진 정적 메서드를 가지는 경우에 해당 메서드를 사용하여 파싱을 진행합니다.
이를 사용하여 case가 다르더라도 파싱할 수 있도록 합니다.
enum class TestType {
ONE,
TWO,
;
@JvmStatic // TestType.Companion.parse 가 아닌 TestType.parse 로 접근할 수 있게 합니다.
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
fun parse(name: String?): TestType? =
// case가 다르더라도 변환할 수 있도록 합니다.
name?.let { EnumUtils.getEnumIgnoreCase(TestType::class.java, it.trim()) }
}
변환 메서드를 만들어야 하는 귀찮음이 있으나, 해당 정적 메서드는 코드 작성시 의외로 유용하게 사용되므로 acceptable 하다고 생각합니다.
application/x-www-form-urlencoded
형식의 경우, 커스텀 컨버터를 사용하게 하려면 WebMvcConfigurer 클래스에 커스텀 컨버터를 등록해 주어야 합니다.
@Configuration(proxyBeanMethods = false)
class WebMvcConfig : WebMvcConfigurer {
override fun addFormatter(registry: FormatterRegistry) {
// 컨버터 등록
}
}
컨버터의 경우
org.springframework.core.convert.converter.Converter<S, T>
org.springframework.core.convert.converter.GenericConverter
org.springframework.core.convert.converter.ConverterFactory<S, T>
중 하나를 구현하여 등록할 수 있습니다.
Converter<S, T>
의 경우 Enum을 만들 때 마다 컨버터를 등록해야 하는 불편함이 있으나, GenericConverter
의 경우 하나만 등록하여 모든 Enum 변환을 담당하도록 할 수 있습니다.
// Enum 파싱 시, case 관계없이 변환할 수 있도록 하는 제네릭 컨버터
object GenericEnumConverter : GenericConverter {
private val convertibleTypes: MutableSet<GenericConverter.ConvertiblePair> =
mutableSetOf(GenericConverter.ConvertiblePair(String::class.java, Enum::class.java))
private val valueOf: Method =
Enum::class.java.getDeclaredMethod("valueOf", Class::class.java, String::class.java)
override fun getConvertibleTypes(): MutableSet<GenericConverter.ConvertiblePair> =
this.convertibleTypes
override fun convert(
source: Any?,
sourceType: TypeDescriptor,
targetType: TypeDescriptor,
): Any? =
source?.let { it as? String }
?.let { convert(it, targetType.type) }
fun <T> convert(
name: String,
type: Class<T>,
): T? =
try {
// Reflection을 사용하여 Enum 으로 변환합니다.
@Suppress("UNCHECKED_CAST")
valueOf.invoke(null, type, name.trim().uppercase()) as T
} catch (e: Throwable) {
null
}
}
@Configuration(proxyBeanMethods = false)
class WebMvcConfig : WebMvcConfigurer {
override fun addFormatter(registry: FormatterRegistry) {
// 커스텀 컨버터를 등록합니다.
registry.addConverter(GenericEnumConverter)
}
}
이제 Enum 의 case가 다른 경우나, 멤버에 없는 값을 받는 경우에는 null이 되게끔 처리하여 Spring 오류가 아닌 프로그래머가 직접 오류를 처리할 수 있습니다.
+
추가
@PathVariable
로 받는 경우에도 유효합니다.
// '/test/one' 또는 '/test/ONE' 모두 매칭
@GetMapping("/test/{type}")
@ResponseBody
fun test(
@PathVariable
type: TestType?,
) {
println(type)
}