feat: 회원 탈퇴(자기 계정 삭제) — Play 정책 대응
CI / build (push) Failing after 15m7s

- DELETE /api/auth/me: 본인 계정과 모든 데이터 영구 삭제
- 게시판(글/댓글/추천/이미지)·가계부 전체·포인트·결제기록·세션 → 회원 순 삭제
- WithdrawMapper(게시판/포인트/결제/세션) + BackupMapper(가계부) 재사용
- AuthServiceTest 생성자 보강

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-28 16:56:23 +09:00
parent be15f5b85d
commit 8142b8b518
5 changed files with 159 additions and 1 deletions
@@ -80,6 +80,15 @@ public class AuthController {
return current; return current;
} }
/** 회원 탈퇴 — 본인 계정과 모든 데이터 삭제 */
@DeleteMapping("/me")
public ResponseEntity<Void> withdraw(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current,
HttpServletRequest request) {
authService.withdraw(current.getId(), AuthInterceptor.resolveToken(request));
return ResponseEntity.noContent().build();
}
/** 현재 사용자 활동 포인트 (최신값 — 게시판 작성으로 수시 변동) */ /** 현재 사용자 활동 포인트 (최신값 — 게시판 작성으로 수시 변동) */
@GetMapping("/points") @GetMapping("/points")
public java.util.Map<String, Long> points( public java.util.Map<String, Long> points(
@@ -0,0 +1,34 @@
package com.sb.web.auth.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 회원 탈퇴 시 가계부(BackupMapper) 외의 사용자 데이터 정리 — 게시판/포인트/결제/세션.
*/
@Mapper
public interface WithdrawMapper {
// 게시판 — 내가 누른 표
int deletePostVotesByMember(@Param("memberId") Long memberId);
int deleteCommentVotesByMember(@Param("memberId") Long memberId);
// 게시판 — 내 글에 달린 것들 (남이 단 댓글/표 포함)
int deleteCommentVotesOnMyPosts(@Param("memberId") Long memberId);
int deletePostVotesOnMyPosts(@Param("memberId") Long memberId);
int deletePostTagsOnMyPosts(@Param("memberId") Long memberId);
int deleteCommentsOnMyPosts(@Param("memberId") Long memberId);
// 게시판 — 내 댓글(남의 글에 단 것)과 그 표
int deleteCommentVotesOnMyComments(@Param("memberId") Long memberId);
int deleteCommentsByAuthor(@Param("memberId") Long memberId);
// 게시판 — 내 글
int deletePostsByAuthor(@Param("memberId") Long memberId);
int deleteBoardImagesByMember(@Param("memberId") Long memberId);
// 포인트 / 결제 / 세션
int deletePointHistory(@Param("memberId") Long memberId);
int deleteIapPurchases(@Param("memberId") Long memberId);
int deleteAuthSessions(@Param("memberId") Long memberId);
}
@@ -38,6 +38,8 @@ public class AuthService {
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
private final com.sb.web.admin.service.AppSettingService appSettingService; private final com.sb.web.admin.service.AppSettingService appSettingService;
private final com.sb.web.auth.mapper.AuthSessionMapper authSessionMapper; private final com.sb.web.auth.mapper.AuthSessionMapper authSessionMapper;
private final com.sb.web.auth.mapper.WithdrawMapper withdrawMapper;
private final com.sb.web.account.mapper.BackupMapper backupMapper;
/** 구글 OAuth 클라이언트 ID (ID 토큰 aud 검증용). 미설정 시 구글 로그인 비활성. */ /** 구글 OAuth 클라이언트 ID (ID 토큰 aud 검증용). 미설정 시 구글 로그인 비활성. */
@org.springframework.beans.factory.annotation.Value("${app.google-client-id:}") @org.springframework.beans.factory.annotation.Value("${app.google-client-id:}")
@@ -289,6 +291,50 @@ public class AuthService {
} }
} }
/**
* 회원 탈퇴 — 사용자가 소유한 모든 데이터를 삭제하고 회원을 제거한다.
* (게시판 글/댓글/추천 · 가계부 전체 · 포인트 · 결제기록 · 세션 → 회원)
*/
@Transactional
public void withdraw(Long memberId, String token) {
Member member = memberMapper.findById(memberId);
if (member == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
}
// 1) 게시판 — 표/댓글/글/이미지 (참조 순서 고려)
withdrawMapper.deletePostVotesByMember(memberId);
withdrawMapper.deleteCommentVotesByMember(memberId);
withdrawMapper.deleteCommentVotesOnMyPosts(memberId);
withdrawMapper.deleteCommentVotesOnMyComments(memberId);
withdrawMapper.deleteCommentsOnMyPosts(memberId);
withdrawMapper.deleteCommentsByAuthor(memberId);
withdrawMapper.deletePostVotesOnMyPosts(memberId);
withdrawMapper.deletePostTagsOnMyPosts(memberId);
withdrawMapper.deletePostsByAuthor(memberId);
withdrawMapper.deleteBoardImagesByMember(memberId);
// 2) 가계부(계정) 데이터 (복구 삭제와 동일 순서)
backupMapper.deleteEntryTags(memberId);
backupMapper.deleteEntries(memberId);
backupMapper.deleteRecurrings(memberId);
backupMapper.deleteBudgets(memberId);
backupMapper.deleteBudgetIncome(memberId);
backupMapper.deleteQuickEntries(memberId);
backupMapper.deleteInvestTrades(memberId);
backupMapper.deleteInvestHoldings(memberId);
backupMapper.deleteCategories(memberId);
backupMapper.deleteTags(memberId);
backupMapper.deleteWallets(memberId);
// 3) 포인트 / 결제 / 세션
withdrawMapper.deletePointHistory(memberId);
withdrawMapper.deleteIapPurchases(memberId);
withdrawMapper.deleteAuthSessions(memberId);
// 4) 회원 삭제
memberMapper.deleteById(memberId);
// 5) 현재 세션(Redis) 정리
logout(token);
log.info("[withdraw] member {} ({}) 및 데이터 전체 삭제", memberId, member.getLoginId());
}
/** 만료된 백업 세션 정리 (매일 새벽 4시) */ /** 만료된 백업 세션 정리 (매일 새벽 4시) */
@org.springframework.scheduling.annotation.Scheduled(cron = "0 0 4 * * *") @org.springframework.scheduling.annotation.Scheduled(cron = "0 0 4 * * *")
public void cleanupExpiredSessions() { public void cleanupExpiredSessions() {
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sb.web.auth.mapper.WithdrawMapper">
<!-- 내가 누른 표 -->
<delete id="deletePostVotesByMember">
DELETE FROM post_vote WHERE member_id = #{memberId}
</delete>
<delete id="deleteCommentVotesByMember">
DELETE FROM comment_vote WHERE member_id = #{memberId}
</delete>
<!-- 내 글에 달린 것들 -->
<delete id="deleteCommentVotesOnMyPosts">
DELETE cv FROM comment_vote cv
JOIN comment c ON c.id = cv.comment_id
JOIN post p ON p.id = c.post_id
WHERE p.author_id = #{memberId}
</delete>
<delete id="deletePostVotesOnMyPosts">
DELETE pv FROM post_vote pv
JOIN post p ON p.id = pv.post_id
WHERE p.author_id = #{memberId}
</delete>
<delete id="deletePostTagsOnMyPosts">
DELETE pt FROM post_tag pt
JOIN post p ON p.id = pt.post_id
WHERE p.author_id = #{memberId}
</delete>
<delete id="deleteCommentsOnMyPosts">
DELETE c FROM comment c
JOIN post p ON p.id = c.post_id
WHERE p.author_id = #{memberId}
</delete>
<!-- 내 댓글과 그 표 -->
<delete id="deleteCommentVotesOnMyComments">
DELETE cv FROM comment_vote cv
JOIN comment c ON c.id = cv.comment_id
WHERE c.author_id = #{memberId}
</delete>
<delete id="deleteCommentsByAuthor">
DELETE FROM comment WHERE author_id = #{memberId}
</delete>
<!-- 내 글 / 업로드 이미지 -->
<delete id="deletePostsByAuthor">
DELETE FROM post WHERE author_id = #{memberId}
</delete>
<delete id="deleteBoardImagesByMember">
DELETE FROM board_image WHERE member_id = #{memberId}
</delete>
<!-- 포인트 / 결제 / 세션 -->
<delete id="deletePointHistory">
DELETE FROM point_history WHERE member_id = #{memberId}
</delete>
<delete id="deleteIapPurchases">
DELETE FROM iap_purchase WHERE member_id = #{memberId}
</delete>
<delete id="deleteAuthSessions">
DELETE FROM auth_session WHERE member_id = #{memberId}
</delete>
</mapper>
@@ -47,12 +47,15 @@ class AuthServiceTest {
@Mock ValueOperations<String, Object> valueOps; @Mock ValueOperations<String, Object> valueOps;
@Mock AppSettingService appSettingService; @Mock AppSettingService appSettingService;
@Mock AuthSessionMapper authSessionMapper; @Mock AuthSessionMapper authSessionMapper;
@Mock com.sb.web.auth.mapper.WithdrawMapper withdrawMapper;
@Mock com.sb.web.account.mapper.BackupMapper backupMapper;
AuthService service; AuthService service;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new AuthService(memberMapper, passwordEncoder, redisTemplate, appSettingService, authSessionMapper); service = new AuthService(memberMapper, passwordEncoder, redisTemplate, appSettingService, authSessionMapper,
withdrawMapper, backupMapper);
} }
private Member localMember() { private Member localMember() {