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 채널에 들어가기 전에, 서버가 앱 시크릿으로 그 요청에 서명합니다. 시크릿은 서버 밖으로 나가지 않습니다.
연결하기
클라이언트는 앱의 WebSocket URL에 연결합니다. 경로에는 앱 키(공개 정보라 브라우저에 넣어도 안전)가 들어가고, 쿼리 문자열로 프로토콜 버전과 라이브러리를 알립니다.
wss://ws-hkg.hibira.io/app/APP_KEY?protocol=7&client=js&version=8.4.0&flash=false| 파라미터 | 의미 |
|---|---|
APP_KEY | 대시보드의 앱 키. 존재하지 않는 키면 코드 4001로 연결이 닫힙니다. |
protocol | 프로토콜 버전. Hibira는 5, 6, 7을 받습니다. 없으면 4008, 지원하지 않는 버전이면 4007로 닫힙니다. |
client, version | 라이브러리 이름과 버전. 로그와 통계에 쓰입니다. |
WebSocket이 열리면 서버는 pusher:connection_established를 보냅니다. data에는 서명과 HTTP API에서 이 연결을 식별하는 소켓 ID와, 초 단위의 활동 타임아웃이 들어 있습니다.
{"event":"pusher:connection_established","data":"{\"socket_id\":\"123.456\",\"activity_timeout\":120}"}data가 JSON 객체가 아니라 JSON을 담은 문자열이라는 점에 주의하세요. 서버가 보내는 모든 이벤트의 data는 이렇게 인코딩되며, 클라이언트 라이브러리가 디코딩해 줍니다.
메시지 형식
양방향의 모든 메시지는 event 이름, 대개 channel, 그리고 data를 가진 JSON 객체 하나입니다. 이름이 pusher: 또는 pusher_internal:로 시작하는 이벤트는 프로토콜의 것이고, 그 밖의 이름은 애플리케이션 이벤트입니다.
| 이벤트 | 방향 | 용도 |
|---|---|---|
pusher:connection_established | 서버 → 클라이언트 | 연결 준비 완료. 소켓 ID를 담습니다. |
pusher:error | 서버 → 클라이언트 | code와 message가 있는 오류. 아래 오류 코드를 참고하세요. |
pusher:ping / pusher:pong | 양방향 | 하트비트. |
pusher:subscribe | 클라이언트 → 서버 | 채널 참여. private와 presence 채널은 서명을 붙입니다. |
pusher:unsubscribe | 클라이언트 → 서버 | 채널 나가기. |
pusher_internal:subscription_succeeded | 서버 → 클라이언트 | 구독 성공. presence 채널은 멤버 목록을 담습니다. |
pusher:subscription_error | 서버 → 클라이언트 | 구독 거부. 예: AuthError. |
pusher_internal:member_added / pusher_internal:member_removed | 서버 → 클라이언트 | 사용자가 presence 채널에 들어오거나 나감. |
pusher:signin / pusher:signin_success | 양방향 | 사용자 인증(로그인). 아래에서 설명합니다. |
pusher:cache_miss | 서버 → 클라이언트 | cache 채널에 아직 캐시된 것이 없음. |
pusher_internal:subscription_count | 서버 → 클라이언트 | 구독 수 집계가 켜져 있을 때, 채널을 구독 중인 소켓 수. |
연결 유지
연결은 프로토콜 수준의 하트비트를 씁니다. 클라이언트는 activity_timeout초(Hibira는 120초) 동안 아무것도 받지 못하면 pusher:ping을 보내고, 서버가 pusher:pong으로 답합니다. 서버도 조용한 클라이언트에게 똑같이 합니다. 120초 동안 조용하면 pusher:ping을 보내고, 30초 안에 아무 메시지도 오지 않으면 4201로 연결을 닫으며, 그 뒤 클라이언트가 다시 연결합니다. 사용자와 Hibira 사이에 프록시나 로드 밸런서가 있다면 유휴 타임아웃을 150초보다 길게 설정해야 합니다.
채널
채널 이름의 접두사가 종류를 정합니다. 이름은 최대 164자이며 A-Z a-z 0-9 _ - = @ , . ;를 쓸 수 있습니다.
| 채널 | 접두사 | 서명 필요 | 클라이언트 이벤트 |
|---|---|---|---|
| Public | 없음(예: orders) | 아니요 | 아니요 |
| Private | private- | 예 | 켜면 가능 |
| Presence | presence- | 예, 사용자 정보 포함 | 켜면 가능 |
| 암호화 | private-encrypted- | 예 | 아니요 |
| Cache | cache-, private-cache-, presence-cache-, private-encrypted-cache- | 기본 종류와 같음 | 기본 종류와 같음 |
| Server-to-user | #server-to-user-USER_ID | 로그인한 본인만 | 아니요 |
| 대규모 presence(Hibira) | presence-large- | 예, 사용자 정보 포함 | 켜면 가능 |
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진수) 앞에 앱 키를 붙인 값입니다.
서버 라이브러리는 호출 한 번으로 처리합니다(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:error4009로 답합니다. - 보낸 사람은 자기 이벤트를 받지 않습니다. presence 채널에서는 받는 쪽에 보낸 사람의
user_id가 전달됩니다. - 연결당 초당 최대 10개입니다. 넘치는 것은 버려지고
pusher:error4301이 옵니다.
{"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로 오고 연결은 열려 있습니다.
| 범위 | 클라이언트 동작 |
|---|---|
| 4000–4099 | 같은 설정으로 다시 연결하지 않습니다. |
| 4100–4199 | 간격을 두고 다시 연결합니다. |
| 4200–4299 | 즉시 다시 연결합니다. |
| 4300–4399 | 오류만 알림. 연결은 유지됩니다. |
| 코드 | Hibira가 보내는 경우 |
|---|---|
| 4001 | 앱 키가 존재하지 않음. |
| 4003 | 앱이 비활성화됨. |
| 4004 | 계정이 요금제의 연결 수 한도를 넘음. |
| 4007 | 지원하지 않는 프로토콜 버전. |
| 4008 | 프로토콜 버전이 없음. |
| 4009 | 허용되지 않음: 출처가 앱의 허용 목록에 없거나, 연결이 종료되었거나, 클라이언트 이벤트가 허용되지 않음. |
| 4100 | 용량 초과, 또는 클라이언트가 너무 느리게 읽음. 간격을 두고 다시 시도. |
| 4200 | 서버 재시작 중. 즉시 다시 연결. |
| 4201 | pong이 제때 오지 않음. |
| 4301 | 클라이언트 이벤트 속도 한도 또는 일일 메시지 한도에 도달. |
HTTP API
서버는 WebSocket과 같은 호스트의 /apps/APP_ID/ 아래로 HTTP 요청을 보내 발행하고 조회합니다.
| 요청 | 용도 |
|---|---|
POST /apps/APP_ID/events | 이벤트 하나를 최대 100개 채널에 발행. |
POST /apps/APP_ID/batch_events | 요청 한 번에 최대 10개 이벤트 발행. |
GET /apps/APP_ID/channels | 구독자가 있는 채널 목록. 접두사로 거르거나 구독자 수를 함께 볼 수 있습니다. |
GET /apps/APP_ID/channels/CHANNEL | 채널 하나의 상태. |
GET /apps/APP_ID/channels/CHANNEL/users | presence 채널의 사용자. |
POST /apps/APP_ID/users/USER_ID/terminate_connections | 로그인한 사용자의 모든 연결 닫기. |
이벤트 본문에는 이벤트 이름, 채널, 문자열로 된 데이터를 넣습니다. 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=529798e83b8523846d8314a6b2b6c517import 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"}]}| 이벤트 | 보내는 시점 |
|---|---|
channel_occupied / channel_vacated | 채널에 첫 구독자가 생기거나 마지막 구독자가 사라짐. |
member_added / member_removed | 사용자의 첫 소켓이 presence 채널에 들어오거나 마지막 소켓이 나감. |
client_event | 클라이언트가 client- 이벤트를 보냄. |
cache_miss | 클라이언트가 캐시가 빈 cache 채널을 구독함. |
subscription_count | 집계가 켜져 있을 때 채널의 구독 수가 바뀜. |
웹훅을 믿기 전에 상수 시간 비교로 서명을 확인하세요.
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는 같은 호스트를 씁니다.
| 클러스터 | 리전 | 호스트 |
|---|---|---|
hkg | 홍콩 | ws-hkg.hibira.io(ws.hibira.io도 가능) |
tyo | 도쿄 | ws-tyo.hibira.io(예정) |
sin | 싱가포르 | ws-sin.hibira.io(예정) |
클라이언트 라이브러리는 호스트를 옵션으로 받으며, 이때 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 테스터를 열어 보세요. 앱 연결의 모든 프레임을 보여 줍니다.