프로젝트 Project

Ehcache로 캐싱하기

달래dallae 2024. 8. 2. 15:12

프로젝트의 규모나 성격, 도메인에 따라 사용할 수 있는 캐싱 전략은 다양합니다.

카테고리를 전체 조회하는 저의 서비스의 경우, 데이터가 자주 조회됨/변경이 자주 일어나지 않음/용량이 크지 않음 이라는 특성을 가지고 있기 때문에 캐싱을 사용하기로 하였습니다.

또한 단일 서버를 사용하고 있으므로 로컬 인메모리로 캐싱하는 Ehcache를 사용하여 빠르게 카테고리 데이터에 접근할 수 있도록 구현하였습니다.

 

1️⃣ 캐싱 전략

카테고리는 조회가 잦지만 수정이 잦지 않다는 점, 그리고 데이터 변경사항에 민감하지 않다는 점을 고려해 가장 일반적으로 쓰이는 look aside + write around 조합(데이터 정합성 이슈가 있음)을 사용할 것입니다. 

자세한 캐싱 전략 참고

✔️ 캐시 메모리 제거

  • 어플리케이션의 로컬 인메모리를 사용하는 만큼 캐시된 데이터의 용량을 관리하는 것이 중요합니다. 디폴트 캐시 설정으로 가장 오랫동안 사용이 되지 않은 항목을 먼저 제거하는 LRU 알고리즘을 사용하였습니다.
  • 단, 카테고리의 경우에는 단일 데이터이며 cache miss가 있을 때에만 데이터를 저장하기 때문에 큰 의미가 없습니다.

💡 그 외의 알고리즘

  • LFU 알고리즘: 가장 적게 액세스된 항목을 먼저 제거
  • FIFO 알고리즘: 가장 오래된 항목을 먼저 제거

 

✔️ 캐시 데이터 만료 시간 설정

  • 쓰기로 write around 전략(cache miss가 있을 때 캐시저장소에 데이터 저장)을 사용하기 때문에 cache miss의 주기를 관리해주어야 합니다.
  • 카테고리의 경우 자주 변경되는 사항이 아니므로 캐시 만료 시간을 1시간으로 설정하였습니다.

 


2️⃣ 구현

Srping boot3, gradle 9, java 17 환경입니다.

✔️ 설정

글로벌 설정

spring:
  cache:
    ehcache:
      config: classpath:ehcache.xml
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'net.sf.ehcache:ehcache:2.10.3'

 

Ehcache 설정

<?xml version="1.0" encoding="UTF-8"?>
<ehcache>
    <defaultCache
            maxElementsInMemory="1000"
            maxElementsOnDisk="0"
            eternal="false"
            statistics="false"
            timeToIdleSeconds="10"
            timeToLiveSeconds="10"
            overflowToDisk="false"
            diskPersistent="false"
            memoryStoreEvictionPolicy="LRU"/>

    <cache
            name="categoryFindService.findAll"
            maxElementsInMemory="100"
            maxElementsOnDisk="0"
            eternal="false"
            statistics="false"
            timeToIdleSeconds="1800"
            timeToLiveSeconds="1800"
            overflowToDisk="false"
            diskPersistent="false"
            memoryStoreEvictionPolicy="LRU">
            
            <cacheEventListenerFactory class="com.blabla.config.CacheEventFactory" />
            
    </cache>

</ehcache>
  • maxElementsInMemory: 디스크가 아닌 인메모리를 사용하여 최대 저장할 수 있는 요소 수를 설정합니다.
  • memoryStoreEvictionPolicy: LRU 데이터 제거 알고리즘을 사용합니다.
  • cacheEventListenerFactory : 분산 서버 또는 필요에 의해 캐시에 대한 정보를 로그로 남기기 위한다면, 캐시 이벤트 리스너를 등록하여 로깅클래스를 생성해 정보를 확인할 수 있습니다.

 

@Configuration
@EnableCaching
public class EhcacheConfiguration {

}
@Slf4j
public class CacheEventLogger implements CacheEventListener<Object, Object> {

    @Override
    public void onEvent(CacheEvent<? extends Object, ? extends Object> cacheEvent) {
        log.info("cache event logger message. getKey: {} / getOldValue: {} / getNewValue:{}", cacheEvent.getKey(), cacheEvent.getOldValue(), cacheEvent.getNewValue());
    }
}

 

✔️ 캐싱 구현

**@Cacheable(value = "categoryFindService.findAll")**
@Transactional(readOnly = true)
public List<CategoryFindResultDto> findCategoriesUsingCache() {
    List<Category> categories = categoryRepository.findAll();
    return categories.stream()
            .map(CategoryFindResultDto::from)
            .toList();
}

저의 경우에는 조건이 필요없는 동일한 데이터셋를 조회하기 때문에 key설정이 필요 없었습니다.

하지만 여러 데이터셋을 조회하는 경우에는 아래와 같이 캐시의 키를 통해 다른 데이터셋을 가져올 수 있습니다.

 

**@Cacheable(value = "categoryFindService.findAll", 
						key = "#request.requestURI + '-' + #pageNo",
						condition = "#pageNo <= 5")**
@Transactional(readOnly = true)
public List<CategoryFindResultDto> findCategoriesUsingCache() {
    List<Category> categories = categoryRepository.findAll();
    return categories.stream()
            .map(CategoryFindResultDto::from)
            .toList();
}
  • key : 캐시를 구분하기 위한 용도입니다. SpEL과 메소드의 파라미터를 이용해 사용해 키를 동적으로 생성할 수 있습니다.
  • condition: 캐시가 적용되기 위한 조건을 지정합니다. SpEL 식이 true 일때만 캐시 처리가 적용됩니다.