[Redis]간단한 E-Commerce 프로젝트를 통해 알아보는 Cache -3 ⏰(Redis Cache로 DB부하 줄이기 : Redis로 얼마나 줄어드는지 성능비교하기)

nana·2024년 11월 7일
0

Redis

목록 보기
3/4
post-thumbnail

0. 이번 포스트의 주제

Redis의 Cache 전략 세우기 아티클을 통해 Redis의 캐시전략을 알아보았다.

지난번엔 DB Lock을 통한 동시성 제어방법을 알아보았다면,
이번엔 캐싱을 이용해 DB의 접근성을 줄이면서 데이터를 빨리 줄일 수 있는 방법에 대해서 알아보고자 한다.

Cache == Redis는 아니며, Cache를 사용하기 위한 수단으로 Redis를 사용한거다.

1. 🛒e-commerce에 캐시를 적용할만한 시나리오는 뭐가 있을까?

이커머스 프로젝트의 기능

나의 이커머스 프로젝트(aka. OhSir39cm)는 다음과 같은 기능을 가지고 있다.

  • 포인트 충전/사용
  • 상품 주문 /결제
  • 상품 조회
  • 장바구니 추가 / 삭제
  • 최근3일의 판매 순위 조회

In My Opinion,

캐싱은 자주 변경되지 않으면서, 읽기 작업이 자주 일어나는 작업에 가장 좋다고 했다.
위의 기능 중, 캐싱하기에 적합한 기능은

  • 상품 조회 (재고 말고)
  • 최근 3일의 판매 순위 조회

이 두가지가 아닐까 생각한다.

특히, 상품 테이블은 상품 정보와 상품 재고 테이블을 정규화 시켜놓았기 때문에

주문 조회에 대한 코드에 캐시를 적용하고 Postman으로 응답 속도를 측정해 보았다.

재고 조회는 오래 걸리더라도 정확해야하므로 락을 걸고 DB조회해 오는 것이 좋다!

2. Cache 적용 예시 - 얼마나 성능이 향상되었나?

@Configuration
@RequiredArgsConstructor
public class CacheConfig {

    public final RedisConnectionFactory connectionFactory;

    @Bean
    public CacheManager redisCacheManager() {

        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext
                        .SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext
                    .SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofMinutes(2L));  

        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(connectionFactory)
                .cacheDefaults(redisCacheConfiguration).build();
    }
}

CacheConfig.java
entryTtl 캐시의 만료시간을 정해주었다.
TTL (Time To Live) : redis 데이터의 만료기간을 설정하여 TTL 기간이 지나면 캐시 데이터가 없어지게된다.
TTL을 설정함으로써 자연스럽게 캐시값이 없어지고 데이터 조회 시 DB데이터를 캐시에 다시 저장하게 되면서 최신의 DB데이터를 캐시에 담을 수 있게 된다.

🔽상품 조회

@Cacheable(cacheNames = "getProductInfo"
           , key = "#productId + ''")
    public Product getProductById(Long productId) {
       log.info("Getting product from DB for ID: {}", productId);
        Product product = productRepository.findByProductId(productId);

        if(product == null)
            throw new BusinessException(ECommerceExceptions.INVALID_PRODUCT);
        return product;
    }

    public Optional<ProductInventory> getProductInventoryById(Long productId) {
        Optional<ProductInventory> productInventory =  productInventoryRepository.findById(productId);

        if (productInventory.isEmpty() || productInventory.get().getAmount() <= 0) {
            throw new BusinessException(ECommerceExceptions.OUT_OF_STOCK);
        }

        return productInventory;
    }

Cache 적용 전

Cache 최초 적용 후 조회

Cache 적용 후 두번째 조회

상품코드로 조회했을 때
캐시 적용 전 : 305.67ms
캐시 적용 후 : ⏰62.74ms 👉🏻 19.46ms

🔽 Top 5 랭킹


   @Cacheable( cacheNames = "getDaysRanking"
               , key = "#dateFormat"
               , unless = "#dateFormat == null")
   @CacheEvict(value = "dateFormat", key ="#dateFormat.minusDays(3)")
    public List<RankingResponse> getThreeDaysRanking(LocalDateTime dateFormat){
        LocalDateTime threeDaysAgo = dateFormat.minusDays(2);
        Pageable pageable = PageRequest.of(0, 5);


        List<Ranking> orderedList = rankingRepository.findByNowdateForRanking(threeDaysAgo, pageable);

        return orderedList.stream()
                .map(r -> {
                    Long productId = r.getProductId();
                    Long orderCount = r.getOrderCount();

                    Product product = productRepository.findByProductId(productId);

                    return new RankingResponse(
                            product.getProductId(),
                            product.getProductName(),
                            orderCount,
                            product.getPrice(),
                            product.getCategory()
                    );
                }).collect(Collectors.toList());

    }

Cache 적용 전

Cache 최초 적용 후 조회

Cache 적용 후 두번째 조회

상품코드로 조회했을 때
캐시 적용 전 : 345.9ms
캐시 적용 후 : ⏰354.9ms 👉🏻 31.83ms

TestCode

RankingService에서 사용되는 Repository를 @SpyBean로 적용하고 캐시 적용 전과 적용 후를 비교하였다.

 @Test
    @DisplayName("🟢Cache 잘 적용되었는지 확인하기.")
    void validateRankingCache(){
        LocalDateTime date = LocalDateTime.now();

        //캐시 적용 전
        rankingService.getThreeDaysRanking(date);

        verify(spyRankingRepository, times(1)).findByNowdateForRanking(any(),any(Pageable.class));

        //캐시 적용 후
        rankingService.getThreeDaysRanking(date);

        verify(spyRankingRepository, times(1)).findByNowdateForRanking(any(),any(Pageable.class));

        rankingService.getThreeDaysRanking(date.minusDays(1)); //다른날짜로 했을 때 조회되는지 확인
        verify(spyRankingRepository, times(2)).findByNowdateForRanking(any(),any(Pageable.class));

    }

같은 데이터를 호출하면 캐시 적용 후에도 repository는 한번만 호출될 것이다.

3. 정말 적절한 적용인가?

검색 엔진과의 비교

사용자의 검색 패턴이 정해져 있지 않은 검색엔진에 비해 이커머스는 검색 트랜드가 변경될 일이 잘 없다.
때문에 검색 파라미터 별로 몇개를 설정해놔서 캐싱하는 방법으로 이커머스에서는 쓰고있다고 한다!

Cache Warmup Time

@Cacheable를 사용하면 캐시에 데이터가 없을 경우에는 기존의 로직을 실행한 후에 캐시에 데이터를 추가하고, 캐시에 데이터가 있으면 캐시의 데이터를 반환하게된다.
그래서 캐시데이터가 없을 때 부하가 DB로 쏠릴 수 있으므로 캐시에 데이터를 선제적으로 넣어주는 작업을 하는데, 이걸 Cache Warming이라고 한다.

Cache Stampeted

캐시 쇄도(Cache Stampede)는 캐시가 전부 정확히 같은 시간에 만료되도록 구현하면 자주 발생한다.
캐시가 만료되면 DB를 바로 조회하게 되는데 동시에 DB로 부하가 몰리면서 발생할 수 있는 문제이다.

위와 같은 문제를 인지하고 예방한다면 캐시는 너무나도 중요한 기술이 될 것이다!
속도가 곧 생명이기에!

4. 캐시에 관한 고급스킬

캐시 레이어링

보통 캐시가 만료되거나 없는 경우를 방지하기 위해 캐시를 여러겹 적용해 놓는다.

1차캐시 -> 로컬 (로컬 전역변수 메모리)
2차캐시 -> 리모트 (글로벌 - 레디스,,,)
3차 -> DB!

타이밍(?)

Look Aside PatternCache Stamped현상을 방어할 수 없다.
때문에 Cache Warmup에 대해 고민해봐야하며, 언제 넣고 언제 만료(evice)할건지 전략을 잘 짜는게 중요하다.

cache eviction은 캐시 성향마다 다를것이다.
어떤 데이터를 ttl어떻게 지정할 것인지 자주 eviction해야하는지 등.

또, 랭킹 시스템과 같이 "실시간이라고 한다면 얼마나 실시간이어야하는지?" 실시간은 페이지를 들어갈 때 마다 DB를 직접적으로 조회하는거 아닌 이상 실시간이라고 할 수 없다.
아 물론 DB를 직접 조회하는 것도 100%실시간이라고 할 순 없다.
조회하고 데이터 내려주는 시간이 있을테니

정책적으로 어디까지 실시간으로 볼껀지를 정하면 될 것같다.


캐시 문제 해결 가이드 - DB 과부하 방지 실전 팁
[Spring] 캐시(Cache) 추상화와 사용법(@Cacheable, @CachePut, @CacheEvict)

profile
BackEnd Developer, 기록의 힘을 믿습니다.

0개의 댓글