레일즈 5의 액션 케이블을 활용한 채팅 앱 구현

레일즈5에 몇몇 변화들이 있었지만 그 중에서도 가장 흥미로운 것이라면 액션 케이블(ActionCable)일 것입니다. 액션 케이블을 간단히 설명하자면

웹소켓(WebSocket)을 자연스럽게 통합해주어, 실시간 발행-구독 모델을 쉽게 구현할 수 있도록 도와주는 풀스택 컴포넌트

라고 할 수 있을 것입니다. 즉, 액션 케이블만 있으면 공식 HTML5 명세에 포함된 웹소켓을 레일즈에서 쉽게 활용할 수 있고 실시간 채팅 앱을 빠르게 구현할 수 있습니다. 이 포스트에서는 액션 케이블의 주요 개념에 대해 살펴보고 실제로 작동하는 채팅 앱을 구현해볼 것입니다. (Ruby MRI 2.3.1, Rails 5.0.0기준으로 작성되었습니다.)

웹소켓

웹소켓에 대해 잠시 살펴보자면, 단방향-비연결지향의 특성을 지닌 HTTP의 한계점을 극복하기 위해 탄생한 양방향-연결지향 통신 프로토콜입니다. 기존에 HTML+JavaScript로 채팅과 같은 실시간 앱을 구현하기 위해서는 Ajax, Comet 등의 기술을 사용해야 했습니다. 이런 기술들은 정적인 HTTP 통신의 한계를 뛰어넘어 비동기적인 통신을 구현했다는 점에서 동적인 웹페이지에서 많이 사용되었지만, 실시간 앱에서 사용되기에는 여전히 단점이 존재했습니다. 진정한 푸쉬 방식이 아닌 롱 폴링(long polling)을 통해 구현되었다는 것이 그것인데, 이로인해 타임아웃 될 때마다 기존의 연결을 끊고 새로운 요청을 생성하는 작업이 이루어져야 하고 이는 성능의 저하 및 서버의 부하로 이어집니다.

웹소켓이 등장함으로 인해 안드로이드, iOS 등 최신 모바일 OS에 적용된 것과 같은 푸쉬 기술을 드디어 HTML5에서도 외부의 라이브러리 없이 사용할 수 있게 되었고, 이러한 장점을 레일즈 애플리케이션에서도 쉽게 활용할 수 있게 도와주는 아주 고마운 존재가 바로 액션 케이블인 것입니다.

주요 개념

액션 케이블은 다른 레일즈 컴포넌트들이 그렇듯이 복잡한 로직을 간단한 인터페이스 뒤에 감추어놓아 손쉽게 사용이 가능하지만 초보자들이 처음 접했을 때 헷갈리기 쉬운 개념들이 존재합니다. 따라서 코드를 살펴보기 전에 채팅 앱에서의 활용 위주로 액션 케이블에서 사용되는 주요 개념들에 대해 살펴보겠습니다.

브로드캐스트(Broadcast)

브로드캐스트는 서버 사이드에서 다수의 구독자에게 데이터가 전파되는 과정을 의미합니다. 채팅 방에서 참여자 중 한 명이 메시지를 작성하고 전송 버튼을 클릭하면 해당 채팅 방에 있는 다른 모든 사람들이 해당 메시지를 받아볼 수 있어야 합니다. 전송 버튼을 클릭하면 데이터베이스에 메시지가 저장되고 난 후 ‘브로드캐스트’ 과정이 이루어지며 이 때 웹소켓을 통해 데이터가 다수의 구독자들에게 전파되는 것입니다.

스트림(Stream)

스트림은 서버 사이드에서 양방향으로 전송되는 데이터 흐름을 추상화한 개념입니다. 채팅 앱에는 여러 개의 채팅 방이 존재할 수 있지만 채팅방#1에서 전송되는 메시지를 채팅방#2의 참여자들이 볼 수 있어서는 안 됩니다. 즉, 각각의 채팅방의 메시지는 다른 채팅방의 메시지들과 구분이 되어있고 이렇게 구분되는 메시지들의 집합이 ‘스트림’인 것입니다. 각각의 스트림은 고유의 이름을 갖고 있고 (예: ‘chat_1’, ‘chat_2’) stream_from 혹은 stream_for 메소드를 이용해 특정 스트림을 참조합니다. 액션 케이블 문서에서는 때로 ‘boradcasting’이라는 용어로 사용되기도 합니다.

채널(Channel)

채널은 서버 사이드에서 스트림에 대응하여 이루어지는 작업들의 집합입니다. 보통 한 가지 종류의 스트림은 하나의 채널에서 처리됩니다. 비동기 요청의 처리를 담당하는 점을 제외하고는 액션 컨트롤러와 유사하다고 볼 수 있습니다.  예를 들어, 채팅 앱에서의 ‘chat’ 스트림과 관련된 작업들은 ChatChannel에서 이루어집니다.  각 채널은 subscribedunsubscribed와 같은 기본적인 콜백 이외에도 ‘메시지 전송’과 같은 사용자 정의 메소드를 가질 수 있습니다. 만약 ChatChannel에 메시지 전송을 처리하는 send_message와 같은 메소드가 정의되어 있다면 클라이언트 사이드의 자바스크립트 코드에서 Subscription.perform()을 이용해 리모트 프로시저 콜의 형태로 해당 메소드를 호출할 수 있습니다. 이와 관련된 사항은 이후 구현 부분에서 살펴보게 됩니다.

연결(Connection)

연결은 서버 사이드에서 웹소켓 연결과 관련된 처리를 합니다. 연결을 시도하는 사용자의 신원을 확인해서 연결을 승인할지 거부할지 결정하게 됩니다.

구독(Subscription)

구독은 클라이언트 사이드에서 스트림을 브로드캐스트 받기 위해 구독자로써 등록하는 과정입니다. 채팅 방에 입장했을 때 참여자는 구독 과정을 거쳐야 하며 이후부터 해당 방에서 다른 참여자들이 입력하는 모든 메시지들을 브로드캐스트 받을 수 있게 됩니다.

소비자(Consumer)

소비자는 클라이언트 사이드에서 앱의 사용자가 구독을 받기 위해 사용하는 웹소켓 클라이언트를 의미합니다. 한 명의 사용자는 여러 개의 소비자를 가지고 있을 수 있는데, 탭을 새로 띄우거나 창을 새로 열어서 새로운 웹소켓 클라이언트를 사용할 수 있기 때문입니다.

구독자(Subscriber)

구독자는 클라이언트 사이드에서 구독한 후의 소비자를 의미하는 용어입니다. 구독자가 되었다면 이제부터 스트림을 브로드캐스트 받을 수 있습니다. 물론 구독자는 unsubscribe(구독 취소)를 할 수도 있고 그러면 브로드캐스트 받지 않게 됩니다.

이 용어들이 친숙해졌다면 액션 케이블에 한걸음 더 다가간 것입니다. 이제 실제로 작동하는 앱을 구현해 봅시다.

채팅 앱 구현하기

준비하기

구현을 하기 전에 여러분의 레일즈 앱은 Devise와 같은 라이브러리를 통해 사용자 인증이 구축되어 있다고 가정합니다. 만약 아직 사용자 인증을 구현하지 않았다면 여기를 참조하여 Devise를 적용하는 것을 추천합니다. 회원가입/인증/권한설정 등의 기능을 쉽게 앱에 적용할 수 있습니다.

사용자 인증이 구현되었다면 다음은 Redis를 설치해야 합니다. Redis에 대해서는 여기에 잘 설명되어 있습니다. config/cable.yml 파일에서 Redis가 아닌 MySQl이나 PostgreSQL 등의 데이터베이스를 사용하도록 설정할 수도 있습니다.

Redis의 설치 방법은 아래와 같습니다.

리눅스:

OS X:

Redis 설치가 끝났으면 redis-server 명령어를 통해 실행해줍니다. 채팅 앱이 작동되기 위해서는 Redis 서버가 백그라운드에서 실행중이어야 합니다.

마지막으로 모델을 생성하고 아래와 같이 관계를 설정합니다.

서버 사이드

가장 먼저 해야할 일은 웹소켓 연결을 설정하는 것입니다. 클라이언트 사이드에서 구독을 요청할 때 가장 처음 이루어지는 작업이기도 하고 앱의 보안을 위해서 가장 중요한 로직이기도 하기 때문입니다.

app/channels/application_cable/connection.rb 파일을 열면 ApplicaionCable::Connection 클래스는 비어있을 것입니다. 다음과 같이 작성해 줍니다.

connect 메소드는 웹소켓이 서버 사이드에 연결됐을 때 호출됩니다. 우리의 코드에서는 find_verified_user 메소드로 연결을 요청한 클라이언트 사이드의 사용자 신원을 파악해서 올바른 사용자라면 연결을 승인하고 그렇지 않다면 거절을 합니다. 이때 사용자를 찾기 위해 cookies.signed[:user_id]를 참조하는데 이는 웹브라우저의 서명된 쿠키에서 :user_id에 해당하는 값을 읽어오는 것을 의미합니다. 따라서 Devise를 이용해 사용자 인증을 한 후에 쿠키 값을 올바로 설정해주는 코드가 있어야 합니다. 또한 identified_by :current_user라고 명시해 주었으므로 액션 케이블은 앞으로 current_user 메소드를 참조해서 현재 사용자가 누군지 확인을 할 것입니다. 만약 로그인 세션의 만료 타임아웃을 설정하고 싶다면 주석을 해제하여 쿠키의 :user_expires_at 값을 참조하면 됩니다.

config/initializers/warden_hooks.rb 파일을 생성하고 다음과 같이 작성합니다.

Warden은 Devise가 기반으로 하고 있는 인증 라이브러리로, 위 코드는 사용자가 로그인할 때 쿠키값을 설정해주고 로그아웃할  때 쿠키값을 삭제해줍니다. 만약 로그인 세션의 만료 타임아웃을 설정하고 싶다면 주석을 해제하면 됩니다.

이로써 연결 설정은 끝났습니다. 웹소켓을 통해 서버 사이드 접속에 성공한 것입니다. 다음 과정은 Channel을 생성하는 것입니다. 기본적으로 app/channel/application_cable/channel.rb에 ApplicationCable::Channel 기반 클래스가 존재합니다. 우리가 생성할 모든 채널들은 이 클래스를 상속할 것이므로 여기에는 공통된 로직이 들어가게 됩니다.

이제 터미널을 열고 아래처럼 채팅을 위한 채널을 생성해 봅시다.

app/channels/chat_channel.rb 파일을 아래와 같이 수정해 줍니다.

subscribed 메소드는 클라이언트 사이드의 구독 요청이 성공했을 때 호출됩니다. stream_from "chat_#{params[:chat_id]}"를 호출함으로써 해당 이름(예: chat_1, chat_2)에 해당하는 스트림으로부터 구독자에게로 브로드캐스트를 시작하게 되는데, 이때 params[:chat_id]는 구독을 요청할 때 클라이언트 사이드에서 전달해주는 파라미터 값으로 이후 자세히 살펴보겠습니다. 그런데, ChatChannel에는 사용자를 인증하는 코드가 없습니다. 어떻게 정확한 구독자에게로 브로드캐스트를 해줄 수 있을까요? 그렇습니다. 바로 이전에 위에서 살펴보았던 ApplicaionCable::Connection의 connect 메소드가 미리 호출됨으로써 접속한 사용자가 누구인지를 파악할 수 있는 것입니다.

send_message 메소드는 클라이언트에서 메시지 전송을 요청할 때 호출됩니다. 여기서 눈여겨볼 부분은 ActionCable.server.broadcast("chat_#{data['chat_id']}", ...) 입니다. 생성된 메시지를 해당 스트림(예: chat_1, chat_2)을 구독하고 있는 모든 구독자들에게 브로드캐스트해주는 아주 중요한 코드입니다. 첫번째 파라미터인 스트림의 이름 이외에도 두번째 파라미터로 브로드캐스트 해줄 데이터를 해시의 형태로 넘기게 됩니다. 이 데이터는 클라이언트 사이드 자바스크립트에서 객체의 형태로 참조할 수 있습니다. 우리는 chats/message 파셜을 미리 렌더링해서 HTML의 형태로 message 값을 브로드캐스트 해줍니다. 물론 단순히 json 객체의 형태로 값만 브로드캐스트한 후에 클라이언트 사이드에서 렌더링을 수행할 수도 있습니다. 하지만 위 코드처럼 하는 것이 더 간단해 보입니다.

만약 채팅 방처럼 id에 따라 나뉘어진 스트림이 아닌 글로벌 스트림(예: 현재 사이트에 접속한 전체 접속자 목록)에서/으로 브로드캐스트하려면 chat_id와 같은 정보를 명시해줄 필요가 없습니다. 즉, stream_from "appearances",  ActionCable.server.broadcast("appearances", ...와 같이 사용하면 됩니다.

다음은 채팅의 목록 및 채팅방을 보여줄 ChatController를 생성하고 라우팅을 설정해줍니다.

 

마지막으로 ChatsController의 뷰를 작성해줍니다.

이제 서버 사이드 구현은 끝이 났습니다. 클라이언트 사이드로 넘어가 봅시다.

클라이언트 사이드

가장 처음에 해줄 일은 소비자를 초기화해주는 일입니다.  액션 케이블에서 소비자를 초기화하는 다음 클라이언트 사이드 코드는 app/assets/javascripts/cable.js에 존재합니다. (이 코드는 액션 케이블에 의해 자동으로 생성되는 코드이므로 신경쓰지 않아도 됩니다.)

기본적으로 소비자는 ws://localhost:3000/cable URL로 연결을 요청하게 되지만  ActionCable.createConsumer()는 하나의 옵셔널 파라미터(url)을 가집니다. 따라서 웹소켓 URL을 변경하고 싶으면 첫번째 파라미터로 원하는  URL을 넘겨주거나 다음과 같이 config 파일을 통해 설정해줄 수 있습니다.

다음으로 ChatChannel로 구독을 요청하는 코드를 작성해 봅시다. app/assets/javascripts/channels/chat.js 파일을 다음과 같이 편집합니다.

messages.length > 0 으로 #messages 요소가 있는지 검사한 후 존재할 때에만 채팅 로직을 수행합니다. 우선 scrollBottom 함수를 정의하고 바로 호출해서 메시지의 가장 하단으로 스크롤합니다.

App.chat 객체를 생성하는 App.cable.subscriptions.create()의 첫번째 파라미터는 채널 이름 및 파라미터가 포함된 객체입니다. 이를 통해 ChatChannel에 구독을 요청하되 chat-id  파라미터를 넘겨주는 것을 알 수 있습니다. 만약 파라미터가 필요하지 않으면 App.cable.subscriptions.create("ChatChannel", ...)와 같이 사용하면 됩니다.

두번째 파라미터는 콜백 함수 및 사용자 정의 함수들이 포함되어 있는 객체입니다. 다른 참가자가 작성한 메시지를 브로드캐스트 받았을 때 received 함수가 호출되며 이때 data['messages']는 ChatChannel#send_message에서 미리 렌더링한 HTML 형태의 메시지입니다.우리는 messages 요소의 가장 하단에 HTML을 append해주고 scrollBottom()을 호출하여 가장 하단으로 스크롤해줍니다. sendMessage 함수는 메시지 전송을 위해 사용됩니다. perform() 호출을 통해 첫번째 파라미터에 해당하는 ChatChannel의 send_message라는 이름의 메소드를 리모트 프로시저 콜의 형태로 호출하게 되고 이 때 두번째 파라미터를 통해 데이터를 전달합니다.

이제 남은 과정은 단 하나, 메시지 폼을 입력하고 전송 버튼을 눌렀을 때 sendMessage 함수를 호출해주는 것입니다. app/assets/javascripts/chats.js에 다음과 같이 추가해줍니다.

모든 구현이 완료되었습니다!

결론

우리는 액션 케이블이 어떤 것인지, 그리고 이를 활용해 각각 구분된 여러 개의 채팅 방을 만들고 참여자들이 실시간으로 메시지를 주고 받을 수 있는 간단한 앱을 만들어 보았습니다. 레일즈 5.0에서 추가된 액션 케이블을 이용하면 이렇게 간단하고 쉽게 웹소켓을 활용한 채팅 앱을 만들 수 있습니다.

위의 예제는 가장 간단한 형태를 띄고 있지만 이를 응용해서 다양한 실시간 앱을 만들어 보시길 바랍니다. 그리고 그 과정에서 쉽게 해결하기 어려운 이슈가 발생한다면 언제든 Ask 서비스를 이용해 주시면 됩니다. 감사합니다!


 

레퍼런스

  1. WebSocket과 Socket.io
  2. Action Cable and WebSockets: An in-Depth Tutorial
    https://www.sitepoint.com/action-cable-and-websockets-an-in-depth-tutorial/
  3. Real-Time Rails: Implementing WebSockets in Rails 5 with Action Cable
    https://blog.heroku.com/archives/2016/5/9/real_time_rails_implementing_websockets_in_rails_5_with_action_cable
  4. ActionCable Devise Authentication
    http://www.rubytutorial.io/actioncable-devise-authentication/

6 thoughts on “레일즈 5의 액션 케이블을 활용한 채팅 앱 구현

  1. 컨트롤러 만들때 chat 인지 chats 인지 예제에 두가지 다 나와있는데 하나만 만들라고 되어있네요

    클라이언트로 접속은 어떻게 해야하는거에요.?

    1. 앗, 오타입니다.
      rails generate controller chat index show를
      rails generate controller chats index show로 바꿔주세요.
      혼란을 드려 죄송합니다.

답글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.