콘텐츠로 건너뛰기
Home » 메타 클래스와 추상 클래스

메타 클래스와 추상 클래스

사실 ‘객체 지향’이라는 단어는 프로그래밍 관련 커뮤니티나 여러 글에서 어렵지 않게 접하게 되지만, 객체 지향 프로그래밍에서 가장 중요하다고 하는 ‘클래스’의 개념을 명확하게 이해하기는 쉽지 않습니다. 애초에 추상적인 개념이니 명확하게 이해하는 것이 이상한 거라고 봐야 할까요? 그런데 파이썬이나 다른 객체 지향 언어를 공부하다보면 ‘메타 클래스’니, ‘추상 클래스’니 하는 용어들이 눈에 띄곤 합니다. 아니, 클래스도 뭔지 감이 잘 안오는데 추상 클래스는 뭐고 또 메타 클래스는 뭐란 말일까요?

일단 파이썬에서 출발해보죠. 클래스에 대해서 간단히 짚고 넘어가겠습니다. 우선 클래스가 무엇인지를 이해하기 위해서는 클래스를 설명하기위해 만들어진 여러 관용구들을 잊어버려야 합니다. ‘객체를 만들기 위한 청사진’, ‘객체의 거푸집’ 등등… 뭐 틀린 말들은 아니기는 합니다. 대신에 최소한 파이썬에서 클래스는 타입과 같은 것이라고 알고 있으면 됩니다. 파이썬의 기본 자료형에는 정수(int)나 실수(float), 문자열, 리스트, 튜플, 사전… 같은 것들이 존재합니다. 예를 들어 1, 2, 3 과 같은 정수값은 하나 하나가 모두 객체이고, 그 타입은 int 입니다. 이 정수값들의 타입이 모두 int 라는 유형으로 동일하기 때문에, 각각의 정수값을 가지고 할 수 있는 일은 똑같습니다. 산술 연산을 이용해서 그 값을 더하나 빼거나 곱하거나 나눌 수 있죠. float 타입은 어떻습니까? int 와 비슷하게 산술 연산을 위한 타입이지만, “부동소수점 소수”라는 별도의 타입을 표현하고 있고, 산술 연산에 있어서 어느 정도 오차가 존재하는 값을 표현하는 타입이라는 것이라는 특징이 있습니다. 또 문자열은 낱개의 글자나 여러 개의 글자로 된 텍스트를 표현하는 str 이라는 타입입니다.파이썬에서 클래스라는 것은 어떤 객체들의 타입을 말하는 것입니다. 일반적인 OOP에서 클래스와 타입은 서로 다른 것이라고 구분하기도 하지만, 파이썬 3에서는 클래스는 곧 타입입니다.

실제로 특정한 객체의 타입을 알아내기 위한 내장 함수로 type() 이 있습니다. type(obj) 라고 하면 ‘obj’라는 객체의 타입을 알아 낼 수 있습니다. 그리고 특정한 객체의 클래스에 대한 정보는 obj.__class__ 라는 숨겨진 속성으로 알 수 있는데, 파이썬3에서 이 두 값은 항상 같습니다.

메타 클래스

type() 함수는 type(anObj) 의 형태로 사용되며 이는 인자로 주어진 객체의 타입이 무엇인지를 알려주는 함수라고 했습니다. 그런데 이 함수는 약간 다르게 사용하는 방법이 있습니다. type(name: str, bases: tuple[type], dict) 의 형식으로 호출하여 ‘새로운 타입’을 만들 수 있습니다.

foo = type('foo', (int, ), {'var': 1})
# foo는 int 타입을 기반으로 한 새로운 타입
# var = 1 이라는 속성을 공통적으로 부여

f = foo(42)
# i = int(42) 와 비슷...

print(f)
# => 42

print(f.bar)
# => 1

print(foo.bar)
# => 1

# foo는 int의 확장 타입. 즉 int를 상속한 타입이므로
g = foo(21)
print(f + g) 
# 63

물론 실제로 type() 함수를 이 방식으로 적극적으로 사용하는 경우는 거의 없습니다. 하지만 type() 함수의 이러한 특징은 우리가 새로운 타입을 실행 시간에 동적으로 생성하는 것이 가능하다는 것을 알려줍니다. 클래스는 곧 타입이면서, 어떤 객체의 특징을 정의하고 있는 근간이 되는 개념입니다. 따라서 어떤 객체를 생성할 때에는 그 베이스가 되는 타입으로부터 여러 가지 속성을 물려받게 됩니다. 따라서 클래스의 기본적인 역할은 객체(인스턴스)를 만드는 용도라고 할 수 있습니다.

그러면 “클래스”는 어떻게 만들어질까요? 객체 인스턴스를 생성할 때 클래스로부터 객체를 만드는 것처럼, type() 함수를 사용하여 동적으로 클래스를 만들든, 소스코드에 class: 구문을 사용하여 새로운 클래스를 정의하든 클래스를 만들 때에도 근간이 되는 무엇인가가 필요할 것입니다. 기본적으로 파이썬의 모든 타입들은 type 이라는 타입으로부터 만들어집니다. type 그 자체는 다른 타입들의 타입이면서, 그 스스로가 자신의 타입이기도 한, 가장 원초적인 타입이라 할 수 있습니다.

메타 클래스는 이 type 이라는 타입 혹은 클래스를 직접 상속하여 정의하는 클래스로, 그 클래스로부터 직접 인스턴스를 만들기 보다는 다른 클래스를 만드는 용도로 사용하는 클래스를 말합니다. 파이썬에서는 메타 클래스를 특별히 다른 키워드로 지칭하지는 않습니다. 사실 어떤 클래스나 메타 클래스가 될 수 있습니다.

일반적인 클래스에서는 새로운 객체 인스턴스를 생성하고 나면, __init__() 을 호출하여 객체의 내부 상태, 즉 속성을 초기화하는 과정을 거칩니다. 따라서 어떤 커스텀 클래스를 만들었다면 그 초기화 과정은 직접 컨트롤할 수 있습니다. 하지만 객체가 생성되는 과정은 파이썬의 내부 매커니즘을 그대로 사용하며, 이 부분을 직접 컨트롤하는 경우는 거의 없습니다. 메타 클래스는 이 과정을 컨트롤하는 하나의 방법으로 사용될 수 있습니다.

def say_hello(self):
  print("hello, world!")

class MyMeta(type):
    def __new__(cls, name, bases, attrs):
        attrs['say_hello'] = say_hello
        return super().__new__(cls, name, (int, *bases), attrs)

위 예제에서 MyMetatype 클래스를 상속하였고, __new__ 메소드를 오버라이드하여 say_hello 라는 메소드를 외부 함수 say_hello() 를 호출하도록 했습니다. 이제 MyMeta를 기반으로 생성한 모든 클래스들은 자동으로 say_hello 라는 메소드를 가지게 됩니다.

Foo = MyMeta('Foo', (,) , {})
f = Foo(1)
print(f)
# 1

f.say_hello()
# hello, world!

이 때의 MyMeta는 클래스를 만드는 클래스로 메타 클래스가 됩니다. 위 예제에서는 동적으로 타입 Foo를 생성했지만, 다음과 같이 이미 작성한 메타클래스를 기반으로 클래스를 작성하는 것도 가능합니다.

class Foo(metaclass=MyMeta):
    pass

위에서는 메타 클래스를 작성할 때 __new__() 메소드를 오버라이드 했습니다. 실제로 f = Foo(1) 와 같이 새로운 인스턴스를 만드는 코드가 실행된다면, Foo.__call__() 메소드가 먼저 호출됩니다. 따라서 이 시점에 유효성 검사와 같은 동작을 삽입할 수 있습니다. 다음은 생성 코드 호출 시에 모든 인자가 정수인지를 확인하는 동작을 메타 클래스를 사용해서 구현하는 방법입니다. 이렇게 메타 클래스를 사용하면 특정한 기능을 공통적으로 구현해야 하는 여러 클래스를 작성할 때, 해당 공통 기능을 메타 클래스에서만 구현하여

class MetaAllInts(type):
    def __call__(cls, *args, **kwds):
        if not all(isinstance(a, int) for a in args):
            raise ValueError("All arguments should be integers.")
        return super().__call__(cls, *args, **kwds)

class Bar(metaclass=MetaAllInts):
    def __init__(self, a, b):
        self.a = a
        self.b = b

b = Bar(1, 2)
c = Bar("1", 2)
# -> ValueError: All arguments should be integers.

여러 클래스에서 공통적으로 사용하는 기능을 구현하는 방법으로는 메타 클래스외에 상속을 사용하는 방법도 있습니다. 하지만 상속을 사용하는 경우에는 클래스의 계층 구조가 고정되고, 계층 구조가 쉽게 복잡해집니다. 이를테면 위와 같이 모든 인자가 정수여야 하는 기능을 공통적으로 필요로 한다고 해서, 모두가 어떤 클래스의 계층 구조에 있어야 하는 상황이 아닐 수도 있습니다. 이런 경우 메타 클래스를 사용하면 클래스 계층 구조와 무관하게 공통된 기능을 사용할 수 있고, 재사용 가능한 기능을 더욱 유연하게 적용할 수 있다는 장점이 있습니다.


추상 클래스

추상 클래스는 특정한 속성의 이름은 정의하고 있지만 세부 구현을 제공하지 않는 클래스를 말합니다. 파이썬에서 클래스를 작성할 때 def some_method(self): 와 같이 메소드 이름을 선언하고 그 내부에 pass 만 적어주면 실제로는 아무런 일을 하지 않는 메소드를 작성할 수 있습니다. 이 클래스를 상속받는 하위 클래스에서는 some_method()를 오버라이드하여 구현할 수 있지만, 이것은 오버라이드이므로 그 구현을 제공해야 하는 것이 강제되지 않습니다. 하지만 추상 클래스는 인터페이스만 선언하고 하위 클래스가 이를 반드시 구현하도록 강제하는 것이 가능하다는 차이가 있습니다. 따라서 특정한 추상 클래스는 파이썬 내에서 특정한 인터페이스를 반드시 구현하고 있다는 것을 보장하므로, 인터페이스에 대한 프로토콜로 작동하는 효과를 얻을 수 있습니다.

예를 들어 애플리케이션에서 사용하는 데이터모델의 타입을 확정하지 않았거나, 특정한 타입으로 고정하지 않으려는 경우에 추상 클래스를 사용하면 안정성과 일관성을 높일 수 있습니다.

파이썬에서 추상 클래스는 메타 클래스와 달리 “acb” (Abstract Base Class)라는 모듈을 사용하여 사용합니다.

  1. 추상 클래스로 생성하려는 클래스는 abc.ABC 라는 클래스를 상속 받아서 만듭니다.
  2. 혹은 abc.abcMeta 를 메타클래스로 사용하여 추상 클래스를 만들 수 있습니다.
  3. @abc.abstractclass 데코레이터를 사용하여 클래스를 작성합니다.

추상 클래스 내에서 선언되는 메소드 중에서 하위 클래스가 반드시 구현해야 하는 메소드들을 ‘추상메소드(abstract method)’라고 합니다. 추상 메소드는 그 선언 부분에 @abc.abstractmethod 데코레이터를 사용해줍니다. 아래 예제는 레코드의 삽입, 변경, 삭제, 조회 등의 기본적인 기능을 추상 메소드로 선언하는 추상 메소드를 작성하는 코드입니다.

import abc

class DataModel(metaclass=abc.ABCMeta):
    @abc.abstractmethod
    @property
    def number_of_items(self):
        pass

    @abc.abstractmethod
    def item_with_key(self, key):
        pass

    @abc.abstractmethod
    def add_new_item(self, info):
        pass

    @abc.abstractmethod
    def remove_item(self, key):
        pass

    @abc.abstractmethod
    def update_item(self, key, info):
        pass

이 클래스를 상속받은 하위 클래스를 구현하려 할 때에는 모든 추상 클래스를 구현해야 하므로, 데이터모델을 사용하려는 코드에서는 데이터모델의 실제 타입(클래스)에 상관없이 DataModel 타입임을 가정하고 작성하면 됩니다.

파이썬에서 추상 클래스와 메타 클래스를 전혀 다른 개념을 말하는 것이지만, 실제 구현에서 이 두 개념은 밀접하게 관련되어 있습니다. 추상 클래스를 구현하는 방법은 여러 가지가 있지만, 내부적으로는 abc.ABCMeta 를 메타클래스로 사용하여 추상 클래스를 생성하도록 되어 있습니다.