From a9339ff8d8d296068217fd0aea1ebefd46c41b70 Mon Sep 17 00:00:00 2001 From: hubtwork Date: Wed, 31 Dec 2025 16:09:54 +0900 Subject: [PATCH 01/13] =?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 c99fb6360..a2c303835 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", ":apps:commerce-streamer", + ":apps:commerce-batch", ":modules:jpa", ":modules:redis", ":modules:kafka", From 3f9eb4fee2df1fff7ae4d92819407c87aae9f8c5 Mon Sep 17 00:00:00 2001 From: hubtwork Date: Wed, 31 Dec 2025 16:16:04 +0900 Subject: [PATCH 02/13] =?UTF-8?q?commerce-batch=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=B4=20README=20=EC=97=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 04950f29d..f86e4dd8a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ docker-compose -f ./docker/monitoring-compose.yml up Root ├── apps ( spring-applications ) │ ├── 📦 commerce-api +│ ├── 📦 commerce-batch │ └── 📦 commerce-streamer ├── modules ( reusable-configurations ) │ ├── 📦 jpa From 569eb94fdb09378bdd1903af29ee80c8e492cbd4 Mon Sep 17 00:00:00 2001 From: junoade Date: Thu, 1 Jan 2026 10:33:40 +0900 Subject: [PATCH 03/13] =?UTF-8?q?refactor:=20=EC=9D=BC=EB=B3=84=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주간/월간 MV 랭킹 메트릭 정보 집계를 위해 아래의 테이블구조를 따르도록 변경하였습니다. ```mermaid erDiagram PRODUCT_METRICS { BIGINT product_id PK VARCHAR2(8) metrics_date PK VARCHAR2(50) METRICS_TYPE PK BIGINT count TIMESTAMP updated_at TIMESTAMP created_at } ``` --- .../metrics/MetricsAggregationService.java | 15 ++++-- .../java/com/loopers/domain/MetricsType.java | 8 +++ .../domain/ProductLikeMetricsModel.java | 33 ------------ .../com/loopers/domain/ProductMetricsId.java | 51 ++++++++++++++++++ .../loopers/domain/ProductMetricsModel.java | 53 +++++++++++++++++++ .../ProductLikeMetricsRepository.java | 7 --- .../ProductMetricsRepository.java | 8 +++ .../idempotency/EventHandledServiceTest.java | 24 ++++++--- 8 files changed, 146 insertions(+), 53 deletions(-) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/MetricsType.java delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/ProductLikeMetricsModel.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsId.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.java delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductLikeMetricsRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java index a29c2ff7e..7fbcdf846 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java @@ -1,7 +1,9 @@ package com.loopers.application.metrics; -import com.loopers.domain.ProductLikeMetricsModel; -import com.loopers.infrastructure.ProductLikeMetricsRepository; +import com.loopers.domain.MetricsType; +import com.loopers.domain.ProductMetricsId; +import com.loopers.domain.ProductMetricsModel; +import com.loopers.infrastructure.ProductMetricsRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -11,15 +13,18 @@ @Service @RequiredArgsConstructor public class MetricsAggregationService { - private final ProductLikeMetricsRepository likeMetricsRepository; + private final ProductMetricsRepository likeMetricsRepository; @Transactional public void handleProductLiked(Long productId) { log.debug("Handling product liked event"); - ProductLikeMetricsModel metrics = likeMetricsRepository.findById(productId) - .orElseGet(() -> likeMetricsRepository.save(ProductLikeMetricsModel.of(productId))); + ProductMetricsId id = ProductMetricsId.of(productId, MetricsType.LIKE); + + ProductMetricsModel metrics = likeMetricsRepository.findById(id) + .orElseGet(() -> likeMetricsRepository.save(ProductMetricsModel.of(id))); metrics.increase(); } + } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/MetricsType.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/MetricsType.java new file mode 100644 index 000000000..cacb1324c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/MetricsType.java @@ -0,0 +1,8 @@ +package com.loopers.domain; + +public enum MetricsType { + VIEW, + LIKE, + ORDER_SUCCESS, + PAYMENT_SUCCESS, +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductLikeMetricsModel.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductLikeMetricsModel.java deleted file mode 100644 index 391b1520b..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductLikeMetricsModel.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.loopers.domain; - - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "product_like_metrics") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ProductLikeMetricsModel { - @Id - private Long productId; - - @Column(nullable = false) - private long likeCount; - - public static ProductLikeMetricsModel of(Long productId) { - ProductLikeMetricsModel m = new ProductLikeMetricsModel(); - m.productId = productId; - m.likeCount = 0; - return m; - } - - public void increase() { - this.likeCount += 1; - } -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsId.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsId.java new file mode 100644 index 000000000..8dae1a8c5 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsId.java @@ -0,0 +1,51 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +@Embeddable +@Getter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class ProductMetricsId implements Serializable { + + @Column(name = "PRODUCT_ID", nullable = false) + private Long productId; + + /** + * yyyyMMdd 로 관리합니다. + */ + @Column(name = "METRICS_DATE", length = 8, nullable = false) + private String metricsDate; + + @Column(name = "METRICS_TYPE", length = 50, nullable = false) + @Enumerated(EnumType.STRING) + private MetricsType metricsType; + + + public static ProductMetricsId of(Long productId, MetricsType metricsType) { + String nowDate = convertDate(Instant.now()); + return new ProductMetricsId(productId, nowDate, metricsType); + } + + + private static String convertDate(Instant occurredAt) { + return occurredAt + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(DateTimeFormatter.BASIC_ISO_DATE); + } + +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.java new file mode 100644 index 000000000..413aa94cb --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.java @@ -0,0 +1,53 @@ +package com.loopers.domain; + + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +@Entity +@Table(name = "product_metrics") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetricsModel { + + @EmbeddedId + private ProductMetricsId id; + + @Column(nullable = false) + private long count; + + @Column(name = "CREATED_AT", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "UPDATED_AT", nullable = false) + private Instant updatedAt; + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = Instant.now(); + } + + public static ProductMetricsModel of(ProductMetricsId id) { + ProductMetricsModel m = new ProductMetricsModel(); + m.id = id; + m.count = 0; + return m; + } + + public void increase() { + this.count += 1; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductLikeMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductLikeMetricsRepository.java deleted file mode 100644 index f966ebd01..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductLikeMetricsRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.infrastructure; - -import com.loopers.domain.ProductLikeMetricsModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ProductLikeMetricsRepository extends JpaRepository { -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java new file mode 100644 index 000000000..8fee9f54a --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.ProductMetricsId; +import com.loopers.domain.ProductMetricsModel; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductMetricsRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java index 84c3b1286..66429f895 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java @@ -1,10 +1,13 @@ package com.loopers.application.idempotency; -import com.loopers.domain.ProductLikeMetricsModel; +import com.loopers.domain.MetricsType; +import com.loopers.domain.ProductMetricsId; +import com.loopers.domain.ProductMetricsModel; import com.loopers.infrastructure.EventHandleRepository; -import com.loopers.infrastructure.ProductLikeMetricsRepository; +import com.loopers.infrastructure.ProductMetricsRepository; import com.loopers.testcontainers.KafkaTestContainersConfig; import com.loopers.utils.KafkaCleanUp; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -20,6 +23,7 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +@Slf4j @ActiveProfiles("local") @Import(KafkaTestContainersConfig.class) @SpringBootTest @@ -31,7 +35,7 @@ class EventHandledServiceTest { @Autowired KafkaTemplate kafkaTemplate; @Autowired - ProductLikeMetricsRepository metricsRepo; + ProductMetricsRepository metricsRepo; @Autowired EventHandleRepository handledRepo; @@ -50,11 +54,13 @@ void setUp() { void duplicate_message_should_be_applied_once() throws Exception { long productId = 1L; - ProductLikeMetricsModel metrics = metricsRepo.findById(productId) - .orElseGet(() -> metricsRepo.save(ProductLikeMetricsModel.of(productId))); + ProductMetricsId id = ProductMetricsId.of(productId, MetricsType.LIKE); + ProductMetricsModel metrics = metricsRepo.findById(id) + .orElseGet(() -> metricsRepo.save(ProductMetricsModel.of(id))); + + long before = metrics.getCount(); + log.debug("before count : {}", before); - long before = metrics.getLikeCount(); - // String payload = """ {"eventId": "06b9f00c-04bb-40fb-a96c-a9da5d0ede53", "traceId": "6944e75c781a87d97e02a61daca86d0a", "userPkId": 1, "eventType": "LIKE_CREATED", "productId": 1, "occurredAt": "2025-12-19T05:49:16.719347Z"} """; @@ -72,8 +78,10 @@ void duplicate_message_should_be_applied_once() throws Exception { // then // like 카운트는 1번만 증가 - long after = metricsRepo.findById(productId).orElseThrow().getLikeCount(); + long after = metricsRepo.findById(id).orElseThrow().getCount(); assertThat(after).isEqualTo(before + 1); + log.debug("after count : {}", after); + long handledCount = handledRepo.countByConsumerNameAndEventId("product-like-metrics", "06b9f00c-04bb-40fb-a96c-a9da5d0ede53"); assertThat(handledCount).isEqualTo(1); From 8206004e69fc7665bbc55ca0ba2aeb78068f2f6b Mon Sep 17 00:00:00 2001 From: junoade Date: Thu, 1 Jan 2026 22:13:55 +0900 Subject: [PATCH 04/13] =?UTF-8?q?init=20:=20=EB=B0=B0=EC=B9=98=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EA=B0=9C=EB=B0=9C=EA=B3=84=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/ProductMetricDataInitializer.java | 43 ++++++++++++++++++ .../db/fixtures/large-product-metric-data.sql | 44 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/db/ProductMetricDataInitializer.java create mode 100644 apps/commerce-batch/src/main/resources/db/fixtures/large-product-metric-data.sql diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/db/ProductMetricDataInitializer.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/db/ProductMetricDataInitializer.java new file mode 100644 index 000000000..5e5f74f83 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/db/ProductMetricDataInitializer.java @@ -0,0 +1,43 @@ +package com.loopers.batch.infrastructure.db; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.datasource.init.ScriptUtils; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; + +@Slf4j +@Component +@Profile("local") +@RequiredArgsConstructor +public class ProductMetricDataInitializer implements CommandLineRunner { + private final DataSource dataSource; + + @Value("${spring.jpa.hibernate.ddl-auto}") + private String ddlAuto; + + @Override + public void run(String... args) throws Exception { + + if(ddlAuto.equals("none")) { + log.info("[ProductMetricDataInitializer] skipped because ddl-auto is none"); + return; + } + + + log.info("[ProductMetricDataInitializer] start"); + try (Connection conn = dataSource.getConnection()) { + ScriptUtils.executeSqlScript( + conn, + new ClassPathResource("db/fixtures/.sql") + ); + } + log.info("[ProductMetricDataInitializer] done"); + } +} diff --git a/apps/commerce-batch/src/main/resources/db/fixtures/large-product-metric-data.sql b/apps/commerce-batch/src/main/resources/db/fixtures/large-product-metric-data.sql new file mode 100644 index 000000000..7ce9d45b5 --- /dev/null +++ b/apps/commerce-batch/src/main/resources/db/fixtures/large-product-metric-data.sql @@ -0,0 +1,44 @@ +-- PRODUCT_METRICS bulk dummy data +-- 범위: [2025-01-01, 2026-01-31] +DELETE FROM product_metrics; + +INSERT INTO product_metrics ( + product_id, + metrics_date, + metrics_type, + `count`, + created_at, + updated_at +) +SELECT + p.product_id, + DATE_FORMAT(DATE_ADD('2025-01-01', INTERVAL n.n DAY), '%Y%m%d') AS metrics_date, + m.metrics_type, + CASE m.metrics_type + WHEN 'VIEW' THEN FLOOR(50 + RAND() * 500) + WHEN 'LIKE' THEN FLOOR(5 + RAND() * 80) + WHEN 'ORDER_SUCCESS' THEN FLOOR(RAND() * 20) + WHEN 'PAYMENT_SUCCESS' THEN FLOOR(RAND() * 20) + END AS `count`, + NOW(), + NOW() +FROM + -- n = 0..395 생성 (최대 999까지 가능) + ( + SELECT (a.d + 10*b.d + 100*c.d) AS n + FROM (SELECT 0 d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) a + CROSS JOIN (SELECT 0 d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) b + CROSS JOIN (SELECT 0 d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) c + ) n + CROSS JOIN + (SELECT 1 AS product_id UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5) p + CROSS JOIN + (SELECT 'VIEW' AS metrics_type + UNION ALL SELECT 'LIKE' + UNION ALL SELECT 'ORDER_SUCCESS' + UNION ALL SELECT 'PAYMENT_SUCCESS') m +WHERE + n.n BETWEEN 0 AND 395; \ No newline at end of file From 0a98c34bc8bd676ed2b203b9e1c6636dd9cadc78 Mon Sep 17 00:00:00 2001 From: junoade Date: Fri, 2 Jan 2026 00:40:33 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20MV=20=EB=8F=84=EB=A9=94=EC=9D=B8,?= =?UTF-8?q?=20=EB=A0=88=ED=8F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ```mermaid erDiagram { MV_PRODUCT_RANK_WEEKLY { varchar year_week PK bigint product_id PK bigint view_count bigint like_count bigint order_count datetime created_at datetime updated_at } MV_PRODUCT_RANK_MONTHLY { varchar year_month PK bigint product_id PK bigint view_count bigint like_count bigint order_count datetime created_at datetime updated_at } } ``` --- .../loopers/domain/mv/ProductRankMonthly.java | 62 +++++++++++++++++++ .../domain/mv/ProductRankMonthlyId.java | 21 +++++++ .../loopers/domain/mv/ProductRankWeekly.java | 62 +++++++++++++++++++ .../domain/mv/ProductRankWeeklyId.java | 21 +++++++ .../ProductRankMonthlyRepository.java | 8 +++ .../ProductRankWeeklyRepository.java | 8 +++ 6 files changed, 182 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankMonthlyRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankWeeklyRepository.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java new file mode 100644 index 000000000..f343166a2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java @@ -0,0 +1,62 @@ +package com.loopers.domain.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "mv_product_rank_monthly") +@Getter +@NoArgsConstructor +public class ProductRankMonthly { + + @EmbeddedId + private ProductRankMonthlyId id; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_count", nullable = false) + private long orderCount; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public static ProductRankMonthly of(String yearMonth, Long productId, + long viewCount, long likeCount, long orderCount) { + ProductRankMonthly e = new ProductRankMonthly(); + e.id = new ProductRankMonthlyId(yearMonth, productId); + e.viewCount = viewCount; + e.likeCount = likeCount; + e.orderCount = orderCount; + e.createdAt = LocalDateTime.now(); + e.updatedAt = LocalDateTime.now(); + return e; + } + + public void overwrite(long viewCount, long likeCount, long orderCount) { + this.viewCount = viewCount; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.updatedAt = LocalDateTime.now(); + } + + public Long productId() { + return id.getProductId(); + } + + public String yearMonth() { + return id.getYearMonth(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java new file mode 100644 index 000000000..6bcabb5fd --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java @@ -0,0 +1,21 @@ +package com.loopers.domain.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.*; + +import java.io.Serializable; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EqualsAndHashCode +public class ProductRankMonthlyId implements Serializable { + + @Column(name = "year_month", length = 10, nullable = false) + private String yearMonth; // e.g. 2026-01 or 202601 + + @Column(name = "product_id", nullable = false) + private Long productId; +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java new file mode 100644 index 000000000..efbd1ec8f --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java @@ -0,0 +1,62 @@ +package com.loopers.domain.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Entity +@Table(name = "mv_product_rank_weekly") +@Getter +@NoArgsConstructor +public class ProductRankWeekly { + @EmbeddedId + private ProductRankWeeklyId id; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_count", nullable = false) + private long orderCount; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public static ProductRankWeekly of(String yearWeek, Long productId, + long viewCount, long likeCount, long orderCount) { + ProductRankWeekly e = new ProductRankWeekly(); + e.id = new ProductRankWeeklyId(yearWeek, productId); + e.viewCount = viewCount; + e.likeCount = likeCount; + e.orderCount = orderCount; + e.createdAt = LocalDateTime.now(ZoneId.systemDefault()); + e.updatedAt = LocalDateTime.now(ZoneId.systemDefault()); + return e; + } + + public void overwrite(long viewCount, long likeCount, long orderCount) { + this.viewCount = viewCount; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.updatedAt = LocalDateTime.now(); + } + + public Long productId() { + return id.getProductId(); + } + + public String yearWeek() { + return id.getYearWeek(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java new file mode 100644 index 000000000..18dd0c875 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java @@ -0,0 +1,21 @@ +package com.loopers.domain.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.*; + +import java.io.Serializable; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EqualsAndHashCode +public class ProductRankWeeklyId implements Serializable { + + @Column(name = "year_week", length = 10, nullable = false) + private String yearWeek; // e.g. 2026-W01 + + @Column(name = "product_id", nullable = false) + private Long productId; +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankMonthlyRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankMonthlyRepository.java new file mode 100644 index 000000000..c07161f20 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankMonthlyRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.mv.ProductRankMonthly; +import com.loopers.domain.mv.ProductRankMonthlyId; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductRankMonthlyRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankWeeklyRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankWeeklyRepository.java new file mode 100644 index 000000000..2946e97aa --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankWeeklyRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.mv.ProductRankWeekly; +import com.loopers.domain.mv.ProductRankWeeklyId; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductRankWeeklyRepository extends JpaRepository { +} From 8472ca24d9efc55566aff7a65d537affcb7de493 Mon Sep 17 00:00:00 2001 From: junoade Date: Fri, 2 Jan 2026 00:44:22 +0900 Subject: [PATCH 06/13] =?UTF-8?q?refactor:=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Instant / ZonedDateTime / LocalDateTime 학습 --- .../java/com/loopers/domain/ProductMetricsModel.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.java index 413aa94cb..3fce86d55 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.java @@ -7,8 +7,8 @@ import lombok.NoArgsConstructor; import java.time.Instant; +import java.time.LocalDateTime; import java.time.ZoneId; -import java.time.format.DateTimeFormatter; @Entity @Table(name = "product_metrics") @@ -23,21 +23,21 @@ public class ProductMetricsModel { private long count; @Column(name = "CREATED_AT", nullable = false, updatable = false) - private Instant createdAt; + private LocalDateTime createdAt; @Column(name = "UPDATED_AT", nullable = false) - private Instant updatedAt; + private LocalDateTime updatedAt; @PrePersist protected void onCreate() { - Instant now = Instant.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault()); this.createdAt = now; this.updatedAt = now; } @PreUpdate protected void onUpdate() { - this.updatedAt = Instant.now(); + this.updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } public static ProductMetricsModel of(ProductMetricsId id) { From 500f4b5aaf293713f3d371494339b1943d37682c Mon Sep 17 00:00:00 2001 From: junoade Date: Fri, 2 Jan 2026 02:50:36 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=EB=B0=B0=EC=B9=98=20=EC=9E=A1=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 청크 기반 / jdbc / 가장 간단하게 ```sh java -jar apps/commerce-batch/build/libs/commerce-batch-8472ca24.jar \ --spring.profiles.active=local \ --spring.batch.job.enabled=true \ --spring.batch.job.name=rankWeeklyMvJob \ yearWeek=2026-W01 ``` --- .../com/loopers/CommerceBatchApplication.java | 18 +++++ .../job/rankWeekly/RankWeeklyJobConfig.java | 71 ++++++++++++++++ .../rankWeekly/step/RankWeeklyProcessor.java | 13 +++ .../step/RankWeeklyReaderConfig.java | 80 +++++++++++++++++++ .../step/RankWeeklyWriterConfig.java | 38 +++++++++ .../job/rankWeekly/step/dto/WeeklyAggRow.java | 15 ++++ .../domain/mv/ProductRankMonthlyId.java | 4 +- .../domain/mv/ProductRankWeeklyId.java | 2 +- 8 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyReaderConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyAggRow.java 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..4b6f04e0f 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -1,10 +1,15 @@ package com.loopers; import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.Bean; +import javax.sql.DataSource; import java.util.TimeZone; @ConfigurationPropertiesScan @@ -21,4 +26,17 @@ public static void main(String[] args) { int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); System.exit(exitCode); } + + @Bean + public static BeanFactoryPostProcessor dataSourceAliasForBatch() { + return (ConfigurableListableBeanFactory beanFactory) -> { + String[] names = beanFactory.getBeanNamesForType(DataSource.class); + // DataSource가 1개면 그걸 "dataSource"라는 이름으로도 접근 가능하게 별칭 등록 + if (names.length == 1 && beanFactory instanceof DefaultListableBeanFactory dlbf) { + if (!dlbf.containsBean("dataSource") && !dlbf.isAlias("dataSource")) { + dlbf.registerAlias(names[0], "dataSource"); + } + } + }; + } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java new file mode 100644 index 000000000..900b219d1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java @@ -0,0 +1,71 @@ +package com.loopers.batch.job.rankWeekly; + +import com.loopers.batch.job.rankWeekly.step.RankWeeklyProcessor; +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.*; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.batch.core.listener.StepExecutionListenerSupport; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = "rankWeeklyMvJob") +@Configuration +// @EnableBatchProcessing +@RequiredArgsConstructor +public class RankWeeklyJobConfig { + @Bean + public Job rankWeeklyMvJob(JobRepository jobRepository, @Qualifier("rankWeeklyMvStep") Step rankWeeklyMvStep) { + return new JobBuilder("rankWeeklyMvJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(rankWeeklyMvStep) + .build(); + } + + @Bean + public Step rankWeeklyMvStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + JdbcPagingItemReader weeklyAggReader, + RankWeeklyProcessor processor, + JdbcBatchItemWriter weeklyMvUpsertWriter + ) { + return new StepBuilder("rankWeeklyMvStep", jobRepository) + .chunk(1000, transactionManager) + .reader(weeklyAggReader) + .processor(processor) + .writer(weeklyMvUpsertWriter) + .listener(rankWeeklyStepLoggingListener()) + .build(); + } + + @Bean + public StepExecutionListener rankWeeklyStepLoggingListener() { + return new StepExecutionListenerSupport() { + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + // 빠르게 "COMPLETED"가 뜨는데 DB에 적재가 안 되는 경우 대부분 readCount=0 입니다. + System.out.printf( + "[RankWeekly] step=%s status=%s read=%d write=%d commit=%d rollback=%d filter=%d skip=%d\n", + stepExecution.getStepName(), + stepExecution.getStatus(), + stepExecution.getReadCount(), + stepExecution.getWriteCount(), + stepExecution.getCommitCount(), + stepExecution.getRollbackCount(), + stepExecution.getFilterCount(), + stepExecution.getSkipCount() + ); + return ExitStatus.COMPLETED; + } + }; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java new file mode 100644 index 000000000..8662904e1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java @@ -0,0 +1,13 @@ +package com.loopers.batch.job.rankWeekly.step; + +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +@Component +public class RankWeeklyProcessor implements ItemProcessor { + @Override + public WeeklyAggRow process(WeeklyAggRow item) { + return item; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyReaderConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyReaderConfig.java new file mode 100644 index 000000000..356acfe5d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyReaderConfig.java @@ -0,0 +1,80 @@ +package com.loopers.batch.job.rankWeekly.step; + +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; +import java.util.Map; + +@Configuration +@RequiredArgsConstructor +public class RankWeeklyReaderConfig { + private final DataSource dataSource; + + @Bean + @StepScope + public JdbcPagingItemReader weeklyAggReader( + @Value("#{jobParameters['yearWeek']}") String yearWeek) { + return new JdbcPagingItemReaderBuilder() + .name("weeklyAggReader") + .dataSource(dataSource) + .queryProvider(queryProvider()) + .parameterValues(Map.of("yearWeek", yearWeek)) + .rowMapper((rs, rowNum) -> new WeeklyAggRow( + rs.getString("year_week"), + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getLong("order_count") + )) + .pageSize(1000) + .build(); + } + + private MySqlPagingQueryProvider queryProvider() { + MySqlPagingQueryProvider qp = new MySqlPagingQueryProvider(); + + qp.setSelectClause(""" + select + year_week, + product_id, + view_count, + like_count, + order_count + """); + + qp.setFromClause(""" + from ( + select + CONCAT( + SUBSTRING(YEARWEEK(STR_TO_DATE(pm.metrics_date, '%Y%m%d'), 3), 1, 4), + '-W', + LPAD(SUBSTRING(YEARWEEK(STR_TO_DATE(pm.metrics_date, '%Y%m%d'), 3), 5, 2), 2, '0') + ) as year_week, + pm.product_id as product_id, + SUM(CASE WHEN pm.metrics_type = 'VIEW' THEN pm.count ELSE 0 END) as view_count, + SUM(CASE WHEN pm.metrics_type = 'LIKE' THEN pm.count ELSE 0 END) as like_count, + SUM(CASE WHEN pm.metrics_type = 'ORDER_SUCCESS' THEN pm.count ELSE 0 END) as order_count + from product_metrics pm + group by year_week, pm.product_id + ) t + """); + + qp.setWhereClause("where t.year_week = :yearWeek"); + + qp.setSortKeys(Map.of( + "product_id", org.springframework.batch.item.database.Order.ASCENDING + )); + + return qp; + } + + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java new file mode 100644 index 000000000..003ceb5ce --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java @@ -0,0 +1,38 @@ +package com.loopers.batch.job.rankWeekly.step; + +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; + +@Configuration +@RequiredArgsConstructor +public class RankWeeklyWriterConfig { + private final DataSource dataSource; + + @Bean + public JdbcBatchItemWriter weeklyMvUpsertWriter() { + String sql = """ + INSERT INTO mv_product_rank_weekly + (year_week_key, product_id, view_count, like_count, order_count, created_at, updated_at) + VALUES + (:yearWeek, :productId, :viewCount, :likeCount, :orderCount, NOW(6), NOW(6)) + ON DUPLICATE KEY UPDATE + view_count = VALUES(view_count), + like_count = VALUES(like_count), + order_count = VALUES(order_count), + updated_at = NOW(6) + """; + + return new JdbcBatchItemWriterBuilder() + .dataSource(dataSource) + .sql(sql) + .beanMapped() + .assertUpdates(false) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyAggRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyAggRow.java new file mode 100644 index 000000000..5a92e12fc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyAggRow.java @@ -0,0 +1,15 @@ +package com.loopers.batch.job.rankWeekly.step.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class WeeklyAggRow { + private String yearWeek; // e.g. 2026-W01 + private Long productId; + + private Long viewCount; + private Long likeCount; + private Long orderCount; +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java index 6bcabb5fd..19465a8f5 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java @@ -13,8 +13,8 @@ @EqualsAndHashCode public class ProductRankMonthlyId implements Serializable { - @Column(name = "year_month", length = 10, nullable = false) - private String yearMonth; // e.g. 2026-01 or 202601 + @Column(name = "year_month_key", length = 8, nullable = false) + private String yearMonth; // e.g. 202601 @Column(name = "product_id", nullable = false) private Long productId; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java index 18dd0c875..c3715d576 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java @@ -13,7 +13,7 @@ @EqualsAndHashCode public class ProductRankWeeklyId implements Serializable { - @Column(name = "year_week", length = 10, nullable = false) + @Column(name = "year_week_key", length = 10, nullable = false) private String yearWeek; // e.g. 2026-W01 @Column(name = "product_id", nullable = false) From 0979b35817c283e3237dbf7e96b6bcc6720c25ac Mon Sep 17 00:00:00 2001 From: junoade Date: Fri, 2 Jan 2026 02:57:24 +0900 Subject: [PATCH 08/13] =?UTF-8?q?refactor:=20mv=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EB=82=B4=20score=EC=BB=AC=EB=9F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다음과 같은 식으로 score를 계산한다 ```md score = 0.1*view + 0.2*like + 0.7*order ``` --- .../main/java/com/loopers/domain/mv/ProductRankMonthly.java | 3 +++ .../src/main/java/com/loopers/domain/mv/ProductRankWeekly.java | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java index f343166a2..54871083e 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java @@ -27,6 +27,9 @@ public class ProductRankMonthly { @Column(name = "order_count", nullable = false) private long orderCount; + @Column(name = "score", nullable = false) + private double score; + @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java index efbd1ec8f..5c145cd38 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java @@ -27,6 +27,9 @@ public class ProductRankWeekly { @Column(name = "order_count", nullable = false) private long orderCount; + @Column(name = "score", nullable = false) + private double score; + @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; From 35541d61c38b9bc7631d8ec54e16b73345b702ac Mon Sep 17 00:00:00 2001 From: junoade Date: Fri, 2 Jan 2026 03:14:18 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat:=20processor,=20mv=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=82=B4=20score=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EB=B0=8F=20writer=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다음과 같은 식으로 score를 계산한다 ```md score = 0.1*view + 0.2*like + 0.7*order ``` --- .../job/rankWeekly/RankWeeklyJobConfig.java | 5 +++-- .../rankWeekly/step/RankWeeklyProcessor.java | 22 ++++++++++++++++--- .../step/RankWeeklyWriterConfig.java | 11 +++++----- .../job/rankWeekly/step/dto/WeeklyAggRow.java | 20 ++++++----------- .../rankWeekly/step/dto/WeeklyRankRow.java | 10 +++++++++ 5 files changed, 45 insertions(+), 23 deletions(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyRankRow.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java index 900b219d1..4052b8800 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java @@ -2,6 +2,7 @@ import com.loopers.batch.job.rankWeekly.step.RankWeeklyProcessor; import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyRankRow; import lombok.RequiredArgsConstructor; import org.springframework.batch.core.*; import org.springframework.batch.core.job.builder.JobBuilder; @@ -36,10 +37,10 @@ public Step rankWeeklyMvStep( PlatformTransactionManager transactionManager, JdbcPagingItemReader weeklyAggReader, RankWeeklyProcessor processor, - JdbcBatchItemWriter weeklyMvUpsertWriter + JdbcBatchItemWriter weeklyMvUpsertWriter ) { return new StepBuilder("rankWeeklyMvStep", jobRepository) - .chunk(1000, transactionManager) + .chunk(1000, transactionManager) .reader(weeklyAggReader) .processor(processor) .writer(weeklyMvUpsertWriter) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java index 8662904e1..9579c7949 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java @@ -1,13 +1,29 @@ package com.loopers.batch.job.rankWeekly.step; import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyRankRow; import org.springframework.batch.item.ItemProcessor; import org.springframework.stereotype.Component; @Component -public class RankWeeklyProcessor implements ItemProcessor { +public class RankWeeklyProcessor implements ItemProcessor { @Override - public WeeklyAggRow process(WeeklyAggRow item) { - return item; + public WeeklyRankRow process(WeeklyAggRow item) { + double score = calculateScore(item); + return new WeeklyRankRow( + item.yearWeek(), + item.productId(), + item.viewCount(), + item.likeCount(), + item.orderCount(), + score + ); + } + + // TODO 정책적으로 분리할 수 있도록 개선한다 + private double calculateScore(WeeklyAggRow item) { + return 0.1 * item.viewCount() + + 0.2 * item.likeCount() + + 0.7 * item.orderCount(); } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java index 003ceb5ce..f77b2aee4 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java @@ -1,6 +1,6 @@ package com.loopers.batch.job.rankWeekly.step; -import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyRankRow; import lombok.RequiredArgsConstructor; import org.springframework.batch.item.database.JdbcBatchItemWriter; import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; @@ -15,20 +15,21 @@ public class RankWeeklyWriterConfig { private final DataSource dataSource; @Bean - public JdbcBatchItemWriter weeklyMvUpsertWriter() { + public JdbcBatchItemWriter weeklyMvUpsertWriter() { String sql = """ INSERT INTO mv_product_rank_weekly - (year_week_key, product_id, view_count, like_count, order_count, created_at, updated_at) + (year_week_key, product_id, view_count, like_count, order_count, score, created_at, updated_at) VALUES - (:yearWeek, :productId, :viewCount, :likeCount, :orderCount, NOW(6), NOW(6)) + (:yearWeek, :productId, :viewCount, :likeCount, :orderCount, :score, NOW(6), NOW(6)) ON DUPLICATE KEY UPDATE view_count = VALUES(view_count), like_count = VALUES(like_count), order_count = VALUES(order_count), + score=VALUES(score), updated_at = NOW(6) """; - return new JdbcBatchItemWriterBuilder() + return new JdbcBatchItemWriterBuilder() .dataSource(dataSource) .sql(sql) .beanMapped() diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyAggRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyAggRow.java index 5a92e12fc..4f8117a1e 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyAggRow.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyAggRow.java @@ -1,15 +1,9 @@ package com.loopers.batch.job.rankWeekly.step.dto; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class WeeklyAggRow { - private String yearWeek; // e.g. 2026-W01 - private Long productId; - - private Long viewCount; - private Long likeCount; - private Long orderCount; -} +public record WeeklyAggRow ( + String yearWeek, // e.g. 2026-W01 + Long productId, + long viewCount, + long likeCount, + long orderCount +) { } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyRankRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyRankRow.java new file mode 100644 index 000000000..9039962a2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyRankRow.java @@ -0,0 +1,10 @@ +package com.loopers.batch.job.rankWeekly.step.dto; + +public record WeeklyRankRow( + String yearWeek, + Long productId, + long viewCount, + long likeCount, + long orderCount, + double score +) { } From a99b2240f32847a246612bae04da8d03467fa33d Mon Sep 17 00:00:00 2001 From: junoade Date: Fri, 2 Jan 2026 03:37:29 +0900 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20stepListener=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 디버깅용에서 StepMonitorListener를 활용하도록 변경 --- .../job/rankWeekly/RankWeeklyJobConfig.java | 38 ++++++------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java index 4052b8800..824e9ac0a 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java @@ -3,31 +3,37 @@ import com.loopers.batch.job.rankWeekly.step.RankWeeklyProcessor; import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; import com.loopers.batch.job.rankWeekly.step.dto.WeeklyRankRow; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; import lombok.RequiredArgsConstructor; -import org.springframework.batch.core.*; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; import org.springframework.batch.core.job.builder.JobBuilder; import org.springframework.batch.core.launch.support.RunIdIncrementer; import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.step.builder.StepBuilder; import org.springframework.batch.item.database.JdbcBatchItemWriter; import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.batch.core.listener.StepExecutionListenerSupport; @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = "rankWeeklyMvJob") @Configuration // @EnableBatchProcessing @RequiredArgsConstructor public class RankWeeklyJobConfig { + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + @Bean public Job rankWeeklyMvJob(JobRepository jobRepository, @Qualifier("rankWeeklyMvStep") Step rankWeeklyMvStep) { return new JobBuilder("rankWeeklyMvJob", jobRepository) .incrementer(new RunIdIncrementer()) .start(rankWeeklyMvStep) + .listener(jobListener) .build(); } @@ -44,29 +50,7 @@ public Step rankWeeklyMvStep( .reader(weeklyAggReader) .processor(processor) .writer(weeklyMvUpsertWriter) - .listener(rankWeeklyStepLoggingListener()) + .listener(stepMonitorListener) .build(); } - - @Bean - public StepExecutionListener rankWeeklyStepLoggingListener() { - return new StepExecutionListenerSupport() { - @Override - public ExitStatus afterStep(StepExecution stepExecution) { - // 빠르게 "COMPLETED"가 뜨는데 DB에 적재가 안 되는 경우 대부분 readCount=0 입니다. - System.out.printf( - "[RankWeekly] step=%s status=%s read=%d write=%d commit=%d rollback=%d filter=%d skip=%d\n", - stepExecution.getStepName(), - stepExecution.getStatus(), - stepExecution.getReadCount(), - stepExecution.getWriteCount(), - stepExecution.getCommitCount(), - stepExecution.getRollbackCount(), - stepExecution.getFilterCount(), - stepExecution.getSkipCount() - ); - return ExitStatus.COMPLETED; - } - }; - } } From 1abf6d06d57a16446819d4ca12c7f856a184bfc4 Mon Sep 17 00:00:00 2001 From: junoade Date: Fri, 2 Jan 2026 04:03:19 +0900 Subject: [PATCH 11/13] =?UTF-8?q?feat:=20=EC=9B=94=EA=B0=84=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=EB=B0=B0=EC=B9=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../job/rankMonthly/RankMonthlyJobConfig.java | 56 ++++++++++++++ .../job/rankMonthly/RankMonthlyProcessor.java | 37 +++++++++ .../RankMonthlyProcessorConfig.java | 17 ++++ .../rankMonthly/RankMonthlyReaderConfig.java | 77 +++++++++++++++++++ .../rankMonthly/RankMonthlyWriterConfig.java | 39 ++++++++++ .../job/rankMonthly/dto/MonthlyAggRow.java | 8 ++ .../job/rankMonthly/dto/MonthlyRankRow.java | 10 +++ 7 files changed, 244 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessorConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyReaderConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyWriterConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyAggRow.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyRankRow.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyJobConfig.java new file mode 100644 index 000000000..b28ff4baf --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyJobConfig.java @@ -0,0 +1,56 @@ +package com.loopers.batch.job.rankMonthly; + +import com.loopers.batch.job.rankMonthly.dto.MonthlyAggRow; +import com.loopers.batch.job.rankMonthly.dto.MonthlyRankRow; +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.*; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.listener.StepExecutionListenerSupport; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = "rankMonthlyMvJob") +@Configuration +// @EnableBatchProcessing +@RequiredArgsConstructor +public class RankMonthlyJobConfig { + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + + @Bean + public Job rankMonthlyMvJob(JobRepository jobRepository, @Qualifier("rankMonthlyMvStep") Step rankWeeklyMvStep) { + return new JobBuilder("rankMonthlyMvJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(rankWeeklyMvStep) + .listener(jobListener) + .build(); + } + + @Bean + public Step rankMonthlyMvStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + JdbcPagingItemReader weeklyAggReader, + RankMonthlyProcessor processor, + JdbcBatchItemWriter weeklyMvUpsertWriter + ) { + return new StepBuilder("rankMonthlyMvStep", jobRepository) + .chunk(1000, transactionManager) + .reader(weeklyAggReader) + .processor(processor) + .writer(weeklyMvUpsertWriter) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessor.java new file mode 100644 index 000000000..dd808edfb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessor.java @@ -0,0 +1,37 @@ +package com.loopers.batch.job.rankMonthly; + +import com.loopers.batch.job.rankMonthly.dto.MonthlyAggRow; +import com.loopers.batch.job.rankMonthly.dto.MonthlyRankRow; +import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RankMonthlyProcessor implements ItemProcessor { + + private final String yearMonth; + + @Override + public MonthlyRankRow process(MonthlyAggRow item) { + double score = calculateScore(item); + return new MonthlyRankRow( + yearMonth, + item.productId(), + item.viewCount(), + item.likeCount(), + item.orderCount(), + score + ); + } + + // TODO 정책적으로 분리할 수 있도록 개선한다 + private double calculateScore(MonthlyAggRow item) { + return 0.1 * item.viewCount() + + 0.2 * item.likeCount() + + 0.7 * item.orderCount(); + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessorConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessorConfig.java new file mode 100644 index 000000000..502fb9a3e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessorConfig.java @@ -0,0 +1,17 @@ +package com.loopers.batch.job.rankMonthly; + +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RankMonthlyProcessorConfig { + @Bean + @StepScope + public RankMonthlyProcessor rankMonthlyProcessor( + @Value("#{jobParameters['yearMonth']}") String yearMonth + ) { + return new RankMonthlyProcessor(yearMonth); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyReaderConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyReaderConfig.java new file mode 100644 index 000000000..348bba19f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyReaderConfig.java @@ -0,0 +1,77 @@ +package com.loopers.batch.job.rankMonthly; + +import com.loopers.batch.job.rankMonthly.dto.MonthlyAggRow; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; +import java.util.Map; + +@Configuration +@RequiredArgsConstructor +public class RankMonthlyReaderConfig { + private final DataSource dataSource; + + @Bean + @StepScope + public JdbcPagingItemReader monthlyAggReader( + @Value("#{jobParameters['startDate']}") String startDate, + @Value("#{jobParameters['endDate']}") String endDate + ) { + return new JdbcPagingItemReaderBuilder() + .name("monthlyAggReader") + .dataSource(dataSource) + .queryProvider(queryProvider()) + .parameterValues(Map.of( + "startDate", startDate, + "endDate", endDate + )) + .rowMapper((rs, rowNum) -> new MonthlyAggRow( + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getLong("order_count") + )) + .pageSize(1000) + .build(); + } + + private MySqlPagingQueryProvider queryProvider() { + MySqlPagingQueryProvider qp = new MySqlPagingQueryProvider(); + + qp.setSelectClause(""" + select + product_id, + view_count, + like_count, + order_count + """); + + qp.setFromClause(""" + from ( + select + pm.product_id as product_id, + SUM(CASE WHEN pm.metrics_type = 'VIEW' THEN pm.count ELSE 0 END) as view_count, + SUM(CASE WHEN pm.metrics_type = 'LIKE' THEN pm.count ELSE 0 END) as like_count, + SUM(CASE WHEN pm.metrics_type = 'ORDER_SUCCESS' THEN pm.count ELSE 0 END) as order_count + from product_metrics pm + where pm.metrics_date between :startDate and :endDate + group by pm.product_id + ) t + """); + + qp.setSortKeys(Map.of( + "product_id", org.springframework.batch.item.database.Order.ASCENDING + )); + + return qp; + } + + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyWriterConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyWriterConfig.java new file mode 100644 index 000000000..6b73b53c4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyWriterConfig.java @@ -0,0 +1,39 @@ +package com.loopers.batch.job.rankMonthly; + +import com.loopers.batch.job.rankMonthly.dto.MonthlyRankRow; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; + +@Configuration +@RequiredArgsConstructor +public class RankMonthlyWriterConfig { + private final DataSource dataSource; + + @Bean + public JdbcBatchItemWriter monthlyMvUpsertWriter() { + String sql = """ + INSERT INTO mv_product_rank_monthly + (year_month_key, product_id, view_count, like_count, order_count, score, created_at, updated_at) + VALUES + (:yearMonth, :productId, :viewCount, :likeCount, :orderCount, :score, NOW(6), NOW(6)) + ON DUPLICATE KEY UPDATE + view_count = VALUES(view_count), + like_count = VALUES(like_count), + order_count = VALUES(order_count), + score = VALUES(score), + updated_at = NOW(6) + """; + + return new JdbcBatchItemWriterBuilder() + .dataSource(dataSource) + .sql(sql) + .beanMapped() + .assertUpdates(false) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyAggRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyAggRow.java new file mode 100644 index 000000000..81723dbf1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyAggRow.java @@ -0,0 +1,8 @@ +package com.loopers.batch.job.rankMonthly.dto; + +public record MonthlyAggRow( + Long productId, + long viewCount, + long likeCount, + long orderCount +) { } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyRankRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyRankRow.java new file mode 100644 index 000000000..b8be65b4a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyRankRow.java @@ -0,0 +1,10 @@ +package com.loopers.batch.job.rankMonthly.dto; + +public record MonthlyRankRow( + String yearMonth, // e.g) 202601 + Long productId, + long viewCount, + long likeCount, + long orderCount, + double score +) { } From ff0d7d394582702703956929a2c49d2bb3df05b0 Mon Sep 17 00:00:00 2001 From: junoade Date: Fri, 2 Jan 2026 05:47:44 +0900 Subject: [PATCH 12/13] =?UTF-8?q?refactor:=20Materialized=20View=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit streamer 모듈에서 api 모듈 위치로 변경 streamer 모듈에서 직접적으로 사용하지 않고, metric 정보를 바탕으로 데이터가 집계되며, api 에서 사용되기 때문 --- .../loopers/domain/mv/ProductRankMonthly.java | 0 .../domain/mv/ProductRankMonthlyId.java | 0 .../loopers/domain/mv/ProductRankWeekly.java | 0 .../domain/mv/ProductRankWeeklyId.java | 0 .../ranking/ProductRankMonthlyRepository.java | 25 +++++++++++++++++++ .../ranking/ProductRankWeeklyRepository.java | 25 +++++++++++++++++++ .../ProductRankMonthlyRepository.java | 8 ------ .../ProductRankWeeklyRepository.java | 8 ------ 8 files changed, 50 insertions(+), 16 deletions(-) rename apps/{commerce-streamer => commerce-api}/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java (100%) rename apps/{commerce-streamer => commerce-api}/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java (100%) rename apps/{commerce-streamer => commerce-api}/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java (100%) rename apps/{commerce-streamer => commerce-api}/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java (100%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyRepository.java delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankMonthlyRepository.java delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankWeeklyRepository.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java similarity index 100% rename from apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java rename to apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java b/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java similarity index 100% rename from apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java rename to apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java similarity index 100% rename from apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java rename to apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java b/apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java similarity index 100% rename from apps/commerce-streamer/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java rename to apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyRepository.java new file mode 100644 index 000000000..7c385c1c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyRepository.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.mv.ProductRankMonthly; +import com.loopers.domain.mv.ProductRankMonthlyId; +import com.loopers.ranking.RankingEntry; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ProductRankMonthlyRepository extends JpaRepository { + + @Query(""" + select new com.loopers.ranking.RankingEntry( + p.id.productId, + p.score + ) + from ProductRankMonthly p + where p.id.yearMonth = :yearMonth + order by p.score desc + """) + List findTopByYearMonth(@Param("yearMonth") String yearMonth, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyRepository.java new file mode 100644 index 000000000..c976faafd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyRepository.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.mv.ProductRankWeekly; +import com.loopers.domain.mv.ProductRankWeeklyId; +import com.loopers.ranking.RankingEntry; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ProductRankWeeklyRepository extends JpaRepository { + + @Query(""" + select new com.loopers.ranking.RankingEntry( + p.id.productId, + p.score + ) + from ProductRankWeekly p + where p.id.yearWeek = :yearWeek + order by p.score desc + """) + List findTopByYearWeek(@Param("yearWeek") String yearWeekKey, Pageable pageable); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankMonthlyRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankMonthlyRepository.java deleted file mode 100644 index c07161f20..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankMonthlyRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.loopers.infrastructure; - -import com.loopers.domain.mv.ProductRankMonthly; -import com.loopers.domain.mv.ProductRankMonthlyId; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ProductRankMonthlyRepository extends JpaRepository { -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankWeeklyRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankWeeklyRepository.java deleted file mode 100644 index 2946e97aa..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductRankWeeklyRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.loopers.infrastructure; - -import com.loopers.domain.mv.ProductRankWeekly; -import com.loopers.domain.mv.ProductRankWeeklyId; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ProductRankWeeklyRepository extends JpaRepository { -} From e3d4b0569005ad6a5a0e4f9b3c0fbfaf1d9af778 Mon Sep 17 00:00:00 2001 From: junoade Date: Fri, 2 Jan 2026 05:48:44 +0900 Subject: [PATCH 13/13] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20-=20=EC=9D=BC=EA=B0=84,?= =?UTF-8?q?=20=EC=A3=BC=EA=B0=84,=20=EC=9B=94=EA=B0=84=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EC=A0=9C=EA=B3=B5=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전략 패턴 적용 --- .../application/ranking/RankingPeriod.java | 7 ++ .../application/ranking/RankingQuery.java | 10 +++ .../ranking/RankingQueryService.java | 30 +++---- .../ranking/strategy/DailyRankingFetcher.java | 39 ++++++++++ .../strategy/MonthlyRankingFetcher.java | 29 +++++++ .../strategy/RankingFetchStrategy.java | 12 +++ .../RankingFetchStrategyResolver.java | 78 +++++++++++++++++++ .../strategy/WeeklyRankingFetcher.java | 29 +++++++ .../api/product/ProductV1Controller.java | 3 +- .../api/ranking/RankingV1Controller.java | 4 +- 10 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQuery.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/DailyRankingFetcher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/MonthlyRankingFetcher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategy.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategyResolver.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/WeeklyRankingFetcher.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java new file mode 100644 index 000000000..862b72b32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java @@ -0,0 +1,7 @@ +package com.loopers.application.ranking; + +public enum RankingPeriod { + DAILY, + WEEKLY, + MONTHLY +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQuery.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQuery.java new file mode 100644 index 000000000..d92863d4c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQuery.java @@ -0,0 +1,10 @@ +package com.loopers.application.ranking; + +import java.time.LocalDate; + +public record RankingQuery( + RankingPeriod period, + String key, + LocalDate date, + int limit +) { } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java index f881e18ef..43b1abec3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java @@ -1,10 +1,8 @@ package com.loopers.application.ranking; -import com.loopers.application.like.event.ProductLikeEvent; import com.loopers.application.product.ProductLikeSummary; import com.loopers.application.product.ProductQueryService; -import com.loopers.domain.product.ProductSortType; -import com.loopers.ranking.DailyRankingResponse; +import com.loopers.application.ranking.strategy.RankingFetchStrategyResolver; import com.loopers.ranking.RankingEntry; import com.loopers.ranking.RankingZSetRepository; import com.loopers.support.error.CoreException; @@ -16,8 +14,6 @@ import java.time.LocalDate; import java.time.ZoneId; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.OptionalDouble; @@ -29,17 +25,20 @@ public class RankingQueryService { private final RankingZSetRepository rankingZSetRepository; private final ProductQueryService productQueryService; + private final RankingFetchStrategyResolver rankingResolver; + @Transactional(readOnly = true) - public RankingQueryResponse getDailyPopularProducts(String date, int size) { - LocalDate target = initLocalDate(date); + public RankingQueryResponse getDailyPopularProducts(RankingPeriod period, String date, int size) { + log.debug("Get {} popular products for {}", period, date); - int limit = (size <= 0) ? 20 : Math.min(size, 100); + RankingFetchStrategyResolver.Resolved resolved = rankingResolver.resolve(period, date, size); + RankingQuery rankingQuery = resolved.rankingQuery(); - List rankingEntries = rankingZSetRepository.findTopDailyAllByLimit(target, limit); + List rankingEntries = resolved.policy().fetchRankingEntries(rankingQuery.key(), rankingQuery.limit()); List productLikeSummaries = findProductSummaryFrom(rankingEntries); return new RankingQueryResponse( - target, + rankingQuery.date(), rankingEntries, productLikeSummaries ); @@ -51,17 +50,6 @@ public OptionalDouble getDailyRankingScore(Long productId) { return rankingZSetRepository.findDailyRanking(now, productId); } - private boolean hasValidDate(String date) { - return date == null || date.isBlank(); - } - - private LocalDate initLocalDate(String date) { - return (hasValidDate(date)) - ? LocalDate.now(ZoneId.systemDefault()) - : LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE); - - } - private List findProductSummaryFrom(List rankingEntries) { List result = new ArrayList<>(); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/DailyRankingFetcher.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/DailyRankingFetcher.java new file mode 100644 index 000000000..5a0d28ed1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/DailyRankingFetcher.java @@ -0,0 +1,39 @@ +package com.loopers.application.ranking.strategy; + +import com.loopers.application.ranking.RankingPeriod; +import com.loopers.ranking.RankingEntry; +import com.loopers.ranking.RankingZSetRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class DailyRankingFetcher implements RankingFetchStrategy { + private final RankingZSetRepository rankingZSetRepository; + + @Override + public RankingPeriod getRankingPeriod() { + return RankingPeriod.DAILY; + } + + @Override + public List fetchRankingEntries(String key, int limit) { + LocalDate target = initLocalDate(key); + return rankingZSetRepository.findTopDailyAllByLimit(target, limit); + } + + private LocalDate initLocalDate(String date) { + return (hasValidDate(date)) + ? LocalDate.now(ZoneId.systemDefault()) + : LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE); + } + + private boolean hasValidDate(String date) { + return date == null || date.isBlank(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/MonthlyRankingFetcher.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/MonthlyRankingFetcher.java new file mode 100644 index 000000000..62c936e66 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/MonthlyRankingFetcher.java @@ -0,0 +1,29 @@ +package com.loopers.application.ranking.strategy; + +import com.loopers.application.ranking.RankingPeriod; +import com.loopers.infrastructure.ranking.ProductRankMonthlyRepository; +import com.loopers.ranking.RankingEntry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MonthlyRankingFetcher implements RankingFetchStrategy { + private final ProductRankMonthlyRepository monthlyRankingRepository; + + @Override + public RankingPeriod getRankingPeriod() { + return RankingPeriod.MONTHLY; + } + + @Override + public List fetchRankingEntries(String key, int limit) { + log.debug("Fetching ranking entries for key {}", key); + return monthlyRankingRepository.findTopByYearMonth(key, PageRequest.of(0, limit)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategy.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategy.java new file mode 100644 index 000000000..6d8a9f51e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategy.java @@ -0,0 +1,12 @@ +package com.loopers.application.ranking.strategy; + +import com.loopers.application.ranking.RankingPeriod; +import com.loopers.ranking.RankingEntry; + +import java.util.List; + + +public interface RankingFetchStrategy { + RankingPeriod getRankingPeriod(); + List fetchRankingEntries(String key, int limit); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategyResolver.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategyResolver.java new file mode 100644 index 000000000..1b56cde60 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategyResolver.java @@ -0,0 +1,78 @@ +package com.loopers.application.ranking.strategy; + +import com.loopers.application.ranking.RankingPeriod; +import com.loopers.application.ranking.RankingQuery; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class RankingFetchStrategyResolver { + private static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.BASIC_ISO_DATE; + private static final DateTimeFormatter YYYYMM = DateTimeFormatter.ofPattern("yyyyMM"); + private final Map policies; + + public RankingFetchStrategyResolver(List policies) { + this.policies = policies.stream() + .collect(Collectors.toMap(RankingFetchStrategy::getRankingPeriod, Function.identity())); + } + + /** + * 랭킹 조회 방법을 선택합니다. + * @param period + * @param date + * @param size + * @return + */ + public Resolved resolve(RankingPeriod period, String date, int size) { + LocalDate target = initLocalDate(date); + int limit = normalizeSize(size); + + String key = switch (period) { + case DAILY -> target.format(YYYYMMDD); + case WEEKLY -> yearWeekKey(target); // 2026-W01 + case MONTHLY -> target.format(YYYYMM); // 202601 + }; + + RankingFetchStrategy policy = policies.get(period); + if (policy == null) throw new IllegalArgumentException("Unsupported period: " + period); + + return new Resolved(new RankingQuery(period, key, target, limit), policy); + } + + public record Resolved(RankingQuery rankingQuery, RankingFetchStrategy policy) {} + + /** + * 최대 상한선 TOP-100 으로 설정 + * @param size + * @return + */ + private int normalizeSize(int size) { + if (size <= 0) return 20; + return Math.min(size, 100); + } + + private LocalDate initLocalDate(String date) { + return (hasValidDate(date)) + ? LocalDate.now(ZoneId.systemDefault()) + : LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE); + } + + private String yearWeekKey(LocalDate date) { + WeekFields wf = WeekFields.ISO; + int y = date.get(wf.weekBasedYear()); + int w = date.get(wf.weekOfWeekBasedYear()); + return "%d-W%02d".formatted(y, w); + } + + private boolean hasValidDate(String date) { + return date == null || date.isBlank(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/WeeklyRankingFetcher.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/WeeklyRankingFetcher.java new file mode 100644 index 000000000..440e0082a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/WeeklyRankingFetcher.java @@ -0,0 +1,29 @@ +package com.loopers.application.ranking.strategy; + +import com.loopers.application.ranking.RankingPeriod; +import com.loopers.infrastructure.ranking.ProductRankWeeklyRepository; +import com.loopers.ranking.RankingEntry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WeeklyRankingFetcher implements RankingFetchStrategy { + private final ProductRankWeeklyRepository weeklyRankingRepository; + + @Override + public RankingPeriod getRankingPeriod() { + return RankingPeriod.WEEKLY; + } + + @Override + public List fetchRankingEntries(String key, int limit) { + log.debug("Fetching ranking entries for key {}", key); + return weeklyRankingRepository.findTopByYearWeek(key, PageRequest.of(0, limit)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index c766297f2..70fa24b22 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -4,10 +4,9 @@ import com.loopers.application.product.ProductQueryService; import com.loopers.application.ranking.RankingQueryService; import com.loopers.domain.product.ProductSortType; -import com.loopers.ranking.RankingEntry; -import com.loopers.support.tracking.general.UserActionType; import com.loopers.interfaces.api.ApiResponse; import com.loopers.support.tracking.annotation.TrackUserAction; +import com.loopers.support.tracking.general.UserActionType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index abaf729d5..21400912f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.ranking; +import com.loopers.application.ranking.RankingPeriod; import com.loopers.application.ranking.RankingQueryResponse; import com.loopers.application.ranking.RankingQueryService; import lombok.RequiredArgsConstructor; @@ -17,10 +18,11 @@ public class RankingV1Controller { @GetMapping public RankingQueryResponse getDailyRanking( + @RequestParam(defaultValue = "DAILY", name = "period") RankingPeriod period, @RequestParam(required = false, name = "date") String date, @RequestParam(defaultValue = "20", name = "size") int size ) { - return rankingQueryService.getDailyPopularProducts(date, size); + return rankingQueryService.getDailyPopularProducts(period, date, size); } }