[Spring] 로컬 캐시: Caffeine과 캐시 전략

개요

로컬 캐시(local cache)는 조회 결과의 사본을 애플리케이션 서버 자신의 메모리에 두는 캐시다. 원본 데이터베이스(database) 접근 횟수를 줄여 응답을 더 빠르게 만드는 것이 목적이다. 스프링은 @Cacheable을 프록시(proxy) 기반으로 구현해 캐시 확인과 원본 조회를 가로챈다. 이 방식은 같은 클래스 안에서의 자기 호출(self-invocation)에는 적용되지 않는다. 캐시 제거 정책, 캐시 스탬피드(cache stampede)·페너트레이션(cache penetration) 방어, 캐시 선택 기준을 다룬다.

캐시 읽기 전략: 미스가 나면 누가 원본을 조회하나

전략동작캐시 장애 시 영향적합 상황
Look Aside애플리케이션이 캐시를 먼저 보고, 미스면 직접 원본을 조회해 캐시에 채운다애플리케이션이 원본을 직접 조회할 수 있어 서비스 전체 장애로 번지지 않는다캐시 구현체를 자유롭게 바꾸거나, 캐시 장애를 서비스 장애와 분리하고 싶은 경우
Read Through캐시 저장소 자체가 미스 시 원본을 조회해 스스로 갱신한다모든 조회의 진입점이 캐시 하나라 단일 장애 지점(single point of failure)이 된다캐시 계층이 조회 경로를 완전히 추상화해야 하는 경우
  • @Cacheable은 Look Aside를 구현한 것이다.
  • 캐시가 없으면 메서드 자신이 원본을 조회하고, 그 결과를 캐시가 가로채 적재한다.

스프링 캐시 추상화는 프록시로 캐시를 가로챈다

sequenceDiagram
    participant Caller as 호출자
    participant Proxy as ProductCache(프록시)
    participant Real as ProductCache(원본)
    participant DB as 원본 저장소

    Caller->>Proxy: findBy(productId)
    alt 캐시 히트
        Proxy-->>Caller: 캐시된 값 반환 (원본 메서드 미실행)
    else 캐시 미스
        Proxy->>Real: findBy(productId) 실행
        Real->>DB: SELECT
        DB-->>Real: Product
        Real-->>Proxy: ProductResponse
        Proxy->>Proxy: 캐시에 적재
        Proxy-->>Caller: ProductResponse 반환
    end

같은 클래스 안에서 캐시 메서드를 부르면 왜 무시되나?

  • this로 자기 메서드를 부르면 프록시를 거치지 않는다(self-invocation).
// 잘못된 패턴: this.findBy() 는 프록시를 거치지 않아 캐시가 전혀 적용되지 않는다
@Service
class ProductQueryServiceBad(private val productRepository: ProductRepository) {
    @Cacheable(cacheNames = ["product"], key = "#productId")
    fun findBy(productId: Long): ProductResponse = ProductResponse.from(productRepository.getBy(productId))
 
    fun findWithDiscount(productId: Long, rate: Int): ProductResponse =
        findBy(productId).applyDiscount(rate)   // 자기 호출: 캐시 조회가 일어나지 않는다
}
 
// 올바른 패턴: 캐시 메서드를 별도 빈으로 분리해 호출부가 항상 프록시를 거치게 한다
@Service
class ProductCache(private val productRepository: ProductRepository) {
    @Cacheable(cacheNames = ["product"], key = "#productId")
    fun findBy(productId: Long): ProductResponse = ProductResponse.from(productRepository.getBy(productId))
}
 
@Service
class ProductQueryService(private val productCache: ProductCache) {
    fun findWithDiscount(productId: Long, rate: Int): ProductResponse =
        productCache.findBy(productId).applyDiscount(rate)   // 외부 빈 경유 → 프록시를 거친다
}

캐시 조회·갱신·삭제를 애너테이션 하나로 처리할 수 있나?

  • @Cacheable: 캐시에 값이 있으면 메서드 실행을 건너뛴다.
  • @CachePut: 값을 변경하는 메서드에 붙인다. 변경된 값으로 캐시를 갱신한다.
  • @CacheEvict: 항목을 제거한다(allEntries면 전체 비움). @Caching은 여러 캐시 애너테이션을 조합한다.
@Service
class ProductAdminService(private val productRepository: ProductRepository) {
    @CachePut(cacheNames = ["product"], key = "#productId")
    fun updatePrice(productId: Long, newPrice: Long): ProductResponse {
        val product = productRepository.getBy(productId).apply { changePrice(newPrice) }
        return ProductResponse.from(productRepository.save(product))
    }
 
    @CacheEvict(cacheNames = ["product"], key = "#productId")
    fun remove(productId: Long) = productRepository.deleteBy(productId)
}

파라미터 없는 메서드의 캐시 키는 왜 충돌하나?

  • key를 생략하면 스프링은 파라미터 조합으로 키를 자동 생성한다.
  • 파라미터가 없는 메서드는 모두 같은 빈 키로 취급된다.
  • 같은 캐시 이름을 쓰면서, 인자가 없는 서로 다른 메서드가 있으면 한쪽의 캐시가 다른 쪽을 덮어쓴다.

Caffeine의 만료 정책과 크기 제한

정책만료 기준성격적합 상황
expireAfterWrite쓰인 시점부터 지정 시간 경과, 접근 여부 무관TTL(Time To Live)원본이 주기적으로 갱신되는 데이터
expireAfterAccess마지막 접근부터 지정 시간 경과TTI(Time To Idle)접근이 적어진 항목만 골라 정리하고 싶은 경우
refreshAfterWrite지정 시간 경과 후 다음 조회 시 오래된 값을 즉시 반환하며 백그라운드로 갱신캐시가 비는 순간 자체가 없음캐시 스탬피드를 줄이고 싶은 경우
  • maximumSize는 항목 개수로, maximumWeight는 항목별 가중치 합으로 상한을 둔다.
  • maximumWeight는 바이트 배열처럼 크기가 제각각인 값에 weigher와 함께 쓰며, maximumSize와 동시에 쓸 수 없다.
@Configuration
@EnableCaching
class CacheConfig {
    @Bean
    fun cacheManager(): CacheManager {
        val manager = CaffeineCacheManager()
        manager.registerCustomCache("product", productCache())
        return manager
    }
 
    private fun productCache(): Cache<Any, Any> =
        Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofMinutes(5))
            .maximumSize(10_000)
            .recordStats()
            .build()
}

Window TinyLfu가 기본값으로 오염을 막는 이유

정책약점원인
LRU(Least Recently Used)배치처럼 한 번씩만 스쳐가는 순차 조회가 몰리면 자주 쓰이는 항목까지 밀려난다최근성만 보고 빈도를 보지 않는다
LFU(Least Frequently Used)과거에 많이 조회됐지만 지금은 안 쓰이는 항목이 누적 빈도 때문에 계속 남는다빈도만 보고 최근성을 보지 않는다

Caffeine은 캐시 공간을 세 구역으로 나누고, 구역 사이를 옮겨 다닐 자격을 조회 빈도로 따진다.

구역기본 크기들어 있는 것
Window전체의 1%방금 처음 조회된 신규 항목, 아무 심사 없이 들어온다
Probation남은 99%의 20%심사는 통과했지만 아직 재조회로 검증되지 않은 항목
Protected남은 99%의 80%재조회로 자주 쓰인다고 확인된 항목

순차 스캔과 LRU

sequenceDiagram
    participant Batch as 배치 스캔
    participant LRU as LRU 캐시
    participant Hot as 인기 상품 재조회

    Note over LRU: 힙 슬롯(예시 용량 3, MRU→LRU) [S2][S1][P], P 는 인기 상품이지만 대기열 끝에 가깝다
    loop 순차 스캔 20건 (S1~S20, 각각 한 번만 조회)
        Batch->>LRU: get(Sn)
        LRU-->>Batch: 미스, 가장 오래 방치된 항목을 축출하고 적재
    end
    Note over LRU: 힙 슬롯 [S20][S19][S18], P 는 스캔에 밀려 이미 축출됐다
    Hot->>LRU: get(P)
    LRU-->>Hot: 미스, P 가 스캔에 밀려 이미 축출됐다
  • LRU는 비교적 조회가 없는 데이터일지라도 조회가 잦은 데이터와 경쟁하게 되고, 스캔이 끝나면 조회가 잦은 데이터가 밀려난다.

순차 스캔과 Window TinyLfu

sequenceDiagram
    participant Batch as 배치 스캔
    participant TLFU as Window TinyLfu 캐시
    participant Hot as 인기 상품 재조회

    Note over TLFU: 힙 슬롯(예시 용량 3 = Window 1 + Probation 1 + Protected 1) [Window:S2][Probation:-][Protected:P]
    loop 순차 스캔 20건 (S1~S20, 각각 한 번만 조회)
        Batch->>TLFU: get(Sn)
        TLFU-->>Batch: 미스, Window 안에서만 적재·축출
    end
    Note over TLFU: 힙 슬롯 [Window:S20][Probation:-][Protected:P], Window 를 벗어나려면 재조회로 빈도 경쟁을 이겨야 하는데 스캔은 한 번뿐이라 대부분 여기서 진다
    Hot->>TLFU: get(P)
    TLFU-->>Hot: 히트, P 는 Protected 영역에 그대로 남아 있었다
  • Window TinyLfu는 순차 스캔이 발생하더라도 빈도 추정기로 인해 제거 대상으로 밀리지 않는다.
  • 후순위 대상으로 밀릴지라도, 재조회가 들어오면 window 혹은 probation 영역으로 프로모션할 수 있다.

LFU

sequenceDiagram
    participant Req as 요청
    participant LFU as LFU 캐시

    Note over LFU: 힙 슬롯(예시 용량 3) [H:9][M:3][X:1], H 는 예전 인기 항목이라 최근엔 조회되지 않는다
    Req->>LFU: get(Q) (신규 항목 첫 조회)
    LFU-->>Req: 미스, 최소 빈도 X(1) 를 축출하고 Q(1) 로 적재
    Note over LFU: 힙 슬롯 [H:9][M:3][Q:1], X 축출됨
    Req->>LFU: get(R) (또 다른 신규 항목)
    LFU-->>Req: 미스, 최소 빈도 Q(1) 를 축출하고 R(1) 로 적재
    Note over LFU: 힙 슬롯 [H:9][M:3][R:1], Q 축출됨, H 는 여전히 안 쓰여도 남는다
  • H는 마지막 조회 이후 한 번도 조회되지 않았지만, 누적 빈도 덕분에 계속 살아남는다.
  • Q·R 처럼 막 들어온 항목은 빈도가 낮아, 다음 신규 조회가 오면 먼저 제거된다.

캐시 스탬피드와 페너트레이션 방어

만료 순간 요청이 몰리면 원본은 어떻게 되나?

캐시 스탬피드는 캐시 만료 순간 같은 키를 노리던 다수 요청이 동시에 원본으로 몰리는 현상이다. TTL이 여러 키에 걸쳐 같은 시각에 맞춰져 있으면 그 시각마다 트래픽들이 원본을 조회한다.

sequenceDiagram
    participant R1 as 요청 1
    participant R2 as 요청 2
    participant R3 as 요청 3
    participant Cache as 캐시
    participant DB as 원본 저장소

    Note over Cache: TTL 만료로 항목 제거
    R1->>Cache: get(key)
    R2->>Cache: get(key)
    R3->>Cache: get(key)
    Cache-->>R1: 미스
    Cache-->>R2: 미스
    Cache-->>R3: 미스
    R1->>DB: SELECT
    R2->>DB: SELECT
    R3->>DB: SELECT
    Note over DB: 같은 키를 위한 동일 쿼리가 3번 실행된다
방식동작비용
락(lock)가장 먼저 도착한 요청만 원본을 조회하고, 나머지는 대기했다가 갱신된 캐시를 읽는다대기 요청이 많을수록 스레드·커넥션이 오래 점유된다
조기 갱신만료 전에 스케줄러가 미리 전체 캐시를 다시 채운다조회되지 않을 항목까지 주기마다 통째로 갱신해 계산 비용이 크다
PER(Probabilistic Early Recomputation)만료 시각이 가까워질수록 확률적으로 일부 요청만 미리 갱신을 트리거한다계산 비용은 낮지만 확률 기반이라 완벽한 차단은 아니다
  • Caffeine의 refreshAfterWrite는 PER에 가까운 효과를 내는 내장 기능이다.
  • 실질적 백그라운드 갱신에는 CacheLoader가 있는 LoadingCache가 필요하다.
  • @Cacheable은 미스마다 메서드를 실행하는 방식이라 결이 달라, 이런 캐시는 LoadingCache를 직접 쓴다.
// 10초가 지난 뒤 조회가 들어오면 오래된 값을 우선 반환하며 새 값을 백그라운드에서 계산한다
@Component
class ExchangeRateLocalCache(private val exchangeRateGateway: ExchangeRateGateway) {
    private val cache: LoadingCache<String, BigDecimal> = Caffeine.newBuilder()
        .refreshAfterWrite(Duration.ofSeconds(10))
        .maximumSize(100)
        .recordStats()
        .build { code -> exchangeRateGateway.fetchRate(code) }
 
    fun rateOf(code: String): BigDecimal = cache.get(code)
}

존재하지 않는 키를 반복 조회하면 왜 원본까지 가나?

  • 캐시 페너트레이션은 존재하지 않는 키의 조회가 캐시를 거치지 않고 매번 원본까지 가는 현상이다.
  • 조회 결과가 없으면 보통 기록하지 않아 동일 조회가 계속 미스이므로, 빈 값 자체를 캐싱해 차단한다.
  • null 캐싱은 미조회와 미존재를 구분 못 하므로, 빈 상태 전용 응답 타입을 둔다.

히트율 관측과 로컬 대 분산 캐시 선택

캐시가 실제로 효과가 있는지 어떻게 확인하나?

  • Caffeine은 recordStats()를 켜면 히트율·미스 수·제거 수를 CacheStats로 제공하며, 끄면 항상 0이다.
  • 히트율이 꾸준히 낮다면 같은 키가 다시 조회될 확률 자체가 낮은 데이터라는 뜻이다.
  • 이런 경우 만료 시간 조정보다 그 데이터에 캐시가 맞는 선택인지부터 재검토한다.

로컬 캐시와 분산 캐시 중 무엇을 고르나?

기준로컬 캐시 (Caffeine)분산 캐시 (Redis)
속도네트워크 홉이 없어 가장 빠르다네트워크 한 홉을 거쳐 로컬보다 느리다
정합성서버마다 값이 달라질 수 있다모든 서버가 같은 값을 공유한다
용량단일 JVM(Java Virtual Machine) 힙 한계 안에서만 확장한다캐시 서버 메모리만큼 별도로 확장한다
재시작 내구성서버가 재시작되면 캐시가 사라진다애플리케이션 재시작과 무관하다
  • 두 캐시를 함께 쓰는 구성도 흔하다. 분산 캐시가 ssot로 동작하고, 로컬은 한 계층위에서 동작한다.
  • 갱신 지연 반영을 관리해야 하므로, 분산 캐시조차 부담되는 조회량일 때만 적용한다.

언제 무엇을 쓰는가

상황선택근거
조회는 잦고 변경은 드문 데이터, 서버마다 값이 달라도 되는 경우로컬 캐시(Caffeine)네트워크 홉이 없어 가장 빠르고 구성이 단순하다
여러 서버가 반드시 같은 값을 봐야 하는 세션·잔액·재고분산 캐시(Redis)로컬 캐시는 서버마다 값이 달라질 수 있다
파라미터가 없는 메서드를 캐싱해야 함key를 SpEL(Spring Expression Language) 로 명시기본 키 생성기는 무인자 메서드를 모두 같은 빈 키로 취급해 캐시가 서로 덮어쓴다
같은 클래스 안에서 캐시 메서드를 호출해야 하는 구조캐시 메서드를 별도 빈으로 분리자기 호출은 프록시를 거치지 않아 캐시가 적용되지 않는다
TTL 이 여러 키에 몰려 만료 순간 원본이 폭주하는 경우refreshAfterWrite(PER) 또는 락조기 갱신은 계산 비용이 크고, PER 은 갱신 시점을 자연스럽게 분산한다
존재하지 않는 키가 반복 조회되는 경우빈 값도 캐싱하거나 블룸 필터로 사전 차단캐싱하지 않으면 매 조회가 원본까지 흘러간다
히트율이 낮거나 재조회 확률이 구조적으로 낮은 경우만료 시간 조정보다 캐시 자체를 재검토하거나 걷어낸다recordStats() 없이는 효과를 알 수 없고, 없다면 무효화 비용만 남는다