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
@@ -0,0 +1,54 @@
package com.loopers.application.ranking;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;

import com.loopers.application.shared.annotation.UseCase;
import com.loopers.domain.ranking.ProductRankingMonthly;
import com.loopers.domain.ranking.RankingItem;
import com.loopers.domain.ranking.RankingService;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import com.loopers.support.page.PageSize;

import lombok.RequiredArgsConstructor;

/**
* 월간 인기 상품 랭킹을 페이지 단위로 조회한다.
*
* <p>배치가 집계한 지정 scoreDate의 월간 랭킹을 반환한다.</p>
*/
@UseCase
@RequiredArgsConstructor
public class ReadMonthlyRankingsUseCase {

private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");

private final RankingService rankingService;
private final RankingResultAssembler rankingResultAssembler;

/**
* @param userId 사용자 ID (비로그인 시 null)
* @param date 조회 기준일 (yyyyMMdd 형식)
* @param pageSize 페이지 정보
* @return 랭킹 페이지 결과 (상품 정보, brandId, 좋아요 여부 포함)
*/
public RankingPageResult execute(Long userId, String date, PageSize pageSize) {
LocalDate scoreDate;
try {
scoreDate = LocalDate.parse(date, DATE_FORMAT);
} catch (DateTimeParseException e) {
throw new CoreException(ErrorType.INVALID_RANKING_DATE_FORMAT);
}
List<ProductRankingMonthly> rankings = rankingService.readMonthlyTopRanked(scoreDate, pageSize.page(), pageSize.size());
List<RankingItem> rankingItems = RankingItem.toRankingItems(
rankings,
pageSize.offset(),
ProductRankingMonthly::getProductId,
ProductRankingMonthly::getScore
);
return rankingResultAssembler.assemble(userId, rankingItems, pageSize);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.loopers.application.ranking;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;

import com.loopers.application.shared.annotation.UseCase;
import com.loopers.domain.ranking.ProductRankingWeekly;
import com.loopers.domain.ranking.RankingItem;
import com.loopers.domain.ranking.RankingService;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import com.loopers.support.page.PageSize;

import lombok.RequiredArgsConstructor;

/**
* 주간 인기 상품 랭킹을 페이지 단위로 조회한다.
*
* <p>배치가 집계한 지정 scoreDate의 주간 랭킹을 반환한다.</p>
*/
@UseCase
@RequiredArgsConstructor
public class ReadWeeklyRankingsUseCase {

private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");

private final RankingService rankingService;
private final RankingResultAssembler rankingResultAssembler;

/**
* @param userId 사용자 ID (비로그인 시 null)
* @param date 조회 기준일 (yyyyMMdd 형식)
* @param pageSize 페이지 정보
* @return 랭킹 페이지 결과 (상품 정보, brandId, 좋아요 여부 포함)
*/
public RankingPageResult execute(Long userId, String date, PageSize pageSize) {
LocalDate scoreDate;
try {
scoreDate = LocalDate.parse(date, DATE_FORMAT);
} catch (DateTimeParseException e) {
throw new CoreException(ErrorType.INVALID_RANKING_DATE_FORMAT);
}
List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked(
scoreDate, pageSize.page(), pageSize.size());
Comment on lines +38 to +46
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

LocalDate.parse 실패가 CoreException으로 래핑되지 않아 응답 일관성이 깨진다.

잘못된 date 쿼리 파라미터(예: date=2026-04-14, date=abcd)가 들어오면 DateTimeParseException이 그대로 전파되어 ApiControllerAdvice의 표준 에러 포맷을 벗어난 500 응답이 반환될 위험이 있다. 리포지토리 학습에 따르면 commerce-api는 모든 에러를 CoreException 경로로 통일하도록 규약되어 있다(RankingKeyResolver의 validateDateFormat 참고).

운영 관점에서 API 에러 응답 스키마 불일치는 클라이언트 처리 분기를 늘리고 SLO 지표에도 오탐을 만든다. 주간/월간 UseCase 모두 RankingKeyResolver.validateDateFormat류 검증을 재사용하거나, try/catch (DateTimeParseException)CoreException(BAD_REQUEST, ...) 래핑을 추가해야 한다. 루트 원인이 동일하므로 Monthly UseCase에도 동일 수정이 필요하다.

추가 테스트: RankingV1ApiE2ETest에 잘못된 date 포맷(예: "invalid", "2026-04-14")에 대해 표준 에러 포맷(4xx)을 반환하는 케이스를 추가하기를 권고한다.

🛡️ 제안 수정
     public RankingPageResult execute(Long userId, String date, PageSize pageSize) {
-        LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT);
+        LocalDate scoreDate;
+        try {
+            scoreDate = LocalDate.parse(date, DATE_FORMAT);
+        } catch (DateTimeParseException e) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "date는 yyyyMMdd 형식이어야 한다.");
+        }
         List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked(

Based on learnings: "loop-pack-be-l2-vol3-java 프로젝트에서 에러 처리는 CoreException을 통해 ApiControllerAdvice로 라우팅하도록 통일한다."

📝 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 RankingPageResult execute(Long userId, String date, PageSize pageSize) {
LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT);
List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked(
scoreDate, pageSize.page(), pageSize.size());
public RankingPageResult execute(Long userId, String date, PageSize pageSize) {
LocalDate scoreDate;
try {
scoreDate = LocalDate.parse(date, DATE_FORMAT);
} catch (DateTimeParseException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "date는 yyyyMMdd 형식이어야 한다.");
}
List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked(
scoreDate, pageSize.page(), pageSize.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/ReadWeeklyRankingsUseCase.java`
around lines 35 - 38, The execute method in ReadWeeklyRankingsUseCase parses the
date with LocalDate.parse which can throw DateTimeParseException and bypass API
error normalization; wrap the parse in a try/catch that catches
DateTimeParseException and rethrow a CoreException with BAD_REQUEST (reuse the
message/format style used by RankingKeyResolver.validateDateFormat), then
proceed to call rankingService.readWeeklyTopRanked as before; apply the
identical change to the monthly use case (MonthlyReadRankingsUseCase / similar
execute method) and add an E2E test in RankingV1ApiE2ETest asserting that
invalid date formats (e.g., "invalid", "2026-04-14") return the standardized 4xx
CoreException error response.


List<RankingItem> rankingItems = RankingItem.toRankingItems(
rankings,
pageSize.offset(),
ProductRankingWeekly::getProductId,
ProductRankingWeekly::getScore
);
return rankingResultAssembler.assemble(userId, rankingItems, pageSize);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.loopers.domain.ranking;

import java.time.LocalDate;
import java.util.List;

/**
* 월간 랭킹 데이터를 조회하는 포트.
*
* <p>배치가 집계한 {@code mv_product_rank_monthly} 테이블에서 랭킹 데이터를 읽기 전용으로 제공한다.</p>
*/
public interface MonthlyRankingRepository {

/**
* 지정한 scoreDate의 월간 랭킹을 점수 내림차순으로 조회한다.
*
* @param scoreDate 조회 기준일
* @param page 페이지 번호 (0-based)
* @param size 페이지 크기
* @return 월간 랭킹 엔티티 목록 (점수 내림차순)
*/
List<ProductRankingMonthly> readTopRanked(LocalDate scoreDate, int page, int size);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.loopers.domain.ranking;

import java.time.LocalDate;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
* 월간 인기 상품 랭킹 엔티티.
*
* <p>배치가 집계한 {@code mv_product_rank_monthly} 테이블의 읽기 전용 매핑이다.</p>
*/
@Entity
@Table(name = "mv_product_rank_monthly")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ProductRankingMonthly {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private Long productId;

@Column(nullable = false)
private LocalDate scoreDate;

@Column(nullable = false)
private Double score;

public static ProductRankingMonthly create(Long productId, LocalDate scoreDate, Double score) {
ProductRankingMonthly ranking = new ProductRankingMonthly();
ranking.productId = productId;
ranking.scoreDate = scoreDate;
ranking.score = score;
return ranking;
}
Comment on lines +21 to +46
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

읽기 전용 뷰 엔티티의 쓰기 경로 차단이 필요하다.

본 엔티티는 "배치가 집계한 mv_product_rank_monthly 테이블의 읽기 전용 매핑"으로 문서화되어 있지만, MonthlyRankingJpaRepositoryJpaRepository<ProductRankingMonthly, Long>를 상속하여 save, saveAll, delete, deleteAll 등 쓰기 메서드가 전부 노출된다. 운영 관점에서 다음 리스크가 있다:

  • API 모듈에서 실수로 save(...)를 호출하면 배치가 관리하는 테이블 상태가 오염될 수 있다(동일 (product_id, score_date) 유니크 제약이 본 엔티티에는 빠져 있어 중복 insert 시 원인 파악도 어려워진다).
  • 도메인 문서(읽기 전용)와 실제 노출 API 간 계약이 불일치한다.

수정안으로는 다음 중 하나를 권장한다:

  1. MonthlyRankingJpaRepositoryJpaRepository 대신 Repository<ProductRankingMonthly, Long>(또는 커스텀 최소 인터페이스)로 변경해 쓰기 메서드를 노출하지 않는다.
  2. 엔티티에 @Immutable(Hibernate) 또는 @Entity(..., readOnly=true)에 준하는 제약을 걸고, 세터/팩토리 노출을 축소한다.
  3. 배치 모듈의 엔티티와 동일하게 API 모듈 엔티티에도 (product_id, score_date) 유니크 제약을 선언해 스키마 계약을 일치시킨다(현재 배치 모듈 ProductRankingMonthly에는 유니크 제약이 있고, API 모듈에는 없다).

추가 테스트로, MonthlyRankingJpaRepository의 쓰기 경로가 허용되지 않음을 확인하는 아키텍처 테스트(ArchUnit 등) 또는 리포지토리 계약 테스트를 제안한다.

🤖 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/ranking/ProductRankingMonthly.java`
around lines 22 - 49, ProductRankingMonthly is mapped as a read-only view but
MonthlyRankingJpaRepository currently extends JpaRepository, exposing write
APIs; update the code so write paths are blocked: change
MonthlyRankingJpaRepository to extend Spring’s Repository<ProductRankingMonthly,
Long> or a custom read-only repository interface (removing
save/saveAll/delete/deleteAll signatures), and/or annotate ProductRankingMonthly
with a Hibernate `@Immutable` (or equivalent readOnly mapping) and tighten
visibility of mutators/factory if needed; also add the (product_id, score_date)
unique constraint on ProductRankingMonthly to match the batch module and add an
architecture or repository contract test (ArchUnit or similar) asserting that
MonthlyRankingJpaRepository does not expose write methods.

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

import java.time.LocalDate;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "mv_product_rank_weekly")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ProductRankingWeekly {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private Long productId;

@Column(nullable = false)
private LocalDate scoreDate;

@Column(nullable = false)
private Double score;

public static ProductRankingWeekly create(Long productId, LocalDate scoreDate, Double score) {
ProductRankingWeekly ranking = new ProductRankingWeekly();
ranking.productId = productId;
ranking.scoreDate = scoreDate;
ranking.score = score;
return ranking;
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
package com.loopers.domain.ranking;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.ToDoubleFunction;

/**
* 랭킹 Sorted Set의 단일 항목.
* 랭킹 단일 항목.
*
* @param rank 1-based 순위
* @param productId 상품 ID
* @param score 가중치 기반 누적 점수
*/
public record RankingItem(int rank, Long productId, double score) {

/**
* 엔티티 목록을 {@link RankingItem} 목록으로 변환한다.
*
* @param items 원본 목록
* @param offset 시작 오프셋 (rank 계산용)
* @param toProductId 상품 ID 추출 함수
* @param toScore 점수 추출 함수
* @return 순위가 포함된 랭킹 항목 목록
*/
public static <T> List<RankingItem> toRankingItems(
List<T> items,
int offset,
Function<T, Long> toProductId,
ToDoubleFunction<T> toScore
) {
List<RankingItem> rankingItems = new ArrayList<>(items.size());
for (int i = 0; i < items.size(); i++) {
T item = items.get(i);
rankingItems.add(new RankingItem(offset + i + 1, toProductId.apply(item), toScore.applyAsDouble(item)));
}
return rankingItems;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.loopers.domain.ranking;

import java.time.LocalDate;
import java.util.List;

import com.loopers.domain.shared.annotation.DomainService;
Expand All @@ -9,13 +10,15 @@
/**
* 랭킹 조회 도메인 서비스.
*
* <p>일간·시간 단위 Redis Sorted Set에서 랭킹 데이터를 읽기 전용으로 제공한다.</p>
* <p>일간·시간 단위는 Redis Sorted Set, 주간·월간은 배치 집계 DB에서 랭킹 데이터를 읽기 전용으로 제공한다.</p>
*/
@DomainService
@RequiredArgsConstructor
public class RankingService {

private final RankingRepository rankingRepository;
private final WeeklyRankingRepository weeklyRankingRepository;
private final MonthlyRankingRepository monthlyRankingRepository;

/**
* 일간 상위 랭킹을 조회한다.
Expand All @@ -42,4 +45,28 @@ public List<RankingItem> readHourlyTopRanked(String datetime, int offset, int co
String key = RankingKeyResolver.resolveHourly(datetime);
return rankingRepository.readTopRanked(key, offset, count);
}

/**
* 주간 상위 랭킹을 조회한다.
*
* @param scoreDate 조회 기준일
* @param page 페이지 번호 (0-based)
* @param size 페이지 크기
* @return 주간 랭킹 엔티티 목록 (점수 내림차순)
*/
public List<ProductRankingWeekly> readWeeklyTopRanked(LocalDate scoreDate, int page, int size) {
return weeklyRankingRepository.readTopRanked(scoreDate, page, size);
}

/**
* 월간 상위 랭킹을 조회한다.
*
* @param scoreDate 조회 기준일
* @param page 페이지 번호 (0-based)
* @param size 페이지 크기
* @return 월간 랭킹 엔티티 목록 (점수 내림차순)
*/
public List<ProductRankingMonthly> readMonthlyTopRanked(LocalDate scoreDate, int page, int size) {
return monthlyRankingRepository.readTopRanked(scoreDate, page, size);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.loopers.domain.ranking;

import java.time.LocalDate;
import java.util.List;

/**
* 주간 랭킹 데이터를 조회하는 포트.
*
* <p>배치가 집계한 {@code mv_product_rank_weekly} 테이블에서 랭킹 데이터를 읽기 전용으로 제공한다.</p>
*/
public interface WeeklyRankingRepository {

/**
* 지정한 scoreDate의 주간 랭킹을 점수 내림차순으로 조회한다.
*
* @param scoreDate 조회 기준일
* @param page 페이지 번호 (0-based)
* @param size 페이지 크기
* @return 주간 랭킹 엔티티 목록 (점수 내림차순)
*/
List<ProductRankingWeekly> readTopRanked(LocalDate scoreDate, int page, int size);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.loopers.infrastructure.ranking.persistence;

import java.time.LocalDate;
import java.util.List;

import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import com.loopers.domain.ranking.ProductRankingMonthly;

public interface MonthlyRankingJpaRepository extends JpaRepository<ProductRankingMonthly, Long> {

List<ProductRankingMonthly> findByScoreDateOrderByScoreDesc(LocalDate scoreDate, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.loopers.infrastructure.ranking.persistence;

import java.time.LocalDate;
import java.util.List;

import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Repository;

import com.loopers.domain.ranking.MonthlyRankingRepository;
import com.loopers.domain.ranking.ProductRankingMonthly;

import lombok.RequiredArgsConstructor;

/**
* 월간 랭킹 조회 구현체.
*
* <p>배치가 집계한 {@code mv_product_rank_monthly} 테이블에서 지정한 scoreDate의 랭킹을 조회한다.</p>
*/
@Repository
@RequiredArgsConstructor
public class MonthlyRankingRepositoryImpl implements MonthlyRankingRepository {

private final MonthlyRankingJpaRepository monthlyRankingJpaRepository;

@Override
public List<ProductRankingMonthly> readTopRanked(LocalDate scoreDate, int page, int size) {
return monthlyRankingJpaRepository.findByScoreDateOrderByScoreDesc(scoreDate, PageRequest.of(page, size));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading