본문으로 건너뛰기
개발 머꼬
개발 노트Spring
hohyeon.dev20

@Cacheable을 붙였는데 캐시가 전혀 안 되는 것처럼 보인 이유

  • #Caching
  • #Engineering Note
  • #Spring

문제 발생

조회 결과를 캐싱하려고 @Cacheable을 붙였는데, 로그를 보니 캐시에 있어야 할 값도 매번 실제 DB 쿼리가 다시 실행되고 있었습니다.

@Service
public class ProductService {
    @Cacheable("products")
    public Product findById(Long id) {
        return repository.findById(id).orElseThrow();
    }

    public void doSomething(Long id) {
        Product p = findById(id); // 같은 클래스 안에서 직접 호출 — 캐싱 안 됨
    }
}

원인 분석

Spring의 @Cacheable(그리고 @Transactional, @Async도 마찬가지)은 AOP 프록시를 통해 동작합니다. Spring이 ProductService를 빈으로 등록할 때, 실제로는 원본 클래스를 감싼 프록시 객체를 만들고 그 프록시가 캐싱 로직(조회 전 캐시 확인, 결과를 캐시에 저장)을 수행한 뒤 실제 메서드를 호출합니다.

문제는 이 프록시가 외부에서 그 빈을 호출할 때만 개입한다는 점입니다. doSomething() 안에서 findById(id)를 호출하는 것은 this.findById(id)와 같은 뜻이고, this는 프록시가 아니라 원본 객체 자신을 가리킵니다 — 프록시를 거치지 않고 메서드가 직접 호출되므로, 캐싱 로직이 통째로 우회됩니다. 이걸 self-invocation(자기 호출) 문제라고 부르며, Spring 공식 문서가 AOP 프록시 기반 기능들의 공통 제약으로 명시하고 있습니다.

해결 방안

  1. 가장 근본적인 해결책은 설계를 바꾸는 것입니다 — 캐싱이 필요한 메서드를 별도의 빈으로 분리해 외부에서 호출하도록 만듭니다.
@Service
public class ProductService {
    private final ProductCacheService cacheService;

    public void doSomething(Long id) {
        Product p = cacheService.findById(id); // 다른 빈을 거치므로 프록시가 개입함
    }
}

@Service
public class ProductCacheService {
    @Cacheable("products")
    public Product findById(Long id) { ... }
}
  1. 구조를 바꾸기 어렵다면, 스프링 컨텍스트에서 자기 자신의 프록시를 주입받아 그걸 통해 호출하는 방법도 있습니다(@Lazy 자기 참조 주입, 또는 AopContext.currentProxy() — 후자는 exposeProxy=true 설정이 추가로 필요합니다). 다만 이 방식은 코드가 다소 어색해지므로, 가능하면 1번처럼 메서드를 다른 빈으로 옮기는 것이 더 권장됩니다.
  2. @Transactional, @Async도 완전히 동일한 제약을 받습니다 — 하나를 이해하면 셋 다 같은 원리로 디버깅할 수 있습니다.
  3. 이 문제는 컴파일이나 실행 자체는 정상적으로 되고 그냥 "기능이 조용히 동작 안 하는" 형태로 나타나므로, 캐싱/트랜잭션/비동기 관련 애너테이션이 안 먹히는 것 같다면 항상 self-invocation부터 의심하는 것이 진단 순서상 효율적입니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.