Skip to content

feat: 회원 탈퇴(DELETE /users/me) 구현#131

Merged
parkjuyeong0312 merged 15 commits into
devfrom
feature/user-withdrawal
Jun 8, 2026
Merged

feat: 회원 탈퇴(DELETE /users/me) 구현#131
parkjuyeong0312 merged 15 commits into
devfrom
feature/user-withdrawal

Conversation

@parkjuyeong0312

@parkjuyeong0312 parkjuyeong0312 commented Jun 7, 2026

Copy link
Copy Markdown
Member

설계 내용

  • user : soft delete 로 진행하고, 익명화 정책을 진행한다.
    • email, nickname, profile_image_url, provider, provider_id 를 모두 NULL 로 바꾸고 deleted_at 에 탈퇴 시각을 기록한다.
    • users 에 deleted_at 컬럼과 users_active_required CHECK 제약(탈퇴자거나 활성 필드가 모두 NOT NULL)을 두고, 탈퇴자 조회용 partial index(users_deleted_at_idx)를 추가한다.
    • Hibernate @SQLRestriction("deleted_at IS NULL") 으로 일반 조회에서는 탈퇴 회원이 자동으로 제외된다.
  • room_members : hard delete
  • HOST 인 방
    • 다른 활성 멤버가 있으면 위임을 진행해야 한다.
      • 위임이 누락되면 422 WITHDRAWAL_REQUIRES_HOST_DELEGATION + roomsRequiringDelegation 페이로드로 거절된다.
    • 다른 멤버가 없으면 방을 hard delete 처리하고 RoomDeletedEvent 를 발행한다.
  • MongoDB 채팅 메시지는 유지한다.
    • senderId 를 그대로 유지한다.
  • Bookmark / BookmarkCategory / Schedule 등에서 added_by / created_by 같은 논리 참조는 그대로 유지한다.
  • 클라이언트는 members 목록에 없는 senderId, addedBy, createdBy 를 일률적으로 "알 수 없음" 으로 표시한다. 추방 / 방 나가기 / 탈퇴를 구분하지 않는다. (추후 PR 에서 수정할 예정)
  • Redis refresh token 은 탈퇴 트랜잭션 커밋 직후(AFTER_COMMIT) UserWithdrawnEvent → UserWithdrawnTokenListener 가 해당 사용자의 RTK 를 모두 폐기한다.
  • 탈퇴 이후 들어오는 refresh 요청은 AuthService 에서 탈퇴 유저 여부를 확인해 재발급을 차단한다.
  • JWT access token 은 stateless 로 두며 만료까지 자연 소멸한다. 다만 탈퇴 응답에서 access / refresh 쿠키를 만료 처리해 클라이언트 세션을 즉시 정리한다.
  • 활성 회원에 한해서만 unique 제약을 거는 partial unique index 를 둔다.
    • users_email_unique_active : email WHERE deleted_at IS NULL
    • users_provider_provider_id_unique_active : (provider, provider_id) WHERE deleted_at IS NULL
    • 탈퇴 회원은 nullable 로 두고 unique 검사 대상에서 제외되어, 동일 OAuth 계정으로도 탈퇴 후 재가입이 가능하다.

변경 상세 내용

  • DELETE /users/me 엔드포인트 추가: users soft delete + 익명화, room_members hard delete, access/refresh 쿠키 만료 처리(UserController, UserWithdrawalService)
  • HOST 방 사전 위임 정책 적용: 다인 HOST 방은 422 WITHDRAWAL_REQUIRES_HOST_DELEGATION + roomsRequiringDelegation 응답, 1인 HOST 방은 자동 hard delete + RoomDeletedEvent 발행
  • 탈퇴 트랜잭션 커밋 후 UserWithdrawnEventUserWithdrawnTokenListener에서 Redis RTK 일괄 폐기, AuthService.refresh에서 탈퇴 유저 토큰 재발급 차단
  • V1.7 마이그레이션: users.deleted_at 추가, NOT NULL 완화, users_active_required CHECK, 활성 회원 partial unique index, 탈퇴자 조회 인덱스
  • 문서 갱신: docs/ai/features.md(탈퇴 항목 [x]), docs/ai/erd.md(users 컬럼/제약), docs/ai/decisions/20260607-user-withdrawal-soft-delete.md(설계 결정 기록)

변경 이유

  • 회원 탈퇴 시 개인정보(이메일/닉네임/프로필/OAuth provider 정보)를 즉시 제거하면서도 채팅/북마크/일정의 created_by 같은 작성자 ID는 보존해 협업 컨텍스트가 깨지지 않도록 하기 위함
  • 동일 Google 계정으로 재가입을 허용하려면 활성 회원 간에만 unique를 유지해야 해서 partial unique index로 전환
  • HOST가 방을 남기고 떠나면 방이 운영 불능 상태가 되므로 다인 방은 사전 위임을 강제하고, 1인 방은 자동 정리

테스트

  • ./gradlew build
  • /review-code-against-docs 스킬로 검증
  • 그 외 수동 검증: UserWithdrawalIntegrationTest로 다인 HOST 거절 / 1인 HOST 자동 삭제 / MEMBER 탈퇴 / RTK 폐기 경로 통합 검증, UserWithdrawalServiceTest·UserWithdrawnTokenListenerTest·AuthServiceTest로 단위 검증

체크리스트

  • PR 제목이 커밋 컨벤션 형식을 따른다.
  • 변경 사유를 PR 설명에 기록했다.
  • 테스트 방법과 결과를 기록했다.
  • 문서 변경이 필요한 경우 반영했다.

하네스 변경 체크리스트

  • CLAUDE.md(AGENTS.md) 변경이 포함되어 있는가? — 미포함
  • 변경 사유가 PR 설명에 기록되어 있는가?
  • 기존 규칙과 충돌하지 않는가?
  • 팀원에게 변경 사항을 공유했는가?

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 회원 탈퇴 API 추가 (DELETE /users/me)
    • 탈퇴 시 개인정보 자동 익명화 및 관련 데이터 정리
    • 방장 위임 필수 상황에 대한 검증 및 안내
  • 문서

    • 회원 탈퇴 설계 명세 및 아키텍처 문서 추가
    • ERD 및 기능 목록 업데이트
  • Chores

    • 데이터베이스 마이그레이션 및 스키마 최적화
    • 통합/단위 테스트 케이스 추가

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

사용자 탈퇴 기능을 구현하는 PR으로, soft delete 기반의 데이터베이스 스키마 변경부터 API 엔드포인트, 토큰 무효화, 이벤트 기반 처리, 포괄적인 테스트까지 전체 탈퇴 플로우를 완성합니다.

Changes

회원 탈퇴 기능 구현

Layer / File(s) Summary
탈퇴 정책 설계 및 데이터베이스 스키마
docs/superpowers/specs/..., docs/ai/decisions/..., docs/ai/erd.md, docs/ai/features.md, docs/superpowers/plans/..., src/main/resources/db/migration/V1.7__users_withdrawal.sql
설계 문서에서 soft delete + 익명화 정책을 정의하고, Flyway 마이그레이션으로 deleted_at 컬럼, 활성 회원 CHECK 제약, partial unique index를 추가합니다. 기능 문서와 ERD를 업데이트합니다.
사용자 엔티티 소프트 삭제 모델링
src/main/java/com/howaboutus/backend/user/entity/User.java
deletedAt 필드와 @SQLRestriction("deleted_at IS NULL")을 추가하여 JPA 조회 시 탈퇴자를 자동 제외합니다. isWithdrawn()anonymize() 도메인 메서드를 구현합니다.
룸 멤버십 위임 검증 쿼리
src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java
findHostRoomsWithOnlySelf()findHostRoomsWithOtherActiveMembers() 메서드, 그리고 RoomRequiringDelegationView projection을 추가하여 탈퇴 시 위임 조건을 검증합니다.
탈퇴 오케스트레이션 서비스
src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.java
위임 검증 → solo HOST 방 삭제 → 잔여 멤버십 삭제 → 재검증 → 익명화 → 이벤트 발행의 단계별 처리를 구현합니다.
인증 및 토큰 관리 확장
src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java, src/main/java/com/howaboutus/backend/auth/service/AuthService.java
RefreshTokenService.invalidateAllForUser() 메서드를 추가하고, AuthService.refresh()에 활성 사용자 검증을 추가합니다.
탈퇴 이벤트 및 비동기 처리
src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java, src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java
UserWithdrawnEvent 레코드와 @TransactionalEventListener(AFTER_COMMIT) 리스너를 추가하여 트랜잭션 커밋 후 Redis RTK를 삭제합니다.
예외 및 에러 응답 처리
src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java, src/main/java/com/howaboutus/backend/common/error/ErrorCode.java, src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java, src/main/java/com/howaboutus/backend/user/service/dto/RoomRequiringDelegation.java, src/main/java/com/howaboutus/backend/user/service/dto/WithdrawalBlockedResponse.java
호스트 위임이 필요한 상황을 HostDelegationRequiredException으로 표현하고, 422 UNPROCESSABLE_ENTITY 상태와 위임 필요 방 목록을 담은 응답 DTO로 클라이언트에 반환합니다.
탈퇴 API 엔드포인트
src/main/java/com/howaboutus/backend/user/controller/UserController.java
DELETE /users/me 엔드포인트를 추가하여 탈퇴 서비스를 호출한 후 access_token과 refresh_token 쿠키를 만료시키고 204 No Content를 반환합니다.
포괄적인 테스트 커버리지
src/test/java/com/howaboutus/backend/user/entity/UserTest.java, src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java, src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java, src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java, src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java, src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java, src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java, src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java, src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java, src/test/java/com/howaboutus/backend/user/controller/UserControllerTest.java
엔티티 메서드 멱등성, 서비스 로직(위임 검증, 정상 탈퇴, 재검증), 이벤트 처리, 토큰 무효화, 저장소 쿼리, 컨트롤러 엔드포인트(성공/실패/인증 없음), 기존 인증 플로우 변경을 검증합니다.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 탈퇴 기능을 들고 온 개발자,
soft delete와 이벤트로 깔끔한 작별을 만들고,
호스트 위임의 벽을 세워 데이터를 지킵니다.
테스트는 촘촘히, 토큰은 즉시 폐기!
거침없이 탈퇴하세요, 다시 돌아올 때까지. 🌙

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 커밋 컨벤션 형식(feat:)을 따르며, 회원 탈퇴 엔드포인트(DELETE /users/me) 구현이라는 주요 변경사항을 명확하게 전달한다.
Description check ✅ Passed PR 설명은 제공된 템플릿의 모든 필수 섹션을 포함하고 있으며, 설계 내용, 변경 상세, 변경 이유, 테스트, 체크리스트를 완벽히 작성했다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud

sonarqubecloud Bot commented Jun 7, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) 관점의 정적 분석 경고 가능성

HostDelegationRequiredExceptionRuntimeException(Throwable)이므로 직렬화 시 필드 roomsRequiringDelegation: List<RoomRequiringDelegation>를 직렬화하려고 할 수 있는데, RoomRequiringDelegation은 현재 Serializable을 구현하지 않습니다.
다만 이 예외는 GlobalExceptionHandler에서 WithdrawalBlockedResponse로 매핑되어 JSON 응답으로 처리되며, 예외 객체 자체를 Java 직렬화하는 경로는 확인되지 않았습니다.
정적 분석 이슈 제거가 필요하면 RoomRequiringDelegationimplements 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

📥 Commits

Reviewing files that changed from the base of the PR and between 0270f97 and 3de05dd.

📒 Files selected for processing (29)
  • docs/ai/decisions/20260607-user-withdrawal-soft-delete.md
  • docs/ai/erd.md
  • docs/ai/features.md
  • docs/superpowers/plans/2026-06-07-user-withdrawal.md
  • docs/superpowers/specs/2026-06-07-user-withdrawal-design.md
  • src/main/java/com/howaboutus/backend/auth/service/AuthService.java
  • src/main/java/com/howaboutus/backend/auth/service/RefreshTokenService.java
  • src/main/java/com/howaboutus/backend/common/error/ErrorCode.java
  • src/main/java/com/howaboutus/backend/common/error/GlobalExceptionHandler.java
  • src/main/java/com/howaboutus/backend/rooms/repository/RoomMemberRepository.java
  • src/main/java/com/howaboutus/backend/user/controller/UserController.java
  • src/main/java/com/howaboutus/backend/user/entity/User.java
  • src/main/java/com/howaboutus/backend/user/event/UserWithdrawnEvent.java
  • src/main/java/com/howaboutus/backend/user/exception/HostDelegationRequiredException.java
  • src/main/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListener.java
  • src/main/java/com/howaboutus/backend/user/service/UserWithdrawalService.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/resources/db/migration/V1.7__users_withdrawal.sql
  • src/test/java/com/howaboutus/backend/auth/service/AuthServiceTest.java
  • src/test/java/com/howaboutus/backend/auth/service/RefreshTokenServiceInvalidateAllForUserTest.java
  • src/test/java/com/howaboutus/backend/common/error/GlobalExceptionHandlerTest.java
  • src/test/java/com/howaboutus/backend/rooms/repository/RoomMemberRepositoryWithdrawalTest.java
  • src/test/java/com/howaboutus/backend/user/UserWithdrawalIntegrationTest.java
  • src/test/java/com/howaboutus/backend/user/controller/UserControllerTest.java
  • src/test/java/com/howaboutus/backend/user/controller/UserWithdrawalControllerTest.java
  • src/test/java/com/howaboutus/backend/user/entity/UserTest.java
  • src/test/java/com/howaboutus/backend/user/listener/UserWithdrawnTokenListenerTest.java
  • src/test/java/com/howaboutus/backend/user/service/UserWithdrawalServiceTest.java

Comment thread docs/ai/erd.md
Comment thread src/main/resources/db/migration/V1.7__users_withdrawal.sql
Comment thread src/main/resources/db/migration/V1.7__users_withdrawal.sql
@parkjuyeong0312 parkjuyeong0312 merged commit 69a7855 into dev Jun 8, 2026
5 checks passed
@parkjuyeong0312 parkjuyeong0312 deleted the feature/user-withdrawal branch June 8, 2026 01:58
@parkjuyeong0312 parkjuyeong0312 restored the feature/user-withdrawal branch June 8, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant