벡엔드/SpringBoot

[Redis] 레디스를 활용한 Spring-boot 캐싱 적용 해결하기 (트러블 슈팅)

코딩하는이씨 2024. 12. 2. 11:05
728x90
반응형

 

 

 

 

https://tech.namong.shop/faster-faster-cache

사이드 프로젝트 팀 기술 블로그 주소

 

 

사이드 프로젝트에서 기존에 사용자 토큰에만 사용하던 redis를 활용해 자주 사용되면서 복잡한 쿼리가 발생하는 API에 대해 캐싱을 적용하게 되었습니다.

 

하지만, 캐싱을 적용하며 아래와 같은 에러들을 만날 수 있었습니다.

  1. LocalDateTime 직렬화 문제
  2. List 역직렬화 문제
  3. 기본 생성자 문제

 

왜 에러가 발생했는지, 어떻게 해결했는지 함께 보겠습니다.

 

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에 대해 기본생성자를 생성하면 됩니다.

728x90
반응형