Node.js를 이용한 모바일 API 서버 개발 사례

  • V8

  • Non-blocking Event I/O

  • CommonJS

  • ES6 support (node.green)

Node.js

// sum.js
const sum = (a, b) => a + b;
module.exports = sum;

// app.js
const sum = require('./sum');
sum(1, 2); // 3
  • 설치: https://nodejs.org/en/

  • 명령어

    • node

      • node

      • node app.js

    • npm 

      • npm init
      • npm install [package name] 
      • npm install [package name] --save
      • npm install [package name] --save-dev
      • npm install
      • npm start

      • npm test

      • npm run [script name]

Node.js

// package.json
{
  "name": "apiserver",
  "scripts": {
    "start": "node bin/www",
    "test": "NODE_ENV=test node_modules/.bin/mocha app/**/*.spec.js"
  },
  "dependencies": {
    "express": "^4.14.0"
  },
  "devDependencies": {
    "mocha": "^3.1.2"
  }
}

Hello world (node)

const http = require('http');
const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello World\n');
});

server.listen(port, hostname, () => {
  console.log(`Run at http://${hostname}:${port}/`);
});
  • Web framework for nodejs

  • Application

  • Middleware

  • Request

  • Response

  • Router

Hello world (express)

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

app.get('/', (req, res) => {
    res.send('Hello World!\n')
});

app.listen(3000, () => {
    console.log(`Run at http://localhost:3000`)
});

Express vs http

  • 요청(Reqeust) 처리
    • 쿼리문자열 파싱
    • 리퀘스트 바디 파싱 (body-parser)
  • 라우팅 처리
    • 구조적인 코드를 유지할 수 있음
  • 응답(Response) 처리
    • 응답 헤더 자동 설정
  • 미들웨어로 추가할 수 있는 기능이 다양함
    • 로깅(morgan), 인증(passport), 파라매터 검증, 세션 ...

Application

  • 익스프레스 인스턴스
  • 미들웨어 세팅
  • 라우팅 세팅
  • 요청대기상태(listen)
const express = require('express');
const app = express();

app.use(require('morgan')('dev'));

app.get('/', (req, res) => res.send('Hello World!\n'));

app.listen(3000, () => console.log('Run at http://localhost:3000'));

Request

  • http 모듈의 request 객체를 사용함
  • express에서 아래 객체를 자동으로 추가해줌
    • req.params
    • req.query
    • req.body
app.get('/:id', (req, res) => {
    const id = req.params.id; // /1
    const limit = req.query.limit; // ?limit=10
    const body = req.body; // {name: 'chris'}
});

Response

  • http 모듈의 response 객체를 사용함
  • express에서 아래 메소드를 자동으로 추가해줌
    • res.send()
    • res.status()
    • res.json()
  • 각 메소드별로 헤더 자동 설정됨
app.get('/:id', (req, res) => {
    res.status(204).end();
    res.send('hello world');
    res.json([{msg: 'hello world'}]);
});

Router

  • 라우팅 설정을 위한 Router 클래스
  • API 서버의 핵심인 라우팅 로직을 구조적으로 구현할수 있음
const user = require('./user');
app.use('/users', user)
// user.js
const router = require('express').Router()

router.get('/', (req, res) => res.send('user list'));
router.post('/', (req, res) => res.send('created user'));

module.exports = router;

Middleware

  • req → middlewares... → route → middlewares... → res
const app = require('express')();
app.use((req, res, next) => {
  console.log('middleware 1');
  next();
});
app.use((req, res, next) => {
  console.log('middleware 2');
  next();
});
app.get('/', (req, res, next) => {
  console.log('middleware 3');
  next();
}, (req, res) => {
  console.log('router GET /');
  res.send('hello world\n')
});
app.listen(3000, _=> console.log('Run server'));

middleware 1

middleware 2

middleware 3

router Get /

Middleware

  • error middleware 1
const app = require('express')();
app.use((req, res, next) => {
  console.log('middleware 1');
  next('error 1');
});
app.use((error, req, res, next) => {
  console.log('middleware 2', error);
  next();
});
app.get('/', (req, res, next) => {
  console.log('middleware 3');
  next();
}, (req, res) => {
  console.log('router GET /');
  res.send('hello world\n')
});
app.listen(3000, _=> console.log('Run server'));

middleware 1

middleware 2, error1

middleware 3

router Get /

Middleware

  • error middleware 2
const app = require('express')();
app.use((req, res, next) => {
  console.log('middleware 1');
  next('error 1');
});
app.use((error, req, res, next) => {
  console.log('middleware 2', error);
  next(error);
});
app.get('/', (req, res, next) => {
  console.log('middleware 3');
  next();
}, (req, res) => {
  console.log('router GET /');
  res.send('hello world\n')
});
app.listen(3000, _=> console.log('Run server'));

middleware 1

middleware 2, error1

error 1

Middleware

API 개발

  • GET /usres/:id

  • POST /usres

  • PUT /usres/:id

  • DELETE /usres/:id

시작

const express = require('express');
const logger = require('morgan');

// 익스프레스 객체 생성
const app = express()

// 유저 데이터
let users = [
    {id: 1, name: 'alice'},
    {id: 2, name: 'bek'},
    {id: 3, name: 'chris'}
];

// 로거 설정 
app.use(logger('dev'));

// 요청 대기 상태 진입 
app.listen(3000, () => console.log('Listening on port 3000'));

GET /users/:id

// GET /users API 구현 
app.get('/users', (req, res) => res.json(users));

// GET /users/:id API 구현 
app.get('/users/:id', (req, res) => {
    const user = users.filter(u => u.id == req.params.id)[0];
    if (user) return res.json(user); 

    // 사용자가 없는 경우 404 상태코드 응답 
    res.status(404).end();
});

POST /users

// 바디 파서 미들웨어 추가 
const bodyParser = require('body-parser');

// 바디 파서 미들웨어 설정 
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: false}));

// post /users API 구현 
app.post('/users', (req, res) => {
    if (!req.body.name) return res.status(400).end();
    
    const user = {id: Date.now(), name: req.body.name} 
    users.push(user);
    res.status(201).json(user);
});

PUT /users/:id

app.put('/users/:id', (req, res) => {
    // 파라매터 에러 처리 (400 반환)
    if (!req.body.name) return res.status(400).end();
    
    const user = users.filter(u => u.id == req.params.id)[0];

    // 없는 유저일 경우 에러처리 (404 반환)
    if (!user) return res.status(404).end(); 

    user.name = req.body.name;    
    res.json(user);
});

DELETE /users/:id

app.delete('/users/:id', (req, res) => {
   users = users.filter(u => u.id != req.params.id);
   res.status(204).end();
});

Express Router 적용 

app.use('/users', require('./api/user'));
// api/user/index.js

// 라우터 객체 생성
const router = require('express').Router();

let users = [
    {id: 1, name: 'alice'},
    {id: 2, name: 'bek'},
    {id: 3, name: 'chris'}
];

router.get('', (req, res) => res.json(users));
router.get('/:id', (req, res) => {
    const user = users.filter(u => u.id == req.params.id)[0];
    if (user) return res.json(user); 
    res.status(404).end();
});
router.post('', (req, res) => {
    if (!req.body.name) return res.status(400).end();
    
    const user = {id: Date.now(), name: req.body.name} 
    users.push(user);
    res.status(201).json(user);
});
router.put('/:id', (req, res) => {
    if (!req.body.name) return res.status(400).end();
    
    const user = users.filter(u => u.id == req.params.id)[0];
    if (!user) return res.status(404).end(); 

    user.name = req.body.name;    
    res.json(user);
});
router.delete('/:id', (req, res) => {
   users = users.filter(u => u.id != req.params.id);
   res.status(204).end();
});

// 라우터를 외부로 노출 
module.exports = router;

Express Router 적용 

// api/user/index.js

// 컨트롤러로 비지니스 로직 분리 
const ctrl = require('./user.ctrl');

router.get('', ctrl.index);
router.get('/:id', ctrl.show);
router.post('', ctrl.create);
router.put('/:id', ctrl.update);
router.delete('/:id', ctrl.destory);
// api/user/user.ctrl.js

let users = [
    {id: 1, name: 'alice'},
    {id: 2, name: 'bek'},
    {id: 3, name: 'chris'}
];

const index = (req, res) => res.json(users);
const show = (req, res) => {
    const user = users.filter(u => u.id == req.params.id)[0];
    if (user) return res.json(user); 
    res.status(404).end();
};
const create = (req, res) => {
    if (!req.body.name) return res.status(400).end();
    
    const user = {id: Date.now(), name: req.body.name} 
    users.push(user);
    res.status(201).json(user);
};
const update = (req, res) => {
    if (!req.body.name) return res.status(400).end();
    
    const user = users.filter(u => u.id == req.params.id)[0];
    if (!user) return res.status(404).end(); 

    user.name = req.body.name;    
    res.json(user);
};
const destory = (req, res) => {
   users = users.filter(u => u.id != req.params.id);
   res.status(204).end();
};

module.exports = {index, show, create, update, destory};

API 개발 2

  • 데이터베이스 연동

ORM

  • Sequelize: nodejs에서 사용하는 sql 디비용 ORM

  • sqlite: 개발, 테스용 / mysql: 운영용

// models/index.js
const Sequelize = require('sequelize');

// 데이터베이스 연결 
const sequelize = new Sequelize({
    dialect: 'sqlite',
    storage: 'db.development.sqlite'
});

// 유저 모델링
const User =  sequelize.define('user', {
  name: Sequelize.STRING,
});

module.exports = {sequelize, User};
// index.js

// 디비 초기화 
models.sequelize.sync({force: true}).then(() => {
    console.log('database is synced');
    app.listen(3000, () => console.log('Listening on port 3000'));
});

ORM

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

const index = (req, res) => {
    models.User.findAll().then(users => res.json(users));
}
const show = (req, res) => {
    models.User.findOne({where: {id: req.params.id}}).then(user => {
        if (!user) return res.status(404).end(); 
        res.json(user); 
    });
};
const create = (req, res) => {
    if (!req.body.name) return res.status(400).end();
    
    models.User.create({name: req.body.name}).then(user => {
        res.status(201).json(user);
    });
};
const update = (req, res) => {
    if (!req.body.name) return res.status(400).end();

    models.User.findOne({where: {id: req.params.id}}).then(user => {
        if (!user) return res.status(404).end();

        user.name = req.body.name;
        user.save().then(_ => res.json(user));
    });
};
const destory = (req, res) => {
    models.User.destroy({where: {id: req.params.id}}).then(() => {
        res.status(204).end();
    });
};

module.exports = {index, show, create, update, destory};
  • findAll(), findOne(), create(), update(), destroy()

테스트

  • Postman

  • Mocha, should, supertest

  • 환경의 분리

Postman, Curl

  • 수작업, 느린 속도, 한계
  • 테스트 러너 
  • 웹스톰과 연동하면 디버깅도 편함
describe('test suite', () => {
    before('setup mokup', () => ...);
    after('tear down mokup', () => ...);
    it('test case 1', () => ...);
    it('test case 2', () => ...);
});
  • 노드 기본 모듈인 assert보다는 서드파티 검증 모듈을 사용하도록 권장
describe('test suite', () => {
    it('test case 1', () => {
        users.should.be.an.instanceOf(Array)
        users.should.have.length(3);
        users[0].should.have.properties('id', 'name', 'caretedAt')
        users[0].should.have.property('name', 'Alice')
    });
});
  • REST API 테스트 라이브러리
const app = require('./app');
const request = require('supertest');

describe('...', () => {
    it('...', done => {
        request(app)
            .get('/users')
            .expect(200)
            .done((err, res) => {
                if (err) throw err;
                res.body.should.be.instanceOf(Array);
                done()
            });
    });
})

서버 코드와 실행 코드를 분리

  • app: 서버 어플리케이션 코드
  • bin: app를 실제 구동하는 코드 
// bin/www.js
const models = require('../app/models');
const app = require('../app');

models.sequelize.sync({force: true}).then(() => {
    console.log('database is synced');
    app.listen(3000, () => console.log('Listening on port 3000'));
});
// app/index.js
const express = require('express');
const logger = require('morgan');
const bodyParser = require('body-parser');
const app = express();

if (process.env.NODE_ENV === 'development') app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: false}));
app.use('/users', require('./api/user'));


// for test
module.exports = app;

데이터베이스 분리

  • 노드 환경 변수는 "development", "production", "test" 세가지 값을 사용 
  • 각 환경에 따라 디비 연결을 분리
// config/index.js
process.env.NODE_ENV = process.env.NODE_ENV || 'development';

const config  = {
    development: {
        db: {
            dialect: 'sqlite',
            storage: 'db.development.sqlte'
        }
    },
    test: {
        db: {
            dialect: 'sqlite',
            storage: ':memory:',
            logging: false
        }
    }, 
    production: {
        db: {
            username: 'root',
            password: 'root',
            database: 'apiserver_production',
            host: '127.0.0.1',
            dialect: 'mysql',
            logging: false
        }
    }
}

module.exports = config[process.env.NODE_ENV];
// models/index.js
const sequelize = new Sequelize(config.db);

테스트 코드 작성

const should = require('should');
const request = require('supertest');
const app = require('../../');
const models = require('../../models');

describe('User', () => {
    const users = [
        {name: 'alice'},
        {name: 'bek'},
        {name: 'chris'}
    ]
    before('sync database', () => models.sequelize.sync({force: true}));
    before('insert seed data', () =>  models.User.bulkCreate(users));
    describe('GET /users', () => {
        it('should return user array', done => {
            request(app)
                .get('/users')
                .expect(200)
                .end((err, res) => {
                    if (err) throw err;
                    res.body.should.be.instanceOf(Array).with.length(users.length)
                    done();
                });
        });
    });
    describe('GET /users/:id', () => {
        it('should return user object', done => {
            request(app)
                .get('/users/1')
                .expect(200)
                .end((err, res) => {
                    if (err) throw err;
                    res.body.should.have.property('name', users[0].name)
                    done();
                });
        });
    });
    describe('POST /users', () => {
        it('should return new user', done => {
            request(app)
                .post('/users')
                .send({name: 'daniel'})
                .expect(201)
                .end((err, res) => {
                    if (err) throw err;
                    res.body.should.have.property('name', 'daniel')
                    done();
                });
        });
    });  
    describe('PUT /users/:id', () => {
        it('should update username', done => {
            request(app)
                .put('/users/4')
                .send({name: 'david'})
                .expect(200)
                .end((err, res) => {
                    if (err) throw err;
                    res.body.should.have.property('name', 'david')
                    done();
                });
        });
    });  
    describe('DELETE /users/:id', () => {
        it('should delete user by id', done => {
            request(app)
                .delete('/users/4')
                .expect(204)
                .end(done);
        });
    });  
});

실행 스크립트 등록

  • package.json에 실행 스크립트를 추가할 수 있음 
  • npm start
  • npm test
  "scripts": {
    "start": "node bin/www",
    "test": "NODE_ENV=test node_modules/.bin/mocha app/api/user/user.spec.js"
  },

문서화

  • Github wiki + postman

  • apidoc + postman

  • swagger

깃헙 위키

  • 문서 일관성 유지 어려움
  • FE개발자도 포스트맨으로 테스트하며 개발
  • POST /users
    • input
      • name
      • gender
      • mobile
      • nationality
      • dob
      • ...
    • response
      • userId
      • name
      • ...
  • 코드에 주석으로 API 문서를 작성하는 형식
  • 코드보다 주석이 많은 경우가 빈번함 

Swagger

  • 스웨거 문법에 맞춰 문서를 작성함 (json, yaml 형식)
  • swagger-ui를 api 서버에 장착하여 개발 
  • 문서와 api 테스트 툴을 함께 사용할 수 있는 장점 

Swagger spec

스웨거 스펙을 이용해 문서 작성

const paths = {
    '/users': {
        get: {
            tags: ['User'],
            summary: 'Get users',
            operationId: 'userUsers',
            produces: ['application/json'],
            parameters: [
            ],
            responses: {
                200: {
                    description: 'Success',
                    schema: {
                        type: 'array',
                        items: {'$ref': '#/definitions/User'}
                    }
                }
            }
        },
        post: {
            tags: ['User'],
            summary: 'Create users',
            operationId: 'postUsers',
            produces: ['application/json'],
            parameters: [{$ref: '#/parameters/User'}],
            responses: {
                201: {
                    description: 'Created',
                    schema: {"$ref": "#/definitions/User"}
                },
                400: {description: 'BadRequest'}
            }
        }
    }
}

const parameters = {
    User: {
        name: 'user',
        in: 'body',
        required: true,
        schema: {
            type: 'object',
            properties: {
                name: {type: 'string', default: 'chris'}
            }
        }
    }
}

const definitions = {
    User: {
        type: 'object',
        properties: {
            id: {type: 'number'},
            name: {type: 'string'},
            createdAt: {type: 'string', format: 'dateTime'},
            updatedAt: {type: 'string', format: 'dateTime'}
        }
    }
};


module.exports  = {
  "swagger": "2.0",
  "info": {
    "title": "apiserver",
    "description": "apiserver API Documents",
    "termsOfService": "",
    "contact": {
      "name": "Chris, WePlanet"
    },
    "license": {
      "name": "Apache 2.0",
      "url": "http://www.apache.org/licenses/LICENSE-2.0.html"
    },
    "version": "1.0.0"
  },
  "host": "",
  "basePath": "/",
  "schemes": [
    "http"
  ],
  "consumes": [
    "application/json"
  ],
  "produces": [
    "application/json"
  ],
  paths: paths,
  parameters: parameters,
  definitions: definitions
};

Swagger spec

작성한 문서 호스팅

// config/swagger/index.js
const express = require('express');
const path = require('path')

const setupDocument = app => {
  app.get('/swagger/doc', (req, res) => {
      const doc = require('./v1.doc');
      res.json(doc);
  });
};

module.exports = app => {
    setupDocument(app);
}

Swagger ui

문서를 렌더링할 swagger-ui 툴을 프로젝트에 추가

// config/swagger/index.js
const setupSwaggerUi = app => {
  app.use('/swagger', (req, res, next) => {
    if (req.url === '/') res.redirect('/swagger?url=doc');
    else next();
  }, express.static(path.join(__dirname, '../../../node_modules/swagger-ui/dist')));
}

module.exports = app => {
    setupDocument(app);
    setupSwaggerUi(app);
}
// index.js

// 스웨거 설정 
const swagger = require('./config/swagger');
swagger(app);

Swagger ui

한계

  • 정적파일(Static files)?
    • →  정정파일은 Nginx로 처리하는 것이 성능상 유리
  • Uncatched Exception으로 서버 죽는 문제
  • Cpu Bound Job?
    • AWS Lambda로 기능 분리 (Resize images, Cron)

배포

  • 우분투에 노드 설치 (참고)
  • Git hooks을 이용한 배포 자동화
    • 배포서버에 git 저장소 설치
      • git init --bare --shared
    • post-receive 후커 작성
    • 배포서버 리모트 추가
      • git remote add dev-server [배포서버 깃 주소]
    • 배포서버로 소스코드 배포
      • git push dev-server master
# hooks/post-receive
#!/bin/bash
APP_NAME=apiserver
APP_DIR=$HOME/$APP_NAME
REVISION=$(expr substr $(git rev-parse --verify HEAD) 1 7)

GIT_WORK_TREE=$APP_DIR git checkout -f

cd $APP_DIR

forever stop apiserver
forever start --uid apiserver npm bin/www

배포

  • AWS Ealstic Beanstalk을 이용한 배포 자동화
    • EC2, 로드밸런서 등을 자동으로 구성해주는 서비스
    • ebcli를 이용한 커맨드라인 배포 지원
      • eb init // 어플리케이션 초기화
      • eb create // 서버 환경 생성
      • eb deploy // 서버 배포, 최신 커밋 버전을 배포함 

코드 스캐폴딩

  •  yoeman
    • 웹 프레임웍을 이용한 코드 제너레이터
    • express, hapi, angular, react 등 코드 제너레이터 제공
  • generator-weplajs
    • express를 이용한 api 서버 개발용 코드 제너레이터
  • Hapi
  • Sails
  • Restify
  • Meteor

감사합니다.

[OCL] Node.js를 이용한 모바일 API 서버 개발

By 김정환

[OCL] Node.js를 이용한 모바일 API 서버 개발

  • 8,024