본문으로 건너뛰기
Advertisement

디스크립터(Descriptor)

디스크립터__get__, __set__, __delete__ 중 하나 이상을 구현한 객체로, 클래스 속성으로 사용될 때 속성 접근을 가로채는 파이썬의 핵심 메커니즘입니다. @property, classmethod, staticmethod도 모두 디스크립터입니다.


디스크립터 프로토콜

class Descriptor:
"""디스크립터 프로토콜 3가지 메서드"""

def __get__(self, obj, objtype=None):
"""속성 읽기: obj.attr 또는 Class.attr 호출 시"""
if obj is None:
# 클래스에서 직접 접근 (Class.attr)
return self
return getattr(obj, "_value", None)

def __set__(self, obj, value):
"""속성 쓰기: obj.attr = value"""
obj._value = value

def __delete__(self, obj):
"""속성 삭제: del obj.attr"""
try:
del obj._value
except AttributeError:
raise AttributeError("속성을 삭제할 수 없습니다.")


class MyClass:
attr = Descriptor() # 클래스 속성으로 등록


obj = MyClass()
obj.attr = 42 # __set__ 호출
print(obj.attr) # __get__ 호출 → 42
print(MyClass.attr) # __get__에서 obj=None → Descriptor 인스턴스 반환
del obj.attr # __delete__ 호출

데이터 디스크립터 vs 비데이터 디스크립터

# 데이터 디스크립터: __set__ 또는 __delete__ 구현 → __dict__보다 우선
class DataDescriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f"__{id(self)}__")

def __set__(self, obj, value):
obj.__dict__[f"__{id(self)}__"] = value


# 비데이터 디스크립터: __get__만 구현 → __dict__에 밀림
class NonDataDescriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return "비데이터 디스크립터 값"


class Demo:
data = DataDescriptor()
non_data = NonDataDescriptor()


d = Demo()
d.data = "데이터 디스크립터"
d.__dict__["data"] = "인스턴스 __dict__" # 데이터 디스크립터가 우선 → 무시됨

d.__dict__["non_data"] = "인스턴스 __dict__" # 비데이터 디스크립터보다 __dict__가 우선

print(d.data) # 데이터 디스크립터 (디스크립터 우선)
print(d.non_data) # 인스턴스 __dict__ (__dict__가 우선)

타입 검증 디스크립터

class TypedAttribute:
"""타입 검증을 수행하는 데이터 디스크립터"""

def __init__(self, name: str, expected_type: type):
self.name = name
self.expected_type = expected_type
self.private_name = f"_{name}"

def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)

def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name}: {self.expected_type.__name__} 기대, "
f"{type(value).__name__} ({value!r}) 받음"
)
setattr(obj, self.private_name, value)

def __delete__(self, obj):
try:
delattr(obj, self.private_name)
except AttributeError:
pass


class RangedFloat:
"""범위 검증 디스크립터"""

def __init__(self, name: str, min_val: float, max_val: float):
self.name = name
self.min_val = min_val
self.max_val = max_val
self.private_name = f"_{name}"

def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)

def __set__(self, obj, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{self.name}은 숫자여야 합니다.")
if not (self.min_val <= value <= self.max_val):
raise ValueError(
f"{self.name}{self.min_val}~{self.max_val} 범위여야 합니다: {value}"
)
setattr(obj, self.private_name, float(value))

def __delete__(self, obj):
setattr(obj, self.private_name, None)


class Person:
name = TypedAttribute("name", str)
age = TypedAttribute("age", int)
height = RangedFloat("height", 0.0, 300.0)
weight = RangedFloat("weight", 0.0, 500.0)

def __init__(self, name: str, age: int, height: float, weight: float):
self.name = name
self.age = age
self.height = height
self.weight = weight

def __repr__(self) -> str:
return f"Person({self.name!r}, {self.age}, {self.height}cm, {self.weight}kg)"

@property
def bmi(self) -> float:
h_m = self.height / 100
return self.weight / (h_m ** 2) if h_m > 0 else 0


p = Person("Alice", 30, 165.0, 60.0)
print(p)
print(f"BMI: {p.bmi:.1f}")

# 타입 오류
try:
p.age = "서른"
except TypeError as e:
print(f"타입 오류: {e}")

# 범위 오류
try:
p.height = 400.0
except ValueError as e:
print(f"범위 오류: {e}")

__set_name__ 활용 (Python 3.6+)

class Validated:
"""__set_name__으로 속성 이름을 자동으로 얻는 디스크립터"""

def __set_name__(self, owner, name: str):
"""클래스 정의 시 자동 호출 — 속성 이름을 알 수 있음"""
self.name = name
self.private_name = f"_{name}"

def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)

def __set__(self, obj, value):
value = self.validate(value)
setattr(obj, self.private_name, value)

def validate(self, value):
return value # 서브클래스에서 오버라이딩


class PositiveInt(Validated):
def validate(self, value) -> int:
if not isinstance(value, int):
raise TypeError(f"{self.name}: int 기대, {type(value).__name__} 받음")
if value <= 0:
raise ValueError(f"{self.name}: 양수 기대, {value} 받음")
return value


class NonEmptyStr(Validated):
def validate(self, value) -> str:
if not isinstance(value, str):
raise TypeError(f"{self.name}: str 기대, {type(value).__name__} 받음")
stripped = value.strip()
if not stripped:
raise ValueError(f"{self.name}: 빈 문자열은 허용되지 않습니다.")
return stripped


class Product:
name = NonEmptyStr() # __set_name__이 자동으로 "name" 전달
price = PositiveInt() # __set_name__이 자동으로 "price" 전달
stock = PositiveInt() # __set_name__이 자동으로 "stock" 전달

def __init__(self, name: str, price: int, stock: int):
self.name = name
self.price = price
self.stock = stock

def __repr__(self) -> str:
return f"Product({self.name!r}, {self.price}원, 재고 {self.stock}개)"


p = Product("Python 책", 35000, 100)
print(p)

try:
Product("", 35000, 100)
except ValueError as e:
print(f"오류: {e}")

try:
Product("책", -100, 50)
except ValueError as e:
print(f"오류: {e}")

캐싱 디스크립터

import functools


class cached_property:
"""계산 비용이 높은 프로퍼티를 캐싱하는 디스크립터 (비데이터 디스크립터)"""

def __init__(self, func):
self.func = func
self.attrname = None
self.__doc__ = func.__doc__

def __set_name__(self, owner, name: str):
self.attrname = name

def __get__(self, obj, objtype=None):
if obj is None:
return self
# 비데이터 디스크립터이므로 __dict__가 우선 → 한 번만 계산
val = self.func(obj)
obj.__dict__[self.attrname] = val # 인스턴스 __dict__에 직접 저장
return val


class Circle:
def __init__(self, radius: float):
self.radius = radius

@cached_property
def area(self) -> float:
"""계산 비용이 높다고 가정"""
import math
print(" [area 계산 중...]")
return math.pi * self.radius ** 2

@cached_property
def perimeter(self) -> float:
print(" [perimeter 계산 중...]")
import math
return 2 * math.pi * self.radius


c = Circle(5.0)

print("첫 번째 접근:")
print(f" 넓이: {c.area:.2f}") # 계산 실행

print("두 번째 접근:")
print(f" 넓이: {c.area:.2f}") # 캐시에서 바로 반환

print(f" 둘레: {c.perimeter:.2f}") # 계산 실행
print(f" 둘레: {c.perimeter:.2f}") # 캐시에서 반환

# Python 표준 라이브러리의 functools.cached_property도 동일한 원리
from functools import cached_property as std_cached_property

class Polygon:
def __init__(self, vertices: list[tuple[float, float]]):
self.vertices = vertices

@std_cached_property
def perimeter(self) -> float:
import math
total = 0.0
n = len(self.vertices)
for i in range(n):
x1, y1 = self.vertices[i]
x2, y2 = self.vertices[(i + 1) % n]
total += math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
return total

디스크립터 내부 동작 이해

# 속성 조회 순서 (MRO + 디스크립터 우선순위)
# 1. 데이터 디스크립터 (type(obj).__mro__에서 __set__ 또는 __delete__ 가진 것)
# 2. 인스턴스 __dict__
# 3. 비데이터 디스크립터 (type(obj).__mro__에서 __get__만 가진 것)

class Spy:
"""속성 접근 흐름을 추적하는 디스크립터"""

def __set_name__(self, owner, name: str):
self.name = name

def __get__(self, obj, objtype=None):
print(f" __get__({obj!r}, {objtype.__name__ if objtype else None})")
if obj is None:
return self
return obj.__dict__.get(f"_{self.name}")

def __set__(self, obj, value):
print(f" __set__({obj!r}, {value!r})")
obj.__dict__[f"_{self.name}"] = value

def __delete__(self, obj):
print(f" __delete__({obj!r})")
obj.__dict__.pop(f"_{self.name}", None)


class Container:
value = Spy()

def __repr__(self) -> str:
return f"Container()"


print("=== 쓰기 ===")
c = Container()
c.value = 100

print("\n=== 읽기 ===")
print(c.value)

print("\n=== 클래스에서 읽기 ===")
print(Container.value) # obj=None 반환 → Spy 인스턴스

print("\n=== 삭제 ===")
del c.value

고수 팁

1. 디스크립터 vs 프로퍼티

# @property: 각 클래스에 독립적, 재사용 불가
class Circle:
def __init__(self, radius: float):
self._radius = radius

@property
def radius(self) -> float:
return self._radius

@radius.setter
def radius(self, value: float) -> None:
if value <= 0:
raise ValueError("반지름은 양수여야 합니다.")
self._radius = value


# 디스크립터: 여러 클래스에서 재사용 가능
class PositiveFloat:
def __set_name__(self, owner, name: str):
self.name = name

def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f"_{self.name}", 0.0)

def __set__(self, obj, value: float) -> None:
if not isinstance(value, (int, float)) or value <= 0:
raise ValueError(f"{self.name}은 양수여야 합니다: {value}")
obj.__dict__[f"_{self.name}"] = float(value)


class Rectangle:
width = PositiveFloat() # 재사용
height = PositiveFloat() # 재사용

def __init__(self, width: float, height: float):
self.width = width
self.height = height


class Cylinder:
radius = PositiveFloat() # 동일 디스크립터 재사용
height = PositiveFloat()

def __init__(self, radius: float, height: float):
self.radius = radius
self.height = height

2. __slots__와 디스크립터

class SlottedDescriptor:
"""__slots__가 있는 클래스에서 사용하는 디스크립터"""

def __set_name__(self, owner, name: str):
self.name = name
# __slots__에 저장 공간 슬롯 자동 추가
slot_name = f"_{name}_slot"
if not hasattr(owner, "__slots__"):
raise TypeError("__slots__가 있는 클래스에서만 사용하세요.")

def __get__(self, obj, objtype=None):
if obj is None:
return self
try:
return object.__getattribute__(obj, f"_{self.name}_slot")
except AttributeError:
return None

def __set__(self, obj, value):
object.__setattr__(obj, f"_{self.name}_slot", value)

정리

메서드호출 시점반환값
__get__(self, obj, type)obj.attr 읽기속성 값 (obj=None이면 디스크립터 자신)
__set__(self, obj, value)obj.attr = value없음 (None)
__delete__(self, obj)del obj.attr없음 (None)
__set_name__(self, owner, name)클래스 정의 시없음 (None)

우선순위 규칙:

  1. 데이터 디스크립터 (__set__ 또는 __delete__ 있음)
  2. 인스턴스 __dict__
  3. 비데이터 디스크립터 (__get__만 있음)

@property는 데이터 디스크립터이므로 인스턴스 __dict__보다 항상 우선합니다.

Advertisement