계정/데이터 삭제 — 스토어 출시 요건에서 잔존 0 회귀 방어선까지 (2026-08-25)
- 기간: 2026-08-24 ~ 2026-08-25 (설계 1세션 + 구현 1세션, 오너 지시 "앱 출시에 필요한 계정 삭제/데이터 삭제 기능" → 08-25 "phase 단위 계획 게시 후 승인 없이 완주")
- 랜딩: PR #721(Phase 1,
6537949b) · #727(Phase 2,7eb6ec2e) · #731(Phase 3,c38af1a7) · #732(Phase 4,4d95ad7b) — 마이그레이션 0건, Vercel Production 배포·실측 완료 - 설계서: 이슈 #663 본문(설계 전문 + "예상 효과" 표, artifact
64b255f1은 참고 사본) - 정본:
api/account/delete.js(삭제 순서 계약) ·src/react/services/accountDeletionClient.ts·src/react/ui/shared/accountDeletionCopy.ts(확인 문구계정 삭제) ·native-bridge.mdApple 재인증 절 ·social-auth.mdprovider 해제 절 - 도구: Management API 쿼리 러너(세션 scratchpad, 레포 밖) — Production 잔존 스캔·프로브 계정 생성에 사용
- 게이트: error-case
CASE-017-account-deletion-round-trip(full-ci 레인) · pgTAPaccount_deletion_residue_scan.test.sql(동적 잔존 스캔) · 유닛tests/auth/accountDeletionProviderDisconnect.test.mjs - 버그리포트: 없음(신규 기능 트랙)
- 계약:
profile-screen-props.mdonDeleteAccount·desktop-screens-props.md계정 카드 절 ·native-bridge.mdappleAccountDeletionReauthV1
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 정책·범위 확정(설계서, D1~D4) | ✅ 이슈 #663 본문 |
| Phase 1 | 서버 삭제 경로 + 왕복 회귀 방어선 | ✅ PR #721 (6537949b) |
| Phase 2 | 앱 내 계정 삭제 UI(2단 확인) | ✅ PR #727 (7eb6ec2e) |
| Phase 3 | 소셜 연결 해제(Kakao unlink·Apple revoke) | ✅ PR #731 (c38af1a7) — 오너 잔여: Vercel env 4종·Apple 실기기 |
| Phase 4 | 웹 삭제 안내 페이지 + 스토어 문서 | ✅ PR #732 (4d95ad7b) |
| Phase 5 | Production 검증·기록 | ✅ 이 문서 PR |
1. 배경
Google Play는 계정을 만드는 앱에 앱 내 계정 삭제 경로 + 웹 URL을, App Store는 심사 지침 5.1.1(v)로 앱 내 계정 삭제와 Sign in with Apple 토큰 revoke를 요구한다. 앱에는 데이터 내보내기만 있고 삭제 경로가 없어 android-release.md에 미결 항목으로 남아 출시를 막고 있었다(08-24 앱스토어 심사 점검에서 "반려 확실" 판정). 08-24 설계 세션이 스키마 61k줄·클라이언트 표면을 전수 조사해 이슈 #663에 설계서를 남겼고, 08-25 오너가 완주를 지시했다.
2. 문제 제기
삭제 수단이 운영자 수동 SQL뿐이었다
유저가 자신의 데이터를 파기할 방법이 없었다. 계정 삭제 RPC·엣지·API 0건.
DB CASCADE만으로는 "데이터 삭제"가 완성되지 않는다
auth.users 삭제는 42개 테이블을 CASCADE로 정리하지만(기검증: pgTAP + CASE-007), storage.objects는 FK가 없어 3버킷(profile-images·inbody-imports·wodup-imports)의 {uid}/ 폴더가 영구 잔존한다. 사진·인바디/Wodup 인입 원본이 남으면 파기 의무 위반이다.
미래 테이블이 조용히 잔존을 만들 수 있었다
기존 검증은 고정 테이블 목록 단언이라, CASCADE 없는 새 테이블이 추가되면 아무 게이트도 울리지 않는다.
Apple revoke는 저장된 토큰이 없어 불가능했다
네이티브 Apple 로그인은 id_token만 쓰고 refresh token을 보관하지 않는다 — revoke할 대상 자체가 없었다.
3. 해결 방안
원칙 (오너 위임에 따라 설계서 권장안 확정, 2026-08-25)
- D1 = 확인 즉시 하드 삭제 — CASCADE 검증 자산 그대로, 마이그레이션 0건. 30일 유예는 상태 컬럼·로그인 차단·복구 경로·핸들 예약을 새로 만드는 범위 2배 확장이라 기각.
- D2 = 소셜 흔적 완전 삭제 — CASCADE 기본 동작이자 파기 요구와 정합. 댓글 묘비 보존 기각.
- D3 = provider 해제 v1 포함 — 코드·브리지·서버 모듈 전부 구현, env 등록만 오너 잔여. 후속 트랙으로 미루면 iOS 심사 리스크가 남아 기각.
- D4 = 정적 안내 페이지 — 웹앱 자체가 재설치 없는 삭제 경로. 이메일 인증 독립 폼은 과잉이라 기각.
접근
- 오케스트레이션은 Vercel 서버리스
api/account/delete.js한 곳:auth.users삭제는 service-role, 스토리지 정리·provider 해제는 외부 HTTP라 SQL 함수 불가. Edge Function(JWT 검증 재작성 필요)·SECURITY DEFINER RPC 단독(외부 HTTP 불가)은 기각. - 순서 계약: JWT 검증(uid는 JWT에서만, 파라미터 없음) → 확인 센티널 → provider 해제(실패 = 경고) → 스토리지 3버킷 재귀 정리 →
deleteUser. 비가역 단계가 맨 뒤라 부분 실패는 같은 요청 재시도로 복구된다. - 미래 테이블 방어는 고정 목록이 아니라 동적 발견: 이름 규약(
user_id·*_user_id) + FK(auth.users/profiles) 합집합으로 user-연결 컬럼을 카탈로그에서 찾아 삭제 뒤 잔존 0을 단언.
4. 적용한 내용
Phase 1 — 서버 삭제 경로 + 왕복 회귀 방어선 (#721)
api/account/delete.js 신설(위 순서 계약, 유저 재삭제 404=성공 멱등). vite.config.mjs에 핸들러 등록 + CI E2E_SUPABASE_* env를 Vercel 런타임 이름으로 별칭 — 로컬·CI가 배포와 동일한 핸들러 파일을 실행한다. error-case CASE-017(대상·대조군 실계정 + 버킷별 실제 오브젝트 → 왕복) + pgTAP account_deletion_residue_scan.test.sql(스캐너 표면 ≥40쌍 sanity + 시드 가시성 + 삭제 뒤 잔존 0 + 대조군 보존).
Phase 2 — 앱 내 계정 삭제 UI (#727)
profileController.deleteAccount()(임퍼서네이션 열람 차단 → API → 로컬 정리) + accountDeletionClient. 모바일 UiAccountDeleteSection(로그아웃 아래, 자체 상태 — ProfileScreen 무상태 유지) + 데스크톱 계정 카드 행 + DkScrim 다이얼로그, 공통 2단 확인(삭제 목록·내보내기 안내·공유 계획 파급 고지 → 확인 문구 계정 삭제 입력, 일치 전 버튼 비활성). supabaseAuth.clearAuthSessionAfterAccountDeletion 신설 — deleteUser가 서버 세션·리프레시 토큰을 이미 전부 파기했으므로 죽은 JWT로 /logout을 부르지 않고 네이티브 세션·localStorage·자동 갱신 타이머만 지운다(네트워크 0회 — 삭제 뒤 4xx가 관측 그물에 잡히지 않는 구조적 선택). designContract 마커 7종, 계약 문서 2건. CASE-017을 실제 UI 주행으로 확장.
Phase 3 — 소셜 연결 해제 (#731)
_kakao.js(Admin 키 unlink)·_apple.js(client secret ES256 JWS → code 교환 → /auth/revoke). iOS MainViewController에 appleAccountDeletionReauth 브리지(삭제 시점 시스템 시트 재인증 → requestId 상관 이벤트로 authorization code 반환). 해제 실패·env 미설정·code 부재는 *_skipped_*/*_failed 경고로 강등 — 연결 해제 실패가 파기 의무 이행을 막지 않는다. 유닛 5종(공개키로 JWS 검증 등).
Phase 4 — 웹 안내 페이지 + 스토어 문서 (#732)
public/account/delete.html(legal.css 재사용, public/legal/ 밖 — 불변 스냅샷 게이트 회피) + sitemap 등록 + android-release.md 미결 해소(웹 URL https://www.barbelic.com/account/delete.html).
Phase 5 — Production 검증
앱 프로젝트 Vercel에 SUPABASE_URL·SUPABASE_PUBLISHABLE_KEY·SUPABASE_SERVICE_ROLE_KEY가 없어 엔드포인트가 server_not_configured였다(admin api는 barbelic-docs 프로젝트에서만 env 보유). 셋을 앱 프로젝트 Production에 Sensitive로 등록하고 재배포 후, 일회용 프로브 계정(SQL 생성 → 이메일 grant 세션 → 세션 1행 + 스토리지 1개 시드)으로 실제 Production UI(내 정보 → 계정 삭제 → 2단 확인 → 로그인 화면 전환)를 주행했다.
주요 결정과 그 근거
- uid 파라미터 금지: 대상은 항상 호출자 JWT — 파괴적 엔드포인트에서 대상 지정 실수·악용 여지를 구조적으로 제거.
- 확인 센티널(
delete-my-account): 인증된 우발 POST가 계정을 지울 수 없다. 서버 게이트고, UI 문구 입력은 별도의 사람 게이트. - 경고 강등 정책: provider 해제는 의무(파기)가 아니라 예의(연결 정리) — 실패가 삭제를 막으면 본말전도.
작업 중 드러난 것
- 앱 프로젝트 Vercel에 SUPABASE_ env가 아예 없었다* —
api/admin/*은 barbelic-docs에서만 살아 있었고, 설계서의 "시크릿 위치 재사용" 전제가 앱 프로젝트에는 성립하지 않았다. service role 키는 Management APIapi-keys?reveal=true로 받아 파일 경유로만 다루고 즉시 삭제. - Vercel redeploy는 ignoreCommand를 다시 평가한다 — docs-only 커밋의 redeploy는 2초 만에 Canceled. src를 만진 최신 커밋을 redeploy해야 env가 반영된 새 배포가 나온다.
- error-case 픽스처의
deleteCurrentUser는 재삭제 404를 실패로 봤다 — API가 먼저 지운 유저의 재삭제는 정리 관점에서 "삭제됨"이라, 404를 인정하도록 확장(CASE-007 성공 경로 단언 불변). - Production은 이메일 password grant가 열려 있어(가입 UI는 없지만) SQL 생성 프로브 계정 + grant 세션 + localStorage 주입으로 실제 배포 UI를 로그인 상태로 주행할 수 있다.
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
| 유저 셀프서비스 삭제 수단 | 없음(운영자 수동 SQL) | 앱·웹 공통 2단 확인 삭제, Production 실주행 확인 |
| 삭제 후 DB 잔존 (user-연결 58쌍 동적 스캔) | — | 0행 (Production 실측) |
삭제 후 스토리지 잔존 (storage.objects {uid}/) | 영구 잔존 (FK 없음) | 0개 (Production 실측) |
| 타 계정 영향 | — | 무영향 (총 유저 수 5 불변, 대조군 데이터·렌더 보존 — CASE-017) |
| 재호출 | — | 401 멱등 (Production 실측) |
android-release.md 계정 삭제 미결 | 1건 | 0건 (웹 URL /account/delete.html 200 확인) |
| 미래 테이블 잔존 회귀 방어 | 고정 목록 단언 | 동적 스캔 pgTAP(카탈로그 발견, 픽스처 수정 불필요) |
| CASE-017 full-ci | — | passed (run 32753343890, 7eb6ec2e) |
미검증: ① Apple revoke 실동작(오너 실기기 + 새 iOS 빌드 필요 — 재인증 브리지는 코드 완성, 취소·미지원 시 삭제는 진행) ② Kakao unlink 실동작(KAKAO_ADMIN_KEY 미등록 — 등록 전까지 kakao_unlink_skipped_no_admin_key 경고로 강등) ③ iOS/Android 셸에서의 UI 주행(웹 Production만 실측).
6. 이번 개선으로 향상된 것
스토어 출시 차단 요건 해소
Play 데이터 안전 양식의 앱 내 삭제 경로 + 웹 URL, App Store 5.1.1(v)의 앱 내 삭제가 준비됐다. Apple revoke는 코드 완성·env 대기.
"삭제하면 정말 다 지워진다"가 게이트로 고정됐다
CASE-017(버튼부터 잔존 0까지 한 왕복) + 동적 잔존 스캔 pgTAP — CASCADE 없는 테이블이 미래에 추가되면 사람이 기억하지 않아도 CI가 실패한다.
구조적으로 남는 것
- 유저 셀프서비스 service-role api 패턴(
api/account/— 지금까지 관리자 전용뿐이었다)과 삭제 순서 계약(비가역 맨 뒤·전 단계 멱등). - provider 해제 모듈(
_apple.js의 ES256 client secret 생성 포함)은 향후 계정 연결 해제 기능에서 재사용 가능. - 삭제 후 로컬 정리의 "네트워크 0회" 원칙(
clearAuthSessionAfterAccountDeletion).
남은 것
- 오너: Vercel env 4종 등록(
APPLE_TEAM_ID·APPLE_KEY_ID·APPLE_PRIVATE_KEY·KAKAO_ADMIN_KEY, social-auth.md 표) → Apple revoke 실기기 확인(새 iOS 빌드). - 재가입 시
lift_guild_adminsemail-only 행으로 권한이 부활할 수 있는 알려진 v1 범위 밖 항목(설계서 문서화됨, 일반 유저 무관).