[volume-9] Redis ZSET 기반 랭킹 시스템 구현 - 박시온#374
[volume-9] Redis ZSET 기반 랭킹 시스템 구현 - 박시온#374hey-sion merged 6 commits intoLoopers-dev-lab:hey-sionfrom
Conversation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
📝 WalkthroughWalkthroughRedis ZSET 기반 상품 순위 시스템을 신규 구현하고, 결제 완료 이벤트에 주문 상품 정보를 추가한다. 상품 조회/좋아요/결제 이벤트를 통해 순위 점수를 동적으로 업데이트하며, 순위 조회 API를 제공한다. Changes
Sequence DiagramsequenceDiagram
participant Client
participant RankingV1Controller
participant RankingFacade
participant RankingRepository
participant Redis as Redis ZSET
participant ProductFacade
Client->>RankingV1Controller: GET /api/v1/rankings?date=20250409&page=1&size=20
RankingV1Controller->>RankingFacade: getRankings(LocalDate, page, size)
RankingFacade->>RankingRepository: getTopRankings(key, offset, size)
RankingRepository->>Redis: ZREVRANGE key offset+size WITHSCORES
Redis-->>RankingRepository: List<RankingEntry>
RankingRepository-->>RankingFacade: List<RankingEntry>
loop for each RankingEntry
RankingFacade->>ProductFacade: getActiveProduct(productId)
alt Product exists
ProductFacade-->>RankingFacade: ProductInfo
RankingFacade->>RankingFacade: map to RankingProductInfo
else Product not found
ProductFacade-->>RankingFacade: CoreException
RankingFacade->>RankingFacade: skip entry
end
end
RankingFacade-->>RankingV1Controller: RankingPageResult
RankingV1Controller-->>Client: ApiResponse<List<RankingResponse>>
sequenceDiagram
participant EventSource
participant EventHandler as Ranking EventHandler
participant RankingScoreUpdater
participant Redis as Redis ZSET
EventSource->>EventHandler: Event<ProductViewedEventPayload>
EventHandler->>RankingScoreUpdater: incrementView(productId)
RankingScoreUpdater->>Redis: ZINCRBY key 0.1 productId
Redis-->>RankingScoreUpdater: new score
RankingScoreUpdater->>Redis: EXPIRE key 172800
EventSource->>EventHandler: Event<ProductLikedEventPayload>
EventHandler->>RankingScoreUpdater: incrementLike(productId)
RankingScoreUpdater->>Redis: ZINCRBY key 0.2 productId
EventSource->>EventHandler: Event<PaymentCompletedEventPayload>
EventHandler->>RankingScoreUpdater: incrementOrder(productId, price, quantity)
RankingScoreUpdater->>Redis: ZINCRBY key (0.7 * price * quantity) productId
Redis-->>RankingScoreUpdater: new score
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (13)
apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java (1)
29-30: 인증 제외를 경로 단위로 두면 향후 메서드 추가 시 우회 범위가 커진다.운영 관점에서 경로 단위 제외는 라우트 확장 시 인증 누락 사고로 이어질 수 있다. 수정안으로
"/api/v1/rankings"제외를 제거하고,LoginUserInterceptor내부에서GET /api/v1/rankings만 예외 처리하도록 메서드 조건을 명시하는 편이 안전하다. 추가 테스트로 인터셉터 통합 테스트에서GET /api/v1/rankings는 통과하고, 동일 경로의 비-GET 요청은 인증 검사를 타는지 검증하는 것이 좋다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java` around lines 29 - 30, Remove the path-level exclusion for "/api/v1/rankings" in WebConfig and instead add an explicit method check inside LoginUserInterceptor (e.g., in preHandle or equivalent) that only bypasses authentication for GET requests to "/api/v1/rankings"; ensure other methods (POST/PUT/DELETE) to the same path still require auth. Update or add an interceptor integration test that asserts GET /api/v1/rankings is allowed without auth while a non-GET request to /api/v1/rankings triggers the authentication flow.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java (1)
5-9: 가변 List 참조 노출로 응답 불변성이 깨질 수 있다.운영 관점에서 동일 요청 처리 중 리스트가 외부에서 변경되면 응답 값이 흔들려 디버깅 난이도가 올라간다. 수정안으로 compact constructor에서
List.copyOf(items)를 사용해 방어적 복사를 적용하는 것이 좋다. 추가 테스트로 원본 리스트 변경이RankingPageResult.items()에 반영되지 않는지, 그리고 반환 리스트가 수정 불가인지 검증해야 한다.🔒 제안 수정안
public record RankingPageResult( List<RankingProductInfo> items, int page, int size -) {} +) { + public RankingPageResult { + items = List.copyOf(items); + } +}As per coding guidelines,
**/*.java: null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java` around lines 5 - 9, RankingPageResult의 공개 필드 items가 가변 List 참조를 노출해 불변성이 깨지므로 compact constructor를 추가해 null 검사 후 items = List.copyOf(items)로 방어적 복사를 적용하고, items()가 변경 불가능한 리스트를 반환하도록 하세요; 또한 RankingPageResult 레코드의 compact constructor에서 items가 null일 때 NullPointerException을 던지도록 처리하고, 단위 테스트로 원본 리스트를 수정해도 RankingPageResult.items()에 반영되지 않는지와 반환 리스트가 수정 불가능한지 검증하세요.apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingKeyGeneratorTest.java (1)
13-24: 단일 정상 케이스만으로는 키 포맷 회귀를 막기 어렵다.운영 관점에서 월/일 0패딩이나 연말 경계 포맷이 깨지면 키가 달라져 랭킹 데이터가 분산될 수 있다. 수정안으로 파라미터라이즈드 테스트를 추가해
2025-01-01,2025-12-31같은 경계 날짜를 함께 검증하는 것이 좋다. 추가 테스트로yyyyMMdd고정 형식(구분자 없음, 8자리) 자체를 정규식으로 함께 검증하면 회귀 탐지가 쉬워진다.As per coding guidelines,
**/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingKeyGeneratorTest.java` around lines 13 - 24, Add parameterized boundary tests in RankingKeyGeneratorTest to cover date edge cases and format validation: create a `@ParameterizedTest` that calls RankingKeyGenerator.keyOf(...) for dates like LocalDate.of(2025,1,1), LocalDate.of(2025,12,31) (and the existing 2025-04-09) and assert each produced key equals the expected string, and also add an assertion that the returned key matches the fixed yyyyMMdd pattern (e.g., matches regex "^ranking:all:\\d{8}$") to catch zero-padding/format regressions; reference the existing test method keyOf_returnsKeyWithGivenDate and the static method RankingKeyGenerator.keyOf to locate where to add these checks.apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/dto/RankingV1Dto.java (1)
7-24: DTO 구조가 적절하나,score필드의 부동소수점 정밀도를 고려해야 한다.
RankingProductInfo에서RankingResponse로의 매핑이 명확하다. 다만Double score가 JSON 응답으로 직렬화될 때0.30000000000000004와 같은 부동소수점 표현 오차가 노출될 수 있다. 클라이언트 표시용이라면 무시해도 되지만, 정밀한 값이 필요하다면 반올림 처리나BigDecimal사용을 검토해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/dto/RankingV1Dto.java` around lines 7 - 24, The DTO exposes score as Double which can reveal floating-point artifacts when serialized; update the RankingResponse.record to either use BigDecimal for score or round the double before mapping: change the score field type in RankingResponse (or convert during the mapping in RankingResponse.from) and transform info.score() with a precise representation (e.g., BigDecimal.valueOf(info.score()).setScale(n, RoundingMode.HALF_UP) or Double.valueOf(Math.round(info.score()*factor)/factor) for a chosen scale) so JSON responses contain the desired fixed precision; adjust imports and any callers accordingly (refer to RankingResponse record and the from(RankingProductInfo) method and the info.score() call).apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RedisRankingRepositoryTest.java (2)
113-121:getScore가 존재하지 않는 상품에 대해null을 반환하는 테스트 누락.
getRank에 대해서는 Line 103-111에서null반환 테스트가 있으나,getScore에 대한 동일한 경계 케이스 테스트가 없다. API 호출 흐름에서getScore도null을 반환할 수 있으므로 테스트 커버리지를 추가하는 것이 좋다.제안: getScore null 반환 테스트 추가
`@DisplayName`("랭킹에 없는 상품 점수 조회 시 null을 반환한다.") `@Test` void getScore_returnsNullWhenNotInRanking() { // act Double score = rankingRepository.getScore(TEST_KEY, 999L); // assert assertThat(score).isNull(); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RedisRankingRepositoryTest.java` around lines 113 - 121, The test suite lacks a boundary test for RedisRankingRepositoryTest#getScore when the product is absent; add a new test method (e.g., getScore_returnsNullWhenNotInRanking) in RedisRankingRepositoryTest that calls rankingRepository.getScore(TEST_KEY, 999L) (or another non-existent product id) and asserts the result is null using assertThat(score).isNull(); ensure the new test has a descriptive `@DisplayName` and `@Test` annotations to mirror existing tests.
32-44: 테스트 격리를 위해@BeforeEach에서도 정리 필요.현재
@AfterEach에서만 정리하고 있어, 이전 테스트 실행이 실패로 중단된 경우 잔여 데이터가 다음 실행에 영향을 줄 수 있다.@BeforeEach에서 먼저 정리 후 데이터를 설정하면 더 안정적인 격리가 가능하다.제안: setUp에서 선행 정리
`@BeforeEach` void setUp() { + redisCleanUp.truncateAll(); redisTemplate.opsForZSet().add(TEST_KEY, "1", 100.0);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RedisRankingRepositoryTest.java` around lines 32 - 44, The setup currently seeds the ZSet in setUp() but only cleans up in tearDown(); to ensure test isolation call redisCleanUp.truncateAll() at the start of setUp() before calling redisTemplate.opsForZSet().add(...) so any leftover data is removed; update the setUp() method (and keep tearDown() as-is) to first invoke redisCleanUp.truncateAll(), then add the TEST_KEY entries ("1","2","3","4","5") with their scores.apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScoreUpdater.java (2)
27-35: 가중치 설정값에 대한 유효성 검증이 없다.음수나 비정상적으로 큰 가중치가 설정되면 랭킹 점수가 예상과 다르게 계산될 수 있다. 설정 오류를 조기에 발견하기 위해 생성자나
@PostConstruct에서 검증하는 것을 고려할 수 있다.가중치 검증 추가 제안
`@PostConstruct` void validateWeights() { if (viewWeight < 0 || likeWeight < 0 || orderWeight < 0) { throw new IllegalStateException("랭킹 가중치는 0 이상이어야 합니다."); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScoreUpdater.java` around lines 27 - 35, Add validation for the injected weight fields in RankingScoreUpdater to prevent negative or invalid weights: implement a `@PostConstruct` method named validateWeights() (or perform checks in the constructor) that verifies viewWeight, likeWeight, and orderWeight are >= 0 (and optionally within an expected max if you want, e.g., <= 1.0); if any check fails, throw an IllegalStateException with a clear message like "랭킹 가중치는 0 이상이어야 합니다." to fail fast and surface config errors.
55-60: Redis 연산이 원자적(atomic)이지 않다.
incrementScore와expire가 별도 연산으로 실행된다.incrementScore후expire실패 시 TTL이 갱신되지 않을 수 있다. 다만, retrieved learnings에 따르면 이 시스템은 "approximate ranking with at-least-once semantics"로 설계되었고, daily key + TTL 구조로 자연 복구되므로 허용 가능한 수준이다.Lua 스크립트를 사용하면 원자적 실행이 가능하지만, 현재 설계 의도상 필수는 아니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScoreUpdater.java` around lines 55 - 60, The current incrementScore method runs masterRedisTemplate.opsForZSet().incrementScore(...) and masterRedisTemplate.expire(...) as two separate calls, which is not atomic; change incrementScore to execute a single Redis Lua script (or RedisScript via masterRedisTemplate.execute) that performs ZINCRBY and sets EXPIRE atomically (using key from RankingKeyGenerator.keyOf(LocalDate.now(clock)) and member = String.valueOf(productId)), or alternatively document/confirm that at-least-once daily-key+TTL semantics are acceptable; update the method incrementScore to use the Lua script approach (or add a clear comment referencing this tradeoff) so readers can find the change.apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java (1)
103-114: 응답 body에 대한 null 체크가 없다.
response.getBody().data()를 직접 호출하는데, HTTP 오류나 예외 상황에서getBody()가 null을 반환할 수 있다. 테스트가 예상치 못하게 NPE로 실패하면 디버깅이 어려워진다.명시적 null 체크 또는 assertThat 체인 사용 제안
// assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - List<RankingV1Dto.RankingResponse> items = response.getBody().data(); + assertThat(response.getBody()).isNotNull(); + List<RankingV1Dto.RankingResponse> items = response.getBody().data(); assertThat(items).hasSize(3);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java` around lines 103 - 114, response.getBody() is used without a null check in RankingV1ApiE2ETest leading to possible NPE; before calling response.getBody().data() add an explicit assertion that response.getBody() is not null (e.g., assertThat(response.getBody()).isNotNull()) or chain assertions to safely obtain data, then assign to items and continue the existing assertions so the test fails with a clear message rather than an NPE; locate this in the test method where response.getBody(), items, and RankingV1Dto.RankingResponse are referenced.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (2)
28-31:page와size파라미터 유효성 검증이 없다.
page <= 0이면 음수 offset이 계산되고,size <= 0이면 빈 결과가 반환되거나 예상치 못한 동작이 발생할 수 있다. PR 요약에서 "parameter validation"을 향후 작업으로 언급했지만, 방어적 코딩 관점에서 최소한의 검증을 추가하는 것이 좋다.파라미터 검증 추가 제안
public RankingPageResult getRankings(LocalDate date, int page, int size) { + if (page < 1) { + page = 1; + } + if (size < 1) { + size = 10; // 기본값 + } String key = RankingKeyGenerator.keyOf(date); int offset = (page - 1) * size;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java` around lines 28 - 31, In RankingFacade.getRankings, validate the incoming page and size before computing offset or calling rankingRepository.getTopRankings: ensure page > 0 and size > 0 and if not throw an IllegalArgumentException (or another appropriate runtime exception) with a clear message referencing the invalid parameter; keep using RankingKeyGenerator.keyOf(date) and compute offset only after validation so offset = (page - 1) * size cannot be negative or otherwise invalid.
41-49: 모든CoreException을 동일하게 처리하는 것이 적절한지 검토가 필요하다.현재
NOT_FOUND뿐 아니라INTERNAL_SERVER_ERROR,UNAUTHORIZED등 모든CoreException이 동일하게 warn 로그만 남기고 스킵된다. 일시적인 DB 장애 등으로 인한 예외까지 스킵되면 정상 상품이 랭킹에서 누락될 수 있다.에러 타입별 분기 처리 제안
private Optional<RankingProductInfo> toRankingProductInfo(RankingEntry entry) { try { ProductInfo product = productFacade.getActiveProduct(entry.productId()); return Optional.of(RankingProductInfo.of(product, entry.rank() + 1, entry.score())); } catch (CoreException e) { - log.warn("랭킹 조회 중 상품 조회 실패 [productId={}]: {}", entry.productId(), e.getMessage()); + if (e.getErrorType() == ErrorType.NOT_FOUND) { + log.info("랭킹에서 삭제된 상품 스킵 [productId={}]", entry.productId()); + } else { + log.error("랭킹 조회 중 예기치 않은 오류 [productId={}]: {}", entry.productId(), e.getMessage(), e); + } return Optional.empty(); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java` around lines 41 - 49, The toRankingProductInfo(RankingEntry) currently catches all CoreException from productFacade.getActiveProduct(...) and silently skips by returning Optional.empty(), which hides transient errors; change the handler to distinguish error types: if the CoreException indicates NOT_FOUND (or a 404-equivalent) keep the warn log and return Optional.empty(); for other statuses (e.g., INTERNAL_SERVER_ERROR, UNAUTHORIZED) rethrow or wrap and propagate the exception so transient/system errors are not silently ignored; update the catch block in toRankingProductInfo to inspect the CoreException status/code and only swallow NOT_FOUND while logging and rethrowing others.apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java (1)
48-91: 경계값 테스트 추가를 고려할 수 있다.현재 테스트는 정상 시나리오(page=1, size=2)만 검증한다.
page또는size가 0 이하일 때의 동작, 요청 크기가 전체 데이터보다 클 때의 동작 등 경계값 테스트가 없다. 운영 환경에서 잘못된 파라미터가 전달될 경우 예상치 못한 동작이 발생할 수 있다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java` around lines 48 - 91, Add boundary tests in RankingFacadeTest: create new `@Test` methods (e.g., getRankings_invalidPageOrSize_throws and getRankings_sizeOrPageBeyondData_returnsExpected) that call rankingFacade.getRankings with page <= 0 and size <= 0 and assert the expected behavior (either an exception or validation response your service uses), and another test where size is larger than total entries or page requests beyond the last page and assert it returns only available items (or empty list) and correct page/size metadata; use the same helpers/fixtures (RankingKeyGenerator.keyOf, rankingRepository.addScore, stubProduct, and given(productFacade.getActiveProduct(...))) to set up data and verify results from RankingFacade.getRankings.apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingScoreUpdaterTest.java (1)
41-100: 테스트가 하드코딩된 가중치 값에 의존한다.테스트 assertion에서
0.1,0.2,0.7등의 가중치를 직접 사용한다.application.yml의ranking.weight.*설정이 변경되면 테스트가 실패할 수 있다. 설정 값을 주입받아 사용하거나, 테스트 프로파일에서 고정된 값을 명시적으로 설정하는 것이 좋다.설정 값 주입 방식 제안
`@Value`("${ranking.weight.view}") private double viewWeight; `@Value`("${ranking.weight.like}") private double likeWeight; `@Value`("${ranking.weight.order}") private double orderWeight; // 테스트에서 사용 assertThat(score).isEqualTo(viewWeight);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingScoreUpdaterTest.java` around lines 41 - 100, Tests in RankingScoreUpdaterTest rely on hardcoded weight literals (0.1, 0.2, 0.7) so they break if ranking.weight.* changes; update the test to read the actual weights from configuration (or a test profile) and use those variables in assertions: inject ranking weights into the test class (e.g., viewWeight, likeWeight, orderWeight) and replace every hardcoded literal in incrementView_addsViewWeight, incrementLike_addsLikeWeight, decrementLike_subtractsLikeWeight, incrementOrder_addsWeightedOrderScore, and multipleEvents_accumulatesScore assertions with expressions using those injected fields (and keep using rankingScoreUpdater and redisTemplate as before). Ensure the test context supplies fixed values via `@TestPropertySource` or test application.yml so assertions remain deterministic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java`:
- Around line 143-147: PaymentCompletedEventPayload is being published with a
null userId, reducing traceability; update the publication so userId is
populated: in PaymentFacade where outboxEventPublisher.publish(...) is called
(the call that creates PaymentCompletedEventPayload.of(payment.getId(),
payment.getOrderId(), null, ...)), obtain the userId from an available source
(prefer the syncPayment method parameter if present, otherwise read it from the
Payment entity or load it via the Order associated with payment) and pass that
userId into PaymentCompletedEventPayload.of; ensure Payment or Order accessors
(e.g., Payment.getUserId() or Order.getUserId()) are used/added if missing so
consumers receive the user identity.
In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`:
- Around line 51-60: In getProductRank in class RankingFacade: after obtaining
Long rank from rankingRepository.getRank(key, productId) and before constructing
new RankingInfo, defensively handle a possible null Double from
rankingRepository.getScore(key, productId) by checking if score == null and
substituting a safe default (e.g., 0.0) or otherwise converting it to a non-null
value, then pass that non-null Double into the RankingInfo constructor;
reference methods: getProductRank, RankingKeyGenerator.keyOf,
rankingRepository.getRank, rankingRepository.getScore, and class RankingInfo.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`:
- Around line 29-30: The controller currently accepts page and size without
validation; add minimal defensive checks in RankingV1Controller (the method
receiving `@RequestParam` page and size) to ensure page >= 1 and size >= 1 (and
optionally enforce an upper bound like size <= 100), and if invalid either throw
a ResponseStatusException(HttpStatus.BAD_REQUEST, "...") or convert to your
domain error by calling RankingFacade to throw a CoreException—this prevents
computing a negative offset ((page - 1) * size) and avoids passing bad values to
Redis.
In
`@apps/commerce-streamer/src/main/java/com/loopers/application/handler/ranking/RankingPaymentEventHandler.java`:
- Around line 32-38: The loop over OrderedProduct lacks null-safety for boxed
fields; before calling rankingScoreUpdater.incrementOrder, check
OrderedProduct.getPrice() and getQuantity() for null (or validate product
itself) and either skip the product or substitute safe defaults (e.g., price 0L,
quantity 0) to avoid auto-unboxing NPEs; update the loop in
RankingPaymentEventHandler to guard each OrderedProduct and only call
rankingScoreUpdater.incrementOrder(product.getProductId(), safePrice,
safeQuantity) when values are non-null or after applying the chosen default/skip
policy.
---
Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`:
- Around line 28-31: In RankingFacade.getRankings, validate the incoming page
and size before computing offset or calling rankingRepository.getTopRankings:
ensure page > 0 and size > 0 and if not throw an IllegalArgumentException (or
another appropriate runtime exception) with a clear message referencing the
invalid parameter; keep using RankingKeyGenerator.keyOf(date) and compute offset
only after validation so offset = (page - 1) * size cannot be negative or
otherwise invalid.
- Around line 41-49: The toRankingProductInfo(RankingEntry) currently catches
all CoreException from productFacade.getActiveProduct(...) and silently skips by
returning Optional.empty(), which hides transient errors; change the handler to
distinguish error types: if the CoreException indicates NOT_FOUND (or a
404-equivalent) keep the warn log and return Optional.empty(); for other
statuses (e.g., INTERNAL_SERVER_ERROR, UNAUTHORIZED) rethrow or wrap and
propagate the exception so transient/system errors are not silently ignored;
update the catch block in toRankingProductInfo to inspect the CoreException
status/code and only swallow NOT_FOUND while logging and rethrowing others.
In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java`:
- Around line 5-9: RankingPageResult의 공개 필드 items가 가변 List 참조를 노출해 불변성이 깨지므로
compact constructor를 추가해 null 검사 후 items = List.copyOf(items)로 방어적 복사를 적용하고,
items()가 변경 불가능한 리스트를 반환하도록 하세요; 또한 RankingPageResult 레코드의 compact constructor에서
items가 null일 때 NullPointerException을 던지도록 처리하고, 단위 테스트로 원본 리스트를 수정해도
RankingPageResult.items()에 반영되지 않는지와 반환 리스트가 수정 불가능한지 검증하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/dto/RankingV1Dto.java`:
- Around line 7-24: The DTO exposes score as Double which can reveal
floating-point artifacts when serialized; update the RankingResponse.record to
either use BigDecimal for score or round the double before mapping: change the
score field type in RankingResponse (or convert during the mapping in
RankingResponse.from) and transform info.score() with a precise representation
(e.g., BigDecimal.valueOf(info.score()).setScale(n, RoundingMode.HALF_UP) or
Double.valueOf(Math.round(info.score()*factor)/factor) for a chosen scale) so
JSON responses contain the desired fixed precision; adjust imports and any
callers accordingly (refer to RankingResponse record and the
from(RankingProductInfo) method and the info.score() call).
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.java`:
- Around line 29-30: Remove the path-level exclusion for "/api/v1/rankings" in
WebConfig and instead add an explicit method check inside LoginUserInterceptor
(e.g., in preHandle or equivalent) that only bypasses authentication for GET
requests to "/api/v1/rankings"; ensure other methods (POST/PUT/DELETE) to the
same path still require auth. Update or add an interceptor integration test that
asserts GET /api/v1/rankings is allowed without auth while a non-GET request to
/api/v1/rankings triggers the authentication flow.
In
`@apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java`:
- Around line 48-91: Add boundary tests in RankingFacadeTest: create new `@Test`
methods (e.g., getRankings_invalidPageOrSize_throws and
getRankings_sizeOrPageBeyondData_returnsExpected) that call
rankingFacade.getRankings with page <= 0 and size <= 0 and assert the expected
behavior (either an exception or validation response your service uses), and
another test where size is larger than total entries or page requests beyond the
last page and assert it returns only available items (or empty list) and correct
page/size metadata; use the same helpers/fixtures (RankingKeyGenerator.keyOf,
rankingRepository.addScore, stubProduct, and
given(productFacade.getActiveProduct(...))) to set up data and verify results
from RankingFacade.getRankings.
In
`@apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RedisRankingRepositoryTest.java`:
- Around line 113-121: The test suite lacks a boundary test for
RedisRankingRepositoryTest#getScore when the product is absent; add a new test
method (e.g., getScore_returnsNullWhenNotInRanking) in
RedisRankingRepositoryTest that calls rankingRepository.getScore(TEST_KEY, 999L)
(or another non-existent product id) and asserts the result is null using
assertThat(score).isNull(); ensure the new test has a descriptive `@DisplayName`
and `@Test` annotations to mirror existing tests.
- Around line 32-44: The setup currently seeds the ZSet in setUp() but only
cleans up in tearDown(); to ensure test isolation call
redisCleanUp.truncateAll() at the start of setUp() before calling
redisTemplate.opsForZSet().add(...) so any leftover data is removed; update the
setUp() method (and keep tearDown() as-is) to first invoke
redisCleanUp.truncateAll(), then add the TEST_KEY entries ("1","2","3","4","5")
with their scores.
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java`:
- Around line 103-114: response.getBody() is used without a null check in
RankingV1ApiE2ETest leading to possible NPE; before calling
response.getBody().data() add an explicit assertion that response.getBody() is
not null (e.g., assertThat(response.getBody()).isNotNull()) or chain assertions
to safely obtain data, then assign to items and continue the existing assertions
so the test fails with a clear message rather than an NPE; locate this in the
test method where response.getBody(), items, and RankingV1Dto.RankingResponse
are referenced.
In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScoreUpdater.java`:
- Around line 27-35: Add validation for the injected weight fields in
RankingScoreUpdater to prevent negative or invalid weights: implement a
`@PostConstruct` method named validateWeights() (or perform checks in the
constructor) that verifies viewWeight, likeWeight, and orderWeight are >= 0 (and
optionally within an expected max if you want, e.g., <= 1.0); if any check
fails, throw an IllegalStateException with a clear message like "랭킹 가중치는 0 이상이어야
합니다." to fail fast and surface config errors.
- Around line 55-60: The current incrementScore method runs
masterRedisTemplate.opsForZSet().incrementScore(...) and
masterRedisTemplate.expire(...) as two separate calls, which is not atomic;
change incrementScore to execute a single Redis Lua script (or RedisScript via
masterRedisTemplate.execute) that performs ZINCRBY and sets EXPIRE atomically
(using key from RankingKeyGenerator.keyOf(LocalDate.now(clock)) and member =
String.valueOf(productId)), or alternatively document/confirm that at-least-once
daily-key+TTL semantics are acceptable; update the method incrementScore to use
the Lua script approach (or add a clear comment referencing this tradeoff) so
readers can find the change.
In
`@apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingKeyGeneratorTest.java`:
- Around line 13-24: Add parameterized boundary tests in RankingKeyGeneratorTest
to cover date edge cases and format validation: create a `@ParameterizedTest` that
calls RankingKeyGenerator.keyOf(...) for dates like LocalDate.of(2025,1,1),
LocalDate.of(2025,12,31) (and the existing 2025-04-09) and assert each produced
key equals the expected string, and also add an assertion that the returned key
matches the fixed yyyyMMdd pattern (e.g., matches regex "^ranking:all:\\d{8}$")
to catch zero-padding/format regressions; reference the existing test method
keyOf_returnsKeyWithGivenDate and the static method RankingKeyGenerator.keyOf to
locate where to add these checks.
In
`@apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingScoreUpdaterTest.java`:
- Around line 41-100: Tests in RankingScoreUpdaterTest rely on hardcoded weight
literals (0.1, 0.2, 0.7) so they break if ranking.weight.* changes; update the
test to read the actual weights from configuration (or a test profile) and use
those variables in assertions: inject ranking weights into the test class (e.g.,
viewWeight, likeWeight, orderWeight) and replace every hardcoded literal in
incrementView_addsViewWeight, incrementLike_addsLikeWeight,
decrementLike_subtractsLikeWeight, incrementOrder_addsWeightedOrderScore, and
multipleEvents_accumulatesScore assertions with expressions using those injected
fields (and keep using rankingScoreUpdater and redisTemplate as before). Ensure
the test context supplies fixed values via `@TestPropertySource` or test
application.yml so assertions remain deterministic.
🪄 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
Run ID: 44554360-e893-4541-88bc-f1ea8328ecac
📒 Files selected for processing (31)
.gitignoreapps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductInfo.javaapps/commerce-api/src/main/java/com/loopers/config/TimeConfig.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingEntry.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingInfo.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RedisRankingRepository.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductV1Dto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/dto/RankingV1Dto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/config/WebConfig.javaapps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/domain/ranking/InMemoryRankingRepository.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RedisRankingRepositoryTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.javaapps/commerce-streamer/src/main/java/com/loopers/application/handler/ranking/RankingLikeEventHandler.javaapps/commerce-streamer/src/main/java/com/loopers/application/handler/ranking/RankingPaymentEventHandler.javaapps/commerce-streamer/src/main/java/com/loopers/application/handler/ranking/RankingUnlikeEventHandler.javaapps/commerce-streamer/src/main/java/com/loopers/application/handler/ranking/RankingViewEventHandler.javaapps/commerce-streamer/src/main/java/com/loopers/config/TimeConfig.javaapps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScoreUpdater.javaapps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingKeyGeneratorTest.javaapps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingScoreUpdaterTest.javamodules/event-contract/src/main/java/com/loopers/event/payload/OrderedProduct.javamodules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.javamodules/event-contract/src/main/java/com/loopers/event/ranking/RankingKeyGenerator.java
| outboxEventPublisher.publish( | ||
| EventType.PAYMENT_COMPLETED, | ||
| PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), null, productIds), | ||
| PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), null, productIds, orderedProducts), | ||
| payment.getOrderId() | ||
| ); |
There was a problem hiding this comment.
userId가 null로 전달되어 이벤트 추적성이 저하된다.
PaymentCompletedEventPayload.of(...) 호출 시 세 번째 파라미터 userId에 null이 전달된다. 결제 완료 이벤트에서 사용자 식별 정보가 누락되면 consumer 측에서 사용자별 분석이나 알림 발송이 불가능하다. handleCallback은 외부 PG 콜백이라 userId 컨텍스트가 없지만, syncPayment는 userId를 파라미터로 받고 있으므로 활용 가능하다.
의도적 설계인지 확인하고, 필요하다면 Payment 엔티티에 userId를 저장하거나 Order에서 조회하는 방안을 검토해야 한다.
#!/bin/bash
# Description: PaymentCompletedEventPayload의 userId 필드가 consumer에서 사용되는지 확인
rg -n "getUserId\(\)" --type=java -g '**/commerce-streamer/**'🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java`
around lines 143 - 147, PaymentCompletedEventPayload is being published with a
null userId, reducing traceability; update the publication so userId is
populated: in PaymentFacade where outboxEventPublisher.publish(...) is called
(the call that creates PaymentCompletedEventPayload.of(payment.getId(),
payment.getOrderId(), null, ...)), obtain the userId from an available source
(prefer the syncPayment method parameter if present, otherwise read it from the
Payment entity or load it via the Order associated with payment) and pass that
userId into PaymentCompletedEventPayload.of; ensure Payment or Order accessors
(e.g., Payment.getUserId() or Order.getUserId()) are used/added if missing so
consumers receive the user identity.
| public RankingInfo getProductRank(Long productId) { | ||
| LocalDate today = LocalDate.now(clock); | ||
| String key = RankingKeyGenerator.keyOf(today); | ||
| Long rank = rankingRepository.getRank(key, productId); | ||
| if (rank == null) { | ||
| return null; | ||
| } | ||
| Double score = rankingRepository.getScore(key, productId); | ||
| return new RankingInfo(rank + 1, score); | ||
| } |
There was a problem hiding this comment.
getScore 결과가 null인 경우에 대한 방어 로직이 없다.
getRank()가 값을 반환하더라도 getScore()가 null을 반환할 가능성이 있다 (Redis 키가 존재하지만 score 조회 시점에 만료되는 race condition 등). 현재 코드는 score가 null이면 RankingInfo에 null이 그대로 전달된다. 운영 환경에서 클라이언트가 null score를 처리하지 못하면 문제가 발생할 수 있다.
방어 로직 추가 제안
public RankingInfo getProductRank(Long productId) {
LocalDate today = LocalDate.now(clock);
String key = RankingKeyGenerator.keyOf(today);
Long rank = rankingRepository.getRank(key, productId);
if (rank == null) {
return null;
}
Double score = rankingRepository.getScore(key, productId);
+ if (score == null) {
+ log.warn("랭킹 점수 조회 실패 [productId={}, key={}]: rank는 존재하나 score가 없음", productId, key);
+ return null;
+ }
return new RankingInfo(rank + 1, score);
}📝 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.
| public RankingInfo getProductRank(Long productId) { | |
| LocalDate today = LocalDate.now(clock); | |
| String key = RankingKeyGenerator.keyOf(today); | |
| Long rank = rankingRepository.getRank(key, productId); | |
| if (rank == null) { | |
| return null; | |
| } | |
| Double score = rankingRepository.getScore(key, productId); | |
| return new RankingInfo(rank + 1, score); | |
| } | |
| public RankingInfo getProductRank(Long productId) { | |
| LocalDate today = LocalDate.now(clock); | |
| String key = RankingKeyGenerator.keyOf(today); | |
| Long rank = rankingRepository.getRank(key, productId); | |
| if (rank == null) { | |
| return null; | |
| } | |
| Double score = rankingRepository.getScore(key, productId); | |
| if (score == null) { | |
| log.warn("랭킹 점수 조회 실패 [productId={}, key={}]: rank는 존재하나 score가 없음", productId, key); | |
| return null; | |
| } | |
| return new RankingInfo(rank + 1, score); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`
around lines 51 - 60, In getProductRank in class RankingFacade: after obtaining
Long rank from rankingRepository.getRank(key, productId) and before constructing
new RankingInfo, defensively handle a possible null Double from
rankingRepository.getScore(key, productId) by checking if score == null and
substituting a safe default (e.g., 0.0) or otherwise converting it to a non-null
value, then pass that non-null Double into the RankingInfo constructor;
reference methods: getProductRank, RankingKeyGenerator.keyOf,
rankingRepository.getRank, rankingRepository.getScore, and class RankingInfo.
| @RequestParam(defaultValue = "20") int size, | ||
| @RequestParam(defaultValue = "1") int page |
There was a problem hiding this comment.
page와 size 파라미터 유효성 검증 부재.
page=0 또는 음수 입력 시 offset = (page - 1) * size가 음수가 되어 Redis ZREVRANGE의 예상치 못한 동작을 유발할 수 있다. size=0이나 음수도 마찬가지다.
운영 환경에서 잘못된 파라미터가 전달되면 빈 결과 또는 Redis 오류로 이어질 수 있다. PR 목표에 파라미터 검증이 향후 작업으로 언급되어 있으나, 최소한의 방어 로직은 필요하다.
제안: 최소 유효성 검증 추가
`@GetMapping`
public ApiResponse<List<RankingV1Dto.RankingResponse>> getRankings(
`@RequestParam`(required = false) `@DateTimeFormat`(pattern = "yyyyMMdd") LocalDate date,
`@RequestParam`(defaultValue = "20") int size,
`@RequestParam`(defaultValue = "1") int page
) {
+ if (page < 1) page = 1;
+ if (size < 1 || size > 100) size = 20;
LocalDate targetDate = (date != null) ? date : LocalDate.now(clock);또는 도메인 레이어(RankingFacade)에서 CoreException을 던지는 방식으로 처리할 수 있다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`
around lines 29 - 30, The controller currently accepts page and size without
validation; add minimal defensive checks in RankingV1Controller (the method
receiving `@RequestParam` page and size) to ensure page >= 1 and size >= 1 (and
optionally enforce an upper bound like size <= 100), and if invalid either throw
a ResponseStatusException(HttpStatus.BAD_REQUEST, "...") or convert to your
domain error by calling RankingFacade to throw a CoreException—this prevents
computing a negative offset ((page - 1) * size) and avoids passing bad values to
Redis.
| for (OrderedProduct product : orderedProducts) { | ||
| rankingScoreUpdater.incrementOrder( | ||
| product.getProductId(), | ||
| product.getPrice(), | ||
| product.getQuantity() | ||
| ); | ||
| } |
There was a problem hiding this comment.
OrderedProduct 필드의 null 값에 대한 방어 코드 부재.
OrderedProduct의 price(Long)와 quantity(Integer)가 null인 경우, RankingScoreUpdater.incrementOrder(Long, long, int) 호출 시 auto-unboxing 과정에서 NullPointerException이 발생한다. 운영 환경에서 데이터 정합성 이슈로 null 값이 유입되면 해당 이벤트 처리 전체가 실패하게 된다.
PaymentFacade에서 OrderedProduct.of()를 생성할 때 값이 항상 존재한다면 문제없으나, Kafka 메시지 역직렬화 과정에서 필드가 누락되면 null이 될 수 있다.
제안: null 체크 추가
for (OrderedProduct product : orderedProducts) {
+ if (product.getProductId() == null || product.getPrice() == null || product.getQuantity() == null) {
+ continue; // 또는 로깅 후 skip
+ }
rankingScoreUpdater.incrementOrder(
product.getProductId(),
product.getPrice(),
product.getQuantity()
);
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-streamer/src/main/java/com/loopers/application/handler/ranking/RankingPaymentEventHandler.java`
around lines 32 - 38, The loop over OrderedProduct lacks null-safety for boxed
fields; before calling rankingScoreUpdater.incrementOrder, check
OrderedProduct.getPrice() and getQuantity() for null (or validate product
itself) and either skip the product or substitute safe defaults (e.g., price 0L,
quantity 0) to avoid auto-unboxing NPEs; update the loop in
RankingPaymentEventHandler to guard each OrderedProduct and only call
rankingScoreUpdater.incrementOrder(product.getProductId(), safePrice,
safeQuantity) when values are non-null or after applying the chosen default/skip
policy.
Round 9 - Redis ZSET 기반 실시간 랭킹 시스템
📌 Summary
product_metrics에 집계하는 구조를 구축했습니다. Round 8에서는 Redis Sorted Set을 대기열 순서 관리에 활용했습니다. 이번 라운드에서는 이 둘을 결합하여, 이벤트 기반 실시간 랭킹 시스템을 구축합니다.ranking:all:{yyyyMMdd})과 TTL 2일로 시간 윈도우를 관리하며, 가중치(view=0.1, like=0.2, order=0.7)를 설정값으로 외부화하여 운영 중 조절 가능합니다.🧭 Context & Decision
① 왜 DB ORDER BY가 아닌 Redis ZSET인가
랭킹 API는 홈 메인, 인기 상품 목록 등에서 높은 빈도로 호출됩니다. DB
GROUP BY + ORDER BY는 데이터가 쌓일수록 느려지고, 조회 빈도가 높아 DB 과부하로 이어질 수 있습니다.ZSET은
(member, score)쌍을 score 기준으로 정렬된 상태로 유지하므로, Top-N 조회(ZREVRANGE), 개별 순위 조회(ZREVRANK), 점수 갱신(ZINCRBY) 모두 효율적입니다.② 점수 반영 방식 — ZINCRBY (증분 갱신)
ZADD로 전체 재계산하는 대신, 이벤트가 발생할 때마다
ZINCRBY로 해당 가중치만큼 증분합니다.@Value로 외부화하여 운영 중 변경 가능주문의 경우
price × quantity × weight로 계산하여, 실제 결제 금액이 랭킹에 반영됩니다. 이를 위해PaymentCompletedEventPayload에 상품별price/quantity를 추가했습니다.③ 상품 정보 조합 — 기존 캐시 활용
ZSET에는
productId만 저장하고, API 응답 시productFacade.getActiveProduct()로 상품 정보를 조합합니다. 상품 상세는 이미@Cacheable로 캐시되어 있어 추가 DB 조회가 거의 발생하지 않습니다.삭제/비활성 상품 방어: 랭킹은 비정규화 데이터이므로 stale entry가 존재할 수 있습니다.
getActiveProduct()실패 시 해당 항목을 skip하고 나머지만 반환하여, 한 항목의 문제로 전체 API가 깨지지 않도록 처리했습니다.④ EventHandler 패턴 활용
기존
EventProcessingService가supports()매칭되는 모든 핸들러를 실행하므로, 랭킹 전용 핸들러를 추가하면 기존 metrics 핸들러와 함께 자동으로 동작합니다. Consumer를 새로 만들 필요 없이 핸들러만 등록하면 됩니다.RankingViewEventHandlerRankingLikeEventHandlerRankingUnlikeEventHandlerRankingPaymentEventHandler🔁 Flow Diagram
전체 흐름
이벤트 → ZSET 갱신 → API 조회
sequenceDiagram participant U as User participant API as commerce-api participant K as Kafka participant S as commerce-streamer participant R as Redis (ZSET) U->>API: 상품 조회/좋아요/주문 API->>K: 이벤트 발행 K->>S: 이벤트 소비 S->>R: ZINCRBY ranking:all:{yyyyMMdd} {score} {productId} U->>API: GET /api/v1/rankings API->>R: ZREVRANGE ranking:all:{yyyyMMdd} 0 N WITHSCORES R-->>API: [(productId, score), ...] API-->>U: 랭킹 + 상품 정보 응답🧪 Verification
단위 테스트
RankingKeyGeneratorTest— 날짜별 키 생성 검증RankingScoreUpdaterTest— 이벤트 타입별 ZINCRBY 동작, 점수 누적, TTL 설정 검증RankingFacadeTest— 랭킹 페이지 조회(점수 순), 빈 랭킹 처리, 개별 상품 순위(1-based), 미등록 상품 null 반환, 삭제 상품 skip 처리통합 테스트
RedisRankingRepositoryTest—@SpringBootTest+ TestContainers로 Redis ZSET 실제 동작 검증E2E 테스트
RankingV1ApiE2ETest—@SpringBootTest(RANDOM_PORT)+ TestContainers⏭ 미구현 / 향후 과제
ZUNIONSTORE× 0.1로 전날 점수 일부 복사✅ Checklist
📈 Ranking Consumer
⚾ Ranking API
🧪 검증
✅ 변경 범위
새로 생성한 파일
payload/OrderedProduct.javaranking/RankingKeyGenerator.javadomain/ranking/RankingScoreUpdater.javahandler/ranking/RankingViewEventHandler.javahandler/ranking/RankingLikeEventHandler.javahandler/ranking/RankingUnlikeEventHandler.javahandler/ranking/RankingPaymentEventHandler.javaconfig/TimeConfig.javadomain/ranking/RankingRepository.javadomain/ranking/RankingEntry.javadomain/ranking/RankingInfo.javainfrastructure/ranking/RedisRankingRepository.javaapplication/ranking/RankingFacade.javaapplication/ranking/RankingPageResult.javaapplication/ranking/RankingProductInfo.javainterfaces/api/ranking/RankingV1Controller.javainterfaces/api/ranking/dto/RankingV1Dto.javaconfig/TimeConfig.java수정한 파일
PaymentCompletedEventPayloadorderedProducts필드 추가 (상품별 결제 금액)PaymentFacadeOrderedProduct매핑 추가ProductV1ControllerProductV1DtoRankingSummary필드 추가WebConfig/api/v1/rankings인증 인터셉터 제외Redis ZSET 기반 실시간 랭킹 시스템 구현
변경 목적: Kafka 이벤트(상품 조회, 좋아요/취소, 결제 완료)로부터 실시간으로 Redis 정렬 집합(ZSET)을 갱신하여 일일 랭킹을 계산하고, API를 통해 상품별/페이지별 랭킹 조회 기능 제공.
핵심 변경점:
리스크/주의사항:
테스트/검증 방법: