feat: 회원 탈퇴 (App Store 심사 필수) - #275
Conversation
|
Warning Review limit reached
Next review available in: 111 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (30)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
App Store 심사 필수 항목이고(계정을 만들 수 있으면 앱 안에서 지울 수도 있어야 한다), 개인정보처리방침 9항이 "앱 내 [마이 → 회원탈퇴]" 로 이미 약속한 경로다. 방침에 적힌 권리가 동작하지 않으면 문서 문제가 아니라 규정 위반이다. **도메인마다 자기 데이터를 지운다.** user 가 itinerary·leave 의 서비스를 직접 부르면 사용자 도메인이 "지울 것이 무엇무엇인지" 를 전부 알아야 하고 도메인이 늘 때마다 그 목록을 고쳐야 한다. UserWithdrawn 이벤트를 띄우고 각 도메인이 받아 치운다. **리스너는 동기라 한 트랜잭션이다.** 커밋 뒤 비동기로 지우면 사용자 행만 사라지고 코스·연차가 소유자 없이 남는데, 그건 **다시는 지울 수 없는** 데이터가 된다. 하나라도 실패하면 통째로 롤백한다. **코스·연차는 X-Guest-Id 를 함께 받아야 지워진다.** 소유 키가 아직 guest_id 라 userId 만으로는 닿지 못한다(전환은 별도 PR). 헤더가 없으면 계정만 지우고 warn 을 남긴다 — 없다고 탈퇴를 400 으로 막으면 계정을 지울 권리가 헤더 하나에 인질로 잡힌다. **공유 링크는 남긴다.** 코스가 사라져 410(게시자가 삭제함)으로 답한다. 함께 지우면 404 가 되는데 그건 "링크를 잘못 옮겨 적었다" 와 구분되지 않는다. 남는 행에는 토큰·코스 id·발급 시각뿐이라 탈퇴자의 개인정보가 남지도 않는다. refresh 는 폐기가 아니라 삭제한다 — 계정이 사라져 재사용을 감지해 보호할 대상이 없고, 남겨두면 주인 없는 개인정보만 쌓인다. USER-006 은 탈퇴 후에도 만료까지 살아 있는 access 토큰이 다시 들어왔을 때다.
f404ba0 to
e148a1b
Compare
CI 가 안 돌아간 이유
그래서 로컬에서 전체를 돌렸다 — |
리뷰 — 회원 탈퇴 (CodeRabbit 레이트리밋 대체)구조는 좋다. 이벤트로 도메인이 자기 데이터를 지우는 것, 리스너를 동기 단일 트랜잭션으로 묶어 부분 성공을 막은 것, 벌크 JPQL 대신 파생 delete 로 다만 검증한 것
🔴 W3 — 탈퇴 후 재가입하면 지워지지 않은 옛 데이터가 새 계정에 붙는다 (PR 에 없는 항목)가장 먼저 봐야 할 항목이다. PR 은 "재가입은 새 계정" 을 테스트로 잠갔지만, 그 테스트는 헤더를 보낸 경우만 본다. 헤더 없이 탈퇴한 경로(= PR 이 "문서화된 한계" 라고 부른 그 경로)에서:
사용자 입장에서 "탈퇴가 동작하지 않았다" 이고, 기기를 중고로 넘겼거나 가족과 공유하는 경우 다른 사람이 탈퇴자의 여행 계획·연차 이력을 본다. 연차 사용 내역은 PR 스스로 "근무 이력에 가까운 개인정보" 라고 적은 데이터다. 게다가 그 데이터는 다시는 지울 수 없다 — 이름을 붙일 수 있었던 계정이 사라졌기 때문이다. 유출보다 나쁘다. 유출은 사후 대응이라도 하지만, 이건 삭제 요청이 와도 실행할 방법이 없다. 🟠 W1 —
|
| 그러면 | 왜 |
|---|---|
| W1 해소 | 탈퇴가 헤더를 안 본다. 인증되지 않은 값이 삭제 범위를 정하지 못한다 |
| W2 해소 | 헤더 없이 탈퇴해도 서버가 소유 데이터를 안다. 조용한 잔존이 없다 |
| W3 해소 | 재가입은 새 링크를 만든다. 옛 데이터는 이미 지워졌다 |
| ⑦ 소유 전환의 전제 | guest_id → user_id 이행의 backfill 키가 바로 이 매핑이다. 지금 안 남기면 나중에 이행할 때 기존 사용자의 코스·연차를 누구에게 붙일지 알 방법이 없다 |
비용: 마이그레이션 1개(ADD COLUMN, additive) + 로그인 경로 몇 줄. 소유 전환 PR 이 어차피 해야 하는 일을 앞당기는 것이라 버려지는 작업이 아니다.
한 기기에서 여러 계정이 로그인하면 링크가 덮어써지는 문제는 남지만, 그건 지금 헤더로 하는 것과 정확히 같은 수준이고 서버에 기록이 남는 만큼 낫다.
안 B — 헤더는 그대로 두되 탈퇴 시 필수로 만든다 (400)
W1 은 남고 W2·W3 만 준다. PR 이 반대한 안인데, 반대 근거("계정 삭제 권리가 헤더에 인질로 잡힌다")는 앱이 그 값을 항상 갖고 있다는 점에서 약하다 — 앱은 저장·조회에 그 헤더를 이미 쓰고 있어서, 못 보내는 상황이면 앱이 이미 고장난 상태다.
안 C — 현행 유지 + 문서 강화
W1·W2·W3 전부 남는다. 최소한 W3(재가입 시 옛 데이터 재노출)은 PR 본문과 프론트 답변에 명시되어야 한다. 지금은 아무 데도 없다.
안 D — 소유 전환을 먼저 하고 탈퇴를 그 뒤에
가장 깨끗하지만 심사 일정과 충돌할 수 있다.
추천: A. 마이그레이션 하나 값어치로 세 문제가 동시에 사라지고, 어차피 해야 할 이행의 선행 작업이다.
🟠 W4 — Apple 은 탈퇴 시 토큰 revoke 를 요구한다. 심사 리스크다
이 PR 의 존재 이유가 App Store 심사인데, 여기에 심사 조항이 하나 더 걸린다.
Sign in with Apple 을 제공하면서 계정 삭제를 지원하는 앱은 Sign in with Apple REST API 로 사용자 토큰을 revoke 해야 한다(2022년 6월부터 요구). "우리 DB 만 지우고 Apple 연결은 남긴다" 는 이 조항에 정면으로 걸린다.
PR 은 소셜 연결 해제를 "후속 · Admin 키 대기" 로 묶었는데, 둘의 사정이 다르다:
- 카카오 unlink — Admin 키가 없어 지금 못 한다. 외부 차단이다.
- Apple revoke —
.p8로 client secret JWT 를 만들어POST /auth/revoke를 부르면 되고, feat: 소셜 로그인 (kakao·apple·google) — 앱이 쓰는 계약으로 #93 이APPLE_TEAM_ID·APPLE_KEY_ID·APPLE_PRIVATE_KEY_BASE64주입 경로를 이미 뚫어 놨다. 막고 있는 것이 없다.
심사 전에 Apple revoke 만이라도 넣는 것을 권한다. 카카오와 한 덩어리로 묶어 미루면, 막지 않은 것 때문에 막힌 것과 함께 늦어진다.
🟡 W5 — USER-006 은 탈퇴 엔드포인트만 막는다
탈퇴 후에도 access 토큰은 만료까지 서명 검증을 통과한다(무상태 JWT 의 대가, 문서화됨). 이 PR 은 그 창에서 두 번째 탈퇴가 200 을 내는 것을 USER-006 으로 막았다 — 좋은 처리다.
다만 막힌 것은 그 엔드포인트뿐이다. @LoginUser 를 쓰는 다른 경로는 사용자 존재를 확인하지 않아, 지워진 계정의 토큰으로 최대 1시간 동안 인증을 통과한다. 지금은 userId 로 데이터를 읽는 엔드포인트가 사실상 없어 피해가 없다. 프로필·알림처럼 userId 기준 조회가 붙는 순간 "지워진 계정이 응답을 받는" 상태가 되므로, 그때는 필터나 공통 지점에서 한 번 걸러야 한다. 지금 고칠 것은 아니고 알아둘 것이다.
🟢 Low · nit
deleteByGuestId는 엔티티를 로드해 지운다 —orphanRemoval을 살리려는 의도적 선택이고 맞다. 다만 코스가 많은 사용자는day_schedule·slot컬렉션까지 끌어온다. 탈퇴는 드문 작업이라 지금은 문제없고,default_batch_fetch_size가 묶어 준다. 판단 근거가 주석에 있어 좋다.UserWithdrawalIntegrationTest에 클래스 레벨@Transactional이 없다(고유 UUID 로 격리).AuthIntegrationTest와 같은 방식이라 일관되지만, 그쪽에는 이유를 적은 주석이 있고 여기엔 없다. 한 줄이면 다음 사람이 "빠뜨린 것" 으로 오해하지 않는다.탈퇴는_다른_사람의_데이터를_건드리지_않는다가 헬퍼 아래(맨 끝)에 있다. 위쪽 시나리오 구획으로 올리면 읽는 순서가 맞다.ApiResponseBody.okWithDetail을ok(T)와 이름으로 가른 판단은 맞다 —T가 String 일 때 어느 쪽이 불릴지 읽는 사람이 알 수 없다는 근거가 정확하다.- 공유 링크를 묘비로 남기는 판단(410 vs 404), 그리고 "그 행에는 탈퇴자를 가리키는 것이 없다" 는 개인정보 근거 — 둘 다 좋다. 이견 없다.
사람이 정해야 할 것
- 탈퇴 범위 (W1·W2·W3) — 안 A/B/C/D 중 선택. A 를 권한다(마이그레이션 1개로 셋 다 해소 + 소유 전환의 선행 작업).
- Apple revoke 를 심사 전에 넣을지 (W4) — 카카오와 달리 막고 있는 것이 없다. 안 넣으면 심사에서 지적될 수 있다.
- W3 을 최소한 문서화할지 — A 를 택하지 않는다면 재가입 시 옛 데이터 재노출은 반드시 PR 본문·프론트 답변에 들어가야 한다.
머지는 하지 않았다. base(#93)에 보안 수정 2건이 올라갔으니 최신으로 당겨 주면 된다 — 충돌 없음을 확인했다.
|
@coderabbitai review |
|
Situation
App Store 심사 필수 항목이다. 계정을 만들 수 있는 앱은 앱 안에서 지울 수도 있어야 한다
(App Review Guideline 5.1.1(v)). 소셜 로그인이 붙는 순간 이 조항이 우리에게 적용된다.
그리고 이미 약속했다. 개인정보처리방침(
https://offway.cloud/privacy) 9항이 "앱 내[마이 → 회원탈퇴]" 로 탈퇴 경로를 명시하고 있다. 방침에 적힌 권리가 동작하지 않으면 그건 문서 문제가
아니라 규정 위반이다.
지금 탈퇴 코드는 아예 없다.
users·user_identity·refresh_token어디에도 지우는 경로가 없다.Task
프론트가 명시적으로 물었다 — "탈퇴 시 연차·저장 코스·공유 토큰까지 함께 지워지는지 알려주세요."
여기에 걸림돌이 있다. 소유 키가
guestId→userId로 옮겨가는 중인데 아직 안 옮겨졌다.인증이 주는 것은
userId(UUID)인데, 코스·연차는 전부guest_id(VARCHAR)로 묶여 있다. 그래서"탈퇴가 무엇을 지우는가" 는 그 이행 상태에 달렸고, 답을 얼버무리면 안 되는 질문이다.
Action
무엇이 지워지고 무엇이 남나 (프론트 질문에 대한 답)
users) · 소셜 연결(user_identity) ·refresh_tokenusers.idcourse) + 일정·슬롯(day_schedule·slot)guest_idX-Guest-Id필요leave_balance·leave_usage)guest_idX-Guest-Id필요trip_outcome)guest_idX-Guest-Id필요course_share)앱은
X-Guest-Id를 반드시 함께 보내야 한다. 저장·조회에 쓰던 것과 같은 값이다. 없으면 계정만지워지고 코스·연차는 남는다. 그래도 헤더 없는 탈퇴를 400 으로 막지 않았다 — 심사·방침이 요구하는
것은 계정 삭제이고, 그 권리가 헤더 하나에 인질로 잡히면 안 된다. 대신 못 지운 것을 warn 으로 남기고
*Api문서에 굵게 적었다.공유 링크를 남긴 이유
course_share는 코스가 지워져도 살아남아 410(ITINERARY-009게시자가 삭제한 코스입니다)으로 답하는묘비다. 탈퇴에서도 그대로 둔다.
함께 지우면 404 가 되는데, 그건 "링크를 잘못 옮겨 적었다" 와 구분되지 않는다. 링크를 받은 사람은
자기가 오타를 낸 건지 원본이 사라진 건지 알 수 없다. 410 은 "있었는데 게시자가 지웠다" 를 정확히 말한다.
개인정보 관점에서도 남길 수 있다 — 그 행에는 토큰·코스 id·발급 시각뿐이고 탈퇴자를 가리키는 것이
하나도 없다. 코스 본문이 사라졌으므로 링크로 볼 수 있는 내용도 없다.
도메인마다 자기 데이터를 지운다
user가itinerary·leave의 서비스를 직접 부르면 사용자 도메인이 "지울 것이 무엇무엇인지" 를 전부알아야 하고, 도메인이 하나 늘 때마다 그 목록을 고쳐야 한다.
UserWithdrawn이벤트를 띄우고 각 도메인이받아 자기 테이블을 치운다 — 스키마를 아는 쪽과 지우는 쪽이 일치한다.
리스너는 동기라 전부 한 트랜잭션이다. 커밋 뒤 비동기로 지우면 사용자 행만 사라지고 코스·연차가
소유자 없이 남아 다시는 지울 수 없는 데이터가 된다. 개인정보를 지우는 작업이라 부분 성공을 허용하지
않았다 — 하나라도 실패하면 통째로 롤백하고 사용자에게 실패를 알린다.
걸려 넘어질 뻔한 것들
벌크 JPQL 을 쓰지 않았다.
delete from Course where guestId = ...는 빠르지만@OneToMany(cascade, orphanRemoval)를 건너뛰어day_schedule·slot이 고아로 남는다. 이 레포는 FK제약을 두지 않으므로(persistence-convention) DB 도 막아주지 않는다. 파생 delete 로 엔티티를 로드해
지운다.
user_identity를 반드시 지워야 한다. 안 지우면(provider, provider_user_id)UNIQUE 가 남아,같은 사람이 다시 가입할 때 없는 사용자를 가리키는 신원에 붙는다. 재가입이 새 계정이 되는지를 테스트로
잠갔다.
refresh_token은 폐기(revoked_at)가 아니라 삭제한다. 평소에 지우지 않는 이유는 "폐기된 토큰재사용" 과 "없는 토큰" 을 구분해 탈취를 감지하기 위해서인데, 탈퇴는 다르다 — 계정 자체가 사라져 감지해서
보호할 대상이 없고, 남겨두면 주인 없는 개인정보만 쌓인다.
access 토큰은 탈퇴 후에도 만료(기본 1시간)까지 서명 검증을 통과한다. 무상태 JWT 의 대가로 원래
설계의 선택이다. 그 창에 들어온 요청을 그냥 두면 없는 계정에 삭제가 또 돌아 200 이 나가고 앱이 두 번째
탈퇴도 성공했다고 오해한다.
USER-006(이미 탈퇴한 계정 · 401)으로 끊어 앱이 로그인 화면으로 돌아가게했다.
성공 detail 이 "탈퇴 처리되었습니다." 여야 해서
ApiResponseBody.okWithDetail(...)을 더했다. 기존성공 detail 은 고정 문구 하나뿐이었다.
ok(T)와 시그니처가 겹치지 않게 이름을 다르게 뒀다 — 겹치면T가 String 일 때 어느 쪽이 불릴지 읽는 사람이 알 수 없다.Result
전체 테스트 통과. 탈퇴가 더한 통합 테스트 8건이 지우는 것과 남기는 것을 양쪽 다 잠근다.
detailUSER-006user_identityUNIQUE 가 정리됐는가남은 것 · 알려진 한계
POST /v1/user/unlink)는 Admin 키가 필요한데 아직 못받았다. Apple revoke 는
.p8로 client secret JWT 를 만들어야 한다(feat: 소셜 로그인 (kakao·apple·google) — 앱이 쓰는 계약으로 #93 이APPLE_TEAM_ID·APPLE_KEY_ID·APPLE_PRIVATE_KEY_BASE64주입 경로를 미리 뚫어 뒀다). 우리 데이터는 지워지지만provider 의 '연결된 서비스' 목록에는 남는다. Admin 키를 받으면 후속 PR.
X-Guest-Id는 인증되지 않은 값이다. 남의 guestId 를 적어 보내면 그 데이터가 지워진다. 다만 이건이 PR 이 만든 구멍이 아니라 게스트 스킴 전체의 성질이다 — 지금도 같은 헤더로 코스를 하나씩 지울 수
있다. 새로 생긴 것은 한 번에 지우는 편의지 새 권한이 아니다. 소유 키가
userId로 옮겨가면 이 인자와함께 사라진다.
guest_id가 비어 어떤 소유자 조회에도 안 걸린다.애초에 어떤 식별자와도 묶여 있지 않아 탈퇴자에게 되짚을 수 없고, 정리는
course_share.created_at기준별도 작업의 몫이다.
연관 이슈