포스트

성능 관찰과 병목 분석 — "느려요"를 추측이 아니라 측정으로 (시리즈 마무리)

'느려요'를 추측이 아니라 측정으로 — 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를 순서대로 훑어 가장 먼저 빨개지는 곳을 범인으로 좁힙니다.

자원별 점검 (Utilization → Saturation → Errors) CPU %us %sy / run queue 메모리 free / swap si·so 디스크 ★ %util 98 · 큐 포화 네트워크 retrans / drops 증상: 응답 지연 ① 자원을 차례로 스캔(파랑) → ② 가장 먼저 포화된 자원이 범인(빨강) → ③ 그 계층으로 파고든다 디스크 I/O 병목 확정 → iostat·blktrace로 심화

현실 체크 — “평균은 거짓말을 한다.” CPU 사용률 “평균 50%”는 두 개의 코어가 각각 100%/0%일 때도, 모든 코어가 50%일 때도 같은 값입니다. p99 지연을 죽이는 건 보통 순간 포화와 꼬리 지연이지 평균이 아닙니다. 평균(utilization)에 더해 분포와 큐(saturation)를 같이 보세요 — vmstat의 r(run queue), iostat의 aqu-sz(평균 큐 길이)가 그 창입니다.

자원별 도구 지도

USE의 각 칸을 채우는 리눅스 도구를 한 장으로 정리하면 이렇습니다. 외울 건 “어디가 아픈지에 따라 어느 도구를 먼저 잡는가”입니다.

자원UtilizationSaturationErrors
CPUtop/mpstat(%us·%sy)vmstat r, load avgperf, dmesg(MCE)
메모리free, /proc/meminfovmstat si/so(swap), OOMdmesg(OOM kill)
디스크iostat -x(%util)iostat(aqu-sz), %iowaitsmartctl, dmesg
네트워크sar -n DEVss -ti(retrans), dropsip -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 · 가로폭 = CPU에 잡힌 시간 비율 main() parse() handle_request() auth() serialize_json() memcpy() ← hot 가장 넓은 봉우리 = 시간을 가장 많이 먹는 경로 ↑ 위로 갈수록 깊은 콜스택 · 좁은 프레임(parse·auth)은 무시, 넓은 hot path부터 최적화

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로 끝까지 추적해 봅시다.

  1. 증상: API p99 지연이 평소 20ms → 800ms로 튐. CPU 사용률은 평균 45%로 멀쩡.
  2. 자원 좁히기(USE): vmstat 1 → wa(iowait) 35%, b(blocked) 다수. r은 낮음 → CPU 아님, 디스크 포화 의심.
  3. 계층 좁히기: iostat -x 1 → 특정 디바이스 %util 99%, aqu-sz 12. pidstat -d 1 → 로그 쓰는 프로세스가 범인.
  4. 근본원인: 동기 fsync를 요청마다 호출(16편 저널링)해 디스크 큐가 포화. 페이지 캐시의 write-back을 못 살리고 있었음.
  5. 수정·검증: 로그를 배치/비동기 flush로 전환 → %util 40%대, 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라는 토대 위에서, 다음 여정인 네트워크와 백엔드가 돌아갑니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 운영체제 A-Z (20편)

  1. 운영체제란 무엇인가 — 커널·유저모드·시스템콜, 왜 프로그램은 하드웨어를 직접 못 만지나
  2. 부팅 — 전원 버튼에서 커널까지, 그 1초 사이에 일어나는 모든 일
  3. 프로세스란 무엇인가 — 주소 공간·PCB·fork와 exec, 그리고 COW의 마법
  4. 스레드와 동시성 — 한 주소 공간 속 여러 실행 흐름, 그리고 비결정성의 시작
  5. CPU 스케줄링 — 누구에게 CPU를 줄까, FCFS부터 리눅스 CFS까지
  6. 컨텍스트 스위치 — 전환은 공짜가 아니다, 직접비용과 캐시·TLB 오염
  7. 프로세스 간 통신(IPC) — 격리된 프로세스가 협력하는 법, 파이프부터 공유 메모리까지
  8. 동기화 (1) — 경쟁 상태와 임계 구역, 뮤텍스 vs 스핀락
  9. 동기화 (2) — 세마포어·조건 변수, 그리고 데드락의 네 가지 조건
  10. 가상 메모리 — 모든 프로세스가 같은 주소에서 시작하는데 왜 안 겹치나
  11. 페이징·페이지 테이블·TLB — 4단계 주소 변환과 그 캐시
  12. 페이지 폴트·디맨드 페이징·스왑 — malloc 1GB가 즉시 성공하는 이유
  13. 페이지 교체 알고리즘 — 메모리가 꽉 찼을 때 누구를 쫓아낼까
  14. 메모리 할당자 — malloc은 어떻게 힙을 쪼개나, buddy와 slab까지
  15. 파일 시스템 (1) — 파일명은 파일이 아니다, inode와 VFS의 세계
  16. 파일 시스템 (2) — 쓰는 중 전원이 꺼지면? 저널링과 크래시 일관성
  17. I/O·인터럽트·DMA — CPU보다 백만 배 느린 장치와 효율적으로 대화하기
  18. 블록 I/O와 페이지 캐시 — 같은 파일을 두 번째 읽으면 왜 빠른가
  19. 가상화와 컨테이너 — VM과 컨테이너, 격리 스펙트럼의 양 끝
  20. 성능 관찰과 병목 분석 — "느려요"를 추측이 아니라 측정으로 (시리즈 마무리) 지금 보는 글