파이썬 메모리 관리와 가비지 컬렉션: 동작 원리 이해

파이썬 메모리 관리와 가비지 컬렉션: 동작 원리 이해

파이썬의 메모리 관리 및 가비지 컬렉션(Garbage Collection, GC) 메커니즘은 '참조 카운팅(Reference Counting)'을 기본으로 하며, '표시-삭제(Mark and Sweep)'와 '세대별 수집(Generational Collection)'이 보조적인 역할을 수행합니다. 여기에 '캐싱 메커니즘'이 더해져 효율성을 높입니다.

1. 참조 카운팅

파이썬 프로그램에서 생성되는 모든 객체는 내부적으로 refchain이라는 이중 연결 리스트에 저장됩니다. 이는 모든 데이터 객체 인스턴스가 연결되는 고리입니다. C 언어로 구현된 파이썬의 하위 레벨에서는 객체가 생성될 때 메모리 공간에 특정 구조체가 생성된다고 볼 수 있습니다. 이중 연결 리스트 기능을 구현하기 위해 이 구조체는 공통된 요소를 포함하는데, 주요 네 가지 요소는 다음과 같습니다: 이전 객체를 가리키는 포인터, 다음 객체를 가리키는 포인터, 해당 객체의 데이터 타입, 그리고 참조 횟수(해당 구조체 메모리를 참조하는 변수의 수)입니다. 물론 데이터 타입에 따라 구조체에 저장되는 데이터 값 등에서 차이가 발생합니다.

1.1. 내부 객체 구조

C 언어 소스 코드를 통해 객체 구조의 구현 방식을 살펴보겠습니다:


#define PY_OBJECT_HEAD       PyObject obj_base;
#define PY_OBJECT_VAR_HEAD   PyVarObject obj_base;

// 이중 연결 리스트 구성을 위한 이전/다음 객체 포인터
#define _PY_OBJECT_HEAD_EXTRA \
    struct _object *obj_next; \
    struct _object *obj_prev;

typedef struct _object {
    _PY_OBJECT_HEAD_EXTRA // 이중 연결 리스트를 위한 추가 필드
    Py_ssize_t obj_ref_count; // 참조 카운터 (해당 객체 메모리 공간의 참조 횟수)
    struct _typeobject *obj_type_ptr; // 데이터 타입 포인터
} PyObject;

typedef struct {
    PyObject obj_base; // 기본 PyObject 구조체
    Py_ssize_t obj_size_val; // 요소 개수 (가변 크기 객체용)
} PyVarObject;
    

즉, C 소스 코드에서 각 객체가 공통적으로 가지는 값은 PyObject 구조체(4개 값)에 명시됩니다. 여러 요소를 가지는 객체(예: 리스트, 딕셔너리)의 경우, PyObject 구조체(4개 값)에 obj_size_val(요소 개수)이 추가됩니다.


// float 타입 구조체 정의 예시
typedef struct {
    PY_OBJECT_HEAD
    double float_value; // 실제 float 값
} PyFloatObject;

// list 타입 구조체 정의 예시
typedef struct {
    PY_OBJECT_VAR_HEAD
    PyObject **list_items_ptr; // PyObject 포인터들의 배열을 가리키는 포인터
    Py_ssize_t allocated_size; // 현재 할당된 공간 크기
} PyListObject;
    

위 예시에서 볼 수 있듯이, float 타입 객체는 PyObject 구조체에 float_value 필드를 추가하여 실제 값을 저장합니다. 반면 list 타입은 PY_OBJECT_VAR_HEAD를 사용하여 기본 변수 상태를 정의합니다. list_items_ptrPyObject 타입 포인터의 포인터입니다. 리스트는 여러 PyObject 타입 객체를 담을 수 있는 컨테이너이며, list_items_ptr는 연속된 메모리 공간을 가리키는데, 이 공간에는 n개의 포인터가 저장되고 각 포인터는 개별 PyObject 객체를 가리킵니다. allocated_size는 현재 list_items_ptr에 할당된 공간의 크기를 나타냅니다. 이로 미루어 볼 때, 리스트는 순차적인 배열(sequential array)과 유사하게 구현됩니다.

1.2. 참조 카운트의 변화

파이썬 프로그램이 실행되면 데이터 타입에 따라 해당하는 구조체를 찾아 관련 데이터를 생성하고, 이 객체를 refchain 이중 연결 리스트에 추가합니다. 각 객체는 obj_ref_count라는 참조 카운터를 가지며, 이 값은 기본적으로 1로 초기화됩니다. 다른 변수가 이 객체를 참조하면 참조 카운터가 변경됩니다.


x = 3.14  # float 객체 생성, 참조 카운터 = 1
y = x     # y가 동일 객체를 참조, 참조 카운터 = 2
del y     # y 변수 삭제, 참조 카운터 = 1
del x     # x 변수 삭제, 참조 카운터 = 0. 메모리 공간 해제
    

객체의 참조 카운터가 0이 되면, 더 이상 이 객체를 사용하는 변수가 없음을 의미하므로, 해당 객체는 가비지(쓰레기)로 간주되어 회수됩니다. 이 과정은 다음과 같습니다:

  • 객체가 refchain 리스트에서 제거됩니다.
  • (간단히 말해) 객체가 파괴되고 메모리가 시스템에 반환됩니다.

1.3. 참조 카운팅의 한계: 순환 참조

참조 카운팅 방식만 사용하면 '순환 참조(Circular Reference)'라는 심각한 문제가 발생할 수 있습니다. 변수들이 서로를 참조하여 순환 고리를 형성하면, 외부에서 해당 객체들을 더 이상 참조하지 않더라도 참조 카운터가 0이 되지 않아 메모리가 해제되지 않습니다. 이는 메모리 누수로 이어지는 '더티 데이터(dirty data)'가 됩니다.


item1 = [1, 2, 3] # 리스트 객체 생성, 참조 카운터 = 1 (item1이 참조)
item2 = [4, 5, 6] # 리스트 객체 생성, 참조 카운터 = 1 (item2가 참조)

item1.append(item2) # item1이 item2를 참조, item2 객체의 참조 카운터 +1 -> 2
item2.append(item1) # item2가 item1을 참조, item1 객체의 참조 카운터 +1 -> 2

del item1 # item1 변수 삭제, item1 객체의 참조 카운터 -1 -> 1
del item2 # item2 변수 삭제, item2 객체의 참조 카운터 -1 -> 1

# 모든 변수가 삭제되었지만, 두 객체는 여전히 서로를 참조하므로 참조 카운터가 0이 되지 않아 메모리가 해제되지 않습니다.
# 이는 아무 변수도 참조하지 않지만 해제되지 않는 '더티 데이터' 상태가 됩니다.
    

2. 표시-삭제 (Mark and Sweep)

참조 카운팅의 순환 참조 문제를 해결하기 위해 '표시-삭제' 기법이 도입되었습니다.

2.1. 동작 방식

파이썬 내부에서는 순환 참조가 발생할 가능성이 있는 객체들(list, tuple, dict, set 등 컨테이너 타입)을 별도의 연결 리스트로 관리합니다. 가비지 컬렉터가 작동할 시점에 이 리스트를 스캔하여 순환 참조를 탐지합니다. 탐지된 순환 참조 객체들의 참조 카운터를 일시적으로 감소시키면서 실제 도달 가능성(reachability)을 판단합니다. 만약 참조 카운터가 0이 되는 객체가 있다면, 이는 순환 참조에 의해 살아있는 것처럼 보였을 뿐 실제로는 도달 불가능한 객체로 간주하여 메모리에서 회수합니다.

2.2. 한계점

표시-삭제 기법은 순환 참조를 해결하지만 두 가지 주요 문제가 있습니다. 첫째, 순환 참조 가능성이 있는 객체 리스트를 언제 스캔해야 하는지 결정하는 문제입니다. 둘째, 이 리스트의 모든 요소를 스캔하는 것은 매번 상당한 비용이 들고 시간이 오래 걸릴 수 있다는 점입니다.

3. 세대별 수집 (Generational Collection)

표시-삭제의 효율성 문제를 해결하기 위해 파이썬은 '세대별 수집' 메커니즘을 도입했습니다. 이는 순환 참조 가능성이 있는 객체들을 하나의 리스트로 관리하는 대신, 세 개의 세대로 나누어 관리하는 방식입니다.

3.1. 세대 구성 및 스캔 조건

  • 0세대 (Generation 0): 새로 생성된 객체들이 처음 들어가는 세대입니다. 이 세대의 객체 수가 특정 임계값(기본 700개)에 도달하면 0세대를 스캔합니다.
  • 1세대 (Generation 1): 0세대 스캔이 10번 발생하면 1세대를 스캔합니다. 0세대에서 살아남은 객체들은 1세대로 승격됩니다.
  • 2세대 (Generation 2): 1세대 스캔이 10번 발생하면 2세대를 스캔합니다. 1세대에서 살아남은 객체들은 2세대로 승격됩니다.

이 메커니즘은 대부분의 객체가 짧은 수명을 가진다는 약한 세대 가설(weak generational hypothesis)에 기반합니다. 자주 스캔해야 하는 0세대 객체의 수는 적고, 오래 살아남아 2세대까지 승격된 객체는 더 적게 스캔하여 전체 가비지 컬렉션의 효율을 높입니다.

4. 캐싱 메커니즘

파이썬은 메모리 관리 프로세스를 더욱 최적화하기 위해 캐싱 메커니즘을 사용합니다. 이는 크게 '풀(Pool)'과 '프리 리스트(Free List)'의 두 가지로 나눌 수 있습니다.

4.1. 풀 (Pool)

풀은 주로 작은 정수(int)와 짧은 문자열(str) 객체에 사용됩니다. 파이썬 인터프리터는 시작 시점에 -5부터 256까지의 정수 객체를 미리 생성하여 풀에 저장해둡니다. 따라서 이 범위 내의 정수를 사용할 때는 새로운 메모리를 할당하는 대신 풀에서 이미 생성된 객체를 직접 가져와 사용합니다. 이는 객체 생성 및 소멸에 드는 비용을 줄여줍니다.


# 파이썬 인터프리터 시작 시 -5 ~ 256 범위의 정수 객체들이 미리 생성됩니다.
val1 = 7    # 새로운 메모리 할당 없이 풀에서 7 객체 참조
val2 = 9    # 새로운 메모리 할당 없이 풀에서 9 객체 참조
val3 = 9    # 새로운 메모리 할당 없이 풀에서 9 객체 참조
print(id(val2) == id(val3)) # 출력: True (동일한 메모리 주소)
    

4.2. 프리 리스트 (Free List)

프리 리스트는 float, list, tuple, dict 등 다른 유형의 객체에 주로 적용됩니다. 객체의 참조 카운터가 0이 되어 일반적으로 회수되어야 할 때, 파이썬은 해당 객체를 즉시 메모리에서 해제하지 않고 '프리 리스트'라는 캐시 목록에 추가합니다. 이후 동일한 타입의 객체가 필요할 때, 새로운 메모리 할당 대신 프리 리스트에서 기존 객체를 재사용하여 초기화 과정을 거칩니다. 프리 리스트가 가득 차면 오래된 객체부터 실제 메모리에서 해제됩니다.


f1 = 3.14  # float 객체 생성 및 refchain에 추가
del f1     # f1 객체는 refchain에서 제거되지만, 즉시 해제되지 않고 free_list에 추가됩니다.
f2 = 999.99 # 새로운 메모리 할당 없이 free_list에서 객체를 가져와 데이터만 초기화 후 refchain에 추가
    

5. CPython 내부 구현 (Float 객체 예시)

float 객체의 생성, 참조, 소멸 과정이 CPython 내부에서 어떻게 처리되는지 자세히 살펴보겠습니다.


// float 타입 객체 구조체
typedef struct {
    PY_OBJECT_HEAD
    double float_value;
} PyFloatObject;
    

5.1. Float 객체 생성 시

파이썬 코드 my_val = 3.14 실행 시, 내부적으로 다음과 유사한 C 코드가 실행됩니다.


/* 전용 프리 리스트 (float_free_list)
   float_free_list는 obj_type_ptr 멤버를 이용하여 연결된 PyFloatObject들의 단일 연결 리스트입니다.
*/
static PyFloatObject *float_free_list = NULL;
static int float_num_free = 0;
  
PyObject *
PyFloat_FromDouble(double actual_val)
{
    PyFloatObject *new_float_obj = float_free_list; // 프리 리스트 확인
    if (new_float_obj != NULL) { // 프리 리스트에 사용 가능한 객체가 있다면
        float_free_list = (PyFloatObject *) Py_TYPE(new_float_obj); // 다음 객체로 리스트 헤드 이동
        float_num_free--; // 프리 객체 수 감소
    } else {   
        // 1단계: float 타입 크기만큼 메모리 할당
        new_float_obj = (PyFloatObject*) PyObject_MALLOC(sizeof(PyFloatObject));
        if (!new_float_obj)
            return PyErr_NoMemory();
    }
    // 2단계: 할당된 메모리에 객체 초기화 (PyObject_INIT 매크로)
    (void)PyObject_INIT(new_float_obj, &PyFloat_Type);    
    // 3단계: 실제 값 할당
    new_float_obj->float_value = actual_val;
    // 4단계: 생성된 float 객체의 주소 반환 (참조/포인터)
    return (PyObject *) new_float_obj;
} 
    

메모리 할당 및 초기화 과정은 다음과 같습니다:


void *PyObject_Malloc(size_t required_size)
{
    // 크기 유효성 검사 등
    if (required_size > (size_t)PY_SSIZE_T_MAX)
        return NULL;
    // 실제 메모리 할당 함수 호출
    return _PyObject.malloc(_PyObject.ctx, required_size);
}

// 새로운 참조 생성 및 초기화
#define PyObject_INIT(obj_ptr, type_ptr) \
    ( Py_TYPE(obj_ptr) = (type_ptr), _Py_NewReference((PyObject *)(obj_ptr)), (obj_ptr) )

static PyObject global_ref_chain = {&global_ref_chain, &global_ref_chain}; // refchain 헤드

void _Py_AddToAllObjects(PyObject *obj_to_add, int force_add)
{
    if (force_add || obj_to_add->obj_prev == NULL) {
        obj_to_add->obj_next = global_ref_chain.obj_next;
        obj_to_add->obj_prev = &global_ref_chain;
        global_ref_chain.obj_next->obj_prev = obj_to_add;
        global_ref_chain.obj_next = obj_to_add;
    }
}

void _Py_NewReference(PyObject *obj_ptr)
{
    _Py_INC_REFTOTAL;
    // 새로 할당된 메모리의 참조 카운터 1로 초기화
    obj_ptr->obj_ref_count = 1;
    // 새 객체의 포인터를 이중 연결 리스트 (global_ref_chain)에 추가
    _Py_AddToAllObjects(obj_ptr, 1);
    _Py_INC_TPALLOCS(obj_ptr);
}
    

5.2. Float 객체 참조 시

파이썬 코드 data = my_val 실행 시, 내부적으로 다음 매크로가 호출되어 참조 카운터가 증가합니다.


// 객체에 새 참조가 생성될 때 참조 카운터 +1
#define Py_INCREF(obj_ptr) (                         \
    _Py_INC_REFTOTAL  _Py_REF_DEBUG_COMMA       \
    ((PyObject *)(obj_ptr))->obj_ref_count++)
    

5.3. Float 객체 소멸 시

파이썬 코드 del my_val 실행 시, 내부적으로 다음 매크로가 호출되어 참조 카운터가 감소하고, 0이 되면 소멸 절차를 시작합니다.


#define Py_DECREF(obj_ptr)                                   \
    do {                                                \
        PyObject *_temp_decref_obj = (PyObject *)(obj_ptr);    \
        if (_Py_DEC_REFTOTAL  _Py_REF_DEBUG_COMMA       \
        --(_temp_decref_obj)->obj_ref_count != 0)             \
            _Py_CHECK_REFCNT(_temp_decref_obj)            \
        else                                            \
        _Py_Dealloc(_temp_decref_obj);                    \
    } while (0)
    

_Py_Dealloc 함수는 실제 객체 소멸을 담당합니다.


void
_Py_Dealloc(PyObject *obj_to_dealloc)
{
    // 1단계: 객체 타입에 정의된 tp_dealloc 함수 (float_dealloc) 호출
    destructor dealloc_func = Py_TYPE(obj_to_dealloc)->tp_dealloc;
    // 2단계: refchain 이중 연결 리스트에서 객체 제거
    _Py_ForgetReference(obj_to_dealloc);
    (*dealloc_func)(obj_to_dealloc); // 실제 소멸 로직 실행
}
// 파일: Objects/object.c
    

float 객체의 tp_dealloc 함수(float_dealloc)는 캐싱 메커니즘 때문에 일반적인 메모리 해제와는 다른 방식으로 작동합니다.

  • float 프리 리스트의 캐시 개수가 PyFloat_MAXFREELIST(기본 100개) 이상일 경우, del my_val 실행 시 해당 객체는 메모리에서 즉시 삭제됩니다.
  • 캐시 개수가 PyFloat_MAXFREELIST 미만일 경우, del my_val 실행 시 객체는 메모리에서 파괴되지 않고, float_free_list라는 단일 연결 리스트에 추가되어 캐시로 보관됩니다. 이는 나중에 동일한 타입의 객체가 필요할 때 재사용하기 위함입니다.

#ifndef PyFloat_MAXFREELIST
#define PyFloat_MAXFREELIST    100 // 프리 리스트 최대 크기
#endif
static int float_num_free = 0;
static PyFloatObject *float_free_list = NULL;
    
PyTypeObject PyFloat_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "float",
    sizeof(PyFloatObject),
    0,
    (destructor)float_dealloc, // tp_dealloc은 float_dealloc 함수를 가리킴
    0, /* tp_print */
    0, /* tp_getattr */
    0, /* tp_setattr */
    0, /* tp_reserved */
    // ...
};

static void float_dealloc(PyFloatObject *obj_to_free)
{
    // float 타입인지 확인
    if (PyFloat_CheckExact(obj_to_free)) {
        // 프리 리스트 개수 확인
        if (float_num_free >= PyFloat_MAXFREELIST)  {
            // 최대 개수 초과 시, 메모리에서 객체 파괴
            PyObject_FREE(obj_to_free);
            return;
        }
        // 그렇지 않으면, 프리 리스트 개수 증가
        // 객체를 float_free_list 단일 연결 리스트에 추가하여 향후 float 객체 생성 시 재사용
        float_num_free++;
        Py_TYPE(obj_to_free) = (struct _typeobject *)float_free_list;
        float_free_list = obj_to_free;
    }
    else {
        Py_TYPE(obj_to_free)->tp_free((PyObject *)obj_to_free);
    }
}
    

태그: python 메모리관리 가비지컬렉션 참조카운팅 마크앤스윕

7월 25일 02:21에 게시됨