py-spy 및 cProfile을 사용한 Python 웹 애플리케이션 병목 현상 식별
Emily Parker
Product Engineer · Leapcell

소개
웹 개발의 활기찬 세계에서 Python은 강력하고 확장 가능한 애플리케이션을 구축하기 위한 필수 언어로 자리 잡았습니다. 그러나 애플리케이션이 복잡해지고 사용자 트래픽이 증가함에 따라 성능은 중요한 문제가 됩니다. 느린 웹 애플리케이션은 사용자 경험 저하, 인프라 비용 증가, 궁극적으로는 불만을 초래할 수 있습니다. 이러한 성능 병목 현상을 식별하고 해결하는 것은 건강하고 효율적인 애플리케이션을 유지하는 데 매우 중요합니다. 이를 위해서는 종종 애플리케이션의 런타임 동작을 깊이 파고들어 시간이 어디에서 소비되는지 이해해야 합니다. 이 기사에서는 이러한 작업을 위해 py-spy
와 cProfile
이라는 두 가지 강력하고 독특한 도구를 탐색합니다. 즉, 실행 중인 Python 웹 애플리케이션의 성능 병목 현상을 분석하는 것입니다. 방법론, 실제 적용 및 이점을 얻고 코드를 최적화하기 위해 이 도구를 활용하는 방법을 논의할 것입니다.
성능 프로파일링 도구 이해
py-spy
와 cProfile
의 구체적인 내용을 살펴보기 전에 성능 프로파일링과 관련된 몇 가지 핵심 개념을 이해하는 것이 중요합니다.
프로파일링: 프로파일링은 프로그램의 공간(메모리) 또는 시간 복잡도, 특정 명령어 사용량 또는 함수 호출 빈도 및 기간과 같은 것을 측정하는 동적 프로그램 분석의 한 형태입니다. 목표는 프로그램 실행에 대한 통계를 수집하여 성능 병목 현상을 식별하는 것입니다.
CPU 바운드 대 I/O 바운드:
- CPU 바운드: 프로그램이 계산(예: 복잡한 수학 연산, 데이터 처리)을 수행하는 데 대부분의 시간을 소비하고 외부 리소스를 기다리는 데는 거의 시간을 소비하지 않으면 CPU 바운드라고 합니다.
- I/O 바운드: 프로그램이 입력/출력 작업(예: 데이터베이스 읽기, 네트워크 요청, 파일 액세스) 완료를 기다리는 데 대부분의 시간을 소비하면 I/O 바운드라고 합니다.
호출 스택: 호출 스택은 프로그램 실행에서 호출되었지만 아직 반환되지 않은 함수의 순서 목록입니다. 함수가 호출되면 스택에 푸시되고, 반환되면 스택에서 팝됩니다.
cProfile: 인프로세스 결정론적 프로파일러
cProfile
은 Python의 내장 C 구현 결정론적 프로파일러입니다. 모든 함수 호출의 정확한 시작 및 종료 시간을 기록하고 이러한 통계를 집계하기 때문에 "결정론적"입니다. 이는 호출 횟수, 함수에서 소비된 총 시간(하위 호출 포함) 및 해당 함수 내에서만 소비된 시간(하위 호출 제외)을 포함한 매우 정확한 데이터를 제공합니다.
cProfile 작동 방식
cProfile
은 Python 코드를 계측하여 작동합니다. 코드 블록이나 전체 스크립트에 대해 cProfile
을 실행하면 본질적으로 타이밍 메커니즘으로 각 함수 호출을 래핑합니다. 이를 통해 각 함수에서 얼마나 많은 시간이 소비되는지에 대한 자세한 정보를 수집할 수 있습니다.
cProfile을 사용한 실제 적용
cProfile
은 코드의 특정 섹션을 프로파일링하거나 애플리케이션에 프로파일링을 포함하도록 쉽게 수정할 수 있는 개발 환경에서 사용하기에 이상적입니다.
간단한 Flask 웹 애플리케이션을 고려해 보겠습니다.
# app.py from flask import Flask, jsonify import time app = Flask(__name__) def heavy_computation(n): """CPU 집약적 작업을 시뮬레이션합니다.""" result = 0 for i in range(n): result += i * i return result def database_query_simulation(): """느린 데이터베이스 쿼리를 시뮬레이션합니다.""" time.sleep(0.1) # 네트워크 지연 또는 복잡한 쿼리 시뮬레이션 return {"data": "some_data"} @app.route('/slow_endpoint') def slow_endpoint(): start_time = time.time() comp_result = heavy_computation(1_000_000) db_result = database_query_simulation() end_time = time.time() return jsonify({ "computation_result": comp_result, "database_data": db_result, "total_time": end_time - start_time }) if __name__ == '__main__': app.run(debug=True)
실행 중인 애플리케이션을 수정하지 않고 cProfile
을 사용하여 slow_endpoint
를 프로파일링하려면 래퍼를 사용할 수 있습니다.
# profile_app.py import cProfile import pstats from app import app # Flask 앱 가져오기 def profile_flask_app(): with app.test_request_context('/slow_endpoint'): # 이것이 slow_endpoint 핸들러를 트리거합니다 app.preprocess_request() response = app.dispatch_request() app.full_dispatch_request() # 전체 수명 주기가 실행되도록 합니다 return response if __name__ == '__main__': profiler = cProfile.Profile() profiler.enable() profile_flask_app() # 요청을 시뮬레이션하는 함수 호출 profiler.disable() stats = pstats.Stats(profiler).sort_stats('cumulative') stats.print_stats(20) # 누적 시간 소모가 많은 상위 20개 호출 인쇄 stats.dump_stats('app_profile.prof') # 더 자세한 분석을 위해 파일에 저장
python profile_app.py
를 실행합니다. 출력에는 자세한 통계가 표시됩니다.
309 function calls (303 primitive calls) in 0.170 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.170 0.170 {built-in method builtins.exec}
1 0.001 0.001 0.170 0.170 profile_app.py:10(profile_flask_app)
1 0.000 0.000 0.169 0.169 app.py:20(slow_endpoint)
1 0.000 0.000 0.100 0.100 app.py:16(database_query_simulation)
1 0.000 0.000 0.069 0.069 app.py:9(heavy_computation)
...
이 출력에서 database_query_simulation
(0.100초)과 heavy_computation
(0.069초)이 slow_endpoint
실행 시간의 가장 큰 기여자임을 명확하게 알 수 있습니다. cumtime
열은 모든 하위 함수 및 자체 함수에서 소비된 총 시간을 나타내므로 특히 유익합니다.
WSGI 서버를 통해 노출된 실행 중인 웹 애플리케이션의 경우, cProfile
은 미들웨어를 사용하거나 요청 핸들러의 일부를 명시적으로 래핑하여 통합할 수 있습니다.
# app_with_profiling_middleware.py from flask import Flask, jsonify, request import cProfile, pstats, io import time app = Flask(__name__) # ... (이전과 같이 heavy_computation 및 database_query_simulation) ... @app.route('/slow_endpoint') def slow_endpoint(): start_time = time.time() comp_result = heavy_computation(1_000_000) db_result = database_query_simulation() end_time = time.time() return jsonify({ "computation_result": comp_result, "database_data": db_result, "total_time": end_time - start_time }) @app.route('/profile') def profile(): if not request.args.get('enabled'): return "Profiling is not enabled." pr = cProfile.Profile() pr.enable() # slow_endpoint에 대한 요청 시뮬레이션 with app.test_request_context('/slow_endpoint'): app.preprocess_request() response = app.dispatch_request() app.full_dispatch_request() pr.disable() s = io.StringIO() sortby = 'cumulative' ps = pstats.Stats(pr, stream=s).sort_stats(sortby) ps.print_stats() return f"<pre>{s.getvalue()}</pre>" if __name__ == '__main__': app.run(debug=True)
이제 브라우저에서 /profile?enabled=true
로 이동하면 브라우저 내에서 /slow_endpoint
의 프로파일링 통계를 볼 수 있습니다. 이를 통해 현장에서 바로 프로파일링할 수 있습니다.
cProfile
의 주요 단점은 오버헤드입니다. 효율적이지만 모든 함수 호출을 계측하므로 트래픽이 많은 프로덕션 애플리케이션의 속도가 상당히 느려지고 성능 특성이 변경될 수 있습니다(옵저버 효과). 따라서 프로덕션에서 지속적인 프로파일링에는 일반적으로 적합하지 않습니다.
py-spy: 실제 프로세스를 위한 샘플링 프로파일러
py-spy
는 Python 프로그램의 경우 매우 강력한 샘플링 프로파일러입니다. cProfile
과 달리 py-spy
는 해당 프로그램의 코드를 수정하거나 다시 시작할 필요 없이 실행 중인 Python 프로그램을 프로파일링하도록 설계되었습니다. 이를 통해 실제 프로덕션 환경에서 성능 문제를 진단하는 데 사용할 수 있습니다.
py-spy 작동 방식
py-spy
는 대상 Python 프로세스의 호출 스택을 높은 빈도로(예: 초당 100회) "샘플링"하여 작동합니다. 즉, 주기적으로 프로그램 호출 스택에서 현재 활성인 함수를 검사합니다. 이는 Python 인터프리터의 내부 데이터 구조를 메모리에서 직접 읽음으로써 수행되며, 프로파일링된 애플리케이션을 수정할 필요가 없고 최소한의 오버헤드를 발생시킵니다. 샘플링이기 때문에 결정론적 결과보다는 확률적 결과를 제공하지만, 주요 병목 현상을 식별하는 데는 매우 효과적이며 프로덕션 사용에 훨씬 안전합니다.
py-spy
는 다음과 같은 다양한 형식으로 출력할 수 있습니다.
- 불꽃 그래프: 호출 스택을 보여주는 시각적 표현으로, 각 막대의 너비는 해당 함수 및 해당 하위 함수에서 소비된 총 시간을 나타냅니다. 너비가 넓은 막대는 "핫" 코드 경로를 나타냅니다.
- Top: Linux의
top
명령과 유사한 상세한 텍스트 기반 출력을 표시하며, 가장 자주 활성인 함수를 보여줍니다. - 원시 출력: 추가 분석을 위한 기계 가독형 데이터.
py-spy를 사용한 실제 적용
먼저 pip install py-spy
를 사용하여 py-spy
를 설치합니다. 다른 프로세스의 메모리를 검사해야 하므로 일반적으로 py-spy
를 사용하려면 sudo
또는 루트 권한이 필요합니다.
Flask 애플리케이션을 일반 프로세스로 시작합니다.
python app.py
애플리케이션이 실행 중일 때(예: 브라우저에서 /slow_endpoint
에 몇 번 요청을 보낼 수 있음), 다른 터미널을 열고 py-spy
를 사용합니다. 먼저 app.py
프로세스의 PID를 찾습니다.
pgrep -f "python app.py" # 예시 출력: 12345
이제 py-spy
를 실행하여 불꽃 그래프를 생성합니다.
sudo py-spy record -o profile.svg --pid 12345
몇 초 동안(예: 10-20초) 실행하는 동안 http://127.0.0.1:5000/slow_endpoint
로 여러 요청을 보냅니다. py-spy
가 완료되면 profile.svg
파일이 생성됩니다. 이 SVG를 웹 브라우저에서 열면 대화형 불꽃 그래프가 표시됩니다.
불꽃 그래프는 일반적으로 slow_endpoint
에 대해 넓은 막대를 보여주고, 그 안에서 heavy_computation
과 database_query_simulation
이 상당 부분을 차지하는 것을 볼 수 있습니다. database_query_simulation
의 time.sleep
호출은 넓은 막대로 나타나 프로그램이 여기서 기다리고 있음을 나타냅니다. 마찬가지로 heavy_computation
의 루프는 "핫" 경로로 표시됩니다.
대신, 실시간 텍스트 뷰를 위해 py-spy top
을 사용할 수 있습니다.
sudo py-spy top --pid 12345
이것은 현재 가장 많은 CPU 시간을 소비하는 함수를 보여주는 계속 업데이트됩니다. 애플리케이션이 CPU 바운드인지, 그리고 CPU 사용량이 정확히 어디에 집중되어 있는지 빠르게 식별하는 데 탁월합니다.
Total Samples: 123, Active Threads: 1, Sampling Rate: ~99 Hz
THREAD 12345 (idle: 0.00%)
app.py:12 heavy_computation - 50.1%
time.py:73 time.sleep - 49.3%
app.py:20 slow_endpoint - 0.6%
(이것은 py-spy top
출력의 단순화된 예이며, 실제 출력은 더 자세하고 실시간으로 업데이트됩니다.)
py-spy
는 호출 스택의 활성 상태를 캡처하기 때문에 "스피닝"(CPU 바운드 루프) 및 "대기"(I/O 바운드 작업)를 감지하는 데 특히 능숙합니다. time.sleep
또는 데이터베이스 드라이버의 execute
메소드와 같은 함수가 불꽃 그래프 또는 top
출력에서 높게 나타나면 I/O 대기를 나타냅니다. 복잡한 계산 함수가 나타나면 CPU 바운드입니다.
py-spy
의 가장 큰 장점은 비침입적 특성과 낮은 오버헤드입니다. 이를 프로덕션에서 애플리케이션을 수정하거나 다시 시작할 수 없을 때 프로덕션 디버깅을 위한 선호되는 도구로 만듭니다.
결론
Python 웹 애플리케이션의 성능 병목 현상을 분석하는 것은 모든 개발자에게 중요한 기술입니다. cProfile
은 함수 호출에 대한 정확한 타이밍을 제공하며 개발 및 대상 코드 최적화에 적합한 정확한 결정론적 프로파일링을 제공합니다. 그러나 오버헤드로 인해 프로덕션에는 덜 이상적입니다. 대조적으로, py-spy
는 프로덕션 환경에서 빛을 발하며, 실제 프로세스를 프로파일링하고 통찰력 있는 불꽃 그래프 또는 실시간 top
-유사 출력을 생성하기 위한 낮은 오버헤드의 비침입 샘플링 접근 방식을 제공합니다. py-spy
와 cProfile
모두를 이해하고 효과적으로 활용함으로써 개발자는 효율적으로 성능 문제점을 식별하여 Python 웹 애플리케이션이 빠르고 반응성이 뛰어나며 확장 가능하도록 보장할 수 있습니다. 컨텍스트에 따라 올바른 도구(cProfile
은 상세한 로컬 분석용, py-spy
는 실제 프로덕션 진단용)를 선택하는 것이 웹 애플리케이션 성능을 마스터하는 열쇠입니다.