[Volume 10] Spring Batch 기반 주간·월간 랭킹 시스템 구현 - 김남진#421
[Volume 10] Spring Batch 기반 주간·월간 랭킹 시스템 구현 - 김남진#421Namjin-kimm wants to merge 6 commits intoLoopers-dev-lab:Namjin-kimmfrom
Conversation
- RankingEntry DTO 도입으로 ZSetOperations.TypedTuple 반환 타입 제거 - RealtimeRankingRepository 인터페이스 신설, RankingService는 이를 통해 조회 - RealtimeRankingRepositoryImpl에서 Redis ZSET 접근 로직 캡슐화 DIP 준수 — domain 레이어가 Redis 구현 세부(RedisTemplate)를 직접 알지 않도록 분리. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WeeklyRankingJob / MonthlyRankingJob: JdbcCursorItemReader → Processor → Writer Chunk(50) 구조 - Reader: Rolling Window [today-7, today-1] / [today-30, today-1] 집계 SQL, LOG(1+x) 점수 공식 - 가중치는 ranking_weight 테이블에서 실행 시점 동적 조회 (BatchRankingWeightRepository) - MvProductRankWeeklyRepository / Monthly: JDBC UPSERT, ON DUPLICATE KEY UPDATE로 멱등성 보장 - RankingScheduler: 매일 01:00 / 01:30 KST cron 자동 실행 (trigger=SCHEDULED) - RankingManualRunner: --trigger 옵션으로 CLI 수동 재실행 (WEIGHT_CHANGE / DATA_FIX / MANUAL_RERUN) - RankingLatestDateCacheListener: Job 성공 + 적재 건수 > 0 시 Redis latest_date 캐시 갱신 (TTL 25h) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WeeklyRank / MonthlyRank: mv_product_rank_weekly/monthly 읽기 전용 JPA 엔티티 - WeeklyRankRepository / MonthlyRankRepository: findLatestSnapshotDate, findBySnapshotDateOrderByRankAsc 인터페이스 - WeeklyRankJpaRepository / Monthly: Spring Data JPA MAX() 쿼리 + 페이지네이션 배치(JDBC UPSERT)와 API(JPA 읽기)가 같은 테이블을 다른 엔티티로 접근하는 구조 — 쓰기/읽기 역할 분리. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- RankingFacade: findWeeklyRanking / findMonthlyRanking 추가
- date 미지정 시 Redis latest_date 캐시 → DB MAX() 폴백 순으로 snapshot 결정
- 메인 캐시 키: rankings:{period}:{snapshotDate}:{page}:{size} (RankingCacheRepository 위임)
- RankingV1Controller: GET /weekly, GET /monthly 엔드포인트 추가
- RankingV1ApiSpec: 주간/월간 OpenAPI 명세 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- MvProductRankWeeklyRepositoryImplIntegrationTest: UPSERT INSERT/UPDATE/created_at 보존 검증 - WeeklyRankingJobIntegrationTest: Rolling Window 경계, TOP 100 제한, JobInstance 멱등성 검증 - Monthly 동일 세트 - RankingFacadeWeeklyIntegrationTest: date 미지정 캐시 히트/미스/DB 폴백, 빈 snapshot 응답 검증 - RankingV1ApiWeeklyMonthlyE2ETest: E2E API 응답 스키마 검증 - WeeklyRankTestFixture / MonthlyRankTestFixture / RankingMetricsTestFixture 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- design-notes.md: Rolling Window, 소스 테이블 전략, MV 스키마, 캐시 설계 등 주요 결정 기록 - 가중치 동적 조회(DB SOT) 결정 및 Redis 미사용 이유 포함 - implementation_plan.md: Step별 구현 가이드 및 실제 구현과의 차이점 기록 - learning-point.md, quests.md: Week 10 학습 과제 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
📝 Walkthrough변경 요약Walkthrough주간 및 월간 랭킹 조회를 위한 새로운 공개 메서드와 배치 처리 인프라가 추가되었다. Redis 기반 실시간 랭킹과 데이터베이스 기반 스냅샷 랭킹 조회를 지원하는 새로운 도메인 타입과 리포지토리가 도입되었다. Changes
Sequence DiagramsequenceDiagram
participant Client
participant Controller as RankingV1Controller
participant Facade as RankingFacade
participant Cache as StringRedisTemplate<br/>(Redis)
participant DB as Weekly/MonthlyRankRepository
participant Product as ProductService
Client->>Controller: GET /api/v1/rankings/weekly<br/>(date?, page, size)
activate Controller
Controller->>Facade: findWeeklyRanking(date, page, size)
activate Facade
Facade->>Facade: resolveSnapshotDate(date)
alt date 제공됨
Facade->>Facade: 입력 date 사용
else date 미제공
Facade->>Cache: GET rankings:weekly:latest_date
alt Redis 캐시 히트
Cache-->>Facade: snapshotDate
else 캐시 미스
Facade->>DB: findLatestSnapshotDate()
DB-->>Facade: snapshotDate
Facade->>Cache: SET latest_date = snapshotDate<br/>(TTL: 25h)
end
end
Facade->>Cache: GET rankings:weekly:{snapshotDate}:{page}:{size}
alt 캐시 히트
Cache-->>Facade: RankingResult (캐시됨)
else 캐시 미스
Facade->>DB: findBySnapshotDateOrderByRankAsc<br/>(snapshotDate, PageRequest)
DB-->>Facade: Page<WeeklyRank>
Facade->>Product: 각 productId마다<br/>제품/브랜드명 조회
Product-->>Facade: Product, Brand
Facade->>Facade: RankingResult 생성<br/>(RankingItem 리스트)
Facade->>Cache: SET rankings:weekly:{key}<br/>= RankingResult
Cache-->>Facade: OK
end
Facade-->>Controller: RankingResult
deactivate Facade
Controller->>Controller: ApiResponse.success()<br/>(RankingListResponse.from())
Controller-->>Client: 200 OK + JSON
deactivate Controller
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45분 근거: 변경 범위가 광범위 (30개 이상 파일), commerce-api와 commerce-batch 양쪽 모두 영향, Redis 캐시 로직과 배치 처리 파이프라인 이질적 변경, 로직 밀도 높음 (cache-aside, 롤링 윈도우, upsert, 스케줄링), 테스트 커버리지 높음. 특히 운영 관점의 캐시 일관성, 배치 멱등성, 중복 실행 처리 검증 필요. Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (13)
apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.java-73-83 (1)
73-83:⚠️ Potential issue | 🟡 Minor
ZonedDateTime.now()가 시스템 기본 타임존에 종속된다.본 프로젝트는 KST(Asia/Seoul) 경계로 일 단위 집계·스케줄링(01:00/01:30 KST)을 수행한다. 서버 TZ가 UTC 로 배포될 경우
createdAt/updatedAt이 KST 기준 날짜와 어긋나 운영/디버깅 시metrics_date와 감사 타임스탬프 간 날짜 경계 혼선이 발생할 수 있다. 본 엔티티가 테스트 DDL 용이라 해도 컨벤션 일관성 관점에서 고정 존 사용을 권고한다.수정 후 시스템 TZ가 UTC 인 환경(예:
-Duser.timezone=UTC) 에서도 경계값 날짜(자정 직전/직후)로 삽입 시createdAt.getZone()이 KST 로 고정되는지 검증하는 단위 테스트 추가를 제안한다.🛠 제안 수정
-import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; @@ - `@PrePersist` - private void prePersist() { - ZonedDateTime now = ZonedDateTime.now(); + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + `@PrePersist` + private void prePersist() { + ZonedDateTime now = ZonedDateTime.now(KST); this.createdAt = now; this.updatedAt = now; } `@PreUpdate` private void preUpdate() { - this.updatedAt = ZonedDateTime.now(); + this.updatedAt = ZonedDateTime.now(KST); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.java` around lines 73 - 83, prePersist()/preUpdate() use ZonedDateTime.now() which depends on system TZ; change both to use a fixed ZoneId.of("Asia/Seoul") (e.g., ZonedDateTime.now(ZoneId.of("Asia/Seoul"))) when assigning createdAt and updatedAt in the RankingMetrics entity so timestamps are always KST, and add a unit test that simulates JVM timezone UTC (e.g., with -Duser.timezone or by temporarily setting TimeZone/ZoneId) to verify createdAt.getZone() and updatedAt.getZone() are Asia/Seoul around boundary times (just before/after midnight) to ensure date boundaries align with metrics_date.apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java-38-52 (1)
38-52:⚠️ Potential issue | 🟡 Minor주간/월간 엔드포인트의 날짜 파라미터 포맷을 일간/시간별과 통일하여 클라이언트 혼선을 제거한다.
운영/SDK 관점에서 동일 API 그룹 내 엔드포인트가 파라미터 포맷을 다르게 취급하면 클라이언트 측 형식 분기 로직이 증가하고 자동 생성 SDK의 개발자 경험이 악화된다.
- 현재: 일/시간별은
String date(yyyyMMdd), 주/월간은LocalDate date(yyyy-MM-dd)- 문제: 클라이언트는 동일 랭킹 API 그룹의 다른 엔드포인트마다 날짜 포맷을 변경해야 함
글로벌
MethodArgumentTypeMismatchException핸들러(ApiControllerAdvice 34-40줄)가 이미 존재하므로 LocalDate 파싱 실패 시 오류 응답 포맷은 일관되나, API 설계 일관성을 위해 주/월간도String으로 받고 도메인 레이어(RankingKeyResolver 계열)에서 파싱·검증하여CoreException으로 통일할 것을 권장한다. 이는 기존 일/시간별 방식과 정합되며 향후 유사한 바인딩 타입 예외에 대한 일괄 처리보다 명시적인 제어 흐름을 확보한다.테스트:
date=invalid,date=2025-02-30,date=(빈 값) 세 케이스에 대해 응답이 CoreException 경로와 동일한지 검증하는 E2E 테스트를 추가한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java` around lines 38 - 52, Change the weekly/monthly endpoints to accept String date (like daily/hourly) instead of LocalDate: update method signatures in RankingV1ApiSpec for getWeeklyRanking and getMonthlyRanking to use String date, then move parsing/validation into the domain resolver (RankingKeyResolver family) so it converts the incoming String to the appropriate date/key and throws CoreException on invalid input; ensure parsing uses the same format/logic as the existing daily/hourly path and keep ApiControllerAdvice unchanged, and add E2E tests for date=invalid, date=2025-02-30, and date= (empty) to verify they follow the CoreException error path.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java-14-17 (1)
14-17:⚠️ Potential issue | 🟡 MinorAPI 모듈
MonthlyRank엔티티에서 인덱스 정의를 추가해야 한다.배치 모듈의
MvProductRankMonthly엔티티에는@Index(name = "idx_snapshot_rank", columnList = "snapshot_date, rank_position")이 정의되어 있으나, API 모듈의MonthlyRank엔티티에는 이 인덱스 선언이 누락되어 있다. 동일한 테이블(mv_product_rank_monthly)에 매핑되는 엔티티이므로 양쪽 정의를 동기화해야 한다.운영 관점의 추가 확인 사항:
- 페이지네이션 성능:
findBySnapshotDateOrderByRankAsc에서(snapshot_date, rank_position)인덱스가 실제 DB 스키마에 존재하는지 확인 필요. 인덱스 부재 시 대량 스냅샷에 대해 full table scan 비용 발생.- MAX 스캔 최적화:
findLatestSnapshotDate는 현재 테이블 전체 MAX 스캔이다. 스냅샷 보관 기간 증가 시snapshot_date DESC역방향 스캔 가능 여부를 DB 쿼리 플랜으로 검증하고, 필요시 인덱스 힌트 추가 고려.추가 테스트: (1) 특정
snapshotDate에 대해 페이지 2, 3까지rank연속성 검증, (2) 데이터 미존재 시findLatestSnapshotDate()가Optional.empty()를 정상 반환하는지 검증.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java` around lines 14 - 17, The MonthlyRank entity is missing the DB index that exists in the batch module; add an equivalent `@Index`(name = "idx_snapshot_rank", columnList = "snapshot_date, rank_position") to the MonthlyRank entity mapping for table mv_product_rank_monthly so the (snapshot_date, rank_position) index is declared and kept in sync; then re-run schema validations/DDL and verify that findBySnapshotDateOrderByRankAsc will use the composite index and that findLatestSnapshotDate still returns Optional.empty() when no rows (and consider checking query plan for MAX(snapshot_date) to decide if additional index hints or DESC access are needed.apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankReaderConfig.java-27-35 (1)
27-35:⚠️ Potential issue | 🟡 Minor필수 입력(
snapshotDate, 가중치)에 대한 방어가 부족하다.
LocalDate.parse(snapshotDateStr)는snapshotDateStr가 null이면NullPointerException, 포맷 오류면DateTimeParseException을 던지며 배치는FAILED로 떨어진다.ManualRunner/스케줄러에서 파라미터 누락 시 로그만으로 원인 추적이 어려우므로, 사전 검증 후 명시적 메시지로 실패시키는 편이 운영에 유리하다.weightRepository.findWeightByEventType("VIEW"/"LIKE"/"ORDER")가null을 반환하면 라인 69의toPlainString()에서 NPE가 발생해 SQL 빌드 단계에서 터진다. DB SOT라는 전제하에 행이 누락된 경우(마이그레이션 미실행 등)를 대비해 명시적 검증을 추가할 것을 권장한다.🛡️ 제안 변경
- BigDecimal viewWeight = weightRepository.findWeightByEventType("VIEW"); - BigDecimal likeWeight = weightRepository.findWeightByEventType("LIKE"); - BigDecimal orderWeight = weightRepository.findWeightByEventType("ORDER"); - - String sql = buildSql(viewWeight, likeWeight, orderWeight); - - LocalDate snapshot = LocalDate.parse(snapshotDateStr); + if (snapshotDateStr == null || snapshotDateStr.isBlank()) { + throw new IllegalArgumentException("jobParameters['snapshotDate'] 는 필수이다."); + } + BigDecimal viewWeight = requireWeight("VIEW", weightRepository.findWeightByEventType("VIEW")); + BigDecimal likeWeight = requireWeight("LIKE", weightRepository.findWeightByEventType("LIKE")); + BigDecimal orderWeight = requireWeight("ORDER", weightRepository.findWeightByEventType("ORDER")); + + String sql = buildSql(viewWeight, likeWeight, orderWeight); + + LocalDate snapshot = LocalDate.parse(snapshotDateStr);그리고
requireWeight는 null 시IllegalStateException("ranking_weight[" + type + "] 누락")을 던지도록 헬퍼로 추출한다. 동일 로직은MonthlyRankReaderConfig에도 일관 적용한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankReaderConfig.java` around lines 27 - 35, WeeklyRankReaderConfig currently parses snapshotDateStr with LocalDate.parse and reads weights via weightRepository.findWeightByEventType without null checks, which can throw unclear NPE/DateTimeParseException during batch runs; add validation: check snapshotDateStr for null/empty and parse with try/catch to throw a clear IllegalArgumentException or fail fast with a descriptive message, and validate each weight returned from weightRepository (for "VIEW","LIKE","ORDER") before calling toPlainString(), throwing an IllegalStateException when a weight is missing; extract a small helper (e.g., requireWeight(String type)) that calls findWeightByEventType, checks for null and throws IllegalStateException("ranking_weight[" + type + "] 누락") to reuse here and in MonthlyRankReaderConfig, and then pass validated BigDecimals into buildSql.apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingLatestDateCacheListener.java-39-47 (1)
39-47:⚠️ Potential issue | 🟡 MinorRedis 장애 시 Job 상태 일관성을 방어한다.
stringRedisTemplate.opsForValue().set(...)과LocalDate.parse(...)는 예외 전파 경로인데,afterJob에서 예외가 던져지면 스프링 배치는 이 Job Execution을 이미COMPLETED로 기록한 뒤라도 리스너 예외로 로그가 오염되고, 운영상 "DB 적재는 성공했으나latest_date캐시만 지연"이라는 흔한 사건이 알람 노이즈가 된다. 아래와 같이 파싱 실패/Redis 실패를 분리 로깅하고 스왈로우하는 것이 의도(캐시는 성능 보조 · DB가 SOT)에 부합한다.🛡️ 제안 변경
- LocalDate snapshotDate = LocalDate.parse(snapshotDateStr); - long count = countByJobName(jobName, snapshotDate); + LocalDate snapshotDate; + try { + snapshotDate = LocalDate.parse(snapshotDateStr); + } catch (DateTimeParseException e) { + log.error("[{}] snapshotDate 파라미터 파싱 실패: {}", jobName, snapshotDateStr, e); + return; + } + long count = countByJobName(jobName, snapshotDate); if (count == 0) { log.warn("[{}] snapshotDate={} 적재 데이터 없음 — latest_date 캐시 갱신 스킵", jobName, snapshotDate); return; } - - stringRedisTemplate.opsForValue().set(cacheKey, snapshotDateStr, TTL); - log.info("[{}] latest_date 캐시 put: {} -> {}", jobName, cacheKey, snapshotDateStr); + try { + stringRedisTemplate.opsForValue().set(cacheKey, snapshotDateStr, TTL); + log.info("[{}] latest_date 캐시 put: {} -> {}", jobName, cacheKey, snapshotDateStr); + } catch (DataAccessException e) { + log.error("[{}] latest_date 캐시 put 실패 (DB MAX() fallback 으로 복구 가능): {}", jobName, cacheKey, e); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingLatestDateCacheListener.java` around lines 39 - 47, The current code in RankingLatestDateCacheListener (in afterJob) lets LocalDate.parse(snapshotDateStr) and stringRedisTemplate.opsForValue().set(...) throw and propagate from the listener; change it to swallow and separate errors: wrap the parse in a try/catch that logs a warning including jobName and snapshotDateStr and returns without throwing, then after count check wrap the Redis set call (stringRedisTemplate.opsForValue().set(cacheKey, snapshotDateStr, TTL)) in its own try/catch that logs an error including jobName, cacheKey, snapshotDateStr and the exception but does not rethrow; keep countByJobName and logging behavior otherwise so DB success remains authoritative and listener failures do not fail the Job execution.apps/commerce-api/src/test/java/com/loopers/fixture/MonthlyRankTestFixture.java-47-53 (1)
47-53:⚠️ Potential issue | 🟡 Minor사용되지 않는
ignoredProductId파라미터는 호출자에게 혼동을 준다인자로 받은
ignoredProductId는 메서드 내부에서 전혀 참조되지 않고 항상 새로 만든product.getId()를 사용한다. 운영/테스트 관점에서 호출자가 "전달한 productId로 insert된다"고 오해해 테스트 가정이 틀어질 수 있다. 해당 파라미터를 제거하거나, 의도적으로 "ID 자동 생성" 의미를 드러내는 이름으로 바꾸는 편이 안전하다.또한 매 호출마다
Brand를 새로 생성하므로 다수 호출 시 브랜드가 N개 쌓인다. 고정 브랜드를 공유하도록 1회만 생성해 캐싱하면 테스트 데이터 준비 비용이 줄어든다. 추가 테스트로 동일snapshotDate에 여러 rank를 insert한 뒤countBySnapshotDate또는 상위 API 조회 결과 순서가 기대치와 일치하는지 검증하는 케이스를 권장한다.♻️ 제안 수정
- public long insertWithProduct(LocalDate snapshotDate, long ignoredProductId, int rank) { - Brand brand = brandJpaRepository.save(new Brand("테스트브랜드")); + public long insertWithProduct(LocalDate snapshotDate, int rank) { + Brand brand = brandJpaRepository.save(new Brand("테스트브랜드")); Product product = productJpaRepository.save( new Product(brand.getId(), "테스트상품" + rank, new Money(10000), new Stock(100))); insert(snapshotDate, product.getId(), rank); return product.getId(); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/fixture/MonthlyRankTestFixture.java` around lines 47 - 53, The method insertWithProduct currently declares an unused parameter ignoredProductId which is confusing; remove that parameter from the signature of insertWithProduct and update all callers to stop passing an ID (or rename to indicate "autoGenerated" if you must preserve API), and change the method body to use the created product's ID as it does now; also avoid creating a new Brand on every call by introducing a cached/shared Brand instance (e.g., a private field initialized once or lazily in MonthlyRankTestFixture) and use its id when constructing new Product to reduce test setup cost; update any tests calling insertWithProduct accordingly.apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingScheduler.java-48-60 (1)
48-60:⚠️ Potential issue | 🟡 Minor광범위한
catch (Exception)과InterruptedException인터럽트 플래그 복원 누락운영 관점에서 두 가지 우려가 있다.
jobLauncher.run은JobExecutionAlreadyRunningException,JobRestartException,JobParametersInvalidException등 의미가 다른 체크 예외를 던지는데, 모두 동일하게error로그만 남기고 알림/재시도 훅이 없다. 최소한 예외 타입별로 분기해 "이미 실행 중"은warn, "파라미터 불량"은error+ 알림 대상 표기로 구분하는 것이 장애 분류에 유리하다.catch (Exception)은 체크 예외 아래로 흘러 들어올 수 있는InterruptedException도 삼키며 인터럽트 플래그를 복원하지 않는다. 스케줄 스레드가 종료 신호를 상실할 수 있다.추가 테스트로
JobInstanceAlreadyCompleteException외에JobExecutionAlreadyRunningException발생 시의 로그/미전파 동작을 커버하는 것이 좋다.🛡️ 제안 수정
} catch (JobInstanceAlreadyCompleteException e) { log.warn("[{}] snapshotDate={} 이미 완료된 JobInstance — 재실행 스킵", jobName, snapshotDate); - } catch (Exception e) { - log.error("[{}] 실행 실패 snapshotDate={}", jobName, snapshotDate, e); + } catch (JobExecutionAlreadyRunningException e) { + log.warn("[{}] snapshotDate={} 이미 실행 중 — 중복 실행 스킵", jobName, snapshotDate); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("[{}] 실행 중 인터럽트 발생 snapshotDate={}", jobName, snapshotDate); + } catch (Exception e) { + log.error("[{}] 실행 실패 snapshotDate={}", jobName, snapshotDate, e); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingScheduler.java` around lines 48 - 60, The run method in RankingScheduler swallows all exceptions and fails to restore the interrupt flag; update run(Job job, String jobName) to catch specific exceptions from jobLauncher.run—handle JobInstanceAlreadyCompleteException (keep warn), JobExecutionAlreadyRunningException (warn + no retry), JobRestartException (error + alert hook), and JobParametersInvalidException (error + alert/skip) separately with distinct log messages including jobName and snapshotDate; add a separate catch for InterruptedException that restores Thread.currentThread().interrupt() and logs interrupt at warn level; finally keep a narrow fallback for other Exceptions but ensure they trigger alerting/retry hooks as appropriate and add unit/integration tests that simulate JobExecutionAlreadyRunningException and InterruptedException to verify logging and interrupt flag restoration.apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingManualRunner.java-42-52 (1)
42-52:⚠️ Potential issue | 🟡 MinorCLI 인자 파싱 실패 시 예외 메시지/스택이 불친절하다
운영 관점에서 다음 세 경로가 모두 원시 예외를 그대로 던져 운영자가 원인을 파악하기 어렵다.
--trigger만 주고 값이 없는 경우:getOptionValues("trigger")가 빈 리스트를 반환하며get(0)에서IndexOutOfBoundsException발생.- 오탈자(
--trigger=WIEGHT_CHANGE등) 시:RankingJobTrigger.valueOf(...)가IllegalArgumentException: No enum constant ...를 그대로 던진다. 허용 값 목록이 로그에 남지 않는다.--snapshotDate=2026/04/10등 포맷 오류:DateTimeParseException이 그대로 전파된다.세 경우 모두 입력 검증 후 명확한 warn 로그로 전환하고 프로세스를 정상 종료하도록 수정하는 편이 수동 재실행 UX에 적합하다. 추가 테스트로는 (1)
--trigger값 누락, (2) 알 수 없는 trigger, (3) 잘못된 snapshotDate 포맷의 3종 케이스를 CLI 진입점 통합 테스트에 포함시키는 것이 좋다.🛡️ 제안 수정
- String triggerStr = args.getOptionValues("trigger").get(0); - RankingJobTrigger trigger = RankingJobTrigger.valueOf(triggerStr); + List<String> triggerValues = args.getOptionValues("trigger"); + if (triggerValues == null || triggerValues.isEmpty()) { + log.warn("[RankingManualRunner] --trigger 값이 누락되었다. 허용 값={}", + Arrays.toString(RankingJobTrigger.values())); + return; + } + String triggerStr = triggerValues.get(0); + RankingJobTrigger trigger; + try { + trigger = RankingJobTrigger.valueOf(triggerStr); + } catch (IllegalArgumentException e) { + log.warn("[RankingManualRunner] 알 수 없는 trigger={} (허용={})", + triggerStr, Arrays.toString(RankingJobTrigger.values())); + return; + } @@ - LocalDate snapshotDate = args.containsOption("snapshotDate") - ? LocalDate.parse(args.getOptionValues("snapshotDate").get(0)) - : LocalDate.now(ZoneId.of("Asia/Seoul")); + LocalDate snapshotDate; + try { + snapshotDate = args.containsOption("snapshotDate") + ? LocalDate.parse(args.getOptionValues("snapshotDate").get(0)) + : LocalDate.now(ZoneId.of("Asia/Seoul")); + } catch (DateTimeParseException | IndexOutOfBoundsException e) { + log.warn("[RankingManualRunner] 잘못된 --snapshotDate 입력 (yyyy-MM-dd 필요)", e); + return; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingManualRunner.java` around lines 42 - 52, RankingManualRunner currently lets raw exceptions propagate when CLI parsing fails: calling args.getOptionValues("trigger").get(0) can throw IndexOutOfBoundsException, RankingJobTrigger.valueOf(...) can throw IllegalArgumentException, and LocalDate.parse(...) can throw DateTimeParseException; update the CLI parsing in RankingManualRunner to validate inputs and handle these cases by (1) checking args.containsOption("trigger") and that getOptionValues("trigger") is non-empty before calling get(0) and log a clear warn + exit, (2) wrapping RankingJobTrigger.valueOf(triggerStr) in a safe lookup (e.g., try/catch or Enum lookup with a set of allowed names) to log allowed enum values on unknown input and exit, and (3) parsing snapshotDate with a try/catch around LocalDate.parse(...) to log the expected yyyy-MM-dd format and exit on parse failure; add unit/integration tests covering the three scenarios (missing trigger, unknown trigger, bad snapshotDate) to the CLI entrypoint tests.apps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImplIntegrationTest.java-75-92 (1)
75-92:⚠️ Potential issue | 🟡 Minor
Thread.sleep(10)에 의존하는 타이밍 테스트는 플래키하다운영/CI 관점에서
NOW(6)의 마이크로초 해상도라도 빠른 머신에서는 두 UPSERT의 타임스탬프가 드물게 동일해질 수 있고, 그럴 경우 "값이 갱신되지 않았다"와 "동일 시각에 갱신됐다"를 구분할 수 없다.created_at보존의 의도는 "두 번째 UPSERT가created_at을 갱신하지 않는다"이므로, 물리 시간이 아닌 DB가 명시적으로 다른 값을 가지도록 제어하는 것이 안정적이다.수정안: 첫 INSERT 직후
jdbc.update(...)로created_at을 과거 시각(예:'2000-01-01')으로 강제 업데이트한 뒤 두 번째upsertAll호출 후 조회하여 여전히 과거 시각인지 검증한다. 이렇게 하면Thread.sleep없이 결정적으로 검증된다.🧪 제안 수정
- LocalDateTime firstCreatedAt = repository.findBySnapshotDateAndProductId(date, 1L).orElseThrow().getCreatedAt(); - Thread.sleep(10); + jdbc.getJdbcTemplate().update( + "UPDATE mv_product_rank_monthly SET created_at = ? WHERE snapshot_date = ? AND product_id = ?", + LocalDateTime.of(2000, 1, 1, 0, 0), date, 1L); + LocalDateTime firstCreatedAt = repository.findBySnapshotDateAndProductId(date, 1L).orElseThrow().getCreatedAt();추가 테스트로 (a)
upsertAll(List.of())빈 입력 안전성, (b) 한 청크에 동일(snapshot_date, product_id)가 중복으로 들어온 경우의 동작을 포함하면 경계값 커버리지가 향상된다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImplIntegrationTest.java` around lines 75 - 92, The test createdAtIsPreservedOnUpdate relies on Thread.sleep and is flaky; replace the timing dependency by forcing the row's created_at to a deterministic past value via a direct JDBC update after the first repository.upsertAll call (e.g. set created_at = '2000-01-01'), then call repository.upsertAll again and assert that repository.findBySnapshotDateAndProductId still returns the forced past created_at; remove Thread.sleep and add separate tests for upsertAll(List.of()) (empty input) and behavior when a single chunk contains duplicate (snapshot_date, product_id) entries to cover boundary cases.apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiWeeklyMonthlyE2ETest.java-144-197 (1)
144-197:⚠️ Potential issue | 🟡 Minor월간 E2E 커버리지가 주간 대비 비대칭이다.
주간 Nested 클래스에는
withoutDate_cacheHit와responseSchemaValidation이 있는데 월간에는 둘 다 빠져 있다. 월간 경로는 리포지토리 구현이 다르기 때문에(monthlyRankRepository::findLatestSnapshotDate,MonthlyRank엔티티) 동일한 회귀가 발생할 수 있다. 특히latest_date캐시 HIT 경로와 응답 스키마(productName/brandName/price 비어있지 않음)는 월간에서도 별도로 검증할 것을 권장한다.추가 테스트 제안: 주간과 동일 구조로
withoutDate_cacheHit,responseSchemaValidation시나리오를 월간에 복제한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiWeeklyMonthlyE2ETest.java` around lines 144 - 197, Add two tests inside the MonthlyRanking nested class mirroring the weekly scenarios: implement withoutDate_cacheHit to pre-seed MONTHLY_LATEST_KEY in Redis, call restTemplate.exchange("/api/v1/rankings/monthly?page=0&size=20", ...) and assert 200 plus that the cached date was used (no DB fallback; verify stringRedisTemplate opsForValue still returns the seeded date) by referencing monthlyRankRepository::findLatestSnapshotDate / MONTHLY_LATEST_KEY; and implement responseSchemaValidation to insert a MonthlyRank via monthlyFixture.insertWithProduct(...) then call the monthly endpoint with explicit date and assert the response body rankings contain non-empty productName, brandName and price fields (same assertions as the weekly responseSchemaValidation), using the existing restTemplate exchange and ApiResponse<RankingV1Dto.RankingListResponse> parsing.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java-170-176 (1)
170-176:⚠️ Potential issue | 🟡 Minor명시된
date에 스냅샷이 아직 없을 때 빈 결과가 그대로 캐시에 저장된다.
given != null경로는resolveSnapshotDate의 null 체크를 우회하므로, 사용자가date=2026-04-17을 요청하고 아직 해당 스냅샷 행이 적재되기 전이라면, 빈RankingResult가 cacheKey 로 저장된다. 이후 배치가 해당 일자에 TOP-100 을 적재해도 TTL 만료 전까지는 빈 목록이 계속 서빙된다. 운영 관점에서 배치 지연/재실행 직후 사용자 체감 장애로 이어진다. 빈 결과는 캐시에 쓰지 않거나(권장), 매우 짧은 negative TTL 을 별도로 적용하는 전략이 필요하다.🛡️ 제안 패치
Page<WeeklyRank> ranks = weeklyRankRepository.findBySnapshotDateOrderByRankAsc( snapshot, PageRequest.of(page, size)); RankingResult result = toRankingResult(ranks.getContent(), r -> r.getRank(), r -> r.getProductId(), r -> r.getScore(), page, size, ranks.getTotalElements()); - rankingCacheRepository.save(cacheKey, result); + if (!result.items().isEmpty()) { + rankingCacheRepository.save(cacheKey, result); + } return result;(월간 경로에도 동일 적용)
추가 테스트 제안: 명시
date로 조회 시 빈 결과가 캐시에 저장되지 않는지(두 번째 호출이 DB 를 다시 조회하는지) 검증하는 케이스 추가를 권장한다.Also applies to: 196-202
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java` around lines 170 - 176, 현재 구현은 사용자가 명시한 date 경로(given != null / resolveSnapshotDate)를 통해 스냅샷이 아직 없을 때 빈 RankingResult를 생성해 rankingCacheRepository.save(cacheKey, result)로 바로 캐시해 버립니다; 빈 결과는 캐시에 쓰지 않거나(권장) 별도의 짧은 negative TTL을 적용하도록 수정하세요: findBySnapshotDateOrderByRankAsc(...)로 조회한 후 ranks.getContent()가 빈 경우에는 rankingCacheRepository.save를 호출하지 않도록 하고(월간 경로도 동일 적용), 만약 부득이 캐싱한다면 save 시점에 negative TTL 옵션을 사용해 매우 짧게 설정하도록 하며 resolveSnapshotDate, RankingResult 및 rankingCacheRepository.save 호출 지점을 변경하세요; 또한 빈 결과를 캐시에 쓰지 않는 동작을 보장하는 통합/단위 테스트(명시 date로 첫 호출이 빈 결과일 때 두번째 호출이 DB를 다시 조회하는지)를 추가하세요.apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeWeeklyIntegrationTest.java-99-111 (1)
99-111:⚠️ Potential issue | 🟡 Minor
mainCacheHit테스트가 캐시 적중을 실제로 검증하지 못한다.현재 단언문은 첫 호출 후 키 존재 여부만 확인한다. 이는 "캐시 키가 생성되었다"만 증명할 뿐, "두 번째 호출이 캐시에서 응답을 반환한다"는 시나리오명과 불일치한다. 운영 관점에서 캐시 로직이 비활성화되거나 키 스키마가 변경되어도 이 테스트는 여전히 통과할 수 있어 리그레션 감지력이 약하다.
수정안: 첫 호출 후 DB 레코드를 삭제(또는 값 변경)하고 두 번째 호출이 이전 결과와 동일하게 반환되는지(즉 DB가 아닌 캐시에서 응답) 검증하는 방식을 권장한다.
♻️ 제안 패치
void mainCacheHit() { // arrange - fixture.insertWithProduct(SNAPSHOT, 1L, 1); + long productId = fixture.insertWithProduct(SNAPSHOT, 1L, 1); // act - rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20); + RankingResult first = rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20); String cacheKey = "rankings:weekly:%s:0:20".formatted(SNAPSHOT); - // assert - assertThat(stringRedisTemplate.hasKey(cacheKey)).isTrue(); + // DB 상태 변경 후에도 두 번째 호출은 캐시에서 동일 결과를 반환해야 함 + databaseCleanUp.truncateAllTables(); + RankingResult second = rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20); + + // assert + assertThat(stringRedisTemplate.hasKey(cacheKey)).isTrue(); + assertThat(second.items()).hasSameSizeAs(first.items()); + assertThat(second.totalElements()).isEqualTo(first.totalElements()); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeWeeklyIntegrationTest.java` around lines 99 - 111, The test mainCacheHit currently only checks that the Redis key exists after the first call; change it to verify an actual cache hit by: call rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20) once, capture the returned result, then remove or mutate the underlying DB data inserted by fixture.insertWithProduct (so subsequent DB reads would differ or fail), call rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20) a second time and assert the second result equals the first (proving the response came from cache), while still optionally asserting stringRedisTemplate.hasKey(cacheKey) remains true; use the existing methods fixture.insertWithProduct and rankingFacade.findWeeklyRanking to locate the code to modify.apps/commerce-batch/src/test/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobIntegrationTest.java-62-66 (1)
62-66:⚠️ Potential issue | 🟡 Minor
JobListener가 남기는 Redisrankings:weekly:latest_date키 정리가 누락되어 있다.현재
tearDown은 DB truncate 와 JobExecution 정리만 수행한다. 그러나 이번 PR 의JobListener는COMPLETED + loaded>0조건일 때 Redisrankings:weekly:latest_date를 쓰므로, 본 테스트 클래스 실행 이후 Redis 에 해당 키가SNAPSHOT값으로 남는다. 이는 동일 Redis 를 공유하는 다른 통합 테스트(특히RankingFacadeWeeklyIntegrationTest,RankingV1ApiWeeklyMonthlyE2ETest의 "cacheMiss" 시나리오)의 전제 조건을 깨뜨려 간헐적 실패를 유발할 수 있다.tearDown에 Redis 키 삭제를 추가할 것을 권장한다.♻️ 제안 패치
`@Autowired` private DatabaseCleanUp databaseCleanUp; + + `@Autowired` + private org.springframework.data.redis.core.StringRedisTemplate stringRedisTemplate; `@BeforeEach` void setUp() { jobLauncherTestUtils.setJob(weeklyRankingJob); } `@AfterEach` void tearDown() { databaseCleanUp.truncateAllTables(); jobRepositoryTestUtils.removeJobExecutions(); + stringRedisTemplate.delete("rankings:weekly:latest_date"); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/test/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobIntegrationTest.java` around lines 62 - 66, tearDown 메서드가 DB와 JobExecution만 정리하고 있어 JobListener가 남긴 Redis 키 "rankings:weekly:latest_date"를 제거하지 못해 이후 통합테스트에 간헐적 실패를 유발합니다; tearDown( )에 Redis 정리 로직을 추가해 테스트가 사용 중인 Redis 클라이언트(예: RedisTemplate 또는 StringRedisTemplate 등 테스트 컨텍스트에서 사용 중인 인스턴스)를 사용해 키 "rankings:weekly:latest_date"를 삭제하도록 하세요(참조 메서드: tearDown, databaseCleanUp.truncateAllTables(), jobRepositoryTestUtils.removeJobExecutions(), JobListener).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: acfebd74-39d3-491b-ae73-0eb5fa0fa07c
⛔ Files ignored due to path filters (4)
.docs/week10/design-notes.mdis excluded by!**/*.mdand included by**.docs/week10/implementation_plan.mdis excluded by!**/*.mdand included by**.docs/week10/learning-point.mdis excluded by!**/*.mdand included by**.docs/week10/quests.mdis excluded by!**/*.mdand included by**
📒 Files selected for processing (48)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRank.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingEntry.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RealtimeRankingRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRank.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RealtimeRankingRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.javaapps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeWeeklyIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/fixture/MonthlyRankTestFixture.javaapps/commerce-api/src/test/java/com/loopers/fixture/WeeklyRankTestFixture.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiWeeklyMonthlyE2ETest.javaapps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.javaapps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingAggregateRow.javaapps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingJobTrigger.javaapps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingLatestDateCacheListener.javaapps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingManualRunner.javaapps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingScheduler.javaapps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankProcessor.javaapps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankReaderConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankWriter.javaapps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankProcessor.javaapps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankReaderConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankWriter.javaapps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/domain/rank/BatchRankingWeightRepository.javaapps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthly.javaapps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthlyRepository.javaapps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeekly.javaapps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeeklyRepository.javaapps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/BatchRankingWeightRepositoryImpl.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImpl.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankWeeklyRepositoryImpl.javaapps/commerce-batch/src/main/resources/application.ymlapps/commerce-batch/src/test/java/com/loopers/batch/ranking/monthly/MonthlyRankingJobIntegrationTest.javaapps/commerce-batch/src/test/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobIntegrationTest.javaapps/commerce-batch/src/test/java/com/loopers/fixture/RankingMetricsTestFixture.javaapps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImplIntegrationTest.javaapps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankWeeklyRepositoryImplIntegrationTest.java
| private LocalDate resolveSnapshotDate(LocalDate given, String latestKey, | ||
| Supplier<Optional<LocalDate>> dbFallback) { | ||
| if (given != null) return given; | ||
|
|
||
| String cached = stringRedisTemplate.opsForValue().get(latestKey); | ||
| if (cached != null) return LocalDate.parse(cached); | ||
|
|
||
| Optional<LocalDate> fromDb = dbFallback.get(); | ||
| fromDb.ifPresent(d -> stringRedisTemplate.opsForValue().set(latestKey, d.toString(), Duration.ofHours(25))); | ||
| return fromDb.orElse(null); | ||
| } |
There was a problem hiding this comment.
Redis 장애 시 API 가 완전 실패한다 — 조회 경로에 예외 격리가 없다.
RankingCacheRepositoryImpl.get/save 는 모든 예외를 삼키고 Optional.empty() 또는 로그만 남기는 캐시-어사이드 철학으로 구현되어 있다. 그러나 resolveSnapshotDate 의 238번째 줄 Redis 읽기와 242번째 줄 Redis 쓰기는 보호 없이 수행되므로, Redis 다운/타임아웃 시 DB 폴백이 있음에도 불구하고 요청 전체가 500 으로 떨어진다. 운영 관점에서 Redis 는 보조 캐시이므로 장애 시 DB MAX 폴백만으로 서비스가 되어야 한다. RankingCacheRepository 와 동일한 패턴으로 try/catch 로 감싸 degrade 경로를 보장할 것을 권장한다.
🛡️ 제안 패치
private LocalDate resolveSnapshotDate(LocalDate given, String latestKey,
Supplier<Optional<LocalDate>> dbFallback) {
if (given != null) return given;
- String cached = stringRedisTemplate.opsForValue().get(latestKey);
- if (cached != null) return LocalDate.parse(cached);
+ String cached = null;
+ try {
+ cached = stringRedisTemplate.opsForValue().get(latestKey);
+ } catch (Exception e) {
+ log.warn("latest_date Redis 조회 실패 (key={}): {}", latestKey, e.getMessage());
+ }
+ if (cached != null) {
+ try {
+ return LocalDate.parse(cached);
+ } catch (DateTimeParseException e) {
+ log.warn("latest_date 포맷 오류 (key={}, value={})", latestKey, cached);
+ }
+ }
Optional<LocalDate> fromDb = dbFallback.get();
- fromDb.ifPresent(d -> stringRedisTemplate.opsForValue().set(latestKey, d.toString(), Duration.ofHours(25)));
+ fromDb.ifPresent(d -> {
+ try {
+ stringRedisTemplate.opsForValue().set(latestKey, d.toString(), Duration.ofHours(25));
+ } catch (Exception e) {
+ log.warn("latest_date Redis 저장 실패 (key={}): {}", latestKey, e.getMessage());
+ }
+ });
return fromDb.orElse(null);
}추가 테스트 제안: Redis 연결을 강제로 실패시키는(혹은 StringRedisTemplate 을 스파이로 throw 하도록 세팅한) 시나리오에서 주간/월간 API 가 DB 폴백으로 200 을 반환하는지 검증하는 테스트 케이스 추가를 권장한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`
around lines 234 - 244, resolveSnapshotDate currently calls
stringRedisTemplate.opsForValue().get(latestKey) and .set(...) without
isolation, so Redis failures bubble up; wrap both the read and the write in
try/catch blocks (mirroring RankingCacheRepositoryImpl’s degrade pattern) so any
Redis exception is caught, logged, and ignored, then continue to use
dbFallback.get() (and still set the cache only if no exception and value
present, preserving Duration.ofHours(25)); ensure the method still returns the
given date if non-null, and returns from dbFallback.orElse(null) when Redis is
unavailable.
| @GetMapping("/weekly") | ||
| @Override | ||
| public ApiResponse<RankingV1Dto.RankingListResponse> getWeeklyRanking( | ||
| @RequestParam(required = false) LocalDate date, | ||
| @RequestParam(defaultValue = "0") int page, | ||
| @RequestParam(defaultValue = "20") int size) | ||
| { | ||
| var result = rankingFacade.findWeeklyRanking(date, page, size); | ||
| return ApiResponse.success(RankingV1Dto.RankingListResponse.from(result)); | ||
| } | ||
|
|
||
| @GetMapping("/monthly") | ||
| @Override | ||
| public ApiResponse<RankingV1Dto.RankingListResponse> getMonthlyRanking( | ||
| @RequestParam(required = false) LocalDate date, | ||
| @RequestParam(defaultValue = "0") int page, | ||
| @RequestParam(defaultValue = "20") int size) | ||
| { | ||
| var result = rankingFacade.findMonthlyRanking(date, page, size); | ||
| return ApiResponse.success(RankingV1Dto.RankingListResponse.from(result)); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -nP -C2 'findWeeklyRanking|findMonthlyRanking' --type=java
rg -nP -C3 'getWeeklyRanking|getMonthlyRanking' --type=javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 10954
🏁 Script executed:
#!/bin/bash
# Check daily/hourly endpoint definitions
rg -nP 'getDaily|getDailyRanking|getHourly|getHourlyRanking' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/
# Check for DATE_FORMAT constant
rg -nP 'DATE_FORMAT|SimpleDateFormat|DateTimeFormatter' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/
# Look for request parameter handling with yyyyMMdd pattern
rg -nP 'yyyyMMdd|yyyy-MM-dd' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2356
🏁 Script executed:
#!/bin/bash
# Get the full RankingV1Controller to see all endpoints
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3576
🏁 Script executed:
#!/bin/bash
# Check for ApiSpec parameter documentation
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2636
🏁 Script executed:
#!/bin/bash
# Check for error/exception handlers in the codebase
rg -nP 'ApiControllerAdvice|@ExceptionHandler|MethodArgumentTypeMismatchException|DateTimeParseException' --type=java apps/commerce-api/src/main/java/
# Look for global error handling configuration
rg -nP 'class.*ControllerAdvice|class.*ExceptionHandler' --type=java apps/commerce-api/src/main/java/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1801
🏁 Script executed:
#!/bin/bash
# Check if there are tests that show actual error behavior
rg -nP 'MethodArgumentTypeMismatchException|DateTimeParseException|invalid.*date|wrong.*format' --type=java apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check the RankingFacade to see if date validation happens there
rg -A5 'findDailyRanking|findWeeklyRanking' --type=java apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java | head -40Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 955
🏁 Script executed:
#!/bin/bash
# Get the full ApiControllerAdvice
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 7266
🏁 Script executed:
#!/bin/bash
# Check if there's any DateTimeParseException handler
rg -B2 -A10 'DateTimeParseException' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Look for any tests that verify error responses for invalid date formats
rg -B3 -A5 'getWeekly|getMonthly|getDailyRanking|getHourlyRanking' --type=java apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/ | head -80Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Search for E2E or integration tests for ranking endpoints
find apps/commerce-api/src/test -name "*RankingV1*" -o -name "*Ranking*E2E*" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 281
🏁 Script executed:
#!/bin/bash
# Check if there are any tests that verify invalid date format handling
rg -B2 -A8 'invalid|wrong|yyyyMMdd|yyyy-MM-dd' --type=java apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/ 2>/dev/null || echo "No matches found"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1877
🏁 Script executed:
#!/antml:bash
# Verify the exact signature of DateTimeParseException in daily/hourly
# and confirm no try-catch is present
rg -B5 -A5 'LocalDate.parse\(date, DATE_FORMAT\)' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1225
🏁 Script executed:
#!/bin/bash
# Check the weekly/monthly E2E test to see what format it's using
cat -n apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiWeeklyMonthlyE2ETest.java | head -80Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4109
🏁 Script executed:
#!/bin/bash
# Search for any try-catch or exception handling around date parsing in the controller
rg -B10 -A10 'public ApiResponse.*getDaily|public ApiResponse.*getHourly|public ApiResponse.*getWeekly|public ApiResponse.*getMonthly' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2295
🏁 Script executed:
#!/bin/bash
# Check if DateTimeParseException is mentioned or handled anywhere in the interfaces/api module
rg -nP 'DateTimeParseException' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check what error type is returned for DateTimeParseException (should be generic INTERNAL_ERROR)
rg -A5 'handle\(Throwable e\)' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 336
🏁 Script executed:
#!/antml:bash
# Check if there are any tests that verify error responses for invalid formats
rg -B3 -A8 'invalid.*date|wrong.*format|DateTimeParseException|MethodArgumentTypeMismatchException' --type=java apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Look for try-catch patterns around LocalDate.parse in the codebase
rg -B5 -A10 'try.*LocalDate\.parse|LocalDate\.parse.*catch' --type=java apps/commerce-api/src/main/java/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/antml:bash
# Check RankingKeyResolver to see how it handles DateTimeParseException
cat -n apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingKeyResolver.java | head -80Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 199
🏁 Script executed:
#!/bin/bash
# Find the RankingKeyResolver file
find apps/commerce-api -name "RankingKeyResolver.java" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/antml:bash
# Search for the domain class mentioned in learnings
rg -l 'RankingKeyResolver' --type=java apps/commerce-api/src/main/java/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Look for any try-catch patterns with DateTimeParseException in the entire codebase
rg -B5 -A8 'catch.*DateTimeParseException|DateTimeParseException.*catch' --type=java apps/commerce-api/src/main/java/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1562
동일 리소스군에서 date 파라미터 타입·포맷이 불일치하고, 파싱 실패 시 에러 응답이 불일관하다.
같은 /api/v1/rankings/* 네임스페이스에서 daily/hourly는 String + yyyyMMdd 형식인데(라인 26-48), weekly/monthly는 LocalDate + yyyy-MM-dd 형식(라인 50-70)으로 받고 있다. 클라이언트 입장에서 같은 엔드포인트 패밀리에 두 가지 날짜 포맷을 요구하게 되므로 문서화 혼란과 호출 오류 가능성이 크다.
더 심각한 것은 파싱 실패 시 에러 응답이 불일관하다는 점이다:
weekly/monthly(LocalDate 자동 변환): MethodArgumentTypeMismatchException → ApiControllerAdvice가 캡처 → BAD_REQUEST (400)daily/hourly(수동 파싱): DateTimeParseException 발생 → 미캡처 → 일반 Throwable 핸들러 → INTERNAL_ERROR (500)
사용자 입력 오류인데 서버 에러로 응답하는 것은 운영 관점에서 심각하다. 기존 daily/hourly 포맷(yyyyMMdd)으로 통일하되, 파싱 실패 시 try-catch로 CoreException을 던져 ApiControllerAdvice를 통한 표준 응답 포맷(BAD_REQUEST)을 보장해야 한다.
♻️ 제안 변경
`@GetMapping`("/weekly")
`@Override`
public ApiResponse<RankingV1Dto.RankingListResponse> getWeeklyRanking(
- `@RequestParam`(required = false) LocalDate date,
+ `@RequestParam`(required = false) String date,
`@RequestParam`(defaultValue = "0") int page,
`@RequestParam`(defaultValue = "20") int size)
{
+ LocalDate targetDate = null;
+ if (date != null) {
+ try {
+ targetDate = LocalDate.parse(date, DATE_FORMAT);
+ } catch (DateTimeParseException e) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (형식: yyyyMMdd)");
+ }
+ }
- var result = rankingFacade.findWeeklyRanking(date, page, size);
+ var result = rankingFacade.findWeeklyRanking(targetDate, page, size);
return ApiResponse.success(RankingV1Dto.RankingListResponse.from(result));
}monthly 핸들러도 동일하게 적용한다. ApiSpec·RankingFacade 시그니처는 이미 LocalDate 타입이므로 변경 없음. E2E 테스트(RankingV1ApiWeeklyMonthlyE2ETest 라인 58)에서 SNAPSHOT.toString() 대신 SNAPSHOT.format(DATE_FORMAT)으로 수정해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`
around lines 50 - 70, The controllers getWeeklyRanking and getMonthlyRanking in
RankingV1Controller use LocalDate binding (yyyy-MM-dd) while daily/hourly use
String (yyyyMMdd), causing inconsistent formats and inconsistent error handling;
change weekly/monthly to accept String date in yyyyMMdd, parse it with
DateTimeFormatter.ofPattern("yyyyMMdd") inside the methods, wrap parsing in
try-catch and throw the existing CoreException (so ApiControllerAdvice returns
BAD_REQUEST) before calling rankingFacade.findWeeklyRanking/findMonthlyRanking
(no signature change to RankingFacade). Also update the E2E test
RankingV1ApiWeeklyMonthlyE2ETest to use SNAPSHOT.format(DATE_FORMAT) instead of
SNAPSHOT.toString() so tests use the yyyyMMdd format.
| private final AtomicInteger rankCounter = new AtomicInteger(0); | ||
|
|
||
| public MonthlyRankProcessor(@Value("#{jobParameters['snapshotDate']}") String snapshotDateStr) { | ||
| this.snapshotDate = LocalDate.parse(snapshotDateStr); | ||
| } | ||
|
|
||
| @Override | ||
| public MvProductRankMonthly process(RankingAggregateRow item) { | ||
| int rank = rankCounter.incrementAndGet(); | ||
| return new MvProductRankMonthly( | ||
| snapshotDate, item.productId(), rank, item.score(), | ||
| item.viewCount(), item.likeCount(), item.orderRevenue()); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Job에 preventRestart 또는 재시작 정책이 설정되어 있는지 확인
rg -nP -C3 '(MonthlyRankingJobConfig|WeeklyRankingJobConfig)\b'
rg -nP -C2 'preventRestart|allowStartIfComplete|startLimit'
# reader가 rank를 DB에서 계산하는지 확인
rg -nP -C3 'ROW_NUMBER|RANK\(\)|DENSE_RANK'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3979
🏁 Script executed:
#!/bin/bash
# Get the full MonthlyRankingJobConfig to check restart policy
find . -name "MonthlyRankingJobConfig.java" -type f | head -1 | xargs cat -n
# Check MonthlyRankProcessor for `@StepScope` and other scope annotations
find . -name "MonthlyRankProcessor.java" -type f | head -1 | xargs cat -nRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4120
🏁 Script executed:
#!/bin/bash
# Check the MonthlyRankingJobIntegrationTest for restart test
find . -name "MonthlyRankingJobIntegrationTest.java" -type f | head -1 | xargs cat -n | head -100
# Search for the monthlyRankReader bean definition (should be in same config or reader config)
rg -n "monthlyRankReader|RankingAggregateRow" --type java apps/commerce-batch/src/main/java -A 5 | head -80Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 14879
🏁 Script executed:
#!/bin/bash
# Read more of the integration test
find . -name "MonthlyRankingJobIntegrationTest.java" -type f | head -1 | xargs cat -n | tail -n +100
# Find reader configuration
find . -path "*/ranking/*" -name "*Reader*.java" -o -name "*ReaderConfig*.java" | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2249
🏁 Script executed:
#!/bin/bash
# Check the reader configuration for ranking computation
find . -name "MonthlyRankReaderConfig.java" -type f | head -1 | xargs cat -nRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3543
스텝 재시작 시 rankCounter가 0부터 다시 증가하여 랭크 순서 정합성이 깨진다.
@StepScope 덕에 각 Step 실행마다 MonthlyRankProcessor는 새 인스턴스로 생성되고, AtomicInteger rankCounter는 0부터 시작한다. 청크 처리 중 실패로 동일 JobExecution 내에서 스텝이 재시작되면, 리더는 동일한 SQL 쿼리를 다시 실행하여 상위 100개 상품을 동일한 순서로 반환한다. 프로세서는 이 상품들에 대해 1부터 100까지 순위를 다시 할당하게 되는데, 라이터의 UPSERT((snapshot_date, product_id) 기준)로 인해 재처리된 상품의 순위는 새로운 값으로 덮어써지는 반면, 실패 지점 이전까지 처리된 상품들은 기존 순위를 유지한다. 결과적으로 rank_position에 중복이 발생하고 순서 번호에 간격이 생겨 TOP-100 랭킹의 순서 정합성이 깨진다.
운영 관점에서 이 지점은 배치 장애 복구 시 "부분 재실행" 시나리오에 노출되는 사용자 랭킹을 왜곡시킨다. 다음 중 하나의 방안을 선택하여 수정한다:
- 해당 Job을 재시작 불가(
.restartable(false))로 구성하고, 실패 시snapshotDate를 동일하게 유지하여 새 JobExecution으로 처음부터 재실행한다. ItemStream을 구현하여ExecutionContext에 카운터를 체크포인트로 저장 및 복원한다.- 리더 수준에서 SQL
ORDER BY ... LIMIT 100대신ROW_NUMBER() OVER (ORDER BY score DESC) AS rank를 사용하여 DB가 순위를 계산하도록 위임한다.
추가로 스텝 재시작 시나리오에 대한 통합 테스트를 추가하여 청크 중간 실패 후 재시작 시 모든 rank_position이 1..N 유일성을 유지하는지 검증한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankProcessor.java`
around lines 18 - 30, MonthlyRankProcessor currently resets AtomicInteger
rankCounter to 0 on step restart causing duplicate/missing rank_positions;
implement ItemStream on MonthlyRankProcessor and persist the counter into the
ExecutionContext (use a unique key like "MonthlyRankProcessor.rankCounter") by:
add implements ItemStream, add open(ExecutionContext) to restore rankCounter
from the context if present, add update(ExecutionContext) to write current
rankCounter.get(), and leave close() no-op; ensure process(RankingAggregateRow)
continues to use rankCounter.incrementAndGet() so resumed runs continue ranking
from the last checkpointed value.
| ORDER BY score DESC | ||
| LIMIT 100 |
There was a problem hiding this comment.
ORDER BY score DESC 단일 정렬 키로는 동점 시 순위가 비결정적이다.
점수가 동일한 상품이 여러 개인 경우 MySQL 정렬 결과는 실행 계획/InnoDB 내부 상태에 따라 달라진다. 동일 snapshotDate 로 배치를 재실행하거나 manualReRun 트리거로 돌릴 때마다 rank_position 이 뒤바뀔 수 있고, UPSERT 로 이전 순위가 덮어쓰여 사용자에게 노출되는 Top 100 목록이 흔들린다. 배치 재실행의 멱등성/관측 가능성 관점에서 보조 정렬 키(product_id)를 추가해 결정론적 순서를 보장할 것을 권장한다. 주간 리더(WeeklyRankReaderConfig.buildSql) 에도 동일 이슈가 있으니 함께 반영해야 한다.
♻️ 제안 패치
- ORDER BY score DESC
+ ORDER BY score DESC, product_id ASC
LIMIT 100추가 테스트 제안: 동점 스코어가 발생하도록 메트릭을 시드한 뒤 동일 snapshotDate 로 manualReRun 경로를 돌려 rank_position 이 동일하게 유지되는지 검증하는 케이스를 추가할 것을 권장한다.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ORDER BY score DESC | |
| LIMIT 100 | |
| ORDER BY score DESC, product_id ASC | |
| LIMIT 100 |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankReaderConfig.java`
around lines 66 - 67, The ORDER BY clause in MonthlyRankReaderConfig (ORDER BY
score DESC) is non-deterministic for ties; update the SQL built by
MonthlyRankReaderConfig (and the analogous WeeklyRankReaderConfig.buildSql) to
add a stable secondary sort key (e.g., ORDER BY score DESC, product_id ASC) so
tied scores produce deterministic rank_position, and add a test that seeds equal
scores and reruns the manualReRun path for the same snapshotDate to assert
rank_position remains identical.
| private long countByJobName(String jobName, LocalDate snapshotDate) { | ||
| return switch (jobName) { | ||
| case "weeklyRankingJob" -> weeklyRepository.countBySnapshotDate(snapshotDate); | ||
| case "monthlyRankingJob" -> monthlyRepository.countBySnapshotDate(snapshotDate); | ||
| default -> 0L; | ||
| }; | ||
| } | ||
|
|
||
| private String resolveCacheKey(String jobName) { | ||
| return switch (jobName) { | ||
| case "weeklyRankingJob" -> "rankings:weekly:latest_date"; | ||
| case "monthlyRankingJob" -> "rankings:monthly:latest_date"; | ||
| default -> null; | ||
| }; | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Job 이름 하드코딩은 상수 참조로 전환한다.
WeeklyRankingJobConfig.JOB_NAME, MonthlyRankingJobConfig.JOB_NAME 상수가 이미 존재하는데 리스너에서 문자열 리터럴("weeklyRankingJob"/"monthlyRankingJob")을 이중 선언하고 있어, Job 이름 변경 시 한쪽만 바뀌면 resolveCacheKey/countByJobName이 조용히 null/0L을 반환해 latest_date 캐시 갱신이 무음으로 누락된다. 운영 관점에서 "COMPLETED 인데 캐시는 갱신 안 됨"이라는 원인 추적이 어려운 장애를 유발할 수 있으므로 상수로 단일화하는 것이 바람직하다.
♻️ 제안 변경
- private long countByJobName(String jobName, LocalDate snapshotDate) {
- return switch (jobName) {
- case "weeklyRankingJob" -> weeklyRepository.countBySnapshotDate(snapshotDate);
- case "monthlyRankingJob" -> monthlyRepository.countBySnapshotDate(snapshotDate);
- default -> 0L;
- };
- }
-
- private String resolveCacheKey(String jobName) {
- return switch (jobName) {
- case "weeklyRankingJob" -> "rankings:weekly:latest_date";
- case "monthlyRankingJob" -> "rankings:monthly:latest_date";
- default -> null;
- };
- }
+ private long countByJobName(String jobName, LocalDate snapshotDate) {
+ if (WeeklyRankingJobConfig.JOB_NAME.equals(jobName)) return weeklyRepository.countBySnapshotDate(snapshotDate);
+ if (MonthlyRankingJobConfig.JOB_NAME.equals(jobName)) return monthlyRepository.countBySnapshotDate(snapshotDate);
+ return 0L;
+ }
+
+ private String resolveCacheKey(String jobName) {
+ if (WeeklyRankingJobConfig.JOB_NAME.equals(jobName)) return "rankings:weekly:latest_date";
+ if (MonthlyRankingJobConfig.JOB_NAME.equals(jobName)) return "rankings:monthly:latest_date";
+ return null;
+ }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingLatestDateCacheListener.java`
around lines 50 - 64, Replace the hard-coded job name string literals in
countByJobName and resolveCacheKey with the existing constants
WeeklyRankingJobConfig.JOB_NAME and MonthlyRankingJobConfig.JOB_NAME; update the
switch cases in the methods countByJobName(String jobName, LocalDate
snapshotDate) and resolveCacheKey(String jobName) to use those constants (and
keep the same return values for each branch and the default behavior), so
job-name changes are centralized on the WeeklyRankingJobConfig and
MonthlyRankingJobConfig constants.
| private String buildSql(BigDecimal viewWeight, BigDecimal likeWeight, BigDecimal orderWeight) { | ||
| return """ | ||
| SELECT product_id, | ||
| SUM(view_count) AS view_count, | ||
| SUM(like_count) AS like_count, | ||
| SUM(order_revenue) AS order_revenue, | ||
| LOG(1 + SUM(view_count)) * %s | ||
| + LOG(1 + SUM(like_count)) * %s | ||
| + LOG(1 + SUM(order_revenue)) * %s AS score | ||
| FROM ranking_metrics | ||
| WHERE metrics_date BETWEEN ? AND ? | ||
| GROUP BY product_id | ||
| ORDER BY score DESC | ||
| LIMIT 100 | ||
| """.formatted( | ||
| viewWeight.toPlainString(), | ||
| likeWeight.toPlainString(), | ||
| orderWeight.toPlainString()); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# ranking_weight 편집 경로/관리자 API 존재 여부 확인
rg -nP -C2 'ranking_weight|findWeightByEventType|RankingWeight' --type=java
# TOP-N 하드코딩 분포 확인
rg -nP -C1 '\bLIMIT\s+100\b' --type=java --type=sqlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 27343
String.formatted()로 SQL을 조립하는 방식을 PreparedStatement 바인딩으로 전환하라.
현재 viewWeight/likeWeight/orderWeight는 DB의 ranking_weight에서 조회되지만, 문서에서 명시된 대로 향후 관리자 API를 통해 동적으로 변경될 수 있다. 문자열 포매팅은 이러한 변경 시 SQL 인젝션의 진입점이 되며, 현재 상태에서도 보안 아키텍처로 권장되지 않는다. 또한 toPlainString() 결과와 MySQL DECIMAL 타입의 문자열 변환 경로 차이로 인해 수치 부정확성이 누적될 수 있다.
NamedParameterJdbcTemplate의 명명된 파라미터 바인딩을 사용하도록 리팩토링하며, score 계산식의 가중치 값들을 PreparedStatement ? 파라미터로 바인딩하라. JdbcCursorItemReader의 preparedStatementSetter에서 바인딩을 처리하는 구조가 권장된다.
LIMIT 100을 상수로 추출하라.
LIMIT 100이 WeeklyRankReaderConfig와 MonthlyRankReaderConfig 양쪽에 하드코딩되어 있어, 순위 정책 변경 시 수정 지점이 분산된다. RankingConstants 등의 상수 클래스로 TOP-N 값을 추출하여 단일 지점에서 관리하라.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankReaderConfig.java`
around lines 54 - 72, Replace the string-formatted SQL in buildSql
(WeeklyRankReaderConfig) with a parameterized query that uses named (or
positional) placeholders for the three weight values instead of injecting
viewWeight/likeWeight/orderWeight via String.formatted; switch the reader to use
a JdbcCursorItemReader with a preparedStatementSetter (or
NamedParameterJdbcTemplate) to bind the three weight parameters into the score
expression at runtime (use BigDecimal values directly, not toPlainString), and
move the hardcoded LIMIT 100 into a single constant (e.g., TOP_N) in a shared
RankingConstants class so both WeeklyRankReaderConfig and
MonthlyRankReaderConfig reference that constant. Ensure the
preparedStatementSetter binds the weights in the same order the placeholders
appear and that the SQL uses placeholders for the score multipliers.
| public static void main(String[] args) { | ||
| int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); | ||
| System.exit(exitCode); | ||
| SpringApplication.run(CommerceBatchApplication.class, args); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# 배포/실행 방식과 수동 러너 구현을 확인한다.
fd -t f -e yml -e yaml . | xargs rg -nP -C2 'commerce-batch|CommerceBatchApplication' 2>/dev/null
fd -t f Dockerfile | xargs rg -nP -C2 'commerce-batch|java ' 2>/dev/null
rg -nP -C3 'ApplicationRunner|CommandLineRunner|RankingJobTrigger' --type=javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 6729
🏁 Script executed:
cat -n apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 994
🏁 Script executed:
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingManualRunner.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2690
🏁 Script executed:
rg -nP '@EnableScheduling|@ConditionalOnProperty|@ConditionalOnExpression|@Profile' apps/commerce-batch --type=java -A2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2060
🏁 Script executed:
rg -nP 'graceful|shutdown|ShutdownHook|ExecutorService.*shutdown' apps/commerce-batch --type=java -A2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
fd -t f -e yml -e yaml -e properties apps/commerce-batch | xargs rg -nP 'shutdown|graceful|timeout' 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 6010
🏁 Script executed:
fd -t f \( -name 'Dockerfile' -o -name 'deployment*' -o -name '*manifest*' -o -name '*.sh' \) | xargs rg -nP 'commerce-batch|java' 2>/dev/null | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4242
🏁 Script executed:
rg -nP '@Profile|spring.profiles' apps/commerce-batch --type=java -B1 -A1Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
fd --full-path 'apps/commerce-batch.*' -e yml -e yaml | xargs cat -n 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1153
🏁 Script executed:
fd -i dockerfile | xargs cat -n 2>/dev/null | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 183
🏁 Script executed:
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingScheduler.java | head -70Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2762
🏁 Script executed:
rg -nP 'System.exit|SpringApplication.exit|applicationContext.close' apps/commerce-batch --type=javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
@EnableScheduling 조건부 활성화 및 수동 러너의 명시적 종료 처리 필수다.
CommerceBatchApplication에서 @EnableScheduling을 무조건 활성화하면서 RankingManualRunner에서 수동 실행 후 프로세스를 종료하지 않으므로, 다음과 같은 운영 문제가 발생할 수 있다.
- Kubernetes Job/CronJob이나 CI 파이프라인에서 수동 트리거(--trigger=WEIGHT_CHANGE 등)로 실행할 때, 프로세스가 자발적으로 종료되지 않아 타임아웃이나 리소스 누수 발생.
- 수동 실행 후 RankingScheduler의 스케줄이 계속 동작하면서, 동일 Job에 대해 중복 실행 시도.
- application.yml에 graceful shutdown 설정이 없어 JVM 종료 시 실행 중인 Job이 강제 중단될 수 있음.
수정 방향:
(1) CommerceBatchApplication에서 @EnableScheduling을 별도 프로파일(예: @ConditionalOnProperty(name = "app.batch.mode", havingValue = "daemon"))로 조건부 활성화하거나, RankingManualRunner에 trigger 옵션 감지 시 @EnableScheduling을 비활성화하는 구조로 변경.
(2) RankingManualRunner의 run() 메서드에서 job 완료 후 명시적으로 System.exit(exitCode) 호출 또는 applicationContext.close()를 통해 프로세스 종료.
(3) application.yml에 graceful shutdown 타임아웃 설정 추가 (예: spring.lifecycle.timeout-per-shutdown-phase: 60s).
추가 테스트:
- 수동 트리거 프로파일에서 스케줄러가 비활성화되는지 확인하는 통합 테스트.
- Job 실행 완료 후 프로세스가 정상 종료되는지 검증하는 시나리오 테스트.
- 데몬 모드에서 SIGTERM 수신 시 실행 중인 Job이 graceful하게 중단되는지 확인하는 테스트.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java`
around lines 22 - 24, Make `@EnableScheduling` conditional and ensure manual runs
exit: move or guard `@EnableScheduling` behind a property (e.g., use a config
class annotated with `@EnableScheduling` and
`@ConditionalOnProperty`(name="app.batch.mode", havingValue="daemon")) so
scheduling is only active in daemon mode instead of always on in
CommerceBatchApplication; in RankingManualRunner.run(...) detect manual trigger
flags and after the job completes call applicationContext.close() or
System.exit(exitCode) to terminate the process explicitly; also add a graceful
shutdown timeout to application.yml
(spring.lifecycle.timeout-per-shutdown-phase: 60s) and add integration tests
that verify scheduler is disabled in manual mode and that the process exits
after a manual run.
| @Column(name = "view_count", nullable = false) | ||
| private int viewCount; | ||
|
|
||
| @Column(name = "like_count", nullable = false) | ||
| private int likeCount; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd -t f 'RankingMetrics*.java' apps/commerce-streamer | xargs -r rg -n -C2 'view_count|like_count|viewCount|likeCount'
rg -n -C2 'ranking_metrics' --type sql || trueRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 546
🏁 Script executed:
#!/bin/bash
# Search for RankingAggregateRow definition
rg -n 'RankingAggregateRow' --type java -A 5
echo "---"
# Search for ranking_metrics in SQL files
rg -n 'ranking_metrics' --type sql
echo "---"
# Search for view_count/like_count in SQL or query files
rg -n 'view_count|like_count' --type sql
echo "---"
# Look for test files related to RankingMetrics
fd -t f 'RankingMetrics.*Test' --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 18778
🏁 Script executed:
#!/bin/bash
# Look at RankingAggregateRow full definition
cat apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingAggregateRow.java
echo "---"
# Search for SQL queries with SUM view_count or like_count
find apps/commerce-batch -name "*.sql" -o -name "*.xml" | xargs grep -l "view_count\|like_count" 2>/dev/null || true
echo "---"
# Look for test cases related to RankingMetrics
fd -i 'rankingmetrics.*test' apps/commerce-batch
echo "---"
# Search for `@ColumnDefinition` or schema info related to ranking_metrics
rg -n 'ranking_metrics' -A 5 -B 5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 29772
🏁 Script executed:
#!/bin/bash
# Get full RankingMetrics definition from commerce-streamer
cat apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingMetrics.java | head -100
echo "---"
# Check if there are any test cases covering Integer.MAX_VALUE or overflow scenarios
rg -n 'Integer.MAX_VALUE|overflow|BIGINT' apps/commerce-batch --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2487
🏁 Script executed:
#!/bin/bash
# Look for database migration files or schema definitions
find . -name "*.sql" -o -name "*schema*" -o -name "*migration*" | grep -v node_modules | head -20
echo "---"
# Check streamer RankingMetrics entity to see field types
rg -n '@Column.*view_count|@Column.*like_count' apps/commerce-streamer --type java -B 1 -A 1
echo "---"
# See if there are any integration tests with large data values
rg -n 'insertMetrics|viewCount|likeCount' apps/commerce-batch/src/test --type java -A 2 | head -80Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 7689
🏁 Script executed:
#!/bin/bash
# Compare field definitions between batch and streamer RankingMetrics
echo "=== Batch RankingMetrics viewCount/likeCount ==="
rg -A 1 'private int (viewCount|likeCount)' apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.java
echo ""
echo "=== Streamer RankingMetrics viewCount/likeCount ==="
rg -A 1 'private int (viewCount|likeCount)' apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingMetrics.java
echo ""
echo "=== Test data values used (no overflow testing) ==="
rg 'insertMetrics.*\b[0-9]{5,}' apps/commerce-batch/src/test --type java | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 357
RankingAggregateRow 의 long 타입과 RankingMetrics 의 int 타입 간 불일치를 개선한다.
RankingAggregateRow 는 reader 에서 rs.getLong("view_count") 로 읽으므로 long viewCount, long likeCount 로 정의되어 있으나, 실제 데이터를 저장하는 양쪽 RankingMetrics 엔티티(batch, streamer 모두)는 int viewCount, int likeCount 로 정의되어 있다. SUM(view_count) 는 원본 테이블의 int 누적값이므로 누적 기간이 길어지면 Integer.MAX_VALUE 를 초과할 수 있으며, 현재 테스트에는 이러한 경계값 케이스가 없다.
RankingMetrics 의 viewCount / likeCount 를 long 으로 변경하여 데이터 누적 오버플로우를 방지하고, 월별 집계 테스트에 1회 이상 Integer.MAX_VALUE 를 초과하는 누적치 시나리오를 추가할 것을 제안한다.
🛠 제안 수정
- `@Column`(name = "view_count", nullable = false)
- private int viewCount;
-
- `@Column`(name = "like_count", nullable = false)
- private int likeCount;
+ `@Column`(name = "view_count", nullable = false)
+ private long viewCount;
+
+ `@Column`(name = "like_count", nullable = false)
+ private long likeCount;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @Column(name = "view_count", nullable = false) | |
| private int viewCount; | |
| @Column(name = "like_count", nullable = false) | |
| private int likeCount; | |
| `@Column`(name = "view_count", nullable = false) | |
| private long viewCount; | |
| `@Column`(name = "like_count", nullable = false) | |
| private long likeCount; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.java`
around lines 55 - 59, RankingMetrics currently uses int viewCount/likeCount
while RankingAggregateRow reads rs.getLong(...); change the fields in the
RankingMetrics entity classes (the batch and streamer variants that declare
viewCount and likeCount) from int to long, keep the `@Column`(name="view_count",
nullable=false) / `@Column`(name="like_count", nullable=false) annotations, update
any getters/setters and constructors referencing viewCount/likeCount to use
long, and adjust persistence/mapping code that assumes int; additionally add or
update the monthly aggregation test to include a scenario where the aggregated
SUM exceeds Integer.MAX_VALUE to verify no overflow occurs.
📌 Summary
ranking_metrics→mv_product_rank_weekly/monthly파이프라인을 구축하고,GET /api/v1/rankings/weekly|monthly엔드포인트를 제공한다.🧭 Context & Decision
문제 정의
선택지와 결정
1. 집계 기간 정의 — Rolling Window vs 캘린더 고정
일/1말일)최종 결정: B (Rolling Window)
트레이드오프: "이번 주"라는 표현이 엄밀하게는 "최근 7일"임. 하지만 실무 e-commerce 사례 조사 결과 Netflix/Melon 등도 동적 윈도우를 쓰는 게 일반적이었고,
[today-7, today-1]로 고정하면 같은snapshotDate파라미터로 언제 재실행해도 소스가 불변 → 멱등성 문제도 동시에 해소됨.2. 소스 테이블 전략 — hourly 직접 집계 vs daily 중간 테이블
최종 결정: A 먼저, 성능 측정 후 B 도입
트레이드오프: 2단계 확장 시
daily → weekly/monthly집계 구조가 훨씬 효율적이지만, 현재 학습 규모에서는 hourly 직접 집계로도 충분. 측정 없이 미리 최적화하는 YAGNI 위반을 피함.3. 가중치 관리 — 하드코딩 vs DB SOT
WEIGHT_CHANGE재집계 시최종 결정: D (DB SOT —
ranking_weight테이블)배치는 매일 1회 실행 → 신속성(캐시 조회 속도)보다 정확성(SOT 일치)이 우선. DB 직접 조회 1회 비용(ms)은 전체 배치 시간 대비 무시 가능.
trigger=WEIGHT_CHANGE로 수동 재실행 시 이전 캐시값이 그대로 사용될 위험이 C안의 결정적 단점.점수 공식: 단순 선형 합산 대신
LOG(1 + x)변환 적용. 매출이 극단적으로 큰 상품이 랭킹을 독점하는 롱테일 왜곡 완화,+1은 0값의LOG(0) = -∞방지.4. MV 저장 범위 + rank 컬럼
최종 결정: B (TOP 100 + rank 저장)
결정적 논리: TOP 100만 저장 시 가중치 변경 → 101위 이하 데이터가 없어 어차피 원천 재집계 필수 → 재집계 과정에서 rank도 자연 재계산 → rank 저장의 추가 비용 사실상 0. "가중치 유연성을 훼손하지 않는다."
5. ItemReader 방식 — JdbcCursorItemReader vs JdbcPagingItemReader
배치 Job이 집계 소스(
ranking_metrics)에서 TOP 100을 읽어오는 방법을 결정해야 했다. 대규모 데이터 처리에서 일반적으로 거론되는 두 방식을 검토했다.최종 결정: B (JdbcCursorItemReader + Chunk 50)
score는
GROUP BY+LOG(1 + SUM(x))의 파생값이라 안정적인 Keyset 키가 없다. 또한 LIMIT 100 규모에서는 OFFSET 성능 문제도 없어 Paging 방식의 이점이 없다. Chunk 50으로 설정한 것은 학습 목적 — 100건을 2청크로 처리하면서 Chunk-Oriented Processing의 트랜잭션 경계를 직접 관찰하기 위함이다.6. ItemWriter 방식 — JpaItemWriter vs 커스텀 Writer
MV 테이블에 데이터를 쓸 때 어떤 Writer 구현을 쓸지 결정해야 했다. 기존 프로젝트의 JPA 관행을 그대로 쓰고 싶었지만, 배치의 요구사항(UPSERT 멱등성)과 충돌이 있었다.
merge()가 내부적으로 SELECT 발사 → 청크 50건에 SELECT 50회 추가 왕복PersistenceExceptionRepository.upsertAll()위임최종 결정: 커스텀
ItemWriter+Repository.upsertAll()위임트레이드오프: Spring Batch 표준 Writer를 쓰지 않아 프레임워크 지원을 일부 잃음. 하지만 JpaItemWriter의 merge 모드는 SELECT N회라는 숨겨진 비용이 있고, UPSERT는 JPA가 자연스럽게 지원하지 않는 연산이다. SQL을 infrastructure 레이어에 모으는 프로젝트 관행을 유지하는 것이 더 중요하다고 판단했다.
7. 배치 멱등성 — 2-Layer 방어
같은 날 배치를 두 번 실행하거나 가중치 변경 후 재집계할 때 데이터 정합성을 보장하는 방법을 설계했다.
Layer 1 — Spring Batch JobRepository
snapshotDate + trigger조합이 같으면 이미 완료된 JobInstance의 재실행을 프레임워크가 차단 (JobInstanceAlreadyCompleteException). 정상 스케줄 실행의 중복을 막는 첫 번째 방어선.Layer 2 — Writer UPSERT
ON DUPLICATE KEY UPDATE로 같은(snapshot_date, product_id)가 들어오면 덮어쓰기. Layer 1을 통과한 의도적 재실행(가중치 변경)에서 데이터 정합성을 보장하는 두 번째 방어선.trigger enum 도입 이유: 재실행 유형을
SCHEDULED / WEIGHT_CHANGE / DATA_FIX / MANUAL_RERUN으로 구분해BATCH_JOB_EXECUTION_PARAMS에 이력을 남긴다. "왜 같은 날 두 번 실행했는가"를 DB에서 바로 확인 가능.8. 배치 실행 주체 — @scheduled vs 외부 크론
배치 Job을 어디서 트리거할지 결정해야 했다. 실무 관점과 현재 프로젝트 맥락을 구분해 선택했다.
최종 결정: @scheduled (단일 인스턴스 가정)
현재 인프라 셋업 비용 없이 빠르게 구현.
weekly 01:00 KST / monthly 01:30 KST로 30분 간격 분산해 DB/로그 관찰성 확보. 단, 다중 인스턴스 환경으로 확장 시 ShedLock 또는 외부 크론으로의 전환이 필수인 기술부채임을 인지하고 있다.9. latest_date 캐시 — 왜 필요한가, 어떻게 동기화하는가
배경 — 이 캐시가 왜 필요한가
daily 랭킹은 Redis ZSET에 실시간으로 점수가 쌓이므로 "오늘 랭킹"이 항상 존재한다. 반면 weekly/monthly는 배치가 매일 한 번 새
snapshot_date로 MV 테이블을 적재한다. 사용자가 날짜 파라미터 없이GET /rankings/weekly를 요청하면 API는 "가장 최근 배치가 만든 snapshot"을 알아야 한다.가장 단순한 방법은 매 요청마다
SELECT MAX(snapshot_date) FROM mv_product_rank_weekly를 날리는 것인데, 트래픽이 많을 때 이 쿼리가 부담이 된다.latest_date캐시는 배치 완료 직후 Listener가 Redis에 최신 snapshot_date를 써두는 방식으로 이 조회를 대체한다.캐시 키 구조:
rankings:weekly:latest_date→"2026-04-17"(TTL 25h)rankings:monthly:latest_date→"2026-04-17"(TTL 25h)설계 선택지와 결정:
최종 결정: C (JobExecutionListener 기반, 25h TTL)
FAILED/STOPPED 시 put 생략 — 실패한 Job의 snapshotDate를 캐시에 박으면 "존재하지 않는 snapshot 가리킴" 위험. 이전 성공값 유지가 더 안전. 25h TTL은 다음 배치(24h 주기) 이전에 자연 만료되지 않도록 하는 안전망.
구현 중 추가된 방어 로직: Job COMPLETED이지만 적재 데이터 count=0인 경우 캐시 갱신 스킵. 서비스 초기 등 집계 대상 상품이 없는 상태에서 빈 snapshot을 latest로 만들지 않기 위함.
10. 엔티티 모듈 배치 — 공유 모듈 vs 앱별 중복 선언
최종 결정: B (앱별 중복 선언)
같은 테이블을 "다른 역할로 본다"는 점이 이름부터 드러남. Spring Data JPA Repository가 엔티티 타입으로 파라미터화되므로 엔티티 분리 결정이 Repository 분리를 자동으로 강제. ISP(쓰기/읽기 메서드 무관) + 트랜잭션 경계(batch=chunk tx, api=readOnly) 자연 분리 부수효과.
🏗️ Design Overview
변경 범위
commerce-batchcommerce-apiWeeklyRank,MonthlyRank도메인+인프라,/weekly,/monthly엔드포인트commerce-api/RankingFacadefindWeeklyRanking(),findMonthlyRanking()+resolveSnapshotDate()공통 메서드주요 컴포넌트 책임
WeeklyRankReaderConfigranking_metrics→ 집계 쿼리 실행 (Rolling Window, LOG 변환, DB 가중치 동적 조회)WeeklyRankProcessorMvProductRankWeeklyPOJO 생성WeeklyRankWriterRepository.upsertAll()호출 (얇은 어댑터)WeeklyRankingJobConfig@ConditionalOnProperty)RankingLatestDateCacheListenerrankings:{period}:latest_dateRedis putRankingSchedulertrigger=SCHEDULED파라미터로 JobInstance 생성RankingManualRunner--trigger옵션 있을 때만 Job 실행 (수동 재집계 CLI 진입점)WeeklyRankRepository(api)RankingFacade🔁 Flow Diagram
Batch 파이프라인 — 매일 새벽 배치가 MV 테이블을 만들고 캐시를 동기화하는 흐름
flowchart TD A["🕐 매일 KST 01:00\n(RankingScheduler)"] --> B subgraph B["Spring Batch Job"] direction LR R["Reader\n최근 7일 ranking_metrics 집계\nLOG 변환 + DB 가중치 적용\nTOP 100 ORDER BY score"] --> P["Processor\n수신 순서대로 rank 부여"] P --> W["Writer\nUPSERT ALL\n(ON DUPLICATE KEY UPDATE)"] end B --> C["mv_product_rank_weekly 적재 완료"] C --> D{"Job COMPLETED\n count > 0?"} D -- Yes --> E["Redis\nrankings:weekly:latest_date = '2026-04-17'\nTTL 25h"] D -- No\n(실패 또는 데이터 없음) --> F["캐시 갱신 스킵\n이전 성공값 유지"]API 조회 흐름 — 두 단계 캐시를 거쳐 응답을 조립하는 흐름
flowchart TD A["GET /api/v1/rankings/weekly\n(date 파라미터 없음)"] --> B subgraph B["snapshot 날짜 결정"] B1{"Redis\nrankings:weekly:latest_date\n캐시 HIT?"} B1 -- HIT --> B2["Redis에서 읽은 날짜 사용\n예: 2026-04-17"] B1 -- MISS --> B3["DB MAX(snapshot_date) 조회\n→ Redis에 다시 put (25h TTL)"] end B2 & B3 --> C subgraph C["랭킹 데이터 조회"] C1{"Redis\nrankings:weekly:2026-04-17:0:20\n메인 캐시 HIT?"} C1 -- HIT --> C2["직렬화된 결과 즉시 반환"] C1 -- MISS --> C3["DB에서 Page 조회\n→ 상품/브랜드 JOIN\n→ Redis에 캐시 put (24h TTL)"] end C2 & C3 --> D["200 OK\n랭킹 목록 반환"]멱등성 2-Layer — 중복 실행 방어 흐름
flowchart TD A["배치 실행 시도\n(자동 스케줄 또는 수동 CLI)"] --> B subgraph B["Layer 1: Spring Batch JobRepository"] B1{"같은 snapshotDate + trigger로\n이미 COMPLETED된 JobInstance 있음?"} B1 -- Yes --> B2["JobInstanceAlreadyCompleteException\n중복 실행 차단"] B1 -- No --> B3["새 JobInstance 생성\n배치 실행 시작"] end B3 --> C subgraph C["Layer 2: Writer UPSERT"] C1["INSERT INTO mv_product_rank_weekly\nON DUPLICATE KEY UPDATE\n(snapshot_date, product_id) 기준"] C1 --> C2["중복 행 → 값 덮어쓰기\n첫 행 → 새로 INSERT\ncreated_at은 최초 시각 유지"] end수동 재집계 흐름 — 가중치 변경 후 재집계
flowchart LR A["운영자\n가중치 DB 변경"] --> B B["./gradlew bootRun\n--trigger=WEIGHT_CHANGE\n--snapshotDate=2026-04-17"] --> C["RankingManualRunner\ntrigger 옵션 확인"] C --> D{"(snapshotDate, trigger) 조합\n이미 실행된 적 있음?"} D -- Yes\nJobInstanceAlreadyCompleteException --> E["⚠ 같은 trigger 재실행 불가\n다른 trigger 값 사용 필요\n(기술부채)"] D -- No --> F["새 JobInstance\n→ 최신 가중치로 재집계\n→ MV UPSERT → 캐시 갱신"]🔑 핵심 코드 변경
1. DB 가중치 동적 조회 + LOG 변환 점수 공식
apps/commerce-batch/.../weekly/WeeklyRankReaderConfig.javatrigger=WEIGHT_CHANGE재집계 시 항상 최신 가중치 사용 보장. 가중치는BigDecimal타입으로 DB에서 제어된 값이라 SQL 인젝션 위험 없음.2. count=0 방어 로직이 있는 캐시 Listener
apps/commerce-batch/.../ranking/RankingLatestDateCacheListener.java계획서에 없던 추가 방어. 서비스 초기 등 집계 대상 상품이 없으면 빈 snapshot을 latest로 만들지 않아 이전 성공 snapshot을 유지한다.
3. trigger 명시로 운영 이력 분리
apps/commerce-batch/.../ranking/RankingScheduler.javaRankingManualRunner는--trigger옵션이 없으면 아무것도 하지 않아 스케줄러 데몬 모드와 충돌 없음.4. resolveSnapshotDate — latest_date 캐시 Miss 폴백
apps/commerce-api/.../application/ranking/RankingFacade.javaweekly/monthly에서 공통 사용.
Supplier<Optional<LocalDate>>로 DB 폴백을 주입받아 테스트에서 Mock으로 교체 용이.📋 기술부채 (인지 중)
log.error()만 있고 Slack Appender 미연결 (supports/logging/logback-spring.xml수정 필요)@Scheduled+ 단일 인스턴스 가정, ShedLock 미적용(snapshotDate, trigger)재실행 불가ranking_metrics_dailySOT 성능 실측 미완료변경 목적
Spring Batch 기반 주간/월간 랭킹 배치 파이프라인 구축 및 API 엔드포인트 노출.
ranking_metrics테이블의 실시간 메트릭을 롤링윈도우(주간 7일, 월간 30일)로 집계하여 미리 계산된 랭킹 데이터(mv_product_rank_weekly,mv_product_rank_monthly)를 제공하고, 캐시 기반 조회로 응답 성능을 향상.핵심 변경점
배치 로직: JdbcCursorItemReader로
ranking_metrics에서 메트릭을 읽고, LOG(1+x) 기반 가중치식으로 점수 계산 (score = LOG(1+views)×w_view + LOG(1+likes)×w_like + LOG(1+revenue)×w_order). 가중치는 런타임에ranking_weight테이블에서 조회(DB SOT). Processor에서 상위 100개 상품만 순위 부여 후 Writer가 UPSERT로 저장(Chunk size 50).API 측: Weekly/Monthly RankingRepository 인터페이스 및 JPA 구현 추가. RankingFacade가 Redis
latest_date캐시 → DB fallback으로 스냅샷 날짜 해석하고, 캐시-어사이드 패턴으로 조회 결과 저장./api/v1/rankings/weekly,/api/v1/rankings/monthly엔드포인트 추가.스케줄링 & 수동 실행: RankingScheduler가 매일 01:00/01:30 KST에 자동 실행(@EnableScheduling). RankingManualRunner가
--triggerCLI 옵션으로 수동 실행 지원(SCHEDULED/WEIGHT_CHANGE/DATA_FIX/MANUAL_RERUN). RankingLatestDateCacheListener가 배치 완료 시 Redislatest_date업데이트(TTL 25h, 로드된 레코드 > 0일 때만).리스크/주의사항
배치 작업이 매일 두 번 실행되므로
ranking_metrics테이블 크기 및 집계 성능 모니터링 필요. 스냅샷 날짜 기반 쿼리이므로 시간대 변환(KST) 정확성 확인 필수. DB 스키마(관계 테이블 DDL)가 제공되지 않아ranking_weight테이블 구조/초기값 검증이 필요한가요?테스트/검증
리포지토리 UPSERT 테스트(삽입, 중복 키 업데이트, createdAt 보존 검증), 배치 통합 테스트(롤링윈도우 경계 조건, TOP-100 필터링, 멱등성, 수동 재실행), 파사드 통합 테스트(Redis 캐시 히트/미스, DB fallback), E2E API 테스트(weekly/monthly 엔드포인트, 페이지네이션, 빈 스냅샷 처리).