본문으로 건너뛰기
Advertisement

데이터클래스 (dataclass)

@dataclass (Python 3.7+)는 주로 데이터를 저장하는 클래스를 간결하게 정의할 수 있는 데코레이터입니다. __init__, __repr__, __eq__ 등 반복적으로 작성하던 메서드를 자동으로 생성해줍니다.


@dataclass 기본

from dataclasses import dataclass


# 일반 클래스 방식 — 반복적인 보일러플레이트
class PointOld:
def __init__(self, x: float, y: float):
self.x = x
self.y = y

def __repr__(self) -> str:
return f"Point(x={self.x}, y={self.y})"

def __eq__(self, other: object) -> bool:
if not isinstance(other, PointOld):
return NotImplemented
return self.x == other.x and self.y == other.y


# @dataclass 방식 — 훨씬 간결
@dataclass
class Point:
x: float
y: float


p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
p3 = Point(3.0, 4.0)

print(p1) # Point(x=1.0, y=2.0) — __repr__ 자동 생성
print(p1 == p2) # True — __eq__ 자동 생성
print(p1 == p3) # False
print(p1.x) # 1.0

자동 생성 메서드

from dataclasses import dataclass


@dataclass
class Student:
name: str
student_id: str
gpa: float
major: str = "미정" # 기본값

# 자동 생성된 것들:
# __init__(self, name, student_id, gpa, major="미정")
# __repr__
# __eq__

s1 = Student("김철수", "2024001", 4.0)
s2 = Student("이영희", "2024002", 3.8, "컴퓨터공학")
s3 = Student("김철수", "2024001", 4.0)

print(s1) # Student(name='김철수', student_id='2024001', gpa=4.0, major='미정')
print(s1 == s3) # True (모든 필드가 같으면 동일)
print(s1 == s2) # False
print(s1.major) # 미정

field(): 세밀한 필드 제어

from dataclasses import dataclass, field
from typing import ClassVar


@dataclass
class Config:
host: str
port: int = 8080

# 가변 기본값은 반드시 field(default_factory=...) 사용
# tags: list = [] ← 오류! 가변 기본값 직접 사용 불가
tags: list[str] = field(default_factory=list)
metadata: dict = field(default_factory=dict)

# repr=False: __repr__에서 제외
_internal_id: str = field(default="", repr=False)

# compare=False: __eq__, __lt__ 등에서 제외
description: str = field(default="", compare=False)

# init=False: __init__ 매개변수에서 제외
created_at: str = field(default="", init=False)

# ClassVar: 클래스 변수 (dataclass 필드 아님)
MAX_TAGS: ClassVar[int] = 10

def __post_init__(self):
"""__init__ 완료 후 자동 호출"""
from datetime import datetime
self.created_at = datetime.now().isoformat()
if len(self.tags) > self.MAX_TAGS:
raise ValueError(f"태그는 최대 {self.MAX_TAGS}개")


cfg1 = Config("localhost")
cfg2 = Config("localhost", tags=["dev", "test"])

print(cfg1) # Config(host='localhost', port=8080, tags=[], ...)
print(cfg1 == cfg2) # False (tags가 다름)
print(cfg1.created_at) # 생성 시각

__post_init__: 초기화 후 처리

from dataclasses import dataclass, field
import re


@dataclass
class Email:
address: str

def __post_init__(self):
self.address = self.address.strip().lower()
if not re.match(r"[^@]+@[^@]+\.[^@]+", self.address):
raise ValueError(f"유효하지 않은 이메일: {self.address!r}")


@dataclass
class OrderItem:
product_name: str
unit_price: float
quantity: int
# init=False 필드는 __post_init__에서 설정
total_price: float = field(init=False)
discount_rate: float = 0.0

def __post_init__(self):
if self.unit_price < 0:
raise ValueError("단가는 음수일 수 없습니다.")
if self.quantity < 1:
raise ValueError("수량은 1 이상이어야 합니다.")
if not (0 <= self.discount_rate <= 1):
raise ValueError("할인율은 0~1 사이여야 합니다.")
self.total_price = self.unit_price * self.quantity * (1 - self.discount_rate)


item = OrderItem("Python 책", 35000, 3, discount_rate=0.1)
print(item)
# OrderItem(product_name='Python 책', unit_price=35000, quantity=3,
# total_price=94500.0, discount_rate=0.1)

email = Email(" USER@EXAMPLE.COM ")
print(email.address) # user@example.com

try:
bad_email = Email("not-an-email")
except ValueError as e:
print(e)

frozen=True: 불변 데이터클래스

from dataclasses import dataclass
import hashlib


@dataclass(frozen=True)
class Point3D:
"""불변 포인트 — 생성 후 수정 불가"""
x: float
y: float
z: float = 0.0

def distance_to_origin(self) -> float:
return (self.x ** 2 + self.y ** 2 + self.z ** 2) ** 0.5

def translate(self, dx: float, dy: float, dz: float = 0.0) -> "Point3D":
"""새 Point3D 반환 (불변이므로 수정 불가)"""
return Point3D(self.x + dx, self.y + dy, self.z + dz)


p = Point3D(1.0, 2.0, 3.0)

# 수정 시도 시 FrozenInstanceError
try:
p.x = 10.0
except Exception as e:
print(f"오류: {type(e).__name__}: {e}")

# frozen=True이면 __hash__가 자동 생성 → 딕셔너리 키, 집합 요소로 사용 가능
positions = {Point3D(0, 0), Point3D(1, 1), Point3D(0, 0)} # 중복 제거
print(positions) # {Point3D(x=0, y=0, z=0.0), Point3D(x=1, y=1, z=0.0)}

cache: dict[Point3D, str] = {}
cache[p] = "origin"
print(cache[Point3D(1.0, 2.0, 3.0)]) # origin


@dataclass(frozen=True)
class ImmutableConfig:
host: str
port: int
debug: bool = False
allowed_origins: tuple[str, ...] = () # 불변 컬렉션 사용

def with_port(self, new_port: int) -> "ImmutableConfig":
"""새 설정 객체 반환"""
from dataclasses import replace
return replace(self, port=new_port)


cfg = ImmutableConfig("localhost", 8080, allowed_origins=("http://localhost:3000",))
new_cfg = cfg.with_port(9090)
print(cfg.port) # 8080 (원본 변경 없음)
print(new_cfg.port) # 9090

order=True: 비교 연산자 자동 생성

from dataclasses import dataclass


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

def __str__(self) -> str:
return f"v{self.major}.{self.minor}.{self.patch}"


# order=True → __lt__, __le__, __gt__, __ge__ 자동 생성 (필드 순서대로 비교)
v1 = Version(1, 0, 0)
v2 = Version(1, 2, 3)
v3 = Version(2, 0, 0)

print(v1 < v2) # True
print(v3 > v2) # True
print(sorted([v3, v1, v2])) # [v1.0.0, v1.2.3, v2.0.0]

dataclass vs NamedTuple vs TypedDict 비교

from dataclasses import dataclass
from typing import NamedTuple, TypedDict


# 1. dataclass — 가장 유연, 가변/불변 모두 지원
@dataclass
class DataclassPoint:
x: float
y: float

def magnitude(self) -> float:
return (self.x ** 2 + self.y ** 2) ** 0.5


# 2. NamedTuple — 튜플 기반, 불변, 인덱스 접근 가능
class NamedTuplePoint(NamedTuple):
x: float
y: float


# 3. TypedDict — 딕셔너리 기반, JSON 데이터와 잘 어울림
class TypedDictPoint(TypedDict):
x: float
y: float


# 비교
dc = DataclassPoint(1.0, 2.0)
nt = NamedTuplePoint(1.0, 2.0)
td: TypedDictPoint = {"x": 1.0, "y": 2.0}

print(dc) # DataclassPoint(x=1.0, y=2.0)
print(nt) # NamedTuplePoint(x=1.0, y=2.0)
print(td) # {'x': 1.0, 'y': 2.0}

# NamedTuple: 인덱스 접근 가능
print(nt[0]) # 1.0
print(tuple(nt)) # (1.0, 2.0)

# dataclass: 메서드 추가 가능
print(dc.magnitude()) # 2.23...

# TypedDict: 딕셔너리처럼 동작
print(td["x"]) # 1.0

선택 기준

dataclass:
- 메서드가 필요한 경우
- 가변/불변 모두 지원
- 일반적인 데이터 객체

NamedTuple:
- 불변 레코드
- 튜플 언패킹이 필요한 경우
- CSV 행, 좌표 등 작은 데이터

TypedDict:
- JSON API 응답 모델링
- 딕셔너리 구조가 이미 정해진 경우
- 기존 딕셔너리 코드와 통합

상속과 dataclass

from dataclasses import dataclass


@dataclass
class Animal:
name: str
age: int


@dataclass
class Dog(Animal):
breed: str
is_trained: bool = False

def bark(self) -> str:
return f"{self.name}: 왈왈!"


@dataclass
class GuideDog(Dog):
handler: str = ""
certification_id: str = ""

def __post_init__(self):
if not self.is_trained:
raise ValueError("안내견은 훈련을 받아야 합니다.")


d = Dog("바둑이", 3, "진돗개")
print(d)
# Dog(name='바둑이', age=3, breed='진돗개', is_trained=False)

gd = GuideDog("눈이", 4, "리트리버", is_trained=True,
handler="김안내", certification_id="GD-001")
print(gd)
print(gd.bark())

실전 예제: API 응답 모델

from dataclasses import dataclass, field
from typing import Optional
from datetime import datetime


@dataclass
class Address:
street: str
city: str
country: str = "KR"
postal_code: str = ""

def __str__(self) -> str:
return f"{self.postal_code} {self.country} {self.city} {self.street}"


@dataclass
class UserProfile:
id: int
username: str
email: str
address: Optional[Address] = None
tags: list[str] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.now)
is_active: bool = True
# 민감한 정보 — repr에서 제외
_hashed_password: str = field(default="", repr=False, compare=False)

def to_public_dict(self) -> dict:
"""공개 가능한 정보만 반환"""
return {
"id": self.id,
"username": self.username,
"email": self.email,
"tags": self.tags,
"created_at": self.created_at.isoformat(),
"is_active": self.is_active,
}

@classmethod
def from_api_response(cls, data: dict) -> "UserProfile":
"""API 응답 딕셔너리에서 생성"""
address_data = data.get("address")
address = Address(**address_data) if address_data else None
return cls(
id=data["id"],
username=data["username"],
email=data["email"],
address=address,
tags=data.get("tags", []),
)


# API 응답 시뮬레이션
api_response = {
"id": 1,
"username": "kim_python",
"email": "kim@example.com",
"address": {
"street": "테헤란로 123",
"city": "서울",
"postal_code": "06234",
},
"tags": ["python", "backend"],
}

user = UserProfile.from_api_response(api_response)
print(user)
print(f"\n공개 정보: {user.to_public_dict()}")

실전 예제: 설정 객체

from dataclasses import dataclass, field
from pathlib import Path


@dataclass
class DatabaseConfig:
host: str = "localhost"
port: int = 5432
database: str = "myapp"
user: str = "postgres"
_password: str = field(default="", repr=False)

@property
def url(self) -> str:
return f"postgresql://{self.user}:***@{self.host}:{self.port}/{self.database}"


@dataclass
class ServerConfig:
host: str = "0.0.0.0"
port: int = 8000
workers: int = 4
debug: bool = False
cors_origins: list[str] = field(default_factory=lambda: ["http://localhost:3000"])


@dataclass
class AppConfig:
app_name: str = "MyApp"
version: str = "1.0.0"
database: DatabaseConfig = field(default_factory=DatabaseConfig)
server: ServerConfig = field(default_factory=ServerConfig)
log_level: str = "INFO"
log_file: Path = field(default_factory=lambda: Path("logs/app.log"))

@classmethod
def from_env(cls) -> "AppConfig":
"""환경 변수에서 설정 로드"""
import os
db = DatabaseConfig(
host=os.getenv("DB_HOST", "localhost"),
port=int(os.getenv("DB_PORT", "5432")),
database=os.getenv("DB_NAME", "myapp"),
)
server = ServerConfig(
port=int(os.getenv("PORT", "8000")),
debug=os.getenv("DEBUG", "false").lower() == "true",
)
return cls(database=db, server=server)


cfg = AppConfig.from_env()
print(cfg.database.url)
print(f"서버: {cfg.server.host}:{cfg.server.port}")
print(f"CORS: {cfg.server.cors_origins}")

고수 팁

1. dataclasses.replace()로 불변 객체 복사

from dataclasses import dataclass, replace


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


original = Config("localhost", 8080)
modified = replace(original, port=9090, debug=True)

print(original) # Config(host='localhost', port=8080, debug=False)
print(modified) # Config(host='localhost', port=9090, debug=True)

2. dataclasses.asdict(), astuple()

from dataclasses import dataclass, asdict, astuple


@dataclass
class Point:
x: float
y: float


p = Point(1.0, 2.0)
print(asdict(p)) # {'x': 1.0, 'y': 2.0}
print(astuple(p)) # (1.0, 2.0)

# 중첩 dataclass도 재귀적으로 변환
@dataclass
class Line:
start: Point
end: Point

line = Line(Point(0, 0), Point(3, 4))
print(asdict(line))
# {'start': {'x': 0, 'y': 0}, 'end': {'x': 3, 'y': 4}}

3. dataclasses.fields()로 필드 정보 조회

from dataclasses import dataclass, fields, field


@dataclass
class MyData:
name: str
value: int = 0
tags: list = field(default_factory=list, metadata={"description": "태그 목록"})


for f in fields(MyData):
print(f"이름: {f.name}, 타입: {f.type}, 기본값: {f.default}, 메타데이터: {f.metadata}")

정리

기능코드설명
기본 dataclass@dataclass__init__, __repr__, __eq__ 자동
불변@dataclass(frozen=True)__hash__ 생성, 수정 불가
정렬 지원@dataclass(order=True)비교 연산자 자동 생성
가변 기본값field(default_factory=list)리스트/딕셔너리 기본값
repr 제외field(repr=False)비밀번호 등 민감 정보
초기화 후 처리__post_init__유효성 검사, 계산 필드
복사dataclasses.replace()일부 필드만 변경한 복사본
딕셔너리 변환dataclasses.asdict()JSON 직렬화 등에 활용
Advertisement