Ehcache 를 이용한 SpringBoot 성능 개선 및 부하테스트 (ngrinder)

Minu·2024년 3월 5일

성능 개선

목록 보기
1/3

배경

현재 웹툰 리뷰 사이트를 토이 프로젝트로 배포중인데 데이터의 수정은 거의 이루어지지 않지만 자주 조회되는 기능으로 요일별 웹툰 조회 기능이 있습니다. 그리고 메인 페이지 요청 시 요일별 웹툰 요청 API가 전송되어 캐시 히트율이 높아지기 때문에 이 부분에 캐시를 적용하기로 결정했습니다.

또한 웹툰 자체에 대한 정보(썸네일 사진,줄거리,작가)등... 이러한 정보는 웬만해선 변하지 않을거라고 판단했습니다.


로컬 캐시를 선택한 이유

Global cache

Global cache는 별도로 캐시 데이터를 저장하는 서버를 두는 방식(예:Redis)

장점

  • 캐시 서버를 별도로 사용하기 때문에 네트워크를 통해 서버 간 데이터 공유가 쉽다

단점

  • 네트워크를 통해 캐시 데이터에 접근하므로 로컬 캐시보다 느림
  • 별도의 캐시 서버를 구성하기 위한 추가적인 비용 발생

Local cache

Local cache는 서버 메모리 내에 데이터를 저장하므로 Global cache 보다 빠른 읽기 및 쓰기 성능 제공

장점

  • 서버 메모리내에 저장되기 때문에 네트워크를 통하지 않아 매우 빠르다
  • 별도의 인프라(캐시 서버) 구성 비용 X

단점

  • 로컬 캐시는 각 서버 인스턴스에 개별적으로 저장되기 때문에 서버를 확장하게되면 각 인스턴스간의 캐시 데이터 동기화의 어려움

로컬 캐시로 선택한 이유

  1. 응답 시간 최적화
  2. 단순한 읽기 작업 & 웬만해선 변경되지 않는 웹툰 정보
  3. 별도의 인프라 비용 발생

따라서 로컬 캐시를 사용하게 되었습니다


Ehcache 사용 예시

설정 자료 및 Ehcache에 대해서는 이미 다른 블로그에 잘 정리가 되어있으니 간단하게 소개합니다

  • 요구사항
    - 월,화,수,목,금,토,일 요청에 따른 웹툰 목록 조회

개발 환경

  • java11
  • springboot 2.7.4
  • ehcache2

build.gradle

   // CACHE
    implementation group: 'net.sf.ehcache', name: 'ehcache', version: '2.10.6'
    implementation 'org.springframework.boot:spring-boot-starter-cache'

main.class (springboot 캐시 활성화)

@EnableCaching
@SpringBootApplication
public class TestApplication {
    public static void main(String[] args) {
        SpringApplication.run(TestApplication.class, args);
    }
}

config.class

@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;
    }
}

src/main/resoucres/ehcache.xml

<?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

  • TTL 을 200으로 설정하여 만료되도록 설정
  • 즉 데이터를 조회한 후, 캐시에 등록되고 200초 후 해당 캐시 데이터가 제거되어 다시 DB를 조회한다.

💡만약 메모리가 가득 찼을경우 어떻게 교체가 이루어지나요? LRU

  • LRU알고리즘을 이용하여 가장 오랫동안 사용되지 않은 데이터를 교체
    - 실제로 성능이 입증됐으며, 가장 많이 사용되는 알고리즘

service.class

// 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());
    }

결과

여기서 제가 확인해 본 내용은 다음과 같습니다.

  1. 캐시 설정한 부분이 적용이 되었는가 (캐시 정보, TTL 만료)
  2. 캐시 사용 전 / 후 속도 비교
  3. scouter 모니터링

1. 캐시 설정한 부분이 적용이 되었는가 (캐시 정보, TTL 만료)

테스트용으로 ehcache.xml 구성 정보 변경

  • 만료 시간(TTL) 200s -> 5s
  • 테스트 용 캐시 구성

ehcache.xml 수정

 <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 담아 리턴하도록 했습니다.

캐시 적용된 api 호출하기 전

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

캐시 적용된 api 호출 후

findWebtoonsByPublishDay: 캐시된 데이터의 이름 (ehcache.xml)

keys : 해당 캐시 Key

value : 실제로 캐시된 값 Value

version : 캐시된 항목의 버전
(캐시된 데이터가 변경되었을 때 사용되는 값으로, 새로운 데이터가 캐시에 추가되면 버전이 증가됨)

hitCount : 캐시에서 항목에 대한 hit 횟수

CreateTime: 캐시 생성 시간

LastAccessTime: 캐시에 마지막으로 접근한 시간

isExpired: 캐시 expired 여부

등록된 캐시 정보를 확인할 수 있고 캐시 expired 가 아직 되지 않았으므로 false

ehcache.xml 에서 설정한 TTL = 5s 적용되는지 확인

  • 캐시 적용된 api 호출 전 / 호출 후 / 5초 뒤
    호출전에는 캐시 정보가 없고, 호출 후에는 캐시 정보가 쌓이며 5초 뒤에는 캐시가 만료되어 사라지는것을 확인

2. 캐시 사용 전 / 후 부하 테스트

부하테스트 툴인 ngrinder 와 모니터링 툴 scouter 를 이용해 환경을 구성했습니다

  • 5천개의 데이터
  • 가상 사용자(Vuser 500명)
  • 1분 40초동안 테스트 실행
  • 점진적으로 Vuser를 늘려가면서 부하를 늘림

테스트 환경 구성 - DB

  • 5천개의 Webtoon 데이터

테스트 환경 구성 - ngrinder

  • VUser : 500명
  • 1분 40초동안 테스트 실행
  • 점진적으로 VUser를 늘려가면서 부하를 늘림

Test 스크립트 작성

  • 월,화,수,목,금,토,일 중 랜덤하게 요청하도록 스크립트 작성
  • 만약 응답이 600ms 이상일 경우 느린 응답속도로 인해 사용자 경험에 좋지 않다고 가정하여 error 처리
	@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))
		}
	}

캐시 적용 전 부하테스트 결과 - ngrinder

💡 용어 정리

  • TPS : 트랜잭션이 초당 처리하는 평균량
  • 최고TPS (Peak TPS) : 서버가 달성한 최대 TPS
  • 테스트 결과 사진

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

  • TPS가 시간에 따라 어떻게 변화하는지 보여주는 그래프

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

- 점진적으로 증가하는 Vuser 와 오류 그래프

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

캐시 적용 후 부하테스트 결과 - ngrinder

  • 테스트 결과 사진

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

  • TPS가 시간에 따라 어떻게 변화하는지 보여주는 그래프

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

  • 점진적으로 증가하는 Vuser 와 오류 그래프

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

캐시 적용 전/후 부하테스트 비교

+서버가 안죽음!


3. scouter 모니터링

scouter를 이용

캐시 사용 전 TPS


470 정도 된다.

캐시 사용 후 TPS


3200 정도로 늘어났다.

캐시 사용 전 응답 속도

~4.5초 까지 걸림

캐시 사용 후 응답 속도

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

0개의 댓글