저희는 설정 값을 하나 추가해야 했습니다. 이번 릴리즈에 맞춰 Flyway 마이그레이션 스크립트로 DB에 새 설정 데이터를 추가했습니다. “어차피 새로 추가하는 값이니 캐시랑 부딪힐 일은 없겠지”라고 판단했습니다. 그런데 배포가 끝난 뒤 전체 설정 목록을 조회하는 API를 호출하면 방금 추가한 설정이 보이지 않았습니다. 몇 번을 다시 호출해도 마찬가지였습니다. “마이그레이션이 실패했나?” 싶어서 DB를 직접 확인해보면 값은 멀쩡히 있었습니다.
캐시를 의심하는 건 자연스러운 수순이었습니다. 원인을 분석해보니 이유가 두 가지 겹쳐 있었습니다. 하나는 Flyway가 실행한 마이그레이션에는 스프링이 캐시를 지울 방법 자체가 없다는 것이고, 다른 하나는 “새로 추가하는 값이니 캐시와 무관하다”는 저희 판단이 절반만 맞는 말이었다는 것입니다.
배경: 설정값을 Redis로 캐싱하는 구조
저희는 MySQL에 잘 바뀌지 않는 설정 값들을 저장해두고, 조회가 잦은 데이터인 만큼 Redis로 캐싱해서 쓰고 있습니다. 캐시를 채우는 쪽은 Spring의 @Cacheable로 처리하는, Spring Cache 추상화를 그대로 쓰는 구조입니다.
@Service
public class ConfigQueryService {
private final ConfigRepository configRepository;
@Cacheable(cacheNames = "config", key = "#key")
public Config getConfig(String key) {
return configRepository.findByKey(key);
}
@Cacheable(cacheNames = "config", key = "'ALL'")
public List<Config> getAllConfigs() {
return configRepository.findAll();
}
}
개별 설정과 전체 목록을 캐시 네임은 config로 똑같이 쓰고, 키만 다르게 해서 관리하고 있었습니다. 개별 조회는 실제 설정 키(#key)를, 전체 목록 조회는 'ALL'이라는 정해진 문자열을 키로 씁니다. 캐시를 하나 더 만드느니 같은 캐시 네임 아래 특수한 키 하나를 얹는 게 더 간단해 보였기 때문입니다. (실제로는 캐시 네임도 다르고 키가 훨씬 복잡하지만 심플하게 작성했습니다.)
설정을 등록하거나 바꾸는 건 원래 관리 콘솔의 API를 거치도록 되어 있고, 그 서비스에는 값이 바뀔 때 개별 캐시와 전체 목록 캐시를 함께 지우는 @CacheEvict가 붙어 있었습니다. 문제는 이번 건이 그 경로를 타지 않았다는 데 있었습니다. 이번에 추가한 설정은 릴리즈에 맞춰 배포되는 초기 데이터라 관리 API 대신 Flyway 마이그레이션 스크립트로 DB에 직접 넣었고, 새로 추가하는 값이니 캐시된 적이 없을 거라 생각해 캐시는 따로 신경 쓰지 않았습니다.
원인 분석: 캐시가 갱신되지 않은 두 가지 이유
저희 API 중에는 설정을 하나씩 조회하는 API 말고, 전체 설정 목록을 한 번에 내려주는 API도 있었습니다. redis-cli로 직접 들여다보고 나서야 상황이 명확해졌습니다.
127.0.0.1:6379> KEYS config::*
1) "config::feature.retry.max"
2) "config::ALL"
127.0.0.1:6379> GET "config::ALL"
"[{\"key\":\"feature.retry.max\",\"value\":\"3\"}, ...]" # 새로 추가한 설정이 빠져 있음
127.0.0.1:6379> TTL "config::ALL"
(integer) -1
이유 ① Flyway가 실행한 마이그레이션은 스프링이 알 방법이 없다
이걸 아파트 경비실에 비유하면 이해가 쉽습니다. 택배 기사님이 정문 경비실을 거쳐 들어와야 경비 아저씨가 “몇 호에 택배 왔다”고 기록을 남길 수 있는데, 담을 넘어 창문으로 물건을 직접 들이면 경비실은 그런 일이 있었는지조차 알 방법이 없습니다.
Flyway는 애플리케이션이 뜨는 시점에 스프링 컨텍스트와는 별개로 자기가 들고 있는 JDBC 커넥션으로 마이그레이션 SQL을 직접 실행합니다. 스프링이 관리하는 빈을 거치지 않는다는 뜻입니다. Spring의 @CacheEvict는 AOP 프록시로 동작하는데, 프록시가 가로챌 수 있는 건 스프링이 관리하는 빈을 통해 들어오는 메서드 호출뿐입니다. 스프링 공식 문서도 이 제약을 이렇게 못박아 둡니다.
In proxy mode (the default), only external method calls coming in through the proxy are intercepted.
같은 클래스 안에서 메서드끼리 호출하는 self-invocation조차 캐싱이 걸리지 않는다고 밝혀둘 정도로, 프록시 바깥에서 일어나는 일은 스프링 캐시 추상화의 눈에 아예 보이지 않습니다. Flyway가 실행한 INSERT 문은 말할 것도 없습니다. 자바 코드를, 그것도 스프링이 관리하는 빈의 메서드를 단 한 줄도 거치지 않았으니, @CacheEvict가 아무리 꼼꼼하게 붙어 있었어도 이번 경우엔 발동할 방법이 없었습니다.
이유 ② “새로 추가하는 값”이라는 판단은 절반만 맞았다
config::feature.retry.max처럼 실제 설정 키로 저장되는 항목 기준으로 보면 “새로 추가하는 값이니 캐시된 적이 없다”는 판단은 맞습니다. 이 키는 이번에 처음 생긴 값이니 지울 캐시도 없습니다. 문제는 config::ALL이라는 전체 목록 캐시가 이 설정이 생기기 훨씬 전부터 이미 존재하고 있었다는 데 있었습니다. 새 설정이 DB에 들어가는 순간 config::ALL이 들고 있는 목록은 원본 데이터와 어긋나 버립니다. 개별 키(#key) 기준으로는 맞았던 판단을, 완전히 다른 무효화 기준을 가진 'ALL' 키에도 별생각 없이 그대로 옮겨 적용해버린 셈이었습니다.
여기에 config::feature.retry.max와 config::ALL이 캐시 네임 config 하나 아래 나란히 들어 있다는 점도 한몫했습니다. 코드를 볼 때 “config 캐시니까 다 같이 관리되겠지”라고 뭉뚱그려 생각하기 쉬웠고, 그 안에 'ALL'이라는 정해진 키 하나가 완전히 다른 무효화 조건을 가진 채 섞여 있다는 사실은 눈에 잘 띄지 않았습니다.
저희는 캐시에 TTL도 따로 걸어두지 않고 있었습니다. TTL 명령이 -1을 반환한 게 그 증거입니다. 설정값은 자주 바뀌지 않는 데이터라 “필요할 때 API에서 evict로 지워주면 되지, 만료 시간까지 이중으로 관리할 필요는 없다”고 판단했기 때문인데, 이 판단 자체는 지금도 유효하다고 생각합니다. 다만 그만큼 API를 거치지 않는 변경이 단 한 번이라도 있으면 캐시가 스스로 복구될 방법이 없다는 뜻이기도 했습니다. TTL이 있었다면 늦어도 몇 분 뒤에는 알아서 갱신됐을 텐데, 저희 쪽은 다음 정상적인 API 호출이 있기 전까지는 config::ALL이 영원히 그 상태로 남아있습니다.
해결: 놓치는 부분이 있더라도 안전하게
이번 사고의 핵심은 “코드에 버그가 있었다”가 아니라 “코드를 아예 거치지 않는 변경이 있었다”는 데 있었습니다. @CacheEvict를 아무리 잘 짜둬도 그 코드가 호출되지 않으면 소용이 없으니, 코드 안에서 버그를 찾기보다 코드 밖에서 무슨 일이 생기든 캐시가 자연스럽게 관리될 수 있는 장치를 두는 쪽으로 방향을 잡았습니다.
① 캐시 네임부터 분리한다
가장 먼저 손댄 건 캐시 구조 자체였습니다. 개별 설정과 전체 목록을 config라는 캐시 네임 하나에 키만 다르게 해서 넣어둔 게, 돌이켜보면 이번 혼란의 시작이었습니다. 캐시 네임이 같다 보니 “config 캐시”라는 말 한마디로 서로 다른 무효화 기준을 가진 두 캐시를 뭉뚱그려 생각하게 됐고, 그 틈에서 'ALL'이라는 문자열 키 하나가 존재감 없이 묻혀 있었습니다. 그래서 캐시 네임 자체를 나눴습니다.
@Service
public class ConfigQueryService {
private final ConfigRepository configRepository;
@Cacheable(cacheNames = "configItem", key = "#key")
public Config getConfig(String key) {
return configRepository.findByKey(key);
}
@Cacheable(cacheNames = "configList")
public List<Config> getAllConfigs() {
return configRepository.findAll();
}
}
이렇게 나눠두면 redis-cli로 KEYS configItem::*와 KEYS configList::*를 따로 조회할 수 있어, 어떤 캐시가 어떤 데이터를 들고 있는지가 키 목록만 봐도 드러납니다. 관리용 캐시 무효화 API를 만들 때도 캐시 네임 단위로 선택해서 비울 수 있고, 나중에 코드를 처음 읽는 동료도 “이 캐시 네임 안에 숨어 있는 특수한 키가 또 있을까”를 신경 쓸 필요가 없습니다. 같은 캐시 네임 안에서 키 하나로 성격이 다른 데이터를 구분하는 방식은 처음엔 간단해 보이지만, 무효화 기준이 서로 다른 데이터가 한 네임 아래 섞이는 순간부터는 오히려 유지보수를 더 어렵게 만든다는 걸 이번에 체감했습니다.
② TTL과 관리용 무효화 API로 이중 방어선을 둔다
캐시 네임을 나눠도 “코드를 거치지 않는 변경”이라는 근본 위험 자체는 사라지지 않습니다. 오늘은 Flyway 마이그레이션이었지만, 내일은 운영자가 급하게 실행하는 수동 쿼리나 별도 배치 작업이 같은 문제를 일으킬 수 있습니다. 그래서 원인 수정과는 별개로 두 가지 안전장치를 함께 두기로 했습니다.
| 안전장치 | 언제 도움이 되나 | 트레이드오프 |
|---|---|---|
| TTL | evict가 아예 호출되지 않거나 실패해도 결국은 만료 시점에 스스로 복구됨 | 만료 순간 여러 요청이 동시에 원본을 조회하는 캐시 스탬피드(cache stampede) 위험 |
| 관리용 캐시 무효화 API | 문제를 발견한 즉시, 재배포 없이 특정 캐시를 걷어낼 수 있음 | 사람이 상황을 인지하고 직접 호출해야 하는 수동 조치 |
TTL은 짧더라도 걸어두면 evict 로직에 다시 구멍이 생기더라도 최악의 경우 만료 시점에는 자연스럽게 복구됩니다. evict를 대체하는 게 아니라, evict가 실패했을 때를 대비한 마지막 방어선으로 두는 셈입니다. 다만 공짜는 아닙니다. 트래픽이 몰리는 시점에 하필 configList의 TTL까지 만료되면 여러 요청이 동시에 원본을 조회하려 드는 캐시 스탬피드 상황이 생길 수 있어서, TTL 값을 얼마로 잡을지는 “얼마나 자주 캐시가 비워지는가”뿐 아니라 “그 순간 원본 조회가 얼마나 무거운가”까지 함께 따져보고 있습니다.
관리용 캐시 무효화 API는 이번 사고와 더 직접적으로 맞닿아 있습니다. 저희 서비스는 멀티 테넌트 구조라 설정 캐시도 테넌트별로 나뉘어 있는데, 코드를 다시 배포하지 않고도 “특정 테넌트(혹은 전체 테넌트)의 configItem/configList 캐시를 강제로 지운다”는 요청을 보낼 수 있는 관리용 REST API를 하나 두면, Flyway로 데이터를 추가·변경하는 릴리즈가 앞으로도 있을 텐데 그때마다 배포 체크리스트에 이 API 호출 한 줄만 추가하면 됩니다. 재배포 없이 즉시 조치할 수 있는 수단이 있다는 것만으로도 다음번엔 이런 사고를 막을 수 있습니다.
정리
- Flyway 마이그레이션처럼 애플리케이션 코드를 거치지 않고 DB를 직접 건드리는 배포라면 →
@CacheEvict는 프록시를 거치지 않는 변경에는 절대 관여하지 못합니다. 배포 체크리스트에 관리용 캐시 무효화 API 호출을 못박아 두는 편이 안전합니다. - 같은 캐시 네임 안에 목록/집계용 sentinel 키(
'ALL','COUNT'같은)를 두고 싶다면 → 캐시 네임을 분리하는 편이 유지보수에 더 유리합니다. 무효화 기준이 다른 데이터를 한 캐시 네임 안에 섞어두면, 그 기준을 코드가 아니라 사람의 기억에 의존하게 됩니다. - 당장 급한 불부터 꺼야 한다면 → 관리용 캐시 무효화 API로 즉시 캐시를 걷어내는 쪽이 재배포보다 빠릅니다.
- 재발 자체를 막고 싶다면 → TTL을 짧게라도 걸어, evict가 실패하거나 아예 호출되지 않는 경로가 생기더라도 캐시가 스스로 복구되게 만들어두는 편이 안전합니다.
캐시 하나만 보고 있으면 놓치기 쉬운 문제였습니다. 코드가 잘못됐는지를 따지기 전에, 이 데이터를 바꾸는 경로가 애초에 캐시를 지울 수 있는 경로인지부터 짚어봐야 한다는 걸 이번에 배웠습니다.
참고자료
- Spring Framework Reference – Cache Abstraction: Annotations
- Redgate Flyway Documentation – Migrations
- Redis Docs – Keys and values
- Redis Blog – How to tame the thundering herd problem
