diff --git a/README.md b/README.md index 04950f29d..f86e4dd8a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ docker-compose -f ./docker/monitoring-compose.yml up Root ├── apps ( spring-applications ) │ ├── 📦 commerce-api +│ ├── 📦 commerce-batch │ └── 📦 commerce-streamer ├── modules ( reusable-configurations ) │ ├── 📦 jpa diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java new file mode 100644 index 000000000..862b72b32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java @@ -0,0 +1,7 @@ +package com.loopers.application.ranking; + +public enum RankingPeriod { + DAILY, + WEEKLY, + MONTHLY +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQuery.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQuery.java new file mode 100644 index 000000000..d92863d4c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQuery.java @@ -0,0 +1,10 @@ +package com.loopers.application.ranking; + +import java.time.LocalDate; + +public record RankingQuery( + RankingPeriod period, + String key, + LocalDate date, + int limit +) { } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java index f881e18ef..43b1abec3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java @@ -1,10 +1,8 @@ package com.loopers.application.ranking; -import com.loopers.application.like.event.ProductLikeEvent; import com.loopers.application.product.ProductLikeSummary; import com.loopers.application.product.ProductQueryService; -import com.loopers.domain.product.ProductSortType; -import com.loopers.ranking.DailyRankingResponse; +import com.loopers.application.ranking.strategy.RankingFetchStrategyResolver; import com.loopers.ranking.RankingEntry; import com.loopers.ranking.RankingZSetRepository; import com.loopers.support.error.CoreException; @@ -16,8 +14,6 @@ import java.time.LocalDate; import java.time.ZoneId; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.OptionalDouble; @@ -29,17 +25,20 @@ public class RankingQueryService { private final RankingZSetRepository rankingZSetRepository; private final ProductQueryService productQueryService; + private final RankingFetchStrategyResolver rankingResolver; + @Transactional(readOnly = true) - public RankingQueryResponse getDailyPopularProducts(String date, int size) { - LocalDate target = initLocalDate(date); + public RankingQueryResponse getDailyPopularProducts(RankingPeriod period, String date, int size) { + log.debug("Get {} popular products for {}", period, date); - int limit = (size <= 0) ? 20 : Math.min(size, 100); + RankingFetchStrategyResolver.Resolved resolved = rankingResolver.resolve(period, date, size); + RankingQuery rankingQuery = resolved.rankingQuery(); - List rankingEntries = rankingZSetRepository.findTopDailyAllByLimit(target, limit); + List rankingEntries = resolved.policy().fetchRankingEntries(rankingQuery.key(), rankingQuery.limit()); List productLikeSummaries = findProductSummaryFrom(rankingEntries); return new RankingQueryResponse( - target, + rankingQuery.date(), rankingEntries, productLikeSummaries ); @@ -51,17 +50,6 @@ public OptionalDouble getDailyRankingScore(Long productId) { return rankingZSetRepository.findDailyRanking(now, productId); } - private boolean hasValidDate(String date) { - return date == null || date.isBlank(); - } - - private LocalDate initLocalDate(String date) { - return (hasValidDate(date)) - ? LocalDate.now(ZoneId.systemDefault()) - : LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE); - - } - private List findProductSummaryFrom(List rankingEntries) { List result = new ArrayList<>(); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/DailyRankingFetcher.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/DailyRankingFetcher.java new file mode 100644 index 000000000..5a0d28ed1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/DailyRankingFetcher.java @@ -0,0 +1,39 @@ +package com.loopers.application.ranking.strategy; + +import com.loopers.application.ranking.RankingPeriod; +import com.loopers.ranking.RankingEntry; +import com.loopers.ranking.RankingZSetRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class DailyRankingFetcher implements RankingFetchStrategy { + private final RankingZSetRepository rankingZSetRepository; + + @Override + public RankingPeriod getRankingPeriod() { + return RankingPeriod.DAILY; + } + + @Override + public List fetchRankingEntries(String key, int limit) { + LocalDate target = initLocalDate(key); + return rankingZSetRepository.findTopDailyAllByLimit(target, limit); + } + + private LocalDate initLocalDate(String date) { + return (hasValidDate(date)) + ? LocalDate.now(ZoneId.systemDefault()) + : LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE); + } + + private boolean hasValidDate(String date) { + return date == null || date.isBlank(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/MonthlyRankingFetcher.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/MonthlyRankingFetcher.java new file mode 100644 index 000000000..62c936e66 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/MonthlyRankingFetcher.java @@ -0,0 +1,29 @@ +package com.loopers.application.ranking.strategy; + +import com.loopers.application.ranking.RankingPeriod; +import com.loopers.infrastructure.ranking.ProductRankMonthlyRepository; +import com.loopers.ranking.RankingEntry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MonthlyRankingFetcher implements RankingFetchStrategy { + private final ProductRankMonthlyRepository monthlyRankingRepository; + + @Override + public RankingPeriod getRankingPeriod() { + return RankingPeriod.MONTHLY; + } + + @Override + public List fetchRankingEntries(String key, int limit) { + log.debug("Fetching ranking entries for key {}", key); + return monthlyRankingRepository.findTopByYearMonth(key, PageRequest.of(0, limit)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategy.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategy.java new file mode 100644 index 000000000..6d8a9f51e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategy.java @@ -0,0 +1,12 @@ +package com.loopers.application.ranking.strategy; + +import com.loopers.application.ranking.RankingPeriod; +import com.loopers.ranking.RankingEntry; + +import java.util.List; + + +public interface RankingFetchStrategy { + RankingPeriod getRankingPeriod(); + List fetchRankingEntries(String key, int limit); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategyResolver.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategyResolver.java new file mode 100644 index 000000000..1b56cde60 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategyResolver.java @@ -0,0 +1,78 @@ +package com.loopers.application.ranking.strategy; + +import com.loopers.application.ranking.RankingPeriod; +import com.loopers.application.ranking.RankingQuery; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class RankingFetchStrategyResolver { + private static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.BASIC_ISO_DATE; + private static final DateTimeFormatter YYYYMM = DateTimeFormatter.ofPattern("yyyyMM"); + private final Map policies; + + public RankingFetchStrategyResolver(List policies) { + this.policies = policies.stream() + .collect(Collectors.toMap(RankingFetchStrategy::getRankingPeriod, Function.identity())); + } + + /** + * 랭킹 조회 방법을 선택합니다. + * @param period + * @param date + * @param size + * @return + */ + public Resolved resolve(RankingPeriod period, String date, int size) { + LocalDate target = initLocalDate(date); + int limit = normalizeSize(size); + + String key = switch (period) { + case DAILY -> target.format(YYYYMMDD); + case WEEKLY -> yearWeekKey(target); // 2026-W01 + case MONTHLY -> target.format(YYYYMM); // 202601 + }; + + RankingFetchStrategy policy = policies.get(period); + if (policy == null) throw new IllegalArgumentException("Unsupported period: " + period); + + return new Resolved(new RankingQuery(period, key, target, limit), policy); + } + + public record Resolved(RankingQuery rankingQuery, RankingFetchStrategy policy) {} + + /** + * 최대 상한선 TOP-100 으로 설정 + * @param size + * @return + */ + private int normalizeSize(int size) { + if (size <= 0) return 20; + return Math.min(size, 100); + } + + private LocalDate initLocalDate(String date) { + return (hasValidDate(date)) + ? LocalDate.now(ZoneId.systemDefault()) + : LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE); + } + + private String yearWeekKey(LocalDate date) { + WeekFields wf = WeekFields.ISO; + int y = date.get(wf.weekBasedYear()); + int w = date.get(wf.weekOfWeekBasedYear()); + return "%d-W%02d".formatted(y, w); + } + + private boolean hasValidDate(String date) { + return date == null || date.isBlank(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/WeeklyRankingFetcher.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/WeeklyRankingFetcher.java new file mode 100644 index 000000000..440e0082a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/WeeklyRankingFetcher.java @@ -0,0 +1,29 @@ +package com.loopers.application.ranking.strategy; + +import com.loopers.application.ranking.RankingPeriod; +import com.loopers.infrastructure.ranking.ProductRankWeeklyRepository; +import com.loopers.ranking.RankingEntry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WeeklyRankingFetcher implements RankingFetchStrategy { + private final ProductRankWeeklyRepository weeklyRankingRepository; + + @Override + public RankingPeriod getRankingPeriod() { + return RankingPeriod.WEEKLY; + } + + @Override + public List fetchRankingEntries(String key, int limit) { + log.debug("Fetching ranking entries for key {}", key); + return weeklyRankingRepository.findTopByYearWeek(key, PageRequest.of(0, limit)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java new file mode 100644 index 000000000..54871083e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java @@ -0,0 +1,65 @@ +package com.loopers.domain.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "mv_product_rank_monthly") +@Getter +@NoArgsConstructor +public class ProductRankMonthly { + + @EmbeddedId + private ProductRankMonthlyId id; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_count", nullable = false) + private long orderCount; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public static ProductRankMonthly of(String yearMonth, Long productId, + long viewCount, long likeCount, long orderCount) { + ProductRankMonthly e = new ProductRankMonthly(); + e.id = new ProductRankMonthlyId(yearMonth, productId); + e.viewCount = viewCount; + e.likeCount = likeCount; + e.orderCount = orderCount; + e.createdAt = LocalDateTime.now(); + e.updatedAt = LocalDateTime.now(); + return e; + } + + public void overwrite(long viewCount, long likeCount, long orderCount) { + this.viewCount = viewCount; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.updatedAt = LocalDateTime.now(); + } + + public Long productId() { + return id.getProductId(); + } + + public String yearMonth() { + return id.getYearMonth(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java b/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java new file mode 100644 index 000000000..19465a8f5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java @@ -0,0 +1,21 @@ +package com.loopers.domain.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.*; + +import java.io.Serializable; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EqualsAndHashCode +public class ProductRankMonthlyId implements Serializable { + + @Column(name = "year_month_key", length = 8, nullable = false) + private String yearMonth; // e.g. 202601 + + @Column(name = "product_id", nullable = false) + private Long productId; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java new file mode 100644 index 000000000..5c145cd38 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java @@ -0,0 +1,65 @@ +package com.loopers.domain.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Entity +@Table(name = "mv_product_rank_weekly") +@Getter +@NoArgsConstructor +public class ProductRankWeekly { + @EmbeddedId + private ProductRankWeeklyId id; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_count", nullable = false) + private long orderCount; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public static ProductRankWeekly of(String yearWeek, Long productId, + long viewCount, long likeCount, long orderCount) { + ProductRankWeekly e = new ProductRankWeekly(); + e.id = new ProductRankWeeklyId(yearWeek, productId); + e.viewCount = viewCount; + e.likeCount = likeCount; + e.orderCount = orderCount; + e.createdAt = LocalDateTime.now(ZoneId.systemDefault()); + e.updatedAt = LocalDateTime.now(ZoneId.systemDefault()); + return e; + } + + public void overwrite(long viewCount, long likeCount, long orderCount) { + this.viewCount = viewCount; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.updatedAt = LocalDateTime.now(); + } + + public Long productId() { + return id.getProductId(); + } + + public String yearWeek() { + return id.getYearWeek(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java b/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java new file mode 100644 index 000000000..c3715d576 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java @@ -0,0 +1,21 @@ +package com.loopers.domain.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.*; + +import java.io.Serializable; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EqualsAndHashCode +public class ProductRankWeeklyId implements Serializable { + + @Column(name = "year_week_key", length = 10, nullable = false) + private String yearWeek; // e.g. 2026-W01 + + @Column(name = "product_id", nullable = false) + private Long productId; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyRepository.java new file mode 100644 index 000000000..7c385c1c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyRepository.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.mv.ProductRankMonthly; +import com.loopers.domain.mv.ProductRankMonthlyId; +import com.loopers.ranking.RankingEntry; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ProductRankMonthlyRepository extends JpaRepository { + + @Query(""" + select new com.loopers.ranking.RankingEntry( + p.id.productId, + p.score + ) + from ProductRankMonthly p + where p.id.yearMonth = :yearMonth + order by p.score desc + """) + List findTopByYearMonth(@Param("yearMonth") String yearMonth, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyRepository.java new file mode 100644 index 000000000..c976faafd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyRepository.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.mv.ProductRankWeekly; +import com.loopers.domain.mv.ProductRankWeeklyId; +import com.loopers.ranking.RankingEntry; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ProductRankWeeklyRepository extends JpaRepository { + + @Query(""" + select new com.loopers.ranking.RankingEntry( + p.id.productId, + p.score + ) + from ProductRankWeekly p + where p.id.yearWeek = :yearWeek + order by p.score desc + """) + List findTopByYearWeek(@Param("yearWeek") String yearWeekKey, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index c766297f2..70fa24b22 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -4,10 +4,9 @@ import com.loopers.application.product.ProductQueryService; import com.loopers.application.ranking.RankingQueryService; import com.loopers.domain.product.ProductSortType; -import com.loopers.ranking.RankingEntry; -import com.loopers.support.tracking.general.UserActionType; import com.loopers.interfaces.api.ApiResponse; import com.loopers.support.tracking.annotation.TrackUserAction; +import com.loopers.support.tracking.general.UserActionType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; 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 abaf729d5..21400912f 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 @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.ranking; +import com.loopers.application.ranking.RankingPeriod; import com.loopers.application.ranking.RankingQueryResponse; import com.loopers.application.ranking.RankingQueryService; import lombok.RequiredArgsConstructor; @@ -17,10 +18,11 @@ public class RankingV1Controller { @GetMapping public RankingQueryResponse getDailyRanking( + @RequestParam(defaultValue = "DAILY", name = "period") RankingPeriod period, @RequestParam(required = false, name = "date") String date, @RequestParam(defaultValue = "20", name = "size") int size ) { - return rankingQueryService.getDailyPopularProducts(date, size); + return rankingQueryService.getDailyPopularProducts(period, date, size); } } diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..b22b6477c --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,21 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java new file mode 100644 index 000000000..4b6f04e0f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,42 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.Bean; + +import javax.sql.DataSource; +import java.util.TimeZone; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceBatchApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); + System.exit(exitCode); + } + + @Bean + public static BeanFactoryPostProcessor dataSourceAliasForBatch() { + return (ConfigurableListableBeanFactory beanFactory) -> { + String[] names = beanFactory.getBeanNamesForType(DataSource.class); + // DataSource가 1개면 그걸 "dataSource"라는 이름으로도 접근 가능하게 별칭 등록 + if (names.length == 1 && beanFactory instanceof DefaultListableBeanFactory dlbf) { + if (!dlbf.containsBean("dataSource") && !dlbf.isAlias("dataSource")) { + dlbf.registerAlias(names[0], "dataSource"); + } + } + }; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/db/ProductMetricDataInitializer.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/db/ProductMetricDataInitializer.java new file mode 100644 index 000000000..5e5f74f83 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/db/ProductMetricDataInitializer.java @@ -0,0 +1,43 @@ +package com.loopers.batch.infrastructure.db; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.datasource.init.ScriptUtils; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; + +@Slf4j +@Component +@Profile("local") +@RequiredArgsConstructor +public class ProductMetricDataInitializer implements CommandLineRunner { + private final DataSource dataSource; + + @Value("${spring.jpa.hibernate.ddl-auto}") + private String ddlAuto; + + @Override + public void run(String... args) throws Exception { + + if(ddlAuto.equals("none")) { + log.info("[ProductMetricDataInitializer] skipped because ddl-auto is none"); + return; + } + + + log.info("[ProductMetricDataInitializer] start"); + try (Connection conn = dataSource.getConnection()) { + ScriptUtils.executeSqlScript( + conn, + new ClassPathResource("db/fixtures/.sql") + ); + } + log.info("[ProductMetricDataInitializer] done"); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java new file mode 100644 index 000000000..7c486483f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java @@ -0,0 +1,48 @@ +package com.loopers.batch.job.demo; + +import com.loopers.batch.job.demo.step.DemoTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +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.support.transaction.ResourcelessTransactionManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class DemoJobConfig { + public static final String JOB_NAME = "demoJob"; + private static final String STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DemoTasklet demoTasklet; + + @Bean(JOB_NAME) + public Job demoJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(categorySyncStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_DEMO_SIMPLE_TASK_NAME) + public Step categorySyncStep() { + return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) + .tasklet(demoTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java new file mode 100644 index 000000000..800fe5a03 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java @@ -0,0 +1,32 @@ +package com.loopers.batch.job.demo.step; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class DemoTasklet implements Tasklet { + @Value("#{jobParameters['requestDate']}") + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + if (requestDate == null) { + throw new RuntimeException("requestDate is null"); + } + System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); + Thread.sleep(1000); + System.out.println("Demo Tasklet 작업 완료"); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyJobConfig.java new file mode 100644 index 000000000..b28ff4baf --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyJobConfig.java @@ -0,0 +1,56 @@ +package com.loopers.batch.job.rankMonthly; + +import com.loopers.batch.job.rankMonthly.dto.MonthlyAggRow; +import com.loopers.batch.job.rankMonthly.dto.MonthlyRankRow; +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.*; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.listener.StepExecutionListenerSupport; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = "rankMonthlyMvJob") +@Configuration +// @EnableBatchProcessing +@RequiredArgsConstructor +public class RankMonthlyJobConfig { + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + + @Bean + public Job rankMonthlyMvJob(JobRepository jobRepository, @Qualifier("rankMonthlyMvStep") Step rankWeeklyMvStep) { + return new JobBuilder("rankMonthlyMvJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(rankWeeklyMvStep) + .listener(jobListener) + .build(); + } + + @Bean + public Step rankMonthlyMvStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + JdbcPagingItemReader weeklyAggReader, + RankMonthlyProcessor processor, + JdbcBatchItemWriter weeklyMvUpsertWriter + ) { + return new StepBuilder("rankMonthlyMvStep", jobRepository) + .chunk(1000, transactionManager) + .reader(weeklyAggReader) + .processor(processor) + .writer(weeklyMvUpsertWriter) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessor.java new file mode 100644 index 000000000..dd808edfb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessor.java @@ -0,0 +1,37 @@ +package com.loopers.batch.job.rankMonthly; + +import com.loopers.batch.job.rankMonthly.dto.MonthlyAggRow; +import com.loopers.batch.job.rankMonthly.dto.MonthlyRankRow; +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RankMonthlyProcessor implements ItemProcessor { + + private final String yearMonth; + + @Override + public MonthlyRankRow process(MonthlyAggRow item) { + double score = calculateScore(item); + return new MonthlyRankRow( + yearMonth, + item.productId(), + item.viewCount(), + item.likeCount(), + item.orderCount(), + score + ); + } + + // TODO 정책적으로 분리할 수 있도록 개선한다 + private double calculateScore(MonthlyAggRow item) { + return 0.1 * item.viewCount() + + 0.2 * item.likeCount() + + 0.7 * item.orderCount(); + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessorConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessorConfig.java new file mode 100644 index 000000000..502fb9a3e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessorConfig.java @@ -0,0 +1,17 @@ +package com.loopers.batch.job.rankMonthly; + +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RankMonthlyProcessorConfig { + @Bean + @StepScope + public RankMonthlyProcessor rankMonthlyProcessor( + @Value("#{jobParameters['yearMonth']}") String yearMonth + ) { + return new RankMonthlyProcessor(yearMonth); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyReaderConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyReaderConfig.java new file mode 100644 index 000000000..348bba19f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyReaderConfig.java @@ -0,0 +1,77 @@ +package com.loopers.batch.job.rankMonthly; + +import com.loopers.batch.job.rankMonthly.dto.MonthlyAggRow; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; +import java.util.Map; + +@Configuration +@RequiredArgsConstructor +public class RankMonthlyReaderConfig { + private final DataSource dataSource; + + @Bean + @StepScope + public JdbcPagingItemReader monthlyAggReader( + @Value("#{jobParameters['startDate']}") String startDate, + @Value("#{jobParameters['endDate']}") String endDate + ) { + return new JdbcPagingItemReaderBuilder() + .name("monthlyAggReader") + .dataSource(dataSource) + .queryProvider(queryProvider()) + .parameterValues(Map.of( + "startDate", startDate, + "endDate", endDate + )) + .rowMapper((rs, rowNum) -> new MonthlyAggRow( + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getLong("order_count") + )) + .pageSize(1000) + .build(); + } + + private MySqlPagingQueryProvider queryProvider() { + MySqlPagingQueryProvider qp = new MySqlPagingQueryProvider(); + + qp.setSelectClause(""" + select + product_id, + view_count, + like_count, + order_count + """); + + qp.setFromClause(""" + from ( + select + pm.product_id as product_id, + SUM(CASE WHEN pm.metrics_type = 'VIEW' THEN pm.count ELSE 0 END) as view_count, + SUM(CASE WHEN pm.metrics_type = 'LIKE' THEN pm.count ELSE 0 END) as like_count, + SUM(CASE WHEN pm.metrics_type = 'ORDER_SUCCESS' THEN pm.count ELSE 0 END) as order_count + from product_metrics pm + where pm.metrics_date between :startDate and :endDate + group by pm.product_id + ) t + """); + + qp.setSortKeys(Map.of( + "product_id", org.springframework.batch.item.database.Order.ASCENDING + )); + + return qp; + } + + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyWriterConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyWriterConfig.java new file mode 100644 index 000000000..6b73b53c4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyWriterConfig.java @@ -0,0 +1,39 @@ +package com.loopers.batch.job.rankMonthly; + +import com.loopers.batch.job.rankMonthly.dto.MonthlyRankRow; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; + +@Configuration +@RequiredArgsConstructor +public class RankMonthlyWriterConfig { + private final DataSource dataSource; + + @Bean + public JdbcBatchItemWriter monthlyMvUpsertWriter() { + String sql = """ + INSERT INTO mv_product_rank_monthly + (year_month_key, product_id, view_count, like_count, order_count, score, created_at, updated_at) + VALUES + (:yearMonth, :productId, :viewCount, :likeCount, :orderCount, :score, NOW(6), NOW(6)) + ON DUPLICATE KEY UPDATE + view_count = VALUES(view_count), + like_count = VALUES(like_count), + order_count = VALUES(order_count), + score = VALUES(score), + updated_at = NOW(6) + """; + + return new JdbcBatchItemWriterBuilder() + .dataSource(dataSource) + .sql(sql) + .beanMapped() + .assertUpdates(false) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyAggRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyAggRow.java new file mode 100644 index 000000000..81723dbf1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyAggRow.java @@ -0,0 +1,8 @@ +package com.loopers.batch.job.rankMonthly.dto; + +public record MonthlyAggRow( + Long productId, + long viewCount, + long likeCount, + long orderCount +) { } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyRankRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyRankRow.java new file mode 100644 index 000000000..b8be65b4a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyRankRow.java @@ -0,0 +1,10 @@ +package com.loopers.batch.job.rankMonthly.dto; + +public record MonthlyRankRow( + String yearMonth, // e.g) 202601 + Long productId, + long viewCount, + long likeCount, + long orderCount, + double score +) { } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java new file mode 100644 index 000000000..824e9ac0a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java @@ -0,0 +1,56 @@ +package com.loopers.batch.job.rankWeekly; + +import com.loopers.batch.job.rankWeekly.step.RankWeeklyProcessor; +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyRankRow; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = "rankWeeklyMvJob") +@Configuration +// @EnableBatchProcessing +@RequiredArgsConstructor +public class RankWeeklyJobConfig { + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + + @Bean + public Job rankWeeklyMvJob(JobRepository jobRepository, @Qualifier("rankWeeklyMvStep") Step rankWeeklyMvStep) { + return new JobBuilder("rankWeeklyMvJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(rankWeeklyMvStep) + .listener(jobListener) + .build(); + } + + @Bean + public Step rankWeeklyMvStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + JdbcPagingItemReader weeklyAggReader, + RankWeeklyProcessor processor, + JdbcBatchItemWriter weeklyMvUpsertWriter + ) { + return new StepBuilder("rankWeeklyMvStep", jobRepository) + .chunk(1000, transactionManager) + .reader(weeklyAggReader) + .processor(processor) + .writer(weeklyMvUpsertWriter) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java new file mode 100644 index 000000000..9579c7949 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java @@ -0,0 +1,29 @@ +package com.loopers.batch.job.rankWeekly.step; + +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyRankRow; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +@Component +public class RankWeeklyProcessor implements ItemProcessor { + @Override + public WeeklyRankRow process(WeeklyAggRow item) { + double score = calculateScore(item); + return new WeeklyRankRow( + item.yearWeek(), + item.productId(), + item.viewCount(), + item.likeCount(), + item.orderCount(), + score + ); + } + + // TODO 정책적으로 분리할 수 있도록 개선한다 + private double calculateScore(WeeklyAggRow item) { + return 0.1 * item.viewCount() + + 0.2 * item.likeCount() + + 0.7 * item.orderCount(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyReaderConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyReaderConfig.java new file mode 100644 index 000000000..356acfe5d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyReaderConfig.java @@ -0,0 +1,80 @@ +package com.loopers.batch.job.rankWeekly.step; + +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; +import java.util.Map; + +@Configuration +@RequiredArgsConstructor +public class RankWeeklyReaderConfig { + private final DataSource dataSource; + + @Bean + @StepScope + public JdbcPagingItemReader weeklyAggReader( + @Value("#{jobParameters['yearWeek']}") String yearWeek) { + return new JdbcPagingItemReaderBuilder() + .name("weeklyAggReader") + .dataSource(dataSource) + .queryProvider(queryProvider()) + .parameterValues(Map.of("yearWeek", yearWeek)) + .rowMapper((rs, rowNum) -> new WeeklyAggRow( + rs.getString("year_week"), + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getLong("order_count") + )) + .pageSize(1000) + .build(); + } + + private MySqlPagingQueryProvider queryProvider() { + MySqlPagingQueryProvider qp = new MySqlPagingQueryProvider(); + + qp.setSelectClause(""" + select + year_week, + product_id, + view_count, + like_count, + order_count + """); + + qp.setFromClause(""" + from ( + select + CONCAT( + SUBSTRING(YEARWEEK(STR_TO_DATE(pm.metrics_date, '%Y%m%d'), 3), 1, 4), + '-W', + LPAD(SUBSTRING(YEARWEEK(STR_TO_DATE(pm.metrics_date, '%Y%m%d'), 3), 5, 2), 2, '0') + ) as year_week, + pm.product_id as product_id, + SUM(CASE WHEN pm.metrics_type = 'VIEW' THEN pm.count ELSE 0 END) as view_count, + SUM(CASE WHEN pm.metrics_type = 'LIKE' THEN pm.count ELSE 0 END) as like_count, + SUM(CASE WHEN pm.metrics_type = 'ORDER_SUCCESS' THEN pm.count ELSE 0 END) as order_count + from product_metrics pm + group by year_week, pm.product_id + ) t + """); + + qp.setWhereClause("where t.year_week = :yearWeek"); + + qp.setSortKeys(Map.of( + "product_id", org.springframework.batch.item.database.Order.ASCENDING + )); + + return qp; + } + + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java new file mode 100644 index 000000000..f77b2aee4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java @@ -0,0 +1,39 @@ +package com.loopers.batch.job.rankWeekly.step; + +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyRankRow; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; + +@Configuration +@RequiredArgsConstructor +public class RankWeeklyWriterConfig { + private final DataSource dataSource; + + @Bean + public JdbcBatchItemWriter weeklyMvUpsertWriter() { + String sql = """ + INSERT INTO mv_product_rank_weekly + (year_week_key, product_id, view_count, like_count, order_count, score, created_at, updated_at) + VALUES + (:yearWeek, :productId, :viewCount, :likeCount, :orderCount, :score, NOW(6), NOW(6)) + ON DUPLICATE KEY UPDATE + view_count = VALUES(view_count), + like_count = VALUES(like_count), + order_count = VALUES(order_count), + score=VALUES(score), + updated_at = NOW(6) + """; + + return new JdbcBatchItemWriterBuilder() + .dataSource(dataSource) + .sql(sql) + .beanMapped() + .assertUpdates(false) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyAggRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyAggRow.java new file mode 100644 index 000000000..4f8117a1e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyAggRow.java @@ -0,0 +1,9 @@ +package com.loopers.batch.job.rankWeekly.step.dto; + +public record WeeklyAggRow ( + String yearWeek, // e.g. 2026-W01 + Long productId, + long viewCount, + long likeCount, + long orderCount +) { } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyRankRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyRankRow.java new file mode 100644 index 000000000..9039962a2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyRankRow.java @@ -0,0 +1,10 @@ +package com.loopers.batch.job.rankWeekly.step.dto; + +public record WeeklyRankRow( + String yearWeek, + Long productId, + long viewCount, + long likeCount, + long orderCount, + double score +) { } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java new file mode 100644 index 000000000..10b09b8fc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java @@ -0,0 +1,21 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.annotation.AfterChunk; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ChunkListener { + + @AfterChunk + void afterChunk(ChunkContext chunkContext) { + log.info( + "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + + "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java new file mode 100644 index 000000000..cb5c8bebd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -0,0 +1,53 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.annotation.AfterJob; +import org.springframework.batch.core.annotation.BeforeJob; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JobListener { + + @BeforeJob + void beforeJob(JobExecution jobExecution) { + log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); + jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); + } + + @AfterJob + void afterJob(JobExecution jobExecution) { + var startTime = jobExecution.getExecutionContext().getLong("startTime"); + var endTime = System.currentTimeMillis(); + + var startDateTime = Instant.ofEpochMilli(startTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + var endDateTime = Instant.ofEpochMilli(endTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + var totalTime = endTime - startTime; + var duration = Duration.ofMillis(totalTime); + var hours = duration.toHours(); + var minutes = duration.toMinutes() % 60; + var seconds = duration.getSeconds() % 60; + + var message = String.format( + """ + *Start Time:* %s + *End Time:* %s + *Total Time:* %d시간 %d분 %d초 + """, startDateTime, endDateTime, hours, minutes, seconds + ).trim(); + + log.info(message); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java new file mode 100644 index 000000000..4f22f40b0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -0,0 +1,44 @@ +package com.loopers.batch.listener; + +import jakarta.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Component +public class StepMonitorListener implements StepExecutionListener { + + @Override + public void beforeStep(@Nonnull StepExecution stepExecution) { + log.info("Step '{}' 시작", stepExecution.getStepName()); + } + + @Override + public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); + var exceptions = stepExecution.getFailureExceptions().stream() + .map(Throwable::getMessage) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + log.info( + """ + [에러 발생] + jobName: {} + exceptions: + {} + """.trim(), jobName, exceptions + ); + // error 발생 시 slack 등 다른 채널로 모니터 전송 + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..9aa0d760a --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,54 @@ +spring: + main: + web-application-type: none + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + batch: + job: + name: ${job.name:NONE} + jdbc: + initialize-schema: never + +management: + health: + defaults: + enabled: false + +--- +spring: + config: + activate: + on-profile: local, test + batch: + jdbc: + initialize-schema: always + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/apps/commerce-batch/src/main/resources/db/fixtures/large-product-metric-data.sql b/apps/commerce-batch/src/main/resources/db/fixtures/large-product-metric-data.sql new file mode 100644 index 000000000..7ce9d45b5 --- /dev/null +++ b/apps/commerce-batch/src/main/resources/db/fixtures/large-product-metric-data.sql @@ -0,0 +1,44 @@ +-- PRODUCT_METRICS bulk dummy data +-- 범위: [2025-01-01, 2026-01-31] +DELETE FROM product_metrics; + +INSERT INTO product_metrics ( + product_id, + metrics_date, + metrics_type, + `count`, + created_at, + updated_at +) +SELECT + p.product_id, + DATE_FORMAT(DATE_ADD('2025-01-01', INTERVAL n.n DAY), '%Y%m%d') AS metrics_date, + m.metrics_type, + CASE m.metrics_type + WHEN 'VIEW' THEN FLOOR(50 + RAND() * 500) + WHEN 'LIKE' THEN FLOOR(5 + RAND() * 80) + WHEN 'ORDER_SUCCESS' THEN FLOOR(RAND() * 20) + WHEN 'PAYMENT_SUCCESS' THEN FLOOR(RAND() * 20) + END AS `count`, + NOW(), + NOW() +FROM + -- n = 0..395 생성 (최대 999까지 가능) + ( + SELECT (a.d + 10*b.d + 100*c.d) AS n + FROM (SELECT 0 d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) a + CROSS JOIN (SELECT 0 d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) b + CROSS JOIN (SELECT 0 d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) c + ) n + CROSS JOIN + (SELECT 1 AS product_id UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5) p + CROSS JOIN + (SELECT 'VIEW' AS metrics_type + UNION ALL SELECT 'LIKE' + UNION ALL SELECT 'ORDER_SUCCESS' + UNION ALL SELECT 'PAYMENT_SUCCESS') m +WHERE + n.n BETWEEN 0 AND 395; \ No newline at end of file diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java new file mode 100644 index 000000000..c5e3bc7a3 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -0,0 +1,10 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class CommerceBatchApplicationTest { + @Test + void contextLoads() {} +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java new file mode 100644 index 000000000..dafe59a18 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java @@ -0,0 +1,76 @@ +package com.loopers.job.demo; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) +class DemoJobE2ETest { + + // IDE 정적 분석 상 [SpringBatchTest] 의 주입보다 [SpringBootTest] 의 주입이 우선되어, 해당 컴포넌트는 없으므로 오류처럼 보일 수 있음. + // [SpringBatchTest] 자체가 Scope 기반으로 주입하기 때문에 정상 동작함. + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(DemoJobConfig.JOB_NAME) + private Job job; + + @BeforeEach + void beforeEach() { + + } + + @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") + @Test + void shouldNotSaveCategories_whenApiError() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) + ); + } + + @DisplayName("demoJob 배치가 정상적으로 실행된다.") + @Test + void success() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.now()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()) + ); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java index a29c2ff7e..7fbcdf846 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java @@ -1,7 +1,9 @@ package com.loopers.application.metrics; -import com.loopers.domain.ProductLikeMetricsModel; -import com.loopers.infrastructure.ProductLikeMetricsRepository; +import com.loopers.domain.MetricsType; +import com.loopers.domain.ProductMetricsId; +import com.loopers.domain.ProductMetricsModel; +import com.loopers.infrastructure.ProductMetricsRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -11,15 +13,18 @@ @Service @RequiredArgsConstructor public class MetricsAggregationService { - private final ProductLikeMetricsRepository likeMetricsRepository; + private final ProductMetricsRepository likeMetricsRepository; @Transactional public void handleProductLiked(Long productId) { log.debug("Handling product liked event"); - ProductLikeMetricsModel metrics = likeMetricsRepository.findById(productId) - .orElseGet(() -> likeMetricsRepository.save(ProductLikeMetricsModel.of(productId))); + ProductMetricsId id = ProductMetricsId.of(productId, MetricsType.LIKE); + + ProductMetricsModel metrics = likeMetricsRepository.findById(id) + .orElseGet(() -> likeMetricsRepository.save(ProductMetricsModel.of(id))); metrics.increase(); } + } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/MetricsType.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/MetricsType.java new file mode 100644 index 000000000..cacb1324c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/MetricsType.java @@ -0,0 +1,8 @@ +package com.loopers.domain; + +public enum MetricsType { + VIEW, + LIKE, + ORDER_SUCCESS, + PAYMENT_SUCCESS, +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductLikeMetricsModel.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductLikeMetricsModel.java deleted file mode 100644 index 391b1520b..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductLikeMetricsModel.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.loopers.domain; - - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "product_like_metrics") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ProductLikeMetricsModel { - @Id - private Long productId; - - @Column(nullable = false) - private long likeCount; - - public static ProductLikeMetricsModel of(Long productId) { - ProductLikeMetricsModel m = new ProductLikeMetricsModel(); - m.productId = productId; - m.likeCount = 0; - return m; - } - - public void increase() { - this.likeCount += 1; - } -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsId.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsId.java new file mode 100644 index 000000000..8dae1a8c5 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsId.java @@ -0,0 +1,51 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +@Embeddable +@Getter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class ProductMetricsId implements Serializable { + + @Column(name = "PRODUCT_ID", nullable = false) + private Long productId; + + /** + * yyyyMMdd 로 관리합니다. + */ + @Column(name = "METRICS_DATE", length = 8, nullable = false) + private String metricsDate; + + @Column(name = "METRICS_TYPE", length = 50, nullable = false) + @Enumerated(EnumType.STRING) + private MetricsType metricsType; + + + public static ProductMetricsId of(Long productId, MetricsType metricsType) { + String nowDate = convertDate(Instant.now()); + return new ProductMetricsId(productId, nowDate, metricsType); + } + + + private static String convertDate(Instant occurredAt) { + return occurredAt + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(DateTimeFormatter.BASIC_ISO_DATE); + } + +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.java new file mode 100644 index 000000000..3fce86d55 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.java @@ -0,0 +1,53 @@ +package com.loopers.domain; + + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Entity +@Table(name = "product_metrics") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetricsModel { + + @EmbeddedId + private ProductMetricsId id; + + @Column(nullable = false) + private long count; + + @Column(name = "CREATED_AT", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "UPDATED_AT", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault()); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(ZoneId.systemDefault()); + } + + public static ProductMetricsModel of(ProductMetricsId id) { + ProductMetricsModel m = new ProductMetricsModel(); + m.id = id; + m.count = 0; + return m; + } + + public void increase() { + this.count += 1; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductLikeMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductLikeMetricsRepository.java deleted file mode 100644 index f966ebd01..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductLikeMetricsRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.infrastructure; - -import com.loopers.domain.ProductLikeMetricsModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ProductLikeMetricsRepository extends JpaRepository { -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java new file mode 100644 index 000000000..8fee9f54a --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.ProductMetricsId; +import com.loopers.domain.ProductMetricsModel; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductMetricsRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java index 84c3b1286..66429f895 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java @@ -1,10 +1,13 @@ package com.loopers.application.idempotency; -import com.loopers.domain.ProductLikeMetricsModel; +import com.loopers.domain.MetricsType; +import com.loopers.domain.ProductMetricsId; +import com.loopers.domain.ProductMetricsModel; import com.loopers.infrastructure.EventHandleRepository; -import com.loopers.infrastructure.ProductLikeMetricsRepository; +import com.loopers.infrastructure.ProductMetricsRepository; import com.loopers.testcontainers.KafkaTestContainersConfig; import com.loopers.utils.KafkaCleanUp; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -20,6 +23,7 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +@Slf4j @ActiveProfiles("local") @Import(KafkaTestContainersConfig.class) @SpringBootTest @@ -31,7 +35,7 @@ class EventHandledServiceTest { @Autowired KafkaTemplate kafkaTemplate; @Autowired - ProductLikeMetricsRepository metricsRepo; + ProductMetricsRepository metricsRepo; @Autowired EventHandleRepository handledRepo; @@ -50,11 +54,13 @@ void setUp() { void duplicate_message_should_be_applied_once() throws Exception { long productId = 1L; - ProductLikeMetricsModel metrics = metricsRepo.findById(productId) - .orElseGet(() -> metricsRepo.save(ProductLikeMetricsModel.of(productId))); + ProductMetricsId id = ProductMetricsId.of(productId, MetricsType.LIKE); + ProductMetricsModel metrics = metricsRepo.findById(id) + .orElseGet(() -> metricsRepo.save(ProductMetricsModel.of(id))); + + long before = metrics.getCount(); + log.debug("before count : {}", before); - long before = metrics.getLikeCount(); - // String payload = """ {"eventId": "06b9f00c-04bb-40fb-a96c-a9da5d0ede53", "traceId": "6944e75c781a87d97e02a61daca86d0a", "userPkId": 1, "eventType": "LIKE_CREATED", "productId": 1, "occurredAt": "2025-12-19T05:49:16.719347Z"} """; @@ -72,8 +78,10 @@ void duplicate_message_should_be_applied_once() throws Exception { // then // like 카운트는 1번만 증가 - long after = metricsRepo.findById(productId).orElseThrow().getLikeCount(); + long after = metricsRepo.findById(id).orElseThrow().getCount(); assertThat(after).isEqualTo(before + 1); + log.debug("after count : {}", after); + long handledCount = handledRepo.countByConsumerNameAndEventId("product-like-metrics", "06b9f00c-04bb-40fb-a96c-a9da5d0ede53"); assertThat(handledCount).isEqualTo(1); diff --git a/settings.gradle.kts b/settings.gradle.kts index c99fb6360..a2c303835 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", ":apps:commerce-streamer", + ":apps:commerce-batch", ":modules:jpa", ":modules:redis", ":modules:kafka",