Turning "It's Slow" Into a Number
한국어 원문으로 표시합니다.
한 줄 요약
"느려요" 로는 아무것도 못 고칩니다. 무엇이 · 언제 · 얼마나 · 몇 % 느린지로 바꾸는 순간 조사 범위가 10분의 1이 됩니다.
왜 이게 필요했나
신고는 이렇게 옵니다. "요즘 시스템이 느려요."
여기서 바로 서버에 들어가 CPU 를 보는 것이 가장 흔한 실수입니다. CPU 가 30% 면 "정상인데요" 로 끝나고, 90% 면 "CPU 때문입니다" 로 잘못 끝납니다. 둘 다 답이 아닙니다.
먼저 물어야 할 것은 네 가지입니다.
| 질문 | 왜 | 답이 좁혀 주는 것 |
|---|---|---|
| 어떤 화면/기능이? | 전부가 느린 경우는 드물다 | 코드 경로 |
| 언제부터? | 배포·설정 변경과 대조 | 원인 시점 |
| 얼마나? (전에는 2초, 지금 20초) | 10배인지 10% 인지 | 문제의 성격 |
| 항상? 가끔? | 평균 문제인지 꼬리 문제인지 | 조사 방법 |
특히 네 번째. 항상 느린 것과 가끔 느린 것은 원인이 다릅니다. 항상이면 구조(쿼리, N+1, 동기 호출)이고, 가끔이면 경합(락, 커넥션 풀, GC, 디스크)입니다. 이 둘을 섞어 놓고 보면 평균에 묻혀 아무것도 안 보입니다.
구간을 쪼갠다
요청 하나가 지나는 길을 나눠 각 구간의 시간을 잽니다.
클라이언트 → [네트워크] → 웹서버 → [앱] → [DB] → 앱 → 웹서버 → 클라이언트
측정 도구가 없어도 curl 하나로 구간을 나눌 수 있습니다.
curl -o /dev/null -s -w \
'dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
https://example.com/slow-page
dns가 크다 → 이름 해석. 리졸버 설정, 캐시 미스.conn이 크다 → TCP 연결. 경로·방화벽·SYN 대기.tls가 크다 → 핸드셰이크. 인증서 체인, OCSP 조회.ttfb가 크고total - ttfb는 작다 → 서버가 생각하는 시간. 앱/DB.total - ttfb가 크다 → 본문 전송. 응답이 크거나 대역폭 문제.
이 다섯 숫자만으로 "네트워크냐 서버냐" 가 갈립니다. 대부분의 논쟁이 여기서 끝납니다.
평균을 믿지 않는다
평균 응답 200ms 인 서비스에서 사용자가 "느리다"고 하는 건 흔한 일입니다. 1000건 중 950건이 50ms 이고 50건이 3초면 평균은 197ms 입니다. 평균은 정상인데 20명 중 1명은 3초를 기다립니다.
그래서 백분위수를 봅니다.
| 지표 | 뜻 | 쓰임 |
|---|---|---|
| p50 (중앙값) | 절반이 이보다 빠름 | 전형적인 경험 |
| p95 | 20명 중 1명이 겪는 값 | 체감 불만의 시작점 |
| p99 | 100명 중 1명 | 꼬리. 경합·GC·재시도 |
| max | 최악 한 건 | 이상값 추적 |
p50 은 그대로인데 p99 만 올랐다면 용량이 아니라 경합입니다. p50 부터 함께 올랐다면 구조나 용량입니다.
부하와 지연은 다른 축이다
CPU 사용률 60% 는 "여유 40%" 를 뜻하지 않습니다. 대기 행렬 이론에서 이용률이 올라가면 대기 시간은 선형이 아니라 급격히 늘어납니다. 70% 를 넘어서면 조금만 더 몰려도 지연이 몇 배가 됩니다. 그래서 "아직 CPU 여유 있는데요" 는 지연 문제의 반박이 되지 못합니다.
현장에서 만나는 모습
- "네트워크가 느린 것 같아요" →
ttfb를 재 보면 서버 처리 시간이 대부분. - 오전 9시만 느리다 → 출근 시간 동시 접속 + 캐시가 비어 있는 시각.
- 특정 고객만 느리다 → 그 고객의 데이터 양이 다르다. 쿼리가 풀스캔.
좁힌 다음에 무엇을 재나
구간을 나눠 서버 쪽이라는 것까지 알았다면, 그다음은 그 안에서 무엇이 기다리고 있는가를 본다. 여기서 흔한 실수가 CPU 사용률 하나로 판단하는 것인데, 앞 코스에서 본 대로 서버가 기다리는 시간의 대부분은 CPU 차례가 아니다.
네 가지를 순서대로 배제한다.
첫째, 실행 대기. 실행할 준비가 된 것이 코어보다 많은지 본다. 부하 평균이 높은데 CPU 사용률이 낮으면 이쪽이 아니다. 컨테이너라면 앞에서 본 스로틀링을 함께 봐야 한다. 평균 사용률은 낮은데 꼬리 지연만 튀는 전형적인 원인이다.
둘째, 입출력 대기. 디스크나 네트워크를 기다리는 시간이다. 데이터베이스가 느린 것도 대개 여기이고, 그때 고칠 것은 애플리케이션이 아니라 쿼리나 인덱스다.
셋째, 잠금과 대기 줄. 커넥션 풀, 스레드 풀, 애플리케이션의 락, 데이터베이스의 행 잠금이다. 동시 요청이 늘 때만 느려지면 거의 항상 여기이고, 혼자 시험하면 재현되지 않는 것이 특징이다.
넷째, 외부 호출. 우리가 부르는 다른 서비스가 느린 것이다. 이때는 우리 쪽에서 고칠 것이 타임아웃과 재시도와 폴백뿐이라, 원인과 대응이 분리된다.
이 넷을 가르는 가장 값싼 방법은 부하를 바꿔 보는 것이다. 요청을 하나만 보냈을 때도 느리면 구조 문제이고(첫째나 둘째), 동시에 여러 개를 보낼 때만 느려지면 경합이다(셋째). 이 한 번의 비교가 조사 범위를 절반으로 줄인다.
그리고 고친 뒤에 같은 방법으로 다시 잰다. 고쳤다고 믿는 것과 숫자가 달라진 것은 다르고, 처음에 잰 값이 없으면 나아졌다는 말을 할 근거가 없다.
이어지는 실습에서 할 것
340건짜리 실제 요청 로그를 받아 네 질문에 직접 답합니다. 답이 다 들어 있는 데이터라, 순서대로 자르기만 하면 "느려요" 한 줄이 버전 하나를 가리키는 보고서가 됩니다.