시작하기

순서가 맞는 채팅을 내 서비스에 붙이는 데 필요한 것은 셋이다. 콘솔에서 만든 앱, 토큰을 발급하는 백엔드 엔드포인트 하나, 브라우저의 SDK.

sh
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를 액면 그대로 믿을 뿐이다. 누가 로그인했는지는 당신 서비스가 아니까, 필요한 것은 그 사람에게 토큰을 발급하는 엔드포인트 하나다.

ts
// 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시간
    })
  })
}
python
# 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)}
go
// 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})
}
kotlin
// 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. 브라우저가 주고받는다

ts
// 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다.

ts
// 백엔드. 2단계에서 만든 createChatServer 인스턴스다
await chat.rooms.ensure({ key: 'lobby', type: 'public' })

아래부터는 다시 브라우저, 3단계에서 만든 createChatClient 쪽이다.

ts
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() // 스크롤을 올려 과거를 볼 때. 끝이면 false

room.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도 있다. 받는 쪽 코드와 서명 검증은 웹훅에 있다.

다음