CRUD ① GET과 POST — Pydantic 모델로 요청 받고 응답하기 (FastAPI 입문 5편)
CRUD와 HTTP 메서드의 대응, REST식 주소 짓는 법을 정리한 뒤 할 일 API의 만들기(POST)와 읽기(GET)를 구현한다. 4편에서 익힌 Pydantic 모델을 FastAPI에 연결해 요청 본문을 검증하고, 입력 모델과 출력 모델을 나누는 이유, response_model로 민감한 필드를 숨기는 법, 201·404 상태 코드와 HTTPException 사용법을 실제 실행 결과와 함께 다룬다. JSON을 고치면 FastAPI의 응답이 바로 바뀌는 본문 검증기 위젯을 포함한다.
어떤 서비스든 데이터를 다루는 동작은 네 가지로 정리된다. 만들고(Create), 읽고(Read), 고치고(Update), 지운다(Delete). 머리글자를 따서 CRUD라고 부른다. 할 일 앱, 쇼핑몰, 게시판, 사내 결재 시스템 — 겉모습은 달라도 API의 대부분은 CRUD다.
이번 편과 다음 편에서 할 일(Task) API 하나를 처음부터 끝까지 만든다. 이번 편은 만들기(POST) 와 읽기(GET) 를 다룬다. 4편에서 FastAPI 없이 따로 익힌 Pydantic 모델을 이제 FastAPI에 꽂아 쓰는 편이기도 하다.
Pydantic 모델은 검문소다. 요청이 함수에 닿기 전에 모델이 먼저 본문을 검사한다. 통과하면 함수는 이미 검증된payload 객체를 받고, 통과하지 못하면 함수는 실행되지도 않은 채 422가 나간다. 그래서 함수 안에는 if not title: ... 같은 검사 코드가 한 줄도 필요 없다.
직접 확인해 보자. 아래 위젯의 JSON을 고치면 FastAPI가 돌려줄 응답이 바로 바뀐다. 에러 문구는 실제 FastAPI의 출력과 같다.
4편에서 파이썬 코드로 확인한 Pydantic의 성격이 FastAPI에서는 이렇게 드러난다.
🧾
① 틀린 곳을 한꺼번에 알려 준다
“빈 제목 + 우선순위 9”는 에러 두 개가 동시에 온다. 첫 번째에서 멈추지 않으니 프론트엔드는 폼의 모든 칸 아래에 메시지를 한 번에 달 수 있다.
🔁
② 숫자는 너그럽고, 문자열은 엄격하다
"priority": "3"(문자열)은 정수 3으로 바뀌어 통과한다. 반대로 "title": 123(숫자)은 문자열로 바꿔 주지 않고 string_type 에러가 난다. 소수 2.5도 정수로 깎지 않고 막는다.
🫥
③ 모르는 필드는 조용히 버린다 ④ JSON 문법이 틀리면 모델까지 가지도 않는다
"owner": "김철수"처럼 모델에 없는 필드는 에러 없이 사라진다. 클라이언트가 "titel"이라고 오타를 내면 “제목 누락” 에러가 나서 그나마 알 수 있지만, 선택 필드의 오타는 아무도 모르게 무시된다. 엄격하게 막고 싶으면 아래처럼 설정한다.
python
from pydantic import BaseModel, ConfigDict
classTaskCreate(BaseModel):
model_config = ConfigDict(extra="forbid") # 모르는 필드가 오면 422
title: str
이렇게 하면 {"title": "a", "owner": "b"}에 extra_forbidden 에러("Extra inputs are not permitted")가 난다. 팀 정책으로 정할 문제다 — 내부 API는 forbid로 오타를 빨리 잡는 편이, 외부에 공개하는 API는 기본값(무시)으로 클라이언트의 구버전 필드를 너그럽게 받는 편이 흔하다.
3. 입력 모델과 출력 모델은 나눈다
할 일을 만들면 서버는 id, 완료 여부, 만든 시각을 붙여서 돌려준다. 이것은 클라이언트가 정하는 값이 아니다. 그래서 모델을 둘로 나눈다.
python
from datetime import datetime
classTaskCreate(BaseModel): # 클라이언트가 보내는 것
title: str = Field(min_length=1, max_length=100, examples=["보고서 초안 쓰기"])
description: str | None = Field(default=None, max_length=1000)
priority: int = Field(default=3, ge=1, le=5, description="1(낮음) ~ 5(높음)")
classTask(BaseModel): # 서버가 돌려주는 것id: int
title: str
description: str | None
priority: int
done: bool
created_at: datetime
하나로 합치면 무슨 일이 생길까? Task 하나로 본문을 받으면 클라이언트가 {"id": 999, "done": true, ...}를 보내 id를 마음대로 정하거나, 만들자마자 완료 상태로 만들 수 있다. 입력 모델에는 클라이언트가 정해도 되는 필드만 둔다.
3-1. response_model — 나가는 데이터의 검문소
출력 쪽에도 검문소가 있다. 라우트에 response_model=Task를 달면 FastAPI는 함수가 무엇을 돌려주든 Task 모양에 맞춰 걸러서 내보낸다. 이게 특히 빛나는 곳은 민감한 필드다.
python
classUserOut(BaseModel):
id: int
email: str@app.get("/users/{user_id}", response_model=UserOut)defread_user(user_id: int):
user = {"id": user_id, "email": "a@b.com", "password_hash": "xxx"} # DB에서 꺼낸 값이라 치자return user
실제 응답은 {"id": 1, "email": "a@b.com"}이다. password_hash는 모델에 없으니 나가지 않는다. 함수를 고친 누군가가 실수로 DB 행 전체를 돌려줘도 비밀번호는 새지 않는다. 그리고 /docs의 Responses에도 이 모양이 그대로 적혀 프론트엔드가 받을 데이터를 정확히 안다.
요청 JSON
→
입력 모델 TaskCreate: 검증·변환
→
내 함수 비즈니스 로직
→
출력 모델 response_model=Task: 거르기
→
응답 JSON
4. POST — 할 일 만들기
이제 조립한다. 이번 편에서는 DB 대신 파이썬 딕셔너리를 저장소로 쓴다. 개념을 익히는 데는 이걸로 충분하고, 7편에서 이 저장소를 갈아 끼우기 쉬운 구조로 바꾼다.
python
from datetime import datetime
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
app = FastAPI(title="할 일 API")
classTaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=100, examples=["보고서 초안 쓰기"])
description: str | None = Field(default=None, max_length=1000)
priority: int = Field(default=3, ge=1, le=5, description="1(낮음) ~ 5(높음)")
classTask(BaseModel):
id: int
title: str
description: str | None
priority: int
done: bool
created_at: datetime
tasks: dict[int, Task] = {}
next_id = 1@app.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)defcreate_task(payload: TaskCreate):
global next_id
task = Task(id=next_id, done=False, created_at=datetime.now(), **payload.model_dump())
tasks[task.id] = task
next_id += 1return task
새로 나온 것 세 가지만 짚자.
status_code=status.HTTP_201_CREATED: 만들기 성공은 200이 아니라 201로 답하는 것이 관례다. 숫자 201을 직접 써도 되지만 status.HTTP_201_CREATED처럼 이름으로 쓰면 읽는 사람이 뜻을 바로 안다.
payload.model_dump(): 모델을 딕셔너리로 바꾼다. {"title": ..., "description": ..., "priority": ...}.
created_at의 datetime 객체가 "2026-09-27T11:42:50.320449" 같은 ISO 8601 문자열로 바뀌어 나간 것도 FastAPI가 해 준 일이다.
⚠️
메모리 저장소의 한계. 딕셔너리 저장소는 ① 서버를 재시작하면(fastapi dev는 파일을 저장할 때마다 재시작한다!) 데이터가 사라지고 ② 워커를 여러 개 띄우면 워커마다 따로 가진다. 연습용으로만 쓰고, 실제 서비스는 DB를 쓴다. global next_id도 연습용 단순화다.
5. GET — 목록과 하나
5-1. 목록: 필터와 페이지
python
@app.get("/tasks", response_model=list[Task])deflist_tasks(done: bool | None = None, skip: int = 0, limit: int = 20):
result = list(tasks.values())
if done isnotNone:
result = [t for t in result if t.done == done]
return result[skip : skip + limit]
2편에서 배운 쿼리 매개변수 그대로다. response_model=list[Task]는 “Task의 목록을 돌려준다”는 뜻이다.
GET /tasks → 전부(최대 20개)
GET /tasks?done=false → 안 끝난 것만
GET /tasks?skip=20&limit=20 → 21~40번째 (두 번째 페이지)
done: bool | None = None으로 쓴 이유를 보자. done: bool = False로 쓰면 “안 보냄”과 “false를 보냄”을 구별할 수 없어서 완료·미완료 전체를 가져올 방법이 없어진다. None을 “필터 안 함”의 뜻으로 쓰는 것이 흔한 패턴이다.
💡
목록 API에는 항상 limit을 둔다. 할 일이 10만 개가 되는 날 GET /tasks 한 번에 서버와 브라우저가 같이 멈추지 않게. 2편의 Query(ge=1, le=100)으로 상한도 걸어 두면 더 안전하다.
5-2. 하나: 없으면 404
python
defget_task_or_404(task_id: int) -> Task:
task = tasks.get(task_id)
if task isNone:
raise HTTPException(status_code=404, detail=f"{task_id}번 할 일이 없습니다")
return task
@app.get("/tasks/{task_id}", response_model=Task)defread_task(task_id: int):
return get_task_or_404(task_id)
raise HTTPException(...) 은 “여기서 멈추고 이 상태 코드로 답하라”는 뜻이다. return이 아니라 raise다. 함수 깊숙한 곳에서 raise해도 FastAPI가 잡아서 응답으로 바꿔 준다. GET /tasks/99의 실제 응답은 404와 {"detail": "99번 할 일이 없습니다"}다.
찾고 없으면 404를 내는 일은 GET·PUT·PATCH·DELETE 네 곳 모두에서 반복되니 get_task_or_404처럼 함수로 빼 둔다. 다음 편에서 바로 재사용하고, 7편에서는 이걸 FastAPI의 의존성(Depends)으로 바꾼다.
⚠️
“없음”을 200으로 답하지 말자.return None이나 return {"error": "없음"}은 상태 코드가 200이라, 프론트엔드의 에러 처리(if (!res.ok))를 조용히 통과한다. 실패는 실패 코드로. 그리고 3편에서 봤듯 이 404는 responses={404: ...}로 문서에도 적어 주자.
6. /docs로 확인하기
서버를 띄우고 /docs를 열면 세 라우트가 보인다. 3편에서 연습한 대로 해 보자.
1
POST /tasks → Try it out. 예시 값 "보고서 초안 쓰기"가 이미 채워져 있다(examples 덕분). Execute → 201.
2
본문을 바꿔 두세 개 더 만든다. 하나는 일부러 "priority": 0으로 보내 422를 확인한다.
3
GET /tasks → limit에 1을 넣고 Execute. 하나만 온다.
4
GET /tasks/{task_id} → 없는 번호 99로 Execute. 404와 한국어 메시지.
화면 맨 아래 Schemas를 펼치면 TaskCreate와 Task가 따로 나온다. 프론트엔드 개발자에게 “보낼 때는 TaskCreate, 받을 때는 Task”라고 한마디만 하면 된다.
7. 연습 문제
GET /tasks에 q: str | None = None을 추가해 제목에 q가 들어간 할 일만 돌려주자. (q in t.title)
GET /tasks에 sort: Literal["priority", "created_at"] = "created_at"을 추가해 정렬해 보자. sort=abc를 보내면 어떤 422가 나오는가?
TaskCreate에 due_date: date | None = None(마감일)을 추가하고, /docs의 Schemas와 Try it out 예시가 어떻게 바뀌는지 보자. "due_date": "2026-13-01"을 보내면?
1·2번 예시 답안
python
from typing importLiteral@app.get("/tasks", response_model=list[Task])deflist_tasks(
done: bool | None = None,
q: str | None = None,
sort: Literal["priority", "created_at"] = "created_at",
skip: int = 0,
limit: int = 20,
):
result = list(tasks.values())
if done isnotNone:
result = [t for t in result if t.done == done]
if q:
result = [t for t in result if q in t.title]
result.sort(key=lambda t: getattr(t, sort), reverse=(sort == "priority"))
return result[skip : skip + limit]
Literal은 2편의 Enum처럼 허용 값을 정한다. sort=abc를 보내면 literal_error 타입의 422가 온다.
정리
🧭
주소는 명사, 메서드는 동사
모음 /tasks와 하나 /tasks/{id} 두 종류의 주소에 POST·GET·PUT·PATCH·DELETE를 조합한다. 만들기는 201, 없으면 404.
🛂
Pydantic 모델 = 들어오고 나가는 검문소
입력 모델(TaskCreate)은 클라이언트가 정해도 되는 필드만 검증해 받고, 출력 모델(response_model=Task)은 나가는 데이터를 걸러 민감한 필드를 막는다.
🔁
반복은 함수로
get_task_or_404처럼 “찾고 없으면 404”를 한곳에 둔다. 다음 편의 수정·삭제에서 그대로 쓴다.
다음 편에서는 나머지 절반 — PUT·PATCH·DELETE — 를 만든다. 가장 헷갈리는 PUT과 PATCH의 차이, 그리고 PATCH에서 초보자가 거의 반드시 밟는 null 함정을 다룬다.
실행 환경. 이 글의 코드와 응답(201·404·422 본문, 문자열 “3”의 정수 변환, 모르는 필드 무시, extra="forbid"의 extra_forbidden, response_model의 필드 거르기, Literal의 literal_error)은 FastAPI 0.141.1 · Pydantic 2.13.5 · Python 3.11에서 직접 실행해 확인했다. 위젯의 에러 문구도 같은 환경의 실제 출력에서 옮겼다. 삽화는 코어닷투데이가 생성했다.