Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8d1be30
docs: SYSTEM 메시지 metadata 기반 렌더링 계약 명문화
parkjuyeong0312 Jun 7, 2026
4730166
docs: ERD/spec의 TIMESTAMP 표기를 실제 DDL에 맞춰 TIMESTAMP WITH TIME ZONE으로 통일
parkjuyeong0312 Jun 7, 2026
5e0ff8d
fix: 회원 탈퇴와 입장 승인 사이의 race 차단
parkjuyeong0312 Jun 7, 2026
9261654
fix: 탈퇴 시 익명화 누락을 DB CHECK로 강제 + DROP CONSTRAINT 방어
parkjuyeong0312 Jun 7, 2026
383b8bd
docs : roomMember entity에 대해 status필드 추가 설계/기획 문서 작성
parkjuyeong0312 Jun 7, 2026
76b4cec
feat: V1.8 마이그레이션으로 room_members status/left_at 도입
parkjuyeong0312 Jun 8, 2026
240cd5a
feat: RoomMember에 MemberStatus(LEFT) + 도메인 메서드 추가
parkjuyeong0312 Jun 8, 2026
8250358
feat: requireActiveMember에서 LEFT 멤버를 비멤버로 차단
parkjuyeong0312 Jun 8, 2026
0fa5875
refactor: leave가 hard delete 대신 status=LEFT 전환을 수행
parkjuyeong0312 Jun 8, 2026
0244ec0
refactor: kick이 hard delete 대신 status=LEFT 전환을 수행
parkjuyeong0312 Jun 8, 2026
c1c9164
refactor: 활성 멤버 쿼리에 status=ACTIVE 필터 일괄 추가
parkjuyeong0312 Jun 8, 2026
b0b39f5
feat: 멤버 응답 DTO에 status 필드 노출
parkjuyeong0312 Jun 8, 2026
f5f5b02
feat: getMembers가 LEFT 멤버까지 노출하고 online=false 강제
parkjuyeong0312 Jun 8, 2026
2b7a024
feat: requestJoin이 LEFT row를 PENDING으로 부활시키도록 지원
parkjuyeong0312 Jun 8, 2026
b5b6aa7
fix: AI 컨텍스트 조회에서 LEFT 멤버 제외
parkjuyeong0312 Jun 8, 2026
c758831
test: V1.8 CHECK 제약(LEFT/left_at/PENDING) 회귀 테스트 추가
parkjuyeong0312 Jun 8, 2026
760db81
style: V1.8 CHECK 테스트 SQL 줄바꿈으로 line-length-120 위반 해소
parkjuyeong0312 Jun 8, 2026
cc39df7
test: 탈퇴 시 LEFT 멤버십도 hard delete 되는지 회귀 테스트 추가
parkjuyeong0312 Jun 8, 2026
4514a95
docs: room_members status/left_at 도입을 features/erd에 반영
parkjuyeong0312 Jun 8, 2026
94a7a76
refactor : 리뷰내용 반영(방나가기 중복 이벤트 차단, 방나감 에러코드 설정, 테스트추가
parkjuyeong0312 Jun 8, 2026
File filter

Filter by extension

Filter by extension

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

- **상태**: 결정
- **날짜**: 2026-06-07
- **관련**: [20260607-user-withdrawal-soft-delete.md](20260607-user-withdrawal-soft-delete.md)

## 배경

SYSTEM 메시지(`MEMBER_JOINED`, `MEMBER_LEFT`, `MEMBER_KICKED`, `HOST_DELEGATED`)는 MongoDB `messages` 컬렉션에 사람 읽기 가능한 문장으로 `content`가 저장된다. 예: `"박주영님이 방을 나갔습니다"`.

회원 탈퇴(soft delete)가 도입되면서, 탈퇴한 사용자의 일반 채팅 메시지는 프론트가 방 멤버 맵에 senderId가 없는 것으로 판단해 `(알 수 없음)`으로 잘 렌더링하고 있다. 그러나 SYSTEM 메시지는 `msg.content`를 그대로 노출하기 때문에 탈퇴 이후에도 옛 닉네임이 굳은 채로 표시된다. 동일 사용자가 같은 화면에서 채팅은 `(알 수 없음)`, 시스템 메시지는 `박주영`으로 보이는 비일관 상태가 발생한다.

가능한 해결 방향은 세 가지였다.

1. **클라이언트가 metadata로 재렌더링**: 서버는 변경 없음. 클라가 `metadata.eventType + userId`를 보고 멤버 맵을 lookup해 문구를 조립.
2. **서버가 조회 시점에 content 재구성**: `MessageResult` 변환 시 userId로 현재 사용자 상태 조회 후 content를 갱신. 페이지 조회마다 user batch lookup 필요, N+1·캐시 부담.
3. **탈퇴 이벤트에서 일괄 update**: 탈퇴 핸들러가 해당 userId가 포함된 SYSTEM 메시지를 한 번에 갱신. 조회 비용 0, 그러나 MongoDB write 부담과 닉네임 변경 같은 다른 케이스를 흡수하지 못함.

이미 `SystemMessageService`가 모든 이벤트에 `eventType`, `userId`(+ `previousHostUserId`/`newHostUserId`), `nickname`을 metadata로 함께 저장하고 있고, 프론트(`src/lib/chat.ts`)에는 일반 채팅용 `memberMap` 기반 `(알 수 없음)` 처리 로직이 이미 있다.

## 결정

**SYSTEM 메시지의 화면 표시 문구는 클라이언트가 `metadata` 기반으로 조립한다.** 백엔드는 저장 구조를 그대로 유지하고, metadata payload를 클라이언트 재렌더링 계약으로 공식화한다.

- 백엔드: `ChatMessage.content`와 `SystemMessageService` 로직은 변경하지 않는다. `content`는 저장 시점 닉네임이 박힌 fallback 문장이며, legacy 클라이언트와 디버깅용으로 의미가 있다.
- 백엔드: `MessagePayload`(STOMP)와 `MessageResponse`(REST)의 `content`/`metadata` 필드 `@Schema`에 위 계약을 명시한다.
- 프론트엔드: `toUiMessage`의 `kind === "SYSTEM"` 분기에서 `metadata.eventType`별 템플릿을 적용한다. userId를 `memberMap`에서 lookup하여 닉네임을 결정하고, 없으면 `(알 수 없음)`. eventType이 미지/누락이면 `msg.content`를 그대로 사용.

이벤트별 템플릿(프론트엔드 가이드):

| eventType | metadata lookup | 화면 표시 |
|---|---|---|
| `MEMBER_JOINED` | `userId` | `{nick}님이 방에 참여했습니다` |
| `MEMBER_KICKED` | `userId` | `{nick}님이 방에서 내보내졌습니다` |
| `MEMBER_LEFT` | `userId` | `{nick}님이 방을 나갔습니다` |
| `HOST_DELEGATED` | `previousHostUserId`, `newHostUserId` | `{prevNick}님이 {newNick}님에게 방장을 위임했습니다` |

`{nick}` 결정 순서:

1. `memberMap.get(userId)?.nickname` 사용
2. memberMap에 없으면 `(알 수 없음)` (탈퇴·방 나감 공통)
3. metadata가 비정상이거나 eventType이 알 수 없는 값이면 `msg.content`를 그대로 표시

## 영향

- 백엔드 데이터 변경 없음. MongoDB 마이그레이션 없음.
- `MessagePayload`, `MessageResponse`의 `@Schema` 설명 갱신 → Swagger/Springwolf 명세에 반영.
- 프론트엔드는 별도 작업으로 `toUiMessage`의 SYSTEM 분기를 metadata 기반 렌더링으로 교체한다.
- 향후 닉네임 변경 기능이 도입되어도 동일 계약으로 자동 흡수된다.
- 다국어가 필요해질 때 템플릿이 클라이언트에 있으므로 서버 변경 없이 대응 가능하다.
4 changes: 2 additions & 2 deletions docs/ai/decisions/20260607-user-withdrawal-soft-delete.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
`users`를 soft delete 모델로 운영한다.

- 활성 회원: `email`, `nickname`, `provider`, `provider_id`는 NOT NULL.
- 탈퇴 회원: 위 컬럼들 NULL 허용. `deleted_at`이 NOT NULL.
- 탈퇴 회원: 위 4개 컬럼 모두 NULL 강제. `deleted_at`이 NOT NULL.
- 활성 회원에 한해 unique 강제: PostgreSQL partial unique index (`WHERE deleted_at IS NULL`).
- 조건부 NOT NULL은 CHECK `users_active_required`로 보장.
- 활성/탈퇴 상태와 4개 개인정보 컬럼의 양방향 정합은 CHECK `users_active_required`로 보장. 어플리케이션 `User.anonymize()` 누락 또는 운영자의 수동 SQL로 익명화 안 된 탈퇴 행이 생기는 경로를 DB 레벨에서 차단한다.
- 엔티티에 `@SQLRestriction("deleted_at IS NULL")`을 적용해 JPA 조회에서 탈퇴자를 자동 제외.

## 대안 검토
Expand Down
43 changes: 24 additions & 19 deletions docs/ai/erd.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ Google OAuth 기반 사용자 정보
| profile_image_url | VARCHAR(500) | NULLABLE | 프로필 이미지 URL |
| provider | VARCHAR(20) | 활성 회원 NOT NULL | OAuth 제공자. 탈퇴 시 NULL |
| provider_id | VARCHAR(255) | 활성 회원 NOT NULL, 활성 회원 간 provider와 조합 UNIQUE | OAuth 제공자 측 사용자 ID. 탈퇴 시 NULL |
| deleted_at | TIMESTAMP | NULLABLE | 탈퇴 시각. NOT NULL이면 익명화된 탈퇴 회원 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 가입일시 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 |
| deleted_at | TIMESTAMP WITH TIME ZONE | NULLABLE | 탈퇴 시각. NOT NULL이면 익명화된 탈퇴 회원 |
| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 가입일시 |
| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 |

**제약:**
- CHECK `users_active_required`: `deleted_at IS NOT NULL OR (email IS NOT NULL AND nickname IS NOT NULL AND provider IS NOT NULL AND provider_id IS NOT NULL)`
- CHECK `users_active_required`: `(deleted_at IS NULL AND email IS NOT NULL AND nickname IS NOT NULL AND provider IS NOT NULL AND provider_id IS NOT NULL) OR (deleted_at IS NOT NULL AND email IS NULL AND nickname IS NULL AND provider IS NULL AND provider_id IS NULL)` — 활성/탈퇴 상태와 4개 개인정보 컬럼의 양방향 정합을 강제 (탈퇴 시 익명화 누락 방지)
- partial unique index `users_email_unique_active`: `email` WHERE `deleted_at IS NULL`
- partial unique index `users_provider_provider_id_unique_active`: `(provider, provider_id)` WHERE `deleted_at IS NULL`

Expand All @@ -47,8 +47,8 @@ Google OAuth 기반 사용자 정보
| end_date | DATE | NOT NULL | 여행 종료일 |
| invite_code | VARCHAR(50) | UNIQUE, NOT NULL | 초대 링크용 고정 코드 (방 생성 시 자동 발급) |
| created_by | BIGINT | 사용자 ID 참조, NOT NULL | 방 생성자 (현재 구현은 users.id 값을 보관하지만 DB FK 제약은 두지 않음) |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 생성일시 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 |
| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 생성일시 |
| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 |

> 방 1개당 초대 코드 1개가 고정됩니다. 링크 유출 시 방장이 invite_code를 재발급(갱신)하는 방식으로 대응합니다. 만료 시간, 사용 횟수 제한 등 세밀한 초대 관리가 필요해지면 별도 room_invitations 테이블로 분리를 검토합니다.

Expand All @@ -64,14 +64,19 @@ Google OAuth 기반 사용자 정보
| room_id | UUID | FK → rooms.id, NOT NULL | |
| user_id | BIGINT | FK → users.id, NOT NULL | |
| role | VARCHAR(20) | NOT NULL | HOST / MEMBER / PENDING (DEFAULT 없이 명시적 지정) |
| joined_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 참여 일시 |
| joined_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 참여 일시 |
| status | VARCHAR(20) | NOT NULL, DEFAULT 'ACTIVE' | ACTIVE / LEFT (방 나가기·추방 시 LEFT, row는 유지하여 과거 메시지 작성자 노출용으로 사용) |
| left_at | TIMESTAMP WITH TIME ZONE | NULLABLE | LEFT 진입 시각. ACTIVE면 NULL. 재입장 UPSERT 시 NULL로 복귀 |
| last_read_message_id | VARCHAR(24) | NULLABLE | 마지막으로 읽은 MongoDB 메시지 `_id` |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 생성일시 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 |
| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 생성일시 |
| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 |

**제약:** UNIQUE(room_id, user_id)
**제약:** UNIQUE(room_id, user_id),
CHECK `ck_room_members_status` (status IN ('ACTIVE','LEFT')),
CHECK `ck_room_members_status_left_at` ((status='ACTIVE' AND left_at IS NULL) OR (status='LEFT' AND left_at IS NOT NULL)),
CHECK `ck_room_members_left_role` (status='ACTIVE' OR role <> 'PENDING')

**인덱스:** UNIQUE(room_id, user_id) 제약 인덱스, (user_id, joined_at DESC) — 내 방 목록 조회 및 커서 정렬용
**인덱스:** UNIQUE(room_id, user_id) 제약 인덱스, (user_id, joined_at DESC) — 내 방 목록 조회 및 커서 정렬용, (room_id, status) — 멤버 목록 조회 및 LEFT 룩업용

---

Expand Down Expand Up @@ -130,8 +135,8 @@ AI_RESPONSE metadata는 `requestMessageId`, `intent`, `placeRecommendation`, `co
| name | VARCHAR(50) | NOT NULL | 방 내 카테고리 이름 |
| color_code | VARCHAR(7) | NOT NULL | 카테고리 색상 코드 (`#RRGGBB`) |
| created_by | BIGINT | 사용자 ID 참조, NULL 가능 | 생성한 사용자 (현재는 인증 연동 전이라 임시로 nullable) |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 생성일시 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 |
| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 생성일시 |
| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 |

**제약:** UNIQUE(room_id, name)

Expand All @@ -152,8 +157,8 @@ AI_RESPONSE metadata는 `requestMessageId`, `intent`, `placeRecommendation`, `co
| category_id | BIGINT | FK → bookmark_categories.id, NOT NULL | 현재 방 소속 카테고리 |
| google_place_id | VARCHAR(300) | NOT NULL | Google Place ID |
| added_by | BIGINT | 사용자 ID 참조, NULL 가능 | 등록한 사용자 (현재는 인증 연동 전이라 임시로 nullable) |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 생성일시 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 |
| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 생성일시 |
| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 |

**제약:** UNIQUE(room_id, google_place_id, category_id)

Expand All @@ -170,8 +175,8 @@ AI_RESPONSE metadata는 `requestMessageId`, `intent`, `placeRecommendation`, `co
| id | BIGINT | PK, AUTO_INCREMENT | |
| room_id | UUID | FK → rooms.id, NOT NULL | |
| day_number | INT | NOT NULL | 여행 N일차 (1부터 시작). 응답 날짜는 `rooms.start_date + day_number - 1`로 계산 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 생성일시 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 |
| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 생성일시 |
| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 |

**제약:** UNIQUE(room_id, day_number) DEFERRABLE INITIALLY IMMEDIATE

Expand All @@ -192,8 +197,8 @@ AI_RESPONSE metadata는 `requestMessageId`, `intent`, `placeRecommendation`, `co
| duration_minutes | INT | NULLABLE | 체류 시간(분). 값이 있으면 0~1000 |
| order_index | INT | NOT NULL | 정렬 순서 (목록 조회 시 0부터 연속 유지) |
| memo | TEXT | NULLABLE | 방문 메모 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 생성일시 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 |
| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 생성일시 |
| updated_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT NOW() | 수정일시 |

**인덱스:** (schedule_id, order_index), (google_place_id)

Expand Down
8 changes: 4 additions & 4 deletions docs/ai/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭
| `[x]` | 토큰 재발급 (Refresh) | Refresh Token Rotation: UUID 기반 HTTP-only 쿠키(path=/auth/refresh), Redis `refresh:token:{uuid}`→userId(TTL 14일) / `refresh:user:{userId}`→Set\<uuid\>. Replay Detection 으로 탈취 시 전체 무효화 | Redis |
| `[x]` | 로그아웃 | 단일 기기 로그아웃: 요청한 토큰만 삭제 | Redis |
| `[x]` | 내 정보 조회 | 로그인된 사용자 프로필 조회 | users |
| `[x]` | 회원 탈퇴 | `DELETE /users/me`. users는 soft delete + 익명화(email/nickname/profile/provider/provider_id NULL, deleted_at 설정), room_members는 hard delete. HOST 방은 사전 위임 필요(422 + roomsRequiringDelegation), 1인 HOST 방은 자동 hard delete. Redis RTK는 AFTER_COMMIT에서 일괄 폐기. 채팅/북마크/일정의 BIGINT 작성자 ID는 유지하며 클라이언트는 members에 없는 ID를 "(알 수 없음)"으로 표시 | users, room_members, Redis |
| `[x]` | 회원 탈퇴 | `DELETE /users/me`. users는 soft delete + 익명화(email/nickname/profile/provider/provider_id NULL, deleted_at 설정), room_members는 ACTIVE/LEFT 무관하게 hard delete. HOST 방은 사전 위임 필요(422 + roomsRequiringDelegation), 1인 HOST 방은 자동 hard delete. Redis RTK는 AFTER_COMMIT에서 일괄 폐기. 채팅/북마크/일정의 BIGINT 작성자 ID는 유지. 클라이언트는 `GET /rooms/{id}/members` 응답(ACTIVE + LEFT 모두 포함)에서 닉네임/프로필을 조회하고, 그래도 members에 없는 ID는 **회원 탈퇴자**이며 "(알 수 없음)"으로 표시 | users, room_members, Redis |

---

Expand All @@ -80,9 +80,9 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭

| 상태 | 기능 | 설명 | ERD 연관 |
|------|------|------|----------|
| `[x]` | 방 멤버 목록 조회 | 방 참여자 목록 + 역할(HOST/MEMBER) + 접속 상태 | room_members |
| `[x]` | 멤버 추방 | HOST가 특정 멤버 추방 (HOST는 추방 불가). 추방 당사자에게 `/user/queue/rooms`로 개인 알림 전송 | room_members |
| `[x]` | 방 나가기 | 본인이 방에서 탈퇴 | room_members |
| `[x]` | 방 멤버 목록 조회 | 방 참여자 목록 + 역할(HOST/MEMBER) + 상태(ACTIVE/LEFT) + 접속 상태. LEFT 멤버는 닉네임/프로필 조회용으로 함께 반환되며 `online`은 항상 false. AI 컨텍스트에서는 LEFT 멤버 제외 | room_members |
| `[x]` | 멤버 추방 | HOST가 특정 멤버 추방 (HOST는 추방 불가). row는 status=LEFT 로 유지(과거 메시지 작성자 노출), 추방 당사자에게 `/user/queue/rooms`로 개인 알림 전송 | room_members |
| `[x]` | 방 나가기 | 본인이 방에서 탈퇴(room_members status=LEFT, row 유지). 채팅 로그(USER_LEFT 시스템 메시지)가 시점의 정본. LEFT 상태에서 invite code 재요청 시 같은 row가 ACTIVE/PENDING 으로 부활 | room_members |
| `[x]` | 실시간 방 접속 상태 추적 | 유효한 access_token 쿠키가 있는 사용자만 WebSocket handshake를 허용한다. SockJS + STOMP 방 topic 구독 성공 시 Redis에 접속 유저를 기록하고 접속 이벤트를 브로드캐스트한다. 새로 온라인이 된 유저의 접속 이벤트에는 `userId`, `nickname`, `profileImageUrl`을 포함해 클라이언트가 방 멤버 프로필 맵을 갱신할 수 있게 한다. 세션 종료 시 제거와 해제 이벤트를 브로드캐스트한다 | Redis (connected_users) |
| `[x]` | 현재 접속 중인 유저 조회 | 멤버 목록 API(`GET /rooms/{roomId}/members`)의 `isOnline` 필드로 접속 상태 포함 | Redis (connected_users) |
| `[x]` | 방장 위임 | HOST가 특정 MEMBER에게 방장 권한 위임 (PATCH /rooms/{roomId}/host) | room_members |
Expand Down
Loading