Repository navigation
Frontend Chat Guide
상태: 머지 완료 (#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분간은 서버가 아직 개방 전으로 판정할 수 있다.
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으로 실패하니, 방 자체의 오류로 취급하지 말고 이름 자리만 대체 표시할 것.
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 이벤트로 줄인다.
POST /api/v1/chat/rooms/{roomId}/read
{ "lastReadMessageId": 30 }
- 단조 증가다. 현재 커서보다 작은 값을 보내면 조용히 무시하고 성공으로 답한다.
- 개방 전·종료된 방에서도 허용된다.
-
lastReadMessageId는 그 방의 메시지 id여야 한다. 다른 방 id나 없는 id를 보내면0001(BAD_REQUEST)로 거부한다. 메시지 id는 방 구분 없는 전역 값이라 방 목록 화면에 들고 있던 다른 방의 id를 잘못 보내면 커서가 안 본 메시지를 건너뛰어 전진하고 되돌릴 수 없어서다. - 커서가 실제로 전진했으면 방 토픽에 READ 이벤트가 나간다(아래 실시간 절). 같은 값 재시도·뒤로 가는 값·방을 나간 사람의 읽음은 이벤트가 없다.
- 권고: 메시지가 올 때마다 즉시 던지지 말고 잠깐 모아(수백 ms) 마지막 id 하나만 보낼 것. 요청 수가 줄고, 겹친 요청이 서로 기다리는 일도 줄어든다.
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 전송 실패는 코드를 돌려주지 않는다(아래 실시간 절 참고).
POST /api/v1/chat/rooms/{roomId}/end
- 요청 바디가 없고, 응답
data도 비어 있다({}). - 멱등이다. 더블 탭이나 재시도로 여러 번 불러도 종료 안내 메시지는 1건만 남고, 최초 종료 시각·사유가 덮이지 않는다. 이미 끝난 방에 다시 불러도 성공으로 답한다.
-
그룹 방은 이 경로로 끝낼 수 없다.
7002가 오는데 코드가 멤버십 오류와 같아서message("그룹 채팅은 이 경로로 종료할 수 없습니다.")로만 구분된다. 그룹은 기한 만료로만 끝난다. - 아직 열리지 않은 방은 종료할 수 있다(
7005로 막히지 않는다). - 응답이 비어 있으므로 종료를 누른 본인 화면은 서버 응답만으로 아무것도 알 수 없다. 낙관적으로 전환하거나 목록·메시지를 다시 조회할 것.
사용자 종료가 실제로 방을 끝냈을 때만 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 경로는 코드를 돌려주지 않으니 방 상태는 목록 조회로 판단할 것.
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). 게다가 전송 실패는 아무 응답도 오지 않아 원인을 알 수 없다.
- 엔드포인트:
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 이벤트 = 아래 절
- 메시지 프레임 = 위 메시지 객체와 동일(IMAGE면
- heartbeat 10s/10s 자동.
누군가 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 메시지를 받으면 로컬 상태를 종료로 뒤집고, 재연결 로직이 그 방을 다시 구독하지 않게 막아야 한다.
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