API Gateway

API Gateway에서 WebSocket API 사용

카니슈카 2020. 11. 30. 14:47

양방향 통신 WebSocket API

AWS API Gateway에서는 Web Socket API를 사용하여, 양방향 통신을 가능케 할 수 있다.

 

HTTP 기반 API는 기본적으로 클라이언트가 서버에 요청 (request)을 하고 요청받은 서버는 클라이언트로 응답 (response)을 내려주는 모델을 사용한다. 반면, WebSocket 기반 API들은 기본적으로 양방향 통신의 기능을 가지고 있다. 

양방향이란?
클라이언트는 서버로 메시지 전송을 할 수 있으며, 반대로 서버도 독립적으로 클라이언트에게 메시지 전송이 가능하다.

 

양방향 통신이 가능해지면, 서버는 HTTP 기반 API처럼 클라이언트로부터 요청을 받아 메시지를 전송하는 게 아니라, 클라이언트 요청 없이 연결된 클라이언트들에게 직접 메시지를 PUSH 할 수 있다. 그렇기 때문에, WebSocket API들은 실시간 어플리케이션 (e.g., 채팅 프로그램, 협업 플랫폼, 금융 트레이딩 플랫폼 등)을 구현할 때 주로 사용된다.

 

WebSocket API

기본적으로 WebSocket API를 만들기 위해서는 WebSocket 프로토콜을 지원하면서 "Persistent Connection"을 관리할 수 있는 서버들을 세팅해야 한다. 그러나, AWS API Gateway를 사용하면, 더 이상 WebSocket 프로토콜을 지원하는 서버들을 사용할 필요가 없다. AWS Gateway에서는 클라이언트와 서버 간 연결 (connection)을 관리해주고, AWS 람다, Kinesis 혹은 다른 HTTP endpoint들과 같은 HTTP 기반 백엔드를 사용하여 비즈니스 로직 구현하면 된다.

 

자, AWS API Gateway에서 제공하는 WebSocket API에 대한 몇 가지 컨셉에 대해서 알아보자.

Route

Route 라고 불리우는 새로운 리소스 타입이 추가되었다. Route는 API Gateway가 특정 타입의 클라이언트 요청을 어떻게  처리해야 하는 지 나타내며, routeKey 파라미터를 포함하고 있다. 여기서 routeKey는 route를 확인(identity)하기 위해 제공되는 값이라고 보면 된다.

 

WebSocket API는 하나 혹은 다수의 route로 구성되어 있다. 특정 inbound 요청이 어떠한 route를 사용해야 하는 지 정하려면, route selection expression이라는 것을 제공해야 한다. 이 expression은 inboud 요청을 가지고 경로의 routeKey 값 중 하나에 해당하는 값을 생성한다.

 

아래와 같이 route를 사용하기 위한 3개의 특별한 routeKey가 존재한다.

  • $default
  • $connect
  • $disconnet

Build a serverless, real-time chat application

아래는 실 시간 채팅 어플리케이션을 가지고 WebSocket API가 어떠한 식으로 쓰이는 지에 대해 설명하고자 한다. 간단하게 구현하기 위해, 이 어플리케이션에는 하나의 채팅 방만이 존재한다고 가정하려고 한다. 앱 기능에 대해 간단히 설명하면,

  • 클라이언트는 WebSocket API를 사용하여 채팅방에 Join 한다.
  • 백엔드 서버는 callback URL을 통해 특정 사용자에게 메세지를 보낼 수 있다. 그리고 이 callback URL은 사용자가 WebSocket API에 접근한 뒤에 제공되어 진다.
  • 사용자들은 채팅방에 메시지들을 보낼 수 있다.
  • 연결이 끊긴 클라이언트들은 채팅방에서 제거된다.

아래는 실시간 채팅 어플리케이션에 대한 overview이다.

 

A serverless real-time chat application using WebSocket API on Amazon API Gateway

위 어플리케이션에 대해서 간략히 설명하면,

 

  1. 클라이언트와 서버 간 연결을 핸들링하는 WebSocket API (AWS API Gateway 제공)로 구성되어 있다.
  2. 클라이언트가 연결될 때 (onConnect), 2번 경로를 통해 람다 함수가 실행된다. 람다 함수는 연결된 클라이언트 정보를 DynamoDB에 저장한다.
  3. 클라이언트는 3번 경로를 통해 메시지를 람다 함수로 전달할 수 있다.
  4. 이 때, 4번 경로를 통해 해당 메시지가 연결되어 있는 모든 클라이언트들에게 전달된다. 연결되어 있는 클라이언트 정보를 획득하기 위해서, 2번 과정을 통해 DynamoDB에 저장된 클라이언트 정보들을 꺼내서 확인한다.
  5. 클라이언트 연결이 끊기면 (onDisconnect), 5번 경로를 통해 람다 함수가 실행된다.

위 과정을 빠르게 실습해보기 위해서는 아래 Serverless Application Respository (SAR)에 저장되어 있는 샘플을 가지고 배포 및 실행해보면 된다.

 

serverlessrepo.aws.amazon.com/#!/applications/arn:aws:serverlessrepo:us-east-1:729047367331:applications~simple-websockets-chat-app 

 

Application Search - AWS Serverless Application Repository

 

serverlessrepo.aws.amazon.com

실제 코드를 확인해보려면 아래 Github 주소를 확인해보면 된다.

github.com/aws-samples/simple-websockets-chat-app

 

aws-samples/simple-websockets-chat-app

This SAM application provides the Lambda functions, DynamoDB table, and roles to allow you to build a simple chat application based on API Gateway's new WebSocket-based API feature. - aws-samp...

github.com

 

자, 그러면 WebSocket API를 실제로 생성해보자

 

  1. API Gateway console에서 "Create API, New API" 선택
  2. "Choose the protocol" 내에 존재하는 "WebSocket" 선택
  3. "API Name"으로는 "My Chat API" 사용
  4. "Route Selection Expression"에는 "$requeset.body.action" 사용
  5. "Create blank API" 선택
  6. "Create" 선택

Route Selection Expression 값으로 표현된 속성은 클라이언트가 API 호출할 때마다 전송되는 모든 메시지에 포함되어야 한다. 아래의 예제를 보면,

{
    "action": "sendmessage",
    "data": "Hello, I am using WebSocket APIs in API Gateway."
}

이전 API Gateway의 WebSocket API 설정 시, Route Selection Expression으로 $request.body.action을 넣었다. 이 의미는 클라이언트 요청의 body 내에 "action" 키에 mapping된 값으로 route한다는 의미이다. WebSocket API는 기본적으로 JSON 형태의 메시지를 가정하고 있다.

Manage routes

클라이언트 요청에 대한 응답으로 new API를 설정해보자. 이전에 설명하였던 3개의 route들을 생성한다. 우선적으로, "sendmessage" route를 람다 함수에 연결시킨다.

 

  1. API Gateway console 화면 내, My Chat API에서 Routes 선택
  2. 새로운 Route Key를 위해서 sendmessage를 입력하고 confirm 진행

 

각각의 route들은 연결될 target 정보 뿐만 아니라 모델 스키마와 같이 route-specific한 정보들을 포함하고 있다. sendmessage route가 생성되고 난 뒤, 메시지를 보낼 역할을 하는 람다 함수를 생성한다.

 

람다 함수는 AWS Serverless Application Repository 백엔드나 AWS SAM을 통해서 배포할 수 있다. 배포된 뒤, 람다 console 화면을 보면,  해당 람다 함수는 클라이언트들 중 하나로부터 데이터를 받고, 현재 연결된 모든 클라이언트들을 찾아서 각각의 모든 클라이언트들에게 전달받은 데이터를 전송한다. 아래는 람다 함수 샘플 중 일부분을 나타낸다.

 

DDB.scan(scanParams, function (err, data) {
    // some code omitted for brevity
    var apigwManagementApi = new AWS.ApiGatewayManagementApi({
        apiVersion: "2018-11-29",
        endpoint: event.requestContext.domainName " /" + event.requestContext.stage
    });
    var postParams = {
        data: JSON.parse(event.body).data
    };
    let callbackArray = data.Items.map( async(el) => { ... });
    Promise.all(callbackArray);
    // some code omitted for brevity
});

연결된 클라이언트들 중 하나의 클라이언트에서 sendmessage action을 담은 메시지를 보냈을 때, 해당 함수는 DynamoDB 테이블을 스캔한다. 그래서, 연결된 모든 클라이언트들을 찾는 작업을 진행한다. 그 후, 각각의 연결된 클라이언트들에게 메시지를 보내기 위해 postToConnection 호출을 한다.

 

메시지를 정확히 보내기 위해서는 클라이언트가 연결된 API를 알아야 한다. 이는 API를 가리키도록 SDK endpoint를 명시적으로 설정하는 것을 의미한다.

 

다음으로는 채팅방에 연결된 클라이언트들에 대한 트래킹을 하는 작업이다. 이 작업을 위해서 2개의 routeKey 값 즉 $connect와 $disconnect를 위한 route들을 구현해야 한다.

 

onConnect 함수는 reqeustContext로부터 DynamoDB 테이블에 connectionId 값을 넣는 작업을 한다.

 

exports.handler = function(event, context, callback) {
  var putParams = {
    TableName: process.env.TABLE_NAME,
    Item: {
      connectionId: { S: event.requestContext.connectionId }
    }
  };

  DDB.putItem(putParams, function(err, data) {
    callback(null, {
      statusCode: err ? 500 : 200,
      body: err ? "Failed to connect: " + JSON.stringify(err) : "Connected"
    });
  });
};

 

onDisconnect 함수는 DynamoDB 테이블에서 연결이 끊긴 클라이언트의 connectionId 값에 해당되는 record를 삭제한다.

exports.handler = function(event, context, callback) {
  var deleteParams = {
    TableName: process.env.TABLE_NAME,
    Key: {
      connectionId: { S: event.requestContext.connectionId }
    }
  };

  DDB.deleteItem(deleteParams, function(err) {
    callback(null, {
      statusCode: err ? 500 : 200,
      body: err ? "Failed to disconnect: " + JSON.stringify(err) : "Disconnected."
    });
  });
};

다시 사용할 수 없는 연결 (staled connection)을 사용하지 않도록 하기 위해서, postToConnection 호출이 성공하지 못할 경우를 대비해 몇 가지 추가적인 로직을 사용하였다.

  const postCalls = connectionData.Items.map(async ({ connectionId }) => {
    try {
      await apigwManagementApi.postToConnection({ ConnectionId: connectionId, Data: postData }).promise();
    } catch (e) {
      if (e.statusCode === 410) {
        console.log(`Found stale connection, deleting ${connectionId}`);
        await ddb.delete({ TableName: TABLE_NAME, Key: { connectionId } }).promise();
      } else {
        throw e;
      }
    }
  });

위에서 보는 바와 같이 API Gateway는 클라이언트와의 연결이 더 이상 유효하지 않을 경우, 410 GONE 상태 메시지를 전달한다. 이와 같은 에러 메시지가 생성되면, DynamoDB 테이블로부터 해당 identifier (connectionId)를 삭제하도록 한다.

 

연결된 클라이언트들을 호출하기 위해서는 "execute-api:ManageConnections" 이라는 새로운 permission이 필요하다.

 

Deploy the WebSocket API

다음 단계는 WebSocket API를 배포를 해보자. 첫 배포이기 때문에, stage를 생성해야 한다. 

 

Stage editor 화면에서는 아래와 같이 WebSocket API를 관리하기 위한 모든 정보들을 제공한다.

  • Settings
  • Logs/Tracing
  • Stage Variables
  • Deployment History

 

Test the chat API

WebSocket API 테스트 목적으로 wscat 유틸을 사용하려고 한다.

$ npm install -g wscat

 

  • 콘솔 화면에서 아래 명령어를 실행하여 publish된 API endpoint에 연결한다.
$ wscat -c wss://{YOUR-API-ID}.execute-api.{YOUR-REGION}.amazonaws.com/{STAGE}

 

  • sendMessage 함수에 대한 테스트를 위해서, 아래와 같은 JSON 메시지를 전달한다. 람다 함수는 콜백 URL을 사용하여 전달받은 메시지를 연결된 모든 클라이언트들에게 전달한다.
$ wscat -c wss://{YOUR-API-ID}.execute-api.{YOUR-REGION}.amazonaws.com/dev
connected (press CTRL+C to quit)
> {"action":"sendmessage", "data":"hello world"}
< hello world

여러 개의 콘솔 화면을 띄워두고 wscat을 실행시키면, 각각의 콘솔 화면에서 "hello world" 문장을 모두 볼 수 있을 것이다. 만약, 응답값으로 hello world가 제대로 표현되지 않을 때에는 IAM에 들어가서 SendMessageFunctionRole로 검색한 뒤, Execute API에 대한 Resource ARN이 제대로 설정되어 있는 지 확인을 해야 한다. (All resources로 설정해서 제대로 되는 지 확인 필요)

 

드디어 WebSocket API를 통해서 메시지를 전달하고 전달받을 준비가 되었다!

 

아래는 WebSocket API에 대한 유튜브 영상이다. 보면 WebSocket API에 대한 이해를 좀 더 넓힐 수 있을 것이다.

www.youtube.com/watch?v=3SCdzzD0PdQ