-
Pydantic BaseModel이란 무엇인가 — 타입 힌트를 진짜 검증으로 바꾸는 도구IT 2026. 6. 29. 23:00
Python으로 LLM 에이전트나 웹 API를 만들다 보면
Pydantic BaseModel이라는 이름을 자주 만난다. LangChain의 도구 정의에서도, FastAPI의 요청 본문에서도, 설정 파일 로딩에서도 BaseModel이 등장한다. 그런데 "BaseModel을 상속받아 클래스를 만든다"는 설명만 보면 그게 정확히 뭘 해주는 도구인지 잡히지 않는다. 이 글은 BaseModel 하나만 떼어내서 본다 — 이 이름이 무엇을 의미하는지, 어떤 문제를 풀려고 태어났는지, 그리고 그 문제를 어떻게 푸는지.먼저 풀어야 할 문제: 타입 힌트는 거짓말을 막지 못한다
Python에는 타입 힌트(type hint)가 있다. 함수 파라미터에
query: str,top_k: int처럼 "이 자리에는 이런 타입이 와야 한다"고 적어두는 문법이다. IDE는 이걸 보고 자동완성을 해주고, mypy 같은 정적 분석기는 코드를 실행하기 전에 타입이 안 맞는 곳을 짚어준다.문제는 타입 힌트가 실행 시점(runtime)에는 완전히 무시된다는 것이다. Python은 함수가 실제로 호출될 때 인자의 타입을 검사하지 않는다.
top_k: int라고 적어둔 자리에 문자열"셋"이 들어와도 Python은 아무 불평 없이 그대로 함수 안으로 들여보낸다. 그래서 잘못된 값은 함수 입구에서 걸리지 않고, 한참 안쪽에서top_k + 1같은 연산을 만났을 때야 뒤늦게 터진다.타입 힌트만으로는 잘못된 값을 입구에서 막지 못하는 구조. 위에서 아래로 읽으면 된다. 외부에서 엉뚱한 타입의 값이 들어와도(맨 위), 함수에
int라는 타입 힌트가 적혀 있어도(둘째), Python은 그 힌트를 런타임에 거들떠보지 않고 값을 그대로 통과시킨다(셋째). 그래서 에러는 값이 들어온 입구가 아니라 그 값을 실제로 쓰는 함수 깊숙한 곳에서야 터진다(맨 아래). 이게 왜 골치 아픈가 하면, 에러가 난 지점과 잘못된 값이 들어온 지점이 멀리 떨어져 있어 원인을 추적하기 어렵기 때문이다. 놓치기 쉬운 함정은 "타입 힌트를 적었으니 타입이 보장되겠지"라는 착각이다 — 힌트는 사람과 도구를 위한 주석일 뿐, 런타임 강제력이 전혀 없다.이 문제는 특히 데이터가 프로그램 바깥에서 들어올 때 심각해진다. 사용자가 보낸 웹 폼, API 요청의 JSON, 환경변수, 설정 파일, 그리고 LLM이 생성한 도구 호출 인자 — 이런 "경계 너머"의 데이터는 내가 통제할 수 없고 항상 깨져 있을 가능성이 있다. 숫자여야 할 곳에 문자열이, 있어야 할 필드가 비어 있고, 형식이 어긋난 값이 섞여 들어온다. 이 지저분한 외부 데이터를 믿을 수 있는 Python 객체로 바꾸는 "관문"이 필요하다. 그게 BaseModel이 풀려는 문제다.
이름의 뜻: Pydantic과 BaseModel
이름을 뜯어보면 도구의 성격이 드러난다. Pydantic은
Py(Python) +pedantic(깐깐한, 세세한 것까지 따지는)의 합성이다. "타입에 대해 깐깐하게 구는 Python 라이브러리"라는 뜻이다. 이름 그대로, 들어오는 데이터가 선언한 규칙에 정확히 맞는지 꼼꼼히 따진다.BaseModel은 Pydantic이 제공하는 부모 클래스(base class)다. "Base"는 상속의 출발점이라는 뜻이고, "Model"은 데이터의 모양(구조와 규칙)을 본떠 만든 틀이라는 뜻이다. 우리가 직접 BaseModel을 쓰는 게 아니라, BaseModel을 상속해서 내 데이터에 맞는 자식 클래스를 정의한다. 그러면 검증·변환·직렬화 같은 기능이 부모로부터 전부 물려받아져, 나는 "필드가 무엇무엇인지"만 선언하면 된다.
BaseModel을 상속해 내 데이터 클래스를 만들면 기능이 자동으로 따라오는 구조. 왼쪽 위가 라이브러리가 주는 부모 클래스이고, 거기서 화살표를 따라 내려가면 내가 만드는 자식 클래스가 있다. 내가 직접 쓰는 코드는 "필드 선언" 한 덩어리뿐이다 — 어떤 필드가 있고 각각 무슨 타입인지만 적는다. 그 대가로 검증·변환·스키마 생성·직렬화 등이 부모로부터 통째로 딸려 온다. 이 패턴의 핵심 가치는 "선언과 기능의 분리"다. 데이터의 모양은 내가 선언하고, 그 모양을 강제하는 지루한 일은 라이브러리가 떠맡는다. 놓치기 쉬운 함정은 BaseModel을 "데이터를 담는 그릇" 정도로만 보는 것인데, 실제로는 그릇이자 동시에 검문소다 — 값을 담는 순간 검사가 일어난다.
어떻게 푸는가 ① — 인스턴스를 만드는 순간 검증이 일어난다
BaseModel의 핵심 동작은 단순하다. 클래스의 인스턴스를 만드는 그 순간, 들어온 값들이 선언한 타입·규칙에 맞는지 검사한다. 통과하면 깔끔한 객체가 만들어지고, 어긋나면 그 자리에서 즉시 에러를 낸다. "함수 깊숙한 곳"이 아니라 "객체를 만드는 입구"에서 막는 것 — 앞에서 본 타입 힌트의 한계를 정확히 메운다.
from pydantic import BaseModel # 1단: 데이터의 모양과 규칙을 클래스로 선언 class SearchInput(BaseModel): query: str # 필수 — 기본값이 없으면 반드시 있어야 함 top_k: int = 3 # 선택 — 기본값 3 # 2단: 인스턴스를 만드는 순간 검증이 일어남 ok = SearchInput(query="크리스토퍼 놀란", top_k=5) # → 통과. ok.query, ok.top_k 로 안전하게 접근 가능 # 3단: 규칙 위반은 입구에서 즉시 차단 bad = SearchInput(top_k=5) # → ValidationError: query 필드가 없음 (필수인데 빠짐)선언 → 인스턴스화 → 검증의 3단계를 보여주는 코드. 첫 단은 데이터의 모양을 클래스로 적는 부분이다. 기본값이 없는
query는 필수 필드, 기본값이 있는top_k는 생략 가능한 선택 필드가 된다 — 이 구분을 별도 코드 없이 "기본값 유무"만으로 표현한다는 게 영리한 지점이다. 둘째 단에서 올바른 값으로 인스턴스를 만들면 검증을 통과해 객체가 나온다. 셋째 단에서 필수 필드를 빠뜨리면, 함수 안쪽이 아니라 바로 이 인스턴스를 만드는 줄에서 에러가 터진다. 핵심 가치는 "잘못된 데이터가 시스템 안으로 한 발짝도 들어오지 못한다"는 것이다 — 검증을 통과한 객체는 그 이후 코드에서 무조건 믿고 쓸 수 있다.BaseModel이 입구에서 데이터를 검문하는 흐름. 원시 입력값(맨 위)이 인스턴스 생성으로 들어오면, 가운데 마름모에서 선언한 규칙과 대조하는 검문이 일어난다. 규칙에 맞으면 오른쪽 아래로 빠져 "검증된 객체"가 되고, 어긋나면 왼쪽 아래로 빠져 에러가 난다. 여기서 주목할 점은 갈림길이 인스턴스를 만드는 그 한 줄에 집약돼 있다는 것이다. 데이터의 신뢰성을 판단하는 지점이 코드 곳곳에 흩어지지 않고 이 관문 하나로 모인다. 그래서 "이 객체가 유효한 데이터인가?"를 매번 다시 의심할 필요가 없어진다 — 객체가 존재한다는 사실 자체가 이미 검증을 통과했다는 증거이기 때문이다.
어떻게 푸는가 ② — 고칠 수 있는 건 고치고(강제 변환), 못 고치면 막는다
검증이라고 해서 무조건 빡빡하게 거절만 하는 건 아니다. Pydantic은 "고칠 수 있는 어긋남"은 알아서 바로잡는다. 이걸 강제 변환(coercion)이라고 한다. 예를 들어 정수 필드에 문자열
"3"이 들어오면, 이건 명백히 정수 3을 의도한 값이므로 Pydantic이 자동으로3으로 바꿔준다. 반대로"셋"처럼 도저히 정수로 해석할 수 없는 값은 변환을 포기하고 에러를 낸다.class Item(BaseModel): count: int Item(count="3") # → count=3 으로 자동 변환 (고칠 수 있는 어긋남) Item(count=3.0) # → count=3 으로 변환 Item(count="셋") # → ValidationError (변환 불가, 막음)강제 변환이 "고칠 수 있는 값"과 "못 고치는 값"을 가르는 코드. 문자열
"3"이나 실수3.0은 정수로 안전하게 옮길 수 있으니 Pydantic이 조용히 변환해 준다. 반면 한글"셋"은 정수로 바꿀 방법이 없으니 변환을 멈추고 에러를 낸다. 이 동작이 중요한 이유는 외부 데이터가 "의미는 맞는데 타입만 살짝 어긋난" 경우가 흔하기 때문이다 — 웹 폼은 모든 값을 문자열로 보내고, JSON에는 정수와 실수 구분이 모호한 경우가 있다. 매번 손으로int(...)를 호출하며 변환하던 일을 라이브러리가 대신해 준다. 놓치기 쉬운 함정은 강제 변환을 "아무거나 다 받아준다"고 오해하는 것이다 — 어디까지나 안전하게 해석 가능한 범위에서만 변환하고, 모호하거나 불가능하면 막는다.값 하나가 들어왔을 때 받아들일지 변환할지 거절할지 판단하는 흐름. 맨 위에서 값이 들어오면, 먼저 "원래 타입이 그대로 맞는지" 본다 — 맞으면 그대로 통과(오른쪽). 안 맞으면 아래 마름모로 내려가 "안전하게 변환할 수 있는지" 따진다. 변환 가능하면 고쳐서 받아들이고, 불가능하면 에러로 막는다. 이 2단계 판단이 곧 Pydantic의 실용적 태도를 보여준다. 완벽하게 깨끗한 데이터만 고집하지도, 아무거나 다 삼키지도 않는다 — 의도가 분명하면 관용을 베풀고, 의도조차 알 수 없으면 단호히 거절한다. 이 균형 덕분에 LLM이
"3"을3으로 잘못 보내는 흔한 실수는 자동 복구되면서도, 진짜 엉뚱한 값은 걸러진다.어떻게 푸는가 ③ — 한 번 선언하면 여러 산출물이 따라 나온다
BaseModel 클래스 하나를 선언하면, 그 선언으로부터 여러 쓸모가 자동으로 파생된다. 같은 정보(필드 이름·타입·기본값·설명)를 여기저기 중복해서 적을 필요가 없다는 게 핵심이다.
한 번의 클래스 선언에서 네 가지 쓸모가 파생되는 구조. 맨 위 클래스 선언 하나가 출발점이고, 거기서 네 갈래로 뻗어 나간다. 들어오는 값을 거르는 검증된 객체, 외부(LLM이나 API 사용자)에게 "이 데이터는 이렇게 생겼다"고 알려주는 JSON Schema, 객체를 저장·전송하기 좋은 dict나 JSON으로 바꾸는 직렬화, 그리고 편집기가 필드 타입을 알아채는 자동완성까지. 이 구조의 진짜 가치는 단일 출처(single source of truth)다. 데이터의 모양을 한 곳에만 적어두면 검증 규칙과 문서(스키마)와 저장 형식이 자동으로 동기화된다 — 클래스를 고치면 네 산출물이 한꺼번에 따라 바뀐다. 놓치기 쉬운 함정은 이것들을 각각 따로 관리하던 기존 방식의 비용을 잊는 것이다. 손으로 JSON Schema를 쓰고, 따로 검증 코드를 짜고, 또 따로 직렬화 함수를 만들면 셋이 어긋나는 순간 버그가 생긴다. BaseModel은 셋을 한 선언에 묶어 어긋날 여지를 없앤다.
다른 방법들과의 비교 — 왜 dict나 dataclass가 아니라 BaseModel인가
"데이터를 담는 것"만 놓고 보면 Python에는 이미 여러 선택지가 있다. 평범한 딕셔너리(dict), 표준 라이브러리의
dataclass, 타입 힌트용TypedDict. 이들과 BaseModel의 결정적 차이는 "런타임에 들어온 값을 실제로 검증하느냐"에 있다.방식 런타임 검증 강제 변환 JSON Schema 생성 필드별 설명 평범한 dict ❌ ❌ ❌ ❌ dataclass (표준) ❌ (타입 미검사) ❌ ❌ △ (수동) TypedDict ❌ (힌트만) ❌ ❌ ❌ Pydantic BaseModel ✅ ✅ ✅ ✅ (Field) 네 가지 데이터 담기 방식의 능력 비교. 위에서 아래로 갈수록 기능이 풍부해진다. 평범한 딕셔너리는 아무것도 검사하지 않아 자유롭지만 위험하다 — 오타 난 키나 잘못된 타입이 그대로 들어간다.
dataclass는 필드를 클래스로 깔끔하게 선언하게 해주지만, 정작 런타임에 타입을 검사하지는 않는다(타입 힌트와 같은 한계).TypedDict는 딕셔너리의 모양을 타입 힌트로 적는 도구라 정적 분석에만 쓰이고 런타임 강제력이 없다. 오직 BaseModel만이 마지막 줄 전부에 체크가 들어간다. 핵심은 BaseModel이 "외부 데이터의 관문"이라는 특수한 역할에 맞춰 설계됐다는 점이다 — 나머지 셋은 "내가 만든 신뢰할 수 있는 데이터를 담는" 용도에 가깝다. 그래서 데이터 출처가 프로그램 바깥(API·LLM·사용자)일 때는 BaseModel이 사실상 표준이 됐다.실제로 어디에 쓰이나
BaseModel이 빛나는 곳은 모두 "신뢰할 수 없는 외부 데이터가 들어오는 경계"다. 대표적인 세 자리를 보자.
BaseModel이 실제로 투입되는 세 가지 경계와 그 공통 효과. 위쪽 세 박스는 각각 다른 상황이다. 웹 API에서는 사용자가 보낸 요청 JSON을, LLM 도구에서는 모델이 생성한 호출 인자를, 설정 로딩에서는 환경변수와 설정 파일 값을 받는다. 출처는 다르지만 셋 다 "내가 통제할 수 없는 바깥에서 온, 깨져 있을 수 있는 데이터"라는 공통점이 있다. 그래서 모두 맨 아래의 같은 효과로 수렴한다 — 지저분한 외부 데이터를 검증된 Python 객체로 바꾸는 관문 역할을 한다. 이게 BaseModel이 현대 Python 생태계에서 이토록 널리 쓰이는 이유다. 특히 LangChain의
@tool데코레이터가 내부에서 BaseModel을 자동 생성해 LLM에게 도구 인터페이스를 알려주는 방식은, 이 "경계 관문" 발상을 LLM 시대에 그대로 적용한 사례다.정리: BaseModel이 바꾼 것
Pydantic BaseModel을 한 문장으로 요약하면 — "데이터의 모양과 규칙을 클래스로 한 번 선언하면, 들어오는 값을 그 규칙에 맞춰 자동으로 검사·변환해 믿을 수 있는 객체로 바꿔주는 도구"다. 이름의 "pedantic"이 약속하는 깐깐함을, 인스턴스를 만드는 그 순간의 검증으로 실현한다.
출발점이었던 문제로 돌아가 보자. Python 타입 힌트는 런타임에 무시되는 주석일 뿐이라, 외부에서 들어온 잘못된 값을 막지 못했다. BaseModel은 그 힌트에 실행력을 부여한다 — 같은 타입 선언 문법을 쓰되, 인스턴스를 만들 때 실제로 검사하고, 고칠 수 있으면 고치고, 못 고치면 그 자리에서 막는다. 그 결과 검증을 통과한 객체는 시스템 어디서든 의심 없이 쓸 수 있고, 같은 선언에서 JSON Schema와 직렬화까지 따라 나와 데이터의 모양이 한 곳에서 단일하게 관리된다. "외부의 지저분한 데이터를 안으로 들이는 관문"이 필요한 모든 자리에서 BaseModel이 표준이 된 이유다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
MCP는 왜 Streamable HTTP로 갈아탔나 — 엔드포인트 하나로 스트림을 다스리는 법 (0) 2026.07.01 MCP는 왜 SSE를 골랐나 — HTTP 위에서 서버가 먼저 말하게 하는 법 (0) 2026.07.01 서브 에이전트를 @tool로 감싸는 멀티 에이전트 패턴 (0) 2026.06.30 @tool이 내부에서 하는 일 — Pydantic BaseModel이 LLM의 호출 인터페이스가 되는 과정 (0) 2026.06.30 LangGraph가 Annotated를 쓰는 이유 — 덮어쓰기 문제와 리듀서의 등장 (0) 2026.06.29 RAG 에이전트 완전 조립 — create_agent부터 동작 추적까지 (1) 2026.06.29 검색 결과를 에이전트 도구로 — build_context와 @tool 패턴 (0) 2026.06.28 RAG의 배경과 make_retriever — LLM이 모르는 문서를 검색하는 방법 (0) 2026.06.28 생성과 검증의 분리 — generator 노드와 validator 노드 설계 (0) 2026.06.28 LangGraph 자기 수정 패턴의 State 설계 — 루프를 위한 5가지 필드 (0) 2026.06.27