LLM WikiAccess-protected knowledge portal
← 스터디 홈
4편 · 약 22분

클래스와 메타프로그래밍

매직 메서드(Dunder): Python 클래스의 계약

__init__, __repr__ 같은 더블언더(double underscore) 메서드는 Python이 연산자, 내장 함수, 프로토콜을 클래스와 연결하는 공식 인터페이스다. 이름처럼 "마법"이 아니라 인터프리터가 호출하는 명시적 계약이다.

class Vector:
    def __init__(self, x: float, y: float) -> None:
        self.x, self.y = x, y

    def __repr__(self) -> str:
        return f"Vector({self.x}, {self.y})"

    def __add__(self, other: "Vector") -> "Vector":
        return Vector(self.x + other.x, self.y + other.y)

    def __mul__(self, scalar: float) -> "Vector":
        return Vector(self.x * scalar, self.y * scalar)

    def __abs__(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __bool__(self) -> bool:
        return bool(abs(self))

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Vector):
            return NotImplemented
        return self.x == other.x and self.y == other.y

v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)   # Vector(4, 6)
print(v1 * 3)    # Vector(3, 6)
print(abs(v2))   # 5.0

자주 쓰는 매직 메서드를 목적별로 정리하면 다음과 같다.

목적메서드
초기화·표현__init__, __repr__, __str__, __format__
산술 연산__add__, __sub__, __mul__, __truediv__, __floordiv__, __mod__, __pow__
비교__eq__, __lt__, __le__, __gt__, __ge__, __hash__
컨테이너__len__, __getitem__, __setitem__, __delitem__, __contains__, __iter__
컨텍스트__enter__, __exit__
속성 접근__getattr__, __setattr__, __delattr__, __getattribute__
호출__call__

__repr__은 개발자용 표현(디버거·로그), __str__은 사용자용 표현이다. __str__이 없으면 str(obj)__repr__을 쓴다. __eq__를 정의하면 __hash__None으로 설정되어 집합·딕셔너리 키로 쓸 수 없게 된다 — 불변 객체라면 __hash__도 함께 정의해야 한다.

디스크립터 프로토콜: property, classmethod의 실체

Python의 속성 접근(obj.attr)은 단순한 변수 읽기가 아니다. 디스크립터 프로토콜이 그 아래에서 작동한다.

디스크립터는 __get__, __set__, __delete__ 중 하나 이상을 구현한 객체다. 클래스 변수로 등록되면 해당 속성 접근을 가로챈다.

  • 데이터 디스크립터: __set__ 또는 __delete__ 있음 → 인스턴스 __dict__보다 우선
  • 비데이터 디스크립터: __get__만 있음 → 인스턴스 __dict__에 같은 이름이 있으면 밀림
class Validator:
    """양수만 허용하는 데이터 디스크립터."""

    def __set_name__(self, owner, name: str) -> None:
        # Python 3.6+: 클래스 생성 시 type.__new__가 자동 호출
        self.name = name
        self.private_name = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self               # 클래스를 통해 접근하면 디스크립터 자체를 반환
        return getattr(obj, self.private_name, 0)

    def __set__(self, obj, value: float) -> None:
        if value < 0:
            raise ValueError(f"{self.name}은 0 이상이어야 합니다 (입력: {value})")
        setattr(obj, self.private_name, value)

class Order:
    quantity = Validator()   # 데이터 디스크립터로 등록
    price    = Validator()

    def __init__(self, quantity: float, price: float) -> None:
        self.quantity = quantity  # Validator.__set__ 호출
        self.price    = price

order = Order(5, 10.0)
# Order(quantity=-1, price=10.0) → ValueError

__set_name__은 Python 3.6(PEP 487)에서 도입되었다. 이 전에는 메타클래스나 수동으로 이름을 넘겨야 했다.

속성 조회 우선순위

obj.attr 조회 순서 ① 데이터 디스크립터 type(obj).__mro__ 탐색 중 __set__ 또는 __delete__가 있는 클래스 변수 → property, 커스텀 Validator 등 없으면 ② 인스턴스 __dict__ obj.__dict__["attr"] — 인스턴스에 직접 저장된 속성 없으면 ③ 비데이터 디스크립터 / 클래스 변수 __get__만 있는 디스크립터(메서드, staticmethod 등), 또는 일반 클래스 속성 없으면 ④ __getattr__ 호출 (정의된 경우) 마지막 수단. AttributeError를 발생시키거나 동적으로 생성해 반환 property = 데이터 디스크립터 / 인스턴스 메서드 = 비데이터 디스크립터
Python 속성 조회 우선순위

property: 가장 흔한 디스크립터

class Temperature:
    def __init__(self, celsius: float = 0.0) -> None:
        self._celsius = celsius

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        if value < -273.15:
            raise ValueError("절대 영도 이하")
        self._celsius = value

    @property
    def fahrenheit(self) -> float:
        return self._celsius * 9 / 5 + 32

t = Temperature(100)
print(t.fahrenheit)    # 212.0
t.celsius = -300       # ValueError

propertyfget, fset, fdel을 받는 데이터 디스크립터다. @prop.setter는 내부적으로 property(fget=celsius, fset=...)를 새로 만들어 반환한다.

classmethod, staticmethod, __slots__

class Config:
    _instances: dict = {}

    def __init__(self, env: str, debug: bool) -> None:
        self.env = env
        self.debug = debug

    @classmethod
    def from_dict(cls, data: dict) -> "Config":
        """팩토리 메서드 — 서브클래스에서 오버라이드 가능."""
        return cls(data["env"], data.get("debug", False))

    @staticmethod
    def validate_env(env: str) -> bool:
        """인스턴스·클래스 상태 모두 필요 없을 때."""
        return env in ("dev", "staging", "prod")

@classmethod는 첫 번째 인자로 cls(클래스 자체)를 받는다. 팩토리 패턴, 다형적 생성자 패턴에 쓴다. @staticmethod는 클래스 네임스페이스에 속하는 유틸리티 함수 — selfcls도 없다.

__slots__: 인스턴스 __dict__ 제거

기본적으로 Python 인스턴스는 __dict__를 갖는다. 수백만 개의 경량 객체를 만들 때 이 비용이 누적된다.

class Point:
    __slots__ = ("x", "y")   # __dict__ 없음, slot 배열로 저장

    def __init__(self, x: float, y: float) -> None:
        self.x, self.y = x, y

# 일반 클래스: ~200 bytes/인스턴스 vs __slots__: ~56 bytes
# 인스턴스에 새 속성 추가 불가 → 불변 스키마 강제

__slots__는 Spark UDF, Airflow 태스크처럼 같은 형태의 객체를 대량 생성하는 코드에서 유효하다. 상속 시 부모와 자식 모두 __slots__를 선언해야 효과가 온전히 유지된다.

dataclass: 보일러플레이트 제거

Python 3.7에서 도입된 @dataclass__init__, __repr__, __eq__를 자동 생성한다.

from dataclasses import dataclass, field

@dataclass
class Article:
    title: str
    author: str
    tags: list[str] = field(default_factory=list)
    views: int = 0

    def summary(self) -> str:
        return f"{self.title} by {self.author} ({self.views} views)"

a = Article("Python 완전정복", "홍길동", tags=["python", "tutorial"])
print(a)        # Article(title='Python 완전정복', author='홍길동', tags=['python', 'tutorial'], views=0)

주요 옵션:

@dataclass(frozen=True)   # __hash__ 자동 생성, 불변 객체
@dataclass(order=True)    # __lt__, __le__, __gt__, __ge__ 생성
@dataclass(slots=True)    # Python 3.10+, __slots__ 자동 적용

frozen=True__hash__가 생성되어 집합·딕셔너리 키로 쓸 수 있다. 가변 필드 기본값은 반드시 field(default_factory=...)로 선언한다 — tags: list = []는 모든 인스턴스가 같은 리스트를 공유하는 버그를 낳는다.

ABC: 추상 기반 클래스

from abc import ABC, abstractmethod

class Storage(ABC):
    @abstractmethod
    def read(self, key: str) -> bytes:
        ...

    @abstractmethod
    def write(self, key: str, data: bytes) -> None:
        ...

    def exists(self, key: str) -> bool:      # 구체 메서드도 가능
        try:
            self.read(key)
            return True
        except KeyError:
            return False

class S3Storage(Storage):
    def read(self, key: str) -> bytes:
        ...  # 구현

    def write(self, key: str, data: bytes) -> None:
        ...  # 구현

# Storage()  → TypeError: Can't instantiate abstract class

@abstractmethod가 붙은 메서드를 모두 구현하지 않으면 서브클래스도 인스턴스화할 수 없다. Airflow 커스텀 훅(BaseHook), dbt 플러그인처럼 플러그인 아키텍처를 강제할 때 유용하다.

메타클래스: 클래스를 만드는 클래스

Python의 모든 클래스는 메타클래스(type)의 인스턴스다. type은 세 인자 type(name, bases, namespace)로 동적으로 클래스를 생성하기도 한다.

# 아래 두 코드는 동일한 결과를 낳는다

class Dog:
    sound = "woof"
    def bark(self): print(self.sound)

Dog2 = type("Dog2", (), {"sound": "woof", "bark": lambda self: print(self.sound)})

커스텀 메타클래스는 type을 상속해 __new____init__을 오버라이드한다.

class SingletonMeta(type):
    _instances: dict = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class Database(metaclass=SingletonMeta):
    def __init__(self, url: str) -> None:
        self.url = url

db1 = Database("postgres://localhost/app")
db2 = Database("postgres://localhost/other")
assert db1 is db2   # True — 싱글턴

메타클래스 생성 순서

type.__prepare__(name, bases)     # 클래스 네임스페이스 생성 (OrderedDict 반환 가능)
    ↓ 클래스 본문 실행 → 네임스페이스 채움
type.__new__(mcs, name, bases, ns) # 클래스 객체 생성
    ↓ 모든 디스크립터의 __set_name__ 호출
type.__init__(cls, name, bases, ns) # 초기화

__init_subclass__: 현대적인 대안 (PEP 487)

Python 3.6+에서 도입된 __init_subclass__는 메타클래스 없이 서브클래스 등록·검증 로직을 구현한다. 대부분의 메타클래스 사용 사례를 이 훅으로 대체할 수 있다.

class Plugin:
    _registry: dict[str, type] = {}

    def __init_subclass__(cls, name: str | None = None, **kwargs) -> None:
        super().__init_subclass__(**kwargs)
        if name:
            Plugin._registry[name] = cls

class CsvPlugin(Plugin, name="csv"):
    ...

class JsonPlugin(Plugin, name="json"):
    ...

print(Plugin._registry)
# {'csv': <class 'CsvPlugin'>, 'json': <class 'JsonPlugin'>}

__init_subclass__Plugin을 상속하는 모든 클래스가 정의될 때 자동으로 호출된다. 플러그인 레지스트리, ORM 모델 등록, 추상 인터페이스 준수 검증에 쓴다.

언제 무엇을 쓸 것인가

속성 유효성 검증·계산
property / 디스크립터
서브클래스 등록·검증
__init_subclass__
클래스 생성 자체 변경
(DSL, ORM 등)
메타클래스(마지막 수단)
보일러플레이트 제거
@dataclass
메타프로그래밍 도구 선택 기준

메타클래스는 강력하지만 스택 트레이스를 복잡하게 만들고 다른 메타클래스와 충돌할 수 있다. 먼저 __init_subclass__, 클래스 데코레이터, @dataclass 등으로 해결되는지 확인하고 마지막 수단으로 쓴다.

정리

  1. 매직 메서드는 Python 연산자·프로토콜과 클래스를 연결하는 공식 계약이다.
  2. 디스크립터 프로토콜: 속성 접근 우선순위 — 데이터 디스크립터 > 인스턴스 __dict__ > 비데이터 디스크립터. property는 데이터 디스크립터다.
  3. __set_name__ (3.6+): 디스크립터가 자신의 이름을 알 수 있는 훅. 메타클래스 없이도 동작.
  4. __slots__: 수백만 경량 객체를 만들 때 메모리를 아낀다. 상속 시 주의.
  5. @dataclass: __init__, __repr__, __eq__ 자동 생성. frozen=True로 불변 객체.
  6. ABC: 구현 강제 + 다형성 인터페이스. @abstractmethod로 서브클래스 계약.
  7. __init_subclass__: 메타클래스 없이 서브클래스 후크. 플러그인 레지스트리에 적합.
  8. 메타클래스: 클래스 생성을 가로채는 마지막 수단. 꼭 필요할 때만.

References

  • Python Docs, Data Model (descriptors, metaclasses): https://docs.python.org/3/reference/datamodel.html
  • Python Docs, Descriptor HowTo Guide: https://docs.python.org/3/howto/descriptor.html
  • Python Docs, dataclasses module: https://docs.python.org/3/library/dataclasses.html
  • Python Docs, abc module: https://docs.python.org/3/library/abc.html
  • PEP 487 – Simpler customisation of class creation (__init_subclass__): https://peps.python.org/pep-0487/
  • PEP 557 – Data Classes: https://peps.python.org/pep-0557/
  • Real Python, "Python Descriptors: An Introduction": https://realpython.com/python-descriptors/
  • GeeksforGeeks, "Metaprogramming with Metaclasses in Python": https://www.geeksforgeeks.org/python/metaprogramming-metaclasses-python/
  • machinelearningplus.com, "Understanding Python Descriptors and the __set_name__ Method": https://machinelearningplus.com/python/understanding-python-descriptors-and-the-__set_name__-method/