Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a1415fb
docs : md 파일 업데이트
Apr 13, 2026
698ce7e
refactor : product_metrics 테이블에 집계 기준날짜 + 주문 수량 합산 필드 추가
Apr 13, 2026
d15406d
refactor : 쿼리 변경 (metricsDate 파라미터 추가)
Apr 13, 2026
c0860a5
feat : ProductMetrics 테이블 동시성 문제를 예방을 위한 원자적 쿼리 추가
Apr 13, 2026
1110a22
refactor : 주문 수량 더하는 쿼리, 주문 수량 합산 하는 쿼리를 1개의 쿼리로 통일
Apr 13, 2026
1c31308
refactor : ProductMetricsProcessor를 날짜 기반 upsert 방식으로 전환
Apr 13, 2026
3e1b790
docs : md 업데이트
Apr 13, 2026
fe12b12
docs : md 업데이트
Apr 13, 2026
50172d7
feat : 주간/월간 랭킹 Materialized View 설계
Apr 15, 2026
3433e33
docs : md 업데이트
Apr 15, 2026
d073d2b
feat : infrastructure 레이어 구현
Apr 15, 2026
192e236
feat : 주간 batch job 구현
Apr 15, 2026
a7f9a3b
feat : 월간 batch job 구현
Apr 15, 2026
c9e6053
refactor : 주간 reader / processor / writer를 각각의 클래스로 분리
Apr 15, 2026
fa3c0bb
refactor : 월간 reader / processor / writer를 각각의 클래스로 분리
Apr 15, 2026
35d4c17
docs : md 업데이트
Apr 15, 2026
42d201a
fix: JobConfig에서 @StepScope Reader 빈 직접 주입으로 수정
Apr 16, 2026
2f311e5
feat : 주간/월간 랭킹 읽기용 mv 추가
Apr 16, 2026
fc9047b
feat : RankingRepositoryImpl에 주간/월간 랭킹 조회 구현 추가
Apr 16, 2026
ebaca99
feat : period 파라미터 추가
Apr 16, 2026
da56e8b
docs : md 업데이트
Apr 16, 2026
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 @@ -23,8 +23,12 @@ public class RankingFacade {
private final ProductService productService;
private final BrandService brandService;

public List<RankingInfo> getRankings(LocalDate date, int size, int page) {
List<RankedProduct> rankedProducts = rankingRepository.getTopN(date, size, page);
public List<RankingInfo> getRankings(LocalDate date, String period, int size, int page) {
List<RankedProduct> rankedProducts = switch (period) {
case "weekly" -> rankingRepository.getWeeklyTopN(date, size, page);
case "monthly" -> rankingRepository.getMonthlyTopN(date, size, page);
default -> rankingRepository.getTopN(date, size, page);
};
if (rankedProducts.isEmpty()) {
return List.of();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

public interface RankingRepository {
List<RankedProduct> getTopN(LocalDate date, int size, int page);
List<RankedProduct> getWeeklyTopN(LocalDate date, int size, int page);
List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page);
Long getRank(Long productId, LocalDate date);
void carryOver(LocalDate from, LocalDate to, double ratio);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.loopers.infrastructure.ranking;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Comment;

@Entity
@Table(name = "mv_product_rank_monthly")
@NoArgsConstructor
public class MvProductRankMonthlyEntity {

@Id
@Comment("상품 id (ref)")
@Column(name = "product_id", nullable = false, updatable = false)
private Long productId;

@Comment("집계 점수")
@Column(name = "score", nullable = false)
private double score;

@Comment("집계 기준 월 (e.g. 202604)")
@Column(name = "year_month", nullable = false, length = 6)
private String yearMonth;
Comment on lines +28 to +29
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

🧩 Analysis chain

🌐 Web query:

Is YEAR_MONTH a reserved keyword in MySQL 8.0 and does it require quoting when used as a column identifier?

💡 Result:

Yes, YEAR_MONTH is a reserved keyword in MySQL 8.0. It requires quoting (using backticks) when used as a column identifier.

Citations:


🏁 Script executed:

# 먼저 현재 파일의 위치 확인
find . -name "MvProductRankMonthlyEntity.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 288


🏁 Script executed:

# 관련 엔티티 찾기 - 주간 랭킹 엔티티
find . -name "*MvProductRankWeekly*" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 502


🏁 Script executed:

# year_month 또는 yearMonth 필드 검색
rg "yearMonth|year_month" --type java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4745


🏁 Script executed:

# DDL 스크립트나 데이터베이스 설정 파일 확인
find . -name "*.sql" -o -name "*schema*" -o -name "*migration*" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 107


🏁 Script executed:

# Check the WeeklyEntity for similar issues
cat ./apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2187


🏁 Script executed:

# Check the database schema/DDL
cat ./db/seed-products.sql

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1357


🏁 Script executed:

# Search for other `@Column` usages with backticks or special characters in the codebase
rg "@Column.*\`" --type java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Search for Hibernate/Spring configuration that might affect column naming
find . -name "application*.properties" -o -name "application*.yml" | xargs cat 2>/dev/null | grep -i "hibernate\|jpa" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 132


🏁 Script executed:

# Search for Flyway or Liquibase migration files
find . -path "*/db/migration*" -o -path "*/db/flyway*" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Look for any V*.sql or similar migration patterns
find . -name "V*.sql" -o -name "U*.sql" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Check application configuration
find . -name "application*.yml" -o -name "application*.yaml" | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 256


🏁 Script executed:

# Search for any CREATE TABLE statements in the codebase
rg "CREATE TABLE.*rank" --type sql -i

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Check if there's any documentation about the schema
find . -name "*.md" -o -name "*.txt" | xargs grep -l "year_month" 2>/dev/null

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 111


🏁 Script executed:

# Check the application configuration for Hibernate settings
cat ./apps/commerce-api/src/main/resources/application.yml

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1670


🏁 Script executed:

# Check the batch ranking documentation
cat ./docs/10-batch-ranking.md

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 8489


🏁 Script executed:

# Search for any SQL or DDL hints in comments
rg "year_month|year_week" --type java -B 2 -A 2 | head -50

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2635


🏁 Script executed:

# Check for any Hibernate configuration that might handle reserved keywords
cat ./apps/commerce-api/src/main/resources/jpa.yml

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 163


🏁 Script executed:

# Verify if there's any Hibernate documentation or configuration for column name quoting
rg "hibernate.*naming|dialect|PhysicalNamingStrategy" --type yaml --type properties

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 119


🏁 Script executed:

# Search for other reserved keywords usage in the codebase
rg "@Column.*name.*=" --type java | head -30

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4522


🏁 Script executed:

# Check if the batch module has the same issue - compare both files
diff -u ./apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java ./apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Search for Hibernate documentation or examples with reserved keywords
rg "backtick|escape|reserved" --type java --type yaml -i

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Check if there are any test files that might show database interactions
find . -name "*Test*.java" -path "*/ranking/*" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Look for any database initialization or schema creation code
rg "CREATE TABLE|year_month" --type java -B 2 -A 2

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1353


🏁 Script executed:

# Check for jpa.yml content - it might be in a different location
find . -name "*.yml" -o -name "*.yaml" | xargs grep -l "jpa\|hibernate" | head -10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 297


🏁 Script executed:

# Look for application-jpa.yml or similar
find . -name "application*" -type f | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 256


🏁 Script executed:

# Check if there's Hibernate dialect configuration
rg "hibernate.dialect|hibernate.query|quoteIdentifier" --type yaml --type java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Verify what exact Hibernate/Jakarta version is used
find . -name "pom.xml" | head -3 | xargs grep -A 5 "jakarta.persistence\|hibernate-core"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 69


🏁 Script executed:

# Read the jpa.yml configuration file
cat ./modules/jpa/src/main/resources/jpa.yml

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2231


🏁 Script executed:

# Check pom.xml for Jakarta/Hibernate versions
cat ./pom.xml | head -100

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 126


🏁 Script executed:

# Look specifically for spring-boot-starter-data-jpa and related dependencies
rg "spring-boot-starter-data-jpa|jakarta.persistence" pom.xml

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 132


🏁 Script executed:

# Find the Maven build files
find . -name "pom.xml" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Check the root directory structure
ls -la | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1228


🏁 Script executed:

# Look for Gradle build files instead
find . -name "build.gradle" -o -name "build.gradle.kts" | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 252


🏁 Script executed:

# Check root build.gradle.kts for dependency versions
cat ./build.gradle.kts

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3761


🏁 Script executed:

# Check modules/jpa build.gradle.kts
cat ./modules/jpa/build.gradle.kts

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 763


year_month는 MySQL 8.0 예약어이므로 백틱으로 이스케이프해야 한다.

YEAR_MONTH는 MySQL 8.0의 INTERVAL 단위 키워드로 예약되어 있어 DDL/DML 파싱 에러가 발생할 수 있다. 현재 코드의 @Column(name = "year_month", ...) 형태로는 예약어 충돌 위험이 있으므로, Hibernate에서 명시적으로 인식하도록 백틱으로 감싸야 한다.

commerce-api와 commerce-batch 두 모듈의 MvProductRankMonthlyEntity 파일 모두에서 동일하게 수정이 필요하다.

📝 수정안
-    `@Column`(name = "year_month", nullable = false, length = 6)
+    `@Column`(name = "`year_month`", nullable = false, length = 6)
     private String yearMonth;

테스트: Testcontainers MySQL 8.0 환경에서 persist/update/derived query(findAllByYearMonth 등) 모두 정상 실행되는지 검증한다.

🤖 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/ranking/MvProductRankMonthlyEntity.java`
around lines 28 - 29, The column name year_month is a MySQL 8.0 reserved keyword
(INTERVAL unit) and must be escaped; update the MvProductRankMonthlyEntity class
so the `@Column` maps to a backticked identifier (replace `@Column`(name =
"year_month", ...) with `@Column`(name = "`year_month`", ...)) to ensure Hibernate
emits the escaped identifier, and apply the same change in both commerce-api and
commerce-batch copies of MvProductRankMonthlyEntity; after changes run
persistence/update and derived queries (e.g., findAllByYearMonth) against
Testcontainers MySQL 8.0 to verify no DDL/DML parsing errors.


@Comment("랭킹 순위")
@Column(name = "product_rank", nullable = false)
private int productRank;

@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

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

public MvProductRankMonthlyEntity(Long productId, double score, String yearMonth, int productRank) {
this.productId = productId;
this.score = score;
this.yearMonth = yearMonth;
this.productRank = productRank;
}
Comment on lines +16 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 | 🟠 Major

단일 productId PK 구조는 월별 이력 보존이 불가능하다.

@IdproductId 단독이므로 동일 상품의 월별 스냅샷을 보관할 수 없고, WeeklyMvRankingItemWriter 등과 동일한 패턴으로 매월 갱신 시 이전 월 데이터가 덮어쓰여 과거 월 랭킹 조회 API가 항상 최신 월 데이터만 반환한다. 컨트롤러/Facade의 period="monthly" + date 파라미터 설계(과거 날짜로 조회 가능)와 정합하지 않는다. (productId, yearMonth) 복합키 또는 별도 PK + unique 제약으로 변경하고, reader/writer/삭제 전략도 함께 재검토가 필요하다.

추가 테스트: (1) 서로 다른 yearMonth로 동일 productId 저장 시 두 행이 공존하는지, (2) 과거 월 날짜로 API 조회 시 해당 월의 랭킹이 반환되는지 검증한다.

🤖 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/ranking/MvProductRankMonthlyEntity.java`
around lines 16 - 46, MvProductRankMonthlyEntity currently uses productId as the
sole `@Id` which prevents preserving monthly snapshots; change the key strategy so
that productId and yearMonth form a composite primary key (or introduce a
surrogate PK plus a unique constraint on (productId, yearMonth)) and update
persistence mappings accordingly (adjust annotations on productId and yearMonth
in MvProductRankMonthlyEntity); also review and update related components such
as WeeklyMvRankingItemWriter/reader/delete logic to insert/update by the new key
strategy, and add tests verifying that saving the same productId with different
yearMonth produces two rows and that querying by past month returns that month’s
ranking.


public void update(double score, String yearMonth, int productRank) {
this.score = score;
this.yearMonth = yearMonth;
this.productRank = productRank;
}

@PrePersist
private void prePersist() {
LocalDateTime now = LocalDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}

@PreUpdate
private void preUpdate() {
this.updatedAt = LocalDateTime.now();
}

public Long getProductId() {
return productId;
}

public double getScore() {
return score;
}

public String getYearMonth() {
return yearMonth;
}

public int getProductRank() {
return productRank;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.loopers.infrastructure.ranking;

import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MvProductRankMonthlyJpaRepository extends JpaRepository<MvProductRankMonthlyEntity, Long> {

List<MvProductRankMonthlyEntity> findAllByYearMonthOrderByProductRankAsc(String yearMonth, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.loopers.infrastructure.ranking;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Comment;

@Entity
@Table(name = "mv_product_rank_weekly")
@NoArgsConstructor
public class MvProductRankWeeklyEntity {

@Id
@Comment("상품 id (ref)")
@Column(name = "product_id", nullable = false, updatable = false)
private Long productId;

@Comment("집계 점수")
@Column(name = "score", nullable = false)
private double score;

@Comment("집계 기준 주 (해당 주 월요일, e.g. 20260414)")
@Column(name = "year_week", nullable = false, length = 8)
private String yearWeek;

@Comment("랭킹 순위")
@Column(name = "product_rank", nullable = false)
private int productRank;

@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

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

public MvProductRankWeeklyEntity(Long productId, double score, String yearWeek, int productRank) {
this.productId = productId;
this.score = score;
this.yearWeek = yearWeek;
this.productRank = productRank;
}

public void update(double score, String yearWeek, int productRank) {
this.score = score;
this.yearWeek = yearWeek;
this.productRank = productRank;
}

@PrePersist
private void prePersist() {
LocalDateTime now = LocalDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}

@PreUpdate
private void preUpdate() {
this.updatedAt = LocalDateTime.now();
}

public Long getProductId() {
return productId;
}

public double getScore() {
return score;
}

public String getYearWeek() {
return yearWeek;
}

public int getProductRank() {
return productRank;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.loopers.infrastructure.ranking;

import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MvProductRankWeeklyJpaRepository extends JpaRepository<MvProductRankWeeklyEntity, Long> {

List<MvProductRankWeeklyEntity> findAllByYearWeekOrderByProductRankAsc(String yearWeek, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
import com.loopers.config.redis.RedisConfig;
import com.loopers.domain.ranking.RankedProduct;
import com.loopers.domain.ranking.RankingRepository;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.redis.connection.zset.Aggregate;
import org.springframework.data.redis.connection.zset.Weights;
import org.springframework.data.redis.core.RedisTemplate;
Expand All @@ -25,13 +27,19 @@ public class RankingRepositoryImpl implements RankingRepository {

private final RedisTemplate<String, String> redisTemplate;
private final RedisTemplate<String, String> masterRedisTemplate;
private final MvProductRankWeeklyJpaRepository weeklyRepository;
private final MvProductRankMonthlyJpaRepository monthlyRepository;

public RankingRepositoryImpl(
RedisTemplate<String, String> redisTemplate,
@Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate<String, String> masterRedisTemplate
@Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate<String, String> masterRedisTemplate,
MvProductRankWeeklyJpaRepository weeklyRepository,
MvProductRankMonthlyJpaRepository monthlyRepository
) {
this.redisTemplate = redisTemplate;
this.masterRedisTemplate = masterRedisTemplate;
this.weeklyRepository = weeklyRepository;
this.monthlyRepository = monthlyRepository;
}

@Override
Expand All @@ -48,6 +56,24 @@ public List<RankedProduct> getTopN(LocalDate date, int size, int page) {
.toList();
}

@Override
public List<RankedProduct> getWeeklyTopN(LocalDate date, int size, int page) {
String yearWeek = date.with(DayOfWeek.MONDAY).format(DateTimeFormatter.BASIC_ISO_DATE);
return weeklyRepository.findAllByYearWeekOrderByProductRankAsc(yearWeek, PageRequest.of(page, size))
.stream()
.map(e -> new RankedProduct(e.getProductId(), e.getScore()))
.toList();
}

@Override
public List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page) {
String yearMonth = date.format(DateTimeFormatter.ofPattern("yyyyMM"));
return monthlyRepository.findAllByYearMonthOrderByProductRankAsc(yearMonth, PageRequest.of(page, size))
.stream()
.map(e -> new RankedProduct(e.getProductId(), e.getScore()))
.toList();
}
Comment on lines +68 to +75
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

월 단위 포맷 상수를 배치와 공유해 포맷 드리프트를 차단하도록 한다.

배치 writer(apps/commerce-batch/.../MonthlyMvRankingItemWriter.java)는 RankingMonthlyJobConfig.YEAR_MONTH_FORMAT을 사용해 yearMonth를 적재하는데, 조회 측(본 구현)은 리터럴 "yyyyMM"을 인라인으로 중복 정의하고 있다. 한쪽이 "yyyy-MM"·"yyyyMMM" 등으로 변경되면 조회 결과가 조용히 공집합이 되어 랭킹 화면이 빈 페이지로 노출되는 운영 장애로 번질 수 있다. 주간 BASIC_ISO_DATE 역시 같은 위험이 있어 상수 추출 또는 공유 상수 참조를 권고한다.

♻️ 제안 수정
-    `@Override`
-    public List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page) {
-        String yearMonth = date.format(DateTimeFormatter.ofPattern("yyyyMM"));
+    private static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyyMM");
+
+    `@Override`
+    public List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page) {
+        String yearMonth = date.format(YEAR_MONTH_FORMAT);
         return monthlyRepository.findAllByYearMonthOrderByProductRankAsc(yearMonth, PageRequest.of(page, size))
                 .stream()
                 .map(e -> new RankedProduct(e.getProductId(), e.getScore()))
                 .toList();
     }

공용 포맷 상수(예: com.loopers.domain.ranking 하위 상수 클래스)로 끌어올려 배치/조회 양측이 동일 심볼을 참조하게 만들거나, 배치의 RankingMonthlyJobConfig.YEAR_MONTH_FORMAT을 상향 이동하는 방식을 권고한다.

추가 테스트 권고

  • 2026-01-05 같은 경계 날짜에 대해 주간 키가 20251229(전년도 마지막 주 월요일)로 산출되는지, 동일 입력에 대해 배치 writer와 조회 구현이 같은 키를 생성하는지 확인하는 계약(contract) 테스트.
🤖 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/ranking/RankingRepositoryImpl.java`
around lines 68 - 75, getMonthlyTopN uses an inline DateTimeFormatter pattern
"yyyyMM" causing format drift with the batch writer; replace the literal with a
shared constant to ensure both writer and reader use the same symbol (e.g.
reference the existing RankingMonthlyJobConfig.YEAR_MONTH_FORMAT or move a
common constant into a shared package such as com.loopers.domain.ranking) and
update getMonthlyTopN to format the date using that shared constant; also verify
similar weekly formatting uses the shared BASIC_ISO_DATE constant if applicable.


@Override
public Long getRank(Long productId, LocalDate date) {
String key = KEY_PREFIX + date.format(DATE_FORMATTER);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ public class RankingV1Controller {
@GetMapping
public ApiResponse<List<RankingV1Dto.RankingResponse>> getRankings(
@RequestParam String date,
@RequestParam(defaultValue = "daily") String period,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
LocalDate localDate = LocalDate.parse(date, DATE_FORMATTER);
List<RankingInfo> rankings = rankingFacade.getRankings(localDate, size, page);
List<RankingInfo> rankings = rankingFacade.getRankings(localDate, period, size, page);
List<RankingV1Dto.RankingResponse> response = rankings.stream()
.map(RankingV1Dto.RankingResponse::from)
.toList();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.loopers.batch.job.ranking;

import com.loopers.batch.listener.JobListener;
import com.loopers.batch.listener.StepMonitorListener;
import com.loopers.batch.job.ranking.step.MonthlyMvRankingItemWriter;
import com.loopers.batch.job.ranking.step.MonthlyRankAssignTasklet;
import com.loopers.batch.job.ranking.step.RankedProductDto;
import com.loopers.batch.job.ranking.step.RankingItemProcessor;
import com.loopers.infrastructure.metrics.ProductMetricsAggregatedDto;
import java.time.format.DateTimeFormatter;
import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.database.JdbcPagingItemReader;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingMonthlyJobConfig.JOB_NAME)
@RequiredArgsConstructor
@Configuration
public class RankingMonthlyJobConfig {

public static final String JOB_NAME = "rankingMonthlyJob";
public static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyyMM");
private static final int CHUNK_SIZE = 100;

private final JobRepository jobRepository;
private final JobListener jobListener;
private final StepMonitorListener stepMonitorListener;
private final PlatformTransactionManager transactionManager;
private final JdbcPagingItemReader<ProductMetricsAggregatedDto> productMetricsItemReader;
private final RankingItemProcessor rankingItemProcessor;
private final MonthlyMvRankingItemWriter monthlyMvRankingItemWriter;
private final MonthlyRankAssignTasklet monthlyRankAssignTasklet;

@Bean(JOB_NAME)
public Job rankingMonthlyJob() {
return new JobBuilder(JOB_NAME, jobRepository)
.start(monthlyScoreCalculationStep())
.next(monthlyRankAssignStep())
.listener(jobListener)
.build();
}

@Bean("monthlyScoreCalculationStep")
public Step monthlyScoreCalculationStep() {
return new StepBuilder("monthlyScoreCalculationStep", jobRepository)
.<ProductMetricsAggregatedDto, RankedProductDto>chunk(CHUNK_SIZE, transactionManager)
.reader(productMetricsItemReader)
.processor(rankingItemProcessor)
.writer(monthlyMvRankingItemWriter)
.listener(stepMonitorListener)
.build();
}

@Bean("monthlyRankAssignStep")
public Step monthlyRankAssignStep() {
return new StepBuilder("monthlyRankAssignStep", jobRepository)
.tasklet(monthlyRankAssignTasklet, transactionManager)
.listener(stepMonitorListener)
.build();
}
}
Loading