Python 메모리 사용량 프로파일러 이해 및 최적화
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
소프트웨어 개발 세계에서 효율적인 리소스 활용은 무엇보다 중요합니다. Python은 가독성과 빠른 개발 기능으로 유명하지만, 종종 메모리를 많이 소비하는 언어로 인식됩니다. 이러한 인식은 완전히 근거 없는 것은 아닙니다. Python의 동적 타이핑, 객체 지향 특성, 가비지 컬렉션 메커니즘은 신중하게 관리하지 않으면 때때로 예상보다 훨씬 큰 메모리 사용량을 초래할 수 있습니다. 제어되지 않는 메모리 증가는 애플리케이션 성능을 저하시키고, 인프라 비용을 증가시키며, 심지어 치명적인 시스템 오류로 이어질 수도 있습니다. 따라서 Python 애플리케이션이 메모리를 어디에 소비하는지 이해하고 분석하는 것은 단순한 디버깅 작업을 넘어 강력하고 확장 가능한 시스템을 구축하기 위한 중요한 단계입니다. 이 문서는 Python 메모리 소비를 해독하는 과정을 안내할 것입니다. 이를 위해 줄별 메모리 추적을 위한 memory-profiler
와 객체 관계 시각화를 위한 objgraph
라는 두 가지 강력한 도구를 사용하며, 궁극적으로 메모리 병목 현상을 식별하고 해결할 수 있도록 지원할 것입니다.
메모리 프로파일링 심층 분석
도구를 살펴보기 전에 Python의 메모리 관리와 관련된 몇 가지 기본 개념을 명확히 해야 합니다.
- 가비지 컬렉션 (GC): Python은 가비지 컬렉터를 통해 자동 메모리 관리를 사용합니다. 주로 객체 참조를 추적하기 위해 참조 카운팅을 사용합니다. 객체의 참조 카운트가 0으로 떨어지면 즉시 할당 해제됩니다. 그러나 참조 카운팅은 순환 참조를 해결할 수 없으며, 여기서 별도의 세대별 가비지 컬렉터가 참조되지 않은 사이클을 감지하고 수집하는 역할을 합니다.
- 객체 오버헤드: Python의 모든 객체는 정수나 문자열과 같은 간단한 유형의 경우에도 특정 메모리 오버헤드를 가집니다. 이 오버헤드에는 참조 카운트, 유형 정보 및 기타 Python 내부 메커니즘을 위한 필드가 포함됩니다. 정수의 작아 보이는 목록이 이 오버헤드로 인해 예상보다 많은 메모리를 소비할 수 있다는 사실을 이해하는 것이 중요합니다.
- 메모리 사용량: 이는 주어진 시간에 애플리케이션이 사용하고 있는 총 메모리 양을 나타냅니다. 코드, 데이터, 스택, 힙, 공유 라이브러리와 같은 다양한 구성 요소로 나눌 수 있습니다. "메모리 사용량"에 대해 이야기할 때, 우리는 일반적으로 Python 객체가 소비하는 힙 메모리를 참조합니다.
이제 memory-profiler
와 objgraph
를 살펴보겠습니다.
memory-profiler
를 사용한 줄별 메모리 분석
memory-profiler
는 프로세스의 메모리 사용량을 줄별로 모니터링하는 Python 모듈입니다. 메모리 사용량에 가장 크게 기여하는 정확한 코드 섹션을 식별하는 데 매우 유용합니다.
설치
먼저 memory-profiler
를 설치하십시오.
pip install memory-profiler
사용 예제
긴 문자열 목록을 생성하는 간단한 (그리고 다소 인위적인) 예제를 고려해 봅시다.
# memory_example.py from memory_profiler import profile def generate_big_list(num_elements): print(f"Generating list with {num_elements} elements...") big_list = [] for i in range(num_elements): big_list.append("This is a rather long string " + str(i) * 10) return big_list @profile def main(): list_a = generate_big_list(100000) # 메모리를 소비할 수 있는 다른 작업을 시뮬레이션 list_b = [str(x) for x in range(50000)] del list_a # 메모리 해제를 보기 위해 명시적으로 삭제 print("List A deleted.") # list_b를 잠시 유지 _ = len(list_b) if __name__ == "__main__": main()
이 프로파일을 실행하려면 python -m memory_profiler
명령을 사용하십시오.
python -m memory_profiler memory_example.py
출력은 다음과 같습니다.
Filename: memory_example.py
Line # Mem usage Increment Line Contents
================================================
10 21.1 MiB 21.1 MiB @profile
11 def main():
12 49.0 MiB 27.9 MiB list_a = generate_big_list(100000)
Generating list with 100000 elements...
13 50.2 MiB 1.2 MiB list_b = [str(x) for x in range(50000)]
14 22.6 MiB -27.6 MiB del list_a # Explicitly delete to see memory release
List A deleted.
15 22.6 MiB 0.0 MiB print("List A deleted.")
16 22.6 MiB 0.0 MiB _ = len(list_b)
출력 이해:
Line #
: 소스 파일의 줄 번호입니다.Mem usage
: 이 줄 실행이 끝날 때 프로세스에서 사용한 총 메모리입니다.Increment
: 이전 줄에서 메모리 사용량의 변경 사항입니다. 이는 메모리 집약적인 작업을 식별하는 데 가장 중요한 열입니다.
출력에서 list_a = generate_big_list(100000)
가 27.9 MiB
의 상당한 증가를 유발했으며 del list_a
가 상당한 양의 메모리를 성공적으로 해제했음을 명확하게 알 수 있습니다. 이 줄별 분석은 메모리 할당이 발생하는 정확한 위치를 식별하는 데 매우 중요합니다.
objgraph
를 사용한 객체 관계 분석
memory-profiler
는 메모리가 어디에 할당되는지 알려주는 반면, objgraph
는 어떤 객체가 메모리를 소비하고 더 중요하게는 왜 아직 메모리에 있는지(즉, 무엇이 참조하는지) 이해하는 데 도움이 됩니다. 이는 원치 않는 객체 보유로 인한 메모리 누수를 추적하는 데 특히 유용합니다.
설치
시각화를 위해 graphviz
와 함께 objgraph
를 설치하십시오.
pip install objgraph graphviz
시각화를 작동시키려면 시스템에 (예: Debian/Ubuntu의 경우 sudo apt-get install graphviz
, macOS의 경우 brew install graphviz
) graphviz
자체를 설치해야 할 수도 있습니다.
사용 예제
이전 예제를 수정하여 미묘한 메모리 누수 시나리오를 만들고 objgraph
를 사용하여 디버그해 보겠습니다.
최소 항목 수를 저장해야 하지만 이전 항목에 대한 참조가 실수로 유지되는 캐시가 있는 시나리오를 상상해 보세요.
# objgraph_example.py import objgraph import random import sys class DataObject: def __init__(self, id_val, payload): self.id = id_val self.payload = payload * 100 # 페이로드를 합리적으로 크게 만듦 def __repr__(self): return f"DataObject(id={self.id})" # 누수가 발생할 수 있는 간단한 캐시 class LeakyCache: def __init__(self): self.cache = {} self.history = [] # 이것이 실수로 참조를 보유할 수 있음 def add_item(self, id_val, payload): obj = DataObject(id_val, payload) self.cache[id_val] = obj # 버그: history 정리를 잊어 참조가 누수됨 self.history.append(obj) # 실제 캐시에서는 크기/시간 기준으로 history 또는 캐시를 정리해야 할 수 있음 def cause_leak(): cache_manager = LeakyCache() for i in range(10): # 항목 추가 cache_manager.add_item(f"item_{i}", f"data_{i}" * 1000) # 마지막 2개 항목만 캐시에 관심이 있다고 가정 # 하지만 모든 항목은 cache_manager.history에 의해 참조됨 print(f"Size of history: {sys.getsizeof(cache_manager.history)} bytes") return cache_manager def main(): print("---") objgraph.show_growth(limit=10) # 일반 객체 성장을 표시 leaky_manager = cause_leak() print("\n---") objgraph.show_growth(limit=10) # DataObject 인스턴스를 보유하고 있는 것을 찾으려고 합니다. # 더 이상 "필요하지 않은" DataObject 인스턴스가 여전히 참조되고 있다고 가정합니다. print(f"\n--- Objects of type DataObject: {objgraph.count(DataObject)} ---") # 이것은 objgraph 디버깅의 핵심입니다: 참조자 찾기 # 'DataObject' 인스턴스가 누수되고 있다고 의심한다고 가정합니다. # 인스턴스 하나를 가져와 참조자를 추적합니다. some_data_object = next(obj for obj in objgraph.by_type(DataObject) if obj.id == 'item_0', None) if some_data_object: print(f"\n--- Showing referrers for {some_data_object} ---") objgraph.show_refs([some_data_object], filename='data_object_refs.png') print("Generated data_object_refs.png for item_0. Open it to see the reference chain.") # 깊이별로 객체를 표시할 수도 있습니다. # objgraph.show_backrefs(objgraph.by_type(DataObject), max_depth=10, filename='data_object_backrefs.png') if __name__ == "__main__": main()
이 스크립트를 실행하십시오.
python objgraph_example.py
그러면 성장 통계가 출력되고 가장 중요한 것은 data_object_refs.png
가 생성됩니다. data_object_refs.png
를 열면 다음과 같은 그래프가 표시됩니다 (단순화된 표현).
graph TD A[DataObject(id=item_0)] --> B[list instance (history)] B --> C[LeakyCache instance] C --> D[__main__ namespace]
이 그래프는 DataObject(id=item_0)
가 LeakyCache
인스턴스에 의해 참조되고 결국 leaky_manager
변수가 이를 보유하기 때문에 전역 __main__
네임스페이스에서 참조되는 list
인스턴트에 의해 참조된다는 것을 명확하게 보여줍니다. 이는 LeakyCache
의 self.history
목록이 오래된 DataObject
인스턴스를 불필요하게 보유하는 원인임을 즉시 나타냅니다.
주요 objgraph
함수:
objgraph.show_growth(limit=10)
: 마지막 호출 또는 프로그램 시작 이후 가장 일반적인 10가지 객체 유형의 성장을 표시합니다. 추세 메모리 문제를 감지하는 데 탁월합니다.objgraph.count(obj_type)
: 주어진 객체 유형의 현재 인스턴스 수를 반환합니다.objgraph.by_type(obj_type)
: 주어진 객체 유형의 모든 현재 인스턴스 목록을 반환합니다.objgraph.show_refs(objects, filename='refs.png', max_depth=X)
:objects
가 참조하는 것을 보여주는 Graphviz PNG 이미지를 생성합니다. 출력 참조를 이해하는 데 유용합니다.objgraph.show_backrefs(objects, filename='backrefs.png', max_depth=X)
:objects
를 참조하는 것을 보여주는 Graphviz PNG 이미지를 생성합니다. 가비지 컬렉션을 방해하는 객체를 찾아 메모리 누수를 찾는 데 더 유용합니다.
도구 결합
실제 시나리오에서는 일반적으로 memory-profiler
를 사용하여 코드의 어떤 부분이 메모리 사용량을 증가시키는지 식별하는 것부터 시작합니다. 의심되는 함수나 블록을 찾았지만 예상대로 메모리가 해제되지 않으면 objgraph
를 해당 섹션 내에서 사용합니다(예: objgraph.show_growth()
를 전후에 추가하거나 의심되는 누수 객체에 직접 show_backrefs()
사용). 그러면 해당 객체가 왜 여전히 메모리에 있는지 이해할 수 있습니다.
애플리케이션 시나리오
- 메모리 누수 식별: 애플리케이션 메모리 사용량이 시간이 지남에 따라 일정하게 증가하지 않고 상승하면
objgraph
를 사용하여 수집되지 않은 객체와 해당 참조자를 식별할 수 있습니다. - 데이터 구조 최적화:
memory-profiler
는 다른 데이터 구조(예: 목록 대 튜플 대 세트) 또는 다른 데이터 저장 접근 방식의 메모리 비용을 보여줄 수 있습니다. - 대규모 데이터 처리: 과학 컴퓨팅 또는 데이터 엔지니어링에서는 대규모 데이터 세트를 처리하는 것이 일반적입니다. 프로파일러는 임시 데이터 구조가 올바르게 정리되고 메모리 효율적인 알고리즘이 사용되도록 하는 데 도움이 될 수 있습니다.
- 웹 서비스 및 API: 웹 서버와 같은 장기 실행 애플리케이션은 메모리 누수로 인해 성능과 안정성이 서서히 저하될 수 있습니다. 정기적인 프로파일링 또는 테스트에 프로파일링을 통합하면 이러한 문제를 예방할 수 있습니다.
결론
Python 메모리 사용량을 이해하고 최적화하는 것은 강력하고 효율적인 애플리케이션을 구축하는 모든 개발자에게 중요한 기술입니다. memory-profiler
는 메모리 할당 증가에 대한 세분화된 줄별 보기를 제공하여 메모리 증가를 담당하는 정확한 코드 섹션을 식별할 수 있도록 합니다. 이를 보완하는 objgraph
는 객체 관계에 대한 강력한 통찰력을 제공하여 어떤 객체가 메모리를 소비하고, 더 중요하게는 다른 객체가 이를 보유하여 가비지 컬렉션을 방지하는지 정확하게 시각화하는 데 도움이 됩니다. 이러한 도구를 효과적으로 활용함으로써 개발자는 메모리 병목 현상을 식별하고 해결하여 더 나은 성능과 안정성을 갖추고 리소스를 효율적으로 관리하는 Python 애플리케이션을 만들 수 있습니다.