feat(S15P11A705-285): 탈퇴 시 공급자 연결을 먼저 끊고 성공했을 때만 소프트 삭제한다 - #181
Conversation
구현 전에 결정을 고정한다. 214가 처음 정한 "커밋 이후 호출 + 큐 + 배치" 구조를 뒤집는 것이라 근거가 남아 있어야 한다. 결정적 이유는 순서를 뒤집으면 실패가 회복 불가능해진다는 것이다. 06 §6.9가 탈퇴 절차의 두 번째 단계로 provider_user_id 마스킹을 요구하므로, 삭제가 먼저 커밋되면 공급자에서 그 사용자를 지목할 수단이 사라진다. "미이행"이 아니라 "이행 불가"이며 back#139가 관측한 8초 재가입이 그 상태다. 해제 수단은 탈퇴 시점에 인가 왕복으로 확보하고 보관하지 않는다. 최소권한이 근거다 — 로그인 때 저장하면 자격증명을 들고 있어야 하고, 카카오 어드민 키는 그 앱의 어떤 사용자에게든 연결 해제와 정보 조회가 가능한 권한이다. 왕복의 시작 권한과 왕복 중 컨텍스트가 다른 층이라는 것을 별도 절로 적었다. 인가 진입은 브라우저 내비게이션이라 GET이고, 의도를 쿼리 파라미터로 받으면 악성 사이트가 피해자를 유도해 계정 삭제까지 이르게 할 수 있다. DELETE에 CSRF를 걸어도 그 뒤 단계가 GET이라 우회된다. 그래서 DELETE가 서명된 일회용 티켓을 발급하고 인가 진입이 그것을 검증한다. SameSite=Strict 쿠키 안을 배제한 근거를 함께 적었다. 공급자에서 돌아오는 콜백도 크로스 사이트 내비게이션이라 Strict 쿠키가 실리지 않아 티켓을 읽을 수 없다 (oauth2-proxy#1663). 우리가 인증 쿠키에 Lax를 고른 이유와 같은 계열이다. 티켓 수명 5분은 Google Identity Platform이 계정 삭제 등 민감 작업에 쓰는 값을 근거로 삼았다. 왕복 컨텍스트를 인가 요청 attributes에 싣는 근거는 규격에서 가져왔다. RFC 6749 §4.1.1이 state를 "maintain state between the request and callback"으로 정의하고, RFC 9700이 PKCE를 확인한 클라이언트는 state의 CSRF 역할을 PKCE에 맡길 수 있다고 하면서 "state로 애플리케이션 상태를 나르면 tampering·swapping을 막아야 한다"고 요구한다. attributes는 클라이언트에 나가지 않으므로 그 요구를 구조로 충족한다. 오류 어휘가 두 층이라는 것도 적었다. 공급자가 주는 access_denied 등은 RFC 6749 §4.1.2.1이 규정하므로 받아서 매핑하고, 프론트향 ?error=는 기존 OAUTH_FAILED 선례를 따라 WITHDRAWAL_* 로 정한다. RFC 9470(Step Up Authentication)은 검토했으나 적용하지 않는다. 우리 문제는 인증 강도가 아니라 제3자에게 보낼 토큰이 없다는 것이라 어휘가 어긋난다. package-structure.md의 auth 행에 client 계층을 더했다. 연결 해제 호출을 security/oauth에 두지 않는 이유는 그 패키지의 기준이 "필터 체인이 언제 도는가"인데 해제는 탈퇴 흐름이 능동적으로 부르는 외부 호출이라서다. 관련: #176 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
인가 진입은 브라우저 내비게이션이라 GET이다. 의도를 쿼리 파라미터로만 받으면 악성 사이트가 피해자를 그 경로로 유도해 계정 삭제까지 이르게 할 수 있고, DELETE /v1/me에 CSRF를 걸어도 뒤 단계가 GET이라 우회된다(BD-48 §②). Access·Refresh와 섞이면 30분짜리 토큰이 계정 삭제 권한을 갖게 되므로 token_use를 따로 둔다. 수명 5분은 응답과 브라우저 이동 사이만 덮으면 되는 구간이다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
티켓이 있으면 탈퇴 왕복이고, 대상 회원을 인가 요청 attributes에 싣는다. attributes를 고른 이유는 redirect-uri를 공급자 콘솔에서 바꾸지 않아도 되고 클라이언트에 나가지 않아 state 인코딩의 tampering·swapping을 피하기 때문이다 (RFC 9700, BD-48 §③). 회원 식별자를 여기 두는 것은 왕복 중 Access(30분)가 만료돼도 탈퇴를 완료할 수 있어야 해서다. 해제에 필요한 공급자 access token은 OAuth2AuthenticationToken에 실리지 않는다. Spring 기본 저장소는 HttpSession을 만들어 STATELESS 선언과 어긋나므로, 필요한 구간(콜백 한 요청)만 사는 요청 범위 저장소로 바꾼다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kakao POST /v1/user/unlink(Bearer), Google POST /revoke(form), Naver POST /oauth2.0/revoke(form + client_id·client_secret). 셋 다 access token 하나로 해제되어 호출부에는 분기가 없지만 요청 형태가 달라 구현을 나눈다. Kakao 어드민 키를 쓰지 않는다 — 그 앱의 어떤 사용자에게든 해제와 정보 조회가 가능한 키를 상시 보관하게 되어 목적에 비해 권한이 과하다(BD-48 §①). 실패를 삼키지 않는다. 해제 없이 회원을 지우면 마스킹이 provider_user_id를 파기해 되살릴 방법이 없다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
순서가 계약이다. social_account 마스킹이 provider_user_id를 파기하므로 뒤집으면 공급자에서 그 사용자를 지목할 수단이 영구히 사라지고, 실패를 큐에 넣어 나중에 재시도할 수도 없다(BD-48). 공급자 화면에서 다른 계정으로 인증할 수 있으므로 콜백이 가져온 계정이 그 회원의 것인지 먼저 확인한다. 확인하지 않으면 계정 B의 연결을 끊고 회원 A를 지운다(§⑤). 해제는 외부 HTTP라 트랜잭션으로 묶지 않는다. 남는 창은 "해제는 됐는데 삭제 실패" 한 방향뿐이고, 그때는 사용자가 다시 시도하면 된다 — 공급자는 이미 폐기된 토큰에도 성공을 준다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DELETE /v1/me는 이제 아무것도 지우지 않고 200 + authorizationUrl을 준다. 메서드와 경로는 그대로다 — 클라이언트가 하는 일(탈퇴 요청)이 바뀌지 않았고 응답 본문이 생겨 204가 200이 됐을 뿐이다. 공급자를 클라이언트에게 받지 않는다. 받으면 A로 로그인한 회원이 B의 화면으로 갈 수 있고 그 결과는 해제 없이 끝나는 탈퇴다. 서버가 social_account에서 정한다. 쿠키도 여기서 지우지 않는다. 왕복이 끝나기 전에 로그아웃시키면 콜백에서 회원을 식별할 근거가 사라진다. 기존 연쇄 삭제·마스킹 테스트는 왕복 이후를 보는 것이므로 완료 단계를 직접 부르게 바꿨다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
같은 콜백이 두 흐름을 받는다. 어느 쪽인지는 인가 요청 attributes만 알고, Spring은 그것을 필터 안에서 소비한 뒤 성공 핸들러에 넘기지 않는다. 소비하는 지점인 쿠키 저장소가 같은 요청에 남기게 했다 — 쿠키를 다시 읽어 역직렬화를 두 번 하는 것보다 낫다. 탈퇴면 세션을 발급하지 않는다. 이 인증은 공급자 토큰을 받기 위한 것이고 끝나면 회원이 사라진다. 완료 시에만 쿠키를 지우고 ?withdrawal=completed로 돌려보내며, 실패는 확정되지 않았으므로 쿠키를 남긴 채 코드만 실어 보낸다. 오류 어휘는 두 층이다(BD-48 §④). 공급자가 주는 것은 RFC 6749 §4.1.2.1이 규정한 값이고, 프론트에 나가는 WITHDRAWAL_*는 우리가 정한다. 탈퇴 왕복이라고 전부 "취소"는 아니라서 access_denied일 때만 CANCELLED로 부른다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
티켓이 인가 URL에 실리면 공급자 서버 로그와 리퍼러에 계정 삭제 권한이 남는다. 스텁 위임으로는 증명되지 않아 실제 DefaultOAuth2AuthorizationRequestResolver로 확인한다 — 실리지 않는다. 해제 클라이언트가 하나 빠져도 컴파일과 나머지 테스트는 통과하고, 그 공급자로 가입한 회원만 탈퇴하지 못한다. 배포 후에야 드러나므로 조립 결과를 직접 본다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
이것이 없으면 공급자의 503 한 번이 탈퇴를 영구히 막는다. 해제를 선행으로 두면서 "공급자 장애가 탈퇴를 막아서는 안 된다"던 214의 결정을 뒤집었는데, 그 판단이 성립하는 조건이 실제 거절은 드물다는 것이다 — 흡수하지 않으면 조건이 깨진다. 일시적인지 아닌지를 예외가 나른다. 뭉뚱그리면 둘 중 하나가 망가진다. 전부 포기하면 위와 같고, 전부 되풀이하면 자격증명 오류처럼 몇 번을 보내도 같은 실패에 사용자를 기다리게 한다. 판정은 응답 상태다 — 5xx·429는 공급자 사정, 나머지 4xx는 우리 쪽이 틀린 것. 응답이 아예 오지 않은 경우는 해제 여부를 알 수 없으므로 일시적으로 본다. 되풀이가 안전한 근거는 멱등성이다(RFC 7009). 첫 요청이 실제로는 성공했는데 응답만 못 받은 경우에도 두 번째가 깨지지 않는다. 사용자가 리다이렉트 뒤에서 기다리는 구간이라 3회·0.2초·0.4초로 짧게 잡았다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**소셜 계정이 둘 이상이면 시작도 완료도 하지 않는다.** 한 번의 왕복은 한 공급자만 인가하는데 소프트 삭제는 그 회원의 계정을 전부 마스킹한다. 하나만 끊고 지우면 나머지는 provider_user_id가 파기돼 영구히 못 끊는다 — 이 설계가 막으려던 바로 그 상태를 이 설계가 만든다. 지금은 계정 생성 경로가 로그인 하나뿐이라 도달할 수 없지만, 계정 연결 기능이 붙는 순간 조용히 깨지므로 양쪽에서 닫는다. **이미 탈퇴한 회원의 두 번째 왕복은 완료로 본다.** 탭 두 개로 동시에 탈퇴하면 두 번째가 늦게 도착하는데, 지금은 소프트 삭제된 계정을 못 찾아 "남의 계정"과 같은 코드가 나갔다. 그 왕복이 만든 새 인가는 끊지 않는다 — 마스킹으로 원본 식별자가 사라져 소유를 확인할 수 없고, 확인 없이 끊으면 §⑤가 막으려는 경로가 열린다. **티켓이 위조·만료면 404 대신 복귀 경로로 돌려보낸다.** 정상 흐름은 응답과 브라우저 이동 사이 수십 ms라 닿지 않지만, SPA가 확인 단계를 끼우면 실제로 생기고 그때 사용자는 이유를 모른다. BD-48의 "일회 소비"를 구현에 맞춰 고쳤다. 서버가 티켓을 무효화하려면 저장소가 필요한데 그것이 곧 기각한 (C)안이다. 남는 성질과 감수하는 근거를 적었다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
둘 다 틀리면 탈퇴가 통째로 죽는데 지금 테스트가 잡지 못하던 것이다. **공급자 토큰이 성공 핸들러에 닿는 것.** 저장도 사용도 Spring 필터 하나가 같은 요청 안에서 하는 일이라 순서가 그 클래스 내부에 있다. 핸들러를 단위로 부르면서 토큰을 손으로 넣어 두면, 실제로 저장되지 않아도 테스트는 통과한다. 필터를 직접 태워 확인한다 — 저장돼 있다. **attributes에 의도를 얹어도 PKCE가 살아남는 것.** 우리는 위임 결과를 복사해 attributes를 더하는데, 그 과정에서 code_verifier가 떨어지면 인가는 성공하고 토큰 교환만 invalid_grant으로 죽는다. 증상이 콜백 실패 하나로만 보여 원인을 찾기 어렵다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
08 §3.6.2가 "연결 해제 성공 → 삭제 완료 | 없음"으로 정해 두었는데 확인하지 않고 ?withdrawal=completed를 만들었다. 명세의 근거는 쿠키다 — 만료된 채 착지하므로 클라이언트의 기존 앱 시작 흐름이 그대로 로그인 화면으로 보낸다. 프론트 콜백 검증 스키마가 error 외의 쿼리를 버리므로 붙여도 도달하지 않는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BI-38에 산출·설계 판단·검증을 적었다. 검증은 테스트 7종과 로컬 실물 스모크 19경로이며, 해제가 200을 받는 성공 완주와 재시도 실동작은 해제 URL이 코드 상수라 로컬에서 돌릴 수 없어 남겼다고 명시했다. authentication.md — 같은 콜백이 로그인과 탈퇴를 함께 받는다는 사실과, 그래서 공급자 토큰 저장소를 요청 범위로 둔 이유를 적었다. 탈퇴를 204로 적은 곳은 없어 상태 코드는 손대지 않았다. package-structure.md — 하위 계층 목록에 exception이 빠져 있었다. 이미 네 도메인이 쓰고 있어 문서가 코드보다 뒤처져 있었고, 이번에 member/exception을 더하며 드러났다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
리뷰순서 뒤집기의 근거가 정확하다. 막는 조건이면 ①만 실제 결함이다. 잘 된 것
① 재시도의 안전 근거가 Google에서 성립하지 않는다
그런데 같은 PR의
두 문장이 충돌하고 후자가 맞다. Google 데이터는 안전하다 — 다시 시도하면 새 토큰을 받아 완주한다. 하지만 재시도가 흡수하려던 바로 그 케이스(타임아웃)에서 재시도가 실패로 굳는다. 재시도를 넣은 전제 자체가 깨진 것이다.
② 인가 요청 쿠키가 이제 인가 판정을 나르는데, 서명이 없다
실제로 뚫리지는 않는다 — 문제는 신뢰 구조가 한 겹이 됐다는 것이다. 티켓 서명은 진입에서만 검증되고, 콜백의 유일한 근거는 위조 가능한 쿠키다. 싼 해법: 이 PR에서 꼭 해야 하는 건 아니다. BD-48에 "이 판정이 ③ 최악 대기 15.6초가 사용자 앞단에 있다읽기 타임아웃 5초 × 3회 + 백오프 0.6초. javadoc이 이미 계산해 뒀지만, 리다이렉트 뒤에서 흰 화면으로 기다리는 15초는 사용자에게 실패와 구분되지 않는다. 공급자 무응답이 반복되는 상황이라면 시도를 2회로 줄이거나 총 예산(예: 8초)을 상한으로 두는 편이 낫다. 판단은 맡긴다 — 근거는 이미 적혀 있다. ④ 소셜 계정 다중이 500이다
⑤ 잔가지
머지 전 확인PR 본문이 이미 짚은 것이라 반복만 한다 — 둘 다 코드가 아니라 배포 순서 문제다.
|
**멱등성 전제가 틀렸다.** "이미 폐기된 토큰에도 세 공급자 모두 성공을 준다"고 적었는데 Google은 400을 준다. 내 스모크 로그가 그 반증이었다. RFC 7009 §2.2를 3사가 따르지 않으므로, 재시도가 흡수하는 것은 요청이 닿지 못한 실패뿐이고 응답만 유실된 경우는 다음 시도가 확정적 4xx로 끝난다. 그 4xx를 "이미 해제됨"으로 간주하자는 제안은 받지 않았다. 실제로 해제되지 않은 경우까지 회원을 지우게 되고, 마스킹 때문에 되돌릴 수 없다 — 이 PR이 순서를 정한 것과 같은 기준이다. 대신 그 경로(무응답 → 확정 실패)를 테스트로 덮었다. **재시도 3회 → 2회.** 시도를 늘려도 비싼 케이스를 못 고친다. 최악 대기가 15.6초에서 10.2초로 준다. **계정 다중 차단이 500 → 409.** 터진 것이 아니라 막은 것이라 운영 알림이 버그로 울면 안 된다. Naver 등록정보 null 확인을 넣었다 — @NullMarked 패키지에서 nullable 반환을 바로 역참조하고 있었다. BD-48 §③에 이 판정이 §⑤의 계정 일치 확인에 기대고 있다는 사실을 적고, requireOwnAccount에 완화 금지 주석을 남겼다. 쿠키에 MAC이 없어 그 검사가 사라지면 임의 계정 삭제가 된다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
리뷰 감사합니다. ①은 제가 틀렸습니다 — 반영했고, 제안하신 해법 중 하나와 ⑤의 한 항목은 근거를 들어 다르게 갔습니다. ① 멱등성 전제 — 지적이 맞습니다
공급자별로 갈라 적었습니다 — Naver 다만
|
재리뷰 (
|
| 항목 | 상태 |
|---|---|
| ⓐ Naver 엔드포인트·200+error 판정 | 막는 조건 |
| ⓑ 클래스 javadoc 잔여 문장 | 고칠 것(한 줄) |
| ⓒ 409 코드 명세 등재 | docs#44에 한 줄 |
| ⓓ 재시도 상한 테스트 | 있으면 좋다 |
| ⓔ docs#44 머지 선행 | 순서 확인 |
①③④⑤ 반영과 ②의 주석·BD-48 기록은 모두 확인했습니다. requireOwnAccount의 완화 금지 주석은 특히 잘 걸어 뒀습니다 — 다음 사람이 정확히 걸릴 자리에 있습니다.
**상태 코드만 보던 것이 위험했다.** Naver 연동 해제 계열은 실패를 200 본문의 error로 알리는 형태가 보고돼 있다. 그러면 해제 실패가 성공으로 읽혀 회원이 지워지고, 마스킹 때문에 그 뒤엔 영구히 못 끊는다 — 이 PR이 막으려는 상태를 Naver 경로에서만 만든다. 본문에 error가 있으면 2xx여도 실패로 본다. 이 판정은 응답 형태가 어느 쪽이든 안전하다 — 본문 없는 성공도, result=success를 주는 성공도 통과한다. **엔드포인트 자체는 확정하지 못했다.** 사내 공유 문서는 /oauth2.0/revoke를, 리뷰와 독립 자료들은 토큰 엔드포인트 + grant_type=delete를 가리킨다. 공식 문서 페이지에 접근하지 못해 javadoc에 미확정임을 명시하고 스테이징 확인 항목으로 남겼다. 재시도 상한 분기를 단독으로 실행하는 테스트가 없었다. 상수를 되돌려도 깨지는 것이 없어 방금 튜닝한 값이 무방비였다. 클래스 javadoc에 남아 있던 "이미 폐기된 토큰에도 성공을 준다"도 정정했다. 결론은 그대로다 — 다시 시도하면 새 왕복이라 새 토큰을 받는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
공식 문서 §4.3이 확인됐다. 엔드포인트는 /oauth2.0/revoke가 맞고, 판정 기준도 문서가 명시한다. 본 API는 입력한 토큰의 유효성과 무관하게, 폐기 자체가 정상 수행되면 200을 반환합니다. 따라서 클라이언트는 응답 본문이 아닌 HTTP 상태 코드를 기준으로 결과를 판단해야 합니다. 직전 커밋의 본문 검사는 "grant_type=delete 방식이면 200 본문에 error가 온다"는 전제 위에 세운 것이었는데, 그 전제가 반증됐다. 성공은 본문 없는 200이고 실패는 상태 코드로 갈리므로 파싱할 것이 없다. ObjectMapper 의존과 테스트 둘을 함께 걷어낸다 — 문서가 없다고 한 응답 형태를 테스트가 계약처럼 고정하고 있었다. Naver는 3사 중 유일하게 이미 폐기된 토큰에도 200을 준다(§4.3 상태 코드 표). Google이 400이라는 앞선 정정은 그대로 유효하다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ⓐ 해소 — 공식 문서 확인했습니다. 엔드포인트는 맞고, 오히려 제 방어 코드가 틀렸습니다작성자가 1차 출처를 확인해 주셨습니다. 네이버가 「연동 해제」를 OAuth 2.0 표준 용어인 Token Revocation으로 개정하면서
판정 기준도 문서가 못 박고 있습니다 — 제가 원래 쓴 그대로였습니다.
직전 커밋( 부수적으로 확인된 것: Naver는 3사 중 유일하게 이미 폐기된 토큰에도 200을 줍니다. ①에서 정정한 "Google은 400" 은 그대로 유효하고, 지적해 주신 덕분에 1차 출처를 확인하는 계기가 됐습니다. 결과적으로 코드는 원래대로지만, 근거가 "붙여받은 텍스트"에서 "확인된 공식 계약"으로 바뀌었습니다. ⓑ ⓓ 반영
ⓒ 반영 — 그리고 1차 ⑤ 반박은 제가 틀렸습니다409를 docs#44에 추가했습니다( 그리고 1차 ⑤에서 다만 이 저장소는 구현 후 명세를 갱신하는 순서로 운영되고 있고 명세 작성자가 곧 작업자라, 값을 유지하고 docs#44에 반영하는 쪽으로 정리했습니다. 접자는 제안도 여전히 유효한 선택지입니다 — 도달 불가라는 지적은 맞고, 유지하는 이유는 4번째 공급자가 붙었을 때 "우리 설정 누락"과 "공급자 실패"가 복귀 URL에서 갈린다는 정도입니다. ⓔ 동의
|
minyongP
left a comment
There was a problem hiding this comment.
3차 — 승인
CI 통과 확인했습니다. 막는 조건이 없습니다.
ⓐ 해소 — 제 지적이 절반 틀렸고, 그 절반이 좋은 결과를 냈습니다
엔드포인트는 원래 코드가 맞았습니다 — 제가 웹 검색으로 찾은 것이 전부 grant_type=delete 구방식 자료였습니다. 1차 출처를 확인해 주신 덕에 "200에 실린 실패" 우려도 공식 계약(상태 코드 기준 판단)으로 닫혔습니다.
fd884e3의 본문 파싱을 21e981a로 되돌린 판단도 옳습니다 — 구방식 전제 위에 쌓은 방어였고, 그 테스트들이 문서에 없는 응답 형태를 계약처럼 고정하고 있었습니다. 최종 NaverUnlinkClient javadoc이 공식 문서 §4.3 인용을 근거로 갖게 된 것이 이번 왕복의 실질 수확입니다. 상태 코드 표(200=이미 폐기 포함, 503=재시도 가능)가 현재 판정 로직과 일치하는 것도 확인했습니다.
ⓑⓒⓓ 반영 확인
- ⓑ 클래스 javadoc "새 왕복이라 새 토큰"으로 정정, 테스트 주석까지 함께 — 확인.
- ⓒ docs#44
ed05f98의 409 + 401 추가 확인. ⑤ 반박 근거 정정도 확인했습니다. - ⓓ
stopsAtTheAttemptCap확인. 상수를 3으로 되돌려도 안 깨졌다는 실측이 이 테스트가 필요했다는 증명입니다.
남는 것 — 전부 코드 밖
- docs#44 → back#181 → front 순서 (합의됨)
- 스테이징 3사 실물 완주 — Naver 우선 유지에 동의합니다. 엔드포인트는 확정됐지만 성공 완주는 여전히 미검증입니다.
잔가지 하나(비차단): 고친 클래스 javadoc의 괄호문이 "(공급자는 다시 시도하면…"으로 시작하는데 주어가 어긋납니다 — "공급자는"이 편집 잔재로 보입니다. 의미 전달에는 지장 없습니다.
네 차례 왕복의 지적이 전부 반영되거나 근거 있는 반박으로 닫혔습니다. 열린 항목은 배포 순서와 스테이징 검증뿐이라 승인합니다.
Naver 엔드포인트는 공급자 공식 문서를 근거로 구현했는데, 리뷰 지적을 받고 그 문서를 다시 읽지 않은 채 웹 검색으로 방향을 틀었다. 검색에 걸린 자료들이 개정 이전 것이었고 원래 구현이 맞았다. 리뷰가 틀렸다는 기록이 아니다 — 지적 덕분에 1차 출처를 확인해 근거가 단단해졌다. 틀린 것은 순서다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"(공급자는 이미 폐기된 토큰에도 성공을 준다)"에서 뒷부분만 갈아끼우면서 주어가 남았다. 다시 시도하는 주체는 사용자다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
요약
탈퇴가 공급자 연결을 먼저 끊고, 끊긴 뒤에만 지운다.
DELETE /v1/me가 즉시 삭제하던 것을 인가 왕복 시작으로 바꿨다 — 마스킹이provider_user_id를 파기하므로 순서를 뒤집으면 해제가 영구히 불가능해진다.Jira (필수)
관련 GitHub Issue (선택)
변경 사항
DELETE /v1/me의 CSRF를 걸어도 뒤 단계가 GET이라 우회된다.attributes— 콜백 경로를 나누면redirect-uri를 공급자 콘솔 양쪽에서 바꿔야 한다.memberId도 여기 실어 왕복 중 Access(30분) 만료에도 완료되게 했다.RequestScopedOAuth2AuthorizedClientRepository) — Spring 기본값이HttpSession을 만들어STATELESS선언과 어긋난다.POST /v1/user/unlink(Bearer), GooglePOST /revoke, NaverPOST /oauth2.0/revoke. 어드민 키는 최소권한 원칙으로 배제.authentication.md·package-structure.md테스트 / 검증
./gradlew clean check --no-daemondocs/backend/decisions/에 BD 추가 또는 기존 BD 링크마이그레이션 없음(스키마 무변경)이라 해당 항목은 제외했다.
RED → GREEN
TDD 사이클 7회. 각 슬라이스에서 실패를 먼저 확인했다.
Regression — rebase 후 재실행
로컬 실물 스모크 (curl, 19경로)
앱을 고정 서명 키로 띄우고 실제 호출했다. 공급자 토큰·userinfo는 로컬 스텁으로 돌려 인가 성공 이후 구간까지 태웠다.
401/403200+authorizationUrl, 인증 쿠키 유지, DB 무변경token_use=withdrawal, 수명 300초WITHDRAWAL_FAILED302공급자,code_challenge_method=S256, 티켓 미포함access_denied(탈퇴 / 로그인)WITHDRAWAL_CANCELLED/OAUTH_FAILEDinvalid_grant→WITHDRAWAL_FAILED, 회원 무변경400→ 재시도 0회 → 회원 무변경WITHDRAWAL_ACCOUNT_MISMATCH, 해제 미호출500/401스텁 로그에
code_verifier=…가 찍혀 PKCE가 실제 토큰 교환에 실려 간 것을 확인했다.배경
S15P11A705-214의 *"공급자 장애가 사용자의 탈퇴를 막아서는 안 된다"*를 명시적으로 뒤집는다. 근거는 대가의 비대칭이다 — 장애로 인한 지연은 일시적이고 재시도로 풀리지만, 반대 순서의 대가(영구 미이행 + 동의 없는 재가입)는 회복 경로가 없다. 카카오는 해제를 약관으로 요구한다.
큐·배치·리포트가 성립하지 않는 이유도 같다. 큐에 넣을 식별자가 마스킹으로 이미 파기된다.
리뷰에서 지적받아 전제 하나를 정정했다. 토큰을 보관하지 않는 이유는 공급자 제약이 아니라 최소권한이다 — 176 코멘트.
리뷰 포인트
OAuth2AuthorizedClientRepository를 요청 범위로 바꾼 것이 로그인에도 영향을 준다. 소비자가 성공 핸들러 하나뿐임을 확인하고 바꿨다.500). 지금은 도달 불가(계정 생성 경로가 로그인 하나)지만, 결과가 되돌릴 수 없어 닫았다. 계정 연결 기능을 붙일 때 여기서 막히도록 의도한 것이다.미결 / 후속
deleteAccount()가 응답 본문을 버리고(Promise<void>),authorizationUrl이 레포 전체에 없고, 콜백은error만 읽는다. 백엔드만 먼저 나가면 "탈퇴가 완료되었습니다"가 뜬 채 아무것도 지워지지 않고,logged_in이 살아 있어/login가드가 홈으로 되돌린다.MockRestServiceServer)로만 덮여 있다.WITHDRAWAL_FAILED를 받는다(실제로는 탈퇴됨). 확률이 낮아 잠금을 걸지 않았다.