목차
8 장

dataclass와 __slots__

데이터 모음 클래스를 짧고 안전하게 만드는 @dataclass의 모든 옵션 — frozen, kw_only, field()와 메모리 절약 도구 __slots__까지 정리합니다.

2부 코드 구조화의 첫 챕터입니다. 1부 7장이 끝나면서 함수 / 컬렉션 / 예외 / 모듈까지 다뤘지만, “모양이 정해진 객체"를 짧고 안전하게 표현하는 방법은 아직 보지 않았습니다. 본 챕터에서는 @dataclass__slots__로 그 문제를 정리합니다.

본 챕터의 dataclass는 책 전체에서 가장 자주 만나게 됩니다. 9장 typing 본격 — Generic, Protocol, TypedDict, Literal의 Protocol과 짝을 이루고, 4부 FastAPI의 Pydantic 모델은 사실상 dataclass의 확장입니다(24장 Pydantic v2 깊이에서 명시적으로 비교합니다).

데이터 클래스가 풀어주는 문제 #

다음 같은 클래스를 적어 본 경험이 있으면 무엇이 귀찮은지 바로 압니다.

🚫 직접 적은 클래스 — 길고 반복적
class User:
    def __init__(self, id: int, name: str, age: int):
        self.id = id
        self.name = name
        self.age = age

    def __repr__(self) -> str:
        return f"User(id={self.id!r}, name={self.name!r}, age={self.age!r})"

    def __eq__(self, other) -> bool:
        if not isinstance(other, User):
            return NotImplemented
        return (self.id, self.name, self.age) == (other.id, other.name, other.age)

세 필드를 위해 __init__, __repr__, __eq__를 직접 적었습니다. 필드를 한 개 추가하면 세 군데를 같이 고쳐야 합니다.

@dataclass가 이걸 풀어줍니다.

✅ @dataclass — 같은 일
from dataclasses import dataclass

@dataclass
class User:
    id: int
    name: str
    age: int

이게 끝입니다. __init__, __repr__, __eq__가 자동으로 만들어집니다.

동작
u = User(id=1, name="커티스", age=30)
print(u)
# User(id=1, name='커티스', age=30)

print(u == User(id=1, name="커티스", age=30))   # True

타입 힌트(id: int 등)가 그대로 필드 정의가 됩니다. 한곳에서 데이터 모양을 선언하면 동작들은 자동.

@dataclass 옵션 — 자주 쓰는 것들 #

옵션이 들어간 형태
from dataclasses import dataclass

@dataclass(frozen=True, kw_only=True, slots=True)
class User:
    id: int
    name: str
    age: int = 0

각 옵션의 의미:

옵션기본값의미
frozenFalseTrue 면 불변 — 생성 후 필드 변경 불가
kw_onlyFalseTrue 면 모든 필드를 keyword-only로 받음
slotsFalseTrue 면 자동으로 __slots__ 추가 (3.10+)
eqTrue__eq__ 자동 생성
orderFalseTrue 면 <, > 같은 비교 연산자 자동 생성
reprTrue__repr__ 자동 생성
initTrue__init__ 자동 생성

frozen=True — 불변 객체 #

frozen
@dataclass(frozen=True)
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
p.x = 3.0    # ✗ FrozenInstanceError

불변이면 좋은 점:

  • 딕셔너리 키, set 원소로 쓸 수 있음 (자동으로 hashable이 됨)
  • 의도치 않은 변경이 막힘 — 함수에 넘긴 후 누군가 고쳐서 버그가 나는 사고를 차단
  • 멀티스레드에서 안전 (race condition 없음)

도메인 모델에서 “이 데이터는 만들어지면 안 바뀐다"는 경우에 잘 어울립니다.

kw_only=True — 위치 인자 금지 #

kw_only
@dataclass(kw_only=True)
class User:
    id: int
    name: str
    age: int = 0

u = User(id=1, name="커티스")          # OK
u = User(1, "커티스")                  # ✗ TypeError

5장 함수 인자 패턴의 keyword-only와 같은 효과입니다. 필드가 많아질수록 User(1, "커티스", 30, True, "admin") 같은 호출은 읽기 어려운데, kw_only=True가 강제로 막아줍니다. 새 데이터 클래스는 기본으로 켜두는 걸 권장 합니다.

slots=True — 메모리와 속도 #

slots
@dataclass(slots=True)
class Point:
    x: float
    y: float

이 옵션의 의미는 본 챕터 후반부에서 자세히 다룹니다. 한 줄 요약은 “인스턴스를 더 가볍고 빠르게 만든다”.

비교 가능하게 — order=True #

order
@dataclass(order=True)
class Score:
    value: int
    name: str

scores = [Score(80, "B"), Score(95, "A"), Score(70, "C")]
scores.sort()    # 자동으로 동작
print(scores)    # [Score(70, 'C'), Score(80, 'B'), Score(95, 'A')]

<, <=, >, >=필드 순서대로 튜플처럼 비교됩니다. 첫 필드가 같으면 다음 필드로 넘어갑니다. 순서 비교가 의미 있는 경우(점수, 시간, 좌표)에 유용합니다.

field() — 필드 단위 세밀 설정 #

기본값이 단순한 값이 아닐 때, 또는 더 세밀한 옵션이 필요할 때 field()를 씁니다.

함정 — 가변 기본값은 직접 적으면 안 됨 #

🚫 에러
@dataclass
class User:
    name: str
    tags: list[str] = []   # ✗ ValueError
# mutable default <class 'list'> for field tags is not allowed

5장에서 본 가변 기본값 함정입니다. dataclass가 친절히 잡아줍니다. default_factory를 씁니다.

✅ default_factory
from dataclasses import dataclass, field

@dataclass
class User:
    name: str
    tags: list[str] = field(default_factory=list)

각 인스턴스마다 새 빈 리스트가 만들어집니다.

field()의 다른 옵션들 #

field 옵션
from dataclasses import dataclass, field

@dataclass
class User:
    id: int
    # 1. 기본값
    role: str = "member"
    # 2. 가변 기본값
    tags: list[str] = field(default_factory=list)
    # 3. repr/eq 에서 제외
    password: str = field(repr=False, compare=False, default="")
    # 4. init 에서 제외 — 생성 후 다른 곳에서 채움
    created_at: float = field(init=False, default=0.0)
    # 5. 메타데이터
    score: int = field(default=0, metadata={"max": 100})

repr=False는 비밀번호 같이 로그에 안 찍혀야 할 필드에 자주 씁니다 (31장 logging과 관측성에서 PII / secret 로깅 방지 패턴을 다시 다룹니다). compare=False는 동일성 판단에 영향을 안 주게 — 예를 들어 created_at이 달라도 같은 사용자로 취급되도록.

__post_init__ — 생성 후 후처리 #

__init__이 자동 생성되니 직접 적기 어려운데, 생성 직후 추가 처리를 하고 싶을 때.

__post_init__
from dataclasses import dataclass, field

@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)

    def __post_init__(self):
        self.area = self.width * self.height

r = Rectangle(3, 4)
print(r.area)   # 12.0

init=False로 생성자에서 제외한 필드를 __post_init__에서 계산해 채우는 패턴이 흔합니다.

dataclass가 어울리지 않는 경우 #

만능은 아닙니다. 다음 경우에는 다른 도구를 보세요.

상황더 어울리는 도구
검증이 강하게 필요 (이메일 형식, 길이 제한 등)Pydantic
JSON 변환을 자주 (직렬화 / 역직렬화)Pydantic, attrs, msgspec
상속 + 동작이 많음일반 클래스
이름 붙은 튜플로 충분NamedTuple
어쨌든 dict 면 됨TypedDict

API 입력 검증이라면 Pydantic이 더 적합합니다. 24장 Pydantic v2 깊이에서 본격적으로 다룹니다. dataclass는 “내부 데이터 모델” 용으로 보면 됩니다.

__slots__ — 메모리와 속도 #

이제 slots=True의 정체로 들어갑니다.

보통의 인스턴스 — __dict__로 동작 #

파이썬 객체는 기본적으로 속성을 dict에 저장 합니다.

dict로 동작
class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

p = Point(1.0, 2.0)
print(p.__dict__)
# {'x': 1.0, 'y': 2.0}

p.z = 3.0    # 새 속성을 자유롭게 추가 가능
print(p.__dict__)
# {'x': 1.0, 'y': 2.0, 'z': 3.0}

장점: 매우 유연. 단점: 속성 하나당 dict 오버헤드가 매번 듭니다. 객체를 수백만 개 만들면 메모리가 크게 늘어납니다.

__slots__ — 미리 정해진 속성만 #

__slots__
class Point:
    __slots__ = ("x", "y")

    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

p = Point(1.0, 2.0)
p.z = 3.0    # ✗ AttributeError: 'Point' object has no attribute 'z'

__slots__를 정의하면:

  • __dict__가 만들어지지 않음 — 메모리가 줄어듬
  • 속성 추가 불가 — 정의된 이름만 사용
  • 속성 접근이 약간 빠름 — dict 조회 대신 직접 슬롯 접근

수치로 보면 인스턴스당 40~50% 메모리 절약, 속성 접근 10~25% 가속 정도가 일반적입니다 (객체 크기와 인터프리터 버전에 따라 다름). 정확한 측정은 21장 성능 — cProfile, py-spy, 메모리 프로파일링에서 도구로 직접 잡아보겠습니다.

dataclass(slots=True)가 가장 편한 사용법 #

__slots__를 직접 적으면 필드 이름을 두 군데 (typing 선언 + __slots__) 적어야 합니다. dataclass(slots=True)가 자동으로 처리해줍니다.

자동 슬롯 생성
from dataclasses import dataclass

@dataclass(slots=True)
class Point:
    x: float
    y: float

뒤에서 일어나는 일은 위에서 직접 적은 것과 동일합니다. 한 줄로 끝나니 굳이 안 쓸 이유가 없습니다.

__slots__를 쓸 때 주의 #

만능은 아닙니다.

1) 다중 상속에 제약 #

__slots__가 정의된 클래스끼리 다중 상속하면 충돌이 납니다. 일반적인 단일 상속만 한다면 문제없습니다.

2) 약한 참조 — weakref가 안 됨 #

기본 __slots__는 weakref를 지원하지 않습니다. 필요하면:

weakref 지원
class Node:
    __slots__ = ("data", "__weakref__")

dataclass(slots=True, weakref_slot=True)도 가능합니다 (3.11+).

3) 클래스 변수와 충돌 주의 #

🚫 충돌
class Bad:
    __slots__ = ("x",)
    x = 0    # ✗ ValueError — 같은 이름 클래스 변수와 슬롯

4) 동적 속성을 못 추가 #

플러그인 / 모킹 등에서 객체에 임시 속성을 붙이는 패턴은 깨집니다. 보통은 문제 안 되지만, 라이브러리를 만들 땐 사용자가 그런 일을 할 가능성을 생각해 보세요.

언제 슬롯을 켤까? #

상황슬롯
인스턴스를 수만~수백만 개 만드는 자료 모델 (좌표, 그래프 노드 등)✅ 무조건
불변성을 강하게 유지하고 싶음 — 임의 속성 추가 차단
일반적인 도메인 객체, 인스턴스가 많지 않음⭕ 켜도 문제 없음 (그냥 켜라)
메타프로그래밍 / 동적 속성 / 다중 상속이 활발❌ 끄거나 신중히

의심스러우면 dataclass(slots=True)를 기본값으로 두는 게 모던 파이썬의 보통 답입니다.

연습문제 #

  1. @dataclass(frozen=True, kw_only=True, slots=True)Address(country: str, city: str, postal_code: str)를 정의하세요. Address("KR", "Seoul", "06000") 호출이 TypeError가 나는지 (kw_only), 같은 값으로 만든 두 인스턴스가 ==인지, hash(addr)가 동작하는지 (frozen → hashable), addr.city = "Busan"FrozenInstanceError인지 확인합니다.
  2. @dataclassCart 클래스를 만드세요. items: list[str] 필드는 가변 기본값이라 field(default_factory=list)가 필요합니다. total: float 필드는 init에서 제외(field(init=False, default=0.0)) 한 뒤, __post_init__에서 items의 개수 × 1000으로 계산해 채웁니다.
  3. @dataclass 1만 개와 @dataclass(slots=True) 1만 개 인스턴스를 각각 만든 뒤, sys.getsizeof(instance) + sys.getsizeof(instance.__dict__) (slots 쪽은 __dict__가 없으니 try / except)로 인스턴스당 메모리를 비교해 보세요. 정확한 메모리 측정 도구는 21장 성능에서 다시 다룹니다.

한 줄 요약: @dataclass 한 줄이 __init__/__repr__/__eq__를 자동 생성한다. 자주 쓰는 옵션은 frozen (불변·hashable), kw_only (호출 가독성), slots (메모리), order (정렬). 가변 기본값은 field(default_factory=...), 생성 후 후처리는 __post_init__. 검증 / 직렬화가 필요하면 dataclass가 아닌 Pydantic.

다음 챕터 #

다음 9장 typing 본격 — Generic, Protocol, TypedDict, Literal에서는 타입 시스템의 강력한 도구들을 다룹니다. 본 챕터의 dataclass와 9장의 Protocol이 합쳐지면 “구조적 타이핑"의 핵심 패턴이 완성됩니다.

X