모던 파이썬 고급 #7 성능 — cProfile, py-spy, 메모리 프로파일링
고급 시리즈의 마지막 글은 성능입니다. “느려요"라는 보고를 받았을 때, 어디가 어떻게 느린지 측정하고 고치는 도구상자를 정리합니다. timeit, cProfile, py-spy, line_profiler, memray, 그리고 흔한 최적화 패턴까지 다룹니다.
첫 번째 룰 — 측정 없이 최적화하지 마라 #
"Premature optimization is the root of all evil." — Donald Knuth읽을 때마다 어쩐지 따분하게 들리지만, 거의 항상 맞습니다. 직관으로 “여기가 느릴 거야"라고 짚으면 70%는 틀립니다. 측정이 첫 단계입니다.
timeit — 작은 단위 측정
#
import timeit
# 한 줄 측정
t = timeit.timeit("sum(range(1000))", number=10_000)
print(f"평균 {t / 10_000 * 1e6:.2f} μs/회")
# 셋업 코드
t = timeit.timeit(
stmt="d.get('key')",
setup="d = {'key': 1}",
number=1_000_000,
)작은 단위 비교에 씁니다. “list comprehension이 빠를까 map이 빠를까”, “f-string이 +보다 빠른가” 같은 경우입니다.
CLI로도 가능:
python -m timeit -s "import json" "json.dumps({'a': 1})"
# 1000000 loops, best of 5: 322 ns per loopcProfile — 함수 단위 프로파일링
#
CPU 시간이 어디에 쓰이는지 함수별로 보여줍니다.
python -m cProfile -s cumulative myapp.py
# cumulative 시간 순 정렬또는 코드에서:
import cProfile
import pstats
with cProfile.Profile() as pr:
do_work()
stats = pstats.Stats(pr).sort_stats("cumulative")
stats.print_stats(20) # top 20출력:
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.001 0.001 2.345 2.345 myapp.py:10(main)
1000 0.500 0.001 1.800 0.002 myapp.py:50(process_item)
100000 0.700 0.000 0.700 0.000 myapp.py:80(parse_line)읽는 법:
tottime— 그 함수 본문에서 직접 쓴 시간 (자식 함수 제외)cumtime— 그 함수 + 자식 함수 모두의 누적 시간ncalls— 호출 횟수
핫스팟 후보는 tottime이 큰 것, 또는 cumtime이 큰 함수의 부모입니다.
시각화 — snakeviz #
uv add --dev snakeviz
python -m cProfile -o profile.out myapp.py
uvx snakeviz profile.out브라우저에서 flame graph 비슷한 형태로 함수 호출 트리를 봅니다. 텍스트 출력보다 직관적입니다.
py-spy — 실행 중인 프로세스 프로파일링
#
cProfile의 단점: 코드를 수정해서 감싸야 함. 실행 중인 프로덕션 프로세스에 붙이고 싶을 땐 py-spy가 답입니다.
uvx py-spy@latest top --pid 12345
# 또는 새 프로세스 시작
uvx py-spy@latest record -o flame.svg -- python myapp.pytop 모드: 실시간 함수별 CPU 사용률 (top 명령처럼)
record 모드: 일정 시간 기록 후 flame graph SVG 생성
py-spy의 가치:
- 소스 수정 불필요
- 샘플링 기반 — 오버헤드가 매우 낮음 (5~10%)
- C 확장도 보임 — NumPy 내부 등도 분석 가능
- GIL 보유 시간도 표시 —
--idle옵션으로 idle 분석
Production / staging에서 “지금 뭐가 느린지"를 즉석에서 보는 도구입니다.
line_profiler — 줄 단위 프로파일링
#
cProfile은 함수 단위입니다. 함수 안 어느 줄이 느린지 보고 싶을 때 씁니다.
uv add --dev line_profiler대상 함수에 @profile 데코레이터를 붙입니다 (line_profiler가 주입).
@profile
def process(items):
parsed = [parse(x) for x in items] # 줄별 시간 측정
filtered = [x for x in parsed if x.valid]
return filtereduv run kernprof -l -v myapp.py출력:
Line # Hits Time Per Hit % Time Line Contents
==============================================================
2 1 1234567.0 1234567.0 85.3 parsed = [parse(x) for x in items]
3 1 200000.0 200000.0 13.8 filtered = [x for x in parsed if x.valid]
4 1 12000.0 12000.0 0.8 return filtered함수의 어느 줄이 시간 비중을 차지하는지 한눈에 보입니다. 세부 최적화에 매우 유용합니다. 다만 측정 오버헤드가 크니 (계측 코드 삽입), 핫스팟이 좁혀진 후에 사용하세요.
메모리 프로파일링 — memray
#
CPU만큼 자주 측정해야 하는 것이 메모리입니다. Bloomberg의 memray가 표준 도구로 자리 잡았습니다.
uv add --dev memray
uv run memray run myapp.py # *.bin 생성
uv run memray flamegraph output.bin # HTML 보고서메모리 누수 추적, peak 메모리 사용처, allocation 호출 트리는 물론 네이티브 메모리까지 추적합니다.
tracemalloc — 표준 라이브러리
#
추가 설치 없이 쓸 수 있는 가벼운 도구입니다.
import tracemalloc
tracemalloc.start()
# ... 작업 ...
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
for stat in top_stats[:10]:
print(stat)지금 메모리를 어디서 가장 많이 잡고 있는지 줄 단위로 보여줍니다. 가벼운 첫 단계로 좋습니다.
CPython의 흔한 성능 함정 #
1) 글로벌 변수 vs 로컬 변수 #
함수 안에서 글로벌을 자주 참조하면 느립니다. 변수에 한 번 받아 쓰는 게 빠릅니다.
def process(items):
return [math.sqrt(x) for x in items] # math, math.sqrt 매번 lookup
# ✅ 로컬에 받기
def process(items):
sqrt = math.sqrt
return [sqrt(x) for x in items]작은 차이지만 핫 루프에서는 의미 있습니다.
2) +=로 문자열 누적
#
result = ""
for s in strings:
result += s # 매번 새 문자열 생성
# ✅ join — O(n)
result = "".join(strings)문자열은 불변이라 +=가 매번 새 객체를 만듭니다. 큰 문자열일수록 끔찍하게 느려집니다.
3) 리스트에 in으로 검색
#
if x in big_list: # 모든 원소 비교
# ✅ set으로 변환 — O(1)
big_set = set(big_list)
if x in big_set:검색 빈도가 높으면 set/dict로 바꾸세요.
4) 잘못된 자료구조 #
| 일 | 자료구조 |
|---|---|
| 양 끝에서 push/pop | collections.deque (list의 pop(0)은 O(n)) |
| 정렬 유지 삽입 | bisect 모듈 |
| 카운트 | collections.Counter |
| 우선순위 큐 | heapq |
| 기본값 dict | collections.defaultdict |
Python의 표준 라이브러리에 거의 다 있습니다. 직접 만들지 말고 가져다 쓰세요.
NumPy / 벡터화 #
수치 계산이라면 루프 대신 NumPy가 거의 항상 빠릅니다.
result = [a[i] * b[i] for i in range(len(a))]import numpy as np
result = np.array(a) * np.array(b) # C 레벨에서 동시 처리100배 ~ 1000배 차이가 나는 경우가 흔합니다. 단, 데이터 변환 비용이 있어서 작은 배열에서는 오히려 느릴 수 있습니다. 측정 후 적용.
캐싱 — functools.cache
#
중급 #5에서 본 도구입니다. 같은 인자로 반복 호출되는 순수 함수에 가장 효과적인 최적화입니다.
from functools import cache
@cache
def expensive(n: int) -> int:
...함수가 순수해야 하고, 인자가 hashable해야 합니다.
__slots__ — 인스턴스 메모리 절약
#
중급 #1에서 본 도구입니다. 객체를 수만 개 만든다면 가장 큰 효과를 봅니다.
@dataclass(slots=True)
class Point:
x: float
y: float인스턴스당 40~50% 메모리 절약, 속성 접근 10~25% 가속.
Cython / Rust 확장 — 마지막 무기 #
순수 파이썬으로 안 되면 C 레벨로 내려갑니다.
- Cython — 파이썬과 비슷한 문법으로 C 컴파일. 점진적 변환 가능.
- PyO3 (Rust) — Rust로 확장 모듈 작성.
maturin빌드 도구. - mypyc — 타입 힌트 있는 파이썬을 C로 컴파일 (mypy 자체가 이 방식).
공통 룰: 핫스팟만. 전체 코드를 옮기지 말고, cProfile로 찾은 좁은 부분만 옮기는 게 비용 대비 효과가 큽니다.
다른 인터프리터 — 한 번 점검 #
- PyPy — JIT 컴파일러 가진 별도 구현. 순수 파이썬 코드는 종종 5~10배 빨라집니다. C 확장 호환성이 약점이라 NumPy/Pandas 헤비 코드에는 안 맞습니다.
- Free-threaded CPython (#5) — 단일 스레드 5~10% 손실, 멀티 스레드 큰 이득.
상황에 따라 인터프리터 자체를 바꾸는 게 가장 큰 변화일 수 있습니다.
비동기의 성능 #
#4의 비동기 코드의 성능을 잴 때:
PYTHONASYNCIODEBUG=1 uvx py-spy@latest record -o async.svg -- python app.pypy-spy는 비동기 코드도 잘 분석합니다. 어느 코루틴이 어디서 await에 묶이는지 보여줍니다.
실전 — 성능 디버깅 흐름 #
- 재현 가능한 벤치마크 — 같은 입력에 같은 결과를 같은 시간에 내야 측정 의미 있음
time으로 전체 시간 확인 — 1초인지 1분인지에 따라 도구 선택- **
cProfile또는py-spy**로 핫스팟 찾기 - **
line_profiler**로 핫 함수 안의 줄 단위 분석 - 흔한 함정 점검 — list
in, 글로벌 lookup, 문자열 누적 - 자료구조 변경 — set/deque/Counter 등
- 벡터화 — NumPy 적용 가능한지
- 캐싱 — 같은 인자 반복 호출인지
- C 레벨 확장 — 마지막 수단
각 단계에서 다시 측정해서 진짜로 빨라졌는지 확인합니다. “이게 빠를 거야"라는 직관은 자주 틀립니다.
정리 + 시리즈 회고 #
이번 글에서 본 도구상자:
timeit— 작은 단위 측정cProfile+snakeviz— 함수 단위 프로파일py-spy— 실행 중 프로세스, 오버헤드 낮음line_profiler— 줄 단위memray+tracemalloc— 메모리- 자주 만나는 함정 — 글로벌 lookup, 문자열
+=, listin, 잘못된 자료구조 - 자료구조:
deque,bisect,Counter,heapq,defaultdict - NumPy 벡터화,
functools.cache,__slots__ - 마지막 수단: Cython, PyO3, mypyc, PyPy, free-threaded CPython
- 흐름: 측정 → 핫스팟 → 자료구조/알고리즘 → 벡터화 → 캐싱 → 확장 → 재측정
시리즈 전체 회고 #
7편에 걸쳐 모던 파이썬 고급의 도구를 모두 다뤘습니다.
- 매직 메소드 — 객체와 언어가 만나는 후크
- 디스크립터 — 속성을 객체화
- 메타클래스 — 클래스를 만드는 클래스 (보통은 안 씀)
- 비동기 깊이 — 이벤트 루프, Future/Task, async generator
- GIL과 동시성 — threading vs multiprocessing vs asyncio + free-threaded
- typing 고급 — variance, ParamSpec, TypeIs, overload, Annotated
- 성능 — 측정 도구와 최적화 패턴
이걸로 모던 파이썬 기초 → 중급 → 고급의 21편이 완료됐습니다. 다음 시리즈는 **모던 파이썬 실전 — FastAPI로 API 만들기 (6편)**입니다. 지금까지 다진 도구들이 한 프로젝트에 모이는 단계입니다.
- 시작과 셋업 — Hello FastAPI, OpenAPI 자동 생성
- 라우팅, Pydantic 모델, 의존성 주입
- DB 연동 — SQLAlchemy 2.x + Alembic
- 인증 — OAuth2 패스워드 플로우 + JWT
- 비동기와 백그라운드 작업
- 테스트와 배포 — pytest, Docker, Railway/Fly