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 @@ -3,7 +3,9 @@
import com.loopers.domain.model.brand.Brand;
import com.loopers.domain.model.common.PageResult;
import com.loopers.domain.model.product.Product;
import com.loopers.domain.model.ranking.RankingPeriod;
import com.loopers.domain.repository.BrandRepository;
import com.loopers.domain.repository.PeriodRankingRepository;
import com.loopers.domain.repository.ProductRepository;
import com.loopers.domain.repository.RankingRepository;
import com.loopers.domain.repository.RankingRepository.RankedProduct;
Expand All @@ -21,27 +23,51 @@
public class RankingQueryService implements RankingQueryUseCase {

private final RankingRepository rankingRepository;
private final PeriodRankingRepository periodRankingRepository;
private final ProductRepository productRepository;
private final BrandRepository brandRepository;

public RankingQueryService(RankingRepository rankingRepository,
PeriodRankingRepository periodRankingRepository,
ProductRepository productRepository,
BrandRepository brandRepository) {
this.rankingRepository = rankingRepository;
this.periodRankingRepository = periodRankingRepository;
this.productRepository = productRepository;
this.brandRepository = brandRepository;
}

@Override
public PageResult<RankingItemInfo> getRankings(LocalDate date, int page, int size) {
return getRankings(date, page, size, RankingPeriod.DAILY);
}

@Override
public PageResult<RankingItemInfo> getRankings(LocalDate date, int page, int size, RankingPeriod period) {
int offset = page * size;
List<RankedProduct> rankedProducts = rankingRepository.getTopRankings(date, offset, size);

List<RankedProduct> rankedProducts;
long totalCount;

switch (period) {
case WEEKLY -> {
rankedProducts = periodRankingRepository.getWeeklyRankings(date, offset, size);
totalCount = periodRankingRepository.getWeeklyTotalCount(date);
}
case MONTHLY -> {
rankedProducts = periodRankingRepository.getMonthlyRankings(date, offset, size);
totalCount = periodRankingRepository.getMonthlyTotalCount(date);
}
default -> {
rankedProducts = rankingRepository.getTopRankings(date, offset, size);
totalCount = rankingRepository.getTotalCount(date);
}
}

if (rankedProducts.isEmpty()) {
return new PageResult<>(Collections.emptyList(), page, size, 0, 0);
}

long totalCount = rankingRepository.getTotalCount(date);
int totalPages = (int) Math.ceil((double) totalCount / size);
Comment on lines +46 to 71
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

입력 가드 부재로 size/page 경계값에서 500이 발생한다.

현재 메서드는 size <= 0, page < 0에 대한 사전 검증이 없다. 아래 경로에서 즉시 런타임 예외가 발생한다:

  • size = 0: 하위 JpaPeriodRankingRepository에서 PageRequest.of(offset / size, size)ArithmeticException, 또한 71행 totalCount / size도 동일.
  • page < 0: offset = page * size가 음수가 되어 PageRequest.of에서 IllegalArgumentException.

이 서비스는 컨트롤러 외에도 재사용될 수 있고, 컨트롤러가 Bean Validation을 쓰지 않는 정책(학습)이므로 유효성은 도메인/서비스 계층에서 막아야 한다. 진입부에서 CoreException(BAD_REQUEST)로 일관된 에러 응답을 반환하도록 가드를 추가할 것을 권장한다.

🛡️ 제안 diff
     public PageResult<RankingItemInfo> getRankings(LocalDate date, int page, int size, RankingPeriod period) {
+        if (size <= 0 || page < 0 || period == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "invalid ranking query parameters");
+        }
         int offset = page * size;

추가 테스트로 size=0, page=-1, period=null 각각에 대해 표준 에러 포맷이 반환되는지 확인하는 단위 테스트를 제안한다.

As per coding guidelines("예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다.") — 예외 메시지는 사용자용/로그용을 분리해 민감한 쿼리 파라미터를 그대로 노출하지 않도록 주의한다.

🤖 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/RankingQueryService.java`
around lines 46 - 71, The getRankings method in RankingQueryService lacks input
guards for invalid pagination/period values; add validation at the start of
getRankings to check if size <= 0 or page < 0 or period == null and throw a
CoreException(BAD_REQUEST) with a user-facing message (e.g., "Invalid pagination
parameters") while preserving the original cause for logs and using a separate
detailed log message; ensure the thrown exception is created from the service
layer (not controller) and references the same behavior for both
periodRankingRepository and rankingRepository code paths so downstream
PageRequest creation and the totalCount/size division cannot divide by zero or
receive negative offsets.


List<Long> productIds = rankedProducts.stream()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package com.loopers.application.ranking;

import com.loopers.domain.model.common.PageResult;
import com.loopers.domain.model.ranking.RankingPeriod;

import java.time.LocalDate;

public interface RankingQueryUseCase {

PageResult<RankingItemInfo> getRankings(LocalDate date, int page, int size);

PageResult<RankingItemInfo> getRankings(LocalDate date, int page, int size, RankingPeriod period);

Long getProductRank(LocalDate date, Long productId);

record RankingItemInfo(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.loopers.domain.model.ranking;

public enum RankingPeriod {
DAILY,
WEEKLY,
MONTHLY
}
Comment on lines +3 to +7
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP --type=java -C3 'RankingPeriod\s+period' 
rg -nP --type=java -C5 '@RequestParam.*RankingPeriod'

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

Length of output: 4467


🏁 Script executed:

#!/bin/bash
# Find ApiControllerAdvice or similar exception handlers
fd -t f -e java | xargs rg -l 'ApiControllerAdvice|@ControllerAdvice' | head -5

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check full RankingPeriod enum with all annotations
cat -n apps/commerce-api/src/main/java/com/loopers/domain/model/ranking/RankingPeriod.java

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

Length of output: 241


🏁 Script executed:

#!/bin/bash
# Search for RankingController tests
find . -path '*test*' -name '*RankingController*' -type f

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Search for custom converters or JsonCreator for enum handling
rg -nP --type=java '@JsonCreator|Converter.*Ranking|ConfigureWebMvc' apps/commerce-api/src

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Search for any ControllerAdvice or exception handler more broadly
rg -nP --type=java 'ControllerAdvice|RestControllerAdvice|ExceptionHandler' apps/commerce-api/src/main

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

Length of output: 1473


🏁 Script executed:

#!/bin/bash
# Search for all test files
find . -type f -name '*.java' | grep -i test | head -20

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

Length of output: 1808


🏁 Script executed:

#!/bin/bash
# Check RankingController file completely
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java | head -50

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

Length of output: 2068


🏁 Script executed:

#!/bin/bash
# Check the full GlobalExceptionHandler to see how Exception is handled
cat -n apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java

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

Length of output: 3933


🏁 Script executed:

#!/bin/bash
# Search specifically for RankingController tests
find . -type f -name '*.java' | xargs rg -l 'RankingController' | grep -i test

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

Length of output: 67


MethodArgumentTypeMismatchException에 대한 에러 처리 추가가 필수이다.

@RequestParam(defaultValue = "DAILY") RankingPeriod period 바인딩 시 잘못된 값("invalid", 소문자 "daily" 등)이 전달되면, Spring의 기본 Enum 변환기가 MethodArgumentTypeMismatchException을 발생시킨다. 현재 GlobalExceptionHandler는 이 예외에 대한 명시적 핸들러가 없어 제네릭 Exception 핸들러로 폴백되어 500 INTERNAL_ERROR를 반환한다. 이는 클라이언트 입력 오류(4xx)를 서버 오류(5xx)로 분류하는 것으로, 운영 모니터링과 클라이언트 에러 처리 전략이 일관되지 않는다.

다음을 적용할 것을 권고한다.

  1. MethodArgumentTypeMismatchException 핸들러 추가: GlobalExceptionHandler에 @ExceptionHandler(MethodArgumentTypeMismatchException.class) 추가하여 400 BAD_REQUEST로 응답하고, 응답 형식을 {"code": "INVALID_PARAMETER", "message": "..."} 로 표준화한다.

  2. 테스트 케이스 추가: period=invalid, period=daily(소문자), period=DAILY(정상), period 누락(기본값 동작) 네 케이스에 대한 RankingController 통합 테스트를 추가한다.

🤖 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/model/ranking/RankingPeriod.java`
around lines 3 - 7, Add a specific handler for
MethodArgumentTypeMismatchException in GlobalExceptionHandler: implement an
`@ExceptionHandler`(MethodArgumentTypeMismatchException.class) method that returns
400 BAD_REQUEST with a standardized body like
{"code":"INVALID_PARAMETER","message":"..."} and ensure the message references
the invalid parameter name/value; reference the GlobalExceptionHandler class and
MethodArgumentTypeMismatchException symbol. Also add integration tests for
RankingController covering period=invalid, period=daily (lowercase),
period=DAILY (valid), and omitted period (uses default) to assert 400 for
invalid values and correct 200 responses for valid/default cases, using
RankingPeriod to validate expected behavior.

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

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.WeekFields;
import java.util.Locale;

public final class RankingPeriodKeyResolver {

private static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");

private RankingPeriodKeyResolver() {}

public static String toYearWeek(LocalDate date) {
WeekFields weekFields = WeekFields.of(Locale.KOREA);
int year = date.getYear();
int week = date.get(weekFields.weekOfWeekBasedYear());
return String.format("%d-W%02d", year, week);
}
Comment on lines +14 to +19
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 | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Java WeekFields weekOfWeekBasedYear weekBasedYear difference Locale.KOREA

💡 Result:

In Java's java.time.temporal.WeekFields, weekOfWeekBasedYear returns a TemporalField for the week number within the week-based year, and weekBasedYear returns the TemporalField for the week-based year itself. These fields define a week-based-year system where each week belongs to exactly one year: Week 1 is the first week starting on the first day-of-week (e.g., Monday in ISO) with at least the minimal days in the first week (e.g., 4). The first/last weeks may overlap calendar years. Key difference from standard year/week-of-year: week-based-year can differ from the proleptic calendar year near year boundaries. Example from docs (Monday first, min 4 days): 2008-12-31 (Wed) is week 1 of 2009 in one config, week 53 of 2008 in another. For Locale.KOREA (ko_KR), WeekFields.of(Locale.KOREA) uses locale-specific firstDayOfWeek (Sunday, as Korean weekdays start with 일요일) and minimalDaysInFirstWeek (likely 1, similar to US). This affects week numbering: e.g., first days of calendar year may belong to prior week-based-year's last week. Usage example: WeekFields wf = WeekFields.of(Locale.KOREA); LocalDate date = LocalDate.of(2026, 1, 1); int wby = date.get(wf.weekBasedYear); // May be 2025 if Jan 1 in last week of prior WB-year int wowby = date.get(wf.weekOfWeekBasedYear); // e.g., 52 or 53 No explicit numerical "difference" found; the fields work together for week-date representation (WB-year + week-of-WB-year + day-of-week), differing from ChronoField.YEAR + weekOfYear by strict week-year alignment.

Citations:


date.getYear()weekOfWeekBasedYear()의 조합이 연말/연초 경계에서 잘못된 키를 생성한다.

weekOfWeekBasedYear()는 주(week) 기준 연도에 속하는 주차를 반환하므로, 짝이 되는 연도는 date.getYear()(달력 연도)가 아니라 weekFields.weekBasedYear()이어야 한다. Locale.KOREA(일요일 시작, 최소 1일)에서 연말/연초 경계일자의 달력 연도와 주 기준 연도가 달라질 수 있으며, 현재 구현은 "2025-W01"을 생성해야 할 날짜에 "2024-W01"을 생성할 수 있다. API의 RankingPeriodKeyResolver와 배치 Writer가 다른 키를 생성하면 API 조회 시 해당 주차 데이터가 조회되지 않아 사용자에게 "주간 랭킹 없음"으로 노출되는 장애를 초래한다.

🐛 제안 수정
 public static String toYearWeek(LocalDate date) {
     WeekFields weekFields = WeekFields.of(Locale.KOREA);
-    int year = date.getYear();
-    int week = date.get(weekFields.weekOfWeekBasedYear());
+    int year = date.get(weekFields.weekBasedYear());
+    int week = date.get(weekFields.weekOfWeekBasedYear());
     return String.format("%d-W%02d", year, week);
 }

연말/연초 경계일자(2023-01-01, 2024-12-30, 2024-12-31, 2025-01-01 등)에 대해 API·배치 두 Resolver가 동일한 키를 생성하는지 검증하는 파라미터화 테스트를 추가해야 한다.

🤖 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/model/ranking/RankingPeriodKeyResolver.java`
around lines 14 - 19, The toYearWeek method currently mixes date.getYear() with
weekFields.weekOfWeekBasedYear(), which yields wrong keys around year
boundaries; change the year source to the week-based year via
date.get(weekFields.weekBasedYear()) so toYearWeek(LocalDate) returns
String.format("%d-W%02d", weekBasedYear, week) using
WeekFields.of(Locale.KOREA), and add a parameterized test that asserts
toYearWeek produces identical keys for known boundary dates (e.g., 2023-01-01,
2024-12-30, 2024-12-31, 2025-01-01) to ensure API and batch resolver parity.


public static String toYearMonth(LocalDate date) {
return date.format(YEAR_MONTH_FORMAT);
}
Comment on lines +8 to +23
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

동일 로직이 commerce-batchRankingPeriodKeyResolver와 완전히 중복된다.

apps/commerce-batch/src/main/java/com/loopers/batch/support/RankingPeriodKeyResolver.java와 주/월 키 산출 로직이 똑같이 존재한다. 한쪽만 수정되면 API와 배치가 서로 다른 키를 만들게 되어 랭킹이 비는 장애가 날 수 있다. 운영 관점에서 "어떤 경우에도 두 모듈이 같은 키를 생산해야 한다"는 불변을 코드 레벨에서 강제해야 한다.

  • 공통 모듈(commerce-domain/commerce-support 등)로 추출해 두 모듈이 동일 구현을 참조하도록 리팩터링할 것을 권고한다.
  • 추출이 즉시 어렵다면, 최소한 두 Resolver 간 동일 출력 보장 계약 테스트(contract test)를 배치/ 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/domain/model/ranking/RankingPeriodKeyResolver.java`
around lines 8 - 23, The RankingPeriodKeyResolver logic (methods toYearWeek and
toYearMonth in class RankingPeriodKeyResolver) is duplicated between
apps/commerce-api and apps/commerce-batch; extract these methods and the
YEAR_MONTH_FORMAT constant into a shared module (e.g., commerce-domain or
commerce-support) and have both modules reference the single shared
RankingPeriodKeyResolver, or if extraction isn’t feasible immediately, add a
contract test that invokes RankingPeriodKeyResolver.toYearWeek(LocalDate) and
toYearMonth(LocalDate) from both API and batch with a common dataset to assert
identical outputs; update imports/usages to reference the shared resolver and
remove the duplicate class.

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

import com.loopers.domain.repository.RankingRepository.RankedProduct;

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

public interface PeriodRankingRepository {

List<RankedProduct> getWeeklyRankings(LocalDate date, int offset, int size);

long getWeeklyTotalCount(LocalDate date);

List<RankedProduct> getMonthlyRankings(LocalDate date, int offset, int size);

long getMonthlyTotalCount(LocalDate date);
Comment on lines +10 to +16
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

저장된 랭크의 1-based ↔ 0-based 변환 규약을 인터페이스 문서로 명시할 것을 권고한다.

동일 RankedProduct 레코드를 반환하지만, 일간(RankingRepository)과 기간(PeriodRankingRepository)의 rank 의미가 각각 "Redis 0-based"와 "MV 1-based → 0-based 변환값"으로 다르다(요약 정보 기준). 문서화가 없으면 호출측에서 +1 또는 -1 실수로 오프바이원 버그가 자라기 쉽다. 운영 관점에서는 "1위 상품이 2위로 보임" 같은 사용자 가시 장애가 된다.

  • 인터페이스 Javadoc에 rank의 0-based 규약과 정렬 보장(동점 처리, ranking asc, productId tiebreaker 등)을 명시할 것을 권고한다.
  • 추가 테스트: 두 Repository 구현체에 대해 "첫 번째 요소의 rank == offset" 불변 검증 테스트를 권고한다.
🤖 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/repository/PeriodRankingRepository.java`
around lines 10 - 16, PeriodRankingRepository의
getWeeklyRankings/getMonthlyRankings가 반환하는 RankedProduct.rank의 기준(0-based인지
1-based인지)과 정렬·동점 처리 규칙이 명시되어 있지 않아 호출측에서 오프바이원 버그가 발생할 수 있으니 인터페이스 Javadoc에
rank는 0-based 기준임을 명시하고 정렬 보장(예: ranking 오름차순, 동점 시 productId로 tiebreaker)과
offset과의 관계(첫 요소의 rank == offset)까지 기술하세요; 동일 규약을 RankingRepository에도 반영해 문서
일관성을 유지하고, 두 Repository 구현체(PeriodRankingRepository와 RankingRepository)에 대해 "첫
번째 요소의 rank == offset" 불변을 검증하는 단위 테스트를 추가하세요.

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

import com.loopers.domain.model.ranking.RankingPeriodKeyResolver;
import com.loopers.domain.repository.PeriodRankingRepository;
import com.loopers.domain.repository.RankingRepository.RankedProduct;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Repository;

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

@Repository
public class JpaPeriodRankingRepository implements PeriodRankingRepository {

private final ProductRankWeeklyJpaRepository weeklyRepository;
private final ProductRankMonthlyJpaRepository monthlyRepository;

public JpaPeriodRankingRepository(ProductRankWeeklyJpaRepository weeklyRepository,
ProductRankMonthlyJpaRepository monthlyRepository) {
this.weeklyRepository = weeklyRepository;
this.monthlyRepository = monthlyRepository;
}

@Override
public List<RankedProduct> getWeeklyRankings(LocalDate date, int offset, int size) {
String yearWeek = RankingPeriodKeyResolver.toYearWeek(date);
PageRequest pageable = PageRequest.of(offset / size, size);
return weeklyRepository.findByYearWeekOrderByRanking(yearWeek, pageable).stream()
.map(e -> new RankedProduct(e.getProductId(), e.getScore(), e.getRanking() - 1L))
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

엔티티의 ranking이 0이거나 음수일 때 rank 값이 비정상이 된다.

e.getRanking() - 1L은 배치가 ranking을 반드시 1부터 채웠다는 전제를 가진다. 배치 장애/초기화 직후 등으로 ranking = 0인 row가 존재하면 RankedProduct.rank = -1이 되어 상위 계층(RankingQueryService.toRankingItemInforanked.rank() + 1)에서 0위가 반환되는 기묘한 응답이 나간다.

운영 관점에서는 고객이 "1위 상품이 없고 0위가 있다"는 UI 이슈로 문의를 올리기 쉬우므로, 엔티티 생성 시 ranking >= 1 불변식을 보장(생성자 가드)하거나, 여기서 Math.max(0, e.getRanking() - 1)로 방어하는 것이 안전하다. 배치 라이터 ItemProcessor에 "ranking은 1-based" 단위 테스트도 함께 추가할 것을 권장한다.

Also applies to: 43-43

🤖 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/JpaPeriodRankingRepository.java`
at line 29, The mapping in JpaPeriodRankingRepository that constructs new
RankedProduct using e.getRanking() - 1L can produce negative ranks when
entity.ranking is 0 or negative; change the mapping to clamp the computed rank
to >= 0 (e.g. use Math.max(0, e.getRanking() - 1)) or otherwise ensure
RankedProduct receives a non-negative rank, and add defensive checks/guards in
the entity constructor or the batch ItemProcessor to enforce ranking >= 1 (and
add a unit test asserting the batch writer/ItemProcessor treats ranking as
1-based) so RankingQueryService.toRankingItemInfo no longer receives and adjusts
negative ranks.

.toList();
}

@Override
public long getWeeklyTotalCount(LocalDate date) {
return weeklyRepository.countByYearWeek(RankingPeriodKeyResolver.toYearWeek(date));
}

@Override
public List<RankedProduct> getMonthlyRankings(LocalDate date, int offset, int size) {
String yearMonth = RankingPeriodKeyResolver.toYearMonth(date);
PageRequest pageable = PageRequest.of(offset / size, size);
return monthlyRepository.findByYearMonthOrderByRanking(yearMonth, pageable).stream()
.map(e -> new RankedProduct(e.getProductId(), e.getScore(), e.getRanking() - 1L))
.toList();
}

@Override
public long getMonthlyTotalCount(LocalDate date) {
return monthlyRepository.countByYearMonth(RankingPeriodKeyResolver.toYearMonth(date));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.loopers.infrastructure.ranking;

import com.loopers.infrastructure.ranking.entity.ProductRankMonthlyJpaEntity;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface ProductRankMonthlyJpaRepository extends JpaRepository<ProductRankMonthlyJpaEntity, Long> {

@Query("SELECT e FROM ProductRankMonthlyJpaEntity e WHERE e.yearMonth = :yearMonth ORDER BY e.ranking ASC")
List<ProductRankMonthlyJpaEntity> findByYearMonthOrderByRanking(
@Param("yearMonth") String yearMonth, Pageable pageable);

long countByYearMonth(String yearMonth);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.loopers.infrastructure.ranking;

import com.loopers.infrastructure.ranking.entity.ProductRankWeeklyJpaEntity;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface ProductRankWeeklyJpaRepository extends JpaRepository<ProductRankWeeklyJpaEntity, Long> {

@Query("SELECT e FROM ProductRankWeeklyJpaEntity e WHERE e.yearWeek = :yearWeek ORDER BY e.ranking ASC")
List<ProductRankWeeklyJpaEntity> findByYearWeekOrderByRanking(
@Param("yearWeek") String yearWeek, Pageable pageable);

long countByYearWeek(String yearWeek);
Comment on lines +13 to +17
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 주간/월간 MV 테이블 스키마 및 인덱스 선언을 레포 내에서 조사
rg -nP -C3 'mv_product_rank_(weekly|monthly)'
fd -t f -e sql | xargs -I{} grep -l -iE 'mv_product_rank' {} 2>/dev/null

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

Length of output: 4336


🏁 Script executed:

#!/bin/bash
# 1. Flyway/Liquibase 마이그레이션 파일 찾기
echo "=== Migration files ==="
fd -e sql -e yaml -e xml | grep -iE '(flyway|liquibase|migration|schema)' | head -20

# 2. ProductRankWeeklyJpaEntity 의 인덱스 정의 확인
echo "=== ProductRankWeeklyJpaEntity content ==="
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/ProductRankWeeklyJpaEntity.java

# 3. JpaPeriodRankingRepository 찾기
echo "=== JpaPeriodRankingRepository ==="
find . -type f -name '*JpaPeriodRankingRepository*'

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

Length of output: 1600


🏁 Script executed:

#!/bin/bash
# ProductRankWeeklyJpaRepository 전체 내용 확인
echo "=== ProductRankWeeklyJpaRepository full content ==="
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyJpaRepository.java

# 호출부 찾기
echo "=== Callers of ProductRankWeeklyJpaRepository ==="
rg 'ProductRankWeeklyJpaRepository|findByYearWeekOrderByRanking' -A2 -B2

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

Length of output: 6556


🏁 Script executed:

#!/bin/bash
# 스키마 정의 파일 검색
echo "=== Schema SQL files ==="
fd -e sql
echo ""
echo "=== application.yml/properties DB config ==="
find . -name 'application*.yml' -o -name 'application*.properties' | xargs grep -l 'jpa\|hibernate' 2>/dev/null

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

Length of output: 519


🏁 Script executed:

#!/bin/bash
# V5__create_product_indexes.sql 확인
echo "=== V5__create_product_indexes.sql ==="
cat -n scripts/V5__create_product_indexes.sql

# MV 테이블 생성 스크립트 찾기
echo ""
echo "=== MV table creation scripts ==="
rg -n 'CREATE TABLE.*mv_product_rank' -A10

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

Length of output: 1298


🏁 Script executed:

#!/bin/bash
# 모든 SQL 스크립트에서 mv_product_rank 관련 내용 확인
echo "=== All mentions of mv_product_rank in SQL files ==="
fd -e sql -x grep -l -i 'mv_product_rank' {} \; | while read f; do
  echo "=== File: $f ==="
  cat -n "$f" | grep -i -E '(CREATE TABLE|INDEX|mv_product_rank)' -A2 -B2
done

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

Length of output: 137


🏁 Script executed:

#!/bin/bash
# Flyway/Liquibase 설정 확인
echo "=== Flyway config in application.yml ==="
grep -A20 -i 'flyway\|liquibase' apps/commerce-api/src/main/resources/application.yml

# 모든 마이그레이션 디렉토리 확인
echo ""
echo "=== Migration directories ==="
find . -type d -name 'db' -o -name 'migration' -o -name 'flyway' -o -name 'liquibase' 2>/dev/null | head -20

# V1, V2, V3, V4 등 다른 버전 스크립트 찾기
echo ""
echo "=== All migration scripts ==="
find . -name 'V*.sql' -o -name 'V*.yaml'

# 테이블 생성 관련 파일 찾기
echo ""
echo "=== Table creation queries ==="
rg 'CREATE TABLE' -n

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

Length of output: 433


🏁 Script executed:

#!/bin/bash
# 엔티티 매핑 재확인 - JPA가 테이블 자동 생성하는지 확인
echo "=== JPA DDL settings in application.yml ==="
grep -E 'hibernate.*ddl|jpa.*ddl|generate-ddl|spring.jpa' apps/commerce-api/src/main/resources/application.yml

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

Length of output: 128


🏁 Script executed:

#!/bin/bash
echo "=== commerce-api application.yml (full) ==="
cat -n apps/commerce-api/src/main/resources/application.yml | head -80

echo ""
echo "=== Spring Data JPA related settings ==="
rg -i 'jpa|hibernate|ddl' apps/commerce-api/src/main/resources/application.yml

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

Length of output: 2639


🏁 Script executed:

#!/bin/bash
# MV 테이블 생성 관련 문서 확인
echo "=== Documentation about MV tables ==="
cat -n claudedocs/10weeks/lec10.md | grep -A15 -B5 'Materialized View\|mv_product'

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

Length of output: 2918


🏁 Script executed:

#!/bin/bash
echo "=== jpa.yml content ==="
find . -name 'jpa.yml' -exec cat -n {} \;

echo ""
echo "=== batch application.yml for JPA settings ==="
grep -E 'jpa|hibernate|ddl' apps/commerce-batch/src/main/resources/application.yml

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

Length of output: 2709


🏁 Script executed:

#!/bin/bash
# 배치에서 MV 테이블을 어떻게 생성/관리하는지 확인
echo "=== ProductRankWeeklyEntity in batch (with table definition) ==="
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductRankWeeklyEntity.java

echo ""
echo "=== WeeklyRankingJobConfig ==="
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java | head -100

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

Length of output: 7831


(yearWeek, ranking) 복합 인덱스를 엔티티 정의에 명시적으로 추가할 것.

WHERE yearWeek = :yearWeek ORDER BY ranking ASC 쿼리가 운영 환경에서 인덱스 없이 실행되면 데이터 증가에 따라 filesort가 발생해 응답 지연을 초래한다.

ProductRankWeeklyJpaEntity와 배치 ProcessRankWeeklyEntity 모두 @Index 어노테이션이 없다. 다음과 같이 엔티티에 복합 인덱스를 추가하고, 운영 환경 DDL이 실제로 반영되는지 확인할 것:

`@Entity`
`@Table`(name = "mv_product_rank_weekly", 
       indexes = `@Index`(name = "idx_year_week_ranking", columnList = "yearWeek, ranking"))
public class ProductRankWeeklyJpaEntity {
  // ...
}

현재 호출부(JpaPeriodRankingRepository)는 PageRequest.of(offset / size, size)로 Sort를 주입하지 않으므로 JPQL의 ORDER BY와 중복되지 않는다. 다만 향후 호출부가 변경될 경우 주의할 것.

이후 운영 환경에서 쿼리 실행 계획(EXPLAIN)을 검증해 인덱스 스캔이 실제로 발생하는지 확인할 것.

🤖 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/ProductRankWeeklyJpaRepository.java`
around lines 13 - 17, Add a composite index on (yearWeek, ranking) to the
ProductRankWeeklyJpaEntity (and mirror it on ProcessRankWeeklyEntity) by
annotating the entity `@Table` with an `@Index` (e.g. name="idx_year_week_ranking",
columnList="yearWeek, ranking"); ensure the column names in the index match the
entity field/column names, redeploy/migrate so the DDL is applied in the
environment, and validate with EXPLAIN that queries from
JpaPeriodRankingRepository (which currently builds PageRequest without a Sort)
use an index scan and not filesort; adjust the repository paging/sort callers
only if they would conflict with the JPQL ORDER BY.

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

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

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

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

@Column(nullable = false)
private Long productId;

@Column(nullable = false, length = 7)
private String yearMonth;

@Column(nullable = false)
private long likeCount;

@Column(nullable = false)
private long orderCount;

@Column(nullable = false)
private long viewCount;

@Column(nullable = false)
private long totalSalesAmount;

@Column(nullable = false)
private double score;

@Column(nullable = false)
private int ranking;

@Column(nullable = false)
private LocalDateTime updatedAt;
Comment on lines +14 to +45
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 스키마 선언 및 엔티티 생성자/빌더 확인
fd -t f ProductRankMonthlyEntity.java
rg -nP -C3 'ProductRankMonthlyJpaEntity|mv_product_rank_monthly'
rg -nP -C3 '@PrePersist|@PreUpdate'

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

Length of output: 6175


🏁 Script executed:

# ProductRankMonthlyJpaEntity 전체 파일 내용 확인
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/ProductRankMonthlyJpaEntity.java

# ProductRankMonthlyEntity 전체 파일 내용 확인
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductRankMonthlyEntity.java

# BaseEntity 확인
cat -n modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java

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

Length of output: 6211


🏁 Script executed:

# API 엔티티 사용처 확인
rg -n "ProductRankMonthlyJpaEntity" apps/commerce-api --type java -A 2 -B 2 | head -80

# API 엔티티 저장/수정 로직이 있는지 확인
rg -n "save|saveAll|persist" apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking --type java

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

Length of output: 3065


API 엔티티가 유일성 제약과 타임스탬프 자동화를 누락하여 배치와 스키마 정의가 불일치한다.

현재 ProductRankMonthlyJpaEntity는 동일 테이블을 매핑하는 ProductRankMonthlyEntity(배치)와 달리 @UniqueConstraint를 선언하지 않았다. 또한 updatedAt 필드는 초기화 메커니즘이 없어서, API에서 저장 시 런타임 NOT NULL 위반이 발생한다. 배치 엔티티는 생성자와 updateRanking()에서 타임스탬프를 명시적으로 관리하지만, API 엔티티는 같은 보호 장치가 없다.

운영상 문제점:

  • 배치 재실행 중 중복 row가 축적되면 월별 API 조회 결과에 중복 productId가 반환되어 UI가 깨진다.
  • API 코드에서 실수로 저장 시도 시 즉시 실패하므로 문제 발견이 지연된다.

다음을 수정한다:

  • @UniqueConstraint(columnNames = {"productId", "yearMonth"})를 API 엔티티 @Table 어노테이션에 추가하여 배치 엔티티와 일치시킨다.
  • 생성자를 추가하고 updatedAt = LocalDateTime.now()를 초기화하거나, @PrePersist/@PreUpdate 콜백을 구현한다.
  • API 엔티티가 읽기 전용이라면, @Immutable 마킹 또는 저장소 수준에서 읽기 메서드만 노출하도록 제한한다.
🤖 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/entity/ProductRankMonthlyJpaEntity.java`
around lines 14 - 45, Add the missing unique constraint and timestamp automation
to ProductRankMonthlyJpaEntity: annotate the class with `@Table`(uniqueConstraints
= `@UniqueConstraint`(columnNames = {"productId","yearMonth"})) to match the batch
entity, and ensure updatedAt is set automatically by adding either a constructor
that initializes updatedAt = LocalDateTime.now() or lifecycle callbacks
`@PrePersist` and `@PreUpdate` methods that assign updatedAt before insert/update;
optionally mark the entity `@Immutable` or restrict the repository to read-only if
it must not be modified.

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

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

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

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

@Column(nullable = false)
private Long productId;

@Column(nullable = false, length = 7)
private String yearWeek;
Comment on lines +23 to +24
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 배치 쪽 동일 엔티티의 yearWeek 길이 및 키 생성 로직 확인
fd -t f ProductRankWeeklyEntity.java
rg -nP -C2 'yearWeek' --type=java
rg -nP -C2 'toYearWeek|%d-W%02d' --type=java

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

Length of output: 13581


yearWeek 컬럼 길이 불일치로 배치 저장 데이터가 잘려 주간 랭킹 조회 실패.

RankingPeriodKeyResolver.toYearWeek()String.format("%d-W%02d", year, week)으로 "2024-W01"과 같은 8자 문자열을 생성하지만, 엔티티는 length = 7로 선언되어 있다. 배치 쓰기 시 데이터 절단이 발생하고, API 조회는 전체 키(2024-W01)로 매칭을 시도해 결과를 찾지 못한다. 데이터베이스 모드에 따라 배치 INSERT 실패 또는 침묵적 잘림으로 주간 랭킹이 항상 빈 결과를 반환한다.

이 문제는 apps/commerce-apiapps/commerce-batch 양쪽 엔티티에서 동일하게 발생한다.

수정안
-    `@Column`(nullable = false, length = 7)
+    `@Column`(nullable = false, length = 8)
     private String yearWeek;

양쪽 엔티티 모두 적용 필요:

  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/ProductRankWeeklyJpaEntity.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductRankWeeklyEntity.java

추가 검증 필요: 배치 INSERT 후 API 조회가 실제로 데이터를 반환하는지 확인하는 통합 테스트 작성.

📝 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
@Column(nullable = false, length = 7)
private String yearWeek;
`@Column`(nullable = false, length = 8)
private String yearWeek;
🤖 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/entity/ProductRankWeeklyJpaEntity.java`
around lines 23 - 24, 엔티티의 yearWeek 컬럼 길이가 잘못되어(현재 length = 7) "2024-W01" 같은 8자
키가 절단되므로 ProductRankWeeklyJpaEntity의 yearWeek 속성(`@Column`)을 length = 8으로 늘리고 동일한
변경을 apps/commerce-batch의 ProductRankWeeklyEntity(또는 해당 엔티티의 yearWeek 필드)에 동일하게
적용하세요; 또한 관련 스키마/마이그레이션(혹은 JPA 스키마 생성 설정)이 있다면 동기화하고, 변경 후
RankingPeriodKeyResolver.toYearWeek()로 생성한 키로 배치 INSERT 후 API 조회가 정상 동작하는 통합
테스트를 추가해 검증하세요.


@Column(nullable = false)
private long likeCount;

@Column(nullable = false)
private long orderCount;

@Column(nullable = false)
private long viewCount;

@Column(nullable = false)
private long totalSalesAmount;

@Column(nullable = false)
private double score;

@Column(nullable = false)
private int ranking;

@Column(nullable = false)
private LocalDateTime updatedAt;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.loopers.application.ranking.RankingQueryUseCase;
import com.loopers.domain.model.common.PageResult;
import com.loopers.domain.model.ranking.RankingPeriod;
import com.loopers.interfaces.api.common.PageResponse;
import com.loopers.interfaces.api.ranking.dto.RankingItemResponse;
import org.springframework.format.annotation.DateTimeFormat;
Expand All @@ -27,12 +28,13 @@ public RankingController(RankingQueryUseCase rankingQueryUseCase) {
public ResponseEntity<PageResponse<RankingItemResponse>> getRankings(
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "0") int page
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "DAILY") RankingPeriod period
) {
LocalDate targetDate = date != null ? date : LocalDate.now();

PageResult<RankingQueryUseCase.RankingItemInfo> rankings =
rankingQueryUseCase.getRankings(targetDate, page, size);
rankingQueryUseCase.getRankings(targetDate, page, size, period);

return ResponseEntity.ok(PageResponse.from(rankings, RankingItemResponse::from));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.loopers.batch.infrastructure.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

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

@Id
private Long productId;

@Column(nullable = false)
private long likeCount;

@Column(nullable = false)
private long orderCount;

@Column(nullable = false)
private long viewCount;

@Column(nullable = false)
private long totalSalesAmount;

@Column(nullable = false)
private LocalDateTime updatedAt;
}
Loading