Spring Boot와 Redis를 연동하여 캐싱을 구현하다 보면, 특히 Kotlin 환경에서 페이징된 데이터(Page<Dto>)를 캐싱할 때 두 가지 큰 장벽에 부딪히게 됩니다.
- InvalidDefinitionException: PageImpl은 기본 생성자가 없어 Jackson이 역직렬화를 못 함.
- 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(메타데이터)로 명확히 분리하여 저장합니다.
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 객체로 조립합니다.
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이어도 타입 정보를 붙여라"는 화이트리스트 전략을 사용합니다.
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를 통해 생성자를 명시해 줍니다.
@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를 반환하도록 감싸줍니다.
@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]도 훌륭한 대안이 될 수 있습니다.
'ETC' 카테고리의 다른 글
| OpenFeign부터 Spring AI, 그리고 Kotlin 최적화까지: 타입 안전한 AI 서비스 구축기 (0) | 2026.02.17 |
|---|---|
| 만들면서 배우는 클린 아키텍처 (2) | 2025.04.22 |