디스크립터
1. 왜 디스크립터가 필요할까
'클래스 심화' 장에서 배운 @property를 떠올려봅시다. 나이가 음수가 되면 안 되는 상황에서 이렇게 썼습니다.
class Person:
def __init__(self, age):
self._age = 0
self.age = age
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if value < 0:
raise ValueError('나이는 음수일 수 없습니다.')
self._age = value
그런데 만약 Student, Teacher, Customer 클래스에도 똑같이 "나이는 음수면 안 됨" 규칙을 적용하고 싶다면 어떻게 할까요? 매번 위와 같은 @property 코드를 복사해서 붙여넣어야 합니다. 규칙이 바뀌면 모든 클래스를 수정해야 하죠.
이럴 때 같은 규칙을 클래스로 따로 만들어두고 여러 곳에서 재사용 할 수 있다면 편할 것 같습니다. 그게 바로 디스크립터입니다.
2. 디스크립터란
디스크립터는 아래 메서드 중 하나 이상을 정의한 클래스입니다.
__get__(self, instance, owner): 속성을 읽을 때 호출됩니다.__set__(self, instance, value): 속성에 값을 할당할 때 호출됩니다.__delete__(self, instance):del키워드로 속성을 삭제할 때 호출됩니다.
이 디스크립터 클래스를 다른 클래스의 클래스 변수로 선언하면, 그 속성에 접근할 때마다 위 메서드들이 자동으로 불립니다.
3. 아주 간단한 예제부터
먼저 아무 검증도 없이, 속성을 읽고 쓸 때 그냥 메시지만 출력하는 디스크립터를 만들어 보겠습니다.
class Age:
def __get__(self, instance, owner):
print('나이를 읽습니다.')
return instance._age
def __set__(self, instance, value):
print(f'나이를 {value}로 설정합니다.')
instance._age = value
class Person:
age = Age() # 클래스 변수로 디스크립터를 선언
def __init__(self, age):
self.age = age # 여기서 Age의 __set__이 호출됨
licat = Person(7)
print(licat.age) # Age의 __get__이 호출됨
licat.age = 8 # Age의 __set__이 호출됨
포인트를 하나씩 짚어보겠습니다.
Age는__get__과__set__을 정의한 클래스입니다. 즉 디스크립터입니다.Person클래스는age = Age()라고 클래스 변수로 디스크립터를 선언했습니다.licat.age = 8처럼 값을 할당하면, 파이썬은 일반적인 할당 대신Age의__set__을 호출합니다.- 실제 값은 인스턴스의
_age라는 속성에 저장합니다.age에 바로 저장하려 하면 다시__set__이 호출되어 무한 반복에 빠지기 때문입니다.
_age처럼 앞에 언더스코어를 붙인 이름을 쓰는 이유는 "실제 값을 담아두는 내부 저장소" 라는 관례를 따르기 위함입니다. @property에서 self._age를 썼던 것과 같은 이유입니다.
4. 검증 로직이 있는 디스크립터
이번에는 __set__에서 나이는 음수일 수 없다 는 검증을 추가해 보겠습니다.
class Age:
def __get__(self, instance, owner):
return instance._age
def __set__(self, instance, value):
if value < 0:
raise ValueError('나이는 음수일 수 없습니다.')
instance._age = value
class Person:
age = Age()
def __init__(self, age):
self.age = age
licat = Person(7)
print(licat.age) # 7
# licat.age = -1 # ValueError: 나이는 음수일 수 없습니다.
여기까지는 @property로 할 수 있는 일과 거의 같아 보입니다. 디스크립터의 진짜 장점은 재사용 에서 나옵니다.
5. 재사용이 주는 힘
학생(Student)과 선생님(Teacher) 클래스에 똑같은 "나이는 음수일 수 없다" 규칙을 적용해 봅시다. 위에서 만든 Age 디스크립터를 그대로 재사용 할 수 있습니다.
class Age:
def __get__(self, instance, owner):
return instance._age
def __set__(self, instance, value):
if value < 0:
raise ValueError('나이는 음수일 수 없습니다.')
instance._age = value
class Student:
age = Age()
def __init__(self, name, age):
self.name = name
self.age = age
class Teacher:
age = Age()
def __init__(self, name, age):
self.name = name
self.age = age
s = Student('licat', 7)
t = Teacher('mura', 30)
print(s.name, s.age) # licat 7
print(t.name, t.age) # mura 30
@property 방식이었다면 Student와 Teacher 양쪽에 getter/setter 메서드를 똑같이 써야 합니다. 디스크립터를 쓰면 age = Age() 한 줄이면 끝입니다. 규칙이 바뀌어도 Age 클래스 한 곳만 고치면 됩니다.
6. 점수 디스크립터 만들어보기
이번엔 0점 ~ 100점만 허용하는 Score 디스크립터를 만들어 봅시다. 국어, 영어, 수학 점수에 모두 같은 규칙을 적용합니다.
class Score:
def __init__(self, subject):
self.subject = subject # 저장할 이름을 구분하기 위한 라벨
def __get__(self, instance, owner):
return getattr(instance, f'_{self.subject}')
def __set__(self, instance, value):
if not (0 <= value <= 100):
raise ValueError(f'{self.subject} 점수는 0~100 사이여야 합니다.')
setattr(instance, f'_{self.subject}', value)
class Student:
korean = Score('korean')
english = Score('english')
math = Score('math')
def __init__(self, korean, english, math):
self.korean = korean
self.english = english
self.math = math
s = Student(90, 85, 100)
print(s.korean, s.english, s.math) # 90 85 100
# s.math = 120 # ValueError: math 점수는 0~100 사이여야 합니다.
여기서는 속성마다 저장 위치가 달라야 해서 Score('korean')처럼 과목 이름을 직접 넘겨줬습니다. getattr과 setattr은 문자열 이름으로 속성을 읽고 쓰는 내장 함수입니다.
매번 Score('korean')처럼 이름을 적는 게 번거롭다면 __set_name__이라는 메서드를 쓰면 자동으로 할당된 이름이 넘어옵니다. 여기서는 개념에 집중하기 위해 명시적으로 적었습니다.
7. @property와의 비교
@property는 사실 파이썬에 내장된 디스크립터입니다. 두 방식은 이렇게 비교할 수 있습니다.
| 상황 | 추천 |
|---|---|
| 한 클래스의 한두 속성에만 검증이 필요 | @property |
| 같은 검증을 여러 속성이나 여러 클래스에서 재사용 | 디스크립터 |
| ORM의 필드처럼 "선언만으로 동작"하게 만들고 싶다 | 디스크립터 |
Django의 models.CharField, models.IntegerField나 SQLAlchemy의 컬럼 정의도 모두 디스크립터로 만들어져 있습니다. 프레임워크가 클래스 변수 선언만으로 복잡한 동작을 끼워 넣는 원리가 바로 디스크립터입니다.
8. 정리
- 디스크립터는
__get__,__set__,__delete__중 하나 이상을 정의한 클래스입니다. - 다른 클래스의 클래스 변수로 선언하면 그 속성의 읽기/쓰기 동작이 디스크립터의 메서드로 연결됩니다.
- 값은 보통
instance._이름같은 내부 속성에 저장합니다. @property로 반복되던 getter/setter 로직을 여러 속성, 여러 클래스에서 재사용 할 수 있다는 점이 가장 큰 장점입니다.- 속성 하나에만 적용된다면
@property로 충분합니다. 중복이 생기기 시작할 때 디스크립터를 꺼내 쓰세요.