AWS 람다로 서버 없이 간단한 REST API 만들기 - 경험담 기술 이야기 2017.08.24 23:26

제 서버에 기생하던 아주 간단한 API가 있었습니다. 필요는 한데 서버를 따로 두는 것은 너무 큰 낭비라서 일단 넣어놨습니다만, 계속 기생시키는 것은 여러모로 찝찝하던 찰나에 문득 AWS 람다를 이용한 Serverless Architecture 가 생각나서 사용해 보기로 했습니다.ㅋ 이미 유명한 서비스라 자세한 설명보다는 경험 위주로 정리해 봅니다.


목표

간단하게 DB Data를 가져오고 업데이트하는 GET, PUT, POST API를 서버 구축 없이 만들기 (DB 구성은 별도, 추가 설명 생략)

서비스 구성

일단 AWS 람다와 API 게이트웨이를 사용해서 만들려고 하는데, 그것이 어떤 것인지는 아래 링크로 대신합니다. 정리를 잘하셨네요. :)

[AWS]서버없이 Lambda와 API Gateway로 서버API 만들기

저는 람다에서 Java를 사용하려합니다. 이유는 단지, Java로 이미 구현되어 있기에...ㅋㅋ 마음이 바뀌었습니다. nodejs로 가렵니다. 가장 큰 이유는, 노드는 그냥 콘솔에서 작업이 가능한데, Java.zip을 업로드 해야 하네요. 물론 노드도 모듈을 올리기 위해서는 .zip으로 한번은 올려야 합니다. 한번만 하면 다음부터는 코드 오버라이딩이 가능합니다. 넘 좋아..ㅠ

DB는 AWS RDS 사용중입니다. DB도 기생 중인데, DB는 그대로 사용하려 합니다. 이후에 나오지만 DB의 위치에 따라서 VPC 설정이 좀 달라집니다.

단계별 경험담

이제 단계별로 썰을 풀어 보겠습니다.

레퍼런스

위에서 소개한 블로그에도 설명이 잘 되어 있지만 가능하면 레퍼런스 가이드를 보고 작업하는 것이 좋습니다. 제가 직접 만들면서 이 글을 준비하는 과정에서도 AWS 콘솔 UI가 싹 바뀌었네요.

후...이렇게 캡쳐 이미지는 무의미해지고... 그냥 화면 캡쳐 없이 글로만 쓸랍니다.

아래 링크가 레퍼런스 가이드입니다. 한글이 좀 어색하네요. 가능하면 영어로 보시거나 둘 다 보세요. 그리고 콘솔도 가능하면 영어로 하는 것이 메뉴 찾기가 더 편합니다.ㅋ

Lambda 함수를 이용하여 API 구축

1단계: 필수 조건
2단계: API 생성
3단계: 리소스 생성
4단계: Lambda 함수 생성
5단계: GET 메서드 생성 및 테스트
6단계: POST 메서드 생성 및 테스트
7단계: API 배포
8단계: API 테스트
9단계: 정리

1단계 필수조건

필수조건 보다는 사전 준비가 좋을 것 같습니다.ㅎ

IAM 유저 만들고, 그룹을 만들어서 권한을 줍니다. 저도 가이드대로 몇몇 권한을 주었는데, 막상 필요한 것은 아니였습니다.;;

2단계: API 생성

다른 AWS 서비스처럼 보이는 것보다, 생각보다 엄청 간단합니다. 설정 할 수 있는 것은 많지만 몇 개만 만져도 사용하는데 지장이 없습니다.ㅎ

아래 가이드를 보고 직관적으로 만들면 됩니다. 리소스메소드로 구분이 되어 있네요.

단계별 API 구축

3단계: 리소스 생성

2단계에서 만들어 놓은 API에 리소스를 생성합니다. URL의 Path를 만든다고 생각하면 됩니다. path parameter를 생성 할 수도 있습니다.

4단계: Lambda 함수 생성

3단계에서 만들어 놓은 API는 잠시 그대로 두시고 람다 함수를 먼저 만듭니다. 콘솔에서 람다를 클릭하고, 과감하게 Get Started Now 를 클릭합니다.ㅋ

여러개의 샘플 함수가 제공되는데, 아무거나 선택해서 작업하심 됩니다.

Configure triggers 는 나중에 API Gateway 콘솔에서 설정해 줄 것이므로 일단 넘어갑니다. Next 클릭

그리고, 가이드에 나와있는 코드 대신 실제 코드 작성하셔서 만들면 됩니다.

여기서 GET에 사용할 ranking_get_sample.js를 아래 붙이겠습니다. 간단한 코드라서...^0^

//ranking_get_sample.js
var BreakException = {};
var mysql = require('mysql');
var async = require('async');
//create connection pool
var pool  = mysql.createPool({
    connectionLimit : 20,
    host     : 'xxxxxxx.xxxxxx.rds.amazonaws.com',
    user     : 'user',
    password : 'password',
    port     : '3306',
    database : 'game'
});

exports.handler = function(event, context, callback){
    var count;
    //top은 매핑된 쿼리파라미터 값입니다.
    if (!event.top || isNaN(event.top)) {
        context.fail('Bad Request: You submitted invalid input');
        return;
    } 

    count = Number(event.top);

    //비동기처리를 위해서 async를 사용하였습니다.
    async.waterfall([
        function (cb) {
            //Weekly Ranking을 가져옵니다.
            pool.query(
                'SELECT Name, Country, Point FROM RankingTable WHERE YEARWEEK(`UpdateTime`, 1) = YEARWEEK(CURDATE(), 1) ORDER BY Point DESC, Updatetime ASC',
                function(err, rows, fields) {
                    if (!err) {
                        //정상적으로 가져오면 데이터와 함께 다음 함수를 호출합니다.
                        cb(null, rows);
                    } else {
                        //에러가 나면 에러를 전달합니다.
                        console.log('Error while performing Query.', err);
                        cb(500);
                    }
                }
            );
        }],
        function (err, result) {
            if (!err) {
                if (result.length > 0) {
                    var res = [];

                    //쿼리 파라미터로 받은 top 만큼만 응답메세지로 만들어서 보냅니다.
                    try {
                        result.forEach(function(value, index) {
                            if (index >= count) throw BreakException;

                            var temp = {};
                            temp.name = value.Name;
                            temp.point = value.Point;
                            res.push(temp);
                        });
                    } catch (e) {
                        if (e !== BreakException) throw e;
                    }
                    context.succeed(res);
                } else {
                    context.succeed('');
                }
            } else {
                //에러는 코드만 받아서 처리하도록 했습니다.
                if (err === 400) {
                    context.fail('Bad Request: You submitted invalid input');
                } else { 
                    context.fail('Internal Error: Internal Error');
                }
            }
        } 
    );
};

노드 모듈을 같이 올려줘야 해서 로컬에서 npm install mysql --save등으로 관련 패키지를 로컬에 설치하신 다음 node_modules 폴더와 js파일을 함께 압축해서 업로드합니다. (예제는 async도 설치 필요)

관련 패키징 관련 가이드는 아래 링크에 있습니다.

Creating a Deployment Package (Node.js)

!!! 한번 압축해서 올리고 나면 다음부터는 콘솔에서 코드만 수정할 수 있습니다. :)

handler 는 업로드한 js 파일명과 같아야 합니다. 예를 들어 index.js 일 경우 index.handler 로 설정

// handler가 잘 못 설정되면 아래 에러가 발생
"errorMessage": "Cannot find module '/var/task/index'",

나머지는 가이드대로 설정합니다.

DB 연결 방법

중요한 부분입니다! RDS와 연동하려면 Advanced settings 에서 VPC를 연결해 줍니다. 컴퓨터에 랜선을 꼽고 IP 설정해주는 과정이라고 생각하심 됩니다.

이때 DB가 같은 VPC에 있는지에 DB의 접근권한 설정이 어떻게 되어 있는지에 따라서 해야줘 할 일이 달라집니다. 보안과 관련되어 있어서 중요한 부분입니다.

제 RDS는 외부에서 접근이 안됩니다. RDS Security Group 에서 ELB등의 Security Group에만 권한을 줬고, 여기서 만든 람다도 그 Security Group을 사용하였습니다.

VPC에서 DB 인스턴스에 액세스하는 시나리오

All AWS Lambda functions run securely inside a default system-managed VPC. However, you can optionally configure Lambda to access resources, such as databases, within your custom VPC.

다 만들고 나서, 테스트를 해 볼 수 있습니다. 정상동작하는지 확인 하고 다음 스텝으로!

5단계: GET 메서드 생성 및 테스트

이제 다시 API Gateway 콘솔로 돌아갑니다. 3단계에서 만들어 놓은 리소스에 메소드를 생성합니다.

이쯤에서 한번 쯤은 화면 캡쳐가 추가 될 것 같은데...UI가 싹 바뀌어서...(사실 이미지 올릴라믄 귀찮음ㅋㅋㅋㅋㅋㅋ)

쿼리 파라미터를 람다 함수로 전달하기

REST API라면 쿼리 파라미터와 패스 파라미터를 사용합죠. URL 뒤에 ?parameter=Jay 이렇게 붙는 Query Parameter와 Path Parameter. 이것을 람다 함수로 전달 하는 부분은 Method Request 에서 API 에 들어오는 파라미터를 정의해주시고, Integration Request 에서 본문에 맵핑을 해주시면 됩니다.



이에 대한 레퍼런스 가이드는 아래있습니다.
API Gateway API 메서드 및 통합 설정

테스트 버튼을 눌러서 테스트를 해봅니다. 예~ 성공!

6단계: POST 메서드 생성 및 테스트

POST 메서드는 GET 메서드와 동일합니다.

여기서 POST에 사용할 ranking_post_sample.js를 아래 붙이겠습니다. 잘못된 부분이나 코멘트는 언제든 댓글로 부탁드립니다.

//ranking_post_sample.js
var mysql = require('mysql');
var async = require('async');
//create connection pool
var pool  = mysql.createPool({
    connectionLimit : 20,
    host     : 'xxxxxxx.xxxxxxxx.rds.amazonaws.com',
    user     : 'user',
    password : 'password',
    port     : '3306',
    database : 'game'
});

exports.handler = function(event, context, callback){
    //통합 요청으로 들어온 파라미터와 컨텐츠를 확인 합니다.
    if (!event.point || isNaN(event.point)
        || !event.name || !event.ip) {

        context.fail('Bad Request: You submitted invalid input');
        return;
    }

    async.waterfall([
        function (cb) {
            //Get Connection from Pool
            pool.getConnection(
                function(err, connection) {
                    if (!err) {
                        cb(null, connection);
                    } else {
                        console.log("Connection Error: " + err);
                        //GET과는 달리 connection도 항상 같이 넘겨줍니다.
                        //반드시 connection.release를 수행하기 위함입니다.
                        cb({statusCode: 500, connection: connection});
                    }
                }
            );
        },
        function (data, cb) {
            //Put a Record to DB
            data.query(
                "INSERT INTO RankingTable (name, point, ip) SELECT ?, ?, ? FROM DUAL WHERE NOT EXISTS (SELECT * FROM RankingTable WHERE YEARWEEK(`UpdateTime`, 1) = YEARWEEK(CURDATE(), 1) and `Point` = ? and `Name` = ?)",
                [event.name, event.point, event.ip, event.point, event.name],
                function(err, rows, fields) {
                    if (!err) {
                        console.log('Insert OK!');
                        cb(null, {connection: data});
                    } else{
                        console.log("Query Error: " + err);
                        cb({statusCode: 500, connection: data});
                    }
                }
            );
        },
        function (data, cb) {
            //Get Ranking
            data.connection.query(
                "SELECT COUNT(*) as count FROM RankingTable WHERE YEARWEEK(`UpdateTime`, 1) = YEARWEEK(CURDATE(), 1) and `Point`>= ? ORDER BY Point DESC, Updatetime ASC",
                [event.point],
                function(err, rows, fields) {
                    console.log('Inside get ranking callback');
                    if (!err) {
                        if (rows[0].count > 0) {
                            cb(null, {ranking: rows[0].count, connection: data.connection});
                        } else {
                            cb(null, {ranking: 1, connection: data.connection});
                        }
                    } else {
                        console.log("2nd Query Error: " + err);
                        cb({statusCode: 500, connection: data.connection});
                    }
                }
            );
        }
        ],
        function (err, result) {
            if (!err) {
                if (result.connection)
                    result.connection.release();

                context.succeed(result.ranking);
            } else {
                if (err.connection)
                    err.connection.release();

                if (err.statusCode && err.statusCode === 400) {
                    context.fail('Bad Request: You submitted invalid input');
                } else { 
                    context.fail('Internal Error: Internal Error');
                }
            }
        } 
    );
};

Request Parameter와 Content 맵핑

GET에서 쿼리 파라미터를 람다 함수로 전달하는 방법을 소개했는데, PUTPOST는 content를 가지고 있어서 그것도 함께 묶어서 전달해야 합니다. 그것을 우리말로 통합 요청, 통합 요청을 위한 맵핑으로 표현을 했네요.ㅎ

레퍼런스 가이드를 보고 찬찬히 따라 해보심 되는데, 간단하게 절차만.

  1. 우선 모델을 만들고,
  2. Method Request 에서 Request Body 에서 그 모델을 설정합니다.
  3. 그리고 Integration Request 에서 Body Mapping Templates 을 설정해 줍니다.

저기에 사용된 IP를 가져오는 파라미터에 대한 내용은 아래 레퍼런스에서 확인 하실 수 있습니다.

API Gateway 매핑 템플릿 기본 제공 함수 및 변수

Response 에 Http Status Code 맵핑

요청을 매핑하는 것과 비슷하게 처리하면 됩니다. 저도 아래 글을 보고 참고 했으요. 설명은 링크로 대체합니다. 채택된 답변이 아닌 그 아래 답변입니다.

Is there a way to change the http status codes returned by Amazon API Gateway?

아...점점 힘이 빠집니다. 할게 되게 많으네요...;;; 근데, 한번만 해보면 쉬워요. :)

7단계: API 배포

작업 버튼에서 API 배포를 선택하고 단계를 만들면 Base URL이 생성이 되는데, 설정한 단계의 이름이 마지막에 붙습니다. 그리고 만들어진 API는 그 뒤로 접근이 가능합니다.

you can define a test stage and deploy your API to it, so that a resource named MyDemoAPI is accessible through a URI that ends in .../test/MyDemoAPI.

여기서 단계는 Stage를 번역한 것입니다. 좀 많이 이상합니다.

API를 배포할 준비가 되었습니다. 이 단계에서는 단계를 생성합니다.

8단계 테스트와 9단계 정리는 생략합니다.

내 도메인과 연결

위에서 레퍼런스 가이드를 따라서 간단한 API를 만들어 봤는데, 추가로 개인 도메인에 연결하는 부분까지 해봤습니다. 내 도메인에 API Gateway에서 생성한 API를 연결하는 부분에 대해서 소개합니다.

콘솔에서 Custom Domain Names 메뉴를 통해서 설정하면 되는데, ACM에 도메인을 먼저 설정해야 합니다.

To use your own domain name for an API and Stage, create a Custom Domain Name backed by an ACM certificate. Add Base Path Mappings to map a URL to an API and Stage.

API 호스트 이름에 대한 사용자 지정 도메인 이름 설정

ACM 발급받기

ACMAmazone Certificate Manager 서비스인데, 무료로 인증서를 발급해주고 별다른 설치 없이 ELBCloudFront등에서 사용할 수 있도록 해줍니다. 물론 기존 발급 받은 인증서를 추가하기도 가능합니다. 아주 간단합니다.

이 내용과 무관하게, 요즘 SSL은 기본이니까...비용이나 기술 문제로 적용하지 않았다면 적용하는 것을 추천합니다.

저는 기존에 하나 발급 받아서 ELB에서 사용하고 있었는데, 하나 더 만들었습니다. api.msalt.net 으로 받았습니다. 지금은 미국 동부(버지니아 북부) 리전의 ACM만 API Gateway에 적용이 가능하네요.

Amazone Certificate Manager에 대한 설명은 아래 링크로 대신하겠습니다. 링크 부자네요...^^;

AWS Certificate Manager(ACM) 서울리전 오픈 및 ELB에 적용하기

API Gateway에 내 도메인 설정하기

API Gateway 콘솔에서 사용자 지정 도메인 이름을 선택하고 도메인 이름을 생성하면 됩니다. ACM 인증서가 안보이면 인증서를 미국 동부(버지니아 북부) 리전에서 생성했는지 확인해 보세요.

도메인을 생성할 때 경로 매핑 절차가 있는데, 이때 Path와 API의 Stage도 선택할 수 있습니다!

이 과정에서 시간이 매우 오래 걸립니다. 문제가 있나 싶었는데, 가이드를 보니 40분이 소요된다고 하네요.

Route53 으로 라우팅

이제 마지막 단계입니다. 기본적으로 만들어진 모든 API 는 AWS의 CloudFront 로 배포가 된다고 합니다. 그러므로, 개인 도메인을 Route53을 이용해서 CloudFront로 라우팅 하면 됩니다. 마치, ELB로 라우팅 하는 것처럼 설정하면 됩니다.

Route53 콘솔로 들어가셔서 설정을 해야 하는데, Route53을 사용하지 않는 상태라면 흠...처음부터 작업을 해야 합니다. DNS도 변경해 줘야 하고요. 할게 많은데, 아래 글에 정리가 잘되어 있네요~ㅎ

Route 53 A 레코드 생성하기

정리

API Gateway를 통해서 API를 만들고, 그걸 Lambda 함수와 연계해서 DB에 데이터를 쓰고, 읽어 와서 리턴하도록 했습니다. 그렇게 REST API를 만든 후 개인 도메인에 연결하였습니다.

이제 기존에 제 서버에서 동작하던 REST API를 제 서버에서 제거하고, 다른 도메인에서 같은 역할을 하는 API를 만들었습니다. 이왕 작업한 김에 S3를 이용해서 웹 앱도 같은 도메인에서 배포했습니다. 이렇게 Serverless Web Application 을 만들었습니다~ㅎ

정말 긴 글이 되었네요. 하다보니 생각보다 어렵지 않고, 지원하는 것도 많고, 무료로 제공하기도 해서 앞으로 여러 분야에서 많이 쓰게 될 것 같습니다. 간단한 웹페이지나 API는 이제 뚝딱 만들 수 있을것 같아요~ㅎ

아, 요금 절약도 중요합니다! 이게 요청 수만큼 요금이 부과되는 것이기 때문에, Parameter를 따로 받아서 유효하지 않으면 에러처리 해버리고, 사용량 계획을 작성해서 한도도 만들어 놓는 것이 좋을것 같습니다. 요금 ㅎㄷㄷ