Spring Boot에서 @Cacheable과 Redis를 이용한 캐싱

1. 개요

많은 애플리케이션이 같은 느린 쿼리를 반복해서 호출합니다. 예를 들어 상품 정보, 설정, 환율을 조회하는 것들입니다. Spring Cache Abstraction은 몇 가지 annotation으로 이 문제를 해결합니다. Redis는 여러 인스턴스가 공유하는 캐시 저장소 역할을 합니다.

이 글을 읽은 후 다음을 할 수 있게 됩니다:

  • Redis를 캐시 provider로 설정하기
  • @Cacheable, @CachePut, @CacheEvict 사용하기
  • 각 캐시마다 TTL을 설정하고 JSON 형식으로 데이터 저장하기
  • JUnit 5와 Testcontainers로 캐시 동작 테스트하기

2. Dependency Maven

Spring Cache와 Redis를 위해 두 개의 starter가 필요합니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

테스트를 위해 Testcontainers를 추가로 필요합니다. Spring Boot가 이러한 dependency의 버전을 관리합니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

3. Redis 연결 설정

application.yml에서 Redis 주소를 선언하고 Redis를 캐시 provider로 지정합니다:

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: redis

각 속성의 의미:

  • spring.data.redis.*는 연결을 설정합니다. Spring Boot는 기본적으로 Lettuce 클라이언트를 사용합니다.
  • spring.cache.type: redis는 provider를 명시적으로 지정합니다. classpath에 Redis가 있으면 Spring Boot가 자동으로 선택하지만, 명시적으로 지정하면 classpath에 여러 provider가 있을 때 혼동을 피할 수 있습니다.

Docker로 로컬 Redis를 빠르게 실행할 수 있습니다:

docker run -d --name redis -p 6379:6379 redis:7-alpine

4. @Cacheable의 기본 예제

4.1. Model과 느린 쿼리를 시뮬레이션하는 repository

상품을 위해 간단한 record를 사용합니다:

public record Product(Long id, String name, BigDecimal price) {}

아래 repository는 느린 데이터베이스를 시뮬레이션합니다. 캐시를 검증하기 위해 호출 횟수도 세고 있습니다:

@Repository
public class ProductRepository {

    private final Map<Long, Product> store = new ConcurrentHashMap<>(Map.of(
            1L, new Product(1L, "Nón lá Huế", new BigDecimal("150000")),
            2L, new Product(2L, "Áo bà ba", new BigDecimal("320000"))
    ));
    private final AtomicInteger queryCount = new AtomicInteger();

    public Optional<Product> findById(Long id) {
        queryCount.incrementAndGet();
        sleep(1000); // 쿼리가 1초 걸린다고 가정
        return Optional.ofNullable(store.get(id));
    }

    public Product save(Product product) {
        store.put(product.id(), product);
        return product;
    }

    public void deleteById(Long id) {
        store.remove(id);
    }

    public int getQueryCount() { return queryCount.get(); }

    public void resetQueryCount() { queryCount.set(0); }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

4.2. 캐싱 활성화 및 method 표시

@EnableCaching으로 캐싱 메커니즘을 활성화합니다. 이 annotation을 @Configuration 클래스(5절에서 작성)나 main 클래스에 붙입니다. 그런 다음 캐시할 method를 표시합니다:

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Cacheable(cacheNames = "products", key = "#id")
    public Product findById(Long id) {
        return productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }
}

처리 흐름은 다음과 같습니다:

  • cacheNames = "products"는 캐시 이름입니다. Redis에서 기본 key 형식은 products::<key>입니다.
  • key = "#id"는 parameter id를 key로 사용하는 SpEL 표현식입니다. method가 parameter 하나만 있으면 이 속성을 생략할 수 있습니다.
  • 처음 호출할 때 Spring은 캐시에서 key를 찾지 못합니다. method를 실행한 후 결과를 Redis에 저장합니다.
  • 같은 id로 이후 호출할 때는 Spring이 Redis에서 결과를 반환합니다. method는 실행되지 않습니다.
  • method가 exception(ProductNotFoundException)을 던지면 Spring은 캐시에 아무것도 저장하지 않습니다.

findById(1L)을 두 번 호출한 후 Redis를 확인해봅시다:

redis-cli KEYS '*'
redis-cli TTL products::1
1) "products::1"
(integer) 1798

처음 호출은 약 1초 걸렸습니다. 두 번째 호출은 수 밀리초만 걸립니다. TTL이 약 1800초 남아 있는 이유는 다음 절에서 cache products를 30분으로 설정하기 때문입니다.

5. RedisCacheManager 설정: TTL과 serializer

기본적으로 RedisCacheManager는 JDK serialization을 사용합니다. 이 방식에는 세 가지 단점이 있습니다:

  • Class는 Serializable을 구현해야 합니다.
  • Redis의 데이터는 binary 형식이므로 읽기 어렵습니다.
  • class 구조를 변경할 때 데이터가 손상되기 쉽습니다.

기본적으로 캐시는 TTL이 없어서 entry가 영구적으로 살아있습니다. 이 두 가지를 모두 수정하겠습니다:

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheConfiguration defaultCacheConfig() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .disableCachingNullValues()
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }

    @Bean
    public RedisCacheManagerBuilderCustomizer productCacheCustomizer() {
        return builder -> builder.withCacheConfiguration("products",
                RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofMinutes(30))
                        .disableCachingNullValues()
                        .serializeValuesWith(RedisSerializationContext.SerializationPair
                                .fromSerializer(new Jackson2JsonRedisSerializer<>(Product.class))));
    }
}

각 bean의 설명:

  • Bean RedisCacheConfiguration는 모든 캐시의 기본 설정입니다. Spring Boot가 자동으로 인식합니다. 모든 entry는 TTL이 10분이고, null은 저장하지 않으며, value는 JSON 형식으로 저장됩니다.
  • RedisCacheManagerBuilderCustomizer는 cache products에 대한 설정을 재정의합니다. 이 캐시는 TTL이 30분이고 Product.class 타입의 serializer를 사용합니다.

왜 products는 Jackson2JsonRedisSerializer<>(Product.class)를 사용할까요? GenericJackson2JsonRedisSerializer는 type 정보(@class)를 JSON에 저장해야 합니다. record 같은 final class의 경우 이 메커니즘이 쉽게 올바른 타입 대신 LinkedHashMap을 반환하여 ClassCastException을 만날 수 있습니다. 타입을 지정한 serializer는 이 문제를 완전히 피합니다.

Redis의 데이터는 이제 직접 읽을 수 있습니다:

redis-cli GET products::1
"{\"id\":1,\"name\":\"Nón lá Huế\",\"price\":150000}"

주의: Bean RedisCacheConfiguration을 직접 선언하면, application.yml의 spring.cache.redis.time-to-live, spring.cache.redis.key-prefix 등의 속성은 무시됩니다. 두 가지 설정 방법 중 하나를 선택하고 섞지 마세요.

6. 캐시 업데이트 및 삭제: @CachePut과 @CacheEvict

캐시는 안의 데이터가 정확할 때만 유용합니다. 상품이 변경되면 해당 entry를 업데이트하거나 삭제해야 합니다.

@CachePut(cacheNames = "products", key = "#product.id()")
public Product update(Product product) {
    return productRepository.save(product);
}

@CacheEvict(cacheNames = "products", key = "#id")
public void delete(Long id) {
    productRepository.deleteById(id);
}

@CacheEvict(cacheNames = "products", allEntries = true)
public void clearAll() {
    // 예: 대량 상품 import 후에 호출
}

각 annotation의 차이:

  • @CachePut은 항상 method를 실행한 후 반환 결과를 key #product.id()로 캐시에 저장합니다. 따라서 method는 캐시할 올바른 object를 반환해야 합니다. void를 반환하면 캐시에 올바른 데이터가 없게 됩니다.
  • @CacheEvict는 method가 성공적으로 실행된 후 key에 따라 entry를 삭제합니다. method 실행 전에 삭제해야 하면 beforeInvocation = true를 추가합니다.
  • allEntries = true는 캐시 products 전체를 삭제합니다. Redis에서 이 작업은 pattern에 따라 key를 스캔합니다. 정말 필요할 때만 사용하세요.

7. 캐싱 제어: condition, unless, sync

@Cacheable은 몇 가지 유용한 속성이 더 있습니다:

@Cacheable(cacheNames = "productSearch",
           key = "#keyword.toLowerCase()",
           condition = "#keyword.length() >= 3",
           unless = "#result.isEmpty()")
public List<Product> search(String keyword) {
    // 느린 쿼리...
    return List.of();
}

@Cacheable(cacheNames = "products", key = "#id", sync = true)
public Product findHotProduct(Long id) {
    return findFromDatabase(id); // 같은 class의 private method로 데이터베이스에서 직접 읽음
}

각 속성의 의미:

  • condition은 method 실행 전에 평가됩니다. 여기서는 키워드가 3글자보다 짧으면 조회하지 않고 캐시도 저장하지 않습니다.
  • unless는 method 실행 후에 평가되며 #result를 사용할 수 있습니다. 여기서는 빈 리스트는 저장되지 않습니다.
  • key = "#keyword.toLowerCase()"는 “Nón”과 “nón”이 같은 entry를 공유하도록 합니다.
  • sync = true는 cache stampede를 방지합니다. 여러 thread가 같은 key를 놓칠 때 하나의 thread만 method를 실행하고 나머지 thread는 결과를 기다립니다. 이 메커니즘은 한 JVM 내에서만 동기화되며, instance 간의 distributed lock이 아닙니다.

Cache productSearch는 별도의 설정이 없으므로 5절의 기본 설정을 사용합니다. 즉, TTL은 10분이고 GenericJackson2JsonRedisSerializer를 사용합니다. 값이 record를 포함하는 List<Product>이므로, products처럼 타입을 지정한 serializer를 선언해야 합니다. 이렇게 하면 5절에서 언급한 ClassCastException 오류를 피할 수 있습니다.

8. 캐시 동작 테스트

가장 신뢰할 수 있는 방법은 Testcontainers를 통해 실제 Redis로 테스트하는 것입니다. Annotation @ServiceConnection은 Spring Boot에 대해 host와 port를 자동으로 설정하므로 application.yml을 수정할 필요가 없습니다.

@SpringBootTest
@Testcontainers
class ProductServiceCacheTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
                    .withExposedPorts(6379);

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private CacheManager cacheManager;

    @BeforeEach
    void setUp() {
        cacheManager.getCache("products").clear();
        productRepository.resetQueryCount();
    }

    @Test
    void whenFindByIdCalledTwice_thenRepositoryIsQueriedOnce() {
        Product first = productService.findById(1L);
        Product second = productService.findById(1L);

        assertThat(first).isEqualTo(second);
        assertThat(productRepository.getQueryCount()).isEqualTo(1);
        assertThat(cacheManager.getCache("products").get(1L, Product.class))
                .isEqualTo(first);
    }

    @Test
    void whenProductUpdated_thenCacheReturnsNewValue() {
        productService.findById(2L);

        productService.update(new Product(2L, "Áo bà ba lụa", new BigDecimal("450000")));
        Product cached = productService.findById(2L);

        assertThat(cached.name()).isEqualTo("Áo bà ba lụa");
        assertThat(productRepository.getQueryCount()).isEqualTo(1);
    }
}

이 두 테스트는 다음 사항들을 검증합니다:

  • 첫 번째 테스트: 두 번 호출하지만 repository는 한 번만 호출됩니다. entry도 정말 캐시에 있고 올바른 타입인 Product입니다. 이는 serializer가 제대로 작동함을 확인합니다.
  • 두 번째 테스트: @CachePut이 새 값을 쓰므로 이후 읽기는 캐시에서 가져오고 repository를 더 이상 호출하지 않습니다.
  • @BeforeEach는 캐시와 카운터를 초기화하여 테스트가 서로 독립적이 되도록 합니다. Container는 static이므로 전체 class에 대해 한 번만 시작합니다.

9. 흔한 실수와 사용하면 안 되는 경우

Self-invocation. Spring Cache는 proxy를 통해 작동합니다. ProductService의 한 method가 this.findById(id)를 호출하면, 호출이 proxy를 거치지 않고 직접 object로 들어가므로 캐시가 적용되지 않습니다. 이것이 7절의 findHotProduct 예제가 findById를 호출하는 대신 데이터베이스에서 직접 읽는 이유입니다. private method의 annotation도 효과가 없습니다. 해결책은 캐시가 필요한 method를 다른 bean으로 분리하는 것입니다.

null이 비활성화되었을 때 null 값 캐싱. disableCachingNullValues()를 사용하면 method가 null을 반환할 때 오류가 발생합니다:

java.lang.IllegalArgumentException: Cache 'products' does not allow 'null' values;
Avoid storing null via '@Cacheable(unless="#result == null")' ...

두 가지 처리 방법이 있습니다: unless = "#result == null"을 추가하거나 4절의 예제처럼 exception을 던집니다.

Class 구조 변경. Product에 필드를 추가하거나 필드 이름을 변경해도 Redis에는 여전히 이전 형식의 JSON이 남아있습니다. 결과적으로 deserialize 오류가 발생하거나 데이터에서 필드가 누락될 수 있습니다. 캐시 이름을 변경하거나(products-v2 같은) 배포 시 캐시를 비워야 합니다.

JPA entity 캐싱. Entity는 lazy proxy와 양방향 관계를 가지고 있어서 serialize할 때 오류가 발생하기 쉽습니다. DTO나 record를 캐시하세요.

주의: 계좌 잔액이나 결제 시점의 재고처럼 절대적인 일관성이 필요한 데이터는 캐시를 사용하지 마세요. 또한 자주 변경되는 데이터도 캐시하지 마세요. hit rate가 낮을 것이고 Redis에 대한 추가 네트워크 호출만 추가됩니다. 인스턴스가 하나만 실행되고 데이터가 작으면 Caffeine 같은 in-memory 캐시가 Redis보다 빠르고 간단한 경우가 많습니다.

10. 결론

Spring Boot의 cache provider로 Redis를 설정했습니다. @Cacheable로 읽고, @CachePut으로 업데이트하고, @CacheEvict로 entry를 삭제합니다. 또한 각 캐시마다 TTL과 JSON serializer를 설정했고, Testcontainers를 통해 실제 Redis로 동작을 검증했습니다.

기억할 세 가지:

  • 항상 TTL을 설정하세요.
  • entity 대신 DTO를 캐시하세요.
  • self-invocation에 주의하세요.

이 세 가지를 올바르게 따르면 cache를 production에 배포할 때 대부분의 문제를 피할 수 있습니다.

spring-bootrediscachespring-cachetestcontainers