부하 테스트를 진행한 이유

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

RPS 40 → 60 → 80 구간 테스트 지표

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

notion image
notion image
notion image
notion image
앞선 테스트에서는 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이 줄고, 스레드 반환이 빨라질 것이다.

notion image
notion image
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 확장

notion image
notion image
지표
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 완화 → 스레드 수가 처리량 한계인지 검증

notion image
notion image
커넥션 풀 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)

notion image
[관측 결과]
  • Idle 커넥션이 부하 초반 즉시 소진
  • Active + Pending이 동시에 크게 증가
  • maxPool을 200으로 늘렸음에도 Pending 다수 유지
 

왜 Max Connections 값을 올렸음에도 처리량이 내려갔을까?

  • 동시에 더 많은 쿼리가 DB로 유입
  • DB CPU / Lock / 내부 리소스 경합 증가
  • DB 처리량은 늘지 않았는데 부하만 증가
⇒ 커넥션 풀의 과도한 확장이 오히려 역효과가 난 것이다.
 

6번째 부하 테스트(스레드 수 유지, maxPool 200 → 150)

notion image
[관측 결과]
  • DB 과부화 완화
  • 쿼리 응답 속도 개선
  • 전체 p95 및 실패율 감소
⇒ 큐 Pending 시간은 조금 늘었지만, DB 처리량이 빨라져 총 응답시간 개선
 
 

마지막 테스트(스레드 수 유지, maxPool 150 → 130)

notion image
지표
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 처리량과 유입량의 균형을 찾는 작업이다.
 

스레드풀과 커넥션풀의 관계

둘은 서로 강하게 결합된 구조이다.
테스트에서 Tomcat 스레드를 100 → 200 → 350까지 늘렸음에도 성능 개선이 거의 없었던 이유는 명확하다. DB 커넥션 풀에서 먼저 병목 발생했기에, 가져갈 커넥션이 없는 스레드 또한 I/O 대기 상태로 들어갈 수 밖에 없었고, 스레드 수를 늘려도 실제 처리 가능한 DB 작업 수는 재자리였던 것이다.
실제 이론상으로도 스레드 수 증가는 I/O bound 작업에는 크게 향상된 결과를 보기 힘들다고 한다.
 

DB 커넥션 풀이 먼저 병목이 된 이유

notion image
notion image
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 대기에 묶이는 것을 원천 차단할 수 있다.