From 60debbefd4307318127d22318ef290598e55872c Mon Sep 17 00:00:00 2001 From: hubtwork Date: Wed, 31 Dec 2025 16:09:54 +0900 Subject: [PATCH 1/7] =?UTF-8?q?commerce-batch=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98=EB=A9=B0,=20=EB=8D=B0?= =?UTF-8?q?=EB=AA=A8=20Batch=20Job=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-batch/build.gradle.kts | 21 +++++ .../com/loopers/CommerceBatchApplication.java | 24 ++++++ .../loopers/batch/job/demo/DemoJobConfig.java | 48 ++++++++++++ .../batch/job/demo/step/DemoTasklet.java | 32 ++++++++ .../loopers/batch/listener/ChunkListener.java | 21 +++++ .../loopers/batch/listener/JobListener.java | 53 +++++++++++++ .../batch/listener/StepMonitorListener.java | 44 +++++++++++ .../src/main/resources/application.yml | 54 +++++++++++++ .../loopers/CommerceBatchApplicationTest.java | 10 +++ .../com/loopers/job/demo/DemoJobE2ETest.java | 76 +++++++++++++++++++ settings.gradle.kts | 1 + 11 files changed, 384 insertions(+) create mode 100644 apps/commerce-batch/build.gradle.kts create mode 100644 apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java create mode 100644 apps/commerce-batch/src/main/resources/application.yml create mode 100644 apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..b22b6477c --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,21 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java new file mode 100644 index 000000000..e5005c373 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,24 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +import java.util.TimeZone; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceBatchApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); + System.exit(exitCode); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java new file mode 100644 index 000000000..7c486483f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java @@ -0,0 +1,48 @@ +package com.loopers.batch.job.demo; + +import com.loopers.batch.job.demo.step.DemoTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class DemoJobConfig { + public static final String JOB_NAME = "demoJob"; + private static final String STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DemoTasklet demoTasklet; + + @Bean(JOB_NAME) + public Job demoJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(categorySyncStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_DEMO_SIMPLE_TASK_NAME) + public Step categorySyncStep() { + return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) + .tasklet(demoTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java new file mode 100644 index 000000000..800fe5a03 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java @@ -0,0 +1,32 @@ +package com.loopers.batch.job.demo.step; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class DemoTasklet implements Tasklet { + @Value("#{jobParameters['requestDate']}") + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + if (requestDate == null) { + throw new RuntimeException("requestDate is null"); + } + System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); + Thread.sleep(1000); + System.out.println("Demo Tasklet 작업 완료"); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java new file mode 100644 index 000000000..10b09b8fc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java @@ -0,0 +1,21 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.annotation.AfterChunk; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ChunkListener { + + @AfterChunk + void afterChunk(ChunkContext chunkContext) { + log.info( + "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + + "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java new file mode 100644 index 000000000..cb5c8bebd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -0,0 +1,53 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.annotation.AfterJob; +import org.springframework.batch.core.annotation.BeforeJob; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JobListener { + + @BeforeJob + void beforeJob(JobExecution jobExecution) { + log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); + jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); + } + + @AfterJob + void afterJob(JobExecution jobExecution) { + var startTime = jobExecution.getExecutionContext().getLong("startTime"); + var endTime = System.currentTimeMillis(); + + var startDateTime = Instant.ofEpochMilli(startTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + var endDateTime = Instant.ofEpochMilli(endTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + var totalTime = endTime - startTime; + var duration = Duration.ofMillis(totalTime); + var hours = duration.toHours(); + var minutes = duration.toMinutes() % 60; + var seconds = duration.getSeconds() % 60; + + var message = String.format( + """ + *Start Time:* %s + *End Time:* %s + *Total Time:* %d시간 %d분 %d초 + """, startDateTime, endDateTime, hours, minutes, seconds + ).trim(); + + log.info(message); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java new file mode 100644 index 000000000..4f22f40b0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -0,0 +1,44 @@ +package com.loopers.batch.listener; + +import jakarta.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Component +public class StepMonitorListener implements StepExecutionListener { + + @Override + public void beforeStep(@Nonnull StepExecution stepExecution) { + log.info("Step '{}' 시작", stepExecution.getStepName()); + } + + @Override + public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); + var exceptions = stepExecution.getFailureExceptions().stream() + .map(Throwable::getMessage) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + log.info( + """ + [에러 발생] + jobName: {} + exceptions: + {} + """.trim(), jobName, exceptions + ); + // error 발생 시 slack 등 다른 채널로 모니터 전송 + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..9aa0d760a --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,54 @@ +spring: + main: + web-application-type: none + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + batch: + job: + name: ${job.name:NONE} + jdbc: + initialize-schema: never + +management: + health: + defaults: + enabled: false + +--- +spring: + config: + activate: + on-profile: local, test + batch: + jdbc: + initialize-schema: always + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java new file mode 100644 index 000000000..c5e3bc7a3 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -0,0 +1,10 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class CommerceBatchApplicationTest { + @Test + void contextLoads() {} +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java new file mode 100644 index 000000000..dafe59a18 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java @@ -0,0 +1,76 @@ +package com.loopers.job.demo; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) +class DemoJobE2ETest { + + // IDE 정적 분석 상 [SpringBatchTest] 의 주입보다 [SpringBootTest] 의 주입이 우선되어, 해당 컴포넌트는 없으므로 오류처럼 보일 수 있음. + // [SpringBatchTest] 자체가 Scope 기반으로 주입하기 때문에 정상 동작함. + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(DemoJobConfig.JOB_NAME) + private Job job; + + @BeforeEach + void beforeEach() { + + } + + @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") + @Test + void shouldNotSaveCategories_whenApiError() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) + ); + } + + @DisplayName("demoJob 배치가 정상적으로 실행된다.") + @Test + void success() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.now()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()) + ); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d26f95453..7c31b65aa 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 db866b4e182e17acabd5bc87b2a2165b933e33aa Mon Sep 17 00:00:00 2001 From: hubtwork Date: Wed, 31 Dec 2025 16:16:04 +0900 Subject: [PATCH 2/7] =?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 24ba2521b81fb4ba271b367b4b0c7092c2fe4f44 Mon Sep 17 00:00:00 2001 From: minwooKang Date: Wed, 31 Dec 2025 18:58:37 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20Round=2010=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=EB=AC=BC=EC=9D=84=20upstream=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=EC=97=90=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 10에서 작업한 주간/월간 랭킹 배치 Job을 upstream 배치 템플릿 구조에 통합합니다. 변경 사항: - WeeklyRankingJobConfig, MonthlyRankingJobConfig 추가 - ProductMetricsAggregateReader 추가 (집계 쿼리) - RankingScoreProcessor 추가 (점수 계산 로직) - WeeklyRankingWriter, MonthlyRankingWriter 추가 - 도메인 엔티티 추가: WeeklyProductRank, MonthlyProductRank - 리포지토리 추가: WeeklyRankRepository, MonthlyRankRepository - JpaConfig 수정: com.loopers.domain 패키지 스캔 추가 - CommerceBatchApplicationTest 수정: Job 자동 실행 비활성화 Upstream의 Listener 구조(JobListener, StepMonitorListener, ChunkListener)를 유지하면서 Round 10 랭킹 Job을 추가했습니다. --- .../com/loopers/batch/config/BatchConfig.java | 13 ++ .../batch/config/MonthlyRankingJobConfig.java | 139 ++++++++++++++ .../batch/config/WeeklyRankingJobConfig.java | 139 ++++++++++++++ .../processor/RankingScoreProcessor.java | 98 ++++++++++ .../reader/ProductMetricsAggregateReader.java | 181 ++++++++++++++++++ .../batch/writer/MonthlyRankingWriter.java | 51 +++++ .../batch/writer/WeeklyRankingWriter.java | 51 +++++ .../domain/dto/ProductRankingAggregation.java | 47 +++++ .../domain/rank/MonthlyProductRank.java | 100 ++++++++++ .../domain/rank/MonthlyRankRepository.java | 35 ++++ .../domain/rank/WeeklyProductRank.java | 100 ++++++++++ .../domain/rank/WeeklyRankRepository.java | 35 ++++ .../loopers/CommerceBatchApplicationTest.java | 2 + .../com/loopers/config/jpa/JpaConfig.java | 2 +- 14 files changed, 992 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/config/MonthlyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/config/WeeklyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/processor/RankingScoreProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/writer/MonthlyRankingWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/writer/WeeklyRankingWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java new file mode 100644 index 000000000..0a09bfa75 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java @@ -0,0 +1,13 @@ +package com.loopers.batch.config; + +import org.springframework.context.annotation.Configuration; + +/** + * Base configuration class for Spring Batch. + * + *

Spring Boot 3.x auto-configures Spring Batch, so @EnableBatchProcessing is not needed. + *

This class can be used for common batch configuration beans if needed. + */ +@Configuration +public class BatchConfig { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/config/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/config/MonthlyRankingJobConfig.java new file mode 100644 index 000000000..d43d12fca --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/config/MonthlyRankingJobConfig.java @@ -0,0 +1,139 @@ +package com.loopers.batch.config; + +import com.loopers.batch.processor.RankingScoreProcessor; +import com.loopers.batch.reader.ProductMetricsAggregateReader; +import com.loopers.batch.writer.MonthlyRankingWriter; +import com.loopers.domain.dto.ProductRankingAggregation; +import com.loopers.domain.rank.MonthlyProductRank; +import com.loopers.domain.rank.MonthlyRankRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Configuration class for monthly ranking aggregation batch job. + * + *

This job aggregates product metrics data on a monthly basis and + * stores the top N rankings in the materialized view table. + * + *

Job execution example: + *

+ * java -jar commerce-batch.jar \
+ *   --job.name=monthlyRankingJob \
+ *   yearMonth=2025-01
+ * 
+ * + *

Chunk-oriented processing flow: + *

    + *
  1. Reader: Aggregate product_metrics by month
  2. + *
  3. Processor: Calculate ranking scores
  4. + *
  5. Writer: Save to mv_product_rank_monthly
  6. + *
+ */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class MonthlyRankingJobConfig { + + private static final int CHUNK_SIZE = 100; + private static final int TOP_N = 100; + + private final EntityManager entityManager; + private final MonthlyRankRepository monthlyRankRepository; + + /** + * Defines the monthly ranking job. + * + * @param jobRepository the Spring Batch job repository + * @param monthlyRankingStep the step to execute + * @return configured Job instance + */ + @Bean + public Job monthlyRankingJob( + JobRepository jobRepository, + Step monthlyRankingStep + ) { + return new JobBuilder("monthlyRankingJob", jobRepository) + .start(monthlyRankingStep) + .build(); + } + + /** + * Defines the monthly ranking step with chunk-oriented processing. + * + * @param jobRepository the Spring Batch job repository + * @param transactionManager the transaction manager + * @param yearMonth the target month (injected from job parameters) + * @return configured Step instance + */ + @Bean + @JobScope + public Step monthlyRankingStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + @Value("#{jobParameters['yearMonth']}") String yearMonth + ) { + log.info("Initializing monthly ranking step: yearMonth={}", yearMonth); + + return new StepBuilder("monthlyRankingStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyMetricsReader(yearMonth)) + .processor(monthlyRankingProcessor(yearMonth)) + .writer(monthlyRankingWriter()) + .build(); + } + + /** + * Creates an ItemReader for monthly metrics aggregation. + * + * @param yearMonth the target month + * @return configured ItemReader + */ + @Bean + @StepScope + public ItemReader monthlyMetricsReader( + @Value("#{jobParameters['yearMonth']}") String yearMonth + ) { + return new ProductMetricsAggregateReader(entityManager, yearMonth, "MONTHLY", TOP_N); + } + + /** + * Creates an ItemProcessor for ranking score calculation. + * + * @param yearMonth the target month + * @return configured ItemProcessor + */ + @Bean + @StepScope + public ItemProcessor monthlyRankingProcessor( + @Value("#{jobParameters['yearMonth']}") String yearMonth + ) { + RankingScoreProcessor processor = new RankingScoreProcessor("MONTHLY", yearMonth); + return item -> (MonthlyProductRank) processor.process(item); + } + + /** + * Creates an ItemWriter for persisting monthly rankings. + * + * @return configured ItemWriter + */ + @Bean + @StepScope + public ItemWriter monthlyRankingWriter() { + return new MonthlyRankingWriter(monthlyRankRepository); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/config/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/config/WeeklyRankingJobConfig.java new file mode 100644 index 000000000..de6d18e94 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/config/WeeklyRankingJobConfig.java @@ -0,0 +1,139 @@ +package com.loopers.batch.config; + +import com.loopers.batch.processor.RankingScoreProcessor; +import com.loopers.batch.reader.ProductMetricsAggregateReader; +import com.loopers.batch.writer.WeeklyRankingWriter; +import com.loopers.domain.dto.ProductRankingAggregation; +import com.loopers.domain.rank.WeeklyProductRank; +import com.loopers.domain.rank.WeeklyRankRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Configuration class for weekly ranking aggregation batch job. + * + *

This job aggregates product metrics data on a weekly basis and + * stores the top N rankings in the materialized view table. + * + *

Job execution example: + *

+ * java -jar commerce-batch.jar \
+ *   --job.name=weeklyRankingJob \
+ *   yearWeek=2025-W01
+ * 
+ * + *

Chunk-oriented processing flow: + *

    + *
  1. Reader: Aggregate product_metrics by week
  2. + *
  3. Processor: Calculate ranking scores
  4. + *
  5. Writer: Save to mv_product_rank_weekly
  6. + *
+ */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class WeeklyRankingJobConfig { + + private static final int CHUNK_SIZE = 100; + private static final int TOP_N = 100; + + private final EntityManager entityManager; + private final WeeklyRankRepository weeklyRankRepository; + + /** + * Defines the weekly ranking job. + * + * @param jobRepository the Spring Batch job repository + * @param weeklyRankingStep the step to execute + * @return configured Job instance + */ + @Bean + public Job weeklyRankingJob( + JobRepository jobRepository, + Step weeklyRankingStep + ) { + return new JobBuilder("weeklyRankingJob", jobRepository) + .start(weeklyRankingStep) + .build(); + } + + /** + * Defines the weekly ranking step with chunk-oriented processing. + * + * @param jobRepository the Spring Batch job repository + * @param transactionManager the transaction manager + * @param yearWeek the target week in ISO format (injected from job parameters) + * @return configured Step instance + */ + @Bean + @JobScope + public Step weeklyRankingStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + @Value("#{jobParameters['yearWeek']}") String yearWeek + ) { + log.info("Initializing weekly ranking step: yearWeek={}", yearWeek); + + return new StepBuilder("weeklyRankingStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyMetricsReader(yearWeek)) + .processor(weeklyRankingProcessor(yearWeek)) + .writer(weeklyRankingWriter()) + .build(); + } + + /** + * Creates an ItemReader for weekly metrics aggregation. + * + * @param yearWeek the target week + * @return configured ItemReader + */ + @Bean + @StepScope + public ItemReader weeklyMetricsReader( + @Value("#{jobParameters['yearWeek']}") String yearWeek + ) { + return new ProductMetricsAggregateReader(entityManager, yearWeek, "WEEKLY", TOP_N); + } + + /** + * Creates an ItemProcessor for ranking score calculation. + * + * @param yearWeek the target week + * @return configured ItemProcessor + */ + @Bean + @StepScope + public ItemProcessor weeklyRankingProcessor( + @Value("#{jobParameters['yearWeek']}") String yearWeek + ) { + RankingScoreProcessor processor = new RankingScoreProcessor("WEEKLY", yearWeek); + return item -> (WeeklyProductRank) processor.process(item); + } + + /** + * Creates an ItemWriter for persisting weekly rankings. + * + * @return configured ItemWriter + */ + @Bean + @StepScope + public ItemWriter weeklyRankingWriter() { + return new WeeklyRankingWriter(weeklyRankRepository); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/processor/RankingScoreProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/processor/RankingScoreProcessor.java new file mode 100644 index 000000000..2f7def084 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/processor/RankingScoreProcessor.java @@ -0,0 +1,98 @@ +package com.loopers.batch.processor; + +import com.loopers.domain.dto.ProductRankingAggregation; +import com.loopers.domain.rank.MonthlyProductRank; +import com.loopers.domain.rank.WeeklyProductRank; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; + +/** + * ItemProcessor for converting aggregated metrics into ranking entities. + * + *

This processor transforms ProductRankingAggregation DTOs into either + * WeeklyProductRank or MonthlyProductRank entities based on the period type. + * The ranking score is calculated using weighted metrics. + * + *

Score calculation formula: + *

+ * score = (viewCount * WEIGHT_VIEW) +
+ *         (likeCount * WEIGHT_LIKE) +
+ *         (orderCount * WEIGHT_ORDER * log10(salesAmount + 1))
+ * 
+ * + * where: + *
    + *
  • WEIGHT_VIEW = 0.1
  • + *
  • WEIGHT_LIKE = 0.2
  • + *
  • WEIGHT_ORDER = 0.6
  • + *
+ */ +@Slf4j +public class RankingScoreProcessor implements ItemProcessor { + + private static final double WEIGHT_VIEW = 0.1; + private static final double WEIGHT_LIKE = 0.2; + private static final double WEIGHT_ORDER = 0.6; + + private final String periodType; + private final String period; + + /** + * Constructs a new RankingScoreProcessor. + * + * @param periodType the type of period ("WEEKLY" or "MONTHLY") + * @param period the period string (e.g., "2025-W01" or "2025-01") + */ + public RankingScoreProcessor(String periodType, String period) { + this.periodType = periodType; + this.period = period; + } + + @Override + public Object process(ProductRankingAggregation item) { + double score = calculateScore(item); + + log.debug("Processing ranking: productId={}, rank={}, score={}", + item.getProductId(), item.getRankPosition(), score); + + if ("WEEKLY".equals(periodType)) { + return WeeklyProductRank.builder() + .productId(item.getProductId()) + .yearWeek(period) + .rankPosition(item.getRankPosition()) + .totalScore(score) + .likeCount(item.getLikeCount()) + .viewCount(item.getViewCount()) + .orderCount(item.getOrderCount()) + .salesAmount(item.getSalesAmount()) + .build(); + } else { + return MonthlyProductRank.builder() + .productId(item.getProductId()) + .yearMonth(period) + .rankPosition(item.getRankPosition()) + .totalScore(score) + .likeCount(item.getLikeCount()) + .viewCount(item.getViewCount()) + .orderCount(item.getOrderCount()) + .salesAmount(item.getSalesAmount()) + .build(); + } + } + + /** + * Calculates the ranking score based on weighted metrics. + * + *

Uses logarithmic normalization for sales amount to prevent + * extreme values from dominating the score. + * + * @param agg the aggregated metrics + * @return the calculated score + */ + private double calculateScore(ProductRankingAggregation agg) { + double salesAmountValue = agg.getSalesAmount() != null ? agg.getSalesAmount().doubleValue() : 0.0; + return (agg.getViewCount() * WEIGHT_VIEW) + + (agg.getLikeCount() * WEIGHT_LIKE) + + (agg.getOrderCount() * WEIGHT_ORDER * Math.log10(salesAmountValue + 1)); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java new file mode 100644 index 000000000..c955d1666 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java @@ -0,0 +1,181 @@ +package com.loopers.batch.reader; + +import com.loopers.domain.dto.ProductRankingAggregation; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemReader; + +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * ItemReader for aggregating product metrics over a time period. + * + *

This reader fetches product_metrics data and aggregates it by product ID + * for a specific time period (weekly or monthly). The aggregated results include + * calculated ranking scores based on weighted metrics. + * + *

Scoring formula: + *

+ * score = (viewCount * 0.1) +
+ *         (likeCount * 0.2) +
+ *         (orderCount * 0.6 * log10(salesAmount + 1))
+ * 
+ */ +@Slf4j +public class ProductMetricsAggregateReader implements ItemReader { + + private final EntityManager entityManager; + private final String period; + private final String periodType; + private final int topN; + private Iterator resultIterator; + + /** + * Constructs a new ProductMetricsAggregateReader. + * + * @param entityManager the JPA entity manager + * @param period the period string (e.g., "2025-W01" for weekly, "2025-01" for monthly) + * @param periodType the type of period ("WEEKLY" or "MONTHLY") + * @param topN the maximum number of top rankings to fetch + */ + public ProductMetricsAggregateReader( + EntityManager entityManager, + String period, + String periodType, + int topN + ) { + this.entityManager = entityManager; + this.period = period; + this.periodType = periodType; + this.topN = topN; + } + + @Override + public ProductRankingAggregation read() { + if (resultIterator == null) { + resultIterator = fetchAggregatedData().iterator(); + log.info("Aggregated data fetched: period={}, type={}, count={}", + period, periodType, resultIterator.hasNext() ? "available" : "empty"); + } + + return resultIterator.hasNext() ? resultIterator.next() : null; + } + + /** + * Fetches and aggregates product metrics for the configured period. + * + * @return list of aggregated ranking data with calculated scores + */ + private List fetchAggregatedData() { + DateRange dateRange = calculateDateRange(period, periodType); + + String sql = """ + SELECT + product_id, + SUM(like_count) as total_likes, + SUM(view_count) as total_views, + SUM(order_count) as total_orders, + SUM(sales_amount) as total_sales, + ( + SUM(view_count) * 0.1 + + SUM(like_count) * 0.2 + + SUM(order_count) * 0.6 * LOG10(SUM(sales_amount) + 1) + ) as total_score + FROM product_metrics + WHERE created_at >= :startDate + AND created_at < :endDate + GROUP BY product_id + ORDER BY total_score DESC + LIMIT :topN + """; + + Query query = entityManager.createNativeQuery(sql) + .setParameter("startDate", dateRange.start()) + .setParameter("endDate", dateRange.end()) + .setParameter("topN", topN); + + @SuppressWarnings("unchecked") + List results = query.getResultList(); + + return IntStream.range(0, results.size()) + .mapToObj(i -> { + Object[] row = results.get(i); + return new ProductRankingAggregation( + ((Number) row[0]).longValue(), + ((Number) row[1]).intValue(), + ((Number) row[2]).intValue(), + ((Number) row[3]).intValue(), + (BigDecimal) row[4], + i + 1 + ); + }) + .collect(Collectors.toList()); + } + + /** + * Calculates the date range for the given period. + * + * @param period the period string + * @param type the period type + * @return the date range with start and end dates + */ + private DateRange calculateDateRange(String period, String type) { + if ("WEEKLY".equals(type)) { + return calculateWeeklyDateRange(period); + } else { + return calculateMonthlyDateRange(period); + } + } + + /** + * Calculates weekly date range from ISO week format. + * + * @param yearWeek the year-week in format "YYYY-Wnn" + * @return date range for the week + */ + private DateRange calculateWeeklyDateRange(String yearWeek) { + int year = Integer.parseInt(yearWeek.substring(0, 4)); + int week = Integer.parseInt(yearWeek.substring(6)); + + LocalDate firstDayOfYear = LocalDate.of(year, 1, 1); + LocalDate firstMonday = firstDayOfYear.with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY)); + + if (firstMonday.isAfter(firstDayOfYear)) { + firstMonday = firstMonday.minusWeeks(1); + } + + LocalDate startOfWeek = firstMonday.plusWeeks(week - 1); + LocalDate endOfWeek = startOfWeek.plusWeeks(1); + + return new DateRange(startOfWeek, endOfWeek); + } + + /** + * Calculates monthly date range. + * + * @param yearMonth the year-month in format "YYYY-MM" + * @return date range for the month + */ + private DateRange calculateMonthlyDateRange(String yearMonth) { + LocalDate startOfMonth = LocalDate.parse(yearMonth + "-01"); + LocalDate endOfMonth = startOfMonth.plusMonths(1); + + return new DateRange(startOfMonth, endOfMonth); + } + + /** + * Represents a date range with start and end dates. + * + * @param start the start date (inclusive) + * @param end the end date (exclusive) + */ + private record DateRange(LocalDate start, LocalDate end) {} +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/writer/MonthlyRankingWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/MonthlyRankingWriter.java new file mode 100644 index 000000000..6515cdfd7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/MonthlyRankingWriter.java @@ -0,0 +1,51 @@ +package com.loopers.batch.writer; + +import com.loopers.domain.rank.MonthlyProductRank; +import com.loopers.domain.rank.MonthlyRankRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.transaction.annotation.Transactional; + +/** + * ItemWriter for persisting monthly ranking data. + * + *

This writer saves MonthlyProductRank entities to the database. + * It uses a delete-and-insert strategy to ensure data consistency + * by removing existing data for the period before inserting new rankings. + */ +@Slf4j +@RequiredArgsConstructor +public class MonthlyRankingWriter implements ItemWriter { + + private final MonthlyRankRepository repository; + + /** + * Writes a chunk of monthly ranking data to the database. + * + *

Implementation strategy: + *

    + *
  1. Delete all existing rankings for the target month
  2. + *
  3. Insert new aggregated rankings
  4. + *
+ * + * @param chunk the chunk of rankings to write + */ + @Override + @Transactional + public void write(Chunk chunk) { + if (chunk.isEmpty()) { + log.warn("Empty chunk received, skipping write operation"); + return; + } + + String yearMonth = chunk.getItems().get(0).getYearMonth(); + log.info("Writing monthly rankings: yearMonth={}, count={}", yearMonth, chunk.size()); + + repository.deleteByYearMonth(yearMonth); + repository.saveAll(chunk.getItems()); + + log.info("Successfully saved {} monthly rankings for month {}", chunk.size(), yearMonth); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/writer/WeeklyRankingWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/WeeklyRankingWriter.java new file mode 100644 index 000000000..e1da19792 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/WeeklyRankingWriter.java @@ -0,0 +1,51 @@ +package com.loopers.batch.writer; + +import com.loopers.domain.rank.WeeklyProductRank; +import com.loopers.domain.rank.WeeklyRankRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.transaction.annotation.Transactional; + +/** + * ItemWriter for persisting weekly ranking data. + * + *

This writer saves WeeklyProductRank entities to the database. + * It uses a delete-and-insert strategy to ensure data consistency + * by removing existing data for the period before inserting new rankings. + */ +@Slf4j +@RequiredArgsConstructor +public class WeeklyRankingWriter implements ItemWriter { + + private final WeeklyRankRepository repository; + + /** + * Writes a chunk of weekly ranking data to the database. + * + *

Implementation strategy: + *

    + *
  1. Delete all existing rankings for the target week
  2. + *
  3. Insert new aggregated rankings
  4. + *
+ * + * @param chunk the chunk of rankings to write + */ + @Override + @Transactional + public void write(Chunk chunk) { + if (chunk.isEmpty()) { + log.warn("Empty chunk received, skipping write operation"); + return; + } + + String yearWeek = chunk.getItems().get(0).getYearWeek(); + log.info("Writing weekly rankings: yearWeek={}, count={}", yearWeek, chunk.size()); + + repository.deleteByYearWeek(yearWeek); + repository.saveAll(chunk.getItems()); + + log.info("Successfully saved {} weekly rankings for week {}", chunk.size(), yearWeek); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java new file mode 100644 index 000000000..004892907 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java @@ -0,0 +1,47 @@ +package com.loopers.domain.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.math.BigDecimal; + +/** + * Data Transfer Object for product ranking aggregation results. + * + *

This DTO carries aggregated metric data from database queries + * to batch processors for further calculation and ranking assignment. + */ +@Getter +@AllArgsConstructor +public class ProductRankingAggregation { + + /** + * Product identifier + */ + private Long productId; + + /** + * Total like count for the period + */ + private Integer likeCount; + + /** + * Total view count for the period + */ + private Integer viewCount; + + /** + * Total order count for the period + */ + private Integer orderCount; + + /** + * Total sales amount for the period + */ + private BigDecimal salesAmount; + + /** + * Calculated rank position (1-based) + */ + private Integer rankPosition; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java new file mode 100644 index 000000000..6a9ca8615 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java @@ -0,0 +1,100 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Monthly product ranking entity for materialized view. + * + *

This table stores pre-aggregated monthly ranking data for performance optimization. + * Aggregation is performed by Spring Batch jobs and stored here for fast query access. + * + * @see com.loopers.batch.config.MonthlyRankingJobConfig + */ +@Entity +@Table( + name = "mv_product_rank_monthly", + uniqueConstraints = @UniqueConstraint( + name = "uk_product_month", + columnNames = {"product_id", "year_month"} + ), + indexes = { + @Index(name = "idx_year_month_rank", columnList = "year_month, rank_position"), + @Index(name = "idx_year_month_score", columnList = "year_month, total_score DESC") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MonthlyProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + /** + * Year-month format: YYYY-MM (e.g., "2025-01") + */ + @Column(name = "`year_month`", nullable = false, length = 7) + private String yearMonth; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "view_count", nullable = false) + private Integer viewCount; + + @Column(name = "order_count", nullable = false) + private Integer orderCount; + + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Builder + public MonthlyProductRank( + Long productId, + String yearMonth, + Integer rankPosition, + Double totalScore, + Integer likeCount, + Integer viewCount, + Integer orderCount, + BigDecimal salesAmount + ) { + this.productId = productId; + this.yearMonth = yearMonth; + this.rankPosition = rankPosition; + this.totalScore = totalScore; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.orderCount = orderCount; + this.salesAmount = salesAmount; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java new file mode 100644 index 000000000..6a308b95a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java @@ -0,0 +1,35 @@ +package com.loopers.domain.rank; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * Repository interface for MonthlyProductRank entity. + * + *

Provides data access methods for monthly product ranking data. + */ +public interface MonthlyRankRepository extends JpaRepository { + + /** + * Finds all rankings for a specific month ordered by rank position. + * + * @param yearMonth the year-month (e.g., "2025-01") + * @return list of monthly rankings ordered by position + */ + List findByYearMonthOrderByRankPositionAsc(String yearMonth); + + /** + * Deletes all rankings for a specific month. + * + *

Used before inserting new aggregated data to ensure data consistency. + * + * @param yearMonth the year-month to delete + */ + @Modifying + @Query("DELETE FROM MonthlyProductRank m WHERE m.yearMonth = :yearMonth") + void deleteByYearMonth(@Param("yearMonth") String yearMonth); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java new file mode 100644 index 000000000..ef829b253 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java @@ -0,0 +1,100 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Weekly product ranking entity for materialized view. + * + *

This table stores pre-aggregated weekly ranking data for performance optimization. + * Aggregation is performed by Spring Batch jobs and stored here for fast query access. + * + * @see com.loopers.batch.config.WeeklyRankingJobConfig + */ +@Entity +@Table( + name = "mv_product_rank_weekly", + uniqueConstraints = @UniqueConstraint( + name = "uk_product_week", + columnNames = {"product_id", "year_week"} + ), + indexes = { + @Index(name = "idx_year_week_rank", columnList = "year_week, rank_position"), + @Index(name = "idx_year_week_score", columnList = "year_week, total_score DESC") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WeeklyProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + /** + * ISO week format: YYYY-Wnn (e.g., "2025-W01") + */ + @Column(name = "year_week", nullable = false, length = 10) + private String yearWeek; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "view_count", nullable = false) + private Integer viewCount; + + @Column(name = "order_count", nullable = false) + private Integer orderCount; + + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Builder + public WeeklyProductRank( + Long productId, + String yearWeek, + Integer rankPosition, + Double totalScore, + Integer likeCount, + Integer viewCount, + Integer orderCount, + BigDecimal salesAmount + ) { + this.productId = productId; + this.yearWeek = yearWeek; + this.rankPosition = rankPosition; + this.totalScore = totalScore; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.orderCount = orderCount; + this.salesAmount = salesAmount; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java new file mode 100644 index 000000000..98265bf64 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java @@ -0,0 +1,35 @@ +package com.loopers.domain.rank; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * Repository interface for WeeklyProductRank entity. + * + *

Provides data access methods for weekly product ranking data. + */ +public interface WeeklyRankRepository extends JpaRepository { + + /** + * Finds all rankings for a specific week ordered by rank position. + * + * @param yearWeek the year-week in ISO format (e.g., "2025-W01") + * @return list of weekly rankings ordered by position + */ + List findByYearWeekOrderByRankPositionAsc(String yearWeek); + + /** + * Deletes all rankings for a specific week. + * + *

Used before inserting new aggregated data to ensure data consistency. + * + * @param yearWeek the year-week to delete + */ + @Modifying + @Query("DELETE FROM WeeklyProductRank w WHERE w.yearWeek = :yearWeek") + void deleteByYearWeek(@Param("yearWeek") String yearWeek); +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java index c5e3bc7a3..71a907186 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; @SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") public class CommerceBatchApplicationTest { @Test void contextLoads() {} diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java b/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java index 7fad5872b..56917985a 100644 --- a/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java +++ b/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java @@ -8,6 +8,6 @@ @Configuration @EnableTransactionManagement @EntityScan({"com.loopers"}) -@EnableJpaRepositories({"com.loopers.infrastructure"}) +@EnableJpaRepositories({"com.loopers.infrastructure", "com.loopers.domain"}) public class JpaConfig { } From 194cd7c8e0411378fe53728206702189950e5981 Mon Sep 17 00:00:00 2001 From: minwooKang Date: Thu, 1 Jan 2026 21:09:42 +0900 Subject: [PATCH 4/7] =?UTF-8?q?docs:=20=EB=B0=B0=EC=B9=98=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20JavaDoc=20=ED=95=9C=EA=B8=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/batch/config/BatchConfig.java | 6 +- .../batch/config/MonthlyRankingJobConfig.java | 50 ++++++++--------- .../batch/config/WeeklyRankingJobConfig.java | 50 ++++++++--------- .../processor/RankingScoreProcessor.java | 40 ++++++------- .../reader/ProductMetricsAggregateReader.java | 56 +++++++++---------- .../batch/writer/MonthlyRankingWriter.java | 18 +++--- .../batch/writer/WeeklyRankingWriter.java | 18 +++--- .../domain/dto/ProductRankingAggregation.java | 18 +++--- .../domain/rank/MonthlyProductRank.java | 8 +-- .../domain/rank/MonthlyRankRepository.java | 16 +++--- .../domain/rank/WeeklyProductRank.java | 8 +-- .../domain/rank/WeeklyRankRepository.java | 16 +++--- 12 files changed, 152 insertions(+), 152 deletions(-) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java index 0a09bfa75..737123495 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchConfig.java @@ -3,10 +3,10 @@ import org.springframework.context.annotation.Configuration; /** - * Base configuration class for Spring Batch. + * Spring Batch 기본 설정 클래스. * - *

Spring Boot 3.x auto-configures Spring Batch, so @EnableBatchProcessing is not needed. - *

This class can be used for common batch configuration beans if needed. + *

Spring Boot 3.x는 Spring Batch를 자동 설정하므로 @EnableBatchProcessing은 필요하지 않습니다. + *

이 클래스는 필요한 경우 공통 배치 설정 빈을 정의하는 용도로 사용할 수 있습니다. */ @Configuration public class BatchConfig { diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/config/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/config/MonthlyRankingJobConfig.java index d43d12fca..83f10880d 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/config/MonthlyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/config/MonthlyRankingJobConfig.java @@ -25,23 +25,23 @@ import org.springframework.transaction.PlatformTransactionManager; /** - * Configuration class for monthly ranking aggregation batch job. + * 월간 랭킹 집계 배치 작업 설정 클래스. * - *

This job aggregates product metrics data on a monthly basis and - * stores the top N rankings in the materialized view table. + *

이 작업은 상품 지표 데이터를 월간 단위로 집계하여 + * 상위 N개 랭킹을 Materialized View 테이블에 저장합니다. * - *

Job execution example: + *

작업 실행 예시: *

  * java -jar commerce-batch.jar \
  *   --job.name=monthlyRankingJob \
  *   yearMonth=2025-01
  * 
* - *

Chunk-oriented processing flow: + *

Chunk 지향 처리 흐름: *

    - *
  1. Reader: Aggregate product_metrics by month
  2. - *
  3. Processor: Calculate ranking scores
  4. - *
  5. Writer: Save to mv_product_rank_monthly
  6. + *
  7. Reader: product_metrics를 월간 단위로 집계
  8. + *
  9. Processor: 랭킹 점수 계산
  10. + *
  11. Writer: mv_product_rank_monthly에 저장
  12. *
*/ @Slf4j @@ -56,11 +56,11 @@ public class MonthlyRankingJobConfig { private final MonthlyRankRepository monthlyRankRepository; /** - * Defines the monthly ranking job. + * 월간 랭킹 작업을 정의합니다. * - * @param jobRepository the Spring Batch job repository - * @param monthlyRankingStep the step to execute - * @return configured Job instance + * @param jobRepository Spring Batch 작업 저장소 + * @param monthlyRankingStep 실행할 Step + * @return 설정된 Job 인스턴스 */ @Bean public Job monthlyRankingJob( @@ -73,12 +73,12 @@ public Job monthlyRankingJob( } /** - * Defines the monthly ranking step with chunk-oriented processing. + * Chunk 지향 처리를 사용하는 월간 랭킹 Step을 정의합니다. * - * @param jobRepository the Spring Batch job repository - * @param transactionManager the transaction manager - * @param yearMonth the target month (injected from job parameters) - * @return configured Step instance + * @param jobRepository Spring Batch 작업 저장소 + * @param transactionManager 트랜잭션 관리자 + * @param yearMonth 대상 월 (작업 파라미터에서 주입) + * @return 설정된 Step 인스턴스 */ @Bean @JobScope @@ -98,10 +98,10 @@ public Step monthlyRankingStep( } /** - * Creates an ItemReader for monthly metrics aggregation. + * 월간 지표 집계를 위한 ItemReader를 생성합니다. * - * @param yearMonth the target month - * @return configured ItemReader + * @param yearMonth 대상 월 + * @return 설정된 ItemReader */ @Bean @StepScope @@ -112,10 +112,10 @@ public ItemReader monthlyMetricsReader( } /** - * Creates an ItemProcessor for ranking score calculation. + * 랭킹 점수 계산을 위한 ItemProcessor를 생성합니다. * - * @param yearMonth the target month - * @return configured ItemProcessor + * @param yearMonth 대상 월 + * @return 설정된 ItemProcessor */ @Bean @StepScope @@ -127,9 +127,9 @@ public ItemProcessor monthlyRanki } /** - * Creates an ItemWriter for persisting monthly rankings. + * 월간 랭킹 저장을 위한 ItemWriter를 생성합니다. * - * @return configured ItemWriter + * @return 설정된 ItemWriter */ @Bean @StepScope diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/config/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/config/WeeklyRankingJobConfig.java index de6d18e94..9ff740328 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/config/WeeklyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/config/WeeklyRankingJobConfig.java @@ -25,23 +25,23 @@ import org.springframework.transaction.PlatformTransactionManager; /** - * Configuration class for weekly ranking aggregation batch job. + * 주간 랭킹 집계 배치 작업 설정 클래스. * - *

This job aggregates product metrics data on a weekly basis and - * stores the top N rankings in the materialized view table. + *

이 작업은 상품 지표 데이터를 주간 단위로 집계하여 + * 상위 N개 랭킹을 Materialized View 테이블에 저장합니다. * - *

Job execution example: + *

작업 실행 예시: *

  * java -jar commerce-batch.jar \
  *   --job.name=weeklyRankingJob \
  *   yearWeek=2025-W01
  * 
* - *

Chunk-oriented processing flow: + *

Chunk 지향 처리 흐름: *

    - *
  1. Reader: Aggregate product_metrics by week
  2. - *
  3. Processor: Calculate ranking scores
  4. - *
  5. Writer: Save to mv_product_rank_weekly
  6. + *
  7. Reader: product_metrics를 주간 단위로 집계
  8. + *
  9. Processor: 랭킹 점수 계산
  10. + *
  11. Writer: mv_product_rank_weekly에 저장
  12. *
*/ @Slf4j @@ -56,11 +56,11 @@ public class WeeklyRankingJobConfig { private final WeeklyRankRepository weeklyRankRepository; /** - * Defines the weekly ranking job. + * 주간 랭킹 작업을 정의합니다. * - * @param jobRepository the Spring Batch job repository - * @param weeklyRankingStep the step to execute - * @return configured Job instance + * @param jobRepository Spring Batch 작업 저장소 + * @param weeklyRankingStep 실행할 Step + * @return 설정된 Job 인스턴스 */ @Bean public Job weeklyRankingJob( @@ -73,12 +73,12 @@ public Job weeklyRankingJob( } /** - * Defines the weekly ranking step with chunk-oriented processing. + * Chunk 지향 처리를 사용하는 주간 랭킹 Step을 정의합니다. * - * @param jobRepository the Spring Batch job repository - * @param transactionManager the transaction manager - * @param yearWeek the target week in ISO format (injected from job parameters) - * @return configured Step instance + * @param jobRepository Spring Batch 작업 저장소 + * @param transactionManager 트랜잭션 관리자 + * @param yearWeek ISO 형식의 대상 주차 (작업 파라미터에서 주입) + * @return 설정된 Step 인스턴스 */ @Bean @JobScope @@ -98,10 +98,10 @@ public Step weeklyRankingStep( } /** - * Creates an ItemReader for weekly metrics aggregation. + * 주간 지표 집계를 위한 ItemReader를 생성합니다. * - * @param yearWeek the target week - * @return configured ItemReader + * @param yearWeek 대상 주차 + * @return 설정된 ItemReader */ @Bean @StepScope @@ -112,10 +112,10 @@ public ItemReader weeklyMetricsReader( } /** - * Creates an ItemProcessor for ranking score calculation. + * 랭킹 점수 계산을 위한 ItemProcessor를 생성합니다. * - * @param yearWeek the target week - * @return configured ItemProcessor + * @param yearWeek 대상 주차 + * @return 설정된 ItemProcessor */ @Bean @StepScope @@ -127,9 +127,9 @@ public ItemProcessor weeklyRanking } /** - * Creates an ItemWriter for persisting weekly rankings. + * 주간 랭킹 저장을 위한 ItemWriter를 생성합니다. * - * @return configured ItemWriter + * @return 설정된 ItemWriter */ @Bean @StepScope diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/processor/RankingScoreProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/processor/RankingScoreProcessor.java index 2f7def084..d4d6ed375 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/processor/RankingScoreProcessor.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/processor/RankingScoreProcessor.java @@ -7,24 +7,24 @@ import org.springframework.batch.item.ItemProcessor; /** - * ItemProcessor for converting aggregated metrics into ranking entities. + * 집계된 지표를 랭킹 엔티티로 변환하는 ItemProcessor. * - *

This processor transforms ProductRankingAggregation DTOs into either - * WeeklyProductRank or MonthlyProductRank entities based on the period type. - * The ranking score is calculated using weighted metrics. + *

이 Processor는 ProductRankingAggregation DTO를 기간 타입에 따라 + * WeeklyProductRank 또는 MonthlyProductRank 엔티티로 변환합니다. + * 랭킹 점수는 가중치가 적용된 지표를 사용하여 계산됩니다. * - *

Score calculation formula: + *

점수 계산 공식: *

- * score = (viewCount * WEIGHT_VIEW) +
- *         (likeCount * WEIGHT_LIKE) +
- *         (orderCount * WEIGHT_ORDER * log10(salesAmount + 1))
+ * 점수 = (조회수 * 조회_가중치) +
+ *       (좋아요수 * 좋아요_가중치) +
+ *       (주문수 * 주문_가중치 * log10(판매금액 + 1))
  * 
* - * where: + * 가중치: *
    - *
  • WEIGHT_VIEW = 0.1
  • - *
  • WEIGHT_LIKE = 0.2
  • - *
  • WEIGHT_ORDER = 0.6
  • + *
  • 조회_가중치 = 0.1
  • + *
  • 좋아요_가중치 = 0.2
  • + *
  • 주문_가중치 = 0.6
  • *
*/ @Slf4j @@ -38,10 +38,10 @@ public class RankingScoreProcessor implements ItemProcessorUses logarithmic normalization for sales amount to prevent - * extreme values from dominating the score. + *

판매 금액에 대해 로그 정규화를 사용하여 + * 극단적인 값이 점수를 지배하는 것을 방지합니다. * - * @param agg the aggregated metrics - * @return the calculated score + * @param agg 집계된 지표 + * @return 계산된 점수 */ private double calculateScore(ProductRankingAggregation agg) { double salesAmountValue = agg.getSalesAmount() != null ? agg.getSalesAmount().doubleValue() : 0.0; diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java index c955d1666..c2a7ced88 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java @@ -16,17 +16,17 @@ import java.util.stream.IntStream; /** - * ItemReader for aggregating product metrics over a time period. + * 특정 기간 동안의 상품 지표를 집계하는 ItemReader. * - *

This reader fetches product_metrics data and aggregates it by product ID - * for a specific time period (weekly or monthly). The aggregated results include - * calculated ranking scores based on weighted metrics. + *

이 Reader는 product_metrics 데이터를 가져와서 상품 ID별로 집계하며, + * 특정 기간(주간 또는 월간) 동안의 데이터를 처리합니다. 집계된 결과에는 + * 가중치가 적용된 지표를 기반으로 계산된 랭킹 점수가 포함됩니다. * - *

Scoring formula: + *

점수 계산 공식: *

- * score = (viewCount * 0.1) +
- *         (likeCount * 0.2) +
- *         (orderCount * 0.6 * log10(salesAmount + 1))
+ * 점수 = (조회수 * 0.1) +
+ *       (좋아요수 * 0.2) +
+ *       (주문수 * 0.6 * log10(판매금액 + 1))
  * 
*/ @Slf4j @@ -39,12 +39,12 @@ public class ProductMetricsAggregateReader implements ItemReader resultIterator; /** - * Constructs a new ProductMetricsAggregateReader. + * ProductMetricsAggregateReader 생성자. * - * @param entityManager the JPA entity manager - * @param period the period string (e.g., "2025-W01" for weekly, "2025-01" for monthly) - * @param periodType the type of period ("WEEKLY" or "MONTHLY") - * @param topN the maximum number of top rankings to fetch + * @param entityManager JPA 엔티티 매니저 + * @param period 기간 문자열 (예: 주간 "2025-W01", 월간 "2025-01") + * @param periodType 기간 타입 ("WEEKLY" 또는 "MONTHLY") + * @param topN 가져올 최대 상위 랭킹 개수 */ public ProductMetricsAggregateReader( EntityManager entityManager, @@ -70,9 +70,9 @@ public ProductRankingAggregation read() { } /** - * Fetches and aggregates product metrics for the configured period. + * 설정된 기간 동안의 상품 지표를 가져와 집계합니다. * - * @return list of aggregated ranking data with calculated scores + * @return 계산된 점수가 포함된 집계 랭킹 데이터 목록 */ private List fetchAggregatedData() { DateRange dateRange = calculateDateRange(period, periodType); @@ -121,11 +121,11 @@ private List fetchAggregatedData() { } /** - * Calculates the date range for the given period. + * 주어진 기간의 날짜 범위를 계산합니다. * - * @param period the period string - * @param type the period type - * @return the date range with start and end dates + * @param period 기간 문자열 + * @param type 기간 타입 + * @return 시작일과 종료일이 포함된 날짜 범위 */ private DateRange calculateDateRange(String period, String type) { if ("WEEKLY".equals(type)) { @@ -136,10 +136,10 @@ private DateRange calculateDateRange(String period, String type) { } /** - * Calculates weekly date range from ISO week format. + * ISO 주차 형식에서 주간 날짜 범위를 계산합니다. * - * @param yearWeek the year-week in format "YYYY-Wnn" - * @return date range for the week + * @param yearWeek "YYYY-Wnn" 형식의 연-주차 + * @return 주간 날짜 범위 */ private DateRange calculateWeeklyDateRange(String yearWeek) { int year = Integer.parseInt(yearWeek.substring(0, 4)); @@ -159,10 +159,10 @@ private DateRange calculateWeeklyDateRange(String yearWeek) { } /** - * Calculates monthly date range. + * 월간 날짜 범위를 계산합니다. * - * @param yearMonth the year-month in format "YYYY-MM" - * @return date range for the month + * @param yearMonth "YYYY-MM" 형식의 연-월 + * @return 월간 날짜 범위 */ private DateRange calculateMonthlyDateRange(String yearMonth) { LocalDate startOfMonth = LocalDate.parse(yearMonth + "-01"); @@ -172,10 +172,10 @@ private DateRange calculateMonthlyDateRange(String yearMonth) { } /** - * Represents a date range with start and end dates. + * 시작일과 종료일을 포함하는 날짜 범위를 나타냅니다. * - * @param start the start date (inclusive) - * @param end the end date (exclusive) + * @param start 시작일 (포함) + * @param end 종료일 (미포함) */ private record DateRange(LocalDate start, LocalDate end) {} } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/writer/MonthlyRankingWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/MonthlyRankingWriter.java index 6515cdfd7..32a17da5d 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/writer/MonthlyRankingWriter.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/MonthlyRankingWriter.java @@ -9,11 +9,11 @@ import org.springframework.transaction.annotation.Transactional; /** - * ItemWriter for persisting monthly ranking data. + * 월간 랭킹 데이터를 저장하는 ItemWriter. * - *

This writer saves MonthlyProductRank entities to the database. - * It uses a delete-and-insert strategy to ensure data consistency - * by removing existing data for the period before inserting new rankings. + *

이 Writer는 MonthlyProductRank 엔티티를 데이터베이스에 저장합니다. + * 데이터 일관성을 보장하기 위해 삭제 후 삽입 전략을 사용하며, + * 새로운 랭킹을 삽입하기 전에 해당 기간의 기존 데이터를 제거합니다. */ @Slf4j @RequiredArgsConstructor @@ -22,15 +22,15 @@ public class MonthlyRankingWriter implements ItemWriter { private final MonthlyRankRepository repository; /** - * Writes a chunk of monthly ranking data to the database. + * 월간 랭킹 데이터 청크를 데이터베이스에 저장합니다. * - *

Implementation strategy: + *

구현 전략: *

    - *
  1. Delete all existing rankings for the target month
  2. - *
  3. Insert new aggregated rankings
  4. + *
  5. 대상 월의 기존 랭킹 모두 삭제
  6. + *
  7. 새로운 집계 랭킹 삽입
  8. *
* - * @param chunk the chunk of rankings to write + * @param chunk 저장할 랭킹 청크 */ @Override @Transactional diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/writer/WeeklyRankingWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/WeeklyRankingWriter.java index e1da19792..4b4875795 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/writer/WeeklyRankingWriter.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/writer/WeeklyRankingWriter.java @@ -9,11 +9,11 @@ import org.springframework.transaction.annotation.Transactional; /** - * ItemWriter for persisting weekly ranking data. + * 주간 랭킹 데이터를 저장하는 ItemWriter. * - *

This writer saves WeeklyProductRank entities to the database. - * It uses a delete-and-insert strategy to ensure data consistency - * by removing existing data for the period before inserting new rankings. + *

이 Writer는 WeeklyProductRank 엔티티를 데이터베이스에 저장합니다. + * 데이터 일관성을 보장하기 위해 삭제 후 삽입 전략을 사용하며, + * 새로운 랭킹을 삽입하기 전에 해당 기간의 기존 데이터를 제거합니다. */ @Slf4j @RequiredArgsConstructor @@ -22,15 +22,15 @@ public class WeeklyRankingWriter implements ItemWriter { private final WeeklyRankRepository repository; /** - * Writes a chunk of weekly ranking data to the database. + * 주간 랭킹 데이터 청크를 데이터베이스에 저장합니다. * - *

Implementation strategy: + *

구현 전략: *

    - *
  1. Delete all existing rankings for the target week
  2. - *
  3. Insert new aggregated rankings
  4. + *
  5. 대상 주차의 기존 랭킹 모두 삭제
  6. + *
  7. 새로운 집계 랭킹 삽입
  8. *
* - * @param chunk the chunk of rankings to write + * @param chunk 저장할 랭킹 청크 */ @Override @Transactional diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java index 004892907..8154988a8 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java @@ -6,42 +6,42 @@ import java.math.BigDecimal; /** - * Data Transfer Object for product ranking aggregation results. + * 상품 랭킹 집계 결과를 담는 Data Transfer Object. * - *

This DTO carries aggregated metric data from database queries - * to batch processors for further calculation and ranking assignment. + *

이 DTO는 데이터베이스 쿼리에서 집계된 지표 데이터를 배치 프로세서로 전달하여 + * 추가 계산 및 랭킹 할당을 수행합니다. */ @Getter @AllArgsConstructor public class ProductRankingAggregation { /** - * Product identifier + * 상품 ID */ private Long productId; /** - * Total like count for the period + * 기간 내 총 좋아요 수 */ private Integer likeCount; /** - * Total view count for the period + * 기간 내 총 조회 수 */ private Integer viewCount; /** - * Total order count for the period + * 기간 내 총 주문 수 */ private Integer orderCount; /** - * Total sales amount for the period + * 기간 내 총 판매 금액 */ private BigDecimal salesAmount; /** - * Calculated rank position (1-based) + * 계산된 랭킹 순위 (1부터 시작) */ private Integer rankPosition; } diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java index 6a9ca8615..38a71b103 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java @@ -10,10 +10,10 @@ import java.time.LocalDateTime; /** - * Monthly product ranking entity for materialized view. + * Materialized View를 위한 월간 상품 랭킹 엔티티. * - *

This table stores pre-aggregated monthly ranking data for performance optimization. - * Aggregation is performed by Spring Batch jobs and stored here for fast query access. + *

이 테이블은 성능 최적화를 위해 사전 집계된 월간 랭킹 데이터를 저장합니다. + * 집계는 Spring Batch Job에 의해 수행되며 빠른 조회를 위해 여기에 저장됩니다. * * @see com.loopers.batch.config.MonthlyRankingJobConfig */ @@ -41,7 +41,7 @@ public class MonthlyProductRank { private Long productId; /** - * Year-month format: YYYY-MM (e.g., "2025-01") + * 년-월 형식: YYYY-MM (예: "2025-01") */ @Column(name = "`year_month`", nullable = false, length = 7) private String yearMonth; diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java index 6a308b95a..7d51f0358 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java @@ -8,26 +8,26 @@ import java.util.List; /** - * Repository interface for MonthlyProductRank entity. + * MonthlyProductRank 엔티티를 위한 Repository 인터페이스. * - *

Provides data access methods for monthly product ranking data. + *

월간 상품 랭킹 데이터에 대한 데이터 접근 메서드를 제공합니다. */ public interface MonthlyRankRepository extends JpaRepository { /** - * Finds all rankings for a specific month ordered by rank position. + * 특정 월의 모든 랭킹을 순위 순서대로 조회합니다. * - * @param yearMonth the year-month (e.g., "2025-01") - * @return list of monthly rankings ordered by position + * @param yearMonth 년-월 (예: "2025-01") + * @return 순위순으로 정렬된 월간 랭킹 목록 */ List findByYearMonthOrderByRankPositionAsc(String yearMonth); /** - * Deletes all rankings for a specific month. + * 특정 월의 모든 랭킹을 삭제합니다. * - *

Used before inserting new aggregated data to ensure data consistency. + *

데이터 일관성을 보장하기 위해 새로운 집계 데이터를 삽입하기 전에 사용됩니다. * - * @param yearMonth the year-month to delete + * @param yearMonth 삭제할 년-월 */ @Modifying @Query("DELETE FROM MonthlyProductRank m WHERE m.yearMonth = :yearMonth") diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java index ef829b253..ffcdc2c4a 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java @@ -10,10 +10,10 @@ import java.time.LocalDateTime; /** - * Weekly product ranking entity for materialized view. + * Materialized View를 위한 주간 상품 랭킹 엔티티. * - *

This table stores pre-aggregated weekly ranking data for performance optimization. - * Aggregation is performed by Spring Batch jobs and stored here for fast query access. + *

이 테이블은 성능 최적화를 위해 사전 집계된 주간 랭킹 데이터를 저장합니다. + * 집계는 Spring Batch Job에 의해 수행되며 빠른 조회를 위해 여기에 저장됩니다. * * @see com.loopers.batch.config.WeeklyRankingJobConfig */ @@ -41,7 +41,7 @@ public class WeeklyProductRank { private Long productId; /** - * ISO week format: YYYY-Wnn (e.g., "2025-W01") + * ISO 주차 형식: YYYY-Wnn (예: "2025-W01") */ @Column(name = "year_week", nullable = false, length = 10) private String yearWeek; diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java index 98265bf64..c077633a2 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java @@ -8,26 +8,26 @@ import java.util.List; /** - * Repository interface for WeeklyProductRank entity. + * WeeklyProductRank 엔티티를 위한 Repository 인터페이스. * - *

Provides data access methods for weekly product ranking data. + *

주간 상품 랭킹 데이터에 대한 데이터 접근 메서드를 제공합니다. */ public interface WeeklyRankRepository extends JpaRepository { /** - * Finds all rankings for a specific week ordered by rank position. + * 특정 주차의 모든 랭킹을 순위 순서대로 조회합니다. * - * @param yearWeek the year-week in ISO format (e.g., "2025-W01") - * @return list of weekly rankings ordered by position + * @param yearWeek ISO 형식의 년-주차 (예: "2025-W01") + * @return 순위순으로 정렬된 주간 랭킹 목록 */ List findByYearWeekOrderByRankPositionAsc(String yearWeek); /** - * Deletes all rankings for a specific week. + * 특정 주차의 모든 랭킹을 삭제합니다. * - *

Used before inserting new aggregated data to ensure data consistency. + *

데이터 일관성을 보장하기 위해 새로운 집계 데이터를 삽입하기 전에 사용됩니다. * - * @param yearWeek the year-week to delete + * @param yearWeek 삭제할 년-주차 */ @Modifying @Query("DELETE FROM WeeklyProductRank w WHERE w.yearWeek = :yearWeek") From 7e4081be050737992a4e0b61a3d27f24f10b89b7 Mon Sep 17 00:00:00 2001 From: minwooKang Date: Thu, 1 Jan 2026 21:09:54 +0900 Subject: [PATCH 5/7] =?UTF-8?q?style:=20=EB=B0=B0=EC=B9=98=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20import=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/CommerceBatchApplication.java | 3 +-- .../com/loopers/batch/listener/JobListener.java | 7 +++---- .../batch/listener/StepMonitorListener.java | 4 ++-- .../reader/ProductMetricsAggregateReader.java | 5 ++--- .../domain/dto/ProductRankingAggregation.java | 3 +-- .../loopers/domain/rank/MonthlyProductRank.java | 15 +++++++++++---- .../domain/rank/MonthlyRankRepository.java | 3 +-- .../loopers/domain/rank/WeeklyProductRank.java | 15 +++++++++++---- .../loopers/domain/rank/WeeklyRankRepository.java | 3 +-- .../java/com/loopers/job/demo/DemoJobE2ETest.java | 10 ++++------ 10 files changed, 37 insertions(+), 31 deletions(-) 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..f52527dee 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -1,12 +1,11 @@ package com.loopers; import jakarta.annotation.PostConstruct; +import java.util.TimeZone; 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 { 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 index cb5c8bebd..fa5720884 100644 --- 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 @@ -1,5 +1,8 @@ package com.loopers.batch.listener; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.JobExecution; @@ -7,10 +10,6 @@ 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 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 index 4f22f40b0..fd22e6baa 100644 --- 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 @@ -1,14 +1,14 @@ package com.loopers.batch.listener; import jakarta.annotation.Nonnull; +import java.util.Objects; +import java.util.stream.Collectors; 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 diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java index c2a7ced88..e9ebb689a 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/reader/ProductMetricsAggregateReader.java @@ -3,9 +3,6 @@ import com.loopers.domain.dto.ProductRankingAggregation; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.item.ItemReader; - import java.math.BigDecimal; import java.time.DayOfWeek; import java.time.LocalDate; @@ -14,6 +11,8 @@ import java.util.List; import java.util.stream.Collectors; import java.util.stream.IntStream; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemReader; /** * 특정 기간 동안의 상품 지표를 집계하는 ItemReader. diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java index 8154988a8..e9bd3d8ea 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/dto/ProductRankingAggregation.java @@ -1,10 +1,9 @@ package com.loopers.domain.dto; +import java.math.BigDecimal; import lombok.AllArgsConstructor; import lombok.Getter; -import java.math.BigDecimal; - /** * 상품 랭킹 집계 결과를 담는 Data Transfer Object. * diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java index 38a71b103..c91dcb73a 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java @@ -1,14 +1,21 @@ package com.loopers.domain.rank; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.math.BigDecimal; +import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.math.BigDecimal; -import java.time.LocalDateTime; - /** * Materialized View를 위한 월간 상품 랭킹 엔티티. * diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java index 7d51f0358..f3da336f2 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java @@ -1,12 +1,11 @@ package com.loopers.domain.rank; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; - /** * MonthlyProductRank 엔티티를 위한 Repository 인터페이스. * diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java index ffcdc2c4a..ab622e7ec 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java @@ -1,14 +1,21 @@ package com.loopers.domain.rank; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.math.BigDecimal; +import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.math.BigDecimal; -import java.time.LocalDateTime; - /** * Materialized View를 위한 주간 상품 랭킹 엔티티. * diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java index c077633a2..23b7a9101 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java @@ -1,12 +1,11 @@ package com.loopers.domain.rank; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; - /** * WeeklyProductRank 엔티티를 위한 Repository 인터페이스. * 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 index dafe59a18..314068f0d 100644 --- 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 @@ -1,7 +1,10 @@ package com.loopers.job.demo; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + import com.loopers.batch.job.demo.DemoJobConfig; -import lombok.RequiredArgsConstructor; +import java.time.LocalDate; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,11 +18,6 @@ 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) From ceab99d548662068649ae989e46221e018b941ca Mon Sep 17 00:00:00 2001 From: minwooKang Date: Thu, 1 Jan 2026 21:10:16 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ranking/RankingFacade.java | 130 ++++++++++++++---- .../domain/rank/MonthlyProductRank.java | 59 ++++++++ .../domain/rank/WeeklyProductRank.java | 59 ++++++++ .../rank/MonthlyRankJpaRepository.java | 21 +++ .../rank/WeeklyRankJpaRepository.java | 21 +++ .../interfaces/api/ranking/RankingApi.java | 65 +++++++-- .../api/ranking/RankingResponse.java | 12 +- docker/init-db.sql | 39 ++++++ 8 files changed, 363 insertions(+), 43 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index 4749bd948..498e4851c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -3,6 +3,10 @@ import com.loopers.application.product.ProductInfo; import com.loopers.application.product.ProductService; import com.loopers.application.ranking.RankingService.RankingItem; +import com.loopers.domain.rank.MonthlyProductRank; +import com.loopers.domain.rank.WeeklyProductRank; +import com.loopers.infrastructure.rank.MonthlyRankJpaRepository; +import com.loopers.infrastructure.rank.WeeklyRankJpaRepository; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; @@ -10,10 +14,14 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; /** - * 랭킹 + 상품 정보 조합 Facade + * Facade for combining ranking data with product information. + * + *

This service provides unified access to daily, weekly, and monthly rankings + * by merging ranking data with detailed product information. */ @Slf4j @Service @@ -22,19 +30,104 @@ public class RankingFacade { private final RankingService rankingService; private final ProductService productService; + private final WeeklyRankJpaRepository weeklyRankJpaRepository; + private final MonthlyRankJpaRepository monthlyRankJpaRepository; /** - * 일간 랭킹 페이지 조회 (상품 정보 포함) + * Retrieves daily rankings with product information. + * + * @param date the target date in yyyyMMdd format + * @param page the page number (1-based) + * @param size the page size + * @return list of rankings with product details */ public List getDailyRanking(String date, int page, int size) { - // 1. Redis ZSET에서 랭킹 조회 List rankingItems = rankingService.getDailyRanking(date, page, size); if (rankingItems.isEmpty()) { return List.of(); } - // 2. Product 정보 조회 (Batch) + return combineWithProductInfo(rankingItems); + } + + /** + * Retrieves today's rankings. + * + * @param page the page number (1-based) + * @param size the page size + * @return list of rankings with product details + */ + public List getTodayRanking(int page, int size) { + String today = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); + return getDailyRanking(today, page, size); + } + + /** + * Retrieves weekly rankings with product information. + * + * @param yearWeek the target week in ISO format (e.g., "2025-W01") + * @param page the page number (1-based) + * @param size the page size + * @return list of rankings with product details + */ + public List getWeeklyRanking(String yearWeek, int page, int size) { + PageRequest pageRequest = PageRequest.of(page - 1, size); + List weeklyRanks = weeklyRankJpaRepository.findByYearWeekOrderByRankPositionAsc( + yearWeek, pageRequest + ); + + if (weeklyRanks.isEmpty()) { + return List.of(); + } + + return combineWithProductInfo( + weeklyRanks.stream() + .map(rank -> new RankingItem( + rank.getRankPosition(), + rank.getProductId(), + rank.getTotalScore() + )) + .toList() + ); + } + + /** + * Retrieves monthly rankings with product information. + * + * @param yearMonth the target month (e.g., "2025-01") + * @param page the page number (1-based) + * @param size the page size + * @return list of rankings with product details + */ + public List getMonthlyRanking(String yearMonth, int page, int size) { + PageRequest pageRequest = PageRequest.of(page - 1, size); + List monthlyRanks = monthlyRankJpaRepository.findByYearMonthOrderByRankPositionAsc( + yearMonth, pageRequest + ); + + if (monthlyRanks.isEmpty()) { + return List.of(); + } + + return combineWithProductInfo( + monthlyRanks.stream() + .map(rank -> new RankingItem( + rank.getRankPosition(), + rank.getProductId(), + rank.getTotalScore() + )) + .toList() + ); + } + + /** + * Combines ranking items with product information. + * + * @param rankingItems the list of ranking items + * @return list of rankings with product details + */ + private List combineWithProductInfo(List rankingItems) { List productIds = rankingItems.stream() .map(RankingItem::getProductId) .toList(); @@ -42,13 +135,12 @@ public List getDailyRanking(String date, int page, int size) Map productMap = productService.findByIds(productIds).stream() .collect(Collectors.toMap(ProductInfo::id, p -> p)); - // 3. 랭킹 + 상품 정보 조합 List results = new ArrayList<>(); for (RankingItem item : rankingItems) { ProductInfo product = productMap.get(item.getProductId()); if (product == null) { - log.warn("랭킹에 있지만 상품 정보 없음 - productId: {}", item.getProductId()); + log.warn("Product not found for ranking: productId={}", item.getProductId()); continue; } @@ -68,26 +160,18 @@ public List getDailyRanking(String date, int page, int size) } /** - * 오늘 랭킹 페이지 조회 - */ - public List getTodayRanking(int page, int size) { - String today = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); - return getDailyRanking(today, page, size); - } - - /** - * 랭킹 + 상품 정보 DTO + * DTO for ranking combined with product information. */ @lombok.Getter @lombok.Builder public static class RankingProductInfo { - private int rank; // 순위 - private Double score; // 점수 - private Long productId; // 상품 ID - private String productName; // 상품명 - private String brandName; // 브랜드명 - private BigDecimal price; // 가격 - private Integer stock; // 재고 - private Long likeCount; // 좋아요 수 + private int rank; + private Double score; + private Long productId; + private String productName; + private String brandName; + private BigDecimal price; + private Integer stock; + private Long likeCount; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java new file mode 100644 index 000000000..2b6893c1a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java @@ -0,0 +1,59 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Monthly product ranking entity for materialized view. + * + *

This table stores pre-aggregated monthly ranking data for performance optimization. + */ +@Entity +@Table(name = "mv_product_rank_monthly") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MonthlyProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "`year_month`", nullable = false, length = 7) + private String yearMonth; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "view_count", nullable = false) + private Integer viewCount; + + @Column(name = "order_count", nullable = false) + private Integer orderCount; + + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java new file mode 100644 index 000000000..093c87a39 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java @@ -0,0 +1,59 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Weekly product ranking entity for materialized view. + * + *

This table stores pre-aggregated weekly ranking data for performance optimization. + */ +@Entity +@Table(name = "mv_product_rank_weekly") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WeeklyProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "year_week", nullable = false, length = 10) + private String yearWeek; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "view_count", nullable = false) + private Integer viewCount; + + @Column(name = "order_count", nullable = false) + private Integer orderCount; + + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java new file mode 100644 index 000000000..c732b7876 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.MonthlyProductRank; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * JPA Repository for MonthlyProductRank entity. + */ +public interface MonthlyRankJpaRepository extends JpaRepository { + + /** + * Finds monthly rankings for a specific month with pagination. + * + * @param yearMonth the year-month (e.g., "2025-01") + * @param pageable pagination parameters + * @return list of monthly rankings + */ + List findByYearMonthOrderByRankPositionAsc(String yearMonth, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java new file mode 100644 index 000000000..1fda668ae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.WeeklyProductRank; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * JPA Repository for WeeklyProductRank entity. + */ +public interface WeeklyRankJpaRepository extends JpaRepository { + + /** + * Finds weekly rankings for a specific week with pagination. + * + * @param yearWeek the year-week in ISO format (e.g., "2025-W01") + * @param pageable pagination parameters + * @return list of weekly rankings + */ + List findByYearWeekOrderByRankPositionAsc(String yearWeek, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApi.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApi.java index 3958a85e9..0798a18f7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApi.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApi.java @@ -16,9 +16,11 @@ import org.springframework.web.bind.annotation.RestController; /** - * 랭킹 API + * API controller for product rankings. + * + *

Provides endpoints for querying daily, weekly, and monthly product rankings. */ -@Tag(name = "Ranking", description = "상품 랭킹 API") +@Tag(name = "Ranking", description = "Product Ranking API") @RestController @RequestMapping("/api/v1/rankings") @RequiredArgsConstructor @@ -29,28 +31,59 @@ public class RankingApi { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); @Operation( - summary = "일간 랭킹 조회", - description = "특정 날짜의 상품 랭킹을 페이지 단위로 조회합니다." + summary = "Query product rankings", + description = "Retrieves product rankings for a specific period (daily, weekly, or monthly) with pagination support." ) @GetMapping public ResponseEntity getRankings( - @Parameter(description = "조회 날짜 (yyyyMMdd), 미입력 시 오늘", example = "20250123") + @Parameter(description = "Period type: DAILY, WEEKLY, MONTHLY", example = "DAILY") + @RequestParam(required = false, defaultValue = "DAILY") String periodType, + + @Parameter(description = "Target date (yyyyMMdd) for daily rankings", example = "20250130") @RequestParam(required = false) String date, - @Parameter(description = "페이지 번호 (1부터 시작)", example = "1") + @Parameter(description = "Target week (YYYY-Wnn) for weekly rankings", example = "2025-W05") + @RequestParam(required = false) String yearWeek, + + @Parameter(description = "Target month (YYYY-MM) for monthly rankings", example = "2025-01") + @RequestParam(required = false) String yearMonth, + + @Parameter(description = "Page number (1-based)", example = "1") @RequestParam(defaultValue = "1") int page, - @Parameter(description = "페이지 크기", example = "20") + @Parameter(description = "Page size", example = "20") @RequestParam(defaultValue = "20") int size ) { - // 날짜 검증 - String targetDate = validateAndGetDate(date); + String period; + List rankings; - // 랭킹 조회 - List rankings = rankingFacade.getDailyRanking(targetDate, page, size); + switch (periodType.toUpperCase()) { + case "WEEKLY": + if (yearWeek == null || yearWeek.isBlank()) { + throw new IllegalArgumentException("yearWeek parameter is required for WEEKLY period type"); + } + period = yearWeek; + rankings = rankingFacade.getWeeklyRanking(yearWeek, page, size); + break; + + case "MONTHLY": + if (yearMonth == null || yearMonth.isBlank()) { + throw new IllegalArgumentException("yearMonth parameter is required for MONTHLY period type"); + } + period = yearMonth; + rankings = rankingFacade.getMonthlyRanking(yearMonth, page, size); + break; + + case "DAILY": + default: + String targetDate = validateAndGetDate(date); + period = targetDate; + rankings = rankingFacade.getDailyRanking(targetDate, page, size); + break; + } return ResponseEntity.ok(new RankingResponse( - targetDate, + period, page, size, rankings @@ -58,7 +91,11 @@ public ResponseEntity getRankings( } /** - * 날짜 검증 및 변환 + * Validates and normalizes the date parameter. + * + * @param date the date string in yyyyMMdd format (nullable) + * @return validated date string, defaults to today if null + * @throws IllegalArgumentException if date format is invalid */ private String validateAndGetDate(String date) { if (date == null || date.isBlank()) { @@ -69,7 +106,7 @@ private String validateAndGetDate(String date) { LocalDate.parse(date, DATE_FORMATTER); return date; } catch (Exception e) { - throw new IllegalArgumentException("날짜 형식이 올바르지 않습니다. (yyyyMMdd)"); + throw new IllegalArgumentException("Invalid date format. Expected: yyyyMMdd"); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java index 641eca8d1..0961126ce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java @@ -4,18 +4,18 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; -@Schema(description = "랭킹 조회 응답") +@Schema(description = "Ranking query response") public record RankingResponse( - @Schema(description = "조회 날짜", example = "20250123") - String date, + @Schema(description = "Query period (date, week, or month)", example = "20250130") + String period, - @Schema(description = "페이지 번호", example = "1") + @Schema(description = "Page number", example = "1") int page, - @Schema(description = "페이지 크기", example = "20") + @Schema(description = "Page size", example = "20") int size, - @Schema(description = "랭킹 목록") + @Schema(description = "List of rankings with product details") List rankings ) { } diff --git a/docker/init-db.sql b/docker/init-db.sql index 0c8efe2e1..513b1b846 100644 --- a/docker/init-db.sql +++ b/docker/init-db.sql @@ -58,3 +58,42 @@ CREATE TABLE IF NOT EXISTS dead_letter_queue ( INDEX idx_topic (original_topic) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='처리 실패한 메시지 저장'; + +-- Round 10: Materialized View for Weekly/Monthly Rankings +-- Weekly Product Ranking +CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL COMMENT '상품 ID', + year_week VARCHAR(10) NOT NULL COMMENT 'YYYY-Wnn (ISO Week)', + rank_position INT NOT NULL COMMENT '순위 (1-100)', + total_score DOUBLE NOT NULL COMMENT '총 점수', + like_count INT NOT NULL DEFAULT 0 COMMENT '주간 좋아요 수', + view_count INT NOT NULL DEFAULT 0 COMMENT '주간 조회 수', + order_count INT NOT NULL DEFAULT 0 COMMENT '주간 주문 수', + sales_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT '주간 판매 금액', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_product_week (product_id, year_week), + INDEX idx_year_week_rank (year_week, rank_position), + INDEX idx_year_week_score (year_week, total_score DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci +COMMENT='주간 상품 랭킹 집계'; + +-- Monthly Product Ranking +CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL COMMENT '상품 ID', + year_month VARCHAR(7) NOT NULL COMMENT 'YYYY-MM', + rank_position INT NOT NULL COMMENT '순위 (1-100)', + total_score DOUBLE NOT NULL COMMENT '총 점수', + like_count INT NOT NULL DEFAULT 0 COMMENT '월간 좋아요 수', + view_count INT NOT NULL DEFAULT 0 COMMENT '월간 조회 수', + order_count INT NOT NULL DEFAULT 0 COMMENT '월간 주문 수', + sales_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT '월간 판매 금액', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_product_month (product_id, year_month), + INDEX idx_year_month_rank (year_month, rank_position), + INDEX idx_year_month_score (year_month, total_score DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci +COMMENT='월간 상품 랭킹 집계'; From ccd8fc2f251b13dcd8c423d0389494a6da3734fa Mon Sep 17 00:00:00 2001 From: minwooKang Date: Thu, 1 Jan 2026 21:10:23 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=EB=B0=B0=EC=B9=98=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .docs/design/03-class-diagram.md | 555 +++++++++++++++++++++++++++++++ .docs/design/04-erd.md | 351 +++++++++++++++++++ 2 files changed, 906 insertions(+) diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md index 46b76008e..293fa11fa 100644 --- a/.docs/design/03-class-diagram.md +++ b/.docs/design/03-class-diagram.md @@ -162,6 +162,102 @@ classDiagram User "1" --> "*" UserCoupon : 보유 쿠폰 Coupon "1" --> "*" UserCoupon : 발급됨 UserCoupon ..> CouponType : 사용 + Order "1" --> "0..1" Payment : 결제 정보 + + class Payment { + <> + +String transactionKey + +String orderId + +String userId + +BigDecimal amount + +PaymentStatus status + +String failureReason + +String cardType + +String cardNo + +updateStatus(status, reason) 상태 변경 + +isPending() 대기 확인 + +isSuccess() 성공 확인 + +isFailed() 실패 확인 + 결제 정보 + } + + class PaymentStatus { + <> + PENDING 대기 + SUCCESS 성공 + FAILED 실패 + } + + class EventOutbox { + <> + +String aggregateType + +String aggregateId + +String eventType + +String payload + +OutboxStatus status + +Integer retryCount + +String errorMessage + +markAsPublished() 발행 완료 + +markAsFailed(message) 발행 실패 + +canRetry() 재시도 가능 여부 + 이벤트 아웃박스 + } + + class OutboxStatus { + <> + PENDING 대기 + PUBLISHED 발행 완료 + FAILED 실패 + } + + class WeeklyProductRank { + <> + +Long id + +Long productId + +String yearWeek + +Integer rankPosition + +Double totalScore + +Integer likeCount + +Integer viewCount + +Integer orderCount + +BigDecimal salesAmount + 주간 랭킹 + } + + class MonthlyProductRank { + <> + +Long id + +Long productId + +String yearMonth + +Integer rankPosition + +Double totalScore + +Integer likeCount + +Integer viewCount + +Integer orderCount + +BigDecimal salesAmount + 월간 랭킹 + } + + class ProductMetrics { + <> + +Long productId + +Integer likeCount + +Integer viewCount + +Integer orderCount + +BigDecimal salesAmount + +Integer version + +incrementLikeCount() 좋아요 증가 + +decrementLikeCount() 좋아요 감소 + +incrementViewCount() 조회 증가 + +incrementOrderCount(quantity, amount) 주문 증가 + 실시간 메트릭 + } + + Payment ..> PaymentStatus : 사용 + EventOutbox ..> OutboxStatus : 사용 + Product "1" --> "*" WeeklyProductRank : 주간 랭킹 + Product "1" --> "*" MonthlyProductRank : 월간 랭킹 + Product "1" --> "1" ProductMetrics : 실시간 집계 ``` ## 📦 도메인별 상세 설계 @@ -854,3 +950,462 @@ Optional findByIdWithLock(@Param("id") Long id); - 기존: 상품 → 포인트 → 주문 - 변경: **쿠폰** → 상품 → **쿠폰 할인** → 포인트 → 주문 + +--- + +## 8. 랭킹 도메인 (Round 10 추가) + +### 8.1 WeeklyProductRank (주간 상품 랭킹) + +```mermaid +classDiagram + class WeeklyProductRank { + <> + -Long id + -Long productId + -String yearWeek + -Integer rankPosition + -Double totalScore + -Integer likeCount + -Integer viewCount + -Integer orderCount + -BigDecimal salesAmount + } +``` + +**책임**: "주간 상품 랭킹 데이터 저장" + +| 속성 | 설명 | 예시 | +|---|---|---| +| id | 랭킹 고유 번호 | 1 | +| productId | 상품 ID | 123 | +| yearWeek | ISO Week 형식 | "2025-W05" | +| rankPosition | 순위 (1~100) | 1 | +| totalScore | 총점 | 125.5 | +| likeCount | 좋아요 수 | 100 | +| viewCount | 조회 수 | 1000 | +| orderCount | 주문 수 | 50 | +| salesAmount | 판매 금액 | 5000000.00 | + +**비즈니스 규칙**: +``` +✓ Spring Batch로 주 1회 집계 (매주 월요일 01:00) +✓ TOP 100만 저장 +✓ 점수 계산: (view_count × 0.1) + (like_count × 0.2) + (order_count × 0.6 × log10(sales_amount + 1)) +✓ Read-Only 데이터 (조회 전용) +``` + +**특징**: +- Materialized View 패턴 적용 +- 조회 성능 최적화를 위한 사전 집계 데이터 +- Batch Job을 통해 주기적으로 갱신 + +--- + +### 8.2 MonthlyProductRank (월간 상품 랭킹) + +```mermaid +classDiagram + class MonthlyProductRank { + <> + -Long id + -Long productId + -String yearMonth + -Integer rankPosition + -Double totalScore + -Integer likeCount + -Integer viewCount + -Integer orderCount + -BigDecimal salesAmount + } +``` + +**책임**: "월간 상품 랭킹 데이터 저장" + +| 속성 | 설명 | 예시 | +|---|---|---| +| id | 랭킹 고유 번호 | 1 | +| productId | 상품 ID | 123 | +| yearMonth | 년월 형식 | "2025-01" | +| rankPosition | 순위 (1~100) | 1 | +| totalScore | 총점 | 850.3 | +| likeCount | 좋아요 수 | 400 | +| viewCount | 조회 수 | 5000 | +| orderCount | 주문 수 | 200 | +| salesAmount | 판매 금액 | 20000000.00 | + +**비즈니스 규칙**: +``` +✓ Spring Batch로 월 1회 집계 (매월 1일 02:00) +✓ TOP 100만 저장 +✓ 점수 계산: 주간 랭킹과 동일 +✓ Read-Only 데이터 (조회 전용) +``` + +--- + +### 8.3 Ranking API 설계 + +**RankingFacade의 역할**: +``` +RankingFacade: +"랭킹 데이터를 Product 정보와 함께 제공" + +1. Daily Ranking (기존) + → product_metrics 테이블 조회 + → Product 정보와 결합 + +2. Weekly Ranking (NEW) + → mv_product_rank_weekly 테이블 조회 + → Product 정보와 결합 + +3. Monthly Ranking (NEW) + → mv_product_rank_monthly 테이블 조회 + → Product 정보와 결합 +``` + +**왜 Facade를 사용하나?** +- 랭킹 데이터만으로는 불충분 (상품명, 브랜드명 필요) +- RankingService는 랭킹 조회만 담당 +- ProductService는 상품 정보 조회만 담당 +- RankingFacade가 둘을 결합 + +**조회 성능 최적화**: +``` +[나쁜 예] +매번 product_metrics 전체 집계 +→ 느림 (SUM, GROUP BY) + +[좋은 예 - Materialized View] +미리 계산된 랭킹 조회 +→ 빠름 (단순 SELECT) +``` + +--- + +## 10. 결제 도메인 (Round 6 추가) + +### 10.1 Payment (결제) + +```mermaid +classDiagram + class Payment { + <> + -Long id + -String transactionKey + -String orderId + -String userId + -BigDecimal amount + -PaymentStatus status + -String failureReason + -String cardType + -String cardNo + +updateStatus(status, failureReason) void + +isPending() boolean + +isSuccess() boolean + +isFailed() boolean + } + + class PaymentStatus { + <> + PENDING + SUCCESS + FAILED + } + + Payment ..> PaymentStatus : 사용 +``` + +**책임**: "결제 상태 관리 및 결제 정보 보관" + +| 속성 | 설명 | 예시 | +|---|---|---| +| id | 결제 고유 번호 | 1 | +| transactionKey | PG사 거래 키 (unique) | "TXN20250130123456" | +| orderId | 주문 ID | "123" | +| userId | 사용자 ID | "user123" | +| amount | 결제 금액 | 50000.00 | +| status | 결제 상태 | PENDING | +| failureReason | 실패 사유 | "카드 한도 초과" | +| cardType | 카드 타입 | "CREDIT" | +| cardNo | 카드 번호 (마스킹) | "1234-****-****-5678" | + +**비즈니스 규칙**: +``` +✓ transactionKey는 중복 불가 (PG사 거래 고유 식별자) +✓ 결제 상태는 PENDING → SUCCESS 또는 FAILED로만 변경 가능 +✓ SUCCESS 또는 FAILED는 최종 상태 (더 이상 변경 불가) +✓ 결제 실패 시 failureReason 필수 +``` + +**주요 메서드**: +- `updateStatus(status, failureReason)`: 결제 상태 업데이트 + - PENDING에서만 SUCCESS 또는 FAILED로 변경 가능 + - FAILED로 변경 시 failureReason 필수 +- `isPending()`, `isSuccess()`, `isFailed()`: 상태 확인 편의 메서드 + +**결제 프로세스**: +``` +1. 주문 생성 → Payment 생성 (status=PENDING) +2. PG사 연동 → 결제 요청 +3. PG사 콜백: + - 성공 → updateStatus(SUCCESS, null) + - 실패 → updateStatus(FAILED, "실패 사유") +4. PaymentSuccessEvent 또는 PaymentFailedEvent 발행 +``` + +--- + +### 10.2 PaymentStatus (결제 상태) + +| 상태 | 의미 | 다음 가능 상태 | +|---|---|---| +| PENDING | 결제 요청됨, PG사 응답 대기 중 | SUCCESS, FAILED | +| SUCCESS | 결제 성공 | 없음 (최종 상태) | +| FAILED | 결제 실패 | 없음 (최종 상태) | + +--- + +## 11. 이벤트 도메인 (Round 8 추가) + +### 11.1 EventOutbox (이벤트 아웃박스) + +```mermaid +classDiagram + class EventOutbox { + <> + -Long id + -String aggregateType + -String aggregateId + -String eventType + -String payload + -OutboxStatus status + -Integer retryCount + -String errorMessage + +markAsPublished() void + +markAsFailed(message) void + +canRetry() boolean + } + + class OutboxStatus { + <> + PENDING + PUBLISHED + FAILED + } + + EventOutbox ..> OutboxStatus : 사용 +``` + +**책임**: "Transactional Outbox 패턴 구현을 통한 이벤트 발행 보장" + +| 속성 | 설명 | 예시 | +|---|---|---| +| id | 아웃박스 고유 번호 | 1 | +| aggregateType | 애그리거트 타입 | "ORDER", "PAYMENT", "LIKE" | +| aggregateId | 애그리거트 ID | "123" | +| eventType | 이벤트 타입 | "OrderCreatedEvent" | +| payload | 이벤트 페이로드 (JSON) | "{\"orderId\":123,\"userId\":\"user1\"}" | +| status | 발행 상태 | PENDING | +| retryCount | 재시도 횟수 | 0 | +| errorMessage | 에러 메시지 | "Kafka broker not available" | + +**비즈니스 규칙**: +``` +✓ 비즈니스 트랜잭션과 동일한 트랜잭션에서 이벤트 저장 +✓ 최대 재시도 횟수는 3회 +✓ 3회 실패 시 status = FAILED (수동 처리 필요) +✓ PUBLISHED 상태는 최종 상태 +``` + +**Transactional Outbox 패턴**: +``` +[문제] +주문 생성 후 이벤트 발행 시 네트워크 장애로 실패하면? +→ 주문은 생성되었지만 이벤트는 발행 안 됨 +→ 데이터 불일치 + +[해결] +1. 주문 생성과 동시에 EventOutbox에 이벤트 저장 (같은 트랜잭션) +2. 별도 스케줄러가 PENDING 이벤트를 Kafka로 발행 +3. 발행 성공 → PUBLISHED +4. 발행 실패 → 재시도 (최대 3회) +5. 3회 실패 → FAILED (수동 처리) + +[장점] +✓ At-least-once 전송 보장 (이벤트 손실 방지) +✓ 트랜잭션 일관성 (주문 생성 실패 → 이벤트도 저장 안 됨) +``` + +**주요 메서드**: +- `markAsPublished()`: 발행 성공 처리 +- `markAsFailed(errorMessage)`: 발행 실패 처리 및 재시도 횟수 증가 +- `canRetry()`: 재시도 가능 여부 확인 (retryCount < 3) + +--- + +### 11.2 OutboxStatus (아웃박스 상태) + +| 상태 | 의미 | 다음 가능 상태 | +|---|---|---| +| PENDING | 발행 대기 중 | PUBLISHED, FAILED | +| PUBLISHED | 발행 완료 | 없음 (최종 상태) | +| FAILED | 발행 실패 (3회 초과) | 없음 (수동 처리 필요) | + +--- + +## 12. 메트릭 도메인 (Round 9 추가) + +### 12.1 ProductMetrics (상품 메트릭) + +```mermaid +classDiagram + class ProductMetrics { + <> + -Long productId + -Integer likeCount + -Integer viewCount + -Integer orderCount + -BigDecimal salesAmount + -Integer version + +incrementLikeCount() void + +decrementLikeCount() void + +incrementViewCount() void + +incrementOrderCount(quantity, amount) void + } +``` + +**책임**: "상품별 실시간 메트릭 집계" + +| 속성 | 설명 | 예시 | +|---|---|---| +| productId | 상품 ID (PK) | 123 | +| likeCount | 좋아요 수 | 100 | +| viewCount | 조회 수 | 1000 | +| orderCount | 주문 수 | 50 | +| salesAmount | 판매 금액 | 5000000.00 | +| version | 낙관적 락 버전 | 0 | + +**비즈니스 규칙**: +``` +✓ Kafka 이벤트를 통해 실시간 집계 +✓ 이벤트 타입별 처리: + - LikeCreatedEvent → incrementLikeCount() + - LikeDeletedEvent → decrementLikeCount() + - ProductViewedEvent → incrementViewCount() + - OrderCreatedEvent → incrementOrderCount() +✓ 낙관적 락을 통한 동시성 제어 +``` + +**이벤트 기반 집계**: +``` +[commerce-streamer 역할] +1. Kafka Consumer가 이벤트 수신 +2. ProductMetrics 조회 (없으면 생성) +3. 이벤트 타입별 메트릭 증가 +4. DB 저장 + +[예시] +ProductViewedEvent 수신 → productMetrics.incrementViewCount() +LikeCreatedEvent 수신 → productMetrics.incrementLikeCount() +OrderCreatedEvent 수신 → productMetrics.incrementOrderCount(quantity, amount) +``` + +**주요 메서드**: +- `incrementLikeCount()`: 좋아요 수 +1 +- `decrementLikeCount()`: 좋아요 수 -1 +- `incrementViewCount()`: 조회 수 +1 +- `incrementOrderCount(quantity, amount)`: 주문 수 +quantity, 판매 금액 +amount + +--- + +## 13. Spring Batch 아키텍처 (Round 10 추가) + +### 9.1 Batch Job 구성 + +```mermaid +classDiagram + class WeeklyRankingJobConfig { + <> + +weeklyRankingJob() Job + +weeklyRankingStep() Step + +weeklyMetricsReader() ItemReader + +weeklyRankingProcessor() ItemProcessor + +weeklyRankingWriter() ItemWriter + } + + class ProductMetricsAggregateReader { + <> + -EntityManager entityManager + -String period + -String periodType + -int topN + +read() ProductRankingAggregation + -fetchAggregatedData() List + } + + class RankingScoreProcessor { + <> + -String periodType + -String period + +process(item) WeeklyProductRank + -calculateScore(agg) double + } + + class WeeklyRankingWriter { + <> + -WeeklyRankJpaRepository repository + +write(chunk) void + } + + WeeklyRankingJobConfig --> ProductMetricsAggregateReader + WeeklyRankingJobConfig --> RankingScoreProcessor + WeeklyRankingJobConfig --> WeeklyRankingWriter +``` + +**Chunk-Oriented Processing**: +``` +Step 실행: +1. Reader: product_metrics에서 100개 읽기 +2. Processor: 점수 계산 및 순위 매기기 +3. Writer: mv_product_rank_weekly에 저장 +4. Transaction Commit +5. 다음 Chunk로 반복 +``` + +**책임 분리**: +- **Reader**: 데이터 읽기 (product_metrics 집계) +- **Processor**: 비즈니스 로직 (점수 계산) +- **Writer**: 데이터 쓰기 (Materialized View 저장) + +--- + +## 요약 + +### Round 10에서 추가된 도메인 + +1. **WeeklyProductRank**: 주간 상품 랭킹 (Materialized View) +2. **MonthlyProductRank**: 월간 상품 랭킹 (Materialized View) +3. **Spring Batch 컴포넌트**: Reader, Processor, Writer + +### 설계 패턴 + +- **Materialized View Pattern**: 조회 성능 최적화를 위한 사전 집계 +- **Chunk-Oriented Processing**: Batch 처리의 효율성과 트랜잭션 관리 +- **Facade Pattern**: 랭킹과 상품 정보를 결합하여 제공 + +### 배치 처리 흐름 + +``` +매주 월요일 01:00 (주간 랭킹): +1. product_metrics 집계 +2. 점수 계산 및 순위 매기기 +3. TOP 100 선정 +4. mv_product_rank_weekly 저장 + +매월 1일 02:00 (월간 랭킹): +1. product_metrics 집계 +2. 점수 계산 및 순위 매기기 +3. TOP 100 선정 +4. mv_product_rank_monthly 저장 +``` diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md index 0aadcf02f..88df38f64 100644 --- a/.docs/design/04-erd.md +++ b/.docs/design/04-erd.md @@ -15,6 +15,15 @@ erDiagram orders ||--|{ order_items : "주문 항목" coupons ||--o{ user_coupons : "쿠폰 발급" users ||--o{ user_coupons : "보유 쿠폰" + products ||--o{ product_metrics : "메트릭 집계" + products ||--o{ mv_product_rank_weekly : "주간 랭킹" + products ||--o{ mv_product_rank_monthly : "월간 랭킹" + orders ||--o| payments : "결제 정보" + event_outbox }o--|| orders : "주문 이벤트" + event_outbox }o--|| payments : "결제 이벤트" + event_outbox }o--|| likes : "좋아요 이벤트" + event_inbox }o--|| catalog_events : "카탈로그 이벤트" + event_inbox }o--|| order_events : "주문 이벤트" users { bigint id PK @@ -119,6 +128,94 @@ erDiagram timestamp updated_at timestamp deleted_at } + + product_metrics { + bigint product_id PK + int like_count + int view_count + int order_count + decimal(15_2) sales_amount + int version + timestamp created_at + timestamp updated_at + } + + mv_product_rank_weekly { + bigint id PK + bigint product_id + varchar(10) year_week + int rank_position + double total_score + int like_count + int view_count + int order_count + decimal(15_2) sales_amount + timestamp created_at + timestamp updated_at + } + + mv_product_rank_monthly { + bigint id PK + bigint product_id + varchar(7) year_month + int rank_position + double total_score + int like_count + int view_count + int order_count + decimal(15_2) sales_amount + timestamp created_at + timestamp updated_at + } + + payments { + bigint id PK + varchar(100) transaction_key UK + varchar(20) order_id + varchar(10) user_id + decimal(19_2) amount + varchar(20) status + varchar(500) failure_reason + varchar(50) card_type + varchar(50) card_no + timestamp created_at + timestamp updated_at + } + + event_outbox { + bigint id PK + varchar(50) aggregate_type + varchar(100) aggregate_id + varchar(100) event_type + text payload + varchar(20) status + int retry_count + text error_message + timestamp created_at + timestamp updated_at + } + + event_inbox { + bigint id PK + varchar(100) event_id UK + varchar(50) aggregate_type + varchar(100) aggregate_id + varchar(100) event_type + text payload + timestamp processed_at + timestamp created_at + } + + dead_letter_queue { + bigint id PK + varchar(50) topic + int partition_number + bigint offset_value + varchar(100) event_type + text payload + text error_message + timestamp created_at + } ``` ## 📦 테이블별 상세 설계 @@ -457,6 +554,100 @@ INDEX idx_user_id_is_used (user_id, is_used, deleted_at) --- +## 11. product_metrics (상품 메트릭 집계) + +**설명**: 실시간 상품 이벤트 집계를 저장하는 테이블 (Round 9 추가) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| product_id | bigint | PK | 상품 ID | +| like_count | int | NOT NULL, DEFAULT 0 | 좋아요 수 | +| view_count | int | NOT NULL, DEFAULT 0 | 조회 수 | +| order_count | int | NOT NULL, DEFAULT 0 | 주문 수 | +| sales_amount | decimal(15,2) | NOT NULL, DEFAULT 0.00 | 판매 금액 | +| version | int | NOT NULL, DEFAULT 0 | 낙관적 락 버전 | +| created_at | timestamp | NOT NULL | 생성 시간 | +| updated_at | timestamp | NOT NULL | 수정 시간 | + +**비즈니스 규칙**: +- Kafka 이벤트를 통해 실시간으로 집계 +- `version` 필드를 통한 낙관적 락으로 동시성 제어 +- 일별 데이터 집계 + +**인덱스**: +```sql +INDEX idx_like_count (like_count DESC) +INDEX idx_view_count (view_count DESC) +INDEX idx_order_count (order_count DESC) +INDEX idx_updated_at (updated_at) +``` + +--- + +## 12. mv_product_rank_weekly (주간 랭킹) + +**설명**: 주간 상품 랭킹 Materialized View (Round 10 추가) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| id | bigint | PK, AUTO_INCREMENT | 랭킹 고유 번호 | +| product_id | bigint | NOT NULL | 상품 ID | +| year_week | varchar(10) | NOT NULL | ISO Week 형식 (YYYY-Wnn) | +| rank_position | int | NOT NULL | 순위 (1~100) | +| total_score | double | NOT NULL | 총점 | +| like_count | int | NOT NULL | 좋아요 수 | +| view_count | int | NOT NULL | 조회 수 | +| order_count | int | NOT NULL | 주문 수 | +| sales_amount | decimal(15,2) | NOT NULL | 판매 금액 | +| created_at | timestamp | NOT NULL | 생성 시간 | +| updated_at | timestamp | NOT NULL | 수정 시간 | + +**비즈니스 규칙**: +- Spring Batch로 주 1회 집계 (매주 월요일 01:00) +- TOP 100만 저장 +- 점수 계산: `(view_count × 0.1) + (like_count × 0.2) + (order_count × 0.6 × log10(sales_amount + 1))` + +**인덱스**: +```sql +UNIQUE KEY uk_product_week (product_id, year_week) +INDEX idx_year_week_rank (year_week, rank_position) +INDEX idx_year_week_score (year_week, total_score DESC) +``` + +--- + +## 13. mv_product_rank_monthly (월간 랭킹) + +**설명**: 월간 상품 랭킹 Materialized View (Round 10 추가) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| id | bigint | PK, AUTO_INCREMENT | 랭킹 고유 번호 | +| product_id | bigint | NOT NULL | 상품 ID | +| year_month | varchar(7) | NOT NULL | 년월 형식 (YYYY-MM) | +| rank_position | int | NOT NULL | 순위 (1~100) | +| total_score | double | NOT NULL | 총점 | +| like_count | int | NOT NULL | 좋아요 수 | +| view_count | int | NOT NULL | 조회 수 | +| order_count | int | NOT NULL | 주문 수 | +| sales_amount | decimal(15,2) | NOT NULL | 판매 금액 | +| created_at | timestamp | NOT NULL | 생성 시간 | +| updated_at | timestamp | NOT NULL | 수정 시간 | + +**비즈니스 규칙**: +- Spring Batch로 월 1회 집계 (매월 1일 02:00) +- TOP 100만 저장 +- 점수 계산: 주간 랭킹과 동일 + +**인덱스**: +```sql +UNIQUE KEY uk_product_month (product_id, year_month) +INDEX idx_year_month_rank (year_month, rank_position) +INDEX idx_year_month_score (year_month, total_score DESC) +``` + +--- + ## 업데이트된 order_items 테이블 (스냅샷 패턴) **변경 사항**: `product_id`를 FK에서 일반 컬럼으로 변경, 스냅샷 필드 추가 @@ -479,6 +670,166 @@ INDEX idx_user_id_is_used (user_id, is_used, deleted_at) --- +## 14. payments (결제) + +**설명**: 결제 정보 및 상태 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| id | bigint | PK, AUTO_INCREMENT | 결제 고유 번호 | +| transaction_key | varchar(100) | UNIQUE, NOT NULL | PG사 거래 키 (중복 방지) | +| order_id | varchar(20) | NOT NULL | 주문 ID | +| user_id | varchar(10) | NOT NULL | 사용자 ID | +| amount | decimal(19,2) | NOT NULL | 결제 금액 | +| status | varchar(20) | NOT NULL | 결제 상태 (PENDING, SUCCESS, FAILED) | +| failure_reason | varchar(500) | NULL | 실패 사유 (FAILED 시 필수) | +| card_type | varchar(50) | NULL | 카드 타입 (CREDIT, DEBIT) | +| card_no | varchar(50) | NULL | 카드 번호 (마스킹 처리) | +| created_at | timestamp | NOT NULL | 생성 시간 | +| updated_at | timestamp | NOT NULL | 수정 시간 | + +**비즈니스 규칙**: +- `transaction_key`는 PG사에서 제공하는 고유 거래 식별자 +- 결제 상태는 PENDING → SUCCESS 또는 FAILED로만 변경 가능 +- 결제 실패 시 `failure_reason` 필수 +- `card_no`는 마스킹 처리 (예: 1234-****-****-5678) + +**인덱스**: +```sql +UNIQUE INDEX uk_transaction_key (transaction_key) +INDEX idx_order_id (order_id) +INDEX idx_user_id (user_id) +INDEX idx_status (status) +``` + +--- + +## 15. event_outbox (이벤트 아웃박스) + +**설명**: Transactional Outbox 패턴 구현 (Round 8 추가) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| id | bigint | PK, AUTO_INCREMENT | 아웃박스 고유 번호 | +| aggregate_type | varchar(50) | NOT NULL | 애그리거트 타입 (ORDER, PAYMENT, LIKE) | +| aggregate_id | varchar(100) | NOT NULL | 애그리거트 ID | +| event_type | varchar(100) | NOT NULL | 이벤트 타입 (OrderCreatedEvent 등) | +| payload | text | NOT NULL | 이벤트 페이로드 (JSON) | +| status | varchar(20) | NOT NULL, DEFAULT 'PENDING' | 발행 상태 (PENDING, PUBLISHED, FAILED) | +| retry_count | int | NOT NULL, DEFAULT 0 | 재시도 횟수 | +| error_message | text | NULL | 에러 메시지 (실패 시) | +| created_at | timestamp | NOT NULL | 생성 시간 | +| updated_at | timestamp | NOT NULL | 수정 시간 | + +**비즈니스 규칙**: +- 비즈니스 트랜잭션과 동일한 트랜잭션에서 이벤트 저장 +- 최대 재시도 횟수는 3회 +- 3회 실패 시 status = FAILED (수동 처리 필요) +- OutboxEventPublisher 스케줄러가 주기적으로 PENDING 이벤트 발행 + +**인덱스**: +```sql +INDEX idx_status_created (status, created_at) +INDEX idx_aggregate (aggregate_type, aggregate_id) +``` + +**Transactional Outbox 패턴**: +``` +[목적] +이벤트 발행 실패 시에도 이벤트 손실 방지 + +[흐름] +1. 주문 생성 + EventOutbox 저장 (같은 트랜잭션) +2. 트랜잭션 커밋 +3. OutboxEventPublisher가 PENDING 이벤트 조회 +4. Kafka로 발행 +5. 성공 → PUBLISHED / 실패 → 재시도 +``` + +--- + +## 16. event_inbox (이벤트 인박스) + +**설명**: Event Inbox 패턴 구현 - 멱등성 보장 (Round 9 추가) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| id | bigint | PK, AUTO_INCREMENT | 인박스 고유 번호 | +| event_id | varchar(100) | UNIQUE, NOT NULL | 이벤트 고유 ID (중복 방지) | +| aggregate_type | varchar(50) | NOT NULL | 애그리거트 타입 | +| aggregate_id | varchar(100) | NOT NULL | 애그리거트 ID | +| event_type | varchar(100) | NOT NULL | 이벤트 타입 | +| payload | text | NOT NULL | 이벤트 페이로드 (JSON) | +| processed_at | timestamp | NOT NULL | 처리 시간 | +| created_at | timestamp | NOT NULL | 생성 시간 (수신 시간) | + +**비즈니스 규칙**: +- Kafka 이벤트 수신 시 중복 처리 방지 +- `event_id`가 이미 존재하면 이벤트 스킵 (멱등성 보장) +- 처리 성공 시에만 저장 + +**인덱스**: +```sql +UNIQUE INDEX uk_event_id (event_id) +INDEX idx_aggregate (aggregate_type, aggregate_id) +INDEX idx_processed_at (processed_at) +``` + +**Event Inbox 패턴**: +``` +[목적] +중복 이벤트 처리 방지 (Exactly-once 보장) + +[흐름] +1. Kafka 메시지 수신 +2. event_id 추출 +3. event_inbox 테이블 조회 +4. 중복이면 → 스킵 +5. 중복 아니면 → 비즈니스 로직 실행 + event_inbox 저장 +``` + +--- + +## 17. dead_letter_queue (실패 메시지 저장) + +**설명**: 처리 실패한 Kafka 메시지 저장 (Round 9 추가) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| id | bigint | PK, AUTO_INCREMENT | DLQ 고유 번호 | +| topic | varchar(50) | NOT NULL | Kafka 토픽명 | +| partition_number | int | NOT NULL | 파티션 번호 | +| offset_value | bigint | NOT NULL | 오프셋 값 | +| event_type | varchar(100) | NULL | 이벤트 타입 | +| payload | text | NOT NULL | 메시지 페이로드 | +| error_message | text | NOT NULL | 에러 메시지 | +| created_at | timestamp | NOT NULL | 생성 시간 (실패 시간) | + +**비즈니스 규칙**: +- Kafka Consumer에서 처리 실패한 메시지 저장 +- 수동으로 재처리 가능 +- 에러 분석 및 디버깅 용도 + +**인덱스**: +```sql +INDEX idx_topic_created (topic, created_at DESC) +INDEX idx_event_type (event_type) +``` + +**DLQ 패턴**: +``` +[목적] +실패한 메시지를 별도 저장소에 보관하여 재처리 가능 + +[흐름] +1. Kafka 메시지 처리 중 예외 발생 +2. dead_letter_queue에 저장 +3. 로그로 알림 +4. 수동으로 원인 파악 후 재처리 +``` + +--- + ## 동시성 제어 전략 요약 ### Version 필드 (@Version)