클래스와 메타프로그래밍
매직 메서드(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)에서 도입되었다. 이 전에는 메타클래스나 수동으로 이름을 넘겨야 했다.
속성 조회 우선순위
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 # ValueErrorproperty는 fget, 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는 클래스 네임스페이스에 속하는 유틸리티 함수 — self도 cls도 없다.
__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 모델 등록, 추상 인터페이스 준수 검증에 쓴다.
언제 무엇을 쓸 것인가
(DSL, ORM 등)
메타클래스는 강력하지만 스택 트레이스를 복잡하게 만들고 다른 메타클래스와 충돌할 수 있다. 먼저 __init_subclass__, 클래스 데코레이터, @dataclass 등으로 해결되는지 확인하고 마지막 수단으로 쓴다.
정리
- 매직 메서드는 Python 연산자·프로토콜과 클래스를 연결하는 공식 계약이다.
- 디스크립터 프로토콜: 속성 접근 우선순위 — 데이터 디스크립터 > 인스턴스
__dict__> 비데이터 디스크립터.property는 데이터 디스크립터다. __set_name__(3.6+): 디스크립터가 자신의 이름을 알 수 있는 훅. 메타클래스 없이도 동작.__slots__: 수백만 경량 객체를 만들 때 메모리를 아낀다. 상속 시 주의.@dataclass:__init__,__repr__,__eq__자동 생성.frozen=True로 불변 객체.ABC: 구현 강제 + 다형성 인터페이스.@abstractmethod로 서브클래스 계약.__init_subclass__: 메타클래스 없이 서브클래스 후크. 플러그인 레지스트리에 적합.- 메타클래스: 클래스 생성을 가로채는 마지막 수단. 꼭 필요할 때만.
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,
dataclassesmodule: https://docs.python.org/3/library/dataclasses.html - Python Docs,
abcmodule: 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/