diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java index 16eb6cc8..fb0f1a2e 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java @@ -25,7 +25,7 @@ public class History { private Long historyId; @ManyToOne - @JoinColumn(name = "member_id") + @JoinColumn(name = "member_id", foreignKey = @ForeignKey(name = "FK_HISTORY_MEMBER")) private Member memberId; @OneToMany(mappedBy = "historyId", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java index 30828254..ec094d4b 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java @@ -3,6 +3,9 @@ import com.ai.lawyer.domain.chatbot.entity.History; import com.ai.lawyer.domain.member.entity.Member; 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 org.springframework.stereotype.Repository; import java.util.List; @@ -14,4 +17,12 @@ public interface HistoryRepository extends JpaRepository { History findByHistoryIdAndMemberId(Long roomId, Member memberId); + /** + * member_id로 채팅 히스토리 삭제 (회원 탈퇴 시 사용) + * Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제 + */ + @Modifying + @Query("DELETE FROM History h WHERE h.memberId.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); + } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java index 0806e9b5..e2f6f62a 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java @@ -5,6 +5,9 @@ import com.ai.lawyer.domain.member.entity.OAuth2Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; +import com.ai.lawyer.domain.post.repository.PostRepository; +import com.ai.lawyer.domain.poll.repository.PollVoteRepository; +import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; import com.ai.lawyer.global.jwt.TokenProvider; import com.ai.lawyer.global.jwt.CookieUtil; import com.ai.lawyer.global.email.service.EmailService; @@ -27,6 +30,9 @@ public class MemberService { private final CookieUtil cookieUtil; private final EmailService emailService; private final EmailAuthService emailAuthService; + private final PostRepository postRepository; + private final PollVoteRepository pollVoteRepository; + private final HistoryRepository historyRepository; public MemberService( MemberRepository memberRepository, @@ -34,13 +40,19 @@ public MemberService( TokenProvider tokenProvider, CookieUtil cookieUtil, EmailService emailService, - EmailAuthService emailAuthService) { + EmailAuthService emailAuthService, + PostRepository postRepository, + PollVoteRepository pollVoteRepository, + HistoryRepository historyRepository) { this.memberRepository = memberRepository; this.passwordEncoder = passwordEncoder; this.tokenProvider = tokenProvider; this.cookieUtil = cookieUtil; this.emailService = emailService; this.emailAuthService = emailAuthService; + this.postRepository = postRepository; + this.pollVoteRepository = pollVoteRepository; + this.historyRepository = historyRepository; } @org.springframework.beans.factory.annotation.Autowired(required = false) @@ -186,24 +198,81 @@ public String getLoginIdByMemberId(Long memberId) { @Transactional public void deleteMember(String loginId) { - // Member 또는 OAuth2Member 삭제 + log.info("회원 탈퇴 시작: loginId={}", loginId); + + // 1. Member 또는 OAuth2Member 조회하여 memberId 가져오기 + Long memberId = null; + boolean isRegularMember = false; + java.util.Optional regularMember = memberRepository.findByLoginId(loginId); if (regularMember.isPresent()) { - memberRepository.delete(regularMember.get()); - log.info("일반 회원 삭제 완료: loginId={}", loginId); + memberId = regularMember.get().getMemberId(); + isRegularMember = true; + log.info("일반 회원 찾음: loginId={}, memberId={}", loginId, memberId); + } else if (oauth2MemberRepository != null) { + java.util.Optional oauth2Member = oauth2MemberRepository.findByLoginId(loginId); + if (oauth2Member.isPresent()) { + memberId = oauth2Member.get().getMemberId(); + log.info("OAuth2 회원 찾음: loginId={}, memberId={}", loginId, memberId); + } + } + + if (memberId == null) { + log.warn("삭제할 회원을 찾을 수 없습니다: loginId={}", loginId); return; } - if (oauth2MemberRepository != null) { + // 2. 연관된 데이터 명시적 삭제 (순서 중요: FK 제약조건 고려) + log.info("연관 데이터 삭제 시작: memberId={}", memberId); + + // 2-1. 채팅 히스토리 삭제 (Chat 엔티티도 cascade로 함께 삭제됨) + try { + historyRepository.deleteByMemberIdValue(memberId); + log.info("채팅 히스토리 삭제 완료: memberId={}", memberId); + } catch (Exception e) { + log.error("채팅 히스토리 삭제 실패: memberId={}, error={}", memberId, e.getMessage()); + } + + // 2-2. 투표 내역 삭제 + try { + pollVoteRepository.deleteByMemberIdValue(memberId); + log.info("투표 내역 삭제 완료: memberId={}", memberId); + } catch (Exception e) { + log.error("투표 내역 삭제 실패: memberId={}, error={}", memberId, e.getMessage()); + } + + // 2-3. 게시글 삭제 (Poll 엔티티도 cascade로 함께 삭제됨) + try { + postRepository.deleteByMemberIdValue(memberId); + log.info("게시글 삭제 완료: memberId={}", memberId); + } catch (Exception e) { + log.error("게시글 삭제 실패: memberId={}, error={}", memberId, e.getMessage()); + } + + // 3. Redis 토큰 삭제 + try { + tokenProvider.deleteAllTokens(loginId); + log.info("Redis 토큰 삭제 완료: loginId={}", loginId); + } catch (Exception e) { + log.error("Redis 토큰 삭제 실패: loginId={}, error={}", loginId, e.getMessage()); + } + + // 4. 회원 정보 삭제 + final Long finalMemberId = memberId; + if (isRegularMember) { + regularMember.ifPresent(member -> { + memberRepository.delete(member); + log.info("일반 회원 삭제 완료: loginId={}, memberId={}", loginId, finalMemberId); + }); + } else if (oauth2MemberRepository != null) { java.util.Optional oauth2Member = oauth2MemberRepository.findByLoginId(loginId); - if (oauth2Member.isPresent()) { - oauth2MemberRepository.delete(oauth2Member.get()); - log.info("OAuth2 회원 삭제 완료: loginId={}", loginId); - return; - } + oauth2Member.ifPresent(member -> { + oauth2MemberRepository.delete(member); + log.info("OAuth2 회원 삭제 완료: loginId={}, memberId={}", loginId, finalMemberId); + }); } - log.warn("삭제할 회원을 찾을 수 없습니다: loginId={}", loginId); + log.info("회원 탈퇴 완료: loginId={}, memberId={}", loginId, finalMemberId); } public void sendCodeToEmailByLoginId(String loginId) { diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java index a2a6138b..f86b32bf 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java @@ -2,6 +2,9 @@ import com.ai.lawyer.domain.poll.entity.PollVote; 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 java.util.List; import java.util.Optional; @@ -11,4 +14,12 @@ public interface PollVoteRepository extends JpaRepository, PollV void deleteByMember_MemberIdAndPoll_PollId(Long memberId, Long pollId); Optional findByMember_MemberIdAndPollOptions_PollItemsId(Long memberId, Long pollItemsId); List findByMember_MemberId(Long memberId); + + /** + * member_id로 투표 내역 삭제 (회원 탈퇴 시 사용) + * Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제 + */ + @Modifying + @Query("DELETE FROM PollVote pv WHERE pv.member.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java b/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java index a69ff05f..ba532bf9 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java @@ -3,6 +3,9 @@ import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.post.entity.Post; 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 org.springframework.stereotype.Repository; import java.util.List; @@ -10,4 +13,12 @@ @Repository public interface PostRepository extends JpaRepository { List findByMember(Member member); + + /** + * member_id로 게시글 삭제 (회원 탈퇴 시 사용) + * Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제 + */ + @Modifying + @Query("DELETE FROM Post p WHERE p.member.memberId = :memberId") + void deleteByMemberIdValue(@Param("memberId") Long memberId); } \ No newline at end of file diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java index a432edb9..36ba2ae6 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceOAuth2Test.java @@ -57,6 +57,15 @@ class MemberServiceOAuth2Test { @Mock private EmailAuthService emailAuthService; + @Mock + private com.ai.lawyer.domain.post.repository.PostRepository postRepository; + + @Mock + private com.ai.lawyer.domain.poll.repository.PollVoteRepository pollVoteRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.HistoryRepository historyRepository; + @Mock private HttpServletResponse response; @@ -74,7 +83,10 @@ void setUp() { tokenProvider, cookieUtil, emailService, - emailAuthService + emailAuthService, + postRepository, + pollVoteRepository, + historyRepository ); memberService.setOauth2MemberRepository(oauth2MemberRepository); diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java index 1c06e932..23e61165 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java @@ -52,6 +52,15 @@ class MemberServiceTest { @Mock private EmailAuthService emailAuthService; + @Mock + private com.ai.lawyer.domain.post.repository.PostRepository postRepository; + + @Mock + private com.ai.lawyer.domain.poll.repository.PollVoteRepository pollVoteRepository; + + @Mock + private com.ai.lawyer.domain.chatbot.repository.HistoryRepository historyRepository; + @Mock private HttpServletResponse response; @@ -73,7 +82,10 @@ void setUp() { tokenProvider, cookieUtil, emailService, - emailAuthService + emailAuthService, + postRepository, + pollVoteRepository, + historyRepository ); memberService.setOauth2MemberRepository(oauth2MemberRepository); @@ -300,7 +312,18 @@ void withdraw_Success() { memberService.deleteMember(loginId); // then + // 1. 회원 조회 verify(memberRepository).findByLoginId(loginId); + + // 2. 연관 데이터 명시적 삭제 (순서 중요) + verify(historyRepository).deleteByMemberIdValue(member.getMemberId()); + verify(pollVoteRepository).deleteByMemberIdValue(member.getMemberId()); + verify(postRepository).deleteByMemberIdValue(member.getMemberId()); + + // 3. Redis 토큰 삭제 + verify(tokenProvider).deleteAllTokens(loginId); + + // 4. 회원 삭제 verify(memberRepository).delete(member); }