문서 / 실시간 WebSocket v2
실시간 WebSocket v2
서버 인증 발행, 사용자 접속권, SDK 복구와 주문 outbox 예제입니다.
키와 고정 버전 SDK 준비
joripspace realtime-v2 status --project PROJECT
joripspace realtime-v2 activate --project PROJECT
joripspace realtime-v2 docs상태를 확인하고 아직 키가 없는 프로젝트에서만 최초 준비를 진행합니다. 개발 도구가 처리할 수 있으며 고객에게 별도 활성화 화면을 요구할 필요는 없습니다. 개인키는 프로젝트의 JORIPSPACE_REALTIME_V2_SIGNING_KEY Secret에 설치되며 복사·출력하지 않습니다.
SDK 2.0.1 manifest의 SHA-256과 파일 크기를 확인한 뒤 server.js·server.d.ts를 src/vendor/realtime/에 저장하세요. 브라우저용 browser.js는 자신의 정적 경로 /sdk/realtime/browser.js로 제공합니다. 서버 SDK를 브라우저 번들에 넣지 않습니다.
Hono의 서버 번들만 배포하면 public 폴더가 자동으로 업로드되는 것으로 가정하지 마세요. 기존 앱의 정적 파일 배포 경로를 사용하거나 브라우저 SDK도 프런트엔드 빌드에 포함합니다.
앱 로그인 검증 후 접속권 발급
세션 미들웨어와 Hono 타입을 사용합니다. 이 예제는 본인 알림방만 허용합니다. 매장 room은 서버가 매장 소속을 조회한 뒤 발급해야 합니다. 내부 사용자 ID는 이메일 대신 영문·숫자·하이픈·밑줄로 된 불투명 ID를 사용하세요.
import { Hono } from 'hono';
import { createRealtimeServer } from '../vendor/realtime/server';
import { requireSession } from '../middleware/session';
import type { AppEnv } from '../types';
type RealtimeEnv = AppEnv & { Bindings: AppEnv['Bindings'] & {
JORIPSPACE_REALTIME_V2_SIGNING_KEY: string;
} };
export const realtimeRoutes = new Hono<RealtimeEnv>();
realtimeRoutes.use('*', requireSession);
realtimeRoutes.post('/token', async (c) => {
const identity = c.get('identity');
const realtime = createRealtimeServer({
origin: new URL(c.req.url).origin,
signingKey: c.env.JORIPSPACE_REALTIME_V2_SIGNING_KEY,
});
// 본인 알림방: 브라우저가 원하는 사용자 ID/room을 지정하게 하지 않습니다.
const room = await realtime.createRoom('user_' + identity.user_id);
return c.json({ room: room.name, ...await realtime.accessToken(room, {
subject: identity.user_id, session: identity.session_hash,
permissions: ['subscribe', 'history'],
}) });
});import { realtimeRoutes } from './routes/realtime';
app.route('/api/realtime', realtimeRoutes);인증된 서버 발행
import { createRealtimeServer, messageId } from './vendor/realtime/server';
// 서버의 권한 검사가 끝난 업무 처리 코드에서 호출합니다.
const realtime = createRealtimeServer({
origin: new URL(request.url).origin,
signingKey: env.JORIPSPACE_REALTIME_V2_SIGNING_KEY,
});
const room = await realtime.getRoom('user_' + userId);
const event = { id: messageId(), type: 'notification.created', data: { notification_id: notificationId } };
await realtime.publish(room, event);
// 업무 DB 변경을 함께 처리한다면 event의 ID·내용·room.generation을 outbox에 저장하고
// 성공할 때까지 같은 값으로 재시도합니다. DB 저장 성공만으로 발행 성공이라고 하지 않습니다.messageId()를 한 번만 만들고 재시도에 같은 ID·내용·room 세대를 사용합니다. 같은 ID로 다른 내용을 발행하면 충돌입니다. 게시 성공은 영구 저장 성공이며 고객 화면의 처리 완료와는 구분됩니다.
브라우저 SDK 연결과 복구
화면에 id="last-notification", id="connection-state" 요소를 추가하고 아래 코드를 ES 모듈로 실행하세요.
import { RealtimeClient } from '/sdk/realtime/browser.js';
// 위 import 파일은 고정 버전 SDK를 내려받아 자신의 사이트에서 제공하세요.
async function getToken() {
const response = await fetch('/api/realtime/token', { method: 'POST', credentials: 'same-origin' });
if (!response.ok) throw new Error('로그인과 권한을 확인하세요.');
return response.json();
}
const first = await getToken();
const client = new RealtimeClient({
room: first.room,
getToken, // 재연결·갱신 때마다 서버가 현재 세션을 다시 검사합니다.
onEvent(event) {
if (event.type !== 'notification.created') return;
const element = document.querySelector('#last-notification');
if (!element) throw new Error('알림 표시 요소가 필요합니다.');
element.textContent = String(event.data.notification_id);
// 성공적으로 적용한 뒤에만 SDK가 순번을 전진시킵니다.
},
onState(state) { document.querySelector('#connection-state').textContent = state; },
onError() { console.warn('실시간 연결 상태를 확인하세요.'); },
});
client.start();
window.addEventListener('pagehide', () => client.stop());SDK가 재연결, 단기 접속권 갱신, heartbeat와 마지막 적용 순번 이후 복구를 처리합니다. onEvent의 비동기 처리가 완료된 뒤 적용 순번이 전진합니다. 인증 토큰을 URL이나 localStorage에 저장하지 않습니다. seq는 십진 문자열이므로 Number로 변환하지 말고 필요할 때 BigInt로 비교하세요.
새로고침 이후에도 정확한 복구 지점을 유지하려면 화면 상태와 generation·적용 seq를 함께 보존하고 서버 원본과 대조하세요. 페이지를 닫을 때 stop()을 호출합니다. 로그인·세대 오류는 앱이 사용자에게 설명하고 다시 초기화해야 할 수 있습니다.
이력·검색·삭제와 권한 회수
// 서버 SDK 예제. realtime과 room은 인증된 서버 코드에서 준비합니다.
async function readPage(cursor) {
return realtime.history(room, {
limit: '50', search: '알림', ...(cursor ? { cursor } : {}),
});
}
const page = await readPage(); // 앱 API에서는 이 한 페이지를 응답합니다.
// 더 보기 요청에는 page.cursor를 전달합니다. null이면 탐색 완료입니다.
// 검색어를 바꾸면 cursor도 초기화하세요. 메시지 원문·토큰은 로그에 남기지 않습니다.
await realtime.deleteMessage(room, messageIdToDelete);
await realtime.revoke(room, { target: 'session', session: sessionHashToRevoke });기본 권한은 구독·이력입니다. 브라우저 검색·삭제는 앱이 권한을 확인한 뒤 해당 scope를 추가로 부여해야 합니다. 조회 결과가 비어 있어도 cursor가 있으면 다음 구간이 남아 있습니다. 웹 API는 한 페이지씩 반환하고 무제한 탐색 요청을 만들지 마세요.
삭제 이벤트와 onDelete를 화면 상태에도 반영하세요. 권한 변경 시 앱 DB의 소속·세션 수정과 함께 revoke를 호출합니다. 여러 시스템의 변경은 하나의 DB 트랜잭션처럼 처리되지 않으므로 실패 재처리를 설계합니다.
이력과 실시간 이벤트를 하나의 목록에 표시
아래 코드는 앞의 단일 알림 화면 대신 사용하는 작은 이벤트 목록 예제입니다. 같은 ID는 한 번만 표시하고 seq를 BigInt로 비교하며, 조회와 동시에 삭제 알림이 도착하는 경우도 처리합니다. HTML 문자열에 메시지를 삽입하지 않습니다.
// 별도 ES 모듈. 이 목록은 업무 DB 목록이 아닌 실시간 이벤트 원문 목록입니다.
// HTML: <ul id="event-list"></ul>
import { RealtimeClient } from '/sdk/realtime/browser.js';
const items = new Map();
const deleted = new Set(); // 조회 중 도착한 삭제가 늦은 응답으로 되살아나지 않게 합니다.
const list = document.querySelector('#event-list');
if (!list) throw new Error('event-list 요소가 필요합니다.');
function draw() {
const rows = [...items.values()].sort((a, b) =>
BigInt(a.seq) < BigInt(b.seq) ? -1 : BigInt(a.seq) > BigInt(b.seq) ? 1 : 0);
list.replaceChildren(...rows.map(event => {
const row = document.createElement('li');
row.dataset.messageId = event.id;
row.textContent = event.type + ' ' + JSON.stringify(event.data);
return row;
}));
}
function merge(event) {
if (event.deleted) return remove(event.id);
if (!deleted.has(event.id)) items.set(event.id, event);
draw();
}
function remove(id) { deleted.add(id); items.delete(id); draw(); }
async function getToken() {
const response = await fetch('/api/realtime/token', { method: 'POST', credentials: 'same-origin' });
if (!response.ok) throw new Error('접속권 발급 실패');
return response.json();
}
const first = await getToken();
const client = new RealtimeClient({ room: first.room, getToken,
onEvent: merge, onDelete: remove,
onError() { console.warn('실시간 복구 상태를 확인하세요.'); },
});
client.start(); // 새 목록이므로 기본 순번 0부터 SDK가 이력을 복구합니다.
window.addEventListener('pagehide', () => client.stop());
// 별도 이력 페이지를 표시할 때도 page.events.forEach(merge)로 같은 상태에 병합합니다.
// 대량 이력 서비스는 아래 설명대로 페이지 단위 목록과 DB 원본 재조회를 사용하세요.기본 순번 0부터 연결하면 SDK가 과거 이력을 복구합니다. 이력이 큰 서비스는 업무 DB의 페이지 목록과 현재 버전을 먼저 읽고 변경 이벤트로 해당 행을 다시 조회하세요. 메시지 목록 자체가 목적이라면 이력 API를 한 페이지씩 호출하고 cursor가 있을 때 더 보기 버튼을 제공합니다. 검색어·room·generation이 바뀌면 커서와 목록 상태를 초기화하며, 빈 페이지라도 cursor가 있으면 탐색이 끝난 것이 아닙니다. 단순 예제의 Map과 삭제 집합은 무한히 커질 수 있으므로 실제 앱은 표시 범위를 제한하고 새 조회 세대에서 상태를 교체하세요. 상태 없이 seq만 저장해 복구를 건너뛰면 빈 화면이 될 수 있습니다.
메시지 편집과 업무 데이터 수정
실시간 메시지는 불변 이벤트이며 본문 수정 API는 없습니다. 동일 ID·다른 내용은 충돌입니다. 채팅 편집이나 주문 상태 변경은 프로젝트 DB의 원본·버전을 수정한 뒤 새 ID의 변경 이벤트로 알립니다. 원본 변경과 outbox 기록을 같은 트랜잭션에 저장하는 주문 예제를 참고하세요.
// 인증·작성자 권한·입력 검증이 끝난 서버 업무 처리 코드의 예입니다.
// 기존 메시지 ID로 본문을 바꾸면 수정이 아니라 충돌입니다.
const updateEvent = {
id: messageId(),
type: 'message.updated', // 앱이 정한 이벤트 이름이며 플랫폼의 수정 명령이 아닙니다.
data: { entity_id: entityId, version: nextVersion },
};
// 업무 DB 수정과 updateEvent의 ID·내용·room 세대가 담긴 outbox를
// 같은 트랜잭션에 저장하고, outbox 처리기가 아래 발행을 재시도합니다.
await realtime.publish(room, updateEvent);
// 수신 화면은 entity_id로 권한이 적용된 앱 조회 API에서 최신 원본을 다시 읽습니다.
// version보다 오래된 조회 결과가 도착하면 현재 화면을 덮어쓰지 않습니다.message.updated는 앱이 정한 이름이며 플랫폼이 과거 행을 수정해주지 않습니다. 수신 앱은 entity_id로 최신 원본을 다시 조회합니다. 원본 조회·수정 API에서 로그인, 소속, 작성자 또는 관리자 권한과 예상 버전을 검사하고 동시 편집 충돌을 처리하세요. 이전 내용이 실시간 이력에 포함됐다면 DB 수정만으로 지워지지 않습니다. 기존 원문도 제거해야 할 때는 권한 확인 후 해당 이벤트를 별도로 삭제하세요.
삭제 요청과 다른 화면 동기화
인증된 서버에서는 realtime.deleteMessage(room, messageId)를 사용합니다. 먼저 앱 DB에서 해당 항목이 현재 사용자의 room·소유권·관리 권한 범위인지 확인하세요. room 접속권은 메시지 작성자별 권한을 대신하지 않습니다. 브라우저에 delete scope를 주면 해당 room 메시지 삭제가 가능하므로 작성자만 삭제할 수 있는 앱은 브라우저에 이 권한을 주지 말고 앱 서버를 거치세요.
성공한 삭제는 요청 화면에서 바로 반영하고 다른 화면은 위 예제의 onDelete에서 같은 ID를 제거합니다. 알림이 지연돼도 중복 제거가 가능하게 만들고 재접속 시 복구와 원본 재조회로 대조하세요. 이미 없는 항목의 처리와 권한 거부를 구분하며 모든 오류를 성공으로 숨기지 않습니다. 삭제 중 버튼을 비활성화하고 실패 시 행을 유지하거나 원래 상태로 복원하세요.
실시간 이벤트 삭제는 프로젝트 DB의 주문·채팅 원본 삭제가 아닙니다. 업무 데이터 삭제는 DB에서 먼저 권한·버전을 검사하고 삭제 상태와 outbox를 함께 기록합니다. room 전체 삭제는 realtime.deleteRoom(room)이며 개별 행 삭제와 구분하세요. 삭제 작업 응답의 status를 확인하고 getRoom으로 deleted 상태가 될 때까지 확인합니다. room을 다시 만들면 이전 generation·접속권·커서를 재사용하지 않습니다.
오류·중복 클릭·응답 손실 처리
서버 SDK의 RealtimeError는 status·code·retryable을 제공합니다. 인증·권한 오류는 세션과 소속을 확인하고, 같은 ID 내용 충돌은 새 요청을 무작정 반복하지 말고 저장된 outbox와 비교하세요. 재시도 가능한 실패는 지수 백오프와 무작위 지연을 적용하되 상태를 사용자에게 알립니다. 한도 오류는 반복 요청만으로 해결되지 않으므로 오류 코드에 맞게 처리합니다. 네트워크 응답을 잃었을 때도 기존 이벤트 ID·내용·room 세대로 재시도합니다.
두 화면에서 생성·편집·삭제, 목록 조회 중 삭제, 중복 클릭, 연결 단절 중 변경, 새로고침, 다른 사용자의 삭제 요청을 확인하세요. 브라우저 처리 완료와 서버 저장 완료를 구분하고, 삭제 요청과 새 이력 응답이 엇갈려도 이전 내용이 다시 표시되지 않는지 확인합니다.
주문 변경과 outbox를 함께 구현하려면
개별 호출을 넘어 업무 상태와 메시지 발행을 연결하려면 공식 주문 예제를 사용하세요. 주문 변경과 outbox 저장을 DB 트리거로 묶고 같은 메시지 ID로 재시도합니다.
이 주문 예제는 위 Hono 본인 알림 예제와 별도의 스키마·app_session 쿠키 규칙을 사용합니다. 서로 다른 세션 예제를 그대로 혼합하지 말고 기존 로그인에 맞춰 하나로 통일하세요. 로그인 화면·매장 소속 설정·주문 UI는 고객 앱에 연결해야 합니다.
예제 진입점에 APP_ORIGIN을 실제 HTTPS 주소로 지정하고 SESSION_HASH_SALT Secret을 준비합니다. 재시도용 /jobs/realtime-outbox를 POST 크론으로 등록하세요. 두 로그인 화면의 수신과 단절 후 복구를 확인한 뒤 운영에 사용합니다.