[ 글의 목적: python 에서 dataclass 와 ENUM 을 쓰면서 생긴 팁 기록 & 공유 ]
3.7 부터 등장한 dataclass (PEP-557), 3.4 부터 등장한 ENUM(PEP-435), 사실 필자는 3.8 부터 해당 기능들을 제대로 쓰기 시작했다! 쓰면서 생긴 나름의 사용 방법을 공유하고자 한다 ㅎ (기본적인 사용법은 거의 skip, 만약 처음이라면 해당 글을 비추천!)
얘의 본질적인 등장 핵심은 "클래스 정의를 간결하게 만들어주는 것" 이며, 기본적으로 생기는 dunder method 에 __init__()
, __repr__()
, __eq__()
를 알아서 정의해주는 것 이다. (물론 비교 연산자, __hash__
등도 정의를 하게 할 수 있다.)
가장 와닿는 예시를 보면, server-side 에서 특정 layer 마다(또는 외부/내부, 도메인 등) "데이터(Object)" 를 주고 받을때 "너 어떻게 생겼니" 를 가르쳐 줘야 한다. (물론 상남자라면 dict 만 쓰면 된다. 평생 혼자 개발하면 된다.) 특히 만약 사내에서 python 으로 "DDD" 를 해보자! 한다면 이제 난리나는 부분들이 이 지점이 아닐까 한다 ㅎ
class ProductDTO:
def __init__(self, id, name, price, in_stock=True):
self.id = id
self.name = name
self.price = price
self.in_stock = in_stock
def __repr__(self):
return f"ProductDTO(id={self.id!r}, name={self.name!r}, price={self.price!r}, in_stock={self.in_stock!r})"
def __eq__(self, other):
if not isinstance(other, ProductDTO):
return NotImplemented
return (self.id, self.name, self.price, self.in_stock) == (other.id, other.name, other.price, other.in_stock)
아주 심플한 상품 DTO 를 위해 __init__
만 있어도 된다. 하지만 기본적으로 출력이나 equal 연산 정도는 해줘야 의미가 있다. (== 연산) 이게 쌓이고 쌓이다 보면 꽤나 아찔한 상황이 있다.
이때 원래 고민의 포인트는 자연스럽게 "Pydantic
를 쓰냐 마냐" 였지만, 사견으로 대부분의 경우 dataclass
선에서 정리가 가능한 것 같다. 시리얼라이징/디시리얼라이징 필요 없고, 외부 의존성도 필요없고, (상대적) 훨씬 유연하고, 오버헤드가 없어서 상대적으로 빠르다.
@dataclass
class ProductDTO:
id: int
name: str
price: float
in_stock: bool = True
ProductDTO
를 위와 같이 아주 심플하게 세팅 가능하다. 물론 "API 입출력, 사용자 입력 validation, 데이터 보정이 필요한 경우" 는 무조건 dataclass
를 사용하는 것은 비추천 한다. 오히려 pydantic.dataclasses.dataclass
를 쓰는 것도 좋은 수단이라고 생각한다. - https://docs.pydantic.dev/latest/concepts/dataclasses/
아래 부터는 "사견이 가득 담긴" 기록이다. 모든 활용을 쓰는 것 보다 실제 필자가 많이 쓴 것 정도, 실무에서 많이 사용해 본 것들 위주로 리스트 해봤다.
dataclass
는 기본적으로 "mutable" 한 객체이다. 하지만 외부 API 응답에 대한 단순 정의 같이 "굳이 바뀔필요 없고, 바뀌어서도 안되는 것" 들은 실수를 방지하게 위해 "immutable" 한게 좋다. (서버 설정 값, 배치 시작 설정 값 등)
그럴때 @dataclass(frozen=True)
를 쓰면 바로 read-only 객체, "immutable" 해진다.
@dataclass(frozen=True)
class DeliveryStatus:
order_id: str
status: str
updated_at: str
# 외부 API 응답 파싱
resp = DeliveryStatus(order_id="ORD123", status="in_transit", updated_at="2025-05-01T12:00:00")
# 값을 바꾸려 하면?
resp.status = "delivered"
# ❌ FrozenInstanceError: cannot assign to field 'status'
O(1)
속도로 같은 응답 체크가 가능해진다! ㅎㅎcache = {resp: "cached"}
if resp in cache:
print("이미 받은 응답입니다.")
default_factory
사실 팁이라기 보단 생각보다 놓칠 수 있는 default_factory
, 그리고 사실 python 3.9 이상이라면 무조건 사용했을 값이다. 즉 "기본값이 리스트, 딕셔너리, set 등 mutable
한 타입일 때는 무조건 써야 하는 값이다."
어떤 문제가 있었을까? "mutable" 한 object 대상으로 "값을 공유하는" 이슈가 있었다. 사실 이슈라기 보다는 python 자체가 기본값은 "정의 시점" 에 평가되는데, dataclass
최초 객체가 생길때 list 하나 만들었으면, 다음번에 만들때 계속 해당 list object 를 공유하기 때문!
@dataclass
class Basket:
items: list[str] = []
a = Basket()
a.items.append("apple")
b = Basket()
print(b.items) # ['apple'] 😱 ???
ValueError
가 발생한다. -> ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory
from dataclasses import dataclass, field
@dataclass
class Basket:
items: list[str] = field(default_factory=list)
a = Basket()
a.items.append("apple")
b = Basket()
print(b.items) # [] → 완전히 독립된 객체
__post_init__
dataclass
를 만들때 벨리데이션이 필요한 경우가 있다. 여기서 pydantic
에 대한 고민이 들지만, 정말 특정 필드에 대한 검증만 하면 된다면, __post_init__()
를 사용해 볼 수 있다.@dataclass
class User:
name: str
age: int
email: str
def __post_init__(self):
if self.age < 0:
raise ValueError(f"❌ 나이(age)는 음수가 될 수 없습니다: {self.age}")
if "@" not in self.email:
raise ValueError(f"❌ 이메일 형식이 잘못되었습니다: {self.email}")
절대 음수가 올 수 없는 값에 대해 ValueError
를 만들어 줄 수 있다. 위의 DeliveryStatus
라는 값 예시에서도, 외부에서 주는 값이라면, 그리고 status
가 절대 될 수 없는 값이 있다면, 이 방법이 아주 유용하다.
외부 API 응답은 성공했는데 응답이 기대한 것과 다를때에 대한 검증이 dataclass
만으로 바로 가능하기 때문이다.
from datetime import datetime
from dataclasses import dataclass
@dataclass
class Event:
title: str
date: str # 입력은 str으로 오지만…
def __post_init__(self):
parsed_date = datetime.fromisoformat(self.date)
object.__setattr__(self, 'date', parsed_date)
# 참고로 self.date ... 로도 접근 가능
더욱이 "값 보정" 을 할 수도 있다. 근데 개인적으로 값 보정 자체를 __post_init__
에서 하는 것은 비추천, "공백 제거 정도" 와 위 예시에서 date
가 어쩔 수 없이 문자열로 받지만, datetime
으로 casting 할 때 유의미 했다.
위 예시는 self.date
를 안썻는데, 이유는 frozen=True
경우는 불가능 하기 때문 이다!
asdict
/ astuple
DTO
나 아주 간단한 객체에 대한 데이터 명시로 사용할때, "직렬화" 가 필요한 경우 사용했다. 특히 중첩된 dataclass, 또는 Kafka (or other message queue)
를 위해서 사용할 때!from dataclasses import dataclass, asdict
@dataclass
class User:
name: str
age: int
@dataclass
class Post:
title: str
author: User
p = Post(title="Hello", author=User(name="Alice", age=<30))
print(asdict(p))
{
'title': 'Hello',
'author': {
'name': 'Alice',
'age': 30
}
}
자동으로 중첩 구조까지 재귀적으로 dict 변환이 된다. 이걸 이제 json.dumps()
할 수 있다! 외부 API / 서버 / 이기종 시스템을 위한 데이터 전송 세팅 준비가 (시리얼라이징) 딸깍 완료! 된다! - 참고로 100% 안전하지는 않는다... datetime
은 직렬화 불가..
그에 반해 사실 astuple
을 많이 쓰지는 않았는데 딱 한 번 매우 동감하며 쓴 기억이 있다. (LLM function call 을 위한) 임시 배치 프로세스를 만들었는데 SQL
을 만들때 "파라미터" 들을 dataclass
로 부터 출발해서 만들때!! 예시로 보면 아래와 같다!
from dataclasses import dataclass, astuple
@dataclass
class User:
name: str
age: int
u = User(name="Alice", age=30)
print(astuple(u)) # ('Alice', 30)
INSERT INTO
에 아주 이쁘게 ('Alice', 30)
를 넣을 수 있다. 즉, 순서형 데이터가 필요한 경우 꽤나 유용하게 쓸 수 있었다.
그리고 다른 일례로, dict는 mutable이라 hashable하지 않지만, tuple은 가능 (단, 내부에 mutable 요소가 없을 경우) 하기 때문에 if astuple(obj1) == astuple(obj2): ...
이런 해쉬 기반으로 아주 빠른 비교가 가능하다.
order
옵션을 통해, data간 대소비교!__eq__()
만 만들어주지만, order=True
를 설정하면__lt__
, __le__
, __gt__
, __ge__
까지 자동으로 추가된다!! 즉, 아래처럼 객체끼리 정렬, 크기 비교가 가능해진다. from dataclasses import dataclass
@dataclass(order=True)
class User:
score: int
name: str
users = [
User(score=50, name="Alice"),
User(score=90, name="Bob"),
User(score=75, name="Charlie"),
]
sorted_users = sorted(users) # score 기준으로 정렬됨
heapq
(우선순위 큐) 를 쓴다면? 최소/최대값을 찾아야 한다면?! 대소 비교 (<, > 비교) 가 필요하다면?! 쓰는 것을 추천!@dataclass(order=True)
class Person:
sort_order: int # 이걸 기준으로 정렬됨
name: str
slots=True
로 메모리 최적화?!Python 3.10+
전용이다. 최근에 배치 프로세스 만들때, 사용자 데이터 분석 할 때 (bluk 로 가져올때) 사용했다. 목적은 단순하다. dataclass
만들때 __dict__
속성도 만들어진다. 근데 이걸 못하게 하는거다! (ps. python class 만들때 __dict__
가 만들어짐)from dataclasses import dataclass
@dataclass
class MyData:
x: int
y: int
d = MyData(1, 2)
d.z = 3 # 동적으로 필드 추가
print(d.__dict__) # {'x': 1, 'y': 2, 'z': 3}
z
attribute 가 만들어지는 것을 막을 수 있다. 참고로 원래 python 에서는 class
에서 이걸 막으려고 전통적으로 __slots__
을 사용했고, 아래와 같은 방식으로 사용했다. (아마 python 2 부터 가능한 것으로 알고 있다.)@dataclass
class A:
__slots__ = ['x', 'y']
x: int
y: int
z: int # ⚠️ 이 필드는 누락되었기 때문에 AttributeError 발생 가능
slots=True
를 추가하면 바로 가능!@dataclass(slots=True)
class B:
x: int
y: int
z: int # 걱정 없음, 자동 처리됨
ps) 이 외에 분명 엄청 많지만, 최대한 사견을 담은 것 위주로 리스팅했다. 더욱이 unsafe_hash
같은 것은 비추천.. 한다..!
Enumeration
, 열거형은 개인적으로 "휴먼 에러 최소화" 에 초점이 가득 담긴 친구라고 생각한다.여기서는 enum 을 쓰면서 기억에 남았던 것들, 좋은 사례를 모아보고자 한다.
python
은 보기드문 "순수 객체 지향 언어" 이다. (java 등과는 다르게 원시 타입이 없다.) str
, int
type 들도 "class" 이고, 객체의 인스턴스가 된다.
과한 자유로움은 장점이자 단점이 되고, 단점을 보강하기 위해 type 을 사용한다. (사실 "협업" 과 "유지보수" 가 최고 목표 아닐까?) python 은 본격적으로 이 부분을 지원한게 PEP 484 – Type Hints 로 시작된다. (물론 이전에 다양한 접근 방식이 존재했지만, official 부분만 놓고 보자면!)
이 흐름속에서 등장한게 Literal
이다. 이유는 단순하다. 정적 타입 힌팅이 강화되면서 str
, int
등 기본 타입은 제한할 수 있었지만, 특정 값만 허용하는 타입 제한은 불가능했기 때문 이다. - PEP 586 – Literal Types
항목 | enum.Enum | typing.Literal |
---|---|---|
정의 목적 | 상수 집합을 클래스로 정의하고, 값과 의미를 묶기 위함 | 함수 인자나 변수 값이 특정 값 중 하나임을 명시적으로 제한 |
런타임 존재 여부 | 런타임에도 존재하며 객체처럼 동작함 | 타입 힌트 전용, 런타임에는 아무 기능도 없음 |
값의 의미 표현 | 각 멤버는 이름과 값을 가지며, 독립된 의미 부여 가능 | 단순한 리터럴 값 ("A" , 1 등)을 나열하는 것 |
확장성 | 메서드 추가 등 확장 가능 (Color.RED.hex() ) | 불가능, 단순한 값 고정 |
타입 검사 도구 활용 | mypy, pyright 등에서도 동작 가능 | 타입 힌트로만 작동 (정적 분석 도구에만 의미 있음) |
용례 | 상태, 종류, 옵션 등의 구분 (UserType.ADMIN ) | 함수 인자의 값 제한 (Literal["asc", "desc"] ) |
Literal
은 여전히 잘 쓰고 있다. ps) Python 3.9~3.11에 걸쳐 typing 라이브러리 일부 기능은 built-in 이 되었으며, 필자는 거의 더 이상 typing 를 사용하지 않고 있다.
str
class 상속 같이 받기.value
attribute 접근 필요가 없어진다.class Reliability(Enum):
HIGH = "high"
reliability = Reliability.HIGH
print(type(reliability)) # <enum 'Reliability'>
print(reliability) # Reliability.HIGH 출력
print(reliability.value) # "high" 출력
print(f"신뢰도: {reliability.value}") # "신뢰도: high" 출력
class Reliability(str, Enum):
HIGH = "high"
reliability = Reliability.HIGH
print(reliability) # "high" 출력
print(f"신뢰도: {reliability}") # "신뢰도: high" 출력
str
의 문자열 표현과 관련된 메서드(__str__
, __repr__
, __add__
등)는 str
의 구현이 사용되기 때문이다. type 제한 하려고 enum 썼는데 아니 type 을 다중으로 해버리네? 라고 생각할 수 있다. 위 같은 방식이면 isinstance
와 같은 빡센 타입 체킹에서 예상치 못한 동작이 있을 수 있다!!
isinstance(reliability, str)
가 True
가 되기 때문. 근데 이걸 유도할 수 도 있다. 문자열 비교가 필요하면 말이다.)그래서 한 가지 대안은 아래와 같다! 직접 __str__
을 오버라이딩 하는 것!
def __str__(self):
return self.value
ps) str 상속해서 메모리 오버헤드 걱정이 된다면.. python 을 안쓰는 것을 추천.. 한다..
IntEnum
사용)Enum
값들을 문자열 형태로 만들다 보면 "비교 연산" 에서 계속 문자열 비교를 하는 경우가 있다. class Priority(Enum):
LOW = "low"
MEDIUM = "mi"
HIGH = "high"
if current_status == Priority.LOW
와 같은 동등 비교 중심으로만 연산하게 된다. 더욱이 동등 연산자의 전자가 문자열이라면, 자연스럽게 문자열로 바꿔 "문자열 비교 연산" 을 하게 된다. class Priority(IntEnum):
LOW = 1
MEDIUM = 2
HIGH = 3
이제 비교 연산이 가능해진다. if current_status.value > Priority.HIGH.value
와 같이, HIGH
가 아닌 모든 것들에 대해 일관적인 조건 체크를 빠르게 할 수 있다.
이 방식이 가장 두드러지는 것은 "로깅 레벨" 에서 볼 수 있다.
auto()
함수와 Flag Enum으로 비트 플래그 만들기나는 "값" 이 중요한게 아니라 "키" 가 중요해! 일 경우 auto
를 쓸 수 있다. 사실 위에 예시와 같이 "상태" 는 "값" 보다는 "의미, 키" 가 중요하다.
auto()는 기본적으로 1부터 시작해 1씩 증가하는 값을 제공한다. 실수로 같은 값을 넣어서 이슈가 있을 가능성 자체가 없어진다. (_generate_next_value_
메서드를 내부적으로 활용한다. 이를 오버라이딩하면 전혀 다른 값으로 증가하게 할 수 있다.)
from enum import Enum, auto
class Status(Enum):
PENDING = auto() # 1
ACTIVE = auto() # 2
INACTIVE = auto() # 3
auto()
를 활용해 Flag
enum 을 좀 더 편하게 사용이 가능하다.| (OR)
: 두 비트 중 하나라도 1이면 1& (AND)
: 두 비트가 모두 1이면 1^ (XOR)
: 두 비트가 서로 다르면 1~ (NOT)
: 비트 반전 (1→0, 0→1)<<, >> (SHIFT)
: 비트를 좌/우로 이동# 이렇게 8개 변수 대신
is_read = True
is_write = False
is_execute = True
# ...5개 더
# 이렇게 하나의 변수로 표현
permissions = 0b10100101 # 한 바이트로 8가지 상태 표현
# 여러 개의 if문 대신
if user.can_read and (user.is_admin or user.is_owner) and not user.is_banned:
# 작업 수행...
# 비트 연산으로 한 번에 처리
required_perm = READ | (ADMIN | OWNER) & ~BANNED
if user.permissions & required_perm == required_perm:
# 작업 수행...
Flag Enum
은 아래와 같이 Flag
클래스를 상속받아 비트 플래그 Enum을 만들 수 있다.from enum import Flag, auto
class Permission(Flag):
READ = auto() # 1 (binary: 001)
WRITE = auto() # 2 (binary: 010)
EXECUTE = auto() # 4 (binary: 100)
# 조합된 값, 복합 상태 값! -> 이게 진짜 강력하다!
READ_WRITE = READ | WRITE # 3 (binary: 011)
ALL = READ | WRITE | EXECUTE # 7 (binary: 111)
# 사용 예시
user_perm = Permission.READ | Permission.WRITE # 3
# 비트 연산으로 권한 확인
if Permission.READ in user_perm:
print("사용자는 읽기 권한이 있습니다")
가장 편리한 지점은, 어짜피 비트 연산을 눈으로 매번 따라가면 실수하기 쉽다. 그러니까 자연스럽게 auto
랑 같이 섞어서 쓸 수 있고, 더욱이 "복합 상태 값" 이 진짜 강력한 것이다.
만약 통신에 이 개념을 사용하면, 데이터 전송 효용성 과 그에 따른 비용 절약 을 기본으로 깔고 갈 수 있다. (사실 실제로 최적화 관점에서 많이들 사용한다.)
Enum
은 기본적으로 "값" 의 중복을 허용하며 이를 "alias(별칭)" 으로 받아들인다. from enum import Enum
class Color(Enum):
RED = 1
CRIMSON = 1 # RED의 별칭
GREEN = 2
@unique
데코레이터를 사용하면 된다!from enum import Enum, unique
@unique
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
# DUPLICATE = 1 # 이렇게 하면 ValueError 발생
auto()
랑 커스텀을 같이 할 때 인 것 같다. 즉, auto
에 unique
를 달아줘서 의도치 않은 실수 방지를 강제할 수 있다!from enum import Enum, auto, unique
@unique
class Status(Enum):
START = auto()
RUNNING = auto()
STOP = 1 # ValueError 발생 가능성!
Enum
값으로 튜플이나 복잡한 객체를 사용할 수 있다! 사실 개인적으로 협업할때 가장 선호하는 형태이긴 하다. 특히 단순한 Enum 이 아니라 "상태 값을 나타내는" 경우, "사람"을 위해서 코드의 가독성을 올려보자!class HttpStatus(Enum):
OK = (200, "Success")
NOT_FOUND = (404, "Not Found")
SERVER_ERROR = (500, "Server Error")
def __init__(self, code, message):
self.code = code
self.message = message
status = HttpStatus.NOT_FOUND
print(f"Status: {status.code}, Message: {status.message}")
class LogLevel(Enum):
"""로깅 레벨을 정의하는 열거형 클래스"""
DEBUG = 10
"""디버깅 목적의 상세 정보"""
INFO = 20
"""일반적인 정보 메시지"""
WARNING = 30
"""잠재적 문제 상황에 대한 경고"""
ERROR = 40
"""프로그램 실행은 가능하나 오류가 발생함"""
Enum
인 것인가!! 더 나아가 Enum 의 __doc__
을 살펴보면 좋다. - https://tech.isyncbrain.com/python/enum/alias/sqlalchemy/2022/05/15/annotated-enum.html (Enum __doc__
을 SQLAlchemy 와 함께 활용한 예제!)_missing_
메서드로 커스텀 룩업 구현하기from enum import Enum
class Color(Enum):
RED = "red"
GREEN = "green"
BLUE = "blue"
Color("RED") # ValueError: 'RED' is not a valid Color
Enum
에 정의되지 않은 값이 주어졌을 때의 처리 방식을 커스터마이징 할 수 있다. 바로 _missing_
를 통해서!class CaseInsensitiveEnum(str, Enum):
@classmethod
def _missing_(cls, value):
if isinstance(value, str):
# 대소문자 구분 없이 찾기
for member in cls:
if member.value.lower() == value.lower():
return member
raise ValueError(f"{value!r}는 {cls.__name__}의 유효한 값이 아닙니다")
class Color(CaseInsensitiveEnum):
RED = "red"
GREEN = "green"
BLUE = "blue"
# 대소문자 구분 없이 값 찾기
assert Color("RED") == Color("red") == Color.RED
이렇게 하면 "RED", "Red", "red"
등 다양한 형태의 문자열을 동일하게 인식하여 Color.RED로 매핑할 수 있다.
참고로 _missing_
에서 return None
을 하는 등의 행위 말고, raise ValueError
가 더 올바른 Enum 접근 방법이다!
__str__
& __repr__
커스터마이징class FormattedEnum(Enum):
def __str__(self):
return f"{self.name} ({self.value})"
def __repr__(self):
return f"{self.__class__.__name__}.{self.name}"
class HttpStatus(FormattedEnum):
OK = 200
NOT_FOUND = 404
ERROR = 500
print(HttpStatus.OK) # "OK (200)"
print(repr(HttpStatus.OK)) # "HttpStatus.OK"
ps) 그 외 Enum 상속에 대한 것 (사실 예시에서 바로 보임), Enum.__members__
로 속성 값 딕셔너리로 다 가져오는 것, 함수형으로 Enum 동적 생성하기 등이 있다.
ps) 더욱이 @property
는 "불변 상수 / 메타데이터" 가 목적인 Enum
에 잘 안맞는다고 생각한다. 잘못 사용하면, "로직" 이 추가되는 side effect 가 다분히 존재한다고 생각한다!