Python의 metaclasses(메타클래스) 이해하기

이 글은 메타클래스에 대해 가장 잘 설명되어있다고 생각되는 Stackoverflow 답변을 번역한 문서입니다.

클래스를 객체로

메타클래스를 이해하기 전에 Python의 클래스에 대한 완전한 이해가 필요합니다. 또한 Python은 Smalltalk 언어에서 따온 매우 특별한 클래스 구성이 존재합니다.

대부분의 언어에서 클래스는 어떻게 객체를 생성할지에 대해 정의하는 코드조각일 뿐입니다. 물론 Python에서도 그렇습니다:

>>> class ObjectCreator(object):
...       pass
... 

>>> my_object = ObjectCreator()
>>> print(my_object)
<__main__.ObjectCreator object at 0x8974f2c>

하지만 Python에서의 클래스는 그 이상의 존재입니다. 클래스는 객체이기도 합니다.

그렇습니다, 객체입니다.

class 키워드를 사용할 때 Python은 실행하면서 객체를 만들어냅니다. 다음 코드는 메모리에 ObjectCreator이라는 이름을 가진 객체를 만들어냅니다.

>>> class ObjectCreator(object):
...       pass
... 

이 객체(클래스)는 그 자체로 새로운 객체(인스턴스)를 만들 수 있으며, 이것이 왜 이 객체가 클래스인지를 나타냅니다.

하지만 여전히 이 클래스는 객체일 뿐입니다, 또한:

  • 변수에 할당할 수도 있고
  • 복사할 수도 있고
  • 새로운 속성을 추가할 수도 있고
  • 함수의 인자로 넘길 수도 있습니다.

예시:

>>> print(ObjectCreator) # 클래스를 출력할 수 있습니다. 클래스가 객체이기 때문입니다.
<class '__main__.ObjectCreator'>
>>> def echo(o):
...       print(o)
... 
>>> echo(ObjectCreator) # 클래스를 함수의 인자로 넘길 수 있습니다.
<class '__main__.ObjectCreator'>
>>> print(hasattr(ObjectCreator, 'new_attribute'))
False
>>> ObjectCreator.new_attribute = 'foo' # 클래스에 새로운 속성을 추가할 수 있습니다.
>>> print(hasattr(ObjectCreator, 'new_attribute'))
True
>>> print(ObjectCreator.new_attribute)
foo
>>> ObjectCreatorMirror = ObjectCreator # 클래스를 변수에 할당할 수 있습니다.
>>> print(ObjectCreatorMirror.new_attribute)
foo
>>> print(ObjectCreatorMirror())
<__main__.ObjectCreator object at 0x8997b4c>

동적으로 클래스 생성하기

클래스가 객체이기 때문에 객체처럼 그때그때 새로운 클래스를 만들 수 있습니다.

먼저, class 키워드를 이용해 함수에서 클래스를 만들 수 있습니다:

>>> def choose_class(name):
...     if name == 'foo':
...         class Foo(object):
...             pass
...         return Foo # 클래스 반환 (인스턴스 X)
...     else:
...         class Bar(object):
...             pass
...         return Bar
...     
>>> MyClass = choose_class('foo') 
>>> print(MyClass) # 이 함수는 인스턴스가 아닌, 클래스를 반환합니다
<class '__main__.Foo'>
>>> print(MyClass()) # 이 클래스로 새로운 객체를 만들 수 있습니다
<__main__.Foo object at 0x89c6d4c>

하지만 이 방법은 여전히 클래스 전체를 직접 작성해야 하기 때문에 동적이라 보기 어렵습니다.

클래스가 객체기 때문에 클래스는 위와 같은 방법이 아닌 다른 어떤 방법으로 만들어질 수 있습니다.

우리가 class 키워드를 사용할 때, Python은 객체를 자동으로 생성한다 했습니다. 하지만 Python에서의 대부분이 그렇듯, 이 또한 직접 할 수 있는 방법이 있습니다.

type 함수를 기억하시나요? 객체가 어떤 타입인지 알 수 있게 해주는 좋으면서 오래된 함수입니다:

>>> print(type(1))
<type 'int'>
>>> print(type("1"))
<type 'str'>
>>> print(type(ObjectCreator))
<type 'type'>
>>> print(type(ObjectCreator()))
<class '__main__.ObjectCreator'>

하지만 type은 완전히 다른 기능 또한 갖고 있습니다, type 함수는 그때그때 클래스를 만들 때에도 쓰일 수 있습니다. type은 인자로 클래스의 정의를 받아 클래스를 반환합니다.

(같은 함수가 인자에 따라 완전히 다른 용도로 사용되는 것은 매우 나쁘다고 알고 있습니다. 여기에는 Python 하위 호환성 문제가 얽혀있습니다.)

type은 다음과 같이 쓰입니다:

type(name of the class, 
     tuple of the parent class (for inheritance, can be empty), 
     dictionary containing attributes names and values)

예시:

>>> class MyShinyClass(object):
...       pass

는 다음과 같이 직접 생성될 수 있습니다:

>>> MyShinyClass = type('MyShinyClass', (), {}) # 클래스 객체 반환
>>> print(MyShinyClass)
<class '__main__.MyShinyClass'>
>>> print(MyShinyClass()) # 클래스 인스턴스 생성
<__main__.MyShinyClass object at 0x8997cec>

여기서 우리는 인자로 넘긴 "MyShinyClass"가 클래스의 이름으로 사용되었고 이를 할당한 변수는 클래스의 레퍼런스를 갖고있는 것을 확인할 수 있습니다.

type은 클래스의 속성을 정의하기 위해 dict를 인자로 받습니다, 예로:

>>> class Foo(object):
...       bar = True

는 다음과 같이 쓸 수 있으며:

>>> Foo = type('Foo', (), {'bar':True})

일반적인 클래스로 사용할 수 있습니다:

>>> print(Foo)
<class '__main__.Foo'>
>>> print(Foo.bar)
True
>>> f = Foo()
>>> print(f)
<__main__.Foo object at 0x8a9b84c>
>>> print(f.bar)
True

그리고 물론 상속도 받을 수 있습니다, 예로:

>>>   class FooChild(Foo):
...         pass

는 다음 코드가 됩니다:

>>> FooChild = type('FooChild', (Foo,), {})
>>> print(FooChild)
<class '__main__.FooChild'>
>>> print(FooChild.bar) # bar is inherited from Foo
True

클래스에 메소드를 추가하고 싶을 때에는 적절한 모양으로 함수를 만들고 인자로 넘기기만 하면 됩니다.

>>> def echo_bar(self):
...       print(self.bar)
... 
>>> FooChild = type('FooChild', (Foo,), {'echo_bar': echo_bar})
>>> hasattr(Foo, 'echo_bar')
False
>>> hasattr(FooChild, 'echo_bar')
True
>>> my_foo = FooChild()
>>> my_foo.echo_bar()
True

물론 이렇게 동적으로 클래스를 만들고 나서 일반 클래스와 동일하게 새로운 메소드나 속성을 추가할 수도 있습니다.

>>> def echo_bar_more(self):
...       print('yet another method')
... 
>>> FooChild.echo_bar_more = echo_bar_more
>>> hasattr(FooChild, 'echo_bar_more')
True

이제 우리가 가고있던 방향을 한 번 짚어봅시다: Python에서 클래스는 객체이고 그때그때 동적으로 생성할 수 있습니다.

이것이 class 키워드를 사용할 때 Python이 어떻게 작동하는지에 대한 전부입니다, 그리고 이 방법은 metaclass를 사용할 때에도 동일하게 작동합니다.

최종: metaclasses는 무엇인가

메타클래스(Metaclasses)는 클래스를 만드는 '무언가'입니다.

우리는 객체를 만들기 위해 클래스를 정의합니다, 그렇죠? 하지만 우리는 Python에서 클래스는 곧 객체라는 것을 알았습니다.

메타클래스는 이러한 객체를 만드는 '무언가'입니다. 메타클래스는 '클래스'의 '클래스'이며, 다음과 같은 방법으로 묘사할 수 있습니다:

MyClass = MetaClass()
MyObject = MyClass()

우리는 위에서 type으로 다음과 같이 정의할 수 있다는 사실을 보았습니다:

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

왜냐하면 type 함수는 실제로 메타클래스이기 때문입니다. type은 Python이 실제로 보이는 코드 뒤에서 클래스를 생성하는 메타클래스입니다.

이제 우리는 왜 typeType이 아닌 소문자로 쓰여졌는지 궁금해할 차례입니다. 제가 추측하기로는 문자열 객체를 생성하는 클래스인 str, 정수 객체를 생성하는 int 클래스와 같이 type은 클래스 객체를 생성하는 클래스이기 때문에 일관성(consistency) 문제로 소문자로 쓰여졌다고 생각합니다.

우리는 이것을 __class__ 속성을 통해 확인해볼 수 있습니다.

모든 것, Python에서의 모든 것은 객체입니다. 여기에는 정수, 문자열, 함수, 클래스를 포함합니다. 이 모든 것들은 객체입니다. 그리고 이 모든 것들은 클래스로부터 생성됩니다:

>>> age = 35
>>> age.__class__
<type 'int'>
>>> name = 'bob'
>>> name.__class__
<type 'str'>
>>> def foo(): pass
>>> foo.__class__
<type 'function'>
>>> class Bar(object): pass
>>> b = Bar()
>>> b.__class__
<class '__main__.Bar'>

자, 아무 __class____class__는 무엇일까요?

>>> age.__class__.__class__
<type 'type'>
>>> name.__class__.__class__
<type 'type'>
>>> foo.__class__.__class__
<type 'type'>
>>> b.__class__.__class__
<type 'type'>

즉, 메타클래스는 그저 클래스 객체를 만드는 '무언가'라고 할 수 있습니다. 우리는 원한다면 '클래스 팩토리'라고 말할 수도 있겠죠.

type은 Python이 사용하는 내장된 메타클래스입니다, 하지만 물론 우리는 우리가 원하는 메타클래스를 만들 수도 있습니다.

__metaclass__ 속성

우리는 클래스 코드를 작성할 때 __metaclass__ 속성을 직접 추가할 수 있습니다.

class Foo(object):
  __metaclass__ = something...
  [...]

이렇게 하면 Python은 Foo 클래스를 생성하기 위해 직접 설정된 메타클래스를 사용하게 됩니다. 다만 약간의 주의가 필요합니다.

우리는 class Foo(object) 코드를 먼저 작성했습니다, 하지만 Foo 클래스 객체는 메모리상에 아직 생성되지 않은 상태입니다.

Python은 클래스 정의에 __metaclass__가 있는지 먼저 확인하게 될거고, 발견된 경우에 Foo 클래스를 만들기 위해 해당 메타클래스를 사용합니다. 발견하지 못한 경우에는 클래스를 만들기 위해 type을 사용하게 됩니다.

이해가 되지 않는다면 몇 번 더 읽어보세요.

만약 우리가 이렇게 쓴다면:

class Foo(Bar):
    pass

Python은 다음과 같이 작동합니다:

  • Foo__metaclass__ 속성이 있나요?
    • 있으면, __metaclass__에 있는 걸로 Foo 클래스 객체(클래스 객체라고 말했습니다.)를 만듭니다.
    • Python이 __metaclass__를 찾지 못했으면, Python은 __metaclass__모듈 레벨에서 찾고 위와 같은 방법으로 작동합니다. (단 상속받지 않은 클래스에 한해서만 그렇습니다, old-style classes 말이죠 - py2.)
    • 그래도 __metaclass__를 찾지 못했으면, Python은 Bar(가장 첫번째 부모 클래스)가 가진 메타클래스(여기서는 기본 메타클래스인 type)를 사용해서 클래스 객체를 만듭니다.

여기서 조심해야 할 부분은 __metaclass__ 속성은 상속되지 않는다는 점입니다, 부모(Bar.__class__)의 메타클래스도 말이죠. 만약 Bar가 (type.__new__()가 아닌) type()을 이용해 Bar를 만든 __metaclass__ 속성을 사용했다면, 서브클래스는 해당 행동을 상속받지 않습니다.

자 이제 큰 질문을 하나 해봅시다, __metaclass__에 무엇을 넣을 수 있을까요?

답은: 클래스를 만드는 '무언가'입니다.

그러면 무엇이 클래스를 만들 수 있을까요? type 또는 서브클래스나 type을 사용하는 모든 것이 해당됩니다.

커스텀 메타클래스

메타클래스의 주 목적은 클래스가 만들어질 때 클래스를 자동으로 바꾸기 위한 것입니다. 우리는 보통 현재 컨텍스트와 알맞는 클래스를 만들기 위해 API와 같은 곳에 커스텀 메타클래스를 사용합니다.

멍청한 예를 하나 그려봅시다, 모듈 내에 있는 모든 클래스의 속성이 대문자로 쓰여져야만 한다고 결정을 내렸습니다. 이것을 실현하는 방법은 많이 있지만, 그 중 하나는 __metaclass__를 모듈 레벨에서 지정하는 것입니다.

이렇게 해서 이 모듈의 모든 클래스가 직접 만든 커스텀 메타클래스를 사용하게 될거고, 그러면 우리는 그냥 모든 속성을 대문자로 바꾸는 메타클래스를 작성하기만 하면 됩니다.

다행히도 __metaclass__는 형식에 얽메인 클래스일 필요 없이 그저 호출할 수 있는 형태(callable)이기만 하면 됩니다.

함수를 이용해 간단히 예를 작성해봅시다:

# 메타클래스는 우리가 보통 `type`에 전달하는 객체와 같은 객체를 받습니다.
def upper_attr(future_class_name, future_class_parents, future_class_attr):
  """
    대문자로 변환된 속성의 리스트와 함께 클래스 객체를 반환합니다.
  """

  # '__'로 시작하지 않는 모든 객체를 가져와 대문자로 변환합니다.
  uppercase_attr = {}
  for name, val in future_class_attr.items():
      if not name.startswith('__'):
          uppercase_attr[name.upper()] = val
      else:
          uppercase_attr[name] = val

  # `type`으로 클래스를 생성합니다.
  return type(future_class_name, future_class_parents, uppercase_attr)

__metaclass__ = upper_attr # 이리하여 모듈 내에 있는 모든 클래스가 영향을 받게 됩니다.

class Foo(): # 하지만 글로벌 메타클래스는 object와 함께 작동하지 않습니다
  # 하지만 우리는 이 클래스에만 영향을 주고자 여기에 __metaclss__를 정의하면
  # object 자식(children)과 함께 작동하게 됩니다.
  bar = 'bip'

print(hasattr(Foo, 'bar'))
# Out: False
print(hasattr(Foo, 'BAR'))
# Out: True

f = Foo()
print(f.BAR)
# Out: 'bip'

이제 완전히 같은 코드를 작성하는데, 실제 클래스로 메타클래스를 작성해봅시다:

# `type`이 실제로는 `str`, `int`와 같은 클래스라는 것을 기억해야 합니다.
# 따라서 우리는 `type`을 상속받을 수 있습니다.
class UpperAttrMetaclass(type): 
    # __new__는 __init__가 호출되기 전에 먼저 호출되는 메소드입니다.
    # 이 메소드는 실제 객체를 만들고 반환합니다.
    # __init__가 넘겨받은 인자로 객체를 초기화하는데 반해
    # 우리는 여기서 __new__를 사용합니다.
    # 여기서 생성되는 객체는 클래스이고 우리는 해당 클래스를 커스텀하기 위해 __new__를
    # 오버라이드해서 사용합니다.
    # 원하는 추가적인 행동에 대해서는 __init__에서 할 수 있습니다.
    # 몇몇 더 복잡한 사용 예에서는 __call__도 오버라이드해서 사용합니다
    # 하지만 이 예에서는 하지 않습니다.
    def __new__(upperattr_metaclass, future_class_name, 
                future_class_parents, future_class_attr):

        uppercase_attr = {}
        for name, val in future_class_attr.items():
            if not name.startswith('__'):
                uppercase_attr[name.upper()] = val
            else:
                uppercase_attr[name] = val

        return type(future_class_name, future_class_parents, uppercase_attr)

하지만 위 코드는 완전한 OOP 코드가 아닙니다. 우리는 type을 직접 호출했고 부모의 __new__ 코드를 오버라이드하거나 호출하지도 않았습니다. 이렇게 바꿔봅시다:

class UpperAttrMetaclass(type): 

    def __new__(upperattr_metaclass, future_class_name, 
                future_class_parents, future_class_attr):

        uppercase_attr = {}
        for name, val in future_class_attr.items():
            if not name.startswith('__'):
                uppercase_attr[name.upper()] = val
            else:
                uppercase_attr[name] = val

        # type.__new__ 메소드를 재사용합니다.
        # 이 예는 간단한 OOP 사용 예입니다, 흑마법은 없습니다.
        return type.__new__(upperattr_metaclass, future_class_name, 
                            future_class_parents, uppercase_attr)

우리는 바뀐 코드에서 추가된 upperattr_metaclass 인자를 확인할 수 있습니다. 하지만 이 인자에 딱히 특별한 건 없습니다: __new__는 항상 첫번째 인자로 정의된 클래스를 받습니다. 우리가 Python에서 클래스를 정의하고 클래스 메소드를 정의할 때 인스턴스를 받기 위해 첫번째 인자로 넣는 self와 같이 말이죠.

물론 이 예에 쓴 첫 인자의 이름은 명확히 말해 너무 깁니다, 하지만 self와 같이 모든 인자는 의미있는 이름을 갖습니다. 따라서 실제로 쓰일 메타클래스는 다음과 같이 생겼을겁니다:

class UpperAttrMetaclass(type): 

    def __new__(cls, clsname, bases, dct):

        uppercase_attr = {}
        for name, val in dct.items():
            if not name.startswith('__'):
                uppercase_attr[name.upper()] = val
            else:
                uppercase_attr[name] = val

        return type.__new__(cls, clsname, bases, uppercase_attr)

우리는 위 코드를 간편하게 상속을 처리해주는 super를 이용해 더 깔끔하게 만들 수 있습니다:

class UpperAttrMetaclass(type): 

    def __new__(cls, clsname, bases, dct):

        uppercase_attr = {}
        for name, val in dct.items():
            if not name.startswith('__'):
                uppercase_attr[name.upper()] = val
            else:
                uppercase_attr[name] = val

        return super(UpperAttrMetaclass, cls).__new__(cls, clsname, bases, uppercase_attr)

이게 전부입니다. 메타클래스에 더 이상 엄청난 것은 없습니다.

메타클래스를 사용하는 코드 복잡성의 원인은 메타클래스를 사용하기 때문이 아닙니다, 그것은 우리가 보통 메타클래스를 introspection, manipulating, inheritance, __dict__와 같은 변수 등을 사용하여 복잡한 무언가를 만드는 데 사용하기 때문입니다.

확실히, 메타클래스는 흑마법을 부리는데 매우 특별한 존재입니다, 그렇기 때문에 복잡합니다. 하지만 메타클래스를 이용하면 다음 일들이 쉬워집니다:

  • 클래스 생성 가로채기(intercept)
  • 클래스 수정하기(modify)
  • 수정된 클래스 반환하기

왜 우리는 함수 대신 메타클래스를 사용할까요?

__metaclass__가 호출될 수 있는 그 어떤 형태도 받아들이는데, 왜 우리는 확실히 더 복잡한 클래스를 사용할까요?

여기에는 몇가지 이유가 존재합니다:

  • 목적이 분명하게 보입니다. 우리가 UpperAttrMetaclass(type)를 읽을 때, 우리는 이게 무엇을 하는지 확실히 알 수 있습니다.
  • OOP를 사용합니다. 메타클래스는 메타클래스로부터 상속받아 부모 메소드를 오버라이드할 수 있습니다. 메타클래스는 심지어 다른 메타클래스를 사용할 수도 있습니다.
  • 코드 구조가 더 간결해집니다. 우리는 메타클래스를 위 예에서 봤듯 사소한 것을 하는데 사용하지 않습니다. 메타클래스는 보통 복잡한 무언가를 하기 위해 사용됩니다. 몇몇 메소드를 만들고 그 메소드를 한 그룹으로 묶는 것은 코드를 더 쉽게 읽을 수 있게 해줍니다.
  • __new__, __init__, __call__에 대한 후킹(hook)을 할 수 있습니다. 따라서 다른 일을 할 수 있게 해줍니다. 모든 것을 __new__ 안에서 한다고 해도 몇몇 사람들은 __init__를 사용하는데 더 편하게 느낄 것입니다.
  • 이것들은 메타클래스(metaclasses)라고 불립니다. 이름 자체가 무언가를 의미하네요. :)

왜 메타클래스를 쓸까요?

가장 큰 질문이겠습니다. 왜 우리는 잘 알려지지도 않은, 오류도 나기 쉬운 기능을 사용할까요?

음, 아마 사용할 필요가 없을겁니다:

메타클래스는 99%의 사용자는 전혀 고려할 필요가 없는 흑마법입니다. 만약 당신이 이게 정말로 필요할지에 대한 의문을 갖는다면, 필요하지 않습니다. (이게 진짜로 필요한 사람은 이게 진짜로 필요하다고 알고 있으면서, 왜 필요한지에 대해 설명할 필요가 없는 사람들입니다.)

Python Guru Tim Peters

메타클래스의 가장 큰 사용 예는 API 개발입니다. 가장 전형적인 예로 Django ORM이 있습니다.

Django ORM은 다음과 같이 정의할 수 있게 해줍니다:

class Person(models.Model):
  name = models.CharField(max_length=30)
  age = models.IntegerField()

하지만 당신이 이렇게 해도:

guy = Person(name='bob', age='35')
print(guy.age)

위 코드는 IntegerField 객체를 반환하지 않습니다. 대신 int를 반환합니다, 심지어 데이터베이스로부터 직접 값을 가져올 수도 있죠.

이것이 가능한 이유는 models.Model에는 __metaclass__가 정의되어있고, 해당 메타클래스는 name, age 등과 함께 간단하게 정의한 Person 클래스를 통해 데이터베이스 필드에 접근할 수 있게 복잡한 후킹(hook)을 해줍니다.

Django는 이런 복잡한 것들을 메타클래스를 통해 쉽게 사용할 수 있게 간단한 API로 제공합니다.

정리

먼저, 우리는 클래스가 인스턴스를 생성하는 객체라는 것을 알았습니다. 실제로 클래스 그 자체는 메타클래스의 인스턴스입니다.

>>> class Foo(object): pass
>>> id(Foo)
142630324

Python에서 모든 것은 객체이고, 모든 객체는 클래스의 인스턴스이거나 메타클래스의 인스턴스입니다. type을 제외하고 말이죠.

type은 실제로 그 자체의 메타클래스(its own metaclass)입니다. 이것은 순수(pure) Python에서는 구현할 수 없는 부분이고, 구현 레벨(implementation level)에서 약간의 흑마법(cheating)을 통해 만들어진 부분입니다.

두번째로, 메타클래스는 복잡합니다. 매우 간단한 클래스의 변형을 위해 메타클래스를 쓸 이유는 전혀 없습니다. 대신 우리는 다른 두가지 기술을 통해 클래스를 변형할 수 있습니다:

클래스 변형이 필요한 99%의 순간에 대부분 위 두 가지 방법을 사용하는 편이 훨씬 낫습니다. 하지만 코딩하는 시간의 99%는 클래스 변형이 아예 필요하지 않습니다.