시스템 설계
캐시
캐시 전략 정리: cache-aside, 쓰기 전략, 무효화와 TTL

캐시 전략 정리: cache-aside, 쓰기 전략, 무효화와 TTL

들어가며

조회가 느린 API 앞에 Redis 캐시를 붙였더니 응답 시간이 크게 줄었습니다. 그런데 며칠 뒤, 프로필 이름을 바꿨는데 화면에는 한동안 옛 이름이 보인다는 문의가 들어옵니다.

캐시는 붙이는 것보다 언제 버리느냐가 어렵습니다. 이번 글에서는 가장 흔한 cache-aside 패턴과 세 가지 쓰기 전략을 비교하고, 무효화와 TTL, 캐시 스탬피드를 함께 정리합니다.


1. cache-aside: 없으면 DB에서 읽어 채우기

캐시는 자주 읽는 값을 빠른 저장소에 복사해 두는 기법입니다. 원본 데이터가 있는 DB를 정본이라고 하고, 캐시는 그 복사본입니다.

가장 널리 쓰는 방식은 cache-aside(lazy loading)입니다. 앱이 캐시를 먼저 보고, 없으면(캐시 미스) DB에서 읽은 뒤 캐시에 저장합니다. 쓸 때는 DB를 먼저 고치고 캐시 키를 지웁니다.

캐시는 자주 찾는 번호를 적어 둔 책상 메모입니다. 번호가 바뀌면 메모를 지웁니다.

읽기없으면 채움
🖥️앱서버📝캐시Redis🗄️DB정본① GET user:7② 미스③ SELECT④ 행⑤ SET + TTL 300

다음 읽기는 캐시에서 바로 끝납니다.

쓰기DB 후 삭제
🖥️앱서버📝캐시Redis🗄️DB정본① UPDATE② 완료③ DEL user:7다음 읽기는 미스

값은 다음 읽기가 DB에서 새로 채웁니다.

조회가 빨라지고 DB 부하가 줄어듭니다. 대신 TTL이 남은 동안 잠시 옛 값이 보일 수 있습니다.
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이 남습니다.

메모를 고쳐 쓰면 늦게 온 사람이 옛 번호로 덮을 수 있지만, 지워 두면 다음 사람이 장부를 다시 봅니다.

캐시 갱신옛 값 남음
요청 ADB←v1지연캐시←v1요청 BDB←v2캐시←v2캐시v2v1010203040ms

DB는 v2인데 캐시는 v1입니다. TTL이 끝날 때까지 옛 값이 나갑니다.

키 삭제다음 읽기가 v2
요청 ADB←v1지연삭제요청 BDB←v2삭제캐시비움비움010203040ms

순서가 바뀌어도 캐시는 비어 있고, 다음 읽기가 DB의 v2를 채웁니다.

시간 간격은 어림입니다. 삭제도 경쟁 구간이 완전히 없지는 않아 TTL을 함께 둡니다.

키를 지우면 순서가 뒤바뀌어도 결과가 같습니다. 캐시는 비고, 다음 읽기가 DB에서 최신 값을 채웁니다. 이것이 쓰기 때 갱신 대신 삭제를 권하는 이유입니다.

다만 삭제도 경쟁 구간을 완전히 없애지는 못합니다. 삭제 직전에 DB에서 옛 값을 읽어 둔 요청이 삭제 뒤에 그 값을 채워 넣을 수 있습니다. 그래서 TTL을 함께 두어 옛 값이 남을 수 있는 시간의 상한을 정합니다.

키 설계가 틀리면 다른 값이 나옵니다

키에 값을 구분하는 축이 빠지면 무효화와 상관없이 다른 대상의 값이 나옵니다. 예를 들어 언어마다 다른 문구를 msg:{id}로만 저장하면, 한국어로 채운 값이 영어 사용자에게도 나갑니다. 결과를 바꾸는 입력은 모두 msg:{id}:{lang}처럼 키에 넣어야 합니다.

4. TTL과 캐시 스탬피드

TTL(Time To Live)은 값이 자동으로 만료되는 시간입니다. 짧으면 신선하지만 미스가 잦아지고, 길면 적중률이 오르는 대신 옛 값이 오래 남습니다.

인기 키가 만료되는 순간에는 다른 문제가 생깁니다. 그 키를 기다리던 요청이 한꺼번에 미스를 맞고 모두 DB로 몰리는 캐시 스탬피드(thundering herd)입니다. 요청 1,000개가 동시에 들어오면 같은 쿼리가 1,000번 나갑니다.

인기 메모가 지워지면 모두 장부실로 달려가는 대신, 한 사람만 다녀오고 나머지는 기다립니다.

잠금 없음DB에 몰림
1000쿼리
  • 만료 순간 요청 1,000개전부 캐시 미스
  • 모두 DB 조회같은 쿼리 1,000번
  • 같은 키를 1,000번 저장
잠금 + 지터한 번만 조회
1쿼리
  • 잠금 얻은 1개만 DB 조회SET NX EX 10
  • 나머지는 잠깐 대기채워지면 캐시에서 읽음
  • TTL 300~360초만료 시점 분산
DB 부하는 크게 줄지만, 기다리는 요청의 응답은 그만큼 늦어집니다.

흔한 대책은 두 가지입니다. 잠금으로 한 요청만 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-asidewrite-throughwrite-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은 옛 값이 남는 시간의 상한이고, 스탬피드는 잠금과 지터로 막습니다.

캐시는 저장하는 일보다 언제 버리느냐를 정하는 일이 더 어렵습니다.