Skip to content

feat(S15P11A705-285): 탈퇴 시 공급자 연결을 먼저 끊고 성공했을 때만 소프트 삭제한다 - #181

Merged
cherry-go-round merged 18 commits into
devfrom
feat/S15P11A705-285-unlink-before-soft-delete
Aug 4, 2026
Merged

feat(S15P11A705-285): 탈퇴 시 공급자 연결을 먼저 끊고 성공했을 때만 소프트 삭제한다#181
cherry-go-round merged 18 commits into
devfrom
feat/S15P11A705-285-unlink-before-soft-delete

Conversation

@cherry-go-round

Copy link
Copy Markdown
Contributor

요약

탈퇴가 공급자 연결을 먼저 끊고, 끊긴 뒤에만 지운다. DELETE /v1/me가 즉시 삭제하던 것을 인가 왕복 시작으로 바꿨다 — 마스킹이 provider_user_id를 파기하므로 순서를 뒤집으면 해제가 영구히 불가능해진다.

Jira (필수)

  • 키 또는 URL: S15P11A705-285

관련 GitHub Issue (선택)

변경 사항

DELETE /v1/me                        → 200 + authorizationUrl (아무것도 지우지 않는다)
GET  /v1/auth/authorize/{id}?ticket= → 티켓 검증 → attributes에 memberId → 공급자로 302
GET  /v1/auth/{id}/callback          → 로그인/탈퇴 분기
                                     → 계정 일치 → 해제(재시도 3회) → 소프트 삭제 → 쿠키 만료
  • 진입 권한을 서명 티켓으로 막는다 — 인가 진입은 GET이라, 의도를 파라미터로만 받으면 DELETE /v1/me의 CSRF를 걸어도 뒤 단계가 GET이라 우회된다.
  • 컨텍스트는 인가 요청 attributes — 콜백 경로를 나누면 redirect-uri를 공급자 콘솔 양쪽에서 바꿔야 한다. memberId도 여기 실어 왕복 중 Access(30분) 만료에도 완료되게 했다.
  • 공급자 토큰 저장소를 요청 범위로 (RequestScopedOAuth2AuthorizedClientRepository) — Spring 기본값이 HttpSession을 만들어 STATELESS 선언과 어긋난다.
  • 3사 해제 클라이언트 — Kakao POST /v1/user/unlink(Bearer), Google POST /revoke, Naver POST /oauth2.0/revoke. 어드민 키는 최소권한 원칙으로 배제.
  • 일시적 실패만 재시도 (3회·0.2s·0.4s) — 5xx·429·응답 없음은 되풀이, 나머지 4xx는 즉시 포기.
  • 되돌릴 수 없는 경로 두 개를 닫았다 — 소셜 계정 다중(하나만 끊고 전부 마스킹), 이미 탈퇴한 회원의 두 번째 왕복.
  • 문서 — BD-48 · BI-38 · 워크로그 · authentication.md · package-structure.md

테스트 / 검증

  • ./gradlew clean check --no-daemon
  • DB 변경 시 PostgreSQL 통합 테스트
  • API 계약 변경 시 관련 문서 갱신
  • 되돌리기 어려운 결정을 포함하면 docs/backend/decisions/에 BD 추가 또는 기존 BD 링크

마이그레이션 없음(스키마 무변경)이라 해당 항목은 제외했다.

RED → GREEN

TDD 사이클 7회. 각 슬라이스에서 실패를 먼저 확인했다.

$ ./gradlew test --tests '...MemberWithdrawalApiTests'
회원 탈퇴 > 탈퇴 요청은 아무것도 지우지 않고 공급자 인가 URL을 돌려준다  FAILED
14 tests completed, 1 failed                                    ← RED

$ ./gradlew test --tests '...MemberWithdrawalApiTests'
BUILD SUCCESSFUL                                                ← GREEN
$ ./gradlew test --tests '...WithdrawalCompletionServiceTest'
탈퇴 완료 > 소셜 계정이 여럿이면 지우지 않고 멈춘다               FAILED
탈퇴 완료 > 이미 탈퇴한 회원의 두 번째 왕복은 완료로 본다          FAILED
탈퇴 인가 진입 > 티켓이 유효하지 않으면 이유를 담아 클라이언트로 돌려보낸다  FAILED
탈퇴 인가 진입 > 소셜 계정이 여럿이면 시작하지 못한다              FAILED
16 tests completed, 4 failed                                    ← RED
→ BUILD SUCCESSFUL                                              ← GREEN

Regression — rebase 후 재실행

$ ./gradlew clean check --no-daemon
BUILD SUCCESSFUL in 1m 46s

로컬 실물 스모크 (curl, 19경로)

앱을 고정 서명 키로 띄우고 실제 호출했다. 공급자 토큰·userinfo는 로컬 스텁으로 돌려 인가 성공 이후 구간까지 태웠다.

확인 결과
미인증 / CSRF 없음 401 / 403
인증된 탈퇴 시작 200 + authorizationUrl, 인증 쿠키 유지, DB 무변경
티켓 클레임 token_use=withdrawal, 수명 300초
Access·Refresh·만료·타 키 서명을 티켓 자리에 모두 WITHDRAWAL_FAILED
정상 티켓 진입 302 공급자, code_challenge_method=S256, 티켓 미포함
access_denied (탈퇴 / 로그인) WITHDRAWAL_CANCELLED / OAUTH_FAILED
실제 Google 토큰 교환 실패 invalid_grantWITHDRAWAL_FAILED, 회원 무변경
스텁 인가 성공 → 일치 → 해제 호출 실제 Google revoke 호출, 400 → 재시도 0회 → 회원 무변경
스텁이 다른 계정 WITHDRAWAL_ACCOUNT_MISMATCH, 해제 미호출
소셜 계정 둘 / 없음 500 / 401

스텁 로그에 code_verifier=…가 찍혀 PKCE가 실제 토큰 교환에 실려 간 것을 확인했다.

배경

S15P11A705-214의 *"공급자 장애가 사용자의 탈퇴를 막아서는 안 된다"*를 명시적으로 뒤집는다. 근거는 대가의 비대칭이다 — 장애로 인한 지연은 일시적이고 재시도로 풀리지만, 반대 순서의 대가(영구 미이행 + 동의 없는 재가입)는 회복 경로가 없다. 카카오는 해제를 약관으로 요구한다.

큐·배치·리포트가 성립하지 않는 이유도 같다. 큐에 넣을 식별자가 마스킹으로 이미 파기된다.

리뷰에서 지적받아 전제 하나를 정정했다. 토큰을 보관하지 않는 이유는 공급자 제약이 아니라 최소권한이다 — 176 코멘트.

리뷰 포인트

  1. OAuth2AuthorizedClientRepository를 요청 범위로 바꾼 것이 로그인에도 영향을 준다. 소비자가 성공 핸들러 하나뿐임을 확인하고 바꿨다.
  2. 소셜 계정이 둘 이상이면 탈퇴를 거절한다(500). 지금은 도달 불가(계정 생성 경로가 로그인 하나)지만, 결과가 되돌릴 수 없어 닫았다. 계정 연결 기능을 붙일 때 여기서 막히도록 의도한 것이다.
  3. BD-48의 "일회 소비"를 구현에 맞춰 고쳤다. 서버가 티켓을 무효화하려면 저장소가 필요한데 그것이 곧 기각한 (C)안이다. 감수하는 근거 셋을 §②에 적었다.

미결 / 후속

  • ⚠️ 프론트와 동시 배포가 필요하다. 프론트 탈퇴는 front#37 시점 그대로다 — deleteAccount()가 응답 본문을 버리고(Promise<void>), authorizationUrl이 레포 전체에 없고, 콜백은 error만 읽는다. 백엔드만 먼저 나가면 "탈퇴가 완료되었습니다"가 뜬 채 아무것도 지워지지 않고, logged_in이 살아 있어 /login 가드가 홈으로 되돌린다.
  • 스테이징에서 3사 각각 실제 탈퇴 1회가 필요하다. 해제 URL이 코드 상수라 로컬에서는 성공 응답을 만들 수 없어, 해제가 200을 받는 성공 완주와 재시도 실동작은 단위 테스트(MockRestServiceServer)로만 덮여 있다.
  • 진짜 동시에 도착한 두 콜백은 데이터는 안전하되 진 쪽이 WITHDRAWAL_FAILED를 받는다(실제로는 탈퇴됨). 확률이 낮아 잠금을 걸지 않았다.

cherry-go-round and others added 13 commits August 4, 2026 11:40
구현 전에 결정을 고정한다. 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>
@minyongP

minyongP commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

리뷰

순서 뒤집기의 근거가 정확하다. SocialAccount.withdraw()provider_user_idwithdrawn:{id}로 덮으므로 삭제 후 해제는 영구히 불가능하고, 큐·배치가 성립하지 않는다는 판단도 그래서 맞다. 설계 판단 네 가지(티켓으로 CSRF 차단, attributes로 컨텍스트 운반, 요청 범위 토큰 저장소, 해제 선행)가 모두 근거를 갖고 있고 문서도 충실하다.

막는 조건이면 ①만 실제 결함이다.

잘 된 것

  • WithdrawalCompletionService.requireOwnAccount — 공급자 화면에서 다른 계정으로 인증하는 경로를 막는다. 이 PR의 보안 축이다(②에서 다시 나온다).
  • 소셜 계정 다중 방어를 진입·완료 양쪽에 — 지금은 도달 불가지만 되돌릴 수 없는 쪽이라 이중으로 닫은 판단이 옳다.
  • 완료 서비스에 @Transactional을 안 붙인 것 — 외부 HTTP를 트랜잭션 밖으로 뺐고, 남는 창("해제 성공 + 삭제 실패")이 무해한 방향이라는 분석이 정확하다.
  • 재시도 루프의 인터럽트 되살리기 — 여기까지 챙긴 재시도는 드물다.
  • RequestScopedOAuth2AuthorizedClientRepository — 소비자가 성공 핸들러 하나뿐임을 git grep으로 대조해도 맞다. HttpSession 유발 제거는 STATELESS 선언과 맞다.
  • 테스트가 두껍다. 계정 불일치·공급자 불일치·다중 계정·이미 탈퇴·클라이언트 부재·일시/영구 실패 구분이 모두 있다.

① 재시도의 안전 근거가 Google에서 성립하지 않는다

WithdrawalCompletionService.unlinkAbsorbingTransientFailure javadoc:

되풀이가 안전한 근거는 멱등성이다 — 이미 폐기된 토큰에도 세 공급자 모두 성공을 준다 (RFC 7009).

그런데 같은 PR의 GoogleUnlinkClient javadoc은 반대로 쓴다:

성공은 200이고, 이미 폐기된 토큰이나 형식이 어긋난 요청은 400이다.

두 문장이 충돌하고 후자가 맞다. Google /revoke는 RFC 7009 §2.2(무효 토큰에도 200)를 따르지 않는다. Kakao unlink도 무효 토큰에 401을 준다. 실동작:

시도1: 읽기 타임아웃 (실제로는 공급자에서 해제 성공)
시도2: 400 → isRetryable=false → 즉시 SocialUnlinkException
     → 회원 삭제 안 됨, 연결은 이미 끊김, 사용자는 WITHDRAWAL_FAILED

데이터는 안전하다 — 다시 시도하면 새 토큰을 받아 완주한다. 하지만 재시도가 흡수하려던 바로 그 케이스(타임아웃)에서 재시도가 실패로 굳는다. 재시도를 넣은 전제 자체가 깨진 것이다.

  • 최소한 javadoc의 "세 공급자 모두 성공을 준다"를 사실에 맞게 고칠 것.
  • 가능하면 **"직전 시도가 무응답이었다면 그 뒤의 확정적 4xx는 이미 해제된 것으로 본다"**를 넣는 편이 낫다. unlinkAbsorbingTransientFailureboolean previousAttemptWasIndeterminate 하나면 된다.
  • 이 경로(타임아웃 → 400)를 덮는 테스트가 없다. SocialUnlinkClientTest는 각 상태를 따로만 본다.

② 인가 요청 쿠키가 이제 인가 판정을 나르는데, 서명이 없다

withdrawal_member_idCookieOAuth2AuthorizationRequestRepository의 쿠키에 attributes로 실려 콜백까지 간다. 그 쿠키는 Java 직렬화 + base64, MAC 없음이다. 이 PR 이전에는 state와 PKCE verifier뿐이라 위조해도 얻는 게 없었다. 지금은 위조가 "누구를 지울지"를 지목한다.

실제로 뚫리지는 않는다 — requireOwnAccount가 공급자 계정 소유를 확인하므로 남의 memberId를 넣어도 불일치로 막히고, HttpOnly라 JS로 덮어쓸 수도 없다. 지금 당장의 취약점은 아니다.

문제는 신뢰 구조가 한 겹이 됐다는 것이다. 티켓 서명은 진입에서만 검증되고, 콜백의 유일한 근거는 위조 가능한 쿠키다. requireOwnAccount를 나중에 누가 완화하면(계정 연결 기능이 붙을 때 정확히 그럴 유인이 생긴다) 조용히 임의 계정 삭제가 된다.

싼 해법: attributesmemberId(Long) 대신 티켓 문자열을 싣고, 콜백에서 parseWithdrawalTicket을 한 번 더 돌린다. 위조 쿠키는 서명 없는 티켓을 못 만든다. 티켓 수명 5분이 왕복을 못 덮는다면(공급자 화면 체류가 5분을 넘길 수 있다) 별도 수명의 서명값을 쓰거나 쿠키 전체에 MAC을 거는 쪽.

이 PR에서 꼭 해야 하는 건 아니다. BD-48에 "이 판정이 requireOwnAccount에 의존한다"를 명시해 두는 것만으로도 다음 사람이 걸린다.

③ 최악 대기 15.6초가 사용자 앞단에 있다

읽기 타임아웃 5초 × 3회 + 백오프 0.6초. javadoc이 이미 계산해 뒀지만, 리다이렉트 뒤에서 흰 화면으로 기다리는 15초는 사용자에게 실패와 구분되지 않는다. 공급자 무응답이 반복되는 상황이라면 시도를 2회로 줄이거나 총 예산(예: 8초)을 상한으로 두는 편이 낫다. 판단은 맡긴다 — 근거는 이미 적혀 있다.

④ 소셜 계정 다중이 500이다

WithdrawalAuthorizationService.requireSingleAccountIllegalStateException. 의도된 차단인데 500으로 나가면 운영 알림이 버그로 울린다. 도달 불가라 실익은 적지만, BusinessException(409)이면 "막았다"와 "터졌다"가 로그에서 구분된다.

⑤ 잔가지

  • NaverUnlinkClient.unlinkclientRegistrations.findByRegistrationId()null을 줄 수 있는데 바로 역참조한다. 설정 누락 시 NPE가 WITHDRAWAL_FAILED로 뭉개진다. 패키지가 @NullMarked인 만큼 명시적 확인이 낫다.
  • WITHDRAWAL_UNLINK_FAILED — 세 공급자 클라이언트가 모두 등록돼 있어 도달 불가다. 프론트가 처리해야 할 코드를 하나 늘리는 값이라 WITHDRAWAL_FAILED로 접는 것을 고려할 만하다.
  • SocialLoginController.authorizationRequestNotResolved{registrationId}를 선언만 하고 안 쓴다. 매핑상 필요하니 그대로 둘 거라면 주석 한 줄이 있으면 좋겠다.

머지 전 확인

PR 본문이 이미 짚은 것이라 반복만 한다 — 둘 다 코드가 아니라 배포 순서 문제다.

  1. 프론트 동시 배포 필수. 백엔드만 나가면 "탈퇴가 완료되었습니다"가 뜬 채 아무것도 안 지워진다. 이 PR의 가장 큰 실질 위험이다.
  2. 스테이징에서 3사 실제 탈퇴 1회. 해제 200 성공 완주와 재시도 실동작이 MockRestServiceServer로만 덮여 있다.

**멱등성 전제가 틀렸다.** "이미 폐기된 토큰에도 세 공급자 모두 성공을 준다"고
적었는데 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>
@cherry-go-round

Copy link
Copy Markdown
Contributor Author

리뷰 감사합니다. ①은 제가 틀렸습니다 — 반영했고, 제안하신 해법 중 하나와 ⑤의 한 항목은 근거를 들어 다르게 갔습니다. 66f21a7.

① 멱등성 전제 — 지적이 맞습니다

SocialUnlinkExceptionunlinkAbsorbingTransientFailure에 *"세 공급자 모두 성공을 준다"*고 쓴 것이 사실과 다릅니다. 제 스모크 로그가 그 반증이었는데 못 봤습니다. 무효 토큰으로 실제 Google /revoke를 부른 결과입니다.

HttpClientErrorException$BadRequest: 400 Bad Request
retry logs: 0        ← 비재시도로 판정

공급자별로 갈라 적었습니다 — Naver 200(문서 명시), Google 400(실제 확인), Kakao 미확인. 그리고 재시도가 흡수하는 범위를 *"요청이 닿지 못한 실패"*로 좁혀 다시 썼습니다. BD-48 §①에도 표를 넣었고, "두 번째 해제도 성공한다(RFC 7009 멱등)"고 적혀 있던 §결정 부분도 함께 고쳤습니다 — 거기는 새 왕복에서 받은 새 토큰이라 성립한다는 점을 명시했습니다.

다만 previousAttemptWasIndeterminate는 받지 않았습니다

무응답 뒤의 확정적 4xx를 "이미 해제됨"으로 읽으면, 토큰이 처음부터 무효였던 경우까지 성공으로 보고 회원을 지웁니다. 마스킹 때문에 그 방향은 되돌릴 수 없습니다. 반대 방향(실패로 보고 사용자가 다시 시도)은 회복 가능합니다 — 이 PR이 순서를 정한 것과 같은 기준이라, 여기서만 반대로 가면 일관성이 깨집니다.

대기 시간 문제는 ③으로 풀었습니다.

말씀하신 대로 그 경로를 덮는 테스트가 없었습니다. 추가했습니다.

@DisplayName("응답이 유실된 뒤의 확정 실패는 성공으로 간주하지 않는다")
void definiteFailureAfterALostResponseIsNotTreatedAsSuccess() {
    google.transientFailuresLeft = 1;   // 시도1 무응답
    google.failing = true;
    google.retryable = false;           // 시도2 확정 실패
    ...
    assertThat(google.attempts).isEqualTo(2);
    assertThat(deletedAtOf("core.member", memberId)).isNull();
}

② 쿠키에 MAC이 없다 — 구조 지적이 맞습니다

*"신뢰가 한 겹이 됐다"*가 정확합니다. 티켓을 attributes에 실어 콜백에서 재검증하는 쪽이 옳지만, 그러려면 티켓 수명이 왕복 전체(인가 요청 쿠키 MAX_AGE 10분)를 덮어야 해서 §②의 "5분은 진입까지만 덮으면 된다"와 어긋납니다. 수명 재산정이 함께 필요해 이 PR 범위 밖으로 봤습니다.

말씀대로 BD-48 §③에 하위 절을 만들어 의존 관계를 못 박고, requireOwnAccount에 주석을 남겼습니다.

완화하기 전에 BD-48 §③을 읽을 것. 이 검사는 편의가 아니라 콜백에서 유일하게 서명으로 뒷받침되지 않는 지점을 지탱한다. (…) 계정 연결 기능이 붙으면 "여러 계정 중 하나만 맞아도 통과"로 느슨하게 하고 싶어진다. 그때가 이 주석이 필요한 시점이다.

③ 15.6초 — 줄였습니다

①과 이어집니다. 시도를 늘려도 비싼 케이스를 못 고칩니다 — 무응답 뒤의 재시도는 Google에서 4xx로 끝나고, 실제로 흡수되는 5xx·연결 거부는 빨리 돌아옵니다. 3회 → 2회로 줄여 최악이 10.2초입니다. 근거를 javadoc에 적었습니다.

④ 500 → 409

WITHDRAWAL_NOT_SUPPORTED(409) + MultipleSocialAccountsNotSupportedException. 지적하신 대로 막은 것과 터진 것이 로그에서 구분됩니다. memberId·계정 수는 응답에 싣지 않고 던지는 쪽에서 log.error로 남깁니다 — BusinessException의 두 인자 생성자는 그 문자열이 그대로 응답에 나갑니다.

⑤ 잔가지 — 둘 반영, 하나 반박

Naver null 역참조 — 맞습니다. @NullMarked 패키지에서 nullable 반환을 바로 썼습니다. 설정 누락은 되풀이해도 같으므로 비재시도로 분류해 던집니다.

컨트롤러 {registrationId}@PathVariable 파라미터는 원래 없습니다(매핑 템플릿에만 있습니다). 오해하실 만해서 왜 안 받는지 javadoc에 적었습니다.

WITHDRAWAL_UNLINK_FAILED 접기 — 이건 못 합니다. 공용 명세가 계약으로 정의한 값입니다.

docs/static/08_API_명세.md:432
| 해제 대상을 판정하지 못함 | `WITHDRAWAL_UNLINK_FAILED` |

CLAUDE.md 9항상 공용 명세는 우리가 고칠 문서가 아닙니다. 접으려면 docs 레포 개정이 선행돼야 하고, 그건 이 PR의 범위가 아닙니다. 도달 불가라는 지적 자체는 맞습니다 — 세 클라이언트가 모두 등록돼 있는지는 everySupportedProviderHasAnUnlinkClient가 고정하고 있습니다.


머지 전 확인 — 동의합니다

두 항목 모두 PR 본문 「미결 / 후속」에 그대로 두었습니다. 특히 프론트 동시 배포는 제가 프론트 코드를 직접 확인한 결과라 근거를 덧붙입니다.

  • deleteAccount()Promise<void>로 응답 본문을 버립니다 (src/features/auth/api/deleteAccount.ts:8)
  • authorizationUrl은 프론트 레포 전체에 0건
  • 콜백 스키마가 z.object({ error })라 다른 쿼리는 파싱 단계에서 버려집니다

백엔드만 나가면 "탈퇴가 완료되었습니다"가 뜬 채 아무것도 지워지지 않고, logged_in이 살아 있어 /login 가드가 홈으로 되돌립니다.

@minyongP

minyongP commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

재리뷰 (66f21a7)

CI 통과했습니다(backend-ci / check). 1차 지적 다섯 중 넷이 반영됐고, ②는 근거를 남기는 쪽으로 처리된 것 확인했습니다.

previousAttemptWasIndeterminate 반박은 그쪽이 맞습니다. 무응답 뒤의 4xx를 성공으로 읽으면 "해제 안 됐는데 삭제"가 열리고 그 방향만 회복 경로가 없습니다. 이 PR이 순서를 정한 기준과 같으니 여기서만 반대로 갈 이유가 없습니다. 철회합니다.

새로 하나 나왔고, 그것만 막는 조건입니다.


ⓐ Naver 해제 엔드포인트가 실재하지 않는 것 같다 — 막는 조건

NaverUnlinkClient.java:31

private static final String REVOKE_URI = "https://nid.naver.com/oauth2.0/revoke";

찾은 자료 어디에도 /oauth2.0/revoke가 없습니다. 네이버의 연동 해제는 토큰 엔드포인트에 grant_type=delete 입니다.

POST https://nid.naver.com/oauth2.0/token
client_id=…&client_secret=…&access_token=…&grant_type=delete&service_provider=NAVER

service_provider도 빠져 있습니다. Kakao(kapi.kakao.com/v1/user/unlink, Bearer)와 Google(oauth2.googleapis.com/revoke)은 맞습니다 — 네이버만 어긋납니다.

여기가 나쁜 이유는 URL이 틀렸다는 것 자체가 아닙니다. 네이버는 실패도 HTTP 200에 본문 error로 줍니다(024 인증 실패 등). 그런데 판정은 상태 코드만 봅니다.

// 본문의 error가 아니라 상태 코드로 판단한다 — 공급자 문서가 그렇게 요구한다.
throw SocialUnlinkException.from("naver revoke failed", e);

엔드포인트를 고쳐 붙이더라도 이 판정이 그대로면 해제 실패가 성공으로 읽히고 회원이 지워집니다. provider_user_id가 마스킹으로 파기되므로 그 뒤엔 영구히 못 끊습니다 — 이 PR이 존재하는 이유인 바로 그 상태를 네이버 경로에서만 만듭니다.

SocialUnlinkException.from의 새 표에 적힌 "Naver 200 — 문서가 이미 폐기된 경우를 명시" 도 이 엔드포인트를 전제한 것이라 함께 재확인이 필요합니다.

단정은 아닙니다(공식 문서 페이지를 직접 열지 못했습니다). 다만 「스테이징 실물 1회」에서 네이버를 가장 먼저, 실패 케이스까지 태워 200 + error 본문이 오는지 확인해 주세요. 온다면 본문 파싱이 필요합니다.

참고: 네이버 오픈 API 에러 코드 목록 · 연동 해제 정리글


ⓑ 고친 전제가 같은 파일 상단에 남아 있다

WithdrawalCompletionService.java:34 클래스 javadoc:

…사용자가 탈퇴를 다시 시도하면 된다(공급자는 이미 폐기된 토큰에도 성공을 준다).

①에서 지운 문장이 메서드 javadoc에서만 고쳐지고 클래스 javadoc에 남았습니다. 결론은 여전히 맞습니다 — 다시 시도하면 새 왕복이라 새 토큰을 받고, 그 토큰으로는 해제가 성립합니다. 틀린 것은 근거뿐입니다. SocialUnlinkException.from이 새로 쓴 문장과 정면으로 충돌하니 근거만 바꾸면 됩니다.

WITHDRAWAL_NOT_SUPPORTED(409)가 공용 명세에 없다

④는 잘 처리됐습니다. 다만 ⑤에서 WITHDRAWAL_UNLINK_FAILED를 접지 못한 근거가 "공용 명세가 계약으로 정의한 값" 이었습니다. 같은 기준이면 명세에 없는 코드를 새로 내보내는 것도 같은 문제입니다 — docs#44 §3.6.1은 성공 200만 정하고 있습니다. 마침 그 PR이 열려 있으니 표에 한 줄 더하는 비용이 0입니다.

ⓓ 재시도 상한 자체는 테스트가 고정하지 않는다

attempt == MAX_UNLINK_ATTEMPTS 분기가 단독으로 실행되는 테스트가 없습니다.

  • transientFailureIsAbsorbedByRetry — 1회 실패 후 성공이라 상한에 닿지 않습니다
  • definiteFailureAfterALostResponseIsNotTreatedAsSuccess — 2회차가 비재시도라 !isRetryable()에서 먼저 걸립니다

즉 상한을 3으로 되돌리거나 조건을 어긋나게 써도 깨지는 테스트가 없습니다. 이 상수가 방금 튜닝 대상이 된 참이라 더 걸립니다. transientFailuresLeft = 2로 "계속 일시적 실패면 상한에서 멈춘다" 한 개면 닫힙니다.

ⓔ 머지 순서에 docs#44가 하나 더 있다

명세 개정 docs#44가 아직 OPEN입니다. main의 08 §3.6은 여전히 "204. 응답 본문이 없다" 이고, 이 PR의 코드 주석은 아직 없는 08 §3.6.2를 인용합니다. 프론트 동시 배포와 같은 묶음으로 봐야 합니다 — docs#44 → back#181 → front.


정리

항목 상태
ⓐ Naver 엔드포인트·200+error 판정 막는 조건
ⓑ 클래스 javadoc 잔여 문장 고칠 것(한 줄)
ⓒ 409 코드 명세 등재 docs#44에 한 줄
ⓓ 재시도 상한 테스트 있으면 좋다
ⓔ docs#44 머지 선행 순서 확인

①③④⑤ 반영과 ②의 주석·BD-48 기록은 모두 확인했습니다. requireOwnAccount의 완화 금지 주석은 특히 잘 걸어 뒀습니다 — 다음 사람이 정확히 걸릴 자리에 있습니다.

cherry-go-round and others added 2 commits August 4, 2026 14:25
**상태 코드만 보던 것이 위험했다.** 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>
@cherry-go-round

Copy link
Copy Markdown
Contributor Author

ⓐ 해소 — 공식 문서 확인했습니다. 엔드포인트는 맞고, 오히려 제 방어 코드가 틀렸습니다

작성자가 1차 출처를 확인해 주셨습니다. 네이버가 「연동 해제」를 OAuth 2.0 표준 용어인 Token Revocation으로 개정하면서 /oauth2.0/revoke 엔드포인트를 제공하고 있습니다. grant_type=delete는 개정 이전 방식입니다.

Token Revocation(토큰폐기)는 /oauth2.0/revoke 엔드포인트로 이용할 수 있습니다.
요청 변수: client_id(Y) · client_secret(Y) · token(Y) · token_type_hint(N, 기본 access_token)

판정 기준도 문서가 못 박고 있습니다 — 제가 원래 쓴 그대로였습니다.

정상 처리 시 본문 없이 HTTP 200을 반환합니다. 폐기에 실패하거나 입력값이 유효하지 않은 경우 HTTP 상태 코드와 함께 error, error_description 필드를 JSON 본문으로 반환합니다.

본 API는 입력한 토큰의 유효성과 무관하게, 폐기 자체가 정상 수행되면 200을 반환합니다. 따라서 클라이언트는 응답 본문이 아닌 HTTP 상태 코드를 기준으로 결과를 판단해야 합니다.

상태 error 설명
200 폐기 성공. 또는 token이 이미 폐기되었거나 존재하지 않는 경우
400 invalid_request 필수 파라미터 누락 등
401 unauthorized_client 자격증명 인증 실패
503 temporarily_unavailable 일시적 장애. 재시도 가능

직전 커밋(fd884e3)에서 넣은 본문 검사를 되돌렸습니다(21e981a). 지적을 받고 "어느 쪽이든 안전하게"라며 본문 파싱을 넣었는데, 그건 grant_type=delete 방식을 전제한 방어였습니다. 실제 계약에서는 성공에 본문이 없고 실패는 상태 코드로 갈리므로 파싱할 것이 없습니다. ObjectMapper 의존과 테스트 둘도 함께 걷어냈습니다 — 그 테스트들이 문서가 없다고 한 응답 형태(result=success 본문)를 계약처럼 고정하고 있었습니다.

부수적으로 확인된 것: Naver는 3사 중 유일하게 이미 폐기된 토큰에도 200을 줍니다. ①에서 정정한 "Google은 400" 은 그대로 유효하고, SocialUnlinkException.from의 표가 이미 그렇게 갈라 적고 있습니다. 503이 재시도 가능이라는 것도 현재 판정(5xx → retryable)과 일치합니다.

지적해 주신 덕분에 1차 출처를 확인하는 계기가 됐습니다. 결과적으로 코드는 원래대로지만, 근거가 "붙여받은 텍스트"에서 "확인된 공식 계약"으로 바뀌었습니다.

ⓑ ⓓ 반영

  • 클래스 javadoc의 잔여 문장 정정. 테스트 주석에도 같은 문장이 하나 더 있어 함께 고쳤습니다.
  • 재시도 상한 테스트 추가. 말씀대로 상수를 3으로 되돌려도 깨지는 것이 없었습니다.
    @DisplayName("계속 일시적 실패면 상한에서 멈춘다")
    void stopsAtTheAttemptCap() {
        google.transientFailuresLeft = 99;
        ...
        assertThat(google.attempts).isEqualTo(2);
    }

ⓒ 반영 — 그리고 1차 ⑤ 반박은 제가 틀렸습니다

409를 docs#44에 추가했습니다(ed05f98). 401(계정 없음)도 함께 적었습니다.

그리고 1차 ⑤에서 WITHDRAWAL_UNLINK_FAILED를 "공용 명세가 정의한 값이라 못 접는다"고 답한 것은 근거가 틀렸습니다. origin/main에는 0건이고 제 PR 브랜치에만 있는데, 로컬에서 그 브랜치를 보고 canonical인 줄 알았습니다. 지적하신 기준이 맞습니다.

다만 이 저장소는 구현 후 명세를 갱신하는 순서로 운영되고 있고 명세 작성자가 곧 작업자라, 값을 유지하고 docs#44에 반영하는 쪽으로 정리했습니다. 접자는 제안도 여전히 유효한 선택지입니다 — 도달 불가라는 지적은 맞고, 유지하는 이유는 4번째 공급자가 붙었을 때 "우리 설정 누락"과 "공급자 실패"가 복귀 URL에서 갈린다는 정도입니다.

ⓔ 동의

docs#44 → back#181 → front 순서로 봅니다. docs#44가 OPEN인 것도 제가 잘못 알고 있었습니다(위와 같은 착오).


./gradlew clean check --no-daemon 통과. 스테이징 확인 항목에서 Naver 우선은 그대로 두겠습니다 — 엔드포인트는 확정됐지만 3사 실물 완주는 여전히 안 해봤습니다.

@minyongP minyongP left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3차 — 승인

CI 통과 확인했습니다. 막는 조건이 없습니다.

ⓐ 해소 — 제 지적이 절반 틀렸고, 그 절반이 좋은 결과를 냈습니다

엔드포인트는 원래 코드가 맞았습니다 — 제가 웹 검색으로 찾은 것이 전부 grant_type=delete 구방식 자료였습니다. 1차 출처를 확인해 주신 덕에 "200에 실린 실패" 우려도 공식 계약(상태 코드 기준 판단)으로 닫혔습니다.

fd884e3의 본문 파싱을 21e981a로 되돌린 판단도 옳습니다 — 구방식 전제 위에 쌓은 방어였고, 그 테스트들이 문서에 없는 응답 형태를 계약처럼 고정하고 있었습니다. 최종 NaverUnlinkClient javadoc이 공식 문서 §4.3 인용을 근거로 갖게 된 것이 이번 왕복의 실질 수확입니다. 상태 코드 표(200=이미 폐기 포함, 503=재시도 가능)가 현재 판정 로직과 일치하는 것도 확인했습니다.

ⓑⓒⓓ 반영 확인

  • 클래스 javadoc "새 왕복이라 새 토큰"으로 정정, 테스트 주석까지 함께 — 확인.
  • docs#44 ed05f98의 409 + 401 추가 확인. ⑤ 반박 근거 정정도 확인했습니다.
  • stopsAtTheAttemptCap 확인. 상수를 3으로 되돌려도 안 깨졌다는 실측이 이 테스트가 필요했다는 증명입니다.

남는 것 — 전부 코드 밖

  1. docs#44 → back#181 → front 순서 (합의됨)
  2. 스테이징 3사 실물 완주 — Naver 우선 유지에 동의합니다. 엔드포인트는 확정됐지만 성공 완주는 여전히 미검증입니다.

잔가지 하나(비차단): 고친 클래스 javadoc의 괄호문이 "(공급자는 다시 시도하면…"으로 시작하는데 주어가 어긋납니다 — "공급자는"이 편집 잔재로 보입니다. 의미 전달에는 지장 없습니다.


네 차례 왕복의 지적이 전부 반영되거나 근거 있는 반박으로 닫혔습니다. 열린 항목은 배포 순서와 스테이징 검증뿐이라 승인합니다.

cherry-go-round and others added 2 commits August 4, 2026 14:46
Naver 엔드포인트는 공급자 공식 문서를 근거로 구현했는데, 리뷰 지적을 받고 그
문서를 다시 읽지 않은 채 웹 검색으로 방향을 틀었다. 검색에 걸린 자료들이
개정 이전 것이었고 원래 구현이 맞았다.

리뷰가 틀렸다는 기록이 아니다 — 지적 덕분에 1차 출처를 확인해 근거가 단단해졌다.
틀린 것은 순서다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"(공급자는 이미 폐기된 토큰에도 성공을 준다)"에서 뒷부분만 갈아끼우면서 주어가
남았다. 다시 시도하는 주체는 사용자다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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-285): 탈퇴 시 공급자 연결을 먼저 끊고 성공했을 때만 소프트삭제한다

2 participants