1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
게시판

14 / 14

마무리

끝났어요. 오른쪽 주소칸에 /docs를 쳐 보세요.

FastAPI가 내 서버의 주소를 전부 읽어서 문서를 만들어 줬어요. 글 상세 GET, 글쓰기 POST, 삭제 DELETE까지. Try it out을 누르면 거기서 바로 요청을 보내 볼 수도 있어요. 코드 한 줄 안 썼는데 생기는 거라 FastAPI를 쓰는 큰 이유 중 하나예요.

게시판에 올리기

오늘 만든 블로그를 부스 게시판에 올려요. 실행 중인 블로그가 그대로 찍혀서 올라가고, 다른 사람이 만든 것도 구경할 수 있어요. 올린 뒤에 코드를 더 고쳤으면 다시 올리기를 누르면 돼요.

오늘 한 것

단계 배운 것
4 주소 하나에 함수 하나
5 HTML은 파일로, 서버는 보내 주기만
6 HTML 태그로 카드 짜기
7, 8 CSS 선택자, 변수, grid, hover
9 JS로 버튼에 동작 붙이기
10 서버 데이터를 for문으로 화면에
11 주소 변수, 404, 템플릿 상속
12 폼과 POST, 303 리다이렉트
13 fetch와 DELETE, JSON 응답

코드 가져가기

위의 다운로드를 누르면 지금 편집기의 파일이 zip으로 받아져요. 집에서 돌리려면

터미널
pip install fastapi uvicorn jinja2 python-multipart
uvicorn app:app --reload

브라우저에서 localhost:8000을 열면 돼요.

보너스

PUT으로 수정하기. 상세 페이지에 수정 버튼을 만들고 write.html을 재활용해 보세요. 서버 쪽은 이런 모양이에요.

app.py
@app.put("/posts/{post_id}")
def update_post(post_id: int, title: str = Form(), body: str = Form()):
    for post in posts:
        if post["id"] == post_id:
            post["title"] = title
            post["body"] = body
            return {"ok": True}
    raise HTTPException(status_code=404)

좋아요 버튼. POST /posts/{post_id}/like를 만들고 fetch로 눌러 보세요. likes에 1 더하고 돌려주면 돼요.

마크다운 본문. pip install markdown 하고 markdown.markdown(post["body"])를 넘기면 글을 마크다운으로 쓸 수 있어요. 템플릿에서는 {{ html | safe }}.

수고했어요. crack 부스에 와 줘서 고마워요.

이 단계 전체 코드
app.py
from datetime import date

from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

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

posts = [
    {
        "id": 1,
        "title": "FastAPI 첫 서버 띄우기",
        "summary": "uvicorn 한 줄이면 끝. 흰 화면에 Hello World가 뜨는 순간부터 시작.",
        "body": "터미널에 uvicorn app:app --reload 를 치면 서버가 뜬다. 브라우저에서 localhost:8000 을 열면 내가 return 한 글자가 그대로 보인다. 여기서부터 전부 시작이다.",
        "date": "2026년 10월 10일",
        "author": "sfadfe",
        "likes": 518,
        "image": "thumb1.svg",
    },
    {
        "id": 2,
        "title": "Jinja2로 HTML 분리하기",
        "summary": "app.py 안에 HTML을 문자열로 두면 금방 지저분해진다. templates 폴더로 꺼내자.",
        "body": "Jinja2Templates(directory='templates') 한 줄이면 HTML 파일을 따로 둘 수 있다. 중괄호 두 개 안에 파이썬 값을 넣으면 그 자리에 찍힌다. 파일이 나뉘니까 어디를 고칠지 바로 보인다.",
        "date": "2026년 10월 11일",
        "author": "aa_100",
        "likes": 523,
        "image": "thumb2.svg",
    },
    {
        "id": 3,
        "title": "fetch로 DELETE 보내기",
        "summary": "버튼 하나에 fetch 한 줄. 서버는 리스트에서 글을 빼고 200을 돌려준다.",
        "body": "주소창으로는 GET밖에 못 보낸다. DELETE 는 JS 의 fetch 로 보낸다. 서버 쪽은 @app.delete 로 받는다. 받으면 리스트에서 글을 빼고 ok 를 돌려준다.",
        "date": "2026년 10월 12일",
        "author": "nosmeri",
        "likes": 9,
        "image": "thumb3.svg",
    },
]


@app.get("/", response_class=HTMLResponse)
def home(request: Request):
    return templates.TemplateResponse(request, "index.html", {"posts": posts})


@app.get("/posts/{post_id}", response_class=HTMLResponse)
def post_detail(request: Request, post_id: int):
    for post in posts:
        if post["id"] == post_id:
            return templates.TemplateResponse(request, "post.html", {"post": post})
    raise HTTPException(status_code=404, detail="그런 글은 없어요")


@app.get("/write", response_class=HTMLResponse)
def write_form(request: Request):
    return templates.TemplateResponse(request, "write.html")


@app.post("/posts")
def create_post(
    title: str = Form(),
    author: str = Form(),
    summary: str = Form(),
    body: str = Form(),
):
    new_id = max((post["id"] for post in posts), default=0) + 1
    posts.append({
        "id": new_id,
        "title": title,
        "summary": summary,
        "body": body,
        "date": date.today().strftime("%Y년 %m월 %d일"),
        "author": author,
        "likes": 0,
        "image": f"thumb{new_id % 4 + 1}.svg",
    })
    return RedirectResponse(f"/posts/{new_id}", status_code=303)


@app.delete("/posts/{post_id}")
def delete_post(post_id: int):
    for post in posts:
        if post["id"] == post_id:
            posts.remove(post)
            return {"ok": True}
    raise HTTPException(status_code=404, detail="그런 글은 없어요")
templates/base.html
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>crack.log</title>
  <link rel="stylesheet" href="/static/style.css">
</head>
<body>
  <header>
    <a href="/">crack.log</a>
    <span>
      <button id="theme">☾</button>
      <a class="write" href="/write">새 글 작성</a>
    </span>
  </header>
  <main>
    {% block content %}{% endblock %}
  </main>
  <script src="/static/script.js"></script>
</body>
</html>
templates/index.html
{% extends "base.html" %}
{% block content %}
<h1>내 블로그</h1>
<div class="cards">
  {% for post in posts %}
  <article class="card">
    <a href="/posts/{{ post.id }}">
      <img class="thumb" src="/static/{{ post.image }}" alt="">
      <div class="body">
        <h2>{{ post.title }}</h2>
        <p>{{ post.summary }}</p>
        <span class="date">{{ post.date }}</span>
      </div>
    </a>
    <div class="foot">
      <span>by <b>{{ post.author }}</b></span>
      <span>♥ {{ post.likes }}</span>
    </div>
  </article>
  {% endfor %}
</div>
{% endblock %}
templates/post.html
{% extends "base.html" %}
{% block content %}
<article class="post">
  <h1>{{ post.title }}</h1>
  <p class="meta">{{ post.author }}, {{ post.date }}</p>
  <img class="cover" src="/static/{{ post.image }}" alt="">
  <p class="content">{{ post.body }}</p>
  <a class="back" href="/">목록으로</a>
  <button class="delete" data-id="{{ post.id }}">삭제</button>
</article>
{% endblock %}
templates/write.html
{% extends "base.html" %}
{% block content %}
<form class="editor" action="/posts" method="post">
  <input name="title" placeholder="제목" required>
  <input name="author" placeholder="닉네임" required>
  <input name="summary" placeholder="한 줄 요약" required>
  <textarea name="body" placeholder="본문" rows="12" required></textarea>
  <button type="submit">출간하기</button>
</form>
{% endblock %}
static/style.css
:root {
  --bg: #f8f9fa;
  --panel: #ffffff;
  --text: #212529;
  --muted: #868e96;
  --line: #e9ecef;
  --mint: #12b886;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  font-family: -apple-system, "Segoe UI", "Noto Sans KR", "Malgun Gothic", sans-serif;
  background: var(--bg);
  color: var(--text);
}

header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 32px;
  background: var(--panel);
  border-bottom: 1px solid var(--line);
}

header a {
  font-size: 22px;
  font-weight: 800;
  color: var(--mint);
  text-decoration: none;
}

main {
  max-width: 1000px;
  margin: 0 auto;
  padding: 32px;
}

h1 { font-size: 22px; }

.card {
  background: var(--panel);
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}

.card a { color: inherit; text-decoration: none; }
.card .thumb { display: block; width: 100%; height: 160px; object-fit: cover; }
.card .body { padding: 16px; }
.card h2 { margin: 0 0 8px; font-size: 16px; }
.card p { margin: 0 0 12px; font-size: 14px; line-height: 1.5; color: var(--muted); }
.card .date { font-size: 12px; color: var(--muted); }

.card .foot {
  display: flex;
  justify-content: space-between;
  padding: 10px 16px;
  border-top: 1px solid var(--line);
  font-size: 12px;
  color: var(--muted);
}

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 24px;
}

.card {
  transition: transform 0.25s, box-shadow 0.25s;
}

.card:hover {
  transform: translateY(-8px);
  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.12);
}

.dark {
  --bg: #121212;
  --panel: #1e1e1e;
  --text: #ececec;
  --muted: #a0a0a0;
  --line: #2a2a2a;
}

#theme {
  font-size: 20px;
  background: none;
  border: none;
  color: var(--text);
  cursor: pointer;
}

.post { max-width: 720px; margin: 0 auto; }
.post h1 { font-size: 36px; margin-bottom: 8px; }
.post .meta { color: var(--muted); margin-bottom: 24px; }
.post .cover { width: 100%; border-radius: 8px; }
.post .content { font-size: 17px; line-height: 1.8; }
.post .back { color: var(--mint); text-decoration: none; }

.write {
  margin-left: 12px;
  padding: 6px 16px;
  border: 1px solid var(--text);
  border-radius: 16px;
  font-size: 14px;
  font-weight: 700;
  color: var(--text);
  text-decoration: none;
}

.editor { max-width: 720px; margin: 0 auto; display: grid; gap: 12px; }
.editor input, .editor textarea {
  padding: 12px;
  font: inherit;
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: 4px;
}
.editor button {
  padding: 10px;
  font: inherit;
  font-weight: 700;
  color: #fff;
  background: var(--mint);
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.delete {
  margin-left: 12px;
  padding: 6px 12px;
  font: inherit;
  color: var(--muted);
  background: none;
  border: 1px solid var(--line);
  border-radius: 4px;
  cursor: pointer;
}
.delete:hover { color: #fa5252; border-color: #fa5252; }
static/script.js
const button = document.getElementById("theme");

function apply(dark) {
  document.body.classList.toggle("dark", dark);
  button.textContent = dark ? "☀" : "☾";
}

apply(localStorage.getItem("theme") === "dark");

button.addEventListener("click", () => {
  const dark = !document.body.classList.contains("dark");
  localStorage.setItem("theme", dark ? "dark" : "light");
  apply(dark);
});

const deleteButton = document.querySelector(".delete");

if (deleteButton) {
  deleteButton.addEventListener("click", async () => {
    if (!confirm("정말 삭제할까요?")) return;
    const res = await fetch("/posts/" + deleteButton.dataset.id, { method: "DELETE" });
    if (res.ok) location.href = "/";
  });
}
준비
localhost:8000 ↗