From ccd971667b7352aa322c86c16e84c258d669bb9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Fri, 17 Apr 2026 09:37:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Spring=20Batch=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=A3=BC=EA=B0=84/=EC=9B=94=EA=B0=84=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WeeklyRankingJob, MonthlyRankingJob: Chunk-Oriented Processing으로 product_metrics 집계 - mv_product_rank_weekly, mv_product_rank_monthly MV 테이블 설계 및 적재 - Ranking API에 period(DAILY/WEEKLY/MONTHLY) 파라미터 추가 - PeriodRankingRepository 도메인 인터페이스 분리 (SRP 준수) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ranking/RankingQueryService.java | 30 ++- .../ranking/RankingQueryUseCase.java | 3 + .../domain/model/ranking/RankingPeriod.java | 7 + .../ranking/RankingPeriodKeyResolver.java | 24 ++ .../repository/PeriodRankingRepository.java | 17 ++ .../ranking/JpaPeriodRankingRepository.java | 51 ++++ .../ProductRankMonthlyJpaRepository.java | 18 ++ .../ProductRankWeeklyJpaRepository.java | 18 ++ .../entity/ProductRankMonthlyJpaEntity.java | 46 ++++ .../entity/ProductRankWeeklyJpaEntity.java | 46 ++++ .../api/ranking/RankingController.java | 6 +- .../entity/ProductMetricsEntity.java | 33 +++ .../entity/ProductRankMonthlyEntity.java | 72 ++++++ .../entity/ProductRankWeeklyEntity.java | 72 ++++++ .../ProductRankMonthlyJpaRepository.java | 14 ++ .../ProductRankWeeklyJpaRepository.java | 14 ++ .../job/ranking/MonthlyRankingJobConfig.java | 163 ++++++++++++ .../job/ranking/WeeklyRankingJobConfig.java | 163 ++++++++++++ .../support/RankingPeriodKeyResolver.java | 29 +++ claudedocs/10weeks/lec10.md | 237 ++++++++++++++++++ claudedocs/10weeks/todo.md | 120 +++++++++ 21 files changed, 1179 insertions(+), 4 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/ranking/RankingPeriod.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/ranking/RankingPeriodKeyResolver.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/repository/PeriodRankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaPeriodRankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/ProductRankMonthlyJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/ProductRankWeeklyJpaEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductMetricsEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductRankMonthlyEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductRankWeeklyEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/repository/ProductRankMonthlyJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/repository/ProductRankWeeklyJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/support/RankingPeriodKeyResolver.java create mode 100644 claudedocs/10weeks/lec10.md create mode 100644 claudedocs/10weeks/todo.md diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java index 61e07780ec..dac103cb41 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java @@ -3,7 +3,9 @@ import com.loopers.domain.model.brand.Brand; import com.loopers.domain.model.common.PageResult; import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.ranking.RankingPeriod; import com.loopers.domain.repository.BrandRepository; +import com.loopers.domain.repository.PeriodRankingRepository; import com.loopers.domain.repository.ProductRepository; import com.loopers.domain.repository.RankingRepository; import com.loopers.domain.repository.RankingRepository.RankedProduct; @@ -21,27 +23,51 @@ public class RankingQueryService implements RankingQueryUseCase { private final RankingRepository rankingRepository; + private final PeriodRankingRepository periodRankingRepository; private final ProductRepository productRepository; private final BrandRepository brandRepository; public RankingQueryService(RankingRepository rankingRepository, + PeriodRankingRepository periodRankingRepository, ProductRepository productRepository, BrandRepository brandRepository) { this.rankingRepository = rankingRepository; + this.periodRankingRepository = periodRankingRepository; this.productRepository = productRepository; this.brandRepository = brandRepository; } @Override public PageResult getRankings(LocalDate date, int page, int size) { + return getRankings(date, page, size, RankingPeriod.DAILY); + } + + @Override + public PageResult getRankings(LocalDate date, int page, int size, RankingPeriod period) { int offset = page * size; - List rankedProducts = rankingRepository.getTopRankings(date, offset, size); + + List rankedProducts; + long totalCount; + + switch (period) { + case WEEKLY -> { + rankedProducts = periodRankingRepository.getWeeklyRankings(date, offset, size); + totalCount = periodRankingRepository.getWeeklyTotalCount(date); + } + case MONTHLY -> { + rankedProducts = periodRankingRepository.getMonthlyRankings(date, offset, size); + totalCount = periodRankingRepository.getMonthlyTotalCount(date); + } + default -> { + rankedProducts = rankingRepository.getTopRankings(date, offset, size); + totalCount = rankingRepository.getTotalCount(date); + } + } if (rankedProducts.isEmpty()) { return new PageResult<>(Collections.emptyList(), page, size, 0, 0); } - long totalCount = rankingRepository.getTotalCount(date); int totalPages = (int) Math.ceil((double) totalCount / size); List productIds = rankedProducts.stream() diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryUseCase.java index ae27bba609..6b4e9c6d1b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryUseCase.java @@ -1,6 +1,7 @@ package com.loopers.application.ranking; import com.loopers.domain.model.common.PageResult; +import com.loopers.domain.model.ranking.RankingPeriod; import java.time.LocalDate; @@ -8,6 +9,8 @@ public interface RankingQueryUseCase { PageResult getRankings(LocalDate date, int page, int size); + PageResult getRankings(LocalDate date, int page, int size, RankingPeriod period); + Long getProductRank(LocalDate date, Long productId); record RankingItemInfo( diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/ranking/RankingPeriod.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/ranking/RankingPeriod.java new file mode 100644 index 0000000000..ac17b8e7dc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/ranking/RankingPeriod.java @@ -0,0 +1,7 @@ +package com.loopers.domain.model.ranking; + +public enum RankingPeriod { + DAILY, + WEEKLY, + MONTHLY +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/ranking/RankingPeriodKeyResolver.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/ranking/RankingPeriodKeyResolver.java new file mode 100644 index 0000000000..76580f2ab7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/ranking/RankingPeriodKeyResolver.java @@ -0,0 +1,24 @@ +package com.loopers.domain.model.ranking; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; +import java.util.Locale; + +public final class RankingPeriodKeyResolver { + + private static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM"); + + private RankingPeriodKeyResolver() {} + + public static String toYearWeek(LocalDate date) { + WeekFields weekFields = WeekFields.of(Locale.KOREA); + int year = date.getYear(); + int week = date.get(weekFields.weekOfWeekBasedYear()); + return String.format("%d-W%02d", year, week); + } + + public static String toYearMonth(LocalDate date) { + return date.format(YEAR_MONTH_FORMAT); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/PeriodRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/PeriodRankingRepository.java new file mode 100644 index 0000000000..dd37e31cc2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/PeriodRankingRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.repository; + +import com.loopers.domain.repository.RankingRepository.RankedProduct; + +import java.time.LocalDate; +import java.util.List; + +public interface PeriodRankingRepository { + + List getWeeklyRankings(LocalDate date, int offset, int size); + + long getWeeklyTotalCount(LocalDate date); + + List getMonthlyRankings(LocalDate date, int offset, int size); + + long getMonthlyTotalCount(LocalDate date); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaPeriodRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaPeriodRankingRepository.java new file mode 100644 index 0000000000..33fa3fdaf3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaPeriodRankingRepository.java @@ -0,0 +1,51 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.model.ranking.RankingPeriodKeyResolver; +import com.loopers.domain.repository.PeriodRankingRepository; +import com.loopers.domain.repository.RankingRepository.RankedProduct; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +public class JpaPeriodRankingRepository implements PeriodRankingRepository { + + private final ProductRankWeeklyJpaRepository weeklyRepository; + private final ProductRankMonthlyJpaRepository monthlyRepository; + + public JpaPeriodRankingRepository(ProductRankWeeklyJpaRepository weeklyRepository, + ProductRankMonthlyJpaRepository monthlyRepository) { + this.weeklyRepository = weeklyRepository; + this.monthlyRepository = monthlyRepository; + } + + @Override + public List getWeeklyRankings(LocalDate date, int offset, int size) { + String yearWeek = RankingPeriodKeyResolver.toYearWeek(date); + PageRequest pageable = PageRequest.of(offset / size, size); + return weeklyRepository.findByYearWeekOrderByRanking(yearWeek, pageable).stream() + .map(e -> new RankedProduct(e.getProductId(), e.getScore(), e.getRanking() - 1L)) + .toList(); + } + + @Override + public long getWeeklyTotalCount(LocalDate date) { + return weeklyRepository.countByYearWeek(RankingPeriodKeyResolver.toYearWeek(date)); + } + + @Override + public List getMonthlyRankings(LocalDate date, int offset, int size) { + String yearMonth = RankingPeriodKeyResolver.toYearMonth(date); + PageRequest pageable = PageRequest.of(offset / size, size); + return monthlyRepository.findByYearMonthOrderByRanking(yearMonth, pageable).stream() + .map(e -> new RankedProduct(e.getProductId(), e.getScore(), e.getRanking() - 1L)) + .toList(); + } + + @Override + public long getMonthlyTotalCount(LocalDate date) { + return monthlyRepository.countByYearMonth(RankingPeriodKeyResolver.toYearMonth(date)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..8792732582 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.infrastructure.ranking.entity.ProductRankMonthlyJpaEntity; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ProductRankMonthlyJpaRepository extends JpaRepository { + + @Query("SELECT e FROM ProductRankMonthlyJpaEntity e WHERE e.yearMonth = :yearMonth ORDER BY e.ranking ASC") + List findByYearMonthOrderByRanking( + @Param("yearMonth") String yearMonth, Pageable pageable); + + long countByYearMonth(String yearMonth); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..fcdd057cd7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.infrastructure.ranking.entity.ProductRankWeeklyJpaEntity; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ProductRankWeeklyJpaRepository extends JpaRepository { + + @Query("SELECT e FROM ProductRankWeeklyJpaEntity e WHERE e.yearWeek = :yearWeek ORDER BY e.ranking ASC") + List findByYearWeekOrderByRanking( + @Param("yearWeek") String yearWeek, Pageable pageable); + + long countByYearWeek(String yearWeek); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/ProductRankMonthlyJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/ProductRankMonthlyJpaEntity.java new file mode 100644 index 0000000000..6dd6f772c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/ProductRankMonthlyJpaEntity.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.ranking.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mv_product_rank_monthly") +public class ProductRankMonthlyJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false, length = 7) + private String yearMonth; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long orderCount; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long totalSalesAmount; + + @Column(nullable = false) + private double score; + + @Column(nullable = false) + private int ranking; + + @Column(nullable = false) + private LocalDateTime updatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/ProductRankWeeklyJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/ProductRankWeeklyJpaEntity.java new file mode 100644 index 0000000000..611ced408f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/entity/ProductRankWeeklyJpaEntity.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.ranking.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mv_product_rank_weekly") +public class ProductRankWeeklyJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false, length = 7) + private String yearWeek; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long orderCount; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long totalSalesAmount; + + @Column(nullable = false) + private double score; + + @Column(nullable = false) + private int ranking; + + @Column(nullable = false) + private LocalDateTime updatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java index efa57e7d40..916632c077 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java @@ -2,6 +2,7 @@ import com.loopers.application.ranking.RankingQueryUseCase; import com.loopers.domain.model.common.PageResult; +import com.loopers.domain.model.ranking.RankingPeriod; import com.loopers.interfaces.api.common.PageResponse; import com.loopers.interfaces.api.ranking.dto.RankingItemResponse; import org.springframework.format.annotation.DateTimeFormat; @@ -27,12 +28,13 @@ public RankingController(RankingQueryUseCase rankingQueryUseCase) { public ResponseEntity> getRankings( @RequestParam(required = false) @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date, @RequestParam(defaultValue = "20") int size, - @RequestParam(defaultValue = "0") int page + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "DAILY") RankingPeriod period ) { LocalDate targetDate = date != null ? date : LocalDate.now(); PageResult rankings = - rankingQueryUseCase.getRankings(targetDate, page, size); + rankingQueryUseCase.getRankings(targetDate, page, size, period); return ResponseEntity.ok(PageResponse.from(rankings, RankingItemResponse::from)); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductMetricsEntity.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductMetricsEntity.java new file mode 100644 index 0000000000..234ee0b102 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductMetricsEntity.java @@ -0,0 +1,33 @@ +package com.loopers.batch.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "product_metrics") +public class ProductMetricsEntity { + + @Id + private Long productId; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long orderCount; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long totalSalesAmount; + + @Column(nullable = false) + private LocalDateTime updatedAt; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductRankMonthlyEntity.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductRankMonthlyEntity.java new file mode 100644 index 0000000000..995cb4fe70 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductRankMonthlyEntity.java @@ -0,0 +1,72 @@ +package com.loopers.batch.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mv_product_rank_monthly", + uniqueConstraints = @UniqueConstraint(columnNames = {"productId", "yearMonth"})) +public class ProductRankMonthlyEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false, length = 7) + private String yearMonth; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long orderCount; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long totalSalesAmount; + + @Column(nullable = false) + private double score; + + @Column(nullable = false) + private int ranking; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public ProductRankMonthlyEntity(Long productId, String yearMonth, + long likeCount, long orderCount, long viewCount, + long totalSalesAmount, double score, int ranking) { + this.productId = productId; + this.yearMonth = yearMonth; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.viewCount = viewCount; + this.totalSalesAmount = totalSalesAmount; + this.score = score; + this.ranking = ranking; + this.updatedAt = LocalDateTime.now(); + } + + public void updateRanking(long likeCount, long orderCount, long viewCount, + long totalSalesAmount, double score, int ranking) { + this.likeCount = likeCount; + this.orderCount = orderCount; + this.viewCount = viewCount; + this.totalSalesAmount = totalSalesAmount; + this.score = score; + this.ranking = ranking; + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductRankWeeklyEntity.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductRankWeeklyEntity.java new file mode 100644 index 0000000000..95dc5dde90 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/entity/ProductRankWeeklyEntity.java @@ -0,0 +1,72 @@ +package com.loopers.batch.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mv_product_rank_weekly", + uniqueConstraints = @UniqueConstraint(columnNames = {"productId", "yearWeek"})) +public class ProductRankWeeklyEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false, length = 7) + private String yearWeek; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long orderCount; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long totalSalesAmount; + + @Column(nullable = false) + private double score; + + @Column(nullable = false) + private int ranking; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public ProductRankWeeklyEntity(Long productId, String yearWeek, + long likeCount, long orderCount, long viewCount, + long totalSalesAmount, double score, int ranking) { + this.productId = productId; + this.yearWeek = yearWeek; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.viewCount = viewCount; + this.totalSalesAmount = totalSalesAmount; + this.score = score; + this.ranking = ranking; + this.updatedAt = LocalDateTime.now(); + } + + public void updateRanking(long likeCount, long orderCount, long viewCount, + long totalSalesAmount, double score, int ranking) { + this.likeCount = likeCount; + this.orderCount = orderCount; + this.viewCount = viewCount; + this.totalSalesAmount = totalSalesAmount; + this.score = score; + this.ranking = ranking; + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/repository/ProductRankMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/repository/ProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..38c70edb30 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/repository/ProductRankMonthlyJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.batch.infrastructure.repository; + +import com.loopers.batch.infrastructure.entity.ProductRankMonthlyEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProductRankMonthlyJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM ProductRankMonthlyEntity e WHERE e.yearMonth = :yearMonth") + void deleteByYearMonth(@Param("yearMonth") String yearMonth); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/repository/ProductRankWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/repository/ProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..9db5819a88 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/repository/ProductRankWeeklyJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.batch.infrastructure.repository; + +import com.loopers.batch.infrastructure.entity.ProductRankWeeklyEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProductRankWeeklyJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM ProductRankWeeklyEntity e WHERE e.yearWeek = :yearWeek") + void deleteByYearWeek(@Param("yearWeek") String yearWeek); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java new file mode 100644 index 0000000000..2d8fe11fae --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -0,0 +1,163 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.infrastructure.entity.ProductMetricsEntity; +import com.loopers.batch.infrastructure.entity.ProductRankMonthlyEntity; +import com.loopers.batch.infrastructure.repository.ProductRankMonthlyJpaRepository; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.repeat.RepeatStatus; +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 com.loopers.batch.support.RankingPeriodKeyResolver; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class MonthlyRankingJobConfig { + + public static final String JOB_NAME = "monthlyRankingJob"; + private static final String STEP_CLEANUP = "monthlyRankingCleanupStep"; + private static final String STEP_AGGREGATE = "monthlyRankingAggregateStep"; + private static final int CHUNK_SIZE = 1000; + private static final int TOP_RANK_LIMIT = 100; + + private static final double WEIGHT_VIEW = 0.1; + private static final double WEIGHT_LIKE = 0.2; + private static final double WEIGHT_ORDER = 0.7; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final EntityManagerFactory entityManagerFactory; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ProductRankMonthlyJpaRepository monthlyRepository; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(monthlyCleanupStep()) + .next(monthlyAggregateStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_CLEANUP) + public Step monthlyCleanupStep() { + return new StepBuilder(STEP_CLEANUP, jobRepository) + .tasklet(monthlyCleanupTasklet(null), transactionManager) + .listener(stepMonitorListener) + .build(); + } + + @StepScope + @Bean + public Tasklet monthlyCleanupTasklet( + @Value("#{jobParameters['requestDate']}") String requestDate + ) { + return (contribution, chunkContext) -> { + String yearMonth = resolveYearMonth(requestDate); + log.info("월간 랭킹 기존 데이터 삭제: yearMonth={}", yearMonth); + monthlyRepository.deleteByYearMonth(yearMonth); + return RepeatStatus.FINISHED; + }; + } + + @JobScope + @Bean(STEP_AGGREGATE) + public Step monthlyAggregateStep() { + return new StepBuilder(STEP_AGGREGATE, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyMetricsReader()) + .processor(monthlyRankingProcessor(null)) + .writer(monthlyRankingWriter(null)) + .listener(stepMonitorListener) + .build(); + } + + @StepScope + @Bean + public JpaPagingItemReader monthlyMetricsReader() { + return new JpaPagingItemReaderBuilder() + .name("monthlyMetricsReader") + .entityManagerFactory(entityManagerFactory) + .queryString( + "SELECT m FROM ProductMetricsEntity m " + + "ORDER BY (m.viewCount * " + WEIGHT_VIEW + + " + m.likeCount * " + WEIGHT_LIKE + + " + m.orderCount * " + WEIGHT_ORDER + ") DESC" + ) + .pageSize(TOP_RANK_LIMIT) + .maxItemCount(TOP_RANK_LIMIT) + .build(); + } + + @StepScope + @Bean + public ItemProcessor monthlyRankingProcessor( + @Value("#{jobParameters['requestDate']}") String requestDate + ) { + AtomicInteger rankCounter = new AtomicInteger(0); + String yearMonth = resolveYearMonth(requestDate); + + return metrics -> { + int rank = rankCounter.incrementAndGet(); + double score = metrics.getViewCount() * WEIGHT_VIEW + + metrics.getLikeCount() * WEIGHT_LIKE + + metrics.getOrderCount() * WEIGHT_ORDER; + + return new ProductRankMonthlyEntity( + metrics.getProductId(), + yearMonth, + metrics.getLikeCount(), + metrics.getOrderCount(), + metrics.getViewCount(), + metrics.getTotalSalesAmount(), + score, + rank + ); + }; + } + + @StepScope + @Bean + public ItemWriter monthlyRankingWriter( + @Value("#{jobParameters['requestDate']}") String requestDate + ) { + return chunk -> { + List items = chunk.getItems(); + monthlyRepository.saveAll(items); + log.info("월간 랭킹 {} 건 저장 완료", items.size()); + }; + } + + private String resolveYearMonth(String requestDate) { + LocalDate date = RankingPeriodKeyResolver.parseDate(requestDate); + return RankingPeriodKeyResolver.toYearMonth(date); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java new file mode 100644 index 0000000000..1f9c214aa3 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -0,0 +1,163 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.infrastructure.entity.ProductMetricsEntity; +import com.loopers.batch.infrastructure.entity.ProductRankWeeklyEntity; +import com.loopers.batch.infrastructure.repository.ProductRankWeeklyJpaRepository; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.repeat.RepeatStatus; +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 com.loopers.batch.support.RankingPeriodKeyResolver; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class WeeklyRankingJobConfig { + + public static final String JOB_NAME = "weeklyRankingJob"; + private static final String STEP_CLEANUP = "weeklyRankingCleanupStep"; + private static final String STEP_AGGREGATE = "weeklyRankingAggregateStep"; + private static final int CHUNK_SIZE = 1000; + private static final int TOP_RANK_LIMIT = 100; + + private static final double WEIGHT_VIEW = 0.1; + private static final double WEIGHT_LIKE = 0.2; + private static final double WEIGHT_ORDER = 0.7; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final EntityManagerFactory entityManagerFactory; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ProductRankWeeklyJpaRepository weeklyRepository; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(weeklyCleanupStep()) + .next(weeklyAggregateStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_CLEANUP) + public Step weeklyCleanupStep() { + return new StepBuilder(STEP_CLEANUP, jobRepository) + .tasklet(weeklyCleanupTasklet(null), transactionManager) + .listener(stepMonitorListener) + .build(); + } + + @StepScope + @Bean + public Tasklet weeklyCleanupTasklet( + @Value("#{jobParameters['requestDate']}") String requestDate + ) { + return (contribution, chunkContext) -> { + String yearWeek = resolveYearWeek(requestDate); + log.info("주간 랭킹 기존 데이터 삭제: yearWeek={}", yearWeek); + weeklyRepository.deleteByYearWeek(yearWeek); + return RepeatStatus.FINISHED; + }; + } + + @JobScope + @Bean(STEP_AGGREGATE) + public Step weeklyAggregateStep() { + return new StepBuilder(STEP_AGGREGATE, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyMetricsReader()) + .processor(weeklyRankingProcessor(null)) + .writer(weeklyRankingWriter(null)) + .listener(stepMonitorListener) + .build(); + } + + @StepScope + @Bean + public JpaPagingItemReader weeklyMetricsReader() { + return new JpaPagingItemReaderBuilder() + .name("weeklyMetricsReader") + .entityManagerFactory(entityManagerFactory) + .queryString( + "SELECT m FROM ProductMetricsEntity m " + + "ORDER BY (m.viewCount * " + WEIGHT_VIEW + + " + m.likeCount * " + WEIGHT_LIKE + + " + m.orderCount * " + WEIGHT_ORDER + ") DESC" + ) + .pageSize(TOP_RANK_LIMIT) + .maxItemCount(TOP_RANK_LIMIT) + .build(); + } + + @StepScope + @Bean + public ItemProcessor weeklyRankingProcessor( + @Value("#{jobParameters['requestDate']}") String requestDate + ) { + AtomicInteger rankCounter = new AtomicInteger(0); + String yearWeek = resolveYearWeek(requestDate); + + return metrics -> { + int rank = rankCounter.incrementAndGet(); + double score = metrics.getViewCount() * WEIGHT_VIEW + + metrics.getLikeCount() * WEIGHT_LIKE + + metrics.getOrderCount() * WEIGHT_ORDER; + + return new ProductRankWeeklyEntity( + metrics.getProductId(), + yearWeek, + metrics.getLikeCount(), + metrics.getOrderCount(), + metrics.getViewCount(), + metrics.getTotalSalesAmount(), + score, + rank + ); + }; + } + + @StepScope + @Bean + public ItemWriter weeklyRankingWriter( + @Value("#{jobParameters['requestDate']}") String requestDate + ) { + return chunk -> { + List items = chunk.getItems(); + weeklyRepository.saveAll(items); + log.info("주간 랭킹 {} 건 저장 완료", items.size()); + }; + } + + private String resolveYearWeek(String requestDate) { + LocalDate date = RankingPeriodKeyResolver.parseDate(requestDate); + return RankingPeriodKeyResolver.toYearWeek(date); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/support/RankingPeriodKeyResolver.java b/apps/commerce-batch/src/main/java/com/loopers/batch/support/RankingPeriodKeyResolver.java new file mode 100644 index 0000000000..7f8597b56b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/support/RankingPeriodKeyResolver.java @@ -0,0 +1,29 @@ +package com.loopers.batch.support; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; +import java.util.Locale; + +public final class RankingPeriodKeyResolver { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM"); + + private RankingPeriodKeyResolver() {} + + public static LocalDate parseDate(String requestDate) { + return LocalDate.parse(requestDate, DATE_FORMAT); + } + + public static String toYearWeek(LocalDate date) { + WeekFields weekFields = WeekFields.of(Locale.KOREA); + int year = date.getYear(); + int week = date.get(weekFields.weekOfWeekBasedYear()); + return String.format("%d-W%02d", year, week); + } + + public static String toYearMonth(LocalDate date) { + return date.format(YEAR_MONTH_FORMAT); + } +} diff --git a/claudedocs/10weeks/lec10.md b/claudedocs/10weeks/lec10.md new file mode 100644 index 0000000000..789418246e --- /dev/null +++ b/claudedocs/10weeks/lec10.md @@ -0,0 +1,237 @@ +## 🧭 루프팩 BE L2 - Round 10 + +> 서비스에서 다양한 가치를 창출하기 위해 대량의 데이터를 모으고, 쌓고, 압착해야 합니다. 데이터의 규모가 커지면, 점점 이런 작업들을 웹 애플리케이션 내에서 처리하는 것에 대한 부하가 가파르게 높아집니다. + +그래서 우리는 마지막으로 `spring-batch` 애플리케이션을 만들어 볼 거예요. 이를 기반으로 일간 랭킹 뿐 아닌 주간, 월간 랭킹 또한 집계를 활용해 만들어 봅시다. +> + + + +지난 라운드에서 Kafka Consumer 와 Redis ZSET 을 활용해 메세지를 압착해 처리량을 높이는 테크닉, 특정 점수 기준의 정렬 SET 활용 방법을 학습하고 실시간으로 갱신되는 일단위 랭킹을 만들어보았습니다. + +이번 라운드에서는 Spring Batch 를 이용해 주간, 월간 랭킹을 구현합니다. **Batch** 는 일간 집계를 기반으로 주간, 월간 집계를 만들어내고 **API** 는 일간 랭킹 뿐 아니라 주간, 월간 랭킹도 제공합니다. + + + +- Spring Batch (Job / Step / Chunk / Tasklet) +- ItemReader / ItemProcessor / ItemWriter +- Materialized View (사전 집계) +- 실시간 처리 vs 배치 처리 + + + +## 🧮 Bacth System + + + +### 🎞️ 실무에서 자주 보는 배치 시나리오 + +- **주문 정산** + - 주문/결제/환불 데이터를 모아 매일 새벽 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 + + + +- **복잡한 집계 쿼리를 미리 계산해둔 조회 전용 구조** +- 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 을 동시에 실행해 성능 향상 +- **모니터링** : 실행 로그, 실패 알림, 처리 건수 추적 + +--- + + + +| 구분 | 링크 | +| --- | --- | +| 🔍 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/) | + + + +이번 10주 동안 우리는 **단순한 CRUD를 넘어서, 실제 서비스에서 마주치는 문제들을 단계적으로 풀어왔습니다**. 현업에서 여러분들이 활약하기 위해 어떤 것들을 알면 좋을지, 문제를 접근하고 해석하는 방법, 문제에 맞는 적절한 해답을 도출하는 방법 등을 전달하려고 노력했어요. + +- **1~3주차** : 도메인 모델링, 계층 분리, 객체 협력 설계 +- **4~6주차** : 트랜잭션과 동시성, 읽기 최적화, 외부 시스템(결제 PG) 연동과 회복 탄력성 +- **7주차** : 이벤트 와 Kafka, 유량제어 +- **8주차** : 대기열 큐 +- **9주차** : 실시간 집계, 랭킹 시스템 구축 +- **10주차** : 배치와 Materialized View를 통한 대규모 집계와 조회 최적화 + +즉, **이커머스라는 시나리오를 통해 → 설계 → 동시성 → 성능 → 회복력 → 이벤트 → 확장성 → 데이터 파이프라인 → 집계** 까지, 실무에서 다루는 거의 모든 챕터를 작은 스케일로 경험해 본 셈입니다. + +하지만 여기서 끝이 아닙니다. + +- 실제 서비스는 **더 많은 데이터와 트래픽, 더 복잡한 요구사항** 속에서 움직입니다. +- 새로운 기능을 추가할 때마다, 이번 과정에서 배ㄴ운 **Trade-off와 선택의 기준**이 반복해서 필요합니다. +- 이직, 프로젝트, 사이드 개발 등 어떤 길을 가더라도, 지금 경험한 **문제 정의 → 분석 → 해결** 과정은 계속해서 쓰이게 될 것이고 힘이 되어줄 겁니다. + + + +이제는 여러분이 스스로 문제를 정의하고, 배운 도구와 방법을 적용하며, 더 깊은 학습으로 나아갈 차례입니다. + +루프팩 BE L2는 끝났지만, **여러분의 성장 여정은 여기서부터가 시작**입니다. \ No newline at end of file diff --git a/claudedocs/10weeks/todo.md b/claudedocs/10weeks/todo.md new file mode 100644 index 0000000000..5485caa7b1 --- /dev/null +++ b/claudedocs/10weeks/todo.md @@ -0,0 +1,120 @@ +# 📝 Round 10 Quests + +--- + +## 💻 Implementation Quest + +> 이번에는 Spring Batch 를 활용해 주간, 월간 랭킹을 제공해 볼 거예요. +이전에 적재했던 `product_metrics` 와 같은 일간 집계정보를 기반으로 **주간, 월간 랭킹 시스템을 구축**해봅니다. +> + + + +### 📋 과제 정보 + +이번 주는 대규모 데이터 집계 및 조회 전용 구조에 대한 설계를 진행해 봅니다. + +### (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 + +> 이번 주에 학습한 내용, 과제 진행을 되돌아보며 +**"내가 어떤 판단을 하고 왜 그렇게 구현했는지"** 를 글로 정리해봅니다. +> +> +> **좋은 블로그 글은 내가 겪은 문제를, 타인도 공감할 수 있게 정리한 글입니다.** +> +> 이 글은 단순 과제가 아니라, **향후 이직에 도움이 될 수 있는 포트폴리오** 가 될 수 있어요. +> + +### 📚 Technical Writing Guide + +### ✅ 작성 기준 + +| 항목 | 설명 | +| --- | --- | +| **형식** | 블로그 | +| **길이** | 제한 없음, 단 꼭 **1줄 요약 (TL;DR)** 을 포함해 주세요 | +| **포인트** | “무엇을 했다” 보다 **“왜 그렇게 판단했는가”** 중심 | +| **예시 포함** | 코드 비교, 흐름도, 리팩토링 전후 예시 등 자유롭게 | +| **톤** | 실력은 보이지만, 자만하지 않고, **고민이 읽히는 글**예: “처음엔 mock으로 충분하다고 생각했지만, 나중에 fake로 교체하게 된 이유는…” | + +--- + +### ✨ 좋은 톤은 이런 느낌이에요 + +> 내가 겪은 실전적 고민을 다른 개발자도 공감할 수 있게 풀어내자 +> + +| 특징 | 예시 | +| --- | --- | +| 🤔 내 언어로 설명한 개념 | Stub과 Mock의 차이를 이번 주문 테스트에서 처음 실감했다 | +| 💭 판단 흐름이 드러나는 글 | 처음엔 도메인을 나누지 않았는데, 테스트가 어려워지며 분리했다 | +| 📐 정보 나열보다 인사이트 중심 | 테스트는 작성했지만, 구조는 만족스럽지 않다. 다음엔… | + +### ❌ 피해야 할 스타일 + +| 예시 | 이유 | +| --- | --- | +| 많이 부족했고, 반성합니다… | 회고가 아니라 일기처럼 보입니다 | +| Stub은 응답을 지정하고… | 내 생각이 아닌 요약문처럼 보입니다 | +| 테스트가 진리다 | 너무 단정적이거나 오만해 보입니다 | + +### 🎯 Retrospective + +- 단순히 “무엇을 했다”가 아니라, **10주 동안 어떻게 성장했는지**를 돌아본다. +- “기능 구현” 중심이 아니라, **사고방식/문제 해결/설계 선택 과정** 중심으로 기록한다. +- 이 글은 **개인 포트폴리오**이자, 앞으로 학습 방향을 스스로 점검하는 기준점이 된다. + +### 담으면 좋은 내용 + +1. **전체 여정 요약** + - 1~10주차 동안 다뤘던 주요 테마 및 문제점들을 간단히 돌아보기 + - 단순 나열이 아니라, **흐름이 어떻게 연결되었는지** 를 강조 +2. **가장 큰 전환점** + - **내 기존의 사고방식이 바뀌었다** 싶은 순간 + - *예: 4주차 트랜잭션/락을 통해 단순 @Transactional 이상의 고민을 알게 된 점, 7주차 이벤트 분리를 통해 ‘확장성’에 눈을 뜬 경험* +3. **나의 Trade-off 판단** + - 실습 중 내가 내린 중요한 선택 1~2개 + - 왜 그 선택을 했고, 대안은 뭐였는지, 지금 다시 한다면 어떻게 할 건지 +4. **실전과의 연결** + - “이건 실제 회사/서비스에서 써먹을 수 있겠다” 싶은 포인트 + - *예: 캐시 무효화 전략, Kafka 기반 집계, Resilience4j 설정 등* \ No newline at end of file