Skip to main content
Summary

캐시는 빠른 조회보다 무효화가 어렵다

Redis를 캐시로 도입하는 이유는 단순하다. 데이터베이스보다 빠르게 읽고, 반복 조회를 줄이고, 서비스 응답 시간을 낮추기 위해서다. 하지만 실제 운영에서 어려운 부분은 값을 넣는 일이 아니다. 언제 지울지, 얼마나 오래 믿을지, 원본 데이터와 틀어졌을 때 어떻게 회복할지가 더 어렵다.

캐시를 이해할 때는 먼저 역할을 분리해야 한다. 보통 원본 데이터는 MySQL, PostgreSQL 같은 데이터베이스에 있다. 이를 Source of Truth라고 부른다. Redis 캐시는 그 데이터를 빠르게 읽기 위한 복사본이다. 따라서 캐시는 언제든 비어 있을 수 있고, 오래된 값을 담고 있을 수도 있으며, 장애 시 버려도 다시 만들 수 있어야 한다.

캐시에서 자주 쓰는 용어는 다음과 같다.

  • Cache Hit: Redis에 원하는 값이 있어 바로 응답하는 상황

  • Cache Miss: Redis에 값이 없어 원본 데이터베이스에서 읽어야 하는 상황

  • Stale Data: 원본은 바뀌었지만 캐시는 아직 예전 값을 가진 상황

  • Invalidation: 캐시 값을 삭제하거나 더 이상 쓰지 않도록 만드는 작업

캐시 설계의 핵심은 Hit 비율을 높이는 것이다. 하지만 그것만으로는 부족하다. 틀린 데이터를 오래 보여주면 빠른 응답은 오히려 장애가 된다. 결국 좋은 캐시는 "얼마나 빨리 응답할 것인가"와 함께 "언제까지 이 값을 믿어도 되는가"를 같이 설계한 결과물이다.

캐시 전략과 데이터 일관성AI 생성 이미지

대표적인 캐시 패턴 비교

캐시 패턴은 읽기와 쓰기를 누가 책임지는지에 따라 나뉜다. Redis를 애플리케이션에서 직접 사용할 때 가장 많이 쓰는 방식은 Cache-Aside다.

Cache-Aside

Cache-Aside는 애플리케이션이 캐시 조회, 원본 조회, 캐시 저장, 캐시 삭제를 직접 처리하는 방식이다. Redis 공식 문서에서도 일반적인 흐름을 "먼저 캐시를 읽고, 없으면 원본 저장소에서 읽은 뒤 Redis에 저장하며, 갱신 시에는 원본을 먼저 쓰고 캐시를 무효화한다"는 형태로 설명한다.1

읽기 흐름은 다음과 같다.

1. GET cache:product:1001
2. 값이 있으면 반환
3. 값이 없으면 DB 조회
4. SET cache:product:1001 <value> EX 300
5. 응답 반환

쓰기 흐름은 보통 다음과 같다.

1. DB의 product:1001 수정
2. DEL cache:product:1001

이 방식은 단순하고 제어하기 쉽다. 필요한 데이터만 캐시에 올라가므로 메모리도 비교적 효율적이다. 대신 캐시 미스가 발생하면 애플리케이션 코드가 DB 조회와 캐시 저장을 직접 처리해야 한다. 또한 캐시 무효화 순서를 잘못 설계하면 오래된 데이터가 남을 수 있다.

Read-Through

Read-Through는 애플리케이션이 캐시만 읽는 것처럼 보이고, 캐시 계층이 원본 조회까지 대신 처리하는 방식이다. 애플리케이션 입장에서는 get(product:1001)만 호출하면 된다. 값이 없을 때 DB에서 읽고 Redis에 채우는 책임은 캐시 라이브러리나 별도 계층이 맡는다.

이 방식은 코드가 깔끔하지만, 캐시 계층이 복잡해진다. 어떤 데이터 소스를 읽을지, 오류가 났을 때 어떻게 fallback할지, TTL을 어떻게 줄지 정책이 숨어 버리기 쉽다. 팀 전체가 같은 캐시 프레임워크를 사용하고, 캐시 정책을 한곳에서 관리할 수 있을 때 효과가 크다.

Write-Through

Write-Through는 데이터를 쓸 때 원본 데이터베이스와 캐시를 함께 갱신하는 방식이다.

1. DB 수정
2. Redis 캐시도 같은 값으로 갱신
3. 둘 다 성공하면 응답

읽기 일관성은 좋아진다. 갱신 직후에도 Redis에 최신 값이 들어 있기 때문이다. 하지만 쓰기 지연 시간이 늘어나고, 실제로 거의 읽히지 않는 데이터까지 캐시에 들어갈 수 있다. 또한 DB 갱신은 성공했지만 캐시 갱신이 실패한 경우, 또는 반대로 캐시 갱신 후 응답 전에 장애가 난 경우를 어떻게 보정할지 정해야 한다.

Write-Behind

Write-Behind는 먼저 캐시에 쓰고, 나중에 비동기로 DB에 반영하는 방식이다. 쓰기 응답이 매우 빠르다는 장점이 있다. 하지만 Redis 장애, 비동기 작업 실패, 순서 꼬임이 발생하면 원본 데이터가 손실되거나 뒤늦게 덮어써질 수 있다.

그래서 주문, 결제, 계좌 잔액처럼 강한 정합성이 필요한 데이터에는 위험하다. 로그성 데이터, 임시 집계, 실패해도 재생 가능한 이벤트처럼 손실을 복구할 수 있는 영역에서만 신중하게 사용해야 한다. Redis를 이 방식의 버퍼로 쓴다면 영속성, 재처리, 중복 처리, 순서 보장까지 함께 설계해야 한다.

패턴읽기 특징쓰기 특징적합한 상황주의할 점
Cache-Aside앱이 Redis 조회 후 미스 시 DB 조회DB 갱신 후 캐시 삭제일반적인 API 응답 캐시무효화 순서와 TTL 관리 필요
Read-Through캐시 계층이 미스 처리별도 정책 필요공통 캐시 계층이 있는 서비스정책이 숨으면 디버깅 어려움
Write-Through캐시에 최신 값 유지DB와 캐시를 함께 갱신읽기 직후 최신성이 중요한 데이터쓰기 지연, 실패 보정 필요
Write-Behind캐시 중심으로 빠르게 응답DB 반영은 비동기임시 집계, 버퍼링데이터 손실과 순서 문제

캐시에서 자주 만나는 문제들

캐시 미스

캐시 미스는 자연스러운 현상이다. 처음 조회하는 데이터, TTL이 만료된 데이터, 방금 무효화된 데이터는 Redis에 없을 수 있다. 문제는 미스가 갑자기 많아질 때다. 배포 직후 캐시가 비어 있거나, 인기 데이터 TTL이 동시에 만료되거나, Redis 장애로 모든 요청이 DB로 몰리면 데이터베이스가 버티지 못할 수 있다.

그래서 캐시는 "없으면 DB에서 읽으면 된다"로 끝나지 않는다. DB가 감당할 수 있는 미스 양을 계산하고, 대량 미스가 생겼을 때의 보호 장치를 둬야 한다. 캐시를 붙였다는 이유로 DB 용량 산정을 잊어버리면, Redis가 흔들리는 순간 더 큰 장애로 이어질 수 있다.

캐시 스탬피드

Cache Stampede는 인기 키 하나가 만료되는 순간 많은 요청이 동시에 DB로 몰리는 현상이다. 예를 들어 cache:home:ranking 키가 5분마다 만료된다고 하자. 만료 직후 수천 명이 메인 화면을 열면 모두 Redis에서 미스를 보고 DB나 집계 시스템을 호출할 수 있다. 이 현상은 thundering herd라고도 부르며, 지연 시간 증가나 장애로 이어질 수 있다.2

완화 방법은 세 가지가 대표적이다.

첫째, TTL 랜덤화다.

기본 TTL: 300초
실제 TTL: 300초 + random(0~60초)

모든 인기 키가 정확히 5분마다 동시에 만료되면 위험하다. TTL에 작은 흔들림, 즉 지터를 주면 만료 시점이 분산된다. 특히 같은 배치 작업에서 여러 키를 한 번에 만들 때 유용하다.

둘째, Mutex Lock이다. 캐시 미스가 발생했을 때 모든 요청이 DB를 읽지 않고, 하나의 요청만 값을 다시 만들게 한다.

1. GET cache:product:1001 → miss
2. SET lock:cache:product:1001 <token> NX PX 3000
3. 락 획득 성공 → DB 조회 후 캐시 저장, 락 해제
4. 락 획득 실패 → 잠시 대기 후 캐시 재조회

락을 사용할 때는 반드시 짧은 만료 시간을 둬야 한다. 값을 재생성하던 서버가 죽어도 락이 영원히 남으면 안 된다. 또한 락 해제 시에는 자신이 만든 락인지 확인해야 한다. 단순히 DEL lock:key를 호출하면 다른 요청이 새로 잡은 락을 지울 수 있다. 이 문제는 Lua 스크립트로 토큰 비교와 삭제를 하나의 원자적 작업으로 묶어 해결할 수 있다.

셋째, Early Refresh다. TTL이 완전히 끝날 때까지 기다리지 않고, 남은 시간이 임계값보다 작으면 백그라운드에서 미리 갱신한다.

TTL이 300초인 데이터
남은 TTL이 30초 이하가 되면 비동기 refresh 시작
사용자에게는 기존 캐시 값을 계속 응답

이 방식은 약간 오래된 값을 잠깐 허용할 수 있는 화면에 적합하다. 예를 들어 인기 게시글, 추천 상품, 대시보드 집계처럼 초 단위 정확성이 중요하지 않은 데이터에 잘 맞는다. 다만 갱신 작업이 실패했을 때 너무 오래된 값을 계속 내보내지 않도록 최대 허용 시간을 따로 두는 편이 좋다.

캐시 관통

Cache Penetration, 즉 캐시 관통은 존재하지 않는 데이터를 계속 조회해서 매번 Redis를 지나 DB까지 도달하는 문제다. 예를 들어 공격자나 버그가 product:-1, product:999999999처럼 없는 ID를 계속 요청하면 Redis에는 값이 없고, DB도 매번 조회된다.

해결책은 Negative Caching이다. "없음"이라는 결과도 짧게 캐싱한다.

SET cache:product:999999999 "__NULL__" EX 30

단, 없는 값을 너무 오래 캐싱하면 실제로 데이터가 생성된 뒤에도 계속 없다고 응답할 수 있다. 그래서 negative cache는 보통 10초, 30초처럼 짧은 TTL을 둔다. 입력 ID 검증, 요청 제한, 존재 가능한 ID 범위 확인도 함께 적용해야 한다.

캐시 오염

캐시 오염은 가치가 낮거나 잘못된 데이터가 캐시 공간을 차지하는 문제다. 한 번만 조회될 데이터를 모두 캐싱하면 정작 자주 쓰는 키가 밀려날 수 있다. 잘못 설계된 키 때문에 사용자 A의 데이터가 사용자 B에게 보이는 것도 넓은 의미의 캐시 오염이다.

이를 줄이려면 캐시할 대상을 선별해야 한다. 모든 조회 결과를 자동으로 캐싱하지 말고, 반복 조회 가능성이 높은 데이터부터 적용한다. 키에는 사용자, 테넌트, 권한, 언어 같은 응답 차이를 만드는 요소를 반드시 포함한다.

나쁜 예: cache:profile
좋은 예: cache:user:1001:profile:v1
좋은 예: cache:tenant:acme:user:1001:profile:v1

특히 권한에 따라 응답 내용이 달라지는 API는 조심해야 한다. 같은 사용자 프로필이라도 관리자에게 보이는 필드와 일반 사용자에게 보이는 필드가 다르다면, 그 차이가 캐시 키나 캐시 대상 선정에 반영되어야 한다.

DB 갱신과 캐시 무효화 순서

캐시 일관성 문제는 대부분 쓰기 시점에 발생한다. 다음 두 가지 순서를 비교해 보자.

먼저 캐시를 지우고 DB를 갱신하는 경우

1. DEL cache:product:1001
2. DB product:1001 가격 변경

겉보기에는 괜찮아 보이지만 위험하다. DEL 직후 다른 요청이 들어오면 캐시 미스를 보고 아직 변경 전인 DB 값을 읽을 수 있다. 그 요청이 예전 값을 다시 Redis에 저장한 뒤 DB 변경이 완료되면, Redis에는 오래된 값이 남는다. 이 값은 TTL이 끝날 때까지 계속 응답될 수 있다.

먼저 DB를 갱신하고 캐시를 지우는 경우

1. DB product:1001 가격 변경
2. DEL cache:product:1001

일반적으로 이 순서가 더 안전하다. DB가 먼저 최신 상태가 되고, 이후 캐시를 삭제하면 다음 읽기 요청은 DB에서 최신 값을 가져와 다시 캐시한다. Cache-Aside에서 흔히 권장되는 흐름도 이 방식이다.1

하지만 이것도 완벽하지는 않다. DB 갱신은 성공했는데 DEL이 실패하면 Redis에는 오래된 값이 남는다. DB 갱신과 DEL 사이의 아주 짧은 시간에는 기존 캐시 값이 응답될 수도 있다. 또 매우 드문 타이밍이지만, 어떤 요청이 예전 DB 값을 읽은 뒤 캐시 저장을 늦게 수행하면 DB 갱신 후 캐시 삭제보다 나중에 오래된 값을 써 넣을 수도 있다.

이런 문제를 줄이는 방법은 다음과 같다.

  • 모든 캐시 값에 TTL을 둬서 오래된 값이 영원히 남지 않게 한다.

  • 캐시 삭제 실패를 로그로 남기고 재시도 큐에서 다시 처리한다.

  • 중요한 데이터는 DB 변경 이벤트나 outbox 패턴으로 무효화를 보장한다.

  • 값에 version, updatedAt을 포함해 더 오래된 값이 캐시에 덮어써지지 않도록 한다.

  • 필요하다면 짧은 시간 뒤 같은 키를 한 번 더 삭제하는 지연 삭제 방식도 검토한다.

  • 정말 강한 일관성이 필요한 요청은 캐시를 우회하고 DB를 직접 읽는다.

캐시는 일관성을 완전히 보장하는 장치가 아니다. 일관성이 필요한 범위를 정하고, 그 범위 밖의 오래된 값은 TTL과 재시도로 회복되게 만드는 장치다.

운영에서의 기본 원칙

Redis 캐시는 다음 원칙으로 시작하면 안전하다.

첫째, 캐시 데이터에는 가능하면 TTL을 둔다. 영구 캐시는 무효화 누락이 생겼을 때 장애가 길어진다.

둘째, 같은 종류의 키라도 TTL을 완전히 같게 두지 않는다. 인기 키가 많다면 랜덤한 지터를 추가한다.

셋째, 인기 키는 미스 순간을 설계한다. Mutex Lock, Early Refresh, stale 응답 허용 여부를 미리 정한다.

넷째, Redis 장애 시 동작을 정한다. 모든 요청이 즉시 DB로 몰리면 Redis 장애가 DB 장애로 번진다. 일부 기능은 임시 기본값을 반환하고, 일부 요청은 rate limit을 걸고, 핵심 요청만 DB로 보내는 식의 우회 정책이 필요하다.

다섯째, 캐시 지표를 본다. Hit ratio만 보지 말고 miss 증가, DB fallback 수, 인기 키 만료 시점, lock 획득 실패, negative cache 비율을 함께 확인해야 한다. 특정 키 하나가 전체 트래픽의 대부분을 차지하는지도 함께 보는 것이 좋다.

캐시는 성능 최적화 도구이지만, 잘못 쓰면 장애 증폭기가 된다. 좋은 캐시 설계는 "무엇을 저장할까"보다 "언제 틀릴 수 있고, 얼마나 빨리 바로잡을까"를 먼저 묻는다.

참고

1Redis cache-aside

2Caching Strategies That Scale

책 목록

Redis 입문과 실전 운영

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기