현재 웹툰 리뷰 사이트를 토이 프로젝트로 배포중인데 데이터의 수정은 거의 이루어지지 않지만 자주 조회되는 기능으로 요일별 웹툰 조회 기능이 있습니다. 그리고 메인 페이지 요청 시 요일별 웹툰 요청 API가 전송되어 캐시 히트율이 높아지기 때문에 이 부분에 캐시를 적용하기로 결정했습니다.
또한 웹툰 자체에 대한 정보(썸네일 사진,줄거리,작가)등... 이러한 정보는 웬만해선 변하지 않을거라고 판단했습니다.


Global cache는 별도로 캐시 데이터를 저장하는 서버를 두는 방식(예:Redis)
장점
단점
Local cache는 서버 메모리 내에 데이터를 저장하므로 Global cache 보다 빠른 읽기 및 쓰기 성능 제공
장점
단점
따라서 로컬 캐시를 사용하게 되었습니다
설정 자료 및 Ehcache에 대해서는 이미 다른 블로그에 잘 정리가 되어있으니 간단하게 소개합니다
// CACHE
implementation group: 'net.sf.ehcache', name: 'ehcache', version: '2.10.6'
implementation 'org.springframework.boot:spring-boot-starter-cache'
@EnableCaching
@SpringBootApplication
public class TestApplication {
public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}
}
@Configuration
@EnableCaching
public class CacheConfiguration {
@Bean
@Primary
public CacheManager cacheManager(EhCacheManagerFactoryBean ehCacheManagerFactoryBean) {
return new EhCacheCacheManager(ehCacheManagerFactoryBean.getObject());
}
@Bean
public EhCacheManagerFactoryBean ehCacheManagerFactoryBean() {
EhCacheManagerFactoryBean ehCacheManagerFactoryBean = new EhCacheManagerFactoryBean();
ehCacheManagerFactoryBean.setConfigLocation(new ClassPathResource("ehcache.xml"));
ehCacheManagerFactoryBean.setShared(true);
return ehCacheManagerFactoryBean;
}
}
<?xml version="1.0" encoding="UTF-8"?>
<ehcache>
<!--특정 캐시에 대한 설정이 제공되지 않은 경우, 이 기본 설정이 사용됨-->
<defaultCache
maxElementsInMemory="1000"
maxElementsOnDisk="0"
eternal="false"
statistics="false"
timeToIdleSeconds="200"
timeToLiveSeconds="200"
overflowToDisk="false"
diskPersistent="false"
memoryStoreEvictionPolicy="LRU"/>
<cache
name="findWebtoonsByPublishDay"
maxElementsInMemory="10000"
maxElementsOnDisk="0"
eternal="false"
statistics="false"
timeToIdleSeconds="200"
timeToLiveSeconds="200"
overflowToDisk="false"
diskPersistent="false"
memoryStoreEvictionPolicy="LRU"/>
</ehcache>
maxElementsInMemory : 메모리에 저장할 수 있는 최대 요소 수
maxElementsOnDisk : 디스크에 저장할 수 있는 최대 요소 수 (메모리 한계에 도달 했을 때 디스크로 옮김) 하지만 사용 안하기에 0으로 설정
eternal : 캐시된 요소가 만료되지 않도록 설정
statistics : 캐시 통계 수집을 활성화할지 여부를 결정
- 캐시 통계 수집에 오버헤드가 완전히 없는 것은 아니지만, 통계 API는 사용량에 따라 자동으로 켜지거나 꺼집니다. 통계가 거의 필요하지 않으면 오버헤드가 거의 발생하지 않습니다. 반면에 더 많은 통계를 사용할수록 더 많은 비용이 발생할 수 있습니다. 통계는 기본적으로 꺼져 있습니다.
https://www.ehcache.org/generated/2.10.4/html/ehc-all/#page/Ehcache_Documentation_Set%2Fco-jmx_performance_considerations.html%23
timeToIdleSeconds : 요소가 마지막으로 접근된 후 제거되기까지의 시간(초) 이 시간 동안 요소에 접근이 없으면 요소는 만료
timeToLiveSecondsM(TTL) : 요소가 생성되고 나서 제거되기까지의 최대 시간(초). 이 시간을 초과하면 요소는 만료
overflowToDisk : 메모리 한계에 도달했을 때 요소를 디스크로 오버플로할지 여부를 결정
diskPersistent : 디스크에 저장된 캐시 데이터가 애플리케이션 재시작 후에도 유지될지 여부를 결정
memoryStoreEvictionPolicy : 메모리 저장소에서 요소를 제거하는 정책 (LRU 적용)
💡만약 캐시에 등록된 데이터가 DB에서 변동이 일어날 경우 어떻게 동기화 시키나요? TTL
💡만약 메모리가 가득 찼을경우 어떻게 교체가 이루어지나요? LRU
// ehcache.xml의 name="findWebtoonsByPublishDayCache"과 일치해야함
@Transactional(readOnly = true)
@Cacheable(value = "findWebtoonsByPublishDayCache", key = "#publishDay")
public List<WebtoonResponse> findWebtoonsByPublishDay(PublishDay publishDay) {
List<Webtoon> webtoons = webtoonRepository.findWebtoonsByPublishDay(publishDay);
return webtoons.stream()
.map(WebtoonResponse::new).collect(Collectors.toList());
}
여기서 제가 확인해 본 내용은 다음과 같습니다.
테스트용으로 ehcache.xml 구성 정보 변경
- 만료 시간(TTL) 200s -> 5s
- 테스트 용 캐시 구성
<cache
name="findWebtoonsByPublishDayCache"
maxElementsInMemory="10000"
maxElementsOnDisk="0"
eternal="false"
statistics="false"
timeToIdleSeconds="200"
timeToLiveSeconds="5"
overflowToDisk="false"
diskPersistent="false"
memoryStoreEvictionPolicy="LRU"/>
<cache
name="test"
maxElementsInMemory="10000"
maxElementsOnDisk="0"
eternal="false"
statistics="false"
timeToIdleSeconds="200"
timeToLiveSeconds="10"
overflowToDisk="false"
diskPersistent="false"
memoryStoreEvictionPolicy="LRU"/>
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1")
public class EhcacheController {
private final CacheManager cacheManager;
@GetMapping("/cache")
public Object findCache() {
Collection<String> cacheNames = cacheManager.getCacheNames();
Map<String, Object> cacheInfos = new HashMap<>();
for (String name : cacheNames) {
EhCacheCache cache = (EhCacheCache) cacheManager.getCache(name);
if (cache == null) {
continue;
}
Ehcache ehCache = cache.getNativeCache();
List<String> keysList = new ArrayList<>();
boolean isExpired = true;
for (Object key : ehCache.getKeys()) {
Element element = ehCache.get(key);
if (element != null) {
keysList.add(element.toString());
isExpired = element.isExpired();
}
}
Map<String, Object> cacheMap = new HashMap<>();
cacheMap.put("keys", keysList);
cacheMap.put("isExpired", isExpired);
cacheInfos.put(name, cacheMap);
}
return cacheInfos;
}
}
cacheManager를 통해 가져온 캐시 정보들을 Map 담아 리턴하도록 했습니다.

보시다시피 ehcache에 설정된 캐시 정보들이 나오지만 아직 캐시가 적용된 API를 호출하지 않았기 때문에 빈 값과 expired가 true임을 확인 할 수 있습니다.

findWebtoonsByPublishDay: 캐시된 데이터의 이름 (ehcache.xml)
keys : 해당 캐시 Key
value : 실제로 캐시된 값 Value
version : 캐시된 항목의 버전
(캐시된 데이터가 변경되었을 때 사용되는 값으로, 새로운 데이터가 캐시에 추가되면 버전이 증가됨)
hitCount : 캐시에서 항목에 대한 hit 횟수
CreateTime: 캐시 생성 시간
LastAccessTime: 캐시에 마지막으로 접근한 시간
isExpired: 캐시 expired 여부
등록된 캐시 정보를 확인할 수 있고 캐시 expired 가 아직 되지 않았으므로 false

부하테스트 툴인 ngrinder 와 모니터링 툴 scouter 를 이용해 환경을 구성했습니다
- 5천개의 데이터
- 가상 사용자(Vuser 500명)
- 1분 40초동안 테스트 실행
- 점진적으로 Vuser를 늘려가면서 부하를 늘림

@Test
public void test() {
long startTime = System.currentTimeMillis()
HTTPResponse response = request.GET("http://127.0.0.1:8080/api/v1/webtoon", params)
long endTime = System.currentTimeMillis()
long responseTime = endTime - startTime
if (response.statusCode == 301 || response.statusCode == 302) {
grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
}else if(responseTime > 600) {
grinder.logger.error("Error. The Rresponse time must be less than 600ms = {}",responseTime + "ms")
fail("Response time was greater than 300ms. (Actual response time: " + responseTime + "ms)")
}else {
assertThat(response.statusCode, is(200))
}
}
💡 용어 정리
- TPS : 트랜잭션이 초당 처리하는 평균량
- 최고TPS (Peak TPS) : 서버가 달성한 최대 TPS

초당 평균 트랜잭션은 247.4가 나왔고 최고 트랜잭션은 550이 나왔습니다. 그리고 총 4만개 가량의 테스트를 실행했지만 17000개의 에러가 발생했습니다.

초반 TPS의 지표는 증가와 감소가 반복하다가 36초부터 TPS가 급감하는 구간이 있는데, 이는 시스템의 자원에 한계에 도달해서 서버의 TPS가 점점 감소하는 것을 볼 수 있엇고 또한 48초부터는 지표가 서서히 감소하더니 결국 1분 30초 부분을 보면 서버가 완전히 가버린 모습을 확인 할 수 있었습니다
- 점진적으로 증가하는 Vuser 와 오류 그래프

48초 부분에서 약 Vuser의 수가 300명으로 되어있는데 위의 TPS표와 같이 보면 이 정도의 동시 접속수와 요청이 있을 경우 서버 장애가 발생한다는 것을 확인 할 수 있었습니다. 마찬가지로 오류 그래프도 48초부터 급격하게 증가하는 것을 확인 할 수 있습니다

초당 평균 트랜잭션은 2,960.1가 나왔고 최고 트랜잭션은 처리량은3,579 나왔습니다. 그리고 총 291,321 가량의 테스트를 실행했고 468개의 에러가 발생했습니다.

TPS 처리량이 캐시를 적용하기 전보다 감소량도 적고 테스트가 끝난뒤에도 여전히 서버가 살아있음을 확인 할 수 있습니다

Vuser가 늘아났음에도 캐시를 적용하기 전보다 오류가 적은걸 확인 할 수 있었습니다

+서버가 안죽음!
scouter를 이용

470 정도 된다.

3200 정도로 늘어났다.

~4.5초 까지 걸림

대부분 0.6초 안으로 걸림
캐시 사용 전 속도보다 확연히 빨라졌다!