Python 성능관리 방법 vol.1 - GC 관리

Intro

안녕하세요 Noah입니다.
이번 포스팅에서는 Python 개발자들이 꼭 알아야 할 성능 관리 방법 중 하나인 가비지 컬렉션(GC) 관리에 대해 다뤄보겠습니다.
Python은 자동으로 메모리를 관리하는 편리한 언어이지만, 기본 설정만으로 모든 상황에서 최적의 성능을 낼 수는 없습니다.
특히, 대규모 데이터 처리나 장기 실행 서비스에서는 메모리 누수 또는 불필요한 성능 저하가 발생할 수 있는데요, 이러한 문제를 해결하기 위해 GC를 이해하고 적절히 최적화하는 방법을 알아보겠습니다.

그럼, 시작해보겠습니다!



목차

  1. Intro
  2. Python 3.x에서 가비지 컬렉션(GC) 최적화 방법
  3. Outro



Python 3.x에서 가비지 컬렉션(GC) 최적화 방법

Python은 기본적으로 레퍼런스 카운팅(reference counting)가비지 컬렉션(garbage collection)을 통해 메모리를 관리합니다.
하지만 기본 설정만으로는 특정 워크로드에서 비효율이 발생할 수 있습니다. 본 글에서는 Python 3.x에서 GC를 최적화하는 방법과 실전 적용 사례를 다룹니다.


1. Python GC의 기본 이해

Python GC는 gc 모듈에 의해 작동합니다. Python은 두 가지 방식으로 메모리를 관리합니다

  • 레퍼런스 카운팅: 객체가 더 이상 참조되지 않을 때 즉시 메모리를 해제합니다.
  • 순환 가비지 수집: 순환 참조(circular reference)가 발생한 객체들이 다른 곳에서 참조되지 않을 때를 찾아 제거합니다.

GC는 세대별 수집(generation collection) 방식을 사용하여 0세대, 1세대, 2세대로 객체를 나누어 수집합니다.
새로 생성된 객체는 0세대에 속하며, 오래된 객체는 점차 상위 세대로 이동합니다.



2. 기본 설정 확인하기

기본적으로 Python의 GC는 자동으로 동작합니다. GC 설정은 gc 모듈로 확인할 수 있습니다.

[조회방법]

import gc

# 현재 GC 설정 확인
print(gc.get_threshold())

[출력값]

(700, 10, 10)
  • 700: 0세대 객체 수가 이 값을 초과하면 가비지 수집이 실행됩니다.
  • 10: 0세대에서 1세대로 이동하기 위한 조건.
  • 10: 1세대에서 2세대로 이동하기 위한 조건.

GC 임계값 조정

GC 임계값을 조정하여 성능을 최적화할 수 있습니다.
기본 임계값(700, 10, 10)은 일반적인 워크로드에 적합하지만, 대규모 데이터 처리 또는 실시간 성능이 중요한 작업에서는 조정이 필요합니다.

[임계값 변경]

# 세대별 객체 수 임계값 변경
gc.set_threshold(1000, 15, 10)
print(gc.get_threshold())

[권장 시나리오]

  • 더 큰 임계값: 객체 생성과 소멸이 많지 않은 경우(예: 장기 실행 서비스).
  • 더 작은 임계값: 메모리 누수 가능성이 있는 경우.



3. GC 비활성화 및 수동 제어

어떤 경우에는 자동 GC가 성능을 저하시킬 수 있습니다. 이때 GC를 비활성화하고 수동으로 제어할 수 있습니다.

  1. 단기적으로 객체가 많이 생성되고 제거되는 작업(예: 대량 데이터 처리).
  2. GC가 과도하게 호출되어 성능이 저하되는 경우.

GC 비활성화

gc.disable()

# 작업 수행
gc.collect()  # 필요할 때만 수동으로 호출
gc.enable()





4. 순환 참조 최소화

순환 참조는 Python GC가 처리하는 주요 대상입니다. 코드에서 순환 참조를 피하는 것은 GC 부담을 줄이는 데 효과적입니다.

순환 참조 예시 및 해결

# 순환 참조 예시
class Node:
    def __init__(self, value):
        self.value = value
        self.ref = None

a = Node(1)
b = Node(2)
a.ref = b
b.ref = a  # 순환 참조 발생

# 순환 참조 해제
a.ref = None
b.ref = None

[해결 방법]

  • 가능하면 weakref 모듈 사용
  • 클래스 소멸자(__del__)를 명시적으로 정의

weakref 모듈 사용

weakref는 기존 객체를 참조하지만, 객체의 참조 횟수(reference count)를 증가시키지 않기 때문에 GC(Garbage Collection)가 해당 객체를 수집할 수 있습니다.
따라서 참조하고 있는 객체가 메모리에서 해제될 경우 weakref무효화됩니다.

import weakref

class MyClass:
    pass

obj = MyClass()  # 강한 참조
weak_obj = weakref.ref(obj)  # 약한 참조 생성

print(weak_obj())  # <__main__.MyClass object at 0x...> (원본 객체에 접근)

del obj  # 강한 참조 삭제
print(weak_obj())  # None (GC가 객체를 수집했으므로 참조 불가)
  • weakref는 객체의 복사를 생성하지 않으며, 단지 원본 객체를 참조합니다.
  • 원본 객체가 삭제되거나 수집되면 weakref도 무효화됩니다.



5. GC 최적화 실전 팁

Python GC를 최적화하는 방법은 다양하지만, 다음과 같은 실전 팁을 참고하면 좋습니다.

  • 대량 작업 중 GC 비활성화: 데이터 처리가 끝난 후에 한 번만 GC를 호출.
  • weakref 사용: 순환 참조를 방지하기 위해 약한 참조 사용.
  • 객체 수명 주기 단축: 필요 없는 객체를 즉시 제거.
  • 데이터 구조 최적화: 중첩 리스트 대신 array 또는 numpy와 같은 모듈 사용.



6. GC 모니터링

gc 모듈은 가비지 컬렉션 횟수 및 수행 시간을 확인하는 기능을 제공합니다.

메모리 사용 패턴 분석

Python GC를 최적화하려면 메모리 사용 패턴을 파악해야 합니다.
tracemallocgc 모듈을 사용하면 어떤 객체가 메모리를 점유하고 있는지 추적할 수 있습니다.

import tracemalloc

tracemalloc.start()
# 메모리 집약 작업
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:10]:
    print(stat)
tracemalloc.stop()



GC 동작 모니터링

import gc

gc.set_debug(gc.DEBUG_STATS)
# 작업 수행
gc.collect()





7. 실제 사례 적용

대규모 데이터 파이프라인을 운영하는 기업에서 아래와 같은 문제가 발생했다고 가정해 봅시다.

상황: 대규모 로그 처리 시스템

한 엔터프라이즈에서는 실시간 로그 데이터를 처리하기 위해 Python 기반의 ETL(Extract, Transform, Load) 시스템을 사용하고 있었습니다.
하루에 10억 건 이상의 로그 데이터를 수집하여, 이를 정제하고 데이터베이스에 저장하는 작업을 수행했는데요, 다음과 같은 문제가 지속적으로 보고되었습니다.

  1. 메모리 누수(memory leak): 작업이 장시간 실행되면 시스템 메모리가 점차 증가하여 결국 OOM(Out of Memory) 에러가 발생.
  2. GC 호출 과잉: 데이터 처리 중 많은 객체가 생성되며 GC가 빈번히 호출되어 성능이 저하.
  3. 순환 참조: 데이터 처리 로직에서 순환 참조로 인해 일부 객체가 메모리에서 제거되지 않음.

해결 방안 및 적용

  1. GC 비활성화와 수동 관리
    • 대량 데이터 처리를 수행하는 동안 GC를 비활성화하고, 특정 작업 단위(batch)가 끝날 때마다 수동으로 gc.collect()를 호출했습니다.
     import gc
        
     def process_batch(batch_data):
         # 데이터 처리
         for record in batch_data:
             process(record)
        
     gc.disable()  # GC 비활성화
     for batch in data_batches:
         process_batch(batch)
         gc.collect()  # 배치 처리 후 수동 GC 호출
     gc.enable()  # GC 재활성화
    
  2. 순환 참조 제거
    • 문제를 추적한 결과, 데이터 처리 객체 간의 순환 참조가 발견되었습니다. 이를 해결하기 위해 weakref를 활용하여 순환 참조를 방지했습니다.
     import weakref
        
     class Node:
         def __init__(self, value):
             self.value = value
             self.child = None  # 약한 참조로 변경
        
     parent = Node("parent")
     child = Node("child")
     parent.child = weakref.ref(child)  # 약한 참조 설정
    
  3. 메모리 사용 모니터링
    • tracemalloc을 사용하여 메모리 사용 패턴을 분석하고, 메모리 누수가 발생하는 지점을 추적했습니다.
     import tracemalloc
        
     tracemalloc.start()
        
     # 데이터 처리
     process_large_data()
        
     snapshot = tracemalloc.take_snapshot()
     for stat in snapshot.statistics("lineno")[:10]:
         print(stat)
        
     tracemalloc.stop()
    
  4. 데이터 구조 최적화
    • 리스트(list)와 딕셔너리(dict)를 사용하는 기존 로직 대신 메모리 효율성이 높은 numpy 배열과 collectionsdeque를 도입하여 메모리 사용량을 줄였습니다.

결과

위 최적화 과정을 통해 다음과 같은 개선 효과를 얻을 수 있었습니다.

  • 메모리 사용량 감소: weakref 도입 및 순환 참조 제거
  • 처리 속도 향상: 불필요한 GC 호출 감소 및 데이터 구조 최적화
  • 안정성 향상: OOM 에러가 발생하지 않음



Outro

Python에서 GC는 대부분의 상황에서 알아서 잘 작동하지만, 워크로드와 서비스 환경에 따라 세심한 관리가 필요할 때도 있습니다.
이번 포스팅을 통해 Python GC의 기본 개념부터 최적화 방법까지 살펴보았는데요, 여러분의 프로젝트에서 성능 개선이 필요한 부분이 있다면 꼭 활용해 보시길 바랍니다.

다음 포스팅에서는 Python 성능 관리 방법의 두 번째 주제를 다룰 예정이니, 많은 관심 부탁드립니다!
긴 글 읽어주셔서 감사합니다. 😊