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가 이걸 풀어줍니다.
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각 옵션의 의미:
| 옵션 | 기본값 | 의미 |
|---|---|---|
frozen | False | True 면 불변 — 생성 후 필드 변경 불가 |
kw_only | False | True 면 모든 필드를 keyword-only로 받음 |
slots | False | True 면 자동으로 __slots__ 추가 (3.10+) |
eq | True | __eq__ 자동 생성 |
order | False | True 면 <, > 같은 비교 연산자 자동 생성 |
repr | True | __repr__ 자동 생성 |
init | True | __init__ 자동 생성 |
frozen=True — 불변 객체
#
@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 — 위치 인자 금지
#
@dataclass(kw_only=True)
class User:
id: int
name: str
age: int = 0
u = User(id=1, name="커티스") # OK
u = User(1, "커티스") # ✗ TypeError5장 함수 인자 패턴의 keyword-only와 같은 효과입니다. 필드가 많아질수록 User(1, "커티스", 30, True, "admin") 같은 호출은 읽기 어려운데, kw_only=True가 강제로 막아줍니다. 새 데이터 클래스는 기본으로 켜두는 걸 권장 합니다.
slots=True — 메모리와 속도
#
@dataclass(slots=True)
class Point:
x: float
y: float이 옵션의 의미는 본 챕터 후반부에서 자세히 다룹니다. 한 줄 요약은 “인스턴스를 더 가볍고 빠르게 만든다”.
비교 가능하게 — order=True
#
@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 allowed5장에서 본 가변 기본값 함정입니다. dataclass가 친절히 잡아줍니다. default_factory를 씁니다.
from dataclasses import dataclass, field
@dataclass
class User:
name: str
tags: list[str] = field(default_factory=list)각 인스턴스마다 새 빈 리스트가 만들어집니다.
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__이 자동 생성되니 직접 적기 어려운데, 생성 직후 추가 처리를 하고 싶을 때.
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.0init=False로 생성자에서 제외한 필드를 __post_init__에서 계산해 채우는 패턴이 흔합니다.
dataclass가 어울리지 않는 경우
#
만능은 아닙니다. 다음 경우에는 다른 도구를 보세요.
| 상황 | 더 어울리는 도구 |
|---|---|
| 검증이 강하게 필요 (이메일 형식, 길이 제한 등) | Pydantic |
| JSON 변환을 자주 (직렬화 / 역직렬화) | Pydantic, attrs, msgspec |
| 상속 + 동작이 많음 | 일반 클래스 |
| 이름 붙은 튜플로 충분 | NamedTuple |
| 어쨌든 dict 면 됨 | TypedDict |
API 입력 검증이라면 Pydantic이 더 적합합니다. 24장 Pydantic v2 깊이에서 본격적으로 다룹니다. dataclass는 “내부 데이터 모델” 용으로 보면 됩니다.
__slots__ — 메모리와 속도
#
이제 slots=True의 정체로 들어갑니다.
보통의 인스턴스 — __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__ — 미리 정해진 속성만
#
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를 지원하지 않습니다. 필요하면:
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)를 기본값으로 두는 게 모던 파이썬의 보통 답입니다.
연습문제 #
@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인지 확인합니다.@dataclass로Cart클래스를 만드세요.items: list[str]필드는 가변 기본값이라field(default_factory=list)가 필요합니다.total: float필드는 init에서 제외(field(init=False, default=0.0)) 한 뒤,__post_init__에서items의 개수 × 1000으로 계산해 채웁니다.@dataclass1만 개와@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이 합쳐지면 “구조적 타이핑"의 핵심 패턴이 완성됩니다.