FastAPI로 Todo List 웹앱 만들기: CRUD와 Jinja2 템플릿

3/13/2025 ·impact

FastAPI로 Todo List 웹앱 만들기: CRUD와 Jinja2 템플릿

FastAPI로 웹 애플리케이션을 처음 만들 때 결과를 가장 빨리 보는 예제는 화면 하나짜리 Todo List입니다. 목록을 읽고 항목을 추가하고 지우는 것만으로 CRUD의 대부분을 다룹니다. 이 글은 가상 환경을 만드는 단계부터 Jinja2 템플릿 렌더링, JSON 파일을 저장소로 쓰는 CRUD 핸들러까지 main.py 하나로 돌아가는 예제를 정리한 것입니다.

FastAPI가 맡아주는 부분

FastAPI 공식 문서는 이 프레임워크를 "표준 파이썬 타입 힌트에 기반해 파이썬으로 API를 만드는 현대적이고 빠른 웹 프레임워크"로 정의합니다. 바닥부터 새로 짠 물건은 아닙니다. 같은 문서가 밝히듯 웹 처리는 Starlette, 데이터 처리는 Pydantic 위에 서 있습니다. 라우팅과 요청·응답 객체는 Starlette 쪽이고, 타입 힌트를 읽어 값을 검증하고 변환하는 일은 Pydantic 쪽입니다.

타입 힌트를 쓰는 방식이 이 프레임워크의 사용감을 결정합니다. FastAPI 문서는 파라미터와 바디의 타입을 함수 인자에 한 번만 선언하면 되고, 특정 라이브러리의 문법이나 클래스를 새로 배울 필요 없이 표준 파이썬 타입만 쓰면 된다고 적습니다.

여기에 딸려오는 것이 자동 문서입니다. FastAPI 문서는 Swagger UI가 제공하는 대화형 API 문서를 /docs에, ReDoc이 제공하는 문서를 /redoc에 붙인다고 설명합니다. 코드를 고치면 문서도 같이 갱신됩니다. 같은 앱에 JSON API를 하나라도 붙이면 이쪽이 먼저 눈에 들어옵니다.

프로젝트 설정과 첫 서버 띄우기

디렉터리를 만들고 가상 환경을 잡는 것부터 시작합니다.

mkdir TODO
cd TODO
python -m venv venv
source venv/bin/activate # Windows에서는 'venv\Scripts\activate'
pip install fastapi uvicorn jinja2 python-multipart

mkdir, cd, source 같은 명령이 손에 익지 않다면 리눅스 필수 명령어 20가지를 먼저 훑어도 좋습니다. 설치 목록에 python-multipart가 들어간 이유는 뒤에서 HTML 폼을 받기 때문입니다. FastAPI의 폼 데이터 문서는 폼을 쓰려면 먼저 python-multipart를 설치하라고 못을 박습니다. jinja2는 템플릿 렌더링용입니다.

서버 파일은 main.py 하나로 시작합니다.

from fastapi import FastAPI
app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello World"}

실행 명령은 다음과 같습니다.

uvicorn main:app --reload

Uvicorn은 파이썬용 ASGI 웹 서버 구현체입니다. main:app에서 앞의 main은 main.py 파일을, 뒤의 app은 그 안에서 app = FastAPI()로 만든 객체를 가리킨다고 FastAPI의 수동 실행 문서가 설명합니다. from main import app과 같은 뜻입니다. 이제 http://localhost:8000 에서 응답을 확인할 수 있고, 첫걸음 문서가 안내하는 대로 http://127.0.0.1:8000/docs 를 열면 Swagger UI 화면이 뜹니다.

--reload는 개발 중에만 붙이는 옵션입니다. 수동 실행 문서는 이 옵션이 자원을 훨씬 많이 쓰고 더 불안정하므로 프로덕션에서는 쓰면 안 된다고 경고합니다. 최근 문서는 개발 서버를 띄우는 명령으로 fastapi dev를 안내하지만, uvicorn을 직접 부르는 위 방식도 그대로 동작합니다.

Jinja2 템플릿으로 목록 화면 그리기

Todo 목록을 보여줄 HTML 파일을 templates 디렉터리에 둡니다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>TODO LIST</title>
</head>
<body>
    <nav class="navbar bg-body-tertiary">
        <div class="container-fluid">
            <h1 class="navbar-brand">My To Do list</h1>
        </div>
    </nav>
    <br>
    <table class="table">
        <tr>
            <th>ToDo</th>
            <th>Options</th>
        </tr>
        {% for id in tododict %}
        <tr>
            <td>{{ tododict[id] }}</td>
            <td><a href="/delete/{{ id }}"><button class="btn btn-danger">Delete</button></a></td>
        </tr>
        {% endfor %}
        <tr>
            <form method="post" action="/add">
                <td><input type="text" name="newtodo" required></td>
                <td style="text-align: center;"><button type="submit" class="btn btn-primary">Add New</button></td>
            </form>
        </tr>
    </table>
</body>
</html>

{% for id in tododict %} 블록이 핸들러에서 넘긴 딕셔너리를 돌면서 행을 찍습니다. 각 행의 Delete 버튼은 /delete/{id}로 가는 링크이고, 표 마지막 줄의 폼은 method="post"/add에 새 항목을 보냅니다.

렌더링 설정은 FastAPI의 템플릿 문서를 따릅니다. from fastapi.templating import Jinja2Templates로 가져와 Jinja2Templates(directory="templates")처럼 디렉터리를 지정합니다. Starlette의 템플릿 문서는 들어온 request 인스턴스가 반드시 템플릿 컨텍스트에 포함돼야 한다고 요구합니다. 아래 핸들러가 request를 따로 넘기는 이유입니다. 같은 문서는 템플릿 컨텍스트에 url_for 함수가 자동으로 들어가 앱 안의 다른 페이지로 링크를 걸 수 있다는 점도 함께 설명합니다. 위 HTML은 Bootstrap을 CDN에서 받아 오지만, 정적 파일을 직접 서빙할 때는 이 url_for로 경로를 잡습니다.

이스케이프 처리도 짚어둘 만합니다. Starlette 템플릿 문서는 directory 인자를 쓰면 .html, .htm, .xml 템플릿에 대해 jinja2.select_autoescape()로 autoescape가 기본 활성화되며 이것이 XSS를 막아준다고 밝힙니다. 사용자가 입력한 Todo 문자열에 스크립트 태그가 섞여 들어와도 문자 그대로 표시되는 것은 이 기본값 덕입니다.

인자 순서는 버전을 봐야 합니다. FastAPI 템플릿 문서는 현재 형태로 templates.TemplateResponse(request=request, name="item.html", context={"id": id})를 보여주면서, FastAPI 0.108.0과 Starlette 0.29.0 이전에는 name이 첫 번째 인자였고 그보다 더 전에는 request를 컨텍스트의 키-값 쌍으로 넘겼다고 설명합니다. 아래 예제는 현재 형태로 적었으니, 낮은 버전이 깔려 있다면 설치된 버전에 맞춰 고쳐야 합니다.

이 예제는 서버가 HTML을 완성해서 내려보냅니다. 화면을 별도 프런트엔드로 떼어내면 목록 상태를 클라이언트가 들고 있어야 하고, 그때부터는 React 상태 관리 Recoil 사용법에서 다룬 것처럼 상태 관리 라이브러리를 따로 골라야 합니다. 화면이 하나뿐인 지금은 템플릿으로 끝내는 쪽이 짧습니다.

database.json을 저장소로 쓰는 CRUD 핸들러

데이터는 database.json 파일에 담습니다. 읽기와 쓰기를 모두 이 파일 하나로 처리합니다.

from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
import json

app = FastAPI()
templates = Jinja2Templates(directory="templates")

@app.get("/")
async def root(request: Request):
    with open('database.json') as f:
        data = json.load(f)
    return templates.TemplateResponse(request=request, name="todolist.html", context={"tododict": data})

@app.get("/delete/{id}")
async def delete_todo(request: Request, id: str):
    with open('database.json') as f:
        data = json.load(f)
    del data[id]
    with open('database.json', 'w') as f:
        json.dump(data, f)
    return RedirectResponse("/", 303)

@app.post("/add")
async def add_todo(request: Request):
    with open('database.json') as f:
        data = json.load(f)
    formdata = await request.form()
    newdata = {str(len(data) + 1): formdata["newtodo"]}
    data.update(newdata)
    with open('database.json', 'w') as f:
        json.dump(data, f)
    return RedirectResponse("/", 303)

세 핸들러 모두 database.json을 열어 딕셔너리로 읽는 것으로 시작합니다. 삭제와 추가는 여기서 딕셔너리를 고친 뒤 파일을 다시 씁니다. 루트 핸들러는 읽은 딕셔너리를 tododict라는 이름으로 템플릿에 넘깁니다. 삭제는 경로 파라미터 {id}를 문자열로 받아 del data[id]로 지운 뒤 파일을 덮어쓰고, 추가는 폼 값을 읽어 기존 항목 수에 1을 더한 값을 새 키로 씁니다. 첫 요청이 들어오기 전에 database.json 파일이 있어야 하며, 내용은 {} 같은 JSON 객체여야 코드가 그대로 돌아갑니다.

폼을 읽는 줄은 await request.form()입니다. FastAPI 폼 문서는 폼 데이터가 보통 application/x-www-form-urlencoded 미디어 타입으로 인코딩되고, 파일이 섞이면 multipart/form-data가 된다고 설명합니다. JSON 바디와 인코딩 자체가 다르니 파싱 쪽 의존성이 따로 필요하고, 앞의 설치 목록에 python-multipart가 있었던 것도 이 때문입니다. 필드를 이름과 타입으로 못 박고 싶다면 FastAPI 0.113.0부터는 폼 모델 문서처럼 Pydantic 모델로 폼을 받을 수 있습니다.

응답은 두 핸들러 모두 RedirectResponse("/", 303)입니다. FastAPI의 커스텀 응답 문서는 RedirectResponse가 기본적으로 307 Temporary Redirect를 쓴다고 적습니다. 여기서 303을 직접 넘긴 데는 이유가 있습니다. MDN의 303 See Other 설명에 따르면 303으로 리다이렉트된 리소스를 가져올 때 메서드는 항상 GET이며, 이 상태 코드는 POST나 PUT의 결과로 확인 화면을 보여줄 때 자주 쓰입니다. POST /add를 처리한 뒤 브라우저를 GET /로 보내면, 새로고침해도 폼이 다시 제출되지 않습니다.

예제 밖으로 나갈 때 걸리는 지점

핸들러가 전부 async def인데 안에서는 open()json.load()로 파일을 동기적으로 읽습니다. FastAPI의 async 문서는 await를 지원하지 않는 라이브러리를 쓸 때 경로 동작 함수를 그냥 def로 선언하라고 안내하고, 일반 def로 선언하면 그 함수를 외부 스레드풀에서 실행하고 그 실행이 끝나기를 기다린다고 설명합니다. 같은 문서는 블로킹 I/O를 수행하는 코드가 들어 있지 않을 때 async def가 낫다고 정리합니다. 파일을 직접 여는 지금 코드는 def 쪽이 문서의 안내에 더 가깝습니다.

키를 만드는 방식도 항목 수에 기대고 있습니다. str(len(data) + 1)은 한 번도 지우지 않은 목록에서만 겹치지 않는 키를 만듭니다. 항목이 "1", "2" 두 개인 상태에서 "1"을 지우면 남는 것은 "2" 하나이고, 여기서 새로 추가하면 키가 다시 "2"가 되어 data.update가 기존 항목을 덮어씁니다. 삭제 쪽은 del data[id]를 바로 부르기 때문에 없는 id로 요청이 들어오면 KeyError가 그대로 올라갑니다. 혼자 쓰는 동안에도 Delete 링크를 두 번 누르면 바로 재현됩니다.

검증 계층도 아직 비어 있습니다. 이 예제에는 Pydantic 모델이 하나도 없고, 폼 값을 딕셔너리에서 바로 꺼내 파일에 넣습니다. 앞에서 본 타입 힌트 기반 검증을 실제로는 거치지 않는 셈입니다. 길이 제한이나 형식 검사를 넣으려면 폼 모델을 쓰거나, JSON API로 확장하면서 요청 바디를 BaseModel로 선언하는 방향으로 가야 합니다.

저장소가 파일 하나라는 점도 그대로 남습니다. 요청마다 전체를 읽고 전체를 덮어쓰는 구조라 항목이 늘면 그만큼 비용이 붙습니다. 쓰기가 겹쳐도 순서를 맞춰 주는 장치는 이 코드에 없습니다. 만든 앱을 늘 켜두고 싶다면 라즈베리파이4에 Ubuntu를 설치하고 SSH로 접속하는 방법을 참고해 작은 보드에 올려두는 선택지도 있습니다. 그 경우에도 --reload는 빼야 합니다.

FastAPI 공식 튜토리얼 확인하기

FastAPI 템플릿 문서TemplateResponse 인자 순서와 버전 차이를 확인할 때 FastAPI async 문서def 와 async def 중 무엇으로 선언할지 정할 때

출처: FastAPI / FastAPI First Steps / FastAPI Templates / FastAPI Form Data / FastAPI Form Models / FastAPI Custom Response / FastAPI Concurrency and async / await / FastAPI Run a Server Manually / Starlette Templates / Uvicorn / MDN 303 See Other