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 @@ -2,12 +2,18 @@

import com.loopers.domain.PageResult;
import com.loopers.domain.ranking.ProductRanking;
import com.loopers.domain.ranking.RankingPeriod;

import java.time.LocalDate;
import java.util.Optional;

public interface RankingQueryService {

PageResult<ProductRanking> getDailyRanking(String date, int page, int size);
/**
* period 에 따라 일간(Redis) / 주간/월간(MV) 랭킹을 반환한다.
* Controller 는 이 단일 진입점만 사용한다.
*/
PageResult<ProductRanking> getRanking(RankingPeriod period, LocalDate baseDate, int page, int size);

Optional<Long> getProductDailyRank(Long productId, String date);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.loopers.domain.ranking;

public interface MonthlyRankingRepository extends PeriodRankingRepository {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.loopers.domain.ranking;

import java.util.List;

public interface PeriodRankingRepository {

/**
* 특정 기간 키(예: "2026-W15", "2026-04") 의 랭킹을 offset/limit 기반으로 조회한다.
*
* @param periodKey 주간이면 "2026-W15", 월간이면 "2026-04"
* @param offset 건너뛸 row 수 (0부터 시작)
* @param limit 가져올 row 수
*/
List<ProductRanking> findTopN(String periodKey, long offset, int limit);

/**
* 해당 기간에 저장된 전체 row 수. MV 는 TOP 100 만 저장되므로 최대 100.
*/
long countByPeriod(String periodKey);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.loopers.domain.ranking;

public enum RankingPeriod {
DAILY,
WEEKLY,
MONTHLY
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.loopers.domain.ranking;

public interface WeeklyRankingRepository extends PeriodRankingRepository {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.MonthlyRankingRepository;
import com.loopers.domain.ranking.ProductRanking;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
@RequiredArgsConstructor
public class MonthlyRankingRepositoryImpl implements MonthlyRankingRepository {

private static final String SELECT_SQL =
"SELECT product_id, score, ranking_position "
+ "FROM mv_product_rank_monthly "
+ "WHERE year_month_key = ? "
+ "ORDER BY ranking_position "
+ "LIMIT ? OFFSET ?";

private static final String COUNT_SQL =
"SELECT COUNT(*) FROM mv_product_rank_monthly WHERE year_month_key = ?";

private final JdbcTemplate jdbcTemplate;

@Override
public List<ProductRanking> findTopN(String periodKey, long offset, int limit) {
return jdbcTemplate.query(
SELECT_SQL,
(rs, rowNum) -> new ProductRanking(
rs.getLong("product_id"),
rs.getDouble("score"),
rs.getLong("ranking_position")
),
periodKey, limit, offset
);
}

@Override
public long countByPeriod(String periodKey) {
Long count = jdbcTemplate.queryForObject(COUNT_SQL, Long.class, periodKey);
return count != null ? count : 0L;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@

import com.loopers.application.ranking.RankingQueryService;
import com.loopers.domain.PageResult;
import com.loopers.domain.ranking.MonthlyRankingRepository;
import com.loopers.domain.ranking.PeriodRankingRepository;
import com.loopers.domain.ranking.ProductRanking;
import com.loopers.support.redis.RankingKeyConstants;
import com.loopers.domain.ranking.RankingPeriod;
import com.loopers.domain.ranking.RankingRepository;
import com.loopers.domain.ranking.WeeklyRankingRepository;
import com.loopers.support.redis.RankingKeyConstants;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.IsoFields;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
Expand All @@ -18,10 +25,33 @@
@Component
public class RankingQueryServiceImpl implements RankingQueryService {

private static final DateTimeFormatter DAY_FORMAT = DateTimeFormatter.ofPattern("uuuuMMdd");

private final RankingRepository rankingRepository;
private final WeeklyRankingRepository weeklyRankingRepository;
private final MonthlyRankingRepository monthlyRankingRepository;

@Override
public PageResult<ProductRanking> getRanking(RankingPeriod period, LocalDate baseDate, int page, int size) {
return switch (period) {
case DAILY -> getDailyRankingFromRedis(baseDate.format(DAY_FORMAT), page, size);
case WEEKLY -> getPeriodRanking(weeklyRankingRepository, toYearWeek(baseDate), page, size);
case MONTHLY -> getPeriodRanking(monthlyRankingRepository, toYearMonth(baseDate), page, size);
};
}

@Override
public PageResult<ProductRanking> getDailyRanking(String date, int page, int size) {
public Optional<Long> getProductDailyRank(Long productId, String date) {
try {
String key = RankingKeyConstants.dayKey(date);
return rankingRepository.getRank(key, productId);
} catch (Exception e) {
log.warn("[Ranking] Redis 순위 조회 실패, empty 반환: {}", e.getMessage());
return Optional.empty();
}
}

private PageResult<ProductRanking> getDailyRankingFromRedis(String date, int page, int size) {
try {
String key = RankingKeyConstants.dayKey(date);
long start = (long) page * size;
Expand All @@ -38,14 +68,25 @@ public PageResult<ProductRanking> getDailyRanking(String date, int page, int siz
}
}

@Override
public Optional<Long> getProductDailyRank(Long productId, String date) {
try {
String key = RankingKeyConstants.dayKey(date);
return rankingRepository.getRank(key, productId);
} catch (Exception e) {
log.warn("[Ranking] Redis 순위 조회 실패, empty 반환: {}", e.getMessage());
return Optional.empty();
}
private PageResult<ProductRanking> getPeriodRanking(
PeriodRankingRepository repository, String periodKey, int page, int size
) {
// 주간/월간은 "정확성 > 실시간성" 원칙. Daily(Redis) 의 graceful degradation 과 달리
// DB 조회 실패는 그대로 전파하여 사용자/운영자에게 장애를 숨기지 않는다.
long offset = (long) page * size;
List<ProductRanking> rankings = repository.findTopN(periodKey, offset, size);
long totalElements = repository.countByPeriod(periodKey);
int totalPages = totalElements == 0 ? 0 : (int) Math.ceil((double) totalElements / size);
return new PageResult<>(rankings, page, size, totalElements, totalPages);
}

private static String toYearWeek(LocalDate date) {
int year = date.get(IsoFields.WEEK_BASED_YEAR);
int week = date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
return String.format("%04d-W%02d", year, week);
}

private static String toYearMonth(LocalDate date) {
return String.format("%04d-%02d", date.getYear(), date.getMonthValue());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.ProductRanking;
import com.loopers.domain.ranking.WeeklyRankingRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
@RequiredArgsConstructor
public class WeeklyRankingRepositoryImpl implements WeeklyRankingRepository {

private static final String SELECT_SQL =
"SELECT product_id, score, ranking_position "
+ "FROM mv_product_rank_weekly "
+ "WHERE year_week = ? "
+ "ORDER BY ranking_position "
+ "LIMIT ? OFFSET ?";

private static final String COUNT_SQL =
"SELECT COUNT(*) FROM mv_product_rank_weekly WHERE year_week = ?";

private final JdbcTemplate jdbcTemplate;

@Override
public List<ProductRanking> findTopN(String periodKey, long offset, int limit) {
return jdbcTemplate.query(
SELECT_SQL,
(rs, rowNum) -> new ProductRanking(
rs.getLong("product_id"),
rs.getDouble("score"),
rs.getLong("ranking_position")
),
periodKey, limit, offset
);
}

@Override
public long countByPeriod(String periodKey) {
Long count = jdbcTemplate.queryForObject(COUNT_SQL, Long.class, periodKey);
return count != null ? count : 0L;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.loopers.infrastructure.ranking.schema;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Index;
import jakarta.persistence.Table;

import java.io.Serializable;
import java.time.Instant;
import java.util.Objects;

/**
* {@code mv_product_rank_monthly} schema-only 매핑.
*
* <p>runtime 쿼리는 {@code MonthlyRankingRepositoryImpl} 이 JdbcTemplate 으로 수행한다.
* 현재 프로젝트는 {@code ddl-auto} 로 DDL 을 관리한다. {@code sql/V12} 는 Flyway 이관 시 참조 스키마.
*
* <p><b>WARNING</b>: commerce-batch 쪽 Entity, 이 Entity, 참조 SQL 세 곳에서 같은 테이블을 각자 정의한다.
* {@link WeeklyRankingMvSchema} 의 WARNING 참조. {@code year_month_key} 네이밍 근거는
* commerce-batch 쪽 {@code MonthlyRankingMvSchema} 주석 참조.
*/
Comment on lines +18 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.

⚠️ Potential issue | 🟡 Minor

스키마 관리 설명 불일치로 모듈 간 DDL 동기화 절차가 흔들릴 수 있다.

운영에서 문서 기준이 분산되면 월간 랭킹 테이블 변경 시 배치/API 간 반영 누락 가능성이 커진다.
수정안은 현재 저장소의 실제 스키마 관리 체계를 기준으로 주석을 정리하고, 참조 SQL과 양 모듈 엔티티 동시 수정 규칙을 더 명확히 적는 것이다.
추가 테스트는 월간 MV 엔티티 정의와 기준 SQL 정의 간 동등성 검증(컬럼 타입/길이/PK/인덱스) 자동화를 권장한다.

Based on learnings: In the Loopers-dev-lab/loop-pack-be-l2-vol3-java project, database index creation (including JPA Index annotations) is NOT managed by JPA ddl-auto and is handled via an internal separate DB migration pipeline.

🤖 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/schema/MonthlyRankingMvSchema.java`
around lines 18 - 23, Update the class-level javadoc in MonthlyRankingMvSchema
to accurately reflect the repository's true schema management: state that DDL
(including indexes and JPA `@Index` annotations) is NOT applied by JPA ddl-auto
but by the internal DB migration pipeline, remove/clarify the old Flyway/sql/V12
wording, and add a clear WARNING (mirroring WeeklyRankingMvSchema) instructing
maintainers to update the migration SQL, the commerce-batch Entity, and this API
Entity in lockstep when changing the monthly MV schema (mention the
year_month_key naming rationale and reference WeeklyRankingMvSchema). Also add a
short note recommending automated equivalence checks (columns, types/lengths,
PKs, indexes) between the Entity and the reference SQL to prevent drift.

@Entity
@Table(
name = "mv_product_rank_monthly",
indexes = @Index(
name = "idx_mv_product_rank_monthly_position",
columnList = "year_month_key, ranking_position"
)
)
@IdClass(MonthlyRankingMvSchema.PK.class)
public class MonthlyRankingMvSchema {

@Id
@Column(name = "year_month_key", nullable = false, length = 7)
private String yearMonth;

@Id
@Column(name = "product_id", nullable = false)
private Long productId;

@Column(name = "ranking_position", nullable = false)
private long rankingPosition;

@Column(name = "score", nullable = false)
private double score;

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

protected MonthlyRankingMvSchema() {}

public static class PK implements Serializable {
private String yearMonth;
private Long productId;

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof PK pk)) return false;
return Objects.equals(yearMonth, pk.yearMonth) && Objects.equals(productId, pk.productId);
}

@Override
public int hashCode() {
return Objects.hash(yearMonth, productId);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.loopers.infrastructure.ranking.schema;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Index;
import jakarta.persistence.Table;

import java.io.Serializable;
import java.time.Instant;
import java.util.Objects;

/**
* {@code mv_product_rank_weekly} schema-only 매핑.
*
* <p>runtime 쿼리는 {@code WeeklyRankingRepositoryImpl} 이 JdbcTemplate 으로 수행한다.
* 이 클래스는 Hibernate {@code ddl-auto} 가 동일 테이블을 생성할 수 있게 구조만 선언한다.
*
* <p>현재 프로젝트는 Flyway 를 쓰지 않고 {@code ddl-auto} 로 DDL 을 관리한다.
* {@code sql/V10__create_mv_product_rank_weekly.sql} 은 Flyway 이관 시 참조 스키마로 준비된 파일.
*
Comment on lines +20 to +22
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

DDL 관리 방식 설명이 저장소 운영 정책과 충돌한다.

Line 20-22 설명은 운영자가 ddl-auto를 스키마 소스로 오인하게 만들어 배포 시 스키마 드리프트 대응 판단을 잘못하게 할 수 있다.
수정안으로 JavaDoc에서 “외부 마이그레이션 파이프라인이 소스 오브 트루스이고, 이 Entity는 읽기/매핑 목적”임을 명확히 적는 것이 필요하다.
추가 테스트로는 프로파일 기반 설정 검증 테스트를 두어 운영 프로파일에서 spring.jpa.hibernate.ddl-auto가 스키마 생성 모드가 아님을 고정하는 것이 좋다.

제안 코드
- * <p>현재 프로젝트는 Flyway 를 쓰지 않고 {`@code` ddl-auto} 로 DDL 을 관리한다.
- * {`@code` sql/V10__create_mv_product_rank_weekly.sql} 은 Flyway 이관 시 참조 스키마로 준비된 파일.
+ * <p>현재 프로젝트의 스키마 변경은 별도 DB 마이그레이션 파이프라인에서 관리한다.
+ * 이 Entity 는 런타임 매핑 일관성을 위한 선언이며, 스키마 관리의 단일 기준은 외부 마이그레이션이다.

Based on learnings "In the Loopers-dev-lab/loop-pack-be-l2-vol3-java project, database index creation ... is handled via an internal, separate DB migration pipeline ...".

📝 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
* <p>현재 프로젝트는 Flyway 쓰지 않고 {@code ddl-auto} DDL 관리한다.
* {@code sql/V10__create_mv_product_rank_weekly.sql} Flyway 이관 참조 스키마로 준비된 파일.
*
* <p>현재 프로젝트의 스키마 변경은 별도 DB 마이그레이션 파이프라인에서 관리한다.
* Entity 런타임 매핑 일관성을 위한 선언이며, 스키마 관리의 단일 기준은 외부 마이그레이션이다.
*
🤖 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/schema/WeeklyRankingMvSchema.java`
around lines 20 - 22, Update the JavaDoc on WeeklyRankingMvSchema to explicitly
state that an external migration pipeline (not ddl-auto) is the source of truth
and that this class/entity is read-only / used only for mapping the existing
materialized view (e.g., reference sql/V10__create_mv_product_rank_weekly.sql as
a prepared migration file), clarifying not to rely on
spring.jpa.hibernate.ddl-auto for schema changes; then add a profile-based test
(e.g., a new test class like ProductionProfileSchemaConfigTest) that loads the
production/operational Spring profile and asserts spring.jpa.hibernate.ddl-auto
is not set to a schema-creating value (validate it is none/validate/update
disabled) to prevent accidental schema generation in deployment.

* <p><b>WARNING — 3중 정의 주의</b>: 이 Entity 와 (1) commerce-batch 의 동명 Entity, (2) 참조 SQL 이
* 같은 테이블을 각자 정의한다. 컬럼/인덱스 변경 시 세 곳을 함께 수정해야 drift 를 막을 수 있다.
*/
@Entity
@Table(
name = "mv_product_rank_weekly",
indexes = @Index(
name = "idx_mv_product_rank_weekly_position",
columnList = "year_week, ranking_position"
)
)
@IdClass(WeeklyRankingMvSchema.PK.class)
public class WeeklyRankingMvSchema {

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

@Id
@Column(name = "product_id", nullable = false)
private Long productId;

@Column(name = "ranking_position", nullable = false)
private long rankingPosition;

@Column(name = "score", nullable = false)
private double score;

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

protected WeeklyRankingMvSchema() {}

public static class PK implements Serializable {
private String yearWeek;
private Long productId;

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof PK pk)) return false;
return Objects.equals(yearWeek, pk.yearWeek) && Objects.equals(productId, pk.productId);
}

@Override
public int hashCode() {
return Objects.hash(yearWeek, productId);
}
}
}
Loading