본문으로 건너뛰기
Advertisement

프로퍼티 (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.deleterdel 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는 변하지 않습니다.

Advertisement