목차
21 장

성능 — cProfile, py-spy, 메모리 프로파일링

느린 파이썬 코드를 찾고 고치는 도구상자 — timeit, cProfile, py-spy, line_profiler, memray, 그리고 흔한 최적화 패턴까지 정리합니다.

3부의 마지막 챕터 — 성능입니다. “느려요"라는 보고를 받았을 때, 어디가 어떻게 느린지 측정하고 고치는 도구상자를 정리합니다. timeit, cProfile, py-spy, line_profiler, memray, 그리고 흔한 최적화 패턴까지입니다.

본 챕터는 19장 GIL과 동시성과 한 쌍입니다. 19장이 “병목 종류에 따른 도구 선택"이라면, 본 챕터는 “병목이 어디인지 측정” 하는 도구입니다. 측정 → 핫스팟 분류 → 도구 선택 → 재측정의 순환이 성능 디버깅의 표준 흐름입니다.

첫 번째 룰 — 측정 없이 최적화하지 마라 #

유명한 인용
"Premature optimization is the root of all evil." — Donald Knuth

읽을 때마다 어쩐지 따분하게 들리지만, 거의 항상 맞습니다. 직관으로 “여기가 느릴 거야"라고 짚으면 70%는 틀립니다. 측정이 첫 단계.

timeit — 작은 단위 측정 #

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로도 가능:

CLI
python -m timeit -s "import json" "json.dumps({'a': 1})"
# 1000000 loops, best of 5: 322 ns per loop

cProfile — 함수 단위 프로파일링 #

CPU 시간이 어디에 쓰이는지 함수별로 보여줍니다.

cProfile 실행
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

출력:

cProfile 출력
   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 #

snakeviz
uv add --dev snakeviz
python -m cProfile -o profile.out myapp.py
uvx snakeviz profile.out

브라우저에서 flame graph 비슷한 형태로 함수 호출 트리를 보겠습니다. 텍스트 출력보다 직관적입니다.

py-spy — 실행 중인 프로세스 프로파일링 #

cProfile의 단점: 코드를 수정해서 감싸야 함. 실행 중인 프로덕션 프로세스에 붙이고 싶을 땐 py-spy가 답입니다.

py-spy
uvx py-spy@latest top --pid 12345
# 또는 새 프로세스 시작
uvx py-spy@latest record -o flame.svg -- python myapp.py

top 모드: 실시간 함수별 CPU 사용률 (top 명령처럼) record 모드: 일정 시간 기록 후 flame graph SVG 생성

py-spy의 가치:

  • 소스 수정 불필요
  • 샘플링 기반 — 오버헤드가 매우 낮음 (5~10%)
  • C 확장도 보임 — NumPy 내부 등도 분석 가능
  • GIL 보유 시간도 표시 — --idle 옵션으로 idle 분석

Production / staging에서 “지금 뭐가 느린지"를 즉석에서 보는 도구입니다.

line_profiler — 줄 단위 프로파일링 #

cProfile은 함수 단위. 함수 안 어느 줄이 느린지 보고 싶을 때.

line_profiler
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 filtered
실행
uv run kernprof -l -v myapp.py

출력:

line_profiler 출력
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가 표준 도구로 역할잡았습니다.

memray
uv add --dev memray
uv run memray run myapp.py     # *.bin 생성
uv run memray flamegraph output.bin  # HTML 보고서

메모리 누수 추적, peak 메모리 사용처, allocation 호출 트리 — 네이티브 메모리까지 추적합니다.

tracemalloc — 표준 라이브러리 #

추가 설치 없이 가능한 가벼운 도구.

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) +=로 문자열 누적 #

🚫 O(n²)
result = ""
for s in strings:
    result += s    # 매번 새 문자열 생성

# ✅ join — O(n)
result = "".join(strings)

문자열은 불변이라 +=가 매번 새 객체를 만듭니다. 큰 문자열일수록 끔찍하게 느려집니다.

3) 리스트에 in으로 검색 #

🚫 list의 in — O(n)
if x in big_list:    # 모든 원소 비교

# ✅ set 으로 변환 — O(1)
big_set = set(big_list)
if x in big_set:

검색 빈도가 높으면 set / dict로 바꾸세요.

4) 잘못된 자료구조 #

자료구조
양 끝에서 push / popcollections.deque (list의 pop(0)은 O(n))
정렬 유지 삽입bisect 모듈
카운트collections.Counter
우선순위 큐heapq
기본값 dictcollections.defaultdict

Python의 표준 라이브러리에 거의 다 있습니다 — 만들지 말고 가져다 쓰세요.

NumPy / 벡터화 #

수치 계산이라면 루프 대신 NumPy가 거의 항상 빠릅니다.

🚫 파이썬 루프
result = [a[i] * b[i] for i in range(len(a))]
✅ NumPy 벡터화
import numpy as np
result = np.array(a) * np.array(b)    # C 레벨에서 동시 처리

100배~1000배 차이가 나는 경우가 흔합니다. 단, 데이터 변환 비용이 있어서 작은 배열에서는 오히려 느릴 수 있습니다. 측정 후 적용.

캐싱 — functools.cache #

12장 데코레이터 패턴에서 본 도구. 같은 인자로 반복 호출되는 순수 함수에 가장 효과적인 최적화.

cache
from functools import cache

@cache
def expensive(n: int) -> int:
    ...

함수가 순수 해야 하고, 인자가 hashable 해야 합니다.

__slots__ — 인스턴스 메모리 절약 #

8장 dataclass와 __slots__에서 본 도구. 객체를 수만 개 만든다면 가장 큰 효과를 봅니다.

dataclass(slots=True)
@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 (19장 GIL과 동시성) — 단일 스레드 5~10% 손실, 멀티 스레드 큰 이득.

상황에 따라 인터프리터 자체를 바꾸는 게 가장 큰 변화일 수 있습니다.

비동기의 성능 #

18장 비동기 깊이의 비동기 코드의 성능을 잴 때:

asyncio 디버그 + 프로파일
PYTHONASYNCIODEBUG=1 uvx py-spy@latest record -o async.svg -- python app.py

py-spy는 비동기 코드도 잘 분석합니다. 어느 코루틴이 어디서 await에 묶이는지 보여줍니다.

실전 — 성능 디버깅 흐름 #

  1. 재현 가능한 벤치마크 — 같은 입력에서 같은 결과가 나와야 측정에 의미가 있습니다.
  2. time으로 전체 시간 확인 — 1초인지 1분인지에 따라 도구 선택
  3. **cProfile 또는 py-spy**로 핫스팟 찾기
  4. **line_profiler**로 핫 함수 안의 줄 단위 분석
  5. 흔한 함정 점검 — list in, 글로벌 lookup, 문자열 누적
  6. 자료구조 변경 — set / deque / Counter 등
  7. 벡터화 — NumPy 적용 가능한지
  8. 캐싱 — 같은 인자 반복 호출인지
  9. C 레벨 확장 — 마지막 수단

각 단계에서 다시 측정 해서 진짜로 빨라졌는지 확인. “이게 빠를 거야” 직관은 자주 틀립니다.

연습문제 #

  1. +=로 1만 개 문자열을 누적하는 코드와 "".join()으로 합치는 코드의 시간을 timeit로 비교하세요. n을 10 / 100 / 10000 / 100000로 늘려가며 O(n²) vs O(n)의 차이가 어디서 분명히 드러나는지 관찰합니다.
  2. cProfile을 실제 코드에 붙여 보세요. (자신의 코드가 없으면 7장의 mathkit 같은 간단한 모듈) 그리고 snakeviz로 시각화. 가장 누적 시간이 큰 함수를 찾아 한 줄 최적화 (자료구조 변경 등) 후 다시 측정해 효과를 확인합니다.
  3. tracemalloc으로 자신의 코드에서 메모리를 가장 많이 잡는 줄을 찾아보세요. 같은 일을 memray로 다시 측정해 두 도구의 출력 차이를 비교합니다.

한 줄 요약: 측정 없는 최적화는 70%가 헛수고. timeit (마이크로) / cProfile + snakeviz (함수) / py-spy (실행 중) / line_profiler (줄) / memray + tracemalloc (메모리)의 도구상자. 흔한 함정은 글로벌 lookup · 문자열 += · list in · 잘못된 자료구조. 자료구조 (deque / bisect / Counter / heapq / defaultdict) → 벡터화 (NumPy) → 캐싱 (@cache) → __slots__ → C 확장 (Cython / PyO3 / mypyc) → 인터프리터 (PyPy / free-threaded). 단계마다 재측정.

3부 마무리 #

3부 7장을 거쳐 깊이 · 동시성 도구상자가 채워졌습니다.

  • 매직 메소드 — 객체와 언어가 만나는 후크
  • 디스크립터 — 속성을 객체화
  • 메타클래스 — 클래스를 만드는 클래스 (보통은 안 씀)
  • 비동기 깊이 — 이벤트 루프, Future / Task, async generator
  • GIL과 동시성 — threading vs multiprocessing vs asyncio + free-threaded
  • typing 고급 — variance, ParamSpec, TypeIs, overload, Annotated
  • 성능 — 측정 도구와 최적화 패턴

이걸로 1부 (입문) → 2부 (구조화) → 3부 (깊이 · 동시성)의 21장이 완료. 다음 4부 실전 FastAPI는 지금까지 다진 도구들이 한 프로젝트에 모이는 단계입니다.

다음 챕터 #

다음 22장 FastAPI 시작과 셋업이 4부의 시작 — 실전 FastAPI 6장 + 신규 2장 (24장 Pydantic v2 깊이, 29장 종합 실습 — TODO API 완성하기)의 첫 챕터입니다. Hello FastAPI, OpenAPI 자동 생성, uv로 첫 프로젝트 셋업까지입니다.

X