시작하기
순서가 맞는 채팅을 내 서비스에 붙이는 데 필요한 것은 셋이다. 콘솔에서 만든 앱, 토큰을 발급하는 백엔드 엔드포인트 하나, 브라우저의 SDK.
npm install @kispi/chat진입점이 둘이다. SDK는 런타임 의존성이 없고 타입 선언을 포함한다.
| 진입점 | 어디서 | 쥐는 키 |
|---|---|---|
@kispi/chat |
브라우저(와 Node) | pk_ |
@kispi/chat/server |
당신의 백엔드 | sk_ |
1. 콘솔에서 앱을 만든다
가입한 뒤 새 앱을 누른다. 앱 하나가 테넌트 하나다 — 유저·방·메시지·키가 앱마다 따로 있다. 만들자마자 키 두 개가 나온다.
| 키 | 어디에 두나 | 무엇을 하나 |
|---|---|---|
pk_… (publishable) |
프론트엔드 코드 | 어느 앱인지만 말한다. 공개값이다 |
sk_… (secret) |
백엔드의 환경변수 | 그 앱의 모든 데이터다. 토큰 발급과 서버 API에 쓴다 |
sk_는 만든 순간 한 번만 보인다. 콘솔은 그 값을 저장하지 않으므로 바로 비밀 저장소에
옮겨 둔다. 잃어버렸으면 새 sk_를 발급하고 옛 것을 폐기한다(키 교체).
허용 오리진
앱을 만들 때 적는 허용 오리진은 브라우저에서 이 pk_를 쓸 수 있는 페이지의 오리진이다.
- 스킴·호스트·포트가 정확히 맞아야 한다.
https://example.com과https://www.example.com은 다른 오리진이다. http://localhost:3000과http://127.0.0.1:3000도 다르다. 개발용 오리진은 따로 넣는다.- 목록에 없는 페이지에서 쓰면
unauthorized로 거절된다. 목록은 키 화면의 오리진 편집으로 바꾼다.
2. 백엔드가 토큰을 발급한다
엔진은 사람이 누구인지 모른다. 토큰의 서명을 검증하고 그 안의 유저 id를 액면 그대로 믿을 뿐이다. 누가 로그인했는지는 당신 서비스가 아니까, 필요한 것은 그 사람에게 토큰을 발급하는 엔드포인트 하나다.
// src/routes/api/chat-token/+server.ts (SvelteKit) — 어떤 프레임워크든 모양은 같다
import { json } from '@sveltejs/kit'
import { createChatServer } from '@kispi/chat/server'
import { env } from '$env/dynamic/private'
const chat = createChatServer({
secretKey: env.CHAT_SECRET_KEY // sk_…
})
export const POST = async ({ locals }) => {
const user = locals.user // 당신 서비스의 세션
if (!user) return new Response(null, { status: 401 })
return json({
token: chat.token({
userId: String(user.id), // 이 사람의 id
name: user.nickname,
avatar: user.avatarUrl, // 선택
ttlSeconds: 3600 // 선택. 기본 1시간, 최대 24시간
})
})
}# chat_token()은 "서버 연동" 쪽에 있다 — 여기서는 그것을 부르기만 한다
@app.post("/api/chat-token")
def issue_chat_token():
user = current_user() # 당신 서비스의 세션
if user is None:
return "", 401
return {"token": chat_token(SK, str(user.id), user.nickname, ttl=3600)}// chatToken()은 "서버 연동" 쪽에 있다 — 여기서는 그것을 부르기만 한다
func issueChatToken(w http.ResponseWriter, r *http.Request) {
user, ok := currentUser(r) // 당신 서비스의 세션
if !ok {
http.Error(w, "", http.StatusUnauthorized)
return
}
token, err := chatToken(sk, user.ID, user.Nickname, time.Hour)
if err != nil {
http.Error(w, "", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"token": token})
}// chatToken()은 "서버 연동" 쪽에 있다 — 여기서는 그것을 부르기만 한다
@PostMapping("/api/chat-token")
fun issueChatToken(session: HttpSession): Map<String, String> {
val user = session.user ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
return mapOf("token" to chatToken(sk, user.id.toString(), user.nickname))
}chat.token()은 네트워크를 타지 않는 로컬 서명이라 요청마다 불러도 된다. 다른 언어도
같다 — 서명뿐이라 엔진을 부르지 않는다. name은 접속할 때마다 이 값이 이긴다. 토큰 규격
자체는 서버 연동에 있고, 로그인하지 않은 방문자는
비로그인 사용자가 같은 엔드포인트에서 발급하는 법을 적는다.
3. 브라우저가 주고받는다
// chat.ts
import { createChatClient } from '@kispi/chat'
export const chat = createChatClient({
key: import.meta.env.VITE_CHAT_PK, // pk_…
token: async () => {
const res = await fetch('/api/chat-token', { method: 'POST' })
return (await res.json()).token
}
})
chat.on('state', (s) => setStatus(s)) // connecting | open | reconnecting | closed
chat.connect().catch(() => {})재접속과 토큰 갱신은 SDK가 한다. 방은 백엔드가 만든다 — 키로 연다고 방이 생기지 않고,
없는 키를 구독하면 not_found다. rooms.ensure는 있으면 그대로 돌려주는 get-or-create다.
// 백엔드. 2단계에서 만든 createChatServer 인스턴스다
await chat.rooms.ensure({ key: 'lobby', type: 'public' })아래부터는 다시 브라우저, 3단계에서 만든 createChatClient 쪽이다.
const room = chat.roomByKey('lobby')
await room.subscribe() // 라이브 피드 + 최근 100건
room.on('messages', (all) => render(all)) // 목록이 바뀔 때마다 통째로
room.on('message.created', (m) => append(m)) // 또는 새 메시지 하나씩
const ack = await room.send({ text: '안녕' }) // { messageId, seq }
await room.send({ text: '이거 봐', attachments: [{ type: 'image', url, key }] })
await room.react(ack.messageId, '👍')
await room.markRead(room.lastSeq)
room.typing()
const { hasMore } = await room.loadOlder() // 스크롤을 올려 과거를 볼 때. 끝이면 falseroom.messages는 언제나 seq 오름차순이고 구멍이 없다. 바뀔 때마다 새 배열이라 참조로
비교하는 상태(Svelte $state.raw, React useState, Vue shallowRef)에 그대로 넣는다.
파일은 엔진이 보관하지 않는다 — 당신이 쓰는 저장소(S3 같은 것)에 올리고 그 주소를
attachments에 싣는다(url은 https://로 시작해야 한다).
두 이벤트는 꼭 처리한다.
reset— SDK가 목록을 버리고 다시 채웠다. 렌더한 것을 버리고room.messages로 다시 그린다.error— 히스토리를 읽는 데 실패했다. 방이 스스로 다시 구독하므로 알리기만 한다.
| 저장되나 | 재접속 뒤 | |
|---|---|---|
| 메시지 | 된다 | SDK가 구멍을 메운다 |
| 리액션 | 된다 | 히스토리에 실려 온다. 끊긴 동안의 변화는 reload() |
읽음 커서(markRead) |
된다 | rooms.list()의 unread가 반영한다 |
| presence | 안 된다 | 새로 받는다 |
| typing | 안 된다 | 놓쳐도 된다. 3초가 지나면 사라진다 |
typing은 보낸 사람에게도 온다 — "X가 입력 중"을 그린다면 자기 userId는 건너뛴다.
실패는 ChatError로 던지고, err.code가 rate_limited면 err.retryAfterMs만큼 기다린다.
4. 웹훅 (선택)
발행·수정·삭제를 당신 백엔드가 들어야 할 때 쓴다 — 푸시 알림, 검색 색인, 감사 로그.
발행 전에 끼어들어 거절하거나 본문을 바꾸는 before_publish도 있다. 받는 쪽 코드와
서명 검증은 웹훅에 있다.