coredot.today
CRUD ① GET과 POST — Pydantic 모델로 요청 받고 응답하기 (FastAPI 입문 5편)
블로그로 돌아가기
FastAPICRUDGETPOSTPydanticBaseModelresponse_modelHTTPException201404422REST입문튜토리얼

CRUD ① GET과 POST — Pydantic 모델로 요청 받고 응답하기 (FastAPI 입문 5편)

CRUD와 HTTP 메서드의 대응, REST식 주소 짓는 법을 정리한 뒤 할 일 API의 만들기(POST)와 읽기(GET)를 구현한다. 4편에서 익힌 Pydantic 모델을 FastAPI에 연결해 요청 본문을 검증하고, 입력 모델과 출력 모델을 나누는 이유, response_model로 민감한 필드를 숨기는 법, 201·404 상태 코드와 HTTPException 사용법을 실제 실행 결과와 함께 다룬다. JSON을 고치면 FastAPI의 응답이 바로 바뀌는 본문 검증기 위젯을 포함한다.

코어닷투데이2026-09-2735분

들어가며 — 데이터를 다루는 네 가지 동작

GET과 POST크게 보기

어떤 서비스든 데이터를 다루는 동작은 네 가지로 정리된다. 만들고(Create), 읽고(Read), 고치고(Update), 지운다(Delete). 머리글자를 따서 CRUD라고 부른다. 할 일 앱, 쇼핑몰, 게시판, 사내 결재 시스템 — 겉모습은 달라도 API의 대부분은 CRUD다.

이번 편과 다음 편에서 할 일(Task) API 하나를 처음부터 끝까지 만든다. 이번 편은 만들기(POST) 와 읽기(GET) 를 다룬다. 4편에서 FastAPI 없이 따로 익힌 Pydantic 모델을 이제 FastAPI에 꽂아 쓰는 편이기도 하다.

1. CRUD와 HTTP 메서드 — 주소는 명사, 메서드는 동사

HTTP에는 이 네 동작에 대응하는 메서드가 있다. 그리고 주소를 짓는 널리 쓰이는 관례가 있다. 주소에는 자원(명사)을, 동작은 메서드(동사)로 나타내는 것이다. 이런 스타일을 흔히 REST라고 부른다.

CRUD메서드주소뜻성공 코드
CreatePOST/tasks할 일 하나 만들기201 Created
ReadGET/tasks할 일 목록200 OK
ReadGET/tasks/{task_id}할 일 하나200 OK
UpdatePUT/tasks/{task_id}통째로 바꾸기200 OK
UpdatePATCH/tasks/{task_id}일부만 고치기200 OK
DeleteDELETE/tasks/{task_id}지우기204 No Content

주소가 딱 두 종류라는 점을 보자. 모음(/tasks)과 하나(/tasks/{task_id}). 동작의 차이는 전부 메서드가 맡는다. 그래서 이런 주소는 피한다.

피할 주소대신이유
POST /createTaskPOST /tasks“만든다”는 이미 POST가 말한다
GET /getTaskListGET /tasks동사를 주소에 넣으면 API마다 이름 규칙이 제각각이 된다
POST /tasks/3/deleteDELETE /tasks/3브라우저·프록시·캐시가 메서드를 보고 동작한다
GET /task/3GET /tasks/3모음은 복수형으로 통일 — “tasks 중 3번”
💡
규칙보다 중요한 건 통일이다. REST 관례는 법이 아니다. 복수형 대신 단수형을 써도 서버는 잘 돈다. 문제는 한 프로젝트 안에서 /tasks와 /user와 /getOrders가 섞일 때다. 팀이 한 번 정하고 7편의 체크리스트처럼 문서로 남겨 두자.

2. 요청 본문과 Pydantic 모델

2편에서는 값을 주소(경로·쿼리)로만 받았다. 그런데 할 일을 만들려면 제목, 설명, 우선순위를 보내야 한다. 이런 데이터는 주소가 아니라 요청의 본문(body) 에 JSON으로 담는다.

json
{
  "title": "보고서 초안 쓰기",
  "description": "3쪽 분량",
  "priority": 4
}

FastAPI에서 본문을 받으려면 먼저 본문의 모양을 4편에서 배운 Pydantic 모델로 선언한다.

python
from pydantic import BaseModel, Field


class TaskCreate(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(높음)")

4편에서 본 규칙 그대로 읽으면 된다.

필드타입필수?규칙
titlestr필수 (기본값 없음)1~100글자. 빈 문자열 금지
descriptionstr | None선택 (기본 None)최대 1000글자. null도 허용
priorityint선택 (기본 3)1 이상 5 이하 정수

2편의 쿼리 매개변수와 규칙이 같다. 기본값이 있으면 선택, 없으면 필수다. Field(...)는 2편의 Query(...)와 같은 역할을 본문 필드에 한다.

그리고 함수 인자의 타입으로 이 모델을 쓰면 끝이다.

python
@app.post("/tasks")
def create_task(payload: TaskCreate):
    ...

FastAPI는 인자 타입이 Pydantic 모델이면 본문, int·str 같은 단순 타입이면 쿼리(또는 이름이 경로에 있으면 경로)로 읽는다. 따로 “이건 본문이야”라고 표시하지 않아도 된다.

2-1. 검문소로서의 모델

Pydantic 모델은 요청의 검문소크게 보기

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


class TaskCreate(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


class TaskCreate(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(높음)")


class Task(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
class UserOut(BaseModel):
    id: int
    email: str


@app.get("/users/{user_id}", response_model=UserOut)
def read_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")


class TaskCreate(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(높음)")


class Task(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)
def create_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 += 1
    return task

새로 나온 것 세 가지만 짚자.

  • status_code=status.HTTP_201_CREATED: 만들기 성공은 200이 아니라 201로 답하는 것이 관례다. 숫자 201을 직접 써도 되지만 status.HTTP_201_CREATED처럼 이름으로 쓰면 읽는 사람이 뜻을 바로 안다.
  • payload.model_dump(): 모델을 딕셔너리로 바꾼다. {"title": ..., "description": ..., "priority": ...}.
  • **: 딕셔너리를 풀어서 키워드 인자로 넘긴다. Task(id=1, done=False, created_at=..., title=..., description=..., priority=...)와 같다.

실행 결과(실제 출력):

요청 본문상태응답
{"title": "보고서 초안 쓰기", "priority": 4}201{"id": 1, "title": "보고서 초안 쓰기", "description": null, "priority": 4, "done": false, "created_at": "2026-09-27T11:42:50.320449"}
{"title": "회의록 정리"}201id: 2, priority: 3(기본값)
{"title": "", "priority": 9}422string_too_short(title) + less_than_equal(priority)
{"priority": "높음"}422missing(title) + int_parsing(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])
def list_tasks(done: bool | None = None, skip: int = 0, limit: int = 20):
    result = list(tasks.values())
    if done is not None:
        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
def get_task_or_404(task_id: int) -> Task:
    task = tasks.get(task_id)
    if task is None:
        raise HTTPException(status_code=404, detail=f"{task_id}번 할 일이 없습니다")
    return task


@app.get("/tasks/{task_id}", response_model=Task)
def read_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. 연습 문제

  1. GET /tasks에 q: str | None = None을 추가해 제목에 q가 들어간 할 일만 돌려주자. (q in t.title)
  2. GET /tasks에 sort: Literal["priority", "created_at"] = "created_at"을 추가해 정렬해 보자. sort=abc를 보내면 어떤 422가 나오는가?
  3. TaskCreate에 due_date: date | None = None(마감일)을 추가하고, /docs의 Schemas와 Try it out 예시가 어떻게 바뀌는지 보자. "due_date": "2026-13-01"을 보내면?
1·2번 예시 답안
python
from typing import Literal


@app.get("/tasks", response_model=list[Task])
def list_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 is not None:
        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에서 직접 실행해 확인했다. 위젯의 에러 문구도 같은 환경의 실제 출력에서 옮겼다. 삽화는 코어닷투데이가 생성했다.