Home Pydantic RootModel의 설계 의도와 v2에서의 올바른 타입 매핑
Post
Cancel

Pydantic RootModel의 설계 의도와 v2에서의 올바른 타입 매핑

서론

Pydantic은 Python에서 데이터 검증과 설정 관리를 위한 핵심 라이브러리로 자리잡았습니다.
그 중에서도 RootModel(또는 v1의 __root__ 필드)은 특별한 용도로 설계된 기능입니다.

하지만 많은 개발자들이 이 기능을 자동 타입 매핑 용도로 활용하면서,
v2에서의 변경사항과 함께 혼란이 생겨났습니다.

이 글에서는 RootModel의 본래 설계 의도부터 실제 사용 패턴,
그리고 v2에서의 변화와 권장되는 구현 방법까지 체계적으로 다루어보겠습니다.

목차

1. RootModel의 원래 설계 의도

핵심 목적 - 단일 값 래핑

Pydantic의 RootModel은 “모델 전체가 하나의 값만을 가질 때 그 값을 검증하고 감싸는 것”이 본래 목적입니다.
일반적인 BaseModel이 여러 필드를 가진 구조화된 데이터를 다루는 반면,
RootModel은 단순한 리스트, 딕셔너리, 또는 단일 객체 전체를 하나의 “루트 값”으로 취급합니다.

Pydantic v1에서의 구현

v1에서는 __root__ 필드를 통해 이를 구현했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
from pydantic import BaseModel
from typing import List, Dict

class Pets(BaseModel):
    __root__: List[str]

class PetsByName(BaseModel):
    __root__: Dict[str, str]

# 사용 예시
pets = Pets.parse_obj(['dog', 'cat'])
print(pets.__root__)  # ['dog', 'cat']

이 방식의 핵심은 입력 데이터 전체가 곧 모델의 값이라는 점입니다.
복잡한 필드 구조 없이 단순한 컬렉션이나 값을 Pydantic의 검증 시스템 내에서 다룰 수 있게 해줍니다.

2. 자동 타입 매핑의 부수적 활용

Union과 Discriminator의 조합

v1에서 개발자들은 __root__ 필드에 Union 타입을 적용하여 자동 타입 매핑을 구현했습니다.
이것은 본래 의도된 용법은 아니었지만, 실용적인 해결책으로 널리 사용되었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from typing import Union, Literal
from pydantic import BaseModel, Field

class MySchema1(BaseModel):
    type: Literal['schema1']
    a: int
    b: int

class MySchema2(BaseModel):
    type: Literal['schema2']
    a: int
    c: int

class RootModel(BaseModel):
    __root__: Union[MySchema1, MySchema2] = Field(discriminator='type')

이 패턴은 입력 데이터의 특정 필드(discriminator) 값에 따라
자동으로 적절한 스키마로 매핑하는 기능을 제공했습니다.

왜 이런 활용이 가능했는가

v1의 __root__ 구조는 충분히 유연해서 Union 타입과 discriminator를 함께 사용할 수 있었습니다.
이는 설계상 의도된 것은 아니었지만, 실제로는 매우 유용한 패턴으로 자리잡았습니다.

개발자들은 이를 통해 하나의 API 엔드포인트에서 여러 다른 형태의 데이터를 받아
자동으로 적절한 모델로 파싱
하는 기능을 구현할 수 있었습니다.

3. Pydantic v2에서의 변화

RootModel의 재설계

v2에서는 __root__ 필드가 완전히 제거되고 RootModel 클래스로 대체되었습니다.
이는 단순한 API 변경이 아니라 본질적 목적에 맞는 재설계였습니다.

1
2
3
4
5
6
7
8
9
from pydantic import RootModel
from typing import List

# v2 방식
class Pets(RootModel[List[str]]):
    pass

pets = Pets.model_validate(['dog', 'cat'])
print(pets.root)  # ['dog', 'cat']

자동 타입 매핑 지원의 제한

v2의 RootModel은 단일 값 래핑이라는 본래 목적에 더욱 집중하도록 설계되었습니다.
이 과정에서 v1에서 가능했던 Union + discriminator 조합이 공식적으로 지원되지 않게 되었습니다.

실제로 GitHub 이슈 #9830에서 확인할 수 있듯이,
RootModel에 discriminator를 적용하면 TypeError가 발생하거나 예상과 다르게 동작하는 알려진 버그가 존재합니다.

마이그레이션의 어려움

이러한 변화로 인해 v1에서 DictionaryInspectorClass.parse_obj(_rows).__root__ 같은 패턴을 사용하던 코드들은
v2에서 DictionaryInspectorClass.model_validate(_rows).root로 변경해야 하지만,
동시에 자동 타입 매핑 기능을 잃게 되었습니다.

4. v2에서 권장되는 자동 타입 매핑 방법

1. BaseModel + Union + Field(discriminator)

v2에서 가장 권장되는 방식은 일반 BaseModel의 필드에 Union과 discriminator를 적용하는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from typing import Union, Literal
from pydantic import BaseModel, Field

class MySchema1(BaseModel):
    type: Literal['schema1']
    a: int
    b: int

class MySchema2(BaseModel):
    type: Literal['schema2']
    a: int
    c: int

class WrapperModel(BaseModel):
    data: Union[MySchema1, MySchema2] = Field(discriminator='type')

# 사용
result = WrapperModel.model_validate({'data': {'type': 'schema1', 'a': 1, 'b': 2}})
print(result.data)  # MySchema1(type='schema1', a=1, b=2)

2. Annotated + TypeAdapter 패턴

더욱 직접적인 방법으로는 AnnotatedTypeAdapter를 활용하는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from typing import Annotated, Union, Literal
from pydantic import BaseModel, Field, TypeAdapter

class MySchema1(BaseModel):
    type: Literal['schema1']
    a: int
    b: int

class MySchema2(BaseModel):
    type: Literal['schema2']
    a: int
    c: int

# Python 3.10 이상에서는 파이프 연산자 사용 가능
MyUnionType = Annotated[
    MySchema1 | MySchema2,  # Union[MySchema1, MySchema2]와 동일
    Field(discriminator='type')
]

# TypeAdapter 사용
adapter = TypeAdapter(MyUnionType)
result = adapter.validate_python({'type': 'schema1', 'a': 1, 'b': 2})
print(result)  # MySchema1(type='schema1', a=1, b=2)

3. Python 3.10 이상에서의 파이프 연산자

Python 3.10 이상에서는 PEP 604에 따라 Union 대신 파이프 연산자(|)를 사용할 수 있습니다.

1
2
3
4
5
# Python 3.10 이상
MyUnionType = Annotated[MySchema1 | MySchema2, Field(discriminator='type')]

# Python 3.9 이하
MyUnionType = Annotated[Union[MySchema1, MySchema2], Field(discriminator='type')]

5. v1/v2 호환 코드 작성 전략

조건부 임포트 패턴

두 버전을 모두 지원해야 하는 경우, 조건부 임포트를 활용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from typing import Annotated, Union, Literal
from pydantic import BaseModel, Field

try:
    from pydantic import TypeAdapter
    # v2 환경
    def create_parser(union_type):
        return TypeAdapter(union_type)
except ImportError:
    # v1 환경
    from pydantic import parse_obj_as
    def create_parser(union_type):
        return lambda data: parse_obj_as(union_type, data)

# 공통 타입 정의
MyUnionType = Annotated[Union[MySchema1, MySchema2], Field(discriminator='type')]
parser = create_parser(MyUnionType)

6. 구현 방향성과 모범 사례

1. 명확한 용도 구분

  • 단일 값 래핑이 목적이라면 RootModel을 사용하세요
  • 자동 타입 매핑이 목적이라면 Annotated + Union + Field(discriminator) 패턴을 사용하세요

2. Discriminator 필드 설계

모든 스키마에 공통으로 존재하는 discriminator 필드를 반드시 정의하세요.

1
2
3
4
5
6
7
class Schema1(BaseModel):
    type: Literal['type1']  # discriminator 필드
    # 기타 필드들...

class Schema2(BaseModel):
    type: Literal['type2']  # discriminator 필드
    # 기타 필드들...

3. 성능과 에러 처리 고려

Discriminated Union은 일반 Union보다 빠른 검증 속도명확한 에러 메시지를 제공합니다.
Pydantic 공식 문서에 따르면,
discriminated union의 로직이 Rust로 구현되어 있어 성능상 큰 이점이 있습니다.
따라서 가능한 한 discriminator를 활용하는 것이 좋습니다.

4. 중첩된 Discriminator 활용

복잡한 타입 구조에서는 중첩된 discriminator를 활용할 수 있습니다.

1
2
3
4
# 먼저 색깔로 구분
Cat = Annotated[Union[BlackCat, WhiteCat], Field(discriminator='color')]
# 그 다음 동물 종류로 구분
Pet = Annotated[Union[Cat, Dog], Field(discriminator='pet_type')]

7. 결론 및 권장사항

Pydantic의 RootModel은 단일 값 래핑이라는 명확한 목적을 가지고 설계되었습니다.
v1에서 가능했던 자동 타입 매핑은 부수적인 활용법이었으며,
v2에서는 이를 위한 별도의 패턴이 권장됩니다.

최종 권장사항

  1. 새로운 프로젝트: Python 3.10 이상이라면 파이프 연산자와 TypeAdapter를 활용하세요
  2. 기존 프로젝트 마이그레이션: Annotated + Union + Field(discriminator) 패턴으로 점진적 마이그레이션하세요
  3. v1/v2 호환성: 조건부 임포트나 호환성 라이브러리를 고려하세요

위 권장사항들은 저의 개발 과정에서 마주친 문제들을 해결하기 위해 조사한 결과입니다.

v1/v2 호환성을 위해 이 문제를 조사하게 되었는데,
RootModel의 자동 타입 매핑 기능이 v2에서 제거되면서 기존 코드를 어떻게 마이그레이션해야 할지 고민이 많았거든요.

조사 결과 TypeAdapter + Annotated 패턴이 가장 안전하고 공식적인 방법이라는 것이라고 판단했습니다.
여기서 정리한 내용들이 다른 분들에게도 도움이 되길 바랍니다.

이러한 접근 방식을 통해 Pydantic의 강력한 타입 시스템을 최대한 활용하면서도,
각 버전의 설계 철학에 맞는 코드를 작성할 수 있으리라 생각합니다.

결국 도구의 본래 목적을 이해하고 적절한 패턴을 선택하는 것이,
유지보수 가능하고 안정적인 코드를 만드는 핵심이 아닐까.. 합니다.

This post is licensed under CC BY 4.0 by the author.

NumPy 1.24 이후 np.float 타입 제거 및 대응 전략

-