-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] 회원 탈퇴 기능 #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Feat] 회원 탈퇴 기능 #17
Conversation
Walkthrough토큰 재발급/저장 로직이 사용자 ID를 인자로 받도록 확장되었고, 인증 필터의 예외 처리와 토큰 검증 흐름이 재구성되었습니다. 사용자 탈퇴 API가 추가되었으며, 탈퇴 시 액세스 토큰 무효화와 시큐리티 컨텍스트 정리가 수행됩니다. 로컬 프로필에서 Redis 초기화 컴포넌트가 도입되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Client
participant AuthController
participant JwtService
Client->>AuthController: POST /auth/reissue {refreshToken}, userId
AuthController->>JwtService: reissueTokens(request, userId)
JwtService->>JwtService: validate refresh, reissue access/refresh
JwtService->>JwtService: storeRefreshToken(newRefresh, userId)
JwtService-->>AuthController: ReissueResponse
AuthController-->>Client: BaseResponse<ReissueResponse>
sequenceDiagram
autonumber
participant Client
participant JwtFilter as JwtAuthenticationFilter
participant JwtUtil
participant SecurityContext
participant EntryPoint as CustomAuthenticationEntryPoint
Client->>JwtFilter: HTTP Request
rect rgba(200,230,255,0.2)
JwtFilter->>JwtUtil: extract access token
JwtFilter->>JwtUtil: validate token + type=access + logout check
JwtFilter->>SecurityContext: set Authentication
end
JwtFilter-->>Client: continue filter chain
alt Auth error
JwtFilter->>EntryPoint: commence(request, response, ex)
EntryPoint-->>Client: 401/403 error response
end
sequenceDiagram
autonumber
actor Client
participant UserController
participant UserService
participant UserRepo as UserRepository
participant JwtUtil
participant JwtService
participant SecurityContext
Client->>UserController: POST /api/users/delete/account
UserController->>UserService: deleteAccount(request, userId)
UserService->>UserRepo: findById(userId)
UserService->>UserRepo: delete(user)
UserService->>JwtUtil: extract access token from request
UserService->>JwtService: invalidAccessToken(accessToken)
UserService->>SecurityContext: clear context
UserService-->>UserController: void
UserController-->>Client: BaseResponse<Void>
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 9
🧹 Nitpick comments (3)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/controller/UserController.java (1)
49-53: HTTP 메서드를 DELETE로 맞추는 것을 고려해주세요.계정 삭제는 리소스 제거 동작이므로
@DeleteMapping("/account")와 같이 HTTP DELETE로 노출하면 API 일관성이 올라갑니다. 프런트 요구 사항이 없다면 메서드 교체를 검토해주세요.- @PostMapping("/delete/account") + @DeleteMapping("/account") public BaseResponse<Void> deleteAccount(HttpServletRequest request, @CurrentUserId Long userId) { userService.deleteAccount(request, userId); return BaseResponse.ok(null); }src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/service/UserService.java (2)
29-29: 사용하지 않는 import 제거 필요
java.net.http.HttpRequest는 실제로 사용되지 않고 있습니다.다음 diff를 적용하여 불필요한 import를 제거하세요:
-import java.net.http.HttpRequest;
29-29: 사용하지 않는 import 제거 필요.
java.net.http.HttpRequest는 코드에서 사용되지 않습니다.다음 diff를 적용하여 제거하세요:
-import java.net.http.HttpRequest;
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/controller/AuthController.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java(3 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/success/CustomAuthenticationSuccessHandler.java(2 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java(3 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/AuthenticationUtil.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/controller/UserController.java(2 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/service/UserService.java(4 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/redis/RedisInitializer.java(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/controller/AuthController.java (3)
src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/resolver/CurrentUserId.java (1)
Target(8-11)src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/resolver/CurrentUserIdArgumentResolver.java (2)
Override(27-35)Component(16-36)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/dto/response/ReissueResponse.java (1)
Getter(6-11)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/controller/UserController.java (2)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/club/controller/ClubController.java (2)
Slf4j(12-55)DeleteMapping(26-30)src/main/java/com/WhoIsRoom/WhoIs_Server/global/config/WebConfig.java (1)
Configuration(11-21)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/service/UserService.java (4)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (1)
Slf4j(19-110)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/controller/UserController.java (1)
Slf4j(15-55)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java (1)
Slf4j(36-116)src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/redis/RedisService.java (1)
Service(11-39)
src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/redis/RedisInitializer.java (4)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (1)
Slf4j(19-110)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/service/UserService.java (1)
Slf4j(33-195)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java (1)
Slf4j(36-116)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/success/CustomAuthenticationSuccessHandler.java (1)
Slf4j(21-62)
🔇 Additional comments (4)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/controller/AuthController.java (2)
36-39: 토큰 재발급 시 사용자 인증 강화 👍
@CurrentUserId를 통해 인증된 사용자의 ID를 명시적으로 전달하는 방식으로 개선되었습니다. 토큰 재발급 흐름에서 보안이 강화되었네요.
36-38: 변경 사항 확인 완료.
@CurrentUserId어노테이션을 통해 userId를 추출하고 서비스로 전달하는 로직이 올바르게 구현되었습니다.CurrentUserIdArgumentResolver와JwtService의 업데이트된 시그니처와 일관성 있게 동작합니다.src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (2)
77-78: API 시그니처 변경사항 확인 완료
storeRefreshToken,invalidAccessToken(가시성 변경),reissueAndSendTokens메서드들의 시그니처가userId를 포함하도록 일관되게 업데이트되었습니다. 전체적인 토큰 관리 흐름이 잘 정리된 것 같네요.Also applies to: 88-91, 93-100
77-78: API 변경 사항 확인 완료.
storeRefreshToken에 userId 파라미터가 추가되고invalidAccessToken의 가시성이 public으로 변경된 것은 계정 삭제 기능을 위해 필요한 변경입니다. 구조적으로 올바르게 구현되었습니다.Also applies to: 88-88
| Authentication authToken = null; | ||
| if ("localhost".equals(principal.getProviderId())) { | ||
| // 폼 로그인(자체 회원) | ||
| authToken = new UsernamePasswordAuthenticationToken(principal, null, authorities); | ||
| } | ||
| // else { | ||
| // // 소셜 로그인 | ||
| // authToken = new OAuth2AuthenticationToken(principal, authorities, loginProvider); | ||
| // } | ||
| log.info("Authentication set in SecurityContext: {}", SecurityContextHolder.getContext().getAuthentication()); | ||
| log.info("Authorities in SecurityContext: {}", authToken.getAuthorities()); | ||
| log.info("Authentication set in SecurityContext: {}", SecurityContextHolder.getContext().getAuthentication()); | ||
| log.info("Authorities in SecurityContext: {}", authToken.getAuthorities()); | ||
|
|
||
| log.info("JWT Filter Success : {}", request.getRequestURI()); | ||
| SecurityContextHolder.getContext().setAuthentication(authToken); | ||
| filterChain.doFilter(request, response); | ||
| log.info("JWT Filter Success : {}", request.getRequestURI()); | ||
| SecurityContextHolder.getContext().setAuthentication(authToken); | ||
| filterChain.doFilter(request, response); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
authToken이 null일 때 NPE 발생합니다.
providerId가 "localhost"가 아니면 authToken은 끝까지 null이고, 곧바로 authToken.getAuthorities() 로깅에서 NullPointerException이 터집니다. 실제 서비스(소셜 로그인 등)에서 곧바로 장애가 나니 null 가드를 추가하거나 기본 토큰을 생성하도록 수정이 필요합니다.
- Authentication authToken = null;
- if ("localhost".equals(principal.getProviderId())) {
- // 폼 로그인(자체 회원)
- authToken = new UsernamePasswordAuthenticationToken(principal, null, authorities);
- }
+ Authentication authToken = null;
+ if ("localhost".equals(principal.getProviderId())) {
+ authToken = new UsernamePasswordAuthenticationToken(principal, null, authorities);
+ }
+
+ if (authToken == null) {
+ throw new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED);
+ }
@@
- log.info("Authorities in SecurityContext: {}", authToken.getAuthorities());
+ log.info("Authorities in SecurityContext: {}", authToken.getAuthorities());📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| Authentication authToken = null; | |
| if ("localhost".equals(principal.getProviderId())) { | |
| // 폼 로그인(자체 회원) | |
| authToken = new UsernamePasswordAuthenticationToken(principal, null, authorities); | |
| } | |
| // else { | |
| // // 소셜 로그인 | |
| // authToken = new OAuth2AuthenticationToken(principal, authorities, loginProvider); | |
| // } | |
| log.info("Authentication set in SecurityContext: {}", SecurityContextHolder.getContext().getAuthentication()); | |
| log.info("Authorities in SecurityContext: {}", authToken.getAuthorities()); | |
| log.info("Authentication set in SecurityContext: {}", SecurityContextHolder.getContext().getAuthentication()); | |
| log.info("Authorities in SecurityContext: {}", authToken.getAuthorities()); | |
| log.info("JWT Filter Success : {}", request.getRequestURI()); | |
| SecurityContextHolder.getContext().setAuthentication(authToken); | |
| filterChain.doFilter(request, response); | |
| log.info("JWT Filter Success : {}", request.getRequestURI()); | |
| SecurityContextHolder.getContext().setAuthentication(authToken); | |
| filterChain.doFilter(request, response); | |
| Authentication authToken = null; | |
| if ("localhost".equals(principal.getProviderId())) { | |
| authToken = new UsernamePasswordAuthenticationToken(principal, null, authorities); | |
| } | |
| if (authToken == null) { | |
| throw new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED); | |
| } | |
| // else { | |
| // // 소셜 로그인 | |
| // authToken = new OAuth2AuthenticationToken(principal, authorities, loginProvider); | |
| // } | |
| log.info("Authentication set in SecurityContext: {}", SecurityContextHolder.getContext().getAuthentication()); | |
| log.info("Authorities in SecurityContext: {}", authToken.getAuthorities()); | |
| log.info("JWT Filter Success : {}", request.getRequestURI()); | |
| SecurityContextHolder.getContext().setAuthentication(authToken); | |
| filterChain.doFilter(request, response); |
🤖 Prompt for AI Agents
In
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java
around lines 94-108, authToken can remain null when principal.getProviderId() !=
"localhost", causing a NullPointerException at authToken.getAuthorities(); fix
by guarding uses of authToken and ensuring a non-null token: either construct
the appropriate OAuth2AuthenticationToken for social providers (use principal,
authorities and the provider/login key) when providerId is not "localhost", or
if you cannot create a token, skip the authority logging and do not call
setAuthentication (log a warning and continue the filter chain). Ensure any
logging or SecurityContextHolder.setAuthentication(...) is only done when
authToken != null.
| String accessToken = jwtUtil.extractAccessToken(request) | ||
| .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_ACCESS_TOKEN)); | ||
|
|
||
| log.info("LogOut Access Token: {}", accessToken); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
프로덕션 환경에서 토큰 로깅 주의 필요
액세스 토큰 전체를 로그에 남기는 것은 보안상 위험할 수 있습니다. 프로덕션 환경에서는 토큰의 일부만 마스킹하여 로깅하는 것을 권장합니다.
다음과 같이 마스킹 처리를 고려해보세요:
-log.info("LogOut Access Token: {}", accessToken);
+log.info("LogOut Access Token: {}****", accessToken.substring(0, Math.min(10, accessToken.length())));또는 프로덕션 프로파일에서는 DEBUG 레벨로 변경:
-log.info("LogOut Access Token: {}", accessToken);
+log.debug("LogOut Access Token: {}", accessToken);🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java
around line 48, avoid logging the full access token; instead mask it (e.g., show
only the first/last few characters and replace the middle with asterisks) before
passing to log.info, or conditionally log the full token only under a
non-production profile / DEBUG level; implement masking logic and/or wrap the
log call with an environment/profile check so production never writes the
complete token to logs.
보안 문제: 로그에 액세스 토큰 노출.
액세스 토큰을 로그에 기록하면 로그 파일을 통해 토큰이 유출될 수 있습니다. 프로덕션 환경에서 이는 심각한 보안 위험입니다.
다음 중 하나를 선택하여 적용하세요:
옵션 1 (권장): 토큰 마스킹
-log.info("LogOut Access Token: {}", accessToken);
+log.info("LogOut Access Token: {}...", accessToken.substring(0, Math.min(10, accessToken.length())));옵션 2: 로그 레벨을 DEBUG로 낮추고 프로덕션에서는 비활성화
-log.info("LogOut Access Token: {}", accessToken);
+log.debug("LogOut Access Token: {}", accessToken);옵션 3: 완전히 제거
-log.info("LogOut Access Token: {}", accessToken);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| log.info("LogOut Access Token: {}", accessToken); | |
| log.info("LogOut Access Token: {}...", accessToken.substring(0, Math.min(10, accessToken.length()))); |
🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java
around line 48, the code logs the raw access token which is a security risk; fix
by either (1) masking the token before logging (e.g., replace middle chars with
asterisks and only show first/last few chars), (2) change the log to DEBUG and
wrap it with a runtime check so it is disabled in production (e.g., only log if
logger.isDebugEnabled()), or (3) remove the log entirely; implement one of these
options consistently and ensure no raw token value is written to logs in
production.
| public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) { | ||
| String refreshToken = tokenRequest.getRefreshToken(); | ||
| jwtUtil.validateToken(refreshToken); | ||
| if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) { | ||
| throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE); | ||
| } | ||
| return reissueAndSendTokens(refreshToken); | ||
| return reissueAndSendTokens(refreshToken, userId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
토큰 재발급 시 사용자 ID 검증 추가 권장
현재 userId 파라미터를 받고 있지만, 리프레시 토큰에 포함된 사용자 ID와 일치하는지 검증하지 않고 있습니다. 이론적으로 인증된 사용자가 다른 사용자의 토큰을 재발급받을 수 있는 보안 취약점이 존재할 수 있습니다.
다음과 같이 검증 로직을 추가하는 것을 권장합니다:
public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) {
String refreshToken = tokenRequest.getRefreshToken();
jwtUtil.validateToken(refreshToken);
if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) {
throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE);
}
+
+ // 리프레시 토큰의 userId와 인증된 userId 일치 검증
+ Long tokenUserId = jwtUtil.getUserId(refreshToken);
+ if (!tokenUserId.equals(userId)) {
+ throw new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED);
+ }
+
return reissueAndSendTokens(refreshToken, userId);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) { | |
| String refreshToken = tokenRequest.getRefreshToken(); | |
| jwtUtil.validateToken(refreshToken); | |
| if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) { | |
| throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE); | |
| } | |
| return reissueAndSendTokens(refreshToken); | |
| return reissueAndSendTokens(refreshToken, userId); | |
| public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) { | |
| String refreshToken = tokenRequest.getRefreshToken(); | |
| jwtUtil.validateToken(refreshToken); | |
| if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) { | |
| throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE); | |
| } | |
| // 리프레시 토큰의 userId와 인증된 userId 일치 검증 | |
| Long tokenUserId = jwtUtil.getUserId(refreshToken); | |
| if (!tokenUserId.equals(userId)) { | |
| throw new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED); | |
| } | |
| return reissueAndSendTokens(refreshToken, userId); | |
| } |
🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java
around lines 61 to 67, the method accepts userId but doesn't verify it matches
the user encoded in the refresh token; extract the user id (or subject) from the
validated refresh token (e.g., via jwtUtil.getUserId/getSubject), compare it to
the provided userId, and if they differ throw an appropriate CustomJwtException
(create/use an error code like INVALID_TOKEN_USER); perform this check after
jwtUtil.validateToken(refreshToken) and before calling reissueAndSendTokens to
prevent cross-user token reissuance.
userId 검증 누락.
reissueTokens 메서드가 userId 파라미터를 받지만, 이 값이 실제로 refresh token에 포함된 userId와 일치하는지 검증하지 않습니다. 공격자가 다른 사용자의 userId를 전달하여 토큰을 탈취할 수 있는 위험이 있습니다.
다음 diff를 적용하여 userId 검증을 추가하세요:
public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) {
String refreshToken = tokenRequest.getRefreshToken();
jwtUtil.validateToken(refreshToken);
if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) {
throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE);
}
+
+ // userId 검증: refresh token의 userId와 파라미터로 받은 userId가 일치하는지 확인
+ Long tokenUserId = jwtUtil.getUserId(refreshToken);
+ if (!tokenUserId.equals(userId)) {
+ throw new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED);
+ }
+
return reissueAndSendTokens(refreshToken, userId);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) { | |
| String refreshToken = tokenRequest.getRefreshToken(); | |
| jwtUtil.validateToken(refreshToken); | |
| if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) { | |
| throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE); | |
| } | |
| return reissueAndSendTokens(refreshToken); | |
| return reissueAndSendTokens(refreshToken, userId); | |
| public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) { | |
| String refreshToken = tokenRequest.getRefreshToken(); | |
| jwtUtil.validateToken(refreshToken); | |
| if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) { | |
| throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE); | |
| } | |
| // userId 검증: refresh token의 userId와 파라미터로 받은 userId가 일치하는지 확인 | |
| Long tokenUserId = jwtUtil.getUserId(refreshToken); | |
| if (!tokenUserId.equals(userId)) { | |
| throw new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED); | |
| } | |
| return reissueAndSendTokens(refreshToken, userId); | |
| } |
🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java
around lines 61 to 67, the method accepts a userId but does not verify it
matches the user encoded in the refresh token; extract the user id (subject)
from the validated refresh token using jwtUtil (e.g., jwtUtil.getUserId or
parsing the token subject), compare it against the userId parameter, and if they
differ throw a CustomJwtException with an appropriate error code (e.g.,
ErrorCode.INVALID_REFRESH_TOKEN_OWNER); then proceed to call
reissueAndSendTokens only after this ownership check passes.
| public void deleteAccount (HttpServletRequest request, Long userId) { | ||
| User user = userRepository.findById(userId) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); | ||
|
|
||
| userRepository.delete(user); | ||
|
|
||
| String accessToken = jwtUtil.extractAccessToken(request) | ||
| .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_ACCESS_TOKEN)); | ||
| if (accessToken != null) { | ||
| jwtService.invalidAccessToken(accessToken); // Redis에 blacklist 저장 | ||
| } | ||
|
|
||
| SecurityContextHolder.clearContext(); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
회원 탈퇴 로직에 트랜잭션 처리 및 순서 개선 필요
현재 구현에는 몇 가지 개선이 필요한 부분이 있습니다:
- 트랜잭션 누락: 사용자 삭제와 토큰 무효화가 원자적으로 처리되지 않아, 중간에 실패 시 불일치 상태가 발생할 수 있습니다.
- 삭제 순서: 사용자를 먼저 삭제하고 토큰을 무효화하는 순서는, 토큰 무효화가 실패할 경우 유효한 토큰이 남게 됩니다. 순서를 바꾸는 것이 안전합니다.
- 불필요한 null 체크 (Line 189):
orElseThrow로 이미 예외가 발생하므로 이후 null 체크는 불필요합니다. - 연관 엔티티: Member 엔티티의 cascade 설정을 확인하여 orphan 레코드가 남지 않는지 검증 필요합니다.
다음과 같이 개선을 권장합니다:
+@Transactional
public void deleteAccount (HttpServletRequest request, Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
- userRepository.delete(user);
-
String accessToken = jwtUtil.extractAccessToken(request)
.orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_ACCESS_TOKEN));
- if (accessToken != null) {
- jwtService.invalidAccessToken(accessToken); // Redis에 blacklist 저장
- }
+ jwtService.invalidAccessToken(accessToken); // Redis에 blacklist 저장
+
+ userRepository.delete(user);
SecurityContextHolder.clearContext();
}Member 엔티티의 cascade 설정을 확인하려면 다음 스크립트를 실행하세요:
🏁 Script executed:
#!/bin/bash
# Member 엔티티에서 User와의 관계 설정 확인
ast-grep --pattern $'@ManyToOne$$$
User $$$'
# User 엔티티에서 Member와의 관계 cascade 설정 확인
ast-grep --pattern $'@OneToMany$$$
cascade$$$
Member$$$'Length of output: 0
🏁 Script executed:
#!/bin/bash
# User 엔티티의 @OneToMany에서 Member와 cascade 옵션 확인
rg -nP --type java '@OneToMany\s*\([^)]*cascade\s*=\s*[^)]*\)' -C2
# Member 엔티티의 @ManyToOne에서 User 참조 확인
rg -nP --type java '@ManyToOne\s*\([^)]*\)\s*private\s+User' -C2Length of output: 0
🏁 Script executed:
#!/bin/bash
# 엔티티 파일 위치 확인
user_file=$(fd -e java User.java | head -n1)
member_file=$(fd -e java Member.java | head -n1)
echo ">>> User 엔티티: $user_file"
grep -n "@OneToMany" -C2 "$user_file"
echo ">>> Member 엔티티: $member_file"
grep -n "@ManyToOne" -C2 "$member_file"Length of output: 449
삭제 로직 수정: 트랜잭션·순서·null·cascade 처리
- 메서드에
@Transactional추가해 사용자 삭제와 토큰 무효화를 원자적으로 처리 orElseThrow뒤의if (accessToken != null)제거- 토큰 무효화(
jwtService.invalidAccessToken)를 사용자 삭제(userRepository.delete) 앞에 호출 User→Member연관에cascade = REMOVE또는orphanRemoval = true설정(또는 명시적 자식 삭제) 적용 검토
🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/service/UserService.java
around lines 181 to 194, make the deleteAccount method transactional, move
extraction/invalidations of the access token before calling
userRepository.delete, remove the redundant if (accessToken != null) check
(since extractAccessToken already throws), and ensure
jwtService.invalidAccessToken(accessToken) is invoked while still in the
transaction so token invalidation and user deletion happen atomically;
additionally review the User→Member mapping and add cascade = REMOVE or
orphanRemoval = true (or perform explicit child deletions) so related child
entities are removed when the user is deleted, and leave
SecurityContextHolder.clearContext() at the end of the method.
계정 삭제 로직에 여러 개선이 필요합니다.
다음 사항들을 검토해주세요:
-
@transactional 어노테이션 누락: 사용자 삭제와 토큰 무효화가 원자적으로 처리되지 않습니다. 토큰 무효화가 실패하면 사용자는 이미 삭제된 상태가 됩니다.
-
작업 순서 문제: 사용자를 먼저 삭제(line 185)한 후 토큰을 추출(line 187-188)하고 있습니다. 토큰 추출이 실패하면 사용자는 이미 삭제되었지만 토큰은 여전히 유효한 상태가 됩니다. 토큰 검증을 먼저 수행하세요.
-
불필요한 null 체크: Line 189의
if (accessToken != null)은 불필요합니다. Line 187-188에서orElseThrow를 사용하므로 accessToken은 항상 non-null입니다. -
Refresh Token 미처리: Access token만 무효화하고 있습니다. Refresh token도 Redis에서 삭제해야 합니다. 그렇지 않으면 사용자가 삭제된 후에도 refresh token으로 새로운 access token을 발급받을 수 있습니다.
-
Member 엔티티 처리: User 삭제 시 Member 엔티티들의 cascade 처리가 올바르게 설정되어 있는지 확인이 필요합니다.
다음과 같이 리팩토링을 권장합니다:
+@Transactional
public void deleteAccount (HttpServletRequest request, Long userId) {
+ // 1. 토큰 검증 먼저 수행
+ String accessToken = jwtUtil.extractAccessToken(request)
+ .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_ACCESS_TOKEN));
+
+ // 2. 사용자 조회 및 삭제
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
-
userRepository.delete(user);
-
- String accessToken = jwtUtil.extractAccessToken(request)
- .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_ACCESS_TOKEN));
- if (accessToken != null) {
- jwtService.invalidAccessToken(accessToken); // Redis에 blacklist 저장
- }
-
+
+ // 3. 토큰 무효화
+ jwtService.invalidAccessToken(accessToken);
+
+ // 4. Refresh token도 삭제 (userId로 조회하여 삭제하는 메서드 필요)
+ // jwtService.deleteRefreshTokenByUserId(userId);
+
+ // 5. Security Context 정리
SecurityContextHolder.clearContext();
}참고: Refresh token 삭제를 위해 JwtService에 userId 기반 삭제 메서드 추가가 필요할 수 있습니다.
🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/service/UserService.java
around lines 181-194, the deleteAccount method must be made atomic and handle
tokens before deleting the user: annotate the method with @Transactional, first
extract/validate the access token from the request (throw on failure), call
jwtService to blacklist the access token and remove any refresh token(s) for the
user (add a jwtService method that deletes refresh tokens by userId if
necessary), then delete the User entity and finally clear the SecurityContext;
remove the redundant null-check on accessToken; also verify cascade or
orphanRemoval settings for related Member entities so deleting User
removes/handles Member rows correctly.
| User user = userRepository.findById(userId) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); | ||
|
|
||
| userRepository.delete(user); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
Member 엔티티 cascade 설정 확인이 필요합니다.
User 삭제 시 연관된 Member 레코드들이 올바르게 cascade 삭제되는지 확인해주세요. 만약 cascade 설정이 없다면 foreign key 제약 위반이 발생할 수 있습니다.
다음 스크립트를 실행하여 User 엔티티의 Member 관계 cascade 설정을 확인하세요:
🏁 Script executed:
#!/bin/bash
# User 엔티티에서 Member와의 관계 설정 확인
# User 엔티티 파일에서 @OneToMany 어노테이션과 cascade 설정 찾기
rg -A 5 '@OneToMany.*Member' --type java -g '**/User.java'
# Member 엔티티에서 User와의 관계 설정 확인
rg -A 3 '@ManyToOne.*User' --type java -g '**/Member.java'Length of output: 0
User 엔티티와 Member 엔티티 간 cascade 설정을 아직 확인하지 못했습니다. 전체 코드베이스에서 @onetomany와 @manytoone 어노테이션을 검색해보세요:
🏁 Script executed:
#!/bin/bash
# 엔티티 관계 설정 재검증
# OneToMany 어노테이션 전체 검색
rg -C3 "@OneToMany" --type java
# ManyToOne 어노테이션 전체 검색
rg -C3 "@ManyToOne" --type javaLength of output: 1104
Member 관계에 대한 cascade 삭제 설정 추가 필요
User.java에 Member와의 @onetomany(mappedBy="user") 매핑이 없거나 cascade 옵션이 누락되어 있습니다. cascade = CascadeType.REMOVE 또는 orphanRemoval = true 를 추가하거나, User 삭제 전에 Member를 직접 삭제하도록 수정하세요.
🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/service/UserService.java
around line 185, the service calls userRepository.delete(user) but the
User↔Member relationship lacks cascade delete/orphan removal, so associated
Member entities are left orphaned; either update the User entity's
@OneToMany(mappedBy="user") to include cascade = CascadeType.REMOVE or
orphanRemoval = true (and ensure fetch/type semantics are correct), or modify
this service to explicitly delete all Member entries for the user (e.g.,
memberRepository.deleteByUser(user) or iterating and deleting members) before
invoking userRepository.delete(user).
| @PostConstruct | ||
| public void clearRedis() { | ||
| redisTemplate.getConnectionFactory() | ||
| .getConnection() | ||
| .flushDb(); // 선택한 DB(예: database: 0)만 초기화 | ||
| log.info("✅ Redis DB 초기화 완료"); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
연결 자원 해제를 추가해주세요.
getConnection()으로 얻은 커넥션을 닫지 않고 flushDb()만 호출하면 커넥션 풀이 반환되지 않아 로컬에서도 누수 위험이 있습니다. try-with-resources로 커넥션을 관리하도록 바꿔주세요.
-import jakarta.annotation.PostConstruct;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.context.annotation.Profile;
-import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.stereotype.Component;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Profile;
+import org.springframework.data.redis.connection.RedisConnection;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
@@
@PostConstruct
public void clearRedis() {
- redisTemplate.getConnectionFactory()
- .getConnection()
- .flushDb(); // 선택한 DB(예: database: 0)만 초기화
+ try (RedisConnection connection = redisTemplate.getConnectionFactory().getConnection()) {
+ connection.flushDb(); // 선택한 DB(예: database: 0)만 초기화
+ }
log.info("✅ Redis DB 초기화 완료");
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @PostConstruct | |
| public void clearRedis() { | |
| redisTemplate.getConnectionFactory() | |
| .getConnection() | |
| .flushDb(); // 선택한 DB(예: database: 0)만 초기화 | |
| log.info("✅ Redis DB 초기화 완료"); | |
| } | |
| import jakarta.annotation.PostConstruct; | |
| import lombok.RequiredArgsConstructor; | |
| import lombok.extern.slf4j.Slf4j; | |
| import org.springframework.context.annotation.Profile; | |
| import org.springframework.data.redis.connection.RedisConnection; | |
| import org.springframework.data.redis.core.RedisTemplate; | |
| import org.springframework.stereotype.Component; | |
| @PostConstruct | |
| public void clearRedis() { | |
| try (RedisConnection connection = redisTemplate.getConnectionFactory().getConnection()) { | |
| connection.flushDb(); // 선택한 DB(예: database: 0)만 초기화 | |
| } | |
| log.info("✅ Redis DB 초기화 완료"); | |
| } |
🤖 Prompt for AI Agents
In
src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/redis/RedisInitializer.java
around lines 18 to 24, the Redis connection obtained via
redisTemplate.getConnectionFactory().getConnection() is not closed after
flushDb(), which can leak connections; change the method to acquire the
connection in a try-with-resources block (try (var connection =
redisTemplate.getConnectionFactory().getConnection()) { connection.flushDb(); })
so the connection is always closed/returned to the pool, and optionally handle
or log any exceptions thrown during flush.
yskim6772
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
수고하셨슴다 !
Related issue 🛠
Work Description 📝
("/api/users/delete/account")
Screenshot 📸
Uncompleted Tasks 😅
To Reviewers 📢
Summary by CodeRabbit