로컬 캐시(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() 는 프록시를 거치지 않아 캐시가 전혀 적용되지 않는다@Serviceclass 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) // 자기 호출: 캐시 조회가 일어나지 않는다}// 올바른 패턴: 캐시 메서드를 별도 빈으로 분리해 호출부가 항상 프록시를 거치게 한다@Serviceclass ProductCache(private val productRepository: ProductRepository) { @Cacheable(cacheNames = ["product"], key = "#productId") fun findBy(productId: Long): ProductResponse = ProductResponse.from(productRepository.getBy(productId))}@Serviceclass ProductQueryService(private val productCache: ProductCache) { fun findWithDiscount(productId: Long, rate: Int): ProductResponse = productCache.findBy(productId).applyDiscount(rate) // 외부 빈 경유 → 프록시를 거친다}
캐시 조회·갱신·삭제를 애너테이션 하나로 처리할 수 있나?
@Cacheable: 캐시에 값이 있으면 메서드 실행을 건너뛴다.
@CachePut: 값을 변경하는 메서드에 붙인다. 변경된 값으로 캐시를 갱신한다.
@CacheEvict: 항목을 제거한다(allEntries면 전체 비움). @Caching은 여러 캐시 애너테이션을 조합한다.
@Serviceclass 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@EnableCachingclass 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초가 지난 뒤 조회가 들어오면 오래된 값을 우선 반환하며 새 값을 백그라운드에서 계산한다@Componentclass 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로 동작하고, 로컬은 한 계층위에서 동작한다.