성능 관찰과 병목 분석 — "느려요"를 추측이 아니라 측정으로 (시리즈 마무리)
'느려요'를 추측이 아니라 측정으로 — USE 방법론과 top·vmstat·iostat·perf·eBPF로 OS 병목을 진단하는 실전 가이드(시리즈 캡스톤).
“서버가 느려요”라는 말부터 의심하라
운영 중 가장 자주 듣고, 가장 다루기 어려운 한 문장이 “느려요”입니다. 느린 게 CPU인지 메모리인지 디스크인지 네트워크인지, 그게 커널인지 애플리케이션인지를 말하지 않은 채 추측이 시작됩니다. 그리고 추측은 거의 항상 틀립니다 — “CPU 늘립시다”라고 인스턴스를 키웠는데 알고 보니 디스크 iowait이 원인이었던 경험은 누구에게나 있습니다.
이 마지막 글은 새로운 개념이 아니라 방법론입니다. 지금까지 프로세스·스케줄링·가상 메모리·페이지 폴트·I/O·페이지 캐시를 따로따로 팠습니다. 이제 이 조각들을 하나의 진단 체계로 묶어, “느려요”가 들어왔을 때 어느 자원의 어느 계층을 어떤 도구로 들여다볼지를 정합니다. 추측을 측정으로 바꾸는 것이 이 시리즈 전체의 결론입니다.
추측을 막는 틀: USE 방법론
브렌던 그레그(Brendan Gregg)의 USE 방법론은 막막함을 없애는 체크리스트입니다. 모든 자원(CPU·메모리·디스크·네트워크·인터커넥트)에 대해 세 가지를 묻습니다.
- Utilization(이용률): 그 자원이 일하는 시간의 비율. CPU 90%, 디스크 95% busy처럼.
- Saturation(포화): 처리하지 못해 대기열에 쌓인 정도. run queue 길이, 디스크 I/O 큐, swap 발생.
- Errors(에러): 실패한 작업. 패킷 드롭, 디스크 에러, ECC.
핵심 통찰은 이용률이 100%가 아니어도 포화가 병목을 만든다는 점입니다. CPU가 평균 60%여도 특정 순간 run queue가 길게 쌓이면(saturation) 응답은 느립니다. 그래서 utilization만 보는 모니터링은 병목을 놓칩니다. 아래 흐름도처럼 자원마다 U→S→E를 순서대로 훑어 가장 먼저 빨개지는 곳을 범인으로 좁힙니다.
현실 체크 — “평균은 거짓말을 한다.” CPU 사용률 “평균 50%”는 두 개의 코어가 각각 100%/0%일 때도, 모든 코어가 50%일 때도 같은 값입니다. p99 지연을 죽이는 건 보통 순간 포화와 꼬리 지연이지 평균이 아닙니다. 평균(utilization)에 더해 분포와 큐(saturation)를 같이 보세요 —
vmstat의r(run queue),iostat의aqu-sz(평균 큐 길이)가 그 창입니다.
자원별 도구 지도
USE의 각 칸을 채우는 리눅스 도구를 한 장으로 정리하면 이렇습니다. 외울 건 “어디가 아픈지에 따라 어느 도구를 먼저 잡는가”입니다.
| 자원 | Utilization | Saturation | Errors |
|---|---|---|---|
| CPU | top/mpstat(%us·%sy) | vmstat r, load avg | perf, dmesg(MCE) |
| 메모리 | free, /proc/meminfo | vmstat si/so(swap), OOM | dmesg(OOM kill) |
| 디스크 | iostat -x(%util) | iostat(aqu-sz), %iowait | smartctl, dmesg |
| 네트워크 | sar -n DEV | ss -ti(retrans), drops | ip -s link, ethtool -S |
flowchart TB
S["증상: 느림 / 높은 지연"] --> A{어느 자원?}
A -->|"%sy 높음·run queue↑"| CPU["CPU·스케줄링 → 5·6편"]
A -->|"si/so·major fault↑"| MEM["메모리·스왑 → 12·13편"]
A -->|"%iowait·aqu-sz↑"| DISK["디스크 I/O → 17·18편"]
A -->|"retrans·drops↑"| NET["네트워크 스택"]
CPU --> ROOT["근본원인 → 수정/검증"]
MEM --> ROOT
DISK --> ROOT
NET --> ROOT
load average를 오해하지 말 것
uptime의 load average는 “CPU 사용률”이 아닙니다. 리눅스에서 load는 실행 가능(R) + 중단 불가 대기(D, 보통 디스크 I/O) 상태 태스크의 이동 평균입니다. 그래서 CPU가 한가해도 디스크가 막히면 load가 치솟습니다.
1
2
load 8.0, 코어 4개 → run queue가 코어의 2배로 쌓임(포화). 다만 D상태(I/O)가
섞였다면 CPU가 아니라 디스크가 범인일 수 있다. %iowait를 함께 봐라.
top의 한 줄을 분해해 읽는 습관이 진단의 8할입니다.
1
2
3
4
5
# CPU 시간의 행방: us(유저)·sy(커널/시스템콜·인터럽트)·wa(I/O 대기)·id(유휴)·st(stolen, VM)
top # 또는 mpstat -P ALL 1 로 코어별
vmstat 1 # r(run queue)·b(blocked)·si/so(swap)·wa — 한 화면에 USE 요약
# %sy가 비정상적으로 높다 → 시스템콜·인터럽트·컨텍스트 스위치 과다 의심
pidstat -w 1 # 프로세스별 컨텍스트 스위치 (6편)
%sy(시스템 시간)가 높다는 건 1편에서 본 모드 전환·시스템콜이 과하다는 신호고, %wa가 높다는 건 블록 I/O 병목이라는 신호입니다. 같은 “느림”이라도 어느 칸이 빨간지에 따라 파고들 편이 달라집니다.
어디서 시간을 쓰는가: 프로파일링과 flame graph
자원을 좁혔으면, 다음은 “코드의 어느 경로가 그 자원을 먹는가”입니다. 여기서 샘플링 프로파일러 perf가 등장합니다 — 일정 주기로 콜스택을 표본 추출해, 가장 자주 잡히는 스택이 곧 hot path입니다. 이를 시각화한 것이 flame graph: 가로폭이 그 함수가 CPU에 잡힌 표본 비율(=시간)이고, 세로는 호출 깊이입니다. 넓은 봉우리가 범인입니다.
flame graph를 읽는 규칙은 단순합니다. 좁은 프레임은 무시하고, 바닥부터 넓게 이어지는 봉우리를 찾는다. 위 그림이라면 serialize_json → memcpy가 전체의 절반 가까이를 먹으므로, 직렬화 경로(불필요한 복사·할당)를 먼저 손봐야 합니다. 마이크로 최적화로 좁은 parse()를 갈아봐야 전체 체감은 안 바뀝니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1) 무엇이 CPU를 먹나 — 실시간 함수별 상위
sudo perf top
# 2) 30초 표본 수집 → flame graph (Brendan Gregg FlameGraph)
sudo perf record -F 99 -a -g -- sleep 30
sudo perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg
# 3) 시스템콜이 의심되면 — 횟수·소요시간 집계 (1편의 그 strace)
strace -c -f -p <pid>
# 4) eBPF로 커널까지 거의 무비용 관측 (요즘 표준)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_* { @[probe] = count(); }' # 시스템콜 분포
sudo biolatency-bpfcc # 블록 I/O 지연 히스토그램 (18편)
sudo offcputime-bpfcc # 왜 CPU를 안 쓰고 멈춰있나(off-CPU) — 블로킹 분석
현실 체크 — “CPU가 안 바쁜데 느리다”의 정체. 흔한 함정은 off-CPU 시간입니다. 스레드가 락 대기(8·9편)나 디스크 I/O로 잠들어 있으면 perf의 on-CPU 프로파일엔 안 잡힙니다. 그래서 on-CPU flame graph가 텅 비었는데도 느릴 수 있습니다. 이때는
offcputime으로 “어디서 블로킹돼 잠드는가”를 봐야 진짜 병목이 보입니다.
한 번의 진단을 끝까지: 사례
증상 하나를 USE로 끝까지 추적해 봅시다.
- 증상: API p99 지연이 평소 20ms → 800ms로 튐. CPU 사용률은 평균 45%로 멀쩡.
- 자원 좁히기(USE):
vmstat 1→wa(iowait) 35%,b(blocked) 다수.r은 낮음 → CPU 아님, 디스크 포화 의심. - 계층 좁히기:
iostat -x 1→ 특정 디바이스%util99%,aqu-sz12.pidstat -d 1→ 로그 쓰는 프로세스가 범인. - 근본원인: 동기
fsync를 요청마다 호출(16편 저널링)해 디스크 큐가 포화. 페이지 캐시의 write-back을 못 살리고 있었음. - 수정·검증: 로그를 배치/비동기 flush로 전환 →
%util40%대, p99 25ms 복귀. 추측 없이 숫자로 닫음.
이 흐름 — 증상 → 자원(USE) → 계층 → 근본원인 → 검증 — 이 시리즈 전체가 향한 목적지입니다.
면접/리뷰 단골 질문
- Q. USE 방법론이 뭐고 왜 유용한가? → 모든 자원에 Utilization·Saturation·Errors를 묻는 체크리스트. 막연한 “느림”을 자원 단위로 체계적으로 좁혀 누락 없이 병목을 찾게 해준다. 특히 이용률이 낮아도 포화로 느려지는 경우를 잡는다.
- Q. load average가 높은데 CPU는 한가하다. 왜? → 리눅스 load는 R + D(중단 불가, 주로 디스크 I/O) 태스크 평균이라, I/O가 막히면 CPU가 놀아도 치솟는다.
%iowait/iostat로 디스크를 확인하라. - Q. %us와 %sy의 차이, %sy가 높으면? → us는 유저 코드, sy는 커널(시스템콜·인터럽트·컨텍스트 스위치). sy가 높으면 시스템콜 과다·인터럽트 폭주·잦은 전환을 의심하고 strace -c, perf로 확인한다.
- Q. flame graph는 어떻게 읽나? → 가로폭이 시간 비율, 세로가 콜 깊이. 넓은 봉우리(바닥부터 이어지는)가 hot path. 좁은 프레임 최적화는 효과가 없다.
- Q. perf로 안 보이는 느림은? → off-CPU(락/IO 대기로 잠든) 시간. on-CPU 프로파일러엔 안 잡히므로 offcputime(eBPF)으로 블로킹 지점을 본다.
정리
- 성능 디버깅의 제1원칙은 추측 금지, 측정 우선. “느려요”를 자원·계층·근본원인으로 분해한다.
- USE 방법론(Utilization·Saturation·Errors)으로 자원을 빠짐없이 훑어 병목을 좁힌다 — 이용률보다 포화가 병목을 만든다.
- 도구 지도를 손에: CPU=
top/vmstat/mpstat, 메모리=free/swap, 디스크=iostat, 네트워크=ss/sar. load·%us·%sy·%wa를 분해해 읽어라. - 자원을 좁힌 뒤엔
perf+ flame graph로 hot path를, 안 보이면 off-CPU(eBPF)로 블로킹을 찾는다. - 이 모든 신호는 결국 시리즈의 각 편(스케줄링·페이지 폴트·I/O·캐시·동기화)으로 되돌아간다 — 진단이란 그 지식을 역방향으로 더듬는 일이다.
시리즈를 마치며: 1편에서 운영체제를 “하드웨어를 추상화·중재·보호하는 계층”으로 정의했습니다. 20편을 지나온 지금, 프로세스·스레드·스케줄링은 중재였고, 가상 메모리·페이징·격리는 추상화와 보호였으며, 파일시스템·I/O는 느린 장치를 추상화해 숨기는 일이었습니다. 그리고 이 글의 성능 분석은 그 모든 추상이 현실의 하드웨어와 부딪히는 지점을 보는 법이었습니다. 이 OS라는 토대 위에서, 다음 여정인 네트워크와 백엔드가 돌아갑니다.
