← 블로그

Action Cable 없는 Hotwire: Rails의 Turbo Streams를 Hibira로

· Hibira

hibira-rails를 만든 이유: broadcasts_to와 turbo_stream_from은 그대로 두고 WebSocket 연결은 Hibira가 맡습니다. 바꾼 메서드 하나, 서명된 스트림의 안전성, 너무 큰 브로드캐스트 처리, 그리고 5분 따라 하기.

Rails에서 실시간 업데이트를 만들 때 가장 매력적인 건 바로 이 한 줄입니다.

class Message < ApplicationRecord
  broadcasts_to :room
end

페이지에 <%= turbo_stream_from @room %> 한 줄만 넣으면, 누군가 메시지를 올리는 순간 그 채팅방을 열어 둔 모든 브라우저에 한 줄이 동시에 추가됩니다. JavaScript는 한 줄도 쓰지 않습니다. Hotwire가 사람들을 Rails에 빠지게 만드는 지점이죠.

문제는 프로덕션에 올릴 때 드러납니다. 이 한 줄 뒤에는 Action Cable이 있습니다. WebSocket 연결은 여러분의 Puma 안에 열리고, 여러 서버에 걸친 브로드캐스트에는 Redis나 Solid Cable이 필요합니다. 호스팅 플랫폼에는 연결 수 제한이 있고, 사용자가 늘어나면 cable server를 따로 떼어 내 별도로 확장해야 합니다.

이 글은 저희가 hibira-rails를 만든 이야기입니다. 왜 만들고 싶었는지, 어느 한 곳을 바꿨는지, 그리고 기존 Rails 앱을 5분 만에 연결하는 방법을 소개합니다.

그 한 줄 뒤에는 서버 한 벌이 통째로 있다

Action Cable은 쓰기 어려운 게 아니라, 운영하기가 어렵습니다. 페이지를 열어 둔 사용자 한 명 한 명이 애플리케이션 서버의 장시간 연결을 하나씩 차지합니다. 평소에는 잘 느껴지지 않지만, 트래픽이 몰릴 때나 배포로 재시작할 때, 서버를 교체할 때 이 연결들이 한꺼번에 끊기고 한꺼번에 다시 붙습니다. 그동안 Rails 프로세스는 일반 요청과 이 재연결 물결을 동시에 처리해야 합니다.

브라우저WebSocket 수천 개
장시간 연결
여러분의 PumaHTTP와 WebSocket을 함께 처리
발행
Redis 또는 Solid Cable서버 간 동기화
Redis 또는 Solid Cable
전달
여러분의 Puma
푸시
브라우저
Action Cable을 쓰면 연결과 브로드캐스트가 모두 여러분의 인프라 안에 있다

저희가 Hibira를 시작한 이유는 팀이 이 계층을 직접 운영하지 않아도 되게 하기 위해서입니다. Hibira는 Pusher Channels 프로토콜과 호환되는 호스팅 실시간 푸시 서비스라서, pusher-js 같은 기존 라이브러리가 그대로 연결됩니다. 그런데 Rails 개발자가 쓰는 건 pusher-js가 아니라 broadcasts_to와 turbo_stream_from입니다. 서비스를 바꾸려고 모든 model과 view를 다시 써야 한다면, 그건 '연결'이 아니라 '재작성'입니다.

그래서 목표를 하나 정했습니다. model도 view도 바꾸지 않는다. 설치하고 나면 그 한 줄이 그대로 Hibira를 거친다.

딱 한 곳만 바꾼다

turbo-rails 소스를 읽어 보면, broadcasts_to, broadcast_append_to, broadcasts_refreshes, 백그라운드 작업에서 도는 broadcast_replace_later_to 중 무엇을 쓰든 결국 모두 같은 메서드 Turbo::StreamsChannel.broadcast_stream_to로 모인다는 걸 알 수 있습니다. 이 메서드가 렌더링된 Turbo Stream HTML을 Action Cable에 넘깁니다.

덕분에 아주 깔끔한 진입점이 생겼습니다. hibira-rails는 이 메서드 하나만 덮어써서, 같은 HTML을 Hibira 채널로 보내게 합니다. 페이지 쪽도 마찬가지입니다. turbo_stream_from은 원래 <turbo-cable-stream-source>를 만들었지만, 이제는 <hibira-turbo-stream-source>를 만들고, 작은 JavaScript가 채널을 구독해 받은 내용을 Turbo에 넘깁니다. Turbo는 메시지가 어디서 왔는지 전혀 모릅니다.

이전

Modelbroadcasts_to
TurboStreamsChannel
Action Cable여러분의 Puma와 Redis
WebSocket
브라우저

이후

Modelbroadcasts_to
TurboStreamsChannel
HTTPS로 한 번 발행
Hibira팬아웃
WebSocket
브라우저
hibira-rails를 설치하면 같은 broadcasts_to가 Hibira를 거친다

이제 Rails 프로세스는 잘하는 일만 하면 됩니다. 요청을 처리하고, HTML을 렌더링하고, 한 번 발행하는 것. WebSocket 연결과 재연결, 팬아웃은 Hibira가 맡습니다.

보안 규칙은 그대로: 서명이 없으면 구독할 수 없다

전송 계층을 바꿀 때 가장 무서운 건 권한까지 함께 바뀌어 버리는 것입니다. Action Cable의 규칙은 간단합니다. turbo_stream_from이 서명된 스트림 이름을 페이지에 넣고, 서버는 서명이 올바른 구독만 받아들입니다. 그 페이지를 볼 수 있는 사람만 서명을 얻습니다.

저희는 이 규칙을 그대로 지켰습니다. Turbo 스트림 하나가 Hibira의 비공개 채널 하나에 대응하고, 채널 이름은 스트림 이름의 해시로 만듭니다. 브라우저가 구독하려면 페이지에 있던 서명을 Rails 앱에 가져가 인가와 교환해야 합니다. 서명이 틀리거나 채널과 스트림이 맞지 않으면 거부됩니다.

브라우저Rails 앱Hibira서명된 스트림 이름이 담긴 페이지turbo_stream_from연결socket_id 받기POST /hibira/turbo/authsocket_id, 채널, 서명인가 서명app secret으로 서명, secret은 서버 밖으로 나가지 않음subscribeprivate-turbo-…broadcasts_to 실행turbo-stream 이벤트 1건turbo-streamTurbo에 넘겨 화면 갱신
비공개 채널을 구독할 때 서명은 Rails 앱이 검증한다

app secret은 여러분의 서버에만 있습니다. 브라우저가 받는 건 공개용 app key뿐입니다.

직접 해 보기: 기존 앱을 5분 만에 연결

새 Rails 8.1 프로젝트로 시연합니다. 일부러 --skip-action-cable을 붙여 만들어서, Action Cable 서버 없이도 전체 과정이 동작한다는 걸 보여 드립니다.

gem을 설치하고 설치 프로그램을 실행합니다.

bundle add hibira-rails
bin/rails generate hibira:install

설치 프로그램은 config/initializers/hibira.rb를 만들고, importmap으로 pusher-js를 고정하며(jsbundling 프로젝트에서는 패키지 매니저로 설치), app/javascript/application.js에 import "hibira/turbo" 한 줄을 추가합니다.

이어서 Hibira 대시보드의 앱 페이지에 있는 세 값을 환경 변수나 bin/rails credentials:edit의 hibira: 아래에 넣습니다.

HIBIRA_APP_ID=your-app-id
HIBIRA_APP_KEY=your-app-key
HIBIRA_APP_SECRET=your-app-secret

여기서 자주 막히는 부분이 두 가지 있습니다. secret은 앱을 만든 직후 한 번만 표시되므로 그때 바로 저장하세요. app ID와 key는 앱 페이지의 개요 → 인증 정보에서 언제든 확인할 수 있습니다. 또한 Free 플랜에서는 허용 오리진에 *를 쓸 수 없습니다. 개발 중의 http://localhost:3000과 운영 도메인처럼 페이지가 열리는 오리진을 모두 추가해야 하며, 그렇지 않으면 브라우저 연결이 거부됩니다.

그리고 확인합니다.

$ bin/rails hibira:check
ok    App 1 on ws.hibira.io:443
ok    Credentials accepted by the REST API
ok    Turbo Streams go through Hibira
info  Push notifications off (set HIBIRA_PUSH_INSTANCE_ID and HIBIRA_PUSH_SECRET_KEY)

model과 view는 Turbo 핸드북에 나온 그대로이고, 한 글자도 바꾸지 않았습니다. 창을 두 개 열고 한쪽에서 메시지를 보내면, 다른 쪽에 새로 고침 없이 나타납니다.

브라우저 창 두 개: 창 B에서 보낸 메시지가 곧바로 창 A에 나타난다. 중간에 Action Cable은 없다.
브라우저 창 두 개: 창 B에서 보낸 메시지가 곧바로 창 A에 나타난다. 중간에 Action Cable은 없다.

페이지를 이동해도 마찬가지입니다. Turbo가 페이지를 바꿀 때 이전 페이지의 구독을 잠시 남겨 두었다가, 새 페이지가 같은 스트림을 구독하면 그대로 이어서 쓰기 때문에 연결이 끊겼다 다시 붙지 않습니다.

테스트가 깨지지 않는다

브로드캐스트를 외부 서비스로 보내게 되면 가장 먼저 탈이 나는 게 테스트입니다. 테스트를 돌릴 때마다 네트워크에 접속하거나, Turbo에 내장된 assert_turbo_stream_broadcasts가 갑자기 실패하곤 합니다.

그래서 기본값은 turbo = :auto입니다. 키가 설정되어 있으면 Hibira를 거치지만, test 환경에서는 Action Cable 동작을 그대로 유지합니다. 기존의 브로드캐스트 검증은 그대로 통과하고, CI도 외부에 접속하지 않습니다. 강제로 켜거나 끄고 싶다면 true나 false로 설정하면 됩니다.

메시지가 너무 클 때

개발하면서 가장 깊이 고민한 부분입니다. Turbo Stream이 보내는 건 렌더링된 HTML입니다. 상품 카드 하나는 수백 바이트일 수 있지만, 표 전체는 몇 KB가 될 수 있습니다. 그리고 각 요금제에는 이벤트 하나당 크기 제한이 있고, Free 요금제는 4 KB입니다.

제한을 넘으면 Hibira는 그 이벤트를 거부합니다. 아무 처리도 하지 않으면 다른 사용자의 화면이 조용히 갱신되지 않는데, 이게 가장 나쁜 경우입니다.

저희의 해법은 이렇습니다. 너무 큰 브로드캐스트는 자동으로 Turbo 8의 page refresh로 바꿔 보냅니다. 수십 바이트밖에 되지 않고, 받은 페이지가 최신 내용을 스스로 다시 가져옵니다. append처럼 한 줄만 늘어나지는 않지만, 화면은 반드시 맞게 됩니다.

Modelbroadcast_append_to
12 KB의 HTML
Hibira제한 초과로 거부
Hibira
너무 크다고 알림
hibira-rails대신 refresh를 보냄
hibira-rails
수십 바이트
Hibira
브라우저페이지를 다시 가져오므로 화면은 항상 정확
이벤트 제한을 넘는 브로드캐스트는 자동으로 page refresh로 바뀐다

원래 긴 목록 페이지라면 Turbo 8의 broadcasts_refreshes와 turbo_refreshes_with method: :morph 조합을 더 추천합니다. 브로드캐스트가 모두 작고, 화면은 morph로 갱신되며, 스크롤 위치도 튀지 않습니다. 자동 refresh가 필요 없다면 로그만 남기고 버리거나 예외를 던지도록 설정할 수도 있습니다.

Turbo만이 아니다

Rails와 Hibira를 연결한 김에 자주 하는 일 몇 가지도 함께 준비했습니다.

  • 직접 만드는 비공개 채널과 presence 채널: authorize_channel을 설정하고 그 안에서 current_user로 누가 어떤 채널을 구독할 수 있는지 정합니다. presence 채널이면 사용자 정보를 반환합니다. ApplicationController를 상속하므로 로그인 상태와 CSRF 보호가 평소대로 동작합니다.
  • Webhook: /hibira/webhooks에서 Hibira의 webhook을 받고, 서명을 자동으로 검증합니다. secret을 교체하는 유예 기간에는 이전 secret으로 서명된 것도 받아들이므로, 다시 배포하기 전에 이벤트를 놓치지 않습니다.
  • 푸시 알림: Hibira.push로 iOS, Android, 브라우저 푸시를 보내고, /hibira/push/token으로 SDK용 사용자 토큰을 발급합니다.

지원 범위

  • Rails 7.0~8.1, Ruby 3.1 이상, turbo-rails 1.5~2. 모든 조합을 CI에서 돌립니다.
  • importmap과 jsbundling 모두 사용할 수 있습니다(Propshaft의 assets:precompile 확인 완료).
  • --skip-action-cable로 만든 프로젝트에서도 사용할 수 있습니다.

hibira-rails는 RubyGems에 공개되어 있고, 소스 코드는 MIT 라이선스로 GitHub에 있습니다. Action Cable로 버티고 있는 Hotwire 앱이 있다면 한번 써 보세요. Hibira 무료 요금제는 신용카드가 필요 없고, 설치하고 나면 그 broadcasts_to 한 줄을 더 이상 여러분의 서버가 떠안지 않아도 됩니다.

다른 프레임워크와 엔진용으로도 비슷한 연동을 만들고 있습니다. 다음은 무엇일지, 다음 글에서 공개하겠습니다. 기대해 주세요.