Skip to content

[round-9] 실시간 상품 랭킹 API 구현#9

Merged
ImSejin merged 22 commits into
masterfrom
round-09
Sep 15, 2025
Merged

[round-9] 실시간 상품 랭킹 API 구현#9
ImSejin merged 22 commits into
masterfrom
round-09

Conversation

@ImSejin

@ImSejin ImSejin commented Sep 11, 2025

Copy link
Copy Markdown
Member

📌 Summary

랭킹 집계 파이프라인

  1. commerce-api가 이벤트를 발행
  2. commerce-streamer가 이벤트를 컨슘.
  3. commerce-streamer가 Redis에 랭킹을 집계.
  4. commerce-api가 Redis에서 랭킹을, RDB에서 상품을 조회하여 데이터 제공.

💬 Review Points

RR을 의도하여 Kafka 파티션키를 일부러 명시하지 않는 경우가 있나요?

파티션키를 명시하지 않고 이벤트를 발행하면 Round-Robin으로 파티션에 할당한다고 배웠습니다.

예를 들어 상품 조회수 집계는 RDB에 접근하지 않으니 경합 문제에서 자유롭습니다.
따라서 파티션키를 통한 순서 보장이 불필요하다고 생각합니다.
오히려 파티션키를 주면 인기 있는 상품을 조회할 때마다 특정 컨슈머에게만 부하가 가해질 거 같습니다.

이를 균등한 부하를 위해, 파티션키를 명시하지 않았던 적이 있으셨는지 궁금합니다.

Carry over하려면 스케쥴러 말고 다른 방법은 없을까?

일별 집계라면 23:30쯤에 다음 날 랭킹을 미리 집계하는 스케쥴러로 해결할 수 있을 거 같습니다.
(Sliding Window 방식을 사용하지 않고) 혹시 스케쥴러 말고 다른 방법으로 carry over 하는 방법이 있을까 고민했습니다.

commerce-streamer가 집계하면서 양자화된 시간 단위가 달라졌을 때만(다음 날),
분산락을 걸어 carry-over 할 수 있겠다고 생각했어요.

이러면 날짜가 변경되어 최초 집계할 때마다 commerce-api가 lock이 해제될 때까지 기다려야 할 거 같아요.
이 방법에 대해서 어떻게 생각하시나요? 혹 더 괜찮은 방법(without sliding window)이 있는지 궁금합니다!

실시간 랭킹 API가 조회일자를 받아야 할까?

과제의 API 스펙에 date 파라미터가 있습니다.
"실시간"이라면, 이 값 자체가 LocalDate.now()로 고정되어야 하지 않나라는 생각이 듭니다.

✅ Checklist

📈 Ranking Consumer

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

⚾ Ranking API

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

Summary by CodeRabbit

  • New Features

    • Added product rankings endpoint (GET /api/v1/rankings) with pagination and date; product detail now exposes an optional rank.
  • Improvements

    • Batch-oriented metric processing and bulk persistence for faster, more accurate ranking updates.
    • Conditional like-count cache updates and targeted product-detail eviction for stock changes.
    • Streamlined Kafka consumers and messaging handling for more robust event processing.
  • Documentation

    • Updated README apps/modules layout and added an HTTP example for rankings.

@ImSejin ImSejin self-assigned this Sep 11, 2025
@coderabbitai

coderabbitai Bot commented Sep 11, 2025

Copy link
Copy Markdown

Walkthrough

Adds a ranking feature (domain, infra, facade, API, controller), threads rank into product detail responses, converts many product DTOs to Java records with renamed repository/cache APIs, migrates Kafka consumers to typed payloads, overhauls metric batching/persistence and Redis cache implementations, and introduces WrappedObjectMapper and redis StringUtils.

Changes

Cohort / File(s) Summary
Documentation & Examples
README.md, http/commerce-api/rank.http
README apps/modules tree updated; added HTTP example for ranking retrieval.
Product detail + rank integration
apps/commerce-api/.../application/product/ProductFacade.java, .../application/product/ProductOutput.java, .../interfaces/api/product/ProductResponse.java
Injected RankingService; lookup rank by date/productId; propagate nullable rank through domain output and API response (factory/builder signatures updated).
Ranking feature (domain → infra → API)
apps/commerce-api/.../application/ranking/*, .../domain/ranking/*, .../infrastructure/ranking/RankingRepositoryImpl.java, .../interfaces/api/ranking/*
Added RankingCommand/QueryResult/Result, RankingService, RankingRepository contract and Redis ZSET implementation, RankingFacade that enriches ranks with product details, API DTOs, spec and controller.
Product DTOs → records & repo/cache renames
apps/commerce-api/.../domain/product/ProductQueryResult.java, .../domain/product/ProductResult.java, .../infrastructure/product/ProductRepositoryImpl.java, .../domain/product/ProductRepository.java, .../domain/product/ProductCacheRepository.java, .../infrastructure/product/ProductCacheRepositoryImpl.java, apps/commerce-api/src/test/.../ProductServiceTest.java, apps/commerce-api/.../application/product/ProductService.java
Converted product DTOs to Java records (accessor renames), added brandName, renamed repository/cache methods (findProductDetailById→findDetail, findProductOptionsByIds→findOptions, saveProductDetail→saveDetail); infra and tests updated.
Kafka consumers → typed payloads & domain commands
apps/commerce-streamer/.../interfaces/consumer/*
Replaced ConsumerRecord+ObjectMapper parsing with Spring Kafka typed @Payload KafkaMessage batches and topic header; consumers now delegate to domain services/commands; method signatures changed.
Metric batching, model & persistence
apps/commerce-streamer/.../application/metric/MetricFacade.java, .../domain/metric/*, .../infrastructure/metric/*
Switched to batch aggregation: MetricCommand.Aggregate with items, Metric value object, MetricService aggregates and merges duplicates, MetricRepository API changed to increase(List) with Redis pipeline; MetricWeight added; legacy merge kept.
Streamer product cache ops + product commands/service
apps/commerce-streamer/.../domain/product/ProductCommand.java, .../domain/product/ProductCacheRepository.java, .../domain/product/ProductService.java, .../infrastructure/product/ProductCacheRepositoryImpl.java
Added ProductCommand records, ProductCacheRepository interface, ProductService to replaceLikeCounts/removeDetails, and Redis-backed ProductCacheRepositoryImpl with pipelined HSET-if-key-exists and DEL.
Utilities & Jackson support
modules/redis/.../support/StringUtils.java, supports/jackson/.../WrappedObjectMapper.java, supports/jackson/.../JacksonConfig.java, (removed) apps/commerce-api/.../support/StringUtils.java
Introduced StringUtils in redis module (padStart, invert9sComplement) and removed duplicate; added WrappedObjectMapper helpers and registered bean in JacksonConfig.
Infrastructure & config tweaks
modules/jpa/.../JpaConfig.java, modules/kafka/.../KafkaConfig.java
Adjusted @EntityScan/@EnableJpaRepositories attributes; added MAX_POLL_INTERVAL_MS constant and consumer config assignment/comment.
Tests & concurrency utils
apps/commerce-api/src/test/.../ProductServiceTest.java, apps/commerce-api/src/test/java/com/loopers/test/assertion/ConcurrentAssertion.java
Tests adapted to record constructors and renamed methods; ConcurrentAssertion now uses CyclicBarrier + CountDownLatch to align worker starts and await completion.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Client
  participant Ctrl as ProductV1Controller
  participant Fac as ProductFacade
  participant Rank as RankingService
  participant Prod as ProductService
  participant Repo as ProductRepository

  Client->>Ctrl: GET /api/v1/products/{id}
  Ctrl->>Fac: getProductDetail(productId, userId)
  Fac->>Rank: findRank(FindRank{date, productId})
  Rank-->>Fac: Optional<Long> rank
  Fac->>Prod: getProductDetail(productId)
  Prod->>Repo: findDetail(productId)
  Repo-->>Prod: ProductDetail
  Prod-->>Fac: ProductResult.GetProductDetail
  Fac-->>Ctrl: ProductOutput (includes rank)
  Ctrl-->>Client: 200 OK
Loading
sequenceDiagram
  autonumber
  actor Client
  participant Ctrl as RankingV1Controller
  participant Fac as RankingFacade
  participant Svc as RankingService
  participant Repo as RankingRepository
  participant Prod as ProductService

  Client->>Ctrl: GET /api/v1/rankings?date&page&size
  Ctrl->>Fac: searchRankings(input)
  Fac->>Svc: searchRanks(SearchRanks)
  Svc->>Repo: searchRanks(date,pageable)
  Repo-->>Svc: Page<SearchRanks>
  Svc-->>Fac: RankingResult.SearchRanks
  alt no items
    Fac-->>Ctrl: empty result
  else items
    loop each item
      Fac->>Prod: getProductDetail(productId)
      Prod-->>Fac: detail or throw NOT_FOUND
    end
    Fac-->>Ctrl: RankingOutput.SearchRankings
  end
  Ctrl-->>Client: 200 OK
Loading
sequenceDiagram
  autonumber
  participant Kafka as Broker
  participant Cons as MetricKafkaConsumer
  participant Fac as MetricFacade
  participant Svc as MetricService
  participant Repo as MetricRepository

  Kafka-->>Cons: Batch messages (@Payload KafkaMessage...)
  Cons->>Fac: aggregateProduct(topic, items)
  Fac->>Fac: filter idempotent (audit)
  Fac->>Svc: aggregate(Aggregate{items})
  Svc->>Svc: map items → Metric, merge duplicates
  Svc->>Repo: increase(List<Metric>) (Redis pipeline)
  Repo-->>Svc: done
  Svc-->>Fac: return
  Fac-->>Cons: ack
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~140 minutes

Poem

I nibble bytes and hop through streams,
ZSETs hum softly in moonlit dreams.
Records sprout where builders lay,
Batched hops keep the lag at bay.
I stamp my paw — cache clean, keys bright. 🐇✨

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 The title "[round-9] 실시간 상품 랭킹 API 구현" succinctly and accurately describes the primary change (implementation of a real-time product ranking API); it is concise, relevant to the changeset, and clear to teammates, with the "[round-9]" prefix serving as a tracking label rather than functional noise.
Description Check ✅ Passed The pull request description matches the repository template: it includes a Summary that explains the ranking pipeline, detailed Review Points with concrete questions for reviewers, and a populated Checklist indicating completed items; the References section is optional and its omission is acceptable here.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch round-09

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


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.

@coderabbitai

coderabbitai Bot commented Sep 11, 2025

Copy link
Copy Markdown

Note

Unit test generation is an Early Access feature. Expect some limitations and changes as we gather feedback and continue to improve it.


Generating unit tests... This may take up to 20 minutes.

@coderabbitai

coderabbitai Bot commented Sep 11, 2025

Copy link
Copy Markdown

UTG Post-Process Complete

No new issues were detected in the generated code and all check runs have completed. The unit test generation process has completed successfully.

@coderabbitai

coderabbitai Bot commented Sep 11, 2025

Copy link
Copy Markdown

Creating a PR to put the unit tests in...

The changes have been created in this pull request: View PR

@ImSejin

ImSejin commented Sep 11, 2025

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 11, 2025

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 24

Caution

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

⚠️ Outside diff range comments (3)
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java (3)

287-291: Use AssertJ’s assertThatThrownBy/assertThatExceptionOfType

assertThatException() isn’t a standard AssertJ entry point and will likely not compile. Use assertThatThrownBy.

-            assertThatException()
-                    .isThrownBy(() -> sut.addStocks(command))
+            assertThatThrownBy(() -> sut.addStocks(command))
                     .isInstanceOf(BusinessException.class)
                     .hasFieldOrPropertyWithValue("errorType", CommonErrorType.INVALID);

Add import:

import static org.assertj.core.api.Assertions.assertThatThrownBy;

315-319: Same fix for thrown exception assertion (NOT_FOUND path)

-            assertThatException()
-                    .isThrownBy(() -> sut.addStocks(command))
+            assertThatThrownBy(() -> sut.addStocks(command))
                     .isInstanceOf(BusinessException.class)
                     .hasFieldOrPropertyWithValue("errorType", CommonErrorType.NOT_FOUND);

463-467: Same fix for thrown exception assertion (NOT_ENOUGH path)

-            assertThatException()
-                    .isThrownBy(() -> sut.deductStocks(command))
+            assertThatThrownBy(() -> sut.deductStocks(command))
                     .isInstanceOf(BusinessException.class)
                     .hasFieldOrPropertyWithValue("errorType", ProductErrorType.NOT_ENOUGH);
🧹 Nitpick comments (69)
README.md (3)

29-31: apps 하위 모듈에 한줄 설명과 경로 링크를 추가하세요.

새로 추가된 실행 앱들이 무엇을 하는지 빠르게 파악할 수 있도록 간단 설명을 바로 근처에 두는 것이 좋습니다.

적용 예시:

 │   ├── 📦 commerce-api
 │   ├── 📦 commerce-streamer
 │   └── 📦 pg-simulator
+
+### Apps
+- `apps/commerce-api`: 상거래 REST API (상품 조회, 실시간 랭킹 API 포함)
+- `apps/commerce-streamer`: Kafka 컨슈머/집계기 (이벤트 가중치 집계 → Redis ZSET 업데이트)
+- `apps/pg-simulator`: 로컬 개발용 트래픽/이벤트 시뮬레이터

35-35: kafka 모듈 도입에 따른 로컬 인프라/환경변수 문서화를 보강하세요.

infra-compose에 Kafka/Redis가 포함되는지, 그리고 앱에서 요구하는 환경변수(bootstrap servers, topic, consumer group 등)를 README에 명시하는 것이 좋습니다. 빠지면 로컬 구동 난이도가 급격히 올라갑니다.

예시 추가안:

 ├── modules ( reusable-configurations )
 │   ├── 📦 jpa
 │   ├── 📦 redis
 │   ├── 📦 kafka
 │   ├── 📦 feign
 │   └── 📦 resilience4j
+
+### Kafka (modules/kafka)
+- 필수 환경변수
+  - `KAFKA_BOOTSTRAP_SERVERS` (예: `localhost:9092`)
+  - `RANKING_TOPIC` (예: `ranking.events`)
+  - `CONSUMER_GROUP_ID` (예: `commerce-streamer`)
+- 로컬 인프라: `docker/infra-compose.yml`에 Kafka/ZooKeeper 포함 여부 확인 필요
+  - 포함되어 있지 않다면 간단한 사용법과 포트 매핑을 README에 안내해주세요.

확인 요청:

  • docker/infra-compose.yml에 Kafka/ZooKeeper, Redis가 포함되어 있나요?
  • 실제 사용 토픽/그룹 이름을 위 예시로 대체해도 무방한가요?

43-49: Misc 대신 ‘실시간 랭킹 API 사용 가이드’를 추가해 주세요.

본 PR 핵심 기능이므로 최소한의 호출 예시와 파라미터(특히 date 파라미터 유무/기본값/타임존)를 명시해 두는 게 유용합니다.

예시 골격:

-## Misc
-
-> https://github.com/HariSekhon/DevOps-Python-tools
-
-docker images tag 찾을 때.
+## 실시간 랭킹 API 가이드 (초안)
+
+- 목적: Top-N 실시간 상품 랭킹 조회 및 상품 상세에 랭킹 포함
+- 파라미터
+  - `size` (기본값/최대값 명시)
+  - `page` (0-based 여부 명시)
+  - `date` (선택): "실시간"의 정의에 따라 기본값을 `LocalDate.now()`로 둘지, 명시 시 과거 스냅샷을 조회할지 결정 필요
+    - 타임존 기준 명시 (예: Asia/Seoul)
+    - 미지정 시 동작: now로 고정 vs 서버 기본 타임존 기준 – 결정 필요
+- 응답: 상품 메타 + `rank` 필드(미진입 시 null) 포함
+- 에러 케이스: 범위를 벗어난 size/page, 잘못된 date 형식
+
+TODO
+- 실제 엔드포인트/예시 cURL 추가 (`GET ...`), 응답 샘플 JSON
+- 상단 ‘Monitoring’ 섹션과 연계하여 랭킹 ZSET 키 전략/TTL 간략 소개 링크
+
+---
+참고 링크는 별도 ‘References’ 섹션으로 이동:
+- https://github.com/HariSekhon/DevOps-Python-tools (docker image 태그 조회 참고)

검토 포인트:

  • “실시간”에서 date를 받지 않는다면, API 스펙과 문서 모두에서 제거/비활성 파라미터로 명시 필요.
  • 받는다면 기본값, 타임존, 과거 조회 범위 정책을 확정해 주세요.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/audit/AuditKafkaConsumer.java (5)

16-16: Remove unused IOException import.

-import java.io.IOException;

32-33: Drop unused keys header parameter.

Not used; simplify signature.

-            @Header(KafkaHeaders.RECEIVED_KEY) List<String> keys,

36-36: Consider downgrading to debug to avoid chatty logs in high throughput paths.

-        log.info("Received {} messages on '{}'", messages.size(), topic);
+        log.debug("Received {} messages on '{}'", messages.size(), topic);

38-45: Guard against null payloads in the batch.

Prevents NPE if a malformed message slips through deserialization.

-        List<AuditInput.Audit.Item> items = messages.stream()
+        List<AuditInput.Audit.Item> items = messages.stream()
+                .filter(m -> m != null && m.payload() != null)
                 .map(message -> new AuditInput.Audit.Item(
                         message.eventId(),
                         message.payload().eventKey(),
                         message.payload().eventName(),
                         message.payload().userId()
                 ))
                 .toList();

47-47: Ensure audit processing is idempotent by eventId.

Duplicates can reappear (retries, rebalances). Confirm auditFacade.audit dedupes on eventId (e.g., unique constraint or Redis set).

modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java (3)

21-24: Point the Javadoc to the exact consumer configs (nit).

Linking directly to the consumer configs improves dev UX; optionally add a @SInCE tag.

Apply:

 /**
- * @see <a href="https://kafka.apache.org/documentation/">
- * Apache Kafka Documentation</a>
+ * @see <a href="https://kafka.apache.org/documentation/#consumerconfigs">Apache Kafka consumer configs</a>
+ * @since 0.1
  */

32-32: Use Duration for time-based constants (avoid ms math; clearer intent).

Keeps time units explicit and prevents accidental overflow in future edits.

Apply:

-public static final int MAX_POLL_INTERVAL_MS = 2 * 60 * 1000; // max poll interval = 2m
+public static final java.time.Duration MAX_POLL_INTERVAL = java.time.Duration.ofMinutes(2); // max poll interval = 2m

And update usage at Line 73:

-consumerConfig.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, MAX_POLL_INTERVAL_MS);
+consumerConfig.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, MAX_POLL_INTERVAL.toMillis());

Optional: add an import instead of FQN:

import java.time.Duration;

68-74: Externalize Kafka batch settings to LoopersKafkaProperties and add p95/p99 batch timers

MAX_POLL_INTERVAL_MS and MAX_POLL_RECORDS are hard-coded in modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java (lines 68–74) and affect multiple batch listeners — make them configurable and monitor batch latencies to avoid rebalances.

  • Move MAX_POLL_INTERVAL_MS and MAX_POLL_RECORDS into LoopersKafkaProperties and inject them into the consumer factory used by KafkaConfig.BATCH_LISTENER.
  • Add instrumentation to record p95/p99 batch processing time and create alerts/dashboards; tune defaults (reduce max.poll.records or increase max.poll.interval.ms) based on observed latency.
  • Affected listeners: apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/{audit/AuditKafkaConsumer.java, product/ProductKafkaConsumer.java, metric/MetricKafkaConsumer.java}.
  • Can implement properties + wiring in a follow-up PR.
modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java (2)

8-18: Javadoc: fix typos and tighten the transactional rationale.

Minor typos and slightly misleading phrasing about read-only commits. Suggest rewording for precision.

-/**
- * <h3>
- * <code>&#064;EnableJpaRepositories(enableDefaultTransactions = false)</code>
- * </h3>
- * {@code SimpleJpaRepository}에 <code>&#064;Transactional</code>이 붙어져 있다.
- * {@code findById}, {@code findAll}을 사용하면 {@link com.loopers.annotation.ReadOnlyTransactional}을 사용해도
- * 읽기 전용 트랜잭션을 종료하가 위해, commit 요청을 보낸다. 이를 방지하기 위해 {@code JpaRepository}의 기본 트랜잭션을 비활성화한다.
- * <p>
- * 비활성화하면 {@code CrudRepository}의 메서드를 실행하는데, 여기에는 애노테이션이 붙어있지 않다.
- * 따라서 {@code save}, {@code saveAll}를 호출할 때 트랜잭션 내에서 실행해야 한다.
- */
+/**
+ * <h3><code>@EnableJpaRepositories(enableDefaultTransactions = false)</code></h3>
+ * Spring Data JPA의 기본 구현체인 {@code SimpleJpaRepository}에는 {@code @Transactional}이 적용되어 있습니다.
+ * 따라서 {@code findById}, {@code findAll} 등의 호출은 읽기 전용이라도 트랜잭션 경계를 열고 닫으며, 종료 시 커밋 단계가 수행됩니다.
+ * 기본 트랜잭션을 비활성화하면 리포지토리 메서드에 기본 {@code @Transactional}이 적용되지 않습니다.
+ * 이때 {@code save}, {@code saveAll}, {@code delete*} 등의 쓰기 작업은 반드시 상위 계층(서비스/유스케이스)에서 트랜잭션 안에서 실행해야 합니다.
+ * 또한 Open-EntityManager-In-View가 비활성화된 환경에서는 지연 로딩을 위해 읽기 작업도 트랜잭션 경계 내 실행을 권장합니다.
+ */

21-21: Entity scan scope: consider anchoring by class to avoid brittle package strings.

Optional: switch to basePackageClasses with a marker/anchor type to make refactors safer and reduce accidental over-scan.

Example:

// import com.loopers.domain.JpaEntitiesMarker;
@EntityScan(basePackageClasses = JpaEntitiesMarker.class)
apps/commerce-streamer/src/main/java/com/loopers/domain/metric/MetricRepository.java (1)

3-8: Generalize parameter type to reduce coupling and improve API ergonomics

Accepting List ties callers to a concrete type and prevents passing Sets/Streams easily. Prefer Collection<? extends Metric>.

-import java.util.List;
+import java.util.Collection;
 
 public interface MetricRepository {
 
-    void increase(List<Metric> metrics);
+    void increase(Collection<? extends Metric> metrics);
 
 }
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryResult.java (1)

5-9: Naming nit: plural type name for a single row

SearchRanks represents a single item. Consider adding a singular alias to improve readability and IDE autocompletion without breaking callers.

 public record RankingQueryResult() {
 
     public record SearchRanks(
             Long productId,
             Long rank
     ) {
     }
+
+    // Alias for readability; keep existing type for compatibility.
+    public record SearchRank(
+            Long productId,
+            Long rank
+    ) { }
 
 }
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingCommand.java (1)

15-20: Add basic input validation (page/size) and default date behavior

Guard against invalid pagination and optionally default date when omitted.

-    public record SearchRanks(
-            LocalDate date,
-            Integer page,
-            Integer size
-    ) {
-    }
+    public record SearchRanks(
+            LocalDate date,
+            Integer page,
+            Integer size
+    ) {
+        public SearchRanks {
+            if (date == null) {
+                date = LocalDate.now();
+            }
+            if (page == null || page < 1) {
+                throw new IllegalArgumentException("page must be >= 1");
+            }
+            if (size == null || size < 1 || size > 1000) {
+                throw new IllegalArgumentException("size must be in [1, 1000]");
+            }
+        }
+    }
apps/commerce-streamer/src/main/java/com/loopers/domain/metric/attribute/MetricWeight.java (1)

18-20: Document order-dependence of toArray()

Callers relying on positional mapping need the declaration order guarantee called out.

-    public static double[] toArray() {
+    /**
+     * Returns weights in enum declaration order. Consumers that rely on positional
+     * mapping must keep this in sync with MetricWeight.values().
+     */
+    public static double[] toArray() {
         return Arrays.stream(values()).mapToDouble(MetricWeight::getValue).toArray();
     }
apps/commerce-streamer/src/main/java/com/loopers/domain/metric/ProductMetricDaily.java (1)

38-51: Optional: use primitives for non-null columns

Since columns are non-null, consider primitive long for counts to avoid accidental null handling in JPA mappings.

apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCommand.java (2)

8-16: Guard against invalid inputs at the boundary

Consider validating that productId > 0 and likeCount ≥ 0 (Bean Validation annotations or facade-level checks) to avoid pushing bad data into caches.


32-38: Avoid duplicate command shapes for metrics/ranking

AggregateRanking overlaps with the Metric/MetricCommand model introduced elsewhere. Consider reusing the Metric command or a shared DTO to reduce duplication and divergence.

modules/redis/src/main/java/com/loopers/support/StringUtils.java (2)

11-15: Confirm padStart contract for non-positive length

Returning an empty string for length <= 0 is atypical; most libs return the original string when requested length is not greater than current. If unintended, consider this tweak.

-        if (length <= 0) {
-            return "";
-        }
+        if (length <= 0) {
+            return s;
+        }

11-11: Null-safety precondition

Defensive null check prevents NPEs at s.length() and documents the contract.

+        java.util.Objects.requireNonNull(s, "s must not be null");
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metric/MetricRepositoryImpl.java (2)

61-61: Trim correctness for <= 1000 elements

removeRange(all, 0, -1001) is the right pattern to keep top 1000. Please add a small test to verify behavior when size <= 1000 (no removals).


63-65: TTL aligns with full-day retention

LocalTime.MIN on date.plusDays(RETENTION_DAYS) can under-retain (expires at the start of the last day). Using plusDays(RETENTION_DAYS + 1) at 00:00 retains full days. If you meant “expire at end-of-day”, swap to LocalTime.MAX instead.

-                Instant ttl = ZonedDateTime.of(date.plusDays(RETENTION_DAYS), LocalTime.MIN, ZoneId.systemDefault()).toInstant();
+                Instant ttl = ZonedDateTime.of(date.plusDays(RETENTION_DAYS + 1), LocalTime.MIN, ZoneId.systemDefault()).toInstant();
supports/jackson/src/main/java/com/loopers/config/jackson/WrappedJsonMapper.java (2)

20-22: Constructor consistency with app-wide ObjectMapper config

The no-arg constructor builds a vanilla JsonMapper, bypassing global configuration. Prefer removing it and always injecting the configured mapper.

-    public WrappedJsonMapper() {
-        this.jsonMapper = new JsonMapper();
-    }
+    // Intentionally no no-arg constructor; use the @RequiredArgsConstructor one to ensure consistent configuration.

41-51: Handle non-object values defensively

If o serializes to a non-object node, node.fields() is empty—returning {} may hide issues. Optional: assert object node.

-        JsonNode node = jsonMapper.valueToTree(o);
+        JsonNode node = jsonMapper.valueToTree(o);
+        if (!node.isObject()) {
+            throw new IllegalArgumentException("Expected object node but got: " + node.getNodeType());
+        }
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductQueryResult.java (3)

5-15: Consider avoiding an empty top-level record as a namespace.

Using public record ProductQueryResult() purely as a holder is unconventional and may confuse readers about value semantics. Prefer a final utility class.

-public record ProductQueryResult() {
+public final class ProductQueryResult {

31-31: EMPTY sentinel: guard against accidental iteration on a null options list.

EMPTY sets options to null, which is easy to forget and iterate over. Either use an empty list or provide an isEmpty() helper for explicit checks.

Option A (safer default):

- public static final ProductDetail EMPTY = new ProductDetail(null, null, null, null, null, null, null);
+ public static final ProductDetail EMPTY = new ProductDetail(null, null, null, null, null, null, List.of());

Option B (keep null sentinel but add helper):

     ) {
+        public boolean isEmpty() { return productId == null; }

7-14: Boxed numeric types: confirm nullability is intentional.

If productId/basePrice/likeCount/brandId are never null at the API boundary, prefer primitives to signal invariants and avoid boxing costs.

http/commerce-api/rank.http (1)

4-4: Use Accept for GET; Content-Type is irrelevant without a body.

For a GET request, set Accept: application/json instead of Content-Type.

-Content-Type: application/json
+Accept: application/json
apps/commerce-streamer/src/main/java/com/loopers/domain/metric/LegacyMetricRepository.java (1)

5-5: Clarify merge contract and return semantics

Document whether true means “inserted or updated at least one row.” Consider returning int affectedRows instead of boolean to avoid ambiguity.

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

75-76: Standardize nullability annotations and OpenAPI hints

This file uses jakarta.annotation.Nullable while ProductOutput uses org.jetbrains.annotations.Nullable. Pick one (recommend jakarta.annotation.Nullable across modules) for consistency and better tooling. Optionally add @Schema(nullable = true, description = "Rank for the current ranking window") to improve API docs.

-@Nullable
-private final Long rank;
+@Nullable
+private final Long rank;

(Keep as jakarta here; align ProductOutput accordingly.)

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

15-18: Bind-and-document request for GET endpoints

For Spring MVC + springdoc, add @ParameterObject (and optionally @ModelAttribute) so query params bind and are documented. Also clarify default/date semantics (use LocalDate.now with a defined ZoneId if date is omitted).

 import com.loopers.interfaces.api.ApiResponse;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.validation.Valid;
+import org.springdoc.core.annotations.ParameterObject;
 
 ...
-    ApiResponse<RankingResponse.SearchRankings> searchRankings(
-            @Valid
-            RankingRequest.SearchRankings request
-    );
+    ApiResponse<RankingResponse.SearchRankings> searchRankings(
+            @Valid
+            @ParameterObject
+            /* @ModelAttribute */ RankingRequest.SearchRankings request
+    );

Additionally: define the service’s business timezone for “today” to avoid boundary issues at midnight.

apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCacheRepository.java (1)

8-10: Define “ifAbsent” and batching contract

Specify whether “absent” means Redis key missing, field missing, or value null, and expected TTL behavior. Also document recommended batch size/chunking to avoid large pipelines.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metric/LegacyMetricRepositoryImpl.java (1)

15-26: Return type may hide useful information

Returning boolean discards affected row count and treats “no-op” updates as false. Consider returning int for clarity, or javadoc the true condition precisely.

-public boolean merge(ProductMetricDaily metric) {
+public int merge(ProductMetricDaily metric) {
     metric.prePersist();
-    return productMetricDailyJpaRepository.merge(
+    return productMetricDailyJpaRepository.merge(
         ...
-    ) == 1;
+    );
 }

Align the interface accordingly if you adopt this.

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

28-29: Align nullability annotation with the API layer

Switch to jakarta.annotation.Nullable (used in ProductResponse) for consistency and better OpenAPI integration.

-import org.jetbrains.annotations.Nullable;
+import jakarta.annotation.Nullable;
apps/commerce-streamer/src/main/java/com/loopers/application/metric/MetricInput.java (1)

16-19: Prefer primitives for counters

Since nulls are no longer used, consider long to avoid boxing and reduce allocations on hot paths.

-Long likeCount,
-Long saleQuantity,
-Long viewCount
+long likeCount,
+long saleQuantity,
+long viewCount
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java (1)

11-11: Define rank semantics (1-based vs 0-based) in the contract.

Ambiguity here leads to off-by-one bugs across layers. Document and enforce 1-based ranks if that’s the intent.

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

19-23: Consider documenting/deciding API contract for “real-time” date.

If the spec decides to drop the date param for real-time, reflect that in RankingV1ApiSpec and deprecate the query param here.

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

33-35: Fail-all on a single missing detail may be harsh for a list endpoint.

Consider partial responses (skip missing) or placeholder items; otherwise a single NOT_FOUND breaks the whole page.

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

63-70: Confirm CacheAside null-object behavior to avoid caching EMPTY.

Ensure CacheAside.isNullObject(...) prevents saveCache when detail == ProductDetail.EMPTY; otherwise EMPTY may be cached.

apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductService.java (2)

21-26: Deduplicate entries per productId before cache write.

Avoid redundant writes when command contains duplicates.

-        List<Map.Entry<Long, Long>> entries = command.items()
-                .stream()
-                .map(item -> Map.entry(item.productId(), item.likeCount()))
-                .toList();
+        List<Map.Entry<Long, Long>> entries = command.items().stream()
+                .collect(collectingAndThen(
+                        toMap(ProductCommand.ReplaceLikeCounts.Item::productId,
+                              ProductCommand.ReplaceLikeCounts.Item::likeCount,
+                              (a, b) -> b),
+                        m -> List.copyOf(m.entrySet())
+                ));

Add imports if missing:

+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.toMap;

33-40: No-op early return when nothing to evict.

Skip repository call if productIds is empty.

         List<Long> productIds = command.items()
                 .stream()
                 // 재고가 소진된 상품만 캐시를 삭제한다.
                 .filter(item -> item.stockQuantity() == 0)
                 .map(ProductCommand.RemoveDetails.Item::productId)
                 .toList();
-        productCacheRepository.evictProductDetails(productIds);
+        if (!productIds.isEmpty()) {
+            productCacheRepository.evictProductDetails(productIds);
+        }
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java (2)

25-31: Guard page/size to safe bounds at the service layer.

Prevents IllegalArgumentException and unbounded page sizes.

-        Pageable pageable = PageRequest.of(command.page(), command.size());
+        int page = Math.max(0, command.page());
+        int size = Math.min(1000, Math.max(1, command.size()));
+        Pageable pageable = PageRequest.of(page, size);

13-16: Remove unused @slf4j.

Slf4j is imported/annotated but unused.

-@Slf4j
 @Service
 @RequiredArgsConstructor
 public class RankingService {

Also remove the import:

-import lombok.extern.slf4j.Slf4j;
apps/commerce-streamer/src/main/java/com/loopers/domain/metric/MetricService.java (1)

24-41: Explicitly key deduplication on (date, productId).

Metric.java already uses @EqualsAndHashCode(onlyExplicitlyIncluded = true) with date and productId (so current code works); still prefer an explicit toMap key for clarity and future-proofing.

-        List<Metric> metrics = command.items()
-                .stream()
-                .map(item -> Metric.builder()
-                        .date(item.date())
-                        .productId(item.productId())
-                        .likeCount(item.likeCount())
-                        .saleQuantity(item.saleQuantity())
-                        .viewCount(item.viewCount())
-                        .build()
-                )
-                .collect(collectingAndThen(
-                        toMap(
-                                Function.identity(),
-                                Function.identity(),
-                                Metric::plus
-                        ),
-                        map -> List.copyOf(map.values())
-                ));
+        List<Metric> metrics = command.items().stream()
+                .collect(collectingAndThen(
+                        toMap(
+                                item -> Map.entry(item.date(), item.productId()),
+                                item -> Metric.builder()
+                                        .date(item.date())
+                                        .productId(item.productId())
+                                        .likeCount(item.likeCount())
+                                        .saleQuantity(item.saleQuantity())
+                                        .viewCount(item.viewCount())
+                                        .build(),
+                                Metric::plus
+                        ),
+                        m -> List.copyOf(m.values())
+                ));
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingRequest.java (1)

10-10: Default “real-time” date via Clock, not LocalDate.now() sprinkled around

Keep the DTO nullable for date, but in the service resolve null to LocalDate.now(clock) with an injected Clock to avoid timezone/test flakiness.

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

212-213: Strengthen verification: ensure method not called with any argument

Verifying with the exact null argument can pass even if the method was called with a different value. Use any() to assert zero invocations.

-            verify(productRepository, never()).findDetail(productId);
+            verify(productRepository, never()).findDetail(any());
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingOutput.java (1)

40-49: Consider exposing rank in the payload (optional)

UX often needs the ordinal (1..N). If non-breaking, consider adding Integer rank to Item and populate it from the iteration index.

apps/commerce-api/src/main/java/com/loopers/domain/product/ProductResult.java (3)

62-62: Minor typo: double space before brandName

There’s a double space before brandName.

-            String  brandName,
+            String brandName,

67-74: Null-safe options mapping (optional)

queryResult.options() may be null depending on repository projections. Guard to avoid NPE.

-                    queryResult.options().stream().map(Option::from).toList()
+                    (queryResult.options() == null ? java.util.List.<ProductQueryResult.ProductDetail.Option>of() : queryResult.options())
+                            .stream()
+                            .map(Option::from)
+                            .toList()

104-107: Null-safe product options list (optional)

Prevent NPE when items() is null.

-                    .items(queryResult.items().stream().map(Item::from).toList())
+                    .items(queryResult.items() == null ? java.util.List.of()
+                           : queryResult.items().stream().map(Item::from).toList())
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java (2)

34-55: Method name vs behavior mismatch

replaceLikeCountsIfAbsent suggests “set only if field is absent,” but the Lua sets when the key exists (field may exist or not). Consider renaming to updateLikeCountsIfKeyExists (or adjust to true “absent” semantics with HSETNX, still guarded by EXISTS to avoid creating keys).

Do callers depend on “only-if-absent” semantics or on “only-if-key-exists”? If the latter, a rename improves clarity.


47-53: Avoid recomputing script bytes in the loop (nit)

Move HSET_IF_KEY_EXISTS.getBytes(...) outside the loop.

-            // Cache put
+            // Cache put
+            byte[] script = HSET_IF_KEY_EXISTS.getBytes(StandardCharsets.UTF_8);
             for (Map.Entry<Long, Long> entry : entries) {
@@
-                byte[] script = HSET_IF_KEY_EXISTS.getBytes(StandardCharsets.UTF_8);
                 connection.scriptingCommands().eval(
                         script,
                         ReturnType.INTEGER,
                         1, // key size
                         key, hashKey, hashValue
                 );
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/product/ProductKafkaConsumer.java (4)

30-46: Batch error path: confirm DLT/retry semantics or add localized handling.

If productService.replaceLikeCounts(command) throws, the batch won't ack and may hot-loop without a DLT. Ensure the container factory has an error handler/DLT configured; otherwise, wrap and route failures explicitly.

Would you confirm KafkaConfig.BATCH_LISTENER uses a DeadLetterPublishingRecoverer (or equivalent) with manual acks?


36-41: Deduplicate productIds within a batch to make last event win.

Batches may contain multiple events for the same productId. Coalesce by productId so the latest value per key is applied once.

-        List<ProductCommand.ReplaceLikeCounts.Item> items = messages.stream()
-                .map(message -> new ProductCommand.ReplaceLikeCounts.Item(
-                        message.payload().productId(),
-                        message.payload().likeCount()
-                ))
-                .toList();
+        Map<Long, Integer> lastLikeCounts = messages.stream()
+                .filter(m -> m != null && m.payload() != null)
+                .collect(java.util.stream.Collectors.toMap(
+                        m -> m.payload().productId(),
+                        m -> m.payload().likeCount(),
+                        (oldV, newV) -> newV, // last wins
+                        java.util.LinkedHashMap::new));
+        List<ProductCommand.ReplaceLikeCounts.Item> items = lastLikeCounts.entrySet().stream()
+                .map(e -> new ProductCommand.ReplaceLikeCounts.Item(e.getKey(), e.getValue()))
+                .toList();

34-34: Add partition/offset context to logs.

Helpful for tracing batch failures and replays.

-        log.info("Received {} messages on '{}'", messages.size(), topic);
+        log.info("Received {} messages on '{}' (manual-ack batch)", messages.size(), topic);

53-69: Mirror the deduplication and null-guards for StockChanged.

Same reasoning as LikeChanged; multiple updates for the same productId should coalesce within a batch.

-        List<ProductCommand.RemoveDetails.Item> items = messages.stream()
-                .map(message -> new ProductCommand.RemoveDetails.Item(
-                        message.payload().productId(),
-                        message.payload().quantity()
-                ))
-                .toList();
+        Map<Long, Integer> lastQty = messages.stream()
+                .filter(m -> m != null && m.payload() != null)
+                .collect(java.util.stream.Collectors.toMap(
+                        m -> m.payload().productId(),
+                        m -> m.payload().quantity(),
+                        (oldV, newV) -> newV,
+                        java.util.LinkedHashMap::new));
+        List<ProductCommand.RemoveDetails.Item> items = lastQty.entrySet().stream()
+                .map(e -> new ProductCommand.RemoveDetails.Item(e.getKey(), e.getValue()))
+                .toList();
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java (3)

74-76: Clarify off-by-one: “cache up to page 2” with 0-based pages caches 0,1,2. Is that intended?

If you meant “first two pages,” use >= 2 in both read/write paths.

-        if (pageRequest.getPageNumber() > 2) {
+        if (pageRequest.getPageNumber() >= 2) {
             return Page.empty(pageRequest);
         }

And mirror the same in saveProducts.


117-129: 30m TTL for product detail risks staleness (likeCount/stock).

Unless events invalidate this key reliably, consider shorter TTL with jitter (e.g., 1–5m) or field-level split (detail vs. volatile fields).

Would you like a proposal for event-driven cache invalidation keyed by product.detail:{id} on LikeChanged/StockChanged?


41-47: Use descriptive keys to avoid rare Objects.hash(...) collisions and aid debugging.

Keys like product.page:kw={kw}:brand={id}:sort={s}:p={p}:sz={sz} are safer and inspectable.

-        String key = "product.page:" + Objects.hash(
-                command.getKeyword(),
-                command.getBrandId(),
-                command.getSort(),
-                command.getPage(),
-                command.getSize()
-        );
+        String key = String.format("product.page:kw=%s:brand=%s:sort=%s:p=%d:sz=%d",
+                command.getKeyword(), command.getBrandId(), command.getSort(), command.getPage(), command.getSize());
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java (3)

128-142: Avoid mutating record state after construction; build options first, then construct.

Records signal immutability; mutating the list after construction is surprising.

-        for (Tuple row : rows) {
-            if (row.get(po.id) == null) {
-                continue;
-            }
-
-            ProductQueryResult.ProductDetail.Option item = new ProductQueryResult.ProductDetail.Option(
-                    row.get(po.id),
-                    row.get(po.name),
-                    row.get(po.additionalPrice),
-                    row.get(po.productId),
-                    row.get(ps.quantity)
-            );
-
-            detail.options().add(item);
-        }
+        java.util.List<ProductQueryResult.ProductDetail.Option> options = new java.util.ArrayList<>();
+        for (Tuple row : rows) {
+            if (row.get(po.id) == null) continue;
+            options.add(new ProductQueryResult.ProductDetail.Option(
+                    row.get(po.id),
+                    row.get(po.name),
+                    row.get(po.additionalPrice),
+                    row.get(po.productId),
+                    row.get(ps.quantity)
+            ));
+        }
+        detail = new ProductQueryResult.ProductDetail(
+                detail.id(), detail.name(), detail.basePrice(), detail.likeCount(), detail.brandId(), detail.brandName(),
+                options
+        );

153-174: Join strategy drops options without stock; confirm desired behavior.

join(ps) (inner) excludes options missing stock rows. If you want “out of stock” items included with quantity=null/0, use leftJoin.


63-71: Count query can skip brand join for minor speedup.

The where clause only needs p.brandId, so the extra join is unnecessary.

-        JPAQuery<Long> countQuery = queryFactory
-                .select(p.count())
-                .from(p)
-                .leftJoin(b).on(b.id.eq(p.brandId))
+        JPAQuery<Long> countQuery = queryFactory
+                .select(p.count())
+                .from(p)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/metric/MetricKafkaConsumer.java (5)

35-46: Short-circuit empty batches to avoid no-op aggregation.

Early-ack empty batches to cut unnecessary work.

Apply:

-    ) {
-        log.info("Received {} messages on '{}'", messages.size(), topic);
+    ) {
+        if (messages.isEmpty()) {
+            acknowledgment.acknowledge();
+            return;
+        }
+        log.info("Received {} messages on '{}'", messages.size(), topic);

40-40: Normalize date bucketing to a single business timezone.

publishedAt().toLocalDate() drops the zone/offset and can misbucket late/early events around midnight. Prefer converting to a configured ZoneId (e.g., business TZ) before deriving LocalDate, or explicitly use “day-of-event” vs “day-of-ingestion” via a central policy.

Would you like me to draft a small DateBucketPolicy and wire a ZoneId/Clock for consistent bucketing?

Also applies to: 65-65, 90-90, 115-115


112-119: Null-safety for sale quantity.

If quantity() can be null, longValue() will NPE. Guard or validate upstream.

-                        message.payload().quantity().longValue()
+                        Long.valueOf(
+                            java.util.Objects.requireNonNull(message.payload().quantity(), "quantity")
+                        )

35-35: Reduce log noise under load.

Consider downgrading to debug or adding sampling for high-traffic topics.

-        log.info("Received {} messages on '{}'", messages.size(), topic);
+        log.debug("Received {} messages on '{}'", messages.size(), topic);

Also applies to: 60-60, 85-85, 110-110


37-45: DRY the mapping/build/dispatch pattern.

The four handlers repeat the same pipeline. A small helper that accepts a mapper and quantity would cut duplication and reduce maintenance risk.

I can sketch a generic helper if you want to proceed.

Also applies to: 62-70, 87-95, 112-120

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8a27820 and c050b75.

📒 Files selected for processing (50)
  • README.md (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (5 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductOutput.java (2 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInput.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingOutput.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheRepository.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductQueryResult.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductResult.java (5 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java (2 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingCommand.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryResult.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingResult.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java (6 hunks)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java (3 hunks)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductResponse.java (2 hunks)
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingRequest.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/support/StringUtils.java (0 hunks)
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java (4 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/application/metric/MetricFacade.java (2 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/application/metric/MetricInput.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metric/LegacyMetricRepository.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metric/Metric.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metric/MetricCommand.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metric/MetricRepository.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metric/MetricService.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metric/ProductMetricDaily.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metric/attribute/MetricWeight.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCacheRepository.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCommand.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductService.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metric/LegacyMetricRepositoryImpl.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metric/MetricRepositoryImpl.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/audit/AuditKafkaConsumer.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/metric/MetricKafkaConsumer.java (4 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/product/ProductKafkaConsumer.java (2 hunks)
  • http/commerce-api/rank.http (1 hunks)
  • modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java (1 hunks)
  • modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java (2 hunks)
  • modules/redis/src/main/java/com/loopers/support/StringUtils.java (1 hunks)
  • supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java (2 hunks)
  • supports/jackson/src/main/java/com/loopers/config/jackson/WrappedJsonMapper.java (1 hunks)
💤 Files with no reviewable changes (1)
  • apps/commerce-api/src/main/java/com/loopers/support/StringUtils.java
🔇 Additional comments (35)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/audit/AuditKafkaConsumer.java (1)

26-35: Confirmed: BATCH_LISTENER uses MANUAL ack mode — no action required.
Set in modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java: factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL).

modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java (1)

77-77: No action—formatting-only change.

modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java (1)

21-25: OK to disable default transactions — write paths are covered.

  • Write/save/delete calls are executed inside @transactional methods (examples: OrderService, ProductService, PaymentService, CouponService, PointService, SagaService, various Facades/Processors).
  • Open-EntityManager-In-View is disabled (modules/jpa/src/main/resources/application-jpa.yml — open-in-view: false).
  • No explicit PlatformTransactionManager/JpaTransactionManager beans found; @EnableJpaRepositories(enableDefaultTransactions = false) remains in modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java.
  • Action: no change required now; if you introduce additional transaction managers later, set transactionManagerRef on @EnableJpaRepositories (or per-repository) to avoid ambiguity.
apps/commerce-streamer/src/main/java/com/loopers/domain/metric/MetricRepository.java (1)

7-7: Clarify failure, idempotency, and empty-input semantics

Define whether increase(...) is:

  • no-op on empty collection,
  • idempotent for duplicate Metric entries,
  • throwing vs. partial success on Redis/network errors (and whether retries are caller- or callee-owned).
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryResult.java (1)

6-7: Verify rank range/type

If rank is always 1..N (e.g., N ≤ 1000), Integer (or int) may be sufficient and prevents accidental nulls. Confirm expected bounds and nullability.

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

7-11: Real-time semantics: should date be optional and default to LocalDate.now()?

Given “real-time” intent, consider allowing null date and defaulting to LocalDate.now() at the boundary (controller/facade) to avoid leaking clock decisions across layers. If historical lookups are desired, keep date required and document it.

apps/commerce-streamer/src/main/java/com/loopers/domain/metric/attribute/MetricWeight.java (1)

12-14: Recheck weight calibration vs. expected metric scales

With VIEW=0.0001, LIKE=0.001, SALE=0.01, large view counts may dominate sales (e.g., 1,000,000 views → 100 vs. 100 sales → 1). Validate that this aligns with business intent for “Top 1000.”

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

7-12: Define API defaults and constraints

Are date/page/size optional at the HTTP layer? If so, enforce defaults (date=LocalDate.now(), page>=1, size bounded) in the controller or facade to keep the domain clean.

apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCommand.java (1)

20-28: Clarify RemoveDetails semantics

If items are removed only when stockQuantity == 0, encode that invariant (e.g., filter upstream or validate here) to prevent accidental evictions.

http/commerce-api/rank.http (1)

2-2: Make date optional for “real-time” and avoid hardcoding.

Omit date to mean "today" (server should default to LocalDate.now()); clarify whether pagination is 0- or 1-based and align fixtures.

Location: http/commerce-api/rank.http (line 2)

Suggested fixture:
GET {{commerce-api}}/api/v1/rankings?page=0&size=10

or, when backfilling/auditing a specific day:

GET {{commerce-api}}/api/v1/rankings?date=2025-09-10&page=0&size=10

Repo search found only this HTTP fixture; controller/API contract not located — verify controller signature to confirm date is optional and whether pagination is 0-based.

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

91-101: LGTM: rank propagation is correct

Mapping output.getRank() through the builder looks good.

apps/commerce-streamer/src/main/java/com/loopers/domain/product/ProductCacheRepository.java (1)

8-10: Incorrect — implementation exists in streamer

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java declares public class ProductCacheRepositoryImpl implements ProductCacheRepository, so streamer will autowire the bean. A duplicate impl also exists at apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java — consolidate or qualify to avoid duplicate-bean conflicts.

Likely an incorrect or invalid review comment.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metric/LegacyMetricRepositoryImpl.java (1)

15-26: Verify createdAt/updatedAt handling on upsert

Calling metric.prePersist() before an upsert-style merge may overwrite createdAt on updates. Ensure your SQL only sets created_at on insert (e.g., DB default or ON CONFLICT ... DO UPDATE SET updated_at = ... without touching created_at).

Provide the underlying ProductMetricDailyJpaRepository.merge query to confirm.

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

31-49: LGTM: builder mapping including rank is coherent

Handling nullable brand and passing through rank is clean.

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

12-14: LGTM: method renames improve clarity

Verified — implementors and callers updated: apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java, apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java, and tests (apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java) reference findDetail/findOptions; no occurrences of the old names remain.

apps/commerce-streamer/src/main/java/com/loopers/application/metric/MetricInput.java (1)

20-30: Confirm aggregator semantics with zero-increments

Search returned no results for null-checks; cannot confirm. Verify downstream aggregation treats zeros as “no change” (not explicit resets) — add explicit handling or unit/integration tests to prove intent.

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

8-9: Nit: imports are fine.

Nothing to flag here.

Also applies to: 21-21


30-30: Dependency wiring looks good.

Constructor injection for RankingService is consistent with the rest of the class.


58-58: Verified — rank is nullable and propagated to the API.
ProductOutput.GetProductDetail.from(...) accepts @nullable Long rank and sets it on the output; ProductResponse.GetProductDetail.from(...) maps output.getRank() to the response's @nullable Long rank, so null ranks will propagate to the API.

apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheRepository.java (2)

18-21: Naming consistency looks good.

saveDetail signature is clear and aligns with findDetail.


16-16: Method rename OK — cannot verify call sites in sandbox; verify locally.

Ensure no remaining usages of findProductDetailById or saveProductDetail and that all callers use findDetail(Long).
Run locally: rg -n --hidden -S "findProductDetailById|saveProductDetail|findDetail"

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

78-80: LGTM on repository rename alignment.

findOptions rename looks consistent; flow unchanged.

apps/commerce-streamer/src/main/java/com/loopers/application/metric/MetricFacade.java (1)

22-36: No change — counts already normalized and empty-list handled

Metric.builder (apps/commerce-streamer/src/main/java/com/loopers/domain/metric/Metric.java) uses Objects.requireNonNullElse to default likeCount/saleQuantity/viewCount to 0L, and MetricService.aggregate already returns early when command.items() is empty, so adding requireNonNullElse in MetricFacade and an additional empty-check is unnecessary.

Likely an incorrect or invalid review comment.

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

8-19: DTO shape and immutability look good

Clear separation of API DTO and domain, Lombok builder + private ctor enforce immutability.

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

28-34: SearchProducts mapping reads well

Record accessors and builder usage look consistent with the refactor to records.

apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java (3)

78-90: JSON (de)serialization via WrappedJsonMapper looks fine.

Serialization/deserialization points are consistent; logging guards are in place.


96-112: Negative caching returns sentinel object; verify all callers treat EMPTY as “not found.”

Returning Optional.of(ProductDetail.EMPTY) changes semantics vs Optional.empty(). Ensure service/controller layers don’t render EMPTY as a real product.

I can scan usages and open a follow-up fix if any callers assume isPresent() implies existence.


49-55: Align cache TTLs or stop touching TTL on reads

getAndExpire(key, Duration.ofSeconds(1)) will shorten an existing 5s TTL to 1s on a cache hit — either extend the read TTL to match the set TTL or use get() to avoid changing TTL.

File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java (lines 49-55)

-            json = stringRedisTemplate.opsForValue().getAndExpire(key, Duration.ofSeconds(1));
+            json = stringRedisTemplate.opsForValue().getAndExpire(key, Duration.ofSeconds(5));
+            // alternatively: json = stringRedisTemplate.opsForValue().get(key);

Confirm the project brings Spring Data Redis >= 2.6 (ValueOperations#getAndExpire introduced in 2.6); if older, use get() + expire(key, Duration.ofSeconds(5)).

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

72-82: Record mapping for list page looks correct.

Selected fields align with constructor order, including brandName.

apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/metric/MetricKafkaConsumer.java (6)

12-14: Typed header/payload imports — LGTM.

Using KafkaHeaders/@Header and @payload aligns with Spring Kafka’s batch deserialization and simplifies handlers.


31-33: Listener signature (Like) — LGTM.

Receiving topic via header and a typed batch payload is the right direction.


60-71: Dislike semantics — verify negative-like approach.

Using ofLikeCount(..., -1L) is fine if the aggregator intentionally models dislikes as negative likes. Confirm this matches scoring rules and dashboards.


81-83: Listener signature (View) — LGTM.


106-108: Listener signature (Sale) — LGTM.


46-49: Ack-after-process — ensure idempotency/backoff.

If aggregateProduct throws, the batch will be redelivered. Verify the facade is idempotent (e.g., dedup by eventId) and that the container has a retry/backoff and DLQ strategy.

I can generate a quick grep script to locate the idempotency/dedup logic and your Batch listener config if helpful.

Also applies to: 71-74, 96-99, 121-124

Comment on lines +45 to +47
RankingCommand.FindRank rankCommand = new RankingCommand.FindRank(LocalDate.now(), productId);
Long rank = rankingService.findRank(rankCommand).orElse(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid system-default time; inject Clock to fix date-boundary bugs.

Using LocalDate.now() ties behavior to the host JVM timezone and can mis-rank around midnight or on misconfigured servers. Inject a Clock and use LocalDate.now(clock).

Apply:

@@
-import java.time.LocalDate;
+import java.time.Clock;
+import java.time.LocalDate;
@@
-    private final RankingService rankingService;
+    private final RankingService rankingService;
+    private final Clock clock;
@@
-        RankingCommand.FindRank rankCommand = new RankingCommand.FindRank(LocalDate.now(), productId);
+        RankingCommand.FindRank rankCommand = new RankingCommand.FindRank(LocalDate.now(clock), productId);

If the API later accepts a date parameter, thread it into this facade and prefer the provided date over now(clock).

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

Comment on lines +31 to +38
List<ProductResult.GetProductDetail> details = ranks.items()
.stream()
.map(rank -> productService.getProductDetail(rank.productId())
.orElseThrow(() -> new BusinessException(CommonErrorType.NOT_FOUND))
)
.toList();

return RankingOutput.SearchRankings.from(ranks, details);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Eliminate N+1 detail fetches (Top 1000 = 1000 calls).

Batch-fetch product details to reduce latency and load. Also decide behavior on missing details (skip vs. fail-all).

-        List<ProductResult.GetProductDetail> details = ranks.items()
-                .stream()
-                .map(rank -> productService.getProductDetail(rank.productId())
-                        .orElseThrow(() -> new BusinessException(CommonErrorType.NOT_FOUND))
-                )
-                .toList();
+        List<Long> productIds = ranks.items().stream().map(RankingResult.SearchRanks.Item::productId).toList();
+        // Prefer batch API to avoid N calls
+        List<ProductResult.GetProductDetail> details = productService.getProductDetails(productIds);
+        // Option A (strict): fail if any missing
+        if (details.size() != productIds.size()) {
+            throw new BusinessException(CommonErrorType.NOT_FOUND);
+        }
+        // Option B (lenient): filter missing and still return (requires output mapping to handle gaps)

Outside-this-file support (add to ProductService):

public List<ProductResult.GetProductDetail> getProductDetails(List<Long> productIds) { /* batch cache + repo */ }

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- rg: getProductDetail occurrences ----"
rg -n --hidden --no-ignore -S "getProductDetail\(" -C3 || true

echo
echo "---- rg: getProductDetails occurrences ----"
rg -n --hidden --no-ignore -S "getProductDetails\(" -C3 || true

echo
echo "---- rg: ProductService definition (interface/class) ----"
rg -n --hidden --no-ignore -S "interface\s+ProductService\b|class\s+ProductService\b" -C3 || true

echo
echo "---- rg: productService variable usages ----"
rg -n --hidden --no-ignore -S "\bproductService\b" -C3 || true

echo
echo "---- Show RankingFacade.java (first 300 lines) ----"
if [ -f "apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java" ]; then
  sed -n '1,300p' apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java || true
else
  echo "File not found: apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java"
fi

echo
echo "---- Show ProductService files (if found) ----"
rg -n --hidden --no-ignore -S "ProductService" -g '!**/target/**' -C2 || true

Length of output: 43208


Eliminate N+1 product-detail fetch — use a batch API

ProductService currently only exposes getProductDetail(Optional); add a batch getProductDetails(List) and update RankingFacade to use it. Decide strict (fail-all) vs lenient (skip-missing) behavior.

Location: apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (stream mapping)

-        List<ProductResult.GetProductDetail> details = ranks.items()
-                .stream()
-                .map(rank -> productService.getProductDetail(rank.productId())
-                        .orElseThrow(() -> new BusinessException(CommonErrorType.NOT_FOUND))
-                )
-                .toList();
+        List<Long> productIds = ranks.items().stream().map(RankingResult.SearchRanks.Item::productId).toList();
+        // Prefer batch API to avoid N calls
+        List<ProductResult.GetProductDetail> details = productService.getProductDetails(productIds);
+        // Option A (strict): fail if any missing
+        if (details.size() != productIds.size()) {
+            throw new BusinessException(CommonErrorType.NOT_FOUND);
+        }
+        // Option B (lenient): filter missing and still return (requires output mapping to handle gaps)

Outside-this-file support (add to ProductService):

public List<ProductResult.GetProductDetail> getProductDetails(List<Long> productIds) { /* batch cache + repo */ }
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
around lines 31–38, the current stream does an N+1 call to
productService.getProductDetail; replace it with a single batch call to a new
ProductService.getProductDetails(List<Long>) and then map results back to the
original ranking order. Implementation steps: collect productIds from ranks into
a List in the same order, call productService.getProductDetails(productIds),
build a Map<Long, ProductResult.GetProductDetail> from the returned list, then
produce details by iterating the original productIds and looking up each id in
the map; choose strict behavior: if any id is missing from the batch result,
throw new BusinessException(CommonErrorType.NOT_FOUND). Also add the
getProductDetails(List<Long>) signature to ProductService and implement batch
caching/repo lookup as needed.

Comment on lines +21 to +38
public static SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
return new SearchRankings(
ranks.totalPages(),
ranks.totalItems(),
ranks.page(),
ranks.size(),
details.stream()
.map(detail -> new Item(
detail.productId(),
detail.productName(),
detail.basePrice(),
detail.likeCount(),
detail.brandId(),
detail.brandName()
))
.toList()
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

❓ Verification inconclusive

Preserve ranking order when joining details (essential)

from(...) ignores the rank order contained in ranks and just streams details, which risks returning a misordered “ranking” if details isn’t already sorted. Build a map by productId and emit items in ranks order.

Apply:

@@
-        public static SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
-            return new SearchRankings(
-                    ranks.totalPages(),
-                    ranks.totalItems(),
-                    ranks.page(),
-                    ranks.size(),
-                    details.stream()
-                            .map(detail -> new Item(
-                                    detail.productId(),
-                                    detail.productName(),
-                                    detail.basePrice(),
-                                    detail.likeCount(),
-                                    detail.brandId(),
-                                    detail.brandName()
-                            ))
-                            .toList()
-            );
-        }
+        public static SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
+            var safeDetails = details == null ? List.<ProductResult.GetProductDetail>of() : details;
+            var byId = safeDetails.stream()
+                    .collect(java.util.stream.Collectors.toMap(
+                            ProductResult.GetProductDetail::productId,
+                            java.util.function.Function.identity(),
+                            (a, b) -> a,
+                            java.util.LinkedHashMap::new));
+
+            var items = ranks.productIds().stream() // assume this exposes ranked product IDs
+                    .map(byId::get)
+                    .filter(java.util.Objects::nonNull)
+                    .map(detail -> new Item(
+                            detail.productId(),
+                            detail.productName(),
+                            detail.basePrice(),
+                            detail.likeCount(),
+                            detail.brandId(),
+                            detail.brandName()
+                    ))
+                    .toList();
+
+            return new SearchRankings(
+                    ranks.totalPages(),
+                    ranks.totalItems(),
+                    ranks.page(),
+                    ranks.size(),
+                    items
+            );
+        }

Additional imports needed:

+import java.util.LinkedHashMap;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;

If SearchRanks uses a different accessor than productIds(), swap accordingly.


Preserve ranking order when joining details (essential)

from(...) currently streams details directly and ignores the rank order in ranks — map details by productId and emit items in ranks order.

File: apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingOutput.java
Lines: 21-38

Apply:

@@
-        public static SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
-            return new SearchRankings(
-                    ranks.totalPages(),
-                    ranks.totalItems(),
-                    ranks.page(),
-                    ranks.size(),
-                    details.stream()
-                            .map(detail -> new Item(
-                                    detail.productId(),
-                                    detail.productName(),
-                                    detail.basePrice(),
-                                    detail.likeCount(),
-                                    detail.brandId(),
-                                    detail.brandName()
-                            ))
-                            .toList()
-            );
-        }
+        public static SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
+            var safeDetails = details == null ? List.<ProductResult.GetProductDetail>of() : details;
+            var byId = safeDetails.stream()
+                    .collect(java.util.stream.Collectors.toMap(
+                            ProductResult.GetProductDetail::productId,
+                            java.util.function.Function.identity(),
+                            (a, b) -> a,
+                            java.util.LinkedHashMap::new));
+
+            var items = ranks.productIds().stream() // assume this exposes ranked product IDs
+                    .map(byId::get)
+                    .filter(java.util.Objects::nonNull)
+                    .map(detail -> new Item(
+                            detail.productId(),
+                            detail.productName(),
+                            detail.basePrice(),
+                            detail.likeCount(),
+                            detail.brandId(),
+                            detail.brandName()
+                    ))
+                    .toList();
+
+            return new SearchRankings(
+                    ranks.totalPages(),
+                    ranks.totalItems(),
+                    ranks.page(),
+                    ranks.size(),
+                    items
+            );
+        }

Additional imports needed:

+import java.util.LinkedHashMap;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;

If SearchRanks uses a different accessor than productIds(), swap accordingly.

📝 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 SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
return new SearchRankings(
ranks.totalPages(),
ranks.totalItems(),
ranks.page(),
ranks.size(),
details.stream()
.map(detail -> new Item(
detail.productId(),
detail.productName(),
detail.basePrice(),
detail.likeCount(),
detail.brandId(),
detail.brandName()
))
.toList()
);
}
public static SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
var safeDetails = details == null ? List.<ProductResult.GetProductDetail>of() : details;
var byId = safeDetails.stream()
.collect(java.util.stream.Collectors.toMap(
ProductResult.GetProductDetail::productId,
java.util.function.Function.identity(),
(a, b) -> a,
java.util.LinkedHashMap::new));
var items = ranks.productIds().stream() // assume this exposes ranked product IDs
.map(byId::get)
.filter(java.util.Objects::nonNull)
.map(detail -> new Item(
detail.productId(),
detail.productName(),
detail.basePrice(),
detail.likeCount(),
detail.brandId(),
detail.brandName()
))
.toList();
return new SearchRankings(
ranks.totalPages(),
ranks.totalItems(),
ranks.page(),
ranks.size(),
items
);
}
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingOutput.java
around lines 21 to 38, the from(...) method ignores the ordering in ranks and
streams details directly; instead build a Map from productId to
ProductResult.GetProductDetail (e.g., Map<String,
ProductResult.GetProductDetail> detailsById =
details.stream().collect(Collectors.toMap(...))) and then iterate the ranks'
product id sequence (e.g., ranks.productIds() or the correct accessor) to
produce the Item list in that exact order, mapping each id to its detail and
creating Item instances (handle missing details gracefully or skip/throw as
appropriate); add imports for java.util.Map and java.util.stream.Collectors (or
java.util.stream.*) as needed.


Optional<Long> findRank(LocalDate date, Long productId);

Page<RankingQueryResult.SearchRanks> searchRanks(LocalDate date, Pageable pageable);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Constrain pageable to Top-1000 and clarify sorting.

For a “Top 1000” feed, cap page size at 1000 and define that results are ordered by rank asc; ignore external Sort to prevent inconsistent ordering.

Run:


🏁 Script executed:

#!/bin/bash
# Check impl enforces top-1000 cap and rank ordering
fd RankingRepositoryImpl.java -t f -x cat {} | rg -n '1000|TOP.?1000|limit|range'
rg -nP 'Z(Set|set)Operations|reverseRange|rangeWithScores' apps/commerce-api/src/main/java

Length of output: 672


Enforce Top‑1000 cap and canonical rank ordering (ignore Pageable.sort)

  • Server‑side cap: compute pageSize = min(pageable.getPageSize(), 1000) and derive start/end from that when calling Redis ZSET (apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java — reverseRange call around line 48).
  • Ignore incoming Pageable.getSort; always order the Top‑1000 feed by rank (define whether that is ascending or descending and document it on the interface: apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java — searchRanks signature, line 13).
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java
around line 13 and
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java
around the reverseRange call (~line 48): the API must enforce a server-side
Top-1000 cap and a canonical sort order (ignore Pageable.getSort). Update the
interface Javadoc at line 13 to state that searchRanks returns the Top-1000 feed
ordered by rank ascending (rank 1 = top), and that incoming Pageable.sort is
ignored; then in the impl compute int cappedPageSize =
Math.min(pageable.getPageSize(), 1000), derive start = pageable.getPageNumber()
* cappedPageSize and end = start + cappedPageSize - 1 when calling the Redis
ZSET reverseRange (or the correct range method) so you always limit results to
the Top‑1000 window and use the canonical ordering instead of pageable.getSort.

page.getTotalElements(),
pageable.getPageNumber(),
pageable.getPageSize(),
page.map(result -> new Item(result.productId(), result.rank())).toList()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Page.map(...).toList() will not compile on Spring Data Page

Page.map(...) returns a Page<U>, which doesn't have toList(). Use page.getContent().stream().map(...).toList() or page.map(...).getContent().

-                    page.map(result -> new Item(result.productId(), result.rank())).toList()
+                    page.getContent().stream()
+                            .map(result -> new Item(result.productId(), result.rank()))
+                            .toList()
📝 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
page.map(result -> new Item(result.productId(), result.rank())).toList()
page.getContent().stream()
.map(result -> new Item(result.productId(), result.rank()))
.toList()
🤖 Prompt for AI Agents
In apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingResult.java
around line 24, the code uses page.map(...).toList() which fails because Spring
Data's Page.map(...) returns a Page<U> that does not have toList(); replace it
by either page.getContent().stream().map(result -> new Item(result.productId(),
result.rank())).toList() or use page.map(result -> new Item(result.productId(),
result.rank())).getContent() so you obtain a List<Item>.

Comment on lines 3 to 14
import com.loopers.application.audit.AuditFacade;
import com.loopers.application.audit.AuditInput;
import com.loopers.config.kafka.KafkaConfig;
import com.loopers.domain.KafkaMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Add missing import for DomainEvent to fix compilation.

DomainEvent is referenced but not imported.

Apply:

 import com.loopers.config.kafka.KafkaConfig;
 import com.loopers.domain.KafkaMessage;
+import com.loopers.domain.DomainEvent;
 import lombok.RequiredArgsConstructor;
📝 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
import com.loopers.application.audit.AuditFacade;
import com.loopers.application.audit.AuditInput;
import com.loopers.config.kafka.KafkaConfig;
import com.loopers.domain.KafkaMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component;
import com.loopers.application.audit.AuditFacade;
import com.loopers.application.audit.AuditInput;
import com.loopers.config.kafka.KafkaConfig;
import com.loopers.domain.KafkaMessage;
import com.loopers.domain.DomainEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component;
🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/audit/AuditKafkaConsumer.java
around lines 3 to 14, the class references DomainEvent but the import is
missing; add the import statement for DomainEvent (e.g., import
com.loopers.domain.DomainEvent;) at the top with the other imports so the file
compiles, and then re-run a build to verify no other missing imports remain.

Comment on lines +33 to +42
public static String invert9sComplement(String s) {
char[] chars = s.toCharArray();

for (int i = 0; i < chars.length; i++) {
char c = chars[i];
chars[i] = (char) ('9' - (c - '0'));
}

return new String(chars);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Validate input is all digits (9's complement otherwise corrupts data)

If any non-digit appears (e.g., a leading '-' or accidental alpha), this produces control/non-digit chars. Given it’s used in Redis members, fail fast.

     public static String invert9sComplement(String s) {
         char[] chars = s.toCharArray();

         for (int i = 0; i < chars.length; i++) {
             char c = chars[i];
-            chars[i] = (char) ('9' - (c - '0'));
+            if (c < '0' || c > '9') {
+                throw new IllegalArgumentException("Non-digit character at index " + i + ": '" + c + "'");
+            }
+            chars[i] = (char) ('9' - (c - '0'));
         }

         return new String(chars);
     }
🤖 Prompt for AI Agents
In modules/redis/src/main/java/com/loopers/support/StringUtils.java around lines
33 to 42, the invert9sComplement method currently assumes every char is a digit
which produces invalid/non-digit characters if input contains non-digits; add
input validation to fail fast: check for null/empty and verify each character is
between '0' and '9' before transforming, and throw an IllegalArgumentException
(with a clear message including the invalid input or offending character/index)
when a non-digit is found so corrupted data cannot be returned to Redis.

Comment on lines +34 to +38
try {
return jsonMapper.treeToValue(node, typeRef);
} catch (Exception e) {
return null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Bug: treeToValue does not accept TypeReference

This won’t compile; use convertValue for TypeReference. Also don’t swallow exceptions—propagate as RuntimeException for consistency with other methods.

-        try {
-            return jsonMapper.treeToValue(node, typeRef);
-        } catch (Exception e) {
-            return null;
-        }
+        try {
+            return jsonMapper.convertValue(node, typeRef);
+        } catch (IllegalArgumentException e) {
+            throw new RuntimeException(e);
+        }
📝 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
try {
return jsonMapper.treeToValue(node, typeRef);
} catch (Exception e) {
return null;
}
try {
return jsonMapper.convertValue(node, typeRef);
} catch (IllegalArgumentException e) {
throw new RuntimeException(e);
}
🤖 Prompt for AI Agents
In
supports/jackson/src/main/java/com/loopers/config/jackson/WrappedJsonMapper.java
around lines 34 to 38, the use of jsonMapper.treeToValue(node, typeRef) is
incorrect because treeToValue does not accept a TypeReference and the catch
block swallows exceptions; change the call to jsonMapper.convertValue(node,
typeRef) and replace the empty catch-return-null with rethrowing the caught
exception wrapped in a RuntimeException (or an appropriate unchecked exception)
so errors propagate consistently with other methods.

@ImSejin ImSejin marked this pull request as ready for review September 12, 2025 06:20

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (2)
supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java (1)

26-33: Remove JsonGenerator.Feature.IGNORE_UNKNOWN — it doesn't ignore unknown input; disable DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES instead.

JsonGenerator.Feature.IGNORE_UNKNOWN exists but is a generator/writing feature and has no effect on JSON deserialization. If the intent is to ignore unknown properties when reading, remove IGNORE_UNKNOWN from supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java (lines 26–33) and either call:

  • mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
    or annotate target types with @JsonIgnoreProperties(ignoreUnknown = true).
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java (1)

49-55: Don’t shrink TTL on read

getAndExpire(key, Duration.ofSeconds(1)) shortens the TTL below the write TTL (5s), causing premature evictions. Use get(key) or keep the same TTL if you intend a sliding window.

-            json = stringRedisTemplate.opsForValue().getAndExpire(key, Duration.ofSeconds(1));
+            json = stringRedisTemplate.opsForValue().get(key);
♻️ Duplicate comments (1)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/metric/MetricKafkaConsumer.java (1)

63-68: Dislike mapping: use a dedicated “dislike” metric or keep net-like intentionally—pick one and be consistent.

Right now Dislikes are sent to ofLikeCount(..., -1L). If the domain needs a separate dislike counter (e.g., for dashboards or weighting), prefer a dedicated builder (e.g., ofDislikeCount(..., 1L)). If you’ve intentionally modeled “net-like score,” keep the current approach but document it to avoid confusion downstream.

Apply if you keep separate metrics:

-                .map(message -> MetricInput.AggregateProduct.Item.ofLikeCount(
+                .map(message -> MetricInput.AggregateProduct.Item.ofDislikeCount(
                         message.eventId(),
-                        message.publishedAt().toLocalDate(),
+                        message.publishedAt().toLocalDate(),
                         message.payload().productId(),
-                        -1L
+                        1L
                 ))
🧹 Nitpick comments (10)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/metric/MetricKafkaConsumer.java (4)

40-40: Date boundary: normalize to an explicit zone to prevent cross-region off-by-one days.

toLocalDate() depends on the embedded offset. If producers publish with mixed offsets, daily buckets can drift around midnight. Consider normalizing to UTC (or a configured service zone) at ingestion.

+import java.time.ZoneOffset;
...
-                        message.publishedAt().toLocalDate(),
+                        message.publishedAt().toInstant().atZone(ZoneOffset.UTC).toLocalDate(),

Also applies to: 65-65, 90-90, 115-115


117-117: Guard sale quantity conversion.

quantity().longValue() will silently truncate for non-integer numerics and NPE if null. If the type isn’t guaranteed integral and non-null, validate or use an exact conversion to fail fast.

Possible adjustment:

-                        message.payload().quantity().longValue()
+                        Math.max(0L, message.payload().quantity().longValue()) // or use longValueExact() if BigDecimal

If negative quantities represent returns, keep them but document the behavior in ranking.


37-46: Reduce duplication with a small mapper helper.

The four streams differ only by the builder and quantity. Extract a private mapper to centralize the common fields and pass a lambda for the builder; this simplifies future changes (e.g., time normalization).

Example (outside this hunk):

private static <T> List<MetricInput.AggregateProduct.Item> mapItems(
        List<KafkaMessage<T>> messages,
        BiFunction<KafkaMessage<T>, Long, MetricInput.AggregateProduct.Item> builder,
        long qty
) {
    return messages.stream()
            .map(m -> builder.apply(m, qty))
            .toList();
}

Usage:

List<MetricInput.AggregateProduct.Item> items =
    mapItems(messages, (m, q) -> MetricInput.AggregateProduct.Item.ofViewCount(
        m.eventId(),
        m.publishedAt().toInstant().atZone(ZoneOffset.UTC).toLocalDate(),
        m.payload().productId(),
        q
    ), 1L);

Also applies to: 62-71, 87-96, 112-121


31-49: Error handling/observability: consider DLQ or recoverer for batch failures.

A single bad record will NACK the whole batch. If not already configured in KafkaConfig.BATCH_LISTENER, consider a DefaultErrorHandler with a DLQ or recoverer and add minimal per-batch failure logs including first/last eventId to aid triage.

Also applies to: 56-74, 81-99, 106-124

supports/jackson/src/main/java/com/loopers/config/jackson/WrappedObjectMapper.java (2)

20-22: Avoid unconfigured ObjectMapper via no-arg ctor

The no-arg constructor creates a plain ObjectMapper (loses Boot config). Remove it or make it package-private and annotate as “for tests” to prevent accidental use.

-    public WrappedObjectMapper() {
-        this.mapper = new ObjectMapper();
-    }
+    // Prefer Spring-injected, preconfigured ObjectMapper

17-18: Caution on Lombok @DeleGate surface area

@Delegate exposes the entire ObjectMapper API (including methods you may not want). Consider delegating only the handful you need to keep the wrapper narrow and stable.

apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java (4)

73-76: Nit: comment vs behavior (off-by-one)

“2 pages only” but > 2 allows pages 0,1,2 (3 pages). Align comment or condition.

-        // 2 페이지까지만 캐싱한다.
-        if (pageRequest.getPageNumber() > 2) {
+        // 첫 2개(0,1) 페이지만 캐싱한다.
+        if (pageRequest.getPageNumber() > 1) {

121-123: Use HSET for sentinel instead of HSETALL

Small optimization/readability: set a single field instead of putAll.

-            stringRedisTemplate.opsForHash().putAll(key, Map.of("__null__", "null"));
+            stringRedisTemplate.opsForHash().put(key, "__null__", "null");

126-129: Group write + expire to reduce race window

Pipeline the hash write and expire to avoid transient keys without TTL under failures.

-        stringRedisTemplate.opsForHash().putAll(key, cache);
-        stringRedisTemplate.expire(key, ttl);
+        stringRedisTemplate.executePipelined(operations -> {
+            operations.opsForHash().putAll(key, cache);
+            operations.expire(key, ttl);
+            return null;
+        });

Additional import needed:

import org.springframework.data.redis.core.RedisOperations;

41-48: Prefer collision-resistant, readable cache keys

Objects.hash(...) can collide and is opaque. Build a structured key.

-        String key = "product.page:" + Objects.hash(
-                command.getKeyword(),
-                command.getBrandId(),
-                command.getSort(),
-                command.getPage(),
-                command.getSize()
-        );
+        String key = String.format("product.page:kw=%s|brand=%s|sort=%s|page=%d|size=%d",
+                command.getKeyword(), command.getBrandId(), command.getSort(), command.getPage(), command.getSize());
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c050b75 and e069460.

📒 Files selected for processing (4)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java (6 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/metric/MetricKafkaConsumer.java (4 hunks)
  • supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java (2 hunks)
  • supports/jackson/src/main/java/com/loopers/config/jackson/WrappedObjectMapper.java (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java (1)
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java (2)
  • detail (67-67)
  • Service (23-160)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/metric/MetricKafkaConsumer.java (3)
apps/commerce-api/src/main/java/com/loopers/infrastructure/activity/event/ActivityEventPublisherImpl.java (1)
  • Component (11-39)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/product/ProductKafkaConsumer.java (1)
  • Slf4j (18-71)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/audit/AuditKafkaConsumer.java (1)
  • Slf4j (19-53)
🔇 Additional comments (4)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/metric/MetricKafkaConsumer.java (3)

12-14: Good move to header/payload-based batch listeners; confirm ActivityEvent imports resolve correctly.

Typed payloads and topic header simplify deserialization and logging. Please double-check that the ActivityEvent type is imported (or package-local) and resolves to the intended class; it’s not visible among imports here.


31-46: LGTM: Like handler uses typed batch, consistent mapping, and manual ack.
The mapping to ofLikeCount(..., 1L) and batching is clear and efficient.


81-96: LGTM: View handler is consistent and clear.
Typed payloads, 1L increments, and batch aggregation look good.

supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java (1)

50-53: Good: wrapper bean now reuses Boot-configured ObjectMapper

This preserves all modules/features from jacksonCustomizer. Nice.

Comment on lines +60 to 64
PageImpl<ProductQueryResult.Products> page = wrappedObjectMapper.readValue(json, new TypeReference<>() {
});

return page == null ? Page.empty(pageRequest) : page;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Bug: TypeReference diamond on anonymous class + missing generic type

new TypeReference<>() {} is not allowed for anonymous classes, and the mapper won’t know the generic. Specify the full type.

-        PageImpl<ProductQueryResult.Products> page = wrappedObjectMapper.readValue(json, new TypeReference<>() {
-        });
+        PageImpl<ProductQueryResult.Products> page =
+                wrappedObjectMapper.readValue(json, new TypeReference<PageImpl<ProductQueryResult.Products>>() {});
📝 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
PageImpl<ProductQueryResult.Products> page = wrappedObjectMapper.readValue(json, new TypeReference<>() {
});
return page == null ? Page.empty(pageRequest) : page;
}
PageImpl<ProductQueryResult.Products> page =
wrappedObjectMapper.readValue(json, new TypeReference<PageImpl<ProductQueryResult.Products>>() {});
return page == null ? Page.empty(pageRequest) : page;
}
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java
around lines 60-64, the anonymous TypeReference uses the diamond operator which
erases the generic information; replace the diamond with an explicit generic
type so Jackson knows the target type (e.g. use a
TypeReference<PageImpl<ProductQueryResult.Products>> anonymous class or
construct the parametric JavaType via
objectMapper.getTypeFactory().constructParametricType(PageImpl.class,
ProductQueryResult.Products.class) and pass that to readValue), and add any
necessary imports.

Comment on lines +109 to 112
ProductQueryResult.ProductDetail detail = wrappedObjectMapper.readMap(cache, new TypeReference<>() {
});
return Optional.of(productDetail);
return Optional.ofNullable(detail);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Bug: Same TypeReference issue for ProductDetail

Specify the target type explicitly.

-        ProductQueryResult.ProductDetail detail = wrappedObjectMapper.readMap(cache, new TypeReference<>() {
-        });
+        ProductQueryResult.ProductDetail detail =
+                wrappedObjectMapper.readMap(cache, new TypeReference<ProductQueryResult.ProductDetail>() {});
📝 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
ProductQueryResult.ProductDetail detail = wrappedObjectMapper.readMap(cache, new TypeReference<>() {
});
return Optional.of(productDetail);
return Optional.ofNullable(detail);
}
ProductQueryResult.ProductDetail detail =
wrappedObjectMapper.readMap(cache, new TypeReference<ProductQueryResult.ProductDetail>() {});
return Optional.ofNullable(detail);
}
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java
around lines 109 to 112, the TypeReference used in wrappedObjectMapper.readMap
is untyped; change it to an explicit generic TypeReference for
ProductQueryResult.ProductDetail (e.g. new
TypeReference<ProductQueryResult.ProductDetail>() {}) so the mapper knows the
concrete target type and returns the correct ProductDetail instance.

Comment on lines +24 to +39
public <T> T readMap(Map<String, String> jsonMap, TypeReference<T> typeRef) {
ObjectNode node = mapper.createObjectNode();
jsonMap.forEach((k, json) -> {
try {
node.set(k, mapper.readTree(json));
} catch (Exception e) {
throw new RuntimeException(e);
}
});

try {
return mapper.treeToValue(node, typeRef);
} catch (Exception e) {
return null;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Bug: treeToValue(TypeReference) doesn’t exist; use convertValue

mapper.treeToValue(node, typeRef) won’t compile. Replace with convertValue.

Apply:

-        try {
-            return mapper.treeToValue(node, typeRef);
-        } catch (Exception e) {
-            return null;
-        }
+        try {
+            return mapper.convertValue(node, typeRef);
+        } catch (IllegalArgumentException e) {
+            return null;
+        }
📝 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 <T> T readMap(Map<String, String> jsonMap, TypeReference<T> typeRef) {
ObjectNode node = mapper.createObjectNode();
jsonMap.forEach((k, json) -> {
try {
node.set(k, mapper.readTree(json));
} catch (Exception e) {
throw new RuntimeException(e);
}
});
try {
return mapper.treeToValue(node, typeRef);
} catch (Exception e) {
return null;
}
}
public <T> T readMap(Map<String, String> jsonMap, TypeReference<T> typeRef) {
ObjectNode node = mapper.createObjectNode();
jsonMap.forEach((k, json) -> {
try {
node.set(k, mapper.readTree(json));
} catch (Exception e) {
throw new RuntimeException(e);
}
});
try {
return mapper.convertValue(node, typeRef);
} catch (IllegalArgumentException e) {
return null;
}
}
🤖 Prompt for AI Agents
In
supports/jackson/src/main/java/com/loopers/config/jackson/WrappedObjectMapper.java
around lines 24-39, the call mapper.treeToValue(node, typeRef) does not compile
because treeToValue doesn't accept a TypeReference; replace that call with
mapper.convertValue(node, typeRef) instead (keeping the surrounding try/catch
behavior or adjusting error handling as desired) so the method uses
ObjectMapper.convertValue with the provided TypeReference.

@ImSejin

ImSejin commented Sep 15, 2025

Copy link
Copy Markdown
Member Author

🧾 종합 피드백 (9월 14일 2025)

RR을 의도하여 Kafka 파티션키를 일부러 명시하지 않는 경우가 있나요?

파티션키를 명시하지 않고 이벤트를 발행하면 Round-Robin으로 파티션에 할당한다고 배웠습니다.
예를 들어 상품 조회수 집계는 RDB에 접근하지 않으니 경합 문제에서 자유롭습니다.
따라서 파티션키를 통한 순서 보장이 불필요하다고 생각합니다.
오히려 파티션키를 주면 인기 있는 상품을 조회할 때마다 특정 컨슈머에게만 부하가 가해질 거 같습니다.

이를 균등한 부하를 위해, 파티션키를 명시하지 않았던 적이 있으셨는지 궁금합니다.

압축효율이 떨어져서 파티션키를 분기하지 않는 방식은 특수한 상황이 아니면 잘 사용하지 않는것 같아요. 처리량을 극도로 늘린다거나,, iot 센서나,, 유저 이벤트라든가 이런 경우는 사용 할 수 있을것 같아요! 현재도 이벤트를 최초로 담는 부분에는 파티션키가 없습니다! 하더라도 userId정도는 할수있겠네요.

Carry over하려면 스케쥴러 말고 다른 방법은 없을까?

일별 집계라면 23:30쯤에 다음 날 랭킹을 미리 집계하는 스케쥴러로 해결할 수 있을 거 같습니다.
(Sliding Window 방식을 사용하지 않고) 혹시 스케쥴러 말고 다른 방법으로 carry over 하는 방법이 있을까 고민했습니다.
commerce-streamer가 집계하면서 양자화된 시간 단위가 달라졌을 때만(다음 날),
분산락을 걸어 carry-over 할 수 있겠다고 생각했어요.

이러면 날짜가 변경되어 최초 집계할 때마다 commerce-api가 lock이 해제될 때까지 기다려야 할 거 같아요.
이 방법에 대해서 어떻게 생각하시나요? 혹 더 괜찮은 방법(without sliding window)이 있는지 궁금합니다!

슬라이딩 윈도우를 직접적인 구현을 하지 않는다면 이전날에 가중합산을 적용해서 20%정도를 이전 랭킹의 스코어를 사용하는것도 방법이겠네요.

실시간 랭킹 API가 조회일자를 받아야 할까?

과제의 API 스펙에 date 파라미터가 있습니다.
"실시간"이라면, 이 값 자체가 LocalDate.now()로 고정되어야 하지 않나라는 생각이 듭니다.

실시간 랭킹 API라면 받을필요가 없어보입니다! 말씀하신대로 now로 하면될것같네요.

Implementation Quest: PASS

Writing Quest: PASS, RT

• 명확한 의도를 가지고 파티션키를 지정하지 않는 부분이 좋았습니다. (경합 고려)
• 3시간으로 자르신다면 3시간 단위의 텀블링 윈도우를 만드신거고, 조회시에 슬라이딩 윈도우 기반으로 집계한게 되겠네요 ㅎㅎ

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
apps/commerce-api/src/test/java/com/loopers/test/assertion/ConcurrentAssertion.java (3)

67-78: Preserve interrupt status when awaiting the start gate.

Catching all Throwable swallows InterruptedException. Restore the interrupt flag to avoid subtle test flakiness.

-                executorService.submit(() -> {
-                    try {
-                        startLatch.await();
+                executorService.submit(() -> {
+                    try {
+                        startLatch.await();
 
                         V executed = adapter.execute(n);
                         this.successCount.incrementAndGet();
                         return executed;
-                    } catch (Throwable t) {
+                    } catch (InterruptedException ie) {
+                        Thread.currentThread().interrupt();
+                        this.errors.add(ie);
+                        return null;
+                    } catch (Throwable t) {
                         this.errors.add(t);
                         return null;
                     } finally {
                         countDownLatch.countDown();
                     }
                 });

81-83: Add a timeout to avoid indefinite waits.

A stuck worker will block the test suite. Use a bounded await and fail fast with a clear message.

-            countDownLatch.await();
+            boolean completed = countDownLatch.await(30, java.util.concurrent.TimeUnit.SECONDS);
+            if (!completed) {
+                throw new AssertionError("ConcurrentAssertion timed out: " + this.threadCount + " tasks did not finish in 30s");
+            }

175-177: Avoid passing null to Consumer in adapter fallbacks.

RunnableCallableAdapter.run() and call() send null to consumer, which can NPE in user code. Either remove Runnable/Callable from this adapter (since you call execute(i) directly), or guard with a clear exception.

-            if (this.consumer != null) {
-                this.consumer.accept(null);
-            }
+            if (this.consumer != null) {
+                throw new UnsupportedOperationException("Consumer requires an index; use execute(i) instead.");
+            }

Apply the same guard in the call() branch.

Also applies to: 191-194

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 801df03 and 56aa885.

📒 Files selected for processing (1)
  • apps/commerce-api/src/test/java/com/loopers/test/assertion/ConcurrentAssertion.java (2 hunks)
🔇 Additional comments (1)
apps/commerce-api/src/test/java/com/loopers/test/assertion/ConcurrentAssertion.java (1)

95-99: Confirm JDK 21+ in build and CI (virtual threads & TWR)

  • Root Gradle toolchain sets Java 21 — build.gradle.kts: java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }.
  • Kotlin jvmToolchain(21) present in apps/pg-simulator/build.gradle.kts.
  • Virtual-thread usage located at apps/commerce-api/src/test/java/com/loopers/test/assertion/ConcurrentAssertion.java (Executors::newVirtualThreadPerTaskExecutor).
  • No CI workflow files found in .github/workflows; CI JDK version not verifiable — ensure CI uses JDK 21+.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
apps/commerce-api/src/test/java/com/loopers/test/assertion/ConcurrentAssertion.java (1)

59-61: Submission-failure can strand workers at the start gate; make the start barrier fail‑safe.

If any submit() throws mid-loop, fewer than threadCount tasks will reach the barrier. Those already submitted block up to 10s and then fail, but latch (initialized to threadCount) will never reach zero, causing up to a 10‑minute wait. Wrap the submission loop in try/finally, track how many were successfully submitted, and, on short‑submit, immediately break the barrier and drain the remaining latch permits.

Apply this diff (includes loop wrapping and fail-safe release):

-        CyclicBarrier barrier = new CyclicBarrier(this.threadCount);
-        CountDownLatch latch = new CountDownLatch(this.threadCount);
+        CyclicBarrier barrier = new CyclicBarrier(this.threadCount);
+        CountDownLatch latch = new CountDownLatch(this.threadCount);
+        int submitted = 0;
@@
-        try (ExecutorService executorService = this.executorServiceProvider.get()) {
-            for (int i = 0; i < this.threadCount; i++) {
+        try (ExecutorService executorService = this.executorServiceProvider.get()) {
+            try {
+                for (int i = 0; i < this.threadCount; i++) {
                 int n = i;
                 executorService.submit(() -> {
@@
                 });
-            }
+                    submitted++;
+                }
+            } finally {
+                // If fewer tasks were submitted, unblock any waiting workers and avoid latch hang.
+                if (submitted < this.threadCount) {
+                    barrier.reset(); // Causes waiting threads to get BrokenBarrierException immediately.
+                    for (int i = submitted; i < this.threadCount; i++) {
+                        latch.countDown();
+                    }
+                }
+            }
🧹 Nitpick comments (2)
apps/commerce-api/src/test/java/com/loopers/test/assertion/ConcurrentAssertion.java (2)

67-69: Restore interrupt status when barrier wait is interrupted; consider a clearer failure signal.

Catching Throwable swallows interrupts. Preserve the interrupt flag to avoid surprising downstream behavior, and optionally tag barrier failures distinctly.

-                    } catch (Throwable t) {
-                        this.errors.add(t);
+                    } catch (Throwable t) {
+                        if (t instanceof InterruptedException) {
+                            Thread.currentThread().interrupt();
+                        }
+                        this.errors.add(t);

82-85: Avoid masking timeouts; fail the assertion with context and prevent long hangs.

  • Throwing TimeoutException and then wrapping it in RuntimeException hides the cause. Prefer an AssertionError with counts.
  • With the fail-safe in the submission loop, we won’t wait the full 10 minutes on short‑submit scenarios.
-            // Wait for all threads to complete execution.
-            if (!latch.await(10, TimeUnit.MINUTES)) {
-                throw new TimeoutException("Timed out waiting for an executor service to execute");
-            }
-        } catch (Throwable t) {
-            throw new RuntimeException(t);
+            // Wait for all threads to complete execution.
+            if (!latch.await(10, TimeUnit.MINUTES)) {
+                throw new AssertionError(
+                    "Timed out waiting for workers to complete: success=" + this.successCount.get()
+                        + ", errors=" + this.errors.size());
+            }
+        } catch (TimeoutException te) {
+            throw new AssertionError(
+                "Timed out waiting for workers to complete: success=" + this.successCount.get()
+                    + ", errors=" + this.errors.size(), te);
+        } catch (RuntimeException | Error e) {
+            throw e;
+        } catch (Throwable t) {
+            throw new RuntimeException(t);
         }

Also consider documenting that the provided ExecutorService should support at least threadCount concurrent starts (e.g., virtual threads or sufficiently large pools); otherwise barrier timeouts are expected.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 56aa885 and 9cc890d.

📒 Files selected for processing (1)
  • apps/commerce-api/src/test/java/com/loopers/test/assertion/ConcurrentAssertion.java (1 hunks)
🔇 Additional comments (1)
apps/commerce-api/src/test/java/com/loopers/test/assertion/ConcurrentAssertion.java (1)

77-77: Good: completion latch is decremented in finally.

Prevents leaks even on worker exceptions.

@ImSejin ImSejin merged commit 99066ca into master Sep 15, 2025
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