[캐시전략] Caffeine + Redis + DB, 3단 캐시 구조로 확장하기

y001·2025년 4월 14일
0
post-thumbnail

1. 왜 3단 캐시 구조가 필요했을까?

이전 글에서는 Caffeine을 활용한 로컬 캐시만으로도 API 성능이 충분히 개선되는 것을 확인했다.
그러나 이 방식은 단일 서버 환경에 한정된 최적화다.

서비스가 실제 운영 환경에서 서버를 여러 대로 수평 확장하는 순간, 각 인스턴스에 존재하는 Caffeine 캐시는 서로 다른 데이터를 보유하게 된다. 이는 캐시 일관성 문제로 이어지며, 다음과 같은 부작용을 일으킨다:

  • A 서버는 캐시를 들고 있지만, B 서버는 동일한 요청에 대해 DB를 다시 조회함
  • 하나의 서버에서만 인밸리데이션이 일어나고 다른 서버에서는 여전히 낡은 데이터를 응답함
  • 특정 인스턴스만 과부하가 걸리는 비균형 상태 발생

따라서 멀티 인스턴스 환경에서는 각 서버가 공유할 수 있는 중앙 캐시가 필요했고, 이에 따라 Redis를 중간 계층으로 추가하여 3단 캐시 구조를 설계하게 되었다.


2. 캐시 계층별 역할 정리

단계별로 명확히 책임을 나누는 것이 핵심이었다. 각 계층은 다음과 같은 목적을 가지고 있다:

계층설명주요 목적특징
Caffeine (1차)각 서버 인스턴스의 메모리 내부에 존재하는 로컬 캐시응답 속도 최적화가장 빠름, 서버 재시작 시 초기화
Redis (2차)모든 인스턴스가 공유하는 중앙 캐시인스턴스 간 캐시 일관성 유지네트워크 IO 발생, TTL 설정 필요
DB (3차)모든 요청의 최종 데이터 소스정확한 데이터 제공성능 비용 높음, 캐시 미스 시만 접근

실제 흐름 예시:

1. 요청 도착
2. Caffeine 에서 조회 (있으면 바로 반환)
3. 없으면 Redis 조회 (있으면 Caffeine에 저장 후 반환)
4. Redis에도 없으면 DB 조회 → Redis + Caffeine에 저장

이 흐름은 운영 중 실제 로그로도 확인할 수 있다:

⚠️ [Caffeine MISS] key = movies:all
⚠️ [Redis MISS] key = movies:all
📡 [DB QUERY] key = movies:all
📥 [Caffeine PUT] (from DB)
📦 [Redis PUT] (from DB)

3. Redis에는 어떤 데이터만 저장해야 할까?

모든 데이터를 Redis에 저장하면 일견 편해 보이지만, Redis는 중앙 캐시이자 네트워크 기반 시스템이다.
그만큼 다음과 같은 비용이 발생한다:

  • 네트워크 트래픽 증가 (요청이 많을수록 Redis 부하 증가)
  • Redis 메모리 사용량 증가 → 결국 비용 상승
  • 불필요한 데이터까지 Redis에 쌓이면 성능이 오히려 저하됨

그래서 우리는 다음 기준을 적용했다:

✅ 5회 이상 조회된 데이터만 Redis에 저장한다

if (hitCount >= <5) {
    redisRepositoryPort.put(cacheKey, data)
}

왜 5번인가?

  • 1~2번 조회된 데이터는 일시적 수요일 가능성이 높다
  • 5회 이상은 "반복 요청"이라 판단하고 Redis에 올린다
  • Redis를 "인기 데이터 전용 계층"으로 활용함으로써 메모리 낭비를 최소화하고, Caffeine이 감당하지 못하는 케이스만 Redis로 보완하도록 설계했다

이 기준은 운영 중 상황에 따라 threshold 값을 조절할 수 있도록 만들어 두었다.


4. 키 전략과 TTL 설계

캐시를 운영하려면 키 관리가 중요하다. 특히 동적 쿼리를 기반으로 하는 검색 기능은 캐시 키 충돌과 메모리 폭증을 동시에 유발할 수 있다.

📌 전체 영화 목록

  • Key: movies:all
  • TTL: 10분
  • 무효화 시점: 영화 등록/수정/삭제 이벤트 발생 시 캐시 삭제

🔍 검색 조건이 포함된 영화 목록

  • Key: movies:search:{title}:{genreHash}
    • genreHash는 장르 리스트를 정렬 후 SHA256 해시로 변환
  • TTL: 5분
  • 주의사항:
    • 유입 쿼리 다양성으로 인해 Redis에 너무 많은 키가 쌓이지 않도록 TTL과 사이즈 제한 필요
    • LRU 기반 만료 정책 적용 (Caffeine에도 동일하게 설정)

이러한 키 설계 전략을 통해 중복 캐시를 방지하고, 빠른 무효화가 가능한 구조를 확보했다.


5. 구조를 아키텍처 레이어에 맞춰 정리하면?

우리는 이 3단 캐시 구조를 헥사고날 아키텍처와 모듈 구조에도 잘 녹여냈다.
다음과 같은 구성이다:

// 포트 인터페이스
interface CachedMoviePort {
    fun find(key: String): List<MovieDto>?
    fun save(key: String, data: List<MovieDto>)
}

// 서비스에서 호출
fun getMovies(): List<MovieDto> {
    return cachedMoviePort.find("movies:all")
        ?: run {
            val result = movieRepository.findAll()
            cachedMoviePort.save("movies:all", result)
            result
        }
}
  • CachedMoviePort는 외부 캐시 계층과의 통신을 추상화
  • RedisCacheAdapter, CaffeineCacheAdapter는 각각 포트를 구현
  • UseCase/Service는 포트만 알고, 구현체에 종속되지 않음

→ 이 구조는 테스트도 쉽고, 차후 Redis에서 다른 캐시 시스템(ZooKeeper, Hazelcast 등)으로 변경해도 코드 변경이 최소화된다.


6. 결론: 성능과 일관성을 동시에 잡는 전략

단순히 캐시를 쓰는 것이 아니라, 각 계층의 역할을 분리하고 운영 목적에 따라 의미 있는 구조로 만드는 것이 중요하다.

구조기대 효과
Caffeine요청 대부분을 빠르게 처리 (초저지연 응답)
Redis인스턴스 간 일관성 확보 (스케일아웃 환경 대응)
DB최후의 수단, 정합성 보장

0개의 댓글