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 확장에 대해 알아보겠습니다.
'개발 언어 > Python' 카테고리의 다른 글
Fabric.js 튜토리얼 1부 (3) | 2025.05.31 |
---|---|
10강: 파이썬 확장 (C Extensions 소개) - 파이썬 고급편 (6) | 2025.05.30 |
8강: 타입 힌트와 정적 분석 - 파이썬 고급편 (18) | 2025.05.29 |
7강: 고급 데이터 구조 및 collections 모듈 - 파이썬 고급편 (0) | 2025.05.29 |
6강: 병행성 vs 병렬성 - 파이썬 고급편 (5) | 2025.05.28 |