Skip to content

Conversation

@kilian-develop
Copy link
Collaborator

@kilian-develop kilian-develop commented Dec 25, 2025

📌 Summary

구현 기능

  • 상품 좋아요 시 발생한 이벤트를 컨슈밍하여 상품 랭킹 점수 증가
  • 상품 조회 시 발생한 이벤트를 컨슈밍하여 상품 랭킹 점수 증가
  • 상품 구매 시 발생한 이벤트를 컨슈밍하여 상품 랭킹 점수 증가
  • 상품 상세 조회 시 랭킹 정보 추가
  • 상품 랭킹 조회 API 추가

상품 랭킹 스코어 증가 구현

상품 좋아요, 구매, 상세 조회 시 상품 랭킹 스코어를 증가시키는 컨슈머 및 서비스를 추가했습니다.

    @KafkaListener(
            topics = {"${spring.kafka.topic.product-like}"},
            containerFactory = KafkaConfig.BATCH_LISTENER,
            groupId = "increase-product-like-ranking-score"
    )
    public void listen(
            List<ConsumerRecord<Object, String>> records,
            Acknowledgment acknowledgment
    ) {
        records.stream()
                .map(event -> JacksonUtil.convertToObject(event.value(), IncreaseProductLikeRankingScoreEvent.class))
                .map(IncreaseProductLikeRankingScoreEvent::toCommand)
                .forEach(service::increase);
        acknowledgment.acknowledge();
    }
@Service
@RequiredArgsConstructor
public class IncreaseProductLikeRankingScoreService {

    private final ProductRankingCacheRepository productRankingCacheRepository;

    @Value("${product.ranking.score.weight.like}")
    private double weight;

    @InboxEvent(
            aggregateType = "PRODUCT",
            eventType = "INCREASE_PRODUCT_LIKE_RANKING_SCORE",
            eventIdField = "eventId",
            aggregateIdField = "productId"
    )
    public void increase(IncreaseProductLikeRankingScoreCommand command) {
        ProductId productId = new ProductId(command.productId());
        productRankingCacheRepository.increaseDaily(productId, LocalDate.now(), weight);
    }
}

좋아요, 조회, 구매의 가중치는 yml을 통해 설정으로 분리했습니다.

product:
  ranking:
    score:
      weight:
        like: 0.2
        view: 0.1
        pay: 0.7
        carryOver: 0.1

상품 랭킹 점수는 하루 기준으로 관리했습니다.
TTL과 같은 기준은 과제의 기준으로 구현했습니다.

    private static final long TTL_DAYS = 2;
    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");

    @Override
    public void increaseDaily(String productId, LocalDate date, Double score) {
        String key = RANKING_KEY_PREFIX + date.format(DATE_FORMATTER);
        redisTemplate.opsForZSet().incrementScore(key, productId, score);
        redisTemplate.expire(key, Duration.ofDays(TTL_DAYS));
    }

상품 랭킹 조회 구현

Redis를 통해 페이지네이션 형식으로 구현했습니다.
reverseRangeWithScores를 통해 시작과 끝 범위 정해 구현했습니다.

    @Override
    public Set<TypedTuple<String>> getTopProductsWithScores(LocalDate date, long start, long stop) {
        String key = RANKING_KEY_PREFIX + date.format(DATE_FORMATTER);
        return redisTemplate.opsForZSet().reverseRangeWithScores(key, start, stop);
    }
    @Override
    public ProductRankings getRankings(LocalDate date, int pageNo, int pageSize) {
        long start = (long) pageNo * pageSize;
        long stop = start + pageSize - 1;

        Set<TypedTuple<String>> topProducts = repository.getTopProductsWithScores(date, start, stop);
        Long totalElements = repository.countRankings(date);

        if (totalElements == null) {
            totalElements = 0L;
        }

        if (topProducts == null || topProducts.isEmpty()) {
            return new ProductRankings(List.of(), totalElements, 0, false, pageNo > 0);
        }

        List<TypedTuple<String>> tuples = topProducts.stream().toList();
        List<ProductRanking> rankings = IntStream.range(0, tuples.size())
                .mapToObj(index -> createProductRanking(tuples.get(index), pageNo, pageSize, index))
                .toList();

        int totalPages = (int) Math.ceil((double) totalElements / pageSize);
        boolean hasNext = pageNo < totalPages - 1;
        boolean hasPrevious = pageNo > 0;

        return new ProductRankings(rankings, totalElements, totalPages, hasNext, hasPrevious);
    }

상품 상세 조회에 랭킹정보 추가

redis zset의 reverseRank, score 를 통해 상품ID로 랭킹을 조회 하도록 했습니다.

    @Override
    public Double getRankScore(LocalDate date, String productId) {
        String key = RANKING_KEY_PREFIX + date.format(DATE_FORMATTER);
        return redisTemplate.opsForZSet().score(key, productId);
    }

    @Override
    public Long getRankingPosition(LocalDate date, String productId) {
        String key = RANKING_KEY_PREFIX + date.format(DATE_FORMATTER);
        Long rank = redisTemplate.opsForZSet().reverseRank(key, productId);
        if (rank == null) {
            return null;
        }
        return rank + 1;
    }

carryOver

콜드스타트 문제를 해결하기위해 carryOver를 구현했습니다. scheduler는 매일 23시 50분에 동작합니다.

@Component
@RequiredArgsConstructor
public class ProductRankingCarryOverScheduler {

    private final ProductRankingCarryOverService service;

    @Scheduled(cron = "0 50 23 * * *")
    public void carryOver() {
        service.carryOver();
    }
}

carryOver는 unionAndStore를 통해 구현했고 이전 점수의 가중치를 설정하여 구현했습니다.
이 가중치 또한 yml로 분리했습니다.

@Service
@RequiredArgsConstructor
public class ProductRankingCarryOverService {

    private final ProductRankingCacheRepository productRankingCacheRepository;

    @Value("${product.ranking.score.weight.carryOver}")
    private Double carryOverWeight;

    public void carryOver() {
        LocalDate today = LocalDate.now();
        LocalDate tomorrow = LocalDate.now().plusDays(1);

        productRankingCacheRepository.carryOver(today, tomorrow, carryOverWeight);
    }
}
    @Override
    public void carryOverWithWeights(String sourceKey, String destKey, Double weight) {
        redisTemplate.opsForZSet().unionAndStore(
                sourceKey,
                Collections.emptyList(),
                destKey,
                Aggregate.SUM,
                Weights.of(weight)
        );
    }

💬 Review Points

  1. 배치 리스너를 통해 이벤트를 받아 스코어를 증가시키는 방식에서 이미 배치 방식으로 수행하는 것 자체가 어느정도의 실시간성을 포기하고 가져가는 것이라면 23시 59분에 구매,조회,좋아요가 진행됐지만 다음날의 스코어로 증가되는 부분은 어느정도 감안하고 가는게 맞을까요?

  2. 멘토링을 들었을 때, 뭔가 대규모 시스템에서는 랭킹 시스템을 redis의 zset을 이용하여 구현하는 것이 부적절해 보였는데(필터링 이슈..) 실제 대규모 시스템에서 어떻게 구현할 수 있을지? 면접 질문으로 들어온다면 어떻게 대답할 수 있을까요?

✅ Checklist

📈 Ranking Consumer

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

⚾ Ranking API

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

📎 References

Summary by CodeRabbit

릴리스 노트

  • 새 기능

    • 상품 랭킹 조회 기능 추가
    • 페이지네이션을 지원하는 상품 랭킹 API 제공
    • 상품 상세정보에 랭킹 데이터 포함
  • 테스트

    • 상품 랭킹 서비스 통합 테스트 추가

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

@kilian-develop kilian-develop self-assigned this Dec 25, 2025
@coderabbitai
Copy link

coderabbitai bot commented Dec 25, 2025

Important

Review skipped

Auto incremental reviews are disabled on this repository.

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

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

Walkthrough

상품 순위 기능을 구현하는 대규모 기능 추가 PR입니다. Kafka 기반의 이벤트 처리, Redis 캐싱, API 엔드포인트, 스케줄러를 포함하며 좋아요/조회/판매 이벤트에 따라 동적으로 상품 순위 점수를 계산 및 관리합니다.

Changes

Cohort / File(s) 변경 요약
API 레이어
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Api.java, ProductV1ApiSpec.java, ProductV1Dto.java, ProductV1ApiIntegrationTest.java
새로운 /rankings 엔드포인트 추가, GetProductRankingService 의존성 주입, 랭킹 응답 DTO 추가 (GetProductRankingsResponse, ProductRankingResponse, GetProductDetailRanking), 상품 상세 조회에 랭킹 정보 포함, 날짜 기반 페이징 파라미터 추가
스케줄러
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductLikeEventScheduler.java, ProductRankingCarryOverScheduler.java
1초 간격의 좋아요 이벤트 발행 스케줄러, 매일 23:50에 실행되는 순위 이월 스케줄러 추가
Kafka 컨슈머 - 순위 점수 업데이트
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeRankingScoreKafkaConsumer.java, IncreaseProductSalesRankingScoreKafkaConsumer.java, IncreaseProductViewRankingScoreKafkaConsumer.java
좋아요/판매/조회 이벤트를 수신하여 Redis 상품 순위 점수 업데이트
Kafka 컨슈머 설정 업데이트
IncreaseProductTotalSalesKafkaConsumer.java, IncreaseProductViewKafkaConsumer.java
컨슈머 그룹 ID 추가
도메인 모델
core/domain/src/main/java/com/loopers/core/domain/product/ProductDetail.java, ProductRankingList.java, ProductRanking.java, ProductRankings.java
상품 상세에 순위 필드 추가, 순위 목록 및 순위 VO 추가
도메인 이벤트
core/domain/src/main/java/com/loopers/core/domain/product/event/ProductLikeEvent.java, core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductLikeEventPublisher.java, ProductRankingCacheRepository.java
상품 좋아요 이벤트, 좋아요 이벤트 발행자 인터페이스, 순위 캐시 저장소 인터페이스 추가
Redis 구현
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepository.java, ProductRankingRedisRepositoryImpl.java, ProductRankingCacheRepositoryImpl.java, RedisCleanUp.java
Redis 기반 순위 관리 (일일 증가, 조회, 이월), 테스트 픽스처 추가
MySQL 인프라
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductQuerydslRepository.java, ProductQuerydslRepositoryImpl.java, ProductRankingListProjection.java, ProductRepositoryImpl.java
순위 조회용 QueryDSL 메서드, 순위 목록 프로젝션 DTO, 저장소 구현 메서드 추가
이벤트 발행 인프라
core/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/dto/ProductLikeKafkaEvent.java, ProductLikeEventPublisherImpl.java
Kafka 기반 좋아요 이벤트 발행 구현
서비스 - 순위 관리
core/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.java, ProductRankingCarryOverService.java, IncreaseProductViewRankingScoreService.java, IncreaseProductSalesRankingScoreService.java
순위 조회, 이월, 조회/판매 순위 점수 증가 서비스 추가
서비스 - 좋아요 처리
core/service/src/main/java/com/loopers/core/service/productlike/IncreaseProductLikeRankingScoreService.java, ProductLikeEventPublishService.java, ProductLikeService.java
좋아요 순위 점수 증가, 좋아요 이벤트 발행, 좋아요 서비스의 아웃박스 통합
커맨드 & 쿼리
core/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductSalesRankingScoreCommand.java, IncreaseProductViewRankingScoreCommand.java, core/service/src/main/java/com/loopers/core/service/productlike/command/IncreaseProductLikeRankingScoreCommand.java, core/service/src/main/java/com/loopers/core/service/product/query/GetProductRankingQuery.java
순위 점수 증가 커맨드, 순위 조회 쿼리 추가
Kafka 컨슈머 DTO
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeRankingScoreEvent.java, IncreaseProductSalesRankingScoreEvent.java, IncreaseProductViewRankingScoreEvent.java
순위 점수 증가 이벤트 및 커맨드 변환 메서드 추가
설정 & 테스트
apps/commerce-api/src/main/resources/application.yml, apps/commerce-streamer/src/main/resources/application.yml, core/service/src/test/resources/application-test.yml, core/service/src/test/java/com/loopers/core/service/IntegrationTest.java, GetProductRankingServiceIntegrationTest.java
상품 좋아요 Kafka 토픽, 순위 점수 가중치 설정 추가, Redis 테스트 정리 추가, 순위 서비스 통합 테스트 추가
이벤트 타입
core/domain/src/main/java/com/loopers/core/domain/event/type/EventType.java
새로운 이벤트 타입: LIKE_PRODUCT, INCREASE_PRODUCT_LIKE_RANKING_SCORE, INCREASE_PRODUCT_VIEW_RANKING_SCORE, INCREASE_SALES_RANKING_COUNT
리포지토리 업데이트
core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductRepository.java, ClearProductCacheService.java, IncreaseProductMetricViewCountService.java, ProductQueryService.java, ProductLikeBulkRepositoryImpl.java, ProductLikeRedisRepositoryImpl.java
순위 목록 조회 메서드 추가, InboxEvent 애너테이션 필드명 변경 (productId → id), ProductDetail 생성 로직 업데이트, 패키지 이동
빌드 설정
core/infra/database/redis/build.gradle.kts, core/service/build.gradle.kts
Redis 테스트 픽스처 플러그인 추가, Redis 테스트 픽스처 의존성 추가

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant API as ProductV1Api
    participant Service as GetProductRankingService
    participant CacheRepo as ProductRankingCacheRepository
    participant Repository as ProductRepository
    participant MySQL as MySQL DB
    participant Redis as Redis Cache

    Client->>API: GET /rankings?date=2025-12-15&pageNo=0&pageSize=10
    API->>Service: getRanking(GetProductRankingQuery)
    Service->>CacheRepo: getRankings(date, pageNo, pageSize)
    CacheRepo->>Redis: ZREVRANGE ranking:all:20251215 0 9 WITHSCORES
    Redis-->>CacheRepo: ProductRankings(list, totalElements, ...)
    Service->>Repository: findRankingListBy(productIds)
    Repository->>MySQL: SELECT product, brand WHERE id IN (...)
    MySQL-->>Repository: List<ProductRankingListProjection>
    Repository-->>Service: ProductRankingList
    Service->>Service: ProductRankingList.with(rankings)
    Service-->>API: ProductRankingList
    API-->>Client: ApiResponse<GetProductRankingsResponse>
Loading
sequenceDiagram
    participant Kafka as Kafka Topic
    participant Consumer as IncreaseProductViewRankingScoreKafkaConsumer
    participant Service as IncreaseProductViewRankingScoreService
    participant CacheRepo as ProductRankingCacheRepository
    participant Redis as Redis

    Kafka->>Consumer: product-detail-viewed records (batch)
    Consumer->>Consumer: Deserialize to IncreaseProductViewRankingScoreEvent
    Consumer->>Service: increase(IncreaseProductViewRankingScoreCommand)
    Service->>CacheRepo: increaseDaily(productId, today, weight=0.1)
    CacheRepo->>Redis: ZINCRBY ranking:all:20251215 0.1 productId
    Redis->>Redis: Update sorted set, set TTL 2 days
    Redis-->>CacheRepo: Score updated
    CacheRepo-->>Service: void
    Service-->>Consumer: void
    Consumer->>Kafka: Acknowledge batch
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

enhancement, feature

Poem

🐰 순위의 나라로, 토끼가 뛰어가~
좋아요와 조회로, 점수 자라자라~
Redis에 담긴 랭킹 데이터,
Kafka 메시지 흐르고 흐르고,
매일 밤 이월하는 마법! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목 '[volume-9] Product Ranking with Redis'는 주요 변경사항인 Redis를 활용한 상품 랭킹 기능 구현을 명확하게 요약합니다.
Description check ✅ Passed PR 설명이 대부분 완성되어 있으며, Summary, Review Points, Checklist 섹션이 모두 포함되어 있습니다.

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

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

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

⚠️ Outside diff range comments (4)
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/productlike/ProductLikeBulkRepositoryImpl.java (1)

32-38: SQL 파라미터 이름 불일치로 인한 런타임 오류 발생

Line 34에서 파라미터를 "id"로 바인딩하고 있지만, Line 27의 SQL 쿼리는 :productId를 참조하고 있습니다. NamedParameterJdbcTemplate는 파라미터 이름이 정확히 일치해야 하므로, 이 메서드 호출 시 런타임에서 InvalidDataAccessApiUsageException이 발생합니다.

🔎 파라미터 이름 불일치 수정
        SqlParameterSource[] batchArgs = entities.stream()
                .map(entity -> new MapSqlParameterSource()
-                        .addValue("id", entity.getProductId())
+                        .addValue("productId", entity.getProductId())
                        .addValue("userId", entity.getUserId())
                        .addValue("createdAt", entity.getCreatedAt())
                )
                .toArray(SqlParameterSource[]::new);
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductLikeRedisRepositoryImpl.java (2)

25-32: Redis 작업에 @transactional 사용 불가

Spring의 @Transactional은 JDBC/JPA 트랜잭션을 위한 것이며, Redis 작업에 원자성을 제공하지 않습니다. 현재 구현의 문제점:

  • Redis 작업들이 원자적으로 실행되지 않음 (ZADD와 EXPIRE 사이에 장애 발생 시 TTL 설정 안됨)
  • 트랜잭션 관리 오버헤드만 발생
  • 개발자에게 잘못된 원자성 보장을 암시

Redis 원자성이 필요한 경우 RedisTemplateexecute() 메서드와 SessionCallback을 사용하여 MULTI/EXEC 트랜잭션을 명시적으로 구현하거나, 이 어노테이션을 제거하세요.

🔎 RedisTemplate을 사용한 원자적 작업 예시
-    @Transactional
     public void saveLike(String productId, String userId, Long timestamp) {
-        redisTemplate.opsForZSet().add(LIKE_PRODUCT_CACHE_KEY_PREFIX + productId, userId, timestamp);
-        redisTemplate.opsForZSet().add(LIKE_USER_CACHE_KEY_PREFIX + userId, productId, timestamp);
-
-        redisTemplate.expire(LIKE_PRODUCT_CACHE_KEY_PREFIX + productId, Duration.ofDays(TTL_DAYS));
-        redisTemplate.expire(LIKE_USER_CACHE_KEY_PREFIX + userId, Duration.ofDays(TTL_DAYS));
+        redisTemplate.execute(new SessionCallback<List<Object>>() {
+            @Override
+            public List<Object> execute(RedisOperations operations) throws DataAccessException {
+                operations.multi();
+                operations.opsForZSet().add(LIKE_PRODUCT_CACHE_KEY_PREFIX + productId, userId, timestamp);
+                operations.opsForZSet().add(LIKE_USER_CACHE_KEY_PREFIX + userId, productId, timestamp);
+                operations.expire(LIKE_PRODUCT_CACHE_KEY_PREFIX + productId, Duration.ofDays(TTL_DAYS));
+                operations.expire(LIKE_USER_CACHE_KEY_PREFIX + userId, Duration.ofDays(TTL_DAYS));
+                return operations.exec();
+            }
+        });
     }

35-39: Redis 작업에 @transactional 사용 불가 (deleteLike)

saveLike와 동일한 문제가 있습니다. 두 개의 ZREM 작업이 원자적으로 실행되지 않아 한 쪽만 성공하면 데이터 불일치가 발생할 수 있습니다.

Redis MULTI/EXEC 트랜잭션을 사용하거나 이 어노테이션을 제거하세요.

core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java (1)

18-23: aggregateIdField 값이 존재하지 않는 필드를 참조합니다.

IncreaseProductMetricViewCountCommand 레코드는 String eventId, String productId 필드만 가지고 있습니다. 현재 설정된 aggregateIdField = "id"는 존재하지 않는 필드를 참조하므로 런타임에 리플렉션 오류가 발생합니다. aggregateIdField"productId"로 변경하거나 명령 클래스에 id 필드를 추가해야 합니다.

🟡 Minor comments (5)
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepositoryImpl.java-61-70 (1)

61-70: unionAndStore의 Aggregate.SUM 동작으로 인한 병합 기능 확인

carryOverWithWeights 메서드가 빈 리스트와 Aggregate.SUM을 사용하므로, 단순 복사+가중치가 아니라 가중치 적용 후 기존 데이터와의 합산입니다.

현재 사용 패턴(매일 23:50에 이전 날짜 순위를 다음 날짜로 이월)에서는 destKey가 새로 생성되어 문제가 없지만, 만약 destKey에 이미 데이터가 있는 경우 점수들이 누적됩니다. 이것이 의도된 동작인지 확인이 필요하며, 필요시 메서드명을 mergeWithWeights 같은 명확한 이름으로 변경하거나 문서화를 추가하세요.

core/service/src/main/java/com/loopers/core/service/product/ProductRankingCarryOverService.java-19-24 (1)

19-24: LocalDate.now()를 두 번 호출하는 것은 자정 경계에서 문제가 될 수 있습니다.

LocalDate.now()를 두 번 호출하면 자정 근처에서 실행될 경우 todaytomorrow가 예상과 다른 날짜를 가질 수 있습니다. 이미 생성된 today 변수를 재사용하세요.

🔎 수정 제안
     public void carryOver() {
         LocalDate today = LocalDate.now();
-        LocalDate tomorrow = LocalDate.now().plusDays(1);
+        LocalDate tomorrow = today.plusDays(1);

         productRankingCacheRepository.carryOver(today, tomorrow, carryOverWeight);
     }
core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductRankingCacheRepository.java-15-15 (1)

15-15: getDailyRankingBy 메서드의 의도를 명확히 하기 위해 문서화가 필요합니다.

이 메서드는 구현에서 LocalDate.now()를 명시적으로 사용하고 있어 오늘의 랭킹만 조회하도록 설계되었습니다. 그러나 메서드 시그니처만으로는 이 의도가 명확하지 않습니다. getRankings(LocalDate date, ...)와 달리 날짜 파라미터가 없어 API 사용자가 혼동할 수 있습니다.

메서드 동작을 명확히 하기 위해 다음 중 하나를 수행하세요:

  1. Javadoc 추가: 이 메서드가 오늘의 랭킹만 반환함을 명확히 문서화
  2. 메서드명 변경: getTodaysRankingBy 또는 getCurrentRankingBy로 변경하여 의도를 명확히 함
  3. LocalDate 파라미터 추가: 메서드 유연성 확대 및 API 일관성 확보
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductSalesRankingScoreService.java-38-45 (1)

38-45: @transactional 어노테이션을 추가하여 트랜잭션 경계를 명시하세요.

이 메서드는 여러 repository 조회(Payment, Order, OrderItems)를 수행하지만 @Transactional 어노테이션이 없습니다. IncreaseProductTotalSalesService와 같은 유사한 서비스들은 @InboxEvent와 함께 @Transactional을 사용하여 일관성 있게 트랜잭션 경계를 정의하고 있습니다. @InboxEvent 어노테이션이 eventId 기반 중복 감지를 통해 이벤트 수준의 멱등성을 보장하므로, 명시적인 @Transactional을 추가하면 데이터베이스 읽기 작업의 일관성을 더욱 강화할 수 있습니다.

core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/impl/ProductRankingCacheRepositoryImpl.java-29-55 (1)

29-55: getRankings 메서드의 페이지네이션 로직이 잘 구현되어 있습니다.

null 체크, 빈 결과 처리, 페이지 계산이 적절합니다. 다만 pageSize 파라미터에 대한 유효성 검증이 없어 0 또는 음수 값이 전달될 경우 50행의 Math.ceil((double) totalElements / pageSize) 에서 ArithmeticException이 발생할 수 있습니다. API 레이어의 @RequestParam@Min(1) 또는 @Positive 애너테이션 추가, 또는 GetProductRankingQuery 레코드에 검증 제약 조건을 추가하는 것을 권장합니다.

🧹 Nitpick comments (12)
core/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductViewRankingScoreCommand.java (1)

1-7: 입력 검증 또는 제약사항 문서화를 고려하세요.

eventIdproductId 필드가 String 타입으로 정의되어 있으며 검증 로직이 없습니다. 이 커맨드가 외부 이벤트(Kafka)에서 생성되는 경우, null이나 빈 문자열 또는 잘못된 형식의 값이 전달될 수 있습니다.

다음 중 하나를 권장합니다:

  • 팩토리 메서드에서 null/빈 문자열 검증 추가
  • 또는 호출자가 유효성을 보장한다는 가정을 JavaDoc으로 명시
🔎 검증 로직이 포함된 팩토리 메서드 예시
 public record IncreaseProductViewRankingScoreCommand(
         String eventId,
         String productId
 ) {
+    public IncreaseProductViewRankingScoreCommand {
+        if (eventId == null || eventId.isBlank()) {
+            throw new IllegalArgumentException("eventId must not be null or blank");
+        }
+        if (productId == null || productId.isBlank()) {
+            throw new IllegalArgumentException("productId must not be null or blank");
+        }
+    }
 }
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepository.java (1)

8-22: 메서드 계약을 문서화하세요.

인터페이스가 깔끔하게 정의되어 있으나, 일부 메서드의 반환값 의미를 명확히 하면 좋겠습니다. 특히:

  • getRankScore: 랭킹에 없는 경우 null 반환?
  • getRankingPosition: 랭킹에 없는 경우 null 반환?

JavaDoc으로 null 반환 조건을 명시하면 호출자가 null 체크를 누락하는 실수를 방지할 수 있습니다.

🔎 JavaDoc 추가 예시
 public interface ProductRankingRedisRepository {

     void increaseDaily(String productId, LocalDate date, Double score);

     Set<TypedTuple<String>> getTopProductsWithScores(LocalDate date, long start, long stop);

     Long countRankings(LocalDate date);

+    /**
+     * 지정된 날짜의 상품 랭킹 점수를 조회합니다.
+     * @return 랭킹 점수, 랭킹에 없는 경우 null
+     */
     Double getRankScore(LocalDate date, String productId);

+    /**
+     * 지정된 날짜의 상품 랭킹 순위를 조회합니다 (1-based).
+     * @return 랭킹 순위 (1부터 시작), 랭킹에 없는 경우 null
+     */
     Long getRankingPosition(LocalDate date, String productId);

     void carryOverWithWeights(String sourceKey, String destKey, Double weight);
 }
core/service/src/test/java/com/loopers/core/service/product/GetProductRankingServiceIntegrationTest.java (1)

129-150: 테스트 커버리지가 포괄적입니다.

페이지네이션, 부분 상품 조회, 빈 랭킹, 상품 정보 정확성에 대한 테스트가 잘 작성되었습니다. 추가로 랭킹 순서(점수 기반 정렬)가 올바르게 적용되는지 검증하는 테스트도 고려해 보세요.

🔎 점수 순서 검증 테스트 예시
@Test
@DisplayName("랭킹은 점수 내림차순으로 정렬된다")
void getRankingOrderedByScore() {
    // Given
    productRankingCacheRepository.increaseDaily(testProducts.get(0).getId(), testDate, 100.0);
    productRankingCacheRepository.increaseDaily(testProducts.get(1).getId(), testDate, 300.0);
    productRankingCacheRepository.increaseDaily(testProducts.get(2).getId(), testDate, 200.0);

    GetProductRankingQuery query = new GetProductRankingQuery(testDate, 0, 10);

    // When
    ProductRankingList result = getProductRankingService.getRanking(query);

    // Then
    assertThat(result.products().get(0).id()).isEqualTo(testProducts.get(1).getId()); // 300점
    assertThat(result.products().get(1).id()).isEqualTo(testProducts.get(2).getId()); // 200점
    assertThat(result.products().get(2).id()).isEqualTo(testProducts.get(0).getId()); // 100점
}
core/infra/database/redis/src/testFixtures/java/com/loopers/core/infra/database/redis/util/RedisCleanUp.java (1)

7-21: 중복 코드 경로 수정 필요합니다.

원본 리뷰 댓글에서 언급한 modules/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java 파일은 존재하지 않습니다. 실제 중복은 다음 위치에 존재합니다:

  • core/infra/database/redis/redis-config/src/testFixtures/java/com/loopers/core/infra/database/redis/util/RedisCleanUp.java

두 파일의 구현이 완전히 동일하므로, 중복을 제거하고 한 곳으로 통합하거나 공유하는 방식으로 리팩토링하는 것을 권장합니다.

core/service/src/main/java/com/loopers/core/service/product/IncreaseProductSalesRankingScoreService.java (2)

43-43: 분산 환경에서 LocalDate.now() 사용을 검토하세요.

LocalDate.now()는 서버의 시스템 시간대를 사용합니다. 여러 리전에 배포된 서비스에서 서로 다른 날짜를 사용할 수 있어 랭킹 데이터가 불일치할 수 있습니다.

🔎 UTC 기반 날짜 사용 제안
+import java.time.ZoneId;
+
 orderItems.forEach(
-        item -> productRankingCacheRepository.increaseDaily(item.getProductId(), LocalDate.now(), weight)
+        item -> productRankingCacheRepository.increaseDaily(item.getProductId(), LocalDate.now(ZoneId.of("UTC")), weight)
 );

또는 이벤트에 날짜 정보를 포함시켜 결정론적으로 처리하는 방안도 고려하세요.


39-41: 조회 메서드 네이밍의 일관성을 고려하세요.

Line 39는 getById를 사용하고 Line 40은 getBy를 사용합니다. 두 메서드 모두 엔티티를 조회하는 동일한 역할을 하지만 네이밍이 다릅니다.

프로젝트 전체에서 "존재하지 않으면 예외를 던지는 조회" 메서드의 네이밍 규칙(예: getById, getBy, findByIdOrThrow)을 통일하는 것을 권장합니다.

core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductRankingCacheRepository.java (1)

11-11: Double 대신 double을 고려하세요.

increaseDaily의 score 파라미터가 Double (nullable)로 선언되어 있습니다. null score를 허용할 명확한 이유가 없다면 primitive double을 사용하는 것이 null 안전성 측면에서 더 낫습니다.

🔎 제안: primitive double 사용
-void increaseDaily(ProductId productId, LocalDate date, Double score);
+void increaseDaily(ProductId productId, LocalDate date, double score);

carryOver의 decayFactor도 동일하게 적용 가능합니다.

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

68-70: 파라미터 유효성 검증을 추가하세요.

현재 구현에는 다음 검증이 없습니다:

  • date가 미래 날짜이거나 과도하게 과거일 수 있음
  • pageNo가 음수일 수 있음
  • pageSize가 0, 음수, 또는 과도하게 클 수 있음 (예: 10000)
🔎 제안: Bean Validation 적용

DTO 클래스에 validation 어노테이션을 추가하거나, 컨트롤러에서 검증하세요:

 public ApiResponse<GetProductRankingsResponse> getProductRankings(
-        @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date,
-        @RequestParam(required = false, defaultValue = "0") int pageNo,
-        @RequestParam(required = false, defaultValue = "10") int pageSize
+        @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") @PastOrPresent LocalDate date,
+        @RequestParam(required = false, defaultValue = "0") @Min(0) int pageNo,
+        @RequestParam(required = false, defaultValue = "10") @Min(1) @Max(100) int pageSize
 ) {

또는 서비스 레이어에서 비즈니스 로직 검증을 수행하세요.

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

9-11: GetProductRankingsResponseGetProductDetailResponse 내부에 중첩되어 있습니다.

GetProductRankingsResponse는 상품 상세 조회와는 독립적인 랭킹 목록 응답입니다. GetProductDetailResponse 내부에 중첩하는 것보다 ProductV1Dto의 직접적인 내부 record로 선언하는 것이 구조적으로 더 명확합니다.

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

122-163: GetProductRankingsResponseGetProductDetailResponse 내부에 중첩되어 있어 구조가 혼란스럽습니다.

랭킹 목록 응답(GetProductRankingsResponse)은 상품 상세 응답(GetProductDetailResponse)과 논리적 관계가 없습니다. ProductV1Dto의 최상위 레벨에 선언하는 것이 더 적절합니다.

🔎 구조 개선 제안
 public class ProductV1Dto {
+
+    public record GetProductRankingsResponse(
+            List<ProductRankingResponse> products,
+            long totalElements,
+            int totalPages,
+            boolean hasNext,
+            boolean hasPrevious
+    ) {
+        // ... 기존 구현
+    }
+
     public record GetProductDetailResponse(
         // ...
     ) {
-        public record GetProductRankingsResponse(
-            // ...
-        ) { }
     }
 }
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/impl/ProductRankingCacheRepositoryImpl.java (2)

57-68: LocalDate.now() 사용으로 단위 테스트가 어렵습니다.

getDailyRankingBy 메서드에서 LocalDate.now()를 직접 호출하면 테스트 시 날짜를 제어할 수 없습니다. Clock을 주입받거나 날짜를 파라미터로 받는 것을 고려해 보세요.

🔎 개선 제안
+    private final Clock clock;
+
     @Override
-    public ProductRanking getDailyRankingBy(ProductId productId) {
-        LocalDate today = LocalDate.now();
+    public ProductRanking getDailyRankingBy(ProductId productId, LocalDate date) {
+        // 또는 Clock 주입 사용: LocalDate today = LocalDate.now(clock);
+        LocalDate today = date;
         Double score = repository.getRankScore(today, productId.value());

70-76: Redis 키 포맷이 ProductRankingRedisRepositoryImpl과 중복됩니다.

"ranking:all:" 접두사와 날짜 포맷이 ProductRankingRedisRepositoryImpl.RANKING_KEY_PREFIX와 중복됩니다. 키 생성 로직을 한 곳에서 관리하거나 상수를 공유하는 것이 좋습니다.

🔎 개선 제안

ProductRankingRedisRepository 인터페이스에 키 생성 메서드를 추가하거나, 공통 상수 클래스를 만들어 중복을 제거하세요:

// ProductRankingRedisRepository에 추가
String buildRankingKey(LocalDate date);

그러면 carryOver 메서드에서:

     @Override
     public void carryOver(LocalDate sourceDate, LocalDate targetDate, Double weight) {
-        String sourceKey = "ranking:all:" + sourceDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
-        String targetKey = "ranking:all:" + targetDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
+        String sourceKey = repository.buildRankingKey(sourceDate);
+        String targetKey = repository.buildRankingKey(targetDate);
 
         repository.carryOverWithWeights(sourceKey, targetKey, weight);
     }
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a3b1903 and 5b8142e.

📒 Files selected for processing (59)
  • apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductLikeEventScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductRankingCarryOverScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Api.java
  • apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Dto.java
  • apps/commerce-api/src/main/resources/application.yml
  • apps/commerce-api/src/test/java/com/loopers/application/api/product/ProductV1ApiIntegrationTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeRankingScoreKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductTotalSalesKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewRankingScoreKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeRankingScoreEvent.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductSalesRankingScoreEvent.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductViewRankingScoreEvent.java
  • apps/commerce-streamer/src/main/resources/application.yml
  • core/domain/src/main/java/com/loopers/core/domain/event/type/EventType.java
  • core/domain/src/main/java/com/loopers/core/domain/product/ProductDetail.java
  • core/domain/src/main/java/com/loopers/core/domain/product/ProductRankingList.java
  • core/domain/src/main/java/com/loopers/core/domain/product/event/ProductDetailViewEvent.java
  • core/domain/src/main/java/com/loopers/core/domain/product/event/ProductLikeEvent.java
  • core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductLikeCacheRepository.java
  • core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductRankingCacheRepository.java
  • core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductRepository.java
  • core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductRanking.java
  • core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductRankings.java
  • core/domain/src/main/java/com/loopers/core/domain/productlike/event/ProductLikeEventPublisher.java
  • core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductQuerydslRepository.java
  • core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductQuerydslRepositoryImpl.java
  • core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/dto/ProductRankingListProjection.java
  • core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/ProductRepositoryImpl.java
  • core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/productlike/ProductLikeBulkRepositoryImpl.java
  • core/infra/database/redis/build.gradle.kts
  • core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductLikeRedisRepositoryImpl.java
  • core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepository.java
  • core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepositoryImpl.java
  • core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/entity/ProductDetailRedisEntity.java
  • core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/impl/ProductRankingCacheRepositoryImpl.java
  • core/infra/database/redis/src/testFixtures/java/com/loopers/core/infra/database/redis/util/RedisCleanUp.java
  • core/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/dto/ProductLikeKafkaEvent.java
  • core/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/impl/ProductLikeEventPublisherImpl.java
  • core/service/build.gradle.kts
  • core/service/src/main/java/com/loopers/core/service/product/ClearProductCacheService.java
  • core/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.java
  • core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java
  • core/service/src/main/java/com/loopers/core/service/product/IncreaseProductSalesRankingScoreService.java
  • core/service/src/main/java/com/loopers/core/service/product/IncreaseProductViewRankingScoreService.java
  • core/service/src/main/java/com/loopers/core/service/product/ProductQueryService.java
  • core/service/src/main/java/com/loopers/core/service/product/ProductRankingCarryOverService.java
  • core/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductSalesRankingScoreCommand.java
  • core/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductViewRankingScoreCommand.java
  • core/service/src/main/java/com/loopers/core/service/product/query/GetProductRankingQuery.java
  • core/service/src/main/java/com/loopers/core/service/productlike/IncreaseProductLikeRankingScoreService.java
  • core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeEventPublishService.java
  • core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeService.java
  • core/service/src/main/java/com/loopers/core/service/productlike/command/IncreaseProductLikeRankingScoreCommand.java
  • core/service/src/test/java/com/loopers/core/service/IntegrationTest.java
  • core/service/src/test/java/com/loopers/core/service/product/GetProductRankingServiceIntegrationTest.java
  • core/service/src/test/resources/application-test.yml
🧰 Additional context used
🧠 Learnings (11)
📚 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/applications/streamer/consumer/product/dto/IncreaseProductViewRankingScoreEvent.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeRankingScoreEvent.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewRankingScoreKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductSalesRankingScoreEvent.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductTotalSalesKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeRankingScoreKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java
📚 Learning: 2025-12-19T21:30:08.018Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/outbox/OutboxEventListener.kt:0-0
Timestamp: 2025-12-19T21:30:08.018Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template Kafka event pipeline, Like events (LikeCreatedEventV1, LikeCanceledEventV1) intentionally use aggregateType="Like" with aggregateId=productId. The aggregateId serves as a partitioning/grouping key (not a unique Like entity identifier), ensuring all like events for the same product go to the same partition for ordering guarantees and aligning with ProductStatisticService's product-based aggregation logic. Using individual like_id would scatter events across partitions and break the statistics aggregation pattern.

Applied to files:

  • core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeService.java
  • apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductLikeEventScheduler.java
  • core/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/impl/ProductLikeEventPublisherImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeRankingScoreEvent.java
  • core/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/dto/ProductLikeKafkaEvent.java
  • apps/commerce-api/src/main/resources/application.yml
  • core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeEventPublishService.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductTotalSalesKafkaConsumer.java
  • core/service/src/main/java/com/loopers/core/service/productlike/command/IncreaseProductLikeRankingScoreCommand.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeRankingScoreKafkaConsumer.java
  • core/domain/src/main/java/com/loopers/core/domain/product/event/ProductLikeEvent.java
  • core/domain/src/main/java/com/loopers/core/domain/productlike/event/ProductLikeEventPublisher.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java
  • core/domain/src/main/java/com/loopers/core/domain/event/type/EventType.java
  • core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java
📚 Learning: 2025-12-19T20:59:50.009Z
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:50.009Z
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:

  • core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeService.java
  • core/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/impl/ProductLikeEventPublisherImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewRankingScoreKafkaConsumer.java
  • core/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/dto/ProductLikeKafkaEvent.java
  • core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeEventPublishService.java
  • core/domain/src/main/java/com/loopers/core/domain/product/event/ProductLikeEvent.java
  • core/domain/src/main/java/com/loopers/core/domain/productlike/event/ProductLikeEventPublisher.java
  • apps/commerce-streamer/src/main/resources/application.yml
  • core/domain/src/main/java/com/loopers/core/domain/event/type/EventType.java
📚 Learning: 2025-11-27T09:09:24.961Z
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product 엔티티 (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)는 유즈케이스별로 의도적으로 다른 락 전략을 사용한다: 좋아요 기능에는 비관적 락(findByIdForUpdate)을, 재고 차감에는 낙관적 락(Version + 재시도)을 사용한다.

Applied to files:

  • core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeService.java
  • core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductLikeCacheRepository.java
  • core/domain/src/main/java/com/loopers/core/domain/product/event/ProductLikeEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1ApiSpec.java
📚 Learning: 2025-11-21T03:38:07.494Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductLikeEventScheduler.java
  • apps/commerce-streamer/src/main/resources/application.yml
📚 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:

  • core/service/src/test/java/com/loopers/core/service/product/GetProductRankingServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/api/product/ProductV1ApiIntegrationTest.java
📚 Learning: 2025-12-16T09:44:15.945Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 58
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/payment/PaymentService.kt:157-183
Timestamp: 2025-12-16T09:44:15.945Z
Learning: In PaymentService.requestPgPayment (apps/commerce-api/src/main/kotlin/com/loopers/domain/payment/PaymentService.kt), PG payment requests are idempotent using paymentId as the idempotency key, so Retryable covering the entire method including the pgClient.requestPayment call is safe and will not cause duplicate charges even if retries occur due to ObjectOptimisticLockingFailureException.

Applied to files:

  • core/service/src/test/resources/application-test.yml
📚 Learning: 2025-12-19T03:23:54.756Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 191
File: modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java:24-34
Timestamp: 2025-12-19T03:23:54.756Z
Learning: In the loopers-spring-java-template project, test container configurations (MySQL, Redis, Kafka) use static initialization blocks for consistency across the template. This pattern should be maintained for uniformity, even if alternative lifecycle management approaches exist.

Applied to files:

  • core/service/src/test/java/com/loopers/core/service/IntegrationTest.java
  • core/infra/database/redis/build.gradle.kts
📚 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:

  • core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductQuerydslRepository.java
📚 Learning: 2025-11-12T13:04:50.782Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 72
File: core/domain/src/main/java/com/loopers/core/domain/productlike/vo/ProductLikeId.java:5-7
Timestamp: 2025-11-12T13:04:50.782Z
Learning: In the com.loopers codebase, domain entity ID value objects (e.g., ProductLikeId, OrderItemId, ProductId, PaymentId, OrderId, BrandId) are system-generated identifiers and do not require pattern validation (regex, length checks). They are implemented as simple records with a String value and an empty() factory method returning null for unsaved entities. This differs from UserIdentifier, which is a user-supplied login ID that requires format validation. Domain IDs should not be flagged for missing validation logic in the create() method.
<!-- [add_learning]
UserIdentifier와 같은 사용자 입력 ID와 ProductLikeId, OrderItemId 등의 도메인 ID는 검증 패턴이 다릅니다. UserIdentifier는 사용자가 입력하는 로그인 ID로서 정규식, 길이 등의 형식 검증이 필요하지만, 도메인 ID는 시스템에서 생성하는 식별자(UUID, DB 생성 ID)이므로 패턴 검증이 불필요합니다. 도메인 ID VO는 단순한 record와 empty() 팩토리 메서드만으로 충분합니다.

Applied to files:

  • core/domain/src/main/java/com/loopers/core/domain/product/event/ProductLikeEvent.java
📚 Learning: 2025-12-19T03:23:51.674Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 191
File: modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java:24-34
Timestamp: 2025-12-19T03:23:51.674Z
Learning: Maintain the use of static initialization blocks in test fixtures for testcontainers configurations (e.g., MySQL, Redis, Kafka) to ensure consistency and predictable initialization across the loopers-spring-java-template project. Apply this pattern to all test-fixture Java files under src/testFixtures/java to preserve uniform lifecycle behavior, unless there's a compelling, well-justified reason to diverge.

Applied to files:

  • core/infra/database/redis/src/testFixtures/java/com/loopers/core/infra/database/redis/util/RedisCleanUp.java
🧬 Code graph analysis (15)
core/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.java (5)
core/service/src/main/java/com/loopers/core/service/product/ClearProductCacheService.java (1)
  • Service (10-25)
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java (1)
  • Service (12-31)
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductSalesRankingScoreService.java (1)
  • Service (20-46)
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductViewRankingScoreService.java (1)
  • Service (13-32)
core/service/src/main/java/com/loopers/core/service/productlike/IncreaseProductLikeRankingScoreService.java (1)
  • Service (13-32)
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/dto/ProductRankingListProjection.java (1)
core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductRankings.java (1)
  • Getter (7-39)
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductLikeEventScheduler.java (1)
core/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/impl/ProductLikeEventPublisherImpl.java (1)
  • Component (11-24)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.java (4)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeRankingScoreKafkaConsumer.java (1)
  • Component (15-36)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductTotalSalesKafkaConsumer.java (1)
  • Component (15-36)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java (1)
  • Component (15-37)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewRankingScoreKafkaConsumer.java (1)
  • Component (15-37)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewRankingScoreKafkaConsumer.java (4)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeRankingScoreKafkaConsumer.java (1)
  • Component (15-36)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.java (1)
  • Component (15-36)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductTotalSalesKafkaConsumer.java (1)
  • Component (15-36)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java (1)
  • Component (15-37)
core/service/src/test/java/com/loopers/core/service/product/GetProductRankingServiceIntegrationTest.java (2)
core/domain/src/testFixtures/java/com/loopers/core/domain/brand/BrandFixture.java (1)
  • BrandFixture (10-35)
core/domain/src/testFixtures/java/com/loopers/core/domain/product/ProductFixture.java (1)
  • ProductFixture (12-73)
core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeEventPublishService.java (1)
core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeService.java (1)
  • Service (25-66)
core/service/src/main/java/com/loopers/core/service/productlike/IncreaseProductLikeRankingScoreService.java (5)
core/service/src/main/java/com/loopers/core/service/product/ClearProductCacheService.java (1)
  • Service (10-25)
core/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.java (1)
  • Service (11-24)
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java (1)
  • Service (12-31)
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductSalesRankingScoreService.java (1)
  • Service (20-46)
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductViewRankingScoreService.java (1)
  • Service (13-32)
core/infra/database/redis/src/testFixtures/java/com/loopers/core/infra/database/redis/util/RedisCleanUp.java (1)
modules/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java (1)
  • Component (7-20)
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepositoryImpl.java (2)
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductRankingCarryOverScheduler.java (1)
  • Component (8-18)
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/impl/ProductRankingCacheRepositoryImpl.java (1)
  • Component (18-89)
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/impl/ProductRankingCacheRepositoryImpl.java (1)
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepositoryImpl.java (1)
  • Component (16-71)
core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductRankings.java (1)
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/dto/ProductRankingListProjection.java (1)
  • Getter (15-48)
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductSalesRankingScoreService.java (5)
core/service/src/main/java/com/loopers/core/service/product/ClearProductCacheService.java (1)
  • Service (10-25)
core/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.java (1)
  • Service (11-24)
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java (1)
  • Service (12-31)
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductViewRankingScoreService.java (1)
  • Service (13-32)
core/service/src/main/java/com/loopers/core/service/productlike/IncreaseProductLikeRankingScoreService.java (1)
  • Service (13-32)
apps/commerce-api/src/test/java/com/loopers/application/api/product/ProductV1ApiIntegrationTest.java (1)
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Dto.java (1)
  • ProductV1Dto (12-165)
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1ApiSpec.java (1)
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Dto.java (1)
  • ProductV1Dto (12-165)
🔇 Additional comments (37)
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductLikeRedisRepositoryImpl.java (2)

1-1: 패키지 구조 단순화 승인

impl 서브패키지를 제거하여 패키지 구조를 단순화한 것은 좋은 리팩토링입니다. 다른 Redis 컴포넌트들과의 일관성을 유지합니다.


26-32: TTL 재설정 패턴 확인 필요

좋아요가 추가될 때마다 TTL이 1일로 재설정됩니다. 이는 자주 좋아요를 받는 상품은 캐시에서 만료되지 않는다는 의미입니다.

이것이 의도된 동작인지 확인하세요:

  • 의도된 경우: 활성 상품을 계속 캐시에 유지하는 것이 목적
  • 의도되지 않은 경우: 초기 생성 시에만 TTL을 설정하거나, TTL 갱신 로직 재검토 필요
core/domain/src/main/java/com/loopers/core/domain/product/event/ProductLikeEvent.java (1)

1-10: LGTM!

깔끔한 도메인 이벤트 정의입니다. 필요한 필드만 포함하고 있으며 record 패턴을 잘 활용하고 있습니다.

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

43-50: 랭킹 점수 가중치 합계를 확인하세요.

테스트 설정과 동일하게 가중치 합계가 1.1입니다 (like: 0.2 + view: 0.1 + pay: 0.7 + carryOver: 0.1).

이벤트 가중치(like, view, pay)와 이월 계수(carryOver)가 동일한 방식으로 합산되는 것이 의도된 설계인지 확인하세요. 일반적으로:

  • 이벤트 가중치는 합이 1.0이 되도록 정규화
  • carryOver는 이전 점수의 감쇠/보존 비율로 별도 처리
core/domain/src/main/java/com/loopers/core/domain/productlike/event/ProductLikeEventPublisher.java (1)

1-8: LGTM!

명확하고 간결한 인터페이스 정의입니다. 단일 책임 원칙을 잘 따르고 있습니다.

core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepositoryImpl.java (1)

20-22: TTL 설정이 비즈니스 요구사항과 일치하는지 확인하세요.

랭킹 데이터의 TTL이 2일로 설정되어 있습니다. 이것이 비즈니스 요구사항에 부합하는지 확인하세요:

  • 일일 랭킹 스케줄러가 매일 자정 전(23:50)에 실행되는 것으로 보임
  • carryOver가 이전 날짜 데이터를 사용
  • 2일 TTL이면 어제와 오늘의 데이터만 유지됨

carryOver 로직이 2일 이상 이전의 데이터를 참조하지 않는다면 적절하지만, 시스템 장애나 스케줄러 지연으로 carryOver가 늦어질 경우 데이터 손실이 발생할 수 있습니다.

안전 여유를 위해 TTL을 3일로 늘리는 것을 고려하세요.

core/service/src/test/resources/application-test.yml (1)

26-33: 가중치 설정이 올바르게 구성되어 있습니다.

Event 가중치(like: 0.2, view: 0.1, pay: 0.7)의 합계는 1.0으로 정규화되어 있으며, carryOver(0.1)는 완전히 별도의 감쇠 계수로 운영됩니다. 세 가지 Event 서비스(IncreaseProductLikeRankingScoreService, IncreaseProductViewRankingScoreService, IncreaseProductSalesRankingScoreService)는 각각 독립적으로 자신의 가중치를 점수에 더하고, ProductRankingCarryOverService는 매일 자정에 실행되어 전날의 점수를 감쇠 계수로 곱해 다음날로 이월합니다. 따라서 1.1의 합계는 문제가 아니며, 가중치 설계는 의도된 대로 정상 작동합니다.

core/infra/database/redis/build.gradle.kts (1)

2-12: LGTM!

테스트 픽스처 플러그인과 의존성 추가가 적절합니다. 다른 모듈(예: core/service)에서 Redis 테스트 픽스처를 활용할 수 있도록 잘 구성되었습니다.

core/domain/src/main/java/com/loopers/core/domain/event/type/EventType.java (1)

14-18: LGTM!

랭킹 기능을 위한 새로운 이벤트 타입들이 기존 네이밍 컨벤션과 일관성 있게 추가되었습니다. 좋아요/조회/판매 이벤트에 대한 명확한 분리가 잘 되어 있습니다.

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

35-42: 가중치 설정이 적절해 보입니다.

like(0.2) + view(0.1) + pay(0.7) = 1.0으로 정규화된 가중치 구성이 잘 되어 있습니다. carryOver(0.1)는 이전 기간 점수 이월에 사용되는 것으로 보입니다.

환경별(dev/qa/prd)로 다른 가중치가 필요한지 확인해 주세요. 프로덕션 환경에서 A/B 테스트나 튜닝이 필요할 경우 환경별 오버라이드를 고려할 수 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java (1)

21-35: 명시적 groupId 추가가 좋습니다.

컨슈머 그룹 식별을 위한 groupId 추가가 적절합니다. 독립적인 오프셋 추적과 모니터링에 도움이 됩니다.

배치 처리 중 일부 레코드 실패 시에도 acknowledgment.acknowledge()가 호출되는 점을 인지해 주세요. 학습된 패턴에 따르면 EventInboxAspect를 통해 중앙화된 에러 처리가 이루어지므로 현재 구조가 의도된 것이라면 문제없습니다.

core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductRepository.java (1)

37-38: LGTM!

findRankingListBy 메서드 추가가 기존 레포지토리 패턴과 일관성 있게 구현되었습니다. 도메인 객체(ProductRankingList)를 반환하여 계층 간 의존성이 적절히 관리됩니다.

구현체에서 빈 productIds 리스트가 전달될 경우의 처리가 되어 있는지 확인해 주세요.

core/service/build.gradle.kts (1)

25-25: LGTM!

Redis 테스트 픽스처 의존성이 기존 MySQL 테스트 픽스처 패턴과 일관성 있게 추가되었습니다.

apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductTotalSalesKafkaConsumer.java (1)

21-35: LGTM!

IncreaseProductViewKafkaConsumer와 동일한 패턴으로 groupId가 추가되어 일관성이 유지됩니다. 판매 이벤트에 대한 독립적인 컨슈머 그룹 관리가 가능해집니다.

core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/entity/ProductDetailRedisEntity.java (1)

31-34: 팩토리 메서드 사용으로의 리팩토링이 적절합니다.

생성자 호출을 정적 팩토리 메서드로 변경하여 도메인 레이어의 변경사항과 일치시켰습니다.

core/service/src/main/java/com/loopers/core/service/product/query/GetProductRankingQuery.java (1)

5-9: 페이지네이션 쿼리 레코드가 깔끔합니다.

불변 데이터 캐리어로서 적절한 구조입니다. pageNo와 pageSize에 대한 유효성 검증은 API 레이어나 서비스 레이어에서 처리되는 것으로 보입니다.

core/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/dto/ProductLikeKafkaEvent.java (1)

5-12: Kafka 이벤트 DTO 구조가 적절합니다.

도메인 이벤트를 Kafka 이벤트로 변환하는 간결한 구조입니다. productId를 키로 사용하여 동일 상품에 대한 이벤트가 같은 파티션으로 전송되도록 보장합니다.

Based on learnings, 같은 상품에 대한 좋아요 이벤트를 동일 파티션으로 라우팅하여 순서 보장과 통계 집계를 지원하는 패턴입니다.

core/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductSalesRankingScoreCommand.java (1)

3-6: 판매 랭킹 점수 증가 커맨드가 깔끔합니다.

불변 커맨드 객체로서 적절한 구조입니다. paymentId를 통해 결제 이벤트와 연결됩니다.

apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductViewRankingScoreEvent.java (1)

5-9: 이벤트-커맨드 변환 패턴이 적절합니다.

Kafka 컨슈머가 이벤트를 커맨드로 변환하여 서비스 레이어로 전달하는 깔끔한 구조입니다.

Based on learnings, 중앙화된 에러 처리(EventInboxAspect)를 활용하므로 컨슈머 레이어에서 단순히 변환 로직만 담당하는 것이 적절합니다.

core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeService.java (1)

44-53: Transactional Outbox 패턴이 올바르게 구현되었습니다.

좋아요 상태 변경 후 이벤트를 outbox에 저장하여 최종적 일관성을 보장합니다. 동일 트랜잭션 내에서 처리되므로 상태 변경과 이벤트 발행의 원자성이 보장됩니다.

Based on learnings, AggregateType.PRODUCT와 productId를 사용하여 동일 상품에 대한 이벤트를 같은 파티션으로 라우팅하고 순서를 보장하는 패턴입니다.

core/service/src/main/java/com/loopers/core/service/productlike/command/IncreaseProductLikeRankingScoreCommand.java (1)

3-6: 좋아요 랭킹 점수 증가 커맨드가 깔끔합니다.

불변 커맨드 객체로서 적절한 구조입니다. 좋아요 이벤트를 기반으로 랭킹 점수를 업데이트하는 플로우에 사용됩니다.

apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeRankingScoreEvent.java (1)

5-12: 구현이 깔끔하고 적절합니다.

이벤트 DTO가 명확하게 정의되어 있고, toCommand() 메서드를 통한 커맨드 변환도 올바르게 구현되었습니다.

core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductRanking.java (1)

3-7: 도메인 값 객체가 올바르게 정의되었습니다.

ProductRanking 레코드가 랭킹 데이터를 표현하는 값 객체로 적절하게 설계되었습니다.

core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductRankings.java (1)

7-38: 페이지네이션 메타데이터를 포함한 값 객체가 잘 설계되었습니다.

ProductRankings 클래스가 랭킹 데이터와 페이지네이션 정보를 적절하게 캡슐화하고 있으며, getProducts() 헬퍼 메서드를 통해 편리한 데이터 추출을 제공합니다.

core/service/src/main/java/com/loopers/core/service/product/ProductQueryService.java (1)

70-81: 랭킹 데이터 통합이 잘 구현되었습니다.

캐시 조회 여부와 관계없이 랭킹 데이터를 별도로 가져와서 ProductDetail에 첨부하는 방식이 적절합니다. 랭킹 데이터는 자주 변경될 수 있으므로 제품 상세 캐시와 분리하여 관리하는 것이 올바른 설계입니다.

apps/commerce-api/src/test/java/com/loopers/application/api/product/ProductV1ApiIntegrationTest.java (1)

147-214: 랭킹 API 통합 테스트가 잘 작성되었습니다.

HTTP 상태 코드 검증과 페이지네이션 정보 검증이 포함되어 있어 기본적인 테스트 커버리지가 확보되었습니다.

core/service/src/main/java/com/loopers/core/service/product/IncreaseProductViewRankingScoreService.java (1)

13-31: 구현이 기존 패턴과 일관성 있게 작성되었습니다.

IncreaseProductSalesRankingScoreServiceIncreaseProductLikeRankingScoreService와 동일한 패턴을 따르고 있어 코드 일관성이 잘 유지되었습니다.

core/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/impl/ProductLikeEventPublisherImpl.java (1)

11-23: 구현이 올바릅니다!

Kafka 메시지 발행 패턴이 적절합니다. productId를 메시지 키로 사용하여 동일 상품의 이벤트들이 같은 파티션으로 전송되도록 보장하고 있으며, 이는 순서 보장과 통계 집계 패턴에 부합합니다.

Based on learnings, 동일 상품의 좋아요 이벤트는 파티셔닝을 위해 productId를 키로 사용해야 합니다.

apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.java (1)

15-36: 구현 패턴이 일관되게 적용되었습니다.

배치 리스너 패턴과 수동 확인(acknowledgment) 처리가 다른 컨슈머들과 동일하게 구현되어 있습니다.

Based on learnings, 에러 처리는 서비스 레이어의 @InboxEvent 어노테이션을 통해 중앙화되어 있으므로, 컨슈머에서 별도의 에러 처리 로직이 없는 것이 의도된 설계입니다.

apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewRankingScoreKafkaConsumer.java (1)

15-37: 구현이 다른 컨슈머들과 일관됩니다.

배치 리스너 패턴이 일관되게 적용되어 있으며, 중앙화된 에러 처리를 활용합니다.

Based on learnings, @InboxEvent 어노테이션을 통한 중앙화된 에러 처리 패턴을 따르고 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductSalesRankingScoreEvent.java (1)

5-13: DTO 설계가 적절합니다.

이벤트에서 커맨드로의 변환이 명확하며, record 타입을 사용하여 불변성을 보장합니다.

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

65-75: 새 랭킹 엔드포인트가 잘 구현되었습니다.

날짜 포맷 지정과 페이지네이션 기본값 설정이 적절합니다.

core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/dto/ProductRankingListProjection.java (1)

37-47: LGTM! to() 메서드의 null 값들은 의도된 설계입니다.

MySQL에서는 상품의 기본 정보만 조회하고, ranking과 score는 Redis 캐시에서 별도로 조회하여 ProductRankingList.with(ProductRankings) 메서드를 통해 병합되는 구조로 이해됩니다.

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

40-44: 새 랭킹 API 엔드포인트 추가가 적절합니다.

날짜 기반 랭킹 조회와 페이지네이션 파라미터가 잘 설계되어 있습니다.

core/domain/src/main/java/com/loopers/core/domain/product/ProductDetail.java (1)

14-28: LGTM! 불변 패턴이 잘 적용되었습니다.

create() 팩토리 메서드와 with() 메서드를 통해 랭킹 데이터를 나중에 첨부할 수 있는 유연한 구조입니다. 도메인 객체의 불변성을 유지하면서 점진적 데이터 구성이 가능합니다.

core/domain/src/main/java/com/loopers/core/domain/product/ProductRankingList.java (1)

45-57: ProductRankingItem record 구조가 적절합니다.

도메인 값 객체들을 활용한 타입 안전한 설계입니다. 단, with(ProductRanking) 메서드의 null 안전성은 위에서 언급한 대로 보완이 필요합니다.

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

91-95: LGTM! null 안전한 랭킹 매핑입니다.

Optional.ofNullable을 사용하여 랭킹 데이터가 없는 경우를 적절히 처리하고 있습니다.

Comment on lines +14 to +17
@Scheduled(fixedDelay = 1000)
public void publish() {
service.publish();
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

1초 간격의 스케줄링이 과도할 수 있습니다.

fixedDelay = 1000은 매우 짧은 간격으로, 프로덕션 환경에서 불필요한 부하를 초래할 수 있습니다. 이벤트 발행 빈도와 비즈니스 요구사항을 고려하여 적절한 간격(예: 5초 또는 10초)으로 조정하는 것을 권장합니다.

추가로, 분산 환경에서 여러 인스턴스가 동시에 실행될 경우 중복 발행이 발생할 수 있습니다. ShedLock과 같은 분산 락 메커니즘 도입을 고려하세요.

🔎 제안하는 수정사항
-    @Scheduled(fixedDelay = 1000)
+    @Scheduled(fixedDelay = 5000) // Consider 5-10 seconds based on business requirements
     public void publish() {
         service.publish();
     }

분산 락 적용 예시:

+    @SchedulerLock(name = "ProductLikeEventScheduler_publish", lockAtMostFor = "4s", lockAtLeastFor = "1s")
     @Scheduled(fixedDelay = 5000)
     public void publish() {
         service.publish();
     }
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductLikeEventScheduler.java
around lines 14 to 17, the scheduled task runs every 1 second which is likely
too frequent for production and can cause unnecessary load and duplicate events
in distributed deployments; increase the fixedDelay to a safer interval (for
example 5000 or 10000 ms) according to business needs and integrate a
distributed lock (e.g., ShedLock or similar) around the publish() invocation so
only one instance executes the task at a time.

Comment on lines +14 to +17
@Scheduled(cron = "0 50 23 * * *")
public void carryOver() {
service.carryOver();
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

타임존 설정 및 분산 락이 누락되었습니다.

현재 크론 표현식에 타임존이 지정되지 않아 서버의 기본 타임존을 사용합니다. 이는 다음 문제를 야기할 수 있습니다:

  1. 타임존 불일치: 여러 인스턴스가 다른 타임존에서 실행될 경우 예상과 다른 시간에 실행됩니다.
  2. 중복 실행: 분산 환경에서 모든 인스턴스가 동시에 carry-over를 실행하여 데이터 불일치가 발생할 수 있습니다.

23:50에 실행되는 일일 처리 작업이므로, 명확한 타임존 지정과 분산 락이 필수적입니다.

🔎 제안하는 수정사항
-    @Scheduled(cron = "0 50 23 * * *")
+    @SchedulerLock(name = "ProductRankingCarryOverScheduler_carryOver", 
+                   lockAtMostFor = "10m", lockAtLeastFor = "1m")
+    @Scheduled(cron = "0 50 23 * * *", zone = "Asia/Seoul")
     public void carryOver() {
         service.carryOver();
     }

Committable suggestion skipped: line range outside the PR's diff.


import java.time.LocalDate;

import static com.loopers.application.api.product.ProductV1Dto.GetProductDetailResponse.GetProductRankingsResponse;
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the definition of GetProductRankingsResponse
rg -A 10 "record GetProductRankingsResponse" --type java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1477


🏁 Script executed:

#!/bin/bash
# Also search for GetProductDetailResponse to see the full structure
rg -B 5 -A 20 "record GetProductDetailResponse" --type java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 3333


🏁 Script executed:

#!/bin/bash
# Check all usages of GetProductRankingsResponse across the codebase
rg "GetProductRankingsResponse" --type java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2634


🏁 Script executed:

#!/bin/bash
# Get the full structure around GetProductDetailResponse and GetProductRankingsResponse
sed -n '90,180p' apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Dto.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2799


🏁 Script executed:

#!/bin/bash
# Count the record definitions in ProductV1Dto to understand the structure
rg "record Get" apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Dto.java --type java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 403


GetProductRankingsResponseProductV1Dto의 최상위 레벨로 이동해야 합니다.

현재 GetProductRankingsResponseGetProductDetailResponse 내부에 중첩되어 있으나, 이는 설계 오류입니다. GetProductRankingsResponse는 상품 랭킹 목록 조회 API(getProductRankings)의 응답으로 사용되며, 상품 상세 조회 API(getProductDetail)의 응답인 GetProductDetailResponse와 전혀 다른 목적을 가집니다. 두 응답 구조가 독립적이므로 GetProductRankingsResponseProductRankingResponseProductV1Dto의 최상위 레벨로 옮기기를 권장합니다.

🤖 Prompt for AI Agents
In
apps/commerce-api/src/test/java/com/loopers/application/api/product/ProductV1ApiIntegrationTest.java
around line 25, the test imports a nested class GetProductRankingsResponse from
ProductV1Dto.GetProductDetailResponse which is incorrect; move
GetProductRankingsResponse (and ProductRankingResponse) out of the
GetProductDetailResponse inner class and promote them to top-level static inner
classes (or separate classes) directly under ProductV1Dto, update the class
declarations and constructors accordingly, then update all imports/usages
(including this test) to import
com.loopers.application.api.product.ProductV1Dto.GetProductRankingsResponse (and
ProductRankingResponse) from the new top-level location and run compilation to
fix any reference errors.

Comment on lines +26 to +35
public void listen(
List<ConsumerRecord<Object, String>> records,
Acknowledgment acknowledgment
) {
records.stream()
.map(event -> JacksonUtil.convertToObject(event.value(), IncreaseProductLikeRankingScoreEvent.class))
.map(IncreaseProductLikeRankingScoreEvent::toCommand)
.forEach(service::increase);
acknowledgment.acknowledge();
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

에러 처리 및 중복 방지 로직이 누락되었습니다.

현재 구현에는 두 가지 중요한 문제가 있습니다:

  1. 에러 처리 부재: 배치 처리 중 하나의 이벤트라도 실패하면 전체 배치가 손실됩니다. 학습된 패턴에 따르면, service.increase() 메서드에 @InboxEvent 어노테이션을 적용하여 EventInboxAspect를 통한 중앙화된 에러 처리를 사용해야 합니다.

  2. 멱등성 보장 부재: eventId가 중복 이벤트를 방지하는 데 사용되지 않습니다. 재처리 시 동일한 이벤트가 여러 번 처리될 수 있습니다.

Based on learnings, centralized error handling via EventInboxAspect should be adopted.

🔎 제안하는 수정사항

서비스 메서드에 @InboxEvent 어노테이션 추가:

// In IncreaseProductLikeRankingScoreService.java
@InboxEvent
public void increase(IncreaseProductLikeRankingScoreCommand command) {
    // existing logic
}

또는 컨슈머에서 각 이벤트별 에러 처리:

     public void listen(
             List<ConsumerRecord<Object, String>> records,
             Acknowledgment acknowledgment
     ) {
-        records.stream()
-                .map(event -> JacksonUtil.convertToObject(event.value(), IncreaseProductLikeRankingScoreEvent.class))
-                .map(IncreaseProductLikeRankingScoreEvent::toCommand)
-                .forEach(service::increase);
+        records.forEach(record -> {
+            try {
+                IncreaseProductLikeRankingScoreEvent event = 
+                    JacksonUtil.convertToObject(record.value(), IncreaseProductLikeRankingScoreEvent.class);
+                service.increase(event.toCommand());
+            } catch (Exception e) {
+                // Log error but continue processing other events
+                log.error("Failed to process event: {}", record.value(), e);
+            }
+        });
         acknowledgment.acknowledge();
     }
🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeRankingScoreKafkaConsumer.java
around lines 26-35, the consumer lacks per-event error handling and idempotency:
update the service method (IncreaseProductLikeRankingScoreService.increase) to
be annotated with @InboxEvent so EventInboxAspect provides centralized error
handling and duplicate protection, ensure the command object carries the eventId
when mapping from the event so the inbox can deduplicate, and adjust the
consumer to process records individually (apply the mapping to commands
one-by-one and invoke service.increase for each) while only acknowledging the
Kafka batch after all records processed successfully (or consider acknowledging
per-record after successful handling) so failed events don’t cause silent loss.

Comment on lines +30 to +33
records.stream()
.map(event -> JacksonUtil.convertToObject(event.value(), IncreaseProductSalesRankingScoreEvent.class))
.map(IncreaseProductSalesRankingScoreEvent::toCommand)
.forEach(service::increase);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find JacksonUtil implementation
fd -type f -name "*JacksonUtil*"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 258


🏁 Script executed:

# Search for JacksonUtil.convertToObject implementation
rg -n "def convertToObject|public.*convertToObject" --type=java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 221


🏁 Script executed:

# Look for the specific file mentioned in the review
fd -type f -name "IncreaseProductSalesRankingScoreKafkaConsumer.java"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 258


🏁 Script executed:

# Search for exception handling patterns in Kafka consumers
rg -n "catch|throws|try" --type=java -g "**/*KafkaConsumer.java" -A 2 -B 2

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

# Read JacksonUtil implementation
cat -n supports/jackson/src/main/java/com/loopers/JacksonUtil.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1749


🏁 Script executed:

# Find the Kafka consumer file
find . -name "IncreaseProductSalesRankingScoreKafkaConsumer.java"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 228


🏁 Script executed:

# Search for KafkaConsumer and error handling patterns
rg -n "catch|throws|ConsumerErrorHandler|DeadLetter" -A 3 -B 1

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 24499


🏁 Script executed:

# Read the Kafka consumer file
cat -n ./apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1778


🏁 Script executed:

# Check for Kafka error handlers in the streamer module
find ./apps/commerce-streamer -name "*.java" -type f -exec grep -l "CommonErrorHandler\|DeadLetterPublishingRecoverer\|ErrorHandler" {} \;

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

# Check Kafka consumer properties and configurations
find ./apps/commerce-streamer -name "*.yml" -o -name "*.yaml" -o -name "*.properties" | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 147


🏁 Script executed:

# Check KafkaConfig to understand batch listener configuration and error handling
cat -n ./apps/commerce-streamer/src/main/java/com/loopers/core/infra/event/kafka/config/KafkaConfig.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 217


🏁 Script executed:

# Check application.yml for Kafka consumer properties
cat -n ./apps/commerce-streamer/src/main/resources/application.yml

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2038


🏁 Script executed:

# Look for any global error handlers or interceptors
find ./apps/commerce-streamer -name "*.java" -type f -exec grep -l "ConsumerAwareListenerErrorHandler\|ErrorHandler" {} \;

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

# Find kafka.yml file
find ./apps/commerce-streamer -name "kafka.yml" -type f

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

# Find KafkaConfig file anywhere in the codebase
find . -name "KafkaConfig.java" -type f

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 192


🏁 Script executed:

# Search for BATCH_LISTENER constant to find where it's defined
rg -n "BATCH_LISTENER" -A 10

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 15881


🏁 Script executed:

# Check if there's any error handler in the streamer module
find ./apps/commerce-streamer -name "*.java" | head -30

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1765


🏁 Script executed:

# Read the complete KafkaConfig.java file
cat -n ./core/infra/event/kafka-config/src/main/java/com/loopers/core/infra/event/kafka/config/KafkaConfig.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 4458


🏁 Script executed:

# Check kafka.yml configuration file
find . -name "kafka.yml" -type f -exec cat -n {} \;

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1584


🏁 Script executed:

# Check if there's any CommonErrorHandler or global error handling defined
rg -n "CommonErrorHandler|ErrorHandler|setErrorHandler" --type=java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

# Look at the full Kafka configuration for error handlers
find . -name "*KafkaListener*" -o -name "*ErrorHandler*" | grep -i java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

# Check Spring Kafka listener error handler annotation usage
rg -n "@KafkaListener" -A 15 apps/commerce-streamer

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 16761


역직렬화 실패 시 무한 재처리 시나리오가 실제로 발생합니다.

JacksonUtil.convertToObject()가 실패하면 RuntimeException이 발생하고, 현재 코드에서는 이를 처리하지 않습니다. MANUAL 커밋 모드에서 예외가 발생하면 acknowledgment.acknowledge()이 호출되지 않아 메시지가 커밋되지 않으며, 공식 에러 핸들러가 없는 상태에서 Kafka는 해당 배치를 계속 재처리합니다. 잘못된 형식의 메시지로 인해 무한 재시도 루프가 발생할 수 있습니다.

다음 중 하나로 해결하세요:

  • 스트림 처리를 try-catch로 감싸고 역직렬화 실패 레코드를 필터링
  • KafkaConfig에서 CommonErrorHandler 또는 DeadLetterPublishingRecoverer 설정
  • 또는 역직렬화 실패 시 로그하고 계속 진행하는 방식으로 개별 메시지 처리
🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.java
around lines 30-33, the stream currently throws a RuntimeException on Jackson
deserialization failure which prevents acknowledgment and causes infinite
reprocessing; change to process records individually with safe deserialization
(e.g., map each record through a try-catch or Optional-returning converter, log
deserialization errors and skip those records), only call service::increase for
successfully deserialized commands, and ensure acknowledgment. Alternatively,
configure a CommonErrorHandler/DeadLetterPublishingRecoverer in KafkaConfig to
route bad records to a DLQ; pick one approach, implement per-record try-catch +
logging and skipping or add the error handler and DLQ configuration so malformed
messages are not retried indefinitely.

Comment on lines +20 to 23
aggregateIdField = "id"
)
public void clear(ClearProductCacheCommand command) {
productCacheRepository.delete(new ProductId(command.productId()));
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find ClearProductCacheCommand definition
echo "=== ClearProductCacheCommand definition ==="
fd -type f -name "*.java" | xargs rg -l "class ClearProductCacheCommand|record ClearProductCacheCommand" 2>/dev/null

# Get more detailed info
echo -e "\n=== ClearProductCacheCommand details ==="
fd -type f -name "*ClearProductCacheCommand*" 2>/dev/null | head -20

# Search for ClearProductCacheCommand class/record definition
echo -e "\n=== Definition content ==="
rg "class ClearProductCacheCommand|record ClearProductCacheCommand" -A 15 2>/dev/null | head -40

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1072


🏁 Script executed:

#!/bin/bash
# Check for all usages of ClearProductCacheCommand
echo "=== ClearProductCacheCommand constructor usages ==="
rg "new ClearProductCacheCommand|ClearProductCacheCommand(" -A 3 -B 1 2>/dev/null | head -80

# Check for CLEAR_PRODUCT_CACHE event references
echo -e "\n=== CLEAR_PRODUCT_CACHE event usage ==="
rg "CLEAR_PRODUCT_CACHE" -B 2 -A 2 2>/dev/null | head -60

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1324


🏁 Script executed:

#!/bin/bash
# Check the ClearProductCacheService file context
echo "=== ClearProductCacheService full content ==="
cat -n core/service/src/main/java/com/loopers/core/service/product/ClearProductCacheService.java 2>/dev/null || \
fd -name "ClearProductCacheService.java" -exec cat -n {} \; 2>/dev/null | head -50

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1155


aggregateIdField를 "id"에서 "productId"로 변경하세요.

ClearProductCacheCommand 레코드는 eventIdproductId 필드만 보유하고 있습니다. 하지만 @InboxEvent 어노테이션의 aggregateIdField = "id"는 존재하지 않는 필드를 참조하고 있어 이벤트 처리 시 런타임 오류가 발생합니다. 라인 20의 aggregateIdField"productId"로 수정하세요.

🤖 Prompt for AI Agents
In
core/service/src/main/java/com/loopers/core/service/product/ClearProductCacheService.java
around lines 20 to 23, the @InboxEvent annotation incorrectly sets
aggregateIdField = "id" which does not exist on ClearProductCacheCommand; change
aggregateIdField to "productId" so it matches the record field, keeping the rest
of the method (productCacheRepository.delete(new
ProductId(command.productId()))) unchanged.

Comment on lines +18 to +23
public ProductRankingList getRanking(GetProductRankingQuery query) {
ProductRankings rankings = productRankingCacheRepository.getRankings(query.date(), query.pageNo(), query.pageSize());
ProductRankingList rankingList = productRepository.findRankingListBy(rankings.getProducts());

return rankingList.with(rankings);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find ProductRankingList.with() method
rg -n "def with|\.with\(" --type=java -A 3 -B 1 | head -40

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 3033


🏁 Script executed:

# Find ProductRankingList class
fd -type f -name "*ProductRankingList*" --type f

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 258


🏁 Script executed:

# Find ProductRepository interface/class
fd -type f -name "*ProductRepository*" --type f

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 258


🏁 Script executed:

# Search for findRankingListBy method
rg -n "findRankingListBy" --type=java -A 5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2047


🏁 Script executed:

# Find ProductRankings class
fd -type f -name "*ProductRankings*" --type f

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 258


🏁 Script executed:

# Find and read ProductRankingList.java
find . -name "ProductRankingList.java" -type f -exec cat {} \;

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2012


🏁 Script executed:

# Read ProductRepositoryImpl.findRankingListBy method completely
rg -n "findRankingListBy" -A 15 --type java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 3102


🏁 Script executed:

# Search for ProductRankings.getProducts()
rg -n "getProducts" -B 2 -A 8 --type java | head -50

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1945


🏁 Script executed:

# Verify ProductRankings to confirm it can be empty
cat $(find . -name "ProductRankings.java" -type f)

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 978


🏁 Script executed:

# Check if getRankings() can return empty rankings
rg -n "getRankings" -B 2 -A 10 | grep -A 10 "getRankings"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 5673


ProductRankingList.with() 메서드에서 NullPointerException 위험

캐시가 반환하는 랭킹 데이터가 ProductRepository에서 조회한 상품과 완전히 일치하지 않을 경우, rankingsByProductId.get(item.id())null을 반환하고, 이 null 값을 item.with(ranking)에 전달하게 됩니다. ProductRankingItem.with() 메서드는 null 체크 없이 ranking.ranking()ranking.score()를 호출하므로 NullPointerException이 발생합니다.

빈 랭킹이나 누락된 상품에 대해 안전하게 처리할 수 있도록 수정해야 합니다.

Comment on lines 22 to 31
@InboxEvent(
aggregateType = "PRODUCT",
eventType = "INCREASE_PRODUCT_LIKE_RANKING_SCORE",
eventIdField = "eventId",
aggregateIdField = "id"
)
public void increase(IncreaseProductLikeRankingScoreCommand command) {
ProductId productId = new ProductId(command.productId());
productRankingCacheRepository.increaseDaily(productId, LocalDate.now(), weight);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the IncreaseProductLikeRankingScoreCommand class definition
find . -type f -name "*IncreaseProductLikeRankingScoreCommand*" 2>/dev/null | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 205


🏁 Script executed:

cat ./core/service/src/main/java/com/loopers/core/service/productlike/command/IncreaseProductLikeRankingScoreCommand.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 251


🏁 Script executed:

#!/bin/bash
# Find and compare similar service classes to verify the correct pattern
fd "IncreaseProductViewRankingScoreService.java" --exec cat {}

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1263


🏁 Script executed:

fd "IncreaseProductViewRankingScoreCommand.java" --exec cat {}

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 247


🏁 Script executed:

#!/bin/bash
# Search for all @InboxEvent annotations to identify the pattern
rg "@InboxEvent" -A 5 --type java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 4918


🏁 Script executed:

#!/bin/bash
# Check ClearProductCacheService command to verify the pattern
fd "ClearProductCacheCommand.java" --exec cat {}

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 233


aggregateIdField 값이 올바르지 않습니다.

aggregateIdField = "id"로 설정되어 있지만, IncreaseProductLikeRankingScoreCommandid 필드가 없고 productId 필드만 있습니다. 같은 문제가 IncreaseProductViewRankingScoreService, ClearProductCacheService, IncreaseProductMetricViewCountService에서도 발견됩니다. aggregateIdField"productId"로 수정해야 합니다.

🤖 Prompt for AI Agents
In
core/service/src/main/java/com/loopers/core/service/productlike/IncreaseProductLikeRankingScoreService.java
around lines 22 to 31, the InboxEvent annotation uses aggregateIdField = "id"
but the command object exposes productId; change aggregateIdField to "productId"
so the framework maps the aggregate id correctly. Also apply the same change
(aggregateIdField -> "productId") in the other affected services:
IncreaseProductViewRankingScoreService, ClearProductCacheService, and
IncreaseProductMetricViewCountService.

Comment on lines +30 to +37
outboxes.stream()
.map(EventOutbox::getPayload)
.map(payload -> JacksonUtil.convertToObject(payload.value(), ProductLikeEvent.class))
.forEach(publisher::publish);

outboxes.stream()
.map(EventOutbox::publish)
.forEach(eventOutboxRepository::save);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

이벤트 발행과 상태 업데이트 사이에 원자성이 보장되지 않습니다.

두 개의 독립적인 스트림 연산으로 인해 다음 문제가 발생할 수 있습니다:

  1. 첫 번째 스트림에서 일부 이벤트 발행이 실패해도 두 번째 스트림에서 모든 outbox가 published로 표시됨
  2. 발행되지 않은 이벤트가 발행된 것으로 잘못 기록될 수 있음

각 이벤트를 개별적으로 처리하여 발행 성공 시에만 상태를 업데이트하는 것을 권장합니다.

🔎 수정 제안
-        outboxes.stream()
-                .map(EventOutbox::getPayload)
-                .map(payload -> JacksonUtil.convertToObject(payload.value(), ProductLikeEvent.class))
-                .forEach(publisher::publish);
-
-        outboxes.stream()
-                .map(EventOutbox::publish)
-                .forEach(eventOutboxRepository::save);
+        outboxes.forEach(outbox -> {
+            ProductLikeEvent event = JacksonUtil.convertToObject(
+                    outbox.getPayload().value(), 
+                    ProductLikeEvent.class
+            );
+            publisher.publish(event);
+            eventOutboxRepository.save(outbox.publish());
+        });

추가로 @Transactional 어노테이션 적용 및 발행 실패 시 예외 처리 전략도 검토해 보세요.

🤖 Prompt for AI Agents
In
core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeEventPublishService.java
around lines 30 to 37, the current two separate streams publish events and then
mark all outboxes published, causing non-atomic behavior where failed publishes
can still be marked published; change to process each EventOutbox in a single
loop: for each outbox convert payload to ProductLikeEvent, call
publisher.publish and only if publish returns/finishes successfully update the
outbox (e.g., set published flag or call EventOutbox.publish()) and immediately
save it via eventOutboxRepository.save; wrap the method in @Transactional (or
use transaction per-item as appropriate) and handle publish exceptions by
logging and rethrowing or skipping without marking the outbox published so
failed publishes are not recorded as published.

Comment on lines +18 to +24
@Autowired(required = false)
private RedisCleanUp redisCleanUp;

@AfterEach
void databaseCleanUp() {
void cleanup() {
databaseCleanUp.truncateAllTables();
redisCleanUp.truncateAll();
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Null 체크 누락으로 인한 NPE 위험이 있습니다.

redisCleanUprequired = false로 선택적 주입되지만, Line 24에서 null 체크 없이 truncateAll()을 호출합니다. Redis가 설정되지 않은 테스트 환경에서 NPE가 발생할 수 있습니다.

🔎 Null 체크를 추가한 수정안
     @AfterEach
     void cleanup() {
         databaseCleanUp.truncateAllTables();
-        redisCleanUp.truncateAll();
+        if (redisCleanUp != null) {
+            redisCleanUp.truncateAll();
+        }
     }
📝 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
@Autowired(required = false)
private RedisCleanUp redisCleanUp;
@AfterEach
void databaseCleanUp() {
void cleanup() {
databaseCleanUp.truncateAllTables();
redisCleanUp.truncateAll();
@Autowired(required = false)
private RedisCleanUp redisCleanUp;
@AfterEach
void cleanup() {
databaseCleanUp.truncateAllTables();
if (redisCleanUp != null) {
redisCleanUp.truncateAll();
}
}
🤖 Prompt for AI Agents
In core/service/src/test/java/com/loopers/core/service/IntegrationTest.java
around lines 18 to 24, redisCleanUp is injected with required = false but used
without a null check on line 24, risking an NPE in environments without Redis;
update cleanup() to guard the call (e.g., if (redisCleanUp != null)
redisCleanUp.truncateAll(); or use
Optional.ofNullable(redisCleanUp).ifPresent(RedisCleanUp::truncateAll)) so
truncateAll() is only invoked when redisCleanUp is present.

@kilian-develop kilian-develop merged commit 3cd37f4 into Loopers-dev-lab:kilian-develop Jan 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant