목차
16 장

디스크립터와 __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 가 None

c.PI라는 단순한 속성 접근이 사실 메소드 호출로 풀립니다. 이 시점에 어떤 코드든 끼워 넣을 수 있습니다.

__get__의 시그니처 #

def __get__(self, instance, owner): ...
  • self — 디스크립터 인스턴스
  • instance — 속성에 접근한 객체. 클래스 자체를 통해 접근하면 None
  • owner — 속성을 가진 클래스

데이터 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가 사실 디스크립터다 #

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 객체입니다. 직접 만들어 보면:

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 — 자주 만나는 한 형태 #

표준 라이브러리에 이미 있습니다.

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 한 줄이 실행될 때 파이썬이 찾는 순서:

  1. 데이터 디스크립터 (클래스에 있음, __set__ 또는 __delete__ 보유) → 호출
  2. 인스턴스 dict → 그냥 반환
  3. 논데이터 디스크립터 / 클래스 속성 → 메소드면 호출, 일반이면 반환
  4. __getattr__ → fallback
  5. AttributeError → 모두 실패

이 우선순위가 위에서 봤던 데이터 vs 논데이터 차이의 정체입니다.

__delete__ — 삭제 후크 #

del 가로채기
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__가 사실 각 슬롯을 디스크립터로 만듭니다.

slot도 디스크립터
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
functoolscached_property
dataclassesfield() (비교 / 생성 메타데이터 일부)
ORM (SQLAlchemy, Django ORM)Column, ForeignKey, 모델 필드
폼 / 검증 (WTForms, Django forms)필드 클래스들
attrs, Pydantic필드 정의

라이브러리 코드를 읽다가 “왜 이 클래스 변수가 마법처럼 동작하지” 싶으면 거의 항상 디스크립터입니다. 25장 DB 연동 — SQLAlchemy 2.x + Alembic의 ORM 모델 정의도 이 원리를 활용합니다.

연습문제 #

  1. PositiveInt() 검증 디스크립터를 작성하세요. __set_name__으로 이름을 받아 _<name> 백킹 필드에 저장합니다. class User: age = PositiveInt(); score = PositiveInt()로 정의 후 두 인스턴스가 서로 다른 값을 가지는지, u.age = -1ValueError인지 확인합니다.
  2. Validator 베이스를 만들고 그 위에 EmailField(Validator)를 작성하세요. validate(value)에서 정규식으로 이메일 형식을 검증합니다. class Account: email = EmailField()로 사용 시 잘못된 값에 대해 검증이 동작합니다.
  3. @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 데코레이터의 분담도 같이 정리합니다.

X