-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-10] Spring Batch 기반 주간·월간 랭킹 시스템 구현 #412
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: leeedohyun
Are you sure you want to change the base?
Changes from all commits
4201d31
7eb7dd4
21e0380
cd4ddda
2f8e244
479004d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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()); | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 읽기 전용 뷰 엔티티의 쓰기 경로 차단이 필요하다. 본 엔티티는 "배치가 집계한
수정안으로는 다음 중 하나를 권장한다:
추가 테스트로, 🤖 Prompt for AI Agents |
||
| } | ||
| 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 |
|---|---|---|
| @@ -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)); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
🤖 Prompt for AI Agents