752 lines
17 KiB
Markdown
752 lines
17 KiB
Markdown
|
|
# 13. v0.1 API And Events Contract
|
||
|
|
|
||
|
|
## 목적
|
||
|
|
|
||
|
|
이 문서는 `v0.1` 구현 범위에 맞는 최소 계약만 따로 고정한다.
|
||
|
|
|
||
|
|
대상 범위:
|
||
|
|
- Alpha 가입
|
||
|
|
- 세션 갱신
|
||
|
|
- 부트스트랩
|
||
|
|
- 대화 목록
|
||
|
|
- 대화 메시지 목록
|
||
|
|
- 텍스트 메시지 전송
|
||
|
|
- WebSocket 실시간 이벤트
|
||
|
|
|
||
|
|
핵심 원칙:
|
||
|
|
- 서버가 최대한 계산하고, 클라이언트는 최대한 렌더링만 한다.
|
||
|
|
- REST와 WebSocket에서 같은 엔티티 모양을 재사용한다.
|
||
|
|
- 파생값은 클라이언트에서 계산하지 않는다.
|
||
|
|
- `v0.1`에서는 전송도 REST로 처리하고, WebSocket은 서버 푸시 전용으로 둔다.
|
||
|
|
|
||
|
|
## 고정 결정
|
||
|
|
|
||
|
|
### 1. `GET /v1/bootstrap`이 `GET /v1/me`를 대체한다
|
||
|
|
|
||
|
|
`v0.1`에서는 별도 `GET /v1/me`를 두지 않는다.
|
||
|
|
|
||
|
|
이유:
|
||
|
|
- 첫 실행과 재실행의 초기 진입 경로를 하나로 고정할 수 있다.
|
||
|
|
- `me`, 대화 목록, WebSocket 연결 정보를 한 번에 받으면 클라이언트 부팅 분기가 줄어든다.
|
||
|
|
|
||
|
|
### 2. 대화방 제목과 대표 이미지는 서버가 확정한다
|
||
|
|
|
||
|
|
DM인 경우에도 클라이언트가 상대 이름으로 제목을 조합하지 않는다.
|
||
|
|
|
||
|
|
서버는 항상 아래를 내려준다.
|
||
|
|
- `title`
|
||
|
|
- `avatar_url`
|
||
|
|
- `subtitle`
|
||
|
|
|
||
|
|
### 3. 대화 목록 정렬과 안읽음 수는 서버 책임이다
|
||
|
|
|
||
|
|
서버는 항상 최신 활동순으로 `conversations`를 정렬한다.
|
||
|
|
|
||
|
|
클라이언트는 아래를 그대로 쓴다.
|
||
|
|
- `sort_key`
|
||
|
|
- `unread_count`
|
||
|
|
- `last_message`
|
||
|
|
|
||
|
|
### 4. 메시지 전송은 REST만 사용한다
|
||
|
|
|
||
|
|
`POST /v1/conversations/{conversation_id}/messages/text`
|
||
|
|
|
||
|
|
이유:
|
||
|
|
- WinUI 클라이언트의 재연결, ACK, 재전송, 세션 resume 복잡도가 줄어든다.
|
||
|
|
- 서버는 저장 성공 후 응답하고, 실시간 갱신은 WebSocket으로 fan-out 한다.
|
||
|
|
|
||
|
|
### 5. WebSocket은 인증된 푸시 채널이다
|
||
|
|
|
||
|
|
연결 시 `Authorization: Bearer <access_token>` 헤더를 사용한다.
|
||
|
|
|
||
|
|
`v0.1`에서는 `auth.connect`, `session.resume`, `message.send` 이벤트를 만들지 않는다.
|
||
|
|
|
||
|
|
### 6. 목록 응답은 항상 클라이언트가 바로 그릴 수 있는 모양으로 준다
|
||
|
|
|
||
|
|
`ConversationSummary`, `MessageItem`은 요약용 별도 shape를 쓰지 않고 그대로 UI 바인딩 가능한 형태를 유지한다.
|
||
|
|
|
||
|
|
## 공통 규칙
|
||
|
|
|
||
|
|
### ID
|
||
|
|
|
||
|
|
- 모든 ID는 문자열이다.
|
||
|
|
- 형식은 `ULID`를 권장한다.
|
||
|
|
- 클라이언트는 ID 정렬 규칙에 의존하지 않는다.
|
||
|
|
|
||
|
|
### 시간
|
||
|
|
|
||
|
|
- 모든 시간은 UTC ISO-8601 문자열이다.
|
||
|
|
- 예: `2026-04-16T11:42:31Z`
|
||
|
|
|
||
|
|
### REST 응답 envelope
|
||
|
|
|
||
|
|
성공:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"data": {}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
목록:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"data": {
|
||
|
|
"items": [],
|
||
|
|
"next_cursor": null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
오류:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"error": {
|
||
|
|
"code": "invite_invalid",
|
||
|
|
"message": "초대코드가 유효하지 않습니다.",
|
||
|
|
"retryable": false,
|
||
|
|
"field_errors": {
|
||
|
|
"invite_code": "초대코드를 다시 확인해 주세요."
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### WebSocket event envelope
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"event": "message.created",
|
||
|
|
"event_id": "01HSX8X4N1Y6C4R1VX9H1F4K98",
|
||
|
|
"occurred_at": "2026-04-16T11:42:31Z",
|
||
|
|
"data": {}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Canonical Shapes
|
||
|
|
|
||
|
|
### AuthTokens
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"access_token": "jwt-or-opaque",
|
||
|
|
"access_token_expires_at": "2026-04-16T12:42:31Z",
|
||
|
|
"refresh_token": "opaque-refresh-token",
|
||
|
|
"refresh_token_expires_at": "2026-05-16T11:42:31Z"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
설명:
|
||
|
|
- `refresh_token`은 매 갱신 시 회전한다.
|
||
|
|
- 클라이언트는 새 `refresh_token`을 받으면 기존 값을 즉시 덮어쓴다.
|
||
|
|
|
||
|
|
### SessionInfo
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"session_id": "01HSX8T8J7CN1J8TJQ3E6V6KPA",
|
||
|
|
"device_id": "01HSX8SZPQZQ4YVE1N9WR6W4KJ",
|
||
|
|
"device_name": "Windows PC",
|
||
|
|
"created_at": "2026-04-16T11:42:31Z"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Me
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"user_id": "01HSX8P2QH5Q1REJYQY3QNZ4N6",
|
||
|
|
"display_name": "이안",
|
||
|
|
"profile_image_url": null,
|
||
|
|
"status_message": null
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### ConversationSummary
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"conversation_id": "01HSX90M5W7Y0S8DDE6RP9N2PD",
|
||
|
|
"type": "self",
|
||
|
|
"title": "나에게 메시지",
|
||
|
|
"avatar_url": null,
|
||
|
|
"subtitle": "메모와 파일을 나에게 보관해 보세요.",
|
||
|
|
"member_count": 1,
|
||
|
|
"is_muted": false,
|
||
|
|
"is_pinned": true,
|
||
|
|
"sort_key": "2026-04-16T11:42:31Z",
|
||
|
|
"unread_count": 0,
|
||
|
|
"last_read_message_id": null,
|
||
|
|
"last_message": null
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
`last_message`가 있을 때:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"message_id": "01HSX92D44DWFA0KTV6B0T0SE5",
|
||
|
|
"text": "안녕하세요",
|
||
|
|
"created_at": "2026-04-16T11:45:02Z",
|
||
|
|
"sender_user_id": "01HSX8P2QH5Q1REJYQY3QNZ4N6"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### MessageItem
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"message_id": "01HSX92D44DWFA0KTV6B0T0SE5",
|
||
|
|
"conversation_id": "01HSX90M5W7Y0S8DDE6RP9N2PD",
|
||
|
|
"client_message_id": "d5bf6a88-b6b0-4f1c-b11d-d2d8a9aaf3b8",
|
||
|
|
"kind": "text",
|
||
|
|
"text": "안녕하세요",
|
||
|
|
"created_at": "2026-04-16T11:45:02Z",
|
||
|
|
"edited_at": null,
|
||
|
|
"sender": {
|
||
|
|
"user_id": "01HSX8P2QH5Q1REJYQY3QNZ4N6",
|
||
|
|
"display_name": "이안",
|
||
|
|
"profile_image_url": null
|
||
|
|
},
|
||
|
|
"is_mine": true
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
설명:
|
||
|
|
- `sender`를 포함해 클라이언트가 별도 사용자 lookup 없이 렌더링 가능하게 한다.
|
||
|
|
- `is_mine`도 서버가 내려준다.
|
||
|
|
|
||
|
|
### BootstrapPayload
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"me": {},
|
||
|
|
"session": {},
|
||
|
|
"tokens": {},
|
||
|
|
"ws": {
|
||
|
|
"url": "wss://ws.example.com/v1/ws"
|
||
|
|
},
|
||
|
|
"conversations": {
|
||
|
|
"items": [],
|
||
|
|
"next_cursor": null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
설명:
|
||
|
|
- 가입 직후와 앱 재실행 직후 모두 같은 shape를 쓴다.
|
||
|
|
- `tokens`는 가입/갱신 응답에만 포함되고, 일반 `GET /v1/bootstrap` 응답에는 포함하지 않는다.
|
||
|
|
|
||
|
|
## Endpoint Contract
|
||
|
|
|
||
|
|
### 1. Alpha 가입
|
||
|
|
|
||
|
|
`POST /v1/auth/register/alpha-quick`
|
||
|
|
|
||
|
|
request:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"display_name": "이안",
|
||
|
|
"invite_code": "ALPHA-SEOUL-1234",
|
||
|
|
"device_name": "Windows PC"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
response:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"data": {
|
||
|
|
"me": {
|
||
|
|
"user_id": "01HSX8P2QH5Q1REJYQY3QNZ4N6",
|
||
|
|
"display_name": "이안",
|
||
|
|
"profile_image_url": null,
|
||
|
|
"status_message": null
|
||
|
|
},
|
||
|
|
"session": {
|
||
|
|
"session_id": "01HSX8T8J7CN1J8TJQ3E6V6KPA",
|
||
|
|
"device_id": "01HSX8SZPQZQ4YVE1N9WR6W4KJ",
|
||
|
|
"device_name": "Windows PC",
|
||
|
|
"created_at": "2026-04-16T11:42:31Z"
|
||
|
|
},
|
||
|
|
"tokens": {
|
||
|
|
"access_token": "jwt-or-opaque",
|
||
|
|
"access_token_expires_at": "2026-04-16T12:42:31Z",
|
||
|
|
"refresh_token": "opaque-refresh-token",
|
||
|
|
"refresh_token_expires_at": "2026-05-16T11:42:31Z"
|
||
|
|
},
|
||
|
|
"ws": {
|
||
|
|
"url": "wss://ws.example.com/v1/ws"
|
||
|
|
},
|
||
|
|
"conversations": {
|
||
|
|
"items": [
|
||
|
|
{
|
||
|
|
"conversation_id": "01HSX90M5W7Y0S8DDE6RP9N2PD",
|
||
|
|
"type": "self",
|
||
|
|
"title": "나에게 메시지",
|
||
|
|
"avatar_url": null,
|
||
|
|
"subtitle": "메모와 파일을 나에게 보관해 보세요.",
|
||
|
|
"member_count": 1,
|
||
|
|
"is_muted": false,
|
||
|
|
"is_pinned": true,
|
||
|
|
"sort_key": "2026-04-16T11:42:31Z",
|
||
|
|
"unread_count": 0,
|
||
|
|
"last_read_message_id": null,
|
||
|
|
"last_message": null
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"next_cursor": null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
계약:
|
||
|
|
- 가입 성공 시 바로 메인 진입 가능한 데이터를 한 번에 반환한다.
|
||
|
|
- 가입 직후 빈 화면을 만들지 않는다.
|
||
|
|
|
||
|
|
### 2. 세션 갱신
|
||
|
|
|
||
|
|
`POST /v1/auth/token/refresh`
|
||
|
|
|
||
|
|
request:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"refresh_token": "opaque-refresh-token"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
response:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"data": {
|
||
|
|
"tokens": {
|
||
|
|
"access_token": "new-access-token",
|
||
|
|
"access_token_expires_at": "2026-04-16T13:42:31Z",
|
||
|
|
"refresh_token": "new-refresh-token",
|
||
|
|
"refresh_token_expires_at": "2026-05-16T11:42:31Z"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
계약:
|
||
|
|
- 성공 시 refresh token도 반드시 회전한다.
|
||
|
|
- 실패 코드는 최소 아래 둘로 제한한다.
|
||
|
|
- `session_expired`
|
||
|
|
- `session_revoked`
|
||
|
|
|
||
|
|
### 3. 부트스트랩
|
||
|
|
|
||
|
|
`GET /v1/bootstrap`
|
||
|
|
|
||
|
|
header:
|
||
|
|
|
||
|
|
```text
|
||
|
|
Authorization: Bearer <access_token>
|
||
|
|
```
|
||
|
|
|
||
|
|
response:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"data": {
|
||
|
|
"me": {
|
||
|
|
"user_id": "01HSX8P2QH5Q1REJYQY3QNZ4N6",
|
||
|
|
"display_name": "이안",
|
||
|
|
"profile_image_url": null,
|
||
|
|
"status_message": null
|
||
|
|
},
|
||
|
|
"session": {
|
||
|
|
"session_id": "01HSX8T8J7CN1J8TJQ3E6V6KPA",
|
||
|
|
"device_id": "01HSX8SZPQZQ4YVE1N9WR6W4KJ",
|
||
|
|
"device_name": "Windows PC",
|
||
|
|
"created_at": "2026-04-16T11:42:31Z"
|
||
|
|
},
|
||
|
|
"ws": {
|
||
|
|
"url": "wss://ws.example.com/v1/ws"
|
||
|
|
},
|
||
|
|
"conversations": {
|
||
|
|
"items": [],
|
||
|
|
"next_cursor": null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
계약:
|
||
|
|
- 앱 시작 시 첫 요청은 항상 이것이다.
|
||
|
|
- 별도 `GET /v1/me` 호출은 하지 않는다.
|
||
|
|
|
||
|
|
### 4. 대화 목록
|
||
|
|
|
||
|
|
`GET /v1/conversations?cursor=<opaque>&limit=30`
|
||
|
|
|
||
|
|
response:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"data": {
|
||
|
|
"items": [
|
||
|
|
{
|
||
|
|
"conversation_id": "01HSX90M5W7Y0S8DDE6RP9N2PD",
|
||
|
|
"type": "dm",
|
||
|
|
"title": "김민지",
|
||
|
|
"avatar_url": "https://files.example.com/avatar/1.jpg",
|
||
|
|
"subtitle": "오후 3시에 볼까요?",
|
||
|
|
"member_count": 2,
|
||
|
|
"is_muted": false,
|
||
|
|
"is_pinned": false,
|
||
|
|
"sort_key": "2026-04-16T12:04:10Z",
|
||
|
|
"unread_count": 2,
|
||
|
|
"last_read_message_id": "01HSX95G8Z3VCP5BNQY53Q8HMT",
|
||
|
|
"last_message": {
|
||
|
|
"message_id": "01HSX97W2NMMN4HMC42NZSE2HC",
|
||
|
|
"text": "오후 3시에 볼까요?",
|
||
|
|
"created_at": "2026-04-16T12:04:10Z",
|
||
|
|
"sender_user_id": "01HSX8V38VG70E2D7XXMX31YHS"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"next_cursor": null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
계약:
|
||
|
|
- 서버가 최신 활동순으로 정렬해서 내려준다.
|
||
|
|
- DM 타이틀 조합, 안읽음 계산, 최근 메시지 미리보기 생성은 서버 책임이다.
|
||
|
|
|
||
|
|
### 5. 대화 메시지 목록
|
||
|
|
|
||
|
|
`GET /v1/conversations/{conversation_id}/messages?before=<message_id>&limit=50`
|
||
|
|
|
||
|
|
설명:
|
||
|
|
- `before`가 없으면 최신 50개를 준다.
|
||
|
|
- `before`가 있으면 해당 메시지보다 오래된 메시지를 준다.
|
||
|
|
- 응답 `items`는 항상 오래된 순 -> 최신 순으로 정렬한다.
|
||
|
|
|
||
|
|
response:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"data": {
|
||
|
|
"conversation": {
|
||
|
|
"conversation_id": "01HSX90M5W7Y0S8DDE6RP9N2PD",
|
||
|
|
"type": "dm",
|
||
|
|
"title": "김민지",
|
||
|
|
"avatar_url": "https://files.example.com/avatar/1.jpg",
|
||
|
|
"subtitle": "오후 3시에 볼까요?",
|
||
|
|
"member_count": 2,
|
||
|
|
"is_muted": false,
|
||
|
|
"is_pinned": false,
|
||
|
|
"sort_key": "2026-04-16T12:04:10Z",
|
||
|
|
"unread_count": 2,
|
||
|
|
"last_read_message_id": "01HSX95G8Z3VCP5BNQY53Q8HMT",
|
||
|
|
"last_message": {
|
||
|
|
"message_id": "01HSX97W2NMMN4HMC42NZSE2HC",
|
||
|
|
"text": "오후 3시에 볼까요?",
|
||
|
|
"created_at": "2026-04-16T12:04:10Z",
|
||
|
|
"sender_user_id": "01HSX8V38VG70E2D7XXMX31YHS"
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"items": [
|
||
|
|
{
|
||
|
|
"message_id": "01HSX94N3RRC88GWZZJ4VGMMQX",
|
||
|
|
"conversation_id": "01HSX90M5W7Y0S8DDE6RP9N2PD",
|
||
|
|
"client_message_id": null,
|
||
|
|
"kind": "text",
|
||
|
|
"text": "좋아요",
|
||
|
|
"created_at": "2026-04-16T11:55:00Z",
|
||
|
|
"edited_at": null,
|
||
|
|
"sender": {
|
||
|
|
"user_id": "01HSX8V38VG70E2D7XXMX31YHS",
|
||
|
|
"display_name": "김민지",
|
||
|
|
"profile_image_url": "https://files.example.com/avatar/1.jpg"
|
||
|
|
},
|
||
|
|
"is_mine": false
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"next_cursor": "01HSX94N3RRC88GWZZJ4VGMMQX"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
계약:
|
||
|
|
- 클라이언트는 응답 순서를 뒤집지 않고 그대로 붙인다.
|
||
|
|
- `conversation`을 같이 내려 대화창 헤더를 별도 API 없이 바로 렌더링하게 한다.
|
||
|
|
|
||
|
|
### 6. 텍스트 메시지 전송
|
||
|
|
|
||
|
|
`POST /v1/conversations/{conversation_id}/messages/text`
|
||
|
|
|
||
|
|
request:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"client_message_id": "d5bf6a88-b6b0-4f1c-b11d-d2d8a9aaf3b8",
|
||
|
|
"text": "안녕하세요"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
response:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"data": {
|
||
|
|
"message": {
|
||
|
|
"message_id": "01HSX92D44DWFA0KTV6B0T0SE5",
|
||
|
|
"conversation_id": "01HSX90M5W7Y0S8DDE6RP9N2PD",
|
||
|
|
"client_message_id": "d5bf6a88-b6b0-4f1c-b11d-d2d8a9aaf3b8",
|
||
|
|
"kind": "text",
|
||
|
|
"text": "안녕하세요",
|
||
|
|
"created_at": "2026-04-16T11:45:02Z",
|
||
|
|
"edited_at": null,
|
||
|
|
"sender": {
|
||
|
|
"user_id": "01HSX8P2QH5Q1REJYQY3QNZ4N6",
|
||
|
|
"display_name": "이안",
|
||
|
|
"profile_image_url": null
|
||
|
|
},
|
||
|
|
"is_mine": true
|
||
|
|
},
|
||
|
|
"conversation": {
|
||
|
|
"conversation_id": "01HSX90M5W7Y0S8DDE6RP9N2PD",
|
||
|
|
"type": "self",
|
||
|
|
"title": "나에게 메시지",
|
||
|
|
"avatar_url": null,
|
||
|
|
"subtitle": "메모와 파일을 나에게 보관해 보세요.",
|
||
|
|
"member_count": 1,
|
||
|
|
"is_muted": false,
|
||
|
|
"is_pinned": true,
|
||
|
|
"sort_key": "2026-04-16T11:45:02Z",
|
||
|
|
"unread_count": 0,
|
||
|
|
"last_read_message_id": "01HSX92D44DWFA0KTV6B0T0SE5",
|
||
|
|
"last_message": {
|
||
|
|
"message_id": "01HSX92D44DWFA0KTV6B0T0SE5",
|
||
|
|
"text": "안녕하세요",
|
||
|
|
"created_at": "2026-04-16T11:45:02Z",
|
||
|
|
"sender_user_id": "01HSX8P2QH5Q1REJYQY3QNZ4N6"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
계약:
|
||
|
|
- `client_message_id`는 멱등 키다.
|
||
|
|
- 같은 세션에서 같은 `client_message_id`를 다시 보내면 서버는 같은 `message`를 재반환한다.
|
||
|
|
- 응답의 `conversation`은 대화 목록 upsert에 그대로 쓴다.
|
||
|
|
|
||
|
|
## WebSocket Contract
|
||
|
|
|
||
|
|
### 연결
|
||
|
|
|
||
|
|
URL:
|
||
|
|
|
||
|
|
```text
|
||
|
|
wss://ws.example.com/v1/ws
|
||
|
|
```
|
||
|
|
|
||
|
|
header:
|
||
|
|
|
||
|
|
```text
|
||
|
|
Authorization: Bearer <access_token>
|
||
|
|
```
|
||
|
|
|
||
|
|
연결 정책:
|
||
|
|
- 인증 성공 시 연결 유지
|
||
|
|
- 토큰 만료 또는 세션 철회 시 서버가 `session.invalidated` 이벤트 후 연결 종료
|
||
|
|
- 클라이언트는 access token refresh 후 재연결한다
|
||
|
|
|
||
|
|
### 서버 -> 클라이언트 이벤트
|
||
|
|
|
||
|
|
#### 1. `conversation.upsert`
|
||
|
|
|
||
|
|
언제:
|
||
|
|
- 새 대화 생성
|
||
|
|
- 새 메시지로 목록 순서 변경
|
||
|
|
- mute/pin/title 변경
|
||
|
|
- unread_count 변경
|
||
|
|
|
||
|
|
payload:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"event": "conversation.upsert",
|
||
|
|
"event_id": "01HSX9DZ7X7P8E7D7NR0Q6SC2N",
|
||
|
|
"occurred_at": "2026-04-16T12:04:10Z",
|
||
|
|
"data": {
|
||
|
|
"conversation": {
|
||
|
|
"conversation_id": "01HSX90M5W7Y0S8DDE6RP9N2PD",
|
||
|
|
"type": "dm",
|
||
|
|
"title": "김민지",
|
||
|
|
"avatar_url": "https://files.example.com/avatar/1.jpg",
|
||
|
|
"subtitle": "오후 3시에 볼까요?",
|
||
|
|
"member_count": 2,
|
||
|
|
"is_muted": false,
|
||
|
|
"is_pinned": false,
|
||
|
|
"sort_key": "2026-04-16T12:04:10Z",
|
||
|
|
"unread_count": 2,
|
||
|
|
"last_read_message_id": "01HSX95G8Z3VCP5BNQY53Q8HMT",
|
||
|
|
"last_message": {
|
||
|
|
"message_id": "01HSX97W2NMMN4HMC42NZSE2HC",
|
||
|
|
"text": "오후 3시에 볼까요?",
|
||
|
|
"created_at": "2026-04-16T12:04:10Z",
|
||
|
|
"sender_user_id": "01HSX8V38VG70E2D7XXMX31YHS"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 2. `message.created`
|
||
|
|
|
||
|
|
언제:
|
||
|
|
- 현재 세션이 열어 둔 대화에 새 메시지가 생김
|
||
|
|
- 같은 사용자의 다른 기기에서 새 메시지가 생김
|
||
|
|
|
||
|
|
payload:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"event": "message.created",
|
||
|
|
"event_id": "01HSX9FYF5Y1WYP8A7A65M23RY",
|
||
|
|
"occurred_at": "2026-04-16T12:04:10Z",
|
||
|
|
"data": {
|
||
|
|
"message": {
|
||
|
|
"message_id": "01HSX97W2NMMN4HMC42NZSE2HC",
|
||
|
|
"conversation_id": "01HSX90M5W7Y0S8DDE6RP9N2PD",
|
||
|
|
"client_message_id": null,
|
||
|
|
"kind": "text",
|
||
|
|
"text": "오후 3시에 볼까요?",
|
||
|
|
"created_at": "2026-04-16T12:04:10Z",
|
||
|
|
"edited_at": null,
|
||
|
|
"sender": {
|
||
|
|
"user_id": "01HSX8V38VG70E2D7XXMX31YHS",
|
||
|
|
"display_name": "김민지",
|
||
|
|
"profile_image_url": "https://files.example.com/avatar/1.jpg"
|
||
|
|
},
|
||
|
|
"is_mine": false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
정책:
|
||
|
|
- REST 전송을 호출한 동일 세션에는 이 이벤트를 생략할 수 있다.
|
||
|
|
- 다른 기기 세션에는 전송한다.
|
||
|
|
|
||
|
|
#### 3. `conversation.read_updated`
|
||
|
|
|
||
|
|
언제:
|
||
|
|
- 현재 사용자의 읽음 기준 메시지가 바뀜
|
||
|
|
|
||
|
|
payload:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"event": "conversation.read_updated",
|
||
|
|
"event_id": "01HSX9H9B9ZPVJKK1X8R74SFCY",
|
||
|
|
"occurred_at": "2026-04-16T12:05:11Z",
|
||
|
|
"data": {
|
||
|
|
"conversation_id": "01HSX90M5W7Y0S8DDE6RP9N2PD",
|
||
|
|
"last_read_message_id": "01HSX97W2NMMN4HMC42NZSE2HC",
|
||
|
|
"unread_count": 0
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 4. `session.invalidated`
|
||
|
|
|
||
|
|
언제:
|
||
|
|
- refresh token family 철회
|
||
|
|
- 원격 로그아웃
|
||
|
|
- 서버 정책상 세션 무효
|
||
|
|
|
||
|
|
payload:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"event": "session.invalidated",
|
||
|
|
"event_id": "01HSX9K6Y3MJ5D6M8Q10M3ZTTE",
|
||
|
|
"occurred_at": "2026-04-16T12:10:00Z",
|
||
|
|
"data": {
|
||
|
|
"reason": "session_revoked"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 5. `sync.required`
|
||
|
|
|
||
|
|
언제:
|
||
|
|
- 이벤트 누락 가능성이 생김
|
||
|
|
- 서버 재배포 후 in-memory 라우팅이 끊김
|
||
|
|
- 클라이언트 재연결 후 증분 상태 신뢰가 깨짐
|
||
|
|
|
||
|
|
payload:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"event": "sync.required",
|
||
|
|
"event_id": "01HSX9MRFFW66R9HKYE7QH1PXN",
|
||
|
|
"occurred_at": "2026-04-16T12:11:30Z",
|
||
|
|
"data": {
|
||
|
|
"scope": "bootstrap"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
정책:
|
||
|
|
- `v0.1`에서는 부분 복구 대신 `GET /v1/bootstrap` 재호출로 단순화한다.
|
||
|
|
|
||
|
|
## Client Flow
|
||
|
|
|
||
|
|
### 첫 가입
|
||
|
|
|
||
|
|
1. `POST /v1/auth/register/alpha-quick`
|
||
|
|
2. 응답의 `tokens` 저장
|
||
|
|
3. 응답의 `conversations.items`로 좌측 목록 즉시 렌더링
|
||
|
|
4. WebSocket 연결
|
||
|
|
|
||
|
|
### 앱 재실행
|
||
|
|
|
||
|
|
1. 저장된 refresh token으로 `POST /v1/auth/token/refresh`
|
||
|
|
2. 성공 시 새 토큰 저장
|
||
|
|
3. `GET /v1/bootstrap`
|
||
|
|
4. WebSocket 연결
|
||
|
|
|
||
|
|
### 대화방 진입
|
||
|
|
|
||
|
|
1. 목록에서 `conversation_id` 선택
|
||
|
|
2. `GET /v1/conversations/{id}/messages`
|
||
|
|
3. 메시지 렌더링
|
||
|
|
4. WebSocket의 `message.created`, `conversation.upsert` 반영
|
||
|
|
|
||
|
|
### 메시지 전송
|
||
|
|
|
||
|
|
1. 로컬에서 `client_message_id` 생성
|
||
|
|
2. 임시 pending UI 추가
|
||
|
|
3. `POST /v1/conversations/{id}/messages/text`
|
||
|
|
4. 성공 시 서버 응답 `message`로 pending 교체
|
||
|
|
5. 응답 `conversation`으로 목록 upsert
|
||
|
|
|
||
|
|
## 구현 메모
|
||
|
|
|
||
|
|
`v0.1`에서 의도적으로 제외:
|
||
|
|
- 메시지 수정/삭제
|
||
|
|
- 첨부파일
|
||
|
|
- typing
|
||
|
|
- presence
|
||
|
|
- 반응
|
||
|
|
- 검색
|
||
|
|
- 멀티 페이지 bootstrap
|
||
|
|
- WebSocket client-to-server command
|
||
|
|
|
||
|
|
이유:
|
||
|
|
- 지금 필요한 것은 `가입 -> 자동 로그인 -> 목록 -> 대화 -> 텍스트 전송 -> 실시간 반영`의 완결된 1차 루프다.
|
||
|
|
- 나머지는 이후 추가해도 contract 파손이 적다.
|