CRUD ② PUT·PATCH·DELETE — 통째로 바꾸기, 일부 고치기, 지우기 (FastAPI 입문 6편)
할 일 API의 나머지 절반인 수정과 삭제를 만든다. PUT(전체 교체)과 PATCH(부분 수정)가 무엇이 다른지, 왜 PUT은 안 보낸 필드를 지워 버리는지, PATCH에서 model_dump(exclude_unset=True)가 하는 일과 exclude_none과의 차이, 그리고 초보자가 거의 반드시 밟는 ‘PATCH로 null이 들어가는’ 함정과 해결법을 실제 실행 결과로 보여 준다. DELETE와 204, 멱등성까지 정리하고, 같은 본문을 다른 메서드로 보내 보는 실험실 위젯을 포함한다.
PUT (전체 교체): 신청서를 처음부터 다시 써서 낸다. 이름·전화번호·주소를 전부 다시 적는다. 새 서류에 안 적은 칸은 빈칸이 된다.
PATCH (부분 수정): 기존 신청서의 주소 칸만 고친다. 나머지는 그대로 둔다.
항목
PUT
PATCH
뜻
이 자원을 보낸 내용으로 바꿔라
이 자원에 보낸 변경만 적용하라
본문
자원의 전체 모습 — 필수 필드를 모두 보내야 함
바꿀 필드만 — 아무거나 몇 개
안 보낸 필드
기본값으로 초기화된다
그대로 유지된다
같은 요청을 두 번
결과가 같다 (멱등)
이 글의 구현에선 같지만, 일반적으로 보장되지 않는다
잘 맞는 화면
“편집” 화면에서 폼 전체를 저장
체크박스 하나 토글, 제목만 인라인 수정
💡
실무에서는 PATCH가 훨씬 많이 쓰인다. 프론트엔드는 보통 “바뀐 것만” 보내고 싶어 한다. PUT을 쓰면 화면이 모든 필드를 들고 있어야 하고, 두 사람이 동시에 다른 필드를 고치면 나중 사람이 앞사람의 수정을 덮어쓴다. 많은 팀이 수정 API를 PATCH 하나만 만든다. 그래도 둘의 차이는 알아야 남의 API를 안전하게 부를 수 있다.
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": "회의록만 바꿈"}
422
priority, done 둘 다 missing
첫 줄이 PUT의 핵심이다. 우리는 설명을 지우라고 한 적이 없다. 그냥 안 보냈을 뿐이다. 하지만 PUT은 “이 모습으로 바꿔라”이므로 안 보낸 description은 모델의 기본값 None이 된다. 서버는 정확히 약속대로 동작했다. PUT을 부르는 쪽이 이 약속을 모르면 데이터가 사라진다.
핵심은 model_dump(exclude_unset=True) 한 줄이다. 문제는 이것이다. 본문에 {"done": true}만 왔다면, payload.title은 None이다. 그런데 이 None이 “클라이언트가 안 보냈음”인지 “클라이언트가 일부러 null을 보냈음”인지 어떻게 구별할까?
Pydantic 모델은 어떤 필드가 실제로 들어왔는지를 기억한다. exclude_unset=True는 실제로 들어온 필드만 꺼낸다. 본문 {"done": true, "description": null}로 세 가지 꺼내는 법을 비교하면(실제 출력):
상태 코드 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
classTaskPatch(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") @classmethoddefreject_null(cls, value):
if value isNone:
raise ValueError("이 필드는 null로 지울 수 없습니다")
return value
field_validator는 값이 실제로 들어왔을 때만 실행된다(안 보낸 필드의 기본값에는 돌지 않는다). 그래서 이렇게 갈린다(실제 출력).
PATCH 본문
상태
결과
{"done": true}
200
done만 바뀜
{"description": null}
200
설명 지우기 — 검증기 대상이 아니라 허용
{"title": null}
422
value_error, "Value error, 이 필드는 null로 지울 수 없습니다"
{}
200
아무것도 안 바뀜
5. DELETE — 204와 멱등성
python
@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)defdelete_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 출력과 같다.
추천 순서:
PATCH 완료 표시만 → 보내기. done만 초록(변경)이다.
초기화 후 PUT 완료 표시만 → 보내기. 422 — PUT은 제목·우선순위도 보내라고 한다.
PUT 네 필드 모두 → 보내기. 설명이 주황(기본값으로 초기화) — 안 보냈다고 지워졌다.
초기화 후 PATCH 제목을 null로 → 보내기. 200인데 제목 칸이 빨갛다. 검증기를 켜고 다시 보내면 422로 막힌다.
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")
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(높음)")
classTaskUpdate(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: boolclassTaskPatch(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") @classmethoddefreject_null(cls, value):
if value isNone:
raise ValueError("이 필드는 null로 지울 수 없습니다")
return value
classTask(BaseModel):
id: int
title: str
description: str | None
priority: int
done: bool
created_at: datetime
tasks: dict[int, Task] = {}
next_id = 1defget_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.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
@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]
@app.get("/tasks/{task_id}", response_model=Task)defread_task(task_id: int):
return get_task_or_404(task_id)
@app.put("/tasks/{task_id}", response_model=Task)defreplace_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)defupdate_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)defdelete_task(task_id: int):
get_task_or_404(task_id)
del tasks[task_id]
8. 상태 코드 총정리
라우트
성공
대상 없음
본문·값 오류
POST /tasks
201 + 만든 할 일
—
422
GET /tasks
200 + 목록 (비어도 []로 200)
—
422 (쿼리 타입 오류)
GET /tasks/{id}
200 + 할 일
404
422 (id가 정수 아님)
PUT /tasks/{id}
200 + 바뀐 할 일
404
422 (필수 필드 누락 등)
PATCH /tasks/{id}
200 + 바뀐 할 일
404
422 (null 거부 등)
DELETE /tasks/{id}
204 (본문 없음)
404
422 (id가 정수 아님)
⚠️
검사 순서에 주의. PUT·PATCH에서 본문도 틀리고 대상도 없으면 무엇이 올까? FastAPI는 함수를 실행하기 전에 본문을 검증하므로 422가 먼저 온다. 404는 함수 안의 get_task_or_404에서 나기 때문이다. 프론트엔드가 에러를 처리할 때 알아 두면 좋다.
9. 연습 문제
POST /tasks/{task_id}/complete — 할 일을 완료 처리하는 전용 API를 만들어 보자. PATCH {"done": true}와 비교해 장단점은? (힌트: 주소에 동사가 들어갔지만, “완료”라는 행위가 중요한 도메인에서는 흔히 쓴다.)
DELETE /tasks?done=true — 완료된 할 일을 한꺼번에 지우는 API. 몇 개를 지웠는지 돌려주려면 204 대신 무엇을 써야 할까?
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에서 직접 실행해 확인한 결과다. 위젯의 응답·에러 문구도 같은 환경의 실제 출력에서 옮겼다. 삽화는 코어닷투데이가 생성했다.