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()) # "..."
속성 찾기 과정:
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
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'>)
바운드 메서드(Bound Method)
__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'>
클래스에서 보면 함수(function)지만, 인스턴스에서 보면 메서드(method)입니다.
바운드 메서드란?
바운드 메서드(Bound Method)는 특정 인스턴스에 "묶인(bound)" 메서드를 말합니다.
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번)"
디스크립터(Descriptor)
바운드 메서드의 동작 원리를 이해했으니, 이제 그 뒤에 있는 더 근본적인 메커니즘을 살펴볼 차례입니다. 디스크립터는 잘 알려지지 않았지만 파이썬에서 가장 강력한 기능 중 하나입니다.
디스크립터란?
디스크립터(Descriptor)는 속성 접근을 제어하는 객체입니다.
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 데코레이터
디스크립터를 매번 클래스로 만들기는 번거롭습니다. @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
메서드도 디스크립터다
디스크립터와 프로퍼티를 배웠으니, 이제 퍼즐의 마지막 조각입니다. 앞에서 배운 바운드 메서드가 어떻게 만들어지는지, 디스크립터를 통해 설명할 수 있습니다.
함수는 디스크립터
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
정리
클래스 내부 구조
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)로 탐색합니다.
바운드 메서드
# 메서드 호출
obj.method(x)
# 실제 내부 동작
method = obj.__class__.method # 함수 가져오기
method.__get__(obj, type(obj))(x) # 디스크립터 프로토콜
디스크립터 프로토콜
| 메서드 | 호출 시점 | 용도 |
|---|---|---|
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가 내부적으로 어떻게 작동하는지 알 수 있습니다
- 커스텀 검증 로직을 재사용 가능한 형태로 만들 수 있습니다
- 파이썬의 "마법"처럼 보이는 동작들이 실제로는 일관된 프로토콜 위에 구축되어 있다는 것을 알게 됩니다