diff --git a/.specify/feature.json b/.specify/feature.json index 9a5903a..6074fb8 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/005-async-verification-pipeline" + "feature_directory": "specs/006-statistics-cache-versioning" } diff --git a/CLAUDE.md b/CLAUDE.md index 737301d..1439f1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,15 +2,15 @@ For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan: -- Active spec: `specs/005-async-verification-pipeline/` -- Plan: `specs/005-async-verification-pipeline/plan.md` -- Research: `specs/005-async-verification-pipeline/research.md` -- Data model: `specs/005-async-verification-pipeline/data-model.md` -- Contracts: `specs/005-async-verification-pipeline/contracts/` - (rest-api.md · stream-message.md) -- Quickstart: `specs/005-async-verification-pipeline/quickstart.md` +- Active spec: `specs/006-statistics-cache-versioning/` +- Plan: `specs/006-statistics-cache-versioning/plan.md` +- Research: `specs/006-statistics-cache-versioning/research.md` +- Data model: `specs/006-statistics-cache-versioning/data-model.md` +- Contracts: `specs/006-statistics-cache-versioning/contracts/` + (cache-key.md) +- Quickstart: `specs/006-statistics-cache-versioning/quickstart.md` - Constitution: `.specify/memory/constitution.md` -- Prior specs (foundation·infra): `specs/001-secure-logging-test-foundation/`, `specs/002-outbox-republisher/` +- Prior specs (foundation·infra): `specs/001-secure-logging-test-foundation/`, `specs/002-outbox-republisher/`, `specs/005-async-verification-pipeline/` ## 하네스: SDD 구현 팀 diff --git a/specs/006-statistics-cache-versioning/_harness/claude-review.md b/specs/006-statistics-cache-versioning/_harness/claude-review.md new file mode 100644 index 0000000..9a6369e --- /dev/null +++ b/specs/006-statistics-cache-versioning/_harness/claude-review.md @@ -0,0 +1,105 @@ +# Claude 리뷰 — Spec 006: 사용자 통계 캐시 stale-cache 해소 (통계 버전 기반 캐시 키) + +> 헌법 원칙 VI(듀얼 AI 리뷰) Claude 측 산출물. PR 본문에 Codex 리뷰와 병기. +> 대상: Wave 1+2+3 전체. **Build GREEN — 179 tests, 인수 테스트 8/8 통과, verifySecretLogScan clean.** + +## 최종 권고: ✅ MERGE 가능 (P1 = 0) + +7원칙 1차 게이트 위반 0건. 인수기준(SC-001~005·FR-007·FR-008·콜드스타트) 전부 자동 테스트로 약화 없이 박제. 잔여는 머지 비차단 P3 1건(직렬화 결속 백로그). + +--- + +## 1. 핵심 설계 요약 + +마이페이지 통계(`GET /api/v1/members/mypage`) 캐시가 24h TTL 동안 stale 해지는 문제를 **캐시 키에 "통계 버전"을 포함**해 해소한다. + +- **버전 = 직전 완료 `progressCalculation` 배치의 기준일**(`yyyy-MM-dd`, Asia/Seoul), `JobLog` 에서 DB 파생. `MAX(endTime) where jobType='progressCalculation' and endTime is not null` (QueryDSL 스칼라 집계). 완료 0건이면 오늘(콜드스타트 잠정). +- **완료 후에만 전환**: production 은 `finish()`(endTime set) 후에만 JobLog 를 save → 진행 중(endTime=null) row 미영속 → 새 날 배치 완료 전까지 버전이 어제로 유지 → 자정 직후 중간 상태가 새 버전으로 캐싱되지 않음(US2). +- **Caffeine 로컬 → Redis 공유 캐시 이관**: 다중 인스턴스가 동일 버전 키 공유, 전역 1회만 재계산. +- **버전 키**: `#memberId + ':' + #version` → 물리 키 `challenge-avg::{memberId}:{version}`. 버전 바뀌면 자연 miss → 재계산. 전수 evict 불요(과거 키 TTL 25h 자연 만료). +- **single-flight**: `lockingRedisCacheWriter` + `@Cacheable(sync=true)` 결합 → 동일 키 동시 첫 조회 Flask 1회 수렴. +- **실패 비캐싱**: Flask 예외(또는 member 미존재) 전파 → `@Cacheable` 미저장 → 다음 요청 재계산. +- **버전 캐시 없음(R4)**: 매 요청 인덱스된 `MAX(endTime)` 1건 읽기로 직접 파생 → stale 창 0 수렴. + +신규 `build.gradle` 의존성 0, 신규 테이블 0. `JobLog` 에 `(job_type, end_time)` 복합 인덱스만 추가. + +--- + +## 2. 헌법 7원칙 점검 + +| 원칙 | 결과 | 근거 | +|------|------|------| +| **I. Testcontainers 통합테스트** | ✅ PASS | 신규 인수 테스트 8 메서드 모두 `IntegrationTest`(싱글톤 MySQL 8.0.36 + Redis 7-alpine) 확장. Redis 키/TTL·JobLog 시드를 실제 컨테이너로 검증. Flask 만 `@MockBean`(비 MySQL/Redis 어댑터 — 의무 대상 아님, R10). 로컬 데몬 불요. | +| **II. 외부 의존 어댑터 격리** | ✅ PASS | `StatisticsVersionProvider`=JPA(DB) 파생, `MemberStatisticsCacheService`=`@Cacheable` 추상화+`FlaskApiClient`(infra). 도메인/서비스의 `RedisTemplate` 직접 호출 0. `CacheConfig` 의 `RedisConnectionFactory`/`RedisCacheWriter` 는 config 레이어 허용 범위. | +| **III. QueryDSL Projections** | ✅ PASS | `JobLogRepositoryCustomImpl`=`select(jobLog.endTime.max())` 스칼라 집계. Entity 반환·서비스단 수동 매핑 없음, N+1 무관. | +| **IV. Outbox 경유 발행** | ➖ N/A | 메시지 발행 없음. | +| **V. 민감정보 로그 금지** | ✅ PASS | 신규/변경 코드에 로깅 추가 0건. verifySecretLogScan clean. | +| **VI. 듀얼 AI 리뷰** | ⏳ 본 문서 + Codex | 본 Claude 리뷰 + Codex 리뷰 PR 병기. | +| **VII. 인수기준=자동테스트** | ✅ PASS | SC-001~005·FR-007·FR-008·콜드스타트 → 테스트 메서드 1:1 박제(아래 §4). | + +기술 스택/라이브러리: 신규 의존성 0 → "라이브러리 도입 규칙" 미트리거. 운영 안티패턴(ddl-auto=update·show-sql) 신규 도입 0. + +--- + +## 3. 변경 파일 전체 목록 + +**src/main (신규 4)** +- `member/service/StatisticsVersionProvider.java` — 버전 해석(매 요청 DB 파생, Asia/Seoul, 콜드스타트=오늘) +- `member/service/MemberStatisticsCacheService.java` — `@Cacheable(challenge-avg, key=memberId:version, sync=true)` 캐시 서비스(자기호출 회피용 별도 빈) +- `scheduler/log/JobLogRepositoryCustom.java` — QueryDSL custom 인터페이스 +- `scheduler/log/JobLogRepositoryCustomImpl.java` — `MAX(endTime)` 구현 + +**src/main (수정 5)** +- `core/config/CacheConfig.java` — Caffeine `CacheManager` → `RedisCacheManager`(TTL 25h, JSON 직렬화, lockingRedisCacheWriter) +- `member/service/MemberServiceImpl.java` — `getMyProgressAvgPer`: `@Cacheable` 제거, 버전 해석 → 캐시 서비스 위임 +- `member/service/dto/GetMyProgressAvgDto.java` — JSON round-trip 대응(`@NoArgsConstructor(PROTECTED)` + `@JsonAutoDetect(ANY)`) +- `scheduler/log/JobLog.java` — `(job_type, end_time)` 복합 인덱스 추가 +- `scheduler/log/JobLogRepository.java` — `JobLogRepositoryCustom` 결합 +- `PlanetrushApplication.java` — `@EnableCaching` 중복 제거(RedisConfig 단일화) + +**src/test (신규 2)** +- `member/StatisticsCacheVersionIntegrationTest.java` — 인수 테스트 8 메서드 +- `member/testsupport/StatisticsCacheTestSupport.java` — JobLog/Member 시드 · Redis 키/TTL 헬퍼 + +**src/test (수정 1)** +- `member/MemberIntegrationTest.java` — Redis+버전키 이관 회귀 수정(캐시 내부조회→행위 검증, 참조동등→값 동등 `usingRecursiveComparison`) + +--- + +## 4. 인수기준 ↔ 테스트 최종 매핑 (헌법 VII 증빙) + +모든 테스트는 `StatisticsCacheVersionIntegrationTest`(MySQL+Redis Testcontainers). + +| 인수기준 | 테스트 메서드 | 검증 방식(행위 기반) | +|---|---|---| +| **SC-001** 배치 완료 후 첫 조회 갱신값 100%(옛 값 0%) | `should_recompute_on_first_read_after_version_transition_and_hit_within_same_version` | D 첫 조회 MISS(Flask×1, 10.0) → 동일 버전 HIT(×1 유지) → D+1 전환 재계산(×2, 20.0) | +| **SC-002** 중간 상태 고정 0건 | `should_not_cache_intermediate_state_while_batch_in_progress` | D+1 진행 중(완료 row 부재)=버전 D HIT, **D+1 키 미생성** → 완료 후에야 재계산 | +| **SC-003** 사용자별 evict 0회 | `should_keep_old_version_key_without_evict_on_version_transition` | 전환 후 구 키(:D)·새 키(:D+1) **둘 다 Redis 잔존**, evict 미호출 | +| **SC-004** 동시 첫 조회 Flask 1회 수렴 | `should_converge_flask_call_to_once_on_concurrent_first_reads` | 16 스레드 동시 출발(latch)+Flask 200ms in-flight → `verify times(1)` | +| **SC-005** 과거 버전 TTL 자연 만료 | `should_set_ttl_around_25h_on_cache_key` | 키 TTL `> 82800s & <= 90000s`(25h≈) — 무만료(-1)/미존재(-2) 배제 | +| **FR-007** 실패 비캐싱·재계산 | `should_not_cache_failure_and_allow_retry_on_next_request` | Flask 예외 전파 + **키 미생성**, 다음 요청 재시도(`times(2)`) | +| **FR-008** 전역 일관(결정성) | `should_return_deterministic_version_for_same_db_state` | 동일 DB 상태 연속 호출 동일 버전 반환(매 요청 DB 파생 → 전역 수렴 근거) | +| **콜드스타트(Q3)** 완료 0건=오늘 잠정 버전 | `should_use_today_as_version_on_cold_start_with_no_completed_batch` | 완료 0건 → `getCurrentVersion()`=오늘(Asia/Seoul), 오늘 버전 키 생성 | + +> SC-004 single-flight 는 `MemberIntegrationTest` 동시성 회귀 테스트로도 교차 커버. +> 단일 JVM 한계상 FR-008(다중 인스턴스 동일 인식)·SC-004(교차 인스턴스 락)은 결정성/인스턴스 내 수렴으로 대체 검증 — 코드(매 요청 DB 파생·lockingRedisCacheWriter)가 구조적으로 보장, 테스트 주석에 한계 명시. + +--- + +## 5. 잔여 백로그 (머지 비차단 P3) + +1. **P3-1 직렬화 `@class` 결속 취약성** — `GetMyProgressAvgDto` / `GenericJackson2JsonRedisSerializer`. JSON 에 FQN(`@class`)이 박혀, 향후 DTO 이동/리네임 시 TTL(≤25h) 내 잔존 캐시 항목이 HIT 시 역직렬화 예외 → read 경로 500 위험. 현재 round-trip 테스트로 정상 확인됨(허용된 알려진 트레이드오프). 클래스 진화 예상 시 "역직렬화 실패→miss 취급" 복원력 또는 버전 태그 직렬화를 후속 도입 권고. +2. **P3(경미) SC-005 TTL 하한 느슨** — 23h 하한을 더 좁혀도 됨(무만료 배제 목적은 충족). +3. **P3(구조적) 교차 인스턴스 미검증** — FR-008·SC-004 의 다중 인스턴스 측면은 단일 JVM 테스트 한계. 인지 항목(코드 변경 불요). + +> **해소 완료**: P3-2(@EnableCaching 중복) — `PlanetrushApplication` 에서 제거, `RedisConfig` 단일 출처로 정리됨. + +--- + +## 6. 종합 + +- 헌법 7원칙 위반 **0**, P1 **0**, P2 **0**. +- 인수기준 8종 전부 Testcontainers 기반 행위 테스트로 박제(헌법 I·VII). +- 설계가 spec Clarifications(Q1~Q3)·research R1~R10·contracts(C1~C3, F1~F5)와 정합. +- **머지 권고**: 승인. P3-1 은 후속 백로그로 추적. + diff --git a/specs/006-statistics-cache-versioning/_harness/phase1-review.md b/specs/006-statistics-cache-versioning/_harness/phase1-review.md new file mode 100644 index 0000000..89a7d88 --- /dev/null +++ b/specs/006-statistics-cache-versioning/_harness/phase1-review.md @@ -0,0 +1,39 @@ +# Spec 006 — Phase 1 (Wave 1) Code Review + +**Scope**: Setup + Foundational + US1 (SC-001, FR-008). Build GREEN (173 tests, 0 fail, verifySecretLogScan clean). +**Verdict**: **P1 = 0, P2 = 0, P3 × 3.** Phase 게이트 PASS. + +## 헌법 7원칙 + +| 원칙 | 결과 | 근거 | +|------|------|------| +| I Testcontainers | ✅ PASS | `StatisticsCacheVersionIntegrationTest extends IntegrationTest`(MySQL+Redis 싱글톤). Flask만 @MockBean(어댑터, 컨테이너 의무 대상 아님) | +| II 어댑터 격리 | ✅ PASS | 버전=JobLogRepository(JPA/DB), 캐시=@Cacheable 추상화. 도메인/서비스 RedisTemplate 직접접근 0. CacheConfig의 RedisCacheWriter는 config 레이어(허용) | +| III QueryDSL | ✅ PASS | `select(jobLog.endTime.max())` 스칼라 집계, Entity→DTO 수동매핑 없음, Optional.ofNullable | +| IV Outbox | ➖ N/A | 메시지 발행 없음 | +| V 시크릿 로그 | ✅ PASS | 신규 로깅 0건, verifySecretLogScan clean | +| VI 듀얼 리뷰 | ⏳ | PR 단계 | +| VII 인수=테스트 | ✅ PASS | SC-001(3단계 행위 박제 times1→times1→times2, 값 10→20), FR-008(결정성 프록시, 한계 주석) 약화 없음 | + +## 결정 정합성 (R1~R10) + +R1 Redis+TTL25h ✅ · R2 key `#memberId+':'+#version` ✅ · R3 endTime→Asia/Seoul LocalDate·콜드=오늘 ✅ · R4 버전 캐시 없음 ✅ · R5 lockingRedisCacheWriter+sync ✅ · R7 자기호출 회피(별도 빈 위임) ✅ · R8 GenericJackson2Json+@JsonAutoDetect ✅ · R9 Asia/Seoul·yyyy-MM-dd ✅ + +**US2/FR-003 race 구조 검증**: `ScheduledTasks.progressCalculation()`은 finish()(endTime 설정) 후에만 save → 진행중(endTime=null) JobLog 미영속 → 새 날 배치 실행 중 MAX(endTime)=어제 유지 → 중간상태 캐싱 불가. 설계 건전. + +## 품질 +- 자기호출 우회 없음, FR-007 예외 전파(미캐싱) 정합, null/Optional 안전, readOnly tx 경계 정상, 단일 CacheManager 빈, 테스트 격리(@BeforeEach cleanup+reset) 견고. +- 회귀 수정(MemberIntegrationTest): 내부구조 직접조회→행위검증, 참조동등→usingRecursiveComparison. 정당. SC-004/직렬화 round-trip 사실상 커버. +- 인덱스 idx_joblog_type_endtime 적합. + +## 발견사항 + +**P1: 없음. P2: 없음.** + +**P3 (비차단 — 백로그):** +1. 직렬화 클래스 결속(`@class` FQN 임베드) — DTO 이동/리네임 시 TTL≤25h 잔존 캐시 역직렬화 예외 가능. 현재 round-trip 정상. 클래스 진화 예상 시 "역직렬화 실패→miss" 복원력 고려. → code-implementer 백로그 +2. `@EnableCaching` 중복(RedisConfig.java:15 + PlanetrushApplication.java:8). 멱등이라 무해하나 CacheConfig javadoc("RedisConfig에 이미 선언")이 단일출처 암시 → 주석/배치 정리 권고. → Polish +3. data-model.md endTime nullable 기술 ↔ JobLog `nullable=false` 불일치(엔티티는 본 Wave 변경 아님). 문서 보정. → **리더 처리(W1에서 수정)** + +## 종합 +P1=0 → 게이트 통과. SC-001·FR-008 약화 없이 박제. US2/US3/SC-004/FR-007은 명시 deferred(후속 Wave). 상충 의견 없음. diff --git a/specs/006-statistics-cache-versioning/_harness/phase1-verify.md b/specs/006-statistics-cache-versioning/_harness/phase1-verify.md new file mode 100644 index 0000000..804e39b --- /dev/null +++ b/specs/006-statistics-cache-versioning/_harness/phase1-verify.md @@ -0,0 +1,160 @@ +# Phase 1 (Wave 1) 빌드 검증 — Statistics Cache Versioning + +검증자: build-verifier · 일시: 2026-06-06 · 브랜치: dev · Docker: 기동 확인(`DOCKER_OK`, 0 containers 시작 상태) + +종합 판정: **RED** — 신규 인수 테스트·시크릿 게이트는 통과하나, Caffeine→Redis 캐시 이관으로 기존 `MemberIntegrationTest` 2건이 회귀 실패. 머지·Phase 커밋 차단. + +--- + +## 1. 컴파일 — PASS + +명령: `./gradlew compileJava compileTestJava` +``` +BUILD SUCCESSFUL in 7s +3 actionable tasks: 2 executed, 1 up-to-date +``` +src/main · src/test 모두 컴파일 성공. 컴파일 결함 없음. + +## 2. 신규 인수 테스트 — PASS (SC-001 · FR-008) + +명령: `./gradlew test --tests "com.planetrush.planetrush.member.StatisticsCacheVersionIntegrationTest"` +``` +BUILD SUCCESSFUL in 25s +``` +결과 XML (`TEST-...StatisticsCacheVersionIntegrationTest.xml`): +``` + + SC-001: 배치 버전 전환(D→D+1) 후 첫 조회는 재계산되고, 전환 전 재조회는 캐시 HIT 다. + FR-008: 동일 DB 상태에서 getCurrentVersion 은 결정적으로 같은 버전을 반환한다. +``` +2/2 통과. + +### 직렬화 round-trip 판정 — 정상 동작 확인 +SC-001은 캐시 HIT 단계에서 Redis 역직렬화가 성공해야 통과한다. 이 테스트가 통과했으므로 +`GenericJackson2JsonRedisSerializer`(default typing `@class`) + `@JsonAutoDetect(ANY)` + protected no-arg 생성자 +조합의 `GetMyProgressAvgDto` round-trip 이 실제 동작함을 확인. **InvalidTypeIdException / 생성자 접근 오류 징후 없음.** +(아래 회귀 실패 2건도 역직렬화 자체는 성공하며, 오히려 "새 인스턴스가 정상 생성됨"을 보여준다 — 직렬화 결함이 아님.) + +## 3. 회귀 점검 — FAIL (MemberIntegrationTest 2/2 실패, 단 설계 변경에 따른 예상 회귀) + +명령: `./gradlew test --tests "com.planetrush.planetrush.member.MemberIntegrationTest"` +``` +2 tests completed, 2 failed +BUILD FAILED in 7s +``` + +### 실패 A — `통계 데이터를 조회할 때 캐싱된 값을 반환해야 한다.` (line 100) +``` +java.lang.AssertionError: Expecting actual not to be null + at MemberIntegrationTest.java:100 +``` +- 테스트는 `cacheManager.getCache("challenge-avg").get(member.getId())` 로 **Long 키 `1L`** 을 직접 조회. +- 신규 캐시 키 전략(`MemberStatisticsCacheService`): + `@Cacheable(cacheNames="challenge-avg", key="#memberId + ':' + #version", sync=true)` + → 실제 키는 문자열 `"1:2026-06-06"`. 따라서 `get(1L)` 은 항상 null → 단언 실패. +- 즉 **기능적 캐싱은 정상**(SC-001이 HIT 검증). 테스트가 구(舊) Caffeine 평문-Long-키 가정에 묶여 있음. + +### 실패 B — `동시에 같은 회원 통계 조회가 들어와도 캐시 로더는 한 번만 실행되어야 한다.` (line 164) +``` +org.opentest4j.AssertionFailedError: +expected: GetMyProgressAvgDto@d678716 + but was: GetMyProgressAvgDto@52f3c809 + at MemberIntegrationTest.java:164 (assertThat(future.get(...)).isEqualTo(dto)) +``` +- 구 Caffeine 인메모리 캐시는 동일 참조를 반환 → `isEqualTo(dto)` 가 참조 동일성으로 통과했음. +- Redis 이관 후 round-trip 으로 **새 인스턴스**가 반환됨. `GetMyProgressAvgDto` 는 `equals/hashCode` 미구현 + (확인: 소스 내 equals/hashCode 0건, `@EqualsAndHashCode` 없음) → `isEqualTo` 가 Object 동일성으로 떨어져 실패. +- 역직렬화 자체는 성공(새 객체 정상 생성). 테스트의 "동일 참조 반환" 가정이 Redis 의미론과 불일치. + +## 4. 시크릿 로그 게이트 (헌법 V) — PASS + +명령: `./gradlew verifySecretLogScan` +``` +> Task :verifySecretLogScan +✓ secret log scan clean +BUILD SUCCESSFUL in 351ms +``` + +## 5. 전체 게이트 — FAIL (실패 범위 = MemberIntegrationTest 2건으로 국한) + +명령: `./gradlew check --continue` +``` +MemberIntegrationTest > 동시에 같은 회원 통계 조회가 ... FAILED +MemberIntegrationTest > 통계 데이터를 조회할 때 ... FAILED +173 tests completed, 2 failed +BUILD FAILED in 32s +``` +전체 173개 중 **2건만 실패**(둘 다 MemberIntegrationTest). 나머지 171건 그린 — Wave 1 변경에 의한 +타 회귀 없음. verifySecretLogScan 포함 게이트는 통과. + +--- + +## 책임 라우팅 제안 + +주 라우팅: **test-author** — `MemberIntegrationTest` 2건은 구 Caffeine 의미론(평문 Long 키 직접 조회 · +캐시 값 참조 동일성)에 묶인 검증으로, 신규 설계(버전드 문자열 키 `{memberId}:{version}` + Redis round-trip) +에서 의도적으로 깨진다. 프로덕션 결함 아님(기능적 캐싱·역직렬화는 SC-001 통과로 입증). +권장 수정: +- 실패 A(line 98~102): 캐시 키를 `member.getId() + ":" + ` 로 조회하거나, 캐시 직접 조회 대신 + `verify(flaskApiClient, times(1))` 의 서비스 레벨 검증으로 전환. +- 실패 B(line 164): `isEqualTo(dto)` 를 필드 단위 비교(`usingRecursiveComparison().isEqualTo(dto)`)로 전환. + +리더 결정 필요(설계 선택지): 실패 B 를 테스트 수정 대신 **code-implementer** 가 `GetMyProgressAvgDto` 에 +`@EqualsAndHashCode`(값 동등성) 부여로 해결할 수도 있음. 단, 직렬화 필드 가시성(`@JsonAutoDetect(ANY)`)과의 +상호작용 검토 필요 → 단순 테스트 수정(test-author)이 더 보수적. + +인프라: 정상(Docker 기동, Testcontainers 컨테이너 기동 실패 없음). 인프라 결함 아님. + +## 완료 기준 체크 (1차 검증 시점) +- [x] 컴파일 PASS +- [x] 신규 인수 테스트(SC-001·FR-008) PASS + 직렬화 round-trip 정상 확인 +- [x] verifySecretLogScan PASS +- [ ] `./gradlew check` GREEN — **미달(MemberIntegrationTest 2건)** → test-author 수정 후 재검증 필요 + +--- + +# 재검증 (test-author 수정 후) — 2026-06-06 + +test-author 가 `MemberIntegrationTest.java` 회귀 2건 수정(평문 Long 키 직접조회 단언 제거 + +`isEqualTo` → `usingRecursiveComparison`). 재검증 결과 **종합 판정: GREEN — 회귀 0, 전체 게이트 통과.** + +## R1. MemberIntegrationTest 재실행 — PASS + +명령: `./gradlew test --tests "com.planetrush.planetrush.member.MemberIntegrationTest"` +``` +BUILD SUCCESSFUL in 9s +``` +결과 XML (`TEST-...MemberIntegrationTest.xml`): +``` + +``` +이전 RED 였던 2건(`통계 데이터를 조회할 때 캐싱된 값을 반환해야 한다.`, +`동시에 같은 회원 통계 조회가 들어와도 캐시 로더는 한 번만 실행되어야 한다.`) 모두 그린. + +## R2. 전체 게이트 `./gradlew check` — PASS (GREEN) + +명령: `./gradlew check` +``` +BUILD SUCCESSFUL in 33s +``` +전체 테스트 집계(`build/test-results/test/TEST-*.xml` 합산): +``` +tests=173 skipped=0 failures=0 errors=0 +``` +시크릿 로그 게이트(헌법 V) — `verifySecretLogScan --rerun-tasks`: +``` +✓ secret log scan clean +BUILD SUCCESSFUL in 364ms +``` +173/173 그린, 실패·에러 0. 1차에서 실패했던 2건이 통과로 전환되었고 신규 회귀 없음. +verifySecretLogScan 포함 전체 게이트 통과. + +## 재검증 완료 기준 체크 +- [x] MemberIntegrationTest 2/2 PASS +- [x] `./gradlew check` GREEN (tests=173, failures=0, errors=0) +- [x] verifySecretLogScan PASS (`✓ secret log scan clean`) +- [x] 회귀 0 + +## 최종 판정: GREEN +Phase 1(Wave 1) 빌드 그린. code-reviewer 최종 리뷰 및 Phase 커밋 진행 가능. +RED 라우팅 사항 없음. (인프라 정상: Docker 기동, Testcontainers 기동 실패 없음.) diff --git a/specs/006-statistics-cache-versioning/_harness/phase2-review.md b/specs/006-statistics-cache-versioning/_harness/phase2-review.md new file mode 100644 index 0000000..a3c61cf --- /dev/null +++ b/specs/006-statistics-cache-versioning/_harness/phase2-review.md @@ -0,0 +1,85 @@ +# Spec 006 — Phase 2 (Wave 2 + Wave 3) Code Review + +**Scope**: US2(SC-002) · US3(SC-003·SC-005) · single-flight(SC-004) · 실패 비캐싱(FR-007) · 콜드스타트 + P3-2 해소(@EnableCaching 단일화). +**Build**: GREEN — 179 tests, 인수 테스트 8/8 통과, verifySecretLogScan clean. +**Verdict**: P1 = 0. Phase 게이트 통과 가능. P2 = 0. P3 = 2(경미·구조적 한계). + +대상: +- src/test `member/StatisticsCacheVersionIntegrationTest.java` — 신규 6 메서드(SC-002·SC-003·SC-005·SC-004·FR-007·콜드스타트) +- src/main `PlanetrushApplication.java` — `@EnableCaching` 중복 제거(P3-2) + +--- + +## 1. P3-2 해소 검증 (@EnableCaching 단일화) + +- `PlanetrushApplication.java`(1-15): `@EnableCaching` import·어노테이션 제거됨. 현재 `@EnableScheduling` + `@SpringBootApplication`만. +- `RedisConfig.java:15`: `@EnableCaching` 단일 잔존 → 애플리케이션 전역 캐싱 인프라 단일 출처. +- `CacheConfig` javadoc("@EnableCaching 은 RedisConfig 에 이미 선언되어 있어 여기서 중복하지 않는다")이 이제 코드와 **정확히 일치**. +- 회귀 위험: 없음. `@EnableCaching`은 캐싱 어드바이저 인프라를 등록하는 부수효과만 있고 단일 선언으로 충분. 빌드 GREEN(179)으로 캐시 어드바이스 정상 동작 확인됨(SC-001·004 HIT/single-flight 통과가 증빙). + +→ **P3-2 정확히 해소. PASS.** + +--- + +## 2. 신규 테스트 약화-없는 박제 점검 + +### SC-002 — `should_not_cache_intermediate_state_while_batch_in_progress` (141-174) — PASS +- **모델링 정확성(핵심)**: "배치 진행 중" = 해당 날짜 완료 JobLog row 부재로 모델링. 이는 production 실제 동작과 정합 — `ScheduledTasks.progressCalculation()`은 `finish()`(endTime set) **후에만** `save()`하므로 endTime=null(진행 중) row 는 절대 영속되지 않는다. 따라서 D+1 배치 진행 중에는 `MAX(endTime)`가 D 로 유지되어 버전이 올라갈 수 없다. +- **단언 강도**: (1) 버전 D 첫 조회 → `times(1)` + keyD 존재. (2) D+1 진행 중(미시드) 재조회 → 값 10.0 유지 + `times(1)` 유지 + **keyDPlus1 미존재**. (3) D+1 실제 완료 시드 → 값 20.0 + `times(2)` + keyDPlus1 존재. +- 중간상태가 새 버전 키로 캐싱되지 않음을 Redis 키 부재로 직접 증명. SC-002("중간 상태 고정 0건")를 약화 없이 박제. `endTime IS NOT NULL` 불변식의 행위적 증명. + +### SC-003 — `should_keep_old_version_key_without_evict_on_version_transition` (181-204) — PASS +- 버전 D 조회로 keyD 생성 → 완료 D+1 시드 후 조회로 keyDPlus1 생성 → **keyD·keyDPlus1 둘 다 잔존** 단언(`challengeAvgKeys().contains(keyD, keyDPlus1)`). 사용자별 evict/`cache.evict` 호출 0건. +- SC-003("사용자별 일괄 캐시 삭제 0회 — 키 변경만으로 신선도")을 Redis 키 잔존으로 직접 검증. 약화 없음. + +### SC-005 — `should_set_ttl_around_25h_on_cache_key` (210-225) — PASS +- 조회로 키 생성 후 `ttlSeconds(keyD)`가 `> 82800(23h)` 그리고 `<= 90000(25h)` 단언. TTL=-1(무만료)·-2(키없음)을 모두 배제 → 과거 버전 자연 만료 보장(FR-005/SC-005). +- 하한(23h)이 다소 느슨하나 핵심(TTL≈25h 설정·무만료 아님)은 정확히 검증. (P3 참고) + +### SC-004 — `should_converge_flask_call_to_once_on_concurrent_first_reads` (231-271) — PASS +- 16 스레드 + `readyLatch`/`startLatch` 동시 출발, Flask `thenAnswer` 200ms sleep 으로 in-flight 구간 강제. 동일 member·동일 버전 첫 조회 동시 발생 → `verify times(1)`. +- 값은 Redis round-trip 역직렬화 새 인스턴스이므로 `usingRecursiveComparison` 값 동등성 비교(분산 캐시 정합 가정 정확). single-flight(locking RedisCacheWriter + `sync=true`)를 실제 Redis 컨테이너에서 검증. SC-004 약화 없음. +- 구조적 한계(P3): 단일 JVM 이라 `sync=true`(per-JVM)만으로도 1회 수렴 가능 — locking writer 의 **교차 인스턴스** 직렬화는 단일 JVM 테스트로 직접 재현 불가(FR-008 결정성 프록시와 동일 성격). 인스턴스 내 수렴은 직접 증명됨. + +### FR-007 — `should_not_cache_failure_and_allow_retry_on_next_request` (279-300) — PASS +- Flask 가 `FlaskConnectionFailedException` throw → (1) 첫 호출 예외 전파 + **keyD 미생성**(실패 비캐싱). (2) 다음 호출 캐시 미스 → 재계산 시도(`verify times(2)`) + keyD 여전히 미생성. +- `@Cacheable` 예외 시 미저장 의미를 Redis 키 부재로 직접 검증. javadoc 이 "@MockBean 이라 production `@Retryable` 미적용"을 정직히 명시 — 본 스펙이 더한 제약("실패 결과 비캐싱")에 정확히 스코프됨. member 는 시드되어 `MemberNotFoundException` 단락 없이 Flask 단계까지 도달. 약화 없음. + +### 콜드스타트 — `should_use_today_as_version_on_cold_start_with_no_completed_batch` (306-324) — PASS +- 완료 JobLog 0건(cleanUp 보장) → `getCurrentVersion() == today(Asia/Seoul)` + 조회 시 Flask `times(1)` + today-버전 키 생성. Q3/R3 콜드스타트("오늘 잠정 버전") 박제. + +--- + +## 3. 헌법 I · VII + +- **I (Testcontainers)**: 6 신규 메서드 모두 `IntegrationTest`(싱글톤 MySQL+Redis) 확장. Redis 키/TTL·JobLog 시드를 실제 컨테이너로 검증. Flask 만 `@MockBean`(비 MySQL/Redis 어댑터 — 의무 대상 아님). 로컬 데몬 불요. PASS. +- **VII (인수기준=테스트)**: SC-002·003·004·005·FR-007·콜드스타트가 메서드 1:1 박제. W1 의 SC-001·FR-008 와 합쳐 8/8. PASS. + +## 4. 테스트 격리 + +- `@BeforeEach`(60-69): `support.cleanUp()`(challenge-avg::* 키 + JobLog + Member 삭제) + `cacheManager.getCache(...).clear()` + `reset(flaskApiClient)`. 콜드스타트 테스트의 "완료 0건" 전제도 cleanUp 으로 보장. +- 각 테스트 독립 시드(날짜·멤버). SC-004 executor 는 `shutdown()`+`awaitTermination` 으로 정리. 크로스-테스트 누수 없음. PASS. + +--- + +## 5. 발견사항 (심각도별) + +**P1 (머지 차단): 없음.** + +**P2 (품질): 없음.** + +**P3 (제안):** + +1. **SC-005 TTL 하한 느슨** — `StatisticsCacheVersionIntegrationTest.java:224`. 하한 82800s(23h)는 생성 직후 검증 특성상 사실상 90000 근처여야 하므로 더 좁혀도 됨(예 `> 89000`). 현재도 무만료/미설정 배제 목적은 충족. 저우선. +2. **SC-004 교차 인스턴스 미검증(구조적)** — `:233`. 단일 JVM 한계로 locking RedisCacheWriter 의 교차 인스턴스 직렬화는 직접 재현 불가(FR-008 와 동일 성격). 인스턴스 내 수렴은 검증됨. 코드 변경 불요 — 인지 항목. + +> 상충 의견 없음. + +## 6. 종합 + +- **P1 = 0** → Phase 2 게이트 통과 가능. +- W2+W3 인수기준(SC-002·003·004·005·FR-007·콜드스타트) 전부 행위 기반·약화 없이 박제. +- P3-2(@EnableCaching 중복) 정확히 해소 — RedisConfig 단일 출처. +- 잔여 백로그(W1 부터 이월): P3-1 직렬화 `@class` 결속 취약성(GetMyProgressAvgDto / GenericJackson2JsonRedisSerializer) — 후속 복원력 과제(머지 비차단). + + diff --git a/specs/006-statistics-cache-versioning/_harness/phase2-verify.md b/specs/006-statistics-cache-versioning/_harness/phase2-verify.md new file mode 100644 index 0000000..900af29 --- /dev/null +++ b/specs/006-statistics-cache-versioning/_harness/phase2-verify.md @@ -0,0 +1,73 @@ +# Phase 2 (Wave 2 + Wave 3) 빌드 검증 — Statistics Cache Versioning + +검증자: build-verifier · 일시: 2026-06-06 · 브랜치: dev · Docker: 기동(Testcontainers 정상) + +종합 판정: **GREEN** — 신규 6 + 기존 2 = 8 인수 테스트 전부 통과, 전체 게이트(`check` + `verifySecretLogScan`) 그린, 회귀 0. code-reviewer 최종 리뷰·Phase 커밋 진행 가능. + +## 대상 변경 +- src/test: `StatisticsCacheVersionIntegrationTest.java` 6개 메서드 추가 — SC-002·SC-003·SC-005·SC-004·FR-007·콜드스타트. +- src/main: `PlanetrushApplication.java` 중복 `@EnableCaching` 제거(RedisConfig 단일화). + +--- + +## 1. 신규 인수 테스트 — PASS (8/8) + +명령: `./gradlew test --tests "com.planetrush.planetrush.member.StatisticsCacheVersionIntegrationTest"` +``` +BUILD SUCCESSFUL in 8s +``` +결과 XML (`TEST-...StatisticsCacheVersionIntegrationTest.xml`): +``` + +``` +실행된 8개 testcase 전부 통과: +- SC-001: 배치 버전 전환(D→D+1) 후 첫 조회 재계산 + 전환 전 재조회 HIT +- SC-002: 배치 진행 중 조회는 직전 버전 HIT, 중간상태 새 버전 미캐싱 +- SC-003: 버전 전환 후 구·신 버전 키 모두 잔존(사용자별 evict 0회) +- SC-004: 동일 member·동일 버전 동시 N 요청 → Flask 호출 1회 수렴(single-flight) +- SC-005: 캐시 키에 25h 근사 TTL 설정(과거 버전 자연 만료) +- FR-007: Flask 예외 시 키 미생성, 다음 요청이 재계산 재시도 +- FR-008: 동일 DB 상태에서 getCurrentVersion 결정적 동일 버전 +- 콜드스타트: 완료 배치 0건 → version=오늘(Asia/Seoul) 계산·캐싱 + +### 주의 신호 — 전부 검증 통과 +- **FR-007 (예외 전파)**: PASS. `@Cacheable` 어드바이스가 `FlaskConnectionFailedException` 을 삼키지 않고 + 전파했고(키 미생성 입증), 다음 요청 재계산 재시도 성공. 예외 삼킴(RED 징후) 없음. +- **SC-004 (single-flight)**: PASS. 동시 N 요청에도 `times(1)` 수렴 — `sync=true` 정상 동작. +- **SC-005 (TTL 범위 82800`. + +요청/응답 스키마·상태코드 불변. 본 기능은 **응답 신선도 보장 방식만** 바꾸며 클라이언트 계약에 영향 없음. + +## 2. 캐시 키 계약 + +| 항목 | 값 | +|------|-----| +| cacheName | `challenge-avg` | +| key 표현식 | `#memberId + ':' + #version` | +| Redis 물리 키 | `challenge-avg::{memberId}:{version}` | +| version 포맷 | `yyyy-MM-dd` (Asia/Seoul) | +| 예시 | `challenge-avg::15:2026-06-06` | + +**불변식**: +- 같은 `memberId` 라도 `version` 이 다르면 별개 항목(FR-001). +- 키에 `version` 외 가변 요소를 추가하지 않는다(사용자별 단일 활성 항목 + 직전 세대 1개로 수렴). + +## 3. 버전 해석 계약 (`StatisticsVersionProvider`) + +**입력**: 없음(현재 시각·DB 상태에서 파생). **출력**: `String version` (`yyyy-MM-dd`). + +``` +getCurrentVersion(): + # 매 요청 DB 파생 (별도 버전 캐시 없음, R4) + endTimeMax = jobLogRepository.findLatestCompletedProgressCalculationEndTime() # Optional + if endTimeMax.isPresent(): + date = endTimeMax.get().atZone(Asia/Seoul).toLocalDate() + else: + date = LocalDate.now(Asia/Seoul) # 콜드스타트 잠정 버전 + return date.format("yyyy-MM-dd") +``` + +**보장**: +- C1 (단조): 연속 호출에서 반환 날짜는 같거나 증가. +- C2 (완료 후 전환): 새 날 `progressCalculation` 의 `endTime` 이 set 되기 전에는 직전 날짜 반환. +- C3 (전역 일관): 동일 DB 상태에서 모든 인스턴스 동일 반환(매 요청 DB 파생 → 배치 완료 즉시 수렴). + +## 4. 통계 조회 흐름 계약 + +``` +MemberServiceImpl.getMyProgressAvgPer(memberId): + version = statisticsVersionProvider.getCurrentVersion() + return memberStatisticsCacheService.getStatistics(memberId, version) + +MemberStatisticsCacheService.getStatistics(memberId, version): # @Cacheable(challenge-avg, key=memberId:version, sync=true) + memberRepository.findById(memberId) or throw MemberNotFoundException + return flaskApiClient.getMyProgressAvg(memberId) # 예외 전파 → 미캐싱(FR-007) +``` + +**보장**: +- F1 (재계산): version 변경 후 첫 호출은 MISS → Flask 1회 호출 → 신선값 반환·캐싱(SC-001). +- F2 (중간상태 차단): 배치 미완료 구간 호출은 직전 version 으로 hit, 새 version 미생성(SC-002). +- F3 (single-flight): 동일 `memberId:version` 동시 첫 호출 시 Flask 호출 1회로 수렴(SC-004) — locking RedisCacheWriter + `sync=true`. +- F4 (실패 비저장): Flask 예외 시 해당 키 미저장, 다음 호출 재시도(FR-007). +- F5 (evict 불요): version 전환은 키 변경만으로 신선도 확보, 사용자별 명시 삭제 0회(SC-003). 과거 키는 TTL 25h 자연 만료(FR-005). diff --git a/specs/006-statistics-cache-versioning/data-model.md b/specs/006-statistics-cache-versioning/data-model.md new file mode 100644 index 0000000..1a4621b --- /dev/null +++ b/specs/006-statistics-cache-versioning/data-model.md @@ -0,0 +1,73 @@ +# Phase 1 Data Model: 통계 캐시 버전 키 + +**Spec**: [spec.md](./spec.md) · **Research**: [research.md](./research.md) + +이 기능은 새 영속 테이블을 만들지 않는다. "통계 버전"은 기존 `JobLog` 에서 **파생되는 값**이고, 캐시 항목은 Redis 키-값이다. + +## 1. StatisticsVersion (파생 값 객체, 비영속) + +현재 유효한 통계 데이터 세대를 식별하는 값. + +| 속성 | 타입 | 규칙 | +|------|------|------| +| value | `String` (`yyyy-MM-dd`) | 캐시 키 구성요소. 예 `2026-06-06` | + +**산정 규칙** (R3): + +``` +endTime_max = MAX(JobLog.endTime) where jobType = 'progressCalculation' and endTime is not null +if endTime_max exists: + version = endTime_max.atZone(Asia/Seoul).toLocalDate() # 직전 완료 배치 기준일 +else: + version = LocalDate.now(Asia/Seoul) # 콜드스타트 잠정 버전 (Q3) +return version.format("yyyy-MM-dd") +``` + +**불변식**: +- **단조 진행(FR-009)**: `endTime` 은 증가만 하므로 version 날짜도 같거나 증가. 과거로 회귀 불가. +- **완료 후 전환(FR-003)**: 새 날 배치가 *완료*(endTime set)되기 전에는 version 이 직전 날짜로 유지. +- **전역 단일(FR-008)**: 모든 사용자·인스턴스가 동일 version 을 본다(같은 DB MAX 결과). 버전은 매 요청 DB 에서 직접 파생(R4, 메모 없음) — 배치 완료 즉시 전 인스턴스 동일 반영. + +## 2. JobLog (기존 엔티티 — 통계 배치 실행 기록) + +변경 없음. 본 기능은 **읽기 전용**으로 사용. + +| 컬럼 | 타입 | 의미 | +|------|------|------| +| id | Long (PK) | | +| jobType | String | `progressCalculation` 필터 대상 | +| startTime | LocalDateTime | 배치 시작 | +| endTime | LocalDateTime (영속 시 NOT NULL) | 완료 시각. 엔티티는 `finish()`(endTime 설정) 후에만 save → 진행 중(미완료) row 는 영속되지 않음. "배치 진행 중"은 *해당 날짜 완료 row 부재*로 표현 | +| elapsedTime | String | 소요 | + +**신규 조회 (QueryDSL custom, 헌법 III 정합)**: +- `findLatestCompletedProgressCalculationEndTime(): Optional` + - `select max(endTime)` from JobLog where `jobType = 'progressCalculation'` and `endTime is not null`. + - 스칼라 집계 — Entity→DTO 수동 매핑 아님. N+1 무관. +- 인덱스: `job_log(job_type, end_time)` 복합 인덱스 **추가**(R4) — 매 요청 `MAX(endTime)` 을 인덱스 끝값 1건 읽기로 처리. + +## 3. 통계 캐시 항목 (Redis) + +| 요소 | 값 | +|------|-----| +| cacheName | `challenge-avg` (유지) | +| key | `{memberId}:{version}` → Redis 키 `challenge-avg::15:2026-06-06` | +| value | `GetMyProgressAvgDto` (JSON 직렬화, R8) | +| TTL | 25시간 (R1) — 1세대 수명 + 여유, 과거 버전 항목 자연 만료(FR-005) | +| 적재 정책 | miss 시 Flask 계산 후 저장. 예외 시 미저장(FR-007). 동일 키 동시 적재는 locking writer 로 직렬화(R5/FR-006) | + +**GetMyProgressAvgDto** (기존, 변경 없음): `completionCnt`, `challengeCnt`, `myTotalAvg/Per`, `totalAvg`, 카테고리별(`exercise/beauty/life/study/etc`) `my*Avg`/`my*Per`/`*Avg` — 모두 원시 타입. + +## 상태 전이 (버전 전환 타임라인) + +``` +[D-1 23:50 배치 완료] endTime=D-1 → version=D-1, 캐시 challenge-avg::{m}:D-1 + │ +[D 00:02 요청] 새 배치 아직 미완료 → MAX(endTime)=D-1 → version=D-1 + │ → D-1 캐시 hit (중간 상태 캐싱 없음, US2/SC-002) + │ +[D 00:05 새 배치 완료] endTime=D → version=D + │ +[D 00:06 요청] version=D → challenge-avg::{m}:D MISS → Flask 재계산 → 최신값 캐싱 (US1/SC-001) + │ (D-1 키는 참조 안 됨 → TTL 25h 후 자연 만료, evict 0회 SC-003) +``` diff --git a/specs/006-statistics-cache-versioning/plan.md b/specs/006-statistics-cache-versioning/plan.md new file mode 100644 index 0000000..7f60cd6 --- /dev/null +++ b/specs/006-statistics-cache-versioning/plan.md @@ -0,0 +1,103 @@ +# Implementation Plan: 사용자 통계 캐시 stale-cache 해소 (통계 버전 기반 캐시 키) + +**Branch**: `006-statistics-cache-versioning` | **Date**: 2026-06-06 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/006-statistics-cache-versioning/spec.md` + +## Summary + +마이페이지 통계(`GET /api/v1/members/mypage`) 캐시가 24h TTL 동안 stale 해지는 문제를, **캐시 키에 "통계 버전"을 포함**해 해소한다. 버전은 *직전에 완료된 `progressCalculation` 배치의 기준일*(`yyyy-MM-dd`, Asia/Seoul)이며 DB(`JobLog`)에서 파생한다. 배치가 *완료된 후에만* 버전이 전환되므로 자정 직후 중간 상태가 새 버전으로 캐싱되지 않는다(US2). 캐시 스토어는 다중 인스턴스 공유를 위해 Caffeine 로컬 → **Redis 공유 캐시**로 이관하며, 버전 전환은 키 변경만으로 신선도를 확보해 전수 evict 가 불필요하다. 신규 의존성·신규 테이블 없음. + +## Technical Context + +**Language/Version**: Java 21 + +**Primary Dependencies**: Spring Boot 3.2.7, Spring Cache + Spring Data Redis(Lettuce), JPA + QueryDSL 5.0.0 — *모두 기존 보유, 추가 없음* + +**Storage**: MySQL 8.x(`JobLog` 읽기 + `(job_type, end_time)` 인덱스), Redis 7.x(통계 캐시) + +**Testing**: JUnit 5 + Testcontainers(MySQL+Redis, 기존 `IntegrationTest` 베이스 재사용), Flask 는 `@MockBean` + +**Target Platform**: Linux 서버 (다중 인스턴스 가정 — spec Q1) + +**Project Type**: web-service (Spring Boot 단일 모듈) + +**Performance Goals**: mypage 통계 조회 캐시 hit 시 외부 호출 0회. 버전 해석은 매 요청 인덱스된 `MAX(endTime)` 1건 읽기(≈0.1ms, 메모 없음 — R4). 새 버전 첫 조회 Flask 호출 요청당 1회 수렴(SC-004). + +**Constraints**: 배치 완료 후에만 버전 전환(FR-003). brief-stale(콜드스타트 당일, 버전 전환 경계 순간) 허용. 실패 결과 비캐싱(FR-007). 전수 evict 0회(SC-003). + +**Scale/Scope**: 사용자별 통계 1종(`GetMyProgressAvgDto`). 변경 파일 ~6개(설정 1, 서비스 2, 리포지토리 1, DTO 0~1, 테스트 N). + +## Constitution Check + +*GATE: Phase 0 전 통과 필수. Phase 1 후 재점검.* + +### 본 컨스티튜션 적용 항목 (Governance) + +| 원칙 | 부합 방식 | 상태 | +|------|-----------|------| +| **I. Testcontainers 통합테스트** | 신규 통합 테스트는 기존 `IntegrationTest`(MySQL+Redis 싱글톤 컨테이너) 확장. 로컬 데몬 불요. | ✅ PASS | +| **II. 외부 의존 어댑터 격리** | Flask 는 기존 `FlaskApiClient`(infra) 경유. 통계 캐시는 Spring Cache 선언적 추상화 사용(서비스가 `RedisTemplate` 직접 호출 안 함). 버전은 JPA 리포지토리(DB)에서 파생 — Redis 직접 접근 없음. | ✅ PASS | +| **III. QueryDSL Projections** | 신규 조회는 `MAX(endTime)` 스칼라 집계(QueryDSL custom). Entity→DTO 수동 매핑 미도입. | ✅ PASS | +| **IV. Outbox 경유 발행** | 메시지 발행 없음 — 해당 없음. | ➖ N/A | +| **V. 민감정보 로그 금지** | 시크릿 로깅 추가 없음. | ✅ PASS | +| **VI. 듀얼 AI 리뷰** | PR 단계에서 Claude+Codex 리뷰 첨부. | ⏳ PR 시 | +| **VII. 인수기준=자동테스트** | SC-001~005 를 통합 테스트로 1:1 박제(tasks 에 매핑). | ✅ PASS(계획) | + +**기술 스택/라이브러리**: 신규 `build.gradle` 의존성 없음 → "라이브러리 도입 규칙" 트리거 안 됨. 운영 안티패턴(ddl-auto=update 등) 신규 도입 없음. + +**게이트 결과**: 위반 없음. `Complexity Tracking` 비움. + +## Project Structure + +### Documentation (this feature) + +```text +specs/006-statistics-cache-versioning/ +├── plan.md # 이 파일 +├── spec.md # 기능 명세 (+ Clarifications) +├── research.md # Phase 0 — 결정 R1~R10 +├── data-model.md # Phase 1 — StatisticsVersion·JobLog·캐시 항목 +├── contracts/ +│ └── cache-key.md # Phase 1 — 캐시 키/버전 해석/조회 흐름 계약 +├── quickstart.md # Phase 1 — 검증 절차 +└── tasks.md # Phase 2 — /speckit-tasks 산출(미생성) +``` + +### Source Code (repository root) + +```text +src/main/java/com/planetrush/planetrush/ +├── core/config/ +│ └── CacheConfig.java # [수정] challenge-avg 를 RedisCacheManager 로 이관 +│ # + lockingRedisCacheWriter(R5) + Jackson 직렬화(R8) +│ # (버전 메모 캐시 없음 — R4) +├── member/service/ +│ ├── MemberServiceImpl.java # [수정] getMyProgressAvgPer: @Cacheable 제거, +│ │ # 버전 해석 → 캐시 서비스 위임(R7) +│ ├── StatisticsVersionProvider.java # [신규] 현재 버전 해석(매 요청 DB 파생, 캐시 없음) +│ └── MemberStatisticsCacheService.java # [신규] @Cacheable(challenge-avg, key=memberId:version) +└── scheduler/log/ + ├── JobLogRepository.java # [유지] JpaRepository + └── JobLogRepositoryCustom(+Impl).java # [신규] findLatestCompletedProgressCalculationEndTime (QueryDSL) + # + JobLog 에 (job_type, end_time) 인덱스 + +src/test/java/com/planetrush/planetrush/member/ +└── StatisticsCacheVersionIntegrationTest.java # [신규] SC-001~005 (IntegrationTest 확장) +``` + +**Structure Decision**: 기존 Spring Boot 단일 모듈 레이어(`core/config`, `member/service`, `scheduler/log`)에 그대로 편입. 캐시 추상화는 설정 레이어, 버전 파생은 `scheduler/log` 리포지토리, 오케스트레이션은 `member/service`. 새 패키지 없음. + +## 구현 단계 개요 (tasks 입력용) + +1. **JobLog 버전 쿼리 + 인덱스**: QueryDSL custom 으로 `MAX(endTime)` (progressCalculation, endTime not null). `(job_type, end_time)` 복합 인덱스 추가. +2. **StatisticsVersionProvider**: 쿼리 → Asia/Seoul LocalDate → `yyyy-MM-dd`, 콜드스타트=오늘. 캐시 없음(매 요청 DB 파생, R4). +3. **CacheConfig 이관**: `RedisCacheManager`(challenge-avg TTL 25h, JSON 직렬화, lockingRedisCacheWriter). 기존 Caffeine-only 정리(버전 메모 캐시 없음). +4. **MemberStatisticsCacheService**: `@Cacheable(challenge-avg, key="#memberId + ':' + #version", sync=true)` 내부에서 member 검증 + Flask 호출. +5. **MemberServiceImpl 리팩터**: `getMyProgressAvgPer` = version 해석 → 캐시 서비스 호출. 기존 `@Cacheable` 어노테이션 제거. +6. **GetMyProgressAvgDto 직렬화 보강**: 필요 시 `@NoArgsConstructor` 등 JSON 역직렬화 대응. +7. **통합 테스트**: SC-001~005 박제(Flask `@MockBean` 호출 횟수, Redis 키/TTL, JobLog 시드). + +## Complexity Tracking + +> 위반 없음 — 비움. diff --git a/specs/006-statistics-cache-versioning/quickstart.md b/specs/006-statistics-cache-versioning/quickstart.md new file mode 100644 index 0000000..da8646f --- /dev/null +++ b/specs/006-statistics-cache-versioning/quickstart.md @@ -0,0 +1,49 @@ +# Quickstart: 통계 캐시 버전 키 검증 + +**Spec**: [spec.md](./spec.md) · **Plan**: [plan.md](./plan.md) + +## 전제 + +- Docker 실행 중(Testcontainers MySQL+Redis 자동 부팅). 로컬 데몬 불필요. +- Flask 통계 서버는 테스트에서 `@MockBean` 으로 대체(호출 횟수로 검증). + +## 자동 테스트로 인수 검증 (권장) + +```bash +./gradlew test --tests "*StatisticsCacheVersion*" +# 또는 전체 게이트 +./gradlew check +``` + +기대: 아래 인수 시나리오가 통합 테스트로 통과. + +| SC | 시나리오 | 검증 방식 | +|----|----------|-----------| +| SC-001 | 배치 완료 후 첫 조회 = 신선값 | JobLog endTime=D 시드 → 조회(Flask 1회) → JobLog endTime=D+1 시드 → 조회(Flask 추가 1회, 새 값) | +| SC-002 | 배치 진행 중 중간상태 미캐싱 | 마지막 완료=D, 진행중(endTime=null) JobLog(D+1) 존재 → 조회는 version=D 로 hit, Flask 추가 호출 없음 → D+1 완료 후 조회 시 재계산 | +| SC-003 | evict 0회 | version 전환 후 신규 키 miss·구 키 잔존 확인. 사용자별 캐시 삭제 호출 없음 | +| SC-004 | single-flight | 동일 member+version 동시 N 요청 → Flask 호출 1회 | +| SC-005 | 과거 버전 자연 만료 | 구 version 키 TTL(25h) 설정 확인 | + +## 수동 확인 (로컬 Redis 사용 시) + +```bash +# 1) 첫 조회 → MISS, Flask 호출, 캐싱 +curl -H "Authorization: Bearer " localhost:8080/api/v1/members/mypage + +# 2) Redis 키 확인 (version = 직전 완료 progressCalculation 기준일) +redis-cli KEYS 'challenge-avg::*' +# 예: challenge-avg::15:2026-06-06 + +# 3) 재조회 → HIT (Flask 미호출) +curl -H "Authorization: Bearer " localhost:8080/api/v1/members/mypage + +# 4) 배치 1회 완료시켜 새 날짜 JobLog 생성 후 재조회 → 새 키 MISS, 재계산 +redis-cli KEYS 'challenge-avg::*' +# 예: challenge-avg::15:2026-06-07 (구 키는 TTL 만료까지 잔존) +``` + +## 롤백/주의 + +- 캐시 스토어를 Caffeine→Redis 로 이관하므로, 배포 후 첫 워밍업 구간에 캐시 miss 가 일시 증가(정상). +- 버전 해석은 매 요청 DB 파생(메모 없음, R4) → 배치 완료 즉시 버전 반영. 직전 값이 보이는 구간은 배치 완료 시점 직전까지로 한정. diff --git a/specs/006-statistics-cache-versioning/research.md b/specs/006-statistics-cache-versioning/research.md new file mode 100644 index 0000000..4e88e0a --- /dev/null +++ b/specs/006-statistics-cache-versioning/research.md @@ -0,0 +1,94 @@ +# Phase 0 Research: 통계 캐시 버전 키 + +**Spec**: [spec.md](./spec.md) · **Branch**: `006-statistics-cache-versioning` · **Date**: 2026-06-06 + +기존 구현 사실(코드 확인 결과)을 전제로, spec 의 결정(Clarifications Q1~Q3)을 만족하는 기술 선택을 확정한다. + +## 현행 구현 사실 (Baseline) + +| 항목 | 현재 | 근거 | +|------|------|------| +| 통계 캐시 | Caffeine 로컬, cacheName `challenge-avg`, key `#memberId`, TTL 24h, `sync=true` | `MemberServiceImpl#getMyProgressAvgPer`, `CacheConfig` | +| 통계 계산 | Flask 호출 (RestTemplate, 재시도 5회) | `FlaskApiClient#getMyProgressAvg` | +| 일별 배치 | `progressCalculation()` — `progress_avg` 갱신, 완료 시 `JobLog(jobType="progressCalculation")` 저장 | `ScheduledTasks` | +| 배치 기록 | `JobLog`: `jobType`, `startTime`, `endTime`(완료 시 set, 미완료=null) | `JobLog`, `JobLogRepository`(plain JPA) | +| 엔드포인트 | `GET /api/v1/members/mypage` → `GetMyProgressAvgDto` | `MemberController` | +| 인프라 | Redis(Lettuce)·`@EnableCaching` 존재, Testcontainers(MySQL+Redis) 베이스 `IntegrationTest` 존재 | `RedisConfig`, `IntegrationTest` | +| 의존성 | `spring-boot-starter-cache`·`spring-boot-starter-data-redis`·`caffeine` 모두 보유 | `build.gradle` | + +> 신규 `build.gradle` 의존성 추가 없음 → 헌법 "라이브러리 도입 규칙" 트리거되지 않음. + +## 결정 (Decisions) + +### R1. 통계 캐시 스토어: Caffeine 로컬 → Redis 공유 캐시 + +- **Decision**: `challenge-avg` 캐시를 Redis 백엔드(`RedisCacheManager`)로 이관한다. TTL 25시간(버전 1세대 수명 + 여유). +- **Rationale**: spec Q1 — 다중 인스턴스 환경에서 모든 인스턴스가 동일 버전 키를 공유해야 한다. Redis 공유 캐시면 새 버전 전환 시 **전역 1회**만 재계산되고 이후 모든 인스턴스가 결과를 공유(SC-004 의도). Caffeine 로컬은 인스턴스마다 중복 계산이 불가피. +- **Alternatives**: (a) Caffeine 유지 + 버전 키 — 정합성은 되나 인스턴스별 중복 계산, spec 전제와 불일치. (b) 2단계(L1 Caffeine + L2 Redis) — 복잡도 과다, 본 스펙 범위 밖. + +### R2. 캐시 키에 버전 포함 + +- **Decision**: cacheName `challenge-avg` 유지, key = `#memberId + ':' + #version` (예: `challenge-avg::15:2026-06-06`). 서비스가 현재 버전을 먼저 해석해 캐시 메서드에 인자로 전달. +- **Rationale**: 버전이 바뀌면 키가 바뀌어 자연 miss → 재계산(FR-001/FR-002). 전수 evict 불필요(FR-004). +- **Alternatives**: cacheName 자체를 버전별로 분리(`challenge-avg-2026-06-06`) — Spring `RedisCacheManager`는 사전 등록 캐시명을 선호하고 동적 캐시명은 TTL 일괄 관리가 번거로움. 키 접미사 방식이 단순·표준적. + +### R3. 통계 버전 산정: 직전 완료 배치의 기준일 (DB 파생) + +- **Decision**: 통계 버전 = **마지막으로 완료된 `progressCalculation` JobLog 의 `endTime` 을 `Asia/Seoul` 기준 `LocalDate` 로 변환한 값**(`yyyy-MM-dd`). 완료 배치 0건이면 **오늘(Asia/Seoul)**. + - 쿼리: `progressCalculation` 이고 `endTime IS NOT NULL` 인 JobLog 중 `MAX(endTime)`. +- **Rationale**: + - spec Q2 — "직전 완료 배치의 데이터 기준일". `endTime.toLocalDate()` 는 날짜 특성상 **단조 진행**(FR-009) 이며 하루 여러 번 실행돼도 같은 날짜 → 같은 버전(캐시 유지). + - **자정 레이스 회피(US2/FR-003)**: 새 날 배치가 *완료*되기 전에는 마지막 완료가 어제 → 버전=어제 → 어제 값 제공, 중간 상태가 새 버전으로 캐싱되지 않음. 새 날 배치 완료 시점에 `MAX(endTime)` 날짜가 오늘로 넘어가며 버전 전환. + - **헌법 II**: 버전 진실 공급원을 DB(JobLog)로 두어 도메인/서비스가 `RedisTemplate` 을 직접 만지지 않는다. Redis 별도 버전 키 불필요 → 스케줄러 변경 0, 회귀 위험 최소. +- **Alternatives**: + - 배치가 Redis 에 `statistics:version` 기록 — 스케줄러 수정 필요 + Redis 휘발 시 버전 유실 위험. 기각. + - 단조 카운터 — 전용 상태 저장 필요, 날짜보다 가독성↓. 기각. +- **콜드스타트 잔여 리스크(Q3 인지)**: 완료 0건일 때 오늘을 잠정 버전으로 캐싱한 뒤, 같은 날짜 기준 배치가 완료되면 버전(날짜)이 동일해 즉시 갱신이 안 될 수 있음 → 최초 배포 당일 1회성·짧은 구간으로 허용(brief-stale 전제와 일관). 본 스펙에서는 잠정/확정 구분을 구현하지 않는다(Out of scope, 필요 시 후속). + +### R4. 버전 해석: 매 요청 DB 파생 (메모이즈 안 함) + +- **Decision**: `getCurrentVersion()` 은 **매 요청 DB 에서 직접** 파생한다(별도 버전 캐시 없음). `job_log(job_type, end_time)` 복합 인덱스를 추가해 `MAX(endTime)` 을 인덱스 끝값 1건 읽기로 처리. +- **Rationale**: + - 막아주는 비용이 인덱스된 `MAX` 스칼라 1건(≈0.1ms)에 불과 — 마이페이지 트래픽 수준에서 캐싱으로 아낄 가치가 낮다(조기 최적화 회피). + - 메모 제거 시 **stale 창이 0 으로 수렴**(배치 완료 즉시 버전 반영), 동시 조회 레이스 경계도 "메모 만료 instant"(≤TTL) → "DB 커밋 instant"(마이크로초)로 축소. + - 코드 단순화(캐시 1개 제거), 정확성↑. +- **Alternatives**: + - 짧은 TTL(5~60s) 메모 — 트래픽 폭주 시 DB 부하 절감되나 stale 창·레이스 창을 그만큼 도입. **실측 부하가 확인되면 그때 재도입**(후속 최적화로 보류). + - 영구/긴 캐시 — 버전 전환 미감지. 기각. +- **인덱스 근거**: 단조 증가하는 `end_time` 의 MAX 를 인덱스 역방향 스캔 1건으로 → 풀스캔 회피. + +### R5. 단일 비행(single-flight) — FR-006 / SC-004 + +- **Decision**: `RedisCacheManager` 를 `RedisCacheWriter.lockingRedisCacheWriter(connectionFactory)` 로 구성하고 캐시 메서드에 `sync = true` 유지. +- **Rationale**: 새 버전 첫 조회가 동시 다발일 때 locking writer 가 동일 키 적재를 직렬화 → 외부 Flask 중복 호출을 "요청당 1회로 수렴"(SC-004). 인스턴스 내 `sync`(per-key lock)와 결합해 스탬피드 방지. +- **Alternatives**: 분산 락 라이브러리(Redisson) 도입 — 신규 의존성, 과한 복잡도. 기각. + +### R6. 실패 미캐싱 — FR-007 + +- **Decision**: 캐시 메서드는 Flask 실패 시 예외를 그대로 전파한다(`@Cacheable` 은 예외 시 저장하지 않음). sentinel/빈 결과를 성공으로 캐싱하지 않는다. +- **Rationale**: 기존 `FlaskApiClient` 가 재시도 후 `FlaskConnectionFailedException`/`ProgressAvgNotFoundException` 을 던짐 → 자연히 미캐싱. 다음 요청이 재계산 시도 가능. + +### R7. 자기호출(self-invocation) 회피 + +- **Decision**: `@Cacheable` 메서드를 별도 빈 `MemberStatisticsCacheService` 로 분리하고 `MemberServiceImpl` 이 주입받아 호출. `MemberServiceImpl#getMyProgressAvgPer` 는 (1) 버전 해석 → (2) 캐시 서비스 호출 오케스트레이션만. +- **Rationale**: 같은 빈 내부 호출은 Spring AOP 캐시 프록시를 우회한다. 분리해야 캐시 어드바이스가 적용됨. spec 의 `statisticsCacheService.getUserStatistics(...)` 스케치와 동일. + +### R8. Redis 직렬화 + +- **Decision**: 값 직렬화는 `GenericJackson2JsonRedisSerializer` 사용. `GetMyProgressAvgDto` 는 모두 원시 타입 필드라 추가 모듈 불필요. (LocalDate 등 등장 시 `JavaTimeModule` 등록.) +- **Rationale**: JDK 직렬화 회피, 가독성·호환성. DTO 에 기본 생성자 보강 필요 여부는 구현 시 확인(`@NoArgsConstructor` 추가 가능). + +### R9. 타임존 고정 + +- **Decision**: 버전 날짜 산정과 콜드스타트 모두 `Asia/Seoul` 고정. +- **Rationale**: 서버 로캘 흔들림에 따른 버전 경계 불일치 방지. 다중 인스턴스 일관성(FR-008). + +### R10. 테스트 전략 (헌법 I·VII) + +- **Decision**: `IntegrationTest`(MySQL+Redis Testcontainers) 확장. `FlaskApiClient` 는 `@MockBean` 으로 대체해 **호출 횟수**로 캐시 hit/miss 와 single-flight 를 검증. JobLog 는 실제 DB 에 시드해 버전 전환을 재현. +- **Rationale**: Flask 는 MySQL/Redis 가 아니므로 헌법 I 의 컨테이너 의무 대상이 아니고, 호출 횟수 검증이 캐시 동작 인수에 가장 직접적. Redis 캐시·JobLog DB 는 실제 컨테이너로 동작. +- **SC ↔ 테스트 매핑**: tasks 단계에서 SC-001~005 각각에 IT 메서드를 1:1 박제. + +## 미해결(NEEDS CLARIFICATION) + +없음 — Phase 0 의 모든 미지수는 위 결정으로 해소됨. diff --git a/specs/006-statistics-cache-versioning/spec.md b/specs/006-statistics-cache-versioning/spec.md new file mode 100644 index 0000000..808c1d1 --- /dev/null +++ b/specs/006-statistics-cache-versioning/spec.md @@ -0,0 +1,111 @@ +# Feature Specification: 사용자 통계 캐시 stale-cache 해소 (통계 버전 기반 캐시 키) + +**Feature Branch**: `006-statistics-cache-versioning` + +**Created**: 2026-06-06 + +**Status**: Draft + +**Input**: User description: "사용자 통계(user-statistics) 캐시의 stale cache 문제 해결. 통계는 사용자가 참여한 모든 챌린지를 기준으로 외부 통계 서버가 계산하며 그 결과가 캐싱된다. 자정을 넘겨 일별 통계 배치가 갱신되면 기존 캐시가 stale 해지는데, 현재는 모든 사용자 캐시를 evict 하기 어렵다. 해결 방향: 캐시 키에 '통계 버전'(또는 통계 기준일)을 포함해 버전이 바뀌면 자연스럽게 새 키로 miss 가 발생하고 통계를 재계산하도록 한다. 모든 사용자 캐시를 직접 evict 하지 않고 기존 캐시는 TTL 로 자연 만료시킨다. 단, 자정 직후 배치가 아직 끝나지 않았는데 첫 요청이 들어와 갱신 전 데이터가 새 키로 캐싱되는 문제를 막기 위해, 날짜 기준이 아니라 '배치 완료 후에만 증가하는 통계 버전' 기준으로 키를 구성한다." + +## Clarifications + +### Session 2026-06-06 + +- Q: 통계 버전의 진실 공급원(source of truth)을 인스턴스 로컬로 둘지, 공유 영속 소스로 둘지? → A: 다중 인스턴스 가정 — 통계 결과는 **공유 분산 캐시(Redis)** 에 보관하고, 현재 통계 버전도 모든 인스턴스가 동일하게 인식하는 **공유 소스**에서 파생한다. (캐시 기술 가정을 인스턴스 로컬 Caffeine → 공유 Redis 로 변경) +- Q: "통계 버전"을 무엇으로 식별/결정하는가? → A: **직전에 성공 완료된 일별 통계 배치가 반영한 데이터 기준일(영업일, 예: 2026-06-06)**. 단 "오늘 날짜"가 아니라 "마지막으로 완료된 배치의 기준일"이므로 자정 직후 배치 미완료 구간에는 직전 기준일이 유지되어 중간 상태 캐싱(US2)을 막는다. +- Q: 완료된 배치가 아직 0건인 콜드스타트 시 동작? → A: **오늘 기준일을 잠정 버전으로 사용**해 실시간 계산값을 제공·캐싱한다(최초 배포 당일 한정). 잔여 리스크: 잠정값 캐싱 후 같은 날짜 기준 배치가 완료되면 버전(날짜)이 동일해 즉시 갱신이 안 될 수 있으나, 최초 1회성·짧은 구간이라 brief-stale 허용 전제와 일관된다. 즉시성이 필요하면 plan 단계에서 잠정/확정 버전 구분으로 해소한다. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - 배치 갱신 후 최신 통계 노출 (Priority: P1) + +마이페이지 통계(완주율 평균·카테고리별 비교)를 조회하는 사용자는, 일별 통계 배치가 새로운 집계값을 산출한 뒤 처음 통계를 열었을 때 **갱신된 최신 수치**를 본다. 이전 배치 시점에 캐싱된 낡은 값이 더 이상 노출되지 않는다. + +**Why this priority**: 이 기능의 존재 이유 그 자체다. 현재는 한 번 캐싱된 통계가 캐시 만료 시간(최대 24시간)까지 그대로 남아, 배치가 갱신한 새 집계값이 사용자에게 하루 가까이 보이지 않을 수 있다. 통계의 신뢰성이 직접 훼손되는 문제이므로 최우선이다. + +**Independent Test**: 통계를 한 번 조회해 캐싱시킨 뒤, 통계 배치를 1회 완료시키고 동일 사용자가 다시 통계를 조회하면 — 별도의 캐시 비우기 조작 없이도 — 배치가 산출한 새 수치가 반환되는지로 단독 검증 가능하다. + +**Acceptance Scenarios**: + +1. **Given** 사용자가 통계를 조회해 결과가 캐싱된 상태, **When** 일별 통계 배치가 새로운 집계값으로 완료된 뒤 같은 사용자가 통계를 다시 조회하면, **Then** 배치가 산출한 갱신된 수치가 반환된다. +2. **Given** 통계 배치가 완료된 직후, **When** 아직 한 번도 새 버전으로 통계를 조회하지 않은 사용자가 처음 조회하면, **Then** 외부 통계 서버를 통해 최신 데이터가 새로 계산되어 반환되고 이후 조회를 위해 캐싱된다. + +--- + +### User Story 2 - 배치 진행 중 중간 상태 캐싱 방지 (Priority: P1) + +자정 직후처럼 통계 배치가 **아직 완료되지 않은 시점**에 통계를 조회하는 사용자는, 배치가 절반만 반영된 불완전한 중간 집계값이 "오늘자 최신값"으로 캐싱되어 굳어버리는 일을 겪지 않는다. + +**Why this priority**: 단순히 "날짜가 바뀌면 새 키"로 처리하면, 배치가 끝나기 전에 들어온 첫 요청이 미완성 데이터를 새 날짜 키로 캐싱해 오히려 잘못된 값을 하루 동안 고정시킬 수 있다. 이 경쟁 조건을 막지 못하면 P1(최신성)을 달성해도 데이터 정합성이 깨지므로 동일하게 P1이다. + +**Independent Test**: 배치가 시작되었으나 완료 처리되지 않은 상태를 만들고 통계를 조회한 뒤, 배치를 완료시키고 다시 조회했을 때 — 배치 진행 중에 조회된 중간값이 캐싱되어 재사용되지 않고 완료 후 값이 반환되는지로 단독 검증 가능하다. + +**Acceptance Scenarios**: + +1. **Given** 통계 배치가 시작되었으나 아직 완료되지 않은 상태, **When** 사용자가 통계를 조회하면, **Then** 직전에 완료된 배치 기준의 값(직전 버전)이 제공되며, 진행 중 데이터가 "최신 버전"으로 캐싱되지 않는다. +2. **Given** 배치 진행 중 조회로 직전 버전 값이 사용된 뒤, **When** 배치가 완료되어 통계 버전이 올라가고 사용자가 다시 조회하면, **Then** 완료된 배치 기준의 갱신된 값이 새로 계산·반환된다. + +--- + +### User Story 3 - 대량 일괄 캐시 무효화 없이 자연 만료 (Priority: P2) + +운영자는 통계 신선도를 보장하기 위해 모든 사용자의 캐시를 일괄로 찾아 비우는 작업을 수행할 필요가 없다. 새 버전으로의 전환은 키 변경만으로 일어나고, 더 이상 참조되지 않는 옛 버전 캐시 항목은 만료 시간에 따라 스스로 사라진다. + +**Why this priority**: 신선도 자체는 P1에서 달성된다. 본 스토리는 그 달성 방식이 "전수 evict" 같은 비용 큰 운영 동작을 요구하지 않아야 한다는 품질·운영 제약이다. 시스템 안정성과 유지보수성에 기여하므로 P2. + +**Independent Test**: 버전 전환 전후로 시스템이 사용자 캐시를 명시적으로 삭제하는 동작을 수행하지 않으며, 옛 버전 키 항목이 설정된 만료 시간 경과 후 제거되는지로 단독 검증 가능하다. + +**Acceptance Scenarios**: + +1. **Given** 다수 사용자의 통계가 직전 버전으로 캐싱된 상태, **When** 통계 버전이 새 버전으로 전환되면, **Then** 시스템은 사용자별 캐시를 일괄 삭제하지 않고도 새 조회가 새 버전 키로 동작한다. +2. **Given** 옛 버전으로 캐싱된 항목이 더 이상 참조되지 않는 상태, **When** 만료 시간이 경과하면, **Then** 해당 항목은 자동으로 제거되어 메모리에 무한히 누적되지 않는다. + +--- + +### Edge Cases + +- **배치 실패/미실행**: 일별 통계 배치가 실패하거나 실행되지 않으면 통계 버전이 올라가지 않는다. 이 경우 사용자에게는 직전에 성공한 배치 기준의 일관된 값이 계속 제공되어야 하며, 미완성·중간 상태가 노출되어서는 안 된다. +- **콜드스타트(완료 배치 0건)**: 성공 완료된 배치가 한 번도 없으면(최초 배포 직후) **오늘 기준일을 잠정 버전**으로 사용해 실시간 계산값을 제공·캐싱한다. 단, 잠정값 캐싱 이후 같은 날짜 기준 배치가 완료되어도 버전(날짜)이 동일하면 즉시 갱신되지 않을 수 있다 — 최초 1회성·짧은 구간으로 허용하되, 즉시성이 필요하면 잠정/확정 버전을 구분해 해소한다. +- **버전 전환 직후 동시 다발 요청(thundering herd)**: 새 버전 첫 조회 시 여러 요청이 동시에 캐시 미스를 일으켜 외부 통계 서버를 중복 호출하지 않도록, 동일 키에 대한 계산은 한 번만 수행되고 나머지는 그 결과를 공유해야 한다. +- **외부 통계 서버 일시 장애**: 새 버전 첫 계산 중 외부 통계 서버가 실패하면, 실패한(불완전) 결과가 새 버전 값으로 캐싱되어 굳어서는 안 된다. 재시도 후에도 실패 시 사용자에게는 오류가 전달되며, 다음 요청이 다시 계산을 시도할 수 있어야 한다. +- **버전 식별자 동률/되돌림**: 통계 버전 식별자는 단조롭게 진행(같거나 증가)해야 하며, 직전 값보다 과거로 되돌아가는 식별자로 인해 옛 캐시가 "최신"으로 오인되어서는 안 된다. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: 시스템은 사용자 통계 조회 결과를 **통계 버전 식별자별로 구분된 캐시 항목**으로 보관해야 한다. 동일 사용자라도 통계 버전이 다르면 서로 다른 캐시 항목으로 취급된다. +- **FR-002**: 시스템은 사용자 통계를 조회할 때, **현재 통계 버전**을 기준으로 캐시를 조회하고, 미스 시 외부 통계 서버를 통해 값을 계산한 뒤 해당 버전의 캐시 항목으로 저장해야 한다. +- **FR-003**: 통계 버전은 **일별 통계 배치가 성공적으로 완료된 이후에만** 새 버전으로 전환되어야 한다. 배치가 시작되었으나 아직 완료되지 않은 동안에는 직전에 완료된 배치의 버전이 현재 버전으로 유지된다. +- **FR-004**: 시스템은 통계 신선도 보장을 위해 **사용자별 캐시 항목을 일괄로 무효화·삭제하는 동작을 요구하지 않아야** 한다. 버전 전환은 조회 키 변경만으로 새 데이터 계산을 유발한다. +- **FR-005**: 더 이상 참조되지 않는 과거 버전의 캐시 항목은 **설정된 만료 시간에 따라 자동으로 제거**되어 캐시가 무한히 누적되지 않아야 한다. +- **FR-006**: 새 버전에 대한 첫 조회가 동시에 다수 발생하더라도, 동일 사용자·동일 버전의 외부 통계 계산은 **중복 호출 없이 한 번만 수행**되고 그 결과가 공유되어야 한다. +- **FR-007**: 외부 통계 서버 호출이 실패하면 **실패·불완전 결과가 해당 버전 값으로 영구 캐싱되지 않아야** 하며, 이후 요청이 재계산을 시도할 수 있어야 한다. +- **FR-008**: 통계 버전 전환은 **모든 사용자에게 일관되게** 적용되어야 한다(특정 사용자만 옛 버전에 머무르거나 다른 사용자만 새 버전을 보는 일이 없어야 한다). 다중 인스턴스로 운영되는 경우에도 동일 시점의 현재 버전 인식이 일치해야 한다. +- **FR-009**: 통계 버전 식별자는 **단조 진행**(같거나 증가)해야 하며, 과거 값으로 되돌아가 옛 캐시가 최신으로 오인되는 일이 없어야 한다. + +### Key Entities *(include if feature involves data)* + +- **사용자 통계 결과(User Statistics Result)**: 한 사용자의 마이페이지 통계 집계값(완주 수·전체 도전 수, 전체 및 카테고리별 본인 평균/비율/전체 평균). 외부 통계 서버가 계산하며 캐싱 대상이다. +- **통계 버전(Statistics Version)**: 현재 유효한 통계 데이터 세대를 식별하는 값으로, **직전에 성공 완료된 일별 통계 배치가 반영한 데이터 기준일(영업일, 예: `2026-06-06`)** 로 표현된다. "오늘 날짜"가 아니라 "마지막 완료 배치의 기준일"이므로 배치가 완료될 때만 새 값(다음 영업일)으로 전환되며, 날짜 특성상 단조 진행한다. 완료된 배치가 0건인 콜드스타트 시에는 오늘 기준일을 잠정 버전으로 사용한다. 캐시 항목을 세대별로 구분하는 키 구성 요소다. +- **통계 배치 실행 기록(Statistics Batch Run)**: 일별 통계 배치의 시작·완료 상태와 시점을 나타내는 기록. 어떤 배치가 성공적으로 완료되었는지를 판단해 현재 통계 버전을 결정하는 근거가 된다. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 일별 통계 배치가 완료된 뒤 사용자가 통계를 처음 조회하면, 배치가 산출한 갱신값이 **100% 반영**되어 반환된다(배치 완료 후 옛 값이 노출되는 비율 0%). +- **SC-002**: 통계 배치가 시작되었으나 완료되지 않은 시점에 들어온 조회가 산출하는 값이, 이후 완료된 배치 기준 조회에서 재사용되는 사례가 **0건**이다(중간 상태 고정 0건). +- **SC-003**: 통계 신선도 보장을 위해 시스템이 수행하는 **사용자별 일괄 캐시 삭제 동작은 0회**다(버전 전환만으로 신선도 확보). +- **SC-004**: 동일 사용자·동일 버전에 대한 동시 첫 조회 다수가 발생해도 외부 통계 서버 호출은 **요청당 1회로 수렴**(동일 키 중복 계산 없음)한다. +- **SC-005**: 과거 버전 캐시 항목은 설정된 만료 시간 경과 후 제거되어, 버전이 누적되어도 캐시 보관량이 **무한히 증가하지 않는다**. + +## Assumptions + +- 본 스펙이 말하는 "사용자 통계"는 현재 마이페이지 통계 평균(완주율 평균·카테고리별 비교) 조회 결과 한 종류를 가리킨다. 다른 캐시 항목은 범위 밖이다. +- 통계 결과는 **공유 분산 캐시(Redis)** 로 보관된다고 가정하며 만료 시간(현재 24시간 수준)이 설정된다. 버전 키 도입으로 새 버전 전환 시 전체적으로 첫 조회 1회만 재계산(동일 버전·동일 사용자 기준)되고 이후 모든 인스턴스가 그 결과를 공유하므로, 전수 evict가 불필요해지는 것이 의도된 동작이다. (참고: 현재 코드 구현은 Caffeine 로컬 캐시지만, 본 스펙은 공유 Redis 캐시를 전제로 한다.) +- "통계 버전"의 전환 근거는 **일별 통계 배치(progressCalculation)의 성공적 완료**다. 배치 완료를 식별할 수 있는 기록(실행 로그/완료 시점)이 현재 시스템에 존재하며 이를 버전 산정에 활용할 수 있다고 가정한다. +- 배치 완료 직후 버전이 올라가기 전까지의 짧은 구간에서 직전 버전 값(직전 완료 배치 기준)이 제공되는 것은 허용된다. 핵심 제약은 "미완성·중간 상태가 새 버전으로 캐싱되지 않는 것"이다. +- 외부 통계 서버 호출에는 기존의 재시도 정책이 적용되며, 본 스펙은 그 위에 "실패 결과를 캐싱하지 않는다"는 제약을 더한다. +- 통계 버전 전환은 사용자 단위가 아니라 **전역 단위**(한 세대 전체)로 일어난다. 사용자별로 서로 다른 버전을 동시에 운영하지 않는다. diff --git a/specs/006-statistics-cache-versioning/tasks.md b/specs/006-statistics-cache-versioning/tasks.md new file mode 100644 index 0000000..aec3964 --- /dev/null +++ b/specs/006-statistics-cache-versioning/tasks.md @@ -0,0 +1,186 @@ +--- +description: "Task list — 통계 캐시 버전 키 (stale-cache 해소)" +--- + +# Tasks: 사용자 통계 캐시 stale-cache 해소 (통계 버전 기반 캐시 키) + +**Input**: Design documents from `specs/006-statistics-cache-versioning/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/cache-key.md, quickstart.md + +**Tests**: 헌법 VII(인수기준=자동테스트, NON-NEGOTIABLE)에 따라 **필수 포함**. 각 SC 를 Testcontainers(MySQL+Redis) 통합 테스트로 박제한다. + +**Organization**: 본 기능은 단일 코드 경로(버전 해석 → 버전 키 캐시 → Flask)를 여러 *시나리오*로 검증하는 인프라 기능이다. 따라서 핵심 메커니즘은 Foundational 에 모이고, 각 User Story 단계는 해당 시나리오의 **인수 테스트 + 필요한 마무리 배선**으로 구성된다. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: 병렬 가능(다른 파일, 미완 의존 없음) +- **[Story]**: US1/US2/US3 +- 모든 태스크에 정확한 파일 경로 포함 + +## Path Conventions + +- Spring Boot 단일 모듈: `src/main/java/com/planetrush/planetrush/...`, 테스트 `src/test/java/com/planetrush/planetrush/...` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: 테스트 지원 도구 준비. 신규 `build.gradle` 의존성 없음(`spring-boot-starter-cache`·`data-redis`·`caffeine` 보유 확인). + +- [ ] T001 [P] 테스트 지원 헬퍼 작성 — JobLog(완료/진행중) 시드 + Redis 키/TTL 조회 유틸 in `src/test/java/com/planetrush/planetrush/member/testsupport/StatisticsCacheTestSupport.java` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: 버전 해석 + 버전 키 Redis 캐시 메커니즘. **모든 User Story 의 전제** — 완료 전 어떤 스토리도 통과 불가. + +**⚠️ CRITICAL**: 이 단계가 끝나야 US1~US3 인수 테스트가 의미를 가진다. + +- [ ] T002 JobLog 엔티티에 `(job_type, end_time)` 복합 인덱스 추가(`@Table(indexes=...)`) in `src/main/java/com/planetrush/planetrush/scheduler/log/JobLog.java` +- [ ] T003 JobLog 버전 쿼리(QueryDSL) — `findLatestCompletedProgressCalculationEndTime(): Optional` (`jobType='progressCalculation'` AND `endTime IS NOT NULL` 의 `MAX(endTime)`) in `src/main/java/com/planetrush/planetrush/scheduler/log/JobLogRepositoryCustom.java` + `JobLogRepositoryCustomImpl.java`; `JobLogRepository` 가 custom 인터페이스 상속하도록 수정 in `JobLogRepository.java` +- [ ] T004 `StatisticsVersionProvider` 작성 — T003 결과를 `Asia/Seoul` `LocalDate`→`yyyy-MM-dd` 로 변환, 완료 0건이면 오늘(콜드스타트). **캐시 없음(매 요청 DB 파생, R4)** in `src/main/java/com/planetrush/planetrush/member/service/StatisticsVersionProvider.java` +- [ ] T005 `CacheConfig` 이관 — `challenge-avg` 를 `RedisCacheManager`(TTL 25h, `GenericJackson2JsonRedisSerializer`, `RedisCacheWriter.lockingRedisCacheWriter`)로 전환, 기존 Caffeine-only `cacheManager` 정리 in `src/main/java/com/planetrush/planetrush/core/config/CacheConfig.java` +- [ ] T006 [P] `GetMyProgressAvgDto` JSON 역직렬화 대응 — `@NoArgsConstructor`(필요 시 접근자) 보강, Redis 직렬화 호환 확인 in `src/main/java/com/planetrush/planetrush/member/service/dto/GetMyProgressAvgDto.java` + +**Checkpoint**: 버전 해석·Redis 버전 키 캐시·직렬화 준비 완료 → 스토리 인수 테스트 착수 가능 + +--- + +## Phase 3: User Story 1 - 배치 갱신 후 최신 통계 노출 (Priority: P1) 🎯 MVP + +**Goal**: 일별 배치가 새 집계값을 완료한 뒤 첫 조회에서 갱신된 수치가 반환된다(SC-001). + +**Independent Test**: JobLog `endTime=D` 시드 → 조회(Flask 1회·캐싱) → JobLog `endTime=D+1` 시드 → 조회 시 Flask 추가 1회 호출·새 값 반환. + +### Tests for User Story 1 ⚠️ (먼저 작성, 실패 확인 후 구현) + +- [ ] T007 [US1] SC-001 통합 테스트 — 버전 전환(D→D+1) 후 첫 조회가 재계산(Flask 추가 호출)·신선값 반환, 전환 전 재조회는 HIT(Flask 미호출) in `src/test/java/com/planetrush/planetrush/member/StatisticsCacheVersionIntegrationTest.java` (`extends IntegrationTest`, `@MockBean FlaskApiClient` 호출 횟수 검증) + +### Implementation for User Story 1 + +- [ ] T008 [US1] `MemberStatisticsCacheService` 작성 — `@Cacheable(cacheNames="challenge-avg", key="#memberId + ':' + #version", sync=true)`, 내부에서 member 검증 + `flaskApiClient.getMyProgressAvg(memberId)` 호출(예외 전파) in `src/main/java/com/planetrush/planetrush/member/service/MemberStatisticsCacheService.java` +- [ ] T009 [US1] `MemberServiceImpl#getMyProgressAvgPer` 리팩터 — 기존 `@Cacheable` 제거, `statisticsVersionProvider.getCurrentVersion()` 해석 → `memberStatisticsCacheService.getStatistics(memberId, version)` 위임(자기호출 회피, R7) in `src/main/java/com/planetrush/planetrush/member/service/MemberServiceImpl.java` + +**Checkpoint**: SC-001 green — stale-cache 해소 핵심 동작 완성(MVP) + +--- + +## Phase 4: User Story 2 - 배치 진행 중 중간 상태 캐싱 방지 (Priority: P1) + +**Goal**: 배치가 *완료되기 전* 조회는 직전 버전 값으로 처리되고, 진행 중 데이터가 새 버전으로 캐싱되지 않는다(SC-002). + +**Independent Test**: 마지막 완료=D, 진행중(`endTime=null`) JobLog(D+1) 존재 → 조회는 version=D 로 HIT(Flask 추가 호출 없음) → D+1 완료 처리 후 조회 시 재계산. + +### Tests for User Story 2 ⚠️ + +- [ ] T010 [US2] SC-002 통합 테스트 — 진행중 배치(endTime=null) 구간 조회가 version=D 유지·중간상태 미캐싱, D+1 완료 후 전환 검증 in `StatisticsCacheVersionIntegrationTest.java` + +### Implementation for User Story 2 + +- [ ] T011 [US2] `StatisticsVersionProvider`/T003 쿼리의 `endTime IS NOT NULL`(진행중 제외) 불변식 가드·검증 보강 in `JobLogRepositoryCustomImpl.java` (필요 시 주석/테스트 보강만) + +**Checkpoint**: SC-002 green — 자정 레이스로 인한 중간상태 고정 방지 + +--- + +## Phase 5: User Story 3 - 전수 evict 없이 자연 만료 (Priority: P2) + +**Goal**: 버전 전환이 사용자별 명시 evict 없이 키 변경만으로 신선도를 확보하고, 과거 버전 키는 TTL 로 자연 만료된다(SC-003, SC-005). + +**Independent Test**: 버전 전환 후 신규 키 miss·구 키 잔존 확인, 사용자별 캐시 삭제 호출 0회; 캐시 항목 TTL(25h) 설정 확인. + +### Tests for User Story 3 ⚠️ + +- [ ] T012 [US3] SC-003 통합 테스트 — 버전 전환 시 신규 키 생성·구 버전 키 잔존, 사용자별 evict/삭제 미수행 검증 in `StatisticsCacheVersionIntegrationTest.java` +- [ ] T013 [P] [US3] SC-005 테스트 — Redis 키 TTL 이 25h(≈90000s) 근사로 설정됨(`redis TTL` 조회) 검증 in `StatisticsCacheVersionIntegrationTest.java` + +**Checkpoint**: SC-003·SC-005 green — 운영 evict 0회 + 무한 누적 방지 + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: 강건성 인수(SC-004/FR-007/콜드스타트) + 헌법 게이트. + +- [ ] T014 [P] SC-004 통합 테스트(single-flight) — 동일 `memberId:version` 동시 N 요청에서 Flask 호출 1회 수렴(`CountDownLatch` + 지연 Flask mock) in `StatisticsCacheVersionIntegrationTest.java` +- [ ] T015 SC-004 충족 검증/보강 — `lockingRedisCacheWriter`+`sync=true` 구성이 동시 적재 직렬화를 보장하는지 확인(T005 연계) in `CacheConfig.java` +- [ ] T016 [P] FR-007 테스트 — Flask 예외 시 해당 키 미캐싱, 다음 요청 재시도(Flask 2회) 검증 in `StatisticsCacheVersionIntegrationTest.java` +- [ ] T017 [P] 콜드스타트(Q3) 테스트 — 완료 JobLog 0건이면 version=오늘(Asia/Seoul)로 계산·캐싱 검증 in `StatisticsCacheVersionIntegrationTest.java` +- [ ] T018 [P] Caffeine→Redis 이관 회귀 점검 — `challenge-avg` 외 캐시/테스트 영향 없는지 확인(필요 시 test 프로파일 캐시 설정 보정) +- [ ] T019 quickstart.md 검증 절차 실행·기록 in `specs/006-statistics-cache-versioning/quickstart.md` +- [ ] T020 헌법 게이트 — `./gradlew check` 및 `./gradlew verifySecretLogScan`(헌법 V) 통과 확인, 결과 증거 기록 + +--- + +## 인수 기준 ↔ 테스트 매핑 (헌법 VII — /speckit-analyze 게이트) + +| Success Criteria | User Story | 테스트 태스크 | 메서드 위치 | +|------------------|-----------|---------------|-------------| +| SC-001 배치 완료 후 최신값 100% | US1 | T007 | `StatisticsCacheVersionIntegrationTest` | +| SC-002 중간상태 고정 0건 | US2 | T010 | 〃 | +| SC-003 사용자별 evict 0회 | US3 | T012 | 〃 | +| SC-005 과거 버전 TTL 자연 만료 | US3 | T013 | 〃 | +| SC-004 single-flight 1회 수렴 | Polish(FR-006) | T014 | 〃 | +| FR-007 실패 비캐싱 | Polish | T016 | 〃 | +| 콜드스타트(Q3) | Polish | T017 | 〃 | + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup(P1)**: 즉시 시작 가능 +- **Foundational(P2)**: Setup 후. **모든 US 차단**. 내부 순서: T002 → T003 → T004(버전 해석), T005·T006(캐시 인프라)는 T003/T004 와 병렬 가능하나 T009 전 완료 필요 +- **US1(P3)**: Foundational 후. MVP +- **US2(P4)·US3(P5)**: Foundational 후. US1 의 배선(T008/T009)에 의존(같은 코드 경로 검증) → US1 완료 후 진행 권장 +- **Polish(P6)**: 대상 US 완료 후 + +### Within Each User Story + +- 테스트 먼저 작성·실패 확인 → 구현 → green +- US2/US3 는 신규 src/main 변경이 거의 없고 주로 인수 테스트(메커니즘은 Foundational+US1 에서 완성) + +### Parallel Opportunities + +- T006 은 T002~T005 와 병렬(다른 파일) +- Polish 의 T014/T016/T017/T018 은 서로 병렬(독립 테스트 메서드/점검) +- 단, 동일 파일(`StatisticsCacheVersionIntegrationTest.java`)에 여러 [P] 테스트가 추가되므로, 물리적 병렬 작성 시 머지 충돌 주의(논리적 독립일 뿐) + +--- + +## Parallel Example: Foundational + +```bash +# T003/T004(버전 해석 라인)과 T005/T006(캐시 인프라)을 병렬로: +Task: "JobLog QueryDSL MAX(endTime) 쿼리 in JobLogRepositoryCustomImpl.java" +Task: "StatisticsVersionProvider in member/service/StatisticsVersionProvider.java" +Task: "CacheConfig Redis 이관 in core/config/CacheConfig.java" +Task: "GetMyProgressAvgDto 직렬화 보강 in member/service/dto/GetMyProgressAvgDto.java" +``` + +--- + +## Implementation Strategy + +### MVP First (US1) + +1. Phase 1 Setup → Phase 2 Foundational(메커니즘 완성) +2. Phase 3 US1(T007 테스트 → T008/T009 구현) → **SC-001 green** +3. **STOP & VALIDATE**: stale-cache 해소 핵심을 단독 검증·시연 + +### Incremental Delivery + +- US1(SC-001) → US2(SC-002) → US3(SC-003/005) → Polish(SC-004/FR-007/콜드스타트/게이트) +- 각 단계가 이전 동작을 깨지 않고 인수 기준을 하나씩 박제 + +--- + +## Notes + +- 신규 `build.gradle` 의존성 없음 → 헌법 라이브러리 규칙 미트리거 +- 외부 의존(Flask)은 `@MockBean` 으로 호출 횟수 검증(헌법 I 의 컨테이너 의무 대상은 MySQL·Redis) +- 모든 통합 테스트는 `IntegrationTest`(MySQL+Redis Testcontainers) 확장 — 로컬 데몬 불요 +- 각 태스크/논리 그룹 후 커밋. untracked `docs/maxlen-*.md`(타 세션 산출물)는 커밋 제외 diff --git a/src/main/java/com/planetrush/planetrush/PlanetrushApplication.java b/src/main/java/com/planetrush/planetrush/PlanetrushApplication.java index 238ea48..1a8ebeb 100644 --- a/src/main/java/com/planetrush/planetrush/PlanetrushApplication.java +++ b/src/main/java/com/planetrush/planetrush/PlanetrushApplication.java @@ -2,10 +2,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.cache.annotation.EnableCaching; import org.springframework.scheduling.annotation.EnableScheduling; -@EnableCaching @EnableScheduling @SpringBootApplication public class PlanetrushApplication { diff --git a/src/main/java/com/planetrush/planetrush/core/config/CacheConfig.java b/src/main/java/com/planetrush/planetrush/core/config/CacheConfig.java index 43ebe9e..a582bbf 100644 --- a/src/main/java/com/planetrush/planetrush/core/config/CacheConfig.java +++ b/src/main/java/com/planetrush/planetrush/core/config/CacheConfig.java @@ -1,38 +1,52 @@ package com.planetrush.planetrush.core.config; -import java.util.concurrent.TimeUnit; +import java.time.Duration; import org.springframework.cache.CacheManager; -import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; - -import com.github.benmanes.caffeine.cache.Caffeine; - -import lombok.AllArgsConstructor; -import lombok.Getter; - +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.cache.RedisCacheWriter; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; + +/** + * 통계 캐시 설정. + * + *

Spec 006 — T005 / R1·R5·R8. 기존 Caffeine 로컬 캐시를 다중 인스턴스 공유를 위해 + * Redis 백엔드({@link RedisCacheManager})로 이관한다. 버전 키 전환만으로 신선도를 확보하므로 + * 전수 evict 가 불필요하다. + * + *

    + *
  • cacheName {@code challenge-avg} — TTL 25시간(버전 1세대 수명 + 여유). 과거 버전 키는 + * 참조되지 않아 자연 만료된다(FR-005).
  • + *
  • value 직렬화 — {@link GenericJackson2JsonRedisSerializer}(JSON). 키는 기본 + * {@code StringRedisSerializer} → 물리 키 {@code challenge-avg::{memberId}:{version}}.
  • + *
  • {@link RedisCacheWriter#lockingRedisCacheWriter(RedisConnectionFactory) lockingRedisCacheWriter} + * — 동일 키 동시 적재를 직렬화해 single-flight(R5/FR-006) 를 보장. 메서드의 + * {@code sync = true} 와 결합해 캐시 스탬피드를 방지한다.
  • + *
+ * + *

{@code @EnableCaching} 은 {@code RedisConfig} 에 이미 선언되어 있어 여기서 중복하지 않는다. + */ @Configuration public class CacheConfig { - @Bean - public CacheManager cacheManager() { - CaffeineCacheManager cacheManager = new CaffeineCacheManager(); - CacheType[] values = CacheType.values(); - for (CacheType ct : CacheType.values()) { - cacheManager.registerCustomCache(ct.getCacheName(), - Caffeine.newBuilder().expireAfterWrite(ct.getDuration(), ct.getTimeUnit()).build()); - } - return cacheManager; - } - - @Getter - @AllArgsConstructor - enum CacheType { - CHALLENGE_AVG("challenge-avg", 24, TimeUnit.HOURS); + private static final String CHALLENGE_AVG_CACHE = "challenge-avg"; + private static final Duration CHALLENGE_AVG_TTL = Duration.ofHours(25); - final String cacheName; - final int duration; - final TimeUnit timeUnit; + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration challengeAvgConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(CHALLENGE_AVG_TTL) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer())); + + return RedisCacheManager + .builder(RedisCacheWriter.lockingRedisCacheWriter(connectionFactory)) + .withCacheConfiguration(CHALLENGE_AVG_CACHE, challengeAvgConfig) + .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/planetrush/planetrush/member/service/MemberServiceImpl.java b/src/main/java/com/planetrush/planetrush/member/service/MemberServiceImpl.java index 36c1b41..e954950 100644 --- a/src/main/java/com/planetrush/planetrush/member/service/MemberServiceImpl.java +++ b/src/main/java/com/planetrush/planetrush/member/service/MemberServiceImpl.java @@ -3,11 +3,9 @@ import java.util.List; import java.util.stream.Collectors; -import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.planetrush.planetrush.infra.flask.util.FlaskApiClient; import com.planetrush.planetrush.member.domain.ChallengeHistory; import com.planetrush.planetrush.member.domain.Member; import com.planetrush.planetrush.member.exception.MemberNotFoundException; @@ -26,9 +24,10 @@ @Transactional(readOnly = true) public class MemberServiceImpl implements MemberService { - private final FlaskApiClient flaskApiClient; private final MemberRepository memberRepository; private final ChallengeHistoryRepositoryCustom challengeHistoryRepositoryCustom; + private final StatisticsVersionProvider statisticsVersionProvider; + private final MemberStatisticsCacheService memberStatisticsCacheService; /** * {@inheritDoc} @@ -51,15 +50,14 @@ public List getPlanetCollections(CollectionSearchCond searc /** * {@inheritDoc} * - *

이 메서드는 반환값을 캐싱하여 관리합니다.

- *

캐시 미스가 발생하는 경우에만 플라스크 서버로 API 요청을 전송하여 새로운 데이터로 캐시에 저장합니다.

+ *

Spec 006 — T009 / R7. 현재 통계 버전을 먼저 해석한 뒤, 버전 키 기반 캐시 서비스 + * ({@link MemberStatisticsCacheService}) 에 위임한다. 캐시 어드바이스는 별도 빈에 적용되며 + * (자기호출 캐시 우회 방지) 버전이 바뀌면 키가 바뀌어 자연 miss → 재계산된다.

*/ - @Cacheable(cacheNames = "challenge-avg", key = "#memberId", sync = true) @Override public GetMyProgressAvgDto getMyProgressAvgPer(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new MemberNotFoundException("Member not found with ID: " + memberId)); - return flaskApiClient.getMyProgressAvg(memberId); + String version = statisticsVersionProvider.getCurrentVersion(); + return memberStatisticsCacheService.getStatistics(memberId, version); } /** diff --git a/src/main/java/com/planetrush/planetrush/member/service/MemberStatisticsCacheService.java b/src/main/java/com/planetrush/planetrush/member/service/MemberStatisticsCacheService.java new file mode 100644 index 0000000..b3e3df6 --- /dev/null +++ b/src/main/java/com/planetrush/planetrush/member/service/MemberStatisticsCacheService.java @@ -0,0 +1,47 @@ +package com.planetrush.planetrush.member.service; + +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import com.planetrush.planetrush.infra.flask.util.FlaskApiClient; +import com.planetrush.planetrush.member.exception.MemberNotFoundException; +import com.planetrush.planetrush.member.repository.MemberRepository; +import com.planetrush.planetrush.member.service.dto.GetMyProgressAvgDto; + +import lombok.RequiredArgsConstructor; + +/** + * 버전 키 기반 통계 캐시 서비스. + * + *

Spec 006 — T008 / R7. {@code @Cacheable} 어드바이스가 AOP 프록시로 적용되려면 캐시 메서드를 + * 호출 측({@code MemberServiceImpl})과 다른 빈으로 분리해야 한다(자기호출 캐시 우회 방지). + * + *

캐시 키는 {@code {memberId}:{version}} — 버전이 바뀌면 키가 바뀌어 자연 miss → 재계산되며 + * 과거 키는 TTL 25h 후 자연 만료된다(전수 evict 불요). {@code sync = true} 와 locking + * RedisCacheWriter 결합으로 동일 키 동시 첫 조회 시 Flask 호출이 요청당 1회로 수렴한다(R5/FR-006). + * + *

실패 비캐싱(FR-007): member 미존재({@link MemberNotFoundException}) 와 Flask 실패 예외는 + * 그대로 전파한다 — {@code @Cacheable} 은 예외 발생 시 결과를 저장하지 않으므로 sentinel/빈 결과가 + * 캐싱되지 않고, 다음 요청이 재계산을 시도한다. + */ +@Service +@RequiredArgsConstructor +public class MemberStatisticsCacheService { + + private final FlaskApiClient flaskApiClient; + private final MemberRepository memberRepository; + + /** + * 버전 키로 통계를 조회한다. 캐시 miss 시에만 Flask 를 호출해 결과를 적재한다. + * + * @param memberId 조회 대상 회원 ID + * @param version 현재 통계 버전({@code yyyy-MM-dd}) — 캐시 키 구성요소 + * @return 통계 DTO + */ + @Cacheable(cacheNames = "challenge-avg", key = "#memberId + ':' + #version", sync = true) + public GetMyProgressAvgDto getStatistics(Long memberId, String version) { + memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException("Member not found with ID: " + memberId)); + return flaskApiClient.getMyProgressAvg(memberId); + } +} diff --git a/src/main/java/com/planetrush/planetrush/member/service/StatisticsVersionProvider.java b/src/main/java/com/planetrush/planetrush/member/service/StatisticsVersionProvider.java new file mode 100644 index 0000000..d860e4b --- /dev/null +++ b/src/main/java/com/planetrush/planetrush/member/service/StatisticsVersionProvider.java @@ -0,0 +1,46 @@ +package com.planetrush.planetrush.member.service; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +import org.springframework.stereotype.Component; + +import com.planetrush.planetrush.scheduler.log.JobLogRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 현재 유효한 통계 버전(직전 완료 {@code progressCalculation} 배치의 기준일)을 해석한다. + * + *

Spec 006 — T004 / R3·R4·R9. 통계 캐시 키의 {@code version} 구성요소를 매 요청 DB 에서 + * 직접 파생한다(별도 버전 캐시 없음 — 인덱스된 {@code MAX(endTime)} 1건 읽기로 충분, 조기 + * 최적화 회피). 배치 완료 즉시 전 인스턴스가 동일 버전으로 수렴한다. + * + *

헌법 II 정합: 버전의 진실 공급원은 DB({@code JobLog}) — Redis/{@code RedisTemplate} 직접 + * 접근 없음. 타임존은 {@code Asia/Seoul} 로 고정(R9)해 다중 인스턴스 버전 경계 일관성을 보장한다. + */ +@Component +@RequiredArgsConstructor +public class StatisticsVersionProvider { + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter VERSION_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private final JobLogRepository jobLogRepository; + + /** + * 현재 통계 버전을 {@code yyyy-MM-dd} 형식으로 반환한다. + * + *

직전 완료 배치가 있으면 그 {@code endTime} 을 {@code Asia/Seoul} 기준일로 변환한다. + * 완료 0건(콜드스타트)이면 {@code Asia/Seoul} 의 오늘 날짜를 잠정 버전으로 사용한다. + * + * @return 통계 버전 문자열(예: {@code 2026-06-06}) + */ + public String getCurrentVersion() { + LocalDate version = jobLogRepository.findLatestCompletedProgressCalculationEndTime() + .map(endTime -> endTime.atZone(SEOUL).toLocalDate()) + .orElseGet(() -> LocalDate.now(SEOUL)); + return version.format(VERSION_FORMAT); + } +} diff --git a/src/main/java/com/planetrush/planetrush/member/service/dto/GetMyProgressAvgDto.java b/src/main/java/com/planetrush/planetrush/member/service/dto/GetMyProgressAvgDto.java index 437059d..ef3008f 100644 --- a/src/main/java/com/planetrush/planetrush/member/service/dto/GetMyProgressAvgDto.java +++ b/src/main/java/com/planetrush/planetrush/member/service/dto/GetMyProgressAvgDto.java @@ -1,15 +1,27 @@ package com.planetrush.planetrush.member.service.dto; +import com.fasterxml.jackson.annotation.JsonAutoDetect; + import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +/** + * 마이페이지 통계 응답 DTO. + * + *

Spec 006 — T006. Redis JSON 캐시(R8, {@code GenericJackson2JsonRedisSerializer}) round-trip + * 대상. setter 가 없으므로 역직렬화 시 Jackson 이 private 필드에 직접 값을 주입하도록 + * {@link JsonAutoDetect}(fieldVisibility = ANY) 를 부여한다. no-arg 생성자는 Jackson 이 + * 인스턴스 생성에 사용한다(protected 가시성은 Jackson 리플렉션으로 접근 가능). 모든 필드가 + * 원시 타입이라 추가 Jackson 모듈은 불필요하다. + */ @Getter @Builder @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) public class GetMyProgressAvgDto { private long completionCnt; diff --git a/src/main/java/com/planetrush/planetrush/scheduler/log/JobLog.java b/src/main/java/com/planetrush/planetrush/scheduler/log/JobLog.java index 086a778..c0442fb 100644 --- a/src/main/java/com/planetrush/planetrush/scheduler/log/JobLog.java +++ b/src/main/java/com/planetrush/planetrush/scheduler/log/JobLog.java @@ -8,6 +8,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; @@ -15,7 +16,10 @@ @Getter @Entity -@Table(name = "job_log") +@Table( + name = "job_log", + indexes = @Index(name = "idx_joblog_type_endtime", columnList = "job_type, end_time") +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class JobLog { diff --git a/src/main/java/com/planetrush/planetrush/scheduler/log/JobLogRepository.java b/src/main/java/com/planetrush/planetrush/scheduler/log/JobLogRepository.java index 04fa642..c517e9a 100644 --- a/src/main/java/com/planetrush/planetrush/scheduler/log/JobLogRepository.java +++ b/src/main/java/com/planetrush/planetrush/scheduler/log/JobLogRepository.java @@ -2,6 +2,13 @@ import org.springframework.data.jpa.repository.JpaRepository; -public interface JobLogRepository extends JpaRepository { +/** + * {@link JobLog} JPA 리포지토리. + * + *

Spec 006 — T003 에서 QueryDSL custom 인터페이스 {@link JobLogRepositoryCustom} 와 결합됨. + * Spring Data JPA 의 fragment 결합 메커니즘이 {@code JobLogRepositoryCustomImpl} 을 런타임에 + * 합쳐 {@code MAX(endTime)} 버전 조회를 본 인터페이스로 노출한다 — 헌법 III 정합. + */ +public interface JobLogRepository extends JpaRepository, JobLogRepositoryCustom { } diff --git a/src/main/java/com/planetrush/planetrush/scheduler/log/JobLogRepositoryCustom.java b/src/main/java/com/planetrush/planetrush/scheduler/log/JobLogRepositoryCustom.java new file mode 100644 index 0000000..0cfcc37 --- /dev/null +++ b/src/main/java/com/planetrush/planetrush/scheduler/log/JobLogRepositoryCustom.java @@ -0,0 +1,28 @@ +package com.planetrush.planetrush.scheduler.log; + +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * {@link JobLog} 의 custom QueryDSL 인터페이스. + * + *

Spec 006 — T003. {@code JobLogRepository} 가 본 인터페이스를 {@code extends} 하여 + * Spring Data JPA 의 fragment 결합 메커니즘으로 노출된다. 구현 클래스명은 규약상 + * {@code JobLogRepositoryCustomImpl} 로 끝나야 자동 결합된다. + * + *

헌법 III 정합: 본 인터페이스는 Entity 를 반환하지 않고 스칼라 집계({@code MAX(endTime)}) 만 + * 반환한다 — Entity→DTO 수동 매핑 없음, N+1 무관. + */ +public interface JobLogRepositoryCustom { + + /** + * 직전에 완료된 {@code progressCalculation} 배치의 {@code endTime} 최댓값을 조회한다. + * + *

Spec 006 — R3/R4. 통계 버전 산정의 진실 공급원. {@code jobType = 'progressCalculation'} + * 이고 {@code endTime IS NOT NULL} 인 JobLog 중 {@code MAX(endTime)} 을 + * {@code (job_type, end_time)} 복합 인덱스의 끝값 1건 읽기로 처리한다. + * + * @return 직전 완료 배치의 endTime, 완료 0건이면 빈 {@link Optional} + */ + Optional findLatestCompletedProgressCalculationEndTime(); +} diff --git a/src/main/java/com/planetrush/planetrush/scheduler/log/JobLogRepositoryCustomImpl.java b/src/main/java/com/planetrush/planetrush/scheduler/log/JobLogRepositoryCustomImpl.java new file mode 100644 index 0000000..1800d43 --- /dev/null +++ b/src/main/java/com/planetrush/planetrush/scheduler/log/JobLogRepositoryCustomImpl.java @@ -0,0 +1,39 @@ +package com.planetrush.planetrush.scheduler.log; + +import static com.planetrush.planetrush.scheduler.log.QJobLog.*; + +import java.time.LocalDateTime; +import java.util.Optional; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +/** + * {@link JobLogRepositoryCustom} QueryDSL 구현. + * + *

Spec 006 — T003. Spring Data JPA 의 custom fragment 규약을 따라 구현 클래스명이 + * {@code JobLogRepositoryCustomImpl} 로 끝나야 {@code JobLogRepository} 에 자동 결합된다. + * + *

헌법 III 정합: 스칼라 집계({@code MAX(endTime)}) 만 수행 — Entity getter 수동 매핑 없음. + */ +@RequiredArgsConstructor +public class JobLogRepositoryCustomImpl implements JobLogRepositoryCustom { + + private static final String PROGRESS_CALCULATION = "progressCalculation"; + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findLatestCompletedProgressCalculationEndTime() { + LocalDateTime endTimeMax = queryFactory + .select(jobLog.endTime.max()) + .from(jobLog) + .where( + jobLog.jobType.eq(PROGRESS_CALCULATION), + jobLog.endTime.isNotNull() + ) + .fetchOne(); + return Optional.ofNullable(endTimeMax); + } +} diff --git a/src/test/java/com/planetrush/planetrush/member/MemberIntegrationTest.java b/src/test/java/com/planetrush/planetrush/member/MemberIntegrationTest.java index ddd45c5..0f2829c 100644 --- a/src/test/java/com/planetrush/planetrush/member/MemberIntegrationTest.java +++ b/src/test/java/com/planetrush/planetrush/member/MemberIntegrationTest.java @@ -95,11 +95,8 @@ void should_use_cache_when_call_get_my_progress_avg() { } // THEN - Cache cache = cacheManager.getCache("challenge-avg"); - assertThat(cache).isNotNull() - .extracting(it -> it.get(member.getId())).isNotNull() - .extracting(it -> it.get()).isNotNull() - .isInstanceOf(GetMyProgressAvgDto.class); + // Redis + 버전키 이관: 캐시 키가 "memberId:version" 이라 평문 memberId 직접 조회는 항상 null. + // 캐싱 여부는 캐시 내부 구조 조회(brittle) 대신 "10회 호출에도 Flask 1회"라는 행위로 검증한다. verify(flaskApiClient, times(1)).getMyProgressAvg(member.getId()); } @@ -161,7 +158,9 @@ void should_load_cache_once_when_concurrent_requests_miss_same_key() throws Exce startLatch.countDown(); executor.shutdown(); for (Future future : futures) { - assertThat(future.get(5, TimeUnit.SECONDS)).isEqualTo(dto); + // Redis round-trip 으로 역직렬화된 새 인스턴스가 반환되고 DTO 는 equals/hashCode 미구현이므로, + // 참조 동등성 대신 값 동등성으로 비교한다(분산 캐시에서 참조 동일 기대는 잘못된 가정). + assertThat(future.get(5, TimeUnit.SECONDS)).usingRecursiveComparison().isEqualTo(dto); } assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); diff --git a/src/test/java/com/planetrush/planetrush/member/StatisticsCacheVersionIntegrationTest.java b/src/test/java/com/planetrush/planetrush/member/StatisticsCacheVersionIntegrationTest.java new file mode 100644 index 0000000..2b3cc21 --- /dev/null +++ b/src/test/java/com/planetrush/planetrush/member/StatisticsCacheVersionIntegrationTest.java @@ -0,0 +1,350 @@ +package com.planetrush.planetrush.member; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.cache.CacheManager; +import org.springframework.data.redis.core.StringRedisTemplate; + +import com.planetrush.planetrush.IntegrationTest; +import com.planetrush.planetrush.infra.flask.util.FlaskApiClient; +import com.planetrush.planetrush.member.repository.MemberRepository; +import com.planetrush.planetrush.member.service.MemberService; +import com.planetrush.planetrush.member.service.StatisticsVersionProvider; +import com.planetrush.planetrush.member.service.dto.GetMyProgressAvgDto; +import com.planetrush.planetrush.member.testsupport.StatisticsCacheTestSupport; +import com.planetrush.planetrush.scheduler.log.JobLogRepository; + +/** + * Spec 006 — 통계 캐시 버전 키 인수 테스트 (SC-001~005 · FR-007/008 · 콜드스타트). + * + *

헌법 I(Testcontainers MySQL+Redis) · VII(인수기준=자동테스트). 외부 통계 서버(Flask)는 + * {@code @MockBean} 으로 대체하고 호출 횟수로 버전 전환 시 재계산 여부를 검증한다. + * + *

명세 도출: 버전 = 직전 완료 progressCalculation 배치의 {@code endTime} 기준일 + * (Asia/Seoul, {@code yyyy-MM-dd}). 서로 다른 날짜의 완료 JobLog 를 시드해 버전 전환을 재현한다 + * (data-model.md 상태 전이 · contracts/cache-key.md F1). + */ +class StatisticsCacheVersionIntegrationTest extends IntegrationTest { + + @Autowired + private MemberService memberService; + + @Autowired + private StatisticsVersionProvider statisticsVersionProvider; + + @Autowired + private JobLogRepository jobLogRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @Autowired + private CacheManager cacheManager; + + @MockBean + private FlaskApiClient flaskApiClient; + + private StatisticsCacheTestSupport support; + + @BeforeEach + void setUp() { + support = new StatisticsCacheTestSupport(jobLogRepository, memberRepository, stringRedisTemplate); + // 격리: Redis challenge-avg 키 + JobLog/Member 정리(두 백엔드 모두 대비해 CacheManager 도 clear). + support.cleanUp(); + if (cacheManager.getCache(StatisticsCacheTestSupport.CACHE_NAME) != null) { + cacheManager.getCache(StatisticsCacheTestSupport.CACHE_NAME).clear(); + } + reset(flaskApiClient); + } + + /** + * SC-001: 일별 배치 완료 후 첫 조회 시 갱신값이 100% 반영된다(옛 값 노출 0%). + * + *

한 코드 경로의 세 단계를 한 테스트로 박제한다. + *

    + *
  1. (US1 시나리오2) 완료 배치 endTime=D 시드 후 첫 조회 → Flask 1회 호출·v1 반환·캐싱.
  2. + *
  3. 같은 버전 재조회 → 캐시 HIT, Flask 추가 호출 0(v1 유지).
  4. + *
  5. (US1 시나리오1) 완료 배치 endTime=D+1 추가 시드 → 버전 전환 → 재조회 시 Flask 추가 1회·v2 반환.
  6. + *
+ * Flask mock 은 distinct DTO 두 개(myTotalAvg 10.0 → 20.0)로 stub 해 버전 전환 시 값이 실제로 바뀌는지 확인한다. + */ + @DisplayName("SC-001: 배치 버전 전환(D→D+1) 후 첫 조회는 재계산되고, 전환 전 재조회는 캐시 HIT 다.") + @Test + void should_recompute_on_first_read_after_version_transition_and_hit_within_same_version() { + // GIVEN: 직전 완료 배치 기준일 D(2026-06-05) + support.seedCompletedBatch(LocalDateTime.of(2026, 6, 5, 23, 50)); + Long memberId = support.seedMember(); + + GetMyProgressAvgDto dtoVersionD = dtoWithMyTotalAvg(10.0); + GetMyProgressAvgDto dtoVersionDPlus1 = dtoWithMyTotalAvg(20.0); + // 1번째 실제 Flask 호출 → v1, 2번째 → v2 (HIT 구간은 Flask 미호출) + when(flaskApiClient.getMyProgressAvg(memberId)).thenReturn(dtoVersionD, dtoVersionDPlus1); + + // WHEN/THEN (1) 버전 D 첫 조회 = MISS → Flask 1회, v1 반환·캐싱 + GetMyProgressAvgDto firstRead = memberService.getMyProgressAvgPer(memberId); + assertThat(firstRead.getMyTotalAvg()).isEqualTo(10.0); + verify(flaskApiClient, times(1)).getMyProgressAvg(memberId); + + // WHEN/THEN (2) 동일 버전 재조회 = HIT → Flask 추가 호출 없음, v1 유지 + GetMyProgressAvgDto secondRead = memberService.getMyProgressAvgPer(memberId); + assertThat(secondRead.getMyTotalAvg()).isEqualTo(10.0); + verify(flaskApiClient, times(1)).getMyProgressAvg(memberId); + + // WHEN/THEN (3) 새 완료 배치 endTime=D+1 → 버전 전환 → 새 키 MISS → 재계산·v2 반환 + support.seedCompletedBatch(LocalDateTime.of(2026, 6, 6, 23, 50)); + GetMyProgressAvgDto afterTransition = memberService.getMyProgressAvgPer(memberId); + assertThat(afterTransition.getMyTotalAvg()).isEqualTo(20.0); + verify(flaskApiClient, times(2)).getMyProgressAvg(memberId); + } + + /** + * FR-008(전역 일관, analyze C1 근사): 동일 DB 상태에서 {@code getCurrentVersion()} 을 연속 호출하면 + * 동일 문자열을 반환한다(결정성). 다중 인스턴스 동일 인식은 단일 JVM 통합 테스트로 직접 검증 불가하므로, + * "동일 입력(DB MAX(endTime)) → 동일 출력" 결정성으로 대체한다. 버전은 매 요청 DB 파생(메모 캐시 없음)이라 + * 결정성이 곧 모든 인스턴스 동일 수렴의 근거다(contracts/cache-key.md C3). + */ + @DisplayName("FR-008: 동일 DB 상태에서 getCurrentVersion 은 결정적으로 같은 버전을 반환한다.") + @Test + void should_return_deterministic_version_for_same_db_state() { + // GIVEN: 직전 완료 배치 기준일 2026-06-05 + support.seedCompletedBatch(LocalDateTime.of(2026, 6, 5, 23, 50)); + + // WHEN: 동일 상태에서 연속 2회 해석 + String first = statisticsVersionProvider.getCurrentVersion(); + String second = statisticsVersionProvider.getCurrentVersion(); + + // THEN: 동일 버전(= 직전 완료 배치 기준일) + assertThat(first).isEqualTo(second); + assertThat(first).isEqualTo("2026-06-05"); + } + + /** + * SC-002 (US2 중간상태 미캐싱): "배치 진행 중" = 해당 날짜의 완료 JobLog row 부재 + * (production 은 finish() 후에만 저장 → endTime=null row 는 영속되지 않는다). + * + *

완료 D 만 있는 상태에서 D+1 배치가 "진행 중"(완료 row 미시드)인 동안 들어온 조회는 + * 여전히 version=D 로 HIT 되어 Flask 를 추가 호출하지 않고, D+1 버전 키를 생성하지 않는다 + * (중간상태가 새 버전으로 캐싱되지 않음). D+1 배치가 실제 완료된 뒤에야 재계산된다. + * 이 행위가 곧 "endTime IS NOT NULL(진행중 제외)" 불변식의 증명이다. + */ + @DisplayName("SC-002: 배치 진행 중(완료 row 부재) 조회는 직전 버전으로 HIT 되고 중간상태가 새 버전으로 캐싱되지 않는다.") + @Test + void should_not_cache_intermediate_state_while_batch_in_progress() { + // GIVEN: 직전 완료 배치 기준일 D(2026-06-05) + support.seedCompletedBatch(LocalDateTime.of(2026, 6, 5, 23, 50)); + Long memberId = support.seedMember(); + + GetMyProgressAvgDto dtoVersionD = dtoWithMyTotalAvg(10.0); + GetMyProgressAvgDto dtoVersionDPlus1 = dtoWithMyTotalAvg(20.0); + when(flaskApiClient.getMyProgressAvg(memberId)).thenReturn(dtoVersionD, dtoVersionDPlus1); + + String keyD = support.cacheKey(memberId, "2026-06-05"); + String keyDPlus1 = support.cacheKey(memberId, "2026-06-06"); + + // WHEN/THEN (1) 버전 D 첫 조회 → Flask 1회, :D 키 생성 + GetMyProgressAvgDto firstRead = memberService.getMyProgressAvgPer(memberId); + assertThat(firstRead.getMyTotalAvg()).isEqualTo(10.0); + verify(flaskApiClient, times(1)).getMyProgressAvg(memberId); + assertThat(support.keyExists(keyD)).isTrue(); + + // WHEN/THEN (2) D+1 "배치 진행 중"(완료 row 미시드) → version 여전히 D → HIT + // → Flask 추가 호출 0, D+1 버전 키 미생성(중간상태 미캐싱) + GetMyProgressAvgDto duringInProgress = memberService.getMyProgressAvgPer(memberId); + assertThat(duringInProgress.getMyTotalAvg()).isEqualTo(10.0); + verify(flaskApiClient, times(1)).getMyProgressAvg(memberId); + assertThat(support.keyExists(keyDPlus1)).isFalse(); + + // WHEN/THEN (3) D+1 배치 실제 완료 → 버전 전환 → 재계산·새 값 + support.seedCompletedBatch(LocalDateTime.of(2026, 6, 6, 23, 50)); + GetMyProgressAvgDto afterComplete = memberService.getMyProgressAvgPer(memberId); + assertThat(afterComplete.getMyTotalAvg()).isEqualTo(20.0); + verify(flaskApiClient, times(2)).getMyProgressAvg(memberId); + assertThat(support.keyExists(keyDPlus1)).isTrue(); + } + + /** + * SC-003 (US3 evict 0회): 버전 전환은 키 변경만으로 신선도를 확보한다. 새 버전 키 생성 후에도 + * 구 버전 키는 명시적으로 삭제되지 않고 Redis 에 그대로 남는다(전수 evict 0회 — TTL 로 자연 만료). + * 본 테스트는 사용자별 evict/cache.evict 를 일절 호출하지 않는다. + */ + @DisplayName("SC-003: 버전 전환 후 구 버전 키와 새 버전 키가 모두 잔존한다(사용자별 evict 0회).") + @Test + void should_keep_old_version_key_without_evict_on_version_transition() { + // GIVEN: 완료 D 시드 + 멤버 + support.seedCompletedBatch(LocalDateTime.of(2026, 6, 5, 23, 50)); + Long memberId = support.seedMember(); + when(flaskApiClient.getMyProgressAvg(memberId)).thenReturn(dtoWithMyTotalAvg(10.0), dtoWithMyTotalAvg(20.0)); + + String keyD = support.cacheKey(memberId, "2026-06-05"); + String keyDPlus1 = support.cacheKey(memberId, "2026-06-06"); + + // WHEN (1) 버전 D 조회 → :D 키 생성 + memberService.getMyProgressAvgPer(memberId); + assertThat(support.keyExists(keyD)).isTrue(); + + // WHEN (2) 완료 D+1 시드 후 조회 → :D+1 키 생성(전수 evict 없이 키 변경만으로 신선도 확보) + support.seedCompletedBatch(LocalDateTime.of(2026, 6, 6, 23, 50)); + memberService.getMyProgressAvgPer(memberId); + + // THEN: 구 키(:D)와 새 키(:D+1) 둘 다 Redis 에 잔존(구 키 미삭제) + assertThat(support.keyExists(keyD)).isTrue(); + assertThat(support.keyExists(keyDPlus1)).isTrue(); + assertThat(support.challengeAvgKeys()).contains(keyD, keyDPlus1); + } + + /** + * SC-005 (과거 버전 자연 만료): 캐시 항목에 만료 시간(TTL ≈ 25h)이 설정되어, 더 이상 참조되지 않는 + * 과거 버전 키가 무한 누적되지 않고 TTL 경과 후 제거된다. 여기서는 TTL 이 25h 근사로 설정됐는지 단언한다. + */ + @DisplayName("SC-005: 캐시 키에 25h 근사 TTL 이 설정된다(과거 버전 자연 만료).") + @Test + void should_set_ttl_around_25h_on_cache_key() { + // GIVEN + support.seedCompletedBatch(LocalDateTime.of(2026, 6, 5, 23, 50)); + Long memberId = support.seedMember(); + when(flaskApiClient.getMyProgressAvg(memberId)).thenReturn(dtoWithMyTotalAvg(10.0)); + + // WHEN: 조회로 키 생성 + memberService.getMyProgressAvgPer(memberId); + String keyD = support.cacheKey(memberId, "2026-06-05"); + + // THEN: TTL 이 25h(90000s) 근사 — 생성 직후 소폭 감소 여유 포함 23h~25h(> 82800s, <= 90000s) + long ttl = support.ttlSeconds(keyD); + assertThat(ttl).isGreaterThan(82800L).isLessThanOrEqualTo(90000L); + } + + /** + * SC-004 (single-flight): 버전이 고정된 상태(완료 JobLog 1건 시드)에서 동일 member·동일 버전의 + * 첫 조회가 동시에 N 건 발생해도 외부 Flask 계산은 1회로 수렴한다(locking RedisCacheWriter + sync=true). + */ + @DisplayName("SC-004: 동일 member·동일 버전 동시 N 요청에도 Flask 호출은 1회로 수렴한다.") + @Test + void should_converge_flask_call_to_once_on_concurrent_first_reads() throws Exception { + // GIVEN: 버전 고정(완료 D 시드) + 멤버 + support.seedCompletedBatch(LocalDateTime.of(2026, 6, 5, 23, 50)); + Long memberId = support.seedMember(); + GetMyProgressAvgDto dto = dtoWithMyTotalAvg(10.0); + + int concurrency = 16; + java.util.concurrent.ExecutorService executor = + java.util.concurrent.Executors.newFixedThreadPool(concurrency); + java.util.concurrent.CountDownLatch readyLatch = new java.util.concurrent.CountDownLatch(concurrency); + java.util.concurrent.CountDownLatch startLatch = new java.util.concurrent.CountDownLatch(1); + java.util.List> futures = new java.util.ArrayList<>(); + + when(flaskApiClient.getMyProgressAvg(memberId)).thenAnswer(invocation -> { + Thread.sleep(200); + return dto; + }); + + // WHEN: N 스레드가 동시에 동일 키 첫 조회 + for (int i = 0; i < concurrency; i++) { + futures.add(executor.submit(() -> { + readyLatch.countDown(); + assertThat(startLatch.await(2, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + return memberService.getMyProgressAvgPer(memberId); + })); + } + assertThat(readyLatch.await(2, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + startLatch.countDown(); + executor.shutdown(); + for (java.util.concurrent.Future future : futures) { + // Redis round-trip 역직렬화로 새 인스턴스 반환 → 값 동등성 비교 + assertThat(future.get(5, java.util.concurrent.TimeUnit.SECONDS)) + .usingRecursiveComparison().isEqualTo(dto); + } + assertThat(executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + + // THEN: 동일 키 중복 계산 없이 Flask 1회 수렴 + verify(flaskApiClient, times(1)).getMyProgressAvg(memberId); + } + + /** + * FR-007 (실패 비캐싱): Flask 호출이 실패하면 실패·불완전 결과가 해당 버전 키로 캐싱되지 않아야 하며, + * 이후 요청이 재계산을 시도할 수 있어야 한다. + * + *

주의: {@code @MockBean} 이라 production {@code @Retryable} 은 적용되지 않고 mock 이 즉시 throw 한다. + */ + @DisplayName("FR-007: Flask 예외 시 키 미생성, 다음 요청이 재계산을 다시 시도한다.") + @Test + void should_not_cache_failure_and_allow_retry_on_next_request() { + // GIVEN: 버전 고정(완료 D 시드) + 멤버 + support.seedCompletedBatch(LocalDateTime.of(2026, 6, 5, 23, 50)); + Long memberId = support.seedMember(); + when(flaskApiClient.getMyProgressAvg(memberId)) + .thenThrow(new com.planetrush.planetrush.infra.flask.exception.FlaskConnectionFailedException("flask down")); + + String keyD = support.cacheKey(memberId, "2026-06-05"); + + // WHEN/THEN (1) 첫 호출 → 예외 전파, 실패 결과 미캐싱(키 미생성) + assertThatThrownBy(() -> memberService.getMyProgressAvgPer(memberId)) + .isInstanceOf(com.planetrush.planetrush.infra.flask.exception.FlaskConnectionFailedException.class); + assertThat(support.keyExists(keyD)).isFalse(); + + // WHEN/THEN (2) 다음 요청 → 캐시 없으므로 Flask 재시도(추가 호출 발생) + assertThatThrownBy(() -> memberService.getMyProgressAvgPer(memberId)) + .isInstanceOf(com.planetrush.planetrush.infra.flask.exception.FlaskConnectionFailedException.class); + verify(flaskApiClient, times(2)).getMyProgressAvg(memberId); + assertThat(support.keyExists(keyD)).isFalse(); + } + + /** + * 콜드스타트 (Q3): 완료된 progressCalculation 배치가 0건이면(최초 배포 직후) 오늘 기준일(Asia/Seoul)을 + * 잠정 버전으로 사용해 실시간 계산값을 제공·캐싱한다. + */ + @DisplayName("콜드스타트: 완료 배치 0건이면 version=오늘(Asia/Seoul)로 계산·캐싱한다.") + @Test + void should_use_today_as_version_on_cold_start_with_no_completed_batch() { + // GIVEN: JobLog 미시드(완료 0건) — cleanUp 으로 보장됨 + Long memberId = support.seedMember(); + when(flaskApiClient.getMyProgressAvg(memberId)).thenReturn(dtoWithMyTotalAvg(10.0)); + + String today = java.time.LocalDate.now(java.time.ZoneId.of("Asia/Seoul")) + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd")); + + // THEN (1) 버전 = 오늘 + assertThat(statisticsVersionProvider.getCurrentVersion()).isEqualTo(today); + + // WHEN/THEN (2) 조회 → Flask 1회, 오늘 버전 키 생성 + GetMyProgressAvgDto read = memberService.getMyProgressAvgPer(memberId); + assertThat(read.getMyTotalAvg()).isEqualTo(10.0); + verify(flaskApiClient, times(1)).getMyProgressAvg(memberId); + assertThat(support.keyExists(support.cacheKey(memberId, today))).isTrue(); + } + + private GetMyProgressAvgDto dtoWithMyTotalAvg(double myTotalAvg) { + return GetMyProgressAvgDto.builder() + .completionCnt(10) + .challengeCnt(10) + .myTotalAvg(myTotalAvg) + .myTotalPer(1.0) + .totalAvg(1.0) + .myExerciseAvg(1.0) + .myExercisePer(1.0) + .exerciseAvg(1.0) + .myBeautyAvg(1.0) + .myBeautyPer(1.0) + .beautyAvg(1.0) + .myLifeAvg(1.0) + .myLifePer(1.0) + .lifeAvg(1.0) + .myStudyAvg(1.0) + .myStudyPer(1.0) + .studyAvg(1.0) + .myEtcAvg(1.0) + .myEtcPer(1.0) + .etcAvg(1.0) + .build(); + } +} diff --git a/src/test/java/com/planetrush/planetrush/member/testsupport/StatisticsCacheTestSupport.java b/src/test/java/com/planetrush/planetrush/member/testsupport/StatisticsCacheTestSupport.java new file mode 100644 index 0000000..469dd92 --- /dev/null +++ b/src/test/java/com/planetrush/planetrush/member/testsupport/StatisticsCacheTestSupport.java @@ -0,0 +1,108 @@ +package com.planetrush.planetrush.member.testsupport; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import com.planetrush.planetrush.fixture.MemberFixture; +import com.planetrush.planetrush.member.domain.Member; +import com.planetrush.planetrush.member.repository.MemberRepository; +import com.planetrush.planetrush.scheduler.log.JobLog; +import com.planetrush.planetrush.scheduler.log.JobLogRepository; + +/** + * Spec 006 — 통계 캐시 버전 키 통합 테스트 지원 헬퍼. + * + *

JobLog(완료 배치) 시드 · Member 시드 · {@code challenge-avg} Redis 키/TTL 조회를 + * 한곳에 모은다. Spring 빈이 아니라 통합 테스트가 autowired 의존성으로 직접 생성해 쓰는 + * 순수 헬퍼다(컨텍스트 오염 없음). + * + *

모델링 주의: {@code JobLog.end_time} 은 NOT NULL 이고 setter 가 없으며 production + * 은 {@code finish()}(endTime=now) 후에만 저장한다. 특정 날짜의 "완료" 배치를 재현하려면 + * {@link ReflectionTestUtils#setField}로 endTime 을 직접 지정해야 한다. 통계 버전은 + * {@code MAX(endTime).toLocalDate()}(Asia/Seoul) 이므로, 서로 다른 날짜의 완료 JobLog 를 + * 시드해 버전 전환을 재현한다. + */ +public class StatisticsCacheTestSupport { + + public static final String CACHE_NAME = "challenge-avg"; + public static final String KEY_PATTERN = CACHE_NAME + "::*"; + private static final String PROGRESS_CALCULATION = "progressCalculation"; + + private final JobLogRepository jobLogRepository; + private final MemberRepository memberRepository; + private final StringRedisTemplate stringRedisTemplate; + + public StatisticsCacheTestSupport(JobLogRepository jobLogRepository, + MemberRepository memberRepository, + StringRedisTemplate stringRedisTemplate) { + this.jobLogRepository = jobLogRepository; + this.memberRepository = memberRepository; + this.stringRedisTemplate = stringRedisTemplate; + } + + /** + * 지정한 {@code endTime} 으로 "완료된" progressCalculation JobLog 를 시드한다. + * production 의 {@code finish()} 가 endTime=now 를 박는 것과 달리, 테스트는 임의 날짜의 + * 완료 배치를 재현하기 위해 endTime 을 직접 지정한다. + * + * @param endTime 완료 시각(이 날짜가 통계 버전이 된다) + * @return 저장된 JobLog + */ + public JobLog seedCompletedBatch(LocalDateTime endTime) { + JobLog jobLog = new JobLog(PROGRESS_CALCULATION); + ReflectionTestUtils.setField(jobLog, "endTime", endTime); + ReflectionTestUtils.setField(jobLog, "elapsedTime", "1ms"); + return jobLogRepository.save(jobLog); + } + + /** + * 테스트용 활성 Member 를 실제 DB 에 저장하고 그 memberId 를 반환한다. + * (cache service 의 {@code memberRepository.findById} 가 성립하도록 실제 행을 시드한다.) + */ + public Long seedMember() { + Member member = memberRepository.save(MemberFixture.activeMember()); + return member.getId(); + } + + /** {@code challenge-avg::} 물리 키 표현(메서드ID 기준). */ + public String cacheKey(Long memberId, String version) { + return CACHE_NAME + "::" + memberId + ":" + version; + } + + /** 현재 Redis 에 존재하는 모든 {@code challenge-avg::*} 키. */ + public Set challengeAvgKeys() { + Set keys = stringRedisTemplate.keys(KEY_PATTERN); + return keys == null ? Collections.emptySet() : keys; + } + + /** 특정 물리 키 존재 여부. */ + public boolean keyExists(String physicalKey) { + return Boolean.TRUE.equals(stringRedisTemplate.hasKey(physicalKey)); + } + + /** + * 특정 물리 키의 잔여 TTL(초). 키 없음=-2, TTL 미설정=-1 (Redis 규약). + */ + public long ttlSeconds(String physicalKey) { + Long ttl = stringRedisTemplate.getExpire(physicalKey, TimeUnit.SECONDS); + return ttl == null ? -2L : ttl; + } + + /** + * 테스트 간 격리: {@code challenge-avg::*} Redis 키 + JobLog/Member 행 정리. + * (challenge-avg 키 잔존이 다른 테스트를 오염시키지 않게 한다.) + */ + public void cleanUp() { + Set keys = challengeAvgKeys(); + if (!keys.isEmpty()) { + stringRedisTemplate.delete(keys); + } + jobLogRepository.deleteAll(); + memberRepository.deleteAll(); + } +}