Skip to content

Conversation

@hyujikoh
Copy link
Collaborator

@hyujikoh hyujikoh commented Dec 26, 2025

Week 9 PR: Redis ZSET 기반 실시간 랭킹 시스템

📌 Summary

Redis ZSET을 활용한 실시간 상품 랭킹 시스템을 구현했습니다.

주요 구현 내용:

  • Consumer (commerce-streamer): Kafka 이벤트 수신 → Redis ZSET에 가중치 기반 점수 적재
  • API (commerce-api): 랭킹 API (GET /api/v1/rankings) 및 상품 상세 조회 시 랭킹 정보 포함
  • 콜드 스타트 해결: Score Carry-Over 스케줄러 및 API Fallback 로직
  • DDD 리팩토링: 계층 분리 및 책임 명확화

구현된 이벤트 → 랭킹 점수:

  • PRODUCT_VIEW: 조회 → Weight 0.1 × Score 1 = 0.1점
  • LIKE_ACTION: 좋아요 → Weight 0.2 × Score 1 = 0.2점
  • PAYMENT_SUCCESS: 주문 → Weight 0.6 × log(totalPrice + 1) = 로그 정규화 점수

💬 Review Points

1. 가중치 설계 (Weighted Sum)

배경 및 문제 상황:
상품 인기도를 측정하기 위해 여러 이벤트(조회, 좋아요, 주문)를 종합해야 하는데, 각 이벤트의 중요도가 다릅니다. 단순히 이벤트 횟수만 합산하면 조회 1회와 주문 1회가 동일한 가중치를 갖게 되어 실제 비즈니스 가치를 반영하지 못합니다.

해결 방안:
이벤트별 가중치(Weight)와 점수(Score)를 분리하여 설계했습니다:

이벤트 Weight Score 최종 점수 설명
조회 0.1 1 0.1 가장 낮은 가중치 (쉽게 발생)
좋아요 0.2 1 0.2 사용자 의도가 반영된 행동
주문 0.6 log(totalPrice + 1) 로그 정규화 실제 구매 전환, 가장 높은 가중치

구현 세부사항:

// CachePayloads.java (28-31줄) - 이벤트별 가중치 정의
public enum EventType {
    PRODUCT_VIEW(0.1),    // 조회: Weight = 0.1
    LIKE_ACTION(0.2),     // 좋아요: Weight = 0.2
    PAYMENT_SUCCESS(0.6); // 주문: Weight = 0.6
}

// CachePayloads.java (68-73줄) - 주문 점수 로그 정규화
public static RankingScore forPaymentSuccess(Long productId, BigDecimal totalPrice, long occurredAt) {
    double normalizedScore = Math.log(totalPrice.doubleValue() + 1);
    return new RankingScore(productId, EventType.PAYMENT_SUCCESS, normalizedScore, occurredAt);
}

// CachePayloads.java (78-80줄) - 최종 가중 점수 계산
public double getWeightedScore() {
    return eventType.getWeight() * score;
}

고민한 점:

  • 주문 점수에 로그 정규화(log(totalPrice + 1))를 적용한 이유는, 고가 상품 1건과 저가 상품 다수 판매 간 균형을 맞추기 위함이었습니다..
  • 좋아요 취소(UNLIKE)는 랭킹 점수에 반영하지 않기로 했습니다. 점수 감소를 허용하면 악의적인 좋아요/취소 반복으로 랭킹 조작이 가능하다 생각하고, 그에 대한 예외 케이스를 충분히 고려하지 못하여서 이번엔 좋아요 부분만 최소한의 멱등성 처리만을 통해 하도록했습니다.

2. DDD 기반 구조 리팩토링

배경 및 문제 상황:
기존 구조에서 여러 문제점이 있었습니다:

  1. Consumer가 비즈니스 로직을 직접 포함하여 책임이 과중
  2. MetricsFacade가 Repository를 직접 의존하여 Self-Transaction 문제 발생
  3. Facade가 Facade를 호출하는 안티패턴 존재 가능성

해결 방안:
클린 아키텍처 원칙에 따라 계층을 명확히 분리했습니다:

[Interface Layer]
MetricsKafkaConsumer (단순 위임, 비즈니스 로직 없음)
        ↓
[Application Layer]
EventProcessingFacade (외부 진입점 - 여러 서비스 조합)
        ↓
    ┌───┴───────────┐
    ↓               ↓
MetricsService    RankingService
(내부 조합)       (Domain)
    ↓
[Domain Layer]
ProductMetricsService, EventHandledService
    ↓
[Domain] Repository Interfaces
    ↓
[Infrastructure Layer]
Repository Implementations, Redis, Cache

구현 세부사항:

1) Interface Layer - Consumer (단순 위임):

// MetricsKafkaConsumer.java (50-75줄)
@KafkaListener(topics = {"catalog-events"}, containerFactory = KafkaConfig.BATCH_LISTENER)
public void onCatalogEvents(List<ConsumerRecord<Object, Object>> records, Acknowledgment ack) {
    // Consumer는 Facade에 위임만 수행, 비즈니스 로직 없음
    final List<CompletableFuture<RankingScore>> futures = records.stream()
            .map(record -> CompletableFuture.supplyAsync(() -> {
                var result = eventProcessingFacade.processCatalogEvent(record.value());
                return (result.processed()) ? result.rankingScore() : null;
            }, executorService))
            .toList();

    List<RankingScore> rankingScores = futures.stream()
            .map(CompletableFuture::join)
            .filter(Objects::nonNull)
            .toList();

    if (!rankingScores.isEmpty()) {
        eventProcessingFacade.updateRankingScores(rankingScores, null);
    }
    ack.acknowledge();
}

2) Application Layer - Facade (외부 진입점):

// EventProcessingFacade.java - 여러 서비스 조합
@Service
@RequiredArgsConstructor
public class EventProcessingFacade {
    // Application Layer 의존성
    private final MetricsService metricsService;
    
    // Domain Layer 의존성
    private final RankingService rankingService;
    
    // Infrastructure Layer 의존성
    private final EventDeserializer eventDeserializer;

    public CatalogEventResult processCatalogEvent(Object eventValue) {
        // 1. 역직렬화 (Infrastructure)
        final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(eventValue);
        
        // 2. 멱등성 체크 (Application → Domain)
        if (!metricsService.tryMarkHandled(envelope.eventId())) {
            return CatalogEventResult.notProcessed();
        }

        // 3. 이벤트 타입별 처리 조합
        return switch (envelope.eventType()) {
            case "PRODUCT_VIEW" -> processProductView(envelope);
            case "LIKE_ACTION" -> processLikeAction(envelope);
            // ...
        };
    }
}

3) Application Layer - Service (내부 조합):

// MetricsService.java - Facade가 아닌 Service로 명명 (Facade→Facade 안티패턴 방지)
@Service
@RequiredArgsConstructor
public class MetricsService {
    // Domain Layer 의존성 (Repository 직접 의존 X)
    private final ProductMetricsService productMetricsService;
    private final EventHandledService eventHandledService;
    
    // Infrastructure Layer 의존성
    private final ProductCacheService productCacheService;

    public boolean tryMarkHandled(String eventId) {
        // 메모리 캐시 확인 후 Domain Service에 위임
        if (processedEventsCache.containsKey(eventId)) {
            return false;
        }
        if (eventHandledService.isAlreadyHandled(eventId)) {
            return false;
        }
        return eventHandledService.markAsHandled(eventId);
    }

    public void incrementView(Long productId, long occurredAtEpochMillis) {
        executeWithLock(productId, () -> {
            ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis);
            productMetricsService.incrementView(productId, eventTime);
        });
    }
}

4) Domain Layer - Domain Service (Self-Transaction 해결):

// ProductMetricsService.java - 트랜잭션 경계 관리
@Service
@RequiredArgsConstructor
public class ProductMetricsService {
    private final ProductMetricsRepository productMetricsRepository;

    @Transactional
    public void incrementView(Long productId, ZonedDateTime eventTime) {
        ProductMetricsEntity metrics = getOrCreateMetrics(productId);
        metrics.incrementView(eventTime);
        productMetricsRepository.save(metrics);
    }

    @Transactional
    public boolean addSales(Long productId, int quantity, ZonedDateTime eventTime) {
        ProductMetricsEntity metrics = getOrCreateMetrics(productId);
        metrics.addSales(quantity, eventTime);
        productMetricsRepository.save(metrics);
        return true;
    }
}

// EventHandledService.java - 멱등성 처리 전담
@Service
@RequiredArgsConstructor
public class EventHandledService {
    private final EventRepository eventRepository;

    @Transactional(readOnly = true)
    public boolean isAlreadyHandled(String eventId) {
        return eventRepository.existsById(eventId);
    }

    @Transactional
    public boolean markAsHandled(String eventId) {
        if (eventRepository.existsById(eventId)) {
            return false;
        }
        eventRepository.save(EventEntity.create(eventId));
        return true;
    }
}

5) DTO 분리:

// EventProcessingResult.java - Application 계층 DTO
public class EventProcessingResult {
    public record CatalogEventResult(boolean processed, RankingScore rankingScore) {
        public static CatalogEventResult notProcessed() {
            return new CatalogEventResult(false, null);
        }
        public static CatalogEventResult processed(RankingScore rankingScore) {
            return new CatalogEventResult(true, rankingScore);
        }
    }

    public record OrderEventResult(boolean processed, RankingScore rankingScore) {
        // ...
    }
}

고민한 점:

  • Facade vs Service 명명: EventProcessingFacade는 외부 진입점(Consumer에서 호출)이므로 Facade로, MetricsService는 내부 조합용이므로 Service로 명명했습니다. Facade가 Facade를 호출하는 것은 안티패턴이라고 생각이 들어서, 이렇게 클래스 명을 따로 구분했는데, 결국 응용계층에서 다른 응용 객체를 이용한것이 안티패턴이 아닌가 싶습니다.

  • Self-Transaction 문제 해결: 기존에 MetricsService가 Repository를 직접 의존하면서 같은 클래스 내 @Transactional 메서드 호출 시 프록시를 거치지 않아 트랜잭션이 적용되지 않는 문제가 있었습니다. Domain Service(ProductMetricsService, EventHandledService)를 분리하여 각 서비스가 자체 트랜잭션 경계를 관리하도록 했습니다.

  • 계층별 책임 분리:

    • Interface: 외부 시스템(Kafka)과의 통신만 담당
    • Application: 유스케이스 조합, 트랜잭션 조정
    • Domain: 비즈니스 로직, 트랜잭션 경계
    • Infrastructure: 기술적 구현 (Redis, JPA 등)

3. 콜드 스타트 해결 전략

배경 및 문제 상황:
일간 랭킹 키(ranking:all:20251226)를 사용하면 자정에 새로운 날짜 키가 생성되어 랭킹 데이터가 비어있게 됩니다.

해결 방안:
2중 안전망을 구축했습니다:

  1. Score Carry-Over Scheduler (23:50 실행): 오늘 점수의 10%를 내일 키에 미리 복사
  2. API Fallback: 오늘 랭킹이 비어있으면 어제 랭킹 반환

구현 세부사항:

// RankingCarryOverScheduler.java (38-55줄)
@Scheduled(cron = "0 50 23 * * *")  // 매일 23:50
public void carryOverDailyRanking() {
    LocalDate today = LocalDate.now();
    LocalDate tomorrow = today.plusDays(1);
    
    // 오늘 점수의 10%를 내일 키에 복사
    long carryOverCount = rankingRedisService.carryOverScores(today, tomorrow, CARRY_OVER_WEIGHT);
}

// ProductFacade.java (125-140줄) - API Fallback
public Page<ProductInfo> getRankingProducts(Pageable pageable, LocalDate date) {
    LocalDate targetDate = date != null ? date : LocalDate.now();
    List<RankingItem> rankings = rankingRedisService.getRanking(targetDate, ...);

    // 콜드 스타트 Fallback: 오늘 랭킹이 비어있으면 어제 랭킹 조회
    if (rankings.isEmpty() && date == null) {
        LocalDate yesterday = targetDate.minusDays(1);
        rankings = rankingRedisService.getRanking(yesterday, ...);
    }
}

고민한 점:

  • Carry-Over 가중치를 10%로 설정한 이유: 어제 인기 상품이 오늘 새벽에도 상위권을 유지하면서, 오전 중 새로운 이벤트로 순위가 변동될 수 있는 균형점입니다.
  • 스케줄러 실행 시간을 23:50으로 설정한 이유: 자정 전에 내일 키에 데이터가 준비되어야 콜드 스타트를 방지할 수 있다 생각이 들어서 50분으로 반영을 했지만, 정말 많은 데이터와 각각의 랭킹 패턴이 다르게 구성이 된다 했을때 명확한 50분 시작도 성공을 보장하는것이 아니라는 생각이 들었습니다.

4. E2E 테스트 전략

배경 및 문제 상황:
단위 테스트와 통합 테스트만으로는 실제 운영 환경에서의 전체 흐름을 검증하기 어렵습니다. 특히 랭킹 시스템은 여러 컴포넌트(DB, Redis, API)가 연동되어야 하므로, 실제 데이터 생성부터 API 응답까지 전체 프로세스를 검증할 필요가 있다 생각했습니다.

해결 방안:
랭킹 시스템에 필요한 각각 인프라에 데이터를 넣는 상황에서 api 응답이 기대했던 대로 진행되었는지 여부만 e2e 로 테스트 했습니다.

구현 세부사항:

1) E2E 테스트 - 실제 데이터 생성 → Redis 적재 → API 조회:

// RankingV1ApiE2ETest.java - 전체 프로세스 검증
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RankingV1ApiE2ETest {

    @BeforeEach
    void setUp() {
        // 1. 실제 브랜드/상품 생성
        BrandEntity brand = brandService.registerBrand(...);
        ProductEntity product = productService.registerProduct(...);
        
        // 2. MV 동기화
        productMVService.syncMaterializedView();
    }

    @Test
    @DisplayName("랭킹 데이터가 있으면 점수 순으로 상품 목록을 반환한다")
    void should_return_products_in_ranking_order() {
        // Given - Redis에 랭킹 데이터 적재
        List<RankingScore> scores = List.of(
                new RankingScore(product1, EventType.PAYMENT_SUCCESS, 100.0, ...),
                new RankingScore(product2, EventType.LIKE_ACTION, 50.0, ...),
                new RankingScore(product3, EventType.PRODUCT_VIEW, 10.0, ...)
        );
        rankingRedisService.updateRankingScoresBatch(scores, today);

        // When - 실제 HTTP API 호출
        ResponseEntity<ApiResponse<PageResponse<ProductListResponse>>> response =
                testRestTemplate.exchange(Uris.Ranking.GET_RANKING, ...);

        // Then - 점수 순 정렬 검증
        assertThat(response.getBody().data().content().get(0).productId()).isEqualTo(product1);
        assertThat(response.getBody().data().content().get(1).productId()).isEqualTo(product2);
        assertThat(response.getBody().data().content().get(2).productId()).isEqualTo(product3);
    }
}

2) 콜드 스타트 Fallback E2E 검증:

@Test
@DisplayName("오늘 랭킹이 없으면 어제 랭킹을 반환한다")
void should_fallback_to_yesterday_when_today_is_empty() {
    // Given - 어제 랭킹만 있음
    rankingRedisService.updateRankingScoresBatch(scores, yesterday);

    // When - 날짜 미지정 (오늘 기준)
    ResponseEntity<...> response = testRestTemplate.exchange(Uris.Ranking.GET_RANKING, ...);

    // Then - 어제 랭킹이 반환됨
    assertThat(response.getBody().data().content()).hasSize(1);
}

@Test
@DisplayName("명시적 날짜 지정 시 Fallback하지 않는다")
void should_not_fallback_when_date_is_explicitly_specified() {
    // Given - 어제 랭킹만 있음
    rankingRedisService.updateRankingScoresBatch(scores, yesterday);

    // When - 오늘 날짜 명시적 지정
    ResponseEntity<...> response = testRestTemplate.exchange(
            Uris.Ranking.GET_RANKING + "?date=" + today, ...);

    // Then - 빈 결과 (Fallback 안 함)
    assertThat(response.getBody().data().content()).isEmpty();
}

3) 상품 상세 조회 시 랭킹 정보 포함 검증:

@Test
@DisplayName("여러 상품 중 특정 상품의 순위가 정확히 반환된다")
void should_return_correct_rank_among_multiple_products() {
    // Given - 3개 상품 랭킹 등록 (product2가 2위)
    List<RankingScore> scores = List.of(
            new RankingScore(product1, EventType.PAYMENT_SUCCESS, 100.0, ...),
            new RankingScore(product2, EventType.LIKE_ACTION, 50.0, ...),
            new RankingScore(product3, EventType.PRODUCT_VIEW, 10.0, ...)
    );
    rankingRedisService.updateRankingScoresBatch(scores, today);

    // When - product2 상세 조회
    ResponseEntity<ApiResponse<ProductDetailResponse>> response =
            testRestTemplate.exchange(Uris.Product.GET_DETAIL, ..., product2);

    // Then - 2위로 반환
    assertThat(response.getBody().data().ranking().rank()).isEqualTo(2L);
}

4) 점수 누적 및 Carry-Over 검증:

@Test
@DisplayName("동일 상품에 여러 이벤트 점수가 누적된다")
void should_accumulate_scores_for_same_product() {
    // Given - 동일 상품에 여러 점수 적재
    // PRODUCT_VIEW: weight 0.1, score 10.0 → 1.0
    // LIKE_ACTION: weight 0.2, score 20.0 → 4.0
    // 누적 점수: 1.0 + 4.0 = 5.0

    // When & Then
    assertThat(response.getBody().data().ranking().score()).isGreaterThanOrEqualTo(5.0);
}

@Test
@DisplayName("Carry-Over 후 다음 날 랭킹에 점수가 이월된다")
void should_carry_over_scores_to_next_day() {
    // Given - 오늘 랭킹 데이터 직접 Redis에 적재
    redisTemplate.opsForZSet().add(todayKey, productId.toString(), 60.0);

    // When - Carry-Over 실행 (10%)
    rankingRedisService.carryOverScores(today, tomorrow, 0.1);

    // Then - 내일 키에 10% 점수가 이월됨
    Double tomorrowScore = redisTemplate.opsForZSet().score(tomorrowKey, productId.toString());
    assertThat(tomorrowScore).isCloseTo(6.0, offset(0.01)); // 60 * 0.1
}

고민한 점:

  • 테스트 격리: @BeforeEach에서 실제 데이터 생성, @AfterEach에서 DB/Redis 정리를 통해 테스트 간 격리를 보장했습니다.
  • 실제 HTTP 호출: TestRestTemplate을 사용하여 실제 HTTP 요청/응답을 검증하여 직렬화/역직렬화 문제도 함께 검증합니다.
  • 가중치 적용 검증: E2E 테스트에서 getWeightedScore() 적용 후 점수를 검증하여 실제 운영 환경과 동일한 결과를 확인합니다.
  • 지금 같이 카프카, redis 등등 인프라를 적용된 상황에서 e2e 테스트코드가 더럽지? 않게 구성하면서, 최종적으로 제가 검증하고 싶은 요구사항을 검증하기 위한 정리가 잘된 테스트 코드를 작성할수 있을지 조언구합니다!

5. Redis ZSET 키 전략

해결 방안:
일간 키 전략을 채택했습니다:

키 형식: ranking:all:{yyyyMMdd}
예시: ranking:all:20251226
TTL: 2일

구현 세부사항:

// CacheKeyGenerator.java (127-133줄)
public String generateDailyRankingKey(LocalDate date) {
    return new StringJoiner(DELIMITER)
            .add(RANKING_PREFIX)    // "ranking"
            .add(ALL_PREFIX)        // "all"
            .add(date.format(DATE_FORMATTER))  // "20251226"
            .toString();
}

// RankingRedisService.java (17줄)
private static final Duration RANKING_TTL = Duration.ofDays(2);

고민한 점:

  • TTL을 2일로 설정한 이유: Carry-Over(오늘 → 내일)와 Fallback(오늘 → 어제) 모두 지원하려면 최소 2일치 데이터가 필요합니다.
  • all prefix를 사용한 이유: 향후 카테고리별/브랜드별 랭킹 확장을 고려했습니다.

✅ Checklist

Ranking Consumer (3/3)

  • 랭킹 ZSET의 TTL, 키 전략을 적절하게 구성하였다

    • modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (17줄, TTL 2일)
    • modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java (127-133줄, 키 생성)
  • 날짜별로 적재할 키를 계산하는 기능을 만들었다

    • modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (44-48줄, getEventDate())
  • 이벤트가 발생한 후, ZSET에 점수가 적절하게 반영된다

    • modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (28-70줄, updateRankingScoresBatch())

Ranking API (3/3)

  • 랭킹 Page 조회 시 정상적으로 랭킹 정보가 반환된다

    • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (40-47줄)
  • 랭킹 Page 조회 시 상품정보가 Aggregation 되어 제공된다

    • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (145-160줄)
  • 상품 상세 조회 시 해당 상품의 순위가 함께 반환된다

    • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (100-101줄)

콜드 스타트 해결 (2/2)

  • Score Carry-Over 스케줄러 구현 (23:50 실행)

    • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java
  • API Fallback 로직 구현 (어제 랭킹 조회)

    • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (125-140줄)

DDD 리팩토링 (4/4)

  • Interface → Application 계층 분리 (Consumer → Facade 위임)

    • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
    • apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java
  • Application 계층 내 Facade/Service 명명 규칙 적용

    • EventProcessingFacade (외부 진입점)
    • MetricsService (내부 조합 - Facade→Facade 안티패턴 방지)
  • Domain Service 분리 (Self-Transaction 문제 해결)

    • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
    • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
  • DTO 분리

    • apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java

테스트 (4/4)

  • RankingServiceTest - 점수 생성, 가중치 계산 단위 테스트

    • apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java
  • RankingIntegrationTest - Kafka → Redis ZSET 통합 테스트

    • apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java
  • ProductFacadeRankingTest - 랭킹 API, Fallback 로직 단위 테스트 (Mock)

    • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java
  • RankingV1ApiE2ETest - 랭킹 API E2E 테스트 (실제 데이터 생성 → Redis 적재 → API 조회)

    • apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java
    • 11개 테스트 케이스: 랭킹 목록 조회(4), 콜드 스타트 Fallback(2), 상품 상세 랭킹 포함(3), 점수 누적(1), Carry-Over(1)

📎 References

Summary by CodeRabbit

  • New Features

    • 실시간 상품 랭킹 시스템 도입 — 날짜별 인기 상품 조회(페이지네이션·선택적 날짜 지원) 및 상품 상세에 랭킹(순위·점수) 노출
    • 랭킹 점수 누적 및 일별 이월(자동 스케쥴/수동 호출) 기능 추가
  • Improvements

    • API 명명 일관성 개선(삭제 관련 메서드명 정리)
    • 랭킹 빈 상태에 대한 콜드스타트(어제 데이터) 폴백 동작 도입
  • Tests

    • 랭킹 관련 단·통합·E2E 테스트 대폭 추가 및 보강

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

hyujikoh and others added 18 commits December 23, 2025 08:49
- Kafka 이벤트 처리 시 비동기 방식으로 변경하여 성능 개선
- ExecutorService를 사용하여 이벤트 처리 스레드 풀 구성
- 종료 시 ExecutorService 정리 로직 추가
- Redis ZSET을 이용한 랭킹 점수 업데이트 및 조회 기능 구현
- 일간 랭킹 키 생성 및 날짜 추출 메서드 추가
- 랭킹 아이템 및 점수 데이터 구조 정의
- 이벤트 처리 파사드 구현
- 다양한 이벤트에 대한 랭킹 점수 생성 메서드 추가
- Kafka 컨슈머에서 이벤트 처리 로직 개선
- 랭킹 점수 배치 업데이트 기능 추가
- CoreException 클래스를 추가하여 사용자 정의 예외 처리 구현
- 다양한 에러 타입을 정의하는 ErrorType 열거형 추가
- 각 에러 타입에 대한 HTTP 상태 코드 및 메시지 설정
- 상품 상세 응답에 랭킹 정보 포함
- 랭킹 정보가 없을 경우 null 처리
- 랭킹 상품 목록 조회 API 추가
- 상품 상세 조회 시 랭킹 정보를 포함하도록 수정
- 랭킹 정보가 없는 경우를 처리하는 로직 추가
- 랭킹 점수 업데이트 기능 개선
- 콜드 스타트 문제 해결을 위해 오늘 랭킹이 비어있을 경우 어제 랭킹을 반환하도록 수정
- 랭킹 Carry-Over 스케줄러 추가로 매일 점수 일부를 다음 날로 이월
- DatabaseCleanUp 및 RedisCleanUp 유틸리티 추가로 테스트 데이터 정리 방식 개선
- EventProcessingFacade를 통한 이벤트 처리 로직 테스트로 신뢰성 향상
- 여러 이벤트의 랭킹 점수를 배치로 업데이트하는 테스트 추가
- EventRepository와 ProductMetricsRepository에서 deleteAll 메서드 제거
- MetricsService를 MetricsApplicationService로 변경
- ProductMetricsRepository에 메트릭 저장 및 조회 메서드 주석 추가
- 이벤트 처리 완료를 위한 Domain Service 구현
- 이벤트 처리 결과 DTO 추가
- Kafka Consumer와의 통합을 위한 메트릭스 서비스 리팩토링
- MetricsService를 MetricsApplicationService로 이름 변경
- EventProcessingFacade 및 MetricsLockCleanupScheduler에서 의존성 수정
- 애플리케이션 이름을 commerce-streamer로 변경
- Kafka 토픽의 파티션 수를 3에서 1로 감소
- 불필요한 Kafka 토픽 설정 코드 제거
- Hot 캐시 갱신 주기를 50분에서 20분으로 변경
- 랭킹 목록 조회 API에 대한 E2E 테스트 구현
- 다양한 랭킹 데이터 시나리오 검증
- 상품 상세 조회 시 랭킹 정보 포함 여부 테스트
- 응답 본문에서 null 체크를 추가하여 안정성 향상
- 불필요한 변수 선언 제거로 코드 간결화
- MetricsApplicationService 클래스를 MetricsService로 리팩토링
- 관련된 모든 의존성 및 호출 부분 수정
@hyujikoh hyujikoh 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

상품 순위 기능을 추가하고, 이벤트 처리 및 메트릭 시스템을 애플리케이션 계층으로 재구성하여 Redis 기반 순위 저장·조회, 이벤트→순위 점수 생성·배치 업데이트, 컨슈머 병렬 처리 및 스케줄러 이월 기능을 도입했습니다.

Changes

코호트 / 파일(s) 변경사항 요약
상품 도메인 / DTO
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java
RankingItem ranking 필드 추가, 팩토리 오버로드 및 fromWithRanking 헬퍼 추가로 순위 전달 경로 확장
상품 비즈니스 / 페이사드
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
RankingRedisService 주입 및 실시간 순위 병합, getRankingProducts(Pageable, LocalDate) 신규, delete* 메서드 명칭 정리
상품 MV 서비스
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java
getByIds(List<Long>) 추가 (MV 엔티티 다중 조회)
API 스펙 / 컨트롤러 / DTO 응답
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java, ProductV1Controller.java, ProductV1Dtos.java
GET /rankings 엔드포인트 추가, ProductDetailResponse에 RankingResponse 추가 및 변환 로직 확장
URI 상수
apps/commerce-api/src/main/java/com/loopers/support/Uris.java
Ranking 중첩 클래스 및 경로 상수(BASE, GET_RANKING) 추가
상품 API 테스트
apps/commerce-api/src/test/.../ProductFacadeRankingTest.java, ProductIntegrationTest.java, ProductV1ApiE2ETest.java, RankingV1ApiE2ETest.java
순위 조회/폴백/상세 반영 단위·E2E 테스트 추가 및 delete 메서드명 반영
이벤트 파사드 / 결과 DTO
apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java, .../dto/EventProcessingResult.java
이벤트 역직렬화·유효성·중복 검사·메트릭 대리 호출·순위점수 생성 담당 파사드 추가 및 처리 결과 레코드 도입
애플리케이션 메트릭 서비스
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java
도메인→애플리케이션 계층 이동된 MetricsService 추가(락 관리·idempotency 관리·메트릭 업데이트 API 제공)
도메인 이벤트 처리/중복 방지
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
이벤트 중복 처리 확인·등록 서비스 추가
도메인 메트릭 서비스 변경
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java, .../ProductMetricsRepository.java
public API 시그니처 변경(ZonedDateTime 기반), deleteAll() 제거
순위 도메인 서비스
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java
이벤트→RankingScore 생성, 배치 업데이트, 조회/상품순위 조회, hasRankingData 등 추가
인프라 - 메트릭 리포지토리 제거/수정
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java, ProductMetricsRepositoryImpl.java
MetricsRepositoryImpl 전체 삭제, ProductMetricsRepositoryImpl에서 deleteAll() 제거
스케줄러
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java, MetricsLockCleanupScheduler.java
일일 순위 이월 스케줄러 추가(10% 가중치), MetricsService import 경로 변경
Kafka 컨슈머 변경
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
병렬 ExecutorService 처리 도입, EventProcessingFacade 위임, 배치 점수 집계·업데이트, 종료 훅 추가
에러 처리 인프라
apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java, ErrorType.java
CoreException 및 ErrorType enum 추가(도메인 오류 타입·HTTP 매핑)
Redis 캐시 / 랭킹 서비스
modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java, RankingRedisService.java
일별 랭킹 키 생성/파싱, ZSET 기반 배치 업데이트·TTL·조회·상품 랭킹 조회·이월 기능 추가
Redis DTO
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java
RankingScore, RankingItem 레코드 및 이벤트 타입별 가중치/팩토리 메서드 추가
테스트 - 스트리머 전반
apps/commerce-streamer/src/test/.../RankingServiceTest.java, RankingIntegrationTest.java, MetricsKafkaConsumerTest.java, MetricsEventProcessingIntegrationTest.java
RankingService 단위/통합/컨슈머 테스트 추가·리팩토링 및 통합 테스트 환경 정비

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant ProductAPI as Product API
    participant ProductFacade as ProductFacade
    participant RankingRedis as RankingRedisService
    participant MV as ProductMVService

    Client->>ProductAPI: GET /api/v1/rankings?date=2025-12-23&page=0&size=20
    ProductAPI->>ProductFacade: getRankingProducts(pageable, date)
    ProductFacade->>RankingRedis: getRanking(date, page, size)
    RankingRedis-->>ProductFacade: List<RankingItem> (productIds, rank, score)
    ProductFacade->>MV: getByIds(productIds)
    MV-->>ProductFacade: List<ProductMaterializedViewEntity>
    ProductFacade->>ProductFacade: 병합 -> Page<ProductInfo>
    ProductFacade-->>ProductAPI: Page<ProductInfo>
    ProductAPI-->>Client: ApiResponse(PageResponse)
Loading
sequenceDiagram
    participant Kafka as Kafka
    participant Consumer as MetricsKafkaConsumer
    participant Executor as ExecutorService
    participant EventFacade as EventProcessingFacade
    participant MetricsApp as MetricsService (app)
    participant RankingDomain as RankingService
    participant RankingRedis as RankingRedisService

    Kafka->>Consumer: 배치 레코드
    Consumer->>Executor: submit 병렬 작업
    Executor->>EventFacade: processCatalogEvent(event)
    EventFacade->>MetricsApp: incrementView/applyLikeDelta/addSales
    MetricsApp-->>EventFacade: 업데이트 완료
    EventFacade->>RankingDomain: generateRankingScore(envelope)
    RankingDomain-->>EventFacade: RankingScore
    Consumer->>RankingRedis: updateRankingScoresBatch(aggregatedScores)
    RankingRedis-->>Consumer: 업데이트 결과
    Consumer-->>Kafka: 레코드 커밋
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 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 38.41% 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 제목이 변경 사항의 주요 내용을 명확하게 설명하고 있습니다. Redis Z-SET 기반 랭킹 시스템 구현이라는 핵심 변경사항을 정확히 반영하고 있습니다.
Description check ✅ Passed PR 설명이 템플릿의 모든 주요 섹션을 포함하고 있습니다. 📌 Summary, 💬 Review Points(5개 상세 섹션), ✅ Checklist(완료 항목 표시), 📎 References를 모두 포함하고 있으며, 각 섹션이 구체적인 코드 예시와 함께 상세히 작성되어 있습니다.

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: 6

🧹 Nitpick comments (23)
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (2)

168-170: 타임존 설정이 시스템 기본값에 의존합니다.

ZoneId.systemDefault()는 배포 환경마다 다를 수 있어 분산 환경에서 시간 불일치가 발생할 수 있습니다. 명시적인 타임존(예: ZoneId.of("Asia/Seoul") 또는 ZoneOffset.UTC) 사용을 권장합니다.

🔎 제안된 수정
+    private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul");  // 또는 ZoneOffset.UTC
+
     private ZonedDateTime convertToZonedDateTime(long epochMillis) {
-        return ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault());
+        return ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZONE_ID);
     }

185-196: ConcurrentHashMap은 삽입 순서를 보장하지 않습니다.

stream().limit(toRemove)로 제거되는 항목은 "가장 오래된" 항목이 아닌 임의의 항목입니다. 메모리 관리 목적이라면 현재 방식도 동작하지만, 오래된 항목 우선 제거가 필요하다면 LinkedHashMap 또는 타임스탬프 기반 만료 로직을 고려해주세요.

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

57-65: 입력 검증 추가를 권장합니다.

새로운 getByIds 메서드가 추가되었습니다. 그러나 productIds가 null이거나 빈 리스트인 경우에 대한 명시적인 처리가 없습니다.

🔎 입력 검증 추가 제안
 public List<ProductMaterializedViewEntity> getByIds(List<Long> productIds) {
+    if (productIds == null || productIds.isEmpty()) {
+        return List.of();
+    }
     return mvRepository.findByIdIn(productIds);
 }
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (2)

13-13: 사용되지 않는 import 제거

RankingItem이 import 되었지만 이 컨트롤러에서는 사용되지 않습니다. ProductFacade.getRankingProductsPage<ProductInfo>를 반환하며, RankingItem은 내부적으로만 사용됩니다.

🔎 제안된 수정
-import com.loopers.cache.dto.CachePayloads.RankingItem;

58-59: 불필요한 주석 제거 또는 수정 필요

// 3. 응답 생성 주석이 있지만 1단계와 2단계 주석이 없어 맥락이 맞지 않습니다. 리팩토링 과정에서 남은 것으로 보입니다.

🔎 제안된 수정
         ProductDetailInfo productDetail = productFacade.getProductDetail(productId, username);
-        
-        // 3. 응답 생성
         return ApiResponse.success(ProductV1Dtos.ProductDetailResponse.from(productDetail));
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java (1)

36-41: 스레드 풀 설정 검토

ThreadPoolExecutor 설정이 합리적으로 보입니다. CallerRunsPolicy를 사용하여 큐가 가득 찼을 때 백프레셔를 적용하는 것은 좋은 선택입니다.

다만, 테스트 가능성을 위해 ExecutorService를 생성자 주입으로 변경하는 것을 고려해 보세요. 현재 구조에서는 단위 테스트 시 스레드 풀 동작을 모킹하기 어렵습니다.

apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)

6-18: LGTM! 원인(cause) 체이닝 생성자 추가 고려

깔끔한 예외 설계입니다. 다만, 다른 예외를 래핑할 때 원인(cause)을 전달할 수 있는 생성자 추가를 고려해 보세요. 이렇게 하면 스택 트레이스에서 근본 원인을 추적하기 쉬워집니다.

🔎 제안: cause 체이닝 생성자 추가
public CoreException(ErrorType errorType, String customMessage, Throwable cause) {
    super(customMessage != null ? customMessage : errorType.getMessage(), cause);
    this.errorType = errorType;
    this.customMessage = customMessage;
}
apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java (1)

259-284: 캐리오버 테스트에서 내일 키 정리 순서 확인

Line 270에서 tomorrowKey를 삭제하고, 테스트 후 Line 283에서 다시 삭제합니다. 첫 번째 삭제는 테스트 사전 조건을 위한 것이고, 두 번째는 정리를 위한 것으로 보입니다. @AfterEachRedisCleanUp을 통해 일관되게 정리하는 것이 더 깔끔할 수 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java (1)

70-73: 수동 캐리오버 접근 제어 고려

manualCarryOver 메서드가 public으로 노출되어 있습니다. 운영 환경에서 의도치 않은 호출을 방지하려면 Admin API를 통해서만 접근하거나, 보안 어노테이션을 추가하는 것을 고려해 보세요.

apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (2)

58-59: OLD_EVENT_THRESHOLD_MS를 설정 가능하도록 외부화 고려

현재 1시간 임계값이 하드코딩되어 있습니다. 환경별로 다른 임계값이 필요할 수 있으므로, @Value 어노테이션이나 설정 파일을 통해 외부에서 주입받는 것을 고려해 주세요.


173-186: LIKE_ACTION 처리 시 UNLIKE도 랭킹 점수를 생성 시도함

processLikeAction에서 action 타입과 무관하게 항상 rankingService.generateRankingScore를 호출합니다. RankingService.generateLikeActionScore에서 UNLIKE는 null을 반환하지만, 의도적인 설계라면 괜찮습니다. 다만 UNLIKE의 경우 호출 자체를 생략하면 불필요한 역직렬화를 피할 수 있습니다.

modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java (1)

155-166: extractDateFromKey 예외 처리 적절함

잘못된 키 형식에 대해 IllegalArgumentException을 throw하는 것이 적절합니다. 다만 LocalDate.parse에서 발생할 수 있는 DateTimeParseException도 함께 처리하면 더 명확한 에러 메시지를 제공할 수 있습니다.

🔎 파싱 예외 처리 추가 예시
     public LocalDate extractDateFromKey(String key) {
         String expectedPrefix = RANKING_PREFIX + DELIMITER + ALL_PREFIX + DELIMITER;
         if (!key.startsWith(expectedPrefix)) {
             throw new IllegalArgumentException("Invalid ranking key format: " + key);
         }

         String dateStr = key.substring(expectedPrefix.length());
-        return LocalDate.parse(dateStr, DATE_FORMATTER);
+        try {
+            return LocalDate.parse(dateStr, DATE_FORMATTER);
+        } catch (DateTimeParseException e) {
+            throw new IllegalArgumentException("Invalid date format in ranking key: " + key, e);
+        }
     }
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java (1)

100-107: 에러 발생 시 반환 타입 불일치

Line 105에서 new ArrayList<>()를 반환하지만, RankingRedisService.getRankingList.of()를 반환합니다. 일관성을 위해 List.of()를 사용하거나, 명시적으로 가변 리스트가 필요한 경우가 아니라면 불변 리스트를 사용하는 것이 좋습니다.

🔎 불변 리스트 사용 제안
         } catch (Exception e) {
             log.error("랭킹 조회 실패: date={}, page={}, size={}", targetDate, page, size, e);
-            return new ArrayList<>();
+            return List.of();
         }
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)

169-177: 랭킹 순서 정렬 시 O(n*m) 복잡도 개선 고려

현재 중첩 스트림으로 productIds × products 탐색이 발생합니다. 상품 수가 많아지면 성능 이슈가 있을 수 있으므로 Map으로 미리 인덱싱하는 것을 권장합니다.

🔎 Map 기반 정렬 개선 예시
+        // 4. 상품 정보 조회 및 Map 인덱싱
         List<ProductMaterializedViewEntity> products = mvService.getByIds(productIds);
+        Map<Long, ProductMaterializedViewEntity> productMap = products.stream()
+                .collect(Collectors.toMap(ProductMaterializedViewEntity::getProductId, p -> p));

         // 5. 랭킹 순서대로 정렬
         List<ProductInfo> sortedProducts = productIds.stream()
-                .map(productId -> products.stream()
-                        .filter(p -> p.getProductId().equals(productId))
-                        .findFirst()
-                        .map(ProductInfo::from)
-                        .orElse(null))
+                .map(productMap::get)
                 .filter(Objects::nonNull)
+                .map(ProductInfo::from)
                 .collect(Collectors.toList());
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)

39-55: eventId null 검증 고려

markAsHandled 메서드에서 eventId가 null인 경우 EventEntity.create(eventId)에서 문제가 발생할 수 있습니다. 호출자(MetricsService.tryMarkHandled)에서 이미 검증한다면 괜찮지만, 방어적으로 null 체크를 추가하면 더 안전합니다.

🔎 null 검증 추가 예시
     @Transactional
     public boolean markAsHandled(String eventId) {
+        if (eventId == null || eventId.isBlank()) {
+            log.warn("이벤트 ID가 null 또는 빈 값: {}", eventId);
+            return false;
+        }
+
         try {
             // 트랜잭션 내에서 다시 한번 확인 (동시성 안전)
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java (1)

50-50: ObjectMapper 인스턴스 공유 고려

ObjectMapper가 테스트 클래스에서 직접 생성되어 있습니다. Spring 컨텍스트에서 제공하는 빈을 사용하거나 @BeforeEach에서 설정하면 일관성을 높일 수 있습니다.

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

108-127: fromWithRankingfrom 메서드 간 중복 코드 존재

두 메서드 간 대부분의 로직이 중복됩니다. from 메서드가 내부적으로 fromWithRanking을 호출하도록 리팩터링하면 유지보수성이 향상됩니다.

🔎 중복 제거 리팩터링 예시
         public static ProductDetailResponse from(ProductDetailInfo productDetailInfo) {
             RankingResponse rankingResponse = null;
             if (productDetailInfo.ranking() != null) {
                 rankingResponse = new RankingResponse(
                         productDetailInfo.ranking().rank(),
                         productDetailInfo.ranking().score()
                 );
             }
-
-            return new ProductDetailResponse(
-                    productDetailInfo.id(),
-                    productDetailInfo.name(),
-                    productDetailInfo.description(),
-                    productDetailInfo.likeCount(),
-                    productDetailInfo.stockQuantity(),
-                    new PriceResponse(
-                            productDetailInfo.price().originPrice(),
-                            productDetailInfo.price().discountPrice()
-                    ),
-                    new BrandDetailResponse(
-                            productDetailInfo.brand().id(),
-                            productDetailInfo.brand().name()
-                    ),
-                    productDetailInfo.isLiked(),
-                    rankingResponse
-            );
+            return fromWithRanking(productDetailInfo, rankingResponse);
         }
modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (4)

42-83: updateRankingScoresBatch 메서드 간 중복 코드

두 오버로드 메서드에서 점수 집계 및 Redis 파이프라인 로직이 중복됩니다. 내부 헬퍼 메서드로 공통 로직을 추출하면 유지보수성이 향상됩니다.

Also applies to: 91-130


250-289: carryOverScores에서 파이프라인 미사용으로 성능 저하 가능

carryOverScores가 상품별로 개별 incrementScore를 호출합니다. 상품 수가 많으면 네트워크 왕복이 증가하여 성능 저하가 발생할 수 있습니다. updateRankingScoresBatch처럼 파이프라인을 사용하는 것을 권장합니다.

🔎 파이프라인 적용 예시
-            for (ZSetOperations.TypedTuple<String> tuple : sourceData) {
-                String member = tuple.getValue();
-                Double score = tuple.getScore();
-                
-                if (member != null && score != null) {
-                    double weightedScore = score * carryOverWeight;
-                    zSetOps.incrementScore(targetKey, member, weightedScore);
-                }
-            }
+            redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
+                for (ZSetOperations.TypedTuple<String> tuple : sourceData) {
+                    String member = tuple.getValue();
+                    Double score = tuple.getScore();
+                    
+                    if (member != null && score != null) {
+                        double weightedScore = score * carryOverWeight;
+                        zSetOps.incrementScore(targetKey, member, weightedScore);
+                    }
+                }
+                return null;
+            });

250-250: carryOverWeight 파라미터 유효성 검증 추가 고려

carryOverWeight가 0.0~1.0 범위인지 검증하지 않습니다. 잘못된 값(음수 또는 1 초과)이 전달되면 예상치 못한 랭킹 점수가 발생할 수 있습니다.

🔎 파라미터 검증 예시
     public long carryOverScores(LocalDate sourceDate, LocalDate targetDate, double carryOverWeight) {
+        if (carryOverWeight < 0.0 || carryOverWeight > 1.0) {
+            throw new IllegalArgumentException("carryOverWeight must be between 0.0 and 1.0: " + carryOverWeight);
+        }
+
         String sourceKey = cacheKeyGenerator.generateDailyRankingKey(sourceDate);

162-162: 완전한 클래스 경로 대신 import 사용 권장

new java.util.ArrayList<>()가 사용되고 있습니다. 파일 상단에 import를 추가하고 new ArrayList<>()로 사용하는 것이 일관성 있습니다.

modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)

47-51: 분산 환경에서 ZoneId.systemDefault() 사용 시 일관성 문제가 발생할 수 있습니다.

서버마다 시스템 타임존이 다를 경우, 동일한 occurredAtEpochMillis가 다른 날짜로 변환될 수 있습니다. 이로 인해 랭킹 데이터가 의도하지 않은 일자 키에 저장될 수 있습니다.

명시적인 타임존(예: ZoneId.of("Asia/Seoul")) 또는 UTC 사용을 고려하세요.

🔎 수정 제안
+    private static final java.time.ZoneId RANKING_ZONE = java.time.ZoneId.of("Asia/Seoul");
+
     public java.time.LocalDate getEventDate() {
         return java.time.Instant.ofEpochMilli(occurredAtEpochMillis)
-                .atZone(java.time.ZoneId.systemDefault())
+                .atZone(RANKING_ZONE)
                 .toLocalDate();
     }
apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java (1)

77-108: 사용되지 않는 지역 변수 testBrandId를 제거하거나 인라인 처리할 수 있습니다.

testBrandId 변수가 선언되고 할당되지만 이후 사용되지 않습니다. brand.getId()를 직접 사용하는 것이 더 간결합니다.

🔎 수정 제안
     @BeforeEach
     void setUp() {
         databaseCleanUp.truncateAllTables();
         redisCleanUp.truncateAll();
         testProductIds.clear();
-        Long testBrandId = null;

         today = LocalDate.now();

         // 테스트용 브랜드 생성
         BrandEntity brand = brandService.registerBrand(
                 BrandTestFixture.createRequest("랭킹테스트브랜드", "랭킹 E2E 테스트용 브랜드")
         );
-        testBrandId = brand.getId();

         // 테스트용 상품 5개 생성
         for (int i = 1; i <= 5; i++) {
             ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest(
-                    testBrandId,
+                    brand.getId(),
                     "랭킹테스트상품" + i,
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bdb5e90 and fc8e319.

📒 Files selected for processing (35)
  • 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/domain/product/ProductMVService.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.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/ProductV1Dtos.java
  • apps/commerce-api/src/main/java/com/loopers/support/Uris.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java
  • apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
  • modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java
  • modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java
  • modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java
💤 Files with no reviewable changes (4)
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
🧰 Additional context used
🧠 Learnings (8)
📚 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/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
📚 Learning: 2025-12-21T15:12:57.845Z
Learnt from: hyujikoh
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 200
File: apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java:29-36
Timestamp: 2025-12-21T15:12:57.845Z
Learning: In MetricsLockCleanupScheduler in apps/commerce-streamer (Loopers-dev-lab/loopers-spring-java-template), the scheduled cleanup methods (cleanupUnusedLocks, cleanupProcessedEvents, monitorLockStatus) operate on JVM-local memory structures (ConcurrentHashMap for locks and idempotency cache in MetricsService). Each application instance has its own separate memory space, so duplicate execution across multiple instances is the intended behavior—each instance must clean up its own memory. Distributed locking is not needed for JVM-internal memory cleanup tasks.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.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-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.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/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
📚 Learning: 2025-12-18T13:24:54.339Z
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:54.339Z
Learning: In this codebase, Kafka consumers delegate error handling and event tracking to the service layer via EventInboxAspect. Service methods annotated with InboxEvent are intercepted by the aspect, which handles failures by saving failed EventInbox entries and logging errors. This centralized approach avoids duplicating error handling logic across multiple consumers.

Applied to files:

  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.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/MetricsKafkaConsumer.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/interfaces/api/product/ProductV1Controller.java
  • modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.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/application/product/ProductDetailInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
🧬 Code graph analysis (14)
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (3)
apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (1)
  • Service (44-240)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)
  • Service (18-56)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (1)
  • Service (21-93)
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java (2)
apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java (1)
  • EventProcessingResult (11-44)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-85)
apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (3)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)
  • Service (18-56)
modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (1)
  • Service (27-291)
apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java (1)
  • EventProcessingResult (11-44)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-85)
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java (3)
modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (1)
  • Service (27-291)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-85)
stress/kafka_event-driven-e2e-test.js (1)
  • action (109-109)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (2)
apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (1)
  • Service (44-240)
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (1)
  • Service (40-207)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (3)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-85)
apps/commerce-api/src/main/java/com/loopers/support/Uris.java (3)
  • Uris (9-115)
  • Ranking (85-91)
  • Product (73-80)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java (1)
  • ProductV1Dtos (10-168)
apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-85)
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-85)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-85)
apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt (1)
  • errorType (3-6)
apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-85)
apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java (2)
apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)
  • Getter (5-19)
apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt (1)
  • status (5-11)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (2)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java (1)
  • Component (21-74)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-85)
🔇 Additional comments (52)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java (1)

6-6: LGTM!

DDD 기반 리팩토링에 따라 MetricsService가 application 계층으로 이동한 것에 맞춰 import 경로가 올바르게 업데이트되었습니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (1)

5-24: LGTM!

DDD 원칙에 따라 순수한 Domain Repository 인터페이스로 잘 정의되었습니다. deleteAll() 제거로 상품 메트릭에 집중된 도메인 경계가 명확해졌습니다.

apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (3)

159-161: 락 획득 실패 시 메트릭 업데이트가 무시됩니다.

100ms 타임아웃 내에 락을 획득하지 못하면 업데이트가 조용히 스킵됩니다. 높은 동시성 상황에서 데이터 누락이 발생할 수 있습니다. 재시도 로직 추가 또는 타임아웃 값 조정을 검토해주세요.


71-95: 멱등성 처리 로직이 잘 구현되었습니다.

메모리 캐시(빠른 경로) → DB 확인 → DB 저장의 3단계 전략으로 효율적인 멱등성 체크가 구현되었습니다. EventHandledService와의 협업도 적절합니다.


40-61: Application 계층 서비스로서 책임 분리가 명확합니다.

Domain Service, Infrastructure 의존성을 조합하여 유스케이스를 조정하는 역할이 잘 정의되어 있습니다. 상품별 메모리 락과 멱등성 캐시 관리를 통한 동시성 제어 전략도 적절합니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (3)

31-37: @transactional 내에서 명시적 save() 호출이 있습니다.

JPA 관리 엔티티는 트랜잭션 커밋 시 dirty checking으로 자동 flush됩니다. getOrCreateMetrics가 새 엔티티를 생성할 수 있으므로 현재의 명시적 save() 호출은 안전한 접근입니다. 코드 의도가 명확하여 유지해도 좋습니다.


44-65: 좋아요 엣지 케이스 처리가 적절합니다.

새 상품에 대한 좋아요 감소(unlike 이벤트가 like보다 먼저 도착하는 경우) 무시 로직이 올바르게 구현되어 있습니다. 명확한 분기 처리와 로깅으로 디버깅도 용이합니다.


89-92: getOrCreateMetrics 헬퍼가 깔끔하게 구현되었습니다.

orElseGet을 사용한 지연 생성 패턴이 적절하며, 호출자가 수정 후 저장하는 구조로 불필요한 DB 호출을 방지합니다.

apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java (1)

1-53: LGTM! 구조화된 에러 타입이 추가되었습니다.

새로운 ErrorType enum이 체계적으로 정의되었으며, 랭킹 시스템을 포함한 도메인별 에러 케이스를 잘 포괄하고 있습니다. CoreException과의 통합도 명확합니다.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java (1)

25-29: 코드 포맷팅 개선

메서드 간 가독성을 위한 공백 추가입니다.

apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java (1)

275-275: 메서드 네이밍 개선이 반영되었습니다.

deletedBrand에서 deleteBrand로의 메서드명 변경이 올바르게 적용되었습니다. 더 명확한 네이밍입니다.

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

207-207: 메서드 네이밍 개선이 E2E 테스트에 반영되었습니다.

deletedProduct에서 deleteProduct로의 메서드명 변경이 E2E 테스트에 일관되게 적용되었습니다.

Also applies to: 343-343

apps/commerce-api/src/main/java/com/loopers/support/Uris.java (1)

82-91: 랭킹 API 엔드포인트 그룹이 추가되었습니다.

기존 패턴과 일관되게 새로운 Ranking 엔드포인트 그룹이 정의되었습니다. 랭킹 시스템 구현을 위한 적절한 URI 구조입니다.

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

15-25: 랭킹 정보 통합이 잘 설계되었습니다.

ProductDetailInforanking 필드가 추가되어 상품 상세 정보에 랭킹 데이터를 포함할 수 있게 되었습니다. Nullable로 설계되어 랭킹 정보가 없는 경우도 유연하게 처리할 수 있습니다.


30-56: 하위 호환성을 유지하는 팩토리 메서드 확장

기존 from(mv, isLiked) 메서드가 from(mv, isLiked, null)을 호출하도록 변경되어 하위 호환성을 유지하면서 새로운 랭킹 파라미터를 추가했습니다. 깔끔한 API 확장 전략입니다.


107-119: 유용한 헬퍼 메서드 추가

fromWithRanking 헬퍼 메서드가 추가되어 기존 ProductDetailInfo 인스턴스에 랭킹 정보를 추가할 수 있게 되었습니다. 이는 랭킹 데이터를 나중에 병합하는 사용 사례에 유용합니다.

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

40-49: LGTM!

랭킹 상품 조회 엔드포인트가 기존 getProducts 패턴을 잘 따르고 있습니다. LocalDate date 파라미터를 optional로 처리하여 콜드 스타트 폴백 로직을 Facade 레이어에서 처리할 수 있게 설계되었습니다.

apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java (1)

47-57: LGTM!

중앙 집중식 정리 유틸리티(DatabaseCleanUp, RedisCleanUp)를 사용하여 테스트 격리를 개선한 좋은 리팩토링입니다. 개별 repository의 deleteAll() 대신 truncateAllTables()truncateAll()을 사용하면 모든 관련 데이터가 일관되게 정리됩니다.

apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java (2)

66-76: 배치 처리 실패 시 동작 확인 필요

모든 레코드가 실패해도 (rankingScores가 비어있어도) ack.acknowledge()가 호출됩니다. 현재 설계 의도가 "at-least-once with best-effort processing"이라면 괜찮지만, 일부 실패 시 재처리가 필요하다면 에러 처리 전략을 재검토해야 합니다.


114-128: LGTM!

@PreDestroy를 통한 graceful shutdown 구현이 적절합니다. 10초 대기 후 강제 종료하고, InterruptedException 발생 시 인터럽트 상태를 복원하는 패턴을 올바르게 따르고 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java (1)

16-43: LGTM!

팩토리 메서드(notProcessed(), processed(RankingScore))를 사용한 결과 DTO 설계가 명확합니다. CatalogEventResultOrderEventResult가 현재 동일한 구조지만, 타입 안전성을 위해 별도로 유지하는 것은 합리적인 선택입니다. 향후 이벤트 유형별로 추가 필드가 필요할 경우 쉽게 확장할 수 있습니다.

apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java (2)

64-70: 테스트 격리 일관성 확인

RedisCleanUp.truncateAll()만 사용하고 있습니다. MetricsEventProcessingIntegrationTest에서는 DatabaseCleanUp도 함께 사용하는데, 이 테스트에서는 DB 정리가 필요하지 않은지 확인해 주세요. 랭킹 데이터가 Redis에만 저장된다면 현재 구현이 맞습니다.


161-198: LGTM!

여러 이벤트의 점수 누적 테스트가 잘 구성되어 있습니다. 조회 3회(0.1×3) + 좋아요 2회(0.2×2) = 0.7 예상 점수 계산이 명확하게 문서화되어 있고, offset(0.1)을 사용하여 부동소수점 오차를 적절히 처리하고 있습니다.

apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java (4)

72-87: LGTM!

createMockMVEntity 헬퍼 메서드가 테스트 데이터 생성을 잘 추상화하고 있습니다. RETURNS_DEEP_STUBS를 사용하여 중첩된 객체(예: mv.getPrice().getOriginPrice())의 모킹을 깔끔하게 처리하고 있습니다.


165-197: LGTM!

콜드 스타트 폴백 테스트가 잘 설계되어 있습니다. 오늘 랭킹이 비어있을 때 어제 랭킹으로 폴백하는 로직을 명확하게 검증하고 있으며, datenull로 전달하여 폴백이 활성화되는 조건을 테스트합니다.


199-215: LGTM!

명시적 날짜 지정 시 폴백하지 않는 동작을 verify(..., never())로 검증하는 것이 적절합니다. 이는 PR 목표에 명시된 "명시적 날짜 지정 시 폴백 없음" 요구사항과 일치합니다.


240-263: LGTM!

상품 상세 조회 시 랭킹 정보 포함 테스트가 적절합니다. RankingItem의 rank와 score가 ProductDetailInfo.ranking()에 올바르게 매핑되는지 검증하고 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (3)

144-155: 예외 처리 시 상위 계층으로 전파하지 않음 확인 필요

updateRankingScores 메서드에서 예외 발생 시 로그만 남기고 호출자에게 전파하지 않습니다. 반면 RankingService.updateRankingScoresBatchCoreException을 throw합니다. 호출자가 실패를 알 필요가 있는지 확인해 주세요.


67-99: 카탈로그 이벤트 처리 로직 LGTM

멱등성 체크, 과거 이벤트 필터링, 이벤트 타입별 분기 처리가 잘 구현되어 있습니다. 과거 이벤트도 tryMarkHandled로 마킹하여 재처리를 방지하는 것이 적절합니다.


188-206: STOCK_DEPLETED 이벤트는 랭킹 점수를 생성하지 않음 확인

processStockDepletedCatalogEventResult.notProcessed()를 반환하여 랭킹 점수를 생성하지 않는 것이 PR 요구사항과 일치합니다 (PRODUCT_VIEW, LIKE_ACTION, PAYMENT_SUCCESS만 랭킹에 반영).

modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java (2)

148-153: LocalDate.now() 사용 시 타임존 고려 필요

generateTodayRankingKey()가 시스템 기본 타임존을 사용합니다. 서버와 애플리케이션 간 타임존이 다르면 날짜 불일치가 발생할 수 있습니다. 명시적으로 타임존을 지정하거나, 일관된 타임존 정책을 확인해 주세요.

🔎 타임존 명시 예시
+    private static final ZoneId RANKING_ZONE = ZoneId.of("Asia/Seoul");
+
     public String generateTodayRankingKey() {
-        return generateDailyRankingKey(LocalDate.now());
+        return generateDailyRankingKey(LocalDate.now(RANKING_ZONE));
     }

136-146: 일간 랭킹 키 생성 로직 LGTM

generateDailyRankingKey 메서드가 기존 키 생성 패턴(StringJoiner, DELIMITER)과 일관되게 구현되어 있습니다.

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

67-84: updateRankingScoresBatch의 예외 전파 전략 확인

updateRankingScoresBatch는 실패 시 CoreException을 throw하지만, getRankinggetProductRanking은 예외를 로그만 남기고 빈 결과/null을 반환합니다. 쓰기 연산은 실패를 전파하고 읽기 연산은 graceful degradation하는 의도적 설계라면 적절합니다.


45-59: 랭킹 점수 생성 로직 LGTM

generateRankingScore 메서드가 이벤트 타입별로 적절히 분기하며, 지원하지 않는 타입에 대해 null을 반환하는 것이 적절합니다.


151-164: 좋아요 취소(UNLIKE)는 랭킹 점수 생성하지 않음 확인

PR 요구사항에 따라 LIKE만 랭킹에 반영하고 UNLIKE는 점수를 생성하지 않는 것이 올바르게 구현되어 있습니다.

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

108-111: 상품 상세 조회에 실시간 랭킹 정보 통합 LGTM

상품 상세 조회 시 오늘 날짜 기준으로 랭킹 정보를 결합하는 로직이 적절하게 구현되어 있습니다. 랭킹 정보가 없어도 null로 처리되어 안전합니다.


140-154: 콜드 스타트 Fallback 로직 LGTM

오늘 랭킹이 비어있고 명시적 날짜 지정이 없을 때만 어제 랭킹으로 폴백하는 로직이 PR 요구사항과 일치합니다. targetDate 변경으로 getRankingCount 호출 시에도 올바른 날짜를 사용합니다.


192-213: 메서드명 개선 완료 (deleteProduct, deleteBrand)

메서드명이 Java 네이밍 컨벤션에 맞게 과거형에서 현재형으로 변경되었으며, 모든 호출 위치가 함께 업데이트되었습니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)

38-55: 이벤트 멱등성 처리 로직 LGTM

트랜잭션 내에서 이중 확인(double-check) 패턴을 사용하여 동시성 문제를 안전하게 처리합니다. Unique 제약 조건 위반 등 예외 발생 시 false를 반환하는 것이 적절합니다.

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

1-296: 포괄적인 단위 테스트 LGTM

RankingService의 주요 기능(점수 생성, 배치 업데이트, 랭킹 조회)에 대한 테스트가 잘 구성되어 있습니다. UNLIKE 시 점수 미생성, 로그 정규화 검증, 빈 리스트 처리 등 엣지 케이스도 포함되어 있습니다.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java (2)

75-106: ProductDetailResponse에 랭킹 정보 추가 LGTM

RankingResponse 추출 로직이 null-safe하게 구현되어 있으며, 랭킹 정보가 없는 경우 null을 반환합니다. API 응답에 실시간 랭킹 정보가 포함됩니다.


158-167: RankingResponse DTO 정의 LGTM

@Schema 어노테이션으로 API 문서화가 잘 되어 있습니다. rankscore 필드가 명확하게 정의되어 있습니다.

modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (2)

35-35: RANKING_TTL 2일 설정 확인

2일 TTL이 요구사항과 일치합니다. 콜드 스타트 폴백 시 어제 데이터 조회가 가능합니다.


140-185: 페이지네이션 랭킹 조회 로직 LGTM

1-based 페이지를 0-based Redis 인덱스로 변환하고, reverseRangeWithScores로 점수 높은 순 조회 후 RankingItem으로 변환하는 로직이 올바르게 구현되어 있습니다. NumberFormatException 처리도 적절합니다.

modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)

70-82: LGTM!

Math.log(totalPrice + 1)을 사용한 로그 정규화와 getWeightedScore() 구현이 적절합니다. 0원 주문에 대한 안전 처리도 잘 되어 있습니다.

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

110-234: LGTM!

랭킹 목록 조회 테스트들이 잘 구성되어 있습니다. 점수 순 정렬, 빈 결과, 페이징, 특정 날짜 조회 등 주요 시나리오가 모두 커버됩니다.


236-296: LGTM!

콜드 스타트 시나리오에 대한 Fallback 테스트가 잘 작성되었습니다. 명시적 날짜 지정 시 Fallback이 동작하지 않는 것도 검증하고 있습니다.


298-429: LGTM!

상품 상세 조회 시 랭킹 정보 포함/미포함 테스트와 점수 누적 테스트가 정확하게 작성되었습니다.


431-461: LGTM!

Score Carry-Over 테스트가 10% 이월 로직을 정확히 검증합니다. isCloseTo를 사용한 부동소수점 비교도 적절합니다.

참고로, Line 458-459의 cleanup 코드는 assertion 실패 시 실행되지 않지만, @BeforeEach에서 redisCleanUp.truncateAll()을 호출하므로 다음 테스트에는 영향이 없습니다.

apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java (3)

43-68: LGTM!

Facade 패턴을 통한 이벤트 처리 위임이 잘 검증되고 있습니다. Based on learnings, 에러 처리가 EventInboxAspect로 위임되는 구조와 일치합니다.


70-94: LGTM!

처리되지 않은 이벤트가 랭킹 업데이트에서 제외되는 것을 never()로 검증하는 것이 적절합니다.


163-191: LGTM!

배치 랭킹 업데이트 테스트가 잘 작성되었습니다. argThat(list -> list.size() == 2)를 사용하여 2개의 점수가 배치로 처리되는 것을 정확히 검증합니다.

hyujikoh and others added 2 commits December 26, 2025 15:22
- 일간 랭킹 키 생성 시 날짜 형식 변경
- EventType의 weight 필드를 final로 변경하여 불변성 강화
- 로그 메시지에서 잘못된 이벤트 타입 수정
- API 문서에서 날짜 형식 설명 업데이트
refactor(cache): 캐시 키 및 페이로드 관련 코드 개선
@hyujikoh
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link

coderabbitai bot commented Dec 26, 2025

✅ Actions performed

Full review triggered.

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

♻️ Duplicate comments (3)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java (1)

47-48: 날짜 형식 명확성 확인 필요

API 문서의 example은 "2025-12-23" (ISO-8601 형식)을 보여주고 있으며, 이는 Spring의 기본 LocalDate 바인딩 형식과 일치합니다. 그러나 이전 리뷰에서 "yyyyMMdd" 형식에 대한 우려가 제기되었습니다.

현재 코드에서 @DateTimeFormat 어노테이션이 없으므로 클라이언트는 ISO 형식("yyyy-MM-dd")을 사용해야 합니다. API 문서의 example과 실제 동작이 일치하는지 확인하고, 다른 형식 지원이 필요하다면 @DateTimeFormat(pattern = "yyyyMMdd") 추가를 고려하세요.

다음 스크립트로 프로젝트 전체의 날짜 형식 처리 패턴을 확인할 수 있습니다:

#!/bin/bash
# 날짜 형식 처리 패턴 확인
rg -nP "@DateTimeFormat|DateTimeFormatter" --type=java -C3
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java (1)

40-61: 스케줄러 구현이 적절합니다.

에러 핸들링과 로깅이 잘 구현되어 있습니다. 스케줄러 실패 시에도 서비스가 계속 동작하도록 예외를 catch하고 로깅하는 방식이 적절합니다.

단, 이전 리뷰에서 지적된 대로 CommerceStreamerApplication@EnableScheduling 어노테이션이 추가되어야 @Scheduled 메서드가 동작합니다.

modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)

49-54: Javadoc 오타가 여전히 존재합니다

"조회 이벤트 생성 생성" → "조회 이벤트 생성"으로 수정이 필요합니다. (이전 리뷰에서 지적된 사항)

🧹 Nitpick comments (14)
apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java (1)

14-17: 에러 코드로 HTTP Reason Phrase 대신 커스텀 식별자 사용을 권장합니다.

현재 code 필드가 HttpStatus.getReasonPhrase()로 설정되어 "Internal Server Error", "Bad Request" 등의 값을 가집니다. 이는 status 필드와 중복되며, 프로그래밍 방식의 에러 처리나 로깅에 유용하지 않습니다.

애플리케이션별 고유 식별자(예: "ERR_INTERNAL_001", "ERR_USER_NOT_FOUND")를 사용하면 더 명확한 에러 추적과 클라이언트 측 처리가 가능합니다.

🔎 제안하는 개선안
 public enum ErrorType {
-    INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."),
-    BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."),
-    NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."),
-    CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."),
+    INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "ERR_INTERNAL_001", "일시적인 오류가 발생했습니다."),
+    BAD_REQUEST(HttpStatus.BAD_REQUEST, "ERR_BAD_REQUEST", "잘못된 요청입니다."),
+    NOT_FOUND(HttpStatus.NOT_FOUND, "ERR_NOT_FOUND", "존재하지 않는 요청입니다."),
+    CONFLICT(HttpStatus.CONFLICT, "ERR_CONFLICT", "이미 존재하는 리소스입니다."),

나머지 에러 타입들도 유사하게 적용:

  • NOT_FOUND_USER → code: "ERR_USER_NOT_FOUND"
  • NOT_FOUND_BRAND → code: "ERR_BRAND_NOT_FOUND"
  • 등등

Also applies to: 50-52

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

36-41: 스레드 풀 설정 검토 권장

현재 설정값(core=20, max=100, queue=1000, CallerRunsPolicy)은 다음을 의미합니다:

  • 최대 1,100개의 작업을 동시 처리 가능 (100 실행 중 + 1,000 대기)
  • 큐가 가득 차면 Kafka 리스너 스레드가 직접 처리하여 백프레셔 제공

프로덕션 환경에서는 다음을 고려하세요:

  • 메모리 사용량: 큐 크기 × 평균 이벤트 크기
  • 처리 지연: 큐가 가득 찰 경우 Kafka 리스너 블로킹으로 인한 Consumer lag 증가
  • 리소스 제한: CPU 코어 수와 스레드 풀 크기의 균형

실제 부하 테스트를 통해 적절한 스레드 풀 크기를 결정하는 것을 권장합니다.


54-64: 예외 처리 시 컨텍스트 정보 추가 권장

현재 catch 블록에서 예외를 로깅하고 null을 반환하지만, 어떤 이벤트에서 실패했는지 추적하기 어렵습니다. 디버깅과 모니터링을 위해 이벤트 메타데이터(topic, partition, offset)를 로그에 포함하는 것을 권장합니다.

🔎 제안된 개선
 .map(record -> CompletableFuture.supplyAsync(() -> {
     try {
         var result = eventProcessingFacade.processCatalogEvent(record.value());
         return (result.processed()) ? result.rankingScore() : null;
     } catch (Exception e) {
-        log.error("Failed to process catalog event", e);
+        log.error("Failed to process catalog event - topic: {}, partition: {}, offset: {}", 
+                  record.topic(), record.partition(), record.offset(), e);
         return null;
     }
 }, executorService))

Also applies to: 88-98


67-70: 배치 처리 타임아웃 설정 고려

CompletableFuture.join()은 무한정 대기하므로, 일부 이벤트 처리가 지연되면 전체 배치가 블로킹됩니다. 프로덕션 환경에서는 배치 처리에 타임아웃을 설정하여 일부 실패가 전체 시스템을 막지 않도록 하는 것을 고려하세요.

예: CompletableFuture.allOf(...).orTimeout(30, TimeUnit.SECONDS) 사용

Also applies to: 101-104

apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (1)

58-60: 과거 이벤트 임계값 검토 권장

현재 1시간(60분)보다 오래된 이벤트를 필터링합니다. Kafka Consumer lag이나 시스템 장애로 인해 지연된 정상 이벤트가 필터링될 수 있습니다.

프로덕션 환경의 예상 최대 지연 시간을 고려하여 임계값을 조정하거나, 설정 파일로 외부화하는 것을 권장합니다.

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

41-42: 테스트 엄격도 설정 재검토 권장

Strictness.LENIENT 설정은 사용되지 않는 stub을 허용하여 테스트가 불필요한 mock 설정을 감지하지 못하게 합니다. 특정 테스트에만 필요한 경우 메서드 레벨에서 적용하고, 클래스 레벨에서는 기본(STRICT_STUBS) 사용을 권장합니다.


72-87: Deep stubs 사용 시 주의

RETURNS_DEEP_STUBS는 편리하지만 테스트를 내부 구현에 강하게 결합시킵니다. 특히 mv.getPrice().getOriginPrice() 같은 체이닝된 호출은 Price 객체의 내부 구조 변경 시 테스트가 깨질 수 있습니다.

가능하다면 실제 객체 또는 명시적 stub을 사용하는 것을 고려하세요. 현재 사용은 헬퍼 메서드에 국한되어 있어 큰 문제는 아닙니다.

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

169-177: 랭킹 순서 정렬 로직의 시간 복잡도 개선을 권장합니다.

현재 구현은 각 productId에 대해 products 리스트를 순회하므로 O(n*m) 복잡도입니다. 상품 수가 많아지면 성능 저하가 발생할 수 있습니다.

🔎 Map을 활용한 개선 제안
-        // 5. 랭킹 순서대로 정렬
-        List<ProductInfo> sortedProducts = productIds.stream()
-                .map(productId -> products.stream()
-                        .filter(p -> p.getProductId().equals(productId))
-                        .findFirst()
-                        .map(ProductInfo::from)
-                        .orElse(null))
-                .filter(Objects::nonNull)
-                .collect(Collectors.toList());
+        // 5. 랭킹 순서대로 정렬 (Map을 활용하여 O(n) 조회)
+        Map<Long, ProductMaterializedViewEntity> productMap = products.stream()
+                .collect(Collectors.toMap(ProductMaterializedViewEntity::getProductId, p -> p));
+        
+        List<ProductInfo> sortedProducts = productIds.stream()
+                .map(productMap::get)
+                .filter(Objects::nonNull)
+                .map(ProductInfo::from)
+                .collect(Collectors.toList());
modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (1)

264-275: Carry-Over 시 Pipeline 미사용으로 인한 성능 저하 가능성

다른 배치 업데이트 메서드와 달리 carryOverScores는 개별 incrementScore 호출을 사용합니다. 상품 수가 많을 경우 Redis 왕복 횟수가 증가하여 성능 저하가 발생할 수 있습니다.

🔎 Pipeline 적용 제안
            // 가중치를 적용하여 대상 키에 추가 (기존 점수에 합산)
            ZSetOperations<String, String> zSetOps = redisTemplate.opsForZSet();
            
-            for (ZSetOperations.TypedTuple<String> tuple : sourceData) {
-                String member = tuple.getValue();
-                Double score = tuple.getScore();
-                
-                if (member != null && score != null) {
-                    double weightedScore = score * carryOverWeight;
-                    zSetOps.incrementScore(targetKey, member, weightedScore);
-                }
-            }
+            redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
+                for (ZSetOperations.TypedTuple<String> tuple : sourceData) {
+                    String member = tuple.getValue();
+                    Double score = tuple.getScore();
+                    
+                    if (member != null && score != null) {
+                        double weightedScore = score * carryOverWeight;
+                        zSetOps.incrementScore(targetKey, member, weightedScore);
+                    }
+                }
+                return null;
+            });
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (2)

185-196: 이벤트 캐시 정리 시 순서 보장 없음

ConcurrentHashMap은 순회 순서를 보장하지 않으므로, limit(toRemove)로 제거되는 항목이 가장 오래된 것이 아닐 수 있습니다. 최근 처리된 이벤트가 제거되면 중복 처리가 발생할 수 있습니다. DB 백업이 있어 심각하지는 않지만, LinkedHashMap 또는 시간 기반 만료를 고려해 보세요.


140-145: 사용되지 않는 매개변수

occurredAtEpochMillis 매개변수가 메서드 내에서 사용되지 않습니다. API 일관성을 위해 유지하는 것이라면 괜찮지만, 그렇지 않다면 제거를 고려해 보세요.

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

108-126: from()fromWithRanking() 간 코드 중복

두 팩토리 메서드가 거의 동일한 매핑 로직을 가지고 있습니다. 현재 규모에서는 허용 가능하지만, 필드가 추가될 때 동기화 누락 가능성이 있습니다. 공통 빌더나 private 헬퍼 메서드 추출을 고려해 보세요.

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

50-50: 사용되지 않는 ObjectMapper 필드

objectMapper 필드가 선언되어 있지만 writeValueAsString으로만 사용됩니다. @Mock이나 @InjectMocks가 아니므로 테스트 간 상태 공유 위험은 없지만, 각 테스트에서 로컬 변수로 사용하거나 @BeforeEach에서 초기화하는 것이 더 명확합니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (1)

89-92: getOrCreateMetrics에서 생성된 엔티티는 저장되지 않음

새로 생성된 ProductMetricsEntity는 이 헬퍼에서 저장되지 않고 호출자가 저장해야 합니다. 현재 모든 호출자가 저장하고 있어 문제는 없지만, 향후 새 호출자 추가 시 주의가 필요합니다. Javadoc에 이 동작을 명시하면 좋겠습니다.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bdb5e90 and 5950d66.

📒 Files selected for processing (36)
  • 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/domain/product/ProductMVService.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.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/ProductV1Dtos.java
  • apps/commerce-api/src/main/java/com/loopers/support/Uris.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java
  • apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java
  • apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
  • modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java
  • modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java
  • modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java
💤 Files with no reviewable changes (4)
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
🧰 Additional context used
🧠 Learnings (9)
📚 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/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.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
  • modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.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/ProductV1ApiSpec.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/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java
📚 Learning: 2025-12-21T15:12:57.845Z
Learnt from: hyujikoh
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 200
File: apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java:29-36
Timestamp: 2025-12-21T15:12:57.845Z
Learning: In MetricsLockCleanupScheduler in apps/commerce-streamer (Loopers-dev-lab/loopers-spring-java-template), the scheduled cleanup methods (cleanupUnusedLocks, cleanupProcessedEvents, monitorLockStatus) operate on JVM-local memory structures (ConcurrentHashMap for locks and idempotency cache in MetricsService). Each application instance has its own separate memory space, so duplicate execution across multiple instances is the intended behavior—each instance must clean up its own memory. Distributed locking is not needed for JVM-internal memory cleanup tasks.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.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-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.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/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.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/MetricsKafkaConsumer.java
📚 Learning: 2025-12-19T23:39:20.851Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-streamer/src/main/kotlin/com/loopers/interfaces/consumer/product/ProductEventConsumer.kt:0-0
Timestamp: 2025-12-19T23:39:20.851Z
Learning: In Loopers-dev-lab/loopers-spring-kotlin-template, toongri prefers pragmatic idempotency handling in Kafka consumers: when idempotency key persistence fails after successful business logic execution, log a warning and continue (accepting low risk of duplicates on retry) rather than rolling back business data. This keeps business logic decoupled from idempotency store (which might be Redis/external system, not RDB), avoids over-engineering for rare edge cases, and prioritizes core business value over auxiliary metadata. Transaction-based atomicity between business data and idempotency keys is considered excessive when duplicate event probability is very low.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
📚 Learning: 2025-12-18T13:24:54.339Z
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:54.339Z
Learning: In this codebase, Kafka consumers delegate error handling and event tracking to the service layer via EventInboxAspect. Service methods annotated with InboxEvent are intercepted by the aspect, which handles failures by saving failed EventInbox entries and logging errors. This centralized approach avoids duplicating error handling logic across multiple consumers.

Applied to files:

  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
🧬 Code graph analysis (13)
apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-81)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-81)
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-81)
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (1)
apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (1)
  • Service (44-240)
apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java (3)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-81)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java (1)
  • ProductV1Dtos (10-168)
apps/commerce-api/src/main/java/com/loopers/support/Uris.java (3)
  • Uris (9-115)
  • Ranking (85-91)
  • Product (73-80)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (2)
apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (1)
  • Service (44-240)
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (1)
  • Service (40-207)
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java (2)
modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (1)
  • Service (27-291)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-81)
apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java (2)
apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)
  • Getter (5-19)
apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt (1)
  • status (5-11)
apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java (1)
apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java (1)
  • ConfigurationPropertiesScan (14-30)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-81)
apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt (1)
  • errorType (3-6)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-81)
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java (2)
apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java (1)
  • EventProcessingResult (11-44)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
  • CachePayloads (10-81)
🔇 Additional comments (54)
apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java (3)

47-51: 클린업 유틸리티 주입이 올바르게 설정되어 있습니다.

DatabaseCleanUpRedisCleanUp은 모두 @Component 어노테이션으로 등록되어 있으며, @SpringBootTest 컨텍스트에서 컴포넌트 스캔을 통해 자동으로 빈으로 등록됩니다. 따라서 @Autowired를 통한 주입이 정상적으로 작동합니다.


55-56: 명시적 클린업 전략이 Kafka 통합 테스트에 적합합니다.

@Transactional 롤백 대신 명시적 클린업을 사용하는 것은 다음 이유로 올바릅니다:

  • Kafka 메시지 처리는 트랜잭션 경계 밖에서 비동기적으로 발생
  • Redis 상태는 트랜잭션 롤백으로 정리되지 않음
  • truncateAllTables()truncateAll() 모두 동기적으로 완료되어 테스트 격리 보장

22-23: 클린업 유틸리티 클래스들이 모두 올바르게 구현되어 있습니다.

  • DatabaseCleanUp: EntityManager를 통해 모든 @entity 클래스를 자동 검색하고, 외래 키 제약 조건을 SET FOREIGN_KEY_CHECKS = 0/1로 처리한 후 모든 테이블을 TRUNCATE합니다.
  • RedisCleanUp: flushAll() 명령으로 Redis의 모든 키를 정리합니다.
  • setUp() 메서드: @beforeeach를 통해 각 테스트 전에 두 유틸리티의 정리 메서드를 순서대로 호출하여 테스트 격리를 보장합니다.

비동기 메시지 처리(Kafka)를 포함하는 통합 테스트에 적합한 구조입니다.

apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)

5-18: 구현이 정확합니다.

CoreException 클래스의 구조와 생성자 로직이 올바르게 구현되었습니다:

  • RuntimeException 확장으로 unchecked exception 패턴 준수
  • Final 필드로 불변성 보장
  • 생성자 위임 패턴이 깔끔하게 적용됨
  • Kotlin 버전과의 일관성 유지
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java (1)

25-29: LGTM!

메서드 간 가독성을 위한 포맷팅 변경입니다.

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

8-14: LGTM!

@EnableScheduling 추가로 RankingCarryOverScheduler, MetricsLockCleanupScheduler 등 스케줄러 컴포넌트가 정상 동작할 수 있습니다. CommerceApiApplication과 일관된 구성입니다.

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

57-65: LGTM!

배치 조회 메서드가 기존 findByIdsAsPage와 일관된 패턴으로 구현되었습니다. 랭킹 기능에서 다수의 상품 MV를 조회하는 데 유용합니다.

apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java (3)

40-71: LGTM!

테스트 설정이 적절합니다. @BeforeEach에서 Redis 정리 후 테스트를 실행하여 격리된 테스트 환경을 보장합니다.


72-199: LGTM!

Kafka → Redis ZSET 적재 테스트가 PR 요구사항에 맞게 구현되었습니다:

  • PRODUCT_VIEW: 0.1점
  • LIKE_ACTION: 0.2점
  • PAYMENT_SUCCESS: 0.6 × log(totalPrice + 1)
  • 동일 상품 점수 누적 검증

201-302: LGTM!

랭킹 조회 및 Score Carry-Over 테스트가 잘 구현되었습니다:

  • 점수 높은 순 정렬 검증
  • 10% 점수 이월 동작 검증
  • 원본 데이터 없을 때 스킵 동작 검증

콜드 스타트 대응 요구사항을 충족합니다.

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

23-55: LGTM!

RankingItem ranking 필드 추가와 오버로드된 팩토리 메서드 구현이 잘 되었습니다. 기존 코드와의 하위 호환성을 유지하면서 랭킹 정보를 확장했습니다.


93-119: LGTM!

fromWithSyncLike가 기존 랭킹을 유지하고, fromWithRanking이 새 랭킹으로 복사본을 생성하는 불변 패턴이 올바르게 구현되었습니다.

apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java (2)

207-207: LGTM!

ProductFacade.deleteProduct 메서드명 변경이 테스트에 올바르게 반영되었습니다.


343-343: LGTM!

동일한 메서드명 변경이 일관되게 적용되었습니다.

apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java (1)

275-275: LGTM!

ProductFacade.deleteBrand 메서드명 변경이 테스트에 올바르게 반영되었습니다.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java (1)

6-6: LGTM!

MetricsService import 경로가 DDD 계층 구조 리팩토링에 맞게 domainapplication 패키지로 업데이트되었습니다. Based on learnings, 이 스케줄러는 JVM-local 메모리 구조에서 동작하므로 분산 락이 필요하지 않습니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (1)

5-24: Javadoc 추가로 문서화 개선됨

Repository 인터페이스에 명확한 Javadoc 주석이 추가되어 각 메서드의 역할이 분명해졌습니다. deleteAll() 메서드 제거는 의도적인 API 축소로 보이며, 안전한 Repository 설계에 부합합니다.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (2)

40-49: 랭킹 엔드포인트 구현 적절함

새로운 랭킹 조회 엔드포인트가 Facade 패턴을 따라 깔끔하게 구현되었습니다. Pageable과 선택적 날짜 파라미터를 받아 일관된 응답 형식으로 반환합니다.


58-60: 반환 로직 간소화 개선

중간 변수를 제거하고 직접 반환하도록 변경하여 코드가 더 간결해졌습니다.

apps/commerce-api/src/main/java/com/loopers/support/Uris.java (1)

82-91: 엔드포인트 상수 추가 적절함

새로운 Ranking 엔드포인트 그룹이 기존 패턴(private 생성자, BASE 및 특정 경로 상수)을 일관되게 따르고 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java (1)

114-128: Graceful shutdown 구현 우수

PreDestroy 훅을 통한 스레드 풀 종료 처리가 잘 구현되어 있습니다. 10초 대기 후 강제 종료하는 방식은 적절하며, InterruptedException 처리도 올바릅니다.

apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (2)

144-155: 랭킹 점수 업데이트 실패 처리 확인 필요

배치 업데이트 실패 시 예외를 로깅만 하고 삼켜버립니다. 랭킹 데이터가 비즈니스에 중요하다면 다음을 고려하세요:

  • 실패한 점수를 Dead Letter Queue에 저장
  • 재시도 메커니즘 구현
  • 모니터링 메트릭 발행

현재 구현은 best-effort 방식이므로, 이것이 의도된 동작인지 확인이 필요합니다.

Based on learnings, 멱등성 키 저장 실패 시 비즈니스 로직을 롤백하지 않는 실용적 접근을 선호한다는 것을 알고 있지만, 랭킹 점수 업데이트는 핵심 기능이므로 실패 처리 전략을 명확히 하는 것이 좋습니다.


67-99: 이벤트 처리 구조 우수

Application Facade 패턴이 잘 적용되었습니다:

  • 역직렬화, 유효성 검증, 멱등성 체크를 한 곳에서 처리
  • Domain 서비스(RankingService, MetricsService)에 위임
  • 명확한 책임 분리와 레이어 구분

Also applies to: 107-136

apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java (1)

1-44: 처리 결과 DTO 설계 우수

Record 타입과 정적 팩토리 메서드를 활용하여 명확하고 간결한 DTO를 구현했습니다. processed 플래그와 RankingScore를 함께 반환하여 처리 여부와 결과를 한 번에 전달할 수 있습니다.

apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java (3)

89-159: 랭킹 조회 테스트 커버리지 우수

랭킹 상품 목록 조회의 핵심 시나리오를 잘 커버하고 있습니다:

  • 랭킹 순서 유지 확인
  • 빈 데이터 처리
  • null 날짜 파라미터 기본값 처리

161-234: 콜드 스타트 Fallback 로직 테스트 포괄적

중요한 비즈니스 로직인 콜드 스타트 대응이 잘 테스트되었습니다:

  • 오늘 → 어제 fallback
  • 명시적 날짜 지정 시 fallback 없음
  • 둘 다 비어있는 경우 처리

PR 목표에 명시된 콜드 스타트 요구사항을 충실히 검증하고 있습니다.


236-286: 상품 상세 랭킹 통합 테스트 적절

상품 상세 조회 시 랭킹 정보 포함/미포함 케이스를 모두 테스트하여 null 처리가 올바른지 확인하고 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java (1)

70-73: 수동 Carry-Over 메서드 구현 확인

테스트 및 운영 목적의 수동 실행 메서드가 적절하게 구현되어 있습니다.

modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java (1)

155-166: extractDateFromKey 구현이 적절합니다.

키 형식 검증 후 날짜 추출 및 파싱 로직이 올바르게 구현되어 있습니다. 잘못된 형식의 키에 대해 IllegalArgumentException을 던지는 것도 적절합니다.

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

77-108: 테스트 셋업이 잘 구성되어 있습니다.

DB/Redis 클린업, 테스트 데이터 생성, MV 동기화까지 E2E 테스트에 필요한 모든 준비 과정이 적절하게 구현되어 있습니다.


240-267: 콜드 스타트 Fallback 테스트가 핵심 시나리오를 잘 검증합니다.

오늘 랭킹이 없을 때 어제 랭킹으로 폴백하는 동작을 명확하게 테스트하고 있습니다.


435-460: Carry-Over 테스트에서 리소스 정리가 적절합니다.

테스트 전 tomorrowKey 삭제와 테스트 후 정리(Line 459)로 테스트 격리가 잘 되어 있습니다. Offset.offset(0.01)을 사용한 부동소수점 비교도 적절합니다.

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

45-59: 이벤트 타입별 랭킹 점수 생성 로직이 적절합니다.

switch 표현식을 사용하여 이벤트 타입별로 분기 처리하고, 지원하지 않는 타입에 대해서는 debug 레벨 로깅 후 null을 반환하는 방식이 적절합니다.


151-164: 좋아요 취소 이벤트 처리 정책이 명확합니다.

"LIKE" 액션만 점수에 반영하고 "UNLIKE"는 무시하는 정책이 명확하게 구현되어 있습니다. PR 명세에 따라 좋아요 취소 시 점수 차감 없이 누적만 하는 방식입니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)

38-55: 멱등성 처리를 위한 이중 확인 패턴이 적절합니다.

트랜잭션 내에서 다시 한번 existsById를 확인하여 동시성 문제를 방지하고, 예외 발생 시 false를 반환하여 중복 저장을 안전하게 처리합니다.

다만, 모든 Exception을 catch하는 것은 예상치 못한 오류도 숨길 수 있으므로, 운영 환경에서는 특정 예외(예: DataIntegrityViolationException)만 처리하고 나머지는 re-throw하는 것을 고려해볼 수 있습니다.

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

108-111: 상품 상세 조회 시 실시간 랭킹 정보 병합이 적절합니다.

오늘 날짜 기준으로 Redis에서 랭킹 정보를 조회하여 상품 상세 정보에 포함시키는 구현이 올바릅니다.


140-154: 콜드 스타트 Fallback 로직이 PR 명세에 맞게 구현되었습니다.

날짜 미지정 시(date == null) 오늘 랭킹이 비어있으면 어제 랭킹으로 폴백하고, 명시적 날짜 지정 시에는 폴백하지 않는 정책이 올바르게 구현되어 있습니다.

apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java (3)

42-67: Facade 기반 테스트 구조가 적절합니다.

Consumer가 EventProcessingFacade에 위임하는 구조를 반영하여, Facade mock을 통해 테스트하는 방식이 올바릅니다.


124-163: 부분 실패 시나리오 테스트가 이전 리뷰 피드백을 반영했습니다.

이전 리뷰에서 지적된 대로, 성공한 메시지의 랭킹 점수가 실제로 업데이트되는지 검증하는 assertion(Line 158-161)이 추가되었습니다.


165-193: 배치 랭킹 업데이트 테스트가 추가되었습니다.

여러 이벤트의 랭킹 점수가 한 번의 배치로 업데이트되는 시나리오를 검증하는 테스트가 적절하게 구현되어 있습니다.

modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (2)

42-83: 배치 업데이트 구현이 적절합니다.

날짜별 그룹화, 상품별 점수 집계, Redis Pipeline 사용, TTL 설정까지 효율적인 배치 처리가 잘 구현되어 있습니다.


140-185: 페이징 랭킹 조회 구현이 올바릅니다.

1-based 페이지 인덱스를 Redis의 0-based 인덱스로 변환하고, reverseRangeWithScores를 사용하여 점수 높은 순으로 조회하는 로직이 적절합니다. NumberFormatException 처리로 잘못된 데이터에 대한 방어도 되어 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (2)

71-95: 멱등성 체크 로직 LGTM

메모리 캐시를 통한 빠른 경로와 DB 확인을 통한 2단계 멱등성 체크가 잘 구현되어 있습니다. 동시성 충돌 시에도 캐시에 기록하여 후속 중복 호출을 빠르게 차단합니다.


149-166: 락 획득 실패 시 업데이트 누락 가능성 검토 필요

tryLock이 실패하면 메트릭 업데이트가 조용히 스킵됩니다. 높은 경합 상황에서 일부 이벤트가 메트릭에 반영되지 않을 수 있습니다. 메트릭 정확도가 중요하다면 재시도 로직 또는 대기 시간 증가를 고려해 보세요.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java (2)

80-106: null-safe 랭킹 매핑 LGTM

productDetailInfo.ranking()이 null인 경우를 적절히 처리하여 안전하게 RankingResponse를 생성합니다.


159-167: RankingResponse 레코드 LGTM

랭킹 정보를 표현하기 위한 간결한 레코드입니다. Swagger 스키마 어노테이션이 적절히 적용되어 있습니다.

apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java (3)

52-181: 랭킹 점수 생성 테스트 LGTM

이벤트 타입별 점수 생성 로직이 잘 테스트되어 있습니다. 가중치 계산(PRODUCT_VIEW: 0.1, LIKE_ACTION: 0.2, PAYMENT_SUCCESS: 0.6)과 로그 정규화 검증이 적절합니다. UNLIKE 이벤트가 null을 반환하는 케이스도 포함되어 있습니다.


183-232: 배치 업데이트 테스트 LGTM

빈 리스트 처리와 날짜가 null인 경우의 오버로드 메서드 호출을 올바르게 검증합니다.


234-295: 랭킹 조회 테스트 LGTM

페이징된 랭킹 조회와 개별 상품 랭킹 조회, 랭킹에 없는 상품의 null 반환을 적절히 검증합니다.

modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (2)

27-38: EventType enum 스레드 안전성 개선 확인

weight 필드가 final로 선언되어 이전 리뷰에서 지적된 스레드 안전성 문제가 해결되었습니다.


14-18: RankingItem 레코드 LGTM

랭킹 아이템을 표현하기 위한 간결한 레코드입니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (3)

31-37: 조회수 증가 로직 LGTM

getOrCreateMetrics를 통해 메트릭을 조회하거나 생성한 후 조회수를 증가시킵니다. 트랜잭션 내에서 안전하게 처리됩니다.


44-65: 좋아요 수 변경 로직 LGTM

새 상품에 대한 좋아요 감소를 무시하는 방어적 로직이 잘 구현되어 있습니다. 기존/신규 메트릭에 대한 분기 처리가 명확합니다.


72-84: 판매량 증가 로직 LGTM

수량 검증과 메트릭 업데이트가 트랜잭션 내에서 안전하게 처리됩니다.


//좋아요 관련 오류
ALREADY_LIKED_PRODUCT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 좋아요한 상품입니다."),
NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "좋아요하지 않은 상품입니다."),
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

NOT_EXIST_LIKED의 HTTP 상태 코드를 NOT_FOUND로 변경하세요.

"좋아요하지 않은 상품"은 리소스가 존재하지 않는 경우이므로 HttpStatus.NOT_FOUND (404)가 더 적절합니다. BAD_REQUEST (400)는 클라이언트 요청 형식이 잘못된 경우에 사용됩니다.

🔎 수정 제안
-    NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "좋아요하지 않은 상품입니다."),
+    NOT_EXIST_LIKED(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "좋아요하지 않은 상품입니다."),
📝 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
NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "좋아요하지 않은 상품입니다."),
NOT_EXIST_LIKED(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "좋아요하지 않은 상품입니다."),
🤖 Prompt for AI Agents
In apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java
around line 32, update the NOT_EXIST_LIKED enum entry to use
HttpStatus.NOT_FOUND instead of HttpStatus.BAD_REQUEST: replace both the
HttpStatus.BAD_REQUEST argument and the status reason argument
(HttpStatus.BAD_REQUEST.getReasonPhrase()) with HttpStatus.NOT_FOUND and
HttpStatus.NOT_FOUND.getReasonPhrase(), leaving the message "좋아요하지 않은 상품입니다."
unchanged.

Comment on lines 137 to 146
/**
* 일간 랭킹 키 생성: ranking:all:2025-12-23
*/
public String generateDailyRankingKey(LocalDate date) {
return new StringJoiner(DELIMITER)
.add(RANKING_PREFIX)
.add(ALL_PREFIX)
.add(date.format(DATE_FORMATTER))
.toString();
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

JavaDoc 주석과 실제 키 형식이 불일치합니다.

주석에는 ranking:all:2025-12-23 형식으로 표기되어 있지만, DATE_FORMATTERyyyyMMdd 패턴을 사용하므로 실제 생성되는 키는 ranking:all:20251223 형식입니다.

🔎 수정 제안
    /**
-    * 일간 랭킹 키 생성: ranking:all:2025-12-23
+    * 일간 랭킹 키 생성: ranking:all:20251223
     */
    public String generateDailyRankingKey(LocalDate date) {
📝 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
/**
* 일간 랭킹 생성: ranking:all:2025-12-23
*/
public String generateDailyRankingKey(LocalDate date) {
return new StringJoiner(DELIMITER)
.add(RANKING_PREFIX)
.add(ALL_PREFIX)
.add(date.format(DATE_FORMATTER))
.toString();
}
/**
* 일간 랭킹 생성: ranking:all:20251223
*/
public String generateDailyRankingKey(LocalDate date) {
return new StringJoiner(DELIMITER)
.add(RANKING_PREFIX)
.add(ALL_PREFIX)
.add(date.format(DATE_FORMATTER))
.toString();
}
🤖 Prompt for AI Agents
In modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java around
lines 137-146, the JavaDoc example shows a hyphenated date
(ranking:all:2025-12-23) but the code uses DATE_FORMATTER with pattern yyyyMMdd
producing ranking:all:20251223; update the JavaDoc to match the actual key
format (e.g., ranking:all:20251223) OR, if the intended key must include dashes,
change DATE_FORMATTER to use yyyy-MM-dd and adjust any dependent code/tests
accordingly.

Comment on lines 66 to 71
public static RankingScore forPaymentSuccess(Long productId, java.math.BigDecimal totalPrice, long occurredAt) {
// 로그 정규화 적용하여 극값 방지
// Math.log(x + 1)을 사용하여 0원일 때도 안전하게 처리
double normalizedScore = Math.log(totalPrice.doubleValue() + 1);
return new RankingScore(productId, EventType.PAYMENT_SUCCESS, normalizedScore, occurredAt);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

totalPrice가 null인 경우 NPE 발생 가능

totalPrice.doubleValue()totalPrice가 null이면 NullPointerException을 발생시킵니다. 호출자가 null을 전달하지 않도록 보장하거나, 방어적으로 null 체크를 추가하세요.

🔎 수정 제안
 public static RankingScore forPaymentSuccess(Long productId, java.math.BigDecimal totalPrice, long occurredAt) {
+    if (totalPrice == null) {
+        totalPrice = java.math.BigDecimal.ZERO;
+    }
     // 로그 정규화 적용하여 극값 방지
     // Math.log(x + 1)을 사용하여 0원일 때도 안전하게 처리
     double normalizedScore = Math.log(totalPrice.doubleValue() + 1);
     return new RankingScore(productId, EventType.PAYMENT_SUCCESS, normalizedScore, occurredAt);
 }
📝 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 static RankingScore forPaymentSuccess(Long productId, java.math.BigDecimal totalPrice, long occurredAt) {
// 로그 정규화 적용하여 극값 방지
// Math.log(x + 1)을 사용하여 0원일 때도 안전하게 처리
double normalizedScore = Math.log(totalPrice.doubleValue() + 1);
return new RankingScore(productId, EventType.PAYMENT_SUCCESS, normalizedScore, occurredAt);
}
public static RankingScore forPaymentSuccess(Long productId, java.math.BigDecimal totalPrice, long occurredAt) {
if (totalPrice == null) {
totalPrice = java.math.BigDecimal.ZERO;
}
// 로그 정규화 적용하여 극값 방지
// Math.log(x + 1)을 사용하여 0원일 때도 안전하게 처리
double normalizedScore = Math.log(totalPrice.doubleValue() + 1);
return new RankingScore(productId, EventType.PAYMENT_SUCCESS, normalizedScore, occurredAt);
}
🤖 Prompt for AI Agents
In modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java around
lines 66 to 71, the method uses totalPrice.doubleValue() which will throw NPE if
totalPrice is null; add a defensive null check at the start of forPaymentSuccess
and handle null safely (either throw a clear IllegalArgumentException with a
descriptive message or treat null as BigDecimal.ZERO), then compute the
normalized score from the non-null BigDecimal (e.g., add 1 before taking
Math.log) and return the RankingScore as before.

hyujikoh and others added 2 commits December 30, 2025 21:35
- 일간 랭킹 키 생성 시 날짜 형식을 변경하여 일관성 향상
- 포인트 충전 관련 오류 메시지를 보다 명확하게 수정
- BigDecimal 사용 시 불필요한 패키지 경로 제거
refactor(cache): 캐시 키 형식 및 오류 메시지 개선
@hyujikoh hyujikoh merged commit 9d05ec6 into Loopers-dev-lab:hyujikoh Jan 2, 2026
1 check passed
@coderabbitai coderabbitai bot mentioned this pull request Jan 2, 2026
4 tasks
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