p99 60초에서 106ms로(Bookvoyage)

HikariCP 병목 제거와 리소스 격리를 통한 장애 해소 사례

#bookvoyage#gatling#latency

테스트 일시: 2026-01-23 (개선 전) → 2026-01-24 (개선 후)
시뮬레이션: FullExperimentSimulation (4단계: Baseline → Batch → Spike → Cooldown)
테스트 도구: Gatling 3.11.5
테스트 시간: 각 45분 이상


0. 테스트 시나리오 설명

0.1 FullExperimentSimulation - 4단계 부하 시뮬레이션

실제 서비스 운영 환경을 재현하기 위해 4개 단계로 구성된 시뮬레이션을 사용했다. 단순히 일정 부하를 주는 것이 아니라, API 트래픽 위에 배치 작업이 겹치는 상황트래픽 급증 상황까지 포함하여 시스템의 한계를 종합적으로 검증한다.

부하 │ ┌─────────┐ │ │ Spike │ │ ┌─────────────────────┤ (2배) │ │ │ Batch ON │ │ │ ┌─────┤ (동일 부하 유지) │ ├───────┐ │ │Base │ │ │Cooldown│ │ │line │ │ │ │ ──┴─────┴─────────────────────┴──────────┴────────┴──→ 시간 0 10분 25분 30분 35분+
단계시간설명
Phase 1: Baseline0~10분사용자 트래픽만 투입. 배치 없이 순수 API 성능의 기준선을 측정한다.
Phase 2: Batch ON10~25분동일 부하를 유지하면서 Neo4j/Elasticsearch 배치 동기화를 트리거한다. 배치가 API 응답에 미치는 영향을 측정한다.
Phase 3: Spike25~30분사용자 부하를 2배로 급증시킨다. 배치 실행 중 트래픽 스파이크가 발생했을 때 시스템이 버티는지 확인한다.
Phase 4: Cooldown30~35분부하를 중단하고 시스템이 정상 상태로 회복되는지 확인한다.

0.2 사용자 구성 - 3가지 시나리오 동시 실행

FullExperimentSimulation은 아래 3가지 시나리오를 동시에 주입하여 실제 트래픽 패턴을 모사한다.

(1) AuthenticatedUserScenario - 인증 사용자 트래픽 (메인 부하)

CSV에 등록된 19개 테스트 계정을 circular 방식으로 재사용하며, 일반 사용자(80%)고빈도 사용자(20%) 로 나뉜다.

로그인 후 아래 API를 가중치 기반으로 반복 호출한다:

액션비율API설명
추천 조회50%GET /api/recommendations/books 또는 /reviews도서/리뷰 추천을 50:50으로 랜덤 호출
리뷰 상세35%GET /api/reviews/{id}1~100 범위의 리뷰 ID를 랜덤 조회
사용자 프로필15%GET /api/users/me내 프로필 조회

일반 사용자(Normal User) 는 각 액션 사이에 3~8초의 Think Time을 두어 실제 사용자의 페이지 탐색 패턴을 재현한다. 고빈도 사용자(Heavy User) 는 Think Time을 1~3초로 짧게 설정하여 헤비 유저나 크롤러 수준의 집중적인 요청 패턴을 시뮬레이션한다.

Search Books API는 외부 API(카카오/구글) Rate Limit 문제로 이번 시뮬레이션에서는 비활성화했다.

(2) LoginLoadScenario - 로그인 부하 (백그라운드)

전체 사용자의 약 2% 비율로, 테스트 전 기간에 걸쳐 2초에 1건씩 로그인 관련 요청을 발생시킨다. 세 가지 패턴을 가중치로 혼합한다:

패턴비율동작
단순 로그인70%Fresh Login → 30~60초 대기
로그인 + 토큰 갱신20%Fresh Login → Refresh Token → 30~60초 대기
로그인 + 로그아웃10%Fresh Login → Logout → 30~60초 대기

이를 통해 JWT 발급, 쿠키 생성, 토큰 갱신, 로그아웃 처리 등 인증 관련 엔드포인트의 지속적인 부하가 메인 트래픽과 함께 발생하는 상황을 재현한다.

(3) BatchTriggerScenario - 배치 동기화 트리거

Phase 1(Baseline) 종료 직후, 단 1명의 가상 사용자가 배치 동기화 API를 순차 호출한다.

  1. POST /api/admin/batch/sync-neo4j - Neo4j 그래프 동기화 트리거
  2. 5초 대기
  3. POST /api/admin/batch/sync-elasticsearch - Elasticsearch 인덱스 동기화 트리거
  4. 300초(5분) 대기 - 배치 완료 대기

배치 API는 permitAll로 인증 없이 접근 가능하다.(테스트 한정, 실제로는 배치 스케줄로 돌아감) 409(Conflict) 응답은 이미 실행 중인 배치가 있음을 의미하며 정상 처리된다. 이 시나리오의 핵심은 배치 요청 자체가 아니라, 배치가 실행되는 동안 DB 커넥션/CPU/메모리를 점유하면서 API 응답에 미치는 영향을 관찰하는 것이다.

0.3 부하 주입 프로파일 상세

기본 사용자 수(baseUsers) 50명 기준의 실제 주입 패턴

Normal User (80%): Phase 1: rampUsers(40) / 2분 → constantUsersPerSec(0) / 8분 Phase 2: constantUsersPerSec(0) / 15분 Phase 3: rampUsers(40) / 1분 → constantUsersPerSec(1) / 4분 Phase 4: nothingFor(5분) Heavy User (20%): Phase 1: rampUsers(10) / 2분 → constantUsersPerSec(0) / 8분 Phase 2: constantUsersPerSec(0) / 15분 Phase 3: rampUsers(10) / 1분 → constantUsersPerSec(0) / 4분 Phase 4: nothingFor(5분) Login Load: rampUsers(1) / 2분 → constantUsersPerSec(0.5) / 33분 Batch Trigger: nothingFor(10분) → atOnceUsers(1)

0.4 Assertion 기준

조건기준값의미
global.successfulRequests.percent > 95%전체 성공률 95% 이상배치+스파이크 상황에서도 기본 안정성 보장
global.responseTime.p95 < 1,500ms전체 p95 응답시간95%의 요청이 1.5초 이내 응답
global.responseTime.p99 < 5,000ms전체 p99 응답시간99%의 요청이 5초 이내 응답
details("Get User Profile").p95 < 300ms프로필 조회 p95경량 엔드포인트의 별도 SLA 기준

1. 핵심 요약

지표개선 전개선 후변화
총 요청 수44,59659,083+32.5%
실패(KO) 건수2,009 (4.50%)0 (0%)-100%
평균 응답시간3,054 ms30 ms-99.0%
p50 (중앙값)35 ms13 ms-62.9%
p7572 ms59 ms-18.1%
p9532,968 ms85 ms-99.7%
p9960,002 ms106 ms-99.8%
최대 응답시간60,003 ms431 ms-99.3%
처리량 (RPS)16.26 req/s21.59 req/s+32.8%

2. 응답시간 분포 비교

개선 전

■■■■■■■■■■■■■■■■■■■■■■■■■■ 65.49% t < 50ms (29,204건) ■■■■■■■■■■■ 27.63% 50~100ms (12,320건) ■ 2.38% t >= 100ms (1,063건) ■■ 4.50% failed (2,009건)

개선 후

■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 74.18% t < 50ms (43,829건) ■■■■■■■■■■ 23.64% 50~100ms (13,969건) ■ 2.17% t >= 100ms (1,285건) 0.00% failed (0건)

50ms 이하 응답 비율이 65.49% → 74.18% 로 향상되었고, 실패 건수는 2,009건에서 0건으로 완전 제거되었다.

개선 전 Gatling 리포트 개요개선 전 Gatling 리포트 개요

개선 후 Gatling 리포트 개요개선 후 Gatling 리포트 개요


3. 엔드포인트별 상세 비교

3.1 Fresh Login

지표개선 전개선 후변화
요청 수12,52713,708+9.4%
KO600 (4.79%)0 (0%)-100%
평균 응답1,409 ms78 ms-94.5%
p95-105 ms-
p99-110 ms-
최대59,819 ms270 ms-99.5%

3.2 Get Review Detail

지표개선 전개선 후변화
요청 수5,71314,485+153.5%
KO454 (7.95%)0 (0%)-100%
평균 응답146 ms11 ms-92.5%
p95-25 ms-
p99-36 ms-
최대59,471 ms196 ms-99.7%

3.3 Get Review Recommendations

지표개선 전개선 후변화
요청 수4,13310,154+145.6%
KO170 (4.11%)0 (0%)-100%
평균 응답605 ms34 ms-94.4%
p95-65 ms-
p99-96 ms-
최대59,575 ms431 ms-99.3%

3.4 Get Book Recommendations

지표개선 전개선 후변화
요청 수4,13010,110+144.8%
KO151 (3.66%)0 (0%)-100%
평균 응답645 ms11 ms-98.3%
p95-25 ms-
p99-38 ms-
최대59,231 ms208 ms-99.6%

3.5 Get User Profile

지표개선 전개선 후변화
요청 수2,7966,196+121.6%
KO93 (3.33%)0 (0%)-100%
평균 응답581 ms3 ms-99.5%
p95-8 ms-
p99-12 ms-
최대58,703 ms59 ms-99.9%

3.6 Refresh Token

지표개선 전개선 후변화
요청 수2,5232,737+8.5%
KO97 (3.84%)0 (0%)-100%
평균 응답1,152 ms5 ms-99.6%
최대59,600 ms24 ms-100.0%

3.7 Logout

지표개선 전개선 후변화
요청 수1,2131,351+11.4%
KO00-
평균 응답1,842 ms3 ms-99.8%
최대47,475 ms27 ms-99.9%

3.8 Search Books

지표개선 전개선 후변화
요청 수11,219-(개선 후 시나리오에서 제외)
KO444 (3.96%)--
평균 응답657 ms--

Search Books는 외부 API를 사용하는데, 하루 할당량을 전부 사용하여.. 개선 후 시나리오에서 당일 검증 불가했다.


4. 근본 원인 분석 및 적용한 개선 사항

4.1 병목 원인: HikariCP 커넥션 풀 고갈

개선 전 테스트에서 확인된 핵심 로그

HikariPool-1 - Connection is not available, request timed out after 30001ms (total=10, active=10, idle=0, waiting=131)

커넥션 풀 크기가 10으로 설정되어 있었고, 동시 부하 시 131개 이상의 요청이 대기 상태에 빠지면서 30초 타임아웃 → 60초 Gatling 타임아웃 연쇄 장애가 발생했다.

4.2 적용한 개선 사항

(1) HikariCP 커넥션 풀 확장

# 개선 전: 기본값 10 # 개선 후: spring.datasource.hikari: maximum-pool-size: 30 # 10 → 30 (3배 확장) minimum-idle: 10 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000

(2) Outbox 전용 데이터소스 분리

배치 동기화(Neo4j Sync, ES Sync)에서 사용하는 Outbox 쓰기 작업을 별도 커넥션 풀(5개) 로 분리하여, API 읽기 트래픽과의 커넥션 경합을 제거했다.

application.yml - Primary와 Outbox 풀 분리

# Primary: API 읽기 트래픽 전용 spring.datasource.hikari: maximum-pool-size: ${DB_POOL_SIZE:30} minimum-idle: ${DB_MIN_IDLE:10} pool-name: PrimaryHikariPool # Outbox: 배치 쓰기 전용 (별도 풀) outbox.datasource.hikari: maximum-pool-size: ${OUTBOX_DB_POOL_SIZE:5} minimum-idle: ${OUTBOX_DB_MIN_IDLE:2}

OutboxDataSourceConfig.java - 별도 EntityManager/TransactionManager 구성

@Configuration @EnableJpaRepositories( basePackages = "org.yyubin.infrastructure.persistence.outbox", entityManagerFactoryRef = "outboxEntityManagerFactory", transactionManagerRef = "outboxTransactionManager" ) public class OutboxDataSourceConfig { @Bean @ConfigurationProperties("outbox.datasource") public DataSourceProperties outboxDataSourceProperties() { return new DataSourceProperties(); } @Bean @ConfigurationProperties("outbox.datasource.hikari") public DataSource outboxDataSource( @Qualifier("outboxDataSourceProperties") DataSourceProperties properties) { return properties.initializeDataSourceBuilder() .type(HikariDataSource.class) .build(); } @Bean public LocalContainerEntityManagerFactoryBean outboxEntityManagerFactory( EntityManagerFactoryBuilder builder, @Qualifier("outboxDataSource") DataSource dataSource) { return builder.dataSource(dataSource) .packages("org.yyubin.infrastructure.persistence.outbox") .persistenceUnit("outbox") .build(); } @Bean public PlatformTransactionManager outboxTransactionManager( @Qualifier("outboxEntityManagerFactory") EntityManagerFactory entityManagerFactory) { return new JpaTransactionManager(entityManagerFactory); } }

Outbox 패키지에 속한 리포지토리만 outboxDataSource를 사용하므로, 배치 동기화가 아무리 많은 쓰기를 수행해도 Primary 풀의 30개 커넥션은 API 트래픽 전용으로 유지된다.

(3) 도서 검색 Redis 캐싱

외부 API(카카오/구글 Books) 호출 결과를 Redis에 1시간 TTL로 캐싱하여

  • 반복 검색의 외부 API 호출 제거
  • 카카오 API 429(Rate Limit) 에러 방지
  • DB 커넥션 점유 시간 단축

CompositeBookSearchAdapter.java:

@Component @Primary public class CompositeBookSearchAdapter implements ExternalBookSearchPort { private static final String CACHE_KEY_PREFIX = "book:search:"; private static final Duration CACHE_TTL = Duration.ofHours(1); @Override public ExternalBookSearchResult search(SearchBooksQuery query) { String cacheKey = buildCacheKey(query); // 1. 캐시 조회 ExternalBookSearchResult cached = getFromCache(cacheKey); if (cached != null) { return cached; } // 2. 외부 API 호출 (카카오 → Google fallback) ExternalBookSearchResult result = searchFromExternalApis(query); // 3. 결과 캐싱 if (result.items() != null && !result.items().isEmpty()) { saveToCache(cacheKey, result); } return result; } private ExternalBookSearchResult searchFromExternalApis(SearchBooksQuery query) { try { ExternalBookSearchResult kakaoResult = kakaoAdapter.search(query); if (kakaoResult.items() != null && !kakaoResult.items().isEmpty()) { return kakaoResult; } } catch (Exception e) { log.warn("Kakao API failed, falling back to Google Books: {}", e.getMessage()); } // Google Books API로 fallback return googleAdapter.search(query); } private String buildCacheKey(SearchBooksQuery query) { return CACHE_KEY_PREFIX + query.keyword().toLowerCase().trim() + ":" + (query.startIndex() != null ? query.startIndex() : 0) + ":" + (query.size() != null ? query.size() : 10); } }

캐시 키는 book:search:{keyword}:{startIndex}:{size} 형태로, 동일 검색 조건에 대해 1시간 동안 외부 API를 재호출하지 않는다.

(4) 외부 API 타임아웃 설정

외부 API 지연이 내부 커넥션 풀을 장시간 점유하는 것을 방지하기 위해, 카카오/구글 클라이언트 모두 동일한 타임아웃을 적용했다.

KakaoBooksClient.java / GoogleBooksClient.java (동일 패턴):

@Component public class KakaoBooksClient { private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3); private static final Duration READ_TIMEOUT = Duration.ofSeconds(5); public KakaoBooksClient(KakaoBooksProperties properties) { HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(CONNECT_TIMEOUT) .build(); JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); requestFactory.setReadTimeout(READ_TIMEOUT); this.restClient = RestClient.builder() .baseUrl(properties.getBaseUrl()) .requestFactory(requestFactory) .build(); } }

타임아웃 없이 외부 API가 30초 이상 응답하지 않으면 해당 스레드가 DB 커넥션을 물고 있는 채로 블로킹되어 풀 고갈을 가속화했다. Connect 3초 + Read 5초로 최대 8초 이내에 실패 처리되도록 보장하고, CompositeBookSearchAdapter의 fallback 로직과 결합하여 한쪽 API 장애가 전체 검색 실패로 이어지지 않도록 했다.

(5) 커스텀 예외 처리 추가

존재하지 않는 리뷰 조회나 권한 없는 접근 시 500 에러 대신 명확한 HTTP 상태 코드를 반환하도록 개선했다.

ReviewExceptionHandler.java:

@RestControllerAdvice public class ReviewExceptionHandler { @ExceptionHandler(ReviewNotFoundException.class) public ResponseEntity<Void> handleReviewNotFound(ReviewNotFoundException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } @ExceptionHandler(ReviewAccessDeniedException.class) public ResponseEntity<Void> handleReviewAccessDenied(ReviewAccessDeniedException ex) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } }
// application/review/exception/ReviewNotFoundException.java public class ReviewNotFoundException extends RuntimeException { public ReviewNotFoundException(String message) { super(message); } } // application/review/exception/ReviewAccessDeniedException.java public class ReviewAccessDeniedException extends RuntimeException { public ReviewAccessDeniedException(String message) { super(message); } }

개선 전에는 이 예외들이 @ExceptionHandler 없이 Spring 기본 처리에 의해 500으로 응답되었고, Gatling에서 KO로 집계되어 실패율을 높이는 원인이 되었다. 특히 Get Review Detail의 KO 비율이 7.95%로 전체 엔드포인트 중 가장 높았는데, 랜덤 리뷰 ID 조회 시 존재하지 않는 리뷰에 대해 500이 아닌 404를 반환하면서 정상 응답으로 처리되도록 했다.


5. 개선 효과 정리

개선 전 Gatling Assertion 리포트개선 전 Gatling Assertion 리포트

개선 후 Gatling Assertion 리포트개선 후 Gatling Assertion 리포트

개선 전 Gatling Latency 리포트개선 전 Gatling Latency 리포트

개선 후 Gatling Latency 리포트개선 후 Gatling Latency 리포트

개선 전 개선 후 ────── ────── 실패율 ██████ 4.50% 0% ← 완전 제거 평균 응답 ████████████████ 3,054ms █ 30ms ← 99% 감소 p99 ████████████████████ 60,002ms █ 106ms ← 99.8% 감소 처리량 ████████ 16.26 rps ██████████ 21.59 rps ← 33% 증가 최대 응답 ████████████████████ 60,003ms █ 431ms ← 99.3% 감소

핵심 성과

  1. 장애 완전 제거: KO 2,009건 → 0건 (실패율 4.50% → 0%)
  2. Tail Latency 극적 감소: p99 60초 → 106ms (타임아웃 완전 해소)
  3. 처리량 33% 향상: 16.26 → 21.59 req/s (같은 시간 내 32.5% 더 많은 요청 처리)
  4. 응답 안정성 확보: 최대 응답시간 60초 → 431ms (표준편차 12,248 → 30)

6. Assertion 결과

조건기준개선 전개선 후
성공률 > 95%Global95.50% (경계)100%
p95 < 1,500msGlobal32,968ms FAIL85ms PASS
p99 < 5,000msGlobal60,002ms FAIL106ms PASS
User Profile p95 < 300msEndpoint-8ms PASS

개선 전에는 p95, p99 assertion이 모두 실패했으나, 개선 후에는 모든 assertion을 통과했다.


7. 결론

외부 API 지연 + 배치 쓰기 + 커넥션 풀 10개 제한 → 스레드 블로킹 → 커넥션 점유 → 대기열 폭증 → 30초 타임아웃 → 60초 테스트 타임아웃의 연쇄적 문제가 주된 문제이긴 했다.
이번 경험을 통해 배울 수 있었던 것은 다음과 같다.

  1. 커넥션 풀은 단순 숫자가 아니라 동시성 제어 장치다
  2. 외부 API 지연은 내부 리소스 고갈로 전이된다
  3. 배치와 API는 반드시 리소스를 격리해야 한다
  4. Tail Latency는 우연이 아니라 구조의 결과다
  5. 부하 테스트는 기능 검증이 아니라 시스템 설계 검증 도구다

실제 수치 및 프로파일링 정보가 필요하다면 https://github.com/yyubin/bookvoyage/tree/main/performance-test/reports/profiling 로 이동하세요. 실제 stats.json과 마지막 테스트에 대한 jfr, async-profiler의 플레임 그래프를 첨부했습니다. 해당 글에서는 첨부하기 어려운(용량이슈) gatling 리포트를 주요 산출물로 작성하였습니다.