캐시 가이드 시리즈

01 캐시 기초

- 캐시의 개념과 동작 원리

- 캐시 계층과 유형

- 캐시 전략과 패턴

02 Spring Cache 핵심

- Spring Cache 추상화

- 캐시 어노테이션 완전 정복

- 캐시 키 전략과 조건부 캐싱

03 캐시 저장소

- CacheManager와 Caffeine ← 현재 편

- Redis 분산 캐시

04 실전과 운영

- 캐시 일관성과 갱신 전략

- 안티패턴과 트러블슈팅

- 면접 대비

지금까지 @Cacheable 같은 어노테이션이 "무엇을 하는지"를 배웠다. 이제 그 어노테이션이 "어디에 저장하는지"를 살펴볼 차례다. CacheManager가 캐시 저장소를 결정한다. 어떤 CacheManager를 선택하느냐에 따라 성능, 기능, 운영 특성이 완전히 달라진다.

CacheManager 구현체 비교

Spring Boot에서 사용할 수 있는 주요 CacheManager 구현체를 정리하면 다음과 같다.

  • ConcurrentMapCacheManager : 별도 의존성 없이 사용 가능하다. TTL이 없고, 크기 제한이 없다. 개발/테스트 전용이다.
  • CaffeineCacheManager : TTL, 최대 크기, 통계 등 풍부한 기능을 제공한다. 로컬 캐시의 최선의 선택이다.
  • RedisCacheManager : 분산 캐시를 지원한다. 여러 서버가 하나의 캐시를 공유한다. 별도의 Redis 서버가 필요하다.
  • EhCacheCacheManager : EhCache 2.x/3.x를 사용한다. 디스크 캐싱을 지원하지만 Caffeine에 비해 무겁다.
  • CompositeCacheManager : 여러 CacheManager를 하나로 묶는다. L1/L2 캐시 구조를 만들 때 사용한다.
flowchart TB CM["CacheManager"] --> ConcurrentMap["ConcurrentMapCacheManager"] CM --> Caffeine["CaffeineCacheManager"] CM --> Redis["RedisCacheManager"] CM --> EhCache["EhCacheCacheManager"] CM --> Composite["CompositeCacheManager"] ConcurrentMap --- F1["TTL ❌ | 크기제한 ❌ | 개발용"] Caffeine --- F2["TTL ✅ | 크기제한 ✅ | 로컬 표준"] Redis --- F3["TTL ✅ | 분산 ✅ | 분산 표준"]

Spring Boot에 별도의 캐시 라이브러리를 추가하지 않으면 ConcurrentMapCacheManager가 자동으로 등록된다. 이것은 프로토타이핑용이지, 운영 환경에서 쓸 물건이 아니다.

ConcurrentMapCacheManager

JDK의 ConcurrentHashMap을 캐시 저장소로 사용하는 가장 단순한 구현체다.

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        return new ConcurrentMapCacheManager("weather", "feed");
    }
}

생성자에 캐시 이름을 전달하면 해당 이름의 캐시 영역만 사용할 수 있다. 이름을 전달하지 않으면 @Cacheable에서 사용하는 이름이 자동으로 등록된다.

치명적인 한계

  • TTL 없음 : 캐시에 한 번 들어간 데이터는 명시적으로 제거하기 전까지 영원히 남는다.
  • 크기 제한 없음 : 메모리가 허용하는 한 계속 쌓인다. 장시간 운영하면 메모리 부족으로 애플리케이션이 죽을 수 있다.
  • 통계 없음 : 적중률이나 캐시 크기를 확인할 방법이 없다.
운영 환경에서 ConcurrentMapCacheManager를 쓰지 마라

로컬 개발이나 단위 테스트에서는 편리하지만, 운영 환경에서는 반드시 Caffeine이나 Redis로 교체해야 한다. "일단 기본값으로 배포하고 나중에 바꾸자"라고 미루다가 메모리 누수로 서버가 다운되는 일이 실제로 흔하다.

Caffeine 캐시

Caffeine은 Google의 Guava Cache를 계승한 고성능 로컬 캐시 라이브러리다. Window TinyLfu라는 캐시 교체 알고리즘을 사용하여, 기존의 LRU 방식보다 적중률이 높다.

의존성 추가

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation 'com.github.ben-manes.caffeine:caffeine'
}

Spring Boot가 클래스패스에서 Caffeine을 감지하면 자동으로 CaffeineCacheManager를 등록한다.

기본 설정

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(500)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .recordStats());
        return cacheManager;
    }
}

이 설정은 모든 캐시 영역에 동일하게 적용된다.

  • maximumSize(500) : 캐시 항목 최대 500개. 초과하면 가장 오래되거나 덜 쓰이는 항목부터 제거된다.
  • expireAfterWrite(10, TimeUnit.MINUTES) : 항목이 캐시에 저장된 시점부터 10분 후 만료된다.
  • recordStats() : 적중률, 미스 횟수 등 통계를 수집한다.

만료 정책의 종류

Caffeine은 두 가지 만료 정책을 제공한다. 각각의 동작이 다르므로 상황에 맞게 선택해야 한다.

  • expireAfterWrite : 항목이 작성(또는 갱신)된 시점부터 시간을 센다. 10분으로 설정하면, 캐시에 저장된 지 10분이 지나면 해당 항목이 만료된다. 아무리 자주 조회해도 작성 시점 기준이므로 10분 후에는 반드시 만료된다.
  • expireAfterAccess : 항목이 마지막으로 접근(읽기 또는 쓰기)된 시점부터 시간을 센다. 10분으로 설정하면, 10분 동안 아무도 조회하지 않은 항목만 만료된다. 자주 조회되는 항목은 영원히 살아 있을 수 있다.
flowchart LR subgraph "expireAfterWrite" W1["저장 시점"] -->|10분 경과| W2["만료"] W3["조회해도 시간 리셋 안 됨"] end subgraph "expireAfterAccess" A1["마지막 접근"] -->|10분 경과| A2["만료"] A3["조회하면 시간 리셋"] end
어떤 만료 정책을 써야 할까

- 데이터 일관성이 중요하면expireAfterWrite를 사용한다. 일정 주기로 반드시 원본 데이터를 다시 가져온다.

- 메모리 절약이 목적이면expireAfterAccess를 사용한다. 안 쓰는 항목만 정리한다.

대부분의 경우 expireAfterWrite가 더 안전하다. expireAfterAccess는 인기 있는 데이터가 영원히 캐시에 남으면서 원본과 점점 벌어질 수 있다.

캐시 영역별 다른 설정 적용

날씨 캐시는 5분, 피드 캐시는 30분처럼 영역마다 다른 TTL을 적용하고 싶은 경우가 많다. Caffeine의 기본 CaffeineCacheManager는 모든 영역에 같은 설정을 적용하므로, 영역별 다른 설정이 필요하면 직접 구성해야 한다.

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        cacheManager.setCaches(List.of(
            buildCache("weather", 500, 5),
            buildCache("feed", 1000, 30),
            buildCache("feedList", 200, 10)
        ));
        return cacheManager;
    }

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

SimpleCacheManager에 직접 만든 CaffeineCache 인스턴스를 등록하는 방식이다. 이렇게 하면 각 캐시 영역마다 최대 크기와 TTL을 독립적으로 설정할 수 있다.

flowchart TB SCM["SimpleCacheManager"] SCM --> W["weather 캐시"] SCM --> F["feed 캐시"] SCM --> FL["feedList 캐시"] W --- WC["maxSize: 500\nTTL: 5분"] F --- FC["maxSize: 1000\nTTL: 30분"] FL --- FLC["maxSize: 200\nTTL: 10분"]
SimpleCacheManager는 동적 캐시 생성을 지원하지 않는다

SimpleCacheManager에 등록하지 않은 캐시 이름을 @Cacheable에서 사용하면 예외가 발생한다. 새로운 캐시 영역을 추가할 때마다 설정 코드를 수정해야 하므로, 처음부터 사용할 캐시 이름을 모두 등록해두어야 한다.

application.yml로 설정하기

코드 대신 설정 파일로 Caffeine을 구성할 수도 있다. 단, 이 방식은 모든 캐시 영역에 동일한 설정이 적용된다.

spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=500,expireAfterWrite=600s
    cache-names:
      - weather
      - feed
      - feedList

spec에는 Caffeine의 스펙 문자열을 작성한다. expireAfterWrite=600s는 600초, 즉 10분이다. 영역별 다른 설정이 필요하면 Java 설정 방식을 사용해야 한다.

캐시 통계 모니터링

Caffeine의 recordStats()를 활성화하면 캐시 적중률을 확인할 수 있다. Spring Boot Actuator와 연동하면 HTTP 엔드포인트로 캐시 메트릭을 노출할 수 있다.

Actuator 설정

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
management:
  endpoints:
    web:
      exposure:
        include: caches, metrics

설정 후 다음 엔드포인트로 캐시 정보를 확인할 수 있다.

  • GET /actuator/caches : 등록된 캐시 목록
  • GET /actuator/metrics/cache.gets : 캐시 조회 횟수
  • GET /actuator/metrics/cache.puts : 캐시 저장 횟수
  • GET /actuator/metrics/cache.evictions : 캐시 제거 횟수
flowchart LR App["애플리케이션"] -->|recordStats| Caffeine["Caffeine 통계"] Caffeine -->|메트릭 노출| Actuator["Spring Actuator"] Actuator -->|HTTP| Dashboard["모니터링 대시보드"] Actuator --> M1["/actuator/caches"] Actuator --> M2["/actuator/metrics/cache.gets"]
적중률 확인이 중요한 이유

캐시를 도입했는데 적중률이 낮다면, 키 전략이 잘못되었거나 TTL이 너무 짧거나 캐시 대상이 적절하지 않은 것이다. 적중률이 50% 미만이면 캐시를 제거하는 게 나을 수도 있다. 불필요한 복잡성만 추가한 셈이기 때문이다.

프로젝트 적용 예시

옷장을 부탁해 프로젝트에 Caffeine 캐시를 적용하는 설정 예시다.

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        cacheManager.setCaches(List.of(
            buildCache("weather", 100, 5),      // 날씨: 최대 100개, 5분
            buildCache("feeds", 500, 10),       // 피드 목록: 최대 500개, 10분
            buildCache("feed", 1000, 30)        // 피드 단건: 최대 1000개, 30분
        ));
        return cacheManager;
    }

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

날씨 데이터는 5분마다 갱신하고, 피드 단건 조회는 30분 동안 캐시한다. 피드 목록은 새 글이 올라올 수 있으므로 10분으로 짧게 잡았다.

자주 하는 실수

maximumSize 없이 Caffeine 사용하기

maximumSize를 설정하지 않으면 Caffeine도 ConcurrentHashMap처럼 무한히 항목을 쌓는다. TTL이 있어도 만료 전까지는 메모리를 계속 소비한다. 반드시 maximumSizeexpireAfterWrite를 함께 설정하라.

[!DANGER] expireAfterAccess만 단독 사용하기

expireAfterAccess만 설정하면, 인기 있는 캐시 항목은 영원히 만료되지 않는다. 계속 조회되니까 접근 시간이 계속 갱신되는 것이다. 원본 데이터가 변경되어도 캐시에는 옛날 데이터가 계속 살아 있다. expireAfterWrite를 함께 사용하거나, expireAfterWrite 단독으로 사용하는 것이 안전하다.

[!DANGER] 캐시 설정을 application.yml에만 의존하기

yml 설정 방식은 모든 캐시 영역에 동일한 설정을 적용한다. 실무에서는 영역마다 TTL이나 크기가 다른 경우가 대부분이다. Java 설정 방식이 더 유연하고, 설정의 의도를 코드 리뷰에서 명확하게 파악할 수 있다.