스프링 프레임워크에서의 Enum 파싱

Dierslair·2022년 5월 21일
0

스프링

목록 보기
1/5

스프링 프레임워크에서 요청을 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 을 직접 변환할 필요가 있습니다.
요구사항으로는

  • Enum 멤버에 없는 이름을 받는 경우, null 로 처리한다.
  • UPPER_CASE든, lower_case 든 변환 가능하도록 한다.

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)
}
profile
Java/Kotlin Backend Developer

0개의 댓글