- Published on
Python dataclass+descriptor로 검증 자동화하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/도메인 코드에서 데이터 검증은 늘 따라옵니다. 문제는 검증 로직이 흩어지기 쉽다는 점입니다. 생성자에서 한 번, setter에서 또 한 번, API 입력 변환 단계에서 또 한 번… 시간이 지나면 “어디에서 무엇을 검증하는지”가 불명확해지고, 누락된 검증은 운영 장애로 이어집니다.
Python에서는 @dataclass로 보일러플레이트를 줄이면서도, descriptor로 “필드 접근 자체를 가로채” 검증을 강제할 수 있습니다. 이 글에서는 @dataclass와 descriptor를 결합해 필드 검증을 선언적으로 작성하고, 할당 시점에 자동으로 실행되게 만드는 실전 패턴을 소개합니다.
참고로, 검증을 엄격히 하려다 보면 스키마/계약 관리가 중요해지는데, 도구 호출/스키마 검증 관점은 Claude Tool Use 400 오류, JSON Schema로 끝내기 글도 함께 보면 맥락이 잘 맞습니다.
왜 @dataclass만으로는 부족한가
@dataclass는 기본적으로 다음을 자동 생성합니다.
__init__(필드 기반 생성자)__repr__,__eq__등- 옵션에 따라
frozen,order등
하지만 필드 할당을 통제하는 기능은 기본 제공되지 않습니다. 보통은 __post_init__에서 생성 시점 검증을 넣습니다.
문제는 다음과 같습니다.
__post_init__는 “생성 시점”만 커버합니다.- 객체 생성 후
obj.field = ...로 바꾸면 검증이 우회됩니다. - 각 필드마다 property를 만들면 코드가 급격히 장황해집니다.
이때 descriptor를 쓰면 “할당 자체”를 후킹할 수 있습니다.
descriptor 핵심: __get__/__set__로 접근을 가로채기
descriptor는 클래스 속성으로 정의되며, 인스턴스에서 해당 속성에 접근하거나 할당할 때 다음 메서드가 호출됩니다.
__get__(self, instance, owner)__set__(self, instance, value)__delete__(self, instance)
즉, User.age = 10 같은 할당이 발생하면 우리가 원하는 검증을 __set__에서 강제할 수 있습니다.
목표: dataclass 필드처럼 쓰지만, 할당 시 검증 자동화
우리가 원하는 사용 경험은 대략 이런 형태입니다.
@dataclass로 선언- 필드에 검증 규칙을 “필드 옆”에 붙임
- 생성 시점뿐 아니라 변경 시점에도 동일한 검증 적용
다만 여기서 중요한 제약이 있습니다.
dataclasses.field()는 descriptor가 아닙니다.- descriptor는 클래스 속성에 있어야 동작합니다.
그래서 실무에서 자주 쓰는 접근은 다음 둘 중 하나입니다.
- dataclass가 생성한
__init__는 그대로 쓰고, 실제 저장은 descriptor가 관리 - dataclass의 필드 메타데이터에 검증 규칙을 넣고,
__setattr__로 공통 처리
이 글은 1) descriptor 중심 패턴을 먼저 다루고, 마지막에 2) 대안도 짚습니다.
구현 1: 재사용 가능한 검증 descriptor 만들기
아래는 “검증 가능한 필드”를 표현하는 descriptor입니다.
validators리스트로 검증 함수를 주입- 값 저장은 인스턴스의
__dict__에 “프라이빗 키”로 저장 __set_name__으로 필드명을 자동으로 획득
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Optional
Validator = Callable[[Any], Any]
class Validated:
def __init__(self, *validators: Validator, default: Any = None):
self.validators = list(validators)
self.default = default
self.public_name: Optional[str] = None
self.private_name: Optional[str] = None
def __set_name__(self, owner: type, name: str) -> None:
self.public_name = name
self.private_name = f"_{name}"
def __get__(self, instance: Any, owner: type | None = None) -> Any:
if instance is None:
return self
assert self.private_name is not None
if self.private_name not in instance.__dict__:
instance.__dict__[self.private_name] = self.default
return instance.__dict__[self.private_name]
def __set__(self, instance: Any, value: Any) -> None:
for v in self.validators:
value = v(value)
assert self.private_name is not None
instance.__dict__[self.private_name] = value
이제 검증 함수(validator)를 몇 개 만들어봅니다.
import re
def non_empty_str(x: Any) -> str:
if not isinstance(x, str):
raise TypeError("must be str")
if not x.strip():
raise ValueError("must be non-empty")
return x
def in_range(min_v: int, max_v: int):
def _v(x: Any) -> int:
if not isinstance(x, int):
raise TypeError("must be int")
if x < min_v or x > max_v:
raise ValueError(f"must be between {min_v} and {max_v}")
return x
return _v
def email_like(x: Any) -> str:
x = non_empty_str(x)
if not re.match(r"^[^@]+@[^@]+\.[^@]+$", x):
raise ValueError("invalid email format")
return x
dataclass에 descriptor 적용: 선언적으로 필드 검증하기
@dataclass는 “타입 어노테이션이 붙은 속성”을 필드로 인식합니다. 그런데 descriptor는 클래스 속성으로도 존재해야 합니다.
여기서 흔히 쓰는 방식은:
- dataclass에는 “논리 필드”를 어노테이션으로 선언
- 실제 값 저장/검증은 descriptor가 담당
init=False로 dataclass가 해당 필드를 생성자 파라미터로 만들지 않게 하고, 대신 우리가 만든__init__또는__post_init__에서 세팅
아래 예시는 User 엔티티에서 name, age, email을 검증합니다.
from dataclasses import dataclass, field
@dataclass
class User:
# dataclass 필드로는 보이되, init 파라미터는 우리가 통제
name: str = field(init=False)
age: int = field(init=False)
email: str = field(init=False)
# descriptor를 클래스 속성으로 둔다
name = Validated(non_empty_str)
age = Validated(in_range(0, 150))
email = Validated(email_like)
def __init__(self, name: str, age: int, email: str):
# 여기서 할당하면 descriptor의 __set__이 자동 호출
self.name = name
self.age = age
self.email = email
사용 예:
u = User(name="Alice", age=30, email="alice@example.com")
u.age = 200 # ValueError
u.email = "not-an-email" # ValueError
u.name = " " # ValueError
이 방식의 장점
- 생성/변경 모두 동일한 검증 경로를 탑니다.
- 검증 로직이 필드 선언 근처에 있어 유지보수가 쉽습니다.
- property를 필드마다 만들 필요가 없습니다.
단점과 트레이드오프
@dataclass의 자동 생성__init__을 그대로 쓰지 못하고 커스텀__init__을 작성했습니다.dataclasses.asdict()같은 도구를 쓰면 descriptor가 저장하는_{name}형태의 내부 키가 노출될 수 있습니다.
이 단점은 다음 섹션에서 개선합니다.
개선 1: __post_init__로 dataclass의 __init__ 유지하기
dataclass의 자동 생성 __init__을 유지하고 싶다면, “입력용 필드”와 “검증 저장용 descriptor”를 분리하는 전략이 있습니다.
- 입력용 필드는
init=True로 받음 __post_init__에서 descriptor 필드에 복사- 입력용 필드는
repr=False/compare=False로 숨기거나, 아예 삭제
from dataclasses import dataclass, field
@dataclass
class User2:
_name_in: str = field(repr=False)
_age_in: int = field(repr=False)
_email_in: str = field(repr=False)
name: str = field(init=False)
age: int = field(init=False)
email: str = field(init=False)
name = Validated(non_empty_str)
age = Validated(in_range(0, 150))
email = Validated(email_like)
def __post_init__(self) -> None:
self.name = self._name_in
self.age = self._age_in
self.email = self._email_in
이제 User2("Alice", 30, "alice@example.com")처럼 dataclass가 만든 생성자를 그대로 사용하면서도 검증은 descriptor로 통일됩니다.
다만 _name_in 같은 입력용 필드가 늘어나는 것이 보기 싫을 수 있습니다. 도메인 모델에서는 깔끔함이 중요하니, 팀 스타일에 따라 선택하면 됩니다.
개선 2: asdict()/직렬화 시 내부 키 숨기기
descriptor는 값을 instance.__dict__["_field"]에 저장합니다. 이 상태에서 asdict()를 쓰면 우리가 원하지 않는 내부 구조가 섞일 수 있습니다.
실무에서는 보통 다음 중 하나를 선택합니다.
- 명시적으로
to_dict()를 제공 - Pydantic 같은 라이브러리로 직렬화/검증을 통합
__iter__나model_dump유사 API를 제공
간단한 to_dict() 예시는 아래와 같습니다.
from dataclasses import dataclass
@dataclass
class UserView:
name: str
age: int
email: str
def user_to_dict(u: User) -> dict:
return {
"name": u.name,
"age": u.age,
"email": u.email,
}
도메인 객체와 API 응답 DTO를 분리하는 방식은 검증/직렬화 문제를 깔끔하게 정리해줍니다.
패턴 확장: nullable, 변환(coercion), 누적 에러
검증은 단순히 raise로 끝나지 않고, 다음 요구가 자주 생깁니다.
None허용(옵셔널)- 문자열 숫자를 int로 변환 같은 coercion
- 여러 필드 에러를 모아서 한 번에 리턴
Optional 허용 descriptor
None이면 통과시키는 래퍼 validator를 만들면 됩니다.
from typing import Any, Callable
def optional(v: Callable[[Any], Any]):
def _v(x: Any) -> Any:
if x is None:
return None
return v(x)
return _v
사용:
class Profile:
nickname: str | None
nickname = Validated(optional(non_empty_str), default=None)
coercion 예: int로 변환
def to_int(x: Any) -> int:
if isinstance(x, int):
return x
if isinstance(x, str) and x.strip().isdigit():
return int(x)
raise TypeError("cannot convert to int")
class Person:
age: int
age = Validated(to_int, in_range(0, 150))
검증 파이프라인을 “변환 후 검증” 순서로 구성할 수 있어 편리합니다.
대안: descriptor 대신 __setattr__ + dataclass metadata
descriptor는 강력하지만, dataclass와의 결합이 약간 부자연스러울 수 있습니다. 다른 접근은 dataclass 필드의 metadata에 검증 규칙을 넣고, __setattr__에서 공통 처리하는 방식입니다.
- 장점: dataclass 필드 정의가 더 자연스럽고
asdict()호환이 좋음 - 단점:
__setattr__오버라이드는 디버깅 난이도를 올릴 수 있고, 상속/슬롯/성능 이슈를 고려해야 함
간단 예시는 아래와 같습니다.
from dataclasses import dataclass, field, fields
from typing import Any
def validators_from_metadata(cls: type) -> dict[str, list]:
out: dict[str, list] = {}
for f in fields(cls):
vs = f.metadata.get("validators")
if vs:
out[f.name] = list(vs)
return out
@dataclass
class Order:
qty: int = field(metadata={"validators": [to_int, in_range(1, 9999)]})
def __post_init__(self) -> None:
# 생성 직후 한 번 돌려서 정규화
self.qty = self.qty
def __setattr__(self, name: str, value: Any) -> None:
vmap = getattr(self, "_vmap", None)
if vmap is None:
super().__setattr__("_vmap", validators_from_metadata(type(self)))
vmap = getattr(self, "_vmap")
if name in vmap:
for v in vmap[name]:
value = v(value)
super().__setattr__(name, value)
이 방식은 “dataclass다움”을 유지하면서도 검증을 중앙화할 수 있습니다.
운영 관점: 검증 자동화가 장애를 줄이는 방식
검증 자동화는 단순히 개발 편의가 아니라, 운영 리스크를 줄이는 장치입니다.
- 잘못된 값이 시스템 깊숙이 들어가기 전에 차단
- 로그/예외가 일관돼서 원인 추적이 쉬움
- 입력 계약이 코드 구조로 드러나 리뷰가 쉬움
특히 외부 입력(HTTP, 메시지 큐, 배치 파일)을 다루는 시스템에서는 “검증이 어디서 수행되는지”가 곧 신뢰성입니다. 장애를 추적하는 과정은 인프라든 애플리케이션이든 결국 신호를 모으는 일인데, 진단/관측을 체계화하는 접근은 Go goroutine 누수 추적 - pprof+trace로 잡기 같은 글과도 결이 같습니다.
정리: 언제 이 패턴을 쓰면 좋은가
- 도메인 엔티티/값 객체(Value Object)에 “항상 지켜야 하는 불변 조건”이 있고
- 생성 이후 변경도 허용하지만, 변경 시에도 동일한 규칙을 강제해야 하며
- Pydantic 같은 외부 의존을 줄이거나, 가벼운 검증 레이어를 직접 운영하고 싶을 때
추천 조합은 다음과 같습니다.
- 작은 프로젝트/라이브러리: descriptor 기반
Validated로 빠르게 통일 - API 스키마/직렬화까지 포함: JSON Schema나 전용 모델 레이어를 병행(계약을 문서화)
다음 단계로는 Validated에 다음 기능을 추가해보면 실무성이 더 올라갑니다.
- 필드별 에러 메시지 커스터마이즈
- i18n 메시지
- 여러 필드 에러 누적(예외 하나로 묶기)
typing.Annotated와 결합해 “타입 힌트에 검증 메타데이터”를 붙이는 방식
@dataclass의 간결함과 descriptor의 강제력을 함께 쓰면, 검증이 “관례”가 아니라 “구조”가 됩니다. 이는 코드베이스가 커질수록 큰 차이를 만듭니다.