FastAPI를 이용한 웹 서비스 구현 연습_12

Frye 'de Bacon·2023년 10월 31일

본 시리즈는 '박응용' 님의 '점프 투 FastAPI'를 바탕으로 학습 및 실습한 내용을 정리한 것입니다.


구현 및 파인튜닝한 모델을 사용한 웹 서비스 구현을 위해 FastAPI의 학습 필요성을 느껴 학습 과정을 정리합니다. 내용의 정확성이나 이론적인 부분은 당연히 원본 페이지를 참조하시는 게 좋고, 본 시리즈에서는 구현 도중 발생하는 문제 등을 해결하는 과정을 함께 기록하여 '처음부터 끝까지 따라 할 수 있는' 시리즈를 만드는 것을 목표로 합니다(물론 제1목표는 학습 내용 기록입니다).


본래 스토어까지 구현하고 나면 날짜 표시, 회원가입 및 로그인 페이지, 게시물 수정 및 삭제 기능 등을 구현한다. 그러나 이는 유사한 과정의 반복이므로 굳이 별도의 내용을 정리할 필요는 없을 것으로 생각되어 별도로 정리하지는 않았다. 필요에 따라 원 출처를 따라 구현 실습을 진행하면 되겠다.

현재 게시물 '3-04 날짜 표시하기'~'3-11 게시물 수정과 삭제'까지 구현하였으며, 이후부터는 개인적으로 가장 부족한 부분이면서 중요한 요소인 git과 aws 활용 내용을 간략히 정리할 것이다. 단, '검색' 기능은 가장 어려운 부분이며, database와도 관련이 깊은 부분이므로 이 부분만 별도로 정리한다.


여기서 사용하는 코드는 SQL과는 차이가 있으므로 참고한다.

1. 조인, 아우터조인, 서브쿼리

조인

동일한 데이터로 연결된 두 모델을 함께 조회할 때 사용된다. 예를 들어 작성자 이름이 '홍길동'인 질문을 검색하려면 다음과 같은 절차가 필요할 것이다.

  • 절차 1. User 모델에서 username이 '홍길동'인 데이터의 id 조사
  • 절차 2. 절차 1에서 조사한 id와 Question 모델의 user_id가 같은 데이터인지 조사

절차대로 코드를 작성하면 다음과 같다.

user = db.query(User).filter(User.username == "홍길동").first()
db.query(Question).filter(Question.user_id == user.id)

이를 조인을 활용하면 다음과 같이 간단히 해낼 수 있다.

db.query(Question).join(User).filter(User.username == "홍길동")

상기 코드는 Question 모델과 User 모델을 join 함수로 조인한 것이다. 조인은 일종의 교집합 역할을 하며, 따라서 조인 후에는 filter 함수를 이용해 User.username이 "홍길동"인 Question 모델 데이터를 얻을 수 있다.

아우터조인

아우터조인은 조인보다 조금 더 복잡하다. 쉽게 이해할 수 있도록 practice_1의 가상환경이 실행된 터미널에서 파이썬을 실행하고 다음과 같이 코드를 입력해보자.

(practice_1) PS C:\workspace\fastapi_practice\practice_1> python
Python 3.11.4 (tags/v3.11.4:d2340ef, Jun  7 2023, 05:45:37) [MSC v.1934 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from database import SessionLocal
>>> db = SessionLocal()
>>> from models import Question, Answer

db 객체를 생성하고 Question 모델과 Answer 모델을 import했다. 여기서 Question 모델 데이터를 모두 검색해 보자.

>>> db.query(Question).count()
305

내 경우 현재 305개의 데이터가 있는 것으로 조회되었다. 같은 방법으로 Answer 모델의 데이터를 조회해 보자.

>>> db.query(Answer).count()
8

답변 데이터는 총 7개 존재한다.

이번엔 앞서 배운 조인을 사용해 보자. Question과 Answer를 조인한 이후의 데이터 개수는 다음과 같다.

>>> db.query(Question).join(Answer).count()
7

앞서 질문, 답변의 등록 및 삭제가 이루어졌기 때문에 답변의 총 개수와 조인 결과물의 개수가 다르지만(cascade 옵션을 사용하지 않았기 때문이다), '교집합'이라는 개념을 생각하면 상기 개수가 이해될 것이다.

만약 "파이썬'이라는 문자열을 포함하는 질문 혹은 답변을 검색하려고 한다면, 일반 조인을 사용할 수 없다. 조인을 사용할 경우 위에서 보았듯 답변이 없는 질문은 모두 제외되기 떄문이다. 그러나 질문에는 '파이썬'이 없어도 답변에는 '파이썬'이 포함된 질문도 조회할 수 있어야 하므로 조인을 사용하기는 해야 한다.

이럴 때 사용하는 것이 바로 아우터조인이다.
다음은 아우터조인을 사용한 결과이다.

>>> db.query(Question).outerjoin(Answer).count()
310

질문의 총 개수보다 많은 수가 조회되었다. 이는 하나의 질문에 여러 개의 답변이 달려 있을 수 있기 때문이다.
이 경우 질문 데이터가 중복되어 나타나게 된다. 따라서 중복을 제거하기 위해 distinct 함수를 사용한다.

>>> db.query(Question).outerjoin(Answer).distinct().count() 
305

상단의 '질문 데이터 개수'와 동일한 개수가 나오는 것을 확인할 수 있다. 이처럼 아우터조인을 사용하면 기준 모델(여기서는 Question)의 데이터가 조회 대상에서 제외되지 않는다. 동시에 조인 대상인 Answer 모델로 검색하는 것도 가능하다. 아래를 참고해 보자.

>>> db.query(Question).outerjoin(Answer).filter(Question.content.ilike("%파이썬%") | Answer.content.ilike("%파이썬%")).distinct().count()
0

서브쿼리

위에서 보았듯 조인이나 아우터조인을 이용하면 두 개 이상의 모델을 연결하여 데이터를 조회할 수 있다. 그러나 경우에 따라 모델 자체를 사용하기보다는 필요한 데이터만 모아서 조회하는 서브쿼리를 만들어 조회해야 할 때도 있다.

답변 작성자를 검색 조건에 포함시키는 경우를 생각해 보자. 답변 내용 검색은 Answer 모델을 Question 모델과 아우터조인하면 쉽게 해결할 수 있지만, 답변 작성자를 조건에 추가하려면 이미 Question 모델과 아우터조인한 Answer 모델을 User 모델과 다시 한 번 더 조인해야 한다. 상당히 복잡한 작업이 될 것을 예상할 수 있는데, 이럴 경우 서브쿼리를 사용하는 것이 가독성과 성능 면에서 유리하다.

서브쿼리는 다음과 같이 작성할 수 있다.

>>> from models import User
>>> sub_query = db.query(Answer.question_id, Answer.content, User.username).outerjoin(User, Answer.user_id == User.id).subquery()

이 서브쿼리는 답변 모델과 사용자 모델을 아우터조인하여 만든 것이다. 그리고 이 서브쿼리와 질문 모델을 연결할 수 있도록 질문 id에 해당하는 Answer.question_id도 조회 항목에 추가하였다. 이제 이 서브쿼리를 Question 모델과 아우터조인해 보자.

db.query(Question).outerjoin(sub_query, sub_query.c.question_id == Question.id).distinct()

여기서 sub_query.c.question_id에 사용한 c는 서브쿼리의 조회 항목을 의미한다. 즉, 서브쿼리의 조회 항목 중 question_id를 의미한다.
이제 서브쿼리를 아우터조인했으므로 sub_query의 조회 항목을 filter 함수에 조건으로 추가할 수 있다.

>>> db.query(Question).outerjoin(sub_query, sub_query.c.question_id == Question.id).filter(sub_query.c.content.ilike("%파이썬%") | sub_query.c.usern
ame.ilike("%파이썬%")).distinct()

서브쿼리까지 확인했으니 이제 본격적으로 검색 기능을 구현해 보자.


2. 검색 기능 만들기

질문 목록 CRUD

우선은 '검색어'가 질문 목록의 조회 조건에 반영될 수 있도록 question_crud.py 파일의 get_question_list 함수를 다음과 같이 수정한다.

...
from models import Question, User, Answer
...
def get_question_list(db: Session, skip: int = 0, limit: int = 10, keyword: str = ""):
    question_list = db.query(Question)
    if keyword:
        search = '%%{}%%'.format(keyword)
        sub_query = db.query(Answer.question_id, Answer.content, User.username).outerjoin(User, Answer.user_id == User.id).subquery()
        question_list = question_list.outerjoin(User).outerjoin(sub_query, sub_query.c.question_id == Question.id)\
        .filter(Question.subject.ilike(search) |  # 질문의 제목
                Question.content.ilike(search) |  # 질문의 내용
                User.username.ilike(serach) |  # 잘문 작성자
                sub_query.c.content.ilike(search) |  # 답변의 내용
                sub_query.c.username.ilike(search)  # 답변 작성자
                )
    total = question_list.distinct().count()
    question_list = question_list.order_by(Question.create_date.desc()).offset(skip).limit(limit).distinct().all()
    return total, question_list  # (전체 질문 개수, 페이징이 적용된 질문의 목록)

만약 전달받은 검색어(keyword)에 값이 있으면 그 값을 질문 제목과 내용, 질문 작성자, 답변 내용, 답변 작성자 항목에서 OR 조건으로 검색하게 했다.

질문 목록 라우터

이제 검색어를 추가로 입력받을 수 있도록 question_router.py 파일을 다음과 같이 수정한다.

...
@router.get('/list', response_model=question_schema.QuestionList)
def question_list(db: Session = Depends(get_db), page: int=0, size: int=10, keyword: str = ""):
    total, _question_list = question_crud.get_question_list(db, skip=page*size, limit=size, keyword=keyword)
    return {'total': total, 'question_list': _question_list}
...

입력 및 출력 항목에 keyword를 추가했다. FastAPI의 docs에서 키워드를 입력하여 질문 목록 조회가 잘되는지 확인해 보자.

검색 창

이제 검색어를 입력할 수 있도록 Home.svelte 파일을 다음과 같이 수정해 질문 목록 화면에 검색 창을 추가하자.

<script>
...
    let question_list = []
    let size = 10
    let total = 0
    let kw = ""
    $: total_page = Math.ceil(total/size)
  
    function get_question_list(_page) {
      let params = {
        page: _page,
        size: size,
        keyword: kw,
      }
      fastapi('get', '/api/question/list', params, (json) => {
        question_list = json.question_list
        $page = _page
        total = json.total
      })
    }
  
    $: get_question_list($page)
</script>

<div class="container my-3">
  <div class="row my-3">
    <div class="col-6">
      <a use:link href="/question-create" class="btn btn-primary {$is_login ? '' : 'disabled'}">질문 등록</a>
    </div>
    <div class="col-6">
      <div class="input-group">
        <input type="text" class="form-control" bind:value="{kw}">
        <button class="btn btn-outline-secondary" on:click={() => get_question_list(0)}>찾기</button>
      </div>
    </div>
  </div>
  <table class="table">
    <thead>
      <tr class="text-center table-dark">
        <th>번호</th>
        <th style="width:50%">제목</th>
        <th>글쓴이</th>
        <th>작성 일시</th>
      </tr>
    </thead>
    <tbody>
...
</ul>
  <!-- 페이징 처리 끝 -->
</div>

<table> 태그 위에 검색어를 입력할 수 있는 텍스트 창을 생성하고, 맨 밑에 있던 '질문 등록' 버튼은 검색 창의 좌측으로 이동시켰다. 검색어를 입력하면 다음과 같이 정상적으로 검색되는 것을 확인할 수 있다.

스토어 변수 사용하기

이전에 페이징 기능을 구현할 때와 마찬가지로, 검색 후 조회된 게시물을 클릭하여 상세 화면으로 들어간 다음 '목록으로' 버튼을 누르면 다시 전체 목록으로 돌아오게 된다. 이를 해결하기 위해 검색어 역시 스토어 변수로 설정하자.
우선 store.js 파일에 keyword 스토어 변수를 생성한다.

...
export const page = persist_storage("page", 0)
export const keyword = persist_storage("keyword", "")
export const access_token = persist_storage("access_token", "")
export const username = persist_storage("username", "")
export const is_login = persist_storage("is_login", false)

그리고 생성한 keyword 스토어 변수를 Home.svelte 파일에 입력한다.

<script>
    import fastapi from "../lib/api"
    import { link } from 'svelte-spa-router'
    import { page, keyword, is_login  } from '../lib/store'
    import moment from 'moment/min/moment-with-locales'

    moment.locale('ko')

    let question_list = []
    let size = 10
    let total = 0
    let kw = ''
    $: total_page = Math.ceil(total/size)
  
    function get_question_list() {
      let params = {
        page: $page,
        size: size,
        keyword: $keyword,
      }
      fastapi('get', '/api/question/list', params, (json) => {
        question_list = json.question_list
        total = json.total
        kw = $keyword
      })
    }
  
    $:$page, $keyword, get_question_list()
</script>

<div class="container my-3">
  <div class="row my-3">
    <div class="col-6">
      <a use:link href="/question-create" class="btn btn-primary {$is_login ? '' : 'disabled'}">질문 등록</a>
    </div>
    <div class="col-6">
      <div class="input-group">
        <input type="text" class="form-control" bind:value="{kw}">
        <button class="btn btn-outline-secondary" on:click={() => {$keyword = kw, $page = 0}}>찾기</button>
      </div>
    </div>
  </div>
  <table class="table">
    <thead>
      <tr class="text-center table-dark">
        <th>번호</th>
        <th style="width:50%">제목</th>
        <th>글쓴이</th>
        <th>작성 일시</th>
      </tr>
    </thead>
    <tbody>
      {#each question_list as question, i}
      <tr class="text-center">
        <td>{ total - ($page * size) -i}</td>
        <td class="text-start">
          <a use:link href="/detail/{question.id}">{question.subject}</a>
          {#if question.answers.length > 0}
          <span class="text-danger small mx-2">{question.answers.length}</span>
          {/if}
        </td>
        <td>{ question.user ? question.user.username : "" }</td>
        <td>{moment(question.create_date).format("YYYY년 MM월 DD일 hh:mm a")}</td>
      </tr>
      {/each}
    </tbody>
  </table>
  <!-- 페이징 처리 시작 -->
  <ul class="pagination justify-content-center">
    <!-- 이전 페이지 -->
    <li class="page-item {$page <= 0 && 'disabled'}">
      <button class="page-link" on:click="{() => $page--}">이전</button>
    </li>
    <!-- 페이지 번호 -->
    {#each Array(total_page) as _, loop_page}
    {#if loop_page >= $page-5 && loop_page <= $page+5}
    <li class="page-item {loop_page === $page && 'active'}">
      <button on:click="{() => $page = loop_page}" class="page-link">{loop_page+1}</button>
    </li>
    {/if}
    {/each}
    <!-- 다음 페이지 -->
    <li class="page-item {$page >= total_page-1 && 'disabled'}">
      <button class="page-link" on:click="{() => $page++}">다음</button>
    </li>
  </ul>
  <!-- 페이징 처리 끝 -->
</div>

크게 두 가지가 변화하였는데, 우선 get_question_list 함수에서 _page 매개변수를 없애고 $page, $keyword 등의 스토어 변수를 직접 사용하였다. 그리고 다음과 같이 $page와 $keyword를 반응형 변수로 설정하였다.

$:$page, $keyword, get_question_list()

위와 같이 '$: 변수1, 변수2, 자바스크립트식'과 같이 사용하면 스벨트는 변수 1 또는 변수 2의 값이 변경되는지를 감시하다가 값이 변경되면 자동으로 '자바스크립트식'을 실행한다. 따라서 $page 또는 $keyword의 값이 변경되면 자동으로 get_question_list() 함수가 실행될 것이다.
이에 따라 get_question_list 함수에서 _page 매개변수를 삭제하였으며($page 값이 수정되면 자동으로 get_question_list 함수가 실행되므로), '이전', '다음', '1, 2, 3, 4, 5' 등 페이지 이동을 위한 버튼들도 on:click 이벤트 발생 시 get_question_list 함수를 실행하는 대신 $page의 값을 변경하도록 수정했다.
'찾기' 버튼에도 동일한 방법을 적용하였으며, 찾기 버튼에 의한 검색 결과는 항상 첫 번째 페이지부터 보여야 하므로 $page의 값도 0으로 설정했다.

로고 동작 수정

이제 페이징 구현 당시 마주했던 문제가 또 하나 남아 있다. 바로 로고 클릭 시 스토어 변수가 사라지지 않는 문제이다. 현재는 당시와 마찬가지로 로고를 클릭해도 검색어가 사라지지 않는 상태이다. 이를 해결해 보자.
Navigation.svelte 파일을 다음과 같이 수정한다.

<script>
    import { link } from 'svelte-spa-router'
    import { page, keyword, access_token, username, is_login } from '../lib/store'
</script>

<!-- 내비게이션 바 -->
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
    <div class="container-fluid">
        <a use:link class="navbar-brand" href='/' on:click="{() => {$keyword = "", $page = 0}}">Practice_1</a>
...

이제 로고도 정상적으로 기능하는 것을 확인할 수 있다.


3. 프론트엔드 빌드

이제 마지막으로 프론트엔드 빌드를 해 보자. 보통 로컬 PC에서 프론트엔드 개발을 하려면 Node.js가 필요한데, 이는 Svelte 프레임워크로 작성한 프로그램을 실행하고 테스트하기 위한 환경이 필요하기 때문이다. 그러나 프론트엔드 개발이 완료된 시점이라면 더 이상 Node.js 서버는 필요하지 않다. 빌드 과정을 통해 필요한 자바스크립트 파일과 스타일시트 파일을 추출할 수 있기 때문이다.
이제 프론트엔드 프로젝트를 빌드하여 FastAPI에 필요한 파일을 만들고, FastAPI 서버에 해당 파일을 적용하여 본 프로젝트 게시판이 동작하도록 해 보자.

프론트엔드 빌드

스벨트 프레임워크로 작성한 코드를 빌드해 보자. frontend 터미널 창에서 npm run build 명령어를 실행한다.

(practice_1) PS C:\workspace\fastapi_practice\practice_1\frontend> npm run build

> frontend@0.0.0 build
> vite build

vite v4.5.0 building for production...
✓ 150 modules transformed.
dist/index.html                   0.45 kB │ gzip:   0.29 kB
dist/assets/index-4e5c0698.css  232.08 kB │ gzip:  30.70 kB
dist/assets/index-2e009113.js   413.80 kB │ gzip: 124.63 kB
✓ built in 2.17s

빌드가 완료되면 frontend 디렉토리 아래에 다음과 같은 3개의 파일이 생성된다.

  • dist/index.html : 인덱스 페이지(접속 시 보여줄 화면)
  • dist/assets/index-4e5c0698.css : index.html에서 참조하는 스타일시트 파일
  • dist/assets/index-2e009113.js : index.html에서 참조하는 자바스크립트 파일

FastAPI에 적용

이렇게 생성된 파일들을 FastAPI 서버가 서비스할 수 있도록 main.py 파일을 다음과 같이 수정한다.

from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import FileResponse
from starlette.staticfiles import StaticFiles

from domain.question import question_router
from domain.answer import answer_router
from domain.user import user_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)
app.include_router(user_router.router)
app.mount("/assets", StaticFiles(directory="frontend/dist/assets"))

@app.get('/')
def index():
    return FileResponse("frontend/dist/index.html")

'/' 경로로 접속하면 frontend/dist/index.html 파일을 읽어서 서비스할 수 있도록 index 함수를 추가했다. FileResponse는 FastAPI가 정적인 파일을 출력할 때 사용한다.
그리고 index.html 파일을 열어보면 다음처럼 js와 css 파일을 참조하는 것을 확인할 수 있다.
※index.html 파일은 확인만 하고 절대 수정하지 않는다.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Svelte</title>
    <script type="module" crossorigin src="/assets/index-2e009113.js"></script>
    <link rel="stylesheet" href="/assets/index-4e5c0698.css">
  </head>
  <body>
    <div id="app"></div>
    
  </body>
</html>

보면 'fastapi_practice\practice_1\frontend\dist\assets\index-2e009113.js' 파일을 '\assets\index-2e009113.js'와 같은 경로로 참조하는 것을 확인할 수 있다. 이러한 이유로 frontend/dist/assets 디렉토리를 /assets 경로로 매핑할 수 있도록 다음의 설정을 추가한 것이다.

app.mount("/assets", StaticFiles(directory="frontend/dist/assets"))

이제 FastAPI 서버를 구동하고 접속하면 프론트엔드 서버 없이도 페이지가 잘 동작하는 것을 확인할 수 있을 것이다.


여기까지 구현하고, 이후부터는 본 프로젝트를 깃으로 관리하고, 라이트세일을 통해 서빙하는 부분까지 진행할 것이다.

profile
AI, NLP, Data analysis로 나아가고자 하는 개발자 지망생

0개의 댓글