Spring Boot Redis 캐시, 배포 버전별로 키 분리하기 (Redisson, Kryo)

배포 직후에 캐시 조회가 실패하는 문제가 있었습니다. 원인을 따라가 보니 코드 버그가 아니라 Redis에 남아 있던 이전 버전의 캐시가 문제였는데요. 이번 글에서는 원인과 조치 방법을 정리해보겠습니다.

문제 상황

Spring Cache 추상화 위에서 RedissonSpringCacheManager를 사용하고 있고, 캐시마다 TTL을 설정해 두었습니다.

Redisson은 별도로 지정하지 않으면 Kryo codec으로 값을 직렬화합니다. Kryo는 빠르고 결과물이 작지만, 클래스 구조가 바뀌는 데에는 취약합니다. 그래서 캐시에 담기는 DTO의 필드를 추가하거나 바꿔서 배포하면 다음 문제가 생깁니다.

  • 새 버전 앱이 이전 버전 앱이 저장한 캐시를 읽습니다.
  • 클래스 구조가 달라서 역직렬화에 실패하고, 결국 조회가 실패합니다.
  • 롤링 배포 중에는 이전 버전과 새 버전 앱이 같은 키를 함께 쓰기 때문에 서로의 캐시를 깨뜨릴 수 있습니다.

TTL이 지나면 저절로 해소되기는 합니다. 하지만 그동안은 해당 캐시를 쓰는 API가 정상적으로 응답하지 못합니다.

조치 방법

1. 캐시 이름에 git commit id 붙이기

gradle-git-properties 플러그인을 적용하면 Spring Boot가 GitProperties 빈을 등록해줍니다. 여기서 short commit id를 가져와 캐시 이름 뒤에 붙였습니다.

userCache            (기존)
userCache:89ca76a    (배포 버전별)

RedissonSpringCacheManager의 getMapCache를 오버라이드해서 TTL이 있는 캐시만 버전 suffix가 붙도록 했습니다.

private String getVersionSuffix() {
  return Optional.ofNullable(gitProperties.getIfAvailable())
    .map(GitProperties::getShortCommitId)
    .map(commitId-> ":" + commitId)
    .orElse("");
}

@Bean
public CacheManager cacheManager() {
  String versionSuffix= getVersionSuffix();
  return new RedissonSpringCacheManager(redissonClient, config) {
    @Override
    protected RMapCache<Object, Object> getMapCache(String name,CacheConfig cacheConfig) {
      return super.getMapCache(name+ versionSuffix, cacheConfig);
    }
  };
}

이렇게 하면 배포 버전마다 서로 다른 Redis 키를 쓰게 됩니다. 새 버전은 이전 버전이 저장한 캐시를 아예 읽지 않으므로 역직렬화 실패가 생기지 않습니다. 롤링 배포 중에 두 버전이 함께 떠 있어도 서로 간섭하지 않습니다.

GitProperties는 ObjectProvider로 주입받았습니다. 그래서 git 정보가 없는 환경(로컬 등)에서는 suffix가 빈 문자열이 되어 기존과 똑같이 동작합니다.

2. 앱 시작 시 이전 버전 캐시 삭제

키를 나누고 나면 이전 버전 키가 Redis에 남는다는 문제가 생깁니다. RMapCache의 만료된 entry는 해당 맵을 사용하는 클라이언트에서만 정리되기 때문에, 아무도 쓰지 않는 이전 버전 맵은 계속 남게 됩니다.

그래서 ApplicationReadyEvent 시점에 현재 버전이 아닌 캐시 키를 직접 삭제하도록 했습니다.

@EventListener(ApplicationReadyEvent.class)
public void deleteOtherVersionCaches() {
  String versionSuffix= getVersionSuffix();
  if (versionSuffix.isEmpty()) {
    return;
  }

  for (String cacheName: VERSIONED_CACHE_NAMES) {
    try {
      List<String> otherVersionNames= Stream.concat(
          Stream.of(cacheName),// suffix 없는 기존 키
          redissonClient.getKeys().getKeysStreamByPattern(cacheName+ ":*")// 다른 버전 키
        )
        .filter(name-> !name.equals(cacheName+ versionSuffix))
        .toList();
      otherVersionNames.forEach(name-> redissonClient.getMapCache(name).delete());
    } catch (Exception e) {
      log.warn("이전 버전 캐시 삭제 실패 : {}", cacheName, e);
    }
  }
}

롤링 배포 중에 이전 버전 앱이 다시 만든 캐시는 다음 배포 때 삭제됩니다. 삭제에 실패하더라도 앱 기동에는 영향이 없도록 warn 로그만 남깁니다.

3. TTL 없는 캐시는 제외

TTL이 없는 캐시는 버전이 바뀌어도 데이터가 이어져야 하는 경우가 많아서 대상에서 뺐습니다. Redisson은 TTL이 있으면 getMapCache, 없으면 getMap으로 캐시를 만들기 때문에 getMapCache만 오버라이드하면 자연스럽게 분리됩니다.

후기

처음에는 "TTL이 짧으니 괜찮겠지"라고 생각했는데요. 실제로는 배포 직후 몇 분 동안 장애가 나기에 충분한 시간이었습니다. 직렬화 포맷을 JSON으로 바꾸는 방법도 생각해봤지만, 변경 범위가 크고 Kryo의 성능 이점을 포기해야 해서 키를 버전별로 나누는 쪽을 선택했습니다.

캐시는 결국 "다른 버전의 코드가 쓴 데이터"라는 점을 잊지 말아야 할 것 같습니다.

참고