diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 1b3f1d9cd..f98a54fe2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -6,6 +6,7 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.EnableAsync; import java.util.TimeZone; @@ -13,6 +14,7 @@ @ConfigurationPropertiesScan @SpringBootApplication @EnableScheduling +@EnableAsync public class CommerceApiApplication { @PostConstruct diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java index 92f6fe503..2b7eb43bc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java @@ -7,6 +7,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; @@ -20,6 +22,7 @@ public class LikeEventHandler { @Async @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handleLikeCreated(LikeCreatedEvent event) { try { productRepository.findByIdForUpdate(event.productId()) @@ -38,6 +41,7 @@ public void handleLikeCreated(LikeCreatedEvent event) { @Async @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handleLikeDeleted(LikeDeletedEvent event) { try { productRepository.findByIdForUpdate(event.productId()) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java index 517b6ee85..9c558eff3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; @@ -25,7 +26,7 @@ public class PaymentEventHandler { */ @TransactionalEventListener(phase = AFTER_COMMIT) @Async("eventTaskExecutor") - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handlePaymentSuccess(PaymentSuccessEvent event) { log.info("결제 성공 이벤트 처리 시작 - orderId: {}", event.orderId()); try { @@ -50,7 +51,7 @@ public void handlePaymentSuccess(PaymentSuccessEvent event) { */ @TransactionalEventListener(phase = AFTER_COMMIT) @Async("eventTaskExecutor") - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handlePaymentFailed(PaymentFailedEvent event) { log.info("결제 실패 이벤트 처리 시작 - orderId: {}, reason: {}", event.orderId(), event.failureReason()); try { 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 d8cf8fdde..f44fd6642 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 @@ -2,17 +2,25 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.infrastructure.rank.MonthlyRankJpaRepository; +import com.loopers.infrastructure.rank.WeeklyRankJpaRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.function.ToIntFunction; @Component @RequiredArgsConstructor @@ -20,6 +28,9 @@ public class RankingFacade { private final RankingService rankingService; private final ProductRepository productRepository; + private final WeeklyRankJpaRepository weeklyRankJpaRepository; + private final MonthlyRankJpaRepository monthlyRankJpaRepository; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); @Transactional(readOnly = true) public List getDailyRanking(String yyyymmdd, int page, int size) { @@ -48,27 +59,101 @@ public List getDailyRanking(String yyyymmdd, int page, int s productMap.put(pdt.getId(), pdt); } + int baseRank = (int) start + 1; + AtomicInteger rankCounter = new AtomicInteger(baseRank); for (ZSetOperations.TypedTuple t : tuples) { String member = t.getValue(); if (member == null || member.isBlank()) continue; Long productId = Long.valueOf(member); Product product = productMap.get(productId); if (product == null) continue; - result.add(toInfo(productId, product)); + int rank = rankCounter.getAndIncrement(); + Double score = t.getScore(); + result.add(toInfo(productId, product, rank, score)); } return result; } - private RankingProductInfo toInfo(Long productId, Product product) { + @Transactional(readOnly = true) + public List getWeeklyRanking(String weekStartYyyymmdd, int page, int size) { + int p = Math.max(1, page); + int s = Math.max(1, size); + LocalDate periodStart = LocalDate.parse(weekStartYyyymmdd, DATE_FORMATTER); + + var rows = weeklyRankJpaRepository.findByPeriodStartOrderByRankPositionAsc( + periodStart, PageRequest.of(p - 1, s) + ); + return buildRanking( + rows, + r -> r.getProductId(), + r -> r.getRankPosition() != null ? r.getRankPosition() : 0, + r -> r.getTotalScore() + ); + } + + @Transactional(readOnly = true) + public List getMonthlyRanking(String monthStartYyyymmdd, int page, int size) { + int p = Math.max(1, page); + int s = Math.max(1, size); + LocalDate periodStart = LocalDate.parse(monthStartYyyymmdd, DATE_FORMATTER); + + var rows = monthlyRankJpaRepository.findByPeriodStartOrderByRankPositionAsc( + periodStart, PageRequest.of(p - 1, s) + ); + return buildRanking( + rows, + r -> r.getProductId(), + r -> r.getRankPosition() != null ? r.getRankPosition() : 0, + r -> r.getTotalScore() + ); + } + + private RankingProductInfo toInfo(Long productId, Product product, int rank, Double score) { return new RankingProductInfo( + rank, + score, productId, product.getName(), product.getPrice() != null ? product.getPrice().getAmount() : BigDecimal.ZERO, + product.getStockQuantity(), product.getLikeCount() != null ? product.getLikeCount() : 0L ); } - + private List buildRanking( + List rows, + Function productIdExtractor, + ToIntFunction rankExtractor, + Function scoreExtractor + ) { + if (rows == null || rows.isEmpty()) { + return List.of(); + } + List productIds = rows.stream() + .map(productIdExtractor) + .toList(); + + List products = productRepository.findByIdIn(productIds); + Map productMap = new HashMap<>(); + for (Product prd : products) { + productMap.put(prd.getId(), prd); + } + + List result = new ArrayList<>(); + for (T row : rows) { + Long productId = productIdExtractor.apply(row); + Product product = productMap.get(productId); + if (product == null) { + continue; + } + int rank = rankExtractor.applyAsInt(row); + Double score = scoreExtractor.apply(row); + result.add(toInfo(productId, product, rank, score)); + } + return result; + } + + } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductInfo.java index 83ddc1977..54ac46a5c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductInfo.java @@ -3,9 +3,12 @@ import java.math.BigDecimal; public record RankingProductInfo( + int rank, + Double score, Long productId, String name, BigDecimal price, + Integer stock, Long likeCount ) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java new file mode 100644 index 000000000..b8862a5b8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java @@ -0,0 +1,29 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +@Configuration +public class AsyncConfig { + + @Bean(name = "eventTaskExecutor") + public Executor eventTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(16); + executor.setQueueCapacity(1000); + executor.setKeepAliveSeconds(60); + executor.setThreadNamePrefix("event-async-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(10); + executor.initialize(); + return executor; + } +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRankView.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRankView.java new file mode 100644 index 000000000..6040750ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRankView.java @@ -0,0 +1,59 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Immutable; + +@Entity +@Table(name = "mv_product_rank_monthly") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Immutable +public class MonthlyProductRankView { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "period_start", nullable = false) + private LocalDate periodStart; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "view_count", nullable = false) + private Integer viewCount; + + @Column(name = "order_count", nullable = false) + private Integer orderCount; + + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRankView.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRankView.java new file mode 100644 index 000000000..4e9d637ef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRankView.java @@ -0,0 +1,59 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Immutable; + +@Entity +@Table(name = "mv_product_rank_weekly") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Immutable +public class WeeklyProductRankView { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "period_start", nullable = false) + private LocalDate periodStart; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "view_count", nullable = false) + private Integer viewCount; + + @Column(name = "order_count", nullable = false) + private Integer orderCount; + + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java new file mode 100644 index 000000000..c9a558571 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.MonthlyProductRankView; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MonthlyRankJpaRepository extends JpaRepository { + List findByPeriodStartOrderByRankPositionAsc(LocalDate periodStart, Pageable pageable); +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java new file mode 100644 index 000000000..5f598be17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.WeeklyProductRankView; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface WeeklyRankJpaRepository extends JpaRepository { + List findByPeriodStartOrderByRankPositionAsc(LocalDate periodStart, Pageable pageable); +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java index 3ac3c6c0a..390cb635b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -14,6 +14,9 @@ public interface RankingV1ApiSpec { description = "랭킹 Page 정보를 조회합니다." ) ApiResponse getRankingPage( + + @Parameter(description = "Period Type (DAILY, WEEKLY, MONTHLY)", example = "DAILY") + @RequestParam(required = false) String periodType, @Parameter(description = "조회 날짜 (yyyyMMdd), 미입력 시 오늘", example = "20250123") @RequestParam(required = false) String date, @Parameter(description = "페이지 번호 (1부터 시작)", example = "1") 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 b77ba8172..91ca682aa 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 @@ -28,6 +28,9 @@ public class RankingV1Controller implements RankingV1ApiSpec { @Override @GetMapping public ApiResponse getRankingPage( + @Parameter(description = "Period Type (DAILY, WEEKLY, MONTHLY)", example = "DAILY") + @RequestParam(required = false) String periodType, + @Parameter(description = "조회 날짜 (yyyyMMdd), 미입력 시 오늘", example = "20250123") @RequestParam(required = false) String date, @@ -39,7 +42,21 @@ public ApiResponse getRankingPage( ) { String targetDate = validateAndGetDate(date); - List rankings = rankingFacade.getDailyRanking(targetDate, page, size); + String period = (periodType == null || periodType.isBlank()) + ? "DAILY" + : periodType.trim().toUpperCase(); + + List rankings; + switch (period) { + case "DAILY" -> rankings = rankingFacade.getDailyRanking(targetDate, page, size); + case "WEEKLY" -> { + rankings = rankingFacade.getWeeklyRanking(targetDate, page, size); + } + case "MONTHLY" -> { + rankings = rankingFacade.getMonthlyRanking(targetDate, page, size); + } + default -> rankings = rankingFacade.getDailyRanking(targetDate, page, size); + } List items = rankings.stream() .map(p -> new RankingV1Dto.RankingItem( diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 32b140329..4d0d1bb73 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -9,7 +9,6 @@ server: accept-count: 100 # 대기 큐 크기 (default : 100) keep-alive-timeout: 60s # 60s max-http-request-header-size: 8KB - resilience4j: retry: instances: diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeIntegrationTest.java index cbfc8c963..021a1442f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeIntegrationTest.java @@ -17,6 +17,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.function.BooleanSupplier; import static org.assertj.core.api.Assertions.assertThat; @@ -72,6 +73,7 @@ void concurrent_like_increments_like_count_exactly() throws InterruptedException pool.shutdown(); // then + awaitTrue(() -> productJpaRepository.findById(productId).orElseThrow().getLikeCount() == users); Product reloaded = productJpaRepository.findById(productId).orElseThrow(); assertThat(reloaded.getLikeCount()).isEqualTo(users); } @@ -105,6 +107,7 @@ void concurrent_unlike_decrements_like_count_exactly() throws InterruptedExcepti pool.shutdown(); // then + awaitTrue(() -> productJpaRepository.findById(productId).orElseThrow().getLikeCount() == 0L); Product reloaded = productJpaRepository.findById(productId).orElseThrow(); assertThat(reloaded.getLikeCount()).isEqualTo(0L); } @@ -116,6 +119,21 @@ private static void await(CountDownLatch latch) { Thread.currentThread().interrupt(); } } + + private static void awaitTrue(BooleanSupplier condition) { + long deadline = System.currentTimeMillis() + 3000; // wait up to 3s + while (System.currentTimeMillis() < deadline) { + if (condition.getAsBoolean()) { + return; + } + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java index ac6146691..6fbece5f2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java @@ -4,6 +4,9 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.Stock; +import com.loopers.domain.like.event.LikeCreatedEvent; +import com.loopers.domain.like.event.LikeDeletedEvent; +import org.springframework.context.ApplicationEventPublisher; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -27,6 +30,8 @@ class LikeModelTest { LikeRepository likeRepository; @Mock ProductRepository productRepository; + @Mock + ApplicationEventPublisher eventPublisher; @InjectMocks LikeService likeService; @@ -48,7 +53,6 @@ void like_registers_when_absent() { .likeCount(0L) .build(); when(productRepository.findByIdForUpdate(PRODUCT_ID)).thenReturn(Optional.of(product)); - when(productRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); // when likeService.likeProduct(USER_ID, PRODUCT_ID); @@ -58,8 +62,7 @@ void like_registers_when_absent() { like.getUserId().equals(USER_ID) && like.getProductId().equals(PRODUCT_ID) )); - verify(productRepository, times(1)).save(eq(product)); - assertThat(product.getLikeCount()).isEqualTo(1L); + verify(eventPublisher, times(1)).publishEvent(any(LikeCreatedEvent.class)); } @@ -83,8 +86,7 @@ void like_ignores_when_present() { // then verify(likeRepository, never()).save(any()); - verify(productRepository, never()).save(any()); - assertThat(product.getLikeCount()).isEqualTo(0L); + verify(eventPublisher, never()).publishEvent(any()); } @Test @@ -102,15 +104,13 @@ void cancel_deletes_when_present() { .likeCount(1L) .build(); when(productRepository.findByIdForUpdate(PRODUCT_ID)).thenReturn(Optional.of(product)); - when(productRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); // when likeService.cancleLikeProduct(USER_ID, PRODUCT_ID); // then verify(likeRepository, times(1)).delete(eq(like)); - verify(productRepository, times(1)).save(eq(product)); - assertThat(product.getLikeCount()).isEqualTo(0L); + verify(eventPublisher, times(1)).publishEvent(any(LikeDeletedEvent.class)); } @Test @@ -125,7 +125,6 @@ void like_and_unlike_update_product_like_count() { .likeCount(0L) .build(); when(productRepository.findByIdForUpdate(PRODUCT_ID)).thenReturn(Optional.of(product)); - when(productRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(likeRepository.findByUserIdAndProductId(USER_ID, PRODUCT_ID)) .thenReturn(Optional.empty()) // for like .thenReturn(Optional.of(new Like(USER_ID, PRODUCT_ID))); // for cancel @@ -135,7 +134,8 @@ void like_and_unlike_update_product_like_count() { likeService.cancleLikeProduct(USER_ID, PRODUCT_ID); // then - assertThat(product.getLikeCount()).isEqualTo(0L); + verify(eventPublisher, times(1)).publishEvent(any(LikeCreatedEvent.class)); + verify(eventPublisher, times(1)).publishEvent(any(LikeDeletedEvent.class)); } @Test 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..752de60fb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,26 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.util.TimeZone; + +@ConfigurationPropertiesScan +@SpringBootApplication +@EnableScheduling +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); + } +} 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/productRankingJob/ProductRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/ProductRankingJobConfig.java new file mode 100644 index 000000000..23469bb6f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/ProductRankingJobConfig.java @@ -0,0 +1,34 @@ +package com.loopers.batch.job.productRankingJob; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.Step; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class ProductRankingJobConfig { + + private final JobRepository jobRepository; + private final Step weeklyRankingStep; + private final Step monthlyRankingStep; + + @Bean + public Job weeklyRankingJob() { + return new JobBuilder("weeklyRankingJob", jobRepository) + .start(weeklyRankingStep) + .build(); + } + + @Bean + public Job monthlyRankingJob() { + return new JobBuilder("monthlyRankingJob", jobRepository) + .start(monthlyRankingStep) + .build(); + } +} + + diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/ProductRankingJobScheduler.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/ProductRankingJobScheduler.java new file mode 100644 index 000000000..5efb2f49a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/ProductRankingJobScheduler.java @@ -0,0 +1,48 @@ +package com.loopers.batch.job.productRankingJob; + +import java.time.LocalDate; +import java.util.Map; +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.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductRankingJobScheduler { + + private final JobLauncher jobLauncher; + private final Job weeklyRankingJob; + private final Job monthlyRankingJob; + + // 매일 01:10에 전일(anchorDate=어제) 기준으로 주/월 MV를 갱신 + @Scheduled(cron = "0 10 1 * * *") + public void runWeeklyRanking() { + runWithAnchor(weeklyRankingJob, LocalDate.now().minusDays(1)); + } + + @Scheduled(cron = "0 20 1 * * *") + public void runMonthlyRanking() { + runWithAnchor(monthlyRankingJob, LocalDate.now().minusDays(1)); + } + + private void runWithAnchor(Job job, LocalDate anchor) { + try { + JobParameters params = new JobParametersBuilder() + .addString("anchorDate", anchor.toString()) // yyyy-MM-dd + .addLong("ts", System.currentTimeMillis()) // 재실행 구분자 + .toJobParameters(); + log.info("Launching job={} anchorDate={}", job.getName(), anchor); + jobLauncher.run(job, params); + } catch (Exception e) { + log.error("Failed to launch job={}", job.getName(), e); + } + } +} + + diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/MonthlyRankingStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/MonthlyRankingStepConfig.java new file mode 100644 index 000000000..0f4406cba --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/MonthlyRankingStepConfig.java @@ -0,0 +1,80 @@ +package com.loopers.batch.job.productRankingJob.step; + +import com.loopers.batch.job.productRankingJob.step.processor.RankingScoreProcessor; +import com.loopers.batch.job.productRankingJob.step.reader.RankingScoreReader; +import com.loopers.batch.job.productRankingJob.step.writer.MonthlyRankingWriter; +import com.loopers.domain.rank.ProductRankingAggregation; +import com.loopers.domain.rank.MonthlyProductRank; +import com.loopers.domain.rank.MonthlyRankRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemWriter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; + +@Configuration +@RequiredArgsConstructor +public class MonthlyRankingStepConfig { + + private static final int CHUNK_SIZE = 100; + private static final int TOP_N = 100; + + private final EntityManager entityManager; + private final MonthlyRankRepository monthlyRankRepository; + + @Bean + @JobScope + public Step monthlyRankingStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager + ) { + return new StepBuilder("monthlyRankingStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyRankingReader(null)) + .processor(monthlyRankingProcessor(null)) + .writer(monthlyRankingWriter(null)) + .build(); + } + + @Bean + @StepScope + public ItemReader monthlyRankingReader( + @org.springframework.beans.factory.annotation.Value("#{jobParameters['anchorDate']}") + String anchorDate + ) { + return new RankingScoreReader( + entityManager, + anchorDate, + "MONTHLY" + ); + } + + @Bean + @StepScope + public ItemProcessor monthlyRankingProcessor( + @org.springframework.beans.factory.annotation.Value("#{jobParameters['anchorDate']}") + String anchorDate + ) { + RankingScoreProcessor processor = + new RankingScoreProcessor("MONTHLY", anchorDate); + + return item -> (MonthlyProductRank) processor.process(item); + } + + @Bean + @StepScope + public ItemWriter monthlyRankingWriter( + @org.springframework.beans.factory.annotation.Value("#{jobParameters['anchorDate']}") + String anchorDate + ) { + return new MonthlyRankingWriter(monthlyRankRepository, java.time.LocalDate.parse(anchorDate)); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/WeeklyRankingStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/WeeklyRankingStepConfig.java new file mode 100644 index 000000000..e0fc2f04c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/WeeklyRankingStepConfig.java @@ -0,0 +1,81 @@ +package com.loopers.batch.job.productRankingJob.step; + +import com.loopers.batch.job.productRankingJob.step.processor.RankingScoreProcessor; +import com.loopers.batch.job.productRankingJob.step.reader.RankingScoreReader; +import com.loopers.batch.job.productRankingJob.step.writer.WeeklyRankingWriter; +import com.loopers.domain.rank.MonthlyProductRank; +import com.loopers.domain.rank.WeeklyProductRank; +import com.loopers.domain.rank.WeeklyRankRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemWriter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.beans.factory.annotation.Value; +import com.loopers.domain.rank.ProductRankingAggregation; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class WeeklyRankingStepConfig { + + private static final int CHUNK_SIZE = 100; + + private final EntityManager entityManager; + private final WeeklyRankRepository weeklyRankRepository; + + @Bean + @JobScope + public Step weeklyRankingStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + @Value("#{jobParameters['anchorDate']}") String anchorDate + ) { + log.info("Initializing weeklyRankingStep: anchorDate={}", anchorDate); + + return new StepBuilder("weeklyRankingStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyRankingReader(null)) + .processor(weeklyRankingProcessor(null)) + .writer(weeklyRankingWriter(null)) + .build(); + } + + @Bean + @StepScope + public ItemReader weeklyRankingReader( + @Value("#{jobParameters['anchorDate']}") String anchorDate + ) { + return new RankingScoreReader( + entityManager, + anchorDate, + "WEEKLY" + ); + } + + @Bean + @StepScope + public ItemProcessor weeklyRankingProcessor( + @Value("#{jobParameters['anchorDate']}") String anchorDate + ) { + RankingScoreProcessor processor = new RankingScoreProcessor("WEEKLY", anchorDate); + return item -> (WeeklyProductRank) processor.process(item); + } + + @Bean + @StepScope + public ItemWriter weeklyRankingWriter( + @Value("#{jobParameters['anchorDate']}") String anchorDate + ) { + return new WeeklyRankingWriter(weeklyRankRepository, java.time.LocalDate.parse(anchorDate)); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/processor/RankingScoreProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/processor/RankingScoreProcessor.java new file mode 100644 index 000000000..78b6514dd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/processor/RankingScoreProcessor.java @@ -0,0 +1,70 @@ +package com.loopers.batch.job.productRankingJob.step.processor; + + +import com.loopers.domain.rank.MonthlyProductRank; +import com.loopers.domain.rank.ProductRankingAggregation; +import com.loopers.domain.rank.WeeklyProductRank; +import org.springframework.batch.item.ItemProcessor; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public class RankingScoreProcessor + implements ItemProcessor { + + private final String periodType; + private final String anchorDate; + + public RankingScoreProcessor(String periodType, String anchorDate) { + this.periodType = periodType; + this.anchorDate = anchorDate; + } + + @Override + public Object process(ProductRankingAggregation item) { + double score = calculateScore(item); + LocalDate periodStart = LocalDate.parse(anchorDate); + + if ("WEEKLY".equals(periodType)) { + return WeeklyProductRank.builder() + .productId(item.getProductId()) + .periodStart(periodStart) + .rankPosition(safeInt(item.getRankPosition())) + .totalScore(score) + .likeCount(safeInt(item.getLikeCount())) + .viewCount(safeInt(item.getViewCount())) + .orderCount(safeInt(item.getOrderCount())) + .salesAmount(safeAmount(item.getSalesAmount())) + .build(); + } + + if ("MONTHLY".equals(periodType)) { + return MonthlyProductRank.builder() + .productId(item.getProductId()) + .periodStart(periodStart) + .rankPosition(safeInt(item.getRankPosition())) + .totalScore(score) + .likeCount(safeInt(item.getLikeCount())) + .viewCount(safeInt(item.getViewCount())) + .orderCount(safeInt(item.getOrderCount())) + .salesAmount(safeAmount(item.getSalesAmount())) + .build(); + } + + throw new IllegalArgumentException("Unsupported periodType: " + periodType); + } + + private double calculateScore(ProductRankingAggregation item) { + // 일간과 동일한 가중치 적용: VIEW 0.1, LIKE 0.2, ORDER 0.6 * (amount 또는 quantity) + int view = safeInt(item.getViewCount()); + int like = safeInt(item.getLikeCount()); + int orderCnt = safeInt(item.getOrderCount()); + BigDecimal amount = safeAmount(item.getSalesAmount()); + + double orderBase = amount.signum() > 0 ? amount.doubleValue() : (double) orderCnt; + return (0.1d * view) + (0.2d * like) + (0.6d * orderBase); + } + + private int safeInt(Integer v) { return v == null ? 0 : v; } + private BigDecimal safeAmount(BigDecimal v) { return v == null ? BigDecimal.ZERO : v; } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/reader/RankingScoreReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/reader/RankingScoreReader.java new file mode 100644 index 000000000..5f8fe963e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/reader/RankingScoreReader.java @@ -0,0 +1,138 @@ +package com.loopers.batch.job.productRankingJob.step.reader; + + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import org.springframework.batch.item.ItemReader; +import com.loopers.domain.rank.ProductRankingAggregation; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Iterator; +import java.util.List; +import java.util.ArrayList; +import java.util.Comparator; + +public class RankingScoreReader implements ItemReader { + + private final EntityManager entityManager; + private final String anchorDate; + private final String periodType; + + private Iterator iterator; + + public RankingScoreReader( + EntityManager entityManager, + String anchorDate, + String periodType + ) { + this.entityManager = entityManager; + this.anchorDate = anchorDate; + this.periodType = periodType; + } + + @Override + public ProductRankingAggregation read() { + if (iterator == null) { + iterator = fetch().iterator(); + } + + return iterator.hasNext() ? iterator.next() : null; + } + + private List fetch() { + LocalDate endDate = LocalDate.parse(anchorDate); + int window = "WEEKLY".equalsIgnoreCase(periodType) ? 6 : 29; + LocalDate startDate = endDate.minusDays(window); + + String sql = + "SELECT prd.product_id, " + + " SUM(COALESCE(prd.like_count, 0)) AS like_count, " + + " SUM(COALESCE(prd.view_count, 0)) AS view_count, " + + " SUM(COALESCE(prd.order_count, 0)) AS order_count, " + + " SUM(COALESCE(prd.sales_amount, 0)) AS sales_amount " + + " FROM product_ranking_daily prd " + + " WHERE prd.stat_date BETWEEN :start AND :end " + + " GROUP BY prd.product_id"; + + Query q = entityManager.createNativeQuery(sql); + q.setParameter("start", startDate); + q.setParameter("end", endDate); + + @SuppressWarnings("unchecked") + List rows = q.getResultList(); + + // 집계값 기반으로 점수 계산 후 정렬 및 순위 부여 (일간과 동일 가중치) + List temp = new ArrayList<>(); + for (Object[] r : rows) { + Long productId = toLong(r[0]); + Integer likeCount = toInt(r[1]); + Integer viewCount = toInt(r[2]); + Integer orderCount = toInt(r[3]); + BigDecimal salesAmount = toDecimal(r[4]); + double score = calcScore(viewCount, likeCount, orderCount, salesAmount); + temp.add(new Row(productId, likeCount, viewCount, orderCount, salesAmount, score)); + } + + temp.sort(Comparator.comparingDouble((Row x) -> x.score).reversed()); + + List result = new ArrayList<>(temp.size()); + int rank = 1; + for (Row r : temp) { + result.add(new ProductRankingAggregation( + r.productId, + r.likeCount, + r.viewCount, + r.orderCount, + r.salesAmount, + rank++ + )); + } + return result; + } + + private static Long toLong(Object o) { + if (o == null) return null; + if (o instanceof Number n) return n.longValue(); + return Long.valueOf(o.toString()); + } + + private static Integer toInt(Object o) { + if (o == null) return 0; + if (o instanceof Number n) return n.intValue(); + return Integer.valueOf(o.toString()); + } + + private static BigDecimal toDecimal(Object o) { + if (o == null) return BigDecimal.ZERO; + if (o instanceof BigDecimal bd) return bd; + if (o instanceof Number n) return BigDecimal.valueOf(n.doubleValue()); + return new BigDecimal(o.toString()); + } + + private static double calcScore(Integer viewCount, Integer likeCount, Integer orderCount, BigDecimal salesAmount) { + int view = viewCount == null ? 0 : viewCount; + int like = likeCount == null ? 0 : likeCount; + int orders = orderCount == null ? 0 : orderCount; + BigDecimal amount = salesAmount == null ? BigDecimal.ZERO : salesAmount; + double orderBase = amount.signum() > 0 ? amount.doubleValue() : (double) orders; + return (0.1d * view) + (0.2d * like) + (0.6d * orderBase); + } + + private static class Row { + final Long productId; + final Integer likeCount; + final Integer viewCount; + final Integer orderCount; + final BigDecimal salesAmount; + final double score; + + Row(Long productId, Integer likeCount, Integer viewCount, Integer orderCount, BigDecimal salesAmount, double score) { + this.productId = productId; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.orderCount = orderCount; + this.salesAmount = salesAmount; + this.score = score; + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/MonthlyRankingWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/MonthlyRankingWriter.java new file mode 100644 index 000000000..ed854fa9d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/MonthlyRankingWriter.java @@ -0,0 +1,40 @@ +package com.loopers.batch.job.productRankingJob.step.writer; +import com.loopers.domain.rank.MonthlyProductRank; +import com.loopers.domain.rank.MonthlyRankRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.ItemStream; +import org.springframework.batch.item.ExecutionContext; + +import java.time.LocalDate; + +@RequiredArgsConstructor +public class MonthlyRankingWriter implements ItemWriter, ItemStream { + + private final MonthlyRankRepository repository; + private final LocalDate periodStart; + private boolean initialized = false; + + @Override + public void open(ExecutionContext executionContext) { + if (!initialized) { + repository.deleteByPeriodStart(periodStart); + initialized = true; + } + } + + @Override + public void update(ExecutionContext executionContext) { } + + @Override + public void close() { } + + @Override + public void write(Chunk chunk) { + if (chunk.isEmpty()) { + return; + } + repository.saveAll(chunk.getItems()); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/WeeklyRankingWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/WeeklyRankingWriter.java new file mode 100644 index 000000000..9e3188131 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/WeeklyRankingWriter.java @@ -0,0 +1,42 @@ +package com.loopers.batch.job.productRankingJob.step.writer; + +import com.loopers.domain.rank.WeeklyProductRank; +import com.loopers.domain.rank.WeeklyRankRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.ItemStream; +import org.springframework.batch.item.ExecutionContext; + +import java.util.List; +import java.time.LocalDate; + +@RequiredArgsConstructor +public class WeeklyRankingWriter implements ItemWriter, ItemStream { + + private final WeeklyRankRepository repository; + private final LocalDate periodStart; + private boolean initialized = false; + + @Override + public void open(ExecutionContext executionContext) { + if (!initialized) { + repository.deleteByPeriodStart(periodStart); + initialized = true; + } + } + + @Override + public void update(ExecutionContext executionContext) { } + + @Override + public void close() { } + + @Override + public void write(Chunk chunk){ + if (chunk.isEmpty()) { + return; + } + repository.saveAll(chunk.getItems()); + } +} 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/java/com/loopers/domain/rank/MonthlyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java new file mode 100644 index 000000000..dad8f9496 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java @@ -0,0 +1,106 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 롤링 30일 월간 랭킹 MV 엔티티. + * period_start = 기준일(anchorDate), 윈도우는 [anchorDate-29, anchorDate]. + */ +@Entity +@Table( + name = "mv_product_rank_monthly", + uniqueConstraints = @UniqueConstraint( + name = "uk_monthly_product_period", + columnNames = {"product_id", "period_start"} + ), + indexes = { + @Index(name = "idx_monthly_period_rank", columnList = "period_start, rank_position"), + @Index(name = "idx_monthly_period_score", columnList = "period_start, total_score DESC") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MonthlyProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "period_start", nullable = false) + private LocalDate periodStart; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "view_count", nullable = false) + private Integer viewCount; + + @Column(name = "order_count", nullable = false) + private Integer orderCount; + + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Builder + public MonthlyProductRank( + Long productId, + LocalDate periodStart, + Integer rankPosition, + Double totalScore, + Integer likeCount, + Integer viewCount, + Integer orderCount, + BigDecimal salesAmount + ) { + this.productId = productId; + this.periodStart = periodStart; + this.rankPosition = rankPosition; + this.totalScore = totalScore; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.orderCount = orderCount; + this.salesAmount = salesAmount; + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java new file mode 100644 index 000000000..2fe8f046d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.rank; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface MonthlyRankRepository extends JpaRepository { + + List findByPeriodStartOrderByRankPositionAsc( + LocalDate periodStart, Pageable pageable + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM MonthlyProductRank m WHERE m.periodStart = :periodStart") + int deleteByPeriodStart(@Param("periodStart") LocalDate periodStart); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankingAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankingAggregation.java new file mode 100644 index 000000000..e97648909 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankingAggregation.java @@ -0,0 +1,25 @@ +package com.loopers.domain.rank; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.math.BigDecimal; + +@Getter +@AllArgsConstructor +public class ProductRankingAggregation { + private Long productId; + + + private Integer likeCount; + + + private Integer viewCount; + + + private Integer orderCount; + + private BigDecimal salesAmount; + + private Integer rankPosition; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java new file mode 100644 index 000000000..9e917c5e1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java @@ -0,0 +1,106 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 롤링 7일 주간 랭킹 MV 엔티티. + * period_start = 기준일(anchorDate), 윈도우는 [anchorDate-6, anchorDate]. + */ +@Entity +@Table( + name = "mv_product_rank_weekly", + uniqueConstraints = @UniqueConstraint( + name = "uk_weekly_product_period", + columnNames = {"product_id", "period_start"} + ), + indexes = { + @Index(name = "idx_weekly_period_rank", columnList = "period_start, rank_position"), + @Index(name = "idx_weekly_period_score", columnList = "period_start, total_score DESC") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WeeklyProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "period_start", nullable = false) + private LocalDate periodStart; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "view_count", nullable = false) + private Integer viewCount; + + @Column(name = "order_count", nullable = false) + private Integer orderCount; + + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Builder + public WeeklyProductRank( + Long productId, + LocalDate periodStart, + Integer rankPosition, + Double totalScore, + Integer likeCount, + Integer viewCount, + Integer orderCount, + BigDecimal salesAmount + ) { + this.productId = productId; + this.periodStart = periodStart; + this.rankPosition = rankPosition; + this.totalScore = totalScore; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.orderCount = orderCount; + this.salesAmount = salesAmount; + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java new file mode 100644 index 000000000..1d54cb1db --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.rank; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface WeeklyRankRepository extends JpaRepository { + + List findByPeriodStartOrderByRankPositionAsc( + LocalDate periodStart, Pageable pageable + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM WeeklyProductRank w WHERE w.periodStart = :periodStart") + int deleteByPeriodStart(@Param("periodStart") LocalDate periodStart); +} 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/migration/V20260102__ranking_mv_ddl.sql b/apps/commerce-batch/src/main/resources/db/migration/V20260102__ranking_mv_ddl.sql new file mode 100644 index 000000000..1a270733e --- /dev/null +++ b/apps/commerce-batch/src/main/resources/db/migration/V20260102__ranking_mv_ddl.sql @@ -0,0 +1,51 @@ +-- product_ranking_daily: 롤링 집계를 위한 일간 스냅샷 소스 +CREATE TABLE IF NOT EXISTS product_ranking_daily ( + stat_date DATE NOT NULL, + product_id BIGINT NOT NULL, + like_count INT NOT NULL DEFAULT 0, + view_count INT NOT NULL DEFAULT 0, + order_count INT NOT NULL DEFAULT 0, + sales_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00, + PRIMARY KEY (stat_date, product_id), + INDEX idx_prd_daily_product (product_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- mv_product_rank_weekly: period_start(anchorDate) 기준 주간 MV +CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( + id BIGINT NOT NULL AUTO_INCREMENT, + product_id BIGINT NOT NULL, + period_start DATE NOT NULL, + rank_position INT NOT NULL, + total_score DOUBLE NOT NULL, + like_count INT NOT NULL DEFAULT 0, + view_count INT NOT NULL DEFAULT 0, + order_count INT NOT NULL DEFAULT 0, + sales_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_weekly_product_period (product_id, period_start), + INDEX idx_weekly_period_rank (period_start, rank_position), + INDEX idx_weekly_period_score (period_start, total_score) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- mv_product_rank_monthly: period_start(anchorDate) 기준 월간 MV +CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( + id BIGINT NOT NULL AUTO_INCREMENT, + product_id BIGINT NOT NULL, + period_start DATE NOT NULL, + rank_position INT NOT NULL, + total_score DOUBLE NOT NULL, + like_count INT NOT NULL DEFAULT 0, + view_count INT NOT NULL DEFAULT 0, + order_count INT NOT NULL DEFAULT 0, + sales_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_monthly_product_period (product_id, period_start), + INDEX idx_monthly_period_rank (period_start, rank_position), + INDEX idx_monthly_period_score (period_start, total_score) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + 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/interfaces/consumer/CatalogEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java index f2fdb135a..e1026ba97 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java @@ -4,6 +4,7 @@ import com.loopers.application.metrics.MetricsAggregator; import com.loopers.application.ranking.RankingAggregator; import com.loopers.application.EventHandledService; +import com.loopers.config.kafka.KafkaConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -26,7 +27,8 @@ public class CatalogEventConsumer { @KafkaListener( topics = "${kafka.topics.catalog-events}", - groupId = "commerce-streamer-catalog" + groupId = "commerce-streamer-catalog", + containerFactory = KafkaConfig.BATCH_LISTENER ) public void consume(List> records) throws Exception { if (records == null || records.isEmpty()) { @@ -34,7 +36,7 @@ public void consume(List> records) throws Excepti } List> accepted = new ArrayList<>(); for (ConsumerRecord record : records) { - Map event = objectMapper.readValue(record.value(), Map.class); + Map event = readEvent(record.value()); String eventId = (String) event.get("id"); String eventType = (String) event.get("eventType"); @@ -54,6 +56,16 @@ public void consume(List> records) throws Excepti metricsAggregator.aggregate(accepted); rankingAggregator.aggregate(accepted); } + + @SuppressWarnings("unchecked") + private Map readEvent(String raw) throws Exception { + if (raw == null) return null; + String s = raw.trim(); + if (s.startsWith("\"") && s.endsWith("\"")) { + s = objectMapper.readValue(s, String.class); + } + return objectMapper.readValue(s, Map.class); + } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java index ba862cec6..df5122d5a 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.consumer; -import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.config.kafka.KafkaConfig; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.support.Acknowledgment; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java index 9ddba8e4e..66e5e2bff 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java @@ -4,6 +4,7 @@ import com.loopers.application.EventHandledService; import com.loopers.application.metrics.MetricsAggregator; import com.loopers.application.ranking.RankingAggregator; +import com.loopers.config.kafka.KafkaConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -26,7 +27,8 @@ public class OrderEventConsumer { @KafkaListener( topics = "${kafka.topics.order-events}", - groupId = "commerce-streamer-order" + groupId = "commerce-streamer-order", + containerFactory = KafkaConfig.BATCH_LISTENER ) public void consume(List> records) throws Exception { if (records == null || records.isEmpty()) { @@ -35,7 +37,7 @@ public void consume(List> records) throws Excepti List> accepted = new ArrayList<>(); for (ConsumerRecord record : records) { - Map event = objectMapper.readValue(record.value(), Map.class); + Map event = readEvent(record.value()); String eventId = (String) event.get("id"); String eventType = (String) event.get("eventType"); @@ -66,6 +68,16 @@ public void consume(List> records) throws Excepti } } + + @SuppressWarnings("unchecked") + private Map readEvent(String raw) throws Exception { + if (raw == null) return null; + String s = raw.trim(); + if (s.startsWith("\"") && s.endsWith("\"")) { + s = objectMapper.readValue(s, String.class); + } + return objectMapper.readValue(s, Map.class); + } } diff --git a/apps/commerce-streamer/src/main/resources/application.yml b/apps/commerce-streamer/src/main/resources/application.yml index bda907dcf..889046380 100644 --- a/apps/commerce-streamer/src/main/resources/application.yml +++ b/apps/commerce-streamer/src/main/resources/application.yml @@ -9,7 +9,6 @@ server: accept-count: 100 # 대기 큐 크기 (default : 100) keep-alive-timeout: 60s # 60s max-http-request-header-size: 8KB - spring: main: web-application-type: servlet @@ -42,7 +41,11 @@ spring: config: activate: on-profile: local, test - +server: + port: 8082 +management: + server: + port: 0 --- spring: config: diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java b/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java index 7fad5872b..c4f5265c6 100644 --- a/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java +++ b/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java @@ -8,6 +8,6 @@ @Configuration @EnableTransactionManagement @EntityScan({"com.loopers"}) -@EnableJpaRepositories({"com.loopers.infrastructure"}) +@EnableJpaRepositories({"com.loopers"}) public class JpaConfig { } diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml index a7a366b19..f97c8e395 100644 --- a/modules/jpa/src/main/resources/jpa.yml +++ b/modules/jpa/src/main/resources/jpa.yml @@ -37,7 +37,7 @@ spring: jpa: show-sql: true hibernate: - ddl-auto: create + ddl-auto: update datasource: mysql-jpa: diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java similarity index 99% rename from modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java rename to modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java index 8af7aa4bd..c357e8e7a 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java @@ -1,4 +1,4 @@ -package com.loopers.confg.kafka; +package com.loopers.config.kafka; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.kafka.clients.consumer.ConsumerConfig; diff --git a/settings.gradle.kts b/settings.gradle.kts index cad1f1cb5..9cd724a7b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,6 +4,7 @@ include( ":apps:commerce-api", ":apps:commerce-streamer", ":modules:pg-simulator", + ":apps:commerce-batch", ":modules:jpa", ":modules:redis", ":modules:kafka", diff --git a/supports/monitoring/src/main/resources/monitoring.yml b/supports/monitoring/src/main/resources/monitoring.yml index c6a87a9cf..caf18b3e7 100644 --- a/supports/monitoring/src/main/resources/monitoring.yml +++ b/supports/monitoring/src/main/resources/monitoring.yml @@ -31,7 +31,7 @@ management: readinessState: enabled: true server: - port: 8081 + port: ${MANAGEMENT_PORT:0} observations: annotations: enabled: true