Skip to content

상품 리스트 조회 성능 개선기 (캐시)

Jiwon Choi edited this page Sep 12, 2023 · 2 revisions

캐시를 도입해보자

안녕하세요? 상품 리스트 조회 두 번째 성능 개선기입니다.

이번에 극한의 성능 최적화를 위해 캐시를 도입해보려고 합니다.

사실 레디스를 쓰고 싶었지만 레디스를 써 본 경험도 없고 저희 aws 상황 상 레디스를 설치할 공간도 없어 이번에는 Spring level에서 캐시를 적용해 극한의 성능 최적화를 진행해봅시다.

Spring Cache 설정

일단 여러분께 Spring cache의 설정 방법에 대해 공유해드려고 합니다.

우선 Spring Cache를 사용하기 위해 build.gradle에 의존성을 추가해줘야 합니다.

다행히도 Spring Boot Starter에서 이 설정을 제공해주네요 코드 한 줄이면 됩니다.

// cache
implementation 'org.springframework.boot:spring-boot-starter-cache'

이제 build.gradle에 의존성을 넣었으니 Spring Boot Application에 설정을 추가해야겠죠? 이것도 매우 간단합니다.

Spring Boot Main 메서드에 설정을 넣어도 되지만 저희 프로젝트는 지금 이런 설정이 전부 분리되어 있는 환경이기 때문에 클래스를 하나 만들겠습니다.

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@EnableCaching
@Configuration
public class CacheConfig {
}

이제 설정은 끝났습니다. 이제 시원하게 캐시를 적용해서 극한의 성능 최적화를 도전해봅시다.

Spring Cache 도입

이제 진짜로 Spring Cache를 도입해봅시다.

저는 Caffeine을 구현체로 하는 캐시를 도입했습니다. 여러 자료를 찾아보니 로컬 캐시는 Caffeine이 성능이 가장 좋다고 하네요~

build.gradle에 Caffeine에 대한 의존성을 추가해줍시다.

implementation 'com.github.ben-manes.caffeine:caffeine'

그리고 이제 우리가 캐시를 적용할 메서드에 접근해 캐시를 도입해봅시다.

@Transactional(readOnly = true)
@Cacheable(cacheNames = "searchProducts", key = "#request.toString()")
public List<ProductResponse> searchProducts(final ProductSearchRequest request) {
    final List<ProductSpecification> productSpecifications = productRepository.searchProducts(
        request.toProductSearchCondition());
    return ProductResponse.listOf(productSpecifications);
}

@Cacheable을 통해 메서드 리턴 값에 대한 캐시를 만들 수 있습니다.

cacheNames 는 캐시 이름을 지정하는 속성입니다. 참고로 value 라는 속성도 있는데 둘의 차이는 없고, Spring 3.1 버전 이후로는 cacheNames를 추천하는 추세입니다. 아마 가독성 때문에 그러지 않을까 싶습니다.

key 는 캐시 데이터의 key 를 의미합니다. 이거는 Map<K, V>에서 K를 생각하면 이해하기 쉽습니다. 여기서도 중요한 점이 하나 있는데 세션과 마찬가지로 로컬에서 데이터를 넣기 때문에 Key 값이 최대한 가벼워야 합니다. 그래서 원래는 객체를 넣어주려고 했는데 객체보다는 toString()으로 옮기는게 더 가벼울거라 생각하여 toString() 값을 넣어 해결했습니다.

그러면 이제 캐시가 삭제되어야 하는 순간도 지정해야겠죠?

저는 캐시 데이터의 정합성이 박살나는 구간에서 캐시가 한 번 삭제되어야 한다고 생각합니다. 그리고 저는 이 타이밍을 상품의 상태(판매중, 품절, 등록)가 변경되는 경우라고 생각했습니다.

그래서 updateStatus 메서드에 캐시를 박살내는 애노테이션을 넣어 캐시를 박살내었습니다.

@Transactional
@CacheEvict(cacheNames = "searchProducts", allEntries = true)
public void updateStatus(final Long id, final ProductStatusUpdateRequest request) {
    Product product = findProductById(id);
    log.info("ProductService.updateStatus() 로직 실행 전: productId = {}, status = {}, updateStatus = {}", id, product.getStatus().name(), request.getProductStatus().name());
    product.updateProductStatus(request.getProductStatus());
    log.info("ProductService.updateStatus() 로직 실행 후: productId = {}, status = {}", id, product.getStatus().name());
}

@CacheEvict 를 통해 해당 메서드가 실행되는 순간 캐시를 박살낼 수 있습니다.

allEntries 속성을 ture로 두어 이 메서드가 실행되는 순간 모든 캐시를 박살내려고 합니다. 사실 이 부분이 제일 아쉽지만 부분적으로 박살내는 방법을 찾을 수 없어 우선을 이렇게 하였습니다. 제가 더 공부해보고 부분적으로 박살낼 수 있다면 적용해보도록 하겠습니다.

이렇게 캐시를 도입하였습니다.

이제 진짜 마지막으로 저희가 의존성으로 추가한 카페인 캐시를 주입해줘야 합니다.

주입에는 2가지 방법이 있습니다.

  1. Configuration에서 직접 등록
  2. application.properites, application.yml 에서 등록

캐시마다 특정 ttl이나 조건을 걸려면 Configuration에서 직접 등록하는게 올바른 방법이지만 저희 프로젝트는 현재 하나의 캐시만을 사용하기 때문에 application.yml에 등록하도록 하겠습니다. 이 부분도 추후 변경이 필요하다면 Configuration에서 직접 등록하도록 하겠습니다.

spring:
  cache:
    caffeine:
      spec: maximumSize=500,expireAfterAccess=5s
    type: caffeine
    cache-names:
      - searchProducts

yaml 파일에 캐시 정보를 등록하였습니다. 캐시 데이터의 ttl은 5초로 설정하였고, 최대 사이즈는 500으로 두어 혹시 모를 메모리 초과에 대비하였습니다.

이제 진짜 모든 설정이 끝났으니 직접 코드를 돌려봅시다.

캐시를 테스트하는 방법에 대해 연구해보았으나 이미 구현된 캐시를 가져다 쓰는 입장에서 굳이 테스트 레이어를 사용해야 될 지 의문이 들어 우선은 서버에 직접 요청을 보내 확인해보았습니다.

아래는 캐시 적용의 결과입니다.

2023-08-27 20:05:36.354  INFO 12900 --- [nio-8080-exec-7] c.w.w.d.product.api.ProductController    : productKeyword: 20, lastProductId: null 상품 조회
Hibernate: 
    select
        product0_.id as col_0_0_,
        productsal1_.sale as col_1_0_,
        product0_.id as id1_6_,
        product0_.created_at as created_2_6_,
        product0_.updated_at as updated_3_6_,
        product0_.image_url as image_ur4_6_,
        product0_.name as name5_6_,
        product0_.price as price6_6_,
        product0_.quantity as quantity7_6_,
        product0_.status as status8_6_ 
    from
        products product0_ 
    left outer join
        product_sales productsal1_ 
            on (
                product0_.id=productsal1_.product_id
            ) 
    where
        (
            lower(product0_.name) like ? escape '!'
        ) 
        and (
            productsal1_.sale_date=? 
            or productsal1_.sale_date is null
        ) 
    order by
        case 
            when productsal1_.sale is null then 1 
            else 0 
        end,
        productsal1_.sale desc,
        product0_.id asc limit ?
2023-08-27 20:05:37.037  INFO 12900 --- [nio-8080-exec-8] c.w.w.d.product.api.ProductController    : productKeyword: 20, lastProductId: null 상품 조회

아래에 쿼리가 안나가는 것을 확인할 수 있습니다. 굿~

주의 사항!!!

찬우님이 캐시를 적용하면 메서드 로직을 건너뛰냐는 질문을 하셔서 직접 검증해보았습니다.

검증 과정은 다음과 같습니다. 메서드에 로그를 찍어 이 로그가 정말 찍히는지 확인하는 방법으로 로직이 진짜 건너뛰는 방식으로 동작하는지 확인해보겠습니다.

@Transactional(readOnly = true)
@Cacheable(value = "searchProducts", key = "#request.toString()")
public List<ProductResponse> searchProducts(final ProductSearchRequest request) {
    final List<ProductSpecification> productSpecifications = productRepository.searchProducts(
        request.toProductSearchCondition());
    log.info("캐시 미적용!!");
    return ProductResponse.listOf(productSpecifications);
}

결과는 다음과 같습니다.

Untitled78

로직을 정말 건너뛰네요~ 캐시 사용할 때 이 부분을 주의하면서 사용해주세요~

캐시 위치의 변경

상품 검색에 캐시를 도입하기에는 너무 성능이 나오지 않을거 같다는 피드백을 받았습니다.

→ 캐시 적중률이 낮고 너무 많은 데이터가 들어갈거 같다.

그래서 저희 상품 리스트 조회의 캐시의 위치를 메인 페이지 상품의 조회로 바꿨습니다.

위의 설명 드린 내용을 바탕으로 캐시의 위치만 옮겼으니 코드로 간단하게 보여드리고 마무리 하겠습니다.

@Transactional(readOnly = true)
@Cacheable(value = "productsFirstRanking")
public List<ProductResponse> findRankingProducts() {
    final List<ProductSpecification> productSpecifications = productRepository.searchProducts(
        ProductSearchCondition.builder().build());

    return ProductResponse.listOf(productSpecifications);
}
@Transactional
@CacheEvict(cacheNames = "productsFirstRanking", allEntries = true)
public void updateStatus(final Long id, final ProductStatusUpdateRequest request) {
    Product product = findProductById(id);
    product.updateProductStatus(request.getProductStatus());
}

추후 서비스가 성장한다면?? (고려해야 될 점)

현재는 저희 서버가 1대라 로컬 캐시를 적용했을 때 큰 문제가 발생하지 않았고 발생하지 않을겁니다.

하지만 서버가 여러 대인 경우 문제가 발생할 수 있습니다.

아래의 코드에 문제가 발생할 수 있는데요 어떤 문제일까 한 번 살펴봅시다.

@Transactional
@CacheEvict(cacheNames = "productsFirstRanking", allEntries = true)
public void updateStatus(final Long id, final ProductStatusUpdateRequest request) {
    Product product = findProductById(id);
    product.updateProductStatus(request.getProductStatus());
}

만약 저희 서비스가 큰 성장을 해서 서버가 3대가 있다고 생각해봅시다.

그런 경우 로컬 캐시이기 때문에 각 서버에 메인 페이지 캐시 정보가 존재할겁니다.

스크린샷 2023-09-12 오후 5 16 04

이 때 서버 1에 접근한 사용자가 메인 페이지에 있는 상품을 주문했고 그 때 그 상품이 품절 되었다고 생각해봅시다. 그러면 위의 메서드가 실행되면서 로컬 캐시가 초기화됩니다. 그러면 아래의 상태로 바뀌겠죠?

스크린샷 2023-09-12 오후 5 19 48

이런 경우 서버 2와 서버 3은 캐시가 초기화되지 않고 살아있게 됩니다. 그러면 서버2와 서버 3에 접근하는 사용자는 메인 페이지에 품절된 상품을 조회하는 상황이 발생하게 됩니다.

이런 불미스러운 상황이 저희 서비스의 서버가 늘어남에 따라 발생할 수 있습니다. 이런 상황을 방지하기 위해 만약 서비스가 성장해서 서버가 늘어나야 된다면 글로벌 캐시를 도입하는 방법을 고민해야 되지 않을까요?

참고 자료

Spring Cache, 제대로 사용하기

[spring] cache

[Spring] 스프링 캐시 알아보기 (@Cacheable, @CachePut, @CacheEvict)

  • GPT