배꼽파지 않도록 잘 개발해요

[엘리스sw] 7주차 1일 - CRUD 게시판 만들기 본문

교육/엘리스 SW 학습 내용

[엘리스sw] 7주차 1일 - CRUD 게시판 만들기

꼽파 2024. 2. 5. 16:03


◆ Template Engine

◆ 프로젝트 세팅하기

◆ 게시판 CRUD 만들기

◆ Async Request Handler

◆ Pagination

◆ PM2 Process Manager


Template Engine

템플릿 엔진

  • 서버에서 클라이언트로 보낼 HTML의 형태를 미리 템플릿으로 저장(SSR 구현)
  • 동작 시에 미리 작성된 템플릿에 데이터를 넣어서 완성된 HTML 생성
  • 템플릿 엔진은 템플릿 작성 문법과 작성된 템플릿을 HTML으로 변환하는 기능을 제공함.

Express.js의 템플릿 엔진

  • EJS : html과 유사한 문법의 템플릿 엔진
  • Mustache : 간단한 데이터 치환 정도만 제공하는 경량화된 템플릿 엔진
  • Pug : 들여쓰기 표현식을 이용한 간략한 표기와 레이아웃 등 강력한 기능을 제공

Pug

  • 들여쓰기 표현식을 이용해 가독성이 좋고 개발 생산성이 높음.
  • HTML을 잘 모르더라도 문법적 실수를 줄일 수 있음.
  • layout, include, mixin 등 강력한 기능 제공함.

pug 문법 소개

  • HTML 닫기 태그 없이 들여쓰기로 블럭을 구분
  • '='을 이용해서 전달받은 변수 사용 가능함.
  • id나 class는 태그 뒤에 이어서 바로 사용함.
  • ()을 이용해서 attribute를 사용함.
html
	head
    	title = tile
    body
    	h1#greeting 안녕하세요
        a.link(href="/") 홈으로

 

each, if

  • each ~ in 표현식으로 주어진 배열의 값을 순환하며 HTML 태그를 만들 수 있음.
  • if, else if, else를 이용해 주어진 값의 조건을 확인하여 HTML 태그를 만들 수 있음.
each item in arr
	if item.name == 'new'
    	h1 New Document
    else
    	h1 = `${item.name}`

 

layout

  • block을 포함한 템플릿을 선언하면 해당 템플릿을 layout으로 사용할 수 있음.
  • layout을 extends하면 block 부분에 작성한 HTML 태그가 포함됨.
  • 반복되는 웹사이트의 틀을 작성해 두고 extends 하며 개발하면 매우 편리한 기능임.
--- layout.pug ---
html
	head
    	title=title
    body
    	block content
--- main.pug ---
extends layout
block content
	h1 Main Page

 

include

  • 자주 반복되는 구문을 미리 작성해 두고 include하여 사용할 수 있음.
  • 일반적인 텍스트 파일도 include하여 템플릿에 포함 가능함.
--- title.pug ---
h1=title
--- main.pug
extend layout
block content
	include title
    div.content
    	안녕하세요
    pre 
    	include article.txt

 

mixin

  • mixin을 사용하여 템플릿을 함수처럼 사용할 수 있게 선언할 수 있음.
  • include는 값을 지정할 수 없지만 mixin은 파라미터를 지정하여 값을 넘겨받아 템플릿에 사용할 수 있음.
--- listItem.pug ---
mixin listItem(title, name)
	tr
    	td title
        td name
--- main.pug ---
include listItem
table
	tbody
    	listItem('제목', '이름')

 

express + pug

  • app.set을 이용해 템플릿이 저장되는 디렉터리를 지정하고, 어떤 템플릿 엔진을 사용할지 지정할 수 있음.
  • res.render 함수는 app.set에 지정된 값을 이용해 화면을 그리는 기능을 수행함.
  • render 함수의 첫 번째 인자는 템플릿의 이름, 두 번째 인자는 템플릿에 전달되는 값
--- app.js ---
app.set('views,
	path.join(__dirname, 'views'));
app.set('view engine', 'pug');

--- request handler ---
res.render('main', {
	title: 'Hello Express',
});

 

app.locals

  • Express.js의 app.locals를 사용하면 render 함수에 전달되지 않은 값이나 함수를 사용할 수 있음.
  • 템플릿에 전역으로 사용될 값을 지정하는 역할
--- app.js ---
app.locals.appName = "Express"

--- main.pug ---
h1 = appName

// <h1>Express</h1>

 

express-generator 사용 시 템플릿 엔진 지정하기

  • express-generator는 기본적으로 jade라는 템플릿 엔진을 사용
  • jade는 pug의 이전 이름으로, 최신 지원을 받기 위해선 템플릿 엔진을 pug로 지정해야함.
  • --view 옵션을 사용하여 템플릿 엔진을 지정할 수 있음.
$ express --view=pug myapp

 

실습


게시판 CRUD 만들기

CRUD란?

  • Create, Read, Update, Delete
  • 데이터를 다루는 네 가지 기본적인 기능
  • 일반적으로 위 네 가지에 대한 구현이 가능해야 서비스 개발에 필요한 요구사항을 충족할 수 있음.

Create

  • 게시판은 게시글을 작성할 수 있어야 함.
  • 게시글 작성 시 제목, 내용, 작성자, 작성 시간 등의 정보를 기록함.
  • 게시글의 제목과 내용은 최소 n글자 이상이어야 함.

Read

  • 게시판은 게시글의 목록게시글의 상세를 볼 수 있어야 함.
  • 게시글 목록은 제목과 작성자 작성 시간의 간략화된 정보를 보여줌.
  • 게시글 상세는 제목, 작성자, 내용, 작성 시간, 수정 시간 등의 상세한 정보를 보여줘야 함.

Update

  • 게시판의 게시글은 수정이 가능해야 함.
  • 게시글 수정 시 제목과 내용이 수정 가능하고, 수정 시간이 기록되어야 함.
  • 게시글의 제목과 내용은 최소 n글자 이상이어야 함.
  • 게시글 수정은 작성자만 가능해야 함.

Delete

  • 게시판의 게시글은 삭제가 가능해야 함.
  • 게시글 삭제 시 목록과 상세에서 게시글이 접근되지 않아야 하며 게시글 삭제는 작성자만 가능해야 함.

Express.js + Mongoose로 CRUD 구현하기

모델 선언하기

  • MongoDB의 ObjectID는 URL에 사용하기 좋은 값이 아니기 때문에 대체할 수 있는 아이디를 shortId로 생성
  • 제목, 내용, 작성자를 String 타입으로 스키마에 선언
    (회원가입 로그인 후 작성자 연동)
  • timestamps 옵션으로 작성 시간, 수정 시간을 자동으로 기록해 줌.
--- ./models/schemas/post.js
const mongoose, { Schema } = require('mongoose');
const shortId = require('./types/short-id');

module.exports = new Schema({
	shortId,
	title: String,
	content: String,
	author: String,
}, {
	timestamps: true,
});

--- ./models/index.js ---
exports.Post = mongoose.model('Post', PostSchema)

 

shortId

  • ObjectId를 대체할 shortId 타입을 Mongoose Custom Type으로 선언
  • 중복 없는 문자열을 생성해주는 nanoid 패키지 활용
  • default를 이용해 모델 생성 시 자동으로 ObjectId를 대체할 아이디 생성
const { nanoid } = require('nanoid');

const shortId = {
	type: String,
    default: () => {
    	return nanoid();
    },
    require: true,
    index: true,
}

module.exports = shortId;

 

게시글 작성

 

게시글 작성 흐름

  1. /posts?write=true로 작성 페이지 접근
  2. <form action="/posts" method="post"> 이용해 post 요청 전송
  3. router.post 이용하여 post 요청 처리
  4. res.redirect 이용하여 post 완료 처리
./routes/posts.js

const { Router } = require('express');

const router = Router();

router.get('/', (req, res, next) => {
	if (req.query.write) {
    	res.render('posts/edit');
        return;
    }
    ...
));

...
module.exports = router;
./views/posts/edit.pug

...
form(action="/posts", method="post")
	table
    	 tbody
        	tr
            	th 제목
                td: input(type="text" name="title")
            tr
            	th 내용
                td: textarea(name="content")
            td
            	td(colspan="2")
                	input(type="submit" value="등록")

 

작성 페이지 만들기

./routes/posts.js

const { Router } = require('express');

const router = Router();

router.get('/', (req, res, next) => {
	if (req.query.write) {
    	res.render('posts/edit');
        return;
    }
    ...
});

...
module.exports = router;

 

./views/posts/edit.pug

...
form(action="/posts", method="post")
	table
    	tbody
        	tr
            	th 제목
                td: input(type="text" name="title")
            tr
            	th 내용
                td: textarea(name="content")
           	td
            	td(colspan="2")
                	input(type="submit" value="등록")

 

POST 요청 처리하기

./routes.posts.js

cosnt { Post } = require('./models');
...
router.post('/', async (req, res, next) => {
	const { title, content } = req.body;
    try {
    	await Post create({
        	title,
            content,
        });
        res.redirect('/');
        } catch (err) {
        	next(err);
        }
    });
...

 

게시글 목록 및 상세 흐름

  1. /posts 로 목록 페이지 접근
  2. <a href="/posts/:shortId"> 이용하여 상세 URL Link
  3. router.get('/:shortId') path parameter 이용하여 요청 처리
./routes/posts.js

router.get('/', async (req, res, next) => {
	const posts = await Post.find({});
	res.render('posts/lists', { posts });
});
./views/posts/lists.pug

...
table
	tbody
    	each post in posts
        	tr
            	td 
                	a(href=`/posts/${post.shortId}`)
                    	= post.title
                    td = post.author
                    td = formatDate(post.createdAt)
     tfoot
     	tr
        	td(colspan="3")
            	a(href="/posts?write=true")
                	등록하기

 

formatDate 함수 추가하기 (별도 생성)

const dayjs = require('dayjs');

app.locals.formatDate = (date) => {
	return dayjs(date).format('YYYY-MM-DD HH:mm:ss');
}

 

게시글 상세 구현하기

./routes/posts.js

router.get('/:shortId', async (req, res, next) => {
	const { shortId } = req.params;
    const post = await Post.findOnde({ shortId });
    if (!post) {
    	next(new Error('Post NotFound');
        return;
    }
    ...
    res.render('posts/view', { post });
});
./views/posts/view.pug

...
table
	tbody
    	tr
        	td(colspan="2")= post.title
        tr 
        	td= post.author
            td= formatDate(post.createdAt)
        tr
        	td(colspan="2"): pre= post.content
        tr
        	td: a(href='/posts/${post.shortId}?edit=true')
            	수정
            td
            	button(onclick='deletePost(`${post.shortId}`)
                	삭제

 

게시글 수정

게시글 수정 흐름

  1. /posts/{shortId}?edit=true로 수정페이지 접근
  2. 작성페이지를 수정페이지로도 동작하도록 작성
  3. <form action="/posts/:shortId" method="post">로 post 요청 전송

※ html form은 PUT method를 지원하지 않기 때문에 post 사용

 

수정 페이지 만들기

./routes/post.js

router.get('/:shortId', async (req, res, next) => {
	...
    if (req.query.edit) {
    	res.render('posts/edit', { post });
    }
	...
});
./views/posts/edit.pug

...
- var action = post ? '/posts/${post.shortId}' : "/posts"
form(action=action, method="post")
	table
    	tbody
        	tr
            	th 제목
                td: input(type="text" name="title" value=post&&&post.title)
            tr
            	th 내용
                td: textarea(name="content")= post&&post.content
            td
            	td(colspan="2")
                	- var value = post ? "수정" : "등록"
                    input(type="submit" value=value)

 

수정 요청 처리하기

./routes/post.js

...
router.post('/:shortId', async (req, res, next) => {
	const { shortId } = req.params;
    const { title, content } = req.body;
    const post = await Post.findOneAndUpdate({ shortId }, {
    	title, content,
    });
	if (!post) {
    	next(new Error('Post NotFound');
        return;
    }
    res.redirect(`/posts/${shortId}`);

 

게시글 삭제

게시글 삭제 흐름

  1. 게시글 상세 페이지에 삭제 버튼 추가
  2. html form은 DELETE 메서드를 지원하지 않음.
  3. JavaScript를 이용해 fetch 함수로 HTTP Delete 요청 전송
  4. router.delete의 응답을 fetch에서 처리
posts/view.pug

td
	button.delete(
    	onclick='deletePost("${post.shortId}")'
    ) 삭제
    
    ...
posts/view.pug
...

script(type="text/javascript").
	function deletePost(shortId) {
    	fetch('/posts/' + shortId, { method: 'delete' })
        	.then((res) => {
            	if (res.ok) {
                	alert('삭제되었습니다.');
                    window.location.href = '/post';
                } else {
                	alert('오류가 발생했습니다.');
                    console.log(res.statusText);
                }
            })
            .catch((err) => {
            	console.log(err};
                alert('오류가 발생했습니다.');
                });
    }

 

DELETE 요청 처리하기

./routes/posts.js

const { Post } = require('./models');
...

router delete('/:shortId', async (req, res, next) => {
	const { shortId } = req.params;
    try {
    	await Post.delete({ shortId });
        res.send('OK');
    } catch (err) {
    	next(err);
    }
    });
    ...

Async Request Handler

async 함수를 쉽게 사용할 수 있음.

request handler 오류처리 간단하게 할 수 있음.

 

request handler의 오류처리

· request handler에서 오류를 처리하기 위한 방법

  • promise().catch(next)
  • async function, try~catch, next

async request handler

· async의 비동기 처리를 매우 편리하지만, 매번 try-catch 구문을 작성하는 것은 귀찮고 실수하기 쉬움.

· request handler를 async function으로 작성하면서 try~catch, next를 자동으로 할 수 있도록 구성한 아이디어

const asyncHandler = (requestHandler) => {
	return async (req, res, next) => {
    	try {
   			await requestHandler(req, res);
    } catch (err) {
    	next(err);
    }
  }
}

--

router.get('/', asyncHandler(async (req, res) => {
	const posts = await Posts.find({});
	if (posts.length < 1) {
    	throw new Error('Not Found');
    }
    res.render('posts/list', { posts });
});

· asyncHandler는 requestHandler를 매개변수로 갖는 함수형 미들웨어

· 전달된 requestHandler는 try-catch로 감싸져 asyncHandler 내에서 실행되고,

· throw 되는 에러는 자동으로 오류처리 미들웨어로 전달되도록 구성됨.


Pagination

Pagination이란?

· 데이터가 많아지면 한 페이지의 목록에 모든 데이터를 표현하기 어려움.

· Pagination은 데이터를 균일한 수로 나누어 페이지로 분리하는 것

ex. 10개씩 나누어 1페이지에는 1~10번까지, 2페이지엔 11~20번까지 보여주기

router.get(... => {
	const page = 
    	Number(req.query.page || 1)
    const perPage = 
    	Number(req.query.perPage || 10)
       ...
})

 

  • page : 현재 페이지
  • perPage : 페이지 당 게시글 수
/posts?page=1&perPage=10

일반적으로 url query를 사용해 전달

query를 문자열로 전달되기 때문에 Number로 형변환이 필요함.

 

Express.js + Mongoose의 Pagination

router.get(... => {
	...
    const total = await Post.countDocument({});
	const posts = await Post.find({})
    	.sort({ createdAt: -1 })
        .skip(perPage * (page - 1))
        .limit(perPage);
    const totalPage = 
    	Math.ceil(total / perPage);
    ...
})

MongoDB의 limit과 skip을 사용하여 pagination 구현 가능

  • limit : 검색 결과 수 제한
  • skip : 검색 시 포함하지 않을 데이터 수

pagination 시에는 데이터의 순서가 유지될 수 있도록 sort를 사용할 수 있도록 함.

게시글 수 / 페이지 당 게시글 수 = 총 페이지 수

 

mixin pagination(path)
	p
    	-for(let i = 1; i <= totalPage; i++)
        	a(href=`${path}?page=${i}&perPage=${perPage}`)
            if i == page
            	b = i
            else
            	= i
            = " "
---
include pagination
tr 
	td
    	+pagination("/posts")

paginatino을 mixin으로 선언

paginatino이 필요한 페이지에서 해당 템플릿을 include한 후, +pagination으로 mixin을 사용함.

현재 페이지는 b태그로 굵게 표시함.


PM2 Process Manager

PM2란?

· Node.js 작업을 관리해주는 Process Manager

· node 명령어로 실행 시 오류발생이나 실행 상태 관리를 할 수 없음.

· pm2는 작업 관리를 위한 다양한 유용한 기능을 제공해 줌.

 

PM2를 사용하는 이유

· 안정적인 프로세스 실행 : 오류발생 시 자동 재실행

· 빠른 개발환경 : 소스 코드 변경 시 자동 재실행

· 배포 시 편리한 관리 : pm2에 모든 프로세스를 한번에 관리

 

PM2 사용 방법

ecosystem.config.js

module.exports = {
	apps : [{
    	name: 'simple-board',
        script: './bin/www',
        watch: '.',
        ignore_watch: 'views',
    }],
};
---
$ pm2 start

$ pm2 init simple 혹은 $ pm2 init 명령어를 이용하여 pm2 설정파일 예제를 만들 수 있음.

예제를 수정하여 설정파일을 생성한 후, $ pm2 start 명령어를 실행하면 어플리케이션을 pm2 데몬으로 실행해 줌.

개발 시 watch 옵션을 사용하여 파일 변경 시 서버 자동 재실행 구성함.

 

pm2 status, pm2 monit

 

728x90