FastAPI 교재 · 3편 · 요청 본문과 응답

FastAPI Pydantic — 요청 본문과 응답 모델

이름·이메일·비밀번호처럼 묶인 데이터는 주소에 못 싣는다. Pydantic 모델 하나로 받고, 검증하고, 응답 모양까지 고정한다.

들어오는 JSON 객체가 필터를 통과해 파이썬 Pydantic 모델 상자로 검증되어 들어가는 컨셉 아이소메트릭 일러스트

2편에서 값을 받는 두 통로, 경로와 쿼리를 익혔다. 둘 다 주소에 실리는 한두 개짜리 값이었다. 그런데 회원가입을 떠올려 보자. 이름, 이메일, 비밀번호, 약관 동의 여부가 한꺼번에 넘어온다. 이걸 주소에 다 붙이면 /signup?name=...&email=...&password=... 처럼 끔찍해지고, 비밀번호가 URL 에 그대로 박혀 서버 로그에 남는다.

그래서 묶인 데이터는 주소가 아니라 요청 본문(request body) 에 JSON 으로 담아 보낸다. 3편은 그 본문을 받는 정석, Pydantic 모델을 다룬다. 모델로 받으면 들어온 JSON 을 검증하는 일도, 응답에서 비밀번호를 빼는 일도 거의 공짜로 따라온다.

1. 주소로 못 받는 것 — 묶인 데이터는 본문에

경로와 쿼리는 값 하나하나가 주소 문자열에 노출된다. 짧고 단순한 식별자나 필터에는 더없이 좋지만, 필드가 대여섯 개로 늘고 그중에 민감한 값이 섞이면 한계가 분명하다. URL 길이 제한에 걸리고, 중첩된 구조(주소 안에 도시·우편번호)는 아예 표현이 안 되며, 로그·브라우저 기록에 값이 그대로 남는다.

HTTP 는 이런 경우를 위해 본문이라는 자리를 따로 둔다. 보통 POST·PUT 요청과 함께 보내고, 형식은 대개 JSON 이다. 정리하면 기준은 단순하다 — "가리키는 값"은 주소로, "보내는 덩어리"는 본문으로.

구분경로·쿼리 매개변수요청 본문(Body)
담는 곳URL 문자열요청 몸체 JSON
적합한 데이터식별자·필터 한두 개필드 여러 개 묶음
주 메서드GETPOST · PUT · PATCH
중첩 구조표현 어려움객체·배열 자유
민감 정보로그에 노출상대적으로 안전

2. Pydantic BaseModel 로 요청 받기

본문을 받는 방법은 의외로 깔끔하다. 받고 싶은 데이터의 모양을 클래스로 한 번 선언하고, 그 클래스를 함수 인자의 타입힌트로 적기만 하면 된다. 이 클래스를 만드는 도구가 1편에서 이름만 나왔던 PydanticBaseModel 이다.

# main.py from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class Item(BaseModel): name: str price: float is_offer: bool = False @app.post("/items") def create_item(item: Item): total = item.price * 1.1 # 부가세 포함 return {"name": item.name, "price_with_tax": total}

여기서 일어나는 일을 풀어 보자. Item 은 "이 본문에는 문자열 name, 실수 price, 불리언 is_offer 가 들어온다" 는 명세다. is_offer= False 를 줬으니 그 필드는 선택이고, 없으면 False 가 채워진다. 함수 인자에 item: Item 이라고 적은 순간 FastAPI 는 인자 이름이 경로에도 쿼리에도 없는 모델 타입임을 보고 "아, 이건 본문에서 꺼내라는 뜻" 이라고 판단한다.

그래서 클라이언트가 이런 JSON 을 보내면,

POST /items { "name": "키보드", "price": 39000, "is_offer": true }

FastAPI 가 본문을 읽어 Item 객체로 파싱하고 검증한 뒤 함수에 넘긴다. 함수 안에서는 item.name, item.price 처럼 점으로 꺼내 쓴다. 더 이상 request.json() 을 직접 부르거나 키가 있는지 일일이 확인할 필요가 없다. 그리고 2편의 경로·쿼리처럼, 이 모델 구조 역시 /docs 문서에 입력 예시로 그대로 그려진다.

딕셔너리로 다시 바꾸려면 — 받은 모델을 그대로 다른 함수에 넘기거나 저장할 때 item.model_dump() 를 쓰면 평범한 딕셔너리가 된다. Pydantic v1 의 .dict() 가 v2 에서 .model_dump() 로 바뀌었으니, 옛 예제를 볼 때 헷갈리지 말 것. 직렬화된 JSON 문자열이 필요하면 item.model_dump_json() 이다.

3. Field 로 검증 강화 — 타입만으로 부족할 때

타입힌트는 "문자열이냐 숫자냐" 까지는 잡지만, "이름은 비면 안 되고 50자 이하", "나이는 0~120 사이" 같은 값의 범위는 모른다. 이런 세부 규칙은 Pydantic 의 Field 로 필드마다 한 줄씩 얹는다. 이메일 형식 검증처럼 자주 쓰는 패턴은 EmailStr 같은 전용 타입이 따로 준비돼 있다.

from pydantic import BaseModel, Field, EmailStr class UserCreate(BaseModel): name: str = Field(min_length=1, max_length=50) email: EmailStr age: int = Field(ge=0, le=120) bio: str = Field(default="", max_length=300) @app.post("/users") def create_user(user: UserCreate): return {"name": user.name, "email": user.email}

제약을 하나씩 보면 — min_length=1 은 빈 이름을 막고, max_length=50 은 너무 긴 입력을 자른다. ge=0 는 "0 이상", le=120 은 "120 이하" 라는 뜻이라 음수 나이나 비현실적인 값을 거른다. EmailStr@ 가 없거나 형식이 깨진 문자열을 알아서 퇴짜 놓는다. (참고로 EmailStr 을 쓰려면 pip install "pydantic[email]" 로 부가 패키지를 한 번 깔아 둬야 한다.)

이 규칙들을 어긴 요청이 오면, 함수 본문은 실행조차 되지 않고 FastAPI 가 곧장 422 Unprocessable Entity 응답을 돌려준다. 본문에 어느 필드가 왜 틀렸는지가 친절히 담긴다.

{ "detail": [{ "loc": ["body", "age"], "msg": "Input should be less than or equal to 120", "type": "less_than_equal" }] }

주목할 점은 "loc": ["body", "age"] 다. 2편에서 경로 검증 에러가 ["path", "user_id"] 였던 것과 같은 구조로, 이번엔 본문의 age 필드가 문제라고 정확히 짚어 준다. 우리는 검증 코드도, 에러 메시지도 한 줄 안 적었다.

4. response_model — 응답 모양을 고정하고 민감 필드를 거른다

지금까지는 들어오는 데이터를 다뤘다. 그런데 나가는 데이터도 똑같이 중요하다. 방금 만든 UserCreate 에는 비밀번호가 들어올 텐데, 회원가입 응답으로 그 비밀번호를 그대로 돌려주면 큰일이다. FastAPI 는 입력 모델과 출력 모델을 분리해서 이 문제를 깔끔히 푼다.

class UserCreate(BaseModel): name: str = Field(min_length=1, max_length=50) email: EmailStr password: str = Field(min_length=8) class UserPublic(BaseModel): name: str email: EmailStr # password 없음 — 응답에서 자동으로 빠진다 @app.post("/users", response_model=UserPublic) def create_user(user: UserCreate): save_to_db(user) # 저장은 password 포함해서 return user # 반환해도 password 는 응답에서 제거됨

핵심은 데코레이터의 response_model=UserPublic 이다. 함수가 비밀번호까지 든 user 를 통째로 return 해도, FastAPI 는 응답을 UserPublic 의 모양에 맞춰 다시 빚는다. UserPublicpassword 가 없으니 그 필드는 응답 JSON 에서 자동으로 사라진다. 개발자가 "이 필드는 빼야지" 하고 손으로 지울 필요가 없어, 실수로 민감 정보를 흘릴 위험이 구조적으로 줄어든다.

response_model 은 보안만 위한 게 아니다. 응답의 모양을 계약처럼 고정하는 역할도 한다. 함수가 내부 사정으로 필드를 더 붙여 반환하더라도, 출력 모델에 없는 필드는 잘려 나가므로 프론트엔드가 받는 JSON 형태가 항상 일정하다. 그리고 이 출력 스키마 또한 /docs 에 "이 API 는 이런 모양으로 응답한다" 고 그려진다.

모델은 평평하기만 한 게 아니다. 필드의 타입으로 다른 모델이나 모델의 리스트를 그대로 쓸 수 있어 중첩 구조도 자연스럽다. 예를 들어 사용자가 여러 주문을 가진다면, class Order(BaseModel) 를 따로 정의하고 orders: list[Order] = [] 처럼 한 줄로 끼워 넣으면 된다. FastAPI 는 이 중첩까지 검증하고 문서로 그려 준다.

흔한 함정 — 입력과 출력을 같은 모델 하나로 돌려쓰고 싶은 유혹이 크다. 하지만 그러면 비밀번호 같은 필드가 응답에 새거나, 반대로 응답에만 있어야 할 id·created_at 을 클라이언트가 굳이 보내야 하는 모순이 생긴다. 입력용(Create)과 출력용(Public)을 처음부터 나눠 정의하는 습관이 길게 보면 훨씬 편하다.

요약

3편을 한 줄로 — 묶인 데이터는 요청 본문에 담고, 그 모양을 Pydantic BaseModel 로 선언하면 파싱·검증이 공짜로 따라온다. Field 로 길이·범위 같은 세부 규칙을 얹으면 어긋난 요청은 함수 앞에서 422 로 걸리고, response_model 로 출력 모델을 따로 두면 비밀번호 같은 민감 필드를 응답에서 자동으로 걸러 내고 응답 모양을 계약처럼 고정한다. 입력 UserCreate 와 출력 UserPublic 을 나누는 것이 핵심 습관이다. 그런데 검증이 실패하면 정확히 어떤 에러가 나가고, 성공하면 어떤 상태코드가 붙는 걸까? 4편에서 그 에러와 상태코드를 손에 쥐고 다룬다.

다음 편 예고 — 에러 처리와 상태코드

검증이 실패하면 어떤 에러가, 성공하면 어떤 상태코드가 나가는지. HTTPException 으로 직접 에러를 던지는 법까지.

© 2026 주나이테크(주) @JUNAITECH