부하 테스트를 진행한 이유
부하 테스트를 통해 RPS(초당 요청 수)를 점진적으로 증가시키며, 단순히 “서버가 몇 RPS까지 버티는가”를 넘어서 시스템이 언제부터 불안정해지는지를 확인할 수 있었다.
포인트는 아래 3가지로 잡았다.
- 요청 처리량이 선형적으로 증가하는지
- p95 응답시간이 어느 시점부터 급격히 상승하는지
- 실패율 없이 처리되더라도, 내부 대기열이 쌓이기 시작하는 임계 구간은 어디인지
또한, 실서비스 특성을 반영하여 다음과 같은 시나리오로 부하를 구성했다.
- 로그인(Login): 가장 빈번한 요청, 실패율 증가는 곧 서비스 장애로 직결
- 회원가입(Sign-up): 배포 초기·축제 기간 전후로 급증 가능 (INSERT, 중복 검사 등으로 상대적으로 DB 부하가 큼)
로그인을 메인 트래픽으로 두고, 회원가입을 낮은 비율로 섞어 동시에 여러 사용자가 로그인·회원가입을 시도하는 상황을 가정했다.
RPS 40 → 60 → 80 구간 테스트 지표




RPS 40→60 구간에서는 평균 응답시간이 100ms를 넘기기 시작하며, p95가 238ms로 급격히 상승했다. 요청 실패 건은 아직까지는 없지만, 대기 시간이 누적되기 시작하며 지연 징후가 보이기 시작했다.


RPS 60→80 구간에서는 p50 2.75초, p95 7초, 최대 응답시간 15초 이상이 소요되며 값이 이전 구간에 비해 확 뛰었다. 처리율은 약 60 RPS 수준에서 더 이상 증가하지 않았고, 타임아웃 또한 발생하였다.
따라서, 해당 구간을 실질적인 서버 처리 한계 도달 지점으로 규정하였다.
RPS 60부터 무너지는 응답 시간
지표 | RPS 20 | RPS 40 | RPS 60 | RPS 80 |
총 요청 수 (http_reqs) | 1,879 (15.58/s) | 3,723 (30.90/s) | 5,585 (46.29/s) | 7,194 (59.53/s) |
로그인 건수 | 1,484 | 2,969 | 4,455 | 5,744 |
회원가입 건수 | 375 | 734 | 1,110 | 1,430 |
평균 응답시간 (http_req_duration) | 79.49 ms | 87.31 ms | 107.50 ms | 2.75 s |
p95 응답시간 | 102.02 ms | 131.49 ms | 238.17 ms
(+81%) | 7.01 s |
최대 응답시간 | 316.33 ms | 380.75 ms | 612.80 ms | 15.38 s |
에러율 (http_req_failed) | 0% | 0% | 0% | 0.01%
(⇒ 타임아웃 시작) |
데이터 수신량 | 2.5 MB | 5.0 MB | 7.5 MB | 9.7 MB |
RPS 단계별 테스트 결괏값이고,
RPS 60부터 평균 응답시간과 p95 지연이 눈에 띄게 증가하기 시작하며 시스템이 포화 지점에 근접하고 있음을 보여준다.
이 구간은 단순한 트래픽 증가가 아니라, 내부 자원(DB, 커넥션 풀, 스레드 등)의 병목이 드러나기 시작하는 경계 구간으로 볼 수 있다.따라서 이후 테스트에서는 RPS를 점진적으로 증가시키면서
- 어떤 지점에서 지연이 발생하는지
- 병목이 애플리케이션 레이어인지, DB 레이어인지
- DB 관점에서 어떤 쿼리와 자원이 한계에 도달하는지
를 중심으로 성능 저하의 원인과 DB 최적화 포인트를 단계적으로 분석해보았다.
첫번째 부하 테스트 (maxPool : 30)




앞선 테스트에서는 vUser = 20 환경에서 RPS 60부터 지연 징후가 측정되었다.이는 요청 유입 속도(RPS) 자체가 시스템 처리 한계에 근접하면서 발생한 지연으로 해석할 수 있다.
반면, 이번 지표는 RPS 40을 유지한 상태에서 vUser를 20 → 100으로 증가시켜 측정한 결과다.
즉, 평균 처리량(RPS)은 낮지만, 동시성을 크게 증가시킨 실험에 해당한다.
이 차이를 전제로 아래 지표를 해석해야 한다.
스레드 풀 & HikariCP 커넥션 풀 자원 상태
구분 | 관측 시점 / 구간 | 측정값 | 해석 |
Tomcat Threads Current | 포화 구간 | 200 (max-threads=200) | 모든 워커 스레드 점유 |
HikariCP Active | 포화 구간 | ~30 (maxPool=30) | DB 커넥션 풀 용량 한계 도달 |
HikariCP Pending | 포화 구간 | 최대 ~150 | DB 커넥션 획득 대기 폭증 |
스레드 풀 지표
tomcat_threads_current가 200 (max-threads) 에 도달
- RPS는 40 수준이지만, 동시에 대기·실행 중인 요청 수가 급증하면서 스레드 풀이 먼저 포화
⇒ 이 시점부터 http_req_duration 95가 3초 이상으로 급상승하며,
커넥션 풀 지표
- 동일 시점에
hikaricp_connections_pending이 최대 150까지 증가
- Tomcat 스레드가 확보 되었으나, DB 커넥션 풀이 한계에 도달하여 커넥션 획득 대기 발생
결국, “동시 요청 증가 → 스레드 점유 시간 증가 → DB 커넥션 요청 중첩 → Pool 대기 폭증” 를
가상의 트래픽을 통해 관찰할 수 있었다.
트래픽 & 응답 지연 지표
구분 | 관측 시점 / 구간 | 측정값 | 해석 |
RPS (http_reqs / 10s) | 피크 | 1,500 | 초반 상승 후 정체, 내부 자원 포화 시 처리량 증가 멈춤 |
Response Time p95
(병목 구간) | 포화 구간 | 3,500 ~ 4,000ms | 요청 큐 대기 및 내부 자원 포화로 급등 |
HTTP Server p95 | 포화 구간 | ~3s+ | 서버 내부 처리 지연이 그대로 반영 |
Fail Rate (http_req_failed) | 최대 | 12 ~ 13% | 타임아웃 및 큐 대기 초과로 실패율 증가 |
- Response Time p95 (http_req_duration) : 클라이언트가 요청을 보내고 응답을 받을 때까지의 전체 시간
- HTTP Server p95 : 서버가 내부에서 요청을 처리하는데 걸린 시간
보통 Response Time p95 > HTTP Server p95이지만, 현재 상황은 스레드를 잡은 뒤에도 DB
커넥션 대기 시간이 HTTP Server p95 지표에 포함되게 되어, 둘 다 3~4초대가 나오는 것임을 알 수 있다.
기능 단위 응답 지연
구분 | 관측 시점 / 구간 | 측정값 | 해석 |
Signup / Login p95 | 정상 구간 | < 1s | 초기 사용자 요청은 문제 없음 |
Signup / Login p95 (병목 구간) | 포화 구간 | 3,500 ~ 4,000ms | DB 커넥션 대기 영향으로 주요 기능 응답 지연 |
DB & 시스템 상태 (아직까지 병목 아님)
구분 | 관측 시점 / 구간 | 측정값 | 해석 |
DB Active Connections | 평균 | 3 ~ 5 | DB 자체 처리 여유 존재 |
DB Idle Connections | 최소 | 0 근접 | 풀 내 여유 커넥션 소진 시점 발생 |
DB TPS | 최대 | ~110 TPS | 포화 이후 처리량 정체 |
Cache Hit Ratio | 전 구간 | ≈ 1.0 | 캐시 정상 동작 |
PG Block I/O Time | 전 구간 | ≈ 0 | 디스크 I/O 병목 없음 |
JVM GC Pause | 전 구간 | < 0.006s | GC 병목 아님 |
JVM Heap Used | 전 구간 | 안정적 | 메모리 부족 징후 없음 |
- Active Connections: 3~5 수준
- TPS: ~110
- PG Block I/O Time: ≈ 0
- Cache Hit Ratio: ≈ 1.0
GC나 JVM 힙 메모리, 디스크 I/O에서도 큰 이상이 없음을 확인했다. 이는 DB가 처리 불능 상태에 빠진 것이 아니라, 애플리케이션 레벨에서 DB 접근을 조절하지 못해 커넥션 풀에서 병목이 발생했음을 알 수 있다.
예상되는 병목의 원인
[가설] HikariCP maxPool=30이 병목의 시작점이다.
RPS 및 동시 사용자 수가 증가하면서, 동시에 다수의 요청(스레드)이 DB 접근을 시도했고, 그 결과 HikariCP의 maxPool(30)에 빠르게 도달했다.
- 커넥션을 즉시 확보하지 못한 요청은 Pending 상태로 전환
- 해당 요청을 처리 중인 Tomcat 스레드는 커넥션 반환을 기다리며 블로킹
즉, DB 자체 성능 문제가 아닌, 커넥션 풀 크기로 인해 애플리케이션 레벨에서 병목이 먼저 발생한 것이다.
[가설] DB 커넥션 대기가 Tomcat 스레드 풀 포화를 유발한다.
DB 커넥션 반환이 지연되자,
- Tomcat의
max-threads=200이 모두 점유
- 이후 유입된 요청은 큐 대기 또는 타임아웃
그 결과, p95 응답시간 급증 및 HTTP 실패율 증가로 이어졌다. 이 시점부터 병목은 Thread Pool + Connection Pool의 조합 문제이다.
2번째 부하 테스트 (maxPool : 30 → 50) : 커넥션 풀을 늘리면 Pending이 줄고, 스레드 반환이 빨라질 것이다.


Tomcat Thread 풀 포화(위)
초록색(Active) 50(maxPool) 도달 및 대기(Pending) 중인 Pool 커넥션 150개(아래)
지표 | maxPool=30 | maxPool=50 | 변화 | 해석 |
chain p95 | 3,009ms | 3,207ms | ▲ | 개선 없음 |
chain 실패율 | 3.62% | 7.15% | ▲ | 악화 |
login p95 | 3,523ms | 4,170ms | ▲ | 악화 |
signup p95 | 3,925ms | 5,207ms | ▲ | 크게 악화 |
http_req_failed | 4.04% | 7.42% | ▲ | 악화 |
- maxPool을 늘렸지만 Pending은 여전히 높음
- DB에서 커넥션 반환이 지연되며 Tomcat 스레드 포화가 해소되지 않음
⇒ 커넥션 풀 크기 증가만으로는 병목 해소가 불가능함을 알 수 있었다.
3번째 부하 테스트(maxPool: 50 → 100) : Pending 자체를 충분히 처리할 수 있을 정도로 Pool 확장


지표 | maxPool=50 | maxPool=100 | 변화 |
http_req_failed | 7.42% | 0.15% | ▼ -7.27%p (대폭 개선) |
chain p95 | 3.21s | 1.57s | ▼ 개선 |
iteration p95 | 11.16s | 6.80s | ▼ 개선 |
요청수(평균 req/s) | 84/s | 98/s | ▲ 처리량 증가 |
- Pending은 줄었지만, 부하 구간에서는 여전히 Active + Pending이 함께 증가
- Tomcat 스레드와 HikariCP가 동시에 포화
- 이는 쿼리 처리 시간 또는 트랜잭션 점유 시간이 여전히 길다는 신호
⇒ 커넥션 풀을 늘리면 Pending은 완화 되지만, 근본 병목은 DB 처리량에 있었다.
4번째 부하 테스트 (max-threads: 200 → 350) : Pending 완화 → 스레드 수가 처리량 한계인지 검증


커넥션 풀 Pending이 늘어난 시점에서,
병목이 스레드 풀인지, 아니면 여전히 DB 처리량인지를 확인하고자 하였다.
[관측 결과]
- 부하 시점에도 Tomcat max-threads(350)에 도달하지 않음
- 반면, HikariCP Active / Pending은 지속적으로 증가
- Idle 커넥션은 부하 시작 직후 거의 0으로 수렴
[결과 해석]
- 스레드는 병목이 아니였다. 스레드 풀을 늘렸음에도, 실제 사용 중인 스레드는 상한선에 도달하지 못하였다. DB I/O 대기로 인해 스레드가 오래 점유되고 있음을 알 수 있다.
- 커넥션 Pending이 재증가 하였고, 커넥션 점유 시간이 늘어났다.
- idle 커넥션이 0이라는 것은, 풀에 여유 커넥션이 없고 모든 커넥션이 장시간 점유 중이라는 의미
병목은 여전히 DB Connection Pool과 DB 처리량 이였다.
5번째 부하 테스트(스레드 수 유지, maxPool 100 → 200)

[관측 결과]
- Idle 커넥션이 부하 초반 즉시 소진
- Active + Pending이 동시에 크게 증가
- maxPool을 200으로 늘렸음에도 Pending 다수 유지
왜 Max Connections 값을 올렸음에도 처리량이 내려갔을까?
- 동시에 더 많은 쿼리가 DB로 유입
- DB CPU / Lock / 내부 리소스 경합 증가
- DB 처리량은 늘지 않았는데 부하만 증가
⇒ 커넥션 풀의 과도한 확장이 오히려 역효과가 난 것이다.
6번째 부하 테스트(스레드 수 유지, maxPool 200 → 150)

[관측 결과]
- DB 과부화 완화
- 쿼리 응답 속도 개선
- 전체 p95 및 실패율 감소
⇒ 큐 Pending 시간은 조금 늘었지만, DB 처리량이 빨라져 총 응답시간 개선
마지막 테스트(스레드 수 유지, maxPool 150 → 130)

지표 | maxPool = 150 | maxPool = 130 | 변화 |
http_req_failed | 0.73% | 6.85% | 악화 |
chain p95 | 1.41s | 3.70s | 악화 |
avg duration | 626ms | 1.59s | 악화 |
- Idle → Active 전환 후 Pending(파랑)이 급격히 치솟음 → 커넥션 풀 부족으로 요청이 대기
- DB 처리 대기시간이 길어지면서 전체 응답 지연 및 실패율 상승
- 150 → 130으로 줄였을 때 Pending 대기열 급증
왜 maxPool 150에서는 괜찮았고, 130에서는 무너졌을까?
150이 DB가 처리할 수 있는 동시 쿼리 수에 해당했던 것이였기에, 요청이 바로 DB로 진입이 가능했으며, 결과적으로 Pending 대기열이 빠르게 소진될 수 있었던 것이다.
반면, 130의 경우 DB에 들어올 수 있는 요청이 줄은 상황이기에, 나머지 요청은 Pending 대기열로 이동되게 되고, DB는 130개만 처리하니 대기열 소모 속도가 느려진다. 그만큼, 스레드 점유 시간은 증가하고, 타임아웃/실패율이 증가하게 된 것이다.
항목 | maxPool=150 | maxPool=130 |
DB 동시 처리량 | 높음 | ↓ 감소 |
Pending 길이 | 짧음 | 길어짐 |
요청 대기 시간 | 짧음 | 길어짐 |
타임아웃 | 낮음 | 높음 |
사용자 체감 | 빠름 | 느림 |
그러면, 아까 maxPool=200에서는 왜 처리량이 낮았을까?
200은 DB가 처리할 수 있는 한계치를 넘어버린 것이다. 동시에 너무 많은 쿼리 요청이 DB로 유입된 것이고, 내부에서 리소스 경합이나 쿼리 처리 시간이 늘어 커넥션 반환이 지연된 것이고, 이로 인해 Pending 대기열이 늘어나게 된 것이다.
결론 및 테스트를 통해 알게된 점
Max Pool Size | 주요 변화 |
200 | Idle → Active 변환 빠름, Pending 거의 없음 ⇒ 실패율 ↓, 지연시간 ↓ |
150 | 안정적. 실패율 0.73%, p95 1.4s ⇒ 성능·안정성 균형점 |
130 | Pending 급증, 실패율 6.85%, p95 3.7s 이상 ⇒ 성능 급격히 악화 |
- 130은 과도한 큐 대기 때문에 지연과 실패율 모두 악화
- 150 커넥션 풀이 현재 RPS에서의 안정성과 성능의 균형점
- 200은 오히려 DB 부하를 높였고, 150이 가장 효율적인 선택으로 보임
HikariCP 커넥션 풀의 역할
HikariCP 커넥션 풀은 성능을 올려주는 장치가 아닌, 트래픽 유입을 조절하는 밸브 역할이었다.
요청 → (대기열) → DB 커넥션 → 쿼리 실행 → 커넥션 반환 // 커넥션 = 동시에 DB에 접근할 수 있는 티켓 // maxPool = 동시에 실행 가능한 DB 작업 수의 상한
커녁션 풀은 DB가 “동시에 잘 처리할 수 있는 쿼리 수” 근처가 최적점에서 결정됨을 알 수 있었다.
즉, Connection Pool 튜닝은 숫자 조정이 아니라, DB 처리량과 유입량의 균형을 찾는 작업이다.
스레드풀과 커넥션풀의 관계
관련해서 정리한 글 : https://generated-bush-cff.notion.site/DBCP-HikariCP-2eb303e252228093bb50fe00be75250b?source=copy_link
둘은 서로 강하게 결합된 구조이다.
테스트에서 Tomcat 스레드를 100 → 200 → 350까지 늘렸음에도 성능 개선이 거의 없었던 이유는 명확하다. DB 커넥션 풀에서 먼저 병목 발생했기에, 가져갈 커넥션이 없는 스레드 또한 I/O 대기 상태로 들어갈 수 밖에 없었고, 스레드 수를 늘려도 실제 처리 가능한 DB 작업 수는 재자리였던 것이다.
실제 이론상으로도 스레드 수 증가는 I/O bound 작업에는 크게 향상된 결과를 보기 힘들다고 한다.
DB 커넥션 풀이 먼저 병목이 된 이유


HikariCP는 애플리케이션의 요청 스레드가 DB를 과하게 건들지 않도록 의도적으로 접근을 제한하는 장치
- 커넥션 풀이 작으면 → 요청 큐 대기 증가 → 요청 스레드 점유(Blocking)
- 커넥션 풀이 너무 크면 → DB 과부하 및 처리 속도 저하 → 커넥션 반환 늦어짐 → 요청 스레드 점유(Blocking)
Tomcat 스레드 풀은 “얼마나 많은 요청을 동시에 받아들일 수 있는가”를 결정하고, HikariCP 커넥션 풀은 “그중 실제로 DB에서 처리할 수 있는 양”을 결정한다.
- Thread Pool ≥ Connection Pool
- 그러나 Thread Pool ≫ Connection Pool은 의미 없음
개선 방향을 어떻게 잡으면 좋을까 ?
1. Fail-Fast 전략 — Connection Timeout 최적화 (큐 대기 시간 제어)
spring: datasource: hikari: maximum-pool-size: 150 connection-timeout: 5000 // 30초 -> 5초 (Fail-Fast) // 5초 이내에 커넥션을 획득하지 못하면 즉시 SQLTransientConnectionException 발생
maxPool=130에서의 핵심 문제는 DB가 느려진 것이 아니라, Pending 대기열이 길어진 것이었다. 이 상태가 지속되면, 모든 워커 스레드가 대기 상태로 점유되어 서비스 전체가 지연 or 멈출 수 있다.
따라서, HikariCP의
connection-timeout을 과감하게 줄인다. 기본값은 30초이지만, 부하 테스트 기준 p95보다 약간 큰 값인 5초로 설정해주었다. 3~4초 이상 기다려야 할 요청이라면, 기본 값인 30초 동안 서버 자원을 붙잡게 하지 않을 것이다. 따라서, 빠르게 통제 가능한 503(Servie Unavailable)로 실패시켜 전체 시스템 가용성을 보호하는 것이 났다.
2. 커넥션 점유 시간 최소화 — Pool을 늘리지 않으면서 처리량 올리기
DB 커넥션 풀이 부족해지는 근본 원인은 단순하다. 커넥션을 너무 오래 들고 있어서다. 이는 Critical Section(임계 구역)이 불필요하게 넓어진 상태라고 판단할 수 있다.
현재 서비스에는 푸시 알림, 외부 PG 결제 API 호출이 이루어지고 있고, 이에 따른 네트워크 지연도 충분히 발생할 수 있다.
이에 대한 대비는 잘 되어있는 상황으로, 외부 API 용과 비즈니스 트랜잭션 용 커넥션 풀을 분리하여
지연/장애 전파가 최소화되어 있는 상태이다.
3. 유입량 제어 — Backpressure / Rate Limiting 도입
// Controller나 Filter에서 적용 public Response handleRequest() { if (bucket.tryConsume(1)) { // 토큰이 있을 때만 통과 return service.process(); } else { // DB가 비명을 지르기 전에 여기서 쳐냄 throw new RateLimitException("시스템 부하가 많습니다. 잠시 후 시도해주세요."); } }
테스트를 통해, maxPool=150이 DB의 처리 한계에 가깝다는 점이 확인되었다. DB 레벨에서의 쿼리 최적화와 같은 개선점 또한 존재하지만, 애플리케이션 단에서 유입량을 제어하는 방안도 가능하다.
요청 → [유입 제어] → DB ↑ 초과 요청 즉시 차단
DB가 감당 가능한 동시성을 기준으로 Backpressure / Rate Limiting 등을 통해 많은 요청을 쳐낼 수 있게 된다. 우선, WAS 앞단에서 Nginx 웹 서버를 사용 중이기에 우선 여기서 처리량을 제한할 수 있다.
location / { # 요청 수 제한 적용 (버스트 20개까지 허용, 노딜레이) limit_req zone=api_limit burst=20 nodelay; # 동시 연결 수 제한 적용 (IP당 10개) limit_conn addr_limit 10; ...
limit_req: IP당 초당 10회 요청(10r/s)으로 제한하며, 순간적인 버스트는 20개까지 허용
limit_conn: IP당 동시 연결 수를 10개로 제한하여 커넥션 점유를 방지
WAS까지 요청이 오지 않으므로, 애플리케이션의 CPU/메모리 자원을 전혀 소모하지 않고 트래픽을 쳐낼 수 있다는 장점이 있다.
resilience4j: bulkhead: instances: paymentBulkhead: maxConcurrentCalls: 140 maxWaitDuration: 0 // 대기 없이 즉시 429 에러 반환 (Backpressure)
애플리케이션 레벨에서는, Resilience4j 를 사용하여 특정 서비스(예: 결제 API)로 들어오는 동시 실행 스레드 수를 제한하여 트래픽 유량을 컨트롤할 수 있다. HikariCP maxPoolSize가 150일 경우, Bulkhead를 140 정도로 잡으면 된다고 한다.
@ExceptionHandler(io.github.resilience4j.bulkhead.BulkheadFullException.class) public ResponseEntity<Response<Void>> handleBulkheadFullException( io.github.resilience4j.bulkhead.BulkheadFullException ex) { log.warn("Bulkhead is full: {}", ex.getMessage()); return ResponseEntity .status(HttpStatus.TOO_MANY_REQUESTS) .body(Response.fail(Code.TOO_MANY_REQUESTS)); }
DB 커넥션이 부족해지기 전에 애플리케이션 레벨에서 즉시
429 Too Many Requests나 Fallback 응답을 내보내어, Tomcat 스레드가 I/O 대기에 묶이는 것을 원천 차단할 수 있다.


