시리즈 안내

1. 캐시란 무엇인가

2. Spring Cache 추상화

3. Caffeine 설정과 활용 ← 현재 문서

4. LoadingCache와 캐시 스탬피드

5. Actuator로 캐시 모니터링

Caffeine이란

Caffeine은 Java에서 가장 빠른 로컬 캐시 라이브러리다. Google Guava Cache의 후속작으로, 더 높은 적중률과 더 나은 성능을 제공한다. Spring Boot에서 로컬 캐시를 쓴다면 사실상 Caffeine이 표준이다.

왜 Caffeine이 빠른가? 내부적으로 W-TinyLFU라는 제거 알고리즘을 사용한다. 이건 LRU(최근 안 쓴 것 제거)와 LFU(적게 쓴 것 제거)의 장점을 결합한 것으로, 대부분의 워크로드에서 다른 캐시 대비 높은 적중률을 보인다. 별도 튜닝 없이도 좋은 성능을 낸다.

@Cacheable이 호출되면 요청이 어떤 레이어를 거쳐 Caffeine까지 도달하는지 흐름을 보면 이해가 쉽다.

flowchart TD subgraph Spring ["Spring Cache 추상화 영역"] Ann["@Cacheable 등
어노테이션"] CM["CacheManager
(인터페이스)"] SCM["SimpleCacheManager
(구현체)"] end subgraph Adapter ["어댑터 영역"] CC["CaffeineCache
(Spring Cache 어댑터)"] end subgraph Native ["Caffeine 라이브러리"] Caf["Caffeine
(실제 로컬 캐시 엔진)"] end Ann -->|캐시 요청 위임| CM CM -.->|구현체 사용| SCM SCM -->|여러 캐시 인스턴스 관리| CC CC -->|실제 데이터 저장/조회| Caf style Ann fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style CM fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style SCM fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style CC fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style Caf fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000
  • CacheManager : Spring Cache 추상화의 핵심 인터페이스. @Cacheable이 이걸 통해 캐시에 접근한다.
  • SimpleCacheManager : CaffeineCache 목록을 직접 등록해서 관리하는 구현체.
  • CaffeineCache : Spring의 Cache 인터페이스를 Caffeine으로 감싼 어댑터.
  • Caffeine : 실제 캐시 인스턴스를 빌드하는 빌더. 여기서 TTL, 크기, 통계 등을 설정한다.

의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'
  • spring-boot-starter-cache : Spring Cache 추상화 (@Cacheable 등)
  • caffeine : 실제 캐시 구현체

Spring Boot는 classpath에 Caffeine이 있으면 자동으로 CaffeineCacheManager를 구성한다. 별도 설정 없이도 @Cacheable이 Caffeine 캐시를 사용한다.

application.yml로 설정하기

가장 간단한 설정 방법이다.

spring:
  cache:
    type: caffeine
    cache-names:
      - users
      - channels
      - notifications
    caffeine:
      spec: >
        maximumSize=100,
        expireAfterAccess=600s
설정의미
type: caffeine캐시 구현체로 Caffeine을 사용
cache-names사용할 캐시 이름 목록. @Cacheable("users")"users"와 일치해야 함
caffeine.specCaffeine 설정을 문자열로 지정

spec에서 사용할 수 있는 주요 옵션은 다음과 같다.

spec 옵션의미예시
maximumSize캐시 항목 최대 개수maximumSize=100
expireAfterWrite저장 후 일정 시간이 지나면 만료expireAfterWrite=300s
expireAfterAccess마지막 접근 후 일정 시간이 지나면 만료expireAfterAccess=600s
recordStats통계 수집 활성화 (Actuator 연동)recordStats
expireAfterWrite vs expireAfterAccess

- expireAfterWrite : 데이터가 캐시에 저장된 시점부터 카운트. 자주 읽혀도 TTL이 지나면 만료.

- expireAfterAccess : 마지막으로 읽히거나 쓴 시점부터 카운트. 계속 읽히면 영원히 안 만료.

데이터 신선도가 중요하면 expireAfterWrite를 써야 한다. expireAfterAccess는 자주 조회되는 데이터가 오래된 채로 유지될 수 있다.

두 정책이 캐시 항목의 생명주기에 어떻게 다르게 작용하는지 나란히 놓으면 차이가 명확하다.

flowchart TD subgraph EAW ["expireAfterWrite (생성 시점 기준 TTL)"] direction LR Start1((시작)) -->|데이터 저장| Valid1["유효 상태"] Valid1 -->|조회/읽기 발생
타이머 유지| Valid1 Valid1 -->|지정된 시간 경과| Expired1["만료됨 (Expired)"] end EAW ~~~ EAA subgraph EAA ["expireAfterAccess (최근 접근 기준 TTL)"] direction LR Start2((시작)) -->|데이터 저장| Valid2["유효 상태"] Valid2 -->|조회/읽기 발생
★ 타이머 초기화!| Valid2 Valid2 -->|지정된 시간 동안 미조회| Expired2["만료됨 (Expired)"] end style Valid1 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style Valid2 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style Expired1 fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style Expired2 fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style EAW fill:#FFFFFF,stroke:#CCC,stroke-dasharray: 5 5,color:#000 style EAA fill:#FFFFFF,stroke:#CCC,stroke-dasharray: 5 5,color:#000
  • expireAfterWrite : 읽기가 발생해도 타이머는 저장 시점에 고정. 신선도 보장.
  • expireAfterAccess : 읽을 때마다 타이머 리셋. 계속 읽히면 영원히 유지될 수 있다.

Java Config로 설정하기

캐시마다 다른 설정을 적용하고 싶으면 Java Config가 필요하다. application.ymlspec은 모든 캐시에 동일하게 적용되기 때문이다.

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        cacheManager.setCaches(List.of(
            buildCache("users", 200, 600),
            buildCache("channels", 500, 300),
            buildCache("notifications", 100, 60)
        ));
        return cacheManager;
    }

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

이제 각 캐시가 독립적인 설정을 가진다.

캐시최대 크기TTL이유
users20010분사용자는 자주 안 바뀜. 오래 유지해도 됨
channels5005분채널 목록은 적당히 자주 바뀜
notifications1001분알림은 실시간성이 중요. 짧게 유지
yml vs Java Config

- 모든 캐시에 같은 설정 → application.yml이 간단

- 캐시마다 다른 설정 → Java Config 필수

- 둘 다 있으면 Java Config가 우선

TTL과 최대 크기 전략

캐시 설정에서 가장 중요한 두 가지 결정은 "얼마나 오래 유지할 것인가(TTL)"와 "몇 개까지 저장할 것인가(maximumSize)"다.

TTL 결정 기준

데이터가 오래돼도 괜찮은 정도로 TTL을 정한다.

  • "사용자 목록이 10분 동안 안 바뀌어도 괜찮은가?" → 괜찮다면 TTL 10분
  • "알림이 1분 동안 안 바뀌어도 괜찮은가?" → 좀 아쉽지만 허용 가능하면 TTL 1분
  • "결제 잔액이 1초라도 틀리면 안 되는가?" → 캐시 X

TTL이 너무 짧으면 캐시의 의미가 없고 (적중률 낮음), 너무 길면 오래된 데이터를 보여줄 위험이 있다.

maximumSize 결정 기준

JVM 힙 메모리와 데이터 크기를 고려한다. List<UserDto> 100개가 각각 1KB면, 캐시 100항목은 ~100KB를 차지한다. JVM 힙이 512MB면 여유가 넉넉하지만, 캐시에 이미지 바이너리 같은 큰 데이터를 넣으면 금방 메모리가 부족해진다.

maximumSize를 초과하면 Caffeine이 W-TinyLFU 알고리즘에 따라 가장 덜 유용한 항목을 자동으로 제거한다. 개발자가 직접 제거 대상을 선정할 필요가 없다.

처음에는 넉넉하게 잡고 모니터링하면서 조절

정확한 값을 처음부터 알기 어렵다. maximumSize=500 정도로 시작하고, 5. Actuator로 캐시 모니터링에서 다루는 통계를 보면서 적중률과 제거 빈도를 확인한 뒤 조절하면 된다.

@CacheEvict 적용 패턴

캐시를 적용했으면, 데이터가 변경되는 모든 곳@CacheEvict를 빠짐없이 넣어야 한다. 이 시리즈에서 계속 강조한 점이다.

Discordit 미션을 예로 들면 이렇다.

// 조회 — 캐시 적용
@Cacheable("channels")
public List<ChannelDto> getChannelsByUserId(UUID userId) { ... }

// 변경 — 캐시 무효화
@CacheEvict(value = "channels", allEntries = true)
public ChannelDto createChannel(ChannelCreateRequest request) { ... }

@CacheEvict(value = "channels", allEntries = true)
public ChannelDto updateChannel(UUID channelId, ChannelUpdateRequest request) { ... }

@CacheEvict(value = "channels", allEntries = true)
public void deleteChannel(UUID channelId) { ... }

allEntries = true를 쓰는 이유는, 채널이 추가되면 어떤 사용자의 목록에 영향을 주는지 모르기 때문이다. 특정 키만 삭제하려면 영향을 받는 모든 사용자의 키를 알아야 하는데, 이는 복잡하다. allEntries = true가 더 안전하다.

Q&A

application.yml 설정과 Java Config 중 어떤 걸 써야 하나요?

캐시가 하나이거나 모든 캐시에 같은 설정을 적용할 때는 application.yml이 더 간결하다. 캐시마다 TTL이나 maximumSize를 다르게 가져가야 한다면 Java Config가 필수다. 실무에서는 캐시별로 다른 전략이 필요한 경우가 대부분이라 Java Config를 주로 쓴다. 둘 다 있으면 Java Config가 우선된다.

[!QUESTION] maximumSize를 초과하면 어떻게 되나요?

Caffeine이 W-TinyLFU 알고리즘으로 가장 덜 유용한 항목을 자동으로 제거한다. 개발자가 직접 제거 대상을 선정할 필요가 없다. 단, 제거가 빈번하면 적중률이 낮아진다. 5. Actuator로 캐시 모니터링에서 cache.evictions 메트릭을 확인해 maximumSize 조정 여부를 판단하면 된다.

[!QUESTION] recordStats를 켜면 성능이 떨어지나요?

통계 수집이 약간의 오버헤드를 추가하는 건 맞다. 그러나 대부분의 애플리케이션에서 체감할 수 있는 수준이 아니다. 개발/스테이징 환경에서는 항상 켜두고 모니터링하는 것이 좋다. 극단적인 성능이 필요한 운영 환경에서만 선택적으로 끄면 된다.

[!QUESTION] expireAfterWrite와 expireAfterAccess를 동시에 설정하면?

동시에 설정할 수 없다. 둘 중 하나만 사용해야 한다. 설정 파일에서 spec에 둘 다 넣으면 마지막에 설정된 것이 적용된다. Java Config에서 빌더 메서드를 둘 다 호출하면 IllegalStateException이 발생한다. refreshAfterWrite와는 함께 쓸 수 있다.