DB 앞단 캐싱 전략 — Redis 캐시 레이어와 무효화
Cache-Aside·Write-Through 같은 캐시 읽기/쓰기 패턴과 무효화 전략, DB-캐시 정합성 문제, 그리고 캐시 스탬피드·침투·핫키 장애 대응까지 Redis 캐시 레이어 관점으로 정리한다.
DB는 대개 시스템에서 가장 먼저 병목이 된다. 읽기 요청이 몰리면 커넥션이 마르고, 같은 쿼리가 초당 수천 번씩 디스크와 옵티마이저를 두드린다. 그런데 그중 상당수는 방금 전과 똑같은 답을 돌려받는다. 캐시는 이 반복되는 읽기를 DB 앞단에서 가로채 응답을 메모리에서 돌려주는 레이어다.
문제는 캐시를 넣는 순간 “데이터가 두 곳에 있다”는 새로운 골칫거리가 생긴다는 것이다. 이 글은 캐시 읽기/쓰기 패턴, 무효화, DB-캐시 정합성, 그리고 대표적인 장애 패턴을 Redis 캐시 레이어 관점에서 정리한다.
1. 왜 캐시인가, 무엇을 캐시할 것인가
캐시의 목적은 단순하다. 비싼 조회를 싼 조회로 바꾼다. DB 쿼리(수 ms~수십 ms) 대신 인메모리 조회(수십 µs~1ms 미만)로 응답하고, 그만큼 DB의 부하와 커넥션 점유를 줄인다.
하지만 아무 데이터나 캐시하면 손해다. 캐시가 잘 맞는 데이터는 다음 성질을 가진다.
- 읽기가 쓰기보다 압도적으로 많다 — 한 번 채워두면 여러 번 재사용된다(높은 hit ratio).
- 자주 바뀌지 않는다 — 무효화 빈도가 낮아 stale 위험이 작다.
- 약간의 지연 반영을 견딜 수 있다 — 상품 정보, 카테고리, 설정값처럼 “몇 초 늦어도 되는” 데이터.
반대로 매 요청마다 정확한 최신값이 필요한 데이터(잔액, 재고 차감, 결제 상태)는 캐시가 오히려 위험하다. 캐시할지 말지는 결국 “이 데이터가 잠깐 낡아도 괜찮은가”라는 질문으로 귀결된다.
캐시는 성능 최적화이지 정합성 보장 장치가 아니다. “낡은 값을 잠깐 보여도 되는가”를 먼저 판단하고 넣는다.
2. 캐시 읽기/쓰기 패턴
캐시를 애플리케이션·DB와 어떻게 배선하느냐에 따라 흐름과 트레이드오프가 달라진다. 네 가지 대표 패턴을 본다.
2.1 Cache-Aside (Lazy Loading)
가장 흔한 패턴. 애플리케이션이 캐시를 직접 다루고, 캐시에 없을 때만 DB에서 읽어 채운다.
1
2
3
4
5
6
7
8
읽기:
1. 캐시 조회
2. HIT → 값 반환
3. MISS → DB 조회 → 캐시에 저장(TTL) → 값 반환
쓰기:
1. DB 갱신
2. 캐시 키 삭제(또는 갱신)
- 장점: 구현이 단순하고, 실제로 요청된 데이터만 캐시에 올라온다(필요한 것만 lazy 적재).
- 단점: 첫 요청은 항상 MISS(cold start). 쓰기 후 삭제와 다음 읽기 사이에 정합성 틈이 생긴다.
대부분의 서비스가 여기서 출발한다. 애플리케이션 코드에 캐시 로직이 노출되지만, 그만큼 제어권도 애플리케이션에 있다.
2.2 Read-Through
캐시 계층(또는 캐시 라이브러리)이 MISS 시 자기가 알아서 DB에서 읽어 채운다. 애플리케이션은 캐시만 바라본다.
1
2
3
읽기:
1. 애플리케이션 → 캐시 조회
2. MISS 시 캐시 레이어가 DB 로더 호출 → 채움 → 반환
Cache-Aside와 흐름은 비슷하지만 적재 책임이 캐시 계층으로 옮겨간다. 로직이 한곳에 모여 애플리케이션 코드가 깔끔해지는 대신, 그런 로딩을 지원하는 캐시 추상화(로더 등록 등)가 필요하다.
2.3 Write-Through
쓰기 시 캐시와 DB를 동기적으로 함께 갱신한다.
1
2
3
4
쓰기:
1. 캐시 갱신
2. DB 갱신(동기)
→ 둘 다 성공해야 완료
- 장점: 캐시가 항상 최신이라 읽기 정합성이 좋다.
- 단점: 모든 쓰기가 DB까지 동기로 기다려 쓰기 지연이 커진다. 읽히지 않을 데이터까지 캐시에 올라와 메모리를 낭비할 수 있다(보통 Read-Through와 짝을 이룬다).
2.4 Write-Behind (Write-Back)
쓰기를 일단 캐시에만 반영하고, DB 반영은 비동기로 나중에 배치·큐로 처리한다.
1
2
3
쓰기:
1. 캐시 갱신 → 즉시 응답
2. 큐에 적재 → 워커가 모아서 DB에 flush(비동기)
- 장점: 쓰기 지연이 가장 짧고, 쓰기를 모아 DB 부하를 낮춘다(write coalescing).
- 단점: 캐시가 DB에 flush되기 전에 죽으면 데이터 유실 위험. 정합성·내구성을 상당 부분 캐시 계층에 맡기게 된다. 카운터·집계처럼 유실 허용 범위가 넓은 곳에 제한적으로 쓴다.
패턴 비교
| 패턴 | 적재/갱신 주체 | 읽기 정합성 | 쓰기 지연 | 유실 위험 | 대표 용도 |
|---|---|---|---|---|---|
| Cache-Aside | 애플리케이션 | 보통(틈 존재) | 낮음 | 없음 | 범용 읽기 캐시 |
| Read-Through | 캐시 계층 | 보통 | 낮음 | 없음 | 로직 집중형 읽기 캐시 |
| Write-Through | 애플리케이션/캐시 | 높음 | 높음 | 없음 | 읽기 정합성 중요 |
| Write-Behind | 캐시 계층 | 높음(로컬) | 매우 낮음 | 있음 | 고빈도 쓰기·집계 |
읽기 패턴(Cache-Aside/Read-Through)과 쓰기 패턴(Write-Through/Write-Behind)은 배타적이지 않다. 실무에서는 “Cache-Aside 읽기 + write 시 키 삭제” 조합이 기본값에 가깝다.
3. 캐시 무효화 (invalidation)
“컴퓨터 과학에서 어려운 두 가지는 캐시 무효화와 이름 짓기다”라는 오래된 농담이 있다. 캐시에 있는 값을 언제, 어떻게 낡은 것으로 처리할지가 캐시 설계의 본체다.
무효화 수단은 크게 셋이다.
- TTL 만료 — 키에 수명을 걸어 시간이 지나면 자동으로 사라진다. 가장 단순하고 견고하다. 다만 TTL 동안은 낡은 값이 노출될 수 있다.
- 명시적 삭제 — 데이터가 바뀔 때 해당 키를 지운다(delete). 다음 읽기가 MISS→DB 재적재로 최신값을 채운다.
- write 시 갱신 — 삭제 대신 새 값으로 덮어쓴다(Write-Through 계열).
무효화가 어려운 이유는 개념이 아니라 경계 조건 때문이다. 하나의 논리 데이터가 여러 키·여러 캐시 계층에 흩어져 있으면(목록 캐시, 상세 캐시, 집계 캐시…) “어느 키들을 무효화해야 하는가”를 빠짐없이 추적하기 어렵다. 하나라도 빠지면 그 키만 계속 낡은 값을 돌려준다.
실무 지침은 다음과 같다.
- 갱신보다 삭제를 선호한다. 삭제 후 lazy 재적재가 “덮어쓰기 경쟁”보다 실수 여지가 적다.
- TTL을 항상 건다. 명시적 무효화를 놓쳐도 TTL이 안전망이 된다. 무효화 로직의 버그가 영구적 stale로 굳지 않게 한다.
- 키 설계로 무효화 범위를 좁힌다.
user:{id}:profile처럼 무효화 단위와 키 단위를 맞춘다.
4. DB-캐시 정합성 문제
캐시를 두는 순간 같은 데이터의 사본이 둘이 되고, 둘을 원자적으로 함께 바꿀 방법은 (분산 트랜잭션 없이는) 없다. 그래서 stale read(캐시가 낡은 값을 돌려줌)는 완전히 없앨 수 없고, 창(window)을 줄이는 문제가 된다.
전형적인 경쟁 상황 하나. Cache-Aside에서 “DB 갱신 후 캐시 삭제”조차 타이밍에 따라 낡은 값을 남길 수 있다.
1
2
3
4
T1(읽기): 캐시 MISS → DB에서 옛 값 v0 읽음
T2(쓰기): DB를 v1로 갱신 → 캐시 삭제
T1(읽기): 캐시에 v0 저장(!) ← 삭제 이후에 옛 값이 채워짐
결과: 캐시에 v0가 남아 TTL까지 stale
이런 틈을 줄이는 정공법이 몇 가지 있다.
- 쓰기는 “DB 먼저, 캐시 삭제 나중” 순서를 지킨다. 반대로 하면 삭제 후 DB 실패 시 캐시만 비고 DB는 옛 값인 더 나쁜 상태가 될 수 있다.
- 짧은 TTL로 stale 창의 상한을 강제한다. 위 경쟁으로 v0가 남아도 TTL이 지나면 자정된다.
- 정합성이 더 중요하면 지연 이중 삭제(갱신 직후 삭제, 잠시 뒤 한 번 더 삭제)로 경쟁 창을 좁히거나, DB 변경 로그(CDC)를 구독해 무효화를 트리거한다.
이 지점이 CAP 정리와 닿는다. 캐시-DB는 사실상 작은 분산 복제 시스템이고, 우리는 대부분 최신성(강한 일관성)을 조금 양보하고 읽기 성능·가용성을 취하는 선택을 한다. 즉 캐시는 의도적으로 최종 일관성(eventual consistency)을 받아들이는 설계다. “얼마나 낡아도 되는가”가 곧 TTL과 무효화 전략의 파라미터가 된다.
캐시에 강한 일관성을 요구하지 마라. 캐시는 “허용 가능한 stale 창”을 정하고 그 안에서 성능을 사는 도구다.
5. 흔한 장애 패턴과 대응
캐시는 잘 돌 땐 조용하다가, 특정 조건에서 DB로 부하를 한꺼번에 쏟아내며 무너진다. 대표 세 가지를 본다.
5.1 캐시 스탬피드 (thundering herd)
인기 키의 TTL이 만료되는 순간, 그 키를 읽던 수많은 요청이 동시에 MISS가 되어 전부 DB로 몰린다. DB가 같은 쿼리 폭탄을 맞고 넘어간다.
대응:
- 뮤텍스 / 싱글플라이트(single-flight) — MISS 시 하나의 요청만 DB를 조회하고 캐시를 채우게 하고, 나머지는 그 결과를 기다린다. 락 키(예:
lock:{key})를SET NX로 잡는 방식이 흔하다. - TTL 지터(jitter) — 만료 시각을
base + random으로 흩뿌려 여러 키가 동시에 만료되지 않게 한다. 특히 대량 키를 한 번에 적재했을 때 필수. - 논리적 만료 / 조기 갱신 — 실제 TTL보다 짧은 논리 만료를 두고, 만료 임박 시 백그라운드로 미리 갱신해 hard-miss 자체를 피한다.
5.2 캐시 침투 (cache penetration)
존재하지 않는 데이터를 계속 조회하는 경우. 캐시에 없고(당연히) DB에도 없으니 매번 MISS→DB로 흘러 캐시가 아무 방어도 못 한다. 잘못된 ID 스캔이나 공격에 취약하다.
대응:
- 널(공백) 캐싱 — DB에 없다는 결과 자체를 짧은 TTL로 캐시해, 반복되는 “없는 조회”를 캐시에서 끊는다. TTL을 짧게 둬 나중에 실제로 생성된 값을 놓치지 않게 한다.
- 입력 검증 — 형식이 명백히 틀린 키(음수 ID 등)는 캐시·DB에 가기 전에 거른다.
- 블룸 필터 — “존재할 수 있는 키” 집합을 확률적으로 걸러, 확실히 없는 키는 DB 조회 자체를 막는다.
5.3 핫키 (hot key)
특정 키 하나에 트래픽이 극단적으로 쏠리는 경우(예: 초대형 인기 상품). 분산 캐시라도 그 키가 사는 단일 노드가 병목·과부하가 된다.
대응:
- 로컬 캐시 승격 — 핫키만 애플리케이션 로컬(프로세스 내) 캐시에 짧은 TTL로 함께 둬 네트워크 홉과 단일 노드 부하를 줄인다.
- 키 분할(replica key) —
hotkey#1..N처럼 여러 키로 복제해 부하를 노드에 분산하고, 읽기는 무작위 replica를 고른다.
| 장애 | 원인 | 핵심 대응 |
|---|---|---|
| 스탬피드 | 인기 키 동시 만료 → DB 폭주 | 싱글플라이트, TTL 지터, 조기 갱신 |
| 침투 | 없는 데이터 반복 조회 | 널 캐싱, 입력 검증, 블룸 필터 |
| 핫키 | 단일 키 트래픽 집중 | 로컬 캐시, 키 분할 |
6. 왜 Redis가 캐시 레이어에 맞는가
“DB 앞단 캐시”라는 좁은 각도에서만 봐도 Redis가 표준처럼 쓰이는 이유는 분명하다.
- 인메모리 — 모든 데이터를 메모리에 두고 단일 스레드 이벤트 루프로 처리해, 조회가 sub-ms로 끝난다. 캐시가 요구하는 “빠른 읽기”에 그대로 부합한다.
- 키별 TTL —
EXPIRE/SET ... EX로 키마다 수명을 걸 수 있어 무효화의 안전망(3절)을 자료구조 차원에서 지원한다. 메모리가 차면maxmemory+ LRU/LFU 계열 정책으로 자동 축출(eviction)한다. - 풍부한 자료구조 — 단순 문자열뿐 아니라 Hash·Sorted Set·Set 등을 제공해, 객체 필드 캐싱·랭킹·집합 연산 같은 캐시 패턴을 자연스럽게 담는다.
- 원자적 명령 —
SET NX(스탬피드 락),INCR(카운터) 같은 원자 연산이 있어 앞서 본 장애 대응을 간단히 구현할 수 있다.
즉 Redis는 “빠른 읽기 + TTL 기반 무효화 + 장애 대응에 쓸 원자 연산”이라는 캐시 레이어의 요구를 정확히 채운다. (Redis의 영속화·클러스터·복제 등은 캐시 각도를 넘어서므로 여기서는 다루지 않는다.)
7. 실무 — 로컬 캐시 vs 분산 캐시, 계층 배치
캐시는 어디에 두느냐로 성격이 갈린다.
| 구분 | 로컬 캐시 (in-process) | 분산 캐시 (Redis) |
|---|---|---|
| 위치 | 애플리케이션 프로세스 메모리 | 별도 서버/클러스터 |
| 속도 | 가장 빠름(네트워크 홉 없음) | 빠름(네트워크 1홉) |
| 용량 | 프로세스 힙에 제약 | 크게 확장 가능 |
| 일관성 | 인스턴스마다 사본 → 불일치 위험 | 모든 인스턴스가 같은 값 공유 |
| 무효화 | 노드 간 전파가 어려움 | 중앙에서 한 번에 |
로컬 캐시는 빠르지만 인스턴스마다 사본이 생겨 무효화가 인스턴스별로 따로 놀 수 있다. 분산 캐시는 한곳을 공유해 정합성을 다루기 쉽지만 네트워크 홉과 단일 장애 지점(운영 부담)이 생긴다.
그래서 규모가 커지면 2계층(다층) 캐시로 배치하는 경우가 많다.
1
2
3
요청 → L1: 로컬 캐시(짧은 TTL, 아주 뜨거운 데이터)
└ MISS → L2: Redis(분산, 공유)
└ MISS → DB
- L1(로컬)은 핫키·초고빈도 데이터만 짧은 TTL로 담아 네트워크 홉을 줄인다(5.3의 핫키 대응).
- L2(Redis)는 공유 캐시로 정합성 기준점 역할을 하고, L1은 짧은 TTL로 “곧 자정된다”는 전제를 둔다.
- 강한 무효화가 필요하면 L1에 pub/sub 기반 무효화 브로드캐스트를 얹기도 하지만, 복잡도가 크게 오르므로 정말 필요할 때만 도입한다.
기본값: Cache-Aside 읽기 + write 시 삭제 + 항상 TTL(+지터). 이 조합이 대부분의 읽기 캐시를 견고하게 굴린다. 로컬 L1은 핫키가 실제로 문제될 때 얹는다.
관련 글
| 글 | 관계 |
|---|---|
| DBCP — HikariCP 커넥션 풀 사이징 | DB 부하를 다루는 또 다른 앞단 — 커넥션 풀 |
| CAP 정리 — P는 고르는 게 아니라 주어진다 | 캐시-DB 정합성이 닿는 일관성 이론 |