Skip to content

feat: 회원 탈퇴 (App Store 심사 필수) - #275

Open
sevineleven wants to merge 1 commit into
feat/34-oauth-user-authfrom
feat/271-account-withdrawal
Open

feat: 회원 탈퇴 (App Store 심사 필수)#275
sevineleven wants to merge 1 commit into
feat/34-oauth-user-authfrom
feat/271-account-withdrawal

Conversation

@sevineleven

Copy link
Copy Markdown
Contributor

스택 PR — base 가 dev 가 아니라 feat/34-oauth-user-auth(#93)다. 탈퇴는 "누가 탈퇴하는지" 를
알아야 해서 인증 없이는 존재할 수 없다. #93 이 머지되면 base 를 dev 로 바꾼다.

Situation

App Store 심사 필수 항목이다. 계정을 만들 수 있는 앱은 앱 안에서 지울 수도 있어야 한다
(App Review Guideline 5.1.1(v)). 소셜 로그인이 붙는 순간 이 조항이 우리에게 적용된다.

그리고 이미 약속했다. 개인정보처리방침(https://offway.cloud/privacy) 9항이 "앱 내
[마이 → 회원탈퇴]" 로 탈퇴 경로를 명시하고 있다. 방침에 적힌 권리가 동작하지 않으면 그건 문서 문제가
아니라 규정 위반이다.

지금 탈퇴 코드는 아예 없다. users·user_identity·refresh_token 어디에도 지우는 경로가 없다.

Task

프론트가 명시적으로 물었다 — "탈퇴 시 연차·저장 코스·공유 토큰까지 함께 지워지는지 알려주세요."

여기에 걸림돌이 있다. 소유 키가 guestIduserId 로 옮겨가는 중인데 아직 안 옮겨졌다.
인증이 주는 것은 userId(UUID)인데, 코스·연차는 전부 guest_id(VARCHAR)로 묶여 있다. 그래서
"탈퇴가 무엇을 지우는가" 는 그 이행 상태에 달렸고, 답을 얼버무리면 안 되는 질문이다.

Action

DELETE /api/v1/users/me
Authorization: Bearer <access>     (필수)
X-Guest-Id: <guestId>              (선택 — 아래 참고)
→ 200 { "status": 200, "code": "OK", "detail": "탈퇴 처리되었습니다.", "data": null }

무엇이 지워지고 무엇이 남나 (프론트 질문에 대한 답)

데이터 소유 키 탈퇴하면
계정(users) · 소셜 연결(user_identity) · refresh_token users.id 지워진다
저장 코스(course) + 일정·슬롯(day_schedule·slot) guest_id 지워진다X-Guest-Id 필요
연차 설정·사용 내역(leave_balance·leave_usage) guest_id 지워진다X-Guest-Id 필요
여행 후기 응답(trip_outcome) guest_id 지워진다X-Guest-Id 필요
공유 링크(course_share) 남는다 (의도) → 410
소유자 없는 "공유만" 코스(#261) 없음 못 지운다
provider 쪽 연결(카카오·Apple '연결된 서비스') 안 끊는다 (후속)

앱은 X-Guest-Id 를 반드시 함께 보내야 한다. 저장·조회에 쓰던 것과 같은 값이다. 없으면 계정만
지워지고 코스·연차는 남는다. 그래도 헤더 없는 탈퇴를 400 으로 막지 않았다 — 심사·방침이 요구하는
것은 계정 삭제이고, 그 권리가 헤더 하나에 인질로 잡히면 안 된다. 대신 못 지운 것을 warn 으로 남기고
*Api 문서에 굵게 적었다.

공유 링크를 남긴 이유

course_share 는 코스가 지워져도 살아남아 410(ITINERARY-009 게시자가 삭제한 코스입니다)으로 답하는
묘비다. 탈퇴에서도 그대로 둔다.

함께 지우면 404 가 되는데, 그건 "링크를 잘못 옮겨 적었다" 와 구분되지 않는다. 링크를 받은 사람은
자기가 오타를 낸 건지 원본이 사라진 건지 알 수 없다. 410 은 "있었는데 게시자가 지웠다" 를 정확히 말한다.

개인정보 관점에서도 남길 수 있다 — 그 행에는 토큰·코스 id·발급 시각뿐이고 탈퇴자를 가리키는 것이
하나도 없다. 코스 본문이 사라졌으므로 링크로 볼 수 있는 내용도 없다.

도메인마다 자기 데이터를 지운다

useritinerary·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건이 지우는 것과 남기는 것을 양쪽 다 잠근다.

시나리오 잠근 것
탈퇴 200 + detail 응답 계약
코스·연차·후기 삭제 게스트 키 데이터가 실제로 사라지는가
헤더 없으면 계정만 삭제 문서화된 한계가 실제 동작과 같은가
공유 링크 410 남기기로 한 것이 남고, 의도한 상태로 답하는가
refresh 재발급 401 세션이 끊기는가
재탈퇴 401 USER-006 무상태 토큰 창이 막히는가
재가입은 새 계정 user_identity UNIQUE 가 정리됐는가
남의 데이터는 안 건드림 삭제 범위가 자기 것으로 한정되는가

남은 것 · 알려진 한계

  • 소셜 연결 해제를 안 한다. 카카오 unlink(POST /v1/user/unlink)는 Admin 키가 필요한데 아직 못
    받았다. Apple revoke 는 .p8 로 client secret JWT 를 만들어야 한다(feat: 소셜 로그인 (kakao·apple·google) — 앱이 쓰는 계약으로 #93APPLE_TEAM_ID·
    APPLE_KEY_ID·APPLE_PRIVATE_KEY_BASE64 주입 경로를 미리 뚫어 뒀다). 우리 데이터는 지워지지만
    provider 의 '연결된 서비스' 목록에는 남는다. Admin 키를 받으면 후속 PR.
  • X-Guest-Id 는 인증되지 않은 값이다. 남의 guestId 를 적어 보내면 그 데이터가 지워진다. 다만 이건
    이 PR 이 만든 구멍이 아니라 게스트 스킴 전체의 성질이다 — 지금도 같은 헤더로 코스를 하나씩 지울 수
    있다. 새로 생긴 것은 한 번에 지우는 편의지 새 권한이 아니다. 소유 키가 userId 로 옮겨가면 이 인자와
    함께 사라진다.
  • 소유자 없는 "공유만" 코스(담지 않고 공유 링크만 발급하는 API #261)는 못 지운다. guest_id 가 비어 어떤 소유자 조회에도 안 걸린다.
    애초에 어떤 식별자와도 묶여 있지 않아 탈퇴자에게 되짚을 수 없고, 정리는 course_share.created_at 기준
    별도 작업의 몫이다.
  • 탈퇴 사유 수집 · 유예 기간(soft delete)은 범위 밖이다. 즉시 삭제로 간다.

연관 이슈

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sevineleven, you've reached your PR review limit, so we couldn't start this review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d6db705c-b2e9-4422-a7e3-c91ae5876c4e

📥 Commits

Reviewing files that changed from the base of the PR and between 67d0990 and e148a1b.

📒 Files selected for processing (30)
  • src/main/java/com/offway/core/common/response/ApiResponseBody.java
  • src/main/java/com/offway/core/itinerary/event/CoursePurgeOnUserWithdrawn.java
  • src/main/java/com/offway/core/itinerary/repository/CourseJpaRepository.java
  • src/main/java/com/offway/core/itinerary/repository/CourseRepository.java
  • src/main/java/com/offway/core/itinerary/repository/CourseRepositoryImpl.java
  • src/main/java/com/offway/core/itinerary/repository/TripOutcomeJpaRepository.java
  • src/main/java/com/offway/core/itinerary/repository/TripOutcomeRepository.java
  • src/main/java/com/offway/core/itinerary/repository/TripOutcomeRepositoryImpl.java
  • src/main/java/com/offway/core/leave/event/LeavePurgeOnUserWithdrawn.java
  • src/main/java/com/offway/core/leave/repository/LeaveBalanceJpaRepository.java
  • src/main/java/com/offway/core/leave/repository/LeaveBalanceRepository.java
  • src/main/java/com/offway/core/leave/repository/LeaveBalanceRepositoryImpl.java
  • src/main/java/com/offway/core/leave/repository/LeaveUsageJpaRepository.java
  • src/main/java/com/offway/core/leave/repository/LeaveUsageRepository.java
  • src/main/java/com/offway/core/leave/repository/LeaveUsageRepositoryImpl.java
  • src/main/java/com/offway/core/user/controller/UserApi.java
  • src/main/java/com/offway/core/user/controller/UserController.java
  • src/main/java/com/offway/core/user/domain/UserErrorCode.java
  • src/main/java/com/offway/core/user/domain/UserException.java
  • src/main/java/com/offway/core/user/event/UserWithdrawn.java
  • src/main/java/com/offway/core/user/repository/RefreshTokenJpaRepository.java
  • src/main/java/com/offway/core/user/repository/RefreshTokenRepository.java
  • src/main/java/com/offway/core/user/repository/RefreshTokenRepositoryImpl.java
  • src/main/java/com/offway/core/user/repository/UserIdentityJpaRepository.java
  • src/main/java/com/offway/core/user/repository/UserIdentityRepository.java
  • src/main/java/com/offway/core/user/repository/UserIdentityRepositoryImpl.java
  • src/main/java/com/offway/core/user/repository/UserRepository.java
  • src/main/java/com/offway/core/user/repository/UserRepositoryImpl.java
  • src/main/java/com/offway/core/user/service/UserWithdrawalService.java
  • src/test/java/com/offway/core/user/controller/UserWithdrawalIntegrationTest.java

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

App Store 심사 필수 항목이고(계정을 만들 수 있으면 앱 안에서 지울 수도 있어야 한다),
개인정보처리방침 9항이 "앱 내 [마이 → 회원탈퇴]" 로 이미 약속한 경로다. 방침에 적힌
권리가 동작하지 않으면 문서 문제가 아니라 규정 위반이다.

**도메인마다 자기 데이터를 지운다.** user 가 itinerary·leave 의 서비스를 직접 부르면
사용자 도메인이 "지울 것이 무엇무엇인지" 를 전부 알아야 하고 도메인이 늘 때마다 그 목록을
고쳐야 한다. UserWithdrawn 이벤트를 띄우고 각 도메인이 받아 치운다.

**리스너는 동기라 한 트랜잭션이다.** 커밋 뒤 비동기로 지우면 사용자 행만 사라지고 코스·연차가
소유자 없이 남는데, 그건 **다시는 지울 수 없는** 데이터가 된다. 하나라도 실패하면 통째로
롤백한다.

**코스·연차는 X-Guest-Id 를 함께 받아야 지워진다.** 소유 키가 아직 guest_id 라 userId
만으로는 닿지 못한다(전환은 별도 PR). 헤더가 없으면 계정만 지우고 warn 을 남긴다 — 없다고
탈퇴를 400 으로 막으면 계정을 지울 권리가 헤더 하나에 인질로 잡힌다.

**공유 링크는 남긴다.** 코스가 사라져 410(게시자가 삭제함)으로 답한다. 함께 지우면 404 가
되는데 그건 "링크를 잘못 옮겨 적었다" 와 구분되지 않는다. 남는 행에는 토큰·코스 id·발급
시각뿐이라 탈퇴자의 개인정보가 남지도 않는다.

refresh 는 폐기가 아니라 삭제한다 — 계정이 사라져 재사용을 감지해 보호할 대상이 없고,
남겨두면 주인 없는 개인정보만 쌓인다. USER-006 은 탈퇴 후에도 만료까지 살아 있는 access
토큰이 다시 들어왔을 때다.
@sevineleven
sevineleven force-pushed the feat/271-account-withdrawal branch from f404ba0 to e148a1b Compare August 13, 2026 17:12
@sevineleven

Copy link
Copy Markdown
Contributor Author

CI 가 안 돌아간 이유

ci.ymldev 로 향하는 PR 에만 걸린다(on.pull_request.branches: [dev]). 이 PR 은 base 가
feat/34-oauth-user-auth 라 자동 검사가 실행되지 않는다. #93 이 머지되고 base 를 dev 로 바꾸면 그때 돈다.

그래서 로컬에서 전체를 돌렸다./gradlew test 1,402건 통과 (실패 0 · skip 21, Testcontainers
MySQL). 컨벤션 훅도 변경 파일 전수 검사에서 차단 0건이다.

@sevineleven

Copy link
Copy Markdown
Contributor Author

리뷰 — 회원 탈퇴 (CodeRabbit 레이트리밋 대체)

구조는 좋다. 이벤트로 도메인이 자기 데이터를 지우는 것, 리스너를 동기 단일 트랜잭션으로 묶어 부분 성공을 막은 것, 벌크 JPQL 대신 파생 delete 로 orphanRemoval 을 살린 것, user_identity 를 지워 재가입이 새 계정이 되게 한 것 — 넘어질 만한 자리를 다 짚었고 테스트가 양쪽(지우는 것/남기는 것)을 잠근다.

다만 X-Guest-Id 를 선택 인자로 둔 결정 하나에서 문제 셋이 파생된다. 그중 하나(W3)는 PR 에 안 적힌 개인정보 사고 경로다.

검증한 것

항목 결과
전체 테스트 1,402건 통과 (실패 0 · skip 21). 탈퇴 통합 9건
트랜잭션 경계 (부분 성공 방지) @EventListener 는 동기라 발행자 트랜잭션에 참여한다. 하나라도 실패하면 통째 롤백 — 의도대로 동작한다
이벤트 순서 (정리 → 계정 삭제)
orphanRemoval 보존 ✅ 파생 deleteByGuestId 가 엔티티를 로드해 지운다. FK 가 없는 레포라 벌크 JPQL 이면 day_schedule·slot 이 고아로 남았을 것
user_identity 삭제 → 재가입이 새 계정 ✅ 테스트로 잠금
refresh 토큰 삭제(폐기 아님) ✅ 판단 근거도 타당 — 감지해서 보호할 계정이 사라졌다
탈퇴 후 access 토큰 창 차단 USER-006 (다만 W5)
남의 데이터 안 건드림 ✅ 테스트 있음 (단, W1 은 그 테스트가 못 잡는 축이다)
공유 링크 410 유지 ✅ 판단·테스트 모두 좋다. 404 와 구분하는 이유가 정확하다
에러코드 append-only USER-006
@ApiResponse 전수 ✅ 200 · 401
마이그레이션 ✅ 스키마 변경 없음 (필요 없다)
컨벤션 훅 ✅ 통과
스택 base 와의 정합 #93 에 방금 올라간 보안 수정 2건과 충돌 없음(겹치는 파일 0). base 만 최신으로 당기면 된다

🔴 W3 — 탈퇴 후 재가입하면 지워지지 않은 옛 데이터가 새 계정에 붙는다 (PR 에 없는 항목)

가장 먼저 봐야 할 항목이다. PR 은 "재가입은 새 계정" 을 테스트로 잠갔지만, 그 테스트는 헤더를 보낸 경우만 본다.

헤더 없이 탈퇴한 경로(= PR 이 "문서화된 한계" 라고 부른 그 경로)에서:

  1. 사용자가 X-Guest-Id 없이 탈퇴한다. 계정만 지워지고 코스·연차·후기는 guest_id 아래 그대로 남는다.
  2. guestId기기에 저장된 값이다 — 게스트 스킴의 존재 이유가 그것이다. 탈퇴해도 앱을 지우지 않는 한 그대로 있다.
  3. 같은 기기에서 다시 가입한다. 새 userId 가 생기지만 앱은 같은 X-Guest-Id 를 계속 보낸다.
  4. 탈퇴한 사람의 저장 코스와 연차 사용 내역이 새 계정에서 그대로 보인다.

사용자 입장에서 "탈퇴가 동작하지 않았다" 이고, 기기를 중고로 넘겼거나 가족과 공유하는 경우 다른 사람이 탈퇴자의 여행 계획·연차 이력을 본다. 연차 사용 내역은 PR 스스로 "근무 이력에 가까운 개인정보" 라고 적은 데이터다.

게다가 그 데이터는 다시는 지울 수 없다 — 이름을 붙일 수 있었던 계정이 사라졌기 때문이다. 유출보다 나쁘다. 유출은 사후 대응이라도 하지만, 이건 삭제 요청이 와도 실행할 방법이 없다.


🟠 W1 — X-Guest-Id 로 남의 데이터를 한 번에 지울 수 있다. "새 권한이 아니다" 는 부정확하다

PR 은 이렇게 적었다:

이건 이 PR 이 만든 구멍이 아니라 게스트 스킴 전체의 성질이다 — 지금도 같은 헤더로 코스를 하나씩 지울 수 있다. 새로 생긴 것은 한 번에 지우는 편의지 새 권한이 아니다.

코스에 대해서는 맞지만, 나머지는 틀리다. 이 브랜치의 DELETE 엔드포인트를 전부 뒤졌다:

DELETE /api/v1/courses/{courseId}
DELETE /api/v1/courses/{courseId}/leave-deduction
DELETE /api/v1/users/me          ← 이 PR

leave_balance·leave_usage·trip_outcome 을 지우는 경로는 이 PR 이전에 하나도 없었다. 즉 이 PR 은 "한 번에 지우는 편의" 가 아니라, 연차 데이터와 여행 후기에 대한 최초의 파괴 권한을 인증되지 않은 헤더 값에 연다.

공격 시나리오

공격자는 피해자의 guestId 하나만 알면 된다(모든 요청 헤더에 실려 다니는 값이라 공유 기기·프록시·로그·스크린샷으로 샌다).

  1. 공격자가 아무 소셜 계정으로 새로 가입한다(무료·즉시·일회용).
  2. DELETE /api/v1/users/me
    Authorization: Bearer <공격자 자신의 토큰>
    X-Guest-Id: <피해자의 guestId>
    
  3. 서버는 인증된 userId(공격자)로 계정을 지우고, 인증되지 않은 헤더 값(피해자) 으로 코스·일정·슬롯·연차 설정·연차 사용 내역·여행 후기를 전부 지운다.

비용: 공격자는 일회용 계정 하나를 버린다. 피해자는 유예 없이 되돌릴 수 없는 전체 데이터 손실을 입는다(soft delete 없음). 반복하려면 계정을 또 만들면 된다.

탈퇴는_다른_사람의_데이터를_건드리지_않는다 테스트는 이 축을 못 잡는다 — 자기 헤더를 정직하게 보낸 경우만 확인하기 때문이다.


🟠 W2 — 헤더 없는 탈퇴는 개인정보를 남긴다 (⑥ 판단)

App Store 심사(5.1.1(v)) 관점: 통과한다. 요구는 "앱 안에서 계정을 지울 수 있을 것" 이고 users 행은 확실히 지워진다.

개인정보처리방침·개인정보보호법 관점: 통과한다고 보기 어렵다. 방침 9항이 약속한 것은 "탈퇴" 이고, 이용자는 그것을 내 데이터가 없어진다로 읽는다. 실제로는 저장 코스·연차 이력·여행 후기가 남고, W3 대로 주인 없이 영구히 남는다. 파기 의무를 이행할 수단 자체가 사라지는 것이 문제다.

PR 은 이 선택을 "계정 삭제 권리 vs 헤더 하나" 의 딜레마로 적었는데, 그 둘 중 고를 필요가 없다. 아래 안 A 를 보라.

최소한, 지금 상태를 유지한다면 프론트 답변과 문서에 이렇게 적혀야 한다 — 지금 PR 표는 "지워진다 — X-Guest-Id 필요" 라고만 해서, 헤더를 빠뜨린 결과가 영구 잔존이라는 게 안 읽힌다.


권고 — 셋을 한 번에 없애는 안

W1·W2·W3 은 전부 "서버가 userId ↔ guestId 를 모른다" 는 하나의 원인에서 나온다. 그래서 하나로 풀린다.

안 A (권장) — 로그인 시점에 guestId 를 서버가 기록한다

POST /api/v1/auth/callback/{provider} 는 앱이 부르는 첫 요청이고, 앱은 이미 X-Guest-Id 를 갖고 있다. 그때 받아 저장한다(users.guest_id 컬럼 하나 또는 user_guest_link 테이블).

그러면
W1 해소 탈퇴가 헤더를 안 본다. 인증되지 않은 값이 삭제 범위를 정하지 못한다
W2 해소 헤더 없이 탈퇴해도 서버가 소유 데이터를 안다. 조용한 잔존이 없다
W3 해소 재가입은 새 링크를 만든다. 옛 데이터는 이미 지워졌다
⑦ 소유 전환의 전제 guest_iduser_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 키 대기" 로 묶었는데, 둘의 사정이 다르다:

심사 전에 Apple revoke 만이라도 넣는 것을 권한다. 카카오와 한 덩어리로 묶어 미루면, 막지 않은 것 때문에 막힌 것과 함께 늦어진다.


🟡 W5 — USER-006 은 탈퇴 엔드포인트만 막는다

탈퇴 후에도 access 토큰은 만료까지 서명 검증을 통과한다(무상태 JWT 의 대가, 문서화됨). 이 PR 은 그 창에서 두 번째 탈퇴가 200 을 내는 것을 USER-006 으로 막았다 — 좋은 처리다.

다만 막힌 것은 그 엔드포인트뿐이다. @LoginUser 를 쓰는 다른 경로는 사용자 존재를 확인하지 않아, 지워진 계정의 토큰으로 최대 1시간 동안 인증을 통과한다. 지금은 userId 로 데이터를 읽는 엔드포인트가 사실상 없어 피해가 없다. 프로필·알림처럼 userId 기준 조회가 붙는 순간 "지워진 계정이 응답을 받는" 상태가 되므로, 그때는 필터나 공통 지점에서 한 번 걸러야 한다. 지금 고칠 것은 아니고 알아둘 것이다.


🟢 Low · nit

  1. deleteByGuestId 는 엔티티를 로드해 지운다orphanRemoval 을 살리려는 의도적 선택이고 맞다. 다만 코스가 많은 사용자는 day_schedule·slot 컬렉션까지 끌어온다. 탈퇴는 드문 작업이라 지금은 문제없고, default_batch_fetch_size 가 묶어 준다. 판단 근거가 주석에 있어 좋다.
  2. UserWithdrawalIntegrationTest 에 클래스 레벨 @Transactional 이 없다(고유 UUID 로 격리). AuthIntegrationTest 와 같은 방식이라 일관되지만, 그쪽에는 이유를 적은 주석이 있고 여기엔 없다. 한 줄이면 다음 사람이 "빠뜨린 것" 으로 오해하지 않는다.
  3. 탈퇴는_다른_사람의_데이터를_건드리지_않는다 가 헬퍼 아래(맨 끝)에 있다. 위쪽 시나리오 구획으로 올리면 읽는 순서가 맞다.
  4. ApiResponseBody.okWithDetailok(T) 와 이름으로 가른 판단은 맞다 — T 가 String 일 때 어느 쪽이 불릴지 읽는 사람이 알 수 없다는 근거가 정확하다.
  5. 공유 링크를 묘비로 남기는 판단(410 vs 404), 그리고 "그 행에는 탈퇴자를 가리키는 것이 없다" 는 개인정보 근거 — 둘 다 좋다. 이견 없다.

사람이 정해야 할 것

  1. 탈퇴 범위 (W1·W2·W3) — 안 A/B/C/D 중 선택. A 를 권한다(마이그레이션 1개로 셋 다 해소 + 소유 전환의 선행 작업).
  2. Apple revoke 를 심사 전에 넣을지 (W4) — 카카오와 달리 막고 있는 것이 없다. 안 넣으면 심사에서 지적될 수 있다.
  3. W3 을 최소한 문서화할지 — A 를 택하지 않는다면 재가입 시 옛 데이터 재노출은 반드시 PR 본문·프론트 답변에 들어가야 한다.

머지는 하지 않았다. base(#93)에 보안 수정 2건이 올라갔으니 최신으로 당겨 주면 된다 — 충돌 없음을 확인했다.

@sevineleven

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.

1 participant