분실 신고서
LOST-012
신고인
분실물 보관소 한 주임
잃어버린 것
유령 가방 B의 짐. 지난달 분실물 사물함(dict)에 등록했는데, 주인이 찾으러 왔을 때 '없다'고 나왔다. 사물함 L7은 아직 잠겨 있다.
해결 조건
사물함이 칸을 찾는 원리로 원인을 밝히고, L7을 열어 바뀌지 않는 키로 다시 보관한다.
하늘섬 국제공항 수하물 센터
어제 13번 컨베이어의 유령 가방 둘을 치웠습니다. 남은 것은 보관 대장 한 줄입니다. 지난달 B가 분실물 사물함에 등록됐는데, 주인이 찾으러 왔을 때 대장은 "없다"고 답했습니다. B는 다시 컨베이어로 돌아갔고, 그 뒤는 어제까지의 이야기입니다.
한 주임이 그때의 대장을 다시 열어 보입니다. 목록을 출력하면 B가 분명히 있는데, in으로 물으면 False가 나옵니다. 도미란 반장이 마지막 신고서를 받으며 말합니다. "사물함 번호는 가방이 정하는 거야. 넣은 뒤에 가방이 바뀌면, 번호도 바뀌지."
==로 확인한다 (8분)dict와 set은 칸이 여러 개인 사물함입니다. 키를 넣거나 찾을 때 매번 같은 순서를 밟습니다.
hash(key)로 키의 해시(사물함 번호)를 구합니다.==로 같은 키인지 확인합니다. 아니면 정해진 규칙으로 다음 칸을 봅니다.그래서 하나의 약속이 필요합니다. a == b이면 hash(a) == hash(b)여야 합니다. 같은 키가 서로 다른 칸에서 찾기 시작하면 영영 만나지 못하기 때문입니다.
print(1 == 1.0 == True, hash(1) == hash(1.0) == hash(True))
# True True
print({1: "int", 1.0: "float", True: "bool"})
# {1: 'bool'}1, 1.0, True는 서로 ==이므로 해시도 같고, dict에서는 같은 키입니다. 키는 처음 넣은 1이 남고 값만 마지막 것으로 바뀌었습니다.
거꾸로는 성립하지 않아도 됩니다. 해시가 같은데 값이 다른 충돌은 정상입니다. 4번 단계의 ==가 가려냅니다.
print(hash(42), hash(-1), hash(-2))
# 42 -2 -2
print(hash(-1) == hash(-2), -1 == -2)
# True False
lockers = {-1: "A", -2: "B"}
print(lockers[-1], lockers[-2])
# A BCPython에서 작은 정수의 해시는 자기 자신입니다. 다만 -1은 C 내부에서 "오류"를 뜻하는 값이라, hash(-1)은 -2가 됩니다. 충돌이지만 dict는 문제없이 둘을 구분합니다.
문자열의 해시는 프로그램을 실행할 때마다 달라집니다. 해시값을 예측해 공격하는 것을 막으려는 장치입니다. 같은 값이 필요하면(테스트 재현 등) 환경 변수 PYTHONHASHSEED를 고정합니다.
$ python -c "print(hash('T1'))"
# 예: 4646783657884298203
$ python -c "print(hash('T1'))"
# 예: -1769311965495050168
$ PYTHONHASHSEED=0 python -c "print(hash('T1'))"
# 실행할 때마다 같은 값그래서 해시값을 파일에 저장하거나 다른 프로세스와 주고받으면 안 됩니다. 해시는 한 번의 실행 안에서만 쓰는 번호입니다.
아래 사물함은 칸 8개짜리로 단순화했습니다. 해시를 8로 나눈 나머지 칸에서 시작해, 차 있으면 바로 다음 칸으로 갑니다. 7과 15, -1과 -2를 차례로 넣어 충돌을 만들어 보세요.
사물함 보관소
칸 8개짜리 단순화한 해시 테이블이에요. CPython의 dict는 칸을 고르는 순서가 더 복잡하지만, '해시로 칸을 찾고 ==로 확인한다'는 원리는 같아요.
사물함은 넣을 때 계산한 번호의 칸에 키를 두고, 그 뒤로 번호를 다시 계산하지 않습니다. 넣은 뒤 키의 값이 바뀌어 해시가 달라지면 어떻게 될까요? 일부러 잘못 만든 키로 확인합니다.
class Tag:
def __init__(self, n):
self.n = n
def __eq__(self, other):
return isinstance(other, Tag) and self.n == other.n
def __hash__(self):
return hash(self.n) # 바뀔 수 있는 값으로 해시를 만든 실수
def __repr__(self):
return f"Tag({self.n})"
key = Tag(3)
lockers = {key: "bag B"}
print(Tag(3) in lockers)
# True
key.n = 13
print(lockers)
# {Tag(13): 'bag B'}
print(Tag(13) in lockers, Tag(3) in lockers, key in lockers)
# False False False목록에는 Tag(13)이 보이는데, 어떻게 물어도 False입니다.
Tag(13)으로 찾으면: 해시 13의 칸에서 시작합니다. 가방은 해시 3의 칸에 있으니 만나지 못합니다.Tag(3)으로 찾으면: 해시 3의 칸에는 도착합니다. 하지만 그 칸의 키는 이제 Tag(13)이라 ==가 False입니다.key 자신으로 찾아도: 지금 해시는 13이라 첫 번째와 같은 길을 갑니다.# 위 예제에 이어서
key.n = 3
print(Tag(3) in lockers)
# True값을 3으로 되돌리면 다시 찾힙니다. 가방은 처음부터 끝까지 넣었던 칸에 그대로 있었습니다. 칸을 찾는 번호만 바뀌었습니다.
사물함 보관소
칸 8개짜리 단순화한 해시 테이블이에요. CPython의 dict는 칸을 고르는 순서가 더 복잡하지만, '해시로 칸을 찾고 ==로 확인한다'는 원리는 같아요.
그래서 파이썬은 값이 바뀔 수 있는 내장 타입을 아예 키로 받지 않습니다. list, dict, set에는 해시가 없습니다.
lockers = {}
lockers[[1, 2]] = "bag"
# TypeError: cannot use 'list' as a dict key (unhashable type: 'list')print(hash((1, 2)) == hash((1, 2)))
# True
hash((1, [2]))
# TypeError: unhashable type: 'list'튜플은 키가 될 수 있지만, 안에 리스트가 들어 있으면 해시를 만들 수 없습니다. 2교시에서 본 대로 튜플의 불변은 "담긴 참조"의 불변일 뿐이라, 안의 리스트가 바뀌면 해시도 바뀌어야 하기 때문입니다. 키로 쓸 때는 (1, (2,))나 frozenset처럼 끝까지 불변인 값을 씁니다.
핀의 안내
→ dict 키와 set 원소는 '넣은 뒤 바뀌지 않는 것'이어야 해요. 바꿔야 한다면 꺼내서(pop) 새 키로 다시 넣으세요.
아무것도 정의하지 않은 클래스는 is로 같음을 따지고, 해시도 정체성으로 만듭니다. 그래서 인스턴스를 키로 쓸 수 있습니다. 그런데 __eq__만 정의하면 파이썬이 __hash__를 None으로 만듭니다(8교시에서 예고한 것). 같음의 기준이 바뀌었는데 옛 해시를 그대로 쓰면 약속이 깨지기 때문입니다.
class Label:
def __init__(self, code):
self.code = code
def __eq__(self, other):
return isinstance(other, Label) and self.code == other.code
print(Label("T1") == Label("T1"), Label.__hash__)
# True None
{Label("T1")}
# TypeError: cannot use 'Label' as a set element (unhashable type: 'Label')해시가 필요하면 방법은 둘입니다.
1. @dataclass(frozen=True): __eq__와 __hash__를 필드로 만들어 주고, 필드를 바꾸지 못하게 막습니다.
from dataclasses import dataclass
@dataclass(frozen=True)
class Tag:
n: int
key = Tag(3)
lockers = {key: "bag B"}
print(lockers[Tag(3)])
# bag B
key.n = 13
# dataclasses.FrozenInstanceError: cannot assign to field 'n'frozen 없이 @dataclass만 쓰면 __eq__만 생기므로 __hash__는 None입니다. 키로 쓸 클래스라면 frozen=True를 붙입니다.
2. __hash__를 직접 쓰기: __eq__에서 비교하는 필드를 그대로 튜플로 묶어 해시합니다.
class Route:
def __init__(self, origin, dest):
self.origin = origin
self.dest = dest
def __eq__(self, other):
if not isinstance(other, Route):
return NotImplemented
return (self.origin, self.dest) == (other.origin, other.dest)
def __hash__(self):
return hash((self.origin, self.dest))
seen = {Route("ICN", "NRT")}
print(Route("ICN", "NRT") in seen, len({Route("ICN", "NRT"), Route("ICN", "NRT")}))
# True 1직접 쓸 때의 규칙은 세 가지입니다.
__eq__가 비교하는 것과 같은 필드로 해시를 만든다.NotImplemented를 돌려준다(8교시).반장이 마지막 과제로 센터의 옛 관리 프로그램을 내놓습니다. 열한 번의 신고에서 만난 버그가 한 파일에 다 들어 있습니다. 실행 결과부터 봅니다.
import gc
import weakref
class Bag:
stickers = [] # 클래스 속성에 가변 리스트
def __init__(self, code, items=[]): # 기본 인자 리스트
self.code = code
self.items = items
self.partner = None
def __eq__(self, other):
return isinstance(other, Bag) and self.code == other.code
def __hash__(self):
return hash(self.code) # 바뀔 수 있는 값으로 해시
a = Bag("A1")
b = Bag("B1")
a.items.append("camera")
a.stickers.append("fragile")
print(b.items, b.stickers)
# ['camera'] ['fragile']
shelves = [[]] * 3 # 같은 리스트 세 번
shelves[0].append("A1")
print(shelves)
# [['A1'], ['A1'], ['A1']]
calls = [lambda: f"gate {g}" for g in (1, 2, 3)]
print([call() for call in calls])
# ['gate 3', 'gate 3', 'gate 3']
lockers = {b: "L7"}
b.code = "B2" # 넣은 뒤 키를 바꿈
print(Bag("B1") in lockers, Bag("B2") in lockers)
# False False
a.partner, b.partner = b, a # 서로를 품은 두 가방
watch = weakref.ref(a)
del a, b, lockers
print(watch() is None)
# False
gc.collect()
print(watch() is None)
# True한 줄씩 원인을 짚어 점검 목록으로 만듭니다.
| 증상 | 원인 | 고침 | 교시 |
|---|---|---|---|
| A에 넣은 카메라가 B에도 있다 | 기본 인자 items=[]는 정의할 때 한 번만 만들어진다 | items=None으로 받고 안에서 새 리스트 | 4 |
| 스티커가 모든 가방에 붙었다 | 클래스 속성 stickers = []를 모든 인스턴스가 공유 | __init__에서 인스턴스 속성으로 | 7 |
| 선반 세 칸이 함께 바뀐다 | [[]] * 3은 같은 리스트의 참조 세 개 | [[] for _ in range(3)] | 3 |
| 알림이 모두 gate 3 | 람다가 값이 아니라 변수 g를 들고 있다(늦은 바인딩) | lambda g=g: ... | 6 |
| 사물함에서 B를 못 찾는다 | 넣은 뒤 키의 값을 바꿔 해시가 달라졌다 | 바뀌지 않는 값을 키로, 바꿀 때는 옮기기 | 12 |
| 이름표를 다 떼도 가방이 남는다 | partner끼리의 순환 참조 | 한쪽을 weakref.ref로 | 11 |
고친 프로그램입니다. 출력이 모두 의도대로 바뀌었습니다.
import weakref
class Bag:
def __init__(self, code, items=None):
self.code = code
self.items = [] if items is None else list(items)
self.stickers = []
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("A1")
b = Bag("B1")
a.items.append("camera")
a.stickers.append("fragile")
print(b.items, b.stickers)
# [] []
shelves = [[] for _ in range(3)]
shelves[0].append("A1")
print(shelves)
# [['A1'], [], []]
calls = [lambda g=g: f"gate {g}" for g in (1, 2, 3)]
print([call() for call in calls])
# ['gate 1', 'gate 2', 'gate 3']
lockers = {b.code: "L7"} # 키는 바뀌지 않는 문자열
lockers["B2"] = lockers.pop(b.code) # 코드를 바꿀 때는 키를 옮긴다
b.code = "B2"
print("B1" in lockers, "B2" in lockers, lockers[b.code])
# False True L7
a.partner, b.partner = b, a
watch = weakref.ref(a)
del a, b, lockers
print(watch() is None)
# Trueitems=list(items)로 받은 것을 복사해 두는 것도 4교시의 방어적 복사입니다. Bag에서 __eq__와 __hash__를 뺐으니, 가방 자체는 정체성으로 비교되고 키는 문자열 코드가 맡습니다.
앞의 두 버그(기본 인자, 클래스 속성)를 태그 보드로 비교해 봅니다. bug에서는 두 인스턴스의 items 화살표가 한 리스트로 모이고, stickers는 규격서(class) 박스에 매달려 있습니다.
태그 보드
단계 1 / 0
다음에 실행할 줄: 0
실습대를 준비하는 중…
방금 바뀐 것
봉인된 가방(불변)
열두 교시를 한 줄씩 돌아봅니다.
| 교시 | 제목 | 한 줄 |
|---|---|---|
| 1 | 태그는 가방이 아니다: 이름과 객체 | =는 객체에 이름표를 매단다. is는 같은 객체, ==는 같은 값. |
| 2 | 봉인된 가방: 가변과 불변 | 불변 객체의 "변경"은 새 객체와 재바인딩. +=는 타입에 따라 두 얼굴. |
| 3 | 가방 속 가방: 컨테이너와 복사 | 컨테이너는 참조를 담는다. 얕은 복사는 한 층, deepcopy는 끝까지. |
| 4 | 가방을 넘겨줄 때: 함수와 인자 | 함수는 같은 객체에 태그를 하나 더 단다. 기본 인자는 한 번만 만들어진다. |
| 5 | 구역과 출입증: 스코프 | 이름은 L → E → G → B 순서로 찾고, 대입하는 곳이 스코프를 정한다. |
| 6 | 기내용 가방: 클로저 | 안쪽 함수는 값이 아니라 변수(셀)를 들고 다닌다. |
| 7 | 가방 규격서: 클래스와 인스턴스 | 클래스 속성은 모든 인스턴스가 공유한다. 인스턴스 속성은 __init__에서. |
| 8 | 공항 표준 절차: 던더 메서드 | 문법은 던더 메서드를 부른다. len(x)는 type(x).__len__(x). |
| 9 | 규격서의 규격서: 상속과 MRO | 속성은 __mro__ 순서로 찾고, super()는 MRO의 다음 클래스. |
| 10 | 검색대: 속성 접근의 비밀 | property와 디스크립터가 속성 접근을 가로챈다. 함수도 디스크립터. |
| 11 | 주인 없는 가방: 메모리와 GC | 참조 횟수 0이면 폐기, 순환은 GC가 치운다. 약한 참조는 점선 태그. |
| 12 | 사물함 번호: 해시와 동등성, 종합 | a == b이면 hash(a) == hash(b). 키는 넣은 뒤 바뀌지 않아야 한다. |
유령 가방 B는 번호 no로 해시를 만드는 가방이었습니다. 사물함에 넣은 뒤 재분류하며 번호를 고쳐 쓰는 바람에 칸을 찾는 길이 바뀌었습니다. 해시를 쓰지 않고 칸을 하나씩 훑으면 B는 그대로 있습니다.
from dataclasses import dataclass
class Bag:
def __init__(self, no):
self.no = no
def __eq__(self, other):
return isinstance(other, Bag) and self.no == other.no
def __hash__(self):
return hash(self.no)
ghost = Bag(3)
lockers = {ghost: "L7"}
ghost.no = 13 # 재분류하며 번호를 고쳐 씀
print(ghost in lockers)
# False
print([v for k, v in lockers.items() if k is ghost]) # 해시 없이 하나씩 훑으면 있다
# ['L7']
@dataclass(frozen=True)
class BagNo:
no: int
lockers = {BagNo(13): "L7"} # 바뀌지 않는 키로 다시 보관
print(lockers[BagNo(13)], BagNo(13) in lockers)
# L7 Trueitems()로 하나씩 훑는 것은 해시를 쓰지 않으니 B를 찾아냅니다. 대신 칸 수만큼 시간이 걸립니다.BagNo는 frozen=True라 넣은 뒤에 번호를 바꿀 수 없습니다. 번호가 바뀌면 새 BagNo를 만들어 옮겨 넣습니다.사물함 L7이 열립니다. 한 달 동안 잠겨 있던 B의 짐이 나오고, 한 주임이 주인에게 연락을 돌립니다. 도미란 반장이 태그 뭉치를 다시 서랍에 넣습니다. "첫날 한 말 기억하지? 가방은 하나, 태그는 여럿. 이제 태그가 어디에 매달려 있는지, 누가 가방을 붙잡고 있는지, 가방이 어느 칸에 있는지 다 보이지?" 핀이 13번 컨베이어를 가리킵니다. 오늘은 아무것도 돌고 있지 않습니다.
종결 보고 — LOST-012. B는 넣은 뒤 해시가 바뀐 키라 사물함에서 찾지 못했음. items()로 훑어 L7을 찾고, @dataclass(frozen=True) 키로 다시 보관. 13번 컨베이어 유령 가방 사건 전체 종결.
{(1, 2): "A", (2, 1): "B"}는 칸이 몇 개 쓰일까요? 두 튜플의 ==와 hash를 각각 비교해 예측한 뒤 확인하세요.Tag에 @dataclass(frozen=True)를 붙여 다시 만들고, key.n = 13이 어디서 막히는지 확인하세요.반장의 업무 수첩
dict·set은 해시로 칸을 찾고 == 로 확인한다. a == b 면 hash(a) == hash(b).
해시 충돌은 정상(hash(-1) == hash(-2)). 문자열 해시는 실행마다 다르다(PYTHONHASHSEED).
넣은 뒤 키가 바뀌면 새 값으로도 옛 값으로도 찾지 못한다. 키는 불변으로.
list·dict·set은 키가 될 수 없다. 리스트가 든 튜플도 마찬가지.
__eq__ 만 정의하면 __hash__ 는 None. @dataclass(frozen=True)나 같은 필드로 __hash__ 를 쓴다.
점검 목록: 공유된 가변 객체, 기본 인자, 늦은 바인딩, 클래스 속성, 순환 참조, 바뀐 키.
가방은 하나, 태그는 여럿. 막히면 이름표와 가방을 그려 본다.
더 배우고 싶다면
제휴 링크이 강의 다음에 이어서 보기 좋은 강의입니다. 제휴 링크라서 이 링크로 수강하면 ReflexKR에 수수료가 돌아가고, 수강료는 똑같습니다. 고르는 기준
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.