Python과 메모리 관리, 파트 1: 객체
Source: Dev.to
번역할 텍스트가 제공되지 않았습니다. 번역을 원하는 본문을 알려주시면 한국어로 번역해 드리겠습니다.
값 동등성 – ==
==는 두 객체가 동일한 데이터를 포함하고 있는지 확인합니다. a == b를 작성하면 파이썬은 객체에 저장된 값을 비교합니다. 여기에는 __eq__와 같은 특수 메서드 호출이 포함될 수 있습니다.
# == compares values
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True – same content
내부적으로 ==는 더 복잡한 연산을 트리거할 수 있습니다:
- 리스트의 경우 각 요소를 비교합니다.
- 사용자 정의 객체의 경우 객체의
__eq__메서드를 호출합니다.
class SensorValue:
def __init__(self, value, tolerance):
self.value = int(value)
self.tolerance = int(tolerance)
def __eq__(self, other):
# Equality within tolerance
return abs(self.value - other.value)
Note (Python 3.14+) –
sys._is_immortal(obj)(비공개 API)를 통해 객체가 영구적인지 조회할 수 있습니다.
7. 요약
| 연산자 / 함수 | 무엇을 검사하는가 | 일반적인 사용 사례 |
|---|---|---|
== | 값 동등성 (__eq__) | 데이터 비교 |
is | 정체성 (같은 메모리 주소) | 싱글톤 검사 (is None) |
id() | 객체의 “주소” (정수) | 디버깅 / 내부 조사 |
이 차이를 이해하면 다음과 같은 현상을 알 수 있습니다:
- 작은 정수는 캐시되어 동일한 객체처럼 보입니다.
- 큰 정수는 명시적으로 여러 이름에 바인딩할 때만 공유됩니다.
- 레퍼런스 카운팅이 메모리 회수를 담당하지만, 영원한 객체는 이를 우회합니다.
이 지식을 갖추면 보다 예측 가능하고 메모리를 고려한 파이썬 코드를 작성할 수 있으며, 동등성과 정체성을 혼동해서 발생하는 미묘한 버그를 피할 수 있습니다.
문자열 인터닝
Python은 일부 문자열에 대해 특별한 최적화를 수행합니다: 문자열을 캐시하고 캐시된 객체를 인터닝된 문자열로 저장합니다. 인터닝된 문자열은 “불멸” 객체처럼 동작하며, 인터프리터가 실행되는 동안 절대 해제되지 않습니다. 공식 설명은 sys.intern 문서를 참고하세요.
Note – Python은 여러 문자열(예: 식별자, 짧은 리터럴)을 암묵적으로 인터닝합니다. 이 때문에
is연산자를 사용해 겉보기에는 같은 문자열을 비교할 때, 실제로는 같은 객체가 아니므로 예상치 못한 결과가 나올 수 있습니다.
Small‑integer memory layout
id()는 객체의 메모리 주소를 반환합니다. 연속된 작은 정수들의 주소를 살펴보면 규칙적인 패턴을 볼 수 있습니다:
for i in range(5):
print(f"id({i+1}) - id({i}) = {id(i+1) - id(i)}")
그 차이는 32 바이트입니다. 이것이 파이썬 정수 객체의 크기일까요?
import sys
print(f"{sys.getsizeof(1)=}") # sys.getsizeof(1)=28
sys.getsizeof(1)은 28 바이트를 보고합니다 – 객체 데이터의 실제 크기.id차이를 출력했을 때 보이는 32‑바이트 간격은 CPython이 작은 정수를 연속 배열에 할당하는 방식 때문이며, 각 슬롯이 32 바이트를 차지하지만 실제 객체 자체는 28 바이트만 사용합니다.
그 32 바이트를 차지하는 것은 무엇인가?
| Offset | Size | Meaning |
|---|---|---|
| 0 – 7 | 8 B | Reference count (ob_refcnt). “불멸” 작은 정수의 경우 이 필드에 매직 값 0xC0000000이 들어갑니다. |
| 8 – 15 | 8 B | 타입 객체에 대한 포인터 (ob_type). |
| 16 – 23 | 8 B | Size field – 정수를 구성하는 digits의 개수 (파이썬 정수는 임의 정밀도를 가집니다). |
| 24 – 27 | 4 B | 첫 번째 digit (ob_digit[0]). 더 큰 숫자는 추가 digit을 할당합니다. |
| 28 – 31 | 4 B | 8‑바이트 정렬을 위한 패딩. |
CPython 객체 레이아웃 (C 수준)
모든 Python 객체는 PyObject 헤더로 시작합니다:
typedef struct _object {
Py_ssize_t ob_refcnt; // reference count (or immortal flag)
PyTypeObject *ob_type; // pointer to type object
} PyObject;
정수 (long) 객체 정의
CPython 소스(cpython/Include/cpython/longintrepr.h)에서:
typedef struct _PyLongValue {
uintptr_t lv_tag; // number of digits, sign, flags
digit ob_digit[1]; // array of 30‑bit “digits” (uint32)
} _PyLongValue;
struct _longobject { // the public integer object
PyObject_HEAD // expands to the PyObject header above
_PyLongValue long_value; // the actual numeric data
};
헤더는 메모리 관리자가 객체를 검색하고 가비지 컬렉션을 수행하는 데 사용됩니다.
레퍼런스 카운팅
CPython 메모리 관리의 핵심은 레퍼런스 카운팅입니다. 각 객체는 자신을 가리키는 레퍼런스가 몇 개인지를 추적하는 카운터를 저장합니다. 카운터가 0이 되면 메모리가 해제됩니다.
sys.getrefcount() 를 사용하면 레퍼런스 카운트를 확인할 수 있습니다 (이 함수는 전달한 인자에 대해 하나의 추가 레퍼런스를 더합니다).
import sys
x = [1, 2, 3]
print(f"Initial refcount: {sys.getrefcount(x)=}") # 2 (x + argument)
y = x
print(f"Assigning y <- x: {sys.getrefcount(x)=}") # 3
z = x
print(f"Assigning z <- x: {sys.getrefcount(x)=}") # 4
del y
print(f"De‑assigning y: {sys.getrefcount(x)=}") # 3
z = None
print(f"De‑assigning z: {sys.getrefcount(x)=}") # 2
del x
print("Deleted x")
레퍼런스 카운트가 0으로 떨어지면 CPython은 즉시 객체를 해제합니다.
약한 참조와 순환 참조
약한 참조는 참조 카운트를 증가시키지 않으며, 약한 참조가 남아 있더라도 객체가 회수될 수 있게 합니다.
import sys
import weakref
class Node:
def __init__(self, child):
self.child = child
a = Node(None) # refcnt = 1
b = Node(None)
a_ref = weakref.ref(a) # 약한 참조, refcnt 증가 없음
a.child = b
b.child = a # 순환 생성, 각 객체의 refcnt = 2
print(f"All refs a, b, and a_ref assigned: {sys.getrefcount(a_ref())=}") # 3 (2 + argument)
del a
print(f"After variable A deleted: {sys.getrefcount(a_ref())=}") # 3 (순환 때문에 아직 살아 있음)
del b
print(f"After variable B deleted: {sys.getrefcount(a_ref())=}") # 2 (객체가 해제됐지만 약한 참조는 여전히 None을 반환)
왜 단순한 참조 카운팅만으로는 순환을 끊을 수 없는가
객체들이 서로를 참조하는 경우(순환), 그들의 참조 카운트가 절대 0이 되지 않아 메모리 누수가 발생합니다. 따라서 CPython은 이러한 순환을 감지하고 수집할 수 있는 세대별 가비지 컬렉터를 추가합니다.
세대별 가비지 컬렉터
컬렉터는 세대 가설에 기반합니다:
- 대부분의 객체는 젊은 시절에 사라집니다 (≈ 80‑90 %).
- 오래된 객체는 젊은 객체를 거의 참조하지 않습니다.
CPython은 세 개의 세대를 유지합니다:
| 세대 | 실행 시점 | 설명 |
|---|---|---|
| 0 | Every gc.get_threshold()[0] allocations (default 700) | New objects. |
| 1 | Every gc.get_threshold()[1] collections of generation 0 (default 10) | Objects that survived one Gen 0 collection. |
| 2 | Every gc.get_threshold()[2] collections of generation 1 (default 10) | Long‑living objects. |
import gc
print(f"GC generations thresholds: {gc.get_threshold()}")
# (700, 10, 10) by default
세대 0 컬렉션은 일정 수의 할당이 이루어진 뒤에만 발생하므로, 새로 만든 객체(또는 약한 참조 객체)는 모든 강한 참조가 사라진 뒤에도 짧은 시간 동안 살아 있을 수 있습니다. 이는 위 예시에서 약한 참조 a_ref()가 a와 b를 삭제한 뒤에도 여전히 접근 가능했던 이유를 설명합니다—순환 가비지가 아직 수집되지 않았기 때문입니다.
TL;DR
- 문자열 인터닝은 특정 문자열을 캐시하여 사실상 영원히 유지됩니다.
- 작은 정수는 미리 할당된 연속 배열에 저장됩니다; 각 슬롯은 32 바이트를 차지하지만 객체 자체는 28 바이트를 사용합니다.
- 모든 CPython 객체는
PyObject헤더(ob_refcnt+ob_type)로 시작합니다. - 레퍼런스 카운팅은 카운트가 0이 되면 자동으로 객체를 해제합니다.
- 약한 참조를 사용하면 객체에 대한 소유권이 없는 포인터를 보유할 수 있습니다.
- 순환 참조는 세대 가비지 컬렉터에 의해 처리되며, 이는 할당 임계값에 따라 주기적으로 실행됩니다.