Skip to content

Frontend Chat Guide

howudong edited this page Sep 20, 2026 · 6 revisions

채팅 API 연동 가이드 (프론트)

상태: 머지 완료 (#103 REST · #105 실시간 · #119 그룹 방 자동 생성 · #128 종료 생명주기 · #133 재매칭 채팅). 읽음 표시(#182, PR #183)는 머지 대기. 아래 unreadCount·READ 이벤트 절은 그 PR 기준이다. 채팅이 끝난 뒤의 평가·재매칭은 Frontend-Review-Rematch-Guide를 볼 것.

공통

  • Base URL: https://api.ditto.pics
  • 인증 헤더 (모든 /api/**): X-API-Key: <키> + Authorization: Bearer <accessToken>
  • 응답 래핑: HTTP는 항상 200. 성공/실패는 success로 구분.
    { "success": true, "data": { }, "error": null }
    { "success": false, "data": null, "error": { "statusCode": 403, "code": "7002", "message": "..." } }
  • 모든 시각은 "2026-07-25 12:00:00" 형식이다. ISO의 T 구분자도 타임존 오프셋도 없으니 KST 기준으로 직접 파싱할 것.

방은 언제 생기고 언제 열리는가

생성과 개방은 다른 시점이다. 방이 목록에 보인다고 대화할 수 있는 것이 아니다.

방 생성 경로는 셋이다.

  • 1:1 매칭 수락(ACCEPT) 즉시 — 서버가 자동 생성한다. 클라가 방을 만들지 않는다.
  • 그룹 매칭 정원 도달 즉시 — 참가자 전원의 방이 자동 생성된다.
  • 재매칭 성사 후 — 스케줄러가 1분 주기로 예약한다.

대화 가능 구간은 금요일 00:00 ~ 월요일 00:00, 72시간이다. 주중에 매칭을 수락하면 방은 며칠간 개방 전 상태로 존재하다가 금요일에 열린다.

  • 1:1·그룹 방은 생성 시각이 속한 주의 금요일에 열린다. 토요일에 수락하면 이미 열린 주말에 합류하므로 남은 시간이 72시간보다 짧다.
  • 재매칭 방은 성사 이후 처음 오는 금요일에 열린다. 진행 중인 주말에는 합류시키지 않는다.
  • 개방·만료는 1분 주기 스케줄러가 처리한다. opensAt이 막 지난 직후 최대 1분간은 서버가 아직 개방 전으로 판정할 수 있다.

REST — 조회

1) 내 채팅방 목록

GET /api/v1/chat/rooms
{ "success": true, "data": [
  {
    "roomId": 1,
    "sourceType": "PERSONAL",              // 원본 유형 (PERSONAL, GROUP, REMATCH)
    "counterpartMemberIds": [2],           // 나를 제외한 참여 회원 (1:1·재매칭이면 1명, 그룹이면 여러 명)
    "lastMessage": {                       // 없으면 null
      "id": 30, "roomId": 1, "senderId": 2,
      "messageType": "TEXT", "content": "안녕하세요",
      "imageUrl": null,                    // IMAGE면 열람용 URL, 아니면 null
      "createdAt": "2026-07-25 12:00:00",
      "unreadCount": 0                     // 이 메시지를 아직 안 읽은 사람 수 (메시지 객체 참고)
    },
    "unreadCount": 2,                      // 방 단위. 내가 안 읽은 메시지 수 (배지용)
    "createdAt": "2026-07-25 11:00:00",
    "opensAt": "2026-07-24 00:00:00",      // 개방 시각 (금요일 00:00)
    "expiresAt": "2026-07-27 00:00:00",    // 자동 종료 예정 시각. 남은 시간 카운트다운의 기준
    "isEnded": false,
    "endedAt": null,                       // 종료된 방에만 값
    "endedReason": null                    // 종료된 방에만 값. EXPIRED | USER_ENDED
  }
], "error": null }
  • 최근 대화순 정렬. 페이징이 없어 참여한 방 전부가 한 번에 온다.
  • 개방 전 방과 종료된 방도 목록에서 빠지지 않는다. 숨기거나 분리하는 것은 FE 판단이다.
  • 방 상태 필드(status)는 내려가지 않는다. isEnded가 true면 종료, 아니면 opensAt과 현재 시각을 비교해 개방 전/진행 중을 FE가 파생해야 한다.
  • 방 상세 조회 API는 없다. 이 목록이 방 메타의 유일한 출처다.
  • unreadCount에는 종료 안내(SYSTEM) 메시지도 포함된다. 상대가 종료하고 끝난 방이 배지 1을 달고 남으니, 배지에서 뺄지는 FE가 정한다.
  • counterpartMemberIds는 ID 배열뿐이다. 닉네임·프로필은 GET /api/v1/users/{id}/profile로 따로 조회해 붙인다. 서로 차단한 사이면 방은 남아 있는데 프로필만 0003으로 실패하니, 방 자체의 오류로 취급하지 말고 이름 자리만 대체 표시할 것.

2) 과거 메시지 (커서 페이징 · 위로 스크롤)

GET /api/v1/chat/rooms/{roomId}/messages?cursor={messageId}&size=30
{ "success": true, "data": {
  "messages": [ /* id DESC(최신 먼저), 각 항목은 아래 메시지 객체 */ ],
  "nextCursor": 12                         // 더 없으면 null
}, "error": null }
  • 최초 조회: cursor 생략. 위로 스크롤: 직전 응답의 nextCursor를 cursor로 전달.
  • size 기본 30, 최대 100. 응답은 최신 먼저 — 화면에는 역순으로 쌓을 것.
  • nextCursor는 페이지가 가득 찼을 때만 채워진다. 마지막 페이지가 정확히 size로 떨어지면 커서가 있는데도 다음 조회가 빈 배열이니, 빈 배열도 끝으로 처리할 것.
  • 개방 전·종료된 방에서도 조회는 허용된다.

메시지 객체

{ "id": 30, "roomId": 1, "senderId": 2, "messageType": "TEXT|IMAGE|SYSTEM",
  "content": "본문 / IMAGE의 S3 key / SYSTEM의 사건 코드", "imageUrl": "IMAGE 열람 URL 또는 null",
  "createdAt": "2026-07-25 12:00:00",
  "unreadCount": 1 }
  • IMAGE 메시지는 content에 S3 key가, imageUrl에 열람용 presigned URL이 채워져 온다. 표시는 imageUrl을 쓴다.
  • SYSTEM 메시지는 아래 "종료 안내 메시지"를 참고.
  • unreadCount — 이 메시지를 아직 안 읽은, 발신자를 뺀 현재 참여자 수. 카카오톡의 1이다. 1:1이면 0 또는 1, 그룹이면 0~(정원-1).
    • 방을 나간 사람은 세지 않는다. 안 빼면 나간 사람 때문에 숫자가 영영 줄지 않는다.
    • SYSTEM 메시지는 항상 0.
    • 항상 내려간다(옵셔널 아님). 내가 받은 메시지에도 값이 있지만 화면에 쓰는 건 내가 보낸 메시지 쪽이다.
    • 조회 시점의 스냅샷이다. 그 뒤 상대가 읽으면 아래 READ 이벤트로 줄인다.

3) 읽음 처리

POST /api/v1/chat/rooms/{roomId}/read
{ "lastReadMessageId": 30 }
  • 단조 증가다. 현재 커서보다 작은 값을 보내면 조용히 무시하고 성공으로 답한다.
  • 개방 전·종료된 방에서도 허용된다.
  • lastReadMessageId는 그 방의 메시지 id여야 한다. 다른 방 id나 없는 id를 보내면 0001(BAD_REQUEST)로 거부한다. 메시지 id는 방 구분 없는 전역 값이라 방 목록 화면에 들고 있던 다른 방의 id를 잘못 보내면 커서가 안 본 메시지를 건너뛰어 전진하고 되돌릴 수 없어서다.
  • 커서가 실제로 전진했으면 방 토픽에 READ 이벤트가 나간다(아래 실시간 절). 같은 값 재시도·뒤로 가는 값·방을 나간 사람의 읽음은 이벤트가 없다.
  • 권고: 메시지가 올 때마다 즉시 던지지 말고 잠깐 모아(수백 ms) 마지막 id 하나만 보낼 것. 요청 수가 줄고, 겹친 요청이 서로 기다리는 일도 줄어든다.

4) 이미지 업로드 URL 발급

POST /api/v1/chat/rooms/{roomId}/image-upload-urls
{ "files": [ { "contentType": "image/jpeg", "contentLength": 20480 } ] }
{ "success": true, "data": {
  "uploads": [ { "objectKey": "chat/2/uuid", "uploadUrl": "https://...presigned-put..." } ]
}, "error": null }
  • 열려 있는 방의 멤버만 발급받을 수 있다. 개방 전이면 7005, 종료 후면 7004.
  • image/*만 허용, 최대 10MB, 한 번에 최대 10장.
  • 채팅에서 이 코드들을 응답으로 실제로 받아볼 수 있는 경로는 여기뿐이다. STOMP 전송 실패는 코드를 돌려주지 않는다(아래 실시간 절 참고).

REST — 종료

채팅 종료

POST /api/v1/chat/rooms/{roomId}/end
  • 요청 바디가 없고, 응답 data도 비어 있다({}).
  • 멱등이다. 더블 탭이나 재시도로 여러 번 불러도 종료 안내 메시지는 1건만 남고, 최초 종료 시각·사유가 덮이지 않는다. 이미 끝난 방에 다시 불러도 성공으로 답한다.
  • 그룹 방은 이 경로로 끝낼 수 없다. 7002가 오는데 코드가 멤버십 오류와 같아서 message("그룹 채팅은 이 경로로 종료할 수 없습니다.")로만 구분된다. 그룹은 기한 만료로만 끝난다.
  • 아직 열리지 않은 방은 종료할 수 있다(7005로 막히지 않는다).
  • 응답이 비어 있으므로 종료를 누른 본인 화면은 서버 응답만으로 아무것도 알 수 없다. 낙관적으로 전환하거나 목록·메시지를 다시 조회할 것.

종료 안내 메시지 (SYSTEM)

사용자 종료가 실제로 방을 끝냈을 때만 SYSTEM 메시지가 발행된다. 기한 만료 마감에는 발행되지 않는다 — 클라이언트가 expiresAt 카운트다운으로 이미 알기 때문이다.

{ "id": 31, "roomId": 1, "senderId": 2,
  "messageType": "SYSTEM", "content": "USER_LEFT", "imageUrl": null,
  "createdAt": "2026-07-26 15:00:00" }
  • content에는 완성된 문장이 아니라 사건 코드가 온다. 같은 사건도 보는 사람에 따라 문구가 달라지기 때문이다.
  • senderId가 종료를 누른 회원이다. 내 ID와 비교해 "채팅을 종료했습니다"(본인)와 "상대방이 채팅을 종료했습니다"(상대)를 FE가 만든다.
  • USER_LEFT가 현재 유일한 코드다. 값은 추가될 수 있으니 모르는 코드는 무시하도록 방어할 것.
  • 종료 시점에 이미 구독 중이던 상대는 실시간으로 받는다. 접속해 있지 않았다면 재구독이 막히므로 GET .../messages로만 확인할 수 있다.

종료된 방 숨기기는 아직 없다

끝난 방을 내 목록에서만 치우는 API(DELETE /api/v1/chat/rooms/{roomId})는 요청받았고 이슈 #201로 열려 있다. 아직 착수 전이라 당분간은 로컬 숨김(ditto.hiddenChatRoomIds)을 유지해야 한다. 내가 나간 방은 이미 목록·조회에서 빠진다(#196).

상태별로 무엇이 막히는가

동작 개방 전 진행 중 종료 후
메시지 전송 (STOMP) 무응답으로 버려짐 허용 무응답으로 버려짐
STOMP 구독 ERROR 프레임 + 연결 종료 허용 ERROR 프레임 + 연결 종료
이미지 업로드 URL 발급 7005 허용 7004
메시지 조회 허용 허용 허용
읽음 처리 허용 허용 허용
방 목록 노출 나옴 나옴 나옴
채팅 종료 허용 허용 성공(변화 없음)

서버는 개방 전을 7005, 종료 후를 7004로 나눠 판정한다. 7005는 "금요일에 열려요", 7004는 "이미 끝났어요"라는 화면 분기를 위해서다. 다만 이 코드를 응답으로 받아볼 수 있는 곳은 이미지 업로드 URL 발급 하나뿐이다 — STOMP 경로는 코드를 돌려주지 않으니 방 상태는 목록 조회로 판단할 것.


이미지 전송 흐름 (3단계)

1) [REST] 업로드 URL 발급  → objectKey, uploadUrl 수신
2) [클라→S3] uploadUrl 로 이미지 바이트 직접 PUT (요청한 contentType/Length 그대로)
3) [STOMP] 메시지 전송: { "content": objectKey, "messageType": "IMAGE" }
  • 이미지 바이트는 서버/WebSocket을 통과하지 않는다(클라↔S3 직접). STOMP엔 작은 IMAGE 메시지만.
  • 2단계 PUT 응답을 확인한 뒤 3단계를 보낼 것. 서버가 전송 시점에 S3 업로드 여부를 확인하므로, 아직이면 내가 발급받은 key여도 거부한다(7003). 게다가 전송 실패는 아무 응답도 오지 않아 원인을 알 수 없다.

실시간 — STOMP over WebSocket

  • 엔드포인트: wss://api.ditto.pics/ws (STOMP, SockJS 아님)
  • 인증은 STOMP CONNECT 프레임 헤더로 (핸드셰이크엔 커스텀 헤더 불가):
    • X-API-Key: <키>, Authorization: Bearer <accessToken>
  • 구독: SUBSCRIBE /sub/chat/rooms/{roomId} — 본인이 속한, 이미 열렸고 아직 끝나지 않은 방만
  • 전송: SEND /pub/chat/rooms/{roomId} body { "content": "...", "messageType": "TEXT" }
    • ⚠️ 전송은 반드시 /pub 목적지로. /sub로 직접 SEND하면 서버가 거부한다(위조 주입 차단).
    • TEXT: content=본문(공백 불가·최대 1000자) / IMAGE: content=업로드한 objectKey
  • 수신 payload는 두 종류다. type 필드가 있으면 READ 이벤트, 없으면 메시지 객체다.
    • 메시지 프레임 = 위 메시지 객체와 동일(IMAGE면 imageUrl 포함, 종료 안내면 SYSTEM, unreadCount 포함)
    • READ 이벤트 = 아래 절
  • heartbeat 10s/10s 자동.

READ 이벤트 — 상대가 읽었을 때

누군가 POST /read로 커서를 전진시키면 방 토픽에 이 프레임이 온다.

{ "type": "READ", "roomId": 3, "memberId": 7, "previousLastReadMessageId": 40, "lastReadMessageId": 42 }
  • memberId가 읽은 사람, lastReadMessageId까지 읽었다는 뜻이다. previousLastReadMessageId는 그 사람의 직전 커서(처음 읽음이면 null).
  • previous < id <= last 구간의 내 메시지만 unreadCount를 1 줄인다. 구간 없이 id <= last 전체를 줄이면 틀린다. 40까지 읽은 사람이 42까지 읽으면 READ(40)·READ(42)가 차례로 오는데, 두 번째에서 1~40을 또 빼게 된다. previous가 null이면 id <= last 전체가 구간이다.
  • 같은 사람에게서 여러 번 온다. 읽음은 한 번 일어나는 사건이 아니라 커서가 계속 앞으로 가는 상태라서, 새 메시지를 읽을 때마다 다시 온다.
  • 저장되지 않는다. 재접속 리줌 때는 이벤트를 따라잡지 않고, 메시지를 다시 조회하면 unreadCount가 최신 값으로 온다.
  • memberId가 나 자신이면(다른 기기에서 읽은 경우) 무시해도 된다. 내 메시지의 카운트에는 내가 들어가지 않는다.
  • ⚠️ 배포 순서. 현재 FE(chatSocket.ts)는 모든 프레임을 메시지로 파싱한다. type 분기가 배포되기 전에 이 이벤트가 나가면 READ 프레임이 메시지로 그려진다. BE PR #183 머지 시점을 맞출 것.

⚠️ 실패가 두 갈래로 갈린다

STOMP 실패는 ApiResponse JSON으로 오지 않는다. 그런데 모든 실패가 같은 방식으로 드러나지도 않는다.

ERROR 프레임 + 연결 종료 — 인증 실패, 구독 거부(개방 전·종료·비참여), /sub로 직접 SEND. 세 경우다. 프레임에 서버 에러 코드가 실리지 않아 7004와 7005를 구분할 수 없다.

아무 응답도 오지 않음 — /pub로 보낸 메시지가 서버 검증에 걸린 경우다. 개방 전·종료된 방, 빈 내용, 1000자 초과, 잘못된 이미지 key가 여기 해당한다. 연결도 끊기지 않고 에러도 오지 않은 채 메시지만 조용히 사라진다.

그래서 전송 실패는 이렇게 판정해야 한다.

1) 낙관적으로 말풍선을 그린다
2) 브로드캐스트로 되돌아오는 내 메시지(에코)를 수신하면 확정
3) 일정 시간 안에 에코가 없으면 실패 처리
4) GET /api/v1/chat/rooms 로 isEnded·opensAt 을 다시 읽어 원인을 가린다

에코를 기다리지 않고 "ERROR 프레임이 없으면 성공"으로 구현하면, 만료·종료 직후 보낸 메시지가 영원히 전송 중으로 남는다.

  • 이미 구독 중인 세션은 상대가 종료해도 끊기지 않는다. 종료 SYSTEM 메시지를 받으면 로컬 상태를 종료로 뒤집고, 재연결 로직이 그 방을 다시 구독하지 않게 막아야 한다.

예시 (@stomp/stompjs)

import { Client } from '@stomp/stompjs';

const client = new Client({
  brokerURL: 'wss://api.ditto.pics/ws',
  connectHeaders: { 'X-API-Key': API_KEY, Authorization: `Bearer ${accessToken}` },
  reconnectDelay: 2000,          // 지터 백오프 권장
  heartbeatIncoming: 10000, heartbeatOutgoing: 10000,
});
client.onConnect = () => {
  client.subscribe(`/sub/chat/rooms/${roomId}`, (frame) => {
    const payload = JSON.parse(frame.body);
    if (payload.type === 'READ') {
      // 내 메시지 중 previous < id <= last 인 것만 unreadCount -1
      applyRead(payload.memberId, payload.previousLastReadMessageId, payload.lastReadMessageId);
      return;
    }
    applyMessage(payload);                   // { id, senderId, content, imageUrl, unreadCount, ... }
  });
};
client.onStompError = (frame) => {
  // 인증·구독 거부·/sub 직접 SEND. 코드가 실리지 않으니 REST로 방 상태를 다시 확인한다.
};
// 텍스트
client.publish({ destination: `/pub/chat/rooms/${roomId}`,
  body: JSON.stringify({ content, messageType: 'TEXT' }) });
// 이미지 (업로드 URL 발급 → S3 PUT 완료 후)
client.publish({ destination: `/pub/chat/rooms/${roomId}`,
  body: JSON.stringify({ content: objectKey, messageType: 'IMAGE' }) });

client.activate();

재연결 & 리줌 (필수 구현)

  • 서버는 현재 단일 인스턴스라 배포/네트워크 시 소켓이 끊길 수 있다. 지터 백오프로 자동 재연결할 것.
  • 재연결 직후 REST로 공백을 메운다(리줌). 메시지는 DB 영속이라 유실이 아니라 잠깐의 공백이다.
  • "특정 id 이후"를 받는 파라미터는 없다. 커서는 과거 방향 한쪽뿐이다. 절차는 이렇다 — cursor 없이 최신 페이지를 받아 마지막 수신 id를 넘는 것만 골라내고, 페이지의 마지막(가장 과거) 항목이 여전히 그 id보다 크면 nextCursor로 더 내려가며 채운다.
  • 브로드캐스트와 겹칠 수 있으니 id 기준 중복 제거는 필수다.

에러 코드 (채팅)

표의 숫자는 실제 HTTP 상태가 아니라 응답 바디의 error.statusCode다. STOMP 경로에서는 이 코드가 클라이언트로 전달되지 않는다.

code statusCode 의미
7001 404 존재하지 않는 채팅방
7002 403 채팅방에 참여한 회원이 아님 / 그룹 방에 종료 요청(message로 구분)
7003 400 유효하지 않은 채팅 이미지 — 내가 발급받은 key가 아니거나 아직 S3 업로드가 끝나지 않은 key
7004 409 종료된 채팅방 — 전송·이미지 발급·구독 차단
7005 409 아직 열리지 않은 채팅방 — 전송·이미지 발급·구독 차단
0001 400 잘못된 요청(빈 내용 / 1000자 초과 / 이미지 아님 / 10MB 초과 등)
0002 401 인증 실패
0003 403 권한 없음 (예: /sub로 직접 SEND, 구독 destination 형식 오류 — 이 경우만 ERROR 프레임으로 드러난다)

서버가 담당하지 않는 것

항목 처리
방 상태(개방 전·진행 중·종료) 판정 FE — isEnded와 opensAt 비교로 파생
남은 시간 카운트다운 FE — expiresAt 기준 계산
전송 성공·실패 판정 FE — 에코 수신 여부로 판정(서버 응답 없음)
종료 안내 문구 FE — senderId와 내 ID 비교
상대 닉네임·프로필 FE — ID마다 프로필 API 호출
종료된 방 숨김·정리 FE — 목록에서 사라지지 않음
만료 감지 FE — 만료에는 SYSTEM 메시지도 실시간 이벤트도 없음
종료 직후 본인 화면 갱신 FE — 종료 응답이 비어 있음

아직 미지원 (로드맵)

  • 채팅 연장(#121) — expiresAt은 연장으로 밀릴 수 있게 설계됐지만 연장 API는 아직 없다. 현재는 항상 고정.
  • 타이핑 표시
  • 읽은 사람 목록·읽은 시각 — READ 이벤트는 "누가 어디까지"만 준다
  • 그룹 멤버 개별 이탈 — 지금 그룹 방을 끝내는 경로는 기한 만료뿐이다.
  • STOMP 전송 실패 응답 — 현재 서버가 오류를 돌려주지 않는다. 위 에코 판정으로 우회할 것.
  • 스케일아웃 시 외부 브로커 전환 — 클라 계약은 동일 유지 예정

참고

  • 전체 스펙(Swagger UI): https://api.ditto.pics/docs
  • Swagger 스키마에 endedAt·endedReason·lastMessage.imageUrl·messages[].imageUrl 네 필드가 빠져 있다(예시 값이 null이라 타입 추론이 안 된 결과). 실제 응답에는 항상 키가 있으니 자동 생성 타입을 쓴다면 보완할 것. sourceType 설명도 아직 REMATCH가 빠져 있어 enum을 자동 생성하면 재매칭 방에서 파싱이 깨질 수 있다.
  • 관련 이슈: #102(채팅 기반) · #104(실시간) · #118(그룹 방) · #120(종료 생명주기) · #132(재매칭 채팅) · #182(읽음 표시, PR #183)
  • 채팅이 끝난 뒤: Frontend-Review-Rematch-Guide
  • 설계 배경: 레포 docs/domains/chat.md · docs/adr/0009-websocket-stomp-auth.md

Clone this wiki locally