모던 파이썬 고급 #2 디스크립터와 __set_name__
#1 매직 메소드 깊이에서 __getattr__/__setattr__가 속성 접근을 가로채는 후크라고 봤습니다. 그 위에 한 단계 더 들어가서, 속성 자체를 객체화하는 도구가 디스크립터입니다. @property, @classmethod, @staticmethod, ORM의 Column, dataclass 필드까지 모두 디스크립터 위에 만들어진 것들입니다.
디스크립터란? #
__get__, __set__, __delete__ 중 하나라도 정의된 클래스의 인스턴스가 디스크립터입니다. 클래스의 속성으로 두면, 그 속성에 접근할 때 디스크립터 메소드가 자동으로 호출됩니다.
class Constant:
def __init__(self, value):
self.value = value
def __get__(self, instance, owner):
return self.value
class Config:
PI = Constant(3.14)
c = Config()
print(c.PI) # 3.14 — Constant.__get__() 호출됨
print(Config.PI) # 3.14 — 같은 메소드, instance가 Nonec.PI라는 단순한 속성 접근이 사실 메소드 호출로 풀립니다. 이 시점에 어떤 코드든 끼워 넣을 수 있습니다.
__get__의 시그니처
#
def __get__(self, instance, owner): ...self— 디스크립터 인스턴스instance— 속성에 접근한 객체. 클래스 자체를 통해 접근하면Noneowner— 속성을 가진 클래스
데이터 vs 논데이터 디스크립터 #
이것이 디스크립터의 가장 헷갈리는 부분입니다.
| 종류 | 정의 | 우선순위 |
|---|---|---|
| 데이터 디스크립터 | __set__ 또는 __delete__가 있음 | 인스턴스 dict보다 우선 |
| 논데이터 디스크립터 | __get__만 있음 | 인스턴스 dict가 우선 |
실제 차이를 보면:
class DataDesc:
def __get__(self, instance, owner):
return "from descriptor"
def __set__(self, instance, value):
pass
class A:
x = DataDesc()
a = A()
a.__dict__["x"] = "from instance dict"
print(a.x) # 'from descriptor' ← 디스크립터가 이김class NonDataDesc:
def __get__(self, instance, owner):
return "from descriptor"
class B:
x = NonDataDesc()
b = B()
b.__dict__["x"] = "from instance dict"
print(b.x) # 'from instance dict' ← 인스턴스 dict가 이김이 우선순위가 왜 중요하냐면:
- 메소드는 논데이터 디스크립터. 인스턴스에 같은 이름의 속성을 만들면 그것이 우선합니다 (메소드 오버라이드 가능)
@property는 데이터 디스크립터. 사용자가 같은 이름으로 인스턴스 속성을 만들어도 항상 property가 동작합니다
@property가 사실 디스크립터다
#
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14 * self.radius ** 2
c = Circle(5)
print(c.area) # 78.5 — area.__get__() 호출됨@property 데코레이터는 property 디스크립터 클래스의 인스턴스를 만듭니다. Circle.area는 함수가 아니라 property 객체입니다. 직접 만들어 보면:
class my_property:
def __init__(self, fget):
self.fget = fget
def __get__(self, instance, owner):
if instance is None:
return self
return self.fget(instance)
class Circle:
def __init__(self, radius):
self.radius = radius
@my_property
def area(self):
return 3.14 * self.radius ** 2
c = Circle(5)
print(c.area) # 78.5표준 라이브러리 property는 여기에 setter/deleter까지 갖춘 형태입니다.
사용자 정의 — 검증 디스크립터 #
자주 쓰는 패턴은 속성을 검증하는 디스크립터입니다.
class PositiveInt:
def __init__(self):
self.value = 0 # ✗ 모든 인스턴스가 같은 디스크립터를 공유
def __get__(self, instance, owner):
return self.value
def __set__(self, instance, value):
if value < 0:
raise ValueError("음수 불가")
self.value = value문제: PositiveInt() 한 개의 디스크립터 인스턴스가 모든 사용처에서 공유됩니다. User 인스턴스 두 개를 만들면 age가 같은 값이 됩니다.
해결: 값을 인스턴스 dict에 저장합니다.
class PositiveInt:
def __set_name__(self, owner, name):
self.name = "_" + name # 백킹 필드 이름
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.name)
def __set__(self, instance, value):
if value < 0:
raise ValueError(f"{self.name[1:]} 음수 불가")
setattr(instance, self.name, value)
class User:
age = PositiveInt()
score = PositiveInt()
u = User()
u.age = 30 # OK
u.age = -1 # ✗ ValueError
u.score = 95 # 다른 인스턴스에 다른 값 저장 가능__set_name__ — 깔끔한 이름 자동 부여 (3.6+)
#
위 코드의 핵심이 **__set_name__**입니다.
def __set_name__(self, owner, name): ...이 메소드는 클래스가 정의될 때 자동으로 호출됩니다.
owner— 디스크립터를 담은 클래스 (User)name— 디스크립터가 묶인 속성 이름 (age,score)
이 후크가 3.6 이전에는 없어서, 이름을 직접 인자로 넘겨야 했습니다.
class User:
age = PositiveInt("age") # 이름 중복 — DRY 위반
score = PositiveInt("score")__set_name__가 그 중복을 없애줍니다. 사용자는 그냥 PositiveInt()라고만 적으면 됩니다.
더 정교한 검증 — Generic으로 #
이것을 여러 타입의 검증 디스크립터로 일반화하면 다음과 같습니다.
class Validator:
def __set_name__(self, owner, name):
self.private_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.private_name)
def __set__(self, instance, value):
self.validate(value)
setattr(instance, self.private_name, value)
def validate(self, value):
raise NotImplementedError # 서브클래스가 구현
class IntRange(Validator):
def __init__(self, lo, hi):
self.lo, self.hi = lo, hi
def validate(self, value):
if not isinstance(value, int):
raise TypeError(f"int 필요, got {type(value).__name__}")
if not (self.lo <= value <= self.hi):
raise ValueError(f"{self.lo} ~ {self.hi} 범위")
class StringLen(Validator):
def __init__(self, min_len, max_len):
self.min_len, self.max_len = min_len, max_len
def validate(self, value):
if not isinstance(value, str):
raise TypeError("str 필요")
if not (self.min_len <= len(value) <= self.max_len):
raise ValueError("길이 범위 벗어남")
class User:
age = IntRange(0, 150)
name = StringLen(1, 50)
u = User()
u.age = 30 # OK
u.age = 999 # ValueError
u.name = "" # ValueError같은 패턴으로 정규식 검증, 이메일 형식 검증 등을 디스크립터로 만들 수 있습니다.
디스크립터 vs @property — 언제 무엇?
#
| 상황 | 어울림 |
|---|---|
| 한 클래스 안에서 한두 군데 검증 | @property (간단) |
| 여러 클래스 / 여러 필드에서 같은 검증 패턴 | 디스크립터 (재사용) |
| 단순 계산 (read-only) | @property 또는 @cached_property |
| ORM 컬럼, 폼 필드처럼 메타데이터까지 묶을 때 | 디스크립터 |
대부분의 일상 코드는 @property로 해결됩니다. 디스크립터를 직접 만드는 경우는 라이브러리/프레임워크 코드입니다.
@cached_property — 자주 만나는 한 형태
#
표준 라이브러리에 이미 있습니다.
from functools import cached_property
class Document:
def __init__(self, text):
self.text = text
@cached_property
def word_count(self):
print("계산 중...")
return len(self.text.split())
d = Document("a b c d")
print(d.word_count) # 계산 중... 4
print(d.word_count) # 4 (캐시 — 계산 안 함)처음 호출할 때만 계산하고, 결과를 인스턴스 dict에 저장합니다. 이후에는 일반 속성처럼 동작합니다. @property는 매번 계산하는 차이가 있습니다.
cached_property는 논데이터 디스크립터입니다. 그래서 첫 계산 후 인스턴스 dict에 저장된 값이 우선시되어 디스크립터가 다시 호출되지 않습니다.
__get__ 호출 흐름 — 전체 그림
#
obj.attr 한 줄이 실행될 때 파이썬이 찾는 순서:
- 데이터 디스크립터 (클래스에 있음,
__set__또는__delete__보유) → 호출 - 인스턴스 dict → 그냥 반환
- 논데이터 디스크립터 / 클래스 속성 → 메소드면 호출, 일반이면 반환
__getattr__→ fallbackAttributeError→ 모두 실패
이 우선순위가 위에서 봤던 데이터 vs 논데이터 차이의 정체입니다.
__delete__ — 삭제 후크
#
class Locked:
def __get__(self, instance, owner):
return getattr(instance, "_value", None)
def __set__(self, instance, value):
instance._value = value
def __delete__(self, instance):
raise AttributeError("삭제 금지")
class Box:
x = Locked()
b = Box()
b.x = 5
del b.x # AttributeError자주 쓰지는 않지만, 자원 정리,롤백 시점에 필요한 경우가 있습니다.
slot과 디스크립터의 관계 #
중급 #1의 __slots__가 사실 각 슬롯을 디스크립터로 만듭니다.
class Point:
__slots__ = ("x", "y")
print(Point.x) # <member 'x' of 'Point' objects>
print(type(Point.x).__set__ is not None) # True (데이터 디스크립터)그래서 __slots__ 객체는 __dict__가 없어도 속성 접근/대입이 동작하는 것입니다.
디스크립터를 쓰는 라이브러리 — 한 표 #
| 라이브러리/도구 | 디스크립터 사용처 |
|---|---|
| 빌트인 | property, classmethod, staticmethod |
functools | cached_property |
dataclasses | field() (비교/생성 메타데이터 일부) |
ORM (SQLAlchemy, Django ORM) | Column, ForeignKey, 모델 필드 |
폼/검증 (WTForms, Django forms) | 필드 클래스들 |
attrs, Pydantic | 필드 정의 |
라이브러리 코드를 읽다가 “왜 이 클래스 변수가 마법처럼 동작하지” 싶으면 거의 항상 디스크립터입니다.
정리 #
이번 글에서 본 것:
- 디스크립터 =
__get__/__set__/__delete__중 하나라도 정의된 클래스의 인스턴스 - 클래스 속성으로 두면 속성 접근이 메소드 호출로 풀림
- 데이터 디스크립터 (
__set__보유) > 인스턴스 dict > 논데이터 디스크립터 @property, 메소드,cached_property가 모두 디스크립터의 한 형태- 검증 디스크립터: 인스턴스 dict에 백킹 필드로 저장
__set_name__(3.6+)으로 이름을 자동 받음 — DRY가 풀림- 디스크립터는 라이브러리/프레임워크에서 활용, 일상 코드는
@property로 충분 __slots__도 디스크립터로 동작
다음 글(#3 메타클래스)에서는 한 단계 더 들어가서 클래스를 만드는 클래스, 메타클래스의 역할을 다룹니다. 메타클래스 vs __init_subclass__ vs 디스크립터 vs 데코레이터의 분담도 같이 살펴봅니다.