From c20803039ed668d3fdd2f6434d56cb02250b440f Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Thu, 30 Jul 2026 17:37:41 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(S15P11A705-65):=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=20=ED=83=88=ED=87=B4=EB=A5=BC=20=EA=B5=AC=ED=98=84=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /v1/me. 소프트 삭제·마스킹·연쇄 삭제·AI 파생 무효화·세션 폐기를 한 트랜잭션에 담는다(08 §3.6, 06 §6.9). 이 공백이 완료된 작업 둘을 미충족으로 붙잡고 있었다. S15P11A705-124가 요구한 AI 무효화 네 지점 중 탈퇴만 비어 있었고(#80이 붙일 서비스가 없어 제외했다), S15P11A705-147이 모아 둔 MemberRepository.isActive는 member.deleted_at을 세팅하는 주체가 없어 한 번도 발동하지 않았다. RED — 13건. 그중 하나가 예상과 다른 사유였다: CsrfFilter가 인가보다 먼저 돌아 CSRF 토큰 없는 DELETE는 인증 여부와 무관하게 403이다. 403은 CSRF 전용이라는 계약(authentication.md)에 맞춰 "미인증 → 401"과 "CSRF 누락 → 403"으로 갈랐다. GREEN - SocialAccount.withdraw() — withdrawn: 치환과 deleted_at을 한 UPDATE에. repository.delete()를 쓰면 removed 상태로 보여 마스킹이 flush되지 않는다 - 리포지토리 finder 5개 — 회원 단위 조회와 Follow 양방향 조회 - MemberWithdrawalService — §6.9 순서를 한 @Transactional에 - MeController — 204 + 쿠키 3종 만료 - JwtAuthenticationFilter — 탈퇴 회원 판정으로 Access 창을 닫음 설계 판단 넷 - 행 잠금을 쓰지 않는다. cascadeDelete가 잠그는 이유는 "활성 Context 1개 이상" 불변식과 record_count 산술의 경합인데, 탈퇴는 전부 지우므로 세어 분기할 일이 없고 Collection은 소유자 자기 Record만 담아 타 회원과 경합하지 않는다. #34 코멘트의 "invalidate를 Collection 잠금보다 앞에" 조언은 잠금 전제가 사라져 순서만 지켰다 - record_count를 갱신하지 않는다. Collection 자체가 함께 죽는다 - Refresh 폐기를 트랜잭션 마지막에 둔다. Redis는 DB 트랜잭션에 참여할 수 없어, 마지막에 두면 Redis 실패가 DB 롤백을 유발해 "로그아웃됐는데 탈퇴는 안 된" 상태가 남지 않는다. 순서를 뒤집으면 그 상태가 생긴다 - 연쇄를 도메인별로 흩뜨리지 않는다. 순서가 계약이라 한 곳에서 읽혀야 한다. RecordDeletionService에 이미 타 도메인 리포지토리 주입 선례가 있다 드러난 것 - Access 토큰은 폐기 목록이 없어 쿠키 만료·Refresh 폐기로 닫히지 않는다. 다른 기기에 남은 쿠키로 최대 30분간 쓰기까지 된다. 필터에서 isActive를 보게 했고 대가는 인증 요청마다 PK 조회 한 번이다 - loginAs로는 그 필터를 검증할 수 없다. SecurityContext에 직접 주입해 필터가 통째로 건너뛰어진다 — 처음 실패의 원인이 구현이 아니라 테스트였다. 그 한 건만 실제 토큰을 쿠키에 실었고, 탈퇴 전 200 → 후 401로 원인을 탈퇴로 특정한다. 쿠키 이름을 틀리게 바꾸면 앞쪽만 실패하고 뒤쪽은 통과한다 — 앞쪽 단언이 vacuous pass를 막는다 뮤테이션 3종 — 각 방어선이 독립이다. 되돌릴 때 실패하는 테스트가 1건씩이고 나머지 12건은 통과한다(필터 isActive → Access 창, invalidate → AI 파생, 이메일 치환 → 마스킹). 세 번째는 DB NOT NULL이 잡지 못한다 — 원본이 그대로 남아 제약 위반이 아니다. REFACTOR — SocialAccount 클래스 javadoc의 "탈퇴 티켓에서 추가" 미래형을 withdraw() 참조로, MemberRepository.isActive javadoc에 인증 경로도 이 판정에 기댄다는 것을 추가. clean check 407개 통과, 실패 0 Refs #34 Co-Authored-By: Claude Opus 5 (1M context) --- .../CollectionRecordRepository.java | 7 + .../repository/CollectionRepository.java | 6 + .../follow/repository/FollowRepository.java | 12 + .../member/controller/MeController.java | 49 ++ .../domain/member/entity/SocialAccount.java | 27 +- .../member/repository/MemberRepository.java | 4 +- .../repository/SocialAccountRepository.java | 4 + .../service/MemberWithdrawalService.java | 131 +++++ .../record/repository/RecordRepository.java | 3 + .../global/security/SecurityConfig.java | 7 +- .../JwtAuthenticationFilter.java | 12 +- .../member/MemberWithdrawalApiTests.java | 454 ++++++++++++++++++ 12 files changed, 708 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/pinlog/pinlogback/domain/member/controller/MeController.java create mode 100644 src/main/java/com/pinlog/pinlogback/domain/member/service/MemberWithdrawalService.java create mode 100644 src/test/java/com/pinlog/pinlogback/domain/member/MemberWithdrawalApiTests.java 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..2f49deb6 --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/member/service/MemberWithdrawalService.java @@ -0,0 +1,131 @@ +package com.pinlog.pinlogback.domain.member.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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; + +/** + * 회원 탈퇴(API 명세 3.6, 데이터모델 6.9). + * + *

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

되돌릴 수 없다. 모든 삭제는 소프트 삭제이지만 {@code social_account}의 개인정보는 마스킹으로 + * 파기되므로 복구 경로가 없다(06 §2.2). + */ +@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 트랜잭션에 참여할 수 없다. 마지막에 두면 + * Redis 실패가 DB 롤백을 유발해 "로그아웃됐는데 탈퇴는 안 된" 상태가 남지 않는다. 순서를 + * 뒤집으면 그 상태가 생긴다. + * + * @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); + + refreshTokenStore.revokeAll(memberId); + } + + /** + * 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/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..3f7a0638 --- /dev/null +++ b/src/test/java/com/pinlog/pinlogback/domain/member/MemberWithdrawalApiTests.java @@ -0,0 +1,454 @@ +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.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.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.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +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; + + @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()); + + Map row = socialAccountRow(accountId); + assertThat(row.get("provider_user_id")).isNotEqualTo("google-withdraw-2"); + assertThat(row.get("email")).isNotEqualTo("victim@example.com"); + // 마스킹은 치환이며 NULL이 아니다(06 §2.2). 두 컬럼 모두 NOT NULL이다. + assertThat(row.get("provider_user_id")).isNotNull(); + assertThat(row.get("email")).isNotNull(); + } + + @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")).isNotEqualTo("google-withdraw-3"); + } + + @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(); + } + + @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); + } +} From 9bc801965a2e43a0648dade6fad781aa46381d46 Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Thu, 30 Jul 2026 17:42:04 +0900 Subject: [PATCH 2/4] =?UTF-8?q?docs(S15P11A705-65):=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=20=ED=83=88=ED=87=B4=20=EA=B2=B0=EC=A0=95=EA=B3=BC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BD-41 — 탈퇴 회원의 남은 Access 토큰을 인증 필터의 PK 조회로 막은 결정. 창 안에서 새는 것이 읽기가 아니라 쓰기라는 것이 결정적이었다. 탈퇴 직후 30분간 Record·Collection 생성이 되고 그 행들은 연쇄 삭제가 지나간 뒤에 만들어져 어떤 정리 경로에도 걸리지 않는다. 요청당 PK 조회를 감수하는 편이 고아 데이터를 감수하는 것보다 싸다고 봤다. Redis 탈퇴 마커를 기각한 이유는 성능이 아니라 장애 전파다. 필터가 Redis를 읽으면 순단이 곧 전체 인증 실패(fail-closed)이거나 순단 중 탈퇴자 통과(fail-open)가 된다. readiness에 redis를 넣지 않기로 한 BD-28과 같은 방향이다. Refresh 폐기를 트랜잭션 마지막에 둔 이유도 함께 적었다 — 실패가 "아무 일도 없었음"으로 수렴하는 순서이고, 뒤집으면 "로그아웃됐는데 탈퇴는 안 된" 상태가 남는다. BI-29 — 구현 보고. 설계 판단 넷(잠금 없음·record_count 미갱신·폐기 순서·연쇄를 한 서비스에), 드러난 것 셋(CSRF가 인가보다 먼저 돎, loginAs로는 필터를 검증할 수 없음, 기존 테스트 무영향), 뮤테이션 3종 결과. 이슈 본문에서 따르지 않은 것도 표로 남겼다. #34이 2026-07-27 작성이라 email = null과 "유일값이 필요하다"가 지금 기준으로 틀렸고, "범위 밖"이라던 연쇄 삭제·AI 무효화는 이번 범위에 들어왔다. 번호는 pull 후 확정했다. BD-40이 #104(재스캔 Scheduler)에 선점돼 BD-41이다. Refs #34 Co-Authored-By: Claude Opus 5 (1M context) --- docs/backend/WORKLOG.md | 1 + ...n-member-check-in-authentication-filter.md | 59 +++++++++++++ docs/backend/decisions/README.md | 1 + .../BI-29-2026-07-30-member-withdrawal.md | 85 +++++++++++++++++++ docs/backend/implements/README.md | 1 + 5 files changed, 147 insertions(+) create mode 100644 docs/backend/decisions/BD-41-withdrawn-member-check-in-authentication-filter.md create mode 100644 docs/backend/implements/BI-29-2026-07-30-member-withdrawal.md 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..6a63fc21 --- /dev/null +++ b/docs/backend/decisions/BD-41-withdrawn-member-check-in-authentication-filter.md @@ -0,0 +1,59 @@ +# 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 트랜잭션에 참여할 수 없어 어느 쪽이 먼저든 부분 실패가 가능하다. 순서에 따라 남는 상태가 다르다. + +| 순서 | Redis 실패 시 | DB 실패 시 | +|---|---|---| +| 폐기를 마지막에 | 예외가 DB를 롤백 → **아무 일도 없었던 상태** | 폐기 전에 롤백 → 아무 일도 없었던 상태 | +| 폐기를 먼저 | — | **로그아웃됐는데 탈퇴는 안 된 상태** | + +마지막에 두면 실패가 "아무 일도 없었음"으로 수렴한다. 사용자는 탈퇴가 실패했다는 오류를 받고 다시 시도하면 된다. + +## 결과 + +- **감수하는 것** + - 인증된 요청마다 `SELECT ... WHERE id = ? AND deleted_at IS NULL` 한 번. PK 조회이고 트랜잭션 밖이라 커넥션을 짧게 쓰지만, 공짜는 아니다. + - `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..b3dc53ca --- /dev/null +++ b/docs/backend/implements/BI-29-2026-07-30-member-withdrawal.md @@ -0,0 +1,85 @@ +# 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회.** 근거와 재검토 트리거는 BD-41에 있다. +- **탈퇴는 되돌릴 수 없다.** 삭제는 모두 소프트 삭제지만 `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) | From 50436f8151cd00f31cdabea9bccb0f6c27eed9be Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Fri, 31 Jul 2026 10:01:33 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(S15P11A705-65):=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=82=B9=20=EB=8B=A8=EC=96=B8=EC=9D=84=20=EA=B0=95=ED=99=94?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20Refresh=20=ED=8F=90=EA=B8=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=20=EC=9D=B4=ED=9B=84=EB=A1=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #120 리뷰 반영(@minyongP). 두 지적 모두 내 실수였다. 1. 마스킹 단언이 테스트 이름을 증명하지 못했다 isNotEqualTo(원본)만으로는 부족하다. 치환이 아니라 접두(MASK + email)로 구현돼도 값이 원본과 달라 통과하면서 개인정보는 그대로 남는다. 복구 경로가 없는 파기이므로 치환값 자체를 고정하고, 형식과 별개로 "원본 조각이 행 어디에도 없다"를 함께 단언했다. 뮤테이션으로 확인했다. this.email = MASK_PREFIX + this.email로 바꾸면 고치기 전에는 통과했고 지금은 실패한다. 2. "그 상태가 남지 않는다"는 보증이 성립하지 않았다 Redis 폐기를 트랜잭션 마지막에 두는 것으로는 "폐기는 됐는데 탈퇴는 롤백된" 상태를 없앨 수 없다 — 폐기가 반환한 뒤 커밋이 실패하거나, 폐기가 키를 일부 지우고 던지면 그대로 남는다. 순서는 창의 크기만 바꾼다. 커밋 이후로 옮기고 실패를 삼켰다. 잔여 위험이 무해한 방향이기 때문이다 — 폐기가 실패해 Refresh가 살아남아도 그것으로 받는 Access는 BD-41이 넣은 탈퇴 판정에 막힌다. 두 결정이 서로를 보완한다. 삼키는 이유는 따로다. 삼키지 않으면 이미 커밋된 탈퇴가 500으로 응답하고 컨트롤러가 도달하지 못해 쿠키도 지워지지 않는다 — 사용자는 실패로 보는데 계정은 사라진 상태가 된다. BD-41에 그 잘못된 보증을 근거로 적어 둔 절이 있어 정정 표시와 함께 고쳤다. 보존 구역 규약대로 틀렸던 판단을 지우지 않고 무엇이 틀렸는지 남겼다. clean check 408개 통과, 실패 0 Refs #34 Co-Authored-By: Claude Opus 5 (1M context) --- ...n-member-check-in-authentication-filter.md | 16 +++++---- .../service/MemberWithdrawalService.java | 35 ++++++++++++++++--- .../member/MemberWithdrawalApiTests.java | 15 ++++---- 3 files changed, 50 insertions(+), 16 deletions(-) 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 index 6a63fc21..9035601f 100644 --- 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 @@ -36,16 +36,20 @@ Access는 자기완결적 JWT이고 폐기 목록이 없다. `JwtAuthenticationF (c)를 기각한 결정적 이유는 성능이 아니라 **장애 전파**다. 지금 Redis가 죽으면 재발급만 실패하고 진행 중인 세션은 Access 만료까지 살아 있다. 필터가 Redis를 읽으면 Redis 순단이 곧 전체 인증 실패이거나(fail-closed), 아니면 순단 중에 탈퇴자가 통과한다(fail-open). readiness 그룹에 `redis`를 넣지 않기로 한 [BD-28](BD-28-readiness-includes-db.md)과 같은 방향이다 — 외부 의존성 하나가 서비스 전체를 끌어내리지 않게 한다. -### 함께 정한 것 — Refresh 폐기를 트랜잭션 마지막에 둔다 +### 함께 정한 것 — Refresh 폐기는 커밋 이후에, 실패를 삼켜서 -Redis는 DB 트랜잭션에 참여할 수 없어 어느 쪽이 먼저든 부분 실패가 가능하다. 순서에 따라 남는 상태가 다르다. +Redis는 DB 트랜잭션에 참여할 수 없다. **트랜잭션 안에서는 "폐기는 됐는데 탈퇴는 롤백된" 상태를 없앨 수 없다** — 폐기가 반환한 뒤 커밋이 실패하거나, 폐기가 키를 일부 지우고 던지면 그대로 남는다. 마지막에 두는 것으로는 창이 좁아질 뿐이다. -| 순서 | Redis 실패 시 | DB 실패 시 | +> **정정(2026-07-31).** 이 ADR의 초기 판단은 *"마지막에 두면 그 상태가 남지 않는다"*였다. 틀렸다. `#120` 리뷰에서 지적받아 고쳤다. 순서는 창의 크기만 바꾼다. + +| 배치 | 폐기 실패 | 커밋 실패 | |---|---|---| -| 폐기를 마지막에 | 예외가 DB를 롤백 → **아무 일도 없었던 상태** | 폐기 전에 롤백 → 아무 일도 없었던 상태 | -| 폐기를 먼저 | — | **로그아웃됐는데 탈퇴는 안 된 상태** | +| 트랜잭션 안, 마지막 | DB 롤백 → 아무 일도 없었음 | **폐기만 반영된 상태가 남는다** | +| **커밋 이후, 실패 삼킴** | 탈퇴는 확정, Refresh가 살아남음 | 폐기 전이라 아무 일도 없었음 | + +**커밋 이후를 택했다.** 잔여 위험이 무해한 방향이기 때문이다 — 폐기가 실패해 Refresh가 살아남아도 그것으로 받는 Access는 이 ADR이 넣은 탈퇴 판정에 막힌다. 즉 **이 결정과 그 결정이 서로를 보완한다.** -마지막에 두면 실패가 "아무 일도 없었음"으로 수렴한다. 사용자는 탈퇴가 실패했다는 오류를 받고 다시 시도하면 된다. +실패를 삼키는 이유는 따로다. 삼키지 않으면 이미 커밋된 탈퇴가 500으로 응답하고 컨트롤러가 도달하지 못해 **쿠키도 지워지지 않는다** — 사용자는 "실패했다"고 보는데 계정은 사라진 상태가 된다. ## 결과 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 index 2f49deb6..6da30e21 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/member/service/MemberWithdrawalService.java +++ b/src/main/java/com/pinlog/pinlogback/domain/member/service/MemberWithdrawalService.java @@ -4,6 +4,8 @@ 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; @@ -23,6 +25,8 @@ 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). * @@ -33,6 +37,7 @@ *

되돌릴 수 없다. 모든 삭제는 소프트 삭제이지만 {@code social_account}의 개인정보는 마스킹으로 * 파기되므로 복구 경로가 없다(06 §2.2). */ +@Slf4j @Service public class MemberWithdrawalService { @@ -79,9 +84,15 @@ public MemberWithdrawalService( *

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

Refresh 폐기를 마지막에 둔다. Redis는 DB 트랜잭션에 참여할 수 없다. 마지막에 두면 - * Redis 실패가 DB 롤백을 유발해 "로그아웃됐는데 탈퇴는 안 된" 상태가 남지 않는다. 순서를 - * 뒤집으면 그 상태가 생긴다. + *

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

그래서 커밋 이후로 옮기고 실패를 삼킨다. 이 방향의 잔여 위험은 무해하다 — 폐기가 + * 실패해 Refresh가 살아남아도, 그것으로 받는 Access는 {@code JwtAuthenticationFilter}의 탈퇴 + * 판정에 막힌다(BD-41). 반대로 삼키지 않으면 이미 커밋된 탈퇴가 500으로 응답해 쿠키도 지워지지 + * 않는다. * * @throws UnauthorizedException 이미 탈퇴한 회원일 때. 인증 계층이 먼저 막지만 그 보증이 * 필터 설정에 있고 이 메서드의 타입에는 없어 한 번 더 확인한다 @@ -108,7 +119,23 @@ public void withdraw(Long memberId) { // 양방향이다 — 내가 만든 팔로우와 나를 대상으로 하는 팔로우 모두 지운다(6.9). followRepository.findAllInvolving(memberId).forEach(Follow::softDelete); - refreshTokenStore.revokeAll(memberId); + 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); + } + } + }); } /** diff --git a/src/test/java/com/pinlog/pinlogback/domain/member/MemberWithdrawalApiTests.java b/src/test/java/com/pinlog/pinlogback/domain/member/MemberWithdrawalApiTests.java index 3f7a0638..9f27a54d 100644 --- a/src/test/java/com/pinlog/pinlogback/domain/member/MemberWithdrawalApiTests.java +++ b/src/test/java/com/pinlog/pinlogback/domain/member/MemberWithdrawalApiTests.java @@ -108,12 +108,15 @@ void withdrawMasksPersonalData() throws Exception { mockMvc.perform(delete(PATH).with(loginAs(memberId))) .andExpect(status().isNoContent()); + // 치환값을 그대로 고정한다. "원본과 다르다"로는 부족하다 — 접두만 붙이는 구현 + // (MASK + email)도 그 단언을 통과하면서 개인정보를 그대로 남긴다. 복구 경로가 없는 + // 파기이므로 형식이 바뀌면 여기서 걸려야 한다. Map row = socialAccountRow(accountId); - assertThat(row.get("provider_user_id")).isNotEqualTo("google-withdraw-2"); - assertThat(row.get("email")).isNotEqualTo("victim@example.com"); - // 마스킹은 치환이며 NULL이 아니다(06 §2.2). 두 컬럼 모두 NOT NULL이다. - assertThat(row.get("provider_user_id")).isNotNull(); - assertThat(row.get("email")).isNotNull(); + 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 @@ -129,7 +132,7 @@ void maskingAndSoftDeleteLandTogether() throws Exception { // 마스킹이 조용히 유실되는 경로가 있어(#34) 같은 행에서 둘을 함께 본다. Map row = socialAccountRow(accountId); assertThat(row.get("deleted_at")).isNotNull(); - assertThat(row.get("provider_user_id")).isNotEqualTo("google-withdraw-3"); + assertThat(row.get("provider_user_id")).isEqualTo("withdrawn:" + accountId); } @Test From 49f26fcd68c4e9c8bb3015c56c99cc4959761f33 Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Fri, 31 Jul 2026 10:41:32 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(S15P11A705-65):=20Redis=20=ED=83=80?= =?UTF-8?q?=EC=9E=84=EC=95=84=EC=9B=83=EC=9D=84=20=EC=A0=95=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=ED=8F=90=EA=B8=B0=20=EC=8B=A4=ED=8C=A8=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A5=BC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #120 리뷰 반영(@minyongP). 이 PR에서 처리하기로 한 두 건이다. spring.data.redis.timeout이 설정돼 있지 않았다. Lettuce 기본값 60초라 Redis가 멎으면 요청 스레드가 1분간 붙잡힌다. 이 PR이 만든 문제는 아니지만(로그인·재발급도 같은 값에 걸려 있다) 폐기를 afterCommit으로 옮기면서 처음으로 사용자 대기에 직결됐다 — 탈퇴는 커밋됐는데 204가 그만큼 늦는다. Redis는 캐시·세션 전용이라 오래 기다려 얻을 것이 없어 timeout 2s, connect-timeout 1s로 끊는다. catch (RuntimeException) 분기에 테스트가 없었다. 이번에 추가된 유일한 새 분기인데 다른 방어선은 전부 뮤테이션으로 확인한 것과 대비된다는 지적이 맞다. RefreshTokenStore가 RedisConnectionFailureException을 던지게 하고, 그래도 204와 쿠키 3종 만료가 나오는지 고정했다. catch를 걷어내면 그 1건만 실패한다. 기록에 감수하는 것 넷을 추가했다. 리뷰가 짚은 것들이고 지금 코드 어디에도 적혀 있지 않았다. - 같은 회원의 동시 요청이 만드는 잔여 행. BD-41이 닫은 Access 창과 같은 종류지만 창이 트랜잭션 길이(수 ms)이고 진행 중인 쓰기가 있어야 한다. 닫으려면 인증 판정을 쓰기 트랜잭션과 묶어야 해서 구조가 커진다 - 연쇄 삭제에 상한이 없다. batch_size도 없어 행마다 UPDATE 한 건씩 낸다 - 연쇄 완결성이 requireAllOwnedActiveRecords 하나에 의존하고 그 불변식을 고정하는 테스트가 없다 - rotate가 isActive를 보지 않아 폐기 실패가 자가 치유되지 않는다 BD-41도 두 곳 보탰다. "PK 조회 한 번"이 실제로는 커넥션 체크아웃 1회이고 (open-in-view: false라 컨트롤러 트랜잭션이 두 번째를 잡는다), 이 변경으로 필터가 인프라 사유로 던질 수 있게 되어 DataAccessException이 공통 envelope을 우회한다는 것이다. clean check 409개 통과, 실패 0 Refs #34 Co-Authored-By: Claude Opus 5 (1M context) --- ...n-member-check-in-authentication-filter.md | 2 ++ .../BI-29-2026-07-30-member-withdrawal.md | 8 ++++- src/main/resources/application.yml | 8 +++++ .../member/MemberWithdrawalApiTests.java | 33 +++++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) 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 index 9035601f..9b4c9986 100644 --- 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 @@ -55,6 +55,8 @@ Redis는 DB 트랜잭션에 참여할 수 없다. **트랜잭션 안에서는 " - **감수하는 것** - 인증된 요청마다 `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`를 참조하는 것과 같은 방향이라 새 위반은 아니지만, 의존이 하나 늘었다. - 캐시가 없다. 같은 회원이 초당 여러 요청을 보내면 그만큼 조회한다. - **재검토 트리거** 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 index b3dc53ca..7265b666 100644 --- a/docs/backend/implements/BI-29-2026-07-30-member-withdrawal.md +++ b/docs/backend/implements/BI-29-2026-07-30-member-withdrawal.md @@ -58,7 +58,13 @@ ## 감수하는 것 -- **인증 요청마다 PK 조회 1회.** 근거와 재검토 트리거는 BD-41에 있다. +- **인증 요청마다 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). 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 index 9f27a54d..1b762c77 100644 --- a/src/test/java/com/pinlog/pinlogback/domain/member/MemberWithdrawalApiTests.java +++ b/src/test/java/com/pinlog/pinlogback/domain/member/MemberWithdrawalApiTests.java @@ -3,6 +3,7 @@ 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; @@ -20,15 +21,18 @@ 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; @@ -86,6 +90,10 @@ class MemberWithdrawalApiTests extends IntegrationContainerSupport { @Autowired private JwtTokenProvider tokenProvider; + /** 폐기 실패 경로만 스텁한다. 다른 테스트에서는 스텁하지 않아 실제 빈에 위임한다. */ + @MockitoSpyBean + private RefreshTokenStore refreshTokenStore; + @Test @DisplayName("204를 반환하고 member와 social_account를 소프트 삭제한다") void withdrawSoftDeletesMemberAndSocialAccount() throws Exception { @@ -234,6 +242,31 @@ void withdrawRevokesEveryRefreshTokenOfTheMember() throws Exception { 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 {