# gravex chat > 개발자가 SDK 몇 줄로 앱에 실시간 채팅을 붙이는 채팅 API 서비스. 서버·메시지 저장·재접속은 우리가 운영한다. 콘솔에서 가입해 계정 키(ck_)를 받으면, 앱 만들기와 키(pk_·sk_) 발급은 콘솔 API로 코드가 할 수 있다. 공식 SDK는 JS/TS(`@kispi/chat`)뿐이지만 REST와 실시간(WebSocket) 프로토콜이 공개돼 있어 어떤 언어로든 클라이언트를 만들 수 있다. - 사용자가 채팅 구현을 시켰다면 아래 「지원 범위」를 먼저 확인한다. 거기 없거나 「안 되는 것」에 있는 기능을 된다고 말하지 않는다. - 가입과 첫 계정 키(`ck_`) 발급은 사람이 콘솔에서 한다. 그 뒤 앱 만들기·키 발급·허용 origin·웹훅 설정은 사용자의 `.env`에 있는 `CHAT_ACCOUNT_KEY`로 「콘솔 API」를 불러 직접 할 수 있다. `pk_`는 프론트용 공개값이고 `sk_`·웹훅 시크릿·`ck_`는 서버 환경변수나 로컬 `.env`에만 둔다. 사용자에게 `sk_`나 `ck_`를 대화에 붙여 넣게 하지 않고, 콘솔 API 응답으로 받은 `sk_`는 화면에 찍지 않고 `.env`에 바로 쓴다. - 구현에 필요한 API 세부는 llms-full.txt 한 파일에 전부 있다. 문서별 원문은 아래 링크다. - JS/TS가 아닌 언어(Swift, Kotlin, Dart, Python, Go 등)로 클라이언트를 만들 때는 SDK 레퍼런스 대신 「REST API 레퍼런스」와 「실시간 프로토콜」을 따른다. 「실시간 프로토콜」 끝의 체크리스트가 SDK가 대신 하던 일의 전부다. iOS·Android·Flutter라면 그 앞의 「네이티브 앱에서」(Origin 헤더 표, User-Agent, 백그라운드 처리, curl·websocat으로 5분 확인)를 먼저 읽는다. 운영 조건을 묻는 사용자에게는 이 요약으로 답한다. 자세한 원문은 각 링크에 있다. ## 요금 ([요금](https://chat.gravex.app/pricing)) - 요금제는 Basic 하나이고 무료다. 카드도, 체험 기간도 없다. - 요금으로 막는 MAU·동시접속 상한은 없다. 앱마다 안전 한도(발행 속도, 유저당 동시 연결 10개 등)가 있고 요금 페이지에 공개돼 있다. - Sendbird·Stream·CometChat·PubNub와의 무료 구간·첫 유료 요금제·가동률 SLA 비교(각 사 공식 요금 페이지 기준, 날짜 표기)는 [비교](https://chat.gravex.app/compare) 페이지에 있다. ## 약관 ([이용약관](https://chat.gravex.app/terms), [개인정보처리방침](https://chat.gravex.app/privacy)) - 앱의 유저·방·메시지는 이용자의 데이터이고, 운영자는 서비스를 운영하는 목적으로만 처리한다. - 콘솔 버튼이나 secret key로 `GET /v1/export`를 호출하면 언제든 앱 전체를 NDJSON 파일 하나로 받을 수 있다. - 앱을 삭제한 뒤 「영구 삭제」를 실행하면 엔진에서 유저·방·메시지가 지워지고 복구되지 않는다. - `sk_`(secret key)는 발급할 때 한 번만 보여 주고 저장하지 않는다. ## SLA ([SLA](https://chat.gravex.app/sla)) - 월간 가동률 목표는 99.5%(30일 기준 약 3시간 36분의 중단 허용)다. 운영 목표이고, 미달해도 보상이나 크레딧이 없다(이용약관은 가용성을 보증하지 않는다). - 메시지는 앱이 지우기 전까지 보존한다. 엔진이 기간을 정해 자동으로 지우지 않는다. - 백업은 데이터베이스 서버의 디스크 스냅숏을 하루 한 번(매일 06:00 KST) 뜬다. 최악의 경우 직전 최대 24시간의 데이터가 사라질 수 있다. - 유료 요금제나 요금 변경은 적용 최소 90일 전에 요금 페이지와 계정 이메일로 공지한다. 서비스 종료는 최소 180일 전에 공지하고 그 기간 내내 내보내기를 보장한다. - 문의·장애 제보는 admin@gravex.app. 별도 상태 페이지와 응답 시간 약속은 없다. - 운영자(GravEx)가 직접 만든 서비스 셋(coinsect.io, everymaple.com, real.gravex.app)의 채팅이 같은 엔진으로 돌아간다. ## 문서 - [시작하기](https://chat.gravex.app/docs): 앱 만들기, 키, 토큰 발급 백엔드, 브라우저 SDK로 접속·방 열기·메시지 보내기. iOS·Android·Flutter·다른 언어는 실시간 프로토콜과 REST로 안내 - [서버 연동](https://chat.gravex.app/docs/server): 유저 토큰 규격, 서버 API(방·메시지·유저 REST), 방 종류, 삭제의 종류, 키 교체 - [비로그인 사용자 (게스트)](https://chat.gravex.app/docs/guests): 로그인하지 않은 방문자를 안전하게 받는 법(chat.guest()), 다른 언어 백엔드, 쿠키 없는 네이티브 앱의 자격증명 보관 - [웹훅](https://chat.gravex.app/docs/webhooks): 웹훅 수신·서명 검증, 이벤트 목록, before_publish(발행 전 거절·수정), 놓친 이벤트 따라잡기 - [이미지 업로드와 모더레이션](https://chat.gravex.app/docs/uploads): 이미지 업로드(S3 presigned POST)와 모더레이션(Rekognition), 네이티브 앱의 multipart 업로드와 첨부 발행, 첨부 URL 검증 훅, 텍스트 모더레이션 - [REST API 레퍼런스](https://chat.gravex.app/docs/rest-api): REST 라우트 전부(방·멤버·메시지·유저·이벤트·내보내기), 인증 헤더, 에러 코드, 한도, 페이지네이션 — JS 밖의 언어(Swift·Kotlin·Dart·Python 등)로 REST를 직접 부를 때 - [실시간 프로토콜 (WebSocket)](https://chat.gravex.app/docs/realtime): WebSocket 실시간 프로토콜: 접속·auth·프레임·이벤트 전부, close 코드와 사유, 재접속과 seq 구멍 메우기, clientMessageId 멱등성, 한도, 「네이티브 앱(iOS·Android·Flutter)에서」(라이브러리가 자동으로 붙이는 Origin 표, User-Agent, 백그라운드 처리, curl·websocat 5분 확인), 「SDK 없이 클라이언트 만들기」 체크리스트 — JS SDK 없이 Swift·Kotlin·Dart 등으로 클라이언트를 만들 때 - [콘솔 API (계정 키)](https://chat.gravex.app/docs/console-api): 계정 키(`ck_`)로 앱 만들기·`pk_`/`sk_` 발급·허용 origin·웹훅·한도 설정, 앱 삭제·복구, 유저·방·메시지 조회 — 콘솔 API 라우트 전부, 인증, 에러 코드. 앱을 코드로 만들고 `.env`를 채울 때 - [SDK 레퍼런스](https://chat.gravex.app/docs/sdk): `@kispi/chat` 모든 메서드·이벤트·에러 코드 - [전체 문서](https://chat.gravex.app/llms-full.txt): 위 문서와 SDK 레퍼런스 전체를 한 파일로 ## Optional - [AI로 시작하기](https://chat.gravex.app/docs/ai): 사람이 AI에게 붙여 넣는 프롬프트 모음 - [아키텍처와 벤치마크](https://chat.gravex.app/docs/architecture): 구조와 벤치마크, 측정하지 않은 것 ## 지원 범위 # 지원 범위 한눈에 사용자가 "이게 되나?"라고 물으면 이 표로 답한다. 표에 없거나 "안 되는 것"에 있는 기능을 된다고 지어내지 않는다. 구현 방법은 오른쪽 문서를 읽는다. 엔진은 **전달·순서·저장·접속 상태·웹훅**을 맡는다. 로그인, 파일 저장, 푸시 발송, 콘텐츠 판정은 사용자의 백엔드가 맡고 엔진은 그것을 위한 훅·이벤트를 연다. 실제 서비스가 이 엔진으로 돈다: 코인충(https://coinsect.io, 코인 방송 커뮤니티 채팅), 여기얼마(https://real.gravex.app, 아파트 실거래가 지도의 채팅), 에브리메이플(https://everymaple.com, 게임 커뮤니티 채팅). ### 되는 것 | 기능 | 어떻게 | 문서 | |---|---|---| | 실시간 채팅방 | 방은 백엔드가 `chat.rooms.ensure({key, type})`로 만들고(get-or-create), 브라우저는 `chat.roomByKey(key)` 또는 `chat.room(id)`로 열어 `subscribe()`, `send({text})` | 시작하기 | | 방 종류 | `public`(누구나 읽고 씀), `private`(멤버만), `channel`(누구나 읽고 owner·admin만 씀), `dm`(두 사람) | 서버 연동 | | 회원 | 백엔드가 유저 JWT를 서명해 발급(`chat.token({userId, name})`). 엔진은 서명을 믿는다 | 시작하기, 서버 연동 | | 비회원(게스트) | 백엔드 `chat.guest()`가 서명된 자격증명을 발급한다. 브라우저가 보낸 id에는 서명하지 않는다 | 비로그인 사용자 | | 히스토리 | 구독하면 최근 100건, 위로 스크롤은 `room.loadOlder()`. 순서(`seq`)에 구멍이 없고 끊기면 SDK가 메운다 | SDK 레퍼런스 | | 스레드 답글 | `send({text, threadId: 루트 메시지 id})`. 루트에 `thread: {count, lastSeq, lastAt}`가 붙고 `thread.updated` 이벤트가 온다. 스레드만 읽기는 `room.history({view: {thread: id}})` | SDK 레퍼런스 | | 인용 답장 | `send({text, replyTo: 메시지 id})`. 스레드와는 별개다 | SDK 레퍼런스 | | 이모지 리액션 | `room.react(messageId, emoji)` / `room.unreact(...)`, 개수는 `reaction.added`·`reaction.removed` 이벤트, 누른 사람은 `room.reactionsOf(...)`. 메시지당 이모지 종류는 최대 20 | SDK 레퍼런스 | | 멘션 | `send({text, entities: [{type: 'mention', userId, offset, length}]})`. 멘션은 `unreadMentions`로 센다 | SDK 레퍼런스 | | 읽음·안 읽음 | `room.markRead(seq)`, `chat.unread()`, `chat.rooms.list()`(방별 안 읽은 수와 마지막 메시지), 다른 방의 새 메시지는 `notification` 이벤트 | SDK 레퍼런스 | | 입력 중 | `room.typing()` 호출, 상대는 `typing` 이벤트(이름은 `room.presence.users`에서 찾는다) | SDK 레퍼런스 | | 접속 중(presence) | `room.presence` = `{count, users?}`, `presence` 이벤트 | SDK 레퍼런스 | | 1:1 대화 | 백엔드 `chat.rooms.ensureDM([a, b])`가 방 id를 준다 | 서버 연동 | | 공지 전용 방 | `type: 'channel'` + 백엔드에서 `members.add(roomId, userId, 'admin')` | 서버 연동 | | 시스템 메시지 | 백엔드 `chat.messages.send(roomId, {kind: 'system', sender, text})`. 심사 훅을 거치지 않는다 | 서버 연동 | | 메시지 수정·삭제 | REST `PATCH`·`DELETE`(브라우저 SDK `Room`에는 수정 메서드가 없다). 삭제는 본문을 비우는 tombstone | 서버 연동 | | 첨부(이미지 등) | 메시지에 `attachments: [{type, url, key?, mime?, w?, h?, name?, size?}]`. **주소(https)와 메타만 싣는다.** 최대 10개 | 이미지 업로드와 모더레이션 | | 금칙어·이미지 검사 | `before_publish` 동기 웹훅이 발행·편집 전에 거절(`deny`)하거나 본문을 바꿔 통과시킨다 | 웹훅, 이미지 업로드와 모더레이션 | | 웹훅 | 서명된 `message.created`·`message.deleted`·`reaction.*` 등을 사용자의 서버로. at-least-once, 놓친 것은 `chat.events.list`로 따라잡는다 | 웹훅 | | 밴·강제 종료 | `chat.users.ban`·`unban`, `revokeTokens`, `disconnect`, 메시지 거두기 `purgeMessages` | 서버 연동 | | 탈퇴·개인정보 삭제 | 익명화 `withdraw`, 영구 삭제 `purge` | 서버 연동 | | 메시지 검색 | REST `q`(방 안 본문 부분 일치). **서버 키(`sk_`)로만** | 서버 연동 | | 데이터 내보내기 | `GET /v1/export`(NDJSON). 유저·방·멤버·메시지·리액션·읽음 위치. **첨부 파일은 없다** | 서버 연동 | | 다른 언어 클라이언트 | iOS(Swift)·Android(Kotlin)·Flutter(Dart)·Python·Go 등. REST와 WebSocket 프로토콜을 문서대로 구현한다. 「SDK 없이 클라이언트 만들기」 체크리스트가 SDK가 대신 하던 일의 전부다 | REST API 레퍼런스, 실시간 프로토콜 | | 앱 분리 | 앱 하나가 테넌트 하나. 운영·개발은 앱을 둘 만든다 | 서버 연동 | | 앱·키 만들기 자동화 | 계정 키(`ck_`)로 콘솔 API(`/api/apps/**`)를 불러 앱 만들기(`pk_`·`sk_`가 응답에 같이 온다), 키 추가 발급·폐기, 허용 origin·웹훅·한도 설정, 앱 삭제·복구, 유저·방·메시지 조회와 모더레이션. 사람이 가입하고 첫 `ck_`를 콘솔에서 받아 `.env`에 넣어 두면 된다 | 콘솔 API | 기본 한도: 본문 4000자, 첨부 10개, 유저당 초당 5건 발행(버스트 10), 유저당 동시 연결 10. ### 안 되는 것 | 기능 | 이유와 대안 | |---|---| | 푸시 알림 발송 | 엔진은 열려 있는 연결에만 실시간으로 전달한다. 앱이 꺼진 사람에게는 `message.created` 웹훅을 받아 사용자의 서버가 FCM·웹푸시·이메일로 보낸다 | | 파일 저장·변환 | 엔진은 파일을 받지 않는다. 사용자의 버킷에 올리고 주소를 첨부로 싣는다 | | 네이티브(iOS·Android·Flutter) 공식 SDK | 공식 SDK는 브라우저·Node용 JS/TS(`@kispi/chat`, 서버용 `@kispi/chat/server`)뿐이다. 다만 REST와 실시간(WebSocket) 프로토콜이 공개돼 있어 다른 언어 클라이언트는 문서를 보고 직접 만들 수 있다(위 「되는 것」의 「다른 언어 클라이언트」) | | 로그인·회원 관리 | 엔진은 사람이 누구인지 모른다. 사용자의 백엔드가 토큰을 서명한다 | | 금칙어·이미지의 자동 판정 | 엔진은 훅만 연다. 판정(사전, Rekognition 등)은 사용자가 만든다 | | 유저 토큰으로 메시지 검색 | 검색은 `sk_` 전용이다. 사용자의 백엔드에 검색 API를 둔다 | | 가입과 첫 계정 키 발급 | 사람이 콘솔에서 이메일 코드로 가입하고 **API 키** 화면에서 `ck_`를 만든다. 계정 키로는 계정 키를 만들 수 없다 | | 음성·영상 통화, 화면 공유 | 없다 | | 푸시 토큰·알림 설정 저장 | 없다. 사용자의 DB에 둔다 | ### 화면 점검표 채팅 화면을 다 만들었으면 이 목록과 하나씩 대조한다. SDK가 대신 해 주지 않아 빠뜨리기 쉬운 것들이다. - **`reset`이 오면 다시 그린다.** 렌더한 목록을 버리고 `room.messages`로 처음부터 그린다. 옛 목록에 이어 붙이면 구멍이 남는다. - **과거를 불러와도 보던 자리를 지킨다.** `loadOlder()`로 위에 붙은 만큼 스크롤 위치를 보정하고, 새 메시지에는 사용자가 맨 아래에 있을 때만 따라 내려간다. - **본 타임라인에서 스레드 답글을 뺀다.** `room.messages`에는 `threadId`가 있는 행도 섞여 있다. - **입력 중 표시는 내 이벤트를 건너뛰고 5초쯤 뒤 지운다.** `typing`은 보낸 사람에게도 오고, "입력 멈춤" 이벤트는 없다. - **`history()`로 읽은 행의 리액션은 직접 고친다.** SDK는 `room.messages` 안의 행만 고친다. 스레드 패널처럼 `history()`로 그린 목록은 `reaction.added`·`reaction.removed`의 `count`와 `userId`로 개수와 `myReactions`를 고친다. - **리액션 개수는 서버 값으로 그린다.** `react()`는 `myReactions`를 먼저 바꾸고 개수는 이벤트가 오면 고친다. 그 사이 개수가 없으면 이모지만 그린다. `myReactions`가 없는 행은 아무것도 누르지 않은 것으로 본다. - **본문은 평문으로 넣는다.** `text`를 HTML로 넣지 않는다(`innerHTML`, `dangerouslySetInnerHTML`, `{@html}`). 멘션 강조도 텍스트를 잘라 감싸서 한다. - **`rate_limited`면 기다린다.** `ChatError`의 `retryAfterMs`만큼 보내기를 막고 남은 시간을 보여 준다. ### 답할 때 - 되는지 모르겠으면 모른다고 하고, 이 표와 문서에 없다고 말한다. 비슷해 보이는 기능으로 대신 약속하지 않는다. - `pk_`는 브라우저에 두어도 되는 공개값이고 `sk_`·웹훅 시크릿·게스트 시크릿은 서버에만 둔다. 사용자에게 `sk_`를 대화에 붙여 넣으라고 하지 않는다. - 계정 키 `ck_`는 계정 전권이다. 서버나 로컬 `.env`에만 두고 프런트 코드·git 저장소·대화에 넣지 않으며, 다 쓰면 폐기하라고 안내한다. 폐기해도 이미 만든 앱과 그 `pk_`·`sk_`는 그대로 동작하고, 폐기한 뒤에는 `.env`의 `CHAT_ACCOUNT_KEY` 줄도 지운다. 콘솔 API 응답으로 받은 `sk_`는 화면이나 로그에 찍지 않고 `.env`에 바로 쓴다. - 환경변수 이름은 모든 문서가 같다: 프런트 `VITE_CHAT_PK`(`pk_`), 서버 `CHAT_SECRET_KEY`(`sk_`)·`CHAT_WEBHOOK_SECRET`·`CHAT_GUEST_SECRET`, 앱을 만드는 스크립트 `CHAT_ACCOUNT_KEY`(`ck_`). Vite는 `VITE_`로 시작하는 변수만 브라우저 번들에 넣는다. **Vite의 `envPrefix`를 `CHAT_`로 넓히지 않는다** — `sk_`와 `ck_`가 번들에 실린다. Vite가 아니면 `pk_`에만 그 프레임워크의 공개 접두사를 붙인다(Next.js `NEXT_PUBLIC_CHAT_PK`). - 허용 origin은 실제로 뜬 개발 서버 주소(스킴·호스트·포트)를 넣는다. 포트가 바뀌면 `PATCH /api/apps/{id}/keys/{keyId}`로 고치는데 목록을 통째로 바꾸므로 기존 값도 같이 보낸다. - 로그인이 없는 프로젝트에는 데모용 로그인을 지어내지 않고 `chat.guest()`로 게스트 신원을 발급한다.