디스크립터와 __set_name__
property가 동작하는 원리 — __get__/__set__ 프로토콜과 데이터/논데이터 디스크립터, __set_name__으로 깔끔한 검증 필드 만들기까지 정리합니다.
15장 매직 메소드 깊이와 프로토콜에서 __getattr__ / __setattr__가 속성 접근을 가로채는 후크라고 봤습니다. 그 위에 한 단계 더 — 속성 자체를 객체화하는 도구가 디스크립터입니다. @property, @classmethod, @staticmethod, ORM의 Column, dataclass 필드까지 — 모두 디스크립터 위에 만들어진 것들입니다.
본 챕터는 다음 17장 메타클래스 — 언제 정말 필요한가와 한 쌍입니다. 둘 다 “라이브러리 / 프레임워크 작성자의 도구"라는 공통점이 있고, 어떤 경우에 어느 쪽을 고르느냐의 분담이 17장에서 명시됩니다.
디스크립터란? #
__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같은 패턴으로 정규식 검증, 이메일 형식 검증 등을 디스크립터로 만들 수 있습니다. 4부의 24장 Pydantic v2 깊이가 본 패턴의 산업급 결과물이라고 보면 됩니다.
디스크립터 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과 디스크립터의 관계 #
8장 dataclass와 __slots__의 __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 | 필드 정의 |
라이브러리 코드를 읽다가 “왜 이 클래스 변수가 마법처럼 동작하지” 싶으면 거의 항상 디스크립터입니다. 25장 DB 연동 — SQLAlchemy 2.x + Alembic의 ORM 모델 정의도 이 원리를 활용합니다.
연습문제 #
PositiveInt()검증 디스크립터를 작성하세요.__set_name__으로 이름을 받아_<name>백킹 필드에 저장합니다.class User: age = PositiveInt(); score = PositiveInt()로 정의 후 두 인스턴스가 서로 다른 값을 가지는지,u.age = -1이ValueError인지 확인합니다.Validator베이스를 만들고 그 위에EmailField(Validator)를 작성하세요.validate(value)에서 정규식으로 이메일 형식을 검증합니다.class Account: email = EmailField()로 사용 시 잘못된 값에 대해 검증이 동작합니다.@cached_property를 직접 만들어 보세요. 첫 호출 때 계산 후 인스턴스__dict__에 같은 이름으로 저장하면 두 번째 호출부터는 디스크립터를 건너뛰고 인스턴스 속성이 직접 반환됩니다. 동작이 표준functools.cached_property와 같은지 확인합니다.
한 줄 요약: 디스크립터 =
__get__/__set__/__delete__중 하나라도 가진 클래스의 인스턴스. 데이터 디스크립터(__set__있음) > 인스턴스 dict > 논데이터 디스크립터의 우선순위.@property/ 메소드 /cached_property/__slots__가 다 디스크립터의 한 형태.__set_name__(3.6+)으로 이름 자동. 일상은@property로 충분, 라이브러리 / 프레임워크 코드만 디스크립터 직접.
다음 챕터 #
다음 17장 메타클래스 — 언제 정말 필요한가에서는 한 단계 더 — 클래스를 만드는 클래스의 역할을 다룹니다. 메타클래스 vs __init_subclass__ vs 디스크립터 vs 데코레이터의 분담도 같이 정리합니다.