본문 바로가기
Programming Language/Python

파이썬 메타클래스 쉽고 깊게 이해하기, Python Metaclass A to Z

by 알파해커 2020. 3. 16.
반응형

Prerequisite

파이썬 메타클래스를 이해하기전에 먼저 확실하게 구분하고 가야하는 것이 있는데, "객체와 인스턴스의 차이"이다.

우리가 객체 지향 언어를 배울 때, 대개 클래스라는 '틀'부터 객체라는 '실체'가 만들어진 것이라고 배운다.

이때 그 '실체'를 우리는 객체라고도 하고, 인스턴스라고도 하는데, 사실 정확히 따지면 약간의 어감상 차이가 있다.

그리고 그 약간의 차이를 정확하게 인지하고 있어야, 파이썬의 메타클래스를 이해하는데 더욱 도움이 된다.

 

 

객체와 인스턴스

클래스로 만든 객체를 인스턴스라고도 한다. 그렇다면 객체와 인스턴스의 차이는 무엇일까?

이렇게 생각해 보자. a = Cookie() 이렇게 만든 a는 객체이다. 그리고 a 객체는 Cookie의 인스턴스이다.

즉, 인스턴스라는 말은 특정 객체(a)가 어떤 클래스(Cookie)의 객체인지를 관계 위주로 설명할 때 사용한다.

 

"a는 인스턴스"보다는 "a는 객체"라는 표현이 어울리며,

"a는 Cookie의 객체"보다는 "a는 Cookie의 인스턴스"라는 표현이 어울린다.

 

 

파이썬은 모든 것이 객체이다

그렇다 파이썬은 모든 것이 객체다. 그래서 클래스도 객체다. 예를 들어서, class TestClass: pass 이렇게 적으면. 얘도 객체다. 그럼 이 클래스라는 객체를 만들기 위한 클래스가 있어야 할텐데. 그게 메타클래스다. 원래 메타클래스를 우리가 특별히 재정의 해주지 않으면, 디폴트로 클래스를 만드는 클래스(클래스의 클래스, 메타클래스)는 정해져있다. 그런데 우리가 특정 클래스를 만들때, 특별한 규칙을 만들고 싶으면 메타클래스를 재정의 해서(커스텀 메타클래스), 활용할 수 있을 것이다. 즉, 클래스의 동작을 내 뜻대로 제어하고 싶을 때, 메타클래스를 쓴다.

 

 

그럼 메타클래스는 어떻게 만드는가?

"type"이라는 키워드를 활용하면 된다. 사실, type은 두 가지 기능이 있다.

(1) 자료형 종류를 알아낼 때

(2) 클래스를 만들 때

 

(1)의 기능은 대부분의 개발자들이 주로 사용하여 익숙할 것이다. type(a) 이런식으로 써서, a라는 변수의 자료형을 확인할때 쓴다. 익숙하지 않은 부분은 (2)인데, 동일한 type이라는 키워드를 이용해서, 클래스를 만들어 쓴다는 것이다. 이 때, type을 이용해서 클래스를 만드는 방법에도 두 가지가 있다.

 

방법1

type 안에 [클래스 이름(string)], [상속할 부모 클래스(tuple)], [속성/메서드(dict)]를 지정해서 만든다. 그러니까 아래와 같은 방식이다.

  • 클래스 = type('클래스이름', 기반클래스튜플, 속성메서드딕셔너리)

예를 들어, 파이썬 콘솔에서, Hello라는 클래스를 생성하고, 인스턴스까지 만들어서 확인해보자.

>>> Hello = type('Hello', (), {}) # type으로 클래스 Hello 생성

>>> Hello
<class '__main__.Hello'>

>>> h = Hello() # 클래스 Hello로 인스턴스 h 생성
>>> h
<__main__.Hello object at 0x029B4750>

 

방법2

type을 상속받아서 메타클래스를 구현하는 방식

class MakeCalc(type):                                    # type을 상속받음
	def __new__(metacls, name, bases, namespace):        # 새 클래스를 만들 때 호출되는 메서드
		namespace['desc'] = '계산 클래스'                   # 새 클래스에 속성 추가
		namespace['add'] = lambda self, a, b: a + b      # 새 클래스에 메서드 추가
		return type.__new__(metacls, name, bases, namespace)  # type의 __new__ 호출
Calc = MakeCalc('Calc', (), {})    # 메타클래스 MakeCalc로 클래스 Calc 생성
c = Calc()                         # 클래스 Calc로 인스턴스 c 생성
print(c.desc)                      # '계산 클래스': 인스턴스 c의 속성 출력
print(c.add(1, 2))                 # 3: 인스턴스 c의 메서드 호출

 

 

메타클래스의 활용

앞서 말했듯이, 메타클래스는 우리가 특정 클래스의 동작을 제어하고 싶을 때 사용한다. 예를 들어, 싱글톤 기능을 하는 메타클래스를 만든다고 해보자. 그러니까, 싱글톤이라는 메타클래스를 이용해서 만든 클래스는 인스턴스가 오직 하나만 만들어지도록 제어하고 싶은 것이다.

class Singleton(type):    # 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 Hello(metaclass=Singleton):    # 메타클래스로 Singleton을 지정
    pass

a = Hello()     # 클래스 Hello로 인스턴스 a 생성
b = Hello()     # 클래스 Hello로 인스턴스 b 생성
print(a is b)   # True: 인스턴스 a와 b는 같음

 

그런데, 눈에 띄는 것이 하나 있다. __call__ 메서드는 인스턴스를 생성할때 실행되는 메서드 아니던가?

예를 들어, a=A() 로 인스턴스를 만들고, a()로 만들어진 인스턴스를 실행할때 A의 __call__ 메서드가 실행되는 것인데,

위의 Singleton 메타클래스를 보면, 특별히 인스턴스를 실행한 것처럼 보이는 것도 없는데,

__call__ 메서드에 의해 동작이 이루어졌다. 어떻게 된 일 일까?

 

 

인스턴스 생성 내부동작

1. __new__, __init__, __call__

어떤 클래스의 인스턴스가 생성되는 과정을 이해하기 위해서는 __new__, __init__, __call__ 에 대해서 알아야한다.

__new__ : 클래스 인스턴스를 생성 (메모리 할당)

__init__ : 생성된 인스턴스 초기화

__call__ : 인스턴스 실행

 

흔히들, __init__ 이 생성자라고 생각하는데, 아니다. 생성은 __new__에서 한다. __init__에서는 생성된 인스턴스를 초기화를 하는 것이다. 대부분의 경우, 인스턴스의 생성 그 자체에는 관여하지 않기 때문에 해당 메스드를 재사용하지 않는다. 반면에, 개발자가 구현하고자하는 내용에 따라 초기화해줘야하는 변수들은 다양하므로 __init__ 메소드는 재사용하는 경우가 많다. 때문에, __init__이 생성자라고 착각하는 경우가 많은데, 사실은 그렇지 않은 것이다.

 

그러니까, 굳이 순서를 따지자면, __new__ -> __init__ -> __call__ 순이 되는 것이다.

 

2. 메타클래스의 __new__, __init__, __call__

그럼 이제, 아까의 질문으로 돌아가보자. 왜 메타클래스는, (메타클래스를 상속한) 클래스가 생성될 때, __call__ 메소드가 호출될까. 그 이유는 메타클래스의 생성은, (메타클래스를 상속한) 클래스가 생성될 때 이루어지는 것이 아니기 때문이다.

 

아래의 예시를 보고 출력이 어떻게 될지 상상해보자.

class MyMetaClass(type):
	def __new__(cls, *args, **kwargs):
		print('metaclass __new__')
		return super().__new__(cls, *args, **kwargs)

	def __init__(cls, *args, **kwargs):
		print('metaclass __init__')
		super().__init__(*args, **kwargs)

	def __call__(cls, *args, **kwargs):
		print('metaclass __call__')
		return super().__call__(*args, **kwargs)


class MyClass(metaclass=MyMetaClass):
	def __init__(self):
		print('child __init__')

	def __call__(self):
		print('child __call__')


print("=============================================")
obj = MyClass()

 

결과는 아래와 같다. MyClass 클래스의 인스턴스가 생성되기도 전에 MyMetaClass(MyClass의 메타클래스)가 생성되고 초기화까지 됐다는 것을 알 수 있다.

metaclass __new__
metaclass __init__
=============================================
metaclass __call__
child __init__

 

처음에 했던 말을 다시 생각해보자.

"클래스는 객체이다. 그리고 메타클래스는 그 클래스라는 객체를 만들어내는 클래스이다."

 

그렇다. "class MyClass(metaclass=MyMetaClass): ..." 라고 클래스가 로드되는 순간.

MyClass라는 클래스가 만들어진 것이고. 다시말해, MyClass라는 객체가 생성된 것이다.

(MyClass'의' 객체가 생성된게 아니다. 헷갈리지 말자.)

 

객체가 생성되었다는 것은, 클래스의 생성자가 호출되었다는 의미이다.

MyClass 클래스의 클래스는, MyMetaClass이다. 그러니까, MyMetaClass의 생성자가 호출되었다는 것이다.

 

그리고, "class MyClass(metaclass=MyMetaClass): ..."라는 클래스는 이미 MyMetaClass의 인스턴스이다.

이 상태에서, "MyClass()"라고 MyClass라는 클래스의 인스턴스를 생성하려고 시도를 하면.

MyMetaClass의 인스턴스를 실행하는 것이 된다.

 

즉, obj == MyClass() == (MyMetaClass())() 이렇게 된다.

 

3. type의 __call__

type을 상속받아서 만든 메타클래스의 __call__ 메소드가 호출되면

(즉, 메타클래스를 이용해서 만든 클래스의 인스턴스를 만들때 == MyClass()),

 

내부적으로는 type의 __call__이 호출(super.__new__(cls, *args, **kwargs))되고,

그 type의 __call__이 인스턴스의 생성자 __new__를 호출하면서, MyClass 클래스의 인스턴스가 만들어진다. 

 

 

 

레퍼런스

반응형

댓글