Amazon Web Services 한국 블로그

Amazon ElastiCache(Redis)를 이용한 채팅 애플리케이션 구성 방법

이 글에서는 Redis를 활용하는 채팅 애플리케이션에 대한 개념 및 아키텍처를 살펴 볼 예정입니다. 또한, 채팅 클라이언트와 서버에 대한 자세한 구성방법 그리고 사용자 AWS에 채팅 예제 애플리케이션을 배포하는 방법에 대해서 이야기를 나누어 보겠습니다.

사전 지식

채팅 애플리케이션을 작성하기 위해서는, 클라이언트가 채팅방 안에 속해있는 다른 참여자들에게 메시지를 전달하기 위한 통신 채널이 필요합니다. 이러한 통신 방법은 Publish-Suscribe 패턴(일명, PubSub)으로 보통 구성하게 됩니다. 이 경우, 메시지는 중앙에 있는 토픽 채널로 전달되게 됩니다. 해당 토픽에 관심이 있는 참여자는 채널에 사전 등록을 하고, 갱신되는 내용에 대해서 알림을 받게 됩니다. 이 패턴을 통하여, 메시지 계시자와 메시지 수신자 사이를 분리할 수 있어, 계시자에 대한 정보 없이, 메시지 수신자의 크기는 늘어나거나 줄어들 수 있게 됩니다.

PubSub은 백엔드 서버에 구성되어 있으며, 클라이언트는 WebSockets을 통하여, 연결합니다. WebSocket은 영속적인 TCP 연결로, 클라이언트와 서버간에 양방향 데이터 스트림 전송을 지원하는 채널을 제공합니다. 한개의 서버로 구성된 단일 서버 아키텍처의 경우, 한개의 PubSub 애플리케이션이 계시자/참여자 상태 정보를 관리하게 되며, WebSocket을 이용하여, 메시지 재분배를 수행할 수 있습니다. 아래 다이어그램에서는 단일 서버 구조상에서 WebSocket을 통하여, 메시지가 어떤 경로로 흘러가는지 보여주고 있습니다.

Single-Server PubSub Architecture

단일 서버 구성은 통신 흐름을 표시할 경우에는 유용하나, 대부분의 솔루션을 구성할 경우, 다수의 서버를 활용하여 설계하기를 원할 것입니다. 다수의 서버를 이용한 구성(이하, 멀티 서버(Multi-server) 구성)은 신뢰도를 향상시키면서, 사용자 증가에 따른 수평 확장을 가능하게 해주는 탄력성을 부여해 줍니다.

멀티 서버 구성에서는, 하나의 클라이언트가 로드밸런서와 WebSocket연결을 한 개 생성하고, 로드벨런서는 서버 풀(Pool)에 트래픽을 전달하게 됩니다. 풀 안에 있는 서버들은 WebSocket 연결과, 전달되는 데이터에 대한 관리 책임을 가지고 있습니다. 한번 WebSocket 연결이 PubSub 애플리케이션 서버와 연결이 되면, 해당 커넥션은 유지되고, 양 방향으로 자료 흐름이 생깁니다. 로드벨런서는 WebSocket연결 요청을 정상적인 서버로 전달시키는 역할을 합니다. 이는 두개의 서로 다른 클라이언트가 서로 다른 애플리케이션 서버로 WebSocket연결을 구성할 수 있다는 의미가 됩니다.

여러 개의 애플리케이션 서버들이 클라이언트 WebSocket 연결을 관리하기 때문에, 애플리케이션 서버간 통신은 메시지 전파를 위해서는 필수적인 요건이 됩니다. 특정 애플리케이션 서버에 접속된 클라이언트에서  보낸 메시지가, 다른 애플리케이션 서버와 연결되어 있는 클라이언트로 메시지를 전파할 필요가 있기 때문입니다. 이 요구사항을 만족시키기 위하여, 클라이언트 연결을 관리하고 있는 애플리케이션 서버들간 공유 채널을, PubSub 구성을 외부로 이전하여 해결할 수 있습니다.

아래 다이어그램은 멀티 서버 PubSub 구조 상에서 두개의 클라이언트간에 WebSocket을 통하여 메시지가 전달되는 경로를 보여주고 있습니다. 영속적인 연결이 로드밸런서를 통하여, 클라이언트와 WebSocket 서버간에 이루어지게 되며, 다시, WebSocket서버와 PubSub 서버간에 영속적인 연결을 구성하여, 모든 클라이언트가 공유할 수 있는 구독 채널을 구성합니다.

Multiserver PubSub Architecture

사용자가 PubSub 기능을 직접 작성하는 것도 가능하지만, 이미 해당 기능을 제공하고 있는 소프트웨어 애플리케이션을 이용하는 것도 좋은 방법입니다. Redis는 빠른 오픈 소스 인-메모리 데이터스토어 및 캐쉬이며, PubSub 기능을 기본적으로 지원하고 있습니다. Amazon ElastiCache for Redis는 사용 편의성을 제공하면서Redis의 성능, 고가용성, 신뢰성 그리고 해당 애플리케이션이 요구하는 성능에 적합한 인-메모리 서비스를 제공합니다.

ElastiCache for Redis와 ELB의 한 종류인 ALB는 WebSocket을 지원하며, 이 둘을 이용하여, 채팅 애플리케이션을 작성하는 방법을 보여줄 예정입니다. 이 애플리케이션은 Node.js와 AWS Elastic Beanstalk으로 백엔드를 구성하고Vue.js를 이용하여 클라이언트를 구성합니다. 여러분은 샘플 애플리케이션의 모든 코드를 elasticache-redis-chatapp GitHub 보관소에서 보실 수 있습니다.

기본 아키텍처

아래 다이어그램은 ElastiCache for Redis, ALB, Node.js Elastic Beanstalk 애플리케이션 그리고 Vue.js 를 이용하는 web client를 포함하는 최종 아키텍처를 보여주고 있습니다.

이제, 고수준에서 채팅 애플리케이션을 구현을 어떻게 했는지 살펴보겠습니다.

구현 개요

예제 애플리케이션은 회원들과 공유 채팅방을 통해 서로 공유하는 메시지로 구성되어 있으며 아래 스크린샷처럼 표시됩니다.

예제 애플리케이션은, 사용자 등록, 프로파일 관리 그리고 로그인 기능은 제거하였습니다. 대신, 브라우저에서 채팅 애플리케이션을 열 때, 사용자를 대신해서, 임의의 사용자 이름과 아바타가 생성됩니다. 이 이름과 아바타는 왼쪽에 있는 회원 목록에 표시됩니다. 다른 회원들이 브라우저를 통해 해당 애플리케이션을 열게 되면 가입이 되고, 그 이름이 웹 애플리케이션 상에 표시됩니다. 회원은 다른 웹 클라이언트 사용자에게 메시지를 배포할 수 있으며, 해당 메시지는 메인 채팅창에 표시됩니다.

다음은, Vue.js 로 구성한 웹 클라이언트를 좀 더 자세하게 살펴보고, Node.js 로 구성되어 있는 백엔드 단을 보도록 하겠습니다.

Vue.js 웹 클라이언트

웹 클라이언트는View Layer 관리를 위한 Vue.js와, UI구성을 위한 Bootstrap, 그리고 WebSocket 통신을 위한 Socket.io를 이용하여 구현하였습니다. 먼저, 익숙하지 않은 분들을 위하여 복잡한 구성은 자제하기로 하였고 이를 위해, JavaScript 번들러는 사용하지 않았습니다. 하지만, 실제 운영환경에서는 webpack 또는 유사한 다른 소프트웨어를 반드시 고려하여야 합니다. Vue.js 는 진행되는 데이터 변경에 따라 수정된 UI를 렌더링해 줍니다. 예제에 적용한 프레임워크나 라이브러리는 최신 단일 페이지(Single-Page) 웹애플리케이션을 대표적으로 보여주기 위하여 선택하였습니다. 그러나 커뮤니티에서 여러 가지 다양한 선택지를 찾을 수 있으며 매일 더 많은 옵션이 나오고 있습니다.

다음은 HTML 마크업 코드 일부로, Vue.js 애플리케이션 컴포넌트와 리스트 멤버를 표시하기 위한 반복자를 구성하는 예입니다. 일부 중급 마크 업과 CSS 스타일을 제거하고 실제 기능에 중점을 두었습니다. 전체 예제는 GitHub 저장소에서 찾을 수 있습니다.

<html>
<body>
    <div id=”app”>
        <li v-for="(value, key) in members">
            <img v-bind:src="value.avatar">
            <small>{{ value.username }}</small>
        </li>
    </div>

v-for 파라미터는 반복자를 정의하기 위하여 사용됩니다. 이 경우에는 회원 정보 모델에 대한 key-value 튜플에 적용하였습니다. 이 루프 안에서, Mustach 템플릿을 이용하여, 개별 회원 객체에 접근하고 회원 이름을 출력합니다. Mustash 템플릿은 HTML 속성 안에서는 동작하지 않기 때문에, v-bind 속성을 활용하여, 아바타 이미지 URL을 가지고 옵니다.

Vue.js는 DOM에서 발생하는 차이점을 매우 현명하게 판단하고, 최소한의 UI 변경만 수행합니다. 이러한 접근 방식으로 우리는 데이터 모델에 대한 상태 변경에 집중할 수 있습니다. 예제 애플리케이션에서는 HTML안에 Javascript 코드를 직접 삽입하였습니다만, 실제 운영 환경에서는 별도의 .vue 파일로 생성한 후, 빌드 시점에 webpack을 이용하여 UI 컴포넌트에 대한 모듈화를 진행하는 방식을 많이 이용하고 있습니다.

<script src="js/vue/2.1.10/vue.min.js"></script>
<script src="js/socket.io/1.7.2/socket.io.min.js"></script>
<script>
    var socket = io();

    new Vue({
        el: '#app',
        data: {
            message: '',
            messages: [],
            members: {}
        }

여기에서, 우리는 Vue.js 애플리케이션을 선언하였고, 애플리케이션과 HTML Div태그를 app ID를 이용하여 묶고 있습니다. 추가적으로 3개의 데이터 모델을 선언하고 있습니다.

  • message: 폼에 입력되는 메시지
  • messages: 메시지 목록. 메시지를 추가만 하기 때문에 배열 활용
  • members: 회원 목록. 회원이 채팅팅룸에 나갈 때 제거하거나, 위치시키기 위하여 객체(Object)를 사용

마크 업 및 Vue.js 응용 프로그램 선언 외에도 웹 클라이언트는 WebSocket 연결을 설정하고, 참여 할 토픽을 선언하고, 해당 토픽에 게시 된 메시지를 앞에서 선언한 데이터 모델에 반영하는 방법을 설정해야 합니다. 해당 애플리케이션에서는 통신을 위하여, 5개의 토픽을 생성합니다. 각 토픽에 해당하는 트리거 이벤트와 대응되는 액션들은 아래와 같습니다.

messages

  • Trigger: 메시지가 채팅방에 전달될 경우
  • Action: 메시지 내용을 이용하여 메시지 목록을 갱신하고, 회원의 메타데이터 수정

member_add

  • Trigger: 신규 회원이 채팅방에 참여
  • Action: 회원이 이름과 패스워드를 회원 목록에 추가

member_delete

  • Trigger: 회원이 채팅방에서 나감
  • Action: 회원 목록에서 해당 회원 정보 제거

message_history

  • Trigger: 한 클라이언트가 메시지 목록을 초기화 함
  • Action: 최신 메시지 중 누락된 부분을 메시지 목록에 추가함

member_history

  • Trigger: 한 클라이언트가 회원 목록을 초기화 함
  • Action: 채팅방에 참여한 회원 목록으로 현 회원 목록을 지정함

아래에 있는 Javascript코드는 위 내용들을 구현한 내용 입니다. 참조를 위하여 Vue.js 코드를 유지하였습니다.

new Vue({
    el: '#app',
    data: {
        message: '',
        messages: [],
        members: {}
    },
    methods: {
        send: function() {
            socket.emit('send', this.message);
            this.message = '';
        },
    mounted: function() {
        socket.on('messages', function(message) {
            this.messages.push(message);
        }.bind(this));

        socket.on('member_add', function(member) {
            Vue.set(this.members, member.socket, member);
        }.bind(this));

        socket.on('member_delete', function(socket_id) {
            Vue.delete(this.members, socket_id);
        }.bind(this));

        socket.on('message_history', function(messages) {
            this.messages = messages;
        }.bind(this));

        socket.on('member_history', function(members) {
                    this.members = members;
        }.bind(this));
    }

앞선 코드 샘플에서 정의한 Socket.io 객체는 socket.on 메쏘드를 이용하여, 앞에서 설명 드렸던 토픽에 참여하게 됩니다. 메시지가 해당 토픽에 보내지게 되면, 콜백 함수가 실행됩니다. 데이터 모델은 Action(set, add, delete)과 대상 데이터 모델(array, object)에 따라 변경이 일어납니다. bind(this) 문장을 추가하여, Vue.js 데이터 모델이 콜백 함수 Scope에서 사용할 수 있게 해 줍니다. (자세한 내용은Function.prototype.bind 참조)

마지막으로, 메시지 폼 전달을 위한 Vue.js 메쏘드가 있습니다. Vue.js는 폼 전달을 편리하게 지원하는 메쏘드를 제공합니다. 해당 메쏘드는 WebSocket을 통하여 텍스 메시지를 게시하고, 비어 있는 스트링에 메시지를 저장합니다. 이후, Vue.js 바인딩 기능을 이용하여, 해당 스트링은 UI를 갱신하게 됩니다.

Node.js 백엔드 애플리케이션

지금까지 웹 클라이언트의 기본적인 내용에 대해서 살펴보았습니다. 이제부터는 Node.js 로 구성된 백엔드 애플리케이션을 살펴보겠습니다. Redis를 어떻게 자료 저장소로 이용하고, PubSub을 활용, WebSocket 메시지들을 재전달하는지 알아보겠습니다.

Redis와 WebSocket 설정하기

웹 클라이언트를 브라우저에서 오픈할 때, PubSub 애플리케이션과 WebSocket 연결을 구성합니다. 연결 후, 애플리케이션 서버는 새로운 사용자 클라이언트에게 게시되어야 하는 기존 회원 정보나 메시지 정보를 구성하여야 합니다. 또한, 다른 사용자들에게는 채팅방에 새롭게 참여한 회원 정보를 갱신하여야 합니다. 다음은 HTTP 애플리케이션과 WebSocket을 어떻게 선언하는지 보여주는 간단한 코드 샘플입니다.

var express = require('express');
var app = express();
var http = require('http').Server(app);
var io = require('socket.io')(http);
var port = process.env.PORT || 3000;

WebSocket 리스너를 생성하는 것 외에, 백엔드 애플리케이션은 Redis Cluster와 연결을 구성합니다. 한개의 커넥션은Redis의 자료 모델 갱신과 토픽에 메시지를 전달하기 위해 필요합니다. 개별 토픽에 참여할 때마다 추가적인 커넥션이 필요합니다.

var Redis = require('ioredis');
var redis_address = process.env.REDIS_ADDRESS || 'redis://127.0.0.1:6379';

var redis = new Redis(redis_address);
var redis_subscribers = {};

function add_redis_subscriber(subscriber_key) {
    var client = new Redis(redis_address);

    client.subscribe(subscriber_key);
    client.on('message', function(channel, message) {
        io.emit(subscriber_key, JSON.parse(message));
    });

    redis_subscribers[subscriber_key] = client;
}
add_redis_subscriber('messages');
add_redis_subscriber('member_add');
add_redis_subscriber('member_delete');

위 코드 샘플에서,  ioredis 자바스크립트 클라이언트를 이용하여 Redis 명령 채널을 구성합니다.  한개의 함수가 정의되었는데요, 새로운 채널 참여 시 초기화하는 역할을 하며, 채널 토픽에 대한 참여자들의 키 값을Hash에 추가합니다. 참여한 채널별로 다음과 같은 역할을 동일하게 수행합니다.

  • 구독 대상인 Redis 채널에서 JSON 메시지를 스트링으로 받습니다.
  • JSON 스트링을 JavaScript 객체로 전환합니다.
  • 전환된 JavaScript 개체를 WebSocket 연결로 게시하며, 이 때 Redis PubSub을 이용하여 동일한 토픽으로 전파합니다.

곧 보게 되겠지만, JavaScript 객체가 JSON 문자열로 직렬화하여, Redis에 값으로 저장되고, PubSub 토픽에 게시되는 일이 매우 중요하다는 점을 알게 될 것입니다. JSON 문자열은 WebSocket을 통한 전파 전에 반드시, JavaScript 객체로 역직렬화가 되어야 합니다. 왜냐하면, Socket.io 라이브러리 또한 백엔드와 클라이언트간 통신을 수행할 때, 객체를 직렬화/역직렬화를 수행하기 때문입니다. (역자 주, 직렬화/역직렬화가 두번 이상 반복되지 않도록 해야 된다는 의미입니다.)

브라우저에서 웹클라이언트가 참여 했을 때, 클라이언트는 WebSocket에 새로운 연결을 하나 생성합니다. 우리는 이러한 연결이 생성되었을 때 특정 액션을 수행할 수 있도록, 다음과 같은 함수를 정의할 수 있습니다.

io.on('connection', function(socket) {
... application business logic ... 
}

위 함수에서 socket은 클라이언트와 연결된 개별 WebSocket을 식별할 수 있도록 ID를 가지고 있는 객체입니다. 우리는 socket.id 값을 이용하여 회원을 구분할 것입니다. 이러한 식별자를 통하여 우리는 Redis 데이터 모델에서 회원을 찾거나 제거할 수 있습니다. 또한, member_delete 토픽을 이용하여, 모든 채팅방에 있는 클라이언트에게 회원이 제거되었음을 알려줄 수 있습니다. 다음에 설명이 될 다른 함수들은 모두 이 Callback 함수 컨텍스트에 안에 위치해 있습니다.

다은 섹션에서는, 새로운 클라이언트가 WebSocket을 통하여 Node.js 백엔드 애플리케이션과 연결할 때, 어떤 일이 발생하는지 살펴보겠습니다.

신규 클라이언트 연결 초기화

새로운 클라이언트가 채팅방에 참여할 때, 다음과 같은 일들이 일어납니다.

  1. 회원의 현재 목록을 가져옵니다.
  2. 클라이언트 재연결이 아니라면, 새로운 회원을 임의의 사용자 이름과, 아바타 URL로 생성합니다. 이후, Redis Hash에 저장합니다.
  3. 최근 메시지 중에 누락된 부분을 가져옵니다.

위 기능을 진행하는 코드를 살펴보도록 하겠습니다. 첫번째는 아래 코드입니다.

var get_members = redis.hgetall('members').then(function(redis_members) {
    var members = {};
    for (var key in redis_members) {
        members[key] = JSON.parse(redis_members[key]);
    }
    return members;
});

ioredis 자바스크립트 클라이언트는 비동기 실행처리를 위하여 Promises를 활용합니다. HGETALL(‘members’) 호출은 ‘members’ 키에 저장되어 있는 모든 키와 값을 되돌려줍니다. Redis는 Hash 데이터 형태를 지원합니다만, 한 레벨 정도 까지만 가능합니다. 해쉬에 저장된 값들은 반드시 문자열이어야 합니다. Callback 함수는 해쉬에 있는 key-value 쌍을 돌면서, 회원 초기화를 위한 다음 단계를 위하여 역직렬화(역주. 객체화)를 수행합니다.

var initialize_member = get_members.then(function(members) {
    if (members[socket.id]) {
        return members[socket.id];
    }

    var username = faker.fake("{{name.firstName}} {{name.lastName}}");
    var member = {
        socket: socket.id,
        username: username,
        avatar: "//api.adorable.io/avatars/30/" + username + '.png'
    };
    
    return redis.hset('members', socket.id, JSON.stringify(member)).then(function() {
        return member;
    });
});

initialize_member Promise 함수는 먼저 해당 회원이 재접속 회원인지 확인합니다. 재접속이 아니라면, 새로운 회원 정보를 Faker라이브러리를 이용하여 임의의 사용자 이름으로 생성합니다. 부여된 사용자 이름으로 임의의 아바타 URL을 Adorable Avatars 서비스를 이용하여 생성합니다.

클라이언트 초기화 마지막 단계는 최근 메시지 중에 누락된 메시지를 다시 가지고 오는 것입니다. 이를 위하여, 우리는 또다른 Redis 데이터 타입 Sorted Set을 사용하여 편리하게 구현할 수 있습니다. 이 타입은 Redis Set과 유사한 구조를 가지고 있으나, Set안에 있는 개별 원소들에 대한 순위 정보를 포함하고 있습니다. Sorted Set은 순위 게시판에서 자주 활용되는 데이터 타입입니다. 타임스탬프 값을 순위로 활용할 경우, 시간 순으로 정렬된 데이터 목록으로 활용할 수 있습니다.

var get_messages = redis.zrange('messages', -1 * channel_history_max, -1).then(function(result) {
    return result.map(function(x) {
       return JSON.parse(x);
    });
});

우리는 Sorted Set에 있는 ZRANGE 라는 명령어를 이용하여 순위에 기초한 요소 목록을 가져오도록 할 것입니다. 요소들은 낮은 순에서 높은 값 순으로 정렬이 되어 있어, 초기화 시점(-1 * cahnnel_history_max)에서부터 최신(-1) 항목까지 최대한 메시지 목록을 가져올 필요가 있습니다. 다시 말씀드리지만, 개별 요소는 JSON 문자열로 직렬화되어 있고, 이를 활용하기 위해서는 역직렬화를 통하여 JavaScript 객체로 전환하여야 합니다.

다시 정리해 보면, 새로운 클라이언트가 채팅방에 참여할 때, 다음과 같은 일들이 일어납니다.

  1. 현재 회원 목록을 조회
  2. 재접속된 클라이언트가 아니라면, 임의의 사용자 이름과 아바타 URL을 가지고 새로운 사용자가 생성되며, 해당 정보는Redis Hash에 저장
  3. 최근까지 발생했던 누락된 메시지를 조회

우리는 위의 각 단계에 대해서 살펴 보았습니다. 이제, 어떻게 초기화를 완료하고, 클라이언트들에게 데이터를 전달하는지 알아보도록 하겠습니다.  ioredis는 Promises를 사용하는 까닭에, 비동기 실행들을 다 같이 연결 실행할 수 있으며, Promise.all 함수를 이용하여, 결과를 처리하기 전, 다른 모든 타스크들이 완료되기 전까지 대기를 할 수 있습니다.

Promise.all([get_members, initialize_member, get_messages])
    .then(function(values) {
        var members = values[0];
        var member = values[1];
        var messages = values[2];
...
)};

우리는 필요한 모든 데이터를 가지고 있습니다. 이제, WebSocket 연결을 이용, 새로 참여한 클라이언트에 데이터를 전달하고, 채팅방에 있는 다른 회원들에게 새로운 회원이 참여했다는 것을 알려 주어야 합니다.

io.emit('member_history', members);
io.emit('message_history', messages);
redis.publish('member_add', JSON.stringify(member));

우리는 Socket.io의 emit 함수를 이용하여, 초기화 진행중인 클라이언트에게 회원 및 메시지 목록을 스트림 처리하여 전달합니다. 하나의 WebSocket은 여러개의 메시지들을 전달하는데 이용할 수 있습니다. 위에서 나온, 관련 토픽(member_history, message_history) 들은 앞서 살펴보았던 클라이언트 코드의 토픽 리스너에 대응됩니다. 새로운 회원은 모든 참여자와 통신을 하여야 합니다. 이를 위해, 우리는 Redis 명령 채널을 이용, member_add 토픽에 직렬화된 JSON문자열을 게시합니다. 앞서 말씀드린 내용이 기억난다면, 세개의 Redis 토픽을 구성하여, 해당 토픽에 참여하고 있는 클라이언트들에게 WebSocket을 통한 메시지 전파가 가능한 것을 기억하실 것입니다.

다음 섹션에서는, 채팅방에서 보내온 메시지 처리를 위한 구성 방안을 살펴보도록 하겠습니다.

메시지 처리

새로운 클라이언트가, WebSocket 연결 초기화를 완료하였을 시점에, 새로운 클라이언트로부터 전달되는 메시지를 처리하기 위한 메시지 처리 루틴을 정의하여야 합니다. 메시지는 메시지 평문, 회원의 이름, 회원의 아바타 그리고 메시지가 생성된 타임스탬프 정보로 이루어져 있습니다.

socket.on('send', function(message_text) {
    var date = moment.now();
    var message = JSON.stringify({
        date: date,
        username: member['username'],
        avatar: member['avatar'],
        message: message_text
    });

    redis.zadd('messages', date, message);
    redis.publish('messages', message);
});

Sorted Set에 저장되는 메시지 히스토리에 새로운 메시지를 Redis의 ZADD 명령어를 이용하여 추가하고, 생성 타임스탬프 정보를 순위로 지정합니다. 마지막으로, Redis 명령 채널을 이용하여, 메시지를 토픽에 계시하고, 이를 위해 앞에서 Redis/WebSockets 재배포를 정의하였습니다.

우리는 어떻게 클라이언트를 초기화하고, 채팅방에 보내진 메시지를 처리하는지 알게 되었습니다. 마지막으로, 클라이언트가 채팅방에서 나갈 때 어떤 일이 일어나는지 확인해 보겠습니다.

Disconnection 처리

Socket.io는 서버와 클라이언트간에 접속이 이루어졌을 경우, 연결된 WebSocket 에 대한 heartbeat을 생성합니다.

socket.on('disconnect', function() {
    redis.hdel('members', socket.id);
    redis.publish('member_delete', JSON.stringify(socket.id));
});

클라이언트가 끊어졌을 경우, 우리는 회원 초기화 시점에 진행된 내용을 역으로 진행합니다. 먼저, 우리는 Redis HDEL 메쏘드를 이용하여, Hash 데이터 타입에 저장되어 있는 회원 정보에서 클라이언트를 삭제합니다. 초기 클라이언트를 추가할 때 사용했던 WebSocket의 Socket ID정보를 동일하게 이용합니다. 채팅방에 참여하는 새로운 회원에 대하여, 기존 참여자들 전원에게 공지를 한 것과 같이, 우리는 채팅방을 떠나는 회원이 있음을 모든 참여자들에게 알려주어야 합니다. 이 경우, member_delete Redis 토픽을 이용합니다. 이 토픽은 WebSocket을 이용하여, 현재 참여하고 있는 클라이언트들에게 전달됩니다.

모든 코드 검토를 완료하였습니다. 다음은 AWS CloudFormation을 이용하여, 애플리케이션을 AWS 배포하는 부분을 살펴보도록 하겠습니다.

AWS CloudFormation을 이용한 배포

CloudFormation은 개발자와 시스템 운영자에게 필요한 AWS 자원을 손쉽게 생성하고 관리할 수 있는 기능을 제공합니다. CloudFormation은 순서에 맞게, 예측가능한 방식으로 자원을 프로비저닝하고 갱신합니다. 해당 채팅 애플리케이션의 CloudFormation Stack을 실행시키고자 한다면, 아래 버튼을 클릭해 주십시요.

CloudFormation 스크립트가 Elastic Beanstalk 환경, Application 및 Configuration 템플릿을 생성합니다. 또한 ElastiCache cluster for Redis와 LoadBalancer를 위한 EC2 Security Group을 생성하고, Application Server와 Redis Cluster를 구성합니다. 이런 방식으로 아키텍처 계층간 최소 권한 보안 구성에 대한 모범 사례를 사용합니다.

ElastiCache for Redis설정 코드에 대한 참고사항은 아래와 같습니다.

AWS::EC2::SecurityGroup에서 ingress 보안 룰에 대한 추가도 가능하지만, 이럴 경우, CacheCluster와 SecurityGroup간에 순환 참조가 발생합니다. ingress 룰은 별도의 AWS::EC2::SecurityGroupIngress 로 구분하여, 순환 참조 생성을 막아야 합니다. 다음 코드를 참조하십시요.

Resources:
  RedisCluster:
    Type: AWS::ElastiCache::CacheCluster
    Properties:
      CacheNodeType:
        Ref: ClusterNodeType
      VpcSecurityGroupIds:
        - !GetAtt CacheSecurityGroup.GroupId
      Engine: redis
      NumCacheNodes: 1
  CacheSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Cache security group
  CacheSecurityGroupIngress:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !GetAtt CacheSecurityGroup.GroupId
      IpProtocol: tcp
      FromPort: !GetAtt RedisCluster.RedisEndpoint.Port
      ToPort: !GetAtt RedisCluster.RedisEndpoint.Port
      SourceSecurityGroupId: !GetAtt ApplicationSecurityGroup.GroupId

다음으로, WebSocket 지원을 위한 Elastic Beanstalk Nginx proxy 환경 설정을 어떻게 수정하였는지 살펴보겠습니다.

WebSocket 지원을 위한 Elastic Beanstalk 상에서 Nginx 설정

AWS Elastic Beanstalk은 손쉽게 웹 애플리케이션이나, 서비스를 배포 확장할 수 있게 해주는 서비스입니다. 배포되는 서비스는  Java, .NET, PHP, Node.js, Python, Ruby, Go 로 구성되어 있거나, 아니면, Apache, Nginx, Passenger, IIS와 유사한 서비스의 Docker 이미지를 활용할 수 있습니다.

Elastic Beanstalk 은 Elastic Load Balancer(ELB)와 Application Load Balancer(ALB)를 모두 지원합니다. 클라이언트와 서버는 WebSocket을 통하여 통신하기 때문에, 우리는WebSockets 지원을 위하여, ALB를 사용합니다. 예제 Node.js 백엔드 애플리케이션의 경우, 사전 정의된 Node.js 애플리케이션 스택을 선택합니다. 우리는 애플리케이션 앞단에서 Nginx를 web tier 프록시로 사용합니다.

Socket.io는 WebSocket이 지원하지 않을 경우, Polling 전략으로 우회할 수 있는 방안이 있습니다. 어찌됐든, ALB와 Nginx 설정에 약간의 수정을 가하는 것만으로, 우리는 WebSocket 지원과 서버에서 클라이언트 방향으로 자료를 밀어 넣어 줄 수 있는 스트림 지원이 가능하게 됩니다. ALB에서 WebSocket지원을 받기 위해서는, 반드시 sticky session 을 활성화하여, WebSocket을 사용하기 위해 커넥션을 업그레이드 하기 위한 연속적인 HTTP 요청을 동일한 인스턴스에서 처리할 수 있게 하여야 합니다. Nginx에서 WebSocket을 활성화하기 위하여, 우리는 Elastic Beanstalk의 .ebextensions 메카니즘을 활용하여, Nginx 설정에 약간의 변경을 가하여야 합니다. Container 명령을 통하여 애플리케이션 배포판이 전개되고, 실제 활성화가 되기 전에, 변경 사항을 반영할 수 있습니다.

container_commands:
  enable_websockets:
    command: |
      sed -i '/\s*proxy_set_header\s*Connection/c \ proxy_set_header Upgrade $http_upgrade;\ proxy_set_header Connection "upgrade";\ ' /tmp/deployment/config/#etc#nginx#conf.d#00_elastic_beanstalk_proxy.conf

위에 있는 예제 코드는, Nginx 설정 /tmp/deployment/config/#etc#nginx#conf.d#00_elastic_beanstalk_proxy.conf 을 변경합니다. sed 명령어로 “proxy set header” 행을 찾아, WebSocket을 지원하는 설정으로 대치합니다. 애플리케이션이 설치되면, Elastic Beanstalk은 해당 설정 파일을 /etc/nginx/conf.d/00_elastic_beanstalk_proxy.conf로 복사합니다. 이후, Nginx 서비스는 변경 사항이 반영된 상태에서 재기동합니다.

결론

이번 블로그를 통하여, 우리는 publish-subscribe pattern을 검토하였습니다. 또한 우리는 ElastiCache for Redis를 활용하여, 채팅 애플리케이션에서 양방향 통신을 어떻게 구성하였는지 살펴보았습니다.

다시 한번 말씀 드리지만, 해당 애플리케이션의 전체 소스는 GitHub repository에서 확인하실 수 있습니다. 이 예제 애플리케이션을 실행시켜 본 후, 여러분들은 이를 확장하여 여러분들의 아이디어를 추가해 주셨으면 좋겠습니다. 예를 들어, 사용자 인증 기능 추가, 파일 첨부 기능 또는 채팅에 필요한 다른 기능 또는 PubSub 애플리케이션에 필요한 기능들을 추가해 보십시요.

이 글은 AWS Database 블로그의 How to Build a Chat Application with Amazon ElastiCache for Redis의 한국어 번역본으로 박경표 AWS 파트너 솔루션즈 아키텍트께서 번역 및 감수에 수고해주셨습니다.