dataclasses: Python의 간편한 데이터 클래스

1. 개요

데이터만 담는 클래스(Order, Customer, Point 등)를 작성할 때, 보통 __init__, __repr__, __eq__를 직접 작성해야 합니다 — 반복되는 보일러플레이트 코드일 뿐입니다. Python 3.7부터 제공되는 dataclasses 모듈은 단 하나의 데코레이터만으로 이 메서드들을 자동으로 생성해줍니다. 이 글을 읽고 나면 @dataclass로 간결한 데이터 클래스를 작성하고, __post_init__으로 데이터를 검증하며, 클래스를 불변으로 만들고, dict/JSON으로 변환하는 방법을 알게 될 것입니다.

2. 기본 dataclass란?

@dataclass는 클래스의 타입 힌트를 읽고 그 필드를 바탕으로 __init__, __repr__, __eq__를 자동으로 생성하는 데코레이터입니다.

from dataclasses import dataclass

@dataclass
class Customer:
    name: str
    email: str
    age: int = 0
  • @dataclass는 타입 힌트가 있는 속성(name, email, age)을 스캔하고 이를 필드로 취급합니다.
  • age: int = 0은 기본값을 가지며, 함수의 기본 매개변수처럼 작동합니다.
  • __init__을 직접 작성할 필요가 없습니다 — 필드의 순서대로 자동으로 생성됩니다.
c1 = Customer("An", "an@example.com")
print(c1)
Customer(name='An', email='an@example.com', age=0)

자동으로 생성된 __repr__은 필드의 이름과 값을 완전히 표시하므로 디버깅할 때 매우 편리합니다 — 일반 클래스에는 기본으로 없는 기능입니다.

3. 일반 클래스와 namedtuple과의 비교

직접 작성한 동등한 클래스는 훨씬 더 깁니다:

class CustomerPlain:
    def __init__(self, name, email, age=0):
        self.name = name
        self.email = email
        self.age = age

    def __repr__(self):
        return f"CustomerPlain(name={self.name!r}, email={self.email!r}, age={self.age!r})"

    def __eq__(self, other):
        if not isinstance(other, CustomerPlain):
            return NotImplemented
        return (self.name, self.email, self.age) == (other.name, other.email, other.age)

@dataclass는 이 모든 코드를 대신 작성해줍니다. namedtuple과 비교하면 dataclass는 다음과 같은 점에서 다릅니다:

  • 객체가 기본적으로 가변(mutable)입니다(c1.age = 20처럼 수정 가능), namedtuple은 그렇지 않습니다.
  • 명확한 타입 힌트를 지원하므로 mypy 같은 타입 체커와 쉽게 통합됩니다.
  • __post_init__, 복잡한 기본값, 상속을 지원합니다 — namedtuple이 잘 지원하지 않는 기능들입니다.

참고: 불변의 가벼운 튜플만 필요하고 메서드가 필요 없다면, namedtuple이나 typing.NamedTuple이 더 간결한 선택입니다.

4. field()와 가변 기본값

매우 흔한 실수: list/dict를 기본값으로 직접 사용하는 것입니다.

from dataclasses import dataclass, field

@dataclass
class Order:
    order_id: str
    items: list[str] = field(default_factory=list)
  • items: list[str] = []로 작성하면 Python이 ValueError: mutable default ... is not allowed 오류를 낼 것입니다 — 기본값 리스트가 모든 인스턴스 간에 공유되어 찾기 어려운 버그가 생기기 때문입니다.
  • field(default_factory=list)는 dataclass가 각 인스턴스마다 list() 새로운 객체를 호출하도록 요구합니다.
o1 = Order("A1")
o2 = Order("A2")
o1.items.append("Sách")

print(o1.items, o2.items)
['Sách'] []

두 객체는 독립적인 items을 가지며, 예상대로 작동합니다.

5. __post_init__으로 데이터 검증

dataclass가 생성한 __init__에는 검증 로직을 직접 삽입할 수 없습니다 — 하지만 __init__ 이후 즉시 실행되는 __post_init__ 훅이 있습니다.

from dataclasses import dataclass

@dataclass
class PaymentRequest:
    amount: float
    currency: str = "VND"

    def __post_init__(self):
        if self.amount <= 0:
            raise ValueError("금액은 0보다 커야 합니다")
        self.currency = self.currency.upper()
  • __post_init__은 모든 필드가 할당된 후 자동으로 호출됩니다.
  • 여기서는 amount를 검증하고 currency를 대문자로 정규화합니다.
try:
    PaymentRequest(amount=-100)
except ValueError as e:
    print(e)
금액은 0보다 커야 합니다

6. frozen=True로 불변 클래스 만들기

객체를 생성 후 수정할 수 없도록 하려면(Java의 record처럼), frozen=True를 사용합니다.

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float
  • frozen=True는 __setattr__을 차단하므로 — 초기화 후 모든 할당 시도는 오류를 발생시킵니다.
  • 불변성 덕분에 frozen 객체는 dict의 키나 set의 요소로 사용할 수 있습니다(frozen=True이고 eq=True일 때 dataclass가 자동으로 __hash__를 생성하기 때문입니다).
p = Point(1.0, 2.0)
try:
    p.x = 5.0
except Exception as e:
    print(type(e).__name__, e)
FrozenInstanceError cannot assign to field 'x'

참고: frozen=True는 최상위 속성 할당만 차단합니다. 필드가 list/dict(가변)라면 내부 내용은 여전히 수정할 수 있습니다. 진정한 불변성을 원한다면 필드에 tuple이나 불변 타입을 사용해야 합니다.

7. dataclass 상속

dataclass는 일반 Python 클래스처럼 상속을 지원하며, 자식 클래스의 필드는 부모 클래스의 필드 뒤에 이어집니다.

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

@dataclass
class Employee(Person):
    salary: float = 0.0
  • Employee는 Person에서 name, age를 상속받고, salary를 추가합니다.
  • 생성된 __init__의 매개변수 순서는 name, age, salary — 선언 순서를 유지합니다.
e = Employee("Bình", 30, salary=15_000_000)
print(e)
Employee(name='Bình', age=30, salary=15000000.0)

참고: 기본값 없는 필드는 전체 상속 체인에서 기본값이 있는 필드 앞에 와야 합니다. 부모 클래스에 기본값이 있는 필드가 있고 자식 클래스가 기본값 없는 필드를 추가하면, Python은 클래스 정의 시 TypeError를 발생시킵니다.

8. order=True로 비교 및 정렬

기본적으로 dataclass는 __eq__만 생성합니다. <, >, <= 비교를 하려면 order=True를 사용합니다.

from dataclasses import dataclass

@dataclass(order=True)
class Product:
    price: float
    name: str = field(compare=False)
  • order=True는 추가로 __lt__, __le__, __gt__, __ge__를 생성하며, 필드 순서대로 비교합니다(튜플 비교처럼).
  • field(compare=False)는 비교에서 name을 제외합니다 — price로만 비교합니다.
products = [Product(50_000, "Bút"), Product(20_000, "Vở")]
print(sorted(products))
[Product(price=20000, name='Vở'), Product(price=50000, name='Bút')]

sorted()는 dataclass가 생성한 __lt__를 사용하므로, key=lambda p: p.price를 수동으로 작성할 필요가 없습니다.

9. dict와 tuple로 변환

dataclasses는 객체를 직렬화하기 위해 asdict와 astuple 유틸리티 함수를 제공합니다.

from dataclasses import dataclass, asdict, astuple

@dataclass
class Address:
    city: str
    country: str

@dataclass
class Customer2:
    name: str
    address: Address
cust = Customer2("Chi", Address("Hà Nội", "Việt Nam"))
print(asdict(cust))
print(astuple(cust))
{'name': 'Chi', 'address': {'city': 'Hà Nội', 'country': 'Việt Nam'}}
('Chi', ('Hà Nội', 'Việt Nam'))
  • asdict는 재귀적으로 중첩된 dataclass(Address)도 하위 dict로 변환합니다 — API를 통해 JSON을 반환할 때 매우 편리합니다.
  • astuple은 비슷하지만 중첩된 튜플을 반환합니다.

참고: asdict/astuple은 전체 데이터를 깊은 복사합니다. 큰 객체이거나 복사할 수 없는 필드(파일 핸들, 연결 등)를 포함하면, 이 함수를 호출할 때 느리거나 오류가 발생할 수 있습니다.

10. dataclass에 대한 테스트 작성

__eq__이 자동으로 생성되므로 pytest로 객체 비교 테스트를 매우 자연스럽게 작성할 수 있습니다.

import pytest
from dataclasses import FrozenInstanceError

def test_customer_equality():
    c1 = Customer("An", "an@example.com", 20)
    c2 = Customer("An", "an@example.com", 20)
    assert c1 == c2

def test_payment_request_rejects_non_positive_amount():
    with pytest.raises(ValueError):
        PaymentRequest(amount=0)

def test_point_is_frozen():
    p = Point(1.0, 2.0)
    with pytest.raises(FrozenInstanceError):
        p.x = 10.0
  • test_customer_equality는 다른 인스턴스이지만 같은 필드 값을 가진 두 객체가 여전히 ==인지 확인합니다 — 자동으로 생성된 __eq__ 덕분에 맞습니다.
  • test_payment_request_rejects_non_positive_amount는 __post_init__이 올바르게 검증하는지 확인합니다.
  • test_point_is_frozen은 frozen=True가 속성 할당을 차단하는지 확인합니다.

11. 흔한 오류 및 사용하지 않아야 할 경우

  • 가변 기본값에서 default_factory 빠뜨리기: = [] 또는 = {}를 직접 사용하면 클래스 선언 시 즉시 오류가 발생하며, field(default_factory=...)를 사용해야 합니다.
  • 상속 시 기본값이 있는 필드와 없는 필드의 순서 혼동: 모듈 로드 시 TypeError를 일으키며, 클래스가 여러 파일에 흩어져 있으면 디버그하기 어렵습니다.
  • frozen=True가 깊은 불변성이라고 생각하기: list/dict 필드의 내부 내용은 여전히 수정할 수 있으며, 속성을 다시 할당만 할 수 없습니다.
  • 복잡한 비즈니스 로직에 dataclass 사용하기: 클래스에 많은 메서드와 다른 객체와의 복잡한 관계가 있다면, dataclass는 초기화/비교 부분만 도와줍니다 — 로직 부분은 여전히 직접 작성해야 하므로, 실제보다 간단한 것처럼 “가장하기” 위해 남용하지 마세요.
  • Python 3.7 이전 호환성 필요: dataclasses가 내장되지 않으므로, PyPI에서 dataclasses 패키지를 설치(백포트)하거나 attrs를 사용해야 합니다.

참고: 복잡한 검증, 사용자 정의 직렬화, 또는 JSON 스키마와의 깊은 통합이 필요하면, pydantic 같은 라이브러리가 순수 dataclasses보다 더 강력한 경우가 많습니다 — 대규모 프로젝트에서 고려할 가치가 있습니다.

12. 결론

dataclasses는 데이터를 담는 클래스 작성 시 대부분의 보일러플레이트를 제거해줍니다: __init__, __repr__, __eq__를 자동으로 생성하고, __post_init__으로 검증을 지원하며, frozen=True로 불변성을 제공하고, asdict/astuple로 쉬운 직렬화를 제공합니다. 간단한 것부터 중간 정도의 데이터 클래스의 경우, 이것은 수동으로 작성하는 것보다 훨씬 간결하고 읽기 쉬운 선택입니다. 검증이나 직렬화 요구사항이 더 복잡하다면, pydantic이나 attrs를 대신 고려할 수 있습니다.

pythondataclassesoopclean-code