북파이(Book Pie) 프로젝트 제작기

React와 Typescript를 활용한 풀스택 프로젝트 도전기

안녕하세요! 융(Yoong)입니다.

벌써 3월이 다 지나가고 봄기운이 완연한 4월이 되었습니다🌸. 지난달, 저는 React와 Typescript를 활용해 앱을 만들어보는 시간을 가졌는데요. 화면 구현뿐만 아니라 직접 테이블 설계까지 해볼 수 있어서 바쁘고 보람찬 한 달이었습니다. 이번 글에서는 저의 첫 풀스택 프로젝트 경험에 대해 공유하려고 합니다. 그럼 함께 보러가실까요?


1. 프로젝트 소개

여러분의 책장에는 안 읽은 책이 얼마나 있으신가요? 조금 부끄럽지만 제 책장에는 사놓고 안 펴본 책도 있고, 읽다가 그만둔 책들도 꽤 많답니다😅. 그래서 저와 같은 사람들을 위해 ‘자신의 독서 습관을 효율적으로 관리할 수 있는 서비스’을 기획하게 되었습니다. 독서 일정을 만들어 각 책마다 진행률을 채크할 수 있고, 독후감을 남기면 연속 독서 성공 일수를 보여줘서 사용자가 독서를 꾸준히 할 수 있도록 도와주는 앱이죠. 이른바 ‘책을 잘라먹자! 북 파이(Book Pie)‘입니다.

👉 Book Pie 깃헙


📌 요구사항

‘북 파이’의 기본 요구사항은 다음과 같습니다. 메인 기능은 크게 독서 일정 CRUD와 독후감 CRUD 두 가지로 구분됩니다.

  • 읽을 책 추가하여 일정 만들기(독서 일정 CRUD)
    1. 입력 정보 바탕으로 독서 일정 추가
      • 책 제목 / 책 저자 입력
      • 독서 페이지 입력 (시작 페이지, 마지막 페이지)
      • 독서 기간 입력 (시작일, 종료일)
    2. 독서 일정 조회/수정/삭제
    3. 연속 독서 일수 표시 (ex. 123일째 독서 중🔥)
    4. 읽은 페이지 기준 독서 진행률 표시
  • 독서 일정 수행하기(독후감 CRUD)
    1. 책을 읽은 마지막 페이지와 독후감 10자 이상 작성 시 독후감 등록 가능
    2. 독서 기록 최신순부터 이전 독후감 리스트 조회
    3. 작성한 독서 기록 수정/삭제

📌 화면 계획

‘북 파이’의 화면 계획은 메인 페이지와 서브 페이지로 구성됩니다. 메인 페이지에서는 등록한 책 정보를 리스트 형태로 확인할 수 있으며, 사용자가 꾸준히 책을 읽도록 유도하기 위해 “00일째 독서 중🔥” 처럼 연속 독서 일수를 보여줍니다. 서브 페이지에서는 오늘 수행할 독서 기록을 작성할 수 있으며, 이전에 작성한 기록도 리스트 형태로 확인할 수 있습니다.

자, 미리 완성된 모습을 살짝 보여드릴게요.

메인페이지

서브페이지

눈치 빠른 분들이시라면 어떤 요소가 중복으로 들어가는지 파악하셨을 텐데요. 저는 기본적으로 회색 테두리가 있는 카드들을 하나의 컴포넌트로 구분해 개발을 진행했습니다.


📌 기술 스택

‘북 파이’는 Typescript와 React를 이용하여 프론트엔드를, Express.js와 inversify를 이용하여 백엔드를 개발하였습니다. 테스트는 Cypress를, DB는 Mysql을 사용하였습니다. 스타일링의 경우 Styled-components 라이브러리를 사용해서 Css-in-JS 방식으로 적용했습니다.

구분 스택
프론트엔드 Typescript, React
백엔드 Express.js, inversify, Typescript
테스트 Cypress
DB Mysql
Css-in-JS Styled-components

inversify는 TypeScript로 구동되는 IoC(Inversion of Control: 제어의 역전) 컨테이너입니다. DI(Dependency Injection: 의존성 주입)를 위해 express로 구현한 서버에 inversify 라이브러리를 설치하여 개발을 진행했습니다.

Cypress는 웹 애플리케이션을 위한 프론트엔드 테스트 도구로, 실제 사용자가 사용하는 상황을 가정하여 e2e 테스트를 해보기위해 설치했습니다.


2. DB 설계

이 프로젝트를 진행하면서 가장 걱정되었던 부분은 ‘DB 설계’였습니다. 이전까지 기존에 구현되어 있던 DB 테이블에서 데이터를 가져와 쓴 적은 있지만, 실제로 테이블을 구성할 일은 없었거든요. 프로젝트에 필요한 테이블을 구성하고 각 컬럼에 저장될 값을 생각하여 데이터 타입을 지정해주었습니다.

북파이ERD

‘BP’는 Book Pie의 약어입니다. 테이블 명과 컬럼 명 앞쪽에는 BP라는 prefix를 붙여 고유 컬럼 명으로 만들었습니다. 컬럼명 뒤에는 데이터타입을 유추할 수 있도록 숫자형이면 NUM, 날짜형이면 DT나 DTHMS, enum(Y, N) 형인 경우 YN을 붙여주었습니다. 또한 관례상 공통으로 쓰이는 컬럼(DEL_YN, WRT_DTHMS, UPDATE_DTHMS)의 경우 테이블 아래쪽으로 배치하였습니다.

데이터타입을 정한 기준은 다음과 같습니다.

  • BP_BOOK_TITLE: 책 제목을 저장하는 컬럼입니다. 커버해야 하는 글자 길이를 알기 위해 검색해보니, 세상에서 가장 긴 책 제목은 총 26,021자였습니다. 하지만 이 하나의 예외를 위해 무작정 넓은 범위의 데이터 타입을 잡는 것은 실용적이지 못했습니다. 따라서 기준을 ‘아마존’으로 잡고 ‘아마존 책 등록 시 제목의 최대 길이’인 200자로 잡았습니다.
  • BP_BOOK_AUTHOR: 마찬가지로 ‘아마존 책 등록 시 저자의 최대 길이’인 100자를 기준으로 잡았습니다.
  • BP_BOOK_PUBLISHER: 마찬가지로 ‘아마존 책 등록 시 출판사의 최대 길이’인 100자를 기준으로 잡았습니다.
  • BP_BOOK_START_NUM, BP_BOOK_END_NUM, BP_BR_LAST_READ_NUM: 책 페이지 수를 저장하는 컬럼들입니다. 얼마의 숫자까지 커버해야 하는지 알기 위해 세상세서 가장 두꺼운 책 페이수를 검색한 결과, 4,032쪽이 나왔습니다. tinyint 타입은 255까지만 저장 가능하므로 그보다 더 범위가 넓은 바로 위 타입인 smallint로 타입을 지정했습니다. (smallint타입: unsigned로 지정 시 최대 65535까지 저장할 수 있음)
  • BP_BR_CONTENT_TEXT: 독후감 내용을 저장하는 컬럼의 경우, 많은 양의 string을 저장할 수 있는 TEXT 타입도 좋지만, 기획에 따라 독후감은 짧게만 남길 예정이기 때문에 500자로 제한하였습니다.

3. 프로젝트 구조

프로젝트는 크게 client 와 server 폴더로 나뉘어져 있습니다.


📌 client

그럼 먼저 client의 파일 구조를 살펴볼까요? client의 주요 코드는 모두 src 폴더에 들어있습니다.

src
├── App.css
├── App.tsx
├── index.css
├── index.tsx
├── components
│   ├── book
│   │   ├── BookBox.tsx
│   │   ├── BookList.tsx
│   │   ├── BookModal.tsx
│   │   ├── FloatingBtn.tsx
│   │   └── ReadCountBox.tsx
│   ├── common
│   │   ├── ErrorMsgBox.tsx
│   │   └── Header.tsx
│   └── report
│       ├── ReportBox.tsx
│       └── ReportList.tsx
├── lib
│   ├── axiosInstance.ts
│   ├── getDate.ts
│   └── getErrorMessage.ts
├── models
│   ├── book.model.ts
│   └── report.model.ts
├── pages
│   ├── BookPage.tsx
│   └── ReportPage.tsx
└── styled
    ├── StyledBox.tsx
    ├── StyledBtn.tsx
    └── StyledInput.tsx
  • pages: 페이지 컴포넌트가 들어있습니다. 메인 페이지인 BookPage와 서브 페이지인 ReportPage로 구성됩니다.
  • components: 페이지 안에 들어갈 세부 컴포넌트들이 들어있습니다. BookPage에서 사용되는 컴포넌트들을 book 폴더에, ReportPage에서 사용되는 컴포넌트들은 report 폴더에 넣어두었습니다. common에는 공통으로 쓰이는 컴포넌트가 들어있습니다.
  • lib: 이 폴더에는 컴포넌트 안에서 공통으로 자주 쓰이는 메서드나 axios 객체를 따로 빼두었습니다.
  • models: interface들을 모아놓은 폴더입니다.
  • styled: Styled-components 코드로 구현한 컴포넌트들을 모아놓은 폴더입니다. 여러 컴포넌트에 공통을 적용해줘야 할 스타일링인 경우 styled 폴더 안에 스타일드 컴포넌트로 만들어두고 import 시켜주었습니다. 특정 컴포넌트 안에서만 쓰이는 스타일링인 경우에는 따로 styled 폴더로 빼지 않고 해당 컴포넌트 내에서 작성했습니다.

📌 server

다음은 server의 폴더 구조입니다. server도 client와 마찬가지로 src 폴더 안에 대부분의 구현 코드가 들어있습니다. 서버 코드의 경우 대부분이 InversifyJSinversify-express-utils 의 예시 코드에 따라 작성되었기 때문에 관련 깃헙을 참고하시면 이해하시기 쉽습니다.

src
├── index.ts
├── config
│   └── ioc.container.ts
├── constant
│   └── types.ts
├── controller
│   ├── book.controller.ts
│   └── report.controller.ts
├── models
│   ├── book.model.ts
│   ├── report.model.ts
│   └── transaction.model.ts
├── query
│   ├── book.query.ts
│   └── report.query.ts
├── repository
│   ├── baseMysql.repository.ts
│   ├── book.repository.interface.ts
│   ├── book.repository.ts
│   ├── report.repository.interface.ts
│   └── report.repository.ts
├── services
│   ├── book.service.ts
│   └── report.service.ts
└── utils
    └── dbConnectionFactory.util.ts
  • config: ioc 컨테이너가 들어있는 폴더입니다. 컨테이너는 다음처럼 작성되어 있습니다.

    import {Container} from 'inversify';
    import TYPES from '../constant/types';
    import ReportService from '../services/report.service';
    import DBConnectionFactory from '../utils/dbConnectionFactory.util';
    import BookRepositoryInterface from '../repository/book.repository.interface';
    import BookRepository from '../repository/book.repository';
    import BookService from '../services/book.service';
    import ReportRepositoryInterface from '../repository/report.repository.interface';
    import ReportRepository from '../repository/report.repository';
    
    const container = new Container();
    
    container.bind<DBConnectionFactory>(TYPES.mysqlPool).to(DBConnectionFactory);
    container.bind<BookRepositoryInterface>(TYPES.BookRepository).to(BookRepository);
    container.bind<BookService>(TYPES.BookService).to(BookService);
    container.bind<ReportRepositoryInterface>(TYPES.ReportRepository).to(ReportRepository);
    container.bind<ReportService>(TYPES.ReportService).to(ReportService);
    
    export default container;
  • constant: InversifyJS에 필요한 식별자 파일이 들어있는 폴더입니다. Inversify에서는 식별자로 Symbol을 활용합니다.

    //types.ts
    const TYPES = {
      mysqlPool: Symbol.for("mysqlPool"),
      BookRepository: Symbol.for("BookRepository"),
      BookService: Symbol.for("BookService"),
      ReportRepository: Symbol.for("ReportRepository"),
      ReportService: Symbol.for("ReportService"),
      DBexecute: Symbol.for("DBexecute"),
    };
    
    export default TYPES;
  • controller: API 요청에 따라 경로를 찾아주는 라우터와 요청을 수행하는 코드가 같이 작성되어 있습니다. inversify-express-utils 코드 예시에 따라 작성되었습니다.

    @controller("/book")
    export class BookController implements interfaces.Controller {
      constructor(@inject(TYPES.BookService) private bookService: BookService) {}
    
      @httpGet("/")
      async getBookList(@response() res: express.Response) {
        return await this.bookService.getBookList();
      }
    	...
    }
  • models: interface들을 모아놓은 폴더입니다.

  • query: 쿼리문만 모아놓은 폴더입니다.

  • services: 컨트롤러에서 실제로 코드를 실행하는 부분( await this.bookService.getBookList())은 services 폴더 안에 들어있습니다. service 파일들은 DB 연결 후 쿼리를 실행합니다. 쿼리 실행 결과에 따라 트랜젝션을 commit할지, rollback할지가 결정되고 실행 후 자원 낭비를 막기위해 바로 release됩니다.

  • repository: service에서 실제 DB에 쿼리를 만들고, 보내고, 결과를 받아오는 부분을 따로 분리해 둔 곳입니다.

  • utils: 여러 곳에서 공통적으로 사용되는 부분을 모아놓은 폴더입니다. 이 프로젝트에서는 MySQL 연결 풀을 만들고 연결 객체를 생성하는 class 파일이 들어있습니다. 이 클래스의 getConnection메서드는 DB연결시 매번 사용되기 때문에 utils 폴더로 따로 분리해두었습니다.


4. 메인 기능 구현

메인 기능이 어떻게 동작하는지 따라가 보도록 합시다.

가장 기본 요청인 ‘책 리스트 가져오기’ 기능의 동작 과정을 살펴볼까요? 아래처럼 메인 페이지에서 책 리스트를 띄워주는 기능입니다.

메인 페이지

  1. BookList 컴포넌트에서 API 요청 보내기

    import BookBox from "./BookBox";
    import { AxiosError } from "axios";
    import { useEffect, useState } from "react";
    import { Book } from "../../models/book.model";
    import ErrorMsgBox from "../common/ErrorMsgBox";
    import { getErrorMessage } from "../../lib/getErrorMessage";
    import { bookAxios } from "../../lib/axiosInstance";
    import styled from "styled-components";
    
    const StyledMsg = styled.div`
      text-align: center;
      color: var(--dark-gray);
      font-size: 16px;
    `;
    
    const BookList = () => {
      const [isError, setIsError] = useState<boolean>(false);
      const [errMsg, setErrMsg] = useState<string>("");
      const [bookList, setBookList] = useState<Book[]>();
    
      const getBookList = async () => {
        try {
          const response = await bookAxios.get("/");
          const data = response.data;
          setBookList(data);
        } catch (error) {
          const { response } = error as unknown as AxiosError;
          setIsError(true);
          setErrMsg(getErrorMessage(response?.status));
        }
      };
    
      useEffect(() => {
        getBookList();
      }, []);
      return (
        <>
          {isError && <ErrorMsgBox errMsg={errMsg} />}
    
          <ul>
            {bookList && Array.isArray(bookList) ? (
              bookList.map((data: Book) => (
                <li key={data.bookId}>
                  <BookBox data={data} />
                </li>
              ))
            ) : (
              <StyledMsg>도서를 등록해주세요.</StyledMsg>
            )}
          </ul>
        </>
      );
    };
    
    export default BookList;
    • 책 리스트를 받아오는 요청은 BookList 컴포넌트에서 이루어집니다.

    • 여기서 bookAxios는 다음처럼 baseURL 을 설정해놓은 axios 인스턴스입니다. 이렇게 baseURL을 설정해두면 API 경로를 더욱 간단하게 작성할 수 있고, 서버 주소 변경 시 유지보수가 편해집니다. 아래 코드에서 bookAxios.get("/") 요청 경로는 실제로는 "http://localhost:4000/book/"가 됩니다.

      //axiosinstance.ts
      import axios from "axios";
      
      const bookAxios = axios.create({
        baseURL: "http://localhost:4000/book",
        timeout: 3000,
      });
      
      const reportAxios = axios.create({
        baseURL: "http://localhost:4000/report",
        timeout: 3000,
      });
      
      export { bookAxios, reportAxios };
    • BookList 컴포넌트가 요청해서 받아온 책 리스트는 배열로 오기 때문에 map을 통해 BookBox라는 컴포넌트로 각 책 정보를 전달해주고, 해당 리스트를 화면에 표시해주고 있습니다. 요청이 제대로 이루어지지 않아, bookList state에 데이터가 없는 경우를 대비해 삼항 연산자로 조건부 렌더링 처리를 해주었습니다.

      ...
      <ul>
        {bookList && Array.isArray(bookList) ? (
          bookList.map((data: Book) => (
            <li key={data.bookId}>
              <BookBox data={data} />
            </li>
          ))
        ) : (
          <StyledMsg>도서를 등록해주세요.</StyledMsg>
        )}
      </ul>
      ...
  2. 서버에서 컨트롤러에 의해 API 라우팅

    • index.ts 파일에서 controller 들을 제대로 import 시켰다면 요청 url 과 메서드에 따라 라우팅이 이루어집니다.

      //index.ts
      import "reflect-metadata";
      import { InversifyExpressServer } from "inversify-express-utils";
      import container from "./config/ioc.container";
      import * as express from "express";
      import * as cors from "cors";
      import "./controller/book.controller";
      import "./controller/report.controller";
      
      const server = new InversifyExpressServer(container);
      
      server.setConfig((app) => {
        app.use(cors({ origin: true }));
        app.use(express.urlencoded({ extended: true }));
        app.use(express.json());
      });
      
      const app = server.build();
      
      app.listen(4000, () => {
        console.log("✅ Listening on: http://localhost:4000");
      });
    • 아래코드에서 BookController 클래스의 가장 상단에 @controller("/book") 로 경로를 설정해주었기 때문에 앞부분이 "http://localhost:4000/book" 인 요청은 모두 이 곳에서 분기됩니다. 프론트 코드에서 "http://localhost:4000/book/" url로 get요청을 보냈으니까 getBookList 메서드가 실행됩니다.

      //book.controller.ts
      import * as express from "express";
      import {
        interfaces,
        controller,
        httpGet,
        httpPost,
        httpPut,
        request,
        response,
        httpDelete,
      } from "inversify-express-utils";
      import { inject } from "inversify";
      import TYPES from "../constant/types";
      import BookService from "../services/book.service";
      import { RequestCreateBook, RequestUpdateBook } from "../models/book.model";
      import { RequestDeleteBook } from "../models/book.model";
      
      @controller("/book")
      export class BookController implements interfaces.Controller {
        constructor(@inject(TYPES.BookService) private bookService: BookService) {}
      
        @httpGet("/")
        async getBookList(@response() res: express.Response) {
          return await this.bookService.getBookList();
        }
      	...
      }
  3. BookService의 getBookList 실행

    • await this.bookService.getBookList() 코드가 실행되면 어떤일이 벌어질까요? BookService 클래스의 메서드들은 트랙젝션을 실행 후 commit, rollback, release 처리가 이루어집니다.

      //book.service.ts
      import { inject, injectable } from "inversify";
      import TYPES from "../constant/types";
      import {
        RequestGetBook,
        RequestCreateBook,
        RequestUpdateBook,
        RequestDeleteBook,
      } from "../models/book.model";
      import { TransactionResult } from "../models/transaction.model";
      import DBConnectionFactory from "../utils/dbConnectionFactory.util";
      import BookRepository from "../repository/book.repository";
      
      @injectable()
      class BookService {
        constructor(
          @inject(TYPES.mysqlPool) private mysqlPool: DBConnectionFactory,
          @inject(TYPES.BookRepository) private repository: BookRepository 
        ){}
      
        public async getBookList<T>(): Promise<T[]> {
          let result: T[];
          let connection;
          
          try {
            connection = await this.mysqlPool.getConnection();
            connection.beginTransaction();
      
            result = await this.repository.getBookList(connection);
            connection && connection.commit();
          } catch (error) {
            connection && connection.rollback();
            throw error;
          } finally {
            connection && connection.release();
          }
      
          return result;
        }
      	...
      }
    • 위의 코드에서 실제 DB에 쿼리를 보내는 코드는 result = await this.repository.getBookList(connection); 라는 것을 확인할 수 있습니다.

  4. BookRepository 로 쿼리 실행한 결과 받아오기

    • 위 코드에서처럼 쿼리를 실제로 실행한 결과를 반환해주는 기능은 Repository 클래스에 들어있습니다. 여기서 반환된 result가 응답에 실리게 됩니다.

      //book.repository.ts
      import { inject } from "inversify";
      import BaseMysqlRepository from "./baseMysql.repository";
      import BookRepositoryInterface from "./book.repository.interface";
      import TYPES from "../constant/types";
      import DBConnectionFactory from "../utils/dbConnectionFactory.util";
      import { BookQuery, BookQueryId } from "../query/book.query";
      import { QueryInfo } from "../models/transaction.model";
      import { RequestCreateBook, RequestGetBook, RequestUpdateBook } from "../models/book.model";
      
      class BookRepository
        extends BaseMysqlRepository
        implements BookRepositoryInterface
      {
        constructor(
          @inject(TYPES.mysqlPool) protected mysqlPool: DBConnectionFactory
        ) {
          super(mysqlPool);
        }
      
        public async getBookList<T>(connection?: any): Promise<T[]> {
          const queryInfo: QueryInfo = BookQuery(BookQueryId.getBookList);
          return await this.query(queryInfo.queryStr, queryInfo.queryParams, connection);
        }
      	...
      }
    • 여기서 query 메서드는 BookRepository 클래스가 상속받고 있는 BaseMysqlRepository에 구현된 메서드입니다. 쿼리를 조회하기만 할 때 사용할 query 메서드와 실제 데이터를 변경할 때 사용할 execute 메서드가 있습니다.

      //baseMysql.repository.ts
      import { injectable } from "inversify";
      import DBConnectionFactory from "../utils/dbConnectionFactory.util";
      
      @injectable()
      class BaseMysqlRepository {
        constructor(protected mysqlPool: DBConnectionFactory) {}
      
        protected async query<T>(
          queryStr: string,
          queryParams?: any[],
          conn?: any
        ): Promise<T[]> {
          let result: T[];
          let connection;
      
          try {
            if (conn) {
              connection = conn;
            } else {
              connection = await this.mysqlPool.getConnection();
            }
      
            const [rows, fields] = await connection.query(queryStr, queryParams);
            result = rows as T[];
      
            if (!conn) connection.commit();
          } catch (error) {
            if (!conn) connection.rollback();
            throw error;
          } finally {
            if (!conn) connection.release();
          }
      
          return result;
        }
      
        protected async execute<T>(
          queryStr: string,
          queryParams?: any[],
          conn?: any
        ): Promise<T> {
          let result: T;
          let connection;
      
          try {
            if (conn) {
              connection = conn;
            } else {
              connection = await this.mysqlPool.getConnection();
            }
      
            const [rows, fields] = await connection.query(queryStr, queryParams);
            result = rows as T;
      
            if (!conn) connection.commit();
          } catch (error) {
            if (!conn) connection.rollback();
            throw error;
          } finally {
            if (!conn) connection.release();
          }
      
          return result;
        }
      }
      
      export default BaseMysqlRepository;
  5. 쿼리문 생성

    • 쿼리문은 const queryInfo: QueryInfo = BookQuery(BookQueryId.getBookList); 코드에서 만들어집니다.

    • 쿼리문을 만들어 주는 BookQuery 메서드는 다음처럼 구현되어 있습니다. BookQuery는 쿼리문(queryStr)과 쿼리파라미터(queryParams)를 객체로 묶어 반환합니다.

      //book.query.ts
      import { QueryInfo } from "../models/transaction.model";
      
      export enum BookQueryId {
        getBookList,
        getBook,
        createBook,
        updateBook,
        deleteBook,
      }
      
      export const BookQuery = (
        queryId: BookQueryId,
        request: any = {}
      ): QueryInfo => {
        const queryInfo: QueryInfo = {
          queryStr: "",
          queryParams: [],
        };
      
        const queryStr: string[] = [];
        const queryParams: any[] = [];
      
        switch (queryId) {
          case BookQueryId.getBookList:
            queryStr.push(`
              SELECT
                B.BP_BOOK_ID AS 'bookId',
                B.BP_BOOK_TITLE AS 'title',
                B.BP_BOOK_AUTHOR AS 'author',
                B.BP_BOOK_PUBLISHER AS 'publisher',
                B.BP_BOOK_START_NUM AS 'startPageNum',
                B.BP_BOOK_END_NUM AS 'endPageNum',
                B.BP_BOOK_START_DT AS 'startDate',
                B.BP_BOOK_END_DT AS 'endDate',
                B.WRT_DTHMS AS 'writtenDatetime',
                B.UPDATE_DTHMS AS 'updateDatetime',
                IFNULL(
                  (
                    SELECT 
                      MAX(R.BP_BR_LAST_READ_NUM) 
                    FROM 
                      BP_BOOK_REPORT R
                    WHERE 
                      R.BP_BOOK_ID = B.BP_BOOK_ID 
                      AND
                      R.DEL_YN = 'N'
                  ), 0) AS 'maxLastReadNum'
              FROM 
                BP_BOOK B
              WHERE
                  B.DEL_YN = 'N'
              ORDER BY 
                  B.BP_BOOK_ID DESC
            `);
            break;
      		...
      
          default:
            break;
        }
      
        if (queryStr.length > 0) {
          queryInfo.queryStr = queryStr.join(" ");
          queryInfo.queryParams = queryParams;
        }
      
        return queryInfo;
      };
    • 책 리스트 조회하는 쿼리문은 추가 쿼리 파라미터가 필요하지 않지만, 만약 책 일정을 추가하는 쿼리문인 경우는 아래처럼 ’?’ 를 활용해야합니다.

      case BookQueryId.createBook:
        queryStr.push(`
          INSERT INTO BP_BOOK
          (
            BP_BOOK_TITLE,
            BP_BOOK_AUTHOR,
            BP_BOOK_PUBLISHER,
            BP_BOOK_START_NUM,
            BP_BOOK_END_NUM,
            BP_BOOK_START_DT,
            BP_BOOK_END_DT
          )
          VALUES (?, ?, ?, ?, ?, ?, ?)
        `);
        queryParams.push(request.title);
        queryParams.push(request.author);
        queryParams.push(request.publisher);
        queryParams.push(request.startPageNum);
        queryParams.push(request.endPageNum);
        queryParams.push(request.startDate);
        queryParams.push(request.endDate);
        break;
    • ’?‘는 placeholder라고 불리는 SQL 문법입니다. 이것은 나중에 입력되는 실제 데이터 값으로 대체됩니다. SQL injection을 방지하는데 중요한 역할을 하죠. 쿼리를 실행할 때, 각 ’?’는 해당 열에 삽입될 실제 값으로 대체됩니다. 예를 들어, 위 코드에서 첫 번째 ’?’는 BP_BOOK_TITLE 열에 삽입될 도서 제목 값이 됩니다.


5. 테스트 코드 적용

‘북파이’ 프로젝트는 Cypress를 사용해 e2e 테스트를 진행했습니다. Cypress를 모르시는 분들을 위해 실제 테스트를 실행한 gif를 보여드리겠습니다.

순식간에 테스트 코드가 실행되고 그 결과가 왼쪽에 표시됩니다. 이렇게 Cypress 사용하면 실제 사용자가 사용하는 화면에서 우리가 원하는대로 기능이 잘 동작하는지 테스트해볼 수 있습니다.

  • 메인 화면에서 요청 에러나 서버 에러가 발생시 에러 메시지가 잘 출력되는지 확인하기위해 다음과 같은 테스트 코드를 작성했습니다. Cypress에서는 intercept 메서드를 사용하여 가짜 요청을 보낼 수도 있고 가짜 응답도 받아올 수도 있습니다.

    //app.cy.ts
    describe("기본 화면 테스트", () => {
      context("도서 리스트가 있는 경우", () => {
        beforeEach(() => {
          cy.visit('/');
        })
    
        it("요청 에러가 발생한다면, 에러 메세지를 보여준다.", () => {
          cy.intercept("GET", "http://localhost:4000/book", {
            statusCode: 404,
          });
          cy.get("ul > li").should("not.exist");
          cy.get(".error-box > span")
            .should("be.visible")
            .and("contain", "잘못된 요청입니다.");
        });
    
        it("서버 에러가 발생한다면, 에러 메세지를 보여준다.", () => {
          cy.intercept("GET", "http://localhost:4000/book", {
            statusCode: 500,
          });
          cy.get("ul > li").should("not.exist");
          cy.get(".error-box")
            .should("be.visible")
            .and("contain", "서버 에러가 발생했습니다. 관리자에게 문의해 주세요.");
        });
      });
    	...
    });
  • 짠! 의도한 대로 요청 에러가 발생하였고, 에러 박스가 화면에 잘 표시되었습니다.

    error 테스트

  • 코드를 수정한 후 기능이 기존처럼 잘 동작하는지 확인해보기 위해 일일이 버튼을 눌러보고, API 요청이 에러가 나는지 확인해본 적 많지 않으신가요? Cypress와 같은 라이브러리를 사용해 프론트엔드 테스트 코드를 작성하면, 개발자가 직접 앱을 실행시키지 않아도 우리가 의도한 대로 잘 동작하는지 빠르게 확인해볼 수 있습니다.


6. 배운 점 및 느낀 점

이상으로 ‘북 파이’ 프로젝트에 대한 설명을 마칩니다. 이번 프로젝트를 진행하며 인상 깊었던 부분은 다음과 같습니다.

  • DB 구조 설계, 컬럼 데이터 타입 정하기
  • Axios 인스턴스 활용하여 리팩터링하기
  • Cypress 테스트 코드 적용하기

첫째, 직접 DB 테이블 구조를 짜보면서 컬럼과 컬럼의 데이터 타입 하나하나가 프로젝트 전반에 영향을 미치게 된다는 것을 알게 되었습니다. 테이블 구조가 프론트 화면, 요청 url, 요청 body와 응답 body 등에 반영되는 것을 경험하며, DB 설계는 정확한 논리성을 가지고 꼼꼼히 작성해야 한다는 것을 알게 되었습니다. 개인적으로 이번 프로젝트에서 DB를 직접 숙고하여 설계해본 경험이 가장 값졌습니다.

둘째, Axios 인스턴스를 활용하여 코드 리팩터링을 진행하면서, 이를 통해 중복 코드를 제거하고 코드의 가독성을 높일 수 있다는 것을 배울 수 있었습니다. 기존에 제가 알고 있던 방법은 .env 파일에 서버url을 저장해서 사용하는 것뿐이었는데, Axios 인스턴스를 사용하니 작성해야 할 코드도 줄어들고 더 많은 옵션을 지정해줄 수 있어 유지보수가 더욱 쉬워졌습니다.

셋째, Cypress 테스트 코드 적용을 통해 자동화된 테스트가 얼마나 유용한지 깨닫게 되었습니다. 이를 통해 개발자가 수동으로 테스트해야 하는 작업을 대부분 자동화할 수 있다는 것을 깨달았습니다.

이번 프로젝트를 진행하면서 프론트엔드와 백엔드를 함께 다루는 경험을 쌓을 수 있었고, TypeScript도 제대로 사용해보는 좋은 기회를 가질 수 있었습니다. 이 경험을 바탕으로 실제 업무에서 더 나은 결과물을 만들도록 노력해야겠습니다.

마지막으로, 긴 글을 읽어주신 독자분들께 감사의 말씀을 드립니다! 모두 좋은 하루 보내세요!🌸


참고 자료 InversifyJS Inversify-express-utils SQL injection prevention tips for web programmers 트랜잭션(Transaction) 개념 & 사용 완벽 정리

디지엠유닛원 주식회사

  • 대표이사 권혁태
  • 개인정보보호책임자 정경영
  • 사업자등록번호 252-86-01619
  • 주소
    서울특별시 금천구 가산디지털1로 83, 6층 601호(가산동, 파트너스타워)
  • 이메일 unit1@dgmit.com