
파이썬 메타프로그래밍 마스터하기: 원하는 모든 것을 제어하세요
파이썬 메타프로그래밍 탐구
많은 사람들이 "메타프로그래밍(metaprogramming)"이라는 개념에 익숙하지 않고, 또 아주 엄밀한 정의가 있는 것도 아닙니다.
이 글은 파이썬(Python)에서의 메타프로그래밍(metaprogramming)을 중심으로 다루지만, 사실 여기서 논의되는 내용이 엄격한 의미의 "메타프로그래밍(metaprogramming)"에 완전히 부합하지 않을 수도 있습니다.
단지 이 글의 주제를 나타내기에 이보다 더 적절한 용어를 찾지 못해서 이 단어를 빌려왔을 뿐입니다.
부제는 "제어하고 싶은 모든 것을 제어하라" 입니다.
본질적으로 이 글은 한 가지에 초점을 맞춥니다.
바로 파이썬(Python)이 제공하는 기능들을 활용하여 코드를 가능한 한 우아하고 간결하게 만드는 것입니다.
구체적으로는, 프로그래밍 기법을 통해 더 높은 추상화 수준에서 추상화의 특성을 수정하는 것에 관한 이야기입니다.
우선, 파이썬(Python)의 모든 것이 객체(object)라는 것은 잘 알려진 상식입니다.
게다가 파이썬(Python)은 특수 메서드(special method)나 메타클래스(metaclass)와 같은 수많은 "메타프로그래밍(metaprogramming)" 메커니즘(mechanism)을 제공합니다.
객체에 동적으로 속성(attribute)이나 메서드(method)를 추가하는 것 같은 작업은 파이썬(Python)에서는 전혀 "메타프로그래밍(metaprogramming)"으로 간주되지도 않습니다.
하지만 일부 정적 언어(static language)에서는 이를 구현하기 위해 특별한 기술이 필요합니다.
이제 파이썬(Python) 프로그래머들을 쉽게 혼란스럽게 할 수 있는 몇 가지 측면에 대해 논의해 보겠습니다.
객체(object)를 여러 수준으로 분류하는 것부터 시작해 봅시다.
일반적으로 우리는 객체(object)에는 그 타입(type)이 있다는 것을 알고 있으며, 파이썬(Python)은 오래전부터 타입을 객체(object)로 구현해왔습니다.
따라서 우리는 인스턴스 객체(instance object)와 클래스 객체(class object)라는 두 가지 수준을 갖게 됩니다.
기본적인 이해가 있는 독자라면 메타클래스(metaclass)의 존재를 알고 있을 것입니다.
간단히 말해, 메타클래스(metaclass)는 "클래스(class)의 클래스(class)"이며, 이는 클래스(class)보다 더 높은 수준에 있다는 것을 의미합니다.
이것으로 또 다른 수준이 추가됩니다.
더 있을까요.
임포트 시점(ImportTime) vs 실행 시점(RunTime)
만약 우리가 다른 관점에서 보고 이전 세 수준과 동일한 기준을 적용할 필요가 없다면, 두 가지 개념을 구별할 수 있습니다.
바로 임포트 시점(ImportTime)과 실행 시점(RunTime)입니다.
이들 사이의 경계는 명확하지 않습니다.
이름에서 알 수 있듯이, 이들은 두 가지 시점, 즉 모듈을 임포트(import)하는 시점과 코드를 실행하는 시점을 의미합니다.
모듈이 임포트(import)될 때 무슨 일이 일어날까요.
전역 스코프(global scope)에 있는 문장들(정의문이 아닌 문장)이 실행됩니다.
함수 정의는 어떨까요.
함수 객체(function object)가 생성되지만, 그 안의 코드는 실행되지 않습니다.
클래스 정의의 경우, 클래스 객체(class object)가 생성되고 클래스 정의 스코프(scope) 내의 코드는 실행되지만, 클래스 메서드(method) 안의 코드는 당연히 실행되지 않습니다.
실행 중에는 어떨까요.
함수와 메서드(method) 안의 코드가 실행될 것입니다.
물론, 먼저 호출해야 하겠죠.
메타클래스 (Metaclasses)
따라서 우리는 메타클래스(metaclass)와 클래스(class)는 임포트 시점(ImportTime)에 속한다고 말할 수 있습니다.
모듈이 임포트(import)된 후에 이들이 생성되기 때문입니다.
인스턴스 객체(instance object)는 실행 시점(RunTime)에 속합니다.
단순히 모듈을 임포트(import)하는 것만으로는 인스턴스 객체(instance object)가 생성되지 않습니다.
하지만 너무 단정적으로 말할 수는 없습니다.
만약 모듈 스코프(scope) 내에서 클래스(class)를 인스턴스화(instantiate)한다면, 인스턴스 객체(instance object) 역시 생성될 것이기 때문입니다.
단지 우리가 보통 인스턴스화 코드를 함수 내부에 작성하기 때문에 이렇게 분류하는 것뿐입니다.
만약 생성되는 인스턴스 객체(instance object)의 특성을 제어하고 싶다면 어떻게 해야 할까요.
아주 간단합니다.
클래스 정의에서 __init__
메서드(method)를 오버라이드(override)하면 됩니다.
그렇다면 클래스(class)의 어떤 속성들을 제어하고 싶다면 어떨까요.
그런 필요가 있을까요.
물론입니다.
고전적인 싱글턴 패턴(singleton pattern)에 관해서는, 여러 구현 방법이 있다는 것을 모두 알고 있습니다.
요구사항은 클래스(class)가 오직 하나의 인스턴스(instance)만 가질 수 있어야 한다는 것입니다.
가장 간단한 구현은 다음과 같습니다.
class _Spam:
def __init__(self):
print("Spam!!!")
_spam_singleton = None
def Spam():
global _spam_singleton
if _spam_singleton is not None:
# 이미 인스턴스가 있으면 반환
return _spam_singleton
else:
# 인스턴스가 없으면 새로 생성
_spam_singleton = _Spam()
return _spam_singleton
이 팩토리(factory)와 유사한 패턴은 그다지 우아하지 않습니다.
요구사항을 다시 한번 검토해 봅시다.
우리는 클래스(class)가 오직 하나의 인스턴스(instance)만 갖기를 원합니다.
클래스(class)에서 정의하는 메서드(method)들은 인스턴스 객체(instance object)의 행동입니다.
따라서 클래스(class) 자체의 행동을 바꾸고 싶다면, 더 높은 수준의 무언가가 필요합니다.
바로 여기서 메타클래스(metaclass)가 등장합니다.
앞서 언급했듯이, 메타클래스(metaclass)는 클래스(class)의 클래스(class)입니다.
즉, 메타클래스(metaclass)의 __init__
메서드(method)는 클래스(class)의 초기화 메서드(method)입니다.
우리는 인스턴스(instance)를 함수처럼 호출할 수 있게 해주는 __call__
메서드(method)도 있다는 것을 알고 있습니다.
그렇다면 메타클래스(metaclass)의 이 메서드(method)는 클래스(class)가 인스턴스화(instantiate)될 때 호출되는 메서드(method)입니다.
코드는 다음과 같이 작성할 수 있습니다.
class Singleton(type): # type을 상속받는 메타클래스 정의
def __init__(self, *args, **kwargs):
self._instance = None # 인스턴스를 저장할 변수 초기화
super().__init__(*args, **kwargs) # 부모 클래스(type)의 __init__ 호출
def __call__(self, *args, **kwargs):
# 클래스를 호출하여 인스턴스를 생성하려고 할 때 실행됨
if self._instance is None:
# 인스턴스가 없으면 새로 생성
self._instance = super().__call__(*args, **kwargs) # 실제 인스턴스 생성 호출
return self._instance
else:
# 인스턴스가 이미 있으면 기존 인스턴스 반환
return self._instance
class Spam(metaclass = Singleton): # Spam 클래스의 메타클래스를 Singleton으로 지정
def __init__(self):
print("Spam!!!")
일반적인 클래스 정의와 비교하여 두 가지 주요 차이점이 있습니다.
하나는 Singleton
의 베이스 클래스(base class)가 type
이라는 것이고, 다른 하나는 Spam
정의에 metaclass = Singleton
이 있다는 것입니다.type
은 무엇일까요.type
은 object
의 서브클래스(subclass)이며, object
는 type
의 인스턴스(instance)입니다.
즉, type
은 모든 클래스(class)의 클래스(class)이며, 가장 근본적인 메타클래스(metaclass)입니다.type
은 모든 클래스(class)가 생성될 때 필요한 몇 가지 연산을 규정합니다.
따라서 우리가 만드는 사용자 정의 메타클래스(metaclass)는 type
을 서브클래스(subclass)해야 합니다.
동시에 type
역시 객체(object)이므로, object
의 서브클래스(subclass)이기도 합니다.
이해하기 조금 어렵지만, 대략적인 개념만 파악하면 됩니다.
데코레이터 (Decorators)
이제 데코레이터(decorator)에 대해 이야기해 봅시다.
대부분의 사람들은 데코레이터(decorator)를 파이썬(Python)에서 이해하기 가장 어려운 개념 중 하나로 생각합니다.
사실, 이것은 단지 문법적 설탕(syntactic sugar)일 뿐입니다.
함수 역시 객체(object)라는 것을 이해하고 나면, 자신만의 데코레이터(decorator)를 쉽게 작성할 수 있습니다.
from functools import wraps # 원본 함수의 메타데이터를 유지하기 위해 사용
def print_result(func):
# @wraps 데코레이터를 사용하여 wrapper 함수가 원본 func 함수의 속성들을 가지도록 함
@wraps(func)
def wrapper(*args, **kwargs):
# 원본 함수 실행
result = func(*args, **kwargs)
# 결과 출력
print(result)
# 결과 반환
return result
# 내부 wrapper 함수 반환
return wrapper
# @print_result 데코레이터 적용
@print_result
def add(x, y):
return x + y
# 위 데코레이터 사용은 아래 코드와 동일합니다:
# add = print_result(add)
# 데코레이터가 적용된 함수 호출
add(1, 3) # 4를 출력하고 4를 반환
여기서 우리는 @wraps
라는 데코레이터(decorator)도 사용했는데, 이것은 반환되는 내부 함수 wrapper
가 원본 함수와 동일한 함수 시그니처(signature)를 갖도록 만들기 위해 사용됩니다.
기본적으로 데코레이터(decorator)를 작성할 때는 이것을 추가해야 합니다.
주석에 쓴 것처럼, @decorator
형태는 func = decorator(func)
와 동일합니다.
이 점을 이해하면 더 많은 종류의 데코레이터(decorator)를 작성할 수 있습니다.
예를 들어, 클래스 데코레이터(class decorator)나 데코레이터(decorator) 자체를 클래스(class)로 작성하는 것 등입니다.
def attr_upper(cls):
# 클래스의 속성들을 순회
for attrname, value in cls.__dict__.items():
# 값이 문자열인 경우
if isinstance(value, str):
# 내부 속성(__로 시작하는 것)이 아닌 경우
if not value.startswith('__'):
# 속성 값을 대문자로 변환하여 다시 설정
setattr(cls, attrname, bytes.decode(str.encode(value).upper()))
# 수정된 클래스 객체 반환
return cls
# 클래스 데코레이터 적용
@attr_upper
class Person:
sex ='man'
# 변환된 속성 값 확인
print(Person.sex) # 출력: MAN
일반 함수 데코레이터(decorator)와 클래스 데코레이터(decorator)의 구현 차이점에 주목하세요.
데이터 추상화 - 디스크립터 (Descriptors)
만약 어떤 클래스(class)들이 특정 공통 특성을 가지거나 클래스 정의 내에서 이를 제어할 수 있기를 원한다면, 사용자 정의 메타클래스(metaclass)를 만들고 이 클래스(class)들의 메타클래스(metaclass)로 지정할 수 있습니다.
만약 어떤 함수들이 특정 공통 기능을 가지고 코드 중복을 피하고 싶다면, 데코레이터(decorator)를 정의할 수 있습니다.
그렇다면 인스턴스(instance)의 속성(attribute)들이 어떤 공통 특성을 갖기를 원한다면 어떨까요.
어떤 사람들은 property
를 사용할 수 있다고 말할 것이고, 실제로 가능합니다.
하지만 이 로직은 각 클래스 정의마다 작성되어야 합니다.
만약 여러 클래스(class)들의 인스턴스(instance) 속성(attribute)들이 동일한 특성을 갖기를 원한다면, 사용자 정의 디스크립터 클래스(descriptor class)를 만들 수 있습니다.
디스크립터(descriptor)에 관해서는, 이 문서 https://docs.python.org/3/howto/descriptor.html 에서 매우 잘 설명하고 있습니다.
동시에, 이 문서는 함수와 메서드(method)의 통합과 차이를 달성하기 위해 디스크립터(descriptor)가 함수 뒤에 어떻게 숨겨져 있는지도 상세히 설명합니다.
몇 가지 예를 들어보겠습니다.
class TypedField:
# 디스크립터 클래스 정의
def __init__(self, _type):
# 속성의 기대 타입을 저장
self._type = _type
def __get__(self, instance, cls):
# 속성 값을 가져올 때 호출됨
if instance is None:
# 클래스를 통해 접근할 경우 (예: Person.age), 디스크립터 객체 자체 반환
return self
else:
# 인스턴스를 통해 접근할 경우, 인스턴스의 __dict__에서 실제 값 반환
# self.name은 __set_name__에서 설정됨
return getattr(instance, self.name)
def __set_name__(self, cls, name):
# 디스크립터가 클래스 속성으로 할당될 때 호출됨 (Python 3.6+)
# 디스크립터가 어떤 이름으로 할당되었는지 저장
self.name = name
def __set__(self, instance, value):
# 속성 값을 설정할 때 호출됨
# 전달된 값(value)이 기대하는 타입(_type)인지 확인
if not isinstance(value, self._type):
raise TypeError('Expected ' + str(self._type))
# 타입이 맞으면 인스턴스의 __dict__에 값을 저장
instance.__dict__[self.name] = value
class Person:
# 디스크립터 인스턴스를 클래스 속성으로 할당
age = TypedField(int)
name = TypedField(str)
def __init__(self, age, name):
# 인스턴스 생성 시 디스크립터의 __set__이 호출되어 타입 검사 수행
self.age = age
self.name = name
jack = Person(15, 'Jack')
jack.age = '15' # 여기서 TypeError 발생: Expected <class 'int'>
여기에는 몇 가지 역할이 있습니다.TypedField
는 디스크립터 클래스(descriptor class)이고, Person
의 속성(attribute)들은 디스크립터 클래스(descriptor class)의 인스턴스(instance)입니다.
디스크립터(descriptor)는 Person
의 속성(attribute), 즉 인스턴스 속성(instance attribute)이 아닌 클래스 속성(class attribute)으로 존재하는 것처럼 보입니다.
하지만 실제로는 Person
의 인스턴스(instance)가 같은 이름의 속성(attribute)에 접근하면 디스크립터(descriptor)가 작동합니다.
파이썬(Python) 3.5 및 이전 버전에는 __set_name__
특수 메서드(special method)가 없다는 점에 유의해야 합니다.
이는 만약 디스크립터(descriptor)가 클래스 정의에서 어떤 이름으로 주어졌는지 알고 싶다면, 디스크립터(descriptor)를 인스턴스화(instantiate)할 때 명시적으로 전달해야 한다는 것을 의미합니다.
즉, 파라미터(parameter)가 하나 더 필요하다는 뜻입니다.
하지만 파이썬(Python) 3.6에서는 이 문제가 해결되었습니다.
디스크립터 클래스 정의에서 __set_name__
메서드(method)를 오버라이드(override)하기만 하면 됩니다.
또한 __get__
의 작성 방식에도 주목하세요.
기본적으로 instance
에 대한 판단은 필수적입니다.
그렇지 않으면 오류가 발생합니다.
이유는 이해하기 어렵지 않으므로 자세히 설명하지 않겠습니다.
서브클래스 생성 제어 - 메타클래스의 대안
파이썬(Python) 3.6에서는 __init_subclass__
특수 메서드(special method)를 구현하여 서브클래스(subclass)의 생성을 사용자 정의할 수 있습니다.
이 방식을 사용하면 어떤 경우에는 다소 번거로운 메타클래스(metaclass) 사용을 피할 수 있습니다.
class PluginBase:
subclasses = [] # 모든 서브클래스를 저장할 리스트
def __init_subclass__(cls, **kwargs):
# 이 클래스를 상속하는 서브클래스가 정의될 때마다 호출됨
super().__init_subclass__(**kwargs) # 부모의 __init_subclass__ 호출 (필요한 경우)
# 생성된 서브클래스(cls)를 리스트에 추가
cls.subclasses.append(cls)
# PluginBase를 상속하면 자동으로 subclasses 리스트에 추가됨
class Plugin1(PluginBase):
pass
class Plugin2(PluginBase):
pass
# 등록된 플러그인(서브클래스) 확인
print(PluginBase.subclasses) # 출력: [<class '__main__.Plugin1'>, <class '__main__.Plugin2'>]
요약
메타클래스(metaclass)와 같은 메타프로그래밍(metaprogramming) 기술들은 대부분의 사람들에게 다소 모호하고 이해하기 어렵습니다.
그리고 대부분의 경우 우리는 이것들을 사용할 필요가 없습니다.
하지만 대부분의 프레임워크(framework) 구현은 사용자가 작성하는 코드가 간결하고 이해하기 쉽도록 만들기 위해 이러한 기술들을 활용합니다.
만약 이러한 기술들에 대해 더 깊이 이해하고 싶다면, "Fluent Python"이나 "Python Cookbook"과 같은 책들을 참고하거나(이 글의 일부 내용도 여기서 참조했습니다), 위에서 언급한 디스크립터(descriptor) How-To나 데이터 모델(Data Model) 섹션 등 공식 문서의 일부 장들을 읽어볼 수 있습니다.
또는 파이썬(Python)으로 작성된 소스 코드나 CPython 소스 코드를 직접 살펴보는 것도 방법입니다.
'Python' 카테고리의 다른 글
SQLAlchemy 2.0 전격 해부: 왜 파이썬(Python) 최강 ORM으로 불릴까요? (0) | 2025.05.20 |
---|---|
파이썬(Python) 최강의 기술, 데코레이터(Decorator) 완전 정복 (0) | 2025.05.17 |
블룸 필터(Bloom Filter) 완벽 해부: 원리, 장단점, 파이썬(Python) 코드까지! (0) | 2025.05.06 |
ASGI 깊이 알기: 파이썬 비동기 웹 앱 통신 규약 파헤치기! (FastAPI, Uvicorn 연관성 포함) (1) | 2025.05.06 |
파이썬 리스트 정렬의 숨겨진 비밀: 팀소트(Timsort)는 왜 빠를까요? (0) | 2025.05.05 |