파이썬 디스크립터와 메타클래스 - 프레임워크의 기법과 남용 금지 (파이썬 고급 20단원)
이 단원에서 배우는 것
18·19단원에서 logkit 은 워커 수, 타임아웃 예산, 동시 요청 수, 알림 엔드포인트 같은 설정값을 계속 늘려 왔다. 지금은 이 값들이 함수 기본값에 흩어져 있다. 이제 설정을 클래스 하나에 선언형으로 모으고, 환경변수에서 읽어 타입 변환·검증까지 자동으로 하는 구조를 만든다. Django 모델, SQLAlchemy, pydantic 이 쓰는 기법이 정확히 이것이고, 이 단원에서 그것을 열어 본다.
- 디스크립터 프로토콜(
__get__/__set__/__set_name__)로 속성 접근을 가로챈다 - 클래스 생성 자체를 가로채는
__init_subclass__와 메타클래스를 구분해 쓴다 - 이 기법들의 비용(디버깅·타입체커·상속 충돌)을 계산하고 언제 쓰지 말아야 하는지 판단한다
왜 필요한가
설정 클래스를 손으로 쓰면 이렇게 된다.
class Settings:
def __init__(self) -> None:
raw = os.environ.get("LOGKIT_WORKERS", "4")
if not raw.isdigit():
raise ValueError("LOGKIT_WORKERS 는 정수여야 한다")
self.workers = int(raw)
raw = os.environ.get("LOGKIT_LIMIT_MS", "1000")
if not raw.isdigit():
raise ValueError("LOGKIT_LIMIT_MS 는 정수여야 한다")
self.limit_ms = int(raw)
# ... 설정이 12개면 이 블록이 12번 반복된다
같은 모양의 코드가 필드마다 복제된다. 필드를 추가할 때 세 군데(환경변수 이름, 변환, 검증)를 고쳐야 하고, 하나만 빠뜨려도 조용히 문자열이 들어간다. property 로 묶어 봐도 필드마다 게터/세터 두 개씩을 또 써야 하니 나아지지 않는다.
우리가 원하는 것은 "필드 하나 = 한 줄" 이다.
class Settings:
workers = Field(int, default=4)
limit_ms = Field(int, default=1000)
endpoint = Field(str, required=True)
이걸 가능하게 하는 것이 디스크립터다. 9단원에서 쓴 @property 는 사실 디스크립터의 특수한 한 형태이고, 우리가 만드는 것은 재사용 가능한 property 라고 봐도 된다.
문법과 예제
디스크립터 프로토콜
클래스 속성으로 놓인 객체가 __get__, __set__, __delete__ 중 하나라도 갖고 있으면 파이썬은 그 속성에 접근할 때 객체를 그대로 주는 대신 그 메서드를 부른다. 그게 전부다.
from __future__ import annotations
import os
from typing import Any, Callable
class ConfigError(Exception):
pass
class Field:
"""환경변수에서 값을 읽어 변환·검증하는 디스크립터."""
def __init__(self, cast: Callable[[str], Any] = str,
default: Any = None, required: bool = False) -> None:
self.cast = cast
self.default = default
self.required = required
def __set_name__(self, owner: type, name: str) -> None:
# 클래스 본문이 실행될 때 파이썬이 불러 준다 (3.6+)
self.name = name
self.env = f"LOGKIT_{name.upper()}"
def __get__(self, obj: Any, owner: type | None = None) -> Any:
if obj is None:
return self # Settings.workers → 디스크립터 자신
if self.name not in obj.__dict__: # 최초 접근 때만 읽는다
raw = os.environ.get(self.env)
if raw is None:
if self.required:
raise ConfigError(f"{self.env} 가 필요하다")
obj.__dict__[self.name] = self.default
else:
obj.__dict__[self.name] = self.cast(raw)
return obj.__dict__[self.name]
def __set__(self, obj: Any, value: Any) -> None:
obj.__dict__[self.name] = self.cast(value) # 대입할 때도 변환한다
class Settings:
workers = Field(int, default=4)
limit_ms = Field(int, default=1000)
concurrency = Field(int, default=20)
endpoint = Field(str, required=True)
os.environ["LOGKIT_WORKERS"] = "8"
os.environ["LOGKIT_ENDPOINT"] = "https://alert.example.com/ingest"
s = Settings()
print(s.workers, s.limit_ms, s.endpoint) # 8 1000 https://alert.example.com/ingest
s.limit_ms = "2500" # 문자열을 넣어도
print(s.limit_ms, type(s.limit_ms)) # 2500 <class 'int'>
print(Settings.workers.env) # LOGKIT_WORKERS
세 가지를 짚어 둔다.
__set_name__이 이름 문제를 해결한다. 3.6 이전에는workers = Field("workers", int)처럼 이름을 두 번 써야 했고, 오타 나면 엉뚱한 값이 나왔다. 지금은 파이썬이 클래스 본문 실행 직후에 속성 이름을 알려 준다.- 값은 인스턴스의
__dict__에 저장한다. 디스크립터 객체는 클래스당 하나뿐이므로self.value = ...로 저장하면 모든 인스턴스가 값을 공유한다. 뒤에서 다시 다룬다. obj is None처리를 빠뜨리면 안 된다.Settings.workers처럼 클래스로 접근하면obj가None으로 들어온다. 문서화 도구나help()가 이 경로를 탄다.
데이터 디스크립터와 비데이터 디스크립터
속성 조회 우선순위가 __set__ 의 유무로 갈린다. 이걸 모르면 캐싱이 작동하지 않는 이유를 못 찾는다.
| 종류 | 가진 메서드 | 우선순위 |
|---|---|---|
| 데이터 디스크립터 | __set__ 또는 __delete__ 가 있음 | 인스턴스 __dict__ 보다 우선 |
| 비데이터 디스크립터 | __get__ 만 있음 | 인스턴스 __dict__ 가 우선 |
이 차이를 이용하면 한 번만 계산하는 지연 속성이 공짜로 만들어진다.
class Lazy:
"""__get__ 만 있는 비데이터 디스크립터."""
def __init__(self, fn):
self.fn = fn
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, owner=None):
if obj is None:
return self
value = self.fn(obj)
obj.__dict__[self.name] = value # 다음부터는 인스턴스 dict 가 이긴다
return value
class Report:
def __init__(self, ms_list: list[int]) -> None:
self.ms_list = ms_list
@Lazy
def p95(self) -> int:
print(" (정렬 중)")
s = sorted(self.ms_list)
return s[int(len(s) * 0.95)]
r = Report(list(range(100)))
print(r.p95) # (정렬 중) → 95
print(r.p95) # 95 — 계산 안 함
같은 클래스에 __set__ 을 추가하는 순간 데이터 디스크립터가 되어 인스턴스 __dict__ 를 무시하고 매번 재계산한다. 표준 라이브러리의 functools.cached_property 가 정확히 이 원리로 만들어져 있다. 새 코드에서는 Lazy 를 직접 만들지 말고 functools.cached_property 를 쓴다. 여기서 만든 이유는 그 안에서 무슨 일이 일어나는지 보기 위해서다.
클래스 생성 가로채기 1: __init_subclass__
logkit 에 감시 규칙을 플러그인으로 붙인다고 하자. 규칙 클래스를 만들면 자동으로 레지스트리에 등록되게 하고 싶다. 메타클래스가 떠오르지만, 대부분 이걸로 충분하다.
class Rule:
registry: dict[str, type["Rule"]] = {}
def __init_subclass__(cls, /, name: str, **kwargs) -> None:
super().__init_subclass__(**kwargs)
if name in Rule.registry:
raise ValueError(f"규칙 이름 중복: {name}")
Rule.registry[name] = cls
def evaluate(self, stats: dict) -> bool:
raise NotImplementedError
class SlowRatio(Rule, name="slow_ratio"):
def evaluate(self, stats: dict) -> bool:
return stats["slow_ratio"] >= 0.05
class ErrorRatio(Rule, name="error_ratio"):
def evaluate(self, stats: dict) -> bool:
return stats["error_ratio"] >= 0.01
print(Rule.registry)
# {'slow_ratio': <class '__main__.SlowRatio'>, 'error_ratio': <class '__main__.ErrorRatio'>}
__init_subclass__ 는 서브클래스가 만들어질 때 자동으로 불린다(암묵적으로 classmethod 다). 클래스 정의 헤더에 준 키워드 인자(name="slow_ratio")를 받을 수도 있다. 메타클래스와 달리 상속 충돌이 없고, 읽는 사람이 클래스 정의만 보고 이해할 수 있다.
클래스 생성 가로채기 2: 메타클래스
__init_subclass__ 로 안 되는 것은 클래스 본문의 네임스페이스를 만들어지는 도중에 가공하는 일이다. 예를 들어 Settings 에 선언된 Field 들을 모아서 목록을 만들고, 검증 메서드를 자동 생성하려면 클래스 객체가 만들어지는 순간에 개입해야 한다.
class SchemaMeta(type):
def __new__(mcls, name, bases, ns, **kwargs):
fields = {k: v for k, v in ns.items() if isinstance(v, Field)}
# 부모가 가진 필드도 물려받는다
for base in bases:
fields = {**getattr(base, "__fields__", {}), **fields}
cls = super().__new__(mcls, name, bases, ns, **kwargs)
cls.__fields__ = fields
return cls
class Settings(metaclass=SchemaMeta):
workers = Field(int, default=4)
limit_ms = Field(int, default=1000)
endpoint = Field(str, required=True)
def validate(self) -> None:
"""모든 필드를 한 번 읽어 필수값 누락을 시작 시점에 잡는다."""
for name in self.__fields__:
getattr(self, name)
def redacted(self) -> dict[str, Any]:
return {n: ("***" if "token" in n else getattr(self, n))
for n in self.__fields__}
print(sorted(Settings.__fields__)) # ['endpoint', 'limit_ms', 'workers']
Settings().validate() # 누락이 있으면 여기서 즉시 ConfigError
여기서 얻은 것이 있다. 설정 누락이 프로그램 시작 3초 안에 잡힌다. 이 구조가 없으면 알림을 보내려는 순간, 즉 장애가 난 새벽 3시에 LOGKIT_ENDPOINT 가 필요하다 를 처음 본다. 클래스 수준 메타 정보를 자동으로 모으는 일이 실제로 값어치를 내는 지점은 대개 이런 조기 실패다.
그래서 언제 쓰지 말아야 하는가
비용을 나열한다.
| 비용 | 내용 |
|---|---|
| 상속 충돌 | 메타클래스가 다른 두 클래스는 함께 상속할 수 없다. 라이브러리 조합이 막힌다 |
| 추적 불가 | s.workers 를 Go to definition 하면 Field 로 간다. 실제 값의 출처는 코드 어디에도 안 보인다 |
| 타입체커 | mypy 는 동적으로 만든 속성을 모른다. 플러그인이 있는 프레임워크(pydantic 등)가 아니면 타입 정보를 잃는다 |
| 디버깅 | 속성 하나 읽는 데 스택이 세 겹 깊어진다. 예외 메시지가 사용자 코드가 아니라 디스크립터 안을 가리킨다 |
충돌은 실제로 이렇게 난다.
class OtherMeta(type):
pass
class A(metaclass=SchemaMeta):
pass
class B(metaclass=OtherMeta):
pass
class C(A, B): # TypeError
pass
TypeError: metaclass conflict: the metaclass of a derived class must be a
(non-strict) subclass of the metaclasses of all its bases
판단 기준은 단순하다. 이 코드를 쓰는 사람 수보다 유지보수하는 사람 수가 훨씬 적을 때만 메타클래스가 이긴다. 라이브러리·프레임워크 저자, 사내에서 50곳이 쓰는 공통 모듈이면 그렇다. 애플리케이션 코드 안의 클래스 세 개를 위해서라면 거의 항상 진다. 대안 순서는 다음과 같다.
@dataclass또는 pydantic — 필드 선언이 필요한 대부분의 경우. 24단원에서 설정을 실제로 이렇게 만든다functools.cached_property,property— 속성 하나짜리 커스터마이즈__init_subclass__— 서브클래스 등록·검증- 클래스 데코레이터 — 클래스를 만든 뒤 손보면 되는 경우. 상속 충돌이 없다
- 메타클래스 — 위 넷으로 안 될 때만
property·메서드·classmethod 가 전부 디스크립터로 구현돼 있다는 사실과 그 순수 파이썬 등가 코드는 Descriptor HowTo Guide에 정리돼 있다.
실무에서 자주 틀리는 것
1. 디스크립터가 값을 자기 안에 저장한다
class Field:
def __get__(self, obj, owner=None):
return self.value # 클래스당 하나뿐인 객체의 속성
def __set__(self, obj, value):
self.value = value # ← 모든 인스턴스가 공유한다
a, b = Settings(), Settings()
a.workers = 8
print(b.workers) # 8 — b 는 건드린 적도 없다
디스크립터 객체는 클래스 본문에서 딱 한 번 생성되어 모든 인스턴스가 같은 것을 본다. 값은 반드시 obj.__dict__[self.name] 에 넣어야 한다. 테스트에서는 인스턴스를 하나만 만들기 때문에 안 잡히고, 운영에서 요청 A 의 설정이 요청 B 로 새는 형태로 터진다. 웹 서버에서 이 버그는 다른 사용자의 값이 보이는 보안 사고가 된다.
obj 를 키로 하는 딕셔너리에 저장하는 것도 흔한 대안인데, 일반 dict 를 쓰면 인스턴스가 영원히 회수되지 않는 누수가 된다. 굳이 그래야 한다면 WeakKeyDictionary 를 쓰되, 그러려면 대상 객체가 해시 가능하고 약한 참조를 지원해야 한다. 대부분은 obj.__dict__ 가 정답이다.
2. __slots__ 와 함께 쓰다 조용히 깨진다
17단원에서 Entry 에 slots=True 를 붙였다. 메모리를 아끼는 좋은 습관인데, __slots__ 가 있는 클래스에는 __dict__ 가 없으므로 위 디스크립터가 값을 저장할 곳이 사라진다. 게다가 이름이 겹치면 클래스 정의 자체가 실패한다.
class Bad:
__slots__ = ("workers",)
workers = Field(int) # ValueError
ValueError: 'workers' in __slots__ conflicts with class variable
사실 __slots__ 자체가 디스크립터로 구현되어 있다. 슬롯 하나마다 파이썬이 데이터 디스크립터를 만들어 클래스에 꽂는다. 우리 Field 와 자리를 두고 정면 충돌하는 것이다. 둘을 같이 쓰려면 디스크립터가 별도 저장소(예: obj._values 라는 슬롯 하나)를 쓰도록 설계해야 한다.
3. __get__ 안에서 무거운 일을 한다
속성 접근은 문법적으로 공짜처럼 보인다. s.endpoint 한 줄이 환경변수 조회에 그치지 않고 파일을 읽거나 HTTP 를 부른다면, 루프 안에 들어갔을 때 재앙이 된다.
for entry in read_log(path): # 500만 회
if entry.ms >= settings.limit_ms: # 매번 os.environ 조회 + int 변환
...
위에서 만든 Field 가 첫 조회 결과를 obj.__dict__ 에 넣어 두는 이유가 이것이다. 캐싱이 없었다면 루프 500만 회 × 환경변수 조회가 된다. 더 확실한 해법은 루프 밖에서 지역 변수로 꺼내는 것이고, 이건 21단원의 최적화 기법과 그대로 이어진다.
limit_ms = settings.limit_ms # 한 번만 읽는다
for entry in read_log(path):
if entry.ms >= limit_ms:
...
4. 메타클래스로 싱글턴을 만든다
인터넷에서 가장 많이 도는 메타클래스 예제가 싱글턴인데, 실무에서 가장 후회하는 것도 이것이다. 클래스가 스스로 인스턴스 수를 통제하면 테스트에서 상태를 초기화할 방법이 없다. 22단원에서 보게 되겠지만, 테스트끼리 설정이 새어 나가면 단독으로는 통과하고 전체 실행에서는 실패하는 유형의 문제가 생긴다. 파이썬에서 "하나만 있는 것"이 필요하면 모듈 수준 변수나 @lru_cache 를 붙인 팩터리 함수로 충분하고, 둘 다 테스트에서 갈아 끼울 수 있다.
from functools import lru_cache
@lru_cache(maxsize=1)
def get_settings() -> Settings:
s = Settings()
s.validate()
return s
# 테스트에서: get_settings.cache_clear()
스스로 확인하기
Field에__set__을 지우면s.workers = 8은 어떻게 동작하는가? 그리고 그 다음s.workers는 무엇을 돌려주는가?- 값의 범위를 검사하는
Field(int, default=20, validator=lambda v: 1 <= v <= 100)를 지원하도록Field를 확장하라. 검사는 어디에서 해야 하는가? - 어떤 라이브러리의 모델 클래스를 상속했는데
TypeError: metaclass conflict가 났다. 우리 쪽에도 메타클래스가 하나 있다. 해결책 두 가지를 들고 각각의 단점을 말하라.
정답
__set__이 없으면 비데이터 디스크립터가 되고, 대입은 디스크립터를 거치지 않고 인스턴스__dict__에 그대로 들어간다. 이후s.workers는 인스턴스__dict__가 우선하므로 대입한 값을 그대로 돌려준다. 값은 맞아 보이지만 타입 변환이 사라진다.s.workers = "8"을 하면 문자열이 그대로 남아range(s.workers)에서 터진다. 변환·검증을 강제하려면__set__이 반드시 있어야 한다.- 검사는
__set__과__get__의 환경변수 로딩 경로 양쪽에서 해야 한다. 한쪽만 하면 나머지 경로로 들어온 잘못된 값이 통과한다. 중복을 피하려면 변환+검증을 한 메서드로 모으고 두 곳에서 부른다.class Field: def __init__(self, cast=str, default=None, required=False, validator=None): self.cast, self.default = cast, default self.required, self.validator = required, validator def _coerce(self, value: Any) -> Any: value = self.cast(value) if self.validator is not None and not self.validator(value): raise ConfigError(f"{self.env} 값이 허용 범위를 벗어났다: {value!r}") return value def __set__(self, obj, value): obj.__dict__[self.name] = self._coerce(value)__get__의self.cast(raw)도self._coerce(raw)로 바꾼다. 기본값은 개발자가 쓴 값이므로 검증을 건너뛸지 말지가 설계 판단인데, 검증하는 편이 낫다. 잘못된 기본값을 배포 전에 잡는다. - (1) 우리 메타클래스를 상대 메타클래스의 서브클래스로 만든다.
class SchemaMeta(type(TheirModel)):로 쓰면 충돌이 풀린다. 단점은 남의 라이브러리 내부 구조에 의존하게 되어, 그쪽이 메타클래스를 바꾸면 우리가 깨진다. (2) 메타클래스를 버리고 클래스 데코레이터나__init_subclass__로 바꾼다. 근본 해결이지만 클래스 본문 네임스페이스를 만들어지는 도중에 가공하는 기능은 포기해야 한다. 실무에서는 (2)를 먼저 시도하고, 정말 안 될 때만 (1)로 간다. 세 번째 선택지로 상속 대신 합성(모델을 필드로 들고 있기)이 있고, 이게 가능하면 이게 가장 깔끔하다.
다음 단원에서는 지금까지 만든 것을 측정한다. 이 단원에서 본 "속성 접근이 공짜가 아니다" 같은 감각을 숫자로 확인하고, 어디를 고쳐야 실제로 빨라지는지 프로파일러로 찾는다.