이번에는 조금 심화로 들어가 보도록 하겠다. 이전 게시물에서는 하나의 CRUD만을 만들었다면 이번에는 실제 blog가 가지고 있는 구조로 CRUD를 확장해 보도록 하겠다. 한번 블로그를 상상해보자. 먼저 게시글들을 포함하는 category가 있을 것이다. 그리고 각각의 게시물들은 제목과 내용이 존재할 것이고, 마지막으로 댓글을 달 수 있는 기능이 있을 것이다.
여기서 중요한 포인트는 댓글은 게시글에, 게시글은 카테고리 안에 포함되어야 한다는 것이다. 눈치 챘을지 모르겠지만 우리는 이미 카테고리는 만들었다. 이전 포스팅에서 만들었던 blogs.js가 바로 그것이다. 이 시점에서 프로젝트를 좀 더 구조적으로 만들기 위해서 디렉토리를 재구성할 필요가 있다.
blogs: 카테고리
articles: 게시글
comments: 댓글 (comments는 이후에 구성할 것이기 때문에 여기선 제외한다.)
index.js: blogs/index.js로 라우팅한다.
blogs/index.js: blogs/blogs.js와 blogs/articles/index.js를 라우팅 한다.
blogs/articles/index.js: blogs/articles/articles.js를 라우팅 한다.
언뜻 보면 우리가 이전에 해왔던 라우팅과 크게 다르지 않다. 하지만 이번 프로젝트는 앞서 말했듯이 각 CRUD들이 상위 디렉토리에 포함되어 있어야 한다. 때문에 이번 프로젝트에는 mergeParams의 개념이 들어간다.
mergeParams란?
상위 라우터에서 req.params 값을 유지하는 것을 의미한다. 만약 부모와 자식이 상충하는 경우는 자식의 가치를 우선시한다.
※ 따로 ture로 지정해주지 않으면 default 값은 false로 되어있다.
예를 들어 설명하겠다. 예를 들어 blogIdx가 2번인 (blogIdx 값은 primary키이며 auto increment 설정을 해두었기 때문에, blog에 데이터가 증가할 때마다 값이 1씩 상승한다.) 있을 때 우리는 그 카테고리 안에 값을 유지해야 하기 때문에 해당 blogIdx를 params 값으로 url을 유지해야만 한다. 즉, blogIdx가 2인 카테고리에 첫 번째 게시물을 create 한다면 그 URL은 blogs/2/articles/1 이 될 것이다.
※ Get방식에 대한 이해가 부족하다면 해당 포스팅을 참고하자. (Reference: https://ooeunz.tistory.com/43)
이번 포스팅 역시 코드 베이스가 크기 때문에 대표적인 코드만 살펴보도록 하겠다.
Routing
앞서 이야기했듯이 이번 프로젝트는 mergeParams개념이 들어간다. 따라서 blogs에서 articles를 라우팅 할 때 아래와 같이
router.use('/:blogIdx/articles', require('./articles'));
형식으로 라우팅을 해준다. 이 코드가 무슨 뜻인가 의아할 수 있는 독자들을 위해 잠시 설명하자면, 현재 blogs 디렉토리까지 들어와 있으므로 blogs/블로그의 Idx값/articles라는 URL로 접속하면 현재 디렉토리 기준 ./article에 있는 index.js로 라우팅 된다는 뜻이다. 지금까지는 단순히 router.use('/article', require('./articles')); 과 같은 라우팅을 해왔기 때문에 이 부분에 대해서 헷갈리지 않도록 확실히 이해하고 다음으로 넘어가길 권고한다. 그리고 위에서 설명했듯 mergeParams를 적용하기 위해
const router = express.Router({mergeParams: true});
와 같이 mergeParams값을 ture로 적용시켜준다. (default 값은 false이다.)
이제 routes > blogs > articles > index.js로 이동해보자. 여기서 해야 할 일은 articles 디렉토리 안에 있는 articles.js 파일로 라우팅 시켜주는 일이 해주어야 한다. blogs/blogIdx/articles로 접속하면 articles > articles.js로 접속되도록 루트 값으로 '/' 경로를 지정해준다.
눈치챈 독자들도 있겠지만, 나중에 comments 디렉토리를 만들게 되면 articles 디렉토리 안에서 blogs 디렉토리에서 했던 것처럼 comments를 라우팅 해주면 된다.
Foreign key로 blogs와 연결된 articles Database 만들기
MySQL workbench를 열어주고 새로운 table articles를 만들어준다. 그리고 column들을 만들어 줄 건데 여기서 포인트는 blogIdx라는 blog 테이블에 있는 primary key와 연결해줄 column을 만들어준다는 것이다. 그리고, article 테이블도 primary key인 articleIdx을 만들어주고 auto incremennt 값을 넣어준다.
그런 다음 아래의 Foreign Keys 탭으로 이동하여 fk(foreign key의 줄임 말)를 설정해 줄 것이다.
Foreign Key : foreign Key의 이름을 만들어준다. 이 값은 fk끼리 이름이 중복되면 안 된다.
Referenced Table : 참조할 table을 선택해준다.
Column : 조금 전에 만들어준 fk로 연결해줄 key를 선택한다.
Referenced Column : 참조할 key를 선택한다. (Column을 선택하면 보통 자동으로 지정된다.)
모든 옵션을 선택했다면 Apply를 눌러서 table을 생성해준다.
models > Article.js
이 부분부터는 앞 포스팅에의 코드와 크게 다른 점은 없다. 다만 몇 가지만 주의해주면 된다. 먼저 이전 포스팅에서는 객체를 export.modules로 바로 export 시켜주었었다. 하지만, 이번 프로젝트 코드를 짤 때는 비동기적으로 내부에 코드가 다 수행되지 않는 상태로 exrport 되는 이슈가 있었다. 따라서 먼저 article에 객체를 넣어준 다음, export.modules = article을 시켜주도록 한다.
그럼 다시 위의 코드를 보도록 하자. 눈치챘다시피 이전 create코드와 크게 다르지 않다. 다만 한 가지 주의해야 할 점은 앞서 생성한 fk인 blogIdx를 직접 지정해서 넣어주어야 한다는 것이다. (pk인 articleIdx는 auto increment 될 것이다.)
그리고 update나 selectOne과 같이 특정 resource를 지정해야 하는 경우에는 blog 안의 article이기 때문에 blogIdx와 articleIdx를 함께 비교하여 해당 resource를 지정해야 한다.
Article.js
const au = require('../modules/util/authUtil');
const rm = require('../modules/util/responseMessage');
const sc = require('../modules/util/statusCode');
const pool = require('../modules/db/pool');
// global variable
const table = 'article';
// exports
article = {
insert: async (title, content, blogIdx) => {
const fields = 'title, content, blogIdx';
const questions = `'${title}', '${content}', '${blogIdx}'`;
const query = `INSERT INTO ${table} (${fields}) VALUES(${questions})`;
const result = await pool.queryParam_None(query);
// running
if (!result) {
return {
code: sc.BAD_REQUEST,
json: au.successFalse(rm.BOARD_CREATE_FAIL)
}
}
return {
code: sc.OK,
json: au.successTrue(rm.BOARD_CREATE_SUCCESS, result)
}
},
selectOne: async (blogIdx, articleIdx) => {
const query = `SELECT * FROM ${table} WHERE blogIdx = '${blogIdx}' AND articleIdx = '${articleIdx}'`;
const result = await pool.queryParam_None(query);
if (!result) {
return {
code: sc.BAD_REQUEST,
json: au.successFalse(rm.BOARD_READ_FAIL)
};
}
return {
code: sc.OK,
json: au.successTrue(rm.BOARD_READ_SUCCESS, result)
};
},
selectAll: async () => {
const query = `SELECT * FROM ${table}`;
const result = await pool.queryParam_None(query);
// running
if (!result) {
return {
code: sc.BAD_REQUEST,
json: au.successFalse(rm.BOARD_READ_ALL_FAIL)
};
}
return {
code: sc.OK,
json: au.successTrue(rm.BOARD_READ_ALL_SUCCESS, result)
};
},
update: async (title, content, blogIdx, articleIdx) => {
const query = `UPDATE ${table} SET title = '${title}', content = '${content}' WHERE blogIdx = '${blogIdx}' AND articleIdx = '${articleIdx}'`;
const result = await pool.queryParam_None(query);
// running
if (!result) {
return {
code: sc.BAD_REQUEST,
json: au.successFalse(rm.BOARD_UPDATE_FAIL)
};
}
return {
code: sc.OK,
json: au.successTrue(rm.BOARD_UPDATE_SUCCESS, result)
};
},
delete: async (blogIdx, articleIdx) => {
const query = `DELETE FROM ${table} WHERE blogIdx = '${blogIdx}' AND articleIdx = '${articleIdx}'`;
const result = await pool.queryParam_None(query);
// running
if (!result) {
return {
code: sc.BAD_REQUEST,
json: au.successFalse(rm.BOARD_DELETE_FAIL)
};
}
return {
code: sc.OK,
json: au.successTrue(rm.BOARD_DELETE_SUCCESS, result)
};
}
}
module.exports = article;
routes > blogs > articles > articles.js
이제 routes로 가보자. 이곳에서 router 역시 앞에서 했던 것과 같이 mergeParams에 true 값을 주도록 한다.
이것의 코드도 이전 코드와 크게 달라진 점이 없다. 다만 이곳에서도 주의해야 할 점이 있다면, articles의 url은 기본적으로 blogIdx 값이 url에 params로 들어가 있다는 것이다. (blogs/blogIdx/articles 이기 때문에 articles로 들어오기 위해선 필수적으로 blogIdx가 들어간다.)
때문에, title과 content는 body로 받더라도 blogIdx 값은 params 값으로 받아온다.
update와 같이 articleIdx 값도 필요할 때는 어떨까?
역시 동일하다. articlesIdx는 이곳에서 새롭게 params로 받아오고, 역시 이미 포함되어 있는 blogIdx는 그대로 받아오면 된다. 다만 url상 blogIdx가 articleIdx보다 앞에 있기 때문에 req.params로 받아올 때는 변수의 순서를 유의한다.
const {title, content} = req.body;
const {blogIdx, articleIdx} = req.params;
articles.js
const express = require('express');
const router = express.Router({mergeParams: true});
const au = require('../../../modules/util/authUtil');
const rm = require('../../../modules/util/responseMessage');
const sc = require('../../../modules/util/statusCode');
const Article = require('../../../models/Article');
router.post('/', async (req, res) => {
const {title, content} = req.body;
const {blogIdx} = req.params;
// TODO 1: body, path 값 확인하기
if (!title || !content || !blogIdx) res.status(sc.BAD_REQUEST).send(au.successFalse(rm.NULL_VALUE));
// TODO 2: 작성하기
try {
const {code, json} = await Article.insert(title, content, blogIdx);
res.status(code).send(json);
} catch (err) {
console.log(err);
res.status(sc.INTERNAL_SERVER_ERROR).send(au.successFalse(rm.BOARD_CREATE_FAIL));
}
});
// READ_ALL
router.get('/', async (req, res) => {
// TODO 1: 읽어오기
try {
const {code, json} = await Article.selectAll();
res.status(code).send(json);
} catch (err) {
console.log(err);
res.status(sc.INTERNAL_SERVER_ERROR).send(au.successFalse(rm.BOARD_READ_ALL_FAIL));
}
});
// READ_ONE
router.get('/:articleIdx', async (req, res) => {
const {blogIdx, articleIdx} = req.params;
// TODO 1: blogIdx 값 확인하기
if (!blogIdx) res.status(sc.BAD_REQUEST).send(au.successFalse(rm.NULL_VALUE));
// TODO 2: 읽어오기
try {
const {code, json} = await Article.selectOne(blogIdx, articleIdx);
res.status(code).send(json);
} catch (err) {
console.log(err);
res.status(sc.INTERNAL_SERVER_ERROR).send(au.successFalse(rm.BOARD_READ_ALL_FAIL));
}
});
// UPDATE
router.put('/:articleIdx', async (req, res) => {
const {title, content} = req.body;
const {blogIdx, articleIdx} = req.params;
// TODO 1: title, content, blogIdx 값 확인하기
if (!title || !content || !blogIdx) res.status(sc.BAD_REQUEST).send(au.successFalse(rm.NULL_VALUE));
// TODO 2: 수정하기
try {
const {code, json} = await Article.update(title, content, blogIdx, articleIdx);
res.status(code).send(json);
} catch (err) {
console.log(err);
res.status(sc.INTERNAL_SERVER_ERROR).send(au.successFalse(rm.BOARD_UPDATE_FAIL));
}
});
// DELETE
router.delete('/:articleIdx', async (req, res) => {
const {blogIdx, articleIdx} = req.params;
// TODO 1: blogIdx 값 확인하기
if (!blogIdx) res.status(sc.BAD_REQUEST).send(au.successFalse(rm.NULL_VALUE));
// TODO 2: 삭제하기
try {
const {code, json} = await Article.delete(blogIdx, articleIdx);
res.status(code).send(json);
} catch (err) {
console.log(err);
res.status(sc.INTERNAL_SERVER_ERROR).send(au.successFalse(rm.BOARD_DELETE_FAIL));
}
});
module.exports = router;
이까지 하고 나면 articles. 즉, 게시물 작성까지 구현이 된 것이다. Postman으로 결과를 확인해보면 잘 작동되는 것을 알 수 있다. 그럼 이어서 comments를 구현해보도록 하겠다. articles까지 잘 따라왔다면 comments의 구현의 단순한 확장의 개념이기 때문에 어렵지 않게 구현할 수 있다.
Routing
이제 프로젝트 전체의 디렉토리 구성을 보여주도록 하겠다. 디렉토리의 구조는 아래 이미지와 같으며, 라우팅을 하는 방법은 blogs를 하는 것과 방법이 동일하므로 따로 하나하나 설명은 하지 않도록 하겠다.
Foreign key로 blogs, articles와 연결된 comments Database 만들기
이번에도 fk를 이용해 database를 만들어 줄 것이다. 방법은 동일하다. 주의할 점은, blogIdx와 articleIdx를 둘 다 fk로 만들어야 한다는 점이다.
둘째로, 앞서 설명했든 Foreign Key의 이름이 겹치면 안 되기 때문에 이번에는 fk_comment_articleIdx와 같이 이름을 만들어준다.
models > Comment.js
models 코드도 commentIdx가 추가되어 query의 길이만 길어졌을 뿐 달라진 점은 없다.
Comment.js
const au = require('../modules/util/authUtil');
const rm = require('../modules/util/responseMessage');
const sc = require('../modules/util/statusCode');
const pool = require('../modules/db/pool');
// global variable
const table = 'comment';
// exports
comment = {
insert: async (text, blogIdx, articleIdx) => {
const fields = 'text, blogIdx, articleIdx';
const questions = `'${text}', '${blogIdx}', '${articleIdx}'`;
const query = `INSERT INTO ${table} (${fields}) VALUES(${questions})`;
const result = await pool.queryParam_None(query);
// running
if (!result) {
return {
code: sc.BAD_REQUEST,
json: au.successFalse(rm.BOARD_CREATE_FAIL)
};
}
return {
code: sc.OK,
json: au.successTrue(rm.BOARD_CREATE_SUCCESS, result)
};
},
selectOne: async (blogIdx, articleIdx, commentIdx) => {
const query = `SELECT * FROM ${table}\
WHERE blogIdx = '${blogIdx}' \
AND articleIdx = '${articleIdx}'\
AND commentIdx = '${commentIdx}'`;
const result = await pool.queryParam_None(query);
if (!result) {
return {
code: sc.BAD_REQUEST,
json: au.successFalse(rm.BOARD_READ_FAIL)
};
}
return {
code: sc.OK,
json: au.successTrue(rm.BOARD_READ_SUCCESS, result)
};
},
selectAll: async () => {
const query = `SELECT * FROM ${table}`;
const result = await pool.queryParam_None(query);
// running
if (!result) {
return {
code: sc.BAD_REQUEST,
json: au.successFalse(rm.BOARD_READ_ALL_FAIL)
};
}
return {
code: sc.OK,
json: au.successTrue(rm.BOARD_READ_ALL_SUCCESS, result)
};
},
update: async (text, blogIdx, articleIdx, commentIdx) => {
const query = `UPDATE ${table} SET text = '${text}'\
WHERE blogIdx = '${blogIdx}'\
AND articleIdx = '${articleIdx}'\
AND commentIdx = '${commentIdx}'`;
const result = await pool.queryParam_None(query);
// running
if (!result) {
return {
code: sc.BAD_REQUEST,
json: au.successFalse(rm.BOARD_UPDATE_FAIL)
};
}
return {
code: sc.OK,
json: au.successTrue(rm.BOARD_UPDATE_SUCCESS, result)
};
},
delete: async (blogIdx, articleIdx, commentIdx) => {
const query = `DELETE FROM ${table}\
WHERE blogIdx = '${blogIdx}'\
AND articleIdx = '${articleIdx}'\
AND commentIdx = '${commentIdx}'`;
const result = await pool.queryParam_None(query);
// running
if (!result) {
return {
code: sc.BAD_REQUEST,
json: au.successFalse(rm.BOARD_DELETE_FAIL)
};
}
return {
code: sc.OK,
json: au.successTrue(rm.BOARD_DELETE_SUCCESS, result)
};
}
}
module.exports = comment;
routes > blogs > articles > comments > comments.js
routes도 commentIdx로 인해 길이만 길어졌을 뿐 방법은 동일하다. 이와 같이 어떤 식으로 프로젝트를 구성해야 하는지 개념만 확실히 알아둔다면 확장하는 것에는 어려움이 없다. 또한 MVC 패턴으로 각각의 기능들을 분리했기 때문에 확장이 편리하다.
comments.js
const express = require('express');
const router = express.Router({mergeParams: true});
const au = require('../../../../modules/util/authUtil');
const rm = require('../../../../modules/util/responseMessage');
const sc = require('../../../../modules/util/statusCode');
const Comment = require('../../../../models/Comment');
router.post('/', async (req, res) => {
const {text} = req.body;
const {blogIdx, articleIdx} = req.params;
// TODO 1: body, path 값 확인하기
if (!text || !blogIdx || !articleIdx) res.status(sc.BAD_REQUEST).send(au.successFalse(rm.NULL_VALUE));
// TODO 2: 작성하기
try {
const {code, json} = await Comment.insert(text, blogIdx, articleIdx);
res.status(code).send(json);
} catch (err) {
console.log(err);
res.status(sc.INTERNAL_SERVER_ERROR).send(au.successFalse(rm.BOARD_CREATE_FAIL));
}
});
// READ_ALL
router.get('/', async (req, res) => {
// TODO 1: 읽어오기
try {
const {code, json} = await Comment.selectAll();
res.status(code).send(json);
} catch (err) {
console.log(err);
res.status(sc.INTERNAL_SERVER_ERROR).send(au.successFalse(rm.BOARD_READ_ALL_FAIL));
}
});
// READ_ONE
router.get('/:commentIdx', async (req, res) => {
const {blogIdx, articleIdx, commentIdx} = req.params;
// TODO 1: blogIdx 값 확인하기
if (!blogIdx || !articleIdx || !commentIdx) res.status(sc.BAD_REQUEST).send(au.successFalse(rm.NULL_VALUE));
// TODO 2: 읽어오기
try {
const {code, json} = await Comment.selectOne(blogIdx, articleIdx, commentIdx);
res.status(code).send(json);
} catch (err) {
console.log(err);
res.status(sc.INTERNAL_SERVER_ERROR).send(au.successFalse(rm.BOARD_READ_ALL_FAIL));
}
});
// UPDATE
router.put('/:commentIdx', async (req, res) => {
const {text} = req.body;
const {blogIdx, articleIdx, commentIdx} = req.params;
// TODO 1: title, content, blogIdx 값 확인하기
if (!text || !blogIdx || !articleIdx || !commentIdx) res.status(sc.BAD_REQUEST).send(au.successFalse(rm.NULL_VALUE));
// TODO 2: 수정하기
try {
const {code, json} = await Comment.update(text, blogIdx, articleIdx, commentIdx);
res.status(code).send(json);
} catch (err) {
console.log(err);
res.status(sc.INTERNAL_SERVER_ERROR).send(au.successFalse(rm.BOARD_UPDATE_FAIL));
}
});
// DELETE
router.delete('/:commentIdx', async (req, res) => {
const {blogIdx, articleIdx, commentIdx} = req.params;
// TODO 1: blogIdx 값 확인하기
if (!blogIdx) res.status(sc.BAD_REQUEST).send(au.successFalse(rm.NULL_VALUE));
// TODO 2: 삭제하기
try {
const {code, json} = await Comment.delete(blogIdx, articleIdx, commentIdx);
res.status(code).send(json);
} catch (err) {
console.log(err);
res.status(sc.INTERNAL_SERVER_ERROR).send(au.successFalse(rm.BOARD_DELETE_FAIL));
}
});
module.exports = router;
회고
프로젝트를 진행하며 Node.js는 자바스크립트로 프론트와 백엔드를 모두 구성할 수 있기 때문에 가성비 좋으며 js만의 특유의 포용력(?) 때문에 "이런 문법이 가능해?"와 같은 문법도 허용이 되는 경우가 많다. 이는 양날의 검으로 오히려 기존의 low level programing language에 익숙한 개발자라면 오히려 헷갈릴 수 있는 부분이다. 왜냐하면 js는 일반 프로그래밍 랭기지와 다르게 property를 기반으로 하고 있는 언어이기 때문에 그 구조를 이해하지 못하면 의도치 못한 곳에서 알 수 없는 에러와 마주치게 될 확률이 높다.
두 번째로 비동기에 대한 이야기를 하고 싶다. 비동기의 개념은 생각보다 매우 어렵다. 프로젝트를 진행하면서 "이렇게 되면 이러한 값이 출력돼야 해"라는 부분에서 예상치 못한 결과 값을 받아보는 경우가 많았다. 대부분의 원인은 비동기에 있었는데, 쉽게 말해서 아직 비동기적으로 처리하지 않은 데이터를 호출하게 되어 값이 빈 데이터 값을 받아오는 경우가 종종 있었다. 따라서 모든 부분을 비동기로 하기보다 일부 부분에서는 동기로 처리하는 것이 필요하다는 인사이트를 얻게 되었다. (하지만 이 부분에 관해서는 나도 좀 더 공부가 필요함을 느꼈다.) 대부분의 node.js의 비동기 관련 포스팅을 보면 "콜백 지옥을 해결하기 위해 promise와 async를 사용했다." 정도의 정보밖에 없어서 공부를 하는 입장에서 불편함을 겪었는 경험이 있다. 그러던 와중 이번에 괜찮은 reference를 발견해서 조만간 번역 포스팅을 할까 생각 중에 있다. (해외 reference이다.)
셋째로 node는 디버깅이 친절하지 않다. 때문에 MVC패턴으로 확실히 기능들은 분리시켜 주고, 각각의 기능들을 명확히 하는 것이 매우 중요하다. 또한 기능들마다 에러 처리를 필수적으로 해서 디버깅을 효과적으로 해야 한다. 그럼에도 에러 메시지만으로는 어디서 에러가 났는지 알기 쉽지 않은 때가 종종 있는데, 이런 때에는 console.log를 적극 활용해서 디버깅하는 것을 추천한다.
물론, 그럼에도 Node.js에서 디버깅은 쉽지 않을 것이다. 하지만 이러한 방법에 익숙해지다 보면 어느새 디버깅에 대한 인사이트를 얻게 될 것이다.
'Server > Node.js (Express)' 카테고리의 다른 글
[Node.js] Sequelize : ORM(Object-relational Mapping) 사용해보기 (0) | 2019.12.16 |
---|---|
[Node.js] await vs return vs return await: 비동기 이해하기 (2) | 2019.11.11 |
[Node.js] CRUD: RDS를 이용하여 동적으로 구성하기 (1) (7) | 2019.10.30 |
[Node.js] Express와 CSV를 이용해 조 편성 애플리케이션 만들기 (1) | 2019.10.17 |
[Node.js] npm이란? (0) | 2019.10.11 |