Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# SYSTEM 메시지는 metadata 기반 클라이언트 렌더링으로 통일

- **상태**: 결정
- **상태**: 교체됨 (→ [20260608-1553-system-message-payload-cleanup.md](20260608-1553-system-message-payload-cleanup.md))
- **날짜**: 2026-06-07
- **관련**: [20260607-user-withdrawal-soft-delete.md](20260607-user-withdrawal-soft-delete.md)

Expand Down
62 changes: 62 additions & 0 deletions docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# SYSTEM 메시지 페이로드에서 표시 데이터 제거

- **상태**: 결정
- **날짜**: 2026-06-08
- **교체**: [20260607-1636-system-message-metadata-rendering.md](20260607-1636-system-message-metadata-rendering.md)

## Status

결정

## Context

`20260607-1636-system-message-metadata-rendering.md`는 "백엔드 변경 없음, 클라이언트가 metadata 기반으로 재렌더링"이라는 보수적 결정을 내렸다. 그 결정에서 `content`와 `metadata.nickname`/`profileImageUrl`/`previousHostNickname`/`newHostNickname`은 legacy 클라이언트와 디버깅용 fallback으로 의도적으로 남겨졌다.

이후 두 가지 전제가 추가로 확정되었다.

1. MongoDB `messages` 컬렉션이 초기화 상태라 마이그레이션 비용이 없다.
2. 프론트엔드가 `messageType` + `metadata.eventType` + `userId` lookup 흐름으로 이미 구현되어 있다.

전제 (1)로 fallback content 보존 동기가 사라졌고, 전제 (2)로 표시 책임을 클라이언트로 일원화할 수 있다. 굳은 표시 데이터를 저장하면 회원 탈퇴·닉네임 변경 후에도 stale 표시가 남는 본 문제의 근본 원인이 그대로 유지된다.

## Decision

SYSTEM 메시지의 MongoDB 문서·STOMP 페이로드·REST 응답에서 다음 키를 **저장하지 않고 응답에도 포함하지 않는다**.

- `content`
- `metadata.nickname`
- `metadata.profileImageUrl`
- `metadata.previousHostNickname`
- `metadata.newHostNickname`

대신 SYSTEM 메시지의 metadata는 다음 식별자만 포함한다.

- `MEMBER_JOINED`/`MEMBER_LEFT`/`MEMBER_KICKED`: `{ eventType, userId }`
- `HOST_DELEGATED`: `{ eventType, previousHostUserId, newHostUserId }`

클라이언트는 `messageType === "SYSTEM"`인 경우 metadata의 `eventType`과 userId 식별자를 기준으로 방 멤버 맵에서 닉네임/프로필을 lookup해 표시 문구를 조립한다. lookup 실패 시 `(알 수 없음)`로 표시한다.

CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE 메시지의 저장 및 전송 구조는 변경하지 않는다.

## Consequences

**장점**
- 회원 탈퇴·닉네임 변경 후에도 SYSTEM 메시지가 stale 표시 데이터로 남지 않는다. 일반 채팅과 SYSTEM 메시지의 표시 일관성이 보장된다.
- MongoDB 문서·payload 크기 감소.
- 표시 문구 조립 책임이 클라이언트로 일원화되어 백엔드는 식별자만 책임진다.

**단점/제약**
- legacy 클라이언트(아직 metadata 기반 분기를 구현하지 못한 버전)에서는 SYSTEM 메시지가 빈 줄로 보일 수 있다. 본 결정 배포는 프론트엔드 분기 구현 배포 이후로 합의한다.
- BSON 직렬화에서 null 필드 skip은 Spring Data MongoDB 기본 동작이지만, 향후 `MongoMappingContext` 설정이 변경되면 깨질 수 있다. 통합 테스트 가드로 회귀를 막는다.

**후속 문서/코드 갱신**
- `docs/ai/features.md` 시스템 메시지 항목
- `docs/superpowers/plans/2026-06-08-mongo-message-payload.md` (본 plan 진행)
- `MessagePayload`/`MessageResponse` `@Schema` 설명

## Related Docs

- `docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md`
- `docs/ai/features.md`
- `docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md` (교체)
- `docs/ai/decisions/20260607-user-withdrawal-soft-delete.md`
4 changes: 2 additions & 2 deletions docs/ai/erd.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ CHECK `ck_room_members_left_role` (status='ACTIVE' OR role <> 'PENDING')

## 4. messages (MongoDB 채팅 메시지 컬렉션)

실시간 채팅 메시지. 메시지별 metadata 구조가 달라질 수 있어 MongoDB 컬렉션으로 저장한다. 일반 채팅 요청은 클라이언트 metadata를 받지 않고 `metadata={}`로 저장하며, AI_REQUEST / AI_RESPONSE / PLACE_SHARE / SYSTEM 같은 타입별 확장 데이터는 서버가 정형 요청 또는 내부 이벤트에서 구성한다. AI_REQUEST는 사용자가 AI에게 보낸 질문이며 `metadata.aiStatus=QUEUED|PROCESSING|CANCELED`, `cancelable`, `canceledBy`(취소 시)를 담는다. AI_RESPONSE는 `senderId=NULL`인 AI 답변이다. PLACE_SHARE metadata는 장소 카드 표시용 스냅샷(`googlePlaceId`, `name`, `formattedAddress`, `latitude`, `longitude`, `rating`, `photoName`)을 담고, SYSTEM metadata는 이벤트 식별자와 대상 사용자 정보를 담는다. MongoDB `_id` 문자열을 클라이언트에 `id`로 노출하고 읽음 위치 복귀 cursor로 사용하며, 방별 `sequence`는 브로드캐스트 누락 감지와 복구 cursor로 사용한다.
실시간 채팅 메시지. 메시지별 metadata 구조가 달라질 수 있어 MongoDB 컬렉션으로 저장한다. 일반 채팅 요청은 클라이언트 metadata를 받지 않고 `metadata={}`로 저장하며, AI_REQUEST / AI_RESPONSE / PLACE_SHARE / SYSTEM 같은 타입별 확장 데이터는 서버가 정형 요청 또는 내부 이벤트에서 구성한다. AI_REQUEST는 사용자가 AI에게 보낸 질문이며 `metadata.aiStatus=QUEUED|PROCESSING|CANCELED`, `cancelable`, `canceledBy`(취소 시)를 담는다. AI_RESPONSE는 `senderId=NULL`인 AI 답변이다. PLACE_SHARE metadata는 장소 카드 표시용 스냅샷(`googlePlaceId`, `name`, `formattedAddress`, `latitude`, `longitude`, `rating`, `photoName`)을 담고, SYSTEM metadata는 이벤트 식별자와 대상 사용자 ID만 담는다. MongoDB `_id` 문자열을 클라이언트에 `id`로 노출하고 읽음 위치 복귀 cursor로 사용하며, 방별 `sequence`는 브로드캐스트 누락 감지와 복구 cursor로 사용한다.

| 필드 | 타입 | 제약조건 | 설명 |
|------|------|----------|------|
Expand All @@ -91,7 +91,7 @@ CHECK `ck_room_members_left_role` (status='ACTIVE' OR role <> 'PENDING')
| sequence | LONG | NOT NULL, UNIQUE(roomId, sequence) | 방 안에서 append-only로 증가하는 메시지 순번 |
| senderId | BIGINT | NULLABLE | PostgreSQL users.id 논리 참조, NULL = 시스템/AI 메시지 |
| messageType | STRING | NOT NULL, DEFAULT 'CHAT' | CHAT / AI_REQUEST / AI_RESPONSE / PLACE_SHARE / SYSTEM |
| content | STRING | NOT NULL | 메시지 내용 |
| content | STRING | NULLABLE | 메시지 내용. CHAT / AI_REQUEST / AI_RESPONSE / PLACE_SHARE에서 사용하며 SYSTEM 메시지는 content 키를 저장하지 않음 |
| metadata | DOCUMENT | NOT NULL, DEFAULT `{}` | 장소 공유, AI 응답 등 메시지 타입별 확장 데이터 |
| createdAt | DATE | NOT NULL | 생성일시 |

Expand Down
2 changes: 1 addition & 1 deletion docs/ai/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭
| `[x]` | 재접속 시 미수신 메시지 동기화 | 클라이언트는 실시간 수신 중 마지막 수신 message `sequence` 이후 메시지를 `afterSequence`로 조회해 브로드캐스트 누락을 복구한다. `afterSequence`는 저장된 읽음 위치 복귀가 아니라 sequence gap 복구용이다 | MongoDB messages |
| `[x]` | 읽음 위치 조회 | `GET /rooms/{roomId}/messages/read-status`로 현재 사용자의 `lastReadMessageId`를 조회한다. 채팅 초기 진입/재접속 시 이 값을 `afterId`에 전달해 저장된 읽음 위치 이후 메시지를 조회한다. 값이 없으면 cursor 없이 최근 메시지를 조회한다 | room_members |
| `[x]` | 장소 카드 메시지 전송 | 클라이언트가 `/app/rooms/{roomId}/messages/place`로 장소 스냅샷을 전송하면 MongoDB `messages` 컬렉션에 `messageType=PLACE_SHARE`로 저장 후 `/topic/rooms/{roomId}/messages`로 브로드캐스트 | MongoDB messages |
| `[x]` | 시스템 메시지 | 입장 승인 같은 멤버십 변경 이벤트를 `messageType=SYSTEM`, `senderId=NULL` 메시지로 저장 후 `/topic/rooms/{roomId}/messages`와 `/topic/rooms/{roomId}/members` 양쪽으로 브로드캐스트. WebSocket 접속/해제 presence 이벤트는 채팅 히스토리에 저장하지 않음 | MongoDB messages |
| `[x]` | 시스템 메시지 | 입장 승인 같은 멤버십 변경 이벤트를 `messageType=SYSTEM`, `senderId=NULL` 메시지로 저장 후 `/topic/rooms/{roomId}/messages`와 `/topic/rooms/{roomId}/members` 양쪽으로 브로드캐스트. MongoDB 문서와 STOMP/REST 응답 모두에서 `content` 키는 포함되지 않으며, metadata는 식별자만 가진다. `MEMBER_JOINED`/`MEMBER_LEFT`/`MEMBER_KICKED`는 `{ eventType, userId }`, `HOST_DELEGATED`는 `{ eventType, previousHostUserId, newHostUserId }`. 표시 문구는 클라이언트가 `eventType`과 userId를 멤버 맵에서 lookup해 조립한다. WebSocket 접속/해제 presence 이벤트는 채팅 히스토리에 저장하지 않음 | MongoDB messages |
| `[x]` | 읽음 처리 (WS) | 클라이언트가 `/app/rooms/{roomId}/messages/read`로 `lastReadMessageId`를 전송하면 `room_members.last_read_message_id`를 업데이트. 후퇴 방지(새 ID > 기존 ID만 허용). 실패 시 로그만 남기고 무시. 읽음 cursor는 현재 `_id` 기준을 유지한다 | room_members |
| `[x]` | 안읽은 메시지 카운트 조회 | `GET /rooms/{roomId}/messages/unread-count`로 `lastReadMessageId` 이후 SYSTEM 제외 메시지 수 반환. 페이지 로딩/재접속 시 호출하여 배지 카운트 초기화, 이후 실시간 메시지는 프론트 로컬 증가. unread count는 현재 `_id` 기준을 유지한다 | room_members, MongoDB messages |
| `[x]` | 채팅 도배 방지 (Flood Protection) | Redis 기반 Bucket4j로 사용자별 `5/sec + 60/min` token bucket 제한을 `/chat`, `/place` 전송에 적용한다. 초과 시 `CHAT_RATE_LIMIT_EXCEEDED` 에러와 다음 토큰 충전까지의 시간(`retryAfterMs`)을 `/user/queue/errors`로 전달한다. Redis/Bucket4j backend 장애 시에는 fail-open으로 전송을 허용한다. 서버 사이드 cooldown과 점진 증가 정책은 사용하지 않는다 | Redis |
Expand Down
Loading