From 8cbadcc86ece600999c66280b4863f77027389ff Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 15:18:47 +0900 Subject: [PATCH 01/11] =?UTF-8?q?docs:=20SYSTEM=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20MongoDB=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20spec=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SYSTEM 메시지에서 content·metadata.nickname·metadata.profileImageUrl을 BSON·JSON 키 자체로 남기지 않고, 표시 책임을 클라이언트로 일원화하는 refactor 설계안을 docs/superpowers/specs/에 작성한다. --- ...2026-06-08-mongo-message-payload-design.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md diff --git a/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md b/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md new file mode 100644 index 00000000..e34e2ae7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md @@ -0,0 +1,159 @@ +# SYSTEM 메시지 MongoDB 저장 페이로드 정리 + +- **상태**: 초안 +- **날짜**: 2026-06-08 +- **관련 ADR**: + - 교체 대상: `docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md` + - 신규 작성 예정: `docs/ai/decisions/20260608--system-message-payload-cleanup.md` +- **워크트리/브랜치**: `.worktrees/refactor-mongo-message-payload` / `refactor/mongo-message-payload` + +## 배경 + +MongoDB `messages` 컬렉션의 SYSTEM 메시지(`MEMBER_JOINED`, `MEMBER_LEFT`, `MEMBER_KICKED`, `HOST_DELEGATED`)는 사람이 읽을 수 있는 `content`("박주영님이 방을 나갔습니다")와 metadata의 `nickname`/`profileImageUrl`/`previousHostNickname`/`newHostNickname`을 함께 저장하고 있다. 이 값은 저장 시점 스냅샷이라 회원 탈퇴·닉네임 변경 후에는 굳은 표시 데이터로 남는다. + +기존 ADR(`20260607-1636`)은 "백엔드 데이터 변경 없음, 클라이언트가 metadata 기반으로 재렌더링"이라는 보수적 결정을 내렸다. 그 결정에서는 content를 legacy 클라이언트와 디버깅용 fallback으로 의도적으로 유지했다. + +이번 작업의 전제는 두 가지다. + +1. MongoDB `messages` 컬렉션이 초기화 상태라 마이그레이션 비용이 없다. +2. 프론트엔드가 `messageType` + `metadata.eventType`으로 분기하고 방 진입 시 받은 멤버 데이터로 lookup하는 흐름으로 이미 구현되어 있다 (또는 그 방향으로 합의된다). + +전제 (1) 덕분에 fallback content를 굳이 보존할 필요가 없고, 전제 (2) 덕분에 표시 책임을 클라이언트로 일원화할 수 있다. + +## 목표 + +- SYSTEM 메시지의 MongoDB 문서·STOMP 페이로드·REST 응답에서 굳은 표시 데이터(`content`, `metadata.nickname`, `metadata.profileImageUrl`, `metadata.previousHostNickname`, `metadata.newHostNickname`)를 **키 자체로 남기지 않는다**. +- 표시 문구 조립 책임을 클라이언트로 일원화한다. 백엔드는 식별자(`eventType` + 관련 `userId`)만 보낸다. +- CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE 메시지의 저장/전송 구조는 변경하지 않는다. + +## 비목표 + +- 기존 MongoDB 문서 마이그레이션 (컬렉션 초기화 상태로 가정). +- presence 토픽(`RoomPresenceChangedEvent` → `/topic/rooms/{id}/presence`)의 nickname/profileImageUrl 사용 변경. +- 도메인 이벤트(`MemberJoinedEvent` 외 3종) 자체의 필드 정리. +- 다국어 처리. + +## 변경 사항 + +### 1. 도메인 모델 (`ChatMessage`) + +`ChatMessage.system()` 팩토리 시그니처에서 `content` 인자를 제거한다. + +```java +// before +public static ChatMessage system(UUID roomId, String content, Map metadata) { + return new ChatMessage(roomId, null, MessageType.SYSTEM, content, metadata, Instant.now()); +} + +// after +public static ChatMessage system(UUID roomId, Map metadata) { + return new ChatMessage(roomId, null, MessageType.SYSTEM, null, metadata, Instant.now()); +} +``` + +다른 팩토리(`chat`, `aiRequest`, `aiResponse`, `placeShare`)와 인스턴스 필드는 변경하지 않는다. `content` 필드는 다른 MessageType이 본질 데이터로 사용한다. + +Spring Data MongoDB의 `MappingMongoConverter`는 엔티티의 null 필드를 BSON에 쓰지 않으므로, 저장된 SYSTEM 문서에는 `content` 키가 생기지 않는다. + +### 2. `SystemMessageService` + +4개 메서드의 시그니처에서 nickname·profileImageUrl·previousHostNickname·newHostNickname 인자를 제거한다. metadata 맵에도 해당 키를 넣지 않는다. `MessageContentValidator.normalizeContent`로 닉네임을 정규화하던 로직도 제거한다. + +```java +public MessageResult sendMemberJoinedSystemMessage(UUID roomId, long joinedUserId) { + Map metadata = Map.of( + "eventType", "MEMBER_JOINED", + "userId", joinedUserId + ); + return saveSystemMessage(roomId, metadata); +} + +public MessageResult sendMemberLeftSystemMessage(UUID roomId, long leftUserId) { ... "MEMBER_LEFT" ... } +public MessageResult sendMemberKickedSystemMessage(UUID roomId, long kickedUserId) { ... "MEMBER_KICKED" ... } + +public MessageResult sendHostDelegatedSystemMessage(UUID roomId, + long previousHostUserId, + long newHostUserId) { + Map metadata = Map.of( + "eventType", "HOST_DELEGATED", + "previousHostUserId", previousHostUserId, + "newHostUserId", newHostUserId + ); + return saveSystemMessage(roomId, metadata); +} + +private MessageResult saveSystemMessage(UUID roomId, Map metadata) { + ChatMessage message = ChatMessage.system(roomId, metadata); + ChatMessage savedMessage = messageSequenceAllocator.saveWithNextSequence(message); + MessageResult result = MessageResult.from(savedMessage); + messagePublisher.publishMessageSent(result); + return result; +} +``` + +`MessageContentValidator` 의존성은 SystemMessageService에서 더 이상 필요 없으면 주입에서 제거한다(다른 메서드가 쓰면 유지). + +### 3. `MessageService` 위임 메서드 + +4개 메서드의 시그니처를 SystemMessageService와 동일하게 줄인다. + +### 4. 리스너 4종 + +- `MemberApprovedMessageListener`, `MemberLeftMessageListener`, `MemberKickedMessageListener`, `HostDelegatedMessageListener`에서 `messageService.sendXxxSystemMessage(...)` 호출 시 nickname/profileImageUrl/previousHostNickname/newHostNickname 인자를 제거한다. +- 같은 리스너에서 발행하는 `RoomPresenceChangedEvent`는 그대로 둔다(별도 토픽). +- 이벤트 클래스(`MemberJoinedEvent`/`MemberLeftEvent`/`MemberKickedEvent`/`HostDelegatedEvent`)는 변경하지 않는다. + +### 5. API DTO + +`MessagePayload.content`, `MessageResponse.content` 필드에 `@JsonInclude(JsonInclude.Include.NON_NULL)`를 적용한다. SYSTEM 메시지에서 `content`가 null이면 JSON 응답에 키가 포함되지 않는다. + +`@Schema` 설명을 다음과 같이 갱신한다. + +- `content`: "사람이 읽을 수 있는 메시지 본문. CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE에서만 제공됩니다. SYSTEM 메시지에서는 응답에 포함되지 않습니다." +- `metadata` 중 SYSTEM 항목: + - `SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED`: `{ eventType, userId }` + - `SYSTEM HOST_DELEGATED`: `{ eventType, previousHostUserId, newHostUserId }` + - 보강 설명: "SYSTEM 메시지의 표시 문구는 metadata.eventType과 userId(또는 previousHostUserId/newHostUserId)로 클라이언트가 멤버 맵에서 lookup해 조립합니다. 닉네임/프로필 이미지는 응답에 포함되지 않습니다." + +### 6. 데이터 흐름 (MEMBER_LEFT 예시) + +1. RoomService → `MemberLeftEvent(roomId, leftUserId, nickname, profileImageUrl)` 발행. +2. `MemberLeftMessageListener`: + - presence 토픽 갱신을 위해 `RoomPresenceChangedEvent`는 기존 인자 그대로 발행. + - 시스템 메시지 호출: `messageService.sendMemberLeftSystemMessage(roomId, leftUserId)` (인자 2개). +3. `SystemMessageService` → metadata `{eventType: "MEMBER_LEFT", userId}`로 저장. content는 null. +4. MongoDB 문서: `{_id, roomId, sequence, senderId:null, messageType:"SYSTEM", metadata:{eventType, userId}, createdAt}` (`content` 키 없음). +5. STOMP/REST 응답 JSON: `content` 키 없음, metadata에 nickname/profileImageUrl 없음. +6. 클라이언트: `messageType === "SYSTEM"` && `metadata.eventType === "MEMBER_LEFT"` → 멤버 맵에서 `userId` lookup해 "{닉}님이 방을 나갔습니다" 조립. lookup 실패 시 `(알 수 없음)`. + +## 테스트 + +| 파일 | 변경 | +|---|---| +| `SystemMessageServiceTest` | 4개 케이스 호출 인자를 축소. 검증: `ChatMessage.content == null`, metadata 키는 `eventType`/`userId`(HOST는 previous/new HostUserId)만, nickname/profileImageUrl 키 없음. | +| `MessageServiceTest`의 SYSTEM 위임 케이스 | 동일하게 인자 축소 및 검증. | +| 4개 리스너 테스트 | `sendXxxSystemMessage` mock verify를 축소된 시그니처로 갱신. `RoomPresenceChangedEvent` 발행 검증은 그대로 유지. | +| (추가) MongoDB 저장 가드 테스트 | mock 기반 `SystemMessageServiceTest`로는 BSON 직렬화를 검증할 수 없으므로 `@DataMongoTest`(또는 기존 `messages` 패키지의 통합 테스트 슬라이스)에서 SYSTEM 메시지를 저장한 뒤 `mongoTemplate.getCollection("messages").find(...)`로 raw `Document`를 받아 `content` 키 부재와 metadata 키 집합을 직접 검증한다. Spring Data MongoDB null skip 동작 회귀 방지. | + +## 문서/ADR + +- **신규 ADR**: `docs/ai/decisions/20260608--system-message-payload-cleanup.md` + - 상태: 결정 + - 배경: 기존 ADR의 "백엔드 변경 없음" 결정과 컬렉션 초기화 기회. + - 결정: SYSTEM 메시지는 metadata 식별자만 저장/전송, content는 저장도 응답도 하지 않는다. + - 영향: API 스키마 갱신, 마이그레이션 없음, 클라이언트는 기존 metadata 렌더링 계약 그대로 사용. +- **기존 ADR 갱신**: `20260607-1636-system-message-metadata-rendering.md` 상단 `상태`를 `결정` → `교체됨 (→ 20260608-...-system-message-payload-cleanup.md)`으로 변경. 본문은 이력 보존을 위해 그대로 둔다. +- **`docs/ai/features.md`**: SYSTEM 메시지 페이로드 설명을 본 spec과 일치하도록 갱신. +- **plan**: 본 spec 승인 후 writing-plans 스킬로 `docs/superpowers/plans/2026-06-08-mongo-message-payload.md` 작성. + +## 위험 / 가드 + +- **Spring Data MongoDB 동작 회귀**: null 필드가 BSON에 포함되지 않는 기본 동작은 보장이지만, 향후 `MongoMappingContext` 설정이 바뀌면 깨질 수 있다. BSON 키 부재를 직접 검증하는 테스트로 가드한다. +- **프론트 합의**: 프론트가 metadata.eventType + userId 기반 분기를 이미 구현했는지 별도 확인 필요. content fallback이 사라지므로 미구현 상태에서 배포하면 SYSTEM 메시지가 빈 줄로 보일 수 있다. 본 작업과 프론트 작업의 배포 순서를 합의한다. +- **presence 토픽 비변경**: nickname/profileImageUrl은 presence 토픽에서 그대로 흘러간다. 그쪽까지 정리하려면 별도 작업. + +## 비목표 재확인 + +- CHAT/AI/PLACE_SHARE 메시지 구조 변경 — 다음 기회. +- 다국어/번역 — 클라이언트 책임. +- 기존 SYSTEM 문서 마이그레이션 — 컬렉션 초기화로 비목표. From bd78e22cfedb293547020b69c44369457d7edec9 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 15:48:26 +0900 Subject: [PATCH 02/11] =?UTF-8?q?docs:=20SYSTEM=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20MongoDB=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20plan=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-08-mongo-message-payload.md | 1019 +++++++++++++++++ 1 file changed, 1019 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-mongo-message-payload.md diff --git a/docs/superpowers/plans/2026-06-08-mongo-message-payload.md b/docs/superpowers/plans/2026-06-08-mongo-message-payload.md new file mode 100644 index 00000000..4d01431b --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-mongo-message-payload.md @@ -0,0 +1,1019 @@ +# SYSTEM 메시지 MongoDB 페이로드 정리 플랜 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** SYSTEM 메시지(`MEMBER_JOINED`/`MEMBER_LEFT`/`MEMBER_KICKED`/`HOST_DELEGATED`)의 MongoDB 문서·STOMP 페이로드·REST 응답에서 굳은 표시 데이터(`content`, `metadata.nickname`, `metadata.profileImageUrl`, `metadata.previousHostNickname`, `metadata.newHostNickname`)를 키 자체로 남기지 않는다. 표시 문구 조립 책임을 클라이언트로 일원화한다. + +**Architecture:** `ChatMessage.system()` 팩토리에서 `content` 인자를 제거해 SYSTEM 문서의 `content` 필드가 항상 null이 되도록 한다. Spring Data MongoDB의 `MappingMongoConverter`는 null 필드를 BSON에 쓰지 않으므로 저장 문서에 `content` 키가 생기지 않는다. `SystemMessageService` 4개 메서드와 `MessageService` 위임, 4개 리스너 호출 사이트에서 nickname/profileImageUrl/previousHostNickname/newHostNickname 인자를 모두 제거한다. `MessagePayload`/`MessageResponse`의 `content` 필드에 `@JsonInclude(NON_NULL)`을 붙여 SYSTEM 응답 JSON에서 `content` 키가 사라지게 한다. CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE 메시지의 저장·전송 구조는 건드리지 않는다. + +**Tech Stack:** Spring Boot 4.0.5, Java 21, Spring Data MongoDB, MongoDB 8, JUnit 5 + Mockito + AssertJ, Testcontainers(MongoDB) 통합 테스트. + +**참조 스펙:** `docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md` + +**전제(스펙):** +1. MongoDB `messages` 컬렉션이 초기화 상태라 마이그레이션 작업 자체가 없다. +2. 프론트엔드는 `messageType` + `metadata.eventType`로 분기하고 방 진입 시 받은 멤버 데이터로 lookup하는 흐름이 이미 구현되어 있다(또는 본 PR 배포 전까지 합의된다). + +--- + +## File Structure + +**신규 파일** +- `docs/ai/decisions/20260608--system-message-payload-cleanup.md` — 본 결정 ADR +- `src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java` — Mongo raw `Document` 키 부재 가드 + +**수정 파일** +- `src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java` — `system()` 팩토리에서 `content` 인자 제거 +- `src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java` — 4개 메서드 시그니처 축소, `MessageContentValidator` 의존성 제거, content 텍스트 조립 제거 +- `src/main/java/com/howaboutus/backend/messages/service/MessageService.java` — 4개 위임 메서드 시그니처 축소 +- `src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java` — 호출 인자 축소 +- `src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java` — 호출 인자 축소 (`RoomPresenceChangedEvent` 발행은 유지) +- `src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java` — 호출 인자 축소 (`RoomPresenceChangedEvent`/`UserRoomActionBroadcaster` 발행은 유지) +- `src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java` — 호출 인자 축소 +- `src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java` — `content`에 `@JsonInclude(NON_NULL)` + `@Schema` 갱신 +- `src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java` — `content`에 `@JsonInclude(NON_NULL)` + `@Schema` 갱신 +- `src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java` — 4개 케이스로 확장, 새 metadata 키 집합 검증 +- `src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java` — SYSTEM 위임 케이스 시그니처 축소 및 assertion 수정 +- `src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java` — verify 시그니처 축소 +- `src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java` — verify 시그니처 축소 +- `src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java` — verify 시그니처 축소 +- `src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java` — verify 시그니처 축소 +- `docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md` — 상태를 `결정` → `교체됨 (→ 20260608--system-message-payload-cleanup.md)` +- `docs/ai/features.md` — 171행 시스템 메시지 항목 설명 갱신 +- `docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md` — 마지막에 본 plan과 ADR 경로 링크 + 상태를 `구현 중`/`구현 완료`로 갱신 + +--- + +## 작업 순서 원칙 + +- 각 Task 끝에 conventional commit(`feat:`/`refactor:`/`test:`/`docs:`/`chore:`) 한 개를 만든다. +- 커밋 직전에는 **반드시** `./gradlew checkstyleMain checkstyleTest`를 실행해 경고 0개를 확인한다. 경고가 있으면 빌드가 실패한다. +- 커밋 메시지에 `Co-Authored-By:` 트레일러를 **절대** 추가하지 않는다 (사용자 선호). +- 단위 테스트는 `@ExtendWith(MockitoExtension.class) + @Mock + @InjectMocks` 우선, 필요한 경우에만 `Mockito.mock(...)` 직접 생성. +- Task 2는 SystemMessageService/MessageService/리스너 4종을 한 번에 바꾼다. 시그니처가 호출 사이트와 양방향으로 의존하므로 분리하면 중간 상태가 컴파일되지 않는다. +- TDD 순서: 테스트 먼저 수정해 fail → 프로덕션 코드 수정 → 동일 테스트 pass → 커밋. +- 본 plan은 컬렉션 초기화 상태를 전제로 한다. 마이그레이션 작업은 없다. + +--- + +## Task 1: ADR 추가 + 기존 ADR 상태 갱신 + +**Files:** +- Create: `docs/ai/decisions/20260608--system-message-payload-cleanup.md` (`` 자리는 커밋 직전 시각 4자리, 예: `1541`) +- Modify: `docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md` (상단 `상태` 줄 1줄) + +- [ ] **Step 1: 신규 ADR 파일 생성** + +`docs/ai/decisions/20260608--system-message-payload-cleanup.md` (현재 시각의 HHMM, 예: 1541): + +```markdown +# 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` +``` + +- [ ] **Step 2: 기존 ADR 상태 줄 갱신** + +`docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md` 상단의 `- **상태**: 결정` 줄을 다음으로 바꾼다 (HHMM은 Step 1에서 정한 값과 일치). + +```markdown +- **상태**: 교체됨 (→ [20260608--system-message-payload-cleanup.md](20260608--system-message-payload-cleanup.md)) +``` + +본문은 이력 보존을 위해 그대로 둔다. + +- [ ] **Step 3: 변경 사항 확인** + +Run: `git diff docs/ai/decisions/` +Expected: 신규 파일 1개 + 기존 파일 상단 1줄 수정. + +- [ ] **Step 4: 커밋** + +```bash +git add docs/ai/decisions/20260608-*-system-message-payload-cleanup.md \ + docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md +git commit -m "docs: SYSTEM 메시지 페이로드 정리 ADR 추가 및 기존 ADR 교체 표기" +``` + +--- + +## Task 2: SystemMessageService + ChatMessage + MessageService + 4 리스너 시그니처 축소 + +도메인 모델·서비스·위임·리스너 5개 파일과 5개 테스트 파일을 한 번에 바꾼다. 호출 사이트와 정의가 양방향으로 의존하므로 분리하면 중간 상태가 컴파일되지 않는다. TDD: 테스트를 먼저 깬 뒤 프로덕션 코드를 바꾼다. + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java:71-73` +- Modify: `src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java` (전체) +- Modify: `src/main/java/com/howaboutus/backend/messages/service/MessageService.java:53-86` +- Modify: `src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java:19-26` +- Modify: `src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java:28-38` +- Modify: `src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java:43-61` +- Modify: `src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java:20-28` +- Modify: `src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java` +- Modify: `src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java:343-373` +- Modify: `src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java:30-34` +- Modify: `src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java` +- Modify: `src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java` +- Modify: `src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java:32-42` + +- [ ] **Step 1: `SystemMessageServiceTest`를 4개 케이스로 확장하고 새 metadata 키만 단언하도록 갱신** + +`src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java` 전체를 다음으로 교체: + +```java +package com.howaboutus.backend.messages.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import com.howaboutus.backend.messages.document.ChatMessage; +import com.howaboutus.backend.messages.document.MessageType; +import com.howaboutus.backend.messages.repository.ChatMessageRepository; +import com.howaboutus.backend.messages.service.dto.MessageResult; +import com.howaboutus.backend.realtime.event.MessageSentEvent; + +@ExtendWith(MockitoExtension.class) +class SystemMessageServiceTest { + + @Mock + private ChatMessageRepository chatMessageRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private SystemMessageService systemMessageService; + + @BeforeEach + void setUp() { + MessagePublisher messagePublisher = new MessagePublisher(eventPublisher); + MessageSequenceAllocator messageSequenceAllocator = new MessageSequenceAllocator(chatMessageRepository); + lenient().when(chatMessageRepository.findFirstByRoomIdOrderBySequenceDescIdDesc(any(UUID.class))) + .thenReturn(Optional.empty()); + systemMessageService = new SystemMessageService(messageSequenceAllocator, messagePublisher); + } + + @Test + @DisplayName("MEMBER_JOINED 시스템 메시지는 content 없이 eventType/userId만 저장한다") + void sendMemberJoinedSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c333"); + + MessageResult result = systemMessageService.sendMemberJoinedSystemMessage(roomId, 7L); + + assertThat(result.senderId()).isNull(); + assertThat(result.messageType()).isEqualTo(MessageType.SYSTEM); + assertThat(result.content()).isNull(); + assertThat(result.sequence()).isEqualTo(1L); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "MEMBER_JOINED"), + entry("userId", 7L) + ); + + verifySavedSystemMessageHasNullContent(); + verify(eventPublisher).publishEvent(MessageSentEvent.from(result)); + } + + @Test + @DisplayName("MEMBER_LEFT 시스템 메시지는 content 없이 eventType/userId만 저장한다") + void sendMemberLeftSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c334"); + + MessageResult result = systemMessageService.sendMemberLeftSystemMessage(roomId, 7L); + + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "MEMBER_LEFT"), + entry("userId", 7L) + ); + + verifySavedSystemMessageHasNullContent(); + } + + @Test + @DisplayName("MEMBER_KICKED 시스템 메시지는 content 없이 eventType/userId만 저장한다") + void sendMemberKickedSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c335"); + + MessageResult result = systemMessageService.sendMemberKickedSystemMessage(roomId, 7L); + + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "MEMBER_KICKED"), + entry("userId", 7L) + ); + + verifySavedSystemMessageHasNullContent(); + } + + @Test + @DisplayName("HOST_DELEGATED 시스템 메시지는 content 없이 이전/새 호스트 userId만 저장한다") + void sendHostDelegatedSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c336"); + + MessageResult result = systemMessageService.sendHostDelegatedSystemMessage(roomId, 1L, 2L); + + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "HOST_DELEGATED"), + entry("previousHostUserId", 1L), + entry("newHostUserId", 2L) + ); + + verifySavedSystemMessageHasNullContent(); + } + + private void givenSaveReturnsWithId(String id) { + given(chatMessageRepository.save(any(ChatMessage.class))).willAnswer(invocation -> { + ChatMessage message = invocation.getArgument(0); + ReflectionTestUtils.setField(message, "id", id); + return message; + }); + } + + private void verifySavedSystemMessageHasNullContent() { + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatMessage.class); + verify(chatMessageRepository).save(captor.capture()); + ChatMessage saved = captor.getValue(); + assertThat(saved.getMessageType()).isEqualTo(MessageType.SYSTEM); + assertThat(saved.getContent()).isNull(); + assertThat(saved.getSenderId()).isNull(); + } +} +``` + +위에서 사용한 `entry(...)`는 `org.assertj.core.api.Assertions.entry`다. import에 이미 wildcard로 포함되어 있다. + +- [ ] **Step 2: `MessageServiceTest`의 SYSTEM 위임 케이스 갱신** + +`src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java`에서 `setUp()`의 `SystemMessageService` 생성자 호출과 `sendMemberJoinedSystemMessageStoresSystemMessage` 케이스를 갱신한다. + +`setUp()` 내 (76-80행) `SystemMessageService` 생성 부분을 다음으로 교체: + +```java + SystemMessageService systemMessageService = new SystemMessageService( + messageSequenceAllocator, + messagePublisher + ); +``` + +이어서 `sendMemberJoinedSystemMessageStoresSystemMessage` 케이스(현재 343-373행)를 다음으로 교체: + +```java + @Test + @DisplayName("멤버 입장 시스템 메시지는 senderId/content 없이 MongoDB에 저장할 수 있다") + void sendMemberJoinedSystemMessageStoresSystemMessage() { + UUID roomId = UUID.randomUUID(); + + given(chatMessageRepository.save(any(ChatMessage.class))).willAnswer(invocation -> { + ChatMessage message = invocation.getArgument(0); + ReflectionTestUtils.setField(message, "id", "6628f5f4c49a9f7b3772c333"); + return message; + }); + + MessageResult result = messageService.sendMemberJoinedSystemMessage(roomId, 7L); + + assertThat(result.senderId()).isNull(); + assertThat(result.messageType()).isEqualTo(MessageType.SYSTEM); + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "MEMBER_JOINED"), + entry("userId", 7L) + ); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ChatMessage.class); + verify(chatMessageRepository).save(messageCaptor.capture()); + assertThat(messageCaptor.getValue().getMessageType()).isEqualTo(MessageType.SYSTEM); + assertThat(messageCaptor.getValue().getContent()).isNull(); + verify(eventPublisher).publishEvent(MessageSentEvent.from(result)); + } +``` + +필요한 import가 누락되어 있으면 `import static org.assertj.core.api.Assertions.*;`로 wildcard import이 이미 있으므로 별도 추가는 없다(`entry`가 그 안에 포함됨). + +- [ ] **Step 3: 4개 리스너 테스트의 verify 시그니처 축소** + +`MemberApprovedMessageListenerTest.java`의 `handleSendsSystemMessage` 본문(30-34행): + +```java + UUID roomId = UUID.randomUUID(); + MemberApprovedEvent event = new MemberApprovedEvent(roomId, 3L, "대기자", "https://example.com/p.png"); + + listener.handle(event); + + verify(messageService).sendMemberJoinedSystemMessage(roomId, 3L); +``` + +`MemberLeftMessageListenerTest.java`의 두 테스트에서 마지막 `then(messageService)` 단언을 다음으로 교체: + +```java + then(messageService).should().sendMemberLeftSystemMessage(ROOM_ID, 2L); +``` + +`MemberKickedMessageListenerTest.java`의 `handleRemovesPresenceAndBroadcastsAndSendsSystemMessage`/`handleSendsMessageEvenWhenRedisFails`의 `then(messageService)` 단언을 다음으로 교체: + +```java + then(messageService).should().sendMemberKickedSystemMessage(ROOM_ID, 2L); +``` + +`HostDelegatedMessageListenerTest.java`의 `handleSendsSystemMessage` 본문(32-42행)을 다음으로 교체: + +```java + @Test + @DisplayName("이벤트 처리 - 시스템 메시지 전송") + void handleSendsSystemMessage() { + HostDelegatedEvent event = new HostDelegatedEvent( + ROOM_ID, 1L, "호스트", 2L, "타겟"); + + listener.handle(event); + + then(messageService).should().sendHostDelegatedSystemMessage(ROOM_ID, 1L, 2L); + } +``` + +리스너 테스트는 `HostDelegatedEvent`/`MemberLeftEvent`/`MemberKickedEvent`/`MemberApprovedEvent` 이벤트 자체는 변경하지 않으므로(spec 비목표) nickname/profileImageUrl/previousHostNickname/newHostNickname 인자는 그대로 둔다. 테스트가 검증하는 것은 "리스너가 messageService에 nickname/profileImageUrl를 전달하지 않는다"는 것이다. + +- [ ] **Step 4: 테스트를 돌려 모두 빨갛게 떠지는지 확인** + +Run: `./gradlew test --tests 'com.howaboutus.backend.messages.service.SystemMessageServiceTest' --tests 'com.howaboutus.backend.messages.service.MessageServiceTest' --tests 'com.howaboutus.backend.messages.listener.*MessageListenerTest' -q` +Expected: 컴파일 오류(`SystemMessageService(...)` 생성자/`sendXxxSystemMessage(...)` 시그니처 mismatch). 이게 정상이다. 테스트가 새 시그니처를 요구한다. + +- [ ] **Step 5: `ChatMessage.system()`에서 content 인자 제거** + +`src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java`의 `system` 팩토리(71-73행)를 다음으로 교체: + +```java + public static ChatMessage system(UUID roomId, Map metadata) { + return new ChatMessage(roomId, null, MessageType.SYSTEM, null, metadata, Instant.now()); + } +``` + +다른 팩토리(`chat`, `aiRequest`, `aiResponse`, `placeShare`)와 인스턴스 필드는 건드리지 않는다. `content` 필드 자체는 다른 MessageType이 본질 데이터로 사용한다. + +- [ ] **Step 6: `SystemMessageService` 전체 재작성** + +`src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java` 전체를 다음으로 교체: + +```java +package com.howaboutus.backend.messages.service; + +import java.util.Map; +import java.util.UUID; + +import org.springframework.stereotype.Service; + +import com.howaboutus.backend.common.logging.Loggable; +import com.howaboutus.backend.messages.document.ChatMessage; +import com.howaboutus.backend.messages.service.dto.MessageResult; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SystemMessageService { + + private final MessageSequenceAllocator messageSequenceAllocator; + private final MessagePublisher messagePublisher; + + @Loggable + public MessageResult sendMemberJoinedSystemMessage(UUID roomId, long joinedUserId) { + Map metadata = Map.of( + "eventType", "MEMBER_JOINED", + "userId", joinedUserId + ); + return saveSystemMessage(roomId, metadata); + } + + @Loggable + public MessageResult sendMemberKickedSystemMessage(UUID roomId, long kickedUserId) { + Map metadata = Map.of( + "eventType", "MEMBER_KICKED", + "userId", kickedUserId + ); + return saveSystemMessage(roomId, metadata); + } + + @Loggable + public MessageResult sendMemberLeftSystemMessage(UUID roomId, long leftUserId) { + Map metadata = Map.of( + "eventType", "MEMBER_LEFT", + "userId", leftUserId + ); + return saveSystemMessage(roomId, metadata); + } + + @Loggable + public MessageResult sendHostDelegatedSystemMessage(UUID roomId, + long previousHostUserId, + long newHostUserId) { + Map metadata = Map.of( + "eventType", "HOST_DELEGATED", + "previousHostUserId", previousHostUserId, + "newHostUserId", newHostUserId + ); + return saveSystemMessage(roomId, metadata); + } + + private MessageResult saveSystemMessage(UUID roomId, Map metadata) { + ChatMessage message = ChatMessage.system(roomId, metadata); + ChatMessage savedMessage = messageSequenceAllocator.saveWithNextSequence(message); + MessageResult result = MessageResult.from(savedMessage); + messagePublisher.publishMessageSent(result); + return result; + } +} +``` + +`MessageContentValidator` 의존성은 SystemMessageService에서 더 이상 필요 없으므로 제거한다. `MessageContentValidator` 클래스 자체는 다른 서비스(`MessageCommandService`, `AiMessageService`, `MessageQueryService`)가 여전히 사용하므로 삭제하지 않는다. + +`MessageMetadata` 유틸리티(`nonNull` / `entries`)는 SYSTEM 경로에서 더 이상 호출되지 않지만, 다른 메시지 경로에서 사용되는지 확인 후 사용처가 없으면 삭제한다. 사용처가 있으면 그대로 둔다. + +Run: `grep -rn "MessageMetadata\." src/main/java` +사용처가 SystemMessageService 외부에 없으면 `src/main/java/com/howaboutus/backend/messages/service/MessageMetadata.java`를 삭제한다. + +- [ ] **Step 7: `MessageService`의 4개 위임 메서드 축소** + +`src/main/java/com/howaboutus/backend/messages/service/MessageService.java`의 53-86행을 다음으로 교체: + +```java + public MessageResult sendMemberJoinedSystemMessage(UUID roomId, long joinedUserId) { + return systemMessageService.sendMemberJoinedSystemMessage(roomId, joinedUserId); + } + + public void sendMemberKickedSystemMessage(UUID roomId, long kickedUserId) { + systemMessageService.sendMemberKickedSystemMessage(roomId, kickedUserId); + } + + public void sendMemberLeftSystemMessage(UUID roomId, long leftUserId) { + systemMessageService.sendMemberLeftSystemMessage(roomId, leftUserId); + } + + public void sendHostDelegatedSystemMessage(UUID roomId, + long previousHostUserId, + long newHostUserId) { + systemMessageService.sendHostDelegatedSystemMessage(roomId, previousHostUserId, newHostUserId); + } +``` + +`sendMemberKickedSystemMessage`/`sendMemberLeftSystemMessage`/`sendHostDelegatedSystemMessage`는 기존과 같이 반환값 없이 `void`로 유지한다. + +- [ ] **Step 8: `MemberApprovedMessageListener`의 호출 인자 축소** + +`src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java`의 19-26행 `handle` 본문을 다음으로 교체: + +```java + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handle(MemberApprovedEvent event) { + messageService.sendMemberJoinedSystemMessage(event.roomId(), event.joinedUserId()); + } +``` + +`MemberApprovedEvent`의 nickname/profileImageUrl 필드는 그대로 둔다(이벤트 변경은 spec 비목표). 단지 이 리스너가 그 값을 무시할 뿐이다. + +- [ ] **Step 9: `MemberLeftMessageListener`의 호출 인자 축소** + +`src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java`의 28-38행 `handle` 본문에서 `messageService.sendMemberLeftSystemMessage(...)` 호출만 다음으로 교체. `RoomPresenceChangedEvent` 발행은 그대로 둔다. + +```java + messageService.sendMemberLeftSystemMessage(event.roomId(), event.leftUserId()); +``` + +전체 `handle` 메서드: + +```java + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handle(MemberLeftEvent event) { + removePresenceSafe(event.roomId(), event.leftUserId()); + eventPublisher.publishEvent(new RoomPresenceChangedEvent( + event.roomId(), event.leftUserId(), + RoomPresenceEventType.USER_DISCONNECTED, + event.nickname(), event.profileImageUrl())); + messageService.sendMemberLeftSystemMessage(event.roomId(), event.leftUserId()); + } +``` + +- [ ] **Step 10: `MemberKickedMessageListener`의 호출 인자 축소** + +`src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java`의 53-55행 `messageService.sendMemberKickedSystemMessage(...)` 호출만 다음으로 교체. `RoomPresenceChangedEvent`/`UserRoomActionBroadcaster.sendToUser` 호출은 그대로 둔다. + +```java + messageService.sendMemberKickedSystemMessage(event.roomId(), event.kickedUserId()); +``` + +- [ ] **Step 11: `HostDelegatedMessageListener`의 호출 인자 축소** + +`src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java`의 20-28행 `handle` 본문을 다음으로 교체: + +```java + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handle(HostDelegatedEvent event) { + messageService.sendHostDelegatedSystemMessage( + event.roomId(), + event.previousHostUserId(), + event.newHostUserId()); + } +``` + +- [ ] **Step 12: 단위 테스트 전부 통과 확인** + +Run: `./gradlew test --tests 'com.howaboutus.backend.messages.service.SystemMessageServiceTest' --tests 'com.howaboutus.backend.messages.service.MessageServiceTest' --tests 'com.howaboutus.backend.messages.listener.*MessageListenerTest' -q` +Expected: PASS. + +- [ ] **Step 13: 전체 빌드 + 체크스타일** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings. + +Run: `./gradlew compileJava compileTestJava -q` +Expected: SUCCESS. SystemMessageService/MessageService/리스너의 새 시그니처가 다른 호출 사이트(테스트 외)와 충돌하지 않아야 한다. + +- [ ] **Step 14: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java \ + src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java \ + src/main/java/com/howaboutus/backend/messages/service/MessageService.java \ + src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java \ + src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java \ + src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java \ + src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java \ + src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java \ + src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java \ + src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java \ + src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java \ + src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java \ + src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java +# MessageMetadata.java 삭제했으면 함께 stage +[ -f src/main/java/com/howaboutus/backend/messages/service/MessageMetadata.java ] || git add -u src/main/java/com/howaboutus/backend/messages/service/MessageMetadata.java +git commit -m "refactor: SYSTEM 메시지 페이로드에서 표시 데이터 제거 및 식별자만 저장" +``` + +--- + +## Task 3: API DTO에서 SYSTEM content 키 응답 제외 + +`MessagePayload`(STOMP)와 `MessageResponse`(REST)의 `content` 필드에 `@JsonInclude(NON_NULL)`을 붙여 SYSTEM 메시지에서 `content`가 null일 때 JSON 응답에서 키 자체가 빠지게 한다. `@Schema` 설명을 spec과 일치하도록 갱신한다. + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java` +- Modify: `src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java` + +- [ ] **Step 1: `MessagePayload`에 `@JsonInclude(NON_NULL)` 및 `@Schema` 갱신** + +`src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java` 전체를 다음으로 교체: + +```java +package com.howaboutus.backend.realtime.service.dto; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.howaboutus.backend.messages.document.MessageType; +import com.howaboutus.backend.realtime.event.MessageSentEvent; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MessagePayload( + String id, + Long sequence, + String clientMessageId, + UUID roomId, + Long senderId, + MessageType messageType, + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = """ + 사람이 읽을 수 있는 메시지 본문. + + CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE에서만 제공됩니다. + SYSTEM 메시지에서는 응답에 포함되지 않습니다(키 자체가 빠집니다).""") + String content, + @Schema(description = """ + 상위 messageType에 따라 구조가 달라지는 확장 데이터입니다. + + - CHAT: 빈 객체 {} + - AI_REQUEST: { clientMessageId: string, aiStatus: QUEUED|PROCESSING|CANCELED, + cancelable: boolean, canceledBy?: number } + - AI_RESPONSE: { requestMessageId: string, intent: string, placeRecommendation?: object, + conversationSummary?: object } + placeRecommendation: { title, subtitle?, places: [{ place_id, name, address?, + lat?, lng?, primary_type?, reason, google_maps_uri? }] } + - PLACE_SHARE: { googlePlaceId: string, name: string, formattedAddress?: string, + latitude?: number, longitude?: number, rating?: number, photoName?: string } + - SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED: { eventType, userId } + - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, newHostUserId } + + SYSTEM 메시지의 표시 문구는 metadata.eventType과 userId + (또는 previousHostUserId/newHostUserId)로 클라이언트가 멤버 맵에서 lookup해 조립합니다. + 닉네임/프로필 이미지는 응답에 포함되지 않습니다.""") + Map metadata, + Instant createdAt +) { + public static MessagePayload from(MessageSentEvent event) { + return new MessagePayload( + event.id(), + event.sequence(), + event.clientMessageId(), + event.roomId(), + event.senderId(), + event.messageType(), + event.content(), + event.metadata(), + event.createdAt() + ); + } +} +``` + +- [ ] **Step 2: `MessageResponse`에 `@JsonInclude(NON_NULL)` 및 `@Schema` 갱신** + +`src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java` 전체를 다음으로 교체: + +```java +package com.howaboutus.backend.messages.controller.dto; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.howaboutus.backend.messages.document.MessageType; +import com.howaboutus.backend.messages.service.dto.MessageResult; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MessageResponse( + String id, + Long sequence, + UUID roomId, + Long senderId, + MessageType messageType, + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = """ + 사람이 읽을 수 있는 메시지 본문. + + CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE에서만 제공됩니다. + SYSTEM 메시지에서는 응답에 포함되지 않습니다(키 자체가 빠집니다).""") + String content, + @Schema(description = """ + 상위 messageType에 따라 구조가 달라지는 확장 데이터입니다. + + - CHAT: 빈 객체 {} + - AI_REQUEST: { clientMessageId: string, aiStatus: QUEUED|PROCESSING|CANCELED, + cancelable: boolean, canceledBy?: number } + - AI_RESPONSE: { requestMessageId: string, intent: string, placeRecommendation?: object, + conversationSummary?: object } + placeRecommendation: { title, subtitle?, places: [{ place_id, name, address?, + lat?, lng?, primary_type?, rating?, user_rating_count?, reason, google_maps_uri? }] } + - PLACE_SHARE: { googlePlaceId: string, name: string, formattedAddress?: string, + latitude?: number, longitude?: number, rating?: number, photoName?: string } + - SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED: { eventType, userId } + - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, newHostUserId } + + SYSTEM 메시지의 표시 문구는 metadata.eventType과 userId + (또는 previousHostUserId/newHostUserId)로 클라이언트가 멤버 맵에서 lookup해 조립합니다. + 닉네임/프로필 이미지는 응답에 포함되지 않습니다.""") + Map metadata, + Instant createdAt +) { + public static MessageResponse from(MessageResult result) { + return new MessageResponse( + result.id(), + result.sequence(), + result.roomId(), + result.senderId(), + result.messageType(), + result.content(), + result.metadata(), + result.createdAt() + ); + } +} +``` + +- [ ] **Step 3: 컴파일 + 체크스타일** + +Run: `./gradlew compileJava checkstyleMain -q` +Expected: SUCCESS, 0 warnings. + +- [ ] **Step 4: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java \ + src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java +git commit -m "refactor: SYSTEM 메시지 응답에서 content 키 제외 및 @Schema 설명 갱신" +``` + +--- + +## Task 4: MongoDB raw BSON 키 부재 통합 테스트 가드 + +mock 기반 단위 테스트로는 Spring Data MongoDB의 BSON 직렬화 동작을 검증할 수 없다. `BaseIntegrationTest`(Testcontainers MongoDB)를 상속한 신규 통합 테스트로 SYSTEM 메시지를 저장한 뒤 `mongoTemplate.getCollection("messages").find(...)`로 raw `Document`를 가져와 `content` 키 부재와 metadata 키 집합을 직접 검증한다. Spring Data MongoDB null skip 동작 회귀 방지가 목적이다. + +**Files:** +- Create: `src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java` + +- [ ] **Step 1: 통합 테스트 파일 생성** + +`src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java`: + +```java +package com.howaboutus.backend.messages.document; + +import static org.assertj.core.api.Assertions.*; + +import java.util.UUID; + +import org.bson.Document; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; + +import com.howaboutus.backend.messages.service.SystemMessageService; +import com.howaboutus.backend.support.BaseIntegrationTest; + +class SystemMessagePayloadIntegrationTest extends BaseIntegrationTest { + + @Autowired + private SystemMessageService systemMessageService; + + @Autowired + private MongoTemplate mongoTemplate; + + @AfterEach + void cleanUp() { + mongoTemplate.getCollection("messages").deleteMany(new Document()); + } + + @Test + @DisplayName("MEMBER_JOINED 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 eventType/userId만 포함한다") + void memberJoinedSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendMemberJoinedSystemMessage(roomId, 7L); + + Document raw = mongoTemplate.getCollection("messages").find().first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata).isNotNull(); + assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); + assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_JOINED"); + assertThat(metadata.getLong("userId")).isEqualTo(7L); + } + + @Test + @DisplayName("MEMBER_LEFT 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 eventType/userId만 포함한다") + void memberLeftSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendMemberLeftSystemMessage(roomId, 7L); + + Document raw = mongoTemplate.getCollection("messages").find().first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); + assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_LEFT"); + } + + @Test + @DisplayName("MEMBER_KICKED 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 eventType/userId만 포함한다") + void memberKickedSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendMemberKickedSystemMessage(roomId, 7L); + + Document raw = mongoTemplate.getCollection("messages").find().first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); + assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_KICKED"); + } + + @Test + @DisplayName("HOST_DELEGATED 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 식별자 3개만 포함한다") + void hostDelegatedSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendHostDelegatedSystemMessage(roomId, 1L, 2L); + + Document raw = mongoTemplate.getCollection("messages").find().first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata.keySet()).containsExactlyInAnyOrder( + "eventType", "previousHostUserId", "newHostUserId"); + assertThat(metadata.getString("eventType")).isEqualTo("HOST_DELEGATED"); + assertThat(metadata.getLong("previousHostUserId")).isEqualTo(1L); + assertThat(metadata.getLong("newHostUserId")).isEqualTo(2L); + } + + @Test + @DisplayName("기준선: CHAT 메시지의 BSON 문서에는 content 키가 보존된다 (회귀 가드)") + void chatMessageBsonStillHasContentKey() { + UUID roomId = UUID.randomUUID(); + ChatMessage chat = ChatMessage.chat(roomId, 42L, "안녕"); + mongoTemplate.save(chat); + + Document raw = mongoTemplate.getCollection("messages") + .find(Query.query(org.springframework.data.mongodb.core.query.Criteria + .where("messageType").is("CHAT")).getQueryObject()) + .first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isTrue(); + assertThat(raw.getString("content")).isEqualTo("안녕"); + } +} +``` + +마지막 케이스(CHAT 회귀 가드)는 향후 누군가 모든 메시지에서 content를 빼버리는 회귀를 막기 위한 기준선이다. + +- [ ] **Step 2: 통합 테스트 실행** + +Run: `./gradlew test --tests 'com.howaboutus.backend.messages.document.SystemMessagePayloadIntegrationTest' -q` +Expected: PASS (Task 2의 코드 변경이 이미 들어가 있어야 한다). + +Testcontainers MongoDB 컨테이너 부팅에 수십 초가 걸린다. CI 환경에서 Docker가 활성화되어 있는지 확인한다. + +- [ ] **Step 3: 체크스타일** + +Run: `./gradlew checkstyleTest -q` +Expected: 0 warnings. + +- [ ] **Step 4: 커밋** + +```bash +git add src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java +git commit -m "test: SYSTEM 메시지 BSON 페이로드 키 부재 통합 테스트 가드" +``` + +--- + +## Task 5: `docs/ai/features.md` 갱신 + +171행의 시스템 메시지 항목을 본 결정을 반영하도록 갱신한다. + +**Files:** +- Modify: `docs/ai/features.md:171` + +- [ ] **Step 1: 시스템 메시지 항목 갱신** + +`docs/ai/features.md`의 171행 시스템 메시지 row를 다음으로 교체: + +```markdown +| `[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 | +``` + +- [ ] **Step 2: spec 상태/링크 갱신** + +`docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md` 상단의 상태를 `초안` → `구현 완료`로 변경하고, "문서/ADR" 섹션의 신규 ADR 경로 placeholder를 Task 1에서 실제로 만든 파일명으로 갱신한다(예: `20260608-1541-system-message-payload-cleanup.md`). + +- [ ] **Step 3: 커밋** + +```bash +git add docs/ai/features.md \ + docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md +git commit -m "docs: SYSTEM 메시지 페이로드 정리 결정 features.md/spec 반영" +``` + +--- + +## Task 6: 최종 빌드/체크스타일 일제 검증 + +PR 올리기 전 마지막 검증. + +- [ ] **Step 1: 체크스타일** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings, BUILD SUCCESSFUL. + +- [ ] **Step 2: 전체 단위 + 통합 테스트** + +Run: `./gradlew test -q` +Expected: BUILD SUCCESSFUL. SystemMessagePayloadIntegrationTest 포함 4 케이스가 통합 테스트 슬라이스에서 통과해야 한다. + +- [ ] **Step 3: 빌드** + +Run: `./gradlew build -q` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: 커밋 로그 점검** + +Run: `git log --oneline origin/main..HEAD` +Expected: Task 1~5의 5개 conventional commit. `Co-Authored-By` 트레일러가 어디에도 없어야 한다. + +- [ ] **Step 5: 프론트엔드 합의 확인 (배포 전 게이트)** + +본 변경은 클라이언트 fallback content를 제거한다. 프론트가 `messageType === "SYSTEM"` + `metadata.eventType` 분기를 이미 배포한 상태인지 확인하고, 그렇지 않다면 본 PR 머지 전 프론트 배포 순서를 합의한다. 합의 결과는 PR 본문의 "배포 노트" 또는 "테스트 메모"에 적는다. + +--- + +## 위험 / 가드 + +- **Spring Data MongoDB null skip 회귀**: BSON 키 부재 검증 통합 테스트(Task 4)로 가드. 향후 `MongoMappingContext` 설정이 바뀌면 이 테스트가 즉시 빨갛게 뜬다. +- **프론트 미배포 상태에서 배포**: SYSTEM 메시지가 빈 줄로 보일 수 있다. Task 6 Step 5의 합의 게이트로 차단. +- **`MessageContentValidator` 사용처 변경**: SystemMessageService에서만 빠진다. 다른 서비스(`MessageCommandService`, `AiMessageService`, `MessageQueryService`)는 그대로. 사용처 검증은 Task 2 Step 6에서 `grep`으로 확인. +- **`MessageMetadata` 유틸리티**: SystemMessageService 외 사용처 없으면 Task 2 Step 6에서 삭제. 사용처 있으면 그대로 둔다. +- **presence 토픽 비변경**: nickname/profileImageUrl은 `/topic/rooms/{id}/presence`에서 그대로 흘러간다. 본 작업 비목표. +- **이벤트 객체 비변경**: `MemberJoinedEvent`/`MemberLeftEvent`/`MemberKickedEvent`/`HostDelegatedEvent`의 nickname/profileImageUrl/previousHostNickname/newHostNickname 필드는 그대로 둔다. 본 작업 비목표. + +--- + +## 비목표 + +- 기존 MongoDB 문서 마이그레이션 (컬렉션 초기화 상태 전제). +- presence 토픽(`/topic/rooms/{id}/presence`)의 nickname/profileImageUrl 정리. +- 도메인 이벤트 4종 자체의 필드 정리. +- 다국어 처리(클라이언트 책임). +- CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE의 저장/전송 구조 변경. From 6eb79141f2b0e84f7491a147e376b32405c9cb34 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 15:55:55 +0900 Subject: [PATCH 03/11] =?UTF-8?q?docs:=20SYSTEM=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20ADR=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20ADR=20=EA=B5=90=EC=B2=B4=20=ED=91=9C=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-1636-system-message-metadata-rendering.md | 2 +- ...608-1553-system-message-payload-cleanup.md | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md diff --git a/docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md b/docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md index 69a34bc6..61d7e307 100644 --- a/docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md +++ b/docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md @@ -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) diff --git a/docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md b/docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md new file mode 100644 index 00000000..f500010c --- /dev/null +++ b/docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md @@ -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` From c864e32aa093c8b5d065af9307393641bf47da84 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 16:03:57 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20SYSTEM=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=91=9C=EC=8B=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=8B=9D=EB=B3=84=EC=9E=90?= =?UTF-8?q?=EB=A7=8C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messages/document/ChatMessage.java | 4 +- .../HostDelegatedMessageListener.java | 4 +- .../MemberApprovedMessageListener.java | 7 +- .../listener/MemberKickedMessageListener.java | 4 +- .../listener/MemberLeftMessageListener.java | 4 +- .../messages/service/MessageService.java | 33 ++---- .../service/SystemMessageService.java | 76 ++++-------- .../ai/service/AiSummaryServiceTest.java | 2 +- .../HostDelegatedMessageListenerTest.java | 3 +- .../MemberApprovedMessageListenerTest.java | 2 +- .../MemberKickedMessageListenerTest.java | 6 +- .../MemberLeftMessageListenerTest.java | 6 +- .../messages/service/MessageServiceTest.java | 23 ++-- .../service/SystemMessageServiceTest.java | 111 +++++++++++++----- 14 files changed, 136 insertions(+), 149 deletions(-) diff --git a/src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java b/src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java index c1d4aacc..cda6a97e 100644 --- a/src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java +++ b/src/main/java/com/howaboutus/backend/messages/document/ChatMessage.java @@ -68,8 +68,8 @@ public static ChatMessage placeShare(UUID roomId, Long senderId, String content, return new ChatMessage(roomId, senderId, MessageType.PLACE_SHARE, content, metadata, Instant.now()); } - public static ChatMessage system(UUID roomId, String content, Map metadata) { - return new ChatMessage(roomId, null, MessageType.SYSTEM, content, metadata, Instant.now()); + public static ChatMessage system(UUID roomId, Map metadata) { + return new ChatMessage(roomId, null, MessageType.SYSTEM, null, metadata, Instant.now()); } public void assignSequence(long sequence) { diff --git a/src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java b/src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java index b739be1c..3536f8e0 100644 --- a/src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java +++ b/src/main/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListener.java @@ -22,8 +22,6 @@ public void handle(HostDelegatedEvent event) { messageService.sendHostDelegatedSystemMessage( event.roomId(), event.previousHostUserId(), - event.previousHostNickname(), - event.newHostUserId(), - event.newHostNickname()); + event.newHostUserId()); } } diff --git a/src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java b/src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java index e93ba55e..59d7b2e8 100644 --- a/src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java +++ b/src/main/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListener.java @@ -17,11 +17,6 @@ public class MemberApprovedMessageListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void handle(MemberApprovedEvent event) { - messageService.sendMemberJoinedSystemMessage( - event.roomId(), - event.joinedUserId(), - event.nickname(), - event.profileImageUrl() - ); + messageService.sendMemberJoinedSystemMessage(event.roomId(), event.joinedUserId()); } } diff --git a/src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java b/src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java index 5db5af5e..6fa27a79 100644 --- a/src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java +++ b/src/main/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListener.java @@ -50,9 +50,7 @@ public void handle(MemberKickedEvent event) { RoomPresenceEventType.USER_DISCONNECTED, event.nickname(), event.profileImageUrl())); //3. 추방 시스템 메시지 전송 - messageService.sendMemberKickedSystemMessage( - event.roomId(), event.kickedUserId(), - event.nickname(), event.profileImageUrl()); + messageService.sendMemberKickedSystemMessage(event.roomId(), event.kickedUserId()); //4. 추방 당사자에게 개인 알림 전송 userRoomActionBroadcaster.sendToUser( event.kickedUserId(), diff --git a/src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java b/src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java index 6bcff40f..188e8115 100644 --- a/src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java +++ b/src/main/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListener.java @@ -32,9 +32,7 @@ public void handle(MemberLeftEvent event) { event.roomId(), event.leftUserId(), RoomPresenceEventType.USER_DISCONNECTED, event.nickname(), event.profileImageUrl())); - messageService.sendMemberLeftSystemMessage( - event.roomId(), event.leftUserId(), - event.nickname(), event.profileImageUrl()); + messageService.sendMemberLeftSystemMessage(event.roomId(), event.leftUserId()); } private void removePresenceSafe(UUID roomId, long userId) { diff --git a/src/main/java/com/howaboutus/backend/messages/service/MessageService.java b/src/main/java/com/howaboutus/backend/messages/service/MessageService.java index f5e3d889..05e48bce 100644 --- a/src/main/java/com/howaboutus/backend/messages/service/MessageService.java +++ b/src/main/java/com/howaboutus/backend/messages/service/MessageService.java @@ -50,39 +50,22 @@ public MessageResult sendAiResponse(UUID roomId, SendAiResponseCommand command) return messageCommandService.sendAiResponse(roomId, command); } - public MessageResult sendMemberJoinedSystemMessage(UUID roomId, - long joinedUserId, - String nickname, - String profileImageUrl) { - return systemMessageService.sendMemberJoinedSystemMessage(roomId, joinedUserId, nickname, profileImageUrl); + public MessageResult sendMemberJoinedSystemMessage(UUID roomId, long joinedUserId) { + return systemMessageService.sendMemberJoinedSystemMessage(roomId, joinedUserId); } - public void sendMemberKickedSystemMessage(UUID roomId, - long kickedUserId, - String nickname, - String profileImageUrl) { - systemMessageService.sendMemberKickedSystemMessage(roomId, kickedUserId, nickname, profileImageUrl); + public void sendMemberKickedSystemMessage(UUID roomId, long kickedUserId) { + systemMessageService.sendMemberKickedSystemMessage(roomId, kickedUserId); } - public void sendMemberLeftSystemMessage(UUID roomId, - long leftUserId, - String nickname, - String profileImageUrl) { - systemMessageService.sendMemberLeftSystemMessage(roomId, leftUserId, nickname, profileImageUrl); + public void sendMemberLeftSystemMessage(UUID roomId, long leftUserId) { + systemMessageService.sendMemberLeftSystemMessage(roomId, leftUserId); } public void sendHostDelegatedSystemMessage(UUID roomId, long previousHostUserId, - String previousHostNickname, - long newHostUserId, - String newHostNickname) { - systemMessageService.sendHostDelegatedSystemMessage( - roomId, - previousHostUserId, - previousHostNickname, - newHostUserId, - newHostNickname - ); + long newHostUserId) { + systemMessageService.sendHostDelegatedSystemMessage(roomId, previousHostUserId, newHostUserId); } public List getMessagesAfter(UUID roomId, String afterId, long userId, int size) { diff --git a/src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java b/src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java index acf78620..7be0acd3 100644 --- a/src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java +++ b/src/main/java/com/howaboutus/backend/messages/service/SystemMessageService.java @@ -17,80 +17,48 @@ public class SystemMessageService { private final MessageSequenceAllocator messageSequenceAllocator; private final MessagePublisher messagePublisher; - private final MessageContentValidator messageContentValidator; @Loggable - public MessageResult sendMemberJoinedSystemMessage(UUID roomId, - long joinedUserId, - String nickname, - String profileImageUrl) { - String normalizedNickname = messageContentValidator.normalizeContent(nickname); - Map metadata = MessageMetadata.nonNull(MessageMetadata.entries( + public MessageResult sendMemberJoinedSystemMessage(UUID roomId, long joinedUserId) { + Map metadata = Map.of( "eventType", "MEMBER_JOINED", - "userId", joinedUserId, - "nickname", normalizedNickname, - "profileImageUrl", profileImageUrl - )); - - return saveSystemMessage(roomId, normalizedNickname + "님이 방에 참여했습니다", metadata); + "userId", joinedUserId + ); + return saveSystemMessage(roomId, metadata); } @Loggable - public MessageResult sendMemberKickedSystemMessage(UUID roomId, - long kickedUserId, - String nickname, - String profileImageUrl) { - String normalizedNickname = messageContentValidator.normalizeContent(nickname); - Map metadata = MessageMetadata.nonNull(MessageMetadata.entries( + public MessageResult sendMemberKickedSystemMessage(UUID roomId, long kickedUserId) { + Map metadata = Map.of( "eventType", "MEMBER_KICKED", - "userId", kickedUserId, - "nickname", normalizedNickname, - "profileImageUrl", profileImageUrl - )); - - return saveSystemMessage(roomId, normalizedNickname + "님이 방에서 내보내졌습니다", metadata); + "userId", kickedUserId + ); + return saveSystemMessage(roomId, metadata); } @Loggable - public MessageResult sendMemberLeftSystemMessage(UUID roomId, - long leftUserId, - String nickname, - String profileImageUrl) { - String normalizedNickname = messageContentValidator.normalizeContent(nickname); - Map metadata = MessageMetadata.nonNull(MessageMetadata.entries( + public MessageResult sendMemberLeftSystemMessage(UUID roomId, long leftUserId) { + Map metadata = Map.of( "eventType", "MEMBER_LEFT", - "userId", leftUserId, - "nickname", normalizedNickname, - "profileImageUrl", profileImageUrl - )); - - return saveSystemMessage(roomId, normalizedNickname + "님이 방을 나갔습니다", metadata); + "userId", leftUserId + ); + return saveSystemMessage(roomId, metadata); } @Loggable public MessageResult sendHostDelegatedSystemMessage(UUID roomId, long previousHostUserId, - String previousHostNickname, - long newHostUserId, - String newHostNickname) { - String normalizedPrevNickname = messageContentValidator.normalizeContent(previousHostNickname); - String normalizedNewNickname = messageContentValidator.normalizeContent(newHostNickname); - Map metadata = MessageMetadata.nonNull(MessageMetadata.entries( + long newHostUserId) { + Map metadata = Map.of( "eventType", "HOST_DELEGATED", "previousHostUserId", previousHostUserId, - "previousHostNickname", normalizedPrevNickname, - "newHostUserId", newHostUserId, - "newHostNickname", normalizedNewNickname - )); - - return saveSystemMessage( - roomId, - normalizedPrevNickname + "님이 " + normalizedNewNickname + "님에게 방장을 위임했습니다", - metadata); + "newHostUserId", newHostUserId + ); + return saveSystemMessage(roomId, metadata); } - private MessageResult saveSystemMessage(UUID roomId, String content, Map metadata) { - ChatMessage message = ChatMessage.system(roomId, content, metadata); + private MessageResult saveSystemMessage(UUID roomId, Map metadata) { + ChatMessage message = ChatMessage.system(roomId, metadata); ChatMessage savedMessage = messageSequenceAllocator.saveWithNextSequence(message); MessageResult result = MessageResult.from(savedMessage); messagePublisher.publishMessageSent(result); diff --git a/src/test/java/com/howaboutus/backend/ai/service/AiSummaryServiceTest.java b/src/test/java/com/howaboutus/backend/ai/service/AiSummaryServiceTest.java index aaacd0fb..f126e012 100644 --- a/src/test/java/com/howaboutus/backend/ai/service/AiSummaryServiceTest.java +++ b/src/test/java/com/howaboutus/backend/ai/service/AiSummaryServiceTest.java @@ -261,7 +261,7 @@ private ChatMessage message(UUID roomId, String id, Long senderId, MessageType m case AI_REQUEST -> ChatMessage.aiRequest(roomId, senderId, content, Map.of()); case AI_RESPONSE -> ChatMessage.aiResponse(roomId, content, Map.of()); case PLACE_SHARE -> ChatMessage.placeShare(roomId, senderId, content, Map.of()); - case SYSTEM -> ChatMessage.system(roomId, content, Map.of()); + case SYSTEM -> ChatMessage.system(roomId, Map.of()); case CHAT -> ChatMessage.chat(roomId, senderId, content); }; ReflectionTestUtils.setField(message, "id", id); diff --git a/src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java b/src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java index c070b913..fcca1af1 100644 --- a/src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java +++ b/src/test/java/com/howaboutus/backend/messages/listener/HostDelegatedMessageListenerTest.java @@ -37,7 +37,6 @@ void handleSendsSystemMessage() { listener.handle(event); - then(messageService).should().sendHostDelegatedSystemMessage( - ROOM_ID, 1L, "호스트", 2L, "타겟"); + then(messageService).should().sendHostDelegatedSystemMessage(ROOM_ID, 1L, 2L); } } diff --git a/src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java b/src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java index a6baf661..3d157f11 100644 --- a/src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java +++ b/src/test/java/com/howaboutus/backend/messages/listener/MemberApprovedMessageListenerTest.java @@ -31,6 +31,6 @@ void handleSendsSystemMessage() { listener.handle(event); - verify(messageService).sendMemberJoinedSystemMessage(roomId, 3L, "대기자", "https://example.com/p.png"); + verify(messageService).sendMemberJoinedSystemMessage(roomId, 3L); } } diff --git a/src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java b/src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java index a2c7155c..9b5fb917 100644 --- a/src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java +++ b/src/test/java/com/howaboutus/backend/messages/listener/MemberKickedMessageListenerTest.java @@ -63,8 +63,7 @@ void handleRemovesPresenceAndBroadcastsAndSendsSystemMessage() { assertThat(published.userId()).isEqualTo(2L); assertThat(published.type()).isEqualTo(RoomPresenceEventType.USER_DISCONNECTED); - then(messageService).should().sendMemberKickedSystemMessage( - ROOM_ID, 2L, "타겟", "https://img/target.jpg"); + then(messageService).should().sendMemberKickedSystemMessage(ROOM_ID, 2L); then(userRoomActionBroadcaster).should().sendToUser( 2L, @@ -97,8 +96,7 @@ void handleSendsMessageEvenWhenRedisFails() { listener.handle(event); - then(messageService).should().sendMemberKickedSystemMessage( - ROOM_ID, 2L, "타겟", "https://img/target.jpg"); + then(messageService).should().sendMemberKickedSystemMessage(ROOM_ID, 2L); then(userRoomActionBroadcaster).should().sendToUser( 2L, diff --git a/src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java b/src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java index c5877e4f..7ad751ce 100644 --- a/src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java +++ b/src/test/java/com/howaboutus/backend/messages/listener/MemberLeftMessageListenerTest.java @@ -56,8 +56,7 @@ void handleRemovesPresenceAndBroadcastsAndSendsSystemMessage() { assertThat(published.userId()).isEqualTo(2L); assertThat(published.type()).isEqualTo(RoomPresenceEventType.USER_DISCONNECTED); - then(messageService).should().sendMemberLeftSystemMessage( - ROOM_ID, 2L, "멤버", "https://img/member.jpg"); + then(messageService).should().sendMemberLeftSystemMessage(ROOM_ID, 2L); } @Test @@ -70,7 +69,6 @@ void handleSendsMessageEvenWhenRedisFails() { listener.handle(event); - then(messageService).should().sendMemberLeftSystemMessage( - ROOM_ID, 2L, "멤버", "https://img/member.jpg"); + then(messageService).should().sendMemberLeftSystemMessage(ROOM_ID, 2L); } } diff --git a/src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java b/src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java index 05e49768..7f576e0c 100644 --- a/src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java +++ b/src/test/java/com/howaboutus/backend/messages/service/MessageServiceTest.java @@ -75,8 +75,7 @@ void setUp() { ); SystemMessageService systemMessageService = new SystemMessageService( messageSequenceAllocator, - messagePublisher, - messageContentValidator + messagePublisher ); MessageQueryService messageQueryService = new MessageQueryService( chatMessageRepository, @@ -340,7 +339,7 @@ void sendAiResponseStoresContentLongerThanUserMessageLimit() { } @Test - @DisplayName("멤버 입장 시스템 메시지는 senderId 없이 MongoDB에 저장할 수 있다") + @DisplayName("멤버 입장 시스템 메시지는 senderId/content 없이 MongoDB에 저장할 수 있다") void sendMemberJoinedSystemMessageStoresSystemMessage() { UUID roomId = UUID.randomUUID(); @@ -350,25 +349,21 @@ void sendMemberJoinedSystemMessageStoresSystemMessage() { return message; }); - MessageResult result = messageService.sendMemberJoinedSystemMessage( - roomId, - 7L, - "대기자", - "https://example.com/profile.png" - ); + MessageResult result = messageService.sendMemberJoinedSystemMessage(roomId, 7L); assertThat(result.senderId()).isNull(); assertThat(result.messageType()).isEqualTo(MessageType.SYSTEM); - assertThat(result.content()).isEqualTo("대기자님이 방에 참여했습니다"); + assertThat(result.content()).isNull(); assertThat(result.metadata()) - .containsEntry("eventType", "MEMBER_JOINED") - .containsEntry("userId", 7L) - .containsEntry("nickname", "대기자") - .containsEntry("profileImageUrl", "https://example.com/profile.png"); + .containsOnly( + entry("eventType", "MEMBER_JOINED"), + entry("userId", 7L) + ); ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ChatMessage.class); verify(chatMessageRepository).save(messageCaptor.capture()); assertThat(messageCaptor.getValue().getMessageType()).isEqualTo(MessageType.SYSTEM); + assertThat(messageCaptor.getValue().getContent()).isNull(); verify(eventPublisher).publishEvent(MessageSentEvent.from(result)); } diff --git a/src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java b/src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java index 675dd3b7..d3d2ec7b 100644 --- a/src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java +++ b/src/test/java/com/howaboutus/backend/messages/service/SystemMessageServiceTest.java @@ -38,46 +38,103 @@ class SystemMessageServiceTest { @BeforeEach void setUp() { MessagePublisher messagePublisher = new MessagePublisher(eventPublisher); - MessageContentValidator messageContentValidator = new MessageContentValidator(); MessageSequenceAllocator messageSequenceAllocator = new MessageSequenceAllocator(chatMessageRepository); lenient().when(chatMessageRepository.findFirstByRoomIdOrderBySequenceDescIdDesc(any(UUID.class))) .thenReturn(Optional.empty()); - systemMessageService = new SystemMessageService( - messageSequenceAllocator, - messagePublisher, - messageContentValidator); + systemMessageService = new SystemMessageService(messageSequenceAllocator, messagePublisher); } @Test - @DisplayName("멤버 입장 시스템 메시지는 전용 서비스에서 생성하고 브로드캐스트한다") - void sendMemberJoinedSystemMessageStoresSystemMessage() { + @DisplayName("MEMBER_JOINED 시스템 메시지는 content 없이 eventType/userId만 저장한다") + void sendMemberJoinedSystemMessageStoresIdentifiersOnly() { UUID roomId = UUID.randomUUID(); - given(chatMessageRepository.save(any(ChatMessage.class))).willAnswer(invocation -> { - ChatMessage message = invocation.getArgument(0); - ReflectionTestUtils.setField(message, "id", "6628f5f4c49a9f7b3772c333"); - return message; - }); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c333"); - MessageResult result = systemMessageService.sendMemberJoinedSystemMessage( - roomId, - 7L, - " 대기자 ", - "https://example.com/profile.png" - ); + MessageResult result = systemMessageService.sendMemberJoinedSystemMessage(roomId, 7L); assertThat(result.senderId()).isNull(); assertThat(result.messageType()).isEqualTo(MessageType.SYSTEM); - assertThat(result.content()).isEqualTo("대기자님이 방에 참여했습니다"); + assertThat(result.content()).isNull(); assertThat(result.sequence()).isEqualTo(1L); assertThat(result.metadata()) - .containsEntry("eventType", "MEMBER_JOINED") - .containsEntry("userId", 7L) - .containsEntry("nickname", "대기자") - .containsEntry("profileImageUrl", "https://example.com/profile.png"); - - ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ChatMessage.class); - verify(chatMessageRepository).save(messageCaptor.capture()); - assertThat(messageCaptor.getValue().getMessageType()).isEqualTo(MessageType.SYSTEM); + .containsOnly( + entry("eventType", "MEMBER_JOINED"), + entry("userId", 7L) + ); + + verifySavedSystemMessageHasNullContent(); verify(eventPublisher).publishEvent(MessageSentEvent.from(result)); } + + @Test + @DisplayName("MEMBER_LEFT 시스템 메시지는 content 없이 eventType/userId만 저장한다") + void sendMemberLeftSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c334"); + + MessageResult result = systemMessageService.sendMemberLeftSystemMessage(roomId, 7L); + + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "MEMBER_LEFT"), + entry("userId", 7L) + ); + + verifySavedSystemMessageHasNullContent(); + } + + @Test + @DisplayName("MEMBER_KICKED 시스템 메시지는 content 없이 eventType/userId만 저장한다") + void sendMemberKickedSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c335"); + + MessageResult result = systemMessageService.sendMemberKickedSystemMessage(roomId, 7L); + + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "MEMBER_KICKED"), + entry("userId", 7L) + ); + + verifySavedSystemMessageHasNullContent(); + } + + @Test + @DisplayName("HOST_DELEGATED 시스템 메시지는 content 없이 이전/새 호스트 userId만 저장한다") + void sendHostDelegatedSystemMessageStoresIdentifiersOnly() { + UUID roomId = UUID.randomUUID(); + givenSaveReturnsWithId("6628f5f4c49a9f7b3772c336"); + + MessageResult result = systemMessageService.sendHostDelegatedSystemMessage(roomId, 1L, 2L); + + assertThat(result.content()).isNull(); + assertThat(result.metadata()) + .containsOnly( + entry("eventType", "HOST_DELEGATED"), + entry("previousHostUserId", 1L), + entry("newHostUserId", 2L) + ); + + verifySavedSystemMessageHasNullContent(); + } + + private void givenSaveReturnsWithId(String id) { + given(chatMessageRepository.save(any(ChatMessage.class))).willAnswer(invocation -> { + ChatMessage message = invocation.getArgument(0); + ReflectionTestUtils.setField(message, "id", id); + return message; + }); + } + + private void verifySavedSystemMessageHasNullContent() { + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatMessage.class); + verify(chatMessageRepository).save(captor.capture()); + ChatMessage saved = captor.getValue(); + assertThat(saved.getMessageType()).isEqualTo(MessageType.SYSTEM); + assertThat(saved.getContent()).isNull(); + assertThat(saved.getSenderId()).isNull(); + } } From 13eda63d7166ec4030ef19fbf297d068a9298326 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 16:10:02 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20SYSTEM=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=9D=91=EB=8B=B5=EC=97=90=EC=84=9C=20content=20?= =?UTF-8?q?=ED=82=A4=20=EC=A0=9C=EC=99=B8=20=EB=B0=8F=20@Schema=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/dto/MessageResponse.java | 22 ++++++++----------- .../realtime/service/dto/MessagePayload.java | 22 ++++++++----------- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java b/src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java index 0038fff3..993b48f5 100644 --- a/src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java +++ b/src/main/java/com/howaboutus/backend/messages/controller/dto/MessageResponse.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonInclude; import com.howaboutus.backend.messages.document.MessageType; import com.howaboutus.backend.messages.service.dto.MessageResult; @@ -15,13 +16,12 @@ public record MessageResponse( UUID roomId, Long senderId, MessageType messageType, + @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(description = """ 사람이 읽을 수 있는 메시지 본문. - SYSTEM 메시지의 경우 저장 시점의 닉네임이 그대로 박혀 있는 fallback 문장입니다. - 탈퇴·닉네임 변경 이후에도 갱신되지 않으므로, 클라이언트는 SYSTEM 메시지에 한해 - metadata.eventType + userId를 기준으로 현재 멤버 정보를 lookup하여 본문을 재구성하는 것을 - 권장합니다. 멤버 목록에 없는 userId(탈퇴/방 나감)는 "(알 수 없음)"으로 표시합니다.""") + CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE에서만 제공됩니다. + SYSTEM 메시지에서는 응답에 포함되지 않습니다(키 자체가 빠집니다).""") String content, @Schema(description = """ 상위 messageType에 따라 구조가 달라지는 확장 데이터입니다. @@ -35,16 +35,12 @@ public record MessageResponse( lat?, lng?, primary_type?, rating?, user_rating_count?, reason, google_maps_uri? }] } - PLACE_SHARE: { googlePlaceId: string, name: string, formattedAddress?: string, latitude?: number, longitude?: number, rating?: number, photoName?: string } - - SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED: { eventType, userId, nickname, - profileImageUrl? } - - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, previousHostNickname, - newHostUserId, newHostNickname } + - SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED: { eventType, userId } + - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, newHostUserId } - SYSTEM 메시지의 metadata는 클라이언트 재렌더링 계약입니다. content는 저장 시점의 - 닉네임이 박힌 fallback이고, 화면 표시 문구는 metadata의 userId를 현재 방 멤버 정보로 - lookup하여 조립합니다. - - nullable 표기된 optional 필드는 서버에서 null로 보내지 않고 metadata에서 생략됩니다.""") + SYSTEM 메시지의 표시 문구는 metadata.eventType과 userId + (또는 previousHostUserId/newHostUserId)로 클라이언트가 멤버 맵에서 lookup해 조립합니다. + 닉네임/프로필 이미지는 응답에 포함되지 않습니다.""") Map metadata, Instant createdAt ) { diff --git a/src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java b/src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java index 782dc266..ba85b266 100644 --- a/src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java +++ b/src/main/java/com/howaboutus/backend/realtime/service/dto/MessagePayload.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonInclude; import com.howaboutus.backend.messages.document.MessageType; import com.howaboutus.backend.realtime.event.MessageSentEvent; @@ -16,13 +17,12 @@ public record MessagePayload( UUID roomId, Long senderId, MessageType messageType, + @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(description = """ 사람이 읽을 수 있는 메시지 본문. - SYSTEM 메시지의 경우 저장 시점의 닉네임이 그대로 박혀 있는 fallback 문장입니다. - 탈퇴·닉네임 변경 이후에도 갱신되지 않으므로, 클라이언트는 SYSTEM 메시지에 한해 - metadata.eventType + userId를 기준으로 현재 멤버 정보를 lookup하여 본문을 재구성하는 것을 - 권장합니다. 멤버 목록에 없는 userId(탈퇴/방 나감)는 "(알 수 없음)"으로 표시합니다.""") + CHAT/AI_REQUEST/AI_RESPONSE/PLACE_SHARE에서만 제공됩니다. + SYSTEM 메시지에서는 응답에 포함되지 않습니다(키 자체가 빠집니다).""") String content, @Schema(description = """ 상위 messageType에 따라 구조가 달라지는 확장 데이터입니다. @@ -36,16 +36,12 @@ public record MessagePayload( lat?, lng?, primary_type?, reason, google_maps_uri? }] } - PLACE_SHARE: { googlePlaceId: string, name: string, formattedAddress?: string, latitude?: number, longitude?: number, rating?: number, photoName?: string } - - SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED: { eventType, userId, nickname, - profileImageUrl? } - - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, previousHostNickname, - newHostUserId, newHostNickname } + - SYSTEM MEMBER_JOINED/MEMBER_LEFT/MEMBER_KICKED: { eventType, userId } + - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, newHostUserId } - SYSTEM 메시지의 metadata는 클라이언트 재렌더링 계약입니다. content는 저장 시점의 - 닉네임이 박힌 fallback이고, 화면 표시 문구는 metadata의 userId를 현재 방 멤버 정보로 - lookup하여 조립합니다. - - nullable 표기된 optional 필드는 서버에서 null로 보내지 않고 metadata에서 생략됩니다.""") + SYSTEM 메시지의 표시 문구는 metadata.eventType과 userId + (또는 previousHostUserId/newHostUserId)로 클라이언트가 멤버 맵에서 lookup해 조립합니다. + 닉네임/프로필 이미지는 응답에 포함되지 않습니다.""") Map metadata, Instant createdAt ) { From 39dea08eba49ed460bc639256b815b5135448de2 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 16:14:33 +0900 Subject: [PATCH 06/11] =?UTF-8?q?test:=20SYSTEM=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20BSON=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=ED=82=A4=20=EB=B6=80=EC=9E=AC=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SystemMessagePayloadIntegrationTest.java | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java diff --git a/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java b/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java new file mode 100644 index 00000000..a96b10c0 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java @@ -0,0 +1,115 @@ +package com.howaboutus.backend.messages.document; + +import static org.assertj.core.api.Assertions.*; + +import java.util.UUID; + +import org.bson.Document; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; + +import com.howaboutus.backend.messages.service.SystemMessageService; +import com.howaboutus.backend.support.BaseIntegrationTest; + +class SystemMessagePayloadIntegrationTest extends BaseIntegrationTest { + + @Autowired + private SystemMessageService systemMessageService; + + @Autowired + private MongoTemplate mongoTemplate; + + @AfterEach + void cleanUp() { + mongoTemplate.getCollection("messages").deleteMany(new Document()); + } + + @Test + @DisplayName("MEMBER_JOINED 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 eventType/userId만 포함한다") + void memberJoinedSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendMemberJoinedSystemMessage(roomId, 7L); + + Document raw = mongoTemplate.getCollection("messages").find().first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata).isNotNull(); + assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); + assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_JOINED"); + assertThat(metadata.getLong("userId")).isEqualTo(7L); + } + + @Test + @DisplayName("MEMBER_LEFT 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 eventType/userId만 포함한다") + void memberLeftSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendMemberLeftSystemMessage(roomId, 7L); + + Document raw = mongoTemplate.getCollection("messages").find().first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); + assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_LEFT"); + } + + @Test + @DisplayName("MEMBER_KICKED 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 eventType/userId만 포함한다") + void memberKickedSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendMemberKickedSystemMessage(roomId, 7L); + + Document raw = mongoTemplate.getCollection("messages").find().first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); + assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_KICKED"); + } + + @Test + @DisplayName("HOST_DELEGATED 시스템 메시지의 BSON 문서에는 content 키가 없고 metadata는 식별자 3개만 포함한다") + void hostDelegatedSystemMessageBsonHasNoContentKey() { + UUID roomId = UUID.randomUUID(); + + systemMessageService.sendHostDelegatedSystemMessage(roomId, 1L, 2L); + + Document raw = mongoTemplate.getCollection("messages").find().first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isFalse(); + + Document metadata = raw.get("metadata", Document.class); + assertThat(metadata.keySet()).containsExactlyInAnyOrder( + "eventType", "previousHostUserId", "newHostUserId"); + assertThat(metadata.getString("eventType")).isEqualTo("HOST_DELEGATED"); + assertThat(metadata.getLong("previousHostUserId")).isEqualTo(1L); + assertThat(metadata.getLong("newHostUserId")).isEqualTo(2L); + } + + @Test + @DisplayName("기준선: CHAT 메시지의 BSON 문서에는 content 키가 보존된다 (회귀 가드)") + void chatMessageBsonStillHasContentKey() { + UUID roomId = UUID.randomUUID(); + ChatMessage chat = ChatMessage.chat(roomId, 42L, "안녕"); + mongoTemplate.save(chat); + + Document raw = mongoTemplate.getCollection("messages") + .find(Query.query(org.springframework.data.mongodb.core.query.Criteria + .where("messageType").is("CHAT")).getQueryObject()) + .first(); + assertThat(raw).isNotNull(); + assertThat(raw.containsKey("content")).isTrue(); + assertThat(raw.getString("content")).isEqualTo("안녕"); + } +} From f324884bd456d06838a4e6fee503993ff16be88a Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 16:19:29 +0900 Subject: [PATCH 07/11] =?UTF-8?q?docs:=20SYSTEM=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EA=B2=B0=EC=A0=95=20features.md/spec=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai/features.md | 2 +- .../specs/2026-06-08-mongo-message-payload-design.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/ai/features.md b/docs/ai/features.md index 467fdf4f..ebc24b4a 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -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 | diff --git a/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md b/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md index e34e2ae7..be54189b 100644 --- a/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md +++ b/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md @@ -1,10 +1,10 @@ # SYSTEM 메시지 MongoDB 저장 페이로드 정리 -- **상태**: 초안 +- **상태**: 구현 완료 - **날짜**: 2026-06-08 - **관련 ADR**: - 교체 대상: `docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md` - - 신규 작성 예정: `docs/ai/decisions/20260608--system-message-payload-cleanup.md` + - 신규 작성 예정: `docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md` - **워크트리/브랜치**: `.worktrees/refactor-mongo-message-payload` / `refactor/mongo-message-payload` ## 배경 @@ -137,7 +137,7 @@ private MessageResult saveSystemMessage(UUID roomId, Map metadat ## 문서/ADR -- **신규 ADR**: `docs/ai/decisions/20260608--system-message-payload-cleanup.md` +- **신규 ADR**: `docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md` - 상태: 결정 - 배경: 기존 ADR의 "백엔드 변경 없음" 결정과 컬렉션 초기화 기회. - 결정: SYSTEM 메시지는 metadata 식별자만 저장/전송, content는 저장도 응답도 하지 않는다. From 2ec604975066e86b1cd20e3050a0edce901b038d Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 16:38:31 +0900 Subject: [PATCH 08/11] =?UTF-8?q?docs:=20SYSTEM=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20MongoDB=20=EB=AC=B8=EC=84=9C=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai/erd.md | 4 ++-- .../specs/2026-06-08-mongo-message-payload-design.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ai/erd.md b/docs/ai/erd.md index 5cc34dfa..687e9ef9 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -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로 사용한다. | 필드 | 타입 | 제약조건 | 설명 | |------|------|----------|------| @@ -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 | 생성일시 | diff --git a/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md b/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md index be54189b..dba0761f 100644 --- a/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md +++ b/docs/superpowers/specs/2026-06-08-mongo-message-payload-design.md @@ -4,7 +4,7 @@ - **날짜**: 2026-06-08 - **관련 ADR**: - 교체 대상: `docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md` - - 신규 작성 예정: `docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md` + - 신규 ADR: `docs/ai/decisions/20260608-1553-system-message-payload-cleanup.md` - **워크트리/브랜치**: `.worktrees/refactor-mongo-message-payload` / `refactor/mongo-message-payload` ## 배경 From ad1c7aef0e8db2f479c33a6353d32bc6a8676461 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 16:45:07 +0900 Subject: [PATCH 09/11] =?UTF-8?q?refactor:=20members=20topic=20SYSTEM=20pa?= =?UTF-8?q?yload=20content=20=ED=82=A4=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/dto/RoomMemberPayload.java | 18 ++++++++--- .../service/RoomMemberBroadcasterTest.java | 30 +++++++++++++++++-- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/howaboutus/backend/realtime/service/dto/RoomMemberPayload.java b/src/main/java/com/howaboutus/backend/realtime/service/dto/RoomMemberPayload.java index 4e52b925..f99471a2 100644 --- a/src/main/java/com/howaboutus/backend/realtime/service/dto/RoomMemberPayload.java +++ b/src/main/java/com/howaboutus/backend/realtime/service/dto/RoomMemberPayload.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonInclude; import com.howaboutus.backend.realtime.event.MessageSentEvent; import io.swagger.v3.oas.annotations.media.Schema; @@ -14,13 +15,22 @@ public record RoomMemberPayload( String id, UUID roomId, RoomMemberEventType type, + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = """ + 사람이 읽을 수 있는 메시지 본문. + + SYSTEM 멤버 이벤트에서는 응답에 포함되지 않습니다(키 자체가 빠집니다).""") String content, @Schema(description = """ 이벤트 타입별 메타데이터: - - MEMBER_JOINED: { eventType, userId, nickname, profileImageUrl } - - MEMBER_LEFT: { eventType, userId, nickname, profileImageUrl } - - MEMBER_KICKED: { eventType, userId, nickname, profileImageUrl } - - HOST_DELEGATED: { eventType, previousHostUserId, previousHostNickname, newHostUserId, newHostNickname }""") + - MEMBER_JOINED: { eventType, userId } + - MEMBER_LEFT: { eventType, userId } + - MEMBER_KICKED: { eventType, userId } + - HOST_DELEGATED: { eventType, previousHostUserId, newHostUserId } + + 표시 문구는 metadata.eventType과 userId + (또는 previousHostUserId/newHostUserId)로 클라이언트가 멤버 맵에서 lookup해 조립합니다. + 닉네임/프로필 이미지는 응답에 포함되지 않습니다.""") Map metadata, Instant createdAt ) { diff --git a/src/test/java/com/howaboutus/backend/realtime/service/RoomMemberBroadcasterTest.java b/src/test/java/com/howaboutus/backend/realtime/service/RoomMemberBroadcasterTest.java index f88e4ef7..84968461 100644 --- a/src/test/java/com/howaboutus/backend/realtime/service/RoomMemberBroadcasterTest.java +++ b/src/test/java/com/howaboutus/backend/realtime/service/RoomMemberBroadcasterTest.java @@ -17,6 +17,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.messaging.simp.SimpMessagingTemplate; +import com.fasterxml.jackson.databind.ObjectMapper; import com.howaboutus.backend.messages.document.MessageType; import com.howaboutus.backend.realtime.event.MessageSentEvent; import com.howaboutus.backend.realtime.service.dto.RoomMemberEventType; @@ -25,6 +26,8 @@ @ExtendWith(MockitoExtension.class) class RoomMemberBroadcasterTest { + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + @Mock private SimpMessagingTemplate messagingTemplate; @@ -37,7 +40,10 @@ void broadcastsSystemMessageToMembersTopic() { UUID roomId = UUID.randomUUID(); String id = "6628f5f4c49a9f7b3772c222"; Instant createdAt = Instant.parse("2026-05-04T00:00:00Z"); - Map metadata = Map.of("eventType", "MEMBER_JOINED"); + Map metadata = Map.of( + "eventType", "MEMBER_JOINED", + "userId", 3L + ); MessageSentEvent event = new MessageSentEvent( id, @@ -46,7 +52,7 @@ void broadcastsSystemMessageToMembersTopic() { roomId, null, MessageType.SYSTEM, - "홍길동 님이 입장했습니다.", + null, metadata, createdAt ); @@ -62,12 +68,30 @@ void broadcastsSystemMessageToMembersTopic() { id, roomId, RoomMemberEventType.MEMBER_JOINED, - "홍길동 님이 입장했습니다.", + null, metadata, createdAt )); } + @Test + @DisplayName("members topic SYSTEM payload는 content 키를 직렬화하지 않는다") + void systemPayloadJsonOmitsNullContent() throws Exception { + UUID roomId = UUID.randomUUID(); + RoomMemberPayload payload = new RoomMemberPayload( + "6628f5f4c49a9f7b3772c222", + roomId, + RoomMemberEventType.MEMBER_JOINED, + null, + Map.of("eventType", "MEMBER_JOINED", "userId", 3L), + Instant.parse("2026-05-04T00:00:00Z") + ); + + String json = objectMapper.writeValueAsString(payload); + + assertThat(json).doesNotContain("\"content\""); + } + @Test @DisplayName("SYSTEM이 아닌 메시지는 무시한다") void ignoresNonSystemMessage() { From f792e8e75341f8712fa8aec4b8a1a10c84e18461 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 18:54:32 +0900 Subject: [PATCH 10/11] =?UTF-8?q?test:=20SYSTEM=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20content=20=ED=82=A4=20=EB=B6=80=EC=9E=AC=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/MessageControllerTest.java | 35 +++++++++++++++++++ .../SystemMessagePayloadIntegrationTest.java | 32 +++++++++++++---- .../service/RoomMessageBroadcasterTest.java | 22 +++++++++--- 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/src/test/java/com/howaboutus/backend/messages/controller/MessageControllerTest.java b/src/test/java/com/howaboutus/backend/messages/controller/MessageControllerTest.java index faa7d5d1..78b940e6 100644 --- a/src/test/java/com/howaboutus/backend/messages/controller/MessageControllerTest.java +++ b/src/test/java/com/howaboutus/backend/messages/controller/MessageControllerTest.java @@ -112,6 +112,41 @@ void getMessagesRoutesToAfterSequenceWhenAfterSequenceProvided() throws Exceptio verify(messageService).getMessagesAfterSequence(ROOM_ID, 123L, USER_ID, 50); } + @Test + @DisplayName("SYSTEM 메시지 조회 응답은 content 키를 포함하지 않고 식별자 metadata만 반환한다") + void getMessagesOmitsContentForSystemMessage() throws Exception { + MessageResult msg = new MessageResult( + "msg-system-001", + 125L, + null, + ROOM_ID, + null, + MessageType.SYSTEM, + null, + Map.of( + "eventType", "MEMBER_JOINED", + "userId", 3L + ), + Instant.now() + ); + given(messageService.getMessagesAfter(eq(ROOM_ID), eq("abc123"), eq(USER_ID), eq(50))) + .willReturn(List.of(msg)); + + mockMvc.perform(get("/rooms/{roomId}/messages", ROOM_ID) + .cookie(new Cookie("access_token", VALID_TOKEN)) + .param("afterId", "abc123")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("msg-system-001")) + .andExpect(jsonPath("$[0].messageType").value("SYSTEM")) + .andExpect(jsonPath("$[0].content").doesNotExist()) + .andExpect(jsonPath("$[0].metadata.eventType").value("MEMBER_JOINED")) + .andExpect(jsonPath("$[0].metadata.userId").value(3)) + .andExpect(jsonPath("$[0].metadata.nickname").doesNotExist()) + .andExpect(jsonPath("$[0].metadata.profileImageUrl").doesNotExist()); + + verify(messageService).getMessagesAfter(ROOM_ID, "abc123", USER_ID, 50); + } + @Test @DisplayName("빈 문자열 afterSequence와 유효한 afterId는 afterId 라우팅으로 처리한다") void getMessagesIgnoresBlankAfterSequenceWhenAfterIdProvided() throws Exception { diff --git a/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java b/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java index a96b10c0..d5b96ea7 100644 --- a/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java +++ b/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java @@ -6,10 +6,12 @@ import org.bson.Document; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import com.howaboutus.backend.messages.service.SystemMessageService; @@ -23,9 +25,14 @@ class SystemMessagePayloadIntegrationTest extends BaseIntegrationTest { @Autowired private MongoTemplate mongoTemplate; + @BeforeEach + void setUp() { + cleanMessages(); + } + @AfterEach void cleanUp() { - mongoTemplate.getCollection("messages").deleteMany(new Document()); + cleanMessages(); } @Test @@ -35,7 +42,7 @@ void memberJoinedSystemMessageBsonHasNoContentKey() { systemMessageService.sendMemberJoinedSystemMessage(roomId, 7L); - Document raw = mongoTemplate.getCollection("messages").find().first(); + Document raw = findSystemMessage(roomId); assertThat(raw).isNotNull(); assertThat(raw.containsKey("content")).isFalse(); @@ -53,7 +60,7 @@ void memberLeftSystemMessageBsonHasNoContentKey() { systemMessageService.sendMemberLeftSystemMessage(roomId, 7L); - Document raw = mongoTemplate.getCollection("messages").find().first(); + Document raw = findSystemMessage(roomId); assertThat(raw).isNotNull(); assertThat(raw.containsKey("content")).isFalse(); @@ -69,7 +76,7 @@ void memberKickedSystemMessageBsonHasNoContentKey() { systemMessageService.sendMemberKickedSystemMessage(roomId, 7L); - Document raw = mongoTemplate.getCollection("messages").find().first(); + Document raw = findSystemMessage(roomId); assertThat(raw).isNotNull(); assertThat(raw.containsKey("content")).isFalse(); @@ -85,7 +92,7 @@ void hostDelegatedSystemMessageBsonHasNoContentKey() { systemMessageService.sendHostDelegatedSystemMessage(roomId, 1L, 2L); - Document raw = mongoTemplate.getCollection("messages").find().first(); + Document raw = findSystemMessage(roomId); assertThat(raw).isNotNull(); assertThat(raw.containsKey("content")).isFalse(); @@ -105,11 +112,22 @@ void chatMessageBsonStillHasContentKey() { mongoTemplate.save(chat); Document raw = mongoTemplate.getCollection("messages") - .find(Query.query(org.springframework.data.mongodb.core.query.Criteria - .where("messageType").is("CHAT")).getQueryObject()) + .find(Query.query(Criteria.where("roomId").is(roomId) + .and("messageType").is("CHAT")).getQueryObject()) .first(); assertThat(raw).isNotNull(); assertThat(raw.containsKey("content")).isTrue(); assertThat(raw.getString("content")).isEqualTo("안녕"); } + + private Document findSystemMessage(UUID roomId) { + return mongoTemplate.getCollection("messages") + .find(Query.query(Criteria.where("roomId").is(roomId) + .and("messageType").is("SYSTEM")).getQueryObject()) + .first(); + } + + private void cleanMessages() { + mongoTemplate.getCollection("messages").deleteMany(new Document()); + } } diff --git a/src/test/java/com/howaboutus/backend/realtime/service/RoomMessageBroadcasterTest.java b/src/test/java/com/howaboutus/backend/realtime/service/RoomMessageBroadcasterTest.java index ff1853cc..cf86e6ac 100644 --- a/src/test/java/com/howaboutus/backend/realtime/service/RoomMessageBroadcasterTest.java +++ b/src/test/java/com/howaboutus/backend/realtime/service/RoomMessageBroadcasterTest.java @@ -17,6 +17,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.messaging.simp.SimpMessagingTemplate; +import com.fasterxml.jackson.databind.ObjectMapper; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.messages.document.MessageType; import com.howaboutus.backend.realtime.event.MessageRateLimitedEvent; @@ -29,6 +30,8 @@ @ExtendWith(MockitoExtension.class) class RoomMessageBroadcasterTest { + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + @Mock private SimpMessagingTemplate messagingTemplate; @@ -98,8 +101,12 @@ void sendsRateLimitedEventToUserQueue() { @Test @DisplayName("SYSTEM 메시지도 messages topic으로 브로드캐스트한다") - void broadcastsSystemMessage() { + void broadcastsSystemMessage() throws Exception { UUID roomId = UUID.randomUUID(); + Map metadata = Map.of( + "eventType", "MEMBER_JOINED", + "userId", 3L + ); MessageSentEvent event = new MessageSentEvent( "mongo-id-1", 2L, @@ -107,16 +114,21 @@ void broadcastsSystemMessage() { roomId, null, MessageType.SYSTEM, - "테스트님이 방에 참여했습니다", - Map.of("eventType", "MEMBER_JOINED"), + null, + metadata, Instant.parse("2026-05-04T00:00:00Z") ); broadcaster.handleMessageSent(event); + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(MessagePayload.class); verify(messagingTemplate).convertAndSend( - "/topic/rooms/" + roomId + "/messages", - MessagePayload.from(event) + eq("/topic/rooms/" + roomId + "/messages"), + payloadCaptor.capture() ); + MessagePayload payload = payloadCaptor.getValue(); + assertThat(payload.content()).isNull(); + assertThat(payload.metadata()).isEqualTo(metadata); + assertThat(objectMapper.writeValueAsString(payload)).doesNotContain("\"content\""); } } From ba1731260dde6bd6a7c80688a7e51049b785bea2 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 19:28:19 +0900 Subject: [PATCH 11/11] =?UTF-8?q?test:=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=8E=98=EC=9D=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messages/controller/MessageControllerTest.java | 13 +++++++------ .../SystemMessagePayloadIntegrationTest.java | 2 ++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/howaboutus/backend/messages/controller/MessageControllerTest.java b/src/test/java/com/howaboutus/backend/messages/controller/MessageControllerTest.java index 78b940e6..606ac5b7 100644 --- a/src/test/java/com/howaboutus/backend/messages/controller/MessageControllerTest.java +++ b/src/test/java/com/howaboutus/backend/messages/controller/MessageControllerTest.java @@ -38,6 +38,7 @@ class MessageControllerTest { private static final UUID ROOM_ID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final Instant FIXED_CREATED_AT = Instant.parse("2020-01-01T00:00:00Z"); private static final Long USER_ID = 1L; private static final String VALID_TOKEN = "valid-jwt"; @Autowired @@ -98,7 +99,7 @@ void getMessagesReturns400WhenBeforeIdAndAfterSequenceProvided() throws Exceptio void getMessagesRoutesToAfterSequenceWhenAfterSequenceProvided() throws Exception { MessageResult msg = new MessageResult( "msg-004", 124L, null, ROOM_ID, USER_ID, - MessageType.CHAT, "누락 복구", Map.of(), Instant.now()); + MessageType.CHAT, "누락 복구", Map.of(), FIXED_CREATED_AT); given(messageService.getMessagesAfterSequence(eq(ROOM_ID), eq(123L), eq(USER_ID), eq(50))) .willReturn(List.of(msg)); @@ -127,7 +128,7 @@ void getMessagesOmitsContentForSystemMessage() throws Exception { "eventType", "MEMBER_JOINED", "userId", 3L ), - Instant.now() + FIXED_CREATED_AT ); given(messageService.getMessagesAfter(eq(ROOM_ID), eq("abc123"), eq(USER_ID), eq(50))) .willReturn(List.of(msg)); @@ -152,7 +153,7 @@ void getMessagesOmitsContentForSystemMessage() throws Exception { void getMessagesIgnoresBlankAfterSequenceWhenAfterIdProvided() throws Exception { MessageResult msg = new MessageResult( "msg-005", 12L, null, ROOM_ID, USER_ID, - MessageType.CHAT, "기존 cursor", Map.of(), Instant.now()); + MessageType.CHAT, "기존 cursor", Map.of(), FIXED_CREATED_AT); given(messageService.getMessagesAfter(eq(ROOM_ID), eq("abc123"), eq(USER_ID), eq(50))) .willReturn(List.of(msg)); @@ -207,7 +208,7 @@ void getMessagesReturns400WhenAfterSequenceIsNegative() throws Exception { void getMessagesRoutesToBeforeWhenBeforeIdProvided() throws Exception { MessageResult msg = new MessageResult( "msg-001", 10L, null, ROOM_ID, USER_ID, - MessageType.CHAT, "안녕", Map.of(), Instant.now()); + MessageType.CHAT, "안녕", Map.of(), FIXED_CREATED_AT); given(messageService.getMessagesBefore(eq(ROOM_ID), eq("def456"), eq(USER_ID), eq(50))) .willReturn(List.of(msg)); @@ -226,7 +227,7 @@ void getMessagesRoutesToBeforeWhenBeforeIdProvided() throws Exception { void getMessagesRoutesToAfterWhenAfterIdProvided() throws Exception { MessageResult msg = new MessageResult( "msg-002", 11L, null, ROOM_ID, USER_ID, - MessageType.CHAT, "반가워", Map.of(), Instant.now()); + MessageType.CHAT, "반가워", Map.of(), FIXED_CREATED_AT); given(messageService.getMessagesAfter(eq(ROOM_ID), eq("abc123"), eq(USER_ID), eq(50))) .willReturn(List.of(msg)); @@ -245,7 +246,7 @@ void getMessagesRoutesToAfterWhenAfterIdProvided() throws Exception { void getMessagesIgnoresBlankAfterIdWhenBeforeIdProvided() throws Exception { MessageResult msg = new MessageResult( "msg-003", 9L, null, ROOM_ID, USER_ID, - MessageType.CHAT, "과거 메시지", Map.of(), Instant.now()); + MessageType.CHAT, "과거 메시지", Map.of(), FIXED_CREATED_AT); given(messageService.getMessagesBefore(eq(ROOM_ID), eq("def456"), eq(USER_ID), eq(50))) .willReturn(List.of(msg)); diff --git a/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java b/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java index d5b96ea7..9f39ad50 100644 --- a/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java +++ b/src/test/java/com/howaboutus/backend/messages/document/SystemMessagePayloadIntegrationTest.java @@ -67,6 +67,7 @@ void memberLeftSystemMessageBsonHasNoContentKey() { Document metadata = raw.get("metadata", Document.class); assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_LEFT"); + assertThat(metadata.getLong("userId")).isEqualTo(7L); } @Test @@ -83,6 +84,7 @@ void memberKickedSystemMessageBsonHasNoContentKey() { Document metadata = raw.get("metadata", Document.class); assertThat(metadata.keySet()).containsExactlyInAnyOrder("eventType", "userId"); assertThat(metadata.getString("eventType")).isEqualTo("MEMBER_KICKED"); + assertThat(metadata.getLong("userId")).isEqualTo(7L); } @Test