모던 파이썬 고급 #3 메타클래스 — 언제 정말 필요한가
#2 디스크립터에서 속성을 객체화하는 도구를 봤다면, 이번 글은 클래스 자체를 객체로 다루는 도구인 메타클래스입니다. “고급 파이썬"에서 자주 거론되지만, 사실 거의 쓰지 않는 게 정답인 도구입니다. 무엇이 메타클래스인지, 그리고 더 가벼운 대안들로 어디까지 풀 수 있는지를 정리합니다.
출발점 — 클래스도 객체 #
파이썬에서는 클래스도 객체입니다.
class User:
pass
print(type(User)) # <class 'type'>
print(User.__class__) # <class 'type'>User 클래스의 **타입은 type**입니다. 즉 User는 type의 인스턴스입니다. 모든 클래스는 어떤 타입(메타클래스)의 인스턴스로 만들어집니다. 기본 메타클래스가 type입니다.
type으로 클래스 동적으로 만들기
#
def greet(self):
print(f"hi, {self.name}")
# 동등: class User: ...
User = type("User", (object,), {
"name": "default",
"greet": greet,
})
u = User()
u.greet() # hi, defaulttype(name, bases, namespace)가 클래스를 동적으로 만드는 함수입니다. 사실 class User: 문법이 내부적으로 이것을 호출합니다.
이것은 드물게 쓰는 기능이지만, 메타클래스의 정체를 보여줍니다. 메타클래스란 결국 type의 서브클래스입니다.
메타클래스 정의 #
class Meta(type):
def __new__(mcs, name, bases, namespace):
print(f"클래스 만드는 중: {name}")
return super().__new__(mcs, name, bases, namespace)
class User(metaclass=Meta):
pass
# 출력: 클래스 만드는 중: Usermetaclass=Meta로 어떤 메타클래스로 만들지 지정합니다. 클래스가 정의되는 그 순간 Meta.__new__가 호출됩니다.
__init_subclass__ — 메타클래스가 필요 없을 때
#
#1에서 짧게 본 __init_subclass__가 거의 모든 메타클래스 일을 풀어줍니다. 메타클래스 없이.
class Plugin:
registry = []
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Plugin.registry.append(cls)
class JsonPlugin(Plugin): pass
class CsvPlugin(Plugin): pass
print(Plugin.registry)
# [<class 'JsonPlugin'>, <class 'CsvPlugin'>]같은 일을 메타클래스로 하면:
class PluginMeta(type):
registry = []
def __init__(cls, name, bases, namespace):
super().__init__(name, bases, namespace)
if name != "Plugin":
PluginMeta.registry.append(cls)
class Plugin(metaclass=PluginMeta): pass
class JsonPlugin(Plugin): pass
class CsvPlugin(Plugin): pass길고, 베이스 클래스 자체를 등록 안 하기 위한 if name != "Plugin" 같은 어색함이 있습니다. 일반적인 자동 등록은 __init_subclass__가 정답입니다.
클래스 데코레이터 — 또 다른 대안 #
중급 #5의 클래스 데코레이터도 메타클래스가 할 일의 상당 부분을 합니다.
def register(cls):
Plugin.registry.append(cls)
return cls
class Plugin:
registry = []
@register
class JsonPlugin(Plugin): pass
@register
class CsvPlugin(Plugin): pass명시적이라는 장점이 있습니다. 암묵적인 등록보다 명시적인 등록이 좋을 때가 많습니다.
그럼 메타클래스는 언제? #
다음 경우에는 메타클래스가 가장 어울립니다.
1) type 자체의 동작을 바꾸고 싶을 때
#
isinstance, issubclass 같은 검사 동작을 메타클래스가 결정합니다. ABC (abc.ABCMeta)가 그 예입니다.
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def save(self, data): ...
class FileStorage(Storage):
def save(self, data): ...
class BadStorage(Storage):
pass
s = FileStorage() # OK
b = BadStorage() # ✗ TypeError: Can't instantiate abstract class@abstractmethod가 있는 클래스를 인스턴스화 못 하게 막는 동작이 메타클래스 레벨에서 일어납니다. 이런 경우는 __init_subclass__로 풀기 어렵거나 불가능합니다.
2) DSL — 클래스 정의 자체를 새 문법처럼 #
class User(Model):
id = IntegerField(primary_key=True)
name = StringField(max_length=50)이런 코드에서 메타클래스가 하는 일:
- 어떤 필드들이 정의됐는지 수집
- 테이블 이름 결정
_meta같은 메타데이터 객체 생성- 검증 메소드 자동 추가
Django ORM, SQLAlchemy, Peewee 등이 이 패턴을 씁니다. 클래스 정의를 일종의 DSL로 쓸 때 메타클래스가 필요합니다.
3) 다중 상속에서 일관된 동작 보장 #
여러 베이스 클래스가 각자 다른 메타클래스를 가지면 충돌이 납니다. 메타클래스 한 단계 위에서 모든 동작을 통제해야 할 때 필요합니다.
이것은 일반 코드에서는 거의 없습니다. 라이브러리 작성자에게 해당하는 영역입니다.
메타클래스 vs 다른 도구 — 결정 가이드 #
| 일 | 첫 시도 |
|---|---|
| 서브클래스를 자동 등록 | __init_subclass__ |
| 한 클래스에 동작 추가 (eq, repr 등) | 클래스 데코레이터 (@dataclass 등) |
| 속성 접근 가로채기 | 디스크립터 |
| 인스턴스 메소드/속성 추가 | 일반 메소드/속성 |
isinstance/issubclass 동작 변경 | 메타클래스 |
| 베이스 클래스 정의 자체를 DSL처럼 | 메타클래스 |
| 클래스 정의 시점에 문법 검증 | 메타클래스 또는 __init_subclass__ |
판단 기준: __init_subclass__ 또는 클래스 데코레이터로 풀린다면 그쪽이 거의 항상 더 가벼운 정답입니다. 메타클래스는 그것들이 풀 수 없을 때 씁니다.
__init_subclass__의 실력
#
이 후크가 얼마나 많은 일을 할 수 있는지 보면, 메타클래스가 줄어드는 이유가 보입니다.
자동 등록 #
class Handler:
handlers = {}
def __init_subclass__(cls, *, route=None, **kwargs):
super().__init_subclass__(**kwargs)
if route:
Handler.handlers[route] = cls
class HomeHandler(Handler, route="/"): pass
class AboutHandler(Handler, route="/about"): pass
print(Handler.handlers)
# {'/': <class 'HomeHandler'>, '/about': <class 'AboutHandler'>}서브클래스 정의부에 route="/" 같은 인자를 넘길 수 있다는 것도 잘 알려지지 않은 사실입니다. __init_subclass__가 그 인자를 받습니다.
필수 메소드 강제 #
class Service:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
for method in ("start", "stop"):
if not hasattr(cls, method):
raise TypeError(f"{cls.__name__} 는 {method} 가 필요")
class Good(Service):
def start(self): ...
def stop(self): ...
class Bad(Service): # ✗ TypeError
def start(self): ...abc.ABC보다 가벼운 강제 방식으로, 검증 로직을 직접 정의할 수 있습니다.
클래스 변수 검증 #
class Form:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not hasattr(cls, "name"):
raise TypeError(f"{cls.__name__}.name 필요")
if not isinstance(cls.name, str):
raise TypeError("name은 str")
class LoginForm(Form):
name = "login"__class_getitem__ — 제네릭 문법 후크
#
MyClass[int] 같은 표기를 가능하게 하는 후크입니다.
class Box:
def __class_getitem__(cls, item):
return f"Box[{item.__name__}]"
print(Box[int]) # Box[int]list[int], dict[str, int] 같은 빌트인 제네릭이 이 후크 위에 만들어졌습니다. 사용자 클래스를 제네릭으로 만들 때(Python 3.12의 class Foo[T]: 새 문법 이전 방식) 이 후크가 일을 했습니다.
type.__call__ — 인스턴스 생성 흐름
#
User()가 실행되면:
type(User).__call__(User)가 호출됨 (메타클래스의__call__)- 그 안에서
User.__new__(User)로 인스턴스 생성 User.__init__(instance)로 초기화
이 흐름을 가로채려면 메타클래스의 __call__을 오버라이드합니다. 싱글턴, 인스턴스 캐싱 같은 경우에 등장합니다.
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class Logger(metaclass=SingletonMeta):
def __init__(self):
self.history = []
a = Logger()
b = Logger()
print(a is b) # True__new__만으로 가능한 일이지만, __init__을 두 번 호출하는 함정이 있어 메타클래스 쪽이 더 안전한 경우가 있습니다.
메타클래스 충돌 #
서브클래스의 메타클래스는 모든 부모의 메타클래스의 서브타입이어야 합니다.
class MetaA(type): pass
class MetaB(type): pass
class A(metaclass=MetaA): pass
class B(metaclass=MetaB): pass
class C(A, B): pass
# TypeError: metaclass conflict여러 라이브러리의 클래스를 다중 상속할 때 만나는 골칫거리입니다. 그래서 라이브러리 작성자도 메타클래스를 신중하게 씁니다.
정리 #
이번 글에서 잡은 것:
- 클래스도 객체, 클래스의 타입은
type(기본 메타클래스) type(name, bases, namespace)로 클래스 동적 생성- 메타클래스 =
type의 서브클래스,class X(metaclass=Meta):로 지정 __init_subclass__(3.6+)가 일반적인 메타클래스 일의 90% 이상을 풀어줌- 클래스 데코레이터도 강력한 대안
- 메타클래스는 ABC, ORM DSL, 다중 상속 일관성 같은 좁은 영역에서만
__init_subclass__는 인자도 받음 —class Sub(Base, route="/x"):__class_getitem__으로Cls[T]표기 후크- 메타클래스는 충돌이 잦은 도구라 가능한 한 안 쓰는 쪽이 좋음
다음 글(#4 비동기 깊이)에서는 중급 #7에서 본 asyncio의 다음 단계 — 이벤트 루프의 동작, gather/wait의 미세한 차이, async generator, 컨커런시 패턴들을 다룹니다.