👀 들어가기 전에
얼마 전 프로젝트에 대하여 회고하며 한 번 이런 이야기를 한 적이 있다.
Hikari CP는 결국에는 대기열을 늘릴 뿐이다
이 이야기에 대해서 이번에 실무에서 진행한 프로젝트와 연관지어서 이야기해보려 한다.
과연 HikariCP는 진짜 대기열만 늘리는 존재일까?
아니면 특정 조건에서는 성능을 드라마틱하게 개선할 수 있는 도구가 될 수 있을까?
있다면 언제, 없다면 왜 없는지 그리고 왜 그런 결과가 나오는지를 고민한 내용을 아래에 정리해보려 한다.
👀 본론
문제 발생 : RPS 20에서 무너진 내 서버 😭
이번 프로젝트에서는 DAU 5,000명을 가정했기에, RPS 50~100 수준을 목표로 부하테스트를 진행했다.
시간 경과에 따른 Latency 폭증 (예시 데이터)
RPS 50~100을 안정적으로 처리할 수 있다면 충분히 DAU 5,000명을 가정한 상황에서 사용 가능한 서버라고 판단했다.
하지만 예상과는 달리 문제는 RPS 20부터 발생했다.
- 초반에는 잘 버텼으나 시간이 지날수록 응답 시간이 급격히 증가했다.
- Max 응답 시간은 21초 이상
- p95 Latency는 12초 이상
으로 결국 RPS 20조차 유지하지 못하는 상황이 발생했다.
처리량은 점점 떨어졌고 시스템은 병목상태에 들어갔다.
체크 포인트 : 쿼리가 느린가?
처음에는 당연히 DB 쿼리 문제를 의심했다.
배경화면 서비스 특성상 예를 들자면 카테고리, 태그, 사용자별 상태값, 배경화면 파일 등등 Join이 많은 구조다.
연관된 데이터만 정확히 가져오려다 보니 DB 접근 횟수가 많아질 수 밖에 없기에 이게 Disk I/O 증가로 이어져 응답 지연이 발생했다고 판단했다. 그러나 AOP로그를 분석해보니 DB 조회 API 응답 시간은 대부분 200ms이내였다.
그도 그럴 것이 이 이전에 내부 아키텍처를 한 번 분리하였고, Transaction 또한 필요한 단위로 잘게 쪼갤만큼 쪼개져 있었다.
쿼리 레벨에서도 묶을 수 있는 부분은 이미 충분히 묶어서 Join해서 가져오고 있었다.
결국 결론은 하나였다.
DB 작업 자체는 빠르다.
1초~2초대 이내로 대부분 처리 가능한 작업들이 21초까지 지연되고 있다는 것은 결국 대기 시간으로 인한 것이라는 결론에 도달했다.
사용자의 요청부터 DB까지의 과정
커넥션 대기열
트랜잭션이 짧은데 응답이 느리다는 것은 요청 대비 처리 가능한 쓰레드가 부족하다는 의미이다.
즉, 요청은 들어오는데 DB 커넥션을 얻기 위해 혹은 네트워크 병목으로 인해 서비스 단으로 들어오기 위한 대기열에서 오래 기다리고 있었던 것이다. 결국 DB쪽 커넥션부터 풀어주기로 했고 본격적으로 HikariCP 튜닝에 들어가게 되었다.
참고한 문헌은 아래 공식문서이다.
https://github.com/brettwooldridge/HikariCP
GitHub - brettwooldridge/HikariCP: 光 HikariCP・A solid, high-performance, JDBC connection pool at last.
光 HikariCP・A solid, high-performance, JDBC connection pool at last. - brettwooldridge/HikariCP
github.com
우선 Max-Pool-Size가 현재 기본값(10)이기에 이에 대한 튜닝부터 진행하기 위해 이것을 기준으로 나머지값들을 정해보려하였다.
읽기 지향 서비스라는 점을 고려하여 아래와 같은 기준으로 접근했다.
- 트랜잭션 점유 시간은 짧다.
- DB 락 경쟁이 거의 없다.
- 목표 RPS가 명확하게 정해져 있다.
따라서 요청당 DB 커넥션 점유시간 x 목표 RPS를 기준으로 Thread Pool Size를 산정하는 것이 합리적이라고 판단했다.
Thread Pool Size 계산
HikariCP 사이즈를 산정하기 위해 흔히 참고되는 공식은 다음과 같다.
(TPS × 평균 응답 시간) / (1 - DB 사용률)
이 공식에서 말하는 평균 응답 시간은 엔드 투 엔드 응답시간을 의미하지만, 이번 케이스에서는 실질적인 병목이 DB 커넥션 점유 구간에 있다는 점이 명확했기 때문에, DB 커넥션을 점유하고 반환하는 데 걸리는 시간을 기준으로 계산하는 것이 더 합리적이라 판단했다.
또한 DB 사용률은 약 70% 정도로 가정하였다.
이는 피크 타임 상황을 대비해 의도적으로 여유 용량을 남겨두기 위한 선택이었다.
DB 사용률을 100%에 가깝게 가져가는 것은 단기적으로는 처리량이 늘어날 수 있으나 결국 순간적인 스파이크 트래픽이나 GC, 네트워크 지연 등 외부 요인에 매우 취약해지기 때문이다.
앞서 AOP 로그를 통해 확인했던 평균 DB 점유 시간은 약 0.2초, 목표로 하는 RPS는 50 이었다.
이를 공식에 대입하면 다음과 같다.
ThreadPoolSize = (50 × 0.2) / (1 - 0.7)
계산 결과를 바탕으로 maximum-pool-size를 35로 설정하게 되었다.
추가 : TPS? RPS?
이상한 점을 느끼신 분들이 있을 것이다. TPS가 갑자기 왜 RPS가 되었는지...
물론 TPS와 RPS는 동일한 개념은 아니다.
- TPS 는 Transaction Per Second로 하나의 요청이 시작되어 종료될 때까지의 전체 트랜잭션 흐름을 기준으로 본다.
- RPS는 반면 Request Per Second로 특정 API 또는 서비스 단위의 요청 처리량을 의미한다.
이번 경우에는 하나의 조회 API 병목을 다루고 있었고 DB 점유 구간이 명확히 분리되어 있었기 때문에
RPS를 기준으로 계산해도 큰 왜곡은 없다고 판단했다.
추가 튜닝값
Thread Pool Size 조정과 함께 다른 설정들도 함께 조절하였다.
- minimum-idle
- connection-timeout
- validation-timeout... 등등
이를 통해 불필요한 커넥션 생성 비용을 줄이고 커넥션 대기로 인한 데드락 상황을 방지하고자 했다.
다만, 일부 세부 설정값과 내부 기준은 회사 보안 정책상 공개가 어려운 점 양해 부탁드립니다...🙇🏻♂️
결과적으로
Latency 안정화 (예시 데이터)
RPS 70의 부하 환경까지 Grafana 모니터링을 통해 확인해본 결과는 아래와 같았다.
- Max 응답시간 : 20초 이상 → 1초 내외
- p95 Latency : 12초 → 1초 내
이로써 문제의 원인이 더욱 명확해졌다. 병목은 쿼리가 아니라 커넥션 대기였다.
다시 서론으로 돌아가, 과연 HikariCP는 대기열만 늘리느냐? 라고 했을 때, 꼭 그런 것만은 아니다.
결국, Connection Pool을 요청 쓰레드가 물고 들어가기 때문에 만약 Transaction이 충분히 짧고 DB 커넥션 시간 자체가 그리 길지 않다면, CP를 튜닝하게 되었을 때 가장 큰 효과를 볼 수 있는 것이다.
반대의 경우가 된다면 HikariCP를 튜닝해도 말 그대로 결국에는 대기열만 늘리는 결과가 될 수도 있는 것이다.
👀 마무리하며
이번 경험을 통해 확실히 깨닳았다.
과거의 경험을 일반화하면 위험하다. 이전 서비스에서는 트랜잭션을 더 줄이기 어려웠고,
쓰기 중심 구조로 인해 DB 커넥션 점유 시간 자체가 길었다. 그래서 DBCP 튜닝 효과를 거의 체감하지 못했다.
하지만 이번처럼 읽기 위주 + 짧은 트랜잭션 환경에서는 HikariCP 튜닝으로도 API 응답 시간을 눈에 띄게 개선할 수 있었다.
결국 중요한 것은
상황에 따라 사고를 유연하게 열어두는 것
항상 부족하다는 마음으로 겸손하게 배울 것
기술은 항상 Trade-Off의 연속이며 그 선택의 결과는 환경에 따라 완전히 달라질 수 있는 점을 다시 한 번 몸으로 느껴낸 경험이었다.