LLM WikiAccess-protected knowledge portal

WIKI

클래스와 메타프로그래밍

매직 메서드 Dunder Python 클래스의 계약 init , repr 같은 더블언더 double underscore 메서드는 Python이 연산자, 내장 함수, 프로토콜을 클래스와 연결하는 공식 인터페이스다. 이름처럼 "마법"이 아니라 인터프리터가 호출하는 명시적 계약 이다. 자주 쓰는 매직 메서드를 목적별로 정리하면 다음과 같다. 목적 메서드 초기화·표현 init , repr , str , format 산술 연산 add

경로human/study/content/python/04-classes-metaprogramming.md
카테고리Study
태그#classes #content #metaprogramming #python #study

매직 메서드(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__ 중 하나 이상을 구현한 객체다. 클래스 변수로 등록되면 해당 속성 접근을 가로챈다.

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