CRUD 구현하기
1. 라우팅 및 세팅
1.1 URL 정보
이번 챕터의 URL 구성은 아래와 같습니다.
| 경로 | 함수명 | 메서드 | 설명 |
|---|---|---|---|
| /items | create_item | POST | 새로운 물품을 등록합니다. |
| /items | read_items | GET | 모든 물품 목록을 반환합니다. |
| /items/{item_id} | read_item | GET | 특정 물품의 상세 정보를 반환합니다. |
| /items/{item_id} | update_item | PATCH | 특정 물품의 정보를 일부 수정합니다. |
| /items/{item_id} | delete_item | DELETE | 특정 물품을 삭제합니다. |
같은 /items 경로에 GET과 POST가 함께 있고, 같은 /items/{item_id} 경로에 GET, PATCH, DELETE가 함께 있습니다. 이것이 앞서 배운 RESTful한 설계입니다. 경로는 자원을 가리키고, 무엇을 할지는 메서드가 정합니다.
1.2 기본 세팅
이번 실습 폴더는 03_1_crud입니다. VSC 터미널에서 사용할 명령어 입니다. 가상환경은 벗어난 상태에서 실행해야 합니다. 앞 실습의 서버가 실행 중이면 Ctrl + C로 멈추고, 만약 터미널 입력창 앞에 (venv)라고 되어 있다면 deactivate 명령어로 가상환경을 나간 상태에서 cd ..으로 상위 폴더로 나와 아래 명령어를 실행해주세요.
mkdir 03_1_crud
cd 03_1_crud
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를 사용합니다. 이후 명령은 가상환경이 활성화된 상태에서 실행합니다.
2. CRUD 애플리케이션 소개
CRUD는 Create(생성), Read(읽기), Update(갱신), Delete(삭제)의 앞글자를 따서 만든 약어입니다. 대부분의 웹 애플리케이션에서 기본이 되는 네 가지 핵심 기능을 의미합니다. 이 네 가지 기능은 데이터를 다루는 거의 모든 애플리케이션에서 필수적인 요소입니다.
| 기능 | HTTP 메서드 | 설명 |
|---|---|---|
| Create | POST | 새로운 데이터 생성 |
| Read | GET | 데이터 조회 |
| Update | PUT / PATCH | 기존 데이터 수정 |
| Delete | DELETE | 데이터 삭제 |
-
Create(생성): 새로운 데이터를 시스템에 추가하는 기능입니다. 예를 들어, 새로운 사용자 계정을 만들거나 새 상품을 데이터베이스에 추가하는 것이 이에 해당합니다.
-
Read(읽기): 저장된 데이터를 조회하는 기능입니다. 단일 항목을 조회하거나 여러 항목의 목록을 가져오는 것 모두 읽기 작업에 해당합니다.
-
Update(갱신): 기존 데이터를 수정하는 기능입니다. 사용자 정보 변경이나 상품 가격 수정 등이 이에 해당합니다.
-
Delete(삭제): 시스템에서 데이터를 제거하는 기능입니다. 사용자 계정 삭제나 재고에서 상품 제거 등이 여기에 해당합니다.
이번 챕터에서는 FastAPI를 사용하여 간단한 CRUD 애플리케이션을 구현해 보겠습니다. 우리의 예제에서는 '아이템' 관리 시스템을 만들 것입니다. 사용자는 아이템을 생성하고, 조회하고, 수정하고, 삭제할 수 있습니다.
2.1 PUT과 PATCH의 차이
Update에 메서드가 두 개인 이유를 먼저 설명하겠습니다.
| PUT | PATCH | |
|---|---|---|
| 의미 | 자원 전체를 이 내용으로 바꿔라 | 자원의 일부만 이렇게 고쳐라 |
| 보내야 할 것 | 모든 필드 | 바꿀 필드만 |
| 안 보낸 필드는 | 비워지거나 기본값이 됩니다 | 그대로 유지됩니다 |
이름과 가격이 있는 물품에서 가격만 바꾸고 싶다면 PATCH가 맞습니다. PUT으로 가격만 보내면 이름이 사라져야 정상입니다. 실무에서는 PUT을 쓰면서 PATCH처럼 동작시키는 경우가 많은데, 이런 코드가 나중에 "왜 이름이 지워졌지" 같은 버그로 이어집니다. 이 절에서는 PATCH로 제대로 구현해보겠습니다.
2.2 이번 절의 저장 방식
이 예제에서는 데이터베이스 대신 메모리 내 파이썬 데이터 구조를 사용하여 데이터를 저장할 것입니다. 이는 개념을 간단히 설명하기 위한 것이며, 실제 애플리케이션에서는 보통 영구적인 저장소인 데이터베이스를 사용합니다. 4장에서 이 코드를 그대로 데이터베이스 버전으로 바꿔볼 예정입니다.
FastAPI는 자동으로 대화형 API 문서(Swagger UI)를 생성하므로, API를 쉽게 테스트하고 사용할 수 있습니다.
3. 기본 설정
먼저 필요한 모듈을 임포트하고 FastAPI 애플리케이션을 생성합니다. 데이터는 items라는 딕셔너리 객체에 저장할 것입니다. 이 딕셔너리는 메모리 내 데이터 저장소로 사용됩니다. 이 딕셔너리는 아이템 ID를 키로 사용하고, 아이템 정보를 값으로 사용합니다. 이 딕셔너리는 애플리케이션 실행 중에만 유지되며, 애플리케이션을 다시 시작하면 초기화됩니다.
from itertools import count
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
app = FastAPI(title="물품 관리 API", version="1.0.0")
# 메모리 내 데이터 저장소
items: dict[int, "Item"] = {}
# ID를 1부터 자동으로 증가시켜 발급합니다
id_counter = count(1)
4. 모델 정의
아이템을 표현할 Pydantic 모델을 정의합니다. 앞 절에서 배운 대로 들어오는 모델과 나가는 모델을 나눕니다.
class ItemCreate(BaseModel):
"""물품을 새로 만들 때 받는 데이터입니다."""
name: str = Field(min_length=1, max_length=50)
description: str | None = None
price: float = Field(ge=0)
class ItemUpdate(BaseModel):
"""물품을 수정할 때 받는 데이터입니다. 모든 필드가 선택입니다."""
name: str | None = Field(default=None, min_length=1, max_length=50)
description: str | None = None
price: float | None = Field(default=None, ge=0)
class Item(ItemCreate):
"""저장되고 반환되는 물품입니다. ID가 붙습니다."""
id: int
세 모델의 역할이 다릅니다.
| 모델 | 언제 쓰나 | 특징 |
|---|---|---|
ItemCreate | POST 요청 본문 | name과 price가 필수 |
ItemUpdate | PATCH 요청 본문 | 전부 선택, 보낸 것만 반영 |
Item | 응답 | ID가 포함됨 |
str | None = None은 해당 필드가 선택적이라는 것을 의미합니다. 즉, 필수가 아니라는 뜻입니다. ItemUpdate의 모든 필드에 기본값 None을 준 것이 PATCH를 구현하는 핵심입니다. 잠시 뒤 Update 기능에서 이 값이 어떻게 쓰이는지 보겠습니다.
Optional[str] = None을 쓴 코드를 봤다면
파이썬 3.9 이전에는 | 연산자를 타입에 쓸 수 없어서 from typing import Optional을 가져와 Optional[str] = None으로 적었습니다. 아래 코드도 아직 많이 보입니다.
from typing import Optional
class Item(BaseModel):
name: str
description: Optional[str] = None
price: float
지금도 동작하지만 str | None = None이 더 짧고 import도 필요 없습니다. 마찬가지로 List[Item]은 list[Item]으로, Dict[str, int]는 dict[str, int]로 씁니다. 우리 수업에서는 최신 버전에 맞춰서 코드를 작성하겠습니다.
5. Create 기능 구현
새로운 아이템을 생성하는 엔드포인트를 구현합니다.
@app.post("/items", status_code=status.HTTP_201_CREATED)
async def create_item(item_data: ItemCreate) -> Item:
item_id = next(id_counter)
item = Item(id=item_id, **item_data.model_dump())
items[item_id] = item
return item
item_data.model_dump()는 Pydantic 모델을 딕셔너리로 바꿉니다. 앞에 **를 붙이면 딕셔너리의 키와 값이 각각 인자로 전달됩니다. 즉 아래 두 코드는 같은 뜻입니다.
item = Item(id=item_id, **item_data.model_dump())
# 위 코드는 아래와 같습니다
item = Item(
id=item_id,
name=item_data.name,
description=item_data.description,
price=item_data.price,
)
필드가 늘어나도 첫 번째 코드는 고칠 필요가 없다는 것이 장점입니다.
ID를 클라이언트가 정하지 않고 서버가 발급하는 점에도 주목하세요. 클라이언트가 ID를 보내게 하면 이미 있는 ID를 보냈을 때 처리해야 하고, 다른 사람의 데이터를 덮어쓸 위험도 생깁니다.
.dict()를 쓴 코드를 봤다면
Pydantic v1에서는 item.dict()였습니다. v2에서는 item.model_dump()입니다. .dict()도 아직 동작하지만 실행할 때마다 경고가 출력되고, 다음 메이저 버전에서 제거될 예정입니다. AI가 만들어 준 코드에서 가장 자주 보게 되는 오래된 문법이니 눈에 익혀두세요.
여기까지 구현된 main.py의 전체 코드는 아래와 같습니다.
from itertools import count
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
app = FastAPI(title="물품 관리 API", version="1.0.0")
# 메모리 내 데이터 저장소
items: dict[int, "Item"] = {}
id_counter = count(1)
class ItemCreate(BaseModel):
name: str = Field(min_length=1, max_length=50)
description: str | None = None
price: float = Field(ge=0)
class ItemUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=50)
description: str | None = None
price: float | None = Field(default=None, ge=0)
class Item(ItemCreate):
id: int
@app.post("/items", status_code=status.HTTP_201_CREATED)
async def create_item(item_data: ItemCreate) -> Item:
item_id = next(id_counter)
item = Item(id=item_id, **item_data.model_dump())
items[item_id] = item
return item
서버를 실행하고 .http 파일에서 다음과 같이 POST 요청을 보내면 아이템이 생성되고, 생성된 아이템이 반환됩니다. 아이템은 2개를 생성하도록 하겠습니다.
POST http://127.0.0.1:8000/items
Content-Type: application/json
{
"name": "item1",
"description": "This is item1",
"price": 100
}
###
POST http://127.0.0.1:8000/items
Content-Type: application/json
{
"name": "item2",
"description": "This is item2",
"price": 200
}
6. Read 기능 구현
아이템 목록을 조회하는 엔드포인트와 특정 아이템을 조회하는 엔드포인트를 구현합니다. 위 구현된 전체 코드 아래에 추가합니다.
@app.get("/items")
async def read_items() -> list[Item]:
return list(items.values())
@app.get("/items/{item_id}")
async def read_item(item_id: int) -> Item:
if item_id not in items:
raise HTTPException(status_code=404, detail="Item not found")
return items[item_id]
GET 요청을 보내면 아이템 목록을 조회할 수 있습니다. 다만 위 코드가 수정되어 서버가 다시 시작되었기 때문에 지금 메모리 영역에는 아이템들이 없습니다. 다시 POST로 데이터를 넣어야 합니다. 이렇게 여러 개의 아이템을 생성하고, 조회하고, 지우고, 수정하는 것을 할 때에는 요청을 .http 파일 하나에 순서대로 적어두면 편리합니다. 모든 코드를 구현한 다음 .http 파일을 이용하여 테스트해보도록 하겠습니다.
GET http://127.0.0.1:8000/items
7. Update 기능 구현
기존 아이템을 수정하는 엔드포인트를 구현합니다. 여기가 이번 절에서 가장 중요한 부분입니다.
@app.patch("/items/{item_id}")
async def update_item(item_id: int, item_data: ItemUpdate) -> Item:
if item_id not in items:
raise HTTPException(status_code=404, detail="Item not found")
stored_item = items[item_id]
update_data = item_data.model_dump(exclude_unset=True)
updated_item = stored_item.model_copy(update=update_data)
items[item_id] = updated_item
return updated_item
PATCH 요청을 보내면 아이템이 업데이트되고, 업데이트된 아이템이 반환됩니다. 모든 필드가 수정되지 않을 수도 있으므로 기본값이 None인 ItemUpdate 모델을 사용합니다. 세 줄에 담긴 의미를 하나씩 보겠습니다.
stored_item = items[item_id]: 지금 저장되어 있는 값을 가져옵니다.item_data.model_dump(exclude_unset=True): 클라이언트가 실제로 보낸 필드만 딕셔너리로 만듭니다.stored_item.model_copy(update=update_data): 기존 값을 복사하면서 보낸 부분만 덮어씁니다.
exclude_unset=True가 핵심입니다. 이 옵션이 없으면 클라이언트가 보내지 않은 필드도 None이라는 값으로 딕셔너리에 들어가서, 결국 기존 값을 None으로 지워버립니다. 가격만 보낸 요청이 있다고 할 때, 두 옵션의 결과는 아래와 같습니다.
| 코드 | 결과 딕셔너리 | 최종 결과 |
|---|---|---|
model_dump() | {"name": None, "description": None, "price": 200} | 이름이 지워집니다 |
model_dump(exclude_unset=True) | {"price": 200} | 이름이 유지됩니다 |
PATCH http://127.0.0.1:8000/items/1
Content-Type: application/json
{
"price": 200
}
.copy(update=...)를 쓴 코드를 봤다면
Pydantic v1에서는 stored_item.copy(update=update_data)였습니다. v2에서는 model_copy(update=...)입니다. 이 두 가지는 이름만 바뀐 것이 아니라 동작에도 차이가 있으니, 오래된 코드를 옮길 때 결과를 꼭 확인하세요.
8. Delete 기능 구현
아이템을 삭제하는 엔드포인트를 구현합니다.
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_item(item_id: int) -> None:
if item_id not in items:
raise HTTPException(status_code=404, detail="Item not found")
del items[item_id]
DELETE 요청을 보내면 아이템이 삭제됩니다. 삭제한 아이템을 반환하지 않고 204 No Content를 반환했습니다. 이미 지워진 것을 돌려주는 것은 의미가 적고, 클라이언트도 대부분 쓰지 않기 때문입니다. 상태 코드만으로 성공 여부를 판단하게 하는 것이 더 깔끔합니다.
DELETE http://127.0.0.1:8000/items/1
9. 전체 코드
이제 모든 CRUD 기능을 구현한 전체 코드를 살펴보겠습니다. main.py에 붙여넣고 실행해보세요.
from itertools import count
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
app = FastAPI(title="물품 관리 API", version="1.0.0")
# 메모리 내 데이터 저장소
items: dict[int, "Item"] = {}
id_counter = count(1)
class ItemCreate(BaseModel):
name: str = Field(min_length=1, max_length=50)
description: str | None = None
price: float = Field(ge=0)
class ItemUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=50)
description: str | None = None
price: float | None = Field(default=None, ge=0)
class Item(ItemCreate):
id: int
@app.post("/items", status_code=status.HTTP_201_CREATED, tags=["물품"])
async def create_item(item_data: ItemCreate) -> Item:
"""새 물품을 등록하고 서버가 발급한 ID와 함께 반환합니다."""
item_id = next(id_counter)
item = Item(id=item_id, **item_data.model_dump())
items[item_id] = item
return item
@app.get("/items", tags=["물품"])
async def read_items() -> list[Item]:
"""등록된 모든 물품을 반환합니다."""
return list(items.values())
@app.get("/items/{item_id}", tags=["물품"])
async def read_item(item_id: int) -> Item:
"""ID로 물품 하나를 조회합니다."""
if item_id not in items:
raise HTTPException(status_code=404, detail="Item not found")
return items[item_id]
@app.patch("/items/{item_id}", tags=["물품"])
async def update_item(item_id: int, item_data: ItemUpdate) -> Item:
"""보낸 필드만 수정합니다. 보내지 않은 필드는 그대로 유지됩니다."""
if item_id not in items:
raise HTTPException(status_code=404, detail="Item not found")
stored_item = items[item_id]
update_data = item_data.model_dump(exclude_unset=True)
updated_item = stored_item.model_copy(update=update_data)
items[item_id] = updated_item
return updated_item
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["물품"])
async def delete_item(item_id: int) -> None:
"""물품을 삭제합니다."""
if item_id not in items:
raise HTTPException(status_code=404, detail="Item not found")
del items[item_id]
서버를 실행합니다.
fastapi dev
10. .http 파일로 한 번에 테스트하기
CRUD는 순서대로 이어서 실행해야 확인이 됩니다. api.http 파일 하나에 전체 흐름을 적어두면 위에서부터 차례로 클릭하며 확인할 수 있습니다. 이렇게 한 파일로 만들면 한 번에 여러 개의 요청을 보낼 수 있어서 편리합니다.
프로젝트 폴더에 api.http 파일을 만들고 아래 내용을 넣어주세요.
@baseUrl = http://127.0.0.1:8000
### 1. 물품 등록
POST {{baseUrl}}/items
Content-Type: application/json
{
"name": "item1",
"description": "This is item1",
"price": 100
}
### 2. 물품 등록 (두 번째)
POST {{baseUrl}}/items
Content-Type: application/json
{
"name": "item2",
"description": "This is item2",
"price": 200
}
### 3. 전체 목록 조회
GET {{baseUrl}}/items
### 4. 상세 조회
GET {{baseUrl}}/items/1
### 5. 가격만 수정
PATCH {{baseUrl}}/items/1
Content-Type: application/json
{
"price": 999
}
### 6. 수정 결과 확인 (이름이 남아 있는지 보세요)
GET {{baseUrl}}/items/1
### 7. 삭제
DELETE {{baseUrl}}/items/1
### 8. 삭제 확인 (404가 나와야 정상입니다)
GET {{baseUrl}}/items/1
### 9. 잘못된 값 보내기 (422가 나와야 정상입니다)
POST {{baseUrl}}/items
Content-Type: application/json
{
"name": "",
"price": -100
}
각 요청 위의 Send Request를 순서대로 클릭해보세요. 중간중간 GET으로 조회를 하면서 확인해보세요. 특히 확인할 것은 아래 세 가지입니다.
- 6번에서
name이item1그대로 남아 있는지 (PATCH가 제대로 동작하는지) - 8번에서 404가 나오는지
- 9번에서 422가 나오고,
detail에name과price두 가지 문제가 모두 담겨 있는지
이 파일은 그냥 텍스트입니다
api.http는 Git에 함께 커밋할 수 있습니다. 프론트엔드 개발자에게 "이 파일 열어서 순서대로 눌러보세요"라고 전달하면 API 사용법 설명이 끝납니다. 별도 도구의 계정이나 컬렉션 내보내기 없이 프로젝트 안에 남는다는 점이 이 방식의 가장 큰 장점입니다.
11. 파이썬 파일로 직접 실행하기
fastapi dev 대신 python main.py로 실행하고 싶다면 파일 맨 아래에 아래 코드를 추가합니다.
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)
python main.py
uvicorn.run의 첫 인자를 app 객체가 아니라 "main:app" 문자열로 준 점에 주의하세요. reload=True를 쓰려면 uvicorn이 파일을 다시 읽어야 하는데, 객체를 직접 넘기면 그 작업을 할 수 없어서 경고가 나오고 자동 새로고침이 동작하지 않습니다.
개발할 때는 fastapi dev가 더 간편하므로, 이 방법은 알아만 두고 넘어가셔도 됩니다.
연습문제
-
위의 CRUD 애플리케이션에 검색 기능을 추가해보세요.
GET /items?q=검색어형태로 이름에 검색어가 포함된 물품만 반환하도록 만들어보세요. -
아이템에
category필드를 추가하고, 카테고리별로 아이템을 필터링하는 기능을 구현해보세요. -
PATCH 대신 PUT 엔드포인트를 만들어보세요. PUT은 모든 필드가 필수여야 하며, 일부만 보내면 422가 나와야 합니다. 두 방식의 차이를
api.http로 직접 비교해보세요. -
같은 이름의 물품이 이미 있으면 409 Conflict를 반환하도록 만들어보세요.
-
삭제된 물품의 ID가 재사용되는지 확인해보세요.
itertools.count()를 쓴 지금 코드에서는 재사용되지 않습니다. 왜 그래야 하는지 생각해보세요.