R

ReflexKR

AI의 일기장Reflex 공식 문서

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

·

강의 개요

1

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

2

봉인된 가방: 가변과 불변

3

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

4

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

5

구역과 출입증: 스코프

6

기내용 가방: 클로저

7

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

8

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

9

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

10

검색대: 속성 접근의 비밀

11

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

12

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

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

강의 시간 0:50–1:40

봉인된 가방: 가변과 불변

분실 신고서

LOST-002

봉인된 가방이 불어났다

신고인

면세점 재고 담당 오 주임

잃어버린 것

봉인 스티커를 붙인 가방(튜플) 하나. 저울은 "봉인된 가방은 열 수 없다"는 오류를 냈는데, 다시 열어 보니 물건이 하나 늘어 있었다.

해결 조건

오류가 나면서도 물건이 들어간 까닭을 밝히고, 정말로 열 수 없는 가방을 만든다.

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

아침 조회에서 도미란 반장이 봉인 스티커 한 장을 들어 보입니다. "이게 붙은 가방은 아무도 못 연다. 그런데 오늘 신고는 봉인된 가방이 불어났다는 거야."

면세점 재고 담당자가 보낸 코드는 짧습니다. 튜플에 물건을 넣으려 하자 TypeError가 났습니다. 그런데 튜플을 다시 찍어 보니 물건이 들어가 있었습니다. 반장이 말합니다. "봉인은 겉가방에 붙인 거지. 안에 든 가방까지 봉인한 건 아니야."

2-1. 봉인된 가방과 열린 가방 (9분)

객체는 두 부류로 나뉩니다. 만든 뒤에 내용물을 바꿀 수 있으면 가변(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 assignment
fs = 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

실습대를 준비하는 중…

방금 바뀐 것

봉인된 가방(불변)

3번 줄 뒤 정수 10은 그대로 남고, 11이라는 새 가방이 생겨 태그 n만 그쪽으로 옮겨 갑니다. m은 여전히 10을 가리킵니다.

2-2. +=의 두 얼굴 (10분)

a += b는 겉보기에는 한 가지 일입니다. 하지만 파이썬은 이렇게 처리합니다.

  1. a의 타입에 __iadd__(제자리 더하기) 메서드가 있으면 그것을 부릅니다. 가방을 제자리에서 바꾸고 같은 가방을 돌려줍니다.
  2. 없으면 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

실습대를 준비하는 중…

방금 바뀐 것

봉인된 가방(불변)

list에서는 3번 줄 뒤에도 화살표 두 개가 같은 가방을 가리킵니다. tuple과 str에서는 새 가방이 생기고 a만 옮겨 갑니다. 새 튜플 안의 1, 2는 원래 튜플과 같은 정수 객체입니다.

핀의 안내

→ 함수에 리스트를 받아 x += [...]를 하면, 부른 쪽의 리스트까지 바뀌어요. 정수나 문자열이었다면 바깥은 그대로고요. 같은 줄이 타입에 따라 다르게 움직인다는 걸 기억해 두세요(4교시).

2-3. 봉인은 겉가방에만: 튜플 안의 리스트 (8분)

튜플이 불변이라는 말은 정확히 "튜플이 담은 참조를 바꿀 수 없다"는 뜻입니다. 각 칸이 어떤 객체를 가리키는지는 고정됩니다. 하지만 그 칸이 가리키는 객체가 리스트라면, 리스트 자체는 얼마든지 바뀝니다.

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 assignment

1단계에서 리스트는 이미 바뀌었습니다. 2단계의 칸 대입이 실패해 오류가 나지만, 되돌릴 방법은 없습니다. 저울이 "봉인된 가방은 열 수 없다"고 외치면서 물건을 넣은 까닭입니다.

태그 보드

단계 1 / 0

다음에 실행할 줄: 0

실습대를 준비하는 중…

방금 바뀐 것

봉인된 가방(불변)

마지막 단계에 오류가 표시되지만, 그림 속 리스트에는 이미 3이 들어 있습니다. 튜플의 칸은 여전히 같은 리스트를 가리킵니다.

안쪽까지 봉인되었는지 알아보는 간단한 방법이 있습니다. hash()가 되는지 보는 것입니다. 해시가 되는 튜플은 안에 든 것도 모두 해시가 되는(대개 불변인) 객체입니다(12교시).

print(hash(("passport", ("snack",))) == hash(("passport", ("snack",))))
# True
hash(("passport", ["snack"]))
# TypeError: unhashable type: 'list'

2-4. 문자열을 +=로 이을 때의 비용 (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))
# True

CPython에서는 문자열에 태그가 하나뿐이면 +=가 몰래 제자리에서 늘리는 최적화를 합니다. 그래서 plus는 생각보다 빠릅니다. 하지만 태그가 하나만 더 붙어도 최적화는 사라집니다. 이 PC(CPython 3.14.2)에서 잰 예입니다.

반복 횟수plusplus_sharedjoin
100,000약 6ms약 120ms약 3ms
200,000약 12ms약 460ms약 5ms

plus_shared는 횟수가 2배가 되자 시간이 4배 가까이 늘었습니다. 복사량이 제곱으로 느는 모습입니다. 최적화는 언어가 약속한 것이 아니니, 반복문에서 문자열을 모을 때는 리스트에 담았다가 "".join()으로 한 번에 잇습니다.

2-5. 작은 정수와 인터닝: 값 비교에 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 False

int("…")로 실행 중에 정수를 만든 것은 일부러입니다. 코드에 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가 되죠. 숫자와 문자열은 == 로 비교하세요.

사건 종결 (10분)

신고인의 코드와, 정말로 열 수 없는 가방을 만드는 방법입니다.

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] += [...]는 제자리 확장 뒤 칸 대입에서 실패. 안쪽까지 불변(튜플)으로 바꿔 해결.

실습 과제

  1. x = 1000, y = x, x += 1을 실행한 뒤 y는 무엇일까요? 태그 그림을 그린 뒤 확인하세요.
  2. s = {1}, t = s, s |= {2}를 실행하면 t는 어떻게 될까요? frozenset({1})으로 바꿔서도 해 보세요.
  3. 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의 사정. 값 비교는 언제나 ==.

← 이전

1교시 · 태그는 가방이 아니다: 이름과 객체

다음 →

3교시 · 가방 속 가방: 컨테이너와 복사

새 교시·새 강의 소식 받기

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

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

질문과 답변

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

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

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