프로젝트의 규모나 성격, 도메인에 따라 사용할 수 있는 캐싱 전략은 다양합니다.
카테고리를 전체 조회하는 저의 서비스의 경우, 데이터가 자주 조회됨/변경이 자주 일어나지 않음/용량이 크지 않음 이라는 특성을 가지고 있기 때문에 캐싱을 사용하기로 하였습니다.
또한 단일 서버를 사용하고 있으므로 로컬 인메모리로 캐싱하는 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 일때만 캐시 처리가 적용됩니다.
'프로젝트 Project' 카테고리의 다른 글
static 내부 클래스로 DTO 관리하기 (0) | 2024.08.06 |
---|---|
Spring Security + JWT로 로그인 구현하기 (1) (0) | 2024.08.05 |
멀티 모듈로 프로젝트 구성하기 (0) | 2024.08.05 |
쿼리 튜닝하기 (0) | 2024.08.02 |
인덱스 적용하기 (0) | 2024.08.02 |