본문 바로가기
개발 언어/Python

9강: 디스크립터와 property - 파이썬 고급편

by 주호파파 2025. 5. 30.
728x90
반응형

 

9강: 속성 접근 제어의 비밀 - 디스크립터와 property

개요 및 중요성

파이썬에서 객체의 속성에 접근할 때 일어나는 내부 동작을 이해하고 제어하는 것은 고급 프로그래밍의 핵심입니다. 디스크립터 프로토콜은 속성 접근(get, set, delete)을 커스터마이징할 수 있는 강력한 메커니즘을 제공하며, property() 함수는 이를 쉽게 활용할 수 있는 내장 도구입니다.

핵심 포인트: 디스크립터는 파이썬의 객체 시스템 핵심에 있으며, 메서드, property, classmethod, staticmethod 모두 디스크립터를 기반으로 구현됩니다.

디스크립터 프로토콜 이해하기

디스크립터는 __get__, __set__, __delete__ 메서드 중 하나 이상을 정의한 객체입니다.

# 기본 디스크립터 예시
class LoggingDescriptor:
    """속성 접근을 로깅하는 디스크립터"""
    
    def __init__(self, name: str = "unknown") -> None:
        self.name = name
        self.value = None
    
    def __get__(self, instance, owner_class):
        """속성 읽기 시 호출"""
        if instance is None:
            # 클래스에서 직접 접근하는 경우
            return self
        
        print(f"[GET] {self.name} 속성을 읽습니다: {self.value}")
        return self.value
    
    def __set__(self, instance, value):
        """속성 설정 시 호출"""
        print(f"[SET] {self.name} 속성을 {self.value}에서 {value}로 변경합니다")
        self.value = value
    
    def __delete__(self, instance):
        """속성 삭제 시 호출"""
        print(f"[DELETE] {self.name} 속성을 삭제합니다")
        self.value = None

# 디스크립터를 사용하는 클래스
class Person:
    # 클래스 변수로 디스크립터 인스턴스 할당
    name = LoggingDescriptor("name")
    age = LoggingDescriptor("age")
    
    def __init__(self):
        # 인스턴스 생성 시 초기값 설정하지 않음
        # 디스크립터가 값을 관리함
        pass

# 사용 예시
person = Person()
person.name = "김철수"      # [SET] 호출
print(person.name)         # [GET] 호출
person.age = 30           # [SET] 호출
print(person.age)         # [GET] 호출

# 다른 인스턴스 생성
person2 = Person()
person2.name = "이영희"
print(f"person1: {person.name}, person2: {person2.name}")  # 같은 디스크립터 인스턴스 공유 문제!

인스턴스별 데이터 저장 문제 해결

위 예시에서 모든 인스턴스가 같은 디스크립터 인스턴스를 공유하는 문제를 해결해보겠습니다.

# 개선된 디스크립터 - 인스턴스별 데이터 저장
class ImprovedDescriptor:
    """인스턴스별로 독립적인 값을 저장하는 디스크립터"""
    
    def __init__(self, name: str) -> None:
        self.name = name
        self.private_name = f'__{name}'  # 내부 저장용 속성명
    
    def __get__(self, instance, owner_class):
        if instance is None:
            return self
        
        # 인스턴스의 __dict__에서 값 가져오기
        value = getattr(instance, self.private_name, None)
        print(f"[GET] {self.name} = {value}")
        return value
    
    def __set__(self, instance, value):
        print(f"[SET] {self.name} = {value}")
        # 인스턴스의 __dict__에 값 저장
        setattr(instance, self.private_name, value)
    
    def __delete__(self, instance):
        print(f"[DELETE] {self.name}")
        if hasattr(instance, self.private_name):
            delattr(instance, self.private_name)

class ImprovedPerson:
    name = ImprovedDescriptor("name")
    age = ImprovedDescriptor("age")

# 테스트
person1 = ImprovedPerson()
person2 = ImprovedPerson()

person1.name = "김철수"
person2.name = "이영희"

print(f"Person1: {person1.name}")  # 김철수
print(f"Person2: {person2.name}")  # 이영희 - 독립적으로 저장됨!

유효성 검사 디스크립터

디스크립터의 가장 유용한 활용 중 하나는 속성값의 유효성 검사입니다.

class ValidatedDescriptor:
    """유효성 검사가 포함된 디스크립터 기본 클래스"""
    
    def __init__(self, name: str = None) -> None:
        self.name = name
        self.private_name = f'_{name}' if name else '_value'
    
    def __set_name__(self, owner, name):
        """Python 3.6+에서 자동으로 호출되는 메서드"""
        if self.name is None:
            self.name = name
            self.private_name = f'_{name}'
    
    def __get__(self, instance, owner_class):
        if instance is None:
            return self
        return getattr(instance, self.private_name, None)
    
    def __set__(self, instance, value):
        # 유효성 검사 수행
        validated_value = self.validate(value)
        setattr(instance, self.private_name, validated_value)
    
    def validate(self, value):
        """서브클래스에서 오버라이드할 유효성 검사 메서드"""
        return value

class PositiveNumber(ValidatedDescriptor):
    """양수만 허용하는 디스크립터"""
    
    def validate(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f"{self.name}은 숫자여야 합니다")
        if value <= 0:
            raise ValueError(f"{self.name}은 양수여야 합니다")
        return value

class NonEmptyString(ValidatedDescriptor):
    """비어있지 않은 문자열만 허용하는 디스크립터"""
    
    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError(f"{self.name}은 문자열이어야 합니다")
        if not value.strip():
            raise ValueError(f"{self.name}은 비어있을 수 없습니다")
        return value.strip()

class EmailAddress(ValidatedDescriptor):
    """간단한 이메일 형식 검사 디스크립터"""
    
    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError(f"{self.name}은 문자열이어야 합니다")
        
        value = value.strip()
        if '@' not in value or '.' not in value.split('@')[-1]:
            raise ValueError(f"{self.name}은 유효한 이메일 형식이어야 합니다")
        
        return value

class BoundedInteger(ValidatedDescriptor):
    """범위가 제한된 정수 디스크립터"""
    
    def __init__(self, name: str = None, min_value: int = 0, max_value: int = 100) -> None:
        super().__init__(name)
        self.min_value = min_value
        self.max_value = max_value
    
    def validate(self, value):
        if not isinstance(value, int):
            raise TypeError(f"{self.name}은 정수여야 합니다")
        if not (self.min_value <= value <= self.max_value):
            raise ValueError(
                f"{self.name}은 {self.min_value}과 {self.max_value} 사이의 값이어야 합니다"
            )
        return value

# 유효성 검사가 포함된 클래스
class User:
    name = NonEmptyString()
    email = EmailAddress()
    age = BoundedInteger(min_value=0, max_value=150)
    salary = PositiveNumber()
    
    def __init__(self, name: str, email: str, age: int, salary: float):
        self.name = name
        self.email = email
        self.age = age
        self.salary = salary
    
    def __str__(self):
        return f"User(name='{self.name}', email='{self.email}', age={self.age}, salary={self.salary})"

# 유효성 검사 테스트
try:
    user = User("김철수", "kim@example.com", 30, 50000.0)
    print(f"유효한 사용자 생성: {user}")
    
    # 잘못된 값 설정 시도
    print("\n유효성 검사 테스트:")
    
    try:
        user.name = ""  # 빈 문자열
    except ValueError as e:
        print(f"이름 오류: {e}")
    
    try:
        user.email = "invalid-email"  # 잘못된 이메일
    except ValueError as e:
        print(f"이메일 오류: {e}")
    
    try:
        user.age = 200  # 범위 초과
    except ValueError as e:
        print(f"나이 오류: {e}")
    
    try:
        user.salary = -1000  # 음수
    except ValueError as e:
        print(f"급여 오류: {e}")

except Exception as e:
    print(f"사용자 생성 실패: {e}")

property() 함수 깊이 파기

property() 함수는 디스크립터 프로토콜을 간편하게 사용할 수 있도록 해주는 내장 함수입니다.

# property를 사용한 온도 변환 클래스
class Temperature:
    def __init__(self, celsius: float = 0.0):
        self._celsius = celsius
    
    # 게터 (getter)
    @property
    def celsius(self) -> float:
        """섭씨 온도 반환"""
        return self._celsius
    
    # 세터 (setter)
    @celsius.setter
    def celsius(self, value: float) -> None:
        """섭씨 온도 설정"""
        if not isinstance(value, (int, float)):
            raise TypeError("온도는 숫자여야 합니다")
        if value < -273.15:
            raise ValueError("절대영도(-273.15°C) 이하의 온도는 불가능합니다")
        self._celsius = float(value)
    
    # 델리터 (deleter)
    @celsius.deleter
    def celsius(self) -> None:
        """온도 초기화"""
        print("온도를 0°C로 초기화합니다")
        self._celsius = 0.0
    
    # 화씨 온도 (계산된 속성)
    @property
    def fahrenheit(self) -> float:
        """섭씨를 화씨로 변환하여 반환"""
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value: float) -> None:
        """화씨 온도를 섭씨로 변환하여 저장"""
        if not isinstance(value, (int, float)):
            raise TypeError("온도는 숫자여야 합니다")
        celsius_value = (value - 32) * 5/9
        self.celsius = celsius_value  # 섭씨 세터를 통해 유효성 검사 수행
    
    # 켈빈 온도 (계산된 속성)
    @property
    def kelvin(self) -> float:
        """섭씨를 켈빈으로 변환하여 반환"""
        return self._celsius + 273.15
    
    @kelvin.setter
    def kelvin(self, value: float) -> None:
        """켈빈 온도를 섭씨로 변환하여 저장"""
        if not isinstance(value, (int, float)):
            raise TypeError("온도는 숫자여야 합니다")
        celsius_value = value - 273.15
        self.celsius = celsius_value
    
    def __str__(self) -> str:
        return f"{self._celsius}°C ({self.fahrenheit:.1f}°F, {self.kelvin:.1f}K)"

# property 사용 예시
temp = Temperature(25)
print(f"초기 온도: {temp}")

# 다양한 온도 단위로 설정
temp.fahrenheit = 100  # 화씨 100도를 섭씨로 변환하여 저장
print(f"화씨 100도 설정 후: {temp}")

temp.kelvin = 300      # 켈빈 300도를 섭씨로 변환하여 저장
print(f"켈빈 300도 설정 후: {temp}")

# 잘못된 값 설정 시도
try:
    temp.celsius = -300  # 절대영도 이하
except ValueError as e:
    print(f"오류: {e}")

# 속성 삭제
del temp.celsius
print(f"삭제 후: {temp}")

property()의 함수형 사용법

# property()를 데코레이터가 아닌 함수로 사용하는 방법
class Circle:
    def __init__(self, radius: float):
        self._radius = radius
    
    def get_radius(self) -> float:
        """반지름 게터"""
        return self._radius
    
    def set_radius(self, value: float) -> None:
        """반지름 세터"""
        if value <= 0:
            raise ValueError("반지름은 양수여야 합니다")
        self._radius = value
    
    def del_radius(self) -> None:
        """반지름 삭제"""
        print("반지름을 1로 초기화합니다")
        self._radius = 1.0
    
    # property() 함수로 프로퍼티 생성
    radius = property(get_radius, set_radius, del_radius, "원의 반지름")
    
    @property
    def diameter(self) -> float:
        """지름 (읽기 전용 계산된 속성)"""
        return self._radius * 2
    
    @property
    def area(self) -> float:
        """면적 (읽기 전용 계산된 속성)"""
        return 3.14159 * self._radius ** 2
    
    @property
    def circumference(self) -> float:
        """둘레 (읽기 전용 계산된 속성)"""
        return 2 * 3.14159 * self._radius

# 원 클래스 사용
circle = Circle(5.0)
print(f"반지름: {circle.radius}")
print(f"지름: {circle.diameter}")
print(f"면적: {circle.area:.2f}")
print(f"둘레: {circle.circumference:.2f}")

circle.radius = 10.0
print(f"\n반지름 변경 후:")
print(f"반지름: {circle.radius}")
print(f"지름: {circle.diameter}")
print(f"면적: {circle.area:.2f}")

실습: 스마트 캐싱 디스크립터

디스크립터와 property를 조합하여 계산 결과를 캐싱하고 의존성을 관리하는 고급 시스템을 구축해보겠습니다.

import time
import functools
from typing import Any, Set, Dict, Callable
from weakref import WeakKeyDictionary

class CachedProperty:
    """계산 결과를 캐싱하는 프로퍼티 디스크립터"""
    
    def __init__(self, func: Callable) -> None:
        self.func = func
        self.name = func.__name__
        self.cache: WeakKeyDictionary = WeakKeyDictionary()
        self.__doc__ = func.__doc__
    
    def __get__(self, instance, owner_class):
        if instance is None:
            return self
        
        # 캐시에서 값 확인
        if instance in self.cache:
            print(f"[CACHE HIT] {self.name} 캐시된 값 반환")
            return self.cache[instance]
        
        # 계산 수행 및 캐시 저장
        print(f"[CACHE MISS] {self.name} 계산 수행")
        result = self.func(instance)
        self.cache[instance] = result
        return result
    
    def __set__(self, instance, value):
        # 캐시 무효화
        if instance in self.cache:
            print(f"[CACHE CLEAR] {self.name} 캐시 무효화")
            del self.cache[instance]
        
        # 실제 값은 설정하지 않음 (읽기 전용)
        raise AttributeError(f"{self.name}은 읽기 전용 속성입니다")
    
    def clear_cache(self, instance=None):
        """캐시 수동 클리어"""
        if instance:
            self.cache.pop(instance, None)
        else:
            self.cache.clear()

class DependentProperty(CachedProperty):
    """다른 속성에 의존하는 캐싱 프로퍼티"""
    
    def __init__(self, func: Callable, depends_on: Set[str] = None) -> None:
        super().__init__(func)
        self.depends_on = depends_on or set()
        self.dependent_properties: Set[str] = set()
    
    def add_dependent(self, property_name: str) -> None:
        """이 속성에 의존하는 다른 속성 추가"""
        self.dependent_properties.add(property_name)

class SmartCaching:
    """의존성 관리가 포함된 스마트 캐싱 믹스인"""
    
    def __init__(self):
        self._property_dependencies: Dict[str, Set[str]] = {}
        self._setup_dependencies()
    
    def _setup_dependencies(self):
        """클래스의 의존성 관계 설정"""
        for name in dir(self.__class__):
            attr = getattr(self.__class__, name)
            if isinstance(attr, DependentProperty):
                self._property_dependencies[name] = attr.depends_on
    
    def __setattr__(self, name: str, value: Any) -> None:
        # 기본 설정 수행
        super().__setattr__(name, value)
        
        # 의존성이 있는 속성들의 캐시 무효화
        if hasattr(self, '_property_dependencies'):
            self._invalidate_dependent_caches(name)
    
    def _invalidate_dependent_caches(self, changed_property: str) -> None:
        """변경된 속성에 의존하는 모든 캐시 무효화"""
        for prop_name, dependencies in self._property_dependencies.items():
            if changed_property in dependencies:
                prop_descriptor = getattr(self.__class__, prop_name)
                if isinstance(prop_descriptor, CachedProperty):
                    prop_descriptor.clear_cache(self)
                    print(f"[DEPENDENCY] {changed_property} 변경으로 {prop_name} 캐시 무효화")

# 복잡한 계산을 포함한 예제 클래스
class DataProcessor(SmartCaching):
    """데이터 처리 클래스 (캐싱과 의존성 관리 포함)"""
    
    def __init__(self, data: list):
        self._data = data
        super().__init__()
    
    @property
    def data(self) -> list:
        return self._data
    
    @data.setter
    def data(self, value: list) -> None:
        self._data = value
    
    @CachedProperty
    def total(self) -> float:
        """합계 계산 (시간 소모 시뮬레이션)"""
        print("  → 합계 계산 중...")
        time.sleep(0.1)  # 무거운 계산 시뮬레이션
        return sum(self._data)
    
    @DependentProperty(depends_on={'_data'})
    def average(self) -> float:
        """평균 계산 (total에 의존)"""
        print("  → 평균 계산 중...")
        time.sleep(0.1)
        return self.total / len(self._data) if self._data else 0
    
    @DependentProperty(depends_on={'_data'})
    def variance(self) -> float:
        """분산 계산 (average에 의존)"""
        print("  → 분산 계산 중...")
        time.sleep(0.1)
        if not self._data:
            return 0
        avg = self.average
        return sum((x - avg) ** 2 for x in self._data) / len(self._data)
    
    @DependentProperty(depends_on={'_data'})
    def standard_deviation(self) -> float:
        """표준편차 계산 (variance에 의존)"""
        print("  → 표준편차 계산 중...")
        time.sleep(0.1)
        return self.variance ** 0.5
    
    @DependentProperty(depends_on={'_data'})
    def summary_stats(self) -> dict:
        """요약 통계 (모든 통계값에 의존)"""
        print("  → 요약 통계 계산 중...")
        return {
            'count': len(self._data),
            'total': self.total,
            'average': self.average,
            'variance': self.variance,
            'std_dev': self.standard_deviation,
            'min': min(self._data) if self._data else None,
            'max': max(self._data) if self._data else None
        }

# 스마트 캐싱 테스트
print("=== 스마트 캐싱 디스크립터 테스트 ===")

# 초기 데이터로 객체 생성
processor = DataProcessor([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])

print("\n1. 첫 번째 계산 (모든 값 계산)")
start_time = time.time()
stats = processor.summary_stats
print(f"통계: {stats}")
first_time = time.time() - start_time

print(f"\n첫 번째 계산 소요 시간: {first_time:.3f}초")

print("\n2. 두 번째 계산 (캐시 사용)")
start_time = time.time()
stats = processor.summary_stats
print(f"통계: {stats}")
second_time = time.time() - start_time

print(f"두 번째 계산 소요 시간: {second_time:.3f}초")
print(f"캐싱으로 인한 속도 향상: {first_time/second_time:.1f}배")

print("\n3. 데이터 변경 (의존성 기반 캐시 무효화)")
processor.data = [10, 20, 30, 40, 50]

print("\n4. 변경 후 계산 (캐시 재생성)")
start_time = time.time()
new_stats = processor.summary_stats
print(f"새로운 통계: {new_stats}")
third_time = time.time() - start_time

print(f"데이터 변경 후 계산 시간: {third_time:.3f}초")

코드 실행 결과

=== 스마트 캐싱 디스크립터 테스트 ===

1. 첫 번째 계산 (모든 값 계산)
[CACHE MISS] summary_stats 계산 수행
  → 요약 통계 계산 중...
[CACHE MISS] total 계산 수행
  → 합계 계산 중...
[CACHE MISS] average 계산 수행
  → 평균 계산 중...
[CACHE HIT] total 캐시된 값 반환
[CACHE MISS] variance 계산 수행
  → 분산 계산 중...
[CACHE HIT] average 캐시된 값 반환
[CACHE MISS] standard_deviation 계산 수행
  → 표준편차 계산 중...
[CACHE HIT] variance 캐시된 값 반환

첫 번째 계산 소요 시간: 0.523초

2. 두 번째 계산 (캐시 사용)
[CACHE HIT] summary_stats 캐시된 값 반환

두 번째 계산 소요 시간: 0.001초
캐싱으로 인한 속도 향상: 523.0배

마무리

디스크립터 프로토콜은 파이썬 객체 시스템의 핵심 메커니즘으로, 속성 접근을 세밀하게 제어할 수 있게 해줍니다. 기본 디스크립터를 통한 유효성 검사부터, property()를 활용한 계산된 속성, 그리고 고급 캐싱 시스템까지 다양한 수준에서 활용할 수 있습니다.

특히 성능이 중요한 애플리케이션에서 캐싱과 의존성 관리를 통해 계산 비용을 크게 줄일 수 있습니다. 다음 강의에서는 파이썬의 성능 한계를 돌파하는 C 확장에 대해 알아보겠습니다.

728x90
반응형