백엔드 API 구현 — 데이터베이스와 HTTP를 연결하다¶
지금까지 배운 것들을 이어붙이는 시간¶
1장에서 API가 무엇인지 배웠습니다.
2장에서 웹 서비스가 어떤 구조로 동작하는지 배웠습니다.
5장에서 데이터베이스를 설계했습니다.
6장에서 SQL로 데이터를 다루는 법을 배웠습니다.
이제 이것들을 하나로 연결할 차례입니다.
클라이언트 (브라우저 / 앱)
↕ HTTP 요청 / 응답
백엔드 서버 ← [이 장에서 만드는 것]
↕ SQL
데이터베이스
REST API 설계 원칙¶
백엔드가 클라이언트에게 제공하는 API를 어떻게 설계해야 할까요?
가장 널리 쓰이는 방식이 REST(Representational State Transfer)입니다.
핵심 규칙¶
1. URL은 명사(자원)를 나타낸다
좋은 예 (명사): 나쁜 예 (동사):
/todos /getTodos
/todos/5 /deleteTodo?id=5
/users/1/todos /getUserTodos?userId=1
2. HTTP 메서드가 행동을 나타낸다
| 메서드 | 의미 | 예시 |
|---|---|---|
GET |
조회 | GET /todos — 목록 조회 |
POST |
생성 | POST /todos — 새 항목 생성 |
PUT |
전체 수정 | PUT /todos/5 — 5번 항목 전체 교체 |
PATCH |
일부 수정 | PATCH /todos/5 — 5번 항목 일부 수정 |
DELETE |
삭제 | DELETE /todos/5 — 5번 항목 삭제 |
3. 응답은 JSON으로
{
"id": 5,
"title": "운동하기",
"is_done": false,
"created_at": "2026-05-21T10:00:00"
}
Todo 서비스 API 설계 예시¶
| 메서드 | URL | 설명 |
|---|---|---|
GET |
/todos |
전체 목록 조회 |
GET |
/todos/5 |
5번 항목 조회 |
POST |
/todos |
새 항목 생성 |
PATCH |
/todos/5 |
5번 항목 수정 |
DELETE |
/todos/5 |
5번 항목 삭제 |
HTTP 상태 코드¶
서버는 응답할 때 상태 코드로 결과를 알립니다.
| 코드 | 의미 | 상황 |
|---|---|---|
200 OK |
성공 | GET, PATCH, DELETE 성공 |
201 Created |
생성 성공 | POST 성공 |
400 Bad Request |
잘못된 요청 | 필수 값 누락, 형식 오류 |
401 Unauthorized |
인증 필요 | 로그인 안 됨 |
403 Forbidden |
권한 없음 | 다른 사람 데이터에 접근 |
404 Not Found |
없는 자원 | 존재하지 않는 ID |
500 Internal Server Error |
서버 오류 | 예외 발생, DB 오류 |
성공: 2xx
클라이언트 실수: 4xx
서버 실수: 5xx
Python으로 백엔드 만들기 — FastAPI¶
이 강의에서는 FastAPI를 사용합니다.
간결하고, 자동으로 API 문서를 생성해 주는 Python 프레임워크입니다.
pip install fastapi uvicorn
가장 단순한 서버¶
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def root():
return {"message": "Hello, World!"}
uvicorn main:app --reload
# http://localhost:8000 에서 동작
# http://localhost:8000/docs 에서 API 문서 자동 생성
데이터베이스 연결 — SQLite + sqlite3¶
Python 표준 라이브러리 sqlite3로 데이터베이스에 연결합니다.
import sqlite3
def get_db():
conn = sqlite3.connect("app.db")
conn.row_factory = sqlite3.Row # 딕셔너리처럼 접근 가능
return conn
테이블 초기화¶
def init_db():
conn = get_db()
conn.execute("""
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
is_done INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
""")
conn.commit()
conn.close()
CRUD API 구현¶
CRUD = Create / Read / Update / Delete
GET /todos — 목록 조회¶
from fastapi import FastAPI
import sqlite3
app = FastAPI()
@app.get("/todos")
def get_todos():
conn = sqlite3.connect("app.db")
conn.row_factory = sqlite3.Row
rows = conn.execute("SELECT * FROM todos ORDER BY created_at DESC").fetchall()
conn.close()
return [dict(row) for row in rows]
응답:
[
{"id": 2, "title": "책 읽기", "is_done": 0, "created_at": "..."},
{"id": 1, "title": "운동하기", "is_done": 1, "created_at": "..."}
]
GET /todos/{id} — 단건 조회¶
from fastapi import HTTPException
@app.get("/todos/{todo_id}")
def get_todo(todo_id: int):
conn = sqlite3.connect("app.db")
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
conn.close()
if row is None:
raise HTTPException(status_code=404, detail="Not found")
return dict(row)
?자리에 값을 넣는 방식을 파라미터 바인딩이라고 합니다.
SQL 문자열에 값을 직접 삽입하면 SQL 인젝션 취약점이 생깁니다.
POST /todos — 생성¶
from pydantic import BaseModel
class TodoCreate(BaseModel):
title: str
@app.post("/todos", status_code=201)
def create_todo(body: TodoCreate):
conn = sqlite3.connect("app.db")
cursor = conn.execute(
"INSERT INTO todos (title) VALUES (?)",
(body.title,)
)
conn.commit()
new_id = cursor.lastrowid
conn.close()
return {"id": new_id, "title": body.title, "is_done": 0}
요청:
POST /todos
{"title": "영어 공부"}
응답:
HTTP 201
{"id": 3, "title": "영어 공부", "is_done": 0}
PATCH /todos/{id} — 수정¶
class TodoUpdate(BaseModel):
title: str | None = None
is_done: bool | None = None
@app.patch("/todos/{todo_id}")
def update_todo(todo_id: int, body: TodoUpdate):
conn = sqlite3.connect("app.db")
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
if row is None:
conn.close()
raise HTTPException(status_code=404, detail="Not found")
new_title = body.title if body.title is not None else row["title"]
new_is_done = 1 if body.is_done else (0 if body.is_done is not None else row["is_done"])
conn.execute(
"UPDATE todos SET title = ?, is_done = ? WHERE id = ?",
(new_title, new_is_done, todo_id)
)
conn.commit()
conn.close()
return {"id": todo_id, "title": new_title, "is_done": bool(new_is_done)}
DELETE /todos/{id} — 삭제¶
@app.delete("/todos/{todo_id}", status_code=200)
def delete_todo(todo_id: int):
conn = sqlite3.connect("app.db")
result = conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
conn.commit()
conn.close()
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="Not found")
return {"deleted": todo_id}
요청-응답 전체 흐름¶
클라이언트 FastAPI 서버 SQLite DB
│ │ │
│ POST /todos │ │
│ {"title": "영어 공부"} ────▶│ │
│ │ INSERT INTO todos ... ────▶│
│ │ │
│ │ ◀──── lastrowid = 3 │
│ │ │
│ ◀──── HTTP 201 │ │
│ {"id": 3, "title": ...} │ │
입력 검증 (Validation)¶
클라이언트에서 잘못된 값이 올 수 있습니다.
FastAPI + Pydantic은 자동으로 검증하고 400 응답을 돌려줍니다.
from pydantic import BaseModel, Field
class TodoCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
# title이 비어있거나 200자를 넘으면 자동으로 400 반환
요청:
POST /todos
{"title": ""}
응답:
HTTP 400
{"detail": [{"type": "string_too_short", "loc": ["body", "title"], ...}]}
쿼리 파라미터¶
URL 뒤에 ?key=value 형태로 전달하는 값입니다.
@app.get("/todos")
def get_todos(is_done: int | None = None, limit: int = 20):
query = "SELECT * FROM todos"
params = []
if is_done is not None:
query += " WHERE is_done = ?"
params.append(is_done)
query += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
conn = sqlite3.connect("app.db")
conn.row_factory = sqlite3.Row
rows = conn.execute(query, params).fetchall()
conn.close()
return [dict(row) for row in rows]
GET /todos → 전체 목록 (최대 20개)
GET /todos?is_done=0 → 미완료만
GET /todos?limit=5 → 최대 5개만
GET /todos?is_done=1&limit=3 → 완료된 것 중 3개
전체 파일 구조¶
project/
├── main.py ← FastAPI 앱, 라우터
├── database.py ← DB 연결, 초기화
└── app.db ← SQLite 데이터베이스 파일 (자동 생성)
# database.py
import sqlite3
DATABASE = "app.db"
def get_db():
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db()
conn.execute("""
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
is_done INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
""")
conn.commit()
conn.close()
# main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from database import get_db, init_db
app = FastAPI()
init_db()
class TodoCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
class TodoUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
is_done: bool | None = None
@app.get("/todos")
def get_todos():
conn = get_db()
rows = conn.execute("SELECT * FROM todos ORDER BY created_at DESC").fetchall()
conn.close()
return [dict(row) for row in rows]
@app.post("/todos", status_code=201)
def create_todo(body: TodoCreate):
conn = get_db()
cursor = conn.execute("INSERT INTO todos (title) VALUES (?)", (body.title,))
conn.commit()
new_id = cursor.lastrowid
conn.close()
return {"id": new_id, "title": body.title, "is_done": False}
@app.delete("/todos/{todo_id}")
def delete_todo(todo_id: int):
conn = get_db()
result = conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
conn.commit()
conn.close()
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="Not found")
return {"deleted": todo_id}
SQL 인젝션 — 절대 하지 말 것¶
# 위험: 사용자 입력을 문자열로 직접 조합
title = request.query_params["title"]
conn.execute(f"SELECT * FROM todos WHERE title = '{title}'")
# 공격자가 title에 이것을 넣으면:
# ' OR '1'='1
# 쿼리: SELECT * FROM todos WHERE title = '' OR '1'='1'
# → 전체 데이터가 노출!
# 안전: 파라미터 바인딩 사용
conn.execute("SELECT * FROM todos WHERE title = ?", (title,))
# sqlite3가 값을 안전하게 처리함
실습 미션¶
미션 1: 서버 실행¶
1. main.py와 database.py를 작성하고 서버를 실행하세요.
2. http://localhost:8000/docs 에서 자동 생성된 API 문서를 확인하세요.
3. 문서 페이지에서 직접 API를 호출해 보세요.
미션 2: CRUD 완성¶
1. GET /todos/{id} 엔드포인트를 추가하세요.
2. PATCH /todos/{id} 엔드포인트를 추가하세요.
3. 각각 404 처리를 포함하세요.
미션 3: 기능 추가¶
1. GET /todos?is_done=0 처럼 필터링이 가능하게 만드세요.
2. GET /todos?limit=5 처럼 개수 제한이 가능하게 만드세요.
3. 완료된 할 일을 일괄 삭제하는 DELETE /todos/done 엔드포인트를 만드세요.
미션 4 (심화): 다중 사용자¶
users 테이블을 추가하고, todos에 user_id FK를 연결하세요.
- POST /users — 사용자 생성
- GET /users/{id}/todos — 특정 사용자의 할 일 목록 조회
- POST /users/{id}/todos — 특정 사용자의 할 일 생성
핵심 요약¶
| 개념 | 설명 |
|---|---|
| REST | URL=자원, HTTP 메서드=행동, JSON=데이터 |
| 상태 코드 | 2xx 성공, 4xx 클라이언트 오류, 5xx 서버 오류 |
| 파라미터 바인딩 | ? 플레이스홀더 — SQL 인젝션 방지 |
| Pydantic | 요청 Body 자동 검증 |
| FastAPI | 라우팅 + 검증 + 자동 문서화 |
DB에 값을 넣을 때는 항상 파라미터 바인딩(
?)을 사용하세요.
문자열 포매팅(f"...{value}")으로 SQL을 만드는 건 보안 취약점입니다.