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

Frye 'de Bacon·2023년 10월 30일

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


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


이제 기초적인 기능을 만들었으므로 실제 상용 게시판 수준까지 본격적으로 다듬어 보자.

1. 내비게이션 바

우선 부트스트랩 컴포넌트를 활용해 화면 최상단에 위치하는 내비게이션 바를 만들어 보자.

내비게이션 바 컴포넌트 작성하기

'frontend/src/components/' 디렉토리에 Navigation.svelte 파일을 만들고 다음과 같이 코드를 작성한다.

<script>
    import { link } from 'svelte-spa-router'
</script>

<!-- 내비게이션 바 -->
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
    <div class="container-fluid">
        <a use:link class="navbar-brand" href='/'>Practice_1</a>
        <button
            class="navbar-toggler"
            type="button"
            data-bs-toggle="collapse"
            data-bs-target="#navbarSupportedContent"
            aria-controls="navbarSupportedContent"
            aria-expanded="false"
            aria-label="Toggle navigation">
            <span class="navbar-toggler-icon" />
        </button>
        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                <li class="nav-item">
                    <a use:link class="nav-link" href="/user-create">회원가입</a>
                </li>
                <li class="nav-item">
                    <a use:link class="nav-link" href="/user-login">로그인</a>
                </li>
            </ul>
        </div>
    </div>
</nav>

우선 내비게이션 바에 질문 목록('/')으로 이동할 수 있는 practice_1 로고(클래스값 navber-brand)를 좌측에 배치하고, 오른쪽으로 '회원가입'과 '로그인' 링크를 추가했다.
회원가입(/user-create)과 로그인(/user-login)은 경로만 설정해 두었고, 해당 기능은 추후 구현할 것이다. 따라서 현재는 해당 링크를 누르면 빈 화면만 나타나게 된다.

내비게이션 바 표시하기

이제 모든 페이지에 내비게이션 바가 보이도록 'frontend/src' 디렉토리 내 App.svelte 파일에 Navigation 컴포넌트를 추가한다.

<script>
  import Router from 'svelte-spa-router'
  import Home from "./routes/Home.svelte"
  import Detail from "./routes/Detail.svelte"
  import QuestionCreate from "./routes/QuestionCreate.svelte"
  import Navigation from './components/Navigation.svelte';
  
  const routes = {
    '/': Home,
    '/detail/:question_id': Detail,
    '/question-create': QuestionCreate,
  }
</script>
<Navigation />
<Router {routes}/>

이제 질문 목록 페이지에 접속하면 최상단에 다음과 같이 내비게이션 바가 나타나게 되고, Practice_1 로고를 누르면 메인 페이지로 돌아갈 수 있게 된다. 그리고 만약 페이지의 크기를 줄이면 회원가입과 로그인 메뉴가 사라지면서 햄버거 메뉴가 나타나는, 반응형 웹도 적용되어 있다.


2. 게시판 페이징

임시 질문 데이터 생성

현재 질문 목록은 별도의 페이징 기능이 구현되어 있지 않다. 따라서 질문 게시물이 다량으로 작성되면 해당 게시물을 하나의 페이지에 그대로 표시하게 되며, 이 경우 스크롤이 길어져 불편함을 야기한다. 이를 방지하기 위하여 페이징 기능은 필수적이다.

우선 페이징 테스트를 위한 데이터를 300개 생성한다. 터미널을 열고 practice_1 가상환경을 실행한 뒤 파이썬 셸을 실행한다.

(practice_1) 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
>>> from models import Question
>>> from datetime import datetime
>>> db = SessionLocal()
>>> for i in range(300):
...     q = Question(subject="테스트 데이터입니다.:[%30d]" % i, content="내용 없음", create_date=datetime.now())
...     db.add(q)
...
>>> db.commit()
>>>

잘 진행했다면 질문 목록에 300개의 질문이 추가된 것읗 확인할 수 있다. 그리고 페이징이 되어 있지 않아 길어진 스크롤도 확인 가능하다.

질문 목록 API 수정

페이징을 적용하기 위해서는 질문 목록 API의 입출력 항목을 다음과 같이 수정해야 한다.

  • 입력 항목 : page(페이지 번호), size(한 페이지에 보여줄 게시물 개수)
  • 출력 항목 : total(전체 게시물 개수), question_list(질문 목록)

※ 현재 입력 항목은 없고, 출력 항목은 '질문 목록'만 존재한다.

우선 question_crud.py 파일의 get_question_list 함수를 다음과 같이 수정한다.

...
def get_question_list(db: Session, skip: int = 0, limit: int = 10):
    _question_list = db.query(Question).order_by(Question.create_date.desc())
    total = _question_list.count()
    question_list = _question_list.offset(skip).limit(limit).all()
    return total, question_list  # (전체 질문 개수, 페이징이 적용된 질문의 목록)
...

기존의 get_question_list에 skip과 limit이라는 매개변수를 추가했다. skip은 조회한 데이터의 시작 위치를, limit은 시작 위치부터 가져올 게디어틔 건수를 의미한다. 즉, 300개의 데이터 중 21~30번째 데이터를 가져오려면 skip은 20을, limit은 10을 전달하면 된다. 그리고 전체 건수와 페이징이 적용된 질문 목록을 별도로 조회하여 리턴한다.
※ 이때, 전체 건수(total)는 offset과 limit을 적용하기 전에 먼저 구해야 한다. 페이징이 적용된 질문 목록에 count() 한수를 적용하면 limit 값인 10이 리턴될 것이다.

이제 질문 목록의 출력 항목이 total(전체 게시물 개수)과 question_list(질문 목록 데이터)로 변경되었으므로 질문 목록 API의 응답으로 사용할 스키마를 다음과 같이 새로 작성해야 한다. question_schema.py 파일을 열어 다음 코드를 추가한다.

...
class QuestionList(BaseModel):
    total: int = 0
    question_list: list[Question] = []

total과 question_list를 포함하는 QuestionList 스키마를 작성했다.

다음 질문 목록 라우터(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):
    total, _question_list = question_crud.get_question_list(db, skip=page*size, limit=size)
    return {'total': total, 'question_list': _question_list}
...

question_list 함수에 페이지 번호(page)와 한 페이지에서 보여줄 게시물의 개수(size) 매개변수를 추가했다. page 번호는 0부터 시작하므로 page*size의 값을 skip에 대입할 수 있다. 즉, size를 10으로 설정할 경우 page가 0이면 skip은 0이 되고, page가 1인 경우 skip은 10이 된다.
그리고 출력 항목에 전체 건수를 추가하기 위해 response_model을 QuestionList 스키마로 변경했다. 출력 스키마가 변경되었으므로 리턴값은 QuestionList의 속성과 매핑되는 딕셔너리를 만들어 리턴한다.

여기까지 왔으면 FastAPI의 docs 문서에서 변경한 API의 정상 동작 여부를 확인하자.

화면에 페이징 적용

질문 목록 API의 입출력 항목이 변경되었으므로 Home.svelte 파일도 변경해야 한다.

우선 질문 목록 출력 코드를 다음과 같이 수정한다.

<script>
    import fastapi from "../lib/api"
    import { link } from 'svelte-spa-router'

    let question_list = []
  
    function get_question_list() {
      fastapi('get', '/api/question/list', {}, (json) => {
        question_list = json.question_list
      })
    }
  
    get_question_list()
</script>
...

질문 목록 API의 출력 항목이 리스트가 아닌 딕셔너리 형태로 바뀌었고, 질문 목록 데이터가 "question_list"라는 이름으로 전달된다. 따라서 success_callback 함수에서 json 대신 json.question_list를 사용해야 한다.

이제 질문 목록 페이지에 접속하면 300건이 넘는 데이터가 한번에 표시되는 대신 페이징 기능으로 한 페이지당 10건씩만 출력되는 것을 확인할 수 있다.

이제 원하는 페이지로 이동할 수 있도록 페이지 리스트를 추가해 보자. 계속해서 Home.svelte 파일을 수정한다.

<script>
    import fastapi from "../lib/api"
    import { link } from 'svelte-spa-router'

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

<div class="container my-3">
  <table class="table">
    ...
  </table>
  <!-- 페이징 처리 시작 -->
  <ul class="pagination justify-content-center">
    <!-- 이전 페이지 -->
    <li class="page-item {page <= 0 && 'disabled'}">
      <button class="page-link" on:click="{() => get_question_list(page-1)}">이전</button>
    </li>
    <!-- 페이지번호 -->
    {#each Array(total_page) as _, loop_page}
    <li class="page-item {loop_page === page && 'active'}">
      <button on:click="{() => get_question_list(loop_page)}" class="page-link">{loop_page+1}</button>
    </li>
    {/each}
    <!-- 다음페이지 -->
    <li class="page-item {page >= total_page-1 && 'disabled'}">
      <button class="page-link" on:click="{() => get_question_list(page+1)}">다음</button>
    </li>
  </ul>
  <!-- 페이징 처리 끝 -->
  <a use:link href="/question-create" class="btn btn-primary">질문 등록하기</a>
</div>

코드 하나하나를 찬찬히 뜯어보자.
우선 질문 목록 API를 호출하는 함수인 get_question_list에 _page 매개변수를 추가했다. 페이지 이동을 위해서는 페이지 번호를 입력으로 받아 질문 목록 API를 호출해야 하기 때문이다. 또한 이 함수는 페이징 처리를 위해 필요한 total 변수도 함께 받아온다. 그리고 page와 size 변수를 이용해 전체 페이지의 개수를 의미하는 total_page 변수도 선언한다.
※ Math.ceil 함수는 소수값이 존재할 때 값을 '올려서' 정수로 만드는 함수이다. 한 페이지에 표시할 게시물의 수가 10개이고 게시물의 총 개수가 11개라면 총 페이지 수는 2개여야 한다(1개가 되면 마지막 1개의 게시물을 표시하지 못하므로).

그리고 total_page 변수 앞에는 $: 기호가 붙어 있는데, 스벨트에서 변수 앞에 $: 기호를 붙이면 해당 변수는 반응형 변수가 된다. 즉, total 변수의 값이 API 호출로 인하여 변하게 되면 total_page의 변수도 실시간으로 재계산된다는 의미이다.

그리고 </table> 태그 밑으로 페이지 이동을 위한 HTML 코드들을 추가했다.
우선 page의 값이 0보다 작거나 같은 경우 '이전' 링크는 비활성화되도록 했다. {page <= 0 && 'disabled'}는 page가 0보다 작거나 같은 경우 disabled 속성을 적용하라는 의미이다.
'다음' 페이지의 경우에도 현재 페이지가 전체 페이지의 개수보다 크거나 같은 경우 '다음' 링크가 비활성화되도록 하였다.
그리고 페이지 리스트를 루프로 돌면서 해당 페이지로 표시할 수 있는 버튼 링크를 생성하였고, 이때 반복되는 페이지 번호가 현재의 페이지 번호와 같은 경우 activate 클래스를 적용하여 강조되도록 하였다.
이렇게 사용된 주요 페이징 기능을 정리하면 다음과 같다.

페이징 기능코드
이전 페이지가 없으면 비활성{page <= 0 && 'disabled'}
이전 페이지 번호page-1
다음 페이지가 없으면 비활성{page >= total_page-1 && 'disabled'}
다음 페이지 번호page+1
페이지 리스트 루프{#each Array(total_page) as _, loop_page}
현재 페이지와 같으면 활성화{loop_page === page && 'active'

그리고 페이지 리스트를 보기 좋게 표시하고자 부트스트랩의 pagination 컴포넌트(pagination, page-item, page-link 등)를 이용하였다.

여기까지 구현하고 나면 다음과 같이 페이지 수가 표시되는 것을 볼 수 있다.

그런데 한 가지 문제가 보인다. 바로 존재하는 모든 페이지가 표시된다는 점이다. 지금이야 31개이니 괜찮지만, 질문 데이터의 개수가 늘어나면 문제가 될 것이다.

이를 해결하기 위해 Home.svelte 파일을 다음과 같이 수정하자.

...
 <!-- 페이징 처리 시작 -->
  <ul class="pagination justify-content-center">
    <!-- 이전 페이지 -->
    <li class="page-item {page <= 0 && 'disabled'}">
      <button class="page-link" on:click="{() => get_question_list(page-1)}">이전</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="{() => get_question_list(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="{() => get_question_list(page+1)}">다음</button>
    </li>
  </ul>
  <a use:link href="/question-create" class="btn btn-primary">질문 등록하기</a>
</div>     
  <!-- 페이징 처리 끝 -->

페이지 리스트를 출력하는 루프 내에 다음 코드를 삽입하여 페이지 표시 제한 기능을 구현하였다.

{#if loop_page >= page-5 && loop_page <= page+5}
...
{/if}

위 코드는 페이지 리스트가 현재 페이지 기준으로 좌우 5개씩만 보이도록 만든다. 현재 페이지(page)보다 5만큼 크거나 같은(또는 작거나 같은) 값만 표시되도록 만든 것이다.

정상적으로 구현했다면, 다음과 같이 현재 페이지 기준 양옆으로 5개씩만 페이지 리스트가 표시되는 것을 확인할 수 있다.

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

0개의 댓글