From 8d1be302bd11b2edbec7ad5a1220587c974d6311 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 16:39:21 +0900 Subject: [PATCH 01/20] =?UTF-8?q?docs:=20SYSTEM=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20metadata=20=EA=B8=B0=EB=B0=98=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EA=B3=84=EC=95=BD=20=EB=AA=85=EB=AC=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탈퇴 사용자의 SYSTEM 메시지 본문이 옛 닉네임으로 굳는 비일관 문제를 백엔드 데이터 변경 없이 클라이언트 재렌더링으로 해결한다는 결정을 ADR로 기록하고, MessagePayload와 MessageResponse의 content/metadata Schema 설명을 보강해 프론트가 metadata.eventType + userId 기반으로 본문을 조립한다는 계약을 Swagger/Springwolf 명세에 노출한다. --- ...-1636-system-message-metadata-rendering.md | 50 +++++++++++++++++++ .../controller/dto/MessageResponse.java | 11 ++++ .../realtime/service/dto/MessagePayload.java | 11 ++++ 3 files changed, 72 insertions(+) create mode 100644 docs/ai/decisions/20260607-1636-system-message-metadata-rendering.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 new file mode 100644 index 00000000..69a34bc6 --- /dev/null +++ b/docs/ai/decisions/20260607-1636-system-message-metadata-rendering.md @@ -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 기반 렌더링으로 교체한다. +- 향후 닉네임 변경 기능이 도입되어도 동일 계약으로 자동 흡수된다. +- 다국어가 필요해질 때 템플릿이 클라이언트에 있으므로 서버 변경 없이 대응 가능하다. 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 6c707733..0038fff3 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 @@ -15,6 +15,13 @@ public record MessageResponse( UUID roomId, Long senderId, MessageType messageType, + @Schema(description = """ + 사람이 읽을 수 있는 메시지 본문. + + SYSTEM 메시지의 경우 저장 시점의 닉네임이 그대로 박혀 있는 fallback 문장입니다. + 탈퇴·닉네임 변경 이후에도 갱신되지 않으므로, 클라이언트는 SYSTEM 메시지에 한해 + metadata.eventType + userId를 기준으로 현재 멤버 정보를 lookup하여 본문을 재구성하는 것을 + 권장합니다. 멤버 목록에 없는 userId(탈퇴/방 나감)는 "(알 수 없음)"으로 표시합니다.""") String content, @Schema(description = """ 상위 messageType에 따라 구조가 달라지는 확장 데이터입니다. @@ -33,6 +40,10 @@ public record MessageResponse( - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, previousHostNickname, newHostUserId, newHostNickname } + SYSTEM 메시지의 metadata는 클라이언트 재렌더링 계약입니다. content는 저장 시점의 + 닉네임이 박힌 fallback이고, 화면 표시 문구는 metadata의 userId를 현재 방 멤버 정보로 + lookup하여 조립합니다. + nullable 표기된 optional 필드는 서버에서 null로 보내지 않고 metadata에서 생략됩니다.""") 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 6dafd76b..782dc266 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 @@ -16,6 +16,13 @@ public record MessagePayload( UUID roomId, Long senderId, MessageType messageType, + @Schema(description = """ + 사람이 읽을 수 있는 메시지 본문. + + SYSTEM 메시지의 경우 저장 시점의 닉네임이 그대로 박혀 있는 fallback 문장입니다. + 탈퇴·닉네임 변경 이후에도 갱신되지 않으므로, 클라이언트는 SYSTEM 메시지에 한해 + metadata.eventType + userId를 기준으로 현재 멤버 정보를 lookup하여 본문을 재구성하는 것을 + 권장합니다. 멤버 목록에 없는 userId(탈퇴/방 나감)는 "(알 수 없음)"으로 표시합니다.""") String content, @Schema(description = """ 상위 messageType에 따라 구조가 달라지는 확장 데이터입니다. @@ -34,6 +41,10 @@ public record MessagePayload( - SYSTEM HOST_DELEGATED: { eventType, previousHostUserId, previousHostNickname, newHostUserId, newHostNickname } + SYSTEM 메시지의 metadata는 클라이언트 재렌더링 계약입니다. content는 저장 시점의 + 닉네임이 박힌 fallback이고, 화면 표시 문구는 metadata의 userId를 현재 방 멤버 정보로 + lookup하여 조립합니다. + nullable 표기된 optional 필드는 서버에서 null로 보내지 않고 metadata에서 생략됩니다.""") Map metadata, Instant createdAt From 473016674e5ad098bc944b5d2a55b77bbe088a06 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 16:45:45 +0900 Subject: [PATCH 02/20] =?UTF-8?q?docs:=20ERD/spec=EC=9D=98=20TIMESTAMP=20?= =?UTF-8?q?=ED=91=9C=EA=B8=B0=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20DDL=EC=97=90?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=B0=20TIMESTAMP=20WITH=20TIME=20ZONE=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모든 마이그레이션이 TIMESTAMP WITH TIME ZONE으로 컬럼을 생성하는데 ERD와 user-withdrawal spec은 짧은 TIMESTAMP로 표기되어 있어 운영/개발 해석에 혼선이 생길 수 있었다. erd.md 16개 라인과 spec의 ALTER 예시를 실제 DDL과 1:1로 정렬한다. --- docs/ai/erd.md | 32 +++++++++---------- .../2026-06-07-user-withdrawal-design.md | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/ai/erd.md b/docs/ai/erd.md index 3b0ec66e..791fee5b 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -19,9 +19,9 @@ 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)` @@ -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 테이블로 분리를 검토합니다. @@ -64,10 +64,10 @@ 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() | 참여 일시 | | 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) @@ -130,8 +130,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) @@ -152,8 +152,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) @@ -170,8 +170,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 @@ -192,8 +192,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) diff --git a/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md b/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md index 216311b7..ec554b37 100644 --- a/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md +++ b/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md @@ -48,7 +48,7 @@ ## 4. DB 스키마 변경 (`users`) ```sql -ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL; +ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP WITH TIME ZONE; ALTER TABLE users ALTER COLUMN email DROP NOT NULL; ALTER TABLE users ALTER COLUMN nickname DROP NOT NULL; From 5e0ff8db38045d9eff0bd52804e2d8da80e138be Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 20:34:24 +0900 Subject: [PATCH 03/20] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=EC=99=80=20=EC=9E=85=EC=9E=A5=20=EC=8A=B9=EC=9D=B8=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=9D=98=20race=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 호스트 본인이 PENDING 입장 승인과 본인 탈퇴를 동시에 발사하면 READ COMMITTED 스냅샷 차이로 솔로 호스트 방 판단 → HOST 멤버십만 삭제 → 호스트 없는 활성 방(orphan room)이 남는 시나리오가 가능했다. withdraw 트랜잭션 시작 시 본인이 HOST인 모든 Room을 PESSIMISTIC_WRITE로 잠그고, approve 트랜잭션 시작 시에도 Room을 findByIdForUpdate로 잡아 두 트랜잭션이 같은 Room 행을 두고 직렬화되도록 한다. --- .../backend/rooms/repository/RoomRepository.java | 13 +++++++++++++ .../backend/rooms/service/RoomInviteService.java | 4 +++- .../backend/user/service/UserWithdrawalService.java | 3 +++ .../rooms/service/RoomInviteServiceTest.java | 8 ++++---- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/howaboutus/backend/rooms/repository/RoomRepository.java b/src/main/java/com/howaboutus/backend/rooms/repository/RoomRepository.java index bc187c34..2bdfa825 100644 --- a/src/main/java/com/howaboutus/backend/rooms/repository/RoomRepository.java +++ b/src/main/java/com/howaboutus/backend/rooms/repository/RoomRepository.java @@ -1,5 +1,6 @@ package com.howaboutus.backend.rooms.repository; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -19,4 +20,16 @@ public interface RoomRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select r from Room r where r.id = :roomId") Optional findByIdForUpdate(@Param("roomId") UUID roomId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select r from Room r + where r.id in ( + select m.room.id from RoomMember m + where m.user.id = :userId + and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + ) + order by r.id + """) + List lockHostRoomsByUser(@Param("userId") Long userId); } diff --git a/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java b/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java index ca681d9b..20cd4319 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java @@ -126,7 +126,9 @@ public List getJoinRequests(UUID roomId, Long userId) { @Loggable @Transactional public void approve(UUID roomId, Long requestId, Long userId) { - requireActiveRoomExists(roomId); + // Room을 잠가 호스트의 동시 탈퇴 트랜잭션과 승격이 직렬화되도록 한다. + roomRepository.findByIdForUpdate(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); roomAuthorizationService.requireHost(roomId, userId); RoomMember target = roomMemberRepository.findByIdAndRoom_Id(requestId, roomId) diff --git a/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java b/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java index dfc29c9e..6032868e 100644 --- a/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java +++ b/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java @@ -41,6 +41,9 @@ public void withdraw(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + // 본인이 HOST인 모든 Room을 잠가 동시 approve/위임이 검증과 삭제 사이에 끼어들지 못하게 함. + roomRepository.lockHostRoomsByUser(userId); + ensureNoHostDelegationRequired(userId); deleteSoloHostRooms(userId); diff --git a/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java b/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java index e7039d7c..74f1568d 100644 --- a/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java @@ -230,7 +230,7 @@ void approveChangesRoleToMember() { RoomMember pendingMember = RoomMember.create(room, pendingUser, RoomRole.PENDING); ReflectionTestUtils.setField(pendingMember, "id", 42L); - given(roomRepository.existsById(ROOM_ID)).willReturn(true); + given(roomRepository.findByIdForUpdate(ROOM_ID)).willReturn(Optional.of(room)); given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, HOST_ID)) .willReturn(Optional.of(hostMember)); given(roomMemberRepository.findByIdAndRoom_Id(42L, ROOM_ID)) @@ -249,7 +249,7 @@ void approvePublishesMemberApprovedEvent() { RoomMember pendingMember = RoomMember.create(room, pendingUser, RoomRole.PENDING); ReflectionTestUtils.setField(pendingMember, "id", 42L); - given(roomRepository.existsById(ROOM_ID)).willReturn(true); + given(roomRepository.findByIdForUpdate(ROOM_ID)).willReturn(Optional.of(room)); given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, HOST_ID)) .willReturn(Optional.of(hostMember)); given(roomMemberRepository.findByIdAndRoom_Id(42L, ROOM_ID)) @@ -288,7 +288,7 @@ void rejectDeletesMember() { @Test @DisplayName("존재하지 않는 요청을 승인하면 JOIN_REQUEST_NOT_FOUND 예외") void approveThrowsWhenRequestNotFound() { - given(roomRepository.existsById(ROOM_ID)).willReturn(true); + given(roomRepository.findByIdForUpdate(ROOM_ID)).willReturn(Optional.of(room)); given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, HOST_ID)) .willReturn(Optional.of(hostMember)); given(roomMemberRepository.findByIdAndRoom_Id(999L, ROOM_ID)) @@ -303,7 +303,7 @@ void approveThrowsWhenRequestNotFound() { @Test @DisplayName("MEMBER가 승인을 시도하면 NOT_ROOM_HOST 예외") void approveThrowsWhenNotHost() { - given(roomRepository.existsById(ROOM_ID)).willReturn(true); + given(roomRepository.findByIdForUpdate(ROOM_ID)).willReturn(Optional.of(room)); given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, MEMBER_ID)) .willReturn(Optional.of(regularMember)); From 92616543f70a0c1a09e113c2da80adaae8e0f8a2 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 23:56:47 +0900 Subject: [PATCH 04/20] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B5=EB=AA=85=ED=99=94=20=EB=88=84=EB=9D=BD=EC=9D=84=20DB?= =?UTF-8?q?=20CHECK=EB=A1=9C=20=EA=B0=95=EC=A0=9C=20+=20DROP=20CONSTRAINT?= =?UTF-8?q?=20=EB=B0=A9=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 users_active_required CHECK는 OR 구조라 탈퇴 회원(deleted_at IS NOT NULL)에 대한 4개 개인정보 컬럼(email, nickname, provider, provider_id)의 NULL 강제가 없어, User.anonymize() 누락이나 운영자의 수동 SQL로 익명화 안 된 탈퇴 행이 생기는 경로를 DB가 막아주지 못했다. CHECK를 양방향(iff) 구조로 교체해 "활성 ⇔ 4개 컬럼 모두 NOT NULL" / "탈퇴 ⇔ 4개 컬럼 모두 NULL"을 강제한다. 같은 V1.7의 기존 UNIQUE 제약 DROP은 환경별 이름 차이/핫픽스 잔재로 실패할 수 있어 DROP CONSTRAINT IF EXISTS로 방어한다. V1.7이 아직 main에 머지되지 않아 새 V1.8 대신 V1.7 자체를 수정한다. ERD/spec/결정 기록도 동일 정책으로 동기화. --- .../20260607-user-withdrawal-soft-delete.md | 4 ++-- docs/ai/erd.md | 2 +- .../2026-06-07-user-withdrawal-design.md | 18 ++++++++++----- .../db/migration/V1.7__users_withdrawal.sql | 23 +++++++++++++------ 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md b/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md index c1f558f9..e0f8228e 100644 --- a/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md +++ b/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md @@ -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 조회에서 탈퇴자를 자동 제외. ## 대안 검토 diff --git a/docs/ai/erd.md b/docs/ai/erd.md index 791fee5b..47391c79 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -24,7 +24,7 @@ Google OAuth 기반 사용자 정보 | 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` diff --git a/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md b/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md index ec554b37..98930cde 100644 --- a/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md +++ b/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md @@ -56,15 +56,21 @@ ALTER TABLE users ALTER COLUMN provider DROP NOT NULL; ALTER TABLE users ALTER COLUMN provider_id DROP NOT NULL; ALTER TABLE users ADD CONSTRAINT users_active_required CHECK ( - deleted_at IS NOT NULL - OR (email IS NOT NULL + (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) ); -ALTER TABLE users DROP CONSTRAINT users_email_key; -ALTER TABLE users DROP CONSTRAINT users_provider_provider_id_key; +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key; +ALTER TABLE users DROP CONSTRAINT IF EXISTS uq_users_provider_provider_id; CREATE UNIQUE INDEX users_email_unique_active ON users(email) WHERE deleted_at IS NULL; @@ -75,13 +81,13 @@ CREATE UNIQUE INDEX users_provider_provider_id_unique_active CREATE INDEX users_deleted_at_idx ON users(deleted_at) WHERE deleted_at IS NOT NULL; ``` -> 기존 UNIQUE 제약 명칭은 실제 환경에서 `\d users`로 확인 후 정확히 지정한다. +> 기존 UNIQUE 제약 명칭은 V1__init.sql 기준이며, 환경별 이름 차이나 핫픽스 잔재에 대비해 `DROP CONSTRAINT IF EXISTS`로 방어한다. ### 결과로 보장되는 invariant - 활성 회원: `email`, `nickname`, `provider`, `provider_id` 모두 NOT NULL (CHECK) - 활성 회원 간: `email`, `(provider, provider_id)` 각각 UNIQUE (partial unique) -- 탈퇴 회원: 위 컬럼들 모두 NULL 허용, unique 검사 대상 제외 → 동일 Google 계정 재가입 가능 +- 탈퇴 회원: 위 컬럼들 모두 NULL 강제 (CHECK) → 익명화 누락 행을 DB 레벨에서 차단, unique 검사 대상 제외 → 동일 Google 계정 재가입 가능 ## 5. 엔티티 변경 (`User.java`) diff --git a/src/main/resources/db/migration/V1.7__users_withdrawal.sql b/src/main/resources/db/migration/V1.7__users_withdrawal.sql index bd8243ea..8067ab7d 100644 --- a/src/main/resources/db/migration/V1.7__users_withdrawal.sql +++ b/src/main/resources/db/migration/V1.7__users_withdrawal.sql @@ -11,20 +11,29 @@ ALTER TABLE users ALTER COLUMN nickname DROP NOT NULL; ALTER TABLE users ALTER COLUMN provider DROP NOT NULL; ALTER TABLE users ALTER COLUMN provider_id DROP NOT NULL; --- 3) 활성 회원에 한해 NOT NULL 강제 (CHECK) +-- 3) 활성/탈퇴 상태와 개인정보 컬럼의 양방향 정합 강제 (CHECK) +-- 활성(deleted_at IS NULL) ⇒ email/nickname/provider/provider_id 모두 NOT NULL +-- 탈퇴(deleted_at IS NOT NULL) ⇒ email/nickname/provider/provider_id 모두 NULL (익명화 강제) ALTER TABLE users ADD CONSTRAINT users_active_required CHECK ( - deleted_at IS NOT NULL - OR ( - email IS NOT NULL + ( + 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) 기존 unique 제약 제거 -ALTER TABLE users DROP CONSTRAINT users_email_key; -ALTER TABLE users DROP CONSTRAINT uq_users_provider_provider_id; +-- 4) 기존 unique 제약 제거 (환경별 제약 이름 차이/핫픽스 잔재에 대비해 IF EXISTS) +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key; +ALTER TABLE users DROP CONSTRAINT IF EXISTS uq_users_provider_provider_id; -- 5) 활성 회원만 unique 적용 (partial unique index) CREATE UNIQUE INDEX users_email_unique_active From 383b8bd8f72b456d4ddfd74489d0c1c57244fdf0 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 01:02:36 +0900 Subject: [PATCH 05/20] =?UTF-8?q?docs=20:=20roomMember=20entity=EC=97=90?= =?UTF-8?q?=20=EB=8C=80=ED=95=B4=20status=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=84=A4=EA=B3=84/=EA=B8=B0=ED=9A=8D=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-08-room-member-left-status.md | 1407 +++++++++++++++++ ...26-06-08-room-member-left-status-design.md | 269 ++++ 2 files changed, 1676 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-room-member-left-status.md create mode 100644 docs/superpowers/specs/2026-06-08-room-member-left-status-design.md diff --git a/docs/superpowers/plans/2026-06-08-room-member-left-status.md b/docs/superpowers/plans/2026-06-08-room-member-left-status.md new file mode 100644 index 00000000..1c88fb87 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-room-member-left-status.md @@ -0,0 +1,1407 @@ +# Room Member LEFT 상태 구현 플랜 + +> **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:** `room_members`에 `status (ACTIVE/LEFT)` + `left_at` 컬럼을 도입하여 방 나가기/추방을 row 유지 + status=LEFT로 표현하고, 회원 탈퇴는 hard delete를 유지한다. 클라이언트는 `GET /rooms/{id}/members` 한 번으로 ACTIVE + LEFT 멤버 정보를 받아 과거 메시지의 닉네임/프로필을 렌더링한다. + +**Architecture:** PostgreSQL 컬럼/CHECK 제약 + 인덱스를 추가하고, JPA 엔티티에 `MemberStatus` enum과 도메인 메서드(`leave`/`kicked`/`rejoinAsPending`)를 추가한다. 권한 게이트(`requireActiveMember`)와 모든 활성 멤버 조회 쿼리에 `status='ACTIVE'` 필터를 추가하여 LEFT 멤버를 비활성으로 취급한다. 멤버 목록 전용 쿼리 1개를 신설해 ACTIVE(HOST/MEMBER) + LEFT 둘 다 반환한다. UPSERT 부활은 같은 row의 status/role/leftAt만 변경. + +**Tech Stack:** Spring Boot 4.0.5, Java 21, PostgreSQL 17 + Flyway, JPA(Hibernate), JUnit 5 + Mockito + AssertJ. + +**참조 스펙:** `docs/superpowers/specs/2026-06-08-room-member-left-status-design.md` + +--- + +## File Structure + +**신규 파일** +- `src/main/resources/db/migration/V1.8__room_members_status.sql` +- `src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java` +- `src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java` + +**수정 파일** +- `src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java` — 필드 2개 + 메서드 3개 + 기존 메서드 가드 강화 +- `src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java` — 6개 쿼리에 `status='ACTIVE'` 필터, `findVisibleMembers` 신설 +- `src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java` — LEFT 가드 추가 +- `src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java` — leave/kick 도메인 메서드 호출로 변경, getMembers에서 LEFT 포함 및 online=false 강제, 정렬 +- `src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java` — requestJoin에 LEFT 부활 분기 +- `src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java` — `status` 필드 추가 +- `src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java` — `status` 필드 추가 +- `src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java` — `status='ACTIVE'` 필터 +- `src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java` — 새 메서드 + 가드 테스트 +- `src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java` — LEFT 가드 테스트 +- `src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java` — leave/kick/getMembers 테스트 변경 +- `src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java` — 재입장 분기 테스트 +- `docs/ai/features.md`, `docs/ai/erd.md` + +--- + +## 작업 순서 원칙 + +- 각 Task 끝에 conventional commit(`feat:`/`refactor:`/`test:`/`docs:`) 한 개를 만든다. +- 커밋 직전 항상 `./gradlew checkstyleMain checkstyleTest` 통과를 확인한다. +- 커밋 메시지에 `Co-Authored-By:` 트레일러를 절대 추가하지 않는다. +- 단위 테스트는 `@ExtendWith(MockitoExtension.class) + @Mock + @InjectMocks` 또는 수동 생성자 주입(기존 `RoomMemberServiceTest` 패턴)을 사용한다. +- 통합 테스트가 ApplicationContext를 로드하기 시작하는 Task 2 완료 이후부터는 빌드 전체가 그린이어야 한다. Task 1 단독 커밋은 JPA 엔티티가 아직 컬럼을 모르는 상태라 ApplicationContext 로딩 테스트가 잠시 실패할 수 있으므로, Task 1 + Task 2를 연속 실행한 뒤에 전체 빌드를 검증한다. + +--- + +## Task 1: Flyway 마이그레이션 V1.8 — `room_members` status/left_at 도입 + +**Files:** +- Create: `src/main/resources/db/migration/V1.8__room_members_status.sql` + +- [ ] **Step 1: 마이그레이션 파일 생성** + +`src/main/resources/db/migration/V1.8__room_members_status.sql`: + +```sql +-- =========================================== +-- V1.8: room_members 에 status / left_at 도입 +-- ACTIVE = 활성 멤버(HOST/MEMBER/PENDING), LEFT = 방 나가기/추방으로 떠난 멤버 +-- 회원 탈퇴는 별도로 hard delete (row 자체 삭제) — 본 마이그레이션과 무관 +-- =========================================== + +-- 1) status / left_at 컬럼 추가 +ALTER TABLE room_members + ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + ADD COLUMN left_at TIMESTAMP WITH TIME ZONE; + +-- 2) status 유효값 +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_status + CHECK (status IN ('ACTIVE', 'LEFT')); + +-- 3) status ↔ left_at 무결성 +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_status_left_at + CHECK ( + (status = 'ACTIVE' AND left_at IS NULL) + OR (status = 'LEFT' AND left_at IS NOT NULL) + ); + +-- 4) LEFT row의 role=PENDING 차단 (PENDING의 LEFT 전이는 정책상 불가) +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_left_role + CHECK (status = 'ACTIVE' OR role <> 'PENDING'); + +-- 5) 멤버 목록 / LEFT 룩업 인덱스 +CREATE INDEX idx_room_members_room_id_status ON room_members (room_id, status); + +-- 기존 UNIQUE(room_id, user_id)는 유지 — 재입장은 같은 row UPSERT. +``` + +- [ ] **Step 2: 컴파일 검증** + +Run: `./gradlew compileJava -q` +Expected: SUCCESS (Java 변경 없음). + +- [ ] **Step 3: 커밋 (Task 2와 묶지 않음 — 마이그레이션 단독 커밋)** + +```bash +git add src/main/resources/db/migration/V1.8__room_members_status.sql +git commit -m "feat: V1.8 마이그레이션으로 room_members status/left_at 도입" +``` + +> 이 시점에 ApplicationContext 로딩 테스트(`./gradlew test`)는 일시적으로 실패할 수 있다(JPA 엔티티가 신규 컬럼을 아직 모름). 다음 Task 2에서 엔티티에 반영한 뒤 전체 빌드를 검증한다. + +--- + +## Task 2: `MemberStatus` enum + `RoomMember` 엔티티 필드/메서드 추가 (TDD) + +**Files:** +- Create: `src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java` +- Modify: `src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java` + +- [ ] **Step 1: `MemberStatus` enum 생성** + +`src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java`: + +```java +package com.howaboutus.backend.rooms.entity; + +public enum MemberStatus { + ACTIVE, LEFT +} +``` + +- [ ] **Step 2: RoomMemberTest에 실패 테스트 추가** + +기존 `src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java`의 `createMember` 헬퍼는 그대로 두고 아래 테스트를 추가한다. + +```java +@Test +@DisplayName("create - 신규 멤버는 status=ACTIVE, leftAt=null 로 시작한다") +void createStartsActive() { + RoomMember member = createMember(RoomRole.MEMBER); + assertThat(member.getStatus()).isEqualTo(MemberStatus.ACTIVE); + assertThat(member.getLeftAt()).isNull(); +} + +@Test +@DisplayName("leave - ACTIVE 멤버를 LEFT로 전환하고 leftAt 을 채운다") +void leaveTransitionsToLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + assertThat(member.getStatus()).isEqualTo(MemberStatus.LEFT); + assertThat(member.getLeftAt()).isNotNull(); + assertThat(member.getRole()).isEqualTo(RoomRole.MEMBER); +} + +@Test +@DisplayName("leave - 이미 LEFT면 예외") +void leaveFailsWhenAlreadyLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + assertThatThrownBy(member::leave).isInstanceOf(IllegalStateException.class); +} + +@Test +@DisplayName("kicked - ACTIVE 멤버를 LEFT로 전환하고 leftAt 을 채운다") +void kickedTransitionsToLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.kicked(); + assertThat(member.getStatus()).isEqualTo(MemberStatus.LEFT); + assertThat(member.getLeftAt()).isNotNull(); +} + +@Test +@DisplayName("rejoinAsPending - LEFT 멤버를 ACTIVE/PENDING 으로 부활시킨다") +void rejoinAsPendingRevives() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + member.rejoinAsPending(); + assertThat(member.getStatus()).isEqualTo(MemberStatus.ACTIVE); + assertThat(member.getRole()).isEqualTo(RoomRole.PENDING); + assertThat(member.getLeftAt()).isNull(); +} + +@Test +@DisplayName("rejoinAsPending - ACTIVE 멤버에 호출하면 예외") +void rejoinAsPendingFailsWhenActive() { + RoomMember member = createMember(RoomRole.MEMBER); + assertThatThrownBy(member::rejoinAsPending).isInstanceOf(IllegalStateException.class); +} + +@Test +@DisplayName("approve - LEFT 상태면 예외") +void approveFailsWhenLeft() { + RoomMember member = createMember(RoomRole.PENDING); + // PENDING 인 채 LEFT 로 만드는 직접 경로는 없으므로, 직접 필드 설정 헬퍼 사용 + ReflectionTestUtils.setField(member, "status", MemberStatus.LEFT); + ReflectionTestUtils.setField(member, "leftAt", java.time.Instant.now()); + assertThatThrownBy(member::approve).isInstanceOf(IllegalStateException.class); +} + +@Test +@DisplayName("promoteToHost - LEFT 상태면 예외") +void promoteToHostFailsWhenLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + assertThatThrownBy(member::promoteToHost).isInstanceOf(IllegalStateException.class); +} + +@Test +@DisplayName("demoteToMember - LEFT 상태면 예외") +void demoteToMemberFailsWhenLeft() { + RoomMember host = createMember(RoomRole.HOST); + ReflectionTestUtils.setField(host, "status", MemberStatus.LEFT); + ReflectionTestUtils.setField(host, "leftAt", java.time.Instant.now()); + assertThatThrownBy(host::demoteToMember).isInstanceOf(IllegalStateException.class); +} +``` + +상단 import에 다음을 추가: +```java +import org.springframework.test.util.ReflectionTestUtils; +``` + +- [ ] **Step 3: 테스트 실행 — 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.entity.RoomMemberTest` +Expected: 신규 테스트들 컴파일 실패(`leave`/`kicked`/`rejoinAsPending`/`getStatus`/`getLeftAt` 미정의). + +- [ ] **Step 4: `RoomMember` 엔티티 수정** + +`src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java` 변경 사항: + +```java +// import 추가 +// (이미 있는 것 외) +// (기존 import 유지) +// private MemberStatus status / private Instant leftAt 필드와 도메인 메서드만 추가 + +// 클래스 본문에 필드 추가 (joinedAt 아래) +@Enumerated(EnumType.STRING) +@Column(nullable = false, length = 20) +private MemberStatus status = MemberStatus.ACTIVE; + +@Column(name = "left_at") +private Instant leftAt; +``` + +도메인 메서드 추가(클래스 끝): + +```java +public void leave() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 LEFT로 전환할 수 있습니다. 현재 상태: " + this.status); + } + this.status = MemberStatus.LEFT; + this.leftAt = Instant.now(); +} + +public void kicked() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 LEFT로 전환할 수 있습니다. 현재 상태: " + this.status); + } + this.status = MemberStatus.LEFT; + this.leftAt = Instant.now(); +} + +public void rejoinAsPending() { + if (this.status != MemberStatus.LEFT) { + throw new IllegalStateException("LEFT 상태의 멤버만 재입장(PENDING) 으로 부활할 수 있습니다. 현재 상태: " + this.status); + } + this.status = MemberStatus.ACTIVE; + this.role = RoomRole.PENDING; + this.leftAt = null; +} +``` + +기존 `approve()` / `promoteToHost()` / `demoteToMember()` 각각의 첫 줄에 status 가드를 추가: + +```java +public void approve() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 승인할 수 있습니다. 현재 상태: " + this.status); + } + if (this.role != RoomRole.PENDING) { + throw new IllegalStateException("PENDING 상태의 멤버만 승인할 수 있습니다. 현재 상태: " + this.role); + } + this.role = RoomRole.MEMBER; +} + +public void promoteToHost() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 승격할 수 있습니다. 현재 상태: " + this.status); + } + if (this.role != RoomRole.MEMBER) { + throw new IllegalStateException("MEMBER 상태의 멤버만 HOST로 승격할 수 있습니다. 현재 상태: " + this.role); + } + this.role = RoomRole.HOST; +} + +public void demoteToMember() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 강등할 수 있습니다. 현재 상태: " + this.status); + } + if (this.role != RoomRole.HOST) { + throw new IllegalStateException("HOST 상태의 멤버만 MEMBER로 강등할 수 있습니다. 현재 상태: " + this.role); + } + this.role = RoomRole.MEMBER; +} +``` + +- [ ] **Step 5: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.entity.RoomMemberTest` +Expected: 모든 테스트 PASS. + +- [ ] **Step 6: 전체 빌드 검증 (마이그레이션 + 엔티티 정합)** + +Run: `./gradlew test` +Expected: 전체 그린. (DB CHECK 제약과 JPA 매핑이 일치해야 ApplicationContext 로딩 테스트가 통과한다.) + +- [ ] **Step 7: Checkstyle** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings. + +- [ ] **Step 8: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java \ + src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java \ + src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java +git commit -m "feat: RoomMember에 MemberStatus(LEFT) + 도메인 메서드 추가" +``` + +--- + +## Task 3: `RoomAuthorizationService.requireActiveMember`에 LEFT 가드 (TDD) + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가** + +`RoomAuthorizationServiceTest`에 추가 (기존 테스트와 같은 클래스, 패턴 동일): + +```java +@Test +@DisplayName("requireActiveMember - LEFT 멤버는 비멤버로 차단된다") +void requireActiveMemberRejectsLeft() { + User user = User.ofGoogle("g1", "u@test.com", "유저", null); + ReflectionTestUtils.setField(user, "id", USER_ID); + Room room = Room.create("여행", "부산", null, null, "invite1", USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + RoomMember member = RoomMember.create(room, user, RoomRole.MEMBER); + member.leave(); + + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, USER_ID)) + .willReturn(java.util.Optional.of(member)); + + assertThatThrownBy(() -> roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.NOT_ROOM_MEMBER); +} +``` + +기존 테스트 클래스의 상수/필드(`ROOM_ID`, `USER_ID`, `roomMemberRepository`, `roomAuthorizationService`)를 그대로 사용한다. 없는 import는 추가: +```java +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.user.entity.User; +import org.springframework.test.util.ReflectionTestUtils; +``` + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomAuthorizationServiceTest` +Expected: 신규 테스트 FAIL — LEFT 멤버가 예외 없이 반환됨. + +- [ ] **Step 3: `requireActiveMember`에 LEFT 가드 추가** + +`src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java`: + +```java +import com.howaboutus.backend.rooms.entity.MemberStatus; + +public RoomMember requireActiveMember(UUID roomId, Long userId) { + RoomMember member = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); + if (member.getStatus() == MemberStatus.LEFT) { + throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); + } + if (member.getRole() == RoomRole.PENDING) { + throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); + } + return member; +} +``` + +- [ ] **Step 4: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomAuthorizationServiceTest` +Expected: 모든 테스트 PASS. + +- [ ] **Step 5: Checkstyle** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings. + +- [ ] **Step 6: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java \ + src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java +git commit -m "feat: requireActiveMember에서 LEFT 멤버를 비멤버로 차단" +``` + +--- + +## Task 4: `RoomMemberService.leave()` 변경 (TDD) + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가/수정** + +`RoomMemberServiceTest`에 leave 동작 확인 테스트를 추가(기존 leave 테스트가 있으면 수정): + +```java +@Test +@DisplayName("leave - row를 삭제하지 않고 LEFT 로 전환하며 이벤트를 발행한다") +void leaveTransitionsToLeftAndPublishesEvent() { + User user = User.ofGoogle("g2", "m@test.com", "멤버", "img"); + ReflectionTestUtils.setField(user, "id", USER_ID); + Room room = Room.create("여행", "부산", null, null, "invite1", USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + RoomMember member = RoomMember.create(room, user, RoomRole.MEMBER); + + given(roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)).willReturn(member); + + roomMemberService.leave(ROOM_ID, USER_ID); + + assertThat(member.getStatus()).isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.LEFT); + assertThat(member.getLeftAt()).isNotNull(); + verify(roomMemberRepository, never()).delete(any(RoomMember.class)); + verify(eventPublisher).publishEvent(any(MemberLeftEvent.class)); +} + +@Test +@DisplayName("leave - HOST 는 이전과 동일하게 CANNOT_LEAVE_AS_HOST 예외") +void leaveByHostFails() { + User user = User.ofGoogle("g1", "h@test.com", "호스트", null); + ReflectionTestUtils.setField(user, "id", HOST_USER_ID); + Room room = Room.create("여행", "부산", null, null, "invite1", HOST_USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + RoomMember host = RoomMember.create(room, user, RoomRole.HOST); + + given(roomAuthorizationService.requireActiveMember(ROOM_ID, HOST_USER_ID)).willReturn(host); + + assertThatThrownBy(() -> roomMemberService.leave(ROOM_ID, HOST_USER_ID)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.CANNOT_LEAVE_AS_HOST); +} +``` + +기존 테스트 중 `delete(member)` 호출을 검증하던 부분이 있다면 위와 같이 status 검증으로 교체한다. + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomMemberServiceTest` +Expected: 새 `leaveTransitionsToLeftAndPublishesEvent` FAIL — 현재는 `roomMemberRepository.delete(...)`가 호출됨. + +- [ ] **Step 3: `RoomMemberService.leave()` 수정** + +`src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java`의 `leave()`: + +```java +@Loggable +@Transactional +public void leave(UUID roomId, Long userId) { + RoomMember member = roomAuthorizationService.requireActiveMember(roomId, userId); + + if (member.getRole() == RoomRole.HOST) { + throw new CustomException(ErrorCode.CANNOT_LEAVE_AS_HOST); + } + + member.leave(); + eventPublisher.publishEvent(new MemberLeftEvent( + roomId, + member.getUser().getId(), + member.getUser().getNickname(), + member.getUser().getProfileImageUrl() + )); +} +``` + +- [ ] **Step 4: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomMemberServiceTest` +Expected: PASS. + +- [ ] **Step 5: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java \ + src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +git commit -m "refactor: leave가 hard delete 대신 status=LEFT 전환을 수행" +``` + +--- + +## Task 5: `RoomMemberService.kick()` 변경 + LEFT 가드 (TDD) + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가** + +```java +@Test +@DisplayName("kick - 대상 row를 삭제하지 않고 LEFT 로 전환하며 이벤트를 발행한다") +void kickTransitionsToLeft() { + User host = User.ofGoogle("g1", "h@test.com", "호스트", null); + ReflectionTestUtils.setField(host, "id", HOST_USER_ID); + User target = User.ofGoogle("g2", "t@test.com", "타겟", "img"); + ReflectionTestUtils.setField(target, "id", TARGET_USER_ID); + Room room = Room.create("여행", "부산", null, null, "invite1", HOST_USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); + RoomMember targetMember = RoomMember.create(room, target, RoomRole.MEMBER); + + given(roomAuthorizationService.requireHost(ROOM_ID, HOST_USER_ID)).willReturn(hostMember); + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, TARGET_USER_ID)) + .willReturn(java.util.Optional.of(targetMember)); + + roomMemberService.kick(ROOM_ID, TARGET_USER_ID, HOST_USER_ID); + + assertThat(targetMember.getStatus()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.LEFT); + verify(roomMemberRepository, never()).delete(any(RoomMember.class)); + verify(eventPublisher).publishEvent(any(MemberKickedEvent.class)); +} + +@Test +@DisplayName("kick - 대상이 이미 LEFT 면 KICK_TARGET_NOT_MEMBER 예외") +void kickFailsWhenTargetAlreadyLeft() { + User host = User.ofGoogle("g1", "h@test.com", "호스트", null); + ReflectionTestUtils.setField(host, "id", HOST_USER_ID); + User target = User.ofGoogle("g2", "t@test.com", "타겟", null); + ReflectionTestUtils.setField(target, "id", TARGET_USER_ID); + Room room = Room.create("여행", "부산", null, null, "invite1", HOST_USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); + RoomMember targetMember = RoomMember.create(room, target, RoomRole.MEMBER); + targetMember.leave(); + + given(roomAuthorizationService.requireHost(ROOM_ID, HOST_USER_ID)).willReturn(hostMember); + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, TARGET_USER_ID)) + .willReturn(java.util.Optional.of(targetMember)); + + assertThatThrownBy(() -> roomMemberService.kick(ROOM_ID, TARGET_USER_ID, HOST_USER_ID)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.KICK_TARGET_NOT_MEMBER); +} +``` + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomMemberServiceTest` +Expected: 새 테스트 두 개 FAIL. + +- [ ] **Step 3: `kick()` 수정** + +`src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java`의 `kick()`: + +```java +@Loggable +@Transactional +public void kick(UUID roomId, Long targetUserId, Long hostUserId) { + roomAuthorizationService.requireHost(roomId, hostUserId); + + RoomMember target = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, targetUserId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_MEMBER_NOT_FOUND)); + + if (target.getStatus() != MemberStatus.ACTIVE) { + throw new CustomException(ErrorCode.KICK_TARGET_NOT_MEMBER); + } + if (target.getRole() == RoomRole.HOST) { + throw new CustomException(ErrorCode.CANNOT_KICK_HOST); + } + if (target.getRole() != RoomRole.MEMBER) { + throw new CustomException(ErrorCode.KICK_TARGET_NOT_MEMBER); + } + + target.kicked(); + eventPublisher.publishEvent(new MemberKickedEvent( + roomId, + target.getUser().getId(), + target.getUser().getNickname(), + target.getUser().getProfileImageUrl() + )); +} +``` + +import에 `com.howaboutus.backend.rooms.entity.MemberStatus` 추가. + +- [ ] **Step 4: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomMemberServiceTest` +Expected: PASS. + +- [ ] **Step 5: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java \ + src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +git commit -m "refactor: kick이 hard delete 대신 status=LEFT 전환을 수행" +``` + +--- + +## Task 6: Repository — 활성 멤버 쿼리에 `status='ACTIVE'` 필터 추가 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java` + +> 이 task는 기존 메서드 시그니처는 유지하면서 내부 필터만 강화한다. 기존 테스트들이 ACTIVE 멤버만 다루므로 **기존 테스트는 그대로 통과해야 한다**. 추가 회귀 테스트는 Task 8(통합 조회) / Task 10(재입장)에서 LEFT 케이스로 커버된다. + +- [ ] **Step 1: 메서드 시그니처/쿼리 변경** + +`src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java`: + +```java +import com.howaboutus.backend.rooms.entity.MemberStatus; + +// 1) ACTIVE 단일 조회 — 모든 ACTIVE 필터링은 JPQL로 일관 표현 +@EntityGraph(attributePaths = "user") +@Query(""" + select m from RoomMember m + where m.room.id = :roomId + and m.role = :role + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE +""") +List findByRoom_IdAndRole(@Param("roomId") UUID roomId, @Param("role") RoomRole role); + +// 2) ACTIVE 다중 role 조회 +@EntityGraph(attributePaths = "user") +@Query(""" + select m from RoomMember m + where m.room.id = :roomId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE +""") +List findByRoom_IdAndRoleIn(@Param("roomId") UUID roomId, @Param("roles") List roles); + +// 3) ACTIVE 카운트 +@Query(""" + select count(m) from RoomMember m + where m.room.id = :roomId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE +""") +long countByRoom_IdAndRoleIn(@Param("roomId") UUID roomId, @Param("roles") List roles); + +// 4) 내 방 목록 (커서 없음) +@EntityGraph(attributePaths = "room") +@Query(""" + select m from RoomMember m + where m.user.id = :userId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + order by m.joinedAt desc +""") +List findByUser_IdAndRoleInOrderByJoinedAtDesc( + @Param("userId") Long userId, @Param("roles") List roles, Pageable pageable); + +// 5) 내 방 목록 (커서) +@EntityGraph(attributePaths = "room") +@Query(""" + select m from RoomMember m + where m.user.id = :userId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and m.joinedAt < :cursor + order by m.joinedAt desc +""") +List findByUser_IdAndRoleInAndJoinedAtBeforeOrderByJoinedAtDesc( + @Param("userId") Long userId, @Param("roles") List roles, + @Param("cursor") Instant cursor, Pageable pageable); +``` + +`findHostRoomsWithOnlySelf` / `findHostRoomsWithOtherActiveMembers` 두 JPQL은 self/other 모두 ACTIVE 조건을 추가: + +```java +@Query(""" + select m.room + from RoomMember m + where m.user.id = :userId + and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and not exists ( + select 1 from RoomMember other + where other.room.id = m.room.id + and other.user.id <> :userId + and other.role in ( + com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER) + and other.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + ) + """) +List findHostRoomsWithOnlySelf(@Param("userId") Long userId); +``` + +```java +@Query(""" + select m.room.id as roomId, m.room.title as title + from RoomMember m + where m.user.id = :userId + and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and exists ( + select 1 from RoomMember other + where other.room.id = m.room.id + and other.user.id <> :userId + and other.role in ( + com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER) + and other.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + ) + """) +List findHostRoomsWithOtherActiveMembers(@Param("userId") Long userId); +``` + +`findAllByUser_Id`, `findByRoom_IdAndUser_Id`, `findByIdAndRoom_Id` 는 **변경 없음** (탈퇴 시 전체 row 삭제 / LEFT 부활 감지 / approve 가드에서 status 검증). + +- [ ] **Step 2: 전체 테스트 회귀 확인** + +Run: `./gradlew test` +Expected: 전체 PASS. 특히 `RoomMemberRepositoryWithdrawalTest`, `RoomMemberServiceTest`, `RoomInviteServiceTest`, `UserWithdrawalServiceTest` 가 회귀 없이 통과. + +- [ ] **Step 3: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java +git commit -m "refactor: 활성 멤버 쿼리에 status=ACTIVE 필터 일괄 추가" +``` + +--- + +## Task 7: `RoomMemberResult` / `RoomMemberResponse`에 `status` 필드 추가 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java` +- Modify: `src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java` + +> Task 8에서 `getMembers`가 LEFT를 포함하기 위해 사전에 DTO 스키마부터 확장한다. 기존 호출지(LEFT 미포함 결과만 다룸)는 `MemberStatus.ACTIVE` 를 그대로 채워 보낸다. + +- [ ] **Step 1: DTO 수정** + +`RoomMemberResult`: +```java +package com.howaboutus.backend.rooms.service.dto; + +import java.time.Instant; + +import com.howaboutus.backend.rooms.entity.MemberStatus; +import com.howaboutus.backend.rooms.entity.RoomRole; + +public record RoomMemberResult( + Long userId, + String nickname, + String profileImageUrl, + RoomRole role, + MemberStatus status, + boolean isOnline, + Instant joinedAt +) { +} +``` + +`RoomMemberResponse`: +```java +package com.howaboutus.backend.rooms.controller.dto; + +import java.time.Instant; + +import com.howaboutus.backend.rooms.entity.MemberStatus; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.rooms.service.dto.RoomMemberResult; + +public record RoomMemberResponse( + Long userId, + String nickname, + String profileImageUrl, + RoomRole role, + MemberStatus status, + boolean isOnline, + Instant joinedAt +) { + public static RoomMemberResponse from(RoomMemberResult result) { + return new RoomMemberResponse( + result.userId(), + result.nickname(), + result.profileImageUrl(), + result.role(), + result.status(), + result.isOnline(), + result.joinedAt()); + } +} +``` + +- [ ] **Step 2: `RoomMemberService.getMembers` 임시 보정 (이번 task 한정)** + +`RoomMemberService.getMembers` 매핑부에 status를 채워 보내도록 임시로 ACTIVE 고정: +```java +return members.stream() + .map(m -> new RoomMemberResult( + m.getUser().getId(), + m.getUser().getNickname(), + m.getUser().getProfileImageUrl(), + m.getRole(), + m.getStatus(), // m.getStatus() — 이 시점에서는 ACTIVE 만 반환되므로 항상 ACTIVE + onlineUserIds.contains(m.getUser().getId()), + m.getJoinedAt())) + .toList(); +``` + +기존 `RoomMemberServiceTest`의 `assertThat(results.get(0).role())...` 라인 다음에 status 검증을 추가하여 회귀를 막는다: +```java +assertThat(results.get(0).status()).isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); +``` + +- [ ] **Step 3: 전체 테스트 회귀 확인** + +Run: `./gradlew test` +Expected: PASS. + +- [ ] **Step 4: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java \ + src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java \ + src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java \ + src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +git commit -m "feat: 멤버 응답 DTO에 status 필드 노출" +``` + +--- + +## Task 8: `findVisibleMembers` 신설 + `RoomMemberService.getMembers` LEFT 포함 (TDD) + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java` +- Modify: `src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가** + +`RoomMemberServiceTest`에 추가: +```java +@Test +@DisplayName("getMembers - LEFT 멤버도 포함하며 LEFT 의 online 은 항상 false") +void getMembersIncludesLeftAndForcesOffline() { + User host = User.ofGoogle("g1", "h@test.com", "호스트", null); + ReflectionTestUtils.setField(host, "id", 1L); + User active = User.ofGoogle("g2", "m@test.com", "활성멤버", null); + ReflectionTestUtils.setField(active, "id", 2L); + User left = User.ofGoogle("g3", "l@test.com", "나간사람", null); + ReflectionTestUtils.setField(left, "id", 3L); + + Room room = Room.create("여행", "부산", null, null, "invite1", 1L); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + + RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); + RoomMember activeMember = RoomMember.create(room, active, RoomRole.MEMBER); + RoomMember leftMember = RoomMember.create(room, left, RoomRole.MEMBER); + leftMember.leave(); + + given(roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)).willReturn(hostMember); + given(roomMemberRepository.findVisibleMembers(ROOM_ID)) + .willReturn(java.util.List.of(hostMember, activeMember, leftMember)); + // LEFT 인 user(3L) 가 마침 Redis presence에 살아 있다고 가정 — 무시되어야 함 + given(roomPresenceService.getOnlineUserIds(ROOM_ID)) + .willReturn(java.util.Set.of(1L, 3L)); + + java.util.List results = roomMemberService.getMembers(ROOM_ID, USER_ID); + + assertThat(results).hasSize(3); + // 정렬: ACTIVE(HOST → MEMBER joinedAt asc) → LEFT(joinedAt asc) + assertThat(results.get(0).userId()).isEqualTo(1L); + assertThat(results.get(0).role()).isEqualTo(RoomRole.HOST); + assertThat(results.get(0).status()).isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); + + assertThat(results.get(1).userId()).isEqualTo(2L); + assertThat(results.get(1).status()).isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); + + assertThat(results.get(2).userId()).isEqualTo(3L); + assertThat(results.get(2).status()).isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.LEFT); + assertThat(results.get(2).isOnline()).isFalse(); +} +``` + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomMemberServiceTest` +Expected: `findVisibleMembers` 미정의로 컴파일 실패. + +- [ ] **Step 3: Repository에 `findVisibleMembers` 추가** + +`RoomMemberRepository`에 추가: +```java +@EntityGraph(attributePaths = "user") +@Query(""" + select m from RoomMember m + where m.room.id = :roomId + and ( + (m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and m.role in ( + com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER)) + or m.status = com.howaboutus.backend.rooms.entity.MemberStatus.LEFT + ) +""") +List findVisibleMembers(@Param("roomId") UUID roomId); +``` + +- [ ] **Step 4: `RoomMemberService.getMembers` 구현 변경** + +`RoomMemberService`: +```java +import com.howaboutus.backend.rooms.entity.MemberStatus; + +private static final java.util.Comparator MEMBER_DISPLAY_ORDER = + java.util.Comparator + .comparing((RoomMember m) -> m.getStatus() == MemberStatus.LEFT) // ACTIVE 먼저 + .thenComparing(m -> m.getRole() != RoomRole.HOST) // HOST 먼저 + .thenComparing(RoomMember::getJoinedAt); + +public List getMembers(UUID roomId, Long userId) { + roomAuthorizationService.requireActiveMember(roomId, userId); + + List members = roomMemberRepository.findVisibleMembers(roomId); + Set onlineUserIds = getOnlineUserIdsSafe(roomId); + + return members.stream() + .sorted(MEMBER_DISPLAY_ORDER) + .map(m -> new RoomMemberResult( + m.getUser().getId(), + m.getUser().getNickname(), + m.getUser().getProfileImageUrl(), + m.getRole(), + m.getStatus(), + m.getStatus() == MemberStatus.ACTIVE && onlineUserIds.contains(m.getUser().getId()), + m.getJoinedAt())) + .toList(); +} +``` + +기존 상수 `ACTIVE_ROLES`는 다른 곳에서 쓰지 않으면 삭제. 사용처가 남아있다면 그대로 유지. + +- [ ] **Step 5: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomMemberServiceTest` +Expected: PASS. + +- [ ] **Step 6: 전체 회귀** + +Run: `./gradlew test` +Expected: PASS. + +- [ ] **Step 7: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java \ + src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java \ + src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +git commit -m "feat: getMembers가 LEFT 멤버까지 노출하고 online=false 강제" +``` + +--- + +## Task 9: `RoomInviteService.requestJoin` 재입장 분기 (TDD) + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가** + +`RoomInviteServiceTest`에 추가 (기존 mock 셋업 패턴 참고): +```java +@Test +@DisplayName("requestJoin - LEFT 멤버는 같은 row가 ACTIVE/PENDING 으로 부활하고 JoinRequestedEvent 가 발행된다") +void requestJoinRevivesLeftMember() { + UUID roomId = UUID.randomUUID(); + Long userId = 42L; + String inviteCode = "code-1"; + + User user = User.ofGoogle("g42", "u@test.com", "유저", null); + ReflectionTestUtils.setField(user, "id", userId); + User hostUser = User.ofGoogle("gh", "h@test.com", "호스트", null); + ReflectionTestUtils.setField(hostUser, "id", 1L); + Room room = Room.create("여행", "부산", null, null, inviteCode, 1L); + ReflectionTestUtils.setField(room, "id", roomId); + + RoomMember left = RoomMember.create(room, user, RoomRole.MEMBER); + left.leave(); + RoomMember hostMember = RoomMember.create(room, hostUser, RoomRole.HOST); + + given(roomRepository.findByInviteCode(inviteCode)).willReturn(java.util.Optional.of(room)); + given(roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId)) + .willReturn(java.util.Optional.of(left)); + given(roomMemberRepository.findByRoom_IdAndRole(roomId, RoomRole.HOST)) + .willReturn(java.util.List.of(hostMember)); + + JoinResult result = roomInviteService.requestJoin(inviteCode, userId); + + assertThat(left.getStatus()).isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); + assertThat(left.getRole()).isEqualTo(RoomRole.PENDING); + assertThat(left.getLeftAt()).isNull(); + assertThat(result.status()).isEqualTo(JoinResult.Status.PENDING); + verify(eventPublisher).publishEvent(any(JoinRequestedEvent.class)); + verify(roomMemberRepository, never()).saveAndFlush(any(RoomMember.class)); +} +``` + +`JoinResult.Status` 의 실제 이름이 다르면 기존 테스트 어셔션 스타일에 맞춰 조정. `userService.getUser`는 LEFT 분기에서는 호출되지 않으므로 stubbing 불필요. + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomInviteServiceTest` +Expected: 신규 테스트 FAIL — 현재는 LEFT 분기가 없어 `alreadyMember`로 빠짐. + +- [ ] **Step 3: `requestJoin` 수정** + +`RoomInviteService.requestJoin`: +```java +import com.howaboutus.backend.rooms.entity.MemberStatus; + +@Loggable +@Transactional +public JoinResult requestJoin(String inviteCode, Long userId) { + Room room = roomRepository.findByInviteCode(inviteCode) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + Optional existing = roomMemberRepository.findByRoom_IdAndUser_Id(room.getId(), userId); + + if (existing.isPresent()) { + RoomMember member = existing.get(); + if (member.getStatus() == MemberStatus.LEFT) { + member.rejoinAsPending(); + publishJoinRequested(room, member.getUser()); + return JoinResult.pending(room.getId(), room.getTitle()); + } + if (member.getRole() == RoomRole.PENDING) { + return JoinResult.pending(room.getId(), room.getTitle()); + } + return JoinResult.alreadyMember(room.getId(), room.getTitle(), member.getRole()); + } + + User user = userService.getUser(userId); + try { + roomMemberRepository.saveAndFlush(RoomMember.create(room, user, RoomRole.PENDING)); + } catch (DataIntegrityViolationException e) { + log.warn("Concurrent join request detected. roomId={}, userId={}", room.getId(), userId, e); + return JoinResult.pending(room.getId(), room.getTitle()); + } + publishJoinRequested(room, user); + return JoinResult.pending(room.getId(), room.getTitle()); +} + +private void publishJoinRequested(Room room, User user) { + List hostUserIds = roomMemberRepository.findByRoom_IdAndRole(room.getId(), RoomRole.HOST) + .stream() + .map(m -> m.getUser().getId()) + .toList(); + eventPublisher.publishEvent(new JoinRequestedEvent( + room.getId(), + user.getId(), + user.getNickname(), + user.getProfileImageUrl(), + hostUserIds + )); +} +``` + +- [ ] **Step 4: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.service.RoomInviteServiceTest` +Expected: PASS. + +- [ ] **Step 5: 전체 회귀** + +Run: `./gradlew test` +Expected: PASS. + +- [ ] **Step 6: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java \ + src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java +git commit -m "feat: requestJoin이 LEFT row를 PENDING으로 부활시키도록 지원" +``` + +--- + +## Task 10: AI 컨텍스트 쿼리에 `status='ACTIVE'` 필터 추가 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java` + +- [ ] **Step 1: 현재 쿼리 확인** + +`AiContextQueryRepositoryImpl.java` 38행 근처에서 `RoomMember`를 `roles IN (HOST, MEMBER)`로 필터링하는 JPQL/Criteria를 찾는다. + +- [ ] **Step 2: 필터 추가** + +해당 쿼리에 `AND status = MemberStatus.ACTIVE` 조건을 추가한다. 예시(JPQL인 경우): + +```java +import com.howaboutus.backend.rooms.entity.MemberStatus; + +// ... 기존 쿼리 빌더 ... +.setParameter("roles", List.of(RoomRole.HOST, RoomRole.MEMBER)) +.setParameter("activeStatus", MemberStatus.ACTIVE) +// JPQL 본문에 "and rm.status = :activeStatus" 추가 +``` + +Native SQL이거나 Criteria라면 동일하게 status 컬럼 필터를 추가. 기존 테스트가 있으면 그대로 통과해야 하며, 필요하면 LEFT 멤버가 포함되지 않음을 검증하는 회귀 테스트 1개를 추가한다. + +- [ ] **Step 3: 회귀 테스트(선택, 기존 AI 테스트 위치에 추가)** + +`AiContextQueryRepositoryImpl`에 대한 테스트 클래스가 이미 있다면 그 패턴에 맞춰 다음 시나리오를 추가: + +> 같은 방에 ACTIVE/MEMBER 1명 + LEFT/MEMBER 1명을 두고 AI 컨텍스트 조회 → ACTIVE 1명만 결과에 포함. + +테스트 클래스가 없다면 이번 task에서는 만들지 않는다(scope 초과). features.md 갱신 시 "LEFT는 AI 컨텍스트에서 제외" 한 줄 명시로 의도 보존. + +- [ ] **Step 4: 빌드 + 커밋** + +```bash +./gradlew test +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java +git commit -m "fix: AI 컨텍스트 조회에서 LEFT 멤버 제외" +``` + +--- + +## Task 11: DB CHECK 제약 통합 테스트 + +**Files:** +- Create: `src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java` + +이 테스트는 Flyway가 적용된 실제 PostgreSQL에서 3개의 CHECK 제약을 검증한다. 프로젝트의 기존 통합 테스트 베이스(예: `RoomMemberRepositoryWithdrawalTest` 패턴)를 그대로 따른다. + +- [ ] **Step 1: 테스트 파일 생성** + +`src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java`: + +```java +package com.howaboutus.backend.rooms.repository; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.EntityManager; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.user.entity.User; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +class RoomMemberStatusConstraintTest { + + @Autowired EntityManager em; + + private User persistUser() { + User u = User.ofGoogle(UUID.randomUUID().toString(), UUID.randomUUID() + "@t", "n", null); + em.persist(u); + return u; + } + + private Room persistRoom(Long hostId) { + Room room = Room.create("t", "d", null, null, UUID.randomUUID().toString(), hostId); + em.persist(room); + return room; + } + + @Test + @DisplayName("CHECK ck_room_members_status_left_at - LEFT + left_at NULL 은 거부") + void leftRequiresLeftAt() { + User u = persistUser(); + Room room = persistRoom(u.getId()); + em.flush(); + + assertThatThrownBy(() -> em.createNativeQuery(""" + insert into room_members (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + values (?1, ?2, 'MEMBER', now(), 'LEFT', NULL, now(), now()) + """).setParameter(1, room.getId()).setParameter(2, u.getId()).executeUpdate()) + .isInstanceOfAny(DataIntegrityViolationException.class, jakarta.persistence.PersistenceException.class); + } + + @Test + @DisplayName("CHECK ck_room_members_status_left_at - ACTIVE + left_at NOT NULL 은 거부") + void activeForbidsLeftAt() { + User u = persistUser(); + Room room = persistRoom(u.getId()); + em.flush(); + + assertThatThrownBy(() -> em.createNativeQuery(""" + insert into room_members (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + values (?1, ?2, 'MEMBER', now(), 'ACTIVE', now(), now(), now()) + """).setParameter(1, room.getId()).setParameter(2, u.getId()).executeUpdate()) + .isInstanceOfAny(DataIntegrityViolationException.class, jakarta.persistence.PersistenceException.class); + } + + @Test + @DisplayName("CHECK ck_room_members_left_role - LEFT + role=PENDING 은 거부") + void leftPendingRejected() { + User u = persistUser(); + Room room = persistRoom(u.getId()); + em.flush(); + + assertThatThrownBy(() -> em.createNativeQuery(""" + insert into room_members (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + values (?1, ?2, 'PENDING', now(), 'LEFT', now(), now(), now()) + """).setParameter(1, room.getId()).setParameter(2, u.getId()).executeUpdate()) + .isInstanceOfAny(DataIntegrityViolationException.class, jakarta.persistence.PersistenceException.class); + } +} +``` + +> `@DataJpaTest` 가 트랜잭션을 롤백하므로 각 케이스마다 깨끗한 상태에서 시작한다. 베이스 테스트 컨테이너 설정은 프로젝트 기존 통합 테스트에 이미 구성되어 있다고 가정한다(`RoomMemberRepositoryWithdrawalTest` 와 동일한 방식). 다른 베이스 설정이 필요하면 동일 클래스의 `@Sql`/`@Import` 패턴을 그대로 채택한다. + +- [ ] **Step 2: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.repository.RoomMemberStatusConstraintTest` +Expected: 3개 PASS. + +- [ ] **Step 3: 전체 회귀 + Checkstyle + 커밋** + +```bash +./gradlew test +./gradlew checkstyleMain checkstyleTest +git add src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java +git commit -m "test: V1.8 CHECK 제약(LEFT/left_at/PENDING) 회귀 테스트 추가" +``` + +--- + +## Task 12: 회원 탈퇴 회귀 — LEFT row를 가진 유저 탈퇴 시 hard delete 보존 + +**Files:** +- Modify: `src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java` + +> 행동 변경은 없지만(`findAllByUser_Id`가 status 무관) 회귀 테스트로 의도를 잠근다. + +- [ ] **Step 1: 실패 위험 없는 회귀 테스트 추가** + +```java +@Test +@DisplayName("withdraw - LEFT row를 가진 유저도 전체 row가 hard delete 된다") +void withdrawDeletesEvenLeftMemberships() { + Long userId = 99L; + User user = User.ofGoogle("g99", "x@t", "x", null); + ReflectionTestUtils.setField(user, "id", userId); + + Room room = Room.create("t", "d", null, null, "iv", 1L); + ReflectionTestUtils.setField(room, "id", UUID.randomUUID()); + + RoomMember leftMember = RoomMember.create(room, user, RoomRole.MEMBER); + leftMember.leave(); + + given(userRepository.findById(userId)).willReturn(java.util.Optional.of(user)); + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(userId)).willReturn(java.util.List.of()); + given(roomMemberRepository.findHostRoomsWithOnlySelf(userId)).willReturn(java.util.List.of()); + given(roomMemberRepository.findAllByUser_Id(userId)) + .willReturn(java.util.List.of(leftMember)); + + userWithdrawalService.withdraw(userId); + + verify(roomMemberRepository).delete(leftMember); +} +``` + +기존 `UserWithdrawalServiceTest`의 mock 셋업/필드 이름이 다를 수 있으니 그 클래스의 패턴에 맞춰 import와 변수명을 조정한다. + +- [ ] **Step 2: 테스트 실행 — 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.service.UserWithdrawalServiceTest` +Expected: PASS. + +- [ ] **Step 3: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java +git commit -m "test: 탈퇴 시 LEFT 멤버십도 hard delete 되는지 회귀 테스트 추가" +``` + +--- + +## Task 13: 문서 갱신 — `docs/ai/features.md`, `docs/ai/erd.md` + +**Files:** +- Modify: `docs/ai/features.md` +- Modify: `docs/ai/erd.md` + +- [ ] **Step 1: `features.md` 회원 탈퇴 행 보강 (라인 54)** + +기존 셀의 마지막 문장을 다음으로 교체: +> 채팅/북마크/일정의 BIGINT 작성자 ID는 유지. 클라이언트는 `GET /rooms/{id}/members` 응답(ACTIVE + LEFT 모두 포함)에서 닉네임/프로필을 조회한다. 그래도 members에 없는 ID는 **회원 탈퇴자**이며 "(알 수 없음)"으로 표시한다. + +- [ ] **Step 2: `features.md` 방 멤버 목록/방 나가기 행 갱신 (라인 80, 82)** + +- 방 멤버 목록 조회 셀: `방 참여자 목록 + 역할(HOST/MEMBER) + 접속 상태` → `방 참여자 목록 + 역할(HOST/MEMBER) + 상태(ACTIVE/LEFT) + 접속 상태. LEFT 멤버는 닉네임/프로필 조회용으로 함께 반환되며 online은 항상 false.` +- 방 나가기 셀: `본인이 방에서 탈퇴` → `본인이 방에서 탈퇴(room_members status=LEFT, row 유지). 채팅 로그(USER_LEFT 시스템 메시지)가 시점의 정본.` +- 멤버 추방 셀에도 동일하게 `(status=LEFT, row 유지)` 한 줄 추가. + +- [ ] **Step 3: `erd.md` `room_members` 테이블 (라인 57~74) 컬럼/제약 추가** + +기존 컬럼 표 아래에 다음 두 행을 추가: + +| status | VARCHAR(20) | NOT NULL, DEFAULT 'ACTIVE' | ACTIVE / LEFT (방 나가기·추방 시 LEFT) | +| left_at | TIMESTAMP WITH TIME ZONE | NULLABLE | LEFT 진입 시각. ACTIVE 면 NULL. 재입장 UPSERT 시 NULL로 복귀 | + +`**제약:**` 줄을 다음으로 확장: +``` +**제약:** 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') +``` + +`**인덱스:**` 줄에 `(room_id, status) — 멤버 목록 조회 및 LEFT 룩업용` 한 줄 추가. + +- [ ] **Step 4: checking-md-conflicts 점검** + +문서 변경 후 자동 hook으로 트리거되는 `checking-md-conflicts` 스킬 결과가 "이슈 없음"인지 확인. + +- [ ] **Step 5: 커밋** + +```bash +git add docs/ai/features.md docs/ai/erd.md +git commit -m "docs: room_members status/left_at 도입을 features/erd에 반영" +``` + +--- + +## Task 14: 최종 빌드 + Checkstyle + Smoke 확인 + +- [ ] **Step 1: 클린 빌드 + 전체 테스트** + +Run: `./gradlew clean build` +Expected: BUILD SUCCESSFUL, 모든 테스트 PASS. + +- [ ] **Step 2: Checkstyle 0 warnings 재확인** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings. + +- [ ] **Step 3: 변경 파일 스모크 점검** + +`git diff --stat origin/main...HEAD` 로 다음을 확인: +- `V1.8__room_members_status.sql` 1개 신규 마이그레이션 +- `RoomMember`, `RoomMemberRepository`, `RoomMemberService`, `RoomInviteService`, `RoomAuthorizationService`, `RoomMemberResult`, `RoomMemberResponse`, `AiContextQueryRepositoryImpl` 수정 +- `MemberStatus` 1개 신규 enum +- `features.md`, `erd.md` 갱신 +- 신규/수정된 테스트가 위 변경을 커버 + +- [ ] **Step 4: PR 준비 가이드 (별도 task 아님, 참고)** + +`/review-code-against-docs` 스킬로 PR 생성 전 검증을 돌린다. + +--- + +## Self-Review 체크리스트 (구현자가 PR 직전에 점검) + +- 모든 활성 멤버 조회 쿼리가 `status='ACTIVE'` 를 포함하는가? (`grep -rn "RoomRole\\." src/main/java | grep "in (" ` 로 누락 검색) +- `RoomMember`의 새 메서드(leave/kicked/rejoinAsPending)에 단위 테스트가 있는가? +- LEFT 멤버에 대해 `requireActiveMember` 가 `NOT_ROOM_MEMBER` 를 던지는가? +- `getMembers` 응답에서 LEFT 의 `online` 이 항상 false 인가? +- `requestJoin` 이 LEFT row를 같은 row로 UPSERT 부활시키는가? (`saveAndFlush`로 새 row 만들지 않음) +- 회원 탈퇴(`UserWithdrawalService`)가 LEFT row 까지 모두 삭제하는가? +- DB CHECK 제약 3개가 통합 테스트로 검증되는가? +- features.md / erd.md 가 동시에 갱신되었는가? diff --git a/docs/superpowers/specs/2026-06-08-room-member-left-status-design.md b/docs/superpowers/specs/2026-06-08-room-member-left-status-design.md new file mode 100644 index 00000000..721d9b63 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-room-member-left-status-design.md @@ -0,0 +1,269 @@ +# Room Member LEFT Status Design + +- **작성일**: 2026-06-08 +- **상태**: 초안 (사용자 리뷰 대기) +- **관련 도메인**: `rooms`, `messages`, `ai`, `user` + +## 배경 + +현재 `room_members`는 회원 탈퇴(withdrawal), 단순 방 나가기(leave), 추방(kick) 세 경우 모두 row를 hard delete 한다. + +- 회원 탈퇴: `users` row는 soft delete + 익명화되므로 사용자 정보(닉네임/프로필) 자체가 사라짐 → '(알 수 없음)' 표시가 자연스러움. +- 방 나가기 / 추방: `users` row는 정상적으로 살아 있는데, `room_members`에서 row가 사라져 클라이언트가 과거 메시지의 작성자 닉네임/프로필을 표시할 수 없음. + +→ "탈퇴는 hard delete 유지, 단순 방 나가기·추방은 row를 남기고 상태로 구분"하여 과거 메시지 렌더링에 닉네임/프로필을 노출한다. + +## 목표 + +1. 회원 탈퇴 시 `room_members`는 hard delete를 유지한다. +2. 방 나가기(leave) / 추방(kick) 시 row를 남기고 `status=LEFT`로 표시한다. +3. 클라이언트가 `GET /rooms/{id}/members` 한 번으로 ACTIVE + LEFT 멤버 정보를 받아 과거 메시지에 닉네임/프로필을 렌더링한다. +4. LEFT 멤버의 재입장이 가능하도록 한다(같은 row를 UPSERT로 부활). + +## 비목표 + +- "언제 누가 나갔는가"의 영속 audit 로그 — 채팅 시스템 메시지(`USER_LEFT`, `MEMBER_KICKED`)가 정본. 재입장 UPSERT 시 `left_at`은 휘발된다. +- 자발 나감 / 추방 구분(LEFT/KICKED) — 단일 `LEFT`로 통일. +- PENDING 상태에서 LEFT로 전이 — 입장 요청 취소는 기존 hard delete 정책 유지. + +## 데이터 모델 + +### 마이그레이션 `V1.8__room_members_status.sql` (신규) + +```sql +-- 1) status / left_at 컬럼 추가 +ALTER TABLE room_members + ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + ADD COLUMN left_at TIMESTAMP WITH TIME ZONE; + +-- 2) status 유효값 +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_status + CHECK (status IN ('ACTIVE', 'LEFT')); + +-- 3) status ↔ left_at 무결성 +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_status_left_at + CHECK ( + (status = 'ACTIVE' AND left_at IS NULL) + OR (status = 'LEFT' AND left_at IS NOT NULL) + ); + +-- 4) LEFT row의 role=PENDING 차단 (PENDING의 LEFT 전이는 정책상 불가) +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_left_role + CHECK (status = 'ACTIVE' OR role <> 'PENDING'); + +-- 5) 멤버 목록/LEFT 룩업 인덱스 +CREATE INDEX idx_room_members_room_id_status ON room_members (room_id, status); + +-- 기존 UNIQUE(room_id, user_id)는 유지 — 재입장은 같은 row UPSERT. +``` + +### 엔티티 변경 + +**`MemberStatus` (신규 enum)** +```java +public enum MemberStatus { ACTIVE, LEFT } +``` + +**`RoomMember`** +- 필드 추가 + - `MemberStatus status` — NOT NULL, default `ACTIVE` + - `Instant leftAt` — nullable +- 도메인 메서드 신규 + - `leave()` — status=LEFT, leftAt=Instant.now(). 전제: status=ACTIVE. role은 그대로(스냅샷). + - `kicked()` — leave()와 동일 처리(LEFT 통일 정책). 별도 메서드명으로 의미 분리. + - `rejoinAsPending()` — status=ACTIVE, role=PENDING, leftAt=null. 전제: status=LEFT. +- 기존 도메인 메서드 가드 강화 + - `approve()`, `promoteToHost()`, `demoteToMember()` 모두 `status == ACTIVE` 일 때만 허용. 위반 시 `IllegalStateException`. + +### 상태 전이 + +``` +[없음] + └ requestJoin ─▶ ACTIVE/PENDING ─approve─▶ ACTIVE/MEMBER ─promote─▶ ACTIVE/HOST + │ │ + leave/kick │ │ demote + ▼ ▼ + LEFT/MEMBER ACTIVE/MEMBER + │ + requestJoin (재입장) + ▼ + ACTIVE/PENDING ─approve─▶ ACTIVE/MEMBER +``` + +- HOST는 위임 후 MEMBER로 강등된 뒤에만 leave 가능 → LEFT row의 role은 항상 `MEMBER`. +- LEFT row의 `role`은 LEFT 진입 시점의 마지막 역할 스냅샷. + +## 서비스 / 권한 변경 + +### `RoomMemberService` +- `leave()` — `roomMemberRepository.delete(member)` 대신 `member.leave()`. 이벤트(`MemberLeftEvent`) / 시스템 메시지 그대로. +- `kick()` — `roomMemberRepository.delete(target)` 대신 `target.kicked()`. 이벤트(`MemberKickedEvent`) / 시스템 메시지 그대로. +- LEFT 멤버를 다시 kick 시도 → `kick()`은 `findByRoom_IdAndUser_Id`로 status 무관 row를 조회하므로 별도 가드 추가 필요. 기존 role 체크 앞에 `target.getStatus() != MemberStatus.ACTIVE` 면 `KICK_TARGET_NOT_MEMBER`로 거부. + +### `RoomAuthorizationService.requireActiveMember` +```java +RoomMember member = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId) + .orElseThrow(() -> new CustomException(NOT_ROOM_MEMBER)); +if (member.getStatus() == MemberStatus.LEFT) { + throw new CustomException(NOT_ROOM_MEMBER); // 신규 +} +if (member.getRole() == RoomRole.PENDING) { + throw new CustomException(NOT_ROOM_MEMBER); +} +return member; +``` +LEFT는 권한·구독·메시지 발송 등 모든 ACTIVE 경로에서 비멤버로 취급. + +### `RoomInviteService.requestJoin` (재입장 분기) +```java +if (existing.isPresent()) { + RoomMember member = existing.get(); + if (member.getStatus() == MemberStatus.LEFT) { + member.rejoinAsPending(); // status=ACTIVE, role=PENDING, leftAt=null + publishJoinRequestedEvent(...); // 기존과 동일 + return JoinResult.pending(...); + } + if (member.getRole() == RoomRole.PENDING) return JoinResult.pending(...); + return JoinResult.alreadyMember(...); +} +// else: 기존 신규 가입 로직(saveAndFlush PENDING) 그대로 +``` +- `lastReadMessageId`는 부활 시점에 손대지 않음. 이후 `approve()`에서 `readStatusService.initializeForNewMember(...)`가 "재입장 시점 최신 메시지 ID"로 점프 → 사용자 결정과 일치. +- 동시 재요청 시 UNIQUE(room_id, user_id) 충돌은 기존 `DataIntegrityViolationException` fallback 경로로 처리. + +### `RoomInviteService.approve` — 변경 없음 +`target.approve()` 가드가 `status==ACTIVE`이므로 LEFT row를 잘못 승인할 위험 없음. + +### `UserWithdrawalService` — 변경 없음 (동작은 의도대로) +- `findAllByUser_Id(userId)`는 status 무관 전체 row 반환 → ACTIVE/LEFT 모두 hard delete. +- HOST 위임 필요 판단 쿼리(`findHostRoomsWithOnlySelf`, `findHostRoomsWithOtherActiveMembers`)는 아래 리포지토리 변경에서 `status='ACTIVE'` 필터 추가로 정합 유지. + +## 리포지토리 / 쿼리 변경 + +| 메서드 | 변경 | +|---|---| +| `findByRoom_IdAndRole` | `AND status='ACTIVE'` 추가 | +| `findByRoom_IdAndRoleIn` | `AND status='ACTIVE'` 추가 | +| `countByRoom_IdAndRoleIn` | `AND status='ACTIVE'` 추가 | +| `findByUser_IdAndRoleInOrderByJoinedAtDesc` | `AND status='ACTIVE'` 추가 | +| `findByUser_IdAndRoleInAndJoinedAtBeforeOrderByJoinedAtDesc` | `AND status='ACTIVE'` 추가 | +| `findHostRoomsWithOnlySelf` (JPQL) | `m.status='ACTIVE'`, `other.status='ACTIVE'` 추가 | +| `findHostRoomsWithOtherActiveMembers` (JPQL) | 동일 | +| `findAllByUser_Id` | **변경 없음** (withdraw에서 status 무관 전체 삭제) | +| `findByRoom_IdAndUser_Id` | **변경 없음** (LEFT 부활/감지에 필요) | +| `findByIdAndRoom_Id` | **변경 없음** (approve 가드가 status 검증) | + +**신규 메서드 (멤버 목록 전용)** +```java +@EntityGraph(attributePaths = "user") +@Query(""" + select m from RoomMember m + where m.room.id = :roomId + and ((m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and m.role in (com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER)) + or m.status = com.howaboutus.backend.rooms.entity.MemberStatus.LEFT) +""") +List findVisibleMembers(@Param("roomId") UUID roomId); +``` +`RoomMemberService.getMembers`에서 호출. PENDING은 제외(가입 대기자는 멤버 UI에 노출 안 함, 기존과 동일). + +### `AiContextQueryRepositoryImpl` +- 라인 38 인근 쿼리(`roles IN (HOST, MEMBER)`)에 `status='ACTIVE'` 필터 추가. AI 컨텍스트에 떠난 멤버 노출 방지. + +## API 응답 + +### `GET /rooms/{roomId}/members` + +```jsonc +{ + "members": [ + { + "userId": 1, "nickname": "호스트", "profileImageUrl": "...", + "role": "HOST", "status": "ACTIVE", "online": true, + "joinedAt": "2026-06-01T10:00:00Z" + }, + { + "userId": 7, "nickname": "나간사람", "profileImageUrl": "...", + "role": "MEMBER", + "status": "LEFT", + "online": false, + "joinedAt": "2026-05-20T09:00:00Z" + } + ] +} +``` + +- `status` 필드 신규 (`ACTIVE` | `LEFT`). +- LEFT 멤버의 `online`은 **항상 false** (Redis presence 조회 무시). +- `leftAt`은 응답에 노출하지 않음. UPSERT로 휘발되는 값이라 클라이언트가 의존하면 부정확. "언제 떠났는가"는 채팅 시스템 메시지(`USER_LEFT`, `MEMBER_KICKED`)가 정본. +- 정렬: ACTIVE(HOST 우선, 그 다음 MEMBER joinedAt 오름차순) → LEFT(joinedAt 오름차순). +- 권한: 기존과 동일하게 `requireActiveMember`. LEFT 멤버는 본인이 떠난 방의 멤버 목록 조회 불가. + +### 시스템 메시지 / 실시간 브로드캐스트 — 변경 없음 +- `USER_LEFT`, `MEMBER_KICKED`, `MEMBER_APPROVED` 그대로. +- 클라이언트는 `MEMBER_APPROVED` 수신 시 멤버 목록을 갱신하면, LEFT였던 사람이 ACTIVE로 바뀐 게 자연스럽게 반영됨. + +## 미해결 작성자 표시 정책 + +`features.md` 회원 탈퇴 행 갱신: +> 채팅/북마크/일정의 BIGINT 작성자 ID는 유지. 클라이언트는 `GET /rooms/{id}/members` 응답(ACTIVE + LEFT 모두 포함)에서 닉네임/프로필을 조회한다. 그래도 members에 없는 ID는 **회원 탈퇴자**이며 "(알 수 없음)"으로 표시한다. + +## 테스트 계획 + +### 단위 — `RoomMemberTest` +- `leave()` → status=LEFT, leftAt != null, role 유지 +- `kicked()` → 동일 +- `rejoinAsPending()` → status=ACTIVE, role=PENDING, leftAt=null +- `approve()` / `promoteToHost()` / `demoteToMember()`가 status=LEFT에서 `IllegalStateException` + +### 단위 — `RoomAuthorizationServiceTest` +- LEFT 멤버에 대해 `requireActiveMember` → `NOT_ROOM_MEMBER` +- `requireHost`는 위와 동일하게 차단 + +### 통합 — `RoomMemberServiceTest` +- leave 후 row 존재 + status=LEFT, ACTIVE 카운트 감소, `MemberLeftEvent` 발행 +- kick 후 동일 (status=LEFT, `MemberKickedEvent` 발행) +- HOST가 leave 시도 → `CANNOT_LEAVE_AS_HOST` (기존) +- LEFT 상태의 멤버를 다시 kick → `KICK_TARGET_NOT_MEMBER` +- `getMembers` 응답이 ACTIVE + LEFT 둘 다 포함, LEFT.online=false, 정렬 순서 + +### 통합 — `RoomInviteServiceTest` +- LEFT 유저가 invite code로 재요청 → 같은 row가 ACTIVE/PENDING으로 부활, `JoinRequestedEvent` 발행 +- 재요청 시 lastReadMessageId는 부활 시점에 변하지 않고, `approve` 이후 `initializeForNewMember`가 점프 처리 +- 동시 재요청 → UNIQUE 충돌 fallback 동작 유지 + +### 통합 — `UserWithdrawalServiceTest` +- LEFT row를 가진 유저가 탈퇴 → 해당 LEFT row까지 hard delete +- 본인이 HOST인 방에 다른 멤버가 LEFT만 있으면 solo host로 판정되어 자동 삭제 (LEFT는 other active로 카운트 안 됨) + +### DB 제약 — 마이그레이션/통합 테스트 +- `status=LEFT` + `left_at IS NULL` insert → CHECK 위반 +- `status=ACTIVE` + `left_at NOT NULL` insert → CHECK 위반 +- `status=LEFT` + `role=PENDING` insert → CHECK 위반 + +### AI 컨텍스트 회귀 +- `AiContextQueryRepositoryImpl` 쿼리 결과에 LEFT 멤버가 포함되지 않음 + +## 함께 갱신할 문서 + +- `docs/ai/features.md` + - "방 나가기" 행: `room_members hard delete` → `room_members status=LEFT (row 유지)` + - "회원 탈퇴" 행: 작성자 표시 정책을 위 "미해결 작성자 표시 정책" 문장으로 보강 +- `docs/ai/erd.md` + - `room_members` 테이블에 `status`, `left_at` 컬럼 + 3개 CHECK 제약 + 인덱스 명시 + +## 변경 영향 요약 + +| 영역 | 변경 | +|---|---| +| DB | `room_members` 컬럼 2개 + CHECK 3개 + 인덱스 1개 | +| 엔티티 | `RoomMember` 필드 2개 + 메서드 3개, `MemberStatus` enum 신규 | +| 서비스 | `RoomMemberService`(leave/kick), `RoomInviteService`(requestJoin), `RoomAuthorizationService`(LEFT 차단) | +| 리포지토리 | 6개 메서드에 `status='ACTIVE'` 필터 추가, 멤버 목록 전용 쿼리 1개 신설 | +| 외부 도메인 | `AiContextQueryRepositoryImpl` 1개 쿼리 | +| API | `GET /rooms/{id}/members` 응답에 `status` 필드 추가, LEFT 멤버 포함 | +| 문서 | features.md, erd.md | From 76b4cec2724ce3530fff955538b1d36501c2fcfb Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:23:25 +0900 Subject: [PATCH 06/20] =?UTF-8?q?feat:=20V1.8=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=EC=9C=BC=EB=A1=9C=20room?= =?UTF-8?q?=5Fmembers=20status/left=5Fat=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration/V1.8__room_members_status.sql | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main/resources/db/migration/V1.8__room_members_status.sql diff --git a/src/main/resources/db/migration/V1.8__room_members_status.sql b/src/main/resources/db/migration/V1.8__room_members_status.sql new file mode 100644 index 00000000..212f35dd --- /dev/null +++ b/src/main/resources/db/migration/V1.8__room_members_status.sql @@ -0,0 +1,33 @@ +-- =========================================== +-- V1.8: room_members 에 status / left_at 도입 +-- ACTIVE = 활성 멤버(HOST/MEMBER/PENDING), LEFT = 방 나가기/추방으로 떠난 멤버 +-- 회원 탈퇴는 별도로 hard delete (row 자체 삭제) — 본 마이그레이션과 무관 +-- =========================================== + +-- 1) status / left_at 컬럼 추가 +ALTER TABLE room_members + ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + ADD COLUMN left_at TIMESTAMP WITH TIME ZONE; + +-- 2) status 유효값 +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_status + CHECK (status IN ('ACTIVE', 'LEFT')); + +-- 3) status ↔ left_at 무결성 +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_status_left_at + CHECK ( + (status = 'ACTIVE' AND left_at IS NULL) + OR (status = 'LEFT' AND left_at IS NOT NULL) + ); + +-- 4) LEFT row의 role=PENDING 차단 (PENDING의 LEFT 전이는 정책상 불가) +ALTER TABLE room_members + ADD CONSTRAINT ck_room_members_left_role + CHECK (status = 'ACTIVE' OR role <> 'PENDING'); + +-- 5) 멤버 목록 / LEFT 룩업 인덱스 +CREATE INDEX idx_room_members_room_id_status ON room_members (room_id, status); + +-- 기존 UNIQUE(room_id, user_id)는 유지 — 재입장은 같은 row UPSERT. From 240cd5a59985e88f3f9993a9b6ea1f331cf07fe4 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:26:00 +0900 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20RoomMember=EC=97=90=20MemberStatu?= =?UTF-8?q?s(LEFT)=20+=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/rooms/entity/MemberStatus.java | 5 ++ .../backend/rooms/entity/RoomMember.java | 42 ++++++++++ .../backend/rooms/entity/RoomMemberTest.java | 83 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java diff --git a/src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java b/src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java new file mode 100644 index 00000000..5f0dce25 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/rooms/entity/MemberStatus.java @@ -0,0 +1,5 @@ +package com.howaboutus.backend.rooms.entity; + +public enum MemberStatus { + ACTIVE, LEFT +} diff --git a/src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java b/src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java index 14c1b1c9..fdd0796b 100644 --- a/src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java +++ b/src/main/java/com/howaboutus/backend/rooms/entity/RoomMember.java @@ -51,6 +51,13 @@ public class RoomMember extends BaseTimeEntity { @Column(nullable = false) private Instant joinedAt; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private MemberStatus status = MemberStatus.ACTIVE; + + @Column(name = "left_at") + private Instant leftAt; + // MongoDB ObjectId 문자열 (24자). 이 멤버가 마지막으로 읽은 메시지 ID. // null이면 아직 한 번도 읽지 않은 상태 → 모든 메시지가 안읽음 처리됨. @Column(length = 24) @@ -61,6 +68,7 @@ private RoomMember(Room room, User user, RoomRole role) { this.user = user; this.role = role; this.joinedAt = Instant.now(); + this.status = MemberStatus.ACTIVE; } public static RoomMember create(Room room, User user, RoomRole role) { @@ -81,6 +89,9 @@ public void updateLastReadMessageId(String messageId) { } public void approve() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 승인할 수 있습니다. 현재 상태: " + this.status); + } if (this.role != RoomRole.PENDING) { throw new IllegalStateException("PENDING 상태의 멤버만 승인할 수 있습니다. 현재 상태: " + this.role); } @@ -88,6 +99,9 @@ public void approve() { } public void promoteToHost() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 승격할 수 있습니다. 현재 상태: " + this.status); + } if (this.role != RoomRole.MEMBER) { throw new IllegalStateException("MEMBER 상태의 멤버만 HOST로 승격할 수 있습니다. 현재 상태: " + this.role); } @@ -95,9 +109,37 @@ public void promoteToHost() { } public void demoteToMember() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 강등할 수 있습니다. 현재 상태: " + this.status); + } if (this.role != RoomRole.HOST) { throw new IllegalStateException("HOST 상태의 멤버만 MEMBER로 강등할 수 있습니다. 현재 상태: " + this.role); } this.role = RoomRole.MEMBER; } + + public void leave() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 LEFT로 전환할 수 있습니다. 현재 상태: " + this.status); + } + this.status = MemberStatus.LEFT; + this.leftAt = Instant.now(); + } + + public void kicked() { + if (this.status != MemberStatus.ACTIVE) { + throw new IllegalStateException("ACTIVE 상태의 멤버만 LEFT로 전환할 수 있습니다. 현재 상태: " + this.status); + } + this.status = MemberStatus.LEFT; + this.leftAt = Instant.now(); + } + + public void rejoinAsPending() { + if (this.status != MemberStatus.LEFT) { + throw new IllegalStateException("LEFT 상태의 멤버만 재입장(PENDING) 으로 부활할 수 있습니다. 현재 상태: " + this.status); + } + this.status = MemberStatus.ACTIVE; + this.role = RoomRole.PENDING; + this.leftAt = null; + } } diff --git a/src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java b/src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java index 69d6ac17..e963c737 100644 --- a/src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/entity/RoomMemberTest.java @@ -2,8 +2,11 @@ import static org.assertj.core.api.Assertions.*; +import java.time.Instant; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; import com.howaboutus.backend.user.entity.User; @@ -50,4 +53,84 @@ void demoteToMemberFailsWhenNotHost() { RoomMember member = createMember(RoomRole.MEMBER); assertThatThrownBy(member::demoteToMember).isInstanceOf(IllegalStateException.class); } + + @Test + @DisplayName("create - 신규 멤버는 status=ACTIVE, leftAt=null 로 시작한다") + void createStartsActive() { + RoomMember member = createMember(RoomRole.MEMBER); + assertThat(member.getStatus()).isEqualTo(MemberStatus.ACTIVE); + assertThat(member.getLeftAt()).isNull(); + } + + @Test + @DisplayName("leave - ACTIVE 멤버를 LEFT로 전환하고 leftAt 을 채운다") + void leaveTransitionsToLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + assertThat(member.getStatus()).isEqualTo(MemberStatus.LEFT); + assertThat(member.getLeftAt()).isNotNull(); + assertThat(member.getRole()).isEqualTo(RoomRole.MEMBER); + } + + @Test + @DisplayName("leave - 이미 LEFT면 예외") + void leaveFailsWhenAlreadyLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + assertThatThrownBy(member::leave).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("kicked - ACTIVE 멤버를 LEFT로 전환하고 leftAt 을 채운다") + void kickedTransitionsToLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.kicked(); + assertThat(member.getStatus()).isEqualTo(MemberStatus.LEFT); + assertThat(member.getLeftAt()).isNotNull(); + } + + @Test + @DisplayName("rejoinAsPending - LEFT 멤버를 ACTIVE/PENDING 으로 부활시킨다") + void rejoinAsPendingRevives() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + member.rejoinAsPending(); + assertThat(member.getStatus()).isEqualTo(MemberStatus.ACTIVE); + assertThat(member.getRole()).isEqualTo(RoomRole.PENDING); + assertThat(member.getLeftAt()).isNull(); + } + + @Test + @DisplayName("rejoinAsPending - ACTIVE 멤버에 호출하면 예외") + void rejoinAsPendingFailsWhenActive() { + RoomMember member = createMember(RoomRole.MEMBER); + assertThatThrownBy(member::rejoinAsPending).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("approve - LEFT 상태면 예외") + void approveFailsWhenLeft() { + RoomMember member = createMember(RoomRole.PENDING); + // PENDING 인 채 LEFT 로 만드는 직접 경로는 없으므로, 직접 필드 설정 헬퍼 사용 + ReflectionTestUtils.setField(member, "status", MemberStatus.LEFT); + ReflectionTestUtils.setField(member, "leftAt", Instant.now()); + assertThatThrownBy(member::approve).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("promoteToHost - LEFT 상태면 예외") + void promoteToHostFailsWhenLeft() { + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + assertThatThrownBy(member::promoteToHost).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("demoteToMember - LEFT 상태면 예외") + void demoteToMemberFailsWhenLeft() { + RoomMember host = createMember(RoomRole.HOST); + ReflectionTestUtils.setField(host, "status", MemberStatus.LEFT); + ReflectionTestUtils.setField(host, "leftAt", Instant.now()); + assertThatThrownBy(host::demoteToMember).isInstanceOf(IllegalStateException.class); + } } From 8250358db42e17bc55c99d003f346cc680a9d6f9 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:26:53 +0900 Subject: [PATCH 08/20] =?UTF-8?q?feat:=20requireActiveMember=EC=97=90?= =?UTF-8?q?=EC=84=9C=20LEFT=20=EB=A9=A4=EB=B2=84=EB=A5=BC=20=EB=B9=84?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=EB=A1=9C=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RoomAuthorizationService.java | 4 ++ .../service/RoomAuthorizationServiceTest.java | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java b/src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java index 216c9c97..a84052d7 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/RoomAuthorizationService.java @@ -7,6 +7,7 @@ import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.RoomMember; import com.howaboutus.backend.rooms.entity.RoomRole; import com.howaboutus.backend.rooms.repository.RoomMemberRepository; @@ -23,6 +24,9 @@ public class RoomAuthorizationService { public RoomMember requireActiveMember(UUID roomId, Long userId) { RoomMember member = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); + if (member.getStatus() == MemberStatus.LEFT) { + throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); + } if (member.getRole() == RoomRole.PENDING) { throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); } diff --git a/src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java b/src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java index 1508da70..7903a482 100644 --- a/src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/service/RoomAuthorizationServiceTest.java @@ -116,6 +116,43 @@ void requireHostRejectsMissingMember() { .isEqualTo(ErrorCode.NOT_ROOM_MEMBER); } + @Test + @DisplayName("LEFT 멤버는 비멤버로 차단된다") + void requireActiveMemberRejectsLeft() { + UUID roomId = UUID.randomUUID(); + Long userId = 7L; + RoomMember member = createMember(RoomRole.MEMBER); + member.leave(); + RoomAuthorizationService service = new RoomAuthorizationService(roomMemberRepository); + + given(roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId)).willReturn(Optional.of(member)); + + assertThatThrownBy(() -> service.requireActiveMember(roomId, userId)) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_ROOM_MEMBER); + } + + @Test + @DisplayName("LEFT 멤버는 HOST 검증에서도 차단된다") + void requireHostRejectsLeft() { + UUID roomId = UUID.randomUUID(); + Long userId = 8L; + RoomMember member = createMember(RoomRole.HOST); + // HOST는 위임 후에만 leave 가능하므로 직접 LEFT로 전환은 호출 경로상 불가하나, + // 가드의 안전성 확인을 위해 멤버를 강제로 LEFT 상태로 만든다. + member.demoteToMember(); + member.leave(); + RoomAuthorizationService service = new RoomAuthorizationService(roomMemberRepository); + + given(roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId)).willReturn(Optional.of(member)); + + assertThatThrownBy(() -> service.requireHost(roomId, userId)) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NOT_ROOM_MEMBER); + } + private RoomMember createMember(RoomRole role) { Room room = Room.create("도쿄 여행", "도쿄", null, null, "INVITE", 1L); User user = User.ofGoogle("google-1", "user@example.com", "사용자", null); From 0fa58752f4c4364bbb7f2181ddd1e45af55c7c1b Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:27:43 +0900 Subject: [PATCH 09/20] =?UTF-8?q?refactor:=20leave=EA=B0=80=20hard=20delet?= =?UTF-8?q?e=20=EB=8C=80=EC=8B=A0=20status=3DLEFT=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/rooms/service/RoomMemberService.java | 2 +- .../backend/rooms/service/RoomMemberServiceTest.java | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java b/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java index 87d9564c..b82b620f 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java @@ -102,7 +102,7 @@ public void leave(UUID roomId, Long userId) { throw new CustomException(ErrorCode.CANNOT_LEAVE_AS_HOST); } - roomMemberRepository.delete(member); + member.leave(); eventPublisher.publishEvent(new MemberLeftEvent( roomId, member.getUser().getId(), diff --git a/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java b/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java index 025bada1..9eb5cfcb 100644 --- a/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java @@ -234,8 +234,8 @@ void kickThrowsWhenTargetNotFound() { } @Test - @DisplayName("leave 성공 - 멤버 삭제 + 이벤트 발행") - void leaveDeletesMemberAndPublishesEvent() { + @DisplayName("leave 성공 - row 삭제하지 않고 status=LEFT 로 전환하며 이벤트 발행") + void leaveTransitionsToLeftAndPublishesEvent() { User member = User.ofGoogle("g2", "member@test.com", "멤버", "https://img/member.jpg"); ReflectionTestUtils.setField(member, "id", TARGET_USER_ID); @@ -249,7 +249,10 @@ void leaveDeletesMemberAndPublishesEvent() { roomMemberService.leave(ROOM_ID, TARGET_USER_ID); - then(roomMemberRepository).should().delete(regularMember); + assertThat(regularMember.getStatus()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.LEFT); + assertThat(regularMember.getLeftAt()).isNotNull(); + then(roomMemberRepository).should(never()).delete(any(RoomMember.class)); then(eventPublisher).should().publishEvent(any(MemberLeftEvent.class)); } From 0244ec00169190191e009dd7ce64a9b1ecbba236 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:28:40 +0900 Subject: [PATCH 10/20] =?UTF-8?q?refactor:=20kick=EC=9D=B4=20hard=20delete?= =?UTF-8?q?=20=EB=8C=80=EC=8B=A0=20status=3DLEFT=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rooms/service/RoomMemberService.java | 15 +++++--- .../rooms/service/RoomMemberServiceTest.java | 36 +++++++++++++++++-- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java b/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java index b82b620f..b1dc6a91 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java @@ -17,6 +17,7 @@ import com.howaboutus.backend.realtime.event.MemberKickedEvent; import com.howaboutus.backend.realtime.event.MemberLeftEvent; import com.howaboutus.backend.realtime.service.RoomPresenceService; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.RoomMember; import com.howaboutus.backend.rooms.entity.RoomRole; import com.howaboutus.backend.rooms.repository.RoomMemberRepository; @@ -73,18 +74,22 @@ public void kick(UUID roomId, Long targetUserId, Long hostUserId) { //2. 타겟이 방 멤버인지 체크 RoomMember target = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, targetUserId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_MEMBER_NOT_FOUND)); - //3. 호스트를 킥하려는지 체크, 호스트는 자기 자신을 추방할 수 없음 + //3. 이미 LEFT 상태인 멤버는 다시 추방할 수 없음 (row는 닉네임/프로필 노출용으로만 남아 있음) + if (target.getStatus() != MemberStatus.ACTIVE) { + throw new CustomException(ErrorCode.KICK_TARGET_NOT_MEMBER); + } + //4. 호스트를 킥하려는지 체크, 호스트는 자기 자신을 추방할 수 없음 if (target.getRole() == RoomRole.HOST) { throw new CustomException(ErrorCode.CANNOT_KICK_HOST); } - //4. 타겟이 멤버인지 체크, 멤버만 추방 가능 + //5. 타겟이 멤버인지 체크, 멤버만 추방 가능 if (target.getRole() != RoomRole.MEMBER) { throw new CustomException(ErrorCode.KICK_TARGET_NOT_MEMBER); } - //5. DB에서 멤버 추방처리 - roomMemberRepository.delete(target); - //6. 추방 이벤트 발행 + //6. status=LEFT 로 전환 (row 유지 — 과거 메시지 렌더링용 닉네임/프로필 보존) + target.kicked(); + //7. 추방 이벤트 발행 eventPublisher.publishEvent(new MemberKickedEvent( roomId, target.getUser().getId(), diff --git a/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java b/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java index 9eb5cfcb..74b684a1 100644 --- a/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java @@ -143,8 +143,8 @@ private RoomMember createDummyMember() { } @Test - @DisplayName("kick 성공 - 멤버 삭제 + 이벤트 발행") - void kickDeletesMemberAndPublishesEvent() { + @DisplayName("kick 성공 - row 삭제하지 않고 status=LEFT 로 전환하며 이벤트 발행") + void kickTransitionsToLeft() { User host = User.ofGoogle("g1", "host@test.com", "호스트", "https://img/host.jpg"); ReflectionTestUtils.setField(host, "id", HOST_USER_ID); User target = User.ofGoogle("g2", "target@test.com", "타겟", "https://img/target.jpg"); @@ -162,10 +162,40 @@ void kickDeletesMemberAndPublishesEvent() { roomMemberService.kick(ROOM_ID, TARGET_USER_ID, HOST_USER_ID); - then(roomMemberRepository).should().delete(targetMember); + assertThat(targetMember.getStatus()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.LEFT); + assertThat(targetMember.getLeftAt()).isNotNull(); + then(roomMemberRepository).should(never()).delete(any(RoomMember.class)); then(eventPublisher).should().publishEvent(any(MemberKickedEvent.class)); } + @Test + @DisplayName("kick - 대상이 이미 LEFT 면 KICK_TARGET_NOT_MEMBER") + void kickThrowsWhenTargetAlreadyLeft() { + User host = User.ofGoogle("g1", "host@test.com", "호스트", null); + ReflectionTestUtils.setField(host, "id", HOST_USER_ID); + User target = User.ofGoogle("g2", "target@test.com", "타겟", null); + ReflectionTestUtils.setField(target, "id", TARGET_USER_ID); + + Room room = Room.create("여행", "부산", null, null, "invite1", HOST_USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + + RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); + RoomMember targetMember = RoomMember.create(room, target, RoomRole.MEMBER); + targetMember.leave(); + + given(roomAuthorizationService.requireHost(ROOM_ID, HOST_USER_ID)).willReturn(hostMember); + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, TARGET_USER_ID)) + .willReturn(Optional.of(targetMember)); + + assertThatThrownBy(() -> roomMemberService.kick(ROOM_ID, TARGET_USER_ID, HOST_USER_ID)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException)ex).getErrorCode()) + .isEqualTo(ErrorCode.KICK_TARGET_NOT_MEMBER)); + then(roomMemberRepository).should(never()).delete(any(RoomMember.class)); + then(eventPublisher).shouldHaveNoInteractions(); + } + @Test @DisplayName("kick - HOST를 추방하려 하면 CANNOT_KICK_HOST") void kickThrowsWhenTargetIsHost() { From c1c91644baa59deb49bdabf3ba8c23654673d0ee Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:30:25 +0900 Subject: [PATCH 11/20] =?UTF-8?q?refactor:=20=ED=99=9C=EC=84=B1=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=EC=BF=BC=EB=A6=AC=EC=97=90=20status=3DACT?= =?UTF-8?q?IVE=20=ED=95=84=ED=84=B0=20=EC=9D=BC=EA=B4=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/RoomMemberRepository.java | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java b/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java index 6c0e83ba..a94cb45b 100644 --- a/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java +++ b/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java @@ -21,22 +21,56 @@ public interface RoomMemberRepository extends JpaRepository { Optional findByRoom_IdAndUser_Id(UUID roomId, Long userId); @EntityGraph(attributePaths = "user") - List findByRoom_IdAndRole(UUID roomId, RoomRole role); + @Query(""" + select m from RoomMember m + where m.room.id = :roomId + and m.role = :role + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + """) + List findByRoom_IdAndRole(@Param("roomId") UUID roomId, @Param("role") RoomRole role); @EntityGraph(attributePaths = "user") - List findByRoom_IdAndRoleIn(UUID roomId, List roles); + @Query(""" + select m from RoomMember m + where m.room.id = :roomId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + """) + List findByRoom_IdAndRoleIn(@Param("roomId") UUID roomId, @Param("roles") List roles); Optional findByIdAndRoom_Id(Long id, UUID roomId); @EntityGraph(attributePaths = "room") + @Query(""" + select m from RoomMember m + where m.user.id = :userId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + order by m.joinedAt desc + """) List findByUser_IdAndRoleInOrderByJoinedAtDesc( - Long userId, List roles, Pageable pageable); + @Param("userId") Long userId, @Param("roles") List roles, Pageable pageable); @EntityGraph(attributePaths = "room") + @Query(""" + select m from RoomMember m + where m.user.id = :userId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and m.joinedAt < :cursor + order by m.joinedAt desc + """) List findByUser_IdAndRoleInAndJoinedAtBeforeOrderByJoinedAtDesc( - Long userId, List roles, Instant cursor, Pageable pageable); + @Param("userId") Long userId, @Param("roles") List roles, + @Param("cursor") Instant cursor, Pageable pageable); - long countByRoom_IdAndRoleIn(UUID roomId, List roles); + @Query(""" + select count(m) from RoomMember m + where m.room.id = :roomId + and m.role in :roles + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + """) + long countByRoom_IdAndRoleIn(@Param("roomId") UUID roomId, @Param("roles") List roles); List findAllByUser_Id(Long userId); @@ -45,6 +79,7 @@ List findByUser_IdAndRoleInAndJoinedAtBeforeOrderByJoinedAtDesc( from RoomMember m where m.user.id = :userId and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE and not exists ( select 1 from RoomMember other where other.room.id = m.room.id @@ -52,6 +87,7 @@ and not exists ( and other.role in ( com.howaboutus.backend.rooms.entity.RoomRole.HOST, com.howaboutus.backend.rooms.entity.RoomRole.MEMBER) + and other.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE ) """) List findHostRoomsWithOnlySelf(@Param("userId") Long userId); @@ -67,6 +103,7 @@ interface RoomRequiringDelegationView { from RoomMember m where m.user.id = :userId and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + and m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE and exists ( select 1 from RoomMember other where other.room.id = m.room.id @@ -74,6 +111,7 @@ and exists ( and other.role in ( com.howaboutus.backend.rooms.entity.RoomRole.HOST, com.howaboutus.backend.rooms.entity.RoomRole.MEMBER) + and other.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE ) """) List findHostRoomsWithOtherActiveMembers(@Param("userId") Long userId); From b0b39f5118add637367795c992174eab84b4a960 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:33:03 +0900 Subject: [PATCH 12/20] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20DTO=EC=97=90=20status=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/rooms/controller/dto/RoomMemberResponse.java | 3 +++ .../backend/rooms/service/RoomMemberService.java | 1 + .../backend/rooms/service/dto/RoomMemberResult.java | 2 ++ .../backend/rooms/controller/RoomControllerTest.java | 8 ++++++-- .../backend/rooms/service/RoomMemberServiceTest.java | 4 ++++ 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java b/src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java index dd5cb073..90fee7c5 100644 --- a/src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java +++ b/src/main/java/com/howaboutus/backend/rooms/controller/dto/RoomMemberResponse.java @@ -2,6 +2,7 @@ import java.time.Instant; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.RoomRole; import com.howaboutus.backend.rooms.service.dto.RoomMemberResult; @@ -10,6 +11,7 @@ public record RoomMemberResponse( String nickname, String profileImageUrl, RoomRole role, + MemberStatus status, boolean isOnline, Instant joinedAt ) { @@ -19,6 +21,7 @@ public static RoomMemberResponse from(RoomMemberResult result) { result.nickname(), result.profileImageUrl(), result.role(), + result.status(), result.isOnline(), result.joinedAt()); } diff --git a/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java b/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java index b1dc6a91..9298d6c3 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java @@ -51,6 +51,7 @@ public List getMembers(UUID roomId, Long userId) { m.getUser().getNickname(), m.getUser().getProfileImageUrl(), m.getRole(), + m.getStatus(), onlineUserIds.contains(m.getUser().getId()), m.getJoinedAt())) .toList(); diff --git a/src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java b/src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java index 12f3aa32..f7d7d4df 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/dto/RoomMemberResult.java @@ -2,6 +2,7 @@ import java.time.Instant; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.RoomRole; public record RoomMemberResult( @@ -9,6 +10,7 @@ public record RoomMemberResult( String nickname, String profileImageUrl, RoomRole role, + MemberStatus status, boolean isOnline, Instant joinedAt ) { diff --git a/src/test/java/com/howaboutus/backend/rooms/controller/RoomControllerTest.java b/src/test/java/com/howaboutus/backend/rooms/controller/RoomControllerTest.java index 73027492..233fcb1b 100644 --- a/src/test/java/com/howaboutus/backend/rooms/controller/RoomControllerTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/controller/RoomControllerTest.java @@ -316,9 +316,11 @@ void rejectJoinRequestReturns200() throws Exception { @DisplayName("멤버 목록 조회 성공 시 200을 반환한다") void getMembersReturns200() throws Exception { List results = List.of( - new RoomMemberResult(1L, "호스트", "https://img/host.jpg", RoomRole.HOST, true, + new RoomMemberResult(1L, "호스트", "https://img/host.jpg", RoomRole.HOST, + com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE, true, Instant.parse("2026-04-20T00:00:00Z")), - new RoomMemberResult(2L, "멤버", null, RoomRole.MEMBER, false, + new RoomMemberResult(2L, "멤버", null, RoomRole.MEMBER, + com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE, false, Instant.parse("2026-04-21T00:00:00Z")) ); given(roomMemberService.getMembers(ROOM_ID, USER_ID)).willReturn(results); @@ -331,8 +333,10 @@ void getMembersReturns200() throws Exception { .andExpect(jsonPath("$.members[0].userId").value(1)) .andExpect(jsonPath("$.members[0].nickname").value("호스트")) .andExpect(jsonPath("$.members[0].role").value("HOST")) + .andExpect(jsonPath("$.members[0].status").value("ACTIVE")) .andExpect(jsonPath("$.members[0].isOnline").value(true)) .andExpect(jsonPath("$.members[1].userId").value(2)) + .andExpect(jsonPath("$.members[1].status").value("ACTIVE")) .andExpect(jsonPath("$.members[1].isOnline").value(false)); } diff --git a/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java b/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java index 74b684a1..0c0e5edf 100644 --- a/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java @@ -82,8 +82,12 @@ void getMembersReturnsActiveMembers() { assertThat(results.get(0).userId()).isEqualTo(1L); assertThat(results.get(0).isOnline()).isTrue(); assertThat(results.get(0).role()).isEqualTo(RoomRole.HOST); + assertThat(results.get(0).status()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); assertThat(results.get(0).nickname()).isEqualTo("호스트"); assertThat(results.get(1).userId()).isEqualTo(2L); + assertThat(results.get(1).status()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); assertThat(results.get(1).isOnline()).isFalse(); } From f5f5b02dafa4700467f70c86ce52a4784eb06292 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:35:23 +0900 Subject: [PATCH 13/20] =?UTF-8?q?feat:=20getMembers=EA=B0=80=20LEFT=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=EA=B9=8C=EC=A7=80=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20online=3Dfalse=20=EA=B0=95=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/RoomMemberRepository.java | 14 +++++ .../rooms/service/RoomMemberService.java | 13 +++-- .../rooms/service/RoomMemberServiceTest.java | 54 +++++++++++++++++-- 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java b/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java index a94cb45b..c4899ac8 100644 --- a/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java +++ b/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java @@ -74,6 +74,20 @@ select count(m) from RoomMember m List findAllByUser_Id(Long userId); + @EntityGraph(attributePaths = "user") + @Query(""" + select m from RoomMember m + where m.room.id = :roomId + and ( + (m.status = com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE + and m.role in ( + com.howaboutus.backend.rooms.entity.RoomRole.HOST, + com.howaboutus.backend.rooms.entity.RoomRole.MEMBER)) + or m.status = com.howaboutus.backend.rooms.entity.MemberStatus.LEFT + ) + """) + List findVisibleMembers(@Param("roomId") UUID roomId); + @Query(""" select m.room from RoomMember m diff --git a/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java b/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java index 9298d6c3..406eea84 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java @@ -1,6 +1,7 @@ package com.howaboutus.backend.rooms.service; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Set; import java.util.UUID; @@ -32,7 +33,12 @@ @Transactional(readOnly = true) public class RoomMemberService { - private static final List ACTIVE_ROLES = List.of(RoomRole.HOST, RoomRole.MEMBER); + // 멤버 목록 정렬: ACTIVE 먼저 → HOST 먼저 → joinedAt 오름차순 → LEFT(joinedAt 오름차순) + private static final Comparator MEMBER_DISPLAY_ORDER = + Comparator + .comparing((RoomMember m) -> m.getStatus() == MemberStatus.LEFT) + .thenComparing(m -> m.getRole() != RoomRole.HOST) + .thenComparing(RoomMember::getJoinedAt); private final RoomMemberRepository roomMemberRepository; private final RoomPresenceService roomPresenceService; @@ -42,17 +48,18 @@ public class RoomMemberService { public List getMembers(UUID roomId, Long userId) { roomAuthorizationService.requireActiveMember(roomId, userId); - List members = roomMemberRepository.findByRoom_IdAndRoleIn(roomId, ACTIVE_ROLES); + List members = roomMemberRepository.findVisibleMembers(roomId); Set onlineUserIds = getOnlineUserIdsSafe(roomId); return members.stream() + .sorted(MEMBER_DISPLAY_ORDER) .map(m -> new RoomMemberResult( m.getUser().getId(), m.getUser().getNickname(), m.getUser().getProfileImageUrl(), m.getRole(), m.getStatus(), - onlineUserIds.contains(m.getUser().getId()), + m.getStatus() == MemberStatus.ACTIVE && onlineUserIds.contains(m.getUser().getId()), m.getJoinedAt())) .toList(); } diff --git a/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java b/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java index 0c0e5edf..19067f38 100644 --- a/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java @@ -38,7 +38,6 @@ class RoomMemberServiceTest { private static final Long USER_ID = 1L; private static final Long HOST_USER_ID = 1L; private static final Long TARGET_USER_ID = 2L; - private static final List ACTIVE_ROLES = List.of(RoomRole.HOST, RoomRole.MEMBER); @Mock private RoomMemberRepository roomMemberRepository; @@ -72,7 +71,7 @@ void getMembersReturnsActiveMembers() { RoomMember regularMember = RoomMember.create(room, member, RoomRole.MEMBER); given(roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)).willReturn(hostMember); - given(roomMemberRepository.findByRoom_IdAndRoleIn(ROOM_ID, ACTIVE_ROLES)) + given(roomMemberRepository.findVisibleMembers(ROOM_ID)) .willReturn(List.of(hostMember, regularMember)); given(roomPresenceService.getOnlineUserIds(ROOM_ID)).willReturn(Set.of(1L)); @@ -113,7 +112,7 @@ void getMembersHandlesRedisFailure() { RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); given(roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)).willReturn(hostMember); - given(roomMemberRepository.findByRoom_IdAndRoleIn(ROOM_ID, ACTIVE_ROLES)) + given(roomMemberRepository.findVisibleMembers(ROOM_ID)) .willReturn(List.of(hostMember)); given(roomPresenceService.getOnlineUserIds(ROOM_ID)) .willThrow(new org.springframework.dao.QueryTimeoutException("Redis connection refused")); @@ -124,12 +123,59 @@ void getMembersHandlesRedisFailure() { assertThat(results.get(0).isOnline()).isFalse(); } + @Test + @DisplayName("getMembers - LEFT 멤버도 포함하며 LEFT 의 online 은 항상 false") + void getMembersIncludesLeftAndForcesOffline() { + User host = User.ofGoogle("g1", "h@test.com", "호스트", null); + ReflectionTestUtils.setField(host, "id", 1L); + User activeUser = User.ofGoogle("g2", "m@test.com", "활성멤버", null); + ReflectionTestUtils.setField(activeUser, "id", 2L); + User leftUser = User.ofGoogle("g3", "l@test.com", "나간사람", null); + ReflectionTestUtils.setField(leftUser, "id", 3L); + + Room room = Room.create("여행", "부산", null, null, "invite1", 1L); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + + RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); + RoomMember activeMember = RoomMember.create(room, activeUser, RoomRole.MEMBER); + RoomMember leftMember = RoomMember.create(room, leftUser, RoomRole.MEMBER); + leftMember.leave(); + + given(roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)).willReturn(hostMember); + // 정렬 검증을 위해 일부러 LEFT → ACTIVE/MEMBER → HOST 순서로 반환한다. + given(roomMemberRepository.findVisibleMembers(ROOM_ID)) + .willReturn(List.of(leftMember, activeMember, hostMember)); + // LEFT 인 user(3L) 가 Redis presence에 살아 있어도 무시되어야 함 + given(roomPresenceService.getOnlineUserIds(ROOM_ID)) + .willReturn(Set.of(1L, 3L)); + + List results = roomMemberService.getMembers(ROOM_ID, USER_ID); + + assertThat(results).hasSize(3); + // 정렬: ACTIVE(HOST → MEMBER joinedAt asc) → LEFT(joinedAt asc) + assertThat(results.get(0).userId()).isEqualTo(1L); + assertThat(results.get(0).role()).isEqualTo(RoomRole.HOST); + assertThat(results.get(0).status()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); + assertThat(results.get(0).isOnline()).isTrue(); + + assertThat(results.get(1).userId()).isEqualTo(2L); + assertThat(results.get(1).role()).isEqualTo(RoomRole.MEMBER); + assertThat(results.get(1).status()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); + + assertThat(results.get(2).userId()).isEqualTo(3L); + assertThat(results.get(2).status()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.LEFT); + assertThat(results.get(2).isOnline()).isFalse(); + } + @Test @DisplayName("멤버가 없으면 빈 리스트를 반환한다") void getMembersReturnsEmptyList() { RoomMember dummyMember = createDummyMember(); given(roomAuthorizationService.requireActiveMember(ROOM_ID, USER_ID)).willReturn(dummyMember); - given(roomMemberRepository.findByRoom_IdAndRoleIn(ROOM_ID, ACTIVE_ROLES)) + given(roomMemberRepository.findVisibleMembers(ROOM_ID)) .willReturn(List.of()); given(roomPresenceService.getOnlineUserIds(ROOM_ID)).willReturn(Set.of()); From 2b7a024dda3e1b2568f540aab5d7b571d2c70499 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:38:00 +0900 Subject: [PATCH 14/20] =?UTF-8?q?feat:=20requestJoin=EC=9D=B4=20LEFT=20row?= =?UTF-8?q?=EB=A5=BC=20PENDING=EC=9C=BC=EB=A1=9C=20=EB=B6=80=ED=99=9C?= =?UTF-8?q?=EC=8B=9C=ED=82=A4=EB=8F=84=EB=A1=9D=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rooms/service/RoomInviteService.java | 13 ++++++++- .../rooms/service/RoomInviteServiceTest.java | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java b/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java index 20cd4319..ba715bdf 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java @@ -15,6 +15,7 @@ import com.howaboutus.backend.messages.service.ReadStatusService; import com.howaboutus.backend.realtime.event.JoinRequestedEvent; import com.howaboutus.backend.realtime.event.MemberApprovedEvent; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.Room; import com.howaboutus.backend.rooms.entity.RoomMember; import com.howaboutus.backend.rooms.entity.RoomRole; @@ -65,6 +66,12 @@ public JoinResult requestJoin(String inviteCode, Long userId) { if (existing.isPresent()) { RoomMember member = existing.get(); + if (member.getStatus() == MemberStatus.LEFT) { + // 같은 row를 ACTIVE/PENDING 으로 부활 (UPSERT 효과). lastReadMessageId는 손대지 않는다. + member.rejoinAsPending(); + publishJoinRequested(room, member.getUser()); + return JoinResult.pending(room.getId(), room.getTitle()); + } if (member.getRole() == RoomRole.PENDING) { return JoinResult.pending(room.getId(), room.getTitle()); } @@ -78,6 +85,11 @@ public JoinResult requestJoin(String inviteCode, Long userId) { log.warn("Concurrent join request detected. roomId={}, userId={}", room.getId(), userId, e); return JoinResult.pending(room.getId(), room.getTitle()); } + publishJoinRequested(room, user); + return JoinResult.pending(room.getId(), room.getTitle()); + } + + private void publishJoinRequested(Room room, User user) { List hostUserIds = roomMemberRepository.findByRoom_IdAndRole(room.getId(), RoomRole.HOST) .stream() .map(m -> m.getUser().getId()) @@ -89,7 +101,6 @@ public JoinResult requestJoin(String inviteCode, Long userId) { user.getProfileImageUrl(), hostUserIds )); - return JoinResult.pending(room.getId(), room.getTitle()); } // 입장 요청 상태 조회 diff --git a/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java b/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java index 74f1568d..0ca123fe 100644 --- a/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java @@ -160,6 +160,34 @@ void requestJoinReturnsPendingWhenAlreadyPending() { assertThat(result.roomTitle()).isEqualTo("부산 여행"); } + @Test + @DisplayName("LEFT 멤버가 invite code로 재요청하면 같은 row가 ACTIVE/PENDING으로 부활하고 JoinRequestedEvent 발행") + void requestJoinRevivesLeftMember() { + User leftUser = User.ofGoogle("google-left", "left@test.com", "나간사람", null); + ReflectionTestUtils.setField(leftUser, "id", 42L); + RoomMember leftMember = RoomMember.create(room, leftUser, RoomRole.MEMBER); + leftMember.leave(); + + given(roomRepository.findByInviteCode("aB3xK9mQ2w")) + .willReturn(Optional.of(room)); + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, 42L)) + .willReturn(Optional.of(leftMember)); + given(roomMemberRepository.findByRoom_IdAndRole(ROOM_ID, RoomRole.HOST)) + .willReturn(List.of(hostMember)); + + JoinResult result = roomInviteService.requestJoin("aB3xK9mQ2w", 42L); + + assertThat(leftMember.getStatus()) + .isEqualTo(com.howaboutus.backend.rooms.entity.MemberStatus.ACTIVE); + assertThat(leftMember.getRole()).isEqualTo(RoomRole.PENDING); + assertThat(leftMember.getLeftAt()).isNull(); + assertThat(result.status()).isEqualTo(JoinStatus.PENDING); + then(eventPublisher).should() + .publishEvent(any(com.howaboutus.backend.realtime.event.JoinRequestedEvent.class)); + // 부활 경로에서는 새 row를 만들지 않는다. + then(roomMemberRepository).should(never()).saveAndFlush(any(RoomMember.class)); + } + @Test @DisplayName("존재하지 않는 초대 코드로 입장 요청하면 ROOM_NOT_FOUND 예외") void requestJoinThrowsWhenInvalidCode() { From b5b6aa776ea4033e47e9015f0fa45670e57a15e2 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:39:55 +0900 Subject: [PATCH 15/20] =?UTF-8?q?fix:=20AI=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=EC=97=90=EC=84=9C=20LEFT=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/ai/repository/AiContextQueryRepositoryImpl.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java b/src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java index 6e374bb4..977ed4ce 100644 --- a/src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java +++ b/src/main/java/com/howaboutus/backend/ai/repository/AiContextQueryRepositoryImpl.java @@ -14,6 +14,7 @@ import com.howaboutus.backend.common.integration.ai.dto.AiBookmarkedPlaceItem; import com.howaboutus.backend.common.integration.ai.dto.AiScheduledDay; import com.howaboutus.backend.common.integration.ai.dto.AiScheduledPlaceItem; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.RoomRole; import com.howaboutus.backend.schedules.entity.Schedule; import com.howaboutus.backend.schedules.entity.ScheduleItem; @@ -33,9 +34,12 @@ public class AiContextQueryRepositoryImpl implements AiContextQueryRepository { public Integer countApprovedParticipants(UUID roomId) { return ((Number) em.createQuery( "select count(rm) from RoomMember rm " - + "where rm.room.id = :roomId and rm.role in :roles") + + "where rm.room.id = :roomId " + + "and rm.role in :roles " + + "and rm.status = :activeStatus") .setParameter(ROOM_ID_PARAM, roomId) .setParameter("roles", List.of(RoomRole.HOST, RoomRole.MEMBER)) + .setParameter("activeStatus", MemberStatus.ACTIVE) .getSingleResult()).intValue(); } From c758831f90dc7a2eb443de00da76169b22037160 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:42:31 +0900 Subject: [PATCH 16/20] =?UTF-8?q?test:=20V1.8=20CHECK=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?(LEFT/left=5Fat/PENDING)=20=ED=9A=8C=EA=B7=80=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RoomMemberStatusConstraintTest.java | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java diff --git a/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java b/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java new file mode 100644 index 00000000..a1e4cf43 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java @@ -0,0 +1,107 @@ +package com.howaboutus.backend.rooms.repository; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +import com.howaboutus.backend.rooms.entity.Room; +import com.howaboutus.backend.support.BaseIntegrationTest; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.repository.UserRepository; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.PersistenceException; + +class RoomMemberStatusConstraintTest extends BaseIntegrationTest { + + @Autowired + private UserRepository userRepository; + @Autowired + private RoomRepository roomRepository; + + @PersistenceContext + private EntityManager em; + + private User persistUser(String suffix) { + return userRepository.save(User.ofGoogle( + "g-" + suffix + "-" + UUID.randomUUID(), + suffix + "-" + UUID.randomUUID() + "@t", + "n", + null)); + } + + private Room persistRoom(Long hostId, String suffix) { + return roomRepository.save(Room.create( + "t", null, LocalDate.now(), LocalDate.now(), + "inv-" + suffix + "-" + UUID.randomUUID(), + hostId)); + } + + @Test + @Transactional + @DisplayName("CHECK ck_room_members_status_left_at - LEFT + left_at NULL 은 거부") + void leftRequiresLeftAt() { + User user = persistUser("a"); + Room room = persistRoom(user.getId(), "a"); + em.flush(); + + assertThatThrownBy(() -> { + em.createNativeQuery(""" + insert into room_members (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + values (?1, ?2, 'MEMBER', now(), 'LEFT', NULL, now(), now()) + """) + .setParameter(1, room.getId()) + .setParameter(2, user.getId()) + .executeUpdate(); + em.flush(); + }).isInstanceOfAny(DataIntegrityViolationException.class, PersistenceException.class); + } + + @Test + @Transactional + @DisplayName("CHECK ck_room_members_status_left_at - ACTIVE + left_at NOT NULL 은 거부") + void activeForbidsLeftAt() { + User user = persistUser("b"); + Room room = persistRoom(user.getId(), "b"); + em.flush(); + + assertThatThrownBy(() -> { + em.createNativeQuery(""" + insert into room_members (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + values (?1, ?2, 'MEMBER', now(), 'ACTIVE', now(), now(), now()) + """) + .setParameter(1, room.getId()) + .setParameter(2, user.getId()) + .executeUpdate(); + em.flush(); + }).isInstanceOfAny(DataIntegrityViolationException.class, PersistenceException.class); + } + + @Test + @Transactional + @DisplayName("CHECK ck_room_members_left_role - LEFT + role=PENDING 은 거부") + void leftPendingRejected() { + User user = persistUser("c"); + Room room = persistRoom(user.getId(), "c"); + em.flush(); + + assertThatThrownBy(() -> { + em.createNativeQuery(""" + insert into room_members (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + values (?1, ?2, 'PENDING', now(), 'LEFT', now(), now(), now()) + """) + .setParameter(1, room.getId()) + .setParameter(2, user.getId()) + .executeUpdate(); + em.flush(); + }).isInstanceOfAny(DataIntegrityViolationException.class, PersistenceException.class); + } +} From 760db81f5477bfbae24d20f5713374e69fbfbccf Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:44:12 +0900 Subject: [PATCH 17/20] =?UTF-8?q?style:=20V1.8=20CHECK=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20SQL=20=EC=A4=84=EB=B0=94=EA=BF=88=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20line-length-120=20=EC=9C=84=EB=B0=98=20=ED=95=B4?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rooms/repository/RoomMemberStatusConstraintTest.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java b/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java index a1e4cf43..f2029965 100644 --- a/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberStatusConstraintTest.java @@ -55,7 +55,8 @@ void leftRequiresLeftAt() { assertThatThrownBy(() -> { em.createNativeQuery(""" - insert into room_members (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + insert into room_members + (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) values (?1, ?2, 'MEMBER', now(), 'LEFT', NULL, now(), now()) """) .setParameter(1, room.getId()) @@ -75,7 +76,8 @@ void activeForbidsLeftAt() { assertThatThrownBy(() -> { em.createNativeQuery(""" - insert into room_members (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + insert into room_members + (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) values (?1, ?2, 'MEMBER', now(), 'ACTIVE', now(), now(), now()) """) .setParameter(1, room.getId()) @@ -95,7 +97,8 @@ void leftPendingRejected() { assertThatThrownBy(() -> { em.createNativeQuery(""" - insert into room_members (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) + insert into room_members + (room_id, user_id, role, joined_at, status, left_at, created_at, updated_at) values (?1, ?2, 'PENDING', now(), 'LEFT', now(), now(), now()) """) .setParameter(1, room.getId()) From cc39df7ed1a612f6169bfd182d072f5a46865dfc Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:45:38 +0900 Subject: [PATCH 18/20] =?UTF-8?q?test:=20=ED=83=88=ED=87=B4=20=EC=8B=9C=20?= =?UTF-8?q?LEFT=20=EB=A9=A4=EB=B2=84=EC=8B=AD=EB=8F=84=20hard=20delete=20?= =?UTF-8?q?=EB=90=98=EB=8A=94=EC=A7=80=20=ED=9A=8C=EA=B7=80=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/UserWithdrawalServiceTest.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java b/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java index c8bd6e2f..cc40d18b 100644 --- a/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java +++ b/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java @@ -131,6 +131,32 @@ void doubleCheckRollback() { assertThat(user.isWithdrawn()).isFalse(); } + @Test + @DisplayName("withdraw - LEFT row를 가진 유저도 전체 row가 hard delete 된다 (status 무관)") + void withdrawDeletesEvenLeftMemberships() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + + Room room = Room.create("MemberRoom", null, null, null, "i-left-room", 999L); + ReflectionTestUtils.setField(room, "id", UUID.randomUUID()); + + RoomMember leftMember = RoomMember.create(room, user, RoomRole.MEMBER); + leftMember.leave(); + + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of()); + given(roomMemberRepository.findHostRoomsWithOnlySelf(USER_ID)) + .willReturn(List.of()); + given(roomMemberRepository.findAllByUser_Id(USER_ID)) + .willReturn(List.of(leftMember)); + + service.withdraw(USER_ID); + + // 회귀 테스트 의도: LEFT row 도 hard delete 대상에 포함된다. + verify(roomMemberRepository).delete(leftMember); + verify(eventPublisher).publishEvent(any(UserWithdrawnEvent.class)); + assertThat(user.isWithdrawn()).isTrue(); + } + @Test @DisplayName("user를 찾을 수 없으면 USER_NOT_FOUND") void userNotFound() { From 4514a95c3260511e22ed84ee37f261733c2fda59 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 09:47:04 +0900 Subject: [PATCH 19/20] =?UTF-8?q?docs:=20room=5Fmembers=20status/left=5Fat?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85=EC=9D=84=20features/erd=EC=97=90=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai/erd.md | 9 +++++++-- docs/ai/features.md | 8 ++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/ai/erd.md b/docs/ai/erd.md index 47391c79..5cc34dfa 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -65,13 +65,18 @@ Google OAuth 기반 사용자 정보 | user_id | BIGINT | FK → users.id, NOT NULL | | | role | VARCHAR(20) | NOT NULL | HOST / MEMBER / PENDING (DEFAULT 없이 명시적 지정) | | 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 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 룩업용 --- diff --git a/docs/ai/features.md b/docs/ai/features.md index f603f3ae..467fdf4f 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -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\. 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 | --- @@ -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 | From 94a7a7689fd39cad94970f8f995fd570996aa8cc Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 10:40:35 +0900 Subject: [PATCH 20/20] =?UTF-8?q?refactor=20:=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EB=B0=98=EC=98=81(=EB=B0=A9=EB=82=98?= =?UTF-8?q?=EA=B0=80=EA=B8=B0=20=EC=A4=91=EB=B3=B5=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B0=A8=EB=8B=A8,=20=EB=B0=A9=EB=82=98=EA=B0=90?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=84=A4=EC=A0=95,?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rooms/service/RoomInviteService.java | 4 +++ .../rooms/service/RoomMemberService.java | 4 +++ .../user/service/UserWithdrawalService.java | 4 ++- .../rooms/service/RoomInviteServiceTest.java | 19 ++++++++++++ .../rooms/service/RoomMemberServiceTest.java | 29 +++++++++++++++++++ .../service/UserWithdrawalServiceTest.java | 24 +++++++++++++++ 6 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java b/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java index ba715bdf..3d183866 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/RoomInviteService.java @@ -110,6 +110,10 @@ public JoinStatusResult getJoinStatus(UUID roomId, Long userId) { RoomMember member = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, userId) .orElseThrow(() -> new CustomException(ErrorCode.JOIN_REQUEST_NOT_FOUND)); + // LEFT row 는 활성 입장/요청 상태가 아니므로 비멤버와 동일하게 취급한다. + if (member.getStatus() == MemberStatus.LEFT) { + throw new CustomException(ErrorCode.JOIN_REQUEST_NOT_FOUND); + } if (member.getRole() == RoomRole.PENDING) { return JoinStatusResult.pending(room.getId(), room.getTitle()); } diff --git a/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java b/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java index 406eea84..69c8f37e 100644 --- a/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java +++ b/src/main/java/com/howaboutus/backend/rooms/service/RoomMemberService.java @@ -136,6 +136,10 @@ public void delegateHost(UUID roomId, Long targetUserId, Long hostUserId) { RoomMember target = roomMemberRepository.findByRoom_IdAndUser_Id(roomId, targetUserId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_MEMBER_NOT_FOUND)); + // LEFT row 는 닉네임/프로필 보존용으로 남아 있을 뿐 활성 멤버가 아니므로 위임 대상이 될 수 없다. + if (target.getStatus() != MemberStatus.ACTIVE) { + throw new CustomException(ErrorCode.DELEGATE_TARGET_NOT_MEMBER); + } if (target.getRole() != RoomRole.MEMBER) { throw new CustomException(ErrorCode.DELEGATE_TARGET_NOT_MEMBER); } diff --git a/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java b/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java index 6032868e..c2bb4002 100644 --- a/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java +++ b/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java @@ -11,6 +11,7 @@ import com.howaboutus.backend.common.logging.Loggable; import com.howaboutus.backend.realtime.event.MemberLeftEvent; import com.howaboutus.backend.realtime.event.RoomDeletedEvent; +import com.howaboutus.backend.rooms.entity.MemberStatus; import com.howaboutus.backend.rooms.entity.Room; import com.howaboutus.backend.rooms.entity.RoomMember; import com.howaboutus.backend.rooms.entity.RoomRole; @@ -82,7 +83,8 @@ private void deleteRemainingMemberships(Long userId, User user) { List memberships = roomMemberRepository.findAllByUser_Id(userId); for (RoomMember membership : memberships) { roomMemberRepository.delete(membership); - if (membership.getRole() == RoomRole.MEMBER) { + // LEFT row 는 이미 나갈 때 USER_LEFT 시스템 메시지가 한 번 발행되었으므로 재발행하지 않는다. + if (membership.getStatus() == MemberStatus.ACTIVE && membership.getRole() == RoomRole.MEMBER) { eventPublisher.publishEvent(new MemberLeftEvent( membership.getRoom().getId(), userId, diff --git a/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java b/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java index 0ca123fe..b37fe9bb 100644 --- a/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/service/RoomInviteServiceTest.java @@ -234,6 +234,25 @@ void getJoinStatusReturnsApproved() { assertThat(result.roomId()).isEqualTo(ROOM_ID); } + @Test + @DisplayName("LEFT 멤버가 상태 조회하면 JOIN_REQUEST_NOT_FOUND 예외 (approved 로 잘못 답하지 않음)") + void getJoinStatusThrowsWhenLeft() { + User leftUser = User.ofGoogle("google-left", "left@test.com", "나간사람", null); + ReflectionTestUtils.setField(leftUser, "id", 77L); + RoomMember leftMember = RoomMember.create(room, leftUser, RoomRole.MEMBER); + leftMember.leave(); + + given(roomRepository.findById(ROOM_ID)) + .willReturn(Optional.of(room)); + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, 77L)) + .willReturn(Optional.of(leftMember)); + + assertThatThrownBy(() -> roomInviteService.getJoinStatus(ROOM_ID, 77L)) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.JOIN_REQUEST_NOT_FOUND); + } + @Test @DisplayName("거절된(레코드 없는) 사용자가 상태 조회하면 JOIN_REQUEST_NOT_FOUND 예외") void getJoinStatusThrowsWhenRejected() { diff --git a/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java b/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java index 19067f38..a701318a 100644 --- a/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java +++ b/src/test/java/com/howaboutus/backend/rooms/service/RoomMemberServiceTest.java @@ -414,6 +414,35 @@ void delegateHostThrowsWhenSelfDelegation() { .isEqualTo(ErrorCode.CANNOT_DELEGATE_TO_SELF)); } + @Test + @DisplayName("delegateHost - 대상이 LEFT면 DELEGATE_TARGET_NOT_MEMBER (500 아님)") + void delegateHostThrowsWhenTargetAlreadyLeft() { + User host = User.ofGoogle("g1", "host@test.com", "호스트", null); + ReflectionTestUtils.setField(host, "id", HOST_USER_ID); + User target = User.ofGoogle("g2", "target@test.com", "타겟", null); + ReflectionTestUtils.setField(target, "id", TARGET_USER_ID); + + Room room = Room.create("여행", "부산", null, null, "invite1", HOST_USER_ID); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + + RoomMember hostMember = RoomMember.create(room, host, RoomRole.HOST); + RoomMember leftMember = RoomMember.create(room, target, RoomRole.MEMBER); + leftMember.leave(); + + given(roomAuthorizationService.requireHost(ROOM_ID, HOST_USER_ID)).willReturn(hostMember); + given(roomMemberRepository.findByRoom_IdAndUser_Id(ROOM_ID, TARGET_USER_ID)) + .willReturn(Optional.of(leftMember)); + + assertThatThrownBy(() -> roomMemberService.delegateHost(ROOM_ID, TARGET_USER_ID, HOST_USER_ID)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException)ex).getErrorCode()) + .isEqualTo(ErrorCode.DELEGATE_TARGET_NOT_MEMBER)); + // role/status 가 변경되지 않아야 함 + assertThat(hostMember.getRole()).isEqualTo(RoomRole.HOST); + assertThat(leftMember.getRole()).isEqualTo(RoomRole.MEMBER); + then(eventPublisher).shouldHaveNoInteractions(); + } + @Test @DisplayName("delegateHost - 대상이 PENDING이면 DELEGATE_TARGET_NOT_MEMBER") void delegateHostThrowsWhenTargetIsPending() { diff --git a/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java b/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java index cc40d18b..689a2c65 100644 --- a/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java +++ b/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java @@ -157,6 +157,30 @@ void withdrawDeletesEvenLeftMemberships() { assertThat(user.isWithdrawn()).isTrue(); } + @Test + @DisplayName("withdraw - LEFT 멤버십에 대해서는 MemberLeftEvent를 재발행하지 않는다 (USER_LEFT 중복 방지)") + void withdrawDoesNotRepublishLeaveEventForLeftMemberships() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + + Room room = Room.create("MemberRoom", null, null, null, "i-no-dup", 999L); + ReflectionTestUtils.setField(room, "id", UUID.randomUUID()); + + RoomMember leftMember = RoomMember.create(room, user, RoomRole.MEMBER); + leftMember.leave(); + + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of()); + given(roomMemberRepository.findHostRoomsWithOnlySelf(USER_ID)) + .willReturn(List.of()); + given(roomMemberRepository.findAllByUser_Id(USER_ID)) + .willReturn(List.of(leftMember)); + + service.withdraw(USER_ID); + + // LEFT row 는 이미 나갈 때 한 번 USER_LEFT 가 발행되었으므로 탈퇴 시점에 다시 발행되어선 안 된다. + verify(eventPublisher, never()).publishEvent(any(MemberLeftEvent.class)); + } + @Test @DisplayName("user를 찾을 수 없으면 USER_NOT_FOUND") void userNotFound() {