Node.js

네트워크 애플리케이션을 위한 자바스크립트 런타임 환경

Node.js Basics

introduction

  • Node.js는 Chrome V8 자바스크립트 엔진으로 빌드된 자바스크립트 런타임 환경(Runtime Environment)으로 주로 서버 사이드 애플리케이션 개발에 사용되는 소프트웨어 플랫폼이다.
  • Node.js는 브라우저 외부 환경에서 자바스크립트 애플리케이션 개발에 사용되며 이에 필요한 모듈, 파일 시스템, HTTP 등 Built-in API를 제공한다.
  • Node.js는 자바스크립트를 사용해 개발한다. Front-end와 Back-end에서 자바스크립트를 사용할 수 있다는 동형성(isomorphic)은 별도의 언어 학습 시간을 단축해 주는 장점이 있다.
  • Node.js는 Non-blocking I/O와 단일 스레드 이벤트 루프를 통한 높은 Request 처리 성능을 가지고 있다.
    • 데이터베이스로부터 대량의 데이터를 취득하여 웹페이지에 표시할 때, 일반적으로 데이터베이스 처리에 대기시간(blocking)이 발생하기 때문에 웹페이지 표시가 지연되는 현상이 발생한다. Node.js의 모든 API는 비동기 방식으로 동작하여 Non-blocking I/O가 가능하고 단일 스레드 이벤트 루프 모델을 사용하여 보다 가벼운 환경에서도 높은 Request 처리 성능을 가지고 있다.
    • Node.js는 데이터를 실시간 처리하여 빈번한 I/O가 발생하는 SPA(Single Page Application)에 적합하다. 하지만 CPU 사용률이 높은 애플리케이션에는 권장하지 않는다.
    • Node.js에는 Socket.io라는 실시간 통신을 실현하는 라이브러리를 사용할 수 있어서 대량의 데이터 처리와 실시간 통신을 구현할 수 기능을 모두 갖추고 있다.

install

Node.js의 웹사이트

npm도 동시에 설치된다.

$ node -v
$ npm -v
$ npm install -g npm@latest
$ npm -v

REPL

REPL(Read Eval Print Loop: 입력 수행 출력 반복)은 Node.js는 물론 대부분의 언어(Java, Python 등)가 제공하는 가상환경으로 간단한 코드를 직접 실행해 결과를 확인해 볼 수 있다.

$ node
> 1 * 0
0
> x = 10
10
> console.log('Hello World')
Hello World
undefined

HTTP Server

Node.js를 사용하여 간단한 HTTP 서버를 작성해 보자. Node.js는 http 서버 모듈을 내장하고 있어서 아파치와 같은 별도의 웹서버를 설치할 필요가 없다. 아래와 같은 내용으로 app.js 파일을 생성한다.

/* app.js */
const http = require('http'); // 1

http.createServer((request, response) => { // 2
  response.statusCode = 200;
  response.setHeader('Content-Type', 'text/plain');
  response.end('Hello World');
}).listen(3000); // 3

console.log('Server running at http://127.0.0.1:3000/');
  1. http 모듈을 로딩하여 변수 http에 할당하였다.
  2. http 모듈의 createServer([requestListener]) 메소드를 사용하여 HTTP 서버 객체를 생성한다. HTTP 서버 객체는 EventEmitter 클래스를 상속한 것으로 request 이벤트가 발생하면 HTTP request를 처리하여 response를 반환하는 request Listener 함수를 호출한다. 이 request Listener 함수는 request와 response 객체를 전달받으며 HTTP request 이벤트가 발생할 때마다 한번씩 호출된다.
  3. createServer 메소드가 반환한 HTTP 서버 객체의 listen 메소드에 포트번호 3000을 전달하여 서버를 실행한다.
  4. app.js 파일이 있는 경로에서 다음 명령으로 서버를 실행한다.
$ node app.js
  1. 위 명령을 실행하고 브라우저에서 “http://localhost:3000” 에 접속하면 “Hello World”가 출력되는 것을 확인할 수 있다.

모듈화와 CommonJS

브라우저에서의 모듈 사용은 대부분의 브라우저가 ES6의 모듈을 지원하지 않고 있으므로 Browserify 또는 webpack과 같은 모듈 번들러를 사용하여야 한다.

Node.js & npm

npm(node package manager)은 자바스크립트 패키지 매니저이다. Node.js에서 사용할 수 있는 모듈들을 패키지화하여 모아둔 저장소 역할과 패키지 설치 및 관리를 위한 CLI(Command line interface)를 제공한다. 자신이 작성한 패키지를 공개할 수도 있고 필요한 패키지를 검색하여 재사용할 수도 있다.

패키지 설치

Node.js에서 사용할 수 있는 모듈인 패키지를 설치할 때에는 npm install 명령어 뒤에 설치할 패키지 이름을 지정한다.

$ npm install <package>

Node.js 환경에서 emoji를 지원하는 node-emoji를 설치해 보자.

$ npm install node-emoji

지역 설치 전역 설치

옵션을 별도로 지정하지 않으면 지역으로 설치되며, 프로젝트 루트 디렉터리에 node_modules 디렉터리가 자동 생성되고 그 안에 패키지가 설치된다. 지역으로 설치된 패키지는 해당 프로젝트 내에서만 사용할 수 있다.

# 전역 설치
$ npm install -g <package>

전역에 설치된 패키지는 OS에 따라 설치 장소가 다르다.

  • macOS의 경우 /usr/local/lib/node_modules
  • 윈도우의 경우 c:\Users%USERNAME%\AppData\Roaming\npm\node_modules

node 명령어로 Node.js REPL을 실행하고 node-emoji를 로드한 후 emoji를 출력해 보자.

$ node
> var emoji = require('node-emoji').emoji;
undefined
> console.log(emoji.heart);
❤️
undefined

package.json 과 의존성 관리

Node.js 프로젝트에서는 많은 패키지를 사용하게 되고 패키지의 버전도 빈번하게 업데이트되므로 프로젝트가 의존하고 있는 패키지를 일괄 관리할 필요가 있다. npm은 package.json 파일을 통해서 프로젝트 정보와 패키지의 의존성(dependency)을 관리한다. 이미 작성된 package.json이 있다면 팀 내에 배포하여 동일한 개발 환경을 빠르게 구축할 수 있는 장점이 있다. package.json은 Java의 maven에서 pom.xml과 비슷한 역할을 한다. package.json을 생성하려면 프로젝트 루트에서 npm init 명령어를 실행한다. npm init 명령어에 --yes 또는 -y 옵션을 추가한다. 그러면 기본 설정값으로 package.json 파일을 생성한다.

npm init -y
/* package.json */
{
  "name": "emoji",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "dependencies": {
    "node-emoji": "^1.8.1"
  },
  "devDependencies": {},
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

package.json에서 가장 중요한 항목은 nameversion이다. 이것으로 패키지의 고유성을 판단하므로 생략할 수 없다. 그리고 dependencies 항목에는 해당 프로젝트가 의존하는 패키지들의 이름과 버전을 명시한다. 여기서 의존하는 패키지란 해당 프로젝트에서 참조하는 모듈을 의미한다. 프로젝트를 진행할 때는 이미 만들어진 여러 패키지를 참조해서 사용하는데, package.json 파일의 dependencies 항목에 해당 패키지의 이름과 버전을 명시함으로써 의존성을 설정한다. npm install 명령어에 --save 옵션을 사용하면 패키지 설치와 함께 package.json의 dependencies에 설치된 패키지 이름과 버전이 기록된다.

npm install 명령어에 --save 옵션을 사용하면 패키지 설치와 함께 package.json의 dependencies에 설치된 패키지 이름과 버전이 기록된다.

$ npm install node-emoji --save

npm@5부터 --save는 기본 옵션이 되었다. --save 옵션을 사용하지 않더라도 모든 install 명령은 package.json의 dependencies에 설치된 패키지와 버전을 기록한다. 기존의 --save-dev은 변경되지 않았다. devDependencies에는 개발 시에만 사용하는 개발용 의존 패키지를 명시한다. 예를 들어 TypeScript와 같은 트랜스파일러는 개발 단계에서만 필요하고 배포할 필요는 없으므로 devDependencies에 포함시킨다. npm install 명령어에 --save-dev(축약형 -D) 옵션을 사용하면 패키지 설치와 함께 package.json의 devDependencies에 설치된 패키지와 버전이 기록된다.

$ npm install <package> --save-dev

npm install 명령어를 사용하면 package.json에 명시된 모든 의존 패키지를 한번에 설치할 수 있다.

npm install

package.json

Semantic versioning(유의적 버전)

npm install 명령어의 패키명 뒤에 @버전을 추가하면 패키지 버전을 지정하여 설치할 수 있다.

$ npm install node-emoji@1.5.0
...
  "dependencies": {
    "node-emoji": "^1.5.1"
  },
...

이때 package.json의 dependencies에 새롭게 추가한 패키지가 추가되고 버전 앞에 ^(캐럿)이 추가된 것을 확인할 수 있다. 이것은 패키지 버전을 지정하였을 때 뿐만이 아니라 --save-exact 옵션을 지정하지 않으면 기본적으로 추가되는 것이다. 이 ^(캐럿)은 이후 해당 패키지의 버전이 업데이트되었을 경우, 마이너 버전 범위 내에서 업데이트를 허용한다는 의미이다. npm install 명령어에 --save-exact 옵션을 지정하면 설치된 버전을 범위 지정없이 기록한다. npm은 semantic versioning(유의적 버전)을 지원한다. 버전 정보는 메이저 버전 번호, 마이너 버전 번호, 패치 버전 번호로 구성된다.

자주 사용하는 npm 명령어

  • package.json 생성
    $ npm init
    # 기본 설정
    $ npm init -y
    
  • 패키지 설치
    # 로컬 설치
    $ npm install <package-name>
    # 전역 설치
    $ npm install -g <package-name>
    # 개발 설치
    $ npm install --save-dev <package-name>
    # package.json의 모든 패키지 설치
    $ npm install
    
  • 패키지 제거
    # 로컬/개발 패키지 제거
    $ npm uninstall <package-name>
    # 전역 패키지 제거
    $ npm uninstall -g <package-name>
    
  • 패키지 업데이트
    $ npm update <package-name>
    
  • 전역 설치 패키지 확인
    $ npm ls -g --depth=0
    
  • package.json scripts 프로퍼티의 start 실행
    $ npm start
    
  • package.json scripts 프로퍼티의 start 이외의 scripts 실행
    $ npm run <script-name>
    
  • 전역 패키지 설치 폴더 확인
    $ npm root -g
    /usr/local/lib/node_modules
    # 파인더 오픈
    $ open /usr/local/lib/node_modules
    
  • 패티지 정보 참조
    $ npm view <package-name>
    # 함께 설치해야 하는 다른 패키지 확인
    $ npm view <package-name> peerDependencies
    
  • 버전 확인
    $ npm -v
    
  • npm 명령어 설명 참조
    $ npm help <command>
    

npm CLI 명령어

Node.js module

  • 브라우저 상에서 동작하는 JavaScript는 script tag로 로드하며 복수의 JavaScript 파일을 로드할 경우 하나의 파일로 merge되며 동일한 유효범위를 갖게 된다.
  • Node.js는 module 단위로 각 기능을 분할할 수 있다. module은 파일과 1대1의 대응 관계를 가지며 하나의 모듈은 자신만의 독립적인 실행 영역(Scope)를 가지게 된다. 따라서 클라이언트 사이드 JavaScript와는 달리 전역변수의 중복 문제가 발생하지 않는다.
  • 모듈은 module.exports 또는 exports 객체를 통해 정의하고 외부로 공개한다. 그리고 공개된 모듈은 require 함수를 사용하여 임포트한다.

exports

// circle.js
const { PI } = Math;
exports.area = (r) => PI * r * r;
exports.circumference = (r) => 2 * PI * r;
// app.js
const circle = require('./circle.js'); // == require('./circle')
console.log(`지름이 4인 원의 면적: ${circle.area(4)}`);
console.log(`지름이 4인 원의 둘레: ${circle.circumference(4)}`);
$ node app
지름이 4인 원의 면적: 50.26548245743669
지름이 4인 원의 둘레: 25.132741228718345

module.exports

exports 객체는 프로퍼티 또는 메소드를 여러 개 정의할 수 있었다. 하지만 module.exports에는 하나의 값(원시 타입, 함수, 객체)을 할당할 수 있다.

// circle.js
const { PI } = Math;
module.exports = function (r) {
  return {
    area() { return PI * r * r; },
    circumference() { return 2 * PI * r}
  };
}
// app.js
const circle = require('./circle');
const myCircle = circle(4);
console.log(`지름이 4인 원의 면적: ${myCircle.area()}`);
console.log(`지름이 4인 원의 둘레: ${myCircle.circumference()}`);
// primitive.js
const pv = 'primitive value';
module.exports = pv;
// app.js
const value = require('./primitive');
console.log(value); // => 'primitive value'
구분 모듈 정의 방식 require 함수의 호출 결과
exports exports 객체에는 값을 할당할 수 없고 공개할 대상을 exports 객체에 프로퍼티 또는 메소드로 추가한다. exports 객체에 추가한 프로퍼티와 메소드가 담긴 객체가 전달된다.
module.exports module.exports 객체에 하나의 값(원시 타입, 함수, 객체)만을 할당한다. module.exports 객체에 할당한 값이 전달된다.

코어 모듈과 파일 모듈

Node.js는 기본으로 포함하고 있는 모듈이 있다. 이를 코어 모듈이라 한다. 코어 모듈을 로딩할 때에는 패스를 명시하지 않아도 무방하다.

const http = require('http');

npm을 통해 설치한 외부 패키지 또한 패스를 명시하지 않아도 무방하다.

const mongoose = require('mongoose');

코어 모듈과 외부 패키지 이외는 모두 파일 모듈이다. 파일 모듈을 로딩할 때에는 패스를 명시하여야 한다.

const foo = require('./lib/foo');

Node.js file upload example

Node.js는 Server-side Javascript이다. 즉, 백엔드에서 자바스크립트를 실행할 수 있게 한다. 또한 유용한 모듈을 내부에 탑재하고 있고 필요에 따라 npm 등을 사용하여 외부에서 필요한 모듈을 import하여 사용할 수 있어 높은 생산성과 스피디한 개발을 가능케 해준다. 따라서 Node.js는 Server-side Javascript와 모듈(라이브러리)로 구성되어 있고 말할 수 있다.

file upload 예제를 통해서 Node.js의 기본과 Routing, 모듈화, Request Handling 등을 알아보자.

Use cases

  • 사용자는 웹 브라우저로 이 웹 애플리케이션을 이용할 수 있다.
  • 사용자가 http://domain/start 를 요청하면 파일 업로드 폼이 들어있는 웰컴 페이지를 볼 수 있어야 한다.
  • 업로드할 이미지 파일을 선택해서 폼으로 전송하면, 해당 이미지는 http://domain/upload 로 업로드 되어야 하며, 업로드가 끝나면 해당 페이지에 표시된다.

Application Stack

  • 웹페이지를 제공해야 한다. 따라서 HTTP 서버가 필요하다.
  • 서버는 URL 요청(request)별로 다르게 응답해야 한다. 따라서, 요청과 요청을 처리할 핸들러들을 연결짓기 위한 라우터(router) 같은 것이 필요하다.
  • 서버로 도착한 요청들, 그리고 라우터를 이용해서 라우팅된 요청들을 만족시키기 위해서 실제적인 요청 핸들러(request handler)가 필요하다.
  • 라우터는 들어오는 어떠한 POST 데이터들도 다룰 수 있어야 한다. 그리고 해당 데이터를 다루기 편한 형태로 만들어 요청 핸들러(request handler) 들에게 넘겨야 한다. 따라서 요청 데이터 핸들링(request data handling)이 필요하다.
  • URL에 대한 요청을 다루는 것뿐 아니라 URL이 요청되었을 때 내용을 표시할 필요도 있다. 즉, 요청 핸들러(request handler)는 사용자 브라우저로 컨텐트를 보내기 위한한 뷰 로직(view logic)이 필요하다.
  • 사용자가 이미지들을 업로드 할 수 있어야 하므로 세부 사항을 다루는 업로드 핸들링(upload handling)이 필요하다.
response의 이동
sever ⇒ router ⇒ request handler
/* index.js */
var server = require("./server");
var router = require("./router");
var requestHandlers = require("./requestHandlers");

var handle = {}
handle["/"] = requestHandlers.start;
handle["/start"] = requestHandlers.start;
handle["/upload"] = requestHandlers.upload;

server.start(router.route, handle);
/* server.js */
var http = require('http');
var url = require("url");

function start(route, handle) {
    function onRequest(request, response) {
        var postData = "";
        var pathname = url.parse(request.url).pathname;
        console.log("Request for " + pathname + " received.");
    
        request.setEncoding("utf8");
    
        request.addListener("data", function(postDataChunk) {
            postData += postDataChunk;
            console.log("Received POST data chunk '"+
            postDataChunk + "'.");
        });

        request.addListener("end", function() {
            route(handle, pathname, response, postData);
        });

    }
    http.createServer(onRequest).listen(8888); 
    console.log("Server has started. try localhost:8000/user?name=kim")
}

exports.start = start;
/* router.js */
function route(handle, pathname, response, postData) {
    console.log("About to route a request for " + pathname);
    if (typeof handle[pathname] === 'function') {
        handle[pathname](response, postData);
    } else {
        console.log("No request handler found for " + pathname);
        response.writeHead(404, {"Content-Type": "text/plain"});
        response.write("404 Not found");
        response.end();
    }
}

exports.route = route;
/* requestHandlers.js */
function start(response, postData) {
    console.log("^^ Request handler 'start' was called.");
  
    var body = '<html>'+
      '<head>'+
      '<meta http-equiv="Content-Type" content="text/html; '+
      'charset=UTF-8" />'+
      '</head>'+
      '<body>'+
      '<form action="/upload" method="post">'+
      '<textarea name="text" rows="20" cols="60"></textarea>'+
      '<input type="submit" value="Submit text" />'+
      '</form>'+
      '</body>'+
      '</html>';
  
    response.writeHead(200, {"Content-Type": "text/html"});
    response.write(body);
    response.end();
}

function upload(response, postData) {
    console.log("Request handler 'upload' was called.");
    response.writeHead(200, {"Content-Type": "text/plain"});
    response.write("You've sent: " + postData);
    response.end();
}

exports.start = start;
exports.upload = upload;
node index.js

url모듈을 사용하여 URL path를 기준으로 요청을 구분할 수 있게 되었다. 이것을 이용하면 URL path를 기반으로 요청을 request handler로 매핑하는 router를 만들 수 있다. router의 역할은 클라이언트의 요청과 request handler를 매핑하는 것이다.

예를 들어, /start 요청과 /upload 요청에 각각 달리 반응하는 request handler를 매핑할 수 있다. 우선은 URL path를 전달받는 router를 구현한다.

http://localhost:8888/start 를 요청하면 “Hello Start”가 출력되고, http://localhost:8888/upload 는 “Hello Upload”가, http://localhost:8888/foo 는 “404 Not found”가 출력된다.

기존의 웹 서버는 대부분 쓰레드를 기반으로 하는 동기 방식으로 I/O를 처리를 한다. 반면에 Node.js는 이벤트를 기반으로 하는 비동기 방식으로 I/O를 처리한다. Node.js의 방식을 이벤트 기반 비동기 방식 이라 한다.

Handling file uploads

사용자가 이미지 파일을 업로드 하면 업로드된 이미지를 브라우저에 출력해보자. 파일 데이터를 받아서 처리하는 것은 단지 POST 데이터를 처리하는 것이지만, 그 처리가 단순하지 않고 복잡하기 때문에, 여기서는 미리 만들어진 formidable 오픈소스 모듈을 사용한다.

formidable은 HTTP POST로 submit 된 “form”을 Node.js에서 파싱할 수 있게 (“parseable”) 한다. 사용법은 다음과 같다.

  1. 새 IncomingForm을 생성한다. 이것은 submit된 form의 추상화 객체이다.
  2. request 객체를 파싱하여 submit된 파일과 필드들을 얻는다.
$ npm init
$ npm install formidable --save
/* package.json */
{
  "name": "test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "dependencies": {
    "formidable": "^1.2.1"
  },
  "scripts": {
    "start": "node index.js"
  },
  "author": "",
  "license": "ISC",
}

formidable을 사용하여 할 일은 2가지다.

  1. 업로드된 파일을 저장 (/tmp 폴더)
  2. 업로드된 파일을 읽어 들여 화면에 출력

/tmp/test.png에 파일이 존재한다고 가정하고 /show request handler에서 이것을 처리한다고 하자.

/* index.js */
var server = require("./server");
var router = require("./router");
var requestHandlers = require("./requestHandlers");

var handle = {}
handle["/"] = requestHandlers.start;
handle["/start"] = requestHandlers.start;
handle["/upload"] = requestHandlers.upload;
handle["/show"] = requestHandlers.show;

server.start(router.route, handle);
/* server.js */
var http = require("http");
var url = require("url");

function start(route, handle) {
  function onRequest(request, response) {
    var pathname = url.parse(request.url).pathname;
    console.log("Request for " + pathname + " received.");
    route(handle, pathname, response, request);
  }

  http.createServer(onRequest).listen(8888);
  console.log("Server has started.");
}

exports.start = start;
/* router.js */
function route(handle, pathname, response, request) {
    console.log("About to route a request for " + pathname);
    if (typeof handle[pathname] === 'function') {
      handle[pathname](response, request);
    } else {
      console.log("No request handler found for " + pathname);
      response.writeHead(404, {"Content-Type": "text/html"});
      response.write("404 Not found");
      response.end();
    }
}

exports.route = route;
/* requestHandlers.js */
var fs = require("fs"),
    formidable = require("formidable");

function start(response) {
  console.log("Request handler 'start' was called.");

  var body = '<html>'+
    '<head>'+
    '<meta http-equiv="Content-Type" content="text/html; '+
    'charset=UTF-8" />'+
    '</head>'+
    '<body>'+
    '<form action="/upload" enctype="multipart/form-data" '+
    'method="post">'+
    '<input type="file" name="upload" multiple="multiple">'+
    '<input type="submit" value="Upload file" />'+
    '</form>'+
    '</body>'+
    '</html>';

    response.writeHead(200, {"Content-Type": "text/html"});
    response.write(body);
    response.end();
}

function upload(response, request) {
  console.log("Request handler 'upload' was called.");

  var form = new formidable.IncomingForm();
  console.log("about to parse");
  form.parse(request, function(error, fields, files) {
    console.log("parsing done");
    fs.renameSync(files.upload.path, "tmp/test.png");
    response.writeHead(200, {"Content-Type": "text/html"});
    response.write("received image:<br/>");
    response.write("<img src='/show' />");
    response.end();
  });
}

function show(response) {
  console.log("Request handler 'show' was called.");
  fs.readFile("tmp/test.png", "binary", function(error, file) {
    if(error) {
      response.writeHead(500, {"Content-Type": "text/plain"});
      response.write(error + "\n");
      response.end();
    } else {
      response.writeHead(200, {"Content-Type": "image/png"});
      response.write(file, "binary");
      response.end();
    }
  });
}

exports.start = start;
exports.upload = upload;
exports.show = show;
node index.js

http://localhost:8888/show 에서 확인

참고자료