Pydantic 2.13.4 Decimal 검증이 순서에 따라 달라질 때

Pydantic 2.13.4에서는 값을 그대로 반환하는 AfterValidator를 Field 앞에 놓았을 때 Decimal의 정수부 자릿수 검사가 빠지는 사례가 확인됩니다. 같은 제약을 선언해도 문자열 12.3을 받아들이는지가 달라집니다. 이 글은 세 가지 선언과 다섯 입력을 직접 비교하고, 검증 파이프라인의 최종 반환값을 다시 검사하는 임시 대응까지 확인합니다. 2.14.1 공식 릴리스에는 관련 수정이 포함되어 있지만, 해당 버전은 여기서 실행하지 않았습니다.

직접 실행한 환경: 2026-10-13, Linux, CPython 3.12.14, Pydantic 2.13.4, pydantic-core 2.46.4입니다. 이미 설치된 환경에서 아래 두 파일을 실행했으며, 패키지 설치나 업그레이드는 하지 않았습니다.

Decimal은 파이썬의 10진수 자료형입니다. 이 글에서 Field는 자릿수 제약을 붙이고, AfterValidator는 타입 검증 뒤 실행할 사용자 함수를 지정합니다. Annotated는 타입에 이런 메타데이터를 함께 적는 표기이며, TypeAdapter는 해당 타입 선언으로 값을 검증하는 객체입니다.

정수부 한 자리를 기대하는 선언

max_digits=3과 decimal_places=2를 함께 지정하면 정수부 한도는 3 − 2 = 1자리입니다. 따라서 1.23은 허용하지만, 정수부가 두 자리인 12.3과 세 자리인 123은 거부해야 합니다. 이는 decimal_whole_digits 오류의 공식 정의에 따른 기대 결과입니다. 소수부를 항상 두 자리로 표시하거나 반올림하라는 설정은 아닙니다.

공유 Decimal 타입에 사용자 검증기를 추가할 때는 정상 입력만 확인하면 이 차이를 놓칠 수 있습니다. 이번 예제의 검증기는 값을 바꾸지 않는 identity 함수로 고정했습니다. 값 변형의 영향을 제외하고 메타데이터 순서만 비교하기 위해서입니다.

세 가지 순서에 같은 문자열 넣기

다음 코드를 decimal_order_check.py로 저장하고 python decimal_order_check.py로 실행합니다. plain은 Field만, before_field는 AfterValidator 다음에 Field, after_field는 그 반대 순서입니다. before_field라는 이름은 BeforeValidator 사용을 뜻하지 않습니다.

엄격한 타입 검사(strict)를 추가하지 않고, 입력 타입 변환을 허용하는 기본 lax 모드를 사용합니다. Python 경로에는 문자열을 전달하고, JSON 경로에는 json.dumps로 만든 JSON 문자열을 전달합니다. 예를 들어 JSON 입력은 숫자 12.3이 아니라 따옴표를 포함한 "12.3"입니다. 선언 3개 × 입력 5개 × 경로 2개로 총 30건을 비교합니다.

from decimal import Decimal
from importlib.metadata import version
from typing import Annotated
import json
import platform

from pydantic import AfterValidator, Field, TypeAdapter, ValidationError

print('Python', platform.python_version())
print('Pydantic', version('pydantic'))
print('pydantic-core', version('pydantic-core'))

limits = Field(max_digits=3, decimal_places=2)
identity = AfterValidator(lambda value: value)
cases = {
    'plain': Annotated[Decimal, limits],
    'before_field': Annotated[Decimal, identity, limits],
    'after_field': Annotated[Decimal, limits, identity],
}


def outcome(adapter, raw, mode):
    try:
        if mode == 'python':
            value = adapter.validate_python(raw)
        else:
            value = adapter.validate_json(json.dumps(raw))
        return 'OK:' + str(value)
    except ValidationError as exc:
        return exc.errors()[0]['type']


print('case input python json')
for name, annotation in cases.items():
    adapter = TypeAdapter(annotation)
    for raw in ('1.23', '12.3', '123', '0.123', '1.234'):
        python_result = outcome(adapter, raw, 'python')
        json_result = outcome(adapter, raw, 'json')
        print(name, raw, python_result, json_result)

실제 출력: 한 행에 Python과 JSON 경로의 결과를 함께 표시했습니다.

Python 3.12.14
Pydantic 2.13.4
pydantic-core 2.46.4
case input python json
plain 1.23 OK:1.23 OK:1.23
plain 12.3 decimal_whole_digits decimal_whole_digits
plain 123 decimal_whole_digits decimal_whole_digits
plain 0.123 decimal_max_places decimal_max_places
plain 1.234 decimal_max_digits decimal_max_digits
before_field 1.23 OK:1.23 OK:1.23
before_field 12.3 OK:12.3 OK:12.3
before_field 123 OK:123 OK:123
before_field 0.123 decimal_max_places decimal_max_places
before_field 1.234 decimal_max_digits decimal_max_digits
after_field 1.23 OK:1.23 OK:1.23
after_field 12.3 decimal_whole_digits decimal_whole_digits
after_field 123 decimal_whole_digits decimal_whole_digits
after_field 0.123 decimal_max_places decimal_max_places
after_field 1.234 decimal_max_digits decimal_max_digits

차이는 정수부 경계에서 드러납니다. 12.3과 123은 plain과 after_field에서 decimal_whole_digits로 거부되지만, before_field에서는 그대로 반환됩니다. 두 입력 경로의 결과는 이번 비교에서 모두 같았습니다.

대조 입력도 나눴습니다. 1.23은 모든 선언에서 허용됩니다. 0.123은 전체 유효 자릿수 한도에는 들어가지만 소수부가 세 자리여서 decimal_max_places로 거부됩니다. 1.234는 decimal_max_digits로 거부됩니다. 선행 0과 끝의 0을 세는 방식은 공식 Decimal 제약 설명을 따릅니다. 이 결과를 모든 Decimal 입력이나 모든 검증기 조합으로 확대할 수는 없습니다.

순서에 따른 차이가 버그인 이유

업스트림 이슈 #13965는 검증기가 Field보다 앞서면 제약을 Python fallback에서 처리하며, 그 경로에 정수부 자릿수 검사가 빠져 있었다고 설명합니다. 수정 PR #13972는 해당 검사를 추가했습니다. 이 원인 설명은 업스트림 자료에 근거하며, 위 출력은 별도로 실행한 2.13.4의 관찰 결과입니다. 여기서 Python은 구현 경로를 뜻하며 validate_python만의 문제라는 뜻은 아닙니다. JSON 문자열 경로에서도 같은 누락이 나타났습니다.

Annotated에서 검증기 실행 순서는 의미가 있지만, 이 사례에서는 같은 값을 반환했는데도 제약 적용 결과가 달라집니다. 이를 정상적인 순서 차이로 받아들이거나 2.13.4에서 처음 생긴 문제라고 단정해서는 안 됩니다. 여기서는 문제가 시작된 버전까지 조사하지 않았습니다.

순서만 바꾸기 전에 최종값 검사하기

앞의 identity 예제에서는 Field를 먼저 놓으면 두 입력이 거부됩니다. 하지만 실제 AfterValidator가 값을 바꾼다면 그 배치를 그대로 해결책으로 옮기기 어렵습니다. 공식 검증기 문서에서도 AfterValidator가 검증 이후 값을 변경하는 예를 보여줍니다.

아래는 애플리케이션 경계에서 사용할 수 있는 임시 검사 예제입니다. 제약은 limits 한 곳에 정의하고, 사용자 검증 파이프라인이 반환한 값을 별도의 canonical TypeAdapter로 다시 검사합니다. decimal_final_guard.py로 저장한 뒤 python decimal_final_guard.py로 실행했습니다.

from decimal import Decimal
from typing import Annotated
from pydantic import AfterValidator, Field, TypeAdapter, ValidationError

limits = Field(max_digits=3, decimal_places=2)
canonical = TypeAdapter(Annotated[Decimal, limits])
identity_pipeline = TypeAdapter(
    Annotated[Decimal, AfterValidator(lambda value: value), limits]
)
mutating_pipeline = TypeAdapter(
    Annotated[
        Decimal, limits,
        AfterValidator(lambda value: value * Decimal('10')),
    ]
)


def checked_result(pipeline, raw):
    result = pipeline.validate_python(raw)
    return canonical.validate_python(result)


for name, pipeline, raw in [
    ('identity_valid', identity_pipeline, '1.23'),
    ('identity_invalid', identity_pipeline, '12.3'),
    ('mutation_after_field', mutating_pipeline, '1.23'),
]:
    print(name, 'pipeline_returned', pipeline.validate_python(raw))
    try:
        print(name, 'final_check_accepted', checked_result(pipeline, raw))
    except ValidationError as exc:
        print(name, 'final_check_rejected', exc.errors()[0]['type'])

같은 2.13.4 환경의 실제 출력:

identity_valid pipeline_returned 1.23
identity_valid final_check_accepted 1.23
identity_invalid pipeline_returned 12.3
identity_invalid final_check_rejected decimal_whole_digits
mutation_after_field pipeline_returned 12.30
mutation_after_field final_check_rejected decimal_whole_digits

정상값 1.23은 최종 검사도 통과합니다. identity_pipeline이 잘못 받아들인 12.3은 최종 검사에서 거부됩니다. Field 뒤에서 10을 곱한 파이프라인은 12.30을 반환하지만, 정수부가 두 자리인 이 값도 최종 검사에서 거부됩니다. 이 임시 검사에서는 사용자 함수까지 모두 거친 반환값을 검사 대상으로 삼았습니다.

이 예제는 임시 경계 검사이며 전체 검증기를 교체하는 처방은 아닙니다. 앞 단계에서 이미 거부된 입력은 복구하지 못하고, 오류 위치나 API 오류 응답 계약도 별도로 확인해야 합니다. 관찰용 print와 검사 함수에서 파이프라인을 각각 호출하므로 예제에는 부작용 없는 함수만 사용했습니다. 실서비스의 부작용 있는 검증기에 이 반복 호출까지 복사하면 안 됩니다.

수정 버전 도입과 보류 기준

2026-10-11 공개된 2.14.1 릴리스는 #13972를 수정 목록에 포함합니다. PyPI 2.14.1 정보에는 Python 3.10 이상, MIT 라이선스가 표시되어 있습니다. 같은 페이지의 소스 링크는 pydantic/pydantic이며 유지관리자는 dmontagu와 samuelcolvin으로 표시됩니다. 이 배포 정보 확인은 새 버전의 직접 실행 검증과 구분해야 합니다.

  • 도입 전: 분리된 검증 환경에서 실제 타입 선언과 같은 30건을 수정 버전에도 실행하고, 서비스의 정규화 함수가 반환하는 값과 오류 응답을 함께 확인합니다. 이는 이번 글에서 실행하지 않은 후속 검증입니다.
  • 보류할 때: Python 3.9를 사용하거나 의존성 호환성이 확인되지 않았다면 바로 업그레이드하지 않습니다. 영향받는 경계의 최종값 검사와 회귀 테스트부터 검토합니다.
  • 되돌릴 때: 배포 전 잠금 파일과 이전 배포물을 보관합니다. 호환성 문제로 이전 버전으로 돌아가더라도 알려진 검증 누락이 다시 노출되지 않도록 임시 검사 유지 여부를 함께 판단합니다.

이 글에서 확인한 범위는 지정한 문자열 입력과 두 로컬 스크립트입니다. JSON 숫자, 실제 API, 데이터베이스 저장, 성능은 시험하지 않았습니다. 재사용 타입의 선언만 볼 것이 아니라, 경계 입력이 최종 반환값까지 어떤 제약을 통과하는지 확인하는 데 이 재현 코드를 활용할 수 있습니다.

댓글