Skip to content

알림 히스토리 삭제 기능 (단건·다건·전체·N일 자동삭제)#752

Merged
sevineleven merged 10 commits into
devfrom
feat/notification-history-delete
Jul 16, 2026
Merged

알림 히스토리 삭제 기능 (단건·다건·전체·N일 자동삭제)#752
sevineleven merged 10 commits into
devfrom
feat/notification-history-delete

Conversation

@sevineleven

@sevineleven sevineleven commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Situation

  • 알림 히스토리 화면에 4개 기능(모두읽음·단건삭제·모두삭제·N일 자동삭제)이 필요한데, 이 중 모두읽음(POST /read {all:true})만 이미 있고 삭제 계열 3개가 통째로 미구현이었다.
  • 삭제를 새로 붙이면서 읽음 처리와 계약·뱃지 동기화가 제각각 놀면 클라가 두 벌의 규칙을 다뤄야 한다. 이미 자리잡은 읽음 흐름에 최대한 대칭으로 얹는 게 이번 작업의 방향이었다.

Task

  • 단건·다건·모두 삭제를 하나의 엔드포인트로 제공한다 (읽음의 all XOR ids 계약을 그대로 미러링).
  • 보존 기간이 지난 알림을 주기적으로 정리하는 N일 자동삭제를 추가한다 (기본 30일).
  • 모든 삭제 경로(단건·다건·모두·자동삭제)가 안읽음이 줄면 뱃지를 truthful하게 유지하도록, 읽음의 뱃지 동기화와 대칭인 경로를 태운다.

Action

삭제 엔드포인트 (단건·다건·모두)

DELETE /api/v1/notifications 하나로 세 가지를 처리한다. body 는 읽음과 똑같이 둘 중 정확히 하나:

방식 동작
all=true 본인 알림 전부 삭제 (읽음 무관)
ids=[id] 단건 삭제
ids=[id, ...] 다건 삭제
  • 하드삭제: deleted_at soft delete 컬럼이 이미 있으나, 히스토리에서 영구 제거가 목적이라 쓰지 않는다.
  • 본인 소유만·멱등: 삭제 쿼리의 user_id 조건이 소유 검증을 겸한다. 타인 id나 없는 id를 섞어 보내도 그 부분만 무영향으로 걸러져 예외 없이 성공한다 (별도 소유 검증 예외를 두지 않은 이유). ids 개수 상한도 두지 않는다: 대량은 all=true 가 담당하고, 본인 한정·멱등·본문 크기 상한으로 bounded (읽음 DTO의 무-cap 결정 미러링).
  • 모두 삭제는 기존 쿼리 재사용: 탈퇴 cascade용 hardDeleteAllByUserId와 동일 쿼리라 그대로 가져다 썼다. 이 벌크 DELETE에는 flushAutomatically·clearAutomatically를 붙여, 같은 트랜잭션의 이후 조회가 1차 캐시 stale과 어긋나지 않게 deleteByUserIdAndIds·markRead와 일관을 맞췄다.
  • 요청 검증: all·ids 동시 전송 / 둘 다 없음 / 빈 ids는 입력 경계에서 400. 읽음 DTO의 @AssertTrue XOR 검증·toCommand()를 복제했다.

뱃지 동기화 (모든 삭제 경로 공통, 읽음과 대칭)

삭제 후 안읽음 수를 다시 세어, 읽음 흐름과 완전히 같은 방식으로 여러 기기의 뱃지를 맞춘다. 단건·다건·모두 삭제뿐 아니라 자동삭제도 이 경로를 태운다.

  • 삭제한 기기: 응답 body의 안읽음 수(전체·탭별)를 그대로 미러링 (별도 조회 불필요).
  • 온라인 기기: 열린 SSE로 인앱 뱃지 silent-sync.
  • 오프라인 기기: FCM silent 푸시로 OS 아이콘 뱃지.

두 동기화는 모두 비동기라 삭제 응답(스케줄 스레드)이 SSE write·FCM latency에 묶이지 않는다.

N일 자동삭제

  • 매일 04:00 KST 스케줄러가 보존 기간을 넘긴 알림을 하드삭제한다. 이 앱의 JVM 기본 TZ는 UTC라 cron에 zone="Asia/Seoul"을 명시하지 않으면 13:00 KST에 도는 함정이 있어, 다른 cron 스케줄러들과 동일하게 KST로 고정했다. 보존 기간은 notification.retention-days(기본 30, env 오버라이드)로 두고 하드코딩하지 않는다. 0·음수 오설정은 cutoff를 현재/미래로 만들어 대량 삭제로 이어지므로, 불변식(require)으로 부팅 시점에 막는다.
  • 자동삭제도 뱃지를 쏜다 (대량 백로그 대비 순차 fan-out): 삭제로 안읽음이 사라진 유저의 뱃지가 stale로 남지 않도록, 삭제 전 cutoff 미만 안읽음 보유 유저를 모아 커밋 후 배지를 전파한다. 읽음만 지워진 유저는 뱃지 불변이라 제외. 첫 실행엔 30일 넘긴 백로그가 수백~수천 유저에 몰릴 수 있는데, 유저당 @Async(유저×2: SSE+FCM)를 한꺼번에 던지면 공유 실행기(pool 4·queue 200·포화 시 drop)를 넘겨 배지가 대량 유실되고 실시간 알림까지 굶는다 (500명 실측 FCM 전달률 21.6%). 그래서 fan-out을 단일 @Async 배치가 순차 전파하도록 해 어느 순간에도 실행기 점유 태스크가 1개뿐이게 했다 (500명 재실측 100%, 실시간 알림 비굶김). 유저별 재집계도 N+1 대신 IN + GROUP BY 한 쿼리라 유저 수에 비례하지 않는다. (단건·다건·모두 삭제는 영향 유저 1명이라 기존 유저당 @Async 유지.)
  • 삭제 로직은 스케줄러에서 분리했다. 스케줄러는 cutoff 계산·뱃지 fan-out만 하고, 실제 삭제(created_at < cutoff)와 트랜잭션 경계는 별도 서비스가 맡아 cutoff를 직접 넘겨 결정적으로 테스트할 수 있다.
  • admin 조건부를 붙이지 않았다: 기존 예약 공지 스케줄러는 백오피스 기능이라 ConditionalOnAdminEnabled가 붙지만, 알림 정리는 admin과 무관한 코어 유지보수라 전 환경에서 돈다.
  • 인덱스 추가: age 기준 전역 삭제가 풀스캔하지 않도록 created_at 단일 인덱스 마이그레이션을 더했다 (기존 인덱스는 (user_id, ...)뿐이라 커버 안 됨).

응답 전수 문서화

DELETE의 도달 가능한 응답을 빠짐없이 문서화했다 (어노테이션 + example 동반, fail detail은 DTO 상수 single-source).

status 사유
200 삭제 성공 (삭제 후 안읽음 수 동봉)
400 all·ids 동시 / 둘 다 없음 / 빈 ids
401 미인증

Result

  • 삭제 3종과 자동삭제가 하나의 계약(읽음 미러링)으로 통일돼, 클라는 삭제도 읽음과 같은 규칙·같은 응답 셰입으로 다루고, 어느 삭제 경로든 뱃지가 서버 권위 값으로 수렴한다.
  • 검증 커버리지: DTO XOR 분기(단위), 단건·다건·모두·타인 무영향·멱등·400·401 계약(통합), 자동삭제의 created_at == cutoff 정확 경계(<<= 회귀 방지)·뱃지 대상 선별(안읽음 지워진 유저만·읽음만 지워진 유저 제외)·스케줄러 fan-out wiring(SSE·FCM이 영향 유저에게만 실제 전파) 검증(통합, 실 빈 + Awaitility).
  • 안전장치: 전체삭제 벌크 flush·clear(삭제 후 조회 일관성), retention-days 양수 검증, 자동삭제 재집계 IN + GROUP BY(N+1 회피), 대량 백로그 fan-out을 순차 배치로 바운드 — 500명 실측으로 배지 전달률 21.6%→100% 회복·실시간 알림 비굶김을 확인.

연관 이슈

Summary by CodeRabbit

  • 새로운 기능
    • 알림 삭제 API를 추가했습니다(전체 삭제 all=true 또는 선택 삭제 ids 지원).
    • 삭제 후 전체/카테고리별 미읽음 수가 응답에 반영되고, 배지 동기화가 수행됩니다.
    • 보존 기간 초과 알림을 자동 정리하도록 스케줄러를 도입했습니다(기본 보존 30일).
    • 삭제 API의 OpenAPI 예시를 추가했습니다.
  • 버그 수정
    • 잘못된 요청 조합과 빈 ids는 400으로 차단하고, 인증 실패는 401로 처리합니다.
    • 다른 사용자의 알림/존재하지 않는 id는 무영향이며 멱등 동작을 보장합니다.

- DELETE /api/v1/notifications 를 읽음(POST /read) 계약과 대칭으로 추가 — body {all XOR ids} 로 단건([id])·다건([id,...])·전체(all=true)를 한 엔드포인트가 처리. 요청 DTO 는 @AssertTrue XOR 검증·toCommand() 를 read DTO 에서 복제
- 삭제는 하드삭제·본인 소유만·멱등(타인/없는 id 무영향). 삭제로 안읽음이 사라지면 badge 도 줄도록 read flow(readAndSyncBadge)와 대칭인 NotificationDeleteOrchestrator 로 삭제 후 재집계 + SSE silent-sync + FCM syncBadge. 전체 삭제는 기존 hardDeleteAllByUserId(탈퇴 cascade) 재사용
- N일 자동삭제: NotificationCleanupScheduler(@scheduled cron, 매일 04:00)가 보존기간(notification.retention-days=30, env 오버라이드) 넘긴 알림을 하드삭제. 로직은 NotificationRetentionService.purgeOlderThan(cutoff)로 분리해 결정적 테스트 가능. admin 무관 코어라 AnnouncementScheduler 와 달리 ConditionalOnAdminEnabled 안 붙임
- created_at 단일 인덱스(idx_notifications_created_at) 마이그레이션 추가 — age 기준 전역 삭제가 풀스캔하지 않게. deleted_at 컬럼은 이미 있으나 하드삭제라 미사용
- 멀쩡한 클라가 받는 응답 전수 문서화(200/400/401): 어노테이션+example 동반, fail detail 은 DTO const single-source
- NotificationDeleteRequestTest(단위): all XOR ids 검증·toCommand 분기 망라
- NotificationDeleteIntegrationTest(통합): 단건·다건·전체 삭제, 타인 무영향(소유 검증), 멱등, 400(all·ids 동시/빈 ids), 401 contract
- NotificationRetentionIntegrationTest(통합): cutoff < 경계(이후 생성분만 삭제, 최근 분 보존)
- all=true(hardDeleteAllByUserId)는 벌크 삭제 후 컨텍스트를 clear 하지 않아 findById 가 1차 캐시 stale 을 돌려주므로, 삭제 검증은 DB 를 치는 existsById 로 확인
@sevineleven sevineleven added the feat 외부 가시적 새 기능 label Jul 15, 2026
@sevineleven sevineleven self-assigned this Jul 15, 2026
@github-actions

Copy link
Copy Markdown

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

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

알림 삭제 API가 추가되어 단건·다건·전체 하드삭제와 소유권 검증, unread 배지 재계산 및 동기화를 지원한다. 보존 기간 기반 자동 삭제 스케줄러와 created_at 인덱스, API·cutoff 경계 테스트도 추가되었다.

Changes

알림 삭제 및 자동 정리

Layer / File(s) Summary
삭제 API 계약
src/main/kotlin/com/depromeet/piki/notification/controller/..., src/main/kotlin/com/depromeet/piki/notification/service/dto/...
allids의 XOR 검증, 삭제 명령·응답 DTO, DELETE 엔드포인트 및 OpenAPI 응답 예시를 추가한다.
소유권 기반 하드삭제와 배지 동기화
src/main/kotlin/com/depromeet/piki/notification/service/..., src/main/kotlin/com/depromeet/piki/notification/repository/..., src/main/resources/db/migration/...
사용자 소유 범위에서 알림을 하드삭제하고, unread 카운트를 재계산해 SSE와 FCM 배지를 동기화한다. 벌크 삭제 쿼리와 created_at 인덱스도 추가한다.
보존 기간 기반 자동 정리
src/main/kotlin/com/depromeet/piki/notification/config/..., src/main/kotlin/com/depromeet/piki/notification/service/..., src/main/resources/application.yml
보존 기간과 크론을 바인딩하고 cutoff 이전 알림을 주기적으로 삭제하며 삭제 결과를 사용자별 배지 동기화에 사용한다.
삭제 및 자동 정리 검증
src/test/kotlin/com/depromeet/piki/notification/controller/..., src/test/kotlin/com/depromeet/piki/notification/service/...
삭제 유형, 입력 오류, 인증, 소유권, 멱등성, 배지 응답과 자동 삭제 cutoff 경계를 검증한다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant NotificationHistoryController
  participant NotificationDeleteOrchestrator
  participant NotificationService
  participant NotificationRepository
  participant SilentSyncDispatcher
  participant PushNotificationChannel

  Client->>NotificationHistoryController: DELETE /api/v1/notifications
  NotificationHistoryController->>NotificationDeleteOrchestrator: deleteAndSyncBadge(userId, command)
  NotificationDeleteOrchestrator->>NotificationService: delete(userId, command)
  NotificationService->>NotificationRepository: 사용자 소유 알림 하드삭제
  NotificationRepository-->>NotificationService: 삭제 건수
  NotificationService-->>NotificationDeleteOrchestrator: 카테고리별 unread count
  NotificationDeleteOrchestrator->>SilentSyncDispatcher: UnreadCountChanged dispatch
  NotificationDeleteOrchestrator->>PushNotificationChannel: OS badge 동기화
  NotificationDeleteOrchestrator-->>NotificationHistoryController: unread count map
  NotificationHistoryController-->>Client: NotificationDeleteResponse
Loading

Assessment against linked issues

Objective Addressed Explanation
단건·다건·전체 하드삭제 API와 XOR 요청 검증 [#747]
사용자 소유권·멱등성·삭제 후 배지 동기화 [#747]
N일 기준 자동 하드삭제와 스케줄 설정 [#747]
자동 삭제용 created_at 인덱스 및 cutoff 경계 검증 [#747]
🚥 Pre-merge checks | ✅ 1 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.59% 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 feat/notification-history-delete

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

- 이 앱의 JVM 기본 TZ 는 UTC 라(로그 표시만 Asia/Seoul 고정), @scheduled 에 zone 을 안 주면 04:00 이 UTC 로 해석돼 13:00 KST 에 돌았다. 의도한 새벽 4시가 아니고 다른 cron 스케줄러(DailyActivityRecorder·WeeklyReportScheduler)의 zone="Asia/Seoul" 관례와도 어긋난다
- @scheduled 에 zone="Asia/Seoul" 을 명시해 매일 04:00 KST 로 고정. cutoff 계산(now-retentionDays)은 created_at 과 같은 JVM TZ 라 그대로 정확 — 실행 시각만 KST 로 옮긴다

@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: 6

🤖 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/notification/config/NotificationProperties.kt`:
- Around line 10-12: NotificationProperties의 retentionDays에 양수 검증을 추가해 0 이하 값이
설정되지 않도록 하세요. 바인딩 검증 방식을 사용할 경우 클래스에 `@Validated를` 적용하고 retentionDays에
`@field`:Positive를 지정하며, 생성자 검증을 사용할 경우 retentionDays > 0을 require로 보장하세요.

In
`@src/main/kotlin/com/depromeet/piki/notification/controller/dto/NotificationDeleteRequest.kt`:
- Around line 14-15: Enforce a maximum number of IDs for
NotificationDeleteRequest.ids by adding bean validation with a defined MAX_IDS
limit, while allowing null and valid-size lists. Ensure requests containing
MAX_IDS + 1 IDs fail validation and return HTTP 400, and verify the relevant
server or gateway JSON body-size limit is configured.

In
`@src/main/kotlin/com/depromeet/piki/notification/service/NotificationRetentionService.kt`:
- Around line 18-22: Update NotificationRetentionService.purgeOlderThan to
collect affected userId values before the bulk delete, then after transaction
commit asynchronously recalculate each user’s unread count and publish
UnreadCountChanged with the corresponding REST/SSE/FCM badge synchronization,
matching NotificationDeleteOrchestrator behavior; do not leave stale counts
after removing unread notifications.

In
`@src/main/kotlin/com/depromeet/piki/notification/service/NotificationService.kt`:
- Around line 89-95: Update the repository method used by the
NotificationDeleteCommand.All path, hardDeleteAllByUserId, to configure its
`@Modifying` annotation with flushAutomatically = true and clearAutomatically =
true. Keep the existing deletion behavior unchanged and align it with the
ID-based bulk deletion method so the persistence context is flushed and cleared.

In
`@src/test/kotlin/com/depromeet/piki/notification/controller/NotificationDeleteIntegrationTest.kt`:
- Around line 60-79: 현재 통합 테스트는 응답의 unread count만 검증하므로 삭제 후 동기화 호출과 전달 값의 회귀를
확인하도록 보강합니다. NotificationDeleteOrchestrator 단위 테스트에서 알림 삭제 결과와 동일한 unread map을
기준으로 SilentSyncDispatcher와 PushNotificationChannel이 모두 호출되는지 검증하고, 각 채널에 전달된 값이
카테고리별 unread count와 일치하는지 확인하세요.

In
`@src/test/kotlin/com/depromeet/piki/notification/service/NotificationRetentionIntegrationTest.kt`:
- Around line 52-62: Update the test `cutoff 이전 알림은 남긴다...` to seed a
notification whose createdAt exactly equals the cutoff, using the existing
`seed` fixture or its underlying persistence setup. Assert that
`purgeOlderThan(cutoff)` returns 0 and that the exact-cutoff notification still
exists, preserving the test’s intended strict-boundary validation.
🪄 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: 0f6617b3-4370-4bec-978b-cf3c0a8f747e

📥 Commits

Reviewing files that changed from the base of the PR and between 38de409 and de9d7d1.

📒 Files selected for processing (19)
  • src/main/kotlin/com/depromeet/piki/notification/config/NotificationProperties.kt
  • src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt
  • src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt
  • src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryController.kt
  • src/main/kotlin/com/depromeet/piki/notification/controller/dto/NotificationDeleteRequest.kt
  • src/main/kotlin/com/depromeet/piki/notification/controller/dto/NotificationDeleteResponse.kt
  • src/main/kotlin/com/depromeet/piki/notification/repository/NotificationJpaRepository.kt
  • src/main/kotlin/com/depromeet/piki/notification/repository/NotificationRepository.kt
  • src/main/kotlin/com/depromeet/piki/notification/repository/NotificationRepositoryImpl.kt
  • src/main/kotlin/com/depromeet/piki/notification/service/NotificationCleanupScheduler.kt
  • src/main/kotlin/com/depromeet/piki/notification/service/NotificationDeleteOrchestrator.kt
  • src/main/kotlin/com/depromeet/piki/notification/service/NotificationRetentionService.kt
  • src/main/kotlin/com/depromeet/piki/notification/service/NotificationService.kt
  • src/main/kotlin/com/depromeet/piki/notification/service/dto/NotificationDeleteCommand.kt
  • src/main/resources/application.yml
  • src/main/resources/db/migration/V20260715224011__add_index_on_notifications_created_at.sql
  • src/test/kotlin/com/depromeet/piki/notification/controller/NotificationDeleteIntegrationTest.kt
  • src/test/kotlin/com/depromeet/piki/notification/controller/dto/NotificationDeleteRequestTest.kt
  • src/test/kotlin/com/depromeet/piki/notification/service/NotificationRetentionIntegrationTest.kt

@github-actions github-actions Bot requested a review from m-a-king July 15, 2026 14:20
- N일 자동삭제가 안읽음 알림을 지우고도 배지 동기화를 안 해, 삭제된 unread 가 REST unreadCount·SSE·FCM 배지에 stale 로 남던 문제 수정. 단건·모두 삭제는 이미 배지를 쐈으므로 자동삭제만 빠진 비대칭이었다. 삭제 전 cutoff 미만 안읽음 보유 유저를 모아, 커밋 후 유저별로 SSE silent-sync + FCM 배지를 쏜다(NotificationDeleteOrchestrator 와 동일한 tx 밖 @async 방식). 읽음만 지워진 유저는 배지가 안 변하므로 대상에서 제외
- retentionDays 양수 검증 추가 — 잘못 구성한 env(0·음수)면 cutoff 가 현재/미래가 되어 알림을 대량 삭제할 수 있어, 불변식으로 부팅 시점에 require 로 막는다(ops 오설정이라 커스텀 예외가 아니라 require)
- 전체 삭제(hardDeleteAllByUserId) 벌크 DELETE 에 flushAutomatically·clearAutomatically 추가 — deleteByUserIdAndIds·markRead 와 일관되게 맞춰, 같은 트랜잭션의 이후 조회가 1차 캐시 stale 과 어긋나지 않게 한다. 탈퇴 cascade 호출부는 삭제 후 log 만 있어 무해
- 자동삭제로 안읽음이 사라진 유저만 배지 재계산 대상에 담고, 읽음만 지워진 유저는 제외하는지 검증 추가(purgeOlderThan 반환 맵)
- created_at == cutoff 인 알림을 native SQL 로 심어 삭제되지 않음을 검증 — 쿼리가 < 에서 <= 로 바뀌는 경계 회귀를 잡는다(기존 테스트는 cutoff 에서 멀어 못 잡았다)
- 전체삭제 벌크에 clearAutomatically 가 붙어 findById 도 삭제 결과를 정확히 반영하므로, existsById 우회를 다시 findById 로 되돌림

@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

🧹 Nitpick comments (1)
src/test/kotlin/com/depromeet/piki/notification/service/NotificationRetentionIntegrationTest.kt (1)

68-89: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

배지 동기화 호출까지 검증하는 테스트가 필요합니다.

현재 테스트는 affectedUnreadByUser가 올바르게 계산되는지만 확인합니다. NotificationCleanupScheduler에서 사용자 ID, UnreadCountChanged, FCM badge 값이 실제로 전달되는지는 검증하지 않으므로 wiring이 회귀해도 테스트가 통과할 수 있습니다.

별도 스케줄러 테스트가 없다면 영향 사용자에 대해 SSE·FCM이 각각 정확한 payload로 한 번씩 호출되고, 읽음 알림만 삭제된 사용자는 호출되지 않는지 추가로 검증해주세요.

As per path instructions, 핵심 비즈니스 규칙과 회귀를 잡는 테스트를 우선 검토해야 합니다.

🤖 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/test/kotlin/com/depromeet/piki/notification/service/NotificationRetentionIntegrationTest.kt`
around lines 68 - 89, NotificationRetentionIntegrationTest는 affectedUnreadByUser
계산만 검증하므로 NotificationCleanupScheduler의 배지 동기화 wiring도 검증하도록 테스트를 추가하세요. 영향 사용자
userA에 대해 SSE와 FCM이 각각 정확히 한 번 호출되고 사용자 ID, UnreadCountChanged, FCM badge 값이 기대
payload로 전달되는지 확인하세요. 읽음 알림만 삭제된 userB에는 어떤 동기화 호출도 발생하지 않는지 검증하고,
purgeOlderThan 결과와 기존 삭제 규칙도 유지하세요.

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.

Inline comments:
In
`@src/main/kotlin/com/depromeet/piki/notification/service/NotificationRetentionService.kt`:
- Around line 24-26: NotificationRetentionService의 associateWith 및 사용자별
countUnreadByCategory 호출을 제거하고, notificationRepository에 affectedUsers 전체의
userId와 type/category별 unread 수를 GROUP BY로 한 번에 조회하는 메서드를 추가해 사용하세요. 조회 결과를
서비스에서 사용자별 집계 구조로 변환하고, 집계 결과가 없는 영향 사용자도 각 category/type을 0으로 보정하여 기존
affectedUnreadByUser 계약을 유지하세요.

---

Nitpick comments:
In
`@src/test/kotlin/com/depromeet/piki/notification/service/NotificationRetentionIntegrationTest.kt`:
- Around line 68-89: NotificationRetentionIntegrationTest는 affectedUnreadByUser
계산만 검증하므로 NotificationCleanupScheduler의 배지 동기화 wiring도 검증하도록 테스트를 추가하세요. 영향 사용자
userA에 대해 SSE와 FCM이 각각 정확히 한 번 호출되고 사용자 ID, UnreadCountChanged, FCM badge 값이 기대
payload로 전달되는지 확인하세요. 읽음 알림만 삭제된 userB에는 어떤 동기화 호출도 발생하지 않는지 검증하고,
purgeOlderThan 결과와 기존 삭제 규칙도 유지하세요.
🪄 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: ea550adb-9a06-49f4-b5de-44916b740368

📥 Commits

Reviewing files that changed from the base of the PR and between 30c8791 and d51ee3d.

📒 Files selected for processing (9)
  • src/main/kotlin/com/depromeet/piki/notification/config/NotificationProperties.kt
  • src/main/kotlin/com/depromeet/piki/notification/repository/NotificationJpaRepository.kt
  • src/main/kotlin/com/depromeet/piki/notification/repository/NotificationRepository.kt
  • src/main/kotlin/com/depromeet/piki/notification/repository/NotificationRepositoryImpl.kt
  • src/main/kotlin/com/depromeet/piki/notification/service/NotificationCleanupScheduler.kt
  • src/main/kotlin/com/depromeet/piki/notification/service/NotificationRetentionService.kt
  • src/main/kotlin/com/depromeet/piki/notification/service/dto/NotificationPurgeResult.kt
  • src/test/kotlin/com/depromeet/piki/notification/controller/NotificationDeleteIntegrationTest.kt
  • src/test/kotlin/com/depromeet/piki/notification/service/NotificationRetentionIntegrationTest.kt
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/main/kotlin/com/depromeet/piki/notification/config/NotificationProperties.kt
  • src/main/kotlin/com/depromeet/piki/notification/repository/NotificationRepositoryImpl.kt
  • src/main/kotlin/com/depromeet/piki/notification/repository/NotificationJpaRepository.kt
  • src/test/kotlin/com/depromeet/piki/notification/controller/NotificationDeleteIntegrationTest.kt

- purgeOlderThan 이 affectedUsers 를 돌며 유저마다 countUnreadByCategory 를 호출해, 보존 만료 알림이 많은 날 유저 수만큼 쿼리가 나가 트랜잭션이 길어지던 문제 수정
- countUnreadByCategoryForUsers(userIds) 를 추가 — userId·type 을 IN + GROUP BY 로 한 번에 집계한다. 안읽음 0 인 유저는 결과 행에 없으므로 대상 유저 전원을 모든 카테고리 0 으로 깔고 덮어, 모든 대상 유저 키를 보장하는 기존 계약을 유지한다
- 5c5f6d8 에서 countUnreadByCategoryForUsers(IN + GROUP BY) 를 추가했으나 git add 를 repository 디렉토리로만 해, 정작 호출부(NotificationRetentionService.purgeOlderThan)의 associateWith N+1 루프 → 배치 호출 교체가 커밋에서 빠졌다. 새 메서드가 dead code 로 남고 실제 경로엔 N+1 이 그대로였던 것을 CodeRabbit 리뷰가 잡았다
- purgeOlderThan 이 affectedUsers.associateWith { countUnreadByCategory(it) } 대신 countUnreadByCategoryForUsers(affectedUsers) 를 호출하도록 연결. affectedUsers 가 비면 배치 메서드가 emptyMap 을 반환해 별도 분기도 불필요
- 기존 retention 테스트는 purgeOlderThan 의 affectedUnreadByUser 계산만 검증해, 스케줄러가 실제로 SSE·FCM 배지를 쏘는 wiring 이 회귀해도 통과하는 사각이 있었다(CodeRabbit nitpick)
- 실 빈 + 실제 커밋 + Awaitility 로 cleanup() 실행 후, 안읽음이 지워진 userA 에겐 SSE(UnreadCountChanged unreadCount=1)·FCM(badge=1)이 전파되고, 읽음만 지워진 userB 에겐 어느 쪽도 호출되지 않음을 검증. 내부 협력자 mock 없이 side effect 로 확인(규약 준수, NotificationBadgeSyncAsyncIntegrationTest 와 동일 결)
@sevineleven

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review body 의 nitpick(스케줄러 배지 fan-out wiring 검증 테스트)을 반영했습니다 — NotificationCleanupSchedulerIntegrationTest 를 추가해, 실 빈 + 실제 커밋 + Awaitility 로 cleanup() 실행 후 안읽음이 지워진 유저엔 SSE(UnreadCountChanged unreadCount=1)·FCM(badge=1)이 전파되고 읽음만 지워진 유저엔 어느 쪽도 호출되지 않음을 검증합니다. 내부 협력자 mock 없이 side effect 로 확인해 테스트 규약도 준수합니다. Fixed in a084faf.

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
git fetch origin pull/752/head:pr752 2>&1 | tail -5
git checkout pr752 2>&1 | tail -5
fd NotificationCleanupSchedulerIntegrationTest
git log --oneline -5 a084faf 2>&1 || true

Length of output: 827


🏁 Script executed:

#!/bin/bash
cat -n src/test/kotlin/com/depromeet/piki/notification/service/NotificationCleanupSchedulerIntegrationTest.kt

Length of output: 6928


This test looks solid — it validates the exact fan-out wiring concern with real commits and Awaitility, without mocking internal collaborators, staying true to the project's testing conventions.

@sevineleven 확인했습니다. NotificationCleanupSchedulerIntegrationTest가 nitpick에서 제기한 스케줄러 배지 fan-out wiring을 정확히 검증하네요:

  • userA(오래된 안읽음 + 최근 안읽음)는 정리 후 안읽음 1건이 남아 SSE(UnreadCountChanged.unreadCount=1)와 FCM(badge=1) 양쪽에 전파됨을 확인
  • userB(오래된 읽음만 있음)는 배지가 불변이므로 SSE/FCM 모두 호출되지 않음을 확인
  • @Async 배지 동기화 특성을 고려해 실제 커밋 + Awaitility.await().untilAsserted로 비동기 완료를 기다리는 패턴을 기존 NotificationBadgeSyncAsyncIntegrationTest와 동일하게 맞춘 점도 좋습니다
  • 내부 협력자를 mock 하지 않고 CleanupBadgeRecordingEmitter(SseEmitter 상속)와 StubFcmMessageSender 콜백으로 side effect를 그대로 관찰하는 방식이라 테스트 규약도 잘 지켰습니다

한 가지만 짚어드리면, created_at@CreatedDate·updatable=false라 JDBC native SQL로 직접 나이를 조작하는 부분(age())이 있는데, 이건 기존 NotificationRetentionIntegrationTest에서도 쓰인 패턴이라 일관성 있게 잘 재사용하셨네요. 👍

작은 참고: finally 블록에서 cleanupRows로 정리하시는 점도 좋습니다만, 혹시 이 테스트가 다른 테스트와 병렬로 실행될 가능성이 있다면 UUID.randomUUID()로 격리했으니 문제는 없을 것 같습니다.

전체적으로 이슈가 잘 해소됐다고 판단합니다. 좋은 작업이었어요!

(=^・ω・^=) ノ彡☆

- 자동삭제 첫 실행은 30일 넘긴 백로그가 수백~수천 유저에 몰릴 수 있는데, 스케줄러가 유저당 @async 태스크(유저×2: SSE+FCM)를 한꺼번에 던져 notificationExecutor(pool 4·queue 200·포화 시 drop)를 넘겼다. 500명 실측 시 FCM 배지 전달률이 21.6%(392명 유실)였고, 같은 실행기를 쓰는 실시간 알림까지 굶길 수 있었다
- NotificationRetentionBadgeNotifier(단일 @async 배치)를 추가해 영향 유저를 한 워커에서 순차 전파한다. 어느 순간에도 실행기 점유 태스크가 1개뿐이라 drop 0, 실시간 알림 보존. 500명 재실측 전달률 100%
- 이를 위해 PushNotificationChannel 에 동기 syncBadgeBlocking 을 분리(@async syncBadge 가 위임)하고, SSE 는 LocalSseDelivery.deliverSilentSync 를 직접 호출. 단건·모두 삭제(NotificationDeleteOrchestrator)는 영향 유저 1명이라 기존 유저당 @async 를 유지한다
@sevineleven sevineleven merged commit b155fe6 into dev Jul 16, 2026
10 checks passed
@sevineleven sevineleven deleted the feat/notification-history-delete branch July 16, 2026 01:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 외부 가시적 새 기능

Projects

None yet

Development

Successfully merging this pull request may close these issues.

알림 히스토리 삭제 기능 (단건·모두·N일 자동삭제)

1 participant