From 7c9d5f4ce4f67b62c044bb4e1705df2edc8810d5 Mon Sep 17 00:00:00 2001 From: MINJOOOONG Date: Sun, 12 Apr 2026 15:15:54 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20daily=20metrics=20snapshot=20batch?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DailyMetricsSnapshotJobConfig.java | 50 +++++ .../step/DailyMetricsSnapshotTasklet.java | 62 ++++++ .../domain/productmetrics/ProductMetrics.java | 40 ++++ .../ProductMetricsRepository.java | 15 ++ .../ranking/RankingSnapshotRepository.java | 10 + .../ProductMetricsJpaRepository.java | 19 ++ .../ProductMetricsRepositoryImpl.java | 36 ++++ .../RankingSnapshotRedisRepository.java | 41 ++++ .../DailyMetricsSnapshotJobE2ETest.java | 183 ++++++++++++++++++ 9 files changed, 456 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/DailyMetricsSnapshotJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingSnapshotRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/DailyMetricsSnapshotJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/DailyMetricsSnapshotJobConfig.java new file mode 100644 index 0000000000..b8ba418199 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/DailyMetricsSnapshotJobConfig.java @@ -0,0 +1,50 @@ +package com.loopers.batch.job.dailymetrics; + +import com.loopers.batch.job.dailymetrics.step.DailyMetricsSnapshotTasklet; +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DailyMetricsSnapshotJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class DailyMetricsSnapshotJobConfig { + + public static final String JOB_NAME = "dailyMetricsSnapshotJob"; + private static final String STEP_NAME = "dailyMetricsSnapshotStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DailyMetricsSnapshotTasklet dailyMetricsSnapshotTasklet; + + @Bean(JOB_NAME) + public Job dailyMetricsSnapshotJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(dailyMetricsSnapshotStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step dailyMetricsSnapshotStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(dailyMetricsSnapshotTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java new file mode 100644 index 0000000000..3308000f0c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java @@ -0,0 +1,62 @@ +package com.loopers.batch.job.dailymetrics.step; + +import com.loopers.batch.job.dailymetrics.DailyMetricsSnapshotJobConfig; +import com.loopers.domain.productmetrics.ProductMetrics; +import com.loopers.domain.productmetrics.ProductMetricsRepository; +import com.loopers.domain.ranking.RankingSnapshotRepository; +import com.loopers.domain.ranking.RankingSnapshotRepository.ProductScore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DailyMetricsSnapshotJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class DailyMetricsSnapshotTasklet implements Tasklet { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @Value("#{jobParameters['requestDate']}") + private LocalDate requestDate; + + private final RankingSnapshotRepository rankingSnapshotRepository; + private final ProductMetricsRepository productMetricsRepository; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + if (requestDate == null) { + throw new RuntimeException("requestDate is required"); + } + + String dateKey = requestDate.format(DATE_FORMAT); + log.info("일간 스냅샷 배치 시작 - requestDate: {}, dateKey: {}", requestDate, dateKey); + + List scores = rankingSnapshotRepository.getAllScores(dateKey); + log.info("Redis에서 조회된 상품 수: {}", scores.size()); + + productMetricsRepository.deleteByMetricDate(requestDate); + + if (!scores.isEmpty()) { + List metricsList = scores.stream() + .map(ps -> new ProductMetrics(ps.productId(), requestDate, ps.score())) + .toList(); + productMetricsRepository.saveAll(metricsList); + log.info("product_metrics 적재 완료 - {} 건", metricsList.size()); + } + + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java new file mode 100644 index 0000000000..8c4470e15c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java @@ -0,0 +1,40 @@ +package com.loopers.domain.productmetrics; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Entity +@Table(name = "product_metrics", + uniqueConstraints = @UniqueConstraint( + name = "uk_product_metrics", + columnNames = {"product_id", "metric_date"} + ), + indexes = @Index(name = "idx_metric_date", columnList = "metric_date") +) +public class ProductMetrics extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "metric_date", nullable = false) + private LocalDate metricDate; + + @Column(name = "score", nullable = false) + private double score; + + protected ProductMetrics() {} + + public ProductMetrics(Long productId, LocalDate metricDate, double score) { + this.productId = productId; + this.metricDate = metricDate; + this.score = score; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java new file mode 100644 index 0000000000..2b0706075f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.productmetrics; + +import java.time.LocalDate; +import java.util.List; + +public interface ProductMetricsRepository { + + ProductMetrics save(ProductMetrics productMetrics); + + List saveAll(List productMetricsList); + + List findByMetricDate(LocalDate metricDate); + + void deleteByMetricDate(LocalDate metricDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingSnapshotRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingSnapshotRepository.java new file mode 100644 index 0000000000..3b0b5e1b54 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingSnapshotRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.ranking; + +import java.util.List; + +public interface RankingSnapshotRepository { + + List getAllScores(String dateKey); + + record ProductScore(Long productId, double score) {} +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java new file mode 100644 index 0000000000..137fb07ca1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.productmetrics; + +import com.loopers.domain.productmetrics.ProductMetrics; +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.time.LocalDate; +import java.util.List; + +public interface ProductMetricsJpaRepository extends JpaRepository { + + List findByMetricDate(LocalDate metricDate); + + @Modifying + @Query("DELETE FROM ProductMetrics pm WHERE pm.metricDate = :metricDate") + void deleteByMetricDate(@Param("metricDate") LocalDate metricDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java new file mode 100644 index 0000000000..44fc010244 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.productmetrics; + +import com.loopers.domain.productmetrics.ProductMetrics; +import com.loopers.domain.productmetrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + @Override + public ProductMetrics save(ProductMetrics productMetrics) { + return productMetricsJpaRepository.save(productMetrics); + } + + @Override + public List saveAll(List productMetricsList) { + return productMetricsJpaRepository.saveAll(productMetricsList); + } + + @Override + public List findByMetricDate(LocalDate metricDate) { + return productMetricsJpaRepository.findByMetricDate(metricDate); + } + + @Override + public void deleteByMetricDate(LocalDate metricDate) { + productMetricsJpaRepository.deleteByMetricDate(metricDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java new file mode 100644 index 0000000000..8a44575316 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.RankingSnapshotRepository; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Component +public class RankingSnapshotRedisRepository implements RankingSnapshotRepository { + + private static final String KEY_PREFIX = "ranking:all:"; + + private final RedisTemplate redisTemplate; + + public RankingSnapshotRedisRepository(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + @Override + public List getAllScores(String dateKey) { + String key = KEY_PREFIX + dateKey; + Set> tuples = + redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, -1); + + if (tuples == null || tuples.isEmpty()) { + return List.of(); + } + + List result = new ArrayList<>(); + for (ZSetOperations.TypedTuple tuple : tuples) { + Long productId = Long.valueOf(tuple.getValue()); + double score = tuple.getScore(); + result.add(new ProductScore(productId, score)); + } + return result; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java new file mode 100644 index 0000000000..ba48282409 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java @@ -0,0 +1,183 @@ +package com.loopers.job.dailymetrics; + +import com.loopers.batch.job.dailymetrics.DailyMetricsSnapshotJobConfig; +import com.loopers.domain.productmetrics.ProductMetrics; +import com.loopers.infrastructure.productmetrics.ProductMetricsJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.data.redis.core.RedisTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + DailyMetricsSnapshotJobConfig.JOB_NAME) +class DailyMetricsSnapshotJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(DailyMetricsSnapshotJobConfig.JOB_NAME) + private Job job; + + @Autowired + private ProductMetricsJpaRepository productMetricsJpaRepository; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + private static final String KEY_PREFIX = "ranking:all:"; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + @DisplayName("일간 스냅샷 배치") + @Nested + class DailySnapshot { + + @DisplayName("requestDate 파라미터가 없으면 배치가 실패한다.") + @Test + void failsWithoutRequestDate() 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("Redis에 일간 점수가 있으면 product_metrics에 정확히 적재된다.") + @Test + void snapshotsRedisScoresToProductMetrics() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + String dateKey = "20260412"; + String redisKey = KEY_PREFIX + dateKey; + redisTemplate.opsForZSet().add(redisKey, "1", 150.5); + redisTemplate.opsForZSet().add(redisKey, "2", 300.0); + redisTemplate.opsForZSet().add(redisKey, "3", 75.2); + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.of(2026, 4, 12)) + .addLong("run.id", 100L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List metrics = productMetricsJpaRepository.findAll(); + assertAll( + () -> assertThat(metrics).hasSize(3), + () -> assertThat(metrics) + .extracting(ProductMetrics::getProductId) + .containsExactlyInAnyOrder(1L, 2L, 3L), + () -> assertThat(metrics) + .filteredOn(m -> m.getProductId().equals(2L)) + .first() + .satisfies(m -> assertThat(m.getScore()).isEqualTo(300.0)) + ); + } + + @DisplayName("같은 날짜로 재실행하면 기존 데이터를 교체한다(멱등성).") + @Test + void replacesExistingDataOnRerun() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + String dateKey = "20260412"; + String redisKey = KEY_PREFIX + dateKey; + LocalDate metricDate = LocalDate.of(2026, 4, 12); + + // 1차 실행 — 점수 100 + redisTemplate.opsForZSet().add(redisKey, "1", 100.0); + + var jobParameters1 = new JobParametersBuilder() + .addLocalDate("requestDate", metricDate) + .addLong("run.id", 1L) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters1); + + // 점수 변경 — 200으로 + redisTemplate.opsForZSet().add(redisKey, "1", 200.0); + + // 2차 실행 + var jobParameters2 = new JobParametersBuilder() + .addLocalDate("requestDate", metricDate) + .addLong("run.id", 2L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters2); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List metrics = productMetricsJpaRepository.findByMetricDate(metricDate); + assertAll( + () -> assertThat(metrics).hasSize(1), + () -> assertThat(metrics.get(0).getScore()).isEqualTo(200.0) + ); + } + + @DisplayName("Redis에 해당 날짜 데이터가 없으면 빈 결과로 정상 완료된다.") + @Test + void completesWithEmptyRedisData() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.of(2026, 4, 12)) + .addLong("run.id", 200L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(productMetricsJpaRepository.findAll()).isEmpty() + ); + } + } +} From 0451247a2ab6764295d3a7fbb4b4b1eb7c773bdf Mon Sep 17 00:00:00 2001 From: MINJOOOONG Date: Sun, 12 Apr 2026 15:59:16 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20daily=20snapshot=20batch=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=EC=84=B1=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../step/DailyMetricsSnapshotTasklet.java | 39 ++++++++++--- .../domain/productmetrics/ProductMetrics.java | 14 +++++ .../RankingSnapshotRedisRepository.java | 13 ++++- .../ProductMetricsUnitTest.java | 51 +++++++++++++++++ .../DailyMetricsSnapshotJobE2ETest.java | 55 +++++++++++++++++++ 5 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/productmetrics/ProductMetricsUnitTest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java index 3308000f0c..7c0ae772a9 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java @@ -18,6 +18,7 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; @Slf4j @@ -37,26 +38,46 @@ public class DailyMetricsSnapshotTasklet implements Tasklet { @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { - if (requestDate == null) { - throw new RuntimeException("requestDate is required"); - } + validateRequestDate(); String dateKey = requestDate.format(DATE_FORMAT); log.info("일간 스냅샷 배치 시작 - requestDate: {}, dateKey: {}", requestDate, dateKey); List scores = rankingSnapshotRepository.getAllScores(dateKey); - log.info("Redis에서 조회된 상품 수: {}", scores.size()); + log.info("Redis에서 조회된 항목 수: {}", scores.size()); productMetricsRepository.deleteByMetricDate(requestDate); if (!scores.isEmpty()) { - List metricsList = scores.stream() - .map(ps -> new ProductMetrics(ps.productId(), requestDate, ps.score())) - .toList(); - productMetricsRepository.saveAll(metricsList); - log.info("product_metrics 적재 완료 - {} 건", metricsList.size()); + List metricsList = new ArrayList<>(); + int skipCount = 0; + + for (ProductScore ps : scores) { + if (ps.productId() == null) { + log.warn("productId가 null인 항목 skip - score: {}", ps.score()); + skipCount++; + continue; + } + metricsList.add(new ProductMetrics(ps.productId(), requestDate, ps.score())); + } + + if (!metricsList.isEmpty()) { + productMetricsRepository.saveAll(metricsList); + } + + contribution.incrementWriteCount(metricsList.size()); + log.info("product_metrics 적재 완료 - 적재: {} 건, skip: {} 건", metricsList.size(), skipCount); } return RepeatStatus.FINISHED; } + + private void validateRequestDate() { + if (requestDate == null) { + throw new RuntimeException("requestDate is required"); + } + if (requestDate.isAfter(LocalDate.now())) { + throw new RuntimeException("requestDate는 미래 날짜일 수 없습니다: " + requestDate); + } + } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java index 8c4470e15c..b8adc50199 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java @@ -36,5 +36,19 @@ public ProductMetrics(Long productId, LocalDate metricDate, double score) { this.productId = productId; this.metricDate = metricDate; this.score = score; + guard(); + } + + @Override + protected void guard() { + if (productId == null) { + throw new IllegalArgumentException("productId는 null일 수 없습니다"); + } + if (metricDate == null) { + throw new IllegalArgumentException("metricDate는 null일 수 없습니다"); + } + if (score < 0) { + throw new IllegalArgumentException("score는 0 이상이어야 합니다"); + } } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java index 8a44575316..e108bce54c 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java @@ -1,6 +1,7 @@ package com.loopers.infrastructure.ranking; import com.loopers.domain.ranking.RankingSnapshotRepository; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.stereotype.Component; @@ -9,6 +10,7 @@ import java.util.List; import java.util.Set; +@Slf4j @Component public class RankingSnapshotRedisRepository implements RankingSnapshotRepository { @@ -32,9 +34,14 @@ public List getAllScores(String dateKey) { List result = new ArrayList<>(); for (ZSetOperations.TypedTuple tuple : tuples) { - Long productId = Long.valueOf(tuple.getValue()); - double score = tuple.getScore(); - result.add(new ProductScore(productId, score)); + String value = tuple.getValue(); + try { + Long productId = Long.valueOf(value); + double score = tuple.getScore(); + result.add(new ProductScore(productId, score)); + } catch (NumberFormatException e) { + log.warn("Redis value 파싱 실패 - key: {}, value: '{}', score: {} (skip)", key, value, tuple.getScore()); + } } return result; } diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/productmetrics/ProductMetricsUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/productmetrics/ProductMetricsUnitTest.java new file mode 100644 index 0000000000..fa73f41dbe --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/productmetrics/ProductMetricsUnitTest.java @@ -0,0 +1,51 @@ +package com.loopers.domain.productmetrics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductMetricsUnitTest { + + @DisplayName("ProductMetrics 생성") + @Nested + class Create { + + @DisplayName("유효한 값으로 생성하면 성공한다.") + @Test + void createsWithValidValues() { + // arrange & act + var metrics = new ProductMetrics(1L, LocalDate.of(2026, 4, 12), 150.5); + + // assert + assertThat(metrics.getProductId()).isEqualTo(1L); + assertThat(metrics.getMetricDate()).isEqualTo(LocalDate.of(2026, 4, 12)); + assertThat(metrics.getScore()).isEqualTo(150.5); + } + + @DisplayName("productId가 null이면 생성에 실패한다.") + @Test + void failsWithNullProductId() { + assertThatThrownBy(() -> new ProductMetrics(null, LocalDate.of(2026, 4, 12), 100.0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("metricDate가 null이면 생성에 실패한다.") + @Test + void failsWithNullMetricDate() { + assertThatThrownBy(() -> new ProductMetrics(1L, null, 100.0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("score가 음수이면 생성에 실패한다.") + @Test + void failsWithNegativeScore() { + assertThatThrownBy(() -> new ProductMetrics(1L, LocalDate.of(2026, 4, 12), -1.0)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java index ba48282409..6f46760978 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java @@ -21,9 +21,11 @@ import org.springframework.test.context.TestPropertySource; import java.time.LocalDate; +import java.util.Collection; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest @@ -158,6 +160,59 @@ void replacesExistingDataOnRerun() throws Exception { ); } + @DisplayName("미래 날짜의 requestDate가 주어지면 배치가 실패한다.") + @Test + void failsWithFutureRequestDate() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + LocalDate futureDate = LocalDate.now().plusDays(1); + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", futureDate) + .addLong("run.id", 300L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("Redis에 파싱 불가능한 value가 섞여 있으면 정상 데이터만 적재된다.") + @Test + void skipsInvalidRedisValues() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + String dateKey = "20260410"; + String redisKey = KEY_PREFIX + dateKey; + redisTemplate.opsForZSet().add(redisKey, "1", 100.0); + redisTemplate.opsForZSet().add(redisKey, "abc", 50.0); + redisTemplate.opsForZSet().add(redisKey, "3", 200.0); + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.of(2026, 4, 10)) + .addLong("run.id", 400L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List metrics = productMetricsJpaRepository.findAll(); + assertAll( + () -> assertThat(metrics).hasSize(2), + () -> assertThat(metrics) + .extracting(ProductMetrics::getProductId) + .containsExactlyInAnyOrder(1L, 3L) + ); + } + @DisplayName("Redis에 해당 날짜 데이터가 없으면 빈 결과로 정상 완료된다.") @Test void completesWithEmptyRedisData() throws Exception { From fcffa97dd4ac9adeca1eabb822edc981bb5f44a5 Mon Sep 17 00:00:00 2001 From: MINJOOOONG Date: Sun, 12 Apr 2026 16:12:24 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20batch=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=EC=84=B1=20=EB=B3=B4=EA=B0=95=20(empty=20result=20guard,=20bat?= =?UTF-8?q?ch=20insert,=20data=20protection)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../step/DailyMetricsSnapshotTasklet.java | 53 +++++++++++++------ .../DailyMetricsSnapshotJobE2ETest.java | 44 ++++++++++++++- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java index 7c0ae772a9..cad03308c1 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java @@ -16,6 +16,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import jakarta.persistence.EntityManager; + import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -33,8 +35,11 @@ public class DailyMetricsSnapshotTasklet implements Tasklet { @Value("#{jobParameters['requestDate']}") private LocalDate requestDate; + private static final int BATCH_SIZE = 1000; + private final RankingSnapshotRepository rankingSnapshotRepository; private final ProductMetricsRepository productMetricsRepository; + private final EntityManager entityManager; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { @@ -46,32 +51,46 @@ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkCon List scores = rankingSnapshotRepository.getAllScores(dateKey); log.info("Redis에서 조회된 항목 수: {}", scores.size()); - productMetricsRepository.deleteByMetricDate(requestDate); + if (scores.isEmpty()) { + log.warn("Redis에 {} 날짜의 랭킹 데이터가 없습니다. 기존 데이터를 유지하고 스킵합니다.", dateKey); + return RepeatStatus.FINISHED; + } - if (!scores.isEmpty()) { - List metricsList = new ArrayList<>(); - int skipCount = 0; - - for (ProductScore ps : scores) { - if (ps.productId() == null) { - log.warn("productId가 null인 항목 skip - score: {}", ps.score()); - skipCount++; - continue; - } - metricsList.add(new ProductMetrics(ps.productId(), requestDate, ps.score())); - } + List metricsList = new ArrayList<>(); + int skipCount = 0; - if (!metricsList.isEmpty()) { - productMetricsRepository.saveAll(metricsList); + for (ProductScore ps : scores) { + if (ps.productId() == null) { + log.warn("productId가 null인 항목 skip - score: {}", ps.score()); + skipCount++; + continue; } + metricsList.add(new ProductMetrics(ps.productId(), requestDate, ps.score())); + } - contribution.incrementWriteCount(metricsList.size()); - log.info("product_metrics 적재 완료 - 적재: {} 건, skip: {} 건", metricsList.size(), skipCount); + if (metricsList.isEmpty()) { + log.warn("유효한 적재 대상이 0건입니다 (전체 skip: {} 건). 기존 데이터를 유지합니다.", skipCount); + return RepeatStatus.FINISHED; } + productMetricsRepository.deleteByMetricDate(requestDate); + saveInBatches(metricsList); + + contribution.incrementWriteCount(metricsList.size()); + log.info("product_metrics 적재 완료 - 적재: {} 건, skip: {} 건", metricsList.size(), skipCount); + return RepeatStatus.FINISHED; } + private void saveInBatches(List metricsList) { + for (int i = 0; i < metricsList.size(); i += BATCH_SIZE) { + int end = Math.min(i + BATCH_SIZE, metricsList.size()); + productMetricsRepository.saveAll(metricsList.subList(i, end)); + entityManager.flush(); + entityManager.clear(); + } + } + private void validateRequestDate() { if (requestDate == null) { throw new RuntimeException("requestDate is required"); diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java index 6f46760978..4aba41f83f 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java @@ -213,9 +213,49 @@ void skipsInvalidRedisValues() throws Exception { ); } - @DisplayName("Redis에 해당 날짜 데이터가 없으면 빈 결과로 정상 완료된다.") + @DisplayName("Redis에 해당 날짜 데이터가 없으면 기존 DB 데이터를 삭제하지 않고 정상 완료된다.") @Test - void completesWithEmptyRedisData() throws Exception { + void preservesExistingDataWhenRedisEmpty() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 기존 데이터를 먼저 적재 + String dateKey = "20260412"; + String redisKey = KEY_PREFIX + dateKey; + LocalDate metricDate = LocalDate.of(2026, 4, 12); + + redisTemplate.opsForZSet().add(redisKey, "1", 100.0); + var seedParams = new JobParametersBuilder() + .addLocalDate("requestDate", metricDate) + .addLong("run.id", 500L) + .toJobParameters(); + jobLauncherTestUtils.launchJob(seedParams); + + assertThat(productMetricsJpaRepository.findByMetricDate(metricDate)).hasSize(1); + + // Redis 데이터 삭제 → 빈 상태로 만듦 + redisTemplate.delete(redisKey); + + // act — Redis 비어 있는 상태에서 재실행 + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", metricDate) + .addLong("run.id", 501L) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert — 기존 데이터가 보전되어야 한다 + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(productMetricsJpaRepository.findByMetricDate(metricDate)).hasSize(1), + () -> assertThat(productMetricsJpaRepository.findByMetricDate(metricDate).get(0).getScore()) + .isEqualTo(100.0) + ); + } + + @DisplayName("Redis에 해당 날짜 데이터가 없고 DB에도 없으면 빈 결과로 정상 완료된다.") + @Test + void completesWithEmptyRedisAndEmptyDb() throws Exception { // arrange jobLauncherTestUtils.setJob(job); From 9abc3ce4e4ce7abbd11d64837005b4e2d0a134bc Mon Sep 17 00:00:00 2001 From: MINJOOOONG Date: Sun, 12 Apr 2026 20:18:35 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20weekly/monthly=20rank=20aggregation?= =?UTF-8?q?=20batch=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RankAggregationJobConfig.java | 118 ++++++ .../rankaggregate/step/MonthlyRankWriter.java | 46 +++ .../step/ProductScoreAggregation.java | 3 + .../step/RankAggregationReader.java | 46 +++ .../rankaggregate/step/WeeklyRankWriter.java | 53 +++ .../domain/productrank/BaseProductRank.java | 51 +++ .../productrank/MonthlyProductRank.java | 27 ++ .../MonthlyProductRankRepository.java | 11 + .../domain/productrank/WeeklyProductRank.java | 27 ++ .../WeeklyProductRankRepository.java | 11 + .../MonthlyProductRankJpaRepository.java | 16 + .../MonthlyProductRankRepositoryImpl.java | 26 ++ .../WeeklyProductRankJpaRepository.java | 16 + .../WeeklyProductRankRepositoryImpl.java | 26 ++ .../MonthlyProductRankUnitTest.java | 62 +++ .../WeeklyProductRankUnitTest.java | 62 +++ .../RankAggregationJobE2ETest.java | 374 ++++++++++++++++++ 17 files changed, 975 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/RankAggregationJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/MonthlyRankWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/ProductScoreAggregation.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/RankAggregationReader.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/WeeklyRankWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productrank/BaseProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRankRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRankRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankRepositoryImpl.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/productrank/MonthlyProductRankUnitTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/productrank/WeeklyProductRankUnitTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/rankaggregate/RankAggregationJobE2ETest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/RankAggregationJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/RankAggregationJobConfig.java new file mode 100644 index 0000000000..d531176831 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/RankAggregationJobConfig.java @@ -0,0 +1,118 @@ +package com.loopers.batch.job.rankaggregate; + +import com.loopers.batch.job.rankaggregate.step.MonthlyRankWriter; +import com.loopers.batch.job.rankaggregate.step.ProductScoreAggregation; +import com.loopers.batch.job.rankaggregate.step.RankAggregationReader; +import com.loopers.batch.job.rankaggregate.step.WeeklyRankWriter; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.productrank.MonthlyProductRankRepository; +import com.loopers.domain.productrank.WeeklyProductRankRepository; +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.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; + +@Slf4j +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankAggregationJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class RankAggregationJobConfig { + + public static final String JOB_NAME = "rankAggregationJob"; + private static final String WEEKLY_STEP_NAME = "weeklyRankStep"; + private static final String MONTHLY_STEP_NAME = "monthlyRankStep"; + private static final int CHUNK_SIZE = 100; + private static final int WEEKLY_DAYS = 7; + private static final int MONTHLY_DAYS = 30; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DataSource dataSource; + private final WeeklyProductRankRepository weeklyProductRankRepository; + private final MonthlyProductRankRepository monthlyProductRankRepository; + + @Bean(JOB_NAME) + public Job rankAggregationJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(weeklyRankStep(null)) + .next(monthlyRankStep(null)) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(WEEKLY_STEP_NAME) + public Step weeklyRankStep( + @Value("#{jobParameters['requestDate']}") LocalDate requestDate + ) { + validateRequestDate(requestDate); + LocalDate startDate = requestDate.minusDays(WEEKLY_DAYS - 1); + + log.info("주간 랭킹 집계 Step 구성 - 기간: {} ~ {}", startDate, requestDate); + + return new StepBuilder(WEEKLY_STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyReader(startDate, requestDate)) + .writer(new WeeklyRankWriter(weeklyProductRankRepository, requestDate)) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(MONTHLY_STEP_NAME) + public Step monthlyRankStep( + @Value("#{jobParameters['requestDate']}") LocalDate requestDate + ) { + validateRequestDate(requestDate); + LocalDate startDate = requestDate.minusDays(MONTHLY_DAYS - 1); + + log.info("월간 랭킹 집계 Step 구성 - 기간: {} ~ {}", startDate, requestDate); + + return new StepBuilder(MONTHLY_STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyReader(startDate, requestDate)) + .writer(new MonthlyRankWriter(monthlyProductRankRepository, requestDate)) + .listener(stepMonitorListener) + .build(); + } + + private JdbcCursorItemReader weeklyReader( + LocalDate startDate, LocalDate endDate + ) { + return RankAggregationReader.create("weeklyRankReader", dataSource, startDate, endDate); + } + + private JdbcCursorItemReader monthlyReader( + LocalDate startDate, LocalDate endDate + ) { + return RankAggregationReader.create("monthlyRankReader", dataSource, startDate, endDate); + } + + private void validateRequestDate(LocalDate requestDate) { + if (requestDate == null) { + throw new RuntimeException("requestDate is required"); + } + if (requestDate.isAfter(LocalDate.now())) { + throw new RuntimeException("requestDate는 미래 날짜일 수 없습니다: " + requestDate); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/MonthlyRankWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/MonthlyRankWriter.java new file mode 100644 index 0000000000..3eeb5b9a32 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/MonthlyRankWriter.java @@ -0,0 +1,46 @@ +package com.loopers.batch.job.rankaggregate.step; + +import com.loopers.domain.productrank.MonthlyProductRank; +import com.loopers.domain.productrank.MonthlyProductRankRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +@Slf4j +@RequiredArgsConstructor +public class MonthlyRankWriter implements ItemWriter { + + private final MonthlyProductRankRepository monthlyProductRankRepository; + private final LocalDate baseDate; + private final AtomicBoolean deleted = new AtomicBoolean(false); + + @Override + public void write(Chunk chunk) { + if (deleted.compareAndSet(false, true)) { + monthlyProductRankRepository.deleteByBaseDate(baseDate); + log.info("기존 monthly 랭킹 삭제 완료 - baseDate: {}", baseDate); + } + + List ranks = new ArrayList<>(); + + for (int i = 0; i < chunk.size(); i++) { + ProductScoreAggregation agg = chunk.getItems().get(i); + ranks.add(new MonthlyProductRank( + agg.productId(), + agg.totalScore(), + i + 1, + baseDate + )); + } + + monthlyProductRankRepository.saveAll(ranks); + log.info("monthly 랭킹 적재 - {} 건 (ranking 1 ~ {})", + ranks.size(), ranks.size()); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/ProductScoreAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/ProductScoreAggregation.java new file mode 100644 index 0000000000..9130aec1b1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/ProductScoreAggregation.java @@ -0,0 +1,3 @@ +package com.loopers.batch.job.rankaggregate.step; + +public record ProductScoreAggregation(Long productId, double totalScore) {} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/RankAggregationReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/RankAggregationReader.java new file mode 100644 index 0000000000..4bb9bba43b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/RankAggregationReader.java @@ -0,0 +1,46 @@ +package com.loopers.batch.job.rankaggregate.step; + +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.jdbc.core.RowMapper; + +import javax.sql.DataSource; +import java.time.LocalDate; + +public class RankAggregationReader { + + private static final String AGGREGATION_SQL = """ + SELECT pm.product_id, SUM(pm.score) as total_score + FROM product_metrics pm + WHERE pm.metric_date BETWEEN ? AND ? + GROUP BY pm.product_id + ORDER BY total_score DESC + LIMIT 100 + """; + + private static final RowMapper ROW_MAPPER = (rs, rowNum) -> + new ProductScoreAggregation( + rs.getLong("product_id"), + rs.getDouble("total_score") + ); + + private RankAggregationReader() {} + + public static JdbcCursorItemReader create( + String name, + DataSource dataSource, + LocalDate startDate, + LocalDate endDate + ) { + return new JdbcCursorItemReaderBuilder() + .name(name) + .dataSource(dataSource) + .sql(AGGREGATION_SQL) + .rowMapper(ROW_MAPPER) + .preparedStatementSetter(ps -> { + ps.setObject(1, startDate); + ps.setObject(2, endDate); + }) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/WeeklyRankWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/WeeklyRankWriter.java new file mode 100644 index 0000000000..f197a77e5f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/WeeklyRankWriter.java @@ -0,0 +1,53 @@ +package com.loopers.batch.job.rankaggregate.step; + +import com.loopers.domain.productrank.WeeklyProductRank; +import com.loopers.domain.productrank.WeeklyProductRankRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +@Slf4j +@RequiredArgsConstructor +public class WeeklyRankWriter implements ItemWriter { + + private final WeeklyProductRankRepository weeklyProductRankRepository; + private final LocalDate baseDate; + private final AtomicBoolean deleted = new AtomicBoolean(false); + + @Override + public void write(Chunk chunk) { + if (deleted.compareAndSet(false, true)) { + weeklyProductRankRepository.deleteByBaseDate(baseDate); + log.info("기존 weekly 랭킹 삭제 완료 - baseDate: {}", baseDate); + } + + List ranks = new ArrayList<>(); + int rankOffset = getRankOffset(chunk); + + for (int i = 0; i < chunk.size(); i++) { + ProductScoreAggregation agg = chunk.getItems().get(i); + ranks.add(new WeeklyProductRank( + agg.productId(), + agg.totalScore(), + rankOffset + i + 1, + baseDate + )); + } + + weeklyProductRankRepository.saveAll(ranks); + log.info("weekly 랭킹 적재 - {} 건 (ranking {} ~ {})", + ranks.size(), rankOffset + 1, rankOffset + ranks.size()); + } + + private int getRankOffset(Chunk chunk) { + // TOP 100이므로 chunkSize >= 100이면 항상 첫 번째 chunk에서 모두 처리됨 + // 만약 chunkSize가 100보다 작은 경우를 대비한 안전 장치 + return 0; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/BaseProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/BaseProductRank.java new file mode 100644 index 0000000000..55348efca6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/BaseProductRank.java @@ -0,0 +1,51 @@ +package com.loopers.domain.productrank; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@MappedSuperclass +public abstract class BaseProductRank extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "total_score", nullable = false) + private double totalScore; + + @Column(name = "ranking", nullable = false) + private int ranking; + + @Column(name = "base_date", nullable = false) + private LocalDate baseDate; + + protected BaseProductRank() {} + + protected BaseProductRank(Long productId, double totalScore, int ranking, LocalDate baseDate) { + this.productId = productId; + this.totalScore = totalScore; + this.ranking = ranking; + this.baseDate = baseDate; + guard(); + } + + @Override + protected void guard() { + if (productId == null) { + throw new IllegalArgumentException("productId는 null일 수 없습니다"); + } + if (totalScore < 0) { + throw new IllegalArgumentException("totalScore는 0 이상이어야 합니다"); + } + if (ranking < 1) { + throw new IllegalArgumentException("ranking은 1 이상이어야 합니다"); + } + if (baseDate == null) { + throw new IllegalArgumentException("baseDate는 null일 수 없습니다"); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRank.java new file mode 100644 index 0000000000..cc4d721575 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRank.java @@ -0,0 +1,27 @@ +package com.loopers.domain.productrank; + +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Entity +@Table(name = "mv_product_rank_monthly", + uniqueConstraints = @UniqueConstraint( + name = "uk_monthly_rank", + columnNames = {"base_date", "product_id"} + ), + indexes = @Index(name = "idx_monthly_base_date", columnList = "base_date") +) +public class MonthlyProductRank extends BaseProductRank { + + protected MonthlyProductRank() {} + + public MonthlyProductRank(Long productId, double totalScore, int ranking, LocalDate baseDate) { + super(productId, totalScore, ranking, baseDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRankRepository.java new file mode 100644 index 0000000000..86a24086db --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRankRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.productrank; + +import java.time.LocalDate; +import java.util.List; + +public interface MonthlyProductRankRepository { + + List saveAll(List ranks); + + void deleteByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRank.java new file mode 100644 index 0000000000..85a20b3a41 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRank.java @@ -0,0 +1,27 @@ +package com.loopers.domain.productrank; + +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Entity +@Table(name = "mv_product_rank_weekly", + uniqueConstraints = @UniqueConstraint( + name = "uk_weekly_rank", + columnNames = {"base_date", "product_id"} + ), + indexes = @Index(name = "idx_weekly_base_date", columnList = "base_date") +) +public class WeeklyProductRank extends BaseProductRank { + + protected WeeklyProductRank() {} + + public WeeklyProductRank(Long productId, double totalScore, int ranking, LocalDate baseDate) { + super(productId, totalScore, ranking, baseDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRankRepository.java new file mode 100644 index 0000000000..407cc5c943 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRankRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.productrank; + +import java.time.LocalDate; +import java.util.List; + +public interface WeeklyProductRankRepository { + + List saveAll(List ranks); + + void deleteByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankJpaRepository.java new file mode 100644 index 0000000000..29f17b317b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.productrank; + +import com.loopers.domain.productrank.MonthlyProductRank; +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.time.LocalDate; + +public interface MonthlyProductRankJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM MonthlyProductRank r WHERE r.baseDate = :baseDate") + void deleteByBaseDate(@Param("baseDate") LocalDate baseDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankRepositoryImpl.java new file mode 100644 index 0000000000..0056fc5da6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.productrank; + +import com.loopers.domain.productrank.MonthlyProductRank; +import com.loopers.domain.productrank.MonthlyProductRankRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class MonthlyProductRankRepositoryImpl implements MonthlyProductRankRepository { + + private final MonthlyProductRankJpaRepository monthlyProductRankJpaRepository; + + @Override + public List saveAll(List ranks) { + return monthlyProductRankJpaRepository.saveAll(ranks); + } + + @Override + public void deleteByBaseDate(LocalDate baseDate) { + monthlyProductRankJpaRepository.deleteByBaseDate(baseDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankJpaRepository.java new file mode 100644 index 0000000000..5ec5092f4d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.productrank; + +import com.loopers.domain.productrank.WeeklyProductRank; +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.time.LocalDate; + +public interface WeeklyProductRankJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM WeeklyProductRank r WHERE r.baseDate = :baseDate") + void deleteByBaseDate(@Param("baseDate") LocalDate baseDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankRepositoryImpl.java new file mode 100644 index 0000000000..fc1c8b9e2c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.productrank; + +import com.loopers.domain.productrank.WeeklyProductRank; +import com.loopers.domain.productrank.WeeklyProductRankRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class WeeklyProductRankRepositoryImpl implements WeeklyProductRankRepository { + + private final WeeklyProductRankJpaRepository weeklyProductRankJpaRepository; + + @Override + public List saveAll(List ranks) { + return weeklyProductRankJpaRepository.saveAll(ranks); + } + + @Override + public void deleteByBaseDate(LocalDate baseDate) { + weeklyProductRankJpaRepository.deleteByBaseDate(baseDate); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/productrank/MonthlyProductRankUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/productrank/MonthlyProductRankUnitTest.java new file mode 100644 index 0000000000..51e5db5e61 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/productrank/MonthlyProductRankUnitTest.java @@ -0,0 +1,62 @@ +package com.loopers.domain.productrank; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MonthlyProductRankUnitTest { + + @DisplayName("MonthlyProductRank 생성") + @Nested + class Create { + + @DisplayName("유효한 값으로 생성하면 성공한다.") + @Test + void createsWithValidValues() { + // arrange & act + var rank = new MonthlyProductRank(1L, 1200.0, 1, LocalDate.of(2026, 4, 12)); + + // assert + assertAll( + () -> assertThat(rank.getProductId()).isEqualTo(1L), + () -> assertThat(rank.getTotalScore()).isEqualTo(1200.0), + () -> assertThat(rank.getRanking()).isEqualTo(1), + () -> assertThat(rank.getBaseDate()).isEqualTo(LocalDate.of(2026, 4, 12)) + ); + } + + @DisplayName("productId가 null이면 생성에 실패한다.") + @Test + void failsWithNullProductId() { + assertThatThrownBy(() -> new MonthlyProductRank(null, 100.0, 1, LocalDate.of(2026, 4, 12))) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("totalScore가 음수이면 생성에 실패한다.") + @Test + void failsWithNegativeTotalScore() { + assertThatThrownBy(() -> new MonthlyProductRank(1L, -1.0, 1, LocalDate.of(2026, 4, 12))) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("ranking이 0 이하이면 생성에 실패한다.") + @Test + void failsWithZeroRanking() { + assertThatThrownBy(() -> new MonthlyProductRank(1L, 100.0, 0, LocalDate.of(2026, 4, 12))) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("baseDate가 null이면 생성에 실패한다.") + @Test + void failsWithNullBaseDate() { + assertThatThrownBy(() -> new MonthlyProductRank(1L, 100.0, 1, null)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/productrank/WeeklyProductRankUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/productrank/WeeklyProductRankUnitTest.java new file mode 100644 index 0000000000..15ac838e7c --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/productrank/WeeklyProductRankUnitTest.java @@ -0,0 +1,62 @@ +package com.loopers.domain.productrank; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class WeeklyProductRankUnitTest { + + @DisplayName("WeeklyProductRank 생성") + @Nested + class Create { + + @DisplayName("유효한 값으로 생성하면 성공한다.") + @Test + void createsWithValidValues() { + // arrange & act + var rank = new WeeklyProductRank(1L, 350.5, 1, LocalDate.of(2026, 4, 12)); + + // assert + assertAll( + () -> assertThat(rank.getProductId()).isEqualTo(1L), + () -> assertThat(rank.getTotalScore()).isEqualTo(350.5), + () -> assertThat(rank.getRanking()).isEqualTo(1), + () -> assertThat(rank.getBaseDate()).isEqualTo(LocalDate.of(2026, 4, 12)) + ); + } + + @DisplayName("productId가 null이면 생성에 실패한다.") + @Test + void failsWithNullProductId() { + assertThatThrownBy(() -> new WeeklyProductRank(null, 100.0, 1, LocalDate.of(2026, 4, 12))) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("totalScore가 음수이면 생성에 실패한다.") + @Test + void failsWithNegativeTotalScore() { + assertThatThrownBy(() -> new WeeklyProductRank(1L, -1.0, 1, LocalDate.of(2026, 4, 12))) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("ranking이 0 이하이면 생성에 실패한다.") + @Test + void failsWithZeroRanking() { + assertThatThrownBy(() -> new WeeklyProductRank(1L, 100.0, 0, LocalDate.of(2026, 4, 12))) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("baseDate가 null이면 생성에 실패한다.") + @Test + void failsWithNullBaseDate() { + assertThatThrownBy(() -> new WeeklyProductRank(1L, 100.0, 1, null)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/rankaggregate/RankAggregationJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/rankaggregate/RankAggregationJobE2ETest.java new file mode 100644 index 0000000000..a4b9276518 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/rankaggregate/RankAggregationJobE2ETest.java @@ -0,0 +1,374 @@ +package com.loopers.job.rankaggregate; + +import com.loopers.batch.job.rankaggregate.RankAggregationJobConfig; +import com.loopers.domain.productmetrics.ProductMetrics; +import com.loopers.domain.productrank.MonthlyProductRank; +import com.loopers.domain.productrank.WeeklyProductRank; +import com.loopers.infrastructure.productmetrics.ProductMetricsJpaRepository; +import com.loopers.infrastructure.productrank.MonthlyProductRankJpaRepository; +import com.loopers.infrastructure.productrank.WeeklyProductRankJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + RankAggregationJobConfig.JOB_NAME) +class RankAggregationJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(RankAggregationJobConfig.JOB_NAME) + private Job job; + + @Autowired + private ProductMetricsJpaRepository productMetricsJpaRepository; + + @Autowired + private WeeklyProductRankJpaRepository weeklyProductRankJpaRepository; + + @Autowired + private MonthlyProductRankJpaRepository monthlyProductRankJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final LocalDate BASE_DATE = LocalDate.of(2026, 4, 12); + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("주간 랭킹 집계") + @Nested + class WeeklyRank { + + @DisplayName("최근 7일 product_metrics를 상품별로 합산하여 weekly 랭킹을 적재한다.") + @Test + void aggregatesWeeklyRanking() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 상품1: 7일간 매일 100점 = 합계 700 + // 상품2: 7일간 매일 50점 = 합계 350 + // 상품3: 3일만 200점 = 합계 600 + for (int i = 0; i < 7; i++) { + LocalDate date = BASE_DATE.minusDays(i); + productMetricsJpaRepository.save(new ProductMetrics(1L, date, 100.0)); + productMetricsJpaRepository.save(new ProductMetrics(2L, date, 50.0)); + } + for (int i = 0; i < 3; i++) { + LocalDate date = BASE_DATE.minusDays(i); + productMetricsJpaRepository.save(new ProductMetrics(3L, date, 200.0)); + } + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 100L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List weeklyRanks = weeklyProductRankJpaRepository.findAll(); + assertAll( + () -> assertThat(weeklyRanks).hasSize(3), + // 1위: 상품1 (700점) + () -> assertThat(weeklyRanks) + .filteredOn(r -> r.getRanking() == 1) + .first() + .satisfies(r -> { + assertThat(r.getProductId()).isEqualTo(1L); + assertThat(r.getTotalScore()).isEqualTo(700.0); + }), + // 2위: 상품3 (600점) + () -> assertThat(weeklyRanks) + .filteredOn(r -> r.getRanking() == 2) + .first() + .satisfies(r -> { + assertThat(r.getProductId()).isEqualTo(3L); + assertThat(r.getTotalScore()).isEqualTo(600.0); + }), + // 3위: 상품2 (350점) + () -> assertThat(weeklyRanks) + .filteredOn(r -> r.getRanking() == 3) + .first() + .satisfies(r -> { + assertThat(r.getProductId()).isEqualTo(2L); + assertThat(r.getTotalScore()).isEqualTo(350.0); + }) + ); + } + + @DisplayName("7일 범위 밖의 데이터는 주간 집계에 포함되지 않는다.") + @Test + void excludesDataOutsideWeeklyRange() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 범위 내 (requestDate - 6 ~ requestDate) + productMetricsJpaRepository.save(new ProductMetrics(1L, BASE_DATE, 100.0)); + // 범위 밖 (7일 전 = requestDate - 7) + productMetricsJpaRepository.save(new ProductMetrics(2L, BASE_DATE.minusDays(7), 500.0)); + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 200L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List weeklyRanks = weeklyProductRankJpaRepository.findAll(); + assertAll( + () -> assertThat(weeklyRanks).hasSize(1), + () -> assertThat(weeklyRanks.get(0).getProductId()).isEqualTo(1L), + () -> assertThat(weeklyRanks.get(0).getTotalScore()).isEqualTo(100.0) + ); + } + } + + @DisplayName("월간 랭킹 집계") + @Nested + class MonthlyRank { + + @DisplayName("최근 30일 product_metrics를 상품별로 합산하여 monthly 랭킹을 적재한다.") + @Test + void aggregatesMonthlyRanking() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 상품1: 30일간 매일 10점 = 합계 300 + // 상품2: 15일만 30점 = 합계 450 + for (int i = 0; i < 30; i++) { + LocalDate date = BASE_DATE.minusDays(i); + productMetricsJpaRepository.save(new ProductMetrics(1L, date, 10.0)); + } + for (int i = 0; i < 15; i++) { + LocalDate date = BASE_DATE.minusDays(i); + productMetricsJpaRepository.save(new ProductMetrics(2L, date, 30.0)); + } + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 300L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List monthlyRanks = monthlyProductRankJpaRepository.findAll(); + assertAll( + () -> assertThat(monthlyRanks).hasSize(2), + // 1위: 상품2 (450점) + () -> assertThat(monthlyRanks) + .filteredOn(r -> r.getRanking() == 1) + .first() + .satisfies(r -> { + assertThat(r.getProductId()).isEqualTo(2L); + assertThat(r.getTotalScore()).isEqualTo(450.0); + }), + // 2위: 상품1 (300점) + () -> assertThat(monthlyRanks) + .filteredOn(r -> r.getRanking() == 2) + .first() + .satisfies(r -> { + assertThat(r.getProductId()).isEqualTo(1L); + assertThat(r.getTotalScore()).isEqualTo(300.0); + }) + ); + } + } + + @DisplayName("TOP 100 절단") + @Nested + class Top100Limit { + + @DisplayName("상품이 100개를 초과하면 TOP 100만 적재된다.") + @Test + void limitsToTop100() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 120개 상품 데이터 생성 (productId: 1~120) + for (long productId = 1; productId <= 120; productId++) { + productMetricsJpaRepository.save( + new ProductMetrics(productId, BASE_DATE, (double) productId) + ); + } + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 400L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List weeklyRanks = weeklyProductRankJpaRepository.findAll(); + List monthlyRanks = monthlyProductRankJpaRepository.findAll(); + + assertAll( + () -> assertThat(weeklyRanks).hasSize(100), + () -> assertThat(monthlyRanks).hasSize(100), + // 1위는 점수가 가장 높은 productId=120 + () -> assertThat(weeklyRanks) + .filteredOn(r -> r.getRanking() == 1) + .first() + .satisfies(r -> assertThat(r.getProductId()).isEqualTo(120L)), + // 100위는 productId=21 (120 - 99) + () -> assertThat(weeklyRanks) + .filteredOn(r -> r.getRanking() == 100) + .first() + .satisfies(r -> assertThat(r.getProductId()).isEqualTo(21L)) + ); + } + } + + @DisplayName("멱등성") + @Nested + class Idempotency { + + @DisplayName("같은 requestDate로 재실행하면 기존 데이터를 교체한다.") + @Test + void replacesExistingDataOnRerun() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 1차 실행 — 상품1 점수 100 + productMetricsJpaRepository.save(new ProductMetrics(1L, BASE_DATE, 100.0)); + + var jobParameters1 = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 500L) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters1); + + // 데이터 변경 — 상품2 추가 + productMetricsJpaRepository.save(new ProductMetrics(2L, BASE_DATE, 200.0)); + + // 2차 실행 + var jobParameters2 = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 501L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters2); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List weeklyRanks = weeklyProductRankJpaRepository.findAll(); + assertAll( + () -> assertThat(weeklyRanks).hasSize(2), + () -> assertThat(weeklyRanks) + .filteredOn(r -> r.getRanking() == 1) + .first() + .satisfies(r -> { + assertThat(r.getProductId()).isEqualTo(2L); + assertThat(r.getTotalScore()).isEqualTo(200.0); + }) + ); + } + } + + @DisplayName("엣지 케이스") + @Nested + class EdgeCase { + + @DisplayName("product_metrics가 비어있으면 빈 결과로 정상 완료된다.") + @Test + void completesWithEmptyMetrics() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 600L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(weeklyProductRankJpaRepository.findAll()).isEmpty(), + () -> assertThat(monthlyProductRankJpaRepository.findAll()).isEmpty() + ); + } + + @DisplayName("requestDate가 없으면 배치가 실패한다.") + @Test + void failsWithoutRequestDate() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("미래 날짜의 requestDate가 주어지면 배치가 실패한다.") + @Test + void failsWithFutureRequestDate() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + LocalDate futureDate = LocalDate.now().plusDays(1); + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", futureDate) + .addLong("run.id", 700L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + } +} From bfb798a584fe0e52f56bfe7d33f16af8e8846f2f Mon Sep 17 00:00:00 2001 From: MINJOOOONG Date: Sun, 12 Apr 2026 20:53:18 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20ranking=20api=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=20(daily/weekly/monthly)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ranking/RankingFacade.java | 22 ++ .../application/ranking/RankingInfo.java | 10 + .../loopers/domain/ranking/RankPeriod.java | 7 + .../domain/ranking/RankingRepository.java | 15 + .../domain/ranking/RankingService.java | 24 ++ .../ranking/RankingRepositoryImpl.java | 74 +++++ .../api/ranking/RankingV1ApiSpec.java | 25 ++ .../api/ranking/RankingV1Controller.java | 52 +++ .../interfaces/api/ranking/RankingV1Dto.java | 23 ++ .../interfaces/api/RankingV1ApiE2ETest.java | 308 ++++++++++++++++++ 10 files changed, 560 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankPeriod.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.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 new file mode 100644 index 0000000000..458f806426 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,22 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.RankPeriod; +import com.loopers.domain.ranking.RankingService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class RankingFacade { + + private final RankingService rankingService; + + public List getRanking(RankPeriod period, LocalDate date, int size) { + return rankingService.getRanking(period, date, size).stream() + .map(RankingInfo::from) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java new file mode 100644 index 0000000000..02c38cd40f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java @@ -0,0 +1,10 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.RankingRepository; + +public record RankingInfo(Long productId, double score, int ranking) { + + public static RankingInfo from(RankingRepository.RankingEntry entry) { + return new RankingInfo(entry.productId(), entry.score(), entry.ranking()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankPeriod.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankPeriod.java new file mode 100644 index 0000000000..1f94e4e8ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankPeriod.java @@ -0,0 +1,7 @@ +package com.loopers.domain.ranking; + +public enum RankPeriod { + DAILY, + WEEKLY, + MONTHLY +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java new file mode 100644 index 0000000000..5b6e907c5f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +public interface RankingRepository { + + List findDailyRanking(LocalDate date, int size); + + List findWeeklyRanking(LocalDate baseDate, int size); + + List findMonthlyRanking(LocalDate baseDate, int size); + + record RankingEntry(Long productId, double score, int ranking) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java new file mode 100644 index 0000000000..c351997faa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,24 @@ +package com.loopers.domain.ranking; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class RankingService { + + private final RankingRepository rankingRepository; + + @Transactional(readOnly = true) + public List getRanking(RankPeriod period, LocalDate date, int size) { + return switch (period) { + case DAILY -> rankingRepository.findDailyRanking(date, size); + case WEEKLY -> rankingRepository.findWeeklyRanking(date, size); + case MONTHLY -> rankingRepository.findMonthlyRanking(date, size); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java new file mode 100644 index 0000000000..914fe80e6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java @@ -0,0 +1,74 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.RankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +@RequiredArgsConstructor +@Component +public class RankingRepositoryImpl implements RankingRepository { + + private final JdbcTemplate jdbcTemplate; + + private static final String DAILY_SQL = """ + SELECT product_id, score + FROM product_metrics + WHERE metric_date = ? + ORDER BY score DESC + LIMIT ? + """; + + private static final String WEEKLY_SQL = """ + SELECT product_id, total_score, ranking + FROM mv_product_rank_weekly + WHERE base_date = ? + ORDER BY ranking ASC + LIMIT ? + """; + + private static final String MONTHLY_SQL = """ + SELECT product_id, total_score, ranking + FROM mv_product_rank_monthly + WHERE base_date = ? + ORDER BY ranking ASC + LIMIT ? + """; + + @Override + public List findDailyRanking(LocalDate date, int size) { + AtomicInteger rank = new AtomicInteger(0); + return jdbcTemplate.query(DAILY_SQL, dailyRowMapper(rank), date, size); + } + + @Override + public List findWeeklyRanking(LocalDate baseDate, int size) { + return jdbcTemplate.query(WEEKLY_SQL, mvRowMapper(), baseDate, size); + } + + @Override + public List findMonthlyRanking(LocalDate baseDate, int size) { + return jdbcTemplate.query(MONTHLY_SQL, mvRowMapper(), baseDate, size); + } + + private RowMapper dailyRowMapper(AtomicInteger rank) { + return (rs, rowNum) -> new RankingEntry( + rs.getLong("product_id"), + rs.getDouble("score"), + rank.incrementAndGet() + ); + } + + private RowMapper mvRowMapper() { + return (rs, rowNum) -> new RankingEntry( + rs.getLong("product_id"), + rs.getDouble("total_score"), + rs.getInt("ranking") + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java new file mode 100644 index 0000000000..3f8a5f9805 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.time.LocalDate; + +@Tag(name = "Ranking V1 API", description = "상품 랭킹 조회 API") +public interface RankingV1ApiSpec { + + @Operation( + summary = "랭킹 조회", + description = "기간별(DAILY/WEEKLY/MONTHLY) 상품 랭킹을 조회합니다." + ) + ApiResponse getRankings( + @Parameter(description = "조회 기간 (DAILY, WEEKLY, MONTHLY)", required = true) + String period, + @Parameter(description = "기준 날짜 (yyyy-MM-dd)", required = true) + LocalDate date, + @Parameter(description = "조회 개수 (기본 100, 최대 100)") + int size + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java new file mode 100644 index 0000000000..01ae6d700a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingFacade; +import com.loopers.application.ranking.RankingInfo; +import com.loopers.domain.ranking.RankPeriod; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/rankings") +public class RankingV1Controller implements RankingV1ApiSpec { + + private static final int DEFAULT_SIZE = 100; + private static final int MAX_SIZE = 100; + + private final RankingFacade rankingFacade; + + @GetMapping + @Override + public ApiResponse getRankings( + @RequestParam("period") String period, + @RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(value = "size", defaultValue = "100") int size + ) { + RankPeriod rankPeriod = parseRankPeriod(period); + int validSize = Math.min(Math.max(size, 1), MAX_SIZE); + + List rankings = rankingFacade.getRanking(rankPeriod, date, validSize); + RankingV1Dto.RankingListResponse response = RankingV1Dto.RankingListResponse.from(rankings); + + return ApiResponse.success(response); + } + + private RankPeriod parseRankPeriod(String period) { + try { + return RankPeriod.valueOf(period.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "잘못된 period 값입니다: " + period); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java new file mode 100644 index 0000000000..a85e37f8f1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -0,0 +1,23 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingInfo; + +import java.util.List; + +public class RankingV1Dto { + + public record RankingResponse(int ranking, Long productId, double score) { + public static RankingResponse from(RankingInfo info) { + return new RankingResponse(info.ranking(), info.productId(), info.score()); + } + } + + public record RankingListResponse(List rankings) { + public static RankingListResponse from(List infos) { + List rankings = infos.stream() + .map(RankingResponse::from) + .toList(); + return new RankingListResponse(rankings); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java new file mode 100644 index 0000000000..d694c8ec1c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java @@ -0,0 +1,308 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.ranking.RankingV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RankingV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/rankings"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @org.junit.jupiter.api.BeforeEach + void setUp() { + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS product_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + metric_date DATE NOT NULL, + score DOUBLE NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + UNIQUE KEY uk_product_metrics (product_id, metric_date), + INDEX idx_metric_date (metric_date) + ) + """); + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + total_score DOUBLE NOT NULL, + ranking INT NOT NULL, + base_date DATE NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + UNIQUE KEY uk_weekly_rank (base_date, product_id), + INDEX idx_weekly_base_date (base_date) + ) + """); + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + total_score DOUBLE NOT NULL, + ranking INT NOT NULL, + base_date DATE NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + UNIQUE KEY uk_monthly_rank (base_date, product_id), + INDEX idx_monthly_base_date (base_date) + ) + """); + } + + @AfterEach + void tearDown() { + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0"); + jdbcTemplate.execute("TRUNCATE TABLE product_metrics"); + jdbcTemplate.execute("TRUNCATE TABLE mv_product_rank_weekly"); + jdbcTemplate.execute("TRUNCATE TABLE mv_product_rank_monthly"); + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1"); + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/rankings - DAILY 랭킹 조회") + @Nested + class DailyRanking { + + @DisplayName("해당 날짜의 product_metrics를 점수 내림차순으로 조회한다.") + @Test + void returnsDailyRanking() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 12); + insertProductMetrics(1L, date, 300.0); + insertProductMetrics(2L, date, 100.0); + insertProductMetrics(3L, date, 200.0); + + String url = ENDPOINT + "?period=DAILY&date=2026-04-12"; + + // act + var response = exchange(url); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).hasSize(3), + () -> assertThat(response.getBody().data().rankings().get(0).ranking()).isEqualTo(1), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(1L), + () -> assertThat(response.getBody().data().rankings().get(0).score()).isEqualTo(300.0), + () -> assertThat(response.getBody().data().rankings().get(1).ranking()).isEqualTo(2), + () -> assertThat(response.getBody().data().rankings().get(1).productId()).isEqualTo(3L), + () -> assertThat(response.getBody().data().rankings().get(2).ranking()).isEqualTo(3), + () -> assertThat(response.getBody().data().rankings().get(2).productId()).isEqualTo(2L) + ); + } + + @DisplayName("size 파라미터로 조회 개수를 제한할 수 있다.") + @Test + void limitsBySize() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 12); + for (long i = 1; i <= 5; i++) { + insertProductMetrics(i, date, (double) i * 10); + } + + String url = ENDPOINT + "?period=DAILY&date=2026-04-12&size=3"; + + // act + var response = exchange(url); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).hasSize(3), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(5L) + ); + } + } + + @DisplayName("GET /api/v1/rankings - WEEKLY 랭킹 조회") + @Nested + class WeeklyRanking { + + @DisplayName("해당 날짜의 주간 랭킹을 순위 오름차순으로 조회한다.") + @Test + void returnsWeeklyRanking() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 12); + insertWeeklyRank(1L, 700.0, 1, baseDate); + insertWeeklyRank(3L, 600.0, 2, baseDate); + insertWeeklyRank(2L, 350.0, 3, baseDate); + + String url = ENDPOINT + "?period=WEEKLY&date=2026-04-12"; + + // act + var response = exchange(url); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).hasSize(3), + () -> assertThat(response.getBody().data().rankings().get(0).ranking()).isEqualTo(1), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(1L), + () -> assertThat(response.getBody().data().rankings().get(0).score()).isEqualTo(700.0), + () -> assertThat(response.getBody().data().rankings().get(1).ranking()).isEqualTo(2), + () -> assertThat(response.getBody().data().rankings().get(2).ranking()).isEqualTo(3) + ); + } + } + + @DisplayName("GET /api/v1/rankings - MONTHLY 랭킹 조회") + @Nested + class MonthlyRanking { + + @DisplayName("해당 날짜의 월간 랭킹을 순위 오름차순으로 조회한다.") + @Test + void returnsMonthlyRanking() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 12); + insertMonthlyRank(2L, 450.0, 1, baseDate); + insertMonthlyRank(1L, 300.0, 2, baseDate); + + String url = ENDPOINT + "?period=MONTHLY&date=2026-04-12"; + + // act + var response = exchange(url); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).hasSize(2), + () -> assertThat(response.getBody().data().rankings().get(0).ranking()).isEqualTo(1), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(2L), + () -> assertThat(response.getBody().data().rankings().get(0).score()).isEqualTo(450.0), + () -> assertThat(response.getBody().data().rankings().get(1).ranking()).isEqualTo(2), + () -> assertThat(response.getBody().data().rankings().get(1).productId()).isEqualTo(1L) + ); + } + } + + @DisplayName("엣지 케이스") + @Nested + class EdgeCase { + + @DisplayName("데이터가 없으면 빈 리스트를 반환한다.") + @Test + void returnsEmptyListWhenNoData() { + // arrange + String url = ENDPOINT + "?period=DAILY&date=2026-04-12"; + + // act + var response = exchange(url); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).isEmpty() + ); + } + + @DisplayName("잘못된 period 값이면 400을 반환한다.") + @Test + void returnsBadRequestForInvalidPeriod() { + // arrange + String url = ENDPOINT + "?period=INVALID&date=2026-04-12"; + + // act + var response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), + new ParameterizedTypeReference>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("period 파라미터가 없으면 400을 반환한다.") + @Test + void returnsBadRequestWithoutPeriod() { + // arrange + String url = ENDPOINT + "?date=2026-04-12"; + + // act + var response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), + new ParameterizedTypeReference>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("date 파라미터가 없으면 400을 반환한다.") + @Test + void returnsBadRequestWithoutDate() { + // arrange + String url = ENDPOINT + "?period=DAILY"; + + // act + var response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), + new ParameterizedTypeReference>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + // --- Helper Methods --- + + private ResponseEntity> exchange(String url) { + return testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + } + + private void insertProductMetrics(Long productId, LocalDate metricDate, double score) { + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, metric_date, score, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())", + productId, metricDate, score + ); + } + + private void insertWeeklyRank(Long productId, double totalScore, int ranking, LocalDate baseDate) { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_weekly (product_id, total_score, ranking, base_date, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())", + productId, totalScore, ranking, baseDate + ); + } + + private void insertMonthlyRank(Long productId, double totalScore, int ranking, LocalDate baseDate) { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_monthly (product_id, total_score, ranking, base_date, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())", + productId, totalScore, ranking, baseDate + ); + } +} From 09b0978be8aa096feb9cbd1227dff4f65b3177b1 Mon Sep 17 00:00:00 2001 From: MINJOOOONG Date: Mon, 15 Jun 2026 17:20:13 +0900 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20README=20=EC=A3=BC=EC=B0=A8?= =?UTF-8?q?=EB=B3=84=20=EA=B5=AC=ED=98=84=20=EB=82=B4=EC=97=AD=20=EB=B0=8F?= =?UTF-8?q?=20=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Week 1~10 구현 내용, 고민했던 설계 결정, 전체 데이터 흐름도, 핵심 설계 결정 요약 테이블 추가 Co-Authored-By: Claude Opus 4.6 --- README.md | 343 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 316 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index f86e4dd8a0..c4968da7b5 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,329 @@ -# Loopers Template (Spring + Java) -Loopers 에서 제공하는 스프링 자바 템플릿 프로젝트입니다. +# Commerce Platform - E-Commerce 상품 랭킹 시스템 + +> Loopers L2 Vol.3 과정에서 주차별로 점진적으로 구축한 **이커머스 플랫폼**입니다. +> 회원/상품/주문 도메인부터 결제, 쿠폰, 대기열, 실시간 랭킹까지 확장하며 +> **클린 아키텍처**, **이벤트 기반 설계**, **배치 파이프라인**을 실전 적용했습니다. + +--- + +## Tech Stack + +| 구분 | 기술 | 버전 | +|------|------|------| +| Language | Java | 21 | +| Framework | Spring Boot | 3.4.4 | +| Cloud | Spring Cloud | 2024.0.1 | +| Database | MySQL | 8.0 | +| Cache | Redis (Master-Replica) | 7.0 | +| Message Queue | Apache Kafka (KRaft) | 3.5.1 | +| Batch | Spring Batch | 5.x | +| Build | Gradle (Kotlin DSL) | 8.x | +| Test | JUnit 5, Testcontainers, MockK | - | +| Monitoring | Prometheus + Grafana | - | + +--- + +## Architecture + +### Multi-Module 구조 + +``` +Root +├── apps ( spring-applications ) +│ ├── 📦 commerce-api ← REST API 서버 (port 8080) +│ ├── 📦 commerce-batch ← Spring Batch 배치 서버 +│ └── 📦 commerce-streamer ← Kafka Consumer 스트리머 +├── modules ( reusable-configurations ) +│ ├── 📦 jpa ← JPA + QueryDSL + MySQL 설정 +│ ├── 📦 redis ← Lettuce Master-Replica 설정 +│ └── 📦 kafka ← Kafka Producer/Consumer 설정 +└── supports ( add-ons ) + ├── 📦 jackson ← 직렬화 설정 + ├── 📦 monitoring ← Prometheus/Grafana 연동 + └── 📦 logging ← 구조화 로깅 +``` + +**설계 원칙:** +- `apps`는 실행 가능한 SpringBootApplication으로, BootJar만 생성 +- `modules`는 도메인에 의존하지 않는 reusable configuration +- `supports`는 logging, monitoring 등 부가 기능 add-on + +### Clean Architecture (각 app 내부) + +``` +interfaces/ ← Controller, Kafka Listener, Batch Job 설정 + ↓ +application/ ← Facade, Use Case 조합 계층 + ↓ +domain/ ← Entity, Value Object, Repository 인터페이스, 비즈니스 규칙 + ↓ +infrastructure/ ← JPA Repository 구현, Redis 연동, 외부 API 클라이언트 +``` + +### 인프라 구성도 + +``` +┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ +│ commerce-api│────▶│ MySQL 8.0 │◀────│ commerce-batch │ +│ (port 8080)│ │ (port 3306)│ │ (Spring Batch) │ +└──────┬──────┘ └─────────────┘ └────────┬─────────┘ + │ │ + │ ┌─────────────┐ │ + ├───────────▶│ Redis Master│◀─────────────┤ + │ │ (port 6379)│ │ + │ └──────┬──────┘ │ + │ ┌──────▼──────┐ │ + │ │Redis Replica│ │ + │ │ (port 6380)│ │ + │ └─────────────┘ │ + │ │ + │ ┌─────────────┐ ┌────────▼─────────┐ + └───────────▶│ Kafka(KRaft)│◀────│commerce-streamer │ + │ (port 9092)│ │ (Kafka Consumer) │ + └─────────────┘ └──────────────────┘ +``` + +--- + +## 주차별 구현 내역 + +### Week 1 - 회원 도메인 & TDD 기반 구축 + +**구현 내용:** +- 회원가입 / 내 정보 조회 API (E2E TDD) +- Clean Architecture 레이어 분리 (interfaces → application → domain → infrastructure) +- Testcontainers 기반 통합 테스트 환경 구성 + +**고민했던 부분:** +- TDD Red-Green-Refactor 사이클을 얼마나 엄격하게 지킬 것인가 → E2E 테스트를 먼저 작성하고 컴파일 에러를 따라가며 구현하는 Outside-In 방식 채택 +- 멀티 모듈에서 테스트 프로필 관리 → 각 app 모듈별 `application-test.yml` 분리, Testcontainers로 인프라 격리 + +--- + +### Week 2 - 설계 문서화 + +**구현 내용:** +- 요구사항 정의서 작성 및 정책 확정 +- 시퀀스 다이어그램, 클래스 다이어그램, ERD 설계 +- 도메인 간 관계와 책임 경계 정의 + +**고민했던 부분:** +- 주문-상품-쿠폰 간의 의존 방향을 어떻게 설정할 것인가 → 도메인 이벤트 기반으로 느슨한 결합 방향 결정 +- 설계 단계에서 확장 가능성과 현실적 구현 범위의 균형 + +--- + +### Week 3 - 상품/주문/좋아요 도메인 구현 + +**구현 내용:** +- Product 재고 차감/증가 로직 (TDD) +- Brand 도메인 및 BrandName Value Object 검증 +- Like(좋아요) 등록/취소/카운트 기능 +- Order(주문) 생성 유스케이스 및 API v1 +- 트랜잭션 경계 정리 및 명시적 save + +**고민했던 부분:** +- 재고 차감 시 동시성 문제 → 이 시점에서는 도메인 로직 정합성에 집중하고, 동시성 제어는 이후 주차에서 해결 +- 트랜잭션 경계를 어디에 둘 것인가 → Application(Facade) 계층에서 `@Transactional`을 관리하고, Domain 계층은 순수 비즈니스 로직만 담당 + +--- + +### Week 4 - 쿠폰 적용 & 동시성 제어 + +**구현 내용:** +- 주문 시 쿠폰 적용 및 정합성 처리 +- 좋아요 동시성 제어 (비관적 락 / 낙관적 락 검토) +- 동시성 통합 테스트 추가 + +**고민했던 부분:** +- 좋아요 카운트의 동시성 제어 방식 → 비관적 락은 처리량 저하 우려, 낙관적 락은 retry 로직 복잡도 증가 → 유스케이스 특성상 충돌 빈도가 낮아 낙관적 락 + retry 채택 +- 쿠폰 적용과 주문 생성이 하나의 트랜잭션에서 처리되어야 하는 이유와 경계 설정 + +--- + +### Week 5 - 상품 조회 성능 최적화 + +**구현 내용:** +- 상품 조회 API 및 좋아요 수 반영 구조 +- 상품 조회 인덱스 설계 및 대용량 성능 검증 +- 상품 상세 Redis 캐시 적용 및 캐시-DB 정합성 검증 + +**고민했던 부분:** +- 좋아요 수를 상품 조회 시 어떻게 효율적으로 반영할 것인가 → 조회 시마다 JOIN vs 비정규화 카운터 → Redis 캐시로 읽기 부하 분산 +- 캐시 무효화 전략 → TTL 기반으로 eventual consistency 허용, 상세 조회에만 캐시 적용 +- 인덱스 설계 시 커버링 인덱스 vs 복합 인덱스 트레이드오프 + +--- + +### Week 6 - 결제 시스템 & Resilience + +**구현 내용:** +- 주문/결제 상태 전이 모델 (State Machine 패턴) +- PG 연동 인터페이스 및 시뮬레이터 클라이언트 +- 결제 오케스트레이션 및 보상 트랜잭션 (Saga 패턴) +- PG callback 상태 반영 흐름 +- 결제 복구 스케줄러 (Pending 상태 타임아웃 처리) +- Resilience4j Bulkhead 적용 +- 결제 동시성 통합 테스트 + +**고민했던 부분:** +- 결제 실패 시 보상 처리 방식 → Choreography vs Orchestration Saga → 결제 도메인이 중심이므로 Orchestration 방식 채택 +- PG callback과 사용자 요청이 동시에 들어올 때의 상태 충돌 → 비관적 락으로 결제 상태 전이의 원자성 보장 +- Bulkhead 설정값 (maxConcurrentCalls, maxWaitDuration) → 부하 테스트 기반으로 PG 응답 시간 고려해 설정 + +--- + +### Week 7 - 이벤트 기반 아키텍처 & 선착순 쿠폰 + +**구현 내용:** +- 주문 생성 이벤트 발행 (Spring ApplicationEvent + `@Async` 핸들러) +- Transactional Outbox 패턴 구현 + - OutboxEvent 엔티티 저장 → Scheduler가 Kafka로 relay → Consumer에서 멱등 처리 +- Kafka Producer (acks=all, idempotence) / Consumer (manual ack) +- 선착순 쿠폰 발급 도메인 + Kafka 기반 비동기 처리 +- Coupon 발급 요청 API 및 polling 조회 +- OutboxEvent partitionKey 기반 Kafka partition 전략 + +**고민했던 부분:** +- 이벤트 발행과 DB 트랜잭션의 원자성 → `@TransactionalEventListener(AFTER_COMMIT)`만으로는 발행 실패 시 유실 가능 → Outbox 패턴으로 at-least-once 보장 +- 멱등 처리를 어디서 할 것인가 → Consumer 측에 idempotency key 테이블을 두어 중복 소비 방어 +- Kafka partition 전략 → 같은 주문의 이벤트는 순서 보장 필요 → orderId 기반 partitionKey 적용 +- 선착순 쿠폰의 재고 관리 → Redis DECR로 원자적 차감 후 Kafka 이벤트로 실제 발급 처리 + +--- + +### Week 8 - 주문 대기열 시스템 + +**구현 내용:** +- Redis 기반 주문 대기열 (Sorted Set) +- 대기열 진입 API 및 토큰 검증 로직 +- 예상 대기시간 계산 로직 +- Lua Script로 dequeue + 토큰 발급 원자화 +- 주문 성공 시 입장 토큰 삭제 +- 동시성 테스트 (2000명 동시 진입, 처리량 초과, TTL 만료) + +**고민했던 부분:** +- 대기열 dequeue와 토큰 발급을 어떻게 원자적으로 처리할 것인가 → 두 개의 Redis 명령을 개별 실행하면 중간에 장애 시 토큰 없는 유저가 빠져나갈 수 있음 → **Lua Script**로 ZPOPMIN + SET NX EX를 하나의 원자 연산으로 묶음 +- 토큰 TTL 관리 → 너무 짧으면 정상 사용자도 만료, 너무 길면 좀비 토큰 누적 → 주문 완료 시 명시적 삭제 + TTL fallback 이중 전략 +- 대기열 순서 보장 → Redis Sorted Set의 score를 timestamp로 사용하여 FIFO 보장 + +--- + +### Week 9 - 실시간 상품 랭킹 파이프라인 + +**구현 내용:** +- Ranking Score Policy 구현 (이벤트별 가중치 기반 점수 산출) +- Kafka Consumer → Redis ZSET 실시간 적재 파이프라인 +- `ZUNIONSTORE` 기반 랭킹 carry-over (콜드 스타트 완화) +- 랭킹 carry-over Scheduler 구현 및 테스트 +- 상품 랭킹 조회 API + +**고민했던 부분:** +- 콜드 스타트 문제 → 자정에 새로운 날짜 키가 생성되면 랭킹이 비어있음 → `ZUNIONSTORE`로 전일 데이터를 가중치를 낮춰 carry-over, Scheduler가 자정에 자동 실행 +- Redis ZSET 하나로 일별 랭킹을 관리하되, 키 네이밍 컨벤션(`ranking:all:{yyyyMMdd}`)으로 일자별 분리 +- Kafka 배치 Consumer 설정 → `max.poll.records=3000`으로 처리량 확보, manual ack으로 유실 방지 + +--- + +### Week 10 - 배치 기반 랭킹 집계 & API 확장 + +**구현 내용:** +- **Daily Metrics Snapshot Batch**: Redis ZSET → MySQL `product_metrics` 테이블로 일별 스냅샷 + - Batch insert (1000건 단위 flush/clear) 로 메모리 효율 확보 + - Empty result guard (Redis 데이터 없을 시 기존 데이터 보호) +- **Weekly/Monthly Rank Aggregation Batch**: `product_metrics` 7일/30일 집계 → `mv_product_rank_weekly` / `mv_product_rank_monthly` + - JdbcCursorItemReader로 대량 데이터 스트리밍 처리 + - Chunk 기반 처리 (chunk size: 100) + - TOP 100 제한으로 불필요한 연산 방지 +- **Ranking API 확장**: DAILY / WEEKLY / MONTHLY 기간별 랭킹 조회 +- 모든 배치 작업 멱등성 보장 (동일 파라미터 재실행 시 동일 결과) + +**고민했던 부분:** +- Redis 스냅샷을 왜 DB에 저장하는가 → Redis는 휘발성 + 장기 데이터 보관에 부적합 → MySQL에 일별 스냅샷을 남겨 주간/월간 집계의 안정적 원천 데이터 확보 +- Batch insert 시 영속성 컨텍스트 관리 → 대량 insert 시 1차 캐시 메모리 폭증 → `entityManager.flush()` + `clear()`를 1000건마다 호출하여 메모리 제어 +- Empty result guard → 배치 실행 시 Redis에 데이터가 없으면 기존 DB 데이터를 삭제하면 안됨 → Redis 결과가 비어있으면 스킵 처리 +- 주간/월간 랭킹 테이블 설계 → View vs 물리 테이블 → 조회 성능과 집계 비용을 고려하여 물리 테이블(`mv_product_rank_weekly/monthly`)에 배치로 적재하는 Materialized View 전략 채택 +- Writer에서의 delete 타이밍 → 첫 번째 chunk 처리 시에만 기존 데이터 삭제 (AtomicBoolean으로 제어), 이후 chunk는 insert만 수행 + +--- + +## 전체 데이터 흐름 + +``` +[사용자 주문/이벤트] + │ + ▼ +┌──────────────┐ Kafka Event ┌──────────────────┐ +│ commerce-api │ ──────────────────▶ │ commerce-streamer│ +│ (Outbox) │ │ (Kafka Consumer) │ +└──────────────┘ └────────┬─────────┘ + │ + Score Policy 적용 + │ + ▼ + ┌────────────────┐ + │ Redis ZSET │ + │ (일별 실시간 │ + │ 랭킹 스코어) │ + └────────┬───────┘ + │ + Daily Snapshot Batch + │ + ▼ + ┌────────────────┐ + │ product_metrics│ + │ (MySQL 일별) │ + └────────┬───────┘ + │ + Rank Aggregation Batch (7일/30일) + │ + ┌────────▼───────┐ + │ mv_product_rank│ + │ _weekly/monthly│ + └────────┬───────┘ + │ + Ranking API 조회 + │ + ▼ + ┌────────────────┐ + │ 클라이언트 │ + └────────────────┘ +``` + +--- + +## 핵심 설계 결정 요약 + +| 주제 | 결정 | 이유 | +|------|------|------| +| 아키텍처 | Clean Architecture + 멀티 모듈 | 도메인 로직 보호, 인프라 교체 용이성 | +| 이벤트 발행 | Transactional Outbox 패턴 | DB 트랜잭션과 메시지 발행의 원자성 보장 | +| 대기열 원자성 | Redis Lua Script | dequeue + 토큰 발급을 단일 원자 연산으로 | +| 랭킹 콜드 스타트 | ZUNIONSTORE carry-over | 자정 랭킹 초기화 문제 해결 | +| 장기 랭킹 | Materialized View 전략 | Redis 휘발성 극복, 주간/월간 안정적 집계 | +| 배치 메모리 | flush/clear per 1000건 | 대량 insert 시 영속성 컨텍스트 OOM 방지 | +| 결제 보상 | Orchestration Saga | 결제 중심의 명확한 보상 흐름 | +| 캐시 전략 | TTL 기반 eventual consistency | 상품 상세 읽기 부하 분산 | +| Kafka 신뢰성 | acks=all + manual ack + 멱등 | 메시지 유실 방지 + 중복 소비 방어 | +| Redis 가용성 | Master-Replica + ReadFrom 분리 | 읽기 부하 분산, 장애 시 replica fallback | + +--- ## Getting Started -현재 프로젝트 안정성 및 유지보수성 등을 위해 아래와 같은 장치를 운용하고 있습니다. 이에 아래 명령어를 통해 프로젝트의 기반을 설치해주세요. + ### Environment -`local` 프로필로 동작할 수 있도록, 필요 인프라를 `docker-compose` 로 제공합니다. +`local` 프로필로 동작할 수 있도록, 필요 인프라를 `docker-compose`로 제공합니다. ```shell docker-compose -f ./docker/infra-compose.yml up ``` + ### Monitoring -`local` 환경에서 모니터링을 할 수 있도록, `docker-compose` 를 통해 `prometheus` 와 `grafana` 를 제공합니다. +`local` 환경에서 모니터링을 할 수 있도록, `prometheus`와 `grafana`를 제공합니다. 애플리케이션 실행 이후, **http://localhost:3000** 로 접속해, admin/admin 계정으로 로그인하여 확인하실 수 있습니다. ```shell docker-compose -f ./docker/monitoring-compose.yml up ``` -## About Multi-Module Project -본 프로젝트는 멀티 모듈 프로젝트로 구성되어 있습니다. 각 모듈의 위계 및 역할을 분명히 하고, 아래와 같은 규칙을 적용합니다. - -- apps : 각 모듈은 실행가능한 **SpringBootApplication** 을 의미합니다. -- modules : 특정 구현이나 도메인에 의존적이지 않고, reusable 한 configuration 을 원칙으로 합니다. -- supports : logging, monitoring 과 같이 부가적인 기능을 지원하는 add-on 모듈입니다. - -``` -Root -├── apps ( spring-applications ) -│ ├── 📦 commerce-api -│ ├── 📦 commerce-batch -│ └── 📦 commerce-streamer -├── modules ( reusable-configurations ) -│ ├── 📦 jpa -│ ├── 📦 redis -│ └── 📦 kafka -└── supports ( add-ons ) - ├── 📦 jackson - ├── 📦 monitoring - └── 📦 logging -``` +### API Documentation +애플리케이션 실행 후 **http://localhost:8080/swagger-ui.html** 에서 API 문서를 확인할 수 있습니다. From d4c181b8953d27d0c90eb594b13cd4554830006f Mon Sep 17 00:00:00 2001 From: MINJOOOONG Date: Mon, 15 Jun 2026 17:33:47 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20README=20=EC=9E=AC=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20-=20=EC=A3=BC=EC=B0=A8=EB=B3=84=20PR=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EB=82=B4=EC=9A=A9=20=EA=B5=AC=EC=B2=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- README.md | 213 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 120 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index c4968da7b5..4269592baa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Commerce Platform - E-Commerce 상품 랭킹 시스템 -> Loopers L2 Vol.3 과정에서 주차별로 점진적으로 구축한 **이커머스 플랫폼**입니다. +> 10주간 점진적으로 구축한 **이커머스 플랫폼**입니다. > 회원/상품/주문 도메인부터 결제, 쿠폰, 대기열, 실시간 랭킹까지 확장하며 > **클린 아키텍처**, **이벤트 기반 설계**, **배치 파이프라인**을 실전 적용했습니다. @@ -87,163 +87,188 @@ infrastructure/ ← JPA Repository 구현, Redis 연동, 외부 API 클라이언 ## 주차별 구현 내역 -### Week 1 - 회원 도메인 & TDD 기반 구축 +### Week 1 - 회원 도메인 & E2E TDD 기반 구축 [PR #37](https://github.com/Loopers-dev-lab/loop-pack-be-l2-vol3-java/pull/37) **구현 내용:** -- 회원가입 / 내 정보 조회 API (E2E TDD) -- Clean Architecture 레이어 분리 (interfaces → application → domain → infrastructure) -- Testcontainers 기반 통합 테스트 환경 구성 +- Member 도메인 모델 및 Value Object (`Email`, `Password`, `Nickname`) 구현 +- 회원가입 API (`POST /api/v1/members`) - 이메일 중복 검증, BCrypt 비밀번호 암호화 +- 내 정보 조회 API (`GET /api/v1/members/me`) - JWT 인증 기반 +- 비밀번호 변경 API (`PATCH /api/v1/members/me/password`) - 기존 비밀번호 확인 후 변경 +- Testcontainers (MySQL) 기반 E2E 통합 테스트 환경 구성 **고민했던 부분:** -- TDD Red-Green-Refactor 사이클을 얼마나 엄격하게 지킬 것인가 → E2E 테스트를 먼저 작성하고 컴파일 에러를 따라가며 구현하는 Outside-In 방식 채택 -- 멀티 모듈에서 테스트 프로필 관리 → 각 app 모듈별 `application-test.yml` 분리, Testcontainers로 인프라 격리 +- TDD 사이클을 어떻게 적용할 것인가 → E2E 테스트를 먼저 작성하고 컴파일 에러를 따라가며 구현하는 **Outside-In TDD** 채택. Controller 테스트가 빨간불 → Service → Domain 순으로 내려가며 구현 +- 멀티 모듈 환경에서 테스트 인프라 격리 → 각 app 모듈별 `application-test.yml` 분리, Testcontainers로 DB 자동 프로비저닝하여 로컬 환경 의존성 제거 --- -### Week 2 - 설계 문서화 +### Week 2 - 설계 문서화 & 도메인 모델링 [PR #76](https://github.com/Loopers-dev-lab/loop-pack-be-l2-vol3-java/pull/76) **구현 내용:** -- 요구사항 정의서 작성 및 정책 확정 -- 시퀀스 다이어그램, 클래스 다이어그램, ERD 설계 -- 도메인 간 관계와 책임 경계 정의 +- 요구사항 정의서 작성 - 회원/상품/주문/쿠폰 도메인별 비즈니스 규칙 정의 +- 시퀀스 다이어그램 - 주문 생성, 결제, 쿠폰 적용 흐름 설계 +- 클래스 다이어그램 - 도메인 간 연관 관계 및 책임 분리 설계 +- ERD 설계 - 테이블 관계, 인덱스 전략, soft delete(`deleted_at`) 컬럼 설계 **고민했던 부분:** -- 주문-상품-쿠폰 간의 의존 방향을 어떻게 설정할 것인가 → 도메인 이벤트 기반으로 느슨한 결합 방향 결정 -- 설계 단계에서 확장 가능성과 현실적 구현 범위의 균형 +- 주문-상품-쿠폰 간의 의존 방향 설정 → 주문이 상품/쿠폰을 직접 참조하면 양방향 의존 발생 → **도메인 이벤트 기반 느슨한 결합** 방향으로 설계 +- 설계 단계에서 확장 가능성 vs 현실적 구현 범위의 균형 → YAGNI 원칙에 따라 현재 요구사항에 충실하되, 이벤트 기반 확장 포인트만 열어둠 --- -### Week 3 - 상품/주문/좋아요 도메인 구현 +### Week 3 - 상품/브랜드/좋아요/주문 도메인 구현 [PR #172](https://github.com/Loopers-dev-lab/loop-pack-be-l2-vol3-java/pull/172) **구현 내용:** -- Product 재고 차감/증가 로직 (TDD) -- Brand 도메인 및 BrandName Value Object 검증 -- Like(좋아요) 등록/취소/카운트 기능 -- Order(주문) 생성 유스케이스 및 API v1 -- 트랜잭션 경계 정리 및 명시적 save +- **Product 도메인**: 재고 차감(`decreaseStock`) / 증가(`increaseStock`) 로직, 재고 부족 시 예외 처리, TDD로 검증 +- **Brand 도메인**: `BrandName` Value Object 검증 (길이, 특수문자 제한), BrandService 등록 로직 +- **Like 도메인**: 좋아요 등록/취소/카운트 기능, 유저당 1회 제한 (unique constraint) +- **Order 도메인**: 주문 생성 유스케이스 - 상품 재고 차감 → 주문 항목 생성 → 총액 계산 +- Like API (`POST /api/v1/likes`, `DELETE /api/v1/likes`) 구현 +- 트랜잭션 경계 정리 - Facade 계층에서 `@Transactional` 관리, Domain은 순수 비즈니스 로직만 담당 +- Brand 인프라 구현 (JPA Converter, Repository) **고민했던 부분:** -- 재고 차감 시 동시성 문제 → 이 시점에서는 도메인 로직 정합성에 집중하고, 동시성 제어는 이후 주차에서 해결 -- 트랜잭션 경계를 어디에 둘 것인가 → Application(Facade) 계층에서 `@Transactional`을 관리하고, Domain 계층은 순수 비즈니스 로직만 담당 +- 재고 차감 동시성 문제 → 이 시점에서는 도메인 로직의 정합성 검증에 집중, 동시성 제어는 Week 4에서 별도 처리하기로 결정 +- 트랜잭션 경계를 어디에 둘 것인가 → Domain 계층에 `@Transactional`을 두면 인프라 의존성 침투 → **Application(Facade) 계층에서 트랜잭션 경계를 관리**하고, Domain은 순수 POJO로 유지 --- -### Week 4 - 쿠폰 적용 & 동시성 제어 +### Week 4 - 쿠폰 적용 주문 & 동시성 제어 [PR #183](https://github.com/Loopers-dev-lab/loop-pack-be-l2-vol3-java/pull/183) **구현 내용:** -- 주문 시 쿠폰 적용 및 정합성 처리 -- 좋아요 동시성 제어 (비관적 락 / 낙관적 락 검토) -- 동시성 통합 테스트 추가 +- 주문 시 쿠폰 적용 로직 - 쿠폰 유효성 검증 (만료일, 사용 여부, 최소 주문금액) → 할인 금액 계산 → 주문 총액에 반영 +- 주문 정합성 처리 - 재고 차감 + 쿠폰 사용 + 주문 생성이 하나의 트랜잭션에서 원자적 처리 +- 좋아요 동시성 제어 - 낙관적 락(`@Version`) + retry 메커니즘 적용 +- 동시성 통합 테스트 - `ExecutorService` + `CountDownLatch`로 다수 스레드 동시 요청 검증 **고민했던 부분:** -- 좋아요 카운트의 동시성 제어 방식 → 비관적 락은 처리량 저하 우려, 낙관적 락은 retry 로직 복잡도 증가 → 유스케이스 특성상 충돌 빈도가 낮아 낙관적 락 + retry 채택 -- 쿠폰 적용과 주문 생성이 하나의 트랜잭션에서 처리되어야 하는 이유와 경계 설정 +- 좋아요 동시성 제어 방식 선택 → **비관적 락**: 처리량 저하 + DB 커넥션 점유 시간 증가 / **낙관적 락**: retry 로직 복잡도 증가 → 좋아요는 충돌 빈도가 낮아 **낙관적 락 + retry** 채택 +- 쿠폰 적용과 재고 차감의 트랜잭션 범위 → 쿠폰만 사용되고 재고 차감 실패 시 데이터 불일치 발생 → 하나의 트랜잭션으로 묶어 all-or-nothing 보장 --- -### Week 5 - 상품 조회 성능 최적화 +### Week 5 - 상품 조회 성능 최적화 & Redis 캐시 [PR #216](https://github.com/Loopers-dev-lab/loop-pack-be-l2-vol3-java/pull/216) **구현 내용:** -- 상품 조회 API 및 좋아요 수 반영 구조 -- 상품 조회 인덱스 설계 및 대용량 성능 검증 -- 상품 상세 Redis 캐시 적용 및 캐시-DB 정합성 검증 +- 상품 목록 조회 API - 좋아요 수 반영 정렬 구조, 커서 기반 페이지네이션 +- **인덱스 최적화**: 상품 조회 쿼리에 복합 인덱스 설계, 대용량(10만건+) 데이터 성능 검증 +- **Redis 캐시 적용**: 상품 상세 조회에 Look-Aside 캐시 패턴 적용 +- 캐시-DB 정합성 검증 테스트 - 상품 수정 후 캐시 무효화 확인 **고민했던 부분:** -- 좋아요 수를 상품 조회 시 어떻게 효율적으로 반영할 것인가 → 조회 시마다 JOIN vs 비정규화 카운터 → Redis 캐시로 읽기 부하 분산 -- 캐시 무효화 전략 → TTL 기반으로 eventual consistency 허용, 상세 조회에만 캐시 적용 -- 인덱스 설계 시 커버링 인덱스 vs 복합 인덱스 트레이드오프 +- 좋아요 수를 조회 시 어떻게 효율적으로 반영할 것인가 → 매번 JOIN은 N+1 문제 발생 → **비정규화 카운터 + Redis 캐시**로 읽기 부하 분산 +- 캐시 무효화 전략 → Write-Through vs TTL 기반 → 상품 상세는 실시간 정합성보다 조회 성능이 중요하므로 **TTL 기반 eventual consistency** 허용 +- 커버링 인덱스 vs 복합 인덱스 → 조회 컬럼이 많아 커버링 인덱스는 비효율적 → WHERE + ORDER BY 절에 맞춘 복합 인덱스로 결정 --- -### Week 6 - 결제 시스템 & Resilience +### Week 6 - 결제 시스템 & Resilience 패턴 [PR #237](https://github.com/Loopers-dev-lab/loop-pack-be-l2-vol3-java/pull/237) **구현 내용:** -- 주문/결제 상태 전이 모델 (State Machine 패턴) -- PG 연동 인터페이스 및 시뮬레이터 클라이언트 -- 결제 오케스트레이션 및 보상 트랜잭션 (Saga 패턴) -- PG callback 상태 반영 흐름 -- 결제 복구 스케줄러 (Pending 상태 타임아웃 처리) -- Resilience4j Bulkhead 적용 -- 결제 동시성 통합 테스트 +- **주문/결제 상태 전이 모델**: State Machine 패턴으로 `PENDING → PAID → CANCELLED` 상태 흐름 관리, 잘못된 전이 시 예외 +- **PG 연동 인터페이스**: `PgClient` 인터페이스 + PG Simulator 클라이언트 구현 (외부 의존성 추상화) +- **결제 오케스트레이션 (Saga 패턴)**: 결제 요청 → PG 승인 → 주문 상태 변경, 실패 시 보상 트랜잭션 (결제 취소 → 재고 복구) +- **PG Callback 처리**: 비동기 PG 응답 수신 → 결제 상태 반영 +- **결제 복구 스케줄러**: PENDING 상태 타임아웃(5분) 감지 → PG 상태 조회 → 자동 보상 처리 +- **Resilience4j 적용**: PG 호출에 `Bulkhead` (maxConcurrentCalls) 설정으로 동시 호출 제한 +- 결제 동시성 통합 테스트 - PG callback과 사용자 요청 동시 도달 시나리오 검증 **고민했던 부분:** -- 결제 실패 시 보상 처리 방식 → Choreography vs Orchestration Saga → 결제 도메인이 중심이므로 Orchestration 방식 채택 -- PG callback과 사용자 요청이 동시에 들어올 때의 상태 충돌 → 비관적 락으로 결제 상태 전이의 원자성 보장 -- Bulkhead 설정값 (maxConcurrentCalls, maxWaitDuration) → 부하 테스트 기반으로 PG 응답 시간 고려해 설정 +- 결제 실패 보상 처리 방식 → **Choreography Saga**: 이벤트 기반으로 각 서비스가 독립적 보상 / **Orchestration Saga**: 중앙 조율자가 보상 흐름 관리 → 결제 도메인이 전체 흐름의 중심이므로 **Orchestration Saga** 채택 +- PG callback과 사용자 취소 요청의 동시 도달 → 결제 상태 전이에 **비관적 락(`SELECT FOR UPDATE`)** 적용으로 race condition 방지 +- Resilience4j 설정값 결정 → PG 평균 응답시간과 서버 스레드풀 크기를 고려하여 Bulkhead `maxConcurrentCalls`와 `maxWaitDuration` 설정 --- -### Week 7 - 이벤트 기반 아키텍처 & 선착순 쿠폰 +### Week 7 - 이벤트 기반 아키텍처 & 선착순 쿠폰 [PR #293](https://github.com/Loopers-dev-lab/loop-pack-be-l2-vol3-java/pull/293) **구현 내용:** -- 주문 생성 이벤트 발행 (Spring ApplicationEvent + `@Async` 핸들러) -- Transactional Outbox 패턴 구현 - - OutboxEvent 엔티티 저장 → Scheduler가 Kafka로 relay → Consumer에서 멱등 처리 -- Kafka Producer (acks=all, idempotence) / Consumer (manual ack) -- 선착순 쿠폰 발급 도메인 + Kafka 기반 비동기 처리 -- Coupon 발급 요청 API 및 polling 조회 -- OutboxEvent partitionKey 기반 Kafka partition 전략 +- **도메인 이벤트 발행**: `ApplicationEventPublisher` + `@TransactionalEventListener(AFTER_COMMIT)` + `@Async` 비동기 핸들러 +- **Transactional Outbox 패턴**: + - `OutboxEvent` 엔티티 (eventType enum, payload JSON, status, partitionKey) + - Scheduler가 미발행 이벤트를 polling → Kafka로 relay + - Consumer 측 `IdempotencyKey` 테이블로 중복 소비 방어 +- **Kafka 설정 강화**: Producer `acks=all` + `enable.idempotence=true`, Consumer `manual ack` + offset commit 보완 +- **선착순 쿠폰 시스템**: + - `CouponIssueRequest` 도메인 모델 (PENDING → ISSUED / FAILED 상태) + - Redis `DECR`로 쿠폰 재고 원자적 차감 + - Kafka Consumer에서 비동기 발급 처리 + - 발급 요청 API (`POST /api/v1/coupons/issue`) + polling 조회 API +- **OutboxEvent partitionKey**: orderId 기반 Kafka partition 전략으로 동일 주문 이벤트 순서 보장 +- 쿠폰 발급 동시성 테스트 및 Kafka 관련 테스트 보강 **고민했던 부분:** -- 이벤트 발행과 DB 트랜잭션의 원자성 → `@TransactionalEventListener(AFTER_COMMIT)`만으로는 발행 실패 시 유실 가능 → Outbox 패턴으로 at-least-once 보장 -- 멱등 처리를 어디서 할 것인가 → Consumer 측에 idempotency key 테이블을 두어 중복 소비 방어 -- Kafka partition 전략 → 같은 주문의 이벤트는 순서 보장 필요 → orderId 기반 partitionKey 적용 -- 선착순 쿠폰의 재고 관리 → Redis DECR로 원자적 차감 후 Kafka 이벤트로 실제 발급 처리 +- 이벤트 발행과 DB 트랜잭션의 원자성 → `@TransactionalEventListener(AFTER_COMMIT)`만으로는 커밋 후 이벤트 발행 실패 시 유실 → **Outbox 패턴으로 at-least-once delivery 보장**, Consumer에서 멱등 처리로 exactly-once semantics 근사 +- Kafka partition 전략 → 같은 주문의 이벤트(생성/결제/취소)는 순서 보장 필요 → `orderId`를 partitionKey로 사용하여 동일 파티션 라우팅 +- 선착순 쿠폰 재고의 원자적 차감 → DB 락은 병목 → **Redis DECR**로 원자적 차감 후, 실제 쿠폰 발급은 Kafka 비동기 처리로 분리 --- -### Week 8 - 주문 대기열 시스템 +### Week 8 - Redis 기반 주문 대기열 시스템 [PR #335](https://github.com/Loopers-dev-lab/loop-pack-be-l2-vol3-java/pull/335) **구현 내용:** -- Redis 기반 주문 대기열 (Sorted Set) -- 대기열 진입 API 및 토큰 검증 로직 -- 예상 대기시간 계산 로직 -- Lua Script로 dequeue + 토큰 발급 원자화 -- 주문 성공 시 입장 토큰 삭제 -- 동시성 테스트 (2000명 동시 진입, 처리량 초과, TTL 만료) +- **Redis Sorted Set 대기열**: score를 timestamp로 사용하여 FIFO 순서 보장 +- 대기열 진입 API (`POST /api/v1/queue/enter`) - 중복 진입 방지 (NX 옵션) +- 대기 상태 조회 API - 현재 대기 순번 + 예상 대기시간 계산 +- 토큰 검증 미들웨어 - 주문 API 호출 시 유효한 입장 토큰 보유 여부 확인 +- **Lua Script 원자 연산**: `ZPOPMIN` (대기열 dequeue) + `SET NX EX` (토큰 발급)를 하나의 스크립트로 묶어 원자 실행 +- 주문 성공 시 입장 토큰 명시적 삭제 + TTL fallback 이중 전략 +- 활성 토큰 보유 유저의 대기열 재진입 차단 +- 동시성 테스트: 2000명 동시 진입, 처리량 초과 검증, 토큰 TTL 만료 시나리오 **고민했던 부분:** -- 대기열 dequeue와 토큰 발급을 어떻게 원자적으로 처리할 것인가 → 두 개의 Redis 명령을 개별 실행하면 중간에 장애 시 토큰 없는 유저가 빠져나갈 수 있음 → **Lua Script**로 ZPOPMIN + SET NX EX를 하나의 원자 연산으로 묶음 -- 토큰 TTL 관리 → 너무 짧으면 정상 사용자도 만료, 너무 길면 좀비 토큰 누적 → 주문 완료 시 명시적 삭제 + TTL fallback 이중 전략 -- 대기열 순서 보장 → Redis Sorted Set의 score를 timestamp로 사용하여 FIFO 보장 +- dequeue + 토큰 발급의 원자성 → 두 Redis 명령을 순차 실행하면 중간 장애 시 토큰 없이 대기열에서 빠져나가는 유저 발생 → **Lua Script**로 `ZPOPMIN + SET NX EX`를 단일 원자 연산으로 묶어 해결 +- 토큰 TTL 설정 → 너무 짧으면(1분) 정상 사용자도 주문 중 만료, 너무 길면(1시간) 좀비 토큰 누적 → **주문 완료 시 명시적 DEL + TTL(5분) fallback** 이중 전략으로 리소스 누수 방지 +- 예상 대기시간 계산 정확도 → 단순 `순번 × 평균처리시간`은 부정확 → 최근 N건의 처리 속도를 기반으로 동적 계산하도록 보완 --- -### Week 9 - 실시간 상품 랭킹 파이프라인 +### Week 9 - 실시간 상품 랭킹 파이프라인 [PR #383](https://github.com/Loopers-dev-lab/loop-pack-be-l2-vol3-java/pull/383) **구현 내용:** -- Ranking Score Policy 구현 (이벤트별 가중치 기반 점수 산출) -- Kafka Consumer → Redis ZSET 실시간 적재 파이프라인 -- `ZUNIONSTORE` 기반 랭킹 carry-over (콜드 스타트 완화) -- 랭킹 carry-over Scheduler 구현 및 테스트 -- 상품 랭킹 조회 API +- **Ranking Score Policy**: 이벤트 유형별 가중치 기반 점수 산출 (주문 완료, 좋아요, 조회 등) +- **Kafka → Redis 실시간 파이프라인**: Kafka 배치 Consumer (`max.poll.records=3000`)가 이벤트 소비 → Score Policy 적용 → Redis ZSET(`ranking:all:{yyyyMMdd}`) `ZINCRBY`로 실시간 적재 +- **ZUNIONSTORE 기반 carry-over**: 자정에 전일 랭킹 데이터를 가중치(0.5)를 낮춰 새 날짜 키로 복사, 콜드 스타트 완화 +- Carry-over Scheduler - 매일 자정 자동 실행, 테스트로 동작 검증 +- 상품 랭킹 조회 API (`GET /api/v1/rankings`) - Redis ZREVRANGE로 TOP N 조회 +- 상품 상세 조회에 dailyRank 필드 추가 **고민했던 부분:** -- 콜드 스타트 문제 → 자정에 새로운 날짜 키가 생성되면 랭킹이 비어있음 → `ZUNIONSTORE`로 전일 데이터를 가중치를 낮춰 carry-over, Scheduler가 자정에 자동 실행 -- Redis ZSET 하나로 일별 랭킹을 관리하되, 키 네이밍 컨벤션(`ranking:all:{yyyyMMdd}`)으로 일자별 분리 -- Kafka 배치 Consumer 설정 → `max.poll.records=3000`으로 처리량 확보, manual ack으로 유실 방지 +- 콜드 스타트 문제 → 자정에 새로운 날짜 키(`ranking:all:20250615`)가 생성되면 데이터가 비어있음 → **`ZUNIONSTORE`로 전일 데이터를 가중치(0.5)로 carry-over**, 당일 이벤트가 쌓이면 자연스럽게 전일 영향 감소 +- Redis 키 네이밍 전략 → `ranking:{category}:{yyyyMMdd}` 형태로 일자별 분리, TTL(3일)로 오래된 키 자동 정리 +- Kafka 배치 Consumer 설정 → `max.poll.records=3000`, `session.timeout.ms=60000`, manual ack으로 처리량 확보와 유실 방지 균형 --- -### Week 10 - 배치 기반 랭킹 집계 & API 확장 +### Week 10 - Spring Batch 랭킹 집계 & API 확장 [PR #420](https://github.com/Loopers-dev-lab/loop-pack-be-l2-vol3-java/pull/420) **구현 내용:** -- **Daily Metrics Snapshot Batch**: Redis ZSET → MySQL `product_metrics` 테이블로 일별 스냅샷 - - Batch insert (1000건 단위 flush/clear) 로 메모리 효율 확보 - - Empty result guard (Redis 데이터 없을 시 기존 데이터 보호) -- **Weekly/Monthly Rank Aggregation Batch**: `product_metrics` 7일/30일 집계 → `mv_product_rank_weekly` / `mv_product_rank_monthly` - - JdbcCursorItemReader로 대량 데이터 스트리밍 처리 - - Chunk 기반 처리 (chunk size: 100) +- **Daily Metrics Snapshot Batch** (`dailyMetricsSnapshotJob`): + - Redis ZSET 전체 스코어 조회 → `product_metrics` 테이블에 일별 스냅샷 저장 + - `EntityManager.flush()/clear()` 1000건마다 호출하여 영속성 컨텍스트 메모리 제어 + - Empty result guard - Redis에 데이터가 없으면 기존 DB 데이터 보호 (삭제 스킵) + - 잘못된 productId(null, 음수) 필터링 +- **Weekly/Monthly Rank Aggregation Batch** (`rankAggregationJob`): + - `JdbcCursorItemReader`로 `product_metrics` 7일/30일 구간 집계 (SUM + GROUP BY + ORDER BY) - TOP 100 제한으로 불필요한 연산 방지 -- **Ranking API 확장**: DAILY / WEEKLY / MONTHLY 기간별 랭킹 조회 -- 모든 배치 작업 멱등성 보장 (동일 파라미터 재실행 시 동일 결과) + - Chunk 기반 처리 (chunk size: 100) + - `WeeklyRankWriter` / `MonthlyRankWriter` - 첫 번째 chunk에서 `AtomicBoolean`으로 기존 데이터 1회 삭제 후 insert + - `mv_product_rank_weekly` / `mv_product_rank_monthly` 테이블에 적재 +- **Ranking API 확장** (`GET /api/v1/rankings?period=DAILY|WEEKLY|MONTHLY`): + - `RankPeriod` enum으로 기간별 분기 + - DAILY: `product_metrics` 직접 조회 (score DESC) + - WEEKLY/MONTHLY: materialized view 테이블 조회 (ranking ASC) + - 페이지 크기 제한 (max 100) +- **Batch 모니터링**: `JobListener`, `StepMonitorListener`, `ChunkListener`로 실행 메트릭 로깅 +- 모든 배치 작업 **멱등성 보장** - 동일 `requestDate` 파라미터로 재실행 시 동일 결과 (`deleteByDate` → `saveAll`) +- E2E 테스트: 집계 정확성, 날짜 범위 필터링, TOP 100 제한, 멱등성, 엣지 케이스 **고민했던 부분:** -- Redis 스냅샷을 왜 DB에 저장하는가 → Redis는 휘발성 + 장기 데이터 보관에 부적합 → MySQL에 일별 스냅샷을 남겨 주간/월간 집계의 안정적 원천 데이터 확보 -- Batch insert 시 영속성 컨텍스트 관리 → 대량 insert 시 1차 캐시 메모리 폭증 → `entityManager.flush()` + `clear()`를 1000건마다 호출하여 메모리 제어 -- Empty result guard → 배치 실행 시 Redis에 데이터가 없으면 기존 DB 데이터를 삭제하면 안됨 → Redis 결과가 비어있으면 스킵 처리 -- 주간/월간 랭킹 테이블 설계 → View vs 물리 테이블 → 조회 성능과 집계 비용을 고려하여 물리 테이블(`mv_product_rank_weekly/monthly`)에 배치로 적재하는 Materialized View 전략 채택 -- Writer에서의 delete 타이밍 → 첫 번째 chunk 처리 시에만 기존 데이터 삭제 (AtomicBoolean으로 제어), 이후 chunk는 insert만 수행 +- Redis 스냅샷을 왜 MySQL에 저장하는가 → Redis는 휘발성이고 장기 데이터 보관에 부적합 → MySQL에 일별 스냅샷을 남겨 **주간/월간 집계의 안정적인 원천 데이터** 확보 +- 대량 insert 시 메모리 관리 → JPA `persist()`가 1차 캐시에 엔티티를 누적해 OOM 위험 → **1000건마다 `flush() + clear()`** 호출로 메모리 사용량 일정하게 유지 +- Empty result guard → Redis 장애나 데이터 부재 시 배치가 기존 DB 데이터를 삭제하면 안됨 → Redis 결과가 비어있으면 **기존 데이터 보호 후 스킵** +- 주간/월간 랭킹 테이블 전략 → DB View는 조회마다 집계 연산 발생으로 느림 → **Materialized View 전략** (물리 테이블 `mv_product_rank_weekly/monthly`에 배치로 미리 적재)으로 조회 성능 확보 +- Writer의 delete 타이밍 → 모든 chunk마다 delete하면 데이터 유실 → `AtomicBoolean`으로 **첫 번째 chunk에서만 1회 삭제**, 이후 chunk는 insert만 수행 --- @@ -297,15 +322,17 @@ infrastructure/ ← JPA Repository 구현, Redis 연동, 외부 API 클라이언 | 주제 | 결정 | 이유 | |------|------|------| | 아키텍처 | Clean Architecture + 멀티 모듈 | 도메인 로직 보호, 인프라 교체 용이성 | +| 트랜잭션 경계 | Application(Facade) 계층 | Domain 계층의 인프라 의존성 제거 | +| 동시성 제어 | 낙관적 락 + retry (좋아요), 비관적 락 (결제) | 유스케이스별 충돌 빈도에 따라 전략 분리 | | 이벤트 발행 | Transactional Outbox 패턴 | DB 트랜잭션과 메시지 발행의 원자성 보장 | | 대기열 원자성 | Redis Lua Script | dequeue + 토큰 발급을 단일 원자 연산으로 | | 랭킹 콜드 스타트 | ZUNIONSTORE carry-over | 자정 랭킹 초기화 문제 해결 | -| 장기 랭킹 | Materialized View 전략 | Redis 휘발성 극복, 주간/월간 안정적 집계 | +| 장기 랭킹 집계 | Materialized View 전략 | Redis 휘발성 극복, 주간/월간 안정적 집계 | | 배치 메모리 | flush/clear per 1000건 | 대량 insert 시 영속성 컨텍스트 OOM 방지 | | 결제 보상 | Orchestration Saga | 결제 중심의 명확한 보상 흐름 | -| 캐시 전략 | TTL 기반 eventual consistency | 상품 상세 읽기 부하 분산 | +| 캐시 전략 | TTL 기반 Look-Aside | 상품 상세 읽기 부하 분산 | | Kafka 신뢰성 | acks=all + manual ack + 멱등 | 메시지 유실 방지 + 중복 소비 방어 | -| Redis 가용성 | Master-Replica + ReadFrom 분리 | 읽기 부하 분산, 장애 시 replica fallback | +| Redis 가용성 | Master-Replica + ReadFrom 분리 | 읽기 부하 분산, replica fallback | ---