백엔드 엔지니어링수정 2026-08-18

캐시 무효화와 동시성 — TTL 설계, 3대 장애, 그리고 분산 락

캐시 무효화 방식 네 가지와 TTL 설계 원칙, Stampede·Penetration·Avalanche 방어, Spring Cache 구현 3계층의 함정, 그리고 원자 카운터와 분산 락으로 동시성을 다루는 법을 정리한다.

캐시를 붙이는 코드는 짧다. 어려운 것은 낡은 값을 언제 어떻게 지우는가이고, 그다음이 여러 요청이 동시에 같은 키를 건드릴 때 무슨 일이 벌어지는가다. 이 두 가지를 설계하지 않으면 캐시는 응답 속도를 얻는 대신 데이터 정합성을 잃는다.

이 글은 그 두 가지를 다룬다. 무효화 방식 네 가지와 TTL 설계 원칙, 캐시가 무너지는 세 가지 방식과 각각의 방어, Spring이 제공하는 세 가지 구현 계층과 그 함정, 그리고 조회수처럼 동시 갱신이 몰리는 값을 원자 연산과 분산 락으로 다루는 법이다. 캐싱 대상 선정과 자료구조 선택, 전략 네 가지의 비교는 Redis 캐시 설계에서 다룬다.

캐시 무효화

무효화 방식 네 가지

무효화란 원본이 바뀌었을 때 캐시의 옛 값(stale)을 없애는 일이다. 방식은 넷이고, 각각 다른 비용을 낸다.

방식동작장점단점
TTL 만료시간이 지나면 자동 삭제된다구현이 단순하고 키 누수가 없다TTL 동안 stale이 유지된다. 만료 순간 스탬피드가 난다
명시적 삭제(Evict)쓰기 트랜잭션에서 관련 키를 삭제한다즉시 정합 상태가 된다삭제 대상 키 목록을 관리하기 어렵다. 누락하면 영구 stale이다
버전 키video:v3:{id}처럼 버전을 키에 포함한다전체 무효화가 O(1)이다(버전만 올리면 된다)구 버전 키가 메모리에 남아 TTL을 병행해야 한다
이벤트 기반변경 이벤트를 Pub/Sub·Kafka로 전파해 각 노드가 삭제한다다중 인스턴스의 로컬 캐시까지 무효화된다인프라 복잡도가 올라간다

실무에서는 이 넷을 배타적으로 고르지 않는다. 명시적 삭제를 주 경로로 두고 TTL을 안전망으로 깔아 두는 조합이 기본형이다. 삭제를 놓쳐도 유한 시간 안에 스스로 복구되기 때문이다.

연관 캐시를 함께 지워야 한다

비디오 하나를 수정하면 단건 캐시만 지워서는 안 된다. 그 비디오가 포함된 목록 캐시도 같이 낡는다. Spring에서는 @Caching으로 묶는다.

@Caching(evict = {
    @CacheEvict(cacheNames = "video",      key = "#videoId"),
    @CacheEvict(cacheNames = "video_list", key = "#channelId")
})
public Video updateVideo(String videoId, String channelId, ...) { ... }
  • 왜 필요한가. 단건만 지우면 목록 API는 계속 옛 제목·썸네일을 반환한다. 사용자 눈에는 "수정이 안 먹었다"로 보인다.
  • 안 하면 무슨 일이 생기나. TTL이 길수록 불일치가 길게 유지되고, TTL이 없으면 영구 불일치다.

무효화가 어려운 지점은 단건이 아니라 파생 캐시다. 제목 하나를 바꾸면 단건·목록·검색 결과·랭킹 스냅샷이 함께 낡는데, 기능이 늘수록 "무엇을 함께 지워야 하는가"의 목록이 코드 여기저기로 흩어진다. @Caching으로 한 지점에 모아 선언하는 것은 성능 때문이 아니라 누락을 코드 리뷰에서 잡을 수 있게 하려는 것이다.

TTL 설계 원칙

원칙이유
TTL은 반드시 건다(무한 캐시 금지)무효화 누락 시의 안전망이며 메모리 상한을 관리한다
데이터 성격별로 다르게 준다프로필은 길게, 목록은 짧게, 랭킹은 집계 주기에 맞춘다
TTL에 지터(랜덤 편차)를 섞는다동시 만료로 인한 Avalanche를 막는다. 예를 들어 300초 ± 0~60초
인기 키는 TTL을 갱신하지 않는다무한 연장되면 stale이 영원히 유지된다

세 번째 원칙이 가장 자주 빠진다. 캐시를 한 번에 적재하면(배포 직후 워밍업, 배치 적재) 같은 시각에 들어온 키가 같은 시각에 죽는다. 지터는 그 동시성을 흩는 한 줄짜리 방어다.

캐시 3대 장애

세 장애 비교

셋 다 "DB가 직격당한다"는 결과는 같지만 발생 조건이 다르므로 방어도 다르다.

장애발생 조건DB에 가해지는 부하대응
Stampede (Thundering Herd)인기 키 하나가 만료된 순간 동시 요청이 전부 miss같은 쿼리 N개가 동시에분산 락으로 하나만 재계산, sync=true, 논리적 만료, 사전 워밍
Penetration존재하지 않는 ID를 반복 조회한다(공격 포함)캐시가 전혀 방어하지 못하고 전량 통과null 결과도 짧은 TTL로 캐싱, Bloom Filter, 입력 검증
Avalanche다수 키가 동시 만료하거나 Redis 노드가 죽는다전체 트래픽이 그대로 DB로TTL 지터, 다층 캐시(로컬+Redis), 서킷 브레이커, 레플리카·클러스터

세 장애를 가르는 질문은 "캐시에 값이 없는 이유가 무엇인가"다. 만료됐으면 Stampede, 애초에 없으면 Penetration, 캐시 전체가 사라졌으면 Avalanche다.

Stampede 대응 흐름

도식을 탭하면 확대해서 볼 수 있습니다
  • 논리적 만료(logical expiry). 실제 TTL은 길게 두고 값 안에 expireAt을 넣는다. 만료가 보이면 옛 값을 즉시 반환하고 갱신은 비동기로 돌린다. 사용자 지연이 0에 수렴하는 대신 잠시 stale을 허용한다.
  • Spring Cache의 sync = true. @Cacheable(sync = true)같은 JVM 안에서만 하나의 스레드가 계산하도록 잠근다. 인스턴스가 여러 대면 인스턴스 수만큼 DB를 때린다는 점을 반드시 알고 써야 한다. 진짜 방어는 Redis 분산 락이다.

두 번째 항목이 실무에서 오해가 잦다. sync = true는 스탬피드를 "완화"하지 "해결"하지 않는다. 인스턴스가 20대면 DB 조회가 N번에서 20번으로 줄 뿐이다.

Penetration 방어에서 주의할 점

null 캐싱은 쉬운 대신 공격자가 임의의 존재하지 않는 ID를 무한 생성하면 캐시 메모리가 오염된다. 그래서 TTL을 짧게(수십 초) 두고, ID 형식 검증(UUID 형태인지)을 앞단에서 먼저 건다. 대규모라면 Bloom Filter로 "확실히 없는 키"를 O(1)에 거른다. Bloom Filter는 "없다"는 판정만 확실하고 "있다"는 판정에 위양성이 있으므로, 통과한 요청은 여전히 DB가 확인한다.

Spring에서의 구현 — 세 계층

접근법 세 가지

Redis를 Spring에서 쓰는 방법은 추상화 수준이 다른 세 가지가 있고, 대상에 따라 셋을 섞어 쓴다.

접근대표 API추상화 수준적합한 대상
Spring Cache@Cacheable @CacheEvict @Caching높다(저장소 교체 가능)메서드 반환값 단위 캐싱. Video 조회·목록
Spring Data Redis RepositoryCrudRepository + @RedisHash @Indexed중간엔티티처럼 다루고 싶은 객체. Channel·User
RedisTemplateValueOperations SetOperations ZSetOperations낮다(Redis 명령에 직접 대응)카운터·Set·랭킹 등 자료구조를 그대로 써야 하는 것
도식을 탭하면 확대해서 볼 수 있습니다

@RedisHash가 실제로 만드는 키

@Indexed는 공짜가 아니다. 무엇이 생기는지 보면 이유가 분명해진다.

@RedisHash("channel")
public class Channel {
    @Id String id;
    @Indexed String contentOwnerId;
    String title;
    String description;
}
// channel:9744d588-...        <- Hash 본체
// channel:9744d588-...:idx    <- 이 엔티티가 속한 인덱스 목록
// channel:contentOwnerId:user <- 보조 인덱스(Set)

Redis에는 인덱스가 없으므로 Spring Data가 Set 키를 별도로 만들어 흉내 낸다. 그래서 삭제·수정 시 본체와 인덱스 키를 함께 정리해야 하고, 애플리케이션이 비정상 종료하면 인덱스에 유령 참조가 남을 수 있다. 조건 조회가 복잡해질수록 RDBMS로 넘기는 쪽이 맞다.

Spring Cache를 쓸 때의 함정

함정내용대응
자기 호출(self-invocation)같은 클래스 내부 메서드 호출은 프록시를 거치지 않아 캐시가 아예 동작하지 않는다빈을 분리하거나 자기 주입
직렬화기본 JDK 직렬화는 크고 클래스 버전에 취약하다GenericJackson2JsonRedisSerializer 등 JSON 직렬화를 지정
TTL애너테이션에 TTL 파라미터가 없다RedisCacheManager에서 cacheName별 TTL 구성
null 캐싱기본적으로 null도 캐싱된다. Penetration 방어에는 유리하나 의도치 않은 stale이 생긴다unless = "#result == null"로 제어
키 충돌파라미터가 여러 개면 기본 키 생성 규칙이 모호하다key = "#videoId"처럼 명시

첫 번째 함정은 조용히 실패한다는 점에서 가장 위험하다. 에러가 나지 않고 그냥 캐시가 없는 것처럼 동작하므로, 성능이 개선되지 않은 이유를 Redis 설정에서 찾다가 시간을 쓴다. 세 번째와 네 번째는 앞의 TTL 설계·Penetration 방어를 Spring에서 실행하는 자리이기도 하다.

조회수 — 동시성 카운터와 분산 락

조회와 조회수 증가를 분리한다

도식을 탭하면 확대해서 볼 수 있습니다

근거는 네 가지다.

  • 조회와 조회수 증가를 API로 분리한다. 조회 응답 성능과 카운팅 정확도는 요구사항이 다르다.
  • 조회수 증가마다 Video 전체를 갱신하는 것은 부하가 크다. 한 필드 때문에 행 전체가 잠기고, 그 순간 캐시도 무효화된다.
  • Redis 키를 분리한다(video:view-count). 변경 빈도가 다른 데이터를 같은 캐시 엔트리에 묶지 않는다.
  • 조회 시 카운터를 추가 조회해 결합한다. 본문 캐시는 오래, 카운터는 실시간이라는 서로 다른 신선도를 한 응답에 합칠 수 있다.

세 번째 항목이 앞 절의 무효화 문제와 직접 이어진다. 변경이 잦은 필드를 캐시 엔트리에 묶으면 그 필드 때문에 엔트리 전체가 계속 무효화된다. 키를 쪼개는 것은 무효화 빈도를 낮추는 설계다.

INCR이 안전한가

Redis는 명령을 단일 스레드로 순차 처리하므로 INCR은 read-modify-write가 쪼개지지 않는다. 반면 애플리케이션에서 GET+1SET을 하면 두 요청 사이에 lost update가 발생한다. "원자성을 어디서 보장받는가"가 카운터 설계의 전부다.

방식원자성정확도비고
RDBMS UPDATE ... SET c = c + 1보장된다정확하다행 락 경합이 있어 인기 컨텐츠에서 병목이 된다
앱에서 GET/SET깨진다유실된다쓰지 않는다
Redis INCR보장된다정확하다(Redis 유실 범위 안에서)이 프로젝트의 선택
Redis PFADD(HyperLogLog)보장된다근사값이다(오차 약 0.81%)UV 집계용. 메모리 12KB 고정

중복 조회를 막아야 한다면 SETEX view:{videoId}:{userId} 1800 1 같은 중복 방지 키를 먼저 걸고, 성공했을 때만 카운터를 올린다.

분산 락

여러 인스턴스가 같은 자원을 동시에 갱신할 때 필요하다. 캐시 재계산, 배치 flush, 재고 차감이 대표적이다.

SET lock:video:{id} {randomToken} NX PX 3000     # 획득: 없을 때만, 3초 자동 만료
EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"
                                                  # 해제: 내 토큰일 때만 삭제(Lua 로 원자화)
요소이유
NX없을 때만 설정한다 — 락의 상호 배제 자체다
PX(만료)락 보유자가 죽어도 영원히 잠기지 않게 한다
랜덤 토큰만료 후 남의 락을 실수로 해제하는 사고를 막는다
Lua로 해제GETDEL 사이의 경쟁 조건을 없앤다
  • Redisson RLock 은 위를 감싸고 watchdog으로 작업이 길어지면 TTL을 자동 연장한다. 직접 구현보다 이쪽이 낫다.
  • Redlock 논쟁. 다중 마스터 기반 Redlock은 GC 정지·시계 오차 상황에서 상호 배제가 깨질 수 있다는 비판(Kleppmann)이 있다. 결론은 "Redis 락은 성능 최적화용이지, 정합성이 돈과 직결되는 곳의 최후 방어선이 아니다"이다. 금액·재고 확정은 DB 트랜잭션·유니크 제약으로 마무리한다.

락에 만료를 거는 순간 상호 배제는 시간 가정에 의존하게 된다. 락을 잡은 프로세스가 GC로 길게 멈추면, 본인은 락을 쥐고 있다고 믿는 동안 TTL이 지나 다른 프로세스가 같은 락을 얻는다. 그래서 틀리면 돈이 나가는 자원은 최종 방어를 DB에 둔다UPDATE stock SET qty = qty - 1 WHERE id = ? AND qty > 0 같은 조건부 갱신이나 유니크 제약이 그 자리다. 분산 락은 그 앞에서 불필요한 DB 경합을 줄이는 최적화 계층으로 쓴다. 그 최종 방어가 DB 안에서 무엇으로 성립하는지 — 조건부 갱신이 왜 경합 상황에서도 음수를 만들지 않는지, 격리수준과 락이 각각 무엇을 보장하는지 — 는 트랜잭션과 동시성 제어에서 다룬다.

정리

문제1차 방어안전망
원본이 바뀌었는데 캐시가 낡았다쓰기 시 명시적 삭제(@CacheEvict), 연관 키는 @Caching으로 묶는다전 키에 TTL 강제
인기 키 만료 순간 DB가 직격당한다분산 락으로 하나만 재계산논리적 만료로 옛 값 반환
없는 키를 반복 조회한다입력 형식 검증null 짧은 TTL 캐싱, Bloom Filter
키가 한꺼번에 죽는다TTL 지터다층 캐시, 서킷 브레이커
동시 갱신에 값이 유실된다Redis 원자 연산(INCR)확정이 필요한 값은 DB 조건부 갱신

여기까지가 "캐시를 지키는 법"이다. 남은 질문은 Redis에 무엇을 담고 무엇을 담지 않을 것인가, 그리고 담은 것을 어떻게 운영할 것인가다. 좋아요·구독을 집합 연산으로 푸는 법, 운영 중 캐시를 다룰 때의 함정, 댓글을 Redis가 아닌 저장소에 두는 판단은 Redis에 담을 것과 담지 않을 것에서 다룬다.