파이썬 서버가 느릴 때: py-spy로 5분 안에 병목 찾기
파이썬 API 서버가 느립니다. API 진단 편의 구간 쪼개기로 좁혀 봐도 “기타 로직"이 크거나, 아예 프로세스가 뭘 하는지 모르겠는 상황이 있습니다. 코드 안을 직접 봐야 할 차례인데, 전통적인 cProfile은 코드를 감싸서 다시 실행해야 하고 오버헤드도 커서 운영 환경에는 부담입니다. 이 상황에 맞는 도구가 py-spy입니다. 실행 중인 프로세스에, 코드 수정도 재시작도 없이, 무시할 만한 오버헤드로 붙는 샘플링 프로파일러입니다.
동작 방식: 밖에서 훔쳐봅니다 #
py-spy는 대상 프로세스 안에서 실행되는 코드가 아닙니다. 별도 프로세스가 대상의 메모리를 밖에서 읽어(리눅스의 process_vm_readv) 파이썬 인터프리터의 콜 스택을 초당 수십〜수백 번 복원합니다. 통계적으로 “어느 함수에서 시간이 흐르는지"를 재는 샘플링 방식이라 모든 호출을 기록하는 cProfile과 달리 대상을 거의 느리게 하지 않고, Rust로 만들어져 자체 비용도 작습니다. 프로덕션에 붙여도 되는 이유가 이 구조에 있습니다.
설치는 한 줄이고, 대상 프로세스의 메모리를 읽는 구조라 리눅스에서는 보통 sudo 또는 SYS_PTRACE 권한이 필요합니다.
$ uv tool install py-spy # 또는 pip install py-spydump: “지금 뭐 하는 중인가"를 한 방에 #
가장 싸고 자주 쓰는 명령부터 봅니다. dump는 지금 이 순간의 전체 스레드 콜 스택을 찍어 줍니다.
$ sudo py-spy dump --pid 4321
Process 4321: gunicorn: worker [api]
Thread 4321 (idle): "MainThread"
_worker (psycopg_pool/pool.py:128)
wait (threading.py:320)
Thread 4380 (idle): "ThreadPoolExecutor-0_0"
acquire (psycopg_pool/pool.py:203) ← 커넥션 풀 대기
...멈춘 것처럼 보이는 프로세스, 응답이 없는 워커의 정체가 이 한 장으로 드러납니다. 모든 스레드가 pool.acquire에 서 있으면 커넥션 풀 고갈(데이터베이스 편의 그 증상), lock.acquire면 락 경합, 외부 API의 read면 업스트림 대기입니다. 서버가 느린 이유 #1에서 “off-CPU 분석으로 내려간다"고 했던 그 단계의 파이썬판 입구가 바로 이 명령입니다. 행이 걸린 프로세스라면 dump를 몇 초 간격으로 두세 번 떠서 스택이 같은 자리에 머무는지 보는 것만으로 판정이 됩니다.
top: 실시간으로 보는 함수별 소비 #
top은 이름 그대로 리눅스 top의 함수판입니다. 초당 샘플을 모아 어떤 함수(와 그 하위 호출)가 시간을 먹는지 실시간 갱신합니다.
$ sudo py-spy top --pid 4321
Total Samples 3200, GIL: 62%, Active: 71%, Threads: 4
%Own %Total Function (filename)
24.0% 24.0% _serialize_row (app/serializers.py)
11.5% 38.2% render_items (app/views.py)
8.1% 8.1% loads (json/decoder.py)헤더의 GIL과 Active 비율이 파이썬 특유의 힌트입니다. Active가 낮으면 프로세스가 대부분 대기 중(I/O·락)이라는 뜻이고, GIL이 100% 근처에 붙어 있는데 스레드가 여럿이면 스레드들이 GIL을 다투는 CPU 바운드 워크로드라는 뜻입니다. 후자라면 스레드를 늘려도 소용없고 프로세스 워커를 늘리는 쪽이 답이라는, 구조적 결론까지 여기서 바로 나옵니다.
record: 플레임그래프로 남기기 #
관찰을 공유 가능한 증거로 만들려면 record입니다. 일정 시간 샘플을 모아 플레임그래프 SVG로 저장합니다.
$ sudo py-spy record -o profile.svg --pid 4321 --duration 60
# CPU가 아니라 대기가 의심될 때: off-CPU 시간까지 포함
$ sudo py-spy record -o profile-idle.svg --pid 4321 --duration 60 --idle플레임그래프 읽는 법은 하드웨어 고급 #1에서 다룬 그대로입니다. 가로 폭이 시간 비중, 넓은 봉우리가 병목입니다. 주목할 옵션이 --idle입니다. 기본 record는 CPU를 쓰는 샘플 위주라, DB 응답이나 락을 기다리는 시간은 안 보입니다. --idle을 켜면 대기 중 스택까지 포함되므로, “CPU는 한가한데 느린” 서버의 시간이 어디로 새는지가 그림에 나타납니다. CPU 병목은 기본 모드, 대기 병목은 –idle 모드라고 기억하면 됩니다. 비동기(asyncio) 서버는 스택이 이벤트 루프 중심으로 보이는 한계가 있어, 필요하면 --gil·--threads 옵션과 함께 해석합니다.
실전 순서: 5분 진단 루틴 #
증상별 진입점을 정리하면 이렇게 됩니다.
- 응답이 아예 없다 / 행이 걸렸다:
dump를 2〜3회 뜹니다. 모든 스레드가 같은 대기 지점에 서 있으면 그곳(풀, 락, 외부 호출)이 답입니다. - 느린데 CPU가 높다:
top으로 상위 함수를 확인하고record(기본 모드)로 플레임그래프를 뜹니다. 직렬화, 파싱, 정규식 같은 CPU 소비자가 봉우리로 나옵니다. GIL 비율로 스레드 전략도 함께 판정합니다. - 느린데 CPU가 낮다:
record --idle을 씁니다. 대기(off-CPU)가 그림에 포함되므로, DB, 외부 API, 락 중 어디서 기다리는지가 보입니다. - 컨테이너 환경이라면: 같은 컨테이너에 py-spy가 없어도, 호스트에서 컨테이너 프로세스의 PID로 붙거나,
SYS_PTRACE권한을 준 사이드카·임시 컨테이너에서 붙습니다. 쿠버네티스라면kubectl debug의 임시 컨테이너가 정석 경로입니다.
개발 단계의 정밀 측정(호출 횟수, 정확한 누적 시간)에는 여전히 cProfile이 맞습니다. 도구 지형은 모던 파이썬 고급 #7에서 다뤘고, py-spy의 역할은 운영 중인 프로세스의 지금을 그대로 보여 주는 것입니다.
정리 #
- py-spy는 실행 중인 파이썬 프로세스에 수정·재시작 없이 붙는 샘플링 프로파일러입니다. 밖에서 메모리를 읽는 구조라 프로덕션에 써도 부담이 없습니다.
dump는 행 걸린 프로세스의 정체를 한 장으로 보여 줍니다. 모든 스레드가 서 있는 자리가 곧 병목입니다.top의 GIL·Active 비율은 CPU 바운드인지 대기인지, 스레드 전략이 맞는지까지 알려 줍니다.- CPU 병목은
record기본 모드, 대기 병목은--idle모드로 플레임그래프를 뜹니다. - 리눅스에서는 SYS_PTRACE 권한이 필요하고, 쿠버네티스에서는 kubectl debug 임시 컨테이너가 정석입니다.