docs(content-ranking): 랭킹 시간 정책 문서를 갱신한다

This commit is contained in:
2026-06-24 22:33:13 +09:00
parent d5f4dc529a
commit ce2b628cc2
2 changed files with 165 additions and 27 deletions

View File

@@ -2,9 +2,9 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** 홈 내부 랭킹 탭에서 `GET /api/v2/home/rankings/creators`로 KST 기준 지난 주 크리에이터 랭킹 상위 20명을 조회한다.
**Goal:** 홈 내부 랭킹 탭에서 `GET /api/v2/home/rankings/creators`로 KST 기준 지난 주 크리에이터 랭킹`visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷의 상위 20명을 조회한다.
**Architecture:** 공개 endpoint는 home 하위 URL을 사용하고, 클라이언트 API 표면(Controller, API 조합 Facade, DTO)은 기존 홈 API 관례에 맞춰 `kr.co.vividnext.sodalive.v2.api.home` 하위에 둔다. 랭킹 기능 본체(domain/application/port/persistence/scheduler)는 추천 기능과 분리된 `kr.co.vividnext.sodalive.v2.ranking` 하위에 둔다. 주간 스냅샷 생성 작업이 KST 기간을 UTC DB 조회 조건으로 변환해 원천 데이터를 집계하고, 조회 API는 최신 완료 주차 스냅샷을 우선 읽어 응답을 조립한다. 단, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있다.
**Architecture:** 공개 endpoint는 home 하위 URL을 사용하고, 클라이언트 API 표면(Controller, API 조합 Facade, DTO)은 기존 홈 API 관례에 맞춰 `kr.co.vividnext.sodalive.v2.api.home` 하위에 둔다. 랭킹 기능 본체(domain/application/port/persistence/scheduler)는 추천 기능과 분리된 `kr.co.vividnext.sodalive.v2.ranking` 하위에 둔다. 주간 스냅샷 생성 작업이 KST 기간을 UTC DB 조회 조건으로 변환해 원천 데이터를 집계하고, 조회 API는 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 읽어 응답을 조립한다. 단, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있고, fallback 응답도 공개 노출 전환 시각을 넘긴 기간에만 허용한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, QueryDSL 또는 native SQL, JUnit 5, Gradle Wrapper
@@ -15,13 +15,19 @@
- API endpoint: `GET /api/v2/home/rankings/creators`
- 랭킹 기능 본체 패키지: `kr.co.vividnext.sodalive.v2.ranking`
- 홈 공개 API 조립 패키지: `kr.co.vividnext.sodalive.v2.api.home`
- 집계 기간: 조회/스냅샷 생성 시점 기준 KST 지난 주 월요일 00:00:00 이상, 이번 주 월요일 00:00:00 미만
- 집계 기준 시각: 매주 월요일 00:00:00 KST. 이 시각을 주간 집계 종료 경계로 사용한다.
- 집계 기간: 생성 시점 기준 KST 지난 주 월요일 00:00:00 이상, 이번 주 월요일 00:00:00 미만
- DB 조회 기간: KST 집계 기간을 UTC 기준 `LocalDateTime` 또는 프로젝트 표준 시간 타입으로 변환한 기간
- 스냅샷 생성 스케줄 후보: 매주 월요일 KST 07:30, `@Scheduled(cron = "0 30 7 * * MON", zone = "Asia/Seoul")`
- 스냅샷 생성 스케줄 후보: 매주 월요일 KST 01:00, `@Scheduled(cron = "0 0 1 * * MON", zone = "Asia/Seoul")`
- 스냅샷 노출 전환 시각: 매주 월요일 KST 09:00. 스냅샷과 job 이력에 `visibleFromAt`으로 저장한다.
- 현재 기본 크리에이터 랭킹 타입: `WEEKLY`. 스냅샷과 job 이력에 `rankingType`으로 저장한다.
- 다중 서버 인스턴스에서 스냅샷 스케줄러가 중복 실행되지 않도록 기존 Redisson 기반 분산 lock을 사용한다.
- 랭킹 스냅샷 lock key는 `lock:creator-ranking-snapshot-refresh`로 고정하고, lock 획득 실패 인스턴스는 정상 skip한다.
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 완료 주차 스냅샷 기준 응답을 유지한다.
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 응답한다.
- 조회 시 09:00 KST 전에는 01:00 KST에 생성된 새 주차 스냅샷이 있어도 직전 공개 스냅샷을 유지한다.
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있다.
- fallback 응답도 fallback 대상 기간의 `visibleFromAt <= now` 조건을 만족할 때만 공개한다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 공개 스냅샷 기준 응답을 유지한다.
- 스냅샷 테이블이 완전히 비어 있는 cold-start fallback 성공 시 조회 API는 fallback 응답을 반환하고, 같은 집계 기간의 스냅샷 생성은 조회 서비스가 직접 저장하지 않고 `CreatorRankingSnapshotJobService`/`CreatorRankingSnapshotRefreshService` 책임으로 위임한다.
- cold-start fallback 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 보강책이며, 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용한다.
- 스냅샷 생성 직전 집계 시작/종료 시각을 포함한 job 이력을 생성하고, 스케줄 실행과 관리자 수동 생성 모두 성공/실패 상태를 기록한다.
@@ -32,7 +38,7 @@
- raw value 방식으로 계산하며 0~100 정규화는 하지 않는다.
- 스냅샷 저장 대상은 20위 점수보다 높은 후보와 20위 점수에 동점인 후보 전체로 제한한다.
- 동점자는 조회 시 랜덤 정렬로 상위 20명을 추출하고, 별도 `randomTieBreaker`는 저장하지 않는다.
- 직전 완료 주차 스냅샷이 없으면 `showRankChange=false`, `rankChange=null`, `isNew=false`로 응답한다.
- 직전 공개 스냅샷이 없으면 `showRankChange=false`, `rankChange=null`, `isNew=false`로 응답한다.
- 비활성 및 탈퇴 크리에이터는 랭킹에 노출하지 않는다.
- 차단 관계가 있으면 row는 유지하되 `creatorId=0`, `nickname=""`, `profileImageUrl=기본 이미지 URL`로 마스킹한다.
- 신규 팔로우 수는 `CreatorFollowing.createdAt` 기준, 언팔로우 수는 `CreatorFollowing.isActive == false``CreatorFollowing.updatedAt` 기준으로 계산한다.
@@ -92,6 +98,22 @@
---
## 1.1 DDL 영향도: `visible_from_at`, `ranking_type`
- `creator_ranking_snapshot`에는 `ranking_type varchar(30) not null`, `visible_from_at timestamp not null`을 추가한다.
- `creator_ranking_snapshot_job`에는 `ranking_type varchar(30) not null`, `visible_from_at timestamp not null`을 추가한다.
- 현재 기본 타입 값은 `WEEKLY`로 문서화하고, 코드 구현 시 `CreatorRankingType` 또는 동등한 enum/상수로 고정한다.
- `visible_from_at`은 집계 종료일 월요일 09:00:00 KST를 UTC로 변환한 값이다. 예: 2026-06-08 09:00:00 KST는 2026-06-08 00:00:00 UTC다.
- `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`의 기존 CREATE DDL은 이미 적용된 기준으로 유지하고, 하단에 운영 반영용 ALTER DDL을 추가한다.
- 운영 DB 변경은 `ADD nullable column -> backfill -> MODIFY NOT NULL -> index 보강/교체` 순서로 적용한다.
- backfill은 `ranking_type='WEEKLY'`, `visible_from_at=aggregation_end_at_utc + interval 9 hour` 기준으로 수행한다.
- 같은 타입/기간 재생성 삭제 기준은 `ranking_type + aggregation_start_at_utc + aggregation_end_at_utc`다.
- 중복 방지 기준은 `ranking_type + aggregation_start_at_utc + aggregation_end_at_utc + creator_id` unique index다.
- 최신 공개 스냅샷 조회는 `ranking_type = WEEKLY and visible_from_at <= nowUtc` 조건에서 가장 큰 `visible_from_at`을 찾은 뒤 해당 스냅샷 row를 `final_score desc` 기준으로 읽는다.
- 직전 공개 스냅샷 조회는 최신 공개 스냅샷보다 작은 `visible_from_at` 중 가장 큰 값을 기준으로 읽는다.
- job 목록/재시도 조회는 `ranking_type + aggregation period + status`, `ranking_type + visible_from_at + status`, `ranking_type + aggregation period + trigger_type + created_at` 인덱스를 사용한다.
- 공개 API 응답 DTO에는 `rankingType`, `visibleFromAt`, 집계 기간, fallback 여부를 노출하지 않는다.
### Phase 1: 기간/점수 도메인 정책
- [x] **Task 1.1: KST 주간 기간 산출과 UTC 조회 기간 변환 정책 작성**
@@ -445,6 +467,90 @@
- REFACTOR: 검증 기록에 실행 명령, 목적, 결과를 누적한다.
- 기대 결과: cold-start 스냅샷 생성 보강이 기존 스케줄/관리자/조회 경로를 깨지 않는다.
### Phase 12: 크리에이터 랭킹 시간 정책 변경
> Phase 1~11은 완료 당시의 구현 이력이다. 시간 정책 변경은 완료된 task를 다시 수행하는 방식이 아니라, Phase 12에서 기존 07:30 생성 스케줄, 최신 완료 주차 조회, 기존 DDL/엔티티/port 구조를 `01:00 생성 후보`, `09:00 노출 전환`, `visibleFromAt <= now` 최신 공개 스냅샷 조회 기준으로 변경한다.
- [ ] **Task 12.1: 집계/생성/노출 시각 분리 정책 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingPeriodPolicyTest.kt`
- RED: 월요일 00:00:00 KST를 집계 종료 경계로 유지하고, 집계 종료일 월요일 09:00:00 KST가 `visibleFromAtUtc`로 변환되는 테스트를 작성한다. 2026-06-08 09:00:00 KST가 2026-06-08 00:00:00 UTC로 변환되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicyTest`
- GREEN: `resolveVisibleFromAtUtc(aggregationEndAtKst)` 또는 동등한 메서드를 추가하고, 기존 집계 기간 산출은 변경하지 않는다.
- REFACTOR: 생성 후보 시각(01:00 KST)은 scheduler 책임으로 두고, period policy는 집계 기간과 공개 노출 시각 계산에 집중한다.
- 기대 결과: 집계 기준 시각과 공개 노출 전환 시각이 코드와 테스트에서 분리된다.
- [ ] **Task 12.2: `rankingType`, `visibleFromAt` 스냅샷/job 저장 구조 반영**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJob.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotJobPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepository.kt`
- Modify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepositoryTest.kt`
- RED: 스냅샷과 job record가 `rankingType=WEEKLY`, `visibleFromAtUtc`를 저장하고, 같은 타입/기간/크리에이터 중복 저장이 불가능하며, 같은 타입/기간 replace가 기존 row를 제거하는 repository 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest`
- GREEN: entity/record/port에 `rankingType`, `visibleFromAtUtc`를 추가하고, 운영 DB 변경용 ALTER DDL을 문서화한다. 기본 타입 `WEEKLY`를 생성/조회 경로에 전달한다.
- REFACTOR: DDL 컬럼명은 `ranking_type`, `visible_from_at`으로 유지하고, Kotlin 필드명은 기존 시간 필드 관례에 맞춰 `visibleFromAtUtc`로 둔다.
- 기대 결과: 스냅샷과 job 이력이 공개 노출 기준으로 조회될 수 있는 데이터를 가진다.
- [ ] **Task 12.3: 스냅샷 생성 스케줄을 월요일 01:00 KST로 변경**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
- RED: scheduler method에 `@Scheduled(cron = "0 0 1 * * MON", zone = "Asia/Seoul")`가 선언되어 있는지 검증하고, 스케줄 job이 `visibleFromAtUtc`를 월요일 09:00 KST 기준으로 저장하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
- GREEN: 기존 07:30 cron을 01:00 cron으로 변경하고, refresh/job 생성 경로에 `visibleFromAtUtc`를 전달한다.
- REFACTOR: lock key는 기존 중복 실행 방지 정책을 유지하되, 기간 기반 lock 내부에서 `rankingType`이 필요한 경우 lock key에 포함할지 테스트로 고정한다.
- 기대 결과: 생성 후보 시각이 집계 종료 1시간 뒤로 당겨져도 공개 노출은 09:00까지 지연된다.
- [ ] **Task 12.4: 조회 API를 최신 생성 스냅샷이 아닌 최신 공개 스냅샷 기준으로 변경**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- RED: 01:00 KST에 새 스냅샷이 생성되어도 08:59:59 KST 조회는 직전 공개 스냅샷을 반환하고, 09:00:00 KST 조회는 새 스냅샷을 반환하는 테스트를 작성한다. 직전 공개 스냅샷 기준 `rankChange`, `isNew`, `showRankChange` 계산도 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
- GREEN: snapshot port에 `findLatestVisibleSnapshots(rankingType, nowUtc)``findPreviousVisibleSnapshots(rankingType, nowUtc)` 또는 동등한 메서드를 추가하고, query service가 이 메서드만 사용하도록 변경한다.
- REFACTOR: 기존 `findLatestSnapshots()`/`findPreviousCompletedSnapshots()`가 더 이상 공개 조회에 쓰이지 않으면 제거하거나 관리자/테스트 전용으로 명확히 제한한다.
- 기대 결과: 공개 API가 latest generated가 아니라 latest visible 스냅샷만 응답한다.
- [ ] **Task 12.5: cold-start fallback 공개 노출 조건 보강과 회귀 검증**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
- RED: 스냅샷 테이블이 완전히 비어 있어도 fallback 대상 기간의 `visibleFromAtUtc > nowUtc`이면 새 주차 결과를 응답하지 않는 테스트를 작성한다. `visibleFromAtUtc <= nowUtc`이면 기존 fallback 응답과 스냅샷 생성 위임이 유지되는지도 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
- GREEN: fallback 집계 전에 공개 가능 여부를 검사하고, 공개 불가 시 빈 응답 또는 직전 공개 스냅샷 응답을 유지한다.
- REFACTOR: 공개 API 응답 DTO에는 `visibleFromAtUtc`, `rankingType`, fallback 여부를 추가하지 않는다.
- 기대 결과: 초기 상태 보강책도 09:00 공개 전환 정책을 우회하지 않는다.
- [ ] **Task 12.6: 시간 정책 변경 문서/DDL 정합성 검증**
- Files:
- Verify: `docs/20260608_크리에이터_랭킹/prd.md`
- Verify: `docs/20260608_크리에이터_랭킹/plan-task.md`
- Verify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
- Modify: `docs/20260608_크리에이터_랭킹/plan-task.md`
- RED: 테스트 작성 예외. `TDD 예외 사유`: 문서와 DDL 변경 범위 검증 task다.
- 대체 검증 방법:
- `rg -n "07:30|01:00|09:00|visibleFromAt|visible_from_at|rankingType|ranking_type|latest visible|최신 공개|최신 생성" docs/20260608_크리에이터_랭킹`
- `rg -n "visible_from_at|ranking_type|creator_ranking_snapshot|creator_ranking_snapshot_job" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
- `./gradlew tasks --all`
- GREEN: 문서에 남은 07:30 표현은 과거 검증 기록 또는 기존 정책 언급인지 확인하고, 현재 목표/신규 task에는 01:00 생성과 09:00 노출 전환만 남긴다.
- REFACTOR: 검증 기록에 실행 명령, 목적, 결과를 누적한다.
- 기대 결과: PRD, 구현 계획, DDL이 같은 시간 정책과 공개 조회 기준을 설명한다.
---
## 2. PRD 요구사항 추적
@@ -455,8 +561,8 @@
- Feature D: Task 1.2, Task 3.3, Task 4.1에서 채널 후원 캔/건수와 최상위 팬 Talk 집계를 검증한다.
- Feature E: Task 1.2, Task 3.4, Task 4.1에서 최종 팔로우 수와 `createdAt`/`updatedAt` 기반 팔로우 증가 수를 검증한다.
- Feature F: Task 1.2, Task 4.1, Task 5.1에서 raw value 최종 점수, 1점 미만 제외, 20위 동점 후보 저장, 동점 랜덤 조회를 검증한다.
- Feature G: Task 5.1, Task 5.2에서 ranking 조회 결과와 차단 마스킹을 검증하고, Task 6.1, Task 6.2에서 홈 API endpoint, 응답 스키마, 인증/비인증 연결을 검증한다. Task 10.1, Task 10.2에서 스냅샷 테이블 완전 공백 상태의 제한적 fallback과 공개 응답 스키마 유지를 검증하고, Task 11.2에서 fallback 성공 후 응답을 깨지 않고 스냅샷 생성 책임을 위임하는 흐름을 검증한다.
- Feature H: Task 2.1, Task 2.2, Task 4.1, Task 4.2, Task 4.3에서 주간 스냅샷 저장, 스케줄, 클러스터 단일 실행 lock을 검증한다. Task 8.1, Task 8.2에서 스케줄 job 이력과 성공/실패 기록을 검증하고, Task 9.1, Task 9.2에서 관리자 날짜 범위 수동 생성과 실패 job 재시도 API를 검증한다. Task 11.1, Task 11.2에서 cold-start fallback 성공 후 기간 기반 lock으로 동일 기간 스냅샷 생성 중복을 방지하는 보강책을 검증한다.
- Feature G: Task 5.1, Task 5.2에서 ranking 조회 결과와 차단 마스킹을 검증하고, Task 6.1, Task 6.2에서 홈 API endpoint, 응답 스키마, 인증/비인증 연결을 검증한다. Task 10.1, Task 10.2에서 스냅샷 테이블 완전 공백 상태의 제한적 fallback과 공개 응답 스키마 유지를 검증하고, Task 11.2에서 fallback 성공 후 응답을 깨지 않고 스냅샷 생성 책임을 위임하는 흐름을 검증한다. Task 12.4, Task 12.5에서 조회 API가 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷만 응답하도록 검증한다.
- Feature H: Task 2.1, Task 2.2, Task 4.1, Task 4.2, Task 4.3에서 주간 스냅샷 저장, 스케줄, 클러스터 단일 실행 lock을 검증한다. Task 8.1, Task 8.2에서 스케줄 job 이력과 성공/실패 기록을 검증하고, Task 9.1, Task 9.2에서 관리자 날짜 범위 수동 생성과 실패 job 재시도 API를 검증한다. Task 11.1, Task 11.2에서 cold-start fallback 성공 후 기간 기반 lock으로 동일 기간 스냅샷 생성 중복을 방지하는 보강책을 검증한다. Task 12.1~12.6에서 집계 기준 00:00 KST, 생성 후보 01:00 KST, 노출 전환 09:00 KST, `rankingType`/`visibleFromAt` DDL 영향과 최신 공개 스냅샷 조회 정책을 검증한다.
- Feature I: Phase 5의 ranking 기능 본체는 `v2.ranking` 패키지 경계를 유지하고, Phase 6의 클라이언트 API 표면은 `v2.api.home` 하위에 둔다. Phase 8~10의 관리자/job/fallback 기능도 공개 API 응답 DTO를 변경하지 않는다.
---
@@ -577,3 +683,16 @@
- 2026-06-09: Phase 11 reviewer 2차 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 9s`를 확인했다.
- 2026-06-09: Phase 11 reviewer 2차 수정 후 Code Quality 재검토 결과 이전 blocking issue가 해소되어 `PASS` 판정을 확인했다.
- 2026-06-24: 크리에이터 랭킹 시간 정책 변경 문서 작업을 시작해 PRD에 집계 기준 00:00:00 KST, 생성 후보 01:00:00 KST, 노출 전환 09:00:00 KST, 최신 공개 스냅샷(`visibleFromAt <= now`) 조회 정책을 반영했다. `plan-task.md`에는 `visible_from_at`/`ranking_type` DDL 영향도와 신규 Phase 12 Task 12.1~12.6을 추가했다.
- 2026-06-24: 문서 정합성 확인: `rg -n "07:30|0 30 7|최신 완료|완료 주차" docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md` 실행 결과 현재 PRD에는 변경 전 정책 표현이 남아 있지 않고, `plan-task.md`의 남은 07:30/최신 완료 표현은 Phase 1~11 완료 당시 이력 또는 과거 검증 기록이며 Phase 12 note에서 신규 변경 범위를 구분했음을 확인했다.
- 2026-06-24: 시간 정책/DDL 키워드 확인: `rg -n "00:00:00 KST|01:00|09:00|visibleFromAt|visible_from_at|rankingType|ranking_type|최신 공개|최신 생성" docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 PRD, plan-task, DDL에 신규 시간 정책과 컬럼명이 반영됐음을 확인했다.
- 2026-06-24: DDL 핵심 컬럼 확인: `rg -n "visible_from_at|ranking_type|creator_ranking_snapshot|creator_ranking_snapshot_job" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 두 테이블의 `ranking_type`, `visible_from_at` 컬럼과 조회/관리 인덱스를 확인했다.
- 2026-06-24: 문서 변경 후 Gradle 명령 유효성 확인: `./gradlew tasks --all`은 sandbox 기본 권한에서 `~/.gradle` wrapper lock 파일 접근 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 2s`를 확인했다.
- 2026-06-24: 사용자 피드백에 따라 이미 적용된 `create-ranking-tables.sql`의 CREATE DDL 변경을 되돌리고, 파일 하단에 기존 적용 DB 변경용 ALTER DDL을 추가했다. 컬럼 추가는 기존 row를 고려해 nullable로 추가한 뒤 `WEEKLY``aggregation_end_at_utc + 9시간` 기준으로 backfill하고, 이후 `MODIFY NOT NULL` 및 인덱스 보강/교체를 수행하는 순서로 정리했다.
- 2026-06-24: 피드백 반영 후 문서/DDL 재검증: `git diff -- docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 CREATE DDL 본문은 변경하지 않고 하단 ALTER 섹션만 추가됐음을 확인했다. `rg -n "이미 위 CREATE DDL|alter table creator_ranking_snapshot|add column ranking_type|update creator_ranking_snapshot|modify column ranking_type|drop index|create index idx_creator_ranking_snapshot_visible_score|alter table creator_ranking_snapshot_job|idx_creator_ranking_snapshot_job_visible_status" docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 ALTER/backfill/modify/index 변경 순서를 확인했다.
- 2026-06-24: 피드백 반영 후 Gradle 명령 유효성 확인: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 808ms`를 확인했다.
- 2026-06-24: 완료된 Phase 본문 수정에 대한 혼동을 줄이기 위해 Phase 2.1, Phase 4.2, Phase 5.1의 완료 task 문구는 기존 이력대로 되돌리고, Phase 12 시작부에 “Phase 1~11은 완료 당시 구현 이력이며 시간 정책 변경은 Phase 12에서 수행한다”는 note를 추가했다.
- 2026-06-24: 완료 Phase 문구 원복 후 Gradle 명령 유효성 확인: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 823ms`를 확인했다.
- 2026-06-24: PRD/plan-task 크로스 체크 결과, PRD Feature A의 변경 전 기간 기준 표현이 09:00 공개 노출 전환 전 응답 정책과 충돌할 수 있어 “스냅샷 생성 또는 fallback 집계 기준 시점” 기준으로 수정했다. `rg -n "조회 시점 기준|2026-06-08 월요일 KST에 조회하면" docs/20260608_크리에이터_랭킹/prd.md`로 PRD 본문에 변경 전 표현이 남지 않았고, `rg -n "집계 기준 시각|생성 후보 시각|노출 전환 시각|visibleFromAt <= now|rankingType|visible_from_at|ranking_type|운영 반영용 ALTER" docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`로 PRD, plan-task, DDL의 요구사항 반영 지점을 확인했다.
- 2026-06-24: PRD/plan-task 크로스 체크 수정 후 Gradle 명령 유효성 확인: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 762ms`를 확인했다.

View File

@@ -1,7 +1,7 @@
# PRD: 크리에이터 랭킹
## 1. Overview
지난 주 월요일 00:00:00 KST부터 일요일 23:59:59.999999999 KST까지의 활동 데이터를 기준으로 크리에이터 랭킹 점수를 계산하고, 최종 점수 상위 20명을 조회할 수 있는 기능을 제공한다.
지난 주 월요일 00:00:00 KST부터 이번 주 월요일 00:00:00 KST 미만까지의 활동 데이터를 기준으로 크리에이터 랭킹 점수를 계산하고, 공개 노출 전환 시각이 지난 스냅샷의 최종 점수 상위 20명을 조회할 수 있는 기능을 제공한다.
---
@@ -22,6 +22,8 @@
- KST 기준 집계 시작/종료 시각을 UTC 기준 조회 시작/종료 시각으로 변환한 뒤 DB 데이터를 조회한다.
- 각 점수 카테고리의 원천 지표와 가중치를 테스트 가능한 형태로 관리한다.
- 조회 시 매번 무거운 원천 집계를 수행하지 않도록 주간 랭킹 계산 결과를 스냅샷으로 저장한다.
- 주간 랭킹 시간 정책을 집계 기준 시각, 스냅샷 생성 후보 시각, 공개 노출 전환 시각으로 분리한다.
- 조회 API는 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 기준으로 응답한다.
- 추후 성능 개선을 위해 캐시 저장소를 추가할 수 있는 포트 경계를 둔다.
---
@@ -57,14 +59,16 @@
### Feature A. 주간 랭킹 기간 산출
#### Requirements
- 랭킹 대상 기간은 조회 시점 기준 "지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만"으로 계산한다.
- 예를 들어 2026-06-08 월요일 KST에 조회하면 대상 기간은 2026-06-01 00:00:00 KST 이상, 2026-06-08 00:00:00 KST 미만이다.
- 랭킹 대상 기간은 스냅샷 생성 또는 fallback 집계 기준 시점의 "지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만"으로 계산한다.
- 예를 들어 2026-06-08 월요일 KST에 스냅샷을 생성하거나 fallback 집계를 수행하면 대상 기간은 2026-06-01 00:00:00 KST 이상, 2026-06-08 00:00:00 KST 미만이다.
- 집계 기준 시각은 매주 월요일 00:00:00 KST이며, 이 시각을 집계 종료 경계로 사용한다.
- 서버 기본 timezone이 UTC여도 기간 산출은 `Asia/Seoul` 기준으로 수행한다.
- DB와 서버 timezone은 UTC이므로, KST 기준 기간을 UTC 기준 `Instant` 또는 프로젝트 표준 시간 타입으로 변환해 DB 조회 조건에 사용한다.
- 예를 들어 2026-06-01 00:00:00 KST 이상, 2026-06-08 00:00:00 KST 미만은 2026-05-31 15:00:00 UTC 이상, 2026-06-07 15:00:00 UTC 미만으로 변환해 조회한다.
#### Edge Cases
- 월요일 00:00:00 KST 직후 조회해도 방금 시작한 이번 주 데이터가 포함되지 않아야 한다.
- 월요일 00:00:00 KST 이후 09:00:00 KST 전까지 조회해도 새로 종료된 주차가 공개 노출 전환 전이면 이전 공개 스냅샷을 응답해야 한다.
- 연도/월 경계를 넘어가는 주차도 동일한 규칙으로 계산한다.
- DST가 없는 KST 기준을 사용하되, 구현은 `ZoneId.of("Asia/Seoul")`처럼 명시적인 timezone을 사용한다.
@@ -147,14 +151,18 @@
#### Requirements
- 홈 내부 랭킹 탭에서 주간 크리에이터 랭킹 상위 20명을 조회하는 API를 제공한다.
- API endpoint는 `GET /api/v2/home/rankings/creators`를 사용한다.
- API는 최신 완료 주차의 스냅샷을 기준으로 조회하며 별도 query parameter 없이 기본 랭킹을 반환한다.
- API는 별도 query parameter 없이 기본 랭킹을 반환한다.
- API는 최신 생성 스냅샷이 아니라 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 기준으로 조회한다.
- 새 주차 스냅샷이 월요일 01:00:00 KST에 생성되었더라도 `visibleFromAt`인 월요일 09:00:00 KST 전에는 공개 조회에 사용하지 않는다.
- 예를 들어 2026-06-08 08:59:59 KST 조회는 2026-06-08 09:00:00 KST 공개 예정 스냅샷이 생성되어 있어도 직전 공개 스냅샷을 응답한다.
- 예를 들어 2026-06-08 09:00:00 KST 이후 조회는 해당 시각까지 공개된 최신 스냅샷을 응답한다.
- 응답에는 순위 변화 표시 여부, 순위, 지난 주 대비 순위 변화, 신규 진입 여부, 크리에이터 id, 닉네임, 프로필 이미지를 포함한다.
- `showRankChange``items`와 같은 레벨에 내려주며, 클라이언트가 순위 변화 UI를 표시할지 판단하는 값이다.
- 각 크리에이터의 순위 변화 값은 `items[].rankChange`에 숫자로 내려준다.
- 순위가 올라갔으면 양수, 순위가 내려갔으면 음수로 내려준다.
- 예를 들어 직전 완료 주차 10위, 최신 완료 주차 5위이면 `rankChange``5`다.
- 예를 들어 직전 완료 주차 1위, 최신 완료 주차 10위이면 `rankChange``-9`다.
- 직전 완료 주차에는 순위에 없고 최신 완료 주차에 진입한 크리에이터는 `items[].isNew == true`로 내려주며, 클라이언트는 이를 `New`로 표시한다.
- 예를 들어 직전 공개 스냅샷 10위, 최신 공개 스냅샷 5위이면 `rankChange``5`다.
- 예를 들어 직전 공개 스냅샷 1위, 최신 공개 스냅샷 10위이면 `rankChange``-9`다.
- 직전 공개 스냅샷에는 순위에 없고 최신 공개 스냅샷에 진입한 크리에이터는 `items[].isNew == true`로 내려주며, 클라이언트는 이를 `New`로 표시한다.
- 신규 진입 크리에이터의 `rankChange`는 비교 가능한 이전 순위가 없으므로 `null`로 내려준다.
- 응답의 크리에이터 id는 크리에이터 상세 이동에 사용한다.
- 응답 스키마 예시는 다음과 같다.
@@ -190,40 +198,50 @@
- 인증 사용자 조건이 필요하지 않은 공개 조회를 기본으로 하되, 차단 마스킹 정책은 인증 사용자에게 적용한다.
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 공개 API 응답 스키마는 fallback 여부와 관계없이 변경하지 않는다.
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 조회 API가 제한적으로 원천 데이터 fallback 집계를 시도할 수 있다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 완료 주차 스냅샷 기준으로 응답한다.
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서 fallback 집계가 성공하면, 조회 API는 응답을 반환하면서 스냅샷 생성 책임을 `CreatorRankingSnapshotRefreshService`/`CreatorRankingSnapshotJobService` 쪽으로 위임해 같은 기간의 `creator_ranking_snapshot` 생성을 트리거한다.
- fallback 응답도 공개 노출 전환 정책을 따라야 하며, fallback 대상 기간의 `visibleFromAt <= now` 조건을 만족하지 않으면 새 주차 결과를 응답하지 않는다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷 기준으로 응답한다.
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서 fallback 집계가 성공하면, 조회 API는 공개 가능한 기간의 응답을 반환하면서 스냅샷 생성 책임을 `CreatorRankingSnapshotRefreshService`/`CreatorRankingSnapshotJobService` 쪽으로 위임해 같은 기간의 `creator_ranking_snapshot` 생성을 트리거한다.
- cold-start fallback에서 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 상황을 위한 보강책이며, 장기 실시간 집계 경로로 사용하지 않는다.
- cold-start fallback 스냅샷 생성은 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용하고, lock 획득 실패 시 다른 요청 또는 작업이 처리 중인 정상 skip으로 간주한다.
#### Edge Cases
- 최종 점수 1점 이상인 랭킹 후보가 20명 미만이면 가능한 만큼만 내려준다.
- 랭킹 계산 결과가 없으면 빈 배열로 성공 응답한다.
- 최신 완료 주차 스냅샷이 없고 스냅샷 테이블도 완전히 비어 있으면 제한적 원천 데이터 fallback 집계를 시도한 뒤 결과를 응답한다.
- 공개 가능한 최신 스냅샷이 없고 스냅샷 테이블도 완전히 비어 있으며 fallback 대상 기간의 `visibleFromAt <= now` 조건을 만족하면 제한적 원천 데이터 fallback 집계를 시도한 뒤 결과를 응답한다.
- 공개 가능한 최신 스냅샷이 없고 fallback 대상 기간의 `visibleFromAt > now`이면 새 주차 결과를 조기 노출하지 않고 빈 배열로 성공 응답한다.
- fallback 성공 뒤 스냅샷 생성 트리거가 실패하더라도 공개 API 응답 스키마는 변경하지 않고, 실패는 로그/job 이력으로 추적한다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 완료 주차 스냅샷 기준 응답을 유지한다.
- 직전 완료 주차 스냅샷이 없으면 `showRankChange``false`로 내려주고, 각 item의 `rankChange``null`, `isNew``false`로 내려준다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 공개 스냅샷 기준 응답을 유지한다.
- 직전 공개 스냅샷이 없으면 `showRankChange``false`로 내려주고, 각 item의 `rankChange``null`, `isNew``false`로 내려준다.
### Feature H. 주간 랭킹 스냅샷
#### Requirements
- 주간 랭킹은 조회 시 매번 원천 데이터를 집계하지 않고, 계산 결과를 스냅샷으로 저장한 뒤 조회 API는 스냅샷을 읽는다.
- 스냅샷 생성 기준 기간은 KST 기준 지난 주 월요일 00:00:00 이상, 이번 주 월요일 00:00:00 미만이다.
- 주간 랭킹 시간 정책은 다음 세 시각을 분리한다.
- 집계 기준 시각: 매주 월요일 00:00:00 KST. 이 시각을 집계 종료 경계로 사용한다.
- 생성 후보 시각: 매주 월요일 01:00:00 KST. 스케줄러가 새 주차 스냅샷 생성을 시도하는 후보 시각이다.
- 노출 전환 시각: 매주 월요일 09:00:00 KST. 생성된 새 주차 스냅샷의 `visibleFromAt`으로 저장하고, 이 시각 이후 공개 조회에 사용한다.
- 스냅샷 생성 시 원천 데이터 조회 조건은 KST 집계 기간을 UTC로 변환한 기간을 사용한다.
- 스냅샷에는 `rankingType``visibleFromAt`을 저장한다.
- 현재 기본 크리에이터 랭킹의 `rankingType` 값은 `WEEKLY`로 시작하고, 향후 다중 크리에이터 랭킹 타입 확장 시 같은 스냅샷/job 구조를 재사용한다.
- 같은 랭킹 타입과 같은 집계 기간의 스냅샷을 재생성할 때는 기존 같은 `rankingType + aggregationStartAt + aggregationEndAt` row를 중복 노출하지 않는다.
- 스냅샷 저장 대상은 20위 점수보다 높은 후보와 20위 점수에 동점인 후보 전체로 제한한다.
- 최종 점수 1점 이상인 후보가 20명 미만이면 해당 후보만 저장한다.
- 스냅샷은 크리에이터 id, 최종 점수, 카테고리별 점수, 원천 지표, 집계 시작/종료 시각을 저장한다.
- 최종 순위는 스냅샷 저장 시 고정하지 않고 조회 시 최종 점수 내림차순과 동점 랜덤 정렬 결과에 따라 부여한다.
- 순위 변화는 최신 완료 주차 응답에서 부여된 순위와 직전 완료 주차 스냅샷 기준 순위를 비교해 계산한다.
- 순위 변화는 최신 공개 스냅샷 응답에서 부여된 순위와 직전 공개 스냅샷 기준 순위를 비교해 계산한다.
- 동점 랜덤 정렬 정책 때문에 동점 구간에 포함된 크리에이터의 순위와 순위 변화는 조회 결과마다 달라질 수 있으며, 이는 허용한다.
- 스냅샷 생성은 이번 주 데이터가 포함되지 않도록 주간 집계 대상 기간이 종료된 뒤 실행한다.
- 기본 스케줄 후보는 매주 월요일 KST 07:30이며, 스케줄러는 `Asia/Seoul` zone을 명시한다.
- 기본 생성 스케줄 후보는 매주 월요일 KST 01:00이며, 스케줄러는 `Asia/Seoul` zone을 명시한다.
- 다중 서버 인스턴스에서 같은 스케줄이 동시에 실행되더라도 클러스터 전체에서 한 인스턴스만 스냅샷 생성을 수행해야 한다.
- 클러스터 단일 실행은 신규 DB 테이블을 추가하지 않고, 기존 프로젝트에 설정된 Redisson 기반 분산 lock을 우선 사용한다.
- 주간 랭킹 스냅샷 lock key는 `lock:creator-ranking-snapshot-refresh`를 사용하며, lock 획득 실패 인스턴스는 스냅샷 생성을 skip한다.
- 같은 집계 기간에 대해 스냅샷을 재생성할 수 있어야 하며, 재생성 시 기존 같은 기간 스냅샷을 중복 노출하지 않는다.
- 조회 API는 최신 완료 주차의 스냅샷을 기준으로 응답한다.
- 같은 랭킹 타입과 같은 집계 기간에 대해 스냅샷을 재생성할 수 있어야 하며, 재생성 시 기존 같은 기간 스냅샷을 중복 노출하지 않는다.
- 조회 API는 `visibleFromAt <= now` 조건을 만족하는 최신 공개 스냅샷을 기준으로 응답한다.
- 스냅샷 생성 직전 집계 시작/종료 시각을 포함한 job 이력을 생성하고, 작업 완료 후 성공/실패 상태와 처리 결과를 기록한다.
- 스케줄러로 실행되는 주간 스냅샷 생성도 job 이력으로 기록한다.
- 스냅샷 job 이력에도 `rankingType``visibleFromAt`을 저장해 관리자 목록, 재시도, DDL 인덱스 기준이 스냅샷 테이블과 일치해야 한다.
- 운영자는 관리자 전용 API를 통해 날짜 범위를 직접 선택해 스냅샷 생성 job을 생성할 수 있어야 한다.
- 실패한 스냅샷 생성 job은 관리자 전용 재시도 API로 재시도할 수 있어야 하며, 기존 관리자 job 패턴과 같이 실패 상태 job을 대기 상태로 되돌려 worker가 다시 처리하도록 한다.
- 관리자 전용 job 목록 API는 날짜 범위, 실행 트리거, 상태, 실패 사유, 재시도 가능 여부를 확인할 수 있어야 한다.
@@ -232,10 +250,11 @@
- lock을 획득한 요청만 refresh job/service를 실행하고, lock을 획득하지 못한 요청은 이미 다른 실행자가 처리 중인 것으로 보고 fallback 응답만 반환한다.
#### Edge Cases
- 최신 완료 주차 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으면 제한적 원천 데이터 fallback 집계를 시도하고, fallback 성공/실패를 장애 추적용 로그로 남긴다.
- 최신 공개 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으며 fallback 대상 기간의 `visibleFromAt <= now` 조건을 만족하면 제한적 원천 데이터 fallback 집계를 시도하고, fallback 성공/실패를 장애 추적용 로그로 남긴다.
- fallback 성공 후 스냅샷 저장 트리거는 실패하더라도 조회 응답을 실패시키지 않되, job 상태 또는 구조화 로그로 실패를 추적할 수 있어야 한다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 완료 주차 스냅샷 기준 응답을 유지한다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 공개 스냅샷 기준 응답을 유지한다.
- 스냅샷 생성 중 일부 원천 집계가 실패하면 해당 주차 스냅샷 저장을 실패 처리하고 부분 결과를 공개하지 않는다.
- 스냅샷 생성은 성공했지만 `visibleFromAt > now`이면 해당 스냅샷은 공개 조회 대상에서 제외한다.
- Redisson lock 획득 실패는 다른 인스턴스가 같은 작업을 수행 중인 정상 skip으로 처리하고, 스냅샷 생성 실패로 집계하지 않는다.
- 실패 job 재시도 API는 실패 상태 job만 대상으로 하며, 이미 대기/처리 중/성공 상태인 job은 재시도 대상으로 변경하지 않는다.