Skip to content

[feat]: 탈퇴 유저 30일 후 영구 삭제 스케줄러 구현#180

Merged
geunyong16 merged 1 commit into
mainfrom
feature/131-hard-delete-withdrawn-users
May 4, 2026
Merged

[feat]: 탈퇴 유저 30일 후 영구 삭제 스케줄러 구현#180
geunyong16 merged 1 commit into
mainfrom
feature/131-hard-delete-withdrawn-users

Conversation

@geunyong16
Copy link
Copy Markdown
Collaborator

@geunyong16 geunyong16 commented May 4, 2026

요약

  • 탈퇴 후 30일이 경과한 사용자의 데이터를 FK 제약을 고려한 순서로 hard delete 처리
  • 매일 새벽 4시에 자동 실행되는 스케줄러 추가
  • 고용주/근로자 케이스별 삭제 로직 분리, 다른 사용자 데이터 영향 방지

Closes #131

구현 상세

UserHardDeleteService

  • 사용자 1명 단위 트랜잭션으로 한 명 실패가 다른 사용자에 영향 없음
  • 고용주 탈퇴: 사업장 → WorkerContract → 하위 모든 데이터(WorkRecord, WeeklyAllowance, Salary, Payment) → CorrectionRequest → Notice → Workplace → Employer 정리
  • 근로자 탈퇴: 본인의 WorkerContract만 정리 (다른 근로자 데이터 영향 없음)
  • FK 삭제 순서: CorrectionRequest → Payment → Salary → WorkRecord → WeeklyAllowance → WorkerContract → Notice → Workplace → Employer/Worker → User

UserHardDeleteScheduler

  • @Scheduled(cron = "0 0 4 * * *") — 매일 새벽 4시
  • 30일 임계값: LocalDateTime.now().minusDays(30)
  • 유저별 try-catch로 격리, 실패 로깅

Repository 메서드 추가

신규 bulk delete 메서드 (총 12개):

  • @Modifying(clearAutomatically = true) 사용으로 영속성 컨텍스트 stale 방지
  • 빈 리스트 처리: 호출 전 isEmpty() 체크

테스트 전략

UserHardDeleteServiceTest (6개 시나리오)

  • ✅ 고용주 영구 삭제: FK 순서 검증 (InOrder 사용)
  • ✅ 근로자 영구 삭제: 본인 계약만 정리 확인
  • ✅ 공통 데이터 정리: Notification, FcmToken, UserSettings, RefreshToken
  • ✅ Employer/Worker 프로필 없는 케이스: NPE 안전성
  • ✅ 계약/사업장 없는 경우: 빈 IN절 쿼리 미호출
  • ✅ NotFoundException: 사용자 존재 검증

테스트 실행 결과

./gradlew test --tests UserHardDeleteServiceTest
./gradlew test --tests UserWithdrawServiceTest
./gradlew clean build
→ BUILD SUCCESSFUL

선행 조건

이슈 #130 (detached 엔티티로 인한 deletedAt 미반영 버그) 완료

참고사항

  • Scheduler 실행 시간: 새벽 4시 (기존 스케줄러와 시간 분산: RefreshToken=3시, WorkRecord=2시)
  • 근로자 삭제 시 영향 분리: 근로자의 contract ID만으로 조회/삭제하므로, 같은 사업장의 다른 근로자 데이터 안전
  • 고용주 삭제 시: 사업장 산하 모든 근로자의 계약이 정리되지만, 근로자 엔티티 자체는 삭제 안 됨 (다른 사업장에서의 활동 보존)

🤖 Generated with Claude Code

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 회원 탈퇴 후 30일 이후 계정이 자동으로 삭제되는 기능이 추가되었습니다. 매일 오전 4시에 자동 삭제 프로세스가 실행되며, 삭제된 계정과 관련된 모든 데이터는 영구적으로 제거됩니다.
  • 테스트

    • 사용자 계정 삭제 기능에 대한 테스트 케이스가 추가되었습니다.

매일 새벽 4시에 `deletedAt`이 30일 이상 경과한 탈퇴 사용자의 데이터를
FK 제약을 고려한 순서로 hard delete 처리하는 기능 추가

**변경 내용:**
- UserHardDeleteService: 사용자별 트랜잭션으로 고용주/근로자 케이스 구분
  - 고용주: 사업장 → 계약 → 하위 모든 데이터(근무/수당/급여/결제) → 정정요청 → 공지 정리
  - 근로자: 본인 계약만 정리, 다른 근로자 데이터 영향 없음
  - FK 의존성 역순: CorrectionRequest → Payment → Salary → WorkRecord →
    WeeklyAllowance → WorkerContract → Notice → Workplace → Employer/Worker → User

- UserHardDeleteScheduler: 매일 새벽 4시 실행
  - 30일 경과 탈퇴 유저 조회 후 유저별 hard delete 호출
  - 한 명 실패 시 try-catch로 격리, 다음 유저 계속 처리

- Repository 메서드 추가 (영구 삭제용 bulk delete)
  - UserRepository: findAllByDeletedAtBefore
  - WorkRecordRepository: deleteAllByContractIdIn
  - WeeklyAllowanceRepository: deleteAllByContractIdIn
  - SalaryRepository: findIdsByContractIdIn, deleteAllByContractIdIn
  - PaymentRepository: deleteAllBySalaryIdIn
  - CorrectionRequestRepository: deleteAllByRequesterId, deleteAllByContractIdIn
  - NoticeRepository: deleteAllByAuthorId, deleteAllByWorkplaceIdIn
  - WorkplaceRepository: deleteAllByEmployerId
  - WorkerContractRepository: findIdsByWorkplaceIdIn, deleteAllByWorkplaceIdIn,
                               findIdsByWorkerId, deleteAllByWorkerId

- UserHardDeleteServiceTest: 고용주/근로자 케이스 및 FK 순서 검증

**선행 조건:**
이슈 #130 (detached 엔티티 버그) 완료 후 진행됨

**테스트:**
- ./gradlew test --tests UserHardDeleteServiceTest: 6개 시나리오 통과
- ./gradlew clean build: 전체 테스트 통과
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

Walkthrough

사용자 hard-delete 기능을 구현하는 스케줄러와 서비스를 추가합니다. 스케줄러는 매일 04:00에 30일 이상 경과한 soft-deleted 사용자들을 hard-delete하고, 서비스는 사용자의 모든 관련 데이터를 외래 키 제약 순서대로 삭제합니다. 여러 도메인의 repository에 bulk delete 메서드를 추가하여 이를 지원합니다.

Changes

사용자 Hard-Delete 기능

Layer / File(s) Summary
Repository 쿼리 확장
src/main/java/com/example/paycheck/domain/user/repository/UserRepository.java
findAllByDeletedAtBefore(LocalDateTime) 메서드 추가: 30일 이상 지난 soft-deleted 사용자를 조회합니다.
Bulk Delete 지원 메서드
src/main/java/com/example/paycheck/domain/allowance/repository/WeeklyAllowanceRepository.java,
src/main/java/com/example/paycheck/domain/contract/repository/WorkerContractRepository.java,
src/main/java/com/example/paycheck/domain/correction/repository/CorrectionRequestRepository.java,
src/main/java/com/example/paycheck/domain/notice/repository/NoticeRepository.java,
src/main/java/com/example/paycheck/domain/payment/repository/PaymentRepository.java,
src/main/java/com/example/paycheck/domain/salary/repository/SalaryRepository.java,
src/main/java/com/example/paycheck/domain/workplace/repository/WorkplaceRepository.java,
src/main/java/com/example/paycheck/domain/workrecord/repository/WorkRecordRepository.java
각 도메인 repository에 deleteAllBy* 메서드들을 추가하여 계약, 급여, 작업 기록, 공지사항 등을 contract ID, employer ID, workplace ID, salary ID 등으로 일괄 삭제합니다. @Modifying(clearAutomatically = true) 애노테이션으로 지속성 컨텍스트 관리를 자동화합니다.
Hard-Delete 서비스
src/main/java/com/example/paycheck/domain/user/service/UserHardDeleteService.java
사용자 타입(고용주/근로자)에 따라 삭제 흐름을 분기하고, 외래 키 제약 순서를 준수하여 correction requests → payments/salaries → work records/allowances → workplaces/contracts → employer/worker → notifications/tokens/settings → user를 삭제합니다.
Hard-Delete 스케줄러
src/main/java/com/example/paycheck/domain/user/scheduler/UserHardDeleteScheduler.java
매일 04:00에 실행되는 Spring 컴포넌트로, 30일 이상 경과한 soft-deleted 사용자들을 조회하고 각각에 대해 UserHardDeleteService.hardDeleteUser()를 호출합니다. 성공/실패 카운트를 추적하고 상세 로깅을 수행합니다.
테스트
src/test/java/com/example/paycheck/domain/user/service/UserHardDeleteServiceTest.java
고용주/근로자 hard-delete 시나리오, 공통 데이터 정리, 누락된 프로필 처리, 빈 workplace 목록 처리, 사용자 미존재 시 예외 던지기 등 7개의 테스트 케이스로 deletion 순서와 동작을 검증합니다.

Sequence Diagram

sequenceDiagram
    participant Scheduler as UserHardDeleteScheduler
    participant UserRepo as UserRepository
    participant HardDeleteSvc as UserHardDeleteService
    participant EmployerRepo as EmployerRepository
    participant WorkplaceRepo as WorkplaceRepository
    participant ContractRepo as WorkerContractRepository
    participant CorrectionRepo as CorrectionRequestRepository
    participant SalaryRepo as SalaryRepository
    participant PaymentRepo as PaymentRepository
    participant WorkRecordRepo as WorkRecordRepository
    participant AllowanceRepo as WeeklyAllowanceRepository
    participant NoticeRepo as NoticeRepository
    participant UserTokenRepo as UserTokenRepository

    Scheduler->>UserRepo: findAllByDeletedAtBefore(30일 전)
    UserRepo-->>Scheduler: 대상 사용자 목록

    loop 각 사용자마다
        Scheduler->>HardDeleteSvc: hardDeleteUser(userId)
        
        HardDeleteSvc->>UserRepo: findById(userId)
        UserRepo-->>HardDeleteSvc: User
        
        alt UserType = EMPLOYER
            HardDeleteSvc->>EmployerRepo: findByUserId(userId)
            EmployerRepo-->>HardDeleteSvc: Employer
            HardDeleteSvc->>WorkplaceRepo: findIdsByEmployerId()
            WorkplaceRepo-->>HardDeleteSvc: workplace IDs
            HardDeleteSvc->>ContractRepo: findIdsByWorkplaceIdIn()
            ContractRepo-->>HardDeleteSvc: contract IDs
        else UserType = WORKER
            HardDeleteSvc->>ContractRepo: findIdsByWorkerId()
            ContractRepo-->>HardDeleteSvc: contract IDs
        end

        HardDeleteSvc->>CorrectionRepo: deleteAllByRequesterId(userId)
        HardDeleteSvc->>CorrectionRepo: deleteAllByContractIdIn(contractIds)
        HardDeleteSvc->>SalaryRepo: findIdsByContractIdIn(contractIds)
        SalaryRepo-->>HardDeleteSvc: salary IDs
        HardDeleteSvc->>PaymentRepo: deleteAllBySalaryIdIn(salaryIds)
        HardDeleteSvc->>SalaryRepo: deleteAllByContractIdIn(contractIds)
        HardDeleteSvc->>WorkRecordRepo: deleteAllByContractIdIn(contractIds)
        HardDeleteSvc->>AllowanceRepo: deleteAllByContractIdIn(contractIds)
        
        alt UserType = EMPLOYER
            HardDeleteSvc->>ContractRepo: deleteAllByWorkplaceIdIn(workplaceIds)
            HardDeleteSvc->>NoticeRepo: deleteAllByWorkplaceIdIn(workplaceIds)
            HardDeleteSvc->>WorkplaceRepo: deleteAllByEmployerId(employerId)
            HardDeleteSvc->>EmployerRepo: delete(employer)
        else UserType = WORKER
            HardDeleteSvc->>ContractRepo: deleteAllByWorkerId(workerId)
            HardDeleteSvc->>NoticeRepo: deleteAllByAuthorId(userId)
        end

        HardDeleteSvc->>NoticeRepo: deleteAllByAuthorId(userId)
        HardDeleteSvc->>UserTokenRepo: 알림/FCM/설정/토큰 삭제
        HardDeleteSvc->>UserRepo: delete(user)
        
        Scheduler->>Scheduler: 성공/실패 카운트
    end

    Scheduler->>Scheduler: 완료 로그 (총/성공/실패)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

변경사항이 8개의 repository 인터페이스에 걸쳐 있고, 새로운 서비스와 스케줄러가 복잡한 deletion 순서 로직을 포함하며, 외래 키 제약을 존중하는지 검증이 필요하고, 227줄의 테스트 코드가 8개의 시나리오를 다루기 때문입니다.

Possibly related issues

Possibly related PRs

  • [perf] WorkRecord 일괄 등록 성능 최적화: saveAll() 적용 #98: 본 PR이 WeeklyAllowanceRepository에 deleteAllByContractIdIn bulk delete 메서드를 추가하는 반면, 해당 PR은 동일한 repository에 date-range finder 메서드를 추가하여 같은 클래스를 수정합니다.
  • [feat] 과거 근무기록 자동완료 #128: 본 PR이 WorkRecordRepository에 deleteAllByContractIdIn 메서드를 추가하고, 해당 PR이 동일한 repository에 findPastScheduledWorkRecords 메서드를 추가하여 코드 수준에서 관련됩니다.
  • [feat] 공지사항 기능 구현 #119: 본 PR의 NoticeRepository bulk delete 메서드 추가가 해당 PR에서 도입된 NoticeRepository 클래스와 같은 파일을 수정하므로 변경사항이 관련됩니다.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 주요 변경 사항을 명확하게 설명합니다. 탈퇴 유저의 30일 후 영구 삭제 스케줄러 구현이라는 핵심 내용이 간결하게 표현되어 있습니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed PR 설명이 템플릿 구조를 대부분 따르고 있으며, 관련 이슈(#131)가 명시되어 있고 상세한 구현 내용과 테스트 전략이 포함되어 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 feature/131-hard-delete-withdrawn-users

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/main/java/com/example/paycheck/domain/user/scheduler/UserHardDeleteScheduler.java (1)

36-49: ⚡ Quick win

대상 조회를 엔티티 전체가 아니라 ID 단위로 줄이는 쪽을 권장합니다.

지금은 삭제 대상 전체를 엔티티로 로드한 뒤 id만 사용하고 있어서, 누적 데이터가 많아지면 메모리 사용량과 처리 시간이 불필요하게 커질 수 있습니다. 스케줄러는 ID 페이징 조회나 배치 처리로 쪼개는 편이 더 안전합니다.

수정 방향 예시
- List<User> targets = userRepository.findAllByDeletedAtBefore(threshold);
+ List<Long> targetIds = userRepository.findIdsByDeletedAtBefore(threshold);

- for (User user : targets) {
+ for (Long userId : targetIds) {
     try {
-        userHardDeleteService.hardDeleteUser(user.getId());
+        userHardDeleteService.hardDeleteUser(userId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/example/paycheck/domain/user/scheduler/UserHardDeleteScheduler.java`
around lines 36 - 49, UserHardDeleteScheduler is loading full User entities via
userRepository.findAllByDeletedAtBefore(threshold) but only uses user.getId(),
causing memory/latency issues; change the repository call to fetch only IDs
(e.g., add a method like findIdsByDeletedAtBefore(LocalDateTime, Pageable) or
findAllIdsByDeletedAtBefore) and iterate in paged/batched loops (use PageRequest
and loop until empty) instead of loading the entire list into targets, then call
userHardDeleteService.hardDeleteUser(id) for each id; keep RETENTION_DAYS and
the existing try/catch logic but operate on id batches to avoid retaining entity
objects in memory.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/main/java/com/example/paycheck/domain/user/service/UserHardDeleteService.java`:
- Around line 133-137: Update the Javadoc in UserHardDeleteService (the method
that deletes contract-related data) so the listed FK deletion order matches the
actual implementation: move CorrectionRequest to the front and list the sequence
as CorrectionRequest → WeeklyAllowance → WorkRecord → Salary → Payment (or the
exact order used by the delete methods in UserHardDeleteService). Reference the
method/class name (UserHardDeleteService) when editing the comment to keep it
accurate for future maintainers.

---

Nitpick comments:
In
`@src/main/java/com/example/paycheck/domain/user/scheduler/UserHardDeleteScheduler.java`:
- Around line 36-49: UserHardDeleteScheduler is loading full User entities via
userRepository.findAllByDeletedAtBefore(threshold) but only uses user.getId(),
causing memory/latency issues; change the repository call to fetch only IDs
(e.g., add a method like findIdsByDeletedAtBefore(LocalDateTime, Pageable) or
findAllIdsByDeletedAtBefore) and iterate in paged/batched loops (use PageRequest
and loop until empty) instead of loading the entire list into targets, then call
userHardDeleteService.hardDeleteUser(id) for each id; keep RETENTION_DAYS and
the existing try/catch logic but operate on id batches to avoid retaining entity
objects in memory.
🪄 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.yaml

Review profile: CHILL

Plan: Pro

Run ID: b7c5ea99-d9b3-4f80-8520-255a75ca39d3

📥 Commits

Reviewing files that changed from the base of the PR and between f06e6aa and 3c96f3c.

📒 Files selected for processing (12)
  • src/main/java/com/example/paycheck/domain/allowance/repository/WeeklyAllowanceRepository.java
  • src/main/java/com/example/paycheck/domain/contract/repository/WorkerContractRepository.java
  • src/main/java/com/example/paycheck/domain/correction/repository/CorrectionRequestRepository.java
  • src/main/java/com/example/paycheck/domain/notice/repository/NoticeRepository.java
  • src/main/java/com/example/paycheck/domain/payment/repository/PaymentRepository.java
  • src/main/java/com/example/paycheck/domain/salary/repository/SalaryRepository.java
  • src/main/java/com/example/paycheck/domain/user/repository/UserRepository.java
  • src/main/java/com/example/paycheck/domain/user/scheduler/UserHardDeleteScheduler.java
  • src/main/java/com/example/paycheck/domain/user/service/UserHardDeleteService.java
  • src/main/java/com/example/paycheck/domain/workplace/repository/WorkplaceRepository.java
  • src/main/java/com/example/paycheck/domain/workrecord/repository/WorkRecordRepository.java
  • src/test/java/com/example/paycheck/domain/user/service/UserHardDeleteServiceTest.java

Comment on lines +133 to +137
/**
* 계약 산하 데이터를 FK 의존성 역순으로 삭제한다.
* Payment → Salary → WorkRecord → WeeklyAllowance → CorrectionRequest
* (CorrectionRequest는 WorkRecord/Contract를 참조하므로 가장 먼저 정리)
*/
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

주석의 삭제 순서 설명을 실제 코드와 맞춰 주세요.

현재 주석은 Payment → Salary → WorkRecord → WeeklyAllowance → CorrectionRequest로 적혀 있지만, 실제 구현은 CorrectionRequest를 가장 먼저 삭제합니다. 다음 유지보수자가 FK 순서를 오해하지 않도록 주석을 코드와 동일하게 정리하는 게 좋습니다.

수정 예시
 /**
  * 계약 산하 데이터를 FK 의존성 역순으로 삭제한다.
- *  Payment → Salary → WorkRecord → WeeklyAllowance → CorrectionRequest
- *  (CorrectionRequest는 WorkRecord/Contract를 참조하므로 가장 먼저 정리)
+ *  CorrectionRequest → Payment → Salary → WorkRecord → WeeklyAllowance
  */
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 계약 산하 데이터를 FK 의존성 역순으로 삭제한다.
* PaymentSalaryWorkRecordWeeklyAllowanceCorrectionRequest
* (CorrectionRequest는 WorkRecord/Contract를 참조하므로 가장 먼저 정리)
*/
/**
* 계약 산하 데이터를 FK 의존성 역순으로 삭제한다.
* CorrectionRequestPaymentSalaryWorkRecordWeeklyAllowance
*/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/example/paycheck/domain/user/service/UserHardDeleteService.java`
around lines 133 - 137, Update the Javadoc in UserHardDeleteService (the method
that deletes contract-related data) so the listed FK deletion order matches the
actual implementation: move CorrectionRequest to the front and list the sequence
as CorrectionRequest → WeeklyAllowance → WorkRecord → Salary → Payment (or the
exact order used by the delete methods in UserHardDeleteService). Reference the
method/class name (UserHardDeleteService) when editing the comment to keep it
accurate for future maintainers.

@geunyong16 geunyong16 merged commit 0f34166 into main May 4, 2026
2 checks passed
@geunyong16 geunyong16 deleted the feature/131-hard-delete-withdrawn-users branch May 4, 2026 12:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] 탈퇴 유저 30일 후 영구 삭제 스케줄러 구현

1 participant