포스트

캐시 추상화: @Cacheable은 어떻게 동작하고 어디서 터지나

캐시 추상화: @Cacheable은 어떻게 동작하고 어디서 터지나

같은 조회를 매번 DB까지 가는 게 아까울 때

자주 바뀌지 않는데 조회는 많은 데이터(카테고리 목록, 환율, 사용자 권한)는 매번 DB를 때리는 게 낭비입니다. Spring의 캐시 추상화를 쓰면 저장소(로컬 맵, Caffeine, Redis…)가 무엇이든 애너테이션만으로 캐싱을 얹을 수 있습니다.

문제는 이 추상화를 “애너테이션 붙이면 빨라지는 마법”으로만 알면, 운영에서 정확히 세 군데서 터진다는 겁니다 — 캐시가 조용히 안 먹을 때, 캐시가 오래된 값을 줄 때, 캐시가 같은 미스를 동시에 수백 번 흘려보낼 때. 이 글은 @Cacheable을 어떤 클래스가 어떤 순서로 처리하는지부터 시작해 그 세 지점을 정확히 짚습니다.

한눈에 보는 캐시 경로 — 히트는 빠르고, 미스는 멀다

같은 메서드 호출이라도 캐시에 값이 있으면(HIT) 메서드 본문을 건너뛰고 즉시 반환하고, 없으면(MISS) DB까지 갔다가 결과를 캐시에 채우고 돌아옵니다.

CacheInterceptor AOP 프록시 캐시 조회 Cache.get(key) 반환 메서드 실행 (DB) 캐시 저장 Cache.put HIT · 본문 건너뜀 → 즉시 반환 MISS ↓

이 그림의 핵심은 HIT일 때 메서드 본문이 아예 실행되지 않는다는 점입니다. 그래서 @Cacheable 메서드 안에 부수효과(로그 적재, 카운터 증가)를 넣으면 캐시 히트 때 통째로 사라집니다.

동작 원리: CacheInterceptor라는 또 하나의 AOP 프록시

@Cacheable은 마법이 아니라 AOP 프록시입니다. @Transactional, 그리고 Spring Data 리포지토리와 완전히 같은 계열이에요. @EnableCaching이 CacheInterceptor(= MethodInterceptor)를 빈에 엮고, 메서드 호출이 프록시를 지날 때 다음을 합니다.

1
2
3
4
5
6
7
CacheInterceptor.invoke()
 └─ CacheAspectSupport.execute()
     ├─ CacheOperationSource 가 메서드의 @Cacheable/@CachePut/@CacheEvict 파싱
     ├─ key 계산 (KeyGenerator)  →  cache = CacheManager.getCache(name)
     ├─ @Cacheable: cache.get(key) 있으면 그 값 반환 (★ 본문 미실행)
     ├─ 없으면 실제 메서드 invoke → cache.put(key, result)
     └─ @CacheEvict: cache.evict(key) 또는 clear()
flowchart LR
    C[호출자] --> P["프록시<br/>CacheInterceptor"]
    P --> L{"cache.get(key)<br/>존재?"}
    L -->|HIT| R["저장값 반환<br/>(본문 skip)"]
    L -->|MISS| M["실제 메서드 실행"]
    M --> S["cache.put(key, result)"]
    S --> R

여기서 두 추상화를 구분하는 게 중요합니다. CacheManager 는 이름별 Cache를 내주는 공장이고, Cache 는 get/put/evict를 가진 저장소 한 칸입니다. 우리 코드는 이 인터페이스에만 의존하고, 실제 구현(ConcurrentMapCache, CaffeineCache, RedisCache)은 갈아끼웁니다.

세 애너테이션, 정확한 의미

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
public class ProductService {

    @Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
    public Product find(Long id) {                 // HIT면 본문 미실행
        return repository.findById(id).orElseThrow();
    }

    @CachePut(cacheNames = "product", key = "#p.id")
    public Product save(Product p) {               // 항상 실행 + 결과로 캐시 갱신
        return repository.save(p);
    }

    @CacheEvict(cacheNames = "product", key = "#id")
    public void delete(Long id) {                  // 캐시에서 제거
        repository.deleteById(id);
    }
}
애너테이션본문 실행캐시 동작쓰임
@CacheableHIT면 skipget→없으면 put조회
@CachePut항상 실행결과로 put(갱신)갱신 후 캐시 동기화
@CacheEvict항상 실행evict / allEntries=true면 clear삭제·무효화

key는 SpEL입니다. 파라미터가 여러 개면 key를 생략했을 때 SimpleKeyGenerator가 모든 인자를 묶어 SimpleKey를 만듭니다. 인자가 하나면 그 인자 자체가 키가 되죠. 그래서 파라미터가 둘인데 key를 안 주면, 의도와 다른 복합 키가 생겨 캐시가 안 맞는 일이 생깁니다 — 다중 인자 메서드는 key를 명시하세요. condition(호출 전 평가, 캐시 적용 여부)과 unless(결과 평가, 저장 제외)도 구분 포인트입니다.

함정 1: self-invocation — 또 그 프록시

프록시 기반이므로 같은 클래스 안에서 @Cacheable 메서드를 직접 호출하면 캐시가 통째로 무시됩니다.

1
2
3
4
5
6
public Product getOrLoad(Long id) {
    return find(id);   // ❌ this.find() → 프록시 우회 → 매번 DB
}

@Cacheable("product")
public Product find(Long id) { ... }

@Transactional 함정 글과 동일한 메커니즘입니다. 해결도 같아요 — 호출 대상을 별도 빈으로 분리하거나, 자기 자신을 주입(self-injection)받아 프록시를 거치게 합니다. AOP를 아는 사람은 캐시 버그의 절반을 여기서 바로 의심합니다.

함정 2: 무효화 타이밍 — 트랜잭션보다 먼저 지워진다

가장 잡기 어려운 버그입니다. @CacheEvict는 기본적으로 메서드 실행이 끝난 직후(트랜잭션 커밋과 무관하게) 캐시를 지웁니다. 그런데 그 트랜잭션이 롤백되면, 캐시는 이미 비워졌고 DB는 옛 값 그대로 → 다음 조회가 옛 값을 다시 캐시에 채웁니다. 반대로 다른 스레드가 커밋 전에 미스로 옛 값을 읽어 캐시를 오염시킬 수도 있습니다.

1
2
3
4
// 위험: 커밋 전에 evict가 일어남
@Transactional
@CacheEvict(cacheNames = "product", key = "#p.id")
public void update(Product p) { repository.save(p); }

해결은 커밋 이후에 무효화하는 것입니다. @TransactionalEventListener(phase = AFTER_COMMIT)로 이벤트를 받아 그때 evict하면, “DB는 새 값, 캐시는 비움”이 항상 보장됩니다.

함정 3: 캐시 스탬피드(thundering herd)와 sync

인기 키의 캐시가 만료된 순간, 수백 개 요청이 동시에 미스가 되어 한꺼번에 DB로 쏟아집니다. 트래픽이 몰리는 서비스에서 캐시가 오히려 장애의 방아쇠가 되는 고전적 패턴이죠.

1
2
@Cacheable(cacheNames = "product", key = "#id", sync = true)
public Product find(Long id) { ... }

sync = true면 같은 키에 대해 하나의 스레드만 본문을 실행하고 나머지는 그 결과를 기다립니다. 단, 주의: 이 잠금은 로컬 캐시 구현 한정입니다(ConcurrentMapCache·Caffeine은 키 단위 락 제공). Redis 같은 분산 캐시엔 추상화 차원의 분산 락이 없으므로, 노드 간 스탬피드는 별도 락(Redisson 등)이나 만료 지터(TTL에 ± 랜덤)로 완화해야 합니다.

저장소 교체 — 그리고 추상화가 끝나는 지점

1
2
3
4
5
spring:
  cache:
    type: redis        # none | simple | caffeine | redis ...
  data:
    redis: { host: localhost, port: 6379 }

@Cacheable 코드는 그대로 두고 CacheManager 구현만 바꾸는 게 이 추상화의 힘입니다.

구현범위TTL특징
ConcurrentMapCacheManager로컬·JVM❌(직접 만료 없음)기본값, 테스트용
Caffeine로컬·고성능✅(빌더)통계·정교한 만료, 단일 인스턴스 최적
RedisCacheManager분산·공용✅(per-cache)다중 인스턴스 일관성

여기서 TTL은 추상화 바깥이라는 점이 중요합니다. @Cacheable엔 TTL 속성이 없어요. 만료는 구현체 설정으로 줍니다.

1
2
3
4
5
6
7
8
@Bean
RedisCacheManagerBuilderCustomizer cacheTtl() {
    return b -> b.withCacheConfiguration("product",
        RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeValuesWith(SerializationPair.fromSerializer(
                new GenericJackson2JsonRedisSerializer())));   // 직렬화 명시
}

함정 4: 직렬화와 null

  • 직렬화: Redis는 값을 바이트로 저장합니다. 기본 JDK 직렬화는 Serializable 강제 + 가독성 0이라, 보통 GenericJackson2JsonRedisSerializer(타입 정보 포함)로 바꿉니다. 다만 JSON 직렬화는 다형성·LocalDateTime 등에서 설정이 필요합니다.
  • null/관통(penetration): 존재하지 않는 키를 반복 조회하면 매번 미스 → DB 직격입니다. @Cacheable은 기본적으로 null도 캐시하므로(빈 결과로 관통 방어 가능) unless = "#result == null"로 끌지, 아니면 의도적으로 유지할지를 결정해야 합니다.
  • 키 충돌: 키 네임스페이스는 cacheNames로 갈립니다. 같은 캐시에 서로 다른 의미의 키가 섞이지 않도록 캐시 이름을 도메인 단위로 나누세요.

디버깅 / 관찰

1
logging.level.org.springframework.cache=TRACE   # get/put/evict가 로그에 찍힘
  • Caffeine은 recordStats()를 켜면 hit/miss/eviction 통계가 /actuator/metrics/cache.gets 등으로 노출됩니다(메트릭 이름에 result:hit|miss 태그).
  • “캐시가 안 먹는 것 같다”의 1순위 점검은 프록시를 거치는 호출인가(self-invocation), 2순위는 키가 매 호출 동일한가(SpEL 결과 확인)입니다.

면접/리뷰 단골 질문

  • Q. @Cacheable 메서드를 같은 클래스에서 호출했더니 캐시가 안 먹는다? → AOP 프록시 우회(self-invocation). @Transactional과 동일 원리.
  • Q. @CacheEvict인데 가끔 옛 값이 보인다? → 트랜잭션 커밋 전에 evict가 일어나고 롤백/경쟁이 발생. AFTER_COMMIT 이벤트로 무효화.
  • Q. 캐시 만료 순간 DB 부하가 튄다? → 스탬피드. 로컬은 sync=true, 분산은 분산 락/TTL 지터.
  • Q. @Cacheable에 TTL을 어떻게 주나? → 못 준다. TTL은 추상화 밖이라 CacheManager 구현 설정(예: RedisCacheConfiguration.entryTtl).

정리

  • @Cacheable/@CachePut/@CacheEvict는 CacheInterceptor(AOP 프록시) 로 동작 — @Transactional과 같은 계열, self-invocation 함정 공유.
  • HIT면 메서드 본문이 실행되지 않는다(부수효과 주의).
  • 무효화는 커밋 이후(@TransactionalEventListener)에 해야 옛 값/롤백 오염을 피한다.
  • 스탬피드는 로컬 sync=true, 분산은 별도 대책.
  • TTL·직렬화·null 정책은 추상화 밖, 구현체에서 챙긴다.

관련 글: 같은 AOP 프록시 함정의 원조는 @Transactional 동작 원리와 함정, 분산 캐시 저장소로서의 Redis는 Redis + Spring Boot 클러스터에서 다룹니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.