-
Notifications
You must be signed in to change notification settings - Fork 44
MV 기반 주간/월간 랭킹 배치 시스템 구축 #422
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: SukheeChoi
Are you sure you want to change the base?
Changes from all commits
8181ea4
f0d3df0
9c1764c
75de902
f6de522
65183a8
739d492
b1f4b16
458c4de
6b5eb12
016aff4
3753d9c
1c3b219
39fcaec
05635d6
5e30b71
ac79004
c814ce7
4c9fbef
1dfb514
1babb5b
ea6d7d5
fa7d840
00bfc99
00e5457
675cf8b
29fcf39
2777e4a
b2afd2f
1df0403
a352a78
94a1110
7ad094d
b65e020
2565bc0
4fd5255
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 |
|---|---|---|
|
|
@@ -3,12 +3,15 @@ | |
| import com.loopers.domain.product.Product; | ||
| import com.loopers.domain.product.ProductRepository; | ||
| import com.loopers.domain.product.ProductWithBrand; | ||
| import com.loopers.domain.ranking.MvProductRank; | ||
| import com.loopers.domain.ranking.MvProductRankRepository; | ||
| import com.loopers.infrastructure.ranking.RankingRedisRepository; | ||
| import com.loopers.interfaces.api.ranking.RankingDto; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
|
|
@@ -31,22 +34,78 @@ public class RankingFacade { | |
| private static final int MAX_RANKING_SIZE = 100; | ||
|
|
||
| private static final String DAILY_ZSET_PREFIX = "ranking:all:"; | ||
| private static final String WEEKLY_ZSET_PREFIX = "ranking:weekly:"; | ||
| private static final String MONTHLY_ZSET_PREFIX = "ranking:monthly:"; | ||
|
|
||
| private final RankingRedisRepository rankingRedisRepository; | ||
| private final MvProductRankRepository mvProductRankRepository; | ||
| private final ProductRepository productRepository; | ||
| private final RankingProperties properties; | ||
|
|
||
| public RankingDto.PagedRankingResponse getRankings(String scope, String date, int page, int size, Long memberId) { | ||
| String resolvedDate = (date != null) ? date : LocalDate.now(KST).format(DATE_FORMATTER); | ||
| String prefix = resolveZsetPrefix(scope, memberId); | ||
|
|
||
| return switch (scope) { | ||
| case "weekly", "monthly" -> getFromMv(scope, resolvedDate, page, size); | ||
| default -> getFromRedis(scope, resolvedDate, page, size, memberId); | ||
| }; | ||
| } | ||
|
|
||
| private RankingDto.PagedRankingResponse getFromMv(String scope, String date, int page, int size) { | ||
| // 1. 당일 MV 조회 | ||
| List<MvProductRank> mvResults = mvProductRankRepository.findByPeriodKeyAndScope( | ||
| date, scope, PageRequest.of(page, size)); | ||
| long totalElements = mvProductRankRepository.countByPeriodKeyAndScope(date, scope); | ||
|
|
||
| // 2. 당일 데이터 없으면 전일 fallback | ||
| if (mvResults.isEmpty()) { | ||
| String previousDate = LocalDate.parse(date, DATE_FORMATTER) | ||
| .minusDays(1).format(DATE_FORMATTER); | ||
| mvResults = mvProductRankRepository.findByPeriodKeyAndScope( | ||
| previousDate, scope, PageRequest.of(page, size)); | ||
| totalElements = mvProductRankRepository.countByPeriodKeyAndScope(previousDate, scope); | ||
|
|
||
| if (!mvResults.isEmpty()) { | ||
| log.info("MV 전일 fallback 적용: scope={}, date={} → {}", scope, date, previousDate); | ||
| } | ||
| } | ||
|
|
||
| // 3. 전일도 없으면 빈 결과 | ||
| if (mvResults.isEmpty()) { | ||
| return new RankingDto.PagedRankingResponse(List.of(), 0, 0, page, size); | ||
| } | ||
|
Comment on lines
+52
to
+74
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. Fallback 트리거 조건이 잘못되어 정상 데이터가 전일 데이터로 덮어써질 수 있다.
Fallback 트리거는 반드시 count 기준으로 판단해야 하며, 호출 순서도 count 먼저 조회하도록 정리하는 것이 낫다. 🐛 제안 수정 private RankingDto.PagedRankingResponse getFromMv(String scope, String date, int page, int size) {
- // 1. 당일 MV 조회
- List<MvProductRank> mvResults = mvProductRankRepository.findByPeriodKeyAndScope(
- date, scope, PageRequest.of(page, size));
- long totalElements = mvProductRankRepository.countByPeriodKeyAndScope(date, scope);
-
- // 2. 당일 데이터 없으면 전일 fallback
- if (mvResults.isEmpty()) {
- String previousDate = LocalDate.parse(date, DATE_FORMATTER)
- .minusDays(1).format(DATE_FORMATTER);
- mvResults = mvProductRankRepository.findByPeriodKeyAndScope(
- previousDate, scope, PageRequest.of(page, size));
- totalElements = mvProductRankRepository.countByPeriodKeyAndScope(previousDate, scope);
-
- if (!mvResults.isEmpty()) {
- log.info("MV 전일 fallback 적용: scope={}, date={} → {}", scope, date, previousDate);
- }
- }
-
- // 3. 전일도 없으면 빈 결과
- if (mvResults.isEmpty()) {
- return new RankingDto.PagedRankingResponse(List.of(), 0, 0, page, size);
- }
+ // 1. 당일 총건수로 존재 여부 판단 (빈 페이지 ≠ 데이터 없음)
+ String effectiveDate = date;
+ long totalElements = mvProductRankRepository.countByPeriodKeyAndScope(effectiveDate, scope);
+
+ // 2. 당일 데이터 자체가 없을 때만 전일 fallback
+ if (totalElements == 0) {
+ String previousDate = LocalDate.parse(date, DATE_FORMATTER)
+ .minusDays(1).format(DATE_FORMATTER);
+ long prevTotal = mvProductRankRepository.countByPeriodKeyAndScope(previousDate, scope);
+ if (prevTotal == 0) {
+ return new RankingDto.PagedRankingResponse(List.of(), 0, 0, page, size);
+ }
+ log.info("MV 전일 fallback 적용: scope={}, date={} → {}", scope, date, previousDate);
+ effectiveDate = previousDate;
+ totalElements = prevTotal;
+ }
+
+ List<MvProductRank> mvResults = mvProductRankRepository.findByPeriodKeyAndScope(
+ effectiveDate, scope, PageRequest.of(page, size));추가 테스트로 다음 케이스를 권한다.
🤖 Prompt for AI Agents |
||
|
|
||
| totalElements = Math.min(totalElements, MAX_RANKING_SIZE); | ||
| int totalPages = (int) Math.ceil((double) totalElements / size); | ||
|
|
||
| // 4. Product 상세 조합 | ||
| List<Long> productIds = mvResults.stream() | ||
| .map(MvProductRank::getProductId).toList(); | ||
|
|
||
| Map<Long, ProductWithBrand> productMap = productRepository.findAllByIds(productIds).stream() | ||
| .collect(Collectors.toMap(pwb -> pwb.product().getId(), pwb -> pwb)); | ||
|
|
||
| List<RankingDto.RankingResponse> data = new ArrayList<>(); | ||
| for (MvProductRank mv : mvResults) { | ||
| ProductWithBrand pwb = productMap.get(mv.getProductId()); | ||
| if (pwb != null) { | ||
| Product product = pwb.product(); | ||
| data.add(new RankingDto.RankingResponse( | ||
| mv.getProductId(), product.getName(), pwb.brandName(), | ||
| product.getPrice().getValue(), mv.getRanking(), mv.getScore() | ||
| )); | ||
| } | ||
| } | ||
|
|
||
| return new RankingDto.PagedRankingResponse(data, totalElements, totalPages, page, size); | ||
| } | ||
|
|
||
| private RankingDto.PagedRankingResponse getFromRedis(String scope, String date, int page, int size, Long memberId) { | ||
| String prefix = resolveDailyPrefix(memberId); | ||
|
|
||
| long totalElements; | ||
| List<RankingRedisRepository.RankingEntry> entries; | ||
|
|
||
| try { | ||
| long rawTotal = rankingRedisRepository.getTotalCount(prefix, resolvedDate); | ||
| long rawTotal = rankingRedisRepository.getTotalCount(prefix, date); | ||
| totalElements = Math.min(rawTotal, MAX_RANKING_SIZE); | ||
|
|
||
| long start = (long) page * size; | ||
|
|
@@ -57,15 +116,14 @@ public RankingDto.PagedRankingResponse getRankings(String scope, String date, in | |
| } | ||
|
|
||
| long end = Math.min(start + size - 1, totalElements - 1); | ||
| entries = rankingRedisRepository.getTopN(prefix, resolvedDate, start, end); | ||
| entries = rankingRedisRepository.getTopN(prefix, date, start, end); | ||
| } catch (Exception e) { | ||
| log.error("랭킹 Redis 조회 실패", e); | ||
| throw new CoreException(ErrorType.INTERNAL_ERROR, "랭킹 서비스를 일시적으로 이용할 수 없습니다."); | ||
| } | ||
|
|
||
| List<Long> productIds = entries.stream() | ||
| .map(RankingRedisRepository.RankingEntry::productId) | ||
| .toList(); | ||
| .map(RankingRedisRepository.RankingEntry::productId).toList(); | ||
|
|
||
| Map<Long, ProductWithBrand> productMap = productRepository.findAllByIds(productIds).stream() | ||
| .collect(Collectors.toMap(pwb -> pwb.product().getId(), pwb -> pwb)); | ||
|
|
@@ -88,24 +146,15 @@ public RankingDto.PagedRankingResponse getRankings(String scope, String date, in | |
| return new RankingDto.PagedRankingResponse(data, totalElements, totalPages, page, size); | ||
| } | ||
|
|
||
| private String resolveZsetPrefix(String scope, Long memberId) { | ||
| // A/B 테스트는 daily에만 적용 | ||
| if ("daily".equals(scope) || scope == null) { | ||
| RankingProperties.Experiment experiment = properties.experiment(); | ||
| if (experiment.enabled() && !experiment.variants().isEmpty() && memberId != null) { | ||
| List<String> variantKeys = new ArrayList<>(experiment.variants().keySet()); | ||
| int variantIndex = (int) (Math.abs(memberId) % variantKeys.size()); | ||
| String selectedKey = variantKeys.get(variantIndex); | ||
| RankingProperties.Variant variant = experiment.variants().get(selectedKey); | ||
| return variant.zsetPrefix(); | ||
| } | ||
| return DAILY_ZSET_PREFIX; | ||
| private String resolveDailyPrefix(Long memberId) { | ||
| RankingProperties.Experiment experiment = properties.experiment(); | ||
| if (experiment.enabled() && !experiment.variants().isEmpty() && memberId != null) { | ||
| List<String> variantKeys = new ArrayList<>(experiment.variants().keySet()); | ||
| int variantIndex = (int) (Math.abs(memberId) % variantKeys.size()); | ||
| String selectedKey = variantKeys.get(variantIndex); | ||
| RankingProperties.Variant variant = experiment.variants().get(selectedKey); | ||
| return variant.zsetPrefix(); | ||
| } | ||
|
|
||
| return switch (scope) { | ||
| case "weekly" -> WEEKLY_ZSET_PREFIX; | ||
| case "monthly" -> MONTHLY_ZSET_PREFIX; | ||
| default -> DAILY_ZSET_PREFIX; | ||
| }; | ||
| return DAILY_ZSET_PREFIX; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import jakarta.persistence.*; | ||
| import lombok.AccessLevel; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @MappedSuperclass | ||
| public abstract class MvProductRank { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @Column(nullable = false) | ||
| private Long productId; | ||
|
|
||
| @Column(nullable = false) | ||
| private Integer ranking; | ||
|
|
||
| @Column(nullable = false) | ||
| private Double score; | ||
|
|
||
| @Column(nullable = false) | ||
| private Long viewCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private Long likeCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private Long salesCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private Long salesAmount; | ||
|
|
||
| @Column(nullable = false, length = 8) | ||
| private String periodKey; | ||
|
|
||
| @Column(nullable = false) | ||
| private LocalDateTime createdAt; | ||
|
Comment on lines
+43
to
+44
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. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# MvProductRank(Weekly|Monthly) 가 JPA save/persist 경로로 쓰이는지 확인
rg -nP --type=java -C3 '\b(MvProductRankWeekly|MvProductRankMonthly)\b'
rg -nP --type=java -C2 '\b(save|persist)\s*\(' -g '**/ranking/**'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 5490 🏁 Script executed: cat apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRank.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1026 🏁 Script executed: # 배치 Job 구현 찾기
fd -e java -path '**/ranking/**' | head -20
rg -n '@PrePersist|@CreationTimestamp' -t java -g '**/ranking/**'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 253 🏁 Script executed: # 전체 저장소에서 save/persist 호출 찾기
rg -n '\.(save|saveAll|persist)\s*\(' -t java --max-count 50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 40751 🏁 Script executed: # 랭킹 관련 배치 Job 또는 JDBC 직접 INSERT 찾기
fd -e java -path '**/ranking/**' -o -path '**/batch/**' -o -path '**/job/**' | head -30
rg -n 'MvProductRank|MERGE|INSERT' -A5 -B5 --type=java | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 13939 🏁 Script executed: # DB 마이그레이션 파일에서 mv_product_rank 스키마 확인
fd -e sql -o -e yaml -o -e xml | xargs grep -l 'mv_product_rank' 2>/dev/null | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 201 JPA 저장 경로 추가 시 현재는 배치 Job이 JDBC 기반 INSERT로
♻️ 제안- `@Column`(nullable = false)
- private LocalDateTime createdAt;
+ `@Column`(nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ `@PrePersist`
+ protected void onCreate() {
+ if (createdAt == null) {
+ createdAt = LocalDateTime.now();
+ }
+ }
🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Table; | ||
| import lombok.AccessLevel; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Entity | ||
| @Table(name = "mv_product_rank_monthly") | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class MvProductRankMonthly extends MvProductRank { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import org.springframework.data.domain.Pageable; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface MvProductRankRepository { | ||
|
|
||
| List<MvProductRank> findByPeriodKeyAndScope(String periodKey, String scope, Pageable pageable); | ||
|
|
||
| long countByPeriodKeyAndScope(String periodKey, String scope); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Table; | ||
| import lombok.AccessLevel; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Entity | ||
| @Table(name = "mv_product_rank_weekly") | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class MvProductRankWeekly extends MvProductRank { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.*; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Repository | ||
| @RequiredArgsConstructor | ||
| public class MvProductRankJpaRepository implements MvProductRankRepository { | ||
|
|
||
| private final MvProductRankWeeklySpringDataRepository weeklyRepository; | ||
| private final MvProductRankMonthlySpringDataRepository monthlyRepository; | ||
|
|
||
| @Override | ||
| public List<MvProductRank> findByPeriodKeyAndScope(String periodKey, String scope, Pageable pageable) { | ||
| return switch (scope) { | ||
| case "weekly" -> weeklyRepository.findByPeriodKeyOrderByRankingAsc(periodKey, pageable) | ||
| .stream().map(r -> (MvProductRank) r).toList(); | ||
| case "monthly" -> monthlyRepository.findByPeriodKeyOrderByRankingAsc(periodKey, pageable) | ||
| .stream().map(r -> (MvProductRank) r).toList(); | ||
| default -> throw new IllegalArgumentException("Invalid scope: " + scope); | ||
| }; | ||
| } | ||
|
|
||
| @Override | ||
| public long countByPeriodKeyAndScope(String periodKey, String scope) { | ||
| return switch (scope) { | ||
| case "weekly" -> weeklyRepository.countByPeriodKey(periodKey); | ||
| case "monthly" -> monthlyRepository.countByPeriodKey(periodKey); | ||
| default -> throw new IllegalArgumentException("Invalid scope: " + scope); | ||
| }; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.MvProductRankMonthly; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface MvProductRankMonthlySpringDataRepository extends JpaRepository<MvProductRankMonthly, Long> { | ||
|
|
||
| List<MvProductRankMonthly> findByPeriodKeyOrderByRankingAsc(String periodKey, Pageable pageable); | ||
|
|
||
| long countByPeriodKey(String periodKey); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.MvProductRankWeekly; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface MvProductRankWeeklySpringDataRepository extends JpaRepository<MvProductRankWeekly, Long> { | ||
|
|
||
| List<MvProductRankWeekly> findByPeriodKeyOrderByRankingAsc(String periodKey, Pageable pageable); | ||
|
|
||
| long countByPeriodKey(String periodKey); | ||
| } |
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.
scope가 null로 들어올 때 NPE가 발생한다.switch (scope)에서 scope가null이면NullPointerException이 발생해 500으로 떨어진다. 컨트롤러 계층에서 필터링되고 있더라도, 이 Facade는memberId/date에 대해서는null을 방어적으로 처리하는 반면scope만 방어가 없어 일관성이 떨어진다.🛡️ 제안 수정
혹은
ScopeTypeenum을 도입하여 상위 계층에서 검증하도록 통일하는 편이 낫다.🤖 Prompt for AI Agents