Redis 캐시를 붙이기 전후 성능 비교
상품 상세와 홈 추천 목록에서 반복 조회가 많아진 시점에 Redis 캐시를 붙였고, 응답 시간과 DB 부하가 어떻게 달라졌는지 정리한 기록입니다.
Redis를 붙이기 전까지는 "지금도 충분히 빠른 것 같은데 굳이?"라는 생각이 있었습니다.
응답이 몇 초씩 걸리는 것도 아니었고, DB도 아직 버티고 있었기 때문입니다.
그런데 홈 추천 상품과 상품 상세가 동시에 트래픽을 받기 시작하니, 느려지는 지점이 눈에 들어오기 시작했습니다.
특히 같은 데이터를 짧은 시간에 계속 읽는 요청이 많아질 때 DB가 필요 이상으로 바빠지고 있었습니다.
문제가 된 조회는 크게 두 가지였습니다.
- 홈에서 반복해서 보여주는 추천 상품 목록
- 상품 상세에 들어갈 때마다 다시 읽는 상품 기본 정보
두 화면 모두 "자주 바뀌지 않는데 자주 읽히는 데이터"였습니다. 이럴 때는 캐시가 잘 맞겠다는 판단이 섰습니다.
캐시를 붙이기 전 상태
처음에는 단순하게 레포지토리 조회 결과를 그대로 DTO로 바꿔 응답하고 있었습니다.
public ProductDetailResponse getProductDetail(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new BusinessException(ErrorCode.PRODUCT_NOT_FOUND));
return ProductDetailResponse.from(product);
}추천 상품도 비슷했습니다.
public List<HomeProductResponse> getRecommendedProducts() {
return productRepository.findRecommendedProducts().stream()
.map(HomeProductResponse::from)
.toList();
}코드 자체는 단순했습니다. 문제는 같은 요청이 너무 자주 들어온다는 점이었습니다.
추천 상품은 운영자가 수시로 바꾸는 영역도 아니었고, 상품 상세 역시 짧은 시간 안에 같은 상품이 반복 조회되는 경우가 많았습니다.
APM과 쿼리 로그를 같이 보니 패턴이 꽤 분명했습니다.
- 상품 상세 평균 응답 시간: 약 280ms
- 추천 상품 API 평균 응답 시간: 약 190ms
- 피크 시간대 DB 읽기 쿼리 수가 눈에 띄게 증가
특히 상품 상세는 페이지 하나가 열린다고 끝이 아니라, 비슷한 상품 묶음이나 옵션 정보까지 같이 따라가면서 쿼리가 계속 이어졌습니다.
캐시를 어디에 붙일지 먼저 정했다
처음부터 모든 조회를 Redis에 넣고 싶진 않았습니다.
캐시는 붙이는 순간 만료 정책, 무효화 시점, 운영 복잡도도 같이 가져오기 때문입니다.
그래서 기준을 먼저 정했습니다.
- 짧은 시간에 반복 조회되는가
- 데이터 변경 주기가 느린가
- 약간의 지연 반영을 감수할 수 있는가
이 기준으로 보니 상품 상세와 추천 상품은 캐시 후보로 충분했습니다.
반대로 주문 상태나 재고처럼 실시간성이 더 중요한 데이터는 이번 대상에서 뺐습니다.
실제 적용 방식
가장 단순한 방식으로 시작했습니다. Spring Cache를 쓰고 저장소를 Redis로 붙였습니다.
@Cacheable(
cacheNames = "product:detail",
key = "#productId"
)
public ProductDetailResponse getProductDetail(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new BusinessException(ErrorCode.PRODUCT_NOT_FOUND));
return ProductDetailResponse.from(product);
}추천 상품은 목록 자체를 캐시했습니다.
@Cacheable(cacheNames = "home:recommended", key = "'default'")
public List<HomeProductResponse> getRecommendedProducts() {
return productRepository.findRecommendedProducts().stream()
.map(HomeProductResponse::from)
.toList();
}TTL은 처음부터 길게 잡지 않았습니다.
- 상품 상세: 10분
- 추천 상품: 5분
운영하면서 보니 이 정도만으로도 체감이 꽤 컸습니다. 너무 공격적인 캐시는 아니고, 그렇다고 DB에 계속 부담을 주는 상태도 아니었습니다.
TTL은 CacheManager에서 분리해서 설정했다
처음에는 @Cacheable만 붙여도 끝난다고 생각했는데, 실제 운영에서는 캐시마다 수명이 달라야 했습니다.
- 상품 상세는 어느 정도 길게 가져가도 괜찮고
- 추천 상품은 운영자가 바꾸면 비교적 빨리 반영되는 편이 좋았습니다
그래서 TTL은 애너테이션에 흩뿌리기보다 CacheManager에서 캐시별로 나눠 관리했습니다.
@Bean
public RedisCacheManager redisCacheManager(
RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper
) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer(objectMapper)
)
)
.disableCachingNullValues()
.entryTtl(Duration.ofMinutes(3));
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
cacheConfigurations.put("product:detail", defaultConfig.entryTtl(Duration.ofMinutes(10)));
cacheConfigurations.put("home:recommended", defaultConfig.entryTtl(Duration.ofMinutes(5)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}이렇게 해두면 나중에 캐시가 늘어나도 어디 TTL을 조정해야 하는지 한눈에 들어왔습니다. 실제로 운영하다 보면 "이 캐시는 너무 오래 간다", "이건 금방 만료돼서 의미가 없다" 같은 얘기가 자주 나오는데, 그럴 때도 설정 위치가 분산돼 있지 않아서 손보기가 편했습니다.
캐시를 붙이고 나서 가장 먼저 본 것
캐시를 붙인 뒤에는 평균 응답 시간보다도 먼저, 캐시 적중률과 DB 읽기 패턴을 봤습니다.
결과는 꽤 분명했습니다.
| 항목 | 적용 전 | 적용 후 |
|---|---|---|
| 상품 상세 평균 응답 시간 | 약 280ms | 약 90ms |
| 추천 상품 API 평균 응답 시간 | 약 190ms | 약 35ms |
| 피크 시간대 상품 조회 쿼리 | 기준치 100% | 약 35~40% 수준 |
숫자도 좋았지만, 제일 좋았던 건 피크 시간대에 DB 그래프가 덜 출렁인다는 점이었습니다.
예전에는 캠페인 링크가 열릴 때마다 비슷한 조회가 몰리면서 읽기 부하가 급격히 올라갔는데, 캐시 이후에는 그래프가 한결 덜 예민해졌습니다.
생각보다 중요했던 건 무효화였다
캐시를 붙이고 나서 바로 부딪힌 건 "언제 지울 것인가"였습니다.
조회는 빨라졌지만, 상품명이 바뀌거나 추천 상품이 교체되는 순간 예전 데이터가 잠깐 남을 수 있었습니다.
그래서 단순 TTL만 두지 않고, 관리 기능에서 수정이 일어나는 시점에 캐시를 지우도록 했습니다.
@CacheEvict(cacheNames = "product:detail", key = "#productId")
public void updateProduct(Long productId, UpdateProductRequest request) {
...
}
@CacheEvict(cacheNames = "home:recommended", key = "'default'")
public void updateRecommendedProducts(List<Long> productIds) {
...
}결국 캐시는 "읽기 성능"만의 문제가 아니고, 데이터를 바꾸는 흐름과 같이 봐야 한다는 걸 여기서 다시 느꼈습니다.
캐시를 붙이고도 남아 있던 문제
Redis를 붙인다고 모든 성능 문제가 끝나진 않았습니다.
특히 아래 두 가지는 따로 봐야 했습니다.
- 캐시 미스가 나는 첫 요청은 여전히 느릴 수 있다
- 캐시에 들어갈 DTO가 너무 크면 오히려 비효율적일 수 있다
처음에는 상품 상세 응답 전체를 통째로 넣고 싶었는데, 실제로는 화면에 꼭 필요한 필드만 담은 DTO로 줄이는 편이 더 나았습니다.
캐시는 빠르지만 공짜는 아니라는 걸 그때 더 분명히 느꼈습니다.
지금 기준에서는 이렇게 판단한다
이후로는 Redis 캐시를 붙일지 고민할 때 아래 질문부터 봅니다.
- 같은 데이터를 짧은 시간 안에 반복 조회하는가
- DB가 느린 게 아니라, 반복 조회 자체가 낭비는 아닌가
- TTL과 무효화 시점을 설명할 수 있는가
이 셋 중 하나라도 애매하면 바로 캐시부터 넣기보다 쿼리와 인덱스를 먼저 다시 보는 편입니다.
마무리
이번에 Redis를 붙이면서 느낀 건, 캐시는 "무조건 빨라지는 기술"이라기보다 읽기 패턴이 맞을 때 힘을 내는 도구라는 점이었습니다.
잘 맞는 지점에 붙이면 효과가 분명하지만, 갱신 전략이 없는 캐시는 나중에 더 큰 혼란을 만들 수도 있습니다.
그래서 지금은 응답 시간 숫자만 보기보다, 이 데이터가 정말 캐시에 어울리는지를 먼저 따져보고 있습니다.