모던 파이썬 고급 #1 매직 메소드 깊이와 프로토콜
모던 파이썬 중급 시리즈를 마쳤다면, 이제 언어 깊이로 들어갑니다. 고급 시리즈 7편은 라이브러리,프레임워크 코드에서 자주 만나는 도구들, 즉 매직 메소드, 디스크립터, 메타클래스, 비동기 깊이, GIL/동시성, typing 고급, 성능을 다룹니다.
- #1 매직 메소드 깊이와 프로토콜 ← 이번 글
- #2 디스크립터와
__set_name__ - #3 메타클래스 — 언제 정말 필요한가
- #4 비동기 깊이 (이벤트 루프, gather/wait, async generator)
- #5 GIL과 동시성 — threading vs multiprocessing vs asyncio
- #6 typing 고급 — Variance, ParamSpec, Self
- #7 성능 — cProfile, line_profiler, 메모리 프로파일링
매직 메소드(또는 던더 메소드, dunder = double underscore)는 파이썬 객체가 언어 기능과 만나는 공식 후크입니다. len(x)가 x.__len__()를 부르고, a + b가 a.__add__(b)를 부르는 식입니다. 이 후크를 정확히 알면 파이썬다운 객체를 만들 수 있고, 라이브러리 코드를 읽을 때 무엇이 호출되는지 보입니다.
객체 생명 주기 #
__init__과 __new__ — 차이
#
class Foo:
def __new__(cls, *args, **kwargs):
# 인스턴스를 만든다 (메모리 할당)
instance = super().__new__(cls)
return instance
def __init__(self, value):
# 만들어진 인스턴스를 초기화한다
self.value = value__new__— 클래스 메소드처럼 동작하며 인스턴스를 만들어 반환합니다.cls를 첫 인자로 받습니다__init__— 인스턴스 메소드로 이미 만들어진 인스턴스를 초기화합니다.self를 받습니다
대부분의 코드는 __init__만 적습니다. __new__가 필요한 경우는 좁습니다:
- 불변 타입(
tuple,str)을 상속해 만들기 - 싱글턴 같은 인스턴스 캐싱
__init__호출 자체를 막아야 할 때
class Singleton:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
a = Singleton()
b = Singleton()
print(a is b) # True__new__가 반환한 객체가 그 클래스의 인스턴스가 아니면 __init__이 호출되지 않으니, 이 점은 주의해야 합니다.
__del__ — 거의 안 씀
#
객체가 가비지 컬렉션될 때 호출됩니다. 다만 다음 한계가 있습니다.
- 언제 호출될지 보장되지 않습니다 — GC 타이밍에 의존합니다
- 순환 참조가 있으면 호출되지 않을 수도 있습니다
- 예외가 나도 무시됩니다
자원 정리는 __del__이 아니라 컨텍스트 매니저 (중급 #3)가 정답입니다.
표현 — __repr__ vs __str__
#
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self) -> str:
return f"Point(x={self.x}, y={self.y})"
def __str__(self) -> str:
return f"({self.x}, {self.y})"
p = Point(1, 2)
print(repr(p)) # Point(x=1, y=2) ← 디버깅용, 모호하지 않게
print(str(p)) # (1, 2) ← 사람이 읽기 좋게
print(f"{p}") # (1, 2) ← f-string은 str
print(p) # (1, 2) ← print도 str룰:
__repr__— 모호하지 않은 표현. 가능하면eval(repr(x)) == x가 되도록 만듭니다__str__— 사용자에게 보여줄 표현. 정의하지 않으면__repr__가 사용됩니다
@dataclass (중급 #1)가 자동으로 만드는 건 __repr__입니다.
__format__ — f-string의 포맷 스펙
#
class Money:
def __init__(self, amount, currency):
self.amount, self.currency = amount, currency
def __format__(self, spec):
if spec == "k":
return f"{self.amount / 1000:.1f}k {self.currency}"
return f"{self.amount} {self.currency}"
m = Money(12345, "KRW")
print(f"{m}") # 12345 KRW
print(f"{m:k}") # 12.3k KRWf-string f"{x:fmt}"의 fmt 부분이 __format__(spec)의 인자로 들어갑니다. 도메인 객체에 사용자 정의 포맷을 부여하는 후크입니다.
비교 #
__eq__와 __hash__ — 짝꿍
#
이 둘은 항상 같이 갑니다.
class User:
def __init__(self, id, name):
self.id, self.name = id, name
def __eq__(self, other):
if not isinstance(other, User):
return NotImplemented
return self.id == other.id
def __hash__(self):
return hash(self.id)룰:
a == b면hash(a) == hash(b)가 반드시 성립해야 합니다__eq__만 정의하면__hash__가 자동으로None이 되어 set/dict 키로 못 씁니다- 가변 객체는 보통 hashable이 아닙니다
@dataclass(frozen=True)가 두 메소드를 같이 자동 생성합니다.
__lt__ 등 비교 — functools.total_ordering
#
<, <=, >, >=, ==, != 여섯을 다 적기 귀찮을 때 씁니다.
from functools import total_ordering
@total_ordering
class Score:
def __init__(self, value):
self.value = value
def __eq__(self, other):
return self.value == other.value
def __lt__(self, other):
return self.value < other.value
# 나머지 4개는 자동으로 채워짐@dataclass(order=True)가 더 짧지만 단순 필드 비교에만 어울립니다. 복잡한 비교 로직은 total_ordering으로.
컨테이너처럼 — 시퀀스/매핑 #
__len__, __getitem__, __contains__, __iter__
#
class Page:
def __init__(self, items):
self.items = items
def __len__(self):
return len(self.items)
def __getitem__(self, index):
return self.items[index]
def __contains__(self, value):
return value in self.items
def __iter__(self):
return iter(self.items)
p = Page(["a", "b", "c"])
len(p) # 3
p[0] # 'a'
"b" in p # True
list(p) # ['a', 'b', 'c']
for x in p: ... # OK이 네 개를 채우면 거의 list처럼 동작합니다. 사실 __getitem__과 __len__만 있어도 for in이 동작합니다 (인덱스 0부터 IndexError까지 시도).
슬라이스도 자동 #
__getitem__의 인자에 slice 객체가 들어올 수 있습니다.
class MyList:
def __init__(self, items):
self.items = items
def __getitem__(self, key):
if isinstance(key, slice):
return MyList(self.items[key])
return self.items[key]
m = MyList([1, 2, 3, 4, 5])
m[1:3].items # [2, 3]m[1:3] 호출 시 key가 slice(1, 3, None)입니다.
__setitem__, __delitem__
#
class Cache:
def __init__(self):
self._data = {}
def __getitem__(self, key):
return self._data[key]
def __setitem__(self, key, value):
self._data[key] = value
def __delitem__(self, key):
del self._data[key]
c = Cache()
c["x"] = 1
print(c["x"]) # 1
del c["x"]dict처럼 동작하는 객체를 만들 때 사용합니다.
호출 가능 — __call__
#
객체를 함수처럼 호출할 수 있게 만드는 후크입니다.
class Counter:
def __init__(self):
self.count = 0
def __call__(self, value):
self.count += 1
return value * 2
c = Counter()
print(c(5)) # 10
print(c(7)) # 14
print(c.count) # 2중급 #5의 클래스 형태 데코레이터가 이 후크 위에 만들어졌습니다. PyTorch의 nn.Module도 __call__을 가져 모델을 함수처럼 호출하게 합니다.
속성 접근 — __getattr__, __setattr__, __getattribute__
#
__getattr__ — 없는 속성 요청 시
#
class Lazy:
def __getattr__(self, name):
if name.startswith("get_"):
field = name[4:]
return lambda: f"value of {field}"
raise AttributeError(name)
l = Lazy()
l.get_name() # 'value of name'
l.get_age() # 'value of age'없는 속성에만 호출됩니다. 있는 속성은 정상 경로를 탑니다. ORM의 자동 메소드, 프록시 객체 등에 자주 등장합니다.
__getattribute__ — 모든 속성 요청에
#
class All:
def __getattribute__(self, name):
print(f"접근: {name}")
return super().__getattribute__(name)모든 속성 접근에 끼어듭니다. 잘못 쓰면 무한 재귀가 나기 쉬워서 (자기 안에서 self.x를 쓰면 또 __getattribute__ 호출), 보통은 __getattr__만 씁니다.
__setattr__, __delattr__
#
class Frozen:
def __init__(self, x):
self.x = x
object.__setattr__(self, "_locked", True)
def __setattr__(self, name, value):
if getattr(self, "_locked", False):
raise AttributeError(f"{name} 변경 불가")
super().__setattr__(name, value)
f = Frozen(5)
f.x = 10 # AttributeError@dataclass(frozen=True)의 동작이 정확히 이 패턴입니다.
산술 연산 — __add__ 등
#
class Vec:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other):
return Vec(self.x + other.x, self.y + other.y)
def __mul__(self, k):
return Vec(self.x * k, self.y * k)
def __rmul__(self, k):
return self.__mul__(k)
v = Vec(1, 2) + Vec(3, 4) # __add__
w = Vec(1, 2) * 3 # __mul__
u = 3 * Vec(1, 2) # __rmul__ (왼쪽 피연산자가 모르는 타입)대칭 연산이 필요하면 __rmul__ 같은 reflected 버전도 정의합니다. 왼쪽 피연산자가 자기를 곱할 줄 모를 때 (예: int * Vec) 호출됩니다.
__bool__ — 진리값
#
class Bag:
def __init__(self):
self.items = []
def __bool__(self):
return bool(self.items)
b = Bag()
if b:
print("뭔가 있음")정의하지 않으면 __len__을 보고, 그것도 없으면 항상 True입니다. 컨테이너 형태에는 __len__만 있어도 충분한 경우가 많습니다.
상속 시점의 후크 — __init_subclass__
#
서브클래스가 만들어질 때 호출되는 후크입니다. 메타클래스를 쓰지 않고도 비슷한 일을 할 수 있습니다.
class Plugin:
registry = []
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Plugin.registry.append(cls)
class JsonPlugin(Plugin):
pass
class CsvPlugin(Plugin):
pass
print(Plugin.registry)
# [<class 'JsonPlugin'>, <class 'CsvPlugin'>]플러그인 자동 등록 같은 경우에 자주 씁니다. 메타클래스 (#3)가 더 강력하지만, 이 정도 일은 __init_subclass__가 더 가볍고 안전합니다.
자주 만나는 메소드 — 한 표 #
| 카테고리 | 메소드 | 호출 시점 |
|---|---|---|
| 생성/소멸 | __new__, __init__, __del__ | 인스턴스 만들 때 / 정리될 때 |
| 표현 | __repr__, __str__, __format__, __bool__ | repr(), str(), f"", if |
| 비교 | __eq__, __hash__, __lt__ 등 | ==, hash(), < |
| 컨테이너 | __len__, __getitem__, __setitem__, __contains__, __iter__ | len(), x[k], in, for |
| 호출 | __call__ | x(...) |
| 속성 | __getattr__, __setattr__, __delattr__ | 속성 접근 |
| 산술 | __add__, __sub__, __mul__, __truediv__, … | +, -, *, / |
| 비동기 | __await__, __aiter__, __anext__, __aenter__, __aexit__ | await, async for/with |
| 상속 후크 | __init_subclass__, __class_getitem__ | 서브클래스 / Cls[T] |
이 표를 다 외울 필요는 없습니다. 어떤 후크가 있다는 것만 인지하면 라이브러리 코드에서 보일 때 검색할 수 있습니다.
정리 #
이번 글에서 잡은 것:
__new__는 인스턴스 생성,__init__은 초기화. 대부분은__init__만__del__보다 컨텍스트 매니저__repr__(개발자) vs__str__(사용자), 정의 안 하면__repr__가 fallback__format__으로 f-string 포맷 스펙 받기__eq__와__hash__는 짝꿍,frozen=Truedataclass가 자동- 컨테이너는
__len__+__getitem__으로 거의 list 수준 동작 __call__로 객체를 함수처럼__getattr__은 없는 속성,__getattribute__는 모든 속성 (위험)__init_subclass__로 메타클래스 없이 가벼운 자동 등록- 매직 메소드는 객체와 언어 기능을 잇는 공식 후크
다음 글(#2 디스크립터와 __set_name__)에서는 매직 메소드 중에서도 특수한 분류 — 속성을 객체화하는 디스크립터를 다룹니다. @property가 사실 디스크립터의 한 형태입니다.