프로퍼티 (Property)
**프로퍼티(Property)**는 메서드를 속성처럼 접근할 수 있게 해주는 Python의 강력한 기능입니다. 데이터 유효성 검사, 계산된 속성, 접근 제어 등을 깔끔하게 구현할 수 있습니다. Python에서 가장 Python스러운(Pythonic) 캡슐화 방법입니다.
@property 데코레이터: getter
@property 데코레이터는 메서드를 읽기 전용 속성처럼 만듭니다. 호출 시 괄호가 필요 없습니다.
class Circle:
def __init__(self, radius: float):
self._radius = radius # 실제 데이터는 _radius에 저장
@property
def radius(self) -> float:
"""radius 속성의 getter"""
return self._radius
@property
def area(self) -> float:
"""계산된 프로퍼티 — getter만 있는 읽기 전용"""
import math
return math.pi * self._radius ** 2
@property
def circumference(self) -> float:
"""둘레"""
import math
return 2 * math.pi * self._radius
c = Circle(5)
print(c.radius) # 5 — 메서드지만 속성처럼 접근
print(c.area) # 78.539...
print(c.circumference) # 31.415...
# setter 없이 읽기 전용
try:
c.area = 100 # AttributeError!
except AttributeError as e:
print(f"오류: {e}") # can't set attribute
@prop.setter: setter
@property.setter를 사용하면 쓰기도 가능하게 만들면서 유효성 검사를 추가할 수 있습니다.
class Temperature:
def __init__(self, celsius: float):
self.celsius = celsius # setter를 통해 초기화 (유효성 검사 포함)
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError(f"절대 영도({value}°C)보다 낮은 온도는 없습니다.")
self._celsius = value
@property
def fahrenheit(self) -> float:
"""섭씨 ↔ 화씨 변환 — 읽기/쓰기 모두 지원"""
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value: float) -> None:
self.celsius = (value - 32) * 5/9 # celsius setter를 통해 유효성 검사
@property
def kelvin(self) -> float:
return self._celsius + 273.15
@kelvin.setter
def kelvin(self, value: float) -> None:
self.celsius = value - 273.15
def __repr__(self) -> str:
return f"Temperature({self._celsius}°C)"
t = Temperature(100)
print(t.celsius) # 100
print(t.fahrenheit) # 212.0
print(t.kelvin) # 373.15
# 화씨로 설정
t.fahrenheit = 32
print(t.celsius) # 0.0
# 켈빈으로 설정
t.kelvin = 0
print(t.celsius) # -273.15
# 유효성 검사
try:
t.celsius = -300 # ValueError!
except ValueError as e:
print(e)
@prop.deleter: deleter
@property.deleter는 del obj.prop 구문을 처리합니다.
class UserProfile:
def __init__(self, username: str, email: str):
self.username = username
self._email = email
self._phone: str | None = None
@property
def email(self) -> str:
return self._email
@email.setter
def email(self, value: str) -> None:
if "@" not in value:
raise ValueError(f"유효하지 않은 이메일: {value}")
self._email = value.lower().strip()
@email.deleter
def email(self) -> None:
print(f"{self.username}의 이메일을 삭제합니다.")
self._email = ""
@property
def phone(self) -> str | None:
return self._phone
@phone.setter
def phone(self, value: str | None) -> None:
if value is not None:
# 전화번호 정규화 (숫자만 추출)
digits = "".join(c for c in value if c.isdigit())
if len(digits) not in (10, 11):
raise ValueError(f"유효하지 않은 전화번호: {value}")
self._phone = digits
else:
self._phone = None
@phone.deleter
def phone(self) -> None:
self._phone = None
user = UserProfile("john", "JOHN@EXAMPLE.COM")
print(user.email) # john@example.com (소문자 정규화)
user.phone = "010-1234-5678"
print(user.phone) # 01012345678 (숫자만)
del user.email # john의 이메일을 삭제합니다.
print(user.email) # ""
유효성 검사 패턴
class Person:
def __init__(self, name: str, age: int, email: str):
# setter를 통해 유효성 검사를 일관성 있게 적용
self.name = name
self.age = age
self.email = email
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str) -> None:
value = value.strip()
if not value:
raise ValueError("이름은 빈 문자열일 수 없습니다.")
if len(value) > 50:
raise ValueError("이름은 50자 이하여야 합니다.")
self._name = value
@property
def age(self) -> int:
return self._age
@age.setter
def age(self, value: int) -> None:
if not isinstance(value, int):
raise TypeError(f"나이는 정수여야 합니다. 받은 값: {type(value)}")
if not (0 <= value <= 150):
raise ValueError(f"유효하지 않은 나이: {value}")
self._age = value
@property
def email(self) -> str:
return self._email
@email.setter
def email(self, value: str) -> None:
import re
value = value.strip().lower()
if not re.match(r"[^@]+@[^@]+\.[^@]+", value):
raise ValueError(f"유효하지 않은 이메일: {value}")
self._email = value
@property
def is_adult(self) -> bool:
"""계산된 읽기 전용 프로퍼티"""
return self._age >= 18
def __repr__(self) -> str:
return f"Person(name={self._name!r}, age={self._age}, email={self._email!r})"
p = Person("김철수", 25, "kim@example.com")
print(p)
print(p.is_adult) # True
# 유효성 검사 동작 확인
try:
p.age = -1
except ValueError as e:
print(f"오류: {e}")
try:
p.email = "invalid-email"
except ValueError as e:
print(f"오류: {e}")
계산된 프로퍼티 (Computed Property)
class Rectangle:
def __init__(self, width: float, height: float):
self.width = width
self.height = height
@property
def width(self) -> float:
return self._width
@width.setter
def width(self, value: float) -> None:
if value <= 0:
raise ValueError(f"너비는 양수여야 합니다: {value}")
self._width = value
@property
def height(self) -> float:
return self._height
@height.setter
def height(self, value: float) -> None:
if value <= 0:
raise ValueError(f"높이는 양수여야 합니다: {value}")
self._height = value
@property
def area(self) -> float:
"""계산된 프로퍼티 — width와 height가 변경될 때마다 재계산"""
return self._width * self._height
@property
def perimeter(self) -> float:
return 2 * (self._width + self._height)
@property
def diagonal(self) -> float:
return (self._width ** 2 + self._height ** 2) ** 0.5
@property
def is_square(self) -> bool:
return self._width == self._height
def __repr__(self) -> str:
return f"Rectangle({self._width} × {self._height})"
r = Rectangle(4, 3)
print(f"넓이: {r.area}") # 12.0
print(f"둘레: {r.perimeter}") # 14.0
print(f"대각선: {r.diagonal}") # 5.0
print(f"정사각형? {r.is_square}") # False
r.width = 5
print(f"변경 후 넓이: {r.area}") # 15.0 (자동으로 재계산)
캐싱 프로퍼티 (functools.cached_property)
Python 3.8+에서는 functools.cached_property를 사용하여 비용이 큰 계산을 한 번만 수행하고 캐싱할 수 있습니다.
from functools import cached_property
import time
class DataAnalyzer:
def __init__(self, data: list[float]):
self._data = data
@cached_property
def mean(self) -> float:
"""처음 접근 시에만 계산, 이후 캐싱"""
print("평균 계산 중...")
time.sleep(0.1) # 비용이 큰 연산 시뮬레이션
return sum(self._data) / len(self._data)
@cached_property
def variance(self) -> float:
print("분산 계산 중...")
mean = self.mean # 이미 캐싱됨
return sum((x - mean) ** 2 for x in self._data) / len(self._data)
@cached_property
def std_dev(self) -> float:
return self.variance ** 0.5
@property
def count(self) -> int:
"""캐싱 불필요한 단순 계산"""
return len(self._data)
data = DataAnalyzer([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0])
print(data.mean) # "평균 계산 중..." 출력 후 5.0
print(data.mean) # 캐싱됨 — 출력 없음, 즉시 반환
print(data.variance) # "분산 계산 중..." 출력
print(data.std_dev) # 이미 variance 캐싱됨
property vs __getattr__ / __setattr__
class FlexibleConfig:
"""__getattr__을 사용한 동적 속성 접근"""
def __init__(self, **kwargs):
self._data: dict = kwargs
def __getattr__(self, name: str):
"""정의되지 않은 속성 접근 시 호출"""
if name.startswith("_"):
raise AttributeError(name)
if name in self._data:
return self._data[name]
raise AttributeError(f"설정 없음: {name}")
def __setattr__(self, name: str, value) -> None:
"""모든 속성 설정 시 호출"""
if name.startswith("_"):
super().__setattr__(name, value) # 내부 속성은 일반 처리
else:
self._data[name] = value # 외부 속성은 _data에 저장
def __delattr__(self, name: str) -> None:
if name in self._data:
del self._data[name]
else:
super().__delattr__(name)
config = FlexibleConfig(host="localhost", port=8080, debug=True)
print(config.host) # localhost
print(config.port) # 8080
config.database = "mydb" # 동적으로 속성 추가
print(config.database) # mydb
del config.debug
try:
print(config.debug) # AttributeError
except AttributeError as e:
print(f"오류: {e}")
실전 예제: 온도 변환 클래스
class TemperatureConverter:
"""다양한 온도 단위를 지원하는 클래스"""
ABSOLUTE_ZERO_C = -273.15
def __init__(self, celsius: float = 0.0):
self.celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < self.ABSOLUTE_ZERO_C:
raise ValueError(
f"절대 영도({self.ABSOLUTE_ZERO_C}°C)보다 낮을 수 없습니다. 입력값: {value}"
)
self._celsius = float(value)
@property
def fahrenheit(self) -> float:
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value: float) -> None:
self.celsius = (value - 32) * 5/9
@property
def kelvin(self) -> float:
return self._celsius - self.ABSOLUTE_ZERO_C
@kelvin.setter
def kelvin(self, value: float) -> None:
if value < 0:
raise ValueError(f"켈빈 온도는 0 이상이어야 합니다: {value}")
self.celsius = value + self.ABSOLUTE_ZERO_C
@property
def rankine(self) -> float:
"""랭킨 온도 (열역학 온도 단위)"""
return self.fahrenheit + 459.67
@property
def description(self) -> str:
"""현재 온도에 대한 설명"""
c = self._celsius
if c < -10:
return "매우 춥습니다"
elif c < 10:
return "춥습니다"
elif c < 20:
return "서늘합니다"
elif c < 30:
return "따뜻합니다"
else:
return "덥습니다"
def __str__(self) -> str:
return (f"{self._celsius}°C / {self.fahrenheit:.1f}°F / "
f"{self.kelvin:.2f}K — {self.description}")
def __repr__(self) -> str:
return f"TemperatureConverter(celsius={self._celsius})"
# 사용 예시
t = TemperatureConverter(25)
print(t) # 25.0°C / 77.0°F / 298.15K — 따뜻합니다
t.fahrenheit = 32
print(t.celsius) # 0.0
t.kelvin = 373.15
print(t.celsius) # 100.00000000000003 (부동소수점 오차)
print(TemperatureConverter(-40)) # -40°C / -40.0°F / ... (-40도는 섭씨=화씨)
실전 예제: 나이 검증 클래스
from datetime import date
class PersonWithAge:
"""나이 유효성 검사와 계산된 프로퍼티를 갖는 클래스"""
def __init__(self, name: str, birth_date: date):
self.name = name
self.birth_date = birth_date
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str) -> None:
value = value.strip()
if not value:
raise ValueError("이름은 비어있을 수 없습니다.")
self._name = value
@property
def birth_date(self) -> date:
return self._birth_date
@birth_date.setter
def birth_date(self, value: date) -> None:
if not isinstance(value, date):
raise TypeError("birth_date는 date 객체여야 합니다.")
if value > date.today():
raise ValueError("미래 날짜는 생년월일로 사용할 수 없습니다.")
if value.year < 1900:
raise ValueError("1900년 이전 생년월일은 지원하지 않습니다.")
self._birth_date = value
@property
def age(self) -> int:
"""만 나이 계산"""
today = date.today()
years = today.year - self._birth_date.year
if (today.month, today.day) < (self._birth_date.month, self._birth_date.day):
years -= 1
return years
@property
def is_adult(self) -> bool:
return self.age >= 18
@property
def is_senior(self) -> bool:
return self.age >= 65
@property
def birth_year(self) -> int:
return self._birth_date.year
@property
def zodiac_sign(self) -> str:
"""서양 별자리"""
month = self._birth_date.month
day = self._birth_date.day
signs = [
(1, 20, "염소자리"), (2, 19, "물병자리"), (3, 20, "물고기자리"),
(4, 20, "양자리"), (5, 21, "황소자리"), (6, 21, "쌍둥이자리"),
(7, 23, "게자리"), (8, 23, "사자자리"), (9, 23, "처녀자리"),
(10, 23, "천칭자리"), (11, 22, "전갈자리"), (12, 22, "사수자리"),
(12, 31, "염소자리"),
]
for m, d, sign in signs:
if month < m or (month == m and day <= d):
return sign
return "염소자리"
def __str__(self) -> str:
adult_str = "성인" if self.is_adult else "미성년자"
return f"{self._name} ({self.age}세, {adult_str})"
p = PersonWithAge("김철수", date(1998, 7, 15))
print(p) # 김철수 (27세, 성인)
print(p.age) # 27
print(p.is_adult) # True
print(p.zodiac_sign) # 게자리
# 유효성 검사
try:
p.birth_date = date(2030, 1, 1) # 미래 날짜
except ValueError as e:
print(f"오류: {e}")
고수 팁
1. property를 클래스 정의 없이 직접 사용
# property()를 직접 사용하는 구식 방법 (이해를 위해)
class OldStyle:
def __init__(self, value):
self._value = value
def _get_value(self):
return self._value
def _set_value(self, v):
self._value = v
value = property(_get_value, _set_value)
# 위와 완전히 동일한 @property 방식 (권장)
class NewStyle:
def __init__(self, value):
self._value = value
@property
def value(self):
return self._value
@value.setter
def value(self, v):
self._value = v
2. Descriptor Protocol을 통한 재사용 가능한 프로퍼티
class PositiveNumber:
"""재사용 가능한 양수 검증 디스크립터"""
def __set_name__(self, owner, name):
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, 0)
def __set__(self, obj, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{self._name}은 숫자여야 합니다.")
if value <= 0:
raise ValueError(f"{self._name}은 양수여야 합니다: {value}")
setattr(obj, self._private_name, value)
class Product:
price = PositiveNumber() # 재사용
quantity = PositiveNumber() # 재사용
weight = PositiveNumber() # 재사용
def __init__(self, price: float, quantity: int, weight: float):
self.price = price
self.quantity = quantity
self.weight = weight
p = Product(9900, 10, 0.5)
print(p.price, p.quantity, p.weight)
try:
p.price = -100 # ValueError: price은 양수여야 합니다
except ValueError as e:
print(e)
3. __init__에서 setter를 통해 유효성 검사
class SafePoint:
def __init__(self, x: float, y: float):
# _x, _y를 직접 설정하지 않고 setter를 사용
self.x = x # → @x.setter 호출 → 유효성 검사
self.y = y # → @y.setter 호출 → 유효성 검사
@property
def x(self) -> float:
return self._x
@x.setter
def x(self, value: float) -> None:
if not isinstance(value, (int, float)):
raise TypeError("x는 숫자여야 합니다.")
self._x = float(value)
@property
def y(self) -> float:
return self._y
@y.setter
def y(self, value: float) -> None:
if not isinstance(value, (int, float)):
raise TypeError("y는 숫자여야 합니다.")
self._y = float(value)
정리
| 방식 | 사용 시기 |
|---|---|
@property (getter만) | 읽기 전용 계산된 속성 |
@prop.setter | 쓰기 시 유효성 검사 필요 |
@prop.deleter | 삭제 시 정리 작업 필요 |
cached_property | 비용이 큰 계산, 한 번만 계산하면 됨 |
__getattr__ | 동적 속성, 없는 속성 접근 처리 |
__setattr__ | 모든 속성 설정을 가로채야 할 때 |
Property는 Python OOP의 핵심 도구입니다. 단순히 인스턴스 변수를 사용하다가 나중에 유효성 검사가 필요해지면 @property로 감싸면 됩니다 — 외부 API는 변하지 않습니다.