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를 대신 고려할 수 있습니다.