지금까지는 Book API 중 전체 조회 API만 MVC 모델 패턴으로 변경해둔 상태다.
개인적으로 가장 어려웠고, 가장 배웠던 점이 많았어서 정리를 좀 해두려고 한다.
MVC 패턴은 Model, View, Controller 의 약자다.
사용자 인터페이스, 데이터 및 논리 제어를 구현하는데 널리 사용되며 소프트웨어의 비즈니스 로직과 화면을 구분하는데 중점을 두고 있는 소프트웨어 디자인 패턴이라고 한다.
이런 구조를 가지고 있는데,
라고 볼 수 있다고 한다.
개인적으로 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 패턴이 자리 잡힌 내 현재 프로젝트 구조다.
(controller)
(view)
(실질적 model)
사실 db 안에 services에 담긴 폴더들을 넣을까 생각했었는데, 크게 분리가 되는 것 같아 보이지 않았고 (개인적으로 폴더 구조로 뭐가 어떤 역할을 맡는지 가시적으로 보이는 게 낫다고 생각한다.) 좀 찾아보니 MVC 패턴에 S(서비스의 S다)를 추가한 MVCS 패턴도 있다고 했다.
실제로 내 프로젝트는 TypeScript 기반 프로젝트이기 때문에 Model 폴더에는 데이터의 타입들에 대한 정의들이 담기게 된다. (실질적 Model 부분이 아님)
현재 프로그래머스에서 배웠을 때, service가 보통 controller에서 요청받은 작업들을 (controller에서는 함수 등으로 호출하는 것 같다) 직접적으로 처리하고 핸들링하는 부분이라고 하니 이 부분을 실질적 Model 역할을 시키면 어떻겠나 하는 생각을 혼자 했다.
그리고 기본적으로 이 글을 많이 참고했다. 이 글 없었으면 나는 프로젝트에 MVC + S 패턴을 적용하지 못했을 것이다. ㅠ_ㅠ
[참고글] NodeJS+Express+Typescript MVC 패턴(w/ Mysql)
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 부분은 다른 데이터라고 생각해서 그냥 분리했다.
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을 했음에도 불구하고 잘 안 돼서 좀 헤맸는데 공식문서 보고 해보니까 조금 익숙해졌다.
공식문서 중 이 부분을 주로 참고했다. 참고 글
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
이 아니라 number
나 boolean
타입으로 들어오면 큰일난다. (에러가 체크되지 않기 때문에 터진다.;)
그럼에도 타입 단언을 해줄 수 있었던 이유는, request.query
로 들어오는 categoryId
나 recent
같은 값이 string
타입임을 알고 있었기 때문이다. (애초에 query string이기도 하고) 좀 뒤지다 보니 이런 글도 있었다. [참고글] TypeScript - Request Query String 형태 문제
이 글에서도 두 가지 방법을 알려주고 있었는데, 타입 단언을 하는 게 좀 더 깔끔하기도 했다.
그리고 하나 또 문제였던 점은, 내가 하나의 API로 쿼리가 있을 때와 없을 때를 나누어서 작업을 처리하는 로직을 짰었기 때문도 있었다.
쿼리가 있다 없으니까 (...) undefined
값이 들어오게 될 가능성이 있는데 이래서도 타입 선언을 해주기가 어려웠다.
그래서 일단 string
타입만 들어온다고 타입 단언을 해주었고,
밑에서 validation 체크를 해 error
가 있는지 없는지를 체크해서 각각의 경우에 따라 작업을 요청하도록 로직을 짰다.
error
가 있다는 이야기는 router에서 validation 체크를 했을 때 값이 비었다는 의미와 동일하다.
그래서 error
가 없으면 쿼리 스트링을 전부 사용하는 메소드를, error
가 있으면 어떤 쿼리 스트링이 없는지를 확인해서 그 쿼리 스트링만 제외한 메소드를 요청했다.
이러니까 쉽게 해결이 됨 (사실 하나도 안 쉬웠음 3일 정도 걸렸음 ㅠㅠ)
조금 더 리팩토링을 할 참인데 "categoryId"
같은 string
부분을 상수로 만들어서 갖고 올 참이다. 이 부분은 책을 조회하고 주문하는 서버인 이상 꽤 많이 사용될 거 같아서, 한 군데서 관리를 하면 좀 더 유지보수적인 측면에서 좋을 거 같다.
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 쿼리를 이용해 데이터를 핸들링하는 부분이라 비즈니스 로직이라 볼 수 있다.
categoryId
나 recent
가 있는지 없는지에 따라 SQL 쿼리를 다르게 불러오게 했고, SQL 쿼리와 같이 보내는 params
부분도 마찬가지로 다르게 보내도록 했다.
이 부분은 사실 크게 고민 안 했다. Controller에서 요청하는 대로 분기만 잘하고 DB에 잘 접근만 하면 되는 부분이었기도 했고, 참고했던 글이 있어서 다행이었다.
내 처음 코드에 비하면 정말 많이... 나아진 거 같다.
물론 앞으로 넘어야 할 산이 진짜 많음... (주문하기 부분이 진짜 헬일 거 같다 ㅠㅠ)
하지만 BOOK API를 해결했기 때문에 나머지 부분은 그렇게 어렵지 않을 것 같다!
오늘도 한 단계 성장한 듯~ 행복하다!