From ad3d42142932f96e590a884fce479807ea9a4a7a Mon Sep 17 00:00:00 2001 From: Dori Date: Fri, 2 Jan 2026 12:31:37 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/CommerceApiApplication.java | 2 ++ .../application/like/LikeEventHandler.java | 4 ++++ .../payment/PaymentEventHandler.java | 5 +++-- .../src/main/resources/application.yml | 1 - .../domain/like/LikeIntegrationTest.java | 18 +++++++++++++++++ .../loopers/domain/like/LikeModelTest.java | 20 +++++++++---------- .../consumer/CatalogEventConsumer.java | 16 +++++++++++++-- .../consumer/OrderEventConsumer.java | 16 +++++++++++++-- .../src/main/resources/application.yml | 7 +++++-- .../com/loopers/config/jpa/JpaConfig.java | 2 +- modules/jpa/src/main/resources/jpa.yml | 2 +- .../src/main/resources/monitoring.yml | 2 +- 12 files changed, 73 insertions(+), 22 deletions(-) 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/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-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..a406bffaa 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.confg.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/OrderEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java index 9ddba8e4e..e0e693012 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.confg.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/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 From 15d43ecb37c604eea86f91909ec5cec1b4e6b5db Mon Sep 17 00:00:00 2001 From: Dori Date: Fri, 2 Jan 2026 14:19:17 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20Ranking=20API=20=ED=99=95=EC=9E=A5(?= =?UTF-8?q?WEEKLY,=20MONTHLY=20=EB=AF=B8=EA=B5=AC=ED=98=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/ranking/RankingV1ApiSpec.java | 3 +++ .../api/ranking/RankingV1Controller.java | 22 ++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) 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..1a186c049 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 @@ -14,6 +14,7 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Collections; @Tag(name = "Ranking", description = "상품 랭킹 API") @RestController @@ -28,6 +29,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 +43,23 @@ 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" -> { + // TODO: 주간 랭킹 구현 시 주간 MV 조회로 대체 + rankings = Collections.emptyList(); + } + case "MONTHLY" -> { + // TODO: 월간 랭킹 구현 시 월간 MV 조회로 대체 + rankings = Collections.emptyList(); + } + default -> rankings = rankingFacade.getDailyRanking(targetDate, page, size); + } List items = rankings.stream() .map(p -> new RankingV1Dto.RankingItem( From 2a66999e9c20072127297eb623d4b22222b4c11b Mon Sep 17 00:00:00 2001 From: hubtwork Date: Wed, 31 Dec 2025 16:09:54 +0900 Subject: [PATCH 3/7] =?UTF-8?q?commerce-batch=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98=EB=A9=B0,=20=EB=8D=B0?= =?UTF-8?q?=EB=AA=A8=20Batch=20Job=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-batch/build.gradle.kts | 21 +++++ .../com/loopers/CommerceBatchApplication.java | 24 ++++++ .../loopers/batch/job/demo/DemoJobConfig.java | 48 ++++++++++++ .../batch/job/demo/step/DemoTasklet.java | 32 ++++++++ .../loopers/batch/listener/ChunkListener.java | 21 +++++ .../loopers/batch/listener/JobListener.java | 53 +++++++++++++ .../batch/listener/StepMonitorListener.java | 44 +++++++++++ .../src/main/resources/application.yml | 54 +++++++++++++ .../loopers/CommerceBatchApplicationTest.java | 10 +++ .../com/loopers/job/demo/DemoJobE2ETest.java | 76 +++++++++++++++++++ settings.gradle.kts | 1 + 11 files changed, 384 insertions(+) create mode 100644 apps/commerce-batch/build.gradle.kts create mode 100644 apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java create mode 100644 apps/commerce-batch/src/main/resources/application.yml create mode 100644 apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java 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..e5005c373 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,24 @@ +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 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); + } +} 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/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/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/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", From 2aa4b689f2fbe380fea3425de6860c51c2f87fb2 Mon Sep 17 00:00:00 2001 From: Dori Date: Fri, 2 Jan 2026 17:46:24 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20spring=20Batch=EB=A5=BC=20=EC=9D=B4?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20=EC=A3=BC=EA=B0=84/=EC=9B=94=EA=B0=84=20?= =?UTF-8?q?=EB=9E=AD=ED=82=B9=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ranking/RankingFacade.java | 91 +++++++++++- .../ranking/RankingProductInfo.java | 3 + .../domain/rank/MonthlyProductRank.java | 59 ++++++++ .../domain/rank/WeeklyProductRank.java | 59 ++++++++ .../rank/MonthlyRankJpaRepository.java | 15 ++ .../rank/WeeklyRankJpaRepository.java | 15 ++ .../api/ranking/RankingV1Controller.java | 7 +- .../com/loopers/CommerceBatchApplication.java | 2 + .../MonthlyRankingJobConfig.java | 23 +++ .../ProductRankingJobConfig.java | 34 +++++ .../ProductRankingJobScheduler.java | 48 ++++++ .../WeeklyRankingJobConfig.java | 23 +++ .../step/MonthlyRankingStepConfig.java | 80 ++++++++++ .../step/WeeklyRankingStepConfig.java | 81 ++++++++++ .../step/processor/RankingScoreProcessor.java | 70 +++++++++ .../step/reader/RankingScoreReader.java | 138 ++++++++++++++++++ .../step/writer/MonthlyRankingWriter.java | 40 +++++ .../step/writer/WeeklyRankingWriter.java | 42 ++++++ .../domain/rank/MonthlyProductRank.java | 106 ++++++++++++++ .../domain/rank/MonthlyRankRepository.java | 22 +++ .../rank/ProductRankingAggregation.java | 25 ++++ .../domain/rank/WeeklyProductRank.java | 106 ++++++++++++++ .../domain/rank/WeeklyRankRepository.java | 22 +++ .../migration/V20260102__ranking_mv_ddl.sql | 51 +++++++ 24 files changed, 1154 insertions(+), 8 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/MonthlyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/ProductRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/ProductRankingJobScheduler.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/WeeklyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/MonthlyRankingStepConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/WeeklyRankingStepConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/processor/RankingScoreProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/reader/RankingScoreReader.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/MonthlyRankingWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/WeeklyRankingWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankingAggregation.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java create mode 100644 apps/commerce-batch/src/main/resources/db/migration/V20260102__ranking_mv_ddl.sql 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/domain/rank/MonthlyProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java new file mode 100644 index 000000000..ed3aea21a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.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 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; +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java new file mode 100644 index 000000000..97f792b34 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.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 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; +} + + 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..25734fdd0 --- /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.MonthlyProductRank; +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..cd3737392 --- /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.WeeklyProductRank; +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/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index 1a186c049..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 @@ -14,7 +14,6 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; -import java.util.Collections; @Tag(name = "Ranking", description = "상품 랭킹 API") @RestController @@ -51,12 +50,10 @@ public ApiResponse getRankingPage( switch (period) { case "DAILY" -> rankings = rankingFacade.getDailyRanking(targetDate, page, size); case "WEEKLY" -> { - // TODO: 주간 랭킹 구현 시 주간 MV 조회로 대체 - rankings = Collections.emptyList(); + rankings = rankingFacade.getWeeklyRanking(targetDate, page, size); } case "MONTHLY" -> { - // TODO: 월간 랭킹 구현 시 월간 MV 조회로 대체 - rankings = Collections.emptyList(); + rankings = rankingFacade.getMonthlyRanking(targetDate, page, size); } default -> rankings = rankingFacade.getDailyRanking(targetDate, page, size); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java index e5005c373..752de60fb 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -4,11 +4,13 @@ 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 diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/MonthlyRankingJobConfig.java new file mode 100644 index 000000000..c142e8a61 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/MonthlyRankingJobConfig.java @@ -0,0 +1,23 @@ +package com.loopers.batch.job.productRankingJob; + +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.repository.JobRepository; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MonthlyRankingJobConfig { + + @Bean + public Job monthlyRankingJob( + JobRepository jobRepository, + @Qualifier("monthlyRankingStep") Step monthlyRankingStep + ) { + return new JobBuilder("monthlyRankingJob", jobRepository) + .start(monthlyRankingStep) + .build(); + } +} 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/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/WeeklyRankingJobConfig.java new file mode 100644 index 000000000..8eb19cb1d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/WeeklyRankingJobConfig.java @@ -0,0 +1,23 @@ +package com.loopers.batch.job.productRankingJob; + +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.repository.JobRepository; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class WeeklyRankingJobConfig { + + @Bean + public Job weeklyRankingJob( + JobRepository jobRepository, + @Qualifier("weeklyRankingStep") Step weeklyRankingStep + ) { + return new JobBuilder("weeklyRankingJob", jobRepository) + .start(weeklyRankingStep) + .build(); + } +} 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/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/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; + + From ed457e39e0e65b9f581eac31eaca499991f686ff Mon Sep 17 00:00:00 2001 From: Dori Date: Sat, 3 Jan 2026 00:49:28 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=A0=95=EC=9D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20View=20Entity?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/config/AsyncConfig.java | 29 +++++++++++++++++++ ...tRank.java => MonthlyProductRankView.java} | 2 +- ...ctRank.java => WeeklyProductRankView.java} | 2 +- .../rank/MonthlyRankJpaRepository.java | 6 ++-- .../rank/WeeklyRankJpaRepository.java | 6 ++-- 5 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java rename apps/commerce-api/src/main/java/com/loopers/domain/rank/{MonthlyProductRank.java => MonthlyProductRankView.java} (97%) rename apps/commerce-api/src/main/java/com/loopers/domain/rank/{WeeklyProductRank.java => WeeklyProductRankView.java} (97%) 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/MonthlyProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRankView.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java rename to apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRankView.java index ed3aea21a..6040750ed 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRankView.java @@ -19,7 +19,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Immutable -public class MonthlyProductRank { +public class MonthlyProductRankView { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRankView.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java rename to apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRankView.java index 97f792b34..4e9d637ef 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRankView.java @@ -19,7 +19,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Immutable -public class WeeklyProductRank { +public class WeeklyProductRankView { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) 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 index 25734fdd0..c9a558571 100644 --- 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 @@ -1,6 +1,6 @@ package com.loopers.infrastructure.rank; -import com.loopers.domain.rank.MonthlyProductRank; +import com.loopers.domain.rank.MonthlyProductRankView; import java.time.LocalDate; import java.util.List; import org.springframework.data.domain.Pageable; @@ -8,8 +8,8 @@ import org.springframework.stereotype.Repository; @Repository -public interface MonthlyRankJpaRepository extends JpaRepository { - List findByPeriodStartOrderByRankPositionAsc(LocalDate periodStart, Pageable pageable); +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 index cd3737392..5f598be17 100644 --- 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 @@ -1,6 +1,6 @@ package com.loopers.infrastructure.rank; -import com.loopers.domain.rank.WeeklyProductRank; +import com.loopers.domain.rank.WeeklyProductRankView; import java.time.LocalDate; import java.util.List; import org.springframework.data.domain.Pageable; @@ -8,8 +8,8 @@ import org.springframework.stereotype.Repository; @Repository -public interface WeeklyRankJpaRepository extends JpaRepository { - List findByPeriodStartOrderByRankPositionAsc(LocalDate periodStart, Pageable pageable); +public interface WeeklyRankJpaRepository extends JpaRepository { + List findByPeriodStartOrderByRankPositionAsc(LocalDate periodStart, Pageable pageable); } From baaf961e2bd76b6d59157d740c5d7ed90b17f62e Mon Sep 17 00:00:00 2001 From: Dori Date: Sat, 3 Jan 2026 00:56:00 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=ED=8C=A8=ED=82=A4=EC=A7=80=EB=AA=85?= =?UTF-8?q?=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/interfaces/consumer/CatalogEventConsumer.java | 2 +- .../java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java | 2 +- .../com/loopers/interfaces/consumer/OrderEventConsumer.java | 2 +- .../java/com/loopers/{confg => config}/kafka/KafkaConfig.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename modules/kafka/src/main/java/com/loopers/{confg => config}/kafka/KafkaConfig.java (99%) 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 a406bffaa..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,7 +4,7 @@ import com.loopers.application.metrics.MetricsAggregator; import com.loopers.application.ranking.RankingAggregator; import com.loopers.application.EventHandledService; -import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.config.kafka.KafkaConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; 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 e0e693012..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,7 +4,7 @@ import com.loopers.application.EventHandledService; import com.loopers.application.metrics.MetricsAggregator; import com.loopers.application.ranking.RankingAggregator; -import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.config.kafka.KafkaConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; 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; From 5587a980eee69209d954e846c539aa8664178172 Mon Sep 17 00:00:00 2001 From: Dori Date: Sat, 3 Jan 2026 01:04:02 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20ProductRankingJobConfig=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MonthlyRankingJobConfig.java | 23 ------------------- .../WeeklyRankingJobConfig.java | 23 ------------------- 2 files changed, 46 deletions(-) delete mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/MonthlyRankingJobConfig.java delete mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/WeeklyRankingJobConfig.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/MonthlyRankingJobConfig.java deleted file mode 100644 index c142e8a61..000000000 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/MonthlyRankingJobConfig.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.loopers.batch.job.productRankingJob; - -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.repository.JobRepository; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class MonthlyRankingJobConfig { - - @Bean - public Job monthlyRankingJob( - JobRepository jobRepository, - @Qualifier("monthlyRankingStep") Step monthlyRankingStep - ) { - return new JobBuilder("monthlyRankingJob", jobRepository) - .start(monthlyRankingStep) - .build(); - } -} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/WeeklyRankingJobConfig.java deleted file mode 100644 index 8eb19cb1d..000000000 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/WeeklyRankingJobConfig.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.loopers.batch.job.productRankingJob; - -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.repository.JobRepository; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class WeeklyRankingJobConfig { - - @Bean - public Job weeklyRankingJob( - JobRepository jobRepository, - @Qualifier("weeklyRankingStep") Step weeklyRankingStep - ) { - return new JobBuilder("weeklyRankingJob", jobRepository) - .start(weeklyRankingStep) - .build(); - } -}