diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..1f80db6bf --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,13 @@ +name: PR Agent +on: + pull_request: + types: [opened, synchronize] +jobs: + pr_agent_job: + runs-on: ubuntu-latest + steps: + - name: PR Agent action step + uses: Codium-ai/pr-agent@main + env: + OPENAI_KEY: ${{ secrets.OPENAI_KEY }} + GITHUB_TOKEN: ${{ secrets.G_TOKEN }} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 82b643505..f42fa72ba 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -3,7 +3,6 @@ import com.loopers.domain.like.LikeService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; /** * packageName : com.loopers.application.like @@ -18,7 +17,6 @@ */ @Component @RequiredArgsConstructor -@Transactional public class LikeFacade { private final LikeService likeService; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/ProductRankSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ProductRankSnapshot.java new file mode 100644 index 000000000..f812bd31c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ProductRankSnapshot.java @@ -0,0 +1,8 @@ +package com.loopers.application.ranking; + +public record ProductRankSnapshot( + long rank, + Long productId, + double score +) { +} 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 6d8b77290..8d9d91e05 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 @@ -21,19 +21,30 @@ public class RankingFacade { private final RankingService rankingService; private final ProductService productService; private final BrandService brandService; + private final RankingMaterializedViewService rankingMaterializedViewService; @Transactional(readOnly = true) - public List getRankingItems(String date, int page, int size) { - return rankingService.getRankingRows(date, page, size) + public List getRankingItems(String date, RankingPeriod period, int page, int size) { + if (period.isDaily()) { + return rankingService.getRankingRows(date, page, size) + .stream() + .map(this::toDto) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + return rankingMaterializedViewService.getRankings(period, period.resolveKey(date), page, size) .stream() - .map(this::toDto) + .map(this::toSnapshotDto) .filter(Objects::nonNull) .collect(Collectors.toList()); } @Transactional(readOnly = true) - public long count(String date) { - return rankingService.count(date); + public long count(String date, RankingPeriod period) { + if (period.isDaily()) { + return rankingService.count(date); + } + return rankingMaterializedViewService.count(period, period.resolveKey(date)); } @Transactional(readOnly = true) @@ -53,8 +64,19 @@ private RankingInfo toDto(RankingRow row) { return null; } - Product product = productService.getProduct(row.productId()); + return createRankingInfo(row.productId(), row.rank(), row.score()); + } + + private RankingInfo toSnapshotDto(ProductRankSnapshot snapshot) { + if (snapshot.productId() == null) { + return null; + } + return createRankingInfo(snapshot.productId(), snapshot.rank(), snapshot.score()); + } + + private RankingInfo createRankingInfo(Long productId, long rank, double score) { + Product product = productService.getProduct(productId); ProductInfo productInfo = ProductInfo.of(product, brandService.getBrand(product.getBrandId())); - return new RankingInfo(row.rank(), row.score(), productInfo); + return new RankingInfo(rank, score, productInfo); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingMaterializedViewService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingMaterializedViewService.java new file mode 100644 index 000000000..a5809142e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingMaterializedViewService.java @@ -0,0 +1,53 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyJpaRepository; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RankingMaterializedViewService { + + private final MvProductRankWeeklyJpaRepository weeklyRepository; + private final MvProductRankMonthlyJpaRepository monthlyRepository; + + public List getRankings(RankingPeriod period, String periodKey, int page, int size) { + List snapshots = fetch(period, periodKey); + int safeSize = Math.max(size, 1); + long skip = (long) Math.max(page, 0) * safeSize; + return snapshots.stream() + .skip(skip) + .limit(safeSize) + .toList(); + } + + public long count(RankingPeriod period, String periodKey) { + return fetch(period, periodKey).size(); + } + + private List fetch(RankingPeriod period, String periodKey) { + if (period == RankingPeriod.WEEKLY) { + return weeklyRepository.findByIdPeriodKeyOrderByRankAsc(periodKey).stream() + .map(this::fromWeekly) + .toList(); + } + if (period == RankingPeriod.MONTHLY) { + return monthlyRepository.findByIdPeriodKeyOrderByRankAsc(periodKey).stream() + .map(this::fromMonthly) + .toList(); + } + throw new IllegalArgumentException("Unsupported period for MV: " + period); + } + + private ProductRankSnapshot fromWeekly(MvProductRankWeekly entity) { + return new ProductRankSnapshot(entity.getRank(), entity.getProductId(), entity.getScore()); + } + + private ProductRankSnapshot fromMonthly(MvProductRankMonthly entity) { + return new ProductRankSnapshot(entity.getRank(), entity.getProductId(), entity.getScore()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java new file mode 100644 index 000000000..c81dc7271 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java @@ -0,0 +1,93 @@ +package com.loopers.application.ranking; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; +import java.time.temporal.WeekFields; +import java.util.Arrays; +import java.util.Locale; + +public enum RankingPeriod { + DAILY("daily") { + @Override + public LocalDate resolveStartDate(String date) { + return parse(date); + } + + @Override + public String resolveKey(String date) { + return parse(date).format(FORMATTER); + } + }, + WEEKLY("weekly") { + @Override + public LocalDate resolveStartDate(String date) { + LocalDate target = parse(date); + return target.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + } + + @Override + public String resolveKey(String date) { + return toYearMonthWeek(resolveStartDate(date)); + } + }, + MONTHLY("monthly") { + @Override + public LocalDate resolveStartDate(String date) { + LocalDate target = parse(date); + return target.withDayOfMonth(1); + } + + @Override + public String resolveKey(String date) { + return toYearMonth(resolveStartDate(date)); + } + }; + + private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + + private final String value; + + RankingPeriod(String value) { + this.value = value; + } + + public static RankingPeriod from(String value) { + if (value == null) { + return DAILY; + } + return Arrays.stream(values()) + .filter(period -> period.value.equalsIgnoreCase(value)) + .findFirst() + .orElse(DAILY); + } + + public abstract LocalDate resolveStartDate(String date); + + public abstract String resolveKey(String date); + + public boolean isDaily() { + return this == DAILY; + } + + private static LocalDate parse(String date) { + if (date == null || date.isBlank()) { + return LocalDate.now(ZONE_ID); + } + return LocalDate.parse(date, FORMATTER); + } + + private static String toYearMonthWeek(LocalDate target) { + WeekFields weekFields = WeekFields.of(Locale.KOREA); + int weekBasedYear = target.get(weekFields.weekBasedYear()); + int week = target.get(weekFields.weekOfWeekBasedYear()); + return String.format("%04d-W%02d", weekBasedYear, week); + } + + private static String toYearMonth(LocalDate target) { + return String.format("%04d-%02d", target.getYear(), target.getMonthValue()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 6aa724710..e0f58c77b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -23,7 +23,6 @@ public class BrandService { private final BrandRepository brandRepository; - @Transactional public void save(Brand brand) { brandRepository.save(brand); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index ec5d485cb..c203253f9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -55,6 +55,7 @@ public static Order create(String userId) { } public void addOrderItem(OrderItem orderItem) { + orderItem.setOrder(this); this.orderItems.add(orderItem); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java index 0dca0071d..76d4b9d92 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -42,7 +42,6 @@ public void charge(Long chargeAmount) { throw new CoreException(ErrorType.BAD_REQUEST, "0원 이하로 포인트를 충전 할수 없습니다."); } this.balance += chargeAmount; - new Point(this.userId, this.balance); } public void use(Long useAmount) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index e3e3f598f..b7aed5d73 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -36,11 +36,11 @@ public Point usePoint(String userId, Long useAmount) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트 정보를 찾을 수 없습니다.")); if (useAmount == null || useAmount <= 0) { - throw new CoreException(ErrorType.NOT_FOUND, "차감할 포인트는 1 이상이어야 합니다."); + throw new CoreException(ErrorType.BAD_REQUEST, "차감할 포인트는 1 이상이어야 합니다."); } if (point.getBalance() < useAmount) { - throw new CoreException(ErrorType.NOT_FOUND, "포인트가 부족합니다."); + throw new CoreException(ErrorType.BAD_REQUEST, "포인트가 부족합니다."); } point.use(useAmount); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 5c4b27ce3..3c11496a9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -85,7 +85,7 @@ private Long requireValidPrice(Long price) { return price; } - public Long requireValidLikeCount(Long likeCount) { + private Long requireValidLikeCount(Long likeCount) { if (likeCount == null || likeCount < 0) { throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 개수는 0개 미만으로 설정할 수 없습니다."); } @@ -93,6 +93,9 @@ public Long requireValidLikeCount(Long likeCount) { } private Long requireValidStock(Long stock) { + if (stock == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 재고는 필수입니다."); + } if (stock < 0) { throw new CoreException(ErrorType.BAD_REQUEST, "상품 재고는 0 미만으로 설정할 수 없습니다."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java index 67e4c66d3..d1537bb16 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java @@ -7,6 +7,7 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; /** * packageName : com.loopers.domain.product @@ -27,6 +28,7 @@ public class ProductDomainService { private final BrandRepository brandRepository; private final LikeRepository likeRepository; + @Transactional(readOnly = true) public ProductDetail getProductDetail(Long id) { Product product = productRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); 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 000000000..02601d282 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -0,0 +1,78 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "mv_product_rank_monthly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankMonthly { + + @EmbeddedId + private ProductRankId id; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_count", nullable = false) + private long salesCount; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "rank", nullable = false) + private int rank; + + @Column(name = "aggregated_at", nullable = false) + private LocalDateTime aggregatedAt; + + private MvProductRankMonthly( + ProductRankId id, + long likeCount, + long salesCount, + double score, + int rank, + LocalDateTime aggregatedAt + ) { + this.id = id; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.score = score; + this.rank = rank; + this.aggregatedAt = aggregatedAt; + } + + public static MvProductRankMonthly create( + String yearMonth, + Long productId, + long likeCount, + long salesCount, + double score, + int rank, + LocalDateTime aggregatedAt + ) { + return new MvProductRankMonthly( + ProductRankId.of(yearMonth, productId), + likeCount, + salesCount, + score, + rank, + aggregatedAt + ); + } + + public String getPeriodKey() { + return id != null ? id.getPeriodKey() : null; + } + + public Long getProductId() { + return id != null ? id.getProductId() : null; + } +} 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 000000000..2d591746d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -0,0 +1,78 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "mv_product_rank_weekly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankWeekly { + + @EmbeddedId + private ProductRankId id; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_count", nullable = false) + private long salesCount; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "rank", nullable = false) + private int rank; + + @Column(name = "aggregated_at", nullable = false) + private LocalDateTime aggregatedAt; + + private MvProductRankWeekly( + ProductRankId id, + long likeCount, + long salesCount, + double score, + int rank, + LocalDateTime aggregatedAt + ) { + this.id = id; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.score = score; + this.rank = rank; + this.aggregatedAt = aggregatedAt; + } + + public static MvProductRankWeekly create( + String yearMonthWeek, + Long productId, + long likeCount, + long salesCount, + double score, + int rank, + LocalDateTime aggregatedAt + ) { + return new MvProductRankWeekly( + ProductRankId.of(yearMonthWeek, productId), + likeCount, + salesCount, + score, + rank, + aggregatedAt + ); + } + + public String getPeriodKey() { + return id != null ? id.getPeriodKey() : null; + } + + public Long getProductId() { + return id != null ? id.getProductId() : null; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankId.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankId.java new file mode 100644 index 000000000..5e322ece3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankId.java @@ -0,0 +1,46 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import java.util.Objects; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor +public class ProductRankId implements Serializable { + + @Column(name = "period_key", nullable = false) + private String periodKey; + + @Column(name = "product_id", nullable = false) + private Long productId; + + private ProductRankId(String periodKey, Long productId) { + this.periodKey = periodKey; + this.productId = productId; + } + + public static ProductRankId of(String periodKey, Long productId) { + return new ProductRankId(periodKey, productId); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ProductRankId that)) { + return false; + } + return Objects.equals(periodKey, that.periodKey) + && Objects.equals(productId, that.productId); + } + + @Override + public int hashCode() { + return Objects.hash(periodKey, productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 57353968a..3cc033076 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -15,7 +15,7 @@ public class UserService { @Transactional public User register(String userId, String email, String birth, String gender) { userRepository.findByUserId(userId).ifPresent(user -> { - throw new CoreException(ErrorType.CONFLICT, "이미 가입된 사용자ID 입니다."); + throw new CoreException(ErrorType.CONFLICT, "이미 가입된 사용자 ID 입니다."); }); User user = new User(userId, email, birth, gender); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index 111990a22..759f3caf1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -3,8 +3,6 @@ import com.loopers.domain.brand.Brand; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; - /** * packageName : com.loopers.infrastructure.brand * fileName : BrandJpaRepository @@ -17,5 +15,4 @@ * 2025. 11. 12. byeonsungmun 최초 생성 */ public interface BrandJpaRepository extends JpaRepository { - Optional findById(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index aca3a8b5f..1316a1104 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -2,6 +2,8 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -38,13 +40,15 @@ public Optional findById(Long id) { @Override public void incrementLikeCount(Long productId) { - Product product = productJpaRepository.findById(productId).get(); + Product product = productJpaRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을수 없습니다.")); product.increaseLikeCount(); } @Override public void decrementLikeCount(Long productId) { - Product product = productJpaRepository.findById(productId).get(); + Product product = productJpaRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을수 없습니다.")); product.decreaseLikeCount(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 000000000..812ff032d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.ProductRankId; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + List findByIdPeriodKeyOrderByRankAsc(String periodKey); + + void deleteByIdPeriodKey(String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 000000000..1d7b4215e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.ProductRankId; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + List findByIdPeriodKeyOrderByRankAsc(String periodKey); + + void deleteByIdPeriodKey(String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 25f05bc6e..8fb6f7bdf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -20,8 +20,7 @@ public Optional findByUserId(String userId) { @Override public User save(User user) { - userJpaRepository.save(user); - return user; + return userJpaRepository.save(user); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java index faa21f303..6f0458399 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java @@ -22,7 +22,7 @@ ApiResponse getPoint( description = "회원의 포인트를 충전한다." ) ApiResponse chargePoint( - @Schema(name = "포인트 충전 요청", description = "조회할 회원 ID") + @Schema(name = "포인트 충전 요청", description = "충전할 포인트 정보를 포함한 요청") PointV1Dto.ChargePointRequest request ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java index 9dfd1e1e1..fbbcead68 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -17,6 +17,9 @@ ApiResponse getRankings( @Schema(description = "조회할 날짜(yyyyMMdd). 미입력 시 오늘 기준", example = "20251226") @RequestParam(required = false) String date, + @Schema(description = "조회 주기(daily, weekly, monthly)", example = "weekly") + @RequestParam(defaultValue = "daily") String period, + @Schema(description = "페이지 번호", example = "0") @RequestParam(defaultValue = "0") int page, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index b538620e3..89a4b9b56 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.ranking; import com.loopers.application.ranking.RankingFacade; +import com.loopers.application.ranking.RankingPeriod; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -16,12 +17,13 @@ public class RankingV1Controller implements RankingV1ApiSpec { @Override @GetMapping - public ApiResponse getRankings(String date, int page, int size) { + public ApiResponse getRankings(String date, String period, int page, int size) { int safePage = Math.max(page, 0); int safeSize = Math.max(size, 1); - long total = rankingFacade.count(date); + RankingPeriod rankingPeriod = RankingPeriod.from(period); + long total = rankingFacade.count(date, rankingPeriod); return ApiResponse.success(RankingV1Dto.RankingResponse.from( - rankingFacade.getRankingItems(date, safePage, safeSize), + rankingFacade.getRankingItems(date, rankingPeriod, safePage, safeSize), safePage, safeSize, total diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index a8f8948ca..7d74fdfe2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -42,12 +42,12 @@ void throwsException_whenInvalidEmailFormat() { void throwsException_whenInvalidBirthFormat() { // given String userId = "yh45g"; - String invalidEmail = "invalid-email-format"; // '@' 없음 - String birth = "1994-12-05"; + String email = "valid@loopers.com"; + String invalidBirth = "19941205"; // 형식 오류: 하이픈 없음 String gender = "MALE"; // when & then - assertThrows(CoreException.class, () -> new User(userId, invalidEmail, birth, gender)); + assertThrows(CoreException.class, () -> new User(userId, email, invalidBirth, gender)); } } } diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..b22b6477c --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,21 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java new file mode 100644 index 000000000..e5005c373 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,24 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +import java.util.TimeZone; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceBatchApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); + System.exit(exitCode); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..960a23d67 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java @@ -0,0 +1,29 @@ +package com.loopers.batch.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "product_metrics") +public class ProductMetrics { + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_count", nullable = false) + private long salesCount; + + @Column(name = "last_catalog_version") + private Long lastCatalogVersion; + + @Column(name = "last_order_version") + private Long lastOrderVersion; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java new file mode 100644 index 000000000..7c486483f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java @@ -0,0 +1,48 @@ +package com.loopers.batch.job.demo; + +import com.loopers.batch.job.demo.step.DemoTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class DemoJobConfig { + public static final String JOB_NAME = "demoJob"; + private static final String STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DemoTasklet demoTasklet; + + @Bean(JOB_NAME) + public Job demoJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(categorySyncStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_DEMO_SIMPLE_TASK_NAME) + public Step categorySyncStep() { + return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) + .tasklet(demoTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java new file mode 100644 index 000000000..800fe5a03 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java @@ -0,0 +1,32 @@ +package com.loopers.batch.job.demo.step; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class DemoTasklet implements Tasklet { + @Value("#{jobParameters['requestDate']}") + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + if (requestDate == null) { + throw new RuntimeException("requestDate is null"); + } + System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); + Thread.sleep(1000); + System.out.println("Demo Tasklet 작업 완료"); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankAggregate.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankAggregate.java new file mode 100644 index 000000000..5639e0938 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankAggregate.java @@ -0,0 +1,9 @@ +package com.loopers.batch.job.ranking; + +public record ProductRankAggregate( + Long productId, + long likeCount, + long salesCount, + double score +) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankAggregationJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankAggregationJobConfig.java new file mode 100644 index 000000000..ddb59244e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankAggregationJobConfig.java @@ -0,0 +1,113 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.domain.metrics.ProductMetrics; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +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.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = ProductRankAggregationJobConfig.JOB_NAME) +public class ProductRankAggregationJobConfig { + + public static final String JOB_NAME = "productRankAggregationJob"; + private static final String STEP_WEEKLY = "weeklyProductRankingStep"; + private static final String STEP_MONTHLY = "monthlyProductRankingStep"; + private static final int CHUNK_SIZE = 50; + private static final int MAX_ITEM_COUNT = 100; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final EntityManagerFactory entityManagerFactory; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ProductRankItemProcessor itemProcessor; + private final NamedParameterJdbcTemplate jdbcTemplate; + + @Bean(JOB_NAME) + public Job productRankAggregationJob( + Step weeklyRankingStep, + Step monthlyRankingStep + ) { + return new JobBuilder(JOB_NAME, jobRepository) + .start(weeklyRankingStep) + .next(monthlyRankingStep) + .listener(jobListener) + .build(); + } + + @Bean(STEP_WEEKLY) + public Step weeklyRankingStep( + JpaPagingItemReader productMetricsReader, + RankingMaterializedViewWriter weeklyRankingWriter + ) { + return new StepBuilder(STEP_WEEKLY, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(productMetricsReader) + .processor(itemProcessor) + .writer(weeklyRankingWriter) + .listener(stepMonitorListener) + .build(); + } + + @Bean(STEP_MONTHLY) + public Step monthlyRankingStep( + JpaPagingItemReader productMetricsReader, + RankingMaterializedViewWriter monthlyRankingWriter + ) { + return new StepBuilder(STEP_MONTHLY, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(productMetricsReader) + .processor(itemProcessor) + .writer(monthlyRankingWriter) + .listener(stepMonitorListener) + .build(); + } + + @Bean + @StepScope + public RankingMaterializedViewWriter weeklyRankingWriter( + @Value("#{jobParameters['requestDate']}") String requestDate + ) { + RankingPeriod period = RankingPeriodResolver.weekly(requestDate); + return new RankingMaterializedViewWriter(jdbcTemplate, period, "mv_product_rank_weekly"); + } + + @Bean + @StepScope + public RankingMaterializedViewWriter monthlyRankingWriter( + @Value("#{jobParameters['requestDate']}") String requestDate + ) { + RankingPeriod period = RankingPeriodResolver.monthly(requestDate); + return new RankingMaterializedViewWriter(jdbcTemplate, period, "mv_product_rank_monthly"); + } + + @Bean + @StepScope + public JpaPagingItemReader productMetricsReader() { + return new JpaPagingItemReaderBuilder() + .name("productMetricsReader") + .entityManagerFactory(entityManagerFactory) + .queryString( + "SELECT p FROM ProductMetrics p ORDER BY (p.likeCount * 0.2d + p.salesCount * 0.8d) DESC" + ) + .pageSize(CHUNK_SIZE) + .maxItemCount(MAX_ITEM_COUNT) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankItemProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankItemProcessor.java new file mode 100644 index 000000000..62da52a86 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankItemProcessor.java @@ -0,0 +1,27 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.domain.metrics.ProductMetrics; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProductRankItemProcessor implements ItemProcessor { + + private final ProductRankScorePolicy scorePolicy; + + @Override + public ProductRankAggregate process(ProductMetrics item) { + if (item == null || item.getProductId() == null) { + return null; + } + double score = scorePolicy.calculate(item.getLikeCount(), item.getSalesCount()); + return new ProductRankAggregate( + item.getProductId(), + item.getLikeCount(), + item.getSalesCount(), + score + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankScorePolicy.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankScorePolicy.java new file mode 100644 index 000000000..4ee5c747e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankScorePolicy.java @@ -0,0 +1,14 @@ +package com.loopers.batch.job.ranking; + +import org.springframework.stereotype.Component; + +@Component +public class ProductRankScorePolicy { + + private static final double LIKE_WEIGHT = 0.2d; + private static final double SALES_WEIGHT = 0.8d; + + public double calculate(long likeCount, long salesCount) { + return (likeCount * LIKE_WEIGHT) + (salesCount * SALES_WEIGHT); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMaterializedViewWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMaterializedViewWriter.java new file mode 100644 index 000000000..9c5d563b5 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMaterializedViewWriter.java @@ -0,0 +1,91 @@ +package com.loopers.batch.job.ranking; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStreamException; +import org.springframework.batch.item.ItemStreamWriter; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; + +public class RankingMaterializedViewWriter implements ItemStreamWriter { + + private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); + private static final String PERIOD_COLUMN = "period_key"; + + private final NamedParameterJdbcTemplate jdbcTemplate; + private final RankingPeriod period; + private final String tableName; + private final AtomicInteger rankCounter = new AtomicInteger(1); + + public RankingMaterializedViewWriter( + NamedParameterJdbcTemplate jdbcTemplate, + RankingPeriod period, + String tableName + ) { + this.jdbcTemplate = jdbcTemplate; + this.period = period; + this.tableName = tableName; + } + + @Override + public void open(ExecutionContext executionContext) throws ItemStreamException { + deleteExistingRows(); + rankCounter.set(1); + } + + @Override + public void update(ExecutionContext executionContext) throws ItemStreamException { + // no-op + } + + @Override + public void close() throws ItemStreamException { + // no-op + } + + @Override + public void write(Chunk chunk) { + if (chunk == null || chunk.isEmpty()) { + return; + } + LocalDateTime aggregatedAt = LocalDateTime.now(ZONE_ID); + List params = new ArrayList<>(); + for (ProductRankAggregate item : chunk.getItems()) { + if (item == null || item.productId() == null) { + continue; + } + MapSqlParameterSource paramSource = new MapSqlParameterSource() + .addValue("periodKey", period.key()) + .addValue("productId", item.productId()) + .addValue("likeCount", item.likeCount()) + .addValue("salesCount", item.salesCount()) + .addValue("score", item.score()) + .addValue("rank", rankCounter.getAndIncrement()) + .addValue("aggregatedAt", aggregatedAt); + params.add(paramSource); + } + if (!params.isEmpty()) { + jdbcTemplate.batchUpdate(insertSql(), params.toArray(SqlParameterSource[]::new)); + } + } + + private void deleteExistingRows() { + jdbcTemplate.update( + "DELETE FROM " + tableName + " WHERE " + PERIOD_COLUMN + " = :periodKey", + new MapSqlParameterSource("periodKey", period.key()) + ); + } + + private String insertSql() { + return """ + INSERT INTO %s (%s, product_id, like_count, sales_count, score, rank, aggregated_at) + VALUES (:periodKey, :productId, :likeCount, :salesCount, :score, :rank, :aggregatedAt) + """.formatted(tableName, PERIOD_COLUMN); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingPeriod.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingPeriod.java new file mode 100644 index 000000000..bc8faaf18 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingPeriod.java @@ -0,0 +1,6 @@ +package com.loopers.batch.job.ranking; + +public record RankingPeriod( + String key +) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingPeriodResolver.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingPeriodResolver.java new file mode 100644 index 000000000..80fd583f0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingPeriodResolver.java @@ -0,0 +1,49 @@ +package com.loopers.batch.job.ranking; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; +import java.time.temporal.WeekFields; +import java.util.Locale; +import org.springframework.util.StringUtils; + +public final class RankingPeriodResolver { + + private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + + private RankingPeriodResolver() { + } + + public static RankingPeriod weekly(String targetDate) { + LocalDate target = parse(targetDate); + LocalDate weekStart = target.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + return new RankingPeriod(toYearMonthWeek(weekStart)); + } + + public static RankingPeriod monthly(String targetDate) { + LocalDate target = parse(targetDate); + LocalDate monthStart = target.withDayOfMonth(1); + return new RankingPeriod(toYearMonth(monthStart)); + } + + private static LocalDate parse(String targetDate) { + if (!StringUtils.hasText(targetDate)) { + return LocalDate.now(ZONE_ID); + } + return LocalDate.parse(targetDate, FORMATTER); + } + + private static String toYearMonthWeek(LocalDate target) { + WeekFields weekFields = WeekFields.of(Locale.KOREA); + int weekBasedYear = target.get(weekFields.weekBasedYear()); + int week = target.get(weekFields.weekOfWeekBasedYear()); + return String.format("%04d-W%02d", weekBasedYear, week); + } + + private static String toYearMonth(LocalDate target) { + return String.format("%04d-%02d", target.getYear(), target.getMonthValue()); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java new file mode 100644 index 000000000..10b09b8fc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java @@ -0,0 +1,21 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.annotation.AfterChunk; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ChunkListener { + + @AfterChunk + void afterChunk(ChunkContext chunkContext) { + log.info( + "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + + "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java new file mode 100644 index 000000000..cb5c8bebd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -0,0 +1,53 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.annotation.AfterJob; +import org.springframework.batch.core.annotation.BeforeJob; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JobListener { + + @BeforeJob + void beforeJob(JobExecution jobExecution) { + log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); + jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); + } + + @AfterJob + void afterJob(JobExecution jobExecution) { + var startTime = jobExecution.getExecutionContext().getLong("startTime"); + var endTime = System.currentTimeMillis(); + + var startDateTime = Instant.ofEpochMilli(startTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + var endDateTime = Instant.ofEpochMilli(endTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + var totalTime = endTime - startTime; + var duration = Duration.ofMillis(totalTime); + var hours = duration.toHours(); + var minutes = duration.toMinutes() % 60; + var seconds = duration.getSeconds() % 60; + + var message = String.format( + """ + *Start Time:* %s + *End Time:* %s + *Total Time:* %d시간 %d분 %d초 + """, startDateTime, endDateTime, hours, minutes, seconds + ).trim(); + + log.info(message); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java new file mode 100644 index 000000000..4f22f40b0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -0,0 +1,44 @@ +package com.loopers.batch.listener; + +import jakarta.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Component +public class StepMonitorListener implements StepExecutionListener { + + @Override + public void beforeStep(@Nonnull StepExecution stepExecution) { + log.info("Step '{}' 시작", stepExecution.getStepName()); + } + + @Override + public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); + var exceptions = stepExecution.getFailureExceptions().stream() + .map(Throwable::getMessage) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + log.info( + """ + [에러 발생] + jobName: {} + exceptions: + {} + """.trim(), jobName, exceptions + ); + // error 발생 시 slack 등 다른 채널로 모니터 전송 + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..9aa0d760a --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,54 @@ +spring: + main: + web-application-type: none + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + batch: + job: + name: ${job.name:NONE} + jdbc: + initialize-schema: never + +management: + health: + defaults: + enabled: false + +--- +spring: + config: + activate: + on-profile: local, test + batch: + jdbc: + initialize-schema: always + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java new file mode 100644 index 000000000..c5e3bc7a3 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -0,0 +1,10 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class CommerceBatchApplicationTest { + @Test + void contextLoads() {} +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java new file mode 100644 index 000000000..dafe59a18 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java @@ -0,0 +1,76 @@ +package com.loopers.job.demo; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) +class DemoJobE2ETest { + + // IDE 정적 분석 상 [SpringBatchTest] 의 주입보다 [SpringBootTest] 의 주입이 우선되어, 해당 컴포넌트는 없으므로 오류처럼 보일 수 있음. + // [SpringBatchTest] 자체가 Scope 기반으로 주입하기 때문에 정상 동작함. + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(DemoJobConfig.JOB_NAME) + private Job job; + + @BeforeEach + void beforeEach() { + + } + + @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") + @Test + void shouldNotSaveCategories_whenApiError() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) + ); + } + + @DisplayName("demoJob 배치가 정상적으로 실행된다.") + @Test + void success() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.now()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()) + ); + } +} diff --git a/docs/10round/10round.md b/docs/10round/10round.md new file mode 100644 index 000000000..fc13f8e77 --- /dev/null +++ b/docs/10round/10round.md @@ -0,0 +1,53 @@ +# 📝 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 + +- [x] Spring Batch Job 을 작성하고, 파라미터 기반으로 동작시킬 수 있다. +- [x] Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) 기반의 배치 처리를 구현했다. +- [x] 집계 결과를 저장할 Materialized View 의 구조를 설계하고 올바르게 적재했다. + +### 🧩 Ranking API + +- [x] API 가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다. diff --git a/docs/2round/03-class-diagram.md b/docs/2round/03-class-diagram.md index 45421dc8b..8d39cfd0a 100644 --- a/docs/2round/03-class-diagram.md +++ b/docs/2round/03-class-diagram.md @@ -32,11 +32,6 @@ class Product { Long stock } -class Stock { - Long productId - int quantity -} - class Like { Long id String userId @@ -73,7 +68,6 @@ class Payment { %% 관계 설정 User --> Point Brand --> Product -Product --> Stock Product --> Like User --> Like User --> Order diff --git a/docs/3round/3round.md b/docs/3round/3round.md index 61df49f68..b9f333cca 100644 --- a/docs/3round/3round.md +++ b/docs/3round/3round.md @@ -24,7 +24,7 @@ ## ✅ Checklist - [x] 상품 정보 객체는 브랜드 정보, 좋아요 수를 포함한다. -- [ ] 상품의 정렬 조건(`latest`, `price_asc`, `likes_desc`) 을 고려한 조회 기능을 설계했다 +- [x] 상품의 정렬 조건(`latest`, `price_asc`, `likes_desc`) 을 고려한 조회 기능을 설계했다 - [x] 상품은 재고를 가지고 있고, 주문 시 차감할 수 있어야 한다 - [x] 재고는 감소만 가능하며 음수 방지는 도메인 레벨에서 처리된다 @@ -33,28 +33,28 @@ - [x] 좋아요는 유저와 상품 간의 관계로 별도 도메인으로 분리했다 - [x] 중복 좋아요 방지를 위한 멱등성 처리가 구현되었다 - [x] 상품의 좋아요 수는 상품 상세/목록 조회에서 함께 제공된다 -- [ ] 단위 테스트에서 좋아요 등록/취소/중복 방지 흐름을 검증했다 +- [x] 단위 테스트에서 좋아요 등록/취소/중복 방지 흐름을 검증했다 ### 🛒 Order 도메인 -- [ ] 주문은 여러 상품을 포함할 수 있으며, 각 상품의 수량을 명시한다 -- [ ] 주문 시 상품의 재고 차감, 유저 포인트 차감 등을 수행한다 -- [ ] 재고 부족, 포인트 부족 등 예외 흐름을 고려해 설계되었다 -- [ ] 단위 테스트에서 정상 주문 / 예외 주문 흐름을 모두 검증했다 +- [x] 주문은 여러 상품을 포함할 수 있으며, 각 상품의 수량을 명시한다 +- [x] 주문 시 상품의 재고 차감, 유저 포인트 차감 등을 수행한다 +- [x] 재고 부족, 포인트 부족 등 예외 흐름을 고려해 설계되었다 +- [x] 단위 테스트에서 정상 주문 / 예외 주문 흐름을 모두 검증했다 ### 🧩 도메인 서비스 -- [ ] 도메인 간 협력 로직은 Domain Service에 위치시켰다 -- [ ] 상품 상세 조회 시 Product + Brand 정보 조합은 도메인 서비스에서 처리했다 -- [ ] 복합 유스케이스는 Application Layer에 존재하고, 도메인 로직은 위임되었다 -- [ ] 도메인 서비스는 상태 없이, 도메인 객체의 협력 중심으로 설계되었다 +- [x] 도메인 간 협력 로직은 Domain Service에 위치시켰다 +- [x] 상품 상세 조회 시 Product + Brand 정보 조합은 도메인 서비스에서 처리했다 +- [x] 복합 유스케이스는 Application Layer에 존재하고, 도메인 로직은 위임되었다 +- [x] 도메인 서비스는 상태 없이, 도메인 객체의 협력 중심으로 설계되었다 ### **🧱 소프트웨어 아키텍처 & 설계** -- [ ] 전체 프로젝트의 구성은 아래 아키텍처를 기반으로 구성되었다 +- [x] 전체 프로젝트의 구성은 아래 아키텍처를 기반으로 구성되었다 - Application → **Domain** ← Infrastructure -- [ ] Application Layer는 도메인 객체를 조합해 흐름을 orchestration 했다 -- [ ] 핵심 비즈니스 로직은 Entity, VO, Domain Service 에 위치한다 -- [ ] Repository Interface는 Domain Layer 에 정의되고, 구현체는 Infra에 위치한다 -- [ ] 패키지는 계층 + 도메인 기준으로 구성되었다 (`/domain/order`, `/application/like` 등) -- [ ] 테스트는 외부 의존성을 분리하고, Fake/Stub 등을 사용해 단위 테스트가 가능하게 구성되었다 \ No newline at end of file +- [x] Application Layer는 도메인 객체를 조합해 흐름을 orchestration 했다 +- [x] 핵심 비즈니스 로직은 Entity, VO, Domain Service 에 위치한다 +- [x] Repository Interface는 Domain Layer 에 정의되고, 구현체는 Infra에 위치한다 +- [x] 패키지는 계층 + 도메인 기준으로 구성되었다 (`/domain/order`, `/application/like` 등) +- [x] 테스트는 외부 의존성을 분리하고, Fake/Stub 등을 사용해 단위 테스트가 가능하게 구성되었다 \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 906b49231..ed485aaa3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,6 +2,7 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", + ":apps:commerce-batch", ":apps:commerce-streamer", ":apps:pg-simulator", ":modules:jpa",