먹고 기도하고 코딩하라

Node.js 로 CRUD 구현 (3) - express.js 도입, 미들웨어와 세션 설명 본문

Javascript

Node.js 로 CRUD 구현 (3) - express.js 도입, 미들웨어와 세션 설명

2G Dev 2020. 7. 9. 10:00
728x90
728x90

 

지금까지 Node.js로 CRUD를 구현한 글을 써봤다. fs 모듈과 mySQL 데이터베이스를 사용해서 CRUD를 구현해봤는데 이번 글에서는 express 프레임워크를 사용해서 CRUD를 구현해보겠다.

 

 

1. Node.js + fs 모듈로 게시글 관리

2. Node.js + mySQL로 게시글과 회원 관리

3. express + fs 모듈 + 세션으로 게시글과 로그인 구현

4. express + mySQL + 세션으로 게시글, 회원 관리

 

여기 가면 1~4단계의 프로젝트 디렉토리가 준비되어 있으니 포크해가서 돌려보면서 뭐가 다른지 봐도 된다. 돌려볼 때는 각 디렉토리로 들어가서 npm install 명령을 실행해줘야 한다. node_modules는 무거워서 버전 관리를 하지 않았기 때문이다.

 

* 사족이 굉장히 길고 실제 CRUD는 좀 아래에 있으니 CRUD만 확인하고 싶으신 분들은 아래를 참고하시면 됩니다.

* 추가로 미들웨어와 express API(app, router, req/res), 세션에 대해 설명한 글이 있습니다. 아래 목록을 먼저 읽어보시면 도움이 됩니다.

 

express.js API 문서 - app, router, req/res

지난 주에 express 문서를 보면서 궁금한 걸 다 털어내버렸다. 그런 김에 정리했으니 한 번 더 되새길 겸 블로그에 글도 남긴다. 더 자세하고 정확한 내용은 문서에 있다. 나는 내가 궁금한 것만 정

dev-dain.tistory.com

 

[Node.js] express 미들웨어란?

미들웨어 사용하기 Express 미들웨어란 무엇인가? 쉽게 말해 함수이다. Express에서는 사실상 모든 것이 미들웨어이다. 내가 이해하기로 미들웨어와 미들웨어 함수는 같은 말이다(아니라면 댓글 부

dev-dain.tistory.com

 

[Node.js] express-session 다뤄보기

HTTP 쿠키와 Node.js, express에서의 쿠키 사용 세션을 다뤄보기 전에 먼저 HTTP 쿠키에 대해 간단히 설명할까 한다. 쿠키는 HTTP 프로토콜에 포함된 기술로, 서버가 사용자의 브라우저(클라이언트)에 전

dev-dain.tistory.com

 


 

3. express.js

이전 단계보다 디렉토리가 몇 개 더 생겼다. DB가 없어서 2단계에서 없어졌던 data 디렉토리가 다시 부활했고 lib 디렉토리도 1단계에서 썼던 template만 남아 있다.

public, routes, sessions 디렉토리가 새로 생겼는데 public은 아래 설명할 정적 리소스(이미지, css, js 파일 등)들의 루트 디렉토리이고, routes는 router 객체를 생성해 export하는 모듈을 담고 있으며, sessions는 사용자가 서버에 접속했을 때 자동으로 생성되는 session 파일들이 위치할 디렉토리이다.

 

 

- 설치한 모듈

  • compression : response body 크기를 줄이려고 gzip 압축을 사용할 때 compression 미들웨어를 사용할 수 있음. 압축을 하고 푸는 것이 네트워크 전송 비용보다 저렴하고 빠르므로 압축하는 것이 좋음. express 홈페이지의 프로덕션 우수 사례: 성능 및 신뢰성에 소개된 미들웨어
  • express : express 프레임워크를 사용할 수 있도록 하는 모듈
  • express-session : session 객체를 만들고 관리할 수 있도록 하는 모듈
  • helmet : HTTP 헤더를 적절히 설정해 몇 가지 웹 취약성으로부터 앱을 보호할 수 있도록 함. XSS 등의 공격을 예방할 수 있음. express 홈페이지의 프로덕션 우수 사례: 보안에 소개된 미들웨어
  • sanitize-html : XSS 공격을 막기 위한 모듈이다. 사용자의 입력에서 <script>나 HTML 태그를 걸러준다. allowed tags로 몇몇 태그를 허용할 수도 있다. npm i sanitize-html 로 설치할 수 있다.
    • helmet이 XSS 공격을 예방해줄 수 있는데 이걸 중복 설치해서 얼마나 실효성이 있는지는 모르겠다. 하지만 입력을 받을 때 <script>와 같은 태그를 아예 삭제해주는 것은 이해하기 쉬운 방어이다.
    • 하지만 sanitize-html도 문제가 있는 게 태그와 아무 관계 없이 '<>' 안에 들어있는 모든 문자들을 다 지워버리는 것이 문제다. 만약 사용자가 '<주의하세요>' 같은 내용을 입력하면 주의하세요 문자열 자체가 날아가버린다.
    • 태그와 그 안의 내용을 지워버리지 않고 '<'과 '>'를 &lt; &gt; 등으로 바꾸려면 html-entities 등의 모듈을 설치해 이스케이프해주는 것도 좋은 방법일 것 같다. 템플릿 엔진을 쓰면 자동으로 이스케이프해주기도 한다. (ex. pug) 일단 여기서는 사용자가 '<>'를 쓰지 않는다고 약속했다고 치자.
  • session-file-store : 세션은 기본적으로 서버에 저장된다. 말인즉슨 서버가 한 번 꺼졌다 켜지면 모든 세션이 다 날아가는 것이다. 세션 파일을 저장할 저장소를 지정할 수 있게 해주는 모듈이다. 

(주석 : body-parser 모듈도 설치했지만 Express의 v4.16.0 이후로는 express.urlencoded 메소드로 body-parser를 대체할 수 있기 때문에 뺐습니다. 아래 설명에서도 express.urlencoded 메소드를 사용하는 방식으로 설명하겠습니다. 만약 v4.16.0 이전 버전의 Express를 사용하신다면 body-parser가 필요합니다. 사용법은 동일합니다.)

 

 

- 라우팅 방법

express에서 라우팅이란 URI 및 특정 HTTP 요청 메소드(get, post 등)에 대한 클라이언트 요청에 애플리케이션이 응답하는 방법을 결정하는 것이다.

아마 express를 쓰면서 Node.js와 비교해 제일 신나고 재미있는 점은 express 앱의 router 객체를 만들어 한 router가 한 path를 담당하도록 할 수 있다는 점이다. 이것은 2단계에서 lib 디렉토리 밑에 article과 author 모듈을 만들어 main에서 path마다 그 모듈의 메소드를 호출하게 한 것과 비슷한 원리이다.

const express = require('express');
const app = express();
const indexRouter = require('./routes/index');
const articleRouter = require('./routes/article');
const authRouter = require('./routes/auth');

//code

app.use('/article', articleRouter);
app.use('/auth', authRouter);
app.use('/', indexRouter);

 

app.use를 써서 첫 번째 인자로 path, 두 번째 인자로 router를 주면 된다. 1, 2단계에서 /create, /create_process 하던 분기는 다 저 라우터 모듈 안에 있다.

 

 

- 미들웨어 사용

일단 app.use로 미들웨어들을 사용해준다. 이 부분은 이 포스팅의 '진짜 미들웨어 사용하기' 섹션에서 다뤘으므로 코드만 올리고 설명은 생략한다.

// /main.js
const fs = require('fs');
const express = require('express');
const app = express();
const helmet = require('helmet');
const compression = require('compression');
const session = require('express-session');
const fileStore = require('session-file-store')(session);

const indexRouter = require('./routes/index');
const articleRouter = require('./routes/article');
const authRouter = require('./routes/auth');

app.use(helmet());
app.use(compression());
app.post('*', express.urlencoded({ extended: false }));
//'public' 디렉토리 안에서 static file을 찾겠다는 의미
app.use(express.static('public'));

app.use(session({
  secret: '!%#!#DAset#tGHBAX3sdag62437',
  resave: false,
  saveUninitialized: true,
  cookie: {
    httpOnly: true
  },
  store: new fileStore()
}));

 

기본적으로 GET 방식으로 받는 요청들에는 글 목록이 다 보이게 되어 있다. 그러므로 GET 방식으로 들어오는 모든 경로 요청에 대해 fs.readdir을 해서 목록을 보여주는 미들웨어를 사용해보겠다. 목록을 읽어와 req.list로 넘기면 라우터에서 req.list로 글 목록을 받을 수 있다.

app.get('*', (req, res, next) => {
  fs.readdir('./data', (err, files) => {
    if (err)
      next(err);
    req.list = files;
    next();
  });
});

app.use('/article', articleRouter);
app.use('/auth', authRouter);
app.use('/', indexRouter);

 

next()로 다음 미들웨어로 제어를 넘기므로, 이후부터는 /article, /auth, 루트 경로 중 어느 경로로 들어왔는지에 따라 해당 라우터로 넘어가게 된다.

만약 /article이나 /auth, 루트가 아닌 주소로 넘어갔다면 404 에러를 내고, 만약 /article로 들어가긴 했는데 없는 파일의 경로에 접근한다면 next(err)를 호출해서 500 에러를 내도록 한다. 최종적으로는 3000번 포트를 열어 서버를 여는 listen이 있어야 한다.

app.use((req, res, next) => {
  res.status(404).send('Sorry! Wrong path.');
});

app.use((err, req, res, next) => {
  console.log(err.stack);
  res.status(500).send('Sorry!');
});

app.listen(3000, () => console.log('node on 3000'));

 

문서에도 설명이 되어 있듯 app.use에서 path를 생략하면 모든 경로에 대해 미들웨어를 수행하는 것이 된다. 오류 처리 미들웨어들은 라우터로 보내는 app.use 위에 있으면 안 되고 가급적 하단에 있어야 한다. res.status.send 를 하면서 응답을 줄 뿐더러 next()로 다음 미들웨어를 호출하지도 않기 때문이다.

 

이제 준비가 됐으니 article create부터 살펴보겠다...

라고 하기 전에 잠시 routes 디렉토리 안의 article.js 라우터 배치를 좀 보겠다. 참고로 이 routes는 사용자가 /article로 시작하는 path로 접속했을 때 들어가는 라우터다. /article이 앞에 있는 것이 당연하기 때문에 router.METHOD 인자의 path에는 /article이 생략되어 있다.

// /routes/article.js

router.get('/article/:id', (req, res, next) => { });
// 이 경우 /article/5가 아닌 /article/article/135 등에 대응됨

 

이 생략은 선택이 아니라 필수다.

// routes/article.js
const express = require('express');
const router = express.Router();
const fs = require('fs');
const template = require('../lib/template');
const sanitizeHtml = require('sanitize-html');

router.get('/create', (req, res) => {
  //code
});
router.post('/create_process', (req, res) => {
  //code
});
router.get('/:name', (req, res, next) => {
  //code
});
router.get('/update/:name', (req, res) => {
  //code
});
router.post('/update_process', (req, res) => {
  //code
});
router.post('/delete_process', (req, res) => {
  //code
});

module.exports = router;

 

지금까지는 Read, Create, Update, Delete 순으로 코드가 작성되어 있었는데 이번에는 Create가 위에 올라와 있다. 이유가 뭘까?

콜론을 앞에 붙인 :name이 보이시는가? req.params를 어떻게 쓰는지 아시는 분이라면 저게 어떻게 동작하는지 알 것이다(모른다면 이 포스팅의 req 부분을 참고하시기 바랍니다). 사용자가 /article/cosmos 경로로 들어왔을 때 name은 cosmos가 되고, /article/1984 경로로 들어오면 1984가 되는 식으로 변하는 경로이다.

그런데 /create가 /:name보다 뒤에 있게 되면 사용자가 create를 하려고 했을 때 router.get('/create')로 들어가는 게 아니라 router.get('/:name')으로 들어가게 된다. 그래서 fs.readFile에서 create.txt 파일을 찾게 되는데 당연히 없어서 next(err)로 500 에러를 뿜는 엉뚱한 행동을 하게 되는 것이다. 이것을 막기 위해 create를 위로 올려줬다.

그 외 /update/:name은 /update 뒤에 한 path 더 들어가니 괜찮다. update_process와 delete_process가 '/:name'으로 들어가지 않는 까닭은 POST 메소드 요청을 처리하는 것이기 때문이다. 만약 GET 방식이라면 이것도 ':/name' 위로 올려줘야 할 것이다. 

 

다시 한 번 정리하자면 app.use('/article', articleRouter)를 했을 때는 그 경로가 /article/create 든지 /article/update/cosmos든지 /article/delete_process 든지 /article 로 시작하는 거라면 죄다 articleRouter로 보내게 된다(실행한다는 말). 그에 반해 app.METHOD와 router.METHOD는 딱 그 path만 보게 된다. 만약 articleRouter의 router.get(':/name')이라면 /article/cosmos, /article/1984 같은 건 되지만 /article/update/cosmos 이렇게 한 path 더 들어가는 건 안 된다. 직접 해봤는데 404 에러가 뜬다.

참고로 맨 아래 module.exports = router는 필수이다. 이것을 하지 않으면 export를 안 했으니 main에서 라우터를 제대로 쓸 수가 없다. 'Router.use() requires a middleware function but got a Object' 에러가 나는데 export 안 해서 나는 에러니까 꼭 export 해주자.

 

그럼 이쯤 마무리하고 진짜 CRUD를 보겠다.

 

 

Session 로그인/로그아웃

이 단계에서는 데이터베이스가 없으므로 사용자의 정보를 따로 관리할 방법이 파일이나 내부적으로 객체를 쓰는 방법 외에는 딱히 없다. 여기서는 일단 객체를 하나 생성해 그 사용자 정보로만 로그인을 할 수 있도록 설정해보겠다. 지금은 /routes/auth.js 로 왔다.

// routes/auth.js
const express = require('express');
const router = express.Router();
const template = require('../lib/template');

let user = {
  email: 'dev@gmail.com',
  pw: '1',
  nickname: 'dev'
};

router.get('/login', (req, res) => {
  if (req.session.isLogined === true) {
    res.redirect(302, '/');
  } else {
    const title = "login";
    const content = 
    `
    <form action="/auth/login_process" method="post">
      <p>  
        <input type="text" name="email" placeholder="email">
      </p>
      <p>
        <input type="password" name="pw" placeholder="password">
      </p>
      <p>
        <input type="submit" value="로그인">
      </p>
    </form>
    `;
    const login = '<a href="/auth/login">login</a>'
    const html = template.html(title, content, '', '', login);
    res.status(200).send(html);
  }
});

router.post('/login_process', (req, res) => {
  if (req.body.email !== user.email || req.body.pw !== user.pw) {
    res.redirect(302, '/auth/login');
  } else {
    req.session.isLogined = true;
    req.session.nickname = user.nickname;
    res.redirect(302, '/');
  }
});

module.exports = router;

 

일단 user 객체를 하나 만들어준다. 이메일, 비밀번호, 닉네임이 들어있다. 정말 허접하지만 어차피 절대 이런 방식을 쓰지 않을 거고 연습용이니 간단하게 만든다.

/auth/login 으로 접속한 사용자는 router.get('/login')으로 들어가게 되는데, 만약 isLogined가 true라면 홈으로 다시 로그인하지 않아도 되니 돌려보낸다. 그렇지 않다면 로그인 폼을 보여준다.

login_process에서는 이메일과 비밀번호가 객체의 정보와 맞는지 확인한 다음 틀리다면 다시 /auth/login 으로 리다이렉션 보내주고 맞다면 req.session.isLogined를 true로 만들어준다. 참고로 최초로 로그인을 했을 때 req.session.isLogined 라는 건 없다. 여기서 새로 만들어주는 거다. 그리고 session에 닉네임 정보도 같이 넣어서 루트로 돌려보내준다. 앞으로 CRUD를 할 때 이 req.session.isLogined 값이 있느냐(즉 로그인을 했는지 안 했는지)에 따라 Read만 하냐 CRUD를 함께 할 수 있느냐가 달라진다. 

로그아웃을 하는 코드는 main.js 안에 썼다. 간단하게 req.session.destroy를 하면 된다. 이 위치는 라우터들과 404 app.use 중간에 끼어 있다.

app.get('/logout', (req, res) => {
  req.session.destroy((err) => {
    if (err) throw err;
    res.redirect(302, '/');
  })
})

 

아래 코드는 /routes/article.js 안의 코드이다.

 

 

Create

router.get('/create', (req, res) => {
  if (!req.session.isLogined) {
    res.redirect(302, '/');
  } else {
    const login = `${req.session.nickname} | <a href="/logout">logout</a>`;
    const title = "Create";
    const content =
      `
    <form action="/article/create_process" method="post">
      <p>
        <input type="text" name="title" placeholder="title">
      </p>
      <p>
        <textarea name="content"></textarea>
      </p>
      <input type="submit" value="글쓰기">
    </form>
    `;
    const list = template.list(req.list);
    const create = template.create();
    const html = template.html(title, content, list,
      `${create}`, login);
    res.status(200).send(html);
  }
});
router.post('/create_process', (req, res) => {
  const title = sanitizeHtml(req.body.title);
  const content = sanitizeHtml(req.body.content);
  fs.writeFile(`./data/${title}.txt`, content, 'utf8', () => {
    res.redirect(302, `/article/${title}`);
  });
});

 

if-else는 로그인하지 않은 사용자는 루트로 튕겨내는 분기문이다. 아까 login에서 살펴봤듯이 로그인을 하지 않으면 req.session.isLogined가 없는데(즉 undefined임) 자바스크립트에서 undefined는 falsy한 값이므로 if문이 true가 되어 홈으로 튕기게 된다. 쉽게 말해 create하고 싶으면 로그인해야 한다는 말이다. else로 넘어왔다면 당연히 로그인이 되어 있는 상태이므로 사용자의 닉네임과 로그아웃할 수 있는 링크를 보여준다.

login이라는 게 추가되고 template.html에 login 매개변수가 추가됐다는 걸 제외하면 (1) Node.js + fs 모듈 CRUD와 상당히 유사하다. 여기서는 fs.readdir을 따로 해주지 않는데 이유는 main.js에서 app.get('*', (req, res, next) => { }) 하면서 여기서 이미 fs.readdir을 다 해버리고 그 결과를 req.list에 넣었기 때문이다. 일이 이렇게 간편해지다니 놀랍지 않은가?

create_process도 Node.js + fs 모듈에서 req.on을 붙여가며 했던 것이 그냥 req.body로 접근할 수 있는 모습이다. (express.urlencoded({ extended: false }) 덕분이다)

//Node.js
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);

res.writeHead(302, { Location: `/?id=${title}`});
res.end();

//express
res.status(200).send(html);

res.redirect(302, `/article/${title}`);

 

상태 코드를 보내고 요청을 끝내는 건 위와 같이 체이닝으로 한 줄로 써줄 수 있다. express의 경우 redirect는 res 객체에 redirect 메소드가 있으므로 그걸 써주면 된다. 2번째 매개변수는 리다이렉션을 보낼 경로이다.

 

Create : fs.readdir으로 목록 조회(app.get으로 미리 실행) + form 만들기 => req body 파싱(express.urlencoded로 미리 실행) + fs.writeFile로 파일 생성, 리다이렉션 

 

 

Read

// routes/index.js
router.get('/', (req, res) => {
  const login = (req.session.isLogined) ? 
    `${req.session.nickname} | <a href="/logout">logout</a>` :
    '<a href="/auth/login">login</a>';

  const title = "Index Page";
  const content = "메인 페이지입니다.";
  const list = template.list(req.list);
  const create = (req.session.isLogined) ? template.create() : '';
  const html = template.html(title,
    `${content}
      <img src="/img/cat.jpeg" style="max-width: 100%; height: auto" alt="고양이">`,
    list, create, login);
  res.status(200).send(html);
});
// routes/article.js
router.get('/:name', (req, res, next) => {
  fs.readFile(`./data/${req.params.name}.txt`, 'utf8', (err, data) => {
    //if-else로 안 나눠주면 err일 때 뻗어버림
    if (err) {
      next(err);
    } else {
      console.log(req.session.isLogined);
      const login = (req.session.isLogined) ?
      `${req.session.nickname} | <a href="/logout">logout</a>` : 
      '<a href="/auth/login">login</a>';
      const title = req.params.name;
      const content = data;
      const list = template.list(req.list);
      const create = (req.session.isLogined) ? template.create() : '';
      const updateDelete = (req.session.isLogined) ?
        template.updateDelete(title) : ''; 
      const html = template.html(title, content, list,
        `${create}${updateDelete}`, login);
      res.status(200).send(html);
    }
  });
});

루트는 따로 라우터를 지정해줬기에 index.js를 잠깐 보겠다. 여기서도 app.get이 미리 실행되어 fs.readdir 한 결과를 req.list로 받아둔 상태다. 여기서는 express.static 했던 걸 써먹기 위해 고양이 사진 한 장을 넣어봤다.

홈은 로그인 사용자와 로그인하지 않은 사용자 모두 조회가 가능하므로 login 바가 다르게 보여야 하는데, 로그인이 되어 있지 않다면 login 링크가 보이고, 로그인돼 있다면 닉네임과 logout 링크가 보이게 해 뒀다. login을 빼면 1단계의 main과 딱히 다른 건 없다.

상세 보기 페이지에서는 fs.readFile 해준다. fs.readdir을 안 한 이유는 이미 두 차례 설명했기에 앞으로는 더 이상 설명하지 않겠다. 여기서는 req.params.name으로 사용자가 입력한 name.txt을 data 디렉토리 안에서 찾아 readFile한다. 만약 그런 파일이 없다면 next(err)를 하면서 articleRouter를 벗어나 main의 (err, req, res, next) 오류 처리 미들웨어로 바로 넘어가게 처리했다. 이것도 login을 제외하면 1단계와 별다를 게 없다. 참고로 로그인을 안 했다면 create, update, delete 버튼이 아예 보이지 않게 처리했다.

 

Read : fs.readFile로 해당 내용 조회, 만약 없는 파일이라면 오류 처리 미들웨어로 제어 넘김

 

 

Update

router.get('/update/:name', (req, res) => {
  if (!req.session.isLogined) {
    res.redirect(302, '/');
  } else {
    fs.readFile(`./data/${req.params.name}.txt`, 'utf8', (err, data) => {
      if (err) throw err;
      const title = "Update";
      const content =
        `
    <form action="/article/update_process" method="post">
      <p>
        <input type="hidden" name="origin_title" value="${req.params.name}" readonly>
        <input type="text" name="title" placeholder="title" 
        value="${req.params.name}">
      </p>
      <p>
        <textarea name="content">${data}</textarea>
      </p>
      <input type="submit" value="글쓰기">
    </form>
    `;
      const login = `${req.session.nickname} | <a href="/logout">logout</a>`;
      const list = template.list(req.list);
      const create = template.create();
      const updateDelete = template.updateDelete(req.params.name);
      const html = template.html(title, content, list,
        `${create}${updateDelete}`, login);
      res.status(200).send(html);
    });
  }
});
router.post('/update_process', (req, res) => {
  const origin_title = req.body.origin_title;
  const title = sanitizeHtml(req.body.title);
  const content = sanitizeHtml(req.body.content);
  fs.rename(`./data/${origin_title}.txt`, `./data/${title}.txt`, () => {
    fs.writeFile(`./data/${title}.txt`, content, 'utf8', () => {
      res.redirect(302, `/article/${title}`);
    });
  });
});

req.session.isLogined가 true가 아니라면 update 페이지에서 튕기도록 했다. 그런 다음 readFile을 해서 form에 미리 내용을 채워뒀다.

update_process에서도 create_process처럼 req.body로 form data가 다 넘어왔기 때문에 파싱하지 않고 그냥 갖다쓸 수 있다. 사용자가 파일 이름을 바꿨을 경우를 대비해 미리 받아둔 origin_title에서 title로 fs.rename으로 이름을 수정해준 뒤 새 파일에 content를 fs.writeFile 해준다.

 

Update : form 만들기 -> fs.rename으로 파일 이름부터 변경 + fs.writeFile로 파일 덮어쓰기

 

 

Delete

router.post('/delete_process', (req, res) => {
  const title = req.body.title;
  fs.unlink(`./data/${title}.txt`, () => {
    res.redirect(302, '/');
  });
});

 

어처구니없을 정도로 간단하다. title을 받아 unlink해서 없애버리면 된다. 

 


 

다음에는 express와 mySQL DB를 연동하는 걸 해볼 텐데, template 대신 pug를 쓰는 것으로 pug도 연습하고 문자열 리터럴이 있는 코드를 좀 고쳐볼까 한다.

 

 

 

Reference

생활코딩 - Node.js - Express

 

 

 

728x90
반응형
0 Comments
댓글쓰기 폼