R

ReflexKR

AI의 일기장Reflex 공식 문서

이름표 공항: 파이썬 객체 모델

·

강의 개요

1

태그는 가방이 아니다: 이름과 객체

2

봉인된 가방: 가변과 불변

3

가방 속 가방: 컨테이너와 복사

4

가방을 넘겨줄 때: 함수와 인자

5

구역과 출입증: 스코프

6

기내용 가방: 클로저

7

가방 규격서: 클래스와 인스턴스

8

공항 표준 절차: 던더 메서드

9

규격서의 규격서: 상속과 MRO

10

검색대: 속성 접근의 비밀

11

주인 없는 가방: 메모리와 GC

12

사물함 번호: 해시와 동등성, 종합

이름표 공항: 파이썬 객체 모델
11교시

강의 시간 8:20–9:10

주인 없는 가방: 메모리와 GC

분실 신고서

LOST-011

13번 컨베이어의 유령 가방

신고인

야간 설비 점검원 윤 기사

잃어버린 것

태그가 하나도 없는데 폐기되지 않는 가방 두 개. 열하루째 13번 컨베이어를 돌고 있다.

해결 조건

두 가방이 폐기되지 않는 이유를 밝히고, 치운 뒤 다시 생기지 않게 한다.

하늘섬 국제공항 수하물 센터

첫날부터 보고가 올라오던 13번 컨베이어의 두 가방이 오늘도 돌고 있습니다. 태그는 하나도 없습니다. 규정대로라면 태그 없는 가방은 그날 바로 폐기장으로 가야 합니다.

도미란 반장이 지난 기록 세 장을 책상에 펼칩니다. 3교시의 엑스레이 사진, 6교시의 메모, 7교시의 partner 기록입니다. "오늘은 저 둘의 정체를 밝히자. 그 전에 폐기 규칙부터 정확히 알아야 해."

11-1. 태그 수 세기: 참조 횟수 (9분)

CPython의 모든 객체는 자기를 가리키는 참조가 몇 개인지 세는 숫자를 갖고 있습니다. 이것이 참조 횟수(reference count)입니다. 태그를 달면 1 늘고, 떼면 1 줄고, 0이 되는 순간 그 자리에서 폐기됩니다.

import sys

a = []
print(sys.getrefcount(a))
# 2
b = a
print(sys.getrefcount(a))
# 3
c = [a, a]
print(sys.getrefcount(a))
# 5
del b, c
print(sys.getrefcount(a))
# 2
  • 처음 값이 1이 아니라 2인 것은, sys.getrefcount(a)를 부르며 인자로 넘긴 참조까지 세기 때문입니다.
  • c = [a, a]는 리스트 안에 태그 두 개를 꽂으니 2가 늘어납니다. 가방 안에 꽂힌 태그도 태그입니다.
  • del b, c로 이름 b가 떨어지고, 리스트 c가 폐기되면서 그 안의 태그 두 개도 함께 사라집니다.

숫자 자체에 기대지는 마세요. 3.14부터 인터프리터가 일부 참조 증감을 건너뛰는 최적화를 합니다. 같은 코드를 함수 안에서 돌리면 처음 값이 CPython 3.14에서는 1, 3.13에서는 2입니다. 볼 것은 "태그 하나에 1"이라는 차이입니다.

0이 되는 순간 폐기된다는 것은 __del__로 확인할 수 있습니다. 객체가 폐기되기 직전에 불리는 던더 메서드입니다(믿으면 안 되는 이유는 11-4에서).

class Bag:
    def __init__(self, label):
        self.label = label
    def __del__(self):
        print("discard", self.label)

a = Bag("A")
b = a
del a
print("after del a")
del b
print("after del b")
# after del a
# discard A
# after del b

def check_in():
    tmp = Bag("T")
    print("inside")

check_in()
print("back")
# inside
# discard T
# back
  • del a 뒤에도 태그 b가 남아 있어 폐기되지 않습니다. 마지막 태그를 떼는 del b에서 바로 "discard A"가 찍힙니다.
  • 함수가 끝나면 호출 칸이 사라지며 지역 이름 tmp도 떨어집니다. 그래서 "back"보다 먼저 폐기됩니다.
  • 6교시에서 반장이 말한 것이 이것입니다. 끝난 함수의 물건이라도 클로저의 셀이 붙잡고 있으면 참조 횟수가 0이 되지 않아 남습니다.

아래 GC 관측대는 일꾼 프로세스에서 진짜 객체를 만들고 진짜 gc를 돌립니다. 가방마다 "참조 수"(이름표 + 다른 가방 안에 꽂힌 태그)가 보입니다. 어떤 이름표에서 출발해 가방 안을 따라가도 닿지 않는 가방은 빨간 점선으로 그려집니다. 점선 태그는 w() → …로 지금 무엇을 가리키는지 보여 줍니다.

GC 관측대

Python

tags에서는 태그를 뗄 때마다 참조 수가 1씩 줄고, 0이 되는 작업에서 폐기됩니다. chain의 4번째 작업에서 b는 이름표가 없지만 a 안에 있어 아직 참조 수가 1이고, a가 폐기될 때 함께 폐기됩니다.

핀의 안내

→ sys.getrefcount의 숫자는 버전과 위치(모듈인지 함수 안인지)에 따라 달라요. 절대값 말고, 태그를 하나 달고 뗄 때 1씩 움직이는 '차이'를 보세요.

11-2. 서로를 품은 두 가방: 순환 참조와 GC (12분)

세 장의 기록을 이어 붙이면 정체가 나옵니다. 3교시 엑스레이의 [[...]]는 가방이 자기 자신을 품은 모습이었습니다. 7교시 기록에서 두 가방은 partner 속성으로 서로를 가리켰습니다. 둘을 그대로 코드로 옮기면 이렇습니다.

태그 보드

단계 1 / 0

다음에 실행할 줄: 0

실습대를 준비하는 중…

방금 바뀐 것

봉인된 가방(불변)

9번 줄 뒤 두 가방이 partner로 서로를 가리킵니다. 10번 줄 뒤 보드에서 두 가방이 사라지는 것은, 보드가 이름에서 출발해 닿는 가방만 그리기 때문입니다.

del a, b로 이름표는 모두 떨어졌습니다. 그런데 A 안에는 B를 가리키는 태그가, B 안에는 A를 가리키는 태그가 남아 있습니다. 둘 다 참조 횟수가 1이라 영영 0이 되지 않습니다. 이것이 순환 참조(reference cycle)입니다.

이름에서 출발해 닿을 수 없는 객체는 프로그램이 다시 쓸 방법이 없는 쓰레기입니다. 태그 보드에서 사라진 것도 그래서입니다. 하지만 참조 횟수만으로는 이 쓰레기를 치울 수 없습니다.

그래서 CPython에는 두 번째 청소부가 있습니다. 순환 가비지 컬렉터(gc 모듈)입니다.

  • 리스트, dict, 인스턴스처럼 다른 객체를 담을 수 있는 객체를 살핍니다.
  • 그 무리 안에서 서로 주고받는 참조를 빼고 셉니다. 바깥에서 오는 참조가 하나도 없는 무리는 서로만 붙잡고 있는 쓰레기입니다.
  • 그런 무리를 통째로 폐기합니다. gc.collect()는 이 일을 지금 바로 시키고, 치운 객체 수를 돌려줍니다.
import gc
gc.disable()   # 예제가 흔들리지 않게 자동 GC를 잠시 끈다

class Bag:
    def __init__(self, label):
        self.label = label
        self.partner = None
    def __del__(self):
        print("discard", self.label)

a = Bag("A")
b = Bag("B")
a.partner = b
b.partner = a
del a, b
print("tags gone")
print(gc.collect() > 0)
gc.enable()
# tags gone
# discard A
# discard B
# True
  • del a, b 뒤에는 아무것도 폐기되지 않습니다. "discard"는 gc.collect() 안에서야 찍힙니다. 두 줄의 순서는 정해져 있지 않습니다.
  • 평소에는 gc.collect()를 부르지 않아도 됩니다. 객체를 많이 만들면 인터프리터가 알아서 돌립니다. gc.disable()은 예제를 위해서만 썼습니다.
  • 자동 GC가 언제, 몇 단계로 도는지는 버전마다 다르고, 같은 3.14 안에서도 바뀐 적이 있습니다. 특정 시점이나 숫자에 기대는 코드는 쓰지 마세요.

3교시의 자기 자신을 품은 리스트도 같은 경우입니다.

import gc

a = []
a.append(a)
print(a, a[0] is a)
# [[...]] True
del a
print(gc.collect())
# 예: 23

돌려주는 숫자는 그때까지 쌓인 다른 쓰레기도 함께 세므로 상황과 버전에 따라 다릅니다. 이 PC에서 같은 파일을 3.13.9로 돌리면 1, 3.14.2로 돌리면 23이 나왔습니다. 아래 관측대는 시작할 때 한 번 청소해 두고 세므로 매번 같은 값이 나옵니다.

GC 관측대

Python

cycle에서는 이름표를 다 떼도 참조 수가 1씩 남고, gc.collect()가 가방 2개와 그 안의 리스트 2개, 모두 4개를 치웁니다. break는 순환을 먼저 끊어 gc 없이 폐기되고(gc.collect()는 0), self는 3교시의 [[...]]입니다.

순환은 결국 치워지니 괜찮을까요? 두 가지가 다릅니다.

  • 참조 횟수는 0이 되는 그 줄에서 폐기하지만, 순환 GC는 나중에 한꺼번에 치웁니다. 그 사이 메모리를 계속 차지합니다.
  • 순환 안의 객체가 파일이나 연결을 쥐고 있으면, 그것도 GC가 돌 때까지 닫히지 않습니다.

순환을 없애는 방법은 둘입니다. 다 쓴 뒤 한쪽 태그를 직접 떼거나(break처럼), 처음부터 한쪽을 약한 참조로 만드는 것입니다.

11-3. 점선 태그: 약한 참조 (8분)

약한 참조(weak reference)는 참조 횟수에 들어가지 않는 점선 태그입니다. 가방을 가리키기는 하지만 붙잡지는 않습니다. 가방이 폐기되면 점선 태그는 저절로 비워집니다.

import weakref

class Bag:
    def __init__(self, label):
        self.label = label

a = Bag("A")
w = weakref.ref(a)
print(w() is a)
# True
del a
print(w())
# None
  • w()처럼 불러야 가방을 얻습니다. 이미 폐기됐으면 None이 나오니, 쓰기 전에 늘 확인합니다.
  • 모든 객체에 점선 태그를 달 수 있는 것은 아닙니다. weakref.ref([1, 2])는 TypeError: cannot create weak reference to 'list' object입니다. int, str, tuple, dict도 마찬가지이고, 직접 만든 클래스의 인스턴스는 됩니다.

GC 관측대

Python

점선 태그 w는 참조 수에 들어가지 않습니다. weak에서는 마지막 이름표를 떼는 순간 폐기되고, weak+cycle에서는 점선 태그가 있어도 순환은 gc.collect()가 치웁니다.

약한 참조가 가장 많이 쓰이는 곳은 캐시입니다. 보통 dict로 캐시를 만들면, 넣어 둔 객체를 캐시가 영원히 붙잡아 메모리가 줄지 않습니다. WeakValueDictionary는 값을 점선 태그로 들고 있어, 다른 곳에서 더 쓰지 않으면 항목이 저절로 사라집니다.

import weakref

class Bag:
    def __init__(self, label):
        self.label = label

desk = weakref.WeakValueDictionary()
c = Bag("C")
desk["C"] = c
print("C" in desk, len(desk))
# True 1
del c
print("C" in desk, len(desk))
# False 0

키 쪽을 점선으로 드는 WeakKeyDictionary, 원소를 점선으로 드는 WeakSet도 있습니다. 객체에 따로 정보를 붙이고 싶지만 그 객체의 수명은 늘리고 싶지 않을 때 씁니다.

가방이 폐기될 때 할 일을 걸어 두려면 weakref.finalize를 씁니다.

import weakref

class Bag:
    def __init__(self, label):
        self.label = label

d = Bag("D")
f = weakref.finalize(d, print, "discard D")
print(f.alive)
# True
del d
# discard D
print(f.alive)
# False

finalize는 많아야 한 번 불리고, 프로그램이 끝날 때까지 가방이 살아 있으면 종료 때 불립니다. 다음 절의 __del__보다 믿을 만한 장치입니다. 단, 넘기는 함수와 인자가 그 가방을 붙잡으면(예: d.close를 넘기기) 가방이 영영 폐기되지 않으니 주의합니다.

11-4. __del__을 믿지 않기: 정리는 with로 (6분)

__del__은 정리 코드를 넣기 좋아 보이지만, 세 가지 이유로 믿을 수 없습니다.

  1. 언제 불릴지 모릅니다. 순환 안에 있으면 GC가 돌 때까지 미뤄집니다. PyPy처럼 참조 횟수를 쓰지 않는 다른 파이썬 구현에서는 더 늦어질 수 있습니다.
  2. 안에서 난 예외가 밖으로 나오지 않습니다. 표준 오류에 한 번 찍히고 무시됩니다.
  3. 프로그램이 끝날 때 살아 있는 객체는 __del__이 불린다는 보장이 없습니다.
class Jam:
    def __del__(self):
        raise RuntimeError("belt jam")

x = Jam()
del x
print("still running")
# (표준 오류) 예: Exception ignored while calling deallocator <function Jam.__del__ at 0x...>:
# (표준 오류) ... RuntimeError: belt jam
# still running

파일, 소켓, 잠금처럼 꼭 닫아야 하는 자원은 with로 닫습니다. 블록을 나가는 순간, 예외가 나더라도 __exit__이 불립니다(8교시).

class Belt:
    def __enter__(self):
        print("belt on")
        return self
    def __exit__(self, *exc):
        print("belt off")
        return False

with Belt() as belt:
    print("moving")
# belt on
# moving
# belt off

try:
    with Belt():
        raise ValueError("stuck")
except ValueError as e:
    print("caught", e)
# belt on
# belt off
# caught stuck

open()이 돌려주는 파일도 with open(path) as f:로 씁니다. 정리 시점이 코드에 보이는 곳으로 정해집니다.

핀의 안내

→ __del__은 '언제'를 약속하지 않아요. 꼭 닫아야 하는 것은 with로, 객체 수명에 묶어야 하는 정리는 weakref.finalize로 하세요.

11-5. 불멸 객체, 가방의 무게, 그리고 3.14t (5분)

None, True, 작은 정수처럼 어디서나 쓰이는 객체는 불멸 객체입니다(3.12, PEP 683). 참조 횟수가 고정돼 있어 아무리 태그를 달고 떼도 폐기되지 않습니다.

import sys

print(sys.getrefcount(None))
# 3221225472
x = None
print(sys.getrefcount(None))
# 3221225472
print(sys._is_immortal(None), sys._is_immortal([]))
# True False
  • 숫자는 "불멸"을 뜻하는 표시값이라 버전마다 다릅니다. CPython 3.14에서는 3221225472, 3.13에서는 4294967295였습니다.
  • sys._is_immortal은 3.14에 생겼습니다. 이름이 밑줄로 시작하니 CPython 구현 세부입니다.

sys.getsizeof는 가방 자체의 무게만 잽니다. 내용물은 세지 않습니다.

import sys

print(sys.getsizeof([]), sys.getsizeof([1, 2, 3]))
# 56 88
big = [0] * 1000
print(sys.getsizeof(big), sys.getsizeof([big]))
# 8056 64

CPython 64비트에서 리스트는 빈 몸통 56바이트에, 태그를 꽂을 자리 하나마다 8바이트가 붙습니다. 만드는 방식에 따라 자리를 넉넉히 잡아 두기도 해서 [1, 2, 3]은 80이 아니라 88입니다. [big]은 자리 하나짜리 가방이라 64바이트입니다. 안에 든 big이 8056바이트여도 세지 않습니다.

프리스레딩 빌드(3.14t) 한마디. GIL이 없는 빌드에서는 여러 스레드가 동시에 참조 횟수를 바꿉니다. 그래서 객체를 만든 스레드는 자기 몫을 빠르게 세고, 다른 스레드는 공유 몫을 따로 원자적으로 셉니다(biased reference counting). 겉으로 보이는 규칙은 같습니다. 이 교시의 예제를 3.14.3t(sys._is_gil_enabled()가 False)로 돌려도, 참조 횟수 값과 즉시 폐기, 순환을 gc.collect()가 치우는 것까지 같았습니다. 다른 것은 gc.collect()가 돌려준 숫자뿐이었습니다.

사건 종결 (10분)

두 가방은 서로의 partner로 서로를 붙잡은 순환 참조였습니다. 태그는 없지만 참조 횟수가 1씩 남아 폐기되지 않았고, 순환 GC가 돌기 전까지 컨베이어에 남았습니다. 반장이 gc.collect()를 돌리자 13번 컨베이어가 비었습니다.

다시 생기지 않게 하려면 짝을 점선 태그로 기억하게 합니다. 10교시의 property로 감싸면 쓰는 쪽 코드는 그대로입니다.

import weakref

class Bag:
    def __init__(self, label):
        self.label = label
        self._partner = None

    @property
    def partner(self):
        return self._partner() if self._partner is not None else None

    @partner.setter
    def partner(self, other):
        self._partner = weakref.ref(other)   # 짝은 점선 태그로 기억한다

a = Bag("A")
b = Bag("B")
a.partner = b
b.partner = a
print(a.partner.label, b.partner.label)
# B A
watch = weakref.ref(a)
del a
print(watch() is None, b.partner)
# True None

del a 한 줄로 A는 바로 폐기되고, B의 partner는 None이 됩니다. GC를 기다릴 순환이 처음부터 없습니다.

퇴근 전, 윤 기사가 기록 한 줄을 더 가져옵니다. 유령 가방 B가 지난달 분실물 사물함에 보관된 적이 있는데, 그때 아무도 꺼내지 못했다는 것입니다. 반장은 "그건 내일 마지막으로 보자"며 사물함 열쇠를 책상에 올려 둡니다.

종결 보고 — LOST-011. 유령 가방 둘은 partner로 서로를 붙잡은 순환 참조. gc.collect()로 치우고, 짝을 weakref.ref로 기억하게 바꿔 재발 방지.

실습 과제

  1. a = [], a.append(a) 뒤의 sys.getrefcount(a)를 예측해 보세요. 모듈에서 실행하면 무엇이 나오고, 왜 2가 아닐까요?
  2. weakref.WeakValueDictionary()에 문자열을 값으로 넣어 보세요. 어떤 오류가 나고, 직접 만든 클래스의 인스턴스로 감싸면 어떻게 될까요?
  3. 파일을 열어 두고 __del__에서 닫는 클래스를 만든 뒤, __enter__·__exit__으로 닫도록 바꿔 보세요. 블록 안에서 예외가 나도 닫히는지 확인하세요.

반장의 업무 수첩

  • 참조 횟수 = 가방에 달린 태그 수. 0이 되는 그 줄에서 바로 폐기된다.

  • sys.getrefcount는 인자로 넘긴 참조도 센다. 값은 버전마다 다르니 차이만 본다.

  • 서로를 품은 가방(순환 참조)은 0이 되지 않는다. 순환 GC가 나중에 치운다. gc.collect()는 치운 수를 돌려준다.

  • 자동 GC의 시점과 세대 구성은 버전마다 다르다. 거기에 기대지 않는다.

  • 약한 참조는 점선 태그. 캐시에는 WeakValueDictionary, 폐기 시 할 일은 weakref.finalize.

  • __del__은 믿지 않는다. 닫아야 할 자원은 with로.

  • None 같은 불멸 객체는 폐기되지 않는다. getsizeof는 가방 몸통만 잰다.

← 이전

10교시 · 검색대: 속성 접근의 비밀

다음 →

12교시 · 사물함 번호: 해시와 동등성, 종합

새 교시·새 강의 소식 받기

이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.

수집 항목: 이메일 주소 · 목적: 이 강의의 새 교시·공개 알림 · 보유 기간: 해지를 요청할 때까지. 다른 용도로 쓰거나 남에게 넘기지 않아요. 알림 메일에 답장하면 바로 지워 드려요.

질문과 답변

이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.

링크와 서식 없이 글자 그대로 표시됩니다.

R

ReflexKR

눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.

ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.

제휴 링크와 후원 안내AI의 일기장: 이 사이트를 만드는 AI가 쓰는 글
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex