Skip to main content

REST API ๋งŒ๋“ค๊ธฐ

FastAPI๋กœ ์™„์ „ํ•œ RESTful API๋ฅผ ์„ค๊ณ„ํ•˜๊ณ  ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๋ฐฐ์›Œ๋ด…์‹œ๋‹ค.

REST API๋ž€?โ€‹

REST (Representational State Transfer)๋Š” ์›น ์„œ๋น„์Šค๋ฅผ ์„ค๊ณ„ํ•˜๊ธฐ ์œ„ํ•œ ์•„ํ‚คํ…์ฒ˜ ์Šคํƒ€์ผ์ž…๋‹ˆ๋‹ค. RESTful API๋Š” HTTP ํ”„๋กœํ† ์ฝœ์„ ํ™œ์šฉํ•˜์—ฌ ๋ฆฌ์†Œ์Šค๋ฅผ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

REST์˜ ํ•ต์‹ฌ ์›์น™โ€‹

  1. ํด๋ผ์ด์–ธํŠธ-์„œ๋ฒ„ ๋ถ„๋ฆฌ - ๋…๋ฆฝ์ ์ธ ๊ฐœ๋ฐœ๊ณผ ํ™•์žฅ
  2. ๋ฌด์ƒํƒœ์„ฑ (Stateless) - ๊ฐ ์š”์ฒญ์€ ๋…๋ฆฝ์ 
  3. ์บ์‹œ ๊ฐ€๋Šฅ - ์‘๋‹ต์€ ์บ์‹œ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ๋ช…์‹œ
  4. ๊ณ„์ธตํ™” ์‹œ์Šคํ…œ - ์ค‘๊ฐ„ ์„œ๋ฒ„ ์‚ฌ์šฉ ๊ฐ€๋Šฅ
  5. ์ธํ„ฐํŽ˜์ด์Šค ์ผ๊ด€์„ฑ - ํ†ต์ผ๋œ ์ธํ„ฐํŽ˜์ด์Šค

HTTP ๋ฉ”์„œ๋“œโ€‹

๋ฉ”์„œ๋“œ์šฉ๋„์˜ˆ์‹œ
GET๋ฆฌ์†Œ์Šค ์กฐํšŒGET /users
POST๋ฆฌ์†Œ์Šค ์ƒ์„ฑPOST /users
PUT๋ฆฌ์†Œ์Šค ์ „์ฒด ์ˆ˜์ •PUT /users/1
PATCH๋ฆฌ์†Œ์Šค ๋ถ€๋ถ„ ์ˆ˜์ •PATCH /users/1
DELETE๋ฆฌ์†Œ์Šค ์‚ญ์ œDELETE /users/1

HTTP ์ƒํƒœ ์ฝ”๋“œโ€‹

์ ์ ˆํ•œ ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค.

from fastapi import FastAPI, status
from fastapi.responses import JSONResponse

app = FastAPI()

# 2xx: ์„ฑ๊ณต
# 200 OK - ์ผ๋ฐ˜์ ์ธ ์„ฑ๊ณต
@app.get("/items/{item_id}", status_code=status.HTTP_200_OK)
def read_item(item_id: int):
return {"item_id": item_id}

# 201 Created - ๋ฆฌ์†Œ์Šค ์ƒ์„ฑ ์„ฑ๊ณต
@app.post("/items/", status_code=status.HTTP_201_CREATED)
def create_item(item: dict):
return item

# 204 No Content - ์„ฑ๊ณตํ–ˆ์ง€๋งŒ ๋ฐ˜ํ™˜ํ•  ๋‚ด์šฉ ์—†์Œ
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int):
return None

# 4xx: ํด๋ผ์ด์–ธํŠธ ์˜ค๋ฅ˜
# 400 Bad Request
@app.post("/invalid/")
def invalid_request():
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": "Invalid request"}
)

# 401 Unauthorized
@app.get("/protected/")
def protected_route():
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": "Authentication required"}
)

# 403 Forbidden
@app.get("/admin/")
def admin_only():
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "Access denied"}
)

# 404 Not Found
@app.get("/items/{item_id}")
def get_item(item_id: int):
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": "Item not found"}
)

# 5xx: ์„œ๋ฒ„ ์˜ค๋ฅ˜
# 500 Internal Server Error
@app.get("/error/")
def server_error():
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Internal server error"}
)

URL ์„ค๊ณ„ ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šคโ€‹

์ข‹์€ URL ์„ค๊ณ„โ€‹

# โœ… ๋ช…์‚ฌ ์‚ฌ์šฉ, ๋ณต์ˆ˜ํ˜•
GET /users # ๋ชจ๋“  ์‚ฌ์šฉ์ž ์กฐํšŒ
GET /users/123 # ํŠน์ • ์‚ฌ์šฉ์ž ์กฐํšŒ
POST /users # ์‚ฌ์šฉ์ž ์ƒ์„ฑ
PUT /users/123 # ์‚ฌ์šฉ์ž ์ „์ฒด ์ˆ˜์ •
PATCH /users/123 # ์‚ฌ์šฉ์ž ๋ถ€๋ถ„ ์ˆ˜์ •
DELETE /users/123 # ์‚ฌ์šฉ์ž ์‚ญ์ œ

# โœ… ๊ณ„์ธต ๊ตฌ์กฐ
GET /users/123/posts # ์‚ฌ์šฉ์ž์˜ ๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก
GET /users/123/posts/456 # ํŠน์ • ๊ฒŒ์‹œ๊ธ€
POST /users/123/posts # ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ
DELETE /users/123/posts/456 # ๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ

# โœ… ํ•„ํ„ฐ๋ง, ์ •๋ ฌ, ํŽ˜์ด์ง•
GET /users?role=admin # ์—ญํ• ๋ณ„ ํ•„ํ„ฐ
GET /users?sort=created_at&order=desc # ์ •๋ ฌ
GET /users?page=2&limit=10 # ํŽ˜์ด์ง•

# โœ… ๊ฒ€์ƒ‰
GET /users/search?q=john # ๊ฒ€์ƒ‰

# โŒ ํ”ผํ•ด์•ผ ํ•  ํŒจํ„ด
GET /getUser/123 # ๋™์‚ฌ ์‚ฌ์šฉ (X)
POST /createUser # ๋™์‚ฌ ์‚ฌ์šฉ (X)
GET /user # ๋‹จ์ˆ˜ํ˜• (X)
GET /users/delete/123 # ๋ฉ”์„œ๋“œ๋ฅผ URL์— ํฌํ•จ (X)

์™„์ „ํ•œ CRUD API ์˜ˆ์ œโ€‹

๋ธ”๋กœ๊ทธ APIโ€‹

from fastapi import FastAPI, HTTPException, Query, Path, status
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum

app = FastAPI(
title="Blog API",
description="์™„์ „ํ•œ RESTful ๋ธ”๋กœ๊ทธ API",
version="1.0.0"
)

# ===== ๋ชจ๋ธ ์ •์˜ =====

class PostStatus(str, Enum):
draft = "draft"
published = "published"
archived = "archived"

class PostBase(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
content: str = Field(..., min_length=1)
status: PostStatus = PostStatus.draft
tags: List[str] = []

class PostCreate(PostBase):
pass

class PostUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
content: Optional[str] = Field(None, min_length=1)
status: Optional[PostStatus] = None
tags: Optional[List[str]] = None

class Post(PostBase):
id: int
author_id: int
created_at: datetime
updated_at: datetime
view_count: int = 0

class Config:
orm_mode = True

# ===== ์ž„์‹œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค =====

posts_db = []
post_counter = 1

# ===== API ์—”๋“œํฌ์ธํŠธ =====

# 1. ๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก ์กฐํšŒ (ํ•„ํ„ฐ๋ง, ์ •๋ ฌ, ํŽ˜์ด์ง•)
@app.get(
"/posts",
response_model=List[Post],
tags=["posts"],
summary="๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก ์กฐํšŒ"
)
def list_posts(
status: Optional[PostStatus] = Query(None, description="์ƒํƒœ๋ณ„ ํ•„ํ„ฐ"),
tag: Optional[str] = Query(None, description="ํƒœ๊ทธ๋ณ„ ํ•„ํ„ฐ"),
search: Optional[str] = Query(None, description="์ œ๋ชฉ ๊ฒ€์ƒ‰"),
sort_by: str = Query("created_at", description="์ •๋ ฌ ๊ธฐ์ค€"),
order: str = Query("desc", regex="^(asc|desc)$"),
page: int = Query(1, ge=1, description="ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ"),
limit: int = Query(10, ge=1, le=100, description="ํŽ˜์ด์ง€๋‹น ํ•ญ๋ชฉ ์ˆ˜")
):
"""
๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.

- **status**: ์ƒํƒœ๋ณ„ ํ•„ํ„ฐ๋ง
- **tag**: ํƒœ๊ทธ๋ณ„ ํ•„ํ„ฐ๋ง
- **search**: ์ œ๋ชฉ ๊ฒ€์ƒ‰
- **sort_by**: ์ •๋ ฌ ๊ธฐ์ค€ (created_at, updated_at, view_count)
- **order**: ์ •๋ ฌ ์ˆœ์„œ (asc, desc)
- **page**: ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ
- **limit**: ํŽ˜์ด์ง€๋‹น ํ•ญ๋ชฉ ์ˆ˜
"""

# ํ•„ํ„ฐ๋ง
filtered_posts = posts_db

if status:
filtered_posts = [p for p in filtered_posts if p["status"] == status]

if tag:
filtered_posts = [p for p in filtered_posts if tag in p["tags"]]

if search:
filtered_posts = [
p for p in filtered_posts
if search.lower() in p["title"].lower()
]

# ์ •๋ ฌ
reverse = (order == "desc")
filtered_posts.sort(key=lambda x: x.get(sort_by, 0), reverse=reverse)

# ํŽ˜์ด์ง•
start = (page - 1) * limit
end = start + limit

return filtered_posts[start:end]

# 2. ํŠน์ • ๊ฒŒ์‹œ๊ธ€ ์กฐํšŒ
@app.get(
"/posts/{post_id}",
response_model=Post,
tags=["posts"],
summary="๊ฒŒ์‹œ๊ธ€ ์กฐํšŒ"
)
def get_post(
post_id: int = Path(..., description="๊ฒŒ์‹œ๊ธ€ ID", ge=1)
):
"""ํŠน์ • ๊ฒŒ์‹œ๊ธ€์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค."""

for post in posts_db:
if post["id"] == post_id:
# ์กฐํšŒ์ˆ˜ ์ฆ๊ฐ€
post["view_count"] += 1
return post

raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post with id {post_id} not found"
)

# 3. ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ
@app.post(
"/posts",
response_model=Post,
status_code=status.HTTP_201_CREATED,
tags=["posts"],
summary="๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ"
)
def create_post(post: PostCreate, author_id: int = 1):
"""์ƒˆ ๊ฒŒ์‹œ๊ธ€์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค."""

global post_counter

now = datetime.now()
new_post = {
"id": post_counter,
"author_id": author_id,
"title": post.title,
"content": post.content,
"status": post.status,
"tags": post.tags,
"created_at": now,
"updated_at": now,
"view_count": 0
}

posts_db.append(new_post)
post_counter += 1

return new_post

# 4. ๊ฒŒ์‹œ๊ธ€ ์ „์ฒด ์ˆ˜์ • (PUT)
@app.put(
"/posts/{post_id}",
response_model=Post,
tags=["posts"],
summary="๊ฒŒ์‹œ๊ธ€ ์ „์ฒด ์ˆ˜์ •"
)
def update_post_full(post_id: int, post: PostCreate):
"""๊ฒŒ์‹œ๊ธ€์„ ์ „์ฒด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค (๋ชจ๋“  ํ•„๋“œ ํ•„์ˆ˜)."""

for existing_post in posts_db:
if existing_post["id"] == post_id:
# ์ „์ฒด ๊ต์ฒด
existing_post["title"] = post.title
existing_post["content"] = post.content
existing_post["status"] = post.status
existing_post["tags"] = post.tags
existing_post["updated_at"] = datetime.now()
return existing_post

raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post with id {post_id} not found"
)

# 5. ๊ฒŒ์‹œ๊ธ€ ๋ถ€๋ถ„ ์ˆ˜์ • (PATCH)
@app.patch(
"/posts/{post_id}",
response_model=Post,
tags=["posts"],
summary="๊ฒŒ์‹œ๊ธ€ ๋ถ€๋ถ„ ์ˆ˜์ •"
)
def update_post_partial(post_id: int, post: PostUpdate):
"""๊ฒŒ์‹œ๊ธ€์„ ๋ถ€๋ถ„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค (์„ ํƒ์  ํ•„๋“œ)."""

for existing_post in posts_db:
if existing_post["id"] == post_id:
# ์ œ๊ณต๋œ ํ•„๋“œ๋งŒ ์—…๋ฐ์ดํŠธ
update_data = post.dict(exclude_unset=True)

for field, value in update_data.items():
existing_post[field] = value

existing_post["updated_at"] = datetime.now()
return existing_post

raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post with id {post_id} not found"
)

# 6. ๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ
@app.delete(
"/posts/{post_id}",
status_code=status.HTTP_204_NO_CONTENT,
tags=["posts"],
summary="๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ"
)
def delete_post(post_id: int):
"""๊ฒŒ์‹œ๊ธ€์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค."""

for i, post in enumerate(posts_db):
if post["id"] == post_id:
posts_db.pop(i)
return

raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Post with id {post_id} not found"
)

# 7. ๊ฒŒ์‹œ๊ธ€ ํ†ต๊ณ„
@app.get(
"/posts/stats/summary",
tags=["posts", "stats"],
summary="๊ฒŒ์‹œ๊ธ€ ํ†ต๊ณ„"
)
def get_post_stats():
"""๊ฒŒ์‹œ๊ธ€ ํ†ต๊ณ„๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค."""

total = len(posts_db)
by_status = {}
total_views = 0

for post in posts_db:
status = post["status"]
by_status[status] = by_status.get(status, 0) + 1
total_views += post["view_count"]

return {
"total_posts": total,
"by_status": by_status,
"total_views": total_views,
"average_views": total_views / total if total > 0 else 0
}

# 8. ๊ฒŒ์‹œ๊ธ€ ๊ฒ€์ƒ‰
@app.get(
"/posts/search",
response_model=List[Post],
tags=["posts"],
summary="๊ฒŒ์‹œ๊ธ€ ๊ฒ€์ƒ‰"
)
def search_posts(
q: str = Query(..., min_length=1, description="๊ฒ€์ƒ‰์–ด"),
fields: List[str] = Query(["title", "content"], description="๊ฒ€์ƒ‰ ํ•„๋“œ")
):
"""๊ฒŒ์‹œ๊ธ€์„ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค."""

results = []
q_lower = q.lower()

for post in posts_db:
if "title" in fields and q_lower in post["title"].lower():
results.append(post)
elif "content" in fields and q_lower in post["content"].lower():
if post not in results:
results.append(post)

return results

๋ฐ์ดํ„ฐ ๊ฒ€์ฆโ€‹

Pydantic์œผ๋กœ ๊ฐ•๋ ฅํ•œ ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

from pydantic import BaseModel, Field, validator, EmailStr
from typing import Optional
from datetime import datetime

class UserCreate(BaseModel):
username: str = Field(
...,
min_length=3,
max_length=50,
regex="^[a-zA-Z0-9_]+$",
description="์˜๋ฌธ, ์ˆซ์ž, ์–ธ๋”์Šค์ฝ”์–ด๋งŒ ๊ฐ€๋Šฅ"
)
email: EmailStr
password: str = Field(..., min_length=8)
age: Optional[int] = Field(None, ge=0, le=150)
website: Optional[str] = None

@validator('username')
def username_alphanumeric(cls, v):
"""์‚ฌ์šฉ์ž๋ช… ์ถ”๊ฐ€ ๊ฒ€์ฆ"""
if not v.isalnum() and '_' not in v:
raise ValueError('Username must be alphanumeric')
return v

@validator('password')
def password_strength(cls, v):
"""๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฐ•๋„ ๊ฒ€์ฆ"""
if not any(char.isdigit() for char in v):
raise ValueError('Password must contain at least one digit')
if not any(char.isupper() for char in v):
raise ValueError('Password must contain at least one uppercase letter')
return v

@validator('website')
def validate_website(cls, v):
"""์›น์‚ฌ์ดํŠธ URL ๊ฒ€์ฆ"""
if v and not v.startswith(('http://', 'https://')):
raise ValueError('Website must start with http:// or https://')
return v

# ์‚ฌ์šฉ
@app.post("/users/")
def create_user(user: UserCreate):
return user

์—๋Ÿฌ ์ฒ˜๋ฆฌโ€‹

์ผ๊ด€๋œ ์—๋Ÿฌ ์‘๋‹ต ํ˜•์‹์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError

app = FastAPI()

# ์ปค์Šคํ…€ ์˜ˆ์™ธ
class ItemNotFoundError(Exception):
def __init__(self, item_id: int):
self.item_id = item_id

class DuplicateItemError(Exception):
def __init__(self, field: str, value: str):
self.field = field
self.value = value

# ์˜ˆ์™ธ ํ•ธ๋“ค๋Ÿฌ
@app.exception_handler(ItemNotFoundError)
async def item_not_found_handler(request: Request, exc: ItemNotFoundError):
return JSONResponse(
status_code=404,
content={
"error": "NotFound",
"message": f"Item with id {exc.item_id} not found",
"path": str(request.url)
}
)

@app.exception_handler(DuplicateItemError)
async def duplicate_item_handler(request: Request, exc: DuplicateItemError):
return JSONResponse(
status_code=409,
content={
"error": "Conflict",
"message": f"{exc.field} '{exc.value}' already exists",
"field": exc.field
}
)

# ๊ฒ€์ฆ ์—๋Ÿฌ ํ•ธ๋“ค๋Ÿฌ
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
errors.append({
"field": ".".join(str(x) for x in error["loc"]),
"message": error["msg"],
"type": error["type"]
})

return JSONResponse(
status_code=422,
content={
"error": "ValidationError",
"message": "Request validation failed",
"details": errors
}
)

# ์ผ๋ฐ˜ ์˜ˆ์™ธ ํ•ธ๋“ค๋Ÿฌ
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={
"error": "InternalServerError",
"message": "An unexpected error occurred",
"path": str(request.url)
}
)

์ธ์ฆ๊ณผ ๊ถŒํ•œโ€‹

JWT ๊ธฐ๋ฐ˜ ์ธ์ฆ์„ ๊ตฌํ˜„ํ•ด๋ด…์‹œ๋‹ค.

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from datetime import datetime, timedelta
import jwt

app = FastAPI()

# ์„ค์ •
SECRET_KEY = "your-secret-key" # ์‹ค์ œ๋กœ๋Š” ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์‚ฌ์šฉ
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

security = HTTPBearer()

# ๋ชจ๋ธ
class User(BaseModel):
username: str
email: str
role: str = "user"

class Token(BaseModel):
access_token: str
token_type: str = "bearer"

# ์ž„์‹œ ์‚ฌ์šฉ์ž DB
users_db = {
"john": {
"username": "john",
"email": "john@example.com",
"password": "hashed_password",
"role": "admin"
}
}

# JWT ํ† ํฐ ์ƒ์„ฑ
def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt

# JWT ํ† ํฐ ๊ฒ€์ฆ
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
token = credentials.credentials

try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")

if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials"
)

return username

except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired"
)
except jwt.JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)

# ํ˜„์žฌ ์‚ฌ์šฉ์ž ๊ฐ€์ ธ์˜ค๊ธฐ
def get_current_user(username: str = Depends(verify_token)):
user = users_db.get(username)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return User(**user)

# ๊ด€๋ฆฌ์ž ๊ถŒํ•œ ํ™•์ธ
def require_admin(current_user: User = Depends(get_current_user)):
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
return current_user

# ๋กœ๊ทธ์ธ
@app.post("/auth/login", response_model=Token)
def login(username: str, password: str):
user = users_db.get(username)
if not user or user["password"] != password: # ์‹ค์ œ๋กœ๋Š” ํ•ด์‹œ ๋น„๊ต
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password"
)

access_token = create_access_token(data={"sub": username})
return {"access_token": access_token}

# ๋ณดํ˜ธ๋œ ์—”๋“œํฌ์ธํŠธ
@app.get("/users/me", response_model=User)
def read_users_me(current_user: User = Depends(get_current_user)):
return current_user

# ๊ด€๋ฆฌ์ž ์ „์šฉ ์—”๋“œํฌ์ธํŠธ
@app.get("/admin/users")
def list_all_users(admin: User = Depends(require_admin)):
return list(users_db.values())

ํŽ˜์ด์ง• ๊ตฌํ˜„โ€‹

ํšจ์œจ์ ์ธ ํŽ˜์ด์ง• ์‹œ์Šคํ…œ์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

from fastapi import FastAPI, Query
from pydantic import BaseModel
from typing import List, Generic, TypeVar
from math import ceil

app = FastAPI()

# ์ œ๋„ค๋ฆญ ํŽ˜์ด์ง• ์‘๋‹ต
T = TypeVar('T')

class PaginatedResponse(BaseModel, Generic[T]):
items: List[T]
total: int
page: int
size: int
pages: int

@classmethod
def create(cls, items: List[T], total: int, page: int, size: int):
return cls(
items=items,
total=total,
page=page,
size=size,
pages=ceil(total / size) if size > 0 else 0
)

class Item(BaseModel):
id: int
name: str

# ์ž„์‹œ ๋ฐ์ดํ„ฐ
items_db = [{"id": i, "name": f"Item {i}"} for i in range(1, 101)]

@app.get("/items", response_model=PaginatedResponse[Item])
def list_items(
page: int = Query(1, ge=1, description="ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ"),
size: int = Query(10, ge=1, le=100, description="ํŽ˜์ด์ง€ ํฌ๊ธฐ")
):
total = len(items_db)
start = (page - 1) * size
end = start + size

items = [Item(**item) for item in items_db[start:end]]

return PaginatedResponse.create(
items=items,
total=total,
page=page,
size=size
)

๋ฒ„์ „ ๊ด€๋ฆฌโ€‹

API ๋ฒ„์ „์„ ๊ด€๋ฆฌํ•˜๋Š” ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค.

from fastapi import FastAPI, APIRouter

app = FastAPI()

# v1 ๋ผ์šฐํ„ฐ
router_v1 = APIRouter(prefix="/api/v1", tags=["v1"])

@router_v1.get("/users")
def get_users_v1():
return {"version": "1.0", "users": []}

# v2 ๋ผ์šฐํ„ฐ
router_v2 = APIRouter(prefix="/api/v2", tags=["v2"])

@router_v2.get("/users")
def get_users_v2():
return {
"version": "2.0",
"users": [],
"metadata": {"total": 0} # v2์—์„œ ์ถ”๊ฐ€๋œ ๊ธฐ๋Šฅ
}

# ๋ผ์šฐํ„ฐ ๋“ฑ๋ก
app.include_router(router_v1)
app.include_router(router_v2)

# ๋˜๋Š” ํ—ค๋” ๊ธฐ๋ฐ˜ ๋ฒ„์ „ ๊ด€๋ฆฌ
from fastapi import Header, HTTPException

@app.get("/users")
def get_users(api_version: str = Header("1.0")):
if api_version == "1.0":
return {"version": "1.0", "users": []}
elif api_version == "2.0":
return {"version": "2.0", "users": [], "metadata": {}}
else:
raise HTTPException(400, "Unsupported API version")

์‹ค์ „ ์˜ˆ์ œ: ์ „์ž์ƒ๊ฑฐ๋ž˜ APIโ€‹

from fastapi import FastAPI, HTTPException, Depends, status
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from enum import Enum

app = FastAPI(title="E-Commerce API", version="1.0.0")

# ===== Enums =====

class OrderStatus(str, Enum):
pending = "pending"
paid = "paid"
shipped = "shipped"
delivered = "delivered"
cancelled = "cancelled"

# ===== Models =====

class Product(BaseModel):
id: int
name: str
description: str
price: float = Field(..., gt=0)
stock: int = Field(..., ge=0)
category: str

class CartItem(BaseModel):
product_id: int
quantity: int = Field(..., gt=0)

class Order(BaseModel):
id: int
user_id: int
items: List[CartItem]
total: float
status: OrderStatus
created_at: datetime
updated_at: datetime

# ===== Database =====

products_db = [
{
"id": 1,
"name": "๋…ธํŠธ๋ถ",
"description": "๊ณ ์„ฑ๋Šฅ ๋…ธํŠธ๋ถ",
"price": 1500000,
"stock": 10,
"category": "electronics"
},
{
"id": 2,
"name": "๋งˆ์šฐ์Šค",
"description": "๋ฌด์„  ๋งˆ์šฐ์Šค",
"price": 30000,
"stock": 50,
"category": "electronics"
}
]

orders_db = []
order_counter = 1

# ===== Products API =====

@app.get("/products", response_model=List[Product], tags=["products"])
def list_products(
category: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
in_stock: bool = True
):
"""์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ"""
results = products_db

if category:
results = [p for p in results if p["category"] == category]

if min_price:
results = [p for p in results if p["price"] >= min_price]

if max_price:
results = [p for p in results if p["price"] <= max_price]

if in_stock:
results = [p for p in results if p["stock"] > 0]

return results

@app.get("/products/{product_id}", response_model=Product, tags=["products"])
def get_product(product_id: int):
"""์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ"""
for product in products_db:
if product["id"] == product_id:
return product
raise HTTPException(404, "Product not found")

# ===== Orders API =====

@app.post("/orders", response_model=Order, status_code=201, tags=["orders"])
def create_order(items: List[CartItem], user_id: int = 1):
"""์ฃผ๋ฌธ ์ƒ์„ฑ"""
global order_counter

# ์ƒํ’ˆ ํ™•์ธ ๋ฐ ์žฌ๊ณ  ๊ฒ€์ฆ
total = 0
for item in items:
product = next((p for p in products_db if p["id"] == item.product_id), None)

if not product:
raise HTTPException(404, f"Product {item.product_id} not found")

if product["stock"] < item.quantity:
raise HTTPException(
400,
f"Insufficient stock for {product['name']}"
)

total += product["price"] * item.quantity

# ์ฃผ๋ฌธ ์ƒ์„ฑ
now = datetime.now()
order = {
"id": order_counter,
"user_id": user_id,
"items": [item.dict() for item in items],
"total": total,
"status": OrderStatus.pending,
"created_at": now,
"updated_at": now
}

# ์žฌ๊ณ  ๊ฐ์†Œ
for item in items:
for product in products_db:
if product["id"] == item.product_id:
product["stock"] -= item.quantity

orders_db.append(order)
order_counter += 1

return order

@app.get("/orders/{order_id}", response_model=Order, tags=["orders"])
def get_order(order_id: int):
"""์ฃผ๋ฌธ ์กฐํšŒ"""
for order in orders_db:
if order["id"] == order_id:
return order
raise HTTPException(404, "Order not found")

@app.patch("/orders/{order_id}/status", response_model=Order, tags=["orders"])
def update_order_status(order_id: int, status: OrderStatus):
"""์ฃผ๋ฌธ ์ƒํƒœ ๋ณ€๊ฒฝ"""
for order in orders_db:
if order["id"] == order_id:
# ์ƒํƒœ ์ „ํ™˜ ๊ฒ€์ฆ
current = order["status"]

# ์ทจ์†Œ๋œ ์ฃผ๋ฌธ์€ ๋ณ€๊ฒฝ ๋ถˆ๊ฐ€
if current == OrderStatus.cancelled:
raise HTTPException(400, "Cannot update cancelled order")

# ๋ฐฐ์†ก ์™„๋ฃŒ ํ›„์—๋Š” ์ทจ์†Œ ๋ถˆ๊ฐ€
if current == OrderStatus.delivered and status == OrderStatus.cancelled:
raise HTTPException(400, "Cannot cancel delivered order")

order["status"] = status
order["updated_at"] = datetime.now()
return order

raise HTTPException(404, "Order not found")

@app.get("/orders", response_model=List[Order], tags=["orders"])
def list_orders(
user_id: Optional[int] = None,
status: Optional[OrderStatus] = None
):
"""์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ"""
results = orders_db

if user_id:
results = [o for o in results if o["user_id"] == user_id]

if status:
results = [o for o in results if o["status"] == status]

return results

์ž์ฃผ ๋ฌป๋Š” ์งˆ๋ฌธโ€‹

Q1. PUT๊ณผ PATCH์˜ ์ฐจ์ด๋Š”?โ€‹

A:

  • PUT: ๋ฆฌ์†Œ์Šค ์ „์ฒด๋ฅผ ๊ต์ฒด (๋ชจ๋“  ํ•„๋“œ ํ•„์ˆ˜)
  • PATCH: ๋ฆฌ์†Œ์Šค ์ผ๋ถ€๋งŒ ์ˆ˜์ • (์„ ํƒ์  ํ•„๋“œ)
# PUT - ๋ชจ๋“  ํ•„๋“œ ํ•„์ˆ˜
@app.put("/users/{id}")
def update_user(id: int, user: UserFull):
# ์ „์ฒด ๊ต์ฒด
pass

# PATCH - ์„ ํƒ์  ํ•„๋“œ
@app.patch("/users/{id}")
def update_user_partial(id: int, user: UserPartial):
# ์ œ๊ณต๋œ ํ•„๋“œ๋งŒ ์—…๋ฐ์ดํŠธ
pass

Q2. REST API์—์„œ ๋ณต์žกํ•œ ์ฟผ๋ฆฌ๋Š” ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ•˜๋‚˜์š”?โ€‹

A: POST ์š”์ฒญ์— ๊ฒ€์ƒ‰ ์กฐ๊ฑด์„ body๋กœ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.

class SearchQuery(BaseModel):
filters: dict
sort: Optional[str] = None
page: int = 1
size: int = 10

@app.post("/users/search")
def search_users(query: SearchQuery):
# ๋ณต์žกํ•œ ๊ฒ€์ƒ‰ ๋กœ์ง
pass

Q3. ๋Œ€๋Ÿ‰ ์ž‘์—…์€ ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ•˜๋‚˜์š”?โ€‹

A: ๋ฐฐ์น˜ ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋งŒ๋“ค๊ฑฐ๋‚˜ ๋น„๋™๊ธฐ ์ž‘์—…์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

@app.post("/users/batch")
def create_users_batch(users: List[UserCreate]):
results = []
for user in users:
# ๊ฐ ์‚ฌ์šฉ์ž ์ƒ์„ฑ
results.append(create_user(user))
return {"created": len(results), "items": results}

Q4. API ๋ฌธ์„œ๋ฅผ ์ปค์Šคํ„ฐ๋งˆ์ด์ฆˆํ•˜๋ ค๋ฉด?โ€‹

A: FastAPI์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์™€ docstring์„ ํ™œ์šฉํ•ฉ๋‹ˆ๋‹ค.

@app.get(
"/items/{item_id}",
summary="์•„์ดํ…œ ์กฐํšŒ",
description="ID๋กœ ํŠน์ • ์•„์ดํ…œ์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค",
response_description="์กฐํšŒ๋œ ์•„์ดํ…œ",
tags=["items"],
deprecated=False
)
def get_item(item_id: int):
"""
์•„์ดํ…œ์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค:

- **item_id**: ์กฐํšŒํ•  ์•„์ดํ…œ์˜ ID
"""
pass

Q5. ์„ฑ๋Šฅ์„ ๊ฐœ์„ ํ•˜๋ ค๋ฉด?โ€‹

A: ์บ์‹ฑ, ํŽ˜์ด์ง•, ํ•„๋“œ ์„ ํƒ์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

# ์บ์‹ฑ
from fastapi_cache import FastAPICache
from fastapi_cache.decorator import cache

@app.get("/items/{item_id}")
@cache(expire=60) # 60์ดˆ ์บ์‹ฑ
def get_item(item_id: int):
pass

# ํ•„๋“œ ์„ ํƒ
@app.get("/users")
def get_users(fields: List[str] = Query(None)):
# ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ ๋ฐ˜ํ™˜
pass

๋‹ค์Œ ๋‹จ๊ณ„โ€‹

์™„์ „ํ•œ REST API๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ๋‹ค๋ฉด, ๋‹ค์Œ์„ ํ•™์Šตํ•ด๋ณด์„ธ์š”:

  1. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๋™ - SQLAlchemy๋กœ ์‹ค์ œ DB ์‚ฌ์šฉํ•˜๊ธฐ
  2. ํ…Œ์ŠคํŠธ - pytest๋กœ API ํ…Œ์ŠคํŠธ ์ž‘์„ฑํ•˜๊ธฐ
  3. ๋ฐฐํฌ - Docker, Kubernetes๋กœ ํ”„๋กœ๋•์…˜ ๋ฐฐํฌํ•˜๊ธฐ
  4. ๋ชจ๋‹ˆํ„ฐ๋ง - ๋กœ๊น…, ๋ฉ”ํŠธ๋ฆญ, ์•Œ๋ฆผ ์„ค์ •ํ•˜๊ธฐ

์ฐธ๊ณ  ์ž๋ฃŒโ€‹