캐시·Redis — 같은 걸 두 번 계산하지 않기
매 요청마다 똑같은 무거운 쿼리를 친다면, 그건 같은 계산을 끝없이 반복하는 낭비다. 캐시는 한 번 구한 값을 가까이 보관해 두었다가 다음에 그대로 꺼내 줌으로써 DB 부하와 지연을 줄인다. 그런데 캐시는 "쓰면 빨라진다"로 끝나는 단순한 물건이 아니다 — 언제 채우고 언제 비울지, Redis가 단일 스레드인데 왜 그렇게 빠른지, 그리고 캐시가 오히려 DB를 죽이는 세 가지 사고(스탬피드·페네트레이션·어밸런치)까지 알아야 제대로 쓴다. 그 순서로 따라가 보자.
가장 흔한 패턴 — 캐시 어사이드
가장 널리 쓰이는 방식은 *캐시 어사이드(look-aside)*다. 읽을 때 캐시를 먼저 보고, 있으면(히트) 그대로 쓰고 없으면(미스) DB에서 읽어 캐시에 채운 뒤 반환한다. 쓸 때는 보통 DB를 갱신하고 *캐시를 무효화(evict)*해 다음 조회 때 새로 채우게 한다. 핵심은 애플리케이션이 직접 캐시와 DB를 다룬다는 점이다(그래서 "look-aside"다). 왜 이게 기본일까? 캐시에 없는 데이터는 안 채우니 메모리를 아끼고(실제로 읽힌 것만 캐시된다), 캐시가 죽어도 DB로 동작이 유지되어 가용성이 좋다. 대신 첫 조회는 미스라 느리고, 무효화 타이밍을 앱이 책임진다는 부담이 따른다 — 바로 이 무효화가 잠시 뒤 보듯 만만치 않다.
쓸 때 캐시를 어떻게 다루나 — 쓰기 전략
쓰기 경로에서 캐시를 다루는 방식은 몇 가지로 갈린다. Write-Through는 DB를 갱신할 때 캐시도 동시에 갱신해 항상 최신이지만 쓰기가 약간 느리고, Write-Behind는 캐시에 먼저 쓰고 나중에 비동기로 DB에 반영해 쓰기는 빠르지만 유실 위험과 복잡성이 따른다. Write-Around는 DB에만 쓰고 캐시는 조회할 때 채워, 쓰고 잘 안 읽는 데이터에 어울린다. 어느 전략이든 결국 부딪히는 진짜 어려운 문제는 무효화다 — "캐시 무효화는 컴퓨터 과학의 두 어려운 문제 중 하나"라는 농담이 있을 정도다. 그래서 보통 TTL을 짧게 두어 자동 만료시키거나(최신성과 부하의 트레이드오프), 변경 이벤트로 정밀하게 무효화한다.
Spring에서 선언적으로 — @Cacheable
Spring은 캐시 구현(Redis·Caffeine)을 추상화해, 애너테이션만으로 캐시를 건다.
@Cacheable(value = "product", key = "#id") // 미스면 실행·저장, 히트면 메서드 생략
public Product find(Long id) { ... }
@CacheEvict(value = "product", key = "#p.id") // 갱신 시 무효화
public void update(Product p) { ... }
여기서 익숙한 함정이 다시 등장한다. @Cacheable도 02·06편의 AOP 프록시로 동작하기 때문에, 자기 호출(this.find())에는 캐시가 걸리지 않는다 — @Transactional과 한 글자도 다르지 않은 프록시 함정이다. 대신 좋은 점은, 백엔드만 *Caffeine(로컬)*과 Redis(분산) 사이에서 갈아 끼우면 코드는 그대로라는 것이다. 그럼 그 Redis가 대체 무엇이길래 캐시의 대명사가 됐는지, 내부까지 보자.
Redis — 단일 스레드인데 왜 빠른가
Redis는 메모리에 데이터를 두는 키-값 저장소인데, 단순 캐시를 넘어 자료구조 서버다. String뿐 아니라 객체를 필드별로 담는 Hash, 큐로 쓰는 List, Set, 랭킹·리더보드에 쓰는 Sorted Set, Stream, Bitmap 같은 구조를 명령 한 번으로 다룬다. 그리고 명령을 한 번에 하나씩 처리하는 단일 스레드라, 개별 명령이 원자적이다(INCR·SETNX 등) — 그래서 분산 락·카운터·레이트 리밋에 경쟁 없이 쓰기 좋다.
"단일 스레드인데 빠르다고?"라는 의문이 자연스럽다. 답은 세 가지가 맞물린다. 첫째 모든 데이터가 메모리에 있어 디스크 I/O가 없고, 둘째 — 단일 스레드라 락도 컨텍스트 스위칭도 없어 각 명령이 나노초 단위로 끝난다(동시성을 포기한 대가로 경합 비용 0을 얻은 것이다), 셋째 수많은 연결은 *I/O 멀티플렉싱(epoll/kqueue)*으로 한 스레드가 논블로킹하게 다룬다(리액티브 편의 이벤트 루프와 같은 발상이다). 즉 Redis는 CPU 바운드가 아니라 메모리·네트워크 바운드라, 한 스레드로도 초당 수십만 명령을 처리한다. (출처: Why Redis is so fast.)
캐시라도 재시작 복구가 필요하면 디스크에 남긴다. RDB는 특정 시점 스냅샷을 — 자식 프로세스를 fork해 떠서 메인 스레드는 계속 일하게 한다(작고 빠르지만 최근 쓰기를 잃을 수 있다). AOF는 모든 쓰기 명령을 로그로 append하며 fsync 주기를 고를 수 있다(매 쓰기=안전하나 느림, 매초=균형, 안 함=빠르나 위험). 메모리가 한계에 닿으면 maxmemory-policy로 축출하는데, LRU(오래 안 쓴 것)·LFU(적게 쓴 것 — 인기 항목이 반복 접근되는 워크로드에 유리)·volatile-*(TTL 있는 키만)·noeviction(가득 차면 에러) 중 고른다. 수평 확장은 Redis Cluster가 키를 16384개 해시 슬롯으로 나눠 노드들이 슬롯 범위를 나눠 갖는 식으로 한다. (출처: Redis Persistence.)
로컬이냐 분산이냐
캐시를 어디에 둘지도 선택이다. *로컬(인프로세스, Caffeine)*은 그 JVM 안에만 두니 네트워크 비용이 0이라 가장 빠르지만, 인스턴스마다 따로라 불일치와 중복 보관이 생긴다. *분산(Redis)*은 여러 인스턴스가 공유해 일관성에 유리하나 네트워크 왕복 비용이 든다. 그래서 자주 쓰는 건 로컬(L1)로 빠르게, 공유는 Redis(L2)로 두는 *다층(near cache)*을 함께 쓰기도 한다.
캐시가 DB를 죽이는 세 가지 — 스탬피드·페네트레이션·어밸런치
캐시의 진짜 위험은 캐시가 빠질 때 드러난다. 첫째 캐시 스탬피드(thundering herd) — 인기 키가 만료되는 바로 그 순간 수천 요청이 동시에 미스가 나 일제히 DB로 몰려 DB를 무너뜨린다. 막는 길은 셋이다 — TTL에 지터를 줘 만료 시점을 흩뜨리고(3600 + random(-300,300)), *같은 키의 동시 미스를 한 번만 계산(single-flight)*하고 나머지는 그 결과를 기다리게 하며, *만료돼도 일단 헌 값을 주고 백그라운드로 갱신(stale-while-revalidate)*한다(더 정교하게는 TTL 전에 확률적으로 미리 갱신하는 XFetch도 있다). 둘째 캐시 어밸런치 — 많은 키가 한꺼번에 만료되거나 Redis 자체가 다운돼 부하가 통째로 DB로 쏠리는 것으로, 만료 시각 분산과 *Redis 고가용성(복제·클러스터)*으로 대비한다. 셋째 캐시 페네트레이션 — 존재하지도 않는 키를 계속 조회하면 캐시도 DB도 매번 미스라 캐시가 무용지물이 되는데, 없음(null)도 캐시하거나 블룸 필터(존재하는 키 집합을 메모리 효율적으로 들고, 없는 키 요청을 캐시·DB 닿기 전에 거른다 — 거짓 양성은 있어도 거짓 음성은 없어 안전하다)로 막는다. (출처: Cache stampede 대응, Bloom filter로 페네트레이션 방지.)
정리하면, 캐시는 같은 계산을 반복하지 않으려는 도구다. 가장 흔한 어사이드는 캐시 먼저 보고 미스면 DB에서 채우며, 쓰기 전략과 무효화가 진짜 어려운 부분이다(TTL·이벤트). Spring @Cacheable은 프록시 기반이라 자기 호출 함정을 공유하고 백엔드를 자유롭게 갈아 끼우며, Redis는 메모리 + 단일 스레드(경합 0) + I/O 멀티플렉싱으로 빠르고 RDB/AOF로 복구하며 *클러스터(16384 슬롯)*로 확장한다 — 단, 스탬피드·어밸런치·페네트레이션으로 캐시가 DB를 덮치지 않게 지터·single-flight·stale-while-revalidate·null 캐시·블룸 필터를 챙겨야 한다.