Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby로 구축하는 실시간 푸시와 Pub/Sub: WebSocket 서버 직접 만들기

Action Cable 같은 라이브러리 덕분에 Rails에서 실시간 기능을 구현하는 일이 한결 쉬워졌습니다. 이번 AppSignal Academy 에피소드에서는 실시간 업데이트의 핵심을 파헤치고, 최소한의 WebSocket 서버를 직접 만들어 보며 내부 동작 원리까지 살펴보겠습니다.

이번에 만들 애플리케이션은 데이터를 푸시(push)하고, WebSocket 위에서 Pub/Sub을 사용합니다. 코드를 작성하기 전에 먼저 이 세 가지 개념이 각각 무엇을 의미하는지 짚고 넘어가겠습니다.

  • 푸시(Push): 수신자가 데이터를 폴링(polling)하지 않고 데이터를 능동적으로 밀어주는 방식입니다. 주식 시세, 채팅 애플리케이션, 운영 콘솔처럼 실시간성이 중요한 서비스에는 필수적인 요소입니다.

  • Pub/Sub(발행/구독): 1990년대 월스트리트에서 TIBCO에 의해 널리 알려진 데이터 푸시용 상호작용 모델입니다. 수신자는 특정 주제(subject)를 구독하고, 발행자가 해당 주제로 데이터를 푸시하기를 기다립니다. 발행된 메시지와 리스너를 매칭할 때 와일드카드 패턴 매칭을 지원하는 경우가 많지만, 일부 단순한 구현체는 와일드카드 대신 이름이 지정된 채널만 사용합니다. 필자도 TIBCO 초기 시절에 경력을 시작했기 때문에 와일드카드 패턴 매칭의 유연함을 특히 좋아합니다.

  • WebSocket: 데이터를 교환하기 위한 프로토콜로, 주로 웹 브라우저와 애플리케이션 사이에서 사용됩니다. HTTP 연결이 WebSocket 연결로 업그레이드되면 두 엔드포인트 간에 양방향 통신이 가능해집니다. WebSocket은 애플리케이션에서 브라우저로 데이터를 푸시할 수 있을 뿐 아니라, POST나 PUT 외에 브라우저의 JavaScript 코드에서 애플리케이션으로 데이터를 전송하는 또 다른 통로도 제공합니다. 꽤 매력적이지 않나요?

내부 동작 원리

WebSocket 서버가 어떻게 동작하는지 예시를 통해 살펴보겠습니다. 브라우저의 클라이언트는 JavaScript 코드로 서버에 WebSocket 연결을 시도합니다.

var sock = new WebSocket("ws://" + document.URL.split("/")[2] + "/upgrade");

서버는 업그레이드 요청이 있음을 나타내는 표시와 함께 HTTP 요청을 받습니다. 일반적으로 서버는 애플리케이션이 업그레이드 여부를 결정하도록 위임하며, 그 방법은 앱에 제공되는 API에 따라 달라집니다. Rack을 지원하는 서버는 소켓을 하이재킹(hijack)해 개발자가 프로토콜 세부 사항을 모두 직접 처리하게 하는 옵션을 제공하거나, 제안된 PR에 따르면 업그레이드 응답만으로 충분합니다.

업그레이드는 서버와 클라이언트 간의 일련의 교환 과정입니다. 모든 브라우저와 일부 서버 젬(gem)은 이러한 세부 사항을 숨겨 줍니다. 연결이 설정되면 WebSocket 프로토콜에 따라 메시지를 주고받을 수 있습니다.

내부의 마법은 인코딩, 디코딩, 메시지 교환 프로토콜을 처리하는 것입니다. 메시지는 SHA1로 암호화된, 후행 페이로드(trailing payload)가 붙은 바이너리 고정폭 구조입니다. WebSocket 프로토콜에는 ping/pong 하트비트, 연결 열기·닫기 메시지 교환 등 여러 메시지 유형이 포함되어 있습니다. 연결 하이재크 방식을 쓰지 않는 서버들이 대신 처리해 주는 마법이 바로 이것입니다.

직접 만들어 보기

현재 시간을 구독 중인 모든 클라이언트에게 발행하는 시계 스레드 예제를 사용하겠습니다. 서버는 속도가 빠르고 복잡성을 최소화하는 Agoo로 구축합니다.

먼저 HTML 페이지에 현재 시간을 표시하는 JavaScript 클라이언트부터 시작합니다. 새로운 WebSocket을 생성한 뒤 상태(status) HTML 요소를 변경하는 onopen 콜백을 설정하고, onmessage 콜백은 message HTML 요소를 갱신합니다. 콜백은 발행/구독 교환처럼 비동기 호출을 다룰 때 흔히 쓰이는 디자인 패턴입니다.

<!-- websocket.html -->
<html>
  <body>
    <p id="status">...</p>
    <p id="message">... waiting ...</p>
 
    <script type="text/javascript">
      var sock = new WebSocket(
        "ws://" + document.URL.split("/")[2] + "/upgrade"
      );
      sock.onopen = function () {
        document.getElementById("status").textContent = "connected";
      };
      sock.onmessage = function (msg) {
        document.getElementById("message").textContent = msg.data;
      };
    </script>
  </body>
</html>

클라이언트가 완성되었으니 이제 서버를 구현해 보겠습니다. 서버는 Rack API를 사용하는 Ruby 애플리케이션입니다. Clock 클래스 자체가 /upgrade 경로의 모든 HTTP 요청을 처리하는 핸들러 역할을 합니다. 요청이 업그레이드 요청이라면 HTTP 상태 코드 200으로 Success를 반환하고, 그렇지 않으면 페이지를 찾을 수 없다는 의미의 404를 반환합니다. #call 메서드에서 유일하게 추가로 필요한 단계는 WebSocket 핸들러 할당입니다.

class Clock
  def self.call(env)
    unless env['rack.upgrade?'].nil?
      env['rack.upgrade'] = Clock
      [ 200, { }, [ ] ]
    else
      [ 404, { }, [ ] ]
    end
  end
end

API는 콜백 기반으로 동작합니다. 이 서버에서 관심을 가질 유일한 콜백은 "time" 주제에 대한 구독을 생성할 수 있게 해주는 #on_open입니다. 메시지는 주제(subject) 또는 토픽(topic)으로 식별되는 채널을 통해 교환되며, #on_open은 WebSocket 연결이 설정될 때 호출됩니다.

class Clock
  # ...
 
  def self.on_open(client)
    client.subscribe('time')
  end
end

이제 매초 현재 시간을 발행하는 스레드로 발행을 시작해 보겠습니다. Agoo.publish 호출은 "time" 주제로 메시지를 전송하고, 해당 주제를 구독 중인 모든 구독자가 메시지를 받습니다. 서버는 구독과 연결 정보를 추적해 JavaScript 클라이언트에 메시지를 전달하고, 클라이언트는 HTML 요소를 갱신합니다.

require 'agoo'
 
Thread.new {
  loop do
    now = Time.now
    Agoo.publish('time', "%02d:%02d:%02d" % [now.hour, now.min, now.sec])
    sleep(1)
  end
}

필요한 나머지 코드는 서버를 초기화하고 시작하는 부분뿐입니다. Agoo::Server.handle(:GET, '/upgrade', Clock) 호출은 서버가 /upgrade URL 경로의 HTTP GET 요청을 수신해 Clock 클래스로 전달하도록 지시합니다. 덕분에 라우팅이 Ruby 외부에서 처리되어 성능과 유연성이 향상됩니다.

Agoo::Server.init(6464, '.', thread_count: 0)
Agoo::Server.handle(:GET, '/upgrade', Clock)
Agoo::Server.start

거의 다 왔습니다. 다음 명령으로 서버를 실행하세요.

$ ruby pubsub.rb

아래와 비슷한 로그가 출력되면 서버가 정상적으로 실행 중이며 6464 포트에서 수신 대기 중이라는 뜻입니다.

I 2018/08/14 19:49:45.170618000 INFO: Agoo 2.5.0 with pid 40366 is listening on https://:6464.

동작 확인하기

https://localhost:6464/websocket.html을 열어 보겠습니다. 연결이 설정되면서 잠깐 깜빡인 뒤 연결 상태와 시간이 화면에 표시될 것입니다. 시간은 초침이 움직일 때마다 1초씩 증가합니다.

connected

19:50:12

발행/구독 방식의 웹 애플리케이션을 성공적으로 만드셨군요. 축하합니다 ;-)

오늘 에피소드에서는 WebSocket 활용법을 살펴보았습니다. Server-Sent Events(SSE)도 동일한 기능을 구현할 수 있는 또 다른 선택지이며, 전체 소스 코드 예제에는 SSE 구현도 함께 포함되어 있습니다. 더 알아보고 싶다면 이번에 사용한 Agoo 서버나 Iodine WebSocket 서버를 참고해 보세요.

궁금한 점이나 의견이 있다면 @AppSignal로 언제든지 연락해 주세요.