웹프로그래밍

  • 노드 설치
  • 프락시 등록
  • 서비스로 등록(부팅시 자동 실행)
  • 익스프레스
  • 데이터베이스 연동

기존에는 웹서버 프로그래밍을 위해서 어플리케이션 서버를 이용해서 구현을 했다. 자바 기반의 어플리케이션 서버로는 글래스피시, 제이보스, 톰캣등이 있다. 그러나 비교적 간단한 프로그래밍을 위해서는 nodejs로도 충분하다. 여기서는 nodejs를 사용한다. nodejs는 자바스크립트로 만들어진 플랫폼이다.

노드

노드 설치

웹에서 LTS 버전 8을 다운받는다.

$ curl --silent --location https://rpm.nodesource.com/setup_8.x | sudo bash -

다음 명령어를 이용해 설치를 한다.

$ sudo yum install nodejs

npm(node package manager)도 함께 설치된다. 제대로 설치되었는지 확인한다.

$ node -v
$ npm -v

노드 간단 살표보기

모듈 사용하기

모듈을 부를 때는 require() 메소드를 사용한다. 간단한 내장모듈을 사용해보자.

os 모듈

os 모듈은 시스템 정보에 대해서 알려준다.

let os = require('os');

console.log('호스트 이름: %s', os.hostname());
console.log('메모리: %d / %d', os.freemem(), os.totalmem());
console.log('CPU 정보\n');
console.dir(os.cpus());
console.log('네트워크 인터페이스 정보\n');
console.dir(os.networkInterfaces());

os.hostname()은 호스트 이름을 반환하고 os.freemem()는 여유 메모리 양을 os.totalmem()는 총 메모리 양을 반환한다. os.cpus() 각 논리 cpu 코어에 대한 정보를 담은 객체에 대한 배열을 반환한다. os.networkInterfaces()는 네트워크 주소에 대응되는 네트워크 인터페이스에 대한 객체를 반환한다.

path 모듈

path 모듈은 파일과 디렉토리 경로에 대한 기능을 제공한다.

const path = require('path');

const dirs = ["users", "kim", "docs"];
const docsDir = dirs.join(path.sep);
console.log('문서 디렉토리: %s', docsDir);

const curPath = path.join('/users/kim', 'node.js');
console.log('노드 파일 경로: %s', curPath);

dirs.join() 메소드는 배열(Array)에 속한 것으로 배열의 요소들을 주어진 연결자로 모두 연결하여 하나의 문자열을 반환한다. path.join()은 여러 개의 경로를 합쳐서 하나의 경로로 만든다. path.sep은 플랫폼에서 사용되는 경로 구분자를 제공한다.

모듈 만들어 사용하기

사용자 모듈은 module.exports를 이용해서 만든다. myModule.js 파일을 만들어 다음과 같이 입력한다.

module.exports.a = "A";
module.exports.f = function() { return "함수입니다";};

모듈을 사용하기 위해서는 위에서 했던 것같이 require()를 이용한다. 사용자 모듈은 위치를 정확하게 지정하고 확장자 .js는 생략한다.

const myModule = require('./myModule');

console.log(myModule.a);
console.log(myModule.f());

프락시(Proxy) 설정

Nginx의 역프락시(reverse proxy) 설정을 통하여 다른 포트에서 실행되는 웹 어플리케이션을 80포트로 서비스할 수 있다.

/etc/nginx/nginx.conf 파일에서 다음과 같이 입력한다. 이것은 /app 경로로 요청이 오는 것을 http://localhost:3000 포트에서 담당하라고 돌리는 것이다.

location /app {
   proxy_pass http://localhost:3000;
}

만일 https 포트로 자동 리다이렉트 했다면 /etc/nginx/conf.d/ssl.conf 파일에 위와 똑같이 넣어준다.

위와 같이 하고 nginx를 다시 실행시킬 때 접속 에러가 나면 SELinux 설정 문제이다. 다음과 같이 설정값을 확인할 수 있다.

httpd_can_network_connect --> off

httpd_can_network_connectoff로 되어 있으면 다음과 같이 on으로 설정을 한다.

sudo setsebool -P httpd_can_network_connect 1

-P는 permanent이다. 그러면 http://localhost/app을 통해서 접근할 수 있다. 물론 3000번 포트로 웹페이지가 서비스 되고 있어야 한다. 다음과 같은 내용을 app.js 파일에 저장하고 node app.js를 실행하고 접속해보면 알 수 있다.

var http = require('http');
var server = http.createServer();

var port = 3000;
server.listen(port, function(req, res) {
  console.log('웹서버가 시작되었습니다. %d', port);
});

server.on('request', function(req, res) {
  console.log('클라이언트 요청이 들어왔습니다.');

  res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
  res.write("<!DOCTYPE HTML>");
  res.write('<html>');
  res.write(" <head>");
  res.write("   <title>응답 페이지</title>");
  res.write(" </head>");
  res.write(" <body>");
  res.write("   <h1>노드제이에스</h1>");
  res.write(" </body>");
  res.write("</html>");
  res.end();
});

앱을 중지하려면 ctrl + c를 누르면 실행이 멈춘다.

노드 앱을 서비스로 실행

만들어진 앱을 백그라운드 서비스로 멈추지 않고 실행되게 하기 위해서는 pm2 패키지를 이용한다.

PM2 설치

pm2는 노드 프로그램들을 관리하는 패키지이다. 노드 앱들을 백그라운드 서비스로 실행하고 관리하는 기능을 가지고 있다. npm을 이용해 pm2를 설치한다.

$ sudo npm install pm2@latest -g

PM2를 이용한 앱 관리

앱 시작

app.js 앱을 pm2를 이용해 시작하려면 다음과 같이 입력한다.

$ pm2 start app.js
[PM2] Starting /home/dyoon/work/nodejs/app.js in fork_mode (1 instance)
[PM2] Done.
┌──────┬──────┬────────┬───┬─────┬───────────┐
│ Name │ mode │ status │ ↺ │ cpu │ memory    │
├──────┼──────┼────────┼───┼─────┼───────────┤
│ app  │ fork │ online │ 0 │ 6%  │ 14.9 MB   │
└──────┴──────┴────────┴───┴─────┴───────────┘
 Use `pm2 show <id|name>` to get more details about an app

pm2에 의해서 시작된 앱들은 중간에 멈추거나 할 때 pm2가 자동으로 다시 시작하게 해준다.

pm2를 시스템 부팅시 자동으로 시작하게 하려면 다음과 같이 startup 명령어를 사용해 주면 된다. startup 명령어는 부팅시 필요한 설정들을 자동으로 만들어 /etc/systemd/system/pm2-root.service 파일에 저장한다.

$ sudo pm2 startup systemd

PM2 사용법

$ pm2 stop app_name(or pid)
$ pm2 restart app_name(or pid)
$ pm2 list
$ pm2 info app_name
$ pm2 monit

웹서버 만들기

간단한 웹서버

다음과 같이 실행을 하면 웹서버가 만들어 진다.

var http = require('http');
var server = http.createServer();

var port = 3000;
server.listen(port, function(req, res) {
  console.log('웹서버가 시작되었습니다. %d', port);
});

server.on('request', function(req, res) {
  console.log('클라이언트 요청이 들어왔습니다.');

  res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
  res.write("<!DOCTYPE HTML>");
  res.write('<html>');
  res.write(" <head>");
  res.write("   <title>응답 페이지</title>");
  res.write(" </head>");
  res.write(" <body>");
  res.write("   <h1>노드제이에스를 이용한 간단한 웹서버를 만들었습니다.</h1>");
  res.write(" </body>");
  res.write("</html>");
  res.end();
});

파일읽어 보내기

서버에 있는 파일을 읽어서 클라이언트에게 보여주는 프로그래밍을 해본다. 노드에서 파일을 다루는 모듈로 fs를 사용한다.

const fs = require('fs');

filename = 'hello.txt';
fs.readFile(filename, 'utf8', (err, data) => { console.log(data); });

서버에 있는 파일을 읽어서 웹페이지로 보내보자.

const http = require('http');
const fs = require('fs');

const port = 3000;
const server = http.createServer();

const filename = 'hello.txt';

server.on('request', (req, res) => {
  console.log('클라이언트 요청이 들어왔습니다.');
  fs.readFile(filename, (err, data) => {
    res.writeHead(200, {"Content-Type": "text/plain; charset=utf-8"});
    res.write(data);
    res.end();
  });
});

server.listen(port, function(req, res) {
  console.log('%d 포트에서 웹서버가 시작되었습니다.', port);
});

이미지 파일을 웹브라우저에 보내보자.

const http = require('http');
const fs = require('fs');

const port = 3000;
const server = http.createServer();

const filename = 'house.png';

server.on('request', (req, res) => {
  console.log('클라이언트 요청이 들어왔습니다.');
  fs.readFile(filename, (err, data) => {
    res.writeHead(200, {"Content-Type": "image/png; charset=utf-8"});
    res.write(data);
    res.end();
  });
});

server.listen(port, function(req, res) {
  console.log('%d 포트에서 웹서버가 시작되었습니다.', port);
});

익스프레스(Express)

익스프레스는 웹 서버를 운영하는데 편리한 방법을 제공하는 프레임워크이다. 익스프레스는 노드의 http 모듈을 기반으로 만들어 졌다.

설치

익스프레스 설치는 기본설치앱 생성기를 이용한 설치 두 가지를 이용할 수 있다.

기본 설치

  1. 프로젝트 폴더 myapp을 생성한다.

    $ mkdir myapp
    
  2. myapp 디렉토리로 이동한 후 npm init 이용 package.json 파일을 생성한다. 여러 가지 질문에 기본값으로 대답을 한다.

    $ cd myapp
    $ npm init
    
  3. 다음을 이용해 package.json 파일 dependencies에 express를 추가하면서 express를 node_modules 디렉토리에 설치한다.

    $ npm install express --save
    

앱 생성기(express-generator)를 이용 설치(선택 사항)

이 부분은 선택 사항이어서 따라하지 않아도 됩니다.

express-generator는 앱의 골격을 쉽게 설치하도록 도와준다.

  1. 프로젝트 폴더 생성하고 그 폴더로 이동.

  2. npm install express-generator -g를 이용해서 express 설치.

  3. express -h를 이용 도움말을 살펴본다.

  4. 다음과 같이 myapp이라는 폴더에 현재 디렉토리 아래에 만들고 설치한다. view는 pug라는 템플릿을 이용한다.

    $ express --view=pug myapp
    
  5. 아래와 같이 실행한다.

    $ cd myapp
    $ npm install
    
  6. 윈도우에서 다음과 같이 실행한다.

    $ set DEBUG=myapp:* & npm start
    
  7. 이후 브라우저에서 http://localhost:3000/을 로드하여 앱에 액세스하십시오.

생성된 앱은 다음과 같은 디렉토리 구조를 갖습니다.

.
├── app.js
├── bin
│   └── www
├── package.json
├── public
│   ├── images
│   ├── javascripts
│   └── stylesheets
│       └── style.css
├── routes
│   ├── index.js
│   └── users.js
└── views
    ├── error.pug
    ├── index.pug
    └── layout.pug

7 directories, 9 files

익스프레스를 이용한 서버 실행

myapp 디렉토리 안에 app.js 파일을 만들고 아래와 같이 입력한다.

const express = require('express');
const app = express();

app.get('/', (req, res) => res.send('안녕하세요. 익스프레스에 오신 걸 환영합니다.'));

app.listen(3000, () => console.log('3000 포트에서 웹서버가 실행되었습니다.'));

다음 명령을 명령어창에서 실행한다. http://localhost:3000으로 접속을 한다.

$ node app.js

웹서버를 끝내기 위해서는 명령창에서 ctrl + c를 누른다. 익스프레스를 사용하기 위해서는 express를 불러와 express() 앱 객체를 만들어 사용한다. 웹서버를 실행하기 위해서는 특별한 용도가 아니라면 http 모듈없이 app.listen() 메소드로 충분하다. app.listen()http.Server.listen() 메소드와 동일하다. 익스프레스 앱객체가 가지고 있는 메소드들 중에 많이 사용되는 것으로는 다음과 같다.

메소드 이름 설명
set(name, value) 앱객체의 속성을 설정한다. get(name) 메소드를 이용해 접근한다.
get(name) 앱객체의 속성에 접근한다.
use([path], callback[, callback…]) 미들웨어 함수를 사용한다. path는 미들웨어가 실행될 경로이다.
get(path, callback[, …]) path 경로로 콜백함수를 실행한다. 이것을 라우팅이라고 한다.

app.set() 예를 들면 다음과 같다.

app.set('port', 3000);

app.listen(app.get('port'), () => console.log('3000 포트에서 웹서버가 실행되었습니다.'));

app.set('port', 3000)을 설정해놨다가 필요할 때는 app.get('port')를 이용해서 사용할 수 있다. app.set(name, value) 설정시 name 중 익스프레스 앱객체가 사용하는 특별한 이름들 즉, env, etag, json escape, views, view engine, … 등은 피해야 한다.

요청/응답 주기

사용자로부터 요청(request)이 들어오면 응답(response)을 다음과 같은 메소드를 이용함으로써 요청-응답 주기를 끝낸다. 아래 메소드 중 어느 하나도 불리지 않으면 클라이언트 요청은 멈추어 있게 된다. 요청과 응답 사이에 미들웨어 함수를 이용해서 다양한 작업들을 할 수 있다.

메소드 설명
res.download() 파일이 다운로드되도록 프롬프트합니다.
res.end() 응답 프로세스를 종료합니다.
res.json() JSON 응답을 전송합니다.
res.jsonp() JSONP 지원을 통해 JSON 응답을 전송합니다.
res.redirect() 요청의 경로를 재지정합니다.
res.render() 보기 템플리트를 렌더링합니다.
res.send() 다양한 유형의 응답을 전송합니다.
res.sendFile 파일을 옥텟 스트림의 형태로 전송합니다.
res.sendStatus() 응답 상태 코드를 설정한 후 해당 코드를 문자열로 표현한 내용을 응답 본문으로서 전송합니다.

프로그램 실행

node app.js를 이용하여 프로그램을 실행시킬수도 있고 nodemon 패키지를 이용해서 실행할 수 있습니다. nodemon을 이용하여 실행을 하면 자동으로 소스 파일들을 모니터링해서 파일이 수정되면 자동으로 재시작을 합니다. 여러 파일을 한꺼번에 수정해서 재시작 시간을 조정하려면 --delay 옵션을 이용해서 nodemon --delay 10 app.js를 실행하면 파일이 수정되고 10초후에 재시작을 합니다.

미들웨어(middleware) 이용

익스프레스는 미들웨어를 이용해서 클라이언트의 요청을 처리하고 응답한다. 앞에서 봤던 app.get(), app.use() 들의 콜백함수 인자들이 모두 미들웨어이다. 클라이언트로부터 요청이 들어오면 app 객체에 등록되어 있는 순서대로 차례로 실행을 한다. 그러다가 응답을 끝내는 함수들 중 하나를 만나면 실행을 끝낸다. app 객체에 등록하는 방법은 app.use(), app.METHOD() 또는 라우터 객체의 Router().use(), Router().METHOD() 들이 있다. 여기서 METHOD()는 HTTP 요청 메소드에 대응되는 get(), post(), 등이 있다.

미들웨어(middleware) 함수

미들웨어란 요청(request)와 응답(response) 사이에서 처리하는 함수입니다.

미들웨어 함수는 다음과 같은 형식으로 되어 있습니다.

function(req, res, next) {
  next();
}

현재의 미들웨어함수가 요청-응답 주기를 끝내지 않는 경우에는 반드시 next() 메소드를 호출해서 다음 미들웨어가 실행이 되도록 해야합니다. 그렇치 않으면 해당 요청은 정지된 채로 있게 됩니다.

var express = require('express');
var app = express();

app.get('/', function(req, res, next) {
    next();
});

app.listen(3000);

미들웨어 사용법

미들웨어를 사용하는 방법은 다음과 같은 방법을 이용합니다.

앱 레벨 미들웨어

app.use() 또는 app.메소드() 함수를 이용합니다. 여기서 메소드get, post, put, delete등이 있습니다.

다음 함수는 앱이 요청을 수신할 때마다 콘솔에 시간을 출력하게 됩니다.

const app = express();

app.use(function (req, res, next) {
  console.log('Time:', Date());
  next();
});

클라이언트에게 응답하려면 res 객체에 출력하면 됩니다.

const express = require('express');

const app = express();

app.set('port', 3000);

app.use(function (req, res, next) {
  console.log('Time:', Date());
  res.writeHead('200', {'Content-Type': 'text/html; charset=utf-8'});
  res.end(`<h1>지금 시각은 ${Date()} 입니다.</h1>`);
});

app.listen(app.get('port'), () => console.log("웹서버가 %d 포트에서 실행되고 있습니다.", app.get('port')))

여러 미들웨어 사용

앱객체에 여러 개의 미들웨어를 등록하여 순서대로 일을 처리하게 합니다.

...... // 중간 생략

app.use((req, res, next) => {
  console.log('첫번째 미들웨어 함수');
  req.myname = "kildong";
  next();
})

app.use((req, res, next) => {
  console.log('두번째 미들웨어 함수');
  res.writeHead('200', {'Content-Type': 'text/html; charset=utf-8'});
  res.end(`<h1>익스프레스에서 ${req.myname}이 요청을 했습니다.</h1>`);
})

URL 질의 문자열 및 매개변수 이용

url의 예제는 다음과 같습니다.

http://example.com/user/search?name=gildong&age=16#section

여기서 http는 프로토콜, example.com 서버 주소, /user/search는 경로, ?name=gildong&age=16은 질의 문자열(query string), #section은 파편(fragment) 부분입니다.

질의 문자열(query string)

클라이언트 요청 경로 중 질의 문자열은 req.query.namereq.query.age를 이용해 접근할 수 있습니다.

app.get('/', )
매개변수 접근

다음은 /user/id 경로를 요청할 때마다 요청 메소드를 출력합니다.

app.use('/user/:id', function (req, res, next) {
  console.log('Request Type:', req.method);
  next();
});

라우팅(Routing)

라우팅(routing)은 URI 경로와 특정한 HTTP 메소드(get, post, put, delete 등)를 포함한 클라이언트 요청에 응답하는 방식을 결정하는 것을 말합니다. 라우팅은 앱객체의 메소드들(app.use(), app.get(), app.post() 등)을 이용하여 응답할 수도 있고 라우터 객체를 이용해서 응답할 수도 있습니다. 각 경로에 대한 라우팅하는 단위를 라우트(route)라고 합니다.

앱 수준 라우팅

앱객체의 메소드들 app.use(), app.get(), app.post() 등을 이용하여 라우팅하는 것을 앱 수준 라우팅이라합니다. app.use('경로', 콜백) 메소드를 사용하면 '경로'에 대한 모든 HTTP 요청을 콜백 함수가 처리합니다. 다음은 /user/:id 경로에 대한 모든 요청을 받아서 콘솔에 요청 메소드 타입을 출력합니다.

app.use('/user/:id', function (req, res, next) {
  console.log('Request Type:', req.method);
  next();
});

특정 메소드에 대한 라우팅을 처리하려면 다음과 같이 특정 메소드를 부릅니다. 다음은 클라이언트로부터 get 타입의 요청을 처리하는 라우팅이다. 클라이언트 웹브라우저에 USER를 출력합니다.

app.get('/user/:id', function (req, res, next) {
  res.send('USER');
});

다음과 같이 여러 개의 미들웨어를 순서대로 처리할 수도 있습니다. /user/:id 경로로 요청하는 모든 HTTP 요청을 첫번째 미들웨어가 처리하여 Request URL을 출력하고 다음에 있는 미들웨어 함수가 실행되어 Request Type이 콘솔에 출력됩니다.

app.use('/user/:id', function(req, res, next) {
console.log('Request URL:', req.originalUrl);
  next();
}, function (req, res, next) {
  console.log('Request Type:', req.method);
  next();
})

다음 예제와 같이 next('route')를 사용하여 다음 라우트로 강제적으로 이동시킬수 있습니다.

app.get('/user/:id', function (req, res, next) {
  // 사용자 id가 0이면 다음 라우트로 이동합니다.
  if (req.params.id == 0) next('route');
  // 그렇지 않으면 이어지는 미들웨어 함수로 이동합니다.
  else next();
}, function (req, res, next) {
  // regular 뷰페이지를 렌더합니다.
  res.render('regular');
});

// 앞 라우트에서 id가 0이면 실행됩니다.
app.get('/user/:id', function (req, res, next) {
  res.render('special');
});

Caution

어떤 경로가 클라이언트에서 요청이 와서 그 경로까지 가는데 app.use()가 사용하는 루트 경로(/) 라우트가 있다면 루트 라우트는 항상 실행됩니다. app.METHOD()들은 해당되지 않습니다.

app.use('/', function(req, res, next) {
  // 루트 경로에 대한 모든 요청 처리
  console.log("/에 대한 모든 요청 처리");
  next();
});

app.use('/app', function(req, res, next) {
  // /app 경로에 대한 모든 요청 처리
  console.log("/app에 대한 모든 요청 처리");
  next();
});

위 예제는 /app 경로에 대한 요청이 오더라도 / 경로에 대한 라우트가 실행이 되어 다음과 같은 출력이 나옵니다.

/ 대한 모든 요청 처리
/app에 대한 모든 요청 처리

라우터(router) 수준 라우팅

라우터 수준 라우팅이란 익스프레스 라우터 인스턴스를 사용하는 라우팅을 말한다. 익스프레스 라우터 인스턴스 express.Router()는 미니앱(mini-app)이라 불리며 미들웨어이다.

라우터를 사용하려면 express.Router() 인스턴스를 만들고 앱 수준 메소드 사용하는 것과 동일한 방식으로 사용한다. 그리고 끝으로 정의한 라우터를 앱에 등록 app.use('루트 경로', 라우터)해야만 사용할 수 있다.

const router = express.Router();

router.use('/', function(req, res, next) {
  // 모든 요청 처리
  console.log(`/에 대한 모든 요청 처리 ${++count}.`);
  next();
  // res.end('finish at /.');
});

router.use('/app', function(req, res, next) {
  // /app에 대한 모든 요청 처리
  console.log(`/app에 대한 모든 요청 처리 ${++count}.`);
  next();
});

// 라우터를 앱에 등록
app.use('/', router);

router.get(), router.post() 등에 대한 HTTP 요청 메소드에 대한 것을 담당하는 메소드들을 사용할 수 있다.

router.get('/', function(req, res, next) {
  console.log('/ 경로에 대한 get 요청이 들어왔습니다.');
  next();
})

router.post('/app', function(req, res, next) {
  console.log('/app 경로에 대한 post 요청이 들어왔습니다.');
  next();
})

router.route('경로') 메소드는 지정된 경로에 대한 하나의 라우트를 만든다. 이 라우트에 router.METHOD()를 붙여서 사용할 수 있다.

router.route('/login')
.all(function(req, res, next) {
  // 경로에 공통된 기능을 이 부분에 넣는다.
  next();
})
.get(function(req, res, next) {
  // get 메소드에 관련된 기능을 추가한다.
  res.end()
})
.post(function(req, res, next) {
  // post 메소드에 관련된 기능을 추가한다.
  res.end()
})
라우터 루트 경로 설정

만일 앱에서 라우터를 등록할 때 루트 경로를 /math로 한다면 라우터에서 사용하는 모든 경로는 /math가 경로 앞에 붙여서 작동하게 된다. 예를 들어보자.

router.use('/app', function(req, res, next) {
  // /app에 대한 모든 요청 처리
  console.log(`/app에 대한 모든 요청 처리 ${++count}.`);
  next();
});

app.use('/math', router);

위에서 '/app'/math/app과 대응하는 경로를 찾게 된다.

라우터 모듈화

라우터를 이용하여 라우팅 기능을 모듈화 할 수 있다. 다음과 같이 animal.js 모듈 파일을 만들고 다음을 입력한다.

const express = require('express');
const router = express.Router();

// animal 홈페이지 라우트
router.get('/', function(req, res) {
  res.send('동물 홈 페이지입니다.');
});

// 소개 페이지 라우트
router.get('/about', function(req, res) {
  res.send('이 페이지는 동물에 대한 설명입니다.');
});

module.exports = router;

app.js 에서 위 모듈 animal.js를 다음과 같이 사용할 수 있다.

const animal = require('./animal');

// 중간 생략

app.use('/animal', animal);

/animal/about 경로 요청을 animal.js 모듈의 about 라우트가 처리하게 된다.

미들웨어들

다음은 내장 미들웨어입니다.

static 미들웨어

static 미들웨어는 특정 디렉토리를 지정한 경로로 접근할 수 있게 만듭니다. 예를 들어 public 디렉토리 아래에 있는 모든 내용을 클라이언트가 루트 경로를 이용하여 접근하게 할 수 있습니다. 내장 static 미들웨어를 불러와서 다음과 같이 사용하면 됩니다.

const path = require('path');
cosnt express = require('express');

// 중간 생략

app.use(express.static(path.join(__dirname, 'public')), options);

클라이언트가 http://localhost:3000/으로 접속하게 되면 서버의 public 디렉토리로 접근하게 됩니다. public 디렉토리에 index.html이 존재하면 그것을 클라이언트에게 전송합니다. 만일 index.html이 존재하지 않으면 다음 미들웨어로 이동합니다. options를 이용해서 여러 가지 설정을 조정할 수 있습니다. 다음과 같이 index 옵션을 지정하여 기본적으로 찾는 파일을 설정할 수 있습니다. 처음에는 index.html이 있는지를 찾고 없으면 index.htm 파일을 찾습니다.

  const options = {
  dotfiles: 'ignore',
  etag: false,
  extensions: ['html', 'htm'],
  index: ['index.html', 'index.htm'],
  maxAge: '1d',
  redirect: false,
  setHeaders: function (res, path, stat) {
    res.set('x-timestamp', Date.now())
  }
};

app.use(express.static('public', options));

다음과 같이 myapp 프로젝트 디렉토리 아래에 public 폴더를 만들고 그 안에 index.html 파일을 만듭니다.

myapp
|   app.js
|
\---public
        index.html

app.js 파일은 다음과 같이 입력한다.

const express = require('express');
const path = require('path');

const app = express();
const port = 3000;

app.use(express.static('public', {
  index: ['index.html', 'index.htm']
}));

 app.get('/', (req, res) => res.send('안녕하세요.\n익스프레스에 오신걸 환영합니다.'));

 app.listen(port, () => console.log('%d 포트에서 기다립니다.', port));

index.html 파일 안에는 다음과 같이 입력한다.

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>serve-static 이용</title>
</head>
<body>
  <h1>Serve-static middleware 사용하는 방법.</h1>
</body>
</html>

http://localhost:3000으로 접속하면 index.html 내용이 출력되는 것을 볼 수 있다.

html 문서에 그림 파일을 넣기 위해서 images 디렉토리를 만들고 그 안에 house.png 파일을 넣는다. 또한 css 디렉토리를 만들고 style.css 파일을 만들어 스타일 설정을 하고 index.html을 수정하면 된다. 다음은 index.html 내용이다.

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>serve-static 이용</title>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <h1>Serve-static middleware 사용하는 방법.</h1>
  <img src="images/house.png" alt="집그림">
</body>
</html>

디렉토리 구조는 다음과 같다.

myapp
|   app.js
|
\---css
|       style.css
|
\---images
|       house.png
|
\---public
        index.html

다양한 외부 미들웨어(third-party)들을 이용할 수 있다.

body-parser 미들웨어

클라이언트가 post 방식으로 요청을 할 때 요청 매개변수와 값들을 알아낼 수 있는 body-parser 미들웨어를 사용해 보자. post 방식으로 요청을 하면 내용이 body 안에 포함되어 전달되게 된다. body-parser는 본문(body)을 파싱하여 매개변수와 값들을 req.body 에 넣는다. 클라이언트 측에서 post 방식으로 요청하는 일반적인 방법은 form 태그를 이용하는 것이다. 다음과 같이 login.html을 만들어보자.

const express = require('express');
const static = require('serve-static');
const bodyParser = require('body-parser');

const port = 3000;
const app = express();

// body-parser를 이용해 application/x-www-form-urlencoded 타입 파싱
app.use(bodyParser.urlencoded({extended: true}));

// body-parser를 이용해 application/json 타입 파싱
app.use(bodyParser.json());

app.use(static('public', {
  index: ['index.html', 'index.htm']
}));

app.use((req, res) => {
  console.log(req.body);

  const paramId = req.body.id;
  const paramPasswd = req.body.password;

  res.writeHead(200, {'Content-Type': 'text/html; utf-8'});
  res.write(`<div><p>ID: ${paramId}</p></div>`)
  res.write(`<div><p>Password: ${paramPasswd}</p></div>`)
  res.end();
});

app.listen(port, () => console.log('%d 포트에서 기다립니다.', port));

body-parser를 이용하기 위해서는 위에서와 같이 app.use(bodyParser.urlencoded({extended: true}));app.use(bodyParser.json())를 넣어 주면 된다. 그리고 localhost:3000/login.html로 접속하면 로그인 화면이 보이고 전송 버튼을 누르면 app.use에서 설정한 화면이 보이게 된다. app.use 설정은 모든 요청에 대해서 마지막으로 처리하는 부분이다.

에러 처리

404 에러는 라우팅 끝에 다음과 같이 넣는다.

app.use(function(req, res) {
  res.writeHead('200', {'Contenty-Type': 'text/html; charset=utf8'});
  res.status(404).send('Page not found.');
});

일반적인 오류를 처리하기 위해서는 모든 처리의 마지막에 다음과 같은 오류 처리 미들웨어를 넣는다.

app.use(function(err, req, res, next) {
  console.error(err.stack);
  res.status(500).send('Something broke!');
});

쿠기, 세션

클라이언트의 상태를 기록하기 위해 쿠키와 세션을 사용한다. 쿠키(cookie)는 클라이언트 웹 브라우저에 저장되는 정보이고 세션(session)은 서버에 저장되는 정보이다.

쿠키 처리하기

쿠키는 클라이언트 웹 브라우저에 저장되며 저장 기간을 설정할 수 있다. cookie-parser 모듈을 사용하여 요청(response) 객체에 원하는 쿠키를 설정하여 보낼 수 있다. cookie-parser 모듈이 없으면 npm install cookie-parser --save를 이용하여 설치합니다. 모듈을 사용하기 위해서 다른 미들웨어 사용법과 같이 app.use()를 사용합니다.

const express = require('express');
const cookieParser = require('cookie-parser');

const app = express();

// 쿠키 파서 미들웨어 사용
app.use(cookieParser());

app.get('/', function(req, res, next) {
  // 쿠키 설정
  res.cookie('customer', {
    name: 'Kildong',
    dept: 'Math'
  });

  console.log(req.cookies);
  res.send(req.cookies);
});

app.listen(3000, () => console.log('Starting at 3000 port'));

웹 브라우저를 두 번 새로 고침하면 쿠키 설정 내용이 보인다. 크롬 브라우저에서 개발자 도구(F12) > Application > Storage 항목에 Cookies 항목을 확인하면 설정 내용을 볼 수 있다. 여기서는 customer가 쿠키 이름이고 나머지 객체가 값이다.

세션 처리하기

express-session 모듈을 이용하여 세션 관리를 한다. 모듈이 설치되어 있지 않으면 npm install express-session --save를 실행한다.

다음과 같이 세션을 이용해서 페이지 조회수를 확인하는 코드를 작성해보자.

/**
 * express-session 이용
 */
const express = require('express');
const cookieParser = require('cookie-parser');
const expressSession = require('express-session');

const app = express();

app.use(cookieParser());
app.use(expressSession({
  secret: 'my key',
  cookie: {maxAge: 10000},
  resave: true,
  saveUninitialized: true
}));

app.get('/', function(req, res, next) {

  if(req.session.views) {
    req.session.views++;

    res.setHeader('Content-Type', 'text/html; charset=utf8');
    res.write('<p>조횟수: ' + req.session.views + '</p>');
    res.write('<p>만료:' + (req.session.cookie.maxAge / 1000) + '</p>');
    res.end();
  } else {
    req.session.views = 1;
    res.setHeader('Content-Type', 'text/html; charset=utf8');

    res.end('세션 데모(10초 후에 세션 만료됩니다). 새로 고침을 누르세요.');
  }

  console.log(req.cookies);
});

app.listen(3000, () => console.log('Starting at 3000 port'));

secret 항목은 세션 아이디 쿠키 서명하는데 필요하며 반드시 문자열로 설정해야 한다. cookie 항목은 여러 가지 옵션을 설정할 수 있으며 maxAge는 세션 만료 기간을 지정하는 것이다. 만료 기간이 지나면 세션 아이디는 브라우저에서 자동으로 삭제되서 다시 세션 아이디를 설정해야 한다.

로그인 했을 때 세션을 저장하여 로그아웃 하기전까지 로그인 상태를 유지하게 한다.

에러 핸들링 [1]

에러 핸들링이란 동기 또는 비동기적으로 발생하는 에러를 처리하는 것을 의미합니다. 익스프레스는 자체적으로 기본 에러 핸들링을 하므로 따로 작성할 필요는 없습니다.

에러 잡기

익스프레스는 라우트 핸들러와 미들웨어를 실행하는 동안 발생하는 모든 에러들을 잡습니다.

동기적 코드를 실행하면서 발생하는 에러를 잡기 위해 따로 작업을 할 필요는 없습니다. 동기화 코드 중에 에러가 발생하면 익스프레스가 잡아서 처리를 해줍니다. 예를 들면

app.get("/", function (req, res) {
  throw new Error("BROKEN"); // 익스프레스가 스스로 에러를 잡습니다.
});

비동기화 함수 안에서 에러가 발생하면 반드시 next() 함수에 에러를 인자로 건네주어야 합니다. 그래야만 익스프레스가 잡아 처리를 할 수 있게 됩니다.

app.get("/", function (req, res, next) {
  fs.readFile("/file-does-not-exist", function (err, data) {
    if (err) {
      next(err); // 익스프레스에게 에러를 건네줍니다.
    }
    else {
      res.send(data);
    }
  });
});

기본 에러 처리기

익스프레스는 앱에서 발생하는 어떤 에러도 처리할 수 있는 기본 내장된 에러 처리기를 제공합니다.

next()를 이용하여 에러를 건네주었는데 사용자 정의 에러 처리기를 작성하지 않았으면 익스프레스는 자동으로 내장 에러 처리기를 사용하여 에러를 처리합니다. 즉, 스택 추적이 붙은 에러를 클라이언트에게 건네줍니다. 스택 추적은 프로덕션 환경에서는 포함되지 않습니다.

에러 처리기 작성

에러 처리 미들웨어 함수 작성은 일반적인 미들웨어 함수와 같은 방식으로 정의할 수 있습니다. 단, 함수의 인자가 4개 (err, req, res, next)를 사용해야 합니다. 예를 들면:

app.use(function (err, req, res, next) {
  console.error(err.stack)
  res.status(500).send('Something broke!')
})

에러 처리 미들웨어는 마지막에 위치시킵니다.

var bodyParser = require('body-parser')
var methodOverride = require('method-override')

app.use(bodyParser.urlencoded({
  extended: true
}))
app.use(bodyParser.json())
app.use(methodOverride())
app.use(function (err, req, res, next) {
  // logic
})

여러 개의 에러 처리 미들웨어를 작성해도 됩니다.

var bodyParser = require('body-parser')
var methodOverride = require('method-override')

app.use(bodyParser.urlencoded({
  extended: true
}))
app.use(bodyParser.json())
app.use(methodOverride())
app.use(logErrors)
app.use(clientErrorHandler)
app.use(errorHandler)

여기서 logErrors 미들웨어는 다음과 같이 간단히 콘솔에 에러 메시지를 출력합니다.

function logErrors (err, req, res, next) {
  console.error(err.stack)
  next(err)
}

next(err)를 이용해서 다음 에러 처리 미들웨어로 넘기는 것을 볼 수 있습니다.

clientErrorHandler 미들웨어는 다음과 같이 작성할 수 있습니다.

function clientErrorHandler (err, req, res, next) {
  if (req.xhr) {
    res.status(500).send({ error: 'Something failed!' })
  } else {
    next(err)
  }
}

모든 에러를 처리할 수 있는 errorHandler 미들웨어는 다음과 같이 작성할 수 있습니다.

function errorHandler (err, req, res, next) {
  res.status(500)
  res.render('error', { error: err })
}

몽고 디비 연동

mongodb 모듈을 설치하고 다음과 같이 연결한다. url은 mongodb://사용자아이디:사용자암호@서버주소:접속포트/데이터베이스이름?authSource=데이터베이스인증디비와 같이 사용한다. authSource는 데이터베이스 사용자를 인증하는 데이터베이스를 말한다.

const mongoClient = require('mongodb').MongoClient;

const database = {};
const dbUrl = 'mongodb://dbadmin:password@localhost:27017/?authSource=admin';;
const dbName = 'work';

function connectDB() {

  mongoClient.connect(dbUrl, function(err, client) {
    if (err) {
      throw err;
    }
    console.log('데이터베이스에 연결됨.' + dbUrl);

    database.client = client;
    database.db = client.db(dbName);
  });
}

app.listen(port, () => {
  console.log('%d 포트에서 기다립니다.', port);

  // 디비 연결 실행
  connectDB();
});

그리고 서버가 종료될 때 디비도 자동으로 종료되게 다음과 같은 것을 넣는다.

const gracefulExit = function() {
  if (database) {
    database.client.close();
    console.log('몽고 DB :' + dbUrl + ' 연결 해제.');
    process.exit(0);
  }
}

// 노드 종료시 몽고 디비 닫기.
process.on('SIGINT', gracefulExit).on('SIGTERM', gracefulExit);

SIGINT는 터미널 인터럽트 신호로 ctrl + c를 눌러 종료할 때 발생하는 이벤트이다. SIGTERM은 프로세스 종료 신호가 올 때 처리한다.

디비 설정 변수 저장

코드에 디비 암호와 사용자 이름을 저장하는 것은 암호를 유추할 수 있어서 보안상 좋지 않다. 다음과 같이 파일로 따로 저장하는 것이 좀더 안전할 것이다. 물론 명령어창에서 실행될 때 입력 변수로 물어볼 수도 있다.

dotenv 모듈.env 파일을 이용해서 사용하는 방법을 알아본다. 우선 dotenv 모듈을 설치한다. dotenv 모듈은 .env 파일을 process.env 환경변수에 대응시킨다.

npm install dotenv

그리고 app.js 파일 맨 윗쪽에 다음과 같이 불러온다.

const path = require('path');
require('dotenv').config({path: path.join(__dirname, '../상대적경로/.env')});

만일 .env 파일이 app.js 파일과 다른 경로에 위치해 있으면 위와 같이 {path: '상대적경로/.env'}를 적어 준다. 그리고 .env 파일 안에 다음과 같이 필요한 정보들을 담는다.

DB_HOST=db_서버주소
DB_USER=db_user_id
DB_PASS=db_password

그리고 필요한 곳에서 다음과 같이 사용한다.

const dbUrl = 'mongodb://' + process.env.DB_USER + ':' + process.env.DB_PASS + '@' + process.env.DB_HOST + ':27017/work?authSource=admin';;

mongoose.connect(dbUrl);

디비에 삽입

public 디렉토리에 adduser.html 파일을 만들어 다음과 같이 입력한다.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>회원가입</title>
</head>
<body>
  <h1>회원 가입</h1>
  <form action="/adduser" method="post">
    <table>
      <tr>
        <td><label for="">아이디</label></td>
        <td><input type="text" name="id"></td>
      </tr>
      <tr>
        <td><label for="">비밀번호</label></td>
        <td><input type="password" name="password"></td>
      </tr>
      <tr>
        <td><label for="">이름</label></td>
        <td><input type="text" name="name"></td>
      </tr>
    </table>
    <input type="submit" value="전송" name="">
  </form>
</body>
</html>

사용자 추가 화면을 위해 /adduser 경로로 get, post 라우티을 다음과 같이 입력한다.

router.route('/adduser')
  .get(function (req, res) {
    // 사용자 추가 화면 출력
    res.sendFile(path.join(__dirname, './public/adduser.html'));
  })
  .post(function (req, res) {
    const database = req.app.get('database');
    const paramId = req.body.id;
    const paramPass = req.body.password;
    const paramName = req.body.name;

    const users = database.db.collection('users');

    users.insertMany([{ "id": paramId, "password": paramPass, "name": paramName }], function (err, result) {
      if (err) {
        throw err;
      }

      if (result.insertedCount > 0) {
        console.log("사용자 추가됨");
      } else {
        console.log("추가 실패.");
      }
    });
    res.send("" + paramId + paramPass);
  });

몽구스(mongoose) 데이터베이스 관리

몽구스는 관계형 데이터베이스와 같이 스키마를 이용해서 관리할 수 있도록 만든 모듈이다. npm install mongoose --save를 설치한다. 몽구스를 이용해 디비를 연결한다.

// 중간 생략

const mongoose = require('mongoose');

let database;
let dbUrl;
let UserSchema, UserModel;

function connectDB() {
  dbUrl = 'mongodb://dbadmin:password@localhost:27017/work?authSource=admin';

  mongoose.connect(dbUrl);

  database = mongoose.connection;

  database.on('error', console.error.bind(console, "mongoose connection error"));
  database.on('open', function() {
    console.log("데이터베이스에 연결되었습니다.");

    UserSchema = mongoose.Schema({
      id: String,
      name: String,
      password: String
    });

    UserModel = mongoose.model("users", UserSchema);
  });

  database.on('disconnected', function() {
    console.log("연결이 끊어졌습니다. 5초후 다시 연결합니다.");
    setInterval(connectDB, 5000);
  });
}

// 중간 생략

app.listen(port, () => {
  console.log('%d 포트에서 기다립니다.', port);

  connectDB();
});

open 이벤트는 데이터베이스가 연결되었을 때 발생하고 error 이벤트는 연결이 되지 않았을 때 발생한다. disconnected 이벤트는 연결이 끊어졌을 때 발생하여 5초 후에 다시 연결을 시도한다. 연결이 되면 스키마와 모델을 만든다. 스키마는 테이블의 스키마와 같은 것이고 모델은 스키마와 연동하여 사용하는 것이다.

모델

모델을 이용하여 사용자 인증을 살펴 보자. 모델은 데이터베이스에서 자료를 읽고, 생성하고, 수정하고, 삭제하는 역할을 하는 인터페이스이다. 몽고스 디비를 연결하지 않고도 모델을 사용할 수 있다. 이렇게하면 데이터베이스 연결이 될 때까지 에러를 내지 않고 기다리게 된다. 이것을 연산 버퍼링이라고 한다. 이것은 bufferCommands 옵션을 지정하여 변경할 수 있다.

UserModel.find({"id": id, "password": password}, function(err, results) {
  if(err) {
    throw err;
  }

  if(results.length > 0) {
    console.log("일치하는 사용자 찾음", id, password);
  } else {
    console.log("일치하는 사용자 없음.");
  }
});

listusers 라우트는 다음과 같이 할 수 있다. 여기서 res.render('listusers', {results: users})listusers.pug 폴더가 views 디렉토리에 있어야 하며 listusers.pug에는 results 변수가 있어야 한다.

router.route('/listusers')
 .get(function (req, res) {
   const database = req.app.get('database');
   const User = database.User;
   User.find(function (err, users) {
     if (err) {
       throw err;
     }
     // console.dir(users);
     res.render('listusers', {results:users});
   });
 });

프로덕션 환경 [2]

프로덕션(production)이라는 용어는 소프트웨어 라이프사이클 중 애플리케이션 또는 API가 최종 사용자 또는 소비자에게 정식으로 제공되는 단계를 말합니다. 이와 반대로 개발(development) 단계에서는 아직 코드가 활발하게 작성 및 테스트되며 애플리케이션은 외부 액세스에 개방되지 않습니다. 이에 대응하는 시스템 환경은 각각 프로덕션 환경 및 개발 환경이라고 부릅니다.

개발 환경 및 프로덕션 환경은 일반적으로 서로 다르게 설정되며 두 환경의 요구사항은 크게 다릅니다. 개발 환경에서는 좋은 것일지라도 프로덕션 환경에서는 허용되지 않을 수도 있습니다. 예를 들면, 개발 환경에서는 디버깅을 위해 상세한 오류 로깅이 선호될 수 있지만, 이러한 행동은 프로덕션 환경에서 보안 우려사항이 될 수 있습니다. 그리고 개발 환경에서는 확장성, 신뢰성 및 성능에 대해 걱정할 필요가 없지만, 이러한 요인들은 프로덕션 환경에서 매우 중요해집니다.

이 문서에서는 프로덕션 환경에 배치된 Express 애플리케이션을 위한 몇 가지 보안 우수 사례에 대해 논의합니다.

더 이상 사용되지 않거나 취약성이 있는 버전의 Express를 사용하지 마십시오

Express 2.x 및 3.x에 대한 유지보수는 더 이상 이루어지지 않습니다. 이러한 버전에서의 보안 및 성능 문제는 수정되지 않습니다. 이러한 버전은 사용하지 마십시오! 아직 버전 4로 이전하지 않은 경우에는 마이그레이션 안내서를 따르십시오.

또한 보안 업데이트 페이지의 목록에 포함된 취약성 있는 Express 버전을 사용하고 있지 않은지 확인하십시오. 취약성 있는 Express 버전을 사용하고 있는 경우에는 안정적인 릴리스 중 하나로 업데이트해야 하며, 가능하면 최신 버전으로 업데이트하는 것이 좋습니다.

TLS 사용

앱이 민감한 데이터를 다루거나 전송하는 경우에는 전송 계층 보안(TLS)을 사용하여 연결 및 데이터를 보호하십시오. 이 기술은 데이터를 클라이언트로부터 서버로 전송하기 전에 데이터를 암호화하며, 따라서 몇 가지 일반적인(그리고 쉬운) 해킹을 방지합니다. Ajax 및 POST 요청은 브라우저에서 분명하게 보이지 않고 “숨겨진” 것처럼 보일 수도 있지만, 이러한 요청의 트래픽은 패킷 가로채기 및 중간자 공격에 취약합니다.

여러분은 SSL(Secure Socket Layer) 암호화에 익숙할 것입니다. TLS는 단순히 SSL이 다음 단계로 발전된 형태입니다. 즉, 이전에 SSL을 사용했다면 TLS로의 업그레이드를 고려해야 합니다. 일반적으로 TLS의 처리를 위해서는 Nginx를 권장합니다. Nginx(및 기타 서버)에서 TLS를 구성하는 방법에 대한 좋은 참조 자료를 확인하려면 Recommended Server Configurations(Mozilla Wiki)를 참조하십시오.

또한 무료 TLS 인증서를 얻기 위한 편리한 도구 중 하나는 Internet Security Research Group(ISRG)이 제공하는 무료의 자동 개방형 인증 기관(CA)인 Let’s Encrypt입니다.

Helmet 사용

Helmet을 이용하면 HTTP 헤더를 적절히 설정하여 몇 가지 잘 알려진 웹 취약성으로부터 앱을 보호할 수 있습니다.

사실 Helmet은 보안 관련 HTTP 헤더를 설정하는 다음과 같은 더 작은 크기의 미들웨어 함수 9개의 모음입니다.

  • csp는 Content-Security-Policy 헤더를 설정하여 XSS(Cross-site scripting) 공격 및 기타 교차 * 사이트 인젝션을 예방합니다.
  • hidePoweredBy는 X-Powered-By 헤더를 제거합니다.
  • hpkp는 Public Key Pinning 헤더를 추가하여, 위조된 인증서를 이용한 중간자 공격을 방지합니다.
  • hsts는 서버에 대한 안전한(SSL/TLS를 통한 HTTP) 연결을 적용하는 Strict-Transport-Security * 헤더를 설정합니다.
  • ieNoOpen은 IE8 이상에 대해 X-Download-Options를 설정합니다.
  • noCache는 Cache-Control 및 Pragma 헤더를 설정하여 클라이언트 측에서 캐싱을 사용하지 않도록 * 합니다.
  • noSniff는 X-Content-Type-Options 를 설정하여, 선언된 콘텐츠 유형으로부터 벗어난 응답에 대한 * 브라우저의 MIME 가로채기를 방지합니다.
  • frameguard는 X-Frame-Options 헤더를 설정하여 clickjacking에 대한 보호를 제공합니다.
  • xssFilter는 X-XSS-Protection을 설정하여 대부분의 최신 웹 브라우저에서 XSS(Cross-site scripting) 필터를 사용하도록 합니다.

다른 모든 모듈처럼 Helmet은 다음과 같이 설치할 수 있습니다.

$ npm install --save helmet

이후 코드에서 Helmet을 사용하는 방법은 다음과 같습니다.

...
var helmet = require('helmet');
app.use(helmet());
...
적어도 X-Powered-By 헤더는 사용하지 않도록 설정

Helmet의 사용을 원치 않는 경우에는 적어도 X-Powered-By 헤더를 사용하지 마십시오. 공격자는 이 헤더(기본적으로 사용하도록 설정되어 있음)를 이용해 Express를 실행하는 앱을 발견한 후 특정한 대상에 대한 공격을 실행할 수 있습니다.

따라서 우수 사례는 다음과 같이 app.disable() 메소드를 이용해 이 헤더를 끄는 것입니다.

app.disable('x-powered-by');

helmet.js를 사용하는 경우에는 사용자를 대신하여 helmet.js가 위의 작업을 실행합니다.

쿠키를 안전하게 사용

쿠키로 인해 앱이 악용에 노출되지 않도록 하기 위해 기본 세션 쿠키 이름을 사용하지 말고 쿠키 보안 옵션을 적절히 설정하십시오.

두 개의 기본 미들웨어 쿠키 세션 모듈은 다음과 같습니다.

  • express-session(Express 3.x에서 기본 제공되는 express.session 미들웨어 대체).
  • cookie-session(Express 3.x에서 기본 제공되는 express.cookieSession 미들웨어 대체).

이 두 모듈의 주요 차이점은 쿠키 세션 데이터를 저장하는 방식입니다. express-session 미들웨어는 세션 데이터를 서버에 저장하며, 쿠키 자체에는 세션 데이터가 아니라 세션 ID만 저장됩니다. 기본적으로 express-session은 인메모리 스토리지를 이용하며, 프로덕션 환경용으로 설계되지 않았습니다. 프로덕션 환경에서는 확장 가능한 session-store를 설정해야 합니다. 호환 가능한 세션 스토어의 목록을 참조하십시오.

이와 반대로 cookie-session 미들웨어는 쿠키 기반의 스토리지를 구현하며, 하나의 세션 키가 아니라 세션 전체를 쿠키에 직렬화합니다. cookie-session은 세션 데이터의 크기가 상대적으로 작으며 (오브젝트가 아닌) 원시 값으로 쉽게 인코딩 가능할 때에만 사용하십시오. 브라우저는 하나의 쿠키당 4,096바이트 이상을 지원하도록 되어 있지만, 한계를 초과하지 않도록 보장하려면 하나의 도메인당 4,093바이트의 크기를 초과하지 마십시오. 또한, 클라이언트에서 쿠키 데이터를 볼 수 있으므로, 쿠키 데이터를 안전하게 또는 모호하게 유지해야 할 이유가 있는 경우에는 express-session을 선택하는 것이 더 나을 수 있습니다.

기본 세션 쿠키 이름을 사용하지 않음

기본 세션 쿠키 이름을 사용하면 앱을 공격에 노출시킬 수 있습니다. 이로 인해 제기되는 보안 문제는 X-Powered-By와 유사하며, 잠재적인 공격자는 이를 이용해 서버의 지문을 채취한 후 이에 따라 공격 대상을 설정할 수 있습니다.

이러한 문제점을 피하려면 일반적인 쿠키 이름을 사용하십시오. 예를 들면 express-session 미들웨어를 이용해 다음과 같이 하십시오.

var session = require('express-session');
app.set('trust proxy', 1) // trust first proxy
app.use( session({
   secret : 's3Cur3',
   name : 'sessionId',
  })
);
쿠키 보안 옵션 설정

다음과 같은 쿠키 옵션을 설정하여 보안을 강화하십시오.

  • secure - 브라우저가 HTTPS를 통해서만 쿠키를 전송하도록 합니다.
  • httpOnly - 쿠키가 클라이언트 JavaScript가 아닌 HTTP(S)를 통해서만 전송되도록 하며, 이를 통해 * XSS(Cross-site scripting) 공격으로부터 보호할 수 있습니다.
  • domain - 쿠키의 도메인을 표시합니다. URL이 요청되고 있는 서버의 도메인에 대해 비교할 때 * 사용하십시오. 두 도메인이 일치하는 경우에는 그 다음으로 경로 속성을 확인하십시오.
  • path - 쿠키의 경로를 표시합니다. 요청 경로에 대해 비교할 때 사용하십시오. 이 경로와 도메인이 * 일치하는 경우에는 요청되고 있는 쿠키를 전송하십시오.
  • expires - 지속적 쿠키에 대한 만기 날짜를 설정하는 데 사용됩니다.

다음에는 cookie-session 미들웨어를 사용한 예가 표시되어 있습니다.

var session = require('cookie-session');
var express = require('express');
var app = express();

var expiryDate = new Date( Date.now() + 60 * 60 * 1000 ); // 1 hour
app.use(session({
  name: 'session',
  keys: ['key1', 'key2'],
  cookie: { secure: true,
            httpOnly: true,
            domain: 'example.com',
            path: 'foo/bar',
            expires: expiryDate
          }
  })
);

종속 항목이 안전한지 확인하십시오

npm을 이용해 애플리케이션의 종속 항목을 관리하는 것은 강력하면서도 편리합니다. 그러나 사용 중인 패키지에는 애플리케이션에 영향을 미칠 수 있는 치명적인 보안 취약성이 포함되어 있을 수도 있습니다. 앱의 보안성은 종속 항목 내의 “가장 약한 링크”의 보안성에 따라 결정됩니다.

npm 버전 6부터는 모든 설치 요구에 대해 검토를 자동적으로 합니다. npm audit를 이용해서 종속 트리를 분석할 수도 있습니다.

$ npm audit

더 안전한 것을 원하시면 Synk <https://snyk.io/>를 고려해보십시오. Synk는 Synk open source vulnerability database로부터 앱 종속성이 안전한지를 확인합니다. 설치는 다음과 같습니다.

$ npm install -g snyk
$ cd your-app

앱이 안전한지 테스트는 명령행에서 다음과 같이 입력합니다.

$ snyk test

패치와 업데이트에 관한 사항은 다음과 같은 명령어를 사용합니다.

$ snyk wizard

추가적인 고려사항

유용한 Node.js Security Checklist에서 발췌한 몇 가지 추가적인 권장사항은 다음과 같습니다. 아래의 권장사항에 대한 모든 상세 정보를 확인하려면 해당 블로그 게시물을 참조하십시오.

  • 속도 제한(rate-limiting)을 구현하여 인증에 대한 무차별 대입 공격을 방지하십시오. 이를 실행하는 * 한 가지 방법은 StrongLoop API Gateway를 이용하여 속도 제한 정책을 적용하는 것입니다. 대안적으로, express-limiter와 같은 미들웨어를 사용할 수 있지만, 그러한 경우에는 코드를 어느 정도 수정해야 합니다.
  • csurf 미들웨어를 이용하여 교차 사이트 요청 위조(CSRF)로부터 보호하십시오.
  • 항상 사용자 입력을 필터링하고 사용자 입력에서 민감한 데이터를 제거하여 XSS(Cross-site scripting) 및 명령 인젝션 공격으로부터 보호하십시오.
  • 매개변수화된 조회 또는 준비된 명령문을 이용하여 SQL 인젝션 공격으로부터 방어하십시오.
  • 오픈 소스 방식의 sqlmap 도구를 이용하여 앱 내의 SQL 인젝션 취약성을 발견하십시오.
  • nmap 및 sslyze 도구를 이용하여 SSL 암호, 키 및 재협상의 구성, 그리고 인증서의 유효성을 테스트하십시오.
  • safe-regex를 이용하여 정규식이 정규식 서비스 거부 공격을 쉽게 받지 않도록 하십시오.

그 외의 알려져 있는 취약성을 피하십시오

Express에, 또는 앱에 사용되는 다른 모듈에 영향을 미칠 수 있는 Node Security Project의 보안 권고문에 항상 주의를 기울이십시오. 일반적으로 Node Security Project는 Node의 보안과 관련된 지식 및 도구에 대한 훌륭한 자원입니다.

마지막으로, 다른 모든 웹 앱과 마찬가지로 Express 앱은 다양한 웹 기반 공격에 취약할 수 있습니다. 알려져 있는 웹 취약성을 숙지한 후 이러한 취약성을 피하기 위한 예방 조치를 취하십시오.

참조 사이트

[1]익스프레스에서 에러 핸들링 https://expressjs.com/en/guide/error-handling.html
[2]프로덕션 환경에서의 보안 http://expressjs.com/ko/advanced/best-practice-security.html

템플릿

웹서버에서 제공하는 페이지들을 미리 정해진 형식으로 파일을 만들어 그 안에 필요한 데이터만 바꿔 페이지를 제공할 수 있도록 하는 페이지를 템플릿(template)이라고 한다.

node에서 많이 사용되는 템플릿 엔진으로는 ejs와 퍼그(pug)가 있다. 퍼그는 이전에 jade라고 불렸다.

뷰 템플릿 사용

여기서는 퍼그 템플릿 엔진을 사용한다. app.js 파일 안에 뷰 엔진을 다음과 같이 설정한다.

app.set("views", path.join(__dirname, 'views'));
app.set("view engine", "pug");

views 디렉토리에 listusers.pug 파일을 만들고 다음과 같이 입력한다.

extends layout

block content
  h1 사용자 리스트
  div
    ul
      - for (let i=0; i<results.length; i++) {
      -   let curId = results[i]._doc.id;
      -   let curName = results[i]._doc.name;
      li #{i} - 아이디 : #{curId}, 이름 : #{curName}
      - }
  br
  br
  a(href = '/listusers') 다시 요청하기

pug

HTML 문서와 자바스크립트를 함께 사용하는 템플릿이다.

참고: 요약

기초문법

태그를 나타내기 위해서 들여쓰기를 사용하고 CSS 문법을 그대로 이용한다.

자바스크립트 사용

자바스크립트를 사용하기 위해서는 -를 붙이고 사용하면 된다.

믹스인

퍼그에서 함수를 만들어 사용하는 것같이 사용할 수 있다.

예제

layout.pug

doctype html
html
  head
    meta(charset = 'utf8')
    title 레이아웃 페이지
  body
    block content
    include ./footer.pug

listusers.pug

extends layout

block content
  h1 사용자 리스트
  div
    ul
      - for (let i=0; i<results.length; i++) {
      -   let curId = results[i]._doc.id;
      -   let curName = results[i]._doc.name;
      li #{i} - 아이디 : #{curId}, 이름 : #{curName}
      - }
  br
  br
  a(href = '/listusers') 다시 요청하기

footer.pug

div#footer
a(href = '/login') 로그인 화면으로

Semantic UI 사용

jquery를 설치한다.

npm install jquery --save

node_modules/jquery/dist 디렉토리에 있는 jquery-min.js 파일을 public 디렉토리로 복사한다.

시맨틱 UI 설치하기위해 gulp를 전역으로 설치한다.

npm install -g gulp

시맨틱 UI를 설치한다. 화살표 키를 사용하여 몇가지 질문에 대답한다.

npm install semantic-ui --save

첫번째 질문에는 자동 설치를 선택하고 다음은 yes를 선택해서 설치한다.

설치된 디렉토리 semantic으로 이동하여 gulp build 명령을 입력한다.

cd semantic
gulp build

설치가 끝나면 dist 디렉토리에 있는 semantic.min.css 파일과 semantic.min.js 파일을 public 디렉토리로 복사한다.

다음과 같이 login.html을 작성해보자.

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>로그인</title>
  <link rel="stylesheet" href="./semantic.min.css">
  <script src="./jquery.min.js"></script>
  <script src="./semantic.min.js"></script>
  <style>
    * {
      padding: 0;
      margin: 0;
      box-sizing: border-box;
    }

    html {
      width: 100%;
      height: 100%;
    }

    body {
      width: 100%;
      height: 100%;
      color: #000;
      background-color: #fff;
    }

    .container {
      width: 100%;
      height: 100%;
      display: flex;
      flex-flow: column wrap;
      align-items: center;
      justify-content: center;
    }

    #cardbox {
      width: 70%;
    }

    #iconImage {
      display: inline;
    }

    #titleText {
      font-size: 1.4em;
      font-weight: bold;
      color: #777;
    }

    #contentsText {
      color: #999;
    }

    .contentsText {
      color: #999;
    }

    #form1 {
      padding: 1em;
    }

    .row {
      height: 3em;
    }

    .col1 {
      width: 5em;
    }

    .inputbox {
      width: 20em;
    }

    #buttonContainer {
      padding-top: 0.6em;
      text-align: right;
    }
  </style>
</head>

<body>
  <div class="container">
    <div id="cardbox" class="ui blue fluid card">
      <div class="content">
        <div class="left floated author">
          <img id="iconImage" class="ui avatar image" src="./images/author.png" alt="">
        </div>
        <div>
          <div id="titleText" class="header">
            로그인
          </div>
          <div id="contentsText" class="description">
            아이디와 비밀번호를 입력하고 로그인하세요.
          </div>
        </div>
      </div>
      <form id="form1" method="post" action="/login">
        <table>
          <tr class="row">
            <td class="col1">
              <label class="contentsText">아이디</label>
            </td>
            <td class="col2" colspan="2">
              <div class="ui input">
                <input type="text" class="inputbox" name="id">
              </div>
            </td>
            <td></td>
          </tr>
          <tr class="row">
            <td class="col1">
              <label class="contentsText">비밀번호</label>
            </td>
            <td class="col2" colspan="2">
              <div class="ui input">
                <input type="text" class="inputbox" name="password">
              </div>
            </td>
            <td></td>
          </tr>
          <tr valign="baseline">
            <td></td>
            <td>
              <div class="ui toggle checkbox">
                <input type="checkbox" name="saveOption">
                <label for="">아이디 저장</label>
              </div>
            </td>
            <td id="buttonContainer" align="right">
              <input type="submit" id="submitButton" class="ui primary button" value="로그인">
            </td>
          </tr>
        </table>
      </form>

    </div>
  </div>
</body>

</html>