Typescript 기반 백엔드 서버에 MVC 패턴 적용하기

haerim·2024년 2월 10일
0

프로젝트 진행 상황

ASIS

  • 순수 자바스크립트 환경이다.
  • 크게 App - Route - Controller 로만 구성되어 있다.
  • 현재는 코드 수정을 하면 서버를 수동으로 껐다가 켜야 한다.
  • 배포가 되어 있지 않다.

TOBE

  • TypeScript 환경으로 변경한다. ✅
  • MVC 모델 패턴으로 변경한다.
  • 코드 수정을 해도 자동으로 서버가 재실행되도록 변경한다. ✅
  • EC2 같은 AWS 서비스로 배포를 한다.

지금까지는 Book API 중 전체 조회 API만 MVC 모델 패턴으로 변경해둔 상태다.
개인적으로 가장 어려웠고, 가장 배웠던 점이 많았어서 정리를 좀 해두려고 한다.

MVC 패턴

MVC 패턴은 Model, View, Controller 의 약자다.
사용자 인터페이스, 데이터 및 논리 제어를 구현하는데 널리 사용되며 소프트웨어의 비즈니스 로직과 화면을 구분하는데 중점을 두고 있는 소프트웨어 디자인 패턴이라고 한다.

이런 구조를 가지고 있는데,

  • 모델(Model): 데이터와 비즈니스 로직을 관리
  • 컨트롤러(Controller): 모델과 뷰로 명령을 전달
  • 뷰(View): 레이아웃과 화면을 처리

라고 볼 수 있다고 한다.

왜 MVC 패턴을 선택했는가?

개인적으로 NVC 패턴을 도입하기로 결심했던 건 "관심사 분리" 때문이었다.

관심사 분리란 시스템 요소가 단일 목적이고 배타성을 가져야 한다는 것을 명시한다고 한다. 즉, 어떤 요소도 다른 요소와의 책임을 공유하면 안 되고, 그 요소와 관계가 없는 책임을 포함시킬 수 없다는 것이다. (Ex. 메소드, 객체, 컴포넌트, 서비스)

현재 JS로 짠 내 코드는 관심사가 전혀 분리가 되어 있지 않았다. 폴더 구조부터 그랬다.

관심사-분리가-되어있지-않은-코드

폴더 구조를 보면 controller, db, helper, routes로 되어 있다.
여기서 routes가 view로서의 역할을 담당하고 있었고, helper는 공통함수, db는 db connecting을 담당하고 있었다.
즉 model과 controller 역할을 controller 폴더 내 파일에서 다 하고 있었던 것.

const allReadBooks = async (req, res) => {
    const { category_id, recent, limit, currentPage } = req.query;
    let offset = (parseInt(currentPage) - 1) * parseInt(limit);
    let values = [];
    let likeSql = "SQL 쿼리";
    let sql = `SQL 쿼리`;

    if (category_id && recent) {
        sql += "SQL 쿼리";
        values = [parseInt(category_id)];
    } else if (category_id) {
        sql += "SQL 쿼리";
        values = [parseInt(category_id)];
    } else if (recent) {
        sql += "SQL 쿼리";
    }

    sql += "SQL 쿼리";
    values = [...values, parseInt(limit), parseInt(offset)];

    let [results, fields] = await conn.query(sql, values);

    if (results[0]) {
        return res.status(StatusCodes.OK).json(results);
    } else {
        return res.status(StatusCodes.NOT_FOUND).json({
            massage: "존재하지 않는 도서입니다."
        });
    }
}

이건 실제 내 books controller 안의 책 전체 조회의 model과 controller를 담당하고 있는 부분이다.
보면 알다시피 화면에서 들어온 요청에 맞게 데이터 조회, 수정, 삭제 등의 작업을 하면서 데이터를 핸들링 하고 있어서 관심사 분리가 전혀 안 되어 있는 상태였다.

const express = require("express");
const {
    allReadBooks,
    detailReadBook
} = require("../controller/bookController");
const router = express.Router();

router.use(express.json());

router.get("/", allReadBooks);
router.get("/:id", detailReadBook);

module.exports = router;

books router도 보다시피 validation 처리가 전혀 안 되어 있는 상태여서 말 그대로 혼파망의 상태였다. (...)

라우터는 그렇다치더라도 controller 부분이 OOP 원칙을 지키지 않고 그냥 절차지향적으로 작성되어 있는 것 같아 보였다.
나는 계속 controller 부분을 어떻게 하면 더 모듈화를 할 수 있는지 고민했는데, 마침 MVC 패턴을 따르면 어느 정도 모듈화를 할 수 있을 것 같았다.

그래서 모듈화를 시작했다.

프로젝트에 MVC 패턴 적용

프로젝트 구조

아직 한참 더 바꿔야 하겠지만, 어느 정도 MVC 패턴이 자리 잡힌 내 현재 프로젝트 구조다.

  • controller: 화면에 들어온 요청에 맞게 데이터 조회, 수정, 삭제 등의 작업 담당 (controller)
  • db: DB connecting
  • model: 데이터의 타입들에 대한 정의 (typescript로 만들어진 프로젝트라 이 곳에는 실제 데이터 핸들링을 하는 게 아니다.)
  • routes: 화면과 상호작용하는 최전선 부분 (view)
  • services: controller에서 요청한 작업에 맞는 쿼리 등을 사용해 데이터 핸들링 (실질적 model)
  • util: 공통 함수

사실 db 안에 services에 담긴 폴더들을 넣을까 생각했었는데, 크게 분리가 되는 것 같아 보이지 않았고 (개인적으로 폴더 구조로 뭐가 어떤 역할을 맡는지 가시적으로 보이는 게 낫다고 생각한다.) 좀 찾아보니 MVC 패턴에 S(서비스의 S다)를 추가한 MVCS 패턴도 있다고 했다.

실제로 내 프로젝트는 TypeScript 기반 프로젝트이기 때문에 Model 폴더에는 데이터의 타입들에 대한 정의들이 담기게 된다. (실질적 Model 부분이 아님)

현재 프로그래머스에서 배웠을 때, service가 보통 controller에서 요청받은 작업들을 (controller에서는 함수 등으로 호출하는 것 같다) 직접적으로 처리하고 핸들링하는 부분이라고 하니 이 부분을 실질적 Model 역할을 시키면 어떻겠나 하는 생각을 혼자 했다.

그리고 기본적으로 이 글을 많이 참고했다. 이 글 없었으면 나는 프로젝트에 MVC + S 패턴을 적용하지 못했을 것이다. ㅠ_ㅠ
[참고글] NodeJS+Express+Typescript MVC 패턴(w/ Mysql)

Model

export type GetBooks = {
    id: number;
    category_id: number;
    title: string;
    img: number;
    form: string;
    isbn: string;
    summary: string;
    detail: string;
    author: string;
    contents: string;
    pages: number;
    price: number;
    likes: number;
    pub_date: string;
};

export type Getpagination = {
    totalCount: number;
    currentPage: string;
};

앞서 말했다시피 여긴 데이터 타입을 정의하는 부분이다.
DB 테이블에 어떻게 데이터를 넣도록 해뒀는지 알아서 이 부분은 그렇게 어렵지 않게 작성할 수 있었던 것 같다.
개인적으로 book 데이터와 pagination 부분은 다른 데이터라고 생각해서 그냥 분리했다.

View

import express from "express";
import { ValidationChain, query } from "express-validator";

import * as bookController from "@controller/bookController";

const router = express.Router();

router.use(express.json());

export const validateRules: ValidationChain[] = [
    query("limit").isString().notEmpty(),
    query("currentPage").isString().notEmpty(),
    query("categoryId").isString().notEmpty()
];

router.get("/", validateRules, bookController.getAllBook);

export default router;

book router다. 화면과 상호작용하는 최전선 부분이고, 이 부분에서 validation도 처리하고 있다.
validation은 express-validator 라이브러리를 사용해서 처리하고 있다.
사실 처음에 validation을 했음에도 불구하고 잘 안 돼서 좀 헤맸는데 공식문서 보고 해보니까 조금 익숙해졌다.
공식문서 중 이 부분을 주로 참고했다. 참고 글

Controller

export async function getAllBook(req: Request, res: Response) {
    const categoryId = req.query.categoryId as string;
    const recent = req.query.recent as string;
    const limit = req.query.limit as string;
    const currentPage = req.query.currentPage as string;

    try {
        const result: Result = validationResult(req);
        const errors: Array<{ path: string }> = result.array();

        let bookInfo: Array<GetBooks> = [];

        if (result.isEmpty()) {
            bookInfo = await BookData.getAllBook(
                limit,
                currentPage,
                categoryId,
                recent,
            );
        } else {
            if (errors[0].path === "categoryId") {
                bookInfo = await BookData.getAllBook(
                    limit,
                    currentPage,
                    undefined,
                    recent,
                );
            } else if (errors[0].path === "recent") {
                bookInfo = await BookData.getAllBook(
                    limit,
                    currentPage,
                    categoryId,
                );
            }
        }

        let totalBookCount: Array<{ totalRows: number }> =
            await BookData.getCountPagination();
        let firstRow = totalBookCount[0];

        let paginationInfo: Getpagination = {
            totalCount: firstRow.totalRows,
            currentPage: currentPage,
        };

        const formattedResults = bookInfo.map((result) => mapData(result));

        res.send({
            books: formattedResults,
            pagination: paginationInfo,
        });
    } catch (error) {
        console.error("Error in getAllBook controller:", error);
        res.status(500).send("Internal Server Error");
    }
}

가장 고생했던 부분이다...
에러도 가장 많이 나고, 트러블 슈팅도 가장 많이 했던 부분...
참고했던 글은 너무 정확하게 API가 하나의 기능만 수행하기도 했고, Router에서 validation을 해주지 않아서...ㅠㅠ
내 컨트롤러에서는 좀 많이 변형을 해줘야 했다. (어쩌면 빼셨을 수도?)

특히 Router에서 쿼리로 받아들이는 부분이 계속 에러가 나서 힘들었다.
'ParsedQs' 형식은 'string' 형식에 할당할 수 없습니다. 계속 이런 에러가 남... 스택 오버 플로우에 올라온 이 글을 참고해 봤는데도 도저히 해결할 수 없었다.
[참고글] What does this error say? Type 'ParsedQs' is not assignable to type 'string'

그래서 그냥 타입 단언(as Type)을 해버렸다.

사실 타입스크립트에서 타입 단언은 함부로 하면 안 된다. 꽤 위험한 행위인데, "내가 타입스크립트 너보다 이 변수에 대한 타입을 잘 알고 있어. 그러니까 내가 이 타입을 지정할게. 이 부분은 타입 체크하지 않고 지나가도 좋아." 라고 아예 단언해 버리는 거기 때문이다. 그래서 만약 저기서 들어오는 값이 string이 아니라 numberboolean 타입으로 들어오면 큰일난다. (에러가 체크되지 않기 때문에 터진다.;)

그럼에도 타입 단언을 해줄 수 있었던 이유는, request.query로 들어오는 categoryIdrecent 같은 값이 string 타입임을 알고 있었기 때문이다. (애초에 query string이기도 하고) 좀 뒤지다 보니 이런 글도 있었다. [참고글] TypeScript - Request Query String 형태 문제

이 글에서도 두 가지 방법을 알려주고 있었는데, 타입 단언을 하는 게 좀 더 깔끔하기도 했다.

그리고 하나 또 문제였던 점은, 내가 하나의 API로 쿼리가 있을 때와 없을 때를 나누어서 작업을 처리하는 로직을 짰었기 때문도 있었다.
쿼리가 있다 없으니까 (...) undefined 값이 들어오게 될 가능성이 있는데 이래서도 타입 선언을 해주기가 어려웠다.

그래서 일단 string 타입만 들어온다고 타입 단언을 해주었고,
밑에서 validation 체크를 해 error가 있는지 없는지를 체크해서 각각의 경우에 따라 작업을 요청하도록 로직을 짰다.
error가 있다는 이야기는 router에서 validation 체크를 했을 때 값이 비었다는 의미와 동일하다.

그래서 error가 없으면 쿼리 스트링을 전부 사용하는 메소드를, error가 있으면 어떤 쿼리 스트링이 없는지를 확인해서 그 쿼리 스트링만 제외한 메소드를 요청했다.

이러니까 쉽게 해결이 됨 (사실 하나도 안 쉬웠음 3일 정도 걸렸음 ㅠㅠ)

조금 더 리팩토링을 할 참인데 "categoryId" 같은 string 부분을 상수로 만들어서 갖고 올 참이다. 이 부분은 책을 조회하고 주문하는 서버인 이상 꽤 많이 사용될 거 같아서, 한 군데서 관리를 하면 좀 더 유지보수적인 측면에서 좋을 거 같다.

Service

export async function getAllBook(
    limit: string,
    currentPage: string,
    categoryId?: string,
    recent?: string,
): Promise<Array<GetBooks>> {
    try {
        let likeSql: string = await getLikeCountSql();
        let sql: string = "";

        if (categoryId && recent) {
            sql = `sql 구문`;
        } else if (recent) {
            sql = `sql 구문`;
        } else {
            sql = `sql 구문`;
        }

        const params = categoryId
            ? [parseInt(categoryId), parseInt(limit)]
            : [parseInt(limit)];
        const offset = (parseInt(currentPage) - 1) * parseInt(limit);

        return conn
            .execute(sql, [...params, offset])
            .then((result: any) => result[0]);
    } catch (error) {
        console.error("Error in getAllBook:", error);
        throw error;
    }
}

SQL 구문은 딱히 자세히 소개할 필요는 없는 거 같아서 비워버렸다.
이 부분이 이제 실질적인 비즈니스 로직 (프로그램의 핵심 로직, 어떻게 데이터가 생성되고 저장되고 수정되는지를 정의한 것) 부분이다.
즉 요청 받은 작업에 따라 SQL 쿼리를 이용해 데이터를 핸들링하는 부분이라 비즈니스 로직이라 볼 수 있다.

categoryIdrecent 가 있는지 없는지에 따라 SQL 쿼리를 다르게 불러오게 했고, SQL 쿼리와 같이 보내는 params 부분도 마찬가지로 다르게 보내도록 했다.

이 부분은 사실 크게 고민 안 했다. Controller에서 요청하는 대로 분기만 잘하고 DB에 잘 접근만 하면 되는 부분이었기도 했고, 참고했던 글이 있어서 다행이었다.

MVC 패턴 적용 후

내 처음 코드에 비하면 정말 많이... 나아진 거 같다.
물론 앞으로 넘어야 할 산이 진짜 많음... (주문하기 부분이 진짜 헬일 거 같다 ㅠㅠ)
하지만 BOOK API를 해결했기 때문에 나머지 부분은 그렇게 어렵지 않을 것 같다!

오늘도 한 단계 성장한 듯~ 행복하다!

profile
멋진 프론트엔드 개발자가 되고 싶은

0개의 댓글