ETC

[Spring Boot/Kotlin] Redis 캐싱: PageImpl 직렬화 에러와 ClassCastException 완벽 해결 가이드

Stitchhhh 2026. 2. 17. 18:05

Spring Boot와 Redis를 연동하여 캐싱을 구현하다 보면, 특히 Kotlin 환경에서 페이징된 데이터(Page<Dto>)를 캐싱할 때 두 가지 큰 장벽에 부딪히게 됩니다.

  1. InvalidDefinitionException: PageImpl은 기본 생성자가 없어 Jackson이 역직렬화를 못 함.
  2. ClassCastException: 캐시에서 꺼낸 데이터가 DTO가 아닌 LinkedHashMap으로 변환됨.

이 글에서는 이 문제의 원인을 파헤치고, 커스텀 Serializer를 이용한 근본적인 해결법Wrapper 클래스를 이용한 간편한 해결법 두 가지를 소개합니다.


문제의 원인

1. PageImpl의 구조적 문제

Spring Data의 PageImpl 클래스는 Jackson이 데이터를 복원할 때 필요한 기본 생성자(No-args constructor)가 없습니다. 또한 필드 구조가 복잡하여 JSON으로 변환했을 때 메타데이터가 깔끔하게 매핑되지 않습니다.

2. Kotlin data class와 Type Erasure

Redis는 데이터를 문자열(JSON)로 저장합니다. 이를 다시 객체로 복원하려면 "이 JSON이 원래 무슨 클래스였는지"에 대한 정보(Type Info, @class)가 필요합니다.

Jackson의 기본 설정(DefaultTyping.NON_FINAL)은 final 클래스에는 타입 정보를 저장하지 않습니다. 문제는 Kotlin의 모든 클래스(data class 포함)는 기본적으로 final이라는 점입니다.

결국 타입 정보 없이 저장된 JSON은 읽어올 때 UserDto가 아닌 LinkedHashMap이 되어버리고, 이를 캐스팅하려다 ClassCastException이 발생합니다.


방법 1. 인프라 레벨 해결 (권장)

특징: 설정은 조금 복잡하지만, 비즈니스 로직(Service) 코드를 전혀 수정할 필요가 없는 가장 깔끔한 방법입니다.

이 방법은 ObjectMapper 설정을 커스터마이징하여 PageImpl을 처리하고, Kotlin 클래스에도 강제로 타입 정보를 붙이도록 합니다.

Step 1. PageImplSerializer (직렬화)

PageImpl을 content(데이터)와 page(메타데이터)로 명확히 분리하여 저장합니다.

Kotlin
 
class PageImplSerializer : JsonSerializer<PageImpl<*>>() {
    override fun serializeWithType(
        value: PageImpl<*>, gen: JsonGenerator, serializers: SerializerProvider, typeSer: TypeSerializer
    ) {
        val typeId = typeSer.typeId(value, JsonToken.START_OBJECT)
        typeSer.writeTypePrefix(gen, typeId)
        writeInternal(value, gen, serializers)
        typeSer.writeTypeSuffix(gen, typeId)
    }

    private fun writeInternal(value: PageImpl<*>, gen: JsonGenerator, serializers: SerializerProvider) {
        // PagedModel을 이용해 표준화된 구조로 변환
        val pagedModel = PagedModel(value)
        gen.writeFieldName("content")
        serializers.defaultSerializeValue(pagedModel.content, gen)
        gen.writeFieldName("page")
        serializers.defaultSerializeValue(pagedModel.metadata, gen)
    }
}

Step 2. PageImplDeserializer (역직렬화)

저장된 JSON을 읽어 다시 PageImpl 객체로 조립합니다.

Kotlin
 
class PageImplDeserializer : JsonDeserializer<PageImpl<*>>() {
    override fun deserialize(p: JsonParser, ctxt: DeserializationContext): PageImpl<*> {
        val mapper = p.codec as ObjectMapper
        val node: JsonNode = mapper.readTree(p)

        // Content 리스트 복원
        val contentNode = node.get("content")
        val content = if (contentNode != null && !contentNode.isNull) {
            mapper.readerFor(object : TypeReference<List<Any>>() {})
                .readValue<List<Any>>(contentNode)
        } else {
            emptyList()
        }

        // Page 메타데이터 복원
        val pageNode = node.get("page")
        val pageNumber = pageNode?.get("number")?.asInt() ?: 0
        val pageSize = pageNode?.get("size")?.asInt() ?: 20
        val totalElements = pageNode?.get("totalElements")?.asLong() ?: content.size.toLong()

        return PageImpl(content, PageRequest.of(pageNumber, pageSize), totalElements)
    }
}

Step 3. 핵심 설정 (Custom Type Resolver)

Kotlin data class 문제를 해결하는 핵심 로직입니다. 보안 문제로 EVERYTHING을 쓸 수 없으므로, "우리 프로젝트 패키지라면 final이어도 타입 정보를 붙여라"는 화이트리스트 전략을 사용합니다.

Kotlin
 
object RedisObjectMapperUtil {
    fun init(objectMapper: ObjectMapper): ObjectMapper {
        val redisObjectMapper = objectMapper.copy()

        // 1. 커스텀 모듈 등록
        val simpleModule = SimpleModule()
        simpleModule.addDeserializer(PageImpl::class.java, PageImplDeserializer())
        simpleModule.addSerializer(PageImpl::class.java, PageImplSerializer())
        redisObjectMapper.registerModule(simpleModule)

        // 2. 커스텀 TypeResolver 설정
        val typer = CustomTypeResolverBuilder()
        typer.init(JsonTypeInfo.Id.CLASS, null)
        typer.inclusion(JsonTypeInfo.As.PROPERTY)
        typer.typeProperty("@class")
        redisObjectMapper.setDefaultTyping(typer)

        return redisObjectMapper
    }

    // 핵심: 패키지 기반 타입 정보 강제 포함
    class CustomTypeResolverBuilder : ObjectMapper.DefaultTypeResolverBuilder(
        ObjectMapper.DefaultTyping.NON_FINAL,
        LaissezFaireSubTypeValidator.instance
    ) {
        override fun useForType(t: JavaType): Boolean {
            if (super.useForType(t)) return true
            // 내 패키지(패키지명)라면 final 클래스라도 타입 정보를 포함!
            return t.rawClass.name.startsWith("패키지명")
        }
    }
}

방법 2. 애플리케이션 레벨 해결 (대안)

특징: 설정이 간편하고 직관적이지만, Service 코드의 리턴 타입을 변경해야 합니다.

복잡한 Jackson 설정이 부담스럽다면, Jackson이 좋아하는 형태(기본 생성자 + 어노테이션)를 갖춘 Wrapper 클래스(RestPage)를 만들어 사용하는 방법이 있습니다.

Step 1. RestPage 클래스 생성

PageImpl을 상속받되 @JsonCreator를 통해 생성자를 명시해 줍니다.

Kotlin
@JsonIgnoreProperties(ignoreUnknown = true)
class RestPage<T> : PageImpl<T> {
    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
    constructor(
        @JsonProperty("content") content: List<T>,
        @JsonProperty("number") number: Int,
        @JsonProperty("size") size: Int,
        @JsonProperty("totalElements") totalElements: Long,
        @JsonProperty("pageable") pageable: JsonNode? = null,
        @JsonProperty("last") last: Boolean,
        @JsonProperty("totalPages") totalPages: Int,
        @JsonProperty("sort") sort: JsonNode? = null,
        @JsonProperty("first") first: Boolean,
        @JsonProperty("numberOfElements") numberOfElements: Int
    ) : super(content, PageRequest.of(number, size), totalElements)

    constructor(page: Page<T>) : super(page.content, page.pageable, page.totalElements)
}

Step 2. Service 코드 수정

캐싱할 메서드에서 Page 대신 RestPage를 반환하도록 감싸줍니다.

Kotlin
@Service
class ProductService(private val repository: ProductRepository) {
    @Cacheable(value = ["products"], key = "#pageable.pageNumber")
    fun getProducts(pageable: Pageable): RestPage<ProductDto> { // 반환 타입 변경
        val page = repository.findAll(pageable).map { ProductDto(it) }
        return RestPage(page) // 감싸서 리턴
    }
}

결론: 어떤 방법을 써야 할까?

비교 항목 방법 1: 커스텀 Serializer (추천) 방법 2: RestPage Wrapper
코드 침투 없음 (Config만 수정) 있음 (Service 코드 수정 필요)
난이도 상 (Jackson 이해 필요) 하 (단순 클래스 추가)
유지보수 중앙에서 일괄 관리 캐싱 메서드마다 Wrapper 처리 필요
Kotlin 이슈 TypeResolver로 완벽 해결 DTO에 open을 붙이거나 추가 설정 필요할 수 있음

프로젝트 규모가 크고 캐싱을 적극적으로 사용한다면 [방법 1]을 강력하게 추천합니다. 한 번만 설정해두면 개발자들은 캐싱 내부 동작을 신경 쓰지 않고 비즈니스 로직에만 집중할 수 있기 때문입니다.

반면, 단순하고 빠르게 적용해야 하는 소규모 프로젝트라면 [방법 2]도 훌륭한 대안이 될 수 있습니다.