[에러 해결] [Python dataclasses] frozen=True, slots=True 조합 시 TypeError (Python < 3.13) 원인과 해결 방법

Python의 dataclasses는 간결한 코드만으로 강력한 데이터 클래스를 생성할 수 있게 해주는 유용한 기능입니다. 특히 frozen=True를 통해 객체의 불변성을 보장하고, slots=True를 통해 메모리 효율성을 높일 수 있죠. 하지만 이 두 가지 속성을 함께 사용할 때, 특정 Python 버전에서 예상치 못한 에러가 발생하여 개발자를 혼란스럽게 할 수 있습니다. 이 글에서는 frozen=True와 slots=True가 함께 사용될 때 Python 3.12 이하 버전에서 발생하는 TypeError의 원인을 분석하고, 이를 해결하는 방법을 제시합니다.

1. 에러 발생 상황

문제의 코드는 frozen=True 및 slots=True로 정의된 dataclass 인스턴스에 선언되지 않은 새 속성을 할당하려 할 때 발생합니다.

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Foo:
    attr1: int

f = Foo(1)

# 존재하지 않는 속성을 할당하려 할 때
f.attr2 = 42

1.1. Python 3.13 이상 버전

예상대로 FrozenInstanceError가 발생합니다. 이는 불변 객체에 속성을 추가할 수 없음을 명확히 알려줍니다.

Traceback (most recent call last):
  File "c:\Users\1\Downloads\stackoverflow.py", line 10, in <module>
    f.attr2 = 42
    ^^^^^^^
  File "<string>", line 15, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'attr2'

1.2. Python 3.12 이하 버전

놀랍게도 FrozenInstanceError 대신 TypeError가 발생합니다. 이 에러 메시지는 언뜻 보기에 속성 할당과는 관련이 없어 보여 개발자를 혼란스럽게 합니다.

Traceback (most recent call last):
  File "c:\Users\1\Downloads\stackoverflow.py", line 10, in <module>
    f.attr2 = 42
    ^^^^^^^
  File "<string>", line 5, in __setattr__
TypeError: super(type, obj): obj must be an instance or subtype of type

이 에러는 frozen=True와 slots=True의 조합, 그리고 __setattr__ 내부에서 발생하는 super() 호출 방식의 특정 버그와 관련이 있습니다.

2. 명확한 발생 원인

이러한 동작의 원인은 dataclasses 모듈이 frozen=True와 slots=True를 처리하는 내부 구현 방식의 차이에서 비롯됩니다.

2.1. frozen=True의 역할

frozen=True로 설정된 dataclass는 불변(immutable) 객체가 됩니다. 즉, 객체가 생성된 후에는 필드의 값을 변경하거나 새로운 필드를 추가할 수 없습니다. 이를 위해 dataclasses는 내부적으로 해당 클래스의 __setattr__ 메서드를 오버라이드하여, 변경 시 FrozenInstanceError를 발생시키도록 합니다.

2.2. slots=True의 역할

slots=True로 설정된 dataclass는 __dict__ 대신 __slots__를 사용하여 인스턴스 속성을 저장합니다. 이는 메모리 사용량을 줄이고 속성 접근 속도를 높이는 장점이 있습니다. __slots__를 사용하면 클래스 정의 시 명시된 속성 외에 새로운 속성을 동적으로 추가할 수 없게 됩니다. 새로운 속성을 추가하려 하면 일반적으로 AttributeError가 발생합니다.

2.3. Python < 3.13에서 발생한 문제

Python 3.12 이하 버전에서 frozen=True와 slots=True를 모두 사용할 때, dataclasses가 __setattr__ 내부에서 super()를 호출하여 FrozenInstanceError를 발생시키려는 로직에 문제가 있었습니다. __slots__를 사용하는 클래스에서 super()를 올바르게 호출하려면 특정 조건이 충족되어야 하는데, 이전 버전의 dataclasses 구현은 이 조건을 제대로 처리하지 못했습니다. 특히, __setattr__가 새로운(슬롯에 없는) 속성 할당을 감지했을 때, super().__setattr__를 호출하여 부모 클래스의 __setattr__를 통해 예외를 발생시키려 하거나, 또는 불변성 검사를 하려 할 때 __slots__ 메커니즘과 충돌하는 방식이었습니다. 이로 인해 super(type, obj): obj must be an instance or subtype of type 이라는 오해의 소지가 있는 TypeError가 발생했던 것입니다.

Python 3.13에서는 이 내부적인 dataclasses의 __setattr__ 구현이 개선되어 frozen=True와 slots=True의 조합에서도 예상대로 FrozenInstanceError가 발생하도록 수정되었습니다. 이는 버그 수정으로 간주할 수 있습니다.

3. 해결 방법 및 코드 예시

3.1. Python 3.13 이상 버전의 경우 (권장)

가장 좋은 해결책은 Python 버전을 3.13 이상으로 업그레이드하는 것입니다. 이 버전부터는 문제의 동작이 수정되어 frozen=True, slots=True 조합에서도 의도된 FrozenInstanceError가 발생합니다.

from dataclasses import dataclass, FrozenInstanceError

@dataclass(frozen=True, slots=True)
class Foo:
    attr1: int

f = Foo(1)

try:
    f.attr2 = 42 # Python 3.13+에서는 FrozenInstanceError 발생
except FrozenInstanceError as e:
    print(f"예상된 에러 발생 (Python 3.13+): {e}")

3.2. Python 3.12 이하 버전의 경우 (워크어라운드)

Python 버전을 업그레이드할 수 없는 상황이라면, TypeError를 직접 처리해야 합니다.

from dataclasses import dataclass, FrozenInstanceError
import sys

@dataclass(frozen=True, slots=True)
class Foo:
    attr1: int

f = Foo(1)

try:
    f.attr2 = 42
except FrozenInstanceError as e: # Python 3.13+
    print(f"FrozenInstanceError 발생 (Python 3.13+): {e}")
except TypeError as e: # Python 3.12 이하
    # TypeError 발생 시, 메시지를 확인하여 원하는 에러인지 검증하는 것이 안전합니다.
    if "super(type, obj): obj must be an instance or subtype of type" in str(e):
        print(f"예상치 못한 TypeError 발생 (Python < 3.13): {e}")
        print("이는 frozen=True, slots=True 조합으로 인한 내부적인 문제입니다.")
    else:
        # 다른 TypeError일 수 있으므로 주의
        raise # 예상치 못한 다른 TypeError는 다시 발생시킴

주의사항: TypeError는 다양한 상황에서 발생할 수 있는 일반적인 예외입니다. 따라서 에러 메시지 내용을 확인하여 이 특정 TypeError인지 검증하는 것이 중요합니다. 단순히 TypeError만 캐치하면 다른 종류의 TypeError까지 잘못 처리할 수 있습니다.

3.3. frozen=True를 유지하고 slots=True를 제거하는 대안

만약 slots=True로 인한 메모리 최적화가 필수적이지 않다면, slots=False로 변경하여 frozen=True의 불변성 동작을 보장하고 FrozenInstanceError를 일관되게 발생시킬 수 있습니다.

from dataclasses import dataclass, FrozenInstanceError

# slots=False로 변경
@dataclass(frozen=True, slots=False) 
class Bar:
    attr1: int

b = Bar(1)

try:
    b.attr2 = 42 # Python 버전 상관없이 FrozenInstanceError 발생
except FrozenInstanceError as e:
    print(f"FrozenInstanceError 발생 (slots=False): {e}")

이 경우 __dict__가 사용되므로 slots=True의 메모리 이점은 사라지지만, 에러 동작의 일관성을 확보할 수 있습니다. slots=True는 일반적으로 메모리 최적화가 중요할 때 고려됩니다.

4. 향후 예방을 위한 팁

  • Python 버전 업데이트: 가능한 한 최신 Python 버전을 사용하는 것을 권장합니다. 특히, 이 문제와 같은 특정 버전에서 발생하는 버그나 예외적인 동작은 새로운 버전에서 수정될 가능성이 높습니다. Python 3.13 이상에서는 이 문제가 해결됩니다.
  • Dataclass 속성 이해: frozen=True (불변성)와 slots=True (메모리 최적화 및 속성 제한)의 각 속성이 어떤 역할을 하는지 명확히 이해하고, 함께 사용할 때 발생할 수 있는 잠재적 상호작용을 인지해야 합니다. frozen 객체에 새로운 속성을 추가하려는 시도는 항상 예외를 발생시켜야 하는 잘못된 동작입니다.
  • 불변 객체 디자인: 불변 객체는 생성 시 모든 필드가 초기화되어야 하며, 이후에는 상태를 변경하거나 새로운 속성을 추가해서는 안 됩니다. 코드 설계 단계에서 이를 명확히 하고, 불변 객체를 오용하는 상황을 방지하세요.
  • 철저한 테스트: 특히 중요한 라이브러리나 프레임워크의 특정 기능을 사용할 때는 다양한 Python 버전에서 코드가 어떻게 동작하는지 확인하는 통합 테스트를 작성하여 잠재적 문제를 미리 발견하고 대응할 수 있도록 합니다.
  • 공식 문서 및 변경 로그 확인: Python 공식 문서나 dataclasses 모듈의 변경 로그를 주기적으로 확인하여 새로운 기능, 버그 수정, 그리고 기존 동작의 변경 사항을 숙지하는 것이 좋습니다.

결론:
frozen=True와 slots=True를 함께 사용하는 dataclasses에서 Python 3.12 이하 버전에서 TypeError가 발생하는 것은 dataclasses 내부 구현의 특정 버그로 인한 것입니다. Python 3.13에서 이 문제는 해결되었으므로, 가능한 경우 Python 버전을 업그레이드하는 것이 가장 확실한 해결책입니다. 부득이하게 이전 버전을 사용해야 한다면, 특정 TypeError를 캐치하여 처리하는 워크어라운드를 적용해야 합니다. 이러한 문제를 통해 라이브러리 내부 구현의 깊은 이해와 버전별 동작 차이의 중요성을 다시 한번 깨달을 수 있습니다.

댓글 남기기