1.4 Node Express API 서버 만들기

Node Express API 서버의 파일 구조는 다음과 같습니다.

environment

├── serverless-api  : API server

│   ├── bin

│   │ └── www : app.js를 로컬에서 실행하기 위한 파일

│   ├── routes

│   │ └── todo.js : /todo로 라우팅하는 파일

│   ├── spec

│   │ └── todo.spec.js : /todo를 테스트 하는 spec 파일

│   ├── app.js : express 서버

│   ├── handler.js  : express를 wrapping하기 위한 handler

│   ├── config.yml : serverless.yml에서 사용하기 위한 변수

│   ├── package.json

│   └── serverless.yml :  Serverless Framework config file

└── static-web-front : SPA 방식의 Web Front


먼저 serverless-api 디렉터리를 생성하고 npm을 초기화합니다.

$ mkdir serverless-api
$ cd serverless-api
$ npm init -y


다음 필요한 npm module을 설치합니다. 서버리스 개발을 위해서는 aws-sdk도 필요하므로 설치합니다. 한 가지 주의할 점은 Lambda는 aws-sdk를 기본적으로 포함하고 있기 때문에 실제로 배포할 때는 포함되지 않아야 한다는 점입니다. 따라서 aws-sdk는 dev-dependency에 넣어 배포시 제외합니다.


  • Dependencies
    • express : 웹 애플리케이션 프레임워크Web Application Framework다.

    • body-parser : Request Body를 파싱parsing하기 위한 미들웨어다.

    • aws-serverless-express : Express를 Lambda에서 사용하도록 래핑Wrapping하는 패키지다.

    • dynamoose : DynamoDB를 사용하기 쉽도록 모델링Modeling하는 도구다.

    • dotenv : 환경변수를 쉽게 관리하기 위한 패키지다.

    • cors : CORSCross-Origon Resource Sharing를 손쉽게 허용하는 미들웨어다.

  • Dev-dependencies
    • mocha : 개발 도구다.

    • supertest : HTTP 테스트를 하기 위한 모듈이다.

    • should : BDDBehaviour-Driven Development를 지원하기 위한 모듈이다.

    • serverless : 서버리스 프레임워크다.

    • aws-sdk : AWS 리소스를 사용하기 위한 SDK다.

    • serverless-apigw-binary : Binary Media Type을 지원하기 위한 플러그인이다.


$ npm i -S express aws-serverless-express body-parser dynamoose dotenv cors
$ npm i -D mocha should supertest serverless aws-sdk serverless-apigw-binary


다음 파일의 내용을 편집합니다.

  • serverless-api/config.yml

AWS_REGION: ap-northeast-2
STAGE: dev
DEPLOYMENT_BUCKET: USERNAME-serverless-hands-on-1    # USERNAME 수정 필요!


  • serverless-api/app.js

const express = require("express");
const bodyParser = require("body-parser");
const cors = require("cors");
const app = express();

require("aws-sdk").config.region = "ap-northeast-2"

app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

// 실제로 사용한다고 가정하면 유저정보를 실어주어야함.
app.use((req, res, next) => {
   res.locals.userId = "1";
   next();
});

app.get("/", (req, res, next) => {
   res.send("hello world!\n");
});

app.use("/todo", require("./routes/todo"));

app.use((req, res, next) => {
   res.status(404).send("Not Found");
});

app.use((err, req, res, next) => {
   console.error(err);
   res.status(500).send(err);
});

module.exports = app;


  • serverless-api/bin/www

const app = require("../app");
const http = require("http");
const port = process.env.PORT || 3000;
const server = http.createServer(app);

server.on("error", (err) => console.error(err));

server.listen(port, () => console.log(`Server is running on ${port}`));


  • serverless-api/handler.js

'use strict'
const awsServerlessExpress = require('aws-serverless-express')
const app = require('./app')
const binaryMimeTypes = [
 'application/javascript',
 'application/json',
 'application/octet-stream',
 'application/x-font-ttf',
 'application/xml',
 'font/eot',
 'font/opentype',
 'font/otf',
 'font/woff',
 'font/woff2',
 'image/jpeg',
 'image/png',
 'image/svg+xml',
 'text/comma-separated-values',
 'text/css',
 'text/html',
 'text/javascript',
 'text/plain',
 'text/text',
 'text/xml'
]

const server = awsServerlessExpress.createServer(app, null, binaryMimeTypes)

module.exports.api = (event, context) => awsServerlessExpress.proxy(server, event, context)


  • serverless-api/routes/todo.js

const router = require("express").Router();
const dynamoose = require('dynamoose');
const _ = require('lodash');
const Todo = dynamoose.model('Todo', {
   userId: {
       type: String,
       hashKey: true
   }, 
   createdAt: {
       type: String,
       rangeKey: true
   },
   updatedAt: String,
   title: String,
   content: String
}, {
   create: false, // Create a table if not exist,
});

router.get("/", (req, res, next) => {
   const userId = res.locals.userId;
   let lastKey = req.query.lastKey;
   
   return Todo.query('userId').eq(userId).startAt(lastKey).limit(1000).descending().exec((err, result) => {
       if(err) return next(err, req, res, next);
       
       res.status(200).json(result);
   })
});

router.get("/:createdAt", (req, res, next) => {
   const userId = res.locals.userId;
   const createdAt = String(req.params.createdAt);

   return Todo.get({userId, createdAt}, function (err, result) {
       if(err) return next(err, req, res, next);
     
       res.status(200).json(result);
   });
});

router.post("/", (req, res, next) => {
   const userId = res.locals.userId;
   const body = req.body;
   
   body.createdAt = new Date().toISOString();
   body.updatedAt = new Date().toISOString();
   body.userId = userId;
   
   return new Todo(body).save((err, result) => {
       if(err) return next(err, req, res, next);
     
       res.status(201).json(result);
   });
});

router.put("/:createdAt", (req, res, next) => {
   const userId = res.locals.userId;
   const createdAt = req.params.createdAt;
   const body = req.body;
   
   if(body.createdAt) delete body.createdAt;
   
   body.updatedAt = new Date().toISOString(); 
   
   return new Todo(_.assign(body, {
       userId,
       createdAt
   })).save((err, result) => {
       if(err) return next(err, req, res, next);
     
       res.status(200).json(result);
   });
});

router.delete("/:createdAt", (req, res, next) => {
   const createdAt = req.params.createdAt;
   const userId = res.locals.userId;
   
   if(!createdAt) return res.status(400).send("Bad request. createdAt is undefined");
   
   return Todo.delete({
       userId,
       createdAt
   }, (err) => {
       if(err) return next(err, req, res, next);
     
       res.status(204).json();
   });
});

module.exports = router;


  • serverless-api/spec/todo.spec.js

const request = require('supertest');
const _ = require('lodash');
const app = require('../app');
const data = {
   title: "hello",
   content: "world"
}
let createdData = null;

describe("POST /todo", () => {
   it('Should return 201 status code', (done) => {
       request(app).post('/todo').send(data).expect(201, (err, res) => {
           if(err) return done(err);
           
           createdData = res.body;
           done();
       });
   });
});

describe("PUT /todo/:id", () => {
   it('Should return 200 status code', (done) => {
       request(app).put(`/todo/${createdData.createdAt}`).send(_.assign(data, {
           content: "world. Successfully modified!"
       })).expect(200, done);
   });
});

describe("GET /todo", () => {
   it('Should return 200 status code', (done) => {
       request(app).get('/todo').expect(200).end((err, res) => {
           if(err) return done(err);
           
           console.log(res.body);
           done();
       });
   });
});

describe("GET /todo/:createdAt", () => {
   it('Should return 200 status code', (done) => {
       request(app).get(`/todo/${createdData.createdAt}`).expect(200).end((err, res) => {
           if(err) return done(err);
           
           console.log(res.body);
           done();
       });
   });
});

describe("DELETE /todo/:id", () => {
   it('Should return 204 status code', (done) => {
       request(app).delete(`/todo/${createdData.createdAt}`).send(data).expect(204, done);
   });
});


  • serverless-api/package.json

scripts 부분에 아래 내용을 추가해야 합니다.

{

 "name": "serverless-api",

 ....

 //// 이 스크립트 영역을 복사해서 붙여넣어줍니다.

 "scripts": {

   "test": "mocha spec/*.spec.js --timeout 10000",

   "start": "node bin/www",

   "deploy": "serverless deploy"

 },

 ////

 ...

 "keywords": [],

 "author": "",

 ...

}


  • serverless-api/serverless.yml

마지막으로 serverless.yml을 생성합니다. 이 파일은 서버리스 프레임워크를 통해 AWS에 서버리스 환경을 손쉽게 배포하도록 도와줍니다. 내부적으로는 클라우드포메이션 템플릿CloudFormation Template을 생성해 배포하는데, 배포된 아티팩트Artifact는 S3에서 확인할 수 있습니다.

app.js와 serverless.yml에는 cors 관련 옵션을 설정했습니다. 브라우저는 보안을 이유로 스크립트 내에서 초기화되는 cross-origin HTTP 요청을 제한하기 때문에 별도로 API Gateway에서 HTTP 요청을 허용하고, 실제로 동작하는 람다에서도 서버처럼 동작하기 때문에 이 옵션을 추가해야 합니다.

service: ServerlessHandsOnPart1

provider:
 name: aws
 runtime: nodejs8.10
 memorySize: 128
 stage:  ${file(./config.yml):STAGE}
 region: ${file(./config.yml):AWS_REGION}
 deploymentBucket: ${file(./config.yml):DEPLOYMENT_BUCKET}
 environment:
   NODE_ENV: production
 iamRoleStatements:
   - Effect: Allow
     Action:
       - dynamodb:DescribeTable
       - dynamodb:Query
       - dynamodb:Scan
       - dynamodb:GetItem
       - dynamodb:PutItem
       - dynamodb:UpdateItem
       - dynamodb:DeleteItem
     Resource: "arn:aws:dynamodb:${opt:region, self:provider.region}:*:*"

plugins:
- serverless-apigw-binary
custom:
 apigwBinary:
   types:
     - 'application/json'
     - 'text/html'

functions:
 webapp:
   handler: handler.api
   events:
     - http: 
         path: /{proxy+}
         method: ANY
         cors: true
     - http: 
         path: /{proxy+}
         method: OPTIONS
         cors: true


서버 가동하기

모든 파일을 설정했다면 이제 서버를 가동해봅니다.

ec2-user:~/environment/serverless-api $ npm start
> serverless-api@1.0.0 start /home/ec2-user/environment/serverless-api
> node bin/www

Server is running on 8080


서버가 제대로 응답하는지 확인하기 위해 새로운 터미널을 열어 get 요청을 합니다.

ec2-user:~/environment/serverless-api $ curl localhost:8080
hello world!
ec2-user:~/environment/serverless-api $ curl localhost:8080/todo
응답없음


서버를 가동했지만 아직은 API가 사용 가능한 상태는 아닙니다. DynamoDB의 테이블을 생성하지 않았기 때문입니다.