모던 파이썬 중급 #1 dataclass와 __slots__

7 분 소요

모던 파이썬 기초 시리즈를 마쳤다면, 이제 한 단계 위로 들어갑니다. 중급 시리즈는 기초에서 살짝 비춘 도구들을 본격적으로 다루는 7편입니다.

  • #1 dataclass와 __slots__ ← 이번 글
  • #2 typing 본격 — Generic, Protocol, TypedDict, Literal
  • #3 컨텍스트 매니저 (with, contextlib)
  • #4 이터러블/제너레이터/yield from
  • #5 데코레이터 패턴
  • #6 패턴 매칭 깊이
  • #7 비동기 입문 (asyncio)

첫 주제는 데이터를 담는 클래스를 짧게 쓰는 도구@dataclass와, 거기에 메모리 절약 옵션을 더해 주는 __slots__ 입니다.

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

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

🚫 직접 적은 클래스 — 길고 반복적
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는 비밀번호 같이 로그에 안 찍혀야 할 필드에 자주 씁니다. 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 입력 검증이라면 기초 #2에서 잠깐 본 Pydantic이 압도적으로 우위입니다. 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% 가속 정도가 일반적입니다 (객체 크기와 인터프리터 버전에 따라 다름).

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)를 기본값으로 두는 게 모던 파이썬의 보통 답입니다.

정리 #

이번 글에서 정리한 도구들:

  • @dataclass__init__/__repr__/__eq__를 자동 생성
  • 옵션: frozen (불변, hashable), kw_only (위치 인자 차단), order (정렬), slots (메모리)
  • 가변 기본값은 field(default_factory=list)
  • field(repr=False, compare=False, init=False)로 필드 단위 제어
  • __post_init__으로 생성 후 후처리
  • __slots__ — 인스턴스당 dict 오버헤드 제거, 메모리,속도 절약
  • dataclass(slots=True)가 가장 짧은 슬롯 사용법
  • 강한 검증,JSON 직렬화는 dataclass보다 Pydantic이 정답

다음 글(#2 typing 본격)에서는 타입 시스템의 강력한 도구들 — Generic, Protocol, TypedDict, Literal을 다룹니다. 기초에서 잡은 타입 힌트의 다음 단계입니다.

X