Post

캐싱 전략 - Cache-Aside, Spring Cache, Caffeine, Redis

캐싱 전략 - Cache-Aside, Spring Cache, Caffeine, Redis

캐싱 패턴 3가지

Cache-Aside (Lazy Loading)

가장 일반적인 패턴입니다. 애플리케이션이 직접 캐시를 관리합니다.

1
2
3
4
5
6
7
8
9
10
11
읽기 흐름:
1. 캐시에서 조회 → 있으면 반환 (Cache Hit)
2. 없으면 DB/API에서 조회 (Cache Miss)
3. 결과를 캐시에 저장
4. 결과를 반환

클라이언트 → 애플리케이션 → 캐시 확인
                          ↓ (miss)
                      DB/외부 API 조회
                          ↓
                      캐시에 저장 → 반환
1
2
3
4
5
6
7
8
9
// Cache-Aside 의사코드
public Drug findDrug(String name) {
    Drug cached = cache.get(name);    // 1. 캐시 조회
    if (cached != null) return cached; // 2. Hit → 바로 반환

    Drug drug = api.search(name);      // 3. Miss → 원본 조회
    cache.put(name, drug);             // 4. 캐시에 저장
    return drug;
}

특징:

  • 실제로 요청된 데이터만 캐싱 → 메모리 효율적
  • 첫 요청은 느림 (Cold Start)
  • 캐시와 원본 데이터 사이에 불일치 가능 (캐시에 저장된 후 원본이 바뀌면)

Read-Through

Cache-Aside와 비슷하지만, 캐시가 직접 원본 데이터를 조회합니다.

1
클라이언트 → 캐시 → (miss일 때) 캐시가 직접 DB/API 조회 → 저장 → 반환
1
2
3
4
5
6
// Read-Through 의사코드
// 캐시 자체에 loader를 등록
Cache<String, Drug> cache = Caffeine.newBuilder()
    .build(name -> api.search(name));  // miss 시 캐시가 직접 호출

Drug drug = cache.get("타이레놀");  // 애플리케이션은 캐시만 호출하면 됨

Cache-Aside와의 차이:

구분Cache-AsideRead-Through
원본 조회 주체애플리케이션캐시
코드 복잡도조건 분기 필요단순 (캐시만 호출)
캐시 라이브러리 요구사항없음loader 지원 필요

Spring의 @Cacheable은 사실상 Read-Through입니다. 캐시 miss 시 메서드가 자동으로 실행되고 결과가 캐시에 저장됩니다.

Write-Through

쓰기 시 캐시와 원본 데이터를 동시에 갱신합니다.

1
2
3
4
5
쓰기 흐름:
클라이언트 → 캐시에 쓰기 → 캐시가 DB에도 쓰기 → 완료

읽기 흐름:
클라이언트 → 캐시에서 읽기 → 항상 최신 데이터

특징:

  • 캐시와 원본이 항상 일치 → 일관성 높음
  • 쓰기가 느림 (캐시 + DB 두 번 쓰기)
  • 읽기가 많고 쓰기가 적은 데이터에 적합

약 검색 서비스에는 어떤 패턴이 적합한가?

약 정보는 읽기만 있고 쓰기가 없는 데이터입니다 (공공 API에서 가져올 뿐, 우리가 수정하지 않음). 따라서 Cache-Aside (또는 Read-Through)가 적합합니다. Write-Through는 쓰기가 있는 데이터에만 의미가 있습니다.

Spring Cache Abstraction

@EnableCaching

캐싱 기능을 활성화하는 어노테이션입니다.

1
2
3
4
5
6
7
8
@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        return new CaffeineCacheManager("drug-search", "drug-detail");
    }
}

@Cacheable — 결과를 캐시에 저장

1
2
3
4
5
6
@Cacheable(value = "drug-search", key = "#itemName + '_' + #page + '_' + #size")
public List<DrugSearchResponse> searchByName(String itemName, int page, int size) {
    // 첫 호출: 이 메서드가 실행되고 결과가 캐시에 저장됨
    // 두 번째 호출: 이 메서드가 실행되지 않고 캐시에서 바로 반환
    return callExternalApi(itemName, page, size);
}

동작 흐름:

1
2
3
4
5
6
7
8
9
1번째 호출: searchByName("타이레놀", 1, 10)
  → 캐시 key "타이레놀_1_10" 조회 → miss
  → 메서드 실행 → 외부 API 호출 → 결과 반환 + 캐시에 저장
  → 응답 시간: ~2000ms

2번째 호출: searchByName("타이레놀", 1, 10)
  → 캐시 key "타이레놀_1_10" 조회 → hit!
  → 메서드 실행 안 함 → 캐시에서 바로 반환
  → 응답 시간: ~1ms

@CacheEvict — 캐시 삭제

1
2
3
4
5
6
7
// 특정 키의 캐시를 삭제
@CacheEvict(value = "drug-search", key = "#itemName + '_' + #page + '_' + #size")
public void evictSearchCache(String itemName, int page, int size) { }

// 캐시 전체를 삭제
@CacheEvict(value = "drug-search", allEntries = true)
public void evictAllSearchCache() { }

관리자 API에서 “캐시 초기화” 기능을 제공할 때 사용합니다.

@CachePut — 캐시를 강제로 갱신

1
2
3
4
5
@CachePut(value = "drug-search", key = "#itemName + '_' + #page + '_' + #size")
public List<DrugSearchResponse> refreshSearch(String itemName, int page, int size) {
    // @Cacheable과 달리, 항상 메서드를 실행하고 결과를 캐시에 덮어쓴다
    return callExternalApi(itemName, page, size);
}

@Cacheable은 캐시가 있으면 메서드를 실행하지 않지만, @CachePut항상 실행하고 결과를 캐시에 갱신합니다.

비교 정리

어노테이션캐시 hit 시캐시 miss 시용도
@Cacheable메서드 실행 안 함, 캐시 반환메서드 실행, 결과 캐싱읽기 캐싱
@CacheEvict캐시 삭제캐시 삭제캐시 무효화
@CachePut메서드 실행, 캐시 덮어쓰기메서드 실행, 결과 캐싱캐시 강제 갱신

Caffeine Cache

특징

Caffeine은 Java 기반의 로컬 캐시 라이브러리입니다. Google Guava Cache의 후속작으로, 현재 Java 생태계에서 가장 빠른 로컬 캐시입니다.

1
2
3
4
5
Cache<String, List<Drug>> cache = Caffeine.newBuilder()
    .maximumSize(1000)                   // 최대 1000건
    .expireAfterWrite(24, TimeUnit.HOURS) // 쓰기 후 24시간 만료
    .recordStats()                        // 히트율 통계 수집
    .build();

핵심 설정

설정의미예시
maximumSize캐시에 저장할 최대 항목 수1000
expireAfterWrite쓰기 후 만료 시간 (TTL)24시간
expireAfterAccess마지막 접근 후 만료 시간1시간
refreshAfterWrite쓰기 후 자동 갱신 시간12시간
recordStats히트율 통계 수집 활성화-

expireAfterWrite vs expireAfterAccess

1
2
3
4
5
6
7
8
expireAfterWrite(1h):
  PUT "타이레놀" ──── 1시간 후 만료 (사용 여부 무관)
                     ↑ 중간에 아무리 GET해도 만료 시간 안 바뀜

expireAfterAccess(1h):
  PUT "타이레놀" ── GET ── GET ── GET ── 1시간 후 만료
                   ↑      ↑      ↑
              접근할 때마다 만료 시간 리셋

약 검색처럼 데이터 신선도가 중요한 경우 expireAfterWrite가 적합합니다. expireAfterAccess는 인기 데이터가 영원히 갱신되지 않을 수 있습니다.

Caffeine의 eviction 정책: Window TinyLFU

캐시가 가득 차면 어떤 항목을 제거할지 결정해야 합니다.

  • LRU (Least Recently Used): 가장 오래 전에 사용된 항목 제거 → 최근 한 번만 접근된 항목이 자주 접근되는 항목을 밀어낼 수 있음
  • LFU (Least Frequently Used): 가장 적게 사용된 항목 제거 → 과거에 많이 쓰이다 안 쓰이는 항목이 남을 수 있음
  • Window TinyLFU (Caffeine): LRU와 LFU의 장점을 결합. 최근 접근 빈도와 전체 접근 빈도를 함께 고려하여 가장 높은 히트율을 달성

Caffeine이 “가장 빠른 로컬 캐시”라고 불리는 이유 중 하나입니다.

Spring Boot에서 Caffeine 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(24, TimeUnit.HOURS)
            .recordStats());
        return manager;
    }
}

캐시별로 다른 설정을 적용하려면 SimpleCacheManager를 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Bean
public CacheManager cacheManager() {
    SimpleCacheManager manager = new SimpleCacheManager();
    manager.setCaches(List.of(
        buildCache("drug-search", 1000, 24),    // 검색 결과: 1000건, 24시간
        buildCache("drug-detail", 5000, 24)     // 상세 정보: 5000건, 24시간
    ));
    return manager;
}

private CaffeineCache buildCache(String name, int maxSize, int ttlHours) {
    return new CaffeineCache(name, Caffeine.newBuilder()
        .maximumSize(maxSize)
        .expireAfterWrite(ttlHours, TimeUnit.HOURS)
        .recordStats()
        .build());
}

Redis

Caffeine과의 핵심 차이

Caffeine은 JVM 힙 메모리에 데이터를 저장합니다. 서버가 2대이면 각 서버의 캐시가 독립적입니다.

1
2
3
4
5
6
7
8
[Caffeine - 로컬 캐시]
서버 A: { "타이레놀": [결과] }   ← 서버 A에서 캐싱
서버 B: { }                    ← 서버 B는 모름, 다시 API 호출 필요

[Redis - 분산 캐시]
서버 A ──┐
         ├── Redis: { "타이레놀": [결과] }   ← 둘 다 같은 캐시 사용
서버 B ──┘

비교

기준Caffeine (로컬)Redis (분산)
조회 속도~ns (같은 JVM 힙)~1ms (네트워크 왕복)
서버 간 공유불가 (각 서버 독립)가능 (중앙 저장소)
데이터 일관성서버마다 다를 수 있음항상 일관
서버 재시작 시캐시 소멸유지
인프라 비용0원Redis 서버 필요
직렬화불필요 (객체 참조)필요 (JSON/바이트 변환)
적합한 상황단일 서버, 읽기 위주다중 서버, 일관성 필요

단일 서버라면 Caffeine이 정답

현재 프로젝트가 단일 서버라면 Caffeine만으로 충분합니다.

  • 네트워크 지연 없음 (ns vs ms)
  • 직렬화/역직렬화 비용 없음
  • Redis 인프라 불필요
  • 서버 재시작 시 캐시 소멸 → 약 검색은 분기별 갱신이니 문제없음

서버가 2대 이상으로 늘어나면 Redis 도입을 검토합니다.

TTL (Time To Live)과 캐시 히트율

TTL이란

캐시에 저장된 데이터가 유효한 시간입니다. TTL이 지나면 캐시에서 자동 삭제됩니다.

1
2
3
TTL = 24시간
PUT "타이레놀" (10:00) → 다음 날 10:00에 만료
                        → 10:01에 조회하면 Cache Miss → 외부 API 재호출 → 새로운 데이터로 캐싱

TTL을 어떻게 결정하는가

고려 요소TTL 길게TTL 짧게
데이터 신선도오래된 데이터 가능성항상 최신
캐시 히트율높음 (오래 유지)낮음 (자주 만료)
외부 API 호출 수적음많음
메모리 사용많음 (오래 유지)적음

약 정보는 분기별 업데이트이므로 TTL 24시간이면 충분합니다. 만료 후 자연스럽게 최신 데이터로 갱신됩니다.

캐시 히트율 (Hit Rate)

1
2
3
히트율 = Cache Hit 수 / 전체 요청 수 × 100

예: 100번 요청 중 85번이 캐시에서 반환 → 히트율 85%

히트율이 높을수록 외부 API 호출이 줄고 응답이 빨라집니다. Caffeine의 recordStats()로 히트율을 모니터링할 수 있습니다.

1
2
3
4
5
CaffeineCache cache = (CaffeineCache) cacheManager.getCache("drug-search");
CacheStats stats = cache.getNativeCache().stats();

log.info("히트율: {}%", stats.hitRate() * 100);
log.info("히트 수: {}, 미스 수: {}", stats.hitCount(), stats.missCount());

캐시 스탬피드 (Cache Stampede)

문제 상황

TTL이 만료되는 순간, 동시에 많은 요청이 같은 데이터를 조회하면

1
2
3
4
5
6
7
8
9
10
TTL 만료! "타이레놀" 캐시 사라짐

요청 1 → 캐시 miss → 외부 API 호출 시작
요청 2 → 캐시 miss → 외부 API 호출 시작  (요청 1이 아직 안 끝남!)
요청 3 → 캐시 miss → 외부 API 호출 시작
...
요청 100 → 캐시 miss → 외부 API 호출 시작

→ 100번의 동일한 외부 API 호출이 동시에 발생! (Thundering Herd)
→ 외부 API rate limit 초과, 서버 과부하

해결 방법

1. Caffeine의 refreshAfterWrite

1
2
3
4
Caffeine.newBuilder()
    .expireAfterWrite(24, TimeUnit.HOURS)    // 24시간 후 만료
    .refreshAfterWrite(12, TimeUnit.HOURS)   // 12시간 후 백그라운드 갱신
    .build(key -> loadFromApi(key));          // CacheLoader 필요

12시간이 지나면 다음 접근 시 하나의 스레드만 백그라운드에서 데이터를 갱신하고, 나머지 요청은 기존 캐시 데이터를 반환합니다. 24시간이 지나면 완전히 만료됩니다.

1
2
3
4
5
6
7
시간 흐름:
0h    → 캐싱됨
12h   → refreshAfterWrite 도달
12h+1 → 요청 A: 기존 캐시 반환 + 백그라운드에서 갱신 시작
12h+1 → 요청 B: 기존 캐시 반환 (갱신 중복 없음!)
12h+2 → 갱신 완료, 새 데이터로 교체
24h   → expireAfterWrite 도달, 완전 만료

2. 분산 락 (Redis 환경)

Redis를 사용한다면, 캐시 miss 시 하나의 요청만 원본을 조회하고 나머지는 대기합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (cache.get(key) == null) {
    if (redis.tryLock(key + ":lock", 5, SECONDS)) {
        try {
            result = loadFromApi(key);
            cache.put(key, result);
        } finally {
            redis.unlock(key + ":lock");
        }
    } else {
        // 다른 스레드가 조회 중, 잠시 대기 후 캐시에서 다시 조회
        Thread.sleep(100);
        return cache.get(key);
    }
}

현재 단일 서버 + Caffeine 구성이라면 refreshAfterWrite로 충분합니다.

Negative Caching

문제

존재하지 않는 약을 검색하면?

1
2
3
4
5
6
7
검색: "asdfghjk" (존재하지 않는 약)
  → 캐시 miss → 외부 API 호출 → 빈 결과 반환
  → 캐시에 저장하지 않음!

같은 검색 반복:
  → 또 캐시 miss → 또 외부 API 호출 → 또 빈 결과
  → 무의미한 API 호출 반복

악의적인 사용자가 존재하지 않는 검색어를 반복 호출하면, 캐시를 우회하여 외부 API에 직접 부하를 줄 수 있습니다.

해결: 빈 결과도 캐싱

1
2
3
4
5
6
@Cacheable(value = "drug-search", key = "#itemName + '_' + #page + '_' + #size")
public List<DrugSearchResponse> searchByName(String itemName, int page, int size) {
    List<DrugSearchResponse> results = callExternalApi(itemName, page, size);
    // 빈 리스트도 정상적으로 반환 → @Cacheable이 빈 리스트도 캐싱함
    return results;
}

Spring의 @Cacheable은 기본적으로 null이 아닌 모든 반환값을 캐싱합니다. 빈 리스트(Collections.emptyList())도 캐싱됩니다. 다만 null을 반환하면 캐싱되지 않으므로, 결과가 없을 때 빈 리스트를 반환하는 것이 중요합니다.

주의: Negative Cache의 TTL

빈 결과의 TTL을 정상 결과와 같게 잡으면, 나중에 해당 약이 추가되어도 24시간 동안 검색되지 않습니다. 빈 결과는 짧은 TTL (예: 1시간)을 적용하는 것이 이상적이지만, 캐시 설정이 복잡해집니다.

단순하게 가려면 동일한 TTL을 적용하되, 관리자 API로 특정 캐시를 수동 초기화할 수 있게 만드는 것이 실용적입니다.

참고 자료

This post is licensed under CC BY 4.0 by the author.