개발/항해99

[Spring, Redis, Cache] @Cacheable 적용

IamBD 2024. 11. 7. 20:04

Cache

애플리케이션을 개발하다 보면 자주 "쓰지는" 않지만 자주 "읽는" 데이터들이 있습니다. 어차피 변하지 않는 값이니 정적 리소스처럼 DB 조회나 기타 연산을 하지 않고 그냥 갖고 있는 값을 보여주면 안 될까?라는 생각을 한 번씩은 하게 됩니다. 작은 토이 프로젝트라면 몰라도 수많은 사람들이 사용하는 B2C 서비스일수록 서버에 대한 부하 때문에 더 느끼곤 하죠. 이럴 때 캐시는 아주 유용하게 사용할 수 있습니다. 

 

Spring Cache

Spring에서는 3.1 버전부터 이 캐시를 쉽게 사용할 수 있도록 캐시 관련 어노테이션을 지원합니다. 해당 어노테이션 붙은 메서드는 Java 메서드에 캐싱을 적용하여 이후 호출에서는 실제 메서드는 실행하지 않고 캐시 된 결과만 반환합니다.  단, 다중 스레드나 다중 인스턴스와 같은 환경을 따로 처리하지 않으니 이는 캐시 구현에서 따로 설정해줘야 합니다.

 

Spring에서는 아래와 같은 어노테이션 기반 캐싱 기능을 지원합니다.

  • @Cacheable : 캐시 채우기
  • @CacheEvict : 캐시 제거
  • @CachePut : 메서드 실행하지 않고 캐시 업데이트
  • @Caching : 여러 캐시 어노테이션을 묶음
  • @CacheConfig : 캐시 관련 설정

 

@Cacheable

결과를 캐싱하고 싶은 메서드를 구분하는 데 사용합니다. 이 캐시 된 메서드는 이후 메서드를 실행하지 않고 캐시 된 값만 반환합니다. 

@Cacheable(value = "productList", cacheManager = "redisCacheManager")
public List<ProductInfo.Main> getProducts() {
    var products = productReader.getProducts();

    return products.stream()
        .map(ProductInfo.Main::from)
        .toList();
}

 

위의 코드에서는 `productList`를 캐싱합니다. 해당 메서드는 호출 때마다 캐시를 확인하고 새로운 값을 반환해야 하는지 여부를 확인합니다. 대부분의 경우 하나의 value만 사용하지만 여러 value를 선언할 수 있으며 하나 이상의 value가 적중하면 캐시 된 결과를 반환합니다.

 

처음 설명에 언급했듯 분산 환경에서의 캐싱은 기본 지원하지 않으니 redis를 통해 제어할 수 있도록 cacheManager를 별도로 지정하여 사용합니다.

 

가끔씩 항상 캐시 된 결과만 반환하는 것이 아닌 특정 조건에서는 메서드의 실행을 원할 수 있습니다. 이럴 경우 조건문을 통해 조금 더 세세한 캐싱을 제어할 수 있습니다. 아래 코드에서는 크게 의미는 없는 조건이지만 사용 방법을 나타내고 있습니다.

@Cacheable(value = "productList", condition="#productId > 100")
public List<ProductInfo.Main> getProduct(Long productId) {
    var product = productReader.getProduct(productId);

    return ProductInfo.Main::from(product);
}

 

 

@CachePut

@Cacheable은 캐시 된 결과가 있다면 메서드 실행을 하지 않지만 @CachePut은 메서드 실행은 방해하지 않고 결과만 캐시에 저장합니다. 즉, 메서드 실행 결과 단축을 위한 목적보다는 캐시를 채워야 하는 상황에서 사용합니다.

@CachePut(value = "product", key="#prodctId")
public List<ProductInfo.Main> getProduct(Long productId) {
    var product = productReader.getProduct(productId);

    return ProductInfo.Main::from(product);
}

 

 

@CacheEvict

의도적으로 캐시를 비워 오래되거나 사용하지 않는 데이터를 제거해야 할 때 사용합니다. allEntries를 통해 캐시 전체를 비울 수도, 특정 캐시만 비울 수도 있습니다.

@CacheEvict(value = {"product", "productList"}, allEntries = true)
public void updateProduct(Product product) {
    productStore.store(product);
}

 

 

@Caching

@CacheEvict이나 @CachePut을 하나의 메서드에서 여러 개의 키를 조작해야 할 수도 있습니다. 이럴 때 @Caching을 통해 묶어서 처리할 수 있습니다.

@Caching(evict = { @CacheEvict("productList"), @CacheEvict(value="product", key="#product.id") })
public void updateProduct(Product product) {
    productStore.store(product);
}

 

 

@CacheConfig

Bean에 등록하여 전역에서 사용할 커스텀한 캐시 설정을 제공합니다.

@CacheConfig
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
        ObjectMapper objectMapper = new ObjectMapper()
            .registerModule(new ParameterNamesModule())
            .registerModule(new Jdk8Module())
            .registerModule(new JavaTimeModule())
            .activateDefaultTyping(
                BasicPolymorphicTypeValidator.builder().allowIfBaseType(Object.class).build(),
                ObjectMapper.DefaultTyping.EVERYTHING,
                JsonTypeInfo.As.PROPERTY
            )
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

        RedisSerializer<Object> serializer = new GenericJackson2JsonRedisSerializer(objectMapper);

        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofHours(12))
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.string()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer));

        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
        cacheConfigurations.put("productList", defaultConfig);
        cacheConfigurations.put("product", defaultConfig);
        cacheConfigurations.put("topSellingProducts", defaultConfig.entryTtl(Duration.ofHours(24)));

        return RedisCacheManager.builder(redisConnectionFactory)
            .withInitialCacheConfigurations(cacheConfigurations)
            .cacheDefaults(defaultConfig)
            .build();
    }

}

 

 

 

캐시적용

이제 간략하게라도 Spring에서 제공하는 캐시에 대해 알아봤으니 현재 E-커머스 프로젝트에 적용해 보겠습니다. 적용에 앞서 서론에 말했듯 "쓰기"는 적지만 "읽기"는 많은 로직에 대해 선별해야 합니다.

 

실제 쇼핑에서는 사람은 상품을 고르는데 시간과 에너지를 많이 소모하고, 결제에는 많은 에너지가 필요하지 않습니다. E-커머스 또한 실제 쇼핑과 마찬가지로 에너지를 상품을 고르는데(read) 사용하고 결제(write)에는 에너지를 거의 안 쓰지 않으시나요?

 

더보기

물론 상품의 입고나 옵션, 수량의 변화 등과 같이 잦은 데이터의 쓰기 작업이 발생하기도 합니다. 이럴 때는 DB가 얼마나 분산되어 있는지 등과 같은 조금 더 심도깊은 고민이 필요해지겠죠?

 

위와 같은 논리로 단순 상품 조회에 캐싱을 적용하고, 상품의 변화가 생길 때 캐시를 초기화해 새로운 데이터도 사용자가 조회할 수 있도록 하겠습니다.

 

상품조회 메서드입니다.

@Override
@Transactional(readOnly = true)
public ProductInfo.Main getProduct(Long productId) {
    var product = productReader.getProduct(productId);

    return ProductInfo.Main.from(product);
}

 

 

위 코드에 캐싱을 적용하는 방법은 아주 간단합니다. redis를 사용하기 위한 설정은 위에 있으니 생략하고 @Cacheable을 적용하면 아래와 같이 한 라인만 추가하면 됩니다.

@Override
@Cacheable(
	value = "product", -------------------------- 1
	key = "#productId", ------------------------- 2
	cacheManager = "redisCacheManager" ---------- 3
)
@Transactional(readOnly = true)
public ProductInfo.Main getProduct(Long productId) {
    var product = productReader.getProduct(productId);

    return ProductInfo.Main.from(product);
}

 

1. produc라는 이름으로 캐시를 적용합니다.

2. 각 상품 ID를 key로 캐싱합니다.

3. redis 사용을 위해 별도로 작성한 Config 내 Bean의 이름입니다.

 

이제 캐시를 적용했으니 hit와 miss 상황을 통해 간단히 성능 비교를 해보겠습니다.

 

다양한 JMeter와 같이 실제 API 호출 후 성능분석 툴을 사용해도 좋으나 이번엔 간단히 테스트 코드로 측정해보겠습니다.

 

 

테스트 코드입니다.

 

Spring에서 제공하는 StopWatch API를 통해 1~1000번의 상품 ID로 조회하고 (miss) 이후 재조회(hit)를 통해 걸리는 시간을 측정해 봤습니다.

@Test
void testPerformanceCache() {
    StopWatch stopWatch = new StopWatch();

    Product mockProduct = mock(Product.class);
    when(productReader.getProduct(any())).thenReturn(mockProduct);

    stopWatch.start("Without Cache");
    for (Long productId : productIds) {
        productService.getProduct(productId);
    }
    stopWatch.stop();

    stopWatch.start("With Cache");
    for (Long productId : productIds) {
        productService.getProduct(productId);
    }
    stopWatch.stop();

    System.out.println(stopWatch.prettyPrint());
}

 

 

실행결과

----------------------------------------
Seconds       %       Task name
----------------------------------------
3.7314686     73%     Without Cache
1.3950377     27%     With Cache

 

 

보시는 바와 같이 캐시 미적중의 경우 3.73초, 캐시 적중일 때는 1.39초로 약 2.7배의 차이를 보이고 있습니다. 다만, 이는 테스트 코드에서의 성능 비교로 실제 API를 통해 조회를 한다거나, 상품 조회에 조금은 더 복잡한 로직이 있거나, 시간 외 다른 지표도 중요하다면 더 큰 차이를 보이게 됩니다.

 

 

하지만...

단순 성능만 놓고 본다면 메모리를 사용하는 캐시가 어쩔 수 없이 좋을 수밖에 없습니다. 그렇다고 분석 없이 무조건적인 적용은 위험할 수 있습니다. 왜냐하면 메모리는 비쌉니다. 비싸다는 뜻은 하드웨어 성능이 좋아졌다고 가정하더라도 적절한 TTL이나 Evicition 전략을 세우지 않는다면 메모리를 많이 사용하는 캐시 특성상 메모리 부족 문제가 발생하거나 사용자가 새로운 데이터가 아닌 캐시 된 오래된 데이터만 조회할 수도 있습니다.

 

따라서 위와 같은 이유로 잘 사용하게 된다면 성능 향상에 큰 도움이 되겠지만, 잘못 사용한다면 오히려 성능의 저하나 메모리 부족으로 인한 다양한 문제를 겪을 수 있으니 캐시 적중률 모니터링과 같이 지속적인 관리를 통해 조금 더 정교한 코드를 작성해야겠습니다.

 

 

 

참고자료

https://docs.spring.io/spring-framework/reference/integration/cache.html

 

Cache Abstraction :: Spring Framework

Since version 3.1, the Spring Framework provides support for transparently adding caching to an existing Spring application. Similar to the transaction support, the caching abstraction allows consistent use of various caching solutions with minimal impact

docs.spring.io