Python Garbage Collection (가비지 컬렉션)
Python Garbage Collection (가비지 컬렉션)
개요
얼마 전 회사에서 이상한 현상을 겪었다. Python 스크립트가 끝났는데도 메모리가 해제되지 않는 것이었다.
1
2
3
4
5
6
7
def process_data():
big_list = [i for i in range(1000000)] # 약 40MB 메모리 사용
# 여기서 뭔가 처리...
return "완료"
result = process_data()
print("함수 끝났는데 메모리가 왜 안 줄어들지?")
분명히 함수가 끝났으니까 big_list
는 사라져야 하는데, 메모리 모니터를 보면 여전히 40MB를 차지하고 있었다.
“Python이 자동으로 메모리 관리해준다면서 왜 이런 일이?”
그래서 Python 가비지 컬렉터(Garbage Collector)에 대해 제대로 알아보려한다.
Python 메모리 관리 기본 원리
📌 참조 카운팅 (Reference Counting)
Python의 기본 메모리 관리 방식은 참조 카운팅이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
# 참조 카운팅 예시
import sys
data = [1, 2, 3, 4, 5]
print(f"참조 카운트: {sys.getrefcount(data)}") # 2 (data 변수 + getrefcount 함수)
backup = data # 참조 카운트 증가
print(f"참조 카운트: {sys.getrefcount(data)}") # 3
del backup # 참조 카운트 감소
print(f"참조 카운트: {sys.getrefcount(data)}") # 2
del data # 참조 카운트 0 → 즉시 메모리 해제
참조 카운팅의 동작:
- 객체를 참조할 때마다 카운트 +1
- 참조가 사라질 때마다 카운트 -1
- 카운트가 0이 되면 즉시 메모리 해제
🤔 그런데 왜 메모리가 안 해제될까?
참조 카운팅만으로는 해결할 수 없는 경우가 있다. 순환 참조다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Node:
def __init__(self, value):
self.value = value
self.children = []
self.parent = None
# 순환 참조 상황 만들기
parent = Node("부모")
child = Node("자식")
parent.children.append(child) # 부모 → 자식 참조
child.parent = parent # 자식 → 부모 참조
# 이제 둘 다 삭제해보자
del parent
del child
# 그런데 메모리가 해제될까?
# 답: 안 된다! 서로를 참조하고 있어서 참조 카운트가 0이 안 됨
순환 참조 문제:
parent
는child
를 참조child
는parent
를 참조- 변수를 삭제해도 서로의 참조 카운트는 1로 유지
- 결과적으로 메모리 누수 발생
Python 가비지 컬렉터의 역할
🗑️ 순환 참조 감지와 해제
Python은 이런 문제를 해결하기 위해 가비지 컬렉터를 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import gc
# 가비지 컬렉터 상태 확인
print(f"가비지 컬렉터 활성화: {gc.isenabled()}")
print(f"현재 객체 수: {len(gc.get_objects())}")
# 순환 참조 객체 생성
def create_circular_reference():
parent = Node("부모")
child = Node("자식")
parent.children.append(child)
child.parent = parent
return "순환 참조 생성 완료"
result = create_circular_reference()
print(f"생성 후 객체 수: {len(gc.get_objects())}")
# 수동으로 가비지 컬렉션 실행
collected = gc.collect()
print(f"수집된 객체 수: {collected}")
print(f"수집 후 객체 수: {len(gc.get_objects())}")
📊 세대별 가비지 컬렉션
Python은 세대별(Generational) 가비지 컬렉션을 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 가비지 컬렉션 통계 확인
print(gc.get_stats())
# 새로 생성된 객체들 (세대 0)
temp1 = [1, 2, 3] # 함수 안에서 잠깐 쓰고 버릴 객체
temp2 = "hello" # 임시 문자열
temp3 = {"key": "val"} # 임시 딕셔너리
# 시간이 지나도 살아남은 객체들 (세대 1)
class_instance = MyClass() # 클래스 인스턴스
global_config = {...} # 전역 설정
# 프로그램 내내 쓰이는 객체들 (세대 2)
imported_modules = sys.modules # 임포트된 모듈들
# 출력 예시:
# [{'collections': 123, 'collected': 45, 'uncollectable': 0}, # 세대 0
# {'collections': 11, 'collected': 12, 'uncollectable': 0}, # 세대 1
# {'collections': 1, 'collected': 3, 'uncollectable': 0}] # 세대 2
세대별 분류 원리:
- 세대 0: 새로 생성된 객체들 (자주 검사)
- 세대 1: 세대 0에서 살아남은 객체들 (가끔 검사)
- 세대 2: 세대 1에서 살아남은 객체들 (드물게 검사)
왜 이렇게 할까?
- 새로운 객체들은 금방 사용되지 않을 가능성이 높음
- 오래 살아남은 객체들은 계속 쓰일 가능성이 높음
- 따라서 새 객체들을 더 자주 검사하는 게 효율적
🔧 가비지 컬렉션 임계값
1
2
3
4
5
6
7
8
# 현재 임계값 확인
print(f"가비지 컬렉션 임계값: {gc.get_threshold()}")
# 출력: (700, 10, 10)
# 의미:
# - 세대 0: 새 객체 700개 생성되면 가비지 컬렉션 실행
# - 세대 1: 세대 0에서 10번 컬렉션이 일어나면 실행
# - 세대 2: 세대 1에서 10번 컬렉션이 일어나면 실행
실제 문제 상황과 해결법
🚨 문제 상황 : 큰 객체가 해제되지 않는 경우
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import gc
from memory_profiler import profile
@profile
def memory_leak_example():
# 큰 데이터 구조 생성
big_data = {}
for i in range(100000):
big_data[i] = {
'data': [j for j in range(100)],
'reference': big_data # 자기 자신을 참조!
}
print("데이터 생성 완료")
# 함수가 끝나도 순환 참조 때문에 메모리 해제 안됨
return "완료"
# 실행 전 메모리 확인
print(f"실행 전 객체 수: {len(gc.get_objects())}")
result = memory_leak_example()
print(f"실행 후 객체 수: {len(gc.get_objects())}")
# 수동으로 가비지 컬렉션 실행
collected = gc.collect()
print(f"가비지 컬렉션으로 수집된 객체: {collected}")
print(f"수집 후 객체 수: {len(gc.get_objects())}")
✅ 해결법 1: 명시적 참조 해제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def memory_safe_example():
big_data = {}
for i in range(100000):
big_data[i] = {
'data': [j for j in range(100)],
# 순환 참조 제거
}
# 명시적으로 참조 해제
del big_data
# 필요하면 수동으로 가비지 컬렉션 실행
gc.collect()
return "완료"
✅ 해결법 2: weakref 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import weakref
class SafeNode:
def __init__(self, value):
self.value = value
self.children = []
self._parent = None # 약한 참조로 저장
@property
def parent(self):
return self._parent() if self._parent else None
@parent.setter
def parent(self, value):
self._parent = weakref.ref(value) if value else None
# 사용법
parent = SafeNode("부모")
child = SafeNode("자식")
parent.children.append(child)
child.parent = parent # 약한 참조로 저장
# 이제 부모를 삭제하면 제대로 해제됨
del parent
print(f"자식의 부모: {child.parent}") # None
실무에서 활용할 수 있는 팁들
🔍 메모리 누수 디버깅하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import gc
import objgraph
def debug_memory_leak():
# 타입별 객체 수 확인
objgraph.show_most_common_types(limit=10)
# 특정 타입의 참조 관계 확인
objgraph.show_backrefs([gc.get_objects()[0]], filename='refs.png')
# 가비지 컬렉션으로 수집할 수 없는 객체들 확인
if gc.garbage:
print("수집 불가능한 객체들:")
for obj in gc.garbage:
print(type(obj), obj)
# 메모리 사용량 모니터링
def monitor_memory_usage():
import tracemalloc
tracemalloc.start()
# 여기서 의심스러운 코드 실행
suspicious_function()
# 메모리 사용량 분석
current, peak = tracemalloc.get_traced_memory()
print(f"현재 메모리 사용량: {current / 1024 / 1024:.1f} MB")
print(f"최대 메모리 사용량: {peak / 1024 / 1024:.1f} MB")
tracemalloc.stop()
결론
📝 핵심 포인트들
- Python은 참조 카운팅 + 가비지 컬렉션으로 메모리 관리
- 순환 참조는 가비지 컬렉터가 해결하지만 즉시는 아님
- 세대별 가비지 컬렉션으로 효율성 확보
- 필요시 수동 조정으로 성능 최적화 가능
🎯 실무에서 기억할 것들
언제 신경 써야 하나:
- 대용량 데이터 처리할 때
- 메모리 사용량이 계속 증가할 때
- 성능이 점진적으로 느려질 때
- 순환 참조가 발생할 수 있는 구조를 만들 때
간단한 해결책:
1
2
3
4
5
6
7
# 1. 명시적 삭제
del big_object
gc.collect()
# 2. 약한 참조 사용
import weakref
weak_ref = weakref.ref(object)
This post is licensed under CC BY 4.0 by the author.