Python 강좌 4편 - 클래스의 내부 구조와 디스크립터
파이썬 클래스가 내부적으로 어떻게 작동하는지, 바운드 메서드와 디스크립터의 비밀을 파헤쳐봅니다
다음에는 이 흐름을 보세요
클래스의 내부 구조
파이썬에서 "모든 것은 객체"라고 한다. 그렇다면 클래스 내부는 어떻게 생겼을까?
__dict__: 속성이 사는 곳
파이썬에서 객체의 속성들은 딕셔너리로 저장된다.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
person = Person("영희", 25)
# 객체의 속성들을 직접 확인
print(person.__dict__)
# {'name': '영희', 'age': 25}
# 속성 추가
person.city = "서울"
print(person.__dict__)
# {'name': '영희', 'age': 25, 'city': '서울'}
속성에 접근할 때 실제로 일어나는 일은 이렇다.
# 우리가 쓰는 코드
print(person.name)
# 실제로 파이썬이 하는 일
print(person.__dict__['name'])
클래스 변수 vs 인스턴스 변수
class Dog:
# 클래스 변수: 모든 개가 공유
species = "Canis familiaris"
legs = 4
def __init__(self, name):
# 인스턴스 변수: 각 개마다 다름
self.name = name
dog1 = Dog("멍멍이")
dog2 = Dog("바둑이")
print(dog1.name) # "멍멍이" (각자 다름)
print(dog2.name) # "바둑이"
print(dog1.legs) # 4 (모두 같음)
print(dog2.legs) # 4
# 클래스 변수를 바꾸면?
Dog.species = "Canis lupus"
print(dog1.species) # "Canis lupus" (모두 변경됨!)
print(dog2.species) # "Canis lupus"
메모리에서 보면 이런 구조다.
graph BT
A["dog1
name: 멍멍이"] -->|__class__| C
B["dog2
name: 바둑이"] -->|__class__| C
C["Dog 클래스
species: Canis familiaris
legs: 4"]
style C fill:#e8f5e9
style A fill:#e3f2fd
style B fill:#e3f2fd
__class__: 자기가 어디서 왔는지 아는 법
모든 객체는 자신이 어떤 클래스에서 만들어졌는지 알고 있다.
print(dog1.__class__) # <class '__main__.Dog'>
print(dog1.__class__.__name__) # "Dog"
# 같은 클래스로 만들어진 객체들
print(dog1.__class__ is dog2.__class__) # True
# 클래스 변수 접근
print(dog1.__class__.species) # "Canis familiaris"
속성 찾기 순서
파이썬이 속성을 어떻게 찾는지 보자.
class Animal:
species = "Unknown"
def speak(self):
return "..."
class Cat(Animal):
species = "Felis catus"
cat = Cat()
print(cat.species) # "Felis catus"
print(cat.speak()) # "..."
cat.species를 찾을 때, 파이썬은 먼저 cat.__dict__를 확인한다. 없다. 그다음 Cat 클래스를 확인한다. 있다.
flowchart TD
S1["cat.species 접근"] --> A1{"cat.__dict__에
species 있는가?"}
A1 -->|없음| B1{"Cat 클래스에
species 있는가?"}
B1 -->|있음!| C1["Cat.species 반환
(Felis catus)"]
style S1 fill:#e3f2fd
style C1 fill:#e8f5e9
cat.speak()의 경우, 인스턴스에도 없고 Cat에도 없어서 Animal까지 올라가야 찾을 수 있다.
flowchart TD
S2["cat.speak() 호출"] --> A2{"cat.__dict__에
speak 있는가?"}
A2 -->|없음| B2{"Cat 클래스에
speak 있는가?"}
B2 -->|없음| C2{"Animal 클래스에
speak 있는가?"}
C2 -->|있음!| D2["Animal.speak 호출"]
style S2 fill:#e3f2fd
style D2 fill:#e8f5e9
이 순서를 MRO (Method Resolution Order)라고 한다.
# MRO 확인하기
print(Cat.__mro__)
# (<class 'Cat'>, <class 'Animal'>, <class 'object'>)
바운드 메서드
__dict__와 __class__로 클래스의 내부 구조를 봤다. 이제 메서드가 내부적으로 어떻게 작동하는지 알아보자.
메서드의 두 얼굴
놀라운 사실이 하나 있다. 클래스의 메서드는 두 가지 형태를 갖고 있다.
class Calculator:
def __init__(self, value):
self.value = value
def add(self, n):
self.value += n
return self.value
calc = Calculator(10)
# 메서드의 두 얼굴
print(type(Calculator.add)) # <class 'function'>
print(type(calc.add)) # <class 'method'>
클래스에서 보면 함수고, 인스턴스에서 보면 메서드다. 같은 건데 관점이 다르다.
바운드 메서드란?
바운드 메서드는 특정 인스턴스에 "묶인" 메서드다.
calc = Calculator(10)
# calc.add는 calc 인스턴스에 묶여있다
print(calc.add)
# <bound method Calculator.add of <__main__.Calculator object at 0x...>>
# 메서드를 변수에 저장할 수도 있다
add_method = calc.add
add_method(5) # calc.value가 15가 됨
print(calc.value) # 15
바인딩의 비밀
class MyClass:
def method(self, x):
return f"값: {x}"
obj = MyClass()
# 이 두 줄은 완전히 같은 일을 한다!
obj.method(42) # 일반적인 방법
MyClass.method(obj, 42) # 직접 호출
비밀은 이거다.
# 우리가 쓰는 코드
result = obj.method(42)
# 실제로 파이썬이 하는 일:
method_func = MyClass.method # 1. 함수를 가져옴
result = method_func(obj, 42) # 2. obj를 첫 번째 인자로 전달
이것이 self의 정체다. 파이썬이 자동으로 인스턴스를 첫 번째 인자로 넘겨준다.
바운드 메서드 내부
더 깊이 들어가보자.
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
counter = Counter()
# 바운드 메서드 상세 정보
bound = counter.increment
print(bound.__self__) # counter 객체를 가리킴
print(bound.__func__) # Counter.increment 함수를 가리킴
# 바운드 메서드는 내부적으로 이렇게 작동:
# bound() → bound.__func__(bound.__self__)
실전 예제: 콜백
바운드 메서드는 콜백으로 자주 쓰인다.
class Button:
def __init__(self, label):
self.label = label
self.click_count = 0
def on_click(self):
self.click_count += 1
print(f"{self.label} 버튼 클릭됨 ({self.click_count}번)")
# 버튼 생성
save_button = Button("저장")
cancel_button = Button("취소")
# 콜백으로 메서드 등록
callbacks = [save_button.on_click, cancel_button.on_click]
# 버튼 클릭 시뮬레이션
callbacks[0]() # "저장 버튼 클릭됨 (1번)"
callbacks[0]() # "저장 버튼 클릭됨 (2번)"
callbacks[1]() # "취소 버튼 클릭됨 (1번)"
각 바운드 메서드가 자기 인스턴스를 기억하고 있기 때문에 콜백이 그냥 작동한다.
디스크립터
바운드 메서드의 원리를 이해했으니, 이제 그 뒤에 있는 더 근본적인 메커니즘을 보자. 디스크립터는 잘 알려지지 않았지만 엄청나게 강력하다.
디스크립터란?
디스크립터는 속성 접근을 제어하는 객체다.
class Descriptor:
def __get__(self, obj, objtype=None):
print("__get__ 호출됨")
return obj._value
def __set__(self, obj, value):
print(f"__set__ 호출됨: {value}")
obj._value = value
def __delete__(self, obj):
print("__delete__ 호출됨")
del obj._value
class MyClass:
attr = Descriptor() # 디스크립터를 클래스 변수로
def __init__(self):
self._value = 0
obj = MyClass()
obj.attr = 10 # "__set__ 호출됨: 10"
print(obj.attr) # "__get__ 호출됨" → 10
del obj.attr # "__delete__ 호출됨"
디스크립터 프로토콜
디스크립터는 세 가지 특수 메서드를 구현한다.
class Descriptor:
def __get__(self, instance, owner):
"""
instance: 속성에 접근하는 객체 (obj.attr의 obj)
owner: 객체의 클래스 (MyClass)
"""
pass
def __set__(self, instance, value):
"""
instance: 속성을 설정하는 객체
value: 설정할 값
"""
pass
def __delete__(self, instance):
"""
instance: 속성을 삭제하는 객체
"""
pass
데이터 디스크립터 vs 비데이터 디스크립터
이 구분이 우선순위에 영향을 준다.
# 데이터 디스크립터: __set__ 또는 __delete__가 있음
class DataDescriptor:
def __get__(self, obj, objtype=None):
return "data descriptor"
def __set__(self, obj, value):
pass
# 비데이터 디스크립터: __get__만 있음
class NonDataDescriptor:
def __get__(self, obj, objtype=None):
return "non-data descriptor"
class MyClass:
data_desc = DataDescriptor()
non_data_desc = NonDataDescriptor()
obj = MyClass()
# 인스턴스 속성 vs 디스크립터 우선순위
obj.__dict__['non_data_desc'] = "instance attr"
print(obj.non_data_desc) # "instance attr" (인스턴스가 이김)
obj.__dict__['data_desc'] = "instance attr"
print(obj.data_desc) # "data descriptor" (데이터 디스크립터가 이김)
데이터 디스크립터는 인스턴스 속성을 이기고, 비데이터 디스크립터는 인스턴스 속성에 진다. 이 우선순위가 파이썬 속성 시스템의 핵심이다.
실전 예제 1: 타입 검증
class TypedProperty:
"""타입을 강제하는 디스크립터"""
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name}은(는) {self.expected_type.__name__} "
f"타입이어야 합니다. {type(value).__name__}이(가) 주어졌습니다."
)
instance.__dict__[self.name] = value
class Person:
name = TypedProperty("name", str)
age = TypedProperty("age", int)
def __init__(self, name, age):
self.name = name
self.age = age
# 정상 작동
person = Person("영희", 25)
print(person.name) # "영희"
# 타입 에러!
try:
person.age = "스물다섯"
except TypeError as e:
print(e) # "age은(는) int 타입이어야 합니다. str이(가) 주어졌습니다."
실전 예제 2: 값 범위 검증
class RangeValue:
"""값의 범위를 제한하는 디스크립터"""
def __init__(self, name, min_value=None, max_value=None):
self.name = name
self.min_value = min_value
self.max_value = max_value
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if self.min_value is not None and value < self.min_value:
raise ValueError(
f"{self.name}은(는) {self.min_value} 이상이어야 합니다"
)
if self.max_value is not None and value > self.max_value:
raise ValueError(
f"{self.name}은(는) {self.max_value} 이하여야 합니다"
)
instance.__dict__[self.name] = value
class Game:
health = RangeValue("health", min_value=0, max_value=100)
level = RangeValue("level", min_value=1)
def __init__(self):
self.health = 100
self.level = 1
def take_damage(self, damage):
self.health -= damage
# 사용
game = Game()
print(game.health) # 100
game.take_damage(30)
print(game.health) # 70
try:
game.take_damage(80) # health가 -10이 되려고 함
except ValueError as e:
print(e) # "health은(는) 0 이상이어야 합니다"
실전 예제 3: 로깅 디스크립터
class LoggedProperty:
"""속성 접근을 로깅하는 디스크립터"""
def __init__(self, name):
self.name = name
self.internal_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
value = getattr(instance, self.internal_name, None)
print(f"[GET] {self.name} = {value}")
return value
def __set__(self, instance, value):
print(f"[SET] {self.name} = {value}")
setattr(instance, self.internal_name, value)
class Account:
balance = LoggedProperty("balance")
def __init__(self, initial_balance):
self.balance = initial_balance
def deposit(self, amount):
self.balance += amount
# 사용
account = Account(1000)
# [SET] balance = 1000
print(account.balance)
# [GET] balance = 1000
account.deposit(500)
# [GET] balance = 1000
# [SET] balance = 1500
프로퍼티: 간편한 디스크립터
@property 데코레이터
디스크립터를 매번 클래스로 만들기는 귀찮다. @property는 같은 기능을 적은 코드로 제공한다.
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""반지름 getter"""
return self._radius
@radius.setter
def radius(self, value):
"""반지름 setter"""
if value < 0:
raise ValueError("반지름은 0 이상이어야 합니다")
self._radius = value
@property
def area(self):
"""넓이 (읽기 전용)"""
return 3.14159 * self._radius ** 2
@property
def diameter(self):
"""지름"""
return self._radius * 2
@diameter.setter
def diameter(self, value):
self._radius = value / 2
# 사용
circle = Circle(5)
print(circle.radius) # 5
print(circle.area) # 78.53975
circle.radius = 10
print(circle.area) # 314.159
# diameter를 설정하면 radius가 자동 계산
circle.diameter = 20
print(circle.radius) # 10.0
property의 정체
@property는 사실 디스크립터다.
class MyProperty:
"""property의 간단한 구현"""
def __init__(self, fget=None, fset=None):
self.fget = fget
self.fset = fset
def __get__(self, instance, owner):
if instance is None:
return self
if self.fget is None:
raise AttributeError("읽을 수 없는 속성")
return self.fget(instance)
def __set__(self, instance, value):
if self.fset is None:
raise AttributeError("설정할 수 없는 속성")
self.fset(instance, value)
def setter(self, fset):
return MyProperty(self.fget, fset)
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@MyProperty
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("절대영도보다 낮을 수 없습니다")
self._celsius = value
# 동작 확인
temp = Temperature(25)
print(temp.celsius) # 25
계산된 속성
프로퍼티의 강력한 활용법은 계산된 속성이다.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
"""넓이는 계산됨"""
return self.width * self.height
@property
def perimeter(self):
"""둘레는 계산됨"""
return 2 * (self.width + self.height)
rect = Rectangle(10, 20)
print(rect.area) # 200
print(rect.perimeter) # 60
# width를 바꾸면 area도 자동으로 바뀜
rect.width = 15
print(rect.area) # 300
area를 일반 속성처럼 접근하지만, 매번 새로 계산된다. 따로 업데이트할 필요가 없다.
메서드도 디스크립터다
퍼즐의 마지막 조각이다. 앞에서 배운 바운드 메서드가 어떻게 만들어지는지, 디스크립터로 완전히 설명할 수 있다.
함수는 디스크립터
class Function:
"""메서드의 간단한 구현"""
def __init__(self, func):
self.func = func
def __get__(self, instance, owner):
if instance is None:
# 클래스에서 접근: 그냥 함수 반환
return self.func
# 인스턴스에서 접근: 바운드 메서드 생성
return lambda *args, **kwargs: self.func(instance, *args, **kwargs)
class MyClass:
@Function
def method(self, x):
return f"{self} 의 값: {x}"
obj = MyClass()
# 클래스에서 접근
print(type(MyClass.method)) # function
# 인스턴스에서 접근
print(type(obj.method)) # function (간단 구현이라 method는 아님)
print(obj.method(42)) # 작동함!
실제 파이썬의 함수 객체는 __get__ 메서드를 내장하고 있다.
def my_function(self, x):
return x
# 함수는 디스크립터!
print(hasattr(my_function, '__get__')) # True
obj.method(x)를 호출하면, 파이썬이 클래스에서 함수를 찾고, __get__을 호출해 바운드 메서드를 만들고, 그걸 실행한다. 밑바닥까지 전부 디스크립터다.
전체 그림
클래스 내부 구조
graph TD
A["인스턴스
obj"]
B["obj.__dict__
인스턴스 속성들"]
C["obj.__class__
클래스 참조"]
D["클래스
MyClass"]
E["클래스 변수
디스크립터"]
A --> B
A --> C
C --> D
D --> E
style A fill:#e3f2fd
style B fill:#fff3e0
style C fill:#f3e5f5
style D fill:#e8f5e9
속성 찾기 우선순위
- 데이터 디스크립터 (클래스/부모 클래스에서
__set__이 있는 디스크립터) - 인스턴스 `__dict__` (인스턴스 변수)
- 비데이터 디스크립터 및 일반 클래스 변수 (클래스/부모 클래스에서
__get__만 있거나 디스크립터가 아닌 변수)
각 단계에서 현재 클래스, 그다음 부모 클래스 순서(MRO)로 탐색한다.
디스크립터 프로토콜
| 메서드 | 호출 시점 | 용도 |
|---|---|---|
get(self, instance, owner) | obj.attr | 속성 읽기 |
set(self, instance, value) | obj.attr = value | 속성 쓰기 |
delete(self, instance) | del obj.attr | 속성 삭제 |
언제 뭘 쓸까
간단한 getter/setter가 필요하면 @property를 쓴다. 깔끔하고 간편하다.
같은 검증 로직을 여러 클래스에서 재사용하려면 디스크립터 클래스를 만든다. DRY 원칙이다.
계산된 속성이 일반 속성처럼 보여야 하면 @property가 딱이다.
로깅이나 캐싱 같은 횡단 관심사에는 디스크립터가 행동을 투명하게 추가해준다.
파이썬에서 클래스도, 함수도, 메서드도 전부 객체다. 디스크립터는 속성 접근을 제어하는 프로토콜이다. 바운드 메서드는 디스크립터로 구현된다. 그리고 @property는 같은 메커니즘을 편리하게 감싼 것일 뿐이다.
이걸 이해하고 나면 @staticmethod나 @classmethod도 더 이상 마법이 아니다. 전부 같은 일관된 프로토콜 위에 만들어져 있다.