diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index 561accaef7..f6fadd9a2f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -3,12 +3,15 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductWithBrand; +import com.loopers.domain.ranking.MvProductRank; +import com.loopers.domain.ranking.MvProductRankRepository; import com.loopers.infrastructure.ranking.RankingRedisRepository; import com.loopers.interfaces.api.ranking.RankingDto; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,22 +34,78 @@ public class RankingFacade { private static final int MAX_RANKING_SIZE = 100; private static final String DAILY_ZSET_PREFIX = "ranking:all:"; - private static final String WEEKLY_ZSET_PREFIX = "ranking:weekly:"; - private static final String MONTHLY_ZSET_PREFIX = "ranking:monthly:"; private final RankingRedisRepository rankingRedisRepository; + private final MvProductRankRepository mvProductRankRepository; private final ProductRepository productRepository; private final RankingProperties properties; public RankingDto.PagedRankingResponse getRankings(String scope, String date, int page, int size, Long memberId) { String resolvedDate = (date != null) ? date : LocalDate.now(KST).format(DATE_FORMATTER); - String prefix = resolveZsetPrefix(scope, memberId); + + return switch (scope) { + case "weekly", "monthly" -> getFromMv(scope, resolvedDate, page, size); + default -> getFromRedis(scope, resolvedDate, page, size, memberId); + }; + } + + private RankingDto.PagedRankingResponse getFromMv(String scope, String date, int page, int size) { + // 1. 당일 MV 조회 + List mvResults = mvProductRankRepository.findByPeriodKeyAndScope( + date, scope, PageRequest.of(page, size)); + long totalElements = mvProductRankRepository.countByPeriodKeyAndScope(date, scope); + + // 2. 당일 데이터 없으면 전일 fallback + if (mvResults.isEmpty()) { + String previousDate = LocalDate.parse(date, DATE_FORMATTER) + .minusDays(1).format(DATE_FORMATTER); + mvResults = mvProductRankRepository.findByPeriodKeyAndScope( + previousDate, scope, PageRequest.of(page, size)); + totalElements = mvProductRankRepository.countByPeriodKeyAndScope(previousDate, scope); + + if (!mvResults.isEmpty()) { + log.info("MV 전일 fallback 적용: scope={}, date={} → {}", scope, date, previousDate); + } + } + + // 3. 전일도 없으면 빈 결과 + if (mvResults.isEmpty()) { + return new RankingDto.PagedRankingResponse(List.of(), 0, 0, page, size); + } + + totalElements = Math.min(totalElements, MAX_RANKING_SIZE); + int totalPages = (int) Math.ceil((double) totalElements / size); + + // 4. Product 상세 조합 + List productIds = mvResults.stream() + .map(MvProductRank::getProductId).toList(); + + Map productMap = productRepository.findAllByIds(productIds).stream() + .collect(Collectors.toMap(pwb -> pwb.product().getId(), pwb -> pwb)); + + List data = new ArrayList<>(); + for (MvProductRank mv : mvResults) { + ProductWithBrand pwb = productMap.get(mv.getProductId()); + if (pwb != null) { + Product product = pwb.product(); + data.add(new RankingDto.RankingResponse( + mv.getProductId(), product.getName(), pwb.brandName(), + product.getPrice().getValue(), mv.getRanking(), mv.getScore() + )); + } + } + + return new RankingDto.PagedRankingResponse(data, totalElements, totalPages, page, size); + } + + private RankingDto.PagedRankingResponse getFromRedis(String scope, String date, int page, int size, Long memberId) { + String prefix = resolveDailyPrefix(memberId); long totalElements; List entries; try { - long rawTotal = rankingRedisRepository.getTotalCount(prefix, resolvedDate); + long rawTotal = rankingRedisRepository.getTotalCount(prefix, date); totalElements = Math.min(rawTotal, MAX_RANKING_SIZE); long start = (long) page * size; @@ -57,15 +116,14 @@ public RankingDto.PagedRankingResponse getRankings(String scope, String date, in } long end = Math.min(start + size - 1, totalElements - 1); - entries = rankingRedisRepository.getTopN(prefix, resolvedDate, start, end); + entries = rankingRedisRepository.getTopN(prefix, date, start, end); } catch (Exception e) { log.error("랭킹 Redis 조회 실패", e); throw new CoreException(ErrorType.INTERNAL_ERROR, "랭킹 서비스를 일시적으로 이용할 수 없습니다."); } List productIds = entries.stream() - .map(RankingRedisRepository.RankingEntry::productId) - .toList(); + .map(RankingRedisRepository.RankingEntry::productId).toList(); Map productMap = productRepository.findAllByIds(productIds).stream() .collect(Collectors.toMap(pwb -> pwb.product().getId(), pwb -> pwb)); @@ -88,24 +146,15 @@ public RankingDto.PagedRankingResponse getRankings(String scope, String date, in return new RankingDto.PagedRankingResponse(data, totalElements, totalPages, page, size); } - private String resolveZsetPrefix(String scope, Long memberId) { - // A/B 테스트는 daily에만 적용 - if ("daily".equals(scope) || scope == null) { - RankingProperties.Experiment experiment = properties.experiment(); - if (experiment.enabled() && !experiment.variants().isEmpty() && memberId != null) { - List variantKeys = new ArrayList<>(experiment.variants().keySet()); - int variantIndex = (int) (Math.abs(memberId) % variantKeys.size()); - String selectedKey = variantKeys.get(variantIndex); - RankingProperties.Variant variant = experiment.variants().get(selectedKey); - return variant.zsetPrefix(); - } - return DAILY_ZSET_PREFIX; + private String resolveDailyPrefix(Long memberId) { + RankingProperties.Experiment experiment = properties.experiment(); + if (experiment.enabled() && !experiment.variants().isEmpty() && memberId != null) { + List variantKeys = new ArrayList<>(experiment.variants().keySet()); + int variantIndex = (int) (Math.abs(memberId) % variantKeys.size()); + String selectedKey = variantKeys.get(variantIndex); + RankingProperties.Variant variant = experiment.variants().get(selectedKey); + return variant.zsetPrefix(); } - - return switch (scope) { - case "weekly" -> WEEKLY_ZSET_PREFIX; - case "monthly" -> MONTHLY_ZSET_PREFIX; - default -> DAILY_ZSET_PREFIX; - }; + return DAILY_ZSET_PREFIX; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRank.java new file mode 100644 index 0000000000..f1d88ca85b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRank.java @@ -0,0 +1,45 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@MappedSuperclass +public abstract class MvProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Integer ranking; + + @Column(nullable = false) + private Double score; + + @Column(nullable = false) + private Long viewCount; + + @Column(nullable = false) + private Long likeCount; + + @Column(nullable = false) + private Long salesCount; + + @Column(nullable = false) + private Long salesAmount; + + @Column(nullable = false, length = 8) + private String periodKey; + + @Column(nullable = false) + private LocalDateTime createdAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java new file mode 100644 index 0000000000..010f78c726 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -0,0 +1,12 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_monthly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankMonthly extends MvProductRank { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java new file mode 100644 index 0000000000..20a748b667 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.ranking; + +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface MvProductRankRepository { + + List findByPeriodKeyAndScope(String periodKey, String scope, Pageable pageable); + + long countByPeriodKeyAndScope(String periodKey, String scope); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java new file mode 100644 index 0000000000..dfa1218e12 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -0,0 +1,12 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_weekly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankWeekly extends MvProductRank { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankJpaRepository.java new file mode 100644 index 0000000000..bae44a38d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankJpaRepository.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.*; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class MvProductRankJpaRepository implements MvProductRankRepository { + + private final MvProductRankWeeklySpringDataRepository weeklyRepository; + private final MvProductRankMonthlySpringDataRepository monthlyRepository; + + @Override + public List findByPeriodKeyAndScope(String periodKey, String scope, Pageable pageable) { + return switch (scope) { + case "weekly" -> weeklyRepository.findByPeriodKeyOrderByRankingAsc(periodKey, pageable) + .stream().map(r -> (MvProductRank) r).toList(); + case "monthly" -> monthlyRepository.findByPeriodKeyOrderByRankingAsc(periodKey, pageable) + .stream().map(r -> (MvProductRank) r).toList(); + default -> throw new IllegalArgumentException("Invalid scope: " + scope); + }; + } + + @Override + public long countByPeriodKeyAndScope(String periodKey, String scope) { + return switch (scope) { + case "weekly" -> weeklyRepository.countByPeriodKey(periodKey); + case "monthly" -> monthlyRepository.countByPeriodKey(periodKey); + default -> throw new IllegalArgumentException("Invalid scope: " + scope); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlySpringDataRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlySpringDataRepository.java new file mode 100644 index 0000000000..4a84e2074c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlySpringDataRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankMonthlySpringDataRepository extends JpaRepository { + + List findByPeriodKeyOrderByRankingAsc(String periodKey, Pageable pageable); + + long countByPeriodKey(String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklySpringDataRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklySpringDataRepository.java new file mode 100644 index 0000000000..8a5bf79dd3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklySpringDataRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankWeeklySpringDataRepository extends JpaRepository { + + List findByPeriodKeyOrderByRankingAsc(String periodKey, Pageable pageable); + + long countByPeriodKey(String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/ProductRankingMvJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/ProductRankingMvJobConfig.java new file mode 100644 index 0000000000..aab2c4d702 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/ProductRankingMvJobConfig.java @@ -0,0 +1,295 @@ +package com.loopers.batch.job.rankingmv; + +import com.loopers.batch.job.rankingmv.step.CleanupTasklet; +import com.loopers.batch.job.rankingcorrection.RankingCorrectionProperties; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +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.partition.support.Partitioner; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Qualifier; +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.core.task.SimpleAsyncTaskExecutor; +import org.springframework.dao.DeadlockLoserDataAccessException; +import org.springframework.dao.TransientDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.retry.backoff.ExponentialBackOffPolicy; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * MV 기반 주간/월간 랭킹 집계 Job. + * + *

product_metrics를 product_id 범위로 분할하여 병렬 집계(스테이징)한 후, + * mergeStep에서 Global TOP 100을 추출하여 MV 테이블에 적재한다.

+ */ +@Slf4j +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = ProductRankingMvJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class ProductRankingMvJobConfig { + + public static final String JOB_NAME = "productRankingMvJob"; + private static final int CHUNK_SIZE = 1_000; + @Value("${ranking.mv.grid-size:4}") + private int gridSize; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final JdbcTemplate jdbcTemplate; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final CleanupTasklet cleanupTasklet; + private final RankingCorrectionProperties properties; + + @Bean(JOB_NAME) + public Job productRankingMvJob( + @Qualifier("cleanupStep") Step cleanupStep, + @Qualifier("partitionedAggregateStep") Step partitionedAggregateStep, + @Qualifier("mergeStep") Step mergeStep + ) { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(cleanupStep).on("FAILED").end() + .from(cleanupStep).on("*").to(partitionedAggregateStep) + .next(mergeStep) + .end() + .listener(jobListener) + .build(); + } + + @JobScope + @Bean("cleanupStep") + public Step cleanupStep() { + return new StepBuilder("cleanupStep", jobRepository) + .tasklet(cleanupTasklet, transactionManager) + .allowStartIfComplete(true) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean("partitionedAggregateStep") + public Step partitionedAggregateStep( + @Value("#{jobParameters['targetDate']}") String targetDate, + @Value("#{jobParameters['scope']}") String scope + ) { + return new StepBuilder("partitionedAggregateStep", jobRepository) + .partitioner("workerStep", createPartitioner(targetDate, scope)) + .step(workerStep()) + .gridSize(gridSize) + .taskExecutor(new SimpleAsyncTaskExecutor("mv-worker-")) + .build(); + } + + private Partitioner createPartitioner(String targetDate, String scope) { + return gridSize -> { + int days = "weekly".equals(scope) ? 6 : 29; + LocalDate endDate = LocalDate.parse(targetDate, DATE_FORMATTER); + LocalDate startDate = endDate.minusDays(days); + + List productIds = jdbcTemplate.queryForList( + "SELECT DISTINCT product_id FROM product_metrics " + + "WHERE metric_date BETWEEN ? AND ? ORDER BY product_id", + Long.class, startDate, endDate); + + if (productIds.isEmpty()) { + log.warn("[Partitioner] 데이터 없음: {} ~ {}", startDate, endDate); + Map empty = new HashMap<>(); + ExecutionContext ctx = new ExecutionContext(); + ctx.putLong("minProductId", 0); + ctx.putLong("maxProductId", 0); + empty.put("partition0", ctx); + return empty; + } + + int totalProducts = productIds.size(); + int partitionSize = totalProducts / gridSize + (totalProducts % gridSize == 0 ? 0 : 1); + Map partitions = new HashMap<>(); + + for (int i = 0; i < gridSize; i++) { + int fromIndex = i * partitionSize; + if (fromIndex >= totalProducts) break; + int toIndex = Math.min((i + 1) * partitionSize, totalProducts); + + ExecutionContext ctx = new ExecutionContext(); + long partMin = productIds.get(fromIndex); + long partMax = productIds.get(toIndex - 1); + ctx.putLong("minProductId", partMin); + ctx.putLong("maxProductId", partMax); + partitions.put("partition" + i, ctx); + + log.info("[Partitioner] partition{}: productId {}~{} ({}건)", i, partMin, partMax, toIndex - fromIndex); + } + return partitions; + }; + } + + @Bean + public Step workerStep() { + ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy(); + backOff.setInitialInterval(100); + backOff.setMultiplier(2.0); + backOff.setMaxInterval(1000); + + return new StepBuilder("workerStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(stagingReader(null, null, null, null)) + .writer(stagingWriter(null)) + .faultTolerant() + .retry(DeadlockLoserDataAccessException.class) + .retry(TransientDataAccessException.class) + .retryLimit(3) + .backOffPolicy(backOff) + .listener(stepMonitorListener) + .build(); + } + + @StepScope + @Bean + public JdbcCursorItemReader stagingReader( + @Value("#{jobParameters['targetDate']}") String targetDate, + @Value("#{jobParameters['scope']}") String scope, + @Value("#{stepExecutionContext['minProductId']}") Long minProductId, + @Value("#{stepExecutionContext['maxProductId']}") Long maxProductId + ) { + int days = "weekly".equals(scope) ? 6 : 29; + LocalDate endDate = LocalDate.parse(targetDate, DATE_FORMATTER); + LocalDate startDate = endDate.minusDays(days); + + RankingCorrectionProperties.Weights w = properties.weights(); + + String sql = """ + SELECT + pm.product_id, + SUM(pm.view_count) AS total_view_count, + SUM(pm.like_count - pm.unlike_count) AS total_net_like_count, + SUM(pm.sales_count) AS total_sales_count, + SUM(pm.sales_amount - pm.cancel_amount_by_event_date) AS total_net_sales_amount, + ( + %s * LOG10(GREATEST(SUM(pm.view_count), 0) + 1) / 7.0 + + %s * LOG10(GREATEST(SUM(pm.like_count - pm.unlike_count), 0) + 1) / 7.0 + + %s * LOG10(GREATEST(SUM(pm.sales_amount - pm.cancel_amount_by_event_date), 0) + 1) / 7.0 + + UNIX_TIMESTAMP() * 1e-16 + ) AS score + FROM product_metrics pm + JOIN product p ON pm.product_id = p.id + WHERE pm.metric_date BETWEEN ? AND ? + AND pm.product_id BETWEEN ? AND ? + AND p.deleted_at IS NULL + GROUP BY pm.product_id + """.formatted(w.view(), w.like(), w.order()); + + return new JdbcCursorItemReaderBuilder() + .name("stagingReader") + .dataSource(dataSource) + .sql(sql) + .preparedStatementSetter(ps -> { + ps.setObject(1, startDate); + ps.setObject(2, endDate); + ps.setLong(3, minProductId); + ps.setLong(4, maxProductId); + }) + .rowMapper((rs, rowNum) -> new ScoredProductRow( + rs.getLong("product_id"), + rs.getDouble("score"), + rs.getLong("total_view_count"), + rs.getLong("total_net_like_count"), + rs.getLong("total_sales_count"), + rs.getLong("total_net_sales_amount") + )) + .build(); + } + + @StepScope + @Bean + public JdbcBatchItemWriter stagingWriter( + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + return new JdbcBatchItemWriterBuilder() + .dataSource(dataSource) + .sql(""" + INSERT INTO mv_product_rank_staging + (product_id, score, view_count, like_count, sales_count, sales_amount, period_key) + VALUES (?, ?, ?, ?, ?, ?, ?) + """) + .itemPreparedStatementSetter((item, ps) -> { + ps.setLong(1, item.productId()); + ps.setDouble(2, item.score()); + ps.setLong(3, item.viewCount()); + ps.setLong(4, item.likeCount()); + ps.setLong(5, item.salesCount()); + ps.setLong(6, item.salesAmount()); + ps.setString(7, targetDate); + }) + .assertUpdates(false) + .build(); + } + + @JobScope + @Bean("mergeStep") + public Step mergeStep( + @Value("#{jobParameters['targetDate']}") String targetDate, + @Value("#{jobParameters['scope']}") String scope + ) { + return new StepBuilder("mergeStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + String mvTable = switch (scope) { + case "weekly" -> "mv_product_rank_weekly"; + case "monthly" -> "mv_product_rank_monthly"; + default -> throw new IllegalArgumentException("Invalid scope: " + scope); + }; + + int inserted = jdbcTemplate.update(""" + INSERT INTO %s + (product_id, ranking, score, view_count, like_count, + sales_count, sales_amount, period_key, created_at) + SELECT + product_id, + ROW_NUMBER() OVER (ORDER BY score DESC) AS ranking, + score, view_count, like_count, sales_count, sales_amount, + ?, NOW() + FROM mv_product_rank_staging + WHERE period_key = ? + ORDER BY score DESC + LIMIT 100 + """.formatted(mvTable), targetDate, targetDate); + + log.info("[Merge] {} 적재 완료: period_key={}, rows={}", mvTable, targetDate, inserted); + return RepeatStatus.FINISHED; + }, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + record ScoredProductRow( + long productId, double score, + long viewCount, long likeCount, + long salesCount, long salesAmount + ) {} +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/step/CleanupTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/step/CleanupTasklet.java new file mode 100644 index 0000000000..139beef6b6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/step/CleanupTasklet.java @@ -0,0 +1,68 @@ +package com.loopers.batch.job.rankingmv.step; + +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.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@StepScope +@RequiredArgsConstructor +@Component +public class CleanupTasklet implements Tasklet { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + private static final int RETENTION_DAYS = 3; + + private final JdbcTemplate jdbcTemplate; + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + @Value("#{jobParameters['scope']}") + private String scope; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String mvTable = resolveMvTable(scope); + + int deletedMv = jdbcTemplate.update( + "DELETE FROM " + mvTable + " WHERE period_key = ?", targetDate); + log.info("[Cleanup] {} 삭제: period_key={}, rows={}", mvTable, targetDate, deletedMv); + + int deletedStaging = jdbcTemplate.update( + "DELETE FROM mv_product_rank_staging WHERE period_key = ?", targetDate); + log.info("[Cleanup] staging 삭제: period_key={}, rows={}", targetDate, deletedStaging); + + LocalDate cutoffDate = LocalDate.parse(targetDate, DATE_FORMATTER).minusDays(RETENTION_DAYS); + String cutoffKey = cutoffDate.format(DATE_FORMATTER); + + int purgedMv = jdbcTemplate.update( + "DELETE FROM " + mvTable + " WHERE period_key < ?", cutoffKey); + int purgedStaging = jdbcTemplate.update( + "DELETE FROM mv_product_rank_staging WHERE period_key < ?", cutoffKey); + + if (purgedMv + purgedStaging > 0) { + log.info("[Cleanup] {}일 이전 데이터 정리: mv={}, staging={}", RETENTION_DAYS, purgedMv, purgedStaging); + } + + return RepeatStatus.FINISHED; + } + + private String resolveMvTable(String scope) { + return switch (scope) { + case "weekly" -> "mv_product_rank_weekly"; + case "monthly" -> "mv_product_rank_monthly"; + default -> throw new IllegalArgumentException("Invalid scope: " + scope); + }; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/rankingmv/ProductRankingMvJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/rankingmv/ProductRankingMvJobE2ETest.java new file mode 100644 index 0000000000..a5d3b485d6 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/rankingmv/ProductRankingMvJobE2ETest.java @@ -0,0 +1,725 @@ +package com.loopers.job.rankingmv; + +import com.loopers.batch.job.rankingmv.ProductRankingMvJobConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + ProductRankingMvJobConfig.JOB_NAME) +@Sql(scripts = "/schema-batch-test.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +class ProductRankingMvJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(ProductRankingMvJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private ProductRankingMvJobConfig jobConfig; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + private static final String TARGET_DATE = "20260416"; + private static final int SEED_BATCH_SIZE = 1_000; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + jdbcTemplate.execute("TRUNCATE TABLE mv_product_rank_weekly"); + jdbcTemplate.execute("TRUNCATE TABLE mv_product_rank_monthly"); + jdbcTemplate.execute("TRUNCATE TABLE mv_product_rank_staging"); + jdbcTemplate.execute("TRUNCATE TABLE product_metrics"); + jdbcTemplate.execute("TRUNCATE TABLE product"); + } + + private void seedProducts(int count) { + for (int i = 1; i <= count; i++) { + jdbcTemplate.update( + "INSERT INTO product (id, brand_id, name, price, stock_quantity, like_count, created_at, updated_at) " + + "VALUES (?, 1, ?, ?, 1000, 0, NOW(), NOW())", + i, "상품" + i, i * 1000); + } + } + + private void seedMetrics(int productCount, int days, String endDateStr) { + LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER); + for (int d = 0; d < days; d++) { + LocalDate date = endDate.minusDays(d); + for (int p = 1; p <= productCount; p++) { + jdbcTemplate.update( + "INSERT INTO product_metrics " + + "(product_id, metric_date, view_count, like_count, unlike_count, " + + "sales_count, sales_amount, cancel_count_by_event_date, cancel_amount_by_event_date, " + + "cancel_count_by_order_date, cancel_amount_by_order_date) " + + "VALUES (?, ?, ?, ?, 0, ?, ?, 0, 0, 0, 0)", + p, date, p * 100, p * 10, p * 5, p * 50000L); + } + } + } + + private BatchStatus runJob(String scope) throws Exception { + var params = new JobParametersBuilder() + .addString("targetDate", TARGET_DATE) + .addString("scope", scope) + .addLong("run.id", System.currentTimeMillis()) + .toJobParameters(); + return jobLauncherTestUtils.launchJob(params).getStatus(); + } + + // ── Bulk Seed (대규모 테스트용) ────────────────────────────────────── + + private void seedProductsBulk(int count) { + String sql = "INSERT INTO product (id, brand_id, name, price, stock_quantity, like_count, created_at, updated_at) " + + "VALUES (?, 1, ?, ?, 1000, 0, NOW(), NOW())"; + for (int batchStart = 0; batchStart < count; batchStart += SEED_BATCH_SIZE) { + int start = batchStart; + int end = Math.min(batchStart + SEED_BATCH_SIZE, count); + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + int p = start + i + 1; + ps.setLong(1, p); + ps.setString(2, "product-" + p); + ps.setInt(3, 10_000 + (p % 90_000)); + } + @Override + public int getBatchSize() { return end - start; } + }); + } + } + + /** + * 6가지 트렌드 패턴으로 메트릭 벌크 시드. + *
+     *   A) 급상승    (1~5,000  = 5%)  : 최근 7일 폭발, 이전 미미
+     *   B) 장기강자  (5,001~15,000 = 10%): 30일 꾸준히 높음
+     *   C) 하락추세  (15,001~20,000 = 5%): 이전 높음 → 최근 급락
+     *   D) 바이럴    (20,001~22,000 = 2%): 오늘만 폭발
+     *   E) 취소높음  (22,001~25,000 = 3%): 매출 높지만 취소 50~70%
+     *   F) 일반      (25,001~100,000 = 75%): 보통 수준
+     * 
+ */ + private void seedMetricsBulkWithTrends(int productCount, int days, String endDateStr) { + LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER); + String sql = "INSERT INTO product_metrics " + + "(product_id, metric_date, view_count, like_count, unlike_count, " + + "sales_count, sales_amount, cancel_count_by_event_date, cancel_amount_by_event_date, " + + "cancel_count_by_order_date, cancel_amount_by_order_date) " + + "VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)"; + + for (int d = 0; d < days; d++) { + LocalDate date = endDate.minusDays(d); + boolean isRecent = d < 7; + boolean isToday = d == 0; + + for (int batchStart = 0; batchStart < productCount; batchStart += SEED_BATCH_SIZE) { + int start = batchStart; + int end = Math.min(batchStart + SEED_BATCH_SIZE, productCount); + final LocalDate metricDate = date; + final boolean recent = isRecent; + final boolean today = isToday; + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + int p = start + i + 1; + int views, likes, salesCount; + long salesAmount; + long cancelAmount = 0; + int cancelCount = 0; + + if (p <= 5_000) { + // A) 급상승 + if (recent) { + views = 4_000 + p; + likes = 500 + p / 10; + salesAmount = 2_000_000L + p * 200L; + } else { + views = 50; + likes = 5; + salesAmount = 20_000L; + } + } else if (p <= 15_000) { + // B) 장기강자 + int pos = p - 5_000; + views = 1_000 + pos / 5; + likes = 100 + pos / 50; + salesAmount = 1_500_000L + pos * 50L; + } else if (p <= 20_000) { + // C) 하락추세 + int pos = p - 15_000; + if (recent) { + views = 100; + likes = 10; + salesAmount = 50_000L; + } else { + views = 2_000 + pos / 3; + likes = 200 + pos / 25; + salesAmount = 1_500_000L + pos * 100L; + } + } else if (p <= 22_000) { + // D) 바이럴 + if (today) { + views = 15_000; + likes = 2_000; + salesAmount = 5_000_000L; + } else { + views = 100; + likes = 10; + salesAmount = 50_000L; + } + } else if (p <= 25_000) { + // E) 취소높음 + views = 1_500; + likes = 150; + salesAmount = 2_000_000L; + int cancelRate = 50 + ((p - 22_001) % 3) * 10; + cancelAmount = salesAmount * cancelRate / 100; + cancelCount = (int) (cancelAmount / 100_000); + } else { + // F) 일반 + int pos = p - 25_000; + views = 200 + pos / 30; + likes = 20 + pos / 300; + salesAmount = 100_000L + pos * 3L; + } + + salesCount = (int) (salesAmount / 50_000) + 1; + + ps.setLong(1, p); + ps.setObject(2, metricDate); + ps.setInt(3, views); + ps.setInt(4, likes); + ps.setInt(5, salesCount); + ps.setLong(6, salesAmount); + ps.setInt(7, cancelCount); + ps.setLong(8, cancelAmount); + ps.setInt(9, cancelCount); + ps.setLong(10, cancelAmount); + } + + @Override + public int getBatchSize() { return end - start; } + }); + } + } + } + + // ── 주간 랭킹 Job ────────────────────────────────────────────────── + + @Test + @DisplayName("주간 정상 — 시드 데이터 기반 주간 TOP 100 적재") + void weeklySuccess() throws Exception { + seedProducts(150); + seedMetrics(150, 7, TARGET_DATE); + + BatchStatus status = runJob("weekly"); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + + int mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(mvCount).isEqualTo(100); + + Long topProductId = jdbcTemplate.queryForObject( + "SELECT product_id FROM mv_product_rank_weekly WHERE period_key = ? AND ranking = 1", + Long.class, TARGET_DATE); + assertThat(topProductId).isEqualTo(150L); + + int stagingCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_staging WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(stagingCount).isEqualTo(150); + } + + @Test + @DisplayName("주간 — 상품이 100개 미만이면 있는 만큼만 적재") + void weeklyLessThan100Products() throws Exception { + seedProducts(30); + seedMetrics(30, 7, TARGET_DATE); + + BatchStatus status = runJob("weekly"); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + + int mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(mvCount).isEqualTo(30); + } + + // ── 월간 랭킹 Job ────────────────────────────────────────────────── + + @Test + @DisplayName("월간 정상 — 30일 데이터 집계") + void monthlySuccess() throws Exception { + seedProducts(50); + seedMetrics(50, 30, TARGET_DATE); + + BatchStatus status = runJob("monthly"); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + + int mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_monthly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(mvCount).isEqualTo(50); + } + + // ── 멱등성 ────────────────────────────────────────────────────────── + + @Test + @DisplayName("멱등성 — 같은 파라미터로 2회 실행해도 결과 동일") + void idempotentDoubleExecution() throws Exception { + seedProducts(50); + seedMetrics(50, 7, TARGET_DATE); + + runJob("weekly"); + BatchStatus secondStatus = runJob("weekly"); + + assertThat(secondStatus).isEqualTo(BatchStatus.COMPLETED); + + int mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(mvCount).isEqualTo(50); + } + + // ── 엣지 케이스 ───────────────────────────────────────────────────── + + @Test + @DisplayName("엣지 — 데이터 없는 날짜로 실행하면 빈 MV") + void noDataProducesEmptyMv() throws Exception { + seedProducts(10); + + BatchStatus status = runJob("weekly"); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + + int mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(mvCount).isEqualTo(0); + } + + @Test + @DisplayName("엣지 — 7일 미만 데이터면 있는 만큼만 집계") + void partialDataAggregated() throws Exception { + seedProducts(20); + seedMetrics(20, 3, TARGET_DATE); + + BatchStatus status = runJob("weekly"); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + + int mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(mvCount).isEqualTo(20); + } + + @Test + @DisplayName("시각화 — 일간/주간/월간 TOP 랭킹 결과 출력") + void printRankingResults() throws Exception { + // 20개 상품 시드 + String[] names = { + "나이키 에어맥스 97", "아디다스 울트라부스트", "뉴발란스 993", "아식스 젤카야노", + "푸마 스웨이드", "리복 클래식", "컨버스 척테일러", "반스 올드스쿨", + "호카 본디 8", "살로몬 XT-6", "노스페이스 눕시", "파타고니아 다운재킷", + "아크테릭스 베타 LT", "스톤아일랜드 오버셔츠", "메종키츠네 폭스티", + "아미 하트로고 맨투맨", "톰브라운 카디건", "르메르 크로와상 백", + "메종마르지엘라 타비슈즈", "보테가베네타 카세트백" + }; + for (int i = 1; i <= 20; i++) { + jdbcTemplate.update( + "INSERT INTO product (id, brand_id, name, price, stock_quantity, like_count, created_at, updated_at) " + + "VALUES (?, 1, ?, ?, 1000, 0, NOW(), NOW())", + i, names[i - 1], i * 15000); + } + + // ── 30일치 메트릭 시드 (상품별 트렌드가 다르게) ── + // 상품 유형: + // A) 최근 급상승 (상품 1,2,3): 최근 7일 폭발, 이전 23일은 미미 + // B) 장기 강자 (상품 17,18,19,20): 30일 내내 꾸준히 높음 + // C) 하락 추세 (상품 14,15): 이전 23일은 높았으나 최근 7일 급락 + // D) 오늘 바이럴 (상품 8): 오늘 하루만 폭발 + // E) 일반 (나머지): 보통 수준으로 꾸준 + + LocalDate endDate = LocalDate.parse(TARGET_DATE, DATE_FORMATTER); + for (int d = 0; d < 30; d++) { + LocalDate date = endDate.minusDays(d); + boolean isRecent = d < 7; // 최근 7일 + boolean isToday = d == 0; // 오늘 + + for (int p = 1; p <= 20; p++) { + int views; int likes; long salesAmount; int salesCount; + long cancelAmount = 0; int cancelCount = 0; + + if (p <= 3) { + // A) 최근 급상승: 최근 7일은 매우 높고 이전 23일은 낮음 + if (isRecent) { + views = 5000 + p * 500; likes = 600 + p * 80; + salesAmount = 3000000L + p * 500000L; + } else { + views = 100 + p * 10; likes = 10 + p; + salesAmount = 50000L + p * 10000L; + } + } else if (p >= 17) { + // B) 장기 강자: 30일 내내 꾸준히 높음 + views = 1200 + (p - 16) * 300; likes = 150 + (p - 16) * 40; + salesAmount = 1800000L + (p - 16) * 400000L; + // 상품 19: 취소율 50% + if (p == 19) { cancelAmount = salesAmount / 2; cancelCount = 3; } + } else if (p == 14 || p == 15) { + // C) 하락 추세: 이전에는 높았으나 최근 급락 + if (isRecent) { + views = 200 + (p - 13) * 50; likes = 20 + (p - 13) * 5; + salesAmount = 100000L + (p - 13) * 30000L; + } else { + views = 3000 + (p - 13) * 800; likes = 400 + (p - 13) * 100; + salesAmount = 2500000L + (p - 13) * 600000L; + } + } else if (p == 8) { + // D) 오늘 바이럴: 오늘만 폭발 + if (isToday) { + views = 15000; likes = 2000; salesAmount = 5000000L; + } else { + views = 200; likes = 20; salesAmount = 80000L; + } + } else { + // E) 일반: 보통 수준 + views = 300 + p * 40; likes = 30 + p * 5; + salesAmount = 200000L + p * 80000L; + } + + salesCount = (int) (salesAmount / 50000) + 1; + jdbcTemplate.update( + "INSERT INTO product_metrics " + + "(product_id, metric_date, view_count, like_count, unlike_count, " + + "sales_count, sales_amount, cancel_count_by_event_date, cancel_amount_by_event_date, " + + "cancel_count_by_order_date, cancel_amount_by_order_date) " + + "VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)", + p, date, views, likes, salesCount, salesAmount, cancelCount, cancelAmount, cancelCount, cancelAmount); + } + } + + // ── Job 실행 ── + BatchStatus weeklyStatus = runJob("weekly"); + assertThat(weeklyStatus).isEqualTo(BatchStatus.COMPLETED); + BatchStatus monthlyStatus = runJob("monthly"); + assertThat(monthlyStatus).isEqualTo(BatchStatus.COMPLETED); + + // ── 공통 출력 헬퍼 ── + String header = String.format(" %-4s │ %-6s │ %-26s │ %10s │ %8s │ %8s │ %12s │ %8s", + "순위", "상품ID", "상품명", "Score", "조회수", "좋아요", "순매출액", "판매수"); + String divider = "───────┼────────┼────────────────────────────┼────────────┼──────────┼──────────┼──────────────┼──────────"; + String border = "═══════════════════════════════════════════════════════════════════════════════════════════════════════"; + + // ── 일간 랭킹 ── + System.out.println("\n" + border); + System.out.println(" [일간 랭킹 TOP 20] date=2026-04-16 (당일 1일 집계 — 운영 시 Redis Speed Layer)"); + System.out.println(border); + System.out.println(header); + System.out.println(divider); + + var dailyRows = jdbcTemplate.queryForList(""" + SELECT + ROW_NUMBER() OVER (ORDER BY + (0.1 * LOG10(GREATEST(pm.view_count, 0) + 1) / 7.0 + + 0.2 * LOG10(GREATEST(pm.like_count - pm.unlike_count, 0) + 1) / 7.0 + + 0.7 * LOG10(GREATEST(pm.sales_amount - pm.cancel_amount_by_event_date, 0) + 1) / 7.0) + DESC) AS ranking, + pm.product_id, p.name, + (0.1 * LOG10(GREATEST(pm.view_count, 0) + 1) / 7.0 + + 0.2 * LOG10(GREATEST(pm.like_count - pm.unlike_count, 0) + 1) / 7.0 + + 0.7 * LOG10(GREATEST(pm.sales_amount - pm.cancel_amount_by_event_date, 0) + 1) / 7.0) AS score, + pm.view_count, (pm.like_count - pm.unlike_count) AS like_count, + (pm.sales_amount - pm.cancel_amount_by_event_date) AS sales_amount, pm.sales_count + FROM product_metrics pm JOIN product p ON pm.product_id = p.id + WHERE pm.metric_date = '2026-04-16' + ORDER BY score DESC LIMIT 20 + """); + for (var row : dailyRows) { + System.out.printf(" %4d │ %6d │ %-26s │ %10.4f │ %,8d │ %,8d │ %,12d │ %,8d%n", + row.get("ranking"), row.get("product_id"), row.get("name"), + ((Number) row.get("score")).doubleValue(), ((Number) row.get("view_count")).longValue(), + ((Number) row.get("like_count")).longValue(), ((Number) row.get("sales_amount")).longValue(), + ((Number) row.get("sales_count")).longValue()); + } + System.out.println(border); + + // ── 주간 랭킹 ── + System.out.println("\n" + border); + System.out.println(" [주간 랭킹 TOP 20] period_key=" + TARGET_DATE + " (최근 7일 집계)"); + System.out.println(border); + System.out.println(header); + System.out.println(divider); + var weeklyRows = jdbcTemplate.queryForList( + "SELECT w.ranking, w.product_id, p.name, w.score, w.view_count, w.like_count, w.sales_amount, w.sales_count " + + "FROM mv_product_rank_weekly w JOIN product p ON w.product_id = p.id " + + "WHERE w.period_key = ? ORDER BY w.ranking", TARGET_DATE); + for (var row : weeklyRows) { + System.out.printf(" %4d │ %6d │ %-26s │ %10.4f │ %,8d │ %,8d │ %,12d │ %,8d%n", + row.get("ranking"), row.get("product_id"), row.get("name"), + ((Number) row.get("score")).doubleValue(), ((Number) row.get("view_count")).longValue(), + ((Number) row.get("like_count")).longValue(), ((Number) row.get("sales_amount")).longValue(), + ((Number) row.get("sales_count")).longValue()); + } + System.out.println(border); + + // ── 월간 랭킹 ── + System.out.println("\n" + border); + System.out.println(" [월간 랭킹 TOP 20] period_key=" + TARGET_DATE + " (최근 30일 집계)"); + System.out.println(border); + System.out.println(header); + System.out.println(divider); + var monthlyRows = jdbcTemplate.queryForList( + "SELECT m.ranking, m.product_id, p.name, m.score, m.view_count, m.like_count, m.sales_amount, m.sales_count " + + "FROM mv_product_rank_monthly m JOIN product p ON m.product_id = p.id " + + "WHERE m.period_key = ? ORDER BY m.ranking", TARGET_DATE); + for (var row : monthlyRows) { + System.out.printf(" %4d │ %6d │ %-26s │ %10.4f │ %,8d │ %,8d │ %,12d │ %,8d%n", + row.get("ranking"), row.get("product_id"), row.get("name"), + ((Number) row.get("score")).doubleValue(), ((Number) row.get("view_count")).longValue(), + ((Number) row.get("like_count")).longValue(), ((Number) row.get("sales_amount")).longValue(), + ((Number) row.get("sales_count")).longValue()); + } + System.out.println(border); + + // ── 일간 vs 주간 vs 월간 순위 비교 ── + System.out.println(); + System.out.println(" [순위 비교] 일간 vs 주간 vs 월간 — 집계 기간에 따른 순위 변동"); + System.out.printf(" %-6s │ %-26s │ %5s │ %5s │ %5s │ %-8s │ %s%n", + "상품ID", "상품명", "일간", "주간", "월간", "주간변동", "유형"); + System.out.println("─────────┼────────────────────────────┼───────┼───────┼───────┼──────────┼──────────────"); + + var compareRows = jdbcTemplate.queryForList(""" + SELECT d.product_id, p.name, d.ranking AS daily_rank, + COALESCE(w.ranking, 0) AS weekly_rank, + COALESCE(mo.ranking, 0) AS monthly_rank + FROM ( + SELECT product_id, + ROW_NUMBER() OVER (ORDER BY + (0.1 * LOG10(GREATEST(view_count, 0) + 1) / 7.0 + + 0.2 * LOG10(GREATEST(like_count - unlike_count, 0) + 1) / 7.0 + + 0.7 * LOG10(GREATEST(sales_amount - cancel_amount_by_event_date, 0) + 1) / 7.0) + DESC) AS ranking + FROM product_metrics WHERE metric_date = '2026-04-16' + ) d + JOIN product p ON d.product_id = p.id + LEFT JOIN mv_product_rank_weekly w ON d.product_id = w.product_id AND w.period_key = ? + LEFT JOIN mv_product_rank_monthly mo ON d.product_id = mo.product_id AND mo.period_key = ? + ORDER BY d.ranking + """, TARGET_DATE, TARGET_DATE); + + for (var row : compareRows) { + int daily = ((Number) row.get("daily_rank")).intValue(); + int weekly = ((Number) row.get("weekly_rank")).intValue(); + int monthly = ((Number) row.get("monthly_rank")).intValue(); + int wDiff = daily - weekly; + String wArrow = weekly == 0 ? " —" : wDiff == 0 ? " —" + : wDiff < 0 ? String.format(" +%d ▲", -wDiff) : String.format(" -%d ▼", wDiff); + + String type = ""; + int pid = ((Number) row.get("product_id")).intValue(); + if (pid <= 3) type = "급상승"; + else if (pid >= 17) type = "장기강자"; + else if (pid == 14 || pid == 15) type = "하락추세"; + else if (pid == 8) type = "오늘바이럴"; + + System.out.printf(" %6d │ %-26s │ %4d │ %4d │ %4d │ %8s │ %s%n", + row.get("product_id"), row.get("name"), daily, weekly, monthly, wArrow, type); + } + System.out.println(); + } + + // ── 대규모 성능 테스트 ────────────────────────────────────────────── + + @Test + @DisplayName("대규모 — 10만 상품 × 30일 메트릭, 4 Partition 병렬 집계") + void largeScalePartitionedBatchTest() throws Exception { + int productCount = 100_000; + int metricDays = 30; + + // ── 시드 ── + long t0 = System.currentTimeMillis(); + seedProductsBulk(productCount); + long productSeedMs = System.currentTimeMillis() - t0; + + t0 = System.currentTimeMillis(); + seedMetricsBulkWithTrends(productCount, metricDays, TARGET_DATE); + long metricSeedMs = System.currentTimeMillis() - t0; + + int metricRows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM product_metrics", Integer.class); + System.out.printf("%n[시드 완료] 상품 %,d건 (%,dms) / 메트릭 %,d건 (%,dms)%n", + productCount, productSeedMs, metricRows, metricSeedMs); + + // ── Weekly ── + t0 = System.currentTimeMillis(); + BatchStatus weeklyStatus = runJob("weekly"); + long weeklyMs = System.currentTimeMillis() - t0; + + assertThat(weeklyStatus).isEqualTo(BatchStatus.COMPLETED); + + int weeklyMvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(weeklyMvCount).isEqualTo(100); + + Long weeklyTopId = jdbcTemplate.queryForObject( + "SELECT product_id FROM mv_product_rank_weekly WHERE period_key = ? AND ranking = 1", + Long.class, TARGET_DATE); + // 급상승 그룹(1~5000) 중 p=5000이 최고 메트릭 + assertThat(weeklyTopId).isEqualTo(5_000L); + + // ── Monthly ── + t0 = System.currentTimeMillis(); + BatchStatus monthlyStatus = runJob("monthly"); + long monthlyMs = System.currentTimeMillis() - t0; + + assertThat(monthlyStatus).isEqualTo(BatchStatus.COMPLETED); + + int monthlyMvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_monthly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(monthlyMvCount).isEqualTo(100); + + Long monthlyTopId = jdbcTemplate.queryForObject( + "SELECT product_id FROM mv_product_rank_monthly WHERE period_key = ? AND ranking = 1", + Long.class, TARGET_DATE); + // 장기강자 그룹(5001~15000) 중 p=15000이 최고 메트릭 + assertThat(monthlyTopId).isEqualTo(15_000L); + + // ── 파티션 균등 분배 검증 (monthly 실행 후 staging 기준) ── + int stagingTotal = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_staging WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(stagingTotal).isEqualTo(productCount); + + // ── 결과 출력 ── + System.out.println(); + System.out.println("═══════════════════════════════════════════════════"); + System.out.println(" 대규모 배치 테스트 결과 (10만 건)"); + System.out.println("═══════════════════════════════════════════════════"); + System.out.printf(" 상품 수 : %,d%n", productCount); + System.out.printf(" 메트릭 행 수 : %,d%n", metricRows); + System.out.printf(" Partitioning : %d Worker%n", 4); + System.out.printf(" Weekly 소요 : %,dms (1위: product_%d, 급상승)%n", weeklyMs, weeklyTopId); + System.out.printf(" Monthly 소요 : %,dms (1위: product_%d, 장기강자)%n", monthlyMs, monthlyTopId); + System.out.printf(" Staging 적재 : %,d건 (~%,d건/partition)%n", stagingTotal, stagingTotal / 4); + System.out.println("═══════════════════════════════════════════════════"); + } + + @Test + @DisplayName("벤치마크 — gridSize=1 vs gridSize=4 소요 시간 비교") + void partitionBenchmark() throws Exception { + int productCount = 100_000; + int metricDays = 30; + + long t0 = System.currentTimeMillis(); + seedProductsBulk(productCount); + seedMetricsBulkWithTrends(productCount, metricDays, TARGET_DATE); + long seedMs = System.currentTimeMillis() - t0; + + int metricRows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM product_metrics", Integer.class); + System.out.printf("%n[시드 완료] 상품 %,d건, 메트릭 %,d건 (%,dms)%n", productCount, metricRows, seedMs); + + // ── gridSize=1 (단일 스레드) ── + ReflectionTestUtils.setField(jobConfig, "gridSize", 1); + + t0 = System.currentTimeMillis(); + BatchStatus singleStatus = runJob("weekly"); + long singleMs = System.currentTimeMillis() - t0; + assertThat(singleStatus).isEqualTo(BatchStatus.COMPLETED); + + int singleMvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(singleMvCount).isEqualTo(100); + + // ── 중간 정리 ── + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly WHERE period_key = ?", TARGET_DATE); + jdbcTemplate.update("DELETE FROM mv_product_rank_staging WHERE period_key = ?", TARGET_DATE); + + // ── gridSize=4 (4 Partition 병렬) ── + ReflectionTestUtils.setField(jobConfig, "gridSize", 4); + + t0 = System.currentTimeMillis(); + BatchStatus partitionedStatus = runJob("weekly"); + long partitionedMs = System.currentTimeMillis() - t0; + assertThat(partitionedStatus).isEqualTo(BatchStatus.COMPLETED); + + int partitionedMvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key = ?", + Integer.class, TARGET_DATE); + assertThat(partitionedMvCount).isEqualTo(100); + + double speedup = (double) singleMs / partitionedMs; + + System.out.println(); + System.out.println("═══════════════════════════════════════"); + System.out.println(" Partitioning 벤치마크 (10만 상품)"); + System.out.println("═══════════════════════════════════════"); + System.out.printf(" gridSize=1: %,dms%n", singleMs); + System.out.printf(" gridSize=4: %,dms%n", partitionedMs); + System.out.printf(" 향상률: %.1fx%n", speedup); + System.out.println("═══════════════════════════════════════"); + } + + // ── 엣지 케이스 ───────────────────────────────────────────────────── + + @Test + @DisplayName("엣지 — 취소 반영: cancel_amount가 score에 반영") + void cancellationReflectedInScore() throws Exception { + seedProducts(2); + + // 상품 1: 매출 100만, 취소 없음 + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, metric_date, view_count, like_count, unlike_count, " + + "sales_count, sales_amount, cancel_count_by_event_date, cancel_amount_by_event_date, " + + "cancel_count_by_order_date, cancel_amount_by_order_date) VALUES (1, '2026-04-16', 100, 10, 0, 10, 1000000, 0, 0, 0, 0)"); + + // 상품 2: 매출 200만, 취소 150만 → 순 매출 50만 + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, metric_date, view_count, like_count, unlike_count, " + + "sales_count, sales_amount, cancel_count_by_event_date, cancel_amount_by_event_date, " + + "cancel_count_by_order_date, cancel_amount_by_order_date) VALUES (2, '2026-04-16', 200, 20, 0, 20, 2000000, 5, 1500000, 5, 1500000)"); + + BatchStatus status = runJob("weekly"); + + assertThat(status).isEqualTo(BatchStatus.COMPLETED); + + // 상품 1이 1위 (순 매출 100만 > 상품 2 순 매출 50만) + Long topProductId = jdbcTemplate.queryForObject( + "SELECT product_id FROM mv_product_rank_weekly WHERE period_key = ? AND ranking = 1", + Long.class, TARGET_DATE); + assertThat(topProductId).isEqualTo(1L); + } +} diff --git a/apps/commerce-batch/src/test/resources/schema-batch-test.sql b/apps/commerce-batch/src/test/resources/schema-batch-test.sql index 4b83fd751b..9dc38a02f4 100644 --- a/apps/commerce-batch/src/test/resources/schema-batch-test.sql +++ b/apps/commerce-batch/src/test/resources/schema-batch-test.sql @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS brand ( CREATE TABLE IF NOT EXISTS product ( id BIGINT AUTO_INCREMENT PRIMARY KEY, brand_id BIGINT NOT NULL, + category_id BIGINT, name VARCHAR(255) NOT NULL, price INT NOT NULL, stock_quantity INT NOT NULL, @@ -98,3 +99,58 @@ CREATE TABLE IF NOT EXISTS reconciliation_mismatch ( updated_at DATETIME(6), note TEXT ); + +CREATE TABLE IF NOT EXISTS product_metrics ( + product_id BIGINT NOT NULL, + metric_date DATE NOT NULL, + view_count INT NOT NULL DEFAULT 0, + like_count INT NOT NULL DEFAULT 0, + unlike_count INT NOT NULL DEFAULT 0, + sales_count INT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + cancel_count_by_event_date INT NOT NULL DEFAULT 0, + cancel_amount_by_event_date BIGINT NOT NULL DEFAULT 0, + cancel_count_by_order_date INT NOT NULL DEFAULT 0, + cancel_amount_by_order_date BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (product_id, metric_date), + INDEX idx_metric_date (metric_date) +); + +CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + ranking INT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + period_key VARCHAR(8) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_period_ranking (period_key, ranking) +); + +CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + ranking INT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + period_key VARCHAR(8) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_period_ranking (period_key, ranking) +); + +CREATE TABLE IF NOT EXISTS mv_product_rank_staging ( + product_id BIGINT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + period_key VARCHAR(8) NOT NULL, + PRIMARY KEY (product_id, period_key) +); diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a9..e3a14cd100 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -82,7 +82,7 @@ subprojects { useJUnitPlatform() systemProperty("user.timezone", "Asia/Seoul") systemProperty("spring.profiles.active", "test") - jvmArgs("-Xshare:off") + jvmArgs("-Xshare:off", "-Xmx2g") } tasks.withType { diff --git a/docs/captures/01-event-flow.png b/docs/captures/01-event-flow.png new file mode 100644 index 0000000000..1a2359dd43 Binary files /dev/null and b/docs/captures/01-event-flow.png differ diff --git a/docs/captures/02-drift-initial.png b/docs/captures/02-drift-initial.png new file mode 100644 index 0000000000..345799248b Binary files /dev/null and b/docs/captures/02-drift-initial.png differ diff --git a/docs/captures/03-ranking-mv-test-output.md b/docs/captures/03-ranking-mv-test-output.md new file mode 100644 index 0000000000..9194715eff --- /dev/null +++ b/docs/captures/03-ranking-mv-test-output.md @@ -0,0 +1,190 @@ +# 랭킹 시스템 테스트 결과 캡처 (일간 / 주간 / 월간) + +> 실행일: 2026-04-17 | DB: MySQL 8.0 (Testcontainers) | targetDate: 20260416 + +## 테스트 데이터 설계 + +- 상품 20개 (패션 브랜드), 30일치 일별 메트릭 시드 +- **상품별 트렌드를 다르게** 설정하여 기간별 순위 차이를 의도적으로 발생시킴 +- Score = `0.1*LOG10(view+1)/7 + 0.2*LOG10(like+1)/7 + 0.7*LOG10(net_sales+1)/7` + +| 유형 | 상품 | 특성 | +|------|------|------| +| **급상승** | 1 (나이키), 2 (아디다스), 3 (뉴발란스) | 최근 7일 폭발, 이전 23일은 미미 | +| **장기 강자** | 17 (톰브라운), 18 (르메르), 19 (마르지엘라), 20 (보테가) | 30일 내내 꾸준히 높음 | +| **하락 추세** | 14 (스톤아일랜드), 15 (메종키츠네) | 이전 23일 높았으나 최근 7일 급락 | +| **오늘 바이럴** | 8 (반스 올드스쿨) | 오늘 하루만 조회 15,000 + 매출 500만 폭발 | +| **일반** | 나머지 | 보통 수준으로 꾸준 | + +추가 조건: 상품 19 (메종마르지엘라) — 매일 취소율 50% 적용 + +--- + +## 일간 랭킹 TOP 20 + +> 운영 환경에서는 Redis Speed Layer (ZREVRANGE)로 서빙. 동일 Score 수식을 1일치 `product_metrics`에 적용하여 시뮬레이션. + +``` +═══════════════════════════════════════════════════════════════════════════════════════════════════════ + [일간 랭킹 TOP 20] date=2026-04-16 (당일 1일 집계 — 운영 시 Redis Speed Layer) +═══════════════════════════════════════════════════════════════════════════════════════════════════════ + 순위 │ 상품ID │ 상품명 │ Score │ 조회수 │ 좋아요 │ 순매출액 │ 판매수 +───────┼────────┼────────────────────────────┼────────────┼──────────┼──────────┼──────────────┼────────── + 1 │ 8 │ 반스 올드스쿨 │ 0.8239 │ 15,000 │ 2,000 │ 5,000,000 │ 101 + 2 │ 3 │ 뉴발란스 993 │ 0.8034 │ 6,500 │ 840 │ 4,500,000 │ 91 + 3 │ 2 │ 아디다스 울트라부스트 │ 0.7965 │ 6,000 │ 760 │ 4,000,000 │ 81 + 4 │ 1 │ 나이키 에어맥스 97 │ 0.7888 │ 5,500 │ 680 │ 3,500,000 │ 71 + 5 │ 20 │ 보테가베네타 카세트백 │ 0.7727 │ 2,400 │ 310 │ 3,400,000 │ 69 + 6 │ 18 │ 르메르 크로와상 백 │ 0.7555 │ 1,800 │ 230 │ 2,600,000 │ 53 + 7 │ 17 │ 톰브라운 카디건 │ 0.7448 │ 1,500 │ 190 │ 2,200,000 │ 45 + 8 │ 19 │ 메종마르지엘라 타비슈즈 │ 0.7346 │ 2,100 │ 270 │ 1,500,000 │ 61 + 9 │ 16 │ 아미 하트로고 맨투맨 │ 0.7179 │ 940 │ 110 │ 1,480,000 │ 30 + 10 │ 13 │ 아크테릭스 베타 LT │ 0.7076 │ 820 │ 95 │ 1,240,000 │ 25 + 11 │ 12 │ 파타고니아 다운재킷 │ 0.7037 │ 780 │ 90 │ 1,160,000 │ 24 + 12 │ 11 │ 노스페이스 눕시 │ 0.6996 │ 740 │ 85 │ 1,080,000 │ 22 + 13 │ 10 │ 살로몬 XT-6 │ 0.6952 │ 700 │ 80 │ 1,000,000 │ 21 + 14 │ 9 │ 호카 본디 8 │ 0.6904 │ 660 │ 75 │ 920,000 │ 19 + 15 │ 7 │ 컨버스 척테일러 │ 0.6796 │ 580 │ 65 │ 760,000 │ 16 + 16 │ 6 │ 리복 클래식 │ 0.6733 │ 540 │ 60 │ 680,000 │ 14 + 17 │ 5 │ 푸마 스웨이드 │ 0.6663 │ 500 │ 55 │ 600,000 │ 13 + 18 │ 4 │ 아식스 젤카야노 │ 0.6584 │ 460 │ 50 │ 520,000 │ 11 + 19 │ 15 │ 메종키츠네 폭스티 │ 0.5984 │ 300 │ 30 │ 160,000 │ 4 + 20 │ 14 │ 스톤아일랜드 오버셔츠 │ 0.5861 │ 250 │ 25 │ 130,000 │ 3 +═══════════════════════════════════════════════════════════════════════════════════════════════════════ +``` + +--- + +## 주간 랭킹 TOP 20 + +``` +═══════════════════════════════════════════════════════════════════════════════════════════════════════ + [주간 랭킹 TOP 20] period_key=20260416 (최근 7일 집계) +═══════════════════════════════════════════════════════════════════════════════════════════════════════ + 순위 │ 상품ID │ 상품명 │ Score │ 조회수 │ 좋아요 │ 순매출액 │ 판매수 +───────┼────────┼────────────────────────────┼────────────┼──────────┼──────────┼──────────────┼────────── + 1 │ 3 │ 뉴발란스 993 │ 0.9241 │ 45,500 │ 5,880 │ 31,500,000 │ 637 + 2 │ 2 │ 아디다스 울트라부스트 │ 0.9172 │ 42,000 │ 5,320 │ 28,000,000 │ 567 + 3 │ 1 │ 나이키 에어맥스 97 │ 0.9095 │ 38,500 │ 4,760 │ 24,500,000 │ 497 + 4 │ 20 │ 보테가베네타 카세트백 │ 0.8934 │ 16,800 │ 2,170 │ 23,800,000 │ 483 + 5 │ 18 │ 르메르 크로와상 백 │ 0.8762 │ 12,600 │ 1,610 │ 18,200,000 │ 371 + 6 │ 17 │ 톰브라운 카디건 │ 0.8655 │ 10,500 │ 1,330 │ 15,400,000 │ 315 + 7 │ 19 │ 메종마르지엘라 타비슈즈 │ 0.8553 │ 14,700 │ 1,890 │ 10,500,000 │ 427 + 8 │ 16 │ 아미 하트로고 맨투맨 │ 0.8386 │ 6,580 │ 770 │ 10,360,000 │ 210 + 9 │ 8 │ 반스 올드스쿨 │ 0.8291 │ 16,200 │ 2,120 │ 5,480,000 │ 113 + 10 │ 13 │ 아크테릭스 베타 LT │ 0.8282 │ 5,740 │ 665 │ 8,680,000 │ 175 + 11 │ 12 │ 파타고니아 다운재킷 │ 0.8243 │ 5,460 │ 630 │ 8,120,000 │ 168 + 12 │ 11 │ 노스페이스 눕시 │ 0.8202 │ 5,180 │ 595 │ 7,560,000 │ 154 + 13 │ 10 │ 살로몬 XT-6 │ 0.8158 │ 4,900 │ 560 │ 7,000,000 │ 147 + 14 │ 9 │ 호카 본디 8 │ 0.8110 │ 4,620 │ 525 │ 6,440,000 │ 133 + 15 │ 7 │ 컨버스 척테일러 │ 0.8001 │ 4,060 │ 455 │ 5,320,000 │ 112 + 16 │ 6 │ 리복 클래식 │ 0.7938 │ 3,780 │ 420 │ 4,760,000 │ 98 + 17 │ 5 │ 푸마 스웨이드 │ 0.7869 │ 3,500 │ 385 │ 4,200,000 │ 91 + 18 │ 4 │ 아식스 젤카야노 │ 0.7789 │ 3,220 │ 350 │ 3,640,000 │ 77 + 19 │ 15 │ 메종키츠네 폭스티 │ 0.7188 │ 2,100 │ 210 │ 1,120,000 │ 28 + 20 │ 14 │ 스톤아일랜드 오버셔츠 │ 0.7064 │ 1,750 │ 175 │ 910,000 │ 21 +═══════════════════════════════════════════════════════════════════════════════════════════════════════ +``` + +--- + +## 월간 랭킹 TOP 20 + +``` +═══════════════════════════════════════════════════════════════════════════════════════════════════════ + [월간 랭킹 TOP 20] period_key=20260416 (최근 30일 집계) +═══════════════════════════════════════════════════════════════════════════════════════════════════════ + 순위 │ 상품ID │ 상품명 │ Score │ 조회수 │ 좋아요 │ 순매출액 │ 판매수 +───────┼────────┼────────────────────────────┼────────────┼──────────┼──────────┼──────────────┼────────── + 1 │ 15 │ 메종키츠네 폭스티 │ 0.9839 │ 107,900 │ 14,010 │ 86,220,000 │ 1,753 + 2 │ 20 │ 보테가베네타 카세트백 │ 0.9836 │ 72,000 │ 9,300 │ 102,000,000 │ 2,070 + 3 │ 14 │ 스톤아일랜드 오버셔츠 │ 0.9728 │ 89,150 │ 11,675 │ 72,210,000 │ 1,470 + 4 │ 18 │ 르메르 크로와상 백 │ 0.9665 │ 54,000 │ 6,900 │ 78,000,000 │ 1,590 + 5 │ 17 │ 톰브라운 카디건 │ 0.9557 │ 45,000 │ 5,700 │ 66,000,000 │ 1,350 + 6 │ 19 │ 메종마르지엘라 타비슈즈 │ 0.9456 │ 63,000 │ 8,100 │ 45,000,000 │ 1,830 + 7 │ 16 │ 아미 하트로고 맨투맨 │ 0.9288 │ 28,200 │ 3,300 │ 44,400,000 │ 900 + 8 │ 3 │ 뉴발란스 993 │ 0.9275 │ 48,490 │ 6,179 │ 33,340,000 │ 683 + 9 │ 2 │ 아디다스 울트라부스트 │ 0.9207 │ 44,760 │ 5,596 │ 29,610,000 │ 613 + 10 │ 13 │ 아크테릭스 베타 LT │ 0.9185 │ 24,600 │ 2,850 │ 37,200,000 │ 750 + 11 │ 12 │ 파타고니아 다운재킷 │ 0.9146 │ 23,400 │ 2,700 │ 34,800,000 │ 720 + 12 │ 1 │ 나이키 에어맥스 97 │ 0.9129 │ 41,030 │ 5,013 │ 25,880,000 │ 543 + 13 │ 11 │ 노스페이스 눕시 │ 0.9105 │ 22,200 │ 2,550 │ 32,400,000 │ 660 + 14 │ 10 │ 살로몬 XT-6 │ 0.9060 │ 21,000 │ 2,400 │ 30,000,000 │ 630 + 15 │ 9 │ 호카 본디 8 │ 0.9013 │ 19,800 │ 2,250 │ 27,600,000 │ 570 + 16 │ 7 │ 컨버스 척테일러 │ 0.8904 │ 17,400 │ 1,950 │ 22,800,000 │ 480 + 17 │ 6 │ 리복 클래식 │ 0.8841 │ 16,200 │ 1,800 │ 20,400,000 │ 420 + 18 │ 5 │ 푸마 스웨이드 │ 0.8771 │ 15,000 │ 1,650 │ 18,000,000 │ 390 + 19 │ 4 │ 아식스 젤카야노 │ 0.8692 │ 13,800 │ 1,500 │ 15,600,000 │ 330 + 20 │ 8 │ 반스 올드스쿨 │ 0.8456 │ 20,800 │ 2,580 │ 7,320,000 │ 159 +═══════════════════════════════════════════════════════════════════════════════════════════════════════ +``` + +--- + +## 일간 vs 주간 vs 월간 순위 비교 + +``` + [순위 비교] 일간 vs 주간 vs 월간 — 집계 기간에 따른 순위 변동 + 상품ID │ 상품명 │ 일간 │ 주간 │ 월간 │ 주간변동 │ 유형 +─────────┼────────────────────────────┼───────┼───────┼───────┼──────────┼────────────── + 8 │ 반스 올드스쿨 │ 1 │ 9 │ 20 │ +8 ▲ │ 오늘바이럴 + 3 │ 뉴발란스 993 │ 2 │ 1 │ 8 │ -1 ▼ │ 급상승 + 2 │ 아디다스 울트라부스트 │ 3 │ 2 │ 9 │ -1 ▼ │ 급상승 + 1 │ 나이키 에어맥스 97 │ 4 │ 3 │ 12 │ -1 ▼ │ 급상승 + 20 │ 보테가베네타 카세트백 │ 5 │ 4 │ 2 │ -1 ▼ │ 장기강자 + 18 │ 르메르 크로와상 백 │ 6 │ 5 │ 4 │ -1 ▼ │ 장기강자 + 17 │ 톰브라운 카디건 │ 7 │ 6 │ 5 │ -1 ▼ │ 장기강자 + 19 │ 메종마르지엘라 타비슈즈 │ 8 │ 7 │ 6 │ -1 ▼ │ 장기강자 + 16 │ 아미 하트로고 맨투맨 │ 9 │ 8 │ 7 │ -1 ▼ │ + 13 │ 아크테릭스 베타 LT │ 10 │ 10 │ 10 │ — │ + 12 │ 파타고니아 다운재킷 │ 11 │ 11 │ 11 │ — │ + 11 │ 노스페이스 눕시 │ 12 │ 12 │ 13 │ — │ + 10 │ 살로몬 XT-6 │ 13 │ 13 │ 14 │ — │ + 9 │ 호카 본디 8 │ 14 │ 14 │ 15 │ — │ + 7 │ 컨버스 척테일러 │ 15 │ 15 │ 16 │ — │ + 6 │ 리복 클래식 │ 16 │ 16 │ 17 │ — │ + 5 │ 푸마 스웨이드 │ 17 │ 17 │ 18 │ — │ + 4 │ 아식스 젤카야노 │ 18 │ 18 │ 19 │ — │ + 15 │ 메종키츠네 폭스티 │ 19 │ 19 │ 1 │ — │ 하락추세 + 14 │ 스톤아일랜드 오버셔츠 │ 20 │ 20 │ 3 │ — │ 하락추세 +``` + +--- + +## 해석 + +### 오늘 바이럴 — 반스 올드스쿨 (상품 8) +- **일간 1위** → 주간 9위 → 월간 20위 +- 오늘 하루 조회 15,000 + 매출 500만으로 일간 압도적 1위 +- 그러나 나머지 29일은 조회 200, 매출 8만 수준 → 기간이 길어질수록 희석되어 순위 급락 + +### 급상승 — 뉴발란스, 아디다스, 나이키 (상품 1~3) +- 일간 2~4위 → **주간 1~3위** → 월간 8~12위 +- 최근 7일 폭발적 (일 매출 350~450만) → 주간에서 정점 +- 이전 23일은 미미 (일 매출 5~7만) → 30일 누적에서는 장기 강자에 밀림 + +### 장기 강자 — 보테가, 르메르, 톰브라운 (상품 17~20) +- 일간 5~8위 → 주간 4~7위 → **월간 2~6위** +- 30일 꾸준한 매출 (보테가: 일 340만 × 30일 = 1억 200만) → 월간에서 상위 독점 +- 특히 메종마르지엘라(19)는 취소 50%에도 30일 누적 조회 63,000 + 순매출 4,500만으로 월간 6위 유지 + +### 하락 추세 — 메종키츠네, 스톤아일랜드 (상품 14~15) +- **월간 1위, 3위** → 주간 19~20위 → 일간 19~20위 +- 이전 23일간 높은 매출 (메종키츠네: 일 조회 4,600 + 매출 370만) → 월간 누적으로 1위 +- 최근 7일 급락 (일 조회 300, 매출 16만) → 주간/일간에서는 꼴찌 +- **"과거의 영광"이 월간에는 남지만 주간/일간에서는 즉시 반영** + +### 취소 영향 — 메종마르지엘라 (상품 19) +- 매일 취소율 50% 적용 → 30일 총매출 9,000만 중 순매출 4,500만 +- 취소 없었다면 월간 2~3위권이지만, 순매출 기준 6위로 하락 +- Score 가중치 `order=0.7`이 지배적이므로 취소에 의한 순매출 감소가 순위에 직접적 영향 + +--- + +## 서빙 경로 정리 + +| 스코프 | 서빙 경로 | 데이터 소스 | 갱신 주기 | +|--------|----------|------------|----------| +| **일간** | `RankingFacade.getFromRedis()` | Redis ZSET (`ranking:all:{date}`) | 실시간 (이벤트 발생 시) | +| **주간** | `RankingFacade.getFromMv()` | `mv_product_rank_weekly` | 배치 (1일 1회) | +| **월간** | `RankingFacade.getFromMv()` | `mv_product_rank_monthly` | 배치 (1일 1회) | diff --git a/docs/captures/04-ranking-api-capture.md b/docs/captures/04-ranking-api-capture.md new file mode 100644 index 0000000000..9cca904b41 --- /dev/null +++ b/docs/captures/04-ranking-api-capture.md @@ -0,0 +1,325 @@ +# Ranking API 호출 결과 캡처 + +> 실행일: 2026-04-17 +> 데이터: 상품 1,020개 × 30일 메트릭 = 30,600행 +> 배치: productRankingMvJob (weekly, monthly) +> Redis: daily 랭킹 1,020개 ZADD +> API: `GET /api/v1/rankings?scope={daily|weekly|monthly}&date=20260407&page=0~4&size=20` + +--- + +## 시드 데이터 트렌드 패턴 + +| 타입 | 비율 | 설명 | +|------|------|------| +| A) 급상승 | 5% (51개) | 과거 23일 미미 → 최근 7일 폭발 (view 6K, sales 250만/일) | +| B) 장기 강자 | 10% (102개) | 30일 꾸준히 높음 (view 3.5K, sales 180만/일) | +| C) 하락 추세 | 5% (51개) | 과거 23일 높음 → 최근 7일 급락 | +| D) 오늘 바이럴 | 2% (20개) | 오늘만 폭발 (view 18K, sales 600만) | +| E) 취소 많음 | 3% (31개) | 매출 높지만 취소 50~70% | +| F) 일반 | 75% (765개) | 보통 수준 (view 500, sales 20만/일) | + +--- + +## TOP 20 비교 (일간 / 주간 / 월간) + +| 순위 | 일간 (Daily — Redis) | score | 주간 (Weekly — MV) | score | 월간 (Monthly — MV) | score | +|:----:|-----|------:|-----|------:|-----|------:| +| 1 | 아디다스 캠퍼스 올리브 L | 0.8319 | 나이키 에어리프트 카키 XL | 0.8803 | 반스 슬립온 올리브 XL | 0.9600 | +| 2 | 살로몬 아웃펄스 네이비 XL | 0.8306 | 컨버스 런스타하이크 그레이 M | 0.8800 | 스투시 카고바지 화이트 M | 0.9585 | +| 3 | 뉴발란스 530 올리브 XL | 0.8298 | 스투시 월드투어후디 카키 S | 0.8794 | 리복 클럽C85 인디고 S | 0.9581 | +| 4 | 디스이즈네버댓 SP로고T 브라운 | 0.8298 | 아디다스 포럼 네이비 | 0.8788 | 노스페이스 1996레트로 크림 S | 0.9580 | +| 5 | 컨버스 올스타 블랙 L | 0.8283 | 아디다스 오즈위고 크림 M | 0.8779 | 뉴발란스 990v6 인디고 S | 0.9575 | +| 6 | 아크테릭스 아톰후디 화이트 S | 0.8263 | 뉴발란스 993 베이지 | 0.8774 | 아디다스 포럼 화이트 | 0.9574 | +| 7 | 나이키 에어맥스 브라운 | 0.8212 | 살로몬 센스라이드5 그레이 L | 0.8769 | 무신사 스탠다드 세미와이드 브라운 S | 0.9573 | +| 8 | 아디다스 울트라부스트 브라운 | 0.8210 | 스투시 카고바지 카키 M | 0.8769 | 자라 니트가디건 화이트 L | 0.9570 | +| 9 | 반스 울트라레인지 차콜 XL | 0.8192 | 칼하트WIP 미시간코트 버건디 M | 0.8767 | 메종키츠네 바시티 네이비 | 0.9568 | +| 10 | 아크테릭스 베타LT자켓 베이지 | 0.8185 | 반스 슬립온 버건디 XL | 0.8765 | 아크테릭스 시에르후디 크림 XL | 0.9566 | +| 11 | 메종키츠네 카페키츠네 올리브 M | 0.8177 | 나이키 덩크 화이트 L | 0.8760 | 칼하트WIP 포켓T 올리브 | 0.9565 | +| 12 | 메종키츠네 폭스헤드 티 올리브 | 0.8168 | 스투시 월드투어후디 차콜 S | 0.8757 | 반스 하프캡 카키 | 0.9565 | +| 13 | 뉴발란스 1906R 블랙 S | 0.8128 | 칼하트WIP 마스터셔츠 블랙 L | 0.8756 | 컨버스 런스타하이크 블랙 M | 0.9565 | +| 14 | 리복 클럽C85 크림 S | 0.8119 | 스투시 슈어샷T 블랙 XL | 0.8753 | 무신사 스탠다드 오버핏후디 네이비 | 0.9563 | +| 15 | 파타고니아 캡쿨T 화이트 XL | 0.8101 | 아디다스 삼바 크림 L | 0.8752 | 컨버스 올스타 그레이 L | 0.9561 | +| 16 | 디스이즈네버댓 패딩베스트 브라운 L | 0.8099 | 아디다스 캠퍼스 베이지 L | 0.8752 | 아디다스 슈퍼스타 그레이 XL | 0.9561 | +| 17 | 노스페이스 1996레트로 카키 S | 0.8083 | 파타고니아 P-6로고T 카키 S | 0.8750 | 푸마 CA프로 차콜 | 0.9561 | +| 18 | 칼하트WIP 마스터셔츠 그레이 L | 0.8074 | 스투시 크루니트 베이지 L | 0.8747 | 칼하트WIP 시드릭팬츠 화이트 S | 0.9560 | +| 19 | 살로몬 스피드크로스6 크림 S | 0.8068 | 무신사 스탠다드 트레이닝팬츠 카키 XL | 0.8744 | 나이키 코르테즈 화이트 M | 0.9559 | +| 20 | 노스페이스 눕시자켓 버건디 | 0.8060 | 리복 레거시 크림 XL | 0.8744 | 메종키츠네 메종키츠네 폭스헤드 | 0.9559 | + +--- + +## 순위 변동 분석 + +### 일간 → 주간 순위 상승 TOP 10 + +| 상품 | 트렌드 | 일간 | 주간 | 변동 | +|------|--------|-----:|-----:|-----:| +| 아디다스 오즈위고 크림 M | 급상승 | 71 | 5 | +66 | +| 컨버스 런스타하이크 그레이 M | 급상승 | 65 | 2 | +63 | +| 나이키 덩크 화이트 L | 급상승 | 74 | 11 | +63 | +| 스투시 크루니트 베이지 L | 급상승 | 73 | 18 | +55 | +| 칼하트WIP 마스터셔츠 블랙 L | 급상승 | 63 | 13 | +50 | +| 스투시 월드투어후디 카키 S | 급상승 | 47 | 3 | +44 | +| 아디다스 캠퍼스 베이지 L | 급상승 | 60 | 16 | +44 | +| 살로몬 센스라이드5 그레이 L | 급상승 | 50 | 7 | +43 | +| 리복 레거시 크림 XL | 급상승 | 61 | 20 | +41 | +| 칼하트WIP 미시간코트 버건디 M | 급상승 | 48 | 9 | +39 | + +### 주간 → 월간 순위 상승 TOP 10 + +| 상품 | 트렌드 | 주간 | 월간 | 변동 | +|------|--------|-----:|-----:|-----:| +| 아디다스 NMD 브라운 S | 장기강자 | 97 | 55 | +42 | +| 반스 올드스쿨 베이지 | 장기강자 | 96 | 62 | +34 | +| 유니클로 드라이EX 화이트 M | 장기강자 | 94 | 71 | +23 | +| 유니클로 블록테크 브라운 L | 장기강자 | 95 | 72 | +23 | +| 나이키 에어포스 1 | 장기강자 | 76 | 56 | +20 | +| 나이키 페가수스 올리브 XL | 장기강자 | 93 | 74 | +19 | +| 푸마 스웨이드 버건디 | 장기강자 | 91 | 76 | +15 | +| 무신사 스탠다드 트레이닝팬츠 크림 XL | 장기강자 | 66 | 52 | +14 | +| 리복 리복 클래식 | 장기강자 | 68 | 54 | +14 | +| 노스페이스 화이트라벨T 올리브 XL | 장기강자 | 77 | 67 | +10 | + +--- + +## 트렌드별 대표 상품의 순위 비교 + +각 트렌드 타입에서 대표 상품이 일간/주간/월간에서 어떤 순위를 차지하는지 비교합니다. + +| 상품 | 트렌드 | 일간 | 주간 | 월간 | 해석 | +|------|--------|-----:|-----:|-----:|------| +| 나이키 에어리프트 카키 XL | 급상승 | 21 | 21 | 100+ | 최근 7일 폭발 → 주간 상위, 월간은 과거 미미해 하락 | +| 스투시 카고바지 인디고 M | 장기강자 | 67 | 100+ | 100+ | 30일 꾸준 → 월간 상위, 일간은 바이럴/급상승에 밀림 | +| 아디다스 캠퍼스 올리브 L | 바이럴 | 1 | 100+ | 100+ | 오늘만 폭발 → 일간 상위, 주간/월간은 1일치만 반영 | + +--- + +## API 호출 예시 및 응답 + +### 일간 랭킹 (Redis Speed Layer) + +```bash +curl 'http://localhost:8080/api/v1/rankings?scope=daily&date=20260407&page=0&size=5' +``` + +```json +{ + "meta": { + "result": "SUCCESS" + }, + "data": { + "data": [ + { + "productId": 179, + "productName": "캠퍼스 올리브 L", + "brandName": "아디다스", + "price": 330000, + "rank": 1, + "score": 0.8319134789 + }, + { + "productId": 1010, + "productName": "아웃펄스 네이비 XL", + "brandName": "살로몬", + "price": 369000, + "rank": 2, + "score": 0.8306099717 + }, + { + "productId": 265, + "productName": "530 올리브 XL", + "brandName": "뉴발란스", + "price": 74000, + "rank": 3, + "score": 0.8298468199 + }, + { + "productId": 701, + "productName": "SP로고T 브라운", + "brandName": "디스이즈네버댓", + "price": 71000, + "rank": 4, + "score": 0.8297926845 + }, + { + "productId": 319, + "productName": "올스타 블랙 L", + "brandName": "컨버스", + "price": 78000, + "rank": 5, + "score": 0.8283206061 + } + ], + "totalElements": 100, + "totalPages": 5, + "page": 0, + "size": 20 + } +} +``` + +### 주간 랭킹 (MV Batch Layer) + +```bash +curl 'http://localhost:8080/api/v1/rankings?scope=weekly&date=20260407&page=0&size=5' +``` + +```json +{ + "meta": { + "result": "SUCCESS" + }, + "data": { + "data": [ + { + "productId": 150, + "productName": "에어리프트 카키 XL", + "brandName": "나이키", + "price": 181000, + "rank": 1, + "score": 0.8803474289772881 + }, + { + "productId": 273, + "productName": "런스타하이크 그레이 M", + "brandName": "컨버스", + "price": 137000, + "rank": 2, + "score": 0.879953227234731 + }, + { + "productId": 762, + "productName": "월드투어후디 카키 S", + "brandName": "스투시", + "price": 375000, + "rank": 3, + "score": 0.8794172982962273 + }, + { + "productId": 86, + "productName": "포럼 네이비", + "brandName": "아디다스", + "price": 344000, + "rank": 4, + "score": 0.8787595546591237 + }, + { + "productId": 178, + "productName": "오즈위고 크림 M", + "brandName": "아디다스", + "price": 245000, + "rank": 5, + "score": 0.8779273299754659 + } + ], + "totalElements": 100, + "totalPages": 5, + "page": 0, + "size": 20 + } +} +``` + +### 월간 랭킹 (MV Batch Layer) + +```bash +curl 'http://localhost:8080/api/v1/rankings?scope=monthly&date=20260407&page=0&size=5' +``` + +```json +{ + "meta": { + "result": "SUCCESS" + }, + "data": { + "data": [ + { + "productId": 365, + "productName": "슬립온 올리브 XL", + "brandName": "반스", + "price": 357000, + "rank": 1, + "score": 0.9600463167413544 + }, + { + "productId": 758, + "productName": "카고바지 화이트 M", + "brandName": "스투시", + "price": 178000, + "rank": 2, + "score": 0.9585216360551394 + }, + { + "productId": 432, + "productName": "클럽C85 인디고 S", + "brandName": "리복", + "price": 286000, + "rank": 3, + "score": 0.9580914295828116 + }, + { + "productId": 902, + "productName": "1996레트로 크림 S", + "brandName": "노스페이스", + "price": 197000, + "rank": 4, + "score": 0.9579669150498178 + }, + { + "productId": 232, + "productName": "990v6 인디고 S", + "brandName": "뉴발란스", + "price": 160000, + "rank": 5, + "score": 0.9575088137571697 + } + ], + "totalElements": 100, + "totalPages": 5, + "page": 0, + "size": 20 + } +} +``` + +--- + +## 핵심 관찰 + +### 1. 시간 윈도우에 따른 랭킹 차이 + +| 관찰 | 설명 | +|------|------| +| **일간 상위 ≠ 주간 상위** | 바이럴 상품이 일간 상위지만 주간에서는 1일치만 반영되어 하락 | +| **주간 상위 ≠ 월간 상위** | 급상승 상품이 주간 상위지만 월간에서는 23일간 미미한 실적으로 하락 | +| **월간 상위 = 장기 강자** | 30일 꾸준히 높은 실적의 상품이 월간에서 상위 차지 | + +### 2. Score 범위 차이 + +| scope | 1위 score | 100위 score | 차이 | +|-------|----------:|-----------:|-----:| +| daily | 0.8319 | 0.7381 | 0.0938 | +| weekly | 0.8803 | 0.8461 | 0.0342 | +| monthly | 0.9600 | 0.9439 | 0.0161 | + +- 월간 score가 가장 높음: 30일 누적 데이터 → LOG10 합산값이 큼 +- 일간 score가 가장 낮음: 1일치 데이터만 반영 +- 주간은 7일 합산으로 중간 범위 + +### 3. 취소 반영 + +취소율이 높은 상품(E타입, 취소 50~70%)은 `sales_amount - cancel_amount_by_event_date` 반영으로 +실제 순매출이 낮아져 순위가 하락합니다. 매출 자체는 높지만 순위에서 불이익을 받습니다. + +--- + +## 배치 실행 정보 + +| 항목 | weekly | monthly | +|------|--------|---------| +| 파티션 수 | 4 (productId 범위 분할) | 4 | +| 소요 시간 | 275ms | 309ms | +| 적재 건수 | 100 (TOP 100) | 100 | +| product_metrics | 30,600행 | 30,600행 | +| 상품 수 | 1,020개 | 1,020개 | +| 메트릭 기간 | 7일 (04-01~04-07) | 30일 (03-08~04-07) | \ No newline at end of file diff --git a/docs/design/volume-10/10-batch-analysis-report.md b/docs/design/volume-10/10-batch-analysis-report.md new file mode 100644 index 0000000000..c13306921e --- /dev/null +++ b/docs/design/volume-10/10-batch-analysis-report.md @@ -0,0 +1,629 @@ +# 10. 배치 어플리케이션 분석 보고서 + +> 배치 앱 2개(production 브랜치)를 분석하고, Spring Batch 주간/월간 랭킹 MV 적재에 적용할 인사이트를 추출한 보고서. + +--- + +## 분석 대상 + +| 배치 앱 | 도메인 | Job 수 | 핵심 역할 | +|---------|--------|--------|----------| +| **aurora-x2bee-batch-gddp** (배치 A) | 상품/전시/검색 | 49개 | 상품 리뷰 집계, 검색 인덱스 적재, 베스트/신상품 산정, SAP 연동 | +| **aurora-x2bee-batch-mbod** (배치 B) | 주문/회원/정산 | 48개 | 마일리지 소멸, 회원 등급 변경, 매출/재고 통계, PG 정산 대사 | + +--- + +## 1. 배치 A — aurora-x2bee-batch-gddp (상품/전시/검색) + +### 구조 분석 + +| 항목 | 내용 | +|------|------| +| **총 Job 수** | 49개 (Tasklet 36 + Chunk 8 + Stub 5) | +| **주요 도메인** | 전시(display), 이벤트(event), 상품(goods), 검색(search), 입점사(vendor) | +| **처리 모델** | **Tasklet 73% / Chunk 16% / Stub 11%** | +| **DB** | PostgreSQL + MySQL, RODB/RWDB 분리 (5쌍) | +| **ORM** | MyBatis 중심 (34개 XML 매퍼) | +| **Spring Boot** | 3.3.4, Java 17 | + +### Job 카테고리별 분포 + +| 카테고리 | Job 수 | 처리 모델 | 대표 Job | +|---------|--------|----------|---------| +| Display | 3 | Tasklet | GoodsBestJob, GoodsNewJob | +| Event | 9 | Tasklet | BatEventState, BatMbrBase | +| Goods | 15 | Tasklet + 일부 Chunk | GoodsReviewTotalJob, GoodsSoldOutJob | +| Search | 13 | Tasklet + Chunk | SearchProductChunkLoad, SearchProductIndex | +| Vendor | 4 | Tasklet | EtEntrEvltDayAgrt, VenderEndContract | +| Sample | 6 | Chunk (학습용) | SampleJdbc, SampleMyBatisCursor | + +### Reader 패턴 + +| Reader | 사용처 | 특징 | +|--------|-------|------| +| **MyBatisCursorItemReader** | 검색 상품 로드, 샘플 | 커서 스트리밍, 메모리 효율적 | +| **MyBatisPagingItemReader** | 검색 인덱스 (pageSize=10,000) | ExecutionContext 저장으로 재시작 가능 | +| **JdbcCursorItemReader** | 샘플 (fetchSize=1,000) | BeanPropertyRowMapper 사용 | +| **JdbcPagingItemReader** | 샘플 (pageSize=1,000) | SqlPagingQueryProviderFactoryBean | +| **FlatFileItemReader** | 샘플 (CSV) | linesToSkip=1, ClassPathResource | + +### Writer 패턴 + +- **CompositeItemWriter**: 다중 Writer를 순차 실행 (UPDATE + INSERT 조합) +- **MyBatisBatchItemWriter**: `assertUpdates(false)` — 영향 행 0건이어도 에러 아님 +- **커스텀 Lambda Writer**: 검색 Job에서 REST API 호출 (200건씩 서브 배치) +- **UPSERT**: `INSERT ... ON DUPLICATE KEY UPDATE` (GoodsReviewTotal 등 집계 Job) + +### SQL 특징 + +- **GROUP BY + SUM/COUNT/AVG** 집계를 Reader SQL에서 처리 +- **LEFT JOIN LATERAL**: 상관 서브쿼리로 복잡한 조인 +- **동적 조건 분기**: `batchTyp` 파라미터(A/R/D/M/AFTERDATE)에 따라 WHERE 절 변경 +- **시간 기반 필터링**: `DATE_SUB(NOW(), INTERVAL 60 MINUTE)` 등 증분 처리 + +### 에러 처리 + +- **SingleJobExecutionListener**: 중복 실행 방지 (같은 Job이 이미 실행 중이면 예외) +- **StepExecutionListener** (검색 인덱스): `beforeStep()`에서 배치 프로세스 카운트 체크, `afterStep()`에서 메타데이터 갱신 +- **Skip/Retry 없음**: 실패 시 즉시 종료 + +### 내 과제 시사점 + +- **집계 쿼리를 Reader SQL에서 처리하는 패턴**이 핵심 참고 대상. `GROUP BY product_id`로 일간 메트릭을 기간별로 합산하는 것은 Reader SQL에서 처리 가능 +- **UPSERT 패턴** (`INSERT ... ON DUPLICATE KEY UPDATE`)이 MV 갱신 대안 중 하나 +- **batchTyp 파라미터로 동일 Job에서 주간/월간 분기**하는 방식 — 하나의 Job Config로 scope 파라미터를 받아 처리 가능 +- **MyBatisCursorItemReader가 대량 조회의 기본 선택** — 기존 RankingCorrectionJob의 JdbcCursorItemReader와 동일한 전략 + +--- + +## 2. 배치 B — aurora-x2bee-batch-mbod (주문/회원/정산) + +### 구조 분석 + +| 항목 | 내용 | +|------|------| +| **총 Job 수** | 48개 (Tasklet 46 + Chunk 2) | +| **주요 도메인** | 정산(adjust), 배송(delivery), 회원(member), 주문(order), **통계(statistics)** | +| **처리 모델** | **Tasklet 96% / Chunk 4%** — 마일리지 소멸, 회원 등급 변경만 Chunk | +| **DB** | MySQL, RODB/RWDB 분리 (6쌍) | +| **ORM** | MyBatis 중심 (68개 XML 매퍼) | +| **Spring Boot** | 3.3.4, Java 17 | + +### Chunk-Oriented Job 상세 (2개) + +**① mileageRemoveJob (CHUNK_SIZE=1,000)** + +``` +Reader: MyBatisCursorItemReader → getExpireMileageList (만료 마일리지 조회) +Processor: MbrAsstResponse → MileageExpireRequestVo (변환) +Writer: CompositeItemWriter (3개) + ├── UPDATE: 기존 이력 마감 처리 + ├── INSERT: 소멸 이력 생성 + └── UPDATE: 잔액 합계 갱신 +``` + +**② memberGradeChangeJob (CHUNK_SIZE=100, Multi-Step)** + +``` +Step 1: memberGradeCalcStep (Tasklet) → 등급 산정 → FAILED 시 종료 +Step 2: memberGradeChangeStep (Chunk) + Reader: MyBatisCursorItemReader → getMbrGradeChangeList + Processor: 등급 변경 대상 변환 + Writer: CompositeItemWriter (3개) + ├── UPDATE: 회원 등급 변경 + ├── UPDATE: 이전 등급 이력 종료 + └── INSERT: 새 등급 이력 생성 +Step 3: memberGradeCouponIssueStep (Tasklet) → 등급 변경 쿠폰 발급 +``` + +### 통계 Job 분석 (10개, 모두 Tasklet) + +| Job | 내용 | +|-----|------| +| orderSaleStatisticsJob | 주문 매출 통계 | +| orderSaleStatisticsByGoodsJob | 상품별 매출 통계 | +| orderSaleStatisticsByCouponJob | 쿠폰별 매출 통계 | +| memberOrderStatisticsJob | 회원별 주문 통계 | +| inventoryStatisticsByGoodsJob | 상품별 재고 통계 | +| paymentMethodStatisticsJob | 결제수단별 통계 | +| aggregateBasketJob | 장바구니 집계 | +| dailyGoodsDetailInflowStatisticsJob | 일간 상품 상세 유입 통계 | +| dailyUmamiInflowStatisticsJob | 일간 유입 통계 | +| infDispCtgStatisticsJob | 전시 카테고리 통계 | + +> **주목**: 통계/집계 Job이 10개인데 **전부 Tasklet**. 회사에서는 "Tasklet 내부에서 직접 SQL로 집계 → INSERT"하는 패턴을 선호. + +### 에러 처리 + +- **SingleJobExecutionListener**: gddp와 동일 (중복 실행 방지) +- **Multi-Step 조건 분기**: `memberGradeChangeJob`에서 Step 1 실패 시 `.on("FAILED").end()`로 후속 Step 스킵 +- **Skip/Retry 없음** + +### 내 과제 시사점 + +- **통계/집계에 Tasklet을 쓰는 이유**: SQL 한 방(GROUP BY + INSERT INTO ... SELECT)으로 처리 가능한 경우 Reader/Processor/Writer 패턴이 오히려 과잉. Chunk는 "행 단위 변환"이 필요할 때만 사용 +- **CompositeItemWriter로 다중 테이블 갱신**: MV 적재 시 "기존 데이터 삭제 → 새 데이터 삽입"을 하나의 트랜잭션에서 처리하는 패턴 +- **Multi-Step 조건 분기**: Step 1에서 데이터 검증/전처리 → Step 2에서 본 처리 — 내 과제에서 "기존 MV 삭제 Step → 집계 적재 Step"으로 활용 가능 +- **UniqueRunIdIncrementer**: `System.currentTimeMillis()`로 run.id 생성 → 같은 파라미터로 재실행 가능 (멱등성과 관련) + +--- + +## 3. 비교 테이블 + +| 비교 항목 | 배치 A (gddp) | 배치 B (mbod) | **내 과제 (추천)** | **근거** | +|----------|--------------|--------------|-------------------|---------| +| **처리 모델** | Tasklet 73% / Chunk 16% / Stub 11% | Tasklet 96% / Chunk 4% | **Chunk-Oriented + Partitioning** | 대규모 집계 병렬 처리. Tasklet이 효율적인 경우도 있지만, Chunk의 운영 기능(retry, 모니터링) 활용 | +| **Reader 타입** | MyBatisCursorItemReader 주력 | MyBatisCursorItemReader (2건) | **JdbcCursorItemReader** | 기존 RankingCorrectionJob과 일관성 유지. 집계 쿼리가 단순하므로 MyBatis 매퍼 오버헤드 불필요 | +| **비즈니스 로직 위치** | Reader SQL에서 GROUP BY 집계 수행 | Tasklet 내부에서 SQL 직접 실행 | **Reader SQL에서 집계 + Processor에서 score 계산** | GROUP BY는 DB가 효율적, score 공식(log₁₀ 정규화)은 Java 코드가 명확 | +| **Writer 전략** | UPSERT (`ON DUPLICATE KEY UPDATE`) | CompositeItemWriter (UPDATE+INSERT) | **DELETE+INSERT** (기간별 전체 교체) | TOP 100만 저장하므로 UPSERT보다 DELETE+INSERT가 단순. 멱등성 자동 보장 | +| **멱등성 보장** | UniqueRunIdIncrementer (매번 새 실행) | UniqueRunIdIncrementer + batchDate | **DELETE+INSERT로 자연 멱등성** | 같은 기간 데이터를 삭제 후 재적재 → 2회 실행해도 결과 동일 | +| **에러 처리** | SingleJobExecutionListener | SingleJobExecutionListener + Step Flow | **SingleJobExecutionListener + Multi-Step Flow** | Step 1(삭제) 실패 시 Step 2(적재) 미실행으로 데이터 보호 | +| **실행 방식** | REST API 트리거 | REST API 트리거 | **CommandLineRunner 또는 스케줄러** | commerce-batch 모듈의 기존 실행 방식 따르기 | +| **DB 분리** | RODB/RWDB 분리 (5쌍) | RODB/RWDB 분리 (6쌍) | **단일 DataSource** | 현재 규모에서 불필요. 스케일아웃 시 분리 고려 | + +--- + +## 4. 내 과제 설계 제안 + +### 핵심 인사이트 + +회사 배치 코드에서 배운 가장 중요한 점: + +> **"통계/집계 Job은 대부분 Tasklet으로 SQL 한 방 처리한다."** +> 그러나 과제 요구사항이 Chunk-Oriented 학습이므로, **Reader SQL에서 집계 → Processor에서 score 계산/순위 산정 → Writer에서 MV 적재**하는 구조가 적합하다. + +### 설계 질문 답변 + +**Q1. Reader: JdbcCursorItemReader vs JdbcPagingItemReader** + +→ **JdbcCursorItemReader 추천.** 두 회사 앱 모두 CursorItemReader를 주력으로 사용. 집계 쿼리 결과(상품 수 = 수천~수만 행)는 커서로 충분히 처리 가능하고, 정렬 순서 보장도 자연스럽다. PagingReader는 집계 쿼리에서 OFFSET 기반 페이징 시 데이터 누락 위험이 있다. + +**Q2. Processor vs SQL** + +→ **SQL에서 GROUP BY 집계, Processor에서 score 계산.** gddp의 GoodsReviewTotal이 이 패턴을 사용한다. DB가 잘하는 것(집계)은 DB에, 비즈니스 공식(log₁₀ 정규화 + tiebreaker)은 Java 코드에. + +**Q3. Writer 전략: DELETE+INSERT vs UPSERT** + +→ **DELETE+INSERT 추천.** TOP 100만 저장하므로 UPSERT로 처리하려면 "이번 주 TOP 100에서 빠진 상품"을 별도로 삭제해야 한다. 기간 키 기준 DELETE 후 INSERT가 단순하고 멱등성도 자동 보장. mbod의 통계 Job들도 이 패턴을 사용한다. + +**Q4. 멱등성** + +→ **"DELETE WHERE period_key = ? → INSERT" 패턴으로 자연 멱등성.** UniqueRunIdIncrementer로 같은 파라미터로 재실행 허용 + 적재 전 기존 데이터 삭제 → 몇 번을 돌려도 결과 동일. + +**Q5. Redis vs MV 공존** + +→ **Redis = Speed Layer (실시간 근사치), MV = Batch Layer (DB 원장 기반 정확값).** API에서 scope별로: + +- `daily` → Redis ZSET (기존 유지) +- `weekly/monthly` → **MV 우선, Redis fallback** (MV가 DB 원장 기반이므로 정확도 우위. Redis 장애 시에도 조회 가능) + +### 구체적 Job 구조 제안 + +``` +WeeklyMonthlyRankingJob + ├── Parameter: targetDate, scope(weekly/monthly) + │ + ├── Step 1: cleanupStep (Tasklet) + │ └── DELETE FROM mv_product_rank_{scope} WHERE period_key = ? + │ + └── Step 2: aggregateStep (Chunk, chunkSize=1000) + ├── Reader: JdbcCursorItemReader + │ └── SELECT product_id, SUM(view_count), SUM(like_count), SUM(order_count) + │ FROM product_metrics + │ WHERE metric_date BETWEEN ? AND ? + │ GROUP BY product_id + │ + ├── Processor: score 계산 (기존 Score v2 공식 재활용) + │ └── 0~1 정규화 + log₁₀ + tiebreaker → 순위 산정 + │ + └── Writer: JdbcBatchItemWriter + └── INSERT INTO mv_product_rank_{scope} + (product_id, rank, score, view_count, like_count, order_count, period_key) +``` + +--- + +## 5. 심층 분석: 통계 Tasklet 내부 SQL 패턴 + +> mbod의 통계 Job 10개 + 대시보드 Job 1개의 실제 SQL을 분석하여 패턴을 분류했다. + +### SQL 패턴 분류 + +| 패턴 | 해당 Job | 특징 | +|------|---------|------| +| **DELETE + INSERT...SELECT...GROUP BY** | InfDispCtgStatistics, InventoryStatisticsByGoods, MemberOrderStatistics, OrderSaleStatisticsByGoods, PaymentMethodStatistics | 가장 흔한 패턴. 날짜 기준 DELETE 후 SQL 한 방으로 집계+적재 | +| **INSERT...SELECT + ON DUPLICATE KEY UPDATE** | DailyUmamiInflowStatistics, OrderSaleStatistics, DashboardOrderSale | UPSERT 패턴. CTE + UNION ALL로 복잡한 다차원 집계 | +| **SELECT → Java 루프 → foreach INSERT** | AggregateBasket | Java에서 변환 후 벌크 INSERT | +| **SELECT → Java 루프 → foreach MERGE** | DailyGoodsDetailInflowStatistics | Java에서 변환 후 개별 UPSERT | + +### 패턴별 상세 + +**패턴 1: DELETE + INSERT...SELECT (5개 Job, 가장 일반적)** + +```sql +-- Step 1: 기간 데이터 삭제 +DELETE FROM sm_daycl_inf_ord_agrt WHERE agrt_dt = #{agrtDt} + +-- Step 2: 집계 결과 직접 적재 +INSERT INTO sm_daycl_inf_ord_agrt (agrt_dt, goods_no, ord_cnt, ...) +SELECT #{agrtDt}, goods_no, COUNT(*), ... +FROM sm_daycl_ord_agrt +WHERE agrt_std_dt = #{agrtDt} +GROUP BY goods_no +``` + +- **멱등성**: DELETE로 기존 데이터 제거 → INSERT로 재적재. 자연 멱등 +- **적합**: 일간/날짜 기준 집계. 내 과제의 MV 갱신에 가장 적합한 패턴 +- **특징**: MemberOrderStatistics는 `CASE WHEN`으로 나이대 버킷팅, InventoryStatistics는 2개 CTE로 상품/아이템 재고 결합 + +**패턴 2: INSERT...SELECT + ON DUPLICATE KEY UPDATE (3개 Job, 가장 복잡)** + +```sql +-- OrderSaleStatistics: ~410줄 SQL +WITH DAY_INFO AS (...), + BNF_INFO AS (...), + ORD_DTL_INFO AS (...) +INSERT INTO sm_daycl_ord_agrt (agrt_std_dt, entr_no, ord_sales_cnt, ...) +SELECT ... +FROM ORD_DTL_INFO +-- 4개 UNION ALL: 주문접수/주문완료 × 정상/취소 +UNION ALL ... +ON DUPLICATE KEY UPDATE + ord_sales_cnt = VALUES(ord_sales_cnt), + ... +``` + +- **멱등성**: PK 충돌 시 UPDATE로 덮어쓰기. 자연 멱등 +- **적합**: 다차원 집계 + Late-Arriving Fact 대응 (배송 완료가 주문일 이후 도착) +- **특징**: OrderSaleStatistics가 가장 복잡(410줄). 3개 CTE + 4개 UNION ALL로 주문접수/완료, 정상/취소를 분리 집계 + +**패턴 3: SELECT → Java → 벌크 INSERT (1개 Job)** + +```java +// AggregateBasketServiceImpl +List list = mapper.getBasketAgrtList(param); // CTE + GROUP BY +trxMapper.deleteAll(); // 전체 삭제 +trxMapper.insertBulkSmBasketAgrt(list); // foreach INSERT +``` + +- **멱등성**: deleteAll → insertBulk. 전체 교체 +- **적합**: Java에서 추가 변환이 필요한 경우 +- **특징**: 읽기 쿼리에 `ROW_NUMBER() OVER (PARTITION BY)` 윈도우 함수 사용 + +**패턴 4: SELECT → Java → 개별 MERGE (1개 Job)** + +```java +// DailyGoodsDetailInflowStatisticsServiceImpl +List list = umamiMapper.getDailyGoodsDetailInflowAgreementList(param); +for (GoodsInflowAgrt item : list) { + trxMapper.mergeSmDayclGoodsInfAgrt(item); // INSERT...ON DUPLICATE KEY UPDATE +} +``` + +- **멱등성**: 개별 UPSERT. 자연 멱등 +- **적합**: 외부 시스템(Umami) 데이터를 Java로 변환 후 적재 +- **비효율**: 행 단위 UPSERT → 대량 데이터에서 성능 저하 (GoodsReviewTotal과 동일한 문제) + +### 통계 Job 간 의존 관계 + +``` +OrderSaleStatisticsJob (원천 집계) + ├── OrderSaleStatisticsByGoodsJob (상품별 재집계) + ├── PaymentMethodStatisticsJob (결제수단별 재집계) + └── InfDispCtgStatisticsJob (전시 카테고리별 재집계) +``` + +> **시사점**: 내 과제에서도 주간/월간 Job이 일간 product_metrics에 의존하므로, 실행 순서 관리가 필요하다. + +### 내 과제에 대한 결론 + +**DELETE + INSERT...SELECT 패턴이 가장 적합.** 이유: + +1. 회사 통계 Job 10개 중 5개(50%)가 이 패턴 사용 — 가장 일반적 +2. 내 과제의 MV(TOP 100)는 전체 교체가 자연스러움 (순위가 바뀌므로 증분 갱신 불가) +3. 멱등성 자동 보장 +4. SQL 복잡도가 낮아 유지보수 용이 + +다만 과제 요구사항이 **Chunk-Oriented**이므로, SQL 한 방(Tasklet) 대신 Reader에서 GROUP BY 집계 → Processor에서 score 계산 → Writer에서 INSERT 하는 구조로 분해한다. + +--- + +## 6. 심층 분석: UniqueRunIdIncrementer + +> 두 앱 모두 동일한 커스텀 구현을 사용한다. Spring Batch 기본 동작과의 차이를 분석했다. + +### 구현 코드 (두 앱 동일, production 브랜치) + +```java +public class UniqueRunIdIncrementer extends RunIdIncrementer { + private static final String RUN_ID = "run.id"; + + @Override + public JobParameters getNext(JobParameters parameters) { + UUID uuid = UUID.randomUUID(); + return new JobParametersBuilder() + .addString(RUN_ID, uuid + Long.toString(System.currentTimeMillis())) + .toJobParameters(); + } +} +``` + +> **이전 브랜치와의 차이**: `addLong(RUN_ID, System.currentTimeMillis())` → `addString(RUN_ID, UUID + timestamp)`. 밀리초 단위 충돌 가능성을 UUID로 해소. `Long` → `String`으로 타입도 변경. + +### Spring Batch 기본 RunIdIncrementer와의 비교 + +| 항목 | 기본 RunIdIncrementer | 커스텀 UniqueRunIdIncrementer | +|------|----------------------|------------------------------| +| **run.id 생성** | 순차 증가 (`run.id + 1`) | `UUID + System.currentTimeMillis()` (UUID + 타임스탬프) | +| **기존 파라미터** | **보존** (기존 파라미터에 run.id만 추가) | **전부 버림** (run.id만 남는 새 JobParameters 생성) | +| **Job Instance 식별** | jobName + 모든 파라미터(run.id 제외) | jobName만으로 식별 (다른 파라미터가 없으므로) | +| **재실행** | 같은 파라미터 + 새 run.id = 같은 Instance의 새 Execution | 매번 새 Execution | +| **유니크 보장** | 순차 → 충돌 없음 | 밀리초 → 동시 실행 시 이론적 충돌 가능 (극히 희소) | + +### 핵심 설계 의도 + +**"같은 Job을 언제든 제한 없이 재실행 가능하게 한다."** + +- 기본 RunIdIncrementer는 이전 파라미터를 보존하므로, `targetDate=20260414`로 실행한 Job을 다시 실행하면 **같은 Job Instance에 새 Execution**이 생긴다 +- 커스텀 UniqueRunIdIncrementer는 파라미터를 전부 버리므로, **항상 같은 Job Instance**에 매번 새 Execution이 생긴다 +- `SingleJobExecutionListener`와 조합하여 "동시 실행만 방지, 순차 재실행은 허용"하는 전략 + +### 내 과제에 대한 시사점 + +**주의: 이 패턴은 파라미터 기반 멱등성과 충돌한다.** + +내 과제에서 `targetDate`와 `scope`를 JobParameter로 받아야 하는데, UniqueRunIdIncrementer를 그대로 쓰면 **파라미터가 버려진다.** 따라서: + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| **기본 RunIdIncrementer 사용** | 파라미터 보존, 같은 날짜 재실행 가능 | Spring Batch가 "이미 완료된 Instance" 에러를 낼 수 있음 | +| **UniqueRunIdIncrementer + 파라미터 직접 추가** | 재실행 자유 | 파라미터가 JobParameters에 포함되지 않아 @Value 주입 불가 | +| **커스텀 Incrementer (파라미터 보존 + 타임스탬프)** | 파라미터 보존 + 재실행 자유 | 구현 필요 | + +**추천**: 기본 `RunIdIncrementer`를 사용하되, Writer에서 DELETE+INSERT로 멱등성을 보장하는 것이 가장 단순하다. + +--- + +## 7. 심층 분석: GoodsReviewTotal UPSERT 패턴 + +> gddp의 상품 리뷰 집계 Job이 사용하는 UPSERT의 실제 SQL과 구조를 분석했다. + +### 실행 흐름 + +``` +GoodsReviewTotalJobConfig + └── GoodsReviewTotalJobTasklet (@StepScope, batchTyp/chngDtm 파라미터) + └── GoodsReviewTotalServiceImpl.run(batchTyp) + ├── Step 1: seltGoodsReviewTotalStep1() → 리뷰 집계 SELECT + ├── Step 2: insertUpdatePrGoodsRevAgrtInfo() → 행 단위 UPSERT 루프 + └── Step 3: syncGoodsSummaryRevCnt() → 전시 요약 테이블 동기화 +``` + +### 집계 SELECT (Reader 역할) + +```sql +SELECT + PGRI.GOODS_NO, + COUNT(PGRI.REV_NO) AS REV_CNT, + SUM(hlpful.HLPFUL_CNT) AS HLPFUL_CNT, + SUM(PGRI.REV_SCR_VAL) AS SUM_SCR_VAL, + ROUND(AVG(PGRI.REV_SCR_VAL), 1) AS REV_SCR_VAL_AVG_VAL +FROM pr_goods_rev_info PGRI +LEFT JOIN LATERAL ( + SELECT COUNT(REV_NO) AS HLPFUL_CNT + FROM pr_goods_rev_hlpful_info PGRHI + WHERE PGRHI.REV_NO = PGRI.REV_NO +) hlpful ON TRUE +WHERE PGRI.REV_DISP_STAT_CD = '20' + AND PGRI.DEL_YN != 'Y' + -- batchTyp에 따른 동적 조건: + -- R(실시간): PGRI.SYS_MOD_DTM BETWEEN DATE_SUB(NOW(), INTERVAL 60 MINUTE) AND NOW() + -- D(일간): PGRI.SYS_MOD_DTM >= CURDATE() + -- M(수동): PGRI.SYS_MOD_DTM >= #{chngDtm} + -- ALL: 조건 없음 (전체) +GROUP BY PGRI.GOODS_NO +``` + +**특징**: LEFT JOIN LATERAL로 리뷰별 도움 수를 상관 서브쿼리로 집계. `batchTyp`에 따라 증분/전체 선택 가능. + +### UPSERT SQL (Writer 역할) + +```sql +INSERT INTO pr_goods_rev_agrt_info ( + GOODS_NO, + REV_CNT, + HLPFUL_CNT, + REV_STARSCR_AVG_VAL, + SYS_REG_ID, SYS_REG_DTM, + SYS_MOD_ID, SYS_MOD_DTM +) VALUES ( + #{goodsNo}, + CAST(#{revCnt} AS SIGNED), + CAST(#{hlpfulCnt} AS SIGNED), + CAST(#{revScrValAvgVal} AS DECIMAL(10,2)), + #{sysRegId}, now(), + #{sysModId}, now() +) +ON DUPLICATE KEY UPDATE + REV_CNT = CAST(#{revCnt} AS SIGNED), + HLPFUL_CNT = CAST(#{hlpfulCnt} AS SIGNED), + REV_STARSCR_AVG_VAL = CAST(#{revScrValAvgVal} AS DECIMAL(10,2)), + SYS_MOD_ID = #{sysModId}, + SYS_MOD_DTM = now() +``` + +**PK**: `GOODS_NO` (상품번호) + +### UPSERT vs DELETE+INSERT 트레이드오프 (실무 코드 기반) + +| 관점 | UPSERT (GoodsReviewTotal 방식) | DELETE+INSERT (통계 Job 방식) | +|------|-------------------------------|------------------------------| +| **적합한 경우** | 1:1 매핑 (상품 → 집계 1행). 기존 데이터에서 빠지는 행이 없음 | TOP-N 랭킹처럼 기간마다 대상이 바뀌는 경우 | +| **멱등성** | 자연 멱등 (PK 충돌 시 UPDATE) | 자연 멱등 (DELETE 후 재적재) | +| **잔여 데이터** | 이전에 있던 행이 그대로 남음 (삭제 안 됨) | 기간 키 기준 깨끗하게 교체 | +| **성능 (소규모)** | 행 단위 UPSERT → N번 DB 호출 | DELETE 1회 + 벌크 INSERT 1회 | +| **성능 (대규모)** | 행 단위 루프가 병목 | DELETE가 락 범위 넓을 수 있음 | +| **감사 추적** | SYS_REG_DTM(최초) / SYS_MOD_DTM(최종) 분리 가능 | 매번 새 행이므로 최종 적재 시각만 기록 | + +### GoodsReviewTotal의 비효율 포인트 + +1. **행 단위 UPSERT 루프**: `for (item : list) { mapper.insertUpdate(item); }` — 상품 10만 건이면 10만 번 DB 호출 +2. **같은 회사의 GoodsSummarySyncJob**은 `INSERT...SELECT...ON DUPLICATE KEY UPDATE`로 SQL 한 방 처리 → 훨씬 효율적 +3. Java 루프 UPSERT는 Reader/Processor가 필요한 Chunk에서도 동일한 비효율 발생 가능 + +### 내 과제에 대한 결론 + +**DELETE+INSERT가 내 과제에 더 적합한 이유**: + +1. **TOP 100 랭킹은 기간마다 대상이 바뀐다** — 이번 주 TOP 100에 있던 상품이 다음 주에는 빠질 수 있음. UPSERT는 빠진 상품을 삭제하지 않으므로 잔여 데이터 문제 발생 +2. **기간 키(period_key) 기준 전체 교체**가 의미적으로 깔끔 — "이번 주 랭킹"은 하나의 단위로 교체되어야 함 +3. **JdbcBatchItemWriter의 벌크 INSERT**는 행 단위 UPSERT보다 성능 우위 +4. GoodsReviewTotal의 행 단위 UPSERT 루프는 **안티패턴** — 내 과제에서 피해야 할 패턴 + +--- + +## 8. MV Score 계산 전략: 메트릭 합산 후 score 1회 계산 (방식 A) + +> Redis의 주간/월간 랭킹은 "일별 score를 합산/감쇠"하는 근사치 방식이다. +> MV는 DB 원장 기반의 정확한 기간 집계를 제공하기 위한 Batch Layer이므로, 다른 계산 방식을 적용한다. + +### Redis vs MV의 score 계산 차이 + +| 항목 | Redis 주간 | Redis 월간 | MV (방식 A) | +|------|-----------|-----------|------------| +| **입력** | 7개 daily ZSET의 score | 전일 monthly score + 당일 daily score | product_metrics 원시 메트릭 | +| **계산** | `ZUNIONSTORE(SUM)` — 일별 score 단순 합산 | `전일 × 0.97 + 당일 × 1.0` — 지수 감쇠 롤링 | `SUM(메트릭) → score 공식 1회 적용` | +| **특성** | log₁₀ 비선형성으로 인한 왜곡 가능 | carry-over 누적 근사치 | DB 원장 기반 정확값 | +| **의미** | "일별 인기도의 합" | "최근 활동에 가중치를 둔 인기도" | "기간 총 활동량 기반 인기도" | + +### 왜 방식 A인가: log₁₀ 비선형성 문제 + +Redis 주간 방식(일별 score 합산)은 수학적으로 부정확할 수 있다: + +``` +예시: 상품 X — 7일간 view_count = [100, 100, 100, 100, 100, 100, 100] +예시: 상품 Y — 7일간 view_count = [0, 0, 0, 0, 0, 0, 700] + +Redis 주간 (일별 score 합산): + X: 7 × log₁₀(101)/7 = 7 × 0.2862 = 2.0034 + Y: 6 × log₁₀(1)/7 + log₁₀(701)/7 = 0 + 0.4063 = 0.4063 + → X가 압도적 우위 (일별 score 합산이므로 매일 꾸준한 상품이 유리) + +MV 방식 A (메트릭 합산 후 score 1회 계산): + X: log₁₀(700 + 1)/7 = 0.4063 + Y: log₁₀(700 + 1)/7 = 0.4063 + → 동일 (총 활동량이 같으므로 동점) +``` + +- **Redis 방식**: "꾸준히 인기 있는 상품"을 우대 — 실시간 트렌드 반영에 적합 +- **MV 방식 A**: "기간 총 활동량"을 공정하게 평가 — 정확한 기간 집계에 적합 + +**두 방식은 관점이 다르고, MV의 존재 이유(정확한 Batch Layer)에는 방식 A가 부합한다.** + +### Reader SQL 설계 + +```sql +SELECT + pm.product_id, + SUM(pm.view_count) AS total_view_count, + SUM(pm.like_count - pm.unlike_count) AS total_net_like_count, + SUM(pm.sales_count) AS total_sales_count, + SUM(pm.sales_amount - pm.cancel_amount_by_event_date) AS total_net_sales_amount +FROM product_metrics pm +JOIN product p ON pm.product_id = p.id +WHERE pm.metric_date BETWEEN :startDate AND :endDate + AND p.deleted_at IS NULL +GROUP BY pm.product_id +``` + +- **주간**: `startDate = targetDate - 6`, `endDate = targetDate` (7일) +- **월간**: `startDate = targetDate - 29`, `endDate = targetDate` (30일) +- Additive Measure 원칙 준수: 취소는 별도 컬럼이므로 조회 시 차감 (`sales_amount - cancel_amount`) + +### Processor 설계 + +기존 Score v2 공식을 그대로 적용: + +``` +score = categoryPriority + + 0.1 × log₁₀(totalViewCount + 1) / 7.0 + + 0.2 × log₁₀(totalNetLikeCount + 1) / 7.0 + + 0.7 × log₁₀(totalNetSalesAmount + 1) / 7.0 + + epochSeconds × 1e-16 (tiebreaker) +``` + +- **가중치/MAX_LOG**: RankingCorrectionJobConfig의 상수 재활용 +- **tiebreaker**: 배치 실행 시점의 `Instant.now().getEpochSecond()` 사용 (동점 해소 용도) +- **TOP-N 필터링**: Processor에서 하지 않음 — 전체 결과를 Writer에 전달하고, Reader SQL에 `ORDER BY score DESC LIMIT 100`을 추가하거나 Writer 후 별도 정리 + +### Writer 설계 (DELETE + INSERT) + +``` +Step 1 (Tasklet): DELETE FROM mv_product_rank_{scope} WHERE period_key = :periodKey +Step 2 (Chunk Writer): + INSERT INTO mv_product_rank_{scope} + (product_id, ranking, score, view_count, like_count, sales_count, sales_amount, period_key, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) +``` + +- **period_key**: 주간 = `2026-W16`, 월간 = `2026-04` (ISO 기반) +- **ranking**: Processor 또는 Writer 단계에서 score 내림차순 순번 부여 +- **TOP 100 제한**: Reader SQL에서 `LIMIT 100` 또는 Processor에서 필터링 + +### 전체 데이터 흐름 + +``` +[product_metrics (DB 원장)] + │ + │ Reader: GROUP BY product_id, SUM(7일 or 30일) + ▼ +[상품별 기간 메트릭 합계] + │ + │ Processor: Score v2 공식 적용 (log₁₀ 정규화 + tiebreaker) + ▼ +[상품별 score] + │ + │ Writer: DELETE period_key → INSERT TOP 100 + ▼ +[mv_product_rank_weekly / mv_product_rank_monthly] + │ + │ API: SELECT WHERE period_key = ? ORDER BY ranking + ▼ +[클라이언트 응답] +``` + +### Redis와 MV의 역할 분담 (최종 — 단일 소스 원칙) + +| 관점 | Redis ZSET | MV 테이블 | +|------|-----------|----------| +| **역할** | Speed Layer — 실시간 근사치 | Batch Layer — DB 원장 기반 정확값 | +| **daily** | 단일 소스 | 불필요 (Redis로 충분) | +| **weekly** | 사용 안 함 (MV 도입 후 제거) | **단일 소스** — 정확한 기간 집계 | +| **monthly** | 사용 안 함 (MV 도입 후 제거) | **단일 소스** — 정확한 기간 집계 | +| **장애 시** | Redis 다운 → daily 조회 불가 | 당일 MV 없으면 → 전일 MV fallback (같은 공식, 1일 stale) | + +--- + +## 부록: 공통 아키텍처 패턴 + +### 두 앱의 공통 패턴 + +| 패턴 | 설명 | 내 과제 적용 | +|------|------|------------| +| **UniqueRunIdIncrementer** | `UUID + System.currentTimeMillis()` 기반 run.id → 같은 파라미터로 재실행 가능. 파라미터 전부 버림 | 파라미터 보존이 필요하므로 기본 RunIdIncrementer 사용 | +| **RODB/RWDB 분리** | 읽기는 Replica, 쓰기는 Primary | 현재 규모에서는 단일 DataSource로 충분 | +| **SingleJobExecutionListener** | `JobExplorer.findRunningJobExecutions()`로 중복 실행 방지 | 동일 패턴 적용 가능 | +| **MyBatis + XML Mapper** | SQL을 XML로 외부 관리, 동적 조건 분기 | JdbcCursorItemReader + 인라인 SQL로 충분 | +| **REST API 트리거** | `/jobs/{jobName}?param=value` → JobLauncher.run() | commerce-batch의 기존 실행 방식 따르기 | +| **@JobScope / @StepScope** | JobParameter 주입을 위한 지연 생성 | 필수 적용 (targetDate, scope 파라미터) | +| **assertUpdates(false)** | Writer에서 영향 행 0건 허용 | ETL 시나리오에서 유용 | + +### Tasklet vs Chunk 선택 기준 (회사 코드에서 도출) + +| 기준 | Tasklet 선택 | Chunk 선택 | +|------|-------------|-----------| +| SQL 복잡도 | INSERT INTO ... SELECT (SQL 한 방) | 행 단위 변환/필터링 필요 | +| 데이터 규모 | SQL이 감당 가능한 범위 | OOM 위험 → chunk 단위 커밋 | +| 비즈니스 로직 | 단순 이동/삭제/갱신 | score 계산, 등급 산정 등 Java 로직 | +| 트랜잭션 | 전체 or nothing | 부분 커밋 필요 (실패 시 일부 복구) | +| 배치 앱 비율 | **85%** (82/97개 Job) | **15%** (10/97개 Job, stub 5개 제외) | diff --git a/docs/design/volume-10/10-batch-code-reference.md b/docs/design/volume-10/10-batch-code-reference.md new file mode 100644 index 0000000000..c2ccaf365d --- /dev/null +++ b/docs/design/volume-10/10-batch-code-reference.md @@ -0,0 +1,944 @@ +# 10. 회사 배치 코드 참고 스니펫 + +> 회사 실무 배치 앱(gddp, mbod)에서 추출한 핵심 코드 패턴. +> 구현 시 직접 참고할 수 있도록 패턴별로 분류했다. + +--- + +## 1. 공통 인프라 코드 + +### UniqueRunIdIncrementer (두 앱 동일, production 브랜치) + +```java +public class UniqueRunIdIncrementer extends RunIdIncrementer { + private static final String RUN_ID = "run.id"; + + @Override + public JobParameters getNext(JobParameters parameters) { + UUID uuid = UUID.randomUUID(); + return new JobParametersBuilder() + .addString(RUN_ID, uuid + Long.toString(System.currentTimeMillis())) + .toJobParameters(); + } +} +``` + +- **이전 버전과의 차이**: `addLong(timestamp)` → `addString(UUID + timestamp)`. 밀리초 충돌 가능성을 UUID로 해소 +- **주의**: 기존 파라미터를 전부 버린다. `targetDate`, `scope` 파라미터가 필요한 경우 기본 `RunIdIncrementer`를 사용할 것 + +### SingleJobExecutionListener (중복 실행 방지) + +```java +@Component +@Slf4j +public class SingleJobExecutionListener implements JobExecutionListener { + + @Autowired + private JobExplorer jobExplorer; + + @Override + public void beforeJob(JobExecution jobExecution) { + int runningJobsCount = jobExplorer + .findRunningJobExecutions(jobExecution.getJobInstance().getJobName()) + .size(); + if (runningJobsCount > 1) { + throw new CommonException( + "이미 실행 중인 Job이 있습니다. 현재 실행을 중지합니다: " + + jobExecution.getJobInstance().getJobName()); + } + } + + @Override + public void afterJob(JobExecution jobExecution) { + log.debug("End of job: [{}] {}", + jobExecution.getJobInstance().getInstanceId(), + jobExecution.getJobInstance().getJobName()); + } +} +``` + +- `JobExplorer.findRunningJobExecutions()`으로 같은 이름의 실행 중인 Job이 있는지 체크 +- 1개 초과 시 예외를 던져 중복 실행 방지 + +--- + +## 2. Chunk-Oriented Job 패턴 + +### 패턴 A: JdbcCursorItemReader + CompositeItemWriter (gddp/SampleJdbcConfig) + +```java +@Configuration +@RequiredArgsConstructor +public class SampleJdbcConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + + @Resource(name = "displayRodbSqlSessionFactory") + private final SqlSessionFactory displayRodbSqlSessionFactory; + + @Resource(name = "displayRwdbSqlSessionFactory") + private final SqlSessionFactory displayRwdbSqlSessionFactory; + + private static final int CHUNK_SIZE = 1000; + + @Bean + public Job sampleJdbcJob() { + return new JobBuilder("sampleJdbcJob", jobRepository) + .start(sampleJdbcStep()) + .incrementer(new UniqueRunIdIncrementer()) + .build(); + } + + @Bean + public Step sampleJdbcStep() { + return new StepBuilder("sampleJdbcStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(sampleJdbcReader()) + .writer(sampleJdbcWriter()) + .build(); + } + + @Bean + @StepScope + public JdbcCursorItemReader sampleJdbcReader() { + DataSource displayRodbDataSource = + (DataSource) ApplicationContextWrapper.getBean("displayRodbDataSource"); + + HashMap queryMap = new HashMap<>(); + queryMap.put("name", "James"); + queryMap.put("sysRegrId", "SYSTEM"); + + BoundSql boundSql = displayRodbSqlSessionFactory.getConfiguration() + .getMappedStatement("selectSampleJdbcList").getBoundSql(queryMap); + + return new JdbcCursorItemReaderBuilder() + .name("jdbcCursorItemReader") + .dataSource(displayRodbDataSource) + .sql(boundSql.getSql()) + .rowMapper(new BeanPropertyRowMapper<>(SampleRequest.class)) + .preparedStatementSetter( + new ArgumentPreparedStatementSetter(getQueryValues(boundSql))) + .fetchSize(1000) + .maxItemCount(1000) + .maxRows(1000) + .build(); + } + + @Bean + public ItemWriter sampleJdbcWriter() { + CompositeItemWriter compositeItemWriter = new CompositeItemWriter<>(); + compositeItemWriter.setDelegates(Arrays.asList(jdbcBatchItemWriter())); + return compositeItemWriter; + } + + @Bean + public JdbcBatchItemWriter jdbcBatchItemWriter() { + DataSource displayRwdbDataSource = + (DataSource) ApplicationContextWrapper.getBean("displayRwdbDataSource"); + + BoundSql boundSql = displayRwdbSqlSessionFactory.getConfiguration() + .getMappedStatement("updateSample2").getBoundSql(new SampleRequest()); + + return new JdbcBatchItemWriterBuilder() + .dataSource(displayRwdbDataSource) + .assertUpdates(true) + .sql(boundSql.getSql()) + .itemPreparedStatementSetter((item, ps) -> { + ps.setString(1, "SYSTEM"); + ps.setString(2, item.getName()); + }) + .build(); + } +} +``` + +**참고 포인트**: +- `JdbcCursorItemReaderBuilder`의 `.fetchSize()`, `.maxItemCount()`, `.maxRows()` 설정 +- `BeanPropertyRowMapper`로 ResultSet → DTO 매핑 +- `JdbcBatchItemWriterBuilder`의 `.assertUpdates(true)` — 영향 행이 0이면 에러 +- `CompositeItemWriter`로 다중 Writer 체이닝 + +--- + +### 패턴 B: MyBatisCursorItemReader + MyBatisBatchItemWriter (gddp/SampleMyBatisCursorJobConfig) + +```java +@Configuration +@RequiredArgsConstructor +public class SampleMyBatisCursorJobConfig { + + @Resource(name = "displayRodbSqlSessionFactory") + private final SqlSessionFactory displayRodbSqlSessionFactory; + + @Resource(name = "displayRwdbSqlSessionFactory") + private final SqlSessionFactory displayRwdbSqlSessionFactory; + + private static final int CHUNK_SIZE = 1000; + + @Bean + public Job sampleMyBatisCursorJob() { + return new JobBuilder("sampleMyBatisCursorJob", jobRepository) + .start(sampleMyBatisCursorStep()) + .incrementer(new UniqueRunIdIncrementer()) + .build(); + } + + @Bean + public Step sampleMyBatisCursorStep() { + return new StepBuilder("sampleMyBatisCursorStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(sampleMyBatisCursorItemReader()) + .writer(sampleeCompositeWriter()) + .build(); + } + + @Bean + public MyBatisCursorItemReader sampleMyBatisCursorItemReader() { + Map parameterValues = new HashMap<>(); + return new MyBatisCursorItemReaderBuilder() + .sqlSessionFactory(displayRodbSqlSessionFactory) + .queryId("com.x2bee.batch.gddp.app.repository.displayrodb.sample.BatSampleMapper.selectSampleList") + .parameterValues(parameterValues) + .build(); + } + + @Bean + public ItemWriter sampleMyBatisBatchItemWriter() { + return new MyBatisBatchItemWriterBuilder() + .sqlSessionFactory(displayRwdbSqlSessionFactory) + .assertUpdates(false) // 영향 행 0건 허용 + .itemToParameterConverter(item -> { + Map parameter = new HashMap<>(); + parameter.put("sysModrId", "BATCH"); + parameter.put("name", item.getName()); + return parameter; + }) + .statementId("com.x2bee.batch.gddp.app.repository.displayrwdb.sample.BatSampleTrxMapper.updateSample") + .build(); + } +} +``` + +**참고 포인트**: +- `MyBatisCursorItemReaderBuilder`는 `queryId`로 매퍼 XML의 SQL을 참조 +- `MyBatisBatchItemWriterBuilder`의 `.itemToParameterConverter()`로 DTO → 파라미터 맵 변환 +- `.assertUpdates(false)` — ETL에서 "영향 없는 행"이 정상인 경우 + +--- + +### 패턴 C: Reader/Processor/Writer 분리 + CompositeItemWriter (gddp/SampleCompositeWriterJobConfig) + +```java +@Bean +public Step sampleCompositeWriterStep() { + return new StepBuilder("sampleCompositeWriterStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(sampleReader()) + .processor(sampleProcessor()) // 타입 변환: Request → Response + .writer(sampleeCompositeWriter()) + .build(); +} + +@Bean +public ItemProcessor sampleProcessor() { + return batSampleCompositeService::processor; // 메서드 레퍼런스 +} + +@Bean +public ItemWriter sampleeCompositeWriter() { + CompositeItemWriter compositeItemWriter = new CompositeItemWriter<>(); + compositeItemWriter.setDelegates(Arrays.asList(updateWriter())); + return compositeItemWriter; +} + +@Bean +public ItemWriter updateWriter() { + return sampleList -> sampleList.forEach(batSampleCompositeService::writer); +} +``` + +**참고 포인트**: +- Processor에서 타입 변환 (`SampleRequest` → `SampleResponse`) +- Lambda Writer (`sampleList -> sampleList.forEach(...)`)로 커스텀 로직 실행 + +--- + +## 3. Multi-Step + 조건 분기 패턴 + +### memberGradeChangeJob (mbod) — Step 1 실패 시 후속 Step 스킵 + +```java +@Bean +public Job memberGradeChangeJob() { + return new JobBuilder("memberGradeChangeJob", jobRepository) + .start(memberGradeCalcStep()).on("FAILED").end() // Step 1 실패 → 종료 + .on("*").to(memberGradeChangeStep("")).on("FAILED").end() // Step 2 실패 → 종료 + .on("*").to(memberGradeCouponIssueStep()) // Step 3 + .end() + .incrementer(new UniqueRunIdIncrementer()) + .listener(singleJobExecutionListener) + .build(); +} + +// Step 1: Tasklet (등급 산정) +@Bean +@JobScope +public Step memberGradeCalcStep() { + return new StepBuilder("memberGradeCalcStep", jobRepository) + .tasklet(memberGradeCalcTasklet(null), transactionManager) + .build(); +} + +// Step 2: Chunk (등급 변경 — Reader/Processor/Writer) +@Bean +@JobScope +public Step memberGradeChangeStep(@Value("#{jobParameters[mbrNo]}") String mbrNo) { + this.mbrNo = mbrNo; + this.batchDate = DateUtil.today(X2Constants.YYYYMMDD); + return new StepBuilder("memberGradeChangeStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(memberGradeChangeItemReader()) + .processor(memberGradeChangeItemProcessor()) + .writer(memberGradeChangeItemWriter()) + .build(); +} + +// Step 3: Tasklet (쿠폰 발급) +@Bean +@JobScope +public Step memberGradeCouponIssueStep() { + return new StepBuilder("memberGradeCouponIssueStep", jobRepository) + .tasklet(memberGradeCouponIssueTasklet(null), transactionManager) + .build(); +} +``` + +**참고 포인트**: +- `.on("FAILED").end()` — 실패 시 후속 Step 실행하지 않고 종료 +- `.on("*").to(nextStep)` — 그 외 모든 상태에서 다음 Step으로 +- **내 과제 적용**: `cleanupStep(DELETE).on("FAILED").end() → aggregateStep(Chunk)` + +### Processor에서 DTO 변환 (memberGradeChange) + +```java +@Bean +public ItemProcessor memberGradeChangeItemProcessor() { + return item -> { + MemberGradeChangeRequest request = new MemberGradeChangeRequest(); + request.setSysRegId(Constants.SYS_REG_ID); + request.setSysModId(Constants.SYS_MOD_ID); + request.setBatchDate(batchDate); + request.setMbrNo(item.getMbrNo()); + request.setMbrGradeCd(item.getMbrGradeCd()); + return request; + }; +} +``` + +### CompositeItemWriter 3개 체이닝 (memberGradeChange) + +```java +@Bean +public ItemWriter memberGradeChangeItemWriter() { + CompositeItemWriter compositeItemWriter = new CompositeItemWriter<>(); + compositeItemWriter.setDelegates(Arrays.asList( + memberGradeChangeItemWriter1(), // UPDATE: 회원 등급 변경 + memberGradeChangeItemWriter2(), // UPDATE: 이전 등급 이력 종료 + memberGradeChangeItemWriter3() // INSERT: 새 등급 이력 생성 + )); + return compositeItemWriter; +} + +@Bean +public ItemWriter memberGradeChangeItemWriter1() { + return new MyBatisBatchItemWriterBuilder() + .sqlSessionFactory(orderRwdbSqlSessionFactory) + .assertUpdates(false) + .statementId("...EtMbrBaseTrxMapper.modifyEtMbrBaseGradeChange") + .build(); +} +``` + +--- + +## 4. Tasklet 패턴 + +### 단순 Tasklet (통계 Job — mbod) + +```java +@RequiredArgsConstructor +@StepScope +@Component +public class OrderSaleStatisticsTasklet implements Tasklet { + private final OrderSaleStatisticsService orderSaleStatisticsService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + orderSaleStatisticsService.orderSaleStatisticsDataProcess(); + return RepeatStatus.FINISHED; + } +} +``` + +### 파라미터 주입 Tasklet (gddp/GoodsReviewTotal) + +```java +@Component +@Slf4j +@StepScope +public class GoodsReviewTotalJobTasklet implements Tasklet { + + @Value("#{jobParameters[batchTyp]}") + private String batchTyp; + + @Value("#{jobParameters[chngDtm]}") + private String chngDtm; + + @Autowired + private GoodsReviewTotalService goodsReviewTotalService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + goodsReviewTotalService.run(batchTyp); + return RepeatStatus.FINISHED; + } +} +``` + +- `@StepScope` + `@Value("#{jobParameters[...]}") `로 파라미터 주입 +- Service에 위임하여 실제 로직 처리 + +--- + +## 5. StepExecutionListener 패턴 (gddp/SearchProductIndex) + +```java +private record IndexBatchCheckListener( + SearchMapper searchMapper, + SearchTrxMapper searchTrxMapper +) implements StepExecutionListener { + + @Override + public void beforeStep(StepExecution stepExecution) { + int startCount = searchMapper.getSearchIndexLoadBatchProcessCount(); + if (startCount > 0) { + stepExecution.setTerminateOnly(); // 다른 배치가 실행 중이면 Step 종료 + } + } + + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + if (stepExecution.getExitStatus().equals(ExitStatus.COMPLETED)) { + searchTrxMapper.updateSearchProductIndexSendYn(); // 메타데이터 갱신 + } else { + return new ExitStatus( + ExitStatus.EXECUTING.getExitCode(), + "Running SearchProductIndexLoadBatch or System Error!" + ); + } + return stepExecution.getExitStatus(); + } +} +``` + +Step에 Listener 등록: + +```java +@Bean +@JobScope +public Step searchProductIndexStep() { + return new StepBuilder("searchProductIndexStep", jobRepository) + .chunk(DEFAULT_CHUNK_SIZE, transactionManager) + .reader(searchProductIndexReader(null, null, null)) + .writer(searchProductIndexWriter()) + .listener(new IndexBatchCheckListener(searchMapper, searchTrxMapper)) + .build(); +} +``` + +**참고 포인트**: +- `record`로 간결하게 구현 +- `stepExecution.setTerminateOnly()` — Step 실행 자체를 방지 +- `afterStep()`에서 성공 시 후처리 (메타데이터 갱신) + +--- + +## 6. 통계 SQL 패턴 + +### 패턴 1: DELETE + INSERT...SELECT (가장 일반적, 5개 Job) + +```sql +-- SmDayclGoodsOrdAgrtTrxMapper.xml +-- Step 1: 기간 데이터 삭제 +DELETE FROM SM_DAYCL_GOODS_ORD_AGRT WHERE AGRT_DT = #{agrtDt} + +-- Step 2: 집계 결과 직접 적재 +INSERT INTO SM_DAYCL_GOODS_ORD_AGRT ( + AGRT_DT, GOODS_NO, ITM_NO, + ORD_QTY, ORD_AMT, CNCL_QTY, CNCL_AMT, ... +) +WITH DAY_INFO AS ( + SELECT #{agrtDt} AS AGRT_DT +) +SELECT + DI.AGRT_DT, + SDOA.GOODS_NO, + SDOA.ITM_NO, + SUM(SDOA.ORD_QTY), + SUM(SDOA.ORD_AMT), + SUM(SDOA.CNCL_QTY), + SUM(SDOA.CNCL_AMT), + ... +FROM SM_DAYCL_ORD_AGRT SDOA +CROSS JOIN DAY_INFO DI +WHERE SDOA.AGRT_DT = DI.AGRT_DT +GROUP BY SDOA.GOODS_NO, SDOA.ITM_NO +``` + +**특징**: +- 멱등성 자동 보장 (DELETE 후 재적재) +- SQL 한 방으로 집계+적재 — Tasklet에서 실행 +- Additive Measure 원칙: `ORD_QTY`와 `CNCL_QTY` 분리 저장 + +### 패턴 2: INSERT...SELECT + ON DUPLICATE KEY UPDATE (가장 복잡, 3개 Job) + +```sql +-- SmDayclOrdAgrtTrxMapper.xml (OrderSaleStatistics, ~410줄) +WITH DAY_INFO AS ( + SELECT CASE WHEN ... END AS AGRT_STD_DT, + CASE WHEN ... END AS AGRT_DT +), +BNF_INFO AS ( + SELECT ORD_NO, ORD_SEQ, SUM(BNF_AMT) AS TOT_BNF_AMT + FROM SM_ORD_BNF_RELS + GROUP BY ORD_NO, ORD_SEQ +), +ORD_DTL_INFO AS ( + SELECT ... FROM SM_ORD_DTL_INFO + JOIN product, member, MD info + WHERE order_date BETWEEN ... +) +INSERT INTO SM_DAYCL_ORD_AGRT ( + AGRT_STD_DT, AGRT_DT, AGRT_GB, ORD_NO, ORD_SEQ, ORD_PROC_SEQ, + GOODS_NO, ITM_NO, ORD_QTY, ORD_AMT, ... +) +-- 4개 UNION ALL: 주문접수/주문완료 × 정상/취소 +SELECT ... FROM ORD_DTL_INFO WHERE ORD_PROC_STAT_CD = '10' -- 주문접수 +UNION ALL +SELECT ... FROM ORD_DTL_INFO WHERE ORD_PROC_STAT_CD = '30' -- 주문완료 +UNION ALL +SELECT ... FROM ORD_DTL_INFO WHERE CNCL conditions -- 취소 +UNION ALL +SELECT ... FROM ORD_DTL_INFO WHERE CNCL + 완료 conditions -- 취소+완료 +ON DUPLICATE KEY UPDATE + AGRT_DT = VALUES(AGRT_DT), + ORD_QTY = VALUES(ORD_QTY), + ORD_AMT = VALUES(ORD_AMT), + ... +``` + +**특징**: +- 3개 CTE + 4개 UNION ALL로 다차원 집계 +- Late-Arriving Fact 대응: 주문접수일(AGRT_STD_DT) vs 실제발생일(AGRT_DT) 이중 기록 +- PK 충돌 시 UPDATE — 증분 갱신에 적합 +- `ORD_QTY`와 `CNCL_QTY` 분리 (Additive Measure) + +### 패턴 3: SELECT → Java 루프 → 벌크 INSERT (AggregateBasket) + +```java +// Java +List list = mapper.getBasketAgrtList(param); // CTE + GROUP BY + ROW_NUMBER() +trxMapper.deleteAll(); // 전체 삭제 +trxMapper.insertBulkSmBasketAgrt(list); // foreach INSERT +``` + +--- + +## 7. UPSERT SQL 실물 (gddp/GoodsReviewTotal) + +### 집계 SELECT (Reader) + +```xml + +``` + +### UPSERT (Writer) + +```xml + + INSERT INTO pr_goods_rev_agrt_info ( + GOODS_NO, REV_CNT, HLPFUL_CNT, REV_STARSCR_AVG_VAL, + SYS_REG_ID, SYS_REG_DTM, SYS_MOD_ID, SYS_MOD_DTM + ) VALUES ( + #{goodsNo}, + CAST(#{revCnt} AS SIGNED), + CAST(#{hlpfulCnt} AS SIGNED), + CAST(#{revScrValAvgVal} AS DECIMAL(10,2)), + #{sysRegId}, now(), #{sysModId}, now() + ) + ON DUPLICATE KEY UPDATE + REV_CNT = CAST(#{revCnt} AS SIGNED), + HLPFUL_CNT = CAST(#{hlpfulCnt} AS SIGNED), + REV_STARSCR_AVG_VAL = CAST(#{revScrValAvgVal} AS DECIMAL(10,2)), + SYS_MOD_ID = #{sysModId}, + SYS_MOD_DTM = now() + +``` + +### 후처리: 전시 요약 테이블 동기화 (CTE + Batch UPDATE) + +```xml + + WITH REV_SUMMARY_INFO_LIST (GOODS_NO, REV_CNT, HLPFUL_CNT, SUM_SCR_VAL, REV_SCR_VAL_AVG_VAL) AS ( + + SELECT #{item.goodsNo}, CAST(#{item.revCnt} AS SIGNED), + CAST(#{item.hlpfulCnt} AS SIGNED), CAST(#{item.sumScrVal} AS SIGNED), + CAST(#{item.revScrValAvgVal} AS SIGNED) + + ) + UPDATE pr_disp_goods_sumr_info PDGSI + JOIN REV_SUMMARY_INFO_LIST RSII ON RSII.GOODS_NO = PDGSI.GOODS_NO + SET GOODS_REV_CNT = RSII.REV_CNT, + GOODS_REV_HLPFUL_CNT = RSII.HLPFUL_CNT, + GOODS_REV_STARSCR_AVG_VAL = RSII.REV_SCR_VAL_AVG_VAL, + SYS_MOD_ID = 'BATCH', SYS_MOD_DTM = NOW() + +``` + +**참고 포인트**: +- `batchTyp` 파라미터로 증분(R/D/M) vs 전체(ALL) 선택 — 내 과제에서 `scope` 파라미터와 유사 +- `LEFT JOIN LATERAL` — 상관 서브쿼리 패턴 +- `foreach + CTE + JOIN UPDATE` — 벌크 UPDATE 패턴 (행 단위 루프 대신) + +--- + +## 8. 커스텀 Lambda Writer (REST API 호출) 패턴 + +```java +// SearchProductChunkLoadConfig — CHUNK_SIZE=2000, BATCH_SIZE=200 +private static final int CHUNK_SIZE = 2000; +private static final int BATCH_SIZE = 200; + +@Bean +@StepScope +public ItemWriter searchProductChunkLoadWriter() { + return items -> { + List subList = new ArrayList<>(BATCH_SIZE); + for (SearchProductLoadRequest item : items) { + subList.add(item); + if (subList.size() == BATCH_SIZE) { + callSearchLoadApi(subList, item.getLangCd()); + subList.clear(); + } + } + if (!subList.isEmpty()) { + callSearchLoadApi(subList, subList.get(0).getLangCd()); + } + }; +} + +private void callSearchLoadApi(List subList, String langCd) { + Map requestData = new HashMap<>(); + requestData.put("langCd", langCd); + requestData.put("data", subList); + restApiUtil.post(searchApiUrl + "index/goods", requestData, + new ParameterizedTypeReference>() {}); + requestData.clear(); +} +``` + +**참고 포인트**: +- Chunk(2000) 내에서 다시 서브 배치(200)로 분할 — API 호출 시 페이로드 크기 제어 +- Lambda Writer로 DB가 아닌 외부 시스템에 쓰기 + +--- + +## 9. @StepScope + JobParameter 주입 패턴 + +```java +// Reader에서 파라미터 주입 +@Bean +@StepScope +public MyBatisCursorItemReader searchProductChunkLoadReader( + @Value("#{jobParameters[intervalTime]}") String intervalTime, + @Value("#{jobParameters[siteNo]}") String siteNo, + @Value("#{jobParameters[langCd]}") String langCd) { + + SearchCommonParam commonParam = new SearchCommonParam(); + this.getBatchType(intervalTime, commonParam); + commonParam.setSiteNo(siteNo); + if (langCd != null && !langCd.isEmpty()) commonParam.setLangCd(langCd); + + Map paramMap = this.getSearchCommonParam(commonParam); + return new MyBatisCursorItemReaderBuilder() + .sqlSessionFactory(searchRodbSqlSessionFactory) + .queryId("...SearchMapper.getProductLoadInfoNew") + .parameterValues(paramMap) + .build(); +} + +// Step에서 파라미터 주입 +@Bean +@JobScope +public Step mileageRemoveStep(@Value("#{jobParameters[batchDate]}") String batchDate) { + this.batchDate = StringUtil.nvl(batchDate, DateUtil.today(X2Constants.YYYYMMDD)); + if (DateUtil.compareWithToday(this.batchDate) > 0) { + throw new ValidationException("배치 처리 일자는 현재 일자보다 클 수 없습니다."); + } + return new StepBuilder("mileageRemoveStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(mileageExpireListItemReader()) + .processor(mileageExpireListItemProcessor()) + .writer(mileageExpireCompositeItemWriter()) + .build(); +} +``` + +**참고 포인트**: +- `@StepScope` Bean에서 `@Value("#{jobParameters[...]}")` 사용 +- Step에서 파라미터 검증 (날짜 유효성 체크) +- `null` 체크 후 기본값 설정 패턴 + +--- + +## 10. Composite VO Processor 패턴 (mbod/MileageRemoveConfig) + +> Reader 결과 1건 → Processor에서 여러 도메인 객체 생성 → CompositeItemWriter가 각각 처리 + +### 구조 + +``` +[Reader: MbrAsstResponse] ← 만료 마일리지 1건 읽기 + │ +[Processor: 복합 변환] + │ ├── EtMbrAstMgrHist (INSERT용) ← 소멸 이력 생성 + │ ├── EtMbrAstMgrHist (UPDATE용) ← 기존 이력 마감 + │ └── MeMbrAstSum ← 잔액 합계 갱신 + │ +[MileageExpireRequestVo] ← 3개 객체를 감싸는 Composite VO + │ +[CompositeItemWriter] + ├── Writer1: UPDATE (기존 이력 마감) + ├── Writer2: INSERT (소멸 이력 생성) + └── Writer3: UPDATE (잔액 합계) +``` + +### Composite VO + +```java +@Getter @Setter +public class MileageExpireRequestVo extends BaseCommonEntity { + private MeMbrAstSum meMbrAstSum; // 잔액 합계 + private EtMbrAstMgrHist insertEtMbrAstMgrHist; // INSERT용 + private EtMbrAstMgrHist updateEtMbrAstMgrHist; // UPDATE용 +} +``` + +### Processor 핵심 로직 + +```java +@Bean +public ItemProcessor mileageExpireListItemProcessor() { + return item -> { + // 1. 트랜잭션 ID 생성 + String astMgrNo = DateUtil.getToday("yyyyMMdd") + .concat("E") + .concat(DateTimeUtil.getFormatString("HHmmss.SSS")); + + // 2. INSERT 엔티티 생성 (소멸 이력) + EtMbrAstMgrHist insertHist = new EtMbrAstMgrHist(item.getValiStrDt(), item.getValiEndDt()); + insertHist.createInsertUseMlg( + item.getMbrNo(), + item.createMileageSaveUse(ME015.MILEAGE, ME016.USE, ME020.EXPIRE, astMgrNo), + item.getAstMgrSeq()); + + // 3. UPDATE 엔티티 생성 (기존 이력 마감) + EtMbrAstMgrHist updateHist = EtMbrAstMgrHist.createUpdateUseMlg(item); + + // 4. 잔액 합계 갱신 엔티티 + MeMbrAstSum summary = new MeMbrAstSum().createUptMeMbrAstSum(insertHist); + + // 5. Composite VO에 래핑 + MileageExpireRequestVo vo = new MileageExpireRequestVo(); + vo.setSysRegId("BATCH"); + vo.setSysModId("BATCH"); + vo.setInsertEtMbrAstMgrHist(insertHist); + vo.setUpdateEtMbrAstMgrHist(updateHist); + vo.setMeMbrAstSum(summary); + return vo; + }; +} +``` + +**내 과제 시사점**: +- 내 과제에서는 Reader 결과(메트릭 합계) → Processor(score 계산) → 단일 Writer(INSERT)이므로 Composite VO까지는 불필요 +- 하지만 향후 "MV 적재 + Redis 갱신"을 동시에 해야 한다면 이 패턴이 유용 + +--- + +## 11. ExecutionContext 기반 재시작/재개 패턴 (gddp/SearchProductIndex) + +> 대량 데이터 처리 시 장애가 발생하면, 처리 완료된 청크를 건너뛰고 실패 지점부터 재개하는 패턴 + +### 커스텀 MyBatisPagingItemReader + +```java +MyBatisPagingItemReader reader = new MyBatisPagingItemReader<>() { + private int currentPage = 0; + private Map parameterValues; + + @Override + public void open(ExecutionContext executionContext) { + super.open(executionContext); + // 재시작 시 이전 위치 복원 + if (executionContext.containsKey("currentPage")) { + currentPage = executionContext.getInt("currentPage"); + } + if (parameterValues == null) { + parameterValues = new HashMap<>(); + parameterValues.put("limit", DEFAULT_PAGE_SIZE); // 10,000 + parameterValues.put("offset", currentPage * DEFAULT_PAGE_SIZE); + setParameterValues(parameterValues); + } + } + + @Override + protected void doReadPage() { + parameterValues.put("offset", currentPage * DEFAULT_PAGE_SIZE); + currentPage++; + super.doReadPage(); + } + + @Override + public void update(ExecutionContext executionContext) { + super.update(executionContext); + // 청크 완료 후 현재 페이지 저장 + executionContext.putInt("currentPage", currentPage); + } +}; +``` + +### 재시작 흐름 + +``` +최초 실행: + Chunk 1: offset=0 → 0~9,999 ✓ → save currentPage=1 + Chunk 2: offset=10,000 → 10,000~19,999 ✓ → save currentPage=2 + Chunk 3: offset=20,000 → 20,000~29,999 ✗ FAILURE (DB 커넥션 에러) + → ExecutionContext에 currentPage=2 저장됨 + +재시작: + open() → executionContext에서 currentPage=2 복원 + Chunk 3: offset=20,000 → 20,000~29,999 ✓ → 실패 지점부터 재개 + Chunk 4: offset=30,000 → ... +``` + +### 페이지네이션 SQL + +```sql +SELECT ... FROM PR_GOODS_SEARCH_INTF PGSI +WHERE INDEX_YN = 'N' AND DISP_CTG_NO IS NOT NULL +ORDER BY SYS_MOD_DTM, ID -- 결정적 정렬: 재시작 시 동일 결과 보장 +LIMIT #{limit} OFFSET #{offset} +``` + +**내 과제 시사점**: +- 내 과제의 MV 적재는 상품 수가 수천~수만 수준이므로 재시작 패턴까지는 불필요 +- 하지만 `JdbcCursorItemReader`는 기본적으로 ExecutionContext에 read count를 저장하므로, Spring Batch의 재시작 메커니즘이 자동으로 동작함 +- 상품 10만 건 이상 규모에서는 이 패턴을 고려할 가치 있음 + +--- + +## 12. 회사 vs 내 프로젝트 application.yml 비교 + +### 핵심 차이점 + +| 설정 | 회사 배치 앱 | commerce-batch | 조치 필요 여부 | +|------|------------|---------------|--------------| +| **spring.batch.job.enabled** | `false` (수동 트리거) | 미설정 (기본값 true) | 기존 Job이 있으므로 이미 `${job.name:NONE}`으로 제어 중. 현행 유지 | +| **graceful shutdown** | `server.shutdown: graceful`, 타임아웃 24h | 미설정 | 배치 Job이 중간에 끊기면 데이터 정합성 문제. 설정 추가 권장 | +| **thread pool** | max-size: 50, queue: 100 | 미설정 (기본 8스레드) | 현재 단일 Job 실행이므로 당장은 불필요. 병렬 Step 사용 시 필요 | +| **connection-timeout** | 30~90s (환경별), 검색 500~800s | 3s (jpa.yml) | 집계 쿼리가 3초 이내면 문제없음. GROUP BY 성능 테스트 후 판단 | +| **RODB/RWDB 분리** | 5~6쌍 | 단일 DataSource | 현재 규모에서 불필요. 설계 문서에 스케일아웃 시 분리 방안 언급만 | +| **@EnableBatchProcessing** | 명시적 DataSource 지정 | 자동 구성 | Spring Boot 3.x는 자동 구성이 기본. 현행 유지 | + +### commerce-batch 현재 설정 (확인된 내용) + +```yaml +spring: + batch: + job: + names: ${job.name:NONE} # Job 이름으로 실행 제어 + jdbc: + initialize-schema: never # 운영: 수동 관리 + # local/test: always # 프로파일별 분기 + + config: + import: + - jpa.yml # HikariCP, JPA 설정 + - redis.yml # Redis Master-Replica + - logging.yml # 로깅 + - monitoring.yml # Prometheus + Actuator +``` + +**결론**: 현재 설정으로 과제 수행에 문제 없음. graceful shutdown만 선택적으로 추가. + +--- + +## 13. 내 과제에 적용할 패턴 요약 + +| 내 과제 구성 요소 | 참고할 회사 코드 | 핵심 패턴 | +|------------------|----------------|----------| +| **Job 구성** | MemberGradeChangeConfig | Multi-Step + `.on("FAILED").end()` | +| **Reader** | SampleJdbcConfig | `JdbcCursorItemReaderBuilder` + `BeanPropertyRowMapper` | +| **Processor** | MemberGradeChangeConfig | DTO 변환 (Response → Request) | +| **Writer** | SampleJdbcConfig | `JdbcBatchItemWriterBuilder` + `itemPreparedStatementSetter` | +| **Cleanup Step** | SmDayclGoodsOrdAgrtTrxMapper | `DELETE WHERE period_key = ?` (Tasklet) | +| **파라미터 주입** | SearchProductChunkLoadConfig | `@StepScope` + `@Value("#{jobParameters[...]}")` | +| **중복 실행 방지** | SingleJobExecutionListener | `JobExplorer.findRunningJobExecutions()` | +| **Score 계산** | RankingCorrectionJobConfig (기존) | Score v2 공식 재활용 | +| **Composite VO** | MileageRemoveConfig | 여러 엔티티를 하나의 VO에 래핑 (향후 확장 시) | +| **재시작/재개** | SearchProductIndexConfig | ExecutionContext에 진행 상태 저장 (대량 데이터 시) | diff --git a/docs/design/volume-10/10-batch-ranking-system.md b/docs/design/volume-10/10-batch-ranking-system.md new file mode 100644 index 0000000000..0220022805 --- /dev/null +++ b/docs/design/volume-10/10-batch-ranking-system.md @@ -0,0 +1,616 @@ +# 10. 배치 랭킹 시스템 설계 — MV 기반 주간/월간 랭킹 + +> Spring Batch로 product_metrics를 기간 집계하여 MV 테이블에 TOP 100 랭킹을 적재하고, +> API에서 주간/월간 요청 시 MV를 primary 소스로 조회하는 시스템. + +--- + +## 요구사항 + +### 과제 요구사항 (10-batch-ranking-quests.md) + +| # | 요구사항 | 상세 | Checklist | +|---|---------|------|-----------| +| **R1** | Spring Batch Job 구현 | `product_metrics`를 **Chunk-Oriented** 방식으로 집계 처리 | Job을 작성하고 **파라미터 기반**으로 동작시킬 수 있다 | +| **R2** | Materialized View 설계 | `mv_product_rank_weekly` (주간 TOP 100), `mv_product_rank_monthly` (월간 TOP 100) | MV 구조를 설계하고 **올바르게 적재**했다 | +| **R3** | Ranking API 확장 | 기존 `GET /api/v1/rankings`에서 **기간 정보**를 받아 일간/주간/월간 랭킹 제공 | 조회 형태에 따라 **적절한 데이터 소스** 기반 랭킹 제공 | +| **R4** | Technical Writing | 블로그 + 10주 회고 (TL;DR 포함, "왜 그렇게 판단했는가" 중심) | — | + +### 기존 구현 현황 (Round 9) + +| 항목 | 상태 | 비고 | +|------|------|------| +| commerce-batch 모듈 | ✅ | 6개 Job 운영 중 | +| product_metrics 테이블 | ✅ | PK: (product_id, metric_date), daily grain | +| RankingCorrectionJob | ✅ | Chunk 1,000, JdbcCursorItemReader → Redis | +| Redis 일간/주간/월간 ZSET | ✅ | carry-over + ZUNIONSTORE | +| Ranking API (scope 파라미터) | ✅ | daily/weekly/monthly → **모두 Redis 조회** | +| MV 테이블 | ❌ | **Round 10 핵심 과제** | + +--- + +## 설계 결정 요약 + +| 질문 | 결정 | 근거 | +|------|------|------| +| 시간 윈도우 | **슬라이딩 윈도우 (매일 갱신)** | Redis weekly와 동일한 시간 범위. 무신사 방식. 사용자에게 매일 갱신되는 랭킹 제공 | +| Score 계산 방식 | **방식 A — 메트릭 균등 합산 후 score 1회 계산** | MV는 "기간 총 실적" 관점. Redis(지수 감쇠)와 다른 관점을 제공하는 것이 MV의 존재 이유 | +| Reader | **JdbcCursorItemReader + Partitioning** | GROUP BY 집계에서 Paging은 페이지마다 재실행하므로 부적합. Cursor의 멀티스레드 한계를 Partitioning으로 극복 | +| 비즈니스 로직 위치 | **Reader SQL에서 집계 + score 계산** | DB의 LOG10/GROUP BY/ORDER BY를 활용. Processor는 pass-through | +| Writer 전략 | **DELETE + INSERT (스테이징 경유)** | 병렬 집계 → 스테이징 → mergeStep에서 Global TOP 100 | +| 멱등성 | **cleanup(DELETE MV + 스테이징) → 전체 재실행** | 스테이징 정합성을 위해 부분 재실행보다 전체 재실행이 안전 | +| Job Instance 동일성 | **RunIdIncrementer** | targetDate, scope 파라미터 보존 + run.id 증가로 재실행 허용. cleanupStep이 멱등성 보장 | +| Redis vs MV 역할 | **daily → Redis, weekly/monthly → MV 단일 소스 (Redis fallback 없음)** | 다른 공식(감쇠 vs 균등)으로 계산한 결과를 fallback으로 쓰면 데이터 일관성이 깨짐. MV 배치 실패 시에는 "빈 결과 + 알림"이 "다른 순위 노출"보다 안전 | +| Job 구조 | **scope 파라미터로 주간/월간 분기하는 단일 Job** | Job Config 중복 방지. 회사 코드의 batchTyp 패턴 참고 | + +--- + +## 시간 윈도우 전략 + +### 슬라이딩 윈도우 (매일 갱신) + +MV는 캘린더 기반(월~일, 1일~말일)이 아닌, **매일 갱신되는 슬라이딩 윈도우**로 집계한다. + +``` +targetDate = 2026-04-16 기준: + +주간: 2026-04-10 ~ 2026-04-16 (최근 7일) + ├─ 다음날 실행 시: 2026-04-11 ~ 2026-04-17 (1일 슬라이드) + └─ 매일 갱신되어 "오늘 기준 최근 7일" 유지 + +월간: 2026-03-18 ~ 2026-04-16 (최근 30일) + ├─ 다음날 실행 시: 2026-03-19 ~ 2026-04-17 (1일 슬라이드) + └─ 매일 갱신되어 "오늘 기준 최근 30일" 유지 +``` + +**선택 근거**: +- Redis weekly도 슬라이딩 7일 (ZUNIONSTORE 최근 7일 daily). MV와 시간 범위가 일치해야 fallback이 의미 있음 +- 무신사 등 이커머스에서 주간/월간 랭킹도 매일 갱신하는 것이 UX에 유리 +- period_key는 targetDate 자체 (`20260416`) — "이 날짜 기준 최근 N일" 의미 + +### Redis monthly(지수 감쇠)와 MV monthly(균등 합산)의 차이 + +Redis monthly는 **지수 감쇠** 방식이다: + +``` +내일_monthly = 오늘_monthly × 0.97 + 오늘_daily × 1.0 +``` + +이를 30일간 풀어쓰면: + +``` +monthly = Σ(i=0 ~ 29) daily_(today-i) × 0.97^i + += daily_today × 0.97⁰ (= 1.000) ++ daily_1일전 × 0.97¹ (= 0.970) ++ daily_2일전 × 0.97² (= 0.941) ++ ... ++ daily_29일전 × 0.97²⁹ (= 0.413) +``` + +**주의**: Redis는 "딱 30일"이 아니라 서비스 시작 이후 **모든 날**이 반영된다. +다만 0.97을 계속 곱하므로 오래된 날일수록 가중치가 0에 수렴한다. +가중치가 절반이 되는 데 걸리는 일수(**반감기**) ≈ 23일 (`ln(0.5) / ln(0.97) ≈ 22.8`). + +``` +일수 가중치(0.97^i) 누적 기여 + 0일 1.000 5.0% (오늘) + 6일 0.833 32.9% ← 최근 7일이 전체의 1/3 +13일 0.673 57.4% ← 최근 14일이 전체의 57% +22일 0.502 80.2% ← 반감기: 23일 전 = 50% +29일 0.413 100.0% +``` + +**MV 방식 A(균등 합산)와의 차이**: + +``` +Redis monthly (지수 감쇠, 윈도우 없음): + 상품 A: 30일 전 매출 1000만원 → 가중치 0.40으로 반영 + 상품 B: 오늘 매출 1000만원 → 가중치 1.00으로 반영 + → B가 유리 (최근 활동 우대) + +MV 방식 A (균등 합산, 30일 고정 윈도우): + 상품 A: 30일 전 매출 1000만원 → 가중치 1.0 + 상품 B: 오늘 매출 1000만원 → 가중치 1.0 + → 동일 (기간 내 총량만 평가) +``` + +**두 방식은 관점이 다르다:** +- Redis: "최근에 뜨는 상품" (트렌드) +- MV: "기간 총 실적이 높은 상품" (누적 성과) + +MV가 Redis와 동일한 지수 감쇠를 쓰면 MV를 만들 이유가 없다. 다른 관점을 제공하는 것이 MV의 존재 가치다. + +--- + +## 아키텍처 + +### 전체 데이터 흐름 + +``` +[product_metrics (DB 원장, daily grain)] + │ + │ Reader: GROUP BY product_id, SUM(최근 7일 or 30일) + ▼ +[상품별 기간 메트릭 균등 합계] + │ + │ Processor: Score v2 공식 (log₁₀ 정규화 + tiebreaker) + ▼ +[상품별 score → 정렬 → TOP 100] + │ + │ Writer: DELETE period_key → INSERT TOP 100 + ▼ +[mv_product_rank_weekly / mv_product_rank_monthly] + │ + │ API: SELECT WHERE period_key = ? ORDER BY ranking + ▼ +[클라이언트] +``` + +### Redis와 MV의 역할 분담 — 단일 소스 원칙 + +``` +[API 요청] + │ + ├── scope=daily → Redis ZSET (단일 소스) + │ + ├── scope=weekly → MV 테이블 (단일 소스, 균등 합산) + │ + └── scope=monthly → MV 테이블 (단일 소스, 균등 합산) +``` + +**Redis fallback을 두지 않는 이유**: Redis(지수 감쇠)와 MV(균등 합산)는 다른 공식으로 계산하므로 같은 기간에 대해 순위가 다르다. MV 배치 실패 시 Redis fallback으로 전환하면 "어제는 A가 1위, 오늘은 B가 1위"라는 데이터 불일치가 발생한다. + +**전일 MV fallback**: 당일 MV가 없으면 전일 MV를 반환한다. 같은 공식, 같은 소스에서 계산한 결과이므로 데이터 불일치가 아니라 1일 시간 지연일 뿐이다 (7일 중 6일 겹침). MV는 carry-over(누적)가 아니라 매번 원장에서 기간 전체를 새로 집계하므로, 전일 MV는 독립적으로 계산된 정확한 결과다. + +**데이터 보존 정책**: cleanupStep에서 당일 period_key만 삭제하고, 3일 이전 데이터를 별도 정리한다. 전일/전전일 MV가 fallback으로 사용 가능하도록 보존. + +**기존 Redis weekly/monthly**: MV 도입 검증 완료 후 carry-over 스케줄러에서 weekly/monthly 생성 로직 제거. daily carry-over만 유지. + +### 전체 재계산 vs 증분 계산 — 왜 매번 원장에서 새로 계산하는가 + +MV는 매일 원장(product_metrics)에서 기간 전체를 GROUP BY로 새로 집계한다. "어제 결과에서 가장 오래된 날을 빼고 오늘을 더하는" 증분 방식이 더 효율적이지 않은가? + +**증분 계산을 채택하지 않은 이유: Late-Arriving Fact** + +이커머스에서 주문 취소/환불은 원주문과 다른 날에 발생한다. product_metrics의 cancel_by_order_date는 원주문 날짜의 행에 기록되므로, **이미 지나간 날의 데이터가 사후에 변경된다**: + +``` +4/10: 상품 A 주문 100건 (1000만원) +4/15: 그 중 30건 취소 → product_metrics 4/10 행의 cancel_by_order_date 갱신 + +증분 계산: 4/10의 값은 이미 어제 MV에 반영됨 → 사후 변경을 감지 못함 +전체 재계산: 4/10~4/16 전체를 다시 읽으므로 → 변경된 값이 자동 반영 +``` + +| 시나리오 | 전체 재계산 | 증분 계산 | +|---------|-----------|----------| +| 정상 주문 | ✅ 정확 | ✅ 정확 | +| 지연 취소 (주문 후 며칠 뒤) | ✅ 자동 반영 | ❌ 원주문 날짜 변경 감지 못함 | +| 운영팀 데이터 보정 | ✅ 다음 배치 자동 반영 | ❌ 전체 재계산을 별도 실행해야 함 | +| 오류 전파 | ❌ 없음 | ⚠️ 어제 MV가 틀리면 오늘도 틀림 | + +증분 계산은 "과거 데이터가 불변"이라는 전제가 필요하다. product_metrics의 Late-Arriving Fact 설계가 이 전제를 깨뜨리므로, 전체 재계산이 이커머스 랭킹에 더 적합하다. + +성능 차이(Partitioning 4 Worker 기준 ~10초 vs ~3초)는 1일 1회 배치에서 운영 영향이 없다. 증분이 유리해지는 전환점은 배치 주기가 5분 이하로 빈번해질 때다. + +--- + +## MV 테이블 스키마 + +### mv_product_rank_weekly + +```sql +CREATE TABLE mv_product_rank_weekly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + ranking INT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + period_key VARCHAR(8) NOT NULL, -- '20260416' (targetDate, 슬라이딩 윈도우 기준일) + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_period_ranking (period_key, ranking) +) ENGINE=InnoDB; +``` + +### mv_product_rank_monthly + +```sql +CREATE TABLE mv_product_rank_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + ranking INT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + period_key VARCHAR(8) NOT NULL, -- '20260416' (targetDate, 슬라이딩 윈도우 기준일) + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_period_ranking (period_key, ranking) +) ENGINE=InnoDB; +``` + +**설계 판단**: +- **PK**: AUTO_INCREMENT id. DELETE+INSERT 전략이므로 단순한 PK가 유리 +- **period_key**: targetDate 문자열 (`20260416`). "이 날짜 기준 최근 7일/30일" 의미. 슬라이딩 윈도우이므로 매일 새로운 period_key 생성 +- **인덱스**: `(period_key, ranking)` — API 조회 패턴 `WHERE period_key = ? ORDER BY ranking` 에 최적화 +- **개별 메트릭 저장**: score뿐 아니라 view_count, like_count 등도 저장 — 분석/디버깅 및 향후 다차원 정렬 확장 용도 +- **이전 기간 데이터**: 당일 기준 period_key만 유지. 이전 날짜 데이터는 CleanupTasklet에서 삭제 (또는 보존 후 별도 정리 Job) + +--- + +## Spring Batch Job 설계 + +### 설계 판단의 흐름 + +1. Chunk vs Tasklet → **Chunk**: 프레임워크 운영 기능(retry, 모니터링, restart) 활용 +2. CursorReader vs PagingReader → **CursorReader**: GROUP BY 집계 쿼리에서 Paging은 페이지마다 집계를 재실행하므로 부적합 +3. CursorReader는 멀티스레드 불가(ResultSet 공유 상태) → **Partitioning**: CursorReader의 장점(1회 쿼리)을 유지하면서 병렬 처리 +4. Partitioning + Global TOP 100 → **3-Step 구조**: 병렬 집계(스테이징) → 글로벌 머지(TOP 100) + +### Job 구조 (Partitioning + Map-Reduce) + +``` +ProductRankingMvJob + ├── Parameter: targetDate (yyyyMMdd), scope (weekly|monthly) + │ + ├── Step 1: cleanupStep (Tasklet) + │ └── DELETE FROM mv_product_rank_{scope} WHERE period_key = :periodKey + │ └── DELETE FROM mv_product_rank_staging WHERE period_key = :periodKey + │ └── allowStartIfComplete(true) + │ └── on("FAILED").end() + │ + ├── Step 2: partitionedAggregateStep (Partitioned Chunk, 병렬) + │ │ + │ │ [Partitioner] product_id 범위를 gridSize(기본 4)개로 분할 + │ │ TaskExecutor: SimpleAsyncTaskExecutor (gridSize 스레드) + │ │ + │ ├── [Worker 1] product_id :minId ~ :maxId + │ │ ├── Reader: JdbcCursorItemReader (GROUP BY + score, 해당 범위만, LIMIT 없음) + │ │ ├── Processor: pass-through + │ │ ├── Writer: JdbcBatchItemWriter → 스테이징 테이블 INSERT + │ │ └── faultTolerant + retry(3) + ExponentialBackOffPolicy + │ │ + │ ├── [Worker 2] ... (동일 구조, 다른 범위) + │ ├── [Worker 3] ... + │ └── [Worker N] ... + │ + └── Step 3: mergeStep (Tasklet) + └── INSERT INTO mv_product_rank_{scope} + SELECT ..., ROW_NUMBER() OVER (ORDER BY score DESC) AS ranking + FROM mv_product_rank_staging + WHERE period_key = :periodKey + ORDER BY score DESC + LIMIT 100 +``` + +### 왜 Partitioning인가 + +**요구사항**: "대량의 데이터를 읽고 처리할 수 있도록 구성" + +쿠팡급(상품 100만, 30일치 3,000만 행) 기준 성능: + +| 구조 | GROUP BY 실행 | 소요 시간 | +|------|-------------|----------| +| 단일 CursorReader | 3,000만 행 1회 | ~30초 | +| **Partitioning (4 Worker)** | 각 750만 행 × 4 병렬 | **~10초** (3배 빠름) | +| Partitioning (10 Worker) | 각 300만 행 × 10 병렬 | **~5초** (6배 빠름) | + +CursorReader의 장점(GROUP BY 1회 실행)을 유지하면서, 데이터를 product_id 범위로 분할하여 병렬 처리한다. PagingReader로 전환하면 페이지마다 GROUP BY를 재실행하는 문제가 생기지만, Partitioning은 각 Worker가 **독립 커넥션 + 독립 CursorReader**를 가지므로 이 문제가 없다. + +### 왜 Chunk인가 — 프레임워크 운영 기능 활용 + +이 작업은 Tasklet(INSERT INTO...SELECT)으로도 가능하고, 네트워크 효율만 따지면 Tasklet이 우위다. chunk를 선택하면 Spring Batch가 제공하는 운영 기능을 활용할 수 있다: + +- **faultTolerant + retry + ExponentialBackOffPolicy**: 일시적 DB 에러(데드락, 커넥션 타임아웃) 시 자동 재시도. 100ms → 200ms → 400ms 간격으로 재시도하여 락 해소 시간 확보 +- **StepExecution 자동 기록**: 각 Worker별 readCount, writeCount 자동 추적 +- **StepMonitorListener**: Worker 실패 시 알림 +- **Partitioned restart**: 실패한 파티션만 재실행 가능 + +### 스테이징 테이블 + +```sql +CREATE TABLE mv_product_rank_staging ( + product_id BIGINT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + period_key VARCHAR(8) NOT NULL, + PRIMARY KEY (product_id, period_key) +) ENGINE=InnoDB; +``` + +각 Worker가 자기 범위의 전체 집계 결과를 스테이징에 적재. PK가 `(product_id, period_key)`이므로 Worker 간 충돌 없음 (product_id 범위가 겹치지 않으므로). + +### Worker Reader SQL (파티션별) + +```sql +SELECT + pm.product_id, + SUM(pm.view_count) AS total_view_count, + SUM(pm.like_count - pm.unlike_count) AS total_net_like_count, + SUM(pm.sales_count) AS total_sales_count, + SUM(pm.sales_amount - pm.cancel_amount_by_event_date) AS total_net_sales_amount, + ( + 0.1 * LOG10(GREATEST(SUM(pm.view_count), 0) + 1) / 7.0 + + 0.2 * LOG10(GREATEST(SUM(pm.like_count - pm.unlike_count), 0) + 1) / 7.0 + + 0.7 * LOG10(GREATEST(SUM(pm.sales_amount - pm.cancel_amount_by_event_date), 0) + 1) / 7.0 + + UNIX_TIMESTAMP() * 1e-16 + ) AS score +FROM product_metrics pm +JOIN product p ON pm.product_id = p.id +WHERE pm.metric_date BETWEEN :startDate AND :endDate + AND pm.product_id BETWEEN :minProductId AND :maxProductId + AND p.deleted_at IS NULL +GROUP BY pm.product_id +``` + +- **주간**: `startDate = targetDate - 6`, `endDate = targetDate` (7일) +- **월간**: `startDate = targetDate - 29`, `endDate = targetDate` (30일) +- **LIMIT 없음**: 각 파티션의 전체 결과를 스테이징에 적재. 글로벌 TOP 100은 mergeStep에서 결정 +- **product_id BETWEEN**: Partitioner가 할당한 범위만 처리 + +### mergeStep SQL + +```sql +INSERT INTO mv_product_rank_{scope} + (product_id, ranking, score, view_count, like_count, sales_count, sales_amount, period_key, created_at) +SELECT + product_id, + ROW_NUMBER() OVER (ORDER BY score DESC) AS ranking, + score, view_count, like_count, sales_count, sales_amount, :periodKey, NOW() +FROM mv_product_rank_staging +WHERE period_key = :periodKey +ORDER BY score DESC +LIMIT 100 +``` + +스테이징에 모인 전체 결과에서 `ROW_NUMBER()`로 글로벌 순위를 부여하고 TOP 100만 MV에 적재. + +### Best Practice 대조 점검 + +| Best Practice | 적용 | 상세 | +|-------------|------|------| +| @StepScope + Late Binding | ✅ | Worker Reader에 minProductId, maxProductId, targetDate, scope 주입 | +| Reader name 설정 | ✅ | 각 Worker별 고유 name. ExecutionContext 저장 시 key | +| Processor에서 DB 수정 금지 | ✅ | pass-through (스테이징 적재는 Writer에서) | +| Writer 벌크 처리 | ✅ | JdbcBatchItemWriter (JDBC batch INSERT) | +| assertUpdates(false) | ✅ | INSERT이므로 | +| ExponentialBackOffPolicy | ✅ | Worker별 데드락 시 간격 두고 재시도 | +| cleanupStep allowStartIfComplete | ✅ | DELETE는 멱등. 재시작 시에도 항상 실행 | +| CursorReader + Partitioning | ✅ | GROUP BY 1회 실행 유지 + 병렬 처리. ResultSet 공유 없음 (Worker별 독립 커넥션) | +| skip policy | 미적용 (의도적) | 집계 결과이므로 데이터 오류 가능성 낮음. 1건 에러 시 해당 파티션 전체 실패가 적절 | + +--- + +## API 확장 + +### 현재 구조 (모두 Redis 조회) + +```java +// RankingFacade — scope별 Redis prefix 분기 +return switch (scope) { + case "weekly" -> WEEKLY_ZSET_PREFIX; // Redis + case "monthly" -> MONTHLY_ZSET_PREFIX; // Redis + default -> DAILY_ZSET_PREFIX; // Redis +}; +``` + +### 변경 후 구조 (weekly/monthly → MV 단일 소스) + +```java +return switch (scope) { + case "daily" -> getFromRedis(DAILY_ZSET_PREFIX, ...); + case "weekly" -> getFromMv("weekly", ...); + case "monthly" -> getFromMv("monthly", ...); +}; +``` + +**MV 조회 흐름**: +1. 당일 period_key로 MV 테이블 조회 +2. 당일 데이터 없으면 → 전일 period_key로 fallback (같은 공식, 1일 stale) +3. 전일도 없으면 → 빈 결과 반환 +4. Product 상세 정보 조합 → 응답 + +**기존 API 시그니처 변경 없음**: `/api/v1/rankings?scope=weekly&date=20260416&size=20&page=0` + +### 필요한 새 컴포넌트 + +| 레이어 | 파일 | 역할 | +|--------|------|------| +| domain | `MvProductRank.java` | MV 엔티티 (@Entity) | +| domain | `MvProductRankRepository.java` | Repository 인터페이스 | +| infrastructure | `MvProductRankJpaRepository.java` | JPA 구현체 | +| application | `RankingFacade.java` (수정) | MV 단일 소스 조회 + 전일 MV fallback | + +--- + +## 실행 전략 + +### 스케줄링 + +| Job | 실행 시점 | 근거 | +|-----|----------|------| +| 주간 MV Job | **매일 01:00** | 전날까지의 7일 데이터 집계. RankingCorrectionJob(1시간 주기)과 시간 분리 | +| 월간 MV Job | **매일 01:30** | 전날까지의 30일 데이터 집계. 주간 Job 완료 후 실행 | + +- 기존 23:50 carry-over 스케줄러와 시간 충돌 없음 +- 매일 실행하여 슬라이딩 윈도우 유지 + +### 실행 명령 + +```bash +# 주간 랭킹 +java -jar commerce-batch.jar --job.name=productRankingMvJob targetDate=20260416 scope=weekly + +# 월간 랭킹 +java -jar commerce-batch.jar --job.name=productRankingMvJob targetDate=20260416 scope=monthly +``` + +--- + +## 파일 구조 + +``` +apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/ + ├── ProductRankingMvJobConfig.java ← Job(3 Step) + Partitioner + Reader + Writer + └── step/ + └── CleanupTasklet.java ← Step 1: DELETE MV + staging + 3일 이전 정리 + +apps/commerce-api/src/main/java/com/loopers/ + ├── domain/ranking/ + │ ├── MvProductRank.java ← MV 엔티티 + │ └── MvProductRankRepository.java ← Repository 인터페이스 + ├── infrastructure/ranking/ + │ └── MvProductRankJpaRepository.java ← JPA 구현체 + └── application/ranking/ + └── RankingFacade.java ← (수정) MV 단일 소스 + 전일 fallback + +apps/commerce-batch/src/test/resources/ + └── schema-batch-test.sql ← DDL (MV + staging 포함) +``` + +--- + +## 구현 순서 + +### Phase 0: 설계 (완료) + +- ✅ 0-1. 아키텍처 결정 — MV 단일 소스 (Redis fallback 없음, 전일 MV fallback) +- ✅ 0-2. MV 스키마 설계 — DDL 확정 (MV weekly/monthly + staging) +- ✅ 0-3. Job 설계 — Partitioning + Map-Reduce (3 Step) +- ✅ 0-4. Score 전략 — 방식 A (균등 합산, 전체 재계산), Reader SQL에서 LOG10 계산 +- ✅ 0-5. 시간 윈도우 — 슬라이딩 윈도우 (매일 갱신) +- ✅ 0-6. 운영 기능 — faultTolerant + retry + ExponentialBackOffPolicy +- ✅ 0-7. 멱등성 — cleanup(DELETE) → 전체 재실행. RunIdIncrementer로 재실행 허용 +- ✅ 0-8. 설계 문서 작성 + +### Phase 1: 배치 Job 구현 → R1, R2 충족 + +| # | 작업 | 상태 | 산출물 | +|---|------|------|--------| +| 1-1 | DDL 작성 | ✅ | `schema-batch-test.sql`에 MV weekly/monthly + staging 추가 | +| 1-2 | CleanupTasklet | ✅ | 당일 MV + staging DELETE + 3일 이전 정리 | +| 1-3 | ProductRankingMvJobConfig | ✅ | 3-Step Job (cleanup → partitioned aggregate → merge) | +| 1-4 | 컴파일 확인 | ✅ | BUILD SUCCESSFUL | + +### Phase 2: API 확장 → R3 충족 + +| # | 작업 | 상태 | 산출물 | +|---|------|------|--------| +| 2-1 | MV 엔티티/리포지토리 | ✅ | `MvProductRank` (MappedSuperclass) + Weekly/Monthly 엔티티 + Repository + JPA 구현체 | +| 2-2 | RankingFacade 수정 | ✅ | daily→Redis, weekly/monthly→MV 단일 소스 + 전일 MV fallback | + +### Phase 3: 테스트 + +| # | 작업 | 상태 | 산출물 | +|---|------|------|--------| +| 3-1 | Job 통합 테스트 | ✅ 코드 작성 | `ProductRankingMvJobE2ETest` — 시드 → Job → MV 결과 검증 | +| 3-2 | 멱등성 테스트 | ✅ 코드 작성 | 같은 파라미터 2회 실행 → MV 결과 동일 | +| 3-3 | 엣지 케이스 | ✅ 코드 작성 | 데이터 없음, 7일 미만, 100개 미만, 취소 반영 | +| 3-4 | 테스트 실행 | ⏳ 보류 | 메모리 부족으로 실행 보류. 아래 실행 가이드 참조 | +| 3-5 | API 통합 테스트 | | MV 조회 + 전일 fallback 동작 검증 (Phase 4에서 수동 검증 가능) | + +**테스트 실행 가이드**: + +```bash +# 사전 조건: Docker 실행 중 (Testcontainers가 MySQL + Redis 컨테이너를 자동 생성) +# JVM 메모리: 최소 1GB 여유 필요 + +# 전체 MV Job 테스트 +./gradlew :apps:commerce-batch:test --tests "com.loopers.job.rankingmv.ProductRankingMvJobE2ETest" + +# 개별 테스트 (메모리 절약) +./gradlew :apps:commerce-batch:test --tests "com.loopers.job.rankingmv.ProductRankingMvJobE2ETest\$WeeklyJob\$success" +``` + +테스트가 실패하면 확인할 것: +- `schema-batch-test.sql`에 product_metrics, MV, staging DDL이 있는지 +- product 테이블에 `category_id` 컬럼이 있는지 (이번에 추가함) +- Testcontainers Docker 접근 가능한지 + +### Phase 4: 시나리오 검증 & 모니터링 + +| # | 작업 | 상태 | 산출물 | +|---|------|------|--------| +| 4-1 | 정상 실행 시나리오 | | 시드 데이터 기반 주간/월간 Job 실행 결과 | +| 4-2 | MV vs Redis 비교 | | 같은 기간 TOP 20 대조, score 차이 분석 | +| 4-3 | 성능 측정 | | Job 실행 시간, 처리 건수, Partitioning 효과 | + +**시나리오 검증 절차**: + +```bash +# 1. 인프라 기동 +docker-compose -f docker/infra-compose.yml up -d + +# 2. commerce-api 실행 +./gradlew :apps:commerce-api:bootRun + +# 3. 시드 데이터 생성 +./scripts/seed-test-data.sh + +# 4. MV 배치 실행 (별도 터미널) +./gradlew :apps:commerce-batch:bootRun --args="--job.name=productRankingMvJob targetDate=20260416 scope=weekly" +./gradlew :apps:commerce-batch:bootRun --args="--job.name=productRankingMvJob targetDate=20260416 scope=monthly" + +# 5. API 검증 +curl "http://localhost:8080/api/v1/rankings?scope=weekly&date=20260416&size=20" +curl "http://localhost:8080/api/v1/rankings?scope=monthly&date=20260416&size=20" +curl "http://localhost:8080/api/v1/rankings?scope=daily&size=20" # 기존 Redis 경로 + +# 6. MV vs Redis 비교 (MySQL 직접 조회) +mysql -u root -p loopers -e "SELECT product_id, ranking, score FROM mv_product_rank_weekly WHERE period_key='20260416' ORDER BY ranking LIMIT 20;" + +# 7. 멱등성 검증: 같은 명령 2회 실행 후 MV 건수 확인 +mysql -u root -p loopers -e "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key='20260416';" + +# 8. 전일 fallback 검증: 존재하지 않는 날짜로 조회 +curl "http://localhost:8080/api/v1/rankings?scope=weekly&date=20260417&size=20" +# → 20260417 데이터 없으면 20260416 데이터가 반환되어야 함 +``` + +### Phase 5: 문서 & PR → R4 충족 + +| # | 작업 | 상태 | 산출물 | +|---|------|------|--------| +| 5-1 | 설계 문서 갱신 | | 구현 결과, 성능 수치, 트레이드오프 반영 | +| 5-2 | PR 작성 | | 변경 요약 + 리뷰 포인트 2~3개 | +| 5-3 | 블로그 + 10주 회고 | | TL;DR 포함, 설계 판단 중심 | + +**PR 리뷰 포인트 후보**: + +1. **Partitioning + CursorReader 조합**: GROUP BY 집계에서 PagingReader 대신 Partitioning을 선택한 이유. CursorReader의 멀티스레드 한계를 어떻게 극복했는가? +2. **MV 단일 소스 원칙**: Redis fallback을 제거하고 전일 MV fallback으로 대체한 판단. 다른 공식의 결과를 같은 API의 fallback으로 쓰면 왜 안 되는가? +3. **전체 재계산 vs 증분 계산**: Late-Arriving Fact(지연 취소)로 인해 증분이 부적합한 이유. 성능 차이(10초 vs 3초)가 1일 1회 배치에서 의미 없는 이유는? + +**블로그 구조 가이드** (소재 문서 `10-technical-writing-topics.md` 기반): + +``` +TL;DR: (1줄 요약) + +1. 도입 — "Redis에 이미 랭킹이 있는데 왜 MV를 만드는가?" + → 소재 4 (Lambda Architecture) + +2. Score 설계 — 균등 합산 vs 지수 감쇠 + → 소재 1 + 전시 기간 편향 분석 + +3. Chunk vs Tasklet — 언제 무엇을 쓰는가 + → 소재 3 (Spring Batch 운영 기능 5가지) + +4. Reader 선택 — CursorReader + Partitioning + → 소재 8, 9 (GROUP BY에서 Paging이 치명적인 이유) + +5. 전체 재계산 vs 증분 — Late-Arriving Fact + → 소재 12 (취소가 과거 데이터를 변경하는 문제) + +6. 데이터 소스 설계 — 단일 소스 원칙 + → 소재 4 하단 (Redis fallback 제거 판단) + +7. 마무리 — 10주 회고 +``` diff --git a/docs/design/volume-10/10-batch-test-results.md b/docs/design/volume-10/10-batch-test-results.md new file mode 100644 index 0000000000..0d1903911a --- /dev/null +++ b/docs/design/volume-10/10-batch-test-results.md @@ -0,0 +1,297 @@ +# ProductRankingMvJob E2E 테스트 결과 + +> 실행일: 2026-04-17 +> 테스트 클래스: `ProductRankingMvJobE2ETest` +> 경로: `apps/commerce-batch/src/test/java/com/loopers/job/rankingmv/ProductRankingMvJobE2ETest.java` +> 결과: **10/10 PASSED** (기능 7 + 시각화 1 + 대규모 1 + 벤치마크 1) + +--- + +## 테스트 환경 + +| 항목 | 값 | +|------|-----| +| DB | MySQL 8.0 (Testcontainers — Docker 컨테이너) | +| Spring Batch Test | `@SpringBatchTest` + `@SpringBootTest` | +| DDL | `schema-batch-test.sql` (BEFORE_TEST_CLASS) | +| targetDate | `20260416` | + +--- + +## 테스트 목록 + +### 1. weeklySuccess — 주간 정상: 시드 데이터 기반 주간 TOP 100 적재 + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품 150개 + 7일치 메트릭 시드 → weekly Job 실행 | +| **검증** | Job 상태 COMPLETED, MV 100건 적재, 1위 = product_id 150 (최고 점수), staging 150건 전체 존재 | +| **결과** | PASSED | +| **의미** | 3-Step 파이프라인 (Cleanup → Partitioned Aggregate → Merge) 정상 동작. LIMIT 100 적용 확인 | + +### 2. weeklyLessThan100Products — 주간: 상품이 100개 미만이면 있는 만큼만 적재 + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품 30개 + 7일치 메트릭 → weekly Job 실행 | +| **검증** | Job COMPLETED, MV 30건 (LIMIT 100이지만 데이터가 30개이므로 30건) | +| **결과** | PASSED | +| **의미** | TOP 100 상한은 있되 데이터가 부족하면 있는 만큼만 적재하는 유연한 처리 확인 | + +### 3. monthlySuccess — 월간 정상: 30일 데이터 집계 + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품 50개 + 30일치 메트릭 → monthly Job 실행 | +| **검증** | Job COMPLETED, `mv_product_rank_monthly` 50건 적재 | +| **결과** | PASSED | +| **의미** | scope=monthly → 30일 윈도우 + `mv_product_rank_monthly` 테이블 분기 정상 동작 | + +### 4. idempotentDoubleExecution — 멱등성: 같은 파라미터로 2회 실행해도 결과 동일 + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품 50개 + 7일치 메트릭 → weekly Job 2회 연속 실행 | +| **검증** | 2차 실행도 COMPLETED, MV 50건 (중복 없음) | +| **결과** | PASSED | +| **의미** | CleanupTasklet이 기존 period_key 데이터를 삭제 후 재적재 → 멱등성 보장. RunIdIncrementer로 JobInstance 구분 | + +### 5. noDataProducesEmptyMv — 엣지: 데이터 없는 날짜로 실행하면 빈 MV + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품 10개만 시드 (메트릭 없음) → weekly Job 실행 | +| **검증** | Job COMPLETED, MV 0건 | +| **결과** | PASSED | +| **의미** | product_metrics가 비어있어도 Job이 FAILED 되지 않고 정상 완료. Partitioner가 빈 범위를 안전하게 처리 | + +### 6. partialDataAggregated — 엣지: 7일 미만 데이터면 있는 만큼만 집계 + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품 20개 + 3일치 메트릭 (7일 미만) → weekly Job 실행 | +| **검증** | Job COMPLETED, MV 20건 | +| **결과** | PASSED | +| **의미** | 슬라이딩 윈도우 7일 중 3일만 있어도 있는 데이터만으로 집계. 부분 데이터 허용 | + +### 7. cancellationReflectedInScore — 엣지: 취소 반영: cancel_amount가 score에 반영 + +| 항목 | 내용 | +|------|------| +| **시나리오** | 상품1: 매출 100만/취소 0, 상품2: 매출 200만/취소 150만(순매출 50만) → weekly Job 실행 | +| **검증** | Job COMPLETED, 1위 = product_id 1 (순매출 100만 > 50만) | +| **결과** | PASSED | +| **의미** | Score SQL에서 `sales_amount - cancel_amount_by_event_date` 반영 확인. 취소가 많은 상품의 순위 하락 검증 | + +--- + +## 수정 이력 (테스트 통과를 위한 코드 수정) + +### 수정 1: Partitioner를 Bean에서 private 메서드로 변경 + +**파일**: `ProductRankingMvJobConfig.java` + +| Before | After | +|--------|-------| +| `@JobScope @Bean productIdPartitioner()` | `private Partitioner createPartitioner(targetDate, scope)` | + +**원인**: `@Value("#{jobParameters['targetDate']}")` SpEL을 사용하는 Bean에 `@JobScope`가 없어 context 로딩 시 `SpelEvaluationException` 발생. `@JobScope`를 추가하면 `@SpringBatchTest`의 `JobScopeTestExecutionListener`와 충돌. + +**해결**: Partitioner를 Spring Bean이 아닌 private 메서드로 변경하여 `partitionedAggregateStep` 내부에서 직접 호출. `targetDate`, `scope`는 이미 `@JobScope`인 step 메서드의 파라미터로 주입받으므로 별도 Bean 불필요. + +### 수정 2: runJob 반환 타입 변경 + +**파일**: `ProductRankingMvJobE2ETest.java` + +| Before | After | +|--------|-------| +| `private JobExecution runJob(String scope)` | `private BatchStatus runJob(String scope)` | + +**원인**: `@SpringBatchTest`의 `JobScopeTestExecutionListener`가 테스트 클래스의 모든 `getDeclaredMethods()`를 스캔하여 `JobExecution` 반환 타입 메서드를 찾음. `runJob(String)`을 발견하고 인자 없이 호출 시도 → `HippyMethodInvoker`에서 `No matching arguments found for method: runJob` 에러. + +**해결**: 반환 타입을 `BatchStatus`로 변경하여 listener의 스캔 대상에서 제외. 모든 테스트는 `execution.getStatus()`만 사용하므로 기능적 영향 없음. + +--- + +## 커버리지 분석 + +| 검증 범위 | 테스트 | +|-----------|--------| +| **3-Step 파이프라인 정상 흐름** | weeklySuccess, monthlySuccess | +| **Partitioning (product_id 범위 분할)** | weeklySuccess (150개 → GRID_SIZE=4 파티션) | +| **LIMIT 100 상한** | weeklySuccess (150개 중 100개), weeklyLessThan100Products (30개 중 30개) | +| **scope 분기 (weekly/monthly)** | weeklySuccess, monthlySuccess | +| **멱등성 (Cleanup + RunIdIncrementer)** | idempotentDoubleExecution | +| **빈 데이터 안전 처리** | noDataProducesEmptyMv | +| **부분 기간 데이터** | partialDataAggregated | +| **취소 반영 (cancel_amount)** | cancellationReflectedInScore | +| **Score 순위 정확성** | weeklySuccess (1위=150L), cancellationReflectedInScore (1위=1L) | + +--- + +## 실 환경 배치 실행 + API 호출 검증 + +> 실행일: 2026-04-17 +> 환경: Docker MySQL 8.0 + Redis Master/Replica + commerce-api (localhost:8080) +> 캡처 파일: [`docs/captures/04-ranking-api-capture.md`](../../captures/04-ranking-api-capture.md) + +### 데이터 규모 + +| 항목 | 값 | +|------|-----| +| 노트북 사양 | Apple M5 Pro, 18코어, 48GB RAM | +| 상품 수 | 1,020개 (20브랜드 × 50종 + 기본 20개) | +| 메트릭 행 수 | 30,600행 (1,020 × 30일) | +| 데이터 생성 방식 | Python 스크립트로 브랜드명 + 모델명 + 컬러/사이즈 조합 랜덤 생성 (크롤링 아님) | + +### 시드 데이터 트렌드 패턴 (6가지) + +| 타입 | 비율 | 설명 | +|------|------|------| +| A) 급상승 | 5% (51개) | 과거 23일 미미 → 최근 7일 폭발 (view 6K, sales 250만/일) | +| B) 장기 강자 | 10% (102개) | 30일 꾸준히 높음 (view 3.5K, sales 180만/일) | +| C) 하락 추세 | 5% (51개) | 과거 23일 높음 → 최근 7일 급락 | +| D) 오늘 바이럴 | 2% (20개) | 오늘만 폭발 (view 18K, sales 600만) | +| E) 취소 높음 | 3% (31개) | 매출 높지만 취소 50~70% | +| F) 일반 | 75% (765개) | 보통 수준 (view 500, sales 20만/일) | + +### 배치 실행 결과 + +| 항목 | weekly | monthly | +|------|--------|---------| +| 파티션 | 4 (productId 1~255, 256~510, 511~765, 766~1020) | 4 | +| 소요 시간 | 275ms | 309ms | +| 적재 건수 | 100 (TOP 100) | 100 | +| 메트릭 기간 | 7일 (04-01~04-07) | 30일 (03-08~04-07) | +| Job 상태 | COMPLETED | COMPLETED | + +### API 호출 결과 (TOP 5 비교) + +``` +GET /api/v1/rankings?scope={daily|weekly|monthly}&date=20260407&page=0&size=20 +``` + +| 순위 | 일간 (Redis) | 주간 (MV) | 월간 (MV) | +|:----:|-------------|-----------|-----------| +| 1 | 아디다스 캠퍼스 올리브 (바이럴) | 나이키 에어리프트 카키 (급상승) | 반스 슬립온 올리브 (장기강자) | +| 2 | 살로몬 아웃펄스 네이비 (바이럴) | 컨버스 런스타하이크 그레이 (급상승) | 스투시 카고바지 화이트 (장기강자) | +| 3 | 뉴발란스 530 올리브 (바이럴) | 스투시 월드투어후디 카키 (급상승) | 리복 클럽C85 인디고 (장기강자) | +| 4 | 디스이즈네버댓 SP로고T (바이럴) | 아디다스 포럼 네이비 (급상승) | 노스페이스 1996레트로 크림 (장기강자) | +| 5 | 컨버스 올스타 블랙 (바이럴) | 아디다스 오즈위고 크림 (급상승) | 뉴발란스 990v6 인디고 (장기강자) | + +### 핵심 관찰 + +1. **일간/주간/월간 TOP 20이 완전히 다른 상품으로 구성** — Lambda Architecture의 시간 윈도우별 랭킹 차이가 명확 +2. **바이럴 상품**: 일간 1위 → 주간 100위 밖 → 월간 100위 밖 (1일치만 반영) +3. **급상승 상품**: 일간 중위 → 주간 상위 → 월간 100위 밖 (23일간 미미) +4. **장기 강자**: 일간 하위 → 주간 하위 → 월간 상위 (30일 꾸준한 실적) +5. **Score 범위**: daily 0.73~0.83 < weekly 0.84~0.88 < monthly 0.94~0.96 (누적 기간에 비례) +6. **취소 반영**: 취소율 50~70% 상품은 순매출 차감으로 순위 하락 확인 + +--- + +## 대규모 테스트 결과 (10만 건) + +> 실행일: 2026-04-17 +> 환경: Testcontainers MySQL 8.0 (`--innodb-buffer-pool-size=256M`) + Gradle `-Xmx2g` +> 테스트 메서드: `largeScalePartitionedBatchTest` + +### 데이터 규모 + +| 항목 | 값 | +|------|-----| +| 상품 수 | 100,000개 | +| 메트릭 행 수 | 3,000,000행 (100,000 × 30일) | +| 시드 방식 | `JdbcTemplate.batchUpdate()` (1,000건씩 벌크 INSERT) | +| 상품 시드 소요 | 1,137ms | +| 메트릭 시드 소요 | 79,518ms (~80초) | + +### 6가지 트렌드 패턴 + +| 그룹 | Product ID 범위 | 비율 | 설명 | +|------|----------------|------|------| +| A) 급상승 | 1~5,000 | 5% | 최근 7일 폭발 (view 9K, sales 300만/일), 이전 미미 | +| B) 장기강자 | 5,001~15,000 | 10% | 30일 꾸준히 높음 (view 3K, sales 200만/일) | +| C) 하락추세 | 15,001~20,000 | 5% | 이전 높음 → 최근 7일 급락 | +| D) 바이럴 | 20,001~22,000 | 2% | 오늘만 폭발 (view 15K, sales 500만) | +| E) 취소높음 | 22,001~25,000 | 3% | 매출 높지만 취소 50~70% | +| F) 일반 | 25,001~100,000 | 75% | 보통 수준 | + +### 배치 실행 결과 + +| 항목 | weekly | monthly | +|------|--------|---------| +| Partitioning | 4 Worker (각 25,000건 균등) | 4 Worker (각 25,000건 균등) | +| 소요 시간 | **2,205ms** | **2,564ms** | +| MV 적재 건수 | 100 (TOP 100) | 100 (TOP 100) | +| Staging 적재 | 100,000건 | 100,000건 | +| Job 상태 | COMPLETED | COMPLETED | + +### Step별 소요 시간 + +| Step | weekly | monthly | +|------|--------|---------| +| cleanupStep | 19ms | 352ms (staging 10만건 삭제) | +| partitionedAggregateStep | 1,977ms | 2,014ms | +| ├ Worker 1 (partition0) | 1,663ms | 1,269ms | +| ├ Worker 2 (partition1) | 1,695ms | 1,274ms | +| ├ Worker 3 (partition2) | 1,642ms | 1,315ms | +| └ Worker 4 (partition3) | 1,697ms | 1,274ms | +| mergeStep | 74ms | 74ms | + +### 1위 검증 + +| scope | 1위 상품 | 트렌드 유형 | 의미 | +|-------|---------|-----------|------| +| weekly | product_5000 (급상승) | 최근 7일 폭발 | 7일 윈도우에서 급상승 상품이 장기강자를 이김 | +| monthly | product_15000 (장기강자) | 30일 꾸준히 높음 | 30일 윈도우에서 장기강자가 급상승을 역전 | + +### 파티션 균등 분배 + +``` +[Partitioner] partition0: productId 1~25000 (25,000건) +[Partitioner] partition1: productId 25001~50000 (25,000건) +[Partitioner] partition2: productId 50001~75000 (25,000건) +[Partitioner] partition3: productId 75001~100000 (25,000건) +``` + +DISTINCT product_id 사전 조회 기반 분할로 4 파티션 완전 균등 분배. Worker별 소요 시간 편차 < 60ms. + +### 규모별 성능 비교 + +| 규모 | 상품 수 | 메트릭 행 수 | weekly | monthly | +|------|--------|------------|--------|---------| +| 중규모 | 1,020 | 30,600 | 275ms | 309ms | +| **대규모** | **100,000** | **3,000,000** | **2,205ms** | **2,564ms** | + +데이터가 100배 증가해도 소요 시간은 ~8배만 증가 — Partitioning + GROUP BY 최적화로 sub-linear scaling 달성. + +--- + +## Partitioning 벤치마크 (gridSize=1 vs gridSize=4) + +> 실행일: 2026-04-17 +> 테스트 메서드: `partitionBenchmark` +> 데이터: 100,000 상품 × 30일 = 3,000,000행 (6가지 트렌드 패턴) + +### 테스트 방식 + +동일 시드 데이터를 한 번만 생성한 후, `ReflectionTestUtils.setField(jobConfig, "gridSize", N)`으로 gridSize만 교체하여 2회 실행. + +1. gridSize=1로 weekly Job 실행 → 소요 시간 측정 +2. MV + staging DELETE → gridSize=4로 weekly Job 실행 → 소요 시간 측정 + +### 결과 + +| 구성 | weekly 소요 시간 | Worker 수 | Worker당 상품 수 | +|------|----------------|-----------|--------------| +| gridSize=1 (단일 스레드) | **3,740ms** | 1 | 100,000 | +| gridSize=4 (4 Partition 병렬) | **1,763ms** | 4 | 25,000 | +| **향상률** | **2.1x** | | | + +### 분석 + +- **이론적 상한: 4x**, 실측: **2.1x** +- Amdahl's Law에 의해 병렬화할 수 없는 부분(Partitioner의 `DISTINCT product_id` 쿼리, mergeStep의 `ROW_NUMBER() OVER`, 각 Step 간 JobRepository 메타데이터 저장)이 전체 소요 시간의 일부를 차지 +- Testcontainers MySQL에서 innodb-buffer-pool-size=256M 제약 환경 기준. 프로덕션 MySQL에서는 더 큰 향상률이 기대됨 +- 양쪽 모두 MV 100건 적재 + Job COMPLETED 검증 통과 diff --git a/docs/design/volume-10/10-large-scale-test-prompt.md b/docs/design/volume-10/10-large-scale-test-prompt.md new file mode 100644 index 0000000000..0cceb9ae40 --- /dev/null +++ b/docs/design/volume-10/10-large-scale-test-prompt.md @@ -0,0 +1,66 @@ +# 10만 건 상품 데이터 기반 대규모 배치 테스트 프롬프트 + +> 이 프롬프트를 다른 컴퓨터의 Claude Code 세션에 붙여넣고 실행하세요. +> 사전 조건: `git pull origin volume-10` 완료, Docker 실행 중 + +--- + +## 맥락 + +MV 랭킹 배치 Job(`ProductRankingMvJobConfig`)이 Partitioning(4 Worker)으로 구현되어 있다. +기존 E2E 테스트는 1,020개 상품으로 기능 검증만 완료한 상태이며, Partitioning의 성능 이점을 검증하려면 최소 10만 건 규모의 데이터가 필요하다. + +## 요청 + +### 1. 10만 건 상품 대규모 테스트 작성 + +`ProductRankingMvJobE2ETest`에 10만 건 테스트를 추가해줘: + +- **상품 10만 개** 시드 (`product` 테이블) +- **30일치 메트릭** 시드 (`product_metrics` 테이블) → 10만 × 30 = 300만 행 +- 시드 데이터는 `JdbcTemplate.batchUpdate()`로 벌크 INSERT (행 단위 INSERT는 시드 자체가 수십 분 걸림) +- 6가지 트렌드 패턴 적용 (급상승 5%, 장기강자 10%, 하락 5%, 바이럴 2%, 취소높음 3%, 일반 75%) + +검증할 것: +- Job 상태 COMPLETED +- MV에 정확히 100건 적재 +- 1위 상품의 정확성 +- **소요 시간 측정**: `System.currentTimeMillis()` 또는 StepExecution의 시작/종료 시각으로 측정 +- **파티션별 처리 건수 균등 여부**: 로그에서 `[Partitioner] partition{}: productId {}~{} ({}건)` 확인 + +### 2. Partitioning 효과 비교 (선택) + +가능하면 gridSize를 1로 변경한 테스트도 추가하여 단일 스레드 vs 4 파티션의 소요 시간을 비교해줘. + +### 3. 결과 기록 + +테스트 결과를 `docs/design/volume-10/10-batch-test-results.md`에 추가: + +```markdown +## 대규모 테스트 결과 (10만 건) + +| 항목 | 값 | +|------|-----| +| 상품 수 | 100,000 | +| 메트릭 행 수 | 3,000,000 | +| Partitioning | 4 Worker | +| weekly 소요 시간 | ?ms | +| monthly 소요 시간 | ?ms | +| 파티션 균등 분배 | 각 파티션 약 25,000건 (±?) | +| MV 적재 건수 | 100 | +``` + +### 4. 주의사항 + +- 시드에 시간이 오래 걸릴 수 있다. `batchUpdate()`로 1,000건씩 벌크 INSERT 권장 +- Testcontainers MySQL의 메모리가 부족할 수 있다. `withCommand("--innodb-buffer-pool-size=256M")` 추가 고려 +- 테스트가 메모리 부족으로 실패하면, Gradle JVM 옵션에 `-Xmx2g` 추가: + ``` + // build.gradle.kts 또는 gradle.properties + tasks.test { jvmArgs = listOf("-Xmx2g") } + ``` +- 기존 7개 테스트에 영향을 주지 않도록 독립적인 `@Test` 메서드로 추가 + +### 5. PR 반영 + +테스트 완료 후 결과를 커밋하고 push해줘. PR draft(`10-pr-draft.md`)의 Summary와 성능 테이블도 10만 건 결과로 업데이트해줘. diff --git a/docs/design/volume-10/10-partition-benchmark-prompt.md b/docs/design/volume-10/10-partition-benchmark-prompt.md new file mode 100644 index 0000000000..d8765f1154 --- /dev/null +++ b/docs/design/volume-10/10-partition-benchmark-prompt.md @@ -0,0 +1,79 @@ +# Partitioning 성능 비교 테스트 + +## 목적 + +gridSize=1(단일 스레드) vs gridSize=4(4 Partition)의 소요 시간을 비교하여 Partitioning의 효과를 측정한다. + +## 요청 + +### 1. GRID_SIZE를 외부에서 주입 가능하게 변경 + +`ProductRankingMvJobConfig.java`의 `GRID_SIZE`를 `application.yml` 또는 JobParameter로 주입 가능하게 변경: + +```java +// 현재: private static final int GRID_SIZE = 4; +// 변경: application.yml에서 주입 +@Value("${ranking.mv.grid-size:4}") +private int gridSize; +``` + +또는 더 간단하게, 테스트에서만 GRID_SIZE를 1로 바꿔서 돌리는 방법: +- `ProductRankingMvJobConfig`를 상속한 테스트용 Config에서 GRID_SIZE를 override +- 또는 ReflectionTestUtils로 GRID_SIZE를 변경 + +### 2. 벤치마크 테스트 추가 + +`ProductRankingMvJobE2ETest`에 추가: + +```java +@Test +@DisplayName("벤치마크 — gridSize=1 vs gridSize=4 소요 시간 비교") +void partitionBenchmark() throws Exception { + int productCount = 100_000; + seedProductsBulk(productCount); + seedMetricsBulkWithTrends(productCount, 30, TARGET_DATE); + + // gridSize=1로 실행 + // (GRID_SIZE를 1로 변경하는 방법 적용) + long t0 = System.currentTimeMillis(); + runJob("weekly"); + long singleMs = System.currentTimeMillis() - t0; + + // cleanup + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly WHERE period_key = ?", TARGET_DATE); + jdbcTemplate.update("DELETE FROM mv_product_rank_staging WHERE period_key = ?", TARGET_DATE); + + // gridSize=4로 실행 + // (GRID_SIZE를 4로 복원) + t0 = System.currentTimeMillis(); + runJob("weekly"); + long partitionedMs = System.currentTimeMillis() - t0; + + System.out.println("═══════════════════════════════════════"); + System.out.println(" Partitioning 벤치마크 (10만 상품)"); + System.out.println("═══════════════════════════════════════"); + System.out.printf(" gridSize=1: %,dms%n", singleMs); + System.out.printf(" gridSize=4: %,dms%n", partitionedMs); + System.out.printf(" 향상률: %.1fx%n", (double) singleMs / partitionedMs); + System.out.println("═══════════════════════════════════════"); +} +``` + +### 3. 결과 기록 + +PR draft(`10-pr-draft.md`)의 Partitioning 섹션을 업데이트: + +``` +10만 상품 × 30일(300만 행) 기준 측정값: + +gridSize=1 (단일): weekly ?ms +gridSize=4 (병렬): weekly ?ms +향상률: ?x +``` + +### 4. 주의사항 + +- 시드 데이터는 한 번만 생성하고, gridSize만 바꿔서 2회 실행 +- 각 실행 전 MV + staging을 DELETE +- Testcontainers MySQL의 `innodb-buffer-pool-size=256M` 설정 확인 +- JVM `-Xmx2g` 설정 확인 diff --git a/docs/design/volume-10/10-pr-draft.md b/docs/design/volume-10/10-pr-draft.md new file mode 100644 index 0000000000..b5bfb8e6b0 --- /dev/null +++ b/docs/design/volume-10/10-pr-draft.md @@ -0,0 +1,193 @@ +# MV 기반 주간/월간 랭킹 배치 시스템 구축 + +## 📌 Summary + +- **배경**: 대규모 데이터를 다루는 이커머스 환경에서 DB 원장 기준의 기간별 집계 랭킹이 필요하다. +- **목표**: Spring Batch로 `product_metrics`(일간 메트릭)를 주간/월간 단위로 합산하여 MV 테이블에 TOP 100 랭킹을 적재하고, API에서 조회할 수 있도록 한다. +- **결과**: Partitioning + Chunk-Oriented 3-Step 배치 구현, API 확장(MV 단일 소스 + 전일 fallback), E2E 테스트 10/10 통과, 10만 개의 상품 × 300만 행 기준 약 1.8초에 집계 완료. Partitioning 벤치마크 gridSize=1 대비 gridSize=4가 2.1x 향상. + +--- + +## 🧭 Context & Decision + +### 문제 정의 + +- **현재 동작**: 일간 메트릭(`product_metrics`)은 적재되어 있지만, 주간/월간 단위의 기간 집계 랭킹은 존재하지 않는다. +- **문제**: "이번 주/이번 달 가장 많이 팔린 상품"이라는 공개 랭킹 보드를 제공하려면 DB 원장 기반의 정확한 기간 집계가 필요하다. +- **성공 기준**: `product_metrics` 기반으로 주간(7일)/월간(30일) 메트릭을 합산하여 TOP 100 랭킹을 MV 테이블에 적재하고, API에서 조회할 수 있다. + +### 선택지와 결정 + +#### 1. Chunk vs Tasklet + +- **A. Tasklet**: `INSERT INTO...SELECT + RANK() OVER + LIMIT 100`으로 SQL 한 방 처리. 네트워크 왕복 0 +- **B. Chunk-Oriented** (채택): Reader/Writer 분리 + faultTolerant + retry +- **결정**: 이 작업은 Tasklet으로도 가능하지만, Chunk를 선택하면 Spring Batch의 운영 기능(`faultTolerant + retry + ExponentialBackOffPolicy`, `StepExecution` 자동 기록, `StepMonitorListener`)을 활용할 수 있다. 100건에 대한 네트워크 왕복 비용(< 1ms)보다 이 운영 기능의 가치가 크다 + +#### 2. Reader 선택 + 병렬 처리 + +- **A. JdbcPagingItemReader**: 멀티스레드 안전하지만, GROUP BY 집계 쿼리를 페이지마다 재실행 +- **B. JdbcCursorItemReader + Partitioning** (채택): GROUP BY 1회 실행 + product_id 범위 분할로 병렬 처리 +- **결정**: GROUP BY 집계에서 Paging은 페이지마다 집계를 반복하므로 규모가 커질수록 치명적. CursorReader의 멀티스레드 한계(ResultSet 공유 상태)를 Partitioning으로 극복 +- **참고**: [Spring Batch Scalability — Partitioning](https://docs.spring.io/spring-batch/reference/scalability.html) + +#### 3. Redis fallback vs 전일 MV fallback + +- **A. Redis fallback**: MV 장애 시 Redis에서 조회 +- **B. 전일 MV fallback** (채택): 당일 MV가 없으면 전일 MV 반환 +- **결정**: Redis(지수 감쇠)와 MV(균등 합산)는 다른 공식이므로, 소스 전환 시 순위가 바뀌는 데이터 불일치 발생. 전일 MV는 같은 공식 + 1일 stale로 순위 불일치 없음 + +#### 4. 전체 재계산 vs 증분 계산 + +- **A. 전체 재계산** (채택): 매일 원장에서 기간 전체를 GROUP BY +- **B. 증분 계산**: 어제 결과 - 가장 오래된 날 + 오늘 (93% 데이터 절감) +- **결정**: 이커머스에서 주문 취소/환불은 원주문과 다른 날에 발생(Late-Arriving Fact). 증분 계산은 "과거 데이터가 불변"이라는 전제가 필요하지만, `cancel_by_order_date`가 과거 행을 사후 갱신하므로 이 전제가 깨진다. 성능 차이(~10초 vs ~3초)는 1일 1회 배치에서 운영 영향 없음 + +#### 5. Score 계산 방식 + +- **A. 균등 합산** (채택): 기간 내 메트릭을 SUM한 뒤 score 공식 1회 적용. 30일 전이나 오늘이나 동등한 가중치로 "기간 총 실적"을 평가 +- **B. 지수 감쇠**: 일별 score에 `0.97^i`를 곱하여 오래된 날일수록 가중치를 줄임(반감기 약 23일). 같은 총 매출이라도 최근에 집중된 상품이 더 높은 순위를 받음. 전시 기간이 길어서 누적된 score가 높은 상품의 이점을 희석할 수 있다는 특징이 있음 +- **결정**: "이번 달 베스트셀러 = 총 판매량 기준"이라는 공개 랭킹 보드의 비즈니스 의미에 부합하는 균등 합산을 채택 +- **트레이드오프**: 균등 합산은 전시 기간이 긴 상품이 유리하다. 지수 감쇠는 이를 희석할 수 있지만, "총 실적"이라는 의미에 집중해야 한다고 생각했다. + +--- + +## 🏗️ Design Overview + +### 변경 범위 + +- **영향 받는 모듈**: `commerce-batch`, `commerce-api` +- **신규 추가**: + - `ProductRankingMvJobConfig.java` — Job + 3 Step + Partitioner + Reader + Writer + - `CleanupTasklet.java` — DELETE + 데이터 보존 정책 + - `MvProductRank.java` / `MvProductRankWeekly.java` / `MvProductRankMonthly.java` — MV 엔티티 + - `MvProductRankRepository.java` + JPA 구현체 — MV 조회 + - `mv_product_rank_weekly` / `mv_product_rank_monthly` / `mv_product_rank_staging` — DDL +- **수정**: `RankingFacade.java` — weekly/monthly 조회 경로를 Redis → MV로 변경 + +### 주요 컴포넌트 책임 + +- `ProductRankingMvJobConfig`: 3-Step Job 오케스트레이션. Partitioner로 product_id 범위 분할, Worker Step에서 Chunk-Oriented 집계, mergeStep에서 Global TOP 100 추출 +- `CleanupTasklet`: 당일 period_key의 MV/staging DELETE + 3일 이전 데이터 퍼지. 멱등성 보장의 핵심 +- `RankingFacade`: scope별 데이터 소스 분기. daily → Redis, weekly/monthly → MV(당일 → 전일 fallback) + +--- + +## 🔁 Flow Diagram + +### 배치 Job 흐름 + +```mermaid +flowchart TD + A[ProductRankingMvJob 시작] --> B[Step 1: CleanupTasklet] + B -->|FAILED| Z[Job 종료] + B -->|COMPLETED| C[Step 2: Partitioned Aggregate] + + C --> D1[Worker 1: product_id 1~25000] + C --> D2[Worker 2: product_id 25001~50000] + C --> D3[Worker 3: product_id 50001~75000] + C --> D4[Worker 4: product_id 75001~100000] + + D1 -->|GROUP BY + score| S[staging 테이블] + D2 -->|GROUP BY + score| S + D3 -->|GROUP BY + score| S + D4 -->|GROUP BY + score| S + + S --> E[Step 3: Merge] + E -->|ROW_NUMBER + LIMIT 100| F[MV 테이블 TOP 100] +``` + +### API 조회 흐름 + +```mermaid +sequenceDiagram + autonumber + participant Client + participant RankingFacade + participant MvProductRankRepo + participant RankingRedisRepo + participant ProductRepo + + Client->>RankingFacade: GET /api/v1/rankings?scope=weekly + + alt scope = daily + RankingFacade->>RankingRedisRepo: ZREVRANGE (Redis ZSET) + RankingRedisRepo-->>RankingFacade: RankingEntry[] + else scope = weekly | monthly + RankingFacade->>MvProductRankRepo: findByPeriodKey(당일) + MvProductRankRepo-->>RankingFacade: MvProductRank[] + alt 당일 데이터 없음 + RankingFacade->>MvProductRankRepo: findByPeriodKey(전일) + MvProductRankRepo-->>RankingFacade: MvProductRank[] + end + end + + RankingFacade->>ProductRepo: findAllByIds(productIds) + ProductRepo-->>RankingFacade: ProductWithBrand[] + RankingFacade-->>Client: PagedRankingResponse +``` + +--- + +## 테스트 결과 + +### E2E 테스트: 10/10 PASSED + +| 항목 | 값 | +|------|-----| +| DB | MySQL 8.0 (Testcontainers) | +| 테스트 클래스 | `ProductRankingMvJobE2ETest` | +| 데이터 | 테스트마다 독립 시드 (JdbcTemplate) | +| 결과 | **10/10 PASSED** (기능 7 + 시각화 1 + 대규모 1 + 벤치마크 1) | + +| 시나리오 | 검증 포인트 | +|---------|-----------| +| scope=weekly (150개 상품) | 3-Step 파이프라인 동작, TOP 100 적재, 1위 정확성 | +| scope=weekly (30개 상품) | 서비스 초기 등 상품이 부족해도 Job 정상 완료 | +| scope=monthly (30일) | 30일 윈도우 집계, monthly 테이블에 적재 | +| 멱등성 (2회 실행) | 중복 없이 동일 결과 | +| 데이터 없음 | Job COMPLETED, 빈 MV | +| 부분 데이터 (3일) | 있는 만큼만 집계 | +| 취소된 주문 반영 | 순매출 기준 순위 결정 | +| 시각화 (20개 상품 × 30일) | 일간/주간/월간 TOP 20 순위 차이 출력 | +| 대규모 (10만 × 30일) | 300만 행 4 Partition 병렬 집계, 파티션 균등 분배 | +| **벤치마크 (gridSize=1 vs 4)** | **단일 스레드 vs 4 Partition 병렬 소요 시간 비교** | + +### 성능 + +| 규모 | 상품 수 | 메트릭 행 수 | weekly | monthly | +|------|--------|------------|--------|---------| +| 중규모 | 1,020 | 30,600 | 275ms | 309ms | +| **대규모** | **100,000** | **3,000,000** | **2,205ms** | **2,564ms** | + +10만 상품 × 30일(300만 행)에서 4 Partition 병렬 집계 + Merge까지 약 1.8초. 데이터 100배 증가 시 소요 시간 ~8배 증가 (sub-linear scaling). + +### Partitioning 벤치마크 (gridSize=1 vs gridSize=4) + +| 구성 | weekly 소요 시간 | 비고 | +|------|----------------|------| +| gridSize=1 (단일 스레드) | 3,740ms | CursorReader 1개로 10만 건 GROUP BY | +| gridSize=4 (4 Partition 병렬) | 1,763ms | 각 Worker가 2.5만 건씩 독립 GROUP BY | +| **향상률** | **2.1x** | | + +동일 데이터(10만 상품 × 30일 = 300만 행)를 `ReflectionTestUtils`로 gridSize만 교체하여 측정. 4 Partition 병렬이 단일 스레드 대비 2.1배 빠르다. + +--- + +## 리뷰 포인트 + +### 1. Partitioning + CursorReader 조합시에 적절한 gridSize, 스테이징 테이블을 두는 효용 산정 방식 + +요구사항에 "대량의 데이터를 읽고 처리할 수 있도록 구성"이 명시되어 있어, 활성 상품 수가 수십만~수백만 규모로 성장하더라도 배치 윈도우 내에 처리 가능한 구조를 고려했습니다. + +GROUP BY 집계에서 PagingReader는 페이지마다 집계를 재실행하고, CursorReader는 멀티스레드에서 사용이 어려워서, Partitioning으로 product_id 범위를 분할하여 각 Worker가 독립 CursorReader를 갖도록 했습니다. + +질문: +- **gridSize를 4로 설정**했는데, 커넥션 풀 크기나 CPU 코어 수에 연동하거나 동적으로 조정해야 할 것 같습니다. 실무에서는 gridSize를 어떻게 설정하시나요? +- **스테이징 테이블에 전체 상품 집계 결과를 적재**한 후 mergeStep에서 TOP 100만 추출하는 구조인데, 상품 수가 많아지면 스테이징 적재 비용이 커집니다. 이 중간 저장 비용 대비 Partitioning의 병렬 처리 이점이 충분한지는 처리 속도만 고려해서 판단해도 될까요? + +### 2. Score 계산을 SQL에 넣은 것에 대하여 + +Score 공식(`LOG10 + 가중치`)을 Reader SQL에 넣어서 DB가 집계 + score + 정렬 + TOP 100을 한 번에 처리합니다. Java의 RankingCorrectionJob에도 동일한 공식이 있어 이중 관리가 됩니다. + +두 Job은 입력이 다르고(일간 메트릭 vs 기간 합산 메트릭), 가중치는 `application.yml`에서 관리하므로 합리적 중복이라고 판단했습니다. 공식 일원화가 필요한지 의견을 구합니다. diff --git a/docs/design/volume-10/10-technical-writing-plan.md b/docs/design/volume-10/10-technical-writing-plan.md new file mode 100644 index 0000000000..3ba7a9220f --- /dev/null +++ b/docs/design/volume-10/10-technical-writing-plan.md @@ -0,0 +1,102 @@ +# 10. 테크니컬 라이팅 기획안 + +> Round 10 블로그 글의 구조, 방향, 소재 배치를 정리한 기획 문서. + +--- + +## 과제 가이드 기준 방향 조정 + +### 과제가 요구하는 것 + +| 항목 | 가이드 원문 | 우리 글에의 의미 | +|------|-----------|---------------| +| **포인트** | "무엇을 했다"보다 **"왜 그렇게 판단했는가"** | 구현 비중 ↓, 판단 과정 비중 ↑ | +| **톤** | 실력은 보이지만, 자만하지 않고, **고민이 읽히는 글** | 정답 제시 X, 고민의 과정을 보여준다 | +| **Trade-off** | 중요한 선택 1~2개. 왜 그 선택을, 대안은 뭐였는지, 다시 한다면? | 소재 12개를 나열하지 않고 **핵심 판단 몇 개에 집중** | +| **실전 연결** | "회사/서비스에서 써먹을 수 있겠다" 싶은 포인트 | 별도 섹션으로 실무 적용 관점 필수 | +| **회고** | 10주간 사고방식/문제 해결/설계 선택 과정의 성장 | 이번 주만이 아니라 전체 여정 안에서의 위치 | + +### 레퍼런스 글에서 가져올 것 / 가져오지 않을 것 + +| 가져올 것 | 가져오지 않을 것 | +|----------|---------------| +| TL;DR, 비교표, 코드+해설 패턴, 볼드 인사이트 | AS-IS/TO-BE 구조 | +| 의문에서 시작하는 서사, 숫자로 증명, 한계 인정 | Phase별 벤치마크 (우리 내용과 안 맞음) | +| 관통 철학 ("Lambda Architecture에서 두 Layer의 역할 분담") | 문제 N개→해결 N개 대응 구조 | + +--- + +## 글 구조 + +### 제목 + +"일간은 Redis, 주간/월간은 왜 다른가 — 이커머스 랭킹 배치 설계기" + +### 전체 뼈대 + +글의 뼈대를 **판단의 흐름**으로 잡는다. 과제 가이드의 4가지 요구("판단 중심", "Trade-off", "실전 연결", "회고")를 각각 섹션으로 녹인다. + +``` +TL;DR + +1. 이 글의 맥락 + +2. Redis에 이미 랭킹이 있는데, MV를 왜 만드는가 + +3. 설계 판단들 + 3.1 "주간 베스트"는 총 판매량인가, 최근 인기인가 + 3.2 시간 윈도우: 매주 월요일에 리셋되는 랭킹이 맞는가 + 3.3 Chunk vs Tasklet: 90개 실무 Job이 알려준 것 + 3.4 Score 계산은 DB에서 끝내야 한다 + 3.5 CursorReader: GROUP BY 집계에서 PagingReader가 위험한 이유 + 3.6 매번 원장에서 재계산하는 게 비효율 아닌가 + +4. 구현: 3-Step Chunk Job + +5. 시행착오 + +6. 실전에서라면 + +7. 돌아보며 +``` + +### 각 판단의 내부 구조 + +``` +왜 이 판단이 필요했는가 (1~2문장) + ↓ +대안 비교 (표) + ↓ +결정 + 근거 (볼드 인사이트) + ↓ +(선택적) 다시 한다면? / 실무에서의 의미 +``` + +--- + +## 소재 배치 + +| 소재 (10-technical-writing-topics.md) | 섹션 | 비중 | +|------|------|------| +| 4 (Redis vs MV) | **섹션 2** — 글의 출발점 | 높음 | +| 1 (Score 방식) | **3.1** | 높음 | +| 2 (윈도우) | **3.2** | 중간 | +| 3 (Chunk vs Tasklet) | **3.3** | 높음 | +| 7 (Best Practice) | **3.3** 안에서 근거 | 중간 | +| 5 (계산 위치) | **3.4** | 중간 | +| 6 (사전 집계) | **3.4** 보조 언급 | 낮음 | +| 8 (Cursor vs Paging) | **3.5** | 중간 | +| 9 (Partitioning) | **3.5** + **6 실전** | 중간 | +| 10 (멱등성) | **4 구현** | 낮음 | +| 11 (Job Instance) | **4 구현** | 낮음 | +| 12 (전체 재계산 vs 증분) | **3.6** | 높음 | + +비중 "높음" 4개(Topic 1, 3, 4, 12)가 글의 뼈대. 나머지가 근거와 디테일. + +--- + +## 관통 철학 + +**"Lambda Architecture에서 두 Layer의 가치는 같은 결과를 내는 것이 아니라, 같은 데이터로 다른 관점을 제공하는 것이다."** + +이 한 줄이 모든 판단의 출발점이자 결론. diff --git a/docs/design/volume-10/10-technical-writing-topics.md b/docs/design/volume-10/10-technical-writing-topics.md new file mode 100644 index 0000000000..302db487e9 --- /dev/null +++ b/docs/design/volume-10/10-technical-writing-topics.md @@ -0,0 +1,1052 @@ +# 10. 테크니컬 라이팅 소재 모음 + +> Round 10 과제를 수행하면서 발생한 설계 고민, 트레이드오프, 판단 근거를 기록한다. +> 블로그 글의 소재로 활용한다. + +--- + +## 소재 1: MV Score 계산 — 균등 합산 vs 지수 감쇠 vs 일평균 + +### 이 고민이 시작된 맥락 + +Redis monthly의 지수 감쇠(`daily × 0.97^i`) 방식을 분석하다가, 이런 의문이 생겼다: **"지수 감쇠의 목적이 이미 전시된 기간의 편향을 보정하기 위함인가?"** 오래 전시된 상품은 노출 기간이 길어서 누적 조회수/판매량이 자연스럽게 높다. 감쇠로 이것을 보정할 수 있지 않을까? + +그런데 반대로 생각하면, 최근에 가중치를 두면 **월간 랭킹이 일간/주간과 비슷해질 수 있다**는 우려도 있었다. 이 양쪽의 긴장에서 "그러면 MV의 score는 어떤 방식이어야 하는가?"라는 질문이 시작되었다. + +추가로, 전시 기간 편향을 보정하는 다른 방법(일평균, 전환율)도 검토하면서, **공개 랭킹 보드에서 어떤 지표가 비즈니스적으로 의미 있는가**라는 근본적인 질문으로 이어졌다. + +### 검토한 3가지 방식 + +**방식 A: 균등 합산 (채택)** + +``` +score = f(SUM(30일 메트릭)) +``` + +- 30일간 메트릭을 단순 합산 후 score 공식 1회 적용 +- "기간 총 실적"을 공정하게 평가 +- 30일 전이나 오늘이나 동등한 가중치 + +**방식 B: 지수 감쇠** + +``` +monthly = Σ(i=0 ~ 29) daily_score × 0.97^i +``` + +- 최근 데이터에 높은 가중치 (반감기 약 23일) +- Redis monthly와 유사한 성격 +- "최근에 뜨는 상품"을 우대 + +**방식 C: 일평균** + +``` +score = f(SUM(메트릭) / COUNT(DISTINCT 전시일수)) +``` + +- 전시 기간에 관계없이 "일당 성과"로 비교 +- 전시 기간 편향 보정 가능 +- 표본 크기 문제 (1일만 전시된 상품이 과대평가) + +### 핵심 트레이드오프 + +#### 균등 합산을 채택한 이유: 공개 랭킹 보드의 비즈니스 의미 + +**"이번 달 베스트셀러"는 총 판매량 기준이 이커머스 업계 표준이다.** + +쿠팡, 무신사, 교보문고 등 주요 이커머스의 공개 랭킹 보드는 기간 총 실적 기준으로 운영된다. 소비자가 "인기 상품 TOP 100"을 볼 때 기대하는 것은 "가장 많이 팔린 상품"이지, "일평균 판매량이 높은 상품"이나 "최근에 급등한 상품"이 아니다. + +균등 합산은 이 비즈니스 의미에 정확히 부합한다: +- **MD/상품기획팀의 관점**: "이번 달 어떤 상품이 가장 많이 팔렸나?" → 총 실적 +- **소비자의 관점**: "다들 뭘 사고 있나?" → 총 판매량 순위 +- **경영진의 관점**: "매출 기여도가 높은 상품은?" → 총 매출 기준 + +#### 지수 감쇠를 선택하지 않은 이유: Lambda Architecture에서의 역할 분담 + +**"MV가 Redis와 같은 결과를 내면, MV를 만들 이유가 없다."** + +| 관점 | Redis (지수 감쇠) | MV (균등 합산) | +|------|------------------|---------------| +| 비즈니스 의미 | "지금 뜨는 상품" (트렌드) | "이번 달 베스트셀러" (누적 성과) | +| 소비자 시나리오 | 메인 페이지 실시간 인기 | 카테고리별 베스트, 기간별 랭킹 | +| 사업자 시나리오 | 실시간 모니터링 | 주간/월간 리포트, MD 성과 분석 | + +두 시스템이 다른 관점을 제공하는 것이 Lambda Architecture에서 Speed Layer와 Batch Layer의 역할 분담이다. Speed Layer(Redis)가 이미 트렌드를 반영하고 있으므로, Batch Layer(MV)는 "정확한 기간 집계"에 집중하는 것이 아키텍처적으로 맞다. + +숫자로 검증한 결과, 감쇠를 쓰더라도 월간이 일간/주간과 비슷해지지는 않는다. 하지만 감쇠를 쓸 이유가 없는 것이 핵심이다. Redis가 이미 하고 있는 일을 MV에서 반복하면 두 시스템의 결과가 수렴하고, MV의 존재 가치가 떨어진다. + +``` +상품 A: 30일간 매일 매출 100만원 (꾸준) +상품 B: 최근 5일간 매일 600만원 (급등), 나머지 0원 +총 실적: 둘 다 3000만원 + + 일간 주간(균등) 주간(감쇠) 월간(균등) 월간(감쇠) +상품 A 0.600 0.693 4.09 0.735 12.0 +상품 B 0.678 0.735 3.33 0.735 3.33 +승자 B B A 동점 A 압승 +``` + +#### 일평균을 선택하지 않은 이유: 공개 랭킹의 목적과 불일치 + +"전시 기간 편향"은 실제 운영에서 존재하는 문제다. 30일 전시된 상품이 3일 전시된 신상품보다 누적 실적이 높은 것은 당연하고, 이것이 "인기"를 정확히 반영하는가는 논쟁의 여지가 있다. + +그러나 일평균으로 전환하면 **공개 랭킹 보드로서의 비즈니스 목적과 충돌**한다: + +``` +상품 A: 전시 30일, 총 매출 3000만원, 일평균 100만원 +상품 B: 전시 3일, 총 매출 900만원, 일평균 300만원 + +일평균 기준: B 승 (300만 > 100만) +총 실적 기준: A 승 (3000만 > 900만) +``` + +- **MD팀이 원하는 "이번 달 베스트"는 A다.** 총 매출 3000만원 상품이 1위에 있어야 매출 기여도 분석이 된다 +- B가 1위가 되면 **"3일 만에 900만원 판 신상품이 베스트셀러"**라는 오해를 줄 수 있다 +- 표본 크기 문제: 1일 전시에 매출 500만원이면 일평균 500만원으로 A보다 위에 올라간다 + +**전시 기간 편향을 보정하려면 일평균보다 더 적합한 방법이 있다:** + +| 방법 | 적합한 시스템 | 이유 | +|------|-------------|------| +| **일평균** | 내부 분석 리포트, MD 대시보드 | "상품의 판매 효율"을 보려면 적합. 하지만 공개 랭킹의 지표는 아님 | +| **노출 대비 전환율** (order/view) | 개인화 추천 시스템 | "이 상품을 본 사람 중 몇 %가 샀는가"는 상품의 매력도를 측정. 하지만 공개 랭킹에 쓰면 조회 500회 전환율 10% 상품이 조회 100만회 전환율 0.5% 상품 위에 올라감 — 소비자가 기대하는 "인기 상품"이 아님 | +| **총 실적 (현행)** | 공개 랭킹 보드 | "가장 많이 팔린 상품" = 소비자와 사업자 모두 직관적으로 이해 | + +전환율이 유용한 곳은 **추천 시스템**이다. "이 사용자에게 어떤 상품을 노출할까?"를 결정할 때 전환율이 높은 상품을 추천하면 구매 확률이 높아진다. 이것은 개인화된 추천 영역이지, 전체 사용자에게 동일하게 보여주는 공개 랭킹과는 목적이 다르다. 다만 실제 이커머스에서 공개 랭킹의 내부 score 공식에 전환율을 보조 가중치로 섞을 가능성은 있다. 핵심은 **primary 지표가 전환율인 공개 랭킹은 없다**는 것이다. + +### 선택하지 않은 대안에서 배운 것 + +- 지수 감쇠를 검토하면서 **"같은 데이터로 다른 관점을 제공하는 것"**이 Lambda Architecture에서 Batch Layer의 존재 가치임을 이해했다 +- 일평균을 검토하면서 **"공정한 비교"와 "비즈니스 의미" 사이의 긴장**을 인식했다. 수학적으로 공정한 것과 비즈니스적으로 의미 있는 것은 다를 수 있다 +- 전환율을 검토하면서 **"같은 지표가 시스템 목적에 따라 다른 위치에 놓인다"**는 것을 이해했다. 전환율은 추천 시스템에서는 핵심 지표이지만, 공개 랭킹에서는 보조 지표다 + +### 지금 다시 한다면? + +균등 합산을 유지하되, **일평균을 별도 컬럼으로 MV에 함께 저장**할 것이다. `avg_daily_sales = total_sales / active_days`를 MV에 추가하면, MD 대시보드에서 "판매 효율 기준 정렬"을 제공할 때 재집계 없이 확장 가능하다. 공개 랭킹의 정렬 기준은 총 실적을 유지하면서, 내부 분석용으로 일평균 데이터를 함께 제공하는 것이 실운영에서 가장 실용적인 접근이다. + +--- + +## 소재 2: 슬라이딩 윈도우 vs 캘린더 윈도우 + +### 이 고민이 시작된 맥락 + +MV 테이블의 period_key를 설계하다가 질문이 나왔다: **"일간, 주간 랭킹은 시간 단위로 윈도우 전략을 사용하는 게 아니야? carry-over가 아닌 캘린더상으로 1주, 1월을 기준으로 집계하는지 궁금해."** + +현재 Redis의 주간/월간이 이미 슬라이딩 윈도우(매일 갱신)로 동작하고 있었다. MV도 같은 방식으로 가야 하는가, 아니면 캘린더(월~일, 1일~말일) 기반으로 가야 하는가? 무신사는 주간/월간도 매일 집계한다는 정보가 판단에 영향을 줬다. + +| 전략 | 예시 | 갱신 주기 | +|------|------|----------| +| 캘린더 | 주간: 월~일, 월간: 1일~말일 | 주 1회, 월 1회 | +| 슬라이딩 | 오늘 기준 최근 7일/30일 | 매일 | + +### 슬라이딩을 선택한 이유 + +1. **Redis weekly와 시간 범위 일치**: Redis ZUNIONSTORE가 "최근 7일 daily"를 합산하는 슬라이딩 방식. MV가 캘린더이면 Redis fallback 시 시간 범위가 불일치하여 랭킹 결과의 연속성이 깨짐 +2. **이커머스 업계 관행**: 무신사, 쿠팡 등에서 주간/월간 랭킹을 매일 갱신. "주간 인기 상품"이 월요일에만 바뀌면 사용자가 매일 같은 랭킹을 보게 되어 재방문 유인이 떨어짐 +3. **배치 비용 대비 효과**: GROUP BY + TOP 100 INSERT는 상품 수만 건 기준 수초 내 완료. 매일 실행해도 시스템 부하가 미미하며, 사용자에게 매일 갱신되는 랭킹을 제공하는 효과가 큼 +4. **운영 단순성**: period_key가 targetDate(`20260416`) 자체이므로 "이 날짜 기준 최근 N일"이라는 명확한 의미. 캘린더 방식은 ISO 주차(`2026-W16`)나 월(`2026-04`) 계산이 필요하고, 월말/주초 경계 처리가 복잡 + +### 캘린더 방식이 더 적합한 경우 + +캘린더를 기각했지만, 다음 상황에서는 캘린더가 맞다: +- **정산/리포팅 시스템**: "4월 매출 정산"은 4/1~4/30 고정 기간이어야 한다. 슬라이딩이면 기준일에 따라 금액이 달라져 정산 불일치 +- **마케팅 캠페인 성과 분석**: "이번 주 프로모션 효과"는 캠페인 시작~종료 고정 기간 기준 +- **배치 비용이 높은 경우**: 수억 건 집계에 수십 분 걸리면 매일 실행이 부담 + +우리 과제는 정산이 아닌 **소비자 대상 랭킹 보드**이므로 슬라이딩이 적합하다. + +--- + +## 소재 3: Chunk vs Tasklet — 언제 무엇을 쓰는가 + +### 이 고민이 시작된 맥락 + +배치 프로젝트 2개(90개 Job)를 분석했더니 통계/집계 Job의 대다수가 Tasklet이었다. 처음에는 "Tasklet이 보편적"이라고 결론 내렸는데, **"다른 개발자들의 이야기를 들어보면 Chunk 방식이 보편적이라고 하는데?"**라는 반론이 나왔다. + +다시 생각해보니, 분석한 배치 프로젝트가 MyBatis + SQL 중심 아키텍처여서 Tasklet(INSERT INTO...SELECT)이 자연스러운 선택이었을 뿐, 이것을 업계 표준으로 일반화한 것은 **한 조직의 패턴을 확대 해석**한 것이었다. Spring Batch 프레임워크 자체가 Chunk를 중심으로 설계되어 있고, retry/skip/restart 등 운영 기능이 Chunk에만 제공된다는 점에서 Chunk가 보편적 선택인 이유가 있었다. + +이 시각 교정 과정에서 "그러면 정확히 언제 Chunk이고 언제 Tasklet인가?"라는 질문으로 이어졌다. + +### Spring Batch가 Chunk-Oriented에 제공하는 운영 기능 + +Chunk-Oriented는 단순히 "Reader → Processor → Writer"의 패턴이 아니다. Spring Batch 프레임워크가 Chunk에 대해 제공하는 **운영 레벨의 기능**이 Chunk를 보편적 선택으로 만드는 핵심이다. + +#### 1. 자동 Retry (Transient Failure 재시도) + +```java +@Bean +public Step step() { + return new StepBuilder("step", jobRepository) + .chunk(1000, transactionManager) + .reader(reader()) + .processor(processor()) + .writer(writer()) + .faultTolerant() + .retry(DeadlockLoserDataAccessException.class) // DB 데드락 시 재시도 + .retry(OptimisticLockingFailureException.class) // 낙관적 락 충돌 시 재시도 + .retryLimit(3) // 최대 3회 + .build(); +} +``` + +대규모 이커머스에서 배치가 수백만 건을 처리하는 동안 **일시적 DB 데드락, 네트워크 타임아웃**이 발생할 수 있다. Chunk는 해당 chunk만 재시도하고, Tasklet에서는 이 로직을 직접 구현해야 한다. + +#### 2. Skip Policy (불량 레코드 건너뛰기) + +```java +.faultTolerant() +.skip(DataIntegrityViolationException.class) // PK 중복 등 → 건너뛰기 +.skipLimit(100) // 최대 100건까지 허용 +.noSkip(OutOfMemoryError.class) // OOM은 절대 건너뛰지 않음 +``` + +100만 건 중 3건의 데이터 오류 때문에 전체 배치가 실패하면 운영 부담이 크다. Skip Policy로 불량 레코드를 건너뛰고 나머지를 계속 처리할 수 있다. Tasklet의 SQL 한 방에서는 1건의 에러가 전체를 롤백시킨다. + +#### 3. Restart (실패 지점부터 재시작) + +``` +최초 실행: + Chunk 1: 1~1,000건 ✓ 커밋 완료 + Chunk 2: 1,001~2,000건 ✓ 커밋 완료 + Chunk 3: 2,001~3,000건 ✗ 실패 (DB 커넥션 에러) + → ExecutionContext에 진행 상태 저장 + +재시작: + Chunk 1~2: 건너뜀 (이미 커밋됨) + Chunk 3: 2,001~3,000건부터 재시작 +``` + +수시간 걸리는 배치가 80% 진행 후 실패하면, 처음부터 재실행하는 것은 비용이 크다. Chunk는 Spring Batch의 메타 테이블(`BATCH_STEP_EXECUTION_CONTEXT`)에 진행 상태를 저장하여 실패 지점부터 재시작할 수 있다. + +#### 4. 자동 모니터링 (처리 건수 추적) + +``` +StepExecution 자동 기록: + - readCount: 읽은 건수 + - writeCount: 쓴 건수 + - skipCount: 건너뛴 건수 + - commitCount: 커밋 횟수 + - rollbackCount: 롤백 횟수 + - readSkipCount / writeSkipCount / processSkipCount +``` + +Tasklet에서는 이 지표들을 직접 카운팅하고 로깅해야 한다. Chunk는 Spring Batch가 자동으로 기록하고, `BATCH_STEP_EXECUTION` 테이블에서 조회할 수 있다. + +#### 5. Listener 기반 확장 + +```java +.listener(new ItemReadListener<>() { + public void onReadError(Exception ex) { alertService.send("Reader 에러: " + ex); } +}) +.listener(new ItemWriteListener<>() { + public void afterWrite(Chunk items) { metrics.increment("batch.write", items.size()); } +}) +``` + +읽기/쓰기/처리 각 단계에 Listener를 붙여 모니터링, 알림, 메트릭 수집을 할 수 있다. + +### Chunk가 보편적 선택인 이유 + +위 기능들은 **프레임워크가 무료로 제공하는 것**이다. Tasklet으로 동일한 수준의 운영 안정성을 확보하려면 retry 루프, skip 카운터, 진행 상태 저장, 처리 건수 추적을 모두 직접 구현해야 한다. 대부분의 배치 작업에서 이 운영 기능의 가치가 네트워크 왕복의 비용보다 크기 때문에 Chunk가 보편적 선택이 된다. + +### Tasklet이 Chunk보다 효율적인 경우 + +그럼에도 Tasklet이 맞는 **특정 조건**이 있다: + +| 조건 | 설명 | 예시 | +|------|------|------| +| **SQL 한 문장으로 완결** | Java 변환이 전혀 없고 DB→DB 이동 | `INSERT INTO...SELECT...GROUP BY` | +| **retry/skip이 불필요** | 실패 시 전체 재실행해도 수초 내 완료 | TOP 100 적재 (100건 INSERT) | +| **중간 상태가 없음** | 처리 중 실패해도 "부분 완료" 상태가 의미 없음 | DELETE + INSERT 패턴 (어차피 전체 교체) | + +실무 배치 앱 분석에서 관찰한 통계/집계 Job이 Tasklet을 쓰는 것은 **이 세 조건을 모두 충족하기 때문**이지, Tasklet이 일반적으로 우월하기 때문이 아니다. + +### 이 작업에서의 판단 + +우리의 MV TOP 100 적재는 Tasklet의 세 조건을 모두 충족한다: +- SQL 한 문장(INSERT INTO...SELECT + RANK() + LIMIT 100)으로 완결 가능 +- 100건 INSERT는 수초 내 완료 → 실패 시 전체 재실행해도 부담 없음 +- DELETE + INSERT 패턴이므로 부분 완료 상태가 의미 없음 + +**그러나 Chunk로 구현하면서 프레임워크의 운영 기능을 활용하는 것도 합리적이다:** +- `.faultTolerant().retry()`로 일시적 DB 에러에 대한 자동 재시도 +- `StepExecution`의 read/write count로 자동 모니터링 +- `StepMonitorListener`와 결합하여 실패 시 알림 + +Chunk의 네트워크 왕복 비용(100건 × ~10KB < 1ms)보다 이 운영 기능의 가치가 크므로, **Chunk를 쓰되 Reader SQL에서 비효율을 최소화하는 것**이 우리의 접근이다. + +### 실무 배치 프로젝트에서는 이 운영 기능을 쓰고 있는가? + +실무 배치 앱 2개(Spring Boot 3.3.4 + Batch 5.x, 총 90개 Job)를 분석한 결과: + +| 운영 기능 | 사용 여부 | +|----------|----------| +| `.faultTolerant()` | ❌ 없음 | +| `.retry()` / `retryLimit` | ❌ 없음 | +| `.skip()` / `skipLimit` | ❌ 없음 | +| `ItemReadListener` / `ItemWriteListener` | ❌ 없음 | +| `ChunkListener` / `SkipListener` | ❌ 없음 | +| `allowStartIfComplete` (restart) | ❌ 없음 | + +**90개 Job 중 단 하나도 retry, skip, restart를 사용하지 않는다.** + +사용하는 Listener는 딱 2종류: +- `SingleJobExecutionListener` — 중복 실행 방지 (JobExecutionListener) +- `StepExecutionListener` — 검색 인덱스 Job 2개에서 다른 배치 실행 중인지 체크 + +이것은 **retry/skip 없이도 실무 운영이 가능하다**는 뜻이다. 그러나 좋은 설계인지는 별개의 문제다: +- retry 없이 운영 = 1건의 일시적 DB 에러가 전체 배치를 실패시킴 +- skip 없이 운영 = 1건의 데이터 오류가 나머지 수만 건의 처리를 막음 +- 이것은 **운영 리스크를 감수하는 것**이지, 모범 사례가 아니다 + +우리 프로젝트에서는 이 부분을 개선하여 `faultTolerant + retry`를 적용한다. 실무에서 빠져 있는 것을 보완하는 것도 의미 있는 설계 판단이다. + +### 코드 레벨 비교: Chunk vs Tasklet + +#### Tasklet 방식 (SQL 중심) + +```java +@Configuration +@RequiredArgsConstructor +public class ProductRankingMvTaskletJobConfig { + + public static final String JOB_NAME = "productRankingMvJob"; + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JdbcTemplate jdbcTemplate; + + @Bean(JOB_NAME) + public Job productRankingMvJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(cleanupStep()).on("FAILED").end() + .from(cleanupStep()).on("*").to(aggregateStep()) + .end() + .build(); + } + + @Bean + @JobScope + public Step cleanupStep() { + return new StepBuilder("cleanupStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + String scope = chunkContext.getStepContext() + .getJobParameters().get("scope").toString(); + String targetDate = chunkContext.getStepContext() + .getJobParameters().get("targetDate").toString(); + String table = "weekly".equals(scope) + ? "mv_product_rank_weekly" : "mv_product_rank_monthly"; + jdbcTemplate.update( + "DELETE FROM " + table + " WHERE period_key = ?", targetDate); + return RepeatStatus.FINISHED; + }, transactionManager) + .build(); + } + + @Bean + @JobScope + public Step aggregateStep() { + return new StepBuilder("aggregateStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + String scope = chunkContext.getStepContext() + .getJobParameters().get("scope").toString(); + String targetDate = chunkContext.getStepContext() + .getJobParameters().get("targetDate").toString(); + String table = "weekly".equals(scope) + ? "mv_product_rank_weekly" : "mv_product_rank_monthly"; + int days = "weekly".equals(scope) ? 6 : 29; + + jdbcTemplate.update(""" + INSERT INTO %s + (product_id, ranking, score, view_count, like_count, + sales_count, sales_amount, period_key) + SELECT product_id, DT_RNK, score, + total_view_count, total_net_like_count, + total_sales_count, total_net_sales_amount, ? + FROM ( + SELECT pm.product_id, + SUM(pm.view_count) AS total_view_count, + SUM(pm.like_count - pm.unlike_count) AS total_net_like_count, + SUM(pm.sales_count) AS total_sales_count, + SUM(pm.sales_amount - pm.cancel_amount_by_event_date) + AS total_net_sales_amount, + (0.1 * LOG10(GREATEST(SUM(pm.view_count),0)+1) / 7.0 + + 0.2 * LOG10(GREATEST(SUM(pm.like_count - pm.unlike_count),0)+1) / 7.0 + + 0.7 * LOG10(GREATEST(SUM(pm.sales_amount + - pm.cancel_amount_by_event_date),0)+1) / 7.0 + + UNIX_TIMESTAMP() * 1e-16) AS score, + RANK() OVER (ORDER BY + (0.1 * LOG10(GREATEST(SUM(pm.view_count),0)+1) / 7.0 + + 0.2 * LOG10(GREATEST(SUM(pm.like_count - pm.unlike_count),0)+1) / 7.0 + + 0.7 * LOG10(GREATEST(SUM(pm.sales_amount + - pm.cancel_amount_by_event_date),0)+1) / 7.0 + + UNIX_TIMESTAMP() * 1e-16) DESC) AS DT_RNK + FROM product_metrics pm + JOIN product p ON pm.product_id = p.id + WHERE pm.metric_date BETWEEN DATE_SUB(STR_TO_DATE(?, '%%Y%%m%%d'), + INTERVAL %d DAY) AND STR_TO_DATE(?, '%%Y%%m%%d') + AND p.deleted_at IS NULL + GROUP BY pm.product_id + ) ranked + WHERE DT_RNK <= 100 + """.formatted(table, days), + targetDate, targetDate, targetDate); + return RepeatStatus.FINISHED; + }, transactionManager) + .build(); + } +} +``` + +- **장점**: 네트워크 왕복 0. 코드가 짧다. SQL 한 문장으로 집계+정렬+적재 완료 +- **단점**: retry/skip 없음. SQL이 비대함. score 공식 단위 테스트 불가 + +#### Chunk 방식 (우리 구현) + +```java +@Configuration +@RequiredArgsConstructor +public class ProductRankingMvJobConfig { + + public static final String JOB_NAME = "productRankingMvJob"; + private static final int CHUNK_SIZE = 100; + + @Bean(JOB_NAME) + public Job productRankingMvJob(Step cleanupStep, Step aggregateStep) { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(cleanupStep).on("FAILED").end() + .from(cleanupStep).on("*").to(aggregateStep) + .end() + .listener(jobListener) + .build(); + } + + @Bean + @StepScope + public JdbcCursorItemReader mvMetricsReader( + @Value("#{jobParameters['targetDate']}") String targetDate, + @Value("#{jobParameters['scope']}") String scope) { + int days = "weekly".equals(scope) ? 6 : 29; + return new JdbcCursorItemReaderBuilder() + .name("mvMetricsReader") + .dataSource(dataSource) + .sql(""" + SELECT pm.product_id, ... , + (0.1 * LOG10(...) + ...) AS score + FROM product_metrics pm + JOIN product p ON pm.product_id = p.id + WHERE pm.metric_date BETWEEN ? AND ? + AND p.deleted_at IS NULL + GROUP BY pm.product_id + ORDER BY score DESC + LIMIT 100 + """) + .preparedStatementSetter(ps -> { /* 날짜 파라미터 바인딩 */ }) + .rowMapper((rs, rowNum) -> new RankedProductRow(...)) + .build(); + } + + @Bean + public Step aggregateStep(JdbcCursorItemReader reader, + ItemWriter writer) { + return new StepBuilder("aggregateStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(reader) + .processor(rankingProcessor()) // ranking 번호 부여 + .writer(writer) + .faultTolerant() + .retry(DeadlockLoserDataAccessException.class) + .retryLimit(3) + .listener(stepMonitorListener) + .build(); + } +} +``` + +- **장점**: retry로 일시적 DB 에러 자동 재시도. StepExecution에 read/write count 자동 기록. StepMonitorListener로 실패 시 알림 +- **단점**: 네트워크 왕복 2회 (100건, < 1ms). Processor가 ranking 부여만 하므로 역할이 가벼움 + +--- + +## 소재 4: Redis(Speed Layer) vs MV(Batch Layer) — Lambda Architecture 실전 + +### 이 고민이 시작된 맥락 + +설계 초기에 자연스럽게 나온 질문이다. Round 9에서 이미 Redis로 일간/주간/월간 랭킹을 제공하고 있다. **"그러면 MV 테이블을 왜 또 만드는가? Redis에 이미 있는 것을 DB에 다시 만드는 것은 중복이 아닌가?"** + +이 질문에 답하려면 Redis 랭킹(carry-over 근사치, 지수 감쇠)과 MV 랭킹(DB 원장 기반 균등 합산)이 **같은 결과를 내는지 다른 결과를 내는지**를 먼저 확인해야 했다. log₁₀의 비선형성을 숫자로 검증하면서 두 시스템이 실제로 다른 순위를 생성한다는 것을 확인했고, 이것이 Lambda Architecture에서 Speed Layer와 Batch Layer가 공존하는 이유와 연결되었다. + +### 핵심 질문 + +"Redis에서 이미 주간/월간 랭킹을 제공하고 있는데, 왜 MV를 또 만드는가?" + +### 운영 관점의 답 + +| 관점 | Redis (Speed Layer) | MV (Batch Layer) | +|------|---------------------|-------------------| +| **정확도** | carry-over 근사치 (일별 score 합산, 지수 감쇠) | DB 원장 기반 정확값 (메트릭 균등 합산) | +| **장애 내성** | Redis 다운 → 주간/월간 조회 불가 | DB만 살아있으면 조회 가능. Redis 장애 시 fallback | +| **데이터 관점** | "지금 뜨는 상품" (트렌드) | "기간 총 실적" (누적 성과) | +| **비즈니스 용도** | 메인 페이지 실시간 인기 | 기간별 베스트셀러, MD 리포트, 정산 참고 | +| **데이터 검증** | Redis 내부 데이터 확인 어려움 | SQL로 즉시 검증 가능 | + +### log₁₀ 비선형성이 만드는 실제 차이 + +``` +상품 X: 7일간 view = [100, 100, 100, 100, 100, 100, 100] (총 700) +상품 Y: 7일간 view = [0, 0, 0, 0, 0, 0, 700] (총 700) + +Redis (일별 score 합산): + X: 7 × log₁₀(101)/7 = 2.003 + Y: 6 × 0 + log₁₀(701)/7 = 0.406 + → X 압도적 유리 (꾸준한 상품 우대) + +MV (메트릭 합산 후 score): + X: log₁₀(701)/7 = 0.406 + Y: log₁₀(701)/7 = 0.406 + → 동점 (총 활동량 동일) +``` + +이 차이가 "두 시스템이 다른 특성을 갖는 이유"이며, 같은 원천 데이터(product_metrics)에서 출발하지만 계산 방식의 차이로 다른 관점의 랭킹을 제공한다. + +### 그러면 같은 API에 두 소스를 번갈아 쓰면 안 되는 이유 + +처음에는 "MV primary, Redis fallback"으로 설계했다. MV 배치가 실패하면 Redis에서 조회하는 구조였다. 하지만 **"우리는 Redis를 사용하는 목적과 MV를 사용하는 목적이 같아 설마?"**라는 질문에서 문제를 발견했다. + +Redis(지수 감쇠)와 MV(균등 합산)는 **같은 기간에 대해 다른 순위를 반환**한다. 이것을 fallback으로 쓰면: + +``` +정상 시: MV 조회 → 상품 A가 1위 (균등 합산) +MV 장애 시: Redis fallback → 상품 B가 1위 (지수 감쇠) +→ 사용자: "어제는 A가 1위였는데 오늘은 B가 1위?" +``` + +**다른 공식으로 계산한 결과를 같은 API의 fallback으로 쓰는 것은 데이터 일관성을 깨뜨린다.** 잘못된 순위를 보여주는 것보다 "현재 랭킹을 준비 중입니다"가 더 안전하다. + +### 최종 결정: 단일 소스 원칙 + +``` +daily → Redis (단일 소스) +weekly → MV (단일 소스, fallback 없음) +monthly → MV (단일 소스, fallback 없음) +``` + +Redis weekly/monthly(carry-over + ZUNIONSTORE)는 제거하거나 내부 모니터링용으로만 유지한다. 각 scope의 데이터 소스가 하나이므로, 소스 전환에 의한 순위 불일치가 발생하지 않는다. + +--- + +## 소재 5: Score 계산과 TOP-N 필터링 — DB에서 하는가, Java에서 하는가 + +### 이 고민이 시작된 맥락 + +처음에는 Reader에서 전체 상품을 조회하고 Processor에서 score를 계산한 후, Writer에서 TOP 100만 INSERT하는 구조를 설계했다. 그런데 **"어차피 삭제할 건데 전부 INSERT하는 게 비효율적이지 않아?"**라는 질문이 나왔다. 수만 건을 INSERT했다가 100건만 남기고 삭제하는 것은 불필요한 I/O다. + +그러면 Reader SQL에서 score 계산까지 처리하고 LIMIT 100으로 100건만 반환할 수 있는가? **"계산 전에 Reader가 100건만 조회할 수 있어? 그럼 그게 TOP 100인 게 맞아?"**라는 후속 질문으로 이어졌고, SQL 실행 순서(GROUP BY → SELECT → ORDER BY → LIMIT)를 분석하여 DB가 TOP 100을 보장한다는 것을 확인했다. + +### 검토한 방안 + +| 방안 | Reader | Processor | Writer | 비효율 포인트 | +|------|--------|-----------|--------|-------------| +| **A. Java 전체 처리** | 전체 조회 (수만 건) | score 계산 | 정렬 + TOP 100 INSERT | 수만 건을 Java로 읽어와서 정렬/필터링 — DB가 이미 최적화된 작업을 애플리케이션에서 반복 | +| **B. 전체 INSERT 후 삭제** | 전체 조회 | score 계산 | 전체 INSERT → Step 3에서 100위 밖 DELETE | 수만 건 INSERT 후 대부분 삭제 — 불필요한 I/O | +| **C. SQL에서 완료 (채택)** | GROUP BY + score + ORDER BY + LIMIT 100 → **100건만 반환** | ranking 부여 | 100건 INSERT | DB가 집계, 계산, 정렬, 필터링을 한 번에 처리 | + +### 방안 C가 효율적인 이유: SQL 실행 순서 + +``` +1. FROM / JOIN → product_metrics × product 조인 +2. WHERE → 날짜 범위 필터 +3. GROUP BY → product_id별 그룹핑 + SUM 집계 +4. SELECT → score 계산 (LOG10 등 수학 함수) +5. ORDER BY → score 내림차순 정렬 (전체 상품 대상) +6. LIMIT 100 → 상위 100건만 반환 +``` + +DB가 **전체 상품의 score를 계산하고 정렬한 후** 상위 100건만 네트워크로 전달한다. Reader는 100건만 받지만, 그 100건이 score 기준 TOP 100인 것은 DB가 보장한다. + +Reader SQL: + +```sql +SELECT + pm.product_id, + SUM(pm.view_count) AS total_view_count, + SUM(pm.like_count - pm.unlike_count) AS total_net_like_count, + SUM(pm.sales_count) AS total_sales_count, + SUM(pm.sales_amount - pm.cancel_amount_by_event_date) AS total_net_sales_amount, + ( + 0.1 * LOG10(GREATEST(SUM(pm.view_count), 0) + 1) / 7.0 + + 0.2 * LOG10(GREATEST(SUM(pm.like_count - pm.unlike_count), 0) + 1) / 7.0 + + 0.7 * LOG10(GREATEST(SUM(pm.sales_amount - pm.cancel_amount_by_event_date), 0) + 1) / 7.0 + + UNIX_TIMESTAMP() * 1e-16 + ) AS score +FROM product_metrics pm +JOIN product p ON pm.product_id = p.id +WHERE pm.metric_date BETWEEN :startDate AND :endDate + AND p.deleted_at IS NULL +GROUP BY pm.product_id +ORDER BY score DESC +LIMIT 100 +``` + +### 회사 배치 앱에서의 검증 + +회사 코드를 분석한 결과, **score 계산 + TOP-N 필터링을 SQL에서 처리하는 것이 실무 표준**이었다: + +**GoodsBestMapper.xml** — 상품 베스트 TOP 100: + +```sql +RANK() OVER (ORDER BY SUM(ORD_QTY) DESC) AS DT_RNK +... +WHERE DT_RNK <= 100 +``` + +- Java(GoodsBestServiceImpl)는 파라미터만 전달. 랭킹 로직 없음 +- DELETE → INSERT 패턴. SQL이 모든 계산을 처리 + +**GoodsNewMapper.xml** — 카테고리별 신상품 TOP 50: + +```sql +DENSE_RANK() OVER (PARTITION BY DISP_CTG_NO ORDER BY SYS_REG_DTM DESC) AS DT_RNK +WHERE DT_RNK <= 50 +``` + +**EtEntrEvltAgrtTrxMapper.xml** — 입점사 매출 상위 10%: + +```sql +PERCENT_RANK() OVER (ORDER BY SUM(ORD_AMT - CNCL_AMT) DESC) AS PERCENT_RNK +WHERE PERCENT_RNK <= 0.1 +``` + +**12개 매퍼에서 `RANK()`, `DENSE_RANK()`, `ROW_NUMBER()`, `PERCENT_RANK()` 윈도우 함수 사용.** Java에서 랭킹/스코어링을 처리하는 배치 Job은 없었다. + +### 트레이드오프: Score 공식의 이중 관리 + +SQL에 score 공식을 넣으면, RankingCorrectionJob(Java)과 MV Job(SQL)에 같은 공식이 두 곳에 존재한다. + +| 관점 | 분석 | +|------|------| +| **왜 허용 가능한가** | 두 Job은 입력이 다르다. RankingCorrectionJob은 **일간 메트릭**(CURDATE() 1일)을 읽고, MV Job은 **기간 합산 메트릭**(7일/30일 SUM)을 읽는다. 같은 공식이지만 적용 대상이 다르므로 하나의 Java 메서드를 공유하는 것이 오히려 부자연스럽다 | +| **변경 시 위험** | 가중치(0.1/0.2/0.7)나 MAX_LOG(7.0) 변경 시 두 곳 모두 수정 필요. 하지만 가중치는 `application.yml`에 정의되어 있으므로, SQL에서도 파라미터로 주입 가능 | +| **회사 코드 참고** | 회사는 score 공식이 SQL에만 존재(Java에 없음). 우리 프로젝트는 RankingCorrectionJob이 이미 Java에 공식을 가지고 있어서 이중 관리가 발생하지만, 이것은 두 Job의 역할이 다르기 때문에 합리적인 중복이다 | + +### 이 판단에서 배운 것 + +- **"어디서 계산하느냐"는 효율의 문제이지 패턴의 문제가 아니다.** Chunk-Oriented에서 Processor가 비즈니스 로직을 담당해야 한다는 것은 일반론이지, 모든 경우에 적용해야 하는 규칙이 아니다 +- **DB가 잘하는 일(집계, 정렬, 필터링)은 DB에서 끝내야 한다.** 수만 건을 Java로 읽어와서 정렬하는 것은 DB가 이미 최적화된 실행 계획으로 한 번에 처리할 수 있는 일을 애플리케이션에서 반복하는 것이다 +- **회사 코드가 이 판단을 뒷받침한다.** 12개 매퍼에서 윈도우 함수로 TOP-N을 처리하고, Java는 오케스트레이션만 하는 것이 이 회사의 실무 표준이다 + +--- + +## 소재 6: 사전 집계 파이프라인과 Chunk의 관계 + +### 이 고민이 시작된 맥락 + +Chunk의 가치가 "대량의 행을 안전하게 처리하는 것"이라면, **"Chunk의 이점을 누리려면 Flink/Spark 같은 사전 집계 파이프라인을 전략적으로 두어야 하는 걸까?"**라는 질문이 나왔다. 사전 집계(product_metrics)가 있어야 Chunk가 유용한 것인가, 아니면 별개의 문제인가? + +### 핵심 통찰: 사전 집계는 입력을, Chunk는 출력을 다룬다 + +``` +사전 집계 파이프라인 (Kafka → Flink/Spark → product_metrics): + 수억 건 이벤트 → 일간 집계 테이블 + → Reader의 입력 볼륨을 줄이는 것 + +Chunk-Oriented: + 대량의 행을 chunk 단위로 읽고-변환하고-적재 + → Writer의 출력 볼륨이 클 때 + 운영 안정성이 필요할 때 가치가 있는 것 + +둘은 서로 다른 문제를 해결한다. +``` + +사전 집계가 있어야 Chunk가 유용한 것이 아니다. Chunk의 가치는 **"프레임워크가 제공하는 retry, skip, restart, 모니터링을 활용하면서 대량의 행을 안정적으로 처리할 때"** 발휘된다. + +### 대규모 이커머스에서의 DB 부하 문제 + +쿠팡급(상품 100만, product_metrics 30일치 3,000만 행) 기준으로, Chunk든 Tasklet이든 **집계 쿼리의 DB 부하는 동일하다.** 진짜 해결해야 할 문제는 처리 모델 선택이 아니라 **"이 집계를 서비스 DB에서 할 것인가"**이다. 답은 Replica DB 또는 DW에서 집계하는 것이고, 이것은 두 방식 모두에 적용된다. + +### 우리의 접근 + +Chunk를 쓰되, Reader SQL에서 GROUP BY + score 계산 + ORDER BY + LIMIT 100까지 처리하여 **Java로 넘어오는 데이터를 100건으로 제한**했다. Chunk의 네트워크 왕복 비용(100건 × ~10KB < 1ms)보다 프레임워크가 제공하는 운영 기능(retry, 모니터링, restart)의 가치가 크므로, Chunk 선택은 합리적이다. + +--- + +## 소재 7: Best Practice 대조 — 이론 vs 우리 설계 vs 배치 프로젝트 분석 + +### 이 고민이 시작된 맥락 + +Spring Batch Chunk Best Practice 문서를 받아서 우리 설계와 대조해봤다. "Best Practice를 따르고 있는가?"만이 아니라, **"배치 프로젝트 90개 Job은 이 Best Practice를 얼마나 따르고 있는가?"**도 함께 비교하고 싶었다. 이론, 우리 설계, 배치 프로젝트 — 세 관점의 교차 분석에서 "retry/skip을 90개 Job 전부가 안 쓰고 있다"는 발견이 나왔다. + +### Spring Batch Chunk Best Practice를 3가지 관점에서 비교 + +| Best Practice | 이론적 권장 | 우리 설계 | 배치 프로젝트 분석 (90개 Job) | +|-------------|-----------|----------|--------------------------| +| **faultTolerant + retry** | 일시적 에러(데드락, 타임아웃) 자동 재시도. ExponentialBackOffPolicy로 간격 확보 | ✅ 적용. retry(3) + ExponentialBackOffPolicy | ❌ 90개 Job 전부 미사용. 1건 에러 = 전체 실패 | +| **skip policy** | 데이터 오류 시 건너뛰고 나머지 처리. SkipListener로 누락 추적 필수 | 미적용 (의도적). 100건이므로 skip 시 chunk scan(100번 재실행) 비용이 더 큼 | ❌ 미사용 | +| **ExponentialBackOffPolicy** | retry 시 100ms→200ms→400ms 간격. 즉시 재시도는 데드락 상태에서 반복 실패 | ✅ 적용 | ❌ retry 자체가 없으므로 해당 없음 | +| **Writer 벌크 처리** | JdbcBatchItemWriter로 JDBC batch INSERT. 개별 INSERT 루프는 안티패턴 | ✅ JdbcBatchItemWriter 사용 | ⚠️ Chunk Job(2개)은 MyBatisBatchItemWriter 사용. 하지만 GoodsReviewTotal은 행 단위 UPSERT 루프 (안티패턴) | +| **Cursor vs Paging 선택** | 단일 스레드 순차 → Cursor. 멀티스레드/재시작 → Paging | ✅ JdbcCursorItemReader (단일 스레드). 병렬화 시 전환 필요 명시 | Cursor(2개), Paging(2개) 혼용 | +| **Processor에서 DB 수정 금지** | 쓰기는 Writer에서만. 트랜잭션 경계 명확화 | ✅ Processor는 ranking 부여만 | ✅ 준수 (Processor에서 DB 수정하는 Job 없음) | +| **cleanupStep allowStartIfComplete** | 멱등한 Step은 재시작 시에도 항상 실행 허용 | ✅ 적용 | ❌ 해당 설정 없음 | +| **saveState(false)** | 재시작 불필요한 Step은 상태 저장 오버헤드 제거 | 미적용. 100건이므로 오버헤드 무시 가능 | ❌ 해당 설정 없음 | +| **SkipListener.onSkipInWrite** | skip된 아이템을 별도 기록하여 데이터 정합성 추적 | 해당 없음 (skip 미적용) | ❌ skip 자체가 없음 | +| **StepExecutionListener** | Step 시작/종료/실패 시 모니터링, 알림 | ✅ StepMonitorListener (기존 인프라) | ⚠️ 2개 Job에서만 사용 (검색 인덱스). 나머지 88개 Job은 미사용 | + +### 이 비교에서 드러나는 것 + +**배치 프로젝트 90개 Job이 retry/skip/restart를 하나도 쓰지 않는다는 것은 주목할 만하다.** 이것은 두 가지로 해석할 수 있다: + +1. **"운영에서 문제가 없었다"**: 배치 데이터가 안정적이고, 실패 빈도가 낮아서 전체 재실행으로 충분히 대응 가능했을 수 있다. 실제로 통계/집계 Job은 대부분 수초~수분 내 완료되므로 전체 재실행 비용이 낮다. + +2. **"운영 리스크를 감수하고 있다"**: 1건의 일시적 에러가 전체 배치를 실패시키는 구조다. 야간 배치가 데드락으로 실패하면 아침에 출근해서 수동 재실행해야 한다. retry를 걸어두면 자동으로 복구됐을 에러다. + +**우리 프로젝트에서 retry + ExponentialBackOffPolicy를 적용하는 것은, 배치 프로젝트에서 빠져 있는 운영 안정성을 보완하는 설계 판단이다.** "남들이 안 쓰니까 안 써도 된다"가 아니라, "프레임워크가 제공하는 운영 기능을 활용하여 야간 배치의 자동 복구 가능성을 높인다"는 근거다. + +### Writer skip 시 chunk scan 문제 + +Best Practice에서 중요한 경고: **Writer에서 skip이 발생하면 해당 chunk 전체가 롤백되고, 아이템을 1개씩 재실행하는 "chunk scan"이 발생한다.** chunkSize=1000이면 최악의 경우 1000번 개별 실행. + +이것이 우리가 skip을 적용하지 않는 이유 중 하나다. 100건에서 skip이 발생하면 100번 개별 INSERT가 실행되는데, 벌크 INSERT의 이점이 사라진다. 100건이라 성능 차이는 미미하지만, skip의 목적(불량 레코드 건너뛰기)이 이 시나리오에서 의미가 없다 — 100건 모두 같은 SQL로 계산된 결과이므로, 1건이 실패하면 SQL 자체의 문제이지 데이터 오류가 아니다. + +--- + +## 소재 8: CursorReader vs PagingReader — GROUP BY 집계 쿼리에서의 선택 + +### 이 고민이 시작된 맥락 + +**"우리는 Cursor Reader방식인거야? 이걸 선택한 이유가 뭐야?"**라는 질문에서 시작했다. 처음에는 "기존 RankingCorrectionJob과 일관성"이라고 답했지만, 대규모 이커머스 기준으로 다시 따져보니 **GROUP BY 집계 쿼리에서 Cursor와 Paging의 동작 차이**가 핵심 판단 기준이었다. + +### 핵심: PagingReader는 GROUP BY 집계 쿼리에서 치명적이다 + +PagingReader는 페이지마다 **독립된 쿼리를 재실행**한다. 단순 WHERE + ORDER BY 쿼리에서는 문제없지만, GROUP BY가 포함된 집계 쿼리에서는 **매 페이지마다 전체 데이터를 다시 집계**한다: + +``` +CursorReader: + GROUP BY 3,000만 행 → 1번 실행 → 결과 스트리밍 + 총 집계 실행: 1회 + +PagingReader (pageSize=1000, 상품 100만 건 = 1,000페이지): + 페이지 1: GROUP BY 3,000만 행 → 정렬 → OFFSET 0 LIMIT 1000 (30초) + 페이지 2: GROUP BY 3,000만 행 → 정렬 → OFFSET 1000 LIMIT 1000 (30초) + ... + 페이지 1000: GROUP BY 3,000만 행 → 정렬 → OFFSET 999000 LIMIT 1000 (30초+) + 총 집계 실행: 1,000회 → 8시간 이상 +``` + +### 대규모 이커머스 기준 비교 + +| 관점 | CursorReader | PagingReader | +|------|-------------|-------------| +| **GROUP BY 집계 쿼리** | ✅ 1회 실행 후 결과 스트리밍 | ❌ 페이지마다 집계 재실행. 대규모에서 치명적 | +| **커넥션 점유** | ❌ Step 전체 동안 1개 점유 | ✅ 페이지 조회 시만 점유, 사이에 반환 | +| **OFFSET 성능** | 해당 없음 | ❌ 뒤쪽 페이지일수록 스캔량 증가 | +| **데이터 변경 안전성** | ✅ 쿼리 시점 스냅샷 (커서 유지) | ❌ 페이지 간 데이터 변경 시 누락/중복 | +| **멀티스레드** | ❌ ResultSet 공유 상태 → 데이터 오염 | ✅ 각 스레드가 독립 쿼리 실행 | +| **재시작** | ⚠️ read count 기반 (제한적) | ✅ 페이지 번호 자동 저장 | + +### CursorReader가 멀티스레드에서 불가능한 이유 + +CursorReader는 하나의 DB 커넥션에서 **하나의 ResultSet을 열어두고 `next()`로 한 행씩 이동**한다. ResultSet은 "지금 커서가 가리키는 행"이라는 상태를 가지고 있다: + +``` +Thread A: reader.read() → resultSet.next() → row 3 반환 +Thread B: reader.read() → resultSet.next() → row 4 반환 ← 동시 호출 + +→ 커서가 2칸 전진하여 row 누락 +→ 또는 Thread A가 읽으려던 행을 Thread B가 밀어버림 (데이터 오염) +``` + +PagingReader는 페이지마다 **별도 쿼리를 별도 커넥션으로 실행**하므로 공유 상태가 없어 안전하다. + +### 커넥션 점유 문제의 해법 + +CursorReader의 커넥션 점유가 문제가 되는 것은 **여러 Job이 동시에 실행되어 커넥션 풀이 고갈**될 때다. 이것을 해결하기 위해 PagingReader로 전환하면 GROUP BY 반복 실행이라는 더 큰 문제가 생긴다. + +**정석적 해법은 배치 전용 DataSource(Replica) 분리다.** 배치가 Replica에서 읽으면 서비스 DB의 커넥션 풀과 독립되므로, CursorReader의 커넥션 점유가 서비스에 영향을 주지 않는다. 분석한 배치 프로젝트 2개도 RODB/RWDB를 5~6쌍으로 분리하여 이 문제를 해결하고 있었다. + +### 병렬화가 필요해지면: Partitioning + +상품이 수백만 건으로 늘어나 병렬 처리가 필요해지면, PagingReader로 전환하는 대신 **Partitioning**이 적합하다: + +``` +Master Step: product_id 범위를 파티션으로 분할 + ├── Partition 1: product_id 1~100,000 → CursorReader (독립 커넥션) + ├── Partition 2: product_id 100,001~200,000 → CursorReader (독립 커넥션) + ├── Partition 3: product_id 200,001~300,000 → CursorReader (독립 커넥션) + └── ... + +각 파티션이 독립 커넥션 + 독립 CursorReader → GROUP BY 1회 + 병렬 처리 +``` + +CursorReader의 장점(1회 쿼리)을 유지하면서 병렬화를 달성한다. + +### 결론: CursorReader + Partitioning으로 두 가지를 모두 해결 + +| 판단 | 근거 | +|------|------| +| **CursorReader 선택** | GROUP BY 집계 쿼리에서 PagingReader는 페이지마다 집계를 재실행하므로 부적합 | +| **Partitioning 적용** | CursorReader의 장점(1회 쿼리)을 유지하면서 멀티스레드 한계를 극복. 각 Worker가 독립 커넥션 + 독립 CursorReader | +| **커넥션 점유 대응** | 다중 Job 동시 실행 시 Replica DataSource 분리 | + +--- + +## 소재 9: CursorReader는 병렬화할 수 없는데, 대규모 집계를 어떻게 빠르게 처리하는가? + +### 이 고민이 시작된 맥락 + +``` +"CursorReader가 GROUP BY에 적합하다" + → "그런데 CursorReader는 멀티스레드에서 사용 불가하다" + → "대규모(상품 100만)에서 단일 스레드로 30초 걸리면?" + → "PagingReader로 바꾸면 페이지마다 GROUP BY 재실행 (더 느림)" + → "CursorReader를 유지하면서 병렬화하는 방법은?" + → Partitioning +``` + +### Partitioning으로 해결하는 구조 + +``` +Step 2: partitionedAggregateStep + + [Partitioner] product_id MIN~MAX를 gridSize(4)개 범위로 분할 + + ┌─────────────────────────────────────────────────────────┐ + │ [Worker 1] [Worker 2] │ + │ id: 1~250,000 id: 250,001~500,000 │ ← 병렬 실행 + │ 독립 CursorReader 독립 CursorReader │ + │ 독립 DB 커넥션 독립 DB 커넥션 │ + │ GROUP BY 750만 행 GROUP BY 750만 행 │ + │ → 스테이징 INSERT → 스테이징 INSERT │ + ├─────────────────────────────────────────────────────────┤ + │ [Worker 3] [Worker 4] │ + │ id: 500,001~750,000 id: 750,001~1,000,000 │ ← 병렬 실행 + │ ... ... │ + └─────────────────────────────────────────────────────────┘ + │ + ▼ + Step 3: mergeStep (Tasklet) + SELECT ... FROM staging ORDER BY score DESC LIMIT 100 + → INSERT INTO mv_product_rank_{scope} +``` + +각 Worker가 **독립 커넥션 + 독립 CursorReader**를 가지므로 ResultSet 공유 문제가 없다. CursorReader의 장점(GROUP BY 1회 실행)을 유지하면서 병렬 처리를 달성한다. + +### 왜 PagingReader 병렬화가 아닌 Partitioning인가 + +| 방식 | GROUP BY 실행 횟수 | 소요 시간 (상품 100만) | +|------|-----------------|---------------------| +| 단일 CursorReader | 1회 (3,000만 행) | ~30초 | +| PagingReader 멀티스레드 | 페이지 수 × 스레드 수 (매번 3,000만 행 GROUP BY) | **수 시간** | +| **Partitioning + CursorReader** | Worker 수 (각 750만 행) | **~10초** | + +PagingReader를 멀티스레드로 돌리면 각 스레드가 **전체 3,000만 행에 대한 GROUP BY를 매 페이지마다 재실행**한다. Partitioning은 데이터를 범위로 분할하여 각 Worker가 **자기 범위의 데이터만 GROUP BY**하므로 근본적으로 다르다. + +### Global TOP 100 문제와 Map-Reduce 패턴 + +Partitioning만으로는 Global TOP 100을 구할 수 없다: + +``` +Worker 1의 로컬 1위: score 0.85 → 글로벌에서는 50위일 수 있음 +Worker 4의 로컬 3위: score 0.92 → 글로벌에서는 1위일 수 있음 +``` + +이것은 분산 시스템의 전형적인 **Map-Reduce** 문제다: +- **Map** (병렬): 각 Worker가 자기 범위를 집계 → 스테이징 테이블에 적재 +- **Reduce** (단일): 스테이징 전체에서 글로벌 정렬 → TOP 100 추출 + +스테이징 테이블이 이 두 단계를 연결하는 중간 저장소 역할을 한다. + +### 성능 산정 (쿠팡급) + +``` +상품 100만, product_metrics 30일치 3,000만 행, Worker 4개: + +Step 1 (cleanup): ~0.1초 (DELETE 2개) +Step 2 (partition): ~10초 (각 Worker GROUP BY 750만 행 × 4 병렬) +Step 3 (merge): ~2초 (스테이징 100만 행 정렬 + TOP 100) +──────────────────────────── +총 소요: ~12초 (단일 스레드 대비 3배 빠름) +``` + +### 트레이드오프 + +| 관점 | 단일 CursorReader | Partitioning | +|------|-------------------|-------------| +| **성능** | ~30초 | ~12초 (3배 향상) | +| **구현 복잡도** | 낮음 (2 Step) | 높음 (3 Step + Partitioner + 스테이징) | +| **스테이징 테이블** | 불필요 | 필요 (상품 수만큼 행) | +| **커넥션 사용** | 1개 | Worker 수만큼 (4~10개) | +| **장애 복구** | 전체 재실행 | 실패한 파티션만 재실행 가능 | +| **스케일 아웃** | 불가 (단일 스레드) | gridSize 조정으로 선형 확장 | + +구현 복잡도가 높아지지만, **"대량의 데이터를 읽고 처리할 수 있도록 구성"**이라는 요구사항에 부합하고, 쿠팡급 스케일에서 실제로 동작 가능한 구조다. + +--- + +## 소재 10: Partitioning 도입 후 멱등성은 어떻게 보장하는가? + +### 이 고민이 시작된 맥락 + +단일 CursorReader에서는 멱등성이 단순했다: + +``` +Step 1: DELETE WHERE period_key = ? → 기존 MV 데이터 삭제 +Step 2: INSERT TOP 100 → 새 데이터 적재 +→ 몇 번을 실행해도 결과 동일 +``` + +Partitioning을 도입하면서 **스테이징 테이블이 추가**되었다. 이제 멱등성 시나리오가 복잡해진다: + +### Step 2에서 일부 Worker만 실패하면? + +``` +Step 1: DELETE MV + DELETE 스테이징 ✓ +Step 2: Worker 1 ✓, Worker 2 ✓, Worker 3 ✗ (DB 에러), Worker 4 ✓ + → 스테이징에 Worker 1,2,4의 데이터만 존재 (Worker 3 누락) + → Step 2 FAILED → Step 3 미실행 +``` + +재실행 시 Spring Batch는 **이미 COMPLETED된 파티션은 건너뛰고 실패한 파티션만 재실행**할 수 있다. 하지만 Step 1의 `allowStartIfComplete(true)`가 스테이징을 전부 DELETE하면, 성공한 Worker 1,2,4의 데이터도 사라진다. + +### 해결: 전체 재실행이 가장 단순하고 안전 + +``` +재실행: + Step 1: DELETE MV + DELETE 스테이징 (전부 정리) + Step 2: Worker 1~4 전체 재실행 (전체 재적재) + Step 3: 스테이징 → MV TOP 100 +``` + +수십 초 수준의 작업이므로 전체 재실행 비용이 문제되지 않는다. "실패한 파티션만 재실행"하는 최적화보다 "전부 정리하고 처음부터"가 운영상 안전하다. 부분 재실행은 스테이징의 정합성을 보장하기 어렵다. + +--- + +## 소재 11: 같은 날짜로 Job을 두 번 돌리면 어떻게 되는가? — Job Instance 동일성 + +### 이 고민이 시작된 맥락 + +``` +01:00 주간 MV Job 실행 (targetDate=20260416, scope=weekly) → 성공 +01:30 데이터 오류 발견 → 수정 후 같은 파라미터로 재실행하고 싶다 +``` + +Spring Batch는 `jobName + identifying JobParameters`로 Job Instance를 식별한다. 같은 파라미터로 재실행하면 "이미 완료된 Instance"라고 거부할 수 있다. + +### RunIdIncrementer가 해결 + +```java +.incrementer(new RunIdIncrementer()) +``` + +RunIdIncrementer는 기존 파라미터를 보존하면서 `run.id`를 1씩 증가시킨다. `run.id`는 non-identifying이므로 Job Instance 식별에 영향을 주지 않는다: + +``` +실행 1: targetDate=20260416, scope=weekly, run.id=1 → Instance A, Execution 1 +실행 2: targetDate=20260416, scope=weekly, run.id=2 → Instance A, Execution 2 (재실행 허용) +``` + +cleanupStep이 DELETE로 시작하므로, 재실행 시 이전 결과를 덮어쓴다 → 멱등성 보장. + +### 배치 프로젝트의 UniqueRunIdIncrementer와의 차이 + +배치 프로젝트의 UniqueRunIdIncrementer는 **모든 파라미터를 버리고 run.id만 남겼다**. 이 방식은 targetDate, scope를 `@Value("#{jobParameters[...]}")`로 주입받을 수 없다. 우리는 파라미터 보존이 필요하므로 기본 RunIdIncrementer를 사용한다. + +--- + +## 소재 12: 매번 원장에서 재계산하는 것이 효율적인가? — 전체 재계산 vs 증분 계산 + +### 이 고민이 시작된 맥락 + +MV가 매일 원장(product_metrics)에서 7일/30일치를 처음부터 GROUP BY한다는 설계를 보고 질문이 나왔다: **"매번 원장에서 새로 계산하는 게 효율적일까? 랭킹은 약간 정도는 틀어져도 사용자가 모를 텐데, 효율성과 장애 대응 관점에서도 고민해야 하지 않을까?"** + +증분 계산(어제 결과 - 가장 오래된 날 + 오늘)이 데이터 처리량을 93%(월간 기준) 줄일 수 있다. 이것이 더 나은 선택이 아닌가? + +그러다 **"주문 취소 건을 제외하고 랭킹을 집계한다면 전체 재계산이 더 의미있지 않을까?"**라는 질문이 결정적 근거를 만들었다. + +### 증분 계산의 원리 + +``` +어제 MV (4/10~4/16 합산): 상품 A = view 700, sales 3000만 +오늘 MV (4/11~4/17 합산): + = 어제 결과 - 4/10의 메트릭 + 4/17의 메트릭 + → 30일치 GROUP BY 대신 2일치만 조회 (93% 절감) +``` + +수학적으로 정확하다. 근사치가 아니다. 하지만 **하나의 전제가 필요하다: "과거 데이터가 변경되지 않는다."** + +### 이커머스에서 이 전제가 깨지는 이유: Late-Arriving Fact + +주문 취소/환불은 원주문과 다른 날에 발생한다: + +``` +4/10: 상품 A 주문 100건 (1000만원) +4/15: 그 중 30건 취소 → product_metrics 4/10 행의 cancel_by_order_date 갱신 + +증분 계산: + 4/10의 값은 이미 MV에 반영됨 (취소 전 1000만원 기준) + 4/15에 4/10 행이 변경됐지만, 증분은 "4/15의 메트릭만 추가"하므로 + → 4/10 행의 사후 변경을 감지 못함 + +전체 재계산: + 4/10~4/16 전체를 다시 읽음 + → 4/10 행의 cancel_by_order_date 변경이 자동 반영 +``` + +| 시나리오 | 전체 재계산 | 증분 계산 | +|---------|-----------|----------| +| 정상 주문 | ✅ 정확 | ✅ 정확 | +| 지연 취소 (주문 후 며칠 뒤) | ✅ 자동 반영 | ❌ 원주문 날짜 변경 감지 못함 | +| 운영팀 데이터 보정 | ✅ 다음 배치 자동 반영 | ❌ 전체 재계산을 별도 실행해야 함 | +| 오류 전파 | ❌ 없음 (매번 원장에서 독립 계산) | ⚠️ 어제 MV가 틀리면 오늘도 틀림 | + +### 성능 차이는 운영에 영향 없는 수준 + +| 방식 | 처리 데이터량 (월간) | 소요 시간 (Partitioning 4 Worker) | +|------|------------------|-------------------------------| +| 전체 재계산 | 30일분 | ~10초 | +| 증분 | 2일분 | ~3초 | + +**1일 1회 배치에서 10초 vs 3초는 운영 차이가 없다.** 증분이 유리해지는 전환점은 배치 주기가 5분 이하로 빈번해질 때다. + +### 결론 + +전체 재계산을 유지한다. 7초의 성능 이점보다 **Late-Arriving Fact 자동 반영 + 오류 자동 복구 + 구현 단순성**이 이커머스 랭킹 시스템에서 더 가치 있다. + +또한, MV의 존재 이유가 "Redis 근사치와 다른 정확한 기간 집계"인데, MV까지 과거 데이터 변경을 반영하지 못하는 증분 방식을 쓰면 MV의 정확성이 약해진다. + +--- + +## 소재 13: (구현 후 추가 예정) + +- MV vs Redis 실제 랭킹 비교 결과 (score 차이 분석) +- Partitioning 실제 성능 측정 결과 + +--- + +## 블로그 작성 시 참고자료 & 참고사례 목록 + +> 블로그 본문에서 관련 섹션에 실제 코드/이미지를 인용하여 사용할 것. + +### 참고자료 (Spring Batch 공식/기술 문서) + +| 자료 | URL | 블로그에서 활용할 부분 | +|------|-----|---------------------| +| Spring Batch Scalability 공식 문서 | https://docs.spring.io/spring-batch/reference/scalability.html | Partitioning 아키텍처 다이어그램, "IO-intensive Step" 언급, 4가지 스케일링 전략 비교 | +| ColumnRangePartitioner 공식 샘플 | https://github.com/SpringOne2GX-2014/spring-batch-performance-tuning/blob/master/sample_code/remote-partitioning/remote-partitioning-master/src/main/java/io/spring/remotepartitioningmaster/partition/ColumnRangePartitioner.java | MIN/MAX → 범위 분할 → ExecutionContext 코드. 우리 createPartitioner와 비교 | +| Baeldung Spring Batch Partitioner | https://www.baeldung.com/spring-batch-partitioner | TaskExecutorPartitionHandler 전체 구현 예시 | +| Partitioner 성능 개선 사례 (prostars.net) | https://prostars.net/357 | 파티션 1→5 변경 시 30초→17초 (1.8배). 스레드 풀 1 제한 시 2분 15초. 성능 비교 표 | + +### 참고사례 (빅테크 엔지니어링 블로그) + +| 사례 | URL | 블로그에서 활용할 부분 | +|------|-----|---------------------| +| Netflix Distributed Counter | https://netflixtechblog.com/netflixs-distributed-counter-abstraction-8d0c45eb66b2 | 시간 기반 파티셔닝 + Rollup 병렬 집계 → merge. 우리 Map-Reduce 3-Step과 구조 유사 | +| Shopify BFCM Flink | https://shopify.engineering/bfcm-live-map-2021-apache-flink-redesign | 텀블링 윈도우 5분 간격 TOP 500 집계. Redis 병목 → Flink 전환. Lambda vs Kappa 비교 | +| Flipkart Unified Ranking | https://blog.flipkart.tech/the-science-of-unified-ranking-integrating-ads-and-organic-recommendations-8cc24113ef21 | 일간 배치로 relevance score 계산. aggregate features 설계 | +| eBay Analytics Data Processing | https://innovation.ebayinc.com/stories/optimizing-analytics-data-processing-on-ebays-new-open-source-based-platform/ | ETL 배치 최적화, 일간 테이블 갱신 | +| Airbnb Search Ranking Pipeline | https://medium.com/airbnb-engineering/machine-learning-powered-search-ranking-of-airbnb-experiences-110b4b1a0789 | 랭킹 파이프라인 offline 배치 실행, daily Airflow | + +### 기술 블로그 작성 참고 + +| 자료 | URL | 활용 | +|------|-----|------| +| 토스 테크니컬 라이팅 가이드 | https://github.com/toss/technical-writing | "명확하고, 독자가 문제를 해결할 수 있는 글". 톤/구조 참고 | +| 토스 8가지 라이팅 원칙 | https://toss.tech/article/8-writing-principles-of-toss | "Clear" — 한 번에 이해되는 문장 | diff --git a/docs/design/volume-10/11-ranking-batch-test-blog.md b/docs/design/volume-10/11-ranking-batch-test-blog.md new file mode 100644 index 0000000000..3e70f1268c --- /dev/null +++ b/docs/design/volume-10/11-ranking-batch-test-blog.md @@ -0,0 +1,299 @@ +# 이커머스 상품 랭킹 배치를 E2E 테스트하며 알게 된 것들 + +> Spring Batch + Testcontainers + 실 API 검증까지, 배치 파이프라인을 테스트하면서 겪은 문제와 발견. + +--- + +## 1. 이 테스트는 무엇을 위한 것인가 + +쿠팡, 무신사 같은 이커머스에서 "인기 상품 TOP 100"은 단순한 조회가 아니다. +조회수, 좋아요, 매출, 취소를 조합한 **Score 계산**, 일간/주간/월간이라는 **시간 윈도우**, 그리고 실시간과 배치라는 **이중 경로**가 얽혀 있다. + +``` +[실시간 경로] Kafka → Redis ZSET → daily 랭킹 (빠르지만 근사치) +[배치 경로] DB 원장 → Spring Batch → MV 테이블 → weekly/monthly 랭킹 (느리지만 정확) +``` + +이 글에서 다루는 것은 **배치 경로의 E2E 테스트**다. "배치 Job이 돌았다"가 아니라, **"배치가 만든 데이터로 API가 의미 있는 결과를 내는가"** 를 검증했다. + +테스트를 통해 확인하고 싶었던 질문: + +- 3-Step 파이프라인(Cleanup → Partitioned Aggregate → Merge)이 정상 동작하는가? +- product_id 범위 분할 Partitioning이 데이터 누락 없이 집계하는가? +- 취소(cancel_amount)가 Score에 정확히 반영되는가? +- 같은 파라미터로 2회 실행해도 멱등성이 보장되는가? +- 데이터가 없거나 부분적일 때 Job이 안전하게 완료되는가? +- **일간/주간/월간 랭킹이 실제로 서로 다른 결과를 보여주는가?** + +--- + +## 2. 배치 구조: 3-Step 파이프라인 + +``` +Step 1: CleanupTasklet + └─ 기존 period_key 데이터 삭제 (멱등성 보장) + └─ 3일 이전 데이터 자동 퍼지 + +Step 2: Partitioned Aggregate (병렬) + └─ product_id MIN~MAX 범위를 4파티션으로 분할 + └─ 각 파티션이 독립적으로 Score 계산 → staging 테이블 적재 + └─ Score = 0.1×LOG10(view+1)/7 + 0.2×LOG10(like+1)/7 + 0.7×LOG10(net_sales+1)/7 + +Step 3: Merge + └─ staging에서 Global TOP 100 추출 → MV 테이블 적재 + └─ ROW_NUMBER() OVER (ORDER BY score DESC) LIMIT 100 +``` + +핵심은 **Map-Reduce 패턴**이다. 각 파티션(Map)이 독립적으로 score를 계산하고, Merge 단계(Reduce)에서 전체 순위를 매긴다. + +--- + +## 3. E2E 테스트: 10개 시나리오와 그 의미 + +### 테스트 환경 + +| 항목 | 값 | +|------|-----| +| DB | MySQL 8.0 (Testcontainers) | +| 프레임워크 | `@SpringBatchTest` + `@SpringBootTest` | +| 데이터 | 테스트마다 독립 시드 (JdbcTemplate INSERT) | + +### 시나리오 목록 + +| # | 테스트 | 시나리오 | 검증 포인트 | +|---|--------|----------|-------------| +| 1 | **weeklySuccess** | 상품 150개 + 7일 메트릭 | 100건 적재, 1위 정확성, 전체 파이프라인 | +| 2 | **weeklyLessThan100** | 상품 30개 | LIMIT 100이지만 30건만 적재 | +| 3 | **monthlySuccess** | 상품 50개 + 30일 메트릭 | monthly 테이블 분기 | +| 4 | **idempotent** | 동일 파라미터 2회 실행 | 중복 없이 동일 결과 | +| 5 | **noData** | 메트릭 0건 | Job FAILED 아닌 COMPLETED | +| 6 | **partialData** | 7일 중 3일만 | 있는 만큼만 집계 | +| 7 | **cancellation** | 매출 200만/취소 150만 vs 매출 100만/취소 0 | 순매출 기준 순위 | +| 8 | **printRankingResults** | 20개 상품 × 30일 (5가지 패턴) | 일간/주간/월간 TOP 20 시각화 출력 | +| 9 | **largeScale** | 10만 상품 × 30일 (300만 행) | 4 Partition 병렬 집계, 파티션 균등 분배, 1위 정확성 | +| 10 | **partitionBenchmark** | gridSize=1 vs gridSize=4 | Partitioning 성능 효과 정량 측정 (2.1x 향상) | + +7~10번 시나리오 중 처음 작성했을 때 기능 테스트(1~7) **모두 실패**했다. 테스트 프레임워크와의 충돌 때문이었다. + +--- + +## 4. 테스트를 작성하며 발견한 문제들 + +### 문제 1: `@SpringBatchTest`가 private 메서드를 몰래 실행한다 + +``` +No matching arguments found for method: runJob +``` + +`@SpringBatchTest`의 `JobScopeTestExecutionListener`는 테스트 클래스의 **모든 메서드**를 스캔한다. 접근 제어자와 무관하게 `getDeclaredMethods()`로 전부 가져온다. 이때 `JobExecution`을 반환하는 메서드를 찾으면 **인자 없이 호출을 시도**한다. + +테스트 헬퍼 메서드를 이렇게 만들었다가 걸렸다: + +```java +// AS-IS: 이렇게 하면 listener가 이 메서드를 발견하고 runJob() 호출 시도 → 실패 +private JobExecution runJob(String scope) throws Exception { ... } +``` + +`JobExecution` 반환 타입이 탐지 조건이었으므로, 반환 타입만 바꾸면 해결된다: + +```java +// TO-BE: BatchStatus를 반환하면 listener 스캔 대상에서 제외 +private BatchStatus runJob(String scope) throws Exception { ... } +``` + +Spring Batch 내부의 `HippyMethodInvoker`(실제 클래스명이다)가 메서드 시그니처로 대상을 결정한다. 공식 문서에는 이 동작이 기술되어 있지 않다. + +### 문제 2: `@JobScope` Partitioner Bean과 `@SpringBatchTest`의 충돌 + +``` +SpelEvaluationException: EvaluationContext has no variable 'jobParameters' +``` + +Partitioner Bean에 `@Value("#{jobParameters['targetDate']}")`를 사용하려면 `@JobScope`가 필요하다. 하지만 `@JobScope`를 붙이면 `@SpringBatchTest`의 `JobScopeTestExecutionListener`와 충돌한다. + +해결: Partitioner를 Bean이 아닌 **private 메서드**로 변경했다. + +```java +// AS-IS: Bean으로 등록하면 @JobScope 필요 → listener와 충돌 +@JobScope @Bean +public Partitioner productIdPartitioner( + @Value("#{jobParameters['targetDate']}") String targetDate) { ... } + +// TO-BE: 이미 @JobScope인 step 메서드에서 직접 호출 +@JobScope @Bean("partitionedAggregateStep") +public Step partitionedAggregateStep( + @Value("#{jobParameters['targetDate']}") String targetDate, + @Value("#{jobParameters['scope']}") String scope) { + return new StepBuilder(...) + .partitioner("workerStep", createPartitioner(targetDate, scope)) + ... +} + +private Partitioner createPartitioner(String targetDate, String scope) { ... } +``` + +`targetDate`, `scope`는 이미 `@JobScope`인 step 메서드의 파라미터로 주입받으므로 Partitioner가 별도 Bean일 필요가 없었다. 더 단순한 구조가 더 테스트하기 쉬운 구조이기도 했다. + +--- + +## 5. 테스트 데이터 설계에서 발견한 함정 + +### "7일 데이터로는 주간과 월간의 차이를 증명할 수 없다" + +처음에는 모든 테스트에 7일치 데이터만 시딩했다. 7개 시나리오는 모두 통과했지만, **시각화 테스트를 추가했을 때** 문제가 드러났다: + +> 주간 랭킹과 월간 랭킹의 수치가 완전히 동일하다. + +당연하다. 주간은 7일 윈도우, 월간은 30일 윈도우인데, 데이터가 7일밖에 없으니 양쪽 모두 같은 7일을 집계한 것이다. + +이건 **테스트가 통과했지만 아무것도 증명하지 못한** 상태다. `monthlySuccess` 테스트는 "30일 윈도우로 쿼리한다"는 것만 확인했을 뿐, "30일 데이터가 7일 데이터와 다른 랭킹을 만든다"는 핵심 가정을 검증하지 않았다. + +### 해결: 30일 데이터 + 6가지 트렌드 패턴 + +``` +A) 급상승 (5%): 과거 23일 미미 → 최근 7일 폭발 +B) 장기강자 (10%): 30일 꾸준히 높음 +C) 하락추세 (5%): 과거 23일 높음 → 최근 7일 급락 +D) 바이럴 (2%): 오늘 하루만 폭발 +E) 취소높음 (3%): 매출 높지만 취소 50~70% +F) 일반 (75%): 보통 수준 +``` + +이 패턴으로 30일 데이터를 시딩하자, 일간/주간/월간 랭킹이 **완전히 다른 TOP 20**을 보여주었다. + +--- + +## 6. 가장 중요한 발견: 시간 윈도우가 랭킹을 결정한다 + +10만 상품 × 30일(300만 행) 대규모 테스트에서, 동일한 6가지 트렌드 패턴 데이터로 weekly와 monthly를 실행한 결과: + +- **weekly 1위**: product_5000 (급상승 — 최근 7일 폭발) +- **monthly 1위**: product_15000 (장기강자 — 30일 꾸준히 높음) + +같은 데이터, 같은 Score 공식인데 시간 윈도우만 달라도 1위가 완전히 다르다. 이 현상을 1,020개 상품 + 실제 API로도 검증했다: + +| 순위 | 일간 (Redis) | 주간 (MV) | 월간 (MV) | +|:----:|-------------|-----------|-----------| +| 1 | 아디다스 캠퍼스 올리브 **(바이럴)** | 나이키 에어리프트 카키 **(급상승)** | 반스 슬립온 올리브 **(장기강자)** | +| 2 | 살로몬 아웃펄스 네이비 **(바이럴)** | 컨버스 런스타하이크 그레이 **(급상승)** | 스투시 카고바지 화이트 **(장기강자)** | +| 3 | 뉴발란스 530 올리브 **(바이럴)** | 스투시 월드투어후디 카키 **(급상승)** | 리복 클럽C85 인디고 **(장기강자)** | + +**같은 데이터, 같은 Score 공식인데 시간 윈도우만 달라도 1위부터 완전히 다르다.** + +| 상품 트렌드 | 일간 순위 | 주간 순위 | 월간 순위 | 해석 | +|------------|:---------:|:---------:|:---------:|------| +| 바이럴 (오늘만 폭발) | 1위 | 100위 밖 | 100위 밖 | 1일치만 반영 | +| 급상승 (최근 7일 폭발) | 중위권 | 상위 | 100위 밖 | 과거 23일 미미 | +| 장기강자 (30일 꾸준) | 하위 | 하위 | 상위 | 꾸준함의 축적 | +| 하락추세 (과거 높고 최근 급락) | 하위 | 하위 | 상위 | 과거 실적이 30일에 반영 | + +이것이 왜 중요한가? + +"인기 상품"의 정의가 시간 윈도우에 따라 완전히 달라진다. **하나의 랭킹만 제공하면 어떤 관점은 반드시 누락된다.** 일간만 보여주면 장기 스테디셀러가 사라지고, 월간만 보여주면 바이럴 상품이 보이지 않는다. 이커머스에서 일간/주간/월간 랭킹을 별도 제공하는 이유가 여기에 있다. + +--- + +## 7. Score 수식과 취소 반영 + +### Score 공식 + +```sql + 0.1 * LOG10(GREATEST(SUM(view_count), 0) + 1) / 7.0 ++ 0.2 * LOG10(GREATEST(SUM(net_like_count), 0) + 1) / 7.0 ++ 0.7 * LOG10(GREATEST(SUM(net_sales_amount), 0) + 1) / 7.0 ++ UNIX_TIMESTAMP() * 1e-16 +``` + +- **LOG10**: 조회수 100만과 200만의 차이가 1과 2만큼 크지 않게 만든다 (로그 스케일링) +- **가중치 0.1/0.2/0.7**: 매출 중심 랭킹 (view 10%, like 20%, sales 70%) +- **/7.0**: 일간 Score와 범위를 맞추기 위한 정규화 +- **UNIX_TIMESTAMP * 1e-16**: Score가 동일할 때 최신 데이터를 우선하는 타이브레이커 + +### 취소 반영 + +```sql +SUM(pm.sales_amount - pm.cancel_amount_by_event_date) AS total_net_sales_amount +``` + +테스트 시나리오: 상품A(매출 100만, 취소 0) vs 상품B(매출 200만, 취소 150만). +상품B의 총매출이 2배지만 순매출은 50만이므로, 상품A(순매출 100만)가 1위가 된다. **매출 크기가 아니라 순매출이 순위를 결정**한다는 것을 테스트로 확인했다. + +--- + +## 8. 배치 실행 성능 + +### 규모별 성능 비교 + +| 규모 | 상품 수 | 메트릭 행 수 | weekly | monthly | +|---------|--------|------------|--------|---------| +| 소규모 | 1,020 | 30,600 | 275ms | 309ms | +| **대규모** | **100,000** | **3,000,000** | **2,205ms** | **2,564ms** | + +10만 상품 × 30일(300만 행)에서 4 Partition 병렬 집계 + Merge까지 약 2.5초. 데이터가 100배(1,020 → 100,000) 증가해도 소요 시간은 ~8배만 증가했다 — Partitioning + GROUP BY 최적화로 **sub-linear scaling**을 달성한 것이다. + +### Partitioning 벤치마크: gridSize=1 vs gridSize=4 + +"Partitioning이 없었다면 단일 쿼리로 처리해야 하므로 데이터가 커질수록 차이가 벌어진다." — 이걸 실제로 측정해봤다. + +10만 상품 × 30일(300만 행)에서 gridSize만 1과 4로 바꿔서 같은 데이터를 2회 실행한 결과: + +| 구성 | weekly 소요 시간 | Worker당 상품 수 | +|------|----------------|--------------| +| gridSize=1 (단일 스레드) | **3,740ms** | 100,000 | +| gridSize=4 (4 Partition 병렬) | **1,763ms** | 25,000 | +| **향상률** | **2.1x** | | + +이론적 상한은 4x지만, 실측은 2.1x다. 차이의 원인: + +1. **Amdahl's Law**: Partitioner의 `SELECT DISTINCT product_id` 쿼리, mergeStep의 `ROW_NUMBER() OVER`, JobRepository 메타데이터 저장 등 **직렬 구간이 전체의 일부**를 차지한다. +2. **Testcontainers 환경 제약**: `innodb-buffer-pool-size=256M`으로 제한된 환경이므로, 프로덕션 MySQL에서는 더 큰 향상률이 기대된다. +3. **IO 경합**: 4개 Worker가 동시에 같은 MySQL 인스턴스에 접근하므로 디스크/메모리 경합이 발생한다. + +그래도 **2.1x는 의미 있는 수치**다. 1일 1회 배치에서 3.7초와 1.8초의 절대적 차이는 크지 않지만, 데이터가 10배(100만 상품)로 늘어나면 37초 vs 18초로 벌어진다. 병렬화의 효과는 규모에 비례한다. + +--- + +## 9. 실 환경 API 검증에서 발견한 것 + +E2E 테스트(Testcontainers)는 모두 통과했지만, **실제 commerce-api에서 weekly/monthly API를 호출하면 빈 결과**가 반환되었다. + +원인: commerce-api가 **화요일에 시작**되었는데, MV Entity 클래스는 그 이후에 추가되었다. `ddl-auto: create`로 시작 시 테이블은 만들어졌지만, **런타임에 해당 Entity의 Repository 코드 자체가 빌드에 없었다.** 앱을 재빌드하고 재시작하자 정상 동작. + +``` +MvProductRank*.class → 빌드에 없음 → getFromMv() 호출되어도 쿼리 실행 안 됨 +``` + +이건 E2E 테스트만으로는 잡을 수 없는 문제다. **E2E 테스트는 "코드가 맞는가"를 검증하지만, "배포된 버전이 최신인가"는 검증하지 않는다.** 실 환경에서 한 번 더 확인하는 것이 의미 있었던 이유다. + +--- + +## 10. 정리: 이 테스트로 무엇을 알게 되었는가 + +### 기술적으로 확인한 것 + +| 검증 항목 | 결과 | +|-----------|------| +| 3-Step 파이프라인 정상 동작 | Cleanup → Partitioned Aggregate → Merge | +| product_id 범위 분할 Partitioning | 4파티션, 데이터 누락 없음 | +| scope 분기 (weekly/monthly) | 각각 다른 MV 테이블에 적재 | +| 멱등성 | Cleanup + RunIdIncrementer로 보장 | +| 빈 데이터 / 부분 데이터 | Job COMPLETED, 안전 처리 | +| 취소 반영 | 순매출 기준 순위 결정 | +| 시간 윈도우별 랭킹 차이 | 일간/주간/월간 TOP 20이 완전히 다름 | +| Partitioning 성능 효과 | gridSize=1 대비 gridSize=4가 2.1x 빠름 (10만 상품 기준) | + +### 테스트 설계에서 배운 것 + +1. **"테스트가 통과한다"와 "의미 있는 것을 검증한다"는 다르다.** 7일 데이터로 월간 테스트를 돌리면 통과하지만 아무것도 증명하지 못한다. + +2. **테스트 데이터의 다양성이 테스트의 품질을 결정한다.** 6가지 트렌드 패턴(급상승, 장기강자, 하락, 바이럴, 취소, 일반)을 설계한 후에야 시간 윈도우별 차이가 드러났다. + +3. **Spring Batch 테스트 프레임워크에는 문서화되지 않은 동작이 있다.** `JobScopeTestExecutionListener`의 메서드 스캔, `HippyMethodInvoker`의 반환 타입 기반 탐지 등. + +4. **E2E 테스트와 실 환경 검증은 다른 것을 잡는다.** 코드 정합성은 Testcontainers가, 배포 정합성은 실 환경 API 호출이 잡는다. + +### 비즈니스 관점에서 확인한 것 + +시간 윈도우는 단순한 "기간 필터"가 아니다. **어떤 시간 윈도우를 선택하느냐가 "인기 상품"의 정의 자체를 바꾼다.** 오늘 SNS에서 터진 상품, 이번 주 꾸준히 팔린 상품, 한 달간 스테디셀러인 상품은 모두 "인기 상품"이지만, 하나의 랭킹으로는 세 관점을 동시에 담을 수 없다. + +Lambda Architecture(실시간 Redis + 배치 MV)를 선택한 이유도 여기에 있다. 실시간 경로는 "지금 뜨는 상품"을, 배치 경로는 "기간 동안 검증된 상품"을 각각 담당한다. 두 경로가 서로 다른 것은 버그가 아니라 설계 의도이며, 이 테스트는 그 설계 의도가 실제로 동작하는지를 확인하는 과정이었다. diff --git a/docs/design/09-event-review.md b/docs/design/volume-9/09-event-review.md similarity index 100% rename from docs/design/09-event-review.md rename to docs/design/volume-9/09-event-review.md diff --git a/docs/design/09-ranking-system-design.md b/docs/design/volume-9/09-ranking-system-design.md similarity index 100% rename from docs/design/09-ranking-system-design.md rename to docs/design/volume-9/09-ranking-system-design.md diff --git a/docs/design/09-ranking-system.md b/docs/design/volume-9/09-ranking-system.md similarity index 100% rename from docs/design/09-ranking-system.md rename to docs/design/volume-9/09-ranking-system.md diff --git a/docs/requirements/volume-10/10-batch-ranking-learning.md b/docs/requirements/volume-10/10-batch-ranking-learning.md new file mode 100644 index 0000000000..008c1fab75 --- /dev/null +++ b/docs/requirements/volume-10/10-batch-ranking-learning.md @@ -0,0 +1,170 @@ +# Round 10 — Collect, Stack, Zip + +--- + +## Overview + +> 서비스에서 다양한 가치를 창출하기 위해 대량의 데이터를 모으고, 쌓고, 압착해야 합니다. +> 데이터의 규모가 커지면, 점점 이런 작업들을 웹 애플리케이션 내에서 처리하는 것에 대한 부하가 가파르게 높아집니다. +> +> 그래서 우리는 마지막으로 `spring-batch` 애플리케이션을 만들어 볼 거예요. +> 이를 기반으로 일간 랭킹 뿐 아닌 주간, 월간 랭킹 또한 집계를 활용해 만들어 봅시다. + +**Summary** + +지난 라운드에서 Kafka Consumer 와 Redis ZSET 을 활용해 메세지를 압착해 처리량을 높이는 테크닉, 특정 점수 기준의 정렬 SET 활용 방법을 학습하고 실시간으로 갱신되는 일단위 랭킹을 만들어보았습니다. + +이번 라운드에서는 Spring Batch 를 이용해 주간, 월간 랭킹을 구현합니다. +**Batch** 는 일간 집계를 기반으로 주간, 월간 집계를 만들어내고 **API** 는 일간 랭킹 뿐 아니라 주간, 월간 랭킹도 제공합니다. + +**Keywords** + +- Spring Batch (Job / Step / Chunk / Tasklet) +- ItemReader / ItemProcessor / ItemWriter +- Materialized View (사전 집계) +- 실시간 처리 vs 배치 처리 + +--- + +## Batch System + +### Batch Processing 이 왜 필요할까? + +- **대규모 집계** + - 수억 건 데이터에 대한 합산, 평균, 통계는 실시간으로 처리하기엔 비용이 너무 크다. + - e.g. "지난 한 달간 상품별 매출 TOP 100" → 매 요청마다 계산하면 DB/Redis 부하로 서비스 전체가 흔들림 +- **운영 리포트/통계** + - 경영진 보고용, BI 툴, 월간 정산 등은 수 초 단위의 실시간성이 필요하지 않음 + - 정확성과 대량처리가 더 중요 → 하루 한 번 배치로 계산해도 충분 +- **데이터 정제 및 적재** + - 로그 수집 → 정제 → DW 적재 같은 과정은 실시간보다는 일정 주기 단위로 몰아서 처리하는 게 효율적 + +### 실무에서 자주 보는 배치 시나리오 + +- **주문 정산** — 주문/결제/환불 데이터를 모아 매일 새벽 3시 정산 테이블 생성. PG사 매출/정산 금액 검증도 함께. +- **랭킹/통계 적재** — 일간/주간/월간 인기 상품 집계, 카테고리별 판매량 통계 +- **데이터 정리/청소** — 만료된 쿠폰 삭제, 오래된 로그 제거, 캐시 초기화 +- **데이터 웨어하우스(DW) 적재** — 서비스 DB → DW(BigQuery, Redshift 등) 로 적재 후 분석 + +### 실시간 vs 배치 트레이드오프 + +| 항목 | 실시간 처리 | 배치 처리 | +|------|------------|----------| +| 장점 | 즉각 반영 → UX 좋음 | 대규모 집계, 비용 효율적 | +| 단점 | 인프라 복잡, 멱등성 관리 필요 | 지연 발생, 실시간성 부족 | +| 적합 | 좋아요 수, 실시간 랭킹 | 월간 리포트, 대시보드, BI | +| 초점 | **신속성** | **정확성 & 효율성** | + +--- + +## Spring Batch + +### 기본 구성 요소 + +- **Job** : 배치 실행 단위 (예: "일간 주문 통계 Job") +- **Step** : Job 을 구성하는 세부 단계 + +### 배치 처리 모델 + +**Chunk-Oriented Processing** + +- 데이터 읽기 (Reader) → 가공 (Processor) → 저장 (Writer) +- 청크 단위로 트랜잭션이 관리됨 → 안정적 대량 처리 + +```java +@Bean +public Step orderStatsStep( + JobRepository jobRepository, + PlatformTransactionManager txManager, + ItemReader reader, + ItemProcessor processor, + ItemWriter writer +) { + return new StepBuilder("orderStatsStep", jobRepository) + .chunk(1000, txManager) + .reader(reader) + .processor(processor) + .writer(writer) + .build(); +} +``` + +장점: +- 대규모 집계/정산/데이터 변환에 적합 +- 트랜잭션 단위 조절 가능 + +**Tasklet** + +- Step = 하나의 작업(Task) 실행 +- 반복 구조 없음, 단발성 작업에 적합 + +```java +@Bean +public Step cleanupStep( + JobRepository jobRepository, + PlatformTransactionManager txManager +) { + return new StepBuilder("cleanupStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + orderRepository.deleteOldOrders(); // 만료 주문 삭제 + return RepeatStatus.FINISHED; + }, txManager) + .build(); +} +``` + +장점: +- 간단한 SQL 실행, 파일 이동, 캐시 초기화 등에 적합 +- Reader/Processor/Writer 필요 없는 작업에 깔끔 + +> 일반적으로 **구현의 용이성** 등을 이유로 Tasklet 내에서 로직 상으로 Chunk Oriented Processing 을 구현하기도 합니다. + +--- + +## Materialized View + +> 이전에 **Join 한계를 극복하기 위한 조회 전용 구조**로서 Materialized View 에 대해 언급되었던 적이 있었습니다. +> 이번엔 **복잡한 집계 쿼리를 극복하기 위한 조회 전용 구조**로서 Materialized View 를 만나볼 거예요. + +- **복잡한 집계 쿼리를 미리 계산해둔 조회 전용 구조** +- MySQL 은 MV 기능이 별도로 없으므로 보통 **별도 테이블 + 배치 적재** 방식 사용 +- 주기적으로 대규모 데이터 (각 상품의 일별 일간 집계) 를 주기적으로 집계해 활용 + +```sql +CREATE TABLE product_metrics_weekly ( -- 주간 상품 이벤트 집계 + product_id BIGINT PRIMARY KEY, + like_count INT, + order_count INT, + view_count INT, + yearMonthWeek VARCHAR, -- 예시입니다. + updated_at DATETIME +); + +CREATE TABLE product_metrics_monthly ( -- 월간 상품 이벤트 집계 + product_id BIGINT PRIMARY KEY, + like_count INT, + order_count INT, + view_count INT, + yearMonth VARCHAR, -- 예시입니다. + updated_at DATETIME +); +``` + +--- + +## 운영 관점에서의 배치 전략 + +- **스케줄링** : Spring Scheduler, Quartz 혹은 인프라 (Cron + K8s) +- **재실행 전략** : 실패 시 부분 롤백 vs 전체 재실행 +- **병렬 Step** : 여러 Step 을 동시에 실행해 성능 향상 +- **모니터링** : 실행 로그, 실패 알림, 처리 건수 추적 + +--- + +## References + +| 구분 | 링크 | +|------|------| +| Spring Batch | [Spring Docs - Spring Batch](https://docs.spring.io/spring-batch/reference/) | +| Spring Boot with Spring Batch | [Baeldung - Spring Boot with Spring Batch](https://www.baeldung.com/spring-boot-spring-batch) | +| Materialized View | [AWS - What is Materialized View](https://aws.amazon.com/ko/what-is/materialized-view/) | diff --git a/docs/requirements/volume-10/10-batch-ranking-progress.md b/docs/requirements/volume-10/10-batch-ranking-progress.md new file mode 100644 index 0000000000..15d0e7c9b3 --- /dev/null +++ b/docs/requirements/volume-10/10-batch-ranking-progress.md @@ -0,0 +1,323 @@ +# Round 10 — 개념 공부 로드맵 & 과제 진도표 + +--- + +## Part A. 개념 공부 로드맵 + +> 초급 개발자 대상. 선수 지식부터 실무 적용까지 순서대로 구성. +> "이것을 모르면 다음 단계가 막힌다"는 기준으로 의존 순서를 잡았다. + +### Step 1. 선수 지식 점검 + +과제를 시작하기 전에 확실해야 하는 기초 체력. + +| 주제 | 왜 필요한가 | 확인 질문 | +|------|------------|----------| +| SQL 집계 함수 | Batch Reader가 읽는 쿼리를 이해해야 한다 | `GROUP BY`, `SUM`, `HAVING`, 서브쿼리로 "상품별 주간 매출 TOP 100"을 작성할 수 있는가? | +| 트랜잭션 기초 | Chunk 단위 커밋/롤백의 의미를 이해해야 한다 | `@Transactional`의 propagation, rollbackFor를 설명할 수 있는가? | +| Spring Bean 생명주기 | JobScope, StepScope가 왜 존재하는지 이해해야 한다 | `@Scope("step")`이 일반 singleton과 뭐가 다른지 설명할 수 있는가? | +| JDBC vs JPA 차이 | ItemReader 선택(JdbcCursorItemReader vs JpaPagingItemReader)에 영향 | 대량 조회에서 JPA N+1이 왜 위험한지 아는가? | + +### Step 2. 배치 처리의 본질 + +코드를 쓰기 전에 "왜 배치인가"를 먼저 이해해야 한다. + +**핵심 질문: "이 작업을 왜 API 서버에서 안 하는가?"** + +| 개념 | 설명 | 연결 | +|------|------|------| +| 실시간 vs 배치 트레이드오프 | 실시간은 신속성, 배치는 정확성+효율성 | 우리 프로젝트: 일간 랭킹은 실시간(Redis), 주간/월간 집계는 배치(Spring Batch) | +| 멱등성(Idempotency) | 같은 Job을 두 번 돌려도 결과가 같아야 한다 | MV 테이블에 UPSERT or DELETE+INSERT 전략 | +| 대량 데이터와 메모리 | 10만 행을 한 번에 읽으면 OOM | Chunk 단위 처리로 메모리 제어 | + +**반드시 읽어볼 것:** +- 과제 개념 문서의 "실시간 vs 배치 트레이드오프" 표 +- 실무 배치 시나리오 4가지 (정산, 랭킹, 정리, DW 적재) + +### Step 3. Spring Batch 아키텍처 + +Spring Batch의 계층 구조를 이해해야 코드가 읽힌다. + +``` +JobLauncher + └── Job (실행 단위) + └── Step (세부 단계, 1개 이상) + ├── Chunk-Oriented: Reader → Processor → Writer + └── Tasklet: 단발성 작업 +``` + +**필수 개념:** + +| 개념 | 설명 | 왜 중요한가 | +|------|------|------------| +| Job / Step | 배치의 실행 단위와 세부 단계 | Job 1개에 Step 여러 개 가능. 순서 제어, 조건 분기 | +| JobRepository | Job 실행 이력을 DB에 기록 (메타 테이블) | 재실행 판단, 실패 복구의 근거. `BATCH_JOB_INSTANCE`, `BATCH_JOB_EXECUTION` 등 | +| JobParameters | Job 실행 시 전달하는 파라미터 | 같은 Job을 날짜별로 실행 (e.g. `targetDate=20260414`) | +| @JobScope / @StepScope | JobParameter를 주입받기 위한 지연 생성 | `@Value("#{jobParameters['targetDate']}")`가 동작하려면 필수 | +| Chunk | N건씩 읽고-가공하고-쓰는 반복 단위 | chunk(1000) = 1000건 읽고 쓴 후 커밋. 실패 시 해당 chunk만 롤백 | + +**선수 관계:** Step 2(왜 배치인가) → Step 3(Spring Batch 구조) + +### Step 4. Chunk-Oriented Processing 상세 + +대량 데이터 처리의 핵심 패턴. + +``` +while (hasMore) { + List items = reader.read(chunkSize); // DB에서 N건 읽기 + List outputs = new ArrayList<>(); + for (I item : items) { + outputs.add(processor.process(item)); // 가공 + } + writer.write(outputs); // 일괄 저장 + transaction.commit(); // chunk 단위 커밋 +} +``` + +**ItemReader 종류 비교 (시니어가 반드시 알아야 할 차이):** + +| Reader | 동작 방식 | 장점 | 주의점 | +|--------|----------|------|--------| +| JdbcCursorItemReader | DB 커서를 열고 한 행씩 fetch | 메모리 효율적, 순서 보장 | 커넥션을 Step 동안 유지 → 커넥션 풀 점유 | +| JdbcPagingItemReader | LIMIT/OFFSET으로 페이지 단위 조회 | 커넥션 점유 짧음 | 정렬 기준 필수, 데이터 변경 시 누락/중복 가능 | +| JpaPagingItemReader | JPA로 페이지 조회 | 엔티티 매핑 편리 | N+1 위험, 대량에서 성능 저하 | + +**우리 프로젝트의 선택:** 기존 RankingCorrectionJob이 `JdbcCursorItemReader` 사용 → 동일 패턴 재활용 + +**Chunk Size 결정 기준 (자주 놓치는 포인트):** + +| chunk size | 효과 | +|-----------|------| +| 너무 작음 (10) | 커밋 횟수 ↑, DB I/O 오버헤드 ↑ | +| 너무 큼 (100,000) | 메모리 ↑, 실패 시 재처리 범위 ↑ | +| **적정 (500~5,000)** | 기존 Job이 1,000으로 설정. 벤치마크로 조정 | + +### Step 5. Materialized View + +**핵심: "미리 계산해둔 조회 전용 테이블"** + +| 개념 | 설명 | +|------|------| +| MV란? | 복잡한 집계 쿼리 결과를 별도 테이블에 저장. 조회 시 집계 없이 SELECT만 | +| MySQL에서의 MV | 네이티브 MV 미지원 → 별도 테이블 + 배치 적재로 구현 | +| 갱신 전략 | 전체 교체(DELETE + INSERT) vs 증분(UPSERT). 데이터 크기와 빈도에 따라 선택 | +| 원본과의 관계 | `product_metrics`(원본) → Spring Batch → `mv_product_rank_weekly/monthly`(MV) | + +**MV vs 실시간 집계:** + +| 기준 | 실시간 집계 (매 요청) | MV (사전 집계) | +|------|---------------------|---------------| +| 조회 속도 | 느림 (GROUP BY + ORDER BY) | 빠름 (단순 SELECT) | +| 데이터 신선도 | 항상 최신 | 배치 주기만큼 stale | +| DB 부하 | 높음 (매번 집계) | 낮음 (배치 때만) | +| 적합 | 소규모, 실시간 필수 | 대규모, 주기적 갱신 허용 | + +### Step 6. 우리 프로젝트 맥락 (기존 구현과의 연결) + +Round 9에서 이미 구축한 것과 Round 10의 관계를 이해해야 설계 판단이 가능하다. + +**현재 아키텍처 (Round 9 완성):** + +``` +[실시간 경로 — Speed Layer] + Kafka → MetricsConsumer → Redis Hash/ZSET (일간) + → 23:50 스케줄러: carry-over + ZUNIONSTORE (주간/월간 Redis ZSET) + +[배치 보정 — Batch Layer] + product_metrics(DB) → RankingCorrectionJob → Redis Hash/ZSET 덮어쓰기 + +[API] + GET /api/v1/rankings?scope=daily|weekly|monthly → Redis ZSET 조회 +``` + +**Round 10이 추가하는 것:** + +``` +[배치 집계 — Materialized View Layer] + product_metrics(DB) → Spring Batch Job → mv_product_rank_weekly/monthly(DB) + +[API 확장] + 주간/월간 요청 → MV 테이블 조회 (Redis 대신 or 함께) +``` + +**핵심 설계 질문: Redis ZSET 주간/월간과 MV 테이블은 어떻게 공존하는가?** + +| 관점 | Redis ZSET (기존) | MV 테이블 (신규) | +|------|-------------------|-----------------| +| 데이터 소스 | Redis carry-over 기반 | DB product_metrics 기반 | +| 신선도 | 일 1회 갱신 (23:50) | 배치 주기 (일 1회) | +| 정확도 | carry-over 누적 근사치 | DB 원장 기반 정확값 | +| 조회 속도 | O(log N + M) ~0.01ms | DB SELECT ~수ms | +| 장애 시 | Redis 장애 → 조회 불가 | DB만 살아있으면 조회 가능 | + +→ 이 트레이드오프를 설계 문서에서 분석하고 판단을 내려야 한다. + +### Step 7. 운영 관점 (시니어가 강조하는 포인트) + +코드가 동작하는 것과 운영 가능한 것은 다르다. + +| 주제 | 질문 | 왜 중요한가 | +|------|------|------------| +| 멱등성 | 같은 날짜로 Job을 두 번 돌리면? | MV 데이터가 2배가 되면 안 된다 | +| 실패 복구 | Step 2에서 실패하면 Step 1부터 다시? | Spring Batch의 재시작 메커니즘 이해 | +| 모니터링 | Job이 성공했는지 어떻게 아는가? | 처리 건수, 소요 시간, 실패 알림 | +| 스케줄링 | 언제 돌리는가? 다른 Job과 충돌은? | 기존 23:50 스케줄러, RankingCorrectionJob과의 시간 배치 | +| 데이터 정합성 | MV와 Redis 랭킹이 다르면? | 어느 쪽이 source of truth인지 정해야 한다 | + +--- + +## Part B. 과제 수행 진도표 + +### 기존 구현 현황 (Round 9) + +| 항목 | 상태 | 비고 | +|------|------|------| +| commerce-batch 모듈 (Spring Batch) | ✅ 완료 | 6개 Job 운영 중 | +| product_metrics 테이블 (daily grain) | ✅ 완료 | PK: (product_id, metric_date) | +| RankingCorrectionJob (배치 보정) | ✅ 완료 | Chunk 1,000, JdbcCursorItemReader | +| Redis 일간/주간/월간 ZSET | ✅ 완료 | 23:50 carry-over + ZUNIONSTORE | +| Ranking API (scope 파라미터) | ✅ 완료 | daily/weekly/monthly → Redis 조회 | +| MV 테이블 | ❌ 미구현 | Round 10 핵심 과제 | + +--- + +### Phase 0. 설계 + +> 코드를 쓰기 전에 결정해야 할 것들. + +- [ ] **0-1. 아키텍처 결정: Redis ZSET vs MV 테이블 역할 분담** + - Redis 주간/월간과 MV 테이블의 공존 전략 (대체? 보완? fallback?) + - API가 어느 소스에서 읽는가 (scope별 분기) + - 설계 문서에 판단 근거 기록 + +- [ ] **0-2. MV 테이블 스키마 설계** + - `mv_product_rank_weekly` / `mv_product_rank_monthly` DDL + - PK 구성 (product_id + 기간키? 별도 id?) + - 저장 항목: rank, score, 개별 메트릭(view/like/order), 기간 식별자 + - TOP 100만 저장하는 전략 (Writer에서 제한? 쿼리에서 제한?) + +- [ ] **0-3. Spring Batch Job 설계** + - Job 이름, Step 구성 (단일 Step? 다중 Step?) + - Reader: product_metrics에서 기간별 집계 쿼리 + - Processor: score 계산 (기존 calculateScore 재활용) + - Writer: MV 테이블 적재 (UPSERT vs DELETE+INSERT) + - JobParameter: targetDate, scope(weekly/monthly) + - 멱등성 보장 전략 + +- [ ] **0-4. 스케줄링/실행 전략** + - 실행 시점 (기존 23:50 carry-over, RankingCorrectionJob과의 관계) + - 주간 Job 실행 주기 (매일? 월요일만?) + - 월간 Job 실행 주기 (매일? 월초만?) + +- [ ] **0-5. 설계 문서 작성** + - `docs/design/10-batch-ranking-system.md` 생성 + - 기존 09 설계와의 연결 명시 + +--- + +### Phase 1. 구현 + +- [ ] **1-1. MV 엔티티/리포지토리** + - Entity 클래스 (commerce-batch 또는 공통 모듈) + - Repository (JPA or JDBC) + +- [ ] **1-2. 주간 랭킹 Batch Job** + - JobConfig 클래스 + - ItemReader: product_metrics → 최근 7일 집계 + - ItemProcessor: score 계산 + 순위 산정 + - ItemWriter: mv_product_rank_weekly 적재 + - JobParameter 처리 (targetDate) + +- [ ] **1-3. 월간 랭킹 Batch Job** + - 주간과 동일 구조, 집계 기간만 30일 + - mv_product_rank_monthly 적재 + +- [ ] **1-4. API 확장 (필요 시)** + - 주간/월간 요청 시 MV 테이블에서 조회하도록 분기 + - 기존 Redis 조회 경로와의 공존 or 전환 + +- [ ] **1-5. 스케줄링/실행 설정** + - application.yml Job 설정 + - 실행 방법 문서화 (커맨드라인, 스케줄러) + +--- + +### Phase 2. 테스트 + +- [ ] **2-1. 단위 테스트** + - Score 계산 로직 (기존 calculateScore와 일치 검증) + - Processor 변환 로직 + +- [ ] **2-2. 통합 테스트** + - Job 전체 실행 (Testcontainers + @SpringBatchTest) + - product_metrics에 시드 데이터 → Job 실행 → MV 결과 검증 + - 멱등성 검증 (같은 날짜로 2회 실행 → 결과 동일) + +- [ ] **2-3. 엣지 케이스** + - 데이터 없는 날짜로 실행 + - 7일/30일 미만 데이터로 실행 (서비스 초기) + - 대량 데이터 성능 테스트 (상품 10만건 기준) + +--- + +### Phase 3. 시나리오 기반 모니터링 + +- [ ] **3-1. 시나리오 정의** + - 시나리오 1: 정상 실행 — 시드 데이터 기반 주간/월간 Job 실행 + - 시나리오 2: MV vs Redis 결과 비교 — 같은 기간 랭킹 TOP 20 대조 + - 시나리오 3: 재실행 — 동일 파라미터로 2회 실행, 멱등성 확인 + +- [ ] **3-2. 모니터링 지표** + - Job 실행 시간, 처리 건수 (Spring Batch 메타 테이블) + - MV 적재 건수 + - Grafana 대시보드 (선택) + +- [ ] **3-3. 결과 기록** + - 스크린샷 또는 로그 기반 실행 결과 정리 + - 성능 수치 (소요 시간, 처리량) + +--- + +### Phase 4. 테크니컬 라이팅 + +- [ ] **4-1. 블로그 글 구성안 작성** + - 핵심 메시지 1줄 + - 목차 + 섹션별 핵심 메시지 + +- [ ] **4-2. 초안 작성** + - 설계 판단 중심 (왜 MV인가, 왜 이 구조인가) + - 기존 Redis 랭킹과의 관계 + - 코드는 핵심 판단을 보여주는 최소한만 + +- [ ] **4-3. Retrospective (10주 회고)** + - 1~10주 전체 여정 요약 + - 가장 큰 전환점 + - Trade-off 판단 1~2개 + - 실전 연결 포인트 + +--- + +### Phase 5. PR & 리뷰 포인트 + +- [ ] **5-1. PR 작성** + - 변경 사항 요약 + - 설계 판단 근거 + - 테스트 계획 + +- [ ] **5-2. 리뷰 포인트 작성 (2~3개)** + - 설계 고민이 드러나는 열린 질문 + - "배경 → 대안 비교 → 선택 근거 → 질문" 구조 + +--- + +### 일정 가이드 (참고용) + +| 일차 | 단계 | 핵심 산출물 | +|------|------|-----------| +| Day 1 | 개념 공부 (Step 1~4) + Phase 0 설계 | 설계 문서 초안 | +| Day 2 | 개념 공부 (Step 5~7) + Phase 0 완료 | 설계 문서 확정 | +| Day 3 | Phase 1 구현 (Job + MV) | 주간/월간 Job 동작 | +| Day 4 | Phase 1 완료 + Phase 2 테스트 | 테스트 통과 | +| Day 5 | Phase 3 모니터링 | 시나리오 검증 결과 | +| Day 6 | Phase 4 테크니컬 라이팅 | 블로그 초안 | +| Day 7 | Phase 5 PR + 리뷰 포인트 | PR 제출 | diff --git a/docs/requirements/volume-10/10-batch-ranking-quests.md b/docs/requirements/volume-10/10-batch-ranking-quests.md new file mode 100644 index 0000000000..3775fed754 --- /dev/null +++ b/docs/requirements/volume-10/10-batch-ranking-quests.md @@ -0,0 +1,82 @@ +# Round 10 Quests + +--- + +## Implementation Quest + +> 이번에는 Spring Batch 를 활용해 주간, 월간 랭킹을 제공해 볼 거예요. +> 이전에 적재했던 `product_metrics` 와 같은 일간 집계정보를 기반으로 **주간, 월간 랭킹 시스템을 구축**해봅니다. + +**Must-Have (이번 주에 무조건 가져가야 좋을 것)** + +- Spring Batch +- Batch Processing +- Materialized View (Statistics) + +### 과제 정보 + +이번 주는 대규모 데이터 집계 및 조회 전용 구조에 대한 설계를 진행해 봅니다. + +### (1) Spring Batch Job 구현 + +- 하루치 메트릭 테이블을 읽어 데이터를 집계하고 처리해봅니다. + - 대상 테이블 : `product_metrics` + - Chunk-Oriented 방식을 통해 대량의 데이터를 읽고 처리할 수 있도록 구성해 보세요. + +### (2) Materialized View 설계 + +- 집계 결과를 조회 전용 테이블 (MV) 로 저장합니다. + - `mv_product_rank_weekly` : 주간 TOP 100 랭킹 + - `mv_product_rank_monthly` : 월간 TOP 100 랭킹 + +### (3) Ranking API 확장 + +- 기존 Ranking 을 제공하는 GET `/api/v1/rankings?date=yyyyMMdd&size=20&page=1` 에서 기간 정보를 전달받아 API 로 일간, 주간, 월간 랭킹을 제공할 수 있도록 개선합니다. + +--- + +## Checklist + +### Spring Batch + +- [ ] Spring Batch Job 을 작성하고, 파라미터 기반으로 동작시킬 수 있다. +- [ ] Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) 기반의 배치 처리를 구현했다. +- [ ] 집계 결과를 저장할 Materialized View 의 구조를 설계하고 올바르게 적재했다. + +### Ranking API + +- [ ] API 가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다. + +--- + +## Technical Writing Quest + +> 이번 주에 학습한 내용, 과제 진행을 되돌아보며 +> **"내가 어떤 판단을 하고 왜 그렇게 구현했는지"** 를 글로 정리해봅니다. +> +> **좋은 블로그 글은 내가 겪은 문제를, 타인도 공감할 수 있게 정리한 글입니다.** +> +> 이 글은 단순 과제가 아니라, **향후 이직에 도움이 될 수 있는 포트폴리오** 가 될 수 있어요. + +### 작성 기준 + +| 항목 | 설명 | +|------|------| +| 형식 | 블로그 | +| 길이 | 제한 없음, 단 꼭 1줄 요약 (TL;DR) 을 포함해 주세요 | +| 포인트 | "무엇을 했다" 보다 **"왜 그렇게 판단했는가"** 중심 | +| 예시 포함 | 코드 비교, 흐름도, 리팩토링 전후 예시 등 자유롭게 | +| 톤 | 실력은 보이지만, 자만하지 않고, **고민이 읽히는 글** | + +### Retrospective + +- 단순히 "무엇을 했다"가 아니라, **10주 동안 어떻게 성장했는지**를 돌아본다. +- "기능 구현" 중심이 아니라, **사고방식/문제 해결/설계 선택 과정** 중심으로 기록한다. +- 이 글은 **개인 포트폴리오**이자, 앞으로 학습 방향을 스스로 점검하는 기준점이 된다. + +### 담으면 좋은 내용 + +1. **전체 여정 요약** — 1~10주차 동안 다뤘던 주요 테마 및 문제점들을 간단히 돌아보기. 단순 나열이 아니라, 흐름이 어떻게 연결되었는지를 강조. +2. **가장 큰 전환점** — 내 기존의 사고방식이 바뀌었다 싶은 순간. +3. **나의 Trade-off 판단** — 실습 중 내가 내린 중요한 선택 1~2개. 왜 그 선택을 했고, 대안은 뭐였는지, 지금 다시 한다면 어떻게 할 건지. +4. **실전과의 연결** — "이건 실제 회사/서비스에서 써먹을 수 있겠다" 싶은 포인트. diff --git a/docs/session-prompts/10-batch-analysis-prompt.md b/docs/session-prompts/10-batch-analysis-prompt.md new file mode 100644 index 0000000000..9233e83beb --- /dev/null +++ b/docs/session-prompts/10-batch-analysis-prompt.md @@ -0,0 +1,97 @@ +# 세션 프롬프트: 회사 배치 어플리케이션 분석 + +> 이 프롬프트를 새 Claude 세션에 붙여넣고, 이어서 회사 배치 코드를 공유하세요. + +--- + +## 역할 + +당신은 대규모 이커머스 서비스에서 Spring Batch를 운영해본 10년 경력의 시니어 백엔드 개발자입니다. +지금부터 내가 공유하는 회사 실무 배치 어플리케이션 2개를 분석하고, 내 개인 프로젝트 과제에 적용할 인사이트를 추출해 주세요. + +--- + +## 내 과제 맥락 + +이커머스 프로젝트에서 **Spring Batch로 주간/월간 랭킹을 집계하여 MV(Materialized View) 테이블에 적재**하는 것이 과제입니다. + +### 이미 구현된 것 (Round 9) + +| 항목 | 구현 상태 | +|------|----------| +| `product_metrics` 테이블 | 일간 grain, PK: (product_id, metric_date) | +| `RankingCorrectionJob` | Chunk 1,000, JdbcCursorItemReader → Redis 덮어쓰기 | +| Redis 일간/주간/월간 ZSET | carry-over + ZUNIONSTORE 방식 | +| Ranking API | scope=daily\|weekly\|monthly → Redis 조회 | + +### 이번에 새로 만들 것 (Round 10) + +| 항목 | 설명 | +|------|------| +| 주간/월간 랭킹 Batch Job | `product_metrics` → 기간 집계 → MV 테이블 적재 | +| MV 테이블 | `mv_product_rank_weekly`, `mv_product_rank_monthly` | +| API 확장 | 주간/월간 요청 시 MV에서도 조회 가능하도록 | + +### 내가 고민 중인 설계 질문 + +1. **Reader**: JdbcCursorItemReader vs JdbcPagingItemReader — 기간 집계 쿼리에 어느 쪽이 적합한가? +2. **Processor vs SQL**: 비즈니스 로직(score 계산, TOP-N 필터링)을 Processor에서 처리할지, Reader SQL에서 처리할지 +3. **Writer 전략**: MV 테이블 갱신 시 DELETE+INSERT vs UPSERT +4. **멱등성**: 같은 날짜 파라미터로 재실행해도 결과가 동일하려면? +5. **기존 Redis 주간/월간과의 공존**: MV가 추가되면 API가 어디서 읽어야 하는가? + +--- + +## 분석 요청 + +회사 배치 어플리케이션을 아래 관점에서 분석해 주세요. + +### 1. 구조 분석 + +각 배치 앱에 대해: +- **Job/Step 구성**: 몇 개의 Step으로 구성되어 있는가? 순차/병렬? +- **처리 모델**: Chunk-Oriented vs Tasklet — 어떤 것을 쓰고 있는가? 왜? +- **Reader 패턴**: 어떤 ItemReader를 쓰는가? SQL이 얼마나 복잡한가? +- **비즈니스 로직 위치**: Reader SQL에 조건이 다 있는가? Processor에서 분기하는가? +- **Writer 패턴**: UPSERT? DELETE+INSERT? 벌크 인서트? +- **에러 처리**: Skip Policy, Retry, Listener 등 사용 여부 + +### 2. 내 과제에 적용할 인사이트 + +분석 결과를 바탕으로: +- **직접 참고할 수 있는 패턴**: 내 과제(주간/월간 랭킹 집계)에 바로 적용할 수 있는 구조나 패턴 +- **피해야 할 안티패턴**: 회사 코드에서 발견되는 문제점이나 개선 포인트 +- **설계 질문에 대한 시사점**: 위 5개 설계 질문에 대해 회사 코드가 어떤 힌트를 주는가 + +### 3. 비교 테이블 + +아래 형식으로 정리해 주세요: + +``` +| 비교 항목 | 회사 배치 A | 회사 배치 B | 내 과제 (추천) | 근거 | +|----------|-----------|-----------|-------------|------| +| 처리 모델 | | | | | +| Reader 타입 | | | | | +| 비즈니스 로직 위치 | | | | | +| Writer 전략 | | | | | +| 멱등성 보장 | | | | | +| 에러 처리 | | | | | +``` + +--- + +## 출력 형식 + +1. **배치 A 분석** (구조 → 장단점 → 내 과제 시사점) +2. **배치 B 분석** (구조 → 장단점 → 내 과제 시사점) +3. **비교 테이블** +4. **내 과제 설계 제안** — 회사 코드에서 배운 점을 반영한 구체적 설계 방향 (Reader SQL, Processor 역할, Writer 전략, 멱등성) +5. **추가 질문** — 분석 중 더 확인이 필요한 부분 + +--- + +## 진행 방식 + +1. 이 프롬프트를 읽고 이해한 내용을 요약해 주세요 +2. 내가 회사 배치 코드를 공유하면 분석을 시작합니다 +3. 배치 A, B를 순서대로 공유할 예정입니다 diff --git a/docs/session-prompts/10-batch-tutor-prompt.md b/docs/session-prompts/10-batch-tutor-prompt.md new file mode 100644 index 0000000000..c2651725e4 --- /dev/null +++ b/docs/session-prompts/10-batch-tutor-prompt.md @@ -0,0 +1,175 @@ +# 세션 프롬프트: Round 10 배치 시스템 학습 튜터 + +> 이 프롬프트를 새 Claude 세션에 붙여넣고, Step 1부터 순서대로 학습을 시작하세요. + +--- + +## 역할 + +당신은 이커머스 도메인에서 Spring Batch를 직접 설계·운영해본 경력 15년의 시니어 개발자이자 기술 교육자입니다. + +**교육 철학:** +- "왜?"를 먼저 설명하고, "어떻게?"는 그 다음 +- 개념은 실무 시나리오와 연결해서 설명 +- 학습자가 스스로 판단할 수 있도록 선택지와 트레이드오프를 제시 +- 코드는 최소한으로, 핵심 판단을 보여주는 수준만 +- 학습자의 답변이 틀려도 바로 정답을 주지 않고, 힌트로 유도 + +**교육 대상:** Spring Boot 경험은 있으나 Spring Batch와 대규모 배치 처리는 처음인 주니어 백엔드 개발자 + +--- + +## 학습자의 프로젝트 맥락 + +학습자는 이커머스 프로젝트에서 랭킹 시스템을 구축하고 있습니다. + +### 이미 구현된 것 (Round 9) + +``` +[실시간 경로 — Speed Layer] + Kafka → MetricsConsumer → Redis Hash/ZSET (일간) + → 23:50 스케줄러: carry-over + ZUNIONSTORE (주간/월간 Redis ZSET) + +[배치 보정 — Batch Layer] + product_metrics(DB) → RankingCorrectionJob → Redis Hash/ZSET 덮어쓰기 + - Chunk 1,000, JdbcCursorItemReader + - Score v2: 0~1 정규화 + log₁₀ + tiebreaker + +[API] + GET /api/v1/rankings?scope=daily|weekly|monthly → Redis ZSET 조회 +``` + +### 이번 과제 (Round 10) + +- Spring Batch Job으로 `product_metrics` → 주간/월간 집계 → MV 테이블 적재 +- MV 테이블: `mv_product_rank_weekly`, `mv_product_rank_monthly` +- API 확장: 일간/주간/월간 랭킹을 적절한 데이터 소스에서 제공 + +--- + +## 학습 로드맵 (7 Step) + +아래 순서대로 학습을 진행합니다. 각 Step마다 **설명 → 확인 질문 → 피드백** 사이클로 진행해 주세요. + +### Step 1. 선수 지식 점검 + +학습자에게 아래 4가지를 확인 질문으로 점검하세요. 부족한 부분이 있으면 보충 설명 후 다음으로 넘어갑니다. + +| 주제 | 확인 질문 | +|------|----------| +| SQL 집계 함수 | "상품별 최근 7일간 view_count 합계 TOP 100을 구하는 SQL을 작성해 보세요" | +| 트랜잭션 기초 | "`@Transactional`의 propagation REQUIRED vs REQUIRES_NEW 차이를 1,000건 chunk 커밋 상황에서 설명해 보세요" | +| Spring Bean 생명주기 | "singleton Bean과 @StepScope Bean의 차이가 배치에서 왜 중요한지 설명해 보세요" | +| JDBC vs JPA 대량 조회 | "10만 건 조회 시 JPA `findAll()`과 JdbcCursorItemReader의 메모리 사용 차이를 설명해 보세요" | + +**진행 기준:** 4개 중 3개 이상 답변 가능하면 Step 2로, 아니면 부족한 부분 보충 후 이동 + +### Step 2. 배치 처리의 본질 + +**핵심 질문으로 시작:** "이 작업을 왜 API 서버에서 안 하는가?" + +다룰 내용: +- 실시간 vs 배치 트레이드오프 (신속성 vs 정확성/효율성) +- 실무 배치 시나리오 4가지 (정산, 랭킹, 정리, DW 적재) +- 멱등성 — 같은 Job을 두 번 돌려도 결과가 같아야 하는 이유 +- 대량 데이터와 메모리 — Chunk의 존재 이유 + +**프로젝트 연결:** +> "학습자의 프로젝트에서 일간 랭킹은 Kafka→Redis 실시간으로 처리하고 있다. 그런데 주간/월간 랭킹을 왜 같은 방식으로 안 하고 배치로 만드는가?" + +이 질문에 학습자가 스스로 답하도록 유도하세요. + +### Step 3. Spring Batch 아키텍처 + +**계층 구조:** +``` +JobLauncher + └── Job (실행 단위) + └── Step (세부 단계) + ├── Chunk-Oriented: Reader → Processor → Writer + └── Tasklet: 단발성 작업 +``` + +다룰 내용: +- Job, Step, JobRepository, JobParameters, @JobScope/@StepScope +- 메타 테이블 (BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION) — 왜 있는지, 뭘 기록하는지 +- JobParameters가 Job Instance의 동일성을 결정하는 원리 + +**프로젝트 연결:** +> "학습자의 프로젝트에는 이미 RankingCorrectionJob이 있다. 이 Job이 `targetDate`를 JobParameter로 받는다면, 같은 날짜로 두 번 실행하면 어떻게 되는가?" + +### Step 4. Chunk-Oriented Processing 상세 + +다룰 내용: +- Reader → Processor → Writer 흐름과 트랜잭션 경계 +- ItemReader 비교: JdbcCursorItemReader vs JdbcPagingItemReader vs JpaPagingItemReader + - 각각의 메모리/커넥션/성능 특성 + - "언제 어떤 것을 쓰는가" 판단 기준 +- Processor에서 `null` 반환 → 해당 아이템 스킵 (필터링 패턴) +- Chunk size 결정 기준 — 너무 작으면? 너무 크면? + +**판단 연습 질문:** +> "학습자가 product_metrics에서 최근 7일 데이터를 GROUP BY하여 상품별 합계를 구한다. 이때 집계를 Reader SQL에서 할지, Processor에서 할지 — 어떤 기준으로 결정하겠는가?" + +### Step 5. Materialized View + +다룰 내용: +- MV의 개념 — "미리 계산해둔 조회 전용 테이블" +- MySQL에서 MV 구현 방식 (별도 테이블 + 배치 적재) +- 갱신 전략: DELETE+INSERT vs UPSERT +- MV vs 실시간 집계 — 조회 속도, 신선도, DB 부하 비교 + +**프로젝트 연결:** +> "지금 주간/월간 랭킹은 Redis ZSET에서 제공한다. MV 테이블이 추가되면, API는 Redis와 MV 중 어디서 읽어야 하는가? 둘 다 유지할 이유가 있는가?" + +이 설계 판단을 학습자가 스스로 내리도록 유도하세요. 정답은 없고, 트레이드오프를 인식하는 것이 목표입니다. + +### Step 6. 프로젝트 맥락 — 기존 구현과의 연결 + +학습자의 프로젝트에 새로운 Job이 들어갈 때 고려할 사항: + +| 관점 | 확인할 것 | +|------|----------| +| 기존 Job과의 관계 | RankingCorrectionJob과 실행 시간 충돌 없는가? | +| Score 계산 | 기존 v2 공식(log₁₀ 정규화 + tiebreaker)을 재활용할 수 있는가? | +| 데이터 소스 | product_metrics에서 GROUP BY 기간만 바꾸면 되는가? | +| Redis vs MV 공존 | 어느 쪽이 source of truth인가? | + +**설계 연습:** +> "주간 랭킹 Job의 Step을 설계해 보세요. Reader의 SQL, Processor의 역할, Writer의 전략을 각각 정해 보세요." + +학습자의 설계안을 받고, 장단점을 피드백해 주세요. + +### Step 7. 운영 관점 + +코드가 동작하는 것과 운영 가능한 것은 다르다. + +다룰 내용: +- 멱등성 보장 — MV 갱신 시 데이터 2배 방지 +- 실패 복구 — Spring Batch 재시작 메커니즘 +- 모니터링 — 처리 건수, 소요 시간, 실패 알림 +- 스케줄링 — 다른 Job과의 시간 배치 + +**최종 종합 질문:** +> "Job이 새벽 1시에 실행 중 Step 2에서 DB 커넥션 에러로 실패했다. 아침에 출근해서 어떻게 대응하는가?" + +--- + +## 진행 규칙 + +1. **한 번에 하나의 Step만** 진행합니다. 학습자가 "다음"이라고 하면 다음 Step으로 넘어갑니다. +2. **각 Step의 흐름:** + - 개념 설명 (프로젝트 맥락과 연결) + - 확인 질문 1~2개 (학습자가 직접 답변) + - 피드백 + 보충 + - 다음 Step 예고 +3. **학습자가 틀려도** 바로 정답을 주지 말고, "이 부분을 다시 생각해 보세요: ___" 형태로 힌트를 주세요. +4. **실무 사례**를 자주 들어주세요. "쿠팡에서는...", "실무에서 흔히 보는 실수는..." 등. +5. 학습자가 충분히 이해했다고 판단되면 **"이 Step은 여기까지. 다음 Step으로 넘어갈까요?"** 로 전환합니다. + +--- + +## 시작 + +"안녕하세요! Round 10 학습을 시작합니다. 먼저 Step 1으로 선수 지식을 점검해 보겠습니다." 로 시작해 주세요. +첫 번째 확인 질문을 하나 던져 주세요. diff --git a/docs/velog-techwriting-vol10.md b/docs/velog-techwriting-vol10.md new file mode 100644 index 0000000000..728019db22 --- /dev/null +++ b/docs/velog-techwriting-vol10.md @@ -0,0 +1,506 @@ +# 일간은 Redis, 주간/월간은 왜 다른가 — 이커머스 랭킹 배치 설계기 + +*Redis에 이미 주간/월간 랭킹이 있는데, 같은 걸 DB에 또 만들어야 할까?* + +> 실시간 Redis 랭킹만으로 충분하다고 생각했다. 그런데 log₁₀의 비선형성을 숫자로 검증하는 순간, "같은 데이터인데 왜 결과가 다르지?"라는 질문이 시작됐고, 이 질문은 Score 방식 선택, 시간 윈도우 전략, Chunk vs Tasklet 판단, CursorReader의 병렬화 한계, 전체 재계산 vs 증분까지 연쇄적으로 이어졌다. Lambda Architecture에서 Speed Layer와 Batch Layer가 왜 공존해야 하는지를 배치 설계 전 과정에 걸쳐 확인한 기록이다. + +--- + +## 1. 이 글의 맥락 + +Round 9에서 Kafka → Redis ZSET 파이프라인으로 실시간 일간/주간/월간 랭킹을 구축했다. 이벤트가 발생할 때마다 score를 갱신하고, ZUNIONSTORE carry-over로 주간/월간을 근사 계산하는 구조다. + +Round 10의 과제는 Spring Batch로 MV(Materialized View) 기반 주간/월간 랭킹을 만드는 것이다. 처음에 든 생각은 단순했다. + +*"Redis에 이미 있는 걸 왜 DB에 또 만들어?"* + +이 질문에 답하려면, 두 시스템이 정말 같은 결과를 내는지부터 확인해야 했다. + +--- + +## 2. Redis에 이미 랭킹이 있는데, MV를 왜 만드는가 + +### log₁₀는 선형이 아니다 + +Redis 주간 랭킹은 **일별 score를 합산**한다. ZUNIONSTORE로 7일치 ZSET을 합치면 `Σ daily_score`가 된다. MV는 **기간 메트릭을 합산한 뒤 score를 한 번 계산**한다. `f(SUM(7일 메트릭))`. 같은 7일 데이터인데, 순서가 다르다. + +log₁₀는 비선형 함수이므로, 이 순서의 차이가 결과를 바꾼다. + +``` +Σ log(daily) ≠ log(Σ daily) +``` + +숫자로 확인했다. + +``` +상품 X: 7일간 view = [100, 100, 100, 100, 100, 100, 100] (총 700) +상품 Y: 7일간 view = [0, 0, 0, 0, 0, 0, 700] (총 700) + +Redis (일별 score 합산): + X: 7 × log₁₀(101)/7 = 2.003 + Y: 6 × 0 + log₁₀(701)/7 = 0.406 + → X 압도적 유리 (꾸준한 상품 우대) + +MV (메트릭 합산 후 score): + X: log₁₀(701)/7 = 0.406 + Y: log₁₀(701)/7 = 0.406 + → 동점 (총 활동량 동일) +``` + +총 조회수가 같은 두 상품이, 계산 순서만 다른데 **Redis에서는 X가 5배 높고, MV에서는 동점**이다. 같은 원천 데이터에서 출발하지만, 계산 방식의 차이가 다른 관점의 랭킹을 만들어내는 것이다. + +### "다른 결과를 내는 것"이 오히려 가치다 + +처음에는 "MV가 Redis보다 정확하니까 MV를 만드는 것"이라고 생각했다. 그런데 생각을 정리하다 보니 방향이 달랐다. + +| 관점 | Redis (Speed Layer) | MV (Batch Layer) | +|------|---------------------|-------------------| +| **Score 특성** | `Σ daily_score` — 꾸준히 팔린 상품 우대 | `f(Σ daily_metrics)` — 총 실적 기준 | +| **비즈니스 의미** | "지금 뜨는 상품" (트렌드) | "이번 달 베스트셀러" (누적 성과) | +| **소비자 시나리오** | 메인 페이지 실시간 인기 | 카테고리별 베스트, 기간별 랭킹 | +| **사업자 시나리오** | 실시간 모니터링 | 주간/월간 리포트, MD 성과 분석 | + +**MV가 Redis와 같은 결과를 내면, MV를 만들 이유가 없다.** 두 시스템이 다른 관점을 제공하는 것이 Lambda Architecture에서 Speed Layer와 Batch Layer의 존재 이유다. + +### 그러면 fallback으로 쓰면 안 되는가 + +설계 초기에는 "MV primary, Redis fallback"으로 구성하려 했다. MV 배치가 실패하면 Redis에서 조회하는 구조. 그런데 위에서 확인했듯이 두 시스템은 같은 기간에 대해 **다른 순위를 반환**한다. + +``` +정상 시: MV 조회 → 상품 A가 1위 (균등 합산) +MV 장애: Redis fallback → 상품 B가 1위 (일별 합산 + 감쇠) +→ "어제는 A가 1위였는데 오늘은 B?" +``` + +다른 공식으로 계산한 결과를 같은 API의 fallback으로 쓰면 데이터 일관성이 깨진다. 잘못된 순위를 보여주는 것보다 "현재 랭킹을 준비 중입니다"가 더 안전하다. + +**최종 결정은 단일 소스 원칙이다.** + +``` +daily → Redis (단일 소스) +weekly → MV (단일 소스) +monthly → MV (단일 소스) +``` + +```java +// RankingFacade.java — scope에 따라 데이터 소스를 분리 +public RankingDto.PagedRankingResponse getRankings( + String scope, String date, int page, int size, Long memberId) { + String resolvedDate = (date != null) ? date + : LocalDate.now(KST).format(DATE_FORMATTER); + + return switch (scope) { + case "weekly", "monthly" -> getFromMv(scope, resolvedDate, page, size); + default -> getFromRedis(scope, resolvedDate, page, size, memberId); + }; +} +``` + +각 scope의 데이터 소스가 하나이므로, 소스 전환에 의한 순위 불일치가 발생하지 않는다. + +--- + +## 3. 설계 판단들 + +### 3.1 "주간 베스트"는 총 판매량인가, 최근 인기인가 + +MV의 score를 어떤 방식으로 계산할 것인가. 세 가지를 검토했다. + +| 방식 | 수식 | 특성 | +|------|------|------| +| **균등 합산 (채택)** | `f(SUM(30일 메트릭))` | 기간 총 실적. 30일 전이나 오늘이나 동등 | +| **지수 감쇠** | `Σ(daily_score × 0.97^i)` | 최근에 높은 가중치. 반감기 약 23일 | +| **일평균** | `f(SUM(메트릭) / COUNT(전시일))` | 전시 기간 편향 보정 | + +**균등 합산을 선택한 이유는 공개 랭킹 보드의 비즈니스 의미에 있다.** + +쿠팡, 무신사, 교보문고의 공개 랭킹 보드는 기간 총 실적 기준이다. "이번 달 베스트셀러"를 볼 때 소비자가 기대하는 것은 "가장 많이 팔린 상품"이지, "일평균 판매량이 높은 상품"이나 "최근에 급등한 상품"이 아니다. + +숫자로도 확인했다. + +``` +상품 A: 30일간 매일 매출 100만원 (꾸준) +상품 B: 최근 5일간 매일 600만원 (급등), 나머지 0원 +총 실적: 둘 다 3000만원 + + 일간 주간(균등) 주간(감쇠) 월간(균등) 월간(감쇠) +상품 A 0.600 0.693 4.09 0.735 12.0 +상품 B 0.678 0.735 3.33 0.735 3.33 +승자 B B A 동점 A 압승 +``` + +감쇠를 쓰더라도 월간이 일간/주간과 비슷해지지는 않는다. 하지만 감쇠를 쓸 *이유*가 없는 것이 핵심이다. Redis가 이미 트렌드를 반영하고 있으므로, MV까지 감쇠를 적용하면 두 시스템의 결과가 수렴한다. + +**지수 감쇠를 기각한 근거**: Lambda Architecture에서 Speed Layer(Redis)가 이미 트렌드를 반영하고 있으므로, Batch Layer(MV)는 "정확한 기간 집계"에 집중하는 것이 아키텍처적으로 맞다. 같은 일을 두 시스템에서 반복하면 MV의 존재 가치가 떨어진다. + +**일평균을 기각한 근거**: 수학적으로 공정한 비교와 비즈니스적으로 의미 있는 비교는 다를 수 있다. 1일 전시에 매출 500만원이면 일평균 기준으로 30일 전시 + 매출 3000만원인 상품보다 위에 올라간다. MD팀이 원하는 "이번 달 베스트"는 총 매출 3000만원인 상품이다. + +다만 일평균은 내부 분석에서는 유용하다. 다시 한다면, `avg_daily_sales = total_sales / active_days`를 MV에 별도 컬럼으로 함께 저장할 것이다. 공개 랭킹의 정렬 기준은 총 실적을 유지하면서, MD 대시보드에서 "판매 효율 기준 정렬"을 재집계 없이 제공할 수 있다. + +### 3.2 시간 윈도우: 매주 월요일에 리셋되는 랭킹이 맞는가 + +MV의 "주간"을 어떻게 정의할 것인가. + +| 전략 | 예시 | 갱신 주기 | +|------|------|----------| +| 캘린더 | 주간: 월~일, 월간: 1일~말일 | 주 1회, 월 1회 | +| 슬라이딩 (채택) | 오늘 기준 최근 7일/30일 | 매일 | + +**슬라이딩을 선택한 4가지 근거:** + +1. **Redis와 시간 범위 일치**: Redis ZUNIONSTORE가 "최근 7일 daily"를 합산하는 슬라이딩 방식. MV가 캘린더이면 시간 범위가 불일치하여 두 시스템 간 비교·검증이 어렵다. +2. **이커머스 업계 관행**: 무신사, 쿠팡 등에서 주간/월간 랭킹을 매일 갱신한다. "주간 인기 상품"이 월요일에만 바뀌면 사용자가 매일 같은 랭킹을 보게 되어 재방문 유인이 떨어진다. +3. **배치 비용 대비 효과**: GROUP BY + TOP 100 INSERT는 상품 수만 건 기준 수초 내 완료. 매일 실행해도 시스템 부하가 미미하며, 매일 갱신되는 효과가 크다. +4. **운영 단순성**: period_key가 targetDate(`20260416`) 그 자체이므로 "이 날짜 기준 최근 N일"이라는 명확한 의미. 캘린더 방식은 ISO 주차(`2026-W16`)나 월(`2026-04`) 계산이 필요하고, 월말/주초 경계 처리가 복잡하다. + +캘린더를 기각했지만, 정산/리포팅 시스템에서는 캘린더가 맞다. "4월 매출 정산"은 4/1~4/30 고정 기간이어야 한다. 슬라이딩이면 기준일에 따라 금액이 달라져 정산 불일치가 생긴다. 우리 과제는 정산이 아닌 소비자 대상 랭킹 보드이므로 슬라이딩이 적합하다. + +### 3.3 Chunk vs Tasklet: 90개 실무 Job이 알려준 것 + +이 판단의 출발은 약간 엉뚱한 곳이었다. 회사 배치 프로젝트 2개(90개 Job)를 분석했더니 통계/집계 Job의 대다수가 Tasklet이었다. 처음에는 "Tasklet이 실무 표준"이라고 결론 내렸는데, **"다른 개발자들의 이야기를 들어보면 Chunk 방식이 보편적이라고 하는데?"**라는 반론이 나왔다. + +다시 생각해보니, 분석한 배치 프로젝트가 MyBatis + SQL 중심 아키텍처여서 `INSERT INTO...SELECT`가 자연스러운 선택이었을 뿐, 이것을 업계 표준으로 일반화한 것은 **한 조직의 패턴을 확대 해석**한 것이었다. + +Spring Batch는 Chunk를 중심으로 설계되어 있다. Chunk가 보편적 선택인 이유는 프레임워크가 Chunk에만 제공하는 운영 기능에 있다. + +```java +// Chunk-Oriented에서만 쓸 수 있는 운영 기능 +.faultTolerant() + .retry(DeadlockLoserDataAccessException.class) // DB 데드락 시 자동 재시도 + .retryLimit(3) // 최대 3회 +.skip(DataIntegrityViolationException.class) // 불량 레코드 건너뛰기 +.skipLimit(100) + +// Chunk가 자동으로 기록하는 것 +StepExecution: + readCount, writeCount, skipCount, commitCount, rollbackCount +``` + +**Tasklet에서 동일한 운영 안정성을 확보하려면 retry 루프, skip 카운터, 진행 상태 저장, 처리 건수 추적을 모두 직접 구현해야 한다.** 대부분의 배치 작업에서 이 운영 기능의 가치가 네트워크 왕복 비용보다 크기 때문에 Chunk가 보편적 선택이 된다. + +그런데 90개 실무 Job을 다시 살펴보니 흥미로운 사실이 있었다. + +| 운영 기능 | 90개 Job 사용 여부 | +|----------|-------------------| +| `.faultTolerant()` | 0개 | +| `.retry()` / `retryLimit` | 0개 | +| `.skip()` / `skipLimit` | 0개 | +| `ItemReadListener` / `ItemWriteListener` | 0개 | +| `allowStartIfComplete` | 0개 | + +**90개 Job 중 단 하나도 retry, skip, restart를 사용하지 않는다.** 이것은 "운영에서 문제가 없었다"로 해석할 수도 있지만, 동시에 "1건의 일시적 DB 에러가 전체 배치를 실패시키는 구조"이기도 하다. 야간 배치가 데드락으로 실패하면 아침에 출근해서 수동 재실행해야 한다. retry를 걸어두면 자동으로 복구됐을 에러다. + +**우리 프로젝트에서 retry + ExponentialBackOffPolicy를 적용하는 것은, 실무에서 빠져 있는 운영 안정성을 보완하는 설계 판단이다.** + +```java +// 우리 MV Job의 workerStep — Chunk + retry + 지수 백오프 +ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy(); +backOff.setInitialInterval(100); +backOff.setMultiplier(2.0); +backOff.setMaxInterval(1000); + +return new StepBuilder("workerStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(stagingReader(null, null, null, null)) + .writer(stagingWriter(null)) + .faultTolerant() + .retry(DeadlockLoserDataAccessException.class) + .retry(TransientDataAccessException.class) + .retryLimit(3) + .backOffPolicy(backOff) + .listener(stepMonitorListener) + .build(); +``` + +retry 간격이 100ms → 200ms → 400ms로 지수적으로 증가하는 이유는, 즉시 재시도하면 데드락 상태에서 같은 충돌이 반복될 가능성이 높기 때문이다. + +Tasklet의 세 조건(SQL 한 문장 완결, retry 불필요, 중간 상태 무의미)을 모두 충족하면 Tasklet이 효율적이다. 우리의 mergeStep은 Tasklet으로 구현했다 — TOP 100 추출 INSERT 한 문장으로 완결되고, 100건이므로 실패 시 전체 재실행해도 수초 내 완료된다. + +### 3.4 Score 계산은 DB에서 끝내야 한다 + +처음에는 Reader에서 전체 상품을 조회하고 Processor에서 score를 계산한 후, Writer에서 TOP 100만 INSERT하는 구조를 설계했다. 그런데 수만 건을 INSERT했다가 100건만 남기고 삭제하는 것은 불필요한 I/O다. + +**"Reader가 100건만 조회해도 TOP 100이 맞아?"** SQL 실행 순서가 이것을 보장한다. + +``` +1. FROM / JOIN → product_metrics × product 조인 +2. WHERE → 날짜 범위 필터 +3. GROUP BY → product_id별 그룹핑 + SUM 집계 +4. SELECT → score 계산 (LOG10 함수) +5. ORDER BY → score 내림차순 정렬 (전체 상품 대상) +6. LIMIT 100 → 상위 100건만 반환 +``` + +DB가 전체 상품의 score를 계산하고 정렬한 후 상위 100건만 네트워크로 전달한다. Reader는 100건만 받지만, 그 100건이 score 기준 TOP 100인 것은 DB가 보장한다. + +```sql +-- Reader SQL (ProductRankingMvJobConfig.stagingReader) +SELECT + pm.product_id, + SUM(pm.view_count) AS total_view_count, + SUM(pm.like_count - pm.unlike_count) AS total_net_like_count, + SUM(pm.sales_count) AS total_sales_count, + SUM(pm.sales_amount - pm.cancel_amount_by_event_date) AS total_net_sales_amount, + ( + 0.1 * LOG10(GREATEST(SUM(pm.view_count), 0) + 1) / 7.0 + + 0.2 * LOG10(GREATEST(SUM(pm.like_count - pm.unlike_count), 0) + 1) / 7.0 + + 0.7 * LOG10(GREATEST(SUM(pm.sales_amount + - pm.cancel_amount_by_event_date), 0) + 1) / 7.0 + + UNIX_TIMESTAMP() * 1e-16 + ) AS score +FROM product_metrics pm +JOIN product p ON pm.product_id = p.id +WHERE pm.metric_date BETWEEN ? AND ? + AND pm.product_id BETWEEN ? AND ? + AND p.deleted_at IS NULL +GROUP BY pm.product_id +``` + +회사 코드를 분석한 결과도 이것을 뒷받침한다. **12개 매퍼에서 `RANK()`, `DENSE_RANK()`, `ROW_NUMBER()`, `PERCENT_RANK()` 윈도우 함수로 TOP-N을 처리하고 있었고, Java에서 랭킹/스코어링을 처리하는 배치 Job은 없었다.** "DB가 잘하는 일(집계, 정렬, 필터링)은 DB에서 끝낸다"가 이 회사의 실무 표준이었다. + +**트레이드오프가 하나 있다.** SQL에 score 공식을 넣으면, RankingCorrectionJob(일간 보정, Java)과 MV Job(주간/월간, SQL)에 같은 공식이 두 곳에 존재한다. 가중치(0.1/0.2/0.7) 변경 시 두 곳 모두 수정이 필요하다. + +이것을 허용한 근거는, 두 Job의 입력이 다르기 때문이다. Correction은 일간 메트릭(CURDATE() 1일)을 읽고, MV는 기간 합산 메트릭(7/30일 SUM)을 읽는다. 같은 공식이지만 적용 대상이 다르므로 하나의 Java 메서드를 공유하는 것이 오히려 부자연스럽다. 가중치 자체는 `application.yml`의 `RankingCorrectionProperties`에 중앙화되어 있어서 SQL에도 파라미터로 주입된다. + +### 3.5 CursorReader: GROUP BY 집계에서 PagingReader가 위험한 이유 + +처음에는 "기존 RankingCorrectionJob과 일관성"이라는 이유로 CursorReader를 골랐다. 대규모 기준으로 다시 따져보니, 이유가 훨씬 근본적이었다. + +**PagingReader는 페이지마다 독립된 쿼리를 재실행한다.** GROUP BY가 포함된 집계 쿼리에서 이것은 치명적이다. + +``` +CursorReader: + GROUP BY 3,000만 행 → 1번 실행 → 결과 스트리밍 + 총 집계 실행: 1회 + +PagingReader (pageSize=1000, 상품 100만건 = 1,000페이지): + 페이지 1: GROUP BY 3,000만 행 → 정렬 → OFFSET 0 (30초) + 페이지 2: GROUP BY 3,000만 행 → 정렬 → OFFSET 1000 (30초) + ... + 페이지 1000: GROUP BY 3,000만 행 → 정렬 → OFFSET 999000 (30초+) + 총 집계 실행: 1,000회 → 8시간 이상 +``` + +| 관점 | CursorReader | PagingReader | +|------|-------------|-------------| +| **GROUP BY 쿼리** | 1회 실행 후 스트리밍 | 페이지마다 재실행 — 대규모에서 치명적 | +| **커넥션 점유** | Step 전체 동안 1개 점유 | 페이지 조회 시만 점유 | +| **멀티스레드** | 불가 (ResultSet 공유 상태) | 가능 (각 스레드 독립 쿼리) | +| **재시작** | 제한적 (read count 기반) | 자연스러움 (페이지 번호 저장) | + +CursorReader의 약점은 멀티스레드에서 쓸 수 없다는 것이다. ResultSet이 "현재 커서 위치"라는 상태를 가지고 있어서, 두 스레드가 동시에 `next()`를 호출하면 행이 누락되거나 중복된다. + +**상품이 수백만 건으로 늘어나면 어떻게 병렬화하는가?** PagingReader로 전환하면 GROUP BY 반복 실행이라는 더 큰 문제가 생긴다. 답은 **Partitioning**이다. + +### 3.6 매번 원장에서 재계산하는 게 비효율 아닌가 + +MV가 매일 원장(product_metrics)에서 7일/30일치를 처음부터 GROUP BY한다. **"어제 결과에서 가장 오래된 날을 빼고 오늘을 더하면 되지 않나?"** 증분 계산은 월간 기준 데이터 처리량을 93% 줄일 수 있다. + +``` +어제 MV (4/10~4/16 합산): 상품 A = view 700, sales 3000만 +오늘 MV (4/11~4/17 합산): + = 어제 결과 - 4/10의 메트릭 + 4/17의 메트릭 + → 30일치 GROUP BY 대신 2일치만 조회 +``` + +수학적으로 정확하다. 근사치가 아니다. **하지만 하나의 전제가 필요하다: "과거 데이터가 변경되지 않는다."** + +이커머스에서 이 전제는 깨진다. 주문 취소는 원주문과 다른 날에 발생한다. + +``` +4/10: 상품 A 주문 100건 (1000만원) +4/15: 그 중 30건 취소 → product_metrics 4/10 행의 cancel_by_order_date 갱신 + +증분 계산: + 4/10의 값은 이미 MV에 반영됨 (취소 전 1000만원 기준) + 4/15에 4/10 행이 변경됐지만, 증분은 "4/15의 메트릭만 추가" + → 4/10 행의 사후 변경을 감지 못함 + +전체 재계산: + 4/10~4/16 전체를 다시 읽음 + → 4/10 행의 cancel_by_order_date 변경이 자동 반영 +``` + +| 시나리오 | 전체 재계산 | 증분 계산 | +|---------|-----------|----------| +| 정상 주문 | 정확 | 정확 | +| 지연 취소 | 자동 반영 | 감지 못함 | +| 운영팀 데이터 보정 | 다음 배치 자동 반영 | 전체 재계산을 별도 실행해야 함 | +| 오류 전파 | 없음 (매번 원장 독립 계산) | 어제 MV가 틀리면 오늘도 틀림 | + +성능 차이는 운영에 영향 없는 수준이다. + +``` +전체 재계산 (Partitioning 4 Worker): ~10초 +증분 계산: ~3초 +→ 1일 1회 배치에서 7초 차이 +``` + +**전체 재계산을 유지한다.** 7초의 성능 이점보다 Late-Arriving Fact 자동 반영 + 오류 자동 복구 + 구현 단순성이 이커머스 랭킹에서 더 가치 있다. 또한, MV의 존재 이유가 "Redis 근사치와 다른 정확한 기간 집계"인데, MV까지 과거 변경을 반영하지 못하는 증분 방식을 쓰면 MV의 정확성이라는 존재 이유가 약해진다. + +--- + +## 4. 구현: 3-Step Chunk Job + +위의 판단들이 구현에서 어떻게 결합되는지 정리한다. + +### 파이프라인 구조 + +``` +Step 1: cleanupStep (Tasklet) + DELETE FROM mv_product_rank_{scope} WHERE period_key = ? + DELETE FROM mv_product_rank_staging WHERE period_key = ? + + 3일 이전 과거 데이터 정리 + +Step 2: partitionedAggregateStep (Chunk × 4 Workers) + [Partitioner] product_id MIN~MAX를 4개 범위로 분할 + ┌───────────────────────────────────────────┐ + │ Worker 1: id 1~250K → CursorReader │ + │ Worker 2: id 250K~500K → CursorReader │ ← 병렬 실행 + │ Worker 3: id 500K~750K → CursorReader │ + │ Worker 4: id 750K~1M → CursorReader │ + └───────────────────────────────────────────┘ + 각 Worker: GROUP BY + score 계산 → 스테이징 테이블 INSERT + +Step 3: mergeStep (Tasklet) + SELECT ... FROM staging ORDER BY score DESC LIMIT 100 + → INSERT INTO mv_product_rank_{scope} +``` + +**이 3-Step은 분산 시스템의 Map-Reduce 패턴이다.** Step 2가 Map(병렬 집계), Step 3가 Reduce(전역 정렬 + TOP 100 추출). 스테이징 테이블이 두 단계를 연결하는 중간 저장소 역할을 한다. + +각 Worker가 독립 커넥션 + 독립 CursorReader를 가지므로, CursorReader의 멀티스레드 한계를 극복하면서 GROUP BY 1회 실행이라는 장점을 유지한다. + +### 왜 PagingReader 멀티스레드가 아닌 Partitioning인가 + +| 방식 | GROUP BY 실행 횟수 | 소요 시간 (상품 100만) | +|------|-----------------|---------------------| +| 단일 CursorReader | 1회 (3,000만 행) | ~30초 | +| PagingReader 멀티스레드 | 페이지 수 × 스레드 수 | **수 시간** | +| **Partitioning + CursorReader** | Worker 수 (각 750만 행) | **~10초** | + +Partitioning은 데이터를 범위로 분할하여 각 Worker가 자기 범위만 GROUP BY하므로, 전체 데이터를 매번 재집계하는 PagingReader와 근본적으로 다르다. + +### Partitioner 구현 + +```java +private Partitioner createPartitioner(String targetDate, String scope) { + return gridSize -> { + int days = "weekly".equals(scope) ? 6 : 29; + LocalDate endDate = LocalDate.parse(targetDate, DATE_FORMATTER); + LocalDate startDate = endDate.minusDays(days); + + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + Long minId = jdbc.queryForObject( + "SELECT COALESCE(MIN(product_id), 0) FROM product_metrics " + + "WHERE metric_date BETWEEN ? AND ?", + Long.class, startDate, endDate); + Long maxId = jdbc.queryForObject( + "SELECT COALESCE(MAX(product_id), 0) FROM product_metrics " + + "WHERE metric_date BETWEEN ? AND ?", + Long.class, startDate, endDate); + + long range = (maxId - minId) / gridSize + 1; + Map partitions = new HashMap<>(); + + for (int i = 0; i < gridSize; i++) { + ExecutionContext ctx = new ExecutionContext(); + ctx.putLong("minProductId", minId + (i * range)); + ctx.putLong("maxProductId", + Math.min(minId + ((i + 1) * range) - 1, maxId)); + partitions.put("partition" + i, ctx); + } + return partitions; + }; +} +``` + +product_id 범위를 균등 분할한다. 각 Worker의 Reader SQL에 `WHERE pm.product_id BETWEEN ? AND ?` 조건이 추가되어 자기 범위만 집계한다. + +### 멱등성: DELETE + INSERT + +```java +// CleanupTasklet — Step 1에서 타겟 날짜의 기존 데이터를 전부 정리 +int deletedMv = jdbcTemplate.update( + "DELETE FROM " + mvTable + " WHERE period_key = ?", targetDate); +int deletedStaging = jdbcTemplate.update( + "DELETE FROM mv_product_rank_staging WHERE period_key = ?", targetDate); +``` + +같은 파라미터로 몇 번을 실행해도 결과가 동일하다. `RunIdIncrementer`가 `run.id`를 증가시켜 재실행을 허용하고, cleanupStep이 기존 데이터를 삭제하고 새로 적재한다. + +Step 2에서 일부 Worker만 실패하면? 전체 재실행이 가장 단순하고 안전하다. 수십 초 수준의 작업이므로 "실패한 파티션만 재실행"보다 "전부 정리하고 처음부터"가 운영상 안전하다. cleanupStep의 `allowStartIfComplete(true)`가 이미 완료된 Step의 재실행을 허용한다. + +--- + +## 5. 시행착오 + +### "Tasklet이 실무 표준" — 한 조직의 패턴을 일반화한 오류 + +90개 Job 분석에서 Tasklet이 대다수인 것을 보고 "통계/집계 Job에서 Tasklet이 표준"이라고 결론 내렸다. MyBatis + SQL 중심 아키텍처라는 맥락을 무시한 확대 해석이었다. + +교훈: 실무 코드를 분석할 때 "무엇을 하고 있는가"뿐 아니라 "어떤 기술 스택·조직 문화에서 이 선택이 나왔는가"를 함께 봐야 한다. 하나의 코드베이스에서 관찰한 패턴은 그 조직의 맥락에서 합리적인 선택이지, 업계 표준과 동치가 아니다. + +### MV를 Redis fallback으로 쓰려다 — 데이터 불일치 함정 + +초기 설계에서 "MV primary, Redis fallback"이 자연스러워 보였다. MV가 장애나면 Redis에서라도 보여주면 되니까. log₁₀ 비선형성 검증을 하기 전까지는 두 시스템이 "대충 비슷한 결과"를 낼 것이라고 암묵적으로 가정하고 있었다. + +교훈: fallback을 설계할 때, primary와 fallback이 **같은 계약을 이행하는지** 확인해야 한다. "비슷한 데이터를 제공한다"와 "같은 기준의 데이터를 제공한다"는 다르다. + +### Chunk-Oriented인데 Processor가 할 일이 없다? + +Score 계산과 TOP-N 필터링을 SQL에서 끝내니까 Processor가 비어버렸다. "Chunk-Oriented에서 Processor가 비즈니스 로직을 담당해야 한다"는 일반론에 어긋나는 것 같아서 불편했다. + +그런데 회사 12개 매퍼를 분석한 결과, DB에서 윈도우 함수로 정렬·필터링까지 끝내고 Java는 오케스트레이션만 하는 것이 실무 패턴이었다. **"어디서 계산하느냐"는 효율의 문제이지 패턴 준수의 문제가 아니다.** + +--- + +## 6. 실전에서라면 + +### Replica DB 분리 + +CursorReader의 커넥션 점유가 문제가 되는 것은 여러 Job이 동시에 실행되어 커넥션 풀이 고갈될 때다. 분석한 회사 배치 프로젝트 2개도 RODB/RWDB를 5~6쌍으로 분리하여 이 문제를 해결하고 있었다. 배치가 Replica에서 읽으면 서비스 DB의 커넥션 풀과 독립되므로, CursorReader의 커넥션 점유가 서비스에 영향을 주지 않는다. + +### 사전 집계 파이프라인의 필요성 + +쿠팡급(상품 100만, product_metrics 30일치 3,000만 행)에서 Chunk든 Tasklet이든 집계 쿼리의 DB 부하는 동일하다. 진짜 해결해야 할 문제는 처리 모델 선택이 아니라 **"이 집계를 서비스 DB에서 할 것인가"**이다. Flink/Spark 같은 사전 집계 파이프라인이나 DW에서 집계하는 것이 대규모에서의 정석이다. + +우리 프로젝트에서는 이미 Kafka → MetricsConsumer → product_metrics라는 사전 집계 레이어가 존재한다. 원시 이벤트(수억 건)가 아닌 일간 집계 테이블(수만 건)을 배치에서 읽는 구조이므로, 사전 집계가 Reader의 입력 볼륨을 줄이는 역할을 하고 있다. + +### gridSize 튜닝 + +현재 gridSize=4로 고정했지만, 실무에서는 상품 수와 DB 커넥션 풀 크기에 따라 동적으로 조정해야 한다. 커넥션 풀이 20개이고 다른 Job과 공유한다면 gridSize를 8 이상으로 올리면 커넥션 부족이 발생할 수 있다. `MIN/MAX(product_id)` 쿼리로 데이터 분포를 확인하고 gridSize를 결정하는 방식을 기본으로 하되, 설정값으로 외부화하여 운영 중 변경할 수 있도록 하는 것이 실용적이다. + +### Drift Detection — 배치 사이의 빈 시간 + +1시간 주기 배치 보정(RankingCorrectionJob) 사이에 Redis drift가 누적될 수 있다. 이것을 조기 감지하기 위해 5분 주기로 Redis Top-20과 DB score를 비교하는 경량 모니터링(RankingDriftScheduler)을 추가했다. 부하는 ~2ms/5분으로 서비스 요청 경로에 영향 없이, "실시간 경로가 얼마나 벗어나고 있는가"를 지속적으로 관찰한다. + +실무에서는 이 drift 메트릭에 알림 임계치를 걸어서 "drift > 20%면 즉시 보정 Job 트리거"와 같은 자동 대응을 구성할 수 있다. + +--- + +## 7. 돌아보며 + +### Lambda Architecture에서 배운 것 + +이 과제를 시작했을 때는 "MV는 Redis의 백업"이라고 생각했다. Redis가 장애나면 MV에서 읽으면 되니까. 그런데 log₁₀ 비선형성을 숫자로 확인하면서, 두 시스템이 같은 데이터로 다른 결과를 내는 것이 단점이 아니라 **설계 의도**라는 것을 이해했다. + +**"Lambda Architecture에서 두 Layer의 가치는 같은 결과를 내는 것이 아니라, 같은 데이터로 다른 관점을 제공하는 것이다."** + +이 관점이 모든 후속 판단의 출발점이 되었다. Score 방식을 균등 합산으로 정한 것도, MV를 Redis fallback으로 쓰지 않기로 한 것도, 전체 재계산을 유지한 것도 — "MV는 Redis와 다른 관점을 정확하게 제공해야 한다"는 한 줄에서 파생되었다. + +### 10주간의 흐름 + +돌이켜보면 1~10주차가 하나의 연결된 흐름이었다. + +초반에는 요구사항을 그대로 구현하는 데 집중했다. "JPA로 CRUD"에서 시작해서, "왜 이 구조인가"라는 질문 없이 동작하는 코드를 만들었다. 전환점은 Round 7~8쯤이었다. Kafka 파이프라인을 설계하면서 "실시간 이벤트가 DB와 Redis에 각각 어떤 시점에 반영되는가"를 추적해야 했고, 이때부터 "동작하는 코드"와 "설명할 수 있는 설계"의 차이를 인식하기 시작했다. + +Round 9에서 Redis 랭킹을 만들면서 가중합의 함정, log₁₀ 정규화, 지수 감쇠 같은 판단을 처음 경험했다. 선택지가 여러 개인데 정답이 없는 상황에서 "왜 이것을 골랐는가"를 숫자로 검증하는 습관이 생겼다. + +Round 10에서는 그 습관이 자연스러워졌다. 균등 합산 vs 지수 감쇠를 비교할 때 직관이 아니라 실제 데이터를 넣어서 확인했고, CursorReader vs PagingReader를 비교할 때 3,000만 행 기준 소요 시간을 산정했다. **"왜 이렇게 했는가"에 숫자로 답할 수 있게 된 것**이 10주간 가장 크게 달라진 점이다. + +### 가장 큰 전환점 + +"실무 코드를 분석했더니 Tasklet이 대다수" → "그러니까 Tasklet이 표준"으로 곧장 결론 내린 순간. 그리고 그 결론이 깨진 순간. + +하나의 코드베이스에서 관찰한 패턴을 일반화하는 것은 위험하다. 그 패턴이 어떤 기술 스택, 조직 문화, 도메인 맥락에서 나왔는지를 함께 봐야 한다. 이것을 경험으로 체득한 것이 이번 과제의 가장 큰 수확이다. diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java index 9c41edacc5..c76d07e88a 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java @@ -18,7 +18,8 @@ public class MySqlTestContainersConfig { .withCommand( "--character-set-server=utf8mb4", "--collation-server=utf8mb4_general_ci", - "--skip-character-set-client-handshake" + "--skip-character-set-client-handshake", + "--innodb-buffer-pool-size=256M" ); mySqlContainer.start();