최용수 인스웨이브 연구개발본부 팀장

▲ 최용수 인스웨이브 연구개발본부 팀장
[컴퓨터월드] 지난 호에서는 웹 애플리케이션에 사용할 각 솔루션들의 특징과 장단점을 살펴보고 설치해 보았다. 이번 호에서는 샘플 프로젝트(manage_user)를 통해 Sever-Side를 구현해 보도록 하겠다. 먼저, 예제 진행을 위해서는 Node.js 및 MongoDB가 설치되어 있어야 한다.[1]

1. 프로젝트 생성

package.json 파일이 포함된 프로젝트를 체크아웃 하거나 다운로드 받아서 단순히 `npm install`를 실행하는 것만으로 필요한 모듈들을 간단히 설치하는 것이 일반적이나, 이해를 돕기 위해 처음부터 설명하도록 하겠다.

프로젝트명(manage_user)으로 디렉토리를 생성하고 해당 디렉토리로 이동한 후, `npm init`을 이용하여 package.json 파일을 생성한다.

```
$ npm init
```

`npm init`을 실행하면 interactive mode로 필요한 정보를 입력 받는다. name, version 등을 적절히 입력하면 된다. `(값)` 형태로 기본값이 제공되는 항목은 입력하지 않고 엔터를 치면 기본값이 입력된다. name의 기본값은 폴더명(manage_user)이 사용된다.

 

입력을 다 마치고 마지막에 yes를 입력하면 manage_user(프로젝트 Base) 폴더 하위에 `package.json` 파일이 생성된다.

2. 종속 모듈 설치

샘플 구현을 위해서 Express.js, MongoDB, Underscore.js[2] 가 필요하다. 이들 모듈을 설치하도록 하겠다. Node.js의 모듈은 npm을 이용해서 관리하며 `install` 명령을 통해 설치한다. 아래는 `express`를 설치하는 명령의 예이다. 설치 시 `--save` 파라미터를 전달하면 설치와 동시에 위에 생성한 `package.json` 파일에 dependency 설정이 추가된다.

```
$ npm install express --save
```

동일한 방식으로 MongoDB Client와 Underscore.js를 설치한다.

```
$ npm install mongodb --save
$ npm install underscore --save
```

아래는 3개의 모듈이 모두 설치된 후 `package.json`의 모습이다. 설치된 모듈의 정보가 `dependencies` 항목에 추가된 것을 확인할 수 있다. 

 

아래 그림은 여기까지 수행한 후의 디렉토리 구조이다. 모듈들은 프로젝트 Base 폴더의 node_modules 디렉토리 하위에 자동으로 설치된다.

 

 3. 서버 기동

모듈 설치가 끝났으니 Express.js를 이용해서 서버를 띄우도록 하겠다. 먼저 프로젝트 Base 폴더에 임의의 파일(server.js)[3] 을 생성해서 아래와 같이 입력한다. Node.js는 CommonJS를 기반으로 모듈을 로딩한다.

[server.js]
```
// express 모듈을 로드한다.
var express = require('express'),
 // express 객체를 생성한다.
    app = express();
// 3000번 port에서 클라이언트 요청을 받기 위해 대기한다.
app.listen(3000);
console.log('Listening on port 3000...');
```

이제 준비는 다 되었다. 서버를 기동시켜보도록 하자. 방금 생성한 자바스크립트 파일을 노드로 실행시킨다.

```
$ node server.js
```

'Listening on port 3000...'가 출력되고 프롬프트가 껌벅거린다면 성공이다. Ctrl + C로 종료시킬 수 있다.

 

4. 요청에 대한 응답

서버가 정상적으로 기동되었다면 클라이언트 요청에 응답할 수 있도록 해보자. Express.js는 HTTP 메소드[4] 별로 라우팅이 가능하며 이에 대응하는 메소드를 제공한다. 아래 예제에서는 get() 함수를 이용해서 모든 `GET` 요청이 두 번째 파라미터로 주어진 Request Handler에 HTTP Request와 Response와 함께 전달되도록 한다.

[server.js]
```
app.get( '/**', function( req, res ) {
  // host[:port]만 입력된 경우 index.html을 전송한다.
  if ( req.url === '/' ) {
    // __dirname 는 Node.js 에서 실행되는 파일(server.js)의 경로를 갖고 있는 변수이다.
    res.sendFile( __dirname + '/index.html' );
  } else {
    // 하위 path에 해당하는 파일을 전송한다.
    res.sendFile( __dirname + req.path );
  }
});
```

서버를 재기동(Ctrl + C, node server.js)하고 접근해 보자. 아래는 포트까지만 입력한 예이다.

 

Base 폴더 하위의 test/dummy.html에 접근한 예이다.

 

 
5. Node.js에서 모듈 로드하기

샘플을 좀 더 작성하기 전에 Node.js가 모듈을 로드하는 방식을 잠깐 살펴보도록 하겠다. Node.js는 파일과 모듈이 1대1로 대응된다. 이는 sampleModule.js가 `require('sampleModule.js')`를 통해 외부 모듈로 로딩될 수 있다는 의미이다. 

`require`의 파라미터에 확장자가 생략되면 '.js', '.json', '.node'를 붙여서 검색한다. 이는 sampleModule.js를 로딩할 때 `require('sampleModule')`로 간략하게 사용할 수 있다는 의미이다.
`/`로 시작하는 모듈은 절대 경로로 인식한다. `./`로 시작하는 모듈은 상대 경로로 인식한다. 

프로젝트 Base 디렉토리에 있는 serer.js가 `[프로젝트 Base 경로]/routes/users.js`를 `require('./routes/users')`와 같이 로딩할 수 있다.
모듈이 `/`나 `./`로 시작하지 않고 해당 모듈이 핵심 모듈(http등)[5]이 아니면 node_modules 디렉토리에서 모듈을 로딩하려고 시도한다. 모듈을 제공하는 입장에서는 일반적으로 `exports` 변수를 통해 노출시킨다. `exports`는 `module.exports`의 참조로 간단히 축약형이라고 생각해도 무방하다. 모듈에 선언된 지역 변수들은 모두 private으로 처리되어 로딩한 애플리케이션에서 직접 접근할 수 없다. 다시 말해 `exports`로 노출시킨 것들만 접근이 가능하다.


6. 모듈 작성

server.js에서 클라이언트 요청에 대해서 모두 처리하고 응답할 수도 있겠지만, 애플리케이션 확장성과 유연성을 확보하기 위해서 라우팅을 제외한 로직은 별도의 모듈로 작성[6]하고, 추가한 모듈을 로딩해서 요청을 처리해 보도록 하겠다. 

애플리케이션이 확장될 수 있으므로 프로젝트 Base 디렉토리에 `routes`라는 폴더를 만들고 이 안에 요청을 처리할 `users.js` 파일을 생성해서 클라이언트 요청을 처리하고 응답할 수 있도록 하겠다.

[users.js]
```
// module.exports에 function을 선언한다.
// require로 모듈을 로딩하면 이 module.exports가 반환된다.
module.exports = function () {
  // 외부에 제공할 객체를 생성한다.
  var handler = {};

  // 생성한 객체에 Request Handler를 선언한다.
  handler.select = function ( req, res ) {
    // HTTP Response에 응답 결과를 write 한다.
    // json 함수는 JavaScript나 Node.js 객체를 JSON String으로 serialize해서 클라이언트로 반환한다.
    res.json( { result: 'success' } );
  }
  // 이 모듈을 로딩한 곳에서 선언된 Request Handler를 이용할 수 있도록 객체를 반환한다.
  return handler;
};
```

server.js에서는 위에서 추가한 모듈을 로딩해서 클라이언트 요청을 Handler에게 전달한다. 라우팅은 선언된 순서대로 실행되므로 기존에 작성한 `app.get`보다 위에서 라우팅 해주어야 한다.


[server.js]
```
var express = require('express'),
    app = express(),
    // 위에서 작성한 모듈을 로딩한다.
    // require로 로딩해서 module.exports에 선언한 함수를 반환 받고 바로 실행함으로써 그 안에 handler 객체를 참조로 갖는다.
    users = require('./routes/users')();

// GET 방식으로 요청된 것 중 첫 번째 파라미터로 주어진 Path와 일치하는 요청을 users 객체의 select 함수로 라우팅한다.
app.get( '/users', users.select );

app.get('/**', function(req, res) {
  생략...
});
생략...
```

브라우저에서 `http://127.0.0.1:3000/users`로 요청하면 Handler에서 반환한 JSON을 받을 수 있다.

 

 7. 데이터 준비

위에서는 users.js에서 직접 JSON Object를 반환했는데 MongoDB에서 데이터를 읽어 반환해 보도록 하겠다. 이를 위해서는 MongoDB에 Database와 Collection(일종의 Table)을 생성하고 샘플 데이터[7]를 넣어 두어야 한다. 

Command Line(터미널이나 명령 창)에서 `mongod`를 입력하여 MongoDB를 기동시킨다. 지난 호에서 설명한 것처럼 MongoDB가 설치된 경로를 환경변수에 추가했다면 정상 실행될 것이다.

새로운 Command Line을 열고 `mongo`를 입력하여 MongoDB에 접속한다. 접속하면 기본적으로 `test` Database를 사용한다. 원하는 Database를 `use` 명령을 이용해서 생성한다. 주어진 이름의 Database가 이미 존재한다면 그 Database로 전환(switch)된다. 이후 `db` 객체로 해당 Database를 제어할 수 있다.

```
> use peopleDB
```

Database의 `createCollection` 메소드로 'people'이라는 Collection을 생성한다.

```
> db.createCollection('people');
```

`show collections`로 방금 생성한 Collection을 확인할 수 있다.

```
> show collections
```

`db.Collection명.insert` 메소드로 데이터를 추가한다.


```
> db.people.insert( { first_name: "James", last_name: "Butt", company_name: "Benton, John B Jr", address: "6649 N Blue Gum St", city: "New Orleans", county: "Orleans", state: "LA", zip: "70116", phone1: "504-621-8927", phone2: "504-845-1427", email: "jbutt@gmail.com", web: "http://www.bentonjohnbjr.com" } )
```

JSON Array를 이용해서 대량의 데이터를 한 번에 입력할 수도 있다.

```
> db.people.insert( [ { first_name: "James" ... }, { first_name: "Art" ... }, { first_name: "Lenna" ... } ] )
``

아래는 데이터 입력 후 `find` 메소드(RDBMS에서 'select')로 입력된 데이터를 확인한 모습이다. 입력하지 않은 `_id` 필드도 보이는데 이는 간단히 `Primary Key`로 이해하면 된다. 입력 시 주어지지 않으면 자동 생성된다. 값으로 사용된 `ObjectId`는 12-byte `BSON` 타입이다. 

 

8. MongoDB로부터 데이터 반환

users.js를 다음과 같이 수정하여 MongoDB로부터 데이터를 읽어 JSON Array를 클라이언트에 반환해 보자.

```
// MongoDB 접속에 필요한 Client와 DB Server Connection을 로딩한다.
var MongoClient = require('mongodb').MongoClient,
    Server      = require('mongodb').Server,
    // local DB에 기본 port로 Connection을 셋업한다.
    mongoClient = new MongoClient( new Server( 'localhost', 27017 ) ),
    db;

// Connection을 연결한다.
mongoClient.open( function( err, mongoClient ) {
  // peopleDB 데이터베이스를 얻어와서 'db' 변수에 할당한다.
  db = mongoClient.db("peopleDB");
}); 
module.exports = function () {
  var handler = {};
  handler.select = function ( req, res ) {
    // peopleDB로부터 people Collection을 Fetch 한다.
    // 실행된 결과는 두 번째 파라미터인 callback function으로 전달된다.
    db.collection( 'people', function ( err, collection ) {
      // find로 커서를 가져 온 후 'first_name' 필드를 기준으로 오름차순 정렬한다.
      // 이 때 필드 값이 -1 이면 내림차순으로 정렬한다.
      // 필드를 여러 개 설정하여 다중 정렬을 할 수도 있다.
      // toArray를 이용해서 결과를 Array로 변환한 후 callback에서 클라이언트로 전송한다.
      // 이 모든 것이 chaining으로 실행된다.
      collection.find().sort( { first_name: 1 } ).toArray( function ( err, items ) {
        res.json( items );
      });
    });
  }

  return handler;
};
```

서버 재기동 후 브라우저에서 확인한 모습은 다음과 같다.

 

9. 데이터 수정 및 추가 라우팅

'Read'에 성공했다면 'Update'를 시도해 보자. HTTP 'GET' 요청을 처리한 것처럼 'PUT' 요청에 대응하는 Express.js 함수를 이용해서 라우팅한다. 추가 구현에 앞서 잠시 라우팅에 대해서 좀 더 살펴보자. 하나의 HTTP 메소드를 url path에 따라 다수의 라우팅으로 정의할 수 있다.

```
app.get( '/users',   users.select );
app.get( '/product', product.select );
```

모든 라우팅에 실패할 경우 Error가 발생한다. app.use() 등을 이용해서 마지막에 에러 처리가 필요하다. 이밖에 Request Handler에서 Error Handling, next()를 이용한 실행 flow switch, 4.x 버전에서 Router 객체가 추가되면서 소개된 chain, all() 등이 유용하나 지면상 설명은 생략한다.[8]
각 함수의 첫 번째 파라미터인 url path에 ':_id'처럼 `:` 을 이용해 파라미터를 표시할 수 있다. 예를 들어 '/users/:_id'로 정의했을 때 클라이언트에서 'http://localhost:3000/users/anonymous'로 요청하면 Request Handler에서 req.params Object를 통해 파라미터를 참조할 수 있다. 여기서 req.params._id의 값은 'anonymous'이다.
Express.js 4.x 버전에서 변경된 것 중 하나는 기존에 번들로 제공하던 JSON Parser를 포함한 Middleware[9] 들을 대부분 별도로 설치하게 되었다. Express에는 Core만 남겨서 모듈 간의 유연성을 확보하려는 방안으로 추측된다. 따라서 JSON을 파싱하기 위해서는 추가로 `body-parser` 모듈을 설치해야 한다. 설치 방식은 다른 모듈들과 동일하다.

```
npm install body-parser --save
```

아래는 설치 후 package.json의 모습이다.

```
{
  생략...

  "dependencies": {
    "body-parser": "^1.9.0",
    "express": "^4.9.7",
    "mongodb": "^1.4.19",
    "underscore": "^1.7.0"
  }
}
```

다시 server.js로 돌아와서 추가로 설치한 body-parser를 설정하고 PUT 요청에 대한 라우팅을 추가한다.

[server.js]
```
var express = require('express'),
    app = express(),
    // 추가한 body-parser 모듈을 로딩한다.
    bodyParser = require('body-parser'),
    users = require('./routes/users')();

// use 메소드로 bodyParser.json을 Mount 한다.
// 클라이언트에서 JSON 포맷으로 요청한 Request Body가 파싱하는데 사용된다.
app.use(bodyParser.json());    

// 클라이언트에서 'PUT'으로 요청을 보내고 해당 경로가 '/users/임의값'과 일치하면 users.js의 update 함수를 호출한다. // 첫 번째 파라미터에 정규식을 사용해서 좀 더 복잡한 패턴을 처리할 수 있다.
app.put( '/users/:_id', users.update );```
server.js에서 invoked될 Handler를 users.js에 추가한다.


[users.js]
```
var MongoClient = require('mongodb').MongoClient,
    Server      = require('mongodb').Server,
    // ObjectID 와 Underscore.js 모듈을 로딩한다.
    ObjectID    = require('mongodb').ObjectID,
    _           = require('underscore'),
    생략...
  handler.update = function ( req, res ) {
    // req.params 객체에서 라우팅 설정에 의해 자동 매핑된 '_id' 값을 얻어온다.
    var _id = req.params._id;
    console.log( 'update[' + _id + ']' );
    db.collection( 'people', function ( err, collection ) {
      // req.body는 bodyParser에 의해 JSON으로 파싱된 Request Body이다.
      // omit은 Underscore.js의 메소드로 두 번째 파라미터로 주어진 필드를 제거한 객체를 반환한다.
      req.body = _.omit( req.body, '_id' );
      // req.params으로 받은 '_id'를 이용해서 업데이트한다.
      // 첫 번째 파라미터는 RDBMS에서 where 절에 해당한다.
      // 두 번째 파라미터에 $set 연산자를 이용해서 업데이트할 필드들을 제공한다.
      // 세 번째 파라미터인 result callback 함수는 두 개의 파라미터를 받는다.
      // callback 첫 번째 파라미터는 실행 중 에러가 발생하면 그에 대한 정보를 담고 있는 객체이다.
      // callback 두 번째 파라미터는 실행에 성공한 result 객체이다.
      // 두 파라미터는 상호 배타적이다.
      collection.update( { _id: ObjectID(_id) }, { $set: req.body }, function ( err, result ) {
        if (err) {
          res.json( { result: 'fail',
                      message: err.message } );
        } else {
          res.json( { result: 'success',
                      updatedCount: result } );
        }
      });
    });
  };
```

아래는 수정 전 터미널에서 조회한 'first name'이 'James'인 Document(Record)이다. 

 

아직 클라이언트는 준비되지 않았으므로 간단하게 크롬 브라우저의 앱으로 제공되는 Restful Client 중 하나를 이용해서 테스트를 해보도록 하겠다. 앱들 간에 큰 차이는 없으며 이중 'Postman'을 이용했다. 아래는 크롬 앱 스토어에 소개된 'Postman'이다. 설치가 단순하기 때문에 설명은 생략한다. 

 

서버로 요청을 해보자. 위에 요청할 URL을 입력한다. 'James'라는 'first name'이 중복되지는 않지만 Primary Key에 해당하는 `_id`를 이용해서 업데이트 한다. 라우팅 정의 중 `:_id`에 해당 하는 부분에 터미널에서 조회한 ObjectID를 입력한다.

URL 옆의 셀렉트 박스에서 PUT 메소드를 선택한다. 아래 'Content-Type' Header를 'application/json'으로 추가해 준다. 이렇게 해야 'body-parser'에서 정상적으로 Request Body를 파싱할 수 있다. JSON을 입력하고 'Send' 버튼을 누르면 서버로 전송되어 맨 아래에 응답을 받을 수 있다. 예제에서는 'phone2'를 임의의 값으로 수정해 보았다.

 

아래는 수정한 결과이다.

 


10. 데이터 생성과 삭제

데이터 생성과 삭제는 위에 설명한 조회 수정과 유사하다.

[server.js]
```
// 'POST', 'DELETE' HTTP 메소드도 'GET', PUT' 과 동일한 방식으로 라우팅한다.
app.post(   '/users',      users.insert );
app.delete( '/users/:_id', users.remove );
```

'POST', 'DELETE' 요청을 처리할 Request Handler를 users.js에 각각 구현한다.

[users.js]
```
  handler.insert = function ( req, res ) {
    // 요청된 Request Body를 JSON.stringify로 확인할 수 있다.
    console.log( 'insert[' + JSON.stringify(req.body) + ']' );
    // Underscore.js의 isEmpty 메소드는 객체 안에 프라퍼티가 존재하는지 체크한다.
    // Request Body가 비어 있으면 클라이언트에 Error JSON 메시지를 반환한다.
    if ( _.isEmpty(req.body) ) {
      res.json( { result: 'fail',
                  message: 'body is empty' } );
    } else {
      db.collection( 'people', function ( err, collection ) {
        collection.insert( req.body, function ( err, result ) {
          if (err) {
            res.json( { result: 'fail' } );
          } else {
            // 요청이 JSON Array로 다건을 입력한 경우이다.
            if ( _.isArray( req.body ) ) {
              res.json( result );
            } else {
              res.json( result[0] );
            }
          }
        });
      });
    }
  };

  handler.remove = function ( req, res ) {
    var _id = req.params._id;
    console.log( 'remove[' + _id + ']' );
    // undefined 등의 validation을 한다.
    // validation은 Express.js의 Router chain과 next()를 이용해서 공통 선처리를 하는 것이 일반적이다.
    if ( _.isUndefined(_id) || _.isEmpty(_id) ) {
      res.json( { result: 'fail',
                  message: '_id is empty' } );
    } else {
      db.collection( 'people', function ( err, collection ) {
        collection.remove( { _id: ObjectID(_id) }, function ( err, result ) {
          if (err) {
            res.json( { result: 'fail' } );
          } else {
            res.json( { result: 'success',
                        removedCount: result } );
          }
        });
      });
    }
  };
```

'Postman'을 이용해서 생성 테스트 요청을 한 모습이다. 'URL'과 HTTP 메소드를 'POST'로 라우팅 설정과 일치시킨다. 결과에 보면 자동으로 생성된 `_id`를 확인할 수 있다.

 

방금 생성한 데이터를 삭제한 모습이다. HTTP 메소드가 'DELETE'인 것을 확인할 수 있다.

 

서버에서 'POST'와 'DELETE' 요청을 처리 후 Collection을 각각 조회한 모습이다. 해당 데이터가 없는 경우 null을 반환한다.

 

11. 마치며

지금까지 Node.js와 MongoDB를 이용해서 데이터 CRUD를 처리하는 간단한 Sever-Side 애플리케이션을 개발해 보았다. 몇 줄의 자바스크립트 코드로 서버를 기동시키고 JSON.stringify를 이용해서 HTTP Request Body를 출력하고 Front-End에서 유용하게 사용하던 Underscore.js 같은 유틸리티를 동일하게 사용할 수 있었다. 이는 모두 자바스크립트 엔진 위에서 동작함으로써 가능한 일이다. 다음 호에는 동일한 문법과 API를 이용해서 Front-End를 개발해 보도록 하겠다.

 

 

< 각주 > 
[1] 자세한 사항은 지난호(컴퓨터월드 10월호) 필자의 강좌 글을 참고한다.
[2] Utility 모듈로 다음 호에서 자세하게 소개될 예정이다.
[3] package.json의 main(entry point)와 test command 항목으로 server.js를 입력했었다.
[4] 'GET', 'POST', 'PUT', 'DELETE' 등이 있으며 각각 'Read', 'Create', 'Update', 'Delete' 동작에 대응한다. 이는 URL Query Parameter나 요청 데이터의 특정 필드에 따라 CRUD 분기하던 것을 동일한 URL로 HTTP 메소드를 통해 라우팅할 수 있게 해 준다.
[5] 핵심 모듈(Core Modules)과 사용자가 정의한 모듈의 이름이 일치하는 경우, 항상 핵심 모듈이 로딩된다.
[6] 기능별로 모듈을 구성하는 것이 일반적이다.
[7] JSON 객체 형태이며 이를 MongoDB에서는 `Document`라고 부른다.
[8] express 자체에서 라우팅하던 것을 4.x 버전에서는 Router 객체를 생성해서 라우팅하도록 변경되었지만 본 예제에서는 기존 방식대로 처리했다.

 

저작권자 © 아이티데일리 무단전재 및 재배포 금지