모던 파이썬 고급 #7 성능 — cProfile, py-spy, 메모리 프로파일링

7 분 소요

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

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

유명한 인용
"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 #

중급 #5에서 본 도구입니다. 같은 인자로 반복 호출되는 순수 함수에 가장 효과적인 최적화입니다.

cache
from functools import cache

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

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

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

중급 #1에서 본 도구입니다. 객체를 수만 개 만든다면 가장 큰 효과를 봅니다.

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 (#5) — 단일 스레드 5~10% 손실, 멀티 스레드 큰 이득.

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

비동기의 성능 #

#4의 비동기 코드의 성능을 잴 때:

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 레벨 확장 — 마지막 수단

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

정리 + 시리즈 회고 #

이번 글에서 본 도구상자:

  • timeit — 작은 단위 측정
  • cProfile + snakeviz — 함수 단위 프로파일
  • py-spy — 실행 중 프로세스, 오버헤드 낮음
  • line_profiler — 줄 단위
  • memray + tracemalloc — 메모리
  • 자주 만나는 함정 — 글로벌 lookup, 문자열 +=, list in, 잘못된 자료구조
  • 자료구조: 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편)**입니다. 지금까지 다진 도구들이 한 프로젝트에 모이는 단계입니다.

  1. 시작과 셋업 — Hello FastAPI, OpenAPI 자동 생성
  2. 라우팅, Pydantic 모델, 의존성 주입
  3. DB 연동 — SQLAlchemy 2.x + Alembic
  4. 인증 — OAuth2 패스워드 플로우 + JWT
  5. 비동기와 백그라운드 작업
  6. 테스트와 배포 — pytest, Docker, Railway/Fly
X