Skip to content

feat(S15P11A705-65): 회원 탈퇴 구현 - #120

Merged
cherry-go-round merged 5 commits into
devfrom
feat/S15P11A705-65-member-withdrawal
Jul 31, 2026
Merged

feat(S15P11A705-65): 회원 탈퇴 구현#120
cherry-go-round merged 5 commits into
devfrom
feat/S15P11A705-65-member-withdrawal

Conversation

@cherry-go-round

@cherry-go-round cherry-go-round commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

요약

DELETE /api/core/v1/me를 구현한다. 소프트 삭제·마스킹·연쇄 삭제·AI 파생 무효화·세션 폐기를 한 트랜잭션에 담는다(08 §3.6, 06 §6.9).

이 공백이 완료된 작업 둘을 미충족으로 붙잡고 있었다. S15P11A705-124가 요구한 AI 무효화 네 지점 중 탈퇴만 비어 있었고(#80이 붙일 서비스가 없어 제외했다), S15P11A705-147이 모아 둔 MemberRepository.isActivemember.deleted_at을 세팅하는 주체가 없어 한 번도 발동하지 않았다.

Jira (필수)

관련 GitHub Issue (선택)

변경 사항

  • MeControllerDELETE /v1/me, 204, 쿠키 3종 만료
  • MemberWithdrawalService§6.9 순서를 한 @Transactional
  • SocialAccount.withdraw()withdrawn:<id> 치환과 deleted_at을 한 UPDATE에
  • JwtAuthenticationFilter — 탈퇴 회원 판정 추가(BD-41)
  • 리포지토리 finder 5개 — 회원 단위 조회 넷, Follow 양방향 하나
  • 기록 — BD-41, BI-29, WORKLOG 1줄

테스트 / 검증

  • ./gradlew clean check --no-daemon
  • DB 변경 시 PostgreSQL 통합 테스트 (신규 13건 전부 Testcontainers)
  • migration 변경 시 빈 DB migration 테스트 — 해당 없음(스키마 변경 없음)
  • API 계약 변경 시 관련 문서 갱신 — 해당 없음(계약은 이미 08 §3.6에 있고 구현이 따라간 것)
  • 되돌리기 어려운 결정을 포함하므로 BD-41 추가

RED — 13건. 그중 하나가 예상과 다른 사유였다.

11건: 204 expected but was 404   (엔드포인트 없음)
 1건: 401 expected but was 403   ← CsrfFilter가 인가보다 먼저 돈다

403은 CSRF 전용이라는 계약(authentication.md)에 맞춰 미인증 → 401(CSRF 토큰은 준다)과 CSRF 누락 → 403(인증은 준다, 아무것도 지워지지 않음)으로 갈랐다.

GREEN + Regression

$ ./gradlew clean check --no-daemon
BUILD SUCCESSFUL in 2m — 408 tests, 0 failures, 0 errors

처음 올릴 때는 407 tests, 2 failed였다. 그 2건은 이 PR과 무관한 dev의 기존 파손(RuntimeSecretWorkflowContractTests)이었고 #121이 병합되며 해소됐다. 지금은 그 수정분 위로 리베이스해 전부 통과한다.

뮤테이션 3종 — 각 방어선이 독립임을 확인했다. 되돌릴 때 실패가 1건씩이고 나머지 12건은 통과한다.

되돌린 것 실패
필터의 .filter(memberRepository::isActive) Access 창 1건
aiDerivedDataRepository.invalidate(...) AI 파생 1건
withdraw()의 이메일 치환 마스킹 1건

세 번째는 DB NOT NULL이 잡지 못한다 — 원본 이메일이 그대로 남아 제약 위반이 아니다. BI-27에서는 두 층이 서로를 보완했지만 여기서는 테스트가 유일한 방어선이다.

배경

#34 본문은 2026-07-27 작성이고 그 뒤로 전제 셋이 바뀌어 그대로 따르지 않았다.

본문 실제
this.email = null; NOT NULL 위반. V6(#103) 이후 넣을 수 없다. 마스킹은 치환이며 NULL이 아니다(06 §2.2)
"deleted:" + id"부분 유니크 충돌을 피할 유일값" 유일값이 불필요하다. 유니크가 활성행만 대상이라 마스킹 시점엔 이미 인덱스 밖이다
연쇄 삭제·AI 무효화가 "범위 밖" 테이블이 모두 들어왔고 AiDerivedDataRepositorydev에 있다 — 이번 범위 안이다

repository.delete()를 쓰지 말라는 경고는 유효하다. @SQLDelete가 도는 시점에 Hibernate가 엔티티를 removed로 보아 마스킹 UPDATE가 flush되지 않는다.

리뷰 포인트

  1. 행 잠금을 쓰지 않은 것@colosair 확인 부탁드립니다. #34 코멘트의 *"invalidate를 Collection 잠금(findByIdForUpdate)보다 앞에 두라"*는 조언은 잠금이 있다는 전제였습니다. cascadeDelete가 잠그는 이유는 "활성 Context 1개 이상" 불변식과 record_count 산술의 경합인데, 탈퇴는 전부 지우므로 세어 분기할 일이 없고 Collection은 소유자 자기 Record만 담아 타 회원과 경합하지 않습니다. 그래서 순서만 지키고 잠금은 뺐습니다(무효화가 Collection 처리보다 앞). BD-37의 롤백 테스트가 그 순서를 전제로 짜였다고 하셨으니, 이 판단이 그 전제를 깨는지 봐 주시면 좋겠습니다.

  2. 인증 필터에 DB 조회를 넣은 것BD-41의 결정입니다. 탈퇴 후에도 다른 기기에 남은 Access로 최대 30분간 쓰기가 됩니다(쿠키 만료는 요청한 기기에만 도달, Refresh 폐기는 재발급만 막음). 그 행들은 연쇄 삭제가 지나간 뒤에 만들어져 어떤 정리 경로에도 걸리지 않아, 요청당 PK 조회 1회를 대가로 냈습니다. Redis 마커는 순단을 인증 실패로 번지게 해 기각했습니다(BD-28과 같은 방향).

  3. loginAs로는 그 필터를 검증할 수 없습니다. SecurityContext에 직접 주입해 필터가 통째로 건너뛰어집니다. Access 창 테스트만 실제 토큰을 쿠키에 실었고, 탈퇴 전 200 → 후 401로 원인을 탈퇴로 특정합니다. 앞쪽 단언이 없으면 안 됩니다 — 쿠키 이름을 틀리게 바꿔 보니 앞쪽만 실패하고 뒤쪽은 통과하는 vacuous pass가 됩니다.

미결 / 후속

  • 탈퇴 후 재로그인 검증이 리포지토리 수준이다. 부분 유니크가 재가입을 허용하는 것까지 확인했지만 실제 OAuth 콜백을 한 번 더 도는 형태는 아니다. 콜백의 기존 회원 판정 계약은 GoogleLoginCallbackTests가 이미 고정한다.
  • 물리 삭제 시점은 범위 밖이다. 개인정보 정책을 따르며(07 §5), 보존 기간은 공용 계약의 미확정 항목이다(06 §8).

cherry-go-round and others added 2 commits July 30, 2026 17:38
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>
@minyongP

Copy link
Copy Markdown
Contributor

Claude Code 자동 리뷰입니다. 판정은 항상 comment이며, 승인·변경 요청은 @minyongP가 직접 남깁니다.

필수 변경 2건

  • src/test/java/com/pinlog/pinlogback/domain/member/MemberWithdrawalApiTests.java:112isNotEqualTo(원본) 두 건은 "원본이 남지 않는다"는 이 테스트의 이름을 증명하지 못합니다. 마스킹이 치환이 아니라 접두(MASK_PREFIX + email)로 잘못 구현돼도 값이 원본과 달라 통과하고, 개인정보는 그대로 남습니다. 복구 경로가 없는 파기이므로 doesNotContain("victim@example.com")이나 기대 마스크값 자체를 고정하는 편이 좋겠습니다.
  • src/main/java/com/pinlog/pinlogback/domain/member/service/MemberWithdrawalService.java:111 — :82~84의 "그 상태가 남지 않는다"는 보증이 실제로는 성립하지 않습니다. revokeAll이 반환한 뒤 커밋이 실패하거나 revokeAll이 키를 일부 지우고 던지면, DB는 롤백되고 Refresh만 폐기된 바로 그 상태가 남습니다. 마지막에 두는 것으로는 창이 좁아질 뿐이니 afterCommit으로 옮기거나(그쪽 실패는 isActive가 이미 막습니다) 주석의 보증 범위를 실제에 맞춰 주시면 좋겠습니다.

RED 13건 중 401·403이 갈린 사유를 계약에 맞춰 다시 세운 점과, loginAs로는 필터가 검증되지 않는다는 것을 vacuous pass까지 확인해 짚은 점이 좋습니다.

diff가 800줄을 넘어 src/main/**·src/test/**만 봤습니다 — docs/backend/**(BD-41·BI-29)는 이번 리뷰에서 생략했습니다.

nit 수준 제안은 내지 않습니다.

@colosair

Copy link
Copy Markdown
Member

AI 파트입니다. 소유 경계 ③(ai.* 를 다루는 코드)에 해당하는 부분만 검토했습니다. 나머지는 minyongP 리뷰 범위라 다루지 않습니다.

AI 파생 무효화는 정확합니다. 변경 요청 없습니다.

확인한 것

호출 위치   @Transactional 안 — AiDerivedDataRepository 가 요구하는 계약 그대로
패턴       -124(#80) 의 cascadeDelete 와 동일한 형태·순서
빈 리스트   records.isEmpty() 조기 반환 + invalidate 내부 가드, 이중
테스트     COMPLETED → CANCELLED 덮어쓰기와 embedding is_deleted 를 모두 단언

context_keyword 를 별도로 지우지 않은 판단이 맞습니다

테스트 주석에 적으신 근거가 AiDerivedDataRepository javadoc 과 같습니다.

context_keyword에는 is_deleted가 없어 keyword_status가 단독으로 조회 제외를 담당하므로, 남기면 탈퇴한 사용자의 키워드가 계속 노출된다(08 §3.6)

그 테이블에는 is_deleted 컬럼이 없고 조회가 context_ai_state 를 조인해 keyword_status 로 거르므로, CANCELLED 전이만으로 제외됩니다. 행을 지우는 것보다 이쪽이 맞습니다 — 삭제하면 Preset 보정용 분석 데이터가 함께 사라집니다.

경합도 이미 막혀 있습니다

탈퇴 시점에 FastAPI 가 그 Context 를 처리 중이어도 문제가 되지 않습니다. invalidate 가 무조건 CANCELLED 로 덮고, 그 뒤에 도착하는 AI 워커의 UPDATE 는 WHERE status = 'PROCESSING' 가드에 막힙니다. 재스캔 Scheduler(S15P11A705-159)도 CANCELLED 를 후보로 잡지 않습니다.

COMPLETED 도 덮는 것을 테스트로 고정하신 것이 특히 좋습니다 — 이미 처리가 끝난 Context 가 가장 흔한 경우이고, 그것을 빠뜨리면 탈퇴자의 키워드와 임베딩이 그대로 남습니다.

참고 — CI 실패는 이 PR 때문이 아닙니다

RuntimeSecretWorkflowContractTests > secretJobHasExactlyTheReviewedEnvironmentAndSteps()  FAILED
RuntimeSecretWorkflowContractTests > infraActionHasExactlyTheReviewedInputsAndSecretMapping()  FAILED
407 tests completed, 2 failed

#121(워크플로 계약 테스트 스텝 조회를 이름 기반으로)이 고치는 대상과 같습니다. 그쪽이 dev 에 들어간 뒤 rebase 하면 통과할 것으로 보입니다. 이미 알고 계실 수 있지만 사실만 전달합니다.

@minyongP

Copy link
Copy Markdown
Contributor

Claude Code 자동 리뷰입니다. 판정은 항상 comment이며, 승인·변경 요청은 @minyongP가 직접 남깁니다.

재리뷰입니다 — 9bc8019..e7308bb 구간의 변경을 봤습니다.

필수 변경은 없습니다. 이 구간은 dev 병합뿐이고, 실제로 들어온 변경은 #121로 이미 머지된 .github/workflows/seal-runtime-secrets.yml·RuntimeSecretWorkflowContractTests.java 두 파일입니다. 탈퇴 구현 쪽 파일은 이 구간에서 바뀌지 않았고, 병합이 어긋난 흔적도 없었습니다.

앞선 리뷰(9bc8019)의 필수 변경 2건은 해당 파일에 변경이 없어 그대로 열려 있습니다 — 같은 내용을 반복하지 않으니 그 코멘트를 참고해 주세요.

nit 수준 제안은 내지 않습니다.

#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>
@cherry-go-round

Copy link
Copy Markdown
Contributor Author

리뷰 감사합니다. 두 지적 모두 제 실수입니다. 50436f8로 고쳤습니다.

1. 마스킹 단언이 테스트 이름을 증명하지 못했다

지적 그대로입니다. isNotEqualTo(원본)접두 구현을 통과시킵니다.

// 이렇게 바뀌어도 통과했다 — 값은 원본과 다르지만 개인정보는 그대로 남는다
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"));

값을 고정한 이유는 복구 경로가 없는 파기라 형식이 조용히 바뀌면 안 되기 때문입니다. maskingAndSoftDeleteLandTogether의 같은 형태 단언도 함께 고쳤습니다.

위 뮤테이션으로 확인했습니다 — 고치기 전에는 통과했고 지금은 그 1건이 실패합니다.

2. "그 상태가 남지 않는다"는 보증이 성립하지 않았다

맞습니다. 순서는 창의 크기만 바꿉니다. 폐기가 반환한 뒤 커밋이 실패하거나 폐기가 키를 일부 지우고 던지면 그대로 남습니다. 제 javadoc이 없는 보증을 주장하고 있었습니다.

제안하신 두 방향 중 afterCommit으로 옮기는 쪽을 택했습니다. 지적하신 근거 그대로 — 그쪽 실패는 isActive가 이미 막습니다.

private void revokeEverySessionAfterCommit(Long memberId) {
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override public void afterCommit() {
            try { refreshTokenStore.revokeAll(memberId); }
            catch (RuntimeException e) { log.error("...", memberId, e); }
        }
    });
}
배치 폐기 실패 커밋 실패
트랜잭션 안, 마지막 DB 롤백 → 아무 일도 없었음 폐기만 반영된 상태가 남는다
커밋 이후, 실패 삼킴 탈퇴는 확정, Refresh가 살아남음 폐기 전이라 아무 일도 없었음

실패를 삼키는 것은 별개 판단입니다. 삼키지 않으면 이미 커밋된 탈퇴가 500으로 응답하고 컨트롤러가 도달하지 못해 쿠키도 지워지지 않습니다 — 사용자는 실패로 보는데 계정은 사라진 상태가 됩니다. 그래서 log.error로 남기고 진행합니다.

BD-41에 그 잘못된 보증을 근거로 적어 둔 절이 있어 정정 표시와 함께 고쳤습니다. 보존 구역 규약대로 틀렸던 판단을 지우지 않고 무엇이 틀렸는지 남겼습니다. 결과적으로 두 결정(필터의 탈퇴 판정 / 커밋 이후 폐기)이 서로를 보완하는 관계가 됐습니다.

검증

./gradlew clean check --no-daemon408개 통과, 실패 0.

범위 안내 확인

docs/backend/**(BD-41·BI-29)를 이번 리뷰에서 생략하셨다고 하셨는데, 위 2번 수정으로 BD-41의 내용이 바뀌었습니다. 그 절만 다시 봐 주시면 좋겠습니다 — 나머지 기록은 변경 없습니다.

@minyongP

Copy link
Copy Markdown
Contributor

Claude Code 자동 리뷰입니다. 판정은 항상 comment이며, 승인·변경 요청은 @minyongP가 직접 남깁니다.

재리뷰입니다 — e7308bb..50436f8 구간의 변경을 봤습니다. 앞선 리뷰의 필수 변경 2건은 둘 다 반영됐습니다 — 마스킹 단언은 치환값 자체를 고정하고 원본 조각까지 훑도록, Refresh 폐기는 afterCommit으로 옮겨 정정됐습니다.

필수 변경 1건 · 확인 요청 2건

  • src/main/java/com/pinlog/pinlogback/domain/member/service/MemberWithdrawalService.java:132 — 이 catch가 폐기를 트랜잭션 밖으로 옮긴 근거 전체인데, 이 경로를 지나는 테스트가 없습니다. 기존 폐기 테스트는 성공 경로만 타므로 catch를 걷어내거나 revokeAll을 다시 트랜잭션 안으로 되돌려도 13건이 전부 통과합니다. RefreshTokenStore가 던지도록 하고 그래도 204와 쿠키 만료가 나오는지 1건이면 고정됩니다.
  • src/main/java/com/pinlog/pinlogback/global/security/authentication/JwtAuthenticationFilter.java:62(구간 밖) — 이 줄로 인증 경로에 DB 의존이 새로 생겼습니다. 필터에서 던진 DataAccessException은 DispatcherServlet 밖이라 @RestControllerAdvice가 잡지 못하는데, DB 순단 시 나가는 응답이 공통 오류 계약(code/message/traceId)을 지키는지 확인되었을까요. 인증 실패로 바꾸자는 뜻은 아닙니다 — BD-28과 반대 방향이 되므로 500이 계약 형태로 나가는지만 궁금합니다.
  • MemberWithdrawalService.java:107(구간 밖) — 리뷰 포인트 1의 잠금 논거가 타 회원과의 경합만 다루는데, 같은 회원의 동시 요청은 어떻게 보셨는지 궁금합니다. 커밋 전까지 다른 탭의 요청은 필터의 isActive를 통과하므로, 이 조회 뒤에 만들어진 Record·Collection은 연쇄 삭제를 지나쳐 탈퇴 회원 소유의 활성 행으로 남습니다. BD-41이 Access 창에서 막은 것과 같은 종류의 잔여 행인데, 창이 트랜잭션 길이라 감수할 만하다는 판단이신지요.

되돌리기 테스트로 세 방어선의 독립성까지 확인한 점이 좋았습니다.

nit 수준 제안은 내지 않습니다. 문서 파일은 이번 리뷰에서 보지 않았습니다.

@minyongP

Copy link
Copy Markdown
Contributor

50436f8에서 두 건(Refresh 폐기 위치, BD-41 문구)이 해소돼서, 남은 것만 모았습니다. afterCommit으로 옮긴 판단과 정정 표시를 남긴 방식 모두 좋습니다. 머지를 막을 만한 건 없고, 한 줄짜리 하나만 이 PR에서 같이 처리하길 제안합니다.

이 PR에서 같이 하면 좋을 것

spring.data.redis.timeout이 설정돼 있지 않습니다.

afterCommit으로 옮기면서 DB 락을 쥔 채 기다리는 문제는 사라졌지만, afterCommit은 여전히 요청 스레드에서 돕니다. application.yml에 Redis 타임아웃이 없어 Lettuce 기본값 60초가 걸리므로, Redis가 멎으면 탈퇴는 커밋됐는데 204 응답이 최대 1분간 지연됩니다.

이 PR이 만든 문제는 아닙니다 — 로그인·재발급도 같은 값에 걸려 있습니다. 다만 커밋 이후 경로가 생기면서 처음으로 사용자 대기로 직결됐습니다. 1~2초면 충분해 보입니다.

후속으로 나눠도 되는 것

1. catch (RuntimeException) 분기에 테스트가 없습니다

이번에 추가된 유일한 새 분기인데, 다른 방어선은 전부 뮤테이션으로 확인하신 것과 대비돼 적어 둡니다. Redis를 내린 채 탈퇴가 204로 끝나고 쿠키가 지워지는지 보는 테스트 하나면, 이 결정의 핵심 근거("삼키지 않으면 확정된 탈퇴가 500으로 나가고 쿠키도 안 지워진다")가 고정됩니다.

2. 필터의 DB 예외가 공통 envelope을 우회합니다

JwtAuthenticationFilter의 javadoc은 원칙을 이렇게 적고 있습니다 — "검증에 실패해도 여기서 401을 쓰지 않는다. 컨텍스트를 비워 둔 채 통과시키면 인가 규칙이 RestAuthenticationEntryPoint로 넘겨 공통 envelope 401을 만든다."

그런데 .filter(memberRepository::isActive)가 던지면 컨텍스트가 비는 게 아니라 체인이 끊깁니다. DataAccessExceptiondoFilterInternal 밖으로 나가 클라이언트는 envelope이 아닌 응답을 받습니다. 이 변경 전까지 이 필터는 순수 JWT 검증이라 인프라 사유로 던질 수 없었습니다.

"DB가 죽으면 어차피 다 죽는다"가 대체로 맞지만, 실제로 갈리는 건 커넥션 풀 고갈입니다 — 아래 3번의 긴 트랜잭션이 풀을 먹으면 그 압박이 그대로 인증 실패로 번집니다. BD-41이 Redis에 대해 피하려던 모양과 같습니다. 삼켜서 미인증 취급할지(→ 401 envelope) 그냥 500으로 둘지 명시적으로 정해 두면 좋겠습니다.

3. 연쇄 삭제가 무제한이고 배치도 없습니다

findByMemberId가 그 회원의 Record 전체를 메모리에 올리고, 그 id 전부가 findByRecordIdIn...의 IN 절에 들어가고, forEach(softDelete)가 행마다 UPDATE 한 건씩 냅니다. hibernate.jdbc.batch_size도 없어 묶이지 않습니다. 사용자가 직접 호출하는 엔드포인트라 상한이 그 회원의 데이터량에 달려 있습니다.

지금 규모에서 문제가 안 될 수 있고, 벌크 @Modifying UPDATE는 영속성 컨텍스트 정합성 때문에 간단하지 않습니다. 우선은 "회원당 Record 상한이 사실상 없다"를 알려진 한계로 남기는 것으로 충분해 보입니다. 지금은 코드 어디에도 이 전제가 적혀 있지 않습니다.

4. 연쇄 완결성이 불변식 하나에 걸려 있습니다

findByCollectionIdIn의 javadoc이 *"Collection이 소유자 자기 Record만 담으므로 이 한 번의 조회가 그 회원의 링크 전체를 덮는다"*고 적고 있습니다. 맞다면 정확한데, 그 불변식이 모든 쓰기 경로에서 강제되지 않으면 남의 Collection에 내 Record 링크가 남습니다. 연쇄의 완결성이 CollectionService.requireAllOwnedActiveRecords 하나에 의존하는 셈이라, 이걸 고정하는 테스트가 있으면 좋겠습니다.

참고

BD-41의 "요청당 PK 조회 한 번"은 실제로는 조회 1회 + 커넥션 풀 체크아웃 1회입니다.

open-in-view: false라 필터의 isActive가 자체 EntityManager와 커넥션을 열고 닫은 뒤, 컨트롤러의 @Transactional이 두 번째 커넥션을 잡습니다. 저트래픽에선 안 보이지만 인증 경로의 풀 여유를 절반으로 깎습니다. BD-41에 한 줄 보태 두면 나중에 튜닝할 때 도움이 될 것 같습니다.

폐기 실패가 자가 치유되지 않습니다.

AuthTokenService.rotateisActive를 보지 않아, 살아남은 Refresh로 재발급하면 save가 새 jti를 Redis에 다시 넣습니다. 발급된 Access는 필터가 막으니 무해하고 BD-41의 논증은 그대로 성립합니다. 다만 고아 인덱스가 계속 갱신되므로, rotate에도 판정 한 줄을 더할지는 취향의 문제로 남겨 둡니다.


행 잠금을 뺀 판단은 제가 보기에도 근거가 맞습니다 — 탈퇴는 세어 분기하지 않고, Collection은 소유자 자기 Record만 담아 타 회원과 경합하지 않습니다. 다만 BD-37 롤백 테스트의 전제까지는 확인하지 않았어서, 그 부분은 @colosair 답변이 필요한 상태 그대로입니다.

#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>
@cherry-go-round

Copy link
Copy Markdown
Contributor Author

반영 완료했습니다 — 49f26fc. 지적 전부 처리하고, 후속은 티켓으로 끊었습니다.

이 PR에서 처리 (요청하신 것)

spring.data.redis.timeout 없었습니다. 확인 결과 application.yml에 Redis 타임아웃 항목 자체가 없어 Lettuce 기본값 60초였습니다.

  data:
    redis:
      timeout: 2s
      connect-timeout: 1s

지적이 정확합니다 — 이 PR이 만든 문제는 아니지만 afterCommit으로 옮기면서 처음으로 사용자 대기에 직결됐습니다.

catch (RuntimeException) 경로에 테스트를 넣었습니다. 후속으로 빼도 된다고 하셨지만, 이번에 추가된 유일한 새 분기인데 다른 방어선은 전부 뮤테이션으로 확인한 것과 대비된다는 지적이 맞습니다.

회원 탈퇴 > Refresh 폐기가 실패해도 탈퇴는 확정되고 쿠키는 지워진다

RefreshTokenStoreRedisConnectionFailureException을 던지게 하고 204·쿠키 3종 만료를 단언합니다. catch를 걷어내면 그 1건만 실패합니다.

확인 요청 2건에 대한 답

① 필터 DB 예외 — 우회하는 것이 맞습니다. 확인해 보니 커스텀 ErrorController가 없고, envelope을 만드는 것은 SecurityErrorWriter(401·403 전용)와 @RestControllerAdvice뿐입니다. 필터에서 나간 DataAccessException둘 다 타지 않습니다.

"커넥션 풀 고갈이 실제 갈림길"이라는 지적이 핵심이라고 봅니다 — BD-41이 Redis에 대해 피하려던 모양이 DB로 재현됩니다. 정책 결정이 필요해 **[S15P11A705-188]**로 분리했고, rotate의 탈퇴 판정(참고 2번)도 "탈퇴 판정을 어디까지 적용하는가"라는 같은 질문이라 그 티켓에 묶었습니다.

② 같은 회원의 동시 요청 — 감수합니다. 지적대로 스냅샷 이후 커밋 전까지 만들어진 행은 연쇄를 지나칩니다. BD-41이 닫은 Access 창과 같은 종류인데 창의 크기가 다릅니다.

발생 조건
Access 창(BD-41이 닫음) 최대 30분 다른 기기에 쿠키가 남아 있으면 됨 — 의도적으로 쓸 수 있다
이 창 트랜잭션 길이(수 ms) 그 순간 진행 중인 쓰기 요청이 있어야 한다

닫으려면 인증 판정을 쓰기 트랜잭션과 묶거나 탈퇴 후 정리 경로를 둬야 해서 구조가 커집니다. BI-29의 "감수하는 것"에 기록했습니다.

참고 2건도 반영

BD-41의 "PK 조회 한 번"이 부정확했습니다. open-in-view: false라 조회 1회 + 커넥션 체크아웃 1회이고 컨트롤러 트랜잭션이 두 번째를 잡습니다. BD-41에 보탰습니다 — 나중에 튜닝할 때 필요한 정보라는 지적 그대로입니다.

후속 티켓

내용
S15P11A705-188 필터 DB 예외의 envelope 정책 확정 (+ rotate의 탈퇴 판정)
S15P11A705-189 연쇄 삭제 상한·배치 — batch_size는 모든 쓰기에 걸려 단독 결정할 값이 아니라는 점 포함
S15P11A705-190 Collection 소유 불변식 테스트 — 연쇄 완결성이 테스트되지 않은 전제에 의존한다

BI-29의 "감수하는 것"에 네 항목(동시 요청 창 · 연쇄 상한 · 불변식 의존 · 폐기 자가치유 부재)을 적어 뒀습니다. 지금 코드 어디에도 이 전제들이 적혀 있지 않았다는 지적이 맞았습니다.

검증

./gradlew clean check --no-daemon409개 통과, 실패 0.

BD-37 롤백 테스트 전제는 @colosair 답변 대기 그대로입니다.

@cherry-go-round
cherry-go-round merged commit 695ba47 into dev Jul 31, 2026
2 checks passed
@cherry-go-round
cherry-go-round deleted the feat/S15P11A705-65-member-withdrawal branch July 31, 2026 01:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(S15P11A705-65): 회원 탈퇴 구현 (소프트 삭제·마스킹·세션 무효화)

3 participants