이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 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흔한 증상
CPUCPU usage/run queue모든 요청 CPU 구간 증가
GC/Memoryheap, GC pause, OOMp99 spike, stop-the-world
DB poolactive/pending/timeout앱 CPU 낮고 요청 대기 증가
DB query/lockslow query, lock wait특정 endpoint만 지연
HTTP client poolpending acquire외부 API 호출 전 대기
Networkbandwidth, retransmit큰 payload, cross-AZ 지연
Thread poolactive/queue/reject요청이 app 내부에서 대기
Cachehit ratio, hot key, evictioncache miss storm, 특정 key 지연
Queuelag, 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 -l

Linux/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만 늘리는 식의 잘못된 처방을 피하기 위해서다.
  • 병목 하나를 해결하면 왜 다음 병목이 보일 수 있는가?
    • 이전 병목이 전체 처리량을 막고 있었고, 그 제한이 풀리면 다음 공유 자원이 새 한계가 되기 때문이다.

참고 문서