본문 바로가기

Back-End/Redis

트러블 슈팅 : Spring Data JPA 페이징 + Redis 캐시에서 직렬화/ 역직렬화 오류 발생

1. 문제 상황

    • 사용 기술 스택 : Spring Boot, Spring Data JPA, Redis, Spring Cache
    • 현상 : 특정 API에서 페이징된 엔티티 목록을 스프링 캐시에 저장했더니, 1차로 조회할 때는 DB에서 가져오므로 성공했지만 이후 캐시에서 데이터를 가져오면서InvalidDefinitionException 또는 Cannot construct instance of PageImpl 오류가 발생했습니다.
2025-02-02T23:28:37.189+09:00 ERROR 38011 --- [nio-8080-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed:
org.springframework.data.redis.serializer.SerializationException: Could not read JSON:Invalid type definition for type com.example.
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: ...
  • 영향 : 해당 캐시된 API를 호출할 때마다 오류가 발생하며 애플리케이션이 정상 응답을 주지 못했습니다.

2. 원인 분석

Spring Data JPA의 페이징 결과는 실제로 PageImpl<T>라는 구현체입니다. Jackson이 Redis에서 JSON을 역직렬화할 때, PageImpl에 기본 생성자 혹은 @JsonCreator가 없어 객체를 만들 수 없으므로 직렬화는 되지만 역직렬화가 실패하여 오류가 발생했습니다.

간단히 말해 Spring Data JPA에서 Page<T>를 반환할 때, 내부적으로는 PageImpl<T> 클래스를 사용합니다. Jackson은 객체 -> JSON(직렬화)은 어떻게든 하겠지만 JSON -> 객체(역직렬화) 시 생성자를 찾지 못해 에러를 내게 됩니다.

캐시를 안 쓰고 Spring Data JPA만 썼을 때는 페이징을 해도 직렬화/역직렬화 신경 안 써도 되던데, 왜 그러지?

 

캐시를 안 쓸 때는 보통 Page<T>를 DB에서 조회하고 바로 컨트롤러에서 HTTP 응답(JSON)으로 내보냅니다.  이때 Jackson은 객체   -> JSON(직렬화)만 수행하고, JSON -> 객체(역직렬화)를 할 필요가 없습니다.

캐시를 쓸 때는 Page<T> 객체를 객체 -> JSON으로 Redis에 저장(직렬화)하고 나중에 JSON -> 객체(역직렬화)로 다시 꺼내 서비스 내부에서 재활용 해야 합니다. 이 과정에서 PageImpl<T> 생성자를 못 찾아 Jackson이 "객체를 어떻게 만들지?" 몰라하면서 에러가 납니다.

쉽게 결론을 말하면 내 메서드 반환 타입이 Page이면 캐시에서도 Page로 받아와야 메서드 호출부에서 동일하게 다룰 수 있습니다. 단순히 역직렬화를 하기 싫어서 JSON으로만 리턴하면, 메서드가 사용되는 곳(다른 서비스 로직, 컨트롤러)에서 JSON 파싱을 따로 해야합니다.


3. 해결

Page<T> 대신 DTO(PageResponse<T>) 사용하여  Page<T>를 직접 캐시에 저장하지 않고 필요한 필드만 담은 DTO를 만들어 저장했습니다.

서비스 로직에서 Page<T>를 조회한 뒤 DTO로 변환하여 캐시에 저장하니 Jackson은 DTO를 문제 없이 직렬화/역직렬화 할 수 있었습니다.

왜냐하면 DTO에는 기본 생성자나 @JsonProperty 등을 자유롭게 추가해줄 수 있기 때문입니다.

그럼 여기까지 글을 읽으셨으면 궁금증이 하나 더 생길 수 있다고 생각합니다.

근데 왜 Page<T>는 안되고 DTO는 되는거야? 왜 오류가 나는지는 이해했는데 어떤 식으로 역직렬화가 되는거지??

핵심은 Jackson이 JSON을 다시 객체로 만들 때 생성자를 찾거나 @JsonCreator가 필요 하다는 사실입니다.

1. PageImpl<T>는 라이브러리 클래스라 기본 생성자나 @JsonCreator가 없습니다

  • 직렬화(객체 -> JSON)는 getter로 필드를 뽑아내는 거니까 문제가 없지만
  • 역직렬화(JSON -> 객체) 할 때는 "객체를 어떻게 new 할지" Jackson이 알 수 없습니다.
  • 그래서 캐시에 JSON이 들어가도 다시 객체로 만들기가 불가능 하므로 오류를 발생 시킵니다.

2. DTO(PageResponse<T>) 는 직접 만든 클래스 이므로 기본 생성자를 추가하고  @JsonProperty를 통해 "Json에서 필드를 어떻게 매핑"할지 명시할 수 있습니다. 그래서 Jackson이 "아! PageResponse는 이렇게 생성해서 필드를 넣으면 되겠구나" 하고 쉽게 역직렬화를 할 수 있습니다.(@JsonProperty와 Setter가 없어도 Jackson이 기본 생성자가 있는 DTO를 reflection 방식으로 필드에 값을 채워 줍니다)

 PageImpl<T>는 역직렬화 로직이 닫혀 있고, PageResponse<T>는 역직렬화 로직을 열어둘 수 있기 때문에 직접 만든 DTO는 "객체 -> JSON -> 객체” 양방향이 자유로울 수 있습니다.


4. 느낀 점

  • 직렬화 대상이 되는 클래스는 기본 생성자, @JsonCreator, @JsonProperty 등 Jackson 호환성을 고려해야 함
  • Spring PageImpl처럼 라이브러리 클래스는 내부 구조가 역직렬화에 친화적이지 않을 수 있음
  • 가능하면 DTO를 통해 필요한 데이터만 캐시에 저장하는 게 더 안정적이고, 구조적 호환성 문제도 줄어든다

5. 결과

"totalPages": 166667,
"totalElements": 5000002,
"page": 0,
"size": 30

1차 조회 때캐시미스 상태라 레디스에 갔다가 DB에서 갖고 오느라 1Page에 30개의 데이터를 가지고 오는데 381ms가 걸렸습니다.

2차 조회 때는 캐시 히트 상태라 바로 캐시에서 가져오므로 약 19배 정도 속도가 향상되었습니다.