2026. 9. 9. 20:19ㆍ크래프톤 정글/포스팅
파이썬으로 class Stack: ...을 정의하고 s = Stack()으로 인스턴스를 만드는 건 너무 익숙한 코드다.
하지만 이 두 줄 뒤에서 파이썬이 내부적으로 무슨 일을 하는지 알아보자.
1부. 클래스와 인스턴스의 관계 — 틀과 실제로 찍어낸 것
클래스는 틀, 인스턴스는 그 틀로 찍어낸 것
- 붕어빵 틀 = 클래스(Stack) — "어떤 모양으로 찍어낼지"에 대한 설계도
- 붕어빵 = 인스턴스(s) — 그 틀로 실제로 찍혀 나온, 손에 쥘 수 있는 결과물
class Stack:
def __init__(self):
self._data = []
def push(self, item):
self._data.append(item)
def __repr__(self):
return f"Stack({self._data})"
s = Stack() # 틀로 붕어빵 하나를 찍어낸 순간
class Stack: 블록은 "붕어빵을 어떻게 찍을지"에 대한 틀 자체를 만드는 것이고,
Stack()을 호출하는 순간 그 틀을 이용해 실제 붕어빵 하나(인스턴스)가 만들어지는 것이다.
틀도 사실 하나의 객체다 - 클래스 객체
여기서 한 걸음 더 들어가면, 틀(Stack) 자체도 파이썬 안에서는 하나의 객체다. class 문이 실행되고 나면 Stack이라는 이름이 가리키는 클래스 객체가 메모리에 만들어지고, 그 안에는 이 틀이 "찍어낼 때 쓸 부품들" — 즉 메서드들이 들어 있다.
print(Stack.__dict__.keys())
# dict_keys(['__module__', '__init__', 'push', '__repr__', ...])
즉 틀 안에는 붕어빵 찍는 방법(__init__), 팥을 넣는 방법(push) 같은 "만드는 방법"들이 저장되어 있는 것이지, 아직 실제 붕어빵(데이터)은 하나도 없다.
인스턴스는 자기만의 내용물만 가진다 - 인스턴스 객체
Stack()을 호출해서 s가 만들어지면, 이 s는 자기만의 내용물(데이터)만 따로 가진다. 틀에 있던 "만드는 방법"들은 복사되지 않는다 — 붕어빵 하나하나가 "팥 넣는 법"을 따로 기억하지 않는 것과 같다. 그건 여전히 틀 쪽에만 있고, 필요할 때 틀을 참조해서 쓴다.
s = Stack()
s.push(1)
print(s.__dict__) # {'_data': [1]} ← 인스턴스는 데이터만
print(Stack.__dict__.keys()) # push, __init__ 등 ← 틀은 방법만
| 클래스 (Stack) | 인스턴스 (s) | |
| 갖고 있는 것 | 메서드들 (__init__, push, __repr__) | 자기만의 데이터 (_data) |
| 저장 위치 | Stack.__dict__ | s.__dict__ |
| 몇 개 존재하나 | 프로그램에 딱 하나 | 필요한 만큼 여러 개 |
붕어빵을 100개 찍어내도 "팥 넣는 방법"은 틀에 하나만 있으면 충분한 것처럼, Stack 인스턴스를 100개 만들어도 push 메서드는 Stack.__dict__에 딱 하나만 존재한다. 각 인스턴스는 그저 자기만의 _data만 따로 가질 뿐이다.
Stack.__dict__ vs s.__dict__ — 헷갈리기 쉬운 두 딕셔너리
이 둘은 "누구의" 네임스페이스인가가 다른, 완전히 별개의 딕셔너리다.
print(Stack.__dict__)
# mappingproxy({'__init__': <function ...>, 'push': <function ...>,
# '__repr__': <function ...>, ...}) ← 클래스 자신의 네임스페이스
print(s.__dict__)
# {'_data': [1]} ← 인스턴스만의 네임스페이스
Stack.__dict__는 mappingproxy라는, "읽을 수는 있지만 직접 수정은 막아놓은" 딕셔너리로 감싸져 있다. 반면 s.__dict__는 평범한 딕셔너리라 자유롭게 읽고 쓸 수 있다.
s.push(1)을 호출하면, 붕어빵이 스스로 팥을 넣는 게 아니라 틀의 방법을 빌려온다
s.push(1)
이 호출이 일어나면, 파이썬은 먼저 s.__dict__를 본다 — 거기엔 push가 없다.
그러면 s가 어떤 틀로 찍혔는지(Stack)를 확인하고, 그 틀 쪽 Stack.__dict__에서 push를 찾아 실행한다. 이때 "이 방법을 지금 어떤 붕어빵에 적용할 건지"를 알려주기 위해 s 자신이 첫 번째 재료로 자동으로 전달되는데, 그게 바로 뒤에서 다룰 self다.
s1 = Stack()
s2 = Stack()
s1.push(1)
print(s1.__dict__) # {'_data': [1]}
print(s2.__dict__) # {'_data': []} ← s1과 완전히 별개
print(Stack.push is Stack.__dict__['push']) # True, push는 하나뿐
2부. 클래스를 정의하면 무슨 일이 일어나는가
class Stack: 블록은 하나의 코드 실행이다
파이썬은 클래스 본문을 새로운 네임스페이스에서 실행한다. def __init__, def push, def __repr__이 실행되면서 각각 함수 객체가 만들어지고, 그 네임스페이스에 저장된다.
클래스도 객체다: type()의 두 얼굴
평소에 type(5)처럼 인자를 하나만 넘기면 "이 객체의 타입이 뭐야?"를 묻는 것이었다. 그런데 type()은 인자를 세 개 넘겨서 부르는 완전히 다른 용법도 있다: "이런 스펙으로 새 클래스를 만들어줘"라는 요청이다.
type(어떤객체) # 1개 인자: 타입 조회
type(name, bases, namespace) # 3개 인자: 새 클래스 생성
class Stack: ...이 실행되고 나면, 파이썬은 내부적으로 이 3-인자 버전을 호출한 것과 같다:
Stack = type('Stack', (object,), namespace)
각 인자는 클래스를 구성하는 세 가지 재료다.
| 인자 | 값 | 의미 |
| name | 'Stack' | 클래스 이름 → Stack.__name__ |
| bases | (object,) | 부모 클래스들의 튜플 → Stack.__bases__ |
| namespace | {'__init__': ..., 'push': ..., '__repr__': ...} | 클래스 본문 실행 결과 → Stack.__dict__의 재료 |
namespace는 클래스 본문을 실행해서 나온 결과물이다
class Stack: 아래의 본문(def __init__, def push, def __repr__)은 먼저 하나의 새 빈 딕셔너리 안에서 순서대로 실행된다. def는 함수 객체를 만들고 그 이름으로 이 딕셔너리에 저장하는 문장이므로:
namespace = {}
# def __init__(self): ... 실행됨 → namespace['__init__'] = <function>
# def push(self, item): ... 실행됨 → namespace['push'] = <function>
# def __repr__(self): ... 실행됨 → namespace['__repr__'] = <function>
본문 실행이 끝난 뒤, 이렇게 채워진 namespace를 들고 type()을 호출해서 클래스를 조립한다.
즉 Stack이라는 이름의 객체는:
- Stack.__dict__ 안에 __init__, push, __repr__을 갖고 있고
- type(Stack)은 type이며
- Stack.__bases__는 (object,)다
직접 손으로 조립해보기
class 없이 type()만으로 완전히 똑같은 클래스를 만들 수 있다 — class 키워드는 이 과정을 감싸놓은 문법 설탕일 뿐이라는 걸 보여준다.
def __init__(self):
self._data = []
def push(self, item):
self._data.append(item)
def __repr__(self):
return f"Stack({self._data})"
namespace = {'__init__': __init__, 'push': push, '__repr__': __repr__}
Stack = type('Stack', (object,), namespace)
s = Stack()
s.push(1)
print(s) # Stack([1]) ← class 문으로 만든 것과 완전히 동일하게 동작
3부. 인스턴스가 태어나는 순간
Stack() 호출은 사실 type.__call__이 처리한다
Stack()을 실행하면, Stack이라는 객체를 "호출"하는 것이다. Stack을 호출 가능하게 만드는 건 type(Stack) 즉 type이 가진 __call__이다. 의사코드로 표현하면:
def type_call(cls, *args, **kwargs):
instance = cls.__new__(cls, *args, **kwargs) # 1. 빈 인스턴스 생성
if isinstance(instance, cls):
instance.__init__(*args, **kwargs) # 2. 초기화
return instance
__new__로 만들고, __init__으로 채운다
- __new__: 인스턴스를 실제로 생성(메모리 할당)하는 역할
- __init__: 이미 만들어진 인스턴스를 초기화하는 역할
Stack은 __new__를 직접 정의하지 않았지만 에러 없이 잘 동작한다. 왜냐하면:
정의 안 하면 object로부터 물려받는다
파이썬의 속성 탐색은 다음 순서로 이루어진다: 인스턴스 → 클래스 → 부모 클래스(MRO를 따라 위로).
print(Stack.__new__ is object.__new__) # True
print(Stack.__init__) # 우리가 정의한 __init__
class Empty:
pass
e = Empty()
print(Empty.__init__ is object.__init__) # True
즉 "정의 안 했으니 없다"가 아니라 "정의 안 했으니 부모(object)에서 물려받은 걸 쓴다"는 것이 파이썬 속성 탐색의 일관된 원칙이다.
4부. type과 object, 두 개의 축
여기서부터는 헷갈리기 쉬운 부분이다. 두 개의 완전히 다른 관계가 동시에 존재하기 때문이다.
인스턴스 관계 vs 상속 관계
- 인스턴스 관계 (is-instance-of): "이건 뭘로 만들어졌나?" → type(Stack) → type
- 상속 관계 (is-a, 부모-자식): "이건 누구를 상속했나?" → Stack.__bases__ → (object,)
s = Stack()
# 세로축: 인스턴스 관계
type(s) # <class 'Stack'>
type(Stack) # <class 'type'>
type(object) # <class 'type'>
type(type) # <class 'type'> ← 여기서 순환이 끝남
# 가로축: 상속 관계
Stack.__bases__ # (<class 'object'>,)
object.__bases__ # () ← object는 부모가 없음
type.__bases__ # (<class 'object'>,) ← type도 object를 상속함!
object와 type의 순환 구조
재밌는 반전: type도 object를 상속한다.
- type은 object의 자식(상속)이면서
- object는 type의 인스턴스(생성)다
한 문장으로: Stack은 object를 상속하고, type으로부터 만들어진 클래스다. object는 조상, type은 제조사인 셈이다.
5부. 던더 메서드의 정체
__init__, __repr__은 그냥 평범한 함수다
type이 __init__을 자동으로 만들어주는 게 아니다. 클래스 안에 직접 정의한 순간, push나 다른 함수와 똑같은 방식으로 Stack.__dict__에 저장되는 함수일 뿐이다.
print(Stack.__dict__['__init__']) # <function Stack.__init__ at 0x...>
파이썬이 "특정 상황에 이 이름을 찾아보기로" 약속한 것뿐
던더(dunder, double underscore)는 파이썬 문법이나 내장 함수와 클래스를 연결하는 다리다.
명시적으로 호출하는 게 아니라, 파이썬이 특정 상황에서 자동으로 찾아서 호출해준다.
| 코드 | 파이썬이 내부적으로 호출하는 것 |
| Stack() | type.__call__ → __new__, __init__ |
| print(s) / repr(s) | s.__repr__() |
| len(s) | s.__len__() |
| s[0] | s.__getitem__(0) |
| s1 + s2 | s1.__add__(s2) |
| for x in s: | s.__iter__() |
규칙은 이름이 아니라, "어느 연산이 어느 던더를 찾는가"는 매핑이 CPython 내부에 미리 박혀 있는 것이다.
앞뒤 밑줄 vs 앞만 밑줄
- __init__, __len__처럼 앞뒤로 둘 다 밑줄 → 던더, "특정 연산과 연결된 훅"
- __data처럼 앞에만 밑줄, 뒤에 없음 → 네임 맹글링. 파이썬이 내부적으로 _ClassName__data로 이름을 바꿔서, 상속 시 충돌을 방지하는 완전히 다른 메커니즘
__repr__과 __str__의 관계로 보는 fallback 원리
print(s)나 repr(s)를 쓰면, 파이썬은 s에게 __repr__이라는 이름의 메서드가 있는지 찾아보고 호출해주는 고정된 절차를 따른다. len()이 __len__을 찾고 +가 __add__를 찾는 것과 똑같은 원리다.
다만 print()는 한 단계 더 있다: 먼저 __str__을 찾아보고, 없으면 __repr__로 대체(fallback)한다.
print(s) # __str__을 찾음 → 없으면 → __repr__로 대체
repr(s) # 무조건 __repr__
- __repr__: 개발자가 디버깅할 때 보기 좋은, 정확한 표현. repr(obj), 인터프리터에 변수만 쳤을 때, 리스트 안에 들어있을 때 등에 호출됨
- __str__: 사람이 보기 좋은 표현. print(obj), str(obj)를 쓸 때 우선적으로 호출됨
class Stack:
def __init__(self):
self._data = []
def push(self, item):
self._data.append(item)
def __repr__(self):
return f"Stack(내부데이터={self._data!r})" # 디버깅용
def __str__(self):
return f"스택에 {len(self._data)}개 들어있음" # 사람 친화적
s = Stack()
s.push(1)
s.push(2)
print(s) # 스택에 2개 들어있음 ← __str__ 호출
print(repr(s)) # Stack(내부데이터=[1, 2]) ← __repr__ 호출
이게 바로 앞서 나온 Stack 클래스가 __repr__만 정의했는데도 print(s)가 잘 동작했던 이유다. "정의 안 하면 다른 곳에서 물려받는다"는 3부의 __new__/__init__ fallback 원리가 여기서도 비슷하게 나타난다 — 다만 이번엔 부모 클래스로 올라가는 게 아니라, __str__ → __repr__이라는 던더 메서드 사이의 대체 순서라는 점이 다르다. __repr__마저 없으면 최종적으로 object가 주는 기본값(<Stack object at 0x...>)으로 대체된다.
실무 관례: __repr__은 거의 항상 정의하는 게 좋다 (디버깅 시 print(변수)를 자주 찍어보기 때문). __str__은 "사람이 보는 화면에 예쁘게 보여줘야 할 때"만 따로 정의하면 된다.
6부. self는 어디서 오는가
self는 키워드가 아니라 관례다
self는 파이썬 예약어가 아니다. 다른 이름을 써도 동작은 똑같다 (하지만 관례를 깨지 않는 게 좋음)
class Stack:
def push(me, item): # self 대신 me
me._data.append(item)
s.push(1)이 Stack.push(s, 1)로 바뀌는 원리
이건 인스턴스 생성(type.__call__)과는 완전히 다른 메커니즘이다.
- s.push라는 속성 접근이 일어남
- s.__dict__에는 없음 → type(s)인 Stack.__dict__에서 찾음
- push는 함수인데, 함수는 __get__이라는 메서드를 가진 디스크립터다
- 인스턴스.함수 형태로 접근하면, __get__이 호출되어 바운드 메서드로 변환된다
bound_method = Stack.__dict__['push'].__get__(s, Stack)
# self가 자동으로 s로 고정된, 새로운 함수 같은 것
print(Stack.push) # <function Stack.push at 0x...> ← 그냥 함수
print(s.push) # <bound method Stack.push of Stack([])> ← 바운드 메서드
print(s.push.__self__) # Stack([]) ← self가 저장된 곳
print(s.push.__func__) # <function Stack.push at 0x...> ← 원본 함수
두 메커니즘은 서로 다르다
| Stack() | s.push(1) | |
| 상황 | 클래스를 호출해서 인스턴스 생성 | 인스턴스의 속성(메서드)에 접근 |
| 지휘자 | type.__call__ | 함수의 __get__ (디스크립터 프로토콜) |
class Stack:
def __init__(self):
self._data = []
print(id(self))
def push(self, item):
self._data.append(item)
s = Stack()
print(id(s)) # __init__ 안에서 찍힌 id(self)와 비교해보자
마무리 정리
- 클래스 정의 = type이 클래스라는 객체를 만드는 것
- 인스턴스 생성 = type.__call__이 __new__와 __init__을 순서대로 호출하는 것
- 메서드 호출 = 속성 탐색(클래스까지 올라감) + 함수의 __get__을 통한 self 자동 바인딩
- self._data = 이름표를 따라가 실제 객체(참조)에 도달하는 것
- print(), f-string = 그 객체에게 "문자열로 보여달라"고 별도로 요청하는 것