이전 포스팅에서 톰캣 스레드 풀을 늘리기 전에, 병목 지점은 데이터베이스 커넥션을 획득하는 부분에 있다는 것을 파악했었습니다.
이번 포스팅에서는 데이터베이스 커넥션 풀의 크기를 조절과 캐시를 적용하는 각각의 과정을 담았습니다.
데이터베이스 커넥션 풀 크기 조절하기
HikariCP 설정 값 변경하기
커넥션 풀 크기를 조절하는 방법은 이미지처럼 `application.yml`에서 간단하게 설정할 수 있습니다.
hikariCP 설정 옵션에 대한 설명입니다.
- maximum-pool-size: 커넥션 풀의 최대 크기를 지정합니다. 커넥션 풀 크기만큼 커넥션이 담기면 idle 상태의 커넥션은 존재하지 않게 된다고 합니다.
- minimum-idle(default: same as maximumPoolSize): 커넥션 풀에 idle 상태로 있을 커넥션의 최소 개수를 지정합니다. 위에서 설명했듯이 서버에 부하가 많을 때 새 커넥션을 획득하는 것은 서버에 더 큰 부담으로 이어져 역효과를 일으킵니다.
따라서 HikariCP는 `최고의 성능`을 내기 위해선 `maximum-pool-size` 값과 동일한 값으로 설정해야 한다고 권장합니다.
(기본값은 maximum-pool-size으로 되어 있기 때문에 풀 크기가 증가하면 같이 증가합니다.)
참고로 이전 포스팅에서 설명했듯이 HikariCP 설정을 통해 최대 사이즈를 늘려도 그 개수만큼 즉시 증가시키지 않습니다. 커넥션을 열고 닫을 때 컴퓨팅 자원이 사용되기 때문에 HikariCP가 실제 사용량에 맞춰서 점진적으로 늘립니다.
만약 즉시 커넥션 풀에 커넥션들을 채워야 한다면 `minimum-idle` 값을 `maximum-pool-size` 값과 동일하게 설정해야 합니다.
커넥션 풀 크기: 5개로 설정
CPU 2개, 8GB 메모리 스펙의 서버에선 최적의 커넥션 풀 크기는 5(=2 * 2 + 1)입니다.
최적이라는 값으로 설정했을 때 응답 속도는 어떻게 됐을까요?
커넥션 풀 크기를 기본값(10개)에서 절반으로 줄이니 오히려 커넥션을 대기하는 요청 수가 `117개 => 189개`로 `61.5%(= (189 - 117) / 117)` 만큼 많아졌습니다.
이유를 알기 위해 커넥션 사용 시간과 커넥션 획득 시간을 봅시다.
이전 포스팅에서 설명했듯이, SSD를 사용할 땐 I/O 작업에 의한 스레드의 차단이 줄어들기 때문에 커넥션 풀 크기를 CPU 코어 개수와 근접하게 설정할수록 처리 속도가 더 빨라진다고 했습니다.
결과를 보니 이전 값 `50ms => 31ms` 정도로 38% 감소해서 실제로 더 빠른 처리하는 것을 확인할 수 있었습니다.
하지만, 커넥션 풀 크기가 줄은 만큼 커넥션 획득 시간은 `790ms => 1004 ms` 정도로 `26%` 늘어나서 전체 응답 시간은 더 느려졌습니다.
핀포인트로 가장 느린 응답을 한 트랜잭션을 볼까요?
가장 느린 응답 속도는 `2900ms => 3972ms`로 커넥션 풀 크기가 10개일 때보다 36% 느려진 것을 확인할 수 있었습니다.
따라서 커넥션 풀의 크기를 무작정 CPU 코어 개수에 맞춘다고 해결되는 것은 아닌 것으로 결론을 낼 수 있습니다.
커넥션 풀 크기: 20개로 설정
그렇다면 이번엔 커넥션 풀의 크기를 기본값에서 2배로 늘리면 어떻게 될까요?
위의 결과와 정 반대로 CPU 코어 개수보다 훨씬 많은 수의 연결은 시분할 시스템에 의해 스레드 처리가 더 느려질 것으로 예상할 수 있습니다.
한 마디로 커넥션 사용 시간은 늘어나지만, 커넥션 획득 시간을 줄어들 것입니다.
커넥션 풀 크기를 20으로 조정하니 10개일 때 보단 `117개 => 168개`로 `43.5%` 만큼 많아졌습니다.
5개일 때보단 20개가 `189개 => 168개`로 `11%`정도 줄어들었습니다.
확실히 부하가 많을 때는 커넥션 풀 크기를 늘리는 것이 조금 더 낫다고 볼 수 있겠네요.
커넥션 사용 시간은 기본값(10개) 일 때보다 20개일 때가 `50ms => 117ms`로 134% 느려졌습니다. 예상대로 시분할 시스템에 의해 스레드 수가 적을 때보다 많을 때가 더 느리다는 것을 명확하게 보여줍니다.
커넥션 획득 시간은 `790ms => 880ms`로 11% 느려졌습니다.
요약하면 사용 시간은 늘었지만 획득 시간은 커넥션 풀 크기가 5개일 때보단 20개일 때가 `1004ms => 880ms` 14% 빨라진 것을 알 수 있었습니다.
가장 느린 응답 속도는 `2900ms => 2748ms`로 커넥션 풀 크기가 10개일 때보다 `5%` 빨라진 것을 확인할 수 있습니다.
커넥션 풀 크기가 5개일 때보단 `3972ms => 2748ms`로 `30%` 빨라졌습니다.
결론적으로 커넥션 풀 크기를 2배로 늘렸을 때 가장 느린 응답 속도가 5% 빨라진 것을 봤을 때만 보면, 커넥션 풀 크기의 조절은 유의미한 결과를 냈다고 보기 어렵습니다.
캐시에 대해 알아보기
캐시를 적용하면 여러 번 조회할 때 매번 데이터베이스 커넥션을 기다릴 필요는 없습니다.
처음 요청은 캐시 미스로 인해 응답 속도가 느려도, 이후 반복되는 요청에는 메모리에 캐시 된 응답을 반환하기 때문에 데이터베이스 접근 자체를 줄이게 됩니다.
현재 시스템은 분산 서버 환경이 아닌, 단일 서버 환경이므로 로컬 캐시에 대해 알아보겠습니다.
캐시
스프링에서 기본으로 지원하는 여러 종류의 캐시들은 `Cache` 인터페이스의 구현체들인데요,
`Cache` 인터페이스를 통해 추상화가 잘 되어있기 때문에 구현 기술에 종속적이지 않다는 장점이 있습니다.
public interface Cache {
String getName();
@Nullable
ValueWrapper get(Object key);
void put(Object key, @Nullable Object value);
void evict(Object key);
void clear();
/**
* A (wrapper) object representing a cache value.
*/
@FunctionalInterface
interface ValueWrapper {
/**
* Return the actual value in the cache.
*/
@Nullable
Object get();
}
// ...
}
캐시 인터페이스에서 메서드 몇 개만 소개하겠습니다.
- `getName()`: 캐시의 이름을 가져옵니다.
- `get()`: `ValueWrapper` 클래스를 통해 캐시에서 `key`에 해당하는 `value`를 가져옵니다.
- `put()`: `key - value`로 캐시를 저장할 때 사용합니다.
- `evict()`: `key`를 통해 매핑되는 `특정 캐시`를 제거할 때 사용합니다.
- `clear()`: 캐시 된 모든 데이터들을 제거합니다.
그림으로 표현하면 캐시에는 `이름`이 있고 해당 이름의 캐시에는 `key-value` 구조로 저장됩니다.
예를 들어 유저 별로 특정 응답을 캐시해야 한다면, `key`에는 유저의 아이디(userId), `value`에는 캐시 할 데이터를 저장합니다.
그리고 이런 종류의 캐시에 `myCache`라는 이름을 붙여서 `Cache` 객체로 감싼 것입니다.
캐시 매니저
위의 캐시 객체를 다루는 캐시 매니저(CacheManager)에 대해서도 알아보겠습니다.
스프링에서 지원하는 캐시 매니저도 `CacheManager` 인터페이스로 제공을 하는데요,
구현 기술에 따라서 사용할 수 있는 CacheManager 구현체들이 여러 개가 있습니다.
대표적으로 `Caffeine` 캐시를 사용하는 `CaffeineCacheManager`, 여러 `CacheManager`를 혼용해서 사용할 수 있는 `CompositeCacheManager`가 있습니다.
`CacheManager` 인터페이스는 간단하게 `getCache()`, `getCacheNames()`로 되어있습니다.
`InMemory` 캐시를 사용하는 `CaffeineCacheManager`, `SimpleCacheManager`는 `ConcurrentHashMap`을 사용해서 구현되어 있습니다.
기본적으로 캐시를 저장하는 `ConcurrentHashMap`에는 최대 16개의 `Cache` 객체만 저장할 수 있으며 캐시를 찾을 때는 문자열로 된 캐시 이름으로 찾을 수 있습니다.
그림으로 보면 `CacheManager`는 여러 `Cache` 객체를 관리합니다.
`Cache`들은 `사용자 정보`를 캐시 하거나 `인기 뉴스`같은 데이터를 캐시 하는 다양하게 존재할 수 있는 거죠.
스프링에서 캐시가 동작하는 방식
스프링에서는 AOP가 적용된 `@Cacheable`, `@CachePut`, `@CacheEvict` 등 애너테이션을 이용해 선언적으로 캐싱 동작을 처리할 수 있는데요,
구체적으로 어떻게 동작하는지 간단하게 알아보겠습니다.
- `CacheAspectSupport`: `@Cacheable` 같은 애너테이션을 파싱하고, 애너테이션이 붙어있는 메서드의 처리를 담당합니다.
- `CacheInterceptor`: `CacheAspectSupport`의 자식 클래스로 `CacheAspectSupport`의 부모 클래스인 `AbstractCacheInvoker`에서 `get`, `put`, `evict` 등 호출을 담당합니다.
위 두 클래스에 의해 선언적으로 캐싱이 동작하는 과정은 아래와 같습니다.
- `@Cacheable` 같은 애너테이션이 붙은 메서드가 호출되면
- `CacheAspectSupport` 내부에서 `CacheManager(CacheResolver)`를 통해 `Cache`를 찾고
- `Cache`를 찾으면 key로 요청된 연산(get, put, evict)을 `CacheInterceptor`가 수행합니다.
(key에 SpEL을 파싱하는 부분은 `CacheAspectSupport`가 처리)
로컬 캐시 비교 (EhCache vs Caffeine Cache)
로컬 캐시로 사용할 수 있는 몇 가지 구현체들 중 EhCache와 Caffeine Cache를 알아보겠습니다.
- EhCache
- 직렬화된 데이터 객체를 저장하는 방식의 `In-Memory` 캐시 구현체입니다.
- OffHeap 공간(JVM GC가 적용되지 않는 공간)에 메모리 저장소를 사용할 수 있습니다. (ehcache3)
- 디스크 공간을 이용해 캐시를 저장할 수 있습니다. (ehcache3)
- Caffeine Cache
- Google에서 제공하는 오픈 소스 라이브러리인 Guava Cache와 ConcurrentHashMap을 개선한 ConcurrentLinkedHashMap을 바탕으로 개선된 `In-Memory` 캐시 구현체입니다.
- 고성능, 최적의 캐싱 라이브러리라고 소개합니다.
- 최적의 적중률을 제공하는 WIndow TunyLfu 제거 정책을 사용합니다. (더 자세한 내용은 https://github.com/ben-manes/caffeine/wiki/Efficiency 이곳에서 확인할 수 있습니다.)
아래 벤치마크 자료들은 MacBook Pro i7-4870HQ CPU @ 2.50GHz (4 core) 16 GB Yosemite 환경에서 실행한 자료입니다.
연산 능력 벤치마크
왼쪽 위: Compute / 오른쪽 위: READ 100%
왼쪽 아래: READ 75% WRITE 25% / 오른쪽 아래: WRITE 100%
Caffeine Cache 사용하기
벤치마크 자료를 보고 로컬 캐시들 중 성능이 가장 좋은 Caffeine Cache를 사용하기로 결정했습니다.
Caffeine Cache를 적용하기 위해 먼저 무엇을 캐싱할 것인지 정해야 합니다.
캐시 종류와 메타 정보
캐시 타입
public enum CacheType {
LOCAL,
GLOBAL,
}
캐시 종류는 현재 로컬 캐시만 사용할 예정입니다. 하지만 추후에 Redis 등을 이용한 글로벌 캐시 또는 로컬 캐시와 글로벌 캐시를 함께 사용할 수도 있으니 캐시 종류를 `enum`으로 관리합니다.
캐시 메타 정보
@Getter
@RequiredArgsConstructor
public enum CacheGroup {
DAILY_REPORT_STATUS("dailyReportStatus", Duration.ofSeconds(24 * 60 * 60), 10_000, CacheType.LOCAL),
WEEKLY_REPORT_STATUS("weeklyReportStatus", Duration.ofSeconds(7 * 24 * 60 * 60), 10_000, CacheType.LOCAL),
DAILY_REPORT("dailyReport", Duration.ofSeconds(24 * 60 * 60), 10_000, CacheType.LOCAL),
WEEKLY_REPORT("weeklyReport", Duration.ofSeconds(7 * 24 * 60 * 60), 10_000, CacheType.LOCAL),
;
private final String cacheName;
private final Duration expiredAfterWrite;
private final long maximumSize;
private final CacheType cacheType;
}
캐시를 적용할 데이터의 성격에 따라 캐시 그룹을 `enum`으로 관리합니다.
- `cacheName`: `CacheManager`가 `Cache`를 찾을 때 사용할 캐시의 이름을 정합니다.
- `expiredAfterWrite`: 캐시에 등록된 후부터 언제 삭제될지를 지정합니다.
- 캐시 무효화 관련 내용은 아래에서 다루겠습니다.
- `maximumSize`: 저장할 최대 캐시 엔트리 수를 지정합니다.
- `CacheType`: 캐시 종류를 지정합니다.
의존성 추가
스프링 프레임워크가 제공하는 `spring-boot-starter-cache`와 Caffein Cache 구현체를 제공하는 라이브러리 의존성을 추가합니다.
Configuration
`yml`로 간단하게 `Caffeine` 캐시를 설정할 수 있지만 캐시의 만료 시간, 캐시 엔트리의 수 등 개별적으로 관리할 수는 없습니다.
따라서 저는 별도의 설정 클래스를 작성했습니다.
- `caffeineCacheManager`: `CacheManager`를 `Bean`으로 등록합니다. 이때 `Cache`도 함께 등록합니다.
- `toCaffeineCache`: `CacheGroup`을 Caffeine Cache 구현체로 변환합니다. 캐시 만료 시간과 최대 크기, 캐시 히트 같은 통계 정보 등을 설정합니다.
SimpleCacheManager vs CaffeineCacheManager
캐시 매니저를 등록할 때 `SimpleCacheManager`, `CaffeineCacheManager` 둘 중에 어느 것을 사용해야 할지 고르기 위해 정리했습니다.
결론적으로 `CaffeineCacheManager`를 사용합니다.
- `SimpleCacheManager`:
- 스프링에서 제공하는 기본 캐시 매니저입니다. 특정 캐시에 종속되지 않기 때문에 캐시의 종류(직접 구현한 캐시, Caffeine, EhCache 등)에 대해 구체적으로 제공해야 합니다. 따라서 사용할 캐시를 직접 등록해야 합니다.
- `CaffeineCacheManager`:
- `Caffeine` 라이브러리 기능(시간 기반 만료, 크기 기반 제거 정책, Weak/Soft reperences 등)을 사용할 수 있습니다. 따라서 `Ceffeine` 라이브러리 의존성이 필요합니다.
- 이름 기반으로 캐시를 자동 생성할 수 있습니다. (`yml` 또는 `@Configuration`으로 생성)
- 고성능과 정책을 통한 제어가 필요할 때 사용합니다. (`weak keys와 soft value`, `lazy cleanup` 관련 아래에서 설명)
Weak keys와 Soft value
`Caffeine`의 대표적인 기능으로 `Weak keys` 와 `Soft value`를 사용할 수 있는데요,
- `Weak keys`는 키가 더 이상 강한 참조로 참조되지 않으면 JVM GC에 의해 캐시 값이 제거됩니다.
- `Soft value`는 메모리가 부족할 때 JVM GC에 의해 캐시 값이 제거됩니다.
이를 통해 고성능 캐싱 기능을 제공하는 것을 알 수 있습니다.
Lazy Cleanup (게으른 정리)
`Caffeine` 또 다른 대표적인 기능 중 하나가 `시간 기반 만료` 정책을 지원합니다.
이는 `CaffeineCachaManager`에 `Caffeine`을 등록할 때 `expireAfterWrite()`, `expireAfterAccess` 등으로 설정할 수 있는데요, 사용할 때 주의할 점이 있습니다.
`CaffeineCachaManager`은 만료 시간이 되었다고 해서 캐시를 바로 제거하지 않습니다. 만료된 항목을 바로 제거하려면 스레드가 주기적으로 스캔하고 제거해야 하는데, 이는 시스템의 자원을 많이 소모합니다.
따라서 `CaffeineCacheManager`는 시스템 자원을 효율적으로 사용하기 위해 `lazy cleanup` 방식으로 캐시를 제거하는데, 캐시에 접근하지 않는 데이터는 굳이 삭제하는 작업을 수행하지 않는 것입니다.
만약 즉시 제거가 필요하다면 `cleanUp()` 메서드 등을 사용하거나 별도의 작업이 필요합니다.
캐시 무효화 정책
데이터를 캐시 할 땐 언제까지 유효해야 하는지 정해야 합니다.
너무 길게 설정하면 시스템 성능에 영향을 주고, 너무 짧게 설정해서 캐시 히트율이 떨어지면 데이터베이스 부하가 증가합니다.
얼마나 자주 조회되는지 등 데이터 접근 패턴도 고려해야 합니다.
매일 캐시가 무효화돼야 하는 경우
이번에 새로 개발한 기능 중 데일리 리포트 기능은 `조회 일자`로부터 한 달까지가 유효한 데이터입니다.
그림에선 11월 20일 조회를 했다고 가정하면, 10월 20일까지가 유효한 데이터가 됩니다. 11월 21일 조회를 하면 10월 21일까지로 유효한 데이터 범위가 계속 이동합니다.
따라서 적어도 하루가 지날 때마다 `최근 한 달`이라는 기준이 계속 변하기 때문에 `매일 자정`이 되면 캐시를 무효화해야 합니다.
매주 캐시가 무효화돼야 하는 경우
위클리 리포트는 `조회 일자`가 포함된 주차부터 4주 차 전까지가 유효한 데이터입니다.
그리고 위클리 리포트는 1주일에 한 번만 생성할 수 있습니다.
그림에선 11월 18일 월요일에 위클리 리포트를 응답받았다고 가정하면, 해당 응답은 그 주 일요일까지 같은 응답이기 때문에 1주간 캐시가 돼야 합니다.
따라서 다음 주차가 되면 새로운 주차에 대한 응답으로 변해야 하기 때문에 적어도 `매주 월요일 자정`이 되면 캐시를 무효화해야 합니다.
무효화의 함정
캐시 무효화를 설정할 때 의도하지 않은 문제가 발생할 수 있습니다.
예를 들어 하루 단위로 캐시를 설정한다고 할 때 86,400초(=24 * 60 * 60)로 설정하면, 자정 직전(D일 23시 59분 59초)에 캐시한 데이터는 다음날 자정 직전(D+1일 23시 59분 59초)까지 캐시가 돼버리는 함정에 빠집니다.
따라서 이런 함정에 빠지지 않도록 별도의 스케쥴러를 통해 캐시를 무효화해 줄 필요가 있습니다.
캐시 무효화 적용
캐시 그룹에 기본 만료 시간 지정
`데일리 리포트 관련 API`는 24시간 * 60분 * 60초 = `86,400초`, `위클리 리포트 관련 API`는 7일 * 24시간 * 60분 * 60초 = `604,800초`로 캐시 무효화 시간을 지정했습니다.
캐시 무효화 스케쥴러
캐시 무효화 스케쥴러는 `@Schedule` 애너테이션으로 `cron` 표현식을 통해 작성했습니다.
- `매일 자정`에 `데일리 리포트 관련 API` 관련 캐시를 모두 무효화합니다.
- `매일 월요일 자정`에는 `위클리 리포트 관련 API` 관련 캐시를 모두 무효화합니다.
서비스에 캐시 적용하기
데일리 리포트 조회 API
메서드에 `@Cacheable` 애너테이션을 붙이는 것만으로 선언적인 캐시가 가능합니다.
- `cacheName`:
- 저장할 `Cache` 객체를 `CacheManager`가 찾을 때 사용하는 이름입니다. 캐시 이름이 중복되면 덮어 쓰이기 때문에 고유한 이름을 사용해야 합니다.
- `cacheManager`:
- 아무것도 지정하지 않을 시 스프링에서 `Bean`으로 등록된 `CacheManager` 중 가장 먼저 찾은 것을 주입합니다.
- 만약 여러 캐시 매니저를 사용한다면, 명시적으로 사용할 `CacheManager`를 지정하는 것이 좋습니다.
- `key`:
- 아무것도 지정하지 않으면 메서드의 모든 매개변수를 조합해서 `SimpleKey` 객체로 키를 생성합니다. 매개변수가 없으면 `SimpleKey.EMPTY`가 키로 사용됩니다.
- `Cache`의 `key`로 사용되는 유일한 값을 생성할 때 사용할 메서드의 인자로 고유한 `key`를 생성하는데, SpEL 표현식으로 사용할 수 있습니다.
- `unless`:
- SpEL 표현식을 사용해서 `조건이 참이면 캐싱하지 않는다`는 의미입니다. 반대로 `condition`은 `조건이 참이면 캐싱한다`라는 의미로 캐싱 작업을 시작하기 전에 조건을 평가합니다.
- `#result`는 메서드가 반환하는 값이며, 예시에선 해당 값이 `null`이면 캐싱을 하지 않는다는 조건입니다.
- 그 외 `p0`, `a0`는 메서드의 첫 번째 매개변수를 참조하고 `p1`, `a1`은 두 번째 매개변수를 참조합니다. `p`와 `a`는 동일하게 매개변수를 가리키지만 표준은 `a`를 사용합니다.
데일리 리포트 상태 조회 API
위클리 리포트 상태 조회 API
나머지 API도 선언적으로 캐시를 적용했습니다.
캐시 적용 테스트
이제 캐시를 적용한 테스트를 진행해 보겠습니다.
테스트 환경
- 스레드 풀 크기: 10 (기본값)
- 테스트 API에 대해 1회씩 요청한 후 수행 (캐시 적용)
- 그 외 기존 부하 테스트와 동일한 조건
부하 테스트 (커넥션 풀 10개)
(기존) 부하 테스트 결과
라벨 | 표본 수 | 평균 | 최소값 | 최대값 | 표준편차 | 오류 % | 처리량 |
생성 가능 일자 조회 (데일리 리포트) |
5000 | 351 | 17 | 500 | 48.13 | 96.340% | 423.51347 |
생성 가능 일자 조회 (위클리 리포트) |
5000 | 348 | 327 | 483 | 19.76 | 100.000% | 421.86973 |
데일리 리포트 조회 | 5000 | 359 | 327 | 579 | 40.92 | 100.000% | 427.31390 |
총계 |
15000 | 353 | 17 | 579 | 38.50 | 98.780% | 283.02955 |
(캐시 적용) 부하 테스트 결과
라벨 | 표본 수 | 평균 | 최소값 | 최대값 | 표준편차 | 오류 % | 처리량 |
생성 가능 일자 조회 (데일리 리포트) |
5000 | 59 | 8 | 217 | 32.81 | 0.000% | 489.09322 |
생성 가능 일자 조회 (위클리 리포트) |
5000 | 70 | 8 | 309 | 43.03 | 0.020% | 489.95590 |
데일리 리포트 조회 | 5000 | 41 | 7 | 246 | 29.51 | 0.000% | 492.22288 |
총계 | 15000 | 57 | 7 | 309 | 37.57 | 0.007% | 294.44095 |
JMeter 테스트 결과를 보면, 평균 응답 속도는 `353ms => 57ms`로 `83.8%` 빨라진 것을 확인할 수 있습니다.
더불어 처리량 각 API마다 대략 70 TPS가 증가했는데, 오류율도 `98.78% => 0.007%`로 목표치에 도달했습니다.
가장 느린 응답 속도는 `579ms => 309ms`로 `46.6%` 빨라졌습니다.
핀포인트를 통해 확인한 결과, 가장 느린 응답 속도는 `3200ms => 176ms`로 `94.5%` 빨라진 것을 확인했습니다.
트랜잭션 세부 내역에서 데이터베이스 커넥션이 없는 것을 확인할 수 있었으며, 실제 캐시가 적용되어 응답 속도가 `25ms`로 굉장히 빠른 것을 볼 수 있습니다.
스파이크 테스트 (커넥션 풀 10개)
(기존) 부하 테스트 결과
라벨 | 표본 수 | 평균 | 최소값 | 최대값 | 표준편차 | 오류 % | 처리량 |
생성 가능 일자 조회 (데일리 리포트) |
1000 | 1605 | 51 | 2719 | 849.00 | 57.600% | 283.28612 |
생성 가능 일자 조회 (위클리 리포트) |
1000 | 1121 | 0 | 2148 | 971.76 | 94.000% | 180.31013 |
데일리 리포트 조회 | 1000 | 1467 | 0 | 2150 | 897.93 | 98.900% | 132.81976 |
총계 | 3000 | 1397 | 0 | 2719 | 930.17 | 83.500% | 393.28789 |
(캐시 적용) 부하 테스트 결과
라벨 | 표본 수 | 평균 | 최소값 | 최대값 | 표준편차 | 오류 % | 처리량 |
생성 가능 일자 조회 (데일리 리포트) |
1000 | 1253 | 46 | 2648 | 603.80 | 6.500% | 290.78220 |
생성 가능 일자 조회 (위클리 리포트) |
1000 | 724 | 0 | 1705 | 422.24 | 18.900% | 243.30900 |
데일리 리포트 조회 | 1000 | 649 | 0 | 1716 | 368.04 | 18.800% | 233.04591 |
총계 | 3000 | 875 | 0 | 2648 | 546.10 | 14.733% | 682.74920 |
JMeter 테스트에서 평균 응답 속도는 `1397ms => 875ms`로 `37.3%` 빨라진 것을 확인할 수 있습니다. 가장 느린 응답 속도는 `2719ms => 2648ms`로 유의미한 효과는 보기 힘들었습니다.
눈에 띄는 점은 2개의 API에서 스파이크 트래픽 상황에서 목표 응답 속도인 `2000ms`보다 빠른 `1700ms`를 달성했다는 점, 처리량이 각 API마다 많게는 100 TPS가 증가했다는 점입니다.
핀포인트를 통해 확인한 가장 느린 응답 속도로 집계된 트랜잭션의 세부 내역을 보면, 데이터베이스에 더 이상 접근하지 않기 때문에 커넥션 대기 시간이 사라졌지만 HTTP 요청 자체를 처리하는데 시간이 오래 걸린 것을 보아 톰캣 스레드 풀의 문제로 보입니다.
정리
- OS의 시분할 시스템에 의해 SSD를 사용할 땐 CPU 코어 개수가 적을 때 데이터베이스 커넥션 풀을 줄이면 처리 속도는 더 빨라집니다.
하지만 트래픽 부하가 있는 상황에선 커넥션 풀을 늘리는 것이 줄이는 것보다 나을 수 있습니다. - 캐시를 적용해 데이터베이스 접근을 줄이면 트래픽 부하 상황을 개선할 수 있습니다.
- 다만, 캐시 무효화 정책을 잘 설정해야 하고, 캐시 적용에 많은 자원이 사용되지 않도록 해야 합니다.
해결 과정에 초점을 맞추다 보니 글이 길어져버렸네요..
그래도 긴 글 읽어주셔서 감사합니다.
참고
- https://freedeveloper.tistory.com/250
- https://erjuer.tistory.com/127
- https://gngsn.tistory.com/158
- https://keun.me/java-caffeine/#%EC%9E%90%EB%B0%94%EC%97%90%EC%84%9C-%EC%82%AC%EC%9A%A9-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%BA%90%EC%8B%9C-%EC%A2%85%EB%A5%98
- https://mangkyu.tistory.com/370
- https://stackoverflow.com/questions/57617581/caffeinecachemanager-vs-simplecachemanager
'Project' 카테고리의 다른 글
[포텐데이 409-1pick] 신규 분석 기능 성능 테스트 (2) (0) | 2024.12.06 |
---|---|
[포텐데이 409-1pick] 신규 분석 기능 성능 테스트 (1) (1) | 2024.12.05 |
[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (5) (0) | 2024.11.28 |
올려 올려 라디오 NCloud 활용 후기 (2) | 2024.11.27 |
[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (4) (2) | 2024.11.27 |