메타클래스에 관해

파이썬의 메타클래스에 대해 살펴보는 짧은 글

메타클래스에 관해
Photo by Sergio Aguirre / Unsplash

메타클래스는 일반적으로 '클래스를 만드는 클래스'로 풀이됩니다. 파이썬에서도 이 말은 분명하게 사실이지만, 그럼에도 메타클래스가 무엇인가하는 질문에는 ("클래스를 만드는 클래스요..."라고 말하면 됩니다만) 선뜻 답을하기가 어렵습니다.

그러면 먼저 파이썬에서 클래스를 만드는 방법에 대해서 알아보겠습니다. class 구문을 사용하면 되죠.

class MyClass(object):
  pass

# 혹은, 파이썬3에서는 object를 상속받지 않아도 됩니다

class MyClass:
  pass


a = MyClass()
# a -> __main__.MyClass object at 0x.....

흔히 클래스는 '객체를 만드는 붕어빵 틀'이나 '청사진', '설계도' 같은 것에 비유되곤 합니다. 그러나 객체지향언어에서 클래스는 그 이상입니다. (굳이 파이썬이 아니더라도 많은 객체지향언어에서는) 클래스는 그 자체로도 객체입니다. 클래스는 객체이면서 다른 객체를 만들어내는데 사용되며, 다음과 같은 일반적인 객체의 특성을 그대로 가지고 있습니다.

  • 변수에 바인딩할 수 있고,
  • 속성을 추가할 수 있으며
  • 함수의 인자로 넘겨지거나, 함수의 리턴값이 될 수 있습니다.

클래스도 객체이므로 함수의 인자나 리턴값이 될 수 있는데, 바로 기본 함수 중에서 type()이 클래스를 리턴하는 함수입니다. 대 부분의 파이썬 입문자들도 알고 있을 이 함수는 특정한 객체 하나를 인자로 전달하면 해당 객체의 클래스명(타입명)을 문자열의 형식으로 리턴하는 것으로 잘 알려진 익숙한 함수입니다.

💡
참고로, 파이썬3에서 object를 상속하지 않은 클래스는 모두 타입으로 간주됩니다.

그런데, 이 외에도 아래와 같은 용법으로 사용하면 새로운 타입을 정의하는데 사용할 수 있습니다.

MyClass = type("MyClass", (), {})

a = MyClass()
print(a)
# __main__.MyClass object at 0x###....

이 용법은 내부적으로 클래스 정의 구문과 완전히 동일한 일을 수행합니다. 굳이 차이를 따진다면 class 구문은 소스코드에서 정적으로 타입을 정의하는 것이고 type() 함수를 사용하면 실행 시간에 동적으로 타입을 정의할 수 있다는 것 정도입니다.

타입을 생성할 때의 type() 함수의 각 인자는 다음과 같습니다.

  1. class_name <str> : 첫번째 인자는 생성할 타입의 이름입니다.
  2. bases <tuple[type, ...]> : 상속할 타입들의 튜플입니다.
  3. attrs <dict[str, any]> : 해당 타입이 가져야 하는 속성들을 사전으로 정의합니다.

특정한 타입에서 상속하는 상위 클래스들은 __bases__ 라는 속성으로 기록되며, 클래스의 속성들은 모두 __dict__ 속성으로 기록됩니다. 즉, type() 함수를 호출하여 새로운 타입을 정의하는 것은 class 구문을 사용하여 새로운 타입을 정의하는 것과 큰 차이가 없습니다.

파이썬의 메타클래스

type() 함수가 클래스를 리턴한다고 해서, 그 자체가 메타클래스라는 말은 아닙니다. 그렇다면 파이썬에서 메타클래스는 어떤 역할을 하는 걸까요? 파이썬의 소스코드가 인터프리터에 의해 해석될 때에는 코드의 각 라인이 그 때 실행되고 해석됩니다. class 구문 역시 예외가 아니며, class 구문 블럭이 '실행'되면서 새로운 타입이 정의가 됩니다.

파이썬의 객체는 속성들을 담고 있는 꾸러미입니다. 우리가 어떤 커스텀 타입을 정의해서 사용하는 경우를 생각해봅시다. 일반적으로 정의하지 않더라도 미리 정의되어 있는 속성들이 있습니다. __class__ 속성이 이러한 속성 중 하나입니다. 이 속성은 해당 객체의 클래스에 대한 참조를 알려주는데, 이러한 '기본속성'은 누가 제공하는 것일까요? 그리고 클래스 역시 객체라고 했습니다. 그렇다면 이 '클래스의 클래스'는 누구일까요? 그 클래스를 만든 클래스, 타입의 타입은 누구일까요? 이건 REPL 상에서도 확인이 가능합니다. 바로 'type'이라는 타입(클래스)입니다. 즉 파이썬에서는 이 type이 클래스를 만드는 기본 클래스이자, 암묵적인 메타클래스입니다.

a = MyClass
print(a.__class__)
# <class '__main__.MyClass'>
print(a.__class__.__class__)
# <class 'type'>

만약 어떤 클래스의 속성들이 어떤 새로운 규칙을 따르도록 강제되어야 한다면 그러한 규칙을 클래스를 만들 때 주입할 수 있어야 합니다. 그런 경우가 바로 새로운 메타클래스를 사용해야 하는 때입니다. 그러면 새로운 메타클래스는 어떻게 만들 수 있을까요? 객체 지향 프로그래밍에서 널리 알려진 기본적인 패턴을 사용합니다. 바로 기본 메타클래스인 type을 상속받는 새로운 타입을 정의하면 됩니다.

type을 상속받아 메타클래스를 구현해보기 전에, 실제로 클래스를 생성하는 과정에서 메타클래스가 어떻게 작용하는지를 알아두어야 합니다. (그렇지 않으면 메타클래스를 작성하는 방법을 알게 되더라도, 이게 어떻게 "마법처럼" 동작하는지는 이해할 수 없게 됩니다.)

예시

우리가 어떤 특별한 메타클래스를 가지고 있고, 이 메타클래스를 통해서 새로운 클래스를 생성하려 한다면, class NewClass(metaclass=MyMetaClass)와 같이 상속관계를 표현하던 위치에 metaclass=라는 키워드 인자 형식을 통해서 메타클래스를 강제로 지정할 수 있습니다.

사실 마법의 비밀은 간단합니다, 이 구문에서 metaclass=로 지정한 메타클래스가 따로 없다면 type을 사용하여 클래스를 생성하고, 지정된 메타클래스가 있다면 '그 객체를 호출'하여 클래스를 생성합니다.

일단 메타클래스를 정의하지 않고, 메타클래스를 사용하여 클래스를 만드는 예를 한 번 보겠습니다. 함수 UpperAttr()은 type()과 비슷하게 새로운 클래스를 만들어내는 함수입니다. 다만 이 함수는 속성 데이터에서 속성 이름을 모두 대문자로 변경하여 type()함수에 전달해 모든 속성명이 대문자로 구성되도록 합니다.

아래와 같이 UpperAttr()을 메타클래스로 사용한다고 클래스 정의 구문을 작성합니다. 우리는 bar라는 속성을 명시했지만, 메타클래스 객체인 UpperAttr()에 이 구문에서 정의한 모든 속성과 메소드가 사전의 형식으로 전달되기 때문에 실제로 만들어지는 클래스에서는 인자 이름이 대문자로 바뀌어 있음을 볼 수 있습니다.

from typing import Type, Any

def UpperAttr(clsname: str, bases: tuple[Type,...], attrs: dict[str, Any]) -> Type:
    x : dict[str, Any] = {}
    for name, value in attrs.items():
      if name.startswith('__'):
        x[name] = value
      else:
        x[name.upper()] = value
    return type(clsname, bases, x)

    
class Foo(metaclass=UpperAttr):
    bar = "bip"

f = Foo()
print(f.BAR)
print(dir(f))

이제 UpperAttr을 함수가 아닌 클래스로, 그러니까 실제 메타클래스로는 어떻게 정의하는지 살펴보겠습니다.

위의 UpperAttr을 클래스를 만드는 함수가 아니라 클래스로 정의해보겠습니다. 클래스를 만드는 type() 함수는 실제로는 type 클래스의 생성자입니다. 따라서 메타클래스를 작성할 때에는 type을 서브클래싱합니다.

__new__()는 클래스의 실질적인 생성자로, 새로운 인스턴스를 만들 때 사용되는 메소드입니다. 메타클래스를 통해서 새로운 인스턴스(메타 클래스의 인스턴스는 새로운 클래스이므로)를 생성할 때에도 이 생성자가 호출됩니다. 따라서 메타클래스를 정의할 때에는 기본적으로 이 생성자 메소드를 작성해야 합니다.

생성자의 정의는 기본적으로 앞서 작성한 함수와 동일합니다. 다만 첫번째 인자로 클래스가 자신이 끼워진다는 부분만 차이가 있습니다.

class UpperAttr(type):
  def __new__(cls, clsname, bases, attrs):
    x = {}
    for name, value in attrs.items():
      if name.startswith('__'):
        x[name] = value
      else:
        x[name.upper()] = value
    return super().__new__(cls, clsname, bases, x)

class Foo(metaclass=UpperAttr)

파이썬의 객체 생성 시나리오에서는 생성자가 호출된 직후, 그 결과값인 새로운 인스턴스가 초기화 메소드인 __init__()으로 전달됩니다. 따라서 생성자는 반드시 인스턴스 객체(여기서는 클래스 객체)를 리턴해야 합니다.

메타클래스의 필요성

class 구문을 사용하면서 metaclass= 인자에 꼭 클래스가 아닌 '클래스를 생성할 수 있는 객체'를 사용해도 된다면, 그래서 UpperAttr() 같은 함수를 사용해도 무방하다면 왜 굳이 클래스 형식으로 된 메타클래스를 사용해야 할까요?

클래스를 리턴하는 함수가 아닌 메타클래스를 직접 작성하는 것이 더 나을 수 있는 몇 가지 경우가 있겠지만, 가장 큰 장점은 클래스를 생성할 때 세세한 부분까지 제어하는 것이 가능하다는 것입니다.

일반적으로 메타클래스를 작성할 때에는 __new__()를 재정의한다고 했지만, 싱글톤을 메타클래스로 구현하는 경우를 생각해보겠습니다. 특정한 클래스가 싱글톤 패턴을 따르기 위해서는, 해당 클래스의 생성자를 호출하는 시점에 특정한 로직이 들어가야 합니다. 이 때는 해당 클래스의 입장에서는 __new__()가 호출되는 상황이지만, 메타클래스에서는 __call__()이 호출되는 상황입니다.

💡
_ _call__()은 해당 클래스로부터 생성된 객체 인스턴스가 함수처럼 호출되었을 때의 실행할 코드를 의미합니다. 따라서 메타클래스를 통해 생성된 클래스의 생성자 호출은 메타클래스 입장에서는 __call__()에 정의되어야 합니다.

싱글톤 구현

싱글톤 구현을 메타클래스로 하는 예를 살펴보겠습니다. 이 구현은 인터넷에 돌아다니는 흔한 싱글톤 구현 예제입니다. 코드는 짧고 단순하지만, 메타클래스 및 파이썬 클래스에 대한 이해가 없으면 해석하기는 어렵습니다.

class Singleton(type):
  _instances = {}

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

class MyClass(metaclass=Singleton):
  pass

a = MyClass()
b = MyClass()

print(a is b)
# True
  1. 앞서 설명한 바와 같이, 메타클래스를 통해 만들어진 인스턴스가 되는 클래스의 생성자 호출패턴을 위해 __new__() 가 아닌 __call__()을 오버라이드합니다.
  2. _instances 라는 사전은 클래스와 그 클래스의 싱글톤 객체를 캐시하는 사전입니다.
  3. Singleton 메타클래스를 기반으로 생성된 클래스의 생성자가 호출되려는 시점에 싱글톤 캐시에서 만들어진 싱글톤 캐시를 찾습니다. 생성된 싱글톤 객체가 아직 없는 클래스라면, 새 객체를 하나 만들게 됩니다.

클래스로 구현한 메타클래스는 상속 계층에 포함됩니다. Singleton을 사용하여 생성된 MyClass는 싱글톤의 특성을 가지게 되었습니다. 뿐만아니라 MyClass를 상속하는 모든 자식클래스도 싱글톤의 특성을 가지게 됩니다.

테스트

메타클래스와 관련된 몇 가지 테스트를 해 보았습니다. 그 결과 중에서 주목할만한 부분 몇 가지만 정리해봅니다.

  1. 클래스로서의 메타클래스는, 그로부터 만들어진 클래스에 대해 자식 클래스를 작성하면 자동으로 상속됩니다.
  2. A라는 메타클래스를 기반으로 만들어진 F라는 클래스가 있고, 다시 F를 상속하는 클래스를 만들면서, B라는 메타클래스를 지정하는 것은 문법적으로는 가능합니다. 하지만 "같은 이름의 메소드를 다르게 구현한 부모를 동시에 상속"하는 것과 같은 문제가 발생하며, 이는 즉시 'metaclass conflict'라는 런타임 오류가 나게 됩니다.
  3. UpperAttr() 과 같은 함수 기반의 메타클래스를 사용하는 경우, 이 함수로부터 전해받는 특성은 하위 메소드로 상속되지는 않습니다.
  4. 2의 상황에서, 부모 클래스가 메타클래스를 바탕으로 만들어졌을 때, 그 자식은 다시 함수를 metaclass 키워드 인자로 사용해서 정의하는 것은 가능합니다. 이 경우에는 metaclass 충돌 오류는 피할 수 있습니다만, 3과 같이, 다시 그의 자손 클래스들은 함수 형식의 메타클래스는 상속받지 않습니다.

결론

일반적인 파이썬 클래스와 다르게 작동하거나 다른 특성을 가지는 클래스를 만드려고 할 때에 메타클래스를 사용할 필요가 발생합니다. 그러나 보통 이런 경우는 특수한 상황이나 용법을 위한 프레임워크를 작성할 때입니다. 즉, 저를 포함한 대부분의 '여러분'은 메타클래스를 사용할 필요는 없습니다. 아이러니 한 부분은, 메타클래스를 사용할 필요가 있는 사람이라면 이런 글이 필요가 없다는 점이겠죠. 대신에 파이썬에서 클래스가 무엇이고 또 어떤 방식으로 만들어지는지에 대한 과정을 좀 더 자세하게 들여다보는 계기 정도로 생각한다면 이 글도 나름대로는 작게나마 의미를 갖는 글이 되지 않을까 생각해 봅니다.