[NodeJS] MVC clean architecture 계층 분할과정 및 code refactoring

Onam Kwon·2023년 3월 21일
0

Node JS

목록 보기
19/25

clean architecture

  • 해당 게시글은 node.js의 계층 분할 과정 및 code refactoring에 대해 다루는 게시글입니다.
  • MVC패턴의 개념은 알지만 실제 구현할 때 특정 부분을 어느 부분에 넣어야 하는지에 관한 내용을 다룹니다.
  • 프로젝트의 구성은 대략적으로 아래와 같으며 크게 controllers models views repositories config 그리고 container로 이루어집니다.
  • 해당 게시글에 나오는 코드는 아직 refactoring 과정중에 있으므로 함수의 이름이 조금씩 다르거나 작동되지 않는 부분이 있을 수 있습니다.
    • 하지만 깃허브 링크의 코드는 계속해서 업데이트 될 예정입니다!
.
├── app.js
├── config
│   └── config.js
├── controllers
│   ├── board
│   ├── chat
│   ├── home
│   └── user
├── filter
│   └── filter.js
├── models
│   ├── APIs
│   ├── DTO
│   ├── MySQLRepository.js
│   ├── authentication
│   ├── board
│   ├── chat
│   ├── connectMongoDB.js
│   ├── container
│   ├── home
│   ├── kakao
│   └── user
├── node_modules
├── package-lock.json
├── package.json
├── public
│   ├── css
│   └── images
├── server.js
├── test
│   └── server.spec.js
└── views
    ├── board
    ├── chat
    ├── home
    └── user
  • views 유저가 보이는 화면, 즉 클라이언트, 브라우저를 나타내며 유저와 제일 가까운곳에 위치해 유저의 요청을 보내주며 응답을 나타냅니다.
  • routers 클라이언트 요청을 적절한 컨트롤러에게 전달하기 위해 존재하며 라우터에서 컨트롤러 함수를 호출합니다.
    • 컨트롤러 함수는 service에 의존하며 주입받은 서비스 클래스를 호출할 수 있습니다.
    • 해당 게시글에서 router는 controllers 디렉토리에 컨트롤러와 함께 위치하고 있습니다.
  • controllers는 router로부터 전달받은 요청을 처리하며 최종적으로 생성된 결과를 다시 클라이언트에 응답하는 역할을 합니다.
    • controller와 router를 나눔으로써 보다 나은 코드 구성, 유지력, 재사용성 그리고 관리력을 얻을 수 있습니다.
  • models DB와 관련된 개념상의 표현이며, repository계층을 포함한다. 해당 앱이 어떤것을 수행하는지 정의합니다.
  • repositories DB에 접근하는 계층.
  • config 설정파일.
  • container DI를 적용할 때 사용하는 파일. typedi 모듈 사용.
  • 게시글과 관련있는 부분만 설명하겠습니다.

routers

/**
 * boardRouter.js
 */

const express = require("express");
const router = express.Router();

const container = require('../../models/container/container');
const FilterInstance = container.get('Filter');
const BoardControllerInstance = container.get('BoardController');

// '/board'
router.get('/', BoardControllerInstance.handleMainRequest);
router.get('/write', FilterInstance.authenticationMethodDistinguisher, BoardControllerInstance.handleGetArticleForm);
router.get('/auto-completion', BoardControllerInstance.handleGetAutoComplete);
router.get('/:id', FilterInstance.authenticationMethodDistinguisher, BoardControllerInstance.handleGetArticle);

router.get('/:resourceType/:id', FilterInstance.authenticationMethodDistinguisher, BoardControllerInstance.handleGetArticleForm2);

// Here resourceType is a placeholder. Depends on its value, routers handle an article or comment.
router.delete('/:resourceType/:id', FilterInstance.authenticationMethodDistinguisher, BoardControllerInstance.handleDeleteResource);
router.post('/:resourceType/:id?', FilterInstance.authenticationMethodDistinguisher, BoardControllerInstance.handlePostResource);
router.put('/:resourceType/:id', FilterInstance.authenticationMethodDistinguisher, BoardControllerInstance.handleUpdateResource);

router.get('/article/:id', FilterInstance.authenticationMethodDistinguisher, BoardControllerInstance.showEditingArticle);

module.exports = router;
  • 클라이언트 요청을 적절한 컨트롤러에게 라우팅하며 해당 라우터에서 컨트롤러의 함수를 호출합니다.
  • 라우터와 컨트롤러를 분리해 더 나은 코드 구성을 가졌으며, 유지력, 관리력, 그리고 재사용성 또한 높아졌습니다.
  • 함수를 2개 호출하는 라우터가 있는데 이는 로그인 처리와 같이 여러 라우터에서 공통적으로 적용되는 작업을 함수화해 filter 클래스에 넣은 후 컨트롤러 시작 전에 우선적으로 실행하도록 했습니다.
    • 이로인해 코드의 중복되는 부분을 줄이고 함수의 재사용성을 높힐수 있었습니다.

Filter

  • Filter 클래스는 말그대로 필터 역할을 하기 때문에 Filter라고 이름 붙혔습니다.
  • 저같은 경우 jwt로그인과 Kakao REST API 로그인 두가지를 지원하기 때문에 로그인이 필요한 요청의 모든 컨트롤러 시작 부분에 유저가 두가지 방법중 어떠한 방법으로 로그인 했는지 파악후 해당 방법에 맞는 방식으로 유저 정보를 파악해 요청을 수행해야 합니다.
    • 이와 같이 중복된 코드의 사용이 자주 발생해 Filter 클래스를 만들어 컨테이너에 등록 후 필요한 곳에서 가져와 호출만 하면 중복된 코드를 방지하며 사용할 수 있습니다. (이 경우 라우터에서 호출)
/**
 * filter.js
 */

const jwt = require('jsonwebtoken');

class Filter {
    #config
    constructor(container) {
        this.#config = container.get('config');
        this.userServiceInstance = container.get('userService');
        this.kakaoServiceInstance = container.get('kakaoService');
    }

    async getJWTInfo(JWT_TOKEN, SECRET_KEY) {
        return await jwt.verify(JWT_TOKEN, SECRET_KEY);
    }

    async getKakaoInfo(kakao_access_token) {
        const { nickname, profile_image } = await this.kakaoServiceInstance.getUserInfo(kakao_access_token);
        const user = {
            id: nickname,
            address: profile_image
        }
        return user;
    }

    defineLoginMethod(req) {
        if(req.cookies.user) {
            return 'jwt';
        } else if(req.session.access_token) {
            return 'kakao';
        }
        return undefined; 
    }
    authenticationMethodDistinguisher = async (req, res, next) => {
        const loginMethod = this.defineLoginMethod(req);
        switch(loginMethod) {
            case 'jwt': {
                req.user = await this.getJWTInfo(req.cookies.user, this.#config.JWT.SECRET);
                return next();
            }
            case 'kakao': {
                req.user = await this.getKakaoInfo(req.session.access_token);
                return next();
            }
            default:
                return res.redirect('/user');
        }
    }
}

module.exports = Filter;

controllers

  • 라우터에서 컨트롤러의 함수를 호출해 요청을 수행하며 최종적으로 생성된 결과를 클라이언트에 응답하는 역할 또한 컨트롤러에서 수행합니다.
  • 라우터와 컨트롤러의 분리로 여러가지 장점을 얻을 수 있습니다.
/**
 * boardConroller.js
 */

const path = require('path');

class BoardController {
    constructor(container) {
        this.serviceInstance = container.get('boardService');
    }

    handleMainRequest = async (req, res, next) => {
        const user = req.user;
        const { search, 'current-page': currentPage, 'items-per-page': itemsPerPage } = req.query;
        const { articles, pagination } = await this.serviceInstance.getPaginatedArticlesByTitle(search, currentPage, itemsPerPage);
        if(articles.length===0) {
            return res.status(400).send('There is no matching result.').end();
        }
        return res.render(path.join(__dirname, '../../views/board/board'), {
            articles, 
            user: (user) ? (user.id) : ('Guest'),
            pagination,
            search
        });
    }

    handleGetArticleForm2 = async (req, res) => {
        const user = req.user;
        const { resourceType, id } = req.params;
        console.log('resourceType:', resourceType);
        try {
            switch(resourceType) {
                case 'write': {
                    return res.render(path.join(__dirname, '../../views/board/boardWrite'), {user:user});
                }
                case 'edit': {
                    const article = await this.serviceInstance.getArticleById(id);
                    if(user.id===article.AUTHOR) {
                        return res.render(path.join(__dirname, '../../views/board/editArticle'), {user:user, article:article});
                    }
                }
                default: 
                    return res.status(400).send('No matching request.');
            }
        } catch(err) {
            console.error(err);
            return res.status(500).send(err.message);
        }
    }

    handleGetArticleForm = async (req, res) => {
        try {
            const user = req.user;
            return res.render(path.join(__dirname, '../../views/board/boardWrite'), {user:user});
        } catch(err) {
            console.error(err);
            return res.status(500).send(err.message);
        }
    }
    
    handleGetAutoComplete = async (req, res, next) => {
        try {
            const { keyStroke } = req.query;
            const titles = await this.serviceInstance.searchTitleByChar(keyStroke);
            return res.status(200).send(titles).end();
        } catch(err) {
            console.error(err); 
            return res.status(500).send(err.message);
        }
    }

    handleGetArticle = async (req, res, next) => {
        try {
            const user = req.user;
            const { id } = req.params;
            const { article, comments } = await this.serviceInstance.getArticleItems(id);
            return res.render(path.join(__dirname, '../../views/board/article'), {user, article, comments});
        } catch(err) {
            console.error(err); 
            return res.status(500).send(err.message);
        }
    }

    handleDeleteResource = async (req, res, next) => {
        const user = req.user;
        const { resourceType, id } = req.params;
        const isUserValidated = await this.serviceInstance.validateUserWithAuthor(user.id, resourceType, id);
        if(!isUserValidated) {
            return res.status(400).send('Account not matched.').end();
        }
        try {
            switch(resourceType) {
                case 'article': {
                    var affectedRows = await this.serviceInstance.deleteArticleById(id);
                    break;
                }
                case 'comment': {
                    var affectedRows = await this.serviceInstance.deleteComment(id);
                    break;
                }
                default: 
                    break;
            }
            if(affectedRows===1) {
                return res.status(200).send(`${resourceType} has been removed.`).end(); 
            } else {
                return res.status(400).send('Something went wrong').end(); 
            }
        } catch(err) {
            console.error(err);
            return res.status(500).send(err.message);
        }
    }
    
    handlePostResource = async (req, res) => {
        const user = req.user;
        /**
         * In article, id refers nothing. undefined.
         * In comment, id refers article_id.
         * In reply, id refers comment_id that a user is replying to.
         */
        const { resourceType, id } = req.params;
        try {
            switch(resourceType) {
                case 'article': {
                    const { title, content } = req.body; 
                    var affectedRows = await this.serviceInstance.insertArticle(title, content, user.id);
                    break;
                }
                case 'comment': {
                    const { content } = req.body;
                    var affectedRows = await this.serviceInstance.insertComment(id, user.id, content);
                    break;
                }
                case 'reply': {
                    const { group_num, content } = req.body;
                    var affectedRows = await this.serviceInstance.insertReply(id, user.id, group_num, content);
                    break;
                }
                default:
                    return res.status(400).send('Invalid resource type.');
            }
            if(affectedRows===1) {
                return res.status(200).send(`${resourceType} has been posted.`);
            } else {
                return res.status(400).send('Something went wrong.');
            }
        } catch(err) {
            console.error(err);
            return res.status(500).send(err.message);
        }
    }

    handleUpdateResource = async (req, res) => {
        const user = req.user;
        const { resourceType, id } = req.params;
        const isUserValidated = await this.serviceInstance.validateUserWithAuthor(user.id, resourceType, id);
        if(!isUserValidated) {
            return res.status(400).send('Account not matched.').end();
        }
        try {
            switch(resourceType) {
                case 'article': {
                    const { title, content } = req.body;
                    var affectedRows = await this.serviceInstance.updateArticle(id, title, content);
                    break;
                }
                case 'comment': {
                    const { content } = req.body;
                    var affectedRows = await this.serviceInstance.editCommentByNum(id, content);
                    break;
                }
                default:
                    return res.status(400).json({ error: 'Invalid resource type.' });
            }
            if(affectedRows===1) {
                return res.status(200).send(`${resourceType} has been updated.`);
            } else {
                return res.status(400).json({ error: 'Something went wrong.' });s 
            }
        } catch(err) {
            console.error(err);
            return res.status(500).send(err.message);
        }
    }

    showEditingArticle = async (req, res) => {
        const user = req.user;
        const { id } = req.params;
        const article = await this.serviceInstance.getArticleById(id);
        if(user.id===article.AUTHOR) {
            return res.render(path.join(__dirname, '../../views/board/editArticle'), {user:user, article:article});
        }
    }

}

module.exports = BoardController;
  • 몇몇 컨트롤러는 resourceType 파라미터를 사용해 해당 요청이 무엇인지 구분하여 switch-case satement로 요청별 작업을 완료할 수 있게 했습니다.
    • 코드의 중복 감소. 재사용 증가.
  • 또한 컨트롤러에서 요청별로 하나의 서비스 함수만 호출해 서비스 함수 내에서 모든 로직이 이루어 지고 결과만 얻을 수 있도록 했습니다.
    • 계층별 역할 분리. 적절한 함수 이름 사용.
  • try-catch statement의 사용으로 에러 핸들링.
  • 컨트롤러의 적절한 함수명 - http메소드를 나타냈으며 어떠한 역할인지 또한 설명 가능합니다.
    • 비슷한 종류의 여러 요청이 있을 경우 컨트롤러 함수 내부에서 switch-case statement를 사용해 요청을 나눴습니다.
      • 코드의 중복 감소. 재사용 증가.

service

  • controller 계층에서 의존하는 계층이며 컨트롤러가 호출하는 함수를 가지고 있습니다.
  • 이곳에서 모든 비지니스 로직이 수행되며 이 계층은 repository 계층에 의존합니다.
  • repository 계층에서 가져온 데이터를 가공해 컨트롤러에 리턴하며 controllerrepository 계층간의 중간 역할을 담당합니다.
// boardService.js

class boardService {
    #REQUEST_URL
    constructor(container) {
        const config = container.get('config');
        this.#REQUEST_URL = 'https://kauth.kakao.com/oauth/authorize?response_type=code&client_id='+config.KAKAO.REST_API_KEY+'&redirect_uri='+config.KAKAO.REDIRECT_URI; 
        this.repository = container.get('MySQLRepository');
    }

    convertDateFormat(date) {
        date = date.toLocaleString('default', {year:'numeric', month:'2-digit', day:'2-digit'});
        let year = date.substr(6,4);
        let month = date.substr(0,2);
        let day = date.substr(3,2);
        let convertedDate = `${year}-${month}-${day}`;
        return convertedDate;
    }
    
    convertTableDateFormat(table) {
        for(let i=0;i<table.length;i++) {
            table[i].POST_DATE = this.convertDateFormat(table[i].POST_DATE);
            table[i].UPDATE_DATE = this.convertDateFormat(table[i].UPDATE_DATE);
        }
        return table;
    }
    
    convertArticleDateFormat(article) {
        article.POST_DATE = this.convertDateFormat(article.POST_DATE);
        article.UPDATE_DATE = this.convertDateFormat(article.UPDATE_DATE);
        return article;
    }
    
    async getTitlesIncludeString(titles, search) {
        let result = [];
        for(let i=0;i<titles.length;i++) {
            if(titles[i].TITLE.includes(search)) result.push(titles[i]);
        }
        return result;
    }
    convertToNumber(number, defaultValue) {
        // Ensuring that the variable value is an integer greater than or equal to 1.
        number = Math.max(1, parseInt(number)); 
        // Making sure that the number is always a number, and if it's not, it defaults to 1.
        number = !isNaN(number) ? number:defaultValue; 
        return number;
    }

    async getMatchingTitleCount(key) {
        // const sql = `SELECT COUNT(TITLE) AS matchingTitleCount FROM BOARD WHERE MATCH(TITLE) AGAINST(? IN NATURAL LANGUAGE MODE);`;
        key ??= '';
        const sql = `SELECT COUNT(TITLE) AS matchingTitleCount FROM BOARD WHERE TITLE LIKE ?;`;
        const values = [`%${key}%`];
        const [[res]] = await this.repository.executeQuery(sql, values);
        return res.matchingTitleCount;
    }

    async getPageItems(totalItems, currentPage, itemsPerPage) {
        currentPage = this.convertToNumber(currentPage, 1);
        itemsPerPage = this.convertToNumber(itemsPerPage, 10);

        const totalPages = Math.ceil(totalItems/itemsPerPage);
        currentPage = currentPage>totalPages ? 1 : currentPage;
        const startIndex = (currentPage-1) * itemsPerPage;
        const endIndex = (currentPage===totalPages) ? totalItems-1 : (currentPage*itemsPerPage-1);
        return {
            currentPage,
            itemsPerPage,
            totalPages,
            startIndex,
            endIndex
        };
    }

    /**
     * ArticlesService
     */
    async getAllArticles() {
        const sql = `SELECT * FROM BOARD ORDER BY BOARD_NO DESC;`;
        let [articles] = await this.repository.executeQuery(sql);
        articles = this.convertTableDateFormat(articles);
        return articles;
    }

    async searchArticlesByTitle(title, startIndex, endIndex) {
        title ??= '';
        const sql = `SELECT * FROM BOARD WHERE TITLE LIKE ? ORDER BY BOARD_NO DESC LIMIT ? OFFSET ?;`;
        const LIMIT = endIndex-startIndex+1
        const OFFSET = startIndex===0 ? 0 : startIndex;
        const values = [`%${title}%`, LIMIT, OFFSET];
        let [articles] = await this.repository.executeQuery(sql, values);
        articles = this.convertTableDateFormat(articles);
        return articles;
    }

    async searchTitleByChar(keyStroke) {
        const sql = `SELECT TITLE FROM BOARD WHERE TITLE LIKE ? ORDER BY BOARD_NO DESC;`;
        const values = [`%${keyStroke}%`];
        let [titles] = await this.repository.executeQuery(sql, values);
        return titles;
    }

    async getArticleById(id) {
        const sql = `SELECT * FROM BOARD WHERE BOARD_NO=?;`;
        const values = [ id ];
        let [article] = await this.repository.executeQuery(sql, values);
        article = this.convertArticleDateFormat(article[0]);
        return article;
    }

    async insertArticle(title, content, author) {
        const sql = `INSERT INTO BOARD (TITLE, content, POST_DATE, UPDATE_DATE, AUTHOR) VALUES ?;`;
        const date_obj = new Date();
        const post_date = date_obj.getFullYear() +"-"+ parseInt(date_obj.getMonth()+1) +"-"+ date_obj.getDate();
        const update_date = post_date;
        let values = [
            [title, content, post_date, update_date, author]
        ];
        const [res] = await this.repository.executeQuery(sql, [values]);
        return res.affectedRows;
    }

    async deleteArticleById(id) {
        const sql = `DELETE FROM BOARD WHERE BOARD_NO=?;`;
        const values = [ id ];
        const [res] = await this.repository.executeQuery(sql, values);
        return res.affectedRows;
    }

    async updateArticle(article_num, title, content) {
        const time = this.getTime(); 
        const sql = 'UPDATE BOARD SET TITLE=?, content=?, UPDATE_DATE=? WHERE BOARD_NO=?;';
        const values = [title, content, time, article_num];
        const [res] = await this.repository.executeQuery(sql, values);
        return res.changedRows;
    }

    /**
     * CommentService
     */

    async getMaxCommentOrder(id, group_num) {
        const sql = `SELECT MAX(comment_order) AS maxCommentOrder FROM comment WHERE article_num=? AND group_num=?;`;
        const values = [ id, group_num];
        const [[res]] = await this.repository.executeQuery(sql, values);
        res.maxCommentOrder ??= 0;
        return res.maxCommentOrder;
    }
    
    async getNewGroupNum(id) {
        const sql = `SELECT MAX(comment.group_num) AS maxGroupNum FROM BOARD LEFT JOIN comment ON BOARD.BOARD_NO=comment.article_num WHERE BOARD.BOARD_NO=?;`;
        const values = [ id ];
        const [[res]] = await this.repository.executeQuery(sql, values);
        res.maxGroupNum ??= 0;
        return res.maxGroupNum+1;
    }
    
    getTime() {
        const date_obj = new Date();
        let date = date_obj.getFullYear() +"-"+ parseInt(date_obj.getMonth()+1) +"-"+ date_obj.getDate()+" ";
        let time = date_obj.getHours() +":"+ date_obj.getMinutes() +":"+ date_obj.getSeconds();
        time = date+time;
        return time;
    }
    
    async getCommentsByArticleId(article_num) {
        // const sql = "SELECT * FROM COMMENT WHERE article_num='"+article_num+"';";
        const sql = `SELECT * FROM comment WHERE article_num=? ORDER BY group_num, comment_order ASC;`
        const values = [ article_num ];
        let [comments] = await this.repository.executeQuery(sql, values);
        return comments;
    }
    
    async insertComment(article_num, author, content) {
        const sql = `INSERT INTO comment (article_num, author, time, class, comment_order, group_num, content) VALUES ?;`;
        const time = this.getTime();
        const depth = 0;
        const new_group = await this.getNewGroupNum(article_num);
        // const comment_order = parseInt(length) + 1;
        const comment_order = await this.getMaxCommentOrder(article_num, new_group)+1;
        let values = [
            [article_num, author, time, depth, comment_order, new_group, content]
        ];
        const [res] = await this.repository.executeQuery(sql, [values]);
        return res.affectedRows;
    }
    
    async editCommentByNum(id, content) {
        const query = `UPDATE comment SET content=? WHERE comment_num=?;`;
        const values = [id, content];
        const [res] = await this.repository.executeQuery(query, values);
        return res.affectedRows;
    }
    
    async insertReply(article_num, author, group_num, content) {
        const sql = `INSERT INTO comment (article_num, author, time, class, comment_order, group_num, content) VALUES ?;`;
        const time = this.getTime();
        const depth = 1;
        const comment_order = await this.getMaxCommentOrder(article_num, group_num)+1;
        let values = [
            [article_num, author, time, depth, comment_order, group_num, content]
        ];
        const [res] = await this.repository.executeQuery(sql, [values]);
        return res.affectedRows;
    }
    
    async getCommentAuthorById(id) {
        const sql = `SELECT author FROM comment WHERE comment_num=?;`;
        const values = [ id ];
        const [[commentAuthor]] = await this.repository.executeQuery(sql, values);
        return commentAuthor.author;
    }
    
    async deleteComment(id) {
        const sql = `UPDATE comment SET author='deleted', content='deleted', time=NULL WHERE comment_num=?;`;
        const values = [ id ];
        const [res] = await this.repository.executeQuery(sql, values);
        return res.affectedRows;
    }
    
    async getPaginatedArticlesByTitle(title, currentPage, itemsPerPage) {
        const matchingTitleCount = await this.getMatchingTitleCount(title); 
        const pagination = await this.getPageItems(matchingTitleCount, currentPage, itemsPerPage);
        const articles = await this.searchArticlesByTitle(title, pagination.startIndex, pagination.endIndex);
        return { articles, pagination };
    }

    async getArticleItems(id) {
        const article = await this.getArticleById(id);
        const comments = await this.getCommentsByArticleId(id);
        return { article, comments };
    }

    async validateUserWithAuthor(userId, resourceType, resourceId) {
        switch(resourceType) {
            case 'article': {
                const article = await this.getArticleById(resourceId);
                return userId===article.AUTHOR;
            }
            case 'comment': {
                const commentAuthor = await this.getCommentAuthorById(resourceId); 
                return userId===commentAuthor;
            }
            default:
                return false;
        }
    }
}

module.exports = boardService;
  • service 계층의 함수는 크게 두가지로 나뉜다.
    • service methods, service functions: 일반적으로 controller에 의해 호출되며 컨트롤러에게 최종 결과물을 반환하는 함수이다. 이 함수는 0개 이상의 helper function으로 구성되어 있습니다.
    • helper functions, utility functions: service function보다 더 작은 단위와 자세한 태스크를 수행합니다. 또한 대체로 이 함수들이 다른곳에서 재사용될 가능성이 높습니다.
  • service 계층은 repository 계층에 DI를 통해 의존하며 sql 쿼리는 ? 기호를 사용해 placeholder 역할을 합니다.
    • SELECT FROM BOARD WHERE ID=${id};가 아닌 SELECT FROM BOARD WHERE ID=?; 를 사용한 후 values 값을 인자로 함께 넘겨줍니다.
    • 이는 서버에 의해 미리 컴파일 되기 때문에 보안성과 퍼포먼스 향상에 도움을 줍니다.
      • ? symbol을 사용해 만든 SQL 쿼리를 prepared statement라고 부르며 prepared statement는 다른 parameter values를 가진 같은 SQL 쿼리문을 반복적으로 사용하게 해줍니다.
      • prepared statement를 사용하면 SQL 쿼리는 DB서버에 의해 먼저 최적화와 컴파일 과정을 거치며 결과적으로 placeholder에 parameter values값만 넣어지며 재사용됩니다. 따라서 후속 실행된 쿼리는 재사용 되기 때문에 성능이 향상됩니다.
    • node.js는 컴파일 언어가 아닌 인터프리터 언어인데 어떻게 미리 컴파일 되나요?
      • node.js 에서 MySQL연결시 사용하는 드라이버들인 mysql2 pg sqlite3 등은 node.js runtime내부가 아닌 해당 드라이버에서 스스로 컴파일과 SQL 쿼리를 최적화 합니다. 이로인해 ?를 활용한 placeholder 사용은 node.js가 인터프리터 언어임에도 불구하고 여전히 성능 향상에 도움을 줍니다.

repository

  • service 계층에서 의존하는 계층이며 서비스 계층에서 SQL 쿼리와 함께 repository의 쿼리 실행 함수를 호출합니다.
  • 이 계층은 service 계층에 의해 호출되며 DB에서 가져온 데이터를 다시 서비스 계층에 반환합니다.
  • serviceDB간 중간 역할을 담당합니다.
/**
 * MySQLRepository
 */

const mysql = require('mysql2/promise');

class MySQLRepository {
    constructor(container) {
        console.log('MySQL has been connected...');
        const config = container.get('config');
        this.pool = mysql.createPool({
            connectionLimit: 10,
            host: 'localhost',
            user: config.MYSQL.USER,
            password: config.MYSQL.PASSWORD,
            database: 'board_db'
        });
        // this.query = util.promisify(this.pool.query).bind(this.pool); 
    }

    async executeQuery(sql, values) {
        let connection = null;
        let res = null;
        try {
            connection = await this.pool.getConnection();
            res = await connection.query(sql, values);
            return res;
        } catch(err) {
            console.log('err:', err);
            throw new Error(err);
        } finally {
            if(connection) {
                connection.release();
            }
        }
    }
}

module.exports = MySQLRepository;
  • connection pool을 사용했으며 이로인해 성능향상, 확장성, 효율적인 자원관리, 개선된 연결 관리 그리고 강화된 보안을 얻을 수 있습니다.
    • Better performance: DB에 새로운 연결을 생성할때 많은 자원이 요구되는데 connection pool을 사용하면 연결이 지속되어 있고 재사용되며 이는 결과적으로 성능향상으로 이어집니다.
    • Scalability: DB서버에 과부하를 주지 않고도 DB 연결을 처리할 수 있도록 하며 이는 확장성을 올려줍니다.
    • Efficient resource utilization: 새로운 연결을 생성하지 않고 기존에 존재하던 연결을 다른 클라이언트와 나눠 사용하므로 자원의 사용을 감소시켜줍니다.
    • Improved connection management: pool은 자동으로 잘못된 연결을 추적하고 새로운 연결로 교체해 줍니다. 이는 연결을 관리하는데 많은 도움을 줍니다.
    • Better security: pool은 제한된 숫자의 연결을 제공하므로 이는 인터넷상에 노출되는 연결의 갯수를 줄여주며 이는 보안성을 올리는데 도움을 줍니다.

typedi container

  • container.js에서 모든 의존 파일들을 관리합니다.
  • 모든 파일들은 이곳에서 require()되며 의존하는 파일이 없는 순으로 컨테이너에 등록되어지며 마지막에 컨테이너를 모듈화 합니다.
  • 이렇게 모듈화 된 컨테이너들은 이 파일 외부에서도 container만 주입 받으면 container에서 가져와 사용할 수 있습니다.
    • @Service 데코레이터를 지원하지 않는 plain javascript에서 DI를 구현할 수 있는 방법입니다.
/**
 * container.js
 */

const container = require('typedi').Container;

const config = require('../../config/config');
const mongoose = require('mongoose'); 
const userSchema = require('../DTO/user');
const User = mongoose.model('User', userSchema);  

const MySQLRepository = require('../MySQLRepository');
const userRepository = require('../user/userRepository');
const filter = require('../../filter/filter');
const UserController = require('../../controllers/user/UserController');
const game2048Repository = require('../2048/2048Repository');
const game2048Service = require('../2048/2048Service');
const kakaoService = require('../kakao/kakaoService');
const userService = require('../user/userService');
const boredAPI = require('../APIs/boredAPI');
const homeService = require('../home/homeService');
const boardService = require('../board/boardService');
const BoardController = require('../../controllers/board/boardController');

container.set('User', User);
container.set('config', config);
container.set('MySQLRepository', new MySQLRepository(container));
container.set('userRepository', new userRepository(container));
container.set('game2048Repository', new game2048Repository());
container.set('game2048Service', new game2048Service(container));
container.set('kakaoService', new kakaoService(container));
container.set('userService', new userService(container));
container.set('Filter', new filter(container));
container.set('UserController', new UserController(container));
container.set('boredAPI', new boredAPI());
container.set('homeService', new homeService(container));
container.set('boardService', new boardService(container));
container.set('BoardController', new BoardController(container));

module.exports = container;

전체적인 과정

  • 전체적인 과정을 보면 아래와 같습니다.
+-----------+    +-----------+    +-----------+    +-----------+    +-----------+    +-----------+
|   Client  | -> |    App    | -> |   Router  | -> | Controller| -> |  Service  | -> | Repository|
+-----------+    +-----------+    +-----------+    +-----------+    +-----------+    +-----------+
                                                                                           | 
                                                                                           | 
                                                                                           v 
                                                                                     +-----------+
                                                                                     |     DB    |
                                                                                     +-----------+
                                                                                           | 
                                                                                           | 
                                                                                           v 
                                  +-----------+    +-----------+    +-----------+    +-----------+
                                  |  Client   | <- | Controller| <- |  Service  | <- | Repository|
                                  +-----------+    +-----------+    +-----------+    +-----------+
  • 유저는 클라이언트의 버튼을 통해 앱에 요청을 보내고 해당 요청은 라우터를 거쳐 컨트롤러에게 전달됩니다. 요청을 전달 받은 컨트롤러는 서비스 함수를 호출하며 이때 필요한 자원도 함께 넘겨줍니다. 서비스 함수는 repository를 통해 DB의 데이터에 접근하며 repository는 다시 서비스에게 이를 반환합니다. 서비스는 전달받은 데이터를 가공한 후 컨트롤러에게 반환하고 컨트롤러는 이를 클라이언트에게 전달합니다.

github

profile
권오남 / Onam Kwon

1개의 댓글

comment-user-thumbnail
2024년 2월 13일

잘 참고하겠습니다!

답글 달기