커넥션 풀 고갈
트래픽이 2배가 되면 왜 응답시간은 2배가 아니라 20배가 될까?
은행에 가봤다고 생각해 봅시다
창구가 3개 있는 은행입니다. 한 사람 처리하는 데 1분이 걸려요.
손님이 1분에 3명씩 오면 어떻게 될까요? 딱 맞습니다. 오는 족족 바로 처리돼서, 아무도 기다리지 않아요.
그런데 손님이 1분에 4명씩 오기 시작하면요?
1분마다 한 명씩 남습니다. 10분 뒤엔 10명이 줄을 서 있고, 1시간 뒤엔 60명이 서 있어요. 손님이 33% 늘었을 뿐인데, 대기 시간은 0분에서 60분이 됐습니다.
이게 이 글에서 다룰 전부입니다. 이름만 바꾸면 그대로 서버 이야기가 돼요.
| 은행 | 서버 |
|---|---|
| 창구 | DB 커넥션 (동시에 DB를 쓸 수 있는 자리) |
| 창구 직원이 한 명 처리하는 시간 | 쿼리 실행 시간 |
| 줄 서 있는 손님 | 커넥션을 기다리는 요청 |
| 못 기다리고 나가버림 | 타임아웃 에러 |
직접 돌려보세요
아래가 그 은행입니다. 왼쪽에서 요청이 들어오고, 가운데 줄을 서고, 오른쪽 파란 칸(커넥션)에서 처리돼서 나갑니다.
노란 점은 슬슬 초조해진 요청, 빨간 점은 곧 포기할 요청이에요.
먼저 아무것도 건드리지 말고 30초만 보세요. 그다음 아래 프리셋을 하나씩 눌러 보세요.
왜 이렇게 되는지, 산수로
필요한 창구 수를 구하는 공식은 놀랄 만큼 간단합니다. 곱셈 하나예요.
필요한 커넥션 = 초당 요청 수 × 요청 하나가 커넥션을 붙잡고 있는 시간
초당 200개 요청이 오고, 쿼리 하나가 0.05초(50ms) 걸린다면:
200 × 0.05 = 10개
10개면 됩니다. 이걸 리틀의 법칙이라고 부르는데, 이름은 몰라도 됩니다. 곱셈만 기억하세요.
여기서 중요한 건 이 공식이 알려주는 두 가지 사실입니다.
1. 100%를 채우면 이미 늦었다
필요한 게 10개니까 10개를 준비하면 될까요? 위 시뮬레이터에서 ‘아슬아슬’ 프리셋이 정확히 그 상태입니다. 돌려보면 에러는 안 나는데 응답시간이 계속 출렁거려요.
이유는 요청이 일정한 간격으로 오지 않기 때문입니다. 평균은 초당 200개여도, 어떤 순간엔 250개가 몰리고 어떤 순간엔 150개만 옵니다. 여유가 없으면 몰린 순간을 흡수할 곳이 없어요.
대기 시간은 이용률이 100%에 가까워질수록 완만하게 늘다가 갑자기 수직으로 튑니다.
| 이용률 | 체감 |
|---|---|
| 50% | 거의 안 기다림 |
| 70% | 가끔 기다림 |
| 90% | 자주 기다림, p99가 튀기 시작 |
| 95% | 조금만 흔들려도 무너짐 |
| 100%+ | 줄이 끝없이 길어짐 |
2. 처리 시간을 줄이는 게 훨씬 강력하다
공식이 곱셈이라는 게 핵심입니다. 필요한 커넥션은 요청 수 × 처리 시간이니까, 처리 시간을 절반으로 줄이면 필요한 커넥션도 절반이 됩니다.
시뮬레이터에서 ‘한 명 처리하는 시간’을 50ms → 25ms로 내려보세요. 창구를 하나도 안 늘렸는데 여유가 두 배로 생깁니다.
인덱스 하나 추가하는 게 서버를 두 배로 늘리는 것과 같은 효과라는 뜻이에요. 그리고 인덱스는 공짜입니다.
“그냥 파드를 늘리면 되잖아요?”
이 질문이 자연스럽게 나옵니다. 쿠버네티스가 알아서 스케일아웃 해주는데 왜 이런 걸 신경 써야 하나요?
여기서 비유를 정확히 맞춰야 합니다.
파드는 창구가 아닙니다. 창구 앞에 손님을 안내하는 직원이에요. 창구는 DB입니다.
안내 직원을 10배로 늘려도 창구가 8개면 여전히 한 번에 8명만 처리됩니다. 처리량은 1도 안 늘어납니다.
여기까진 그냥 소용없는 정도인데, 실제로는 더 나빠집니다.
파드마다 자기 커넥션 풀을 들고 있기 때문입니다. 파드 하나에 커넥션 10개씩이라면:
파드 10개 × 10 = 100 커넥션 ← 평소
파드 100개 × 10 = 1,000 커넥션 ← 스케일아웃 후
Postgres 기본 최대 접속 수가 100입니다. 스케일아웃하는 순간 DB가 접속을 거부하기 시작합니다. 트래픽을 견디려고 한 행동이 장애를 만드는 거예요.
스케일아웃이 통하는 건 상태가 없고, 뒤에 공유 자원이 없는 계층뿐입니다. 웹 서버가 CPU로 템플릿을 렌더링하는 일 같은 거요. 뒤에 DB·캐시·외부 API·파일 저장소처럼 모두가 함께 쓰는 것이 있으면, 병목은 거기로 옮겨갈 뿐 사라지지 않습니다.
같은 문제를 platform별로 보면
먼저 역할을 분리해야 합니다. Docker container는 앱과 실행 환경을 묶습니다. container를 하나 더
실행하는 것 자체는 DB의 처리 능력을 늘리지 않습니다. Kubernetes Pod는 하나 이상의 container를
실행하는 단위이고, Deployment가 그 복제본을 관리합니다.
**Horizontal Pod Autoscaler(HPA)**는 CPU, memory 또는 custom metric을 보고 Pod 수를 조절합니다. 이것은 stateless application 계층의 여유를 늘리는 일입니다. DB connection pool처럼 공유된 downstream resource의 상한은 별도로 관리해야 합니다. CPU가 아니라 queue length나 connection wait time이 병목을 더 잘 설명한다면, HPA도 그 custom metric을 보게 해야 합니다.
Serverless에서도 서버가 사라진 게 아니라 instance 관리가 platform으로 이동한 것입니다. 예를 들어 AWS Lambda가 짧은 시간에 많은 execution environment를 만들면, 각 함수가 DB connection을 열면서 같은 병목을 만들 수 있습니다. 이때 Amazon RDS Proxy가 connection을 pool하고 재사용해 database가 보는 connection 수를 관리 가능한 범위로 줄입니다.
| 실행 방식 | 자동으로 늘어나는 것 | 그대로 남는 한계 | 흔한 안전장치 |
|---|---|---|---|
| Node.js process / Docker | 직접 늘린 process·container | PostgreSQL connection·CPU·I/O | app pool 상한, PgBouncer |
| Kubernetes | Deployment의 Pod replica | DB, cache, external API | HPA custom metric, global connection budget |
| AWS Lambda | concurrent execution | DB connection, downstream quota | reserved concurrency, RDS Proxy |
| Async worker | consumer 수 | queue drain rate, DB write rate | bounded concurrency, backpressure, DLQ |
공식 문서: Kubernetes HPA, Amazon RDS Proxy
과부하는 누가 막나요?
Autoscaling은 과부하 정책이 아닙니다. 새 Pod나 function이 준비될 때까지 요청은 이미 들어오고 있고, downstream이 한계라면 instance를 더 만드는 것이 오히려 부하를 키웁니다. 그래서 각 경계에 책임을 둡니다.
- Edge/API Gateway: rate limit과 request size 상한
- Application: timeout, concurrency limit, load shedding
- Queue/worker: queue depth, consumer concurrency, dead-letter queue(DLQ)
- Database boundary: connection pool과 statement timeout
- Autoscaler: CPU뿐 아니라 queue lag·p99·connection wait 같은 병목 metric
핵심은 “얼마나 빨리 늘릴까?”보다 **“늘어나는 동안 무엇을 거절하고 어디에 보관할까?”**입니다.
재시도는 왜 불을 키우는가
시뮬레이터에서 ‘재시도 폭풍’ 프리셋을 눌러보세요. 그리고 ‘실제 유입’ 숫자를 보세요.
설정한 도착률보다 훨씬 큰 숫자가 나옵니다. 실패한 요청이 되돌아와서 다시 줄을 서기 때문이에요.
서버가 버거워서 실패했는데, 그 실패가 부하를 더 만듭니다. 가장 도움이 필요한 순간에 부하가 스스로 증식하는 것 — 이게 리트라이 스톰입니다.
챌린지
이제 직접 지켜보세요. 배운 걸 다 써야 통과합니다.
🎯 챌린지 — 이벤트 오픈을 버텨라
평소 80/초로 잔잔하던 트래픽이, 이벤트가 열리는 순간 400/초로 5배 뜁니다. 45초 동안 서비스를 지켜내세요. 단, DB가 감당 못 해서 커넥션은 20개까지만 늘릴 수 있습니다.
확인 문제
⏱ 30초 리마인드
- 병목은 가장 적은 자원에서 생긴다. 보통 그건 앱 서버가 아니라 DB다.
- 이용률이 100%에 가까워지면 대기 시간이 수직으로 튄다. 70%를 넘기지 않게 설계한다.
- 처리 시간을 줄이는 게 커넥션을 늘리는 것보다 강력하다. 곱셈이라서, 절반으로 줄이면 필요량도 절반이 된다.
- 파드를 늘려도 DB 커넥션 총량만 늘어난다. 스케일아웃은 뒤에 공유 자원이 없을 때만 통한다.
- 과부하 상황의 재시도는 부하를 증식시킨다. 백오프와 서킷 브레이커가 세트로 필요하다.
현실에서는
시뮬레이터를 벗어나 실제 설정으로 옮기면 이렇게 됩니다.
# HikariCP (Spring Boot)
spring:
datasource:
hikari:
maximum-pool-size: 20 # 초당요청 × 쿼리시간 ÷ 0.7 로 역산
connection-timeout: 2000 # 여기 걸리면 "커넥션 못 얻음" 에러
max-lifetime: 1200000
leak-detection-threshold: 20000 # 반납 안 되는 커넥션 잡기
maximum-pool-size를 정할 때 감으로 찍지 말고 위 곱셈으로 역산하세요.
피크 트래픽이 초당 400, 평균 쿼리 시간이 30ms라면 400 × 0.03 ÷ 0.7 ≈ 17.
그리고 이 값에는 상한이 있습니다. 파드 수 × 풀 크기가 DB의 max_connections를 넘으면 안 돼요.
넘을 것 같으면 풀을 줄이는 게 아니라 PgBouncer 같은 풀러를 도입할 때입니다.
다음 랩
이 랩은 자원이 부족할 때 벌어지는 일을 다뤘습니다. 다음은 그 자원이 70% 찼을 때와 95% 찼을 때 대기시간이 왜 전혀 다른지 — Queue의 감각을 곡선으로 확인합니다.