본문으로 건너뛰기

실시간 (Realtime)

WebSocket 기반 실시간 통신 API입니다. 채팅, 알림, 협업 기능 등에 사용합니다.

📌 cb.realtime 은 카테고리(category) 기반 pub/sub 입니다. 카테고리는 콘솔에서 생성하거나 서버에서 자동 생성된 이름(예: chat-room-1)을 사용합니다.

연결

typescript
// 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() 의 옵션:

옵션타입기본값설명
accessTokenstring-앱 멤버 JWT (지정 시 Public Key 대신 사용)
userIdstring-표시용 사용자 식별자
maxRetriesnumber5자동 재연결 횟수
retryIntervalnumber1000재연결 간격 (ms)
timeoutnumber30000응답 대기 타임아웃 (ms)
debugbooleanfalse디버그 로그

카테고리 구독

typescript
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 채널").

메시지 수신 / 전송

typescript
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보낸 연결이 방송인인지예 (서버 판정)
typescript
chat.onMessage((m) => {
  if (!m.fromMember) return              // 로그인 멤버의 메시지만 처리
  if (blocked.has(m.fromMember)) return  // 차단, 신고, 점수는 멤버 ID 로
  render(m.fromMember, m.data)
})

히스토리(getHistory)의 메시지에도 fromMember 가 실립니다. 익명(공개 키) 연결이 보낸 메시지와 구버전 서버가 보낸 메시지에는 없습니다.

히스토리 조회

persist=true 로 설정된 카테고리만 메시지를 저장합니다.

typescript
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())
  })
}

구독 해제 / 연결 종료

typescript
await chat.unsubscribe()
cb.realtime.disconnect()

연결 상태

typescript
const state = cb.realtime.getState()
// 'disconnected' | 'connecting' | 'connected' | 'reconnecting'

cb.realtime.connectionId  // 현재 연결 ID
cb.realtime.appId          // 연결된 앱 ID

연결 거부 (6.5.0+)

서버가 연결을 받지 않으면 connect() 가 RealtimeConnectionError 를 던집니다. code 로 분기하고 retryable 로 재시도 여부를 정하세요.

typescript
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)
typescript
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 을 구독한 연결끼리만 오갑니다.

typescript
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:

json
{
  "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)
javascript
// 카테고리의 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 인가 함수와는 함께 둘 수 없습니다.

json
{
  "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 응답을 토큰 단위로 스트리밍 받을 수 있습니다.

typescript
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 에서 제거됐습니다.

typescript
// 본인 온라인 상태 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()  // 구독 해제
typescript
// 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 옵션으로 영구 저장 가능.