feat: 회원 탈퇴(DELETE /users/me) 구현#131
Conversation
📝 WalkthroughWalkthrough사용자 탈퇴 기능을 구현하는 PR으로, soft delete 기반의 데이터베이스 스키마 변경부터 API 엔드포인트, 토큰 무효화, 이벤트 기반 처리, 포괄적인 테스트까지 전체 탈퇴 플로우를 완성합니다. Changes회원 탈퇴 기능 구현
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java (1)
87-99:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift일괄 무효화가 동시 발급된 RT를 놓칠 수 있습니다.
Line 93에서
members(userKey)스냅샷을 읽은 뒤 Line 99에서 집합 키를 지우는 구조라, 그 사이에create()나rotate()가 새 UUID를 추가하면 그 UUID는 스냅샷에 없어서 token key가 삭제되지 않습니다. 마지막delete(userKey)때문에 인덱스만 사라지고refresh:token:*는 TTL까지 살아남아서, 강제 로그아웃/탈퇴 직후에도 최신 RT가 유효할 수 있습니다. 사용자 단위 revocation marker/version을 먼저 세우거나, 생성/회전과 무효화를 함께 막는 원자적 Redis 스크립트로 바꾸는 편이 안전합니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java` around lines 87 - 99, invalidateAllTokens(String userId) races with create()/rotate() because members(userKey) snapshots can miss tokens added concurrently before delete(userKey); to fix, make per-user revocation atomic: either (A) replace the logic with an atomic Redis Lua script that reads all members of USER_KEY_PREFIX+userId, deletes TOKEN_KEY_PREFIX+<each>, and removes the user set in one script, or (B) implement a revocation/version marker (e.g., USER_REVOCATION_PREFIX+userId) that invalidateAllForUser increments before deleting the set and ensure create()/rotate() store tokens with the current version and reject/replace tokens if the marker changed; update invalidateAllForUser, invalidateAllTokens, and create()/rotate() to use the chosen approach so no token added concurrently survives.
🧹 Nitpick comments (6)
src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java (1)
44-48: ⚡ Quick win시스템 시계 의존을 제거해 테스트를 결정적으로 고정해주세요.
Line 44, Line 46, Line 48, Line 73, Line 75, Line 95, Line 97에서
LocalDate.now()를 직접 쓰면 경계 시점(자정/타임존)에서 테스트 재현성이 떨어질 수 있습니다. 고정 날짜 상수를 써서 fixture를 안정화하는 편이 안전합니다.예시 수정
+import java.time.LocalDate; + class RoomMemberRepositoryWithdrawalTest extends BaseIntegrationTest { + private static final LocalDate FIXED_DATE = LocalDate.of(2026, 1, 1); @@ - Room hostOnly = roomRepository.save(Room.create("A", null, - java.time.LocalDate.now(), java.time.LocalDate.now(), "inv-delegation-a", host.getId())); + Room hostOnly = roomRepository.save(Room.create("A", null, + FIXED_DATE, FIXED_DATE, "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 hostWithOther = roomRepository.save(Room.create("B", null, + FIXED_DATE, FIXED_DATE, "inv-delegation-b", host.getId()));Also applies to: 73-75, 95-97
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java` around lines 44 - 48, Tests use java.time.LocalDate.now() when creating Room fixtures (e.g., Room.create calls that produce hostWithOther and memberRoom), which makes tests non-deterministic; replace all LocalDate.now() usages in Room fixture creation (and other occurrences noted around the test: the Room.create calls and any similar date parameters near lines referenced) with a fixed LocalDate constant (e.g., define a static final LocalDate FIXED_DATE = LocalDate.of(2023, 1, 1) at the top of the test) and use that constant in the Room.create invocations and any assertions so the fixtures are stable and reproducible.Source: Linters/SAST tools
src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java (1)
55-68: ⚡ Quick win만료 쿠키 검증에
domain/secure계약도 포함해주세요.현재 성공 케이스는
maxAge/path만 확인합니다. PR 목표에 맞게 환경별domain/secure가 바뀌는 계약까지 테스트에 고정해두면, 브라우저에서 쿠키 삭제 실패나 보안 플래그 회귀를 조기에 잡을 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java` around lines 55 - 68, Update the deleteMeSuccess test in UserWithdrawalControllerTest to assert the cookie domain and secure flags as well as maxAge/path: when mocking the DELETE "/users/me" flow (the test method deleteMeSuccess that uses jwtProvider.extractUserId and userWithdrawalService.withdraw and mockMvc.perform), add cookie assertions for cookie().domain("access_token", expectedDomain) and cookie().secure("access_token", expectedSecure) and the same for "refresh_token"; derive expectedDomain/expectedSecure from the same test fixture or config value used by the controller (or explicitly set the expected values matching the environment used in the test) so the contract covers domain and secure flags in addition to maxAge/path.src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java (1)
36-44: ⚡ Quick win테스트 의도를 명확히 하는 어설션으로 개선하세요.
Line 43의
assertThat(true).isTrue()는 의미 없는 어설션입니다. 이 테스트는invalidateAllForUser가 RTK가 없는 유저에 대해 예외 없이 안전하게 no-op임을 검증하는 것이므로,assertDoesNotThrow를 사용하거나 해당 라인을 제거하는 것이 더 명확합니다.♻️ 제안 수정안
`@Test` `@DisplayName`("대상 유저가 RTK를 갖고 있지 않으면 안전하게 no-op이다") void noOpWhenNoTokens() { long userId = 9002L; - refreshTokenService.invalidateAllForUser(userId); - - assertThat(true).isTrue(); + assertThatNoException() + .isThrownBy(() -> refreshTokenService.invalidateAllForUser(userId)); }또는 어설션을 완전히 제거:
`@Test` `@DisplayName`("대상 유저가 RTK를 갖고 있지 않으면 안전하게 no-op이다") void noOpWhenNoTokens() { long userId = 9002L; refreshTokenService.invalidateAllForUser(userId); - - assertThat(true).isTrue(); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java` around lines 36 - 44, The current test noOpWhenNoTokens in RefreshTokenServiceInvalidateAllForUserTest uses a meaningless assertion (assertThat(true).isTrue()); update it to clearly assert the method does not throw by replacing that line with an assertion like assertDoesNotThrow(() -> refreshTokenService.invalidateAllForUser(userId)) or simply remove the redundant assertion and rely on the method invocation in the test body, ensuring the intent that RefreshTokenService.invalidateAllForUser is a safe no-op for a user with no tokens is explicit.src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java (1)
124-127: ⚡ Quick win테스트에서 고정된 날짜를 사용하세요.
Line 125의
LocalDate.now()는 테스트를 비결정적으로 만듭니다. 테스트 재현성과 시간대 독립성을 위해 고정된 날짜 값을 사용하는 것이 좋습니다.♻️ 제안 수정안
private Room room(String title, String inviteCode, Long createdBy) { - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.of(2024, 1, 1); return Room.create(title, null, today, today, inviteCode, createdBy); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java` around lines 124 - 127, The room(...) test helper uses LocalDate.now(), making tests non-deterministic; change the method to use a fixed LocalDate (e.g., LocalDate.of(2023,1,1)) or accept a date parameter and pass a deterministic date from tests so Room.create(title, null, date, date, inviteCode, createdBy) is called with a stable value; update the room(...) method (and any callers) to use that fixed date instead of LocalDate.now().Source: Linters/SAST tools
src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java (1)
20-21:fallbackExecution=true는 AFTER_COMMIT 계약을 흐릴 수 있음(다만 현재 발행 경로에선 영향 제한적)
UserWithdrawalService.withdraw()가@Transactional상태에서eventPublisher.publishEvent(new UserWithdrawnEvent(userId))를 호출하므로 현재 코드 경로에선AFTER_COMMIT로 커밋 후 실행될 가능성이 큽니다. 그래도 향후 비트랜잭션 컨텍스트에서 이벤트가 발행되면 즉시 실행될 수 있어fallbackExecution은 제거(기본false)하는 편이 계약을 더 명확히 합니다.🔧 제안 변경안
- `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT) public void handle(UserWithdrawnEvent event) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java` around lines 20 - 21, The `@TransactionalEventListener` annotation on UserWithdrawnTokenListener.handle currently sets fallbackExecution = true; remove the fallbackExecution attribute so the annotation uses the default (false) and enforces AFTER_COMMIT-only execution; update the annotation on the handle method in class UserWithdrawnTokenListener (keeping TransactionPhase.AFTER_COMMIT) to omit fallbackExecution.src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java (1)
12-16: 예외 직렬화(Serializable) 관점의 정적 분석 경고 가능성
HostDelegationRequiredException는RuntimeException(Throwable)이므로 직렬화 시 필드roomsRequiringDelegation: List<RoomRequiringDelegation>를 직렬화하려고 할 수 있는데,RoomRequiringDelegation은 현재Serializable을 구현하지 않습니다.
다만 이 예외는GlobalExceptionHandler에서WithdrawalBlockedResponse로 매핑되어 JSON 응답으로 처리되며, 예외 객체 자체를 Java 직렬화하는 경로는 확인되지 않았습니다.
정적 분석 이슈 제거가 필요하면RoomRequiringDelegation에implements Serializable을 추가하거나(권장) 예외 필드를transient로 처리하세요.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java` around lines 12 - 16, HostDelegationRequiredException currently holds a non-transient List<RoomRequiringDelegation> (roomsRequiringDelegation) which may trigger static-analysis warnings since RoomRequiringDelegation does not implement Serializable; to fix, either make RoomRequiringDelegation implement Serializable (preferred) or mark the exception field roomsRequiringDelegation as transient in HostDelegationRequiredException so the exception is safe for Java serialization; update references in GlobalExceptionHandler/WithdrawalBlockedResponse if needed to map the data from the transient field into the response payload before serializing.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/ai/erd.md`:
- Line 22: The ERD entry for the deleted_at column is out of sync with the
actual DDL: update the deleted_at type in docs/ai/erd.md to match the migration
V1.7__users_withdrawal.sql by changing its type from TIMESTAMP to TIMESTAMP WITH
TIME ZONE (ensure the description remains the same and references the deleted_at
column name so readers see it matches the migration).
In `@src/main/java/com/howaboutus/backend/user/controller/UserController.java`:
- Around line 41-43: Active profile string is compared directly which fails for
multi-profile values like "prod,blue"; inject
org.springframework.core.env.Environment into UserController and AuthController
(or parse activeProfile by splitting on ',' and trimming) and replace the
fragile comparison ("prod".equals(activeProfile)) with
Environment.acceptsProfiles(Profiles.of("prod")) or a check that any split
profile equals "prod"; then update the cookie creation/expiry logic in the
methods that set cookies (referencing UserController and AuthController
cookie-generation/logout methods) to use this robust check so Secure flag is
correctly applied when any active profile is prod.
In
`@src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java`:
- Around line 44-49: 현재 ensureNoHostDelegationRequired 호출로만 검증한 뒤
deleteSoloHostRooms 및 deleteRemainingMemberships를 수행하면 검증-삭제 사이에 다른 쓰기가 끼어들어 경쟁
상태가 발생합니다; UserWithdrawalService에서 검증과 삭제를 하나의 원자적 트랜잭션으로 묶고 관련 방·멤버십 행에 대해 행
잠금(예: SELECT ... FOR UPDATE 또는 JPA의 PESSIMISTIC_WRITE)으로 읽기를 고정하거나 삭제 쿼리에 검증 조건을
포함하도록 변경하여 race를 방지하세요; 구체적으로 ensureNoHostDelegationRequired 대신 트랜잭션 시작 후 대상
room/membership을 PESSIMISTIC_WRITE로 조회(또는 delete 쿼리에 현재 호스트 수/멤버 상태를 WHERE로
추가)하고 그 잠긴 상태에서 deleteSoloHostRooms 및 deleteRemainingMemberships 로직을 실행한 뒤 커밋하도록
수정하세요.
In `@src/main/resources/db/migration/V1.7__users_withdrawal.sql`:
- Around line 15-23: 현재 CHECK 제약 users_active_required는 활동 회원에 대해 개인정보 컬럼(email,
nickname, provider, provider_id)을 NOT NULL로만 강제하고 탈퇴(deleted_at IS NOT NULL) 시
익명화(NULL 처리)를 강제하지 않으므로, 제약을 양방향으로 바꿔야 합니다; users 테이블의 users_active_required 제약을
ALTER/REPLACE 하여 논리를 "deleted_at IS NULL THEN
email,nickname,provider,provider_id 모두 NOT NULL" 또는 "deleted_at IS NOT NULL THEN
email,nickname,provider,provider_id 모두 IS NULL"으로 표현하도록 수정하세요(즉 deleted_at 상태에
따라 개인정보 컬럼이 반대 상태가 되도록 검사하는 체크 제약으로 변경; 관련 식별자: users, users_active_required,
deleted_at, email, nickname, provider, provider_id).
- Around line 26-27: The two DROP CONSTRAINT statements that target
users_email_key and uq_users_provider_provider_id should be made defensive by
using IF EXISTS to avoid migration failures when constraints differ across
environments; update the ALTER TABLE users DROP CONSTRAINT users_email_key and
ALTER TABLE users DROP CONSTRAINT uq_users_provider_provider_id lines to use
ALTER TABLE ... DROP CONSTRAINT IF EXISTS ... so the migration will skip
non-existent constraints safely.
---
Outside diff comments:
In `@src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java`:
- Around line 87-99: invalidateAllTokens(String userId) races with
create()/rotate() because members(userKey) snapshots can miss tokens added
concurrently before delete(userKey); to fix, make per-user revocation atomic:
either (A) replace the logic with an atomic Redis Lua script that reads all
members of USER_KEY_PREFIX+userId, deletes TOKEN_KEY_PREFIX+<each>, and removes
the user set in one script, or (B) implement a revocation/version marker (e.g.,
USER_REVOCATION_PREFIX+userId) that invalidateAllForUser increments before
deleting the set and ensure create()/rotate() store tokens with the current
version and reject/replace tokens if the marker changed; update
invalidateAllForUser, invalidateAllTokens, and create()/rotate() to use the
chosen approach so no token added concurrently survives.
---
Nitpick comments:
In
`@src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java`:
- Around line 12-16: HostDelegationRequiredException currently holds a
non-transient List<RoomRequiringDelegation> (roomsRequiringDelegation) which may
trigger static-analysis warnings since RoomRequiringDelegation does not
implement Serializable; to fix, either make RoomRequiringDelegation implement
Serializable (preferred) or mark the exception field roomsRequiringDelegation as
transient in HostDelegationRequiredException so the exception is safe for Java
serialization; update references in
GlobalExceptionHandler/WithdrawalBlockedResponse if needed to map the data from
the transient field into the response payload before serializing.
In
`@src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java`:
- Around line 20-21: The `@TransactionalEventListener` annotation on
UserWithdrawnTokenListener.handle currently sets fallbackExecution = true;
remove the fallbackExecution attribute so the annotation uses the default
(false) and enforces AFTER_COMMIT-only execution; update the annotation on the
handle method in class UserWithdrawnTokenListener (keeping
TransactionPhase.AFTER_COMMIT) to omit fallbackExecution.
In
`@src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java`:
- Around line 36-44: The current test noOpWhenNoTokens in
RefreshTokenServiceInvalidateAllForUserTest uses a meaningless assertion
(assertThat(true).isTrue()); update it to clearly assert the method does not
throw by replacing that line with an assertion like assertDoesNotThrow(() ->
refreshTokenService.invalidateAllForUser(userId)) or simply remove the redundant
assertion and rely on the method invocation in the test body, ensuring the
intent that RefreshTokenService.invalidateAllForUser is a safe no-op for a user
with no tokens is explicit.
In
`@src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java`:
- Around line 44-48: Tests use java.time.LocalDate.now() when creating Room
fixtures (e.g., Room.create calls that produce hostWithOther and memberRoom),
which makes tests non-deterministic; replace all LocalDate.now() usages in Room
fixture creation (and other occurrences noted around the test: the Room.create
calls and any similar date parameters near lines referenced) with a fixed
LocalDate constant (e.g., define a static final LocalDate FIXED_DATE =
LocalDate.of(2023, 1, 1) at the top of the test) and use that constant in the
Room.create invocations and any assertions so the fixtures are stable and
reproducible.
In
`@src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java`:
- Around line 55-68: Update the deleteMeSuccess test in
UserWithdrawalControllerTest to assert the cookie domain and secure flags as
well as maxAge/path: when mocking the DELETE "/users/me" flow (the test method
deleteMeSuccess that uses jwtProvider.extractUserId and
userWithdrawalService.withdraw and mockMvc.perform), add cookie assertions for
cookie().domain("access_token", expectedDomain) and
cookie().secure("access_token", expectedSecure) and the same for
"refresh_token"; derive expectedDomain/expectedSecure from the same test fixture
or config value used by the controller (or explicitly set the expected values
matching the environment used in the test) so the contract covers domain and
secure flags in addition to maxAge/path.
In
`@src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java`:
- Around line 124-127: The room(...) test helper uses LocalDate.now(), making
tests non-deterministic; change the method to use a fixed LocalDate (e.g.,
LocalDate.of(2023,1,1)) or accept a date parameter and pass a deterministic date
from tests so Room.create(title, null, date, date, inviteCode, createdBy) is
called with a stable value; update the room(...) method (and any callers) to use
that fixed date instead of LocalDate.now().
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 84546fb8-9d18-4f13-8c1c-ceef25114f60
📒 Files selected for processing (29)
docs/ai/decisions/20260607-user-withdrawal-soft-delete.mddocs/ai/erd.mddocs/ai/features.mddocs/superpowers/plans/2026-06-07-user-withdrawal.mddocs/superpowers/specs/2026-06-07-user-withdrawal-design.mdsrc/main/java/com/howaboutus/backend/auth/service/AuthService.javasrc/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.javasrc/main/java/com/howaboutus/backend/common/error/ErrorCode.javasrc/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.javasrc/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.javasrc/main/java/com/howaboutus/backend/user/controller/UserController.javasrc/main/java/com/howaboutus/backend/user/entity/User.javasrc/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.javasrc/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.javasrc/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.javasrc/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.javasrc/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.javasrc/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.javasrc/main/resources/db/migration/V1.7__users_withdrawal.sqlsrc/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.javasrc/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.javasrc/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.javasrc/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.javasrc/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.javasrc/test/java/com/howaboutus/backend/user/controller/UserControllerTest.javasrc/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.javasrc/test/java/com/howaboutus/backend/user/entity/UserTest.javasrc/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.javasrc/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java



설계 내용
변경 상세 내용
DELETE /users/me엔드포인트 추가:userssoft delete + 익명화,room_membershard delete, access/refresh 쿠키 만료 처리(UserController,UserWithdrawalService)422 WITHDRAWAL_REQUIRES_HOST_DELEGATION+roomsRequiringDelegation응답, 1인 HOST 방은 자동 hard delete +RoomDeletedEvent발행UserWithdrawnEvent→UserWithdrawnTokenListener에서 Redis RTK 일괄 폐기,AuthService.refresh에서 탈퇴 유저 토큰 재발급 차단users.deleted_at추가, NOT NULL 완화,users_active_requiredCHECK, 활성 회원 partial unique index, 탈퇴자 조회 인덱스docs/ai/features.md(탈퇴 항목[x]),docs/ai/erd.md(users 컬럼/제약),docs/ai/decisions/20260607-user-withdrawal-soft-delete.md(설계 결정 기록)변경 이유
created_by같은 작성자 ID는 보존해 협업 컨텍스트가 깨지지 않도록 하기 위함테스트
./gradlew build/review-code-against-docs스킬로 검증UserWithdrawalIntegrationTest로 다인 HOST 거절 / 1인 HOST 자동 삭제 / MEMBER 탈퇴 / RTK 폐기 경로 통합 검증,UserWithdrawalServiceTest·UserWithdrawnTokenListenerTest·AuthServiceTest로 단위 검증체크리스트
하네스 변경 체크리스트
Summary by CodeRabbit
릴리스 노트
새로운 기능
DELETE /users/me)문서
Chores