상위 목록: 하위 목록: 작성 날짜: 읽는 데 16 분 소요

데이터 클래스(Data Class)

데이터 클래스(Data Class)란 값을 저장하는 것이 주된 목적인 클래스를 간단하게 작성할 수 있도록 도와주는 기능입니다.

Python 3.7 이상부터 표준 라이브러리인 dataclasses 모듈의 @dataclass 데코레이터로 사용할 수 있습니다.

제 27강 클래스에서는 __init__ 메서드를 직접 작성하고, self.변수 = 값의 형태로 인스턴스 변수를 하나씩 할당했습니다.

출력이나 비교가 필요하다면 __repr__, __eq__와 같은 매직 메서드도 직접 구현해야 합니다.

데이터 클래스는 클래스 변수에 작성한 타입 힌트를 읽어 이러한 메서드를 자동으로 생성하므로, 반복되는 코드를 크게 줄일 수 있습니다.

@dataclass는 제 47강 - 데코레이터에서 다룬 클래스에 적용하는 데코레이터이며, 필드를 선언할 때에는 제 49강 - 타입 힌트의 문법을 사용합니다. 두 강좌의 내용이 실제로 어떻게 쓰이는지 보여 주는 대표적인 예라고 할 수 있습니다.

이번 강좌에서는 일반 클래스와 데이터 클래스를 비교해 무엇이 자동으로 만들어지는지 확인하고, 기본값과 field(), 초기화 후처리, 불변 객체, 정렬, 슬롯, 키워드 전용 필드, 변환과 복사 함수, 그리고 비슷한 도구인 namedtuple, TypedDict와의 차이를 다룹니다.



일반 클래스와 비교

데이터 클래스가 어떤 문제를 해결하는지 보기 위해, 먼저 데이터 클래스 없이 값을 저장하는 클래스를 제대로 작성해 봅니다. 여기서 “제대로”란 출력했을 때 내용을 알아볼 수 있고, 값이 같은 두 객체를 같다고 판단할 수 있다는 의미입니다.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point(x={self.x!r}, y={self.y!r})"

    def __eq__(self, other):
        if other.__class__ is not self.__class__:
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)


p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1)
print(p1 == p2)
결과
Point(x=1, y=2)
True

일반 클래스로 좌표를 저장하는 클래스를 작성하면 위와 같습니다.

변수가 두 개뿐인데도 __init__, __repr__, __eq__ 메서드를 모두 직접 작성해야 합니다.

변수가 늘어날수록 같은 이름을 여러 번 반복해서 작성해야 하며, 변수를 하나 추가할 때 세 메서드를 모두 수정해야 합니다.

  • Tip : __repr__을 작성하지 않으면 <__main__.Point object at 0x...>와 같은 형태로 출력되며, __eq__를 작성하지 않으면 값이 같아도 서로 다른 객체이므로 False가 반환됩니다.


from dataclasses import dataclass


@dataclass
class Point:
    x: int
    y: int


p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1)
print(p1 == p2)
print(p1.x, p1.y)
결과
Point(x=1, y=2)
True
1 2

@dataclass 데코레이터를 클래스에 적용하면 변수명: 타입 형태로 작성한 클래스 변수를 필드(Field)로 인식합니다.

데이터 클래스는 필드를 작성한 순서대로 사용해 __init__, __repr__, __eq__ 메서드를 자동으로 생성합니다.

그러므로, 앞의 일반 클래스와 동일한 결과를 훨씬 짧은 코드로 얻을 수 있습니다.

데이터 클래스도 일반 클래스이므로, 클래스 내부에 메서드를 자유롭게 추가할 수 있습니다.

결과의 첫 줄은 자동 생성된 __repr__이 클래스명(필드=값, ...) 형식으로 출력한 것이고, 두 번째 줄 True는 자동 생성된 __eq__가 두 객체의 필드 값을 순서대로 비교한 결과입니다. 세 번째 줄은 자동 생성된 __init__이 인수를 self.x, self.y에 할당했다는 것을 보여 줍니다.

변수를 하나 추가해야 한다면 일반 클래스는 세 메서드를 모두 고쳐야 하지만, 데이터 클래스는 필드 선언 한 줄만 추가하면 됩니다.


import inspect
from dataclasses import dataclass


class Plain:
    x: int
    y: int


Decorated = dataclass(Plain)

print(Decorated is Plain)
print(inspect.signature(Plain))
print("__init__" in Plain.__dict__, "__repr__" in Plain.__dict__, "__eq__" in Plain.__dict__)
print(Plain.__dataclass_fields__.keys())
결과
True
(x: int, y: int) -> None
True True True
dict_keys([‘x’, ‘y’])

@dataclass가 내부에서 무엇을 하는지 확인하는 예제입니다. @ 구문 대신 dataclass(Plain)으로 직접 호출했습니다.

첫 줄의 True는 dataclass()가 새 클래스를 만드는 것이 아니라 전달받은 클래스를 수정해서 그대로 반환한다는 의미입니다.

두 번째 줄은 inspect.signature()로 확인한 생성자의 형태로, 필드 선언 순서대로 매개변수가 만들어졌습니다. 세 번째 줄은 클래스의 __dict__에 __init__, __repr__, __eq__가 실제로 추가되었다는 것을 보여 줍니다.

즉, 데이터 클래스는 특별한 종류의 클래스가 아니라, 클래스의 주석(__annotations__)을 읽어 우리가 직접 작성했을 메서드를 대신 작성해 주는 데코레이터입니다. 필드 정보는 __dataclass_fields__에 저장되며, 뒤에서 다루는 fields(), asdict() 같은 함수가 이 정보를 사용합니다.


from dataclasses import dataclass


@dataclass
class Point:
    x: int
    y: int


p = Point("one", None)
print(p)
결과
Point(x=’one’, y=None)

데이터 클래스의 타입 힌트는 필드를 정의하기 위한 용도이며, 실행 중에 값의 타입을 검사하지 않습니다.

int로 선언한 필드에 문자열이나 None을 전달해도 오류가 발생하지 않습니다.

제 49강 - 타입 힌트에서 확인한 것처럼 타입 힌트는 실행 중에 강제되지 않으며, 데이터 클래스도 이 원칙을 그대로 따릅니다. 데이터 클래스에서 타입 힌트의 역할은 이 이름이 필드라는 표시이며, 자료형이 올바른지는 정적 타입 검사기가 확인합니다.

값의 범위나 자료형을 실행 중에 검사해야 한다면, 뒤에서 다루는 __post_init__ 메서드에서 직접 검사합니다.

  • Tip : 타입 힌트가 없는 클래스 변수는 필드로 인식되지 않습니다. 타입을 특정하기 어려운 경우 typing.Any를 사용합니다.



기본값(Default Value)

from dataclasses import dataclass, fields


@dataclass
class Product:
    name: str
    price: int = 0
    stock: int = 10


print(Product("A"))
print(Product("B", 1500))
print(Product("C", stock=3))
print([f.name for f in fields(Product)])
결과
Product(name=’A’, price=0, stock=10)
Product(name=’B’, price=1500, stock=10)
Product(name=’C’, price=0, stock=3)
[‘name’, ‘price’, ‘stock’]

필드에 값을 할당하면 생성되는 __init__ 메서드의 기본값으로 사용됩니다.

함수의 매개변수와 마찬가지로 위치 인수와 키워드 인수를 모두 사용할 수 있습니다.

fields() 함수는 데이터 클래스의 필드 정보를 튜플(Tuple)로 반환합니다.

결과를 보면 Product("A")는 price와 stock에 기본값이 사용되고, Product("B", 1500)은 두 번째 위치 인수가 price에 들어갑니다. Product("C", stock=3)은 키워드 인수로 stock만 지정했으므로 price는 기본값 0을 유지합니다.

fields()가 반환하는 각 항목은 Field 객체이며, name 외에도 type, default 같은 속성으로 필드의 세부 정보를 확인할 수 있습니다.


from dataclasses import dataclass, fields
from typing import ClassVar


@dataclass
class Product:
    name: str
    price: int = 0
    tax_rate: ClassVar[float] = 0.1
    category = "기타"


p = Product("A", 1000)
print(p)
print([f.name for f in fields(Product)])
print(p.tax_rate, p.category)
결과
Product(name=’A’, price=1000)
[‘name’, ‘price’]
0.1 기타

모든 인스턴스가 공유하는 클래스 변수를 데이터 클래스에 두고 싶다면 typing.ClassVar로 표기합니다.

ClassVar[float]로 표기한 tax_rate와 타입 힌트가 없는 category는 필드가 아니므로 생성자의 매개변수와 출력 결과에서 빠집니다. 두 값은 일반 클래스 변수처럼 인스턴스에서 읽을 수 있습니다.

타입 힌트를 생략해도 필드에서 빠지지만, ClassVar로 표기하면 의도적으로 클래스 변수로 만들었다는 것을 드러내고 정적 타입 검사기도 자료형을 알 수 있습니다.


from dataclasses import dataclass


try:
    @dataclass
    class Product:
        name: str = "Unknown"
        price: int
except TypeError as e:
    print(e)
결과
non-default argument ‘price’ follows default argument ‘name’

함수의 매개변수처럼 기본값이 없는 필드는 기본값이 있는 필드 뒤에 올 수 없습니다.

__init__ 메서드가 생성되는 시점인 클래스 정의 단계에서 TypeError가 발생합니다.

데이터 클래스는 필드를 선언 순서대로 __init__(self, name="Unknown", price) 형태의 매개변수로 만드는데, 이는 함수 문법에서 허용되지 않는 형태이기 때문입니다.

이 문제는 기본값이 없는 필드를 앞으로 옮기거나, 뒤에서 다루는 키워드 전용 필드로 만들어 해결할 수 있습니다.



가변 기본값과 field(default_factory)

class Cart:
    def __init__(self, owner, items=[]):
        self.owner = owner
        self.items = items


a = Cart("Alpha")
b = Cart("Beta")
a.items.append("Apple")
print(b.items)
결과
[‘Apple’]

일반 클래스에서 목록(List)과 같은 가변 객체를 기본값으로 사용하면, 기본값은 함수가 정의될 때 한 번만 생성됩니다.

그러므로 모든 인스턴스가 같은 목록을 공유하며, a에 추가한 값이 b에서도 보이는 문제가 발생합니다.

a와 b를 만들 때 items를 전달하지 않았으므로 두 인스턴스 모두 같은 기본값 리스트 객체를 self.items에 저장합니다. 그 상태에서 a.items.append()로 리스트 내부를 변경하면, 같은 객체를 가리키는 b.items에도 그대로 보입니다.

이 문제는 오류 없이 조용히 발생하기 때문에, 인스턴스가 여러 개 만들어진 뒤에야 엉뚱한 값이 섞여 있는 것을 발견하게 됩니다.


from dataclasses import dataclass


try:
    @dataclass
    class Cart:
        items: list = []
except ValueError as e:
    print(e)
결과
mutable default <class ‘list’> for field items is not allowed: use default_factory

데이터 클래스는 이러한 실수를 막기 위해 list, dict, set과 같은 가변 기본값을 직접 할당하면 ValueError를 발생시킵니다.

오류 메시지에서 안내하는 것처럼 default_factory를 사용해야 합니다.


from dataclasses import dataclass, field


@dataclass
class Cart:
    owner: str
    items: list = field(default_factory=list)
    count: int = field(default=0, repr=False)


a = Cart("Alpha")
b = Cart("Beta")
a.items.append("Apple")

print(a)
print(b)
print(a.items is b.items)
결과
Cart(owner=’Alpha’, items=[‘Apple’])
Cart(owner=’Beta’, items=[])
False

field() 함수는 필드마다 세부 설정을 지정할 때 사용합니다.

default_factory에는 인수 없이 호출할 수 있는 함수를 전달하며, 인스턴스가 생성될 때마다 호출되어 새로운 객체를 기본값으로 사용합니다.

그러므로 a.items와 b.items는 서로 다른 목록이 됩니다.

repr=False로 설정한 count 필드는 출력 결과에서 제외됩니다.

결과를 보면 a에만 Apple이 추가되었고 b.items는 빈 리스트이며, 마지막 줄의 is 비교도 False입니다. 생성된 __init__은 items가 전달되지 않을 때마다 list()를 호출해 새 리스트를 만들어 할당하기 때문입니다.

default_factory에는 list, dict, set 같은 클래스뿐만 아니라 lambda: ["기본"]처럼 초기 값을 담은 객체를 만드는 함수도 전달할 수 있습니다.


field() 주요 매개변수

매개변수 설명
default 필드의 기본값
default_factory 기본값을 생성하는 함수(인스턴스마다 호출)
init False일 경우 __init__의 매개변수에서 제외
repr False일 경우 __repr__ 출력에서 제외
compare False일 경우 비교(__eq__, 정렬) 대상에서 제외
kw_only True일 경우 키워드 전용 매개변수로 설정(Python 3.10 이상)



초기화 후처리(post_init)

from dataclasses import dataclass, field


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

    def __post_init__(self):
        if self.width < 0 or self.height < 0:
            raise ValueError("width와 height는 0 이상이어야 합니다.")
        self.area = self.width * self.height


print(Rectangle(3, 4))

try:
    Rectangle(-1, 4)
except ValueError as e:
    print(e)
결과
Rectangle(width=3, height=4, area=12)
width와 height는 0 이상이어야 합니다.

데이터 클래스는 __init__ 메서드를 자동으로 생성하므로, 초기화 과정에 코드를 추가하려면 __post_init__ 메서드를 사용합니다.

__post_init__ 메서드는 자동 생성된 __init__ 메서드가 필드 할당을 마친 직후 호출됩니다.

주로 값의 유효성 검사나 다른 필드로부터 계산되는 필드를 설정할 때 활용합니다.

field(init=False)로 설정한 area 필드는 생성자의 매개변수에서 제외되며, __post_init__ 메서드에서 값을 할당합니다.

결과의 첫 줄은 Rectangle(3, 4)가 만들어진 뒤 __post_init__이 area를 12로 계산한 결과입니다. 두 번째 줄은 width가 음수여서 __post_init__이 ValueError를 발생시킨 결과이며, 이 경우 인스턴스는 만들어지지 않습니다.

__init__을 직접 작성하면 자동 생성 기능이 사라지므로, 데이터 클래스에서 초기화에 코드를 더하고 싶을 때에는 __init__을 덮어쓰지 않고 __post_init__을 사용합니다.


from dataclasses import dataclass, field, InitVar


@dataclass
class Account:
    owner: str
    password: InitVar[str]
    hashed: str = field(init=False, repr=False)

    def __post_init__(self, password):
        self.hashed = "*" * len(password)


a = Account("Alpha", "secret")
print(a)
print(a.hashed)
print(hasattr(a, "password"))
결과
Account(owner=’Alpha’)
******
False

생성자에서 값을 받기는 하지만 필드로 저장하지는 않을 값은 dataclasses.InitVar로 표기합니다.

InitVar[str]로 선언한 password는 __init__의 매개변수가 되고, 그 값은 __post_init__의 인수로 전달됩니다. 하지만 인스턴스 속성으로 저장되지 않으므로 hasattr(a, "password")는 False이며 출력 결과에도 나타나지 않습니다.

예제에서는 원래 비밀번호를 저장하지 않고 가공한 값만 hashed에 저장했습니다. 이처럼 초기화에만 필요한 값을 받을 때 활용합니다.



불변 데이터 클래스(frozen)

설정값이나 좌표, 색상처럼 한 번 만든 뒤에 바뀌면 안 되는 값이 있습니다. 여러 곳에서 같은 객체를 공유할 때 한 곳에서 실수로 값을 바꾸면 다른 곳의 동작까지 달라지므로, 처음부터 변경할 수 없게 만드는 것이 안전합니다.

from dataclasses import dataclass, FrozenInstanceError


@dataclass(frozen=True)
class Color:
    r: int
    g: int
    b: int


red = Color(255, 0, 0)

try:
    red.r = 0
except FrozenInstanceError as e:
    print(type(e).__name__, ":", e)

palette = {red: "빨강"}
print(palette[Color(255, 0, 0)])
print(hash(red) == hash(Color(255, 0, 0)))
결과
FrozenInstanceError : cannot assign to field ‘r’
빨강
True

@dataclass(frozen=True)로 설정하면 인스턴스를 생성한 이후 필드 값을 변경할 수 없는 불변(Immutable) 객체가 됩니다.

필드에 값을 할당하면 FrozenInstanceError가 발생합니다.

frozen=True와 eq=True(기본값)가 함께 설정되면 필드 값을 기반으로 하는 __hash__ 메서드가 생성되므로, 사전(Dictionary)의 키나 집합(Set)의 원소로 사용할 수 있습니다.

결과의 두 번째 줄은 red를 키로 저장한 사전에서, 새로 만든 Color(255, 0, 0)으로 값을 찾은 결과입니다. 두 객체는 서로 다른 인스턴스지만 필드 값이 같으므로 해시와 == 결과가 같아 같은 키로 취급됩니다.

불변은 생성된 __setattr__이 필드에 값을 할당하려 할 때 FrozenInstanceError를 발생시키는 방식으로 구현됩니다.


from dataclasses import dataclass


@dataclass
class Point:
    x: int
    y: int


p = Point(1, 2)
p.x = 10
print(p)

try:
    print(hash(p))
except TypeError as e:
    print("TypeError :", e)
결과
Point(x=10, y=2)
TypeError : unhashable type: ‘Point’

반대로 frozen을 설정하지 않은 기본 데이터 클래스는 필드를 변경할 수 있고, 해시할 수 없습니다.

__eq__가 필드 값으로 비교하는데 필드 값이 바뀔 수 있다면, 사전에 키로 넣은 뒤 값이 바뀌었을 때 해시가 달라져 그 키를 다시 찾을 수 없게 됩니다. 그래서 데이터 클래스는 eq=True이면서 frozen=False일 때 __hash__를 None으로 설정해 이런 사용을 막습니다.

사전의 키나 집합의 원소로 사용할 데이터 클래스라면 frozen=True로 만듭니다.

  • Tip : frozen=True는 필드 재할당만 막습니다. 필드에 저장된 목록과 같은 가변 객체의 내부 값은 변경할 수 있습니다.

  • Tip : frozen=True인 클래스의 __post_init__에서 필드 값을 설정해야 한다면 object.__setattr__(self, "필드명", 값)을 사용합니다.



정렬(order)

from dataclasses import dataclass


@dataclass(order=True)
class Version:
    major: int
    minor: int
    patch: int = 0


versions = [Version(3, 14, 6), Version(3, 9), Version(3, 13, 1), Version(3, 14)]
print(sorted(versions))
print(Version(3, 14) < Version(3, 14, 6))
print(max(versions))
결과
[Version(major=3, minor=9, patch=0), Version(major=3, minor=13, patch=1), Version(major=3, minor=14, patch=0), Version(major=3, minor=14, patch=6)]
True
Version(major=3, minor=14, patch=6)

@dataclass(order=True)로 설정하면 __lt__, __le__, __gt__, __ge__ 비교 메서드가 생성됩니다.

비교는 필드를 정의한 순서대로 묶은 튜플을 비교하는 것과 같습니다.

즉, major를 먼저 비교하고 같다면 minor, 그다음 patch를 비교합니다.

결과의 첫 줄을 보면 major가 모두 3이므로 minor로 순서가 정해지고, minor가 14로 같은 두 버전은 patch로 순서가 정해집니다. 문자열로 비교했다면 "3.9"가 "3.14"보다 크다고 판단했겠지만, 정수 필드를 비교하므로 올바른 순서가 됩니다.

Version(3, 14) < Version(3, 14, 6)은 (3, 14, 0) < (3, 14, 6)을 비교한 것과 같아 True이며, max()도 같은 기준으로 가장 큰 버전을 찾습니다.


from dataclasses import dataclass


@dataclass(order=True)
class Version:
    major: int
    minor: int


@dataclass(order=True)
class Build:
    major: int
    minor: int


print(Version(3, 14) == Build(3, 14))

try:
    Version(3, 14) < Build(3, 14)
except TypeError as e:
    print("TypeError :", e)
결과
False
TypeError : ‘<’ not supported between instances of ‘Version’ and ‘Build’

생성된 비교 메서드는 같은 클래스의 인스턴스끼리만 비교합니다.

필드 구성과 값이 완전히 같아도 클래스가 다르면 ==는 False이고, <는 TypeError가 발생합니다. 서로 다른 의미의 데이터가 우연히 같은 값을 가졌다고 같다고 판단하지 않도록 하기 위한 동작입니다.


from dataclasses import dataclass, field


@dataclass(order=True)
class Student:
    sort_index: int = field(init=False, repr=False)
    name: str
    score: int

    def __post_init__(self):
        self.sort_index = -self.score


students = [Student("A", 80), Student("B", 95), Student("C", 70)]
print(sorted(students))
결과
[Student(name=’B’, score=95), Student(name=’A’, score=80), Student(name=’C’, score=70)]

정렬 기준을 바꾸고 싶다면 정렬용 필드를 가장 앞에 정의하고 __post_init__에서 값을 계산합니다.

sort_index에 점수의 음수를 저장했으므로 점수가 높은 순서로 정렬됩니다.

  • Tip : 특정 필드를 비교 대상에서 제외하려면 field(compare=False)를 사용합니다.



슬롯(slots)

from dataclasses import dataclass


@dataclass
class Normal:
    x: int
    y: int


@dataclass(slots=True)
class Slotted:
    x: int
    y: int


n = Normal(1, 2)
s = Slotted(1, 2)

print(Slotted.__slots__)
print(hasattr(n, "__dict__"), hasattr(s, "__dict__"))

n.z = 3
print(n.z)

try:
    s.z = 3
except AttributeError as e:
    print(e)
결과
(‘x’, ‘y’)
True False
3
‘Slotted’ object has no attribute ‘z’ and no __dict__ for setting new attributes

Python 3.10 이상부터 @dataclass(slots=True)로 __slots__가 적용된 데이터 클래스를 생성할 수 있습니다.

__slots__가 적용된 인스턴스는 속성을 저장하는 __dict__를 갖지 않으므로 메모리 사용량이 줄고 속성 접근이 빨라집니다.

대신 정의되지 않은 속성을 새로 추가할 수 없습니다.

인스턴스를 대량으로 생성하는 경우에 유용합니다.

결과를 보면 __slots__에 필드 이름이 튜플로 등록되었고, Normal의 인스턴스만 __dict__를 가지고 있습니다. Normal 인스턴스에는 선언하지 않은 z 속성을 추가할 수 있지만, Slotted 인스턴스는 AttributeError가 발생합니다.

일반 인스턴스는 속성을 인스턴스마다 하나씩 있는 사전에 저장하지만, __slots__를 사용하면 정해진 개수의 자리에 바로 저장합니다. 사전이 없으므로 메모리가 줄고, 속성 이름을 잘못 입력해 새 속성이 만들어지는 실수도 막을 수 있습니다.

  • Tip : __slots__에 대한 설명은 제 37강 속성(Attribute)을 참고합니다.



키워드 전용 필드(kw_only)

from dataclasses import dataclass


@dataclass(kw_only=True)
class Config:
    host: str
    port: int = 8080
    debug: bool = False


print(Config(host="localhost", debug=True))

try:
    Config("localhost", 80)
except TypeError as e:
    print(e)
결과
Config(host=’localhost’, port=8080, debug=True)
Config.__init__() takes 1 positional argument but 3 were given

Python 3.10 이상부터 @dataclass(kw_only=True)로 모든 필드를 키워드 전용 인수로 설정할 수 있습니다.

필드가 많은 경우 위치 인수로 전달하면 순서를 헷갈리기 쉬우므로, 키워드 전용 인수로 설정하면 코드의 가독성이 높아집니다.

위치 인수로 전달하면 TypeError가 발생합니다.

Config("localhost", 80)만 보고는 80이 포트인지 시간 제한인지 알 수 없지만, Config(host="localhost", port=80)은 코드만 읽어도 의미가 분명합니다. 또한 나중에 필드의 순서를 바꾸거나 중간에 필드를 추가해도, 키워드로 전달하는 호출 코드는 영향을 받지 않습니다.

  • Tip : 오류 메시지의 1 positional argument는 self를 의미합니다.


from dataclasses import dataclass, field, KW_ONLY


@dataclass
class Request:
    url: str
    method: str = "GET"
    _: KW_ONLY
    timeout: float = 10.0
    headers: dict = field(default_factory=dict)
    retry: int


r = Request("https://example.com", "POST", retry=3)
print(r)
결과
Request(url=’https://example.com’, method=’POST’, timeout=10.0, headers={}, retry=3)

일부 필드만 키워드 전용으로 설정하려면 Python 3.10 이상에서 추가된 KW_ONLY를 사용합니다.

_: KW_ONLY는 필드가 아닌 구분자 역할을 하며, 이후에 정의된 필드는 모두 키워드 전용 필드가 됩니다.

제 33강 키워드 인자화에서 다룬 함수 매개변수의 *와 같은 역할입니다.

키워드 전용 필드는 위치 순서의 제약을 받지 않으므로, 기본값이 있는 timeout 뒤에 기본값이 없는 retry 필드를 정의해도 오류가 발생하지 않습니다.

  • Tip : 필드 하나만 키워드 전용으로 설정하려면 field(kw_only=True)를 사용합니다.



변환과 복사(asdict, replace)

import copy
from dataclasses import dataclass, field, asdict, astuple, replace


@dataclass
class Address:
    city: str
    zipcode: str


@dataclass(frozen=True)
class User:
    name: str
    age: int
    address: Address
    tags: list = field(default_factory=list)


user = User("Alpha", 20, Address("Seoul", "04524"), ["admin"])

print(asdict(user))
print(astuple(user))

older = replace(user, age=21)
print(older)
print(user.age, older.age)

renamed = copy.replace(user, name="Beta")
print(renamed)
print(older.tags is user.tags)
결과
{‘name’: ‘Alpha’, ‘age’: 20, ‘address’: {‘city’: ‘Seoul’, ‘zipcode’: ‘04524’}, ‘tags’: [‘admin’]}
(‘Alpha’, 20, (‘Seoul’, ‘04524’), [‘admin’])
User(name=’Alpha’, age=21, address=Address(city=’Seoul’, zipcode=’04524’), tags=[‘admin’])
20 21
User(name=’Beta’, age=20, address=Address(city=’Seoul’, zipcode=’04524’), tags=[‘admin’])
True

asdict() 함수는 데이터 클래스를 사전(Dictionary)으로, astuple() 함수는 튜플(Tuple)로 변환합니다.

필드에 다른 데이터 클래스가 포함되어 있다면 재귀적으로 변환하므로, address 필드도 사전으로 변환됩니다.

JSON으로 저장하거나 다른 함수에 전달할 때 유용합니다.

asdict()와 astuple()은 리스트 같은 필드의 값도 새로 복사해서 담습니다. 그러므로 변환 결과를 수정해도 원본 인스턴스는 바뀌지 않습니다.


replace() 함수는 기존 인스턴스는 그대로 두고 일부 필드만 변경한 새로운 인스턴스를 반환합니다.

frozen=True로 필드 값을 변경할 수 없는 경우에도 값을 바꾼 새 객체를 만들 수 있습니다.

Python 3.13 이상부터는 copy.replace() 함수로 같은 작업을 수행할 수 있습니다.

copy.replace()는 데이터 클래스뿐만 아니라 namedtuple, datetime 등 __replace__ 메서드를 지원하는 객체에 공통으로 사용할 수 있습니다.

  • Tip : replace()는 얕은 복사입니다. 변경하지 않은 tags 필드는 원본과 같은 목록을 참조하므로 is 연산 결과가 True입니다.

  • Tip : field(init=False)로 정의한 필드는 replace()로 변경할 수 없습니다.

replace()는 원본의 필드 값을 읽은 뒤 바꿀 값만 덮어써서 __init__을 다시 호출하는 방식으로 동작합니다. 그러므로 __post_init__의 유효성 검사도 새 인스턴스에 다시 적용됩니다. 결과에서 user.age는 20 그대로이고 older.age만 21인 것을 확인할 수 있습니다.


import json
from dataclasses import dataclass, asdict


@dataclass
class Item:
    name: str
    price: int


@dataclass
class Order:
    order_id: int
    items: list[Item]

    def total(self) -> int:
        return sum(item.price for item in self.items)


order = Order(1, [Item("Apple", 1000), Item("Pear", 2500)])
print(order.total())

text = json.dumps(asdict(order), ensure_ascii=False)
print(text)

data = json.loads(text)
restored = Order(data["order_id"], [Item(**item) for item in data["items"]])
print(restored == order)
결과
3500
{“order_id”: 1, “items”: [{“name”: “Apple”, “price”: 1000}, {“name”: “Pear”, “price”: 2500}]}
True

데이터 클래스를 JSON으로 저장하고 다시 불러오는 실용 예제입니다.

asdict()는 items 리스트 안의 Item 인스턴스까지 사전으로 변환하므로, 결과를 바로 json.dumps()에 전달할 수 있습니다.

반대로 JSON을 읽은 결과는 사전이므로, Item(**item)처럼 사전을 키워드 인수로 풀어 인스턴스를 다시 만듭니다. 마지막 줄의 True는 복원한 객체가 원래 객체와 모든 필드 값이 같다는 의미입니다.

total()처럼 데이터와 관련된 계산을 메서드로 함께 두면, 사전으로 데이터를 다룰 때보다 코드의 의도가 명확해집니다.

  • Tip : asdict()는 사전으로 바꾸기만 하며, 사전에서 데이터 클래스로 되돌리는 표준 함수는 없습니다. 중첩된 데이터 클래스는 예제처럼 직접 변환합니다.



namedtuple, TypedDict와 비교

from collections import namedtuple
from dataclasses import dataclass
from typing import NamedTuple, TypedDict


PointA = namedtuple("PointA", ["x", "y"])


class PointB(NamedTuple):
    x: int
    y: int


class PointC(TypedDict):
    x: int
    y: int


@dataclass
class PointD:
    x: int
    y: int


a = PointA(1, 2)
b = PointB(1, 2)
c = PointC(x=1, y=2)
d = PointD(1, 2)

print(a, b, c, d, sep="\n")
print(type(c))
print(a == (1, 2), b == (1, 2), d == (1, 2))

x, y = b
print(x, y)

c["x"] = "문자열"
print(c)

try:
    a.x = 10
except AttributeError as e:
    print(e)
결과
PointA(x=1, y=2)
PointB(x=1, y=2)
{‘x’: 1, ‘y’: 2}
PointD(x=1, y=2)
<class ‘dict’>
True True False
1 2
{‘x’: ‘문자열’, ‘y’: 2}
can’t set attribute

값을 묶어서 저장하는 방법은 데이터 클래스 외에도 namedtuple, typing.NamedTuple, typing.TypedDict가 있습니다.

namedtuple과 NamedTuple은 튜플을 상속하므로 불변이며, 일반 튜플과 비교하거나 언패킹할 수 있습니다.

TypedDict는 타입 검사 도구를 위한 힌트일 뿐이며, 실제로 생성되는 객체는 일반 사전(Dictionary)입니다. 그러므로 int로 선언한 키에 문자열을 넣어도 오류가 발생하지 않습니다.

데이터 클래스는 일반 클래스이므로 튜플과 같지 않으며, 메서드와 기본값, 후처리 등을 가장 유연하게 설정할 수 있습니다.

결과를 순서대로 보면, 네 방식 모두 비슷하게 출력되지만 TypedDict만 사전 형태로 출력되고 type(c)도 dict입니다. a == (1, 2)와 b == (1, 2)는 튜플이므로 True지만, 데이터 클래스인 d는 False입니다. x, y = b처럼 NamedTuple은 언패킹할 수 있고, TypedDict에는 다른 자료형을 넣어도 오류가 없으며, namedtuple은 값을 바꾸려 하면 AttributeError가 발생합니다.


구분 dataclass namedtuple / NamedTuple TypedDict
실제 객체 일반 클래스 인스턴스 튜플 사전
값 변경 가능(frozen=True로 불가) 불가 가능
값 접근 obj.x obj.x, obj[0] obj["x"]
언패킹 불가 가능 불가(키만 반복)
기본값 가능 가능 불가
메서드 추가 가능 가능 불가
정렬·해시 order, frozen 설정 튜플과 동일 해시 불가
실행 중 타입 검사 없음 없음 없음


  • Tip : 함수 간에 불변의 작은 레코드를 주고받는다면 NamedTuple, JSON처럼 사전 형태의 데이터에 타입 힌트만 붙이고 싶다면 TypedDict, 그 외에 동작을 가진 데이터 객체가 필요하다면 데이터 클래스가 적합합니다.



정리

이름 설명 반환값/특징
@dataclass 필드 선언으로 __init__, __repr__, __eq__ 자동 생성 같은 클래스를 수정해 반환
field() 필드별 세부 설정 default, default_factory, init, repr, compare, kw_only
default_factory 인스턴스마다 새 기본값 생성 가변 기본값은 반드시 사용
ClassVar 필드가 아닌 클래스 변수 표기 생성자·출력에서 제외
__post_init__ 자동 생성된 __init__ 직후 호출 유효성 검사, 계산 필드
InitVar 초기화에만 쓰는 인수 __post_init__에 전달, 속성으로 저장되지 않음
frozen=True 필드 변경 금지 __hash__ 생성, 사전 키로 사용 가능
order=True 비교 연산자 생성 필드 순서대로 튜플처럼 비교
slots=True __slots__ 적용 메모리 절약, 새 속성 추가 불가 (Python 3.10 이상)
kw_only / KW_ONLY 키워드 전용 필드 Python 3.10 이상
fields() 필드 정보 조회 Field 객체의 튜플
asdict() / astuple() 사전·튜플로 재귀 변환 새 객체 반환
replace() / copy.replace() 일부 필드만 바꾼 새 인스턴스 얕은 복사, copy.replace는 Python 3.13 이상


데이터 클래스는 반복되는 메서드 작성을 대신해 주는 데코레이터일 뿐, 결과물은 평범한 클래스입니다. 그러므로 필요한 옵션만 켜고, 나머지는 일반 클래스와 똑같이 메서드를 추가해 사용하면 됩니다.

댓글 남기기