Skip to content

Conversation

@yeonsu00
Copy link
Collaborator

@yeonsu00 yeonsu00 commented Dec 26, 2025

πŸ“Œ Summary

  • μ»¨μŠˆλ¨Έμ—μ„œ μƒν’ˆ 일별 λž­ν‚Ή 집계
    • Redis ZSET TTL, ν‚€ μ „λž΅ ꡬ성
    • μ½œλ“œ μŠ€νƒ€νŠΈ 문제 ν•΄κ²°
  • λž­ν‚Ή Page 쑰회
  • μƒν’ˆ 상세 쑰회 μ‹œ λž­ν‚Ή λ°˜ν™˜

πŸ’¬ Review Points

1. product_metrics의 μš©λ„

ν˜„μž¬ κ΅¬ν˜„μ—μ„œλŠ” Kafka 이벀트(μ’‹μ•„μš” 등둝, 쑰회수, μ£Όλ¬Έ 생성)λ₯Ό μˆ˜μ‹ ν•˜λ©΄

  • Redis ZSET에 μ¦‰μ‹œ 점수λ₯Ό λ°˜μ˜ν•˜κ³ 
  • product_metrics ν…Œμ΄λΈ”μ—λ„ μ—…λ°μ΄νŠΈν•©λ‹ˆλ‹€.

데이터 흐름:

Kafka 이벀트 λ°œμƒ (예: ProductLiked)
    ↓
MetricsConsumer.handleProductLikedEvents()
    ↓
    β”œβ”€β†’ rankingService.incrementScore(productId, LIKE)
    β”‚   └─→ Redis ZSET에 점수 μΆ”κ°€ 
    β”‚
    └─→ productMetricsService.incrementLikeCount(productId)
        └─→ ProductMetrics ν…Œμ΄λΈ”: productId의 likeCount 컬럼 +1
            (λž­ν‚Ή 계산과 무관)

prodcut_metricsλŠ” ν˜„μž¬ λˆ„μ  λ°μ΄ν„°λ§Œ μ €μž₯ν•˜λŠ” ꡬ쑰인데, 일별 λž­ν‚Ήμ„ μœ„ν•œ 일별 λ³€ν™”λŸ‰μ„ μΆ”μ ν•˜λ €λ©΄ product_metrics ν…Œμ΄λΈ”μ— μ €μž₯ λ‚ μ§œ μ»¬λŸΌμ„ μΆ”κ°€ν•΄μ•Ό ν• κΉŒμš”?


2. 동일 점수 처리 방식

λ ˆλ””μŠ€ zsetμ—μ„œ μ μˆ˜κ°€ λ™μΌν•˜λ©΄ λ©€λ²„μ˜ 사전식 μˆœμ„œλ‘œ μ •λ ¬λ˜λŠ” 것 κ°™μŠ΅λ‹ˆλ‹€.

Redis ZSET μƒνƒœ:
- μƒν’ˆ 1: 10점
- μƒν’ˆ 2: 10점  (동일 점수)
- μƒν’ˆ 3: 5점

ν˜„μž¬ λ™μž‘:
1μœ„: μƒν’ˆ 1 (10점, rank=1)
2μœ„: μƒν’ˆ 2 (10점, rank=2)  ← 동일 μ μˆ˜μΈλ°λ„ λ‹€λ₯Έ μˆœμœ„
3μœ„: μƒν’ˆ 3 (5점, rank=3)

μ‹€λ¬΄μ—μ„œλŠ” 동일 점수인 경우 μ–΄λ–€ λΉ„μ¦ˆλ‹ˆμŠ€ 둜직으둜 μ²˜λ¦¬ν•˜λŠ” 것이 μΌλ°˜μ μΈκ°€μš”?


3. 비동기 μ½œλ°±μ—μ„œ νŠΈλžœμž­μ…˜ 관리

μ§€λ‚œ μ£Ό 과제 λ¦¬νŒ©ν† λ§μ„ ν•˜λ©΄μ„œ OutService의 이벀트 λ°œν–‰ λ‘œμ§μ„ 비동기 λ°©μ‹μ—μ„œ 동기 λ°©μ‹μœΌλ‘œ λ³€κ²½ν–ˆμŠ΅λ‹ˆλ‹€.

λ³€κ²½ μ „:

public void publishPendingOutboxes(int batchSize) {
    List<Outbox> pendingOutboxes = outboxRepository.findPendingOutboxes(batchSize);
    
    for (Outbox outbox : pendingOutboxes) {
        kafkaTemplate
                .send(
                        outbox.getTopic(),
                        outbox.getPartitionKey(),
                        outbox.getPayload()
                )
                .whenComplete((result, exception) -> {
                    // 비동기 μ½œλ°±μ—μ„œ DB μ—…λ°μ΄νŠΈ μ‹œλ„
                    if (exception == null) {
                        outboxRepository.markAsPublished(outbox.getId());
                    } else {
                        outboxRepository.markAsFailed(outbox.getId(), exception.getMessage());
                    }
                });
    }
}

λ³€κ²½ ν›„:

public void publishPendingOutboxes(int batchSize) {
        List<Outbox> pendingOutboxes = outboxRepository.findPendingOutboxes(batchSize);
        
        for (Outbox outbox : pendingOutboxes) {
            try {
                // .get()으둜 동기 처리둜 λ³€κ²½
                kafkaTemplate.send(
                    outbox.getTopic(),
                    outbox.getPartitionKey(),
                    outbox.getPayload()
                ).get();  // CompletableFuture.get() - λΈ”λ‘œν‚Ή 호좜둜 λ³€κ²½
                
                outboxRepository.markAsPublished(outbox.getId());
            } catch (Exception e) {
                outboxRepository.markAsFailed(outbox.getId(), e.getMessage());
            }
        }
    }

whenComplete μ½œλ°±μ€ λΉ„λ™κΈ°λ‘œ μ‹€ν–‰λ˜λ―€λ‘œ outboxRepository.markAsPublished()와 markAsFailed() 호좜이 νŠΈλžœμž­μ…˜ λ°–μ—μ„œ 싀행될 수 μžˆμ–΄μ„œ, 동기 λ°©μ‹μœΌλ‘œ λ³€κ²½ν•΄μ€¬μŠ΅λ‹ˆλ‹€.

비동기 방식을 계속 μ‚¬μš©ν•˜λ €λ©΄ νŠΈλžœμž­μ…˜ 관리λ₯Ό ν•΄μ€˜μ•Ό ν•˜λŠ”λ°, 같은 클래슀 λ‚΄μ—μ„œ νŠΈλžœμž­μ…˜ 관리λ₯Ό μœ„ν•΄ λ³„λ„μ˜ Managerλ₯Ό μ‚¬μš©ν•˜λŠ” 방식이 μ‹€λ¬΄μ—μ„œ 많이 μ‚¬μš©ν•˜λŠ” λ°©μ‹μΈκ°€μš”? μ•„λ‹ˆλ©΄ μ§€μ–‘ν•˜λŠ” λ°©μ‹μΈκ°€μš”?


βœ… Checklist

πŸ“ˆ Ranking Consumer

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

⚾ Ranking API

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

Summary by CodeRabbit

릴리슀 λ…ΈνŠΈ

  • μƒˆλ‘œμš΄ κΈ°λŠ₯
    • μ œν’ˆμ— μˆœμœ„ 정보 좔가됨
    • 일일 μ œν’ˆ μˆœμœ„ 쑰회 API μΆ”κ°€ (λ‚ μ§œ, νŽ˜μ΄μ§€λ„€μ΄μ…˜ 지원)
    • μ£Όλ¬Έ μ‹œ μƒν’ˆ 상세 정보가 ν•¨κ»˜ 기둝됨
    • Redis 기반 μˆœμœ„ 캐싱 μ‹œμŠ€ν…œ κ΅¬ν˜„μœΌλ‘œ λΉ λ₯Έ μˆœμœ„ 쑰회 제곡

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

@coderabbitai
Copy link

coderabbitai bot commented Dec 26, 2025

Walkthrough

μˆœμœ„ 관리 κΈ°λŠ₯을 μΆ”κ°€ν•˜μ—¬ Redis μΊμ‹œ 기반의 일일 μ œν’ˆ μˆœμœ„ 쑰회 및 갱신을 μ§€μ›ν•©λ‹ˆλ‹€. μ œν’ˆ 쑰회 μ‹œ μˆœμœ„ 정보λ₯Ό ν¬ν•¨ν•˜λ„λ‘ ν†΅ν•©ν•˜κ³ , 이벀트 기반 점수 λˆ„μ  및 μžλ™ 이월 κΈ°λŠ₯을 μ œκ³΅ν•©λ‹ˆλ‹€.

Changes

Cohort / File(s) Summary
Ranking Domain Layer
apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java, RankingItem.java, RankingService.java
μˆœμœ„ κ΄€λ ¨ 도메인 λͺ¨λΈ(Ranking, RankingItem) 및 μ„œλΉ„μŠ€ 클래슀 μ‹ κ·œ μΆ”κ°€. RankingServiceλŠ” νŽ˜μ΄μ§•μ„ μ μš©ν•˜μ—¬ λ‚ μ§œλ³„ μˆœμœ„ λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€.
Ranking Cache Infrastructure
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java
Redis ZSET을 ν™œμš©ν•˜μ—¬ μˆœμœ„ 데이터λ₯Ό μ €μž₯/μ‘°νšŒν•˜λŠ” μΊμ‹œ μ„œλΉ„μŠ€ μ‹ κ·œ μΆ”κ°€. getRankingRange 및 getProductRank λ©”μ„œλ“œ 제곡.
Ranking Application Layer
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java, RankingInfo.java, RankingFacade.java
μˆœμœ„ 쑰회 λͺ…λ Ή(GetDailyRankingCommand), 응닡(RankingInfo), νŒŒμ‚¬λ“œ(RankingFacade) 클래슀 μ‹ κ·œ μΆ”κ°€.
Ranking API Layer
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java, RankingV1Controller.java, RankingV1Dto.java
REST API μ—”λ“œν¬μΈνŠΈ(/api/v1/rankings) 및 DTO μ‹ κ·œ μΆ”κ°€. OpenAPI λͺ…μ„Έ μ •μ˜ 및 컨트둀러 κ΅¬ν˜„.
Product Integration
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java, ProductInfo.java
ProductInfo에 μˆœμœ„ ν•„λ“œ μΆ”κ°€, ProductFacadeμ—μ„œ RankingCacheServiceλ₯Ό 톡해 μˆœμœ„ 쑰회. ProductRepositoryImpl, ProductJpaRepository에 findProductsByIds λ©”μ„œλ“œ μΆ”κ°€.
Product DTO Update
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java
ProductResponse에 μˆœμœ„ ν•„λ“œ μΆ”κ°€ 및 νŒ©ν† λ¦¬ λ©”μ„œλ“œ μ—…λ°μ΄νŠΈ.
Order Event Enhancements
apps/commerce-api/src/main/java/com/loopers/application/order/OrderEvent.java, KafkaOutboxEventListener.java
OrderCreated λ ˆμ½”λ“œμ— orderItems ν•„λ“œ μΆ”κ°€. KafkaOutboxEventListenerμ—μ„œ 이벀트 λ§€ν•‘ μ‹œ orderItems 전달.
Kafka Event Update
modules/kafka/src/main/java/com/loopers/application/kafka/KafkaEvent.java
KafkaEvent.OrderEvent.OrderCreated에 orderItems ν•„λ“œ μΆ”κ°€ 및 νŒ©ν† λ¦¬ λ©”μ„œλ“œ μ—…λ°μ΄νŠΈ.
Brand Domain
apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
무인자 μƒμ„±μž 및 λΉŒλ” 기반 μƒμ„±μž, createBrand νŒ©ν† λ¦¬ λ©”μ„œλ“œ μΆ”κ°€.
Commerce Streamer Ranking Service
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java, RankingWeight.java
Redis 기반 μˆœμœ„ 점수 증가 및 이월 κΈ°λŠ₯ 제곡. RankingWeight μ—΄κ±°ν˜•μœΌλ‘œ κ°€μ€‘μΉ˜ 관리.
Commerce Streamer Integration
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java, scheduler/RankingScoreCarryOverScheduler.java, CommerceStreamerApplication.java
이벀트 ν•Έλ“€λŸ¬μ—μ„œ μˆœμœ„ 점수 κ°±μ‹ , 일일 μžλ™ 이월 μŠ€μΌ€μ€„λŸ¬ μΆ”κ°€. @EnableScheduling 적용.
Integration Tests
apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java, domain/ranking/RankingServiceIntegrationTest.java, infrastructure/cache/RankingCacheServiceIntegrationTest.java, interfaces/api/ranking/RankingV1ApiE2ETest.java, product/ProductFacadeIntegrationTest.java, infrastructure/cache/ProductCacheServiceIntegrationTest.java
μˆœμœ„ κΈ°λŠ₯ 및 μ œν’ˆ 톡합에 λŒ€ν•œ λ‹¨μœ„/톡합/E2E ν…ŒμŠ€νŠΈ μΆ”κ°€.
Commerce Streamer Tests
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java
μˆœμœ„ 점수 증가/이월 λ‘œμ§μ— λŒ€ν•œ 톡합 ν…ŒμŠ€νŠΈ μΆ”κ°€.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant RankingV1Controller
    participant RankingFacade
    participant RankingService
    participant RankingCacheService
    participant Redis
    participant ProductService
    participant Database
    participant BrandService

    Client->>RankingV1Controller: GET /api/v1/rankings?date=yyyyMMdd&page=1&size=20
    
    activate RankingV1Controller
    RankingV1Controller->>RankingFacade: getDailyRanking(GetDailyRankingCommand)
    deactivate RankingV1Controller

    activate RankingFacade
    RankingFacade->>RankingService: getRanking(date, page, size)
    
    activate RankingService
    RankingService->>RankingCacheService: getRankingRange(date, start, end)
    deactivate RankingService
    
    activate RankingCacheService
    RankingCacheService->>Redis: ZREVRANGE ranking:all:yyyyMMdd (with scores)
    activate Redis
    Redis-->>RankingCacheService: List[RankingItem]
    deactivate Redis
    RankingCacheService-->>RankingFacade: List[Ranking]
    deactivate RankingCacheService
    
    rect rgb(200, 220, 240)
        note over RankingFacade: μƒν’ˆ 데이터 쑰회 및 보강
        RankingFacade->>ProductService: findProductsByIds(productIds)
        activate ProductService
        ProductService->>Database: Query Product entities
        activate Database
        Database-->>ProductService: List[Product]
        deactivate Database
        ProductService-->>RankingFacade: List[Product]
        deactivate ProductService
        
        RankingFacade->>BrandService: Brand name lookup
        activate BrandService
        BrandService->>Database: Query Brand entities
        activate Database
        Database-->>BrandService: Brand data
        deactivate Database
        BrandService-->>RankingFacade: Brand names
        deactivate BrandService
    end
    
    RankingFacade-->>RankingV1Controller: RankingInfo (enriched with product details)
    deactivate RankingFacade
    
    activate RankingV1Controller
    RankingV1Controller->>RankingV1Controller: Convert to DailyRankingListResponse
    RankingV1Controller-->>Client: ApiResponse<DailyRankingListResponse> (HTTP 200)
    deactivate RankingV1Controller
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 λΆ„

Possibly related PRs

  • [volume - 8] Decoupling with Kafka Β #205: OrderCreated λ ˆμ½”λ“œμ— orderItems ν•„λ“œλ₯Ό μΆ”κ°€ν•˜κ³  νŒ©ν† λ¦¬ λ©”μ„œλ“œλ₯Ό μ—…λ°μ΄νŠΈν•˜λŠ” λ™μΌν•œ 변경이 ν¬ν•¨λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.
  • [volume - 8] Decoupling with KafkaΒ #196: Kafka 이벀트 νŽ˜μ΄λ‘œλ“œ μˆ˜μ • 및 commerce-streamer λ©”νŠΈλ¦­/μˆœμœ„ μ†ŒλΉ„μž μΆ”κ°€ λ“± 슀트리밍 κ³„μΈ΅μ˜ λ™μΌν•œ 변경사항을 ν¬ν•¨ν•©λ‹ˆλ‹€.
  • [volume-5] 인덱슀 및 Redis μΊμ‹œ 적용 #140: ProductFacade 및 ProductInfo λ³€κ²½μœΌλ‘œ μ œν’ˆ API κ³„μΈ΅μ˜ λ™μΌν•œ μ½”λ“œλ₯Ό μˆ˜μ •ν•©λ‹ˆλ‹€.

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 0.00% 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 제λͺ©μ€ '[volume-9] Product Ranking with Redis'둜, 핡심 변경사항인 Redisλ₯Ό 기반으둜 ν•œ μƒν’ˆ λž­ν‚Ή κΈ°λŠ₯을 λͺ…ν™•ν•˜κ²Œ μš”μ•½ν•©λ‹ˆλ‹€.
Description check βœ… Passed PR μ„€λͺ…이 ν…œν”Œλ¦Ώμ˜ ν•„μˆ˜ μ„Ήμ…˜(Summary, Review Points, Checklist)을 λͺ¨λ‘ ν¬ν•¨ν•˜κ³  있으며, κ΅¬ν˜„λœ κΈ°λŠ₯κ³Ό 섀계 고민사항이 μƒμ„Ένžˆ μž‘μ„±λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.
✨ Finishing touches
  • πŸ“ Generate docstrings
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

🧹 Nitpick comments (7)
apps/commerce-api/src/main/java/com/loopers/interfaces/listener/KafkaOutboxEventListener.java (1)

25-32: 쀑볡 λ§€ν•‘ λ‘œμ§μ„ 헬퍼 λ©”μ„œλ“œλ‘œ μΆ”μΆœν•˜λŠ” 것을 κ³ λ €ν•˜μ„Έμš”.

handleOrderCreated의 lines 25-32와 handleOrderPaid의 lines 47-54μ—μ„œ λ™μΌν•œ λ§€ν•‘ 둜직이 λ°˜λ³΅λ©λ‹ˆλ‹€. μœ μ§€λ³΄μˆ˜μ„± ν–₯상을 μœ„ν•΄ 곡톡 헬퍼 λ©”μ„œλ“œλ‘œ μΆ”μΆœν•  수 μžˆμŠ΅λ‹ˆλ‹€.

πŸ”Ž 쀑볡 제거λ₯Ό μœ„ν•œ 헬퍼 λ©”μ„œλ“œ μ œμ•ˆ
+    private List<KafkaEvent.OrderEvent.OrderItemInfo> mapToKafkaOrderItemInfos(
+            List<OrderEvent.OrderItemInfo> orderItems) {
+        return orderItems.stream()
+                .map(item -> new KafkaEvent.OrderEvent.OrderItemInfo(
+                        item.productId(),
+                        item.productName(),
+                        item.price(),
+                        item.quantity()
+                ))
+                .toList();
+    }
+
     @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
     public void handleOrderCreated(OrderEvent.OrderCreated event) {
-        List<KafkaEvent.OrderEvent.OrderItemInfo> orderItemInfos = event.orderItems().stream()
-                .map(item -> new KafkaEvent.OrderEvent.OrderItemInfo(
-                        item.productId(),
-                        item.productName(),
-                        item.price(),
-                        item.quantity()
-                ))
-                .toList();
+        List<KafkaEvent.OrderEvent.OrderItemInfo> orderItemInfos = 
+                mapToKafkaOrderItemInfos(event.orderItems());
         
         KafkaEvent.OrderEvent.OrderCreated kafkaEvent = KafkaEvent.OrderEvent.OrderCreated.from(
                 event.orderKey(),
                 event.userId(),
                 event.orderId(),
                 event.originalTotalPrice(),
                 event.discountPrice(),
                 orderItemInfos
         );
         outboxService.saveOutbox("order-created-events", event.orderKey(), kafkaEvent);
     }

     @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
     public void handleOrderPaid(OrderEvent.OrderPaid event) {
-        List<KafkaEvent.OrderEvent.OrderItemInfo> orderItemInfos = event.orderItems().stream()
-                .map(item -> new KafkaEvent.OrderEvent.OrderItemInfo(
-                        item.productId(),
-                        item.productName(),
-                        item.price(),
-                        item.quantity()
-                ))
-                .toList();
+        List<KafkaEvent.OrderEvent.OrderItemInfo> orderItemInfos = 
+                mapToKafkaOrderItemInfos(event.orderItems());
         
         KafkaEvent.OrderEvent.OrderPaid kafkaEvent = KafkaEvent.OrderEvent.OrderPaid.from(
                 event.orderKey(),
                 event.userId(),
                 event.orderId(),
                 event.totalPrice(),
                 orderItemInfos
         );
         outboxService.saveOutbox("order-paid-events", event.orderKey(), kafkaEvent);
     }

Also applies to: 39-40

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

62-87: μˆœμœ„ 톡합 둜직 확인 μ™„λ£Œ, λ¦¬νŒ©ν† λ§ μ œμ•ˆ

RankingCacheServiceλ₯Ό ν†΅ν•œ μˆœμœ„ 쑰회 및 ProductInfo 보강 둜직이 μ˜¬λ°”λ₯΄κ²Œ κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€. Null μ²˜λ¦¬λ„ μ μ ˆν•©λ‹ˆλ‹€.

ν˜„μž¬ κ΅¬ν˜„μ—μ„œλŠ” μˆœμœ„κ°€ μžˆμ„ λ•Œ ProductInfo의 λͺ¨λ“  ν•„λ“œλ₯Ό μˆ˜λ™μœΌλ‘œ λ³΅μ‚¬ν•˜μ—¬ μƒˆ μΈμŠ€ν„΄μŠ€λ₯Ό μƒμ„±ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€. 가독성과 μœ μ§€λ³΄μˆ˜μ„± ν–₯상을 μœ„ν•΄ λ‹€μŒ λ¦¬νŒ©ν† λ§μ„ κ³ λ €ν•΄λ³΄μ„Έμš”:

πŸ”Ž μ œμ•ˆν•˜λŠ” λ¦¬νŒ©ν† λ§

μ˜΅μ…˜ 1: ProductInfo에 νŒ©ν† λ¦¬ λ©”μ„œλ“œ μΆ”κ°€

ProductInfo ν΄λž˜μŠ€μ— λ‹€μŒ λ©”μ„œλ“œλ₯Ό μΆ”κ°€:

public ProductInfo withRank(Long rank) {
    return new ProductInfo(
        this.id, this.name, this.brandId, this.brandName,
        this.price, this.likeCount, this.stock, this.createdAt, rank
    );
}

그러면 ProductFacadeμ—μ„œ:

-        if (rank != null) {
-            return new ProductInfo(
-                    productInfo.id(),
-                    productInfo.name(),
-                    productInfo.brandId(),
-                    productInfo.brandName(),
-                    productInfo.price(),
-                    productInfo.likeCount(),
-                    productInfo.stock(),
-                    productInfo.createdAt(),
-                    rank
-            );
-        }
-
-        return productInfo;
+        return rank != null ? productInfo.withRank(rank) : productInfo;

μ˜΅μ…˜ 2 (더 간단): μ‚Όν•­ μ—°μ‚°μžλ‘œ κ°„μ†Œν™”

         Long rank = rankingCacheService.getProductRank(LocalDate.now(), productId);
-
-        if (rank != null) {
-            return new ProductInfo(
-                    productInfo.id(),
-                    productInfo.name(),
-                    productInfo.brandId(),
-                    productInfo.brandName(),
-                    productInfo.price(),
-                    productInfo.likeCount(),
-                    productInfo.stock(),
-                    productInfo.createdAt(),
-                    rank
-            );
-        }
-
-        return productInfo;
+        return rank != null 
+            ? new ProductInfo(productInfo.id(), productInfo.name(), productInfo.brandId(), 
+                productInfo.brandName(), productInfo.price(), productInfo.likeCount(), 
+                productInfo.stock(), productInfo.createdAt(), rank)
+            : productInfo;

70-70: LocalDate.now() μ‚¬μš© 확인

일일 μˆœμœ„ 쑰회λ₯Ό μœ„ν•΄ LocalDate.now()λ₯Ό μ‚¬μš©ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€. μ΄λŠ” ν˜„μž¬ λ‚ μ§œ κΈ°μ€€ μˆœμœ„λ₯Ό μ‘°νšŒν•˜λŠ” μ˜λ„μ— λΆ€ν•©ν•©λ‹ˆλ‹€. λ‹€λ§Œ, ν…ŒμŠ€νŠΈ κ°€λŠ₯μ„±κ³Ό μ‹œκ°„λŒ€ κ΄€λ ¨ 이슈λ₯Ό κ³ λ €ν•œλ‹€λ©΄ Clock μ£Όμž…μ„ 톡해 λ‚ μ§œλ₯Ό μ œμ–΄ν•  수 μžˆλ„λ‘ ν•˜λŠ” 것도 κ²€ν† ν•΄λ³Ό 수 μžˆμŠ΅λ‹ˆλ‹€.

πŸ”Ž 선택적 κ°œμ„  사항

ν…ŒμŠ€νŠΈ μš©μ΄μ„±μ„ μœ„ν•΄ Clock을 μ£Όμž…λ°›λŠ” νŒ¨ν„΄:

 @RequiredArgsConstructor
 @Component
 public class ProductFacade {
 
     private final ProductService productService;
     private final BrandService brandService;
     private final ProductCacheService productCacheService;
     private final RankingCacheService rankingCacheService;
+    private final Clock clock;
 
     public ProductInfo getProduct(Long productId) {
         ProductInfo productInfo = productCacheService.getProduct(productId, () -> {
             Product product = productService.findProductById(productId)
                     .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "μƒν’ˆμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."));
             String brandName = brandService.findBrandNameById(product.getBrandId());
             return ProductInfo.from(product, brandName);
         });
 
-        Long rank = rankingCacheService.getProductRank(LocalDate.now(), productId);
+        Long rank = rankingCacheService.getProductRank(LocalDate.now(clock), productId);
         
         // ...
     }
 }

Config ν΄λž˜μŠ€μ—μ„œ Bean 등둝:

@Bean
public Clock clock() {
    return Clock.systemDefaultZone();
}

μ΄λ ‡κ²Œ ν•˜λ©΄ ν…ŒμŠ€νŠΈμ—μ„œ κ³ μ •λœ μ‹œκ°„μ„ μ£Όμž…ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java (1)

12-22: 잠재적 NullPointerException μœ„ν—˜

item.rank().intValue() 호좜 μ‹œ rank()κ°€ null이면 NPEκ°€ λ°œμƒν•©λ‹ˆλ‹€. λž­ν‚Ή κ²°κ³Όμ—μ„œ rankκ°€ 항상 μ‘΄μž¬ν•΄μ•Ό ν•˜μ§€λ§Œ, 방어적 코딩을 μœ„ν•΄ null 체크λ₯Ό κ³ λ €ν•΄λ³΄μ„Έμš”.

πŸ”Ž μ œμ•ˆλœ μˆ˜μ •
                     .map(item -> new DailyRankingItem(
                             item.productId(),
                             item.productName(),
                             item.brandName(),
                             item.price(),
                             item.likeCount(),
-                            item.rank().intValue()
+                            item.rank() != null ? item.rank().intValue() : null
                     ))

λ˜λŠ” DailyRankingItem의 rank νƒ€μž…μ„ Long으둜 ν†΅μΌν•˜λŠ” 것도 κ³ λ €ν•΄λ³΄μ„Έμš”.

apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java (1)

30-43: λ§€ ν˜ΈμΆœλ§ˆλ‹€ TTL κ°±μ‹  μ΅œμ ν™” κ³ λ €

incrementScoreκ°€ 호좜될 λ•Œλ§ˆλ‹€ expire()λ₯Ό ν˜ΈμΆœν•˜μ—¬ TTL을 κ°±μ‹ ν•©λ‹ˆλ‹€. κ³ λΉˆλ„ 호좜 ν™˜κ²½μ—μ„œλŠ” λΆˆν•„μš”ν•œ Redis λͺ…령이 될 수 μžˆμŠ΅λ‹ˆλ‹€.

πŸ”Ž μ΅œμ ν™” μ œμ•ˆ (선택적)
// μ˜΅μ…˜ 1: Lua 슀크립트둜 ZINCRBY와 EXPIREλ₯Ό μ›μžμ μœΌλ‘œ μ‹€ν–‰
// μ˜΅μ…˜ 2: TTL이 μ§§μ•„μ‘Œμ„ λ•Œλ§Œ κ°±μ‹  (getExpire 체크 ν›„ κ°±μ‹ )
// ν˜„μž¬ κ΅¬ν˜„λ„ κΈ°λŠ₯μ μœΌλ‘œλŠ” λ¬Έμ œμ—†μœΌλ©°, μ΅œμ ν™”λŠ” λΆ€ν•˜ ν…ŒμŠ€νŠΈ ν›„ κ²°μ • κ°€λŠ₯
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)

41-43: Collectors.toMapμ—μ„œ 쀑볡 ν‚€ λ°œμƒ μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•  수 μžˆμŠ΅λ‹ˆλ‹€.

ProductService.findProductsByIdsκ°€ 쀑볡 μƒν’ˆμ„ λ°˜ν™˜ν•˜λŠ” κ²½μš°λŠ” λ“œλ¬Όμ§€λ§Œ, λ°©μ–΄μ μœΌλ‘œ merge function을 μΆ”κ°€ν•˜λŠ” 것이 μ•ˆμ „ν•©λ‹ˆλ‹€.

πŸ”Ž μ œμ•ˆλœ μˆ˜μ •
         List<Product> products = productService.findProductsByIds(productIds);
         Map<Long, Product> productMap = products.stream()
-                .collect(Collectors.toMap(Product::getId, product -> product));
+                .collect(Collectors.toMap(Product::getId, product -> product, (p1, p2) -> p1));
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java (1)

152-217: LGTM! Carry-over ν…ŒμŠ€νŠΈκ°€ 잘 κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

μ „λ‚  점수의 κ°€μ€‘μΉ˜ 적용과 원본 데이터 보쑴을 μ˜¬λ°”λ₯΄κ²Œ κ²€μ¦ν•©λ‹ˆλ‹€.

선택적 κ°œμ„  μ œμ•ˆ: 였늘 킀에 이미 데이터가 μžˆλŠ” μƒνƒœμ—μ„œ carry-overκ°€ λ°œμƒν•  λ•Œ μ μˆ˜κ°€ μ˜¬λ°”λ₯΄κ²Œ ν•©μ‚°λ˜λŠ”μ§€ κ²€μ¦ν•˜λŠ” ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€λ₯Ό μΆ”κ°€ν•˜λ©΄ 더 μ™„λ²½ν•œ 컀버리지λ₯Ό μ œκ³΅ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

πŸ“œ Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 9ecdb90 and 945a352.

πŸ“’ Files selected for processing (33)
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.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/brand/Brand.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.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/RankingV1ApiSpec.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-api/src/main/java/com/loopers/interfaces/listener/KafkaOutboxEventListener.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductCacheServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/RankingCacheServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java
  • apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/scheduler/RankingScoreCarryOverScheduler.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java
  • modules/kafka/src/main/java/com/loopers/application/kafka/KafkaEvent.java
🧰 Additional context used
🧠 Learnings (7)
πŸ““ 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-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/RankingCacheService.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/interfaces/consumer/MetricsConsumer.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/listener/KafkaOutboxEventListener.java
  • modules/kafka/src/main/java/com/loopers/application/kafka/KafkaEvent.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/interfaces/consumer/MetricsConsumer.java
πŸ“š Learning: 2025-12-18T13:24:51.650Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:51.650Z
Learning: Adopt centralized error handling for Kafka consumers by using the EventInboxAspect to intercept methods annotated with InboxEvent. Ensure that service methods annotated with InboxEvent save failed EventInbox entries and log errors, avoiding duplicating error handling logic in individual consumers. Apply this pattern broadly to similar consumer/service layers within the commerce-streamer module.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java
πŸ“š Learning: 2025-12-19T09:30:12.459Z
Learnt from: HongChangMo
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 198
File: apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventListener.java:0-0
Timestamp: 2025-12-19T09:30:12.459Z
Learning: In the loopers-spring-java-template repository's commerce-api module, when entities are managed within a transactional context (e.g., Transactional methods), prefer relying on JPA dirty checking for automatic persistence rather than explicit save() calls. Both Payment and Order entities in PaymentEventListener use this pattern, with state changes automatically flushed on transaction commit.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/interfaces/listener/KafkaOutboxEventListener.java
πŸ“š 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/interfaces/api/product/ProductV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
🧬 Code graph analysis (5)
apps/commerce-api/src/main/java/com/loopers/interfaces/listener/KafkaOutboxEventListener.java (1)
apps/commerce-api/src/main/java/com/loopers/application/order/OrderEvent.java (1)
  • OrderEvent (6-87)
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java (1)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java (1)
  • Slf4j (20-214)
apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java (1)
apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java (1)
  • ConfigurationPropertiesScan (12-28)
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java (1)
k6_product_list.js (1)
  • productId (356-356)
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java (2)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
  • RequiredArgsConstructor (17-76)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (1)
  • RequiredArgsConstructor (15-41)
πŸ”‡ Additional comments (33)
apps/commerce-api/src/main/java/com/loopers/application/order/OrderEvent.java (1)

26-27: LGTM!

OrderCreated μ΄λ²€νŠΈμ— μ£Όλ¬Έ ν•­λͺ© 정보가 μ˜¬λ°”λ₯΄κ²Œ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€. λ§€ν•‘ 둜직이 μ •ν™•ν•˜λ©° κΈ°μ‘΄ OrderPaid νŒ¨ν„΄κ³Ό 일관성을 μœ μ§€ν•©λ‹ˆλ‹€.

Also applies to: 30-37, 45-46

modules/kafka/src/main/java/com/loopers/application/kafka/KafkaEvent.java (1)

32-32: LGTM!

Kafka 이벀트 OrderCreated에 μ£Όλ¬Έ ν•­λͺ© 정보가 μ˜¬λ°”λ₯΄κ²Œ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€. νŒ©ν† λ¦¬ λ©”μ„œλ“œ μ‹œκ·Έλ‹ˆμ²˜μ™€ μƒμ„±μž 호좜이 μ •ν™•ν•˜κ²Œ μ—…λ°μ΄νŠΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€.

Also applies to: 35-35, 43-43

apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductCacheServiceIntegrationTest.java (1)

328-342: ν…ŒμŠ€νŠΈ 헬퍼 λ©”μ„œλ“œ μ—…λ°μ΄νŠΈ 확인 μ™„λ£Œ

createProductInfo 헬퍼 λ©”μ„œλ“œκ°€ μƒˆλ‘œμš΄ rank ν•„λ“œλ₯Ό ν¬ν•¨ν•˜λ„λ‘ μ˜¬λ°”λ₯΄κ²Œ μ—…λ°μ΄νŠΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€. μΊμ‹œ ν…ŒμŠ€νŠΈμ—μ„œλŠ” μˆœμœ„κ°€ μ€‘μš”ν•˜μ§€ μ•ŠμœΌλ―€λ‘œ null을 μ „λ‹¬ν•˜λŠ” 것이 μ μ ˆν•©λ‹ˆλ‹€.

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

43-65: μƒν’ˆ 상세 응닡에 μˆœμœ„ ν•„λ“œ μΆ”κ°€ 확인 μ™„λ£Œ

ProductResponse에 rank ν•„λ“œκ°€ μ˜¬λ°”λ₯΄κ²Œ μΆ”κ°€λ˜μ—ˆμœΌλ©°, info.rank()λ₯Ό 톡해 적절히 λ§€ν•‘λ˜κ³  μžˆμŠ΅λ‹ˆλ‹€. ProductItem (λͺ©λ‘ 쑰회용)μ—λŠ” μˆœμœ„κ°€ ν¬ν•¨λ˜μ§€ μ•ŠλŠ” 것이 μ˜λ„λœ μ„€κ³„λ‘œ 보이며, 상세 μ‘°νšŒμ—μ„œλ§Œ μˆœμœ„λ₯Ό μ œκ³΅ν•˜λŠ” 것은 합리적인 μ„ νƒμž…λ‹ˆλ‹€.

apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java (1)

1-8: RankingItem λ ˆμ½”λ“œ 섀계 확인 μ™„λ£Œ

Redis ZSET 데이터λ₯Ό μ „λ‹¬ν•˜κΈ° μœ„ν•œ κ°„λ‹¨ν•œ 데이터 μΊλ¦¬μ–΄λ‘œμ„œ μ μ ˆν•˜κ²Œ μ„€κ³„λ˜μ—ˆμŠ΅λ‹ˆλ‹€. Double νƒ€μž…μ˜ score ν•„λ“œλŠ” Redis ZSET의 score 값을 ν‘œν˜„ν•˜κΈ°μ— μ ν•©ν•©λ‹ˆλ‹€.

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

7-13: μŠ€μΌ€μ€„λ§ κΈ°λŠ₯ ν™œμ„±ν™” 확인 μ™„λ£Œ

@EnableScheduling μ–΄λ…Έν…Œμ΄μ…˜μ΄ μΆ”κ°€λ˜μ–΄ RankingScoreCarryOverScheduler와 같은 μŠ€μΌ€μ€„ μž‘μ—…μ΄ 싀행될 수 μžˆμŠ΅λ‹ˆλ‹€. CommerceApiApplicationκ³Ό μΌκ΄€λ˜κ²Œ κ΅¬μ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java (1)

1-8: Ranking 도메인 λ ˆμ½”λ“œ 섀계 확인 μ™„λ£Œ

RankingItemκ³Ό λͺ…ν™•νžˆ κ΅¬λΆ„λ˜λŠ” 도메인 λ ˆμ½”λ“œλ‘œμ„œ μ μ ˆν•˜κ²Œ μ„€κ³„λ˜μ—ˆμŠ΅λ‹ˆλ‹€. RankingItem(μΊμ‹œ 계측)μ—μ„œ Ranking(도메인 계측)으둜의 λ³€ν™˜ μ‹œ μˆœμœ„ 계산이 μΆ”κ°€λ˜λŠ” ꡬ쑰가 κΉ”λ”ν•©λ‹ˆλ‹€.

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

18-18: findProductsByIds λ©”μ„œλ“œλŠ” null 및 빈 리슀트 μ²˜λ¦¬κ°€ μ μ ˆν•˜κ²Œ κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

κ΅¬ν˜„μ²΄μ—μ„œ if (productIds == null || productIds.isEmpty())둜 μž…λ ₯값을 κ²€μ¦ν•œ ν›„ 빈 리슀트λ₯Ό λ°˜ν™˜ν•˜κ³  μžˆμ–΄ μ—£μ§€ μΌ€μ΄μŠ€λ₯Ό μ•ˆμ „ν•˜κ²Œ μ²˜λ¦¬ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€.

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

56-59: 배치 쑰회 λ©”μ„œλ“œ κ΅¬ν˜„ 확인 μ™„λ£Œ

@Transactional(readOnly = true)λ₯Ό μ‚¬μš©ν•œ 읽기 μ „μš© λ©”μ„œλ“œλ‘œ μ μ ˆν•˜κ²Œ κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€. λ¦¬ν¬μ§€ν† λ¦¬μ˜ ProductRepositoryImpl.findProductsByIds()μ—μ„œ nullκ³Ό 빈 리슀트λ₯Ό μ•ˆμ „ν•˜κ²Œ μ²˜λ¦¬ν•˜κ³  μžˆμœΌλ―€λ‘œ μ—£μ§€ μΌ€μ΄μŠ€ μ²˜λ¦¬λ„ λ¬Έμ œμ—†μŠ΅λ‹ˆλ‹€.

apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java (1)

28-29: LGTM!

배치 쑰회λ₯Ό μœ„ν•œ ν‘œμ€€μ μΈ IN 절 κ΅¬ν˜„μ΄λ©°, μ†Œν”„νŠΈ μ‚­μ œλœ λ ˆμ½”λ“œλ₯Ό μ˜¬λ°”λ₯΄κ²Œ ν•„ν„°λ§ν•©λ‹ˆλ‹€.

apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java (1)

42-48: LGTM!

null/empty 체크λ₯Ό ν†΅ν•œ 방어적 ν”„λ‘œκ·Έλž˜λ°μœΌλ‘œ λΆˆν•„μš”ν•œ DB 쿼리λ₯Ό λ°©μ§€ν•˜κ³  NPEλ₯Ό μ˜ˆλ°©ν•©λ‹ˆλ‹€.

apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java (1)

28-34: LGTM!

μž…λ ₯ 값이 μœ νš¨ν•˜λ‹€λŠ” μ „μ œ ν•˜μ— μˆœμœ„ 계산 둜직(start + 1 + i)은 μ˜¬λ°”λ₯΄λ©°, 1-based μˆœμœ„λ₯Ό μ •ν™•ν•˜κ²Œ μƒμ„±ν•©λ‹ˆλ‹€.

apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java (1)

21-35: LGTM!

JPAλ₯Ό μœ„ν•œ no-arg μƒμ„±μžμ™€ Builder νŒ¨ν„΄ κ΅¬ν˜„μ΄ μ μ ˆν•©λ‹ˆλ‹€. createBrand νŒ©ν† λ¦¬ λ©”μ„œλ“œλŠ” λΉŒλ” νŒ¨ν„΄μ˜ νŽΈμ˜μ„± 래퍼둜 ν…ŒμŠ€νŠΈ μ½”λ“œ μž‘μ„± μ‹œ μœ μš©ν•©λ‹ˆλ‹€.

apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java (1)

73-212: LGTM!

포괄적인 E2E ν…ŒμŠ€νŠΈ 컀버리지λ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€:

  • λž­ν‚Ή 데이터 쑴재 μ‹œ 쑰회
  • 데이터 없을 λ•Œ 빈 리슀트 λ°˜ν™˜
  • νŽ˜μ΄μ§€λ„€μ΄μ…˜ λ™μž‘ 검증

λͺ…ν™•ν•œ arrange-act-assert ꡬ쑰와 μ μ ˆν•œ μ–΄μ„€μ…˜μœΌλ‘œ μ£Όμš” μ‹œλ‚˜λ¦¬μ˜€λ₯Ό 잘 κ²€μ¦ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€.

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

5-18: LGTM!

λž­ν‚Ή 정보λ₯Ό μ „λ‹¬ν•˜κΈ° μœ„ν•œ κΉ”λ”ν•œ λ ˆμ½”λ“œ κ΅¬μ‘°μž…λ‹ˆλ‹€. 쀑첩 λ ˆμ½”λ“œλ₯Ό μ‚¬μš©ν•œ μΊ‘μŠν™”κ°€ μ μ ˆν•˜λ©°, λͺ¨λ“  ν•„λ“œμ˜ νƒ€μž…μ΄ μ ν•©ν•©λ‹ˆλ‹€.

apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.java (1)

1-19: LGTM!

κ°€μ€‘μΉ˜ μƒμˆ˜κ°€ λͺ…ν™•ν•˜κ²Œ μ •μ˜λ˜μ–΄ 있고, μ£Όλ¬Έ 생성(0.7) > μ’‹μ•„μš”(0.2) > 쑰회(0.1) 순으둜 μ μ ˆν•œ κ°€μ€‘μΉ˜ λ°°λΆ„μž…λ‹ˆλ‹€. @Getter ν™œμš©λ„ μ μ ˆν•©λ‹ˆλ‹€.

apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsConsumer.java (3)

47-48: μ’‹μ•„μš” μ·¨μ†Œ μ‹œ λž­ν‚Ή 점수 처리 확인 ν•„μš”

handleProductLikedEventsμ—μ„œλŠ” rankingService.incrementScoreλ₯Ό ν˜ΈμΆœν•˜μ§€λ§Œ, handleProductUnlikedEvents(라인 66-87)μ—μ„œλŠ” λž­ν‚Ή 점수λ₯Ό μ°¨κ°ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. 이것이 μ˜λ„λœ 섀계인지 확인이 ν•„μš”ν•©λ‹ˆλ‹€.

λ§Œμ•½ λž­ν‚Ήμ΄ 순수 긍정 μ‹ ν˜Έλ§Œ λ°˜μ˜ν•˜λŠ” 섀계라면 ν˜„μž¬ κ΅¬ν˜„μ΄ λ§žμ§€λ§Œ, μ’‹μ•„μš” μ·¨μ†Œκ°€ λž­ν‚Ήμ— λ°˜μ˜λ˜μ–΄μ•Ό ν•œλ‹€λ©΄ decrementScore λ©”μ„œλ“œ μΆ”κ°€κ°€ ν•„μš”ν•©λ‹ˆλ‹€.


106-107: LGTM!

쑰회 μ΄λ²€νŠΈμ— λŒ€ν•œ λž­ν‚Ή 점수 증가 둜직이 κΈ°μ‘΄ νŒ¨ν„΄μ„ λ”°λ₯΄λ©° μ˜¬λ°”λ₯΄κ²Œ ν†΅ν•©λ˜μ—ˆμŠ΅λ‹ˆλ‹€.


136-146: LGTM!

μ£Όλ¬Έ 생성 μ΄λ²€νŠΈμ—μ„œ 각 μ£Όλ¬Έ ν•­λͺ©λ³„λ‘œ λž­ν‚Ή 점수λ₯Ό μ¦κ°€μ‹œν‚€λŠ” 둜직이 μ˜¬λ°”λ₯΄κ²Œ κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€. ORDER_CREATED κ°€μ€‘μΉ˜(0.7)κ°€ κ°€μž₯ λ†’μ•„ ꡬ맀 μ „ν™˜μ΄ λž­ν‚Ήμ— κ°€μž₯ 큰 영ν–₯을 λ―ΈμΉ˜λ„λ‘ μ„€κ³„λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.

apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java (2)

56-57: LGTM!

RankingCacheService에 λŒ€ν•œ @MockitoSpyBean μΆ”κ°€κ°€ μ μ ˆν•©λ‹ˆλ‹€. κΈ°μ‘΄ ν…ŒμŠ€νŠΈ νŒ¨ν„΄κ³Ό 일관성 있게 κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€.


289-354: LGTM!

λž­ν‚Ήμ΄ μ—†λŠ” 경우(rank = null)와 λž­ν‚Ήμ΄ μžˆλŠ” 경우(rank = 5L) 두 κ°€μ§€ μ‹œλ‚˜λ¦¬μ˜€μ— λŒ€ν•œ ν…ŒμŠ€νŠΈκ°€ 잘 κ΅¬ν˜„λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€. RankingCacheService μƒν˜Έμž‘μš© 검증도 ν¬ν•¨λ˜μ–΄ μžˆμ–΄ ν…ŒμŠ€νŠΈ 컀버리지가 μΆ©λΆ„ν•©λ‹ˆλ‹€.

apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeIntegrationTest.java (1)

1-213: LGTM!

λž­ν‚Ή νŒŒμ‚¬λ“œμ— λŒ€ν•œ 톡합 ν…ŒμŠ€νŠΈκ°€ ν¬κ΄„μ μœΌλ‘œ κ΅¬ν˜„λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€:

  • 정상 쑰회 μ‹œλ‚˜λ¦¬μ˜€
  • DB에 μ—†λŠ” μƒν’ˆ μ œμ™Έ 둜직
  • 빈 κ²°κ³Ό 처리
  • νŽ˜μ΄μ§€λ„€μ΄μ…˜ λ™μž‘

Redis와 DB 정리 λ‘œμ§λ„ @AfterEach에 적절히 ν¬ν•¨λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.

apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java (1)

1-158: LGTM!

RankingService.getRanking λ©”μ„œλ“œμ— λŒ€ν•œ 톡합 ν…ŒμŠ€νŠΈκ°€ 잘 κ΅¬ν˜„λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€:

  • 데이터 쑴재 μ‹œ 정상 쑰회
  • νŽ˜μ΄μ§€λ„€μ΄μ…˜ λ™μž‘ (3νŽ˜μ΄μ§€ ν…ŒμŠ€νŠΈ)
  • 빈 데이터 처리
  • μˆœμœ„κ°€ 1λΆ€ν„° μ‹œμž‘ν•˜λŠ”μ§€ 검증

@BeforeEach와 @AfterEachμ—μ„œ Redisλ₯Ό μ •λ¦¬ν•˜μ—¬ ν…ŒμŠ€νŠΈ 격리가 보μž₯λ©λ‹ˆλ‹€.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java (1)

16-23: 이 뢀뢄은 μ‹€μ œλ‘œλŠ” λ¬Έμ œκ°€ μ—†μŠ΅λ‹ˆλ‹€. RankingV1Controller.javaμ—μ„œ κ΅¬ν˜„ μ‹œ @DateTimeFormat(pattern = "yyyyMMdd") μ–΄λ…Έν…Œμ΄μ…˜μ„ μ‚¬μš©ν•˜κ³  μžˆμœΌλ―€λ‘œ, API λͺ…μ„Έμ˜ μ„€λͺ… "yyyyMMdd ν˜•μ‹"κ³Ό μ‹€μ œ λ™μž‘μ΄ μΌμΉ˜ν•©λ‹ˆλ‹€.

apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java (1)

45-58: carryOverScore λ™μž‘ μ‹œμ  확인 및 ν…ŒμŠ€νŠΈ 컀버리지 κ°œμ„  μ œμ•ˆ

μŠ€μΌ€μ€„λŸ¬κ°€ 맀일 23:50 PM에 μ‹€ν–‰λ˜λ―€λ‘œ targetDate(내일)λŠ” 아직 ν•˜λ£¨κ°€ μ‹œμž‘λ˜μ§€ μ•Šμ€ μƒνƒœμž…λ‹ˆλ‹€. λ”°λΌμ„œ μ‹€μ œ 운영 ν™˜κ²½μ—μ„œλŠ” toKey에 κΈ°μ‘΄ 데이터가 없을 κ°€λŠ₯성이 λ†’μŠ΅λ‹ˆλ‹€.

λ‹€λ§Œ, ν˜„μž¬ ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€λŠ” toKeyκ°€ λΉ„μ–΄μžˆλŠ” 경우만 κ²€μ¦ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€. ν˜Ήμ‹œ 였λ₯˜ λ³΅κ΅¬λ‚˜ μˆ˜λ™ μ‹€ν–‰ λ“±μœΌλ‘œ toKey에 이미 λˆ„μ λœ μ μˆ˜κ°€ μžˆλŠ” μƒν™©μ—μ„œ unionAndStore(..., Aggregate.SUM, ...)이 μ œλŒ€λ‘œ λ³‘ν•©ν•˜λŠ”μ§€ ν™•μΈν•˜λŠ” ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€ μΆ”κ°€λ₯Ό ꢌμž₯ν•©λ‹ˆλ‹€.

// toKey에 κΈ°μ‘΄ 데이터가 μžˆλŠ” 경우 병합 λ™μž‘ 검증
zSetOps.add(todayKey, "productId1", 50.0);  // κΈ°μ‘΄ 당일 점수
rankingService.carryOverScore(yesterday, today);
// 검증: κΈ°μ‘΄ μ μˆ˜μ™€ carry-over μ μˆ˜κ°€ SUM으둜 ν•©μ³μ§€λŠ”κ°€?
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (1)

22-39: LGTM! 컨트둀러 κ΅¬ν˜„μ΄ μ μ ˆν•©λ‹ˆλ‹€.

νŽ˜μ΄μ§• κΈ°λ³Έκ°’κ³Ό λ‚ μ§œ 포맷이 잘 κ΅¬μ„±λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€. RankingV1ApiSpec μΈν„°νŽ˜μ΄μŠ€λ₯Ό κ΅¬ν˜„ν•˜μ—¬ API λ¬Έμ„œν™”μ™€ 계약을 λΆ„λ¦¬ν•œ 점도 μ’‹μŠ΅λ‹ˆλ‹€.

apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java (1)

14-42: LGTM! rank ν•„λ“œ μΆ”κ°€κ°€ κΉ”λ”ν•˜κ²Œ κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

κΈ°μ‘΄ 2개 νŒŒλΌλ―Έν„° νŒ©ν† λ¦¬ λ©”μ„œλ“œλ₯Ό μœ μ§€ν•˜λ©΄μ„œ μƒˆλ‘œμš΄ 3개 νŒŒλΌλ―Έν„° λ©”μ„œλ“œλ₯Ό μΆ”κ°€ν•˜μ—¬ ν•˜μœ„ ν˜Έν™˜μ„±μ„ 보μž₯ν•©λ‹ˆλ‹€.

apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/RankingCacheServiceIntegrationTest.java (2)

43-114: LGTM! ν…ŒμŠ€νŠΈ 컀버리지가 μ μ ˆν•©λ‹ˆλ‹€.

λž­ν‚Ή λ²”μœ„ 쑰회, νŽ˜μ΄μ§•, 빈 데이터 μΌ€μ΄μŠ€λ₯Ό ν¬κ΄„μ μœΌλ‘œ ν…ŒμŠ€νŠΈν•©λ‹ˆλ‹€. RedisCleanUp을 ν™œμš©ν•œ teardown도 μ μ ˆν•©λ‹ˆλ‹€.


117-160: LGTM! μƒν’ˆ λž­ν‚Ή 쑰회 ν…ŒμŠ€νŠΈκ°€ μ μ ˆν•©λ‹ˆλ‹€.

1-based λž­ν‚Ή λ³€ν™˜κ³Ό μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μƒν’ˆμ— λŒ€ν•œ null λ°˜ν™˜μ„ μ˜¬λ°”λ₯΄κ²Œ κ²€μ¦ν•©λ‹ˆλ‹€.

apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RankingCacheService.java (2)

29-60: LGTM! Redis ZSET 쑰회 둜직이 μ μ ˆν•˜κ²Œ κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

NumberFormatException μ²˜λ¦¬μ™€ null 체크가 λ°©μ–΄μ μœΌλ‘œ 잘 κ΅¬ν˜„λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.


62-75: LGTM! 1-based λž­ν‚Ή λ³€ν™˜μ΄ μ˜¬λ°”λ₯΄κ²Œ κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

Redis의 0-based reverseRankλ₯Ό 1-based λž­ν‚ΉμœΌλ‘œ λ³€ν™˜ν•˜λŠ” 둜직이 λͺ…ν™•ν•©λ‹ˆλ‹€.

apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceIntegrationTest.java (1)

41-150: LGTM! 점수 증가 ν…ŒμŠ€νŠΈκ°€ ν¬κ΄„μ μž…λ‹ˆλ‹€.

λ‹€μ–‘ν•œ 이벀트 νƒ€μž…(LIKE, VIEW, ORDER_CREATED)에 λŒ€ν•œ 점수 증가, λˆ„μ , λ‚ μ§œλ³„ ν‚€ 격리λ₯Ό 잘 κ²€μ¦ν•©λ‹ˆλ‹€. TTL 검증(2일)도 μ μ ˆν•©λ‹ˆλ‹€.

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

61-69: NPE μš°λ €λŠ” κ·Όκ±°κ°€ μ—†μŠ΅λ‹ˆλ‹€.

Product μ—”ν‹°ν‹°μ˜ price와 likeCount ν•„λ“œλŠ” λ‹€μŒκ³Ό 같이 도메인 λΆˆλ³€ 쑰건으둜 λ³΄ν˜Έλ©λ‹ˆλ‹€:

  • @AttributeOverride의 nullable = false μ œμ•½
  • μƒμ„±μžμ˜ λͺ…μ‹œμ  검증 (validatePrice, validateLikeCount)
  • λ°μ΄ν„°λ² μ΄μŠ€ μŠ€ν‚€λ§ˆμ˜ NOT NULL μ œμ•½

λ”°λΌμ„œ Product μΈμŠ€ν„΄μŠ€κ°€ μ‘΄μž¬ν•˜λ©΄ 두 ν•„λ“œ λͺ¨λ‘ 항상 null이 μ•„λ‹ˆλ©°, μ½”λ“œλŠ” μ•ˆμ „ν•©λ‹ˆλ‹€.

Likely an incorrect or invalid review comment.

@yeonsu00 yeonsu00 merged commit 50eba17 into Loopers-dev-lab:yeonsu00 Dec 30, 2025
1 check passed
@yeonsu00 yeonsu00 self-assigned this Dec 30, 2025
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