From 385fa7cabc5327d5ba26208338f060adfbe84481 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 12:26:15 +0900 Subject: [PATCH 01/15] =?UTF-8?q?docs:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EA=B8=B0=EB=8A=A5=20=EC=84=A4=EA=B3=84=20spec?= =?UTF-8?q?=EA=B3=BC=20=EA=B5=AC=ED=98=84=20=ED=94=8C=EB=9E=9C=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 --- .../plans/2026-06-07-user-withdrawal.md | 1581 +++++++++++++++++ .../2026-06-07-user-withdrawal-design.md | 277 +++ 2 files changed, 1858 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-user-withdrawal.md create mode 100644 docs/superpowers/specs/2026-06-07-user-withdrawal-design.md diff --git a/docs/superpowers/plans/2026-06-07-user-withdrawal.md b/docs/superpowers/plans/2026-06-07-user-withdrawal.md new file mode 100644 index 00000000..38c10578 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-user-withdrawal.md @@ -0,0 +1,1581 @@ +# 회원 탈퇴 기능 구현 플랜 + +> **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:** `DELETE /users/me` API로 회원 탈퇴를 처리한다. `users`는 soft delete + 익명화, `room_members`는 hard delete, HOST 방은 사전 위임을 요구하며 1인 HOST 방은 자동 hard delete한다. 탈퇴 직후 해당 유저의 모든 Redis RTK를 즉시 폐기한다. + +**Architecture:** PostgreSQL 단일 `@Transactional` 안에서 사전 검증 → 1인 HOST 방 삭제 → 잔여 `room_members` 삭제 → 더블체크 → `users.anonymize()` 순으로 처리한다. RTK 폐기와 도메인 이벤트(MemberLeft/RoomDeleted)는 `@TransactionalEventListener(AFTER_COMMIT)`로 분리한다. partial unique index + CHECK 제약으로 활성 회원에만 NOT NULL/UNIQUE를 강제하고 탈퇴 행에는 NULL을 허용해 동일 Google 계정 재가입을 허용한다. + +**Tech Stack:** Spring Boot 4.0.5, Java 21, PostgreSQL 17 + Flyway, JPA(Hibernate `@SQLRestriction`), Redis(StringRedisTemplate), JUnit 5 + Mockito + AssertJ, Testcontainers. + +**참조 스펙:** `docs/superpowers/specs/2026-06-07-user-withdrawal-design.md` + +--- + +## File Structure + +**신규 파일** +- `src/main/resources/db/migration/V1.7__users_withdrawal.sql` — DDL 변경 +- `src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java` — `record UserWithdrawnEvent(Long userId)` +- `src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java` +- `src/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.java` +- `src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java` +- `src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java` +- `src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java` — RTK 폐기 리스너 +- `src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java` +- `src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java` +- `src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java` +- `src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryTest.java` (없다면 신규) + +**수정 파일** +- `src/main/java/com/howaboutus/backend/user/entity/User.java` — 컬럼 nullable, `deletedAt`, `@SQLRestriction`, `anonymize()` +- `src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java` — 위임 검증/잔여 멤버십 쿼리 +- `src/main/java/com/howaboutus/backend/common/error/ErrorCode.java` — `WITHDRAWAL_REQUIRES_HOST_DELEGATION` 추가 +- `src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java` — `HostDelegationRequiredException` 매핑 +- `src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java` — `invalidateAllForUser(Long)` 노출 +- `src/main/java/com/howaboutus/backend/user/controller/UserController.java` — `DELETE /me` 엔드포인트 +- `docs/ai/features.md`, `docs/ai/erd.md`, `docs/ai/decisions/*.md` + +--- + +## 작업 순서 원칙 + +- 각 Task 끝에 `feat:`/`refactor:`/`test:` 같은 conventional commit 한 개를 만든다. +- 커밋 직전 항상 `./gradlew checkstyleMain checkstyleTest` 통과를 확인한다(CLAUDE.md `Before Commit` 규칙). +- 커밋 메시지에 `Co-Authored-By:` 트레일러를 절대 추가하지 않는다(CONTRIBUTING.md). +- 단위 테스트는 `@ExtendWith(MockitoExtension.class) + @Mock + @InjectMocks` 또는 수동 생성자 주입 패턴(`RoomMemberServiceTest` 참고)을 사용한다. + +--- + +## Task 1: Flyway 마이그레이션 — `users` soft delete 컬럼/제약/인덱스 변경 + +**Files:** +- Create: `src/main/resources/db/migration/V1.7__users_withdrawal.sql` + +- [ ] **Step 1: 마이그레이션 파일 생성** + +`src/main/resources/db/migration/V1.7__users_withdrawal.sql`: + +```sql +-- =========================================== +-- V1.7: users 회원 탈퇴 지원 (soft delete + 익명화) +-- =========================================== + +-- 1) 탈퇴 시각 컬럼 +ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP WITH TIME ZONE; + +-- 2) 활성 회원 NOT NULL 완화 (탈퇴 시 NULL 허용) +ALTER TABLE users ALTER COLUMN email DROP NOT NULL; +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) +ALTER TABLE users ADD CONSTRAINT users_active_required CHECK ( + 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 + ) +); + +-- 4) 기존 unique 제약 제거 +ALTER TABLE users DROP CONSTRAINT users_email_key; +ALTER TABLE users DROP CONSTRAINT uq_users_provider_provider_id; + +-- 5) 활성 회원만 unique 적용 (partial unique index) +CREATE UNIQUE INDEX users_email_unique_active + ON users (email) WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX users_provider_provider_id_unique_active + ON users (provider, provider_id) WHERE deleted_at IS NULL; + +-- 6) 탈퇴자 필터/조회 인덱스 +CREATE INDEX users_deleted_at_idx ON users (deleted_at) + WHERE deleted_at IS NOT NULL; +``` + +- [ ] **Step 2: 마이그레이션 적용 확인용 빌드** + +Run: `./gradlew compileJava -q` +Expected: SUCCESS (Java 변경 없음) + +- [ ] **Step 3: 컨테이너 기동 후 마이그레이션 검증(선택)** + +Run: `./gradlew test --tests com.howaboutus.backend.HowAboutUsBackendApplicationTests` (컨텍스트 로드만) +Expected: 컨텍스트 로딩 성공. Flyway가 V1.7을 적용. 실패 시 SQL 오류 메시지로 디버깅. + +- [ ] **Step 4: Checkstyle** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/resources/db/migration/V1.7__users_withdrawal.sql +git commit -m "feat: V1.7 마이그레이션으로 users 회원 탈퇴 스키마 지원" +``` + +--- + +## Task 2: `User` 엔티티 — `deletedAt`, nullable, `@SQLRestriction`, `anonymize()` + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/user/entity/User.java` +- Test: `src/test/java/com/howaboutus/backend/user/entity/UserTest.java` (없으면 신규) + +- [ ] **Step 1: 단위 테스트 작성 (`UserTest`)** + +`src/test/java/com/howaboutus/backend/user/entity/UserTest.java`: + +```java +package com.howaboutus.backend.user.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class UserTest { + + @Test + @DisplayName("anonymize는 개인정보 필드를 null로 비우고 deletedAt을 설정한다") + void anonymizeClearsPersonalFieldsAndSetsDeletedAt() { + User user = User.ofGoogle("pid", "a@a.com", "닉", "https://img/a.png"); + + assertThat(user.isWithdrawn()).isFalse(); + + user.anonymize(); + + assertThat(user.getEmail()).isNull(); + assertThat(user.getNickname()).isNull(); + assertThat(user.getProfileImageUrl()).isNull(); + assertThat(user.getProvider()).isNull(); + assertThat(user.getProviderId()).isNull(); + assertThat(user.getDeletedAt()).isNotNull(); + assertThat(user.isWithdrawn()).isTrue(); + } + + @Test + @DisplayName("이미 탈퇴된 user는 anonymize를 멱등하게 수용한다") + void anonymizeIsIdempotent() { + User user = User.ofGoogle("pid", "a@a.com", "닉", null); + user.anonymize(); + var firstDeletedAt = user.getDeletedAt(); + + user.anonymize(); + + assertThat(user.getDeletedAt()).isEqualTo(firstDeletedAt); + } +} +``` + +- [ ] **Step 2: 테스트 실행으로 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.entity.UserTest` +Expected: FAIL — `anonymize`, `isWithdrawn`, `getDeletedAt` 메서드 없음. + +- [ ] **Step 3: 엔티티 수정** + +`src/main/java/com/howaboutus/backend/user/entity/User.java` 전체를 다음으로 교체: + +```java +package com.howaboutus.backend.user.entity; + +import java.time.Instant; + +import org.hibernate.annotations.SQLRestriction; + +import com.howaboutus.backend.common.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLRestriction("deleted_at IS NULL") +public class User extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private String email; + + @Column(length = 50) + private String nickname; + + @Column(length = 500) + private String profileImageUrl; + + @Column(length = 20) + private String provider; + + @Column + private String providerId; + + @Column(name = "deleted_at") + private Instant deletedAt; + + private User(String providerId, String email, String nickname, String profileImageUrl, String provider) { + this.providerId = providerId; + this.email = email; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.provider = provider; + } + + public static User ofGoogle(String providerId, String email, String nickname, String profileImageUrl) { + return new User(providerId, email, nickname, profileImageUrl, "GOOGLE"); + } + + public boolean isWithdrawn() { + return this.deletedAt != null; + } + + public void anonymize() { + if (isWithdrawn()) { + return; + } + this.email = null; + this.nickname = null; + this.profileImageUrl = null; + this.provider = null; + this.providerId = null; + this.deletedAt = Instant.now(); + } +} +``` + +`@Table`의 기존 `uniqueConstraints`는 partial unique index로 옮겼으므로 엔티티 어노테이션에서 제거한다. + +- [ ] **Step 4: 단위 테스트 실행으로 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.entity.UserTest` +Expected: PASS (2 tests). + +- [ ] **Step 5: 기존 user 단위 테스트 회귀 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.*` +Expected: 기존 `UserServiceTest`, `UserControllerTest`도 PASS. 실패하면 nullable 변경/@SQLRestriction에 의한 영향을 점검(Mock 기반이라 영향 없을 가능성 높음). + +- [ ] **Step 6: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/user/entity/User.java \ + src/test/java/com/howaboutus/backend/user/entity/UserTest.java +git commit -m "feat: User에 soft delete와 익명화 도메인 메서드 추가" +``` + +--- + +## Task 3: ErrorCode 추가 — `WITHDRAWAL_REQUIRES_HOST_DELEGATION` + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/common/error/ErrorCode.java` + +- [ ] **Step 1: 422 섹션 추가 (해당 enum 끝부분에 새 섹션)** + +`ErrorCode.java`에서 마지막 enum 항목인 `EXTERNAL_API_ERROR(...)` 바로 위에 다음 섹션을 삽입: + +```java + // 422 UNPROCESSABLE ENTITY + WITHDRAWAL_REQUIRES_HOST_DELEGATION( + HttpStatus.UNPROCESSABLE_ENTITY, + "방장 위임이 필요한 방이 있습니다"), +``` + +세미콜론 위치를 헷갈리지 않도록, `EXTERNAL_API_ERROR(HttpStatus.BAD_GATEWAY, ...)` 뒤의 `;`는 그대로 두고 그 앞 콤마 처리만 신경 쓴다. + +- [ ] **Step 2: 컴파일 확인** + +Run: `./gradlew compileJava -q` +Expected: SUCCESS. + +- [ ] **Step 3: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain +git add src/main/java/com/howaboutus/backend/common/error/ErrorCode.java +git commit -m "feat: WITHDRAWAL_REQUIRES_HOST_DELEGATION 에러 코드 추가" +``` + +--- + +## Task 4: `RoomMemberRepository` 쿼리 메서드 추가 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java` +- Test: `src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java` (신규) + +- [ ] **Step 1: 신규 메서드의 단위 통합 테스트 작성** + +`src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java`: + +```java +package com.howaboutus.backend.rooms.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +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.rooms.repository.RoomMemberRepository.RoomRequiringDelegationView; +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; + +class RoomMemberRepositoryWithdrawalTest extends BaseIntegrationTest { + + @Autowired + private RoomMemberRepository roomMemberRepository; + @Autowired + private UserRepository userRepository; + @Autowired + private com.howaboutus.backend.rooms.repository.RoomRepository roomRepository; + + @PersistenceContext + private EntityManager em; + + @Test + @DisplayName("HOST이면서 다른 활성 멤버가 있는 방만 위임 필요 목록에 포함된다") + void findHostRoomsWithOtherActiveMembers() { + User host = userRepository.save(User.ofGoogle("g1", "h@a.com", "h", null)); + User other = userRepository.save(User.ofGoogle("g2", "o@a.com", "o", null)); + + Room hostOnly = roomRepository.save(Room.create("A", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-a", host.getId())); + Room hostWithOther = roomRepository.save(Room.create("B", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-b", host.getId())); + Room memberRoom = roomRepository.save(Room.create("C", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-c", other.getId())); + + roomMemberRepository.save(RoomMember.create(hostOnly, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithOther, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithOther, other, RoomRole.MEMBER)); + roomMemberRepository.save(RoomMember.create(memberRoom, other, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(memberRoom, host, RoomRole.MEMBER)); + em.flush(); + em.clear(); + + List result = + roomMemberRepository.findHostRoomsWithOtherActiveMembers(host.getId()); + + assertThat(result).extracting(RoomRequiringDelegationView::getRoomId) + .containsExactly(hostWithOther.getId()); + } + + @Test + @DisplayName("HOST이면서 다른 활성 멤버가 없는 방만 1인 방 목록에 포함된다 (PENDING만 있어도 포함)") + void findHostRoomsWithOnlySelf() { + User host = userRepository.save(User.ofGoogle("g1", "h@a.com", "h", null)); + User pendingUser = userRepository.save(User.ofGoogle("g2", "p@a.com", "p", null)); + + Room soloHost = roomRepository.save(Room.create("A", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-a", host.getId())); + Room hostWithPending = roomRepository.save(Room.create("B", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-b", host.getId())); + + roomMemberRepository.save(RoomMember.create(soloHost, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithPending, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithPending, pendingUser, RoomRole.PENDING)); + em.flush(); + em.clear(); + + List result = roomMemberRepository.findHostRoomsWithOnlySelf(host.getId()) + .stream().map(Room::getId).toList(); + + assertThat(result).containsExactlyInAnyOrder(soloHost.getId(), hostWithPending.getId()); + } + + @Test + @DisplayName("findAllByUser_Id는 역할 무관하게 사용자의 모든 room_members를 반환한다") + void findAllByUserId() { + User u = userRepository.save(User.ofGoogle("g1", "h@a.com", "h", null)); + Room r1 = roomRepository.save(Room.create("A", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "i-a", u.getId())); + Room r2 = roomRepository.save(Room.create("B", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "i-b", u.getId())); + roomMemberRepository.save(RoomMember.create(r1, u, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(r2, u, RoomRole.PENDING)); + em.flush(); + em.clear(); + + List result = roomMemberRepository.findAllByUser_Id(u.getId()); + assertThat(result).hasSize(2); + } +} +``` + +- [ ] **Step 2: 테스트 실행으로 컴파일 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.repository.RoomMemberRepositoryWithdrawalTest` +Expected: COMPILE ERROR — 메서드 없음. + +- [ ] **Step 3: Repository 쿼리 추가** + +`src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java`에 import와 메서드 추가: + +```java +import java.util.UUID; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.howaboutus.backend.rooms.entity.Room; + +// ... 기존 메서드들 아래에 ... + +List findAllByUser_Id(Long userId); + +@Query(""" + select m.room as room + from RoomMember m + where m.user.id = :userId + and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + 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) + ) + """) +List findHostRoomsWithOnlySelf(@Param("userId") Long userId); + +interface RoomRequiringDelegationView { + UUID getRoomId(); + String getTitle(); +} + +@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 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) + ) + """) +List findHostRoomsWithOtherActiveMembers(@Param("userId") Long userId); +``` + +- [ ] **Step 4: 테스트 실행으로 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.rooms.repository.RoomMemberRepositoryWithdrawalTest` +Expected: PASS (3 tests). + +- [ ] **Step 5: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java \ + src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java +git commit -m "feat: 회원 탈퇴용 RoomMember 조회 메서드 추가" +``` + +--- + +## Task 5: 예외/DTO 정의 (`HostDelegationRequiredException`, `RoomRequiringDelegation`, `WithdrawalBlockedResponse`) + +**Files:** +- Create: 4개 신규 파일 + +- [ ] **Step 1: RoomRequiringDelegation DTO** + +`src/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.java`: + +```java +package com.howaboutus.backend.user.service.dto; + +import java.util.UUID; + +public record RoomRequiringDelegation(UUID roomId, String title) { +} +``` + +- [ ] **Step 2: WithdrawalBlockedResponse DTO** + +`src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java`: + +```java +package com.howaboutus.backend.user.service.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "회원 탈퇴 거절 응답 - 방장 위임이 필요한 방 목록") +public record WithdrawalBlockedResponse( + @Schema(description = "방장 위임이 필요한 방 목록") + List roomsRequiringDelegation +) { +} +``` + +- [ ] **Step 3: HostDelegationRequiredException** + +`src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java`: + +```java +package com.howaboutus.backend.user.exception; + +import java.util.List; + +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; + +import lombok.Getter; + +@Getter +public class HostDelegationRequiredException extends RuntimeException { + + private final List roomsRequiringDelegation; + + public HostDelegationRequiredException(List roomsRequiringDelegation) { + super("Host delegation required for " + roomsRequiringDelegation.size() + " room(s)."); + this.roomsRequiringDelegation = List.copyOf(roomsRequiringDelegation); + } +} +``` + +- [ ] **Step 4: 컴파일 확인 + Checkstyle** + +Run: `./gradlew compileJava checkstyleMain` +Expected: SUCCESS. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/user/service/dto \ + src/main/java/com/howaboutus/backend/user/exception +git commit -m "feat: 회원 탈퇴 거절 응답 DTO와 예외 정의" +``` + +--- + +## Task 6: `RefreshTokenService.invalidateAllForUser(Long)` 노출 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java` +- Test: `src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java` (신규) + +- [ ] **Step 1: 통합 테스트 작성 (Testcontainers 기반)** + +`src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java`: + +```java +package com.howaboutus.backend.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.support.BaseIntegrationTest; + +class RefreshTokenServiceInvalidateAllForUserTest extends BaseIntegrationTest { + + @Autowired + private RefreshTokenService refreshTokenService; + + @Test + @DisplayName("invalidateAllForUser는 해당 유저의 모든 RTK를 삭제한다") + void invalidatesAllTokensForUser() { + long userId = 9001L; + String t1 = refreshTokenService.create(userId); + String t2 = refreshTokenService.create(userId); + + refreshTokenService.invalidateAllForUser(userId); + + assertThatThrownBy(() -> refreshTokenService.rotate(t1)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + assertThatThrownBy(() -> refreshTokenService.rotate(t2)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("대상 유저가 RTK를 갖고 있지 않으면 안전하게 no-op이다") + void noOpWhenNoTokens() { + long userId = 9002L; + + refreshTokenService.invalidateAllForUser(userId); + + assertThat(true).isTrue(); + } +} +``` + +- [ ] **Step 2: 실행으로 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.auth.service.RefreshTokenServiceInvalidateAllForUserTest` +Expected: COMPILE ERROR — `invalidateAllForUser` 없음. + +- [ ] **Step 3: 서비스에 public 메서드 추가** + +`RefreshTokenService.java`의 private 헬퍼 `invalidateAllTokens(String userId)` 위에 public wrapper를 추가하고 기존 private은 그대로 둔다: + +```java +public void invalidateAllForUser(Long userId) { + invalidateAllTokens(String.valueOf(userId)); +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.auth.service.RefreshTokenServiceInvalidateAllForUserTest` +Expected: PASS (2 tests). + +- [ ] **Step 5: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java \ + src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java +git commit -m "feat: RefreshTokenService에 사용자 단위 토큰 일괄 폐기 API 노출" +``` + +--- + +## Task 7: `UserWithdrawnEvent`와 RTK 폐기 리스너 + +**Files:** +- Create: `src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java` +- Create: `src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java` +- Test: `src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java` + +- [ ] **Step 1: 이벤트 정의** + +`src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java`: + +```java +package com.howaboutus.backend.user.event; + +public record UserWithdrawnEvent(Long userId) { +} +``` + +- [ ] **Step 2: 리스너 단위 테스트 작성** + +`src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java`: + +```java +package com.howaboutus.backend.user.listener; + +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.howaboutus.backend.auth.service.RefreshTokenService; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; + +@ExtendWith(MockitoExtension.class) +class UserWithdrawnTokenListenerTest { + + @Mock + private RefreshTokenService refreshTokenService; + + @InjectMocks + private UserWithdrawnTokenListener listener; + + @Test + @DisplayName("UserWithdrawnEvent 수신 시 해당 userId의 RTK를 일괄 폐기한다") + void invalidatesTokensOnEvent() { + listener.handle(new UserWithdrawnEvent(42L)); + + verify(refreshTokenService).invalidateAllForUser(42L); + } +} +``` + +- [ ] **Step 3: 실행으로 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.listener.UserWithdrawnTokenListenerTest` +Expected: COMPILE ERROR — 리스너 없음. + +- [ ] **Step 4: 리스너 구현** + +`src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java`: + +```java +package com.howaboutus.backend.user.listener; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.howaboutus.backend.auth.service.RefreshTokenService; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserWithdrawnTokenListener { + + private final RefreshTokenService refreshTokenService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handle(UserWithdrawnEvent event) { + try { + refreshTokenService.invalidateAllForUser(event.userId()); + } catch (Exception e) { + log.warn("Failed to invalidate refresh tokens for withdrawn user. userId={}", + event.userId(), e); + } + } +} +``` + +- [ ] **Step 5: 테스트 통과 확인 + Checkstyle** + +Run: `./gradlew test --tests com.howaboutus.backend.user.listener.UserWithdrawnTokenListenerTest && ./gradlew checkstyleMain checkstyleTest` +Expected: PASS, 0 warnings. + +- [ ] **Step 6: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/user/event \ + src/main/java/com/howaboutus/backend/user/listener \ + src/test/java/com/howaboutus/backend/user/listener +git commit -m "feat: 탈퇴 이벤트와 RTK 일괄 폐기 리스너 추가" +``` + +--- + +## Task 8: `UserWithdrawalService` — 핵심 로직 TDD + +**Files:** +- Create: `src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java` +- Test: `src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java` + +> 주의: `RoomMemberRepository.RoomRequiringDelegationView`는 인터페이스 projection이라 Mockito로 mock하기 번거롭다. 단위 테스트에서는 익명 구현체를 만들어 반환값을 구성한다. + +- [ ] **Step 1: 단위 테스트 작성** + +`src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java`: + +```java +package com.howaboutus.backend.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.realtime.event.MemberLeftEvent; +import com.howaboutus.backend.realtime.event.RoomDeletedEvent; +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.rooms.repository.RoomMemberRepository; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository.RoomRequiringDelegationView; +import com.howaboutus.backend.rooms.repository.RoomRepository; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class UserWithdrawalServiceTest { + + private static final Long USER_ID = 100L; + + @Mock + private UserRepository userRepository; + @Mock + private RoomRepository roomRepository; + @Mock + private RoomMemberRepository roomMemberRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + + private UserWithdrawalService service; + private User user; + + @BeforeEach + void setUp() { + service = new UserWithdrawalService( + userRepository, roomRepository, roomMemberRepository, eventPublisher); + user = User.ofGoogle("g", "u@a.com", "닉", "https://img/me.png"); + ReflectionTestUtils.setField(user, "id", USER_ID); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + } + + @Test + @DisplayName("위임 필요 방이 있으면 HostDelegationRequiredException으로 차단되고 mutation이 없다") + void blocksWithoutMutationsWhenDelegationRequired() { + UUID roomId = UUID.randomUUID(); + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of(view(roomId, "Trip"))); + + assertThatThrownBy(() -> service.withdraw(USER_ID)) + .isInstanceOf(HostDelegationRequiredException.class) + .satisfies(ex -> { + HostDelegationRequiredException e = (HostDelegationRequiredException) ex; + assertThat(e.getRoomsRequiringDelegation()).hasSize(1); + assertThat(e.getRoomsRequiringDelegation().get(0).roomId()).isEqualTo(roomId); + }); + + verify(roomRepository, never()).delete(any()); + verify(roomMemberRepository, never()).delete(any()); + assertThat(user.isWithdrawn()).isFalse(); + } + + @Test + @DisplayName("정상 탈퇴는 1인 방 삭제, 잔여 멤버십 삭제, 익명화, 이벤트 발행을 수행한다") + void successfulWithdrawal() { + Room solo = Room.create("Solo", null, null, null, "i-solo", USER_ID); + ReflectionTestUtils.setField(solo, "id", UUID.randomUUID()); + + Room memberRoom = Room.create("MemberRoom", null, null, null, "i-mr", 999L); + ReflectionTestUtils.setField(memberRoom, "id", UUID.randomUUID()); + + RoomMember memberOfRoom = RoomMember.create(memberRoom, user, RoomRole.MEMBER); + RoomMember pendingMembership = RoomMember.create(memberRoom, user, RoomRole.PENDING); + + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of()); + given(roomMemberRepository.findHostRoomsWithOnlySelf(USER_ID)) + .willReturn(List.of(solo)); + given(roomMemberRepository.findAllByUser_Id(USER_ID)) + .willReturn(List.of(memberOfRoom, pendingMembership)); + + service.withdraw(USER_ID); + + verify(roomRepository).delete(solo); + verify(eventPublisher).publishEvent(any(RoomDeletedEvent.class)); + verify(roomMemberRepository, times(2)).delete(any(RoomMember.class)); + verify(eventPublisher).publishEvent(any(MemberLeftEvent.class)); // MEMBER 1건만 + verify(eventPublisher).publishEvent(any(UserWithdrawnEvent.class)); + assertThat(user.isWithdrawn()).isTrue(); + assertThat(user.getEmail()).isNull(); + } + + @Test + @DisplayName("최종 더블체크에서 위임 필요 방이 검출되면 예외로 롤백 시그널을 보낸다") + void doubleCheckRollback() { + UUID lateRoomId = UUID.randomUUID(); + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of()) + .willReturn(List.of(view(lateRoomId, "Late"))); + given(roomMemberRepository.findHostRoomsWithOnlySelf(USER_ID)).willReturn(List.of()); + given(roomMemberRepository.findAllByUser_Id(USER_ID)).willReturn(List.of()); + + assertThatThrownBy(() -> service.withdraw(USER_ID)) + .isInstanceOf(HostDelegationRequiredException.class); + + assertThat(user.isWithdrawn()).isFalse(); + } + + @Test + @DisplayName("user를 찾을 수 없으면 USER_NOT_FOUND") + void userNotFound() { + given(userRepository.findById(USER_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.withdraw(USER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); + } + + private RoomRequiringDelegationView view(UUID roomId, String title) { + return new RoomRequiringDelegationView() { + @Override + public UUID getRoomId() { + return roomId; + } + + @Override + public String getTitle() { + return title; + } + }; + } +} +``` + +- [ ] **Step 2: 실행으로 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.service.UserWithdrawalServiceTest` +Expected: COMPILE ERROR. + +- [ ] **Step 3: 서비스 구현** + +`src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java`: + +```java +package com.howaboutus.backend.user.service; + +import java.util.List; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +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.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository.RoomRequiringDelegationView; +import com.howaboutus.backend.rooms.repository.RoomRepository; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.repository.UserRepository; +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserWithdrawalService { + + private final UserRepository userRepository; + private final RoomRepository roomRepository; + private final RoomMemberRepository roomMemberRepository; + private final ApplicationEventPublisher eventPublisher; + + @Loggable + @Transactional + public void withdraw(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + ensureNoHostDelegationRequired(userId); + + deleteSoloHostRooms(userId); + deleteRemainingMemberships(userId, user); + + ensureNoHostDelegationRequired(userId); // 더블체크: 사전 검증 이후 윈도우 보호 + + user.anonymize(); + eventPublisher.publishEvent(new UserWithdrawnEvent(userId)); + } + + private void ensureNoHostDelegationRequired(Long userId) { + List blocking = + roomMemberRepository.findHostRoomsWithOtherActiveMembers(userId); + if (!blocking.isEmpty()) { + List mapped = blocking.stream() + .map(v -> new RoomRequiringDelegation(v.getRoomId(), v.getTitle())) + .toList(); + throw new HostDelegationRequiredException(mapped); + } + } + + private void deleteSoloHostRooms(Long userId) { + List soloRooms = roomMemberRepository.findHostRoomsWithOnlySelf(userId); + for (Room room : soloRooms) { + roomRepository.delete(room); + eventPublisher.publishEvent(new RoomDeletedEvent(room.getId(), List.of(userId))); + } + } + + private void deleteRemainingMemberships(Long userId, User user) { + List memberships = roomMemberRepository.findAllByUser_Id(userId); + for (RoomMember m : memberships) { + roomMemberRepository.delete(m); + if (m.getRole() == RoomRole.MEMBER) { + eventPublisher.publishEvent(new MemberLeftEvent( + m.getRoom().getId(), userId, + user.getNickname(), user.getProfileImageUrl())); + } + } + } +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.service.UserWithdrawalServiceTest` +Expected: PASS (4 tests). + +- [ ] **Step 5: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java \ + src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java +git commit -m "feat: UserWithdrawalService로 탈퇴 트랜잭션 오케스트레이션 구현" +``` + +--- + +## Task 9: `GlobalExceptionHandler`에 `HostDelegationRequiredException` 매핑 추가 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java` + +> 이 예외는 본문 구조가 `ApiErrorResponse`와 다르기 때문에 별도 핸들러로 처리한다. + +- [ ] **Step 1: 핸들러 추가** + +`GlobalExceptionHandler.java` 상단에 import 추가: + +```java +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.service.dto.WithdrawalBlockedResponse; +``` + +`handleOptimisticLockingFailure` 위에 핸들러 추가: + +```java +@ExceptionHandler(HostDelegationRequiredException.class) +public ResponseEntity handleHostDelegationRequired( + HostDelegationRequiredException exception) { + log.warn("[EXCEPTION] {} | {}", + kv("errorCode", ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION), + kv("blockedRoomCount", exception.getRoomsRequiringDelegation().size())); + return ResponseEntity.status(ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION.getStatus()) + .body(new WithdrawalBlockedResponse(exception.getRoomsRequiringDelegation())); +} +``` + +- [ ] **Step 2: 컴파일 + Checkstyle** + +Run: `./gradlew compileJava checkstyleMain` +Expected: SUCCESS. + +- [ ] **Step 3: 커밋** + +```bash +git add src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java +git commit -m "feat: 회원 탈퇴 거절 응답 핸들러 추가" +``` + +--- + +## Task 10: 컨트롤러 `DELETE /users/me` + 만료 쿠키 + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/user/controller/UserController.java` +- Test: `src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java` (신규) + +> 만료 쿠키 구성은 `AuthController.logout`과 동일 패턴(`activeProfile`, `cookieProperties`)을 따른다. `UserController`에 `JwtProperties`/`CookieProperties` 의존을 직접 주입해도 되지만, 더 단순하게 `AuthService`에 분리하지 말고 컨트롤러 안에서 처리한다. + +- [ ] **Step 1: WebMvc 테스트 작성** + +`src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java`: + +```java +package com.howaboutus.backend.user.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.BDDMockito.doNothing; +import static org.mockito.BDDMockito.doThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +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.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; + +@WebMvcTest(controllers = UserController.class) +class UserWithdrawalControllerTest { + + @Autowired + private MockMvc mvc; + + @MockBean + private UserService userService; + @MockBean + private UserWithdrawalService userWithdrawalService; + + @Test + @DisplayName("DELETE /users/me 성공 시 204와 만료 쿠키를 반환한다") + void deleteMeSuccess() throws Exception { + doNothing().when(userWithdrawalService).withdraw(1L); + + mvc.perform(delete("/users/me").requestAttr("authenticatedUserId", 1L)) + .andExpect(status().isNoContent()) + .andExpect(cookie().maxAge("access_token", 0)) + .andExpect(cookie().maxAge("refresh_token", 0)); + } + + @Test + @DisplayName("DELETE /users/me 위임 필요 시 422와 roomsRequiringDelegation 본문을 반환한다") + void deleteMeBlocked() throws Exception { + UUID roomId = UUID.randomUUID(); + doThrow(new HostDelegationRequiredException( + List.of(new RoomRequiringDelegation(roomId, "Trip")))) + .when(userWithdrawalService).withdraw(1L); + + mvc.perform(delete("/users/me").requestAttr("authenticatedUserId", 1L)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.roomsRequiringDelegation[0].roomId") + .value(roomId.toString())) + .andExpect(jsonPath("$.roomsRequiringDelegation[0].title").value("Trip")); + } +} +``` + +> 위 테스트는 Spring Security `@AuthenticationPrincipal` 주입을 우회하려고 `requestAttr` 패턴을 가정한다. 프로젝트에서 사용하는 기존 `AuthControllerTest`/`UserControllerTest` 패턴을 그대로 따라 `@MockBean` + custom argument resolver, 또는 `with(...)` Security context 설정을 사용한다. 기존 `UserControllerTest`가 있다면 동일한 방식으로 인증 시뮬레이션을 수행한다. + +- [ ] **Step 2: 컴파일 + 실행으로 실패 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.controller.UserWithdrawalControllerTest` +Expected: 컴파일 또는 어설션 실패. + +- [ ] **Step 3: 컨트롤러 수정** + +`UserController.java` 전체를 다음으로 교체(기존 `getMyProfile` 유지): + +```java +package com.howaboutus.backend.user.controller; + +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.howaboutus.backend.common.config.properties.CookieProperties; +import com.howaboutus.backend.common.error.ApiErrorCodes; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.common.logging.Loggable; +import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; +import com.howaboutus.backend.user.service.dto.UserResponse; +import com.howaboutus.backend.user.service.dto.WithdrawalBlockedResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Users", description = "사용자 API") +@RestController +@RequestMapping("/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + private final UserWithdrawalService userWithdrawalService; + private final CookieProperties cookieProperties; + + @Value("${spring.profiles.active:dev}") + private String activeProfile; + + @Operation(summary = "내 프로필 조회", description = "현재 인증된 사용자의 프로필을 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공") + @ApiErrorCodes({ErrorCode.INVALID_TOKEN, ErrorCode.ACCESS_TOKEN_EXPIRED, ErrorCode.USER_NOT_FOUND}) + @GetMapping("/me") + public ResponseEntity getMyProfile(@AuthenticationPrincipal Long userId) { + return ResponseEntity.ok(userService.getMyProfile(userId)); + } + + @Operation( + summary = "회원 탈퇴", + description = "현재 인증된 사용자의 탈퇴를 처리합니다. " + + "방장인 방은 사전 위임이 필요하며, 1인 방은 자동 hard delete됩니다. " + + "성공 시 인증 쿠키를 만료시킵니다." + ) + @ApiResponse(responseCode = "204", description = "탈퇴 성공 (No Content)", content = @Content) + @ApiResponse(responseCode = "422", + description = "방장 위임이 필요한 방이 존재", + content = @Content(schema = @Schema(implementation = WithdrawalBlockedResponse.class))) + @ApiErrorCodes({ + ErrorCode.INVALID_TOKEN, + ErrorCode.ACCESS_TOKEN_EXPIRED, + ErrorCode.USER_NOT_FOUND, + ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION + }) + @Loggable + @DeleteMapping("/me") + public ResponseEntity withdraw(@AuthenticationPrincipal Long userId) { + userWithdrawalService.withdraw(userId); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, expiredCookie("access_token").toString()) + .header(HttpHeaders.SET_COOKIE, expiredCookie("refresh_token").toString()) + .build(); + } + + private ResponseCookie expiredCookie(String name) { + boolean secure = "prod".equals(activeProfile); + ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, "") + .httpOnly(true).sameSite("Lax").path("/") + .maxAge(Duration.ZERO).secure(secure); + String domain = cookieProperties.domain(); + if (domain != null && !domain.isBlank()) { + builder.domain(domain); + } + return builder.build(); + } +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.controller.UserWithdrawalControllerTest` +Expected: PASS (2 tests). + +- [ ] **Step 5: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/main/java/com/howaboutus/backend/user/controller/UserController.java \ + src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java +git commit -m "feat: DELETE /users/me 탈퇴 엔드포인트와 만료 쿠키 처리" +``` + +--- + +## Task 11: 통합 테스트 — 엔드 투 엔드 흐름 + +**Files:** +- Test: `src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java` (신규) + +- [ ] **Step 1: 통합 테스트 작성** + +`src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java`: + +```java +package com.howaboutus.backend.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.support.TransactionTemplate; + +import com.howaboutus.backend.auth.service.RefreshTokenService; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +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.rooms.repository.RoomMemberRepository; +import com.howaboutus.backend.rooms.repository.RoomRepository; +import com.howaboutus.backend.support.BaseIntegrationTest; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.repository.UserRepository; +import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +class UserWithdrawalIntegrationTest extends BaseIntegrationTest { + + @Autowired private UserWithdrawalService withdrawalService; + @Autowired private UserRepository userRepository; + @Autowired private RoomRepository roomRepository; + @Autowired private RoomMemberRepository roomMemberRepository; + @Autowired private UserService userService; + @Autowired private RefreshTokenService refreshTokenService; + @Autowired private TransactionTemplate tx; + + @PersistenceContext private EntityManager em; + + @Test + @DisplayName("정상 탈퇴: 1인 방 삭제, 잔여 멤버십 삭제, 익명화, RTK 즉시 삭제") + void happyPath() { + User u = userRepository.save(User.ofGoogle("g1", "u@a.com", "닉", null)); + User other = userRepository.save(User.ofGoogle("g2", "o@a.com", "오", null)); + + Room solo = roomRepository.save(Room.create("Solo", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-s", u.getId())); + roomMemberRepository.save(RoomMember.create(solo, u, RoomRole.HOST)); + + Room memberRoom = roomRepository.save(Room.create("M", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-m", other.getId())); + roomMemberRepository.save(RoomMember.create(memberRoom, other, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(memberRoom, u, RoomRole.MEMBER)); + + String rtk = refreshTokenService.create(u.getId()); + + withdrawalService.withdraw(u.getId()); + em.clear(); + + assertThat(roomRepository.findById(solo.getId())).isEmpty(); + assertThat(roomMemberRepository.findByRoom_IdAndUser_Id(memberRoom.getId(), u.getId())) + .isEmpty(); + assertThat(userRepository.findById(u.getId())).isEmpty(); // @SQLRestriction 효과 + + assertThatThrownBy(() -> refreshTokenService.rotate(rtk)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("다인 HOST 방이 있으면 차단되고 어떤 변경도 일어나지 않는다") + void blocksAndRollsBack() { + User host = userRepository.save(User.ofGoogle("g1", "h@a.com", "h", null)); + User other = userRepository.save(User.ofGoogle("g2", "o@a.com", "o", null)); + Room room = roomRepository.save(Room.create("R", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-r", host.getId())); + roomMemberRepository.save(RoomMember.create(room, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(room, other, RoomRole.MEMBER)); + + assertThatThrownBy(() -> withdrawalService.withdraw(host.getId())) + .isInstanceOf(HostDelegationRequiredException.class); + + em.clear(); + Optional reread = userRepository.findById(host.getId()); + assertThat(reread).isPresent(); + assertThat(reread.get().isWithdrawn()).isFalse(); + } + + @Test + @DisplayName("탈퇴자와 동일 (provider, providerId)로 재가입할 수 있다") + void reSignupWithSameProvider() { + User u = userRepository.save(User.ofGoogle("pid-1", "re@a.com", "닉", null)); + tx.executeWithoutResult(s -> withdrawalService.withdraw(u.getId())); + em.clear(); + + User reborn = userService.getOrCreateGoogleUser( + "pid-1", "re@a.com", "재가입", null); + + assertThat(reborn.getId()).isNotEqualTo(u.getId()); + assertThat(userRepository.findByProviderAndProviderId("GOOGLE", "pid-1").get().getId()) + .isEqualTo(reborn.getId()); + } +} +``` + +- [ ] **Step 2: 실행으로 통과 확인** + +Run: `./gradlew test --tests com.howaboutus.backend.user.UserWithdrawalIntegrationTest` +Expected: PASS (3 tests). + +> Testcontainers 첫 부팅이 느릴 수 있다. 캐시된 컨테이너 이미지가 없는 경우 5~10분이 걸릴 수 있다. + +- [ ] **Step 3: Checkstyle + 커밋** + +```bash +./gradlew checkstyleMain checkstyleTest +git add src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java +git commit -m "test: 회원 탈퇴 엔드 투 엔드 통합 테스트 추가" +``` + +--- + +## Task 12: 문서 갱신 (`features.md`, `erd.md`, 결정 기록) + +**Files:** +- Modify: `docs/ai/features.md` +- Modify: `docs/ai/erd.md` +- Create: `docs/ai/decisions/20260607-user-withdrawal-soft-delete.md` + +- [ ] **Step 1: `docs/ai/features.md` 변경** + +"1. 인증 (Auth)" 표 마지막 행에 다음을 추가하거나, 별도 "사용자(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 | +``` + +- [ ] **Step 2: `docs/ai/erd.md` `users` 섹션 갱신** + +`deleted_at` 컬럼을 추가하고 제약/인덱스를 다음과 같이 갱신: + +``` +| deleted_at | TIMESTAMP | NULLABLE | 탈퇴 시각. NOT NULL이면 익명화된 탈퇴 회원 | + +**제약:** +- CHECK `users_active_required`: `deleted_at IS NOT NULL OR (email AND nickname AND provider AND provider_id IS NOT NULL)` +- 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` + +**인덱스 (탈퇴자 필터):** `users_deleted_at_idx` ON `(deleted_at)` WHERE `deleted_at IS NOT NULL` + +> 활성 회원은 email/nickname/provider/provider_id가 NOT NULL이며 이메일과 (provider, provider_id) 조합이 unique. 탈퇴 회원은 모두 NULL 가능하며 unique 검사 대상에서 제외되어 동일 OAuth 계정으로 재가입할 수 있다. +``` + +기존 "**제약:** UNIQUE(provider, provider_id)" 문구는 제거한다. + +- [ ] **Step 3: 결정 기록 작성** + +`docs/ai/decisions/20260607-user-withdrawal-soft-delete.md`: + +```markdown +# users soft delete: partial unique index + CHECK 채택 + +- **상태**: 결정 +- **날짜**: 2026-06-07 +- **관련**: docs/superpowers/specs/2026-06-07-user-withdrawal-design.md + +## 배경 + +이용약관/개인정보 정책상 회원 탈퇴 기능이 필요하다. 채팅 등 공동 협업 데이터의 무결성을 유지하면서 탈퇴자의 개인정보는 즉시 제거되어야 한다. 동시에 `users.email`과 `(provider, provider_id)`에 걸린 UNIQUE 제약 때문에 단순히 컬럼을 NULL로 비울 수 없다. + +## 결정 + +`users`를 soft delete 모델로 운영한다. + +- 활성 회원: `email`, `nickname`, `provider`, `provider_id`는 NOT NULL. +- 탈퇴 회원: 위 컬럼들 NULL 허용. `deleted_at`이 NOT NULL. +- 활성 회원에 한해 unique 강제: PostgreSQL partial unique index (`WHERE deleted_at IS NULL`). +- 조건부 NOT NULL은 CHECK `users_active_required`로 보장. +- 엔티티에 `@SQLRestriction("deleted_at IS NULL")`을 적용해 JPA 조회에서 탈퇴자를 자동 제외. + +## 대안 검토 + +- **placeholder 값**(`deleted+{id}@deleted.local` 등): NOT NULL/UNIQUE 제약을 그대로 둘 수 있으나 통계/검색에 가짜 값이 섞이고, 재가입 시 충돌 회피가 복잡. 동일 계정 재가입 차단이 정책 요건이 아닌 본 프로젝트에서는 이점이 없다. +- **분리된 `deleted_users` 테이블**: row 이관 비용/추가 join 필요. soft delete의 단순성을 잃는다. + +## 영향 + +- DDL 변경(`V1.7__users_withdrawal.sql`): NOT NULL drop, 기존 UNIQUE 제약 제거, partial unique index 및 CHECK 추가. +- `User` 엔티티에 `deletedAt`, `anonymize()`, `isWithdrawn()` 추가. +- 동일 OAuth 계정 재가입 가능. 정책상 차단할 필요가 있을 때만 별도 결정 기록으로 변경. +``` + +- [ ] **Step 4: 문서 일치성 점검** + +`/checking-md-conflicts` 스킬을 실행해 죽은 참조/중복/모순이 없는지 확인하고, 보고된 이슈가 있으면 해결한다. + +- [ ] **Step 5: 커밋** + +```bash +git add docs/ai/features.md docs/ai/erd.md docs/ai/decisions/20260607-user-withdrawal-soft-delete.md +git commit -m "docs: 회원 탈퇴 정책을 features/erd/decisions에 반영" +``` + +--- + +## Task 13: 최종 회귀 + 푸시 준비 + +**Files:** N/A + +- [ ] **Step 1: 전체 빌드와 테스트** + +Run: `./gradlew build` +Expected: BUILD SUCCESSFUL. 모든 기존 테스트와 신규 테스트 모두 PASS. + +- [ ] **Step 2: 체크스타일 최종 확인** + +Run: `./gradlew checkstyleMain checkstyleTest` +Expected: 0 warnings. + +- [ ] **Step 3: `/review-code-against-docs` 스킬 실행** + +CLAUDE.md `Before You Finish` 규칙에 따라 `/review-code-against-docs`로 변경 코드가 문서 스펙과 일치하는지 검증. + +- [ ] **Step 4: 푸시 (사용자 승인 필요)** + +```bash +git push -u origin feature/user-withdrawal +``` + +CLAUDE.md `Agent Boundaries`에 따라 push는 사용자 확인 후에만 수행. + +- [ ] **Step 5: PR 생성 (사용자 승인 필요)** + +`.github/pull_request_template.md` 양식을 그대로 따라 PR을 작성. base는 `dev`. + +--- + +## 자가 검토 요약 + +- **스펙 커버리지**: API 형태(Task 10), DB 변경(Task 1), User 엔티티 변경(Task 2), Repository 쿼리(Task 4), 서비스 오케스트레이션(Task 8), 더블체크(Task 8 Step 3 코드), 예외/응답(Task 5/9), RTK 즉시 폐기(Task 6/7), 채팅 senderId 처리(서버 변경 없음 — 문서로만 명시: Task 12), 1인 방 hard delete(Task 8), PENDING 처리(Task 4/8), 통합 테스트(Task 11), 문서 갱신(Task 12) — 모두 커버. +- **플레이스홀더 스캔**: 모든 step에 실행 가능한 코드/명령 포함. "TBD"/"적절한 에러 처리" 같은 표현 없음. +- **타입 일관성**: `RoomRequiringDelegation` (DTO), `RoomRequiringDelegationView` (projection 인터페이스), `HostDelegationRequiredException`, `WithdrawalBlockedResponse`, `UserWithdrawnEvent`의 시그니처가 Task 4·5·7·8·9·10에서 동일하게 사용됨. +- **범위 외**: JWT blacklist, 탈퇴 유예/복구, 별도 user 조회 API(클라이언트 senderId 해석용)는 스펙 §11에 따라 본 플랜에서 제외. + +--- + +## 실행 옵션 + +플랜이 완성되어 `docs/superpowers/plans/2026-06-07-user-withdrawal.md`에 저장되었습니다. + +**1. Subagent-Driven (추천)** — Task 단위로 fresh 서브에이전트를 띄우고 사이마다 리뷰. 빠른 반복. + +**2. Inline Execution** — 이 세션에서 그대로 실행. `superpowers:executing-plans` 사용. 체크포인트로 검토. + +어느 쪽으로 진행할까요? diff --git a/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md b/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md new file mode 100644 index 00000000..216311b7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-user-withdrawal-design.md @@ -0,0 +1,277 @@ +# 회원 탈퇴 기능 설계 (User Withdrawal) + +- **상태**: 초안 +- **작성일**: 2026-06-07 +- **관련 문서**: [`docs/ai/features.md`](../../ai/features.md), [`docs/ai/erd.md`](../../ai/erd.md) + +## 1. 배경 + +이용약관/개인정보 처리 정책 상 회원 탈퇴 기능이 필요하다. 채팅을 비롯한 공동 협업 데이터의 무결성을 유지하면서 탈퇴자의 개인정보는 즉시 익명화하고, 인증 정보는 즉시 무효화해야 한다. + +## 2. 정책 요약 + +- `users` 행은 **soft delete + 익명화**한다. 채팅 작성자 식별 등 BIGINT 논리 참조 일관성은 그대로 유지된다. +- `room_members`는 **hard delete**한다. +- **방장(HOST)인 방**은 다음과 같이 처리한다. + - 다른 활성 멤버가 있으면: 탈퇴 전에 위임이 선행되어야 한다. 위임이 누락되면 422로 거절한다. + - 다른 활성 멤버가 없으면(1인 방): 탈퇴 처리 안에서 방을 **hard delete**한다. +- **MongoDB 채팅 메시지**는 유지한다. `senderId`도 그대로 둔다. +- **Bookmark / BookmarkCategory / Schedule** 등의 `added_by`/`created_by` BIGINT 논리 참조는 그대로 유지한다. +- **클라이언트 표시 규칙**: 클라이언트는 `members` 목록에 없는 `senderId`/`addedBy`/`createdBy`를 일률적으로 "(알 수 없음)"으로 표시한다. 추방/방 나가기/탈퇴를 구분하지 않는다. +- **Redis refresh token**은 탈퇴 직후 해당 사용자의 모든 RTK를 즉시 삭제한다. +- **JWT access_token**은 stateless로 두며 만료까지 자연 소멸한다(별도 blacklist 미도입). + +## 3. API + +### `DELETE /users/me` + +**요청**: 인증 필요(쿠키 `access_token`), 요청 본문 없음. + +**응답** + +- `204 No Content` + Set-Cookie로 만료된 `access_token` / `refresh_token` (maxAge=0) +- `422 Unprocessable Entity` + 본문: + +```json +{ + "roomsRequiringDelegation": [ + { "roomId": "uuid", "title": "string" } + ] +} +``` + +- `401 Unauthorized`: 인증 실패 (기존 필터 처리) +- `404 Not Found`: 이미 탈퇴 상태(USER_NOT_FOUND) + +422를 받은 클라이언트는 기존 `PATCH /rooms/{roomId}/host`로 각 방의 위임을 완료한 뒤 `DELETE /users/me`를 재시도한다. + +## 4. DB 스키마 변경 (`users`) + +```sql +ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL; + +ALTER TABLE users ALTER COLUMN email DROP NOT NULL; +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; + +ALTER TABLE users ADD CONSTRAINT users_active_required CHECK ( + 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) +); + +ALTER TABLE users DROP CONSTRAINT users_email_key; +ALTER TABLE users DROP CONSTRAINT users_provider_provider_id_key; + +CREATE UNIQUE INDEX users_email_unique_active + ON users(email) WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX users_provider_provider_id_unique_active + ON users(provider, provider_id) WHERE deleted_at IS NULL; + +CREATE INDEX users_deleted_at_idx ON users(deleted_at) WHERE deleted_at IS NOT NULL; +``` + +> 기존 UNIQUE 제약 명칭은 실제 환경에서 `\d users`로 확인 후 정확히 지정한다. + +### 결과로 보장되는 invariant + +- 활성 회원: `email`, `nickname`, `provider`, `provider_id` 모두 NOT NULL (CHECK) +- 활성 회원 간: `email`, `(provider, provider_id)` 각각 UNIQUE (partial unique) +- 탈퇴 회원: 위 컬럼들 모두 NULL 허용, unique 검사 대상 제외 → 동일 Google 계정 재가입 가능 + +## 5. 엔티티 변경 (`User.java`) + +- `email`, `nickname`, `provider`, `providerId`의 `@Column(nullable = false)` 제거 +- `private Instant deletedAt;` 컬럼 추가 +- 클래스에 `@SQLRestriction("deleted_at IS NULL")` 적용 → JPA 통한 모든 `User` 조회에서 탈퇴자 자동 제외 +- 도메인 메서드: + - `void anonymize()` — 모든 개인정보 컬럼을 null로 설정하고 `deletedAt = Instant.now()` + - `boolean isWithdrawn()` — `deletedAt != null` + +## 6. 컴포넌트 구성 + +### 신규 `user/service/UserWithdrawalService` + +```java +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserWithdrawalService { + + @Transactional + public void withdraw(Long userId) { + // 1) 사전 검증 + List blocking = + roomMemberRepository.findHostRoomsWithOtherActiveMembers(userId); + if (!blocking.isEmpty()) { + throw new HostDelegationRequiredException(blocking); + } + + // 2) 1인 HOST 방 hard delete + List soloHostRooms = roomMemberRepository.findHostRoomsWithOnlySelf(userId); + for (Room room : soloHostRooms) { + roomRepository.delete(room); + eventPublisher.publishEvent(new RoomDeletedEvent(room.getId(), List.of(userId))); + } + + // 3) 잔여 room_members hard delete + // 이 시점에 남는 역할: MEMBER, PENDING (HOST는 step 2에서 이미 정리됨) + // MEMBER에 대해서만 MemberLeftEvent 발행, PENDING은 조용히 정리 + List remainingMemberships = + roomMemberRepository.findAllByUserId(userId); + for (RoomMember m : remainingMemberships) { + roomMemberRepository.delete(m); + if (m.getRole() == RoomRole.MEMBER) { + eventPublisher.publishEvent(new MemberLeftEvent( + m.getRoom().getId(), userId, + user.getNickname(), user.getProfileImageUrl())); + } + } + + // 4) 더블체크 (사전 검증 ~ 삭제 사이 윈도우 보호) + List stillBlocking = + roomMemberRepository.findHostRoomsWithOtherActiveMembers(userId); + if (!stillBlocking.isEmpty()) { + throw new HostDelegationRequiredException(stillBlocking); // 롤백 + } + + // 5) 익명화 + user.anonymize(); + } +} +``` + +`ApplicationEventPublisher` 이벤트는 기본적으로 `@TransactionalEventListener(phase = AFTER_COMMIT)` 리스너가 받는다. 본 설계에서도 이미 그렇게 동작 중이다. + +### 신규 컨트롤러 / 또는 `UserController`에 추가 + +```java +@DeleteMapping("/me") +public ResponseEntity withdraw(@AuthenticationPrincipal Long userId) { + userWithdrawalService.withdraw(userId); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, expiredAccessCookie().toString()) + .header(HttpHeaders.SET_COOKIE, expiredRefreshCookie().toString()) + .build(); +} +``` + +`HostDelegationRequiredException`은 글로벌 ExceptionHandler에서 422 + 본문으로 매핑한다. + +### 신규 `user/service/dto/WithdrawalBlockedResponse` + +```java +public record WithdrawalBlockedResponse( + List roomsRequiringDelegation +) { + public record RoomRequiringDelegation(UUID roomId, String title) {} +} +``` + +### `auth/service/RefreshTokenService` 확장 + +- 기존 private `invalidateAllTokens(userId)`를 public `invalidateAllForUser(Long userId)`로 노출 +- 탈퇴 트랜잭션 커밋 이후 `@TransactionalEventListener(AFTER_COMMIT)`에서 호출 + +신규 이벤트 `UserWithdrawnEvent(Long userId)`를 정의하고 위 리스너가 RTK 삭제를 수행한다. + +### `RoomMemberRepository` 보강 + +- `findHostRoomsWithOtherActiveMembers(userId)`: HOST이면서 본인 외 활성 멤버(`HOST`/`MEMBER`)가 1명 이상 있는 방을 `(roomId, title)`로 조회 +- `findHostRoomsWithOnlySelf(userId)`: HOST이면서 본인 외 활성 멤버(`HOST`/`MEMBER`)가 0명인 방. PENDING은 활성 멤버로 카운트하지 않으며, PENDING만 남아 있어도 1인 HOST 방으로 간주해 hard delete한다. +- `findAllByUserId(userId)`: 사용자의 모든 `room_members` 행 (역할 무관). 탈퇴 시 잔여 PENDING 행도 함께 정리하기 위함. + +이 쿼리들은 단일 SQL로 풀어 N+1을 회피한다. + +### `ErrorCode` 추가 + +```java +WITHDRAWAL_REQUIRES_HOST_DELEGATION( + HttpStatus.UNPROCESSABLE_ENTITY, + "방장 위임이 필요한 방이 있습니다" +) +``` + +## 7. 데이터 흐름 + +### Case A: 정상 탈퇴 + +1. 클라이언트가 `DELETE /users/me` 호출. +2. 사전 검증에서 위임 필요 방이 없음을 확인. +3. 1인 HOST 방을 hard delete하면서 `RoomDeletedEvent`를 큐잉. PENDING만 남은 HOST 방도 같은 단계에서 삭제된다. +4. 사용자의 모든 잔여 `room_members`를 hard delete. `MEMBER` 역할에 한해 `MemberLeftEvent`를 큐잉(PENDING은 조용히 정리). +5. 더블체크: 사전 검증 ~ 삭제 사이 윈도우에서 새 위임/입장으로 다인 HOST 상태가 발생했는지 재확인. 발생 시 `HostDelegationRequiredException`으로 전체 롤백. +6. `user.anonymize()` 호출 후 커밋. +7. AFTER_COMMIT: `UserWithdrawnEvent` 리스너가 Redis RTK 일괄 삭제. 도메인 이벤트 리스너가 SYSTEM 메시지/브로드캐스트 등 후속 처리. +8. 응답: 204 + 만료 쿠키 헤더. + +### Case B: 위임 필요 + +1. `DELETE /users/me` 호출. +2. 사전 검증에서 다인 HOST 방 발견 → `HostDelegationRequiredException` throw → 전체 롤백. +3. 응답: 422 + `roomsRequiringDelegation` 목록. +4. 클라이언트가 기존 `PATCH /rooms/{roomId}/host`로 위임 후 재시도 → Case A 흐름. + +## 8. 에러 처리 + +| 상황 | 응답 | ErrorCode | 비고 | +|------|------|-----------|------| +| 미인증 | 401 | INVALID_TOKEN / ACCESS_TOKEN_EXPIRED | 기존 필터 | +| 이미 탈퇴 | 404 | USER_NOT_FOUND | `@SQLRestriction`으로 자동 처리 | +| HOST 위임 필요 | 422 | WITHDRAWAL_REQUIRES_HOST_DELEGATION | body에 목록 포함 | +| Redis RTK 삭제 실패 | 로그만 | — | 트랜잭션 이미 커밋, fail-open. TTL 만료로 자연 소멸 | +| 이벤트 발행 실패 | 로그만 | — | AFTER_COMMIT 단계, 본 트랜잭션 영향 없음 | + +**동시성 가드**: 위임 검증과 hard delete 사이의 짧은 윈도우 동안 새로운 위임/입장이 발생할 수 있으므로, 익명화 직전에 한 번 더 `findHostRoomsWithOtherActiveMembers(userId)`를 확인한다. 결과가 비어있지 않으면 동일 예외로 롤백한다. + +## 9. 테스트 전략 + +### 단위 테스트 (`UserWithdrawalServiceTest`, Mockito) + +- 정상 탈퇴(HOST 방 없음, MEMBER 방 2개) → 익명화 + `room_members` 삭제 + `MemberLeftEvent` 2건 +- 1인 HOST 방 → 방 delete + `RoomDeletedEvent` +- 다인 HOST 방 존재 → `HostDelegationRequiredException`, 어떤 mutation도 없음 +- 1인 방 + 다인 HOST 혼합 → 422 throw (다인 HOST가 있으므로 전체 차단) + +### 통합 테스트 (Testcontainers) + +- partial unique 인덱스/CHECK 제약 실제 동작 검증 +- 익명화 이후 동일 (provider, providerId)로 `getOrCreateGoogleUser` 성공 +- `@SQLRestriction`: `userRepository.findById(withdrawnId)`가 empty +- AFTER_COMMIT 이벤트 발행 확인 (`ApplicationEvents`) +- RTK 무효화: 탈퇴 후 `refreshTokenService.rotate(token)` → `REFRESH_TOKEN_NOT_FOUND` + +### WebMvc 테스트 (`UserWithdrawalControllerTest`) + +- 정상: 204 + Set-Cookie 헤더에 maxAge=0인 access/refresh +- 422: 응답 본문 `roomsRequiringDelegation` 구조 +- 미인증: 401 + +### 회귀 + +- `getMyProfile(withdrawnId)` → USER_NOT_FOUND +- `getOrCreateGoogleUser`: 활성 회원의 (provider, providerId) 중복은 기존처럼 충돌 회복(`DataIntegrityViolationException` 처리 경로) + +## 10. 문서 갱신 + +- `docs/ai/features.md` + - "사용자(Users)" 또는 Auth 섹션에 `[ ] 회원 탈퇴` 항목 추가 + - 정책 요약(soft delete + 익명화, room_members hard delete, 1인 방 자동 삭제, RTK 즉시 삭제) + - 클라이언트 표시 규칙(members에 없는 senderId/addedBy = "(알 수 없음)") +- `docs/ai/erd.md` + - `users` 테이블에 `deleted_at` 컬럼 추가 + - partial unique 인덱스와 CHECK 제약 명세 +- `docs/ai/decisions/` + - 신규 결정 기록: 회원 탈퇴 모델 — partial unique + CHECK 채택 배경, 대안(placeholder, 분리 테이블) 비교, 트레이드오프 + +## 11. 범위 외 (Out of Scope) + +- JWT access_token blacklist 도입 +- 탈퇴 유예 기간(쿨다운) / 복구 기능 +- 별도 비밀번호 재인증 단계 (Google OAuth 단일 provider이므로 access_token 보유만으로 충분) +- 동일 Google 계정 재가입 제한 +- Bookmark/Schedule의 `added_by`/`created_by` 처리 변경 (채팅과 동일하게 유지) From b1f47d2e3fd1973aa417f54184f0dcd894ac9939 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 12:29:06 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20V1.7=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=20users?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=20=ED=83=88=ED=87=B4=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V1.7__users_withdrawal.sql | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/main/resources/db/migration/V1.7__users_withdrawal.sql diff --git a/src/main/resources/db/migration/V1.7__users_withdrawal.sql b/src/main/resources/db/migration/V1.7__users_withdrawal.sql new file mode 100644 index 00000000..bd8243ea --- /dev/null +++ b/src/main/resources/db/migration/V1.7__users_withdrawal.sql @@ -0,0 +1,38 @@ +-- =========================================== +-- V1.7: users 회원 탈퇴 지원 (soft delete + 익명화) +-- =========================================== + +-- 1) 탈퇴 시각 컬럼 +ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP WITH TIME ZONE; + +-- 2) 활성 회원 NOT NULL 완화 (탈퇴 시 NULL 허용) +ALTER TABLE users ALTER COLUMN email DROP NOT NULL; +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) +ALTER TABLE users ADD CONSTRAINT users_active_required CHECK ( + 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 + ) +); + +-- 4) 기존 unique 제약 제거 +ALTER TABLE users DROP CONSTRAINT users_email_key; +ALTER TABLE users DROP CONSTRAINT uq_users_provider_provider_id; + +-- 5) 활성 회원만 unique 적용 (partial unique index) +CREATE UNIQUE INDEX users_email_unique_active + ON users (email) WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX users_provider_provider_id_unique_active + ON users (provider, provider_id) WHERE deleted_at IS NULL; + +-- 6) 탈퇴자 필터/조회 인덱스 +CREATE INDEX users_deleted_at_idx ON users (deleted_at) + WHERE deleted_at IS NOT NULL; From a3e78042526bb7739ea719923d477692fd100e53 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 12:33:27 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20User=EC=97=90=20soft=20delete?= =?UTF-8?q?=EC=99=80=20=EC=9D=B5=EB=AA=85=ED=99=94=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A9=94=EC=84=9C=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 --- .../howaboutus/backend/user/entity/User.java | 37 ++++++++++++++---- .../backend/user/entity/UserTest.java | 39 +++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/howaboutus/backend/user/entity/UserTest.java diff --git a/src/main/java/com/howaboutus/backend/user/entity/User.java b/src/main/java/com/howaboutus/backend/user/entity/User.java index 324d3a8a..e2c5d88b 100644 --- a/src/main/java/com/howaboutus/backend/user/entity/User.java +++ b/src/main/java/com/howaboutus/backend/user/entity/User.java @@ -1,5 +1,9 @@ package com.howaboutus.backend.user.entity; +import java.time.Instant; + +import org.hibernate.annotations.SQLRestriction; + import com.howaboutus.backend.common.entity.BaseTimeEntity; import jakarta.persistence.Column; @@ -8,38 +12,39 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @Entity -@Table(name = "users", uniqueConstraints = { - @UniqueConstraint(columnNames = {"provider", "provider_id"}) -}) +@Table(name = "users") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLRestriction("deleted_at IS NULL") public class User extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false, unique = true) + @Column private String email; - @Column(nullable = false, length = 50) + @Column(length = 50) private String nickname; @Column(length = 500) private String profileImageUrl; - @Column(nullable = false, length = 20) + @Column(length = 20) private String provider; - @Column(nullable = false) + @Column private String providerId; + @Column(name = "deleted_at") + private Instant deletedAt; + private User(String providerId, String email, String nickname, String profileImageUrl, String provider) { this.providerId = providerId; this.email = email; @@ -51,4 +56,20 @@ private User(String providerId, String email, String nickname, String profileIma public static User ofGoogle(String providerId, String email, String nickname, String profileImageUrl) { return new User(providerId, email, nickname, profileImageUrl, "GOOGLE"); } + + public boolean isWithdrawn() { + return this.deletedAt != null; + } + + public void anonymize() { + if (isWithdrawn()) { + return; + } + this.email = null; + this.nickname = null; + this.profileImageUrl = null; + this.provider = null; + this.providerId = null; + this.deletedAt = Instant.now(); + } } diff --git a/src/test/java/com/howaboutus/backend/user/entity/UserTest.java b/src/test/java/com/howaboutus/backend/user/entity/UserTest.java new file mode 100644 index 00000000..095dc0b4 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/user/entity/UserTest.java @@ -0,0 +1,39 @@ +package com.howaboutus.backend.user.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class UserTest { + + @Test + @DisplayName("anonymize는 개인정보 필드를 null로 비우고 deletedAt을 설정한다") + void anonymizeClearsPersonalFieldsAndSetsDeletedAt() { + User user = User.ofGoogle("pid", "a@a.com", "닉", "https://img/a.png"); + + assertThat(user.isWithdrawn()).isFalse(); + + user.anonymize(); + + assertThat(user.getEmail()).isNull(); + assertThat(user.getNickname()).isNull(); + assertThat(user.getProfileImageUrl()).isNull(); + assertThat(user.getProvider()).isNull(); + assertThat(user.getProviderId()).isNull(); + assertThat(user.getDeletedAt()).isNotNull(); + assertThat(user.isWithdrawn()).isTrue(); + } + + @Test + @DisplayName("이미 탈퇴된 user는 anonymize를 멱등하게 수용한다") + void anonymizeIsIdempotent() { + User user = User.ofGoogle("pid", "a@a.com", "닉", null); + user.anonymize(); + var firstDeletedAt = user.getDeletedAt(); + + user.anonymize(); + + assertThat(user.getDeletedAt()).isEqualTo(firstDeletedAt); + } +} From 91be9f4db268674366d2dd713d89bea4d0357d22 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 12:37:42 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20WITHDRAWAL=5FREQUIRES=5FHOST=5FDE?= =?UTF-8?q?LEGATION=20=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=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 --- .../java/com/howaboutus/backend/common/error/ErrorCode.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java b/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java index fb5c76a1..63158fa6 100644 --- a/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java +++ b/src/main/java/com/howaboutus/backend/common/error/ErrorCode.java @@ -72,6 +72,11 @@ public enum ErrorCode { // 503 SERVICE UNAVAILABLE ROUTE_TEMPORARILY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "이동 경로를 조회 중입니다. 잠시 후 다시 시도해 주세요"), + // 422 UNPROCESSABLE ENTITY + WITHDRAWAL_REQUIRES_HOST_DELEGATION( + HttpStatus.UNPROCESSABLE_ENTITY, + "방장 위임이 필요한 방이 있습니다"), + // 502 BAD GATEWAY EXTERNAL_API_ERROR(HttpStatus.BAD_GATEWAY, "외부 API 호출 중 오류가 발생했습니다"); From d441af1abd23dc741b587596d631f30f6d5bb9b5 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 12:50:17 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=EC=9A=A9=20RoomMember=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=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 --- .../repository/RoomMemberRepository.java | 43 +++++++ .../RoomMemberRepositoryWithdrawalTest.java | 106 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java 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 235b7837..6c0e83ba 100644 --- a/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java +++ b/src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java @@ -8,7 +8,10 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import com.howaboutus.backend.rooms.entity.Room; import com.howaboutus.backend.rooms.entity.RoomMember; import com.howaboutus.backend.rooms.entity.RoomRole; @@ -34,4 +37,44 @@ List findByUser_IdAndRoleInAndJoinedAtBeforeOrderByJoinedAtDesc( Long userId, List roles, Instant cursor, Pageable pageable); long countByRoom_IdAndRoleIn(UUID roomId, List roles); + + List findAllByUser_Id(Long userId); + + @Query(""" + select m.room + from RoomMember m + where m.user.id = :userId + and m.role = com.howaboutus.backend.rooms.entity.RoomRole.HOST + 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) + ) + """) + List findHostRoomsWithOnlySelf(@Param("userId") Long userId); + + interface RoomRequiringDelegationView { + UUID getRoomId(); + + String getTitle(); + } + + @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 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) + ) + """) + List findHostRoomsWithOtherActiveMembers(@Param("userId") Long userId); } diff --git a/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java b/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java new file mode 100644 index 00000000..ac9600a0 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java @@ -0,0 +1,106 @@ +package com.howaboutus.backend.rooms.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +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.transaction.annotation.Transactional; + +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.rooms.repository.RoomMemberRepository.RoomRequiringDelegationView; +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; + +class RoomMemberRepositoryWithdrawalTest extends BaseIntegrationTest { + + @Autowired + private RoomMemberRepository roomMemberRepository; + @Autowired + private UserRepository userRepository; + @Autowired + private RoomRepository roomRepository; + + @PersistenceContext + private EntityManager em; + + @Test + @Transactional + @DisplayName("HOST이면서 다른 활성 멤버가 있는 방만 위임 필요 목록에 포함된다") + void findHostRoomsWithOtherActiveMembers() { + User host = userRepository.save(User.ofGoogle("g1-delegation", "h-delegation@a.com", "h", null)); + User other = userRepository.save(User.ofGoogle("g2-delegation", "o-delegation@a.com", "o", null)); + + Room hostOnly = roomRepository.save(Room.create("A", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-delegation-a", host.getId())); + Room hostWithOther = roomRepository.save(Room.create("B", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-delegation-b", host.getId())); + Room memberRoom = roomRepository.save(Room.create("C", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-delegation-c", other.getId())); + + roomMemberRepository.save(RoomMember.create(hostOnly, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithOther, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithOther, other, RoomRole.MEMBER)); + roomMemberRepository.save(RoomMember.create(memberRoom, other, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(memberRoom, host, RoomRole.MEMBER)); + em.flush(); + em.clear(); + + List result = + roomMemberRepository.findHostRoomsWithOtherActiveMembers(host.getId()); + + assertThat(result).extracting(RoomRequiringDelegationView::getRoomId) + .containsExactly(hostWithOther.getId()); + } + + @Test + @Transactional + @DisplayName("HOST이면서 다른 활성 멤버가 없는 방만 1인 방 목록에 포함된다 (PENDING만 있어도 포함)") + void findHostRoomsWithOnlySelf() { + User host = userRepository.save(User.ofGoogle("g1-solo", "h-solo@a.com", "h", null)); + User pendingUser = userRepository.save(User.ofGoogle("g2-solo", "p-solo@a.com", "p", null)); + + Room soloHost = roomRepository.save(Room.create("A", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-solo-a", host.getId())); + Room hostWithPending = roomRepository.save(Room.create("B", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-solo-b", host.getId())); + + roomMemberRepository.save(RoomMember.create(soloHost, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithPending, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(hostWithPending, pendingUser, RoomRole.PENDING)); + em.flush(); + em.clear(); + + List result = roomMemberRepository.findHostRoomsWithOnlySelf(host.getId()) + .stream().map(Room::getId).toList(); + + assertThat(result).containsExactlyInAnyOrder(soloHost.getId(), hostWithPending.getId()); + } + + @Test + @Transactional + @DisplayName("findAllByUser_Id는 역할 무관하게 사용자의 모든 room_members를 반환한다") + void findAllByUserId() { + User user = userRepository.save(User.ofGoogle("g1-all", "h-all@a.com", "h", null)); + Room r1 = roomRepository.save(Room.create("A", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "i-all-a", user.getId())); + Room r2 = roomRepository.save(Room.create("B", null, + java.time.LocalDate.now(), java.time.LocalDate.now(), "i-all-b", user.getId())); + roomMemberRepository.save(RoomMember.create(r1, user, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(r2, user, RoomRole.PENDING)); + em.flush(); + em.clear(); + + List result = roomMemberRepository.findAllByUser_Id(user.getId()); + assertThat(result).hasSize(2); + } +} From dd6d0fcd2f9c1950237a2a074fb2e0123141ae65 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 12:53:15 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EA=B1=B0=EC=A0=88=20=EC=9D=91=EB=8B=B5=20DTO?= =?UTF-8?q?=EC=99=80=20=EC=98=88=EC=99=B8=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HostDelegationRequiredException.java | 18 ++++++++++++++++++ .../service/dto/RoomRequiringDelegation.java | 6 ++++++ .../service/dto/WithdrawalBlockedResponse.java | 12 ++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java create mode 100644 src/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.java create mode 100644 src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java diff --git a/src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java b/src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java new file mode 100644 index 00000000..21aea486 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java @@ -0,0 +1,18 @@ +package com.howaboutus.backend.user.exception; + +import java.util.List; + +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; + +import lombok.Getter; + +@Getter +public class HostDelegationRequiredException extends RuntimeException { + + private final List roomsRequiringDelegation; + + public HostDelegationRequiredException(List roomsRequiringDelegation) { + super("Host delegation required for " + roomsRequiringDelegation.size() + " room(s)."); + this.roomsRequiringDelegation = List.copyOf(roomsRequiringDelegation); + } +} diff --git a/src/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.java b/src/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.java new file mode 100644 index 00000000..eee0cb54 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.java @@ -0,0 +1,6 @@ +package com.howaboutus.backend.user.service.dto; + +import java.util.UUID; + +public record RoomRequiringDelegation(UUID roomId, String title) { +} diff --git a/src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java b/src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java new file mode 100644 index 00000000..1f354e56 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java @@ -0,0 +1,12 @@ +package com.howaboutus.backend.user.service.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "회원 탈퇴 거절 응답 - 방장 위임이 필요한 방 목록") +public record WithdrawalBlockedResponse( + @Schema(description = "방장 위임이 필요한 방 목록") + List roomsRequiringDelegation +) { +} From 7fee5129eae0c71de2dcfaf54e24ef958d55a531 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 13:03:58 +0900 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20RefreshTokenService=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=8B=A8=EC=9C=84=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=9D=BC=EA=B4=84=20=ED=8F=90=EA=B8=B0=20API=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 --- .../auth/service/RefreshTokenService.java | 4 ++ ...hTokenServiceInvalidateAllForUserTest.java | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java diff --git a/src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java b/src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java index 935d9ed1..ceb46b7f 100644 --- a/src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java +++ b/src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java @@ -84,6 +84,10 @@ private CustomException handleMissingToken(TokenParts parts) { return new CustomException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); } + public void invalidateAllForUser(Long userId) { + invalidateAllTokens(String.valueOf(userId)); + } + private void invalidateAllTokens(String userId) { String userKey = USER_KEY_PREFIX + userId; Set tokens = redisTemplate.opsForSet().members(userKey); diff --git a/src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java b/src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java new file mode 100644 index 00000000..3711cc94 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java @@ -0,0 +1,45 @@ +package com.howaboutus.backend.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.support.BaseIntegrationTest; + +class RefreshTokenServiceInvalidateAllForUserTest extends BaseIntegrationTest { + + @Autowired + private RefreshTokenService refreshTokenService; + + @Test + @DisplayName("invalidateAllForUser는 해당 유저의 모든 RTK를 삭제한다") + void invalidatesAllTokensForUser() { + long userId = 9001L; + String t1 = refreshTokenService.create(userId); + String t2 = refreshTokenService.create(userId); + + refreshTokenService.invalidateAllForUser(userId); + + assertThatThrownBy(() -> refreshTokenService.rotate(t1)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + assertThatThrownBy(() -> refreshTokenService.rotate(t2)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("대상 유저가 RTK를 갖고 있지 않으면 안전하게 no-op이다") + void noOpWhenNoTokens() { + long userId = 9002L; + + refreshTokenService.invalidateAllForUser(userId); + + assertThat(true).isTrue(); + } +} From 3d92096cc488d6d6e5cc21ad7bdb4f37d1df1e34 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 13:05:49 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20=ED=83=88=ED=87=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=EC=99=80=20RTK=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20=EB=A6=AC=EC=8A=A4=EB=84=88=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 --- .../user/event/UserWithdrawnEvent.java | 4 +++ .../listener/UserWithdrawnTokenListener.java | 29 +++++++++++++++++ .../UserWithdrawnTokenListenerTest.java | 31 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java create mode 100644 src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java create mode 100644 src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java diff --git a/src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java b/src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java new file mode 100644 index 00000000..2d225273 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java @@ -0,0 +1,4 @@ +package com.howaboutus.backend.user.event; + +public record UserWithdrawnEvent(Long userId) { +} diff --git a/src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java b/src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java new file mode 100644 index 00000000..be98da30 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java @@ -0,0 +1,29 @@ +package com.howaboutus.backend.user.listener; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.howaboutus.backend.auth.service.RefreshTokenService; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserWithdrawnTokenListener { + + private final RefreshTokenService refreshTokenService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handle(UserWithdrawnEvent event) { + try { + refreshTokenService.invalidateAllForUser(event.userId()); + } catch (Exception e) { + log.warn("Failed to invalidate refresh tokens for withdrawn user. userId={}", + event.userId(), e); + } + } +} diff --git a/src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java b/src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java new file mode 100644 index 00000000..9eaae370 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java @@ -0,0 +1,31 @@ +package com.howaboutus.backend.user.listener; + +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.howaboutus.backend.auth.service.RefreshTokenService; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; + +@ExtendWith(MockitoExtension.class) +class UserWithdrawnTokenListenerTest { + + @Mock + private RefreshTokenService refreshTokenService; + + @InjectMocks + private UserWithdrawnTokenListener listener; + + @Test + @DisplayName("UserWithdrawnEvent 수신 시 해당 userId의 RTK를 일괄 폐기한다") + void invalidatesTokensOnEvent() { + listener.handle(new UserWithdrawnEvent(42L)); + + verify(refreshTokenService).invalidateAllForUser(42L); + } +} From c79a826ee86c331750d1a5824fd9abba4813f088 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 13:14:03 +0900 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20UserWithdrawalService=EB=A1=9C=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EC=98=A4=EC=BC=80=EC=8A=A4=ED=8A=B8=EB=A0=88=EC=9D=B4=EC=85=98?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/service/UserWithdrawalService.java | 89 ++++++++++ .../service/UserWithdrawalServiceTest.java | 157 ++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java create mode 100644 src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java diff --git a/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java b/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java new file mode 100644 index 00000000..efdc5b38 --- /dev/null +++ b/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java @@ -0,0 +1,89 @@ +package com.howaboutus.backend.user.service; + +import java.util.List; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +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.Room; +import com.howaboutus.backend.rooms.entity.RoomMember; +import com.howaboutus.backend.rooms.entity.RoomRole; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository.RoomRequiringDelegationView; +import com.howaboutus.backend.rooms.repository.RoomRepository; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.repository.UserRepository; +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserWithdrawalService { + + private final UserRepository userRepository; + private final RoomRepository roomRepository; + private final RoomMemberRepository roomMemberRepository; + private final ApplicationEventPublisher eventPublisher; + + @Loggable + @Transactional + public void withdraw(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + ensureNoHostDelegationRequired(userId); + + deleteSoloHostRooms(userId); + deleteRemainingMemberships(userId, user); + + ensureNoHostDelegationRequired(userId); + + user.anonymize(); + eventPublisher.publishEvent(new UserWithdrawnEvent(userId)); + } + + private void ensureNoHostDelegationRequired(Long userId) { + List blocking = + roomMemberRepository.findHostRoomsWithOtherActiveMembers(userId); + if (blocking.isEmpty()) { + return; + } + + List roomsRequiringDelegation = blocking.stream() + .map(room -> new RoomRequiringDelegation(room.getRoomId(), room.getTitle())) + .toList(); + throw new HostDelegationRequiredException(roomsRequiringDelegation); + } + + private void deleteSoloHostRooms(Long userId) { + List soloHostRooms = roomMemberRepository.findHostRoomsWithOnlySelf(userId); + for (Room room : soloHostRooms) { + roomRepository.delete(room); + eventPublisher.publishEvent(new RoomDeletedEvent(room.getId(), List.of(userId))); + } + } + + 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) { + eventPublisher.publishEvent(new MemberLeftEvent( + membership.getRoom().getId(), + userId, + user.getNickname(), + user.getProfileImageUrl())); + } + } + } +} diff --git a/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java b/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java new file mode 100644 index 00000000..c8bd6e2f --- /dev/null +++ b/src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java @@ -0,0 +1,157 @@ +package com.howaboutus.backend.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.realtime.event.MemberLeftEvent; +import com.howaboutus.backend.realtime.event.RoomDeletedEvent; +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.rooms.repository.RoomMemberRepository; +import com.howaboutus.backend.rooms.repository.RoomMemberRepository.RoomRequiringDelegationView; +import com.howaboutus.backend.rooms.repository.RoomRepository; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.event.UserWithdrawnEvent; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class UserWithdrawalServiceTest { + + private static final Long USER_ID = 100L; + + @Mock + private UserRepository userRepository; + @Mock + private RoomRepository roomRepository; + @Mock + private RoomMemberRepository roomMemberRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + + private UserWithdrawalService service; + private User user; + + @BeforeEach + void setUp() { + service = new UserWithdrawalService( + userRepository, roomRepository, roomMemberRepository, eventPublisher); + user = User.ofGoogle("g", "u@a.com", "닉", "https://img/me.png"); + ReflectionTestUtils.setField(user, "id", USER_ID); + } + + @Test + @DisplayName("위임 필요 방이 있으면 HostDelegationRequiredException으로 차단되고 mutation이 없다") + void blocksWithoutMutationsWhenDelegationRequired() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + UUID roomId = UUID.randomUUID(); + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of(view(roomId, "Trip"))); + + assertThatThrownBy(() -> service.withdraw(USER_ID)) + .isInstanceOf(HostDelegationRequiredException.class) + .satisfies(exception -> { + HostDelegationRequiredException delegationException = + (HostDelegationRequiredException) exception; + assertThat(delegationException.getRoomsRequiringDelegation()).hasSize(1); + assertThat(delegationException.getRoomsRequiringDelegation().get(0).roomId()).isEqualTo(roomId); + }); + + verify(roomRepository, never()).delete(any()); + verify(roomMemberRepository, never()).delete(any()); + assertThat(user.isWithdrawn()).isFalse(); + } + + @Test + @DisplayName("정상 탈퇴는 1인 방 삭제, 잔여 멤버십 삭제, 익명화, 이벤트 발행을 수행한다") + void successfulWithdrawal() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + Room solo = Room.create("Solo", null, null, null, "i-solo", USER_ID); + ReflectionTestUtils.setField(solo, "id", UUID.randomUUID()); + + Room memberRoom = Room.create("MemberRoom", null, null, null, "i-mr", 999L); + ReflectionTestUtils.setField(memberRoom, "id", UUID.randomUUID()); + + RoomMember memberOfRoom = RoomMember.create(memberRoom, user, RoomRole.MEMBER); + RoomMember pendingMembership = RoomMember.create(memberRoom, user, RoomRole.PENDING); + + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of()); + given(roomMemberRepository.findHostRoomsWithOnlySelf(USER_ID)) + .willReturn(List.of(solo)); + given(roomMemberRepository.findAllByUser_Id(USER_ID)) + .willReturn(List.of(memberOfRoom, pendingMembership)); + + service.withdraw(USER_ID); + + verify(roomRepository).delete(solo); + verify(eventPublisher).publishEvent(any(RoomDeletedEvent.class)); + verify(roomMemberRepository, times(2)).delete(any(RoomMember.class)); + verify(eventPublisher).publishEvent(any(MemberLeftEvent.class)); // MEMBER 1건만 + verify(eventPublisher).publishEvent(any(UserWithdrawnEvent.class)); + assertThat(user.isWithdrawn()).isTrue(); + assertThat(user.getEmail()).isNull(); + } + + @Test + @DisplayName("최종 더블체크에서 위임 필요 방이 검출되면 예외로 롤백 시그널을 보낸다") + void doubleCheckRollback() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + UUID lateRoomId = UUID.randomUUID(); + given(roomMemberRepository.findHostRoomsWithOtherActiveMembers(USER_ID)) + .willReturn(List.of()) + .willReturn(List.of(view(lateRoomId, "Late"))); + given(roomMemberRepository.findHostRoomsWithOnlySelf(USER_ID)).willReturn(List.of()); + given(roomMemberRepository.findAllByUser_Id(USER_ID)).willReturn(List.of()); + + assertThatThrownBy(() -> service.withdraw(USER_ID)) + .isInstanceOf(HostDelegationRequiredException.class); + + assertThat(user.isWithdrawn()).isFalse(); + } + + @Test + @DisplayName("user를 찾을 수 없으면 USER_NOT_FOUND") + void userNotFound() { + given(userRepository.findById(USER_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.withdraw(USER_ID)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); + } + + private RoomRequiringDelegationView view(UUID roomId, String title) { + return new RoomRequiringDelegationView() { + @Override + public UUID getRoomId() { + return roomId; + } + + @Override + public String getTitle() { + return title; + } + }; + } +} From 0e35f3c9908372c86d75ad3da2400fdb471bd8af Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 13:15:18 +0900 Subject: [PATCH 10/15] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EA=B1=B0=EC=A0=88=20=EC=9D=91=EB=8B=B5=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/error/GlobalExceptionHandler.java | 13 +++++++++++++ .../error/GlobalExceptionHandlerTest.java | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java b/src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java index 27efaba5..64fbd228 100644 --- a/src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java +++ b/src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java @@ -17,6 +17,9 @@ import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.service.dto.WithdrawalBlockedResponse; + import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; @@ -106,6 +109,16 @@ public ResponseEntity handleHttpMessageNotReadableException( .body(ApiErrorResponse.of(HttpStatus.BAD_REQUEST, "요청 본문 형식이 올바르지 않습니다")); } + @ExceptionHandler(HostDelegationRequiredException.class) + public ResponseEntity handleHostDelegationRequired( + HostDelegationRequiredException exception) { + log.warn("[EXCEPTION] {} | {}", + kv("errorCode", ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION), + kv("blockedRoomCount", exception.getRoomsRequiringDelegation().size())); + return ResponseEntity.status(ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION.getStatus()) + .body(new WithdrawalBlockedResponse(exception.getRoomsRequiringDelegation())); + } + private int validationMessagePriority(FieldError fieldError) { String code = fieldError.getCode(); if ("NotBlank".equals(code) || "NotEmpty".equals(code)) { diff --git a/src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java b/src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java index 23781a38..790a5e9d 100644 --- a/src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java @@ -17,6 +17,10 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; +import com.howaboutus.backend.user.service.dto.WithdrawalBlockedResponse; + import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; @@ -59,6 +63,20 @@ void handleOptimisticLockingFailureReturnsConflictResponse() { assertThat(response.getBody()).isEqualTo(ApiErrorResponse.of(ErrorCode.SCHEDULE_CONFLICT)); } + @Test + @DisplayName("방장 위임 필요 예외는 422와 위임 필요 방 목록 응답으로 변환한다") + void handleHostDelegationRequiredReturnsBlockedResponse() { + RoomRequiringDelegation room = new RoomRequiringDelegation( + java.util.UUID.randomUUID(), "Trip"); + HostDelegationRequiredException exception = + new HostDelegationRequiredException(List.of(room)); + + var response = globalExceptionHandler.handleHostDelegationRequired(exception); + + assertThat(response.getStatusCode().value()).isEqualTo(422); + assertThat(response.getBody()).isEqualTo(new WithdrawalBlockedResponse(List.of(room))); + } + @Test @DisplayName("MethodArgumentNotValidException 처리 시 같은 필드의 NotBlank 메시지를 우선한다") void handleMethodArgumentNotValidPrefersNotBlankMessage() { From 037afdb60c0dde0a6d56d8ae239412c3690e6794 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 13:17:48 +0900 Subject: [PATCH 11/15] =?UTF-8?q?feat:=20DELETE=20/users/me=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=EC=99=80=20=EB=A7=8C=EB=A3=8C=20=EC=BF=A0=ED=82=A4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 56 +++++++++++ .../user/controller/UserControllerTest.java | 8 ++ .../UserWithdrawalControllerTest.java | 93 +++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java diff --git a/src/main/java/com/howaboutus/backend/user/controller/UserController.java b/src/main/java/com/howaboutus/backend/user/controller/UserController.java index eeb498b1..997adb03 100644 --- a/src/main/java/com/howaboutus/backend/user/controller/UserController.java +++ b/src/main/java/com/howaboutus/backend/user/controller/UserController.java @@ -1,17 +1,29 @@ package com.howaboutus.backend.user.controller; +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.howaboutus.backend.common.config.properties.CookieProperties; import com.howaboutus.backend.common.error.ApiErrorCodes; import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.common.logging.Loggable; import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; import com.howaboutus.backend.user.service.dto.UserResponse; +import com.howaboutus.backend.user.service.dto.WithdrawalBlockedResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -23,6 +35,11 @@ public class UserController { private final UserService userService; + private final UserWithdrawalService userWithdrawalService; + private final CookieProperties cookieProperties; + + @Value("${spring.profiles.active:dev}") + private String activeProfile; @Operation(summary = "내 프로필 조회", description = "현재 인증된 사용자의 프로필을 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공") @@ -31,4 +48,43 @@ public class UserController { public ResponseEntity getMyProfile(@AuthenticationPrincipal Long userId) { return ResponseEntity.ok(userService.getMyProfile(userId)); } + + @Operation( + summary = "회원 탈퇴", + description = "현재 인증된 사용자의 탈퇴를 처리합니다. 방장인 방은 사전 위임이 필요하며, 1인 방은 자동 삭제됩니다. 성공 시 인증 쿠키를 만료시킵니다." + ) + @ApiResponse(responseCode = "204", description = "탈퇴 성공 (No Content)", content = @Content) + @ApiResponse(responseCode = "422", + description = "방장 위임이 필요한 방이 존재", + content = @Content(schema = @Schema(implementation = WithdrawalBlockedResponse.class))) + @ApiErrorCodes({ + ErrorCode.INVALID_TOKEN, + ErrorCode.ACCESS_TOKEN_EXPIRED, + ErrorCode.USER_NOT_FOUND, + ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION + }) + @Loggable + @DeleteMapping("/me") + public ResponseEntity withdraw(@AuthenticationPrincipal Long userId) { + userWithdrawalService.withdraw(userId); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, expiredCookie("access_token").toString()) + .header(HttpHeaders.SET_COOKIE, expiredCookie("refresh_token").toString()) + .build(); + } + + private ResponseCookie expiredCookie(String name) { + boolean secure = "prod".equals(activeProfile); + ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, "") + .httpOnly(true) + .sameSite("Lax") + .path("/") + .maxAge(Duration.ZERO) + .secure(secure); + String domain = cookieProperties.domain(); + if (domain != null && !domain.isBlank()) { + builder.domain(domain); + } + return builder.build(); + } } diff --git a/src/test/java/com/howaboutus/backend/user/controller/UserControllerTest.java b/src/test/java/com/howaboutus/backend/user/controller/UserControllerTest.java index ce67fb6b..cc45e8bd 100644 --- a/src/test/java/com/howaboutus/backend/user/controller/UserControllerTest.java +++ b/src/test/java/com/howaboutus/backend/user/controller/UserControllerTest.java @@ -15,11 +15,13 @@ import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; import com.howaboutus.backend.auth.service.JwtProvider; import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.config.properties.CookieProperties; import com.howaboutus.backend.common.error.CustomException; import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.error.GlobalExceptionHandler; import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; import com.howaboutus.backend.user.service.dto.UserResponse; import jakarta.servlet.http.Cookie; @@ -35,9 +37,15 @@ class UserControllerTest { @MockitoBean private UserService userService; + @MockitoBean + private UserWithdrawalService userWithdrawalService; + @MockitoBean private JwtProvider jwtProvider; + @MockitoBean + private CookieProperties cookieProperties; + @Test @DisplayName("인증된 사용자가 GET /users/me 요청 시 프로필을 반환한다") void returnsProfileForAuthenticatedUser() throws Exception { diff --git a/src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java b/src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java new file mode 100644 index 00000000..ec915777 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java @@ -0,0 +1,93 @@ +package com.howaboutus.backend.user.controller; + +import static org.mockito.BDDMockito.doNothing; +import static org.mockito.BDDMockito.doThrow; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +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.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.howaboutus.backend.auth.filter.JwtAuthenticationFilter; +import com.howaboutus.backend.auth.service.JwtProvider; +import com.howaboutus.backend.common.config.SecurityConfig; +import com.howaboutus.backend.common.config.properties.CookieProperties; +import com.howaboutus.backend.common.error.GlobalExceptionHandler; +import com.howaboutus.backend.common.security.JwtAuthenticationEntryPoint; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; +import com.howaboutus.backend.user.service.dto.RoomRequiringDelegation; + +import jakarta.servlet.http.Cookie; + +@WebMvcTest(UserController.class) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, JwtAuthenticationEntryPoint.class, + GlobalExceptionHandler.class}) +class UserWithdrawalControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserService userService; + + @MockitoBean + private UserWithdrawalService userWithdrawalService; + + @MockitoBean + private JwtProvider jwtProvider; + + @MockitoBean + private CookieProperties cookieProperties; + + @Test + @DisplayName("DELETE /users/me 성공 시 204와 만료 쿠키를 반환한다") + void deleteMeSuccess() throws Exception { + given(jwtProvider.extractUserId("valid-jwt")).willReturn(1L); + doNothing().when(userWithdrawalService).withdraw(1L); + + mockMvc.perform(delete("/users/me") + .cookie(new Cookie("access_token", "valid-jwt"))) + .andExpect(status().isNoContent()) + .andExpect(cookie().maxAge("access_token", 0)) + .andExpect(cookie().path("access_token", "/")) + .andExpect(cookie().maxAge("refresh_token", 0)) + .andExpect(cookie().path("refresh_token", "/")); + } + + @Test + @DisplayName("DELETE /users/me 위임 필요 시 422와 roomsRequiringDelegation 본문을 반환한다") + void deleteMeBlocked() throws Exception { + UUID roomId = UUID.randomUUID(); + given(jwtProvider.extractUserId("valid-jwt")).willReturn(1L); + doThrow(new HostDelegationRequiredException( + List.of(new RoomRequiringDelegation(roomId, "Trip")))) + .when(userWithdrawalService).withdraw(1L); + + mockMvc.perform(delete("/users/me") + .cookie(new Cookie("access_token", "valid-jwt"))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.roomsRequiringDelegation[0].roomId") + .value(roomId.toString())) + .andExpect(jsonPath("$.roomsRequiringDelegation[0].title").value("Trip")); + } + + @Test + @DisplayName("DELETE /users/me 인증 없이 요청 시 401을 반환한다") + void deleteMeUnauthorized() throws Exception { + mockMvc.perform(delete("/users/me")) + .andExpect(status().isUnauthorized()); + } +} From 5a7349bd5bfd18a22ce0a8bf2f5f004b9d6ba15d Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 13:23:08 +0900 Subject: [PATCH 12/15] =?UTF-8?q?fix:=201=EC=9D=B8=20=EB=B0=A9=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=82=AD=EC=A0=9C=20=EC=88=9C=EC=84=9C=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/service/UserWithdrawalService.java | 2 + .../user/UserWithdrawalIntegrationTest.java | 132 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java 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 efdc5b38..dfc29c9e 100644 --- a/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java +++ b/src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java @@ -68,6 +68,8 @@ private void ensureNoHostDelegationRequired(Long userId) { private void deleteSoloHostRooms(Long userId) { List soloHostRooms = roomMemberRepository.findHostRoomsWithOnlySelf(userId); for (Room room : soloHostRooms) { + roomMemberRepository.findByRoom_IdAndUser_Id(room.getId(), userId) + .ifPresent(roomMemberRepository::delete); roomRepository.delete(room); eventPublisher.publishEvent(new RoomDeletedEvent(room.getId(), List.of(userId))); } diff --git a/src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java b/src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java new file mode 100644 index 00000000..27fbc604 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java @@ -0,0 +1,132 @@ +package com.howaboutus.backend.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.support.TransactionTemplate; + +import com.howaboutus.backend.auth.service.RefreshTokenService; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +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.rooms.repository.RoomMemberRepository; +import com.howaboutus.backend.rooms.repository.RoomRepository; +import com.howaboutus.backend.support.BaseIntegrationTest; +import com.howaboutus.backend.user.entity.User; +import com.howaboutus.backend.user.exception.HostDelegationRequiredException; +import com.howaboutus.backend.user.repository.UserRepository; +import com.howaboutus.backend.user.service.UserService; +import com.howaboutus.backend.user.service.UserWithdrawalService; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +class UserWithdrawalIntegrationTest extends BaseIntegrationTest { + + @Autowired + private UserWithdrawalService withdrawalService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private RoomMemberRepository roomMemberRepository; + + @Autowired + private UserService userService; + + @Autowired + private RefreshTokenService refreshTokenService; + + @Autowired + private TransactionTemplate tx; + + @PersistenceContext + private EntityManager em; + + @Test + @DisplayName("정상 탈퇴: 1인 방 삭제, 잔여 멤버십 삭제, 익명화, RTK 즉시 삭제") + void happyPath() { + User user = userRepository.save(User.ofGoogle("g-withdraw-1", "withdraw1@a.com", "닉", null)); + User other = userRepository.save(User.ofGoogle("g-withdraw-2", "withdraw2@a.com", "오", null)); + + Room solo = roomRepository.save(room("Solo", "inv-withdraw-solo", user.getId())); + roomMemberRepository.save(RoomMember.create(solo, user, RoomRole.HOST)); + + Room memberRoom = roomRepository.save(room("MemberRoom", "inv-withdraw-member", other.getId())); + roomMemberRepository.save(RoomMember.create(memberRoom, other, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(memberRoom, user, RoomRole.MEMBER)); + + String refreshToken = refreshTokenService.create(user.getId()); + clearPersistenceContext(); + + withdrawalService.withdraw(user.getId()); + em.clear(); + + assertThat(roomRepository.findById(solo.getId())).isEmpty(); + assertThat(roomMemberRepository.findByRoom_IdAndUser_Id(memberRoom.getId(), user.getId())).isEmpty(); + assertThat(userRepository.findById(user.getId())).isEmpty(); + assertThatThrownBy(() -> refreshTokenService.rotate(refreshToken)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("다인 HOST 방이 있으면 차단되고 어떤 변경도 일어나지 않는다") + void blocksAndRollsBack() { + User host = userRepository.save(User.ofGoogle("g-block-host", "block-host@a.com", "h", null)); + User other = userRepository.save(User.ofGoogle("g-block-other", "block-other@a.com", "o", null)); + Room room = roomRepository.save(room("Blocked", "inv-withdraw-block", host.getId())); + roomMemberRepository.save(RoomMember.create(room, host, RoomRole.HOST)); + roomMemberRepository.save(RoomMember.create(room, other, RoomRole.MEMBER)); + clearPersistenceContext(); + + assertThatThrownBy(() -> withdrawalService.withdraw(host.getId())) + .isInstanceOf(HostDelegationRequiredException.class); + + em.clear(); + Optional reread = userRepository.findById(host.getId()); + assertThat(reread).isPresent(); + assertThat(reread.get().isWithdrawn()).isFalse(); + assertThat(roomMemberRepository.findByRoom_IdAndUser_Id(room.getId(), host.getId())).isPresent(); + } + + @Test + @DisplayName("탈퇴자와 동일 (provider, providerId)로 재가입할 수 있다") + void reSignupWithSameProvider() { + User user = userRepository.save(User.ofGoogle("pid-resignup", "resignup@a.com", "닉", null)); + clearPersistenceContext(); + + tx.executeWithoutResult(status -> withdrawalService.withdraw(user.getId())); + em.clear(); + + User reborn = userService.getOrCreateGoogleUser( + "pid-resignup", "resignup@a.com", "재가입", null); + + assertThat(reborn.getId()).isNotEqualTo(user.getId()); + assertThat(userRepository.findByProviderAndProviderId("GOOGLE", "pid-resignup")) + .get() + .extracting(User::getId) + .isEqualTo(reborn.getId()); + } + + private Room room(String title, String inviteCode, Long createdBy) { + LocalDate today = LocalDate.now(); + return Room.create(title, null, today, today, inviteCode, createdBy); + } + + private void clearPersistenceContext() { + em.clear(); + } +} From edbc6bf95d5281e567a8d538fdb8d3bb7a54f1bb Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 13:25:39 +0900 Subject: [PATCH 13/15] =?UTF-8?q?docs:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=A0=95=EC=B1=85=EC=9D=84=20features/erd/decision?= =?UTF-8?q?s=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260607-user-withdrawal-soft-delete.md | 30 +++++++++++++++++++ docs/ai/erd.md | 18 +++++++---- docs/ai/features.md | 1 + 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 docs/ai/decisions/20260607-user-withdrawal-soft-delete.md diff --git a/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md b/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md new file mode 100644 index 00000000..c1f558f9 --- /dev/null +++ b/docs/ai/decisions/20260607-user-withdrawal-soft-delete.md @@ -0,0 +1,30 @@ +# users soft delete: partial unique index + CHECK 채택 + +- **상태**: 결정 +- **날짜**: 2026-06-07 +- **관련**: docs/superpowers/specs/2026-06-07-user-withdrawal-design.md + +## 배경 + +이용약관/개인정보 정책상 회원 탈퇴 기능이 필요하다. 채팅 등 공동 협업 데이터의 무결성을 유지하면서 탈퇴자의 개인정보는 즉시 제거되어야 한다. 동시에 `users.email`과 `(provider, provider_id)`에 걸린 UNIQUE 제약 때문에 단순히 컬럼을 NULL로 비울 수 없다. + +## 결정 + +`users`를 soft delete 모델로 운영한다. + +- 활성 회원: `email`, `nickname`, `provider`, `provider_id`는 NOT NULL. +- 탈퇴 회원: 위 컬럼들 NULL 허용. `deleted_at`이 NOT NULL. +- 활성 회원에 한해 unique 강제: PostgreSQL partial unique index (`WHERE deleted_at IS NULL`). +- 조건부 NOT NULL은 CHECK `users_active_required`로 보장. +- 엔티티에 `@SQLRestriction("deleted_at IS NULL")`을 적용해 JPA 조회에서 탈퇴자를 자동 제외. + +## 대안 검토 + +- **placeholder 값**(`deleted+{id}@deleted.local` 등): NOT NULL/UNIQUE 제약을 그대로 둘 수 있으나 통계/검색에 가짜 값이 섞이고, 재가입 시 충돌 회피가 복잡하다. 동일 계정 재가입 차단이 정책 요건이 아닌 본 프로젝트에서는 이점이 없다. +- **분리된 `deleted_users` 테이블**: row 이관 비용과 추가 join이 필요하다. soft delete의 단순성을 잃는다. + +## 영향 + +- DDL 변경(`V1.7__users_withdrawal.sql`): NOT NULL drop, 기존 UNIQUE 제약 제거, partial unique index 및 CHECK 추가. +- `User` 엔티티에 `deletedAt`, `anonymize()`, `isWithdrawn()` 추가. +- 동일 OAuth 계정 재가입 가능. 정책상 차단할 필요가 있을 때만 별도 결정 기록으로 변경한다. diff --git a/docs/ai/erd.md b/docs/ai/erd.md index efd87795..c845d9c0 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -14,15 +14,23 @@ Google OAuth 기반 사용자 정보 | 컬럼 | 타입 | 제약조건 | 설명 | |------|------|----------|------| | id | BIGINT | PK, AUTO_INCREMENT | 사용자 고유 ID | -| email | VARCHAR(255) | UNIQUE, NOT NULL | 구글 이메일 | -| nickname | VARCHAR(50) | NOT NULL | 표시 이름 | +| email | VARCHAR(255) | 활성 회원 NOT NULL, 활성 회원 간 UNIQUE | 구글 이메일. 탈퇴 시 NULL | +| nickname | VARCHAR(50) | 활성 회원 NOT NULL | 표시 이름. 탈퇴 시 NULL | | profile_image_url | VARCHAR(500) | NULLABLE | 프로필 이미지 URL | -| provider | VARCHAR(20) | NOT NULL, DEFAULT 'GOOGLE' | OAuth 제공자 | -| provider_id | VARCHAR(255) | NOT NULL | OAuth 제공자 측 사용자 ID | +| 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() | 수정일시 | -**제약:** UNIQUE(provider, provider_id) +**제약:** +- 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)` +- 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` + +**인덱스 (탈퇴자 필터):** `users_deleted_at_idx` ON `(deleted_at)` WHERE `deleted_at IS NOT NULL` + +> 활성 회원은 email/nickname/provider/provider_id가 NOT NULL이며 이메일과 (provider, provider_id) 조합이 unique. 탈퇴 회원은 모두 NULL 가능하며 unique 검사 대상에서 제외되어 동일 OAuth 계정으로 재가입할 수 있다. --- diff --git a/docs/ai/features.md b/docs/ai/features.md index c42091d4..145d7a4f 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -51,6 +51,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 | --- From 1eb172824a2a391329c156247290fecfd567110e Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 13:49:41 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=B0=A8=EB=8B=A8=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/common/error/GlobalExceptionHandler.java | 5 ++++- .../backend/user/service/dto/WithdrawalBlockedResponse.java | 6 ++++++ .../backend/common/error/GlobalExceptionHandlerTest.java | 5 ++++- .../user/controller/UserWithdrawalControllerTest.java | 2 ++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java b/src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java index 64fbd228..987d9995 100644 --- a/src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java +++ b/src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java @@ -116,7 +116,10 @@ public ResponseEntity handleHostDelegationRequired( kv("errorCode", ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION), kv("blockedRoomCount", exception.getRoomsRequiringDelegation().size())); return ResponseEntity.status(ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION.getStatus()) - .body(new WithdrawalBlockedResponse(exception.getRoomsRequiringDelegation())); + .body(new WithdrawalBlockedResponse( + ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION.name(), + ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION.getMessage(), + exception.getRoomsRequiringDelegation())); } private int validationMessagePriority(FieldError fieldError) { diff --git a/src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java b/src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java index 1f354e56..9ca3dd8d 100644 --- a/src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java +++ b/src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java @@ -6,6 +6,12 @@ @Schema(description = "회원 탈퇴 거절 응답 - 방장 위임이 필요한 방 목록") public record WithdrawalBlockedResponse( + @Schema(description = "에러 코드", example = "WITHDRAWAL_REQUIRES_HOST_DELEGATION") + String code, + + @Schema(description = "사용자에게 표시할 수 있는 에러 메시지", example = "방장 위임이 필요한 방이 있습니다") + String message, + @Schema(description = "방장 위임이 필요한 방 목록") List roomsRequiringDelegation ) { diff --git a/src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java b/src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java index 790a5e9d..a9758ffa 100644 --- a/src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java @@ -74,7 +74,10 @@ void handleHostDelegationRequiredReturnsBlockedResponse() { var response = globalExceptionHandler.handleHostDelegationRequired(exception); assertThat(response.getStatusCode().value()).isEqualTo(422); - assertThat(response.getBody()).isEqualTo(new WithdrawalBlockedResponse(List.of(room))); + assertThat(response.getBody()).isEqualTo(new WithdrawalBlockedResponse( + ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION.name(), + ErrorCode.WITHDRAWAL_REQUIRES_HOST_DELEGATION.getMessage(), + List.of(room))); } @Test diff --git a/src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java b/src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java index ec915777..200dc77c 100644 --- a/src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java +++ b/src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java @@ -79,6 +79,8 @@ void deleteMeBlocked() throws Exception { mockMvc.perform(delete("/users/me") .cookie(new Cookie("access_token", "valid-jwt"))) .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("WITHDRAWAL_REQUIRES_HOST_DELEGATION")) + .andExpect(jsonPath("$.message").value("방장 위임이 필요한 방이 있습니다")) .andExpect(jsonPath("$.roomsRequiringDelegation[0].roomId") .value(roomId.toString())) .andExpect(jsonPath("$.roomsRequiringDelegation[0].title").value("Trip")); From 3de05dd39af0b0abba7783cc5050381853da9e09 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Sun, 7 Jun 2026 13:49:56 +0900 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/auth/service/AuthService.java | 14 ++++++++++++++ .../backend/auth/service/AuthServiceTest.java | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/main/java/com/howaboutus/backend/auth/service/AuthService.java b/src/main/java/com/howaboutus/backend/auth/service/AuthService.java index 451f2904..5ff6a33a 100644 --- a/src/main/java/com/howaboutus/backend/auth/service/AuthService.java +++ b/src/main/java/com/howaboutus/backend/auth/service/AuthService.java @@ -6,6 +6,8 @@ import com.howaboutus.backend.auth.service.dto.GoogleUserInfo; import com.howaboutus.backend.auth.service.dto.LoginResult; import com.howaboutus.backend.auth.service.dto.RotateResult; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.integration.google.GoogleOAuthClient; import com.howaboutus.backend.common.logging.Loggable; import com.howaboutus.backend.user.entity.User; @@ -43,6 +45,7 @@ public LoginResult googleLogin(String authorizationCode) { @Loggable public LoginResult refresh(String refreshToken) { RotateResult rotated = refreshTokenService.rotate(refreshToken); + ensureActiveUser(rotated.userId()); String accessToken = jwtProvider.generateAccessToken(rotated.userId()); return new LoginResult(accessToken, rotated.token(), rotated.userId()); @@ -52,4 +55,15 @@ public LoginResult refresh(String refreshToken) { public void logout(String refreshToken) { refreshTokenService.delete(refreshToken); } + + private void ensureActiveUser(Long userId) { + try { + userService.getUser(userId); + } catch (CustomException exception) { + if (exception.getErrorCode() == ErrorCode.USER_NOT_FOUND) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + throw exception; + } + } } diff --git a/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java b/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java index 5ab7b4aa..77610292 100644 --- a/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java @@ -15,6 +15,8 @@ import com.howaboutus.backend.auth.service.dto.GoogleUserInfo; import com.howaboutus.backend.auth.service.dto.LoginResult; import com.howaboutus.backend.auth.service.dto.RotateResult; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; import com.howaboutus.backend.common.integration.google.GoogleOAuthClient; import com.howaboutus.backend.user.entity.User; import com.howaboutus.backend.user.service.UserService; @@ -84,6 +86,20 @@ void refreshReturnsNewTokens() { assertThat(result.refreshToken()).isEqualTo("1:new-uuid"); } + @Test + @DisplayName("탈퇴한 사용자의 Refresh Token 재발급은 거절하고 Access Token을 발급하지 않는다") + void refreshRejectsWithdrawnUser() { + given(refreshTokenService.rotate("1:old-uuid")) + .willReturn(new RotateResult("1:new-uuid", 1L)); + given(userService.getUser(1L)) + .willThrow(new CustomException(ErrorCode.USER_NOT_FOUND)); + + assertThatThrownBy(() -> authService.refresh("1:old-uuid")) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REFRESH_TOKEN_NOT_FOUND); + verify(jwtProvider, never()).generateAccessToken(any()); + } + @Test @DisplayName("로그아웃 시 RefreshTokenService.delete를 호출한다") void logoutDeletesToken() {