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 @@ -27,53 +27,8 @@ public class RankingFacade {

@Transactional(readOnly = true)
public RankingInfo.RankingPageResponse getRankings(String date, int page, int size) {
// 1. ZSET에서 Top-N 조회 (Redis)
// ZREVRANGE ranking:all:{date} {offset} {offset+size-1} WITHSCORES
List<RankingEntry> entries = rankingService.getTopRankings(date, page, size);

if (entries.isEmpty()) {
return new RankingInfo.RankingPageResponse(List.of(), page, size);
}

// 2. productId 목록으로 상품 정보 일괄 조회 (DB)
// ZSET에는 productId만 있으므로, 상품명/가격/이미지 등은 DB에서 조회
List<Long> productIds = entries.stream()
.map(RankingEntry::productId)
.toList();
Map<Long, ProductModel> productMap = productService.getByIds(productIds).stream()
.collect(Collectors.toMap(ProductModel::getId, Function.identity()));

// 3. 브랜드 정보 일괄 조회 (DB)
// 상품의 brandId로 브랜드명을 가져온다 (N+1 방지를 위해 일괄 조회)
Set<Long> brandIds = productMap.values().stream()
.map(ProductModel::getBrandId)
.collect(Collectors.toSet());
Map<Long, BrandModel> brandMap = brandService.getByIds(brandIds);

// 4. ZSET 결과 + 상품 정보 + 브랜드 정보를 조합하여 응답 구성
int baseRank = (page - 1) * size; // 페이지별 기준 순위 (page=2, size=20이면 20)
List<RankingInfo.RankingItem> rankings = new ArrayList<>();

for (int i = 0; i < entries.size(); i++) {
RankingEntry entry = entries.get(i);
ProductModel product = productMap.get(entry.productId());
if (product == null) continue; // 삭제된 상품은 skip

BrandModel brand = brandMap.get(product.getBrandId());
String brandName = brand != null ? brand.getName() : "Unknown";

rankings.add(new RankingInfo.RankingItem(
baseRank + i + 1,
entry.score(),
product.getId(),
product.getName(),
brandName,
product.getPrice(),
product.getImageUrl()
));
}

return new RankingInfo.RankingPageResponse(rankings, page, size);
return assembleResponse(entries, page, size);
}

/**
Expand All @@ -83,22 +38,48 @@ public RankingInfo.RankingPageResponse getRankings(String date, int page, int si
@Transactional(readOnly = true)
public RankingInfo.RankingPageResponse getRankingsFromDB(int page, int size) {
List<RankingEntry> entries = rankingService.getTopRankingsFromDB(page, size);
return assembleResponse(entries, page, size);
}

/** 주간 랭킹 조회: MV 테이블 → 상품/브랜드 Aggregation */
@Transactional(readOnly = true)
public RankingInfo.RankingPageResponse getRankingsWeekly(String date, int page, int size) {
List<RankingEntry> entries = rankingService.getTopRankingsWeekly(date, page, size);
return assembleResponse(entries, page, size);
}

/** 월간 랭킹 조회: MV 테이블 → 상품/브랜드 Aggregation */
@Transactional(readOnly = true)
public RankingInfo.RankingPageResponse getRankingsMonthly(String date, int page, int size) {
List<RankingEntry> entries = rankingService.getTopRankingsMonthly(date, page, size);
return assembleResponse(entries, page, size);
}

/**
* RankingEntry 목록을 상품/브랜드 정보와 조합하여 응답을 구성한다.
* 일간/주간/월간 모두 동일한 Aggregation 로직을 사용하므로 공통 메서드로 추출.
*/
private RankingInfo.RankingPageResponse assembleResponse(
List<RankingEntry> entries, int page, int size
) {
if (entries.isEmpty()) {
return new RankingInfo.RankingPageResponse(List.of(), page, size);
}

// 상품 정보 일괄 조회
List<Long> productIds = entries.stream()
.map(RankingEntry::productId)
.toList();
Map<Long, ProductModel> productMap = productService.getByIds(productIds).stream()
.collect(Collectors.toMap(ProductModel::getId, Function.identity()));

// 브랜드 정보 일괄 조회
Set<Long> brandIds = productMap.values().stream()
.map(ProductModel::getBrandId)
.collect(Collectors.toSet());
Map<Long, BrandModel> brandMap = brandService.getByIds(brandIds);

// 응답 조합
int baseRank = (page - 1) * size;
List<RankingInfo.RankingItem> rankings = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.loopers.domain.ranking;

import jakarta.persistence.*;
import lombok.Getter;
import org.hibernate.annotations.Immutable;

@Entity
@Immutable
@Table(name = "mv_product_rank_monthly")
@Getter
public class MvProductRankMonthlyReadModel {

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

@Column(nullable = false)
private Long productId;

@Column(nullable = false)
private long viewCount;

@Column(nullable = false)
private long likeCount;

@Column(nullable = false)
private long salesCount;

@Column(nullable = false)
private double score;

@Column(name = "`ranking`", nullable = false)
private int ranking;

@Column(name = "`year_month`", nullable = false, length = 10)
private String yearMonth;

protected MvProductRankMonthlyReadModel() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.loopers.domain.ranking;

import jakarta.persistence.*;
import lombok.Getter;
import org.hibernate.annotations.Immutable;

/**
* 주간 MV 읽기 전용 엔티티 (commerce-api용).
* commerce-batch에서 적재한 mv_product_rank_weekly 테이블을 조회만 한다.
* @Immutable: Hibernate의 dirty checking 대상에서 제외 (읽기 전용 최적화)
*/
@Entity
@Immutable
@Table(name = "mv_product_rank_weekly")
@Getter
public class MvProductRankWeeklyReadModel {

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

@Column(nullable = false)
private Long productId;

@Column(nullable = false)
private long viewCount;

@Column(nullable = false)
private long likeCount;

@Column(nullable = false)
private long salesCount;

@Column(nullable = false)
private double score;

@Column(name = "`ranking`", nullable = false)
private int ranking;

@Column(name = "year_week", nullable = false, length = 10)
private String yearWeek;

protected MvProductRankWeeklyReadModel() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,10 @@ public interface RankingRepository {

/** DB ORDER BY 기반 Top-N 조회 — ZSET 성능 비교용 */
List<RankingEntry> getTopNFromDB(int offset, int size);

/** MV 주간 랭킹 조회 — yearWeek(예: "2026W15") 기준 */
List<RankingEntry> getTopNWeekly(String yearWeek, int offset, int size);

/** MV 월간 랭킹 조회 — yearMonth(예: "202604") 기준 */
List<RankingEntry> getTopNMonthly(String yearMonth, int offset, int size);
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,25 @@ public Long getProductRank(Long productId) {
Long rank = rankingRepository.getRank(key, productId);
return rank != null ? rank + 1 : null; // 0-based → 1-based
}

/** 주간 랭킹 조회: date(yyyyMMdd)에서 yearWeek를 계산하여 MV 테이블 조회 */
public List<RankingEntry> getTopRankingsWeekly(String date, int page, int size) {
String yearWeek = computeYearWeek(date);
int offset = (page - 1) * size;
return rankingRepository.getTopNWeekly(yearWeek, offset, size);
}

/** 월간 랭킹 조회: date(yyyyMMdd)에서 yearMonth를 계산하여 MV 테이블 조회 */
public List<RankingEntry> getTopRankingsMonthly(String date, int page, int size) {
String yearMonth = date.substring(0, 6); // "yyyyMMdd" → "yyyyMM"
int offset = (page - 1) * size;
return rankingRepository.getTopNMonthly(yearMonth, offset, size);
}
Comment on lines +58 to +63
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

월간 키 생성에서 substring에 의존하지 말아야 한다

Line 60은 입력 앞 6자리만 잘라 yearMonth를 만들기 때문에, 이 서비스가 다른 진입점에서 재사용되면 짧은 값은 StringIndexOutOfBoundsException, 잘못된 값은 존재하지 않는 월 키로 흘러갈 수 있다. 운영에서는 같은 날짜 입력인데 주간/월간 경로의 실패 방식이 달라져 원인 추적이 어려워진다. 주간 경로처럼 날짜를 먼저 해석한 뒤 yyyyMM으로 포맷하도록 맞추는 편이 안전하다. 추가 테스트로는 "202604", "20261301", "20260230" 입력에서 월간 경로가 예측 가능한 예외 흐름을 가지는지 확인해야 한다.

예시 수정안
     /** 월간 랭킹 조회: date(yyyyMMdd)에서 yearMonth를 계산하여 MV 테이블 조회 */
     public List<RankingEntry> getTopRankingsMonthly(String date, int page, int size) {
-        String yearMonth = date.substring(0, 6);   // "yyyyMMdd" → "yyyyMM"
+        LocalDate parsedDate = LocalDate.parse(date, DATE_FORMAT);
+        String yearMonth = parsedDate.format(DateTimeFormatter.ofPattern("yyyyMM"));
         int offset = (page - 1) * size;
         return rankingRepository.getTopNMonthly(yearMonth, offset, size);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java`
around lines 58 - 63, getTopRankingsMonthly currently derives yearMonth via
date.substring(0,6) which can throw StringIndexOutOfBoundsException for short
inputs or produce invalid months for malformed dates; change
getTopRankingsMonthly to parse the incoming date string into a
java.time.LocalDate (using appropriate DateTimeFormatter patterns or trying
multiple patterns like yyyyMMdd and yyyyMM01), validate the parsed date, then
format it to "yyyyMM" (e.g., DateTimeFormatter.ofPattern("yyyyMM")) and pass
that to rankingRepository.getTopNMonthly; on parse/validation failure throw a
clear IllegalArgumentException with a descriptive message so callers get a
predictable error flow.


private String computeYearWeek(String dateStr) {
LocalDate date = LocalDate.parse(dateStr, DATE_FORMAT);
int year = date.get(java.time.temporal.WeekFields.ISO.weekBasedYear());
int week = date.get(java.time.temporal.WeekFields.ISO.weekOfWeekBasedYear());
return String.format("%dW%02d", year, week);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.MvProductRankMonthlyReadModel;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MvProductRankMonthlyJpaRepository extends JpaRepository<MvProductRankMonthlyReadModel, Long> {

/** 특정 월의 랭킹을 순위순으로 페이징 조회 */
List<MvProductRankMonthlyReadModel> findByYearMonthOrderByRankingAsc(String yearMonth, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.MvProductRankWeeklyReadModel;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MvProductRankWeeklyJpaRepository extends JpaRepository<MvProductRankWeeklyReadModel, Long> {

/** 특정 주차의 랭킹을 순위순으로 페이징 조회 */
List<MvProductRankWeeklyReadModel> findByYearWeekOrderByRankingAsc(String yearWeek, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.loopers.domain.ranking.RankingEntry;
import com.loopers.domain.ranking.RankingRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Component;
Expand All @@ -16,6 +17,8 @@ public class RankingRedisRepository implements RankingRepository {

private final RedisTemplate<String, String> redisTemplate;
private final ProductMetricsJpaRepository productMetricsJpaRepository;
private final MvProductRankWeeklyJpaRepository weeklyJpaRepository;
private final MvProductRankMonthlyJpaRepository monthlyJpaRepository;

@Override
public List<RankingEntry> getTopN(String key, int offset, int size) {
Expand Down Expand Up @@ -53,4 +56,22 @@ public List<RankingEntry> getTopNFromDB(int offset, int size) {
((Number) row[1]).doubleValue()))
.toList();
}

@Override
public List<RankingEntry> getTopNWeekly(String yearWeek, int offset, int size) {
int page = offset / size;
return weeklyJpaRepository.findByYearWeekOrderByRankingAsc(yearWeek, PageRequest.of(page, size))
.stream()
.map(mv -> new RankingEntry(mv.getProductId(), mv.getScore()))
.toList();
}

@Override
public List<RankingEntry> getTopNMonthly(String yearMonth, int offset, int size) {
int page = offset / size;
return monthlyJpaRepository.findByYearMonthOrderByRankingAsc(yearMonth, PageRequest.of(page, size))
.stream()
.map(mv -> new RankingEntry(mv.getProductId(), mv.getScore()))
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
@Tag(name = "Ranking V1 API", description = "상품 랭킹 API")
public interface RankingV1ApiSpec {

@Operation(summary = "랭킹 조회", description = "날짜별 상품 랭킹을 조회합니다.")
@Operation(summary = "랭킹 조회", description = "기간별 상품 랭킹을 조회합니다. (일간/주간/월간)")
ApiResponse<RankingV1Dto.RankingPageResponse> getRankings(
@Parameter(description = "기간 (daily/weekly/monthly, 기본 daily)") String period,
@Parameter(description = "조회 날짜 (yyyyMMdd), 미지정 시 오늘") String date,
@Parameter(description = "페이지 크기 (기본 20)") int size,
@Parameter(description = "페이지 번호 (1부터, 기본 1)") int page
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.loopers.application.ranking.RankingFacade;
import com.loopers.application.ranking.RankingInfo;
import com.loopers.interfaces.api.ApiResponse;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -11,6 +13,8 @@

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

@RequiredArgsConstructor
@RestController
Expand All @@ -21,22 +25,42 @@ public class RankingV1Controller implements RankingV1ApiSpec {

private static final DateTimeFormatter DATE_FORMAT =
DateTimeFormatter.ofPattern("yyyyMMdd");
private static final Set<String> VALID_PERIODS = Set.of("daily", "weekly", "monthly");

@GetMapping
@Override
public ApiResponse<RankingV1Dto.RankingPageResponse> getRankings(
@RequestParam(defaultValue = "daily") String period,
@RequestParam(required = false) String date,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "1") int page
) {
// date 미지정 시 오늘 날짜를 기본값으로 사용
if (!VALID_PERIODS.contains(period)) {
throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 기간입니다: " + period);
}
if (page < 1) {
throw new CoreException(ErrorType.BAD_REQUEST, "page는 1 이상이어야 합니다.");
}
if (size < 1 || size > 100) {
throw new CoreException(ErrorType.BAD_REQUEST, "size는 1~100 범위여야 합니다.");
}

if (date == null || date.isBlank()) {
date = LocalDate.now().format(DATE_FORMAT);
} else {
try {
LocalDate.parse(date, DATE_FORMAT);
} catch (DateTimeParseException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)");
}
Comment on lines +50 to +55
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:

In Java, does LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")) reject invalid dates strictly by default? If not, what is the recommended formatter for strict validation of an 8-digit yyyyMMdd request parameter?

💡 Result:

No, LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")) does not reject invalid dates strictly by default. It uses SMART resolver style, which adjusts invalid day-of-month values to the last valid day of the month (e.g., "20240230" parses to 2024-02-29). For strict validation of an 8-digit yyyyMMdd string, use DateTimeFormatter.BASIC_ISO_DATE, which matches the exact format and uses STRICT resolver style internally, rejecting invalid dates like "20240230" with DateTimeParseException. Alternatively, create a custom formatter: DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT), which also rejects invalid dates. DateTimeFormatter.BASIC_ISO_DATE is recommended as it is predefined, official for basic ISO date (yyyyMMdd), and ensures strict parsing without lenient adjustments.

Citations:


엄격한 날짜 검증으로 불가능한 입력을 400으로 거부해야 한다

현재 50번 줄의 LocalDate.parse(date, DATE_FORMAT)은 기본값인 SMART 리졸버 스타일을 사용하므로 존재하지 않는 날짜를 자동 보정한다. 예를 들어 20240230은 2024-02-29로 파싱되어 DateTimeParseException이 발생하지 않는다. 잘못된 요청이 400 에러로 거부되지 않고 다른 일자의 랭킹 데이터를 반환하므로 운영에서 입력값과 결과가 어긋나는 장애를 야기한다. 엄격한 검증이 필요하다.

포맷터를 두 가지 방법 중 선택하여 수정한다:

  1. 권장: DateTimeFormatter.BASIC_ISO_DATE로 변경 (공식 ISO 8601 기본 형식, 기본 내장되어 엄격한 검증 제공)
  2. 대안: 기존 방식 유지 시 DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT) 적용

이후 20260230, 비윤년 20250229, 자리수 부족 입력(202401 등)이 모두 DateTimeParseException으로 거부되는지 단위 테스트로 검증한다.

방법 1: BASIC_ISO_DATE 적용 (권장)
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
 ...
     private static final DateTimeFormatter DATE_FORMAT =
-            DateTimeFormatter.ofPattern("yyyyMMdd");
+            DateTimeFormatter.BASIC_ISO_DATE;
방법 2: STRICT 리졸버 스타일 명시
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
+import java.time.format.ResolverStyle;
 ...
     private static final DateTimeFormatter DATE_FORMAT =
-            DateTimeFormatter.ofPattern("yyyyMMdd");
+            DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT);
🤖 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/interfaces/api/ranking/RankingV1Controller.java`
around lines 50 - 55, The LocalDate parsing in RankingV1Controller currently
uses a SMART resolver (LocalDate.parse(date, DATE_FORMAT)) which auto-corrects
invalid dates; change the formatter to enforce strict validation by replacing
DATE_FORMAT with DateTimeFormatter.BASIC_ISO_DATE (preferred) or update the
existing DATE_FORMAT to
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
so LocalDate.parse(date, DATE_FORMAT) throws DateTimeParseException for inputs
like 20260230/20250229/202401; update the controller to use the new formatter
and add unit tests asserting that those invalid inputs result in
DateTimeParseException (and therefore a 400 via the existing CoreException
path).

}

// Facade에서 ZSET 조회 + 상품/브랜드 Aggregation 수행
RankingInfo.RankingPageResponse info = rankingFacade.getRankings(date, page, size);
// application 레이어 Info → interfaces 레이어 DTO 변환
RankingInfo.RankingPageResponse info = switch (period) {
case "weekly" -> rankingFacade.getRankingsWeekly(date, page, size);
case "monthly" -> rankingFacade.getRankingsMonthly(date, page, size);
default -> rankingFacade.getRankings(date, page, size);
};

return ApiResponse.success(RankingV1Dto.RankingPageResponse.from(info));
}

Expand Down
Loading