실전 프로젝트 — 구현과 배포¶
18장 설계를 코드로 만들기¶
18장에서 설계한 링크 북마크 앱을 이제 실제로 구현합니다.
설계: 어떻게 만들 것인가 → 문서
구현: 실제로 만든다 → 코드
백엔드 구현¶
database.py¶
import sqlite3
import os
DATABASE = os.getenv("DATABASE_PATH", "app.db")
def get_db():
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init_db():
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
url TEXT NOT NULL,
title TEXT,
memo TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_bookmarks_user_id
ON bookmarks(user_id);
""")
conn.commit()
conn.close()
auth.py¶
import os
from datetime import datetime, timedelta
from jose import jwt, JWTError
from passlib.context import CryptContext
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise RuntimeError("SECRET_KEY 환경 변수를 설정하세요")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
pwd_context = CryptContext(schemes=["bcrypt"])
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(user_id: int) -> str:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode(
{"sub": str(user_id), "exp": expire},
SECRET_KEY,
algorithm=ALGORITHM
)
def decode_token(token: str) -> int:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return int(payload["sub"])
main.py¶
import logging
from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, Field, HttpUrl
from database import get_db, init_db
from auth import hash_password, verify_password, create_access_token, decode_token
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Bookmark App")
init_db()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
security = HTTPBearer()
# ── 스키마 ──────────────────────────────────────
class UserCreate(BaseModel):
email: str = Field(min_length=5, max_length=100)
password: str = Field(min_length=8)
name: str = Field(min_length=1, max_length=50)
class UserLogin(BaseModel):
email: str
password: str
class BookmarkCreate(BaseModel):
url: str = Field(min_length=1)
title: str | None = Field(default=None, max_length=200)
memo: str | None = Field(default=None, max_length=1000)
class BookmarkUpdate(BaseModel):
title: str | None = Field(default=None, max_length=200)
memo: str | None = Field(default=None, max_length=1000)
# ── 의존성 ──────────────────────────────────────
def get_current_user(creds: HTTPAuthorizationCredentials = Depends(security)):
try:
user_id = decode_token(creds.credentials)
except Exception:
raise HTTPException(status_code=401, detail="유효하지 않은 토큰입니다")
conn = get_db()
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
conn.close()
if row is None:
raise HTTPException(status_code=401, detail="사용자를 찾을 수 없습니다")
return dict(row)
# ── 인증 ──────────────────────────────────────
@app.post("/auth/signup", status_code=201)
def signup(body: UserCreate):
conn = get_db()
if conn.execute("SELECT id FROM users WHERE email = ?", (body.email,)).fetchone():
conn.close()
raise HTTPException(status_code=400, detail="이미 사용 중인 이메일입니다")
cursor = conn.execute(
"INSERT INTO users (email, password, name) VALUES (?, ?, ?)",
(body.email, hash_password(body.password), body.name)
)
conn.commit()
user_id = cursor.lastrowid
conn.close()
logger.info(f"회원가입: {body.email}")
return {"access_token": create_access_token(user_id), "token_type": "bearer"}
@app.post("/auth/login")
def login(body: UserLogin):
conn = get_db()
row = conn.execute("SELECT * FROM users WHERE email = ?", (body.email,)).fetchone()
conn.close()
if not row or not verify_password(body.password, row["password"]):
logger.warning(f"로그인 실패: {body.email}")
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다")
logger.info(f"로그인 성공: {body.email}")
return {"access_token": create_access_token(row["id"]), "token_type": "bearer"}
# ── 북마크 CRUD ──────────────────────────────────
@app.get("/bookmarks")
def get_bookmarks(user=Depends(get_current_user)):
conn = get_db()
rows = conn.execute(
"SELECT * FROM bookmarks WHERE user_id = ? ORDER BY created_at DESC",
(user["id"],)
).fetchall()
conn.close()
return [dict(r) for r in rows]
@app.post("/bookmarks", status_code=201)
def create_bookmark(body: BookmarkCreate, user=Depends(get_current_user)):
conn = get_db()
cursor = conn.execute(
"INSERT INTO bookmarks (user_id, url, title, memo) VALUES (?, ?, ?, ?)",
(user["id"], body.url, body.title, body.memo)
)
conn.commit()
new_id = cursor.lastrowid
conn.close()
logger.info(f"북마크 생성: id={new_id}, user={user['email']}")
return {"id": new_id, "url": body.url, "title": body.title, "memo": body.memo}
@app.get("/bookmarks/{bookmark_id}")
def get_bookmark(bookmark_id: int, user=Depends(get_current_user)):
conn = get_db()
row = conn.execute("SELECT * FROM bookmarks WHERE id = ?", (bookmark_id,)).fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail="북마크를 찾을 수 없습니다")
if row["user_id"] != user["id"]:
raise HTTPException(status_code=403, detail="권한이 없습니다")
return dict(row)
@app.patch("/bookmarks/{bookmark_id}")
def update_bookmark(bookmark_id: int, body: BookmarkUpdate, user=Depends(get_current_user)):
conn = get_db()
row = conn.execute("SELECT * FROM bookmarks WHERE id = ?", (bookmark_id,)).fetchone()
if not row:
conn.close()
raise HTTPException(status_code=404, detail="북마크를 찾을 수 없습니다")
if row["user_id"] != user["id"]:
conn.close()
raise HTTPException(status_code=403, detail="권한이 없습니다")
new_title = body.title if body.title is not None else row["title"]
new_memo = body.memo if body.memo is not None else row["memo"]
conn.execute(
"UPDATE bookmarks SET title = ?, memo = ? WHERE id = ?",
(new_title, new_memo, bookmark_id)
)
conn.commit()
conn.close()
return {"id": bookmark_id, "title": new_title, "memo": new_memo}
@app.delete("/bookmarks/{bookmark_id}")
def delete_bookmark(bookmark_id: int, user=Depends(get_current_user)):
conn = get_db()
row = conn.execute("SELECT * FROM bookmarks WHERE id = ?", (bookmark_id,)).fetchone()
if not row:
conn.close()
raise HTTPException(status_code=404, detail="북마크를 찾을 수 없습니다")
if row["user_id"] != user["id"]:
conn.close()
raise HTTPException(status_code=403, detail="권한이 없습니다")
conn.execute("DELETE FROM bookmarks WHERE id = ?", (bookmark_id,))
conn.commit()
conn.close()
logger.info(f"북마크 삭제: id={bookmark_id}, user={user['email']}")
return {"deleted": bookmark_id}
프론트엔드 구현¶
login.html¶
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Bookmark App — 로그인</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="auth-container">
<h1>Bookmark App</h1>
<div id="login-form">
<h2>로그인</h2>
<input type="email" id="email" placeholder="이메일">
<input type="password" id="password" placeholder="비밀번호">
<button id="login-btn">로그인</button>
<p id="login-error" class="error"></p>
<p>계정이 없으신가요? <a href="#" id="show-signup">회원가입</a></p>
</div>
<div id="signup-form" style="display:none">
<h2>회원가입</h2>
<input type="text" id="signup-name" placeholder="이름">
<input type="email" id="signup-email" placeholder="이메일">
<input type="password" id="signup-password" placeholder="비밀번호 (8자 이상)">
<button id="signup-btn">가입하기</button>
<p id="signup-error" class="error"></p>
<p>이미 계정이 있으신가요? <a href="#" id="show-login">로그인</a></p>
</div>
</div>
<script src="auth.js"></script>
</body>
</html>
auth.js¶
const API = "http://localhost:8000";
// 이미 로그인되어 있으면 메인으로 이동
if (localStorage.getItem("token")) {
location.href = "index.html";
}
document.getElementById("login-btn").addEventListener("click", async () => {
const email = document.getElementById("email").value;
const password = document.getElementById("password").value;
try {
const res = await fetch(`${API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const err = await res.json();
document.getElementById("login-error").textContent = err.detail;
return;
}
const data = await res.json();
localStorage.setItem("token", data.access_token);
location.href = "index.html";
} catch {
document.getElementById("login-error").textContent = "서버 연결 오류";
}
});
// 폼 전환
document.getElementById("show-signup").addEventListener("click", (e) => {
e.preventDefault();
document.getElementById("login-form").style.display = "none";
document.getElementById("signup-form").style.display = "block";
});
index.html (핵심 구조)¶
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Bookmark App</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<header>
<h1>Bookmark App</h1>
<button id="logout-btn">로그아웃</button>
</header>
<section class="add-form">
<input type="url" id="url-input" placeholder="https://...">
<input type="text" id="title-input" placeholder="제목 (선택)">
<input type="text" id="memo-input" placeholder="메모 (선택)">
<button id="add-btn">추가</button>
</section>
<section id="bookmark-list">
<p id="loading">불러오는 중...</p>
</section>
</div>
<script src="app.js"></script>
</body>
</html>
테스트 작성¶
# test_bookmarks.py
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def get_token(email="[email protected]", password="password123"):
client.post("/auth/signup", json={
"email": email, "password": password, "name": "테스트"
})
res = client.post("/auth/login", json={"email": email, "password": password})
return res.json()["access_token"]
def auth_headers(token):
return {"Authorization": f"Bearer {token}"}
def test_create_and_get_bookmark():
token = get_token()
headers = auth_headers(token)
# 생성
res = client.post("/bookmarks", json={"url": "https://example.com"}, headers=headers)
assert res.status_code == 201
bm_id = res.json()["id"]
# 조회
res = client.get(f"/bookmarks/{bm_id}", headers=headers)
assert res.status_code == 200
assert res.json()["url"] == "https://example.com"
def test_cannot_delete_others_bookmark():
token_a = get_token("[email protected]")
token_b = get_token("[email protected]")
# A가 북마크 생성
res = client.post("/bookmarks",
json={"url": "https://a.com"},
headers=auth_headers(token_a)
)
bm_id = res.json()["id"]
# B가 삭제 시도 → 403
res = client.delete(f"/bookmarks/{bm_id}", headers=auth_headers(token_b))
assert res.status_code == 403
배포¶
Dockerfile¶
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY *.py ./
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Railway 배포 체크리스트¶
□ GitHub에 코드 push 완료
□ .env가 .gitignore에 포함
□ Railway 프로젝트 생성 및 저장소 연결
□ 환경 변수 설정:
SECRET_KEY = (무작위 강력한 값)
□ 배포 완료 확인
□ POST /auth/signup 호출 테스트
□ POST /auth/login 호출 테스트
□ 로그 확인
완성 후 개선 아이디어¶
태그 기능:
- tags 테이블 추가
- bookmark_tags 조인 테이블
- GET /bookmarks?tag=python 필터링
검색:
- GET /bookmarks?q=검색어
- url, title, memo에서 LIKE 검색
링크 미리보기:
- URL을 저장할 때 og:title을 자동으로 수집
- httpx로 URL 페이지를 요청, BeautifulSoup으로 파싱
즐겨찾기:
- bookmarks에 is_starred 컬럼 추가
- GET /bookmarks?starred=true 필터링
실습 미션¶
미션 1: 백엔드 구현¶
위 코드를 그대로 따라 구현하고:
1. uvicorn main:app --reload 로 서버를 실행하세요.
2. /docs 에서 모든 엔드포인트를 테스트하세요.
3. 회원가입 → 로그인 → 북마크 추가 → 조회 → 삭제 흐름을 확인하세요.
미션 2: 테스트 실행¶
테스트 파일을 작성하고 pytest로 실행하세요:
1. 위 test_create_and_get_bookmark 테스트 통과
2. test_cannot_delete_others_bookmark 테스트 통과
3. 로그인 실패 케이스 테스트 추가 후 통과
미션 3: 프론트엔드 구현¶
login.html + auth.js를 완성하고:
1. 회원가입 폼도 동작하게 만드세요.
2. index.html + app.js를 작성해 북마크 목록, 추가, 삭제가 되게 하세요.
3. 토큰이 없으면 login.html로 리다이렉트하세요.
미션 4 (심화): 기능 추가¶
다음 중 하나를 선택해 추가하세요:
A. 검색: GET /bookmarks?q=키워드
B. 즐겨찾기: is_starred 필드 + PATCH로 토글
C. 태그: tags 테이블 + 북마크별 태그 추가/조회
핵심 요약¶
| 단계 | 핵심 원칙 |
|---|---|
| 구현 시작 | DB → 인증 → CRUD 순서로 진행 |
| 의존성 주입 | Depends(get_current_user)로 인증 공통화 |
| 소유권 확인 | 수정/삭제 전 반드시 user_id 일치 확인 |
| 테스트 | 정상 케이스 + 403/404 케이스 모두 작성 |
| 배포 | 환경 변수 → Dockerfile → CI/CD |
기획에서 정한 것만 구현하세요.
추가 기능은 배포 후, 사용자 피드백을 본 다음에 결정하세요.