파이썬에서 name = 'leethon'과 같이 변수에 값을 할당하면, 변수와 데이터 값이 메모리 상에서 연결됩니다. 이때 변수가 다른 값을 참조하도록 재할당되면, 기존 데이터 값은 더 이상 변수를 통해 접근할 수 없게 됩니다. 이런 데이터가 메모리에 계속 쌓이면 리소스 낭비가 발생하죠.
이렇게 접근 불가능한 데이터를 '가비지(Garbage)'라고 부르며, 파이썬은 자동으로 이를 수거합니다. 그 핵심 원리는 세 가지입니다: 참조 카운팅, 마크 앤 스윕, 세대별 수집입니다.
참조 카운팅 (Reference Counting)
참조 카운팅은 매우 직관적인 방식입니다. 모든 객체는 자신을 참조하는 변수의 수를 카운트로 가지고 있습니다. 변수가 객체를 가리키면 카운트가 1 증가하고, 변수가 다른 객체를 가리키거나 삭제되면 카운트가 1 감소합니다. 카운트가 0이 되면 해당 객체는 가비지로 간주되어 즉시 메모리에서 해제됩니다.
# 예시: 참조 카운팅의 기본 동작
x = [1, 2, 3] # 리스트 객체 생성, 참조 카운트 = 1
y = x # 같은 객체를 y도 참조, 참조 카운트 = 2
del x # x 삭제, 참조 카운트 = 1
y = None # y가 다른 값을 참조, 참조 카운트 = 0 → 메모리 해제
참조 카운팅이 증가하는 경우는 다음과 같습니다:
- 변수에 객체가 할당될 때
- 리스트, 딕셔너리 등의 컨테이너에 객체가 추가될 때
- 함수 인자로 객체가 전달될 때
반대로 감소하는 경우는:
- 변수가 재할당되거나
del로 삭제될 때 - 컨테이너 객체 자체가 삭제되거나 항목이 제거될 때
하지만 참조 카운팅만으로는 해결할 수 없는 문제가 있습니다. 바로 순환 참조입니다.
# 순환 참조 예제
a = [1] # 리스트 A 생성, 참조 카운트 = 1
b = [2] # 리스트 B 생성, 참조 카운트 = 1
a.append(b) # A가 B를 참조, B의 카운트 = 2
b.append(a) # B가 A를 참조, A의 카운트 = 2
del a # A의 카운트 = 1 (B가 여전히 참조)
del b # B의 카운트 = 1 (A가 여전히 참조)
이 상태에서 두 리스트는 더 이상 어떤 변수로도 접근할 수 없지만, 서로를 참조하고 있어 카운트가 0이 되지 않습니다. 따라서 일반적인 참조 카운팅으로는 이들을 회수할 수 없습니다.
마크 앤 스윕 (Mark and Sweep)
순환 참조 문제를 해결하기 위해 파이썬은 '마크 앤 스윕' 알고리즘을 사용합니다. 이 방식은 메모리 부족이 임박했을 때 실행됩니다.
동작 과정은 다음과 같습니다:
- 마크 단계: 프로그램 실행을 일시 중단하고, 루트 집합(전역 변수, 스택 변수 등)에서 시작하여 도달 가능한 모든 객체를 추적합니다. 도달되지 않는 객체는 '가비지'로 마킹됩니다.
- 스윕 단계: 마킹된 객체들을 메모리에서 해제합니다.
이 과정에서 순환 참조된 객체들도 변수에서 도달할 수 없기 때문에 마킹되어 제거됩니다.
세대별 수집 (Generational Collection)
매번 전체 메모리를 스캔하는 마크 앤 스윕은 비용이 큽니다. 파이썬은 이를 최적화하기 위해 객체를 세대(Generation)로 나누어 관리합니다.
- 0세대: 새로 생성된 객체들. 가장 자주 검사합니다.
- 1세대: 0세대에서 한 번 이상 살아남은 객체들. 덜 자주 검사합니다.
- 2세대: 1세대에서 살아남은 객체들. 가장 드물게 검사합니다.
기본 아이디어는 간단합니다: 오래된 객체일수록 순환 참조를 만들 가능성이 낮다는 경험적 가정에 기반합니다. 새 객체는 빠르게 생성되고 버려지는 경향이 있으므로 자주 검사하고, 오래된 객체는 상대적으로 안정적이므로 검사 빈도를 낮춥니다. 이를 통해 전체적인 GC 오버헤드를 줄입니다.
각 세대의 GC가 실행되는 임계값은 gc.get_threshold()로 확인할 수 있으며, 기본값은 보통 (700, 10, 10)입니다. 이는 0세대 객체가 700개 늘어나면 0세대 GC가 실행되고, 0세대 GC가 10번 실행되면 1세대 GC가, 1세대 GC가 10번 실행되면 2세대 GC가 실행됨을 의미합니다.