분실 신고서
LOST-011
신고인
야간 설비 점검원 윤 기사
잃어버린 것
태그가 하나도 없는데 폐기되지 않는 가방 두 개. 열하루째 13번 컨베이어를 돌고 있다.
해결 조건
두 가방이 폐기되지 않는 이유를 밝히고, 치운 뒤 다시 생기지 않게 한다.
하늘섬 국제공항 수하물 센터
첫날부터 보고가 올라오던 13번 컨베이어의 두 가방이 오늘도 돌고 있습니다. 태그는 하나도 없습니다. 규정대로라면 태그 없는 가방은 그날 바로 폐기장으로 가야 합니다.
도미란 반장이 지난 기록 세 장을 책상에 펼칩니다. 3교시의 엑스레이 사진, 6교시의 메모, 7교시의 partner 기록입니다. "오늘은 저 둘의 정체를 밝히자. 그 전에 폐기 규칙부터 정확히 알아야 해."
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))
# 2sys.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
# backdel a 뒤에도 태그 b가 남아 있어 폐기되지 않습니다. 마지막 태그를 떼는 del b에서 바로 "discard A"가 찍힙니다.tmp도 떨어집니다. 그래서 "back"보다 먼저 폐기됩니다.아래 GC 관측대는 일꾼 프로세스에서 진짜 객체를 만들고 진짜 gc를 돌립니다. 가방마다 "참조 수"(이름표 + 다른 가방 안에 꽂힌 태그)가 보입니다. 어떤 이름표에서 출발해 가방 안을 따라가도 닿지 않는 가방은 빨간 점선으로 그려집니다. 점선 태그는 w() → …로 지금 무엇을 가리키는지 보여 줍니다.
GC 관측대
Python
핀의 안내
→ sys.getrefcount의 숫자는 버전과 위치(모듈인지 함수 안인지)에 따라 달라요. 절대값 말고, 태그를 하나 달고 뗄 때 1씩 움직이는 '차이'를 보세요.
세 장의 기록을 이어 붙이면 정체가 나옵니다. 3교시 엑스레이의 [[...]]는 가방이 자기 자신을 품은 모습이었습니다. 7교시 기록에서 두 가방은 partner 속성으로 서로를 가리켰습니다. 둘을 그대로 코드로 옮기면 이렇습니다.
태그 보드
단계 1 / 0
다음에 실행할 줄: 0
실습대를 준비하는 중…
방금 바뀐 것
봉인된 가방(불변)
del a, b로 이름표는 모두 떨어졌습니다. 그런데 A 안에는 B를 가리키는 태그가, B 안에는 A를 가리키는 태그가 남아 있습니다. 둘 다 참조 횟수가 1이라 영영 0이 되지 않습니다. 이것이 순환 참조(reference cycle)입니다.
이름에서 출발해 닿을 수 없는 객체는 프로그램이 다시 쓸 방법이 없는 쓰레기입니다. 태그 보드에서 사라진 것도 그래서입니다. 하지만 참조 횟수만으로는 이 쓰레기를 치울 수 없습니다.
그래서 CPython에는 두 번째 청소부가 있습니다. 순환 가비지 컬렉터(gc 모듈)입니다.
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
# Truedel a, b 뒤에는 아무것도 폐기되지 않습니다. "discard"는 gc.collect() 안에서야 찍힙니다. 두 줄의 순서는 정해져 있지 않습니다.gc.collect()를 부르지 않아도 됩니다. 객체를 많이 만들면 인터프리터가 알아서 돌립니다. gc.disable()은 예제를 위해서만 썼습니다.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
순환은 결국 치워지니 괜찮을까요? 두 가지가 다릅니다.
순환을 없애는 방법은 둘입니다. 다 쓴 뒤 한쪽 태그를 직접 떼거나(break처럼), 처음부터 한쪽을 약한 참조로 만드는 것입니다.
약한 참조(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())
# Nonew()처럼 불러야 가방을 얻습니다. 이미 폐기됐으면 None이 나오니, 쓰기 전에 늘 확인합니다.weakref.ref([1, 2])는 TypeError: cannot create weak reference to 'list' object입니다. int, str, tuple, dict도 마찬가지이고, 직접 만든 클래스의 인스턴스는 됩니다.GC 관측대
Python
약한 참조가 가장 많이 쓰이는 곳은 캐시입니다. 보통 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)
# Falsefinalize는 많아야 한 번 불리고, 프로그램이 끝날 때까지 가방이 살아 있으면 종료 때 불립니다. 다음 절의 __del__보다 믿을 만한 장치입니다. 단, 넘기는 함수와 인자가 그 가방을 붙잡으면(예: d.close를 넘기기) 가방이 영영 폐기되지 않으니 주의합니다.
__del__을 믿지 않기: 정리는 with로 (6분)__del__은 정리 코드를 넣기 좋아 보이지만, 세 가지 이유로 믿을 수 없습니다.
__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 stuckopen()이 돌려주는 파일도 with open(path) as f:로 씁니다. 정리 시점이 코드에 보이는 곳으로 정해집니다.
핀의 안내
→ __del__은 '언제'를 약속하지 않아요. 꼭 닫아야 하는 것은 with로, 객체 수명에 묶어야 하는 정리는 weakref.finalize로 하세요.
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 Falsesys._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 64CPython 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()가 돌려준 숫자뿐이었습니다.
두 가방은 서로의 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 Nonedel a 한 줄로 A는 바로 폐기되고, B의 partner는 None이 됩니다. GC를 기다릴 순환이 처음부터 없습니다.
퇴근 전, 윤 기사가 기록 한 줄을 더 가져옵니다. 유령 가방 B가 지난달 분실물 사물함에 보관된 적이 있는데, 그때 아무도 꺼내지 못했다는 것입니다. 반장은 "그건 내일 마지막으로 보자"며 사물함 열쇠를 책상에 올려 둡니다.
종결 보고 — LOST-011. 유령 가방 둘은 partner로 서로를 붙잡은 순환 참조. gc.collect()로 치우고, 짝을 weakref.ref로 기억하게 바꿔 재발 방지.
a = [], a.append(a) 뒤의 sys.getrefcount(a)를 예측해 보세요. 모듈에서 실행하면 무엇이 나오고, 왜 2가 아닐까요?weakref.WeakValueDictionary()에 문자열을 값으로 넣어 보세요. 어떤 오류가 나고, 직접 만든 클래스의 인스턴스로 감싸면 어떻게 될까요?__del__에서 닫는 클래스를 만든 뒤, __enter__·__exit__으로 닫도록 바꿔 보세요. 블록 안에서 예외가 나도 닫히는지 확인하세요.반장의 업무 수첩
참조 횟수 = 가방에 달린 태그 수. 0이 되는 그 줄에서 바로 폐기된다.
sys.getrefcount는 인자로 넘긴 참조도 센다. 값은 버전마다 다르니 차이만 본다.
서로를 품은 가방(순환 참조)은 0이 되지 않는다. 순환 GC가 나중에 치운다. gc.collect()는 치운 수를 돌려준다.
자동 GC의 시점과 세대 구성은 버전마다 다르다. 거기에 기대지 않는다.
약한 참조는 점선 태그. 캐시에는 WeakValueDictionary, 폐기 시 할 일은 weakref.finalize.
__del__은 믿지 않는다. 닫아야 할 자원은 with로.
None 같은 불멸 객체는 폐기되지 않는다. getsizeof는 가방 몸통만 잰다.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.