캐시 전략 정리: cache-aside, 쓰기 전략, 무효화와 TTL
들어가며
조회가 느린 API 앞에 Redis 캐시를 붙였더니 응답 시간이 크게 줄었습니다. 그런데 며칠 뒤, 프로필 이름을 바꿨는데 화면에는 한동안 옛 이름이 보인다는 문의가 들어옵니다.
캐시는 붙이는 것보다 언제 버리느냐가 어렵습니다. 이번 글에서는 가장 흔한 cache-aside 패턴과 세 가지 쓰기 전략을 비교하고, 무효화와 TTL, 캐시 스탬피드를 함께 정리합니다.
1. cache-aside: 없으면 DB에서 읽어 채우기
캐시는 자주 읽는 값을 빠른 저장소에 복사해 두는 기법입니다. 원본 데이터가 있는 DB를 정본이라고 하고, 캐시는 그 복사본입니다.
가장 널리 쓰는 방식은 cache-aside(lazy loading)입니다. 앱이 캐시를 먼저 보고, 없으면(캐시 미스) DB에서 읽은 뒤 캐시에 저장합니다. 쓸 때는 DB를 먼저 고치고 캐시 키를 지웁니다.
캐시는 자주 찾는 번호를 적어 둔 책상 메모입니다. 번호가 바뀌면 메모를 지웁니다.
다음 읽기는 캐시에서 바로 끝납니다.
값은 다음 읽기가 DB에서 새로 채웁니다.
def get_user(uid):
key = f"user:{uid}"
if (v := r.get(key)) is not None:
return json.loads(v)
row = db.fetch_user(uid)
r.set(key, json.dumps(row), ex=300) # TTL 300초
return row
def update_user(uid, data):
db.update_user(uid, data) # DB 먼저
r.delete(f"user:{uid}") # 그다음 키 삭제캐시는 앱 옆에 따로 있을 뿐이라, 캐시 서버가 죽어도 느려질 뿐 서비스는 DB로 버틸 수 있습니다. 실제로 읽힌 값만 캐시에 올라가므로 메모리도 아낄 수 있습니다.
2. 쓰기 전략: cache-aside, write-through, write-back
값이 바뀔 때 캐시와 DB를 어떤 순서로 고칠지에 따라 전략이 나뉩니다.
- write-through는 쓸 때마다 캐시와 DB를 함께 갱신합니다. 읽기는 항상 최신에 가깝지만, 쓰기마다 두 곳을 기다려야 합니다.
- write-back(write-behind)은 캐시에 먼저 쓰고 DB에는 모아서 나중에 반영합니다. 쓰기는 가장 빠르지만, 반영 전에 캐시가 죽으면 그 사이 값을 잃습니다.
조회수는 연필 메모에 적고 나중에 옮겨도 되지만, 잔액은 바로 장부에 적습니다.
| 쓰기 전략 | 쓰기 빠름 | 읽기 최신 | 유실 없음 |
|---|---|---|---|
| cache-asideDB 쓰고 키 삭제 | DB 속도 | 삭제 전후 | |
| write-through캐시·DB 함께 | |||
| write-back캐시 먼저 | 반영 전 장애 |
그래서 전략은 데이터마다 다르게 고릅니다. 조회수처럼 몇십 초치를 잃어도 괜찮은 값은 write-back이 잘 맞습니다.
# 요청마다: 캐시에만 더한다
r.incr(f"views:{pid}")
# 1분마다 배치에서: 꺼내면서 지우고 DB에 더한다
n = int(r.getdel(f"views:{pid}") or 0) # Redis 6.2 이상
db.add_views(pid, n)GETDEL로 값을 원자적으로 꺼내야 같은 증가분이 두 번 반영되지 않습니다. 반면 계좌 잔액은 DB 트랜잭션이 정본이고, 캐시는 화면 표시용으로만 둡니다. 출금 가능 여부는 반드시 DB에서 판단합니다.
3. 무효화: 고치지 말고 지우기
DB를 바꾼 뒤 캐시 값도 새 값으로 덮어쓰면 될 것 같지만, 동시에 두 요청이 쓰면 문제가 생깁니다. 요청 A가 DB에 v1을 쓰고 잠시 멈춘 사이 요청 B가 v2를 쓰고 캐시도 v2로 고칩니다. 뒤늦게 깨어난 A가 캐시를 v1으로 덮으면, DB는 v2인데 캐시에는 v1이 남습니다.
메모를 고쳐 쓰면 늦게 온 사람이 옛 번호로 덮을 수 있지만, 지워 두면 다음 사람이 장부를 다시 봅니다.
DB는 v2인데 캐시는 v1입니다. TTL이 끝날 때까지 옛 값이 나갑니다.
순서가 바뀌어도 캐시는 비어 있고, 다음 읽기가 DB의 v2를 채웁니다.
키를 지우면 순서가 뒤바뀌어도 결과가 같습니다. 캐시는 비고, 다음 읽기가 DB에서 최신 값을 채웁니다. 이것이 쓰기 때 갱신 대신 삭제를 권하는 이유입니다.
다만 삭제도 경쟁 구간을 완전히 없애지는 못합니다. 삭제 직전에 DB에서 옛 값을 읽어 둔 요청이 삭제 뒤에 그 값을 채워 넣을 수 있습니다. 그래서 TTL을 함께 두어 옛 값이 남을 수 있는 시간의 상한을 정합니다.
키 설계가 틀리면 다른 값이 나옵니다
키에 값을 구분하는 축이 빠지면 무효화와 상관없이 다른 대상의 값이 나옵니다. 예를 들어 언어마다 다른 문구를 msg:{id}로만 저장하면, 한국어로 채운 값이 영어 사용자에게도 나갑니다. 결과를 바꾸는 입력은 모두 msg:{id}:{lang}처럼 키에 넣어야 합니다.
4. TTL과 캐시 스탬피드
TTL(Time To Live)은 값이 자동으로 만료되는 시간입니다. 짧으면 신선하지만 미스가 잦아지고, 길면 적중률이 오르는 대신 옛 값이 오래 남습니다.
인기 키가 만료되는 순간에는 다른 문제가 생깁니다. 그 키를 기다리던 요청이 한꺼번에 미스를 맞고 모두 DB로 몰리는 캐시 스탬피드(thundering herd)입니다. 요청 1,000개가 동시에 들어오면 같은 쿼리가 1,000번 나갑니다.
인기 메모가 지워지면 모두 장부실로 달려가는 대신, 한 사람만 다녀오고 나머지는 기다립니다.
- 만료 순간 요청 1,000개전부 캐시 미스
- 모두 DB 조회같은 쿼리 1,000번
- 같은 키를 1,000번 저장
- 잠금 얻은 1개만 DB 조회SET NX EX 10
- 나머지는 잠깐 대기채워지면 캐시에서 읽음
- TTL 300~360초만료 시점 분산
흔한 대책은 두 가지입니다. 잠금으로 한 요청만 DB에서 채우게 하고, TTL에 무작위 값(지터)을 더해 여러 키가 같은 순간에 만료되지 않게 합니다.
def get_user(uid):
key = f"user:{uid}"
for _ in range(20):
if (v := r.get(key)) is not None:
return json.loads(v)
if r.set(f"lock:{key}", "1", nx=True, ex=10): # 한 요청만 성공
try:
row = db.fetch_user(uid)
ttl = 300 + random.randint(0, 60) # 300~360초
r.set(key, json.dumps(row), ex=ttl)
return row
finally:
r.delete(f"lock:{key}")
time.sleep(0.05) # 나머지는 잠깐 기다림
return db.fetch_user(uid) # 오래 걸리면 DB로만료 직전에 백그라운드에서 미리 갱신하는 방법도 있습니다. 갱신 비용이 큰 키에 주로 씁니다.
한눈에 비교
| cache-aside | write-through | write-back | |
|---|---|---|---|
| 쓰기 순서 | DB 쓰고 키 삭제 | 캐시와 DB 함께 | 캐시 먼저, DB 나중 |
| 쓰기 속도 | DB 속도 | 가장 느림 | 가장 빠름 |
| 유실 위험 | 없음 | 없음 | 반영 전 장애 시 |
| 잘 맞는 데이터 | 읽기가 많은 일반 데이터 | 읽자마자 최신이어야 하는 값 | 조회수, 좋아요 수 |
헷갈리기 쉬운 점
- TTL만 두면 정합성이 해결된다는 오해. 만료 전까지는 옛 값이 보이고, 키 설계가 틀렸다면 TTL과 상관없이 엉뚱한 값이 나옵니다.
- 캐시를 두면 모든 읽기를 캐시에서 해야 한다는 오해. 돈처럼 틀리면 안 되는 판단은 정본인 DB에서 읽습니다. 캐시는 화면 표시용입니다.
- 삭제 순서를 거꾸로 하는 실수. 키를 먼저 지우고 DB를 고치면, 그 사이 들어온 읽기가 옛 값을 다시 채웁니다. DB를 먼저 고치고 지웁니다.
면접에서 자주 받는 질문
쓸 때 캐시 값을 고치지 않고 왜 지우나요?
동시 쓰기가 겹치면 캐시를 고치는 순서가 DB와 뒤바뀌어 옛 값이 남을 수 있습니다. 지우면 다음 읽기가 DB에서 최신 값을 채웁니다. 다만 경쟁 구간이 완전히 없어지지는 않아서 TTL을 함께 둡니다.
캐시 스탬피드는 무엇이고 어떻게 막나요?
인기 키가 만료되는 순간 요청이 한꺼번에 DB로 몰리는 현상입니다. 잠금으로 한 요청만 값을 채우게 하거나, TTL에 무작위 값을 더해 만료 시점을 흩거나, 만료 전에 미리 갱신해 막습니다.
write-back에서 유실을 줄이려면 어떻게 하나요?
DB 반영 주기를 짧게 하고 Redis 영속화(AOF)를 켭니다. 값은 GETDEL처럼 원자적으로 꺼내 두 번 반영되지 않게 합니다. 그래도 유실 가능성이 0은 아니므로 잃어도 되는 값에만 씁니다.
잔액 캐시가 잠깐 옛 값을 보여도 괜찮나요?
출금 판단은 DB가 하므로 돈이 틀리지는 않습니다. 다만 사용자가 방금 바꾼 값이 안 보이면 혼란스럽습니다. 쓰기 직후 키를 지우고, 본인 화면은 DB에서 읽게 해 해결합니다.
정리
- cache-aside는 없으면 DB에서 채우고, 쓸 때는 DB를 고친 뒤 키를 지웁니다.
- 쓰기 전략은 데이터가 잃어도 되는 값인지로 고릅니다. write-back은 빠르지만 유실을 감수해야 합니다.
- TTL은 옛 값이 남는 시간의 상한이고, 스탬피드는 잠금과 지터로 막습니다.
캐시는 저장하는 일보다 언제 버리느냐를 정하는 일이 더 어렵습니다.