[Sansam E-commerce] 7. MVP 이후 리팩토링 #5 : HikariCP 튜닝
- -
👀 들어가기 전에
앞선 글에서 언급했듯이 지금 부터는 계속해서 검증하고 확인하는 것을 중점적으로 보았다.
실제 트래픽이 몰렸을때 어느 지점에서 왜 느려지는지를 수치와 흐름으로 설명할 수 있어야 한다고 생각했다.
처음에는 매 요청마다 로그를 찍으며 시간을 재봤지만, 이 방식은 반복 테스트 환경에서는 너무 번거롭다는 문제가 있었다.
그러다 Jaeger라는 툴을 알게 되었고 기존에 사용하던 Prometheus + Grafana + K6 + InfluxDB 부하테스트 스택에 분산 트레이싱(Jaeger)을 결합해 병목 지점을 추적해보기로 했다.
👀 본론
Prometheus + Grafana + K6 + InfluxDB 기반 부하 테스트 그리고 병목 분석을 위한 Jaeger 도입
🔭 테스트 환경
- 실행 환경 : Docker 위에서 JAR 실행
- 테스트 방식
- 피크 테스트 이전, 스모크 테스트 선행
- 급격한 부하로 인한 왜곡 방지
- 시나리오 (E2E)
- 주문 요청 → 1s sleep → 결제 요청 → Mock API 호출 → 1s sleep → 응답
의도적으로 sleep 구간을 넣어 네트워크 및 외부 I/O 상황을 흉내내려고 하였다.
따라서 전체 응답 시간과 순수 비즈니스 로직 처리 시간은 구분해서 해석해야 한다.
스모크 테스트 중 Jaeger에서 발견한 병목 (feat. DB Indexing & 멱등성 보장)
결제 요청은 멱등성 보장이 필수다.
같은 paymentKey로 요청이 들어오면, 기존 결과를 그대로 반환해야 한다.
Payments existing = paymentsRepository.findByPaymentKey(normalizePayment.paymentKey())
.orElse(null);
if (existing != null)
return paymentMapper.toTossPaymentResponse(existing);

Jaeger Trace를 통해 전체 요청 흐름을 시각화해보니, paymentKey 조회 구간이 상대적으로 가장 긴 지연 구간으로 관측되었다.
(OrderNumber로 order를 조회하는 것과 같은 맥락이었다.)
- 절대적인 쿼리 비용은 크지 않음
- 그러나 모든 결제 요청마다 반드시 수행
- 동시 요청 환경에서는 누적 비용이 병목으로 확대될 가능성
이 시점에서 들었던 생각은 단순했다.
절대적인 쿼리 비용은 크지 않지만, 동시 요청 환경에서 반복적으로 호출되며 병목지점으로 작용할 수 있겠다.
이에 OrderNumber를 Indexing하였던 것과 동일하게 PaymentKey에도 Indexing을 적용했습니다.
VUser 500명 피크 테스트 (동시 주문 → 결제)
500명의 사용자가 동시에 주문부터 결제까지 한 번에 요청하는 피크테스트를 진행했다.

sleep구간을 제외하더라도 비즈니스 로직 처리 시간만으로도 목표 응답시간(2~3s)을 크게 초과하고 있다.
즉, 단순한 네트워크 대기 문제가 아니라 서버 내부 처리 흐름 자체가 한계에 도달하고 있다는 신호였다.
무엇을 하면 좋을까?
앞선 피크테스트에서 확인했듯이 단순히 커넥션 풀이 부족해서 응답이 느려진 것인지 아니면 트랜잭션 내부에서 커넥션이 불필요하게 오래 점유되고 있는 것인지를 구분하는 것이 중요했다. 하지만, 트랜잭션 내부에 대한 튜닝은 이미 다 끝난 상태였고 코드를 계속 보면서 커넥션을 줄여보려고 해도 최대라는 생각이 들었었다. (주문, 결제 한정) 그래서 HikariCP를 튜닝해보기로 하였고, 튜닝을 진행하기 전에 HikariCP가 실제로 커넥션을 어떻게 주고 받는지부터 살펴보게 되었다.
HikariCP가 일하는 방식
(참고 : https://techblog.woowahan.com/2664)
Spring Boot 2점대 부터 기본 JDBC Connection Pool로 채택된 HikariCP는 빠르다는 평판만큼이나 내부 구조가 단순하고 락을 최소화한 설계를 가지고 있다.
하나의 쿼리는 어떻게 Connection을 사용하고 반납할까?
실제 코드는 훨씬 복잡하지만 하나의 쿼리가 실행되는 뼈대는 대략 아래와 같다.
Connection connection = null;
PreparedStatement preparedStatement = null;
try {
connection = hikariDataSource.getConnection();
preparedStatement = connection.prepareStatement(sql);
preparedStatement.executeQuery();
} finally {
if (preparedStatement != null) {
preparedStatement.close();
}
if (connection != null) {
connection.close(); // 실제로는 Pool에 반납
}
}
여기서 중요한 것은 getConnection()부터 close()까지 이 Connection은 점유된 자원이라는 것이다.
HikariCP는 Connection을 어떻게 관리할까?
HikariCP는 내부적으로 실제 Connection을 한번 더 감싼 PoolEntry라는 객체로 관리한다. (이해를 돕기 위해 Connection이라하겠다.)
그리고 이 Connection들은 ConcurrentBag이라는 자료 구조에서 관리된다.
HikariPool.getConnection()
→ ConcurrentBag.borrow()
HikariCp가 성능이 좋은 이유는 이 borrow 과정이 최소한의 동기화만 사용하도록 설계되어 있기 때문이다.
Connection을 받아가는 과정!
- Thread가 Connection을 요청한다
- 이전에 쓰던 Connection이 있는지 확인
(ThreadLocal 캐시를 먼저 확인 → 재사용 가능하면 가장 빠르다) - Pool 전체에서 idle Connection 탐색
(ConcurrentBag을 순회하며 idle 상태 탐색) - 전부 사용 중이면 handoffQueue에서 대기
(기본 connection-timeout = 30s)
Connection 반납은 어떻게 될까?
HikariCp에서 받은 Connection은 실제로는 ProxyConnection 타입이다.
connection.close();
이 코드는 DB연결을 닫는 것이 아니라 아래 과정을 수행한다.
- 상태를 NOT_IN_USE로 변경
- handoffQueue에 대기 중인 Thread가 있다면 즉시 전달
- 없으면 idle Connection으로 Pool 유지
- 해당 Thread의 최근 사용 Connection 정보 저장
그렇다면 아래와 같은 상황에서는 어떤 문제가 발생할 수 있을까?

- Thread 수 : 1
- HikariCP maximumPoolSize: 1
- 하나의 Task에서 필요한 Connection 수 : 2
이때, 아래와 같은 흐름이 나오게 된다.


- Thread-1이 Transaction을 시작한다 → Root Transaction용 Connection 1개 획득
- Transaction 내부에서 또 다른 DB 작업을 위해 Connection 요청
- Pool에는 이미 Connection이 1개 뿐이고 그마저도 자기 자신이 사용 중
- handoffQueue에서 자기 자신이 반납하기만을 기다리는 상태
- 30초 대기 후 Connection timeout
- Sub Transaction 실패 → rollback
- Root Transaction까지 rollbackOnly → 전체 롤백

이는 커넥션 개수 부족으로 인한 구조적 Deadlock이 발생하게 되는 것이다.
만일 이 문제를 해결해야 한다면, 아래와 같은 해결방법으로 해결해야한다.

1차 HikariCP 튜닝
다시 돌아와서 이 구조를 이해하고 나서 나의 프로젝트에서 HikariCP 설정값들을 튜닝하기 시작했다.
이렇게 튜닝한 값이 아래와 같다.
- maximum-pool-size: 30
- connection-timeout: 30000
- idle-timeout: 600000
- max-lifetime: 1800000
- keepalive-time: 300000
설정의 근거는 아래와 같다.
- Pool Size를 늘리는 것만으로는 근본적인 해결책이 되지 않는다.
- 커넥션 풀 증설은 대기열만 늘리는 것에 가깝다. - idle상태에서 발생 가능한 DB / 네트워크 레벨 커넥션 종료 / 예기치 않은 커넥션 무효화를 줄이기 위해 아래의 사안들도 함께 고려하였다.
- idle-timeout, max-lifetime, keepalive-time
이에 따른 결과는 아래와 같다.

비교적 안정적인 수치이지만 느린 것이 너무 확연하게 보이는 것을 알 수 있다.
이는 VUser를 조금만 올려보면 너무 명백하게 보인다.

명확하게 임계점이 오는 것을 알 수 있다. 이 시점부터는 커넥션 대기 + 트랜잭션 처리 지연이 함께 발생하기 시작했다.
2차 HikariCP 튜닝 (MaximumPoolSize = 40)
동일한 시나리오로 MaximumPoolSize만 변경하여 테스트해보았다.

이 결과로 하나의 사실을 분명히 알게 되었다.
커넥션 풀 확장은 응급처치일 뿐이며 트랜잭션 처리 시간이 줄어들지 않으면 결국 응답 지연은 반복될 뿐이다.
테스트를 하며 정리된 어플리케이션 전체 흐름
이번 테스트를 진행하며 Spring 어플리케이션이 요청 하나를 처리하는 전과정을 구조적으로 이해하게 되었다.
- 요청 수신 (Tomcat 작업 스레드 1개 할당)
- Servlet Filter Chain → DispatcherServlet 진입
- Controller 실행
- Service 진입
- Transaction AOP 프록시 동작
- HikariCP에서 DB 커넥션 대여 - Commit / Rollback
- 커넥션 반환
- HTTP 응답 작성 및 반환
- Tomcat 스레드 풀 복귀
➡️ 요청 1개 = Tomcat 스레드 1개 + DB 커넥션 1개
따라서, 동시 요청이 많은 상황에서 트랜잭션이 길어질수록, 외부 I/O가 많아질수록, DB 접근 비용이 누적될수록
스레드와 커넥션은 동시에 묶이게 되는 것이다.
👀 마치며
이번 테스트를 통해 명확한 결론을 얻었다.
- 문제의 본질은 커넥션 수의 부족이 아니었다. (물론 너무 작긴했다..)
- 진짜 병목은 트랜잭션 길이, 외부 의존성 I/O, 반복 호출되는 DB 접근 비용 에서 비롯된다.
성능 문제는 구조를 바꿔야만 해결되는 문제가 다르다는 것을 이번 경험을 통해 체감하게 되었다.
다음 글에서는 캐싱처리 DB 접근 패턴 개선등 지금까지 최적화할 수 있는 부분을 다 했다고 하였지만 놓친 부분들을 짚어볼 생각이다.
'Project' 카테고리의 다른 글
당신이 좋아할만한 콘텐츠
-
[Sansam E-commerce] 9. MVP 이후 리팩토링 #7 : MSA는 언제 필요한가 (feat. 재고 비동기 처리 실험과 서버 분리 검증) 2025.12.30
-
[Sansam E-commerce] 8. MVP 이후 리팩토링 #6 : 캐싱 (캐싱에 대한 고찰) 2025.12.29
-
[Sansam E-commerce] 6. MVP 이후 리팩토링 #4 : Order 트랜잭션 분리 & DB Indexing을 통한 최적화 & 테스트 툴 변경 2025.12.26
-
[Sansam E-commerce] 5. MVP 이후 리팩토링 #3 : 테스트 코드 작성과 Outbox 패턴 구현기 2025.12.24
소중한 공감 감사합니다