이전 편에서 캐시의 개념과 동작 원리를 알아봤다. 이제 Spring이 캐시를 어떻게 다루는지 살펴볼 차례다. Spring은 특정 캐시 구현체에 종속되지 않도록 추상화 계층을 제공한다. 이 설계 덕분에 캐시 기술을 바꿔도 비즈니스 로직을 수정할 필요가 없다.

Spring이 캐시를 추상화하는 이유

캐시를 직접 코드에 구현한다고 생각해보자.

public WeatherResponse getWeather(String city) {
    String cacheKey = "weather:" + city;

    // 캐시에서 조회
    WeatherResponse cached = cacheStore.get(cacheKey);
    if (cached != null) {
        return cached;
    }

    // DB 또는 외부 API 호출
    WeatherResponse result = weatherApi.fetch(city);

    // 캐시에 저장
    cacheStore.put(cacheKey, result);

    return result;
}

비즈니스 로직보다 캐시 관련 코드가 더 많다. 게다가 cacheStoreConcurrentHashMap이면 나중에 Redis로 바꿀 때 이 코드를 전부 수정해야 한다. 모든 서비스 메서드에 이런 보일러플레이트가 반복된다면 유지보수가 끔찍해진다.

Spring Cache Abstraction은 이 문제를 해결한다. 캐시 로직을 비즈니스 코드에서 완전히 분리하는 것이 핵심이다.

@Cacheable("weather")
public WeatherResponse getWeather(String city) {
    return weatherApi.fetch(city);
}

@Cacheable 어노테이션 하나로 동일한 효과를 낸다. 캐시 저장소가 바뀌어도 이 코드는 그대로다.

PSA와 캐시 추상화

Spring의 캐시 추상화는 PSA의 대표적인 사례다. PSA는 Portable Service Abstraction의 약자로, "이식 가능한 서비스 추상화"를 뜻한다.

핵심 아이디어는 단순하다. 인터페이스로 기능을 정의하고, 구현체를 갈아끼울 수 있게 만드는 것이다.

Spring에서 이미 경험한 PSA 사례가 있다.

  • 트랜잭션 : @Transactional을 붙이면 JPA든 JDBC든 트랜잭션이 동작한다. PlatformTransactionManager라는 추상화 덕분이다.
  • 데이터 접근 : JpaRepository를 사용하면 Hibernate든 EclipseLink든 코드가 동일하다.

캐시도 마찬가지다. CacheManager라는 추상화 뒤에 ConcurrentHashMap, Caffeine, Redis 등 다양한 구현체를 숨긴다.

flowchart TB App["@Cacheable / @CacheEvict"] CM["CacheManager 인터페이스"] App --> CM CM --> Impl1["ConcurrentMapCacheManager"] CM --> Impl2["CaffeineCacheManager"] CM --> Impl3["RedisCacheManager"] Impl1 --> S1["ConcurrentHashMap"] Impl2 --> S2["Caffeine"] Impl3 --> S3["Redis Server"]
PSA의 실질적 이점

개발 초기에는 간단한 로컬 캐시로 시작하고, 트래픽이 늘어나면 Redis로 전환하는 일이 흔하다. PSA 덕분에 이 전환이 설정 변경만으로 가능하다. 서비스 코드의 @Cacheable은 그대로 둔다.

핵심 인터페이스

Spring Cache Abstraction은 두 개의 핵심 인터페이스로 구성된다.

classDiagram class CacheManager { +getCache(name) Cache +getCacheNames() Collection } class Cache { +getName() String +get(key) ValueWrapper +put(key, value) void +evict(key) void +clear() void } CacheManager "1" --> "*" Cache : 관리

CacheManager

캐시 저장소를 관리하는 최상위 인터페이스다. 이름으로 Cache 인스턴스를 가져오는 역할을 한다.

public interface CacheManager {
    Cache getCache(String name);
    Collection<String> getCacheNames();
}

getCache("weather")를 호출하면 "weather"라는 이름의 캐시 영역을 반환한다. 캐시 영역은 논리적으로 분리된 저장 공간이다. 게시글 캐시와 날씨 캐시를 같은 공간에 섞어두면 관리가 어려우니, 이름으로 구분하는 것이다.

Spring Boot에서 제공하는 주요 CacheManager 구현체는 다음과 같다.

  • ConcurrentMapCacheManager : JDK의 ConcurrentHashMap을 사용한다. 별도 의존성 없이 바로 쓸 수 있지만, TTL 기능이 없다.
  • CaffeineCacheManager : Caffeine 라이브러리를 사용한다. TTL, 최대 크기 등 세밀한 제어가 가능하다.
  • RedisCacheManager : Redis를 캐시 저장소로 사용한다. 분산 환경에 적합하다.
CacheManager는 하나만 등록하라

여러 CacheManager를 동시에 등록하면 Spring이 어떤 것을 사용할지 결정하지 못해 오류가 발생한다. 두 종류 이상의 캐시 저장소를 함께 쓰고 싶다면 CompositeCacheManager를 사용하거나, @CacheablecacheManager 속성으로 명시적으로 지정해야 한다.

Cache

실제 캐시 연산을 수행하는 인터페이스다. 키-값 쌍을 저장하고 조회하고 삭제한다.

public interface Cache {
    String getName();
    Object getNativeCache();
    ValueWrapper get(Object key);
    void put(Object key, Object value);
    void evict(Object key);
    void clear();
}

개발자가 이 인터페이스를 직접 호출할 일은 거의 없다. @Cacheable 같은 어노테이션을 사용하면 Spring이 내부적으로 이 인터페이스의 메서드를 호출한다. 하지만 어노테이션이 내부에서 무엇을 하는지 알아야 문제가 생겼을 때 디버깅할 수 있다.

동작 원리 — AOP 기반 프록시

Spring Cache는 AOP를 기반으로 동작한다. AOP는 Aspect-Oriented Programming의 약자로, "관심사를 분리하는 프로그래밍 패러다임"이다.

@Cacheable을 붙인 메서드를 호출하면 실제로는 프록시 객체가 먼저 호출을 가로챈다.

전체 흐름은 다음과 같다.

  1. 클라이언트가 getWeather("서울")을 호출한다
  2. Spring이 만든 프록시 객체가 호출을 가로챈다
  3. 프록시가 CacheManager에서 "weather" 캐시를 가져온다
  4. 캐시에서 키 "서울"로 조회한다
  5. Cache Hit → 캐시 값을 반환하고 원본 메서드는 실행하지 않는다
  6. Cache Miss → 원본 메서드를 실행하고, 결과를 캐시에 저장한 뒤 반환한다
sequenceDiagram participant Caller as 호출자 participant Proxy as 프록시 객체 participant CM as CacheManager participant Cache as Cache participant Target as 실제 객체 Caller->>Proxy: getWeather("서울") Proxy->>CM: getCache("weather") CM-->>Proxy: Cache 인스턴스 Proxy->>Cache: get("서울") alt Cache Hit Cache-->>Proxy: 캐시 값 반환 Proxy-->>Caller: 캐시 값 반환 else Cache Miss Cache-->>Proxy: null Proxy->>Target: getWeather("서울") Target-->>Proxy: 결과 Proxy->>Cache: put("서울", 결과) Proxy-->>Caller: 결과 반환 end
내부 호출은 캐시가 동작하지 않는다

같은 클래스 내에서 this.getWeather("서울")처럼 호출하면 프록시를 거치지 않으므로 캐시가 동작하지 않는다. 이것은 Spring AOP의 근본적인 한계이며, 캐시뿐 아니라 @Transactional도 동일한 문제를 가진다. 이 문제의 원인과 해결 방법은 안티패턴과 트러블슈팅 편에서 자세히 다룬다.

프록시 기반이라는 점을 기억해두자. 이 구조를 이해하면 "왜 캐시가 안 먹히지?"라는 문제의 대부분을 설명할 수 있다.

Spring Boot에서 캐시 활성화하기

Spring Boot에서 캐시를 사용하려면 두 가지가 필요하다.

의존성 추가

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-cache'
}

spring-boot-starter-cache는 Spring Cache Abstraction의 핵심 모듈을 포함한다. 별도의 캐시 구현체를 추가하지 않으면 ConcurrentMapCacheManager가 기본으로 사용된다.

@EnableCaching 설정

@Configuration
@EnableCaching
public class CacheConfig {
}

@EnableCaching은 Spring에게 "캐시 어노테이션을 처리할 프록시를 만들어라"라고 알려주는 스위치다. 이 어노테이션이 없으면 @Cacheable을 아무리 붙여도 캐시가 동작하지 않는다.

@SpringBootApplication에 직접 붙여도 된다

@EnableCaching은 반드시 별도의 설정 클래스에 있을 필요는 없다. 메인 클래스의 @SpringBootApplication 위에 붙여도 동일하게 동작한다. 다만, 캐시 관련 설정이 늘어나면 별도의 CacheConfig 클래스로 분리하는 편이 관리하기 좋다.

최소 동작 예제

의존성과 @EnableCaching만 추가하면 바로 캐시를 사용할 수 있다.

@Service
public class WeatherService {

    @Cacheable("weather")
    public WeatherResponse getWeather(String city) {
        // 이 메서드는 같은 city로 두 번째 호출부터 실행되지 않는다
        return weatherApi.fetch(city);
    }
}

첫 번째 호출에서 weatherApi.fetch("서울")이 실행되고, 결과가 "weather"라는 캐시 영역에 저장된다. 두 번째 호출부터는 캐시에서 바로 반환되므로 weatherApi.fetch는 다시 실행되지 않는다.

캐시 어노테이션 한눈에 보기

Spring Cache는 네 가지 핵심 어노테이션을 제공한다. 각각의 역할을 간략히 소개하고, 상세한 사용법은 다음 편에서 다룬다.

  • @Cacheable : 캐시 조회 → Hit이면 반환, Miss면 실행 후 캐시에 저장
  • @CachePut : 항상 메서드를 실행하고 결과를 캐시에 저장 (갱신 용도)
  • @CacheEvict : 캐시에서 항목을 제거
  • @Caching : 위 세 가지를 하나의 메서드에 조합

한 가지 더 있다. @CacheConfig는 클래스 레벨에서 캐시 이름 등 공통 설정을 지정한다. 메서드마다 같은 캐시 이름을 반복 작성하지 않아도 된다.

flowchart LR Read["읽기 요청"] --> Cacheable["@Cacheable"] Cacheable -->|Hit| Return["캐시 반환"] Cacheable -->|Miss| Execute["메서드 실행 → 캐시 저장"] Update["갱신 요청"] --> CachePut["@CachePut"] CachePut --> Always["항상 실행 → 캐시 저장"] Delete["삭제 요청"] --> CacheEvict["@CacheEvict"] CacheEvict --> Remove["캐시 제거"]

자주 하는 실수

@EnableCaching을 빼먹고 캐시가 안 된다고 당황하기

가장 흔한 실수다. @Cacheable을 붙였는데 캐시가 전혀 동작하지 않는다면 가장 먼저 @EnableCaching이 있는지 확인하라. 이 어노테이션이 없으면 Spring은 캐시 프록시를 생성하지 않으므로 모든 캐시 어노테이션이 무시된다.

[!DANGER] 캐시 구현체 없이 운영 환경에 배포하기

spring-boot-starter-cache만 추가하면 기본적으로 ConcurrentMapCacheManager가 사용된다. 개발 환경에서는 문제없지만, TTL이 없고 크기 제한도 없으므로 운영 환경에서는 메모리 누수가 발생할 수 있다. 반드시 Caffeine이나 Redis 같은 전용 캐시 구현체를 함께 추가해야 한다.

[!DANGER] private 메서드에 @Cacheable 붙이기

Spring AOP는 프록시 기반이므로 public 메서드에서만 동작한다. private이나 protected 메서드에 @Cacheable을 붙이면 아무런 효과가 없다. 컴파일 에러도 없고 런타임 예외도 없기 때문에 발견하기 어렵다.