Python 데이터 처리 스크립트 최적화 사례 | cProfile로 병목 찾고 NumPy·Cython·multiprocessing 비교하기
이 글의 핵심
처리 시간이 너무 길어진 스크립트를 앞에 두고 무엇부터 고칠지 막막할 때, 작은 샘플로 기준선을 잡고 프로파일링으로 병목을 좁혀 가는 과정을 그대로 따라갑니다. NumPy 벡터화가 왜 빠른지, Cython과 멀티프로세싱은 어떤 비용을 치르고 얻는 이득인지 짚어 비슷한 문제에 적용할 순서를 제시합니다.
들어가며
“Python은 느려서 프로덕션에 못 쓴다”는 말을 자주 듣습니다. 하지만 올바른 최적화 기법을 적용하면 충분히 빠릅니다. 이 글에서는 10시간 넘게 걸리던 데이터 처리 스크립트를 몇 분 단위로 줄인 과정을 공유합니다. 본문의 수치는 모두 이 글의 측정(이 스크립트와 데이터 기준)이며, 어떤 단계가 왜 효과가 있었는지에 초점을 맞춥니다. 일상에 빗대면, 손으로 영수증 한 장씩 더하는 것을 계산기 한 번에 합계 내기로 바꾼 것과 비슷합니다. 언어가 느려서라기보다 같은 일을 너무 자주 반복한 경우가 많습니다.
데이터 처리를 공장 라인에 비유하면, 처음 코드는 제품 하나마다 손으로 공정을 반복한 것에 가깝으며, 벡터화·병렬화는 한 번에 한 묶음을 처리하거나 라인을 여러 개 두는 쪽에 가깝습니다.
문제: 데이터 처리가 너무 느림
상황
문제의 본질은 “한 줄 처리 로직이 틀렸다”가 아니라, 100만 행에 대해 순수 Python 루프와 반복 할당이 누적되어 야간 배치가 SLA를 넘긴다는 점이었습니다. CSV 파일(100만 행)을 처리하는 스크립트가 지나치게 느렸습니다.
# process_data.py
import csv
def process_file(filename):
with open(filename) as f:
reader = csv.DictReader(f)
results = []
for row in reader:
# 각 행에 대해 복잡한 계산
result = calculate(row)
results.append(result)
return results
def calculate(row):
# 중첩 루프로 계산
total = 0
for i in range(1000):
for j in range(100):
total += float(row['value']) * i * j
return total
실행 시간
$ time python process_data.py input.csv
real 10h 23m 45s # 10시간!
측정: 기준선 설정
작은 샘플로 테스트
# 1000행만 처리
import time
start = time.time()
results = process_file('sample_1000.csv')
elapsed = time.time() - start
print(f"1000 rows: {elapsed:.2f}s")
# 출력: 1000 rows: 37.23s
# 예상 시간 계산
estimated_hours = (37.23 * 1000000 / 1000) / 3600
print(f"Estimated for 1M rows: {estimated_hours:.1f} hours")
# 출력: Estimated for 1M rows: 10.3 hours
기준선을 작은 샘플로 잡는 이유는 두 가지입니다. 첫째, 10시간짜리 스크립트로는 최적화 한 번의 효과를 확인하는 데 하루가 걸립니다. 1000행 샘플이면 수십 초 안에 반복 측정할 수 있습니다. 둘째, 이렇게 선형 외삽이 가능한지 자체가 중요한 정보입니다. 행마다 하는 일이 독립적이고 비슷하다면 시간은 행 수에 비례하지만, 중복 제거나 조인처럼 행끼리 비교하는 로직이 섞여 있으면 O(n²)이 되어 1000행 결과로 100만 행을 예측할 수 없습니다. 이때는 1000, 10000, 100000행으로 세 번 재 보고 증가 비율을 확인하는 것이 안전합니다.
측정에는 time.time()보다 time.perf_counter()가 적합합니다. time.time()은 시스템 시계라 NTP 동기화로 값이 뒤로 갈 수도 있고 해상도도 플랫폼에 따라 낮습니다. 또 첫 실행은 파일 캐시, 모듈 import, 워밍업 비용이 섞이므로 같은 측정을 몇 번 반복해 가장 작은 값이나 중앙값을 보는 편이 흔들림이 적습니다.
프로파일링: cProfile로 병목 찾기
cProfile 실행
$ python -m cProfile -o profile.stats process_data.py sample_1000.csv
-m cProfile: 표준 라이브러리 cProfile을 모듈로 실행합니다.-o profile.stats: 프로파일 결과를 바이너리 파일로 저장해 나중에pstats로 정렬·필터링하기 쉽게 합니다.
결과 분석
import pstats
stats = pstats.Stats('profile.stats')
stats.sort_stats('cumulative')
stats.print_stats(10)
sort_stats('cumulative'): 함수가 호출 스택 전체에서 누적한 시간이 큰 순으로 봅니다(“어디서 시간이 새는지” 찾기에 적합합니다).print_stats(10): 상위 10개 함수만 출력합니다.
ncalls tottime percall cumtime percall filename:lineno(function)
1000 35.234 0.035 35.234 0.035 process_data.py:15(calculate)
발견: calculate 함수가 전체 시간의 94% 차지!
출력을 읽을 때는 tottime과 cumtime을 구분해야 합니다. tottime은 그 함수 자신의 코드에서 보낸 시간, cumtime은 그 함수가 호출한 하위 함수까지 포함한 시간입니다. calculate처럼 둘이 거의 같다면 시간이 함수 본문(여기서는 중첩 루프) 자체에서 쓰이고 있다는 뜻이고, cumtime만 크고 tottime이 작다면 병목은 그 함수가 부르는 다른 곳에 있습니다. 또 cProfile은 모든 함수 호출에 훅을 걸기 때문에 호출이 아주 많은 코드에서는 실제보다 느려지고 비율도 왜곡됩니다. 이 스크립트처럼 루프 안에서 float()를 1억 번 부르면 그 호출 비용 자체가 과장되어 보일 수 있으므로, 함수 단위 병목을 찾은 뒤에는 line_profiler(kernprof -l)로 줄 단위로 확인하거나, 운영 중인 프로세스에 붙일 수 있는 샘플링 프로파일러 py-spy로 교차 확인하는 것이 좋습니다.
병목 1: 중첩 루프
문제 분석
def calculate(row):
total = 0
for i in range(1000): # 1000번
for j in range(100): # × 100번
total += float(row['value']) * i * j # = 100,000번
return total
# 1000행 × 100,000번 = 1억 번 연산!
복잡도
- 시간 복잡도: O(n × 1000 × 100) = O(n × 100,000)
- n = 1,000,000: 1000억 번 연산
그런데 이 루프를 가만히 보면 더 근본적인 사실이 드러납니다. total += value * i * j를 모든 i, j에 대해 더한 값은 value × (0+1+…+999) × (0+1+…+99), 즉 value × 499,500 × 4,950 = value × 2,472,525,000입니다. i * j의 합은 행과 무관한 상수이므로 한 번만 계산하면 되고, 행마다 할 일은 곱셈 한 번뿐입니다. 게다가 float(row['value'])가 안쪽 루프 안에 있어서 같은 문자열을 행마다 10만 번씩 다시 변환하고 있었습니다. 실무 코드에서도 “루프 안에서 매번 같은 값을 다시 계산하는” 패턴이 병목의 상당수를 차지합니다. 다음 절의 NumPy 버전이 극적으로 빠른 이유의 대부분은 사실 이 상수 끌어올리기(loop-invariant hoisting) 에 있고, 순수 Python에서도 factor = 2472525000; return [float(r['value']) * factor for r in reader]로 바꾸기만 해도 비슷한 차원의 개선이 나옵니다. 벡터화의 효과를 제대로 평가하려면 이 둘을 분리해서 봐야 합니다.
최적화 1: NumPy 벡터화
NumPy로 전환
import numpy as np
import pandas as pd
def process_file_numpy(filename):
# Pandas로 CSV 읽기 (C로 구현되어 빠름)
df = pd.read_csv(filename)
# NumPy 벡터 연산
values = df['value'].values # NumPy array
# 중첩 루프를 벡터 연산으로
i_range = np.arange(1000)
j_range = np.arange(100)
# 외적 (outer product)
ij_product = np.outer(i_range, j_range).sum()
# 벡터화 연산 (브로드캐스팅)
results = values * ij_product
return results
# 테스트
start = time.time()
results = process_file_numpy('sample_1000.csv')
elapsed = time.time() - start
print(f"NumPy: {elapsed:.2f}s")
# 출력: NumPy: 0.23s (37.23s → 0.23s, 162배 개선!)
왜 빠른가?
- C로 구현: NumPy는 내부적으로 C/Fortran
- 벡터화: 루프를 CPU 벡터 명령어로 처리
- 메모리 효율: 연속된 메모리 블록 사용
순수 Python 루프가 느린 가장 큰 이유는 연산 자체가 아니라 연산을 둘러싼 인터프리터 비용입니다. total += value * i * j 한 줄마다 인터프리터는 바이트코드를 해석하고, i와 j의 타입을 확인하고, 결과로 새 int/float 객체를 힙에 만들고, 이전 객체의 참조 카운트를 줄입니다. NumPy의 values * ij_product는 이 과정을 배열 전체에 대해 한 번만 하고, 실제 곱셈은 타입이 고정된 연속 메모리 위에서 C 루프(가능하면 SIMD 명령)로 처리합니다. 그래서 배열이 클수록 격차가 커지고, 반대로 원소가 몇 개뿐이라면 NumPy 함수 호출 오버헤드 때문에 순수 Python이 더 빠르기도 합니다.
이 예제의 1000행 측정값 대부분은 계산이 아니라 pd.read_csv와 import 시간입니다. 행 수가 적을 때는 CSV 파싱이 전체를 지배하므로, 100만 행에서 병목이 무엇인지는 다시 재 봐야 합니다. 주의할 점도 있습니다. 벡터화는 중간 결과를 배열 전체 크기로 메모리에 만든다는 비용을 치릅니다. a * b + c는 임시 배열을 두 개 만들므로, 수천만 행 이상에서는 메모리 부족이 새 병목이 될 수 있고, 이때는 청크 단위 처리(pd.read_csv(..., chunksize=...))나 np.multiply(a, b, out=a) 같은 제자리 연산을 고려합니다.
병목 2: 문자열 연산
추가 문제 발견
def format_results(results):
output = ""
for r in results:
output += f"{r}\n" # 🚨 문자열 += 반복
return output
# 1,000,000행 → 1,000,000번 문자열 재할당
최적화
# 방법 1: join 사용
def format_results(results):
return "\n".join(str(r) for r in results)
# 방법 2: StringIO
from io import StringIO
def format_results(results):
output = StringIO()
for r in results:
output.write(f"{r}\n")
return output.getvalue()
문자열은 불변 객체라 output += ...는 원칙적으로 매번 새 문자열을 만들고 기존 내용을 복사하므로 전체 비용이 O(n²)이 됩니다. 다만 CPython은 문자열 참조가 하나뿐일 때 제자리에서 늘리는 최적화를 하기 때문에, 단순한 로컬 변수 +=는 생각보다 덜 느린 경우가 많습니다. 이 최적화는 CPython 구현 세부라 PyPy 등에서는 적용되지 않고, 다른 변수가 같은 문자열을 참조하는 순간 꺼지므로 믿고 쓸 성질은 아닙니다. "\n".join()은 전체 길이를 먼저 계산해 한 번에 할당하므로 어떤 구현에서도 선형이고, 결과를 파일로 쓸 거라면 문자열을 만들지 말고 f.writelines()나 df.to_csv()로 바로 쓰는 편이 메모리도 아낍니다. 이 사례의 결과표에서 문자열 단계의 개선이 작았던 것도, 전체 시간 대비 이 부분의 비중이 원래 작았기 때문입니다.
최적화 2: Cython 적용
Cython 코드
# calculate.pyx
cimport cython
@cython.boundscheck(False) # 경계 검사 제거
@cython.wraparound(False) # 음수 인덱스 제거
def calculate_cython(double value):
cdef long i, j
cdef double total = 0.0
for i in range(1000):
for j in range(100):
total += value * i * j
return total
빌드 및 사용
# setup.py
from setuptools import setup
from Cython.Build import cythonize
setup(
ext_modules=cythonize("calculate.pyx")
)
cythonize:.pyx를 C 확장 모듈로 컴파일할 소스 목록으로 바꿉니다.
$ python setup.py build_ext --inplace
build_ext: Cython이 만든 확장 모듈을 빌드합니다.--inplace: 빌드 산출물을 소스 옆(패키지 트리 안)에 두어import calculate가 바로 되게 합니다.
# 사용
from calculate import calculate_cython
def process_file_cython(filename):
df = pd.read_csv(filename)
results = [calculate_cython(v) for v in df['value']]
return results
Cython이 빨라지는 이유는 cdef로 선언한 i, j, total이 Python 객체가 아니라 C의 long, double이 되어 루프가 순수 C 코드로 번역되기 때문입니다. 타입 선언을 빠뜨리면 Cython은 여전히 Python 객체로 연산하는 코드를 만들어 기대만큼 빨라지지 않습니다. cython -a calculate.pyx로 HTML 주석 보고서를 만들면 Python API를 호출하는 줄이 노란색으로 표시되므로, 노란 줄을 없애는 것이 최적화의 기준이 됩니다. 참고로 위 코드의 boundscheck, wraparound 데코레이터는 배열 인덱싱이 있을 때만 의미가 있고, 이 함수처럼 인덱싱이 없는 루프에는 효과가 없습니다.
이 단계에서 치르는 비용도 분명합니다. C 컴파일러가 필요해 Windows 개발 환경이나 CI 설정이 복잡해지고, 배포 시 플랫폼별 wheel을 빌드해야 합니다. 그리고 이 예제는 여전히 [calculate_cython(v) for v in df['value']]처럼 행마다 Python에서 함수를 호출하므로 호출 오버헤드가 남습니다. 앞 절에서 본 것처럼 루프 자체가 상수로 줄어드는 문제라면 Cython은 과한 선택이고, 알고리즘으로 줄일 수 없는 복잡한 행 단위 로직(조건 분기가 많은 상태 기계 등)일 때 가치가 있습니다.
최적화 3: 멀티프로세싱
병렬 처리
from multiprocessing import Pool
import numpy as np
def process_chunk(chunk, factor):
"""청크 단위로 처리 (워커에서 실행되므로 모듈 최상위에 정의)"""
return chunk * factor
def process_file_parallel(filename, num_workers=4):
df = pd.read_csv(filename)
values = df['value'].values
ij_product = np.outer(np.arange(1000), np.arange(100)).sum()
# 청크로 분할
chunks = np.array_split(values, num_workers)
# 병렬 처리: 상수는 전역 변수 대신 인자로 함께 넘깁니다
with Pool(num_workers) as pool:
results = pool.starmap(process_chunk, [(c, ij_product) for c in chunks])
# 결과 합치기
return np.concatenate(results)
멀티프로세싱이 스레드 대신 쓰이는 이유는 GIL입니다. 프로세스마다 별도의 인터프리터와 GIL을 가지므로 순수 Python 연산도 코어 수만큼 동시에 돌 수 있습니다. 대신 프로세스 사이에는 메모리가 공유되지 않아, starmap에 넘기는 청크와 결과는 모두 pickle로 직렬화되어 파이프로 복사됩니다. 이 예제처럼 청크마다 곱셈 한 번만 하는 작업은 계산보다 데이터 복사가 더 오래 걸리고, macOS와 Windows의 기본 시작 방식인 spawn은 워커마다 인터프리터를 새로 띄우고 모듈을 다시 import하므로 시작 비용도 큽니다. 제가 이런 배치 스크립트를 다룰 때 가장 흔히 본 실수도 “코어가 8개니 8배 빨라지겠지”라는 기대로 Pool을 먼저 붙이는 것이었는데, 대개는 아래 결과표처럼 오히려 느려집니다. 병렬화는 청크당 계산 시간이 직렬화·전송 시간보다 충분히 클 때만 이득입니다.
최종 결과
단계별 개선
| 단계 | 방법 | 전체 실행 시간에 대한 효과 |
|---|---|---|
| 0 | 초기 (Pure Python) | 기준선 (1000행 샘플에서 외삽한 약 10시간) |
| 1 | 상수 끌어올리기 + NumPy 벡터화 | 대부분의 개선이 여기서 발생 (시간 단위 → 분 이하) |
| 2 | 문자열 최적화 | 소폭 개선 |
| 3 | 멀티프로세싱 | 오히려 느려짐 |
해석: 이 사례에서 효과의 거의 전부는 1단계에서 나왔습니다. 원소마다 파이썬 인터프리터가 반복하던 계산이 C로 구현된 배열 연산 한 번으로 바뀌었기 때문입니다. 반면 3단계 멀티프로세싱은 전체 실행 시간을 오히려 늘렸습니다. 벡터화 후에는 계산 자체가 이미 짧아서, 프로세스를 띄우고 청크를 pickle로 직렬화해 보내고 결과를 다시 받아 합치는 비용이 병렬화 이득보다 컸던 것입니다(계산 부분만 따로 떼어 마이크로 벤치마크로 재면 빨라 보여도, 전체 파이프라인에서는 결과가 달라질 수 있습니다). 멀티프로세싱은 청크당 계산이 충분히 무거울 때만 도입하고, 반드시 전체 실행 시간으로 비교하세요.
최종 코드
import pandas as pd
import numpy as np
from multiprocessing import Pool
def scale_chunk(chunk, factor):
# Pool은 작업 함수를 pickle로 워커에 보내므로 lambda가 아닌 모듈 최상위 함수로 둡니다
return chunk * factor
def process_file_optimized(filename, num_workers=4):
# Pandas로 빠른 CSV 읽기
df = pd.read_csv(filename)
values = df['value'].values
# 계산 상수 (한 번만)
i_range = np.arange(1000)
j_range = np.arange(100)
ij_product = np.outer(i_range, j_range).sum()
# 청크 분할
chunks = np.array_split(values, num_workers)
# 병렬 처리
with Pool(num_workers) as pool:
results = pool.starmap(
scale_chunk,
[(c, ij_product) for c in chunks]
)
return np.concatenate(results)
if __name__ == "__main__": # Windows/macOS의 spawn 방식에서는 이 가드가 필수입니다
process_file_optimized("input.csv")
위 결과표처럼 이 데이터 크기에서는 병렬화가 오히려 느렸으므로, 단일 프로세스 NumPy 버전과 전체 실행 시간을 비교한 뒤 선택하는 것이 좋습니다.
추가 최적화 팁
PyPy 사용
# PyPy로 실행 (JIT 컴파일)
$ pypy3 process_data.py
# 순수 Python 코드는 PyPy가 더 빠를 수 있음
# NumPy는 CPython이 더 나음
Numba 사용
from numba import jit
@jit(nopython=True)
def calculate_numba(value):
total = 0.0
for i in range(1000):
for j in range(100):
total += value * i * j
return total
# 루프 코드를 거의 그대로 둔 채 네이티브 코드로 컴파일
Numba는 첫 호출 때 함수를 LLVM으로 컴파일하므로 첫 호출이 느립니다(수백 ms~수 초). 짧게 끝나는 CLI 스크립트라면 이 컴파일 시간이 이득을 상쇄할 수 있어 @jit(cache=True)로 컴파일 결과를 디스크에 캐시하는 옵션을 함께 씁니다. nopython=True 모드는 Numba가 이해하는 타입(숫자, NumPy 배열, 일부 컨테이너)만 다룰 수 있어, 함수 안에서 pandas DataFrame이나 딕셔너리를 자유롭게 쓰면 TypingError로 컴파일이 실패합니다. 계산 핵심만 숫자 배열을 받는 작은 함수로 떼어 내는 것이 Numba를 잘 쓰는 방법입니다.
메모리 매핑
# 큰 파일은 메모리 매핑
import mmap
with open('huge_file.bin', 'r+b') as f:
mmapped = mmap.mmap(f.fileno(), 0)
# 필요한 부분만 읽기
data = mmapped[1000:2000]
마무리
Python 성능 최적화의 핵심:
- 측정 먼저: 추측하지 말고 프로파일링
- 알고리즘 개선: 복잡도를 먼저 줄이기
- NumPy 벡터화: 루프를 벡터 연산으로
- 병렬화: CPU 코어 활용
- Cython/Numba: 핫스팟만 최적화 핵심: 순수 파이썬 루프는 느리지만, 무거운 반복을 NumPy·Pandas 같은 C 구현으로 넘기면 대부분의 데이터 처리 작업은 충분히 빨라집니다.
프로덕션에 옮기기 전에 점검할 것
- 수치 동일성: 벡터화·병렬화 후에도 허용 오차 안에서 결과가 맞는지 테스트로 고정합니다.
- 메모리: 대형 배열을 여러 번 복사하면 RAM이 병목이 될 수 있습니다.
- 배치 SLA: 야간 배치라면 총 시간뿐 아니라 피크 시간 DB 부하도 함께 봅니다.
FAQ
Q1. NumPy vs Pandas 중 뭘 써야 하나요? Pandas는 데이터프레임 조작에, NumPy는 수치 계산에 특화되어 있습니다. 둘을 조합하여 사용하세요.
Q2. 멀티스레딩 vs 멀티프로세싱? Python GIL 때문에 CPU 바운드 작업은 멀티프로세싱을, I/O 바운드는 멀티스레딩을 사용하세요.
Q3. Cython vs Numba? Cython은 유연하지만 빌드가 필요하며, Numba는 간단하지만 NumPy 스타일 코드에 최적화되어 있습니다.