Skip to content

[WTH-409] 위드 탈퇴 구현#82

Merged
soo0711 merged 21 commits into
devfrom
feat/WTH-409-위드-탈퇴-구현
Jun 15, 2026

Hidden character warning

The head ref may contain hidden characters: "feat/WTH-409-\uc704\ub4dc-\ud0c8\ud1f4-\uad6c\ud604"
Merged

[WTH-409] 위드 탈퇴 구현#82
soo0711 merged 21 commits into
devfrom
feat/WTH-409-위드-탈퇴-구현

Conversation

@soo0711

@soo0711 soo0711 commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

📌 Summary

어떤 작업인지 한 줄 요약해 주세요.

위드 탈퇴를 구현했습니다.

📝 Changes

변경사항을 what, why, how로 구분해 작성해 주세요.

What

  • 위드 탈퇴 API 추가
  • 탈퇴 후 access/refresh token 쿠키 만료 및 refresh token 삭제
  • 탈퇴 사용자로 동아리 생성/가입이 진행되지 않도록 방어 로직 추가

Why

  • 위드 탈퇴 시 사용자 계정과 소속 동아리 멤버십이 함께 정리해야합니다.
  • LEAD를 보유하고 있으면 탈퇴를 차단해야합니다.

How

  • refresh token 삭제는 DB 커밋 이후 수행하도록 TransactionSynchronization.afterCommit으로 분리
  • JWT 인증 필터와 refresh token 재발급 경로에서 BANNED/LEFT 사용자를 차단
  • 관련 테스트를 수정, 추가

📸 Screenshots / Logs

필요시 스크린샷 or 로그를 첨부해주세요.
로컬 부하테스트 결과

스크린샷 2026-06-15 오후 4 00 16

💡 Reviewer 참고사항

리뷰에 참고할 내용을 작성해주세요.

  • User.leftAt, User.hardDeleteAfter 컬럼이 추가했습니다.
  • PR 리뷰 반영 후 최종 컬럼이 확정되면 Flyway에 추가하겠습니다!

✅ Checklist

  • PR 제목 설정 완료 (WTH-123 인증 필터 설정)
  • 테스트 구현 완료
  • 리뷰어 등록 완료
  • 자체 코드 리뷰 완료

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 사용자 계정 탈퇴 기능 추가(DELETE /me)
    • 탈퇴 시 리프레시 토큰 삭제 및 액세스 토큰 블랙리스트 등록(쿠키 즉시 만료)
  • 개선 사항

    • 동아리 생성/가입 시 비활성·미등록 사용자 요청을 즉시 차단
    • 탈퇴 시 활성 동아리 리더 보유자는 충돌 응답으로 차단
    • 액세스 토큰 인증 단계에서 블랙리스트 여부를 확인해 처리

@soo0711 soo0711 requested a review from hyxklee June 12, 2026 13:33
@soo0711 soo0711 self-assigned this Jun 12, 2026
@soo0711 soo0711 added ✨ Feature 새로운 기능 추가 🔨 Refactor 코드 구조 개선 및 리팩토링 labels Jun 12, 2026
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a60ba300-0351-4064-bf54-74c4eeb1dc13

📥 Commits

Reviewing files that changed from the base of the PR and between 2d0253d and 541d06f.

📒 Files selected for processing (2)
  • src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt
  • src/main/resources/db/migration/V4__add_user_leave_metadata.sql
✅ Files skipped from review due to trivial changes (1)
  • src/main/resources/db/migration/V4__add_user_leave_metadata.sql
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt

📝 Walkthrough

Walkthrough

사용자 탈퇴(leave) 기능을 전체 구현합니다. User 엔티티에 탈퇴 이력 필드를 추가하고, LeaveUserUseCase로 LEAD 클럽 보유 검증·멤버 탈퇴·토큰 폐기를 수행합니다. 액세스 토큰 블랙리스트(Redis) 및 JWT 필터 검증을 추가하고, DELETE /me 엔드포인트를 노출합니다. 클럽 가입/생성 시 탈퇴 사용자 검증도 추가됩니다.

Changes

사용자 탈퇴 전체 기능

Layer / File(s) Summary
탈퇴 도메인 모델 및 예외
src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt, src/main/kotlin/com/weeth/domain/user/application/exception/UserHasLeadClubException.kt, src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt, src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt
UserleftAt, hardDeleteAfter 필드 및 RETENTION_DAYS 상수 추가. leave(now: LocalDateTime) 시그니처 변경. UserHasLeadClubExceptionUSER_HAS_LEAD_CLUB(20913, CONFLICT) 에러 코드 추가. UserTest에 탈퇴 상태 전환, leftAt/hardDeleteAfter 값 검증 및 이중 탈퇴 시 IllegalStateException 검증 추가.
LeaveUserUseCase 및 저장소 메서드
src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt, src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt, src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt, src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt, src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt
LeaveUserUseCase.execute가 락 기반 유저/활성 멤버 조회 후 LEAD 보유 시 예외 발생, 멤버·유저 탈퇴 처리, 커밋 후 토큰 폐기(3회 재시도) 수행. ClubMemberRepositoryfindAllActiveByUserIdWithLock 추가. AuthUserUseCase에서 leave 메서드 및 userReader 의존성 제거. 멤버 없음, 토큰 폐기 실패, ACTIVE 멤버 보유, LEAD 멤버 보유 등 5개 시나리오 테스트 추가.
액세스 토큰 블랙리스트 포트 및 Redis 어댑터
src/main/kotlin/com/weeth/global/auth/jwt/domain/port/AccessTokenBlacklistStorePort.kt, src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisAccessTokenBlacklistStoreAdapter.kt, src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt, src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt, src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisAccessTokenBlacklistStoreAdapterTest.kt, src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt
AccessTokenBlacklistStorePort 인터페이스 정의. Redis 어댑터(accessTokenBlacklist: prefix, JWT 만료 + 60초 TTL) 구현. JwtManageUseCasedeleteRefreshToken 메서드 추가. TokenCookieProviderexpireAccessTokenCookie/expireRefreshTokenCookie(maxAge=Duration.ZERO) 메서드 추가. Redis 통합 테스트 및 쿠키 만료 테스트 추가.
JWT 필터 블랙리스트 검증 및 Security 설정
src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt, src/main/kotlin/com/weeth/global/config/SecurityConfig.kt, src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt
JwtAuthenticationProcessingFilterAccessTokenBlacklistStorePort 주입 및 validateAccessTokenBlacklist 호출 추가(블랙리스트 사용자 인증 차단). 블랙리스트 조회 실패 시 로그 후 재throw. SecurityConfigfilterChain/jwtAuthenticationProcessingFilter 빈 메서드를 의존성 주입 방식으로 변경. ACCESS/TEMPORARY 토큰 정상 처리 및 블랙리스트 시나리오 테스트 추가.
DELETE /me 엔드포인트 및 응답 코드
src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt, src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt, src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt
UserControllerDELETE /me 엔드포인트 추가, LeaveUserUseCase 주입. buildExpiredTokenResponse 헬퍼로 만료된 액세스/리프레시 토큰 쿠키 설정. USER_LEFT_SUCCESS(10906, OK) 응답 코드 정의. 컨트롤러 테스트에서 응답 코드, 쿠키 이름/만료(Max-Age=0), use case 호출 검증.
클럽 작업 활성 상태 검증
src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt, src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt, src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt, src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt
클럽 가입(join) 및 생성(create) 시 user.isRegistered() 검증 추가, 탈퇴 사용자 시 UserInActiveException 발생. 기존 픽스처를 createRegisteredUser()로 교체하고 탈퇴 사용자 시나리오 테스트 추가, 후속 저장 로직 미실행 검증.
데이터베이스 마이그레이션
src/main/resources/db/migration/V4__add_user_leave_metadata.sql
users 테이블에 left_at, hard_delete_after(각 DATETIME(6) NULL) 컬럼을 추가하고, statushard_delete_after 조합 기준 인덱스를 생성합니다.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant UserController
  participant LeaveUserUseCase
  participant ClubMemberRepository
  participant User
  participant TransactionSynchronizationManager
  participant JwtManageUseCase
  participant RedisAccessTokenBlacklistStoreAdapter

  Client->>UserController: DELETE /api/v4/users/me
  UserController->>LeaveUserUseCase: execute(userId)
  LeaveUserUseCase->>ClubMemberRepository: findAllActiveByUserIdWithLock(userId)
  alt LEAD 활성 멤버 존재
    LeaveUserUseCase-->>UserController: UserHasLeadClubException
  else 정상
    LeaveUserUseCase->>User: member.leave(now), user.leave(now)
    LeaveUserUseCase->>TransactionSynchronizationManager: registerSynchronization
    LeaveUserUseCase-->>UserController: 완료
    UserController-->>Client: USER_LEFT_SUCCESS + 만료 쿠키
    TransactionSynchronizationManager-->>JwtManageUseCase: afterCommit → deleteRefreshToken(userId)
    TransactionSynchronizationManager-->>RedisAccessTokenBlacklistStoreAdapter: afterCommit → blacklist(userId)
  end
Loading
sequenceDiagram
  participant Client
  participant JwtAuthenticationProcessingFilter
  participant RedisAccessTokenBlacklistStoreAdapter
  participant SecurityContextHolder

  Client->>JwtAuthenticationProcessingFilter: HTTP 요청 (액세스 토큰)
  JwtAuthenticationProcessingFilter->>RedisAccessTokenBlacklistStoreAdapter: isBlacklisted(userId)
  alt 블랙리스트 등록됨
    RedisAccessTokenBlacklistStoreAdapter-->>JwtAuthenticationProcessingFilter: true
    JwtAuthenticationProcessingFilter->>JwtAuthenticationProcessingFilter: UserInActiveException 발생
  else 정상
    RedisAccessTokenBlacklistStoreAdapter-->>JwtAuthenticationProcessingFilter: false
    JwtAuthenticationProcessingFilter->>SecurityContextHolder: setAuthentication(token)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • hyxklee

Poem

🐰 탈퇴하는 사용자여, 걱정 마세요~
쿠키는 사라지고, 토큰도 블랙리스트에!
LEAD 동아리 없어야 문이 열리니,
30일 뒤엔 데이터도 깨끗이 삭제!
토끼가 열심히 코드를 짰답니다 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 작업 ID와 주요 변경 사항(위드 탈퇴 구현)을 명확하게 요약하고 있으며, 변경 세트의 주요 목표를 잘 나타냅니다.
Description check ✅ Passed PR 설명이 제공된 템플릿 구조를 따르고 있으며, Summary, What, Why, How 섹션이 모두 작성되어 있고 관련 성능 테스트 결과와 리뷰 참고사항도 포함되어 있습니다.
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.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/WTH-409-위드-탈퇴-구현

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.

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt (1)

47-69: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

UserInActiveException가 실제 차단 응답으로 이어지지 않습니다.

validateUserStatus()UserInActiveException을 던져도 doFilterInternal()catch (e: RuntimeException)에서 로깅만 하고 체인을 계속 태워 버립니다. 그래서 이 변경으로 기대한 USER_INACTIVE(403) 응답은 나오지 않고, LEFT/BANNED 사용자는 그냥 익명 요청처럼 처리됩니다. 보호된 엔드포인트는 일반 인증 실패로 바뀌고, permitAll 엔드포인트는 그대로 진행될 수 있습니다. 이 예외는 여기서 삼키지 말고 보안 예외 처리기나 HandlerExceptionResolver로 위임해서 명시적인 실패 응답으로 끝내는 쪽이 맞습니다.

🤖 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/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt`
around lines 47 - 69, The filter currently swallows UserInActiveException in
doFilterInternal (catch(RuntimeException) logs and continues), so banned/left
users are treated as anonymous; modify JwtAuthenticationProcessingFilter so
validateUserStatus(userId) exceptions are not swallowed: either remove the broad
RuntimeException catch or in that catch detect UserInActiveException (or
AuthenticationException) and delegate to the app's HandlerExceptionResolver
(resolveException(request, response, null, e)) or to the security failure
handlers (AuthenticationEntryPoint/AccessDeniedHandler) and then return without
calling filterChain.doFilter; ensure validateUserStatus, UserInActiveException
and the doFilterInternal catch are the referenced symbols when making the
change.
🧹 Nitpick comments (2)
src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt (1)

36-65: ⚡ Quick win

InvalidTokenException 분기도 테스트로 고정해 주세요.

Line 36-65의 추가 케이스는 LEFT 사용자 차단만 검증합니다. 이번 변경의 또 다른 핵심 분기인 extractId("refresh-token") == null 경로도 함께 테스트해 두는 편이 좋습니다. 그 케이스에서 InvalidTokenException이 발생하고 userReader.getById(...) / jwtManageUseCase.reIssueToken(...)가 호출되지 않아야 이 변경이 회귀 없이 유지됩니다.

🤖 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/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt`
around lines 36 - 65, Add a test that covers the extractId == null branch: mock
jwtTokenExtractor.extractRefreshToken(servletRequest) to return "refresh-token"
and mock jwtTokenExtractor.extractId("refresh-token") to return null, then
assert useCase.refreshToken(servletRequest) throws InvalidTokenException and
verify that userReader.getById(...) and jwtManageUseCase.reIssueToken(...) are
never called; reference the existing mocks and methods
(jwtTokenExtractor.extractRefreshToken, jwtTokenExtractor.extractId,
userReader.getById, jwtManageUseCase.reIssueToken, useCase.refreshToken) to
locate where to add this new spec alongside the current refreshToken tests.
src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt (1)

64-67: ⚡ Quick win

비활성 사용자 검사를 리소스/중복 조회보다 먼저 배치해 주세요.

공통 원인은 src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.ktjoin()src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.ktcreate()가 모두 호출자 자격 확인보다 리소스 조회를 먼저 수행한다는 점입니다. 이 순서에서는 탈퇴/미등록 사용자가 클럽 존재 여부나 이름 중복 여부에 따라 서로 다른 실패를 관찰할 수 있습니다. 두 유스케이스 모두 사용자 상태 검사를 가장 먼저 수행하도록 맞추는 편이 일관성과 방어 측면에서 더 낫습니다.

🤖 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/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt`
around lines 64 - 67, The user-active check must run before any
resource/duplication lookups; in ManageClubMemberUsecase.join() (and similarly
in ManageClubUseCase.create()) move the user retrieval via
userReader.getByIdWithLock and the isRegistered() check (throwing
UserInActiveException) to occur before calling clubRepository.getClubById or any
name/existence checks so that inactive/unregistered callers fail consistently
regardless of resource state.
🤖 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
`@src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt`:
- Around line 20-23: The current flow extracts userId via
jwtTokenExtractor.extractId(refreshToken) then fetches the user and checks
user.isBannedOrLeft() before Redis matching in JwtManageUseCase.reIssueToken;
change this so Redis refresh token validation (call
refreshTokenStore.validateRefreshToken(refreshToken) or the existing validation
method used by JwtManageUseCase.reIssueToken) runs immediately after
jwtTokenExtractor.extractId(refreshToken) and before calling
userReader.getById(userId), and if validation fails throw the appropriate token
exception (e.g., InvalidTokenException/TokenMismatchException) so we avoid
unnecessary userReader.getById and user.isBannedOrLeft() checks for revoked
tokens.

---

Outside diff comments:
In
`@src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt`:
- Around line 47-69: The filter currently swallows UserInActiveException in
doFilterInternal (catch(RuntimeException) logs and continues), so banned/left
users are treated as anonymous; modify JwtAuthenticationProcessingFilter so
validateUserStatus(userId) exceptions are not swallowed: either remove the broad
RuntimeException catch or in that catch detect UserInActiveException (or
AuthenticationException) and delegate to the app's HandlerExceptionResolver
(resolveException(request, response, null, e)) or to the security failure
handlers (AuthenticationEntryPoint/AccessDeniedHandler) and then return without
calling filterChain.doFilter; ensure validateUserStatus, UserInActiveException
and the doFilterInternal catch are the referenced symbols when making the
change.

---

Nitpick comments:
In
`@src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt`:
- Around line 64-67: The user-active check must run before any
resource/duplication lookups; in ManageClubMemberUsecase.join() (and similarly
in ManageClubUseCase.create()) move the user retrieval via
userReader.getByIdWithLock and the isRegistered() check (throwing
UserInActiveException) to occur before calling clubRepository.getClubById or any
name/existence checks so that inactive/unregistered callers fail consistently
regardless of resource state.

In
`@src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt`:
- Around line 36-65: Add a test that covers the extractId == null branch: mock
jwtTokenExtractor.extractRefreshToken(servletRequest) to return "refresh-token"
and mock jwtTokenExtractor.extractId("refresh-token") to return null, then
assert useCase.refreshToken(servletRequest) throws InvalidTokenException and
verify that userReader.getById(...) and jwtManageUseCase.reIssueToken(...) are
never called; reference the existing mocks and methods
(jwtTokenExtractor.extractRefreshToken, jwtTokenExtractor.extractId,
userReader.getById, jwtManageUseCase.reIssueToken, useCase.refreshToken) to
locate where to add this new spec alongside the current refreshToken tests.
🪄 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

Run ID: f25cbda5-83ed-4213-8315-8a2a9ba98169

📥 Commits

Reviewing files that changed from the base of the PR and between db4eff3 and 7ff3c83.

📒 Files selected for processing (22)
  • src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt
  • src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt
  • src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt
  • src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt
  • src/main/kotlin/com/weeth/domain/user/application/exception/UserHasLeadClubException.kt
  • src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt
  • src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt
  • src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt
  • src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt
  • src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt
  • src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt
  • src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt
  • src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt
  • src/main/kotlin/com/weeth/global/config/SecurityConfig.kt
  • src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt
  • src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt
  • src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt
  • src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt
  • src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt
  • src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt
  • src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt
  • src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt

Comment on lines +68 to +74
@Column(name = "left_at", nullable = true)
var leftAt: LocalDateTime? = null
private set

@Column(name = "hard_delete_after", nullable = true)
var hardDeleteAfter: LocalDateTime? = null
private set

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

새 컬럼 매핑은 이번 PR에 마이그레이션이 같이 들어와야 합니다.

users.left_at, users.hard_delete_after를 엔티티에 바로 매핑했는데, PR 설명상 Flyway migration은 아직 없는 상태입니다. 이대로 배포되면 JPA가 users를 조회/저장할 때 존재하지 않는 컬럼을 참조해서 탈퇴 기능뿐 아니라 일반 사용자 로드도 깨질 수 있습니다.

@hyxklee hyxklee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

고생하셨습니다!
탈퇴 후 접근을 막아야하니 그런 부분에 대해 정합성을 맞추면서도 성능을 챙길 수 있는 방향으로 리뷰를 집중적으로 달아봤습니당

한 번 읽어보시고 적절하다고 판단되는 부분들 수정해주시면 감사하겠습니다!

"""
SELECT cm
FROM ClubMember cm
JOIN FETCH cm.club

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

club까지 JOIN으로 가져오면 해당 동아리 row까지 Lock으로 잠길 것 같아요!
해당 메서드의 사용처를 보니 Club 정보를 사용하진 않는 것 같던데, Lock 걸리는 요소는 최소화하는게 좋아 보입니다!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

현재 사용처에서 필요한 건 club 엔티티 자체가 아니라 member.club.id 정도라서 락 범위가 불필요하게 커질 수 있겠네용
ClubMember row 중심으로만 락을 잡도록 수정하겠습니다!!

fun refreshToken(httpServletRequest: HttpServletRequest): JwtDto {
val refreshToken = jwtTokenExtractor.extractRefreshToken(httpServletRequest)
val userId = jwtTokenExtractor.extractId(refreshToken) ?: throw InvalidTokenException()
val user = userReader.getById(userId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

이미 탈퇴 로직에서 Refresh Token 제거를 진행했다면, 재발급에서는 DB를 타지 않는 것은 어떨까요??
해당 방식이 보안적으로는 더 안전하긴 하지만, 1차 보안 정책(토큰 삭제)가 있는 상황에서 2차 보안 정책으로 DB 접근이 들어가면 토큰 재발급 성능이 약간 아쉬워질 것 같아요!

1차 보안 정책에서 동시성/엣지 케이스 없이 토큰을 삭제함이 보장된다면 2차 보안 정책은 없어도 될 것 같다는 의견입니다!

import java.time.LocalDateTime

@Service
class WithdrawUserUseCase(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

탈퇴를 위한 유스케이스 일까요?? Withdraw라는 표현을 AI가 좀 좋아하는 것 같은데, 약관 직관적으로 아쉬운 것 같아용 좀 더 직관적인 이름이 머가 있을까요.. LeaveUserUseCase..?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

동아리를 탈퇴하다가 Leave a club이니까 Leave가 좋을 것 같네용! 이걸로 수정하겠습니닷

deleteRefreshTokenAfterCommit(userId)
}

private fun deleteRefreshTokenAfterCommit(userId: Long) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

트랜잭션이 완료된 후에 동작하도록 분리된 것은 정말 조은 것 같아요!
하지만 위에서 달았던 코멘트와 연결되는 부분인데, 해당 메서드에서 리프레시 토큰이 제거되지 않는 경우가 발생하는 것을 최대한 막으면 좋을 것 같아요!

아웃박스까지는 너무 오버 엔지니어링 같고, 여기서 1, 2회 정도 짧은 재시도라도 일단 추가해서 방어적으로 설계해두는 건 어떨까용??

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

좋은 의견 감사합니당! 저도 아웃박스 패턴을 고민하긴 했는데 현재 상황에서는 다소 오버엔지니어링이라고 판단됐습니닷
말씀해주신 대로 재시도 로직을 추가해서 실패 가능성을 줄여보겠습니다!!

MDC.put("userId", claims.id.toString())
}

private fun validateUserStatus(userId: Long) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

해당 Filter는 모든 요청에 대해서 동작하기 때문에 DB에 접근하는 건 전체적으로 봤을 때 비용이 많이 발생해요!
실제로 측정해봤을 때 요청 시간도 몇 ms 정도 지연이 됐고, 매 요청마다 DB에 접근하면 커넥션이 부족해지는 케이스도 생길 것 같아서 개인적으로 filter에서 DB에 접근하는 것은 최대한 지양하는 편입니당

액세스 토큰 방어가 필요하긴 하니... 서비스 사용시 무조건적으로 요청되는 API에서 확인을 하거나, Redis를 이용해서 짧은 기간 동안(액세스 토큰 만료 기간보다 약간 크게) 블랙리스트를 관리해서 필터링한다거나 하면 좋을 것 같아요!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

넵!!! Filter에서의 DB 접근 비용이 생각보다 크군요..!
말씀해주신 방향으로 DB 접근을 줄일 수 있도록 개선해보겠습니당👍 좋은 의견 감사합니다!!

@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: 1

🧹 Nitpick comments (2)
src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt (1)

48-61: 🏗️ Heavy lift

afterCommit 훅에서 동기적 Redis 작업으로 응답 시간이 증가할 수 있습니다.

TransactionSynchronization.afterCommit()은 HTTP 응답을 반환하기 전에 실행되므로, 각 토큰 폐기 작업이 3회씩 재시도하면 최악의 경우 사용자는 탈퇴 API 응답을 받기까지 수 초 대기할 수 있습니다.

비동기 처리로 개선하는 것을 고려해보세요:

  • Spring의 @Async를 사용하여 afterCommit 내부 작업을 비동기로 실행
  • 또는 메시지 큐를 활용하여 토큰 폐기를 분리된 워커에서 처리

이렇게 하면 사용자는 즉시 응답을 받고, 토큰 폐기는 백그라운드에서 처리됩니다.

🤖 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/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt`
around lines 48 - 61, The revokeTokensAfterCommit method executes synchronous
Redis operations (retryTokenRevoke calls for both deleteRefreshToken and
blacklist) within the afterCommit callback, which blocks the HTTP response.
Since each operation retries up to 3 times, this causes significant response
delays. Refactor this to use asynchronous processing: either extract the token
revocation logic from the afterCommit method into a separate service method
marked with Spring's `@Async` annotation, or publish a domain event/message that
is handled asynchronously by a message queue consumer, allowing the afterCommit
hook to return immediately while token revocation proceeds in the background.
src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisAccessTokenBlacklistStoreAdapter.kt (1)

14-23: 💤 Low value

블랙리스트 TTL이 access token의 남은 유효 시간을 고려하지 않습니다.

현재 구현은 jwtProperties.access.expiration + 60초를 TTL로 사용하므로, access token이 발급된 직후 탈퇴하든 만료 직전에 탈퇴하든 항상 같은 TTL이 적용됩니다.

예를 들어 access token 유효 기간이 1시간이고 발급 1분 후 탈퇴하면, 블랙리스트는 약 1시간 1분 동안 유지되지만 실제로는 59분만 필요합니다.

더 정확하게 하려면 access token에서 만료 시간(exp claim)을 추출하여 (exp - now) + buffer를 TTL로 사용할 수 있습니다. 하지만 현재 구현도 기능상 문제는 없으며, 약간의 메모리 오버헤드만 발생합니다.

🤖 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/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisAccessTokenBlacklistStoreAdapter.kt`
around lines 14 - 23, The blacklist method in
RedisAccessTokenBlacklistStoreAdapter currently uses a fixed TTL based on the
full access token expiration duration plus a buffer, regardless of when the
token was issued. To optimize this, modify the blacklist method to accept the
access token's expiration timestamp (exp claim) as a parameter, calculate the
actual remaining validity time from the current moment to that expiration
timestamp, and use (remaining_time + TTL_BUFFER_MILLIS) as the Redis TTL instead
of (jwtProperties.access.expiration + TTL_BUFFER_MILLIS). This ensures the
blacklist entry persists only as long as necessary to cover the actual remaining
validity of the token.
🤖 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
`@src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt`:
- Around line 63-83: The retryTokenRevoke method silently fails after exhausting
all TOKEN_REVOKE_ATTEMPTS without propagating the error or implementing proper
failure handling. After the retry loop completes in the for statement, you need
to check if the final attempt failed and handle the error appropriately by
either re-throwing the last exception, sending an admin notification (e.g.,
Slack alert), publishing to a compensation/retry queue (e.g., SQS/Kafka), or at
minimum recording an ERROR level log and metrics. Currently the method only logs
warnings during retry attempts and returns without indicating to the caller
whether token revocation ultimately succeeded or failed, which leaves the system
in an inconsistent state where the user is marked as LEFT in the database but
their tokens may still be active in Redis.

---

Nitpick comments:
In
`@src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt`:
- Around line 48-61: The revokeTokensAfterCommit method executes synchronous
Redis operations (retryTokenRevoke calls for both deleteRefreshToken and
blacklist) within the afterCommit callback, which blocks the HTTP response.
Since each operation retries up to 3 times, this causes significant response
delays. Refactor this to use asynchronous processing: either extract the token
revocation logic from the afterCommit method into a separate service method
marked with Spring's `@Async` annotation, or publish a domain event/message that
is handled asynchronously by a message queue consumer, allowing the afterCommit
hook to return immediately while token revocation proceeds in the background.

In
`@src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisAccessTokenBlacklistStoreAdapter.kt`:
- Around line 14-23: The blacklist method in
RedisAccessTokenBlacklistStoreAdapter currently uses a fixed TTL based on the
full access token expiration duration plus a buffer, regardless of when the
token was issued. To optimize this, modify the blacklist method to accept the
access token's expiration timestamp (exp claim) as a parameter, calculate the
actual remaining validity time from the current moment to that expiration
timestamp, and use (remaining_time + TTL_BUFFER_MILLIS) as the Redis TTL instead
of (jwtProperties.access.expiration + TTL_BUFFER_MILLIS). This ensures the
blacklist entry persists only as long as necessary to cover the actual remaining
validity of the token.
🪄 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

Run ID: 886cb8bb-3ea1-4e21-81a8-778031aa0b2e

📥 Commits

Reviewing files that changed from the base of the PR and between 7ff3c83 and 8c4eda0.

📒 Files selected for processing (13)
  • src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt
  • src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt
  • src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt
  • src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt
  • src/main/kotlin/com/weeth/global/auth/jwt/domain/port/AccessTokenBlacklistStorePort.kt
  • src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt
  • src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisAccessTokenBlacklistStoreAdapter.kt
  • src/main/kotlin/com/weeth/global/config/SecurityConfig.kt
  • src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt
  • src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt
  • src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt
  • src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt
  • src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisAccessTokenBlacklistStoreAdapterTest.kt
💤 Files with no reviewable changes (2)
  • src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt
  • src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt


private fun saveAuthentication(accessToken: String) {
val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException()
validateAccessTokenBlacklist(claims.id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

해당 방식에 대한 성능 측정 / 부하 테스트도 한 번 진행되면 좋을 것 같아요!

저도 측정해보진 못했지만 도입 전/후로 측정 데이터가 나오면 조금 더 명확하게 블랙리스트 필터링을 할지 액세스 토큰을 극단적으로 줄일지 결정이 가능할 것 같아욥
redis를 넣는 것도 부담이 될지, 혹은 큰 문제가 없을지

또 만약에 Redis에 장애가 발생한 경우에 대한 처리 방안도 필요할 것 같아용 try - catch 라거나

@soo0711 soo0711 Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

1차로 /api/v4/clubs로 로컬 부하 테스트를 진행해봤는데 p95 차이는 VU 10/50/100 기준 최대 1.79ms였습니당
현재 측정 범위에서는 Redis 조회가 큰 병목으로 보이지 않아 우선 블랙리스트 필터링은 유지해도 괜찮아 보입니다!
로컬 테스트라서 추후에 개발환경에서 반복 측정해 더 명확히 확인해보는 편이 좋을 것 같습니다용

장애가 발생한 경우에 대한 처리 방안도 추가해두겠습니닷!! 👍

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

위에 부하테스트 결과 스크린샷도 추가해두었습니당

@soo0711 soo0711 merged commit 5fca1e3 into dev Jun 15, 2026
2 checks passed
@soo0711 soo0711 deleted the feat/WTH-409-위드-탈퇴-구현 branch June 15, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feature 새로운 기능 추가 🔨 Refactor 코드 구조 개선 및 리팩토링

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants