이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- CPU, DB, network, connection pool, lock, external API 병목을 metric으로 어떻게 구분하는가?
- “서버가 느리다”는 증상을 queue가 생긴 위치로 어떻게 바꾸는가?
- 병목 하나를 풀었을 때 다음 병목이 드러나는 이유를 어떻게 설명하는가?
개요
병목 구분은 느린 요청의 원인을 하나의 이름으로 찍는 일이 아니라 queue가 생긴 위치를 찾는 일이다. CPU 사용률이 낮아도 DB connection pool이 꽉 차 있으면 요청은 기다린다. network bandwidth가 남아 있어도 external API quota가 차면 timeout이 난다. 백엔드는 각 자원별 metric과 증상을 연결해 봐야 한다.
- 병목은 utilization, saturation, error로 본다.
- saturation은 queue length, pending, wait time, retry, timeout으로 드러난다.
- trace waterfall은 “어디서 기다렸는가”를 보여 준다.
- 한 병목을 풀면 숨겨져 있던 다음 병목이 나타나는 것이 정상이다.
원리
Brendan Gregg의 USE Method는 자원마다 다음을 보라고 말한다.
Utilization: 자원이 얼마나 바쁜가?
Saturation: 처리 못 한 일이 줄 서 있는가?
Errors: 자원 오류가 있는가?백엔드에 적용하면 CPU, memory, disk, network뿐 아니라 DB pool, HTTP client pool, thread pool, event loop, queue, lock, downstream quota에도 같은 질문을 던진다.
병목별 증상
| 병목 | 대표 metric | 흔한 증상 |
|---|---|---|
| CPU | CPU usage/run queue | 모든 요청 CPU 구간 증가 |
| GC/Memory | heap, GC pause, OOM | p99 spike, stop-the-world |
| DB pool | active/pending/timeout | 앱 CPU 낮고 요청 대기 증가 |
| DB query/lock | slow query, lock wait | 특정 endpoint만 지연 |
| HTTP client pool | pending acquire | 외부 API 호출 전 대기 |
| Network | bandwidth, retransmit | 큰 payload, cross-AZ 지연 |
| Thread pool | active/queue/reject | 요청이 app 내부에서 대기 |
| Cache | hit ratio, hot key, eviction | cache miss storm, 특정 key 지연 |
| Queue | lag, consumer utilization | 비동기 처리 지연 |
Trace로 보는 법
trace waterfall이 다음처럼 보이면 DB가 느린 것이 아니라 DB pool 대기일 수 있다.
HTTP request total: 1200ms
acquire db connection: 800ms
query: 80ms
serialize response: 20ms반대로 query가 800ms라면 SQL, index, lock, row count를 본다. pool wait과 query time을 분리하지 않으면 엉뚱한 최적화를 하게 된다.
코드와 명령으로 확인하기
OS socket 상태를 본다.
ss -s
ss -tan state established | wc -l
ss -tan state time-wait | wc -l프로세스 file descriptor를 본다.
pid=$(pgrep -f 'java.*app' | head -1)
ls /proc "$pid" >/dev/null 2>&1 || true
lsof -p "$pid" | wc -lLinux/macOS 차이가 있으므로 운영 runbook에는 환경별 명령을 따로 둔다.
Prometheus가 있다면 이런 식으로 pool pending을 본다.
sum(rate(http_server_requests_seconds_count[1m])) by (uri, status)
histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))
hikaricp_connections_pending주니어가 자주 하는 오해
- CPU 사용률이 낮으면 서버는 한가하다고 생각한다.
- latency가 늘면 instance 수를 먼저 늘리고 DB pool, lock, downstream quota를 나중에 본다.
- p99 spike를 평균 latency가 괜찮다는 이유로 무시한다.
- “네트워크가 느림”을 bandwidth 문제로만 좁혀 보고 retransmit, DNS, NAT, pool 대기를 보지 않는다.
시니어의 설계 판단 기준
- 병목을 자원 이름이 아니라 queue가 생긴 위치와 대기 시간으로 설명한다.
- app scale out이 shared resource 부하를 어떻게 바꾸는지 먼저 계산한다.
- 병목 해결 후 다음 병목이 드러나는 것을 실패가 아니라 capacity frontier 이동으로 본다.
- 기업 운영에서는 app metric, infra metric, DB metric, trace의 timestamp를 맞춘 뒤 결론을 낸다.
인프라 협업 포인트
- 인프라 팀은 node/network/LB/NAT metric을 보고, 앱 팀은 trace/pool/thread/DB call metric을 본다. 둘 중 하나만으로는 결론이 약하다.
- retransmit, packet drop, conntrack, NAT port 같은 metric은 앱에서 보이지 않을 수 있다.
- 앱 팀은 endpoint별 dependency fan-out을 알려야 한다. 요청 하나가 DB 3번, cache 5번, 외부 API 2번을 호출하면 병목 위치가 달라진다.
- 장애 중에는 metric screenshot보다 timestamp와 query가 재사용 가능한 형태로 남아야 한다.
실전 팁
- “DB가 느리다” 대신 “DB connection acquire p99가 800ms”처럼 대기 위치를 말한다.
- CPU가 낮고 latency가 높으면 wait, lock, pool, downstream을 먼저 본다.
- scale out 후에도 latency가 같으면 shared bottleneck 가능성이 크다.
- p99만 튀면 GC, lock convoy, noisy neighbor, packet loss, cold cache처럼 간헐 원인을 본다.
- 개인 프로젝트라도 Actuator/Micrometer와 DB slow query log를 켜면 병목 탐지력이 크게 올라간다.
위험 신호!
- 평균 latency만 보고 p99를 보지 않는다.
- DB pool pending과 query time을 구분하지 않는다.
- retry 증가를 traffic 증가로 착각한다.
- app metric과 infra metric의 시간대가 맞지 않는다.
확인 질문
확인 질문
- CPU가 낮은데 응답이 느릴 때 먼저 볼 것은 무엇인가?
- pool pending, lock wait, downstream latency, network wait, queue length처럼 CPU 밖에서 기다리는 지표를 본다.
- pool 대기와 query 지연을 구분해야 하는 이유는 무엇인가?
- pool 대기 문제에 인덱스를 추가하거나 query 문제에 pool만 늘리는 식의 잘못된 처방을 피하기 위해서다.
- 병목 하나를 해결하면 왜 다음 병목이 보일 수 있는가?
- 이전 병목이 전체 처리량을 막고 있었고, 그 제한이 풀리면 다음 공유 자원이 새 한계가 되기 때문이다.