분실 신고서
LOST-002
신고인
면세점 재고 담당 오 주임
잃어버린 것
봉인 스티커를 붙인 가방(튜플) 하나. 저울은 "봉인된 가방은 열 수 없다"는 오류를 냈는데, 다시 열어 보니 물건이 하나 늘어 있었다.
해결 조건
오류가 나면서도 물건이 들어간 까닭을 밝히고, 정말로 열 수 없는 가방을 만든다.
하늘섬 국제공항 수하물 센터
아침 조회에서 도미란 반장이 봉인 스티커 한 장을 들어 보입니다. "이게 붙은 가방은 아무도 못 연다. 그런데 오늘 신고는 봉인된 가방이 불어났다는 거야."
면세점 재고 담당자가 보낸 코드는 짧습니다. 튜플에 물건을 넣으려 하자 TypeError가 났습니다. 그런데 튜플을 다시 찍어 보니 물건이 들어가 있었습니다. 반장이 말합니다. "봉인은 겉가방에 붙인 거지. 안에 든 가방까지 봉인한 건 아니야."
객체는 두 부류로 나뉩니다. 만든 뒤에 내용물을 바꿀 수 있으면 가변(mutable), 바꿀 수 없으면 불변(immutable) 입니다.
| 부류 | 대표 타입 | 공항으로 치면 |
|---|---|---|
| 가변 | list, dict, set | 지퍼가 열리는 가방. 넣고 빼도 같은 가방이다. |
| 불변 | int, float, str, tuple, frozenset, bool, None | 봉인된 가방. 내용을 바꾸려면 새 가방을 싸야 한다. |
불변 객체에는 내용을 바꾸는 방법이 아예 없습니다.
word = "gate"
print(word.upper(), word)
# GATE gate
word[0] = "G"
# TypeError: 'str' object does not support item assignmentfs = frozenset({1, 2})
fs.add(3)
# AttributeError: 'frozenset' object has no attribute 'add'word.upper()는 word를 고치지 않습니다. 대문자로 된 새 문자열을 만들어 돌려줍니다. 문자열 메서드는 전부 이렇게 움직입니다.
그러면 n = n + 1은 무엇일까요? 정수 10이라는 가방을 고친 것이 아닙니다. 11이라는 새 정수 객체를 얻고, 태그 n을 그쪽으로 옮긴 것입니다. 불변 객체의 "변경"은 언제나 새 객체 + 재바인딩입니다.
아래 태그 보드는 정수도 따로 가방으로 그립니다. 3번 줄 뒤에 n의 화살표가 어디로 가는지 보세요.
태그 보드
단계 1 / 0
다음에 실행할 줄: 0
실습대를 준비하는 중…
방금 바뀐 것
봉인된 가방(불변)
+=의 두 얼굴 (10분)a += b는 겉보기에는 한 가지 일입니다. 하지만 파이썬은 이렇게 처리합니다.
a의 타입에 __iadd__(제자리 더하기) 메서드가 있으면 그것을 부릅니다. 가방을 제자리에서 바꾸고 같은 가방을 돌려줍니다.a = a + b처럼 __add__로 새 객체를 만들고 태그를 옮깁니다.print(hasattr(list, "__iadd__"), hasattr(int, "__iadd__"))
# True False
print(hasattr(str, "__iadd__"), hasattr(tuple, "__iadd__"))
# False False리스트는 가변이라 __iadd__가 있습니다. 불변 타입에는 없습니다. 봉인된 가방은 제자리에서 바꿀 수 없으니까요. 그래서 같은 +=가 이렇게 갈립니다.
a = [1, 2]
b = a
a += [3]
print(a, b, a is b)
# [1, 2, 3] [1, 2, 3] True
a = (1, 2)
b = a
a += (3,)
print(a, b, a is b)
# (1, 2, 3) (1, 2) False
a = "T1"
b = a
a += "-A"
print(a, b, a is b)
# T1-A T1 False| 타입 | a += x가 하는 일 | 다른 태그 b에서 보이나 |
|---|---|---|
list | __iadd__: 같은 가방에 extend | 보인다 |
tuple, str, int | __add__: 새 객체를 만들어 a만 옮김 | 보이지 않는다 |
1교시의 a = a + [3]과 리스트의 a += [3]이 다른 결과를 내는 까닭이 이것입니다. 앞은 새 리스트, 뒤는 같은 리스트입니다. set의 |=, dict의 |=도 리스트처럼 제자리에서 바꿉니다.
세 타입을 번갈아 돌려 보세요.
태그 보드
단계 1 / 0
다음에 실행할 줄: 0
실습대를 준비하는 중…
방금 바뀐 것
봉인된 가방(불변)
핀의 안내
→ 함수에 리스트를 받아 x += [...]를 하면, 부른 쪽의 리스트까지 바뀌어요. 정수나 문자열이었다면 바깥은 그대로고요. 같은 줄이 타입에 따라 다르게 움직인다는 걸 기억해 두세요(4교시).
튜플이 불변이라는 말은 정확히 "튜플이 담은 참조를 바꿀 수 없다"는 뜻입니다. 각 칸이 어떤 객체를 가리키는지는 고정됩니다. 하지만 그 칸이 가리키는 객체가 리스트라면, 리스트 자체는 얼마든지 바뀝니다.
t = (1, [2])
t[1].append(3)
print(t)
# (1, [2, 3])
t[1] = [9]
# TypeError: 'tuple' object does not support item assignment신고인의 코드는 append 대신 +=를 썼습니다. 이때 기묘한 일이 벌어집니다.
t = (1, [2])
try:
t[1] += [3]
except TypeError as e:
print("TypeError:", e)
print(t)
# TypeError: 'tuple' object does not support item assignment
# (1, [2, 3])오류가 났는데 리스트는 바뀌었습니다. t[1] += [3]을 풀어 쓰면 두 단계이기 때문입니다.
t = (1, [2])
tmp = t[1]
tmp = tmp.__iadd__([3]) # 1단계: 리스트를 제자리에서 바꾼다(성공)
print(t)
# (1, [2, 3])
t[1] = tmp # 2단계: 튜플 칸에 다시 넣는다(실패)
# TypeError: 'tuple' object does not support item assignment1단계에서 리스트는 이미 바뀌었습니다. 2단계의 칸 대입이 실패해 오류가 나지만, 되돌릴 방법은 없습니다. 저울이 "봉인된 가방은 열 수 없다"고 외치면서 물건을 넣은 까닭입니다.
태그 보드
단계 1 / 0
다음에 실행할 줄: 0
실습대를 준비하는 중…
방금 바뀐 것
봉인된 가방(불변)
안쪽까지 봉인되었는지 알아보는 간단한 방법이 있습니다. hash()가 되는지 보는 것입니다. 해시가 되는 튜플은 안에 든 것도 모두 해시가 되는(대개 불변인) 객체입니다(12교시).
print(hash(("passport", ("snack",))) == hash(("passport", ("snack",))))
# True
hash(("passport", ["snack"]))
# TypeError: unhashable type: 'list'+=로 이을 때의 비용 (6분)문자열은 불변이므로 s += "x"는 원칙적으로 매번 새 문자열을 만듭니다. 길이가 n인 문자열을 한 글자씩 이으면, 복사하는 글자 수가 1 + 2 + … + n으로 불어납니다.
def plus(n):
s = ""
for i in range(n):
s += "x"
return s
def plus_shared(n):
s = ""
for i in range(n):
last = s # 같은 문자열에 태그가 하나 더 붙어 있다
s += "x"
return s
def join(n):
return "".join(["x" for i in range(n)])
print(plus(5) == plus_shared(5) == join(5))
# TrueCPython에서는 문자열에 태그가 하나뿐이면 +=가 몰래 제자리에서 늘리는 최적화를 합니다. 그래서 plus는 생각보다 빠릅니다. 하지만 태그가 하나만 더 붙어도 최적화는 사라집니다. 이 PC(CPython 3.14.2)에서 잰 예입니다.
| 반복 횟수 | plus | plus_shared | join |
|---|---|---|---|
| 100,000 | 약 6ms | 약 120ms | 약 3ms |
| 200,000 | 약 12ms | 약 460ms | 약 5ms |
plus_shared는 횟수가 2배가 되자 시간이 4배 가까이 늘었습니다. 복사량이 제곱으로 느는 모습입니다. 최적화는 언어가 약속한 것이 아니니, 반복문에서 문자열을 모을 때는 리스트에 담았다가 "".join()으로 한 번에 잇습니다.
is를 쓰지 않는 이유 (7분)불변 객체는 바뀔 일이 없으니, 같은 값이면 한 객체를 여럿이 나눠 써도 안전합니다. CPython은 이 점을 이용해 자주 쓰는 객체를 미리 만들어 둡니다.
작은 정수 캐시. CPython에서는 -5부터 256까지의 정수를 시작할 때 미리 만들어 두고 늘 같은 객체를 돌려줍니다.
print(int("256") is int("256"), int("257") is int("257"))
# True False
print(int("-5") is int("-5"), int("-6") is int("-6"))
# True Falseint("…")로 실행 중에 정수를 만든 것은 일부러입니다. 코드에 257 is 257처럼 리터럴을 직접 쓰면, 컴파일러가 같은 상수를 하나로 합쳐 True가 나올 수 있습니다. 파이썬도 이런 비교를 경고합니다.
x = 256
print(x is 256)
# SyntaxWarning: "is" with 'int' literal. Did you mean "=="?
# True문자열 인터닝. CPython은 이름처럼 생긴 문자열 상수를 한 객체로 모아 씁니다(인터닝). 실행 중에 만든 문자열은 값이 같아도 다른 객체일 수 있습니다. sys.intern()을 쓰면 같은 값의 문자열을 한 객체로 맞출 수 있습니다.
import sys
a = "gate"
b = "".join(["ga", "te"])
print(a == b, a is b)
# True False
print(sys.intern(b) is a)
# True결과가 캐시와 인터닝 여부에 따라 달라지니, is로 값을 비교하면 어떤 값에서는 맞고 어떤 값에서는 틀립니다. 값은 언제나 ==로, is는 None처럼 하나뿐인 객체에만 씁니다.
핀의 안내
→ 테스트에서 작은 숫자로만 확인하면 x is 100 이 늘 True라서 버그가 숨어요. 운영에서 1000이 들어오는 순간 False가 되죠. 숫자와 문자열은 == 로 비교하세요.
신고인의 코드와, 정말로 열 수 없는 가방을 만드는 방법입니다.
sealed = ("passport", ["snack"])
try:
sealed[1] += ["perfume"]
except TypeError as e:
print("scale:", e)
print(sealed)
# scale: 'tuple' object does not support item assignment
# ('passport', ['snack', 'perfume'])
sealed = ("passport", ("snack",)) # 안쪽도 튜플로
try:
sealed[1] += ("perfume",)
except TypeError as e:
print("scale:", e)
print(sealed)
# scale: 'tuple' object does not support item assignment
# ('passport', ('snack',))__iadd__가 먼저 성공했고, 칸 대입만 실패했습니다.__iadd__가 없으니 새 튜플을 만들기만 하고, 칸 대입에서 실패합니다. 원래 가방은 그대로입니다.반장이 새 봉인 스티커를 안쪽 가방에도 붙이며 말합니다. "봉인은 겉에서 끝나지 않아. 안에 든 가방까지 봐야지."
종결 보고 — LOST-002. 튜플은 담은 참조만 고정할 뿐, 안의 리스트는 가변이었음. t[1] += [...]는 제자리 확장 뒤 칸 대입에서 실패. 안쪽까지 불변(튜플)으로 바꿔 해결.
x = 1000, y = x, x += 1을 실행한 뒤 y는 무엇일까요? 태그 그림을 그린 뒤 확인하세요.s = {1}, t = s, s |= {2}를 실행하면 t는 어떻게 될까요? frozenset({1})으로 바꿔서도 해 보세요.t = ([],)일 때 t[0] += [1]과 t[0].extend([1])은 각각 어떻게 끝날까요? 실행 뒤 t도 찍어 보세요.반장의 업무 수첩
가변: list, dict, set. 불변: int, float, str, tuple, frozenset.
불변 객체의 '변경'은 언제나 새 객체를 만들고 태그를 옮기는 것.
+= 는 __iadd__가 있으면 제자리(list), 없으면 새 객체(int, str, tuple).
튜플은 담은 참조만 고정한다. 안의 리스트는 바뀐다. t[1] += [...]는 오류를 내면서도 바꾼다.
반복문에서 문자열을 모을 때는 리스트에 담아 "".join().
작은 정수 캐시와 인터닝은 CPython의 사정. 값 비교는 언제나 ==.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.