[Spring] 캐시매니저를 Redis 로 전환해보자

Hocaron·2024년 3월 13일
1

Spring

목록 보기
42/44

로컬 캐시를 사용하던 프로젝트에서 캐시 동기화 이슈와 캐시 히트율을 높히기 위해 캐시 매니저를 Caffeine에서 Redis 로 전환해야했다.

전환하면서 겪은 역직렬화시 클래스 데이터 타입으로 인한 이슈와 캐시되어있던 데이터에 필드가 추가되면서 겪었던 ObjectMapper 설정 이슈에 대해 정리해보자.

역직렬화시 문제

다양한 Serializer 로 인해 선택부터 어려웠다

❎ StringRedisSerializer:

장점

  • 간단하고 가벼운 Serializer이다.
  • 문자열 형태로 데이터를 직렬화하므로 읽기 쉽고 이해하기 쉬운 형태로 데이터가 저장된다.

단점

  • 객체를 직렬화하거나 역직렬화하기 위해서는 별도의 encode, decode 과정이 필요하다.
  • 객체를 문자열로 변환하기 때문에 복잡한 구조의 객체를 표현하기 어렵다.

❎ GenericJackson2JsonRedisSerializer:

장점

  • JSON 형식으로 객체를 직렬화하고 역질렬화할 수 있다.
  • 다양한 유형의 객체를 처리할 수 있다.

단점

  • 객체를 역직렬화할 때 클래스 정보를 필요로 한다. 클래스 위치가 서로 다른 서비스에서 호출이 필요하면 에러가 발생할 수 있고, 추후 리팩토링 시에도 클래스의 위치를 고려해야한다.

✅ Jackson2JsonRedisSerializer

장점

  • GenericJackson2JsonRedisSerializer 과 마찬가지로 JSON 형식으로 객체를 직렬화하고 역질렬화할 수 있다.
  • Java 객체의 구조를 그대로 유지하면서 직렬화하므로 객체 간의 관계를 잘 유지할 수 있다.

단점:

  • 멀티스레드 환경에서 여러 타입을 함께쓰는 경우 조심해야 한다. (예: 동일한 RedisTemplate<String, Object>를 사용하는 경우, A 스레드에서는 A 데이터 타입으로 Serializer 를 등록했지만, B 스레드에서 B 데이터 타입으로 등록해버려 역직렬화에 실패할 수 있다.)

유연성을 고려하여 Jackson2JsonRedisSerializer 을 선택했다.

    private RedisCacheConfiguration createCache(CacheType cacheType) {

        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.of(cacheType.getExpireAfterWrite(), cacheType.getExpireTimeUnit()))
                .serializeKeysWith(fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(fromSerializer(new Jackson2JsonRedisSerializer<>(cacheType.getClazz())))
                .disableCachingNullValues();
    }
}

LocalDateTime 은 역직렬화가 되지 않는다고요?

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 

커스텀 ObjectMapper 에 LocalDateTime 을 역직렬화 할 수 있도록 설정을 추가해주었다.

    private RedisCacheConfiguration createCache(CacheType cacheType) {
    
        ObjectMapper objectMapper = new ObjectMapper()
                .registerModule(new JavaTimeModule())
                .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.of(cacheType.getExpireAfterWrite(), cacheType.getExpireTimeUnit()))
                .serializeKeysWith(fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(fromSerializer(new Jackson2JsonRedisSerializer<>(objectMapper, cacheType.getClazz())))
                .disableCachingNullValues();
    }

해치웠나...

데이터에 필드가 추가되는 경우 발생한 이슈

요구사항으로 인해 캐시되어있는 데이터에 필드가 추가되자 에러 알림이 발생했다.

ObjectMapper 의 FAIL_ON_UNKNOWN_PROPERTIES 기본 설정은 true 이므로 데이터 타입에 정의되지 않은 필드가 조회한 데이터에 있는 경우 위의 에러가 발생한다.

이런 에러가 발생한 이유는 뭘까?

새로운 필드가 추가된 체로 데이터가 저장된거면 배포된 서버에서도 새로운 필드가 추가되어있을텐데, 왜 에러가 발생했을까?! 이유는 우리 배포 방법이 블루 / 그린 배포 방식이면서 25프로씩 트래픽을 전환하고 있기 때문이다. 새버전에서 추가된 job 필드로 인해 해당 필드가 추가된 데이터가 캐싱되지만, 구버전에서는 job 필드가 정의되지 않은 필드이기 때문에 역직렬화시에 에러가 발생한다.

배포 전에 FAIL_ON_UNKNOWN_PROPERTIES 를 false 로 설정 후 적용했다면 에러가 발생하지 않았을 것이다. 추후에 이 히스토리를 모르고 필드를 추가할 누군가를 위해 위 설정은 적용하자!

코드로 살펴보자

Caffeine 을 캐시 매니저로 사용한 코드

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager caffeineCacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        cacheManager.setCaches(createCaches());
        return cacheManager;
    }

    private List<CaffeineCache> createCaches() {
        return Arrays.stream(CacheType.values())
                .map(this::createCache)
                .collect(Collectors.toList());
    }

    private CaffeineCache createCache(CacheType cacheType) {
        return new CaffeineCache(cacheType.getCacheName(),
                Caffeine.newBuilder()
                        .recordStats()
                        .expireAfterWrite(cacheType.getExpireAfterWrite(), cacheType.getExpireTimeUnit())
                        .maximumSize(cacheType.getMaximumSize())
                        .build()
        );
    }
}

Redis 을 캐시 매니저로 사용한 코드

@EnableCaching
@Configuration
@RequiredArgsConstructor
public class CacheConfig {

    private final ObjectMapper objectMapper;

    @Bean
    public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {

        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .withInitialCacheConfigurations(createCaches())
                .enableStatistics()
                .build();
    }

    private Map<String, RedisCacheConfiguration> createCaches() {
        return Arrays.stream(CacheType.values())
                .collect(Collectors.toMap(
                        CacheType::getCacheName,
                        this::createCache
                ));
    }

    private RedisCacheConfiguration createCache(CacheType cacheType) {
    
        ObjectMapper objectMapper = new ObjectMapper()
                .registerModule(new JavaTimeModule())
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.of(cacheType.getExpireAfterWrite(), cacheType.getExpireTimeUnit()))
                .serializeKeysWith(fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(fromSerializer(new Jackson2JsonRedisSerializer<>(objectMapper, cacheType.getClazz())))
                .disableCachingNullValues();
    }
}

Spring Cache 사용하면서 알게된 좋을 내용들

@CacheConfig 사용으로 클래스 단위로 키를 관리할 수 있다

@CacheConfig(cacheNames={"user"})
public class UserService {

클래스 단위로 캐시 키가 같다면, @CacheConfig 으로 간단하게 설정할 수 있다. 캐시 키가 개발 중간에 변경되는 경우, @Cacheable, @CacheEvict 등등 모두 찾아다니면서 변경해줘야 하는데 @CacheConfig 를 사용한다면 클래스 단위로 변경만 하면 된다.

@CacheEvict(allEntries = true) 시에 KEYS 가 기본 동작이므로 조심하자

KEYS 명령어로 조회 후에 DEL 로 데이터 삭제를 하게된다. KEYS 는 블로킹 명령어로 운영환경에서 피해야하는데 @CacheEvict 시에 논블로킹 명령어인 SCAN 을 사용하도록 변경할 수 있다.

    @Bean
    public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {

        return RedisCacheManager.RedisCacheManagerBuilder
                .fromCacheWriter(RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory, BatchStrategies.scan(1000))) // 🔥🔥🔥 BatchStrategies 기본값인 KEYS 를 SCAN 으로 변경
                .withInitialCacheConfigurations(cacheConfigs())
                .enableStatistics()
                .build();
    }

결론

  • 다양한 Serializer 를 상황에 맞게 적절하게 사용하자
  • 필드를 엄격하게 제한하지 않아도 되는 경우, ObjectMapper 의 FAIL_ON_UNKNOWN_PROPERTIES 설정은 false 로 설정하자.
  • @CacheConfig 로 키를 클래스 단위에서 관리할 수 있다.

References

profile
기록을 통한 성장을

0개의 댓글