← 실험실 목록
입문 읽는 데 12분

커넥션 풀 고갈

트래픽이 2배가 되면 왜 응답시간은 2배가 아니라 20배가 될까?

은행에 가봤다고 생각해 봅시다

창구가 3개 있는 은행입니다. 한 사람 처리하는 데 1분이 걸려요.

손님이 1분에 3명씩 오면 어떻게 될까요? 딱 맞습니다. 오는 족족 바로 처리돼서, 아무도 기다리지 않아요.

그런데 손님이 1분에 4명씩 오기 시작하면요?

1분마다 한 명씩 남습니다. 10분 뒤엔 10명이 줄을 서 있고, 1시간 뒤엔 60명이 서 있어요. 손님이 33% 늘었을 뿐인데, 대기 시간은 0분에서 60분이 됐습니다.

이게 이 글에서 다룰 전부입니다. 이름만 바꾸면 그대로 서버 이야기가 돼요.

은행서버
창구DB 커넥션 (동시에 DB를 쓸 수 있는 자리)
창구 직원이 한 명 처리하는 시간쿼리 실행 시간
줄 서 있는 손님커넥션을 기다리는 요청
못 기다리고 나가버림타임아웃 에러

직접 돌려보세요

아래가 그 은행입니다. 왼쪽에서 요청이 들어오고, 가운데 줄을 서고, 오른쪽 파란 칸(커넥션)에서 처리돼서 나갑니다.

노란 점은 슬슬 초조해진 요청, 빨간 점은 곧 포기할 요청이에요.

먼저 아무것도 건드리지 말고 30초만 보세요. 그다음 아래 프리셋을 하나씩 눌러 보세요.

커넥션 풀 실험실
창구가 넉넉합니다. 필요한 건 5.0인데 20가 있어서 대부분의 손님이 기다리지 않고 바로 처리됩니다.
처리량
0.0/초
에러율
0.0%
응답시간 p50
응답시간 p99
대기줄
0
실제 유입
0.0/초
필요한 창구 수 = 도착 100/초 × 처리 0.050초 = 5.0
지금 있는 창구 = 20이용률 25%
이 설정의 처리량 한계 = 창구 20개 ÷ 0.050초 = 400/초 (아무리 기다려도 이보다 많이는 못 나감)
100/초
1초에 몇 개의 요청이 들어오는지.
20개
동시에 DB를 쓸 수 있는 요청의 최대 개수. 늘리면 DB가 부담을 집니다.
50ms
인덱스가 빠지면 이 값이 10배가 됩니다. 그때 무슨 일이 일어나는지 보세요.
2.0초
이 시간을 넘게 기다리면 에러를 내고 나갑니다.

왜 이렇게 되는지, 산수로

필요한 창구 수를 구하는 공식은 놀랄 만큼 간단합니다. 곱셈 하나예요.

필요한 커넥션 = 초당 요청 수 × 요청 하나가 커넥션을 붙잡고 있는 시간

초당 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·containerPostgreSQL connection·CPU·I/Oapp pool 상한, PgBouncer
KubernetesDeployment의 Pod replicaDB, cache, external APIHPA custom metric, global connection budget
AWS Lambdaconcurrent executionDB connection, downstream quotareserved concurrency, RDS Proxy
Async workerconsumer 수queue drain rate, DB write ratebounded 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개까지만 늘릴 수 있습니다.

에러율 1% 미만
응답시간 p99 500ms 미만
커넥션 20개 이하 사용10개
10개
동시에 DB를 쓸 수 있는 요청 수
2.0초
길게 잡으면 에러는 줄지만 p99가 나빠집니다. 트레이드오프.
쿼리를 손본다 처리 시간50ms

확인 문제

Q1. 초당 300개 요청이 오고, 쿼리 하나가 200ms 걸립니다. 커넥션이 최소 몇 개 필요할까요?
Q2. 응답이 느려져서 파드를 5개에서 50개로 늘렸습니다. 가장 먼저 일어날 일은?
Q3. 트래픽이 2배가 됐는데 응답시간이 2배가 아니라 20배가 됐습니다. 왜일까요?
Q4. 과부하 상황에서 클라이언트 재시도를 켜면?

⏱ 30초 리마인드

필요한 커넥션 = 초당 요청 수 × 요청당 처리 시간
  1. 병목은 가장 적은 자원에서 생긴다. 보통 그건 앱 서버가 아니라 DB다.
  2. 이용률이 100%에 가까워지면 대기 시간이 수직으로 튄다. 70%를 넘기지 않게 설계한다.
  3. 처리 시간을 줄이는 게 커넥션을 늘리는 것보다 강력하다. 곱셈이라서, 절반으로 줄이면 필요량도 절반이 된다.
  4. 파드를 늘려도 DB 커넥션 총량만 늘어난다. 스케일아웃은 뒤에 공유 자원이 없을 때만 통한다.
  5. 과부하 상황의 재시도는 부하를 증식시킨다. 백오프와 서킷 브레이커가 세트로 필요하다.

현실에서는

시뮬레이터를 벗어나 실제 설정으로 옮기면 이렇게 됩니다.

# 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의 감각을 곡선으로 확인합니다.