본문 바로가기
Project/StyleLab

StyleLab의 다섯 번째 노트: Ehcache 적용

by 규난 2024. 1. 18.
728x90

이번 프로젝트를 진행하면서 카테고리 목록 조회에 cache를 적용한 경험을 공유해 보도록 하겠습니다.

 

목차

  1. 상품 카테고리 테이블 구조
  2. cache를 도입한 이유
  3. Ehcache
  4. Ehcache 적용
  5. APM Pinpoint로 monitoring
  6. reference

 

1. 상품 카테고리 테이블 구조

상품 카테고리 테이블은 계층형 데이터 모델인 경로 열거 테이블 구조 방식을 사용하여 데이터를 저장하고 있습니다.

상품 카테고리 테이블

 

상품 카테고리 테이블을 경로 열거 테이블 구조로 결정한 이유는 다음과 같습니다.

  • 계층 구조가 크게 변하지 않을 거라 예측되며, 구현이 쉽습니다.
  • 계층의 깊이가 깊지 않고 상대적으로 고정된 구조를 갖기 때문에 계층 구조를 직관적으로 이해하기 쉽습니다.
  • 쿠팡, 무신사, 네이버, 오늘의 집 모두 경로 열거형 방식의 계층형 테이블 구조를 사용하는 것으로 판단하였습니다.

 

2. cache를 도입한 이유

밑의 코드는 카테고리 테이블에 전체 데이터를 조회 후 recursive method를 사용하여 카테고리 트리를 만드는 코드입니다. recursive method를 사용하여 카테고리 트리를 만든 이유는 다음과 같습니다.

  • 코드의 수정 없이 카테고리의 깊이 변화에 대응이 가능합니다.
  • 카테고리의 데이터양이 많지 않고 깊이가 깊지 않기 때문에 충분히 recursive method를 사용하여 트리 구조를 만들 수 있다 판단하였습니다.
@Component
public class ProductCategoriesFacade {

    private final ProductCategoriesService productCategoriesService;

    public ProductCategoriesFacade(ProductCategoriesService productCategoriesService) {
        this.productCategoriesService = productCategoriesService;
    }

    public ProductCategoriesResponse findAllCategories() {
        List<ProductCategoriesDto> productCategoryDtos = productCategoriesService.findAllCategories();

        List<Categories> parentCategories = productCategoryDtos.stream()
                .filter(productCategoriesDto -> !StringUtils.hasText(productCategoriesDto.parentCategory()))
                .map(Categories::of)
                .collect(Collectors.toList());

        generateCategoryTreeRecursively(productCategoryDtos, parentCategories);

        return ProductCategoriesResponse.createResponse(parentCategories);
    }

    private void generateCategoryTreeRecursively(
            List<ProductCategoriesDto> productCategoryDtos, List<Categories> categories) {
        for (Categories category : categories) {
            List<Categories> childCategories = productCategoryDtos.stream()
                    .filter(productCategoriesDto -> Objects.equals(productCategoriesDto.parentCategory(), category.categoryPath()))
                    .map(Categories::of)
                    .collect(Collectors.toList());

            category.addAllChildCategories(childCategories);

            generateCategoryTreeRecursively(productCategoryDtos, childCategories);
        }
    }
}

 

위 코드를 실행한 결과는 다음과 같습니다. 원하는 대로 결과가 잘 나왔네요 ㅎㅎ

{
    "code": "20000",
    "message": "Success",
    "categories": [
        {
            "categoryName": "상의",
            "categoryPath": "001001",
            "childCategories": [
                {
                    "categoryName": "맨투맨/후드",
                    "categoryPath": "001001001",
                    "parentCategory": "001001",
                    "childCategories": [
                        {
                            "categoryName": "맨투맨",
                            "categoryPath": "001001001001",
                            "parentCategory": "001001001",
                            "childCategories": []
                        },
                        {
                            "categoryName": "후드",
                            "categoryPath": "001001001002",
                            "parentCategory": "001001001",
                            "childCategories": []
                        }
                    ]
                },
                {
                    "categoryName": "니트/스웨터",
                    "categoryPath": "001001002",
                    "parentCategory": "001001",
                    "childCategories": [
                        {
                            "categoryName": "니트",
                            "categoryPath": "001001002001",
                            "parentCategory": "001001002",
                            "childCategories": []
                        },
                        {
                            "categoryName": "스웨터",
                            "categoryPath": "001001002002",
                            "parentCategory": "001001002",
                            "childCategories": []
                        }
                    ]
                },
                {
                    "categoryName": "반소매 티셔츠",
                    "categoryPath": "001001003",
                    "parentCategory": "001001",
                    "childCategories": []
                },
                {
                    "categoryName": "셔츠",
                    "categoryPath": "001001004",
                    "parentCategory": "001001",
                    "childCategories": []
                },
                {
                    "categoryName": "민소매 티셔츠",
                    "categoryPath": "001001005",
                    "parentCategory": "001001",
                    "childCategories": []
                }
            ]
        },
        {
            "categoryName": "신발",
            "categoryPath": "001004",
            "childCategories": [
                {
                    "categoryName": "구두",
                    "categoryPath": "001004001",
                    "parentCategory": "001004",
                    "childCategories": []
                },
                {
                    "categoryName": "샌들",
                    "categoryPath": "001004002",
                    "parentCategory": "001004",
                    "childCategories": []
                },
                {
                    "categoryName": "슬리퍼",
                    "categoryPath": "001004003",
                    "parentCategory": "001004",
                    "childCategories": []
                },
                {
                    "categoryName": "스니커즈",
                    "categoryPath": "001004004",
                    "parentCategory": "001004",
                    "childCategories": []
                },
                {
                    "categoryName": "캔버스/단화",
                    "categoryPath": "001004005",
                    "parentCategory": "001004",
                    "childCategories": [
                        {
                            "categoryName": "캔버스",
                            "categoryPath": "001004005001",
                            "parentCategory": "001004005",
                            "childCategories": []
                        },
                        {
                            "categoryName": "단화",
                            "categoryPath": "001004005002",
                            "parentCategory": "001004005",
                            "childCategories": []
                        }
                    ]
                }
            ]
        }
    ]
}

 

여기서 카테고리 목록 조회 API 개발이 끝난 거 같았으나 조금 더 고민을 해보니 local cache를 사용함으로써 효율적으로 카테고리 목록을 조회할 수 있다는 생각을 하게 되었습니다. cache를 사용하려는 이유와 local cache를 선택한 이유는 다음과 같습니다.

  • 카테고리 트리 구조는 잘 변하지 않고 많은 사용자가 같은 카테고리 목록 데이터를 조회함으로써 cache hit이 높습니다.
  • local cache를 사용하여 운영비와 관리 포인트를 늘리지 않을 수 있습니다.

 

3. Ehcache

Ehcache는 Java 기반의 오픈 소스 캐시 라이브러리입니다.

redis와 같이 별도의 서버가 필요 없고 Spring Framwork 내부에서 동작이 가능하기 때문에 비용적인 측면과 관리 포인트가 늘지 않는다는 장점이 있습니다.

 

Storage Tiers

Ehcache에는 캐시 데이터를 저장할 수 있는 다양한 데이터 저장 계층인

Heap Tier(near cache) - Off Heap Tier - Disk Tier(farther cache)가 존재합니다.

near cache → farther cache로 갈수록 조회 속도는 저하되고 저장 영역의 크기는 증가하게 됩니다.

Ehcache Store Tier 구조

 

On-Heap Store(Heap Tier)

자바 애플리케이션과 동일한 힙 메모리를 사용하기 때문에 접근 속도가 가장 빠르지만 캐시로 인한 힙 메모리 사용량이 증가할수록 GC를 수행하기 위한 스레드를 제외하고 애플리케이션의 모든 스레드 작업이 멈추는 stop-the-world가 길어지기 때문에 애플리케이션 성능에 치명적일 수 있습니다.

 

Off-Heap Store(Off Heap Tier)

자바 애플리케이션 힙 영역이 아닌 외부 메모리에 저장하기 때문에 GC 대상이 아니며 캐시 데이터를 저장 시 반드시 직렬화를 해야 하고 조회 시 역직렬화를 해야 하므로 On-Heap Store보다 속도가 느립니다.

 

Disk Store(Disk Tier)

디스크에 캐시 데이터를 저장하는 방식입니다. RAM을 사용하여 캐시 데이터를 저장하는 방식보다 접근 속도는 느리지만 많은 양의 캐시 데이터를 저장할 수 있습니다.

(이 외에 Clustered Store가 존재합니다. 또한 위 계층들을 조합하여 다중 계층을 설정할 수 있는데 이 부분은 마지막 목차 reference의 공식 홈페이지에서 확인하실 수 있습니다.)

 

4. Ehcache 적용

Spring boot 3.2.1, java 17을 사용하고 있으며 ehcache3.10.8 버전을 사용하여 ehcache를 적용해 보도록 하겠습니다.

가장 먼저 bulid.gradle에 밑의 의존성을 추가해 줍니다.

Ehcache 3 버전 이상부터는 JSR-107(java cahce 표준) 기반으로 만들어졌기 때문에 JSR-107 API도 필요합니다.

implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.ehcache:ehcache:3.10.8'
implementation 'javax.cache:cache-api:1.1.1'

// XML 문서와 Bean들 간의 바인딩을 지정해 주기 위해 사용되는 의존성
implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.1'
implementation 'javax.xml.bind:jaxb-api:2.3.1'

 

의존성을 추가 후 EhCacheConfiguration을 추가해 Spring 애플리케이션에서 캐싱 기능을 활성화시켜주겠습니다. 설정 클래스는 부트 애플리케이션 클래스와 동일 디렉터리나 하위 디렉터리에 위치해야 합니다.

@EnableCaching
@Configuration
public class EhCacheConfiguration {
}

 

캐싱 기능을 활성화시킨 후 resource 디렉터리 하위에 ehcache.xml 추가하여 캐시 관련 설정을 해주도록 하겠습니다.

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns="http://www.ehcache.org/v3"
        xsi:schemaLocation="
            http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.0.xsd">

    <cache alias="productCategoriesDtos">
    	<!-- 만료시간이 없고 키를 가지지 않으며 List 타입의 값을 가지는 캐시 값 설정 -->
        <value-type>java.util.List</value-type>

        <!-- 캐시가 생성될 때 동작하는 비동기 방식의 리스너 설정 -->
        <listeners>
            <listener>
                <class>com.stylelab.common.configuration.CacheEventLogger</class>
                <event-firing-mode>ASYNCHRONOUS</event-firing-mode>
                <event-ordering-mode>UNORDERED</event-ordering-mode>
                <events-to-fire-on>CREATED</events-to-fire-on>
                <events-to-fire-on>EXPIRED</events-to-fire-on>
            </listener>
        </listeners>

        <!-- 
          ehcache:3.10.8에서는 heap element는 deprecated됨. 
          힙 영역 외부에 저장하는 offheap을 권장하고 있음.
        -->
        <resources>
            <offheap unit="MB">10</offheap>
        </resources>
    </cache>

</config>

 

캐시 관련 설정을 마친 후 Spring 애플리케이션이 ehcache.xml을 찾을 수 있도록 yml에 위치 정보 속성을 추가하여 주겠습니다.

spring:
  cache:
    jcache:
      config: classpath:ehcache.xml

 

기본 설정을 다 마쳤으니 상품 카테고리 테이블에서 카테고리 목록을 가져오는 코드에 @Cachable 애노테이션을 사용하여 캐시를 적용해 보도록 하겠습니다.

@Cacheable은 캐시가 있으면 캐시 정보를 가져오고 없으면 캐시를 등록해 주는 애노테이션입니다.

ehcache.xml에서 설정한 productCategoriesDtos 캐시를 @Cacheable의 value 또는 cacheNames 속성에 넣어주도록 하겠습니다.

@Slf4j
@Service
@Transactional(readOnly = true)
public class ProductCategoriesServiceImpl implements ProductCategoriesService {

    private final ProductCategoriesRepository productCategoriesRepository;

    public ProductCategoriesServiceImpl(ProductCategoriesRepository productCategoriesRepository) {
        this.productCategoriesRepository = productCategoriesRepository;
    }

    @Override
    @Cacheable(value = "productCategoriesDtos")
    public List<ProductCategoriesDto> findAllCategories() {
        return productCategoriesRepository.findAll().stream()
                .map(ProductCategoriesDto::toDto)
                .collect(Collectors.toList());
    }
}

 

Spring 애플리케이션을 실행 후 카테고리 목록을 조회해 보면 캐시에 데이터가 없는 경우에 조회 속도는 133ms, 캐시에 데이터가 있는 경우에는 조회 속도가 7ms 나온 것을 확인하실 수 있습니다.

(좌) 캐시 미적용 (우) 캐시 적용

 

이어서 카테고리 목록을 카테고리 트리를 만드는 코드에도 캐시를 적용해 보도록 하겠습니다.

 

목록 2. cache를 도입한 이유에서 카테고리 트리를 만드는 코드를 보시면 ProductCategoriesFacade 클래스 안에 findAllCategories() 메서드와 generateCategoryTreeRecursively() 메서드가 존재하고 findAllCategories() 메서드에서 generateCategoryTreeRecursively()를 호출하는 것을 확인할 수 있습니다.

 

여기서 한 가지 주의할 점은 @Cacheable도 Spring AOP 기반으로 동작하기 때문에 이러한 자가 호출(self-invocation)을 하는 구조에서는 generateCategoryTreeRecursively() 메서드에 @Cacheable 애노테이션을 사용하더라도 카테고리 트리가 캐시 데이터로 등록되지 않습니다.

 

주의점을 알아보았으니 generateCategoryTreeRecursively()를 다른 클래스로 분리하여 @Cacheable 애노테이션을 적용해 보도록 하겠습니다.

 

먼저 ehcache.xml에 카테고리 트리 관련 캐시 설정을 추가해 주도록 하겠습니다.

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns="http://www.ehcache.org/v3"
        xsi:schemaLocation="
            http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.0.xsd">

    <!-- 생략... -->

    <cache alias="productCategoryTree">
        <value-type>java.util.List</value-type>

        <listeners>
            <listener>
                <class>com.stylelab.common.configuration.CacheEventLogger</class>
                <event-firing-mode>ASYNCHRONOUS</event-firing-mode>
                <event-ordering-mode>UNORDERED</event-ordering-mode>
                <events-to-fire-on>CREATED</events-to-fire-on>
                <events-to-fire-on>EXPIRED</events-to-fire-on>
            </listener>
        </listeners>

        <resources>
            <offheap unit="MB">10</offheap>
        </resources>
    </cache>

</config>

 

캐시 설정을 한 후 generateCategoryTreeRecursively()를 다른 클래스로 분리하도록 하겠습니다.

@Component
public class ProductCategoryTree {

    @Cacheable("productCategoryTree")
    public List<ProductCategoriesResponse.Categories> generateCategoryTreeRecursively(List<ProductCategoriesDto> productCategoryDtos) {
        List<ProductCategoriesResponse.Categories> parentCategories = productCategoryDtos.stream()
                .filter(productCategoriesDto -> !StringUtils.hasText(productCategoriesDto.parentCategory()))
                .map(ProductCategoriesResponse.Categories::of)
                .collect(Collectors.toList());

        generateCategoryTreeRecursively(productCategoryDtos, parentCategories);

        return parentCategories;
    }

    private void generateCategoryTreeRecursively(
            List<ProductCategoriesDto> productCategoryDtos, List<ProductCategoriesResponse.Categories> categories) {
        for (ProductCategoriesResponse.Categories category : categories) {
            List<ProductCategoriesResponse.Categories> childCategories = productCategoryDtos.stream()
                    .filter(productCategoriesDto -> Objects.equals(productCategoriesDto.parentCategory(), category.categoryPath()))
                    .map(ProductCategoriesResponse.Categories::of)
                    .collect(Collectors.toList());

            category.addAllChildCategories(childCategories);

            generateCategoryTreeRecursively(productCategoryDtos, childCategories);
        }
    }
}

 

다시 Spring 애플리케이션을 실행 후 카테고리 목록을 조회해 보면 캐시 설정에서 등록한 캐시 이벤트 리스너가 동작하는 것을 보실 수 있습니다.

캐시 이벤트 리스너 로그

 

5. APM Pinpoint로 monitoring

 

마지막으로 APM Pinpoint로 server map과 transaction의 call tree를 살펴보겠습니다.

포스트맨을 사용하여 카테고리 목록을 총 4번 조회한 후, 서버 맵을 확인한 결과 4번 중 하나의 요청만 애플리케이션을 거쳐 MySQL 서버로 전송된 것을 확인할 수 있습니다.

pinpoint server map

 

실제로 하나의 요청만 MySQL 서버로 전송되었는지 확인해 보겠습니다. 아래의 사진에서 Transaction list와 call tree를 살펴보면 처음 들어온 카테고리 목록 조회 요청만이 DB connection을 통해 MySQL 서버로의 요청이 확인됩니다. 또한 Res, Exec 컬럼을 보시면 최대 약 10배 조회 속도 성능이 개선된 것을 확인할 수 있습니다.

 

6. reference

https://www.ehcache.org/documentation/3.10/

https://www.baeldung.com/spring-boot-ehcache

728x90