Skip to content

Volume 9 - #395

Merged
BINBEAN777 merged 3 commits into
loopers-labs:BINBEAN777from
BINBEAN777:volume-9
Jul 22, 2026
Merged

BINBEAN777 merged 3 commits into
loopers-labs:BINBEAN777from
BINBEAN777:volume-9

Conversation

@BINBEAN777

Copy link
Copy Markdown

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: R7 파이프라인(Kafka → commerce-streamer)은 product_metrics 에 누적 집계만 한다. "오늘의 인기상품"을 뽑으려면 DB GROUP BY + ORDER BY 가 필요한데, 랭킹은 조회 빈도가 매우 높은 지면이라 데이터가 쌓일수록 DB 부하로 직결된다. 또한 누적 집계만으로는 오래된 인기 상품이 상위권을 독식한다(롱테일).
  • 문제(또는 리스크): 실시간 랭킹을 DB 로 풀면 느리고, 집계 윈도우 없이 풀면 랭킹의 신선도/공정성이 사라진다.
  • 성공 기준(완료 정의):
    • 이벤트(조회/좋아요/주문) 소비 시 일간 키 ranking:all:{yyyyMMdd} ZSET 에 가중치 점수 반영 (TTL 2일)
    • GET /api/v1/rankings?date&page&size — 상품 정보 Aggregation 포함 랭킹 페이지 조회
    • 상품 상세에 오늘 순위 동봉 (순위 없으면 null)
    • 가중치 검증: 주문 1건(수량 1) > 좋아요 3건
    • 일자가 바뀌어도 이전 날짜 랭킹 조회 가능

선택지와 결정

  • 고려한 대안:
    • A: 랭킹 반영도 정합성 보장 — 랭킹 전용 컨슈머 그룹 분리(Redis 성공까지 ack 보류) 또는 Redis 반영 여부를 별도 멱등 키로 관리
    • B: best-effort — DB 멱등 처리(event_handled)를 통과한 이벤트만 랭킹에 반영하되, Redis 반영 실패는 로깅 후 스킵
  • 최종 결정: B. 랭킹은 근사치 데이터이고, 정확한 원본(source of truth)은 product_metrics 가 갖는다. 현재 구조에서 예외를 던져 배치를 재소비시켜도 DB 멱등 체크에 걸려 applied 가 비므로 랭킹은 어차피 복구되지 않는다 — 던져서 얻는 게 없다면 명시적으로 삼키는 것이 낫다고 판단.
  • 그 외 결정:
    • 단일 ZSET(가중치 선반영) vs 지표별 ZSET + ZUNIONSTORE 재합산 → 단일 ZSET. 실시간 가중치 조절은 현재 요구사항이 아니고, 메모리 3배 + 조회 복잡도의 대가가 크다.
    • 날짜 키는 occurredAt(Asia/Seoul) 기준 — 컨슈머 랙이 있어도 "발생한 날"의 판에 반영. 시간을 입력으로 받게 되어 자정 경계 테스트도 가능해졌다.
    • 랭킹 조회는 replica 읽기 — 대기열(R8, master 강제)과 달리 랭킹은 복제 지연을 허용할 수 있는 데이터.
    • 주문 점수는 수량 기반(0.7×qty) — 이벤트 페이로드에 가격이 없어 price×amount 는 보류. 가중치는 "주문 1건 > 좋아요 3건" 제약을 부등식으로 놓고 역산 (0.7 > 0.2×3).
    • 배치 정제 — 메시지 1건당 ZINCRBY 하지 않고 (랭킹판, 상품)별로 애플리케이션에서 사전 합산 후 반영. 3000건 배치에 상품 100종이면 Redis 왕복 3000 → 100.
  • 트레이드오프: 랭킹 점수의 유실/이중반영 가능성을 열어두는 대신 파이프라인 단순성과 처리량을 얻음. 가중치 변경은 소급 불가.
  • 추후 개선 여지: 콜드 스타트 완화 Carry-Over 스케줄러(ZUNIONSTORE WEIGHTS), 주문 이벤트 페이로드에 가격 추가, 시간 단위 랭킹 키 확장, Redis 파이프라이닝.

🤔 고민한 점 / 막혔던 부분

  • DB 트랜잭션과 Redis 반영의 정합성 틈: 멱등 기록(event_handled)은 DB 트랜잭션 안에서만 원자적이라, 커밋 후 Redis 반영이 실패하면 그 이벤트의 랭킹 점수는 영원히 유실된다. "예외를 던지면 재처리되겠지"라고 생각했는데, 따라가 보니 재소비 시 멱등 체크가 applied 를 비워버려 복구가 안 되는 구조였다. 결국 보장 수준(at-most-once)을 명시적으로 선택하고 그 근거를 주석으로 남겼다.
  • RedisTestContainersConfig 가 앱에 따라 동작하거나 안 하거나: 시스템 프로퍼티를 생성자에서 세팅하고 있었는데, commerce-api 에서는 우연히 동작했고 commerce-streamer 에서는 RedisConfig 의 프로퍼티 바인딩이 먼저 일어나 localhost 기본값으로 붙었다. static 블록(클래스 로딩 시점)으로 옮겨 빈 생성 순서와 무관하게 만들었다.
  • @Cacheable 상세 응답에 순위가 박히는 문제: 상품 상세는 5분 캐시인데 순위를 그 안에서 조합하면 순위가 5분간 얼어붙는다. 순위 조회를 캐시 경계 밖(컨트롤러 레벨)에서 조합하는 것으로 분리했다.

🙋 기타

  • 검증: JAVA_HOME=(jdk21) ./gradlew :apps:commerce-streamer:test :apps:commerce-api:test 전체 통과 (Testcontainers MySQL/Redis)
  • API 문서: http/commerce-api/rankings.http

BINBEAN777 and others added 3 commits July 17, 2026 11:02
생성자에서 시스템 프로퍼티를 세팅하면 RedisConfig 의 프로퍼티 바인딩이
먼저 일어나는 앱(commerce-streamer)에서 localhost 기본값으로 붙는다.
클래스 로딩 시점(static)으로 옮겨 빈 생성 순서와 무관하게 동작하도록 수정.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- RankingKey: occurredAt(Asia/Seoul) 기준 ranking:all:yyyyMMdd 키 계산
- RankingScorePolicy: view 0.1 / like ±0.2 / order 0.7×qty (주문 1건 > 좋아요 3건)
- RankingEventHandler: 배치 내 (판, 상품)별 사전 합산 후 ZINCRBY, 실패는 best-effort 스킵
- RedisRankingRepository: 키 최초 생성 시에만 TTL 2일
- MetricsEventHandler 가 "새로 처리 여부" 반환 → 중복 이벤트는 랭킹 미반영

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- GET /api/v1/rankings?date&page&size — ZREVRANGE 후 상품·브랜드 IN 쿼리 Aggregation
- 랭킹 조회는 replica 읽기 (복제 지연 허용), 없는 날짜는 빈 목록
- 상품 상세에 오늘 순위(rank) 추가 — @Cacheable 밖에서 조합, 순위 없으면 null, Redis 장애 격리
- ProductRepository.findAllByIds 추가 (N+1 방지)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 17, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

변경 목적: 이벤트 기반 일일 상품 랭킹을 도입하고 랭킹 조회 및 상품 상세 응답에 순위 정보를 제공한다.
핵심 변경점: 조회·좋아요·주문 이벤트를 가중치로 Redis ZSET에 집계하고, 날짜별 페이징 API와 상품·브랜드 정보를 제공한다.
핵심 변경점: 상품 상세 조회에 오늘의 순위를 추가하며, 랭킹 데이터가 없거나 Redis 조회 실패 시 rank=null로 응답한다.
리스크/주의사항: 랭킹 조회는 Redis 레플리카를 사용하므로 복제 지연 가능성이 있으며, 존재하지 않는 상품은 순위를 유지한 채 목록에서 제외된다.
테스트/검증: Redis/MySQL Testcontainers 기반 단위·E2E 테스트로 점수 정책, 타임존·TTL, 페이지네이션, 과거 날짜 조회 및 상품 상세 연동을 검증했다.

Walkthrough

이 변경은 이벤트 기반 일간 랭킹 점수 집계와 Redis 저장을 추가하고, Commerce API에서 날짜별 랭킹 페이지와 상품 상세의 오늘 순위를 제공한다. 랭킹 데이터는 상품·브랜드 정보와 조합되며, 입력 검증과 E2E 테스트가 추가된다.

Changes

일간 랭킹 기능

Layer / File(s) Summary
랭킹 계약과 점수 정책
apps/commerce-api/.../domain/ranking/*, apps/commerce-streamer/.../domain/ranking/*, apps/commerce-streamer/src/test/.../ranking/*
일간 랭킹 키와 저장소 계약을 정의하고 조회·좋아요·주문 이벤트의 점수 정책 및 관련 테스트를 추가한다.
이벤트 집계와 Redis 반영
apps/commerce-streamer/src/main/java/com/loopers/application/..., apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/*, apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/*, apps/commerce-streamer/src/test/..., modules/redis/src/testFixtures/...
멱등 처리된 이벤트만 배치 집계하고 상품별 점수 증분과 2일 TTL을 Redis ZSET에 반영한다.
랭킹 저장소와 조회 조합
apps/commerce-api/src/main/java/com/loopers/application/ranking/*, apps/commerce-api/src/main/java/com/loopers/domain/..., apps/commerce-api/src/main/java/com/loopers/infrastructure/...
Redis에서 랭킹 페이지와 상품 순위를 조회하고, 상품·브랜드 정보를 결합해 RankingInfo 목록을 구성한다.
랭킹 API와 상품 상세 응답
apps/commerce-api/src/main/java/com/loopers/interfaces/api/..., apps/commerce-api/src/test/..., http/commerce-api/rankings.http
/api/v1/rankings 조회 API와 상품 상세의 rank 필드를 추가하고, 페이지·날짜 검증 및 랭킹 조회 시나리오를 E2E 테스트로 검증한다.

Sequence Diagram(s)

이벤트 기반 랭킹 반영

sequenceDiagram
  participant CatalogEventConsumer
  participant MetricsEventHandler
  participant RankingEventHandler
  participant RedisRankingRepository
  CatalogEventConsumer->>MetricsEventHandler: 이벤트 멱등 처리
  MetricsEventHandler-->>CatalogEventConsumer: 적용 여부 반환
  CatalogEventConsumer->>RankingEventHandler: 적용 이벤트 배치 전달
  RankingEventHandler->>RedisRankingRepository: 점수 증분 및 TTL 반영
Loading

랭킹 페이지 조회

sequenceDiagram
  participant Client
  participant RankingV1Controller
  participant RankingFacade
  participant RedisRankingRepository
  participant ProductRepository
  participant BrandRepository
  Client->>RankingV1Controller: 랭킹 조회 요청
  RankingV1Controller->>RankingFacade: 날짜·페이지·크기 전달
  RankingFacade->>RedisRankingRepository: ZSET 랭킹 조회
  RankingFacade->>ProductRepository: 상품 정보 조회
  RankingFacade->>BrandRepository: 브랜드 정보 조회
  RankingV1Controller-->>Client: 랭킹 페이지 응답
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.96% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive 제목이 'Volume 9'만으로는 변경 내용을 식별할 수 없어 역사 기록용으로는 너무 모호하다. 주요 변경을 한 문장으로 요약해 '일간 상품 랭킹 조회 및 스트리밍 반영 추가'처럼 구체적으로 수정한다.
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed 템플릿의 문제 정의, 선택지와 결정, 고민한 점, 기타를 포함해 요구 사항을 대부분 충족한다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

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

🧹 Nitpick comments (3)
apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java (1)

36-38: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

빈 리스트 처리 시 불필요한 자원 낭비를 방지한다.

운영 환경에서는 이벤트가 모두 중복으로 판정되어 appliedMessages가 빈 리스트로 넘어올 가능성이 존재한다. 빈 리스트일 경우 불필요한 Map 객체 생성 및 순회를 막기 위해 빠른 종료(Early return)를 적용해야 한다. 빈 리스트 입력 시 예외 없이 정상 종료되는지 확인하는 테스트를 추가한다.

  • apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java#L36-L38: handleCatalog 메서드 도입부에 if (appliedMessages.isEmpty()) return;을 추가한다.
  • apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java#L53-L55: handleOrder 메서드 도입부에도 동일하게 빠른 종료 조건을 추가한다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java`
around lines 36 - 38, Update RankingEventHandler.handleCatalog and handleOrder
to return immediately when appliedMessages is empty, before creating maps or
iterating messages. Preserve existing processing for non-empty inputs, and add
tests confirming empty lists complete normally without errors.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java (2)

151-171: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

page와 size의 경계값을 E2E 테스트에 추가해야 한다.

현재는 잘못된 날짜만 검증하므로 요청 제한이 회귀해도 탐지되지 않는다. page=0, size=0, size=101은 400을, page=1, size=1, size=100은 정상 응답을 검증해야 한다. 최대 크기 제한 회귀는 운영 환경의 Redis·DB 과다 조회로 이어질 수 있다.

As per path instructions, 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. <path_instructions>

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java`
around lines 151 - 171, Extend the RankingV1ApiE2ETest coverage around the
existing test methods to validate pagination boundaries: assert HTTP 400 for
page=0, size=0, and size=101, and assert successful responses for page=1,
size=1, and size=100. Reuse the existing endpoint and response setup, keeping
the assertions focused on the request validation outcomes.

Source: Path instructions


222-238: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Redis 장애 격리 시나리오를 별도로 검증해야 한다.

빈 랭킹에서 null을 반환하는 테스트는 Redis 예외 흐름을 통과하지 않는다. 랭킹 저장소가 예외를 던져도 상품 상세가 200과 rank=null을 반환하는 테스트를 추가해야 한다. 그렇지 않으면 Redis 장애가 상품 상세 API 장애로 확산되는 회귀를 탐지할 수 없다.

As per path instructions, 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. <path_instructions>

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java`
around lines 222 - 238, RankingV1ApiE2ETest의
returnsNullRank_whenProductIsNotRanked와 별도로 Redis 랭킹 저장소가 예외를 던지는 장애 격리 시나리오를
추가하라. 랭킹 조회 의존성을 예외 발생하도록 설정한 뒤 상품 상세 API를 호출하고, 응답이 200 OK이며
ProductDetailResponse의 rank가 null인지 검증하라.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`:
- Around line 47-54: Replace the per-ID brandRepository.findById loop in
RankingFacade with a single bulk IN-query using the collected brandIds, then
construct brandMap from the returned brands keyed by ID. Add an integration test
for a ranking containing multiple brands that verifies only one brand lookup
query is executed.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`:
- Around line 20-39: RankingV1Controller에 `@Validated를` 추가하고 getRankings의 page와
size 파라미터에 각각 `@Min`(1), `@Max`(100) 검증 애너테이션을 적용하세요. 컨트롤러 내부의 page/size 하드코딩 조건문과
직접 CoreException을 발생시키는 검증 로직은 제거하고, 검증 실패가 기존 `@ControllerAdvice의` 표준 예외 응답으로
처리되도록 관련 테스트를 추가하세요.

In
`@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RedisRankingRepository.java`:
- Around line 28-42: Update RedisRankingRepository.incrementScores to execute
all ZINCRBY operations through redisTemplate.executePipelined, preserving the
empty-input early return and existing key/TTL behavior. Apply the expiry
handling after the pipeline completes, and add an integration test covering
multiple product score updates through the pipeline.

In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java`:
- Around line 38-49: Update the record-processing loop in CatalogEventConsumer
to skip records whose record.value() is null before deserialization, without
logging an error. For deserialization or handling failures, extend the existing
log.error call to include record.topic(), record.partition(), and
record.offset() so the failed Kafka message is fully identifiable. Add unit
tests covering silent tombstone skipping and poison-message logs containing
topic and partition.

In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java`:
- Around line 40-47: Update the exception handling in the OrderEventConsumer
processing loop to catch and skip only non-recoverable deserialization failures
from objectMapper.readValue, while allowing transaction or infrastructure
exceptions thrown by metricsEventHandler.handleOrder to propagate to the Kafka
container for retry and prevent acknowledge from committing the batch. Add an
integration test covering a DB/infrastructure failure and verifying the offset
is not committed.

---

Nitpick comments:
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java`:
- Around line 151-171: Extend the RankingV1ApiE2ETest coverage around the
existing test methods to validate pagination boundaries: assert HTTP 400 for
page=0, size=0, and size=101, and assert successful responses for page=1,
size=1, and size=100. Reuse the existing endpoint and response setup, keeping
the assertions focused on the request validation outcomes.
- Around line 222-238: RankingV1ApiE2ETest의
returnsNullRank_whenProductIsNotRanked와 별도로 Redis 랭킹 저장소가 예외를 던지는 장애 격리 시나리오를
추가하라. 랭킹 조회 의존성을 예외 발생하도록 설정한 뒤 상품 상세 API를 호출하고, 응답이 200 OK이며
ProductDetailResponse의 rank가 null인지 검증하라.

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java`:
- Around line 36-38: Update RankingEventHandler.handleCatalog and handleOrder to
return immediately when appliedMessages is empty, before creating maps or
iterating messages. Preserve existing processing for non-empty inputs, and add
tests confirming empty lists complete normally without errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 10f7c573-c272-411c-9bd2-100330a8fb25

📥 Commits

Reviewing files that changed from the base of the PR and between 35de6d3 and d1e8c7d.

📒 Files selected for processing (29)
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankedProduct.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingKey.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RedisRankingRepository.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java
  • apps/commerce-api/src/test/resources/docker-java.properties
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsEventHandler.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingKey.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScorePolicy.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RedisRankingRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java
  • apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsEventHandlerTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingEventHandlerTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingKeyTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingScorePolicyTest.java
  • http/commerce-api/rankings.http
  • modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java

Comment on lines +47 to +54
Set<Long> brandIds = productMap.values().stream()
.map(ProductModel::getBrandId)
.collect(Collectors.toSet());
Map<Long, BrandModel> brandMap = new HashMap<>();
for (Long id : brandIds) {
brandRepository.findById(id).ifPresent(b -> brandMap.put(id, b));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

루프 내 개별 DB 조회(N+1)로 인한 성능 저하를 방지해야 한다.

여러 상품의 브랜드 정보를 조회할 때 findById를 반복 호출하면 N+1 문제가 발생한다. 트래픽이 몰릴 경우 DB 커넥션 풀을 고갈시키고 전체 시스템의 지연(Latency)과 장애를 유발할 수 있다.

IN 쿼리를 사용하여 한 번에 조회하도록 수정한다. 또한 여러 브랜드가 포함된 랭킹 조회 시 쿼리가 1회만 발생하는지 통합 테스트를 통해 검증해야 한다. As per path instructions, N+1 가능성을 점검해야 한다.

🛠️ 제안하는 수정안
         Set<Long> brandIds = productMap.values().stream()
                 .map(ProductModel::getBrandId)
                 .collect(Collectors.toSet());
         Map<Long, BrandModel> brandMap = new HashMap<>();
-        for (Long id : brandIds) {
-            brandRepository.findById(id).ifPresent(b -> brandMap.put(id, b));
-        }
+        brandRepository.findAllById(brandIds).forEach(b -> brandMap.put(b.getId(), b));
📝 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
Set<Long> brandIds = productMap.values().stream()
.map(ProductModel::getBrandId)
.collect(Collectors.toSet());
Map<Long, BrandModel> brandMap = new HashMap<>();
for (Long id : brandIds) {
brandRepository.findById(id).ifPresent(b -> brandMap.put(id, b));
}
Set<Long> brandIds = productMap.values().stream()
.map(ProductModel::getBrandId)
.collect(Collectors.toSet());
Map<Long, BrandModel> brandMap = new HashMap<>();
brandRepository.findAllById(brandIds).forEach(b -> brandMap.put(b.getId(), b));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`
around lines 47 - 54, Replace the per-ID brandRepository.findById loop in
RankingFacade with a single bulk IN-query using the collected brandIds, then
construct brandMap from the returned brands keyed by ID. Add an integration test
for a ranking containing multiple brands that verifies only one brand lookup
query is executed.

Source: Path instructions

Comment on lines +20 to +39
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/rankings")
public class RankingV1Controller implements RankingV1ApiSpec {

private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE;

private final RankingFacade rankingFacade;

@GetMapping
@Override
public ApiResponse<RankingV1Dto.RankingPageResponse> getRankings(
@RequestParam(required = false) String date,
@RequestParam(required = false, defaultValue = "1") Integer page,
@RequestParam(required = false, defaultValue = "20") Integer size
) {
LocalDate targetDate = parseDateOrToday(date);
if (page < 1 || size < 1 || size > 100) {
throw new CoreException(ErrorType.BAD_REQUEST, "page 는 1 이상, size 는 1~100 이어야 합니다.");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Bean Validation을 사용하여 요청 파라미터 검증을 표준화해야 한다.

컨트롤러 내부에 하드코딩된 조건문으로 파라미터를 검증하면, 에러 응답 포맷이 파편화되고 @ControllerAdvice를 통한 중앙 집중식 예외 처리를 활용하기 어렵다. 이는 운영 환경에서 API 에러 모니터링 및 클라이언트 연동에 혼선을 초래한다.

클래스 레벨에 @Validated를 추가하고 파라미터에 @Min, @Max 애너테이션을 적용하여 검증 로직을 위임한다. 검증 실패 시 전역 예외 처리기에서 동일한 형태의 표준 에러 응답이 내려가는지 검증하는 테스트를 추가해야 한다. As per path instructions, Controller는 요청 검증(Bean Validation)에 집중하고 @ControllerAdvice 기반 표준 처리로 유도해야 한다.

🛠️ 제안하는 수정안 (Validation 의존성 추가 필요)
+import org.springframework.validation.annotation.Validated;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Max;
+
+@Validated
 `@RequiredArgsConstructor`
 `@RestController`
 `@RequestMapping`("/api/v1/rankings")
 public class RankingV1Controller implements RankingV1ApiSpec {
 
     // ...
 
     `@GetMapping`
     `@Override`
     public ApiResponse<RankingV1Dto.RankingPageResponse> getRankings(
             `@RequestParam`(required = false) String date,
-            `@RequestParam`(required = false, defaultValue = "1") Integer page,
-            `@RequestParam`(required = false, defaultValue = "20") Integer size
+            `@RequestParam`(required = false, defaultValue = "1") `@Min`(1) Integer page,
+            `@RequestParam`(required = false, defaultValue = "20") `@Min`(1) `@Max`(100) Integer size
     ) {
         LocalDate targetDate = parseDateOrToday(date);
-        if (page < 1 || size < 1 || size > 100) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "page 는 1 이상, size 는 1~100 이어야 합니다.");
-        }
         List<RankingInfo> infos = rankingFacade.getRankingPage(targetDate, page, size);
📝 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
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/rankings")
public class RankingV1Controller implements RankingV1ApiSpec {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE;
private final RankingFacade rankingFacade;
@GetMapping
@Override
public ApiResponse<RankingV1Dto.RankingPageResponse> getRankings(
@RequestParam(required = false) String date,
@RequestParam(required = false, defaultValue = "1") Integer page,
@RequestParam(required = false, defaultValue = "20") Integer size
) {
LocalDate targetDate = parseDateOrToday(date);
if (page < 1 || size < 1 || size > 100) {
throw new CoreException(ErrorType.BAD_REQUEST, "page 는 1 이상, size 는 1~100 이어야 합니다.");
}
`@RequiredArgsConstructor`
`@RestController`
`@RequestMapping`("/api/v1/rankings")
`@Validated`
public class RankingV1Controller implements RankingV1ApiSpec {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE;
private final RankingFacade rankingFacade;
`@GetMapping`
`@Override`
public ApiResponse<RankingV1Dto.RankingPageResponse> getRankings(
`@RequestParam`(required = false) String date,
`@RequestParam`(required = false, defaultValue = "1") `@Min`(1) Integer page,
`@RequestParam`(required = false, defaultValue = "20") `@Min`(1) `@Max`(100) Integer size
) {
LocalDate targetDate = parseDateOrToday(date);
List<RankingInfo> infos = rankingFacade.getRankingPage(targetDate, page, size);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`
around lines 20 - 39, RankingV1Controller에 `@Validated를` 추가하고 getRankings의 page와
size 파라미터에 각각 `@Min`(1), `@Max`(100) 검증 애너테이션을 적용하세요. 컨트롤러 내부의 page/size 하드코딩 조건문과
직접 CoreException을 발생시키는 검증 로직은 제거하고, 검증 실패가 기존 `@ControllerAdvice의` 표준 예외 응답으로
처리되도록 관련 테스트를 추가하세요.

Source: Path instructions

Comment on lines +28 to +42
@Override
public void incrementScores(RankingKey key, Map<Long, Double> scoreDeltaByProductId) {
if (scoreDeltaByProductId.isEmpty()) {
return;
}
String keyValue = key.value();
scoreDeltaByProductId.forEach((productId, delta) ->
redisTemplate.opsForZSet().incrementScore(keyValue, String.valueOf(productId), delta));

// ZINCRBY 로 키가 처음 생겼을 때만 TTL 을 건다. (매번 걸면 쓰기가 있을 때마다 만료가 밀린다)
Long ttlSeconds = redisTemplate.getExpire(keyValue);
if (ttlSeconds != null && ttlSeconds == -1) {
redisTemplate.expire(keyValue, TTL);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Redis 파이프라인을 사용하여 네트워크 왕복(Round-trip) 횟수를 최소화한다.

운영 관점에서 이벤트 스트림을 처리할 때 상품 수만큼 Redis에 개별적으로 incrementScore를 호출하면 N번의 네트워크 왕복이 발생하여 애플리케이션 지연 및 병목의 원인이 된다. redisTemplate.executePipelined를 사용하여 명령을 한 번에 전송하도록 변경해야 한다. 추가로 대량의 상품 점수 업데이트 시 파이프라인 처리가 정상 작동하는지 검증하는 통합 테스트를 작성한다.

⚡ 제안하는 파이프라인 적용 수정안
     `@Override`
     public void incrementScores(RankingKey key, Map<Long, Double> scoreDeltaByProductId) {
         if (scoreDeltaByProductId.isEmpty()) {
             return;
         }
         String keyValue = key.value();
+        
+        Long currentTtl = redisTemplate.getExpire(keyValue);
+        boolean needsTtl = currentTtl != null && currentTtl < 0;
+
+        redisTemplate.executePipelined(new org.springframework.data.redis.core.SessionCallback<Object>() {
+            `@Override`
+            public <K, V> Object execute(org.springframework.data.redis.core.RedisOperations<K, V> operations) throws org.springframework.dao.DataAccessException {
+                `@SuppressWarnings`("unchecked")
+                org.springframework.data.redis.core.RedisOperations<String, String> stringOps = (org.springframework.data.redis.core.RedisOperations<String, String>) operations;
+                scoreDeltaByProductId.forEach((productId, delta) ->
+                        stringOps.opsForZSet().incrementScore(keyValue, String.valueOf(productId), delta));
+                
+                if (needsTtl) {
+                    stringOps.expire(keyValue, TTL);
+                }
+                return null;
+            }
+        });
-        scoreDeltaByProductId.forEach((productId, delta) ->
-                redisTemplate.opsForZSet().incrementScore(keyValue, String.valueOf(productId), delta));
-
-        // ZINCRBY 로 키가 처음 생겼을 때만 TTL 을 건다. (매번 걸면 쓰기가 있을 때마다 만료가 밀린다)
-        Long ttlSeconds = redisTemplate.getExpire(keyValue);
-        if (ttlSeconds != null && ttlSeconds == -1) {
-            redisTemplate.expire(keyValue, TTL);
-        }
     }
📝 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
@Override
public void incrementScores(RankingKey key, Map<Long, Double> scoreDeltaByProductId) {
if (scoreDeltaByProductId.isEmpty()) {
return;
}
String keyValue = key.value();
scoreDeltaByProductId.forEach((productId, delta) ->
redisTemplate.opsForZSet().incrementScore(keyValue, String.valueOf(productId), delta));
// ZINCRBY 로 키가 처음 생겼을 때만 TTL 을 건다. (매번 걸면 쓰기가 있을 때마다 만료가 밀린다)
Long ttlSeconds = redisTemplate.getExpire(keyValue);
if (ttlSeconds != null && ttlSeconds == -1) {
redisTemplate.expire(keyValue, TTL);
}
}
`@Override`
public void incrementScores(RankingKey key, Map<Long, Double> scoreDeltaByProductId) {
if (scoreDeltaByProductId.isEmpty()) {
return;
}
String keyValue = key.value();
Long currentTtl = redisTemplate.getExpire(keyValue);
boolean needsTtl = currentTtl != null && currentTtl < 0;
redisTemplate.executePipelined(new org.springframework.data.redis.core.SessionCallback<Object>() {
`@Override`
public <K, V> Object execute(org.springframework.data.redis.core.RedisOperations<K, V> operations) throws org.springframework.dao.DataAccessException {
`@SuppressWarnings`("unchecked")
org.springframework.data.redis.core.RedisOperations<String, String> stringOps = (org.springframework.data.redis.core.RedisOperations<String, String>) operations;
scoreDeltaByProductId.forEach((productId, delta) ->
stringOps.opsForZSet().incrementScore(keyValue, String.valueOf(productId), delta));
if (needsTtl) {
stringOps.expire(keyValue, TTL);
}
return null;
}
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RedisRankingRepository.java`
around lines 28 - 42, Update RedisRankingRepository.incrementScores to execute
all ZINCRBY operations through redisTemplate.executePipelined, preserving the
empty-input early return and existing key/TTL behavior. Apply the expiry
handling after the pipeline completes, and add an integration test covering
multiple product score updates through the pipeline.

Comment on lines +38 to 49
List<CatalogEventMessage> applied = new ArrayList<>();
for (ConsumerRecord<String, byte[]> record : records) {
try {
CatalogEventMessage message = objectMapper.readValue(record.value(), CatalogEventMessage.class);
metricsEventHandler.handleCatalog(message);
if (metricsEventHandler.handleCatalog(message)) {
applied.add(message); // 새로 반영된 이벤트만 랭킹 대상 (중복 제외)
}
} catch (Exception e) {
// 개별 메시지 처리 실패는 로깅 후 스킵(포이즌 메시지가 파티션을 막지 않게). 필요 시 DLQ.
log.error("[catalog-events] 처리 실패 offset={} → 스킵", record.offset(), e);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

메시지 파티션 로깅 누락 및 툼스톤 메시지 처리 부재를 개선해야 한다.

운영 환경에서 예외 발생 시, 토픽과 파티션 정보 없이 오프셋만으로는 Kafka 브로커에서 실패한 메시지를 추적할 수 없다. 또한, 값이 null인 툼스톤(Tombstone) 메시지가 유입될 경우, objectMapper.readValue에서 파싱 예외가 발생하여 불필요한 에러 로그가 기록된다. 이는 디버깅을 어렵게 하고 불필요한 로그 비용을 발생시킨다.

수정안:

  • record.value()가 null인 경우 처리를 건너뛰는 방어적 코드를 추가한다.
  • 에러 로그에 record.topic() 및 record.partition()을 추가하여 장애 시 메시지 위치를 명확히 식별할 수 있도록 한다.

추가 테스트:

  • ConsumerRecord의 값이 null로 들어왔을 때, 에러 로그가 발생하지 않고 무시되는지 검증하는 단위 테스트를 추가한다.
  • 메시지 파싱이 실패하는 포이즌 메시지를 주입했을 때, 토픽과 파티션 정보가 포함된 에러 로그가 남는지 확인하는 테스트를 추가한다.
💻 제안하는 수정안
         List<CatalogEventMessage> applied = new ArrayList<>();
         for (ConsumerRecord<String, byte[]> record : records) {
+            if (record.value() == null) {
+                continue; // 툼스톤 메시지 무시
+            }
             try {
                 CatalogEventMessage message = objectMapper.readValue(record.value(), CatalogEventMessage.class);
                 if (metricsEventHandler.handleCatalog(message)) {
                     applied.add(message);   // 새로 반영된 이벤트만 랭킹 대상 (중복 제외)
                 }
             } catch (Exception e) {
                 // 개별 메시지 처리 실패는 로깅 후 스킵(포이즌 메시지가 파티션을 막지 않게). 필요 시 DLQ.
-                log.error("[catalog-events] 처리 실패 offset={} → 스킵", record.offset(), e);
+                log.error("[catalog-events] 처리 실패 topic={} partition={} offset={} → 스킵",
+                        record.topic(), record.partition(), record.offset(), 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
List<CatalogEventMessage> applied = new ArrayList<>();
for (ConsumerRecord<String, byte[]> record : records) {
try {
CatalogEventMessage message = objectMapper.readValue(record.value(), CatalogEventMessage.class);
metricsEventHandler.handleCatalog(message);
if (metricsEventHandler.handleCatalog(message)) {
applied.add(message); // 새로 반영된 이벤트만 랭킹 대상 (중복 제외)
}
} catch (Exception e) {
// 개별 메시지 처리 실패는 로깅 후 스킵(포이즌 메시지가 파티션을 막지 않게). 필요 시 DLQ.
log.error("[catalog-events] 처리 실패 offset={} → 스킵", record.offset(), e);
}
}
List<CatalogEventMessage> applied = new ArrayList<>();
for (ConsumerRecord<String, byte[]> record : records) {
if (record.value() == null) {
continue; // 툼스톤 메시지 무시
}
try {
CatalogEventMessage message = objectMapper.readValue(record.value(), CatalogEventMessage.class);
if (metricsEventHandler.handleCatalog(message)) {
applied.add(message); // 새로 반영된 이벤트만 랭킹 대상 (중복 제외)
}
} catch (Exception e) {
// 개별 메시지 처리 실패는 로깅 후 스킵(포이즌 메시지가 파티션을 막지 않게). 필요 시 DLQ.
log.error("[catalog-events] 처리 실패 topic={} partition={} offset={} → 스킵",
record.topic(), record.partition(), record.offset(), e);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java`
around lines 38 - 49, Update the record-processing loop in CatalogEventConsumer
to skip records whose record.value() is null before deserialization, without
logging an error. For deserialization or handling failures, extend the existing
log.error call to include record.topic(), record.partition(), and
record.offset() so the failed Kafka message is fully identifiable. Add unit
tests covering silent tombstone skipping and poison-message logs containing
topic and partition.

Source: Path instructions

Comment on lines 40 to 47
try {
OrderEventMessage message = objectMapper.readValue(record.value(), OrderEventMessage.class);
metricsEventHandler.handleOrder(message);
if (metricsEventHandler.handleOrder(message)) {
applied.add(message); // 새로 반영된 이벤트만 랭킹 대상 (중복 제외)
}
} catch (Exception e) {
log.error("[order-events] 처리 실패 offset={} → 스킵", record.offset(), e);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

인프라 장애 시 데이터 유실을 막기 위해 예외 처리를 세분화한다.

운영 관점에서 MetricsEventHandler 호출 시 DB 연결 실패 등 일시적인 인프라 예외가 발생할 수 있다. 현재는 모든 Exception을 잡고 반복문을 계속 진행한 뒤 오프셋을 커밋(acknowledge)하므로, DB 장애 시 해당 배치의 모든 이벤트가 유실된다. 메시지 역직렬화 실패(포이즌 필)와 같은 복구 불가능한 예외만 건너뛰고, 트랜잭션/인프라 장애는 밖으로 던져 카프카 컨테이너가 재시도하도록 수정해야 한다. 재시도 시나리오에 대한 통합 테스트(예: DB 예외 발생 시 오프셋이 커밋되지 않는지)를 추가한다.

🚨 제안하는 예외 처리 세분화 수정안
-            try {
-                OrderEventMessage message = objectMapper.readValue(record.value(), OrderEventMessage.class);
-                if (metricsEventHandler.handleOrder(message)) {
-                    applied.add(message);   // 새로 반영된 이벤트만 랭킹 대상 (중복 제외)
-                }
-            } catch (Exception e) {
-                log.error("[order-events] 처리 실패 offset={} → 스킵", record.offset(), e);
-            }
+            OrderEventMessage message;
+            try {
+                message = objectMapper.readValue(record.value(), OrderEventMessage.class);
+            } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
+                log.error("[order-events] 메시지 역직렬화 실패 offset={} → 스킵", record.offset(), e);
+                continue;
+            }
+            
+            if (metricsEventHandler.handleOrder(message)) {
+                applied.add(message);
+            }
📝 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 {
OrderEventMessage message = objectMapper.readValue(record.value(), OrderEventMessage.class);
metricsEventHandler.handleOrder(message);
if (metricsEventHandler.handleOrder(message)) {
applied.add(message); // 새로 반영된 이벤트만 랭킹 대상 (중복 제외)
}
} catch (Exception e) {
log.error("[order-events] 처리 실패 offset={} → 스킵", record.offset(), e);
}
OrderEventMessage message;
try {
message = objectMapper.readValue(record.value(), OrderEventMessage.class);
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
log.error("[order-events] 메시지 역직렬화 실패 offset={} → 스킵", record.offset(), e);
continue;
}
if (metricsEventHandler.handleOrder(message)) {
applied.add(message);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java`
around lines 40 - 47, Update the exception handling in the OrderEventConsumer
processing loop to catch and skip only non-recoverable deserialization failures
from objectMapper.readValue, while allowing transaction or infrastructure
exceptions thrown by metricsEventHandler.handleOrder to propagate to the Kafka
container for retry and prevent acknowledge from committing the batch. Add an
integration test covering a DB/infrastructure failure and verifying the offset
is not committed.

@BINBEAN777
BINBEAN777 merged commit 6c280e7 into loopers-labs:BINBEAN777 Jul 22, 2026
2 checks 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