Skip to content

[volume-9] Redis ZSET 기반 랭킹 시스템 구현 - 박시온#374

Merged
hey-sion merged 6 commits intoLoopers-dev-lab:hey-sionfrom
hey-sion:round9-ranking-v2
Apr 13, 2026
Merged

[volume-9] Redis ZSET 기반 랭킹 시스템 구현 - 박시온#374
hey-sion merged 6 commits intoLoopers-dev-lab:hey-sionfrom
hey-sion:round9-ranking-v2

Conversation

@hey-sion
Copy link
Copy Markdown

@hey-sion hey-sion commented Apr 10, 2026

Round 9 - Redis ZSET 기반 실시간 랭킹 시스템


📌 Summary

  • 배경: Round 7에서 Kafka → commerce-streamer 파이프라인으로 유저 행동 이벤트(조회, 좋아요, 주문)를 수집하고 product_metrics에 집계하는 구조를 구축했습니다. Round 8에서는 Redis Sorted Set을 대기열 순서 관리에 활용했습니다. 이번 라운드에서는 이 둘을 결합하여, 이벤트 기반 실시간 랭킹 시스템을 구축합니다.
  • 목표: Kafka Consumer가 소비하는 이벤트를 기반으로 Redis ZSET에 가중치 점수를 실시간 갱신하고, API는 ZSET을 조회하여 Top-N 랭킹과 개별 상품 순위를 제공합니다.
  • 결과: 이벤트 발행 → Kafka 소비 → ZINCRBY 점수 갱신 → 랭킹 API 조회의 전체 파이프라인을 구현했습니다. 일별 키 전략(ranking:all:{yyyyMMdd})과 TTL 2일로 시간 윈도우를 관리하며, 가중치(view=0.1, like=0.2, order=0.7)를 설정값으로 외부화하여 운영 중 조절 가능합니다.

🧭 Context & Decision

① 왜 DB ORDER BY가 아닌 Redis ZSET인가

랭킹 API는 홈 메인, 인기 상품 목록 등에서 높은 빈도로 호출됩니다. DB GROUP BY + ORDER BY는 데이터가 쌓일수록 느려지고, 조회 빈도가 높아 DB 과부하로 이어질 수 있습니다.

방법 장점 단점
DB ORDER BY 정합성 높음 느림, 부하 높음
Redis ZSET 정렬 내장, O(log N) 삽입/수정, 다양한 조회 메모리 사용

ZSET은 (member, score) 쌍을 score 기준으로 정렬된 상태로 유지하므로, Top-N 조회(ZREVRANGE), 개별 순위 조회(ZREVRANK), 점수 갱신(ZINCRBY) 모두 효율적입니다.


② 점수 반영 방식 — ZINCRBY (증분 갱신)

ZADD로 전체 재계산하는 대신, 이벤트가 발생할 때마다 ZINCRBY로 해당 가중치만큼 증분합니다.

  • 장점: 이벤트 한 건당 Redis 연산 한 번으로 끝남. 별도 집계 쿼리 불필요
  • 가중치: @Value로 외부화하여 운영 중 변경 가능
// RankingScoreUpdater.java
public void incrementView(Long productId) { incrementScore(productId, viewWeight); }    // 0.1
public void incrementLike(Long productId) { incrementScore(productId, likeWeight); }    // 0.2
public void incrementOrder(Long productId, long price, int quantity) {
    incrementScore(productId, orderWeight * price * quantity);                           // 0.7 × 결제금액
}

주문의 경우 price × quantity × weight로 계산하여, 실제 결제 금액이 랭킹에 반영됩니다. 이를 위해 PaymentCompletedEventPayload에 상품별 price/quantity를 추가했습니다.


③ 상품 정보 조합 — 기존 캐시 활용

ZSET에는 productId만 저장하고, API 응답 시 productFacade.getActiveProduct()로 상품 정보를 조합합니다. 상품 상세는 이미 @Cacheable로 캐시되어 있어 추가 DB 조회가 거의 발생하지 않습니다.

삭제/비활성 상품 방어: 랭킹은 비정규화 데이터이므로 stale entry가 존재할 수 있습니다. getActiveProduct() 실패 시 해당 항목을 skip하고 나머지만 반환하여, 한 항목의 문제로 전체 API가 깨지지 않도록 처리했습니다.

// RankingFacade.java
private Optional<RankingProductInfo> toRankingProductInfo(RankingEntry entry) {
    try {
        ProductInfo product = productFacade.getActiveProduct(entry.productId());
        return Optional.of(RankingProductInfo.of(product, entry.rank() + 1, entry.score()));
    } catch (CoreException e) {
        log.warn("랭킹 조회 중 상품 조회 실패 [productId={}]: {}", entry.productId(), e.getMessage());
        return Optional.empty();
    }
}

④ EventHandler 패턴 활용

기존 EventProcessingServicesupports() 매칭되는 모든 핸들러를 실행하므로, 랭킹 전용 핸들러를 추가하면 기존 metrics 핸들러와 함께 자동으로 동작합니다. Consumer를 새로 만들 필요 없이 핸들러만 등록하면 됩니다.

핸들러 이벤트 동작
RankingViewEventHandler PRODUCT_VIEWED ZINCRBY +0.1
RankingLikeEventHandler PRODUCT_LIKED ZINCRBY +0.2
RankingUnlikeEventHandler PRODUCT_UNLIKED ZINCRBY -0.2
RankingPaymentEventHandler PAYMENT_COMPLETED ZINCRBY +(0.7 × price × quantity)

🔁 Flow Diagram

전체 흐름

[commerce-api]
     → 유저 행동 이벤트 발행 (조회, 좋아요, 주문)
     → Kafka

[commerce-streamer]
     → 이벤트 소비
     → product_metrics upsert (기존 R7)
     → Redis ZSET 랭킹 점수 갱신 (R9 신규)

[commerce-api]
     → GET /api/v1/rankings        → ZREVRANGE (Top-N)
     → GET /api/v1/products/{id}   → ZREVRANK + ZSCORE (개별 순위)

이벤트 → ZSET 갱신 → API 조회

sequenceDiagram
    participant U as User
    participant API as commerce-api
    participant K as Kafka
    participant S as commerce-streamer
    participant R as Redis (ZSET)

    U->>API: 상품 조회/좋아요/주문
    API->>K: 이벤트 발행

    K->>S: 이벤트 소비
    S->>R: ZINCRBY ranking:all:{yyyyMMdd} {score} {productId}

    U->>API: GET /api/v1/rankings
    API->>R: ZREVRANGE ranking:all:{yyyyMMdd} 0 N WITHSCORES
    R-->>API: [(productId, score), ...]
    API-->>U: 랭킹 + 상품 정보 응답
Loading

🧪 Verification

단위 테스트

  • RankingKeyGeneratorTest — 날짜별 키 생성 검증
  • RankingScoreUpdaterTest — 이벤트 타입별 ZINCRBY 동작, 점수 누적, TTL 설정 검증
  • RankingFacadeTest — 랭킹 페이지 조회(점수 순), 빈 랭킹 처리, 개별 상품 순위(1-based), 미등록 상품 null 반환, 삭제 상품 skip 처리

통합 테스트

  • RedisRankingRepositoryTest@SpringBootTest + TestContainers로 Redis ZSET 실제 동작 검증
    • Top-N 점수 순 정렬, offset 기반 페이지네이션, 개별 순위/점수 조회, 빈 키 처리

E2E 테스트

  • RankingV1ApiE2ETest@SpringBootTest(RANDOM_PORT) + TestContainers
    • 랭킹 페이지 조회 (점수 높은 순 반환)
    • 점수 크기에 따른 순위 정렬 검증
    • 이전 날짜 랭킹 조회 (date 파라미터)
    • 빈 랭킹 시 빈 리스트 반환
    • 페이지네이션 동작
    • 상품 상세 조회 시 랭킹 정보(rank, score) 포함
    • 랭킹 미등록 상품은 ranking null 반환
    • 삭제된 상품이 랭킹에 남아있어도 skip 후 정상 응답

⏭ 미구현 / 향후 과제

항목 내용
콜드 스타트 완화 매일 23:50에 ZUNIONSTORE × 0.1로 전날 점수 일부 복사
Kafka 배치 최적화 같은 상품 이벤트를 애플리케이션 레벨에서 합산 후 ZINCRBY 1회 처리
파라미터 검증 page/size 경계값(0, 음수 등) validation 추가
주간/월간 집계 일간 데이터를 기반으로 주간/월간 ZUNIONSTORE 집계

✅ Checklist

📈 Ranking Consumer

  • 랭킹 ZSET의 TTL, 키 전략을 적절하게 구성하였다
  • 날짜별로 적재할 키를 계산하는 기능을 만들었다
  • 이벤트가 발생한 후, ZSET에 점수가 적절하게 반영된다

⚾ Ranking API

  • 랭킹 Page 조회 시 정상적으로 랭킹 정보가 반환된다
  • 랭킹 Page 조회 시 단순히 상품 ID가 아닌 상품정보가 Aggregation 되어 제공된다
  • 상품 상세 조회 시 해당 상품의 순위가 함께 반환된다 (순위에 없다면 null)

🧪 검증

  • 이벤트 발행 → ZSET 점수 반영 → API 조회까지 E2E 흐름이 정상 동작하는지 확인
  • 일자가 변경되어도 이전 날짜의 랭킹 조회가 정상적으로 동작하는지 확인
  • 가중치 적용이 의도대로 랭킹 순서에 반영되는지 확인

✅ 변경 범위

새로 생성한 파일

모듈 파일 역할
event-contract payload/OrderedProduct.java 상품별 결제 금액 VO
event-contract ranking/RankingKeyGenerator.java 일별 키 생성 (공통)
commerce-streamer domain/ranking/RankingScoreUpdater.java ZINCRBY 점수 갱신
commerce-streamer handler/ranking/RankingViewEventHandler.java 조회 → 랭킹 반영
commerce-streamer handler/ranking/RankingLikeEventHandler.java 좋아요 → 랭킹 반영
commerce-streamer handler/ranking/RankingUnlikeEventHandler.java 좋아요 취소 → 감점
commerce-streamer handler/ranking/RankingPaymentEventHandler.java 결제 → 랭킹 반영
commerce-streamer config/TimeConfig.java Clock 빈 등록
commerce-api domain/ranking/RankingRepository.java 랭킹 조회 인터페이스
commerce-api domain/ranking/RankingEntry.java 랭킹 항목 record
commerce-api domain/ranking/RankingInfo.java 상품별 랭킹 정보 record
commerce-api infrastructure/ranking/RedisRankingRepository.java Redis 구현체
commerce-api application/ranking/RankingFacade.java 랭킹 + 상품 정보 조합
commerce-api application/ranking/RankingPageResult.java 페이지 결과 record
commerce-api application/ranking/RankingProductInfo.java 랭킹 상품 정보 record
commerce-api interfaces/api/ranking/RankingV1Controller.java 랭킹 REST API
commerce-api interfaces/api/ranking/dto/RankingV1Dto.java 요청/응답 DTO
commerce-api config/TimeConfig.java Clock 빈 등록

수정한 파일

파일 변경 내용
PaymentCompletedEventPayload orderedProducts 필드 추가 (상품별 결제 금액)
PaymentFacade 이벤트 발행 시 OrderedProduct 매핑 추가
ProductV1Controller 상품 상세에 랭킹 정보 추가
ProductV1Dto RankingSummary 필드 추가
WebConfig /api/v1/rankings 인증 인터셉터 제외

Redis ZSET 기반 실시간 랭킹 시스템 구현

변경 목적: Kafka 이벤트(상품 조회, 좋아요/취소, 결제 완료)로부터 실시간으로 Redis 정렬 집합(ZSET)을 갱신하여 일일 랭킹을 계산하고, API를 통해 상품별/페이지별 랭킹 조회 기능 제공.

핵심 변경점:

  • commerce-streamer에서 RankingScoreUpdater(조회 0.1, 좋아요 0.2, 주문 0.7 가중치), 4개 이벤트 핸들러 추가로 ZSET 점수 갱신
  • PaymentCompletedEventPayload에 orderedProducts 필드 추가하여 가격·수량 정보 전달
  • commerce-api에서 RankingRepository(Redis 기반 구현) 및 RankingFacade로 랭킹 조회 제공
  • RankingV1Controller(/api/v1/rankings), ProductV1Controller 제품 상세에 랭킹 정보 추가
  • RankingKeyGenerator를 event-contract 모듈로 이동(공유화)

리스크/주의사항:

  • 삭제된/비활성 상품이 Redis 랭킹에 남을 수 있으므로, ProductFacade.getActiveProduct() 호출 시 예외 발생 시 해당 항목을 필터링하는 방식으로 방어 중 (빈 응답 위험은 없으나 불완전한 순위 반영 가능성 확인 필요)
  • 일일 ZSET의 TTL이 2일로 고정되어 있는데, 콜드스타트 시 전날 데이터와의 연계 전략이 문서화되지 않음 (ZUNIONSTORE 병합 계획이 future work에 언급됨)

테스트/검증 방법:

  • Unit 테스트(InMemoryRankingRepository), Redis 통합 테스트(TestContainers), E2E 테스트(실제 DB/Redis)로 이벤트→점수 갱신→조회 전체 흐름 검증
  • 페이지네이션, 빈 랭킹, 삭제된 상품 필터링, 개별 상품 순위 조회 등 6가지 시나리오 커버 확인됨

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 10, 2026

📝 Walkthrough

Walkthrough

Redis ZSET 기반 상품 순위 시스템을 신규 구현하고, 결제 완료 이벤트에 주문 상품 정보를 추가한다. 상품 조회/좋아요/결제 이벤트를 통해 순위 점수를 동적으로 업데이트하며, 순위 조회 API를 제공한다.

Changes

Cohort / File(s) Summary
Configuration & Time Management
apps/commerce-api/src/main/java/com/loopers/config/TimeConfig.java, apps/commerce-streamer/src/main/java/com/loopers/config/TimeConfig.java
두 애플리케이션에 Clock 빈을 등록하여 테스트 환경에서 시간 주입 가능하게 한다.
Domain Layer - Ranking
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingEntry.java, RankingInfo.java, RankingRepository.java, apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScoreUpdater.java
순위 데이터 구조(RankingEntry, RankingInfo), 저장소 계약(RankingRepository), 점수 업데이트 로직을 정의한다. RankingScoreUpdater는 Redis ZSET에 가중치 기반 점수를 누적한다.
Infrastructure - Ranking Repository
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RedisRankingRepository.java
Redis ZSET을 활용한 RankingRepository 구현체로, 순위 조회 및 점수 추출을 수행한다.
Application Layer - Ranking
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java, RankingPageResult.java, RankingProductInfo.java
순위 조회 비즈니스 로직을 담당하며, 상품 정보와 순위 데이터를 결합하여 응답 DTO를 생성한다.
API Layer - Ranking
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java, RankingV1Dto.java
/api/v1/rankings 엔드포인트를 통해 페이지네이션된 순위 목록 조회를 제공한다.
API Layer - Product
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java, dto/ProductV1Dto.java
상품 상세 조회 응답에 순위 정보를 추가하여, 상품과 함께 순위를 반환한다.
API Configuration
apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java
순위 조회 엔드포인트를 로그인 인터셉터 제외 대상에 추가하여 인증 없이 접근 가능하게 한다.
Event Handlers - Ranking
apps/commerce-streamer/src/main/java/com/loopers/application/handler/ranking/RankingViewEventHandler.java, RankingLikeEventHandler.java, RankingUnlikeEventHandler.java, RankingPaymentEventHandler.java
상품 조회/좋아요/결제 이벤트를 수신하여 RankingScoreUpdater를 통해 Redis 점수를 업데이트한다.
Event Contract
modules/event-contract/src/main/java/com/loopers/event/payload/OrderedProduct.java, PaymentCompletedEventPayload.java, modules/event-contract/src/main/java/com/loopers/event/ranking/RankingKeyGenerator.java
주문 상품 정보 모델을 추가하고, 결제 완료 이벤트 페이로드를 확장한다. 순위 Redis 키 생성 유틸리티를 제공한다.
Payment Application
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
결제 완료 이벤트 발행 시 상품 목록뿐 아니라 OrderedProduct 객체를 함께 포함하여 이벤트 구성을 풍부하게 한다.
Domain Test Utilities
apps/commerce-api/src/test/java/com/loopers/domain/ranking/InMemoryRankingRepository.java
테스트 환경에서 메모리 기반 순위 저장소 구현체를 제공한다.
Unit Tests - Application
apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java, apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java
RankingFacade의 페이지네이션, 삭제된 상품 필터링, 개별 상품 순위 조회를 검증한다. PaymentFacade 테스트를 확장하여 주문 상품 포함 이벤트 발행을 검증한다.
Unit Tests - Infrastructure
apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RedisRankingRepositoryTest.java
Redis ZSET 기반 순위 저장소의 조회, 순위 계산, 점수 추출을 검증한다.
Unit Tests - Domain
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingKeyGeneratorTest.java, RankingScoreUpdaterTest.java
순위 키 생성과 가중치 기반 점수 누적, TTL 설정을 검증한다.
E2E Integration Test
apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java
순위 API와 상품 조회 API의 통합 동작을 검증한다. Redis 랭킹 데이터, 데이터베이스 상품 정보, 삭제된 상품 필터링을 포함한 엔드-투-엔드 시나리오를 다룬다.
VCS Configuration
.gitignore
Claude 로컬 설정 파일을 버전 관리에서 제외한다.

Sequence Diagram

sequenceDiagram
    participant Client
    participant RankingV1Controller
    participant RankingFacade
    participant RankingRepository
    participant Redis as Redis ZSET
    participant ProductFacade
    
    Client->>RankingV1Controller: GET /api/v1/rankings?date=20250409&page=1&size=20
    RankingV1Controller->>RankingFacade: getRankings(LocalDate, page, size)
    RankingFacade->>RankingRepository: getTopRankings(key, offset, size)
    RankingRepository->>Redis: ZREVRANGE key offset+size WITHSCORES
    Redis-->>RankingRepository: List<RankingEntry>
    RankingRepository-->>RankingFacade: List<RankingEntry>
    
    loop for each RankingEntry
        RankingFacade->>ProductFacade: getActiveProduct(productId)
        alt Product exists
            ProductFacade-->>RankingFacade: ProductInfo
            RankingFacade->>RankingFacade: map to RankingProductInfo
        else Product not found
            ProductFacade-->>RankingFacade: CoreException
            RankingFacade->>RankingFacade: skip entry
        end
    end
    
    RankingFacade-->>RankingV1Controller: RankingPageResult
    RankingV1Controller-->>Client: ApiResponse<List<RankingResponse>>
Loading
sequenceDiagram
    participant EventSource
    participant EventHandler as Ranking EventHandler
    participant RankingScoreUpdater
    participant Redis as Redis ZSET
    
    EventSource->>EventHandler: Event<ProductViewedEventPayload>
    EventHandler->>RankingScoreUpdater: incrementView(productId)
    RankingScoreUpdater->>Redis: ZINCRBY key 0.1 productId
    Redis-->>RankingScoreUpdater: new score
    RankingScoreUpdater->>Redis: EXPIRE key 172800
    
    EventSource->>EventHandler: Event<ProductLikedEventPayload>
    EventHandler->>RankingScoreUpdater: incrementLike(productId)
    RankingScoreUpdater->>Redis: ZINCRBY key 0.2 productId
    
    EventSource->>EventHandler: Event<PaymentCompletedEventPayload>
    EventHandler->>RankingScoreUpdater: incrementOrder(productId, price, quantity)
    RankingScoreUpdater->>Redis: ZINCRBY key (0.7 * price * quantity) productId
    Redis-->>RankingScoreUpdater: new score
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 PR의 핵심 변경사항인 'Redis ZSET 기반 랭킹 시스템 구현'을 명확하게 설명하고 있다.
Description check ✅ Passed PR 설명은 템플릿의 모든 필수 섹션(Summary, Context & Decision, Flow Diagram, Verification, Checklist)을 포함하고 있으며, 기술적 결정과 구현 상세가 체계적으로 기술되어 있다.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

❤️ Share

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

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (13)
apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java (1)

29-30: 인증 제외를 경로 단위로 두면 향후 메서드 추가 시 우회 범위가 커진다.

운영 관점에서 경로 단위 제외는 라우트 확장 시 인증 누락 사고로 이어질 수 있다. 수정안으로 "/api/v1/rankings" 제외를 제거하고, LoginUserInterceptor 내부에서 GET /api/v1/rankings만 예외 처리하도록 메서드 조건을 명시하는 편이 안전하다. 추가 테스트로 인터셉터 통합 테스트에서 GET /api/v1/rankings는 통과하고, 동일 경로의 비-GET 요청은 인증 검사를 타는지 검증하는 것이 좋다.

🤖 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/config/WebConfig.java`
around lines 29 - 30, Remove the path-level exclusion for "/api/v1/rankings" in
WebConfig and instead add an explicit method check inside LoginUserInterceptor
(e.g., in preHandle or equivalent) that only bypasses authentication for GET
requests to "/api/v1/rankings"; ensure other methods (POST/PUT/DELETE) to the
same path still require auth. Update or add an interceptor integration test that
asserts GET /api/v1/rankings is allowed without auth while a non-GET request to
/api/v1/rankings triggers the authentication flow.
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java (1)

5-9: 가변 List 참조 노출로 응답 불변성이 깨질 수 있다.

운영 관점에서 동일 요청 처리 중 리스트가 외부에서 변경되면 응답 값이 흔들려 디버깅 난이도가 올라간다. 수정안으로 compact constructor에서 List.copyOf(items)를 사용해 방어적 복사를 적용하는 것이 좋다. 추가 테스트로 원본 리스트 변경이 RankingPageResult.items()에 반영되지 않는지, 그리고 반환 리스트가 수정 불가인지 검증해야 한다.

🔒 제안 수정안
 public record RankingPageResult(
         List<RankingProductInfo> items,
         int page,
         int size
-) {}
+) {
+    public RankingPageResult {
+        items = List.copyOf(items);
+    }
+}

As per coding guidelines, **/*.java: null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다.

🤖 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/RankingPageResult.java`
around lines 5 - 9, RankingPageResult의 공개 필드 items가 가변 List 참조를 노출해 불변성이 깨지므로
compact constructor를 추가해 null 검사 후 items = List.copyOf(items)로 방어적 복사를 적용하고,
items()가 변경 불가능한 리스트를 반환하도록 하세요; 또한 RankingPageResult 레코드의 compact constructor에서
items가 null일 때 NullPointerException을 던지도록 처리하고, 단위 테스트로 원본 리스트를 수정해도
RankingPageResult.items()에 반영되지 않는지와 반환 리스트가 수정 불가능한지 검증하세요.
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingKeyGeneratorTest.java (1)

13-24: 단일 정상 케이스만으로는 키 포맷 회귀를 막기 어렵다.

운영 관점에서 월/일 0패딩이나 연말 경계 포맷이 깨지면 키가 달라져 랭킹 데이터가 분산될 수 있다. 수정안으로 파라미터라이즈드 테스트를 추가해 2025-01-01, 2025-12-31 같은 경계 날짜를 함께 검증하는 것이 좋다. 추가 테스트로 yyyyMMdd 고정 형식(구분자 없음, 8자리) 자체를 정규식으로 함께 검증하면 회귀 탐지가 쉬워진다.

As per coding guidelines, **/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingKeyGeneratorTest.java`
around lines 13 - 24, Add parameterized boundary tests in
RankingKeyGeneratorTest to cover date edge cases and format validation: create a
`@ParameterizedTest` that calls RankingKeyGenerator.keyOf(...) for dates like
LocalDate.of(2025,1,1), LocalDate.of(2025,12,31) (and the existing 2025-04-09)
and assert each produced key equals the expected string, and also add an
assertion that the returned key matches the fixed yyyyMMdd pattern (e.g.,
matches regex "^ranking:all:\\d{8}$") to catch zero-padding/format regressions;
reference the existing test method keyOf_returnsKeyWithGivenDate and the static
method RankingKeyGenerator.keyOf to locate where to add these checks.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/dto/RankingV1Dto.java (1)

7-24: DTO 구조가 적절하나, score 필드의 부동소수점 정밀도를 고려해야 한다.

RankingProductInfo에서 RankingResponse로의 매핑이 명확하다. 다만 Double score가 JSON 응답으로 직렬화될 때 0.30000000000000004와 같은 부동소수점 표현 오차가 노출될 수 있다. 클라이언트 표시용이라면 무시해도 되지만, 정밀한 값이 필요하다면 반올림 처리나 BigDecimal 사용을 검토해야 한다.

🤖 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/dto/RankingV1Dto.java`
around lines 7 - 24, The DTO exposes score as Double which can reveal
floating-point artifacts when serialized; update the RankingResponse.record to
either use BigDecimal for score or round the double before mapping: change the
score field type in RankingResponse (or convert during the mapping in
RankingResponse.from) and transform info.score() with a precise representation
(e.g., BigDecimal.valueOf(info.score()).setScale(n, RoundingMode.HALF_UP) or
Double.valueOf(Math.round(info.score()*factor)/factor) for a chosen scale) so
JSON responses contain the desired fixed precision; adjust imports and any
callers accordingly (refer to RankingResponse record and the
from(RankingProductInfo) method and the info.score() call).
apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RedisRankingRepositoryTest.java (2)

113-121: getScore가 존재하지 않는 상품에 대해 null을 반환하는 테스트 누락.

getRank에 대해서는 Line 103-111에서 null 반환 테스트가 있으나, getScore에 대한 동일한 경계 케이스 테스트가 없다. API 호출 흐름에서 getScorenull을 반환할 수 있으므로 테스트 커버리지를 추가하는 것이 좋다.

제안: getScore null 반환 테스트 추가
`@DisplayName`("랭킹에 없는 상품 점수 조회 시 null을 반환한다.")
`@Test`
void getScore_returnsNullWhenNotInRanking() {
    // act
    Double score = rankingRepository.getScore(TEST_KEY, 999L);

    // assert
    assertThat(score).isNull();
}
🤖 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/infrastructure/ranking/RedisRankingRepositoryTest.java`
around lines 113 - 121, The test suite lacks a boundary test for
RedisRankingRepositoryTest#getScore when the product is absent; add a new test
method (e.g., getScore_returnsNullWhenNotInRanking) in
RedisRankingRepositoryTest that calls rankingRepository.getScore(TEST_KEY, 999L)
(or another non-existent product id) and asserts the result is null using
assertThat(score).isNull(); ensure the new test has a descriptive `@DisplayName`
and `@Test` annotations to mirror existing tests.

32-44: 테스트 격리를 위해 @BeforeEach에서도 정리 필요.

현재 @AfterEach에서만 정리하고 있어, 이전 테스트 실행이 실패로 중단된 경우 잔여 데이터가 다음 실행에 영향을 줄 수 있다. @BeforeEach에서 먼저 정리 후 데이터를 설정하면 더 안정적인 격리가 가능하다.

제안: setUp에서 선행 정리
 `@BeforeEach`
 void setUp() {
+    redisCleanUp.truncateAll();
     redisTemplate.opsForZSet().add(TEST_KEY, "1", 100.0);
🤖 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/infrastructure/ranking/RedisRankingRepositoryTest.java`
around lines 32 - 44, The setup currently seeds the ZSet in setUp() but only
cleans up in tearDown(); to ensure test isolation call
redisCleanUp.truncateAll() at the start of setUp() before calling
redisTemplate.opsForZSet().add(...) so any leftover data is removed; update the
setUp() method (and keep tearDown() as-is) to first invoke
redisCleanUp.truncateAll(), then add the TEST_KEY entries ("1","2","3","4","5")
with their scores.
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScoreUpdater.java (2)

27-35: 가중치 설정값에 대한 유효성 검증이 없다.

음수나 비정상적으로 큰 가중치가 설정되면 랭킹 점수가 예상과 다르게 계산될 수 있다. 설정 오류를 조기에 발견하기 위해 생성자나 @PostConstruct에서 검증하는 것을 고려할 수 있다.

가중치 검증 추가 제안
`@PostConstruct`
void validateWeights() {
    if (viewWeight < 0 || likeWeight < 0 || orderWeight < 0) {
        throw new IllegalStateException("랭킹 가중치는 0 이상이어야 합니다.");
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScoreUpdater.java`
around lines 27 - 35, Add validation for the injected weight fields in
RankingScoreUpdater to prevent negative or invalid weights: implement a
`@PostConstruct` method named validateWeights() (or perform checks in the
constructor) that verifies viewWeight, likeWeight, and orderWeight are >= 0 (and
optionally within an expected max if you want, e.g., <= 1.0); if any check
fails, throw an IllegalStateException with a clear message like "랭킹 가중치는 0 이상이어야
합니다." to fail fast and surface config errors.

55-60: Redis 연산이 원자적(atomic)이지 않다.

incrementScoreexpire가 별도 연산으로 실행된다. incrementScoreexpire 실패 시 TTL이 갱신되지 않을 수 있다. 다만, retrieved learnings에 따르면 이 시스템은 "approximate ranking with at-least-once semantics"로 설계되었고, daily key + TTL 구조로 자연 복구되므로 허용 가능한 수준이다.

Lua 스크립트를 사용하면 원자적 실행이 가능하지만, 현재 설계 의도상 필수는 아니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScoreUpdater.java`
around lines 55 - 60, The current incrementScore method runs
masterRedisTemplate.opsForZSet().incrementScore(...) and
masterRedisTemplate.expire(...) as two separate calls, which is not atomic;
change incrementScore to execute a single Redis Lua script (or RedisScript via
masterRedisTemplate.execute) that performs ZINCRBY and sets EXPIRE atomically
(using key from RankingKeyGenerator.keyOf(LocalDate.now(clock)) and member =
String.valueOf(productId)), or alternatively document/confirm that at-least-once
daily-key+TTL semantics are acceptable; update the method incrementScore to use
the Lua script approach (or add a clear comment referencing this tradeoff) so
readers can find the change.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java (1)

103-114: 응답 body에 대한 null 체크가 없다.

response.getBody().data()를 직접 호출하는데, HTTP 오류나 예외 상황에서 getBody()가 null을 반환할 수 있다. 테스트가 예상치 못하게 NPE로 실패하면 디버깅이 어려워진다.

명시적 null 체크 또는 assertThat 체인 사용 제안
             // assert
             assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
-            List<RankingV1Dto.RankingResponse> items = response.getBody().data();
+            assertThat(response.getBody()).isNotNull();
+            List<RankingV1Dto.RankingResponse> items = response.getBody().data();
             assertThat(items).hasSize(3);
🤖 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/RankingV1ApiE2ETest.java`
around lines 103 - 114, response.getBody() is used without a null check in
RankingV1ApiE2ETest leading to possible NPE; before calling
response.getBody().data() add an explicit assertion that response.getBody() is
not null (e.g., assertThat(response.getBody()).isNotNull()) or chain assertions
to safely obtain data, then assign to items and continue the existing assertions
so the test fails with a clear message rather than an NPE; locate this in the
test method where response.getBody(), items, and RankingV1Dto.RankingResponse
are referenced.
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (2)

28-31: pagesize 파라미터 유효성 검증이 없다.

page <= 0이면 음수 offset이 계산되고, size <= 0이면 빈 결과가 반환되거나 예상치 못한 동작이 발생할 수 있다. PR 요약에서 "parameter validation"을 향후 작업으로 언급했지만, 방어적 코딩 관점에서 최소한의 검증을 추가하는 것이 좋다.

파라미터 검증 추가 제안
     public RankingPageResult getRankings(LocalDate date, int page, int size) {
+        if (page < 1) {
+            page = 1;
+        }
+        if (size < 1) {
+            size = 10; // 기본값
+        }
         String key = RankingKeyGenerator.keyOf(date);
         int offset = (page - 1) * size;
🤖 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 28 - 31, In RankingFacade.getRankings, validate the incoming page
and size before computing offset or calling rankingRepository.getTopRankings:
ensure page > 0 and size > 0 and if not throw an IllegalArgumentException (or
another appropriate runtime exception) with a clear message referencing the
invalid parameter; keep using RankingKeyGenerator.keyOf(date) and compute offset
only after validation so offset = (page - 1) * size cannot be negative or
otherwise invalid.

41-49: 모든 CoreException을 동일하게 처리하는 것이 적절한지 검토가 필요하다.

현재 NOT_FOUND뿐 아니라 INTERNAL_SERVER_ERROR, UNAUTHORIZED 등 모든 CoreException이 동일하게 warn 로그만 남기고 스킵된다. 일시적인 DB 장애 등으로 인한 예외까지 스킵되면 정상 상품이 랭킹에서 누락될 수 있다.

에러 타입별 분기 처리 제안
     private Optional<RankingProductInfo> toRankingProductInfo(RankingEntry entry) {
         try {
             ProductInfo product = productFacade.getActiveProduct(entry.productId());
             return Optional.of(RankingProductInfo.of(product, entry.rank() + 1, entry.score()));
         } catch (CoreException e) {
-            log.warn("랭킹 조회 중 상품 조회 실패 [productId={}]: {}", entry.productId(), e.getMessage());
+            if (e.getErrorType() == ErrorType.NOT_FOUND) {
+                log.info("랭킹에서 삭제된 상품 스킵 [productId={}]", entry.productId());
+            } else {
+                log.error("랭킹 조회 중 예기치 않은 오류 [productId={}]: {}", entry.productId(), e.getMessage(), e);
+            }
             return 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/application/ranking/RankingFacade.java`
around lines 41 - 49, The toRankingProductInfo(RankingEntry) currently catches
all CoreException from productFacade.getActiveProduct(...) and silently skips by
returning Optional.empty(), which hides transient errors; change the handler to
distinguish error types: if the CoreException indicates NOT_FOUND (or a
404-equivalent) keep the warn log and return Optional.empty(); for other
statuses (e.g., INTERNAL_SERVER_ERROR, UNAUTHORIZED) rethrow or wrap and
propagate the exception so transient/system errors are not silently ignored;
update the catch block in toRankingProductInfo to inspect the CoreException
status/code and only swallow NOT_FOUND while logging and rethrowing others.
apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java (1)

48-91: 경계값 테스트 추가를 고려할 수 있다.

현재 테스트는 정상 시나리오(page=1, size=2)만 검증한다. page 또는 size가 0 이하일 때의 동작, 요청 크기가 전체 데이터보다 클 때의 동작 등 경계값 테스트가 없다. 운영 환경에서 잘못된 파라미터가 전달될 경우 예상치 못한 동작이 발생할 수 있다.

🤖 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/RankingFacadeTest.java`
around lines 48 - 91, Add boundary tests in RankingFacadeTest: create new `@Test`
methods (e.g., getRankings_invalidPageOrSize_throws and
getRankings_sizeOrPageBeyondData_returnsExpected) that call
rankingFacade.getRankings with page <= 0 and size <= 0 and assert the expected
behavior (either an exception or validation response your service uses), and
another test where size is larger than total entries or page requests beyond the
last page and assert it returns only available items (or empty list) and correct
page/size metadata; use the same helpers/fixtures (RankingKeyGenerator.keyOf,
rankingRepository.addScore, stubProduct, and
given(productFacade.getActiveProduct(...))) to set up data and verify results
from RankingFacade.getRankings.
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingScoreUpdaterTest.java (1)

41-100: 테스트가 하드코딩된 가중치 값에 의존한다.

테스트 assertion에서 0.1, 0.2, 0.7 등의 가중치를 직접 사용한다. application.ymlranking.weight.* 설정이 변경되면 테스트가 실패할 수 있다. 설정 값을 주입받아 사용하거나, 테스트 프로파일에서 고정된 값을 명시적으로 설정하는 것이 좋다.

설정 값 주입 방식 제안
`@Value`("${ranking.weight.view}")
private double viewWeight;

`@Value`("${ranking.weight.like}")
private double likeWeight;

`@Value`("${ranking.weight.order}")
private double orderWeight;

// 테스트에서 사용
assertThat(score).isEqualTo(viewWeight);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingScoreUpdaterTest.java`
around lines 41 - 100, Tests in RankingScoreUpdaterTest rely on hardcoded weight
literals (0.1, 0.2, 0.7) so they break if ranking.weight.* changes; update the
test to read the actual weights from configuration (or a test profile) and use
those variables in assertions: inject ranking weights into the test class (e.g.,
viewWeight, likeWeight, orderWeight) and replace every hardcoded literal in
incrementView_addsViewWeight, incrementLike_addsLikeWeight,
decrementLike_subtractsLikeWeight, incrementOrder_addsWeightedOrderScore, and
multipleEvents_accumulatesScore assertions with expressions using those injected
fields (and keep using rankingScoreUpdater and redisTemplate as before). Ensure
the test context supplies fixed values via `@TestPropertySource` or test
application.yml so assertions remain deterministic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java`:
- Around line 143-147: PaymentCompletedEventPayload is being published with a
null userId, reducing traceability; update the publication so userId is
populated: in PaymentFacade where outboxEventPublisher.publish(...) is called
(the call that creates PaymentCompletedEventPayload.of(payment.getId(),
payment.getOrderId(), null, ...)), obtain the userId from an available source
(prefer the syncPayment method parameter if present, otherwise read it from the
Payment entity or load it via the Order associated with payment) and pass that
userId into PaymentCompletedEventPayload.of; ensure Payment or Order accessors
(e.g., Payment.getUserId() or Order.getUserId()) are used/added if missing so
consumers receive the user identity.

In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`:
- Around line 51-60: In getProductRank in class RankingFacade: after obtaining
Long rank from rankingRepository.getRank(key, productId) and before constructing
new RankingInfo, defensively handle a possible null Double from
rankingRepository.getScore(key, productId) by checking if score == null and
substituting a safe default (e.g., 0.0) or otherwise converting it to a non-null
value, then pass that non-null Double into the RankingInfo constructor;
reference methods: getProductRank, RankingKeyGenerator.keyOf,
rankingRepository.getRank, rankingRepository.getScore, and class RankingInfo.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`:
- Around line 29-30: The controller currently accepts page and size without
validation; add minimal defensive checks in RankingV1Controller (the method
receiving `@RequestParam` page and size) to ensure page >= 1 and size >= 1 (and
optionally enforce an upper bound like size <= 100), and if invalid either throw
a ResponseStatusException(HttpStatus.BAD_REQUEST, "...") or convert to your
domain error by calling RankingFacade to throw a CoreException—this prevents
computing a negative offset ((page - 1) * size) and avoids passing bad values to
Redis.

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/handler/ranking/RankingPaymentEventHandler.java`:
- Around line 32-38: The loop over OrderedProduct lacks null-safety for boxed
fields; before calling rankingScoreUpdater.incrementOrder, check
OrderedProduct.getPrice() and getQuantity() for null (or validate product
itself) and either skip the product or substitute safe defaults (e.g., price 0L,
quantity 0) to avoid auto-unboxing NPEs; update the loop in
RankingPaymentEventHandler to guard each OrderedProduct and only call
rankingScoreUpdater.incrementOrder(product.getProductId(), safePrice,
safeQuantity) when values are non-null or after applying the chosen default/skip
policy.

---

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`:
- Around line 28-31: In RankingFacade.getRankings, validate the incoming page
and size before computing offset or calling rankingRepository.getTopRankings:
ensure page > 0 and size > 0 and if not throw an IllegalArgumentException (or
another appropriate runtime exception) with a clear message referencing the
invalid parameter; keep using RankingKeyGenerator.keyOf(date) and compute offset
only after validation so offset = (page - 1) * size cannot be negative or
otherwise invalid.
- Around line 41-49: The toRankingProductInfo(RankingEntry) currently catches
all CoreException from productFacade.getActiveProduct(...) and silently skips by
returning Optional.empty(), which hides transient errors; change the handler to
distinguish error types: if the CoreException indicates NOT_FOUND (or a
404-equivalent) keep the warn log and return Optional.empty(); for other
statuses (e.g., INTERNAL_SERVER_ERROR, UNAUTHORIZED) rethrow or wrap and
propagate the exception so transient/system errors are not silently ignored;
update the catch block in toRankingProductInfo to inspect the CoreException
status/code and only swallow NOT_FOUND while logging and rethrowing others.

In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java`:
- Around line 5-9: RankingPageResult의 공개 필드 items가 가변 List 참조를 노출해 불변성이 깨지므로
compact constructor를 추가해 null 검사 후 items = List.copyOf(items)로 방어적 복사를 적용하고,
items()가 변경 불가능한 리스트를 반환하도록 하세요; 또한 RankingPageResult 레코드의 compact constructor에서
items가 null일 때 NullPointerException을 던지도록 처리하고, 단위 테스트로 원본 리스트를 수정해도
RankingPageResult.items()에 반영되지 않는지와 반환 리스트가 수정 불가능한지 검증하세요.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/dto/RankingV1Dto.java`:
- Around line 7-24: The DTO exposes score as Double which can reveal
floating-point artifacts when serialized; update the RankingResponse.record to
either use BigDecimal for score or round the double before mapping: change the
score field type in RankingResponse (or convert during the mapping in
RankingResponse.from) and transform info.score() with a precise representation
(e.g., BigDecimal.valueOf(info.score()).setScale(n, RoundingMode.HALF_UP) or
Double.valueOf(Math.round(info.score()*factor)/factor) for a chosen scale) so
JSON responses contain the desired fixed precision; adjust imports and any
callers accordingly (refer to RankingResponse record and the
from(RankingProductInfo) method and the info.score() call).

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java`:
- Around line 29-30: Remove the path-level exclusion for "/api/v1/rankings" in
WebConfig and instead add an explicit method check inside LoginUserInterceptor
(e.g., in preHandle or equivalent) that only bypasses authentication for GET
requests to "/api/v1/rankings"; ensure other methods (POST/PUT/DELETE) to the
same path still require auth. Update or add an interceptor integration test that
asserts GET /api/v1/rankings is allowed without auth while a non-GET request to
/api/v1/rankings triggers the authentication flow.

In
`@apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java`:
- Around line 48-91: Add boundary tests in RankingFacadeTest: create new `@Test`
methods (e.g., getRankings_invalidPageOrSize_throws and
getRankings_sizeOrPageBeyondData_returnsExpected) that call
rankingFacade.getRankings with page <= 0 and size <= 0 and assert the expected
behavior (either an exception or validation response your service uses), and
another test where size is larger than total entries or page requests beyond the
last page and assert it returns only available items (or empty list) and correct
page/size metadata; use the same helpers/fixtures (RankingKeyGenerator.keyOf,
rankingRepository.addScore, stubProduct, and
given(productFacade.getActiveProduct(...))) to set up data and verify results
from RankingFacade.getRankings.

In
`@apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RedisRankingRepositoryTest.java`:
- Around line 113-121: The test suite lacks a boundary test for
RedisRankingRepositoryTest#getScore when the product is absent; add a new test
method (e.g., getScore_returnsNullWhenNotInRanking) in
RedisRankingRepositoryTest that calls rankingRepository.getScore(TEST_KEY, 999L)
(or another non-existent product id) and asserts the result is null using
assertThat(score).isNull(); ensure the new test has a descriptive `@DisplayName`
and `@Test` annotations to mirror existing tests.
- Around line 32-44: The setup currently seeds the ZSet in setUp() but only
cleans up in tearDown(); to ensure test isolation call
redisCleanUp.truncateAll() at the start of setUp() before calling
redisTemplate.opsForZSet().add(...) so any leftover data is removed; update the
setUp() method (and keep tearDown() as-is) to first invoke
redisCleanUp.truncateAll(), then add the TEST_KEY entries ("1","2","3","4","5")
with their scores.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java`:
- Around line 103-114: response.getBody() is used without a null check in
RankingV1ApiE2ETest leading to possible NPE; before calling
response.getBody().data() add an explicit assertion that response.getBody() is
not null (e.g., assertThat(response.getBody()).isNotNull()) or chain assertions
to safely obtain data, then assign to items and continue the existing assertions
so the test fails with a clear message rather than an NPE; locate this in the
test method where response.getBody(), items, and RankingV1Dto.RankingResponse
are referenced.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScoreUpdater.java`:
- Around line 27-35: Add validation for the injected weight fields in
RankingScoreUpdater to prevent negative or invalid weights: implement a
`@PostConstruct` method named validateWeights() (or perform checks in the
constructor) that verifies viewWeight, likeWeight, and orderWeight are >= 0 (and
optionally within an expected max if you want, e.g., <= 1.0); if any check
fails, throw an IllegalStateException with a clear message like "랭킹 가중치는 0 이상이어야
합니다." to fail fast and surface config errors.
- Around line 55-60: The current incrementScore method runs
masterRedisTemplate.opsForZSet().incrementScore(...) and
masterRedisTemplate.expire(...) as two separate calls, which is not atomic;
change incrementScore to execute a single Redis Lua script (or RedisScript via
masterRedisTemplate.execute) that performs ZINCRBY and sets EXPIRE atomically
(using key from RankingKeyGenerator.keyOf(LocalDate.now(clock)) and member =
String.valueOf(productId)), or alternatively document/confirm that at-least-once
daily-key+TTL semantics are acceptable; update the method incrementScore to use
the Lua script approach (or add a clear comment referencing this tradeoff) so
readers can find the change.

In
`@apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingKeyGeneratorTest.java`:
- Around line 13-24: Add parameterized boundary tests in RankingKeyGeneratorTest
to cover date edge cases and format validation: create a `@ParameterizedTest` that
calls RankingKeyGenerator.keyOf(...) for dates like LocalDate.of(2025,1,1),
LocalDate.of(2025,12,31) (and the existing 2025-04-09) and assert each produced
key equals the expected string, and also add an assertion that the returned key
matches the fixed yyyyMMdd pattern (e.g., matches regex "^ranking:all:\\d{8}$")
to catch zero-padding/format regressions; reference the existing test method
keyOf_returnsKeyWithGivenDate and the static method RankingKeyGenerator.keyOf to
locate where to add these checks.

In
`@apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingScoreUpdaterTest.java`:
- Around line 41-100: Tests in RankingScoreUpdaterTest rely on hardcoded weight
literals (0.1, 0.2, 0.7) so they break if ranking.weight.* changes; update the
test to read the actual weights from configuration (or a test profile) and use
those variables in assertions: inject ranking weights into the test class (e.g.,
viewWeight, likeWeight, orderWeight) and replace every hardcoded literal in
incrementView_addsViewWeight, incrementLike_addsLikeWeight,
decrementLike_subtractsLikeWeight, incrementOrder_addsWeightedOrderScore, and
multipleEvents_accumulatesScore assertions with expressions using those injected
fields (and keep using rankingScoreUpdater and redisTemplate as before). Ensure
the test context supplies fixed values via `@TestPropertySource` or test
application.yml so assertions remain deterministic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 44554360-e893-4541-88bc-f1ea8328ecac

📥 Commits

Reviewing files that changed from the base of the PR and between dc8fbd5 and 76c16e4.

📒 Files selected for processing (31)
  • .gitignore
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductInfo.java
  • apps/commerce-api/src/main/java/com/loopers/config/TimeConfig.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingEntry.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingInfo.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RedisRankingRepository.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/dto/RankingV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/ranking/InMemoryRankingRepository.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RedisRankingRepositoryTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/handler/ranking/RankingLikeEventHandler.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/handler/ranking/RankingPaymentEventHandler.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/handler/ranking/RankingUnlikeEventHandler.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/handler/ranking/RankingViewEventHandler.java
  • apps/commerce-streamer/src/main/java/com/loopers/config/TimeConfig.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScoreUpdater.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingKeyGeneratorTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingScoreUpdaterTest.java
  • modules/event-contract/src/main/java/com/loopers/event/payload/OrderedProduct.java
  • modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java
  • modules/event-contract/src/main/java/com/loopers/event/ranking/RankingKeyGenerator.java

Comment on lines 143 to 147
outboxEventPublisher.publish(
EventType.PAYMENT_COMPLETED,
PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), null, productIds),
PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), null, productIds, orderedProducts),
payment.getOrderId()
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

userIdnull로 전달되어 이벤트 추적성이 저하된다.

PaymentCompletedEventPayload.of(...) 호출 시 세 번째 파라미터 userIdnull이 전달된다. 결제 완료 이벤트에서 사용자 식별 정보가 누락되면 consumer 측에서 사용자별 분석이나 알림 발송이 불가능하다. handleCallback은 외부 PG 콜백이라 userId 컨텍스트가 없지만, syncPayment는 userId를 파라미터로 받고 있으므로 활용 가능하다.

의도적 설계인지 확인하고, 필요하다면 Payment 엔티티에 userId를 저장하거나 Order에서 조회하는 방안을 검토해야 한다.

#!/bin/bash
# Description: PaymentCompletedEventPayload의 userId 필드가 consumer에서 사용되는지 확인

rg -n "getUserId\(\)" --type=java -g '**/commerce-streamer/**'
🤖 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/payment/PaymentFacade.java`
around lines 143 - 147, PaymentCompletedEventPayload is being published with a
null userId, reducing traceability; update the publication so userId is
populated: in PaymentFacade where outboxEventPublisher.publish(...) is called
(the call that creates PaymentCompletedEventPayload.of(payment.getId(),
payment.getOrderId(), null, ...)), obtain the userId from an available source
(prefer the syncPayment method parameter if present, otherwise read it from the
Payment entity or load it via the Order associated with payment) and pass that
userId into PaymentCompletedEventPayload.of; ensure Payment or Order accessors
(e.g., Payment.getUserId() or Order.getUserId()) are used/added if missing so
consumers receive the user identity.

Comment on lines +51 to +60
public RankingInfo getProductRank(Long productId) {
LocalDate today = LocalDate.now(clock);
String key = RankingKeyGenerator.keyOf(today);
Long rank = rankingRepository.getRank(key, productId);
if (rank == null) {
return null;
}
Double score = rankingRepository.getScore(key, productId);
return new RankingInfo(rank + 1, score);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

getScore 결과가 null인 경우에 대한 방어 로직이 없다.

getRank()가 값을 반환하더라도 getScore()가 null을 반환할 가능성이 있다 (Redis 키가 존재하지만 score 조회 시점에 만료되는 race condition 등). 현재 코드는 score가 null이면 RankingInfo에 null이 그대로 전달된다. 운영 환경에서 클라이언트가 null score를 처리하지 못하면 문제가 발생할 수 있다.

방어 로직 추가 제안
     public RankingInfo getProductRank(Long productId) {
         LocalDate today = LocalDate.now(clock);
         String key = RankingKeyGenerator.keyOf(today);
         Long rank = rankingRepository.getRank(key, productId);
         if (rank == null) {
             return null;
         }
         Double score = rankingRepository.getScore(key, productId);
+        if (score == null) {
+            log.warn("랭킹 점수 조회 실패 [productId={}, key={}]: rank는 존재하나 score가 없음", productId, key);
+            return null;
+        }
         return new RankingInfo(rank + 1, score);
     }
📝 Committable suggestion

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

Suggested change
public RankingInfo getProductRank(Long productId) {
LocalDate today = LocalDate.now(clock);
String key = RankingKeyGenerator.keyOf(today);
Long rank = rankingRepository.getRank(key, productId);
if (rank == null) {
return null;
}
Double score = rankingRepository.getScore(key, productId);
return new RankingInfo(rank + 1, score);
}
public RankingInfo getProductRank(Long productId) {
LocalDate today = LocalDate.now(clock);
String key = RankingKeyGenerator.keyOf(today);
Long rank = rankingRepository.getRank(key, productId);
if (rank == null) {
return null;
}
Double score = rankingRepository.getScore(key, productId);
if (score == null) {
log.warn("랭킹 점수 조회 실패 [productId={}, key={}]: rank는 존재하나 score가 없음", productId, key);
return null;
}
return new RankingInfo(rank + 1, score);
}
🤖 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 51 - 60, In getProductRank in class RankingFacade: after obtaining
Long rank from rankingRepository.getRank(key, productId) and before constructing
new RankingInfo, defensively handle a possible null Double from
rankingRepository.getScore(key, productId) by checking if score == null and
substituting a safe default (e.g., 0.0) or otherwise converting it to a non-null
value, then pass that non-null Double into the RankingInfo constructor;
reference methods: getProductRank, RankingKeyGenerator.keyOf,
rankingRepository.getRank, rankingRepository.getScore, and class RankingInfo.

Comment on lines +29 to +30
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "1") int page
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

pagesize 파라미터 유효성 검증 부재.

page=0 또는 음수 입력 시 offset = (page - 1) * size가 음수가 되어 Redis ZREVRANGE의 예상치 못한 동작을 유발할 수 있다. size=0이나 음수도 마찬가지다.

운영 환경에서 잘못된 파라미터가 전달되면 빈 결과 또는 Redis 오류로 이어질 수 있다. PR 목표에 파라미터 검증이 향후 작업으로 언급되어 있으나, 최소한의 방어 로직은 필요하다.

제안: 최소 유효성 검증 추가
 `@GetMapping`
 public ApiResponse<List<RankingV1Dto.RankingResponse>> getRankings(
         `@RequestParam`(required = false) `@DateTimeFormat`(pattern = "yyyyMMdd") LocalDate date,
         `@RequestParam`(defaultValue = "20") int size,
         `@RequestParam`(defaultValue = "1") int page
 ) {
+    if (page < 1) page = 1;
+    if (size < 1 || size > 100) size = 20;
     LocalDate targetDate = (date != null) ? date : LocalDate.now(clock);

또는 도메인 레이어(RankingFacade)에서 CoreException을 던지는 방식으로 처리할 수 있다.

🤖 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 29 - 30, The controller currently accepts page and size without
validation; add minimal defensive checks in RankingV1Controller (the method
receiving `@RequestParam` page and size) to ensure page >= 1 and size >= 1 (and
optionally enforce an upper bound like size <= 100), and if invalid either throw
a ResponseStatusException(HttpStatus.BAD_REQUEST, "...") or convert to your
domain error by calling RankingFacade to throw a CoreException—this prevents
computing a negative offset ((page - 1) * size) and avoids passing bad values to
Redis.

Comment on lines +32 to +38
for (OrderedProduct product : orderedProducts) {
rankingScoreUpdater.incrementOrder(
product.getProductId(),
product.getPrice(),
product.getQuantity()
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

OrderedProduct 필드의 null 값에 대한 방어 코드 부재.

OrderedProductprice(Long)와 quantity(Integer)가 null인 경우, RankingScoreUpdater.incrementOrder(Long, long, int) 호출 시 auto-unboxing 과정에서 NullPointerException이 발생한다. 운영 환경에서 데이터 정합성 이슈로 null 값이 유입되면 해당 이벤트 처리 전체가 실패하게 된다.

PaymentFacade에서 OrderedProduct.of()를 생성할 때 값이 항상 존재한다면 문제없으나, Kafka 메시지 역직렬화 과정에서 필드가 누락되면 null이 될 수 있다.

제안: null 체크 추가
 for (OrderedProduct product : orderedProducts) {
+    if (product.getProductId() == null || product.getPrice() == null || product.getQuantity() == null) {
+        continue; // 또는 로깅 후 skip
+    }
     rankingScoreUpdater.incrementOrder(
             product.getProductId(),
             product.getPrice(),
             product.getQuantity()
     );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/handler/ranking/RankingPaymentEventHandler.java`
around lines 32 - 38, The loop over OrderedProduct lacks null-safety for boxed
fields; before calling rankingScoreUpdater.incrementOrder, check
OrderedProduct.getPrice() and getQuantity() for null (or validate product
itself) and either skip the product or substitute safe defaults (e.g., price 0L,
quantity 0) to avoid auto-unboxing NPEs; update the loop in
RankingPaymentEventHandler to guard each OrderedProduct and only call
rankingScoreUpdater.incrementOrder(product.getProductId(), safePrice,
safeQuantity) when values are non-null or after applying the chosen default/skip
policy.

@hey-sion hey-sion merged commit 681c61f into Loopers-dev-lab:hey-sion Apr 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant