
지난 스터디 시간은 내 발표 차례였다. 준비한 코드를 한 줄 한 줄 설명하며 내려가던 중, 평소 차분하게 설명하는 성희의 목소리가 유독 한 대목에서 커졌던 것 같다.
내 코드중에는 아래와 같은 코드가 포함되어있었다.
class QuestionRequest(BaseModel):
query: str
현직 AI 개발자인 성희는 이 BaseModel 부분을 보더니 평소보다 훨씬 강한 어조로 말했다.
"이거, 생각보다 훨씬 무서운 애예요. 진짜 지독하게 강제한다니까요?"
(진짜 이렇게 얘기했다기 보다... 이런 느낌이었다)
그 말 속에는 단순한 설명 이상의 무언가가 느껴졌다. 뭔가 '애증'의 느낌이었달까. 대체 성희는 왜 이 짧은 코드 한 줄에 그토록 진심이었을까? 오늘은 성희가 그토록 강조했던 Pydantic의 BaseModel이 대체 무엇을 어떻게 강제하는지, 왜 우리 서버의 파수꾼이라 불리는지 파헤쳐 보려 한다.
✦ 제미나이 왈 : BaseModel은 FastAPI와 같은 현대적인 Python 프레임워크에서 데이터의 규격(Spec)을 정의하는 핵심도구이다.
지피티 왈 : BaseModel은 FastAPI에서 JSON 요청/응답을 Python 객체처럼 다루게 해주는 핵심 도구야.
1. 그래서... BaseModel이 뭐냐고?
BaseModel은 데이터 유효성 검사 및 설정 관리를 위한 라이브러리인 Pydantic의 핵심 클래스이다.
쉽게 비유하자면, 데이터를 담는 틀(붕어빵 틀)이자 검문소 역할을 한다.
1.1. 타입 강제(Type Enforcement) : 성희가 "강제"라는 단어를 "강조"했었던 것 같은데...
파이썬은 원래 변수 타입을 자유롭게 바꿀 수 있는 유연한 언어지만, BaseModel안에서는 그게 허용되지 않는다.
BaseModel은 단순히 데이터를 담는 그릇이 아니라, 데이터를 엄격하게 관리하고 통제하는 "법률"같은 역할을 한다.
무엇을 강제하는지 핵심적인 3가지를 정리해보도록 하자.
1.1.1. 엄격한 타입 체크 :
age : int라고 선언했는데 "서른 살" 같은 문자열이 들어오면, BaseModel은 가차 없이 ValidationError를 던지며 거절한다.
1.1.2. 자동 형변환
만약 age : int인데 "30"(따옴표가 붙은 문자열)이 들어오면, '이 정도는 숫자로 바꿀 수 있겠네'라고 판단해서 자동으로 숫자 30으로 변환해 준다.
이를 통해 개발자가 일일이 int()로 형변환 하는 수고를 덜어준다.
1.2. 필수 값과 제약 조건 강제
아래의 코드는 스터디 준비를 위해 작성했던 코드 중, 리팩토링된 코드로 Filed(...)나 min_length 같은 설정들이 이 부분에 해당한다.
query: str = Field(..., min_length=1, description="사용자 질문")
- 존재 유무 강제 : query : str = Field(...)에서 ...은 "이 값은 절대 누락되면 안 된다"는 뜻이다. 클라이언트가 query를 빼먹고 요청을 보내면 서버는 실행조차 되지 않고 입구에서 컷(Cut)당한다.
- 값의 범위 강제 : 코드상의 main_length=1은 "최소 한 글자는 써라"는 강제 사항이다. 공백이나 빈 값을 보낼 수 없도록 논리적으로 막아버린다.
1.3. 구조의 강제(Schema Rigidity) :
클라이언트가 우리 서버에 데이터를 보낼 때, 우리가 정한 '설계도' 그대로 보내도록 강제한다.
- 예를 들어, QuestionRequest는 오직 query라는 키(Key) 하나만 받기로 약속된 모델이다.
- 만약 누군가 {"question : "안녕"} 이렇게 이름을 마음대로 바꿔서 보내면, "나는 query를 기다리고 있는데 왜 다른 걸 보내니?"라며 거부한다.
1.4. 결론 : 근데... 왜 이렇게까지 강제하는걸까?
개발자(특히 백엔드 개발자) 입장에서 예상치 못한 데이터는 곧"장애"이다.
1. 방어적 설계 : 데이터가 서버 내부 로직으로 깊숙이 들어오기 전에 입구에서 미리 걸러내여, 시스템이 뻗는 것을 방지한다.
2. 커뮤니케이션 비용 절감 : API 명세서(Swagger 등)에 이 강제 사항들이 그대로 노출되므로, 프론트엔드 개발자나 외부 사용자가, "아, 데이터는 이렇게 보내야만 하는구나"라고 즉시 알 수 있게 된다.
"결국 주는 대로 다 받는 착한 서버 보다는 까다롭게 검사하는 깐깐한 서버가 훨씬 안전하고 똑똑한 서버라고 볼 수 있다."라고 제미나이가 말해줬다.
1.2. 주요 기능

→ 이미 에러가 났다는건 터졌다는거 아닌가? 서버가 터지는 것을 방지한다니 이건 또 무슨말이야... :
- 데이터를 받을 때 검증 없이 그냥 받는다고 가정해 보자.
# 가상의 상황: 검증 없이 데이터를 바로 연산에 사용
def calculate_price(quantity):
# 사용자가 숫자가 아닌 "열개"라고 보냈다면?
total = quantity * 1000 # 여기서 TypeError 발생!
return total
- 상황 : 클라이언트가 quantity에 "열개"라는 문자열을 보낸다.
- 결과 : 파이썬 코드가 실행되다가 quantity * 1000 부분에서 TypeError가 나면서 멈춰버린다.
- 사용자 화면 : "500 Internal Server Error"가 뜬다. 서버 로그에는 시뻘건 에러 메시지가 찍히고, 서비스는 응답을 중단하게 된다.
이게 우리가 흔히 말하는 "서버가 터졌다"는 상황이다.
2) BaseModel이 있을 때(입구에서 '컷'하는 상황)
- BaseModel을 적용한 경우
class OrderRequest(BaseModel):
quantity: int # 숫자가 들어와야 한다고 명시
- 상황 : 클라이언트가 똑같이 "열개"를 보낸다.
- 결과 : 이 데이터가 서버 내부의 복잡한 계산 로직(calculate_price)으로 들어가기도 전에, BaseModel이 입구에서 딱 잡는다.
"야, 너 int 준다며? 근데 왜 문자열이야? 다시 가져와!"
- 사용자 화면 : 500 에러가 아니라 "422 Unprocessable Entity"에러가 뜬다. "요청한 데이터 형식이 잘못되었다"는 친절한 안내가 나가는 거다.
핵심적인 차이점
| 비교 항목 | 검증 없을 때 (위험) | BaseModel 사용 시(안전) |
| 에러 시점 | 비즈니스 로직 수행 도중 | 데이터 입구(Endpoint) |
| 에러 종류 | 500 Internal Server Error | 422 Validation Error |
| DB 영향 | 잘못된 데이터가 DB에 쌓일 수 있음 | 잘못된 데이터는 근처에도 못 감 |
| 서버 상태 | 예기치 못한 종료 위험 | 안정적으로 계속 구동됨 |
결국 BaseModel이 서버가 터지는 걸 방지한다는 말은, "잘못된 데이터 때문에 프로그램 내부에서 예상치 못한 에러(Crash)가 발생하는 것을 막고, 사용자에게 "너 데이터 잘못 보냈어"라고 알려준다는 뜻이다.
특히 외부 API를 쓰거나 데이터를 수집할 때, 이상한 값이 들어와서 전체 시스템이 먼추면 안되니까 이러한 검증이 정말 중요하다.
이건 쉽게 말하면:
“요청 Body로 들어오는 JSON은 query라는 문자열 값을 가져야 한다”
라는 데이터 설계도 / DTO 클래스야.
예를 들어 클라이언트가 이렇게 보내면:
{
"query": "금리가 오르면 채권 가격은 왜 떨어져?"
}
FastAPI는 이 JSON을 자동으로 검사해서:
request.query
처럼 쓸 수 있게 바꿔줘.
즉 BaseModel을 쓰면 FastAPI가 자동으로 해주는 게 많아.
class QuestionRequest(BaseModel):
query: str
이 코드 하나로 아래 기능이 생김.
1. JSON Body 파싱
2. 타입 검사
3. 필수값 검사
4. 에러 응답 자동 생성
5. Swagger 문서 자동 생성
6. Python 객체처럼 접근 가능
예를 들어 사용자가 이렇게 보내면:
{
"query": 123
}
또는
{}
FastAPI/Pydantic이 알아서 “잘못된 요청입니다”라고 422 에러를 만들어줘.
2. 그럼 Field는 뭐냐?
기존 코드:
class QuestionRequest(BaseModel):
query: str
리팩토링 코드:
class QuestionRequest(BaseModel):
query: str = Field(..., min_length=1, description="사용자 질문")
이건 query에 조건을 추가한 거야.
Field(...)
여기서 ...은 필수값이라는 뜻이야.
min_length=1
빈 문자열은 안 된다는 뜻이야.
description="사용자 질문"
Swagger 문서에 설명으로 표시돼.
즉 이 코드는:
query: str = Field(..., min_length=1, description="사용자 질문")
이런 의미야.
query는 필수값이고,
문자열이어야 하고,
최소 1글자 이상이어야 하며,
Swagger 문서에서는 "사용자 질문"이라고 설명한다.
3. 코드가 왜 이렇게 늘어났을까?
기존에는 요청 데이터만 정의했어.
class QuestionRequest(BaseModel):
query: str
즉 “사용자가 질문을 보낸다” 정도만 정의한 거야.
그런데 리팩토링 후에는 응답 데이터 구조까지 정의한 거야.
class SearchResult(BaseModel):
page: int
chunk: int
similarity: float
content: str
이건 검색 결과 하나의 구조야.
예를 들면:
{
"page": 3,
"chunk": 2,
"similarity": 0.87,
"content": "금리가 상승하면 기존 채권의 매력도가 낮아져..."
}
그리고 이걸 여러 개 담는 응답이:
class QuestionResponse(BaseModel):
status: str
results: list[SearchResult]
즉 최종 응답은 이런 모양이라는 뜻이야.
{
"status": "success",
"results": [
{
"page": 3,
"chunk": 2,
"similarity": 0.87,
"content": "금리가 상승하면..."
},
{
"page": 5,
"chunk": 1,
"similarity": 0.82,
"content": "채권 가격과 시장금리는..."
}
]
}
핵심 정리
기존 코드:
class QuestionRequest(BaseModel):
query: str
의 의미는:
요청으로 query 하나만 받겠다.
리팩토링 코드:
class QuestionRequest(BaseModel):
query: str = Field(..., min_length=1, description="사용자 질문")
class SearchResult(BaseModel):
page: int
chunk: int
similarity: float
content: str
class QuestionResponse(BaseModel):
status: str
results: list[SearchResult]
의 의미는:
요청은 query를 받고,
빈 질문은 막고,
응답은 status와 검색 결과 목록 형태로 명확하게 반환하겠다.
그래서 코드가 늘어난 이유는 복잡해진 게 아니라, 요청/응답 구조를 명확하게 문서화하고 검증하도록 만든 것이야.
실무적으로는 리팩토링 후 코드가 더 좋은 방향이 맞아.
'스터디 > RAG' 카테고리의 다른 글
| [framework] 랭체인은 쉽게되는데 왜 우리는 안됐을까? (0) | 2026.05.03 |
|---|---|
| [Python - Library] 화면 그리기 귀찮다... 나도 Gradio 써본다. (0) | 2026.05.03 |
| [개념짚고가기] FAISS 이 녀석은 왜 나타났나? (0) | 2026.04.23 |
| [코드리뷰] VectorStore - 리팩토링2 (0) | 2026.04.20 |
| [FastAPI] Spring Boot 개발자가 이해하는 FastAPI 구조와 용어 매핑 (1) | 2026.04.19 |