Jackson 3 변경으로 인한 Redis 역직렬화 장애 분석
ImHere 프로젝트는 로그인 및 회원가입 과정에서 카카오의 OIDC(OpenID Connect)를 활용하고 있습니다.
카카오 공식 문서에서는 OIDC 서명 검증에 필요한 공개 키를 매 요청마다 조회하는 대신, 일정 시간 캐싱하여 사용할 것을 권장하고 있습니다.
이에 따라 본 프로젝트에서는 카카오 공개키를 캐싱하여 사용하고 있으며, 추가로 자체적으로 사용하는 refresh-token 역시 데이터베이스가 아닌 캐시 기반으로 관리하도록 설계하였습니다.
해당 프로젝트는 기존 MVP 형태로 배포했던 서비스를 리팩토링하여 재운영할 계획이기 때문에, 확장성과 성능을 고려하여 캐시 저장소로 Redis를 채택하였습니다.
배경
이 프로젝트는 단순한 서비스 구현을 넘어 학습 목적도 함께 가지고 있습니다.
이에 따라 최신 기술 스택 경험을 위해 Spring Boot 4로의 마이그레이션을 진행하였습니다.
Spring Boot 4는 여러 주요 변경 사항을 포함하고 있으며, 그중 하나가 Jackson 라이브러리의 2.x → 3.x 업그레이드입니다.
Jackson 2.x는 과거 Remote Code Execution(RCE) 취약점 이슈가 있었으며, 이를 완화하기 위해 PolymorphicTypeValidator가 도입되었습니다. 또한 ObjectMapper.DefaultTyping.EVERYTHING 옵션은 보안 문제로 인해 deprecated 처리되었고, 이후 Jackson 3에서 완전히 제거되었습니다.
DefaultTyping.EVERYTHING 삭제 결정 Github Issu
- All @Deprecated (as of 2.20) methods, fields and classes are removed from 3.0
- Javadocs in Jackson 2.20 updated to indicate replacements where available (incomplete: PRs welcome for more!)
문제 상황
카카오 공개키를 Redis에 캐싱하는 과정에서, 다음과 같은 구조를 사용하고 있습니다.
@Component
class KakaoOauthClient(
restClientBuilder: RestClient.Builder
) : OauthClientPort {
companion object {
private val log = LoggerFactory.getLogger(KakaoOauthClient::class.java)
const val KAKAO_BASE_URL = "<https://kauth.kakao.com>"
const val KEY_REQ_URL = "/.well-known/jwks.json"
}
private val restClient: RestClient = restClientBuilder
.baseUrl(KAKAO_BASE_URL)
.build()
@Cacheable(value = ["kakaoOidcKeys"], cacheManager = "oidcCacheManager", key = "'kakaoPublicKeySet'")
override fun getPublicKeyFromProvider(): OIDCPublicKeyResponse? {
return fetchPublicKey()
}
private fun fetchPublicKey(): OIDCPublicKeyResponse? {
return restClient.get()
.uri(KEY_REQ_URL)
.retrieve()
.body()
}
}
공개키를 받은 즉시 OIDCPublicKeyResponse 로 매핑하는데, OIDCPublicKeyResponse 는 Kotlin의 data class로 정의되어 있습니다.
data class OIDCPublicKeyResponse(
val keys: List<OIDCPublicKey> = emptyList()
)
문제는 캐시 조회 시점에서 발생했습니다.
org.springframework.data.redis.serializer.SerializationException: Could not read JSON:Could not resolve subtype of [simple type, class java.lang.Object]: missing type id property '@class'
at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); byte offset: #467]
at org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer.deserialize(GenericJacksonJsonRedisSerializer.java:212) ~[spring-data-redis-4.0.3.jar:4.0.3]
at org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer.deserialize(GenericJacksonJsonRedisSerializer.java:182) ~[spring-data-redis-4.0.3.jar:4.0.3]
at org.springframework.data.redis.core.AbstractOperations.deserializeValue(AbstractOperations.java:427) ~[spring-data-redis-4.0.3.jar:4.0.3]
at org.springframework.data.redis.core.AbstractOperations$ValueDeserializingRedisCallback.doInRedis(AbstractOperations.java:67) ~[spring-data-redis-4.0.3.jar:4.0.3]
at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:414) ~[spring-data-redis-4.0.3.jar:4.0.3]
at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:381) ~[spring-data-redis-4.0.3.jar:4.0.3]
at org.springframework.data.redis.core.AbstractOperations.execute(AbstractOperations.java:166) ~[spring-data-redis-4.0.3.jar:4.0.3]
at org.springframework.data.redis.core.DefaultValueOperations.get(DefaultValueOperations.java:51) ~[spring-data-redis-4.0.3.jar:4.0.3]
{생략}
Caused by: tools.jackson.databind.exc.InvalidTypeIdException: Could not resolve subtype of [simple type, class java.lang.Object]: missing type id property '@class'
at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); byte offset: #467]
at tools.jackson.databind.exc.InvalidTypeIdException.from(InvalidTypeIdException.java:41) ~[jackson-databind-3.0.4.jar:3.0.4]
at tools.jackson.databind.DeserializationContext.missingTypeIdException(DeserializationContext.java:2105) ~[jackson-databind-3.0.4.jar:3.0.4]
at tools.jackson.databind.DeserializationContext.handleMissingTypeId(DeserializationContext.java:1690) ~[jackson-databind-3.0.4.jar:3.0.4]
at tools.jackson.databind.jsontype.impl.TypeDeserializerBase._handleMissingTypeId(TypeDeserializerBase.java:285) ~[jackson-databind-3.0.4.jar:3.0.4]
at tools.jackson.databind.jsontype.impl.AsPropertyTypeDeserializer._deserializeTypedUsingDefaultImpl(AsPropertyTypeDeserializer.java:173) ~[jackson-databind-3.0.4.jar:3.0.4]
at tools.jackson.databind.jsontype.impl.AsPropertyTypeDeserializer.deserializeTypedFromObject(AsPropertyTypeDeserializer.java:112) ~[jackson-databind-3.0.4.jar:3.0.4]
at tools.jackson.databind.jsontype.impl.AsPropertyTypeDeserializer.deserializeTypedFromAny(AsPropertyTypeDeserializer.java:203) ~[jackson-databind-3.0.4.jar:3.0.4]
at tools.jackson.databind.deser.jdk.UntypedObjectDeserializerNR.deserializeWithType(UntypedObjectDeserializerNR.java:98) ~[jackson-databind-3.0.4.jar:3.0.4]
문제 원인
Jackson은 내부적으로 DefaultTypeResolverBuilder를 사용하여 직렬화 및 역직렬화 시 타입 정보를 포함할지 여부를 결정합니다. 이때 핵심적인 판단 기준이 되는 메서드가 useForType이며, 해당 로직에서는 특정 타입에 대해 타입 정보를 포함할지 여부를 판단합니다.
이때 useForType 메서드의 주석을 살펴보면, String, Boolean, Integer와 같은 기본 타입뿐과 아니라 구체적(concrete)이고 final인 클래스는 타입 정보를 포함하지 않는 대상이 됩니다.
Method called to check if the default type handler should be used for given type. Note: "natural types" (String, Boolean, Integer, Double) will never use typing; that is both due to them being concrete and final, and since actual serializers and deserializers will also ignore any attempts to enforce typing.
결과적으로 Redis에는 단순한 JSON 형태의 데이터만 저장되게 되었고, 이를 나중에 다시 역직렬화 하는 과정에서 매핑할 클래스 정보를 알 수 없게 되어서 문제가 발생하게 되었습니다
@Component
class RedisCacheAdapter(
private val redisTemplate: RedisTemplate<String, String>,
private val mapper: JsonMapper
) : CachePort {
override fun save(key: String, data: Any, duration: Duration) {
val jsonString = mapper.writeValueAsString(data)
redisTemplate.opsForValue().set(key, jsonString, duration)
}
override fun <T> find(key: String): T? {
val jsonString = redisTemplate.opsForValue().get(key) ?: return null
return try {
mapper.readValue(jsonString)
} catch (e: Exception) {
null
}
}
}
해결
이 문제를 해결하기 위해 애플리케이션 레벨에서 역직렬화 시 클래스 타입을 명시하였습니다.
- Redis에는 객체를 JSON 문자열(String) 형태로 저장
- ⇒ 조회 시에는 명시적으로 타입을 전달하여 역직렬화 수행
이 방식으로 변경함으로써 다음과 같은 효과를 얻을 수 있었습니다.
- Jackson 3의 정책 변화에 영향을 받지 않음
- 타입 정보 누락으로 인한 역직렬화 문제 제거
- 직렬화 구조가 단순해져 디버깅이 용이해짐
배운 점
Spring이 JSON 변환에 Jackson을 사용하는 것은 알고 있었지만 내부 구조에 대해서는 생각해보거나 공부한 적이 없어서 문제를 해결하는 데 오랜 시간이 걸렸습니다. 특히 Deprecated 예고가 된 기능을 사용했다는 점에서 Spring 내부에서 사용되는 라이브러리에 대한 이해 역시 필요함을 느끼게 되었습니다.