모던 파이썬 고급 #3 메타클래스 — 언제 정말 필요한가

6 분 소요

#2 디스크립터에서 속성을 객체화하는 도구를 봤다면, 이번 글은 클래스 자체를 객체로 다루는 도구인 메타클래스입니다. “고급 파이썬"에서 자주 거론되지만, 사실 거의 쓰지 않는 게 정답인 도구입니다. 무엇이 메타클래스인지, 그리고 더 가벼운 대안들로 어디까지 풀 수 있는지를 정리합니다.

출발점 — 클래스도 객체 #

파이썬에서는 클래스도 객체입니다.

클래스 = 객체
class User:
    pass

print(type(User))   # <class 'type'>
print(User.__class__)   # <class 'type'>

User 클래스의 **타입은 type**입니다. 즉 Usertype의 인스턴스입니다. 모든 클래스는 어떤 타입(메타클래스)의 인스턴스로 만들어집니다. 기본 메타클래스가 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, default

type(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

# 출력: 클래스 만드는 중: User

metaclass=Meta로 어떤 메타클래스로 만들지 지정합니다. 클래스가 정의되는 그 순간 Meta.__new__가 호출됩니다.

__init_subclass__ — 메타클래스가 필요 없을 때 #

#1에서 짧게 본 __init_subclass__거의 모든 메타클래스 일을 풀어줍니다. 메타클래스 없이.

자동 등록 — __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)가 그 예입니다.

ABC = 메타클래스
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 — 클래스 정의 자체를 새 문법처럼 #

ORM 모델 — 사용자 관점
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_getitem__
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()가 실행되면:

  1. type(User).__call__(User)가 호출됨 (메타클래스의 __call__)
  2. 그 안에서 User.__new__(User)로 인스턴스 생성
  3. 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, 컨커런시 패턴들을 다룹니다.

X