클래스의 내부 구조

파이썬에서 "모든 것은 객체"라고 하죠. 그렇다면 클래스 내부는 어떻게 생겼을까요?

__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

속성 찾기 우선순위

  1. 데이터 디스크립터 (클래스/부모 클래스에서 __set__이 있는 디스크립터)
  2. 인스턴스 `__dict__` (인스턴스 변수)
  3. 비데이터 디스크립터 및 일반 클래스 변수 (클래스/부모 클래스에서 __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자동 계산
로깅/캐싱디스크립터투명한 추가 기능

배운 내용

  1. 모든 것은 객체: 클래스도, 함수도, 메서드도 객체
  2. 디스크립터: 속성 접근을 제어하는 프로토콜
  3. 바운드 메서드: 디스크립터로 구현된 메서드 바인딩
  4. 프로퍼티: 디스크립터의 편리한 래퍼

왜 중요한가?

디스크립터를 이해하면:
- @property, @staticmethod, @classmethod가 내부적으로 어떻게 작동하는지 알 수 있습니다
- 커스텀 검증 로직을 재사용 가능한 형태로 만들 수 있습니다
- 파이썬의 "마법"처럼 보이는 동작들이 실제로는 일관된 프로토콜 위에 구축되어 있다는 것을 알게 됩니다