“캐시는 어렵지 않은데, 잘못 붙이면 더 큰 장애 포인트가 된다.”

이 글을 쓰게 된 계기
Spring 기반 백엔드에서 Redis를 캐시 용도로 붙이기 시작했을 때, 솔직히 이렇게 생각했다.
“DB 쿼리 전에 Redis 한 번 보고, 없으면 DB 조회해서 넣으면 끝 아닌가?”
처음에는 진짜로 그 정도만 해도 성능이 꽤나 좋아졌다.
조회 수가 많은 API에 캐시를 한 번 둘러줬더니, DB 커넥션 수가 눈에 띄게 줄고 응답 시간도 안정적으로 떨어졌다.
문제는 그다음이었다.
- 캐시 갱신 타이밍이 꼬이면서 유저에게는 이미 삭제된 데이터가 계속 보이고
- 특정 키의 TTL이 한꺼번에 만료되면서 캐시 스탬피드가 터지고
- 여러 서비스에서 같은 Redis를 쓰다 보니 키 충돌과 메모리 압박이 슬슬 보이기 시작했다.
그때부터 “그냥 Redis 붙였다”와 “캐싱 전략을 설계했다” 사이에 꽤 큰 차이가 있다는 걸 체감했다.
이 글은 그 과정을 정리하면서,
- Redis로 캐시를 쓸 때 어떤 전략들이 있는지
- 각 전략은 어떤 장단점과 위험을 가지고 있는지
- 실제 프로젝트에서는 어떤 선택을 했는지
를 한 번에 정리해보려는 시도다.
1. 왜 캐싱 전략까지 고민해야 할까?
내가 Redis를 처음 붙였을 때는 “성능” 말고는 크게 생각하지 않았다.
근데 실무에서 겪은 문제를 정리해보면, 캐시는 단순한 성능 도구가 아니라 설계 대상이라는 걸 알게 된다.
내가 특히 크게 느꼈던 지점은 세 가지였다.
- 데이터 정합성 문제
- DB에서는 이미 삭제/수정된 데이터가 Redis에 계속 남아 있어
“클라이언트에서 삭제했다고 하는데 왜 다시 살아나요?” 같은 이슈가 터진다. - 특히 관리자 콘솔과 사용자 화면이 같은 데이터를 다른 뷰로 보여줄 때,
한쪽만 최신 상태로 보이는 일이 생각보다 자주 발생했다.
- DB에서는 이미 삭제/수정된 데이터가 Redis에 계속 남아 있어
- 캐시 스탬피드(Cache Stampede)
- 인기 있는 키의 TTL이 한 지점에 몰려 있으면, 만료 순간에 동시에 수백/수천 요청이 DB로 튀어 들어간다.
- 모니터링을 켜 놓고 보면 딱 TTL 만료 타이밍에 DB QPS가 스파이크 치는 걸 눈으로 확인할 수 있다.
- 리소스/비용 이슈
- 캐시라고 해서 공짜가 아니다. Redis도 메모리 기반이라 용량과 비용을 신경 써야 한다.
- “일단 다 캐싱하자”는 전략은 거의 항상 나중에 Eviction 정책과 메모리 압박으로 부메랑처럼 돌아왔다.
그래서 “Redis를 쓴다”는 말은 곧,
“우리는 어떤 데이터에 대해, 어떤 일관성을 희생하면서, 어떤 성능을 얻을 것인가”
를 선택하는 일과 거의 같다.
그 선택을 구체적인 전략으로 내려보는 게 이 글의 핵심이다.
2. 그림 한 장으로 보는 캐싱 전략
먼저, Redis 캐싱 전략을 아주 거칠게 “읽기/쓰기/갱신 흐름” 관점에서 나눠보면 이렇게 정리할 수 있다.
- Cache-Aside (Lazy Loading)
- 애플리케이션이 먼저 캐시를 조회하고, 없으면 DB를 조회한 뒤 캐시에 채워 넣는 패턴
- Read-Through / Write-Through
- 캐시가 저장소 앞에 프록시처럼 서 있어서, 읽기/쓰기를 항상 캐시를 통해 수행하는 패턴
- Write-Back (Write-Behind)
- 쓰기는 캐시에만 반영하고, 나중에 비동기 배치로 실제 저장소에 밀어 넣는 패턴
- Refresh-Ahead
- TTL이 끝나기 전에 미리 캐시를 갱신해 두는 패턴 (배치/백그라운드 작업)
여기에 더해서 실무에서는 다음 요소들을 필수로 같이 고민하게 된다.
- 키 설계 전략: 네임스페이스, 버전, 샤딩 키 등
- TTL 전략: 데이터 성격에 따른 만료 시간, Jitter(랜덤 값) 추가
- 일관성 전략: 쓰기 시 무효화 vs 갱신, 강한/약한 일관성 선택
- 스탬피드/핫키 대응: 요청 한 번만 DB로 보내고 나머지는 대기시키는 방법, 락 활용 등
한 줄로 줄이면 이렇게 정리할 수 있다.
“무엇을 언제 캐시에 올리고, 언제까지 살려둘 것인지, 그리고 쌓인 캐시를 어떻게 정리할 것인지”를 미리 정해두는 게 캐싱 전략이다.
이제 각 전략을 조금 더 사람처럼(?) 소개해 보자.
3. 주요 캐싱 전략들을 사람처럼 소개해 보기
3-1. Cache-Aside – 필요할 때만 창고에서 꺼내는 방식
내가 가장 먼저, 그리고 가장 많이 쓰는 전략이 Cache-Aside다.
이 패턴은 말 그대로 “캐시는 옆에 두고, 필요할 때만 슬쩍 사용하는” 느낌이다.
흐름은 단순하다.
- 요청이 들어오면 캐시(Redis)에서 데이터를 먼저 찾는다.
- 있으면 바로 반환한다. (cache hit)
- 없으면 DB를 조회해서
- DB 결과를 캐시에 넣고 (set + TTL)
- 그 데이터를 반환한다. (cache miss)
코드로 적으면 대략 이런 느낌이다.
String key = "user:" + userId;
String cached = redis.get(key);
if (cached != null) {
return deserialize(cached);
}
User user = userRepository.findById(userId);
redis.setex(key, TTL_SECONDS, serialize(user));
return user;
이 패턴의 좋은 점은:
- 애플리케이션 입장에서 DB가 진실의 원천(Source of Truth)으로 남아 있고
- Redis는 단순히 “조회 결과의 복제본”이기 때문에,
캐시가 날아가도 시스템이 동작을 멈추지 않는다.
반대로 단점/주의점은:
- 쓰기/수정 때 캐시를 어떻게 갱신할지(무효화 vs 업데이트)를 코드 레벨에서 신경 써야 한다.
- 여러 서비스에서 같은 데이터를 캐시하고 있다면, 캐시 일관성 문제가 금방 드러난다.
실무에서 나는 “대부분의 읽기 캐시는 Cache-Aside를 기본값으로 두자”는 식으로 접근했다.
3-2. Write-Through – 창고 관리자에게 항상 맡겨 두는 방식
Write-Through는 “쓰기까지 캐시가 책임지는” 전략이다.
흐름은 다음과 같다.
- 애플리케이션이 데이터 쓰기를 요청할 때
- 캐시 계층이 먼저 DB에 쓰고
- 그 결과를 캐시에 갱신한 뒤
- 애플리케이션에 결과를 돌려준다.
Cache-Aside와 비교하면 차이는 정확히 여기 있다.
- Cache-Aside: 애플리케이션이 DB와 캐시를 모두 신경 쓴다.
- Write-Through: 캐시 계층이 DB 쓰기와 캐시 갱신을 함께 책임진다.
장점:
- 캐시와 DB의 일관성이 상대적으로 좋다.
- 애플리케이션 코드에서는 “항상 캐시만 보고/쓰면 된다”는 단순한 모델이 된다.
단점:
- 캐시 계층이 DB 쓰기까지 책임지기 때문에 구현 복잡도가 올라간다.
- 대부분의 애플리케이션에서는 캐시 앞에 별도의 스토리지 계층을 두는 구조를 따로 설계해야 한다.
솔직히 말하면, 일반적인 Spring 애플리케이션에서 순수 Write-Through를 “예쁘게” 구현하는 경우는 많이 보지는 못했다.
대부분은 프레임워크나 라이브러리(예: 특정 ORM 2nd level cache)가 이 패턴을 내부에서 쓰고 있는 형태였다.
3-3. Write-Back (Write-Behind) – 먼저 메모에 적어두고 나중에 정산하는 방식
Write-Back은 “쓰기 성능”에 엄청 민감할 때 선택하게 되는 전략이다.
흐름은 이렇다.
- 쓰기 요청이 오면 DB가 아니라 캐시에만 반영하고
- 일정 시간/조건이 되면 비동기로 캐시 → DB 동기화를 수행한다.
마치, 편의점 알바가 판매할 때마다 창고 재고를 즉시 업데이트하는 대신,
- 판매 내역만 노트에 적어두고
- 새벽에 몰아서 재고를 정산하는 느낌이다.
장점:
- 쓰기 요청 처리 속도가 매우 빠르다.
- 여러 번의 쓰기를 하나의 배치로 묶어서 DB I/O를 줄일 수 있다.
단점(그리고 이게 꽤 크다):
- Redis(캐시)가 장애 나면, 아직 DB에 반영되지 않은 데이터가 날아갈 수 있다.
- DB와 캐시 간 정합성을 맞추는 로직(배치, 리플레이 등)을 별도로 설계해야 한다.
개인적으로는, 업데이트가 잦고, 약간의 데이터 유실/지연을 감수할 수 있는 로그성 데이터에만 아주 제한적으로 사용할 수 있겠다는 생각이 들었다.
3-4. Refresh-Ahead – 만료되기 전에 미리 채워두는 방식
Refresh-Ahead는 스탬피드를 방지하기 위한 전략 중 하나다.
아이디어는 단순하다.
- TTL이 1시간인 캐시가 있다고 할 때
- 만료 직전에 백그라운드 작업이 미리 DB를 조회해서 캐시를 갱신해 둔다.
사용자는 항상 “따뜻한(warm) 캐시”만 보게 되고,
TTL 만료 타이밍에 몰리는 스파이크를 어느 정도 완화할 수 있다.
다만, 이 전략을 쓰려면:
- 어떤 키를 대상으로
- 어떤 주기로
- 어떤 방식으로 선제적 갱신을 할 것인지
를 꽤 구체적으로 결정해야 한다.
실제로는 정말 중요한 소수의 핫 키에만 이 패턴을 적용하는 게 현실적이었다.
4. 읽기/쓰기 흐름 따라가 보기 – Cache-Aside 기준으로
여러 패턴 중에서도, 실무에서 제일 많이 쓰는 건 결국 Cache-Aside였다.
그래서 이 패턴을 기준으로 “요청 하나가 어떻게 흐르는지”를 정리해보면, 다른 전략을 이해할 때도 기준이 된다.
4-1. 읽기 요청 하나를 끝까지 따라가기
시나리오: GET /users/{id} 요청이 들어왔을 때, 사용자 정보를 캐싱하고 싶다고 해보자.
- 컨트롤러 → 서비스에서 유저 조회 메서드를 호출한다.
- 서비스는 우선 Redis에
user:{id}키가 있는지 조회한다. - 있으면 그 값을 역직렬화해서 반환한다. (cache hit)
- 없으면 DB에서
userRepository.findById(id)로 조회한다. - 조회 결과가 있으면 Redis에
setex("user:{id}", TTL, value)로 저장한다. - 최종적으로 조회 결과를 클라이언트에게 반환한다.
여기까지는 모두가 알고 있는 흐름인데, 실무에서는 다음 지점을 하나씩 더 고민하게 된다.
- TTL을 얼마로 둘 것인가? (5분 vs 1시간 vs 24시간)
- 유저가 탈퇴/정지/닉네임 변경 등 상태가 바뀌었을 때, 언제 캐시를 갱신할 것인가?
- 없는 유저(404)를 얼마나 오래 캐시할 것인가? (Negative Cache)
4-2. 쓰기 요청과 캐시 무효화
같은 사용자에 대해 PUT /users/{id}로 프로필을 수정하는 경우를 생각해보자.
옵션은 크게 두 가지가 있다.
- 쓰기 후 캐시 삭제(Invalidate)
- DB 업데이트가 성공하면
DEL user:{id}를 호출한다. - 다음 읽기 요청에서 다시 DB를 보고 캐시를 채운다.
- DB 업데이트가 성공하면
- 쓰기 후 캐시 갱신(Update)
- DB 업데이트가 성공하면 새로운 값으로
SETEX user:{id}를 호출한다. - 다음 요청부터는 바로 최신 캐시를 보게 된다.
- DB 업데이트가 성공하면 새로운 값으로
내가 써본 결과,
- 도메인 로직이 단순하고 변경이 잦지 않은 데이터는 “삭제 후 Lazy Loading”이 훨씬 단순했다.
- 반대로 실시간성이 중요한 데이터(예: 프로필 닉네임, 노출 순서 등)는 “즉시 캐시 갱신”이 필요했다.
결국, 캐시 무효화 전략도 데이터 중요도/변경 패턴에 따라 케이스 바이 케이스로 가져가는 게 현실적이었다.
4-3. 캐시 스탬피드와 핫키 대응
한 번은 특정 리소스가 “이벤트 페이지”로 쓰이면서 갑자기 트래픽이 쏠리는 일이 있었다.
캐시 TTL이 5분이었는데, 딱 TTL 만료 타이밍마다 DB QPS가 두 배로 튀는 스파이크가 찍혔다.
이 상황을 이해해 보면:
- TTL 만료 직후, 처음 들어온 여러 요청이 동시에 캐시에서 MISS를 맞고
- 각각이 DB로 동일한 쿼리를 날리면서
- 캐시가 따뜻해지기 전까지 짧은 시간 동안 DB가 괴로워진다.
이걸 줄이기 위해 사용했던 방법은 크게 두 가지였다.
- 랜덤 TTL(Jitter)
- 기본 TTL에 랜덤 값을 섞어서, 만료 타이밍을 분산시킨다.
- 예:
TTL = 300초 + rand(0~60초)
- 분산 락 기반의 Single Flight
- 첫 번째 요청만 DB로 가도록 락을 걸고
- 나머지 요청은 락 해제까지 기다렸다가 캐시에서 읽도록 만든다.
두 번째 방식은 구현이 조금 귀찮지만,
“핫키 + 짧은 TTL” 조합에서는 꽤 효과가 좋았다.
6. 실제 프로젝트에서 어떻게 가져갔는지
실제 서비스 코드에서는 다음과 같은 순서로 캐시를 도입하는 게 현실적이었다.
5-1. 먼저 “어떤 데이터에 캐시를 쓸지”부터 고르기
처음에는 “조회가 많은 모든 API에 캐시를 붙이자”는 생각이었다.
근데 곧 깨달았다. 그렇게 하면 캐시 관리 비용이 너무 커진다.
그래서 기준을 이렇게 바꿨다.
- 읽기:쓰기 비율이 압도적으로 높은 데이터
- DB 쿼리가 상대적으로 무겁거나,
조인/집계가 많이 들어가는 조회 - 약간의 지연된 일관성을 허용해도 되는 데이터
예를 들면:
- 인기 게시글 목록
- 정적에 가까운 설정값/메타데이터
- 홈 화면에 반복적으로 노출되는 리소스들
5-2. 키 설계와 네임스페이스 정리하기
여러 서비스가 같은 Redis 클러스터를 공유하면서, 키 설계를 대충 하면 금방 지옥이 열린다.
그래서 키는 항상 아래 패턴을 강제했다.
{서비스명}:{도메인}:{버전}:{식별자}
예) feed:user-timeline:v1:123
예) user:profile:v2:42
이렇게 해두면:
- 서비스별 키를 쉽게 구분할 수 있고
- 버전 업(스키마 변경)이 필요할 때
v1→v2로 키를 분리해서 점진 전환이 가능하다.
5-3. Spring Cache vs 직접 Redis 접근
Spring에서는 @Cacheable/@CacheEvict 같은 애너테이션 기반 캐시도 지원한다.
처음에는 이게 너무 편해서 거의 모든 캐시를 여기부터 시작했다.
@Cacheable(cacheNames = "userProfile", key = "#userId")
public UserProfile getUserProfile(Long userId) { ... }
하지만 쓰다 보면 다음 같은 고민이 생겼다.
- 캐시 키가 복잡해질수록, 애너테이션만으로는 키 규칙을 일관되게 관리하기 어렵다.
- 캐시 스탬피드 방지, 분산 락, 캐시 프리로드 등은 애너테이션 모델에서 표현하기 애매하다.
그래서 결론은 대략 이렇게 정리됐다.
- 단순한 읽기 캐시:
@Cacheable로 최대한 빠르게 도입 - 핵심 도메인/핫키/고급 전략이 필요한 곳: 직접 Redis 템플릿/클라이언트로 제어
5-4. 모니터링과 인덱스/메모리 튜닝
캐시를 붙인 이후에 제일 도움됐던 건, Redis 모니터링을 제대로 보는 습관이었다.
- 키 개수/메모리 사용량
- 히트율(hit/miss)
- 가장 많이 사용되는 키 패턴
- Eviction이 발생하는지 여부
이걸 보고 나서야,
- 정말 효과 있는 캐시와
- 그냥 “심리적 안심용” 캐시를 구분할 수 있었다.
6. Redis 캐시를 직접 만지면서 배운 것들
정리하다 보니, 결국 캐시에서 가장 중요한 건 “기술 이름”보다도 어떤 위험을 감수하겠다고 합의했는지였다.
내가 기억해 두고 싶은 포인트는 대략 이렇다.
- 캐시는 결국 복제본이다.
- 진실의 원천이 무엇인지(DB? 외부 API?)를 항상 명확히 해두자.
- 장애/재시작 시 캐시를 날려도, 시스템이 복구 가능한 구조인지 먼저 확인해야 한다.
- 캐시는 일관성 대신 성능을 산다.
- “최신 데이터가 아니어도 되는 구간”을 먼저 찾는 게 중요하다.
- 그 구간을 좁게 잡을수록, 캐시는 관리가 어렵지 않다.
- TTL과 무효화가 반은 먹고 들어간다.
- TTL을 충분히 짧게 두면 정합성 문제는 줄지만, 스탬피드와 DB 부하가 올라간다.
- TTL을 길게 두면 성능은 좋은데, 잘못된 데이터가 더 오래 노출될 수 있다.
- 핫키/스탬피드는 언젠가 꼭 만난다.
- 초기에 Jitter(랜덤 TTL)와 Single Flight/락 등에 대한 대비를 해두면,
- 나중에 장애 대응 속도가 훨씬 빨라진다.
7. 마무리 – 캐시는 “조금 느린 정확함”과 “조금 빠른 부정확함” 사이의 선택이다
처음 Redis 캐시를 붙였을 때는,
“성능 개선을 위한 보너스” 정도로만 생각했다.
하지만 여러 장애와 버그를 겪고 나니,
캐시는 단순한 성능 최적화가 아니라 도메인 설계의 일부라는 생각이 든다.
캐시는 “조금 느린 정확함”과 “조금 빠른 부정확함” 사이에서,
어디까지 타협할 것인지에 대한 설계 선택이다.
이 글에서 정리한 것들을 기준으로,
- 우리 서비스에서 “캐시로 감싸고 싶은 데이터”는 무엇인지
- 그 데이터에 대해 허용할 수 있는 일관성/지연/유실의 범위는 어디까지인지
- 그에 맞는 캐싱 전략(Cache-Aside, Write-Through, Write-Back, Refresh-Ahead 등)을 어떻게 조합할 것인지
를 한 번씩 점검해 보면 좋을 것 같다.
마지막으로, 실제 프로젝트에서 캐시를 도입할 때는,
- 가장 트래픽이 높은 조회 API 하나를 고르고
- Cache-Aside 패턴으로 작은 TTL과 함께 시작해 보고
- 모니터링을 통해 효과와 부작용(스탬피드, 정합성 문제 등)을 관찰한 뒤
- 필요할 때만 전략을 확장(프리로드, 락, 키 버전 등)하는 방식으로
“작게 시작해서 점진적으로 정교하게 만들어가는 캐시”를 지향하는 게 현실적이라는 생각을 남겨두고 싶다.
'[ 기술 스택 ] > Cache' 카테고리의 다른 글
| Local Cache로 Caffeine 쓰면서 배운 것들 (0) | 2025.12.23 |
|---|