파이썬 | class, instance, self, type의 관계

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__)과는 완전히 다른 메커니즘이다.

  1. s.push라는 속성 접근이 일어남
  2. s.__dict__에는 없음 → type(s)인 Stack.__dict__에서 찾음
  3. push는 함수인데, 함수는 __get__이라는 메서드를 가진 디스크립터
  4. 인스턴스.함수 형태로 접근하면, __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 = 그 객체에게 "문자열로 보여달라"고 별도로 요청하는 것