diff --git a/docs/backend/WORKLOG.md b/docs/backend/WORKLOG.md index 6cc4458a..3d98d964 100644 --- a/docs/backend/WORKLOG.md +++ b/docs/backend/WORKLOG.md @@ -88,3 +88,4 @@ | 2026-07-30 | back#98 리뷰 반영. `dev`가 `required_conversation_resolution`이라 **미해결 스레드가 그대로 병합 게이트**여서, 판정이 `COMMENTED`·내용이 `nit`인 줄 단위 지적 2건과 리뷰가 지목한 테스트 구멍 3건을 닫았다. 고친 둘은 **javadoc이 약속한 범위와 실제 방어 범위가 어긋난 자리**라는 점에서 성격이 같다 — ① `AiSearchClient` 생성자 javadoc이 "시크릿 검사를 여기 두지 않는 이유"로 든 논거는 "같은 키를 읽는 `AiProcessClient`가 이미 검사한다"인데, `embedding-profile`은 **이 클라이언트만 읽는 새 키**라 그 논거가 적용되지 않는다. 빈 문자열이면 FastAPI가 Profile 대조에서 422를 주므로 결과는 모든 검색이 503이고, `application.yml` 기본값은 변수를 **설정하지 않은** 경우만 막는다(`PINLOG_AI_EMBEDDING_PROFILE=`처럼 빈 값으로 정의하면 빈 문자열이 이긴다 — BT-05로 이미 겪은 형태). `requireSecret`과 같은 기준(운영 기동 실패/그 외 경고)으로 기동 시점에 끊었다. 기각된 (c)(기동 시 FastAPI 조회)가 **아니다** — 상대에게 묻지 않고 우리 값 유무만 본다. ② `distinctByRecord`는 "상대 결함이 우리 500이 되지 않게 한다"고 적어 두고 `match` **자체가 null**인 경우가 빠져 있었다. `AiSearchClient`가 최상위 `results == null`을 이미 방어하는데 그것도 계약상 올 수 없는 형태라 **층이 어긋난** 것이라, 원소 쪽 층을 맞췄다. 테스트 구멍 셋(`SEARCH_QUERY_MAX` 501자 미검증 · `PRIVATE_ONLY` 한 번도 미투입 · `insertPreset`의 `active`가 죽은 파라미터)은 **코드는 이미 맞는데 지키는 단언이 없던** 자리라 평소의 RED가 안 나온다 — 세 가드를 일부러 부순 뒤(화이트리스트를 `IN ('PUBLIC')`으로 좁히고 `is_active` 조건을 지우고 `@Size`를 떼고) **새 테스트만 실패하고 기존 24개는 전부 통과하는 것**을 관측해 RED를 대신했다. 리뷰가 "그렇게 고쳐도 전부 초록"이라고 한 것이 그대로 재현됐다. `match == null`은 보통의 RED였다(대역 `{"results":[null]}` → **500** 관측 → 가드 → 200). `docs/ai/spec/ai-integration.md` §2의 낡은 줄 2개(패키지 위치 · "Bean 하나")는 **위임 범위 밖이라 고치지 않고 남겼다**(CLAUDE.md 9). `clean check` **370개 통과**(checkstyle·jacoco 포함) (S15P11A705-135) | [BD-39](decisions/BD-39-embedding-profile-in-application-config.md) · [BI-25](implements/BI-25-2026-07-29-personal-search-backend-integration.md) | | 2026-07-30 | 유실·정지된 AI 처리를 복구하는 재스캔 Scheduler와 FAILED Finalizer를 붙였다(S15P11A705-159). **이 저장소에 스케줄링이 처음 들어온다** — `@Scheduled`가 0건이었다. AI 연동의 실패 경로 네 곳이 모두 *"재스캔이 복구한다"*를 안전망으로 전제하고 있었는데 그 재스캔이 없어, 한 번 실패한 Context가 영구히 `PENDING`으로 남았다(상태만 보면 정상과 구별되지 않는다). Bean을 셋으로 가른 것은 **트랜잭션 프록시 때문**이다 — 한 클래스에 두면 자기 메서드 호출이 프록시를 지나지 않아 트랜잭션 없이 돌고, 그러면 `FOR UPDATE SKIP LOCKED`의 잠금이 조회 직후 풀려 중복 방어가 조용히 사라진다. 명세가 근거로 든 것을 **실측으로 뒤집은 지점이 하나 있다**: "Finalize를 먼저 두는 이유는 방금 `retry_count`를 3으로 올린 행이 곧바로 종결되기 때문"이라는데, `runOnce`의 두 줄을 맞바꿔도 테스트가 통과했다 — 증가가 `updated_at`을 함께 갱신해 그 행이 **만료 상태에서 벗어나** Finalizer 후보 조건에 걸리지 않는다. 창을 실제로 확보하는 것은 순서가 아니라 만료 조건 + `updated_at` 갱신이고, 순서는 심층 방어로 남겨 `InOrder` 단위 테스트로 고정했다(그 사실을 BI-28에 적었다). `SKIP LOCKED`는 주장으로 두지 않고 **다른 커넥션이 행을 붙잡은 채 회차를 돌려** 실제로 건너뛰는지 봤다 — 없으면 테스트가 매달리므로 별 스레드 + 15초 타임아웃으로 실패로 드러나게 했다. 함정 둘: `@Scheduled(fixedDelayString)`은 Boot의 완화된 바인딩을 쓰지 않아 `5m`이면 기동이 실패한다(`PT5M`로 두고 `ConfigurationContractTests`가 고정), Spring은 스케줄러를 **작업별로 고르지 않아** 전용 스케줄러라도 Bean 이름 `taskScheduler`를 점유해야 해석이 확정된다(BD-40). RED 6건 확인. `clean check` 386개 통과 | [BD-40](decisions/BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md) · [BI-28](implements/BI-28-2026-07-30-ai-rescan-scheduler.md) · [패키지 구조](../development/package-structure.md) | | 2026-07-30 | 소셜 로그인 이메일을 필수로 만들었다(#97). 프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다는 결정이고, **공용 계약이 먼저 바뀌어야 했다** — `06 §2.2`가 "미동의·미제공 시 null일 수 있다"로 정하고 있어 구현만 바꾸면 계약 위반이다(`CLAUDE.md` 9번). docs#28을 먼저 병합했고 거기서 두 가지를 함께 정리했다: **1차 보장은 공급자 콘솔의 필수 동의 설정**(사용자가 이메일만 거절하고 진행하는 선택지가 동의 화면에 없으므로 이 구현이 막는 것은 일상 흐름이 아니라 방어선), 그리고 **마스킹은 치환이며 NULL이 아니다**(적지 않으면 탈퇴 구현이 NULL을 넣어 이 제약과 부딪힌다. `provider_user_id`가 이미 NOT NULL이면서 마스킹 대상이라 전제는 원래 있었다). **구현하며 드러난 것 셋.** ① **뒤집을 테스트가 예상보다 많았다** — 사전 조사로 3건을 찾았는데 `clean check`에서 `SocialAccountPersistenceTests`가 걸렸다. `emailIsOptional`이 영속성 층에서 null 저장을 고정하고 있었고, 같은 파일의 조회 테스트도 **준비 코드에 null 이메일**이 섞여 함께 깨졌다 — `useEmail(null)`·`isNull()` 검색으로는 안 걸리는 형태다 ② **두 방어선이 독립임을 뮤테이션이 보여 줬다** — 정규화의 `required(...)`만 되돌리면 단위 테스트 4건은 실패하지만 **콜백 테스트 3건은 통과한다**. DB `NOT NULL`이 대신 잡아 같은 `OAUTH_FAILED`로 귀결하기 때문이다. 결함이 아니라 층이 갈린 결과다 — 콜백 테스트는 관측 가능한 계약을, 단위 테스트는 어느 층이 막는가를 고정한다. 반대 방향(`ALTER` 주석 처리)은 `FlywayMigrationTests`가 잡는다 ③ **백필을 넣지 않았다** — 운영 DB에 NULL 행이 없고, 있었다면 **채울 값이 없다**(이메일은 공급자가 주는 값이다). 임의 값을 넣으면 "표시할 이메일"이라는 목적이 깨지므로 그런 환경에서는 마이그레이션이 실패하는 편이 맞다고 보고 SQL 주석에 남겼다. 프론트 질문(실패 사유를 별도 error 값으로 가르는지)에는 **기존 결정대로 `OAUTH_FAILED`로 묶인다**고 답하고 `08 §3.2`에 명시했다 — 사용자가 우리 화면에서 고칠 수 있는 실패가 아니고, 필수 동의 설정에서는 도달하지 않으므로 값을 가르면 발생하지 않는 분기가 남는다. `clean check` **342개 통과** (S15P11A705-152) | [BI-27](implements/BI-27-2026-07-30-social-login-email-required.md) · [docs#28](https://github.com/Team-PinLog/docs/pull/28) | +| 2026-07-30 | 회원 탈퇴를 구현했다(S15P11A705-65, #34). `DELETE /v1/me` 하나로 소프트 삭제·마스킹·연쇄 삭제·AI 파생 무효화·세션 폐기를 한 트랜잭션에 담는다. **이 공백이 완료된 작업 둘을 미충족으로 붙잡고 있었다** — `S15P11A705-124`가 요구한 AI 무효화 네 지점 중 탈퇴만 비어 있었고(#80이 붙일 서비스가 없어 제외), `S15P11A705-147`이 모아 둔 `MemberRepository.isActive`는 `member.deleted_at`을 세팅하는 주체가 없어 한 번도 발동하지 않았다. **가장 큰 판단은 Access 창이다** — 쿠키 만료는 요청을 보낸 기기에만 도달하고 Refresh 폐기는 재발급만 막으므로, 다른 기기에 남은 Access로 최대 30분간 **쓰기까지** 된다. 그 행들은 연쇄 삭제가 지나간 뒤에 만들어져 어떤 정리 경로에도 걸리지 않으므로, 인증 필터가 `isActive`를 보게 하고 요청당 PK 조회 1회를 대가로 냈다(BD-41). Redis 마커는 순단을 인증 실패로 번지게 해서 기각했다(BD-28과 같은 방향). **드러난 것 셋.** ① CSRF가 인가보다 먼저 돌아 토큰 없는 `DELETE`는 인증 여부와 무관하게 403이다 — 403은 CSRF 전용이라는 계약에 맞춰 "미인증 → 401"과 "CSRF 누락 → 403"으로 갈랐다 ② **`loginAs`로는 인증 필터를 검증할 수 없다.** `SecurityContext`에 직접 주입해 필터가 통째로 건너뛰어진다. 처음 실패의 원인이 구현이 아니라 테스트였고, 그 한 건만 실제 토큰을 쿠키에 실어 탈퇴 전 200 → 후 401로 원인을 특정했다. 앞쪽 단언이 없으면 안 된다 — 쿠키 이름을 틀리게 바꾸면 앞쪽만 실패하고 뒤쪽은 통과하는 vacuous pass가 된다 ③ 행 잠금을 쓰지 않았다. `cascadeDelete`가 잠그는 이유는 개수 분기와 `record_count` 산술의 경합인데 탈퇴는 전부 지우므로 둘 다 없고, Collection이 소유자 자기 Record만 담아 타 회원과 경합하지 않는다. 뮤테이션 3종으로 방어선의 독립을 확인했고, 이메일 치환은 **DB `NOT NULL`이 잡지 못해** 테스트가 유일한 방어선이다. `clean check` **407개 통과** (S15P11A705-65) | [BI-29](implements/BI-29-2026-07-30-member-withdrawal.md) · [BD-41](decisions/BD-41-withdrawn-member-check-in-authentication-filter.md) | diff --git a/docs/backend/decisions/BD-41-withdrawn-member-check-in-authentication-filter.md b/docs/backend/decisions/BD-41-withdrawn-member-check-in-authentication-filter.md new file mode 100644 index 00000000..9b4c9986 --- /dev/null +++ b/docs/backend/decisions/BD-41-withdrawn-member-check-in-authentication-filter.md @@ -0,0 +1,65 @@ +# BD-41. 탈퇴 회원의 남은 Access 토큰을 인증 필터의 DB 조회로 막는다 + +- **상태**: Accepted +- **날짜**: 2026-07-30 +- **관련**: [S15P11A705-65](https://ssafy.atlassian.net/browse/S15P11A705-65), [back#34](https://github.com/Team-PinLog/back/issues/34), + [BD-21](BD-21-auth-token-model.md)(Access 30분·Refresh 7일), [BD-35](BD-35-refresh-reuse-family-revocation.md)(회원 단위 Refresh 폐기) + +## 맥락 + +탈퇴가 하는 두 조치로는 **이미 발급된 Access 토큰을 회수할 수 없다.** + +| 조치 | 덮는 범위 | 못 덮는 것 | +|---|---|---| +| `AuthCookies.clear` | 요청을 보낸 **그 기기**의 쿠키 | `Set-Cookie`는 그 응답을 받은 클라이언트에만 도달한다 | +| `RefreshTokenStore.revokeAll` | **재발급** | 이미 발급된 Access는 만료(30분)까지 유효 | + +Access는 자기완결적 JWT이고 폐기 목록이 없다. `JwtAuthenticationFilter`는 서명과 만료만 확인하므로, **다른 기기에 남은 쿠키로 최대 30분간 조회와 쓰기가 모두 가능하다.** 탈퇴한 회원이 새 Record를 만들 수 있다는 뜻이고, 그 행은 삭제 표시된 `member`를 참조한다. + +공용 계약은 이 창에 대해 말하지 않는다. `08 §3.6`이 요구하는 것은 "해당 회원의 모든 Refresh 무효화와 쿠키 만료"까지다. 반면 `back#34`의 완료 조건은 *"탈퇴 후 기존 인증 쿠키로 보호 경로를 요청하면 401"*로 계약보다 강하다. + +## 선택지 + +| 안 | 장점 | 단점 | +|---|---|---| +| (a) 필터에서 `MemberRepository.isActive` 확인 | 창이 즉시 닫힌다. 판정이 이미 한 곳에 모여 있다(S15P11A705-147) | 인증 요청마다 PK 조회 1회 | +| (b) 창을 감수하고 문서화 | 추가 비용 0. 스테이트리스 토큰의 알려진 대가다 | 탈퇴자가 30분간 쓰기 가능. 완료 조건을 계약 수준으로 낮춰야 한다 | +| (c) Redis 탈퇴 마커(TTL 30분) | DB 부하 없음, 만료 자동 | 새 키 스페이스. 필터가 Redis에 의존하게 되어 **Redis 순단이 인증 실패로 번지고**, fail-open/closed 판단이 추가로 필요하다 | + +## 결정 + +**(a)** — 능동적 선택이다. 결정적 이유 둘. + +**① 창 안에서 가능한 것이 읽기가 아니라 쓰기다.** 조회만 새는 것이라면 (b)를 택할 만하다. 그런데 탈퇴 직후 30분간 Record·Collection 생성이 되고, 그 행들은 연쇄 삭제가 이미 지나간 뒤에 만들어져 **어떤 정리 경로에도 걸리지 않는다.** 탈퇴 후 남는 고아 데이터를 감수하는 것과 요청당 PK 조회를 감수하는 것 사이의 선택이고, 후자가 싸다. + +**② 판정 지점이 이미 존재한다.** `S15P11A705-147`이 공개 조회 세 경로의 미탈퇴 판정을 `MemberRepository.isActive` 하나로 모아 두었고, 그 javadoc이 *"탈퇴 정책이 바뀌면 만질 자리는 여기 하나"*라고 적고 있다. 인증 경로가 같은 메서드를 쓰면 정책이 갈라지지 않는다. (c)를 택하면 미탈퇴 판정이 두 벌(DB·Redis)이 되고 둘의 동기화가 새 문제가 된다. + +(c)를 기각한 결정적 이유는 성능이 아니라 **장애 전파**다. 지금 Redis가 죽으면 재발급만 실패하고 진행 중인 세션은 Access 만료까지 살아 있다. 필터가 Redis를 읽으면 Redis 순단이 곧 전체 인증 실패이거나(fail-closed), 아니면 순단 중에 탈퇴자가 통과한다(fail-open). readiness 그룹에 `redis`를 넣지 않기로 한 [BD-28](BD-28-readiness-includes-db.md)과 같은 방향이다 — 외부 의존성 하나가 서비스 전체를 끌어내리지 않게 한다. + +### 함께 정한 것 — Refresh 폐기는 커밋 이후에, 실패를 삼켜서 + +Redis는 DB 트랜잭션에 참여할 수 없다. **트랜잭션 안에서는 "폐기는 됐는데 탈퇴는 롤백된" 상태를 없앨 수 없다** — 폐기가 반환한 뒤 커밋이 실패하거나, 폐기가 키를 일부 지우고 던지면 그대로 남는다. 마지막에 두는 것으로는 창이 좁아질 뿐이다. + +> **정정(2026-07-31).** 이 ADR의 초기 판단은 *"마지막에 두면 그 상태가 남지 않는다"*였다. 틀렸다. `#120` 리뷰에서 지적받아 고쳤다. 순서는 창의 크기만 바꾼다. + +| 배치 | 폐기 실패 | 커밋 실패 | +|---|---|---| +| 트랜잭션 안, 마지막 | DB 롤백 → 아무 일도 없었음 | **폐기만 반영된 상태가 남는다** | +| **커밋 이후, 실패 삼킴** | 탈퇴는 확정, Refresh가 살아남음 | 폐기 전이라 아무 일도 없었음 | + +**커밋 이후를 택했다.** 잔여 위험이 무해한 방향이기 때문이다 — 폐기가 실패해 Refresh가 살아남아도 그것으로 받는 Access는 이 ADR이 넣은 탈퇴 판정에 막힌다. 즉 **이 결정과 그 결정이 서로를 보완한다.** + +실패를 삼키는 이유는 따로다. 삼키지 않으면 이미 커밋된 탈퇴가 500으로 응답하고 컨트롤러가 도달하지 못해 **쿠키도 지워지지 않는다** — 사용자는 "실패했다"고 보는데 계정은 사라진 상태가 된다. + +## 결과 + +- **감수하는 것** + - 인증된 요청마다 `SELECT ... WHERE id = ? AND deleted_at IS NULL` 한 번. PK 조회이고 트랜잭션 밖이라 커넥션을 짧게 쓰지만, 공짜는 아니다. + - **정확히는 조회 1회가 아니라 커넥션 체크아웃 1회가 늘어난다.** `open-in-view: false`라 필터의 `isActive`가 자체 `EntityManager`와 커넥션을 열고 닫은 뒤, 컨트롤러의 `@Transactional`이 두 번째 커넥션을 잡는다. 저트래픽에서는 보이지 않지만 인증 경로가 쓰는 풀 여유를 그만큼 깎는다([#120](https://github.com/Team-PinLog/back/pull/120) 리뷰에서 지적). + - **필터가 인프라 사유로 던질 수 있게 됐다.** 이 변경 전까지 이 필터는 순수 JWT 검증이라 던질 이유가 없었다. `DataAccessException`은 `DispatcherServlet` 밖이라 `@RestControllerAdvice`도 `SecurityErrorWriter`도 타지 않아 **공통 오류 envelope을 우회한다.** 어떻게 다룰지는 별도 티켓에서 정한다. + - `global/security/authentication`이 `domain/member/repository`를 참조한다. `SecurityConfig`가 이미 `domain.auth`를 참조하는 것과 같은 방향이라 새 위반은 아니지만, 의존이 하나 늘었다. + - 캐시가 없다. 같은 회원이 초당 여러 요청을 보내면 그만큼 조회한다. +- **재검토 트리거** + - 인증 경로의 DB 조회가 지연·커넥션 풀에서 실제로 문제로 관측될 때. 그때는 캐시(짧은 TTL) 또는 (c)를 다시 본다. + - 토큰 폐기 목록이나 키 회전이 도입될 때. 그 장치가 생기면 이 검사가 중복이 될 수 있다. + - 탈퇴에 유예 기간이 도입될 때. "탈퇴 = 즉시 차단"이 아니게 되면 `isActive`의 의미부터 다시 정해야 한다. diff --git a/docs/backend/decisions/README.md b/docs/backend/decisions/README.md index dae43c74..f1484aa2 100644 --- a/docs/backend/decisions/README.md +++ b/docs/backend/decisions/README.md @@ -102,3 +102,4 @@ | [BD-38](BD-38-published-predicate-per-layer.md) | 발행 여부 판정은 층마다 자체 보유하고(아홉 곳), 대가를 각 자리를 지키는 테스트로 갚는다 | Accepted | S15P11A705-148 | | [BD-39](BD-39-embedding-profile-in-application-config.md) | Embedding Profile을 `application.yml` 리터럴로 두고 환경변수는 덮어쓰기로만 — 런타임 대조가 성립하려면 Spring도 값을 가져야 한다 | Accepted | S15P11A705-135 | | [BD-40](BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md) | 스케줄링을 `@EnableScheduling` + 자체 `taskScheduler` Bean으로 들이고, 다중 인스턴스 조정은 분산 락 없이 `SKIP LOCKED`에 맡긴다 | Accepted | S15P11A705-159 | +| [BD-41](BD-41-withdrawn-member-check-in-authentication-filter.md) | 탈퇴 회원의 남은 Access 토큰을 인증 필터의 PK 조회로 막는다 — 창 안에서 새는 것이 읽기가 아니라 쓰기이고, Redis 마커는 순단을 인증 실패로 번지게 한다 | Accepted | S15P11A705-65 | diff --git a/docs/backend/implements/BI-29-2026-07-30-member-withdrawal.md b/docs/backend/implements/BI-29-2026-07-30-member-withdrawal.md new file mode 100644 index 00000000..7265b666 --- /dev/null +++ b/docs/backend/implements/BI-29-2026-07-30-member-withdrawal.md @@ -0,0 +1,91 @@ +# BI-29. 회원 탈퇴 + +- **상태**: ✅ 완료 +- **날짜**: 2026-07-30 +- **관련**: S15P11A705-65, [back#34](https://github.com/Team-PinLog/back/issues/34), + [BD-41](../decisions/BD-41-withdrawn-member-check-in-authentication-filter.md)(Access 창을 필터에서 닫은 결정), + [BD-37](../decisions/BD-37-ai-derived-invalidation-inside-deletion-transaction.md)(AI 무효화를 삭제 트랜잭션 안에), + [BI-23](BI-23-2026-07-29-ai-derived-invalidation-on-delete.md)(나머지 삭제 경로 셋) + +## 산출 + +- `DELETE /api/core/v1/me` — `MeController`. 204, 본문 없음, 쿠키 3종 만료. +- `MemberWithdrawalService` — `06 §6.9`의 순서를 한 `@Transactional`에 담는다. +- `SocialAccount.withdraw()` — `withdrawn:` 치환과 `deleted_at`을 한 UPDATE에. +- `JwtAuthenticationFilter` — 탈퇴 회원 판정 추가(BD-41). +- 리포지토리 finder 5개 — 회원 단위 조회 넷과 Follow 양방향 조회 하나. + +## 이 공백이 붙잡고 있던 것 + +탈퇴 경로가 없어서 **완료된 작업 둘이 미충족 상태였다.** + +- `S15P11A705-124`가 요구한 AI 파생 무효화 네 지점 중 **탈퇴만 비어 있었다.** [#80](https://github.com/Team-PinLog/back/pull/80)이 나머지 셋에 적용했지만 붙일 서비스가 없어 제외했다. +- `S15P11A705-147`이 미탈퇴 판정을 `MemberRepository.isActive` 하나로 모아 뒀는데, **`member.deleted_at`을 세팅하는 주체가 없어 그 코드가 한 번도 발동하지 않았다.** 이제 공개 조회 세 경로(Collection 상세 404·팔로우 404·팔로우한 책장 빈 목록)가 새 코드 없이 동작한다. + +## 이슈 본문에서 따르지 않은 것 + +`back#34`은 2026-07-27 작성이고 그 뒤로 전제가 바뀌었다. + +| 본문 | 실제 | +|---|---| +| `this.email = null;` | **`NOT NULL` 위반.** `V6`(#103) 이후 넣을 수 없다. 마스킹은 치환이며 `NULL`이 아니다(06 §2.2) | +| `"deleted:" + id`가 *"부분 유니크 충돌을 피할 유일값"* | 유일값이 **불필요하다.** 유니크가 활성행만 대상이라 마스킹 시점엔 이미 인덱스 밖이다. id는 운영 추적 편의다 | +| 연쇄 삭제·AI 무효화가 *"범위 밖"* | 테이블이 모두 들어왔고 `AiDerivedDataRepository`도 `dev`에 있다 — **이번 범위 안이다** | + +`repository.delete()`를 쓰지 말라는 경고는 유효하다. `@SQLDelete`가 도는 시점에 Hibernate가 엔티티를 removed로 보아 마스킹 UPDATE가 flush되지 않고, `@SQLRestriction` 때문에 다시 조회해 고칠 수도 없다. + +## 설계 판단 넷 + +**① 행 잠금을 쓰지 않는다.** `RecordDeletionService.cascadeDelete`가 Record·Collection을 잠그는 이유는 "활성 Context 1개 이상" 불변식과 `record_count` 산술이 동시 요청과 경합하기 때문이다. 탈퇴는 그 회원의 데이터를 전부 지우므로 **개수를 세어 분기할 일이 없고**, Collection은 소유자 자기 Record만 담아(`CollectionService.requireAllOwnedActiveRecords`) 타 회원과 경합하지도 않는다. + +> `#34` 코멘트의 *"`invalidate`를 Collection 잠금보다 앞에 두라"*는 조언은 잠금이 있다는 전제였다. 전제가 사라졌으므로 **순서만 지켰다**(무효화가 Collection 처리보다 앞). 리뷰에서 뒤집힐 수 있는 판단이라 PR에 명시했다. + +**② `record_count`를 갱신하지 않는다.** Collection 자체가 함께 죽는다. `CollectionService.deleteCollection`이 같은 이유로 갱신하지 않는다. + +**③ Refresh 폐기를 트랜잭션 마지막에 둔다.** 근거는 BD-41에 있다 — 실패가 "아무 일도 없었음"으로 수렴하는 순서다. + +**④ 연쇄를 도메인별 서비스로 흩뜨리지 않는다.** `§6.9`가 정한 것이 순서이므로 한 곳에서 읽혀야 하고, 각 단계가 같은 트랜잭션에 있다는 보장도 흩뜨리면 호출부마다 따라가 봐야 알게 된다. 타 도메인 리포지토리 주입은 `RecordDeletionService`에 선례가 있다. + +## 드러난 것 + +**① CSRF가 인가보다 먼저 돈다.** RED에서 `미인증 → 401`을 기대한 테스트가 **403**으로 실패했다. `CsrfFilter`가 앞에 있어 토큰 없는 `DELETE`는 인증 여부와 무관하게 403이다. `authentication.md`가 403을 CSRF 전용으로 정해 뒀으므로 테스트를 둘로 갈랐다 — 미인증(CSRF 토큰은 주고) → 401, CSRF 누락(인증은 주고) → 403이며 후자는 아무것도 지워지지 않는 것까지 단언한다. + +**② `loginAs`로는 인증 필터를 검증할 수 없다.** Access 창 테스트가 처음 실패했는데 원인이 구현이 아니라 테스트였다. `AuthTestSupport.loginAs`는 인증 객체를 `SecurityContext`에 직접 주입하고, `JwtAuthenticationFilter`는 컨텍스트가 비어 있을 때만 도는 탓에 **통째로 건너뛰어진다.** + +그 한 건만 `JwtTokenProvider.issueAccessToken`으로 실제 토큰을 만들어 쿠키에 실었다. **탈퇴 전 200 → 탈퇴 후 401**로 두 번 호출하는 형태이고, 앞쪽 단언이 없으면 안 된다 — 쿠키 이름을 틀리게 바꿔 확인했더니 **앞쪽만 실패하고 뒤쪽은 통과했다.** 즉 앞쪽이 없으면 토큰이 한 번도 읽히지 않아도 초록불이 되는 vacuous pass가 된다. + +**③ 기존 테스트는 하나도 깨지지 않았다.** 인증 필터에 DB 조회를 추가했는데 407개 전부 통과한다. 도메인 테스트는 `loginAs`로 필터를 우회하고, 실제 쿠키를 쓰는 `AuthTokenContractTests`는 콜백으로 진짜 회원 행을 만들기 때문이다. 존재하지 않는 memberId로 Access를 위조하는 테스트는 `JwtTokenProviderTest`(단위, 필터 미경유)뿐이었다. + +## 감수하는 것 + +- **인증 요청마다 PK 조회 1회**(정확히는 커넥션 체크아웃 1회). 근거와 재검토 트리거는 BD-41에 있다. +- **같은 회원의 동시 요청이 만드는 잔여 행.** `findByMemberId`로 스냅샷을 뜬 뒤 커밋 전까지, 다른 탭의 요청은 필터의 `isActive`를 아직 통과한다. 그 사이에 만들어진 Record·Collection은 연쇄 삭제를 지나쳐 **탈퇴 회원 소유의 활성 행**으로 남는다. + + BD-41이 닫은 Access 창과 같은 종류인데 **창의 크기가 다르다** — 그쪽은 최대 30분이고 사용자가 의도적으로 쓸 수 있지만, 이쪽은 트랜잭션 길이(수 ms)이고 그 순간에 진행 중인 쓰기 요청이 있어야 한다. 닫으려면 인증 판정을 쓰기 트랜잭션과 묶거나 탈퇴 후 정리 경로를 두어야 해서 구조가 커진다. 지금은 감수하고 기록만 남긴다([#120](https://github.com/Team-PinLog/back/pull/120) 리뷰에서 지적). +- **연쇄 삭제에 상한이 없다.** `findByMemberId`가 그 회원의 Record 전체를 메모리에 올리고, id 전부가 `IN` 절에 들어가고, `forEach(softDelete)`가 행마다 UPDATE를 낸다. `hibernate.jdbc.batch_size`도 설정돼 있지 않아 묶이지 않는다. 사용자가 직접 호출하는 엔드포인트라 **상한이 그 회원의 데이터량에 달려 있다.** 지금 규모에서는 문제가 되지 않지만 전제로 적어 둔다. +- **연쇄 완결성이 불변식 하나에 걸려 있다.** `findByCollectionIdIn`이 그 회원의 링크 전체를 덮는 근거는 "Collection이 소유자 자기 Record만 담는다"이고, 이는 `CollectionService.requireAllOwnedActiveRecords` 한 곳이 강제한다. 그 불변식이 깨지면 남의 Collection에 탈퇴 회원의 링크가 남는다 — 이를 고정하는 테스트는 아직 없다. +- **폐기 실패가 자가 치유되지 않는다.** `AuthTokenService.rotate`가 `isActive`를 보지 않으므로, 살아남은 Refresh로 재발급하면 새 jti가 Redis에 다시 들어간다. 발급된 Access는 필터가 막아 무해하지만 고아 인덱스가 계속 갱신된다. +- **탈퇴는 되돌릴 수 없다.** 삭제는 모두 소프트 삭제지만 `social_account`의 개인정보는 마스킹으로 파기되므로 복구 경로가 없다. 운영자 복구도 새 INSERT로만 가능하다(06 §7). +- **물리 삭제 시점은 이 구현의 범위가 아니다.** 개인정보 정책을 따른다(07 §5). 소프트 삭제 데이터 보존 기간은 공용 계약의 미확정 항목이다(06 §8). + +## 검증 + +`./gradlew clean check --no-daemon` — **407개 통과, 실패 0, 오류 0.** + +신규 테스트 13건은 전부 PostgreSQL Testcontainers 기반이고, 삭제 표시된 행은 `@SQLRestriction` 때문에 리포지토리로 보이지 않아 **확인을 `JdbcTemplate` native 쿼리로 한다**(`MemberSoftDeleteTests`와 같은 방식). + +**뮤테이션 3종 — 각 방어선이 독립이다.** 되돌릴 때 실패하는 테스트가 1건씩이고 나머지 12건은 통과한다. + +| 되돌린 것 | 실패 | +|---|---| +| 필터의 `.filter(memberRepository::isActive)` | Access 창 1건 | +| `aiDerivedDataRepository.invalidate(...)` | AI 파생 1건 | +| `withdraw()`의 이메일 치환 | 마스킹 1건 | + +세 번째는 **DB `NOT NULL`이 잡지 못한다** — 원본 이메일이 그대로 남아 제약 위반이 아니다. `BI-27`에서는 두 층(정규화·DB 제약)이 서로를 보완했지만, 여기서는 테스트가 유일한 방어선이다. + +## 남은 것 + +- **탈퇴 후 재로그인 검증이 리포지토리 수준이다.** 활성 행이 사라져 부분 유니크가 재가입을 허용하는 것까지는 확인했지만, 실제 OAuth 콜백을 한 번 더 도는 형태는 아니다. 콜백의 기존 회원 판정은 `SocialAccountRepository.findByProviderAndProviderUserId`가 담당하고 그 계약은 `GoogleLoginCallbackTests`가 이미 고정한다. +- **연쇄 대상에 `place`가 없다.** 공용 데이터라 유지한다(06 §6.9). +- **`ai.context_keyword`는 별도 처리가 없다.** 조회가 `context_ai_state`를 조인해 `keyword_status`로 거르므로 `CANCELLED` 전이만으로 제외된다(08 §3.6). diff --git a/docs/backend/implements/README.md b/docs/backend/implements/README.md index 2476f6ca..a8446290 100644 --- a/docs/backend/implements/README.md +++ b/docs/backend/implements/README.md @@ -43,3 +43,4 @@ | BI-26 | 운영 Secret 5개를 Infra SealedSecret PR로 전달하는 수동 workflow (S15P11A705-154) | ✅ 완료 | [BI-26](BI-26-2026-07-29-runtime-secret-workflow.md) | | BI-27 | 소셜 로그인 이메일 필수화 — 컬럼 NOT NULL·정규화 거절 (S15P11A705-152) | ✅ 완료 | [BI-27](BI-27-2026-07-30-social-login-email-required.md) | | BI-28 | 재스캔 Scheduler와 FAILED Finalizer — 유실·정지된 AI 처리 복구 (S15P11A705-159) | ✅ 완료 | [BI-28](BI-28-2026-07-30-ai-rescan-scheduler.md) | +| BI-29 | 회원 탈퇴 — 마스킹·연쇄 소프트 삭제·AI 무효화·세션 폐기 (S15P11A705-65) | ✅ 완료 | [BI-29](BI-29-2026-07-30-member-withdrawal.md) | diff --git a/src/main/java/com/pinlog/pinlogback/domain/collection/repository/CollectionRecordRepository.java b/src/main/java/com/pinlog/pinlogback/domain/collection/repository/CollectionRecordRepository.java index c8db3a2c..9110320e 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/collection/repository/CollectionRecordRepository.java +++ b/src/main/java/com/pinlog/pinlogback/domain/collection/repository/CollectionRecordRepository.java @@ -18,6 +18,13 @@ public interface CollectionRecordRepository extends JpaRepository findByCollectionId(Long collectionId); + /** + * 탈퇴 연쇄 삭제용. Collection이 소유자 자기 Record만 담으므로 + * ({@code CollectionService.requireAllOwnedActiveRecords}) 이 한 번의 조회가 그 회원의 링크 + * 전체를 덮는다 — Record 쪽에서 다시 훑을 필요가 없다(데이터모델 6.9). + */ + List findByCollectionIdIn(List collectionIds); + long countByCollectionId(Long collectionId); /** diff --git a/src/main/java/com/pinlog/pinlogback/domain/collection/repository/CollectionRepository.java b/src/main/java/com/pinlog/pinlogback/domain/collection/repository/CollectionRepository.java index 40e35a9c..37d4738b 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/collection/repository/CollectionRepository.java +++ b/src/main/java/com/pinlog/pinlogback/domain/collection/repository/CollectionRepository.java @@ -16,6 +16,12 @@ public interface CollectionRepository extends JpaRepository { + /** + * 탈퇴 연쇄 삭제용. 기존 목록 조회들과 달리 페이지네이션이 없다 — 커서로 나누는 것은 사용자에게 + * 보여 줄 때 필요하고, 여기서는 그 회원의 Collection 전체가 한 트랜잭션의 대상이다(데이터모델 6.9). + */ + List findByMemberId(Long memberId); + /** * 활성 연결 수를 세고 분기한 뒤 record_count를 쓰는 유스케이스의 행 잠금(데이터모델 6.7). */ diff --git a/src/main/java/com/pinlog/pinlogback/domain/follow/repository/FollowRepository.java b/src/main/java/com/pinlog/pinlogback/domain/follow/repository/FollowRepository.java index 96cc628e..0e22c9e4 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/follow/repository/FollowRepository.java +++ b/src/main/java/com/pinlog/pinlogback/domain/follow/repository/FollowRepository.java @@ -15,6 +15,18 @@ public interface FollowRepository extends JpaRepository { Optional findByFolloweeMemberIdAndFollowerMemberId(Long followeeMemberId, Long followerMemberId); + /** + * 탈퇴 연쇄 삭제용. 양방향을 함께 가져온다 — 내가 만든 팔로우와 나를 대상으로 하는 + * 팔로우 모두 지워야 한다(데이터모델 6.9). + * + *

위 목록 조회들과 달리 {@code Member} join을 걸지 않는다. 그쪽은 "탈퇴한 상대는 숨긴다"가 + * 목적이지만 여기서는 상대의 탈퇴 여부와 무관하게 내 행을 지워야 하므로, join을 걸면 + * 상대가 이미 탈퇴한 행이 빠져 남는다. + */ + @Query("select f from Follow f" + + " where f.followerMemberId = :memberId or f.followeeMemberId = :memberId") + List findAllInvolving(@Param("memberId") Long memberId); + /** * 내 팔로우 목록(Library의 팔로우 책장 축). followee가 탈퇴한 행은 제외한다 — * Member entity join에 {@code @SQLRestriction}이 적용되어 탈퇴 회원이 걸러진다(데이터모델 1.4). diff --git a/src/main/java/com/pinlog/pinlogback/domain/member/controller/MeController.java b/src/main/java/com/pinlog/pinlogback/domain/member/controller/MeController.java new file mode 100644 index 00000000..693b653e --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/member/controller/MeController.java @@ -0,0 +1,49 @@ +package com.pinlog.pinlogback.domain.member.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.pinlog.pinlogback.domain.member.service.MemberWithdrawalService; +import com.pinlog.pinlogback.global.security.authentication.LoginMember; +import com.pinlog.pinlogback.global.security.authentication.MemberPrincipal; +import com.pinlog.pinlogback.global.security.token.AuthCookies; + +import jakarta.servlet.http.HttpServletResponse; + +/** + * 내 계정(API 명세 3.6). context-path(/api/core)는 인프라 고정값이므로 버전 세그먼트만 명시한다. + * + *

이 경로는 Refresh 쿠키의 {@code Path} 범위 밖이라 Refresh가 전송되지 않는다. 따라서 + * Access 쿠키로 회원을 식별하고, 그 회원의 Refresh를 서버 쪽에서 전부 폐기한다 — 탈퇴는 모든 + * 기기에서 즉시 끊겨야 하므로 전체 폐기가 의도된 동작이다(08 §3.6). + */ +@RestController +@RequestMapping("/v1/me") +public class MeController { + + private final MemberWithdrawalService memberWithdrawalService; + private final AuthCookies authCookies; + + public MeController(MemberWithdrawalService memberWithdrawalService, AuthCookies authCookies) { + this.memberWithdrawalService = memberWithdrawalService; + this.authCookies = authCookies; + } + + /** + * 회원 탈퇴. 본문이 없는 {@code 204}이므로 공통 envelope가 적용되지 않는다 + * ({@code ApiResponseBodyAdvice}가 {@code null} body를 그대로 통과시킨다). + * + *

쿠키 만료를 서비스가 아니라 여기서 한다 — 트랜잭션이 성공했을 때만 지워야 하고, 실패하면 + * 예외가 이 지점에 도달하지 않는다. 반대로 서비스 안에서 지우면 롤백된 요청이 클라이언트를 + * 로그아웃시킨다. + */ + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public void withdraw(@LoginMember MemberPrincipal me, HttpServletResponse response) { + memberWithdrawalService.withdraw(me.memberId()); + authCookies.clear(response); + } +} diff --git a/src/main/java/com/pinlog/pinlogback/domain/member/entity/SocialAccount.java b/src/main/java/com/pinlog/pinlogback/domain/member/entity/SocialAccount.java index 5a5049d5..ac22004e 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/member/entity/SocialAccount.java +++ b/src/main/java/com/pinlog/pinlogback/domain/member/entity/SocialAccount.java @@ -23,10 +23,9 @@ *

식별은 (provider, provider_user_id) 조합이다. 공급자 간 값이 충돌할 수 있어 복합으로 두며, * 유니크는 활성행에만 걸린다 — 전체 유니크면 탈퇴 후 같은 계정으로 재가입할 수 없다. * - *

탈퇴 시 provider_user_id와 email을 마스킹해야 한다. 그때 - * {@code repository.delete()}를 쓰면 안 된다 — Hibernate가 엔티티를 removed 상태로 보아 - * 마스킹 변경이 flush되지 않고, {@code @SQLRestriction} 때문에 다시 조회할 수도 없다. - * 마스킹과 deleted_at을 한 UPDATE에 담는 도메인 메서드를 쓴다(탈퇴 티켓에서 추가). + *

탈퇴는 {@link #withdraw()}로 한다. {@code repository.delete()}를 쓰면 안 된다 — + * Hibernate가 엔티티를 removed 상태로 보아 마스킹 변경이 flush되지 않고, {@code @SQLRestriction} + * 때문에 다시 조회해 고칠 수도 없다. */ @Entity @Table(name = "social_account", schema = "core") @@ -34,6 +33,9 @@ @SQLRestriction("deleted_at IS NULL") public class SocialAccount extends BaseEntity { + /** 마스킹 치환값 접두사. 원본을 되돌릴 수 없어야 하고 NULL이면 안 된다(06 §2.2). */ + private static final String MASK_PREFIX = "withdrawn:"; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -89,4 +91,21 @@ public String getProviderUserId() { public String getEmail() { return email; } + + /** + * 탈퇴 시 개인정보를 파기하고 소프트 삭제한다(06 §6.9). 두 변경이 한 UPDATE에 담겨야 하므로 + * 도메인 메서드로 둔다 — {@code repository.delete()}를 쓰면 Hibernate가 엔티티를 removed로 + * 보아 마스킹이 flush되지 않고, {@code @SQLRestriction} 때문에 다시 조회해 고칠 수도 없다. + * + *

마스킹은 치환이며 {@code NULL}이 아니다 — 두 컬럼 모두 {@code NOT NULL}이다(06 §2.2). + * 치환값이 회원끼리 겹쳐도 무해하다. 유니크는 활성행만 대상이고({@code WHERE deleted_at IS NULL}) + * 이 시점엔 이미 인덱스 밖이므로, id를 붙이는 것은 제약 회피가 아니라 운영 추적 편의다. + * + *

이메일 자리에 {@code .invalid}를 쓴다 — 예약 TLD(RFC 2606)라 어떤 경로로도 발송되지 않는다. + */ + public void withdraw() { + this.providerUserId = MASK_PREFIX + id; + this.email = MASK_PREFIX + id + "@deleted.invalid"; + softDelete(); + } } diff --git a/src/main/java/com/pinlog/pinlogback/domain/member/repository/MemberRepository.java b/src/main/java/com/pinlog/pinlogback/domain/member/repository/MemberRepository.java index 67e9c05b..73626b1c 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/pinlog/pinlogback/domain/member/repository/MemberRepository.java @@ -9,7 +9,9 @@ public interface MemberRepository extends JpaRepository { /** * 미탈퇴 회원인지. {@link Member}가 {@code @SQLRestriction("deleted_at IS NULL")}이라 행이 * 조회되는 것 자체가 미탈퇴 판정이다. 공개 진입 검사(데이터모델 5.5)의 "소유자 미탈퇴"를 - * 공개 조회 경로들이 공유하는 유일한 지점이며, 실패 응답은 규칙이 아니라 표현이므로 + * 공개 조회 경로들이 공유하는 유일한 지점이고, 인증 경로도 여기에 기댄다 — + * {@code JwtAuthenticationFilter}가 탈퇴 회원의 남은 Access 토큰을 이 판정으로 막는다 + * (S15P11A705-65). 실패 응답은 규칙이 아니라 표현이므로 * 호출부가 정한다 — Collection 상세·팔로우는 404, 팔로우한 Shelf 목록은 빈 목록이다(BI-11·BI-13). * *

{@code existsById}가 아니라 {@code findById}를 쓰는 것은 기존 세 경로와 같은 조회를 diff --git a/src/main/java/com/pinlog/pinlogback/domain/member/repository/SocialAccountRepository.java b/src/main/java/com/pinlog/pinlogback/domain/member/repository/SocialAccountRepository.java index a6b33833..980e90c0 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/member/repository/SocialAccountRepository.java +++ b/src/main/java/com/pinlog/pinlogback/domain/member/repository/SocialAccountRepository.java @@ -1,5 +1,6 @@ package com.pinlog.pinlogback.domain.member.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -15,4 +16,7 @@ public interface SocialAccountRepository extends JpaRepository findByProviderAndProviderUserId(SocialProvider provider, String providerUserId); + + /** 탈퇴 시 마스킹 대상. 한 회원이 여러 공급자 계정을 가질 수 있어 목록이다(데이터모델 6.9). */ + List findByMemberId(Long memberId); } diff --git a/src/main/java/com/pinlog/pinlogback/domain/member/service/MemberWithdrawalService.java b/src/main/java/com/pinlog/pinlogback/domain/member/service/MemberWithdrawalService.java new file mode 100644 index 00000000..6da30e21 --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/member/service/MemberWithdrawalService.java @@ -0,0 +1,158 @@ +package com.pinlog.pinlogback.domain.member.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import com.pinlog.pinlogback.domain.ai.repository.AiDerivedDataRepository; +import com.pinlog.pinlogback.domain.auth.service.RefreshTokenStore; +import com.pinlog.pinlogback.domain.collection.entity.Collection; +import com.pinlog.pinlogback.domain.collection.entity.CollectionRecord; +import com.pinlog.pinlogback.domain.collection.repository.CollectionRecordRepository; +import com.pinlog.pinlogback.domain.collection.repository.CollectionRepository; +import com.pinlog.pinlogback.domain.follow.entity.Follow; +import com.pinlog.pinlogback.domain.follow.repository.FollowRepository; +import com.pinlog.pinlogback.domain.member.entity.Member; +import com.pinlog.pinlogback.domain.member.entity.SocialAccount; +import com.pinlog.pinlogback.domain.member.repository.MemberRepository; +import com.pinlog.pinlogback.domain.member.repository.SocialAccountRepository; +import com.pinlog.pinlogback.domain.record.entity.Context; +import com.pinlog.pinlogback.domain.record.entity.Record; +import com.pinlog.pinlogback.domain.record.repository.ContextRepository; +import com.pinlog.pinlogback.domain.record.repository.RecordRepository; +import com.pinlog.pinlogback.global.exception.UnauthorizedException; + +import lombok.extern.slf4j.Slf4j; + +/** + * 회원 탈퇴(API 명세 3.6, 데이터모델 6.9). + * + *

순서가 계약이므로 한 메서드에 담는다. 도메인별 서비스로 흩뜨리면 6.9가 정한 흐름을 + * 한 곳에서 읽을 수 없고, 각 단계가 같은 트랜잭션에 있다는 보장도 호출부마다 따라가 봐야 알게 된다. + * 다른 도메인의 리포지토리를 직접 주입하는 것은 {@code RecordDeletionService}에 이미 선례가 있다. + * + *

되돌릴 수 없다. 모든 삭제는 소프트 삭제이지만 {@code social_account}의 개인정보는 마스킹으로 + * 파기되므로 복구 경로가 없다(06 §2.2). + */ +@Slf4j +@Service +public class MemberWithdrawalService { + + private final MemberRepository memberRepository; + private final SocialAccountRepository socialAccountRepository; + private final RecordRepository recordRepository; + private final ContextRepository contextRepository; + private final CollectionRepository collectionRepository; + private final CollectionRecordRepository collectionRecordRepository; + private final FollowRepository followRepository; + private final AiDerivedDataRepository aiDerivedDataRepository; + private final RefreshTokenStore refreshTokenStore; + + public MemberWithdrawalService( + MemberRepository memberRepository, + SocialAccountRepository socialAccountRepository, + RecordRepository recordRepository, + ContextRepository contextRepository, + CollectionRepository collectionRepository, + CollectionRecordRepository collectionRecordRepository, + FollowRepository followRepository, + AiDerivedDataRepository aiDerivedDataRepository, + RefreshTokenStore refreshTokenStore + ) { + this.memberRepository = memberRepository; + this.socialAccountRepository = socialAccountRepository; + this.recordRepository = recordRepository; + this.contextRepository = contextRepository; + this.collectionRepository = collectionRepository; + this.collectionRecordRepository = collectionRecordRepository; + this.followRepository = followRepository; + this.aiDerivedDataRepository = aiDerivedDataRepository; + this.refreshTokenStore = refreshTokenStore; + } + + /** + * 데이터모델 6.9의 흐름을 그대로 수행한다. + * + *

행 잠금을 쓰지 않는다. {@code RecordDeletionService.cascadeDelete}가 Record·Collection을 + * 잠그는 이유는 "활성 Context 1개 이상" 불변식과 {@code record_count} 산술이 동시 요청과 경합하기 + * 때문이다. 탈퇴는 그 회원의 데이터를 전부 지우므로 개수를 세어 분기할 일이 없고, Collection은 + * 소유자 자기 Record만 담아 타 회원과 경합하지도 않는다. + * + *

{@code record_count}를 갱신하지 않는다. Collection 자체가 함께 죽는다 + * ({@code CollectionService.deleteCollection}이 같은 이유로 갱신하지 않는다). + * + *

Refresh 폐기는 커밋 이후에 한다. Redis는 DB 트랜잭션에 참여할 수 없으므로 트랜잭션 + * 안에 두면 "폐기는 됐는데 탈퇴는 롤백된" 상태를 없앨 수 없다 — 폐기가 반환한 뒤 커밋이 + * 실패하거나, 폐기가 키를 일부 지우고 던지면 그대로 남는다. 마지막에 두는 것으로는 창이 좁아질 + * 뿐이다. + * + *

그래서 커밋 이후로 옮기고 실패를 삼킨다. 이 방향의 잔여 위험은 무해하다 — 폐기가 + * 실패해 Refresh가 살아남아도, 그것으로 받는 Access는 {@code JwtAuthenticationFilter}의 탈퇴 + * 판정에 막힌다(BD-41). 반대로 삼키지 않으면 이미 커밋된 탈퇴가 500으로 응답해 쿠키도 지워지지 + * 않는다. + * + * @throws UnauthorizedException 이미 탈퇴한 회원일 때. 인증 계층이 먼저 막지만 그 보증이 + * 필터 설정에 있고 이 메서드의 타입에는 없어 한 번 더 확인한다 + */ + @Transactional + public void withdraw(Long memberId) { + Member member = memberRepository.findById(memberId).orElseThrow(UnauthorizedException::new); + + member.softDelete(); + socialAccountRepository.findByMemberId(memberId).forEach(SocialAccount::withdraw); + + List records = recordRepository.findByMemberId(memberId); + invalidateContextsOf(records); + records.forEach(Record::softDelete); + + List collections = collectionRepository.findByMemberId(memberId); + List collectionIds = collections.stream().map(Collection::getId).toList(); + if (!collectionIds.isEmpty()) { + collectionRecordRepository.findByCollectionIdIn(collectionIds) + .forEach(CollectionRecord::softDelete); + } + collections.forEach(Collection::softDelete); + + // 양방향이다 — 내가 만든 팔로우와 나를 대상으로 하는 팔로우 모두 지운다(6.9). + followRepository.findAllInvolving(memberId).forEach(Follow::softDelete); + + revokeEverySessionAfterCommit(memberId); + } + + /** 커밋 이후에 최선 노력으로 폐기한다. 이유는 {@link #withdraw(Long)} javadoc에 있다. */ + private void revokeEverySessionAfterCommit(Long memberId) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + refreshTokenStore.revokeAll(memberId); + } catch (RuntimeException e) { + // 탈퇴는 이미 확정됐다. 여기서 던지면 확정된 탈퇴가 500으로 응답하고 쿠키도 + // 지워지지 않는다. 남은 Refresh는 BD-41의 탈퇴 판정이 무력화한다. + log.error("failed to revoke refresh tokens after withdrawal: memberId={}", memberId, e); + } + } + }); + } + + /** + * AI 파생 데이터를 무효화한다. Context 소프트 삭제와 같은 트랜잭션이어야 한다 — 나누면 + * "core는 지웠는데 검색 결과에는 계속 나오는" 부분 실패가 남는다(BD-37). + * + *

영향 행이 0이어도 정상이다. 파생 데이터가 생기기 전에 삭제된 경우이고, Record가 없는 회원은 + * 빈 목록이라 호출 자체가 no-op이다. + */ + private void invalidateContextsOf(List records) { + if (records.isEmpty()) { + return; + } + List recordIds = records.stream().map(Record::getId).toList(); + List contexts = + contextRepository.findByRecordIdInOrderByOriginCreatedAtAscIdAsc(recordIds); + contexts.forEach(Context::softDelete); + aiDerivedDataRepository.invalidate(contexts.stream().map(Context::getId).toList()); + } +} diff --git a/src/main/java/com/pinlog/pinlogback/domain/record/repository/RecordRepository.java b/src/main/java/com/pinlog/pinlogback/domain/record/repository/RecordRepository.java index 50415035..cf0a0572 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/record/repository/RecordRepository.java +++ b/src/main/java/com/pinlog/pinlogback/domain/record/repository/RecordRepository.java @@ -19,6 +19,9 @@ public interface RecordRepository extends JpaRepository { Optional findByMemberIdAndPlaceId(Long memberId, Long placeId); + /** 탈퇴 연쇄 삭제용. 그 회원의 활성 Record 전체다(데이터모델 6.9). */ + List findByMemberId(Long memberId); + List findByIdInAndMemberId(List ids, Long memberId); /** diff --git a/src/main/java/com/pinlog/pinlogback/global/security/SecurityConfig.java b/src/main/java/com/pinlog/pinlogback/global/security/SecurityConfig.java index 93d682dc..9c409013 100644 --- a/src/main/java/com/pinlog/pinlogback/global/security/SecurityConfig.java +++ b/src/main/java/com/pinlog/pinlogback/global/security/SecurityConfig.java @@ -16,6 +16,7 @@ import org.springframework.security.web.csrf.CsrfFilter; import com.pinlog.pinlogback.domain.auth.controller.SocialLoginController; +import com.pinlog.pinlogback.domain.member.repository.MemberRepository; import com.pinlog.pinlogback.global.security.authentication.JwtAuthenticationFilter; import com.pinlog.pinlogback.global.security.error.RestAccessDeniedHandler; import com.pinlog.pinlogback.global.security.error.RestAuthenticationEntryPoint; @@ -109,7 +110,8 @@ public SecurityFilterChain securityFilterChain( OAuthLoginSuccessHandler successHandler, OAuthLoginFailureHandler failureHandler, CookieCsrfTokenRepository csrfTokenRepository, - JwtTokenProvider jwtTokenProvider + JwtTokenProvider jwtTokenProvider, + MemberRepository memberRepository ) throws Exception { return http .oauth2Login(oauth2 -> oauth2 @@ -137,7 +139,8 @@ public SecurityFilterChain securityFilterChain( .addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class) // Access 쿠키를 SecurityContext로 옮긴다. 인가 판정 전에 돌아야 한다. .addFilterBefore( - new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) + new JwtAuthenticationFilter(jwtTokenProvider, memberRepository), + UsernamePasswordAuthenticationFilter.class) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 기본 401이 로그인 폼 리다이렉트나 WWW-Authenticate로 나가지 않도록 끈다. .formLogin(AbstractHttpConfigurer::disable) diff --git a/src/main/java/com/pinlog/pinlogback/global/security/authentication/JwtAuthenticationFilter.java b/src/main/java/com/pinlog/pinlogback/global/security/authentication/JwtAuthenticationFilter.java index d1f70e4f..b3c5ec99 100644 --- a/src/main/java/com/pinlog/pinlogback/global/security/authentication/JwtAuthenticationFilter.java +++ b/src/main/java/com/pinlog/pinlogback/global/security/authentication/JwtAuthenticationFilter.java @@ -10,6 +10,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.WebUtils; +import com.pinlog.pinlogback.domain.member.repository.MemberRepository; import com.pinlog.pinlogback.global.security.token.AuthCookies; import com.pinlog.pinlogback.global.security.token.JwtTokenProvider; @@ -26,6 +27,12 @@ * {@code RestAuthenticationEntryPoint}로 넘겨 공통 envelope 401을 만든다 — 오류 응답 형식이 * 한 곳에만 있어야 계약이 어긋나지 않는다. * + *

서명·만료만으로는 부족해 탈퇴 여부를 함께 본다. Access는 30분짜리이고 폐기 목록이 + * 없으므로, 토큰만 검사하면 탈퇴한 회원이 다른 기기에 남은 쿠키로 최대 30분간 읽기·쓰기를 + * 계속할 수 있다. Refresh 폐기는 재발급만 막고 이미 발급된 Access는 막지 못한다. 대가는 인증 + * 요청마다 PK 조회 한 번이며, 판정은 {@link MemberRepository#isActive}에 모여 있다 — + * S15P11A705-147이 그 목적으로 만든 지점이다. + * *

이미 인증된 요청은 건드리지 않는다. OAuth 로그인 흐름이 자기 인증을 이미 올려 둔 경우가 있다. * *

빈으로 만들지 않는다. Spring Boot는 {@code Filter} 타입 빈을 서블릿 컨테이너에도 자동 @@ -36,9 +43,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; + private final MemberRepository memberRepository; - public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) { + public JwtAuthenticationFilter(JwtTokenProvider tokenProvider, MemberRepository memberRepository) { this.tokenProvider = tokenProvider; + this.memberRepository = memberRepository; } @Override @@ -50,6 +59,7 @@ protected void doFilterInternal( if (SecurityContextHolder.getContext().getAuthentication() == null) { readAccessToken(request) .flatMap(tokenProvider::parseAccessToken) + .filter(memberRepository::isActive) .ifPresent(memberId -> authenticate(request, memberId)); } filterChain.doFilter(request, response); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8d17934a..9fae3e65 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -74,6 +74,14 @@ spring: user-info-uri: https://openapi.naver.com/v1/nid/me # 본문이 response로 한 겹 감싸여 있어 최상위에 식별자가 없다. 감싼 키를 지목한다. user-name-attribute: response + data: + redis: + # Lettuce 기본값은 60초다. 그 값이면 Redis가 멎었을 때 요청 스레드가 1분간 붙잡힌다 — + # 탈퇴는 커밋 이후 폐기가 요청 스레드에서 돌아(MemberWithdrawalService) 204 응답이 그만큼 + # 늦고, 로그인·재발급도 같은 값에 걸린다. Redis는 캐시·세션 전용이라 오래 기다려 얻을 것이 + # 없으므로 짧게 끊고 실패로 처리한다. + timeout: 2s + connect-timeout: 1s jpa: hibernate: ddl-auto: validate diff --git a/src/test/java/com/pinlog/pinlogback/domain/member/MemberWithdrawalApiTests.java b/src/test/java/com/pinlog/pinlogback/domain/member/MemberWithdrawalApiTests.java new file mode 100644 index 00000000..1b762c77 --- /dev/null +++ b/src/test/java/com/pinlog/pinlogback/domain/member/MemberWithdrawalApiTests.java @@ -0,0 +1,490 @@ +package com.pinlog.pinlogback.domain.member; + +import static com.pinlog.pinlogback.domain.ai.repository.AiDerivedDataRepository.CANCELLED; +import static com.pinlog.pinlogback.support.AuthTestSupport.loginAs; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import com.pinlog.pinlogback.domain.auth.service.RefreshTokenStore; +import com.pinlog.pinlogback.domain.collection.entity.Collection; +import com.pinlog.pinlogback.domain.collection.repository.CollectionRepository; +import com.pinlog.pinlogback.domain.member.entity.Member; +import com.pinlog.pinlogback.domain.member.entity.SocialAccount; +import com.pinlog.pinlogback.domain.member.entity.SocialProvider; +import com.pinlog.pinlogback.domain.member.repository.MemberRepository; +import com.pinlog.pinlogback.domain.member.repository.SocialAccountRepository; +import com.pinlog.pinlogback.global.security.authentication.MemberPrincipal; +import com.pinlog.pinlogback.global.security.token.AuthCookies; +import com.pinlog.pinlogback.global.security.token.JwtTokenProvider; +import com.pinlog.pinlogback.integration.IntegrationContainerSupport; + +import jakarta.servlet.http.Cookie; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +/** + * 회원 탈퇴(API 명세 3.6, 데이터모델 6.9). + * + *

삭제 표시된 행은 {@code @SQLRestriction} 때문에 리포지토리로 보이지 않는다. 그래서 마스킹과 + * 연쇄 삭제 확인은 모두 {@link JdbcTemplate} native 쿼리로 한다({@code MemberSoftDeleteTests}와 + * 같은 방식). + * + *

연쇄 대상이 여섯 테이블이라 픽스처를 API로 만든다 — 리포지토리로 직접 넣으면 실제 경로가 + * 만드는 연관(Collection의 record_count, CollectionRecord 링크)을 손으로 재현해야 하고, 그때부터 + * 테스트가 검증하는 것이 "탈퇴가 지우는가"에서 "내가 픽스처를 맞게 만들었는가"로 옮겨간다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@DisplayName("회원 탈퇴") +class MemberWithdrawalApiTests extends IntegrationContainerSupport { + + private static final String PATH = "/v1/me"; + + private final JsonMapper jsonMapper = JsonMapper.builder().build(); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private SocialAccountRepository socialAccountRepository; + + @Autowired + private CollectionRepository collectionRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private StringRedisTemplate redisTemplate; + + @Autowired + private JwtTokenProvider tokenProvider; + + /** 폐기 실패 경로만 스텁한다. 다른 테스트에서는 스텁하지 않아 실제 빈에 위임한다. */ + @MockitoSpyBean + private RefreshTokenStore refreshTokenStore; + + @Test + @DisplayName("204를 반환하고 member와 social_account를 소프트 삭제한다") + void withdrawSoftDeletesMemberAndSocialAccount() throws Exception { + long memberId = newMemberId(); + long accountId = givenSocialAccount(memberId, "google-withdraw-1", "user@example.com"); + + mockMvc.perform(delete(PATH).with(loginAs(memberId))) + .andExpect(status().isNoContent()); + + assertThat(deletedAtOf("core.member", memberId)).isNotNull(); + assertThat(deletedAtOf("core.social_account", accountId)).isNotNull(); + } + + @Test + @DisplayName("provider_user_id와 email을 마스킹한다 — 원본이 남지 않는다") + void withdrawMasksPersonalData() throws Exception { + long memberId = newMemberId(); + long accountId = givenSocialAccount(memberId, "google-withdraw-2", "victim@example.com"); + + mockMvc.perform(delete(PATH).with(loginAs(memberId))) + .andExpect(status().isNoContent()); + + // 치환값을 그대로 고정한다. "원본과 다르다"로는 부족하다 — 접두만 붙이는 구현 + // (MASK + email)도 그 단언을 통과하면서 개인정보를 그대로 남긴다. 복구 경로가 없는 + // 파기이므로 형식이 바뀌면 여기서 걸려야 한다. + Map row = socialAccountRow(accountId); + assertThat(row.get("provider_user_id")).isEqualTo("withdrawn:" + accountId); + assertThat(row.get("email")).isEqualTo("withdrawn:" + accountId + "@deleted.invalid"); + // 형식과 별개로 성립해야 하는 성질 — 원본 조각이 어디에도 남지 않는다. + assertThat(row.values().stream().map(String::valueOf)) + .noneMatch(value -> value.contains("victim") || value.contains("google-withdraw-2")); + } + + @Test + @DisplayName("마스킹과 deleted_at이 한 트랜잭션에서 함께 반영된다") + void maskingAndSoftDeleteLandTogether() throws Exception { + long memberId = newMemberId(); + long accountId = givenSocialAccount(memberId, "google-withdraw-3", "atomic@example.com"); + + mockMvc.perform(delete(PATH).with(loginAs(memberId))) + .andExpect(status().isNoContent()); + + // 둘 중 하나만 적용된 상태가 없다는 것이 계약이다. @SQLDelete + @SQLRestriction 조합에서는 + // 마스킹이 조용히 유실되는 경로가 있어(#34) 같은 행에서 둘을 함께 본다. + Map row = socialAccountRow(accountId); + assertThat(row.get("deleted_at")).isNotNull(); + assertThat(row.get("provider_user_id")).isEqualTo("withdrawn:" + accountId); + } + + @Test + @DisplayName("Record·Context·Collection·CollectionRecord·Follow를 연쇄 소프트 삭제한다") + void withdrawCascadesToOwnedData() throws Exception { + long memberId = newMemberId(); + givenSocialAccount(memberId, "google-withdraw-4", "cascade@example.com"); + long recordId = createRecord(memberId, "wd-cascade-1", "맥락"); + long contextId = firstContextId(memberId, recordId); + long collectionId = createCollection(memberId, "내 책장", List.of(recordId)); + + long followerId = newMemberId(); + long followId = follow(followerId, collectionId); + long followeeCollectionId = collectionRepository + .save(Collection.create(newMemberId(), "남의 책장")).getId(); + long myFollowId = follow(memberId, followeeCollectionId); + + mockMvc.perform(delete(PATH).with(loginAs(memberId))) + .andExpect(status().isNoContent()); + + assertThat(deletedAtOf("core.record", recordId)).isNotNull(); + assertThat(deletedAtOf("core.context", contextId)).isNotNull(); + assertThat(deletedAtOf("core.collection", collectionId)).isNotNull(); + assertThat(collectionRecordDeletedCount(collectionId)).isEqualTo(1L); + // 내가 만든 Follow와 나를 대상으로 하는 Follow 둘 다 지운다(06 §6.9). + assertThat(deletedAtOf("core.follow", myFollowId)).isNotNull(); + assertThat(deletedAtOf("core.follow", followId)).isNotNull(); + } + + /** + * 남의 계정을 지우려는 요청이 아니다 — 그런 요청은 만들 수 없다. {@code DELETE /v1/me}에는 경로 + * 변수가 없고 대상은 항상 인증된 본인이다(BD-14 식별자 은닉). 여기서 보는 것은 내 탈퇴의 + * 연쇄가 남의 행까지 지우지 않는가이며, 연쇄 조회에서 회원 조건을 빼먹으면 깨진다. + */ + @Test + @DisplayName("내 탈퇴의 연쇄 삭제가 타인 데이터로 번지지 않는다") + void cascadeDoesNotSpillIntoOtherMembersData() throws Exception { + long memberId = newMemberId(); + givenSocialAccount(memberId, "google-withdraw-5", "mine@example.com"); + createRecord(memberId, "wd-mine-1", "내 맥락"); + + long otherId = newMemberId(); + long otherRecordId = createRecord(otherId, "wd-other-1", "남의 맥락"); + long otherCollectionId = createCollection(otherId, "남의 책장", List.of(otherRecordId)); + + mockMvc.perform(delete(PATH).with(loginAs(memberId))) + .andExpect(status().isNoContent()); + + assertThat(deletedAtOf("core.member", otherId)).isNull(); + assertThat(deletedAtOf("core.record", otherRecordId)).isNull(); + assertThat(deletedAtOf("core.collection", otherCollectionId)).isNull(); + } + + @Test + @DisplayName("AI 파생 데이터를 무효화한다 — State CANCELLED, Embedding is_deleted") + void withdrawInvalidatesAiDerivedData() throws Exception { + long memberId = newMemberId(); + givenSocialAccount(memberId, "google-withdraw-6", "ai@example.com"); + long recordId = createRecord(memberId, "wd-ai-1", "맥락"); + long contextId = firstContextId(memberId, recordId); + givenDerivedData(memberId, recordId, contextId, "COMPLETED", "COMPLETED"); + + mockMvc.perform(delete(PATH).with(loginAs(memberId))) + .andExpect(status().isNoContent()); + + // COMPLETED도 덮는다. context_keyword에는 is_deleted가 없어 keyword_status가 단독으로 + // 조회 제외를 담당하므로, 남기면 탈퇴한 사용자의 키워드가 계속 노출된다(08 §3.6). + assertThat(aiState(contextId)) + .containsEntry("embedding_status", CANCELLED) + .containsEntry("keyword_status", CANCELLED); + assertThat(embeddingDeleted(contextId)).isTrue(); + } + + @Test + @DisplayName("파생 데이터가 없는 회원도 탈퇴한다") + void withdrawWorksWithoutAnyOwnedData() throws Exception { + long memberId = newMemberId(); + givenSocialAccount(memberId, "google-withdraw-7", "bare@example.com"); + + // Record·Collection이 없으면 무효화 대상 contextIds가 빈 목록이다. invalidate(List.of())가 + // no-op이어야 하고, 여기서 깨지면 "가입만 하고 아무것도 안 한 회원"이 탈퇴하지 못한다. + mockMvc.perform(delete(PATH).with(loginAs(memberId))) + .andExpect(status().isNoContent()); + + assertThat(deletedAtOf("core.member", memberId)).isNotNull(); + } + + @Test + @DisplayName("해당 회원의 Refresh를 전부 폐기한다") + void withdrawRevokesEveryRefreshTokenOfTheMember() throws Exception { + long memberId = newMemberId(); + givenSocialAccount(memberId, "google-withdraw-8", "redis@example.com"); + givenRefreshToken(memberId, "jti-a"); + givenRefreshToken(memberId, "jti-b"); + + mockMvc.perform(delete(PATH).with(loginAs(memberId))) + .andExpect(status().isNoContent()); + + assertThat(refreshKeysOf(memberId)).isEmpty(); + } + + /** + * 폐기를 트랜잭션 밖(커밋 이후)으로 옮기고 실패를 삼킨 판단의 근거를 고정한다. 삼키지 않으면 + * 이미 커밋된 탈퇴가 500으로 응답하고 컨트롤러에 도달하지 못해 쿠키도 지워지지 않는다 — + * 사용자는 실패로 보는데 계정은 사라진 상태가 된다(BD-41). + * + *

남은 Refresh는 무해하다. 그것으로 받는 Access는 필터의 탈퇴 판정에 막힌다. + */ + @Test + @DisplayName("Refresh 폐기가 실패해도 탈퇴는 확정되고 쿠키는 지워진다") + void withdrawSurvivesRefreshRevocationFailure() throws Exception { + long memberId = newMemberId(); + givenSocialAccount(memberId, "google-withdraw-12", "redis-down@example.com"); + doThrow(new RedisConnectionFailureException("Redis 순단을 가장한다")) + .when(refreshTokenStore).revokeAll(memberId); + + MvcResult result = mockMvc.perform(delete(PATH).with(loginAs(memberId))) + .andExpect(status().isNoContent()) + .andReturn(); + + assertThat(deletedAtOf("core.member", memberId)).isNotNull(); + assertThat(expiredCookieNames(result.getResponse().getHeaders(HttpHeaders.SET_COOKIE))) + .containsExactlyInAnyOrder( + AuthCookies.ACCESS_TOKEN, AuthCookies.REFRESH_TOKEN, AuthCookies.LOGGED_IN); + } + + @Test + @DisplayName("인증 쿠키와 표시 쿠키를 모두 만료시킨다") + void withdrawExpiresAllThreeCookies() throws Exception { + long memberId = newMemberId(); + givenSocialAccount(memberId, "google-withdraw-9", "cookie@example.com"); + + MvcResult result = mockMvc.perform(delete(PATH).with(loginAs(memberId))) + .andExpect(status().isNoContent()) + .andReturn(); + + List setCookies = result.getResponse().getHeaders(HttpHeaders.SET_COOKIE); + assertThat(expiredCookieNames(setCookies)).containsExactlyInAnyOrder( + AuthCookies.ACCESS_TOKEN, AuthCookies.REFRESH_TOKEN, AuthCookies.LOGGED_IN); + } + + @Test + @DisplayName("미인증 요청은 401이다") + void withdrawWithoutAuthenticationIs401() throws Exception { + // CSRF 토큰은 준다. CsrfFilter가 인가보다 먼저 돌아서 토큰이 없으면 인증 여부와 무관하게 + // 403이 되고, 그러면 이 테스트가 401을 확인하지 못한다(authentication.md — 403은 CSRF 전용). + mockMvc.perform(delete(PATH).with(csrf())) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("CSRF 토큰이 없으면 403이다") + void withdrawWithoutCsrfTokenIs403() throws Exception { + long memberId = newMemberId(); + givenSocialAccount(memberId, "google-withdraw-11", "csrf@example.com"); + + mockMvc.perform(delete(PATH).with(authentication(principalOf(memberId)))) + .andExpect(status().isForbidden()); + + // 거절됐으므로 아무것도 지워지지 않아야 한다. + assertThat(deletedAtOf("core.member", memberId)).isNull(); + } + + @Test + @DisplayName("탈퇴한 회원의 Access 쿠키로는 보호 경로에 접근할 수 없다") + void withdrawnMemberCannotUseARemainingAccessToken() throws Exception { + long memberId = newMemberId(); + givenSocialAccount(memberId, "google-withdraw-10", "stale@example.com"); + + // 다른 기기에 남은 쿠키. loginAs는 SecurityContext에 직접 넣어 검증 대상인 필터를 + // 건너뛰므로 여기서만 실제 토큰을 쓴다. + Cookie staleAccess = new Cookie(AuthCookies.ACCESS_TOKEN, tokenProvider.issueAccessToken(memberId)); + + mockMvc.perform(get("/v1/records/map").cookie(staleAccess)) + .andExpect(status().isOk()); + + mockMvc.perform(delete(PATH).with(loginAs(memberId))) + .andExpect(status().isNoContent()); + + // 같은 쿠키가 이제 401이다. 서명·만료는 그대로이고 탈퇴 여부만 바뀌었다. + mockMvc.perform(get("/v1/records/map").cookie(staleAccess)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("탈퇴 후 같은 소셜 계정으로 다시 로그인하면 신규 회원이 된다") + void reSignupAfterWithdrawalCreatesANewMember() throws Exception { + long memberId = newMemberId(); + givenSocialAccount(memberId, "google-rejoin-1", "rejoin@example.com"); + + mockMvc.perform(delete(PATH).with(loginAs(memberId))) + .andExpect(status().isNoContent()); + + // 활성 행이 사라졌으므로 콜백의 기존 회원 판정이 비어 있는 결과를 받는다(08 §3.6). + // 부분 유니크가 활성행만 대상이라 같은 (provider, provider_user_id)로 저장이 된다. + Member rejoined = memberRepository.save(Member.create()); + SocialAccount reAccount = socialAccountRepository.saveAndFlush( + SocialAccount.create(rejoined, SocialProvider.GOOGLE, "google-rejoin-1", "rejoin@example.com")); + + assertThat(rejoined.getId()).isNotEqualTo(memberId); + assertThat(socialAccountRepository + .findByProviderAndProviderUserId(SocialProvider.GOOGLE, "google-rejoin-1")) + .get() + .extracting(SocialAccount::getId) + .isEqualTo(reAccount.getId()); + } + + // --- 픽스처 ------------------------------------------------------------- + + private long newMemberId() { + return memberRepository.save(Member.create()).getId(); + } + + /** CSRF 없이 인증만 주기 위한 것. {@code loginAs}는 둘을 함께 넣으므로 여기서는 쓸 수 없다. */ + private Authentication principalOf(long memberId) { + return new UsernamePasswordAuthenticationToken(new MemberPrincipal(memberId), null, List.of()); + } + + private long givenSocialAccount(long memberId, String providerUserId, String email) { + Member member = memberRepository.findById(memberId).orElseThrow(); + return socialAccountRepository + .saveAndFlush(SocialAccount.create(member, SocialProvider.GOOGLE, providerUserId, email)) + .getId(); + } + + private void givenRefreshToken(long memberId, String tokenId) { + redisTemplate.opsForValue().set("auth:refresh:" + memberId + ":" + tokenId, "1"); + redisTemplate.opsForSet().add("auth:refresh-index:" + memberId, tokenId); + } + + private long createRecord(long memberId, String kakaoPlaceId, String contextBody) throws Exception { + String body = """ + { + \t"place": { + \t\t"kakaoPlaceId": "%s", + \t\t"name": "장소", + \t\t"address": "주소", + \t\t"lat": 37.5, + \t\t"lng": 127.0 + \t}, + \t"contextBody": "%s" + } + """.formatted(kakaoPlaceId, contextBody); + JsonNode response = parse(mockMvc.perform(post("/v1/records").with(loginAs(memberId)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString()); + return response.at("/data/recordId").asLong(); + } + + private long firstContextId(long memberId, long recordId) throws Exception { + JsonNode detail = parse(mockMvc.perform(get("/v1/records/{recordId}", recordId) + .with(loginAs(memberId))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + return detail.at("/data/contexts/0/contextId").asLong(); + } + + private long createCollection(long memberId, String title, List recordIds) throws Exception { + String ids = recordIds.stream().map(String::valueOf).collect(Collectors.joining(", ")); + JsonNode response = parse(mockMvc.perform(post("/v1/collections").with(loginAs(memberId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\": \"" + title + "\", \"recordIds\": [" + ids + "]}")) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString()); + return response.at("/data/collectionId").asLong(); + } + + private long follow(long memberId, long collectionId) throws Exception { + JsonNode response = parse(mockMvc.perform(post("/v1/follows").with(loginAs(memberId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"collectionId\": " + collectionId + "}")) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString()); + return response.at("/data/followId").asLong(); + } + + private void givenDerivedData(long memberId, long recordId, long contextId, + String embeddingStatus, String keywordStatus) { + jdbcTemplate.update(""" + INSERT INTO ai.context_ai_state (context_id, embedding_status, keyword_status) + VALUES (?, ?, ?) + ON CONFLICT (context_id) DO UPDATE SET + embedding_status = EXCLUDED.embedding_status, + keyword_status = EXCLUDED.keyword_status + """, contextId, embeddingStatus, keywordStatus); + jdbcTemplate.update(""" + INSERT INTO ai.context_embedding + (context_id, user_id, record_id, embedding, embedding_profile) + VALUES (?, ?, ?, ?::vector, 'test-profile') + """, contextId, memberId, recordId, zeroVector()); + } + + private String zeroVector() { + return "[" + "0,".repeat(1535) + "0]"; + } + + // --- 검증 (native) ------------------------------------------------------ + + private Object deletedAtOf(String table, long id) { + return jdbcTemplate.queryForMap("SELECT deleted_at FROM " + table + " WHERE id = ?", id) + .get("deleted_at"); + } + + private Map socialAccountRow(long id) { + return jdbcTemplate.queryForMap( + "SELECT provider_user_id, email, deleted_at FROM core.social_account WHERE id = ?", id); + } + + private Long collectionRecordDeletedCount(long collectionId) { + return jdbcTemplate.queryForObject( + "SELECT count(*) FROM core.collection_record" + + " WHERE collection_id = ? AND deleted_at IS NOT NULL", + Long.class, collectionId); + } + + private Map aiState(long contextId) { + return jdbcTemplate.queryForMap( + "SELECT embedding_status, keyword_status FROM ai.context_ai_state WHERE context_id = ?", + contextId); + } + + private boolean embeddingDeleted(long contextId) { + return jdbcTemplate.queryForObject( + "SELECT is_deleted FROM ai.context_embedding WHERE context_id = ?", Boolean.class, contextId); + } + + private Set refreshKeysOf(long memberId) { + Set tokens = redisTemplate.keys("auth:refresh:" + memberId + ":*"); + Set index = redisTemplate.keys("auth:refresh-index:" + memberId); + return java.util.stream.Stream.concat(tokens.stream(), index.stream()) + .collect(Collectors.toSet()); + } + + /** Max-Age=0으로 만료된 쿠키 이름만 고른다. 값 비우기만으로는 브라우저에 남는다. */ + private Set expiredCookieNames(List setCookies) { + return setCookies.stream() + .filter(header -> header.contains("Max-Age=0")) + .map(header -> header.substring(0, header.indexOf('='))) + .collect(Collectors.toSet()); + } + + private JsonNode parse(String json) { + return jsonMapper.readTree(json); + } +}