kotalk/문서/13-v0.1-api-and-events-contract.md
2026-04-16 09:24:26 +09:00

17 KiB

13. v0.1 API And Events Contract

목적

이 문서는 v0.1 구현 범위에 맞는 최소 계약만 따로 고정한다.

대상 범위:

  • Alpha 가입
  • 세션 갱신
  • 부트스트랩
  • 대화 목록
  • 대화 메시지 목록
  • 텍스트 메시지 전송
  • WebSocket 실시간 이벤트

핵심 원칙:

  • 서버가 최대한 계산하고, 클라이언트는 최대한 렌더링만 한다.
  • REST와 WebSocket에서 같은 엔티티 모양을 재사용한다.
  • 파생값은 클라이언트에서 계산하지 않는다.
  • v0.1에서는 전송도 REST로 처리하고, WebSocket은 서버 푸시 전용으로 둔다.

고정 결정

1. GET /v1/bootstrapGET /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

성공:

{
  "data": {}
}

목록:

{
  "data": {
    "items": [],
    "next_cursor": null
  }
}

오류:

{
  "error": {
    "code": "invite_invalid",
    "message": "초대코드가 유효하지 않습니다.",
    "retryable": false,
    "field_errors": {
      "invite_code": "초대코드를 다시 확인해 주세요."
    }
  }
}

WebSocket event envelope

{
  "event": "message.created",
  "event_id": "01HSX8X4N1Y6C4R1VX9H1F4K98",
  "occurred_at": "2026-04-16T11:42:31Z",
  "data": {}
}

Canonical Shapes

AuthTokens

{
  "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

{
  "session_id": "01HSX8T8J7CN1J8TJQ3E6V6KPA",
  "device_id": "01HSX8SZPQZQ4YVE1N9WR6W4KJ",
  "device_name": "Windows PC",
  "created_at": "2026-04-16T11:42:31Z"
}

Me

{
  "user_id": "01HSX8P2QH5Q1REJYQY3QNZ4N6",
  "display_name": "이안",
  "profile_image_url": null,
  "status_message": null
}

ConversationSummary

{
  "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가 있을 때:

{
  "message_id": "01HSX92D44DWFA0KTV6B0T0SE5",
  "text": "안녕하세요",
  "created_at": "2026-04-16T11:45:02Z",
  "sender_user_id": "01HSX8P2QH5Q1REJYQY3QNZ4N6"
}

MessageItem

{
  "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

{
  "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:

{
  "display_name": "이안",
  "invite_code": "ALPHA-SEOUL-1234",
  "device_name": "Windows PC"
}

response:

{
  "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:

{
  "refresh_token": "opaque-refresh-token"
}

response:

{
  "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:

Authorization: Bearer <access_token>

response:

{
  "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:

{
  "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:

{
  "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:

{
  "client_message_id": "d5bf6a88-b6b0-4f1c-b11d-d2d8a9aaf3b8",
  "text": "안녕하세요"
}

response:

{
  "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:

wss://ws.example.com/v1/ws

header:

Authorization: Bearer <access_token>

연결 정책:

  • 인증 성공 시 연결 유지
  • 토큰 만료 또는 세션 철회 시 서버가 session.invalidated 이벤트 후 연결 종료
  • 클라이언트는 access token refresh 후 재연결한다

서버 -> 클라이언트 이벤트

1. conversation.upsert

언제:

  • 새 대화 생성
  • 새 메시지로 목록 순서 변경
  • mute/pin/title 변경
  • unread_count 변경

payload:

{
  "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:

{
  "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:

{
  "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:

{
  "event": "session.invalidated",
  "event_id": "01HSX9K6Y3MJ5D6M8Q10M3ZTTE",
  "occurred_at": "2026-04-16T12:10:00Z",
  "data": {
    "reason": "session_revoked"
  }
}

5. sync.required

언제:

  • 이벤트 누락 가능성이 생김
  • 서버 재배포 후 in-memory 라우팅이 끊김
  • 클라이언트 재연결 후 증분 상태 신뢰가 깨짐

payload:

{
  "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 파손이 적다.