데이터클래스 (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 직렬화 등에 활용 |