e-commerce 비즈니스 로직에서 조회가 오래걸리는 쿼리를 캐싱을 통해 성능을 개선해보고, 적절한 캐싱전략을 사용해보자 하였습니다.
⚠️ 썸네일에 Redis 이미지가 있지만, Redis와 캐시는 동일한 개념이 아닙니다. Redis는 캐시를 구현하기 위한 데이터 저장소 중 하나일 뿐이며, 캐시 외에도 저장소, 메시지 브로커 등 다양한 기능을 제공합니다. 따라서 Redis를 단순히 캐시로만 생각하면 안 됩니다.
캐시(Cache)는 데이터나 값을 미리 저장해 두었다가 필요할 때 빠르게 제공하기 위한 메모리 공간입니다. 주로 자주 사용되는 데이터에 대해 빠른 접근을 제공함으로써 응답 시간을 단축하고 시스템 성능을 개선하는 데 사용됩니다. 데이터베이스나 원격 서버로의 반복적인 접근을 줄여 시스템 리소스를 효율적으로 사용할 수 있게 합니다.
캐시의 종류는 정말 다양하고 각각의 원리에 대해서 파고들면 그것만으로도 포스팅을 하나만들 수 있을 정도로 내용이 많아지기 때문에... 가볍게 특징정도만 알아보겠습니다.
CPU 캐시 메모리

메모리 캐시

웹 캐시

브라우저 캐시
데이터베이스 캐시
분산 캐시
이번 포스팅에서는
Redis를 활용하여메모리 캐싱을 사용해보겠습니다.
캐싱을 적용해야하는 상황은 주로 다음과 같습니다.
| API | 1.자주 조회o, 자주 변경X | 2.쿼리가 복잡 | 3.동일데이터 반복요청 | 4.데이터 일관성 중요도 낮음 |
|---|---|---|---|---|
| /api/products[GET] - 상품목록조회 | ⭕ | ❌ | ⭕ | ⭕ |
| /api/products/{id}[GET] - 상품단건조회 | ❌ | ❌ | ❌ | ❌ |
| /api/products/popular[GET] - 인기상품 조회 | ⭕ | ⭕ | ⭕ | ⭕ |
| /api/points[GET] - 포인트 조회 | ❌ | ❌ | ❌ | ❌ |
| /api/carts[GET] - 장바구니 조회 | ❌ | ❌ | ❌ | ⭕ |
※ POST, PUT, DELETE 요청은 캐싱적용 대상이 아니기 때문에 표에 넣지 않았습니다.
자주 조회되는 로직입니다.조인되고, 집계가 들어가기 때문에 쿼리가 복잡합니다.실시간으로 반영되지 않더라도 주문 시점에서 정확한 데이터를 보여주면 되기 때문에 캐싱대상으로 적합하다 생각하였습니다.포인트조회, 장바구니 조회와 같은 경우는 쿼리가 복잡하지 않고, 캐싱을 하는 것은 불필요하게 메모리를 차지한다고 생각하였습니다.포인트 충전, 주문 등과 같은 CUD 형태의 API는 실시간 데이터가 중요하고, 사용자의 재산 및 재고 관리와 직결되므로, 데이터의 정확성이 중요합니다. 따라서 이러한 API는 최신 데이터를 즉시 조회하고 변경하는 것이 적합하여 캐싱을 하지 않았습니다.캐시 전략에는 다양한 방식이 있으며, 각 전략은 시스템의 성능, 사용 사례, 요구사항 등에 따라 선택됩니다. 이론 및 개념에 대해서는 아래 잘 정리된 링크를 남겨두겠습니다.
캐시(Cache) 설계 전략 지침 💯 총정리
읽기 전략으로는 Lock Aside, 쓰기 전략으로는 Write Around를 사용하여 이를 구현하였습니다.
- 캐시와 DB가 분리되어 가용되기 때문에 원하는 데이터만 별도로 구성하여 캐시에 저장
- 캐시와 DB가 분리되어 가용되기 때문에 캐시 장애 대비 구성이 되어있음.
만일 redis가 다운 되더라도 DB에서 데이터를 가져올수있어 서비스 자체는 문제가 없음.- 대신에 캐시에 붙어있던 connection이 많았다면, redis가 다운된 순간 순간적으로 DB로 몰려서 부하 발생.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
@Configuration
@EnableCaching
@EnableRedisRepositories
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
return redisTemplate;
}
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
// 인기상품 조회는 TTL 1시간
cacheConfigurations.put(CacheConstants.POPULAR_PRODUCTS_CACHE, defaultCacheConfig.entryTtl(Duration.ofHours(1)));
// 상품목록 조회는 TTL 10분
cacheConfigurations.put(CacheConstants.PRODUCTS_ALL, defaultCacheConfig.entryTtl(Duration.ofMinutes(10)));
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultCacheConfig )
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
@Cacheable(value = CacheConstants.PRODUCTS_CACHE, key = CacheConstants.PRODUCTS_ALL)
public List<ProductInfo.Amount> getDetailList() {
// 로직생략...
}
@Cacheable(value = CacheConstants.POPULAR_PRODUCTS_CACHE, key = CacheConstants.RECENT_3_DAY_TOP_5_KEY)
public List<ProductInfo.OrderAmount> getPopularProducts() {
// 로직생략...
}
}
1초동안 1000명의 사용자가 조회요청을 보내는 사니리오로 테스트를 하였습니다.
| 인기상품조회(전) | 인기상품조회(후) | 상품목록조회(전) | 상품목록조회(후) | |
|---|---|---|---|---|
| 평균응답시간(ms) | 1792 | 874 | 2609 | 815 |
| 초당처리 요청수 | 362.6/sec | 696.9/sec | 161.9/sec | 694.9/sec |
인기상품조회(전)

인기상품조회(후)

상품목록조회(전)

상품목록조회(후)

- 인기상품조회는
평균응답시간은 51.2%감소,초당처리 요청수는 92.2% 증가하였습니다.- 상품목록조회는
평균응답시간은 68.7%감소,초당처리 요청수는 329.2% 증가하였습니다.
상품 조회 요청은 3가지가 있습니다. 인기 상품조회, 상품목록 조회, 상품단건(상세)조회 캐시를 적용할 때 TTL 설정에 따라 실시간성과 정확도가 달라지게 되는데 이는 상호 배타적인 성격으로 인해 용도에 따라 적절하게 적용할 필요가 있습니다.
아래와같은 근거로 저는 TTL 설정, 캐싱 적용을 다르게 하였습니다.
| 캐시적용여부 | TTL | 이유 | |
|---|---|---|---|
| 인기상품 조회 | Y | 1시간 | 메인 페이지에 들어가면 항상 조회되는 데이터로, 실시간성이 낮아도 되며, 트래픽을 줄이는 것이 중요합니다. 1시간 동안 자주 변화하지 않기 때문에 1시간 TTL이 적절합니다. |
| 상품목록 조회 | Y | 10분 | 사용자가 특정 카테고리나 상품군을 조회할 때, 데이터의 실시간성이 중요하지만 완전한 실시간 업데이트가 필요하지는 않습니다. 데이터가 자주 갱신되므로 TTL을 10분으로 설정하여 실시간성과 효율성을 균형 있게 유지합니다. |
| 상품단건(상세)조회 | N | 상품의 상세 정보는 가격, 재고, 프로모션 등과 같은 데이터가 자주 변경될 수 있기 때문에 실시간성이 매우 중요합니다. 캐시를 적용하지 않아 항상 최신 정보를 제공합니다. |
캐시를 사용하는 시스템에서는 캐시 데이터가 만료되었을 때 Cache Stamped 현상이 발생할 수 있습니다. 이러한 현상은 캐시가 일시적으로 비어 있는 동안 많은 요청이 동시에 데이터베이스(DB)에 접근해 부하를 일으키는 상황을 말합니다. 이 문제를 해결하기 위해 스케쥴링을 이용한 캐시 업데이트를 통해 해결하고자 하였습니다.
@Component
@EnableScheduling
@RequiredArgsConstructor
public class ProductSchedule {
private final ProductRepository productRepository;
private final CacheManager cacheManager;
/**
* 인기상품 조회 캐시 업데이트
* 20분 간격으로 실행 (20분 = 1200000ms)
*/
@Scheduled(fixedRate = 1200000)
public void updatePopularProductsCache() {
// 새로운 데이터를 DB에서 조회
List<ProductInfo.OrderAmount> updatedData = //생략...
// 캐시 덮어쓰기
Objects.requireNonNull(cacheManager.getCache(CacheConstants.POPULAR_PRODUCTS_CACHE))
.put(CacheConstants.RECENT_3_DAY_TOP_5_KEY.replace("'", ""), updatedData);
}
/**
* 상품목록 조회 캐시 업데이트
* 3분 간격으로 실행 (3분 = 180000ms)
*/
@Scheduled(fixedRate = 180000)
public void updateProductsCache() {
// 새로운 데이터를 DB에서 조회
List<Product> productList = //생략...
// 캐시 덮어쓰기
Objects.requireNonNull(cacheManager.getCache(CacheConstants.PRODUCTS_CACHE))
.put(CacheConstants.PRODUCTS_ALL.replace("'", ""), updatedData);
}
}
인기 상품 조회는 20분 간격,상품 목록 조회는 3분 간격으로 스케쥴링이 동작하도록 하였는데 이는TTL시간/3정도의 값으로 스케쥴링 간격을 설정하였습니다.- 이는
캐시가 만료되기 전에 업데이트시켜주어야 한다고 판단하였고, 혹시스케쥴링이 실패하거나타이밍이 안맞았을 경우를 대비해서 넉넉하게 시간을 잡기 위해TTL시간/3` 정도의 값 으로 스케쥴링 간격을 설정하였습니다.