Skip to content

탈퇴 유저 tombstone 누수 차단 - 활성 유저 전용 조회 분리#773

Open
sevineleven wants to merge 2 commits into
devfrom
refactor/691-user-find-active-by-id
Open

탈퇴 유저 tombstone 누수 차단 - 활성 유저 전용 조회 분리#773
sevineleven wants to merge 2 commits into
devfrom
refactor/691-user-find-active-by-id

Conversation

@sevineleven

@sevineleven sevineleven commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Situation

  • UserService.findById 는 탈퇴(soft-delete)한 유저도 그대로 돌려준다. 그래서 "이 계정이 아직 살아있나" 를 확인하는 책임이 호출부 12곳에 흩어졌고, 각자 다르게 처리하고 있었다: 예외를 던지는 곳, 활성 여부만 보고 건너뛰는 곳, 아예 확인하지 않는 곳.
  • 확인이 아예 없던 두 곳이 실제 누수였다. 마이페이지 조회(GET /me)는 탈퇴한 계정 정보를 200 으로 내려줬고, 위시리스트 회원 가드는 회원 종류만 보고 탈퇴 여부를 안 봐서 죽은 계정이 위시리스트를 그대로 쓸 수 있었다.
  • 무엇보다 새로 추가되는 코드가 이 확인을 빠뜨려도 컴파일도 테스트도 통과한다. 조용히 되살아나는 종류의 버그다.

Task

  • 활성 계정을 기대하는 경로와, 탈퇴 상태 자체를 읽어야 하는 경로를 구조적으로 갈라 새 호출부가 실수로 후자를 고르지 못하게 만든다.
  • 단순히 전역 필터로 탈퇴 계정을 걸러낼 수는 없었다. 탈퇴 상태를 의도적으로 읽어야 하는 경로가 실재하기 때문이다: 탈퇴한 소셜 계정의 재로그인 판정(죽은 계정을 되살리지 않기), 재탈퇴 멱등 처리, 토큰 갱신 거부.

Action

조회 메서드를 둘로 나눔

메서드 탈퇴 계정을 만나면 쓰는 곳
findActiveById (신설) 409 USER-003 으로 끊음 탈퇴 계정 접근이 계약 위반인 경로
findById (기존) 그대로 반환 탈퇴 상태 자체를 판단해야 하는 경로

호출부마다 흩어져 있던 탈퇴 확인을 findActiveById 한 자리로 모았다. 두 메서드 모두 왜 그 쪽을 골라야 하는지 주석으로 박아, 다음 호출부가 이름만 보고도 고를 수 있게 했다.

활성 전용으로 통일한 경로

경로 이전
getMyProfile (GET /me) 확인 없음, 탈퇴 계정이 200 통과
WishlistService.requireMember 확인 없음, 탈퇴 회원이 위시리스트 사용 가능
updateProfile 호출부가 직접 확인 후 예외
updateProfileImageUrl
promoteToMember
AuthService.issueTokenForExistingUser
ProfileUpdateService.updateMe

기존 동작을 유지한 경로

탈퇴 상태를 읽는 게 목적이라 그대로 뒀고, 왜 활성 조회를 안 쓰는지 각 자리에 주석을 남겼다.

경로 그대로 둔 이유
AuthService.refresh 거부 응답이 409 가 아니라 401 이고, 거부 사유 로그를 따로 남긴다
SocialAccountService.loginExisting 탈퇴 계정이면 없는 것으로 보고 신규 가입 경로로 보낸다
SocialAccountWriter.linkGuestAndPromote 탈퇴한 게스트는 연결 대상이 아니라 null 반환
WithdrawalService.withdraw / softDelete 재탈퇴는 이미 목표 상태라 409 가 아니라 멱등 성공이어야 한다

작업 중 발견한 누락

위시리스트 가격 이력 조회만 회원 가드를 안 타고 있었다. 나머지 10개 엔드포인트는 전부 첫 줄에서 가드를 호출하는데 이것만 빠져 있었고, 왜 없는지 주석도 없었다. 문서에는 "회원 필요" 라고 적혀 있어 문서와 코드가 어긋난 상태였다.

  • 소유권 검증은 남의 위시만 막고 탈퇴 여부는 보지 않아서, 이 엔드포인트만 이번 수정을 그대로 빠져나갔다.
  • 문서를 코드에 맞춰 약화시키는 대신 가드를 추가했다. 문서가 원래 의도한 계약이 그쪽이었고, 그래야 이 PR 의 목적(활성 확인 누락 제거)이 위시리스트 전체에 균일하게 적용된다.
  • 부정확했던 403 설명과 실제로는 발생할 수 없던 403 예시도 실제 응답에 맞게 정정했다.

검토했으나 채택하지 않은 안

왜 안 썼나
전역 @SQLRestriction 으로 탈퇴 계정 일괄 제외 탈퇴 cascade 와 재로그인 판정이 같은 데이터를 필요로 해, 전역으로 걸면 그 경로들이 함께 깨진다
탈퇴 계정을 401 로 끊기 클라이언트 처리는 401 이 더 단순하지만, 프로필 수정 등 기존 경로가 이미 409 USER-003 을 쓰고 있어 도메인 안에서 응답이 갈린다

문서

새로 도달 가능해진 409 를 마이페이지 조회와 위시리스트 11개 엔드포인트에 반영했다. 이미 다른 의미의 409(항목 상태 충돌)를 쓰던 두 엔드포인트는 상태 코드당 블록이 하나라, 기존 설명에 사유를 덧붙이고 예시만 추가했다. 예시는 문구를 손으로 박지 않고 예외 정의에서 끌어와 실제 응답과 어긋나지 않게 했다.

리뷰에서 나온 보강 - 업로드 객체 회수

프로필 이미지 업로드는 트랜잭션 밖에서 먼저 끝나므로, 뒤이은 영속화가 떨어지면 아무도 안 가리키는 객체가 S3 에 남는다. 닉네임 중복(409)이 가장 흔한 트리거이고, 활성 확인과 영속화 사이에 탈퇴가 커밋된 경우엔 탈퇴 정리의 프로필 파기가 이미 지나간 뒤라 사진이 계속 남는다.

  • 이미지 등록 경로의 원본 회수와 같은 패턴으로 보상 삭제를 걸었다.
  • 회수 자체는 best-effort 다. 실패해도 삼키고 로그만 남겨 원래 예외(409 등)를 가리지 않는다. 삭제는 객체가 없어도 무동작이라 멱등하다.
  • 이 PR 이 만든 문제는 아니지만 같은 메서드를 이미 건드리고 있었고 레포에 확립된 패턴이 있어 함께 정리했다.

Result

  • 이 409 는 평상시에 뜨지 않는다. 정상 탈퇴 흐름에서는 토큰 차단 목록이 요청을 401 로 먼저 끊어 서비스까지 오지 않는다. 탈퇴 시 토큰 무효화가 부분 실패했거나([P8] 탈퇴 후 토큰 무효화 부분 실패 시 보안 창 제거 #689) 차단 목록 키가 유실된 창에서만 도달하는 2차 방어선이다.
  • 동작 변경: 그 창에서 마이페이지 조회와 위시리스트는 이제 200 대신 409 USER-003 을 내려준다. 클라이언트 대응은 401 과 같다(토큰 폐기 후 로그인 화면).
  • 새로 추가한 통합 테스트 4건은 각각 해당 수정을 임시로 되돌렸을 때 그 테스트만 실패하는 것까지 확인했다. 탈퇴 상태를 직접 만들고 살아있는 토큰으로 호출하는 방식이라, 차단 목록이 놓친 창을 그대로 재현한다.
  • 후속으로 분리한 것: 활성 확인과 쓰기 사이의 경합(활성 유저 확인과 쓰기 사이의 check-then-use 경합 차단 #776)은 이 PR 이 만든 게 아니고(변경 전에도 같은 check-then-use 였다) 처방이 엔티티 전반의 잠금 설계라 별도로 뺐다. 위시리스트 예외의 자체 에러 코드 이관(에러 응답을 message 기반에서 code 기반으로 전환 #728)도 범위 밖이라 예시가 공통 코드로 폴백한다.

연관 이슈

Summary by CodeRabbit

  • 버그 수정
    • 탈퇴한 계정이 유효한 토큰으로 내 정보 및 위시리스트 관련 기능에 접근하면 일관되게 409(CONFLICT) 이 반환됩니다.
    • 탈퇴한 계정의 프로필 수정/회원 승격/가격 히스토리 조회가 차단됩니다.
    • 프로필 이미지 업로드 후 갱신이 실패하면 방금 업로드한 이미지가 회수됩니다.
  • 문서
    • 내 정보 및 위시리스트 API의 409 응답과 예시가 추가/확장되었습니다.
  • 테스트
    • 탈퇴 계정 409 응답, 코드/메시지, 이미지 회수 및 가격 히스토리/위시리스트 접근 제한 시나리오가 보강되었습니다.

findById 가 soft-deleted(tombstone) 유저도 반환해, 활성 검증이 호출부마다
제각각(throw / isActive / 미검증) 흩어져 있었다. GET /me 와 위시리스트 회원
가드는 검증이 아예 없어 탈퇴 유저가 그대로 통과했다.

- UserService.findActiveById 추가 - tombstone 이면 deletedUser(409)
- 활성 전용 7곳을 findActiveById 로 통일 (getMyProfile · updateProfile ·
  updateProfileImageUrl · promoteToMember · AuthService.issueTokenForExistingUser ·
  ProfileUpdateService.updateMe · WishlistService.requireMember)
- 의도적 tombstone 읽기 5곳은 findById 유지 + 이유 주석 (AuthService.refresh 는
  401 · SocialAccountService/Writer 는 재로그인 판정 · WithdrawalService ·
  softDelete 는 멱등 탈퇴)
- getPriceHistory 에 누락돼 있던 requireMember 추가 - 문서엔 "MEMBER 필요"로
  적혀 있었으나 실제 가드가 없어 위시 엔드포인트 중 유일하게 활성 검증을
  빠져나갔다. 부정확했던 403 description·example 도 실제 응답에 맞춤
- 새로 도달 가능해진 409 를 GET /me · 위시리스트 11개 엔드포인트 문서에 반영

동작 변경: GET /me 와 위시리스트가 tombstone 에 200 대신 409(USER-003).
정상 운영에선 denylist 가 401 로 먼저 끊어 도달하지 않고, 토큰 무효화가
부분 실패한 창(#689)에서만 뜨는 2차 방어선이다.
@sevineleven sevineleven added the refactor 구조 개선, 외부 동작 불변 label Jul 20, 2026
@sevineleven sevineleven self-assigned this Jul 20, 2026
@github-actions

Copy link
Copy Markdown

Discord 스레드 연동용 메타데이터입니다. discord-pr-bot 워크플로가 자동 생성하며, 수정·삭제하면 PR 과 Discord 알림 연동이 끊깁니다.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

탈퇴 사용자와 활성 사용자 조회 계약을 분리하고, 인증·사용자·위시리스트 서비스에서 활성 사용자 검증을 적용했습니다. 관련 API의 409 응답 문서와 예시를 추가했으며, 탈퇴 사용자 접근 차단과 프로필 이미지 보상 삭제를 통합 테스트로 검증했습니다.

Changes

탈퇴 사용자 차단

Layer / File(s) Summary
활성 사용자 조회 계약
src/main/kotlin/com/depromeet/piki/user/service/UserService.kt, src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt, src/main/kotlin/com/depromeet/piki/user/service/WithdrawalService.kt
findActiveById를 추가하고 프로필 조회·수정·멤버 승격 경로에서 탈퇴 사용자를 차단한다. 재탈퇴 처리는 tombstone 조회를 유지하며, 프로필 이미지 영속화 실패 시 업로드 객체를 회수한다.
인증 사용자 상태 검증
src/main/kotlin/com/depromeet/piki/auth/service/AuthService.kt
기존 사용자 토큰 발급에 활성 사용자 조회를 적용하고 관련 주석과 import를 정리한다.
위시리스트 활성 회원 검증
src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt
회원 검증과 가격 히스토리 조회 진입부에서 활성 사용자 및 회원 조건을 확인한다.
API 계약과 통합 검증
src/main/kotlin/com/depromeet/piki/user/controller/*, src/main/kotlin/com/depromeet/piki/wishlist/controller/*, src/test/kotlin/com/depromeet/piki/user/controller/*, src/test/kotlin/com/depromeet/piki/wishlist/controller/*
탈퇴 계정의 409 USER-003 응답 문서와 예시를 추가하고, 사용자·위시리스트·가격 히스토리 접근 및 이미지 회수 시나리오를 통합 테스트로 검증한다.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Assessment against linked issues

Objective Addressed Explanation
활성 사용자 전용 findActiveById를 분리하고 tombstone 접근이 부적절한 호출부에 적용 [#691]

Sequence Diagram(s)

sequenceDiagram
  participant AccessToken
  participant AuthService
  participant UserService
  participant WishlistService
  AccessToken->>AuthService: 사용자 식별
  AuthService->>UserService: findActiveById
  UserService-->>AuthService: 활성 사용자 또는 USER-003
  AuthService->>WishlistService: 위시리스트 요청 전달
  WishlistService->>UserService: findActiveById
  UserService-->>WishlistService: 활성 회원 또는 USER-003
Loading
🚥 Pre-merge checks | ✅ 1 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/691-user-find-active-by-id

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt (1)

32-39: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

최종 저장 실패 시 S3 고아 객체를 정리해야 합니다.

활성 확인 후 S3 업로드가 먼저 수행되고, 탈퇴가 그 사이에 커밋되면 마지막 updateProfileUSER-003으로 실패하지만 업로드된 프로필 이미지가 남습니다. 닉네임 충돌 등 후속 영속화 실패에도 같은 문제가 발생하므로, 객체 단위 보상 삭제나 정리 outbox를 추가해야 합니다. 프로필 이미지는 PII이므로 lifecycle 정리만 의존하면 안 됩니다.

As per path instructions, Kotlin Service 계층의 외부 호출과 상태 변경 타이밍을 엄격히 검토해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt`
around lines 32 - 39, Update ProfileUpdateService around imageStorage.upload and
userService.updateProfile so any persistence failure after a successful upload
immediately compensates by deleting the uploaded object using the storage
abstraction’s object-delete operation; preserve the original exception and avoid
cleanup failures masking it. Ensure cleanup covers deactivation races and other
updateProfile failures, while retaining the existing pre-upload validation.

Source: Path instructions

src/main/kotlin/com/depromeet/piki/user/service/UserService.kt (1)

297-324: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

활성 여부 체크는 쓰기와 같은 트랜잭션/락으로 묶어야 합니다.
UserService.findActiveById() 뒤의 save/saveAndFlush, WishlistService.requireMember() 뒤의 WishPersistenceService.persist*()는 모두 check-then-use라서, 중간에 softDelete()가 커밋되면 탈퇴한 사용자에 프로필 변경이 반영되거나 위시가 생성될 수 있습니다. BaseEntity@Version이 없으니, 사용자 상태를 쓰기 전에 SELECT ... FOR UPDATE로 잡거나 optimistic locking을 추가하고, 위시 생성도 persistence 경로 안에서 다시 active 상태를 재검증하세요. Spring Data JPA locking / Hibernate optimistic locking 문서 참고.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/kotlin/com/depromeet/piki/user/service/UserService.kt` around lines
297 - 324, 활성 사용자 확인과 쓰기를 동일 트랜잭션의 비관적 행 잠금 또는 낙관적 잠금으로 보호해 softDelete와의 경쟁을
차단하세요. src/main/kotlin/com/depromeet/piki/user/service/UserService.kt 297-324의
findActiveById 이후 saveAndFlush 경로와 334-343의 관련 사용자 쓰기 경로에 잠금/버전 검증을 적용하고,
src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt 42-47의
requireMember 확인은 WishPersistenceService.persist* 내부에서 active 상태를 다시 검증하도록
수정하세요.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt`:
- Around line 32-39: Update ProfileUpdateService around imageStorage.upload and
userService.updateProfile so any persistence failure after a successful upload
immediately compensates by deleting the uploaded object using the storage
abstraction’s object-delete operation; preserve the original exception and avoid
cleanup failures masking it. Ensure cleanup covers deactivation races and other
updateProfile failures, while retaining the existing pre-upload validation.

In `@src/main/kotlin/com/depromeet/piki/user/service/UserService.kt`:
- Around line 297-324: 활성 사용자 확인과 쓰기를 동일 트랜잭션의 비관적 행 잠금 또는 낙관적 잠금으로 보호해
softDelete와의 경쟁을 차단하세요.
src/main/kotlin/com/depromeet/piki/user/service/UserService.kt 297-324의
findActiveById 이후 saveAndFlush 경로와 334-343의 관련 사용자 쓰기 경로에 잠금/버전 검증을 적용하고,
src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt 42-47의
requireMember 확인은 WishPersistenceService.persist* 내부에서 active 상태를 다시 검증하도록
수정하세요.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: fe418bfa-e383-4052-a7a6-fce2d80abd0f

📥 Commits

Reviewing files that changed from the base of the PR and between d967106 and 2c5e45c.

📒 Files selected for processing (12)
  • src/main/kotlin/com/depromeet/piki/auth/service/AuthService.kt
  • src/main/kotlin/com/depromeet/piki/user/controller/UserApi.kt
  • src/main/kotlin/com/depromeet/piki/user/controller/UserApiExamples.kt
  • src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt
  • src/main/kotlin/com/depromeet/piki/user/service/UserService.kt
  • src/main/kotlin/com/depromeet/piki/user/service/WithdrawalService.kt
  • src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApi.kt
  • src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt
  • src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt
  • src/test/kotlin/com/depromeet/piki/user/controller/UserControllerIntegrationTest.kt
  • src/test/kotlin/com/depromeet/piki/wishlist/controller/WishPriceHistoryIntegrationTest.kt
  • src/test/kotlin/com/depromeet/piki/wishlist/controller/WishlistCrudIntegrationTest.kt

@github-actions
github-actions Bot requested a review from m-a-king July 20, 2026 01:49
업로드는 트랜잭션 밖에서 먼저 끝나므로, 뒤이은 updateProfile 이 떨어지면
아무도 안 가리키는 객체가 S3 에 남는다. 닉네임 중복(409)이 흔한 트리거이고,
활성 확인과 영속화 사이에 탈퇴가 커밋된 경우엔 탈퇴 cascade 의 prefix 파기가
이미 지나간 뒤라 프로필 사진(PII)이 계속 남는다.

registerFromImages 의 raw 회수와 같은 패턴으로 보상 삭제를 건다. 회수 자체는
best-effort 라 실패해도 삼키고 warn 만 남겨 원래 예외를 가리지 않는다.

CodeRabbit 리뷰 지적 반영.
@sevineleven

sevineleven commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

CodeRabbit 리뷰의 outside-diff 코멘트 2건 처리 결과입니다.

1. ProfileUpdateService 최종 저장 실패 시 S3 고아 객체 (Major)

Accepted. a990f44 에서 반영했습니다.

지적대로 업로드가 트랜잭션 밖에서 먼저 끝나므로, 뒤이은 updateProfile 이 떨어지면 아무도 안 가리키는 객체가 남습니다. 닉네임 중복(409)이 가장 흔한 트리거이고, 활성 확인과 영속화 사이에 탈퇴가 커밋된 경우엔 탈퇴 cascade 의 prefix 파기가 이미 지나간 뒤라 프로필 사진이 계속 남습니다.

registerFromImages 의 raw 회수와 같은 패턴으로 보상 삭제를 걸었습니다.

return runCatching { userService.updateProfile(userId, nickname, url) }
    .onFailure { deleteQuietly(key) }
    .getOrThrow()

회수 자체는 best-effort 입니다. runCatching { imageStorage.delete(key) } 로 감싸 실패해도 삼키고 warn 만 남겨, 원래 예외(409 등)를 가리지 않습니다. delete 는 객체가 없어도 no-op 이라 멱등합니다.

통합 테스트를 함께 추가했고(닉네임 중복 409 → 방금 올린 key 가 삭제 호출됨), 보상 삭제를 임시로 제거하면 그 테스트만 실패하는 것까지 확인했습니다.

2. 활성 확인과 쓰기의 check-then-use 경합 (Major)

별도 이슈로 분리했습니다: #776 (이 PR 에서는 반영하지 않음)

경합 자체는 실재합니다. 같은 행을 두 트랜잭션이 UPDATE 하므로 행 락으로 직렬화는 되지만, 탈퇴가 먼저 커밋되면 뒤 UPDATE 가 tombstone 위에 씁니다. 이 PR 에서 닫지 않는 이유는 둘입니다.

이 PR 이 만든 경합이 아니고, 오히려 좁혔습니다. 변경 전에도 findById + deletedAt 확인 + save 로 동일한 check-then-use 였습니다. 위시리스트는 변경 전에 활성 확인이 아예 없어서 탈퇴 유저가 경합 없이도 위시를 만들 수 있었고, 이번 변경이 그걸 좁은 경합 창으로 줄였습니다.

제안된 처방이 이 PR 의 범위를 넘습니다. BaseEntity@Version 을 추가하면 모든 엔티티에 version 컬럼 마이그레이션과 충돌 재시도 정책이 따라오고, 비관적 락은 락 순서에 따라 데드락이 새로 생길 수 있어 쓰기 경로 전수 점검이 필요합니다. 리팩토링 PR 에 얹기보다 설계 판단을 거쳐 들어가는 게 맞다고 봤습니다. (리뷰에서도 🏗️ Heavy lift 로 분류된 항목입니다.)

#776 에 세 가지 선택지(비관적 락 / 낙관적 락 / 영속화 경로 재검증)와 각각의 blast radius 를 정리해뒀습니다.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt (1)

32-45: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

탈퇴와 프로필 수정을 쓰기 시점에 막아야 합니다.
지금은 findActiveById()가 읽기 시점만 확인하고, updateProfile()saveAndFlush()로 같은 User를 다시 쓰기 때문에 그 사이 WithdrawalPersistenceService가 커밋되면 탈퇴 행을 마지막 커밋이 덮어쓸 수 있습니다. deleted_at 조건이 없고 @Version도 없어, 프로필 수정으로 탈퇴 계정이 되살아나는 race가 남습니다. @Version(JPA 낙관적 락)이나 UPDATE ... WHERE deleted_at IS NULL로 바꾸고, 0건이면 deletedUser()로 처리하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt`
around lines 32 - 45, Update the persistence path used by
UserService.updateProfile so profile writes cannot overwrite a concurrently
committed withdrawal: add optimistic locking with `@Version` or make the update
conditional on deleted_at being null, and treat a zero-row update (or
optimistic-lock failure) as deletedUser(). Keep findActiveById’s existing
validation and ensure the race results in a deletion error with no profile
update persisted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt`:
- Around line 39-45: 프로필 이미지 보상 삭제를 즉시 실패 처리에서 분리해 업로드와 DB 반영 결과를 안전하게 추적하세요.
ProfileUpdateService의 updateProfile 흐름에서 새 S3 객체를 pending asset/outbox로 기록하고,
업로드 응답 유실·timeout을 포함한 미확정 결과도 추적되도록 하며, 비동기 정리 작업은 DB에 해당 URL 참조가 없음을 확인한 객체만
deleteQuietly로 회수하도록 변경하세요.

---

Outside diff comments:
In `@src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt`:
- Around line 32-45: Update the persistence path used by
UserService.updateProfile so profile writes cannot overwrite a concurrently
committed withdrawal: add optimistic locking with `@Version` or make the update
conditional on deleted_at being null, and treat a zero-row update (or
optimistic-lock failure) as deletedUser(). Keep findActiveById’s existing
validation and ensure the race results in a deletion error with no profile
update persisted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: 467168fc-e6ea-4e79-9a8f-367df56e6739

📥 Commits

Reviewing files that changed from the base of the PR and between 2c5e45c and a990f44.

📒 Files selected for processing (2)
  • src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt
  • src/test/kotlin/com/depromeet/piki/user/controller/UserControllerIntegrationTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/test/kotlin/com/depromeet/piki/user/controller/UserControllerIntegrationTest.kt

Comment on lines +39 to +45
// 영속화가 떨어지면 방금 올린 객체가 아무도 안 가리키는 orphan 으로 남는다 — 닉네임 중복(409)이 흔한
// 트리거이고, 활성 확인과 영속화 사이에 탈퇴가 커밋되면 탈퇴 cascade 의 prefix 파기가 이미 지나간 뒤라
// 프로필 사진(얼굴 등 PII)이 S3 에 계속 남는다. 그래서 lifecycle 에 맡기지 않고 즉시 회수한다
// (registerFromImages 의 raw 회수와 같은 패턴).
return runCatching { userService.updateProfile(userId, nickname, url) }
.onFailure { deleteQuietly(key) }
.getOrThrow()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
rg -n -A35 -B10 \
  'fun (upload|delete|updateProfile)|`@Transactional`' \
  src/main/kotlin

Repository: TeamPiKi/core

Length of output: 50370


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# 후보 파일과 관련 심볼 위치만 좁게 확인
fd -a 'ProfileUpdateService.kt|UserService.kt|ImageStorage.kt|.*Storage.*\.kt' src/main/kotlin

echo '--- ProfileUpdateService ---'
ast-grep outline src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt --view expanded || true
cat -n src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt | sed -n '1,140p'

echo '--- UserService ---'
fd -a 'UserService.kt' src/main/kotlin
ast-grep outline src/main/kotlin/com/depromeet/piki/user/service/UserService.kt --view expanded || true
cat -n src/main/kotlin/com/depromeet/piki/user/service/UserService.kt | sed -n '1,220p'

echo '--- ImageStorage impl/interface ---'
rg -n -A40 -B10 'interface ImageStorage|class .*ImageStorage|fun upload\(|fun delete\(' src/main/kotlin

Repository: TeamPiKi/core

Length of output: 50369


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo '--- UserService.updateProfile ---'
cat -n src/main/kotlin/com/depromeet/piki/user/service/UserService.kt | sed -n '280,360p'

echo '--- ProfileUpdateService context ---'
cat -n src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt | sed -n '1,120p'

echo '--- search for commit/transaction note around user profile ---'
rg -n -A6 -B6 'updateProfile\(|duplicateNickname|DataIntegrityViolationException|`@Transactional`' src/main/kotlin/com/depromeet/piki/user/service/UserService.kt

Repository: TeamPiKi/core

Length of output: 16987


프로필 이미지 보상 삭제를 업로드 단계와 커밋 결과에 맞게 분리하세요.
지금은 updateProfile 실패에만 deleteQuietly(key)가 걸려 있어서, 업로드 중 응답 유실/timeout처럼 “S3에는 올라갔지만 예외가 난” 경우는 회수되지 않습니다. 반대로 커밋이 메서드 반환 뒤에 실패할 수 있는 예외까지 즉시 삭제하면, DB가 이미 새 URL을 가리키는 상태와 어긋날 수 있습니다. 업로드/DB 반영을 pending asset(outbox)로 추적하고, 참조 없음이 확인된 객체만 비동기로 회수하는 쪽이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/kotlin/com/depromeet/piki/user/service/ProfileUpdateService.kt`
around lines 39 - 45, 프로필 이미지 보상 삭제를 즉시 실패 처리에서 분리해 업로드와 DB 반영 결과를 안전하게 추적하세요.
ProfileUpdateService의 updateProfile 흐름에서 새 S3 객체를 pending asset/outbox로 기록하고,
업로드 응답 유실·timeout을 포함한 미확정 결과도 추적되도록 하며, 비동기 정리 작업은 DB에 해당 URL 참조가 없음을 확인한 객체만
deleteQuietly로 회수하도록 변경하세요.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

refactor 구조 개선, 외부 동작 불변

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P7] findById tombstone 미필터 — 활성 조회 메서드 분리

1 participant