From 69101c88842e8dd81a3ff448c2628f1602f37039 Mon Sep 17 00:00:00 2001 From: Azin Date: Tue, 7 Oct 2025 18:10:37 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat/#186:=20=ED=83=88=ED=87=B4=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 탈퇴 요청 기능 추가 2. 탈퇴 철회 기능 추가 3. 몇몇 변수들 property로 이동 4. 탈퇴 1주후까지 복구 가능 5. 영속성 전이 설정 -> @OnDelete(action = OnDeleteAction.CASCADE) 6. 스케줄러를 통해 매일 밤 11시 59분 59초에 기한 지난 유저 삭제 7. 로그아웃 및 탈퇴시 blacklist에 access토큰 추가하는 로직을 기존 로그아웃 및 탈퇴 서비스에서 분리 --- .../divary/domain/avatar/entity/Avatar.java | 3 ++ .../device_session/entity/DeviceSession.java | 3 ++ .../divary/domain/logbase/LogBaseInfo.java | 3 ++ .../divary/domain/member/entity/Member.java | 22 ++++++++- .../divary/domain/member/enums/Status.java | 5 ++ .../member/repository/MemberRepository.java | 4 ++ .../domain/member/service/MemberService.java | 4 ++ .../member/service/MemberServiceImpl.java | 34 ++++++++++++- .../notification/entity/Notification.java | 3 ++ .../config/jwt/JwtAuthenticationFilter.java | 13 +++++ .../divary/global/exception/ErrorCode.java | 2 +- .../oauth/controller/OauthController.java | 40 +++++++++++++++- .../dto/response/DeactivateResponse.java | 17 +++++++ .../global/oauth/service/OauthService.java | 4 +- .../oauth/service/social/AppleOauth.java | 4 +- .../oauth/service/social/GoogleOauth.java | 3 +- .../oauth/service/social/SocialOauth.java | 2 +- .../oauth/util/UserDeletionScheduler.java | 48 +++++++++++++++++++ 18 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/divary/domain/member/enums/Status.java create mode 100644 src/main/java/com/divary/global/oauth/dto/response/DeactivateResponse.java create mode 100644 src/main/java/com/divary/global/oauth/util/UserDeletionScheduler.java diff --git a/src/main/java/com/divary/domain/avatar/entity/Avatar.java b/src/main/java/com/divary/domain/avatar/entity/Avatar.java index 6d7861f5..40d39cd5 100644 --- a/src/main/java/com/divary/domain/avatar/entity/Avatar.java +++ b/src/main/java/com/divary/domain/avatar/entity/Avatar.java @@ -5,6 +5,8 @@ import com.divary.domain.avatar.enums.*; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; @Entity @Getter @@ -16,6 +18,7 @@ public class Avatar extends BaseEntity { @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") + @OnDelete(action = OnDeleteAction.CASCADE) private Member user; @Column(length = 20) diff --git a/src/main/java/com/divary/domain/device_session/entity/DeviceSession.java b/src/main/java/com/divary/domain/device_session/entity/DeviceSession.java index 641468d6..5aa8e541 100644 --- a/src/main/java/com/divary/domain/device_session/entity/DeviceSession.java +++ b/src/main/java/com/divary/domain/device_session/entity/DeviceSession.java @@ -10,6 +10,8 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; @Entity @Table(name = "device_session", uniqueConstraints = { @@ -24,6 +26,7 @@ public class DeviceSession extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") + @OnDelete(action = OnDeleteAction.CASCADE) private Member user; @Column(nullable = false) diff --git a/src/main/java/com/divary/domain/logbase/LogBaseInfo.java b/src/main/java/com/divary/domain/logbase/LogBaseInfo.java index 53660d40..77363706 100644 --- a/src/main/java/com/divary/domain/logbase/LogBaseInfo.java +++ b/src/main/java/com/divary/domain/logbase/LogBaseInfo.java @@ -26,6 +26,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; @Getter @Schema(description = "다이빙 로그 기본정보") @@ -41,6 +43,7 @@ public class LogBaseInfo extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) @Schema(description = "유저 id", example = "1L") + @OnDelete(action = OnDeleteAction.CASCADE) private Member member; @OneToMany(mappedBy = "logBaseInfo", cascade = CascadeType.REMOVE, orphanRemoval = true) diff --git a/src/main/java/com/divary/domain/member/entity/Member.java b/src/main/java/com/divary/domain/member/entity/Member.java index 229faa79..416e7885 100644 --- a/src/main/java/com/divary/domain/member/entity/Member.java +++ b/src/main/java/com/divary/domain/member/entity/Member.java @@ -4,6 +4,7 @@ import com.divary.domain.member.enums.Levels; import com.divary.domain.member.enums.Role; import com.divary.common.enums.SocialType; +import com.divary.domain.member.enums.Status; import jakarta.annotation.Nullable; import jakarta.persistence.Entity; import jakarta.persistence.*; @@ -11,6 +12,8 @@ import jakarta.validation.constraints.Null; import lombok.*; +import java.time.LocalDateTime; + @Entity @Builder @Getter @@ -29,5 +32,22 @@ public class Member extends BaseEntity { @Enumerated(EnumType.STRING) private Levels level; - + + @Enumerated(EnumType.STRING) + private Status status; // 사용자 상태 + + private LocalDateTime deactivatedAt; + + + // 탈퇴 요청 처리 + public void requestDeletion() { + this.status = Status.DEACTIVATED; + this.deactivatedAt = LocalDateTime.now(); + } + + // 탈퇴 요청 취소 (계정 복구) + public void cancelDeletion() { + this.status = Status.ACTIVE; + this.deactivatedAt = null; + } } diff --git a/src/main/java/com/divary/domain/member/enums/Status.java b/src/main/java/com/divary/domain/member/enums/Status.java new file mode 100644 index 00000000..1024c56c --- /dev/null +++ b/src/main/java/com/divary/domain/member/enums/Status.java @@ -0,0 +1,5 @@ +package com.divary.domain.member.enums; + +public enum Status { + ACTIVE, DEACTIVATED +} diff --git a/src/main/java/com/divary/domain/member/repository/MemberRepository.java b/src/main/java/com/divary/domain/member/repository/MemberRepository.java index ddd57088..497b951b 100644 --- a/src/main/java/com/divary/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/divary/domain/member/repository/MemberRepository.java @@ -1,11 +1,15 @@ package com.divary.domain.member.repository; import com.divary.domain.member.entity.Member; +import com.divary.domain.member.enums.Status; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); Optional findById(Long id); + List findByStatusAndDeactivatedAtBefore(Status status, LocalDateTime cutoffDate); } diff --git a/src/main/java/com/divary/domain/member/service/MemberService.java b/src/main/java/com/divary/domain/member/service/MemberService.java index f54a9938..c83aa791 100644 --- a/src/main/java/com/divary/domain/member/service/MemberService.java +++ b/src/main/java/com/divary/domain/member/service/MemberService.java @@ -3,6 +3,7 @@ import com.divary.domain.member.dto.response.MyPageImageResponseDTO; import com.divary.domain.member.entity.Member; import com.divary.domain.member.dto.requestDTO.MyPageLevelRequestDTO; +import com.divary.global.oauth.dto.response.DeactivateResponse; import org.springframework.web.multipart.MultipartFile; public interface MemberService { @@ -11,4 +12,7 @@ public interface MemberService { Member saveMember(Member member); void updateLevel(Long userId, MyPageLevelRequestDTO requestDTO); MyPageImageResponseDTO uploadLicense(MultipartFile image, Long userId); + DeactivateResponse requestToDeleteMember(Long memberId); + void cancelDeleteMember(Long memberId); + } diff --git a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java index 1b2ba555..80b1b2d1 100644 --- a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java @@ -5,11 +5,16 @@ import com.divary.domain.image.service.ImageService; import com.divary.domain.member.dto.requestDTO.MyPageLevelRequestDTO; import com.divary.domain.member.dto.response.MyPageImageResponseDTO; +import com.divary.domain.member.enums.Status; import com.divary.global.exception.BusinessException; import com.divary.global.exception.ErrorCode; +import com.divary.global.oauth.dto.response.DeactivateResponse; +import com.divary.global.redis.service.TokenBlackListService; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; +import org.springframework.security.core.token.TokenService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.divary.domain.member.repository.MemberRepository; @@ -17,13 +22,18 @@ import com.divary.domain.member.enums.Levels; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor @Transactional public class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; private final ImageService imageService; - String additionalPath = "qualifications"; + private final TokenBlackListService tokenBlackListService; + + @Value("${jobs.user-deletion.grace-period-days}") + private int gracePeriodDays; @Override public Member findMemberByEmail(String email) { @@ -68,4 +78,26 @@ public MyPageImageResponseDTO uploadLicense(MultipartFile image, Long userId) { return new MyPageImageResponseDTO(fileUrl); } + @Override + @Transactional + public DeactivateResponse requestToDeleteMember(Long memberId) { + Member member = memberRepository.findById(memberId).orElseThrow(()-> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + member.requestDeletion(); + + LocalDateTime scheduledDeletionAt = member.getDeactivatedAt() + .plusDays(gracePeriodDays); + + return new DeactivateResponse(scheduledDeletionAt); + } + + @Override + @Transactional + public void cancelDeleteMember(Long memberId) { + Member member = memberRepository.findById(memberId).orElseThrow(()-> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + // DEACTIVATED 상태일 때만 취소 가능 + if (member.getStatus() == Status.DEACTIVATED) { + member.cancelDeletion(); + } + } } diff --git a/src/main/java/com/divary/domain/notification/entity/Notification.java b/src/main/java/com/divary/domain/notification/entity/Notification.java index bad7214f..a0817700 100644 --- a/src/main/java/com/divary/domain/notification/entity/Notification.java +++ b/src/main/java/com/divary/domain/notification/entity/Notification.java @@ -7,6 +7,8 @@ import jakarta.annotation.Nullable; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; @Entity @Getter @@ -18,6 +20,7 @@ public class Notification extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "receiver_id") + @OnDelete(action = OnDeleteAction.CASCADE) private Member receiver; @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java index 124f6431..5444e46f 100644 --- a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java @@ -1,6 +1,9 @@ package com.divary.global.config.jwt; import com.divary.common.response.ApiResponse; +import com.divary.domain.member.entity.Member; +import com.divary.domain.member.enums.Status; +import com.divary.domain.member.service.MemberService; import com.divary.global.exception.BusinessException; import com.divary.global.exception.ErrorCode; import com.divary.global.redis.service.TokenBlackListService; @@ -33,6 +36,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final JwtResolver jwtResolver; private final TokenBlackListService tokenBlackListService; + private final MemberService memberService; @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @@ -45,9 +49,18 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, // 2. 헤더에서 Access Token을 추출합니다. String accessToken = jwtResolver.resolveAccessToken(request); + // 3. Access Token이 존재하는 경우에만 검증을 시작합니다. if (StringUtils.hasText(accessToken)) { + Long userId = jwtTokenProvider.getUserIdFromToken(accessToken); + + Member member = memberService.findById(userId); + + if(member.getStatus() == Status.DEACTIVATED){ + throw new BusinessException(ErrorCode.MEMBER_IS_DEACTIVATE); + } + //토큰이 유효한지 검증합니다. if (jwtTokenProvider.validateToken(accessToken)) { // 토큰이 유효하면, 로그아웃 처리된 토큰인지 블랙리스트를 확인하고 인증 정보를 SecurityContext에 등록합니다. diff --git a/src/main/java/com/divary/global/exception/ErrorCode.java b/src/main/java/com/divary/global/exception/ErrorCode.java index bd36198a..aa7947c7 100644 --- a/src/main/java/com/divary/global/exception/ErrorCode.java +++ b/src/main/java/com/divary/global/exception/ErrorCode.java @@ -42,7 +42,7 @@ public enum ErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_002", "유저를 찾을 수 없습니다."), MEMBER_ALREADY_EXISTS(HttpStatus.NOT_FOUND, "MEMBER_003", "이미 가입된 이메일입니다."), DEVICE_ID_NOT_FOUND(HttpStatus.NOT_FOUND, "DEVICE_001", "디바이스 아이디를 찾을 수 없습니다"), - + MEMBER_IS_DEACTIVATE(HttpStatus.NOT_FOUND, "MEMBER_004", "탈퇴 예정인 계정입니다."), //소셜 로그인 관련 GOOGLE_BAD_GATEWAY(HttpStatus.BAD_GATEWAY, "GOOGLE_001", "구글 유저를 찾을 수 없습니다"), diff --git a/src/main/java/com/divary/global/oauth/controller/OauthController.java b/src/main/java/com/divary/global/oauth/controller/OauthController.java index 59188acc..6b62ccb9 100644 --- a/src/main/java/com/divary/global/oauth/controller/OauthController.java +++ b/src/main/java/com/divary/global/oauth/controller/OauthController.java @@ -3,6 +3,7 @@ import com.divary.common.enums.SocialType; import com.divary.common.response.ApiResponse; +import com.divary.domain.member.service.MemberService; import com.divary.global.config.SwaggerConfig.ApiErrorExamples; import com.divary.global.config.SwaggerConfig.ApiSuccessResponse; import com.divary.global.exception.ErrorCode; @@ -10,8 +11,10 @@ import com.divary.global.config.security.CustomUserPrincipal; import com.divary.global.oauth.dto.request.LogoutRequestDto; import com.divary.global.oauth.dto.request.LoginRequestDto; +import com.divary.global.oauth.dto.response.DeactivateResponse; import com.divary.global.oauth.dto.response.LoginResponseDTO; import com.divary.global.oauth.service.OauthService; +import com.divary.global.redis.service.TokenBlackListService; import io.swagger.v3.oas.annotations.Operation; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -28,6 +31,8 @@ public class OauthController { private final OauthService oauthService; private final JwtResolver jwtResolver; + private final MemberService memberService; + private final TokenBlackListService tokenBlackListService; @PostMapping(value = "/{socialLoginType}/login") @@ -51,7 +56,8 @@ public ApiResponse login(@PathVariable(name = "socialLoginType public ApiResponse logout(@AuthenticationPrincipal CustomUserPrincipal userPrincipal, @PathVariable(name = "socialLoginType") SocialType socialLoginType, HttpServletRequest request, @RequestBody LogoutRequestDto logoutRequestDto) { String accessToken = jwtResolver.resolveAccessToken(request); - oauthService.logout(socialLoginType, logoutRequestDto.getDeviceId(), userPrincipal.getId(), accessToken); + oauthService.logout(socialLoginType, logoutRequestDto.getDeviceId(), userPrincipal.getId()); + tokenBlackListService.addToBlacklist(accessToken); return ApiResponse.success("로그아웃에 성공했습니다."); } @@ -67,4 +73,36 @@ public ApiResponse reissueToken(HttpServletRequest request) { return ApiResponse.success(newTokens); } + + @PostMapping(value = "/deactivate") + @Operation(summary = "회원 탈퇴를 요청합니다.") + @ApiSuccessResponse(dataType = DeactivateResponse.class) + @ApiErrorExamples(value = {ErrorCode.MEMBER_NOT_FOUND}) + public ApiResponse deactivateUser(@AuthenticationPrincipal CustomUserPrincipal userPrincipal, HttpServletRequest request) { + Long userId = userPrincipal.getId(); + String accessToken = jwtResolver.resolveAccessToken(request); + + DeactivateResponse response = memberService.requestToDeleteMember(userId); + + /** + * Redis의 SADD 명령어는 Set에 멤버를 추가하는데, 이미 멤버가 존재하면 아무 작업도 하지 않고 성공을 반환합니다. 에러가 발생하지 않습니다. + *이 경우, isContainToken을 호출하는 것은 불필요한 DB 조회(네트워크 왕복)를 한 번 더 하는 셈이므로 성능상 손해입니다. + * 그냥 바로 addToBlacklist를 호출하는 것이 코드도 간결하고 효율적입니다. + */ + tokenBlackListService.addToBlacklist(accessToken); + + return ApiResponse.success(response); + } + + @PostMapping(value = "/reactivate") + @Operation(summary = "회원 탈퇴를 취소합니다.") + @ApiSuccessResponse(dataType = void.class) + @ApiErrorExamples(value = {ErrorCode.MEMBER_NOT_FOUND}) + public ApiResponse reactivate(@AuthenticationPrincipal CustomUserPrincipal userPrincipal) { + Long userId = userPrincipal.getId(); + + memberService.cancelDeleteMember(userId); + return ApiResponse.success("회원 정보 복구에 성공했습니다"); + } + } \ No newline at end of file diff --git a/src/main/java/com/divary/global/oauth/dto/response/DeactivateResponse.java b/src/main/java/com/divary/global/oauth/dto/response/DeactivateResponse.java new file mode 100644 index 00000000..99423238 --- /dev/null +++ b/src/main/java/com/divary/global/oauth/dto/response/DeactivateResponse.java @@ -0,0 +1,17 @@ +package com.divary.global.oauth.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class DeactivateResponse { + // 최종 삭제 예정 시간을 담을 필드 + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") + private final LocalDateTime scheduledDeletionAt; + + public DeactivateResponse(LocalDateTime scheduledDeletionAt) { + this.scheduledDeletionAt = scheduledDeletionAt; + } +} diff --git a/src/main/java/com/divary/global/oauth/service/OauthService.java b/src/main/java/com/divary/global/oauth/service/OauthService.java index e563fa82..84c18c2b 100644 --- a/src/main/java/com/divary/global/oauth/service/OauthService.java +++ b/src/main/java/com/divary/global/oauth/service/OauthService.java @@ -43,12 +43,12 @@ public LoginResponseDTO authenticateWithAccessToken(SocialType socialLoginType, } @Transactional - public void logout(SocialType socialLoginType, String deviceId, Long userId, String accessToken) { + public void logout(SocialType socialLoginType, String deviceId, Long userId) { SocialOauth socialOauth = this.findSocialOauthByType(socialLoginType); if (socialOauth == null) { throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); } - socialOauth.logout(deviceId, userId, accessToken); + socialOauth.logout(deviceId, userId); } /** diff --git a/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java b/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java index 312ae985..886bbb1d 100644 --- a/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java +++ b/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java @@ -81,9 +81,7 @@ public LoginResponseDTO verifyAndLogin(String identityToken, String deviceId) { } @Override - public void logout(String deviceId, Long userId, String accessToken) { - // AccessToken을 블랙리스트에 추가합니다. - tokenBlackListService.addToBlacklist(accessToken); + public void logout(String deviceId, Long userId) { // DB에서 Refresh Token(디바이스 세션)을 삭제합니다. deviceSessionService.removeRefreshToken(deviceId, userId); diff --git a/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java b/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java index 40919b00..a06ff77f 100644 --- a/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java +++ b/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java @@ -105,8 +105,7 @@ public LoginResponseDTO verifyAndLogin(String googleAccessToken, String deviceId return LoginResponseDTO.builder().accessToken(accessToken).refreshToken(refreshToken).build(); } - public void logout(String deviceId, Long userId, String accessToken) { - tokenBlackListService.addToBlacklist(accessToken); + public void logout(String deviceId, Long userId) { //DB에서 Refresh Token을 삭제합니다. deviceSessionService.removeRefreshToken(deviceId, userId); diff --git a/src/main/java/com/divary/global/oauth/service/social/SocialOauth.java b/src/main/java/com/divary/global/oauth/service/social/SocialOauth.java index 9f36b447..17eac98b 100644 --- a/src/main/java/com/divary/global/oauth/service/social/SocialOauth.java +++ b/src/main/java/com/divary/global/oauth/service/social/SocialOauth.java @@ -5,7 +5,7 @@ public interface SocialOauth { LoginResponseDTO verifyAndLogin(String token, String deviceId); - void logout(String deviceId, Long userId, String accessToken); + void logout(String deviceId, Long userId); SocialType getType(); } diff --git a/src/main/java/com/divary/global/oauth/util/UserDeletionScheduler.java b/src/main/java/com/divary/global/oauth/util/UserDeletionScheduler.java new file mode 100644 index 00000000..a6387ef9 --- /dev/null +++ b/src/main/java/com/divary/global/oauth/util/UserDeletionScheduler.java @@ -0,0 +1,48 @@ +package com.divary.global.oauth.util; + +import com.divary.domain.member.entity.Member; +import com.divary.domain.member.enums.Status; +import com.divary.domain.member.repository.MemberRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.List; + +@Component +public class UserDeletionScheduler { + + // 유예 기간 (예: 7일) + @Value("${jobs.user-deletion.grace-period-days}") + private int gracePeriodDays; + + private final MemberRepository memberRepository; + + public UserDeletionScheduler(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + // 매일 밤 12시에 실행 (cron = "초 분 시 일 월 요일") + @Scheduled(cron = "${jobs.user-deletion.cron}") + @Transactional + public void cleanupDeactivatedUsers() { + System.out.println("탈퇴 유예 기간이 지난 사용자 삭제 작업을 시작합니다..."); + + // 유예 기간이 지난 탈퇴 요청 사용자 조회 + LocalDateTime cutoffDate = LocalDateTime.now().minusDays(gracePeriodDays); + + List usersToDelete = memberRepository.findByStatusAndDeactivatedAtBefore( + Status.DEACTIVATED, + cutoffDate + ); + + // 실제 데이터 영구 삭제 + if (!usersToDelete.isEmpty()) { + memberRepository.deleteAll(usersToDelete); + System.out.println(usersToDelete.size() + "명의 사용자 정보가 영구 삭제되었습니다."); + } else { + System.out.println("삭제할 사용자가 없습니다."); + } + } +} From 626bc07ecc7f22418f61fe7b49f1f5e0dd45277d Mon Sep 17 00:00:00 2001 From: Azin Date: Tue, 14 Oct 2025 14:21:56 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat/#186:=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 캐시 관련 오류 수정 - repository에 직접 접근하는 메소드에 캐시관리 어노테이션 추가 2. member status not null 3. status가 deactivate시 filter에서 막음 3-1. 모든 요청을 막을경우 복구 요청도 불가능하니 복구 요청이 아니고 deactive시만 막게 작성 4. verson을 통해 레이스 컨디션 방지 --- build.gradle | 1 + .../divary/domain/member/entity/Member.java | 7 +++++- .../member/service/MemberServiceImpl.java | 22 +++++++++++++---- .../system/controller/SystemController.java | 2 ++ .../config/jwt/JwtAuthenticationFilter.java | 10 ++++++-- .../divary/global/exception/ErrorCode.java | 1 + .../global/oauth/service/OauthService.java | 24 ++++++++++++++----- .../oauth/service/social/AppleOauth.java | 2 ++ .../oauth/service/social/GoogleOauth.java | 2 ++ 9 files changed, 57 insertions(+), 14 deletions(-) diff --git a/build.gradle b/build.gradle index f36f941c..b75381ac 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-devtools' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' implementation 'org.springframework.boot:spring-boot-starter-data-redis' //블랙리스트 관련 redis diff --git a/src/main/java/com/divary/domain/member/entity/Member.java b/src/main/java/com/divary/domain/member/entity/Member.java index 416e7885..903c6a4b 100644 --- a/src/main/java/com/divary/domain/member/entity/Member.java +++ b/src/main/java/com/divary/domain/member/entity/Member.java @@ -34,9 +34,14 @@ public class Member extends BaseEntity { private Levels level; @Enumerated(EnumType.STRING) + @NotNull private Status status; // 사용자 상태 - private LocalDateTime deactivatedAt; + + private LocalDateTime deactivatedAt; //비활성화 된 시간과 날짜 + + @Version + private Long version; //버전을통해 레이스 컨디션 해결 // 탈퇴 요청 처리 diff --git a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java index 80b1b2d1..7936dab0 100644 --- a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java @@ -14,6 +14,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.security.core.token.TokenService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -80,24 +81,35 @@ public MyPageImageResponseDTO uploadLicense(MultipartFile image, Long userId) { @Override @Transactional + @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#memberId") public DeactivateResponse requestToDeleteMember(Long memberId) { + try { Member member = memberRepository.findById(memberId).orElseThrow(()-> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + member.requestDeletion(); LocalDateTime scheduledDeletionAt = member.getDeactivatedAt() .plusDays(gracePeriodDays); return new DeactivateResponse(scheduledDeletionAt); + }catch (ObjectOptimisticLockingFailureException e) { + throw new BusinessException(ErrorCode.CONCURRENT_REQUEST_ERROR, "요청 처리 중 충돌이 발생했습니다. 잠시 후 다시 시도해주세요."); + } } @Override @Transactional + @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#memberId") public void cancelDeleteMember(Long memberId) { - Member member = memberRepository.findById(memberId).orElseThrow(()-> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); - - // DEACTIVATED 상태일 때만 취소 가능 - if (member.getStatus() == Status.DEACTIVATED) { - member.cancelDeletion(); + try { + Member member = memberRepository.findById(memberId).orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + // DEACTIVATED 상태일 때만 취소 가능 + if (member.getStatus() == Status.DEACTIVATED) { + member.cancelDeletion(); + } + } catch (ObjectOptimisticLockingFailureException e) { + throw new BusinessException(ErrorCode.CONCURRENT_REQUEST_ERROR, "요청 처리 중 충돌이 발생했습니다. 잠시 후 다시 시도해주세요."); } } } diff --git a/src/main/java/com/divary/domain/system/controller/SystemController.java b/src/main/java/com/divary/domain/system/controller/SystemController.java index 8257b180..5ae59e98 100644 --- a/src/main/java/com/divary/domain/system/controller/SystemController.java +++ b/src/main/java/com/divary/domain/system/controller/SystemController.java @@ -3,6 +3,7 @@ import com.divary.common.response.ApiResponse; import com.divary.domain.member.entity.Member; import com.divary.domain.member.enums.Role; +import com.divary.domain.member.enums.Status; import com.divary.domain.member.repository.MemberRepository; import com.divary.domain.image.enums.ImageType; import com.divary.domain.image.service.ImageService; @@ -99,6 +100,7 @@ public ApiResponse createTestUser(@RequestParam(defaultValue = "test@div if (memberRepository.findByEmail(email).isEmpty()) { Member testUser = Member.builder() .email(email) + .status(Status.ACTIVE) .role(Role.USER) .build(); memberRepository.save(testUser); diff --git a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java index 5444e46f..bd6cf8a2 100644 --- a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java @@ -22,7 +22,6 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.util.Arrays; /** * 클라이언트의 모든 API 요청을 가로채 Access Token의 유효성을 검증하는 필터입니다. @@ -37,6 +36,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtResolver jwtResolver; private final TokenBlackListService tokenBlackListService; private final MemberService memberService; + private static final String REACTIVATE_MEMBER_URI = "/api/v1/auth/reactivate"; + private static final String REACTIVATE_MEMBER_METHOD = "POST"; @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @@ -57,7 +58,12 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, Member member = memberService.findById(userId); - if(member.getStatus() == Status.DEACTIVATED){ + // 1. 현재 요청이 회원 복구 API인지 확인합니다. + boolean isRecoveryRequest = request.getRequestURI().equals(REACTIVATE_MEMBER_URI) && + request.getMethod().equalsIgnoreCase(REACTIVATE_MEMBER_METHOD); + + // 2. 복구 요청이 아닌 경우에만 비활성화 상태를 체크합니다. + if (!isRecoveryRequest && member.getStatus() == Status.DEACTIVATED) { throw new BusinessException(ErrorCode.MEMBER_IS_DEACTIVATE); } diff --git a/src/main/java/com/divary/global/exception/ErrorCode.java b/src/main/java/com/divary/global/exception/ErrorCode.java index aa7947c7..b1da5553 100644 --- a/src/main/java/com/divary/global/exception/ErrorCode.java +++ b/src/main/java/com/divary/global/exception/ErrorCode.java @@ -43,6 +43,7 @@ public enum ErrorCode { MEMBER_ALREADY_EXISTS(HttpStatus.NOT_FOUND, "MEMBER_003", "이미 가입된 이메일입니다."), DEVICE_ID_NOT_FOUND(HttpStatus.NOT_FOUND, "DEVICE_001", "디바이스 아이디를 찾을 수 없습니다"), MEMBER_IS_DEACTIVATE(HttpStatus.NOT_FOUND, "MEMBER_004", "탈퇴 예정인 계정입니다."), + CONCURRENT_REQUEST_ERROR(HttpStatus.CONFLICT, "MEMBER_004", "탈퇴 요청 처리 중 데이터 충돌이 발생했습니다"), //소셜 로그인 관련 GOOGLE_BAD_GATEWAY(HttpStatus.BAD_GATEWAY, "GOOGLE_001", "구글 유저를 찾을 수 없습니다"), diff --git a/src/main/java/com/divary/global/oauth/service/OauthService.java b/src/main/java/com/divary/global/oauth/service/OauthService.java index 84c18c2b..cb2fae6f 100644 --- a/src/main/java/com/divary/global/oauth/service/OauthService.java +++ b/src/main/java/com/divary/global/oauth/service/OauthService.java @@ -1,10 +1,13 @@ package com.divary.global.oauth.service; import com.divary.common.enums.SocialType; +import com.divary.domain.member.entity.Member; import com.divary.domain.member.enums.Role; +import com.divary.domain.member.enums.Status; import com.divary.domain.member.repository.MemberRepository; import com.divary.domain.device_session.entity.DeviceSession; import com.divary.domain.device_session.repository.DeviceSessionRepository; +import com.divary.domain.member.service.MemberService; import com.divary.global.config.jwt.JwtTokenProvider; import com.divary.global.config.security.CustomUserPrincipal; import com.divary.global.exception.BusinessException; @@ -27,6 +30,8 @@ public class OauthService { private final JwtTokenProvider jwtTokenProvider; private final DeviceSessionRepository deviceSessionRepository; private final SocialOauthServiceFactory socialOauthServiceFactory; + private final MemberRepository memberRepository; + private final MemberService memberService; public SocialOauth findSocialOauthByType(SocialType socialType) { @@ -65,16 +70,23 @@ public LoginResponseDTO reissueToken(String refreshToken, String deviceId) { throw new BusinessException(ErrorCode.INVALID_TOKEN, "Refresh Token이 유효하지 않습니다."); } - // 2. DB에 저장된 토큰과 일치하는지, Device ID가 맞는지 확인 - boolean exists = deviceSessionRepository.existsByRefreshTokenAndDeviceId(refreshToken, deviceId); - if (!exists) { - throw new BusinessException(ErrorCode.REFRESH_TOKEN_NOT_FOUND, "저장소에 Refresh Token이 없거나 기기 정보가 일치하지 않습니다."); - } - // 3. 토큰에서 사용자 ID 추출 + // 2. 토큰에서 사용자 ID 추출 Long userId = jwtTokenProvider.getUserIdFromToken(refreshToken); Role role = jwtTokenProvider.getRoleFromToken(refreshToken); + Member member = memberRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + // 3. 회원의 탈퇴 여부 확인 + if (member.getStatus() == Status.DEACTIVATED){ + throw new BusinessException(ErrorCode.MEMBER_IS_DEACTIVATE); + } + + // 4. DB에 저장된 토큰과 일치하는지, Device ID가 맞는지 확인 + boolean exists = deviceSessionRepository.existsByRefreshTokenAndDeviceId(refreshToken, deviceId); + if (!exists) { + throw new BusinessException(ErrorCode.REFRESH_TOKEN_NOT_FOUND, "저장소에 Refresh Token이 없거나 기기 정보가 일치하지 않습니다."); + } diff --git a/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java b/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java index 886bbb1d..747aa98a 100644 --- a/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java +++ b/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java @@ -3,6 +3,7 @@ import com.divary.common.enums.SocialType; import com.divary.domain.member.entity.Member; import com.divary.domain.member.enums.Role; +import com.divary.domain.member.enums.Status; import com.divary.domain.member.service.MemberService; import com.divary.domain.device_session.service.DeviceSessionService; import com.divary.global.config.jwt.JwtTokenProvider; @@ -55,6 +56,7 @@ public LoginResponseDTO verifyAndLogin(String identityToken, String deviceId) { } catch (BusinessException e) { member = memberService.saveMember(Member.builder() .email(email) + .status(Status.ACTIVE) .role(Role.USER) .build()); diff --git a/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java b/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java index a06ff77f..69e3ed8c 100644 --- a/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java +++ b/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java @@ -3,6 +3,7 @@ import com.divary.common.enums.SocialType; import com.divary.domain.member.entity.Member; import com.divary.domain.member.enums.Role; +import com.divary.domain.member.enums.Status; import com.divary.domain.member.service.MemberService; import com.divary.domain.avatar.service.AvatarService; import com.divary.domain.device_session.service.DeviceSessionService; @@ -81,6 +82,7 @@ public LoginResponseDTO verifyAndLogin(String googleAccessToken, String deviceId } catch (BusinessException e) { member = memberService.saveMember(Member.builder() .email(email) + .status(Status.ACTIVE) .role(Role.USER) .build()); From 22deb45b444a1f097e915876e587cbb69e7166bd Mon Sep 17 00:00:00 2001 From: Azin Date: Wed, 15 Oct 2025 16:24:20 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat/#186:=20exception=20handler=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. ObjectOptimisticLockingFailureException의 경우 globalExceptionHandler로 라우팅 됨 -> globalHander에 관련 메소드가 없음 -> 500에러 handler 메소드 작성을 통해 해결 2. status관련 기본값 엔티티에 설정 3. 에러코드 오타 수정 --- .../com/divary/domain/member/entity/Member.java | 3 +-- .../com/divary/global/exception/ErrorCode.java | 8 +++++--- .../global/exception/GlobalExceptionHandler.java | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/divary/domain/member/entity/Member.java b/src/main/java/com/divary/domain/member/entity/Member.java index 903c6a4b..1e143352 100644 --- a/src/main/java/com/divary/domain/member/entity/Member.java +++ b/src/main/java/com/divary/domain/member/entity/Member.java @@ -35,8 +35,7 @@ public class Member extends BaseEntity { @Enumerated(EnumType.STRING) @NotNull - private Status status; // 사용자 상태 - + Status status = Status.ACTIVE; // 사용자 상태 private LocalDateTime deactivatedAt; //비활성화 된 시간과 날짜 diff --git a/src/main/java/com/divary/global/exception/ErrorCode.java b/src/main/java/com/divary/global/exception/ErrorCode.java index b1da5553..b6c52a37 100644 --- a/src/main/java/com/divary/global/exception/ErrorCode.java +++ b/src/main/java/com/divary/global/exception/ErrorCode.java @@ -42,8 +42,8 @@ public enum ErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_002", "유저를 찾을 수 없습니다."), MEMBER_ALREADY_EXISTS(HttpStatus.NOT_FOUND, "MEMBER_003", "이미 가입된 이메일입니다."), DEVICE_ID_NOT_FOUND(HttpStatus.NOT_FOUND, "DEVICE_001", "디바이스 아이디를 찾을 수 없습니다"), - MEMBER_IS_DEACTIVATE(HttpStatus.NOT_FOUND, "MEMBER_004", "탈퇴 예정인 계정입니다."), - CONCURRENT_REQUEST_ERROR(HttpStatus.CONFLICT, "MEMBER_004", "탈퇴 요청 처리 중 데이터 충돌이 발생했습니다"), + MEMBER_IS_DEACTIVATE(HttpStatus.FORBIDDEN, "MEMBER_004", "탈퇴 예정인 계정입니다."), + CONCURRENT_REQUEST_ERROR(HttpStatus.CONFLICT, "MEMBER_005", "탈퇴 요청 처리 중 데이터 충돌이 발생했습니다"), //소셜 로그인 관련 GOOGLE_BAD_GATEWAY(HttpStatus.BAD_GATEWAY, "GOOGLE_001", "구글 유저를 찾을 수 없습니다"), @@ -79,8 +79,10 @@ public enum ErrorCode { //device session 관련 - REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "DEVICE_001", "refresh token을 찾을 수 없습니다."); + REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "DEVICE_001", "refresh token을 찾을 수 없습니다."), + //충돌 오류 + CONCURRENCY_CONFLICT(HttpStatus.CONFLICT, "Conflict_001", "요청이 다른 사용자와 충돌했습니다. 페이지를 새로고침 후 다시 시도해주세요."); // TODO: 비즈니스 로직 개발하면서 필요한 에러코드들 추가 private final HttpStatus status; private final String code; diff --git a/src/main/java/com/divary/global/exception/GlobalExceptionHandler.java b/src/main/java/com/divary/global/exception/GlobalExceptionHandler.java index 3c71aab0..58df2b5d 100644 --- a/src/main/java/com/divary/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/divary/global/exception/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.validation.BindException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -72,6 +73,19 @@ protected ResponseEntity> handleMethodNotSupportedException(Ht .body(ApiResponse.error(ErrorCode.METHOD_NOT_ALLOWED, request.getRequestURI())); } + /** + * JPA Optimistic Lock 버전 충돌 예외 처리 + */ + @ExceptionHandler(ObjectOptimisticLockingFailureException.class) + protected ResponseEntity> handleOptimisticLockingFailureException(ObjectOptimisticLockingFailureException e) { + log.warn("handleOptimisticLockingFailureException", e); // 충돌 발생 로깅 + + // 409 Conflict 상태 코드로 응답 + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(ApiResponse.error(ErrorCode.CONCURRENCY_CONFLICT)); + } + /** * 기타 모든 예외 처리 */ From 850dd041624945572d40cc7888d0e8857f15fbfb Mon Sep 17 00:00:00 2001 From: Azin Date: Thu, 16 Oct 2025 10:25:42 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat/#186:=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 멤버를 생성할때 try catch에서 rollback only 오류가 발생 -> optional로 변경후 처리 --- .../domain/member/service/MemberService.java | 2 ++ .../member/service/MemberServiceImpl.java | 18 ++++++++++++++++++ .../oauth/service/social/AppleOauth.java | 14 +------------- .../oauth/service/social/GoogleOauth.java | 13 +------------ 4 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/divary/domain/member/service/MemberService.java b/src/main/java/com/divary/domain/member/service/MemberService.java index c83aa791..ac57de97 100644 --- a/src/main/java/com/divary/domain/member/service/MemberService.java +++ b/src/main/java/com/divary/domain/member/service/MemberService.java @@ -14,5 +14,7 @@ public interface MemberService { MyPageImageResponseDTO uploadLicense(MultipartFile image, Long userId); DeactivateResponse requestToDeleteMember(Long memberId); void cancelDeleteMember(Long memberId); + public Member findOrCreateMember(String email); + } diff --git a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java index 7936dab0..df265762 100644 --- a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java @@ -5,6 +5,7 @@ import com.divary.domain.image.service.ImageService; import com.divary.domain.member.dto.requestDTO.MyPageLevelRequestDTO; import com.divary.domain.member.dto.response.MyPageImageResponseDTO; +import com.divary.domain.member.enums.Role; import com.divary.domain.member.enums.Status; import com.divary.global.exception.BusinessException; import com.divary.global.exception.ErrorCode; @@ -24,6 +25,7 @@ import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -112,4 +114,20 @@ public void cancelDeleteMember(Long memberId) { throw new BusinessException(ErrorCode.CONCURRENT_REQUEST_ERROR, "요청 처리 중 충돌이 발생했습니다. 잠시 후 다시 시도해주세요."); } } + @Override + @Transactional + public Member findOrCreateMember(String email) { + // 1. Optional을 사용하여 회원을 조회합니다. + Optional optionalMember = memberRepository.findByEmail(email); + + // 2. 회원이 존재하면 그대로 반환하고, 존재하지 않으면 새로 생성하여 저장한 뒤 반환합니다. + return optionalMember.orElseGet(() -> { + Member newMember = Member.builder() + .email(email) + .status(Status.ACTIVE) + .role(Role.USER) + .build(); + return memberRepository.save(newMember); + }); + } } diff --git a/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java b/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java index 747aa98a..6ddafaa5 100644 --- a/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java +++ b/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java @@ -48,19 +48,7 @@ public LoginResponseDTO verifyAndLogin(String identityToken, String deviceId) { Map userInfo = appleJwtParser.parse(identityToken); String email = userInfo.get("email"); - Member member; - - try { - member = memberService.findMemberByEmail(email); - - } catch (BusinessException e) { - member = memberService.saveMember(Member.builder() - .email(email) - .status(Status.ACTIVE) - .role(Role.USER) - .build()); - - } + Member member = memberService.findOrCreateMember(email); CustomUserPrincipal principal = new CustomUserPrincipal(member); diff --git a/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java b/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java index 69e3ed8c..3c18fcda 100644 --- a/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java +++ b/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java @@ -74,19 +74,8 @@ public LoginResponseDTO verifyAndLogin(String googleAccessToken, String deviceId Map userInfo = requestUserInfo(googleAccessToken); String email = (String) userInfo.get("email"); - Member member; - try { - member = memberService.findMemberByEmail(email); - - } catch (BusinessException e) { - member = memberService.saveMember(Member.builder() - .email(email) - .status(Status.ACTIVE) - .role(Role.USER) - .build()); - - } + Member member = memberService.findOrCreateMember(email); CustomUserPrincipal principal = new CustomUserPrincipal(member); From 295c6186e01be3f4aae2fb571aa737e931b0aa97 Mon Sep 17 00:00:00 2001 From: Azin Date: Thu, 16 Oct 2025 14:04:49 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat/#186:=20=ED=83=88=ED=87=B4=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EC=9D=B4=20=EB=B0=80=EB=A6=B4=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. member가 비활성화 상태인데도 탈퇴요청이 들어올 수 있음 -> 탈퇴 시간이 계속 밀림 -> member롤 servie 호출전에 확인하고 서비스 넘어가서 확인하는거보다 서비스에서 확인해서 비활성화이면 원래 시간 리턴하도록 변경 2. 의미 없는 trycatch 삭제 --- .../domain/member/service/MemberServiceImpl.java | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java index df265762..1e1277dc 100644 --- a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java @@ -85,34 +85,29 @@ public MyPageImageResponseDTO uploadLicense(MultipartFile image, Long userId) { @Transactional @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#memberId") public DeactivateResponse requestToDeleteMember(Long memberId) { - try { Member member = memberRepository.findById(memberId).orElseThrow(()-> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + if (member.getStatus() == Status.DEACTIVATED) { + return new DeactivateResponse(member.getDeactivatedAt()); + } member.requestDeletion(); LocalDateTime scheduledDeletionAt = member.getDeactivatedAt() .plusDays(gracePeriodDays); return new DeactivateResponse(scheduledDeletionAt); - }catch (ObjectOptimisticLockingFailureException e) { - throw new BusinessException(ErrorCode.CONCURRENT_REQUEST_ERROR, "요청 처리 중 충돌이 발생했습니다. 잠시 후 다시 시도해주세요."); - } } @Override @Transactional @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#memberId") public void cancelDeleteMember(Long memberId) { - try { Member member = memberRepository.findById(memberId).orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); // DEACTIVATED 상태일 때만 취소 가능 if (member.getStatus() == Status.DEACTIVATED) { member.cancelDeletion(); } - } catch (ObjectOptimisticLockingFailureException e) { - throw new BusinessException(ErrorCode.CONCURRENT_REQUEST_ERROR, "요청 처리 중 충돌이 발생했습니다. 잠시 후 다시 시도해주세요."); - } } @Override @Transactional From 2cabe3098f31bd5241c6c19d968bff8c369fb1ac Mon Sep 17 00:00:00 2001 From: Azin Date: Sun, 19 Oct 2025 14:29:01 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat/186:=20filter=20=EC=B1=85=EC=9E=84?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 하드코어 되어있는 경로를 security로 분리 --- .../divary/global/config/jwt/JwtAuthenticationFilter.java | 7 +++---- .../com/divary/global/config/security/SecurityConfig.java | 7 +++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java index bd6cf8a2..0ed3df65 100644 --- a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java @@ -17,6 +17,7 @@ import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; @@ -36,8 +37,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtResolver jwtResolver; private final TokenBlackListService tokenBlackListService; private final MemberService memberService; - private static final String REACTIVATE_MEMBER_URI = "/api/v1/auth/reactivate"; - private static final String REACTIVATE_MEMBER_METHOD = "POST"; + private final RequestMatcher reactivateMemberRequestMatcher; @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @@ -59,8 +59,7 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, Member member = memberService.findById(userId); // 1. 현재 요청이 회원 복구 API인지 확인합니다. - boolean isRecoveryRequest = request.getRequestURI().equals(REACTIVATE_MEMBER_URI) && - request.getMethod().equalsIgnoreCase(REACTIVATE_MEMBER_METHOD); + boolean isRecoveryRequest = reactivateMemberRequestMatcher.matches(request); // 2. 복구 요청이 아닌 경우에만 비활성화 상태를 체크합니다. if (!isRecoveryRequest && member.getStatus() == Status.DEACTIVATED) { diff --git a/src/main/java/com/divary/global/config/security/SecurityConfig.java b/src/main/java/com/divary/global/config/security/SecurityConfig.java index f6ef95f3..15e9cf28 100644 --- a/src/main/java/com/divary/global/config/security/SecurityConfig.java +++ b/src/main/java/com/divary/global/config/security/SecurityConfig.java @@ -19,6 +19,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import jakarta.annotation.PostConstruct; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; @Configuration @EnableWebSecurity @@ -87,4 +88,10 @@ public AccessDeniedHandler customAccessDeniedHandler() { public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public PathPatternRequestMatcher reactivateMemberRequestMatcher() { + return PathPatternRequestMatcher.withDefaults() + .matcher(HttpMethod.POST, "/api/v1/auth/reactivate"); + } } \ No newline at end of file From 9246df805aced9a41254618dcd4a39ab1e2fc3d0 Mon Sep 17 00:00:00 2001 From: Azin Date: Wed, 22 Oct 2025 19:01:32 +0900 Subject: [PATCH 7/9] =?UTF-8?q?Revert=20"feat/186:=20filter=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=EB=B6=84=EB=A6=AC"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 2cabe3098f31bd5241c6c19d968bff8c369fb1ac. --- .../divary/global/config/jwt/JwtAuthenticationFilter.java | 7 ++++--- .../com/divary/global/config/security/SecurityConfig.java | 7 ------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java index 0ed3df65..bd6cf8a2 100644 --- a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java @@ -17,7 +17,6 @@ import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; @@ -37,7 +36,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtResolver jwtResolver; private final TokenBlackListService tokenBlackListService; private final MemberService memberService; - private final RequestMatcher reactivateMemberRequestMatcher; + private static final String REACTIVATE_MEMBER_URI = "/api/v1/auth/reactivate"; + private static final String REACTIVATE_MEMBER_METHOD = "POST"; @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @@ -59,7 +59,8 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, Member member = memberService.findById(userId); // 1. 현재 요청이 회원 복구 API인지 확인합니다. - boolean isRecoveryRequest = reactivateMemberRequestMatcher.matches(request); + boolean isRecoveryRequest = request.getRequestURI().equals(REACTIVATE_MEMBER_URI) && + request.getMethod().equalsIgnoreCase(REACTIVATE_MEMBER_METHOD); // 2. 복구 요청이 아닌 경우에만 비활성화 상태를 체크합니다. if (!isRecoveryRequest && member.getStatus() == Status.DEACTIVATED) { diff --git a/src/main/java/com/divary/global/config/security/SecurityConfig.java b/src/main/java/com/divary/global/config/security/SecurityConfig.java index 15e9cf28..f6ef95f3 100644 --- a/src/main/java/com/divary/global/config/security/SecurityConfig.java +++ b/src/main/java/com/divary/global/config/security/SecurityConfig.java @@ -19,7 +19,6 @@ import org.springframework.security.core.context.SecurityContextHolder; import jakarta.annotation.PostConstruct; -import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; @Configuration @EnableWebSecurity @@ -88,10 +87,4 @@ public AccessDeniedHandler customAccessDeniedHandler() { public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } - - @Bean - public PathPatternRequestMatcher reactivateMemberRequestMatcher() { - return PathPatternRequestMatcher.withDefaults() - .matcher(HttpMethod.POST, "/api/v1/auth/reactivate"); - } } \ No newline at end of file From 1a51bc50fa575bc707db841d3de0bf949934d2f3 Mon Sep 17 00:00:00 2001 From: Azin Date: Wed, 22 Oct 2025 19:06:30 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat/#186:=20=EC=88=9C=ED=99=98=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=EB=AC=B8=EC=A0=9C=EB=A1=9C=20=ED=95=98=EB=93=9C?= =?UTF-8?q?=EC=BD=94=EB=94=A9=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit todo 추가 --- .../com/divary/global/config/jwt/JwtAuthenticationFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java index bd6cf8a2..1c47a5cd 100644 --- a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java @@ -37,7 +37,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final TokenBlackListService tokenBlackListService; private final MemberService memberService; private static final String REACTIVATE_MEMBER_URI = "/api/v1/auth/reactivate"; - private static final String REACTIVATE_MEMBER_METHOD = "POST"; + private static final String REACTIVATE_MEMBER_METHOD = "POST"; //todo 하드코딩 안하게 변경 @Override protected void doFilterInternal(@NonNull HttpServletRequest request, From 2e381cbece19c41c5e6e4a27e581620e5f88be55 Mon Sep 17 00:00:00 2001 From: Azin Date: Wed, 22 Oct 2025 19:22:37 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix/#191:=20=EC=98=A4=EB=A5=98=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/divary/global/config/security/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/divary/global/config/security/SecurityConfig.java b/src/main/java/com/divary/global/config/security/SecurityConfig.java index f6ef95f3..acccf93e 100644 --- a/src/main/java/com/divary/global/config/security/SecurityConfig.java +++ b/src/main/java/com/divary/global/config/security/SecurityConfig.java @@ -7,7 +7,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -87,4 +86,5 @@ public AccessDeniedHandler customAccessDeniedHandler() { public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } + } \ No newline at end of file