본문 바로가기

디스크립터

디스크립터(descriptor)는 '__get__', '__set__' 같은 특별한 메서드를 정의한 클래스로, 다른 클래스의 속성에 값을 넣거나 꺼낼 때의 동작을 대신해줍니다. 같은 검증 로직을 여러 속성에서 재사용하고 싶을 때 유용합니다.

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로 충분합니다. 중복이 생기기 시작할 때 디스크립터를 꺼내 쓰세요.
디스크립터 - 견고한 파이썬 | 위니버시티