Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.ranking.MvProductRankMonthly;
import com.loopers.domain.ranking.MvProductRankMonthlyRepository;
import com.loopers.domain.ranking.MvProductRankWeekly;
import com.loopers.domain.ranking.MvProductRankWeeklyRepository;
import com.loopers.infrastructure.ranking.RankingRedisRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.ZSetOperations;
Expand All @@ -23,16 +27,29 @@ public class RankingFacade {

private final RankingRedisRepository rankingRedisRepository;
private final ProductService productService;
private final MvProductRankWeeklyRepository mvWeeklyRepository;
private final MvProductRankMonthlyRepository mvMonthlyRepository;

/**
* 날짜 기반 랭킹 목록을 페이징으로 조회한다.
* - ZSET에서 상위 N개 productId + score 조회
* - productService.findAllByIds()로 일괄 IN 조회 (N+1 방지)
* - ZSET에 있지만 삭제된 상품은 결과에서 제외
* period(daily|weekly|monthly) 기반으로 랭킹 목록을 조회한다.
* - daily (기본값): Redis ZSET 조회 (date 파라미터 기반)
* - weekly: mv_product_rank_weekly 조회
* - monthly: mv_product_rank_monthly 조회
*
* @param date yyyyMMdd 형식. null 또는 빈 값이면 오늘 날짜 사용.
* @param period "daily" | "weekly" | "monthly". null 또는 기타 값이면 "daily" 로 처리.
* @param date yyyyMMdd 형식. period=daily 일 때만 사용. null 이면 오늘.
*/
public List<RankingInfo> findRankings(String date, int page, int size) {
public List<RankingInfo> findRankings(String period, String date, int page, int size) {
if ("weekly".equalsIgnoreCase(period)) {
return findWeeklyRankings(page, size);
}
if ("monthly".equalsIgnoreCase(period)) {
return findMonthlyRankings(page, size);
}
return findDailyRankings(date, page, size);
}

private List<RankingInfo> findDailyRankings(String date, int page, int size) {
LocalDate targetDate = (date == null || date.isBlank())
? LocalDate.now()
: LocalDate.parse(date, DATE_FORMATTER);
Expand Down Expand Up @@ -63,4 +80,48 @@ public List<RankingInfo> findRankings(String date, int page, int size) {
}
return result;
}

private List<RankingInfo> findWeeklyRankings(int page, int size) {
List<MvProductRankWeekly> rows = mvWeeklyRepository.findTop(page, size);
if (rows.isEmpty()) {
return List.of();
}
List<Long> productIds = rows.stream().map(MvProductRankWeekly::getProductId).toList();
Map<Long, Product> productMap = productService.findAllByIds(productIds).stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));

List<RankingInfo> result = new ArrayList<>();
int baseRank = page * size + 1;
for (int i = 0; i < rows.size(); i++) {
MvProductRankWeekly row = rows.get(i);
Product product = productMap.get(row.getProductId());
if (product == null) {
continue;
}
result.add(RankingInfo.of(baseRank + i, product, row.getScore()));
}
return result;
}

private List<RankingInfo> findMonthlyRankings(int page, int size) {
List<MvProductRankMonthly> rows = mvMonthlyRepository.findTop(page, size);
if (rows.isEmpty()) {
return List.of();
}
List<Long> productIds = rows.stream().map(MvProductRankMonthly::getProductId).toList();
Map<Long, Product> productMap = productService.findAllByIds(productIds).stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));

List<RankingInfo> result = new ArrayList<>();
int baseRank = page * size + 1;
for (int i = 0; i < rows.size(); i++) {
MvProductRankMonthly row = rows.get(i);
Product product = productMap.get(row.getProductId());
if (product == null) {
continue;
}
result.add(RankingInfo.of(baseRank + i, product, row.getScore()));
}
return result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,24 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;
import lombok.Getter;

import java.time.LocalDate;

/**
* 상품 집계 지표 (product_metrics).
* like_count는 Kafka 이벤트 소비 후 비동기로 갱신된다 (Eventual Consistency).
* commerce-api: 읽기 전용 조회 (likeCount 표시용)
* commerce-streamer: 쓰기 (Kafka Consumer가 upsert)
* 일별 상품 집계 지표 (product_metrics).
* like_count / order_count 는 해당 날짜(date)의 순증감을 기록한다.
* commerce-api: 읽기 전용 조회 (오늘 날짜 기준 likeCount 표시용)
* commerce-streamer: 쓰기 (Kafka Consumer가 (product_id, date) 기준 upsert)
*/
@Getter
@Entity
@Table(name = "product_metrics")
@Table(
name = "product_metrics",
uniqueConstraints = @UniqueConstraint(columnNames = {"product_id", "date"})
)
public class ProductMetrics {

@Id
Expand All @@ -27,9 +33,12 @@ public class ProductMetrics {
@Version
private Long version;

@Column(name = "product_id", nullable = false, unique = true)
@Column(name = "product_id", nullable = false)
private Long productId;

@Column(name = "date", nullable = false)
private LocalDate date;

@Column(name = "like_count", nullable = false)
private int likeCount;

Expand All @@ -39,8 +48,9 @@ public class ProductMetrics {
protected ProductMetrics() {
}

public ProductMetrics(Long productId, int likeCount) {
public ProductMetrics(Long productId, LocalDate date, int likeCount) {
this.productId = productId;
this.date = date;
this.likeCount = likeCount;
Comment on lines +51 to 54
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 | 🟡 Minor

생성자에서 productId/date null 방어가 없어 장애 전파 지점이 늦다.

운영 관점에서 잘못된 이벤트/호출이 들어오면 DB flush 시점에야 실패해 소비자 재시도 누적으로 지연이 커질 수 있다. Line 51-54에서 생성 즉시 null 검증으로 fail-fast 처리하는 것이 안전하다. 추가 테스트로 new ProductMetrics(null, today, 0)new ProductMetrics(productId, null, 0)가 즉시 예외를 던지는지 검증해야 한다.

수정 예시
+import java.util.Objects;
+
 public ProductMetrics(Long productId, LocalDate date, int likeCount) {
-    this.productId = productId;
-    this.date = date;
+    this.productId = Objects.requireNonNull(productId, "productId must not be null");
+    this.date = Objects.requireNonNull(date, "date must not be null");
     this.likeCount = likeCount;
     this.orderCount = 0;
 }

As per coding guidelines **/*.java: 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 ProductMetrics(Long productId, LocalDate date, int likeCount) {
this.productId = productId;
this.date = date;
this.likeCount = likeCount;
public ProductMetrics(Long productId, LocalDate date, int likeCount) {
this.productId = Objects.requireNonNull(productId, "productId must not be null");
this.date = Objects.requireNonNull(date, "date must not be null");
this.likeCount = likeCount;
🤖 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/domain/product/ProductMetrics.java`
around lines 51 - 54, 생성자 ProductMetrics(Long productId, LocalDate date, int
likeCount)에서 productId와 date에 대한 즉시 null 방어가 없어 실패가 지연되므로, 생성 시작 시 두 파라미터를 체크하고
null이면 명시적인 런타임 예외(예: IllegalArgumentException)를 던지도록 수정하세요; 예외 메시지는 어떤 필드가
null인지 명확히 표기하고 호출 지점이 쉽게 추적되게 하며, 이 동작을 검증하는 단위 테스트로 new ProductMetrics(null,
today, 0) 및 new ProductMetrics(productId, null, 0)가 즉시 예외를 던지는지를 추가하세요.

this.orderCount = 0;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.loopers.domain.ranking;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;

import java.time.LocalDateTime;

/**
* 월간 랭킹 Materialized View (mv_product_rank_monthly).
* Spring Batch MonthlyRankingJob 이 집계한 결과를 조회 전용으로 사용한다.
*/
@Getter
@Entity
@Table(name = "mv_product_rank_monthly")
public class MvProductRankMonthly {

@Id
@Column(name = "product_id")
private Long productId;

@Column(name = "like_count", nullable = false)
private int likeCount;

@Column(name = "order_count", nullable = false)
private int orderCount;

@Column(name = "score", nullable = false)
private double score;

@Column(name = "ranking_period", nullable = false)
private String rankingPeriod;

@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;

protected MvProductRankMonthly() {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.loopers.domain.ranking;

import java.util.List;

public interface MvProductRankMonthlyRepository {
List<MvProductRankMonthly> findTop(int page, int size);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.loopers.domain.ranking;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;

import java.time.LocalDateTime;

/**
* 주간 랭킹 Materialized View (mv_product_rank_weekly).
* Spring Batch WeeklyRankingJob 이 집계한 결과를 조회 전용으로 사용한다.
*/
@Getter
@Entity
@Table(name = "mv_product_rank_weekly")
public class MvProductRankWeekly {

@Id
@Column(name = "product_id")
private Long productId;

@Column(name = "like_count", nullable = false)
private int likeCount;

@Column(name = "order_count", nullable = false)
private int orderCount;

@Column(name = "score", nullable = false)
private double score;

@Column(name = "year_month_week", nullable = false)
private String yearMonthWeek;

@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;

protected MvProductRankWeekly() {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.loopers.domain.ranking;

import java.util.List;

public interface MvProductRankWeeklyRepository {
List<MvProductRankWeekly> findTop(int page, int size);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
import com.loopers.domain.product.ProductMetrics;
import org.springframework.data.jpa.repository.JpaRepository;

import java.time.LocalDate;
import java.util.Optional;

public interface ProductMetricsJpaRepository extends JpaRepository<ProductMetrics, Long> {
Optional<ProductMetrics> findByProductId(Long productId);
Optional<ProductMetrics> findByProductIdAndDate(Long productId, LocalDate date);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.util.Optional;

@RequiredArgsConstructor
Expand All @@ -15,6 +16,6 @@ public class ProductMetricsRepositoryImpl implements ProductMetricsRepository {

@Override
public Optional<ProductMetrics> findByProductId(Long productId) {
return productMetricsJpaRepository.findByProductId(productId);
return productMetricsJpaRepository.findByProductIdAndDate(productId, LocalDate.now());
}
Comment on lines 18 to 20
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 | 🟠 Major

LocalDate.now() 사용으로 타임존 불일치 문제가 발생할 수 있다.

LocalDate.now()는 JVM 기본 타임존을 사용하지만, LikeEventConsumerZoneId.of("Asia/Seoul")을 명시적으로 사용한다. 서버 타임존이 UTC인 경우:

  • 문제 시나리오: KST 00:30에 Streamer가 2026-04-16 날짜로 데이터 저장 → API 서버(UTC)는 2026-04-15로 조회 → 좋아요 수 0 반환
  • 운영 영향: 자정 전후 시간대에 좋아요 수가 갑자기 0으로 표시되는 현상 발생
🐛 수정안: 명시적 타임존 사용
+import java.time.ZoneId;
+
 `@RequiredArgsConstructor`
 `@Component`
 public class ProductMetricsRepositoryImpl implements ProductMetricsRepository {
+    private static final ZoneId RANKING_ZONE = ZoneId.of("Asia/Seoul");

     private final ProductMetricsJpaRepository productMetricsJpaRepository;

     `@Override`
     public Optional<ProductMetrics> findByProductId(Long productId) {
-        return productMetricsJpaRepository.findByProductIdAndDate(productId, LocalDate.now());
+        return productMetricsJpaRepository.findByProductIdAndDate(productId, LocalDate.now(RANKING_ZONE));
     }
 }

추가 테스트 권장: 타임존 경계 시간대(KST 00:00-01:00)에서의 동작을 검증하는 테스트 케이스를 추가한다.

📝 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 Optional<ProductMetrics> findByProductId(Long productId) {
return productMetricsJpaRepository.findByProductId(productId);
return productMetricsJpaRepository.findByProductIdAndDate(productId, LocalDate.now());
}
import java.time.ZoneId;
`@RequiredArgsConstructor`
`@Component`
public class ProductMetricsRepositoryImpl implements ProductMetricsRepository {
private static final ZoneId RANKING_ZONE = ZoneId.of("Asia/Seoul");
private final ProductMetricsJpaRepository productMetricsJpaRepository;
`@Override`
public Optional<ProductMetrics> findByProductId(Long productId) {
return productMetricsJpaRepository.findByProductIdAndDate(productId, LocalDate.now(RANKING_ZONE));
}
}
🤖 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/infrastructure/product/ProductMetricsRepositoryImpl.java`
around lines 18 - 20, The method findByProductId in ProductMetricsRepositoryImpl
uses LocalDate.now() which can produce a different date than LikeEventConsumer
(which uses ZoneId.of("Asia/Seoul")), causing off-by-one-day lookups across
timezones; change the date computation to use the explicit KST zone (e.g.,
LocalDate.now(ZoneId.of("Asia/Seoul")) or inject a ZoneId/Clock configured to
"Asia/Seoul") and pass that date to
productMetricsJpaRepository.findByProductIdAndDate(productId, date); also add
tests exercising the KST midnight boundary (e.g., times around 00:00–01:00 KST)
to verify correct behavior.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.MvProductRankMonthly;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MvProductRankMonthlyJpaRepository extends JpaRepository<MvProductRankMonthly, Long> {
Page<MvProductRankMonthly> findAllByOrderByScoreDesc(Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.MvProductRankMonthly;
import com.loopers.domain.ranking.MvProductRankMonthlyRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;

import java.util.List;

@RequiredArgsConstructor
@Component
public class MvProductRankMonthlyRepositoryImpl implements MvProductRankMonthlyRepository {

private final MvProductRankMonthlyJpaRepository jpaRepository;

@Override
public List<MvProductRankMonthly> findTop(int page, int size) {
return jpaRepository.findAllByOrderByScoreDesc(PageRequest.of(page, size)).getContent();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.MvProductRankWeekly;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MvProductRankWeeklyJpaRepository extends JpaRepository<MvProductRankWeekly, Long> {
Page<MvProductRankWeekly> findAllByOrderByScoreDesc(Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.MvProductRankWeekly;
import com.loopers.domain.ranking.MvProductRankWeeklyRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;

import java.util.List;

@RequiredArgsConstructor
@Component
public class MvProductRankWeeklyRepositoryImpl implements MvProductRankWeeklyRepository {

private final MvProductRankWeeklyJpaRepository jpaRepository;

@Override
public List<MvProductRankWeekly> findTop(int page, int size) {
return jpaRepository.findAllByOrderByScoreDesc(PageRequest.of(page, size)).getContent();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,29 @@ public class RankingV1Controller {
private final RankingFacade rankingFacade;

/**
* 날짜 기반 상품 랭킹 목록을 페이징으로 조회한다.
* - date 미입력 시 오늘 날짜 기준
* 기간(period) 기반 상품 랭킹 목록을 페이징으로 조회한다.
* - period: "daily"(기본) | "weekly" | "monthly"
* - date: yyyyMMdd 형식. period=daily 일 때 사용. 미입력 시 오늘 날짜.
* - page는 0-based
* - 인증 불필요 (공개 API)
*/
@GetMapping
public ApiResponse<RankingV1Dto.RankingPageResponse> getRankings(
@RequestParam(required = false) String period,
@RequestParam(required = false) String date,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
String targetDate = (date == null || date.isBlank())
? LocalDate.now().format(DATE_FORMATTER)
: date;
String resolvedPeriod = (period == null || period.isBlank()) ? "daily" : period;
String targetDate = "daily".equalsIgnoreCase(resolvedPeriod)
? (date == null || date.isBlank() ? LocalDate.now().format(DATE_FORMATTER) : date)
: null;

List<RankingInfo> rankings = rankingFacade.findRankings(targetDate, page, size);
List<RankingInfo> rankings = rankingFacade.findRankings(resolvedPeriod, targetDate, page, size);
List<RankingV1Dto.RankingResponse> content = rankings.stream()
.map(RankingV1Dto.RankingResponse::from)
.toList();

return ApiResponse.success(new RankingV1Dto.RankingPageResponse(content, targetDate, page, size));
return ApiResponse.success(new RankingV1Dto.RankingPageResponse(content, resolvedPeriod, targetDate, page, size));
Comment on lines +39 to +49
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 | 🟠 Major

비정상 period 값이 실제 조회 경로와 응답 본문을 어긋나게 만든다.

period=foo면 Line 40-42에서 targetDatenull이 되지만, Line 44는 RankingFacade의 daily fallback으로 조회된다. 결과적으로 실제 데이터는 daily인데 응답의 period"foo"이고 date도 비어 있어 캐시 키, 클라이언트 분기, 운영 지표가 잘못 분류될 수 있다. weekly/monthly만 허용하고 나머지는 "daily"로 정규화해야 한다. period=fooperiod=foo&date=20260401 케이스를 추가해 응답 필드와 실제 조회 결과가 항상 일치하는지 E2E로 검증하는 편이 안전하다.

As per coding guidelines **/*Controller*.java: Controller는 요청 검증과 응답 조립에 집중한다.

🤖 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 39 - 49, Normalize and validate the incoming period in
RankingV1Controller: replace the current resolvedPeriod assignment so only
"weekly" and "monthly" are allowed; any other value (including null/blank) must
be normalized to "daily" before computing targetDate and before calling
rankingFacade.findRankings, and compute targetDate only when resolvedPeriod
equals "daily" (use LocalDate.now() if date is missing); ensure the same
normalized resolvedPeriod and computed targetDate are used in the ApiResponse so
response fields always match the actual lookup (symbols: resolvedPeriod,
targetDate, rankingFacade.findRankings, RankingV1Controller), and add E2E tests
for period=foo and period=foo&date=20260401 to assert response.period/date align
with the lookup.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public static RankingResponse from(RankingInfo info) {

public record RankingPageResponse(
List<RankingResponse> content,
String period,
String date,
int page,
int size
Expand Down
Loading