들어가며
이번 포스팅에서는 웹소켓을 통해서 채팅 서버를 구현하는 방법에 대해서 알아볼 예정이다. 먼저 채팅 서버의 가장 중요한 특징은 무엇일까? 그것은 바로 실시간이라는 것이다. 일반적으로 요청(Request)와 응답(Response)으로 이루어진 HTTP API와는 다르게 실시간으로 통신하는 것이 중요한 채팅 서버에서는 웹소켓(Websocket)이라는 프로토콜을 사용한다. 따라서 이번에는 웹소켓이라는 프로토콜을 사용할 예정이다.
위와 같은 채팅 서버를 구현하기에 앞서 우리는 AWS를 이용할 예정이다. 우리가 사용할 서비스는 IAM, Amazon Lambda, API Gateway, DynamoDB를 사용할 예정이다.
간단하게 프리뷰를 하자면 다음의 포스팅을 참고하였다. 해당 포스팅에서는 CloudFormation이라는 AWS의 IaC 서비스를 사용하는데, 빠르게 생성하고 싶다면 아래의 포스팅을 참고하여 CloudFormation을 사용하는 것을 권장한다.
- [AWS] Websocket 채팅 앱 생성 : https://docs.aws.amazon.com/ko_kr/apigateway/latest/developerguide/websocket-api-chat-app.html
본론
위 구조는 서론에 첨부한 레퍼런스에서 보여주는 구조이다. 우리는 위의 아키텍처대로의 설계를 진행할 예정이다. 간단하게 설명을 한다면, Lambda functions를 통해서 NoSQL 구조를 가진 DynamoDB에 연결에 관련된 클라이언트의 ID(connectionId)를 집어넣거나, 원한다면 추가로 커스터마이징하여 다양한 정보를 담을 수 있다.
이러한 Lambda들을 웹소켓 프로토콜에 맞게끔 설정을 해둔 후, API Gateway를 생성하여 Websocket의 방식으로 만들어, 사전에 만든 기본적인 lambda functions들과 sendmessage라는 함수를 연결하면 손쉽게 채팅 서버를 만들 수 있다. 그렇다면 대체 웹소켓은 무엇일까?
Websocket?
웹소켓(Websocket)이란 클라이언트와 서버 간의 양방향 통신을 가능하게 하는 프로토콜을 뜻한다. 기존의 HTTP 프로토콜과는 다르게 클라이언트가 서버와 지속적으로 연결된 상태를 유지하여 실시간으로 데이터를 주고받을 수 있게 하는 프로토콜이다.
실제로 AWS의 API Gateway라는 서비스에서 직접 서비스를 만들려면 프로토콜을 지정해야 하는 데, 이 부분에서 HTTP API 이외에도 Websocket API가 존재하는 것을 확인할 수 있다. 아래는 API Gateway의 콘솔이다.
Websocket API를 눌러 구축하려고 하면, $connect, $disconnect, $default 등 다양한 것들을 지정하라고 나와있다. 이러한 프로토콜의 컨밴션에 따라 람다 함수를 작성하고, 연결할 예정이다.
관련된 코드 및 수행 방법에 대해서는 모두 처음 레퍼런스에서 받을 수 있는 starter.yaml 파일에 있는 IaC 코드를 가져온 것이다. 따라서 원본 코드를 참조하고 싶다면 위의 AWS 자습서를 확인하는 것을 추천한다. 이제 본격적으로 AWS를 통해서 채팅 서버를 구현해 보자.
Amazon DynamoDB
먼저, 커넥션에 관련된 데이터를 저장할 NoSQL구조를 가진 Amazon DynamoDB를 생성한다. 테이블 이름은 적당히 chat-dynamodb로 지정하였다. 파티션 키로는 connectionId 반드시 쓰도록 하고 생성한다.
해당 DynamoDB는 연결이 될 때마다 connectionId 데이터를 쌓는 일을 한다. 나중에 Lambda 쪽에서 DynamoDB에 연결하기 위해서 해당 데이터베이스 명을 참조하기 때문에 꼭 이름을 기억해 두도록 한다.
Amazon Lambda
먼저 아마존 람다를 만들어보자. Lambda는 AWS에서 제공하는 서버리스 서비스로 서버리스(Serverless)란 서버가 없다는 것이 아닌, 전혀 관리할 필요가 없다는 점이다. 다양한 언어를 지원하는데, 특정 소스 코드를 Lambda function에 써두면, Lambda는 요청이 올 때마다 해당 소스 코드의 내용을 수행한다.
인프라 관점에서 봤을 때, 람다는 처리 속도(Throughput) 또한 상당하며 EC2와 같은 24시간으로 항상 켜져 있어 시간 단위로 비용이 발생하는 EC2와는 다르게, Lambda는 서비스의 호출 건수 당 비용이 발생하며, 일반적으로는 EC2에 비해 많은 비용을 아낄 수 있다. 추가적으로 엣지 로케이션 설정을 해주면, 글로벌 서비스에서는 훨씬 빠른 속도를 보장할 수 있기도 하다. 반면에 EC2는 단일 로케이션에서만 작동하게 된다.
먼저 Lambda를 생성하기 전에 앞서 IAM 생성을 먼저 진행하자.
IAM
IAM이라는 것은 AWS 내에서 서비스가 어떠한 대상에 대해 접근할 수 있는 권한을 부여하는 것을 말한다. 현재 상황에서는 Lambda가 DynamoDB 그리고 API Gateway에 대한 액세스 권한을 갖고 있어야 하기 때문에, 이러한 권한을 모두 갖는 IAM을 생성하도록 한다.
위 콘솔에서 역할 -> 역할 생성을 클릭한다.
IAM은 참고로, 서비스에게 서비스에 접근할 수 있는 권한을 부여하는 것이다. 따라서 Lambda를 1단계에서 지정해 준다.
그 후에는 해당 권한 AmazonAPIGatewayInvokeFullAccess를 클릭하여 Lambda와 API Gateway가 서로 상호작용할 수 있게 만들어준다. 또한 DynamoDB와도 서로 통신해야 하기 때문에, AmazonDynamoDBFullAccess를 넣어준다. 마지막으로 AWSLambdaBasicExecutionRole을 추가한다. 그렇게 되면 아래와 같은 권한을 가진 IAM이 만들어진다.
위와 같이 세 가지의 권한을 넣어 Lambda에게 물려줄 권한을 만든다.
그 후 마지막으로, 3단계에서는 적당한 이름을 써넣은 후 생성을 누르면 위와 같이 새로운 정책이 생긴 것을 확인할 수 있다.
Amazon Lambda
그다음으로, 람다를 생성해 보자. Amazon Lambda에 접속하여 기존에 만들었던 IAM을 추가하여 만드는 것을 잊지 말자.
위에서 함수의 정보들을 입력하고 생성하면 된다. 함수 이름은 간단하게 chat-lambda-*로 한다. 런타임은 Node.js 20.x 버전을 사용하도록 한다. 그 후 기본 실행 역할 변경 토글을 열어 기존 역할 사용으로 방금 만들었던 역할을 연결한다.
여기서 API Gateway의 프리뷰를 미리 하자면, 프로토콜의 구조상 $default, $connect, $disconnect에 대한 세 가지의 케이스에 해당하는 함수를 만들어야 한다. 따라서 chat-lambda-default, chat-lambda-connect, chat-lambda-disconnect 이 세 가지 람다가 기본적으로 만들어져야 한다. 여기서 추가로 우리는 sendMessage라는 Lambda를 만들고 추가적으로 경로를 하나 더 만들 셈이다. 결과적으로는 아래와 같이 만들어진다.
각각에 Lambda에 해당하는 코드 index.js는 아래의 코드를 사용한다. 참고로 그냥 Lambda를 만들면 index.mjs라는 확장자를 가지게 될 수도 있는데, 해당 .mjs 확장자에 코드를 집어넣으면 502 Bad Gateway 에러가 날 수 있다. 이제부터 총 네 가지의 코드를 써놓을 것이다. 각각에 해당하는 Lambda function에 코드를 집어넣는다. 여기서 한 가지 짚고 넘어가야 할 부분이 코드 중간에 process.env.TABLE_NAME이라는 값이 있다는 것이다. 따라서 람다에서 환경 변수를 직접 설정해줘야 하는데, 이는 아래와 같이 설정한다. 우리가 만들었던 DynamoDB의 테이블명을 입력해 주면 된다. (여기서는 'chat-dynamodb'를 사용했다)
위와 같이 Lambda 콘솔에서 구성 -> 환경 변수 -> 편집 과정을 거쳐 TABLE_NAME이라는 키의 값으로 DynamoDB의 테이블 명을 입력해 준다. 번거롭겠지만 모든 람다 펑션에 해주어야 한다.
// connect
const {DynamoDBClient} = require("@aws-sdk/client-dynamodb")
const {DynamoDBDocumentClient, PutCommand } = require("@aws-sdk/lib-dynamodb")
exports.handler = async function(event) {
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const command = new PutCommand({
TableName: process.env.TABLE_NAME,
Item: {
connectionId: event.requestContext.connectionId,
},
});
try {
await docClient.send(command)
} catch (err) {
console.log(err)
return {
statusCode: 500
};
}
return {
statusCode: 200,
};
}
// disconnect
const {DynamoDBClient} = require("@aws-sdk/client-dynamodb")
const {DynamoDBDocumentClient, DeleteCommand } = require("@aws-sdk/lib-dynamodb")
exports.handler = async function(event) {
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const command = new DeleteCommand({
TableName: process.env.TABLE_NAME,
Key: {
connectionId: event.requestContext.connectionId,
},
});
try {
await docClient.send(command)
} catch (err) {
console.log(err)
return {
statusCode: 500
};
}
return {
statusCode: 200,
};
}
// default
const {ApiGatewayManagementApiClient, PostToConnectionCommand, GetConnectionCommand} = require("@aws-sdk/client-apigatewaymanagementapi")
exports.handler = async function(event) {
let connectionInfo;
let connectionId = event.requestContext.connectionId;
const callbackAPI = new ApiGatewayManagementApiClient({
apiVersion: '2018-11-29',
endpoint: 'https://' + event.requestContext.domainName + '/' + event.requestContext.stage
});
try {
connectionInfo = await callbackAPI.send(new GetConnectionCommand(
{ConnectionId: event.requestContext.connectionId }
));
} catch (e) {
console.log(e);
}
connectionInfo.connectionID = connectionId;
await callbackAPI.send(new PostToConnectionCommand(
{ConnectionId: event.requestContext.connectionId,
Data:
'Use the sendmessage route to send a message. Your info:' +
JSON.stringify(connectionInfo)}
));
return {
statusCode: 200,
};
};
const {DynamoDBClient} = require("@aws-sdk/client-dynamodb")
const {DynamoDBDocumentClient, ScanCommand } = require("@aws-sdk/lib-dynamodb")
const {ApiGatewayManagementApiClient, PostToConnectionCommand} = require("@aws-sdk/client-apigatewaymanagementapi")
exports.handler = async function(event) {
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const ddbcommand = new ScanCommand({
TableName: process.env.TABLE_NAME
})
let connections;
try { connections = await docClient.send(ddbcommand);
} catch (err) {
console.log(err)
return {
statusCode: 500,
};
}
const callbackAPI = new ApiGatewayManagementApiClient({
apiVersion: '2018-11-29',
endpoint: 'https://' + event.requestContext.domainName + '/' + event.requestContext.stage,
});
const message = JSON.parse(event.body).message;
const sendMessages = connections.Items.map(async ({connectionId}) => {
if (connectionId !== event.requestContext.connectionId) {
try {
await callbackAPI.send(new PostToConnectionCommand(
{ ConnectionId: connectionId, Data: message, }
));
} catch (e) {
console.log(e);
}
}
});
try {
await Promise.all(sendMessages)
} catch (e) {
console.log(e);
return {
statusCode: 500,
};
}
return{statusCode: 200};
};
위 코드를 각각 집어넣어 네 가지의 Lambda 함수를 만든다. 이러한 과정이 끝났다면, 이제는 API Gateway로 넘어갈 차례이다. 이렇게 만들어진 각각의 serverless 람다 함수들을 합쳐서 웹소켓을 통한 채팅 서버를 만들 차례이다.
API Gateway
dynamoDB와 Lambda function의 빌드가 끝났다면 API Gateway를 통해 최종 등록하는 일만 남았다. 아래의 API Gateway 콘솔에 접근한다.
해당 섹션에서 WebSocket API를 누른다.
위와 같이 API 이름은 적당한 것으로, 라우팅 선택 표현식에는 request.body.action을 입력한다.
그다음으로는 $connect, $disconnect, $default에 해당하는 라우팅 키들을 전부 추가하고 사용자 지정 경로 추가 버튼을 눌러 sendmesaage도 추가한다.
그 후 네 가지의 람다 함수를 만들었는데, 각각에 라우팅에 맞는 함수들을 연결한다. connect, disconnect, default, sendmessage에 맞는 람다 함수들을 네이밍 컨벤션을 잘 지켜 만들었다면 헷갈리지 않게 연결할 수 있을 것이다.
스테이지 이름은 그냥 기본 그대로 입력해도 된다.
마지막으로 위와 같이 생성이 끝나면 API Gateway 콘솔에서 스테이지로 들어가면 Websocket URL과 connections URL이 있다. 람다 함수의 코드를 살펴보면 람다 쪽에서는 밑의 https://로 시작하는 connections URL을 사용하는 것을 알 수 있다. 우리는 위의 wss://로 시작하는 웹소켓 URL을 이용할 것이다. WebSocket URL을 미리 복사해 둔다.
채팅 서버 접속하기
자, 이제 서버의 구성은 끝났다. 채팅 서버에 접속할 차례이다. 채팅 서버를 접속하기 위해서 npm의 wscat이라는 라이브러리를 사용할 예정이다. npm이 없다면 따로 설치해 주길 바란다.
- [npm] wscat : https://www.npmjs.com/package/wscat
위 라이브러리를 설치하고 웹소켓에 접속하는 방법은 아래와 같다.
npm install -g wscat
그 후 API Gateway에 있던 Websocket URL을 복사해 와 아래와 같이 입력한다.
wscat -c wss://example.execute-api.ap-northeast-2.amazonaws.com/production/
두 개의 터미널을 통해서 테스트하면 아래와 같이 테스트할 수 있다. sendmessage라는 action을 사용하기 위해서는 JSON으로 데이터를 보내야 한다.
이렇게 채팅 서버의 구현을 마쳤다. 하얀색의 본인의 채팅이고 파란색이 다른 사람의 채팅이다. AWS에서 제공하는 Serverless 서비스들을 통해서 손쉽게 채팅 서버를 구축할 수 있다.
마치며
해당 서비스의 손쉬운 개발을 위해서 람다 함수가 사용할 IAM에 FullAccess들을 다 물려두어, 람다 콘솔에서의 구성 - 권한에 접속하면 모든 권한이 아래와 같이 물려 있다는 것을 확인할 수 있다. 만약 좀 더 정교한 커스터마이징이 필요한 경우에는 IAM을 직접 컨트롤하여 최소한의 권한만 가져가도록 설정하는 것이 좋다!
또한, 힘들었던 점은 Serverless이기 때문에 안 되는 상황에서 트러블슈팅하는 것이 힘들었는데, 그럴 때에는 CloudWatch에 접속하여 로그 내역들을 직접 살펴봐야 한다. 터미널에 그냥 뜨는 로그에 비해 살짝 보기 불편한 점이 없잖아 있으나, 금방 적응한다면 서버리스 아키텍처는 정말 좋은 것 같다.
'project' 카테고리의 다른 글
HPC Cluster에서 Grafana dashboard 만들기 (2) | 2024.09.24 |
---|---|
github.io를 이용한 포트폴리오 사이트 만들기 (2) | 2024.03.06 |
크롤러, 스크래퍼를 만드는 방법 (2) | 2024.01.09 |