실시간 프로토콜 (WebSocket)
JS SDK 없이 실시간 채팅 클라이언트를 만드는 법이다. iOS(Swift), Android(Kotlin), Flutter(Dart), Python, Go처럼 WebSocket과 JSON, HTTP만 되는 언어면 무엇이든 같은 일을 할 수 있다. 이 문서의 규칙을 구현하면 공식 SDK와 똑같이 동작한다. 마지막의 「SDK 없이 클라이언트 만들기」가 그 목록이다.
- 서버가 보내는 이벤트(새 메시지, 리액션, 입력 중, 접속 중…)는 WebSocket으로 온다.
- 히스토리, 끊겼던 동안의 구멍 메우기, 안 읽은 수는 **REST**로 가져온다. 둘은 서로를 대신하지 못한다. 구독은 지난 일을 다시 보내 주지 않고, REST는 새 일을 밀어 주지 않는다.
- 메시지 보내기는 둘 다 된다. 리액션, 읽음 위치, 입력 중 알림은 WebSocket으로만 된다.
한눈에
- 백엔드에서 유저 토큰(JWT)을 받는다.
sk_로 서명하는 일은 백엔드 몫이다(서버 연동). wss://api.chat.gravex.app/v1/ws?v=1로 WebSocket을 연다.- **첫 프레임으로
auth**를 보낸다. 5초 안에 못 보내면 끊긴다. hello를 받으면 준비가 끝난 것이다. 25초마다ping을 보낸다.- 보고 있는 방마다
subscribe를 보내고, 받은lastSeq와 내가 가진 마지막seq의 차이를 REST 히스토리로 채운다. push프레임으로 오는 이벤트를 화면 상태에 반영한다.- 끊기면 백오프로 다시 연결하고 5번부터 되풀이한다.
모든 프레임은 JSON 텍스트 프레임 하나다. 바이너리 프레임은 쓰지 않는다.
접속
wss://api.chat.gravex.app/v1/ws?v=1?v=1은 프로토콜 버전이다. 없거나 다르면 HTTP 400으로 핸드셰이크가 거절된다.- 키와 토큰은 URL이나 HTTP 헤더가 아니라 첫 프레임에 싣는다(URL은 로그에 남는다).
- 브라우저는
Origin이pk_의 허용 origin에 있어야 한다.Origin헤더를 보내지 않는 네이티브 클라이언트는 이 검사를 받지 않는다. - 서버가 점검 중이면 핸드셰이크가 HTTP 503이다. 백오프로 다시 시도한다.
- 압축 확장, 서브프로토콜은 쓰지 않는다.
프레임 형식
모든 프레임은 이 봉투다. id와 data는 없을 수 있다.
{ "type": "publish", "id": "c7", "data": { } }클라이언트가 보내는 프레임
type은 아래 8가지 중 하나다. 다른 값은invalid다.- 항상
id를 붙인다. 연결 안에서 유일한 아무 문자열이면 된다("c1","c2"…). - 서버는 같은
id로ack아니면error를 정확히 하나 돌려준다. 이것이 요청과 응답을 잇는 방법이다. 여러 프레임을 연달아 보내도 되고 답이 순서대로 온다는 보장은 없다. id는 멱등키가 아니다. 중복 방지는publish의clientMessageId하나뿐이다.- 모르는 필드는 서버가 무시한다.
서버가 보내는 프레임
type |
모양 | 뜻 |
|---|---|---|
hello |
{data: {…}} |
인증 성공 |
ack |
{id, data} |
그 id의 요청이 성공했다. data는 요청별로 다르다 |
error |
{id?, data: {code, message, retryAfterMs?, appCode?}} |
그 id의 요청이 실패했다. id가 없으면 연결 수준 오류다(프레임이 너무 크거나 JSON이 아니어서 id를 못 읽었다) |
push |
{data: {roomId?, event, data}} |
서버가 먼저 보내는 이벤트. 방 이벤트는 roomId가 있고 유저 이벤트는 없다 |
pong |
{} |
ping의 답 |
reconnect |
{data: {reason, delayMs}} |
서버가 곧 내려간다. delayMs 뒤에 다시 접속한다 |
- 서버의 프로토콜은 필드를 더하기만 한다. 모르는 필드와 모르는
push이벤트는 무시한다(오류로 취급하지 않는다). - 프레임 하나는 기본 64 KB까지다. 넘으면
too_large이고 연결은 유지된다.hello뒤에는hello.limits.frameSizeBytes가 상한이다. - 에러
code는 REST와 같다:unauthorized,forbidden,not_found,invalid,too_large,rate_limited,banned,moderation_denied,cursor_too_old,quota_exceeded,internal. 뜻은 REST API의 표와 같다. 프레임 하나가 실패해도 연결은 끊기지 않는다(인증 단계만 예외다).
인증: auth → hello
연결을 열면 5초 안에 첫 프레임으로 보낸다.
{ "type": "auth", "id": "auth", "data": { "key": "pk_…", "token": "<유저 토큰 JWT>" } }성공하면 같은 연결로 hello가 온다.
{
"type": "hello",
"data": {
"connectionId": "0AAS4P1MM0001",
"user": { "id": "u-1", "name": "alice", "avatar": "https://example.com/a.png" },
"mode": "token",
"serverTime": 1792861844000,
"heartbeatMs": 25000,
"limits": { "frameSizeBytes": 65536, "bodyTextMaxLen": 4000, "publishPerSecond": 5, "…": 0 },
"unread": { "total": 3, "mentions": 1 },
"warnings": []
}
}| 필드 | 설명 |
|---|---|
connectionId |
이 연결의 id. 지원 문의나 강제 종료의 대상 지정에 쓴다 |
user |
서버가 보는 나. id는 토큰의 sub다. 메시지의 sender가 이 값으로 나간다 |
mode |
늘 "token" |
serverTime |
서버 시각(에포크 밀리초). 기기 시계가 틀렸을 때 보정에 쓴다 |
heartbeatMs |
ping을 보낼 간격(밀리초) |
limits |
이 앱의 한도 실효값. 한도 |
unread |
내가 멤버인 방 전체의 안 읽은 총계: {total, mentions, roomsCapped?}. 방별 목록이 아니다. 필드가 없을 수 있고, 없는 것은 0이 아니다 — 없으면 GET /v1/me/unread로 묻는다. 0이라고 믿고 배지를 지우지 않는다 |
warnings |
문자열 배열. 지금은 보통 비어 있다 |
인증이 실패하면
error가 먼저 오고(그 id는 auth 프레임의 id다) 서버가 연결을 닫는다. 닫힘 사유(close reason)는 에러 code와 같다.
code |
원인 | 다음 동작 |
|---|---|---|
unauthorized |
키·토큰이 틀렸다, 만료·무효화됐다, Origin 불허, 첫 프레임이 auth가 아니다, 5초 안에 auth가 없다 |
같은 토큰으로 다시 하지 않는다. 백엔드에서 새 토큰을 받을 수 있으면 받아서 한 번 더, 안 되면 멈추고 사용자에게 알린다 |
forbidden |
이 키로는 허용되지 않는다 | 멈춘다 |
invalid |
auth 데이터가 JSON이 아니다 |
코드를 고친다 |
rate_limited |
접속 한도(유저당 분당 10, 키+IP당 분당 60) 또는 동시 연결 한도 | retryAfterMs가 있으면 그만큼, 없으면 백오프로 기다렸다 재접속 |
internal |
서버 오류 | 백오프로 재접속 |
토큰은 접속할 때마다 백엔드에서 새로 받는다. 한 번 받아 캐시해 두고 재사용하지 않는다. 토큰은 24시간 이내로 만료되고, 이미 열린 연결은 만료와 상관없이 유지된다.
연결 유지
heartbeatMs(25초)마다{"type":"ping","id":"p1"}을 보낸다. 서버는{"type":"pong"}으로 답한다. 서버는 60초 동안 클라이언트 프레임이 없으면 연결을 끊는다. WebSocket 라이브러리 자체의 ping 제어 프레임이 아니라 이 JSONping을 보내야 한다.- 모바일에서 앱이 백그라운드에서 돌아왔거나 네트워크가 바뀌면 바로
ping을 보내 본다. 답이 없으면 소켓이 죽은 것이니 즉시 재접속한다. - 서버가 배포 때문에 내려갈 때는
{"type":"reconnect","data":{"reason":"shutdown","delayMs":3200}}를 먼저 보낸다.delayMs만큼 기다렸다가 새로 접속한다(모든 클라이언트가 한꺼번에 몰리지 않게 서버가 나눠 준 시간이다). - 보내는 이벤트를 너무 느리게 읽으면 서버가 연결을 닫는다(사유
slow_consumer). 읽기 루프에서 오래 걸리는 일을 하지 않는다. 다시 접속하고 구멍을 메우면 된다.
서버가 닫을 때의 사유
서버가 닫는 연결은 WebSocket close 코드 1008(드물게 1000)에 아래 사유 문자열이 붙는다.
| 사유 | 의미 | 클라이언트 |
|---|---|---|
unauthorized, forbidden, invalid, rate_limited, internal, auth timeout |
인증 단계에서 거절 | 위 표 |
disconnected (또는 서비스가 정한 문구) |
서비스가 이 유저의 연결을 강제로 끊었다 | 가진 토큰으로 재접속해도 된다 |
tokens_revoked |
이 유저의 토큰이 전부 무효화됐다 | 백엔드에서 새 토큰을 받아야 한다. 같은 토큰으로 재접속하면 unauthorized로 되돌아온다 |
app_deleted |
앱이 삭제됐다 | 재접속은 unauthorized가 된다. 멈춘다 |
slow_consumer |
이벤트를 너무 느리게 읽었다 | 재접속하고 구멍을 메운다 |
사유를 못 읽어도(라이브러리마다 다르다) 규칙은 같다. 닫히면 재접속하고, 인증 단계에서 unauthorized/forbidden/invalid가 오면 멈춘다.
방 보기: subscribe / unsubscribe
**watch(구독)**는 "이 연결이 그 방의 모든 이벤트를 받는다"는 뜻이다. 지금 화면에 열려 있는 방만 구독한다(연결당 maxSubscriptions, 기본 100개).
{ "type": "subscribe", "id": "c2", "data": { "key": "lobby", "since": 12 } }| 필드 | 설명 |
|---|---|
roomId 또는 key |
정확히 하나. 방을 key로 열어도 이후 모든 push는 roomId로만 온다. ack의 roomId를 기억해 둔다 |
since |
내가 가진 메시지 중 끊김 없이 이어진 맨 위 seq. 처음 보는 방이면 생략한다 |
응답:
{
"type": "ack", "id": "c2",
"data": {
"roomId": "0AAS4P1MM0000", "lastSeq": 15, "minSeq": 1,
"presence": { "count": 2, "users": [{ "id": "u-1", "name": "alice" }, { "id": "u-2", "name": "bob" }] }
}
}| ack 필드 | 설명 |
|---|---|
lastSeq |
방의 마지막 seq. 내가 가진 것이 이보다 작으면 구멍이다 |
minSeq |
히스토리로 읽을 수 있는 가장 오래된 seq |
presence |
지금 방에 있는 사람 {count, users?, capped?}. users는 방이 100명 이하일 때만 있다. 조회가 실패하면 필드째 빠진다(0명과 다르다) |
- 구독해도 지난 이벤트는 오지 않는다. 히스토리는 REST로 가져온다(재접속과 구멍 메우기).
since가minSeq - 1보다 작으면cursor_too_old에러다. 이어 붙일 수 없으니 가진 목록을 버리고since없이 다시subscribe한 뒤 최근 히스토리를 받는다.- 이미 구독한 방에 다시
subscribe하면 아무것도 바뀌지 않고 같은 ack가 온다. - 구독은 연결에 붙어 있다. 소켓이 끊기면 모두 사라지므로
hello뒤에 다시 보낸다. - 구독하는 순간 이 유저가 방의 접속 중(presence) 목록에 들어가고,
unsubscribe하거나 연결이 끊기면 빠진다. - 구독할 수 없는 방은 에러다: 없거나 다른 앱의 방
not_found, 읽을 수 없는 방forbidden, 한도 초과too_large, 탈퇴한 유저unauthorized. publish·react·read·typing은 구독하지 않은 방에도 보낼 수 있다(권한만 있으면 된다). 그 방의 이벤트는 구독해야 받는다.
{ "type": "unsubscribe", "id": "c3", "data": { "roomId": "0AAS4P1MM0000" } }unsubscribe는 구독하지 않은 방에도 항상 빈 ack({})로 성공한다.
구독하지 않은 방의 새 메시지는 내가 멤버일 때 유저 이벤트 notification.message로 온다(안 읽은 배지용). 그 방의 message.created는 오지 않는다. 한 연결에는 둘 중 하나만 온다.
메시지 보내기: publish
{
"type": "publish", "id": "c4",
"data": {
"roomId": "0AAS4P1MM0000", "clientMessageId": "cm-6f1c9a1e",
"body": { "text": "안녕 @bob", "entities": [{ "type": "mention", "userId": "u-2", "offset": 3, "length": 4 }] },
"replyTo": "0AAS4P1P40000", "threadId": "0AAS4P1P40000", "meta": { "client": "ios" }
}
}| 필드 | 필수 | 설명 |
|---|---|---|
roomId |
예 | |
clientMessageId |
예 | 아래 「멱등성」 |
body |
예 | {text?, attachments?, entities?}. 첨부는 {type, url, key?, mime?, w?, h?, name?, size?}이고 url은 https만 된다 |
replyTo |
인용할 메시지 id. 같은 방이어야 한다 | |
threadId |
스레드 답글이면 원 메시지 id. 답글의 id를 주면 그 루트로 바꿔 저장한다 | |
meta |
JSON 객체 | |
kind |
생략한다(text). system은 서버 키로 REST에서만 보낸다 |
ack: {"messageId": "0AAS4P1P40800", "seq": 16, "createdAt": 1792861844902}
- ack를 받은 메시지는 저장된 것이다. 방을 구독하고 있으면 내가 보낸 메시지도
message.created로 다시 온다. ack와message.created가 오는 순서는 정해져 있지 않으니id나seq로 중복을 걸러 한 번만 그린다. - 보내는 순간에 미리 화면에 그리는(낙관적 표시) 것은 권장하지 않는다. 앱의
before_publish훅이 본문을 바꾸거나 거절할 수 있어서, 저장된 모양은 ack 뒤에야 확정된다. - 실패
code:forbidden(쓸 수 없는 방,channel의 일반 멤버),banned,moderation_denied(message와data.appCode에 서비스가 정한 값이 실린다),rate_limited(retryAfterMs),too_large(본문이bodyTextMaxLen을 넘는다),invalid(clientMessageId없음, 첨부url이 https가 아님,entities100개 초과),quota_exceeded(앱의 저장 쿼터),not_found. - 본문 텍스트는 엔진이 이스케이프하지 않는다. HTML·마크다운으로 렌더하는 클라이언트는 렌더 단계에서 이스케이프한다. 표시 이름도 같다.
entities의 멘션은userId가 실제 유저일 때만 멘션으로 센다. 모르는 id는 거절이 아니라 제외이고, 본문의 entity는 그대로 남는다.
멱등성: clientMessageId
- 메시지마다 새 값을 만든다(UUIDv4 권장, 예
"cm-" + uuid). - 같은 보낸 사람이 24시간 안에 같은 값으로 다시 보내면 새 메시지를 만들지 않고 처음 저장된 메시지의
messageId·seq로 ack한다. - ack 전에 연결이 끊겨 저장 여부를 모를 때는 같은
clientMessageId로 다시 보낸다. 새 값을 쓰면 같은 말이 두 번 올라간다. - 사용자가 "다시 보내기"를 누르면 값을 그대로 둔다. 새 내용은 새 값이다.
리액션: react
{ "type": "react", "id": "c5", "data": { "roomId": "0AAS4P1MM0000", "messageId": "0AAS4P1P40800", "emoji": "👍", "op": "add" } }op는"add"또는"remove". 기본값이 없다. 없거나 다른 값이면invalid다.- 멱등이다. 이미 누른 이모지를 또
add해도 오류가 아니다. emoji는 앱이 정하는 불투명한 문자열이고 64바이트 이하다(글자 수가 아니라 바이트).- 한 메시지에 서로 다른 이모지는
maxDistinctEmojiPerMessage(기본 20)개까지다. 이미 달린 이모지를 누르는 것은 이 한도와 무관하다. 넘기면invalid다. 화면에서 미리 막으려면hello의limits와 행의reactions개수를 본다. - ack:
{"messageId": "…", "emoji": "👍", "count": 3, "changed": true}.count는 누른 뒤의 값이고changed: false면 아무것도 바뀌지 않았다. 브로드캐스트를 놓쳤어도 이count로 맞춘다. - 지워진 메시지에는
not_found다. 밴된 유저는banned다.
읽음 위치: read
{ "type": "read", "id": "c6", "data": { "roomId": "0AAS4P1MM0000", "seq": 16 } }seq는 1 이상이다. 내가 본 마지막 메시지의seq를 보낸다.- 위치는 앞으로만 간다. 지금보다 작은 값은 오류 없이 무시된다. 방의
lastSeq를 넘는 값은lastSeq로 잘린다. - ack:
{"roomId": "…", "seq": 16, "advanced": true}. 서버가 실제로 쓴 값이다.advanced: false면 무시되거나(뒤로 가는 값) 저장할 것이 없었다(메시지 없는 방). threadId를 주면 그 스레드의 위치다(답글 id를 줘도 루트로 바뀐다). ack의threadId도 바뀐 루트다. 안 주면 본 타임라인이고, 스레드를 읽었다고 방 배지가 지워지지 않는다.- 내 위치를 새로 불러오려면
GET /v1/rooms/{roomId}/reads?userId=<내 id>를 쓴다. - 안 읽은 수는 서버가 센다.
lastSeq - readSeq로 계산하지 않는다(스레드 답글, 내 메시지, 삭제된 메시지 때문에 틀린다).
입력 중: typing
{ "type": "typing", "id": "c7", "data": { "roomId": "0AAS4P1MM0000", "threadId": "0AAS4P1P40000" } }threadId는 스레드 답글을 쓰는 중일 때만 준다.
보내는 쪽:
- 입력 이벤트마다 부르되 같은 방에는 1초에 한 번 이하로 보낸다(클라이언트에서 줄인다). 한 번도 안 줄이면 프레임이 낭비된다.
- 서버는 한 유저의 방 하나에서 3초에 한 번만 방송한다. 줄여진 프레임에도
ack는 정상으로 오고data가 비어 있다. - 방 전체로도 한 창(3초)에 알리는 사람 수에 상한이 있어 붐비는 방에서는 일부가 알려지지 않는다.
- 쓰기 권한이 필요하다.
channel의 일반 멤버는 읽을 수 있어도forbidden이다. 밴·탈퇴한 유저의typing은 조용히 무시된다. typing은 ack를 기다리지 않고 보내도 된다. 실패해도 입력을 막지 않는다.
받는 쪽 (typing 이벤트):
- "입력 멈춤" 이벤트는 없다. 마지막
typing이벤트를 받은 뒤 5초 이상 표시하고 지운다. - 내가 보낸
typing도 나에게 온다. 이벤트의userId가 내 id면 건너뛴다. - 이름은 이벤트에 없다. 방의 접속 중 목록(
presence.users)이나 최근 메시지의sender에서 찾는다. threadId가 있으면 그 스레드(루트 메시지 id)의 입력 중이고, 없으면 본 타임라인이다.
서버가 보내는 이벤트 (push)
{ "type": "push", "data": { "roomId": "0AAS4P1MM0000", "event": "message.created", "data": { } } }방을 구독하고 있을 때 그 방의 이벤트가 오고, 유저 이벤트(notification.message, user.presence)는 구독과 상관없이 온다. 방 이벤트는 roomId로 어느 방인지 안다.
event |
범위 | data |
|---|---|---|
message.created |
방 | 메시지 객체. 스레드 답글도 이 이벤트다. thread는 없고 reactions는 빈 배열이며 myReactions는 없다(갓 만든 메시지다) |
message.updated |
방 | 메시지 객체. 수정된 메시지의 reactions·thread는 실제 값이다. myReactions는 없다 |
message.deleted |
방 | {id, seq}. seq는 그 메시지가 발행될 때의 값이다 |
reaction.added, reaction.removed |
방 | {messageId, emoji, count, userId}. count는 변경 후의 값이다 |
thread.updated |
방 | {rootId, count, lastSeq, lastAt}. 답글이 달리거나 지워질 때. 지워질 때는 message.deleted 다음에 온다 |
presence |
방 | {count, joined?, left?, users?}. 아래 「접속 중」 |
typing |
방 | {userId, threadId?}. 위 「입력 중」 |
read |
방 | {userId, seq, threadId?}. 누군가의 읽음 위치가 앞으로 갔다 |
member.joined, member.left |
방 | {userId, role?} |
room.updated |
방 | 방 객체 |
room.deleted |
방 | {roomId}. 이 프레임 뒤로 그 방의 구독이 풀린다. 연결은 유지된다 |
custom |
방 | 서비스 백엔드가 POST …/custom으로 보낸 JSON 객체 그대로 |
notification.message |
유저 | {roomId, seq, unread?, unreadMentions?, unreadCapped?, preview?}. 내가 멤버인데 구독하지 않은 방에 새 메시지가 왔다. unread는 이 메시지를 포함한 내 안 읽은 수이고, 계산이 실패하면 필드가 빠진다(0이 아니다). preview는 {sender: {id, name, avatar?}, text, createdAt} |
user.presence |
유저 | {userId, status: "online" | "offline", lastSeenAt?}. 내가 멤버인 dm·private 방의 상대 상태다. 그 유저의 연결 수가 0에서 1이 되거나 1에서 0이 될 때만 온다. lastSeenAt은 offline일 때만 있다 |
- 메시지 이벤트(
message.*)는 놓쳐도 재접속 뒤 REST 히스토리로 복구된다. 휘발 이벤트(presence,typing,custom,notification.message,user.presence)는 놓쳐도 되고 복구되지 않는다. 리액션·스레드 집계·읽음·멤버 변동을 놓친 것은 히스토리에 실린 메시지의 집계로만 보정된다. notification.message를 놓쳤으면GET /v1/me/rooms로 방별 안 읽은 수를 다시 받는다.
이벤트를 화면 상태에 반영하는 규칙
| 이벤트 | 규칙 |
|---|---|
message.created |
seq 자리에 끼워 넣는다. 그 seq가 이미 있으면 덮어쓴다(단, 내가 삭제됨으로 표시한 것은 되살리지 않는다). seq가 내가 가진 맨 위 +1이 아니면 구멍이다 → 구멍 메우기 |
message.updated |
같은 id의 필드를 새 값으로 바꾼다. 이미 삭제된 행이면 무시한다 |
message.deleted |
행을 지우지 말고 본문을 비우고 삭제됨으로 표시한다(seq 자리는 유지). 아직 그 행이 없으면 id를 기억해 두었다가 행이 들어올 때 비운다 |
reaction.added, reaction.removed |
그 이모지의 개수를 count로 맞춘다(더하거나 빼지 않는다). count가 0 이하면 그 이모지를 뺀다. userId가 내 id면 내 myReactions에 넣거나 뺀다 |
thread.updated |
루트 메시지의 thread를 {count, lastSeq}로 바꾼다 |
room.deleted |
그 방을 닫는다 |
- 본 타임라인에는
threadId가 없는 메시지만 그린다. 스레드 답글은 스레드 화면에 그린다. - 동기화 위치(
seq)는 방 전체 스트림 기준이다. 본 타임라인만 골라 그리더라도 위치 계산에는 스레드 답글을 포함한 모든 메시지를 쓴다.
접속 중 (presence)
방의 접속 중은 그 방을 구독한 연결의 유저다. 처음 값은 subscribe ack의 presence에서 받고, 이후는 presence 이벤트로 갱신한다.
presence이벤트의joined·left는 증분이다. 가진 목록에joined를 더하고(같은id가 있으면 교체)left(유저 id 배열)를 뺀다.users가 오면 목록 전체를 그것으로 바꾼다(교체).joined/left와users는 한 프레임에 같이 오지 않는다.- 방이 100명 이하면 사람이 들어오고 나갈 때마다 즉시
joined/left가 온다. 그보다 크면 주기적으로(5초마다)count만 오고joined·users는 오지 않는다. 100명에서 올라가고 90명에서 내려온다. - 이벤트가
count만 싣고joined·left·users가 모두 없으면 가진 목록은 이제 믿을 수 없다. 목록을 버리고count만 쓴다. 전체 목록이 필요하면GET /v1/rooms/{roomId}/presence?full=true로 가져온다(1000명에서 잘린다). - 작은 방으로 돌아오면
{count, users: 전체}스냅샷이 한 번 온다. 이것이 낡은 목록을 고친다. - 가진 목록이 없을 때는
joined만으로 목록을 만들지 않는다(나중에 온 사람만 보이게 된다). - 한 사람이 연결을 여럿 열어도 목록에는 한 줄이다.
joined는 그 유저의 첫 구독에서,left는 마지막 구독이 풀릴 때 온다.
재접속과 구멍 메우기
끊김은 정상이다. 지하철, 백그라운드, 배포가 모두 끊김이다. 어떤 이벤트를 놓쳤더라도 seq로 알 수 있다. 방의 seq는 1부터 구멍 없이 늘기 때문에, 내가 가진 번호가 이어지지 않으면 그 사이를 REST로 받으면 된다.
재접속 루프
attempt = 0
loop:
token = 백엔드에서 새 토큰 받기 # 접속마다. 실패하면 백오프 뒤 다시
ws 열기 → auth 보내기
hello 받으면: attempt = 0, ping 타이머 시작, 아래 resubscribe 실행
ws가 닫히면(또는 ack 없이 죽으면):
대기 중이던 요청은 모두 실패 처리(아래 「재전송」)
인증 단계 거절(unauthorized/forbidden/invalid)이면 멈춘다
대기 시간 = 서버가 정해 준 값(reconnect.delayMs, rate_limited의 retryAfterMs)이 있으면 그 값
없으면 500ms + random() × (min(30000, 500ms × 2^attempt) − 500ms) # 지수 백오프 + 지터
attempt += 1, 대기 후 loop- 지터를 빼지 않는다. 서버가 내려가면 모든 클라이언트가 같은 순간에 끊겨 같은 순간에 돌아온다.
- 사용자가 직접 누르는 전송은 백오프를 건너뛰고 즉시 재접속을 시도해도 된다. 단 서버가 정해 준 대기는 건너뛰지 않는다.
hello의user.id가 이전 접속과 다르면(로그아웃 후 다른 계정) 방 상태를 전부 버리고 새로 받는다. 이전 유저의myReactions가 새 유저 화면에 남지 않게 한다.
resubscribe (hello를 받을 때마다, 처음에도)
방마다 한다. 방 상태는 messages(seq 순으로 정렬된 목록)와 contiguousSeq다.
contiguousSeq = 목록의 첫 메시지부터 seq가 +1(또는 같게) 이어지는 맨 위 값. 목록이 비면 0
subscribe 보내기 { roomId 또는 key, since: contiguousSeq (0이면 생략) }
├─ cursor_too_old 에러 → 목록을 버리고 since 없이 다시 subscribe → 최근 히스토리 받기(아래 A) → 화면에 "목록이 새로 만들어졌다" 알림
└─ ack {lastSeq, minSeq}
├─ since 있음(내가 가진 게 있다) → catchUp(lastSeq) (아래 B)
└─ since 없음 → lastSeq > 0이면 최근 히스토리 받기(A)A. 최근 히스토리: GET /v1/rooms/{roomId}/messages?limit=100 한 번. 받은 페이지로 목록을 바꾸되, 그 요청이 오가는 동안 push로 이미 들어온 메시지 중 페이지의 맨 위 seq보다 큰 것은 지우지 않고 남긴다.
B. catchUp(upTo): 내 contiguousSeq부터 upTo까지를 채운다.
fill(after, before): # after는 포함하지 않고 before도 포함하지 않는 구간
cursor = after
while cursor < before - 1:
page = GET /v1/rooms/{roomId}/messages?after={cursor}&limit=100 # view는 생략(방 전체)
if page가 비었으면 종료
page의 각 메시지를 seq 자리에 끼워 넣는다
if page의 맨 위 seq <= cursor 이면 종료
cursor = page의 맨 위 seq
catchUp(upTo): fill(contiguousSeq, upTo + 1)after=N은 seq > N 중 오래된 것부터 최대 100개를 준다. 한 번에 다 안 오면 마지막 seq를 after로 넣어 이어 받는다. 이미 이어진 상태면(upTo <= contiguousSeq) REST를 부르지 않는다.
실시간 중의 구멍
message.created의 seq가 가진 맨 위 seq+1보다 크면(예: 15까지 가졌는데 18이 왔다) 먼저 18을 끼워 넣고 fill(15, 18)로 16·17을 받는다. 목록이 비어 있으면 구멍 검사를 하지 않는다.
위로 스크롤 (더 오래된 히스토리)
가진 맨 위 seq가 minSeq보다 크면 더 오래된 것이 있다. GET …/messages?before={가진 맨 위 seq}&limit=100으로 받아 앞에 붙인다. 응답이 limit보다 적으면 더 없다.
이 규칙의 함정
view=main·thread:…·q(검색)의 결과는seq가 이어지지 않는다. 그 결과의 마지막seq를 동기화 위치로 쓰지 않는다. 동기화는 늘view없이(방 전체) 한다.- 삭제된 메시지도
seq를 가진 행으로 남아 있다(tombstone).seq는 삭제로 비지 않는다. - REST가 401이면 토큰이 만료된 것일 수 있다. 새 토큰으로 한 번만 다시 시도한다. 소켓은 접속 때 한 번만 인증하므로 소켓은 멀쩡한데 REST만 401인 일이 생긴다.
- 반응·스레드 개수·읽음 위치·멤버 변동은 히스토리 페이지에 들어 있는 메시지에 대해서만 복구된다. 오래 떨어져 있었다면 화면에 보이는 구간의 최근 히스토리를 다시 받는 것이 가장 정직하다.
재전송
ack를 받기 전에 연결이 끊긴 요청은 서버가 받았는지 모른다. 이렇게 다룬다.
| 요청 | 다시 보내도 되나 |
|---|---|
publish |
같은 clientMessageId로 다시 보낸다. 중복되지 않는다 |
react |
다시 보낸다(멱등) |
read |
다시 보낸다(멱등) |
typing |
다시 보내지 않는다(휘발) |
subscribe |
새 연결에서 resubscribe가 대신한다 |
재전송은 한 번이면 충분하다. 연결이 안 돌아오면 시간 초과(권장 15초)로 사용자에게 실패를 알린다.
한도
한도는 앱마다 다르고 hello.limits에 이 앱의 실효값이 실린다. 아래 숫자를 코드에 박지 말고 hello.limits를 읽는다. 키가 없으면 기본값이다.
limits 키 |
기본값 | 걸리면 |
|---|---|---|
frameSizeBytes |
65536 | too_large (프레임) |
bodyTextMaxLen |
4000 | too_large (본문 글자 수) |
maxAttachments |
10 | invalid |
publishPerSecond / publishBurst |
5 / 10 | rate_limited (유저당, 순간 10건 + 10초 평균 5건) |
reactPerSecond |
10 | rate_limited |
typingPerSecond |
20 | rate_limited |
editPerSecond |
2 | rate_limited (REST 수정) |
connectPerMinute |
10 | 접속 시 rate_limited (유저당) |
connectionEstablishPerMinute |
60 | 접속 시 rate_limited (같은 pk_와 같은 IP) |
maxConcurrentConnections |
10 | 접속 시 rate_limited. retryAfterMs가 없다(언제 자리가 날지 모른다) |
maxSubscriptions |
100 | subscribe가 too_large |
roomCreatePerDay |
50 | rate_limited (유저 토큰으로 방 만들기) |
maxDistinctEmojiPerMessage |
20 | invalid |
notificationMemberCap |
1000 | 멤버가 이보다 많은 방은 notification.message가 가지 않는다. GET /v1/me/rooms를 주기적으로 읽는다 |
editWindowMs |
0 (제한 없음) | 수정 가능 시간 |
maxStoredMessages |
없음(무제한) | 앱의 저장 쿼터. 차면 quota_exceeded |
hello.limits에 이 표에 없는 키가 있을 수 있다. 모르는 키는 무시한다.hello.limits는 접속한 순간의 값이다. 한도가 바뀌어도 열린 연결에는 다시 오지 않는다. 다음 접속의hello에 실린다.- 한도는 고정 창으로 센다(슬라이딩이 아니다). 창 경계에서 잠깐 두 배가 나갈 수 있다.
rate_limited에러의retryAfterMs만큼 기다렸다가 같은 요청을 다시 보낸다. 짧게 줄여 재시도하지 않는다.- 서버 키(
sk_)의 REST 호출은 발행·수정 한도를 세지 않는다.
SDK 없이 클라이언트 만들기
공식 SDK(JS/TS)가 대신 해 주는 일의 전체 목록이다. 이 목록을 구현하면 SDK와 같은 클라이언트다. 위에서 아래로 만들면 된다.
연결
- 백엔드에서 유저 토큰을 받는 함수를 둔다. 접속할 때마다, 그리고 REST가 401일 때 이 함수를 부른다.
wss://api.chat.gravex.app/v1/ws?v=1로 연결하고 열리면auth를 보낸다.hello를 받으면open상태다. 상태는connecting→open→reconnecting→ (closed)로 갖는다.- 프레임
id를 만들고,id별로 응답을 기다리는 표(대기 중 요청)를 둔다.ack면 성공으로,error면 실패로 잇는다. 시간 초과(15초)와 연결 끊김은 표 전체를 실패시킨다. heartbeatMs마다ping을 보낸다. 앱이 포그라운드로 돌아오거나 네트워크가 돌아오면 바로ping을 보내 보고 안 되면 재접속한다.- 끊기면 지수 백오프(500ms~30s)와 지터로 재접속한다.
reconnect.delayMs와rate_limited.retryAfterMs가 있으면 그 값을 우선한다. 인증 단계의unauthorized·forbidden·invalid면 멈추고 사용자에게 알린다. hello.user.id가 바뀌면 방 상태를 전부 버린다.
방
- 방마다
messages(seq 순 목록)와contiguousSeq를 둔다. hello를 받을 때마다 구독 중인 방 전부에subscribe(since = contiguousSeq)를 다시 보낸다.cursor_too_old면 목록을 버리고since없이 다시 구독해 최근 히스토리를 받는다.- ack의
lastSeq까지 RESTGET …/messages?after=로 구멍을 메운다. 실시간message.created가seq구멍을 만들어도 같은 방법으로 메운다. - 위로 스크롤은
before=로 받는다. - 이벤트를 위의 「반영하는 규칙」대로 목록에 반영한다. 삭제는 행을 지우지 않고 비운다. 리액션 개수는 서버
count로 맞춘다. presence를 증분(joined/left)과 교체(users)로 합치고,count만 오면 목록을 버린다.
보내기
publish마다 새clientMessageId를 만든다. ack 전에 끊기면 같은 값으로 한 번 다시 보낸다.react·read는 ack 전에 끊기면 다시 보낸다.typing은 다시 보내지 않는다.typing은 방마다 1초에 한 번 이하로 보내고, 받는 쪽은 마지막 이벤트 뒤 5초 이상 보여 준 뒤 지운다. 내userId는 건너뛴다.read는 화면에 실제로 보인 마지막seq를 앞으로만 보낸다. 너무 자주 보내지 않는다.
안 읽은 수와 알림
- 접속 직후
hello.unread가 있으면 배지에 쓰고, 없으면GET /v1/me/unread로 묻는다. - 구독하지 않은 방은
notification.message의unread로 방 목록 배지를 올리고, 놓쳤을 수 있으면GET /v1/me/rooms로 다시 받는다.
마무리
- 에러는
code로 분기한다(rate_limited는 대기,banned·moderation_denied는 사용자에게 표시,quota_exceeded·forbidden은 재시도하지 않는다). - 본문 텍스트와 이름을 화면에 그릴 때 이스케이프한다. 엔진은 하지 않는다.
- (선택) 리액션을 누를 때 내
myReactions를 먼저 바꾸고 ack가 실패하면 되돌린다. 개수는 서버 값만 쓴다.
푸시 알림은 이 연결의 일이 아니다. 앱이 꺼져 있는 사람에게는 message.created 웹훅을 받은 서비스 서버가 FCM·APNs로 보낸다.
프레임 대화 예시
→는 클라이언트가 보낸 것, ←는 서버가 보낸 것이다. 한 줄이 프레임 하나다.
→ {"type":"auth","id":"auth","data":{"key":"pk_…","token":"eyJ…"}}
← {"type":"hello","data":{"connectionId":"0AAS4P1MM0001","user":{"id":"u-1","name":"alice"},"mode":"token","serverTime":1792861844000,"heartbeatMs":25000,"limits":{"frameSizeBytes":65536,"bodyTextMaxLen":4000},"unread":{"total":0,"mentions":0},"warnings":[]}}
→ {"type":"subscribe","id":"c1","data":{"key":"lobby"}}
← {"type":"ack","id":"c1","data":{"roomId":"0AAS4P1MM0000","lastSeq":15,"minSeq":1,"presence":{"count":1,"users":[{"id":"u-1","name":"alice"}]}}}
(이어서 REST: GET /v1/rooms/0AAS4P1MM0000/messages?limit=100)
→ {"type":"publish","id":"c2","data":{"roomId":"0AAS4P1MM0000","clientMessageId":"cm-6f1c9a1e","body":{"text":"안녕"}}}
← {"type":"ack","id":"c2","data":{"messageId":"0AAS4P1P40800","seq":16,"createdAt":1792861844902}}
← {"type":"push","data":{"roomId":"0AAS4P1MM0000","event":"message.created","data":{"id":"0AAS4P1P40800","seq":16,"kind":"text","sender":{"id":"u-1","name":"alice"},"body":{"text":"안녕"},"mentions":[],"reactions":[],"createdAt":1792861844902}}}
→ {"type":"react","id":"c3","data":{"roomId":"0AAS4P1MM0000","messageId":"0AAS4P1P40800","emoji":"👍","op":"add"}}
← {"type":"ack","id":"c3","data":{"messageId":"0AAS4P1P40800","emoji":"👍","count":1,"changed":true}}
← {"type":"push","data":{"roomId":"0AAS4P1MM0000","event":"reaction.added","data":{"messageId":"0AAS4P1P40800","emoji":"👍","count":1,"userId":"u-1"}}}
→ {"type":"publish","id":"c4","data":{"roomId":"0AAS4P1MM0000","clientMessageId":"cm-77aa","body":{"text":"빠르게"}}}
← {"type":"error","id":"c4","data":{"code":"rate_limited","message":"rate limit exceeded","retryAfterMs":640}}
→ {"type":"ping","id":"p1"}
← {"type":"pong"}