Skip to content

Conversation

@sylee6529
Copy link
Collaborator

@sylee6529 sylee6529 commented Dec 26, 2025

πŸ“Œ Summary

Redis ZSET 기반 μ‹€μ‹œκ°„ λž­ν‚Ή μ‹œμŠ€ν…œ κ΅¬ν˜„

  • Kafka Consumerμ—μ„œ 쑰회/μ’‹μ•„μš”/μ£Όλ¬Έ 이벀트 μˆ˜μ‹  β†’ 일간 λž­ν‚Ή ZSET 적재
  • λž­ν‚Ή 쑰회 API (GET /api/v1/rankings) 및 μƒν’ˆ 상세에 μˆœμœ„ 정보 μΆ”κ°€

πŸ’¬ Review Points

κ³ λ―Όν–ˆλ˜ 섀계 포인트

  • μ’‹μ•„μš” μ·¨μ†Œ μ‹œ 점수 κ°μ†Œ 처리 (일관성 vs 관심 이λ ₯ μœ μ§€ 쀑 일관성 선택)
  • commerce-api / commerce-streamer κ°„ ν‚€ 생성 λ‘œμ§μ„ CacheKeyGenerator둜 톡일

μ£Όμš” κ΅¬ν˜„ 둜직

  • ProductRankingCache.addOrderScoresBatch(): Redis Pipeline 배치 처리 및 fallback 둜직
  • MetricsAggregationService: λ©±λ“±μ„± μ²˜λ¦¬μ™€ λž­ν‚Ή μ μž¬κ°€ 같은 νŠΈλžœμž­μ…˜μ— μœ„μΉ˜

βœ… Checklist

  • λž­ν‚Ή ZSET의 TTL, ν‚€ μ „λž΅μ„ μ μ ˆν•˜κ²Œ κ΅¬ν˜„ν•œλ‹€
  • λ‚ μ§œλ³„λ‘œ μ μž¬ν•  ν‚€λ₯Ό κ³„μ‚°ν•˜λŠ” κΈ°λŠ₯을 λ§Œλ“€μ—ˆλ‹€
  • μ΄λ²€νŠΈκ°€ λ°œμƒν•œ ν›„, ZSET에 μ μˆ˜κ°€ μ μ ˆν•˜κ²Œ λ°˜μ˜λœλ‹€

Ranking API

  • λž­ν‚Ή Page 쑰회 μ‹œ μ •μƒμ μœΌλ‘œ λž­ν‚Ή 정보가 λ°˜ν™˜λœλ‹€
  • λž­ν‚Ή Page 쑰회 μ‹œ μƒν’ˆμ •λ³΄κ°€ Aggregation λ˜μ–΄ μ œκ³΅λœλ‹€
  • μƒν’ˆ 상세 쑰회 μ‹œ ν•΄λ‹Ή μƒν’ˆμ˜ μˆœμœ„κ°€ ν•¨κ»˜ λ°˜ν™˜λœλ‹€

Summary by CodeRabbit

릴리슀 λ…ΈνŠΈ

  • μƒˆλ‘œμš΄ κΈ°λŠ₯
    • μƒν’ˆ μˆœμœ„ νŽ˜μ΄μ§€ μΆ”κ°€ - 인기 μƒν’ˆ μˆœμœ„λ₯Ό λ‚ μ§œλ³„λ‘œ 쑰회 κ°€λŠ₯
    • μƒν’ˆ 상세 정보에 μˆœμœ„ 데이터 포함
    • μ‹€μ‹œκ°„ μˆœμœ„ μ‚°μ • - 쑰회, μ’‹μ•„μš”, ꡬ맀 ν™œλ™μ— 따라 μžλ™ μ—…λ°μ΄νŠΈ
    • μƒν’ˆ 정보에 λΈŒλžœλ“œ ID μΆ”κ°€

✏️ Tip: You can customize this high-level summary in your review settings.

- RankingConfig: μ΄λ²€νŠΈλ³„ κ°€μ€‘μΉ˜ μ„€μ • (view: 0.1, like: 0.2, order: 0.6)
- TTL μ„€μ • (2일)
- ProductRankingCache: ZINCRBY둜 점수 적재
- CacheKeyGenerator: 일간 λž­ν‚Ή ν‚€ 생성 (ranking:all:v1:{date})
- TTL race condition λ°©μ§€ (AtomicReference)
- μ£Όλ¬Έ 점수 log μ •κ·œν™”
- Redis Pipeline 배치 처리 지원
- 쑰회/μ’‹μ•„μš”/μ£Όλ¬Έ 이벀트 μ‹œ λž­ν‚Ή 점수 적재
- μ’‹μ•„μš” μ·¨μ†Œ μ‹œ 점수 κ°μ†Œ 반영
- 주문 이벀트 Pipeline 배치 처리
- ProductRankingCache: ZSET 쑰회 (μˆœμœ„, 점수, νŽ˜μ΄μ§€λ„€μ΄μ…˜)
- CacheKeyGenerator: dailyRankingKey μΆ”κ°€
- RankingFacade: λž­ν‚Ή 쑰회 λΉ„μ¦ˆλ‹ˆμŠ€ 둜직
- RankingInfo: λž­ν‚Ή νŽ˜μ΄μ§€/μ•„μ΄ν…œ 정보 DTO
- μƒν’ˆ/λΈŒλžœλ“œ 정보 Aggregation (N+1 λ°©μ§€)
- GET /api/v1/rankings?date=&page=&size=
- page νŒŒλΌλ―Έν„° 1-based (API) β†’ 0-based (λ‚΄λΆ€) λ³€ν™˜
- ProductDetailInfo: ranking, brandId ν•„λ“œ μΆ”κ°€
- ProductReadService: brandId μ„€μ •
- ProductV1Dto: ranking 응닡 ν•„λ“œ μΆ”κ°€
- brandId μΊμ‹œ μ‚¬μš©μœΌλ‘œ λΆˆν•„μš”ν•œ DB 쑰회 제거
- μ‹€μ‹œκ°„ λž­ν‚Ή 쑰회 연동
@sylee6529 sylee6529 self-assigned this Dec 26, 2025
@coderabbitai
Copy link

coderabbitai bot commented Dec 26, 2025

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

이 PR은 Redis 기반 μ œν’ˆ λž­ν‚Ή μ‹œμŠ€ν…œμ„ λ„μž…ν•©λ‹ˆλ‹€. μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ „λ°˜μ— ProductRankingCacheλ₯Ό ν†΅ν•©ν•˜μ—¬ μ‹€μ‹œκ°„ λž­ν‚Ή 데이터λ₯Ό μ œκ³΅ν•˜κ³ , μƒˆλ‘œμš΄ RankingFacade 및 API μ—”λ“œν¬μΈνŠΈλ‘œ λž­ν‚Ή 쑰회 κΈ°λŠ₯을 λ…ΈμΆœν•˜λ©°, λ©”νŠΈλ¦­ μˆ˜μ§‘ μ‹œ λž­ν‚Ή 점수λ₯Ό λ™μ μœΌλ‘œ μ—…λ°μ΄νŠΈν•©λ‹ˆλ‹€.

Changes

μ½”ν˜ΈνŠΈ / 파일(λ“€) λ³€κ²½ μš”μ•½
μ œν’ˆ 상세 정보 ν™•μž₯
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java, apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java, apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java
ProductDetailInfo에 brandId와 ranking ν•„λ“œλ₯Ό μΆ”κ°€ν•˜κ³ , ProductReadServiceμ—μ„œ 이λ₯Ό μ΄ˆκΈ°ν™”ν•˜λ©°, ProductV1Dtoμ—μ„œ API 응닡에 ν¬ν•¨ν•©λ‹ˆλ‹€.
μ œν’ˆ 쑰회 μ΅œμ ν™”
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
ProductRepository μ˜μ‘΄μ„±μ„ ProductRankingCache둜 λŒ€μ²΄ν•˜μ—¬ DB 쑰회 제거, μΊμ‹œλœ 정보λ₯Ό μ‚¬μš©ν•˜μ—¬ brandId 및 μ‹€μ‹œκ°„ ranking을 ProductDetailInfo에 μ£Όμž…ν•©λ‹ˆλ‹€.
μΊμ‹œ ν‚€ 생성
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java
두 λͺ¨λ“ˆ λͺ¨λ‘μ— dailyRankingKey(date) λ©”μ„œλ“œλ₯Ό μΆ”κ°€ν•˜μ—¬ ranking:all:v1:{yyyyMMdd} ν˜•μ‹μ˜ Redis ZSET ν‚€λ₯Ό μƒμ„±ν•©λ‹ˆλ‹€.
λž­ν‚Ή μΊμ‹œ κ΅¬ν˜„
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java
두 λͺ¨λ“ˆμ— Redis ZSET 기반 μ œν’ˆ λž­ν‚Ή μΊμ‹œλ₯Ό κ΅¬ν˜„ν•˜μ—¬, 쑰회/μ’‹μ•„μš”/μ£Όλ¬Έ 점수λ₯Ό κ΄€λ¦¬ν•˜κ³  pagination을 μ§€μ›ν•©λ‹ˆλ‹€.
λž­ν‚Ή 쑰회 API
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java, apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.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/RankingV1Dto.java
RankingFacadeμ—μ„œ μΊμ‹œλœ λž­ν‚Ή 쑰회, μ œν’ˆ/λΈŒλžœλ“œ 정보 쑰회, RankingPageInfo ꡬ성; RankingV1Controllerμ—μ„œ GET /api/v1/rankings μ—”λ“œν¬μΈνŠΈλ‘œ λ…ΈμΆœν•˜λ©° RankingV1Dtoλ₯Ό 톡해 API μ‘λ‹΅μœΌλ‘œ λ³€ν™˜ν•©λ‹ˆλ‹€.
λ©”νŠΈλ¦­ 톡합
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java
ProductRankingCacheλ₯Ό μ£Όμž…λ°›μ•„ μ’‹μ•„μš”/μ‹«μ–΄μš”/쑰회/μ£Όλ¬Έ 이벀트 λ°œμƒ μ‹œ ranking 점수λ₯Ό addLikeScore, subtractLikeScore, addViewScore, addOrderScoresBatch둜 μ—…λ°μ΄νŠΈν•©λ‹ˆλ‹€.
λž­ν‚Ή μ„€μ •
apps/commerce-streamer/src/main/java/com/loopers/config/RankingConfig.java, apps/commerce-streamer/src/main/resources/application.yml
RankingConfigμ—μ„œ ranking.weight (view: 0.1, like: 0.2, order: 0.6)와 ranking.ttl-days: 2λ₯Ό μ„€μ • κ°€λŠ₯ν•˜κ²Œ ν•˜κ³ , application.ymlμ—μ„œ 기본값을 μ •μ˜ν•©λ‹ˆλ‹€.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant RankingV1Controller
    participant RankingFacade
    participant ProductRankingCache
    participant ProductDB
    participant BrandDB

    Client->>RankingV1Controller: GET /api/v1/rankings?date=20251223&page=1&size=10
    RankingV1Controller->>RankingFacade: getRankings("20251223", 0, 10)
    
    rect rgb(200, 220, 255)
    Note over RankingFacade,ProductRankingCache: μΊμ‹œλœ λž­ν‚Ή 쑰회
    RankingFacade->>ProductRankingCache: getTopRankings("20251223", 0, 10)
    ProductRankingCache-->>RankingFacade: List<RankingEntry>
    end
    
    alt λž­ν‚Ή 데이터 쑴재
        rect rgb(220, 255, 220)
        Note over RankingFacade,BrandDB: μ œν’ˆ 및 λΈŒλžœλ“œ 쑰회
        RankingFacade->>ProductDB: findAllById(productIds)
        ProductDB-->>RankingFacade: List<Product>
        RankingFacade->>BrandDB: findAllById(brandIds)
        BrandDB-->>RankingFacade: List<Brand>
        end
        
        rect rgb(255, 240, 200)
        Note over RankingFacade: 응닡 ꡬ성
        RankingFacade->>RankingFacade: construct RankingPageInfo<br/>with items & metadata
        end
    else λž­ν‚Ή 데이터 μ—†μŒ
        RankingFacade->>RankingFacade: return empty RankingPageInfo
    end
    
    RankingFacade-->>RankingV1Controller: RankingPageInfo
    RankingV1Controller->>RankingV1Controller: map to RankingPageResponse
    RankingV1Controller-->>Client: ApiResponse.success(RankingPageResponse)
Loading
sequenceDiagram
    participant Event as Event Handler
    participant MetricsAggregationService
    participant MetricsService
    participant ProductRankingCache
    participant Redis

    Event->>MetricsAggregationService: onProductLiked(productId)
    MetricsAggregationService->>MetricsService: updateLikeCount(...)
    MetricsService-->>MetricsAggregationService: complete
    
    rect rgb(200, 220, 255)
    Note over MetricsAggregationService,Redis: λž­ν‚Ή 점수 μ—…λ°μ΄νŠΈ
    MetricsAggregationService->>ProductRankingCache: addLikeScore(productId)
    ProductRankingCache->>Redis: ZINCRBY ranking:all:v1:{date} 0.2 {productId}
    Redis-->>ProductRankingCache: success
    end

    Event->>MetricsAggregationService: onOrderCompleted(order)
    MetricsAggregationService->>MetricsService: updateOrderCount(...)
    MetricsService-->>MetricsAggregationService: complete
    
    rect rgb(220, 255, 220)
    Note over MetricsAggregationService,Redis: 배치 μ£Όλ¬Έ 점수 μ—…λ°μ΄νŠΈ
    MetricsAggregationService->>MetricsAggregationService: build OrderItemScore list
    MetricsAggregationService->>ProductRankingCache: addOrderScoresBatch(items)
    ProductRankingCache->>Redis: pipeline ZINCRBY x N
    Redis-->>ProductRankingCache: success
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

enhancement

Poem

🐰 λž˜λΉ—μ΄ λ…Έλž˜ν•΄μš”,
λž­ν‚Ήμ΄ 좀을 μΆ˜λ‹€λ„€!
Redis μΊμ‹œ 반짝반짝,
μ œν’ˆ μˆœμœ„ 쏙쏙 올라
μ μˆ˜μ™€ ν•¨κ»˜ λ‚ μ•„κ°€, ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.64% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
βœ… Passed checks (2 passed)
Check name Status Explanation
Title check βœ… Passed 제λͺ©μ΄ PR의 μ£Όμš” 변경사항인 Redis 기반 μ‹€μ‹œκ°„ μƒν’ˆ λž­ν‚Ή μ‹œμŠ€ν…œ κ΅¬ν˜„μ„ λͺ…ν™•ν•˜κ²Œ μš”μ•½ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€.
Description check βœ… Passed PR μ„€λͺ…이 μš”κ΅¬λ˜λŠ” ν…œν”Œλ¦Ώμ˜ μ£Όμš” μ„Ήμ…˜(Summary, Review Points, Checklist)을 λͺ¨λ‘ ν¬ν•¨ν•˜κ³  있으며 μΆ©λΆ„νžˆ μƒμ„Έν•©λ‹ˆλ‹€.

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

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java (1)

38-62: Redis μ˜ˆμ™Έ 처리 방식 μž¬κ²€ν†  ν•„μš”

ν˜„μž¬ μ½”λ“œμ—μ„œ productRankingCache.addLikeScore()λŠ” @Transactional λ²”μœ„ λ‚΄μ—μ„œ 호좜되고 μžˆμŠ΅λ‹ˆλ‹€. ProductRankingCache의 incrementScore λ©”μ„œλ“œ(lines 147-156)λŠ” Redis μ˜ˆμ™Έλ₯Ό catchν•˜μ—¬ 경고만 λ‘œκΉ…ν•˜κ³  μž¬λ°œμƒμ‹œν‚€μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.

μ΄λŠ” λ‹€μŒ 두 κ°€μ§€ 문제λ₯Ό μ•ΌκΈ°ν•©λ‹ˆλ‹€:

  1. 데이터 뢈일치: Redis μ—°μ‚° μ‹€νŒ¨ μ‹œμ—λ„ λ°μ΄ν„°λ² μ΄μŠ€ 변경사항이 μ»€λ°‹λ˜μ–΄ μΊμ‹œμ™€ DB κ°„ 뢈일치 λ°œμƒ
  2. 암묡적 μ‹€νŒ¨: μ˜ˆμ™Έκ°€ 쑰용히 μ‚ΌμΌœμ Έ λͺ¨λ‹ˆν„°λ§ 및 디버깅이 어렀움

섀계 μ˜λ„λ₯Ό λͺ…ν™•νžˆ ν•œ ν›„, ν•„μš”μ‹œ λ‹€μŒ 쀑 ν•˜λ‚˜λ₯Ό κ³ λ €ν•˜μ„Έμš”:

  • Redis 연산을 νŠΈλžœμž­μ…˜ μ™ΈλΆ€λ‘œ 뢄리 (이벀트 λ°œν–‰ λ˜λŠ” 별도 μŠ€λ ˆλ“œ)
  • λͺ…μ‹œμ  μž¬μ‹œλ„ 둜직 μΆ”κ°€ 및 μ΅œμ’… μ‹€νŒ¨ μ‹œ κ²½κ³  κ°•ν™”
  • Compensating transaction νŒ¨ν„΄ 적용
🧹 Nitpick comments (3)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java (1)

1-19: λͺ¨λ“ˆ κ°„ ν‚€ 생성 일관성이 잘 μœ μ§€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

commerce-streamer와 commerce-api λͺ¨λ“ˆμ—μ„œ λ™μΌν•œ ν‚€ ν˜•μ‹μ„ μ‚¬μš©ν•˜μ—¬ Redis ν‚€ 뢈일치 문제λ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€. μ£Όμ„μœΌλ‘œ λͺ…μ‹œμ μœΌλ‘œ μ„€λͺ…ν•œ 것도 μ’‹μŠ΅λ‹ˆλ‹€.

ν–₯ν›„ λ¦¬νŒ©ν† λ§ μ‹œ 고렀사항: 두 λͺ¨λ“ˆμ—μ„œ λ™μΌν•œ CacheKeyGenerator μ½”λ“œκ°€ μ€‘λ³΅λ˜κ³  μžˆμŠ΅λ‹ˆλ‹€. 곡톡 λͺ¨λ“ˆλ‘œ μΆ”μΆœν•˜λ©΄ μœ μ§€λ³΄μˆ˜μ„±μ΄ ν–₯상될 수 μžˆμŠ΅λ‹ˆλ‹€. λ‹€λ§Œ ν˜„μž¬ κ΅¬μ‘°μ—μ„œλŠ” λͺ¨λ“ˆ κ°„ μ˜μ‘΄μ„±μ„ 쀄이기 μœ„ν•΄ 쀑볡을 ν—ˆμš©ν•˜λŠ” 것도 합리적인 μ„ νƒμž…λ‹ˆλ‹€.

apps/commerce-streamer/src/main/java/com/loopers/config/RankingConfig.java (1)

14-15: μ„€μ • κ°’ 검증 μΆ”κ°€λ₯Ό ꢌμž₯ν•©λ‹ˆλ‹€.

weight의 각 κ°€μ€‘μΉ˜μ™€ ttlDays 값에 λŒ€ν•œ 검증이 μ—†μŠ΅λ‹ˆλ‹€. 잘λͺ»λœ μ„€μ • κ°’(음수, 0 λ“±)이 μ£Όμž…λ  경우 λŸ°νƒ€μž„ 였λ₯˜λ‚˜ μ˜ˆμƒμΉ˜ λͺ»ν•œ λ™μž‘μ΄ λ°œμƒν•  수 μžˆμŠ΅λ‹ˆλ‹€.

πŸ”Ž 검증 둜직 μΆ”κ°€ μ˜ˆμ‹œ
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Positive;
+
 @Getter
 @Setter
 @Configuration
 @ConfigurationProperties(prefix = "ranking")
+@Validated
 public class RankingConfig {
 
     private Weight weight = new Weight();
+    @Min(1)
     private int ttlDays = 2;
 
     @Getter
     @Setter
     public static class Weight {
+        @Positive
         private double view = 0.1;
+        @Positive
         private double like = 0.2;
+        @Positive
         private double order = 0.6;
     }
 }
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java (1)

105-135: NumberFormatException 처리λ₯Ό ν™•μΈν•˜μ„Έμš”.

Line 123μ—μ„œ Long.parseLong(tuple.getValue().toString())을 ν˜ΈμΆœν•  λ•Œ, 값이 μˆ«μžκ°€ μ•„λ‹ˆλ©΄ NumberFormatException이 λ°œμƒν•©λ‹ˆλ‹€. ν˜„μž¬ μ™ΈλΆ€ catch λΈ”λ‘μ—μ„œ μ²˜λ¦¬λ˜μ§€λ§Œ, 일뢀 ν•­λͺ©λ§Œ νŒŒμ‹± μ‹€νŒ¨ν•˜λŠ” 경우 전체 κ²°κ³Όκ°€ 빈 리슀트둜 λ°˜ν™˜λ©λ‹ˆλ‹€.

λΆ€λΆ„ μ‹€νŒ¨λ₯Ό ν—ˆμš©ν•˜λ €λ©΄ κ°œλ³„ ν•­λͺ© νŒŒμ‹±μ„ try-catch둜 κ°μ‹ΈλŠ” 것을 κ³ λ €ν•˜μ„Έμš”.

πŸ”Ž κ°œλ³„ ν•­λͺ© μ˜ˆμ™Έ 처리 μ˜ˆμ‹œ
             for (ZSetOperations.TypedTuple<Object> tuple : results) {
-                Long productId = Long.parseLong(tuple.getValue().toString());
-                Double score = tuple.getScore();
-                entries.add(new RankingEntry(rank++, productId, score));
+                try {
+                    Long productId = Long.parseLong(tuple.getValue().toString());
+                    Double score = tuple.getScore();
+                    entries.add(new RankingEntry(rank++, productId, score));
+                } catch (NumberFormatException e) {
+                    log.warn("[Ranking] Invalid productId format - value: {}", tuple.getValue());
+                    rank++; // μˆœμœ„λŠ” μ¦κ°€μ‹œμΌœ 일관성 μœ μ§€
+                }
             }
πŸ“œ Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between d2b809f and bd05e11.

πŸ“’ Files selected for processing (15)
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/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/RankingV1Dto.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java
  • apps/commerce-streamer/src/main/java/com/loopers/config/RankingConfig.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java
  • apps/commerce-streamer/src/main/resources/application.yml
🧰 Additional context used
🧠 Learnings (6)
πŸ““ Common learnings
Learnt from: junoade
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 0
File: :0-0
Timestamp: 2025-12-02T08:12:06.383Z
Learning: ProductQueryServiceμ—μ„œ μƒν’ˆ λͺ©λ‘ 쑰회 μ‹œ Redis μΊμ‹œλ₯Ό μ μš©ν–ˆμœΌλ©°, μΊμ‹œ ν‚€λŠ” brandId, sortType, pageNumber, pageSize의 μ‘°ν•©μœΌλ‘œ κ΅¬μ„±λ˜κ³  TTL은 5λΆ„μœΌλ‘œ μ„€μ •λ˜μ–΄ μžˆλ‹€.
πŸ“š Learning: 2025-11-27T09:09:24.961Z
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product μ—”ν‹°ν‹° (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)λŠ” μœ μ¦ˆμΌ€μ΄μŠ€λ³„λ‘œ μ˜λ„μ μœΌλ‘œ λ‹€λ₯Έ 락 μ „λž΅μ„ μ‚¬μš©ν•œλ‹€: μ’‹μ•„μš” κΈ°λŠ₯μ—λŠ” 비관적 락(findByIdForUpdate)을, 재고 μ°¨κ°μ—λŠ” 낙관적 락(Version + μž¬μ‹œλ„)을 μ‚¬μš©ν•œλ‹€.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
πŸ“š Learning: 2025-12-19T20:59:57.713Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: docs/week8/round8-detailed-design.md:151-178
Timestamp: 2025-12-19T20:59:57.713Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository's Kafka event pipeline, only 5 domain events are intentionally published to Kafka via CloudEventEnvelopeFactory: OrderPaidEventV1, LikeCreatedEventV1, LikeCanceledEventV1, ProductViewedEventV1, and StockDepletedEventV1. Other domain events (OrderCreatedEventV1, OrderCanceledEventV1, PaymentCreatedEventV1, PaymentPaidEventV1, PaymentFailedEventV1) are internal-only and intentionally not mapped in resolveMetadata(), which correctly returns null for them to exclude them from Outbox publication.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java
πŸ“š Learning: 2025-12-19T21:30:16.024Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/outbox/OutboxEventListener.kt:0-0
Timestamp: 2025-12-19T21:30:16.024Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template Kafka event pipeline, Like events (LikeCreatedEventV1, LikeCanceledEventV1) intentionally use aggregateType="Like" with aggregateId=productId. The aggregateId serves as a partitioning/grouping key (not a unique Like entity identifier), ensuring all like events for the same product go to the same partition for ordering guarantees and aligning with ProductStatisticService's product-based aggregation logic. Using individual like_id would scatter events across partitions and break the statistics aggregation pattern.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
πŸ“š Learning: 2025-12-02T08:12:06.383Z
Learnt from: junoade
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 0
File: :0-0
Timestamp: 2025-12-02T08:12:06.383Z
Learning: ProductQueryServiceμ—μ„œ μƒν’ˆ λͺ©λ‘ 쑰회 μ‹œ Redis μΊμ‹œλ₯Ό μ μš©ν–ˆμœΌλ©°, μΊμ‹œ ν‚€λŠ” brandId, sortType, pageNumber, pageSize의 μ‘°ν•©μœΌλ‘œ κ΅¬μ„±λ˜κ³  TTL은 5λΆ„μœΌλ‘œ μ„€μ •λ˜μ–΄ μžˆλ‹€.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
πŸ“š Learning: 2025-11-09T10:41:39.297Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 25
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductRepository.kt:1-12
Timestamp: 2025-11-09T10:41:39.297Z
Learning: In this codebase, domain repository interfaces are allowed to use Spring Data's org.springframework.data.domain.Page and org.springframework.data.domain.Pageable types. This is an accepted architectural decision and should not be flagged as a DIP violation.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
🧬 Code graph analysis (4)
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java (1)
  • CacheKeyGenerator (5-59)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java (1)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java (1)
  • CacheKeyGenerator (7-19)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (2)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java (1)
  • RankingInfo (7-40)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java (1)
  • RankingV1Dto (9-57)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java (1)
  • RankingInfo (7-40)
πŸ”‡ Additional comments (11)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java (1)

17-17: μƒˆλ‘œμš΄ ν•„λ“œ μΆ”κ°€κ°€ μ μ ˆν•©λ‹ˆλ‹€.

brandId와 ranking ν•„λ“œκ°€ λͺ…ν™•ν•˜κ²Œ μΆ”κ°€λ˜μ—ˆμœΌλ©°, νƒ€μž…κ³Ό 주석이 μ μ ˆν•©λ‹ˆλ‹€. ranking이 μˆœμœ„κΆŒ 밖일 λ•Œ null을 λ°˜ν™˜ν•˜λŠ” 섀계도 ν•©λ¦¬μ μž…λ‹ˆλ‹€.

Also applies to: 24-24, 29-29, 39-40

apps/commerce-streamer/src/main/resources/application.yml (1)

32-37: λž­ν‚Ή 섀정이 λͺ…ν™•ν•˜κ²Œ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

κ°€μ€‘μΉ˜μ™€ TTL 섀정이 ν•©λ¦¬μ μž…λ‹ˆλ‹€. μ£Όλ¬Έ μ΄λ²€νŠΈμ— κ°€μž₯ 높은 κ°€μ€‘μΉ˜(0.6)λ₯Ό λΆ€μ—¬ν•œ 것은 λΉ„μ¦ˆλ‹ˆμŠ€ λ‘œμ§μƒ μ μ ˆν•©λ‹ˆλ‹€.

κ°€μ€‘μΉ˜ 합계가 0.9둜 μ˜λ„μ μœΌλ‘œ 1.0이 μ•„λ‹Œμ§€ ν™•μΈν•˜μ„Έμš”. 일반적으둜 μ •κ·œν™”λœ κ°€μ€‘μΉ˜(합계 1.0)λ₯Ό μ‚¬μš©ν•˜λŠ” 것이 점수 해석에 더 λͺ…ν™•ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)

42-43: DTO에 λž­ν‚Ή ν•„λ“œκ°€ μ˜¬λ°”λ₯΄κ²Œ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

ranking ν•„λ“œκ°€ λ ˆμ½”λ“œ 끝에 μΆ”κ°€λ˜μ–΄ κΈ°μ‘΄ ν•„λ“œ μˆœμ„œλ₯Ό μœ μ§€ν•˜λ©°, from() λ©”μ„œλ“œμ—μ„œ μ˜¬λ°”λ₯΄κ²Œ λ§€ν•‘λ©λ‹ˆλ‹€.

Also applies to: 55-56

apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java (1)

54-54: ν•„λ“œ μΆ”κ°€κ°€ μ μ ˆν•˜λ©° 관심사 뢄리가 λͺ…ν™•ν•©λ‹ˆλ‹€.

brandIdλ₯Ό μ—”ν‹°ν‹°μ—μ„œ μΆ”μΆœν•˜κ³ , ranking을 null둜 μ΄ˆκΈ°ν™”ν•˜λŠ” 것이 ν•©λ¦¬μ μž…λ‹ˆλ‹€. μ£Όμ„μ—μ„œ μ„€λͺ…ν•œ λŒ€λ‘œ Facade κ³„μΈ΅μ—μ„œ μ‹€μ‹œκ°„ λž­ν‚Ήμ„ μ‘°νšŒν•˜μ—¬ κ΅μ²΄ν•˜λŠ” 섀계가 λͺ…ν™•ν•©λ‹ˆλ‹€.

Also applies to: 61-61

apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (3)

10-10: ProductRankingCache μ˜μ‘΄μ„± μΆ”κ°€κ°€ μ μ ˆν•©λ‹ˆλ‹€.

μƒˆλ‘œμš΄ λž­ν‚Ή μΊμ‹œλ₯Ό 톡해 μ‹€μ‹œκ°„ λž­ν‚Ή 데이터λ₯Ό μ‘°νšŒν•  수 μžˆλ„λ‘ κΉ”λ”ν•˜κ²Œ ν†΅ν•©λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

Also applies to: 31-31


124-130: 이벀트 λ°œν–‰ μ΅œμ ν™”κ°€ νš¨κ³Όμ μž…λ‹ˆλ‹€.

μΊμ‹œλœ brandIdλ₯Ό μ‚¬μš©ν•˜μ—¬ 이벀트λ₯Ό λ°œν–‰ν•¨μœΌλ‘œμ¨ μΆ”κ°€ DB 쑰회λ₯Ό λ°©μ§€ν•œ 것이 쒋은 κ°œμ„ μž…λ‹ˆλ‹€.


102-121: μ œν’ˆ 상세 정보 쑰회 흐름이 잘 κ°œμ„ λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

μΊμ‹œλœ 정보λ₯Ό μž¬μ‚¬μš©ν•˜κ³  동적 데이터(isLikedByMember, ranking)만 μ‹€μ‹œκ°„μœΌλ‘œ μ‘°νšŒν•˜λŠ” 섀계가 νš¨μœ¨μ μž…λ‹ˆλ‹€. brandIdλ₯Ό μΊμ‹œμ—μ„œ 가져와 λΆˆν•„μš”ν•œ DB 쑰회λ₯Ό μ œκ±°ν•œ 것도 쒋은 μ΅œμ ν™”μž…λ‹ˆλ‹€. ProductRankingCache.getRank()λŠ” μˆœμœ„κΆŒ λ°–μ˜ μƒν’ˆμ— λŒ€ν•΄ null을 μ˜¬λ°”λ₯΄κ²Œ λ°˜ν™˜ν•˜λ―€λ‘œ, ProductDetailInfo의 ranking ν•„λ“œκ°€ null일 λ•Œμ˜ ν΄λΌμ΄μ–ΈνŠΈ 처리만 ν™•μΈν•˜λ©΄ λ©λ‹ˆλ‹€.

apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java (1)

98-139: 배치 처리 둜직이 잘 κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

μ£Όλ¬Έ μ•„μ΄ν…œμ˜ λž­ν‚Ή 점수λ₯Ό 배치둜 μ²˜λ¦¬ν•˜μ—¬ Redis λ„€νŠΈμ›Œν¬ μ˜€λ²„ν—€λ“œλ₯Ό 쀄인 것은 쒋은 μ ‘κ·Όμž…λ‹ˆλ‹€. addOrderScoresBatch의 fallback λ‘œμ§λ„ κ²¬κ³ ν•©λ‹ˆλ‹€.

apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)

56-91: N+1 문제 λ°©μ§€κ°€ 잘 κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

μƒν’ˆκ³Ό λΈŒλžœλ“œ 정보λ₯Ό 일괄 μ‘°νšŒν•˜μ—¬ N+1 문제λ₯Ό 효과적으둜 λ°©μ§€ν–ˆμœΌλ©°, λˆ„λ½λœ 데이터에 λŒ€ν•œ null μ²˜λ¦¬λ„ μ μ ˆν•©λ‹ˆλ‹€. 특히 λΈŒλžœλ“œκ°€ 없을 λ•Œ "Unknown" 폴백을 μ œκ³΅ν•˜λŠ” 것은 μ‚¬μš©μž κ²½ν—˜ μΈ‘λ©΄μ—μ„œ 쒋은 μ„ νƒμž…λ‹ˆλ‹€.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java (2)

86-95: 둜그 μ •κ·œν™”λ₯Ό ν†΅ν•œ 가격 편ν–₯ λ°©μ§€κ°€ 잘 κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

Math.log1p(orderAmount)λ₯Ό μ‚¬μš©ν•˜μ—¬ κ³ κ°€ μƒν’ˆμ˜ κ³Όλ„ν•œ 점수 νŽΈμ€‘μ„ λ°©μ§€ν•œ 것은 ν›Œλ₯­ν•œ μ„€κ³„μž…λ‹ˆλ‹€. 둜그 μŠ€μΌ€μΌ λ³€ν™˜μœΌλ‘œ κ³΅μ •ν•œ λž­ν‚Ή 반영이 κ°€λŠ₯ν•©λ‹ˆλ‹€.


103-137: Pipeline 배치 μ²˜λ¦¬μ™€ fallback μ „λž΅μ΄ κ²¬κ³ ν•©λ‹ˆλ‹€.

Redis Pipeline을 μ‚¬μš©ν•œ 배치 처리둜 λ„€νŠΈμ›Œν¬ μ˜€λ²„ν—€λ“œλ₯Ό μ€„μ˜€μœΌλ©°, μ‹€νŒ¨ μ‹œ κ°œλ³„ 처리둜 ν΄λ°±ν•˜λŠ” μ „λž΅μ΄ ν›Œλ₯­ν•©λ‹ˆλ‹€. μ΄λŠ” μ‹ λ’°μ„±κ³Ό μ„±λŠ₯을 λͺ¨λ‘ κ³ λ €ν•œ κ΅¬ν˜„μž…λ‹ˆλ‹€.

- RankingFacadeTest, ProductRankingCacheTest, RankingV1ApiE2ETest μΆ”κ°€
- MetricsAggregationServiceRankingTest, ProductRankingCacheTest μΆ”κ°€
- MetricsAggregationServiceIdempotencyTest에 MockBean μΆ”κ°€
- 잘λͺ»λœ λ‚ μ§œ ν˜•μ‹ λ“± IllegalArgumentException 400 처리
- @Min/@max 검증 μ‹€νŒ¨ μ‹œ ConstraintViolationException 400 처리
- LikeServiceIntegrationTest: LikeFacadeλ₯Ό 톡해 λ©±λ“±μ„± 보μž₯
- DlqPublisherTest: RedisTestContainersConfig μΆ”κ°€
- commerce-streamer CacheConfig μΆ”κ°€ (RedisTemplate<String, Object>)
- ProductFacadeTest: λž­ν‚Ή 포함/미포함 λ‹¨μœ„ ν…ŒμŠ€νŠΈ
- ProductV1ApiE2ETest: λž­ν‚Ή 정보 E2E ν…ŒμŠ€νŠΈ
@looper88888
Copy link
Contributor

πŸ“ˆ Ranking Consumer

  • λž­ν‚Ή ZSET 의 TTL, ν‚€ μ „λž΅μ„ μ μ ˆν•˜κ²Œ κ΅¬μ„±ν•˜μ˜€λ‹€
  • λ‚ μ§œλ³„λ‘œ μ μž¬ν•  ν‚€λ₯Ό κ³„μ‚°ν•˜λŠ” κΈ°λŠ₯을 λ§Œλ“€μ—ˆλ‹€
  • μ΄λ²€νŠΈκ°€ λ°œμƒν•œ ν›„, ZSET 에 μ μˆ˜κ°€ μ μ ˆν•˜κ²Œ λ°˜μ˜λœλ‹€

⚾ Ranking API

  • λž­ν‚Ή Page 쑰회 μ‹œ μ •μƒμ μœΌλ‘œ λž­ν‚Ή 정보가 λ°˜ν™˜λœλ‹€
  • λž­ν‚Ή Page 쑰회 μ‹œ λ‹¨μˆœνžˆ μƒν’ˆ ID κ°€ μ•„λ‹Œ μƒν’ˆμ •λ³΄κ°€ Aggregation λ˜μ–΄ μ œκ³΅λœλ‹€
  • μƒν’ˆ 상세 쑰회 μ‹œ ν•΄λ‹Ή μƒν’ˆμ˜ μˆœμœ„κ°€ ν•¨κ»˜ λ°˜ν™˜λœλ‹€ (μˆœμœ„μ— μ—†λ‹€λ©΄ null)

@coderabbitai 체크리슀트 확인해

@coderabbitai
Copy link

coderabbitai bot commented Dec 27, 2025

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check RankingConfig for TTL and weight configuration
echo "=== RankingConfig.java ==="
cat apps/commerce-streamer/src/main/java/com/loopers/config/RankingConfig.java

echo -e "\n=== CacheKeyGenerator (commerce-streamer) ==="
cat apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java

echo -e "\n=== CacheKeyGenerator (commerce-api) ==="
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java

echo -e "\n=== ProductRankingCache (commerce-streamer) - TTL and score methods ==="
cat apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java

Length of output: 9579


🏁 Script executed:

#!/bin/bash
# Check MetricsAggregationService to verify event score updates
echo "=== MetricsAggregationService.java - Event handling ==="
cat apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java

echo -e "\n=== RankingV1Controller.java ==="
cat apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java

echo -e "\n=== RankingFacade.java ==="
cat apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java

Length of output: 11864


🏁 Script executed:

#!/bin/bash
# Check ProductFacade for product detail ranking integration
echo "=== ProductFacade.java ==="
cat apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java

echo -e "\n=== ProductRankingCache (commerce-api) ==="
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductRankingCache.java

echo -e "\n=== application.yml (ranking config) ==="
grep -A 10 "^ranking:" apps/commerce-streamer/src/main/resources/application.yml

Length of output: 10379


@looper88888 체크리슀트λ₯Ό ν™•μΈν–ˆμŠ΅λ‹ˆλ‹€! λͺ¨λ“  ν•­λͺ©μ΄ μ μ ˆν•˜κ²Œ κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€. βœ…

πŸ“ˆ Ranking Consumer

βœ… λž­ν‚Ή ZSET 의 TTL, ν‚€ μ „λž΅μ„ μ μ ˆν•˜κ²Œ κ΅¬μ„±ν•˜μ˜€λ‹€

TTL μ„€μ •:

  • RankingConfig: κΈ°λ³Έ TTL 2일 μ„€μ • (ttlDays = 2)
  • ProductRankingCache (streamer): AtomicReferenceλ₯Ό μ‚¬μš©ν•œ CAS λ°©μ‹μœΌλ‘œ Race Condition λ°©μ§€
private void ensureTtl(String key) {
    String oldKey = ttlInitializedKey.getAndSet(key);
    if (!key.equals(oldKey)) {
        cacheRedisTemplate.expire(key, rankingConfig.getTtlDays(), TimeUnit.DAYS);
        log.info("[Ranking] TTL set for key: {} ({} days)", key, rankingConfig.getTtlDays());
    }
}

ν‚€ μ „λž΅:

  • ν˜•μ‹: ranking:all:v1:{yyyyMMdd} (버전 관리 포함)
  • CacheKeyGeneratorκ°€ commerce-api와 commerce-streamerμ—μ„œ λ™μΌν•œ ν‚€ 생성 둜직 μ‚¬μš©
  • Asia/Seoul νƒ€μž„μ‘΄ κΈ°μ€€μœΌλ‘œ λ‚ μ§œ 계산

βœ… λ‚ μ§œλ³„λ‘œ μ μž¬ν•  ν‚€λ₯Ό κ³„μ‚°ν•˜λŠ” κΈ°λŠ₯을 λ§Œλ“€μ—ˆλ‹€

CacheKeyGenerator (μ–‘μͺ½ λͺ¨λ“ˆμ— λ™μΌν•˜κ²Œ κ΅¬ν˜„):

public static String dailyRankingKey(String date) {
    return String.format("ranking:all:%s:%s", VERSION, date);
}

λ‚ μ§œ 계산:

private String getTodayKey() {
    String date = LocalDate.now(ZONE_ID).format(DATE_FORMATTER);
    return CacheKeyGenerator.dailyRankingKey(date);
}

βœ… μ΄λ²€νŠΈκ°€ λ°œμƒν•œ ν›„, ZSET 에 μ μˆ˜κ°€ μ μ ˆν•˜κ²Œ λ°˜μ˜λœλ‹€

MetricsAggregationServiceμ—μ„œ λͺ¨λ“  이벀트 νƒ€μž…μ— λŒ€ν•΄ λž­ν‚Ή 점수 μ—…λ°μ΄νŠΈ:

  1. μƒν’ˆ 쑰회: addViewScore(productId) - κ°€μ€‘μΉ˜ 0.1
  2. μ’‹μ•„μš”: addLikeScore(productId) - κ°€μ€‘μΉ˜ 0.2
  3. μ’‹μ•„μš” μ·¨μ†Œ: subtractLikeScore(productId) - κ°€μ€‘μΉ˜ -0.2 (일관성 μœ μ§€)
  4. μ£Όλ¬Έ μ™„λ£Œ: addOrderScoresBatch(orderItems) - κ°€μ€‘μΉ˜ 0.6, Pipeline 배치 처리

μ£Όλ¬Έ 점수 계산 (둜그 μ •κ·œν™”):

double score = weight * Math.log1p(orderAmount);
// 예: 100,000원 β†’ log1p(100000) β‰ˆ 11.5, 1,000원 β†’ log1p(1000) β‰ˆ 6.9

Pipeline 배치 처리:

cacheRedisTemplate.executePipelined(new SessionCallback<>() {
    @Override
    public Object execute(RedisOperations operations) throws DataAccessException {
        for (OrderItemScore item : orderItems) {
            long orderAmount = item.price() * item.quantity();
            double score = weight * Math.log1p(orderAmount);
            operations.opsForZSet().incrementScore(key, item.productId().toString(), score);
        }
        return null;
    }
});

⚾ Ranking API

βœ… λž­ν‚Ή Page 쑰회 μ‹œ μ •μƒμ μœΌλ‘œ λž­ν‚Ή 정보가 λ°˜ν™˜λœλ‹€

RankingV1Controller:

@GetMapping("/api/v1/rankings")
public ApiResponse<RankingPageResponse> getRankings(
        @RequestParam(value = "date", required = false) String date,
        @RequestParam(value = "page", defaultValue = "1") @Min(1) int page,
        @RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(100) int size
) {
    // APIλŠ” 1-based, λ‚΄λΆ€λŠ” 0-based둜 λ³€ν™˜
    int zeroBasedPage = Math.max(0, page - 1);
    RankingPageInfo info = rankingFacade.getRankings(date, zeroBasedPage, size);
    RankingPageResponse response = RankingPageResponse.from(info);
    return ApiResponse.success(response);
}

ProductRankingCache.getTopRankings():

  • Redis ZSET의 reverseRangeWithScores μ‚¬μš©
  • νŽ˜μ΄μ§€λ„€μ΄μ…˜ 지원 (0-based)
  • 1-based μˆœμœ„ 계산

βœ… λž­ν‚Ή Page 쑰회 μ‹œ λ‹¨μˆœνžˆ μƒν’ˆ ID κ°€ μ•„λ‹Œ μƒν’ˆμ •λ³΄κ°€ Aggregation λ˜μ–΄ μ œκ³΅λœλ‹€

RankingFacade.getRankings():

  1. ZSETμ—μ„œ λž­ν‚Ή μ—”νŠΈλ¦¬ 쑰회
  2. productRepository.findByIdIn(productIds) - μƒν’ˆ 정보 일괄 쑰회
  3. brandRepository.findByIdIn(brandIds) - λΈŒλžœλ“œ 정보 일괄 쑰회 (N+1 λ°©μ§€)
  4. RankingItemInfo 생성:
    • rank (μˆœμœ„)
    • productId, productName, price, likeCount
    • brandName (λΈŒλžœλ“œ μ—†μœΌλ©΄ "Unknown")
    • score (λž­ν‚Ή 점수)
return new RankingItemInfo(
    entry.rank(),
    product.getId(),
    product.getName(),
    brandName,
    product.getPrice(),
    product.getLikeCount(),
    entry.score()
);

βœ… μƒν’ˆ 상세 쑰회 μ‹œ ν•΄λ‹Ή μƒν’ˆμ˜ μˆœμœ„κ°€ ν•¨κ»˜ λ°˜ν™˜λœλ‹€ (μˆœμœ„μ— μ—†λ‹€λ©΄ null)

ProductFacade.getProductDetail():

// 3. μˆœμœ„ 쑰회 (μ‹€μ‹œκ°„)
Integer ranking = productRankingCache.getRank(productId);

// 4. 동적 ν•„λ“œ(ranking)λ₯Ό ν¬ν•¨ν•΄μ„œ λ°˜ν™˜
ProductDetailInfo result = ProductDetailInfo.builder()
    // ... other fields
    .ranking(ranking)  // μˆœμœ„κΆŒ 밖이면 null
    .build();

ProductRankingCache.getRank():

public Integer getRank(Long productId) {
    Long rank = cacheRedisTemplate.opsForZSet().reverseRank(key, productId.toString());
    
    if (rank == null) {
        log.debug("[Ranking] Product not in ranking - productId: {}", productId);
        return null;  // μˆœμœ„μ— μ—†μœΌλ©΄ null λ°˜ν™˜
    }
    
    // 0-based β†’ 1-based λ³€ν™˜
    return rank.intValue() + 1;
}

ProductDetailResponse DTO:

public record ProductDetailResponse(
    // ... other fields
    Integer ranking  // μˆœμœ„ ν•„λ“œ μΆ”κ°€ (null κ°€λŠ₯)
) {
    public static ProductDetailResponse from(ProductDetailInfo info) {
        return new ProductDetailResponse(
            // ... other fields
            info.getRanking()
        );
    }
}

λͺ¨λ“  체크리슀트 ν•­λͺ©μ΄ μ™„λ²½ν•˜κ²Œ κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€! πŸ‘

μ£Όμš” 섀계 포인트:

  • πŸ”’ TTL Race Condition λ°©μ§€ (AtomicReference CAS)
  • πŸš€ μ£Όλ¬Έ 이벀트 배치 처리 (Redis Pipeline)
  • πŸ“Š 둜그 μ •κ·œν™”λ‘œ κ³ κ°€ μƒν’ˆ νŽΈμ€‘ λ°©μ§€
  • πŸ”„ μ’‹μ•„μš” μ·¨μ†Œ μ‹œ 점수 κ°μ†Œ (일관성 μš°μ„ )
  • 🎯 N+1 쿼리 λ°©μ§€ (λΈŒλžœλ“œ/μƒν’ˆ 일괄 쑰회)
  • 🌏 Asia/Seoul νƒ€μž„μ‘΄ κΈ°μ€€ 일관성
  • πŸ”‘ μ–‘μͺ½ λͺ¨λ“ˆ ν‚€ 생성 둜직 톡일
    /\___/\
   (  o.o  )
    > ^ <   
   /|     |\
  (_|     |_)

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.

2 participants