[Redis 실무 입문 #3] 캐시 구현하기 — Cache-Aside와 정합성

Redis의 가장 흔한 용도인 캐시를 제대로 구현한다. Cache-Aside 패턴, TTL을 반드시 걸어야 하는 이유, 그리고 DB를 바꿨는데 캐시가 낡은 값을 주는 정합성 문제까지 정리한다.

-

캐시는 쉬워 보이지만, 한 번씩은 데인다

내가 Redis를 처음 붙인 것도 캐시 때문이었다. 특정 조회가 느려서 “이거 Redis로 캐싱하면 되잖아” 하고 시작했다. 값을 넣고 빼는 것 자체는 #1에서 본 SET/GET이 전부라 30분이면 붙인다.

진짜 어려운 건 그 다음이었다. 언제 채우고, 언제 비우고, 원본이 바뀌면 어떻게 맞출 것인가. 캐시로 데이는 사람은 대부분 이 정합성에서 데인다. 이번 편은 그걸 순서대로 짚는다.


Cache-Aside — 캐시의 기본기

가장 널리 쓰는 패턴이다. 이름은 거창하지만 하는 일은 단순하다. “먼저 캐시를 보고, 없으면 DB에서 가져와 캐시에 채운다.”

1. Redis에 있나? (GET)
   → 있으면 그대로 반환 (Cache Hit)
   → 없으면 ↓
2. DB에서 조회
3. 결과를 Redis에 저장 (SET, TTL 필수)
4. 반환

코드로도 이 흐름 그대로다.

public User getUser(Long id) {
    String cached = redis.get("user:" + id);
    if (cached != null) {
        return deserialize(cached);              // 캐시 히트
    }
    User user = userRepository.findById(id);     // 캐시 미스 → DB
    redis.set("user:" + id, serialize(user), Duration.ofHours(1)); // TTL 1시간
    return user;
}

처음 캐시를 붙이면 딱 여기까지 짜고 “됐다” 한다. 나도 그랬다. 근데 여기엔 함정이 두 개 숨어 있다.


함정 1: TTL을 안 걸면 언젠가 터진다

위 코드에서 Duration.ofHours(1)을 빼면 어떻게 될까. 캐시가 영원히 남는다. 처음엔 아무 문제 없어 보인다. 그러다 서서히 두 가지가 온다.

  • 메모리가 계속 찬다. 캐시가 쌓이기만 하고 안 비워져서 결국 maxmemory에 도달한다. 그때부터 Redis가 키를 쫓아내거나 쓰기를 거부하기 시작한다.
  • 낡은 데이터가 안 죽는다. 원본이 바뀌어도 캐시는 옛 값을 계속 준다.

그래서 캐시엔 거의 항상 TTL을 건다. 기준은 간단하다. “이 데이터, 최대 얼마나 낡아도 사용자가 괜찮은가?” 자주 안 바뀌면 길게(몇 시간), 자주 바뀌면 짧게(몇십 초~몇 분). 캐시는 원래 비워지라고 있는 거다. 안 비워지는 캐시는 캐시가 아니라 메모리 누수다.

SET user:1 "..." EX 3600     # 저장과 동시에 TTL 1시간

함정 2: 원본이 바뀌면 캐시는 어긋난다

이게 진짜다. DB의 사용자 정보를 수정했는데 Redis엔 옛 값이 남아 있으면, 사용자는 방금 자기가 바꾼 값이 아니라 낡은 값을 본다. “분명 저장했는데 왜 그대로예요?” 문의가 이렇게 나온다.

가장 단순하고 안전한 해법은 수정할 때 그 캐시를 지우는 것이다.

public void updateUser(User user) {
    userRepository.save(user);              // 1. DB 먼저 수정
    redis.delete("user:" + user.getId());   // 2. 캐시는 갱신(X) 삭제(O)
}

여기서 두 가지가 포인트다.

첫째, 캐시를 새 값으로 “덮어쓰기"가 아니라 “삭제"한다. 처음엔 “수정했으니 캐시도 새 값으로 업데이트하면 되잖아?” 싶은데, 그러면 동시에 여러 요청이 수정할 때 어느 값이 최종인지 꼬인다. 그냥 지워버리면 다음 조회가 Cache-Aside를 타고 DB에서 최신값을 다시 채운다. 훨씬 안전하다.

둘째, 순서가 중요하다. DB를 먼저 바꾸고 캐시를 지운다. 캐시를 먼저 지우면, 그 찰나에 다른 요청이 아직 안 바뀐 DB 값을 읽어 캐시를 옛 값으로 다시 채워버릴 수 있다. “DB → 캐시 삭제” 순서를 지키자.


미리 알아두면 좋은 함정들

위 두 개가 기본이고, 규모가 커지면 이런 것도 만난다. 지금 당장은 아니어도 이름은 알아두자.

  • 캐시 스탬피드(Thundering Herd): 인기 데이터의 캐시가 만료되는 그 순간, 수많은 요청이 동시에 “캐시 없네?” 하고 전부 DB로 몰린다. TTL에 약간의 무작위(jitter)를 주거나, 캐시 갱신은 한 요청만 하도록 락을 걸어 완화한다.
  • 히트율이 낮으면 오히려 손해: 캐시가 거의 안 맞으면 “캐시 확인 → 미스 → DB → 캐시 저장"이 그냥 DB 조회보다 느리다. 자주 조회되는 데이터에만 걸어야 한다. 안 그러면 Redis만 놀고 느려진다.
  • 너무 큰 값: 수백 KB짜리를 캐시하면 꺼낼 때마다 네트워크·메모리를 크게 먹는다. 캐시는 작고 자주 읽히는 데이터일수록 이득이다.

마치며

캐시는 Redis의 시작이자 가장 실수하기 쉬운 부분이다. 정리하면 Cache-Aside + TTL + 수정 시 삭제, 이 세 개만 지켜도 대부분의 캐시는 조용히 잘 굴러간다. 나머지는 규모가 커진 다음 얘기다.

다음 편에서는 캐시 말고 Redis를 진짜 다양하게 쓰는 실전 패턴 — 세션, Rate Limiting, 분산 락, 랭킹 — 을 한 번에 훑는다. “이런 것도 Redis로 하네” 싶은 것들이다.

개인정보처리방침