파이썬 속성 조회 순서: 데이터 디스크립터가 인스턴스 __dict__를 이기는 이유와 __set_name__
obj.x는 인스턴스 __dict__보다 데이터 디스크립터를 먼저 본다. __set__이나 __delete__ 정의 여부로 갈리는 조회 순서, cached_property가 덮어써지는 이유, 클래스 생성 뒤 붙인 디스크립터에서 __set_name__이 호출되지 않는 경우를 정리했다.
obj.__dict__["x"]에 값을 직접 넣었는데 obj.x가 그 값을 돌려주지 않는 경우가 있다. 반대로 functools.cached_property로 만든 속성은 인스턴스에 같은 이름을 대입하면 그대로 덮어써진다. 둘 다 디스크립터인데 결과가 다른 이유는 __set__(또는 __delete__)을 정의했느냐 하나로 갈린다.
이 글은 Python 3.14 문서(페이지 표기 3.14.8)의 데이터 모델 레퍼런스, Descriptor Guide, functools 문서를 기준으로 하며, 모두 2026-10-19에 확인했다. 예제 출력은 실행해서 얻은 것이 아니라, 문서에 적힌 조회 규칙을 코드에 따라 한 줄씩 적용해 적은 것이다. 이 글의 조회 순서는 기본 object.__getattribute__()를 쓰는 인스턴스 기준이다. __getattribute__()를 재정의한 클래스는 이 순서를 바꾸거나 건너뛸 수 있고, 재정의하더라도 object.__getattribute__()에 위임하면 같은 순서를 유지할 수 있다.
디스크립터와 두 종류
데이터 모델 레퍼런스는 __get__(), __set__(), __delete__() 중 하나라도 정의한 객체를 디스크립터라고 부른다. 그중 __set__()이나 __delete__()를 정의하면 데이터 디스크립터, 둘 다 없으면 비데이터 디스크립터다.
디스크립터는 클래스 변수로 있을 때만 동작한다. Descriptor Guide는 인스턴스에 넣어 둔 디스크립터는 아무 효과가 없다고 적는다. 그래서 아래의 조회 순서는 “인스턴스 __dict__에 있는 값”과 “클래스(와 MRO의 부모 클래스)에 있는 디스크립터” 사이의 우선순위 이야기다.
인스턴스 조회 순서
Descriptor Guide는 인스턴스 조회를 이렇게 요약한다. 데이터 디스크립터가 가장 먼저이고, 그다음 인스턴스 변수, 비데이터 디스크립터, 클래스 변수 순이며, 마지막으로 __getattr__()이 정의되어 있으면 그것을 부른다.
같은 문서에 실린 object_getattribute()의 순수 파이썬 버전을 줄이면 순서가 코드로 보인다.
type(obj).__mro__를 따라 클래스 쪽에서 이름을 찾는다.- 찾은 값의 타입에
__get__이 있고__set__이나__delete__도 있으면(데이터 디스크립터) 바로__get__(obj, type(obj))을 호출해 돌려준다. 인스턴스__dict__는 보지 않는다. - 아니면 인스턴스
__dict__에 이름이 있는지 보고, 있으면 그 값을 돌려준다. - 클래스 쪽 값이 비데이터 디스크립터라면
__get__을 호출한다. - 그냥 클래스 변수라면 그 값을 돌려준다.
- 모두 없으면
AttributeError를 낸다.
__getattr__()은 이 함수 안에 없다. 같은 문서에 따르면 점 연산자와 getattr()이 __getattribute__()가 AttributeError를 냈을 때 __getattr__()을 부른다. 그래서 __getattribute__()를 직접 호출하면 __getattr__()은 건너뛴다.
데이터 모델 레퍼런스도 같은 결론을 적는다. __get__()과 __set__()(또는 __delete__())를 정의한 데이터 디스크립터는 인스턴스 딕셔너리의 재정의를 항상 이기고, 비데이터 디스크립터는 인스턴스가 덮어쓸 수 있다. 일반 메서드와 @staticmethod, @classmethod는 비데이터 디스크립터이고 @property는 데이터 디스크립터다. 인스턴스가 메서드는 덮어쓸 수 있어도 property의 동작은 덮어쓸 수 없는 이유다.
예제
아래 파일 하나로 이 글의 동작을 모두 확인할 수 있다. Field는 __set__이 있는 데이터 디스크립터로, 정수만 받고 값은 인스턴스의 _이름 속성에 저장한다. Lazy는 __get__만 있는 비데이터 디스크립터로, 처음 읽을 때 값을 계산해 인스턴스 __dict__에 같은 이름으로 넣는다. functools.cached_property와 비슷한 방식이지만 설명용으로 단순하게 만든 것이다.
class Field:
def __set_name__(self, owner, name):
print(f"__set_name__({owner.__name__}, {name!r})")
self.name = name
self.storage = "_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.storage)
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} must be int")
setattr(obj, self.storage, value)
class Lazy:
def __init__(self, func):
self.func = func
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
print(f"computing {self.name}")
value = self.func(obj)
obj.__dict__[self.name] = value
return value
print("defining Order")
class Order:
qty = Field()
@Lazy
def label(self):
return f"order x{self.qty}"
def __init__(self, qty):
self.qty = qty
print("== 1 lazy")
o = Order(3)
print(o.label)
print(o.label)
print(vars(o))
print("== 2 data descriptor vs __dict__")
o.__dict__["qty"] = "bad"
print(o.qty)
try:
o.qty = "x"
except TypeError as e:
print("TypeError:", e)
print("== 3 non-data descriptor vs __dict__")
o.label = "manual"
print(o.label)
del o.label
print(o.label)
print("== 4 added after class creation")
late = Field()
Order.extra = late
try:
o.extra = 1
except AttributeError as e:
print("AttributeError:", e)
late.__set_name__(Order, "extra")
o.extra = 1
print(o.extra)
위 규칙대로라면 출력은 다음과 같다. 마지막 AttributeError 메시지의 정확한 문구는 인터프리터 버전에 따라 다를 수 있다.
defining Order
__set_name__(Order, 'qty')
== 1 lazy
computing label
order x3
order x3
{'_qty': 3, 'label': 'order x3'}
== 2 data descriptor vs __dict__
3
TypeError: qty must be int
== 3 non-data descriptor vs __dict__
manual
computing label
order x3
== 4 added after class creation
AttributeError: 'Field' object has no attribute 'storage'
__set_name__(Order, 'extra')
1
__set_name__은 클래스를 만들 때 한 번 불린다
__set_name__(Order, 'qty')는 인스턴스를 만들기 전, class Order: 문이 실행될 때 출력된다. 데이터 모델 레퍼런스의 __set_name__ 항목은 이 메서드가 소유 클래스가 생성될 때 자동으로 호출되며, 인자는 그 클래스와 디스크립터가 대입된 이름이라고 적는다. 3.6에서 추가되었다.
클래스 객체 생성 절에 따르면 기본 메타클래스 type을 쓰거나 결국 type.__new__를 부르는 메타클래스라면, 클래스 객체를 만든 뒤 type.__new__가 네임스페이스에서 __set_name__을 가진 속성을 모으고, 각각을 클래스와 이름으로 호출한 다음, MRO상 바로 위 부모의 __init_subclass__()를 부른다. Descriptor Guide는 __set_name__이 디스크립터가 아닌 객체에도 불린다고 덧붙인다.
Field는 이 덕분에 qty = Field("qty")처럼 이름을 두 번 쓰지 않아도 저장할 속성 이름을 안다.
1. 비데이터 디스크립터는 한 번만 계산된다
처음 o.label을 읽으면 인스턴스 __dict__에 label이 없으니 비데이터 디스크립터인 Lazy.__get__이 불리고, computing label이 찍힌다. 이 메서드가 결과를 o.__dict__["label"]에 넣었기 때문에 두 번째 읽기에서는 3단계(인스턴스 __dict__)에서 값이 바로 나온다. computing label이 한 번만 찍힌 이유다.
functools.cached_property도 문서상 같은 성질을 가진다. 같은 이름의 속성이 없을 때 조회에서만 실행되고, 실행되면 같은 이름의 속성에 값을 쓰며, 이후 읽기와 쓰기는 일반 속성처럼 동작한다. 속성을 삭제하면 다시 계산된다. 같은 문서는 각 인스턴스의 __dict__가 변경 가능한 매핑이어야 해서, __dict__ 없이 __slots__만 정의한 클래스에서는 동작하지 않는다고 적는다. 3.12부터는 이전에 있던 문서화되지 않은 잠금이 빠져서, 여러 스레드에서 같은 인스턴스의 getter가 두 번 이상 실행될 수 있다.
2. 데이터 디스크립터는 __dict__를 무시한다
o.__dict__["qty"] = "bad"로 인스턴스 딕셔너리에 qty를 직접 넣어도 o.qty는 3이다. Field가 데이터 디스크립터라서 2단계에서 Field.__get__이 먼저 불리고, 이 메서드는 _qty를 읽는다. "bad"는 vars(o)에는 남아 있지만 o.qty로는 보이지 않는다.
대입도 마찬가지다. o.qty = "x"는 인스턴스 __dict__에 쓰지 않고 Field.__set__을 거쳐 TypeError가 난다. property가 setter 없이 쓰기를 막는 것도 같은 구조다. Descriptor Guide에 따르면 읽기 전용 데이터 디스크립터는 __set__()이 AttributeError를 내도록 정의하면 되고, 예외만 내는 __set__()을 정의해도 데이터 디스크립터가 된다.
3. 비데이터 디스크립터는 인스턴스가 덮어쓴다
o.label = "manual"은 Lazy에 __set__이 없으니 인스턴스 __dict__에 바로 들어가고, 이후 o.label은 manual이다. del o.label로 그 항목을 지우면 다음 읽기에서 다시 Lazy.__get__이 불려 computing label이 찍힌다.
함수도 같은 경우다. Descriptor Guide는 함수가 바운드 메서드를 만들기 위해 __get__()을 가지고 있어서 비데이터 디스크립터라고 설명한다. 인스턴스에 메서드와 같은 이름의 속성을 대입하면 그 인스턴스에서만 메서드가 가려진다.
4. 나중에 붙인 디스크립터는 이름을 모른다
Order.extra = late처럼 클래스가 만들어진 뒤에 디스크립터를 대입하면 __set_name__은 자동으로 불리지 않는다. 데이터 모델 레퍼런스와 Descriptor Guide 모두 이 경우를 명시하고, 필요하면 직접 호출하라고 한다. 예제에서는 late에 storage가 아직 없어서 o.extra = 1이 AttributeError로 끝난다. late.__set_name__(Order, "extra")를 직접 부른 뒤에는 정상으로 동작한다.
Field는 1이 정수인지 검사까지 통과한 뒤 self.storage를 읽다가 실패한다. 이름을 __set_name__에서 받는 디스크립터를 클래스가 만들어진 뒤에 setattr() 등으로 붙이는 코드라면, 그 자리에서 __set_name__도 함께 불러야 한다. 이 문장은 문서가 아니라 위 동작에서 끌어낸 조언이다.
값을 디스크립터에 저장하지 말 것
Field가 값을 self.value처럼 디스크립터 자신에 저장하면, 디스크립터는 클래스에 하나뿐이므로 모든 인스턴스가 같은 값을 보게 된다. 그래서 예제는 __set_name__으로 받은 이름을 써서 값을 각 인스턴스에 둔다. 객체 하나가 여러 곳에서 공유되어 생기는 문제라는 점은 파이썬 기본 인자에 []를 쓰면 안 되는 이유와 같은 모양이다. 이 절은 예제 설계에 대한 설명이고, 공유된다는 점은 디스크립터가 클래스 변수라는 문서 내용에서 끌어낸 것이다.
참고 자료
모두 2026-10-19에 확인했다. 페이지 표기는 3.14.8이다.