coredot.today
CRUD ② PUT·PATCH·DELETE — 통째로 바꾸기, 일부 고치기, 지우기 (FastAPI 입문 6편)
블로그로 돌아가기
FastAPICRUDPUTPATCHDELETEexclude_unsetmodel_dump멱등성204field_validatorPydantic입문튜토리얼

CRUD ② PUT·PATCH·DELETE — 통째로 바꾸기, 일부 고치기, 지우기 (FastAPI 입문 6편)

할 일 API의 나머지 절반인 수정과 삭제를 만든다. PUT(전체 교체)과 PATCH(부분 수정)가 무엇이 다른지, 왜 PUT은 안 보낸 필드를 지워 버리는지, PATCH에서 model_dump(exclude_unset=True)가 하는 일과 exclude_none과의 차이, 그리고 초보자가 거의 반드시 밟는 ‘PATCH로 null이 들어가는’ 함정과 해결법을 실제 실행 결과로 보여 준다. DELETE와 204, 멱등성까지 정리하고, 같은 본문을 다른 메서드로 보내 보는 실험실 위젯을 포함한다.

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

들어가며 — “고친다”에는 두 가지가 있다

PUT · PATCH · DELETE크게 보기

5편에서 할 일을 만들고(POST) 읽었다(GET). 이번 편은 고치고 지운다. 그런데 HTTP에는 “고친다”가 두 개 있다. PUT과 PATCH. 협업에서 가장 자주 헷갈리는 부분이고, 잘못 쓰면 데이터가 조용히 지워지는 버그가 난다.

이 글을 다 읽으면 다음을 설명할 수 있다.

  • PUT과 PATCH의 차이, 그리고 PUT이 “안 보낸 필드”를 어떻게 처리하는지
  • PATCH 구현의 핵심 한 줄 model_dump(exclude_unset=True)
  • PATCH로 필수 필드에 null이 들어가는 함정과 막는 법
  • DELETE가 204를 돌려주는 이유와 멱등성
📚
FastAPI 입문 시리즈 (전 7편)
1편 왜 파이썬, 왜 FastAPI · 2편 첫 API와 라우팅 · 3편 /docs로 협업하기 · 4편 Pydantic 기초 · 5편 CRUD ① GET·POST · 6편 CRUD ② PUT·PATCH·DELETE (이 글) · 7편 프로젝트 폴더 구조

1. PUT과 PATCH — 서류를 새로 내느냐, 한 칸을 고치느냐

전체 교체와 부분 수정크게 보기

주민센터에 낸 신청서의 주소가 틀렸다고 하자. 방법은 둘이다.

  • PUT (전체 교체): 신청서를 처음부터 다시 써서 낸다. 이름·전화번호·주소를 전부 다시 적는다. 새 서류에 안 적은 칸은 빈칸이 된다.
  • PATCH (부분 수정): 기존 신청서의 주소 칸만 고친다. 나머지는 그대로 둔다.
항목PUTPATCH
뜻이 자원을 보낸 내용으로 바꿔라이 자원에 보낸 변경만 적용하라
본문자원의 전체 모습 — 필수 필드를 모두 보내야 함바꿀 필드만 — 아무거나 몇 개
안 보낸 필드기본값으로 초기화된다그대로 유지된다
같은 요청을 두 번결과가 같다 (멱등)이 글의 구현에선 같지만, 일반적으로 보장되지 않는다
잘 맞는 화면“편집” 화면에서 폼 전체를 저장체크박스 하나 토글, 제목만 인라인 수정
💡
실무에서는 PATCH가 훨씬 많이 쓰인다. 프론트엔드는 보통 “바뀐 것만” 보내고 싶어 한다. PUT을 쓰면 화면이 모든 필드를 들고 있어야 하고, 두 사람이 동시에 다른 필드를 고치면 나중 사람이 앞사람의 수정을 덮어쓴다. 많은 팀이 수정 API를 PATCH 하나만 만든다. 그래도 둘의 차이는 알아야 남의 API를 안전하게 부를 수 있다.

2. PUT 구현 — 모든 필드가 필수인 모델

PUT은 “전체 모습”을 받으니, 수정 가능한 필드를 모두 필수로 둔 모델을 만든다.

python
class TaskUpdate(BaseModel):
    title: str = Field(min_length=1, max_length=100)
    description: str | None = Field(default=None, max_length=1000)
    priority: int = Field(ge=1, le=5)
    done: bool


@app.put("/tasks/{task_id}", response_model=Task)
def replace_task(task_id: int, payload: TaskUpdate):
    old = get_task_or_404(task_id)
    task = Task(id=old.id, created_at=old.created_at, **payload.model_dump())
    tasks[task_id] = task
    return task

get_task_or_404는 5편에서 만든 헬퍼다. 경로 매개변수(task_id)와 본문(payload)을 같이 받는 것도 눈여겨보자. 2편의 규칙대로 FastAPI가 이름과 타입을 보고 알아서 나눈다.

id와 created_at은 기존 값을 그대로 쓰고, 나머지는 보낸 값으로 통째로 새 Task를 만든다. 실제 실행 결과다. 처음 상태는 {"title": "보고서 초안 쓰기", "description": "설명", "priority": 4, "done": false}.

PUT 본문상태결과
{"title": "보고서 최종본", "priority": 5, "done": true}200제목·우선순위·완료가 바뀜. 그런데 description이 "설명" → null
{"title": "회의록만 바꿈"}422priority, done 둘 다 missing

첫 줄이 PUT의 핵심이다. 우리는 설명을 지우라고 한 적이 없다. 그냥 안 보냈을 뿐이다. 하지만 PUT은 “이 모습으로 바꿔라”이므로 안 보낸 description은 모델의 기본값 None이 된다. 서버는 정확히 약속대로 동작했다. PUT을 부르는 쪽이 이 약속을 모르면 데이터가 사라진다.

3. PATCH 구현 — 모든 필드가 선택인 모델과 exclude_unset

PATCH는 “바꿀 것만” 받으니 모든 필드를 선택(| None = None)으로 둔다.

python
class TaskPatch(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=100)
    description: str | None = Field(default=None, max_length=1000)
    priority: int | None = Field(default=None, ge=1, le=5)
    done: bool | None = None


@app.patch("/tasks/{task_id}", response_model=Task)
def update_task(task_id: int, payload: TaskPatch):
    old = get_task_or_404(task_id)
    changes = payload.model_dump(exclude_unset=True)
    task = old.model_copy(update=changes)
    tasks[task_id] = task
    return task

핵심은 model_dump(exclude_unset=True) 한 줄이다. 문제는 이것이다. 본문에 {"done": true}만 왔다면, payload.title은 None이다. 그런데 이 None이 “클라이언트가 안 보냈음”인지 “클라이언트가 일부러 null을 보냈음”인지 어떻게 구별할까?

Pydantic 모델은 어떤 필드가 실제로 들어왔는지를 기억한다. exclude_unset=True는 실제로 들어온 필드만 꺼낸다. 본문 {"done": true, "description": null}로 세 가지 꺼내는 법을 비교하면(실제 출력):

꺼내는 법결과PATCH에 쓰면
model_dump(){"title": None, "description": None, "priority": None, "done": True}안 보낸 제목·우선순위까지 None으로 덮어씀 — 재앙
model_dump(exclude_none=True){"done": True}일부러 보낸 description: null(설명 지우기)까지 버려짐
model_dump(exclude_unset=True){"description": None, "done": True}정답 — 보낸 것만, 보낸 그대로

그다음 old.model_copy(update=changes) 는 기존 객체를 복사하면서 changes에 있는 필드만 바꾼 새 객체를 만든다. {"done": true}를 PATCH하면 실제로 done만 true가 되고 제목·설명·우선순위는 그대로다.

4. 함정 — PATCH로 null이 들어간다

여기까지의 PATCH 코드에는 초보자가 거의 반드시 밟는 구멍이 있다. 이 본문을 보내 보자.

json
{"title": null}

TaskPatch.title의 타입은 str | None이다. None(null)이 합법이다. 그래서 검증을 통과하고, exclude_unset=True는 “title이 들어왔다”고 알려 주고, model_copy는 제목을 None으로 바꾼다. 실제 실행 결과:

json
{"id": 1, "title": null, "description": null, "priority": 3, "done": false, "created_at": "..."}

상태 코드 200. 제목이 필수인 할 일에 제목이 없어졌다. 더 놀라운 건 response_model=Task(title: str)도 이걸 막지 못했다는 점이다. 함수가 돌려준 값이 이미 Task 객체라서 FastAPI가 다시 검증하지 않고 그대로 내보냈다. 이 할 일은 이제 목록 화면에서 프론트엔드를 터뜨릴 것이다.

4편 10절에서 본 “검사는 만들 때만 한다”가 여기서 문제가 된다. model_copy(update=...)는 새 값을 다시 검사하지 않는다. 그리고 원인은 PATCH 모델에서 None이 두 가지 뜻을 겸하고 있어서다. 4편 4절의 두 질문 — “안 보내도 되나”와 “비어 있어도 되나” — 이 한 필드에 섞였다. “안 보냄”의 기본값이기도 하고, “null을 보냄”의 값이기도 하다. description처럼 원래 비울 수 있는 필드는 null이 “지우기”라는 정당한 뜻이지만, title처럼 비울 수 없는 필드에 null이 오면 거절해야 한다.

해결은 “들어온 값이 null이면 거절”하는 검증기를 붙이는 것이다.

python
from pydantic import BaseModel, Field, field_validator


class TaskPatch(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=100)
    description: str | None = Field(default=None, max_length=1000)
    priority: int | None = Field(default=None, ge=1, le=5)
    done: bool | None = None

    @field_validator("title", "priority", "done")
    @classmethod
    def reject_null(cls, value):
        if value is None:
            raise ValueError("이 필드는 null로 지울 수 없습니다")
        return value

field_validator는 값이 실제로 들어왔을 때만 실행된다(안 보낸 필드의 기본값에는 돌지 않는다). 그래서 이렇게 갈린다(실제 출력).

PATCH 본문상태결과
{"done": true}200done만 바뀜
{"description": null}200설명 지우기 — 검증기 대상이 아니라 허용
{"title": null}422value_error, "Value error, 이 필드는 null로 지울 수 없습니다"
{}200아무것도 안 바뀜

5. DELETE — 204와 멱등성

python
@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int):
    get_task_or_404(task_id)
    del tasks[task_id]
  • 204 No Content: “성공했고, 돌려줄 본문은 없다.” 지운 자원을 다시 보여 줄 이유가 없으니 본문이 빈 204가 관례다. 실제 응답 본문도 빈 바이트(b'')다. 함수는 아무것도 return하지 않는다.
  • 두 번째 DELETE: 이미 없으니 404다. 실제로 DELETE /tasks/1을 두 번 보내면 204, 그다음 404 {"detail": "1번 할 일이 없습니다"}가 온다.

그런데 두 번째가 404라면 “DELETE는 멱등(여러 번 해도 결과가 같다)하다”는 말과 모순 아닐까? 멱등성은 응답 코드가 아니라 서버 상태 기준이다. 한 번 지우든 열 번 지우든 서버 상태는 “1번 없음”으로 같다. 그래서 네트워크가 끊겨 클라이언트가 DELETE를 재시도해도 안전하다.

메서드안전 (상태를 안 바꿈)멱등 (여러 번 = 한 번)재시도해도 되나
GET예예언제든
PUT아니오예 — 같은 모습으로 또 바꿔도 같은 모습예
DELETE아니오예 — 없는 걸 또 지워도 없음예
PATCH아니오보장 안 됨 — “조회수 +1” 같은 변경이면 매번 달라짐구현에 따라
POST아니오아니오 — 두 번 보내면 두 개 생김주의 (결제 두 번!)

“결제 버튼을 두 번 눌렀더니 두 번 결제됐다”는 POST가 멱등하지 않아서 생기는 고전적인 버그다. 프론트엔드는 POST 중에는 버튼을 막고, 백엔드는 중복 요청을 걸러 내는 장치를 따로 둔다.

💡
실무에서는 “진짜로” 안 지우는 경우가 많다. 되돌리기, 감사 기록, 통계 때문에 행을 지우지 않고 deleted_at 시각만 채우는 소프트 삭제를 흔히 쓴다. 이때도 API 모양은 똑같이 DELETE /tasks/{id} → 204로 두고, 목록 GET에서 지워진 것을 빼 주면 된다. 클라이언트는 차이를 몰라도 된다.

6. 실험실 — 같은 본문, 다른 메서드

지금까지의 내용을 한 위젯에 담았다. 1번 할 일에 PUT·PATCH·DELETE를 보내고 필드별로 무엇이 바뀌었는지 보자. 응답과 에러 문구는 실제 FastAPI 출력과 같다.

추천 순서:

  1. PATCH 완료 표시만 → 보내기. done만 초록(변경)이다.
  2. 초기화 후 PUT 완료 표시만 → 보내기. 422 — PUT은 제목·우선순위도 보내라고 한다.
  3. PUT 네 필드 모두 → 보내기. 설명이 주황(기본값으로 초기화) — 안 보냈다고 지워졌다.
  4. 초기화 후 PATCH 제목을 null로 → 보내기. 200인데 제목 칸이 빨갛다. 검증기를 켜고 다시 보내면 422로 막힌다.
  5. DELETE를 두 번. 204, 404 — 그래도 서버 상태는 같다.

7. 완성된 할 일 API 전체 코드

5편과 6편을 합친 main.py 전체다. 이대로 fastapi dev main.py로 실행하면 /docs에서 여섯 API를 모두 쓸 수 있다.

python
from datetime import datetime

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field, field_validator

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 TaskUpdate(BaseModel):
    title: str = Field(min_length=1, max_length=100)
    description: str | None = Field(default=None, max_length=1000)
    priority: int = Field(ge=1, le=5)
    done: bool


class TaskPatch(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=100)
    description: str | None = Field(default=None, max_length=1000)
    priority: int | None = Field(default=None, ge=1, le=5)
    done: bool | None = None

    @field_validator("title", "priority", "done")
    @classmethod
    def reject_null(cls, value):
        if value is None:
            raise ValueError("이 필드는 null로 지울 수 없습니다")
        return value


class Task(BaseModel):
    id: int
    title: str
    description: str | None
    priority: int
    done: bool
    created_at: datetime


tasks: dict[int, Task] = {}
next_id = 1


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.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


@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]


@app.get("/tasks/{task_id}", response_model=Task)
def read_task(task_id: int):
    return get_task_or_404(task_id)


@app.put("/tasks/{task_id}", response_model=Task)
def replace_task(task_id: int, payload: TaskUpdate):
    old = get_task_or_404(task_id)
    task = Task(id=old.id, created_at=old.created_at, **payload.model_dump())
    tasks[task_id] = task
    return task


@app.patch("/tasks/{task_id}", response_model=Task)
def update_task(task_id: int, payload: TaskPatch):
    old = get_task_or_404(task_id)
    changes = payload.model_dump(exclude_unset=True)
    task = old.model_copy(update=changes)
    tasks[task_id] = task
    return task


@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int):
    get_task_or_404(task_id)
    del tasks[task_id]

8. 상태 코드 총정리

라우트성공대상 없음본문·값 오류
POST /tasks201 + 만든 할 일—422
GET /tasks200 + 목록 (비어도 []로 200)—422 (쿼리 타입 오류)
GET /tasks/{id}200 + 할 일404422 (id가 정수 아님)
PUT /tasks/{id}200 + 바뀐 할 일404422 (필수 필드 누락 등)
PATCH /tasks/{id}200 + 바뀐 할 일404422 (null 거부 등)
DELETE /tasks/{id}204 (본문 없음)404422 (id가 정수 아님)
⚠️
검사 순서에 주의. PUT·PATCH에서 본문도 틀리고 대상도 없으면 무엇이 올까? FastAPI는 함수를 실행하기 전에 본문을 검증하므로 422가 먼저 온다. 404는 함수 안의 get_task_or_404에서 나기 때문이다. 프론트엔드가 에러를 처리할 때 알아 두면 좋다.

9. 연습 문제

  1. POST /tasks/{task_id}/complete — 할 일을 완료 처리하는 전용 API를 만들어 보자. PATCH {"done": true}와 비교해 장단점은? (힌트: 주소에 동사가 들어갔지만, “완료”라는 행위가 중요한 도메인에서는 흔히 쓴다.)
  2. DELETE /tasks?done=true — 완료된 할 일을 한꺼번에 지우는 API. 몇 개를 지웠는지 돌려주려면 204 대신 무엇을 써야 할까?
  3. PATCH에서 priority를 “+1 올리기”로 바꾸면 멱등성이 어떻게 되는지 설명해 보자.

정리

📝
PUT = 전체 교체, PATCH = 부분 수정
PUT 모델은 전부 필수, PATCH 모델은 전부 선택. PUT에서 안 보낸 필드는 기본값으로 초기화된다. 실무에서는 PATCH를 더 많이 쓴다.
🎯
PATCH의 두 줄
model_dump(exclude_unset=True)로 보낸 것만 꺼내고 model_copy(update=...)로 적용한다. 비울 수 없는 필드에는 field_validator로 null을 거부한다.
🗑️
DELETE = 204, 멱등
본문 없는 204. 두 번째는 404지만 서버 상태는 같으니 멱등하다. 재시도가 위험한 건 POST다.

할 일 API가 완성됐다. 그런데 main.py 한 파일에 모델·저장소·라우트·헬퍼가 다 들어 있다. 혼자라면 괜찮지만 다섯 명이 동시에 이 파일을 고치면 충돌이 끊이지 않는다. 마지막 편에서는 이 코드를 팀이 같이 쓰기 좋은 구조로 나누고, 의존성 주입과 테스트, 팀 규칙까지 정리한다.

ℹ️
실행 환경. PUT의 description 초기화, PUT 부분 본문의 422, model_dump 세 방식의 출력, PATCH {"title": null}이 200으로 저장되는 동작, reject_null 검증기의 422, DELETE의 204(빈 본문)·재시도 404는 모두 FastAPI 0.141.1 · Pydantic 2.13.5 · Python 3.11에서 직접 실행해 확인한 결과다. 위젯의 응답·에러 문구도 같은 환경의 실제 출력에서 옮겼다. 삽화는 코어닷투데이가 생성했다.