글쓰기와 POST
1. 이번 절의 목표
지금까지 서버는 주는 일만 했습니다. 이번 절부터 받는 일을 합니다. 브라우저가 서버에 글을 보내는 방법이 POST입니다.
지난 절에서 글쓰기 버튼을 누르면 Not Found가 났습니다. 글쓰기는 두 단계입니다.
- 글쓰기 화면을 보여 준다. 제목과 내용을 적는 칸이 있는 화면입니다.
- 적은 것을 서버가 받아서
data에 넣는다.
1번은 지금까지 하던 대로 주소에 함수를 붙이면 됩니다. 2번이 새롭습니다.
| 주소 | 방식 | 하는 일 |
|---|---|---|
/notice/write | GET | 글쓰기 화면을 보여 준다 |
/notice/write | POST | 적은 것을 받아 data에 넣고 목록으로 보낸다 |
같은 주소인데 방식이 다릅니다. GET은 "주세요", POST는 "받으세요"입니다. 지금까지 쓴 @app.get은 전부 "주세요"였습니다.
주소는 같아도 방식이 다르면 다른 함수가 붙습니다. 서버는 주소와 방식, 두 가지를 보고 함수를 고릅니다.
2. 글쓰기 화면
뼈대이니 손으로 칩니다. main.py에서 notice 함수와 notice_detail 함수 사이에 아래를 넣습니다. 위치가 중요합니다. 이유는 4번에서 봅니다.
# 글쓰기 화면
@app.get("/notice/write", response_class=HTMLResponse)
def write_form():
return f"""
{style}
<h1>글쓰기</h1>
<form action="/notice/write" method="post">
<input name="title" placeholder="제목을 입력하세요" required>
<textarea name="contents" rows="10" placeholder="내용을 입력하세요" required></textarea>
<button class="btn">등록</button>
<a href="/notice">취소</a>
</form>
"""
style 변수 안에 입력 칸 꾸미기도 한 줄 추가합니다. </style> 바로 위에 넣습니다.
/* 글쓰기 */
input, textarea { width: 100%; padding: 10px; margin-bottom: 12px;
border: 1px solid #ddd; border-radius: 6px; box-sizing: border-box; }
글쓰기 버튼을 누르면 화면이 나옵니다. 등록을 누르면 아직 Method Not Allowed가 나옵니다. 화면은 됐는데 받는 쪽이 없다는 뜻입니다.
<form>을 읽습니다.
action="/notice/write": 등록을 누르면 이 주소로 보냅니다.method="post": "받으세요" 방식으로 보냅니다.name="title",name="contents": 칸마다 이름표입니다. 서버는 이 이름으로 값을 꺼냅니다.required: 비워 두면 브라우저가 보내지 않습니다.
3. 받는 쪽
맨 위 import 두 줄을 아래처럼 바꿉니다. 새로 쓰는 것이 세 개 늘었습니다.
from datetime import date
from fastapi import FastAPI, Form
from fastapi.responses import HTMLResponse, RedirectResponse
그리고 write_form 함수 바로 아래에 받는 함수를 넣습니다.
# 글쓰기 처리
@app.post("/notice/write")
def write(title: str = Form(), contents: str = Form()):
data.append({'title': title, 'contents': contents, 'date': str(date.today())})
return RedirectResponse("/notice", status_code=303)
다섯 줄입니다. 글쓰기 화면에서 제목과 내용을 적고 등록을 누릅니다. 목록으로 돌아오고, 방금 쓴 글이 맨 위에 있습니다. 눌러 보면 내용도 들어 있습니다.
한 줄씩 읽습니다.
@app.post(...): 처음 나온post스티커입니다. 같은 주소라도get과post는 다른 함수에 붙습니다.title: str = Form(): 폼에서name="title"인 칸의 값을title에 담아 달라는 뜻입니다. 지난 장의id: int가 주소에서 값을 받았다면,Form()은 폼에서 값을 받습니다.data.append({...}): 리스트 끝에 딕셔너리 하나를 붙입니다. 게시물이 하나 늘었습니다.str(date.today()): 오늘 날짜를2026-09-19모양의 글자로 만듭니다.RedirectResponse("/notice", status_code=303): 화면을 돌려주는 대신 "이제/notice로 가세요"라고 브라우저에 알립니다. 브라우저는 알아서/notice를 다시 달라고 합니다. 글을 쓴 뒤 목록으로 돌아가는 것이 이 한 줄입니다.
파이썬 다시 보기: append
리스트.append(값)은 리스트 맨 뒤에 값을 하나 붙입니다. data에 게시물이 3개였다면 4개가 됩니다. len(data)도 4가 되어서 /notice/4가 열립니다. 함수는 안 바꿨는데 주소가 하나 늘어난 셈입니다.
4. 함정: 순서가 중요합니다
2번에서 write_form을 notice_detail 위에 넣으라고 했습니다. 아래에 넣으면 어떻게 되는지 직접 해 보세요. write_form 함수를 잘라서 파일 맨 아래로 옮기고 저장한 뒤 글쓰기 버튼을 누릅니다.
{"detail":[{"type":"int_parsing", ... "Input should be a valid integer" ...}]}
/notice/write를 달라고 했는데, FastAPI는 위에서부터 스티커를 훑다가 /notice/{id}를 먼저 만납니다. write를 {id} 자리에 넣어 보려다 정수가 아니라서 에러를 냅니다. /notice/write까지 내려가지 못합니다.
구체적인 주소는 변수가 있는 주소보다 위에 있어야 합니다. 이 함정은 AI도 자주 빠집니다. AI에게 "글쓰기 기능 추가해줘"라고 하면 새 함수를 파일 맨 아래에 붙이는 경우가 많고, 그러면 방금 본 에러가 납니다. 이 에러를 보고 "아, 순서 문제구나"라고 알아보는 것이 이 책이 말하는 이해입니다.
확인이 끝나면 write_form을 다시 notice_detail 위로 올려 둡니다.
5. 완성된 파일
여기까지 따라온 main.py의 전체 모습입니다. 자기 파일과 비교해 보세요.
from datetime import date
from fastapi import FastAPI, Form
from fastapi.responses import HTMLResponse, RedirectResponse
app = FastAPI()
data = [
{'title': 'fastapi 너무 재미있어요! 1', 'contents': '첫 번째 글입니다.', 'date': '2026-09-17'},
{'title': 'fastapi 너무 재미있어요! 2', 'contents': '두 번째 글입니다.', 'date': '2026-09-18'},
{'title': 'fastapi 너무 재미있어요! 3', 'contents': '세 번째 글입니다.', 'date': '2026-09-19'},
]
# 모든 페이지에서 같이 쓰는 꾸미기
style = """
<style>
body { max-width: 700px; margin: 40px auto; font-family: sans-serif; color: #333; }
a { color: #333; text-decoration: none; }
.top { display: flex; justify-content: space-between; align-items: center; }
.btn { display: inline-block; padding: 8px 16px; background: #2E6FF2;
color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
/* 게시판 목록 */
table { width: 100%; border-collapse: collapse; border-top: 2px solid #333; }
th { padding: 12px; background: #f5f5f5; border-bottom: 1px solid #ddd; }
td { padding: 12px; border-bottom: 1px solid #ddd; text-align: center; }
td.title { text-align: left; }
tr:hover td { background: #f0f5ff; }
/* 게시물 상세 */
.post { border-top: 2px solid #333; border-bottom: 1px solid #ddd; }
.post-head { padding: 16px; background: #f5f5f5; border-bottom: 1px solid #ddd; }
.post-head h2 { margin: 0 0 8px; }
.post-head span { color: #888; font-size: 14px; }
.post-body { padding: 24px 16px; min-height: 150px; line-height: 1.6; }
/* 글쓰기 */
input, textarea { width: 100%; padding: 10px; margin-bottom: 12px;
border: 1px solid #ddd; border-radius: 6px; box-sizing: border-box; }
</style>
"""
@app.get("/", response_class=HTMLResponse)
def index():
return "<h1>hello world</h1>"
@app.get("/about", response_class=HTMLResponse)
def about():
return "<h1>about 입니다</h1>"
# 게시판 목록
@app.get("/notice", response_class=HTMLResponse)
def notice():
rows = ''
# 최신 글이 위로 오도록 뒤에서부터 꺼내기
for id in range(len(data), 0, -1):
i = data[id - 1]
rows += f"""
<tr>
<td>{id}</td>
<td class="title"><a href="/notice/{id}">{i['title']}</a></td>
<td>{i['date']}</td>
</tr>"""
return f"""
{style}
<div class="top">
<h1>게시판</h1>
<a class="btn" href="/notice/write">글쓰기</a>
</div>
<table>
<tr>
<th width="60">번호</th>
<th>제목</th>
<th width="120">작성일</th>
</tr>
{rows}
</table>
"""
# 글쓰기 화면 (주의: /notice/{id}보다 위에 있어야 함)
@app.get("/notice/write", response_class=HTMLResponse)
def write_form():
return f"""
{style}
<h1>글쓰기</h1>
<form action="/notice/write" method="post">
<input name="title" placeholder="제목을 입력하세요" required>
<textarea name="contents" rows="10" placeholder="내용을 입력하세요" required></textarea>
<button class="btn">등록</button>
<a href="/notice">취소</a>
</form>
"""
# 글쓰기 처리
@app.post("/notice/write")
def write(title: str = Form(), contents: str = Form()):
data.append({'title': title, 'contents': contents, 'date': str(date.today())})
return RedirectResponse("/notice", status_code=303)
# 게시물 상세
@app.get("/notice/{id}", response_class=HTMLResponse)
def notice_detail(id: int):
if id < 1 or id > len(data):
return "존재하지 않는 게시물입니다."
post = data[id - 1]
return f"""
{style}
<h1>게시판</h1>
<div class="post">
<div class="post-head">
<h2>{post['title']}</h2>
<span>번호 {id} · 작성일 {post['date']}</span>
</div>
<div class="post-body">{post['contents']}</div>
</div>
<p><a class="btn" href="/notice">목록으로</a></p>
"""
100줄 남짓입니다. 이 중 여러분이 손으로 친 뼈대는 스티커와 함수 머리, data, append, RedirectResponse 정도이고 나머지 절반은 AI가 만든 HTML과 CSS입니다. 그런데 어느 줄이 무엇을 하는지는 전부 읽을 수 있습니다. 그것이 목표였습니다.
AI에게 물어보기
아래 코드에서 RedirectResponse의 status_code=303이 무슨 뜻이야? 303 대신 그냥 목록 HTML을 return 하면 안 되는 이유가 있어?
(main.py 붙여넣기)
아래 게시판에 글 삭제 기능을 추가하고 싶어. /notice/번호/delete 주소로. 함수를 어디에 넣어야 하는지 순서도 같이 알려줘.
(main.py 붙여넣기)
두 번째 프롬프트로 받은 코드가 파일 맨 아래에 붙어 왔다면, 4번에서 본 함정을 떠올리고 위치를 옮겨 보세요. 여러분이 AI를 고치는 첫 순간입니다.