Pusher 프로토콜 해설: 연결, 채널, 인증, 웹훅

Pusher Channels 프로토콜(버전 7)의 동작 방식: WebSocket 핸드셰이크, 메시지 형식, 채널 종류와 서명, 클라이언트 이벤트, 오류 코드, HTTP API와 웹훅, 그리고 Hibira의 구현.

Pusher 프로토콜이란

Pusher Channels 프로토콜은 pusher-js, Laravel Echo, 공식 Pusher 서버 라이브러리가 실시간 서비스와 통신할 때 쓰는 WebSocket 메시지와 HTTP 호출의 약속입니다. 공개 문서가 있고 안정적이며, 현재 버전은 7입니다. Hibira는 이 프로토콜을 구현한 Pusher 호환 독립 서비스이므로, 호스트만 바꾸면 이 라이브러리들이 그대로 동작합니다.

프로토콜은 세 부분으로 이루어집니다.

  • WebSocket 연결. 브라우저와 앱이 WebSocket 하나를 열고, 그 위에서 채널을 구독하고 이벤트를 받습니다.
  • HTTP API. 서버가 서명된 HTTP 요청으로 이벤트를 발행하고 채널 상태를 조회합니다. 클라이언트는 이것으로 발행하지 않습니다.
  • 웹훅. 채널에 구독자가 생기거나 비었을 때, presence 멤버가 들어오거나 나갔을 때, 클라이언트가 이벤트를 보냈을 때 서비스가 서버에 알려 줍니다.

호출이 하나 더 있는데, 바로 내 클라이언트와 내 서버 사이의 호출입니다. 클라이언트가 private 또는 presence 채널에 들어가기 전에, 서버가 앱 시크릿으로 그 요청에 서명합니다. 시크릿은 서버 밖으로 나가지 않습니다.

Browser / apppusher-js · EchoHibiraws.hibira.ioYour serverpusher-http-*WebSocketprotocol 7HTTP APIwebhookschannel authorizationPOST socket_id + channel_name → {"auth": "key:signature"}

연결하기

클라이언트는 앱의 WebSocket URL에 연결합니다. 경로에는 앱 키(공개 정보라 브라우저에 넣어도 안전)가 들어가고, 쿼리 문자열로 프로토콜 버전과 라이브러리를 알립니다.

wss://ws-hkg.hibira.io/app/APP_KEY?protocol=7&client=js&version=8.4.0&flash=false

WebSocket이 열리면 서버는 pusher:connection_established를 보냅니다. data에는 서명과 HTTP API에서 이 연결을 식별하는 소켓 ID와, 초 단위의 활동 타임아웃이 들어 있습니다.

{"event":"pusher:connection_established","data":"{\"socket_id\":\"123.456\",\"activity_timeout\":120}"}

data가 JSON 객체가 아니라 JSON을 담은 문자열이라는 점에 주의하세요. 서버가 보내는 모든 이벤트의 data는 이렇게 인코딩되며, 클라이언트 라이브러리가 디코딩해 줍니다.

ClientHibiraGET wss://ws.hibira.io/app/APP_KEY?protocol=7Upgrade: websocketpusher:connection_established{"socket_id":"123.456","activity_timeout":120}pusher:subscribe{"channel":"orders"}pusher_internal:subscription_succeeded"channel":"orders"order-shippedyour event, from your server120 s without a messagepusher:pingpusher:pong

메시지 형식

양방향의 모든 메시지는 event 이름, 대개 channel, 그리고 data를 가진 JSON 객체 하나입니다. 이름이 pusher: 또는 pusher_internal:로 시작하는 이벤트는 프로토콜의 것이고, 그 밖의 이름은 애플리케이션 이벤트입니다.

연결 유지

연결은 프로토콜 수준의 하트비트를 씁니다. 클라이언트는 activity_timeout초(Hibira는 120초) 동안 아무것도 받지 못하면 pusher:ping을 보내고, 서버가 pusher:pong으로 답합니다. 서버도 조용한 클라이언트에게 똑같이 합니다. 120초 동안 조용하면 pusher:ping을 보내고, 30초 안에 아무 메시지도 오지 않으면 4201로 연결을 닫으며, 그 뒤 클라이언트가 다시 연결합니다. 사용자와 Hibira 사이에 프록시나 로드 밸런서가 있다면 유휴 타임아웃을 150초보다 길게 설정해야 합니다.

채널

채널 이름의 접두사가 종류를 정합니다. 이름은 최대 164자이며 A-Z a-z 0-9 _ - = @ , . ;를 쓸 수 있습니다.

public 채널 구독은 메시지 하나로 끝나며, pusher_internal:subscription_succeeded가 돌아옵니다.

{"event":"pusher:subscribe","data":{"channel":"orders"}}

그다음 서버가 HTTP API로 orders에 발행하면 모든 구독자가 이벤트를 받습니다.

{"event":"order-shipped","channel":"orders","data":"{\"id\":1042}"}

private와 presence 채널 인가

private 채널에는 서버가 이 연결의 참여를 허락했다는 증명이 필요합니다. 클라이언트 라이브러리가 소켓 ID와 채널 이름을 보내 인증 엔드포인트에 묻습니다. 서버는 사용자의 세션을 확인한 뒤 서명을 돌려줍니다. 서명은 앱 시크릿을 키로 한 socket_id:channel_name의 HMAC-SHA256(16진수) 앞에 앱 키를 붙인 값입니다.

ClientYour serverHibiraconnectsocket_id "123.456"POST /pusher/authsocket_id, channel_name{"auth":"APP_KEY:HMAC…"}pusher:subscribe{"channel":"private-orders","auth":…}HMAC-SHA256(secret, "123.456:private-orders") matchespusher_internal:subscription_succeeded

서버 라이브러리는 호출 한 번으로 처리합니다(Node의 pusher.authorizeChannel(socketId, channel), PHP의 $pusher->authorizeChannel(), Laravel의 Broadcast::channel()). 직접 작성하면 다음과 같습니다.

import crypto from 'node:crypto';

// POST /pusher/auth with socket_id and channel_name, after checking the session
function authorize(socketId, channel) {
  const signature = crypto
    .createHmac('sha256', process.env.PUSHER_APP_SECRET)
    .update(`${socketId}:${channel}`)
    .digest('hex');
  return { auth: `${process.env.PUSHER_APP_KEY}:${signature}` };
}

클라이언트는 그 결과를 구독 요청에 담아 보냅니다.

{"event":"pusher:subscribe","data":{"channel":"private-orders","auth":"APP_KEY:81065a8a0a6e7e1f501edc52ef55314aa125075065593e6e9bed5e538ec3d12f"}}

서명이 틀리면 종류가 AuthError인 pusher:subscription_error로 거부되며, 연결은 열린 채로 남습니다.

Presence 채널

presence 채널은 사용자가 누구인지도 알립니다. 인증 응답에 channel_data(user_id와 선택 항목인 user_info를 담은 JSON 문자열)가 추가되고 서명도 이를 포함합니다. 서명할 문자열은 socket_id:channel_name:channel_data입니다.

{"auth":"APP_KEY:SIGNATURE","channel_data":"{\"user_id\":\"42\",\"user_info\":{\"name\":\"Mei\"}}"}

그러면 pusher_internal:subscription_succeeded가 멤버(ids, hash, count)를 담아 오고, 사용자가 오가면 member_added와 member_removed가 이어집니다. 탭을 여러 개 연 사용자도 한 명으로 셉니다. Hibira는 Pusher와 마찬가지로 presence 채널당 멤버 100명, user_info 1 KB까지 허용합니다. 더 큰 청중에게는 Hibira 확장인 presence-large- 채널이 인원수와 멤버 100명의 샘플을 보내고, 참여와 이탈은 주기적으로 묶어서 전달합니다.

암호화 채널

private-encrypted- 채널에서는 서버 라이브러리가 채널 이름과 암호화 마스터 키에서 유도한 키(NaCl secretbox)로 각 이벤트의 데이터를 암호화합니다. 인증 응답은 그 채널의 키도 shared_secret으로 돌려주므로 클라이언트가 복호화할 수 있습니다. Hibira는 private 채널처럼 구독을 인가하고, 내용을 읽을 수 없는 암호문을 그대로 중계합니다. 암호화 채널에서는 클라이언트 이벤트를 쓸 수 없습니다.

사용자 인증

채널과 별개로, 연결은 사용자로 로그인할 수도 있습니다. 클라이언트는 user_data(최소한 id가 있는 JSON 문자열)와 socket_id::user::user_data에 서명한 auth를 담아 pusher:signin을 보냅니다. pusher:signin_success 이후에는 서버가 그 사용자의 모든 연결에 이벤트를 보내거나(sendToUser, #server-to-user-USER_ID로 전달) 모든 연결을 닫을 수 있습니다(terminate_connections).

클라이언트 이벤트

클라이언트는 서버를 거치지 않고 같은 채널의 다른 구독자에게 이벤트를 바로 보낼 수 있습니다. 입력 중 표시, 커서 위치, 게임 동작 등에 알맞습니다. 규칙은 다음과 같습니다.

  • 이벤트 이름은 client-로 시작합니다.
  • 클라이언트가 참여한 private와 presence 채널에서만 쓸 수 있습니다. public과 암호화 채널에서는 안 됩니다.
  • 앱 설정에서 클라이언트 이벤트를 켜야 합니다. 꺼져 있으면 서버가 pusher:error 4009로 답합니다.
  • 보낸 사람은 자기 이벤트를 받지 않습니다. presence 채널에서는 받는 쪽에 보낸 사람의 user_id가 전달됩니다.
  • 연결당 초당 최대 10개입니다. 넘치는 것은 버려지고 pusher:error 4301이 옵니다.
{"event":"client-typing","channel":"private-chat-7","data":{"user":"Mei"}}

서버는 client_event 웹훅으로 클라이언트 이벤트를 따라갈 수 있습니다.

Cache 채널

cache 채널은 마지막 이벤트를 30분 동안 기억했다가, 새 구독자마다 subscription_succeeded 직후에 보내 줍니다. 덕분에 페이지는 서버에 묻지 않고도 현재 상태를 보여 줄 수 있습니다. 캐시된 것이 없으면 클라이언트는 pusher:cache_miss를 받고, 서버는 cache_miss 웹훅을 받아 현재 값을 발행할 수 있습니다.

오류 코드와 종료 코드

4000~4299 코드는 연결을 닫으며, 범위가 클라이언트의 다음 동작을 알려 줍니다. 4300 이상의 코드는 pusher:error로 오고 연결은 열려 있습니다.

HTTP API

서버는 WebSocket과 같은 호스트의 /apps/APP_ID/ 아래로 HTTP 요청을 보내 발행하고 조회합니다.

이벤트 본문에는 이벤트 이름, 채널, 문자열로 된 데이터를 넣습니다. socket_id를 주면 이벤트를 일으킨 연결이 제외되어, 보낸 사람이 자기 변경을 두 번 보지 않습니다. 이벤트 데이터는 최대 10 KB입니다.

{"name":"order-shipped","channels":["orders"],"data":"{\"id\":1042}","socket_id":"123.456"}

요청 서명

모든 요청에는 auth_key, auth_timestamp(Unix 초, 서버 시계와의 차이 600초 이내), auth_version=1.0, 본문이 있는 요청이라면 body_md5, 그리고 auth_signature가 붙습니다. 서명은 메서드, 경로, 이름순으로 정렬한 나머지 파라미터를 줄바꿈으로 이은 문자열의 HMAC-SHA256(앱 시크릿을 키로 한 16진수)입니다.

POST
/apps/123/events
auth_key=APP_KEY&auth_timestamp=1792458481&auth_version=1.0&body_md5=529798e83b8523846d8314a6b2b6c517
import crypto from 'node:crypto';

const body = JSON.stringify({ name: 'order-shipped', channels: ['orders'], data: '{"id":1042}' });
const params = {
  auth_key: process.env.PUSHER_APP_KEY,
  auth_timestamp: Math.floor(Date.now() / 1000),
  auth_version: '1.0',
  body_md5: crypto.createHash('md5').update(body).digest('hex'),
};
const query = Object.keys(params).sort().map((k) => `${k}=${params[k]}`).join('&');
const path = `/apps/${process.env.PUSHER_APP_ID}/events`;
const signature = crypto
  .createHmac('sha256', process.env.PUSHER_APP_SECRET)
  .update(`POST\n${path}\n${query}`)
  .digest('hex');

await fetch(`https://${process.env.PUSHER_HOST}${path}?${query}&auth_signature=${signature}`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body,
});

실제로는 공식 서버 라이브러리(Node의 pusher, pusher/pusher-php-server, Python의 pusher, pusher-http-go 등)가 호스트 옵션만 설정하면 대신 서명해 줍니다.

웹훅

웹훅은 앱 페이지에서 설정한 URL로 Hibira가 보내는 POST 요청입니다. 모든 요청에는 서명이 있습니다. X-Pusher-Key에는 앱 키가, X-Pusher-Signature에는 원본 본문의 HMAC-SHA256(앱 시크릿을 키로 한 16진수)이 들어 있습니다. 이벤트는 최대 1초 동안 모아서 요청당 최대 100개씩 보냅니다.

{"time_ms":1792458481958,"events":[{"name":"channel_occupied","channel":"orders"}]}

웹훅을 믿기 전에 상수 시간 비교로 서명을 확인하세요.

import crypto from 'node:crypto';

function verify(rawBody, signature) {
  const expected = crypto.createHmac('sha256', process.env.PUSHER_APP_SECRET).update(rawBody).digest('hex');
  return signature?.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

Hibira의 프로토콜 대응

Pusher의 클러스터처럼 각 앱은 클러스터 하나에 속하며, 그 클라이언트와 서버는 클러스터의 호스트에 연결합니다. WebSocket과 HTTP API는 같은 호스트를 씁니다.

클라이언트 라이브러리는 호스트를 옵션으로 받으며, 이때 cluster 옵션은 무시됩니다.

const pusher = new Pusher('APP_KEY', {
  cluster: 'hkg',
  wsHost: 'ws-hkg.hibira.io',
  forceTLS: true,
  enabledTransports: ['ws', 'wss'],
});

수정 없이 동작하는 것: pusher-js, Laravel Echo와 Laravel Broadcasting, 공식 서버 라이브러리, 공식 iOS·Android 라이브러리, 호스트를 설정할 수 있는 Flutter와 Unity 커뮤니티 클라이언트, 그리고 Beams 호환 푸시 알림. 기존 앱을 옮길 때는 호스트 변경과 새 자격 증명만 있으면 됩니다. Pusher에서 마이그레이션과 연동 가이드를 참고하세요. 프로토콜을 실시간으로 보고 싶다면 WebSocket 테스터를 열어 보세요. 앱 연결의 모든 프레임을 보여 줍니다.