python 사용자 지정 불변 객체를 만드는 3가지 방법

2024. 11. 17. 16:09·탐구 생활/Python

사용자 지정 불변 객체

이전 글에서는 사용자가 만든 클래스는 모두 가변 객체인것 처럼 설명되었다. 정말 사용자가 만든 클래스는 불변 객체가 될 수 없는 걸까?

용자가 작성한 클래스를 불변 객체로 만들기 위해서는 다음의 조건이 충족되어야 할 것이다.

  • 새로운 attribute 를 추가하는것을 막는다.
  • 기존 attribute 를 변경하는 것 모두가 막는다.
  • 값을 기반으로 객체간 동등성을 비교하도록 재정의 한다.

1. 새로운 attribute 추가 제한

파이썬 객체는 별다른 설정을 하지 않는다면 attribute 를 dict 자료형에 저장한다. 그리고 dict 자료형은 값을 추가, 삭제할 수 있다. 아래의 예시를 보자.

 

자유로은 attribute 추가

class MutableClass:
    value: str

    def __init__(self, value: str):
        self.value = value

    def __repr__(self):
        # __dict__ 를 통해서 내부 attribute 에 접근
        attrs = ", ".join(f"{key}: {value}" for key, value in self.__dict__.items())
        return f"MutableClass({attrs})"


mutable = MutableClass("mutable")

print(f"mutable: {mutable}")
# mutable: MutableClass(value: mutable)

mutable.value2 = "mutable..."

print(f"mutable: {mutable}")
# mutable: MutableClass(value: mutable, value2: mutable...)

 

MutableClass 내부에 정의된 __repr__ 메서드에서 명시적으로 __dict__ 에서 값을 가져오는 것을 알 수 있다.

 

__slots__ 를 이용한 attribute 추가 제한

클래스를 정의할때 __slots__ 를 통해 attribute  의 이름값을 정의한다면, 새로운 attribute 를 추가할때 AttributeError 가 발생하게 되며, 이는 IDE 에 따라서는 적절한 경고를 띄워준다.

__slots__ 를 이용하는 것은 객체의 사용방법을 제한하는 것 외에도 다른 장점이 있지만 이 글에서는 이 정도만 알아본다.

class MutableClass:
    value: str

    def __init__(self, value: str):
        self.value = value

    def __repr__(self):
        # __dict__ 를 통해서 내부 attribute 에 접근
        attrs = ", ".join(f"{key}: {value}" for key, value in self.__dict__.items())
        return f"MutableClass({attrs})"


class ImmutableClass1:
    __slots__ = ["value"]

    def __init__(self, value: str):
        self.value = value

    def __repr__(self):
        # __slots__ 를 통해서 내부 attribute 에 접근
        attrs = ", ".join(
            f"{slot}: {getattr(self, slot, None)}" for slot in self.__slots__
        )
        return f"ImmutableClass1({attrs})"


mutable = MutableClass("mutable")
immutable1 = ImmutableClass1("immutable1")

print(f"mutable: {mutable}\nimmutable1: {immutable1}")
# mutable: MutableClass(value: mutable)
# immutable1: ImmutableClass1(value: immutable1)

mutable.value2 = "mutable..."
immutable1.value2 = "immutable1..." # AttributeError: 'ImmutableClass1' object has no attribute 'value2'

 

하지만 __slots__ 를 통해 새로운 attribute 를 추가하는 것을 막았다고 하더라도 이는 기존 attribute 의 불변성을 보장하는 것이 아니다.


2. 기존 attribute 변경 제한

2.1 __setattr__ 재정의

파이썬 클래스는 내부적으로 attribute의 값을 초기화하거나 변경할때 __setattr__ 메서드를 호출하게 되어있다. 따라서 처음에 객체를 초기화할때를 제외하는 __setattr__ 을 호출하지 못하게 하면 attribute 를 변경하지 못하는 불변 객체가 완성되는 것이다.

class ImmutableClass1:
    __slots__ = ["value"]

    def __init__(self, value: str):
        self.value = value

    def __repr__(self):
        # __slots__ 를 통해서 내부 attribute 에 접근
        attrs = ", ".join(
            f"{slot}: {getattr(self, slot, None)}" for slot in self.__slots__
        )
        return f"ImmutableClass1({attrs})"


class ImmutableClass2:
    __slots__ = ["value"]

    def __init__(self, value: str):
        # 속성을 직접 설정 (이 단계에서는 __setattr__ 호출 방지)
        super().__setattr__("value", value)

    def __setattr__(self, key, value):
        # 객체 초기화 후에는 속성 변경 금지
        raise AttributeError(f"Cannot modify attribute '{key}'")

    def __repr__(self):
        # __slots__ 를 통해서 내부 attribute 에 접근
        attrs = ", ".join(
            f"{slot}: {getattr(self, slot, None)}" for slot in self.__slots__
        )
        return f"ImmutableClass2({attrs})"



immutable1 = ImmutableClass1("immutable1")
immutable2 = ImmutableClass2("immutable2")

print(f"immutable1: {immutable1}\nimmutable2: {immutable2}")
# immutable1: ImmutableClass1(value: immutable1)
# immutable2: ImmutableClass2(value: immutable2)

immutable1.value = "immutable1+1"
immutable2.value = "immutable2+2" # AttributeError: Cannot modify attribute 'value'

3. 두가지를 한번에

사실 위의 과정은 너무 복잡하고 boilerplate 가 많다. 사용자가 정의한 클래스를 불변 객체로 만들고자하는 욕구는 먼저 python 을 거쳐갔던 수많은 개발자들도 동일했을 것이므로 더 멋진 방법이 있을 것이라고 생각하는게 당연하다. 

3.1 dataclass

그리고 그 방법 중 하나가 datablasses 이다.

 

이 방법은 __slots__ 를 이용하지 않아도, __setattr__ 을 재정의 하지 않아도 기존에 기대하던 모든 것들을 충족시켜준다. IDE 에 따라서는 적절한 경고를 띄워준다.

(기본적으로 slot=False 로 설정되어 있는데, slot=True 로 설정하여 slots 을 사용하는 이점을 누릴 수 있다.)

from dataclasses import dataclass


class ImmutableClass2:
    __slots__ = ["value"]

    def __init__(self, value: str):
        # 속성을 직접 설정 (이 단계에서는 __setattr__ 호출 방지)
        super().__setattr__("value", value)

    def __setattr__(self, key, value):
        # 객체 초기화 후에는 속성 변경 금지
        raise AttributeError(f"Cannot modify attribute '{key}'")

    def __repr__(self):
        # __slots__ 를 통해서 내부 attribute 에 접근
        attrs = ", ".join(
            f"{slot}: {getattr(self, slot, None)}" for slot in self.__slots__
        )
        return f"ImmutableClass2({attrs})"


@dataclass(frozen=True)
class ImmutableClass3:
    value: str

    def __repr__(self):
        # __dict__ 를 통해서 내부 attribute 에 접근
        attrs = ", ".join(f"{key}: {value}" for key, value in self.__dict__.items())
        return f"ImmutableClass3({attrs})"


immutable2 = ImmutableClass2("immutable2")
immutable3 = ImmutableClass3("immutable3")

print(f"immutable2: {immutable2}\nimmutable3: {immutable3}")
# immutable2: ImmutableClass2(value: immutable2)
# immutable3: ImmutableClass3(value: immutable3)

immutable3.value = "immutable3+3"  # dataclasses.FrozenInstanceError: cannot assign to field 'value'
immutable3.value2 = "immutable3..." # dataclasses.FrozenInstanceError: cannot assign to field 'value2'

 

3.2 NamedTuple

NamedTuple 을 클래스가 상속하게 하는 방법도 있다. 이 방법 역시 새로운 attribute 의 추가와 기존 attribute 의 변경을 막는다. 이때는 기존 클래스의 __dict__ 나 __slots__ 를 이용하는 것이 아니라 tuple 구조로 바로 변경된다. tuple 자체가 불변 객체이므로 사용자가 정의한 객체에 불변성을 부여할 수 있다.

IDE 에 따라서는 적절한 에러를 띄워준다.

from dataclasses import dataclass
from typing import NamedTuple


@dataclass(frozen=True)
class ImmutableClass3:
    value: str

    def __repr__(self):
        # __dict__ 를 통해서 내부 attribute 에 접근
        attrs = ", ".join(f"{key}: {value}" for key, value in self.__dict__.items())
        return f"ImmutableClass3({attrs})"


class ImmutableClass4(NamedTuple):
    value: str

    def __repr__(self):
        # ._fields 를 통해 NamedTuple 필드에 접근
        attrs = ", ".join(f"{field}: {getattr(self, field)}" for field in self._fields)
        return f"ImmutableClass4({attrs})"


immutable3 = ImmutableClass3("immutable3")
immutable4 = ImmutableClass4("immutable4")

print(f"immutable3: {immutable3}\nimmutable4: {immutable4}")
# immutable3: ImmutableClass3(value: immutable3)
# immutable4: ImmutableClass4(value: immutable4)

immutable4.value = "immutable4+4"  # AttributeError: can't set attribute
immutable4.value2 = "immutable4..."  # AttributeError: 'ImmutableClass4' object has no attribute 'value2'

 


4. 동등성 비교 재정의

불변 객체의 동등성은 값 비교로 이루어지는게 타당할 것이다.

따라서 위의 예제 중 ImmutableClass2 에 __eq__ 메서드를 재정의할 수 있을 것이고, dataclass 와 NamedTuple 은 모두 값을 기준으로 __eq__ 비교를 하도록 재정의 해준다.

from dataclasses import dataclass
from typing import NamedTuple

class ImmutableClass2:
    __slots__ = ["value"]

    def __init__(self, value: str):
        # 속성을 직접 설정 (이 단계에서는 __setattr__ 호출 방지)
        super().__setattr__("value", value)

    def __setattr__(self, key, value):
        # 객체 초기화 후에는 속성 변경 금지
        raise AttributeError(f"Cannot modify attribute '{key}'")

    def __repr__(self):
        # __slots__ 를 통해서 내부 attribute 에 접근
        attrs = ", ".join(
            f"{slot}: {getattr(self, slot, None)}" for slot in self.__slots__
        )
        return f"ImmutableClass2({attrs})"

    def __eq__(self, other):
        # 동등성 비교: 다른 객체와 값 비교
        if not isinstance(other, ImmutableClass2):
            return False
        return all(
            getattr(self, slot, None) == getattr(other, slot, None)
            for slot in self.__slots__
        )

@dataclass(frozen=True)
class ImmutableClass3:
    value: str

    def __repr__(self):
        # __dict__ 를 통해서 내부 attribute 에 접근
        attrs = ", ".join(f"{key}: {value}" for key, value in self.__dict__.items())
        return f"ImmutableClass3({attrs})"


class ImmutableClass4(NamedTuple):
    value: str

    def __repr__(self):
        # ._fields 를 통해 NamedTuple 필드에 접근
        attrs = ", ".join(f"{field}: {getattr(self, field)}" for field in self._fields)
        return f"ImmutableClass4({attrs})"


immutable2_1 = ImmutableClass2("immutable2")
immutable2_2 = ImmutableClass2("immutable2")

immutable3_1 = ImmutableClass3("immutable3")
immutable3_2 = ImmutableClass3("immutable3")

immutable4_1 = ImmutableClass4("immutable4")
immutable4_2 = ImmutableClass4("immutable4")

print(f"""immutable2: {immutable2_1 == immutable2_2}
immutable3: {immutable3_1 == immutable3_2}
immutable4: {immutable4_1 == immutable4_2}
""")

5. 어떤걸 사용할까?

그렇다면 지금까지 알아본 3가지 방법중 어떤것을 이용하는게 좋을까?

  1. 직접 재정의한 불변 객체
  2. dataclass 를 이용한 불변 객체
  3. NamedTuple 을 이용한 불변 객체

5.1 구현 복잡도

직접 불변 객체를 재정의한 경우가 구현 복잡도가 가장 높았다. 그리고 dataclass 와 NamedTuple 의 경우 구현복잡도 에서 크게 차이가 있는것 같지는 않다, dataclass는 구현 복잡도가 아주 조금 더 높지만 그 덕분에 다양한 요구 사항에 따라 적절한 객체를 커스터마이징 하여 사용할 수 있다.

5.2 메모리 효율성

구현 복잡성은 너무나 명확한 문제이고, 메모리 효율성을 따져보자

from dataclasses import dataclass
from typing import NamedTuple
import sys
from pympler import asizeof


class ImmutableClass2:
    __slots__ = ["id", "value"]

    def __init__(self, id: int, value: str):
        super().__setattr__("id", id)
        super().__setattr__("value", value)

    def __setattr__(self, key, value):
        raise AttributeError(f"Cannot modify attribute '{key}'")


@dataclass(frozen=True, slots=True)
class ImmutableClass3:
    id: int
    value: str


class ImmutableClass4(NamedTuple):
    id: int
    value: str


immutable2 = ImmutableClass2(10, "immutable")
immutable3 = ImmutableClass3(10, "immutable")
immutable4 = ImmutableClass4(10, "immutable")

# 객체의 기본 메모리 크기만 측정
print(f"self created size: {sys.getsizeof(immutable2)} bytes")
print(f"dataclass size: {sys.getsizeof(immutable3)} bytes")
print(f"NamedTuple size: {sys.getsizeof(immutable4)} bytes")
# self created size: 48 bytes
# dataclass size: 48 bytes
# NamedTuple size: 56 bytes

print(f"======================")

# 객체와 객체가 참조하는 모든 메모리
print(f"self created actual size: {asizeof.asizeof(immutable2)} bytes")
print(f"dataclass actual size: {asizeof.asizeof(immutable3)} bytes")
print(f"NamedTuple actual size: {asizeof.asizeof(immutable4)} bytes")
# self created actual size: 136 bytes
# dataclass actual size: 136 bytes
# NamedTuple actual size: 144 bytes

 

NamedTuple 방식이 8 byte 정도 메모리를 더 소모하는 것을 확인할 수 있다.

이는 NamedTuple 이 Tuple 의 기능에 더해  "key" 값으로 "value" 를 조회할 수 있는 추가적인 기능을 갖는 자료구조를 구현함으로 추가 메모리 공간을 소모하는 것으로 추측된다.

 

 

5.3 결론

지금까지 확인한 사실을 상대적 비교로 도표화하면 아래와 같다.

 

그렇다면 항상 dataclass 를 사용하는게 이득이 아닐까? 반드시 그런것은 아니다.

dataclass 는 사용자에 따라서 설정 값이 달라질 수 있으므려 협업시 예상치 못한 행위를 일으키지 않기 위해 옵션을 확인해야하는 불편함이 있곗지만 NamedTuple 은 별다른 옵션이 없으므로 예상치 못한 행위가 발생될 가능성이 적다. 그리고 tuple 의 기능을 이용할 수 있다는 장점이 있다.

 

dataclass 를 이용하는 것은 직접 구현에 비해서 관련 클래스 메서드 오버라이딩 등 오버헤드가 있으므로 조금이지만 메모리 효율성에서 불리한 점이 있다. 따라서 엄청 대용량의 데이터를 객체로 매핑해서 다뤄야하는 경우에는 직접 구현하는 방법을 고려할 수 있겠다.

 

 

 

'탐구 생활 > Python' 카테고리의 다른 글

Python asyncio에 대한 탐구  (0) 2025.03.23
Python의 Type System  (0) 2025.03.20
Python 가변 객체와 불변 객체  (0) 2024.11.17
'탐구 생활/Python' 카테고리의 다른 글
  • Python asyncio에 대한 탐구
  • Python의 Type System
  • Python 가변 객체와 불변 객체
개발프로브
개발프로브
가볍게, 오랫동안 기록하고 싶은 블로그입니다.
  • 개발프로브
    ProbeHub
    개발프로브
  • 전체
    오늘
    어제
    • 분류 전체보기 (56)
      • 탐구 생활 (47)
        • 개발 탐구 (8)
        • FastAPI CORS (3)
        • FastAPI Log (4)
        • gRPC&Python (4)
        • SpringBoot 파헤치기 (2)
        • Python Monorepo (3)
        • Python 과 zstd (2)
        • Python (4)
        • FastAPI (4)
        • Terraform (8)
        • MSA (0)
        • GraphQL (2)
        • 데이터베이스 (2)
        • 네트워크 (0)
      • 기초 지식 (9)
        • Terraform (2)
        • MSA (5)
        • K8s (2)
  • 블로그 메뉴

    • 링크

      • github
      • stackoverflow
    • 공지사항

    • 인기 글

    • 태그

      java
      rest vs grpc
      FastAPI
      오블완
      grpc
      티스토리챌린지
      Terraform
      brotli
      fastapi logging
      springboot
      django 성능 개선
      PostgreSQL
      zstd
      ORM 문제
      gzip
      백엔드 성능
      MSA
      python 성능 개선
      spring 트랜잭션
      RDBMS 성능 최적화
      granian
      python 불변 객체
      fastapi cors
      ORM 성능 최적화
      python amd64
      ORM 성능
      python graviton
      python arn64
      Python
      sqlalchemy
    • 최근 댓글

    • 최근 글

    • hELLO· Designed By정상우.v4.10.0
    개발프로브
    python 사용자 지정 불변 객체를 만드는 3가지 방법
    상단으로

    티스토리툴바