사용 가능한 값이 제한되어 있는 경우 Enum을 사용하면 가독성과 사용성을 높일 수 있다. 현재 운영중인 '개발 한 스푼' 서비스의 API 요청/응답 Body에서도 이런 Enum 스타일의 필드가 여럿 존재한다.
문제는 해당 필드값들이 소문자 문자열이라는 것이다. 현재 서버리스 API 서버에서 Spring Boot+Kotlin 서버로 마이그레이션 중인데 Kotlin에서는 Enum값을 대문자로 작성하는 것을 권장한다.
이번 글에서는 Body에 포함되어 있는 다수의 Enum 스타일의 소문자 문자열들(Legacy Enum)을 효율적으로 처리하기 위해 적용한 방식을 공유해본다.
해당 포스트는 다음의 수순으로 진행됩니다.
1. 기존 가능한 방식과 문제점
2. 방향성 및 해결방법
1. 기존 가능한 방식과 문제점
1.1 @JsonCreator와 @JsonValue 사용하기
잘 알려진(Well-Known) 방식으로 @JsonCreator와 @JsonValue 어노테이션을 사용하면 Enum 스타일의 소문자 문자열들을 Enum의 값과 매핑시킬 수 있다.
// 요청 객체
data class LoginRequest(
val token: String,
val type: LoginType,
)
// 응답 객체
data class LoginResponse(
val serviceToken: String,
val socialType: LoginType,
)
// type enum
enum class LoginType {
KAKAO,
APPLE;
@JsonValue
fun getValue(): String {
return this.name.lowerCase()
}
companion object {
@JvmStatic
@JsonCreator
fun fromValue(value: String) =
values().find { it.name.lowerCase() == value }
?: throw IllegalArgumentException("Unknown LoginType: $value")
}
}
@JsonCreator는 요청 Body(JSON)를 객체에 매핑할때 특정키의 값을 다른 값으로 커스텀할때 사용되고,
@JsonValue는 응답 Body(객체)를 JSON으로 직렬화시 특정필드의 값을 다른 값으로 커스텀할때 사용된다.
1.2 현 상황에서 해당 방식의 문제점
한 두개 정도라면 위 처럼 사용해도 큰 무리가 없다. 하지만 이러한 스타일이 한 두개가 아니라는 것이다.
모든 Enum에 매번 Body내 소문자 문자열을 Enum 값에 매핑하기 위해 @JsonCreator 메서드를 작성해야하고 응답값을 소문자로 바꾸기 위해 @JsonValue에 작성해야한다.
즉, 소문자 문자열과 Enum 값 간 변환로직을 매번 반복적으로 작성해야하는 문제가 존재한다.
2. 방향성 및 해결방법
소문자 문자열과 Enum 간 변환하는 로직을 재사용할 수 있는 방법을 고민했다.
Enum에서는 기능을 확장시키기 위해 인터페이스를 이용할 수 있다. 하지만 인터페이스에서는 이를 구현하는 Enum이 가지는 구체적인 값들을 얻을 수 때문에 메서드로 구체적인 타입을 넘겨 처리해야 한다. 때문에 각 Enum들에서 다시 한 번 메서드를 구현해야한다. 어느 정도 반복적인 코드를 통합시킬 수 있겠지만 Enum에서 변환 로직을 완전히 제거하지 못한다.
반복적인 변환 로직이 각 Enum에 담기지 않게 완전히 분리시키기 위해 고민하다 변환 로직을 따로 작성하고 이를 인터페이스를 통해 서빙해보기로 했다.
2.1 Legacy Enum 확장용 인터페이스 선언
우선 소문자 문자열과 Enum간 변환이 필요한 것들(이하 Legacy Enum)을 위한 인터페이스를 선언한다.
interface LegacyDtoEnum
// Request, Resposne Body에 사용되는 Legacy Enum
enum class LoginType: LegacyDtoEnum {
KAKAO,
APPLE;
}
필자는 인터페이스 설정만으로 Legacy Enum을 처리할 것이다. 이제 변환 로직을 담당할 커스텀 역/직렬화 클래스를 구현한다.
2.2 커스텀 역/직렬화 클래스 구현하기
Jackson에서는 역/직렬화 과정을 직접 커스텀할 수 있도록 각각 StdDeserializer와 StdSerializer 클래스를 지원한다. 이후 타입이나 필드에 @JsonDeserialize(using = 커스텀클래스) 를 적용하면 해당 부분을 역/직렬화 시 해당 커스텀 클래스를 사용하게 된다.
각 클래스는 deserialize 와 serialize 메서드에서 변환로직을 작성하면된다. 역직렬화 시에는 소문자를 Enum에 매핑하고 직렬화시에는 Enum값을 소문자 문자열로 교체하는 로직이다.
class LegacyDtoEnumCombinedSerializer {
// 역직렬화 커스텀 로직
class LegacyDtoEnumDeserializer<T>(vc: Class<*>?): StdDeserializer<T>(vc), ContextualDeserializer where T : LegacyDtoEnum, T: Enum<*> {
constructor() : this(null)
@Suppress("UNCHECKED_CAST")
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): T {
val jsonNode: JsonNode = p.codec.readTree(p)
// 소문자 -> 대문자
val value: String = jsonNode.asText().uppercase()
// Enum 순회하며 name과 맞는 것 선택하기
val enumValue = (_valueClass as Class<out Enum<*>>)
.enumConstants
.firstOrNull { it.name == value }
?: throw ApiInvalidEnumException(p.currentName)
return enumValue as T
}
override fun createContextual(ctxt: DeserializationContext, property: BeanProperty): JsonDeserializer<*> {
return LegacyDtoEnumDeserializer(property.type.rawClass)
}
}
// 직렬화 커스텀
class LegacyDtoEnumSerializer<T>(vc: Class<*>?): StdSerializer<T>(vc, true) where T : LegacyDtoEnum, T: Enum<*> {
constructor() : this(null)
override fun serialize(value: T, gen: JsonGenerator, provider: SerializerProvider) {
gen.writeString(value.name.lowercase())
}
}
}
LegacyDtoEnumDeserializer와 LegacyDtoEnumSerializer 클래스가 각각 역직렬화, 직렬화 로직을 수행한다. 타입이 LegacyDtoEnum 타입 & Enum일 경우에만 사용가능하도록 제약을 걸었다.(where 절)
한 가지 주목할 점은 ContextualDeserializer 인터페이스를 구현한다는 점이다. _valueClass는 적용될 클래스의 구체적인 타입을 가진다. 추상화된 제네릭 타입(인터페이스 & Enum)을 이용하면 _valueClass에 값이 들어가지 않는다. 객체 생성시에 타입을 알 수 없기 때문이다.
Enum이 가지는 값들을 사용하기 위해서는 구체적인 타입이 필요하다. 이를 위해 ContextualDeserializer 를 지원한다. 실행 중 커스텀 객체가 적용된 필드의 타입을 받아와 구체적인 타입으로 재지정할 수 있도록 도와준다.( createContextual 메서드)
JsonDeserializer와 StdDeserializer의 차이
JsonDeserializer는 ObjectMapper를 이용해 Json로 역질렬화하는 class. Docs에서는 커스텀 Deserializer를 작성한다면 이 클래스가 아닌 StdDeserializer를 상속받아 만들 것을 권장한다.
StdDeserializer는 JsonDeserializer를 상속받았으며, 기본적으로 String으로부터 원시타입을 파싱할 수 있는 메서드들을 제공해준다.
2.3 Serializer 적용하기
작성한 커스텀 클래스를 인터페이스에 적용함으로써 이를 상속받는 모든 Enum들에 커스텀 역/직렬화 로직이 자동적용 될 것이다.
@JsonDeserialize(using = LegacyDtoEnumCombinedSerializer.LegacyDtoEnumDeserializer::class)
@JsonSerialize(using = LegacyDtoEnumCombinedSerializer.LegacyDtoEnumSerializer::class)
interface LegacyDtoEnum
참고로 작성한 커스텀 클래스에 @JsonComponent 를 적용하여 전역적으로 자동적용시킬 수도 있다. Jackson 모듈에 직접적으로 적용하여 Jackson 동작시 알맞은 타입의 경우 해당 클래스를 이용해 역/직렬화 시켜준다.
@JsonComponent
class LegacyDtoEnumCombinedSerializer {
// ...직렬화, 역직렬화 커스텀 클래스
}
@JsonComponent를 통해서 적용시키면 좋을까 싶기도 했지만 인터페이스에 직접 적용하는게 보다 선언적이고 가독성이 좋다고 생각했다. 후에 각 Enum에서 LegacyDtoEnum 인터페이스를 보는 것만으로 동작흐름을 파악할 수 있을 거라 여겼고 @JsonComponent 를 통한 등록은 그 연결고리를 찾기가 쉽지 않을 것 같았다.
3. 마무리
인터페이스와 커스텀 객체를 활용해 Enum들이 공통적으로 취해야할 행위(변환로직)를 분리, 재사용할 수 있도록 만들어 보았다.
위 내용들을 적용한 전,후의 Legacy Enum들을 보면 확연히 깔끔하고 가독성이 좋아보이기도 한다.
// 적용 전
enum class LoginType {
KAKAO,
APPLE;
@JsonValue
fun getValue(): String {
return this.name.lowerCase()
}
companion object {
@JvmStatic
@JsonCreator
fun fromValue(value: String) =
values().find { it.name.lowerCase() == value }
?: throw IllegalArgumentException("Unknown LoginType: $value")
}
}
// 인터페이스로 분리 & 커스텀 로직 적용 후
enum class LoginType: LegacyDtoEnum {
KAKAO,
APPLE;
}
전체 코드는 아래 레포에서 확인할 수 있다.
adevspoon-backend/adevspoon-api/src/main/kotlin/com/adevspoon/api/config/controller/serializer/LegacyDtoEnumCombinedSerializer.k
CS 질문 - adevspoon-backend. Contribute to kids-ground/adevspoon-backend development by creating an account on GitHub.
github.com
5.1 참고
https://d2.naver.com/helloworld/0473330
StdDeserializer (jackson-databind 2.7.0 API)
Method for accessing type of values this deserializer produces. Note that this information is not guaranteed to be exact -- it may be a more generic (super-type) -- but it should not be incorrect (return a non-related type). Default implementation will ret
fasterxml.github.io
JsonDeserializer (jackson-databind 2.7.0 API)
Accessor that can be used to check whether this deserializer is expecting to possibly get an Object Identifier value instead of full value serialization, and if so, should be able to resolve it to actual Object instance to return as deserialized value. Def
fasterxml.github.io
JsonComponent (Spring Boot 3.2.4 API)
The value may indicate a suggestion for a logical component name, to be turned into a Spring bean in case of an autodetected component.
docs.spring.io
'Spring' 카테고리의 다른 글
예외 정보를 인터페이스와 infix함수로 확장성, 가독성 높게 관리하기 (0) | 2024.04.18 |
---|---|
API별 인증 해제, 어노테이션으로 효율적으로 처리하기 (2) | 2024.04.09 |
문자열 응답 시 공통 응답형식이 적용되지 않는 문제 해결하기 (0) | 2024.04.05 |
공통 형식의 응답 효율적으로 처리하기 (1) | 2024.04.04 |
Query에 포함된 다수의 Legacy Enum 우아하게 관리하기 (2) | 2024.04.04 |