본 시리즈는 '박응용' 님의 '점프 투 FastAPI'를 바탕으로 학습 및 실습한 내용을 정리한 것입니다.
구현 및 파인튜닝한 모델을 사용한 웹 서비스 구현을 위해 FastAPI의 학습 필요성을 느껴 학습 과정을 정리합니다. 내용의 정확성이나 이론적인 부분은 당연히 원본 페이지를 참조하시는 게 좋고, 본 시리즈에서는 구현 도중 발생하는 문제 등을 해결하는 과정을 함께 기록하여 '처음부터 끝까지 따라 할 수 있는' 시리즈를 만드는 것을 목표로 합니다(물론 제1목표는 학습 내용 기록입니다).
앞서 질문 목록과 상세 내역을 조회하는 기능을 만들었다. 이번에는 질문에 대한 답변을 등록하고, 등록한 답변을 보여주는 기능을 만들어 보자.
[답변 등록 API 명세]
| API명 | URL | 요청 방법 | 설명 |
|---|---|---|---|
| 답변 등록 | api/answer/create/{question_id} | post | 질문에 대한 답변을 등록한다. |
[답변 등록 API의 입력 항목]
content : 등록할 답변의 내용
[답변 등록 API의 출력 항목]
없음
질문 목록과 마찬가지로 domain 디렉토리 하위에 answer 디렉토리를 생성하자. 역시 이 디렉토리에 answer_router.py, answer_schema.py, answer_crud.py 파일을 저장할 것이다.

우선 answer_schema.py 파일을 만들어 다음 내용을 입력하자.
from pydantic import BaseModel
class AnswerCreate(BaseModel):
content: str
답변 등록 시 사용할 스키마로 AnswerCreate 클래스를 선언하였으며, 등록 시 전달되는 파라미터는 content 하나이다. 디폴트값을 설정하지 않았으므로 필수로 입력되어야 하는 값이다. 그런데, ""와 같이 빈 문자열을 입력할 수도 있으므로 다음과 같이 코드를 수정해 답변에 빈 문자열을 허용하지 않도록 한다.
from pydantic import BaseModel, validator
class AnswerCreate(BaseModel):
content: str
@validator('content')
def not_empty(cls, v):
if not v or not v.strip():
raise ValueError("빈 값은 허용되지 않습니다.")
return v
@validator('content') 어노테이션을 적용한 not_empty 함수를 추가했다. 이 함수는 AnswerCreate 스키마에 content값이 저장될 때 실행되며, content의 값이 없거나 빈 값인 경우 "빈 값은 허용되지 않습니다."라는 오류가 발생하도록 했다.
답변 등록 API는 POST 방식으로, content라는 입력 항목이 있다. 답변 등록 라우터에서 이 값을 읽기 위해서는 반드시 content 항목을 포함하는 Pydantic 스키마를 통해 읽어야 하며, 스키마를 사용하지 않고 라우터 함수의 매개변수에 content: str 항목을 추가하여 그 값을 읽는 것은 불가능하다. 이는 get이 아닌 다른 방식(post, put, delete)의 입력값은 Pydantic 스키마로만 읽을 수 있기 때문이다(반대로 get 방식의 입력 항목은 Pydantic 스키마로 읽는 것이 불가하며, 각각의 입력 항목을 라우터 함수의 매개변수로 받아야 한다).
이는 다음의 규칙을 따르는 것이다.
- HTML 프로토콜의 URl에 포함된 입력값(URL parameter)은 라우터의 스키마가 아닌 매개변수로 읽는다(Path parameter, Query parameter).
- HTML 프로토콜의 Body에 포함된 입력값(payload)은 Pydantic 스키마로 읽는다(Request Body).
※ 마찬가지로 html 지식이 필요한 부분으로 보인다.
이제 답변 데이터를 데이터베이스에 저장하기 위한 answer_crud.py 파일을 만들고 다음과 같이 입력하여 답변을 등록하기 위한 create_answer 함수를 선언한다.
from datetime import datetime
from sqlalchemy.orm import Session
from domain.answer.answer_schema import AnswerCreate
from models import Question, Answer
def create_answer(db: Session, question: Question, answer_create: AnswerCreate):
db_answer = Answer(question=question,
content=answer_create.content,
create_date=datetime.now())
db.add(db_answer)
db.commit()
이제 답변 라우터 파일 answer_router.py를 만들어 답변 등록 API를 완성해 보자.
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from starlette import status
from database import get_db
from domain.answer import answer_schema, answer_crud
from domain.question import question_crud
router = APIRouter(
prefix="/api/answer",
)
@router.post("/create/{question_id}", status_code=status.HTTP_204_NO_CONTENT)
def answer_create(question_id: int,
_answer_create: answer_schema.AnswerCreate,
db: Session = Depends(get_db)):
# create answer
question = question_crud.get_question(db, question_id=question_id)
if not question:
raise HTTPException(status_code=404, detail="Question not found")
answer_crud.create_answer(db, question=question,
answer_create=_answer_create)
답변 등록을 처리할 answer_create 라우터 함수를 선언했다. 우선 입력과 출력을 보자.
입력을 담당하는 AnswerCreate 스키마에는 content 속성이 있으며, 프론트엔드에서 API 호출 시 파라미터로 전달한 content가 AnswerCreate 스키마에 자동으로 매핑된다.
출력의 경우 response_model을 사용하는 대신 'status_code=status.HTTP_204_NO_CONTENT'를 사용했다. 이처럼 리턴할 응답이 없을 경우 응답코드 204를 리턴하여 '응답 없음'을 나타낼 수 있다.
그리고 답변의 등록을 위해서는 question_id 값을 이용해 질문을 먼저 조회해야 한다(어느 질문에 대한 답변인지 알아야 하므로). 이때 해당하는 질문이 없을 경우 HTTPException을 발생시키며, 프론트엔드에는 "Question not found"라는 오류가 전달된다.
이제 작성한 라우터를 main.py에 등록한다.
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from domain.question import question_router
from domain.answer import answer_router
app = FastAPI()
origins = [
"http://localhost:5173",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(question_router.router)
app.include_router(answer_router.router)
답변 등록 API는 응답 결과가 없는 다소 특별한 API이다. 따라서 fastapi 함수도 일부 수정을 해야 한다. 현재 fastapi 함수는 응답 결과(json)가 있을 경우에만 success_callback을 실행하기 때문이다.
응답 상태 코드가 204인 경우에는 응답 결과가 없더라도 success_callback을 실행할 수 있도록 api.js에서 fastapi 함수를 다음과 같이 수정한다.
const fastapi = (operation, url, params, success_callback, failure_callback) => {
...
fetch(_url, options).then(response => {
if(response.status === 204) { // No content
if(success_callback) {
success_callback()
}
return
}
response.json().then(json => {
if(response.status >= 200 && response.status < 300) {
if(success_callback) {
success_callback(json)
}
}
else {
if(failure_callback) {
failure_callback(json)
}
else{
alert(JSON.stringify(json))
}
}
})
.catch(error => {
alert(JSON.stringify(error))
})
})
}
export default fastapi
response.status가 204인 경우 success_callback을 호출하고, 나머지 코드가 실행되지 않도록 return 처리한다. 이 경우 응답 결과가 없으므로 success_callback()과 같이 파라미터 없이 함수만 호출한다.
이제 답변을 등록할 수 있도록 질문 상세 화면을 수정한다. 질문 상세 화면에 답변을 입력하기 위한 텍스트 창(textarea)과 '답변 등록' 버튼을 생성하고, 이 버튼을 누르면 텍스트 창에 입력한 답변을 저장하도록 한다.
Detail.svelte 파일을 열어 질문 상세 화면에 답변을 저장하기 위한 form, textarea, input 엘리먼트를 추가한다.
<script>
import fastapi from "../lib/api"
export let params = {}
let question_id = params.question_id
let question = {}
let content = ""
function get_question() {
fastapi("get", "/api/question/detail/" + question_id, {}, (json) => {
question = json
})
}
get_question()
</script>
<h1>{question.subject}</h1>
<div>
{question.content}
</div>
<form method="post">
<textarea rows="15" bind:value={content}></textarea>
<input type="submit" value="답변 등록">
</form>
우선 content를 선언하고, 답변 등록을 위한 <form> 엘리먼트를 추가했다. textarea(크기는 15줄로 설정)에 답변 내용을 적고 submit 타입으로 만든 input 엘리먼트를 누르면("답변 등록" 버튼) 답변이 등록되어야 한다. 이때 텍스트 창에 작성한 내용은 content 변수와 연결되도록 bind:value={content}를 사용했다. 따라서 textarea에 값을 추가하거나 변경할 때마다 content의 값도 자동으로 변경된다.
이제 "답변 등록" 버튼을 누르면 답변 등록 API를 호출하도록 다음과 같이 수정하자.
<script>
import fastapi from "../lib/api"
export let params = {}
let question_id = params.question_id
let question = {}
let content = ""
function get_question() {
fastapi("get", "/api/question/detail/" + question_id, {}, (json) => {
question = json
})
}
get_question()
function post_answer(event) {
event.preventDefault()
let url = "/api/answer/create/" + question_id
let params = {
content: content
}
fastapi('post', url, params,
(json) => {
content = ''
get_question()
}
)
}
</script>
<h1>{question.subject}</h1>
<div>
{question.content}
</div>
<form method="post">
<textarea rows="15" bind:value={content}></textarea>
<input type="submit" value="답변 등록" on:click="{post_answer}">
</form>
"답변 등록" 버튼을 누르면 post_answer 함수가 실행되도록 on:click="{post_answer}" 속성을 추가했다. post_answer 함수는 textarea에 작성한 content를 파라미터로 답변 등록 API를 호출한다. 답변 등록이 성공하면 등록한 답변이 textarea에서 지워지도록 content에는 빈 문자열을 대입하였고, 새로운 결괏값을 반영하도록 get_question 함수를 실행한다.
※ event.PreventDefault()는 submit 버튼이 눌렸을 때 form이 자동으로 전송되는 것을 방지하기 위해 사용한다.
이제 텍스트 창에 적당한 내용을 입력하고 "답변 등록" 버튼을 눌러보자.

그런데 textarea의 내용만 사라질 뿐 특별한 변화가 나타나지는 않는다. 이는 아직 등록된 답변을 표시하는 기능이 구현되지 않았기 때문이다.
답변을 표시하려면 질문 상세 API의 출력 스키마를 수정해야 한다. 현재 질문 상세 API의 출력 항목에는 '답변'이 포함되어 있지 않기 때문이다. 먼저 answer_schema.py 파일을 열어 Answer 스키마를 추가하자.
import datetime
from pydantic import BaseModel, validator
class AnswerCreate(BaseModel):
content: str
@validator('content')
def not_empty(cls, v):
if not v or not v.strip():
raise ValueError("빈 값은 허용되지 않습니다.")
return v
class Answer(BaseModel):
id: int
content: str
create_date: datetime.datetime
class Config:
from_attributes = True
질문 상세 조회에 사용할 Answer 스키마를 추가하였다. Answer 스키마는 출력으로 사용할 답변 1건을 의미하며, 마찬가지로 조회한 모델의 속성을 매핑하기 위해 from_attributes = True로 설정하였다.
다음은 question_schema.py 파일을 다음처럼 수정한다.
import datetime
from pydantic import BaseModel
from domain.answer.answer_schema import Answer
class Question(BaseModel):
id: int
subject: str
content: str
create_date: datetime.datetime
answers: list[Answer] = []
class Config:
from_attributes = True
이처럼 Question 스키마에 Answer 스키마로 구성된 answers 리스트를 추가했다.
Answer 모델은 Questions 모델과 answers라는 이름으로 연결되어 있다. models.py에서 Answer 모델을 만들면서 Question 모델과 연결할 때 backref="answers"라고 속성을 지정했기 때문이다. 따라서 Question 스키마에도 answers라는 속성을 사용해야 등록된 답변들이 정확하게 매핑되며, 다른 이름을 사용하면 값이 채워지지 않게 된다.
이제 FastAPI의 docs 문서에서 질문에 달린 답변이 잘 표시되는지 확인해 보자.

이제 질문에 등록된 답변들을 화면에 표시해 보자. Detail.svelte 파일을 열어 질문 상세 화면을 다음과 같이 수정한다.
<script>
import fastapi from "../lib/api"
export let params = {}
let question_id = params.question_id
let question = {answers:[]}
let content = ""
function get_question() {
fastapi("get", "/api/question/detail/" + question_id, {}, (json) => {
question = json
})
}
get_question()
function post_answer(event) {
event.preventDefault()
let url = "/api/answer/create/" + question_id
let params = {
content: content
}
fastapi('post', url, params,
(json) => {
content = ''
get_question()
}
)
}
</script>
<h1>{question.subject}</h1>
<div>
{question.content}
</div>
<ul>
{#each question.answers as answer}
<li>{answer.content}</li>
{/each}
</ul>
<form method="post">
<textarea rows="15" bind:value={content}></textarea>
<input type="submit" value="답변 등록" on:click="{post_answer}">
</form>
먼저 question 변수의 초깃값을 {}에서 {answers:[]}로 변경한다. 이유는 등록된 답변을 표시하는 each 블록에서 question.answer를 참조하기 때문이다. 질문 상세 조회 API는 비동기로 진행되며, 따라서 아직 조회가 되지 않은 상태에서 each 블록이 실행될 경우 answers 항목이 존재하지 않아 오류가 발생한다.
이제 등록된 답변을 다음과 같이 화면에서 확인할 수 있다.

만약 textarea에 아무런 값을 넣지 않고 "답변 등록" 버튼을 누르면 다음과 같이 알림창이 나타난다.

이는 fastapi를 호출할 때 failure_callback 함수를 전달하지 않았기 때문이다. 이 오류 메시지를 보기 좋게 정리하여 화면에 표시해 보자.
지금까지 확인한 FastAPI의 오류 응답에는 2가지 유형이 있다. 첫 번째는 다음과 같은 필드 오류이다.
{
"detail": [
{
"loc": [
"body",
"content"
],
"msg": "빈 값은 허용되지 않습니다.",
"type": "value_error"
}
]
}
이 유형의 오류는 해당 필드명(content)과 오류의 내용을 표시할 수 있을 것이다.
두 번째는 다음과 같은 일반 오류이다.
{
"detail": "Question not found"
}
앞서 답변 등록 시 question_id와 매치되는 질문이 없을 경우 발생시켰던 HTTPException 오류가 이러한 일반 오류에 해당한다. 이 경우 오류의 내용만 표시할 수 있을 것이다.
이 2가지 유형의 오류를 화면에 표시할 수 있도록 해 보자.
오류를 표시할 Error 컴포넌트를 만들어 보자. 우선 src 디렉토리 아래에 Error 컴포넌트를 저장할 components 디렉토리를 생성하고, 그곳에 Error.svelte 파일을 생성한다.

생성한 파일에 다음 내용을 입력한다.
<script>
export let error // 전달받은 오류
</script>
{#if typeof error.detail === 'string'}
<ul>
<li>{error.detail}</li>
</ul>
{:else if typeof error.detail === 'object' && error.detail.length > 0}
<ul>
{#each error.detail as err, i}
<li>
<strong>{err.loc[1]}</strong> : {err.msg}
</li>
{/each}
</ul>
{/if}
Error 컴포넌트는 Error 컴포넌트를 호출하는 주체로부터 error를 전달받아 오류를 표시하는 컴포넌트이다. 따라서 Error 컴포넌트는 다음과 같은 형태로 호출해야 한다.
<Error error={{detail: "오류입니다."}}/>
Error 컴포넌트를 생성할 때 이처럼 error 속성을 지정하면 Error 컴포넌트에서는 다음과 같이 전달한 error 값을 읽을 수 있다.
export let error
그리고 전달받은 오류는 앞에서 확인한 2가지 유형을 처리하도록 했다. 오류의 detail 속성이 배열로 구성되었다면 이는 필드 오류이므로 해당 배열을 순회하면서 필드명과 필드 오류를 출력하도록 했고, detail 속성이 문자열인 경우 일반 오류이므로 오류의 내용만을 표시하도록 했다.
이제 질문 상세 화면에 오류를 표시할 수 있도록 Detail.svelte 화면을 다음과 같이 수정하자.
<script>
import fastapi from "../lib/api"
import Error from "../components/Error.svelte"
export let params = {}
let question_id = params.question_id
let question = {answers:[]}
let content = ""
let error = {detail:[]}
function get_question() {
fastapi("get", "/api/question/detail/" + question_id, {}, (json) => {
question = json
})
}
get_question()
function post_answer(event) {
event.preventDefault()
let url = "/api/answer/create/" + question_id
let params = {
content: content
}
fastapi('post', url, params,
(json) => {
content = ''
error = {detail:[]}
get_question()
},
(err_json) => {
error = err_json
}
)
}
</script>
<h1>{question.subject}</h1>
<div>
{question.content}
</div>
<ul>
{#each question.answers as answer}
<li>{answer.content}</li>
{/each}
</ul>
<Error error={error} />
<form method="post">
<textarea rows="15" bind:value={content}></textarea>
<input type="submit" value="답변 등록" on:click="{post_answer}">
</form>
오류가 발생할 경우 오류의 내용을 확인할 수 있도록 form 엘리먼트 바로 위에 Error 컴포넌트를 추가했다. error의 초깃값은 'error = {detail:[]}'와 같이 설정하여 detail 항목의 값이 비어 있도록 하였으며, 이후 post_answer 함수 호출 시 오류가 발생하면 다음 failure_callback이 실행되어 오류가 표시된다.
(err_json) => {
error = err_json
에러가 발생할 경우 err_json이 {detail:...} 형태로 전달되므로 error 변수에 오류 내용이 저장되고, 이 error 변수는 Error 컴포넌트와 연결되어 있으므로 오류가 표시된다.
그리고 오류가 발생한 뒤 다시 입력값을 조정하여 성공 거래가 발생하면 이전에 표시되었던 오류 메시지를 없애도록 거래 성공 시의 에러 변수를 다음과 같이 초기화하였다.
(json) => {
content = ''
error = {detail:[]}
get_question()
이제 아까와 같이 textarea에 아무런 값을 입력하지 않고 "답변 등록" 버튼을 눌러보자. 다음과 같이 오류 메시지가 표시된다.

이번 부분은 상당히 어려운 내용이 많았다. svelte가 아무리 간단한 언어라고 하더라도 html이나 javascript에 대한 기본적인 지식이 없을 경우 실제 서비스를 구현할 때는 상당한 어려움이 있을 듯하다. 우선 본 예시를 따라 하면서 전체적인 흐름에 익숙해지도록 하고, 이후 실제 서비스를 구현할 때에는 다른 참고 자료들을 최대한 찾아보는 것이 필요할 듯하다.