From 7cd65eab53ea71b667a237cdad52ebf607fd7c6a Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 18 May 2026 21:45:47 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20=EC=95=A1=EC=84=B8=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=A7=8C=EB=A3=8C=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=95=20=EB=B0=8F=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 2 +- .../global/oauth2/service/KakaoOauthService.java | 15 ++++----------- src/main/resources/application.yml | 2 +- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java index 55d1150d0..5d3137cdd 100644 --- a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java +++ b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java @@ -103,7 +103,7 @@ public BaseResponse registerMemberDetailInfo(@RequestBody @Valid MemberD @PostMapping("/auth/refresh") @Operation(summary = "토큰 재발급 API", - description = "액세스 토큰을 재발급 하고 리프레시 토큰 또한 만료일이 3일 이하로 남았을 경우 재발급 해주는 api입니다. 리프레시토큰은 헤더에 쿠키로 들어갑니다.") + description = "액세스 토큰과 리프레시 토큰을 모두 재발급합니다 (Refresh Token Rotation). 리프레시 토큰은 응답 쿠키로 전달됩니다.") public ResponseEntity refresh(@CookieValue("refreshToken") String refreshToken) { if (refreshToken == null || refreshToken.isBlank()) { diff --git a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java index 079cc69bc..fca2a973f 100644 --- a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java +++ b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java @@ -28,7 +28,6 @@ public class KakaoOauthService { private final MemberRepository memberRepository; private final JwtTokenProvider jwtTokenProvider; - private static final long EXPIRED = 7 * 24 * 60 * 60 * 1000L; @Transactional public KakaoLoginResponseDTO signup(String code) { @@ -125,21 +124,15 @@ public KakaoLoginResponseDTO createOtherDevToken() { ; } + @Transactional public TokenRefreshResponse validateMember(String refreshToken) { Member member = memberRepository.findByRefreshToken(refreshToken) .orElseThrow(() -> new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN)); - // 액세스 토큰 재발급 String newAccessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); + String newRefreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + member.setRefreshToken(newRefreshToken); - // 리프레시 토큰 만료가 3일 이하로 남은 경우 갱신 (sliding session) - if (jwtTokenProvider.isTokenExpiringSoon(refreshToken, EXPIRED)) { - String newRefreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); - member.setRefreshToken(newRefreshToken); - - return new TokenRefreshResponse(newAccessToken, newRefreshToken); - } - - return new TokenRefreshResponse(newAccessToken, refreshToken); + return new TokenRefreshResponse(newAccessToken, newRefreshToken); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 23feadeab..6a0916987 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -82,7 +82,7 @@ kakao: jwt: secret: ${JWT_SECRET_KEY} - access-token-validity: 3600000 + access-token-validity: 900000 refresh-token-validity: 1209600000 logging: From f8232e376b1b38b2330494351c53d688464a81a6 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 18 May 2026 22:01:30 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20refreshToken=EC=9D=84=20redis?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/domain/member/domain/Member.java | 7 - .../member/repository/MemberRepository.java | 3 - .../global/auth/RefreshTokenRepository.java | 38 +++ .../global/jwt/domain/JwtTokenProvider.java | 18 -- .../oauth2/service/KakaoOauthService.java | 289 +++++++++--------- ...05.18.00.00__drop_member_refresh_token.sql | 1 + 6 files changed, 190 insertions(+), 166 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java create mode 100644 src/main/resources/db/migration/V2026.05.18.00.00__drop_member_refresh_token.sql diff --git a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java index 1b5456858..988e0fdb5 100644 --- a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java +++ b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java @@ -51,8 +51,6 @@ public class Member extends BaseEntity { @Enumerated(EnumType.STRING) private MemberStatus isActive; - private String refreshToken; - @Column(nullable = false) private Long socialId; // 카카오에서 받아온 고유id @@ -175,14 +173,9 @@ public boolean hasDuplicateAddr(CreateMemberAddrRequestDTO requestDTO) { public void withdraw() { this.isActive = MemberStatus.INACTIVE; - this.refreshToken = null; this.fcmToken = null; } - public void setRefreshToken(String refreshToken) { - this.refreshToken = refreshToken; - } - // FCM 토큰 업데이트 메서드 public void updateFcmToken(String fcmToken) { this.fcmToken = fcmToken; diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java index cb87a6294..92a2f4d11 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java @@ -34,9 +34,6 @@ SELECT new map(m.id as id, m.memberName as name) FROM Member m Optional findBySocialId(Long socialId); - Optional findByRefreshToken(String refreshToken); - - @Query(""" SELECT m FROM Member m LEFT JOIN FETCH m.addresses addr diff --git a/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java b/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java new file mode 100644 index 000000000..fd177c297 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/auth/RefreshTokenRepository.java @@ -0,0 +1,38 @@ +package umc.cockple.demo.global.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; +import umc.cockple.demo.global.jwt.properties.JwtProperties; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class RefreshTokenRepository { + + private static final String KEY_PREFIX = "refresh:"; + + private final StringRedisTemplate stringRedisTemplate; + private final JwtProperties jwtProperties; + + public void save(String refreshToken, Long memberId) { + stringRedisTemplate.opsForValue().set( + KEY_PREFIX + refreshToken, + String.valueOf(memberId), + jwtProperties.getRefreshTokenValidity(), + TimeUnit.MILLISECONDS + ); + } + + public Optional findMemberIdByToken(String refreshToken) { + String value = stringRedisTemplate.opsForValue().get(KEY_PREFIX + refreshToken); + if (value == null) return Optional.empty(); + return Optional.of(Long.valueOf(value)); + } + + public void delete(String refreshToken) { + stringRedisTemplate.delete(KEY_PREFIX + refreshToken); + } +} diff --git a/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java b/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java index ab3f4a89a..020aeabc0 100644 --- a/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java +++ b/src/main/java/umc/cockple/demo/global/jwt/domain/JwtTokenProvider.java @@ -18,7 +18,6 @@ import umc.cockple.demo.global.security.domain.CustomUserDetails; import java.security.Key; -import java.time.Duration; import java.util.Date; @Component @@ -108,23 +107,6 @@ public boolean validateToken(String token) { } - public boolean isTokenExpiringSoon(String refreshToken, long thresholdMillis) { - try { - Claims claims = Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(refreshToken) - .getBody(); - - Date expiration = claims.getExpiration(); - long now = System.currentTimeMillis(); - - return expiration.getTime() - now < thresholdMillis; - } catch (JwtException e) { - throw new MemberException(MemberErrorCode.INVALID_TOKEN); - } - } - public Long getUserId(String token) { Claims claims = Jwts.parserBuilder() .setSigningKey(key) diff --git a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java index fca2a973f..dd36e905f 100644 --- a/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java +++ b/src/main/java/umc/cockple/demo/global/oauth2/service/KakaoOauthService.java @@ -1,138 +1,151 @@ -package umc.cockple.demo.global.oauth2.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseCookie; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.dto.kakao.KakaoLoginDTO; -import umc.cockple.demo.domain.member.enums.MemberStatus; -import umc.cockple.demo.domain.member.exception.MemberErrorCode; -import umc.cockple.demo.domain.member.exception.MemberException; -import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; -import umc.cockple.demo.global.jwt.domain.TokenRefreshResponse; -import umc.cockple.demo.global.oauth2.domain.KakaoClient; -import umc.cockple.demo.global.oauth2.domain.info.KakaoClientInfo; - -import java.time.Duration; -import java.util.Optional; - -import static umc.cockple.demo.domain.member.dto.kakao.KakaoLoginDTO.*; - -@Service -@RequiredArgsConstructor -public class KakaoOauthService { - - private final KakaoClient kakaoClient; - private final MemberRepository memberRepository; - private final JwtTokenProvider jwtTokenProvider; - - - @Transactional - public KakaoLoginResponseDTO signup(String code) { - // 1. accessToken 발급 - String kakaoAccessToken = kakaoClient.getAccessToken(code); - - // 2. 카카오에 사용자 정보 요청 - KakaoClientInfo info = kakaoClient.getClientInfo(kakaoAccessToken); - - // 3. 기존 유저 여부 확인 - Optional optionalMember = memberRepository.findBySocialId(info.kakaoId()); - boolean newMember = optionalMember.isEmpty(); - - Member member = optionalMember.orElseGet(() -> - memberRepository.save(Member.builder() - .socialId(info.kakaoId()) - .nickname(info.nickname()) - .isActive(MemberStatus.ACTIVE) - .build()) - ); - - if (member.getIsActive() == MemberStatus.INACTIVE) { - member.rejoin(); - newMember = true; - } - - // 4. jwt 발급 - String accessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); - String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); - - // 5. refresh는 db에 저장 - member.setRefreshToken(refreshToken); - - // jwt개발할 때 넣기 - return new KakaoLoginResponseDTO(accessToken, refreshToken, member.getId(), member.getNickname(), newMember); - } - - public void unlinkAccess(Member member) { - if (member.getSocialId() != null) { - try { - kakaoClient.unlinkByAdmin(member.getSocialId()); - } catch (Exception e) { - throw new MemberException(MemberErrorCode.OAUTH_UNLINK_FAIL); - } - } - - } - - public KakaoLoginResponseDTO createDevToken() { - // 특정 member 가져오기 - Member member = memberRepository.findById(1L) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - - // accessToken: 2주 만료 - String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname()); - - // refreshToken: 기본 만료 - String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); - - // refreshToken DB에 저장 - member.setRefreshToken(refreshToken); - - return KakaoLoginResponseDTO.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .memberId(member.getId()) - .nickname(member.getNickname()) - .isNewMember(false) - .build() - ; - } - - public KakaoLoginResponseDTO createOtherDevToken() { - // 특정 member 가져오기 - Member member = memberRepository.findById(2L) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - - // accessToken: 2주 만료 - String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname()); - - // refreshToken: 기본 만료 - String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); - - // refreshToken DB에 저장 - member.setRefreshToken(refreshToken); - - return KakaoLoginResponseDTO.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .memberId(member.getId()) - .nickname(member.getNickname()) - .isNewMember(false) - .build() - ; - } - - @Transactional - public TokenRefreshResponse validateMember(String refreshToken) { - Member member = memberRepository.findByRefreshToken(refreshToken) - .orElseThrow(() -> new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN)); - - String newAccessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); - String newRefreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); - member.setRefreshToken(newRefreshToken); - - return new TokenRefreshResponse(newAccessToken, newRefreshToken); - } -} +package umc.cockple.demo.global.oauth2.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.dto.kakao.KakaoLoginDTO; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.auth.RefreshTokenRepository; +import umc.cockple.demo.global.jwt.domain.JwtTokenProvider; +import umc.cockple.demo.global.jwt.domain.TokenRefreshResponse; +import umc.cockple.demo.global.oauth2.domain.KakaoClient; +import umc.cockple.demo.global.oauth2.domain.info.KakaoClientInfo; + +import java.util.Optional; + +import static umc.cockple.demo.domain.member.dto.kakao.KakaoLoginDTO.*; + +@Service +@RequiredArgsConstructor +public class KakaoOauthService { + + private final KakaoClient kakaoClient; + private final MemberRepository memberRepository; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional + public KakaoLoginResponseDTO signup(String code) { + // 1. accessToken 발급 + String kakaoAccessToken = kakaoClient.getAccessToken(code); + + // 2. 카카오에 사용자 정보 요청 + KakaoClientInfo info = kakaoClient.getClientInfo(kakaoAccessToken); + + // 3. 기존 유저 여부 확인 + Optional optionalMember = memberRepository.findBySocialId(info.kakaoId()); + boolean newMember = optionalMember.isEmpty(); + + Member member = optionalMember.orElseGet(() -> + memberRepository.save(Member.builder() + .socialId(info.kakaoId()) + .nickname(info.nickname()) + .isActive(MemberStatus.ACTIVE) + .build()) + ); + + if (member.getIsActive() == MemberStatus.INACTIVE) { + member.rejoin(); + newMember = true; + } + + // 4. jwt 발급 + String accessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + + // 5. refresh는 redis에 저장 + refreshTokenRepository.save(refreshToken, member.getId()); + + // jwt개발할 때 넣기 + return new KakaoLoginResponseDTO(accessToken, refreshToken, member.getId(), member.getNickname(), newMember); + } + + public void unlinkAccess(Member member) { + if (member.getSocialId() != null) { + try { + kakaoClient.unlinkByAdmin(member.getSocialId()); + } catch (Exception e) { + throw new MemberException(MemberErrorCode.OAUTH_UNLINK_FAIL); + } + } + + } + + public KakaoLoginResponseDTO createDevToken() { + // 특정 member 가져오기 + Member member = memberRepository.findById(1L) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // accessToken: 2주 만료 + String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname()); + + // refreshToken: 기본 만료 + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + + // refreshToken Redis에 저장 + refreshTokenRepository.save(refreshToken, member.getId()); + + return KakaoLoginResponseDTO.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .memberId(member.getId()) + .nickname(member.getNickname()) + .isNewMember(false) + .build() + ; + } + + public KakaoLoginResponseDTO createOtherDevToken() { + // 특정 member 가져오기 + Member member = memberRepository.findById(2L) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // accessToken: 2주 만료 + String accessToken = jwtTokenProvider.createDevToken(member.getId(), member.getNickname()); + + // refreshToken: 기본 만료 + String refreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + + // refreshToken Redis에 저장 + refreshTokenRepository.save(refreshToken, member.getId()); + + return KakaoLoginResponseDTO.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .memberId(member.getId()) + .nickname(member.getNickname()) + .isNewMember(false) + .build() + ; + } + + public TokenRefreshResponse validateMember(String refreshToken) { + // Redis에서 memberId 조회 + Long memberId = refreshTokenRepository.findMemberIdByToken(refreshToken) + .orElseThrow(() -> new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN)); + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 탈퇴한 회원 차단 + if (member.getIsActive() == MemberStatus.INACTIVE) { + throw new MemberException(MemberErrorCode.INVALID_REFRESH_TOKEN); + } + + // 기존 토큰 삭제 (Rotation) + refreshTokenRepository.delete(refreshToken); + + // 액세스 토큰 재발급 + String newAccessToken = jwtTokenProvider.createAccessToken(member.getId(), member.getNickname()); + + // 새 리프레시 토큰 발급 및 Redis 저장 + String newRefreshToken = jwtTokenProvider.createRefreshToken(member.getId(), member.getNickname()); + refreshTokenRepository.save(newRefreshToken, member.getId()); + + return new TokenRefreshResponse(newAccessToken, newRefreshToken); + } +} diff --git a/src/main/resources/db/migration/V2026.05.18.00.00__drop_member_refresh_token.sql b/src/main/resources/db/migration/V2026.05.18.00.00__drop_member_refresh_token.sql new file mode 100644 index 000000000..9fd08e0df --- /dev/null +++ b/src/main/resources/db/migration/V2026.05.18.00.00__drop_member_refresh_token.sql @@ -0,0 +1 @@ +ALTER TABLE member DROP COLUMN refresh_token; From cf672d04e1d3421fc59dee9ec787aa1f89805a49 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 18 May 2026 22:12:35 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=ED=83=88=ED=87=B4=2014=EC=9D=BC=20?= =?UTF-8?q?=ED=9B=84=20member=20hard=20delete=EB=90=98=EB=8F=84=EB=A1=9D?= =?UTF-8?q?=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/umc/cockple/demo/Application.java | 2 + .../member/controller/MemberController.java | 6 ++- .../demo/domain/member/domain/Member.java | 4 ++ .../member/repository/MemberRepository.java | 4 ++ .../WithdrawnMemberCleanupScheduler.java | 37 +++++++++++++++++++ .../member/service/MemberCommandService.java | 11 +++++- ...026.05.18.22.00__add_member_deleted_at.sql | 1 + 7 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java create mode 100644 src/main/resources/db/migration/V2026.05.18.22.00__add_member_deleted_at.sql diff --git a/src/main/java/umc/cockple/demo/Application.java b/src/main/java/umc/cockple/demo/Application.java index 934d856b5..5fbaf64e1 100644 --- a/src/main/java/umc/cockple/demo/Application.java +++ b/src/main/java/umc/cockple/demo/Application.java @@ -4,10 +4,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing @EnableCaching +@EnableScheduling public class Application { public static void main(String[] args) { diff --git a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java index 5d3137cdd..7799e0844 100644 --- a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java +++ b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java @@ -130,11 +130,13 @@ public ResponseEntity refresh(@CookieValue("refreshToken") @PatchMapping(value = "/member") @Operation(summary = "회원 탈퇴 API", description = "사용자 회원 탈퇴") - public BaseResponse withdraw(@AuthenticationPrincipal CustomUserDetails member) { + public BaseResponse withdraw( + @AuthenticationPrincipal CustomUserDetails member, + @CookieValue(value = "refreshToken", required = false) String refreshToken) { Long memberId = member.getMemberId(); - memberCommandService.withdrawMember(memberId); + memberCommandService.withdrawMember(memberId, refreshToken); return BaseResponse.success(CommonSuccessCode.NO_CONTENT); } diff --git a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java index 988e0fdb5..6370cb940 100644 --- a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java +++ b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java @@ -17,6 +17,7 @@ import umc.cockple.demo.global.common.BaseEntity; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.Period; import java.util.ArrayList; import java.util.List; @@ -51,6 +52,8 @@ public class Member extends BaseEntity { @Enumerated(EnumType.STRING) private MemberStatus isActive; + private LocalDateTime deletedAt; + @Column(nullable = false) private Long socialId; // 카카오에서 받아온 고유id @@ -173,6 +176,7 @@ public boolean hasDuplicateAddr(CreateMemberAddrRequestDTO requestDTO) { public void withdraw() { this.isActive = MemberStatus.INACTIVE; + this.deletedAt = LocalDateTime.now(); this.fcmToken = null; } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java index 92a2f4d11..b05f304c7 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java @@ -4,7 +4,9 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Optional; @@ -34,6 +36,8 @@ SELECT new map(m.id as id, m.memberName as name) FROM Member m Optional findBySocialId(Long socialId); + List findAllByIsActiveAndDeletedAtBefore(MemberStatus isActive, LocalDateTime threshold); + @Query(""" SELECT m FROM Member m LEFT JOIN FETCH m.addresses addr diff --git a/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java b/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java new file mode 100644 index 000000000..9d56eeb65 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java @@ -0,0 +1,37 @@ +package umc.cockple.demo.domain.member.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import umc.cockple.demo.domain.member.repository.MemberRepository; + +import java.time.LocalDateTime; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class WithdrawnMemberCleanupScheduler { + + private final MemberRepository memberRepository; + + // 매일 새벽 3시에 탈퇴 후 14일이 지난 회원 데이터 하드 딜리트 + @Scheduled(cron = "0 0 3 * * *") + @Transactional + public void deleteExpiredWithdrawnMembers() { + LocalDateTime threshold = LocalDateTime.now().minusDays(14); + List targets = memberRepository.findAllByIsActiveAndDeletedAtBefore( + MemberStatus.INACTIVE, threshold); + + if (targets.isEmpty()) { + return; + } + + memberRepository.deleteAll(targets); + log.info("[CLEANUP] 탈퇴 회원 하드 딜리트 완료 - {}명", targets.size()); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java index 7457c6020..b245d336e 100644 --- a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java @@ -19,6 +19,7 @@ import java.time.LocalDate; import java.time.LocalTime; import java.util.List; +import umc.cockple.demo.global.auth.RefreshTokenRepository; import umc.cockple.demo.global.oauth2.service.KakaoOauthService; import static umc.cockple.demo.domain.member.dto.CreateMemberAddrDTO.*; @@ -38,6 +39,7 @@ public class MemberCommandService { private final KakaoOauthService kakaoOauthService; private final FileService fileService; + private final RefreshTokenRepository refreshTokenRepository; // ==================== 회원 관련 =================== @@ -76,7 +78,7 @@ public void memberDetailInfo(Long memberId, MemberDetailInfoRequestDTO requestDT } - public void withdrawMember(Long memberId) { + public void withdrawMember(Long memberId, String refreshToken) { // 회원 찾기 Member member = findByMemberId(memberId); @@ -91,8 +93,13 @@ public void withdrawMember(Long memberId) { // 카카오 연결 끊기 kakaoOauthService.unlinkAccess(member); - // 활성화 여부 해제, 리프레시 토큰 삭제 + // 활성화 여부 해제 member.withdraw(); + + // Redis 리프레시 토큰 즉시 삭제 + if (refreshToken != null) { + refreshTokenRepository.delete(refreshToken); + } } diff --git a/src/main/resources/db/migration/V2026.05.18.22.00__add_member_deleted_at.sql b/src/main/resources/db/migration/V2026.05.18.22.00__add_member_deleted_at.sql new file mode 100644 index 000000000..d78e212ba --- /dev/null +++ b/src/main/resources/db/migration/V2026.05.18.22.00__add_member_deleted_at.sql @@ -0,0 +1 @@ +ALTER TABLE member ADD COLUMN deleted_at DATETIME; From 3015e015ca805e37e567c416dd101d66b7135a00 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 18 May 2026 22:16:14 +0900 Subject: [PATCH 4/7] =?UTF-8?q?test:=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MemberCommandServiceTest.java | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java index f74620c03..d308e771c 100644 --- a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java @@ -28,6 +28,7 @@ import umc.cockple.demo.global.enums.Keyword; import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.global.auth.RefreshTokenRepository; import umc.cockple.demo.global.oauth2.service.KakaoOauthService; import umc.cockple.demo.support.fixture.MemberAddrFixture; import umc.cockple.demo.support.fixture.MemberFixture; @@ -62,6 +63,7 @@ class MemberCommandServiceTest { @Mock private ChatRoomMemberRepository chatRoomMemberRepository; @Mock private FileService fileService; @Mock private KakaoOauthService kakaoOauthService; + @Mock private RefreshTokenRepository refreshTokenRepository; private Member normalMember; @@ -603,7 +605,7 @@ class WithdrawMember { given(memberRepository.findById(normalMember.getId())).willReturn(Optional.of(normalMember)); // when - memberCommandService.withdrawMember(normalMember.getId()); + memberCommandService.withdrawMember(normalMember.getId(), null); // then then(memberExerciseRepository).should() @@ -624,7 +626,7 @@ class Success { .willReturn(Optional.of(normalMember)); // when - memberCommandService.withdrawMember(normalMember.getId()); + memberCommandService.withdrawMember(normalMember.getId(), null); // then then(memberExerciseRepository).should() @@ -635,19 +637,18 @@ class Success { } @Test - @DisplayName("탈퇴_후_회원_상태가_INACTIVE가_되고_refreshToken이_null이_된다") - void 탈퇴_후_회원_상태가_INACTIVE가_되고_refreshToken이_null이_된다() { + @DisplayName("탈퇴_후_회원_상태가_INACTIVE가_되고_deletedAt이_설정된다") + void 탈퇴_후_회원_상태가_INACTIVE가_되고_deletedAt이_설정된다() { // given - normalMember.setRefreshToken("existing-refresh-token"); given(memberRepository.findById(normalMember.getId())) .willReturn(Optional.of(normalMember)); // when - memberCommandService.withdrawMember(normalMember.getId()); + memberCommandService.withdrawMember(normalMember.getId(), null); // then assertThat(normalMember.getIsActive()).isEqualTo(MemberStatus.INACTIVE); - assertThat(normalMember.getRefreshToken()).isNull(); + assertThat(normalMember.getDeletedAt()).isNotNull(); } @Test @@ -658,7 +659,7 @@ class Success { .willReturn(Optional.of(normalMember)); // when - memberCommandService.withdrawMember(normalMember.getId()); + memberCommandService.withdrawMember(normalMember.getId(), null); // then then(kakaoOauthService).should().unlinkAccess(normalMember); @@ -677,7 +678,7 @@ class Failure { .willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> memberCommandService.withdrawMember(999L)) + assertThatThrownBy(() -> memberCommandService.withdrawMember(999L, null)) .isInstanceOf(MemberException.class) .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); } @@ -693,7 +694,7 @@ class Failure { .willReturn(Optional.of(withdrawnMember)); // when & then - assertThatThrownBy(() -> memberCommandService.withdrawMember(withdrawnMember.getId())) + assertThatThrownBy(() -> memberCommandService.withdrawMember(withdrawnMember.getId(), null)) .isInstanceOf(MemberException.class) .hasFieldOrPropertyWithValue("code", MemberErrorCode.ALREADY_WITHDRAW); } @@ -753,7 +754,7 @@ class Failure { .willReturn(Optional.of(normalMember)); // when - memberCommandService.withdrawMember(normalMember.getId()); + memberCommandService.withdrawMember(normalMember.getId(), null); // then: 예외 없이 탈퇴 처리됨 assertThat(normalMember.getIsActive()).isEqualTo(MemberStatus.INACTIVE); From fc5b03292cf23ca5f0762b4ec34882d0405c4a7d Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 18 May 2026 22:42:33 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=ED=83=88=ED=87=B4=EC=8B=9C=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20senderId=EB=A5=BC=20null=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ChatMessageRepository.java | 6 ++++++ .../member/repository/MemberRepository.java | 8 +++++++- .../repository/MemberTermsRepository.java | 9 +++++++++ .../WithdrawnMemberCleanupScheduler.java | 19 +++++++++++++++++++ .../PartyJoinRequestRepository.java | 1 + 5 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/main/java/umc/cockple/demo/domain/member/repository/MemberTermsRepository.java diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java index 6e511a3be..8c72f379e 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java @@ -2,6 +2,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.chat.domain.ChatMessage; @@ -49,4 +50,9 @@ List findByRoomIdAndIdLessThanOrderByCreatedAtDesc( Pageable pageable); int countByChatRoomId(Long chatRoomId); + + // 탈퇴 회원 하드 딜리트 전 sender_id null 처리 (메시지 보존) + @Modifying + @Query("UPDATE ChatMessage m SET m.sender = null WHERE m.sender.id = :memberId") + void nullifySenderByMemberId(@Param("memberId") Long memberId); } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java index b05f304c7..3daa54345 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java @@ -36,7 +36,13 @@ SELECT new map(m.id as id, m.memberName as name) FROM Member m Optional findBySocialId(Long socialId); - List findAllByIsActiveAndDeletedAtBefore(MemberStatus isActive, LocalDateTime threshold); + @Query(""" + SELECT m FROM Member m + LEFT JOIN FETCH m.profileImg + WHERE m.isActive = :isActive + AND m.deletedAt < :threshold + """) + List findAllByIsActiveAndDeletedAtBefore(@Param("isActive") MemberStatus isActive, @Param("threshold") LocalDateTime threshold); @Query(""" SELECT m FROM Member m diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberTermsRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberTermsRepository.java new file mode 100644 index 000000000..881576fe4 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberTermsRepository.java @@ -0,0 +1,9 @@ +package umc.cockple.demo.domain.member.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberTerms; + +public interface MemberTermsRepository extends JpaRepository { + void deleteByMember(Member member); +} diff --git a/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java b/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java index 9d56eeb65..ed9a40165 100644 --- a/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java +++ b/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java @@ -5,9 +5,13 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.enums.MemberStatus; import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.member.repository.MemberTermsRepository; +import umc.cockple.demo.domain.party.repository.PartyJoinRequestRepository; import java.time.LocalDateTime; import java.util.List; @@ -18,6 +22,10 @@ public class WithdrawnMemberCleanupScheduler { private final MemberRepository memberRepository; + private final ChatMessageRepository chatMessageRepository; + private final PartyJoinRequestRepository partyJoinRequestRepository; + private final MemberTermsRepository memberTermsRepository; + private final FileService fileService; // 매일 새벽 3시에 탈퇴 후 14일이 지난 회원 데이터 하드 딜리트 @Scheduled(cron = "0 0 3 * * *") @@ -31,6 +39,17 @@ public void deleteExpiredWithdrawnMembers() { return; } + for (Member member : targets) { + // S3 프로필 이미지 삭제 + if (member.getProfileImg() != null) { + fileService.delete(member.getProfileImg().getImgKey()); + } + // cascade 없는 FK 관계 수동 처리 + chatMessageRepository.nullifySenderByMemberId(member.getId()); // 메시지 보존, sender만 null + partyJoinRequestRepository.deleteByMember(member); + memberTermsRepository.deleteByMember(member); + } + memberRepository.deleteAll(targets); log.info("[CLEANUP] 탈퇴 회원 하드 딜리트 완료 - {}명", targets.size()); } diff --git a/src/main/java/umc/cockple/demo/domain/party/repository/PartyJoinRequestRepository.java b/src/main/java/umc/cockple/demo/domain/party/repository/PartyJoinRequestRepository.java index 913005db5..689535905 100644 --- a/src/main/java/umc/cockple/demo/domain/party/repository/PartyJoinRequestRepository.java +++ b/src/main/java/umc/cockple/demo/domain/party/repository/PartyJoinRequestRepository.java @@ -11,4 +11,5 @@ public interface PartyJoinRequestRepository extends JpaRepository { boolean existsByPartyAndMemberAndStatus(Party party, Member member, RequestStatus requestStatus); Slice findByPartyAndStatus(Party party, RequestStatus status, Pageable pageable); + void deleteByMember(Member member); } From 669a2cdd9b1800e3cfce6cdc2a460603edaedca3 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 18 May 2026 22:53:12 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20=ED=95=98=EB=93=9C=EB=94=9C?= =?UTF-8?q?=EB=A6=AC=ED=8A=B8=20=EC=8B=9C=20bulk=EB=A1=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(N+?= =?UTF-8?q?1=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExerciseBookmarkRepository.java | 5 +- .../repository/PartyBookmarkRepository.java | 5 ++ .../repository/ChatMessageRepository.java | 4 +- .../repository/ChatRoomMemberRepository.java | 4 ++ .../MessageReadStatusRepository.java | 4 ++ .../contest/repository/ContestRepository.java | 13 ++++ .../repository/MemberAddrRepository.java | 8 +++ .../repository/MemberExerciseRepository.java | 4 ++ .../repository/MemberKeywordRepository.java | 7 ++ .../repository/MemberPartyRepository.java | 5 ++ .../member/repository/MemberRepository.java | 5 ++ .../repository/MemberTermsRepository.java | 11 +++- .../repository/ProfileImgRepository.java | 16 +++++ .../WithdrawnMemberCleanupScheduler.java | 66 ++++++++++++++----- .../repository/NotificationRepository.java | 4 ++ .../PartyJoinRequestRepository.java | 10 ++- 16 files changed, 150 insertions(+), 21 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/domain/member/repository/ProfileImgRepository.java diff --git a/src/main/java/umc/cockple/demo/domain/bookmark/repository/ExerciseBookmarkRepository.java b/src/main/java/umc/cockple/demo/domain/bookmark/repository/ExerciseBookmarkRepository.java index 02307a394..cfab07fcb 100644 --- a/src/main/java/umc/cockple/demo/domain/bookmark/repository/ExerciseBookmarkRepository.java +++ b/src/main/java/umc/cockple/demo/domain/bookmark/repository/ExerciseBookmarkRepository.java @@ -1,6 +1,7 @@ package umc.cockple.demo.domain.bookmark.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark; @@ -32,5 +33,7 @@ List findAllExerciseIdsByMemberIdAndExerciseIds( Optional findFirstByMemberOrderByCreatedAtAsc(Member member); - + @Modifying + @Query("DELETE FROM ExerciseBookmark eb WHERE eb.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/bookmark/repository/PartyBookmarkRepository.java b/src/main/java/umc/cockple/demo/domain/bookmark/repository/PartyBookmarkRepository.java index 75059b2d4..1567f9f92 100644 --- a/src/main/java/umc/cockple/demo/domain/bookmark/repository/PartyBookmarkRepository.java +++ b/src/main/java/umc/cockple/demo/domain/bookmark/repository/PartyBookmarkRepository.java @@ -1,6 +1,7 @@ package umc.cockple.demo.domain.bookmark.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.bookmark.domain.PartyBookmark; @@ -37,4 +38,8 @@ public interface PartyBookmarkRepository extends JpaRepository findAllByMember(Member member); Optional findFirstByMemberOrderByCreatedAtAsc(Member member); + + @Modifying + @Query("DELETE FROM PartyBookmark pb WHERE pb.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java index 8c72f379e..a3d9b0fbc 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java @@ -53,6 +53,6 @@ List findByRoomIdAndIdLessThanOrderByCreatedAtDesc( // 탈퇴 회원 하드 딜리트 전 sender_id null 처리 (메시지 보존) @Modifying - @Query("UPDATE ChatMessage m SET m.sender = null WHERE m.sender.id = :memberId") - void nullifySenderByMemberId(@Param("memberId") Long memberId); + @Query("UPDATE ChatMessage m SET m.sender = null WHERE m.sender.id IN :memberIds") + void nullifySenderByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java index 14884030a..d3b8f30f2 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java @@ -74,6 +74,10 @@ List findByChatRoomIdAndStatusWithMember( @Param("status") ChatRoomMemberStatus status ); + @Modifying + @Query("DELETE FROM ChatRoomMember crm WHERE crm.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); + @Query(""" SELECT counterPart FROM ChatRoomMember counterPart WHERE counterPart.chatRoom.type = 'DIRECT' diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java index 9392be1ea..628f8ef87 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java @@ -63,4 +63,8 @@ int countUnreadByMessageId( ORDER BY mrs.chatMessageId ASC """) List findUnreadMessageIdsByMember(Long chatRoomId, Long memberId); + + @Modifying + @Query("DELETE FROM MessageReadStatus mrs WHERE mrs.memberId IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/contest/repository/ContestRepository.java b/src/main/java/umc/cockple/demo/domain/contest/repository/ContestRepository.java index 799fab005..a8b61b32b 100644 --- a/src/main/java/umc/cockple/demo/domain/contest/repository/ContestRepository.java +++ b/src/main/java/umc/cockple/demo/domain/contest/repository/ContestRepository.java @@ -1,6 +1,7 @@ package umc.cockple.demo.domain.contest.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.contest.domain.Contest; @@ -24,4 +25,16 @@ public interface ContestRepository extends JpaRepository { @Query("SELECT COUNT(c) FROM Contest c WHERE c.member.id = :memberId AND c.medalType = 'BRONZE'") int countBronzeMedalsByMemberId(@Param("memberId") Long memberId); + + @Modifying + @Query("DELETE FROM ContestVideo cv WHERE cv.contest.id IN (SELECT c.id FROM Contest c WHERE c.member.id IN :memberIds)") + void deleteContestVideosByMemberIds(@Param("memberIds") List memberIds); + + @Modifying + @Query("DELETE FROM ContestImg ci WHERE ci.contest.id IN (SELECT c.id FROM Contest c WHERE c.member.id IN :memberIds)") + void deleteContestImgsByMemberIds(@Param("memberIds") List memberIds); + + @Modifying + @Query("DELETE FROM Contest c WHERE c.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberAddrRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberAddrRepository.java index 60c8581ab..d85950dc2 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberAddrRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberAddrRepository.java @@ -1,11 +1,19 @@ package umc.cockple.demo.domain.member.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberAddr; +import java.util.List; import java.util.Optional; public interface MemberAddrRepository extends JpaRepository { Optional findByMemberAndIsMain(Member member, Boolean isMain); + + @Modifying + @Query("DELETE FROM MemberAddr ma WHERE ma.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberExerciseRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberExerciseRepository.java index 4f86a2d58..d898d4fb9 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberExerciseRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberExerciseRepository.java @@ -61,4 +61,8 @@ SELECT me.member.id, MAX(e.date) List findLastExerciseDateByMemberIdsAndPartyId( @Param("memberIds") List memberIds, @Param("partyId") Long partyId); + + @Modifying + @Query("DELETE FROM MemberExercise me WHERE me.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java index 930259ffb..d00fa020d 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java @@ -1,6 +1,9 @@ package umc.cockple.demo.domain.member.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberKeyword; @@ -11,4 +14,8 @@ public interface MemberKeywordRepository extends JpaRepository findAllByMemberId(Long memberId); + + @Modifying + @Query("DELETE FROM MemberKeyword mk WHERE mk.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java index a88a73790..07ba28cd4 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberPartyRepository.java @@ -3,6 +3,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.member.domain.Member; @@ -56,4 +57,8 @@ List findAllPartyIdsByMemberAndPartyIds(@Param("memberId") Long memberId, List findAllByPartyIdWithMember(@Param("partyId") Long partyId); Optional findByPartyIdAndRole(Long partyId, Role role); + + @Modifying + @Query("DELETE FROM MemberParty mp WHERE mp.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java index 3daa54345..55b06f18c 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberRepository.java @@ -1,6 +1,7 @@ package umc.cockple.demo.domain.member.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.member.domain.Member; @@ -44,6 +45,10 @@ SELECT new map(m.id as id, m.memberName as name) FROM Member m """) List findAllByIsActiveAndDeletedAtBefore(@Param("isActive") MemberStatus isActive, @Param("threshold") LocalDateTime threshold); + @Modifying + @Query("DELETE FROM Member m WHERE m.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); + @Query(""" SELECT m FROM Member m LEFT JOIN FETCH m.addresses addr diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberTermsRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberTermsRepository.java index 881576fe4..cfa1b3d23 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberTermsRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberTermsRepository.java @@ -1,9 +1,16 @@ package umc.cockple.demo.domain.member.repository; import org.springframework.data.jpa.repository.JpaRepository; -import umc.cockple.demo.domain.member.domain.Member; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.member.domain.MemberTerms; +import java.util.List; + public interface MemberTermsRepository extends JpaRepository { - void deleteByMember(Member member); + + @Modifying + @Query("DELETE FROM MemberTerms mt WHERE mt.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/ProfileImgRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/ProfileImgRepository.java new file mode 100644 index 000000000..6a6160d2e --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/member/repository/ProfileImgRepository.java @@ -0,0 +1,16 @@ +package umc.cockple.demo.domain.member.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import umc.cockple.demo.domain.member.domain.ProfileImg; + +import java.util.List; + +public interface ProfileImgRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM ProfileImg pi WHERE pi.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); +} diff --git a/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java b/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java index ed9a40165..abca82635 100644 --- a/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java +++ b/src/main/java/umc/cockple/demo/domain/member/scheduler/WithdrawnMemberCleanupScheduler.java @@ -5,12 +5,17 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository; import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; +import umc.cockple.demo.domain.contest.repository.ContestRepository; import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.enums.MemberStatus; -import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.domain.member.repository.MemberTermsRepository; +import umc.cockple.demo.domain.member.repository.*; +import umc.cockple.demo.domain.notification.repository.NotificationRepository; import umc.cockple.demo.domain.party.repository.PartyJoinRequestRepository; import java.time.LocalDateTime; @@ -22,9 +27,20 @@ public class WithdrawnMemberCleanupScheduler { private final MemberRepository memberRepository; + private final ProfileImgRepository profileImgRepository; + private final MemberKeywordRepository memberKeywordRepository; + private final MemberAddrRepository memberAddrRepository; + private final MemberPartyRepository memberPartyRepository; + private final MemberExerciseRepository memberExerciseRepository; + private final MemberTermsRepository memberTermsRepository; + private final ContestRepository contestRepository; + private final NotificationRepository notificationRepository; + private final ExerciseBookmarkRepository exerciseBookmarkRepository; + private final PartyBookmarkRepository partyBookmarkRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; private final ChatMessageRepository chatMessageRepository; + private final MessageReadStatusRepository messageReadStatusRepository; private final PartyJoinRequestRepository partyJoinRequestRepository; - private final MemberTermsRepository memberTermsRepository; private final FileService fileService; // 매일 새벽 3시에 탈퇴 후 14일이 지난 회원 데이터 하드 딜리트 @@ -39,18 +55,38 @@ public void deleteExpiredWithdrawnMembers() { return; } - for (Member member : targets) { - // S3 프로필 이미지 삭제 - if (member.getProfileImg() != null) { - fileService.delete(member.getProfileImg().getImgKey()); - } - // cascade 없는 FK 관계 수동 처리 - chatMessageRepository.nullifySenderByMemberId(member.getId()); // 메시지 보존, sender만 null - partyJoinRequestRepository.deleteByMember(member); - memberTermsRepository.deleteByMember(member); - } + List memberIds = targets.stream().map(Member::getId).toList(); + + // S3 프로필 이미지 삭제 (DB 삭제 전에 처리) + targets.stream() + .filter(m -> m.getProfileImg() != null) + .forEach(m -> fileService.delete(m.getProfileImg().getImgKey())); + + // Contest 자식 테이블 먼저 (FK: contest_id) + contestRepository.deleteContestVideosByMemberIds(memberIds); + contestRepository.deleteContestImgsByMemberIds(memberIds); + contestRepository.deleteByMemberIds(memberIds); + + // sender_id null 처리 (메시지 보존) + chatMessageRepository.nullifySenderByMemberIds(memberIds); + + // 나머지 자식 테이블 일괄 삭제 + messageReadStatusRepository.deleteByMemberIds(memberIds); + partyJoinRequestRepository.deleteByMemberIds(memberIds); + memberTermsRepository.deleteByMemberIds(memberIds); + notificationRepository.deleteByMemberIds(memberIds); + memberKeywordRepository.deleteByMemberIds(memberIds); + memberAddrRepository.deleteByMemberIds(memberIds); + chatRoomMemberRepository.deleteByMemberIds(memberIds); + exerciseBookmarkRepository.deleteByMemberIds(memberIds); + partyBookmarkRepository.deleteByMemberIds(memberIds); + memberPartyRepository.deleteByMemberIds(memberIds); + memberExerciseRepository.deleteByMemberIds(memberIds); + profileImgRepository.deleteByMemberIds(memberIds); + + // Member 일괄 삭제 + memberRepository.deleteByMemberIds(memberIds); - memberRepository.deleteAll(targets); - log.info("[CLEANUP] 탈퇴 회원 하드 딜리트 완료 - {}명", targets.size()); + log.info("[CLEANUP] 탈퇴 회원 하드 딜리트 완료 - {}명", memberIds.size()); } } diff --git a/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java b/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java index de619694d..66923423c 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java @@ -20,4 +20,8 @@ public interface NotificationRepository extends JpaRepository memberIds); } diff --git a/src/main/java/umc/cockple/demo/domain/party/repository/PartyJoinRequestRepository.java b/src/main/java/umc/cockple/demo/domain/party/repository/PartyJoinRequestRepository.java index 689535905..cd2c281ea 100644 --- a/src/main/java/umc/cockple/demo/domain/party/repository/PartyJoinRequestRepository.java +++ b/src/main/java/umc/cockple/demo/domain/party/repository/PartyJoinRequestRepository.java @@ -3,13 +3,21 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.party.domain.Party; import umc.cockple.demo.domain.party.domain.PartyJoinRequest; import umc.cockple.demo.domain.party.enums.RequestStatus; +import java.util.List; + public interface PartyJoinRequestRepository extends JpaRepository { boolean existsByPartyAndMemberAndStatus(Party party, Member member, RequestStatus requestStatus); Slice findByPartyAndStatus(Party party, RequestStatus status, Pageable pageable); - void deleteByMember(Member member); + + @Modifying + @Query("DELETE FROM PartyJoinRequest pjr WHERE pjr.member.id IN :memberIds") + void deleteByMemberIds(@Param("memberIds") List memberIds); } From d430148bce2921e0f99723ef4935b7bc40d5a679 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 18 May 2026 22:57:10 +0900 Subject: [PATCH 7/7] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9D=B8=EC=9E=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/domain/member/service/MemberCommandServiceTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java index d308e771c..956541ce4 100644 --- a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java @@ -714,7 +714,7 @@ class Failure { .willReturn(Optional.of(normalMember)); // when & then - assertThatThrownBy(() -> memberCommandService.withdrawMember(normalMember.getId())) + assertThatThrownBy(() -> memberCommandService.withdrawMember(normalMember.getId(), null)) .isInstanceOf(MemberException.class) .hasFieldOrPropertyWithValue("code", MemberErrorCode.MANAGER_CANNOT_LEAVE); } @@ -734,7 +734,7 @@ class Failure { .willReturn(Optional.of(normalMember)); // when & then - assertThatThrownBy(() -> memberCommandService.withdrawMember(normalMember.getId())) + assertThatThrownBy(() -> memberCommandService.withdrawMember(normalMember.getId(), null)) .isInstanceOf(MemberException.class) .hasFieldOrPropertyWithValue("code", MemberErrorCode.SUBMANAGER_CANNOT_LEAVE); }