요청 본문 처리
1. 라우팅 및 세팅
1.1 URL 정보
POST 요청은 서버에 새로운 데이터를 생성하거나 기존 데이터를 수정할 때 사용됩니다. 이번 챕터의 URL 구성은 아래와 같습니다. 이번에는 앞서 작성했던 URL 정보에 메서드를 추가하였습니다.
| 경로 | 함수명 | 메서드 | 설명 |
|---|---|---|---|
| / | index | GET | 들어온 값을 그대로 출력합니다. |
| /item | item_list | GET | 물품 목록을 반환합니다. |
| /item | item_create | POST | 물품을 등록합니다. |
| /item/{item_id} | item_detail | GET | 물품 상세 정보를 반환합니다. |
| /item/{item_id} | item_update | PUT | 물품 상세 정보를 업데이트합니다. |
1.2 기본 세팅
이번 실습 폴더는 02_3_request입니다. VSC 터미널에서 사용할 명령어 입니다. 가상환경은 벗어난 상태에서 실행해야 합니다. 앞 실습의 서버가 실행 중이면 Ctrl + C로 멈추고, 터미널 입력창 앞에 (venv)라고 되어 있다면 deactivate 명령어로 가상환경을 나간 상태에서 cd ..으로 상위 폴더로 나와 아래 명령어를 실행해주세요.
mkdir 02_3_request
cd 02_3_request
python -m venv venv
.\venv\Scripts\Activate.ps1
pip install "fastapi[standard]"
macOS/Linux에서는 python -m venv venv 대신 python3 -m venv venv를, 활성화 명령 대신 source ./venv/bin/activate를 사용합니다. 이후 명령은 가상환경이 활성화된 상태에서 실행합니다.
1.3 GET과 POST 요청
GET 요청은 URL로 데이터를 전달하고, POST 요청은 요청 본문(body)에 데이터를 담아 서버로 전송합니다. 아래와 같이 index.html 파일을 만들어주세요. 파일을 만들고 더블 클릭하여 실행해도 되고, VSCode에서 Extension인 Live Server를 설치하여 실행하여도 됩니다.
<form action="" method="GET">
<input type="text" name="name">
<input type="number" name="price">
<button type="submit">GET으로 제출</button>
</form>
<form action="" method="POST">
<input type="text" name="name">
<input type="number" name="price">
<button type="submit">POST로 제출</button>
</form>
본문에 값을 담아 보낸다는 말이 무엇인가요
데이터는 서버로 전송할 때 URL에 담아 보낼 수도 있고, 요청 본문에 담아 보낼 수도 있습니다. 실제로 여러분 컴퓨터에서 서버로 보내지는 패킷의 구조, 메서드 등은 아래 네트워크 베이스캠프 자료를 참고해주세요.
POST /api/users HTTP/1.1 # 요청라인(메서드와 경로)
Host: www.example.com # 헤더
Content-Type: application/json
Content-Length: 27
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
Accept: application/json
# 빈 줄
{"name": "John", "age": 30} # JSON 형식의 본문
헤더와 본문 사이에 빈 줄이 하나 들어간다는 점을 기억해두세요. 잠시 뒤에 배울 .http 파일도 정확히 같은 구조로 작성합니다.
첫번째 폼에 값을 넣고 제출 버튼을 누르게 되면 아래와 같은 형식으로 URL이 변경됩니다.
http://127.0.0.1:5500/?name=hello&price=100
이렇게 ? 뒤에 붙은 값들은 GET 요청으로 전달된 값입니다. 두 번째 폼으로 제출하면 주소창에 아무것도 붙지 않습니다. 값이 URL이 아니라 본문에 실려 갔기 때문입니다. 현재 form에 action이 비어있기 때문에 현재 페이지로 요청을 보내게 됩니다. 여기 주소를 FastAPI가 서비스 되고 있는 http://localhost:8000/로 변경하면 FastAPI에서 GET 요청을 받을 수 있습니다. GET만 한 번 실습해보도록 하겠습니다.
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def index(name: str, price: int):
print(name, price)
return {"name": name, "price": price}
위 코드를 main.py로 생성한 후 아래 명령어로 실행해주세요.
fastapi dev
.html코드는 아래와 같이 수정해주세요.
<form action="http://localhost:8000/" method="GET">
<input type="text" name="name">
<input type="number" name="price">
<button type="submit">제출</button>
</form>
이제 index.html을 실행하고 값을 입력하고 제출 버튼을 누르면 URL은 http://localhost:8000/?name=hello&price=100와 같이 변하고, 터미널에는 name과 price가 찍히며, 브라우저에는 {"name":"hello","price":100}이 표시됩니다.
이 차이가 중요한 이유는, 두 가지를 FastAPI에서 받는 방법이 서로 다르기 때문입니다. URL에 담긴 값은 함수의 매개변수로 바로 받고, 본문에 담긴 값은 별도의 모델을 만들어서 받습니다.
다만 이렇게 실습을 하면 .html파일을 매번 작성해야 하는 번거로움이 있습니다. 이럴 때 API 테스트 도구를 사용하면 편리합니다. 우리 수업에서는 FastAPI 공부에 좀 더 초점을 맞추기 위해 테스트 도구를 사용하도록 하겠습니다.
이렇게 .html 파일을 만들어도 제대로된 테스트를 할 수 없습니다. CORS 때문인데요. CORS는 미들웨어에서 다룹니다. 따라서 테스트 도구를 사용하는 것은 현재 챕터에서 선택이 아니라 필수 입니다.
2. API 테스트 도구 준비하기
브라우저 주소창으로는 GET 요청밖에 보낼 수 없습니다. POST, PUT, DELETE를 테스트하려면 도구가 필요합니다. 이 책에서는 두 가지를 함께 사용합니다.
| 도구 | 언제 쓰나 | 장점 |
|---|---|---|
Swagger UI (/docs) | 한 번씩 눌러보며 확인할 때 | 설치가 필요 없고 요청 형식을 알려줍니다 |
REST Client (.http 파일) | 같은 요청을 반복할 때 | 요청이 파일로 남아 다시 쓰고 공유할 수 있습니다 |
2.1 Swagger UI로 테스트하기
가장 먼저 알아둘 것은, FastAPI가 이미 테스트 도구를 하나 갖고 있다는 사실입니다. 서버를 실행하고 http://127.0.0.1:8000/docs에 접속한 다음,
- 테스트할 엔드포인트를 클릭해 펼칩니다.
- 오른쪽의
Try it out버튼을 누릅니다. - 요청 본문 입력칸이 활성화됩니다. 예시 값이 미리 채워져 있습니다.
Execute버튼을 누르면 응답과 상태 코드가 아래에 표시됩니다.
별도 설치가 필요 없고, 어떤 값을 넣어야 하는지 화면이 알려준다는 것이 가장 큰 장점입니다.
2.2 REST Client로 테스트하기
같은 요청을 열 번 스무 번 반복하게 되면 Swagger UI는 번거롭습니다. 이럴 때 .http 파일을 사용합니다.
1장에서 설치한 REST Client 익스텐션이 있으면, 프로젝트 폴더에 api.http 파일을 만들고 아래 내용을 적습니다.
@baseUrl = http://127.0.0.1:8000
### 물품 목록 조회
GET {{baseUrl}}/item
### 물품 등록
POST {{baseUrl}}/item
Content-Type: application/json
{
"name": "item1",
"price": 100
}
작성 규칙은 아래와 같습니다.
| 문법 | 의미 |
|---|---|
@baseUrl = ... | 변수 선언, 본문에서 {{baseUrl}}로 사용 |
### | 요청과 요청을 구분하는 줄, 뒤에 요청 이름을 적을 수 있습니다 |
| 첫 줄 | 메서드 URL 형식 |
| 그 다음 줄들 | 헤더 |
| 빈 줄 | 헤더와 본문의 경계, 반드시 필요합니다 |
| 빈 줄 다음 | 요청 본문 |
각 요청 위에 Send Request라는 작은 글씨가 나타납니다. 이것을 클릭하면 옆에 응답창이 열립니다.
다른 도구를 써도 되나요
Thunder Client나 Postman, Bruno 같은 GUI 도구를 써도 됩니다. 요청을 보내고 응답을 확인한다는 개념은 모두 같습니다. 다만 Thunder Client는 무료 사용에 제한(컬렉션 3개, 컬렉션당 요청 15개)이 있어 실습 도중에 막힐 수 있으므로, 이 책은 REST Client를 사용합니다.
.http 파일 방식에는 부수적인 장점도 있습니다. 요청이 그냥 텍스트 파일이라 Git에 함께 커밋할 수 있고, 팀원에게 "이 파일 열어서 Send 눌러보세요"라고 전달할 수 있습니다. 도구의 계정이나 구독과 무관하게 프로젝트에 남습니다.
3. POST 요청 처리하기
POST 요청은 서버에 새로운 데이터를 생성하거나 기존 데이터를 수정할 때 주로 사용됩니다. FastAPI에서 POST 요청을 처리하는 방법을 살펴보겠습니다.
from fastapi import FastAPI
app = FastAPI()
items = []
@app.get("/item")
async def item_list():
return {"items": items}
@app.post("/item")
async def item_create(item: dict):
items.append(item)
return {"item": item}
이 코드는 /item 경로로 들어오는 POST 요청을 처리합니다. item: dict는 "요청 본문의 JSON을 파이썬 딕셔너리로 바꿔서 item에 넣어달라"는 뜻입니다.
api.http 파일에서 물품 등록 요청을 두 번 보내보겠습니다. 두 번째는 값을 바꿔서 보내주세요.
@baseUrl = http://127.0.0.1:8000
### 물품 등록 1
POST {{baseUrl}}/item
Content-Type: application/json
{
"name": "item1",
"price": 100
}
### 물품 등록 2
POST {{baseUrl}}/item
Content-Type: application/json
{
"name": "item2",
"price": 200
}
### 물품 목록 조회
GET {{baseUrl}}/item
마지막 목록 조회 요청을 보내면 아래와 같은 응답이 옵니다.
{
"items": [
{ "name": "item1", "price": 100 },
{ "name": "item2", "price": 200 }
]
}
여기서 잠깐 실험을 하나 해보겠습니다. 아래처럼 완전히 엉뚱한 값을 보내보세요.
### 이상한 값 보내기
POST {{baseUrl}}/item
Content-Type: application/json
{
"이름": "item3",
"값": "비쌈"
}
그대로 저장되고 목록에도 나옵니다. dict로 받으면 어떤 모양의 JSON이든 통과합니다. 이 문제를 해결하는 것이 다음 단계입니다.
서버를 껐다 켜면 데이터가 사라집니다
items = []는 파이썬 프로그램의 메모리에 있는 리스트입니다. 서버가 재시작되면 초기화됩니다. 코드를 수정해서 저장할 때마다 자동으로 재시작되므로, 실습 중에 데이터가 갑자기 비어 있다면 이 때문입니다. 4장에서 데이터베이스로 옮기면 해결됩니다.
4. Pydantic 모델을 사용한 데이터 검증
Pydantic은 FastAPI와 함께 사용되는 데이터 검증 라이브러리입니다. Pydantic 모델을 사용하면 입력 데이터의 구조와 타입을 명확히 정의하고 자동으로 검증할 수 있습니다.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
items = []
class Item(BaseModel):
name: str
price: float = 0
@app.get("/item")
async def item_list():
return {"items": items}
@app.post("/item")
async def item_create(item: Item):
items.append(item)
return {"item": item}
이 예제에서 Item 클래스는 Pydantic 모델입니다. BaseModel을 상속받아 만들고, 클래스 안에 필드를 타입 힌트로 적습니다.
| 작성 | 의미 |
|---|---|
name: str | 필수 필드, 문자열이어야 합니다 |
price: float = 0 | 선택 필드, 없으면 0이 들어갑니다 |
item: dict를 item: Item으로 바꾼 것만으로 아래 세 가지가 한 번에 생깁니다.
- 잘못된 요청을 걸러내는 검증
/docs문서에 표시되는 요청 형식 설명- 에디터에서
item.name을 칠 때 나오는 자동 완성
4.1 검증 동작 확인하기
세 가지 경우를 직접 보내보겠습니다.
숫자로 바꿀 수 있는 문자열을 보내면 통과하되 자동으로 변환됩니다.
### 문자열로 된 숫자
POST {{baseUrl}}/item
Content-Type: application/json
{
"name": "item1",
"price": "100"
}
이 경우 FastAPI는 요청을 처리하지만, 응답에서 price가 100.0으로 바뀌어 있습니다.
{
"item": { "name": "item1", "price": 100.0 }
}
만약 변환할 수 없는 문자열을 넘긴다면 아래와 같은 경고문구가 뜨고, 처리되지 않는 것을 확인할 수 있습니다.
### 숫자가 아닌 값
POST {{baseUrl}}/item
Content-Type: application/json
{
"name": "item1",
"price": "hello"
}
{
"detail": [
{
"type": "float_parsing",
"loc": ["body", "price"],
"msg": "Input should be a valid number, unable to parse string as a number",
"input": "hello"
}
]
}
필수인 항목을 입력하지 않아도 아래와 같은 경고문구가 뜨고, 처리되지 않는 것을 확인할 수 있습니다.
### 필수 항목 누락
POST {{baseUrl}}/item
Content-Type: application/json
{
"price": 100
}
{
"detail": [
{
"type": "missing",
"loc": ["body", "name"],
"msg": "Field required",
"input": { "price": 100 }
}
]
}
loc의 첫 번째 값이 body인 것을 확인해보세요. 앞 절에서는 path였습니다. 어디에서 온 값이 문제인지 알려줍니다.
4.2 필드에 조건 걸기
경로 매개변수에 Path를 썼던 것처럼, 모델의 필드에도 조건을 걸 수 있습니다. Pydantic에서는 Field를 사용합니다.
from pydantic import BaseModel, Field
class Item(BaseModel):
name: str = Field(min_length=1, max_length=50, description="물품 이름")
price: float = Field(ge=0, description="0원 이상이어야 합니다")
description: str | None = Field(default=None, max_length=300)
description에 적은 설명은 /docs 문서에 그대로 나타납니다. 이제 빈 문자열이나 음수 가격을 보내면 거부됩니다.
Pydantic v1 코드를 알아보는 법
Pydantic은 2023년에 v2로 크게 바뀌었습니다. 인터넷에는 아직 v1 코드가 많고, AI도 종종 v1 문법을 섞어 만들어냅니다. 아래 표의 왼쪽이 보이면 오래된 코드입니다.
| v1 (오래된 방식) | v2 (지금 방식) |
|---|---|
item.dict() | item.model_dump() |
item.json() | item.model_dump_json() |
item.copy(update=...) | item.model_copy(update=...) |
class Config: | model_config = ConfigDict(...) |
@validator | @field_validator |
Optional[str] = None | str | None = None |
v1 코드를 v2 환경에서 실행하면 대부분 경고만 뜨고 동작은 합니다. 그래서 모르고 지나치기 쉽습니다. 다음 메이저 버전에서는 사라질 예정이니 새로 쓰는 코드는 오른쪽으로 작성하세요.
5. 요청 본문과 경로 매개변수
요청 본문과 경로 매개변수를 함께 사용할 수 있습니다. 이는 특정 리소스에 대한 정보를 업데이트할 때 유용합니다. 업데이트가 실제로 되었는지를 확인하기 위해 수정하는 코드와 함께 확인하는 코드도 추가하도록 하겠습니다.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
items = []
class Item(BaseModel):
name: str
price: float = 0
@app.get("/item")
async def item_list():
return {"items": items}
@app.post("/item")
async def item_create(item: Item):
items.append(item)
return {"item": item}
@app.get("/item/{item_id}")
async def item_detail(item_id: int):
if item_id < 1 or item_id > len(items):
raise HTTPException(status_code=404, detail="Item not found")
return {"item": items[item_id - 1]}
@app.put("/item/{item_id}")
async def item_update(item_id: int, item: Item):
if item_id < 1 or item_id > len(items):
raise HTTPException(status_code=404, detail="Item not found")
items[item_id - 1] = item
return {"item": item}
이 코드에서 item_detail 엔드포인트는 URL에서 item_id를 받고, 해당 item_id에 해당하는 아이템을 반환합니다. 여기서 해당 아이템이 없을 경우를 대비하여 예외처리를 해주었습니다. item_update 엔드포인트는 URL에서 item_id를 받고, 요청 본문에서 Item 객체를 받습니다.
FastAPI는 함수의 매개변수를 보고 어디에서 값을 가져올지 스스로 판단합니다.
| 매개변수 | 어디서 가져오나 | 판단 근거 |
|---|---|---|
item_id: int | URL 경로 | 데코레이터의 경로에 {item_id}가 있음 |
item: Item | 요청 본문 | 타입이 Pydantic 모델임 |
없는 아이템을 조회했을 때 {"error": "Item not found"}처럼 딕셔너리를 반환하지 않고 HTTPException을 발생시킨 점에 주목하세요. 딕셔너리를 반환하면 상태 코드가 200으로 나가서, 클라이언트 입장에서는 "성공했는데 이상한 값이 왔다"가 됩니다. HTTPException을 쓰면 404가 정확하게 전달됩니다. 이 부분은 6장에서 더 자세히 다룹니다.
api.http로 순서대로 테스트해보세요. 테스트를 위해 2개 이상의 데이터를 입력해주시는 것을 권합니다.
@baseUrl = http://127.0.0.1:8000
### 1. 물품 등록
POST {{baseUrl}}/item
Content-Type: application/json
{
"name": "item1",
"price": 100
}
### 2. 등록 확인
GET {{baseUrl}}/item/1
### 3. 수정
PUT {{baseUrl}}/item/1
Content-Type: application/json
{
"name": "hello",
"price": 1000
}
### 4. 수정 확인
GET {{baseUrl}}/item/1
### 5. 없는 물품 조회
GET {{baseUrl}}/item/999
4번 요청에서 아래와 같은 응답이 왔다면 정상입니다.
{
"item": {
"name": "hello",
"price": 1000.0
}
}
5번 요청에서 응답 상태 코드가 404 Not Found로 나오는지 확인해보세요.
6. 요청 본문의 중첩된 모델
Pydantic 모델은 중첩될 수 있어, 복잡한 데이터 구조도 쉽게 처리할 수 있습니다.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
items = []
class ItemDetail(BaseModel):
description: str
weight: float
class Item(BaseModel):
name: str
price: float
details: ItemDetail
@app.get("/item")
async def item_list():
return {"items": items}
@app.post("/item")
async def item_create(item: Item):
items.append(item)
return {"item": item}
이 예제에서 Item 모델은 ItemDetail 모델을 포함하고 있습니다. 요청은 아래와 같이 보냅니다.
### 중첩 모델로 등록
POST {{baseUrl}}/item
Content-Type: application/json
{
"name": "item1",
"price": 100,
"details": {
"description": "This is an item",
"weight": 10
}
}
아이템이 제대로 생성되었는지 확인하기 위해 목록 조회 요청을 보내보세요. 아래와 같은 응답이 왔다면 정상입니다.
{
"items": [
{
"name": "item1",
"price": 100.0,
"details": {
"description": "This is an item",
"weight": 10.0
}
}
]
}
중첩된 안쪽까지 검증됩니다. weight에 문자열을 넣어보면 loc이 ["body", "details", "weight"]로 나오는 것을 확인할 수 있습니다. 어느 깊이에서 문제가 생겼는지 정확히 알려줍니다.
연습문제
-
사용자 정보를 저장하는 POST 엔드포인트를 만들어보세요. 사용자 정보는 이름, 이메일, 나이를 포함해야 합니다. Pydantic 모델을 사용하여 데이터를 검증하고, 나이는 0 이상 150 이하만 허용하도록
Field를 사용하세요. -
블로그 포스트를 생성하는 엔드포인트를 만들어보세요. 블로그 포스트는 제목, 내용, 작성자 정보(이름, 이메일)를 포함해야 합니다. 중첩된 Pydantic 모델을 사용하세요.
-
1번에서 만든 모델의 이메일 필드 타입을
str대신EmailStr로 바꿔보세요.from pydantic import EmailStr로 가져올 수 있으며,fastapi[standard]에 포함된email-validator덕분에 추가 설치 없이 동작합니다. 잘못된 이메일을 보냈을 때 어떤 에러가 나오는지 확인해보세요. -
지금까지 만든 요청을 모두
api.http파일 하나에 정리해보세요. 이 파일은 다음 절에서도 재활용합니다.