Sequelize 시리즈
- [Node.js] Sequelize(1) - sequelize와 sequelize-cli
- [Node.js] sequelize(2) - 모델 정의하기
- [Node.js] sequelize(3) - CRUD + RESTful 게시판 만들기
- [Node.js] sequelize(4) - 관계 설정( association ) 및 게시판 댓글 구현하기
- [Node.js] sequelize(5) - seed 활용하기
- [Node.js] sequelize(6) - 제약조건 추가 ( custom validate )
- [Node.js] sequelize(7) - 검색기능에 필요한 query operator
- [Node.js] sequelize(8) - pagination
2019. 07. 21 수정
이번 글에서는 이전 글 게시판 만들기에 이어서, sequelize로 게시글의 댓글을 구현해보도록 하겠습니다.
댓글은 모든 게시글에 달려있는 댓글이 아닌 이상, 한 게시글에만 존재해야( 소속 되어야 ) 합니다.
즉, 게시글과 댓글은 관계를 맺고 있는데, 한 게시글은 여러 댓글들을 소유하고 있으므로 게시글과 댓글은 1 : N 관계가 됩니다.
이 글에서는 sequelize에서 모델끼리 관계를 맺는 법, 그리고 게시글에 댓글을 구현하는 방법을 알아보도록 하겠습니다.
- 개발환경
- express-generator 4.16.1
- MySQL 8.0.16
- sequelize 5.10.1
1. reply 모델 정의하기
우선 reply 모델에 대한 테이블을 생성해야 하므로, sequelize-cli로 모델을 생성합니다.
# sequelize model:create --name reply --attributes "postId:integer, writer:string, content:text"
이어서 sequelize-cli로 생성된 /models/reply.js 파일과 /migrations/create-reply.js 파일을 아래와 같이 수정합니다.
/models/reply.js
'use strict';
module.exports = (sequelize, DataTypes) => {
var reply = sequelize.define('reply', {
postId: {
type: DataTypes.INTEGER,
allowNull: false,
},
writer: {
type: DataTypes.STRING,
allowNull: false,
},
content: {
type: DataTypes.TEXT,
allowNull: false,
}
});
return reply;
};
/migrations/create-reply.js
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('replies', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
postId: {
type: Sequelize.INTEGER,
allowNull: false,
},
writer: {
type: Sequelize.STRING,
allowNull: false,
},
content: {
type: Sequelize.TEXT,
allowNull: false,
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('replies');
}
};
2. post 모델과 reply 모델의 관계 설정
게시글은 많은 댓글을 가지고 있으며, 댓글은 한 게시글에 속해 있기 때문에, 게시글과 댓글은 1 : M 관계입니다.
sequelize에서는 이러한 모델 관계를 어떻게 설정할 수 있는지 알아보도록 하겠습니다. ( 참고문서 )
모델 관계는 모델을 정의하는 파일에서 관계 설정에 대한 정의를 해야합니다.
/models/index.js 파일을 보시면 관계설정에 대해 취합하는 코드가 있습니다.
Object.keys(db).forEach(modelName => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
따라서 models 폴더 안에 존재하는 각각의 모델 파일에서 모델간의 관계를 정의하면, /models/index.js 파일에서 모델 간의 관계를 통합하게 됩니다.
먼저 게시글은 많은 댓글을 가질 수 있다는 모델 관계를 정의하기 위해,
/models/post.js을 아래와 같이 수정하겠습니다.
'use strict';
module.exports = (sequelize, DataTypes) => {
var post = sequelize.define('post', {
title: {
type: DataTypes.STRING,
allowNull: false,
},
writer: {
type: DataTypes.STRING,
allowNull: false,
}
});
post.associate = function (models) {
post.hasMany(models.reply);
};
return post;
};
- post 객체에 associate 프로퍼티를 함수로 정의하면, /models/index.js에서 post모델에 대한 관계를 알 수 있게 됩니다.
- 게시글과 댓글 1 : M 관계이므로, 이에 대한 메서드인 post.hasMany() 메서드를 호출하여, post는 많은 reply를 갖도록 합니다.
- 인자로는 1 : M 관계에서 M에 해당하는 모델을 작성합니다.
- 예제에서는 reply 모델이므로 models.reply를 전달합니다.
*** 참고 ***
그런데 associate를 정의하는 함수에서 models 변수는 무엇일까요?
/models/index.js 파일을 보시면 모델 관계를 정의할 때, associate() 메서드를 호출하면서 인자로 db객체를 전달합니다.
따라서 매개변수 models는 db객체가 됩니다.
( 참고로 /models/index.js 모듈은 db 객체를 반환합니다. )
마찬가지로 reply 모델을 정의하는 모듈에서도 게시글과 관련된 연관 관계를 매핑해야 합니다.
/models/reply.js
'use strict';
module.exports = (sequelize, DataTypes) => {
var reply = sequelize.define('reply', {
postId: {
type: DataTypes.INTEGER,
allowNull: false,
},
writer: {
type: DataTypes.STRING,
allowNull: false,
},
content: {
type: DataTypes.TEXT,
allowNull: false,
}
});
reply.associate = function(models){
reply.belongsTo(models.post, {
foreignKey: "postId"
})
};
return reply;
};
- reply는 하나의 post에 소속돼야 하는데, 이를 표현한 메서드는 belongsTo()입니다.
- 즉, reply는 하나의 post에 포함된다는 의미로 reply.belongsTo() 메서드를 호출합니다.
- reply는 post와 다르게 하나의 post에 속해야 하므로 어떤 객체( 게시글 )에 속할 것인지 알아야 합니다.
- 따라서 외래키로써 postId를 사용하겠다는 것을 객체 형태로 두 번째 인자에 전달합니다.
- 이것이 reply 모델을 정의할 때 postId 필드를 정의한 이유입니다.
게시글과 댓글의 연관관계 매핑이 끝났으므로 migrate를 수행하고, 테이블 생성을 위해 서버를 실행합니다.
# sequelize db:migrate
# npm start
reply 모델은 실제로 replies 테이블로 등록이 될 것입니다.
2. reply 작성 form 만들기 및 reply 라우터 등록
reply 작성 form은 이전 글에서 작성했던 /views/show.ejs 파일을 수정할 것입니다.
/views/show.ejs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<h1>목록 추가하기</h1>
<hr>
<form action="/board" method="POST">
<table>
<tr>
<td><input type="text" name="inputTitle" placeholder="제목을 입력하세요."></td>
</tr>
<tr>
<td><input type="text" name="inputWriter" placeholder="작성자를 입력하세요."></td>
</tr>
</table>
<input type="submit" value="전송하기">
</form>
<hr>
<% for(let post of posts) { %>
<table>
<tr>
<td>제목</td>
<td>작성자</td>
<td>작성일</td>
</tr>
<tr>
<td><%= post.title %></td>
<td><%= post.writer %></td>
<td><%= post.createdAt %></td>
<td><button ><a href="/board/<%=post.id%>">수정하기</a></button></td>
<form action="/board/<%=post.id%>?_method=DELETE" method="post">
<td><input type="submit" value="삭제하기"></input></td>
</form>
</tr>
<form action="/reply/<%=post.id%>" method="post">
<tr>
<td><input type="text" name="replyWriter" placeholder="작성자를 입력해주세요"></td>
<td><input type="text" name="replyContent" placeholder="내용을 입력해주세요"></td>
</tr>
<tr>
<td><input type="submit" value="댓글 등록"></td>
</tr>
</form>
</table>
<hr>
<% } %>
</body>
</html>
댓글을 작성하면 /reply/게시글id 경로로 요청을 합니다.
어떤 게시글에 속한 댓글인지 알기 위해서 post의 id를 넘겨줘야 하기 때문에 게시글id를 넘겨줍니다.
다음으로는 "댓글 등록" 버튼을 눌렀을 때, 라우터 함수에서 댓글 데이터를 reply 테이블에 추가하는 작업을 해야 합니다.
해당 파일 역시 이전글에서 작성한 index.js 파일을 기준으로 작성되었습니다.
/routes/index.js
const express = require('express');
const models = require('../models');
const router = express.Router();
// 게시글 목록
router.get('/board', function(req, res, next) {
models.post.findAll({
where: {writer: "victolee"}
})
.then( result => {
res.render("show", {
posts: result
});
})
.catch(function(err){
console.log(err);
});
});
// 게시글 등록
router.post('/board', function(req, res, next) {
let body = req.body;
models.post.create({
title: body.inputTitle,
writer: body.inputWriter
})
.then( result => {
console.log("데이터 추가 완료");
res.redirect("/show");
})
.catch( err => {
console.log("데이터 추가 실패");
})
});
// 게시글 조회
router.get('/board/:id', function(req, res, next) {
let postID = req.params.id;
models.post.findOne({
where: {id: postID}
})
.then( result => {
res.render("edit", {
post: result
});
})
.catch( err => {
console.log("데이터 조회 실패");
});
});
// 게시글 수정
router.put('/board/:id', function(req, res, next) {
let postID = req.params.id;
let body = req.body;
models.post.update({
title: body.editTitle,
writer: body.editWriter
},{
where: {id: postID}
})
.then( result => {
console.log("데이터 수정 완료");
res.redirect("/board");
})
.catch( err => {
console.log("데이터 수정 실패");
});
});
// 게시글 삭제
router.delete('/board/:id', function(req, res, next) {
let postID = req.params.id;
models.post.destroy({
where: {id: postID}
})
.then( result => {
res.redirect("/board")
})
.catch( err => {
console.log("데이터 삭제 실패");
});
});
// 댓글 등록
router.post("/reply/:postID", function(req, res, next){
let postID = req.params.postID;
let body = req.body;
models.reply.create({
postId: postID,
writer: body.replyWriter,
content: body.replyContent
})
.then( results => {
res.redirect("/board");
})
.catch( err => {
console.log(err);
});
});
module.exports = router;
댓글과 관련된 라우터는 가장 마지막 함수 router.post("/reply/:postID")입니다.
댓글의 데이터를 추가하는 과정은 게시글의 데이터를 추가하는 과정과 같으므로, 자세한 내용은 생략하도록 하겠습니다.
테스트를 위해 localhost:3000/board에서 댓글을 작성해보시고, replies 테이블에 데이터가 추가 되었는지 확인해보세요.
3. 게시글과 댓글 모두 출력하는 라우터 함수 등록 및 뷰 페이지 작성
그러면 이제 기존에 작성된 /board 라우터 함수에 게시글의 목록 뿐만 아니라, 댓글의 목록까지 객체에 담아서 응답하는 일만 남았습니다.
먼저 테스트를 목적으로, id가 1인 post에 해당하는 댓글들을 출력해보도록 하겠습니다.
기존에 작성된 "게시글 목록"에 해당하는 라우터 함수를 수정합니다.
/routes/index.js
// 게시글 목록
router.get('/board', function(req, res, next) {
models.post.findAll()
.then( result => {
models.post.findOne({
include: {
model: models.reply,
where: {postId: 1}
}
})
.then( result2 => {
console.log(result2.replies)
})
})
.catch(function(err){
console.log(err);
});
});
- 먼저 models.post.findAll() 메서드를 호출하여 모든 post 데이터들을 조회합니다.
- models.post.findOne() 메서드를 호출하여 한 개의 post를 조회하는데, 이 때 매개변수의 프로퍼티로 include를 작성하면, 관계가 설정되어 있으면서 동시에 어떤 조건을 만족하는 모델 객체를 불러올 수 있습니다.
- 즉, 여기서는 reply 모델 중에서 postId( 외래키 )가 1인 row들을 모두 불러와서 콜백 함수로 전달합니다.
테스트를 위해 브라우저에서 localhost:3000/show 으로 요청한 후 로그를 확인해보세요.
만약 " Cannot read property 'replies' of null" 에러가 발생했다면 postId의 값을 바꿔보세요. id값이 1인 post가 없기 때문입니다.
console.log( result2.replies )
해당 로그를 보시면 다음과 같습니다. (현재 페이지는 정상 동작하지 않아도 됩니다.)
여기서 dataValues라는 속성에 주목해주세요.
조금 뒤에 뷰 페이지에서 dataValues 프로퍼티에 접근하여 실제 값을 출력할 것입니다.
다시 라우터함수로 돌아가서, 어떤 게시글에 대응되는 모든 댓글을 출력하는 방법은 위와 같습니다.
그러면 특정 게시글이 아닌, 모든 게시글에 대한 댓글들은 어떻게 응답해야 할까요?
*** 아래의 코드를 보시기 전에, 직접 해보시길 권장드립니다. ***
sequelize가 비동기라는 특성때문에 아마 잘 안될것입니다.
저도 처음할 때 꽤 고생했거든요...
"게시글 목록" 라우터 함수를 수정해보겠습니다.
/routes/index.js
// 게시글 목록
router.get('/board', async function(req, res, next) {
let result = await models.post.findAll();
if (result){
for(let post of result){
let result2 = await models.post.findOne({
include: {
model: models.reply,
where: {
postId: post.id
}
}
})
if(result2){
post.replies = result2.replies
}
}
}
res.render("show", {
posts : result
});
});
sequelize가 비동기로 동작하기 때문에, async/await을 통해 비동기를 해결했습니다.
aysnc/await 문법은 비동기로 동작하는 Node.js에서 매우 중요한 문법이므로, 꼭 익히셔야할 테크닉입니다. ( 참고 )
지금까지 sequelize를 사용할 때 promise를 사용했는데요, 이렇게 async/await을 사용해서 프로그래밍하면 가독성이 더 좋아집니다.
이제 마지막으로 댓글을 목록을 뿌려주는 뷰 페이지를 작성하도록 하겠습니다.
/views/show.ejs
<% if(post.replies){
for(let reply of post.replies){ %>
<tr>
<td><%= reply.dataValues.writer %></td>
<td><%= reply.dataValues.content %></td>
</tr>
<% } %>
<% } %>
- 댓글을 등록했던 </form> 태그 아래에 위의 코드를 작성해주시면 됩니다.
- 라우터 함수에서 각 post객체마다 replies 속성을 추가했고, 한 게시글에 댓글은 여러 개가 있을 수 있으므로 반복문으로 댓글을 출력하도록 했습니다.
- 댓글이 없는 게시글이 존재할 수 있으므로, if( post.replies ) 로 예외처리를 했습니다.
이제 코딩은 끝났으니, 테스트를 해보시길 바랍니다.
이상으로 sequelize를 이용해서 게시판 및 댓글 구현까지 구현해보았습니다.
사실 모델 관계를 설정하는 부분만 보면 크게 어렵지는 않을 것입니다.
그리고 Node.js에서는 비동기 코드가 많기 때문에, async/await 문법을 사용하는 것을 권장합니다.