feat(S15P11A705-65): 회원 탈퇴 구현 - #120
Conversation
DELETE /v1/me. 소프트 삭제·마스킹·연쇄 삭제·AI 파생 무효화·세션 폐기를 한 트랜잭션에 담는다(08 §3.6, 06 §6.9). 이 공백이 완료된 작업 둘을 미충족으로 붙잡고 있었다. S15P11A705-124가 요구한 AI 무효화 네 지점 중 탈퇴만 비어 있었고(#80이 붙일 서비스가 없어 제외했다), S15P11A705-147이 모아 둔 MemberRepository.isActive는 member.deleted_at을 세팅하는 주체가 없어 한 번도 발동하지 않았다. RED — 13건. 그중 하나가 예상과 다른 사유였다: CsrfFilter가 인가보다 먼저 돌아 CSRF 토큰 없는 DELETE는 인증 여부와 무관하게 403이다. 403은 CSRF 전용이라는 계약(authentication.md)에 맞춰 "미인증 → 401"과 "CSRF 누락 → 403"으로 갈랐다. GREEN - SocialAccount.withdraw() — withdrawn:<id> 치환과 deleted_at을 한 UPDATE에. repository.delete()를 쓰면 removed 상태로 보여 마스킹이 flush되지 않는다 - 리포지토리 finder 5개 — 회원 단위 조회와 Follow 양방향 조회 - MemberWithdrawalService — §6.9 순서를 한 @transactional에 - MeController — 204 + 쿠키 3종 만료 - JwtAuthenticationFilter — 탈퇴 회원 판정으로 Access 창을 닫음 설계 판단 넷 - 행 잠금을 쓰지 않는다. cascadeDelete가 잠그는 이유는 "활성 Context 1개 이상" 불변식과 record_count 산술의 경합인데, 탈퇴는 전부 지우므로 세어 분기할 일이 없고 Collection은 소유자 자기 Record만 담아 타 회원과 경합하지 않는다. #34 코멘트의 "invalidate를 Collection 잠금보다 앞에" 조언은 잠금 전제가 사라져 순서만 지켰다 - record_count를 갱신하지 않는다. Collection 자체가 함께 죽는다 - Refresh 폐기를 트랜잭션 마지막에 둔다. Redis는 DB 트랜잭션에 참여할 수 없어, 마지막에 두면 Redis 실패가 DB 롤백을 유발해 "로그아웃됐는데 탈퇴는 안 된" 상태가 남지 않는다. 순서를 뒤집으면 그 상태가 생긴다 - 연쇄를 도메인별로 흩뜨리지 않는다. 순서가 계약이라 한 곳에서 읽혀야 한다. RecordDeletionService에 이미 타 도메인 리포지토리 주입 선례가 있다 드러난 것 - Access 토큰은 폐기 목록이 없어 쿠키 만료·Refresh 폐기로 닫히지 않는다. 다른 기기에 남은 쿠키로 최대 30분간 쓰기까지 된다. 필터에서 isActive를 보게 했고 대가는 인증 요청마다 PK 조회 한 번이다 - loginAs로는 그 필터를 검증할 수 없다. SecurityContext에 직접 주입해 필터가 통째로 건너뛰어진다 — 처음 실패의 원인이 구현이 아니라 테스트였다. 그 한 건만 실제 토큰을 쿠키에 실었고, 탈퇴 전 200 → 후 401로 원인을 탈퇴로 특정한다. 쿠키 이름을 틀리게 바꾸면 앞쪽만 실패하고 뒤쪽은 통과한다 — 앞쪽 단언이 vacuous pass를 막는다 뮤테이션 3종 — 각 방어선이 독립이다. 되돌릴 때 실패하는 테스트가 1건씩이고 나머지 12건은 통과한다(필터 isActive → Access 창, invalidate → AI 파생, 이메일 치환 → 마스킹). 세 번째는 DB NOT NULL이 잡지 못한다 — 원본이 그대로 남아 제약 위반이 아니다. REFACTOR — SocialAccount 클래스 javadoc의 "탈퇴 티켓에서 추가" 미래형을 withdraw() 참조로, MemberRepository.isActive javadoc에 인증 경로도 이 판정에 기댄다는 것을 추가. clean check 407개 통과, 실패 0 Refs #34 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BD-41 — 탈퇴 회원의 남은 Access 토큰을 인증 필터의 PK 조회로 막은 결정. 창 안에서 새는 것이 읽기가 아니라 쓰기라는 것이 결정적이었다. 탈퇴 직후 30분간 Record·Collection 생성이 되고 그 행들은 연쇄 삭제가 지나간 뒤에 만들어져 어떤 정리 경로에도 걸리지 않는다. 요청당 PK 조회를 감수하는 편이 고아 데이터를 감수하는 것보다 싸다고 봤다. Redis 탈퇴 마커를 기각한 이유는 성능이 아니라 장애 전파다. 필터가 Redis를 읽으면 순단이 곧 전체 인증 실패(fail-closed)이거나 순단 중 탈퇴자 통과(fail-open)가 된다. readiness에 redis를 넣지 않기로 한 BD-28과 같은 방향이다. Refresh 폐기를 트랜잭션 마지막에 둔 이유도 함께 적었다 — 실패가 "아무 일도 없었음"으로 수렴하는 순서이고, 뒤집으면 "로그아웃됐는데 탈퇴는 안 된" 상태가 남는다. BI-29 — 구현 보고. 설계 판단 넷(잠금 없음·record_count 미갱신·폐기 순서·연쇄를 한 서비스에), 드러난 것 셋(CSRF가 인가보다 먼저 돎, loginAs로는 필터를 검증할 수 없음, 기존 테스트 무영향), 뮤테이션 3종 결과. 이슈 본문에서 따르지 않은 것도 표로 남겼다. #34이 2026-07-27 작성이라 email = null과 "유일값이 필요하다"가 지금 기준으로 틀렸고, "범위 밖"이라던 연쇄 삭제·AI 무효화는 이번 범위에 들어왔다. 번호는 pull 후 확정했다. BD-40이 #104(재스캔 Scheduler)에 선점돼 BD-41이다. Refs #34 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
필수 변경 2건
RED 13건 중 401·403이 갈린 사유를 계약에 맞춰 다시 세운 점과, diff가 800줄을 넘어
|
|
AI 파트입니다. 소유 경계 ③( AI 파생 무효화는 정확합니다. 변경 요청 없습니다. 확인한 것
|
재리뷰입니다 — 9bc8019..e7308bb 구간의 변경을 봤습니다. 필수 변경은 없습니다. 이 구간은 앞선 리뷰(9bc8019)의 필수 변경 2건은 해당 파일에 변경이 없어 그대로 열려 있습니다 — 같은 내용을 반복하지 않으니 그 코멘트를 참고해 주세요.
|
#120 리뷰 반영(@minyongP). 두 지적 모두 내 실수였다. 1. 마스킹 단언이 테스트 이름을 증명하지 못했다 isNotEqualTo(원본)만으로는 부족하다. 치환이 아니라 접두(MASK + email)로 구현돼도 값이 원본과 달라 통과하면서 개인정보는 그대로 남는다. 복구 경로가 없는 파기이므로 치환값 자체를 고정하고, 형식과 별개로 "원본 조각이 행 어디에도 없다"를 함께 단언했다. 뮤테이션으로 확인했다. this.email = MASK_PREFIX + this.email로 바꾸면 고치기 전에는 통과했고 지금은 실패한다. 2. "그 상태가 남지 않는다"는 보증이 성립하지 않았다 Redis 폐기를 트랜잭션 마지막에 두는 것으로는 "폐기는 됐는데 탈퇴는 롤백된" 상태를 없앨 수 없다 — 폐기가 반환한 뒤 커밋이 실패하거나, 폐기가 키를 일부 지우고 던지면 그대로 남는다. 순서는 창의 크기만 바꾼다. 커밋 이후로 옮기고 실패를 삼켰다. 잔여 위험이 무해한 방향이기 때문이다 — 폐기가 실패해 Refresh가 살아남아도 그것으로 받는 Access는 BD-41이 넣은 탈퇴 판정에 막힌다. 두 결정이 서로를 보완한다. 삼키는 이유는 따로다. 삼키지 않으면 이미 커밋된 탈퇴가 500으로 응답하고 컨트롤러가 도달하지 못해 쿠키도 지워지지 않는다 — 사용자는 실패로 보는데 계정은 사라진 상태가 된다. BD-41에 그 잘못된 보증을 근거로 적어 둔 절이 있어 정정 표시와 함께 고쳤다. 보존 구역 규약대로 틀렸던 판단을 지우지 않고 무엇이 틀렸는지 남겼다. clean check 408개 통과, 실패 0 Refs #34 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
리뷰 감사합니다. 두 지적 모두 제 실수입니다. 1. 마스킹 단언이 테스트 이름을 증명하지 못했다지적 그대로입니다. // 이렇게 바뀌어도 통과했다 — 값은 원본과 다르지만 개인정보는 그대로 남는다
this.email = MASK_PREFIX + this.email;치환값 자체를 고정하고, 형식과 별개로 성립해야 하는 성질을 함께 단언했습니다. assertThat(row.get("provider_user_id")).isEqualTo("withdrawn:" + accountId);
assertThat(row.get("email")).isEqualTo("withdrawn:" + accountId + "@deleted.invalid");
// 형식과 별개 — 원본 조각이 행 어디에도 남지 않는다
assertThat(row.values().stream().map(String::valueOf))
.noneMatch(value -> value.contains("victim") || value.contains("google-withdraw-2"));값을 고정한 이유는 복구 경로가 없는 파기라 형식이 조용히 바뀌면 안 되기 때문입니다. 위 뮤테이션으로 확인했습니다 — 고치기 전에는 통과했고 지금은 그 1건이 실패합니다. 2. "그 상태가 남지 않는다"는 보증이 성립하지 않았다맞습니다. 순서는 창의 크기만 바꿉니다. 폐기가 반환한 뒤 커밋이 실패하거나 폐기가 키를 일부 지우고 던지면 그대로 남습니다. 제 javadoc이 없는 보증을 주장하고 있었습니다. 제안하신 두 방향 중 private void revokeEverySessionAfterCommit(Long memberId) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() {
try { refreshTokenStore.revokeAll(memberId); }
catch (RuntimeException e) { log.error("...", memberId, e); }
}
});
}
실패를 삼키는 것은 별개 판단입니다. 삼키지 않으면 이미 커밋된 탈퇴가 500으로 응답하고 컨트롤러가 도달하지 못해 쿠키도 지워지지 않습니다 — 사용자는 실패로 보는데 계정은 사라진 상태가 됩니다. 그래서
검증
범위 안내 확인
|
재리뷰입니다 — e7308bb..50436f8 구간의 변경을 봤습니다. 앞선 리뷰의 필수 변경 2건은 둘 다 반영됐습니다 — 마스킹 단언은 치환값 자체를 고정하고 원본 조각까지 훑도록, Refresh 폐기는 필수 변경 1건 · 확인 요청 2건
되돌리기 테스트로 세 방어선의 독립성까지 확인한 점이 좋았습니다.
|
|
이 PR에서 같이 하면 좋을 것
이 PR이 만든 문제는 아닙니다 — 로그인·재발급도 같은 값에 걸려 있습니다. 다만 커밋 이후 경로가 생기면서 처음으로 사용자 대기로 직결됐습니다. 1~2초면 충분해 보입니다. 후속으로 나눠도 되는 것1.
|
#120 리뷰 반영(@minyongP). 이 PR에서 처리하기로 한 두 건이다. spring.data.redis.timeout이 설정돼 있지 않았다. Lettuce 기본값 60초라 Redis가 멎으면 요청 스레드가 1분간 붙잡힌다. 이 PR이 만든 문제는 아니지만(로그인·재발급도 같은 값에 걸려 있다) 폐기를 afterCommit으로 옮기면서 처음으로 사용자 대기에 직결됐다 — 탈퇴는 커밋됐는데 204가 그만큼 늦는다. Redis는 캐시·세션 전용이라 오래 기다려 얻을 것이 없어 timeout 2s, connect-timeout 1s로 끊는다. catch (RuntimeException) 분기에 테스트가 없었다. 이번에 추가된 유일한 새 분기인데 다른 방어선은 전부 뮤테이션으로 확인한 것과 대비된다는 지적이 맞다. RefreshTokenStore가 RedisConnectionFailureException을 던지게 하고, 그래도 204와 쿠키 3종 만료가 나오는지 고정했다. catch를 걷어내면 그 1건만 실패한다. 기록에 감수하는 것 넷을 추가했다. 리뷰가 짚은 것들이고 지금 코드 어디에도 적혀 있지 않았다. - 같은 회원의 동시 요청이 만드는 잔여 행. BD-41이 닫은 Access 창과 같은 종류지만 창이 트랜잭션 길이(수 ms)이고 진행 중인 쓰기가 있어야 한다. 닫으려면 인증 판정을 쓰기 트랜잭션과 묶어야 해서 구조가 커진다 - 연쇄 삭제에 상한이 없다. batch_size도 없어 행마다 UPDATE 한 건씩 낸다 - 연쇄 완결성이 requireAllOwnedActiveRecords 하나에 의존하고 그 불변식을 고정하는 테스트가 없다 - rotate가 isActive를 보지 않아 폐기 실패가 자가 치유되지 않는다 BD-41도 두 곳 보탰다. "PK 조회 한 번"이 실제로는 커넥션 체크아웃 1회이고 (open-in-view: false라 컨트롤러 트랜잭션이 두 번째를 잡는다), 이 변경으로 필터가 인프라 사유로 던질 수 있게 되어 DataAccessException이 공통 envelope을 우회한다는 것이다. clean check 409개 통과, 실패 0 Refs #34 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
반영 완료했습니다 — 이 PR에서 처리 (요청하신 것)
data:
redis:
timeout: 2s
connect-timeout: 1s지적이 정확합니다 — 이 PR이 만든 문제는 아니지만
확인 요청 2건에 대한 답① 필터 DB 예외 — 우회하는 것이 맞습니다. 확인해 보니 커스텀 "커넥션 풀 고갈이 실제 갈림길"이라는 지적이 핵심이라고 봅니다 — BD-41이 Redis에 대해 피하려던 모양이 DB로 재현됩니다. 정책 결정이 필요해 **[S15P11A705-188]**로 분리했고, ② 같은 회원의 동시 요청 — 감수합니다. 지적대로 스냅샷 이후 커밋 전까지 만들어진 행은 연쇄를 지나칩니다. BD-41이 닫은 Access 창과 같은 종류인데 창의 크기가 다릅니다.
닫으려면 인증 판정을 쓰기 트랜잭션과 묶거나 탈퇴 후 정리 경로를 둬야 해서 구조가 커집니다. 참고 2건도 반영BD-41의 "PK 조회 한 번"이 부정확했습니다. 후속 티켓
검증
|
요약
DELETE /api/core/v1/me를 구현한다. 소프트 삭제·마스킹·연쇄 삭제·AI 파생 무효화·세션 폐기를 한 트랜잭션에 담는다(08 §3.6,06 §6.9).이 공백이 완료된 작업 둘을 미충족으로 붙잡고 있었다.
S15P11A705-124가 요구한 AI 무효화 네 지점 중 탈퇴만 비어 있었고(#80이 붙일 서비스가 없어 제외했다),S15P11A705-147이 모아 둔MemberRepository.isActive는member.deleted_at을 세팅하는 주체가 없어 한 번도 발동하지 않았다.Jira (필수)
관련 GitHub Issue (선택)
변경 사항
MeController—DELETE /v1/me, 204, 쿠키 3종 만료MemberWithdrawalService—§6.9순서를 한@Transactional에SocialAccount.withdraw()—withdrawn:<id>치환과deleted_at을 한 UPDATE에JwtAuthenticationFilter— 탈퇴 회원 판정 추가(BD-41)BD-41,BI-29, WORKLOG 1줄테스트 / 검증
./gradlew clean check --no-daemon08 §3.6에 있고 구현이 따라간 것)BD-41추가RED — 13건. 그중 하나가 예상과 다른 사유였다.
403은 CSRF 전용이라는 계약(authentication.md)에 맞춰 미인증 → 401(CSRF 토큰은 준다)과 CSRF 누락 → 403(인증은 준다, 아무것도 지워지지 않음)으로 갈랐다.GREEN + Regression
뮤테이션 3종 — 각 방어선이 독립임을 확인했다. 되돌릴 때 실패가 1건씩이고 나머지 12건은 통과한다.
.filter(memberRepository::isActive)aiDerivedDataRepository.invalidate(...)withdraw()의 이메일 치환세 번째는 DB
NOT NULL이 잡지 못한다 — 원본 이메일이 그대로 남아 제약 위반이 아니다.BI-27에서는 두 층이 서로를 보완했지만 여기서는 테스트가 유일한 방어선이다.배경
#34본문은 2026-07-27 작성이고 그 뒤로 전제 셋이 바뀌어 그대로 따르지 않았다.this.email = null;NOT NULL위반.V6(#103) 이후 넣을 수 없다. 마스킹은 치환이며NULL이 아니다(06 §2.2)"deleted:" + id가 "부분 유니크 충돌을 피할 유일값"AiDerivedDataRepository도dev에 있다 — 이번 범위 안이다repository.delete()를 쓰지 말라는 경고는 유효하다.@SQLDelete가 도는 시점에 Hibernate가 엔티티를 removed로 보아 마스킹 UPDATE가 flush되지 않는다.리뷰 포인트
행 잠금을 쓰지 않은 것 —
@colosair확인 부탁드립니다.#34코멘트의 *"invalidate를 Collection 잠금(findByIdForUpdate)보다 앞에 두라"*는 조언은 잠금이 있다는 전제였습니다.cascadeDelete가 잠그는 이유는 "활성 Context 1개 이상" 불변식과record_count산술의 경합인데, 탈퇴는 전부 지우므로 세어 분기할 일이 없고 Collection은 소유자 자기 Record만 담아 타 회원과 경합하지 않습니다. 그래서 순서만 지키고 잠금은 뺐습니다(무효화가 Collection 처리보다 앞).BD-37의 롤백 테스트가 그 순서를 전제로 짜였다고 하셨으니, 이 판단이 그 전제를 깨는지 봐 주시면 좋겠습니다.인증 필터에 DB 조회를 넣은 것 —
BD-41의 결정입니다. 탈퇴 후에도 다른 기기에 남은 Access로 최대 30분간 쓰기가 됩니다(쿠키 만료는 요청한 기기에만 도달, Refresh 폐기는 재발급만 막음). 그 행들은 연쇄 삭제가 지나간 뒤에 만들어져 어떤 정리 경로에도 걸리지 않아, 요청당 PK 조회 1회를 대가로 냈습니다. Redis 마커는 순단을 인증 실패로 번지게 해 기각했습니다(BD-28과 같은 방향).loginAs로는 그 필터를 검증할 수 없습니다.SecurityContext에 직접 주입해 필터가 통째로 건너뛰어집니다. Access 창 테스트만 실제 토큰을 쿠키에 실었고, 탈퇴 전 200 → 후 401로 원인을 탈퇴로 특정합니다. 앞쪽 단언이 없으면 안 됩니다 — 쿠키 이름을 틀리게 바꿔 보니 앞쪽만 실패하고 뒤쪽은 통과하는 vacuous pass가 됩니다.미결 / 후속
GoogleLoginCallbackTests가 이미 고정한다.07 §5), 보존 기간은 공용 계약의 미확정 항목이다(06 §8).