개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › Spring › 그 밖의 기능

캐시 추상화

@Cacheable을 붙이면 메서드 본문은 언제 실행되지 않나

스프링에서는 메서드에 @Cacheable을 붙이기만 하면, 같은 인자로 다시 부를 때 본문을 실행하지 않고 전에 돌려준 값을 캐시에서 꺼내 줍니다. 캐시를 조회하고 저장하는 코드는 프록시가 대신 실행합니다. 어떤 캐시 저장소를 쓰든 같은 애너테이션으로 다룰 수 있게 스프링이 중간에 둔 층을 캐시 추상화라고 합니다.

실행으로 대조 · 스프링 프레임워크 7.0.9의 @EnableCaching과 ConcurrentMapCacheManager로 마흔 가지 조합을 실행해 대조

이 실습에 나오는 개념

@Cacheable
메서드의 결과를 캐시에 넣어 두고, 같은 인자로 다시 부르면 본문을 실행하지 않고 캐시의 값을 돌려주게 하는 애너테이션이다. 프록시가 호출을 받아 캐시를 먼저 찾아보고, 값이 없을 때만 본문을 실행한 뒤 결과를 저장한다. 캐시의 키는 기본으로 메서드의 인자이며, cacheNames로 어느 캐시에 넣을지 정한다. unless에 조건을 쓰면 그 조건이 참인 결과는 저장하지 않는다(예: 결과가 null일 때). 프록시가 하는 일이므로 같은 클래스 안에서 this로 부르면 적용되지 않는다.
@CacheEvict와 @CachePut
캐시의 값을 원본 데이터와 맞추는 데 쓰는 두 애너테이션이다. @CacheEvict는 메서드가 불리면 캐시에서 해당 키의 값을 지워, 다음 조회가 본문을 다시 실행하게 한다. @CachePut은 @Cacheable과 달리 본문을 건너뛰지 않고 항상 실행하며, 돌려준 값을 캐시에 넣어 기존 값을 덮어쓴다. 데이터를 바꾸는 메서드에 둘 중 하나를 붙이지 않으면 캐시에 바뀌기 전의 값이 남아, 조회하는 쪽이 옛 값을 받게 된다.
캐시
자주 사용하는 데이터나 계산 결과를 빠르게 접근할 수 있는 곳에 미리 저장해 두고 재사용하는 것이다. 원본(주로 데이터베이스)을 매번 조회하지 않아 응답이 빨라지고 원본의 부하가 줄어든다. 대신 원본이 바뀌었을 때 캐시의 값이 오래된 상태로 남는 문제를 관리해야 한다.
프록시(Proxy)
실제 객체를 대신해 호출을 받는 대리 객체다. 호출을 받으면 부가 작업을 수행한 뒤 실제 객체에 전달한다. 스프링은 AOP와 @Transactional 같은 기능을 적용할 빈을 프록시로 감싸 컨테이너에 등록하므로, 다른 빈이 주입받는 것은 실제 객체가 아니라 프록시다.
자기 호출(Self-invocation)
같은 객체 안에서 자신의 다른 메서드를 직접 호출하는 것이다. 프록시 기반의 기능은 외부에서 프록시를 거쳐 들어오는 호출에만 적용되므로, 내부에서 this로 호출한 메서드에는 @Transactional, @Async, @Cacheable 같은 설정이 적용되지 않는다. 해당 메서드를 다른 빈으로 분리하는 것이 일반적인 해결책이다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. productService.find(1L) 호출 (첫 번째): 다른 빈이 부릅니다. 호출은 프록시에 먼저 도착합니다. 프록시는 @Cacheable을 보고, 본문을 실행하기 전에 캐시 "product"에서 키 1을 찾아봅니다. 프록시는 캐시가 실제로 어디에 저장되는지 모릅니다. CacheManager가 내준 Cache 객체에 get과 put을 부를 뿐이어서, 저장소를 메모리의 맵에서 Redis로 바꿔도 이 코드와 애너테이션은 그대로입니다. 스프링은 이 구조를 캐시 추상화라고 부릅니다.
  2. 캐시 조회: 키 1이 없습니다. 아직 아무것도 넣은 적이 없습니다. 프록시가 실제 객체의 find를 부릅니다.
  3. find 본문 실행 (1번째): findCount가 1이 됩니다. DB에서 id 1인 상품을 찾아 "키보드"를 돌려줍니다.
  4. 프록시가 결과를 캐시에 저장: 키 1 → 키보드. 다음에 같은 인자로 부르면 이 값을 씁니다.
  5. productService.find(1L) 호출 (두 번째): 다른 빈이 부릅니다. 호출은 프록시에 먼저 도착합니다. 프록시는 @Cacheable을 보고, 본문을 실행하기 전에 캐시 "product"에서 키 1을 찾아봅니다.
  6. 캐시 조회: 키 1이 있습니다. 프록시는 find의 본문을 실행하지 않고 캐시에 든 "키보드"를 돌려줍니다. 본문의 실행 횟수는 1번 그대로입니다.

실행 결과

코드

// 다른 빈에서 이 순서로 부른다
productService.find(1L);
productService.find(1L);

@Service
class ProductService {
    // DB 대신 쓰는 맵
    private final Map<Long, String> db = new HashMap<>(
        Map.of(1L, "키보드", 2L, "마우스"));
    private int findCount;

    @Cacheable(cacheNames = "product")
    public String find(Long id) {
        findCount++;                 // 본문이 실행된 횟수
        return db.get(id);           // 없으면 null
    }
}

이 모형의 전제

마흔 가지 조합 모두를 이 PC의 스프링 프레임워크 7.0.9(스프링 부트 4.1.1에 들어 있는 버전)에서 @EnableCaching과 ConcurrentMapCacheManager로 실행해, find 본문이 실행된 횟수, 두 번째 find가 돌려준 값, 끝난 뒤 캐시에 든 것을 대조했습니다. rename의 본문이 캐시에 값이 있어도 실행된다는 것, 캐시에 없는 키를 @CacheEvict로 지워도 오류가 나지 않는다는 것도 같은 실행에서 확인했습니다. ConcurrentMapCacheManager는 애플리케이션의 메모리에 맵으로 저장하는 가장 단순한 캐시 매니저로, 만료 시간(TTL)이나 크기 제한이 없습니다. 만료와 동시에 몰리는 요청은 "캐시와 TTL" 실습에서 다룹니다. null을 캐시에 넣을 수 있는지는 캐시 구현의 설정에 따라 다르며, ConcurrentMapCacheManager는 기본으로 null을 넣습니다(다른 캐시 구현에서의 동작은 실행으로 확인하지 않았습니다). DB는 화면과 실행 모두 메모리의 맵으로 대신했습니다. 화면의 코드는 @EnableCaching이 켜져 있다고 가정하며, key = "#id"처럼 매개변수 이름을 쓰려면 -parameters 옵션으로 컴파일해야 합니다. 이 PC에서 이 옵션 없이 컴파일해 실행했을 때는 rename을 부르는 순간 키가 null이라는 IllegalArgumentException이 났습니다(스프링 부트로 만든 프로젝트의 빌드 설정에는 이 옵션이 들어 있습니다). 같은 클래스 안에서 부르는 호출에 프록시 기반 기능이 적용되지 않는 이유는 "프록시와 자기 호출" 실습에서 자세히 다룹니다. 여기서는 한 스레드에서 차례로 부르는 경우만 다뤘습니다. 여러 스레드가 같은 키를 동시에 처음 조회하는 경우는 실행해 보지 않았습니다.

앞 실습: 외부 API 호출과 타임아웃 · 다음 실습: 시큐리티 필터 체인

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요