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
'개발 > 항해99' 카테고리의 다른 글
mariadb 쿼리 성능 개선 및 MSA 환경에서의 트랜잭션 (7) | 2024.11.15 |
---|---|
[Spring, Redisson] e-커머스 동시성 이슈 분석 및 제어 방법 (0) | 2024.10.30 |
항해 플러스 백엔드 (4주차 WIL) (1) | 2024.10.19 |
항해 플러스 백엔드 (3주차 WIL) (0) | 2024.10.12 |
항해 플러스 백엔드 (2주차 WIL) (3) | 2024.10.09 |