From ab7d77b594c71644080a2e97a80befdb489298e5 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:41:53 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20Spring=20Batch=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20Auto-configuration?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spring Boot 3.x Auto-configuration 사용을 위해 @EnableBatchProcessing 제거 Batch 모듈을 별도 모듈로 분리하여 재사용성 향상 --- apps/commerce-api/build.gradle.kts | 1 + modules/batch/build.gradle.kts | 14 ++++++++++++++ .../com/loopers/config/batch/BatchConfig.java | 16 ++++++++++++++++ modules/batch/src/main/resources/batch.yml | 6 ++++++ settings.gradle.kts | 1 + 5 files changed, 38 insertions(+) create mode 100644 modules/batch/build.gradle.kts create mode 100644 modules/batch/src/main/java/com/loopers/config/batch/BatchConfig.java create mode 100644 modules/batch/src/main/resources/batch.yml diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 9f57f5ffa..6307fbc7d 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -3,6 +3,7 @@ dependencies { implementation(project(":modules:jpa")) implementation(project(":modules:redis")) implementation(project(":modules:kafka")) + implementation(project(":modules:batch")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) diff --git a/modules/batch/build.gradle.kts b/modules/batch/build.gradle.kts new file mode 100644 index 000000000..015b925da --- /dev/null +++ b/modules/batch/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + `java-library` +} + +dependencies { + // Spring Batch + api("org.springframework.boot:spring-boot-starter-batch") + + // JPA module + api(project(":modules:jpa")) + + // Test + testImplementation("org.springframework.batch:spring-batch-test") +} \ No newline at end of file diff --git a/modules/batch/src/main/java/com/loopers/config/batch/BatchConfig.java b/modules/batch/src/main/java/com/loopers/config/batch/BatchConfig.java new file mode 100644 index 000000000..aa6b62d9c --- /dev/null +++ b/modules/batch/src/main/java/com/loopers/config/batch/BatchConfig.java @@ -0,0 +1,16 @@ +package com.loopers.config.batch; + +import org.springframework.context.annotation.Configuration; + +/** + * Spring Batch Configuration + * + * Spring Boot 3.x에서는 @EnableBatchProcessing을 사용하지 않고 + * Auto-configuration을 사용합니다. + * + * spring.batch.job.enabled=false로 설정하여 자동 실행을 방지합니다. + */ +@Configuration +public class BatchConfig { + +} \ No newline at end of file diff --git a/modules/batch/src/main/resources/batch.yml b/modules/batch/src/main/resources/batch.yml new file mode 100644 index 000000000..a9970040d --- /dev/null +++ b/modules/batch/src/main/resources/batch.yml @@ -0,0 +1,6 @@ +spring: + batch: + job: + enabled: false # Job을 자동 실행하지 않음 (수동 실행) + jdbc: + initialize-schema: always # Batch 메타 테이블 초기화 \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 906b49231..e17a8b181 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ include( ":modules:jpa", ":modules:redis", ":modules:kafka", + ":modules:batch", ":supports:jackson", ":supports:logging", ":supports:monitoring", From 79a0f2b7e4754d0934444d7edc82d5579f31b20a Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:41:54 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EB=B0=8F=20Period=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주간/월간 랭킹을 위한 Entity 및 Repository 인터페이스 추가 기간 타입(DAILY/WEEKLY/MONTHLY) 및 날짜 계산 유틸리티 구현 --- .../loopers/domain/ranking/PeriodType.java | 7 ++ .../loopers/domain/ranking/PeriodUtils.java | 75 +++++++++++++++++++ .../ranking/monthly/MonthlyRanking.java | 48 ++++++++++++ .../monthly/MonthlyRankingRepository.java | 14 ++++ .../domain/ranking/weekly/WeeklyRanking.java | 44 +++++++++++ .../weekly/WeeklyRankingRepository.java | 15 ++++ 6 files changed, 203 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodUtils.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRanking.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRanking.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRankingRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java new file mode 100644 index 000000000..df70e8f5f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java @@ -0,0 +1,7 @@ +package com.loopers.domain.ranking; + +public enum PeriodType { + DAILY, + WEEKLY, + MONTHLY +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodUtils.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodUtils.java new file mode 100644 index 000000000..c6841e20c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodUtils.java @@ -0,0 +1,75 @@ +package com.loopers.domain.ranking; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; + +public class PeriodUtils { + + private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + + /** + * 주어진 날짜가 속한 주의 시작일(월요일) 반환 + */ + public static LocalDate getWeekStartDate(LocalDate date) { + return date.with(DayOfWeek.MONDAY); + } + + /** + * 주어진 날짜가 속한 주의 종료일(일요일) 반환 + */ + public static LocalDate getWeekEndDate(LocalDate date) { + return date.with(DayOfWeek.MONDAY).plusDays(6); + } + + /** + * 주어진 날짜가 속한 월의 시작일 반환 + */ + public static LocalDate getMonthStartDate(LocalDate date) { + return YearMonth.from(date).atDay(1); + } + + /** + * 주어진 날짜가 속한 월의 마지막일 반환 + */ + public static LocalDate getMonthEndDate(LocalDate date) { + return YearMonth.from(date).atEndOfMonth(); + } + + /** + * 월간 키 생성 (YYYY-MM 형태) + */ + public static String getMonthKey(LocalDate date) { + return YearMonth.from(date).format(MONTH_FORMATTER); + } + + /** + * 문자열 날짜(yyyyMMdd)를 LocalDate로 변환 + */ + public static LocalDate parseDate(String dateString) { + return LocalDate.parse(dateString, DateTimeFormatter.ofPattern("yyyyMMdd")); + } + + /** + * 주간 범위 정보 + */ + public record WeekRange(LocalDate start, LocalDate end) { + public static WeekRange from(LocalDate date) { + return new WeekRange(getWeekStartDate(date), getWeekEndDate(date)); + } + } + + /** + * 월간 범위 정보 + */ + public record MonthRange(LocalDate start, LocalDate end, String key) { + public static MonthRange from(LocalDate date) { + return new MonthRange( + getMonthStartDate(date), + getMonthEndDate(date), + getMonthKey(date) + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRanking.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRanking.java new file mode 100644 index 000000000..3f4b152b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRanking.java @@ -0,0 +1,48 @@ +package com.loopers.domain.ranking.monthly; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "mv_product_rank_monthly") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MonthlyRanking extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "month_year", nullable = false) + private String monthYear; + + @Column(name = "month_start_date", nullable = false) + private LocalDate monthStartDate; + + @Column(name = "month_end_date", nullable = false) + private LocalDate monthEndDate; + + public MonthlyRanking(Integer rankPosition, Long productId, Double totalScore, + String monthYear, LocalDate monthStartDate, LocalDate monthEndDate) { + this.rankPosition = rankPosition; + this.productId = productId; + this.totalScore = totalScore; + this.monthYear = monthYear; + this.monthStartDate = monthStartDate; + this.monthEndDate = monthEndDate; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRankingRepository.java new file mode 100644 index 000000000..f028fbed8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRankingRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.ranking.monthly; + +import java.util.List; + +public interface MonthlyRankingRepository { + + List findByMonthYearOrderByRankPosition(String monthYear); + + List findByMonthYearWithPagination(String monthYear, int offset, int limit); + + void deleteByMonthYear(String monthYear); + + void saveAll(List rankings); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRanking.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRanking.java new file mode 100644 index 000000000..1ddd92372 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRanking.java @@ -0,0 +1,44 @@ +package com.loopers.domain.ranking.weekly; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "mv_product_rank_weekly") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WeeklyRanking extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "week_start_date", nullable = false) + private LocalDate weekStartDate; + + @Column(name = "week_end_date", nullable = false) + private LocalDate weekEndDate; + + public WeeklyRanking(Integer rankPosition, Long productId, Double totalScore, + LocalDate weekStartDate, LocalDate weekEndDate) { + this.rankPosition = rankPosition; + this.productId = productId; + this.totalScore = totalScore; + this.weekStartDate = weekStartDate; + this.weekEndDate = weekEndDate; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRankingRepository.java new file mode 100644 index 000000000..1e96989d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRankingRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.ranking.weekly; + +import java.time.LocalDate; +import java.util.List; + +public interface WeeklyRankingRepository { + + List findByWeekStartDateOrderByRankPosition(LocalDate weekStartDate); + + List findByWeekStartDateWithPagination(LocalDate weekStartDate, int offset, int limit); + + void deleteByWeekStartDate(LocalDate weekStartDate); + + void saveAll(List rankings); +} \ No newline at end of file From bf82a127d95e359b99d84ce871f64bf3d46f2aaf Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:41:55 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20Repository?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20Materialized=20View=20DDL?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QueryDSL 기반 Repository 구현으로 N+1 문제 해결 주간/월간 TOP 100 랭킹 저장을 위한 MV 테이블 DDL 추가 트랜잭션 처리 및 QueryDSL delete 쿼리 최적화 --- .../monthly/MonthlyRankingJpaRepository.java | 22 ++++++++ .../monthly/MonthlyRankingRepositoryImpl.java | 55 ++++++++++++++++++ .../weekly/WeeklyRankingJpaRepository.java | 23 ++++++++ .../weekly/WeeklyRankingRepositoryImpl.java | 56 +++++++++++++++++++ .../init/03-ranking-materialized-views.sql | 28 ++++++++++ 5 files changed, 184 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingRepositoryImpl.java create mode 100644 docker/mysql/init/03-ranking-materialized-views.sql diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingJpaRepository.java new file mode 100644 index 000000000..a82a3c964 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingJpaRepository.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.ranking.monthly; + +import com.loopers.domain.ranking.monthly.MonthlyRanking; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +public interface MonthlyRankingJpaRepository extends JpaRepository { + + List findByMonthYearOrderByRankPosition(String monthYear); + + @Query("SELECT m FROM MonthlyRanking m WHERE m.monthYear = :monthYear " + + "ORDER BY m.rankPosition LIMIT :limit OFFSET :offset") + List findByMonthYearWithPagination(String monthYear, int offset, int limit); + + @Modifying + @Transactional + void deleteByMonthYear(String monthYear); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingRepositoryImpl.java new file mode 100644 index 000000000..d49c53d16 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingRepositoryImpl.java @@ -0,0 +1,55 @@ +package com.loopers.infrastructure.ranking.monthly; + +import com.loopers.domain.ranking.monthly.MonthlyRanking; +import com.loopers.domain.ranking.monthly.MonthlyRankingRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.loopers.domain.ranking.monthly.QMonthlyRanking.monthlyRanking; + +@Repository +@RequiredArgsConstructor +public class MonthlyRankingRepositoryImpl implements MonthlyRankingRepository { + + private final MonthlyRankingJpaRepository jpaRepository; + private final JPAQueryFactory queryFactory; + + @Override + public List findByMonthYearOrderByRankPosition(String monthYear) { + return queryFactory + .selectFrom(monthlyRanking) + .where(monthlyRanking.monthYear.eq(monthYear)) + .orderBy(monthlyRanking.rankPosition.asc()) + .fetch(); + } + + @Override + public List findByMonthYearWithPagination(String monthYear, int offset, int limit) { + return queryFactory + .selectFrom(monthlyRanking) + .where(monthlyRanking.monthYear.eq(monthYear)) + .orderBy(monthlyRanking.rankPosition.asc()) + .offset(offset) + .limit(limit) + .fetch(); + } + + @Override + @Transactional + public void deleteByMonthYear(String monthYear) { + queryFactory + .delete(monthlyRanking) + .where(monthlyRanking.monthYear.eq(monthYear)) + .execute(); + } + + @Override + @Transactional + public void saveAll(List rankings) { + jpaRepository.saveAll(rankings); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingJpaRepository.java new file mode 100644 index 000000000..fbd4f33d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingJpaRepository.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.ranking.weekly; + +import com.loopers.domain.ranking.weekly.WeeklyRanking; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +public interface WeeklyRankingJpaRepository extends JpaRepository { + + List findByWeekStartDateOrderByRankPosition(LocalDate weekStartDate); + + @Query("SELECT w FROM WeeklyRanking w WHERE w.weekStartDate = :weekStartDate " + + "ORDER BY w.rankPosition LIMIT :limit OFFSET :offset") + List findByWeekStartDateWithPagination(LocalDate weekStartDate, int offset, int limit); + + @Modifying + @Transactional + void deleteByWeekStartDate(LocalDate weekStartDate); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingRepositoryImpl.java new file mode 100644 index 000000000..927af53e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingRepositoryImpl.java @@ -0,0 +1,56 @@ +package com.loopers.infrastructure.ranking.weekly; + +import com.loopers.domain.ranking.weekly.WeeklyRanking; +import com.loopers.domain.ranking.weekly.WeeklyRankingRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +import static com.loopers.domain.ranking.weekly.QWeeklyRanking.weeklyRanking; + +@Repository +@RequiredArgsConstructor +public class WeeklyRankingRepositoryImpl implements WeeklyRankingRepository { + + private final WeeklyRankingJpaRepository jpaRepository; + private final JPAQueryFactory queryFactory; + + @Override + public List findByWeekStartDateOrderByRankPosition(LocalDate weekStartDate) { + return queryFactory + .selectFrom(weeklyRanking) + .where(weeklyRanking.weekStartDate.eq(weekStartDate)) + .orderBy(weeklyRanking.rankPosition.asc()) + .fetch(); + } + + @Override + public List findByWeekStartDateWithPagination(LocalDate weekStartDate, int offset, int limit) { + return queryFactory + .selectFrom(weeklyRanking) + .where(weeklyRanking.weekStartDate.eq(weekStartDate)) + .orderBy(weeklyRanking.rankPosition.asc()) + .offset(offset) + .limit(limit) + .fetch(); + } + + @Override + @Transactional + public void deleteByWeekStartDate(LocalDate weekStartDate) { + queryFactory + .delete(weeklyRanking) + .where(weeklyRanking.weekStartDate.eq(weekStartDate)) + .execute(); + } + + @Override + @Transactional + public void saveAll(List rankings) { + jpaRepository.saveAll(rankings); + } +} \ No newline at end of file diff --git a/docker/mysql/init/03-ranking-materialized-views.sql b/docker/mysql/init/03-ranking-materialized-views.sql new file mode 100644 index 000000000..2bf95412a --- /dev/null +++ b/docker/mysql/init/03-ranking-materialized-views.sql @@ -0,0 +1,28 @@ +-- 주간 랭킹 Materialized View +CREATE TABLE mv_product_rank_weekly ( + rank_position INT NOT NULL, + product_id BIGINT NOT NULL, + total_score DOUBLE NOT NULL, + week_start_date DATE NOT NULL COMMENT '주간 시작일 (월요일)', + week_end_date DATE NOT NULL COMMENT '주간 종료일 (일요일)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (week_start_date, rank_position), + INDEX idx_week_product (week_start_date, product_id), + INDEX idx_product_week (product_id, week_start_date) +) COMMENT '주간 상품 랭킹 (TOP 100)'; + +-- 월간 랭킹 Materialized View +CREATE TABLE mv_product_rank_monthly ( + rank_position INT NOT NULL, + product_id BIGINT NOT NULL, + total_score DOUBLE NOT NULL, + month_year VARCHAR(7) NOT NULL COMMENT '월간 키 (YYYY-MM)', + month_start_date DATE NOT NULL COMMENT '월간 시작일', + month_end_date DATE NOT NULL COMMENT '월간 종료일', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (month_year, rank_position), + INDEX idx_month_product (month_year, product_id), + INDEX idx_product_month (product_id, month_year) +) COMMENT '월간 상품 랭킹 (TOP 100)'; \ No newline at end of file From 3c35f075cf62033dcaf64c350bf11647ddd06a37 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:41:56 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20Batch=20Job=EC=9A=A9=20DTO,=20Pro?= =?UTF-8?q?cessor,=20Writer=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProductMetricsDto: 배치 Reader가 읽어올 DTO RankedProductDto: 점수 계산 후 정렬용 DTO RankingScoreProcessor: 가중치 기반 점수 계산 (view 0.1, like 0.2, order 0.6) InMemoryRankingCollector: Step 완료 후 정렬을 위한 메모리 수집기 --- .../batch/dto/ProductMetricsDto.java | 14 +++++ .../batch/dto/RankedProductDto.java | 21 +++++++ .../processor/RankingScoreProcessor.java | 57 +++++++++++++++++++ .../writer/InMemoryRankingCollector.java | 42 ++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/batch/dto/ProductMetricsDto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/batch/dto/RankedProductDto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/batch/processor/RankingScoreProcessor.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/batch/writer/InMemoryRankingCollector.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/ProductMetricsDto.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/ProductMetricsDto.java new file mode 100644 index 000000000..c52f623fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/ProductMetricsDto.java @@ -0,0 +1,14 @@ +package com.loopers.application.batch.dto; + +/** + * Batch 처리용 ProductMetrics DTO + * - JdbcItemReader가 읽어온 데이터를 담는 객체 + */ +public record ProductMetricsDto( + Long productId, + Long likeCount, + Long viewCount, + Long salesCount, + Long salesAmount +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/RankedProductDto.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/RankedProductDto.java new file mode 100644 index 000000000..4ed3251ec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/RankedProductDto.java @@ -0,0 +1,21 @@ +package com.loopers.application.batch.dto; + +/** + * 점수가 계산된 상품 정보 + * - Processor가 생성하는 중간 데이터 + */ +public record RankedProductDto( + Long productId, + Double totalScore, + Long likeCount, + Long viewCount, + Long salesCount, + Long salesAmount +) implements Comparable { + + @Override + public int compareTo(RankedProductDto other) { + // 점수 내림차순 정렬 + return Double.compare(other.totalScore, this.totalScore); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/processor/RankingScoreProcessor.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/processor/RankingScoreProcessor.java new file mode 100644 index 000000000..a270b6ccf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/processor/RankingScoreProcessor.java @@ -0,0 +1,57 @@ +package com.loopers.application.batch.processor; + +import com.loopers.application.batch.dto.ProductMetricsDto; +import com.loopers.application.batch.dto.RankedProductDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; + +/** + * ProductMetrics를 읽어서 점수를 계산하는 Processor + */ +@Slf4j +public class RankingScoreProcessor implements ItemProcessor { + + private final double viewWeight; + private final double likeWeight; + private final double orderWeight; + + /** + * 생성자 주입으로 weight 값 받기 + */ + public RankingScoreProcessor(double viewWeight, double likeWeight, double orderWeight) { + this.viewWeight = viewWeight; + this.likeWeight = likeWeight; + this.orderWeight = orderWeight; + } + + @Override + public RankedProductDto process(ProductMetricsDto item) { + double totalScore = calculateTotalScore( + item.viewCount(), + item.likeCount(), + item.salesAmount() + ); + + return new RankedProductDto( + item.productId(), + totalScore, + item.likeCount(), + item.viewCount(), + item.salesCount(), + item.salesAmount() + ); + } + + /** + * 총 점수 계산 (기존 Redis 방식과 동일한 가중치) + */ + private double calculateTotalScore(Long viewCount, Long likeCount, Long salesAmount) { + double viewScore = viewCount * viewWeight; + double likeScore = likeCount * likeWeight; + + // 주문 점수: log 정규화 적용 (고가 상품 편중 방지) + double orderScore = orderWeight * Math.log1p(salesAmount); + + return viewScore + likeScore + orderScore; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/writer/InMemoryRankingCollector.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/writer/InMemoryRankingCollector.java new file mode 100644 index 000000000..1c6253ef4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/writer/InMemoryRankingCollector.java @@ -0,0 +1,42 @@ +package com.loopers.application.batch.writer; + +import com.loopers.application.batch.dto.RankedProductDto; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Chunk 처리 중 RankedProduct를 메모리에 수집하는 Writer + * - Step 완료 후 정렬하여 TOP 100을 선택하기 위함 + */ +@Slf4j +public class InMemoryRankingCollector implements ItemWriter { + + @Getter + private final List collectedItems = Collections.synchronizedList(new ArrayList<>()); + + @Override + public void write(Chunk chunk) { + collectedItems.addAll(chunk.getItems()); + log.debug("[Collector] Collected {} items, total: {}", chunk.size(), collectedItems.size()); + } + + /** + * 수집된 데이터 초기화 (다음 실행을 위해) + */ + public void clear() { + collectedItems.clear(); + } + + /** + * 수집된 데이터 개수 + */ + public int size() { + return collectedItems.size(); + } +} From 1c2d1157e4b45d79b01cf421553d3a54a6b26ad5 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:41:57 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20Batch=20Job=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20Chunk=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk-Oriented Processing (CHUNK_SIZE=100) 적용 JdbcPagingItemReader로 product_metrics 페이징 조회 StepExecutionListener에서 정렬 후 TOP 100 선택 및 저장 Reader 초기화를 위한 afterPropertiesSet() 호출 추가 --- .../batch/MonthlyRankingJobConfig.java | 202 ++++++++++++++++++ .../batch/WeeklyRankingJobConfig.java | 201 +++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/batch/MonthlyRankingJobConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/batch/WeeklyRankingJobConfig.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/MonthlyRankingJobConfig.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/MonthlyRankingJobConfig.java new file mode 100644 index 000000000..003be297f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/MonthlyRankingJobConfig.java @@ -0,0 +1,202 @@ +package com.loopers.application.batch; + +import com.loopers.application.batch.dto.ProductMetricsDto; +import com.loopers.application.batch.dto.RankedProductDto; +import com.loopers.application.batch.processor.RankingScoreProcessor; +import com.loopers.application.batch.writer.InMemoryRankingCollector; +import com.loopers.domain.ranking.PeriodUtils; +import com.loopers.domain.ranking.monthly.MonthlyRanking; +import com.loopers.domain.ranking.monthly.MonthlyRankingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +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.batch.item.database.Order; +import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 월간 랭킹 집계 배치 Job (Chunk-Oriented Processing) + * - Reader: JdbcPagingItemReader로 ProductMetrics 조회 + * - Processor: 점수 계산 + * - Writer: 메모리에 수집 + * - Listener: Step 완료 후 정렬하여 TOP 100 저장 + */ +@Configuration +@RequiredArgsConstructor +@Slf4j +public class MonthlyRankingJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final MonthlyRankingRepository monthlyRankingRepository; + + private static final int CHUNK_SIZE = 100; + private static final int PAGE_SIZE = 100; + private static final int TOP_N = 100; + + @Value("${ranking.weight.view:0.1}") + private double viewWeight; + + @Value("${ranking.weight.like:0.2}") + private double likeWeight; + + @Value("${ranking.weight.order:0.6}") + private double orderWeight; + + @Bean + public Job monthlyRankingJob() { + return new JobBuilder("monthlyRankingJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(monthlyRankingStep()) + .build(); + } + + @Bean + public Step monthlyRankingStep() { + InMemoryRankingCollector collector = new InMemoryRankingCollector(); + + return new StepBuilder("monthlyRankingStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyRankingReader()) + .processor(new RankingScoreProcessor(viewWeight, likeWeight, orderWeight)) + .writer(collector) + .listener(monthlyRankingStepListener(collector)) + .build(); + } + + /** + * Reader: ProductMetrics 테이블을 페이징으로 읽기 + */ + private JdbcPagingItemReader monthlyRankingReader() { + JdbcPagingItemReader reader = new JdbcPagingItemReader<>(); + reader.setDataSource(dataSource); + reader.setPageSize(PAGE_SIZE); + reader.setRowMapper(productMetricsRowMapper()); + + // MySQL PagingQueryProvider + MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider(); + queryProvider.setSelectClause("SELECT product_id, like_count, view_count, sales_count, sales_amount"); + queryProvider.setFromClause("FROM product_metrics"); + queryProvider.setSortKeys(Map.of("product_id", Order.ASCENDING)); // 정렬 기준 (페이징을 위해 필요) + + reader.setQueryProvider(queryProvider); + reader.setName("monthlyRankingReader"); + + // IMPORTANT: Reader 초기화 필수 + try { + reader.afterPropertiesSet(); + } catch (Exception e) { + throw new RuntimeException("Failed to initialize monthlyRankingReader", e); + } + + return reader; + } + + /** + * RowMapper: ResultSet을 ProductMetricsDto로 변환 + */ + private RowMapper productMetricsRowMapper() { + return (rs, rowNum) -> new ProductMetricsDto( + rs.getLong("product_id"), + rs.getLong("like_count"), + rs.getLong("view_count"), + rs.getLong("sales_count"), + rs.getLong("sales_amount") + ); + } + + /** + * StepExecutionListener: Step 완료 후 정렬 및 TOP 100 저장 + */ + private StepExecutionListener monthlyRankingStepListener(InMemoryRankingCollector collector) { + return new StepExecutionListener() { + + @Override + public void beforeStep(StepExecution stepExecution) { + // Collector 초기화 + collector.clear(); + log.info("[MonthlyRanking] Step 시작 - Collector 초기화 완료"); + } + + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + String targetDateParam = stepExecution.getJobParameters() + .getString("targetDate"); + + if (targetDateParam == null) { + log.error("[MonthlyRanking] targetDate 파라미터가 없습니다"); + return ExitStatus.FAILED; + } + + LocalDate targetDate = LocalDate.parse(targetDateParam); + PeriodUtils.MonthRange monthRange = PeriodUtils.MonthRange.from(targetDate); + + log.info("[MonthlyRanking] Step 완료 - 월간: {} ({} ~ {})", + monthRange.key(), monthRange.start(), monthRange.end()); + + // 1. 기존 월간 랭킹 데이터 삭제 + monthlyRankingRepository.deleteByMonthYear(monthRange.key()); + log.info("[MonthlyRanking] 기존 월간 랭킹 데이터 삭제 완료: {}", monthRange.key()); + + // 2. 수집된 데이터 가져오기 + List collectedItems = collector.getCollectedItems(); + log.info("[MonthlyRanking] 총 {} 개 상품 메트릭 수집 완료", collectedItems.size()); + + if (collectedItems.isEmpty()) { + log.warn("[MonthlyRanking] 집계할 데이터가 없습니다"); + return ExitStatus.COMPLETED; + } + + // 3. 정렬 (점수 내림차순) + Collections.sort(collectedItems); + + // 4. TOP 100만 선택 + List topRankings = collectedItems.stream() + .limit(TOP_N) + .toList(); + + log.info("[MonthlyRanking] TOP {} 선택 완료", topRankings.size()); + + // 5. 순위 설정 및 저장 + AtomicInteger rank = new AtomicInteger(1); + List rankedList = topRankings.stream() + .map(dto -> new MonthlyRanking( + rank.getAndIncrement(), + dto.productId(), + dto.totalScore(), + monthRange.key(), + monthRange.start(), + monthRange.end() + )) + .toList(); + + monthlyRankingRepository.saveAll(rankedList); + + log.info("[MonthlyRanking] 월간 랭킹 집계 완료 - {} 건 저장", rankedList.size()); + + return ExitStatus.COMPLETED; + } + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/WeeklyRankingJobConfig.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/WeeklyRankingJobConfig.java new file mode 100644 index 000000000..1d300012c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/WeeklyRankingJobConfig.java @@ -0,0 +1,201 @@ +package com.loopers.application.batch; + +import com.loopers.application.batch.dto.ProductMetricsDto; +import com.loopers.application.batch.dto.RankedProductDto; +import com.loopers.application.batch.processor.RankingScoreProcessor; +import com.loopers.application.batch.writer.InMemoryRankingCollector; +import com.loopers.domain.ranking.PeriodUtils; +import com.loopers.domain.ranking.weekly.WeeklyRanking; +import com.loopers.domain.ranking.weekly.WeeklyRankingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +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.batch.item.database.Order; +import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 주간 랭킹 집계 배치 Job (Chunk-Oriented Processing) + * - Reader: JdbcPagingItemReader로 ProductMetrics 조회 + * - Processor: 점수 계산 + * - Writer: 메모리에 수집 + * - Listener: Step 완료 후 정렬하여 TOP 100 저장 + */ +@Configuration +@RequiredArgsConstructor +@Slf4j +public class WeeklyRankingJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final WeeklyRankingRepository weeklyRankingRepository; + + private static final int CHUNK_SIZE = 100; + private static final int PAGE_SIZE = 100; + private static final int TOP_N = 100; + + @Value("${ranking.weight.view:0.1}") + private double viewWeight; + + @Value("${ranking.weight.like:0.2}") + private double likeWeight; + + @Value("${ranking.weight.order:0.6}") + private double orderWeight; + + @Bean + public Job weeklyRankingJob() { + return new JobBuilder("weeklyRankingJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(weeklyRankingStep()) + .build(); + } + + @Bean + public Step weeklyRankingStep() { + InMemoryRankingCollector collector = new InMemoryRankingCollector(); + + return new StepBuilder("weeklyRankingStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyRankingReader()) + .processor(new RankingScoreProcessor(viewWeight, likeWeight, orderWeight)) + .writer(collector) + .listener(weeklyRankingStepListener(collector)) + .build(); + } + + /** + * Reader: ProductMetrics 테이블을 페이징으로 읽기 + */ + private JdbcPagingItemReader weeklyRankingReader() { + JdbcPagingItemReader reader = new JdbcPagingItemReader<>(); + reader.setDataSource(dataSource); + reader.setPageSize(PAGE_SIZE); + reader.setRowMapper(productMetricsRowMapper()); + + // MySQL PagingQueryProvider + MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider(); + queryProvider.setSelectClause("SELECT product_id, like_count, view_count, sales_count, sales_amount"); + queryProvider.setFromClause("FROM product_metrics"); + queryProvider.setSortKeys(Map.of("product_id", Order.ASCENDING)); // 정렬 기준 (페이징을 위해 필요) + + reader.setQueryProvider(queryProvider); + reader.setName("weeklyRankingReader"); + + // IMPORTANT: Reader 초기화 필수 + try { + reader.afterPropertiesSet(); + } catch (Exception e) { + throw new RuntimeException("Failed to initialize weeklyRankingReader", e); + } + + return reader; + } + + /** + * RowMapper: ResultSet을 ProductMetricsDto로 변환 + */ + private RowMapper productMetricsRowMapper() { + return (rs, rowNum) -> new ProductMetricsDto( + rs.getLong("product_id"), + rs.getLong("like_count"), + rs.getLong("view_count"), + rs.getLong("sales_count"), + rs.getLong("sales_amount") + ); + } + + /** + * StepExecutionListener: Step 완료 후 정렬 및 TOP 100 저장 + */ + private StepExecutionListener weeklyRankingStepListener(InMemoryRankingCollector collector) { + return new StepExecutionListener() { + + @Override + public void beforeStep(StepExecution stepExecution) { + // Collector 초기화 + collector.clear(); + log.info("[WeeklyRanking] Step 시작 - Collector 초기화 완료"); + } + + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + String targetDateParam = stepExecution.getJobParameters() + .getString("targetDate"); + + if (targetDateParam == null) { + log.error("[WeeklyRanking] targetDate 파라미터가 없습니다"); + return ExitStatus.FAILED; + } + + LocalDate targetDate = LocalDate.parse(targetDateParam); + LocalDate weekStartDate = PeriodUtils.getWeekStartDate(targetDate); + LocalDate weekEndDate = PeriodUtils.getWeekEndDate(targetDate); + + log.info("[WeeklyRanking] Step 완료 - 주간: {} ~ {}", weekStartDate, weekEndDate); + + // 1. 기존 주간 랭킹 데이터 삭제 + weeklyRankingRepository.deleteByWeekStartDate(weekStartDate); + log.info("[WeeklyRanking] 기존 주간 랭킹 데이터 삭제 완료: {}", weekStartDate); + + // 2. 수집된 데이터 가져오기 + List collectedItems = collector.getCollectedItems(); + log.info("[WeeklyRanking] 총 {} 개 상품 메트릭 수집 완료", collectedItems.size()); + + if (collectedItems.isEmpty()) { + log.warn("[WeeklyRanking] 집계할 데이터가 없습니다"); + return ExitStatus.COMPLETED; + } + + // 3. 정렬 (점수 내림차순) + Collections.sort(collectedItems); + + // 4. TOP 100만 선택 + List topRankings = collectedItems.stream() + .limit(TOP_N) + .toList(); + + log.info("[WeeklyRanking] TOP {} 선택 완료", topRankings.size()); + + // 5. 순위 설정 및 저장 + AtomicInteger rank = new AtomicInteger(1); + List rankedList = topRankings.stream() + .map(dto -> new WeeklyRanking( + rank.getAndIncrement(), + dto.productId(), + dto.totalScore(), + weekStartDate, + weekEndDate + )) + .toList(); + + weeklyRankingRepository.saveAll(rankedList); + + log.info("[WeeklyRanking] 주간 랭킹 집계 완료 - {} 건 저장", rankedList.size()); + + return ExitStatus.COMPLETED; + } + }; + } +} From 42f0daeef1522bdc29f335665dbe8baeeecd385d Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:41:58 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20Service=20?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=EB=B3=84=20=EC=A1=B0=ED=9A=8C=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RankingService: 주간/월간 랭킹 조회 및 N+1 해결 RankingFacade: PeriodType에 따라 DAILY/WEEKLY/MONTHLY 분기 처리 배치 조회로 상품 및 브랜드 정보 효율적 조회 --- .../application/ranking/RankingFacade.java | 45 ++++- .../domain/ranking/RankingService.java | 187 ++++++++++++++++++ 2 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index c8dfc8226..a66e1e282 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -6,6 +6,9 @@ import com.loopers.domain.brand.repository.BrandRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.repository.ProductRepository; +import com.loopers.domain.ranking.PeriodType; +import com.loopers.domain.ranking.PeriodUtils; +import com.loopers.domain.ranking.RankingService; import com.loopers.infrastructure.cache.ProductRankingCache; import com.loopers.infrastructure.cache.ProductRankingCache.RankingEntry; import lombok.RequiredArgsConstructor; @@ -33,17 +36,34 @@ public class RankingFacade { private final ProductRankingCache productRankingCache; private final ProductRepository productRepository; private final BrandRepository brandRepository; + private final RankingService rankingService; /** @param page 0-based */ public RankingPageInfo getRankings(String date, int page, int size) { - // 날짜 검증 및 기본값 처리 - String targetDate = validateAndNormalizeDate(date); + return getRankings(date, PeriodType.DAILY, page, size); + } + + /** @param page 0-based */ + public RankingPageInfo getRankings(String date, PeriodType periodType, int page, int size) { + // 날짜 검증 및 변환 + LocalDate targetDate = parseAndValidateDate(date); + + return switch (periodType) { + case DAILY -> getDailyRankings(targetDate, page, size); + case WEEKLY -> rankingService.getWeeklyRankings(targetDate, page, size); + case MONTHLY -> rankingService.getMonthlyRankings(targetDate, page, size); + }; + } + + /** @param page 0-based */ + private RankingPageInfo getDailyRankings(LocalDate targetDate, int page, int size) { + String dateString = targetDate.format(DATE_FORMATTER); // 1. ZSET에서 랭킹 조회 - List rankingEntries = productRankingCache.getTopRankings(targetDate, page, size); + List rankingEntries = productRankingCache.getTopRankings(dateString, page, size); if (rankingEntries.isEmpty()) { - return RankingPageInfo.of(Collections.emptyList(), targetDate, page, size, 0); + return RankingPageInfo.of(Collections.emptyList(), dateString, page, size, 0); } // 2. 상품 ID 목록 추출 @@ -90,11 +110,24 @@ public RankingPageInfo getRankings(String date, int page, int size) { .toList(); // 6. 전체 개수 조회 - long totalCount = productRankingCache.getTotalCount(targetDate); + long totalCount = productRankingCache.getTotalCount(dateString); - return RankingPageInfo.of(rankings, targetDate, page, size, totalCount); + return RankingPageInfo.of(rankings, dateString, page, size, totalCount); } + /** @throws IllegalArgumentException 유효하지 않은 날짜 형식 */ + private LocalDate parseAndValidateDate(String date) { + if (date == null || date.isBlank()) { + return LocalDate.now(); + } + + try { + return LocalDate.parse(date, DATE_FORMATTER); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Invalid date format. Expected yyyyMMdd, got: " + date); + } + } + /** @throws IllegalArgumentException 유효하지 않은 날짜 형식 */ private String validateAndNormalizeDate(String date) { if (date == null || date.isBlank()) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java new file mode 100644 index 000000000..6faf64014 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,187 @@ +package com.loopers.domain.ranking; + +import com.loopers.application.ranking.RankingInfo.RankingItemInfo; +import com.loopers.application.ranking.RankingInfo.RankingPageInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.repository.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.repository.ProductRepository; +import com.loopers.domain.ranking.monthly.MonthlyRanking; +import com.loopers.domain.ranking.monthly.MonthlyRankingRepository; +import com.loopers.domain.ranking.weekly.WeeklyRanking; +import com.loopers.domain.ranking.weekly.WeeklyRankingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RankingService { + + private final WeeklyRankingRepository weeklyRankingRepository; + private final MonthlyRankingRepository monthlyRankingRepository; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + /** + * 주간 랭킹 조회 + */ + public RankingPageInfo getWeeklyRankings(LocalDate targetDate, int page, int size) { + LocalDate weekStartDate = PeriodUtils.getWeekStartDate(targetDate); + + // 1. 주간 랭킹 데이터 조회 + List weeklyRankings = weeklyRankingRepository + .findByWeekStartDateWithPagination(weekStartDate, page * size, size); + + if (weeklyRankings.isEmpty()) { + return RankingPageInfo.of(Collections.emptyList(), + targetDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")), + page, size, 0); + } + + // 2. 상품 및 브랜드 정보 조회 + List rankingItems = buildRankingItemsFromWeekly(weeklyRankings); + + // 3. 전체 개수 조회 + List allRankings = weeklyRankingRepository + .findByWeekStartDateOrderByRankPosition(weekStartDate); + long totalCount = allRankings.size(); + + return RankingPageInfo.of(rankingItems, + targetDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")), + page, size, totalCount); + } + + /** + * 월간 랭킹 조회 + */ + public RankingPageInfo getMonthlyRankings(LocalDate targetDate, int page, int size) { + String monthKey = PeriodUtils.getMonthKey(targetDate); + + // 1. 월간 랭킹 데이터 조회 + List monthlyRankings = monthlyRankingRepository + .findByMonthYearWithPagination(monthKey, page * size, size); + + if (monthlyRankings.isEmpty()) { + return RankingPageInfo.of(Collections.emptyList(), + targetDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")), + page, size, 0); + } + + // 2. 상품 및 브랜드 정보 조회 + List rankingItems = buildRankingItemsFromMonthly(monthlyRankings); + + // 3. 전체 개수 조회 + List allRankings = monthlyRankingRepository + .findByMonthYearOrderByRankPosition(monthKey); + long totalCount = allRankings.size(); + + return RankingPageInfo.of(rankingItems, + targetDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")), + page, size, totalCount); + } + + /** + * WeeklyRanking을 RankingItemInfo로 변환 + */ + private List buildRankingItemsFromWeekly(List weeklyRankings) { + // 1. 상품 ID 목록 추출 + List productIds = weeklyRankings.stream() + .map(WeeklyRanking::getProductId) + .toList(); + + // 2. 상품 정보 조회 + List products = productRepository.findByIdIn(productIds); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + // 3. 브랜드 정보 조회 (N+1 방지) + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + List brands = brandRepository.findByIdIn(brandIds); + Map brandMap = brands.stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + // 4. 응답 생성 + return weeklyRankings.stream() + .map(ranking -> { + Product product = productMap.get(ranking.getProductId()); + if (product == null) { + log.warn("[WeeklyRanking] Product not found - productId: {}", ranking.getProductId()); + return null; + } + Brand brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : "Unknown"; + + return new RankingItemInfo( + ranking.getRankPosition(), + product.getId(), + product.getName(), + brandName, + product.getPrice(), + product.getLikeCount(), + ranking.getTotalScore() + ); + }) + .filter(item -> item != null) + .toList(); + } + + /** + * MonthlyRanking을 RankingItemInfo로 변환 + */ + private List buildRankingItemsFromMonthly(List monthlyRankings) { + // 1. 상품 ID 목록 추출 + List productIds = monthlyRankings.stream() + .map(MonthlyRanking::getProductId) + .toList(); + + // 2. 상품 정보 조회 + List products = productRepository.findByIdIn(productIds); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + // 3. 브랜드 정보 조회 (N+1 방지) + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + List brands = brandRepository.findByIdIn(brandIds); + Map brandMap = brands.stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + // 4. 응답 생성 + return monthlyRankings.stream() + .map(ranking -> { + Product product = productMap.get(ranking.getProductId()); + if (product == null) { + log.warn("[MonthlyRanking] Product not found - productId: {}", ranking.getProductId()); + return null; + } + Brand brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : "Unknown"; + + return new RankingItemInfo( + ranking.getRankPosition(), + product.getId(), + product.getName(), + brandName, + product.getPrice(), + product.getLikeCount(), + ranking.getTotalScore() + ); + }) + .filter(item -> item != null) + .toList(); + } +} \ No newline at end of file From ea663e95e05bde20bfb697433714905449084b61 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:41:59 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20Batch=20Job=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=20API=20=EB=B0=8F=20Ranking=20API=20period=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BatchJobController: 주간/월간 랭킹 집계 Job 수동 실행 API RankingV1Controller: period 파라미터로 일간/주간/월간 선택 지원 batch.yml 설정 import 추가 --- .../api/batch/BatchJobController.java | 75 +++++++++++++++++++ .../api/ranking/RankingV1Controller.java | 11 ++- .../src/main/resources/application.yml | 1 + 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchJobController.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchJobController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchJobController.java new file mode 100644 index 000000000..12f2aa6e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchJobController.java @@ -0,0 +1,75 @@ +package com.loopers.interfaces.api.batch; + +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@RestController +@RequestMapping("/api/v1/admin/batch") +@RequiredArgsConstructor +@Slf4j +public class BatchJobController { + + private final JobLauncher jobLauncher; + private final Job weeklyRankingJob; + private final Job monthlyRankingJob; + + /** + * 주간 랭킹 집계 Job 실행 + */ + @PostMapping("/weekly-ranking") + public ApiResponse runWeeklyRankingJob( + @RequestParam(value = "targetDate", required = false) String targetDate + ) { + try { + String dateParam = targetDate != null ? targetDate : LocalDate.now().toString(); + + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", dateParam) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(weeklyRankingJob, jobParameters); + + log.info("주간 랭킹 집계 Job 실행 완료 - targetDate: {}", dateParam); + return ApiResponse.success("주간 랭킹 집계 Job이 성공적으로 실행되었습니다."); + + } catch (Exception e) { + log.error("주간 랭킹 집계 Job 실행 실패", e); + return ApiResponse.fail("BATCH_ERROR", "주간 랭킹 집계 Job 실행에 실패했습니다: " + e.getMessage()); + } + } + + /** + * 월간 랭킹 집계 Job 실행 + */ + @PostMapping("/monthly-ranking") + public ApiResponse runMonthlyRankingJob( + @RequestParam(value = "targetDate", required = false) String targetDate + ) { + try { + String dateParam = targetDate != null ? targetDate : LocalDate.now().toString(); + + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", dateParam) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(monthlyRankingJob, jobParameters); + + log.info("월간 랭킹 집계 Job 실행 완료 - targetDate: {}", dateParam); + return ApiResponse.success("월간 랭킹 집계 Job이 성공적으로 실행되었습니다."); + + } catch (Exception e) { + log.error("월간 랭킹 집계 Job 실행 실패", e); + return ApiResponse.fail("BATCH_ERROR", "월간 랭킹 집계 Job 실행에 실패했습니다: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index 1e9b98610..3211bea36 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -2,6 +2,7 @@ import com.loopers.application.ranking.RankingFacade; import com.loopers.application.ranking.RankingInfo.RankingPageInfo; +import com.loopers.domain.ranking.PeriodType; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.ranking.RankingV1Dto.RankingPageResponse; import jakarta.validation.constraints.Max; @@ -19,20 +20,24 @@ public class RankingV1Controller { private final RankingFacade rankingFacade; /** - * 랭킹 페이지 조회 - * GET /api/v1/rankings?date=yyyyMMdd&size=20&page=1 + * 랭킹 페이지 조회 + * GET /api/v1/rankings?date=yyyyMMdd&period=DAILY&size=20&page=1 * + * @param date 조회 날짜 (yyyyMMdd 형태) + * @param period 조회 기간 (DAILY/WEEKLY/MONTHLY, 기본값 DAILY) * @param page 페이지 번호 (1-based, 기본값 1) + * @param size 페이지 크기 (기본값 20, 최대 100) */ @GetMapping("/rankings") public ApiResponse getRankings( @RequestParam(value = "date", required = false) String date, + @RequestParam(value = "period", defaultValue = "DAILY") PeriodType period, @RequestParam(value = "page", defaultValue = "1") @Min(1) int page, @RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(100) int size ) { // API는 1-based, 내부는 0-based로 변환 int zeroBasedPage = Math.max(0, page - 1); - RankingPageInfo info = rankingFacade.getRankings(date, zeroBasedPage, size); + RankingPageInfo info = rankingFacade.getRankings(date, period, zeroBasedPage, size); RankingPageResponse response = RankingPageResponse.from(info); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 2fd21d1c8..cb415c8e8 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -22,6 +22,7 @@ spring: - jpa.yml - redis.yml - kafka.yml + - batch.yml - logging.yml - monitoring.yml - resilience4j.yml From 0450d2a641b59adc62b509978b3615a429ab7507 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:42:00 +0900 Subject: [PATCH 08/10] =?UTF-8?q?test:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20Batch=20Job=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WeeklyRankingJobTest: 주간 랭킹 배치 3개 시나리오 테스트 MonthlyRankingJobTest: 월간 랭킹 배치 4개 시나리오 테스트 TestContainers 기반 실제 MySQL 환경 테스트 init-ranking-tables.sql: 테스트용 DDL 스크립트 --- .../batch/MonthlyRankingJobTest.java | 217 ++++++++++++++++++ .../batch/WeeklyRankingJobTest.java | 189 +++++++++++++++ .../test/resources/db/init-ranking-tables.sql | 46 ++++ 3 files changed, 452 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/batch/MonthlyRankingJobTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/batch/WeeklyRankingJobTest.java create mode 100644 apps/commerce-api/src/test/resources/db/init-ranking-tables.sql diff --git a/apps/commerce-api/src/test/java/com/loopers/application/batch/MonthlyRankingJobTest.java b/apps/commerce-api/src/test/java/com/loopers/application/batch/MonthlyRankingJobTest.java new file mode 100644 index 000000000..359e19379 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/batch/MonthlyRankingJobTest.java @@ -0,0 +1,217 @@ +package com.loopers.application.batch; + +import com.loopers.domain.ranking.monthly.MonthlyRanking; +import com.loopers.domain.ranking.monthly.MonthlyRankingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * MonthlyRankingJob 통합 테스트 + * - TestContainers로 MySQL 실행 + * - 샘플 ProductMetrics 데이터 생성 + * - 배치 실행 및 결과 검증 + */ +@SpringBootTest +@ActiveProfiles("test") +@Sql(scripts = "/db/init-ranking-tables.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +class MonthlyRankingJobTest { + + @Autowired + private JobLauncher jobLauncher; + + @Autowired + private Job monthlyRankingJob; + + @Autowired + private MonthlyRankingRepository monthlyRankingRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + // 기존 데이터 정리 + jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + jdbcTemplate.execute("DELETE FROM product_metrics"); + } + + @Test + @DisplayName("월간 랭킹 배치가 ProductMetrics를 읽어서 TOP 100 랭킹을 생성한다") + void shouldGenerateMonthlyRankingFromProductMetrics() throws Exception { + // Given: 샘플 ProductMetrics 데이터 생성 (150개) + insertSampleProductMetrics(150); + + LocalDate targetDate = LocalDate.of(2024, 12, 15); + String monthKey = "2024-12"; + LocalDate monthStartDate = LocalDate.of(2024, 12, 1); + LocalDate monthEndDate = LocalDate.of(2024, 12, 31); + + // When: 월간 랭킹 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", targetDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) // 유니크 파라미터 + .toJobParameters(); + + JobExecution jobExecution = jobLauncher.run(monthlyRankingJob, jobParameters); + + // Then: 배치 실행 성공 + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + + // Then: TOP 100만 저장되었는지 확인 + List rankings = monthlyRankingRepository + .findByMonthYearOrderByRankPosition(monthKey); + + assertThat(rankings).hasSize(100); + + // Then: 순위가 올바르게 설정되었는지 확인 (1위부터 100위까지) + for (int i = 0; i < 100; i++) { + MonthlyRanking ranking = rankings.get(i); + assertThat(ranking.getRankPosition()).isEqualTo(i + 1); + assertThat(ranking.getMonthYear()).isEqualTo(monthKey); + assertThat(ranking.getMonthStartDate()).isEqualTo(monthStartDate); + assertThat(ranking.getMonthEndDate()).isEqualTo(monthEndDate); + } + + // Then: 점수가 내림차순으로 정렬되었는지 확인 + for (int i = 0; i < rankings.size() - 1; i++) { + assertThat(rankings.get(i).getTotalScore()) + .isGreaterThanOrEqualTo(rankings.get(i + 1).getTotalScore()); + } + + // Then: 1위 상품이 가장 높은 점수를 가지는지 확인 + MonthlyRanking firstRank = rankings.get(0); + assertThat(firstRank.getRankPosition()).isEqualTo(1); + assertThat(firstRank.getTotalScore()).isGreaterThan(0); + } + + @Test + @DisplayName("ProductMetrics가 100개 미만일 때 모든 데이터를 랭킹에 포함한다") + void shouldIncludeAllDataWhenLessThan100() throws Exception { + // Given: 50개의 ProductMetrics 데이터 + insertSampleProductMetrics(50); + + LocalDate targetDate = LocalDate.of(2024, 12, 15); + String monthKey = "2024-12"; + + // When: 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", targetDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + JobExecution jobExecution = jobLauncher.run(monthlyRankingJob, jobParameters); + + // Then: 50개 모두 저장 + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + + List rankings = monthlyRankingRepository + .findByMonthYearOrderByRankPosition(monthKey); + + assertThat(rankings).hasSize(50); + } + + @Test + @DisplayName("기존 월간 랭킹 데이터가 있으면 삭제 후 새로 생성한다") + void shouldDeleteOldRankingBeforeCreatingNew() throws Exception { + // Given: 기존 월간 랭킹 데이터 생성 + LocalDate targetDate = LocalDate.of(2024, 12, 15); + String monthKey = "2024-12"; + LocalDate monthStartDate = LocalDate.of(2024, 12, 1); + LocalDate monthEndDate = LocalDate.of(2024, 12, 31); + + MonthlyRanking oldRanking = new MonthlyRanking( + 1, 999L, 100.0, monthKey, monthStartDate, monthEndDate + ); + monthlyRankingRepository.saveAll(List.of(oldRanking)); + + // Given: 새로운 ProductMetrics 데이터 + insertSampleProductMetrics(10); + + // When: 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", targetDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(monthlyRankingJob, jobParameters); + + // Then: 기존 데이터는 삭제되고 새 데이터만 존재 + List rankings = monthlyRankingRepository + .findByMonthYearOrderByRankPosition(monthKey); + + assertThat(rankings).hasSize(10); + assertThat(rankings).noneMatch(r -> r.getProductId().equals(999L)); + } + + @Test + @DisplayName("다른 월의 랭킹 데이터는 영향을 받지 않는다") + void shouldNotAffectOtherMonthsRanking() throws Exception { + // Given: 2024-11월 랭킹 데이터 + MonthlyRanking nov2024Ranking = new MonthlyRanking( + 1, 888L, 200.0, "2024-11", + LocalDate.of(2024, 11, 1), + LocalDate.of(2024, 11, 30) + ); + monthlyRankingRepository.saveAll(List.of(nov2024Ranking)); + + // Given: 2024-12월 ProductMetrics 데이터 + insertSampleProductMetrics(10); + + // When: 2024-12월 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", "2024-12-15") + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(monthlyRankingJob, jobParameters); + + // Then: 2024-11월 데이터는 그대로 유지 + List novRankings = monthlyRankingRepository + .findByMonthYearOrderByRankPosition("2024-11"); + + assertThat(novRankings).hasSize(1); + assertThat(novRankings.get(0).getProductId()).isEqualTo(888L); + + // Then: 2024-12월 데이터는 새로 생성 + List decRankings = monthlyRankingRepository + .findByMonthYearOrderByRankPosition("2024-12"); + + assertThat(decRankings).hasSize(10); + } + + /** + * 샘플 ProductMetrics 데이터 생성 + * - productId: 1부터 count까지 + * - 점수가 다양하도록 랜덤하게 생성 + */ + private void insertSampleProductMetrics(int count) { + for (long i = 1; i <= count; i++) { + long likeCount = (count - i + 1) * 10; // 역순으로 점수 부여 + long viewCount = (count - i + 1) * 100; + long salesCount = (count - i + 1) * 5; + long salesAmount = (count - i + 1) * 50000; + + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount, last_updated) " + + "VALUES (?, ?, ?, ?, ?, NOW())", + i, likeCount, viewCount, salesCount, salesAmount + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/batch/WeeklyRankingJobTest.java b/apps/commerce-api/src/test/java/com/loopers/application/batch/WeeklyRankingJobTest.java new file mode 100644 index 000000000..0062d003e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/batch/WeeklyRankingJobTest.java @@ -0,0 +1,189 @@ +package com.loopers.application.batch; + +import com.loopers.domain.ranking.weekly.WeeklyRanking; +import com.loopers.domain.ranking.weekly.WeeklyRankingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * WeeklyRankingJob 통합 테스트 + * - TestContainers로 MySQL 실행 + * - 샘플 ProductMetrics 데이터 생성 + * - 배치 실행 및 결과 검증 + */ +@SpringBootTest +@ActiveProfiles("test") +@Sql(scripts = "/db/init-ranking-tables.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +class WeeklyRankingJobTest { + + @Autowired + private JobLauncher jobLauncher; + + @Autowired + private Job weeklyRankingJob; + + @Autowired + private WeeklyRankingRepository weeklyRankingRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + // 기존 데이터 정리 + jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.execute("DELETE FROM product_metrics"); + } + + @Test + @DisplayName("주간 랭킹 배치가 ProductMetrics를 읽어서 TOP 100 랭킹을 생성한다") + void shouldGenerateWeeklyRankingFromProductMetrics() throws Exception { + // Given: 샘플 ProductMetrics 데이터 생성 (150개) + insertSampleProductMetrics(150); + + LocalDate targetDate = LocalDate.of(2024, 12, 30); // 월요일 + LocalDate weekStartDate = LocalDate.of(2024, 12, 30); // 월요일 + LocalDate weekEndDate = LocalDate.of(2025, 1, 5); // 일요일 + + // When: 주간 랭킹 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", targetDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) // 유니크 파라미터 + .toJobParameters(); + + JobExecution jobExecution = jobLauncher.run(weeklyRankingJob, jobParameters); + + // Then: 배치 실행 성공 + if (!jobExecution.getExitStatus().getExitCode().equals("COMPLETED")) { + System.out.println("=== Job Execution Failed ==="); + System.out.println("Exit Status: " + jobExecution.getExitStatus()); + System.out.println("Exit Description: " + jobExecution.getExitStatus().getExitDescription()); + jobExecution.getStepExecutions().forEach(step -> { + System.out.println("Step: " + step.getStepName()); + System.out.println(" Exit Status: " + step.getExitStatus()); + System.out.println(" Failures: " + step.getFailureExceptions()); + step.getFailureExceptions().forEach(Throwable::printStackTrace); + }); + } + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + + // Then: TOP 100만 저장되었는지 확인 + List rankings = weeklyRankingRepository + .findByWeekStartDateOrderByRankPosition(weekStartDate); + + assertThat(rankings).hasSize(100); + + // Then: 순위가 올바르게 설정되었는지 확인 (1위부터 100위까지) + for (int i = 0; i < 100; i++) { + WeeklyRanking ranking = rankings.get(i); + assertThat(ranking.getRankPosition()).isEqualTo(i + 1); + assertThat(ranking.getWeekStartDate()).isEqualTo(weekStartDate); + assertThat(ranking.getWeekEndDate()).isEqualTo(weekEndDate); + } + + // Then: 점수가 내림차순으로 정렬되었는지 확인 + for (int i = 0; i < rankings.size() - 1; i++) { + assertThat(rankings.get(i).getTotalScore()) + .isGreaterThanOrEqualTo(rankings.get(i + 1).getTotalScore()); + } + + // Then: 1위 상품이 가장 높은 점수를 가지는지 확인 + WeeklyRanking firstRank = rankings.get(0); + assertThat(firstRank.getRankPosition()).isEqualTo(1); + assertThat(firstRank.getTotalScore()).isGreaterThan(0); + } + + @Test + @DisplayName("ProductMetrics가 100개 미만일 때 모든 데이터를 랭킹에 포함한다") + void shouldIncludeAllDataWhenLessThan100() throws Exception { + // Given: 50개의 ProductMetrics 데이터 + insertSampleProductMetrics(50); + + LocalDate targetDate = LocalDate.of(2024, 12, 30); + LocalDate weekStartDate = LocalDate.of(2024, 12, 30); + + // When: 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", targetDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + JobExecution jobExecution = jobLauncher.run(weeklyRankingJob, jobParameters); + + // Then: 50개 모두 저장 + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + + List rankings = weeklyRankingRepository + .findByWeekStartDateOrderByRankPosition(weekStartDate); + + assertThat(rankings).hasSize(50); + } + + @Test + @DisplayName("기존 주간 랭킹 데이터가 있으면 삭제 후 새로 생성한다") + void shouldDeleteOldRankingBeforeCreatingNew() throws Exception { + // Given: 기존 주간 랭킹 데이터 생성 + LocalDate targetDate = LocalDate.of(2024, 12, 30); + LocalDate weekStartDate = LocalDate.of(2024, 12, 30); + LocalDate weekEndDate = LocalDate.of(2025, 1, 5); + + WeeklyRanking oldRanking = new WeeklyRanking( + 1, 999L, 100.0, weekStartDate, weekEndDate + ); + weeklyRankingRepository.saveAll(List.of(oldRanking)); + + // Given: 새로운 ProductMetrics 데이터 + insertSampleProductMetrics(10); + + // When: 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", targetDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(weeklyRankingJob, jobParameters); + + // Then: 기존 데이터는 삭제되고 새 데이터만 존재 + List rankings = weeklyRankingRepository + .findByWeekStartDateOrderByRankPosition(weekStartDate); + + assertThat(rankings).hasSize(10); + assertThat(rankings).noneMatch(r -> r.getProductId().equals(999L)); + } + + /** + * 샘플 ProductMetrics 데이터 생성 + * - productId: 1부터 count까지 + * - 점수가 다양하도록 랜덤하게 생성 + */ + private void insertSampleProductMetrics(int count) { + for (long i = 1; i <= count; i++) { + long likeCount = (count - i + 1) * 10; // 역순으로 점수 부여 + long viewCount = (count - i + 1) * 100; + long salesCount = (count - i + 1) * 5; + long salesAmount = (count - i + 1) * 50000; + + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount, last_updated) " + + "VALUES (?, ?, ?, ?, ?, NOW())", + i, likeCount, viewCount, salesCount, salesAmount + ); + } + } +} diff --git a/apps/commerce-api/src/test/resources/db/init-ranking-tables.sql b/apps/commerce-api/src/test/resources/db/init-ranking-tables.sql new file mode 100644 index 000000000..0fb6f964b --- /dev/null +++ b/apps/commerce-api/src/test/resources/db/init-ranking-tables.sql @@ -0,0 +1,46 @@ +-- 주간 랭킹 Materialized View +CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( + id BIGINT AUTO_INCREMENT, + rank_position INT NOT NULL, + product_id BIGINT NOT NULL, + total_score DOUBLE NOT NULL, + week_start_date DATE NOT NULL COMMENT '주간 시작일 (월요일)', + week_end_date DATE NOT NULL COMMENT '주간 종료일 (일요일)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + INDEX idx_week_rank (week_start_date, rank_position), + INDEX idx_week_product (week_start_date, product_id), + INDEX idx_product_week (product_id, week_start_date) +) COMMENT '주간 상품 랭킹 (TOP 100)'; + +-- 월간 랭킹 Materialized View +CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( + id BIGINT AUTO_INCREMENT, + rank_position INT NOT NULL, + product_id BIGINT NOT NULL, + total_score DOUBLE NOT NULL, + month_year VARCHAR(7) NOT NULL COMMENT '월간 키 (YYYY-MM)', + month_start_date DATE NOT NULL COMMENT '월간 시작일', + month_end_date DATE NOT NULL COMMENT '월간 종료일', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + INDEX idx_month_rank (month_year, rank_position), + INDEX idx_month_product (month_year, product_id), + INDEX idx_product_month (product_id, month_year) +) COMMENT '월간 상품 랭킹 (TOP 100)'; + +-- Product Metrics 테이블 (배치 소스) +CREATE TABLE IF NOT EXISTS product_metrics ( + product_id BIGINT NOT NULL, + like_count BIGINT NOT NULL DEFAULT 0, + view_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (product_id) +) COMMENT '상품 메트릭 집계 테이블'; From b6dbc3dff190d60d80cc3badd03fabd2113c7cc6 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 2 Jan 2026 22:20:56 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20Batch=20DTO=EC=9D=98=20boxed=20Lon?= =?UTF-8?q?g=EC=9D=84=20primitive=20long=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=98=EC=97=AC=20NPE=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/batch/dto/ProductMetricsDto.java | 11 ++++++----- .../application/batch/dto/RankedProductDto.java | 13 +++++++------ .../batch/processor/RankingScoreProcessor.java | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/ProductMetricsDto.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/ProductMetricsDto.java index c52f623fc..272d4ac68 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/ProductMetricsDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/ProductMetricsDto.java @@ -3,12 +3,13 @@ /** * Batch 처리용 ProductMetrics DTO * - JdbcItemReader가 읽어온 데이터를 담는 객체 + * - primitive long 타입 사용으로 NPE 방지 (DB NULL은 0으로 변환됨) */ public record ProductMetricsDto( - Long productId, - Long likeCount, - Long viewCount, - Long salesCount, - Long salesAmount + long productId, + long likeCount, + long viewCount, + long salesCount, + long salesAmount ) { } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/RankedProductDto.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/RankedProductDto.java index 4ed3251ec..d071c3bc3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/RankedProductDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/RankedProductDto.java @@ -3,14 +3,15 @@ /** * 점수가 계산된 상품 정보 * - Processor가 생성하는 중간 데이터 + * - primitive 타입 사용으로 NPE 방지 */ public record RankedProductDto( - Long productId, - Double totalScore, - Long likeCount, - Long viewCount, - Long salesCount, - Long salesAmount + long productId, + double totalScore, + long likeCount, + long viewCount, + long salesCount, + long salesAmount ) implements Comparable { @Override diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/processor/RankingScoreProcessor.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/processor/RankingScoreProcessor.java index a270b6ccf..9b58080cc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/batch/processor/RankingScoreProcessor.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/processor/RankingScoreProcessor.java @@ -45,7 +45,7 @@ public RankedProductDto process(ProductMetricsDto item) { /** * 총 점수 계산 (기존 Redis 방식과 동일한 가중치) */ - private double calculateTotalScore(Long viewCount, Long likeCount, Long salesAmount) { + private double calculateTotalScore(long viewCount, long likeCount, long salesAmount) { double viewScore = viewCount * viewWeight; double likeScore = likeCount * likeWeight; From b10a63d3bf8b8e6266b9a87e706eba95eaf5f532 Mon Sep 17 00:00:00 2001 From: Seoyeon Lee <68765200+sylee6529@users.noreply.github.com> Date: Fri, 2 Jan 2026 22:32:57 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EC=97=90=20=EC=9C=A0=EB=8B=88=ED=81=AC=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EB=8F=99=EC=8B=9C=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=8B=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=86=90=EC=8B=A4=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/ranking/monthly/MonthlyRanking.java | 10 +++++++++- .../loopers/domain/ranking/weekly/WeeklyRanking.java | 10 +++++++++- .../src/test/resources/db/init-ranking-tables.sql | 4 ++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRanking.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRanking.java index 3f4b152b1..c55294ae1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRanking.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRanking.java @@ -9,7 +9,15 @@ import java.time.LocalDate; @Entity -@Table(name = "mv_product_rank_monthly") +@Table( + name = "mv_product_rank_monthly", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_monthly_ranking_month_position", + columnNames = {"month_start_date", "rank_position"} + ) + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class MonthlyRanking extends BaseEntity { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRanking.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRanking.java index 1ddd92372..8be2d1751 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRanking.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRanking.java @@ -9,7 +9,15 @@ import java.time.LocalDate; @Entity -@Table(name = "mv_product_rank_weekly") +@Table( + name = "mv_product_rank_weekly", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_weekly_ranking_week_position", + columnNames = {"week_start_date", "rank_position"} + ) + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class WeeklyRanking extends BaseEntity { diff --git a/apps/commerce-api/src/test/resources/db/init-ranking-tables.sql b/apps/commerce-api/src/test/resources/db/init-ranking-tables.sql index 0fb6f964b..863e4e34c 100644 --- a/apps/commerce-api/src/test/resources/db/init-ranking-tables.sql +++ b/apps/commerce-api/src/test/resources/db/init-ranking-tables.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), - INDEX idx_week_rank (week_start_date, rank_position), + UNIQUE KEY uk_weekly_ranking_week_position (week_start_date, rank_position), INDEX idx_week_product (week_start_date, product_id), INDEX idx_product_week (product_id, week_start_date) ) COMMENT '주간 상품 랭킹 (TOP 100)'; @@ -28,7 +28,7 @@ CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), - INDEX idx_month_rank (month_year, rank_position), + UNIQUE KEY uk_monthly_ranking_month_position (month_start_date, rank_position), INDEX idx_month_product (month_year, product_id), INDEX idx_product_month (product_id, month_year) ) COMMENT '월간 상품 랭킹 (TOP 100)';