
야구보구를 개발하면서, "현장톡"이라는 실시간 채팅 기능을 만들었다. 경기 중에 홈런이나 역전이 터지는 순간에 채팅방이 폭발적으로 올라가는 상황을 상상하면서 부하테스트를 돌렸는데, 생각보다 빨리 DB 커넥션 풀(hikariCP)이 다 찼다.
이 글은 그 과정에서 겪었던 커넥션 풀 고갈 패턴을 어떻게 재현하고, 모니터링으로 상태를 관찰하고, 설정을 조정해서 TPS를 71 → 91(약 28.6% 향상)까지 끌어올렸는지 정리한 글이다.
문제가 된 요청 POST /api/v1/talks/{gameId}
문제가 된 요청은 POST /api/v1/talks/{gameId} 현장톡을 생성하는 API였다.
톡이 생성되면, 처음으로 톡을 작성한 조건을 만족하는 유저에게 뱃지를 발급하는 비동기 로직이 함께 동작한다.
뱃지 발급 로직은 대략 이런 구조이다.
@Async("badgeAsyncExecutor")
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleBadgeEvent(final BadgeEvent event) {
BadgePolicy policy = badgePolicyRegistry.getPolicy(event.policy());
BadgeAwardCandidate candidate = policy.determineAwardCandidate(event);
if (candidate != null) {
badgeAwardService.award(candidate);
}
}
여기서 볼 부분은 두 가지가 있다.
1. @TransactionalEventListener(phase = AFTER_COMIIT)
→ HTTP 요청 트랜잭션이 커밋된 이후에 뱃지 이벤트가 비동기로 실행된다.
2. @Async + REQUIRES_NEW
→ 다른 스레드에서, 새로운 트랜잭션으로 뱃지 로직이 돌아간다. 즉, 톡을 1번 생성했을 때 db 커넥션을 1개만 쓰는 것이 아니라, 뱃지 때문에 1개를 더 쓸 수 있는 구조이다.
동시에 많은 신규 유저가 채팅을 남기면,
톡을 저장하는 커넥션
뱃지를 계산+발급하는 커넥션
이렇게 2개의 커넥션이 엮이면서 풀을 소모하게 된다.
첫 번째 부하테스트
처음 부하 테스트를 돌렸을 때의 application-k6.yml 설정은 아래와 같았다.
spring:
datasource:
hikari:
maximum-pool-size: 3
minimum-idle: 0
connection-timeout: 5000 # 5초
max-lifetime: 600000
idle-timeout: 300000
leak-detection-threshold: 4000
task:
execution:
pool:
core-size: 10
max-size: 10
queue-capacity: 0
thread-name-prefix: app-async-
- Hikari 풀은 3개 커넥션으로 고정
- 비동기 스레드는 최대 10개까지 동시에 실행
- 큐는 0이라 대기 없이 바로 스레드를 돌리도록 설정
여기에 k6로 ramping-arrival-rate를 사용해 최대 200RPS까지 올리도록 시나리오를 짰다.
k6 메트릭은 Prometheus Remote Write로 보내고 Grafana에서 시각화하도록 했다.
추가한 모니터링 패널은 다음과 같다.
# 활성 스레드 수
executor_active_threads{name="badgeAsyncExecutor"}
# 큐에 쌓인 작업 수
executor_queued_tasks{name="badgeAsyncExecutor"}
# 최대 스레드 수 대비 사용률
(executor_active_threads{name="app-async"} / executor_pool_max_threads{name="app-async"}) * 100
# 완료된 작업 증가율 (초당 처리량)
rate(executor_completed_tasks_total{name="badgeAsyncExecutor"}[1m])
| 패널 | 타입 | 설명 |
| 활성 스레드 수 | Time Series | 부하 시 실시간 스레드 사용량 |
| 큐 대기 작업 수 | Bar Gauge | 큐 포화 시점 확인용 |
| 최대 스레드 수 대비 사용률 | Stat | 경고 기준 설정 가능함 |
| 처리량 | Time Series | 완료된 작업 증가율 |




부하테스트 하기 전에 부하용 서버에서 Prometheus용 메트릭 잘 뿌리는지 확인해보자.
curl http://<서버주소>:80/actuator/prometheus
모니터링 서버에서가 부하용 백엔드(dev-k6)의 메트릭을 수집하기 위해서는 다음 설정이 필요하다.
프로메테우스 입장에서 새로운 scrape 대상이 생겼기 때문에 다음과 같이 설정 파일을 수정해주어야한다.
- job_name: yagubogu-backend-dev-k6
metrics_path: /actuator/prometheus
static_configs:
- targets:
- <dev-k6 서버 IP : 포트>
labels:
app: yagubogu-backend
environment: dev-k6

테스트 중에 관찰한 HikariCP 메트릭은 다음과 같다.

- Connections Size = 3
- 최대 풀 크기는 3개로 고정이다. 풀은 정상적으로 3개까지만 생성됐다.
- Active = 3, Idle = 0, Pending = 28
- 동시에 28개의 요청이 커넥션을 기다린다. 풀 포화가 발생했다는 의미이다.
- Connection Acquire Time = 1.7s
- 커넥션을 얻기까지 1.7초 이상 걸린다. 풀에서 커넥션을 빌리기까지 오래 대기해야 한다.
- Connection Timeout Count = 6909
- 테스트가 끝날 때 갑자기 수천 건이 한 번에 튀어 오름
- Hikari가 커넥션을 못 빌려준 채 timeout(5000ms)이 발생한다.
- Connection Usage Time / Creation Time
- 일정 시간 유지 후 급격히 감소한다. db 요청 쓰레드가 timeout 이후 다 사라진다.
여기서 내가 이해되지 않았던 부분은 connection-timeout을 5초로 지정해두었기 때문에, 5분 동안 부하를 줬으면 테스트 중간중간에 조금씩 타임아웃이 나야 정상이라고 생각했다. 왜 테스트가 끝난 직후에 수천 건이 몰려서 터지는 현상이 이해되지 않았다.
부하 테스트 중에는 계속 요청이 들어오기 때문에 커넥션이 꽉 찬다. 새 요청들은 풀의 큐 안에서 최대 5초 동안 기다린다. 테스트가 끝날 때쯤, 큐에 쌓인 요청들이 한꺼번에 5초를 넘기게 된다. 즉, 실제로는 5초 동안 대기하던 요청들이 부하테스트가 끝난 시점에 타임아웃 된 것이었다. 이를 더 명확히 검증하기 위해 connection-timeout을 1초로 수정해보았다.

1초로 수정하니 이번에는 Grafana에서 시간이 지날수록 Timeout Count가 조금씩 순차적으로 증가하는 모습을 볼 수 있었다.
↓ 부하테스트 결과 ↓

고갈 → acquire queue 포화 → timeout 폭발 → 요청 실패율 80%
풀 고갈 패턴이 재현되었다.
Peak RPS(=TPS)는 31.4req/s 였다.

목표 TPS 설정하기
튜닝을 하려면 먼저 목표를 정해야 했다.
야구보구의 현장톡에서 가장 트래픽이 폭증하는 순간은 다음과 같다고 생각했다.
한 경기장 최대 입장 인원: 20,000명
그 중 30%가 톡방에 입장: 6,000명
그 중 15%가 실제 채팅을 남김: 900명
홈런같은 이슈 발생한 10초 동안 1인당 평균 2개씩 채팅 남김
→ (6,000명 x 0.15 x 2건) / 10초 ≈ 180 TPS
→ (6,000명 x 0.15 x 2건) / 10초 ≈ 180 TPS
이렇게 현실적으로 피크 구간을 정해서 약 180TPS 수준의 폭발만 버티면 된다고 생각했다.
이 목표를 기준으로, k6 시나리오에서 최대 200 RPS까지 올려 보면서 서버가 어디까지 버티는지 테스트를 설계했다.
k6 + Prometheus + Grafana로 부하량 모니터링하기
부하 테스트를 하는 동안 중요한 것은 두 가지였다.
1. ramping-arrival-rate로 TPS 기반 부하 주기
- 0 → 50 → 100 →200 TPS까지 단계적으로 올리고, 일정 시간 유지하고 다시 낮추기
- k6 옵션에서 states를 이용해 시간별 target 조정
2. Prometheus Remote Write로 메트릭 보내기
k6 run \
-o experimental-prometheus-rw \
-e K6_PROMETHEUS_RW_SERVER_URL=http://<prometheus-ip>:9090/api/v1/write \
-e BASE_URL=http://<backend-ip>:80 \
-e GAME_ID=2 \
vps_talk_create.js
k6가 직접 Prometheus로 테스트 지표를 보낼 수 있게 설정했다.
덕분에 http_req_duration, http_req_failed 같은 기본 메트릭을 Prometheus가 수집하고, Grafana에서는 api별로 rps, 응답 지연 시간, 에러율을 패널로 나눠 한 눈에 볼 수 있다.
요청마다 tags: { api: 'create-talk' } 를 붙여서 API별로 분리해서 보는 것도 함께 구성했다.
더 자세한 모니터링 설정은 다음 블로그를 참고해주세용
https://nourzoo.tistory.com/55
[Prometheus, Grafana] 야구보구 부하테스트 모니터링 구축기 (k6 → Prometheus → Grafana)
야구보구 모니터링현재 야구보구 모니터링 스택은 Grafana, Prometheus, Loki, Promtail, Tempo로 구성되어 있다.기존에는 cloudwatch를 사용했는데, cloudwatch의 여러 한계점을 만나게 되었다. cloudwatch를 사용하
nourzoo.tistory.com
DB 커넥션을 늘려보았어요 👍🏽❓
목표 TPS를 높이기 위해 처음 시도한 건 Hikari 풀 사이즈를 크게 늘리는 것이었다.
1개의 요청이 db 커넥션을 붙잡고 있는 시간은 약 50ms 정도가 나왔기 때문에 목표 TPS인 180을 처리하려면 평균 9개의 커넥션이 동시에 열려 있어야 한다. 또, 여기서 끝이 아니라 뱃지 발급 로직으로 인해 요청 1건이 최대 2개의 커넥션을 동시에 잡을 수 있기 때문에 maximum-pool-size를 최대로 설정한 결과 18이라는 값이 나왔다.
아래처럼 maximum-pool-size를 18까지 올리고, Tomcat 스레드랑 async도 넉넉히 잡았다.
spring:
datasource:
hikari:
maximum-pool-size: 18
minimum-idle: 5
connection-timeout: 2000
leak-detection-threshold: 2000
task:
execution:
pool:
core-size: 2
max-size: 4
queue-capacity: 100
server:
tomcat:
threads:
max: 100
min-spare: 20
accept-count: 200
max-connections: 500
부하테스트를 시작하자 로그에 이런 메세지가 나왔다.
main-hikari-pool - Connection is not available, request timed out after 2000ms
(total=18, active=18, idle=0, waiting=103)
CannotCreateTransactionException: Could not open JPA EntityManager for transaction
Apparent connection leak detected
Unexpected exception occurred invoking async method: ... BadgeEventHandler.handleBadgeEvent

즉, 풀 크기를 18까지 키웠지만 18개가 모두 active인 상태에서 idle은 0이고, 대기 중인 스레드가 100개 이상이었다.
이전의 문제가 단순히 커넥션 개수가 적어서가 아니라, 한 요청이 동시에 여러 커넥션을 쓰는 구조와 너무 많은 동시 스레드가 합쳐졌기 때문이라고 생각을 바꾸게 되었다.
- 토크 생성 트랜잭션 1개
- 뱃지 비동기 트랜잭션 1개
이렇게 두 개의 트랜잭션이 엮이면서 커넥션이 두 배로 사용됐다.
해결 시도
max pool size를 무작정 크게만 잡을 것이 아니라, 균형 잡힌 풀과 동시성 상한을 목표로 설정을 다시 짰다.
생각을 정리하면
- 목표 TPS: 180
- 요청 1건이 db 커넥션 점유하는 시간 대략 50 ms
- 뱃지 비동기가 별도 트랜잭션 여는 경우 커넥션 2개 사용
이 때 필요한 동시 db 커넥션 수는 대략 다음과 같다.
180 TPS × 0.05초 × 1.5 ≒ 13개
최종적으로 안정적으로 동작했던 설정은 아래와 같았다.
spring:
datasource:
hikari:
maximum-pool-size: 12
minimum-idle: 5
connection-timeout: 5000
max-lifetime: 600000
idle-timeout: 300000
leak-detection-threshold: 5000
task:
execution:
pool:
core-size: 2
max-size: 4
queue-capacity: 100
thread-name-prefix: app-async-
server:
tomcat:
threads:
max: 50
min-spare: 10
accept-count: 100
max-connections: 200
동시에 처리 가능한 요청 스레드를 50으로 줄여서 한번에 db로 몰리는 요청을 줄였다.
hikari 풀 사이즈를 12로 줄였다.
동시에 돌아갈 수 있는 비동기 작업 수를 최대 4개로 제한했다. 이전에 10개로 설정했었는데, 그러면 비동기 작업이 한 번에 10개까지 동시에 띄워지고 그 중 다수가 db 트랜잭션을 열면 hikari 풀을 더 빨리 잠가버림.
최종 부하 테스트 결과 (TPS 28.6 ↑)
튜닝 이후 다시 부하 테스트를 돌렸을 때 수치는 다음과 같았다.


부하테스트 결과를 그래프로 보면, k6는 최대 200RPS까지 트래픽을 올리려고 시도했지만, 실제로 서버와 db가 처리한 요청량은 140-150 rps 근처가 최대였다. vu수를 더 올려도 rps가 안 올라가고 이 구간에서는 평형을 이룬다.
현재 서버 스펙은 t4g.small(2 vCPU)이다. 아주 여유있는 환경은 아님.
- 피크 RPS: 147 req/s
- p95, p99 응답시간: 30ms
- 에러율: 3.51%
숫자로 환산하면 147RPS는 분당 약 8,820개의 요청을 처리하는 속도이고, 30분 동안 같은 수준으로 유지된다고 하면 약 26만 건의 요청을 소화할 수 있다.
내가 처음 짰던 야구장 트래픽 폭증 시나리오로 보면, 한 경기장에 2만 명이 입장해 있고, 그 중 일부만 동시에 톡을 친다고 해도, 실제로 채팅이 폭발하는 몇 분 동안 140RPS 수준을 안정적으로 유지할 수 있다면 충분한 수치이다.
커넥션 풀이 터지기 전에 체크해야 할 것들
1. 한 http 요청이 최대 몇 개의 db 커넥션을 쓸 수 있는 구조인가?
특히 @Async와 @Transactional(propagation = REQUIRES_NEW)가 함께 쓰이는 구조라면, 요청 하나가 커넥션을 한 개만 쓰지 않고, 비동기 트랜잭션마다 새로운 커넥션을 추가로 열게 된다.
즉, 요청 수와 커넥션 사용량이 1:1로 대응하지 않고, 요청이 몰릴수록 풀 고갈 속도가 기하급수적으로 빨라질 수 있다.
2. Tomcat 스레드 수, Async 스레드 수, Hikari max-pool-size가 서로 맞는가?
Tomcat 스레드가 200개, Async 스레드가 50개인데 Hikari 풀 크기가 5개라면, 거의 모든 스레드가 커넥션을 빌리기 위해 대기하게 된다.
이런 불균형 구조에서는 요청이 몰리는 순간 timeout이 연쇄적으로 발생하며, 풀 내부에서는 Active가 꽉 차고 Pending만 계속 늘어나는 데드락과 비슷한 상태가 된다.
3. 커넥션 timeout을 너무 길게 잡고 있진 않은가?
예를 들어 connection-timeout=5000(5초)로 두면, 이미 실패가 확정된 요청이 5초 동안 커넥션을 붙잡고 있기 때문에
부하 종료 시점에 timeout이 몰려 폭발하는 것처럼 보일 수 있다.
이럴 땐 1~2초 정도로 줄여서, 실패를 빠르게 감지하고 문제의 원인을 조기에 드러내는 게 좋다.
4. 모니터링 패널을 충분히 준비했는가?
Hikari 메트릭으로는 Active, Idle, Pending, Timeout Count, Acquire Time, Usage Time을,
Async Executor에서는 Active Threads, Queue Size, Completed Task Rate를,
그리고 API 레벨에서는 RPS, p95, p99, 실패율 같은 지표를 함께 모니터링하면 된다.
'공뷰 > Spring' 카테고리의 다른 글
| [Spring] 한 번 이긴 유저가 1등? 승리요정 랭킹 공정성 개선기 (45초 → 0.14초) (3) | 2025.11.22 |
|---|---|
| [Spring, MySQL] 야구보구 현장톡의 모든 것 - 1편 (Polling, Cursor Pagination, 인덱스 튜닝) (0) | 2025.11.18 |
| [Spring] 야구보구의 실시간 기능, 배포 후 발생한 SSE 문제 정리(nginx 설정) (2) | 2025.10.05 |
| [Spring] Soft Delete와 영속성 컨텍스트 (flush, clear, JPA 엔티티 상태) (0) | 2025.09.29 |
| [Spring] 야구보구 홈 화면 SSE(Server-Sent Events)로 실시간 팬 현황 갱신하기 (5) | 2025.09.15 |