Serverless 활용하기: MongoDB 기반 RESTful CRUD API 만들기


지난 튜토리얼에서는 Serverless 프레임워크를 사용하여 아주 간단한 API 를 만들어주었습니다. 이번 튜토리얼에서는, Serverless 를 조금 더 활용해서 MongoDB 에 연동한 RESTful CRUD (Create, Remove, Update, Delete) API 를 만들어보겠습니다.

이 튜토리얼은, 지난번에 진행하던 프로젝트에서 이어서 진행하도록 하겠습니다. 만약에 지난 튜토리얼을 진행하지 않았다면 해당 튜토리얼을 먼저 진행하고 이 튜토리얼을 읽어주세요. 추가적으로, Node.js 에 익숙하지 않다면 이 튜토리얼이 조금! 어려울 수도 있습니다.

이 튜토리얼에서 사용되는 코드는 Github Repo에서 참고 할 수 있으니 참고하세요.

여러개의 함수 만들기

우선, 우리의 서버리스 애플리케이션에서 관리 할 여러가지의 함수를 만들어주겠습니다.

serverless.yml 파일을 열어보세요.

serverless.yml

service: hello-serverless

provider:
  name: aws
  runtime: nodejs6.10
  stage: dev
  region: ap-northeast-2

functions:
  hello:
    handler: handler.hello
    events: 
      - http:
          path: hello
          method: get

기존에 있던 서버리스 설정을 보시면, hello 라는 함수에 handler 값이 handler.hello 로 설정되어 있지요? 이 의미는, handler.js 파일에서 hello 를 호출하라는 의미입니다.

자, 우리는 story 라는 파일을 만들고, 해당 함수에 CRUD 에 해당하는 4가지 함수를 만들어주겠습니다.

기존의 handler.js 를 지우시고, src 라는 디렉토리를 만드세요. 그리고, 그 디렉토리에 stories.js 파일을 생성하세요.

src/stories.js

const createResponse = (status, body) => ({
  statusCode: status,
  body: JSON.stringify(body)
});

// 스토리 만들기
exports.createStory = (event, ctx, cb) => {
  cb(null, createResponse(200, { message: 'create' }));
};

// 여러개의 스토리 리스팅
exports.readStories = (event, ctx, cb) => {
  cb(null, createResponse(200, { message: 'list' }));
};

// 특정 스토리 읽기
exports.readStory = (event, ctx, cb) => {
  cb(null, createResponse(200, { message: 'read' }));
};

// 스토리 수정
exports.updateStory = (event, ctx, cb) => {
  cb(null, createResponse(200, { message: 'update' }));
};

// 스토리 삭제
exports.deleteStory = (event, ctx, cb) => {
  cb(null, createResponse(200, { message: 'delete' }));
};

response 객체를 직접 계속해서 만드는게 귀찮으니, 따로 createResponse 라는 함수를 만들어 주었고, 생성, 리스팅, 조회, 수정, 삭제에 해당하는 함수를 만들어서 내보내주었습니다.

그 다음엔, serverless.yml 파일을 수정하여 각 함수에 API 주소를 연결시켜줍시다.

serverless.yml

service: hello-serverless

provider:
  name: aws
  runtime: nodejs6.10
  stage: dev
  region: ap-northeast-2

functions:
  createStory:
    handler: src/stories.createStory
    events:
      - http:
          path: stories
          method: post
  readStories:
    handler: src/stories.readStories
    events:
      - http:
          path: stories
          method: get
  readStory:
    handler: src/stories.readStory
    events:
      - http:
          path: stories/{id}
          method: get
  updateStory:
    handler: src/stories.updateStory
    events:
      - http:
          path: stories/{id}
          method: patch
  deleteStory:
    handler: src/stories.deleteStory
    events:
      - http:
          path: stories/{id}
          method: delete

예를 들어서, createStory 는 src/stories.js 파일에 있는 createStory 함수이니, handler 의 값으로는 src/stories.createStory 와 같은 형태로 작성을 하시면 됩니다.

createStory 와 readStories 를 제외한 함수들은, 특정 스토리를 읽거나, 수정하거나, 삭제하는 기능입니다. 따라서, API 경로가 stories 가 아닌 stories/{id} 형태로 되어있는데요, 이 의미는 id 값을 URL 파라미터로 받아오겠다는 의미입니다. 즉, /stories/507f1f77bcf86cd799439011 이러한 형태로 특정 스토리에 대한 작업을 할 수 있는 것이죠.

id 로 전달 된 값은 함수 내부에서 event.pathParameters.id 를 통하여 조회 할 수 있습니다.

코드를 저장하고, sls deploy 명령어를 입력하시면, 여러 함수가 배포된 것을 확인 할 수 있습니다.

npm 에서 모듈 설치하기

이번엔, npm 에서 모듈을 설치해서 사용해보겠습니다. 우선 npm init 명령어를 통하여 package.json 을 만들어주겠습니다. CLI 에서 물어보는 질문에 모두 Enter 키를 누르시면 됩니다.

$ npm init

그 다음에는, mongoose 라는 라이브러리를 설치하겠습니다. 이 라이브러리는, MongoDB ODM 으로서, MongoDB 연동을 하게 될 때 편하게 작업 할 수 있게 해주는 라이브러리 입니다.

$ npm i --save mongoose

우리의 애플리케이션에서 MongoDB 를 사용하려면, 데이터베이스 서버가 필요하겠지요? 여러분이 직접 구축을 하셔도 되지만, 그냥 학습용이라면 이 작업이 조금 번거로울 수도 있습니다. 그렇다면, mLab 혹은 MongoDB Atlas 에서 무료 호스팅을 받을 수도 있습니다.

아쉽게도, 아직 두 서비스 모두 한국서버는 지원하지 않아서, 해당 서비스들은 가벼운 용도로만 사용하시는것을 추천합니다.

이 튜토리얼에선, 제가 미리 만들어둔 공개 데이터베이스와 계정을 사용하겠습니다. 직접 만들게 되신다면, 이 가이드를 참고하세요.

MongoDB 연결하기

우리가 만든 함수에서 mongoose 를 사용하기 위해서 따로 해야 할 작업은 없습니다. 일반 Node.js 애플리케이션에서 모듈을 불러와서 사용 할 때와 같이, 그냥 require 를 통해서 불러와주시면 됩니다.

일반 Node.js 에서 mongoose 를 사용 할 때와 다른 점은, 함수를 호출 할 때마다 MongoDB 에 연결을 해주어야 합니다.

API 가 호출 될 때마다 MongoDB 에 접속요청을 하는 것은 성능적으로 낭비 일 수 있습니다. 다행히도, 함수가 시작되고 일정 시간 안에 새로운 요청이 들어온다면 MongoDB 접속정보를 재활용하는 방법이 있습니다. 이는, 나중에 다뤄보도록 하겠습니다.

src/stories.js

const mongoose = require('mongoose');

const connect = () => {
  return mongoose.connect('mongodb://serverless:serverless@ds239128.mlab.com:39128/serverless');
};

const createResponse = (status, body) => ({
  statusCode: status,
  body: JSON.stringify(body)
});

// 스토리 만들기
exports.createStory = (event, ctx, cb) => {
  ctx.callbackWaitsForEmptyEventLoop = false;
  connect().then(
    () => {
      cb(null, createResponse(200, { message: 'connected to db' }));
    }
  ).catch(
    (e) => {
      cb(null, createResponse(500, { error: e }));
    }
  )
};

(...)

위 코드에서 ctx.callbackWaitsForEmptyEventLoop = false; 를 함수의 상단에서 입력을 해주었는데요, 이걸 작성한 이유는, 람다에서의 콜백은, 기본적으로는 이벤트 루프 안의 모든 작업이 비워질때까지, 즉, 애플리케이션에서 진행중인 모든 작업이 끝날때 까지 콜백이 호출되지 않습니다. 우리가 mongoose 로 데이터베이스 서버에 접속을 하였기 때문에 콜백이 처리되지 않게 되므로 해당 설정을 해주면, 콜백이 제대로 처리됩니다.

추가적으로, 나중에 이 설정을 통하여 기존에 만들었던 접속정보를 재활용 할 수도 있게 됩니다.

Story 모델 만들기

Story 데이터를 읽고, 쓰고, 수정하고, 삭제하기 위하여 Story 모델을 만들도록 하겠습니다.

src/models/Story.js

const mongoose = require('mongoose');

const StorySchema = new mongoose.Schema({
  title: String,
  body: String
});

const Story = mongoose.model('Story', StorySchema);

module.exports = Story;

CRUD 구현하기

자, 이제 우리가 방금 만든 모델을 사용하여 CRUD 를 구현하겠습니다.

데이터 생성 – createStory

src/stories.js

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

const connect = () => {
  return mongoose.connect('mongodb://serverless:serverless@ds239128.mlab.com:39128/serverless');
};

const createResponse = (status, body) => ({
  statusCode: status,
  body: JSON.stringify(body)
});


// 스토리 만들기
exports.createStory = (event, ctx, cb) => {
  ctx.callbackWaitsForEmptyEventLoop = false;
  const { title, body } = JSON.parse(event.body);
  connect().then(
    () => {
      const story = new Story({ title, body });
      return story.save();
    }
  ).then(
    story => {
      cb(null, createResponse(200, story));
    }
  ).catch(
    e => cb(e)
  );
};

(...)

이렇게 createStory 함수를 완성하고, sls deploy 를 입력하여 배포를 하세요. 그 다음엔, PostMan 같은 API 테스팅 도구로 다음과 같이 POST 요청을 해보세요.

POST /stories

{
  "title": "제목",
  "body": "이야기"
}

혹은, 다음과 같이 터미널로 요청을 해보셔도 됩니다.

$ curl -X POST \
  https://n7zvdgkmu5.execute-api.ap-northeast-2.amazonaws.com/dev/stories \
  -H 'cache-control: no-cache' \
  -H 'content-type: application/json' \
  -d '{
	"title": "제목",
	"body": "이야기"
}'

지금은 함수가 실행 될 때마다 MongoDB 에 요청을 하도록 설정되었으므로, 요청시간이 1000ms 전후로 걸릴 것입니다. 자, 코드를 조금 더 수정해서 마지막으로 사용했던 데이터베이스 접속정보를 재사용해보겠습니다.

접속정보 재사용하기

우선, connect 함수에서 만약에 기존에 연결중이던게 있으면 새로 연결하지 않도록 설정합니다.

src/stories.js

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

let connection = null;

const connect = () => {
  // 연결 되어있으면 기존것을 연결시키고
  if (connection && mongoose.connection.readyState === 1) return Promise.resolve(connection);
  // 없으면 새로 연결함
  return mongoose.connect('mongodb://serverless:serverless@ds239128.mlab.com:39128/serverless').then(
    conn => {
      connection = conn;
      return connection;
    }
  );
};

(...)

그리고, 이미 데이터베이스가 설정 된 상태에서 새로 모델을 정의하게 되면 오류가 발생합니다. 따라서, Story 모델 파일도 변경을 해주겠습니다.

src/models/Story.js

const mongoose = require('mongoose');

const StorySchema = new mongoose.Schema({
  title: String,
  body: String
});

// Cannot overwrite model once compiled. 이슈 해결
global.Story = global.Story || mongoose.model('Story', StorySchema);
module.exports = global.Story;

이제 다시 배포를 하고 요청을 하고나면, 처리기간이 단축 된 것을 확인 하실 수 있습니다.

현재 200ms 수준으로 단축된것을 확인하실 수 있을텐데요, 나중에 여러분이 MongoDB 서버를 AWS EC2 로 돌리시게 된다면, 더욱 단축 할 수 있을 것입니다.

그럼, 나머지 기능들도 구현해줍시다.

데이터 리스팅 – readStories

여러개의 스토리 데이터를 불러오는 readStories 함수를 완성해주겠습니다.

src/stories.js

// 여러개의 스토리 리스팅
exports.readStories = (event, ctx, cb) => {
  ctx.callbackWaitsForEmptyEventLoop = false;
  connect().then(
    // 역순으로, 최대 20개 리스팅
    () => Story.find().sort({ _id: -1 }).limit(20).lean().exec()
  ).then(
    stories => cb(null, createResponse(200, stories))
  );
};

여기서 .lean() 을 한 이유는 plain JSON 객체를 받아오기 위함 입니다.

배포 후 제대로 작동하는지 확인하세요.

GET /stories

이제, 특정 스토리에 하는 작업을 완성하겠습니다.

특정 스토리 읽기 – readStory

_id 값을 사용하여 특정 데이터를 조회하는 API 를 만들겠습니다.

src/stories.js

// 특정 스토리 읽기
exports.readStory = (event, ctx, cb) => {
  ctx.callbackWaitsForEmptyEventLoop = false;
  connect().then(
    // 역순으로, 최대 20개 리스팅
    () => Story.findById(event.pathParameters.id).exec()
  ).then(
    story => {
      if (!story) {
        return cb(null, { statusCode: 404 });
      }
      cb(null, createResponse(200, story));
    }
  );
};

배포를 하고 요청을 해보세요.

id 파라미터는 우리가 아까 리스팅 API 를 호출 했을 때 나타났던 _id 값 (예: 5a8823a57b1e40000137a0f2) 을 넣어주시면 됩니다.

GET /stories/5a8823a57b1e40000137a0f2

특정 스토리 삭제 – deleteStory

이번엔, 특정 스토리를 삭제하는 기능을 구현하겠습니다.

src/stories.js

// 스토리 삭제
exports.deleteStory = (event, ctx, cb) => {
  ctx.callbackWaitsForEmptyEventLoop = false;
  connect().then(
    // 역순으로, 최대 20개 리스팅
    () => Story.remove({ _id: event.pathParameters.id }).exec()
  ).then(
    () => cb(null, createResponse(204, null))
  );
};

특정 스토리 업데이트

마지막 API 인 특정 스토리 업데이트 함수를 구현해주겠습니다. 이 API 에선 event 의 body 값도 사용하고, pathParameters 값도 사용합니다.

src/stories.js

// 스토리 수정
exports.updateStory = (event, ctx, cb) => {
  ctx.callbackWaitsForEmptyEventLoop = false;
  const update = JSON.parse(event.body);
  connect().then(
    // id 로 찾아서 업데이트
    () => Story.findOneAndUpdate({ _id: event.pathParameters.id }, update, { new: true }).exec()
  ).then(
    story => {
      if (!story) {
        return cb(null, { statusCode: 404 });
      }
      cb(null, createResponse(200, story));
    }
  );
};

드디어! 모든 작업이 끝났습니다.

정리

우리는 Serverless 프레임워크를 통하여 RESTful CRUD API 를 개발해보았습니다. Node.js 를 사용해서 express, koa 등을 사용해본 적이 있다면, 조금 불편했을 것입니다. 예를 들어서, 라우트를 하나하나 다른 함수로 설정해야 된다는점, 모든 함수에서 데이터베이스를 연결해야된다는점 등등…

이어질 튜토리얼에서는, 서버리스 애플리케이션 개발을 조금 더 즐겁고 편하게 개발하기 위해서 다음 사항들을 다뤄보겠습니다:

  • ESLint 설정하기
  • Babel, Webpack 설정하기
  • serverless-http 를 통하여 Koa, Express 등의 Node.js 웹 프레임워크와 함께 사용하기

Reference

  • Bruce Kwon

    강좌 잘 보고 있습니다. 에러가 나서 확인을 해보니, sls deploy했는데 models 디렉토리가 aws에 보니 없습니다.
    로컬드라이브에 .serverless안에 .zip파일은 분명있는데요. 이게 어떤 현상일까요?

    • 해당 이슈는 정상입니다. 다른 파일들은 S3 로 올라가게 될 거에요.
      오류가, 아마 다른 이유때문에 발생 했을 것 같은데, 한번 모니터링 -> 로그로 이동을 해서 확인을 해보세요.

      • Bruce Kwon

        아, 그런건가요? ^^; 지금 재밌는 현상이 발견되었는데요. models디렉토리를 aws에 수동으로 생성시켰더니 갑자기 Story.js가 파일이 들어오네요. 흐미..
        여튼 다시 해볼께요. 빠른 답변 감사드립니다. 🙂

  • 우기게

    다음 강좌가 정말 기대됩니다 항상 좋은 강의 감사합니다. 얼른 책나오면 리액트 책도 구입하고 싶어요

  • 문상호

    튜토리얼 잘 보았습니다 🙂 좋은 정보 감사합니다! 질문이 하나 있는데요.
    기존에 사용하고 있는 접속정보를 재사용하기 위해서 connection 변수에 정보를 할당했는데, 처음 할당한 이후 계속해서 그 정보가 사라지지 않고 유지되는 건가요?? AWS 에서 Lambda 서비스를 구현하기 위해서 결국에는 내부적으로 서버를 운용할 거라고 예상되는데, 서버가 꺼지지 않고 계속해서 유지되는 걸까요?

  • harry

    훌륭한 예제 잘 보았습니다. connection.readystate부분은 진작 보았더라면 삽질을 안했을텐데 ㅎㅎ lambda의 warm state와 cold state때문에도 처음에 많이들 삽질하게되죠. 개인적으로 운영하는 서비스에서 현재 테스트는 이상이 없는데 위와 같은 캐싱 방식으로 커넥션을 계속 해준다면 moongoose에서 bufferMaxEntries옵션과 bufferCommands을 구지 false로 주어야하나요?

  • leejh3224

    따라가다보니 몇 가지 이슈가 있어서 여기다가 남겨봅니다.

    1. 계속 internal server error가 떠서 로그를 확인해보니
    unexpected token ) 이 뜨길래 이게 prettier 때문에 생기는 것 같더군요.

    ex)
    .then(() =>
    Story.find()
    .sort({ _id: -1 })
    .limit(20)
    .lean()
    .exec(),
    ) // 이런 식으로 , 다음 라인에 괄호가 위치하면 에러가 발생하는 것 같습니다.

    2. deleteStory에서 remove를 쓰면 MongoDB Error: Cannot use retryable writes with limit=0에러가 발생하는데 이 경우에는 findOneAndRemove를 대신 사용하니까 잘 되네요

    그리고 로그 확인 같은 경우에는 sls logs -f 함수이름 요렇게 하면 됩니다.

    잘 보고 가요~