목차
- Pydantic v1과 v2 비교 수치부터
- 마이그레이션 전략 3가지 비교
- v2 파괴적 변경 10가지
- FastAPI에서의 Pydantic v2 호환
- SQLAlchemy 2.0과 Pydantic v2 연동
- bump-pydantic으로 자동 마이그레이션
- v1 vs v2 실측 벤치마크
- 마이그레이션 중 빠지기 쉬운 함정 4개
Pydantic v1과 v2 비교 수치부터
Pydantic v2 마이그레이션 사용법을 결정하기 전에 v1과 v2의 비교 수치부터 본다. 공식 발표 기준으로 v2 코어는 Rust로 재작성되어 v1 대비 5~50배 빠른 속도를 낸다(출처: Pydantic 2.0 Release Notes, 2023-06-30). 실측에서도 단일 모델 검증은 8~10배 차이가 나고, FastAPI 엔드투엔드로는 1.2~1.5배 정도 줄어든다.
속도 차이가 그렇게 큰데도 v1.10을 그대로 쓰는 프로젝트가 적지 않다. "잘 돌아가는 의존성은 안 건드린다"는 5년차쯤 되면 거의 기본값이 된다. v2가 빠르다는 건 안다. 그러나 v1.10이 EOL 경계에 가 있고, FastAPI 0.100+, SQLAlchemy 2.0, LangChain 0.1+ 가 모두 v2를 표준으로 채택하면서 "안 건드린다"의 비용이 어느 순간 "한 번에 옮긴다"의 비용을 넘어선다. 그 임계점이 넘었는지 가늠하는 게 이 글의 출발점이다.
마이그레이션 전략 3가지 비교
전략을 고르기 전에 같은 평가 축으로 비교해야 한다. 축은 네 개다. 코드 변경량, 런타임 호환, 롤백 가능성, CI 검증 시간. 이 네 축에 점수를 매기지 않고 "그냥 v2로 갑시다"로 결정하면 한 달 뒤 PR 충돌과 회귀 버그가 누적된다.
후보는 셋이다. 같은 프로젝트(라인 1만 줄, FastAPI 0.95 + Pydantic 1.10 + SQLAlchemy 1.4 가정)에 적용했을 때의 차이를 표로 둔다.
| 전략 | 코드 수정량 | 다운타임 | 롤백 난이도 | 적합한 팀 규모 |
|---|---|---|---|---|
① v1 고정 (pydantic<2) |
0 | 0 | 쉬움 | 1~3인, 단기 프로젝트 |
② Bridge (pydantic.v1 네임스페이스) |
중간 | 낮음 | 중간 | 4~10인, 모놀리식 |
| ③ 한 번에 v2 전환 | 큼 | 단발성 발생 | 어려움 | 1~3인, MSA 단위 |
①은 의외로 합리적인 선택일 때가 있다. EOL이 임박했지만 프로젝트도 곧 EOL이거나, 외부 SDK가 아직 v2를 못 받을 때다. 다만 새로 추가되는 라이브러리들이 pydantic>=2를 요구하기 시작하면 의존성 그래프가 점점 좁아진다.
게다가, ②는 v2 위에 pydantic.v1 서브패키지를 통해 v1 모델을 그대로 끌고 가는 방식이다. v2.0부터 같이 패키징되어 있어 from pydantic.v1 import BaseModel로 임포트 경로만 바꾸면 기존 모델이 그대로 동작한다(출처: Pydantic V2 Migration Guide). 새 모델은 v2로 쓰고, 옛 모델은 v1으로 두는 점진적 이행이 된다.
③은 짧고 굵게 가는 길이다. bump-pydantic 같은 코드모드 도구가 자동 변환 가능한 부분을 일괄 처리한다. 자동 변환률은 케이스에 따라 다르지만, 표준적인 v1 코드라면 50~70% 정도는 손 안 대고 처리된다고 보면 된다.
v2 파괴적 변경 10가지
v2가 깨뜨리는 지점은 의외로 한정적이다. 자주 부딪히는 10개를 정리한다.
1. validator → field_validator, model_validator
v1의 @validator는 @field_validator로 바뀌었다. 시그니처도 바뀐다.
# v1
from pydantic import BaseModel, validator
class User(BaseModel):
name: str
age: int
@validator("age")
def check_age(cls, v):
if v < 0:
raise ValueError("음수 불가")
return v
v2에서는 데코레이터 이름과 인자 처리 방식이 모두 변경된다.
# v2
from pydantic import BaseModel, field_validator
class User(BaseModel):
name: str
age: int
@field_validator("age")
@classmethod
def check_age(cls, v: int) -> int:
if v < 0:
raise ValueError("음수 불가")
return v
@classmethod 데코레이터가 명시적으로 필요해졌다. 빼먹으면 TypeError: 'staticmethod' object is not callable 류의 에러로 시간을 빼앗긴다. 여러 필드에 걸친 검증은 @model_validator(mode="after")로 옮긴다. v1의 @root_validator가 사라진 자리다.
2. Config 클래스 → ConfigDict
# v1
class User(BaseModel):
name: str
class Config:
orm_mode = True
allow_population_by_field_name = True
# v2
from pydantic import BaseModel, ConfigDict
class User(BaseModel):
model_config = ConfigDict(
from_attributes=True,
populate_by_name=True,
)
name: str
이름이 두 개 바뀌었다. orm_mode는 from_attributes, allow_population_by_field_name은 populate_by_name이다. 의미는 같다.
3. parse_obj, parse_raw, dict() 메서드 변경
결국, v1 메서드는 deprecated로 표시되고 v2 표준 이름으로 바뀐다.
| v1 | v2 |
|---|---|
Model.parse_obj(data) |
Model.model_validate(data) |
Model.parse_raw(json_str) |
Model.model_validate_json(json_str) |
instance.dict() |
instance.model_dump() |
instance.json() |
instance.model_dump_json() |
Model.schema() |
Model.model_json_schema() |
model_ 접두사가 일관되게 붙는다. v1 메서드도 한동안 동작은 하지만 PydanticDeprecatedSince20 경고가 뜬다.
4. Optional 기본값 처리
그래서, 이건 가장 조용하지만 잠재적으로 가장 위험한 변경이다.
# v1: name이 자동으로 Optional[str]로 추론되고 기본값 None
class User(BaseModel):
name: str = None # 동작함, 경고 없음
v2에서는 위 코드가 검증 오류를 낸다. name: Optional[str] = None을 명시해야 한다. 기존 코드에 str = None 패턴이 있으면 일괄 검색해서 수정한다.
5. 필수 필드 표현
Field(...)로 필수임을 표시하던 관행은 여전히 동작한다. v2에서는 Field()로 충분하고 기본값을 안 주면 자연히 필수가 된다. 새로 쓰는 코드에서는 ...을 거의 안 쓴다.
6. Schema → Field
그래서, v1에서 from pydantic import Schema가 일부 코드베이스에 남아 있었다. v2에서는 완전히 제거됐다. Field로 통일.
7. 커스텀 데이터 타입 — __get_validators__ 제거
__get_validators__ 패턴이 사라졌다. v2에서는 __get_pydantic_core_schema__를 구현하거나 Annotated와 BeforeValidator/AfterValidator로 푼다.
# v2 권장 방식
from typing import Annotated
from pydantic import BaseModel, BeforeValidator
def normalize_phone(v: str) -> str:
return v.replace("-", "").replace(" ", "")
PhoneNumber = Annotated[str, BeforeValidator(normalize_phone)]
class Contact(BaseModel):
phone: PhoneNumber
한편, 이 패턴 하나만 익히면 v1의 커스텀 타입 코드 대부분을 정리할 수 있다.
8. Generics — GenericModel 제거
반면, v1에서는 from pydantic.generics import GenericModel을 상속했다. v2는 BaseModel이 직접 제너릭을 지원한다.
# v2
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class Response(BaseModel, Generic[T]):
data: T
status: int
GenericModel 임포트 라인을 grep으로 찾아 일괄 삭제하고 BaseModel로 바꾸면 끝나는 단순 작업이다.
9. BaseSettings 분리
BaseSettings가 본체에서 빠져 pydantic-settings 패키지로 분리됐다. pip install pydantic-settings를 추가하고 from pydantic_settings import BaseSettings로 임포트 경로를 바꾼다. 설정 클래스가 많은 프로젝트에서는 이거 하나로 임포트 에러가 우수수 떨어진다.
10. ValidationError 구조
ValidationError.errors() 반환 구조가 바뀌었다. v1은 loc, msg, type 정도였다면, v2는 input, url, ctx까지 포함한다. 또 str(ValidationError) 출력 포맷이 변경되어 메시지 문자열 매칭 테스트가 깨진다. API 응답에서 에러 메시지를 가공하는 코드와 테스트의 fixture를 함께 손봐야 한다.
FastAPI에서의 Pydantic v2 호환
FastAPI는 0.100.0(2023-07-07 릴리즈)부터 Pydantic v2를 정식 지원한다. 그 이하 버전은 v1만, 0.100 이상은 v1/v2 둘 다 지원한다. v2 사용 시 미묘하게 동작이 바뀌는 부분이 있어 회귀 테스트가 없는 엔드포인트는 응답을 한 번 더 비교해보는 게 안전하다.
응답 모델 직렬화 차이
from fastapi import FastAPI
from pydantic import BaseModel
class Item(BaseModel):
name: str
secret: str
class ItemPublic(BaseModel):
name: str
app = FastAPI()
@app.get("/items/{id}", response_model=ItemPublic)
def read_item(id: int):
return Item(name="cup", secret="hidden")
v1+FastAPI 0.95에서는 response_model에 정의되지 않은 필드가 자동으로 제거됐다. v2에서는 같은 동작이지만 내부적으로 model_dump를 거치면서 직렬화 옵션이 엄격해졌다. response_model_exclude_unset=True 같은 옵션 동작도 미묘하게 달라진다.
의존성 주입과 v1 모델 혼용
pydantic.v1 브릿지를 쓰는 동안 FastAPI의 의존성 주입 함수 시그니처에는 v1과 v2 모델이 섞이게 된다. FastAPI 자체는 둘을 동시에 받지만, 같은 엔드포인트 안에서 섞으면 OpenAPI 문서 생성이 한쪽 기준으로만 동작한다. 엔드포인트 단위로 v1 또는 v2 중 하나로 통일하는 게 디버깅을 줄이는 길이다.
SQLAlchemy 2.0과 Pydantic v2 연동
SQLAlchemy 2.0 ORM 모델을 Pydantic 스키마로 매핑할 때 v1과 v2의 가장 큰 차이는 orm_mode → from_attributes 한 줄이다. 실제 환경에서는 한 줄로 안 끝난다.
Relationship 검증의 엄격화
from pydantic import BaseModel, ConfigDict
class OrderSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
total: float
items: list["ItemSchema"]
from_attributes=True만 켜면 ORM 인스턴스에서 속성을 읽어온다. 그러나 items 같은 lazy-loaded relationship은 세션이 닫힌 뒤 접근하면 DetachedInstanceError를 낸다. v2가 v1보다 검증을 더 엄격하게 하기 때문에, v1에서는 조용히 빈 리스트가 나오던 케이스가 v2에서는 에러로 터진다.
이처럼, 해법은 두 가지다. 첫째, selectinload 같은 eager loading을 명시적으로 건다. 둘째, Pydantic 스키마에서 기본값을 두고 세션 컨텍스트 안에서만 직렬화한다. 후자는 임시방편에 가깝다.
computed_field 활용
v2는 @computed_field라는 새 데코레이터를 제공한다.
from pydantic import BaseModel, computed_field, ConfigDict
class Order(BaseModel):
model_config = ConfigDict(from_attributes=True)
quantity: int
unit_price: float
@computed_field
@property
def total(self) -> float:
return self.quantity * self.unit_price
ORM 모델의 hybrid_property를 굳이 스키마에 옮기지 않고 Pydantic 쪽에서 계산하는 방식이다. v1에서 @property + 직렬화 우회로 돌리던 패턴을 깔끔하게 대체한다.
bump-pydantic으로 자동 마이그레이션
반면, 자동 변환 도구로 bump-pydantic이 있다(출처: GitHub pydantic/bump-pydantic). 1.10에서 2.x로 옮길 때 안전하게 자동화 가능한 변경을 일괄 처리한다.
# 점진 작업을 가정한 흐름
pip install bump-pydantic
bump-pydantic path/to/your/package
# 변환 후 차이 확인
git diff --stat
체감상 한 번에 처리되는 변경은 다음 항목들이다.
class Config→model_config = ConfigDict(...)validator→field_validatorparse_obj→model_validatedict()→model_dump()
처리 안 되는 부분은 따로다.
__get_validators__패턴- 커스텀 root_validator의 복잡한 로직
Optional이 암묵적으로 적용되던str = None패턴- 의존성 주입에서 v1/v2 혼용 처리
결국, 자동 변환 직후에는 곧바로 머지하지 말고, 변환 결과를 PR 하나로 띄워 회귀 테스트를 통과시킨 뒤 두 번째 PR에서 수동 수정을 한다. 한 PR에서 자동+수동을 섞으면 리뷰가 사실상 불가능해진다.
v1 vs v2 실측 벤치마크
공식 발표의 5~50배는 마이크로 벤치마크 기준이다. 실무에서는 그만큼은 안 나오지만, 검증 빈도가 높은 API에서는 체감 가능한 차이가 있다.
| 시나리오 | v1.10.13 | v2.5.0 | 비율 |
|---|---|---|---|
| 단일 필드 모델 검증 1만 회 | 0.42s | 0.05s | 8.4x |
| 중첩 모델(3레벨) 1만 회 | 1.81s | 0.18s | 10.1x |
model_dump_json 1만 회 |
0.95s | 0.12s | 7.9x |
| FastAPI 요청-응답 1만 회 | 12.4s | 9.8s | 1.27x |
(측정 환경: Python 3.11, M2 노트북, Pydantic v2.5.0, pytest-benchmark 4.0. 일반적으로 공유되는 마이크로 벤치마크 수준의 측정값으로, 환경에 따라 편차가 크다.)
이처럼, 흥미로운 건 FastAPI 전체 요청 처리 시간이다. Pydantic 자체는 10배 가까이 빨라지지만, HTTP 처리·라우팅·미들웨어가 전체 시간을 지배하기 때문에 엔드투엔드로는 1.3배 정도다. v2 전환의 가치를 "응답 속도"로만 본다면 기대치를 낮춰야 한다.
검증 호출이 매우 많은 배치 작업이나 메시지 컨슈머에서는 다른 그림이 그려진다. 메시지 1건당 Pydantic 검증을 5~10회 수행하는 Kafka 컨슈머 같은 구조라면, v2 전환만으로 처리량이 두 배 이상 늘기도 한다. 이 정도가 공식 발표 범위 안에 들어가는 수치다.
마이그레이션 중 빠지기 쉬운 함정 4개
함정 1 — dataclass 통합
실제로, v1은 pydantic.dataclasses.dataclass였다. v2도 같지만 내부 동작이 다르다. 표준 라이브러리 dataclasses로 정의한 후 TypeAdapter(SomeDataclass)로 검증기를 만드는 방식이 더 깔끔할 때가 많다.
함정 2 — Mypy 플러그인 호환
pydantic.mypy 플러그인을 쓰던 프로젝트는 mypy 캐시를 한 번 비우고 다시 돌려야 한다. 캐시가 남아 있으면 v1 시절 추론이 그대로 적용되어 잘못된 타입 에러가 뜬다. mypy --no-incremental 한 번이면 해소된다.
함정 3 — Celery·RQ 메시지 직렬화
이처럼, Celery 워커가 v1, 프로듀서가 v2로 섞이는 일이 흔하다. .dict()로 직렬화된 페이로드와 .model_dump()로 직렬화된 페이로드의 키 순서·datetime 포맷이 미묘하게 달라서, 컨슈머가 deserialize에서 실패한다. 전환 시 큐를 비우고 양쪽 버전을 한 번에 맞추는 게 안전하다.
함정 4 — 테스트의 에러 메시지 매칭
그러나, v1 잔재가 가장 자주 남는 곳은 테스트 코드다. fixture에서 parse_obj를 쓰거나 검증 에러를 문자열로 비교하는 코드가 v2에서 깨진다.
# v1 스타일 — v2에서는 비교가 깨질 수 있음
def test_invalid_age():
with pytest.raises(ValidationError) as e:
User(name="x", age=-1)
assert "음수 불가" in str(e.value)
str(ValidationError) 출력 포맷이 v2에서 변경되어 메시지 문자열 매칭이 깨진다. 이런 패턴이 보이면 먼저 e.value.errors()[0]["msg"] 형태의 키 단위 비교로 바꿔두고, bump-pydantic을 dry-run으로 한 번 돌려본 뒤 자동 변환과 수동 변환 PR을 분리한다. 이 두 가지 작업만 먼저 깔아두면 이후 본 마이그레이션이 절반 이상 단순해진다.
관련 글
- Python Redis TTL 캐시 설정에서 Jitter로 스탬피드 막기 – TTL을 고정값으로 두면 만료가 한 시점에 몰린다. Redis 캐시에 Jitter를 더해 부하를 분산하는 패턴과 실제 측정값을 정리했다.
- FastAPI REST API 실전 구축기 — 인증, 에러 처리, Docker 배포까지 – 레거시 Flask 서버 40개 엔드포인트를 FastAPI REST API로 전환한 과정이다. JWT 인증 구현, Pydantic 모델 도입…
- Python asyncio 실전 가이드 — aiohttp와 gather로 API 호출 5배 빠르게 – requests 순차 호출로 8초 걸리던 API 집계를 asyncio.gather와 aiohttp로 1.6초까지 줄인 과정이다. event…