[Redis] 레디스를 활용한 Spring-boot 캐싱 적용 해결하기 (트러블 슈팅)
https://tech.namong.shop/faster-faster-cache
사이드 프로젝트 팀 기술 블로그 주소
사이드 프로젝트에서 기존에 사용자 토큰에만 사용하던 redis를 활용해 자주 사용되면서 복잡한 쿼리가 발생하는 API에 대해 캐싱을 적용하게 되었습니다.
하지만, 캐싱을 적용하며 아래와 같은 에러들을 만날 수 있었습니다.
- LocalDateTime 직렬화 문제
- List 역직렬화 문제
- 기본 생성자 문제
왜 에러가 발생했는지, 어떻게 해결했는지 함께 보겠습니다.
1. LocalDateTime 직렬화 문제
캐시를 적용한 API에서 LocalDateTime 타입 필드가 Redis에 저장될 때 아래와 같은 직렬화 문제가 발생했습니다
Error Message
Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type java.time.LocalDateTime not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: org.hibernate.collection.spi.PersistentBag[0]->com.namo.spring.db.mysql.domains.record.entity.Activity["createdAt"])
이 오류는 Jackson에서 기본적으로 LocalDateTime 타입을 지원하지 않기 때문에 발생합니다. 이를 해결하기 위해 jackson-datatype-jsr310 모듈을 추가하고, ObjectMapper에 등록해야 합니다.
해결 방법
build.gradle에 Jackson 모듈 추가
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
1. 개별 필드에 직렬화 및 역직렬화 지정
public static class ActivityInfoDto {
...
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonFormat(shape= JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm")
private LocalDateTime activityStartDate;
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonFormat(shape= JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm")
private LocalDateTime activityEndDate;
...
}
위와 같이 특정 필드에 대해서만 직렬화 및 역직렬화를 커스터마이징할 수 있습니다.
하지만 해당 필드마다 일일이 설정해야 하기 때문에 다수의 LocalDateTime 필드가 있을 경우 불편할 수 있습니다.
2. ObjectMapper에 JavaTimeModule 등록
ObjectMapper에 JavaTimeModule을 등록하면 프로젝트 전반에서 LocalDateTime 타입을 Jackson이 처리할 수 있게됩니다.
public class RedisConfig {
private final String redisHost;
private final int redisPort;
...
@Bean
@Primary
@DomainRedisTemplate
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(customObjectMapper()));
return redisTemplate;
}
@Bean
@DomainRedisCacheManager
public RedisCacheManager redisCacheManager(@DomainRedisConnectionFactory RedisConnectionFactory cf) {
RedisCacheConfiguration cacheConfiguration =
RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(customObjectMapper())))
.entryTtl(Duration.ofHours(3L));
return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf)
.cacheDefaults(cacheConfiguration)
.build();
}
@Bean
public ObjectMapper customObjectMapper(){
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator
.builder()
.allowIfSubType(Object.class)
.build();
return new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModule(new JavaTimeModule())
.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL)
.disable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(SerializationFeature.WRITE_DATE_KEYS_AS_TIMESTAMPS);
}
...
}
이렇게 CustomObejectMapper를 생성해 다음과같이 설정하면 LocalDateTime이 기존 Format 대로 표현되고, 개별 컬럼에 대해 직렬화, 역직렬화 정보를 지정할 필요가 없어지게됩니다.
또한 Redis에 직렬화/역직렬화시에만 Class정보가 포함된 데이터로 다뤄집니다.
2. List 역직렬화 문제
해당 메서드는 스케줄의 활동들에대해 DTO로 변환후 한번에 가져오는 메서드 입니다.
@Cacheable 어노테이션을 사용해 스케줄의 활동들을 DTO로 변환하여 캐싱했을 때, Redis에 저장은 정상적으로 완료되었지만 재조회 시 저장된 값이 역직렬화되지 않는 문제가 발생했습니다.
@Cacheable(value = "ActivityInfoDtoList", key = "#scheduleId", cacheManager = "redisCacheManager", unless = "#result==null")
@Transactional(readOnly = true)
public List<ActivityInfoDto> getActivities(Long memberId, Long scheduleId) {
...
}
Redis에 저장할 때 사용되는 직렬화 도구로 GenericJackson2JsonRedisSerializer를 사용하는 경우, DTO 클래스가 Redis에 저장될 때 그 클래스의 FQCN(Full Qualified Class Name)을 포함하여 저장됩니다. 하지만 클래스 로더가 이 정보를 찾지 못하면 역직렬화에 실패하게 됩니다.
Error message
"Could not read JSON:Unexpected token (START_OBJECT), expected VALUE_STRING: need String, Number of Boolean value that contains type id (for subtype of java.lang.Object)\n at [Source: (byte[])\"
해결방법
List를 그대로 저장하지 않고, 이를 감싸는 Wrapper Class를 만들어 저장함으로써 역직렬화 문제를 해결할 수 있습니다.
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class ActivityInfoDtoList{
private List<ActivityInfoDto> activityInfoDto;
@Schema(description = "활동 조회 DTO")
public static class ActivityInfoDto {
...
}
}
이후 List<ActivityInfoDto> 대신 ActivityInfoDtoList를 Redis에 저장하도록 캐싱 로직을 변경합니다.
@Cacheable(value = "ActivityInfoDtoList", key = "#scheduleId", cacheManager = "redisCacheManager", unless = "#result==null")
@Transactional(readOnly = true)
public ActivityInfoDtoList getActivities(Long memberId, Long scheduleId) {
...
}
3. 기본 생성자 문제
@Cacheable을 적용한 DTO 클래스에서 기본 생성자가없어 에러가 발생했습니다.
이는 Jackson이 역직렬화 시 기본 생성자를 필요로 하기 때문입니다.
Error message
"Could not read JSON:Cannot construct instance of com.namo.spring.application.external.api.record.dto.ActivityResponse$ActivityInfoDtoList (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)\n at [Source: (byte[])\"
해결방법
역직렬화되는 Class에 대해 기본생성자를 생성하면 됩니다.