node.js plain Javascript DI 적용
- Typescript가 아닌 plain Javascript, 순수 자바스크립트에 DI(dependency injection, 의존성 주입)을 하는 과정을 설명하겠습니다.
- Typescript를 사용하면 @Service 데코레이터를 이용해 쉽게 적용할 수 있지만 plain Javascript 에서는 데코레이터를 지원하지 않지만
Awilix
, typedi
이 두가지 모듈은 자바스크립트에서도 DI를 적용할 수 있는 기능을 지원합니다.
- 해당 게시글은
typedi
모듈의 container를 이용해 이를 구현하도록 하겠습니다.
DI(Dependency Injection, 의존성 주입)을 적용하는 이유
- 아래는 DI를 적용하면 나타나는 장점은 다음과 같다.
Decoupling
: DI는 components간의 연결을 끊어준다. 특정 클래스는 의존하는 클래스가 어디서 왔는지, 어떤식으로 구성되어 있는지 등에 대해 알아야 할 필요가 전혀 없다.
Testability
: DI는 유닛 테스트를 적용하기 쉽게 만들어준다. mock이나 다른 임의의 의존성을 주입하는 방법을 통해 고립된 component의 테스트가 가능하다. 이로인해 쉽게 작성가능한 테스트, 빠른 문제 파악등 신속한 개발 과정을 얻는데 도움을 준다.
Reusability
: DI는 코드의 재사용성을 높여준다. 종속성을 시스템의 다른 부분에 주입할 수 있는 독립된 구성 요소로 분리하면 여러 프로젝트나 모듈에서 코드를 재사용할 수 있다.
Flexibility
: DI는 코드에 유연성을 부여한다. 종속성을 주입하면 인터페이스 변경을 하지 않고 구성 요소의 구현을 변경할 수 있다. 이는 시스템을 보다 쉽게 발전시키며 변화하는 요구사항에 쉽게 적응할 수 있게 만들어준다.
- 전체적으로 DI는 더 나은 코드 구성, 테스트력 그리고 유지력을 제공한다.
미리 알아야할 개념
typedi
- 위에서 언급했지만 해당 게시글에서는 plain Javascript와 typedi 모듈을 사용해 DI를 적용하는 방법에 대해 알아보도록 하겠습니다.
Awilix
, typedi
이 두가지 모듈중 하나를 사용하면 @Service 데코레이터를 사용하지 않고 DI를 적용할 수 있습니다.
- typedi 모듈을 통해 컨테이너 라는 공간에 클래스를 등록한 후 원하는 곳에서 등록된 클래스를 사용하는 개념입니다.
- router에서 container에 등록된 controller 객체를 가져와 함수를 호출하는 과정을 구현하겠습니다. 또한 controller 클래스는 service layer에 의존하고 있으며 service는 repository에 의존하고 있습니다.
router
-> controller
-> service
-> repository
controller
- 아래는 router에서 사용할 BoardController 클래스 파일입니다.
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;
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;
- 해당 클래스는 BoardService 클래스에 의존하고 있으며 constructor(생성자)를 통해 container를 주입받아 컨테이너에서 boardService 객체를 꺼내 의존성을 주입받고 있습니다.
- 위 파일은 전체 파일이며 클래스 외부 어디에서도 boardService.js 파일을 가져오지 않았습니다.
- 클래스 전체를 모듈화해 사용할 예정이므로 맨 아래에
module.exports = BoardController
를 토해 모듈화해준다.
service
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) {
number = Math.max(1, parseInt(number));
number = !isNaN(number) ? number:defaultValue;
return number;
}
async getMatchingTitleCount(key) {
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
};
}
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;
}
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=? 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 = 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;
- controller가 의존하고 있는 service layer이며 해당 클래스도 repository에 의존하고 있다.
- 위와 마찬가지로 이 클래스 또한 생성자에서 container에 등록되어 있는 repository를 주입받는다.
repository
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'
});
}
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;
- service가 의존하고 있는 repository이며 해당 클래스는 config를 의존하고 있다.
- 위와 마찬가지로 이 클래스 또한 생성자에서 container를 받으며 config를 주입받는다.
config
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
const config = {
JWT: {
SECRET: process.env.SECRET_KEY
},
MYSQL: {
USER: process.env.SQL_USER,
PASSWORD: process.env.SQL_PASSWORD,
DATABASE: process.env.MYSQL_DATABASE,
ROOT_PASSWORD: process.env.MYSQL_ROOT_PASSWORD
},
};
module.exports = config;
- config.js는 .env파일을 직접적으로 가져와 config 객체에 값을 할당해준다.
- .env파일을 바로 사용하지 않고 config 객체를 굳이 하나 더 만들어 사용하는 이유는 이런 방식을 사용하면 코드를 짤 때 자동완성 기능을 사용해 오타를 줄일 수 있으며, config 객체 또한 container에 등록해 좀더 편하게 가져와 사용할 수 있을 뿐만 아니라 모든 의존성을 한곳에서 관리할 수 있다.
container 등록
- 우선 container.js 파일에서 프로젝트에 사용할 모듈들을 가져온다.
- 이후, container.set() 를 이용해 컨테이너에 등록할 수 있으며, 나중에 등록된 컨테이너를 가져와 사용할 수 있다.
const container = require('typedi').Container;
const config = require('../../config/config');
const MySQLRepository = require('../MySQLRepository');
const BoardController = require('../controllers/board/BoardController');
const boardService = require('../board/boardService');
container.set('config', config);
container.set('MySQLRepository', new MySQLRepository(container));
container.set('boardService', new boardService(container));
container.set('BoardController', new BoardController(container));
module.exports = container;
- 컨테이너를 등록하는 방법은 위와 같으며, MySQLRepository의 경우 첫번째 인자에 등록시 사용할 이름을 전달해 주며(이경우
MySQLRepository
) 두번째 인자에는 MySQLRepository 객체를 넘겨준다. 이때, MySQLRepository 클래스는 생성자에서 DI를 적용하기 때문에 container를 전달해야한다.
- 한가지 더 주의해야 할 점은 컨테이너에 등록하는 순서인데, 의존하는 클래스가 없는 순으로 등록해야한다.
- config는 의존하는 클래스가 없으므로 제일 우선순위를 차지한다(.env파일은 바로 가져오니 제외).
- 두번째로 MySQLRepository가 컨테이너에 등록되어 있는 config를 의존하므로 그 다음 우선순위를 차지한다.
- 세번째로 boardService가 컨테이너에 등록되어 있는 MySQLRepository를 의존하므로 그 다음 우선순위를 차지한다.
- 마지막으로 BoardController가 컨테이너에 등록되어 있는 boardService를 의존하므로 마지막 우선순위를 차지한다.
- 컨테이너 등록을 마친 후 module.exports = container를 해준다.
- 이 외에도 주의해야 할 점은
circular dependency
를 주의해야 하는데 서로다른 두개의 클래스가 서로 의존하는 경우를 말한다.
- 컨테이너에 등록하려면 클래스 파일을 가져와 객체를 생성해야 하는데 의존하고 있는 클래스가 아직 컨테이너에 등록되어 있지 않는 상황이 맞물려 있어 발생함.
등록된 객체 가져오기
- container.get() 을 사용해 가져올 수 있다.
const container = require('../../models/container/container');
const BoardControllerInstance = container.get('BoardController');
router.get('/', BoardControllerInstance.handleMainRequest);
router.delete('/:resourceType/:id', FilterInstance.authenticationMethodDistinguisher, BoardControllerInstance.handleDeleteResource);
router.post('/:resourceType/:id?', FilterInstance.authenticationMethodDistinguisher, BoardControllerInstance.handlePostResource);
router.put('/:resourceType/:id', FilterInstance.authenticationMethodDistinguisher, BoardControllerInstance.handleUpdateResource);
module.exports = router;
- container.js에서 모든 객체를 등록했다면 이를 사용할 파일에서 위처럼 가져올 수 있다.
- 파일 전체를 container 객체에 넣어준 후 container.get() 을 사용해 원하는 객체를 가져올 수 있다.
- 이때 컨테이너 등록시 사용했던 이름을 함께 넘겨주며 원하는 객체를 선택할 수 있다.
- 가져온 객체는 위처럼 평소 사용하던 대로 사용할 수 있다.
- 추가로 계층별로 필요한 모듈만 가져와 사용하기 때문에 코드의 윗부분이 훨씬 깔끔해지는 효과도 있다.
github