Python과 메모리 관리, 파트 1: 객체

발행: (2026년 2월 8일 오전 06:06 GMT+9)
11 분 소요
원문: Dev.to

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 바이트를 차지하는 것은 무엇인가?

OffsetSizeMeaning
0 – 78 BReference count (ob_refcnt). “불멸” 작은 정수의 경우 이 필드에 매직 값 0xC0000000이 들어갑니다.
8 – 158 B타입 객체에 대한 포인터 (ob_type).
16 – 238 BSize field – 정수를 구성하는 digits의 개수 (파이썬 정수는 임의 정밀도를 가집니다).
24 – 274 B첫 번째 digit (ob_digit[0]). 더 큰 숫자는 추가 digit을 할당합니다.
28 – 314 B8‑바이트 정렬을 위한 패딩.

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은 세 개의 세대를 유지합니다:

세대실행 시점설명
0Every gc.get_threshold()[0] allocations (default 700)New objects.
1Every gc.get_threshold()[1] collections of generation 0 (default 10)Objects that survived one Gen 0 collection.
2Every 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()ab를 삭제한 뒤에도 여전히 접근 가능했던 이유를 설명합니다—순환 가비지가 아직 수집되지 않았기 때문입니다.

TL;DR

  • 문자열 인터닝은 특정 문자열을 캐시하여 사실상 영원히 유지됩니다.
  • 작은 정수는 미리 할당된 연속 배열에 저장됩니다; 각 슬롯은 32 바이트를 차지하지만 객체 자체는 28 바이트를 사용합니다.
  • 모든 CPython 객체는 PyObject 헤더(ob_refcnt + ob_type)로 시작합니다.
  • 레퍼런스 카운팅은 카운트가 0이 되면 자동으로 객체를 해제합니다.
  • 약한 참조를 사용하면 객체에 대한 소유권이 없는 포인터를 보유할 수 있습니다.
  • 순환 참조세대 가비지 컬렉터에 의해 처리되며, 이는 할당 임계값에 따라 주기적으로 실행됩니다.
0 조회
Back to Blog

관련 글

더 보기 »

Python–TypeScript 계약

The Python–TypeScript Contract 표지 이미지 https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to...

문제 13: 애너그램 그룹화

개요: 주어진 문자열 리스트에서 서로 애너그램인 단어들을 그룹화하는 함수를 작성하는 것이 과제입니다. 애너그램은 단어나 구가 ...