실시간 (Realtime)
WebSocket 기반 실시간 통신 API입니다. 채팅, 알림, 협업 기능 등에 사용합니다.
📌
cb.realtime은 카테고리(category) 기반 pub/sub 입니다. 카테고리는 콘솔에서 생성하거나 서버에서 자동 생성된 이름(예:chat-room-1)을 사용합니다.
연결
// Public Key 만으로 연결 (비로그인)
await cb.realtime.connect()
// 또는 앱 멤버 토큰으로 연결
const member = await cb.auth.signInMember({ login_id: 'myuser123', password: 'password123!' })
await cb.realtime.connect({ accessToken: member.access_token })connect() 의 옵션:
| 옵션 | 타입 | 기본값 | 설명 |
|---|---|---|---|
accessToken | string | - | 앱 멤버 JWT (지정 시 Public Key 대신 사용) |
userId | string | - | 표시용 사용자 식별자 |
maxRetries | number | 5 | 자동 재연결 횟수 |
retryInterval | number | 1000 | 재연결 간격 (ms) |
timeout | number | 30000 | 응답 대기 타임아웃 (ms) |
debug | boolean | false | 디버그 로그 |
카테고리 구독
const chat = await cb.realtime.subscribe('chat-room-1')
console.log(chat.info.category, chat.info.persist)subscribe() 가 반환하는 Subscription 객체는 다음 메서드를 가집니다:
| 메서드 | 설명 |
|---|---|
onMessage(handler) | 메시지 수신 핸들러 등록 (해제 함수 반환) |
send(data, options?) | 메시지 전송 |
getHistory(limit?) | 히스토리 조회 (persist=true 카테고리만) |
unsubscribe() | 구독 해제 |
구독 객체의 channel 은 서버에 구독한 채널 이름이고, room 을 구독했으면 room 이 있습니다(6.7.0+, 아래 "room 채널").
메시지 수신 / 전송
const chat = await cb.realtime.subscribe('chat-room-1')
// 수신 핸들러 등록
const off = chat.onMessage<{ text: string }>((msg) => {
console.log('새 메시지:', msg.data.text)
console.log('보낸 멤버:', msg.fromMember) // 서버가 검증한 앱 멤버 ID (6.7.0+, 익명이 보낸 메시지면 undefined)
})
// 전송 (기본적으로 발신자에게도 echo 됨)
await chat.send({ text: '안녕!' })
// echo 없이 전송
await chat.send({ text: '...' }, { includeSelf: false })
// 핸들러 해제
off()보낸 사람 확인 (6.7.0+)
msg.from 은 보낸 연결이 접속할 때 스스로 정해 보내는 client_id 라 누구나 다른 값을 흉내 낼 수 있습니다. 메시지 data 안에 넣은 userId 같은 값도 마찬가지입니다. 서버가 검증한 보낸 사람은 msg.fromMember 입니다.
| 필드 | 값 | 믿을 수 있나 |
|---|---|---|
from | 보낸 연결의 client_id | 아니오 (클라이언트가 정함) |
fromMember | 보낸 사람의 앱 멤버 ID. 보낸 연결이 accessToken 으로 접속했을 때만 있음 | 예 (서버 검증) |
fromPublisher | 보낸 연결이 방송인인지 | 예 (서버 판정) |
chat.onMessage((m) => {
if (!m.fromMember) return // 로그인 멤버의 메시지만 처리
if (blocked.has(m.fromMember)) return // 차단, 신고, 점수는 멤버 ID 로
render(m.fromMember, m.data)
})히스토리(getHistory)의 메시지에도 fromMember 가 실립니다. 익명(공개 키) 연결이 보낸 메시지와 구버전 서버가 보낸 메시지에는 없습니다.
히스토리 조회
persist=true 로 설정된 카테고리만 메시지를 저장합니다.
if (chat.info.persist) {
const history = await chat.getHistory(50)
history.messages.forEach((m) => {
console.log(m.from, m.data, new Date(m.sentAt).toISOString())
})
}구독 해제 / 연결 종료
await chat.unsubscribe()
cb.realtime.disconnect()연결 상태
const state = cb.realtime.getState()
// 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
cb.realtime.connectionId // 현재 연결 ID
cb.realtime.appId // 연결된 앱 ID연결 거부 (6.5.0+)
서버가 연결을 받지 않으면 connect() 가 RealtimeConnectionError 를 던집니다. code 로 분기하고 retryable 로 재시도 여부를 정하세요.
import { RealtimeConnectionError } from 'connectbase-client'
try {
await cb.realtime.connect()
} catch (e) {
if (e instanceof RealtimeConnectionError) {
// 'connection_limit_exceeded' | 'quota_exceeded' | 'server_at_capacity' | 'unauthorized' | ...
if (e.code === 'connection_limit_exceeded') showFull(e.details?.limit)
else if (e.retryable) setTimeout(() => cb.realtime.connect(), e.retryAfterMs ?? 5000)
}
}- 동시접속 한도는 계정(앱 소유자) 단위이고, 같은 IP 에서의 동시 연결은 1000개까지입니다.
- 서버가 거부한 연결은 SSE 폴백으로 넘어가지 않습니다. 자동 재연결 중의 거부는
retryable이면 계속 시도하고, 아니면 멈춘 뒤onError로 알립니다. - 서버 코드(함수)에서 realtime 을 쓰면 끝날 때
cb.realtime.disconnect()를 부르세요. 함수 컨테이너는 재사용되므로 닫지 않은 소켓이 호출마다 쌓입니다. - 한 IP 에서 연결을 짧은 시간에 많이 열면(초당 20개, 순간 200개 초과) 서버가
rate_limited로 거부합니다. 6.6.0+ 는 서버가 알려 준 시간(retryAfterMs)에 지터를 더해connect()안에서 스스로 다시 붙고, 연결 제한 시간(timeout) 안에 못 붙을 때만 던집니다. 실제 관객은 IP 가 서로 달라 이 한도와 무관합니다. - 인증 서버가 잠깐 닿지 않아
auth_unavailable로 거부되면 6.7.0+ 는rate_limited와 같이connect()안에서 서버가 알려 준 시간(구버전 서버라 시간이 없으면 지수 백오프) 뒤 스스로 다시 붙습니다.unauthorized처럼 다시 해도 소용없는 거부는 바로 던집니다.
방송형 카테고리 (6.6.0+)
1명이 방송하고 많은 사람이 받는 카테고리(가상 콘서트, 라이브)는 콘솔의 카테고리 설정에서 방송인 역할(publisher_roles)과 발행 정책(send_policy)을 정합니다. 방송인은 그 역할을 가진 로그인 멤버(accessToken) 연결이고, 공개 키(익명) 연결은 방송인이 될 수 없습니다.
| 정책 | 방송인의 메시지 | 다른 구독자의 메시지 |
|---|---|---|
open (기본) | 모두에게 | 모두에게 |
upstream | 모두에게 | 방송인에게만 (히스토리에 저장 안 됨) |
publishers_only | 모두에게 | 보낼 수 없음 (SEND_NOT_ALLOWED) |
import { RealtimeRequestError } from 'connectbase-client'
const concert = await cb.realtime.subscribe('concert')
concert.info.isPublisher // 이 연결이 방송인인지 (구독할 때 정해짐)
concert.onMessage((m) => {
if (m.fromPublisher) renderStage(m.data) // 서버가 보증하는 방송인 메시지
else addReaction(m.data) // upstream: 관객 반응은 방송인만 받는다
})
await concert.send({ cheer: 1 }) // upstream 이면 방송인에게만
await concert.send({ cheer: 1 }, { to: 'publishers' }) // open 카테고리에서도 이 메시지만 방송인에게
try {
await concert.send({ x: 1 })
} catch (e) {
if (e instanceof RealtimeRequestError && e.code === 'SEND_NOT_ALLOWED') hideSendButton()
}- 관객 1,000명이 반응을 보내도
upstream이면 반응 하나가 방송인에게 한 번만 갑니다. 방송인이 받은 반응을 합쳐 자기 방송에 실어 보내세요. - 방송인 여부는 구독할 때 정해집니다. 멤버 역할이나 카테고리 설정을 바꾸면 다시 구독(재연결)할 때 반영됩니다(카테고리 설정은 저장하면 몇 초 안에 서버 전체에 퍼집니다).
- room 채널에서는 room 인가 함수가 room 마다 방송인을 정할 수도 있습니다(6.7.0+, 아래 "room 채널"). 발행 정책도 room 단위로 적용되어 한 room 의 관객 반응은 그 room 의 방송인에게만 갑니다.
room 채널 (6.7.0+)
같은 설정의 방(공연, 채팅방)이 여러 개면 카테고리를 방마다 만들지 말고 room 을 쓰세요. 채널 이름은 카테고리:room 이고 설정(발행 정책, 방송인 역할, persist 등)은 베이스 카테고리의 것을 씁니다. 메시지는 같은 room 을 구독한 연결끼리만 오갑니다.
const stage = await cb.realtime.subscribe('concert', { room: 'show-1025' })
stage.channel // 'concert:show-1025'
stage.room // 'show-1025'
stage.info.isPublisher // room 인가 함수가 이 멤버를 방송인으로 정했으면 true
await stage.send({ cheer: 1 }) // send / getHistory / unsubscribe / onMessage 는 이 room 채널로 동작
await cb.realtime.markRead(stage.channel, ['msg-1']) // 이름을 받는 메서드에는 channel 을 넘긴다
// 문자열로 써도 같다
await cb.realtime.subscribe('concert:show-1025')| 부분 | 허용 문자 | 길이 |
|---|---|---|
| 카테고리 | 영문, 숫자, -, _ | 1~128자 |
| room | 영문, 숫자, -, _, . | 1~128자 |
채널 전체 (카테고리:room) | 256자 이하 |
{ room }이 규칙에 어긋나면 서버에 보내기 전에 에러를 던집니다. 서버는 잘못된 채널을CATEGORY_NOT_FOUND로만 알리므로, 한글이나 공백이 든 이름은 ID 로 바꿔 쓰세요.- 재연결하면 room 채널도 다시 구독되고 구독 객체(
info, 핸들러)가 그대로 이어집니다.
room 인가 함수
카테고리에 require_room_auth: true 와 room_auth_function_id 를 설정하면(콘솔 API 의 카테고리 생성/수정), 그 카테고리의 room 채널을 구독하거나 room 에 보낼 때 서버가 앱의 함수를 불러 허락을 받습니다. 함수가 받는 payload:
{
"event": "room_auth",
"app_id": "앱 UUID",
"category": "concert",
"room": "show-1025",
"client_id": "cb_k2x9a7",
"user_id": "0b7c1f3e-5d2a-4c8e-9f10-2a3b4c5d6e7f",
"member_id": "0b7c1f3e-5d2a-4c8e-9f10-2a3b4c5d6e7f",
"action": "subscribe",
"timestamp": 1761100000000
}| 함수 반환 | 결과 |
|---|---|
{ "authorized": true } | 허용 |
{ "authorized": true, "publisher": true } | 허용, 그리고 이 연결(로그인 멤버)이 그 room 의 방송인 (info.isPublisher, 받는 쪽의 fromPublisher) |
{ "authorized": false } | 거부 (ROOM_ACCESS_DENIED) |
// 카테고리의 room_auth_function_id 로 연결한 함수
export default async function handler(payload, ctx) {
const { room, member_id, action } = payload // member_id: 서버가 검증한 멤버 ID (익명 연결이면 '')
const show = await loadShow(ctx, room) // 앱 데이터에서 공연 조회 (앱이 구현)
if (!show || !member_id || !show.memberIds.includes(member_id)) return { authorized: false }
if (action === 'subscribe') return { authorized: true, publisher: show.performerId === member_id }
return { authorized: show.chatOpen === true } // publish: 연결마다 room 마다 한 번만 불린다
}action은subscribe(구독) 또는publish(보내기)입니다. 판단은member_id로 하세요 — 로그인 멤버(accessToken) 연결이면 서버가 검증한 멤버 ID(UUID), 공개 키 연결이면 빈 문자열입니다.user_id는 예전 호환용이고,client_id는 클라이언트가 정하는 값이라 판단 근거가 아닙니다.- 구독 인가와 방송인 판정은 구독할 때마다(재연결 포함) 묻습니다. 발행 인가는 연결마다, room 마다 한 번만 묻고 허락을 기억합니다 — 메시지마다 함수를 부르지 않으므로, 발행 권한을 회수해도 이미 허락받은 연결에는 다시 연결할 때까지 반영되지 않습니다.
- 함수가 없거나, 실패하거나, 10초 안에 답하지 않으면 거부됩니다.
publisher: true는 로그인 멤버 연결에만 적용되고, room 이 없는 채널(concert)은 함수를 거치지 않습니다.
room 규칙 (함수 없이)
room 인가 함수가 "room 에 맞는 줄을 찾아 그 주인이면 방송인" 만 한다면, 함수 대신 카테고리에 room_rule 을 두세요(콘솔 API 의 카테고리 생성/수정, MCP create_realtime_category / update_realtime_category). 서버가 직접 판정하므로 함수 콜드 스타트도, 관객 수만큼의 함수 호출도 없습니다. 두면 require_room_auth 가 켜지고, room 인가 함수와는 함께 둘 수 없습니다.
{
"room_rule": {
"table_id": "공연 표 ID",
"room_field": "room",
"publisher_if": "data.owner_id == auth.member_id && data.status != 'ended'",
"anonymous": "subscribe",
"missing": "subscribe"
}
}| 키 | 뜻 |
|---|---|
table_id (필수) | 이 앱의 표 ID |
room_field (필수) | room 문자열과 같아야 하는 줄의 필드 |
publisher_if (필수) | 보안 규칙 문법의 식. data.필드 는 찾은 줄, auth.member_id 는 연결의 검증된 멤버 ID. 참이면 그 room 의 방송인 |
pick | 같은 room 의 줄이 여럿이면 oldest(기본) 또는 newest |
anonymous | 공개 키 연결: deny(기본, 거부) 또는 subscribe(관객) |
missing | 맞는 줄이 없을 때: deny(기본, 거부) 또는 subscribe(관객) |
- 줄은 room 마다 약 15초(줄 없음은 약 2초) 캐시합니다. 방송인 여부는 구독할 때 정해지므로, 줄을 바꾼 뒤에는 다시 구독할 때 반영됩니다.
- 줄은 서버 권한으로 읽고 클라이언트에 보내지 않습니다. 그 표의 보안 규칙은 적용되지 않습니다.
- 보내기도 같은 규칙으로 허락합니다(받는 사람은
send_policy가 정한다). 줄을 읽지 못하는 일시 장애에서는 거부되고, 다시 시도하면 통과할 수 있습니다.
AI 스트리밍
LLM 응답을 토큰 단위로 스트리밍 받을 수 있습니다.
await cb.realtime.connect()
const session = await cb.realtime.stream(
[{ role: 'user', content: '안녕하세요' }],
{
onToken: (token) => process.stdout.write(token),
onDone: (result) => console.log('\n완료:', result.fullText),
onError: (e) => console.error('에러:', e)
},
{ provider: 'gemini', model: 'gemini-2.0-flash', temperature: 0.7 }
)
// 필요 시 중지
await session.stop()자동 재연결
SDK는 maxRetries 만큼 자동으로 재연결을 시도합니다. 별도 핸들러 등록 없이 동작합니다.
Presence / Typing / Read-receipt
2026-04-27 단일화 이후 cb.realtime 이 presence / typing / read-receipt 의 단일 SoT 입니다. cb.database.connectRealtime 은 DB 변경 구독 전용으로 분리됐고, presence/typing 메서드는 SDK v1.13 에서 deprecate, v2 에서 제거됐습니다.
// 본인 온라인 상태 publish (다른 디바이스/사용자에게 알림)
await cb.realtime.setPresence('online', { device: 'web', metadata: { nickname: '홍길동' } })
// 클라이언트가 disconnect 되면 자동으로 적용될 status 미리 등록
await cb.realtime.setPresenceOnDisconnect('offline')
// 특정 사용자 현재 상태 조회 (단건)
const info = await cb.realtime.getPresence('user_123')
// 여러 사용자 한 번에 조회
const map = await cb.realtime.getPresenceMany(['user_123', 'user_456'])
// 특정 사용자 상태 구독
const unsub = await cb.realtime.subscribePresence('user_123', (event) => {
console.log(event.userId, event.status, event.eventType) // join | leave | update
})
unsub() // 구독 해제// Typing indicator — 룸 단위 (chat 카테고리 / 게임 룸 등 임의 키)
await cb.realtime.startTyping('chat-room-123')
await cb.realtime.stopTyping('chat-room-123')
// 다른 사용자 typing 변경 구독
const offTyping = cb.realtime.onTypingChange('chat-room-123', (event) => {
console.log('현재 typing 중:', event.users) // userId[]
})
// Read-receipt — 메시지 ID 단위 읽음 표시
await cb.realtime.markRead('chat-room-123', 'msg_xxx')📌 presence 와 typing 은 in-memory 상태입니다(영구 저장 안 됨). 메시지/read-receipt 만 카테고리
persist=true옵션으로 영구 저장 가능.