본문 바로가기

DB 구성 및 static 파일 서빙

1. DB 구성

앞서 배웠던 SQLAlchemy를 사용하여 데이터베이스를 구성해보겠습니다. 지금까지 만든 블로그는 서버를 다시 시작하면 글이 모두 사라집니다. 4장에서 배운 SQLAlchemy를 붙여 데이터를 파일에 저장합니다. sqlalchemy 패키지는 05-1절에서 설치하였습니다. 혹시 설치가 되어있지 않다면 아래 명령어를 사용하여 설치해주세요.

pip install sqlalchemy

main.py 파일 전체를 아래 코드로 바꿉니다.

from collections.abc import Generator
from contextlib import asynccontextmanager
from datetime import date
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import create_engine, select
from sqlalchemy.orm import (
    DeclarativeBase,
    Mapped,
    Session,
    mapped_column,
    sessionmaker,
)

# ------------------------------------------------------------------
# 데이터베이스 설정
# ------------------------------------------------------------------
SQLALCHEMY_DATABASE_URL = "sqlite:///./blogs.db"

engine = create_engine(
    SQLALCHEMY_DATABASE_URL,
    connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(bind=engine)


class Base(DeclarativeBase):
    pass


# ------------------------------------------------------------------
# 데이터베이스 모델
# ------------------------------------------------------------------
class BlogModel(Base):
    __tablename__ = "blogs"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    title: Mapped[str] = mapped_column(index=True)
    content: Mapped[str]
    author: Mapped[str]
    created_at: Mapped[date]
    updated_at: Mapped[date]


# ------------------------------------------------------------------
# Pydantic 스키마
# ------------------------------------------------------------------
class BlogCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    content: str = Field(min_length=1)


class BlogUpdate(BlogCreate):
    pass


class Blog(BlogCreate):
    model_config = ConfigDict(from_attributes=True)

    id: int
    author: str
    created_at: date
    updated_at: date


# ------------------------------------------------------------------
# 의존성
# ------------------------------------------------------------------
def get_db() -> Generator[Session, None, None]:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


SessionDep = Annotated[Session, Depends(get_db)]


# ------------------------------------------------------------------
# 시작할 때 할 일
# ------------------------------------------------------------------
def seed_data() -> None:
    """데이터가 하나도 없을 때만 예시 글 3개를 넣습니다."""
    with SessionLocal() as db:
        if db.scalar(select(BlogModel).limit(1)) is not None:
            return

        db.add_all(
            [
                BlogModel(
                    title="Hello",
                    content="World",
                    author="admin",
                    created_at=date(2026, 1, 6),
                    updated_at=date(2026, 1, 6),
                ),
                BlogModel(
                    title="FastAPI",
                    content="Python",
                    author="admin",
                    created_at=date(2026, 1, 7),
                    updated_at=date(2026, 1, 7),
                ),
                BlogModel(
                    title="Django",
                    content="Python",
                    author="admin",
                    created_at=date(2026, 1, 8),
                    updated_at=date(2026, 1, 8),
                ),
            ]
        )
        db.commit()


@asynccontextmanager
async def lifespan(app: FastAPI):
    # 서버가 시작될 때 실행됩니다
    Base.metadata.create_all(bind=engine)
    seed_data()
    yield
    # 서버가 종료될 때 실행할 코드를 여기에 둡니다


# ------------------------------------------------------------------
# 앱과 미들웨어
# ------------------------------------------------------------------
app = FastAPI(title="위니브 블로그 API", lifespan=lifespan)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=False,
    allow_methods=["*"],
    allow_headers=["*"],
)


# ------------------------------------------------------------------
# 엔드포인트
# ------------------------------------------------------------------
def get_blog_or_404(db: Session, blog_id: int) -> BlogModel:
    blog = db.get(BlogModel, blog_id)
    if blog is None:
        raise HTTPException(status_code=404, detail="Blog not found")
    return blog


@app.get("/blogs", tags=["블로그"])
def read_blogs(db: SessionDep) -> list[Blog]:
    """최신 글이 위에 오도록 ID 역순으로 반환합니다."""
    return list(db.scalars(select(BlogModel).order_by(BlogModel.id.desc())).all())


@app.get("/blogs/{blog_id}", tags=["블로그"])
def read_blog(blog_id: int, db: SessionDep) -> Blog:
    return get_blog_or_404(db, blog_id)


@app.post("/blogs", status_code=status.HTTP_201_CREATED, tags=["블로그"])
def create_blog(blog_data: BlogCreate, db: SessionDep) -> Blog:
    today = date.today()
    blog = BlogModel(
        title=blog_data.title,
        content=blog_data.content,
        author="admin",
        created_at=today,
        updated_at=today,
    )
    db.add(blog)
    db.commit()
    db.refresh(blog)
    return blog


@app.put("/blogs/{blog_id}", tags=["블로그"])
def update_blog(blog_id: int, blog_data: BlogUpdate, db: SessionDep) -> Blog:
    blog = get_blog_or_404(db, blog_id)
    blog.title = blog_data.title
    blog.content = blog_data.content
    blog.updated_at = date.today()
    db.commit()
    db.refresh(blog)
    return blog


@app.delete("/blogs/{blog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["블로그"])
def delete_blog(blog_id: int, db: SessionDep) -> None:
    blog = get_blog_or_404(db, blog_id)
    db.delete(blog)
    db.commit()

1.1 lifespan으로 시작할 때 할 일 정하기

새로 나온 것은 lifespan입니다.

@asynccontextmanager
async def lifespan(app: FastAPI):
    Base.metadata.create_all(bind=engine)
    seed_data()
    yield


app = FastAPI(lifespan=lifespan)

yield를 기준으로 위는 서버가 시작될 때, 아래는 종료될 때 실행됩니다. get_db에서 봤던 구조와 같습니다.

여기서는 테이블을 만들고 예시 데이터를 넣습니다. 나중에는 캐시 연결, 백그라운드 작업 시작 같은 것도 여기에 둡니다.

@app.on_event("startup")을 쓴 코드를 봤다면

오래된 자료에서는 아래처럼 적습니다.

# 오래된 방식, 지금은 권장되지 않습니다
@app.on_event("startup")
async def startup():
    Base.metadata.create_all(bind=engine)

on_event 방식은 시작과 종료가 서로 다른 함수로 흩어져서, 시작할 때 연 것을 종료할 때 닫아야 하는 경우 짝을 맞추기 어렵습니다. lifespan은 하나의 함수 안에서 yield 위아래로 짝을 이룹니다.

1.2 초기 데이터를 조건부로 넣기

seed_data 함수는 데이터가 이미 있으면 아무것도 하지 않습니다.

if db.scalar(select(BlogModel).limit(1)) is not None:
    return

이 확인이 없으면 서버를 재시작할 때마다 같은 글이 3개씩 계속 쌓입니다. fastapi dev는 파일을 저장할 때마다 서버를 재시작하므로 금방 지저분해집니다.

1.3 스키마와 모델의 이름

클래스 이름을 눈여겨봐 주세요.

클래스정체상속
BlogModel데이터베이스 테이블Base
Blog응답으로 나가는 형태BaseModel

4장에서는 Item과 ItemPublic으로 나눴는데, 여기서는 테이블 쪽에 Model을 붙였습니다. 어느 쪽이든 상관없지만 프로젝트 안에서는 한 가지 방식으로 통일하는 것이 중요합니다. 두 방식이 섞이면 코드를 읽을 때마다 이게 어느 쪽인지 확인해야 합니다.

Blog에 model_config = ConfigDict(from_attributes=True)가 붙은 이유는 4장에서 설명한 대로입니다. SQLAlchemy 객체를 Pydantic 모델로 바꾸기 위해 필요합니다.

1.4 확인하기

서버를 실행하고 .http 파일이나 Swagger UI로 확인해보세요.

fastapi dev

글을 몇 개 만든 다음 서버를 껐다가 다시 켜보세요. 데이터가 그대로 남아 있습니다. 폴더에 blogs.db 파일이 생겼을 것이고, SQLite Viewer로 열어보면 내용을 확인할 수 있습니다.

2. static 파일 서빙

05-4 절에서는 MPA로 블로그를 구현하였습니다. 이렇게 하면 Live Server와 FastAPI 서버를 동시에 실행해야 하는 번거로움이 있습니다. 터미널 창도 두 개, 주소도 두 개입니다. 이를 해결하기 위해 FastAPI가 화면 파일까지 함께 제공하도록 바꿔보겠습니다.

2.1 폴더 정리

프로젝트 폴더에 static 폴더를 만들고, 앞 절에서 만든 화면 파일을 모두 옮깁니다.

05_blog
┣━ 📄main.py
┣━ 📄blogs.db
┗━ 📁static/
    ┣━ 📄index.html
    ┣━ 📄common.js
    ┣━ 📄blog_list.html
    ┣━ 📄blog_detail.html
    ┣━ 📄blog_create.html
    ┗━ 📄blog_edit.html

index.html은 새로 만드는 파일입니다. 주소 뒤에 아무것도 붙이지 않고 접속했을 때 목록으로 보내주는 역할을 합니다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="refresh" content="0; url=blog_list.html">
    <title>위니브 블로그</title>
</head>
<body>
    <p><a href="blog_list.html">블로그 목록으로 이동</a></p>
</body>
</html>

2.2 app.frontend()로 연결하기

main.py의 맨 아래에 아래 한 줄을 추가합니다.

app.frontend("/", directory="static")

이 한 줄이 하는 일은 아래와 같습니다.

  • static 폴더의 파일들을 / 아래에서 제공합니다. static/blog_list.html은 http://127.0.0.1:8000/blog_list.html이 됩니다.
  • API 경로를 먼저 확인하고, 일치하는 것이 없을 때만 파일을 찾습니다. 그래서 /blogs는 API로, /blog_list.html은 파일로 갑니다.
  • /docs, /redoc도 그대로 동작합니다.

app.mount와 StaticFiles를 쓴 코드를 봤다면

대부분의 자료에서는 아래 방식을 씁니다.

from fastapi.staticfiles import StaticFiles

app.mount("/static", StaticFiles(directory="static"), name="static")

이 방식도 여전히 동작하지만 불편한 점이 있습니다.

  • 주소에 /static/이 항상 붙습니다. http://127.0.0.1:8000/static/blog_list.html
  • static 폴더가 없으면 서버가 아예 시작되지 않아, 폴더를 만드는 코드를 따로 넣어야 합니다.
  • 없는 파일을 요청했을 때의 처리를 직접 정해야 합니다.

FastAPI 0.141부터 추가된 app.frontend()는 이 세 가지를 정리해줍니다. 특히 개발 중에 폴더가 없어도 경고만 출력하고 서버는 정상적으로 뜹니다.

2.3 API 주소 바꾸기

이제 화면과 API가 같은 주소에서 제공되므로, common.js의 API 주소를 비워둡니다.

// 화면과 API가 같은 서버에서 제공되므로 주소를 비워둡니다.
// fetch("/blogs")는 지금 페이지와 같은 출처로 요청을 보냅니다.
const API = "";

앞 절에서 API 주소를 상수 하나로 모아둔 덕분에 한 줄만 고치면 끝납니다. 페이지 사이 링크도 상대 경로로 적어두었기 때문에 고칠 것이 없습니다.

3. 실행

아래 명령으로 FastAPI 서버를 실행하여 블로그를 확인해보세요. 이제 Live Server로 실행할 필요는 없습니다.

fastapi dev

브라우저에서 http://127.0.0.1:8000/으로 접속하면 목록 페이지로 이동합니다. 모든 기능이 그대로 동작하는지 확인해보세요.

터미널 창 하나, 주소 하나로 정리되었습니다.

4. 서버가 하나가 된 뒤의 CORS

main.py에서 CORS 미들웨어를 주석 처리하고 서버를 다시 실행해보세요. 화면이 그대로 잘 동작합니다.

화면과 API의 출처가 같아졌기 때문입니다. http://127.0.0.1:8000에서 받은 페이지가 http://127.0.0.1:8000으로 요청을 보내니 브라우저가 막을 이유가 없습니다.

그렇다면 CORS 설정을 지워도 될까요. 상황에 따라 다릅니다.

상황CORS 필요 여부
지금처럼 FastAPI가 화면까지 제공필요 없음
프론트엔드 팀이 별도 서버로 배포필요함
모바일 앱이 API를 호출필요 없음 (브라우저가 아니므로)
외부 개발자에게 API를 공개필요함

이 책에서는 다음 절에서도 두 가지 구성을 모두 다룰 수 있도록 CORS 설정을 남겨두겠습니다. 주석을 다시 해제해주세요.

실제 서비스에서는 어떻게 하나요

React나 Vue로 만든 프론트엔드도 결국 빌드하면 HTML, CSS, JS 파일 묶음이 됩니다. 이 결과물을 app.frontend()로 서빙하면 지금과 똑같은 구성이 됩니다. FastAPI 공식 문서도 이 방식을 안내하고 있습니다.

다만 규모가 커지면 화면 파일은 CDN에, API는 API 서버에 나누어 두는 것이 성능상 유리해집니다. 그때는 다시 CORS 설정이 필요해집니다. 정답이 하나 있는 것이 아니라 상황에 따라 고르는 문제입니다.

5. 여기까지의 결과물

지금 시점에서 완성된 것은 아래와 같습니다.

  • 데이터가 SQLite에 영구적으로 저장됩니다.
  • 목록, 상세, 작성, 수정, 삭제가 모두 화면에서 동작합니다.
  • 서버 하나로 화면과 API가 모두 제공됩니다.
  • /docs에서 API 문서를 확인하고 테스트할 수 있습니다.

아직 없는 것은 사용자 개념입니다. 지금은 누구나 아무 글이나 수정하고 삭제할 수 있고, 작성자는 항상 admin으로 고정되어 있습니다. 다음 절에서 인증을 붙이겠습니다.

연습문제

  1. 목록 조회에 페이지네이션을 붙여보세요. skip과 limit 쿼리 매개변수를 받아 select(BlogModel).offset(skip).limit(limit)을 사용합니다.
  2. 제목으로 검색하는 기능을 추가하고, 목록 페이지에 검색창을 붙여보세요.
  3. blogs.db 파일을 지우고 서버를 다시 실행해보세요. 예시 글 3개가 다시 만들어지는지 확인해보세요.
  4. seed_data에서 데이터 존재 여부를 확인하는 부분을 지우고 서버를 두 번 재시작해보세요. 어떤 일이 벌어지는지 확인한 뒤 되돌리세요.
  5. .gitignore 파일을 만들고 blogs.db, venv/, __pycache__/를 넣어보세요.