Published on

Python dataclass+descriptor로 검증 자동화하기

Authors

서버/도메인 코드에서 데이터 검증은 늘 따라옵니다. 문제는 검증 로직이 흩어지기 쉽다는 점입니다. 생성자에서 한 번, 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는 클래스 속성에 있어야 동작합니다.

그래서 실무에서 자주 쓰는 접근은 다음 둘 중 하나입니다.

  1. dataclass가 생성한 __init__는 그대로 쓰고, 실제 저장은 descriptor가 관리
  2. 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의 강제력을 함께 쓰면, 검증이 “관례”가 아니라 “구조”가 됩니다. 이는 코드베이스가 커질수록 큰 차이를 만듭니다.