diff --git a/.gitignore b/.gitignore index 89bcb47c63..a4b7de7a60 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,7 @@ exp* ### technical observation tests ### **/src/test/java/**/technical/ **/src/test/java/**/benchmark -**/src/test/java/**/verification \ No newline at end of file +**/src/test/java/**/verification + +### local performance tests (k6 등 체크리스트 외 성능 테스트) ### +k6/local/ \ No newline at end of file diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 137e122199..4bb7375290 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -7,6 +7,7 @@ dependencies { implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + implementation(project(":supports:ranking")) // web implementation("org.springframework.boot:spring-boot-starter-web") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankPublicationRow.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankPublicationRow.java new file mode 100644 index 0000000000..1c0716b304 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankPublicationRow.java @@ -0,0 +1,12 @@ +package com.loopers.application.ranking; + +import java.sql.Timestamp; + +record MvRankPublicationRow( + String periodType, + String periodKey, + long publishedVersion, + long nextVersion, + Timestamp updatedAt +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankStatusApp.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankStatusApp.java new file mode 100644 index 0000000000..e529fec0a5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankStatusApp.java @@ -0,0 +1,68 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.RankPeriodType; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class MvRankStatusApp { + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + + private static final String SELECT_PUBLICATION_BASE = + "SELECT period_type, period_key, published_version, next_version, updated_at " + + "FROM mv_product_rank_publication WHERE period_type = ?"; + + private static final RowMapper PUBLICATION_MAPPER = (rs, n) -> new MvRankPublicationRow( + rs.getString("period_type"), + rs.getString("period_key"), + rs.getLong("published_version"), + rs.getLong("next_version"), + rs.getTimestamp("updated_at") + ); + + private final JdbcTemplate jdbcTemplate; + + public List listStatuses(RankPeriodType type, int offset, int size) { + return jdbcTemplate.query( + SELECT_PUBLICATION_BASE + " ORDER BY period_key DESC LIMIT ? OFFSET ?", + PUBLICATION_MAPPER, type.name(), size, offset + ).stream() + .map(r -> buildStatus(type, r)) + .toList(); + } + + public MvRankStatusInfo getStatus(RankPeriodType type, String periodKey) { + return jdbcTemplate.query( + SELECT_PUBLICATION_BASE + " AND period_key = ?", + PUBLICATION_MAPPER, type.name(), periodKey + ).stream().findFirst() + .map(r -> buildStatus(type, r)) + .orElseGet(() -> new MvRankStatusInfo(type.name(), periodKey, 0L, 0L, null, 0L, 0L, 0L, List.of())); + } + + private MvRankStatusInfo buildStatus(RankPeriodType type, MvRankPublicationRow row) { + List breakdown = jdbcTemplate.query( + "SELECT version, COUNT(*) AS cnt FROM " + type.getTableName() + + " WHERE period_key = ? GROUP BY version ORDER BY version DESC", + (rs, n) -> new MvRankVersionCount(rs.getLong("version"), rs.getLong("cnt")), + row.periodKey() + ); + long total = breakdown.stream().mapToLong(MvRankVersionCount::rowCount).sum(); + long publishedRows = breakdown.stream() + .filter(b -> b.version() == row.publishedVersion()) + .mapToLong(MvRankVersionCount::rowCount) + .sum(); + ZonedDateTime ts = row.updatedAt() == null ? null : row.updatedAt().toInstant().atZone(SEOUL); + return new MvRankStatusInfo(row.periodType(), row.periodKey(), + row.publishedVersion(), row.nextVersion(), ts, + total, publishedRows, total - publishedRows, breakdown); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankStatusInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankStatusInfo.java new file mode 100644 index 0000000000..db61124034 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankStatusInfo.java @@ -0,0 +1,17 @@ +package com.loopers.application.ranking; + +import java.time.ZonedDateTime; +import java.util.List; + +public record MvRankStatusInfo( + String periodType, + String periodKey, + long publishedVersion, + long nextVersion, + ZonedDateTime updatedAt, + long totalRowCount, + long publishedRowCount, + long orphanRowCount, + List versionBreakdown +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankVersionCount.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankVersionCount.java new file mode 100644 index 0000000000..2af5e4a2e0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankVersionCount.java @@ -0,0 +1,4 @@ +package com.loopers.application.ranking; + +public record MvRankVersionCount(long version, long rowCount) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingApp.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingApp.java index a5cb2f176d..60284c4c9c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingApp.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingApp.java @@ -1,33 +1,53 @@ package com.loopers.application.ranking; +import com.loopers.domain.ranking.MvProductRankRepository; +import com.loopers.domain.ranking.RankPeriodType; import com.loopers.domain.ranking.RankingEntry; +import com.loopers.domain.ranking.RankingPeriod; import com.loopers.domain.ranking.RankingRepository; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.function.BiFunction; @Component @RequiredArgsConstructor public class RankingApp { private final RankingRepository rankingRepository; + private final MvProductRankRepository mvProductRankRepository; private final RankingProductCache productCache; - public RankingPageResult getTopN(LocalDate date, long page, long size) { + @Value("${ranking.cold-start-fallback.enabled:false}") + private boolean coldStartFallbackEnabled; + + public RankingPageResult getTopN(RankingPeriod period, LocalDate date, long page, long size) { long offset = page * size; - List entries = rankingRepository.findTopN(date, offset, size); - long totalElements = rankingRepository.countMembers(date); - List items = enrich(entries, offset); - return new RankingPageResult(items, page, size, totalElements); + if (!period.isMvBased()) { + return getDailyTopN(date, page, size, offset); + } + RankPeriodType type = period.toMvType(); + RankingPageResult primary = queryMvPeriod(type, period.periodKey(date, false), page, size, offset, false); + if (primary.totalElements() > 0 || !coldStartFallbackEnabled) { + return primary; + } + return queryMvPeriod(type, period.periodKey(date, true), page, size, offset, true); + } + + public RankingPageResult getTopN(LocalDate date, long page, long size) { + return getTopN(RankingPeriod.DAILY, date, page, size); } public RankingCursorResult getByCursor(LocalDate date, Double cursorScore, long size) { List entries = rankingRepository.findByCursor(date, cursorScore, size); - List items = enrichByCursor(date, entries); + List items = enrichWith(entries, + (i, e) -> rankingRepository.findRank(date, e.productDbId()).orElse(0L)); Double nextCursor = items.isEmpty() ? null : entries.get(entries.size() - 1).score(); return new RankingCursorResult(items, nextCursor); } @@ -41,81 +61,63 @@ public Optional getProductRanking(Long productDbId, LocalDat return Optional.of(new ProductRankingInfo(rank.get(), score)); } - private List enrich(List entries, long baseOffset) { - if (entries.isEmpty()) { - return List.of(); - } - List items = new ArrayList<>(entries.size()); - long rank = baseOffset; - for (RankingEntry entry : entries) { - CachedProductSnapshot snapshot = productCache.findById(entry.productDbId()); - if (snapshot == null || snapshot.deleted()) { - continue; - } - rank++; - items.add(toInfo(entry, rank, snapshot)); - } - return items; - } - - private List enrichByCursor(LocalDate date, List entries) { - if (entries.isEmpty()) { - return List.of(); - } - List items = new ArrayList<>(entries.size()); - for (RankingEntry entry : entries) { - CachedProductSnapshot snapshot = productCache.findById(entry.productDbId()); - if (snapshot == null || snapshot.deleted()) { - continue; - } - Long globalRank = rankingRepository.findRank(date, entry.productDbId()).orElse(null); - long rank = globalRank != null ? globalRank : 0L; - items.add(toInfo(entry, rank, snapshot)); - } - return items; - } - public RankingPageResult getHourlyTopN(LocalDate date, int hour, long page, long size) { long offset = page * size; List entries = rankingRepository.findHourlyTopN(date, hour, offset, size); long totalElements = rankingRepository.countHourlyMembers(date, hour); - List items = enrich(entries, offset); - return new RankingPageResult(items, page, size, totalElements); + List items = enrichByOffset(entries, offset); + return new RankingPageResult(items, page, size, totalElements, null); } public RankingCursorResult getHourlyByCursor(LocalDate date, int hour, Double cursorScore, long size) { List entries = rankingRepository.findHourlyCursor(date, hour, cursorScore, size); - List items = enrichHourlyCursor(date, hour, entries); + List items = enrichWith(entries, + (i, e) -> rankingRepository.findHourlyRank(date, hour, e.productDbId()).orElse(0L)); Double nextCursor = items.isEmpty() ? null : entries.get(entries.size() - 1).score(); return new RankingCursorResult(items, nextCursor); } - private List enrichHourlyCursor(LocalDate date, int hour, List entries) { + private RankingPageResult queryMvPeriod(RankPeriodType type, String periodKey, long page, long size, long offset, boolean isFallback) { + List entries = mvProductRankRepository.findByPeriodKey(type, periodKey, offset, size); + long totalElements = mvProductRankRepository.countByPeriodKey(type, periodKey); + java.time.ZonedDateTime lastUpdatedAt = mvProductRankRepository.findLastUpdatedAt(type, periodKey).orElse(null); + Long publishedVersion = mvProductRankRepository.findPublishedVersion(type, periodKey).orElse(null); + List items = enrichByOffset(entries, offset); + return new RankingPageResult(items, page, size, totalElements, lastUpdatedAt, periodKey, isFallback, publishedVersion); + } + + private RankingPageResult getDailyTopN(LocalDate date, long page, long size, long offset) { + List entries = rankingRepository.findTopN(date, offset, size); + long totalElements = rankingRepository.countMembers(date); + List items = enrichByOffset(entries, offset); + return new RankingPageResult(items, page, size, totalElements, null); + } + + private List enrichByOffset(List entries, long baseOffset) { + return enrichWith(entries, (i, e) -> baseOffset + i + 1); + } + + private List enrichWith(List entries, BiFunction rankResolver) { if (entries.isEmpty()) { return List.of(); } + List ids = entries.stream().map(RankingEntry::productDbId).toList(); + Map snapshots = productCache.findAllByIds(ids); List items = new ArrayList<>(entries.size()); - for (RankingEntry entry : entries) { - CachedProductSnapshot snapshot = productCache.findById(entry.productDbId()); - if (snapshot == null || snapshot.deleted()) { - continue; - } - Long globalRank = rankingRepository.findHourlyRank(date, hour, entry.productDbId()).orElse(null); - long rank = globalRank != null ? globalRank : 0L; - items.add(toInfo(entry, rank, snapshot)); + for (int i = 0; i < entries.size(); i++) { + RankingEntry entry = entries.get(i); + items.add(toInfo(entry, rankResolver.apply(i, entry), snapshots.get(entry.productDbId()))); } return items; } private RankingInfo toInfo(RankingEntry entry, long rank, CachedProductSnapshot snapshot) { - return new RankingInfo( - rank, - entry.score(), - snapshot.id(), - snapshot.productId(), - snapshot.productName(), - snapshot.price(), - RankingInfo.STATUS_ACTIVE - ); + if (snapshot == null || snapshot.deleted()) { + return new RankingInfo(rank, entry.score(), entry.productDbId(), + null, null, null, RankingInfo.STATUS_DISCONTINUED); + } + return new RankingInfo(rank, entry.score(), snapshot.id(), + snapshot.productId(), snapshot.productName(), snapshot.price(), + RankingInfo.STATUS_ACTIVE); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java index 13c2193fc6..fa903667d3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java @@ -1,11 +1,24 @@ package com.loopers.application.ranking; +import java.time.ZonedDateTime; import java.util.List; public record RankingPageResult( List items, long page, long size, - long totalElements + long totalElements, + ZonedDateTime lastUpdatedAt, + String periodKey, + boolean isFallback, + Long publishedVersion ) { + public RankingPageResult(List items, long page, long size, long totalElements, ZonedDateTime lastUpdatedAt) { + this(items, page, size, totalElements, lastUpdatedAt, null, false, null); + } + + public RankingPageResult(List items, long page, long size, long totalElements, + ZonedDateTime lastUpdatedAt, String periodKey, boolean isFallback) { + this(items, page, size, totalElements, lastUpdatedAt, periodKey, isFallback, null); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductCache.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductCache.java index 6b442f135d..5c46732dd4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductCache.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductCache.java @@ -1,5 +1,6 @@ package com.loopers.application.ranking; +import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.connection.RedisConnectionFactory; @@ -10,6 +11,10 @@ import org.springframework.stereotype.Component; import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -35,22 +40,82 @@ public RankingProductCache(ProductRepository productRepository, this.redisTemplate = buildRedisTemplate(redisConnectionFactory); } - public CachedProductSnapshot findById(Long productDbId) { - String key = KEY_PREFIX + productDbId; - byte[] bytes = redisTemplate.opsForValue().get(key); + public Map findAllByIds(List productDbIds) { + if (productDbIds == null || productDbIds.isEmpty()) { + return Map.of(); + } + List keys = new ArrayList<>(productDbIds.size()); + for (Long id : productDbIds) { + keys.add(KEY_PREFIX + id); + } - if (bytes != null) { - CachedProductSnapshot snapshot = (CachedProductSnapshot) serializer.deserialize(bytes); - if (snapshot != null) { - if (isWithinSoftTtl(snapshot.cachedAtEpochSecond())) { - return snapshot; - } - refreshAsync(productDbId, key); - return snapshot; + List raw; + try { + raw = redisTemplate.opsForValue().multiGet(keys); + } catch (RuntimeException e) { + log.warn("랭킹 상품 캐시 multiGet 실패, DB 폴백 수행 size={}", productDbIds.size(), e); + return fetchAndCacheBatch(productDbIds); + } + + Map result = new HashMap<>(productDbIds.size()); + List missingIds = new ArrayList<>(); + List staleIds = new ArrayList<>(); + for (int i = 0; i < productDbIds.size(); i++) { + Long id = productDbIds.get(i); + byte[] bytes = (raw == null || i >= raw.size()) ? null : raw.get(i); + if (bytes == null) { + missingIds.add(id); + continue; + } + CachedProductSnapshot snapshot; + try { + snapshot = (CachedProductSnapshot) serializer.deserialize(bytes); + } catch (RuntimeException e) { + log.warn("랭킹 상품 캐시 역직렬화 실패 productDbId={}", id, e); + missingIds.add(id); + continue; + } + if (snapshot == null) { + missingIds.add(id); + continue; + } + result.put(id, snapshot); + if (!isWithinSoftTtl(snapshot.cachedAtEpochSecond())) { + staleIds.add(id); } } - return fetchAndCache(productDbId, key); + if (!missingIds.isEmpty()) { + result.putAll(fetchAndCacheBatch(missingIds)); + } + for (Long staleId : staleIds) { + refreshAsync(staleId, KEY_PREFIX + staleId); + } + log.debug("랭킹 상품 캐시 조회 요청={} 히트={} 미스={} Stale={}", + productDbIds.size(), productDbIds.size() - missingIds.size(), missingIds.size(), staleIds.size()); + return result; + } + + private Map fetchAndCacheBatch(List ids) { + List products; + try { + products = productRepository.findAllByIdIncludingDeleted(ids); + } catch (RuntimeException e) { + log.error("랭킹 상품 DB 배치 조회 실패 ids={}", ids, e); + return Map.of(); + } + Map map = new HashMap<>(products.size()); + for (ProductModel product : products) { + CachedProductSnapshot snapshot = CachedProductSnapshot.from(product); + try { + byte[] bytes = serializer.serialize(snapshot); + redisTemplate.opsForValue().set(KEY_PREFIX + snapshot.id(), bytes, HARD_TTL_SECONDS, TimeUnit.SECONDS); + } catch (RuntimeException e) { + log.warn("랭킹 상품 캐시 적재 실패 productDbId={}", snapshot.id(), e); + } + map.put(snapshot.id(), snapshot); + } + return map; } private boolean isWithinSoftTtl(long cachedAtEpochSecond) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankId.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankId.java new file mode 100644 index 0000000000..ed4bee984f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankId.java @@ -0,0 +1,23 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class MvProductRankId implements Serializable { + + @Column(name = "period_key", nullable = false, length = 8) + private String periodKey; + + @Column(name = "rank_no", nullable = false) + private Integer rankNo; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java new file mode 100644 index 0000000000..be192e7485 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java @@ -0,0 +1,43 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +@Entity +@Table(name = "mv_product_rank_monthly") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankMonthlyModel { + + @EmbeddedId + private MvProductRankId id; + + @Column(name = "ref_product_id", nullable = false) + private Long refProductId; + + @Column(nullable = false) + private double score; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal orderAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankPublicationId.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankPublicationId.java new file mode 100644 index 0000000000..4532417687 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankPublicationId.java @@ -0,0 +1,23 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class MvProductRankPublicationId implements Serializable { + + @Column(name = "period_type", nullable = false, length = 20) + private String periodType; + + @Column(name = "period_key", nullable = false, length = 8) + private String periodKey; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankPublicationModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankPublicationModel.java new file mode 100644 index 0000000000..5482911f06 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankPublicationModel.java @@ -0,0 +1,30 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "mv_product_rank_publication") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankPublicationModel { + + @EmbeddedId + private MvProductRankPublicationId id; + + @Column(name = "published_version", nullable = false) + private Long publishedVersion; + + @Column(name = "next_version", nullable = false) + private Long nextVersion; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java new file mode 100644 index 0000000000..b991890f27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.ranking; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface MvProductRankRepository { + + List findByPeriodKey(RankPeriodType type, String periodKey, long offset, long size); + + long countByPeriodKey(RankPeriodType type, String periodKey); + + Optional findLastUpdatedAt(RankPeriodType type, String periodKey); + + Optional findPublishedVersion(RankPeriodType type, String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java new file mode 100644 index 0000000000..b4d583ad69 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java @@ -0,0 +1,43 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +@Entity +@Table(name = "mv_product_rank_weekly") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankWeeklyModel { + + @EmbeddedId + private MvProductRankId id; + + @Column(name = "ref_product_id", nullable = false) + private Long refProductId; + + @Column(nullable = false) + private double score; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal orderAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankPeriodType.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankPeriodType.java new file mode 100644 index 0000000000..6f5c49f1da --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankPeriodType.java @@ -0,0 +1,17 @@ +package com.loopers.domain.ranking; + +public enum RankPeriodType { + WEEKLY("mv_product_rank_weekly"), + MONTHLY("mv_product_rank_monthly"), + QUARTERLY("mv_product_rank_quarterly"); + + private final String tableName; + + RankPeriodType(String tableName) { + this.tableName = tableName; + } + + public String getTableName() { + return tableName; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java new file mode 100644 index 0000000000..b99bed2b26 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java @@ -0,0 +1,48 @@ +package com.loopers.domain.ranking; + +import com.loopers.ranking.RankingKeyGenerator; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDate; + +public enum RankingPeriod { + DAILY, + WEEKLY, + MONTHLY, + QUARTERLY; + + public static RankingPeriod fromString(String value) { + if (value == null || value.isBlank()) { + return DAILY; + } + try { + return valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, + "지원하지 않는 ranking period: " + value + " (allowed: daily, weekly, monthly, quarterly)"); + } + } + + public boolean isMvBased() { + return this != DAILY; + } + + public RankPeriodType toMvType() { + return switch (this) { + case DAILY -> throw new IllegalStateException("DAILY has no MV period type"); + case WEEKLY -> RankPeriodType.WEEKLY; + case MONTHLY -> RankPeriodType.MONTHLY; + case QUARTERLY -> RankPeriodType.QUARTERLY; + }; + } + + public String periodKey(LocalDate date, boolean previous) { + return switch (this) { + case DAILY -> throw new IllegalStateException("DAILY has no MV period key"); + case WEEKLY -> previous ? RankingKeyGenerator.previousWeeklyPeriodKey(date) : RankingKeyGenerator.weeklyPeriodKey(date); + case MONTHLY -> previous ? RankingKeyGenerator.previousMonthlyPeriodKey(date) : RankingKeyGenerator.monthlyPeriodKey(date); + case QUARTERLY -> previous ? RankingKeyGenerator.previousQuarterlyPeriodKey(date) : RankingKeyGenerator.quarterlyPeriodKey(date); + }; + } +} 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 0000000000..74e27520a1 --- /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.MvProductRankId; +import com.loopers.domain.ranking.MvProductRankMonthlyModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + @Query("SELECT COUNT(m) FROM MvProductRankMonthlyModel m WHERE m.id.periodKey = :periodKey") + long countByPeriodKey(@Param("periodKey") String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankPublicationJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankPublicationJpaRepository.java new file mode 100644 index 0000000000..8b0319a0f3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankPublicationJpaRepository.java @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankPublicationId; +import com.loopers.domain.ranking.MvProductRankPublicationModel; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MvProductRankPublicationJpaRepository + extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankRepositoryImpl.java new file mode 100644 index 0000000000..28d973d4ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankRepositoryImpl.java @@ -0,0 +1,68 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankRepository; +import com.loopers.domain.ranking.RankPeriodType; +import com.loopers.domain.ranking.RankingEntry; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class MvProductRankRepositoryImpl implements MvProductRankRepository { + + private final JdbcTemplate jdbcTemplate; + + @Override + public List findByPeriodKey(RankPeriodType type, String periodKey, long offset, long size) { + String sql = "SELECT mv.ref_product_id, mv.score FROM " + type.getTableName() + " mv " + + "INNER JOIN mv_product_rank_publication p " + + "ON p.period_type = ? AND p.period_key = mv.period_key AND p.published_version = mv.version " + + "WHERE mv.period_key = ? ORDER BY mv.rank_no ASC LIMIT ? OFFSET ?"; + + return jdbcTemplate.query(sql, + (rs, rowNum) -> new RankingEntry( + rs.getLong("ref_product_id"), + rs.getDouble("score") + ), + type.name(), periodKey, size, offset); + } + + @Override + public long countByPeriodKey(RankPeriodType type, String periodKey) { + String sql = "SELECT COUNT(*) FROM " + type.getTableName() + " mv " + + "INNER JOIN mv_product_rank_publication p " + + "ON p.period_type = ? AND p.period_key = mv.period_key AND p.published_version = mv.version " + + "WHERE mv.period_key = ?"; + Long count = jdbcTemplate.queryForObject(sql, Long.class, type.name(), periodKey); + return count != null ? count : 0L; + } + + @Override + public Optional findPublishedVersion(RankPeriodType type, String periodKey) { + List result = jdbcTemplate.queryForList( + "SELECT published_version FROM mv_product_rank_publication " + + "WHERE period_type = ? AND period_key = ?", + Long.class, type.name(), periodKey + ); + return result.isEmpty() ? Optional.empty() : Optional.ofNullable(result.get(0)); + } + + @Override + public Optional findLastUpdatedAt(RankPeriodType type, String periodKey) { + String sql = "SELECT MAX(mv.updated_at) FROM " + type.getTableName() + " mv " + + "INNER JOIN mv_product_rank_publication p " + + "ON p.period_type = ? AND p.period_key = mv.period_key AND p.published_version = mv.version " + + "WHERE mv.period_key = ?"; + Timestamp ts = jdbcTemplate.queryForObject(sql, Timestamp.class, type.name(), periodKey); + if (ts == null) { + return Optional.empty(); + } + return Optional.of(ts.toInstant().atZone(java.time.ZoneId.of("Asia/Seoul"))); + } +} 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 0000000000..8b4c75e6ee --- /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.MvProductRankId; +import com.loopers.domain.ranking.MvProductRankWeeklyModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + @Query("SELECT COUNT(m) FROM MvProductRankWeeklyModel m WHERE m.id.periodKey = :periodKey") + long countByPeriodKey(@Param("periodKey") String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/ranking/MvRankAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/ranking/MvRankAdminV1Controller.java new file mode 100644 index 0000000000..f51a7eae96 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/ranking/MvRankAdminV1Controller.java @@ -0,0 +1,78 @@ +package com.loopers.interfaces.api.admin.ranking; + +import com.loopers.application.ranking.MvRankStatusApp; +import com.loopers.application.ranking.MvRankStatusInfo; +import com.loopers.domain.ranking.RankPeriodType; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api-admin/v1/rankings/mv") +@RequiredArgsConstructor +@Tag(name = "MV Rank Admin", description = "랭킹 MV publication·version 상태 조회") +public class MvRankAdminV1Controller { + + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + private static final int MAX_PAGE_SIZE = 500; + + private final MvRankStatusApp mvRankStatusApp; + + @GetMapping("/{periodType}") + @Operation(summary = "periodType별 MV 상태 목록", description = "published_version/total/orphan row 수 페이징 조회") + public ResponseEntity>> listStatus( + @RequestHeader(value = "X-Loopers-Ldap", required = false) String ldapHeader, + @Parameter(description = "WEEKLY | MONTHLY | QUARTERLY") @PathVariable String periodType, + @Parameter(description = "페이징 offset (기본 0)") @RequestParam(defaultValue = "0") int offset, + @Parameter(description = "페이지 크기 (기본 50, 최대 500)") @RequestParam(defaultValue = "50") int size + ) { + validateAdmin(ldapHeader); + RankPeriodType type = parsePeriodType(periodType); + int clampedSize = Math.max(1, Math.min(size, MAX_PAGE_SIZE)); + int clampedOffset = Math.max(0, offset); + List statuses = mvRankStatusApp.listStatuses(type, clampedOffset, clampedSize); + return ResponseEntity.ok(ApiResponse.success( + statuses.stream().map(MvRankAdminV1Dto.StatusResponse::from).toList() + )); + } + + @GetMapping("/{periodType}/{periodKey}") + @Operation(summary = "단건 MV 상태 조회") + public ResponseEntity> getStatus( + @RequestHeader(value = "X-Loopers-Ldap", required = false) String ldapHeader, + @PathVariable String periodType, + @PathVariable String periodKey + ) { + validateAdmin(ldapHeader); + RankPeriodType type = parsePeriodType(periodType); + MvRankStatusInfo status = mvRankStatusApp.getStatus(type, periodKey); + return ResponseEntity.ok(ApiResponse.success(MvRankAdminV1Dto.StatusResponse.from(status))); + } + + private RankPeriodType parsePeriodType(String periodType) { + try { + return RankPeriodType.valueOf(periodType.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 periodType: " + periodType); + } + } + + private void validateAdmin(String ldap) { + if (!ADMIN_LDAP_VALUE.equals(ldap)) { + throw new CoreException(ErrorType.FORBIDDEN, "관리자 권한 필요"); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/ranking/MvRankAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/ranking/MvRankAdminV1Dto.java new file mode 100644 index 0000000000..1650d23b19 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/ranking/MvRankAdminV1Dto.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.admin.ranking; + +import com.loopers.application.ranking.MvRankStatusInfo; +import com.loopers.application.ranking.MvRankVersionCount; + +import java.time.ZonedDateTime; +import java.util.List; + +public class MvRankAdminV1Dto { + + public record StatusResponse( + String periodType, + String periodKey, + long publishedVersion, + long nextVersion, + ZonedDateTime updatedAt, + long totalRowCount, + long publishedRowCount, + long orphanRowCount, + List versionBreakdown + ) { + public static StatusResponse from(MvRankStatusInfo info) { + return new StatusResponse( + info.periodType(), info.periodKey(), + info.publishedVersion(), info.nextVersion(), + info.updatedAt(), info.totalRowCount(), info.publishedRowCount(), + info.orphanRowCount(), info.versionBreakdown() + ); + } + } +} 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 ac41172a64..eb6fe7a0ba 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 @@ -11,11 +11,13 @@ @Tag(name = "Ranking API", description = "실시간 랭킹 조회 API") public interface RankingV1ApiSpec { - @Operation(summary = "Top-N 랭킹 조회 (offset 기반)", description = "date, page, size 파라미터로 Top-N 랭킹을 조회합니다.") + @Operation(summary = "Top-N 랭킹 조회 (offset 기반)", description = "period, date, page, size 파라미터로 Top-N 랭킹을 조회합니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공 (빈 결과는 empty items)") }) ResponseEntity> getRankingByOffset( + @Parameter(description = "랭킹 기간 (daily|weekly|monthly|quarterly). 생략 시 daily", example = "daily") + @RequestParam(required = false) String period, @Parameter(description = "랭킹 날짜 (yyyyMMdd). 생략 시 오늘", example = "20260405") @RequestParam(required = false) String date, @Parameter(description = "페이지 번호 (0부터)", example = "0") 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 d78638a0a9..320d25032a 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 @@ -3,7 +3,10 @@ import com.loopers.application.ranking.RankingApp; import com.loopers.application.ranking.RankingCursorResult; import com.loopers.application.ranking.RankingPageResult; +import com.loopers.domain.ranking.RankingPeriod; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -14,6 +17,7 @@ import java.time.LocalDate; import java.time.LocalTime; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; @RestController @RequestMapping("/api/v1/rankings") @@ -27,13 +31,23 @@ public class RankingV1Controller implements RankingV1ApiSpec { @GetMapping @Override public ResponseEntity> getRankingByOffset( + @RequestParam(required = false) String period, @RequestParam(required = false) String date, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { + RankingPeriod rankingPeriod = RankingPeriod.fromString(period); LocalDate targetDate = parseDateOrToday(date); - RankingPageResult result = rankingApp.getTopN(targetDate, page, size); - return ResponseEntity.ok(ApiResponse.success(RankingV1Dto.RankingPageResponse.from(result))); + RankingPageResult result = rankingApp.getTopN(rankingPeriod, targetDate, page, size); + ResponseEntity.BodyBuilder builder = ResponseEntity.ok(); + if (result.periodKey() != null) { + builder.header("X-Ranking-Period-Key", result.periodKey()); + builder.header("X-Ranking-Is-Fallback", String.valueOf(result.isFallback())); + } + if (result.publishedVersion() != null) { + builder.header("X-Ranking-Version", String.valueOf(result.publishedVersion())); + } + return builder.body(ApiResponse.success(RankingV1Dto.RankingPageResponse.from(result))); } @GetMapping("/cursor") @@ -80,7 +94,12 @@ private LocalDate parseDateOrToday(String date) { if (date == null || date.isBlank()) { return LocalDate.now(); } - return LocalDate.parse(date, DATE_FORMATTER); + try { + return LocalDate.parse(date, DATE_FORMATTER); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, + "지원하지 않는 date 형식: " + date + " (expected: yyyyMMdd)"); + } } private int parseHourOrNow(Integer hour) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java index b8fc45ba9a..c1f20936c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -5,6 +5,7 @@ import com.loopers.application.ranking.RankingPageResult; import java.math.BigDecimal; +import java.time.ZonedDateTime; import java.util.List; public class RankingV1Dto { @@ -35,13 +36,17 @@ public record RankingPageResponse( List items, long page, long size, - long totalElements + long totalElements, + ZonedDateTime lastUpdatedAt, + String periodKey, + boolean isFallback ) { public static RankingPageResponse from(RankingPageResult result) { List items = result.items().stream() .map(RankingItemResponse::from) .toList(); - return new RankingPageResponse(items, result.page(), result.size(), result.totalElements()); + return new RankingPageResponse(items, result.page(), result.size(), result.totalElements(), + result.lastUpdatedAt(), result.periodKey(), result.isFallback()); } } diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 9f87f84efb..9453089f4f 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -1,3 +1,11 @@ +ranking: + weight: + view: 0.1 + like: 0.2 + order: 0.7 + cold-start-fallback: + enabled: false + server: shutdown: graceful tomcat: diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/QuarterlyJoinVsAppAggregationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/QuarterlyJoinVsAppAggregationTest.java new file mode 100644 index 0000000000..ac53a276d9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/QuarterlyJoinVsAppAggregationTest.java @@ -0,0 +1,243 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("Quarterly — JOIN vs App-level Aggregation 비교") +class QuarterlyJoinVsAppAggregationTest { + + private static final Logger log = LoggerFactory.getLogger(QuarterlyJoinVsAppAggregationTest.class); + private static final int PRODUCT_COUNT = 100; + private static final int ITERATIONS = 50; + private static final int WARMUP = 5; + private static final String PERIOD_KEY = "20260416"; + + private static final String JOIN_SQL = """ + SELECT mv.rank_no, mv.ref_product_id, mv.score, + p.product_id, p.product_name, p.price + FROM mv_product_rank_quarterly mv + INNER JOIN mv_product_rank_publication pub + ON pub.period_type = 'QUARTERLY' + AND pub.period_key = mv.period_key + AND pub.published_version = mv.version + INNER JOIN products p ON p.id = mv.ref_product_id + WHERE mv.period_key = ? + ORDER BY mv.rank_no ASC + LIMIT ? OFFSET ? + """; + + private static final String RANK_ONLY_SQL = """ + SELECT mv.rank_no, mv.ref_product_id, mv.score + FROM mv_product_rank_quarterly mv + INNER JOIN mv_product_rank_publication pub + ON pub.period_type = 'QUARTERLY' + AND pub.period_key = mv.period_key + AND pub.published_version = mv.version + WHERE mv.period_key = ? + ORDER BY mv.rank_no ASC + LIMIT ? OFFSET ? + """; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + createMvTablesIfAbsent(); + seedProducts(); + seedRankMv(); + seedPublication(); + } + + private void createMvTablesIfAbsent() { + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS mv_product_rank_quarterly ( + period_key VARCHAR(8) NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + rank_no INT NOT NULL, + ref_product_id BIGINT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + order_amount DECIMAL(15,2) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (period_key, version, rank_no) + ) + """); + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS mv_product_rank_publication ( + period_type VARCHAR(20) NOT NULL, + period_key VARCHAR(8) NOT NULL, + published_version BIGINT NOT NULL DEFAULT 0, + next_version BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (period_type, period_key) + ) + """); + jdbcTemplate.execute("DELETE FROM mv_product_rank_quarterly"); + jdbcTemplate.execute("DELETE FROM mv_product_rank_publication"); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("두 방식이 동일한 결과를 반환한다") + void correctnessEquivalence() { + List joinResult = fetchByJoin(PERIOD_KEY, 0, 100); + List appResult = fetchByAppAggregation(PERIOD_KEY, 0, 100); + + assertThat(joinResult).hasSameSizeAs(appResult); + for (int i = 0; i < joinResult.size(); i++) { + assertThat(joinResult.get(i).rankNo()).isEqualTo(appResult.get(i).rankNo()); + assertThat(joinResult.get(i).productDbId()).isEqualTo(appResult.get(i).productDbId()); + assertThat(joinResult.get(i).productName()).isEqualTo(appResult.get(i).productName()); + } + } + + @Test + @DisplayName("성능 비교: JOIN vs App-level 2-step") + void performanceBenchmark() { + for (int i = 0; i < WARMUP; i++) { + fetchByJoin(PERIOD_KEY, 0, 100); + fetchByAppAggregation(PERIOD_KEY, 0, 100); + } + + double joinAvgMs = benchmark(() -> fetchByJoin(PERIOD_KEY, 0, 100)); + double appAvgMs = benchmark(() -> fetchByAppAggregation(PERIOD_KEY, 0, 100)); + + log.info("=== Quarterly JOIN vs App Aggregation Benchmark ==="); + log.info("Iterations: {}, Products: {}, TopN: 100", ITERATIONS, PRODUCT_COUNT); + log.info("JOIN (native SQL): avg={} ms", formatMs(joinAvgMs)); + log.info("APP (2-step fetch): avg={} ms", formatMs(appAvgMs)); + log.info("Delta: {} ms ({} x)", formatMs(appAvgMs - joinAvgMs), + String.format(Locale.ROOT, "%.2f", appAvgMs / joinAvgMs)); + } + + private double benchmark(Runnable target) { + long totalNs = 0; + for (int i = 0; i < ITERATIONS; i++) { + long start = System.nanoTime(); + target.run(); + totalNs += (System.nanoTime() - start); + } + return (totalNs / (double) ITERATIONS) / 1_000_000.0; + } + + private static String formatMs(double ms) { + return String.format(Locale.ROOT, "%.3f", ms); + } + + private List fetchByJoin(String periodKey, int offset, int size) { + return jdbcTemplate.query(JOIN_SQL, + (rs, i) -> new QuarterlyRankViewRow( + rs.getInt("rank_no"), + rs.getLong("ref_product_id"), + rs.getDouble("score"), + rs.getString("product_id"), + rs.getString("product_name"), + rs.getBigDecimal("price") + ), + periodKey, size, offset); + } + + private List fetchByAppAggregation(String periodKey, int offset, int size) { + List rankOnly = jdbcTemplate.query(RANK_ONLY_SQL, + (rs, i) -> new QuarterlyRankViewRow( + rs.getInt("rank_no"), + rs.getLong("ref_product_id"), + rs.getDouble("score"), + null, null, null), + periodKey, size, offset); + + if (rankOnly.isEmpty()) { + return List.of(); + } + List ids = new ArrayList<>(rankOnly.size()); + for (QuarterlyRankViewRow r : rankOnly) { + ids.add(r.productDbId()); + } + + Map productMap = new HashMap<>(); + for (ProductModel p : productRepository.findAllByIdIncludingDeleted(ids)) { + productMap.put(p.getId(), p); + } + + List result = new ArrayList<>(rankOnly.size()); + for (QuarterlyRankViewRow r : rankOnly) { + ProductModel p = productMap.get(r.productDbId()); + result.add(new QuarterlyRankViewRow( + r.rankNo(), r.productDbId(), r.score(), + p == null ? null : p.getProductId().value(), + p == null ? null : p.getProductName().value(), + p == null ? null : p.getPrice().value() + )); + } + return result; + } + + private void seedProducts() { + for (int i = 1; i <= PRODUCT_COUNT; i++) { + productRepository.save(ProductModel.create( + String.format("P%04d", i), + 1L, + "Product " + i, + new BigDecimal(1000 + i * 10), + 100 + )); + } + } + + private void seedRankMv() { + List ids = jdbcTemplate.queryForList( + "SELECT id FROM products ORDER BY id ASC LIMIT ?", Long.class, PRODUCT_COUNT); + String sql = """ + INSERT INTO mv_product_rank_quarterly + (period_key, version, rank_no, ref_product_id, score, + view_count, like_count, order_amount, created_at, updated_at) + VALUES (?, 1, ?, ?, ?, 0, 0, 0, NOW(), NOW()) + """; + for (int i = 0; i < ids.size(); i++) { + jdbcTemplate.update(sql, PERIOD_KEY, i + 1, ids.get(i), (PRODUCT_COUNT - i) * 10.0); + } + } + + private void seedPublication() { + jdbcTemplate.update(""" + INSERT INTO mv_product_rank_publication + (period_type, period_key, published_version, next_version, updated_at) + VALUES ('QUARTERLY', ?, 1, 1, ?) + """, PERIOD_KEY, Instant.now()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/QuarterlyRankViewRow.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/QuarterlyRankViewRow.java new file mode 100644 index 0000000000..b18a309dc5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/QuarterlyRankViewRow.java @@ -0,0 +1,13 @@ +package com.loopers.application.ranking; + +import java.math.BigDecimal; + +record QuarterlyRankViewRow( + int rankNo, + long productDbId, + double score, + String productId, + String productName, + BigDecimal price +) { +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppColdStartFallbackTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppColdStartFallbackTest.java new file mode 100644 index 0000000000..32443ee4b1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppColdStartFallbackTest.java @@ -0,0 +1,104 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.MvProductRankRepository; +import com.loopers.domain.ranking.RankPeriodType; +import com.loopers.domain.ranking.RankingEntry; +import com.loopers.domain.ranking.RankingPeriod; +import com.loopers.domain.ranking.RankingRepository; +import com.loopers.ranking.RankingKeyGenerator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("RankingApp 콜드스타트 fallback") +class RankingAppColdStartFallbackTest { + + private RankingRepository rankingRepository; + private MvProductRankRepository mvProductRankRepository; + private RankingProductCache productCache; + private RankingApp rankingApp; + + @BeforeEach + void setUp() { + rankingRepository = mock(RankingRepository.class); + mvProductRankRepository = mock(MvProductRankRepository.class); + productCache = mock(RankingProductCache.class); + rankingApp = new RankingApp(rankingRepository, mvProductRankRepository, productCache); + } + + @DisplayName("flag off + 현재 periodKey 비어있음 → 빈 페이지 반환 (fallback 미발생)") + @Test + void fallback_disabled_returnsEmpty() { + ReflectionTestUtils.setField(rankingApp, "coldStartFallbackEnabled", false); + LocalDate date = LocalDate.of(2026, 4, 13); + String currentKey = RankingKeyGenerator.weeklyPeriodKey(date); + when(mvProductRankRepository.findByPeriodKey(eq(RankPeriodType.WEEKLY), eq(currentKey), anyLong(), anyLong())) + .thenReturn(List.of()); + when(mvProductRankRepository.countByPeriodKey(RankPeriodType.WEEKLY, currentKey)).thenReturn(0L); + when(mvProductRankRepository.findLastUpdatedAt(RankPeriodType.WEEKLY, currentKey)).thenReturn(Optional.empty()); + + RankingPageResult result = rankingApp.getTopN(RankingPeriod.WEEKLY, date, 0, 20); + + assertThat(result.totalElements()).isZero(); + assertThat(result.isFallback()).isFalse(); + assertThat(result.periodKey()).isEqualTo(currentKey); + } + + @DisplayName("flag on + 현재 periodKey 비어있음 → 직전 periodKey로 fallback") + @Test + void fallback_enabled_usesPrevious() { + ReflectionTestUtils.setField(rankingApp, "coldStartFallbackEnabled", true); + LocalDate date = LocalDate.of(2026, 4, 13); + String currentKey = RankingKeyGenerator.weeklyPeriodKey(date); + String previousKey = RankingKeyGenerator.previousWeeklyPeriodKey(date); + + when(mvProductRankRepository.findByPeriodKey(eq(RankPeriodType.WEEKLY), eq(currentKey), anyLong(), anyLong())) + .thenReturn(List.of()); + when(mvProductRankRepository.countByPeriodKey(RankPeriodType.WEEKLY, currentKey)).thenReturn(0L); + when(mvProductRankRepository.findLastUpdatedAt(RankPeriodType.WEEKLY, currentKey)).thenReturn(Optional.empty()); + + when(mvProductRankRepository.findByPeriodKey(eq(RankPeriodType.WEEKLY), eq(previousKey), anyLong(), anyLong())) + .thenReturn(List.of(new RankingEntry(1L, 100.0))); + when(mvProductRankRepository.countByPeriodKey(RankPeriodType.WEEKLY, previousKey)).thenReturn(1L); + when(mvProductRankRepository.findLastUpdatedAt(RankPeriodType.WEEKLY, previousKey)).thenReturn(Optional.empty()); + when(productCache.findAllByIds(List.of(1L))).thenReturn(java.util.Map.of()); + + RankingPageResult result = rankingApp.getTopN(RankingPeriod.WEEKLY, date, 0, 20); + + assertThat(result.isFallback()).isTrue(); + assertThat(result.periodKey()).isEqualTo(previousKey); + assertThat(result.totalElements()).isEqualTo(1L); + verify(mvProductRankRepository).findByPeriodKey(RankPeriodType.WEEKLY, previousKey, 0L, 20L); + } + + @DisplayName("flag on + 현재 periodKey 데이터 존재 → fallback 미발생") + @Test + void fallback_enabled_butCurrentHasData_noFallback() { + ReflectionTestUtils.setField(rankingApp, "coldStartFallbackEnabled", true); + LocalDate date = LocalDate.of(2026, 4, 13); + String currentKey = RankingKeyGenerator.weeklyPeriodKey(date); + + when(mvProductRankRepository.findByPeriodKey(eq(RankPeriodType.WEEKLY), eq(currentKey), anyLong(), anyLong())) + .thenReturn(List.of(new RankingEntry(1L, 100.0))); + when(mvProductRankRepository.countByPeriodKey(RankPeriodType.WEEKLY, currentKey)).thenReturn(1L); + when(mvProductRankRepository.findLastUpdatedAt(RankPeriodType.WEEKLY, currentKey)).thenReturn(Optional.empty()); + when(productCache.findAllByIds(List.of(1L))).thenReturn(java.util.Map.of()); + + RankingPageResult result = rankingApp.getTopN(RankingPeriod.WEEKLY, date, 0, 20); + + assertThat(result.isFallback()).isFalse(); + assertThat(result.periodKey()).isEqualTo(currentKey); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppIntegrationTest.java index 8e821cf597..dbb749fa32 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppIntegrationTest.java @@ -89,8 +89,8 @@ void topNEnrichmentFlow() { } @Test - @DisplayName("삭제된 상품은 랭킹에서 숨긴다 (멘토링 피드백: 부정적 피드백 미노출)") - void deletedProductIsHidden() { + @DisplayName("삭제된 상품은 DISCONTINUED 상태로 랭킹에 포함된다") + void deletedProductIsDiscontinued() { // given: 상품 2개 중 1개 soft delete ProductModel p1 = productRepository.save(ProductModel.create("P001", 1L, "정상 상품", new BigDecimal("1000"), 10)); ProductModel p2 = productRepository.save(ProductModel.create("P002", 1L, "삭제된 상품", new BigDecimal("1000"), 10)); @@ -105,10 +105,11 @@ void deletedProductIsHidden() { // when RankingPageResult result = rankingApp.getTopN(date, 0, 10); - // then: 삭제 상품 필터링 → 정상 상품 1개만 반환 - assertThat(result.items()).hasSize(1); - assertThat(result.items().get(0).productName()).isEqualTo("정상 상품"); - assertThat(result.items().get(0).status()).isEqualTo(RankingInfo.STATUS_ACTIVE); + // then: 삭제 상품도 포함, DISCONTINUED 상태 + assertThat(result.items()).hasSize(2); + assertThat(result.items().get(0).status()).isEqualTo(RankingInfo.STATUS_DISCONTINUED); + assertThat(result.items().get(1).status()).isEqualTo(RankingInfo.STATUS_ACTIVE); + assertThat(result.items().get(1).productName()).isEqualTo("정상 상품"); } @Test diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppPeriodTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppPeriodTest.java new file mode 100644 index 0000000000..4b00f33cf3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppPeriodTest.java @@ -0,0 +1,118 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.MvProductRankRepository; +import com.loopers.domain.ranking.RankPeriodType; +import com.loopers.domain.ranking.RankingEntry; +import com.loopers.domain.ranking.RankingPeriod; +import com.loopers.domain.ranking.RankingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("RankingApp period 분기 단위 테스트") +class RankingAppPeriodTest { + + private RankingRepository rankingRepository; + private MvProductRankRepository mvProductRankRepository; + private RankingProductCache productCache; + private RankingApp rankingApp; + + @BeforeEach + void setUp() { + rankingRepository = mock(RankingRepository.class); + mvProductRankRepository = mock(MvProductRankRepository.class); + productCache = mock(RankingProductCache.class); + rankingApp = new RankingApp(rankingRepository, mvProductRankRepository, productCache); + } + + @DisplayName("period=DAILY → RankingRepository(Redis) 호출") + @Test + void daily_usesRankingRepository() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 11); + when(rankingRepository.findTopN(date, 0, 20)).thenReturn(List.of()); + when(rankingRepository.countMembers(date)).thenReturn(0L); + + // act + rankingApp.getTopN(RankingPeriod.DAILY, date, 0, 20); + + // assert + verify(rankingRepository).findTopN(date, 0, 20); + verify(mvProductRankRepository, never()).findByPeriodKey(any(), anyString(), anyLong(), anyLong()); + } + + @DisplayName("period=WEEKLY → MvProductRankRepository 호출") + @Test + void weekly_usesMvRepository() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 11); + when(mvProductRankRepository.findByPeriodKey(eq(RankPeriodType.WEEKLY), anyString(), eq(0L), eq(20L))) + .thenReturn(List.of()); + when(mvProductRankRepository.countByPeriodKey(eq(RankPeriodType.WEEKLY), anyString())).thenReturn(0L); + + // act + rankingApp.getTopN(RankingPeriod.WEEKLY, date, 0, 20); + + // assert + verify(mvProductRankRepository).findByPeriodKey(eq(RankPeriodType.WEEKLY), anyString(), eq(0L), eq(20L)); + verify(rankingRepository, never()).findTopN(any(), anyLong(), anyLong()); + } + + @DisplayName("period=MONTHLY → MvProductRankRepository(MONTHLY) 호출") + @Test + void monthly_usesMvRepository() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 11); + when(mvProductRankRepository.findByPeriodKey(eq(RankPeriodType.MONTHLY), anyString(), eq(0L), eq(20L))) + .thenReturn(List.of()); + when(mvProductRankRepository.countByPeriodKey(eq(RankPeriodType.MONTHLY), anyString())).thenReturn(0L); + + // act + rankingApp.getTopN(RankingPeriod.MONTHLY, date, 0, 20); + + // assert + verify(mvProductRankRepository).findByPeriodKey(eq(RankPeriodType.MONTHLY), anyString(), eq(0L), eq(20L)); + } + + @DisplayName("period=QUARTERLY → MvProductRankRepository(QUARTERLY) 호출") + @Test + void quarterly_usesMvRepository() { + LocalDate date = LocalDate.of(2026, 4, 16); + when(mvProductRankRepository.findByPeriodKey(eq(RankPeriodType.QUARTERLY), anyString(), eq(0L), eq(20L))) + .thenReturn(List.of()); + when(mvProductRankRepository.countByPeriodKey(eq(RankPeriodType.QUARTERLY), anyString())).thenReturn(0L); + + rankingApp.getTopN(RankingPeriod.QUARTERLY, date, 0, 20); + + verify(mvProductRankRepository).findByPeriodKey(eq(RankPeriodType.QUARTERLY), anyString(), eq(0L), eq(20L)); + verify(rankingRepository, never()).findTopN(any(), anyLong(), anyLong()); + } + + @DisplayName("period 없는 기존 메서드는 DAILY로 동작한다") + @Test + void legacyMethod_defaultsToDaily() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 11); + when(rankingRepository.findTopN(date, 0, 20)).thenReturn(List.of()); + when(rankingRepository.countMembers(date)).thenReturn(0L); + + // act + rankingApp.getTopN(date, 0, 20); + + // assert + verify(rankingRepository).findTopN(date, 0, 20); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppTest.java index 929d42976e..9210863a89 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppTest.java @@ -1,5 +1,6 @@ package com.loopers.application.ranking; +import com.loopers.domain.ranking.MvProductRankRepository; import com.loopers.domain.ranking.RankingEntry; import com.loopers.domain.ranking.RankingRepository; import org.junit.jupiter.api.BeforeEach; @@ -11,7 +12,9 @@ import java.time.Instant; import java.time.LocalDate; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -24,14 +27,16 @@ class RankingAppTest { private RankingRepository rankingRepository; + private MvProductRankRepository mvProductRankRepository; private RankingProductCache productCache; private RankingApp rankingApp; @BeforeEach void setUp() { rankingRepository = mock(RankingRepository.class); + mvProductRankRepository = mock(MvProductRankRepository.class); productCache = mock(RankingProductCache.class); - rankingApp = new RankingApp(rankingRepository, productCache); + rankingApp = new RankingApp(rankingRepository, mvProductRankRepository, productCache); } @Nested @@ -47,10 +52,12 @@ void enrichesEntriesWithProductInfo() { new RankingEntry(2L, 80.0) )); when(rankingRepository.countMembers(date)).thenReturn(2L); - when(productCache.findById(1L)).thenReturn(new CachedProductSnapshot( + Map snapshots = new HashMap<>(); + snapshots.put(1L, new CachedProductSnapshot( 1L, "P001", "상품 A", new BigDecimal("1000"), false, Instant.now().getEpochSecond())); - when(productCache.findById(2L)).thenReturn(new CachedProductSnapshot( + snapshots.put(2L, new CachedProductSnapshot( 2L, "P002", "상품 B", new BigDecimal("2000"), false, Instant.now().getEpochSecond())); + when(productCache.findAllByIds(any())).thenReturn(snapshots); RankingPageResult result = rankingApp.getTopN(date, 0, 2); @@ -64,39 +71,44 @@ void enrichesEntriesWithProductInfo() { } @Test - @DisplayName("삭제된 상품은 랭킹에서 숨긴다 (멘토링 피드백: 부정적 피드백 미노출)") - void deletedProductIsHidden() { + @DisplayName("삭제된 상품은 DISCONTINUED 상태로 랭킹에 포함된다") + void deletedProductIsDiscontinued() { LocalDate date = LocalDate.of(2026, 4, 5); when(rankingRepository.findTopN(eq(date), any(Long.class), any(Long.class))).thenReturn(List.of( new RankingEntry(10L, 50.0), new RankingEntry(11L, 40.0) )); when(rankingRepository.countMembers(date)).thenReturn(2L); - when(productCache.findById(10L)).thenReturn(new CachedProductSnapshot( + Map snapshots = new HashMap<>(); + snapshots.put(10L, new CachedProductSnapshot( 10L, "P010", "판매종료 상품", new BigDecimal("500"), true, Instant.now().getEpochSecond())); - when(productCache.findById(11L)).thenReturn(new CachedProductSnapshot( + snapshots.put(11L, new CachedProductSnapshot( 11L, "P011", "정상 상품", new BigDecimal("300"), false, Instant.now().getEpochSecond())); + when(productCache.findAllByIds(any())).thenReturn(snapshots); RankingPageResult result = rankingApp.getTopN(date, 0, 10); - assertThat(result.items()).hasSize(1); - assertThat(result.items().get(0).productName()).isEqualTo("정상 상품"); - assertThat(result.items().get(0).status()).isEqualTo(RankingInfo.STATUS_ACTIVE); + assertThat(result.items()).hasSize(2); + assertThat(result.items().get(0).status()).isEqualTo(RankingInfo.STATUS_DISCONTINUED); + assertThat(result.items().get(1).status()).isEqualTo(RankingInfo.STATUS_ACTIVE); + assertThat(result.items().get(1).productName()).isEqualTo("정상 상품"); } @Test - @DisplayName("캐시에 없는 상품(DB에서도 삭제)도 랭킹에서 숨긴다") - void missingProductIsHidden() { + @DisplayName("캐시에 없는 상품도 DISCONTINUED로 랭킹에 포함된다") + void missingProductIsDiscontinued() { LocalDate date = LocalDate.of(2026, 4, 5); when(rankingRepository.findTopN(eq(date), any(Long.class), any(Long.class))).thenReturn(List.of( new RankingEntry(999L, 30.0) )); when(rankingRepository.countMembers(date)).thenReturn(1L); - when(productCache.findById(999L)).thenReturn(null); + when(productCache.findAllByIds(any())).thenReturn(Map.of()); RankingPageResult result = rankingApp.getTopN(date, 0, 10); - assertThat(result.items()).isEmpty(); + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).status()).isEqualTo(RankingInfo.STATUS_DISCONTINUED); + assertThat(result.items().get(0).productDbId()).isEqualTo(999L); } @Test @@ -120,8 +132,8 @@ void secondPageRankStartsFromEleven() { new RankingEntry(11L, 5.0) )); when(rankingRepository.countMembers(date)).thenReturn(20L); - when(productCache.findById(11L)).thenReturn(new CachedProductSnapshot( - 11L, "P011", "상품 11", new BigDecimal("100"), false, Instant.now().getEpochSecond())); + when(productCache.findAllByIds(any())).thenReturn(Map.of(11L, new CachedProductSnapshot( + 11L, "P011", "상품 11", new BigDecimal("100"), false, Instant.now().getEpochSecond()))); RankingPageResult result = rankingApp.getTopN(date, 1, 10); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerPeriodTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerPeriodTest.java new file mode 100644 index 0000000000..2b62241067 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerPeriodTest.java @@ -0,0 +1,94 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingApp; +import com.loopers.application.ranking.RankingPageResult; +import com.loopers.domain.ranking.RankingPeriod; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("RankingPeriod 파싱 테스트") +class RankingV1ControllerPeriodTest { + + @DisplayName("null → DAILY") + @Test + void nullInput_returnDaily() { + assertThat(RankingPeriod.fromString(null)).isEqualTo(RankingPeriod.DAILY); + } + + @DisplayName("빈 문자열 → DAILY") + @Test + void emptyInput_returnDaily() { + assertThat(RankingPeriod.fromString("")).isEqualTo(RankingPeriod.DAILY); + } + + @DisplayName("daily → DAILY") + @Test + void daily() { + assertThat(RankingPeriod.fromString("daily")).isEqualTo(RankingPeriod.DAILY); + } + + @DisplayName("weekly → WEEKLY (대소문자 무관)") + @Test + void weekly_caseInsensitive() { + assertThat(RankingPeriod.fromString("weekly")).isEqualTo(RankingPeriod.WEEKLY); + assertThat(RankingPeriod.fromString("WEEKLY")).isEqualTo(RankingPeriod.WEEKLY); + assertThat(RankingPeriod.fromString("Weekly")).isEqualTo(RankingPeriod.WEEKLY); + } + + @DisplayName("monthly → MONTHLY") + @Test + void monthly() { + assertThat(RankingPeriod.fromString("monthly")).isEqualTo(RankingPeriod.MONTHLY); + } + + @DisplayName("quarterly → QUARTERLY") + @Test + void quarterly() { + assertThat(RankingPeriod.fromString("quarterly")).isEqualTo(RankingPeriod.QUARTERLY); + assertThat(RankingPeriod.fromString("QUARTERLY")).isEqualTo(RankingPeriod.QUARTERLY); + } + + @DisplayName("invalid → CoreException(BAD_REQUEST)") + @Test + void invalid_throwsException() { + assertThatThrownBy(() -> RankingPeriod.fromString("invalid")) + .isInstanceOfSatisfying(CoreException.class, + e -> assertThat(e.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("지원하지 않는 ranking period"); + } + + @DisplayName("date 파라미터가 yyyyMMdd 형식이 아니면 BAD_REQUEST") + @Test + void invalidDate_throwsBadRequest() { + RankingApp app = Mockito.mock(RankingApp.class); + RankingV1Controller controller = new RankingV1Controller(app); + + assertThatThrownBy(() -> controller.getRankingByOffset(null, "2026-04-01", 0, 20)) + .isInstanceOfSatisfying(CoreException.class, + e -> assertThat(e.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)) + .hasMessageContaining("지원하지 않는 date 형식"); + + Mockito.verifyNoInteractions(app); + } + + @DisplayName("date 파라미터가 yyyyMMdd 형식이면 정상 호출") + @Test + void validDate_invokesApp() { + RankingApp app = Mockito.mock(RankingApp.class); + Mockito.when(app.getTopN(Mockito.any(), Mockito.any(), Mockito.anyLong(), Mockito.anyLong())) + .thenReturn(new RankingPageResult(List.of(), 0L, 20L, 0L, null)); + RankingV1Controller controller = new RankingV1Controller(app); + + assertThatCode(() -> controller.getRankingByOffset(null, "20260401", 0, 20)) + .doesNotThrowAnyException(); + } +} diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts index b22b6477cc..961b0c0678 100644 --- a/apps/commerce-batch/build.gradle.kts +++ b/apps/commerce-batch/build.gradle.kts @@ -5,6 +5,7 @@ dependencies { implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + implementation(project(":supports:ranking")) // batch implementation("org.springframework.boot:spring-boot-starter-batch") diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/MonthlyRankJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/MonthlyRankJobConfig.java new file mode 100644 index 0000000000..ac673a9b40 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/MonthlyRankJobConfig.java @@ -0,0 +1,83 @@ +package com.loopers.batch.job.rank; + +import com.loopers.batch.job.rank.step.RankJobFactory; +import com.loopers.domain.rank.RankPeriodType; +import com.loopers.ranking.RankingKeyGenerator; +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.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDate; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class MonthlyRankJobConfig { + + public static final String JOB_NAME = "monthlyRankJob"; + private static final String VALIDATION_STEP = "validateMonthlyScoreCompletenessStep"; + private static final String BUILD_STEP = "buildMonthlyRankStep"; + private static final String HEALTH_CHECK_STEP = "healthCheckMonthlyRankStep"; + + private final RankJobFactory rankJobFactory; + private final JobRepository jobRepository; + + @Bean(JOB_NAME) + public Job monthlyRankJob(Step validateMonthlyScoreCompletenessStep, + Step buildMonthlyRankStep, + Step healthCheckMonthlyRankStep) { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(validateMonthlyScoreCompletenessStep) + .next(buildMonthlyRankStep) + .next(healthCheckMonthlyRankStep) + .build(); + } + + @Bean(VALIDATION_STEP) + @JobScope + public Step validateMonthlyScoreCompletenessStep(@Value("#{jobParameters['date']}") String dateStr) { + LocalDate date = LocalDate.parse(dateStr, RankJobFactory.DATE_FMT); + return rankJobFactory.buildValidationStep( + VALIDATION_STEP, + RankingKeyGenerator.monthStart(date), + RankingKeyGenerator.monthEnd(date) + ); + } + + @Bean(BUILD_STEP) + @JobScope + public Step buildMonthlyRankStep(@Value("#{jobParameters['date']}") String dateStr) { + LocalDate date = LocalDate.parse(dateStr, RankJobFactory.DATE_FMT); + return rankJobFactory.buildStep( + BUILD_STEP, + RankPeriodType.MONTHLY, + RankingKeyGenerator.monthlyPeriodKey(date), + RankingKeyGenerator.monthStart(date), + RankingKeyGenerator.monthEnd(date) + ); + } + + @Bean(HEALTH_CHECK_STEP) + @JobScope + public Step healthCheckMonthlyRankStep(@Value("#{jobParameters['date']}") String dateStr, + @Value("#{jobParameters['mode']}") String mode) { + LocalDate date = LocalDate.parse(dateStr, RankJobFactory.DATE_FMT); + boolean backfillMode = "backfill".equalsIgnoreCase(mode); + return rankJobFactory.buildHealthCheckStep( + HEALTH_CHECK_STEP, + RankPeriodType.MONTHLY, + RankingKeyGenerator.monthlyPeriodKey(date), + RankingKeyGenerator.previousMonthlyPeriodKey(date), + backfillMode + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/MvRankCleanupJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/MvRankCleanupJobConfig.java new file mode 100644 index 0000000000..312da83881 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/MvRankCleanupJobConfig.java @@ -0,0 +1,75 @@ +package com.loopers.batch.job.rank; + +import com.loopers.batch.job.rank.step.RankJobFactory; +import com.loopers.domain.rank.RankPeriodType; +import com.loopers.ranking.RankingKeyGenerator; +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.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDate; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MvRankCleanupJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class MvRankCleanupJobConfig { + + public static final String JOB_NAME = "mvRankCleanupJob"; + private static final String WEEKLY_STEP = "cleanupWeeklyMvStep"; + private static final String MONTHLY_STEP = "cleanupMonthlyMvStep"; + private static final String QUARTERLY_STEP = "cleanupQuarterlyMvStep"; + + private final RankJobFactory rankJobFactory; + private final JobRepository jobRepository; + + @Bean(JOB_NAME) + public Job mvRankCleanupJob(Step cleanupWeeklyMvStep, Step cleanupMonthlyMvStep, Step cleanupQuarterlyMvStep) { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(cleanupWeeklyMvStep) + .next(cleanupMonthlyMvStep) + .next(cleanupQuarterlyMvStep) + .build(); + } + + @Bean(WEEKLY_STEP) + @JobScope + public Step cleanupWeeklyMvStep(@Value("#{jobParameters['date']}") String dateStr) { + LocalDate date = LocalDate.parse(dateStr, RankJobFactory.DATE_FMT); + return rankJobFactory.buildCleanupStep( + WEEKLY_STEP, + RankPeriodType.WEEKLY, + RankingKeyGenerator.weeklyPeriodKey(date) + ); + } + + @Bean(MONTHLY_STEP) + @JobScope + public Step cleanupMonthlyMvStep(@Value("#{jobParameters['date']}") String dateStr) { + LocalDate date = LocalDate.parse(dateStr, RankJobFactory.DATE_FMT); + return rankJobFactory.buildCleanupStep( + MONTHLY_STEP, + RankPeriodType.MONTHLY, + RankingKeyGenerator.monthlyPeriodKey(date) + ); + } + + @Bean(QUARTERLY_STEP) + @JobScope + public Step cleanupQuarterlyMvStep(@Value("#{jobParameters['date']}") String dateStr) { + LocalDate date = LocalDate.parse(dateStr, RankJobFactory.DATE_FMT); + return rankJobFactory.buildCleanupStep( + QUARTERLY_STEP, + RankPeriodType.QUARTERLY, + RankingKeyGenerator.quarterlyPeriodKey(date) + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/QuarterlyRankJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/QuarterlyRankJobConfig.java new file mode 100644 index 0000000000..09d6aec268 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/QuarterlyRankJobConfig.java @@ -0,0 +1,83 @@ +package com.loopers.batch.job.rank; + +import com.loopers.batch.job.rank.step.RankJobFactory; +import com.loopers.domain.rank.RankPeriodType; +import com.loopers.ranking.RankingKeyGenerator; +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.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDate; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = QuarterlyRankJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class QuarterlyRankJobConfig { + + public static final String JOB_NAME = "quarterlyRankJob"; + private static final String VALIDATION_STEP = "validateQuarterlyScoreCompletenessStep"; + private static final String BUILD_STEP = "buildQuarterlyRankStep"; + private static final String HEALTH_CHECK_STEP = "healthCheckQuarterlyRankStep"; + + private final RankJobFactory rankJobFactory; + private final JobRepository jobRepository; + + @Bean(JOB_NAME) + public Job quarterlyRankJob(Step validateQuarterlyScoreCompletenessStep, + Step buildQuarterlyRankStep, + Step healthCheckQuarterlyRankStep) { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(validateQuarterlyScoreCompletenessStep) + .next(buildQuarterlyRankStep) + .next(healthCheckQuarterlyRankStep) + .build(); + } + + @Bean(VALIDATION_STEP) + @JobScope + public Step validateQuarterlyScoreCompletenessStep(@Value("#{jobParameters['date']}") String dateStr) { + LocalDate date = LocalDate.parse(dateStr, RankJobFactory.DATE_FMT); + return rankJobFactory.buildValidationStep( + VALIDATION_STEP, + RankingKeyGenerator.quarterlyStart(date), + RankingKeyGenerator.quarterlyEnd(date) + ); + } + + @Bean(BUILD_STEP) + @JobScope + public Step buildQuarterlyRankStep(@Value("#{jobParameters['date']}") String dateStr) { + LocalDate date = LocalDate.parse(dateStr, RankJobFactory.DATE_FMT); + return rankJobFactory.buildStep( + BUILD_STEP, + RankPeriodType.QUARTERLY, + RankingKeyGenerator.quarterlyPeriodKey(date), + RankingKeyGenerator.quarterlyStart(date), + RankingKeyGenerator.quarterlyEnd(date) + ); + } + + @Bean(HEALTH_CHECK_STEP) + @JobScope + public Step healthCheckQuarterlyRankStep(@Value("#{jobParameters['date']}") String dateStr, + @Value("#{jobParameters['mode']}") String mode) { + LocalDate date = LocalDate.parse(dateStr, RankJobFactory.DATE_FMT); + boolean backfillMode = "backfill".equalsIgnoreCase(mode); + return rankJobFactory.buildHealthCheckStep( + HEALTH_CHECK_STEP, + RankPeriodType.QUARTERLY, + RankingKeyGenerator.quarterlyPeriodKey(date), + RankingKeyGenerator.previousQuarterlyPeriodKey(date), + backfillMode + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/WeeklyRankJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/WeeklyRankJobConfig.java new file mode 100644 index 0000000000..58d4823e08 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/WeeklyRankJobConfig.java @@ -0,0 +1,83 @@ +package com.loopers.batch.job.rank; + +import com.loopers.batch.job.rank.step.RankJobFactory; +import com.loopers.domain.rank.RankPeriodType; +import com.loopers.ranking.RankingKeyGenerator; +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.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDate; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class WeeklyRankJobConfig { + + public static final String JOB_NAME = "weeklyRankJob"; + private static final String VALIDATION_STEP = "validateWeeklyScoreCompletenessStep"; + private static final String BUILD_STEP = "buildWeeklyRankStep"; + private static final String HEALTH_CHECK_STEP = "healthCheckWeeklyRankStep"; + + private final RankJobFactory rankJobFactory; + private final JobRepository jobRepository; + + @Bean(JOB_NAME) + public Job weeklyRankJob(Step validateWeeklyScoreCompletenessStep, + Step buildWeeklyRankStep, + Step healthCheckWeeklyRankStep) { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(validateWeeklyScoreCompletenessStep) + .next(buildWeeklyRankStep) + .next(healthCheckWeeklyRankStep) + .build(); + } + + @Bean(VALIDATION_STEP) + @JobScope + public Step validateWeeklyScoreCompletenessStep(@Value("#{jobParameters['date']}") String dateStr) { + LocalDate date = LocalDate.parse(dateStr, RankJobFactory.DATE_FMT); + return rankJobFactory.buildValidationStep( + VALIDATION_STEP, + RankingKeyGenerator.weekStart(date), + RankingKeyGenerator.weekEnd(date) + ); + } + + @Bean(BUILD_STEP) + @JobScope + public Step buildWeeklyRankStep(@Value("#{jobParameters['date']}") String dateStr) { + LocalDate date = LocalDate.parse(dateStr, RankJobFactory.DATE_FMT); + return rankJobFactory.buildStep( + BUILD_STEP, + RankPeriodType.WEEKLY, + RankingKeyGenerator.weeklyPeriodKey(date), + RankingKeyGenerator.weekStart(date), + RankingKeyGenerator.weekEnd(date) + ); + } + + @Bean(HEALTH_CHECK_STEP) + @JobScope + public Step healthCheckWeeklyRankStep(@Value("#{jobParameters['date']}") String dateStr, + @Value("#{jobParameters['mode']}") String mode) { + LocalDate date = LocalDate.parse(dateStr, RankJobFactory.DATE_FMT); + boolean backfillMode = "backfill".equalsIgnoreCase(mode); + return rankJobFactory.buildHealthCheckStep( + HEALTH_CHECK_STEP, + RankPeriodType.WEEKLY, + RankingKeyGenerator.weeklyPeriodKey(date), + RankingKeyGenerator.previousWeeklyPeriodKey(date), + backfillMode + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/AggregatedScoreRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/AggregatedScoreRow.java new file mode 100644 index 0000000000..cc3826f510 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/AggregatedScoreRow.java @@ -0,0 +1,11 @@ +package com.loopers.batch.job.rank.step; + +import java.math.BigDecimal; + +public record AggregatedScoreRow( + long productDbId, + double totalScore, + long totalView, + long totalLike, + BigDecimal totalOrder +) {} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/MvOutputHealthCheckTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/MvOutputHealthCheckTasklet.java new file mode 100644 index 0000000000..8d6f218f59 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/MvOutputHealthCheckTasklet.java @@ -0,0 +1,100 @@ +package com.loopers.batch.job.rank.step; + +import com.loopers.domain.rank.RankPeriodType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +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.jdbc.core.JdbcTemplate; + +@Slf4j +public class MvOutputHealthCheckTasklet implements Tasklet { + + private final JdbcTemplate jdbcTemplate; + private final RankPeriodType periodType; + private final String currentPeriodKey; + private final String previousPeriodKey; + private final long minRows; + private final double maxVariancePct; + private final boolean failOnAnomaly; + private final boolean backfillMode; + + public MvOutputHealthCheckTasklet(JdbcTemplate jdbcTemplate, + RankPeriodType periodType, + String currentPeriodKey, + String previousPeriodKey, + long minRows, + double maxVariancePct, + boolean failOnAnomaly) { + this(jdbcTemplate, periodType, currentPeriodKey, previousPeriodKey, + minRows, maxVariancePct, failOnAnomaly, false); + } + + public MvOutputHealthCheckTasklet(JdbcTemplate jdbcTemplate, + RankPeriodType periodType, + String currentPeriodKey, + String previousPeriodKey, + long minRows, + double maxVariancePct, + boolean failOnAnomaly, + boolean backfillMode) { + this.jdbcTemplate = jdbcTemplate; + this.periodType = periodType; + this.currentPeriodKey = currentPeriodKey; + this.previousPeriodKey = previousPeriodKey; + this.minRows = minRows; + this.maxVariancePct = maxVariancePct; + this.failOnAnomaly = failOnAnomaly; + this.backfillMode = backfillMode; + } + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + long currentCount = countRows(currentPeriodKey); + if (currentCount < minRows) { + handleAnomaly(String.format("MV row 부족: type=%s periodKey=%s count=%d < min=%d", + periodType, currentPeriodKey, currentCount, minRows)); + return RepeatStatus.FINISHED; + } + + if (backfillMode || previousPeriodKey == null) { + log.info("MV health {}: type={} periodKey={} rows={}", + backfillMode ? "backfill-skip-variance" : "pass", periodType, currentPeriodKey, currentCount); + return RepeatStatus.FINISHED; + } + + long previousCount = countRows(previousPeriodKey); + if (previousCount == 0L) { + log.info("MV health: previous {} 비어있음 — variance skip", previousPeriodKey); + return RepeatStatus.FINISHED; + } + double variance = Math.abs(currentCount - previousCount) / (double) previousCount; + if (variance > maxVariancePct) { + handleAnomaly(String.format( + "MV row 변동폭 초과: type=%s %s=%d %s=%d variance=%.2f%% > threshold=%.2f%%", + periodType, previousPeriodKey, previousCount, currentPeriodKey, currentCount, + variance * 100, maxVariancePct * 100)); + return RepeatStatus.FINISHED; + } + log.info("MV health ok: type={} prev={} curr={} variance={}%", + periodType, previousCount, currentCount, String.format("%.2f", variance * 100)); + return RepeatStatus.FINISHED; + } + + private long countRows(String periodKey) { + Long n = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM " + periodType.getTableName() + " WHERE period_key = ?", + Long.class, periodKey + ); + return n == null ? 0L : n; + } + + private void handleAnomaly(String msg) { + if (failOnAnomaly) { + log.error(msg); + throw new IllegalStateException(msg); + } + log.warn("{} — failOnAnomaly=false로 진행", msg); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/MvRankCleanupTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/MvRankCleanupTasklet.java new file mode 100644 index 0000000000..9b6326d861 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/MvRankCleanupTasklet.java @@ -0,0 +1,80 @@ +package com.loopers.batch.job.rank.step; + +import com.loopers.domain.rank.RankPeriodType; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +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.jdbc.core.JdbcTemplate; + +import java.util.List; + +@Slf4j +public class MvRankCleanupTasklet implements Tasklet { + + private final JdbcTemplate jdbcTemplate; + private final RankPeriodType periodType; + private final String periodKey; + private final int batchLimit; + private final MeterRegistry meterRegistry; + + public MvRankCleanupTasklet(JdbcTemplate jdbcTemplate, + RankPeriodType periodType, + String periodKey, + int batchLimit) { + this(jdbcTemplate, periodType, periodKey, batchLimit, null); + } + + public MvRankCleanupTasklet(JdbcTemplate jdbcTemplate, + RankPeriodType periodType, + String periodKey, + int batchLimit, + MeterRegistry meterRegistry) { + this.jdbcTemplate = jdbcTemplate; + this.periodType = periodType; + this.periodKey = periodKey; + this.batchLimit = batchLimit; + this.meterRegistry = meterRegistry; + } + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + List publishedVersions = jdbcTemplate.queryForList( + "SELECT published_version FROM mv_product_rank_publication WHERE period_type = ? AND period_key = ?", + Long.class, periodType.name(), periodKey + ); + Long publishedVersion = publishedVersions.isEmpty() ? null : publishedVersions.get(0); + if (publishedVersion == null || publishedVersion <= 0L) { + log.info("MV cleanup 스킵: type={}, periodKey={} — published_version 부재", + periodType, periodKey); + return RepeatStatus.FINISHED; + } + + Timer.Sample sample = meterRegistry == null ? null : Timer.start(meterRegistry); + long totalDeleted = 0L; + int deleted; + do { + deleted = jdbcTemplate.update( + "DELETE FROM " + periodType.getTableName() + + " WHERE period_key = ? AND version < ? LIMIT ?", + periodKey, publishedVersion, batchLimit + ); + totalDeleted += deleted; + } while (deleted == batchLimit); + + if (sample != null) { + sample.stop(Timer.builder("batch.rank.cleanup") + .tag("period_type", periodType.name()) + .register(meterRegistry)); + meterRegistry.counter("batch.rank.cleanup.deleted", + "period_type", periodType.name()).increment(totalDeleted); + } + + log.info("MV cleanup 완료: type={}, periodKey={}, publishedVersion={}, deleted={}", + periodType, periodKey, publishedVersion, totalDeleted); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/PublishingRankWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/PublishingRankWriter.java new file mode 100644 index 0000000000..71250a7364 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/PublishingRankWriter.java @@ -0,0 +1,167 @@ +package com.loopers.batch.job.rank.step; + +import com.loopers.domain.rank.MvProductRankPublicationRepository; +import com.loopers.domain.rank.MvProductRankRepository; +import com.loopers.domain.rank.MvProductRankRow; +import com.loopers.domain.rank.RankPeriodType; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemWriter; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +public class PublishingRankWriter implements ItemWriter, StepExecutionListener { + + private static final long UNASSIGNED = 0L; + private static final String CTX_KEY_VERSION = "publishingWriter.version"; + private static final String CTX_KEY_RANK = "publishingWriter.rankCursor"; + private static final String CTX_KEY_WRITTEN = "publishingWriter.written"; + + private final MvProductRankRepository rankRepository; + private final MvProductRankPublicationRepository publicationRepository; + private final RankPeriodType periodType; + private final String periodKey; + private final TransactionTemplate insertTx; + private final TransactionTemplate publishTx; + private final MeterRegistry meterRegistry; + private final AtomicInteger rankCounter = new AtomicInteger(0); + + private volatile long myVersion = UNASSIGNED; + private volatile long totalWritten = 0L; + private volatile ExecutionContext executionContext; + + public PublishingRankWriter(MvProductRankRepository rankRepository, + MvProductRankPublicationRepository publicationRepository, + RankPeriodType periodType, + String periodKey, + TransactionTemplate insertTx, + TransactionTemplate publishTx, + MeterRegistry meterRegistry) { + this.rankRepository = rankRepository; + this.publicationRepository = publicationRepository; + this.periodType = periodType; + this.periodKey = periodKey; + this.insertTx = insertTx; + this.publishTx = publishTx; + this.meterRegistry = meterRegistry; + } + + @Override + public void beforeStep(StepExecution stepExecution) { + this.executionContext = stepExecution.getExecutionContext(); + if (executionContext.containsKey(CTX_KEY_VERSION)) { + myVersion = executionContext.getLong(CTX_KEY_VERSION); + rankCounter.set((int) executionContext.getLong(CTX_KEY_RANK, 0L)); + totalWritten = executionContext.getLong(CTX_KEY_WRITTEN, 0L); + log.info("PublishingRankWriter restart 복원: version={} rankCursor={} written={}", + myVersion, rankCounter.get(), totalWritten); + } + } + + @Override + public void write(Chunk chunk) { + if (chunk.isEmpty()) { + return; + } + long version = ensureVersionAssigned(); + List rows = assignRanks(chunk.getItems(), version); + insertTx.executeWithoutResult(status -> rankRepository.batchInsert(periodType, rows)); + totalWritten += rows.size(); + executionContext.putLong(CTX_KEY_RANK, rankCounter.get()); + executionContext.putLong(CTX_KEY_WRITTEN, totalWritten); + } + + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + if (stepExecution.getStatus() != BatchStatus.COMPLETED) { + log.info("MV publish 스킵: status={}, written={}", stepExecution.getStatus(), totalWritten); + return stepExecution.getExitStatus(); + } + if (myVersion == UNASSIGNED) { + log.info("MV publish 스킵: 입력 없음"); + return stepExecution.getExitStatus(); + } + try { + long durationMs = timeCas(); + log.info("MV publish 완료: type={}, periodKey={}, version={}, written={}, casDurationMs={}", + periodType, periodKey, myVersion, totalWritten, durationMs); + stepExecution.getExecutionContext().putLong("mvPublishVersion", myVersion); + stepExecution.getExecutionContext().putLong("mvCasDurationMs", durationMs); + } catch (RuntimeException e) { + log.error("MV publish CAS 실패: type={}, periodKey={}, version={}, written={}", + periodType, periodKey, myVersion, totalWritten, e); + stepExecution.setStatus(BatchStatus.FAILED); + stepExecution.addFailureException(e); + return ExitStatus.FAILED; + } + return stepExecution.getExitStatus(); + } + + private long ensureVersionAssigned() { + if (myVersion != UNASSIGNED) { + return myVersion; + } + myVersion = time("batch.rank.bump", + () -> publicationRepository.bumpNextVersion(periodType, periodKey)); + executionContext.putLong(CTX_KEY_VERSION, myVersion); + log.info("PublishingRankWriter version 획득: type={} periodKey={} version={}", + periodType, periodKey, myVersion); + return myVersion; + } + + private long timeCas() { + long startNs = System.nanoTime(); + Timer.Sample sample = meterRegistry == null ? null : Timer.start(meterRegistry); + publishTx.execute(status -> publicationRepository.casPublishIfGreater(periodType, periodKey, myVersion)); + long nanos = sample == null + ? (System.nanoTime() - startNs) + : sample.stop(timer("batch.rank.cas", periodType)); + return TimeUnit.NANOSECONDS.toMillis(nanos); + } + + private T time(String metric, java.util.function.Supplier body) { + if (meterRegistry == null) { + return body.get(); + } + Timer.Sample sample = Timer.start(meterRegistry); + try { + return body.get(); + } finally { + sample.stop(timer(metric, periodType)); + } + } + + private List assignRanks(List items, long version) { + List out = new ArrayList<>(items.size()); + for (AggregatedScoreRow item : items) { + int rank = rankCounter.incrementAndGet(); + BigDecimal orderAmount = item.totalOrder() == null ? BigDecimal.ZERO : item.totalOrder(); + out.add(new MvProductRankRow( + periodKey, rank, item.productDbId(), + item.totalScore(), item.totalView(), item.totalLike(), orderAmount, + version + )); + } + return out; + } + + private Timer timer(String name, RankPeriodType type) { + return Timer.builder(name) + .tag("period_type", type.name()) + .publishPercentiles(0.5, 0.95, 0.99) + .register(meterRegistry); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/RankAggregationSql.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/RankAggregationSql.java new file mode 100644 index 0000000000..e6427969a2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/RankAggregationSql.java @@ -0,0 +1,20 @@ +package com.loopers.batch.job.rank.step; + +public final class RankAggregationSql { + + public static final String AGGREGATE_BY_DATE_RANGE = """ + SELECT sd.product_db_id, + SUM(sd.score) AS total_score, + SUM(sd.view_count) AS total_view, + SUM(sd.like_count) AS total_like, + SUM(sd.order_amount) AS total_order + FROM mv_product_score_daily sd + WHERE sd.score_date BETWEEN ? AND ? + GROUP BY sd.product_db_id + ORDER BY total_score DESC, sd.product_db_id ASC + LIMIT ? + """; + + private RankAggregationSql() { + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/RankJobFactory.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/RankJobFactory.java new file mode 100644 index 0000000000..db0da63df8 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/RankJobFactory.java @@ -0,0 +1,194 @@ +package com.loopers.batch.job.rank.step; + +import com.loopers.batch.listener.ChunkListener; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.rank.MvProductRankPublicationRepository; +import com.loopers.domain.rank.MvProductRankRepository; +import com.loopers.domain.rank.RankPeriodType; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Component +@RequiredArgsConstructor +public class RankJobFactory { + + public static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final MvProductRankRepository mvProductRankRepository; + private final MvProductRankPublicationRepository mvProductRankPublicationRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ChunkListener chunkListener; + private final MeterRegistry meterRegistry; + + @Value("${batch.rank.validation.fail-on-incomplete:false}") + private boolean failOnIncomplete; + + @Value("${batch.rank.health-check.min-rows:1}") + private long healthCheckMinRows; + + @Value("${batch.rank.health-check.max-variance-pct:0.5}") + private double healthCheckMaxVariancePct; + + @Value("${batch.rank.health-check.fail-on-anomaly:false}") + private boolean healthCheckFailOnAnomaly; + + @Value("${batch.rank.cleanup.batch-limit:1000}") + private int cleanupBatchLimit; + + @Value("${batch.rank.top-n:100}") + private int topN; + + @Value("${batch.rank.chunk-size:20}") + private int chunkSize; + + public Job buildJob(String jobName, Step step) { + return new JobBuilder(jobName, jobRepository) + .incrementer(new RunIdIncrementer()) + .listener(jobListener) + .start(step) + .build(); + } + + public Job buildJob(String jobName, Step validationStep, Step buildStep) { + return new JobBuilder(jobName, jobRepository) + .incrementer(new RunIdIncrementer()) + .listener(jobListener) + .start(validationStep) + .next(buildStep) + .build(); + } + + public Job buildJob(String jobName, Step validationStep, Step buildStep, Step healthCheckStep, Step cleanupStep) { + return new JobBuilder(jobName, jobRepository) + .incrementer(new RunIdIncrementer()) + .listener(jobListener) + .start(validationStep) + .next(buildStep) + .next(healthCheckStep) + .next(cleanupStep) + .build(); + } + + public Step buildHealthCheckStep(String stepName, + RankPeriodType periodType, + String currentPeriodKey, + String previousPeriodKey) { + return buildHealthCheckStep(stepName, periodType, currentPeriodKey, previousPeriodKey, false); + } + + public Step buildHealthCheckStep(String stepName, + RankPeriodType periodType, + String currentPeriodKey, + String previousPeriodKey, + boolean backfillMode) { + MvOutputHealthCheckTasklet tasklet = new MvOutputHealthCheckTasklet( + new JdbcTemplate(dataSource), + periodType, + currentPeriodKey, + previousPeriodKey, + healthCheckMinRows, + healthCheckMaxVariancePct, + healthCheckFailOnAnomaly, + backfillMode + ); + return new StepBuilder(stepName, jobRepository) + .tasklet(tasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + public Step buildCleanupStep(String stepName, RankPeriodType periodType, String periodKey) { + MvRankCleanupTasklet tasklet = new MvRankCleanupTasklet( + new JdbcTemplate(dataSource), + periodType, + periodKey, + cleanupBatchLimit, + meterRegistry + ); + return new StepBuilder(stepName, jobRepository) + .tasklet(tasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + public Step buildValidationStep(String stepName, LocalDate periodStart, LocalDate periodEnd) { + ScoreCompletenessTasklet tasklet = new ScoreCompletenessTasklet( + new JdbcTemplate(dataSource), periodStart, periodEnd, failOnIncomplete + ); + return new StepBuilder(stepName, jobRepository) + .tasklet(tasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + public Step buildStep(String stepName, + RankPeriodType periodType, + String periodKey, + LocalDate periodStart, + LocalDate periodEnd) { + TransactionTemplate insertTx = new TransactionTemplate(transactionManager); + insertTx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_READ_COMMITTED); + TransactionTemplate publishTx = new TransactionTemplate(transactionManager); + publishTx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_READ_COMMITTED); + PublishingRankWriter writer = new PublishingRankWriter( + mvProductRankRepository, + mvProductRankPublicationRepository, + periodType, + periodKey, + insertTx, + publishTx, + meterRegistry + ); + + return new StepBuilder(stepName, jobRepository) + .chunk(chunkSize, transactionManager) + .reader(buildReader(stepName + "Reader", periodStart, periodEnd)) + .writer(writer) + .listener(writer) + .listener(stepMonitorListener) + .listener(chunkListener) + .build(); + } + + private JdbcCursorItemReader buildReader(String name, LocalDate start, LocalDate end) { + return new JdbcCursorItemReaderBuilder() + .name(name) + .dataSource(dataSource) + .sql(RankAggregationSql.AGGREGATE_BY_DATE_RANGE) + .preparedStatementSetter(ps -> { + ps.setObject(1, start); + ps.setObject(2, end); + ps.setInt(3, topN); + }) + .rowMapper((rs, rowNum) -> new AggregatedScoreRow( + rs.getLong("product_db_id"), + rs.getDouble("total_score"), + rs.getLong("total_view"), + rs.getLong("total_like"), + rs.getBigDecimal("total_order") + )) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/ScoreCompletenessTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/ScoreCompletenessTasklet.java new file mode 100644 index 0000000000..354021584d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/ScoreCompletenessTasklet.java @@ -0,0 +1,59 @@ +package com.loopers.batch.job.rank.step; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +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.jdbc.core.JdbcTemplate; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; + +@Slf4j +public class ScoreCompletenessTasklet implements Tasklet { + + private static final String COUNT_DISTINCT_DATES_SQL = + "SELECT COUNT(DISTINCT score_date) FROM mv_product_score_daily WHERE score_date BETWEEN ? AND ?"; + + private final JdbcTemplate jdbcTemplate; + private final LocalDate periodStart; + private final LocalDate periodEnd; + private final boolean failOnIncomplete; + + public ScoreCompletenessTasklet(JdbcTemplate jdbcTemplate, + LocalDate periodStart, + LocalDate periodEnd, + boolean failOnIncomplete) { + this.jdbcTemplate = jdbcTemplate; + this.periodStart = periodStart; + this.periodEnd = periodEnd; + this.failOnIncomplete = failOnIncomplete; + } + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + long expected = ChronoUnit.DAYS.between(periodStart, periodEnd) + 1; + Long actual = jdbcTemplate.queryForObject( + COUNT_DISTINCT_DATES_SQL, Long.class, periodStart, periodEnd + ); + long actualDates = actual == null ? 0L : actual; + + if (actualDates >= expected) { + log.info("Score 완결성 검증 통과: period=[{}, {}] dates={}/{}", + periodStart, periodEnd, actualDates, expected); + return RepeatStatus.FINISHED; + } + + String msg = String.format( + "Score 완결성 부족: period=[%s, %s] dates=%d/%d (누락 %d일)", + periodStart, periodEnd, actualDates, expected, expected - actualDates + ); + if (failOnIncomplete) { + log.error(msg); + throw new IllegalStateException(msg); + } + log.warn("{} — failOnIncomplete=false로 진행", msg); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/score/DailyScoreJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/score/DailyScoreJobConfig.java new file mode 100644 index 0000000000..e72fc65844 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/score/DailyScoreJobConfig.java @@ -0,0 +1,122 @@ +package com.loopers.batch.job.score; + +import com.loopers.batch.job.score.step.DailyScoreProcessor; +import com.loopers.batch.job.score.step.MvProductScoreDailyRow; +import com.loopers.batch.listener.ChunkListener; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.ranking.ScoreCalculator; +import com.loopers.domain.signal.ProductDailySignalModel; +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.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DailyScoreJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class DailyScoreJobConfig { + + public static final String JOB_NAME = "dailyScoreJob"; + private static final String STEP_NAME = "buildDailyScoreStep"; + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final EntityManagerFactory entityManagerFactory; + private final DataSource dataSource; + private final ScoreCalculator scoreCalculator; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ChunkListener chunkListener; + + @Value("${batch.daily-score.chunk-size:5000}") + private int chunkSize; + + @Bean(JOB_NAME) + public Job dailyScoreJob(Step buildDailyScoreStep) { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .listener(jobListener) + .start(buildDailyScoreStep) + .build(); + } + + @Bean(STEP_NAME) + @JobScope + public Step buildDailyScoreStep( + @Value("#{jobParameters['date']}") String dateStr + ) { + LocalDate date = LocalDate.parse(dateStr, DATE_FMT); + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(chunkSize, transactionManager) + .reader(dailySignalReader(date)) + .processor(dailyScoreProcessor(date)) + .writer(scoreDailyWriter()) + .listener(stepMonitorListener) + .listener(chunkListener) + .build(); + } + + private JpaPagingItemReader dailySignalReader(LocalDate date) { + JpaPagingItemReader reader = new JpaPagingItemReader<>(); + reader.setEntityManagerFactory(entityManagerFactory); + reader.setQueryString( + "SELECT p FROM ProductDailySignalModel p WHERE p.signalDate = :signalDate ORDER BY p.productDbId" + ); + reader.setParameterValues(Map.of("signalDate", date)); + reader.setPageSize(chunkSize); + reader.setName("dailySignalReader"); + return reader; + } + + private ItemProcessor dailyScoreProcessor(LocalDate date) { + return new DailyScoreProcessor(scoreCalculator, date); + } + + private JdbcBatchItemWriter scoreDailyWriter() { + return new JdbcBatchItemWriterBuilder() + .dataSource(dataSource) + .sql(""" + INSERT INTO mv_product_score_daily + (product_db_id, score_date, score, view_count, like_count, order_amount, created_at, updated_at) + VALUES + (?, ?, ?, ?, ?, ?, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + score = VALUES(score), + view_count = VALUES(view_count), + like_count = VALUES(like_count), + order_amount = VALUES(order_amount), + updated_at = NOW() + """) + .itemPreparedStatementSetter((row, ps) -> { + ps.setLong(1, row.productDbId()); + ps.setObject(2, row.scoreDate()); + ps.setDouble(3, row.score()); + ps.setLong(4, row.viewCount()); + ps.setLong(5, row.likeCount()); + ps.setBigDecimal(6, row.orderAmount()); + }) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/score/step/DailyScoreProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/score/step/DailyScoreProcessor.java new file mode 100644 index 0000000000..1700f8a970 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/score/step/DailyScoreProcessor.java @@ -0,0 +1,35 @@ +package com.loopers.batch.job.score.step; + +import com.loopers.ranking.ScoreCalculator; +import com.loopers.domain.signal.ProductDailySignalModel; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.ItemProcessor; + +import java.time.LocalDate; + +@RequiredArgsConstructor +public class DailyScoreProcessor implements ItemProcessor { + + private final ScoreCalculator calculator; + private final LocalDate targetDate; + + @Override + public MvProductScoreDailyRow process(ProductDailySignalModel signal) { + double score = calculator.calculateTotal( + signal.getViewCount(), + signal.getLikeCount(), + signal.getOrderAmount() + ); + if (score <= 0.0) { + return null; + } + return new MvProductScoreDailyRow( + signal.getProductDbId(), + targetDate, + score, + signal.getViewCount(), + signal.getLikeCount(), + signal.getOrderAmount() + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/score/step/MvProductScoreDailyRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/score/step/MvProductScoreDailyRow.java new file mode 100644 index 0000000000..3694559584 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/score/step/MvProductScoreDailyRow.java @@ -0,0 +1,13 @@ +package com.loopers.batch.job.score.step; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record MvProductScoreDailyRow( + long productDbId, + LocalDate scoreDate, + double score, + long viewCount, + long likeCount, + BigDecimal orderAmount +) {} 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 index 10b09b8fcc..b7216eba7f 100644 --- 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 @@ -14,8 +14,9 @@ public class ChunkListener { @AfterChunk void afterChunk(ChunkContext chunkContext) { log.info( - "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + - "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" + "청크 종료: readCount: {}, writeCount: {}", + chunkContext.getStepContext().getStepExecution().getReadCount(), + chunkContext.getStepContext().getStepExecution().getWriteCount() ); } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java index 6686726198..4f362589c5 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -19,7 +19,7 @@ public class JobListener { @BeforeJob void beforeJob(JobExecution jobExecution) { - log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); + log.info("Job '{}' 시작", jobExecution.getJobInstance().getJobName()); jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankId.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankId.java new file mode 100644 index 0000000000..e0e9485ba7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankId.java @@ -0,0 +1,36 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class MvProductRankId implements Serializable { + + @Column(name = "period_key", nullable = false, length = 8) + private String periodKey; + + @Column(name = "version", nullable = false) + private Long version; + + @Column(name = "rank_no", nullable = false) + private Integer rankNo; + + public MvProductRankId(String periodKey, Long version, Integer rankNo) { + this.periodKey = periodKey; + this.version = version; + this.rankNo = rankNo; + } + + public MvProductRankId(String periodKey, Integer rankNo) { + this(periodKey, 1L, rankNo); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthlyModel.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthlyModel.java new file mode 100644 index 0000000000..5a4b70017b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthlyModel.java @@ -0,0 +1,49 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +@Entity +@Table( + name = "mv_product_rank_monthly", + uniqueConstraints = { + @UniqueConstraint(name = "uk_period_version_product_monthly", columnNames = {"period_key", "version", "ref_product_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankMonthlyModel { + + @EmbeddedId + private MvProductRankId id; + + @Column(name = "ref_product_id", nullable = false) + private Long refProductId; + + @Column(nullable = false) + private double score; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal orderAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationId.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationId.java new file mode 100644 index 0000000000..bdaf7cf584 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationId.java @@ -0,0 +1,28 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class MvProductRankPublicationId implements Serializable { + + @Column(name = "period_type", nullable = false, length = 20) + private String periodType; + + @Column(name = "period_key", nullable = false, length = 8) + private String periodKey; + + public MvProductRankPublicationId(String periodType, String periodKey) { + this.periodType = periodType; + this.periodKey = periodKey; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationModel.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationModel.java new file mode 100644 index 0000000000..bcbe4ba3c2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationModel.java @@ -0,0 +1,30 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "mv_product_rank_publication") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankPublicationModel { + + @EmbeddedId + private MvProductRankPublicationId id; + + @Column(name = "published_version", nullable = false) + private Long publishedVersion; + + @Column(name = "next_version", nullable = false) + private Long nextVersion; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationRepository.java new file mode 100644 index 0000000000..6846fecaa9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.rank; + +public interface MvProductRankPublicationRepository { + + long bumpNextVersion(RankPeriodType type, String periodKey); + + long findPublishedVersion(RankPeriodType type, String periodKey); + + boolean casPublishIfGreater(RankPeriodType type, String periodKey, long newVersion); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankQuarterlyModel.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankQuarterlyModel.java new file mode 100644 index 0000000000..7c9a3b93bb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankQuarterlyModel.java @@ -0,0 +1,49 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +@Entity +@Table( + name = "mv_product_rank_quarterly", + uniqueConstraints = { + @UniqueConstraint(name = "uk_period_version_product_quarterly", columnNames = {"period_key", "version", "ref_product_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankQuarterlyModel { + + @EmbeddedId + private MvProductRankId id; + + @Column(name = "ref_product_id", nullable = false) + private Long refProductId; + + @Column(nullable = false) + private double score; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal orderAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankRepository.java new file mode 100644 index 0000000000..17825e413d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.rank; + +import java.util.List; + +public interface MvProductRankRepository { + + void deleteByPeriodKey(RankPeriodType type, String periodKey); + + void batchInsert(RankPeriodType type, List rows); + + List findByPeriodKey(RankPeriodType type, String periodKey, long offset, long size); + + long countByPeriodKey(RankPeriodType type, String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankRow.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankRow.java new file mode 100644 index 0000000000..3ecb920919 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankRow.java @@ -0,0 +1,19 @@ +package com.loopers.domain.rank; + +import java.math.BigDecimal; + +public record MvProductRankRow( + String periodKey, + int rankNo, + long refProductId, + double score, + long viewCount, + long likeCount, + BigDecimal orderAmount, + long version +) { + public MvProductRankRow(String periodKey, int rankNo, long refProductId, double score, + long viewCount, long likeCount, BigDecimal orderAmount) { + this(periodKey, rankNo, refProductId, score, viewCount, likeCount, orderAmount, 1L); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeeklyModel.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeeklyModel.java new file mode 100644 index 0000000000..7edc72b5be --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeeklyModel.java @@ -0,0 +1,49 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +@Entity +@Table( + name = "mv_product_rank_weekly", + uniqueConstraints = { + @UniqueConstraint(name = "uk_period_version_product", columnNames = {"period_key", "version", "ref_product_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankWeeklyModel { + + @EmbeddedId + private MvProductRankId id; + + @Column(name = "ref_product_id", nullable = false) + private Long refProductId; + + @Column(nullable = false) + private double score; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal orderAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankPeriodType.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankPeriodType.java new file mode 100644 index 0000000000..09aee494b4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankPeriodType.java @@ -0,0 +1,17 @@ +package com.loopers.domain.rank; + +public enum RankPeriodType { + WEEKLY("mv_product_rank_weekly"), + MONTHLY("mv_product_rank_monthly"), + QUARTERLY("mv_product_rank_quarterly"); + + private final String tableName; + + RankPeriodType(String tableName) { + this.tableName = tableName; + } + + public String getTableName() { + return tableName; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyId.java b/apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyId.java new file mode 100644 index 0000000000..5160dfb5aa --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyId.java @@ -0,0 +1,29 @@ +package com.loopers.domain.score; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDate; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class MvProductScoreDailyId implements Serializable { + + @Column(name = "product_db_id", nullable = false) + private Long productDbId; + + @Column(name = "score_date", nullable = false) + private LocalDate scoreDate; + + public MvProductScoreDailyId(Long productDbId, LocalDate scoreDate) { + this.productDbId = productDbId; + this.scoreDate = scoreDate; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyModel.java b/apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyModel.java new file mode 100644 index 0000000000..019090a6f3 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyModel.java @@ -0,0 +1,53 @@ +package com.loopers.domain.score; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZonedDateTime; + +@Entity +@Table(name = "mv_product_score_daily") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductScoreDailyModel { + + @EmbeddedId + private MvProductScoreDailyId id; + + @Column(nullable = false) + private double score; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal orderAmount; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + public MvProductScoreDailyModel(Long productDbId, LocalDate scoreDate, double score, + long viewCount, long likeCount, BigDecimal orderAmount) { + this.id = new MvProductScoreDailyId(productDbId, scoreDate); + this.score = score; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.orderAmount = orderAmount; + ZonedDateTime now = ZonedDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyRepository.java new file mode 100644 index 0000000000..dc24856ecb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.score; + +import java.util.List; + +public interface MvProductScoreDailyRepository { + + void batchUpsert(List rows); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyRow.java b/apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyRow.java new file mode 100644 index 0000000000..aea102fc33 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyRow.java @@ -0,0 +1,13 @@ +package com.loopers.domain.score; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record MvProductScoreDailyRow( + long productDbId, + LocalDate scoreDate, + double score, + long viewCount, + long likeCount, + BigDecimal orderAmount +) {} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/signal/ProductDailySignalModel.java b/apps/commerce-batch/src/main/java/com/loopers/domain/signal/ProductDailySignalModel.java new file mode 100644 index 0000000000..1d36a8768b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/signal/ProductDailySignalModel.java @@ -0,0 +1,40 @@ +package com.loopers.domain.signal; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Entity +@Table( + name = "product_daily_signals", + uniqueConstraints = { + @UniqueConstraint(name = "uk_product_daily_signals_product_date", columnNames = {"product_db_id", "signal_date"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductDailySignalModel extends BaseEntity { + + @Column(name = "product_db_id", nullable = false) + private Long productDbId; + + @Column(name = "signal_date", nullable = false) + private LocalDate signalDate; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal orderAmount; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..95ce74d46f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.MvProductRankId; +import com.loopers.domain.rank.MvProductRankMonthlyModel; +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 MvProductRankMonthlyJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM MvProductRankMonthlyModel m WHERE m.id.periodKey = :periodKey") + void deleteByPeriodKey(@Param("periodKey") String periodKey); + + @Query("SELECT COUNT(m) FROM MvProductRankMonthlyModel m WHERE m.id.periodKey = :periodKey") + long countByPeriodKey(@Param("periodKey") String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankPublicationRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankPublicationRepositoryImpl.java new file mode 100644 index 0000000000..56c4a7af39 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankPublicationRepositoryImpl.java @@ -0,0 +1,51 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.MvProductRankPublicationRepository; +import com.loopers.domain.rank.RankPeriodType; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MvProductRankPublicationRepositoryImpl implements MvProductRankPublicationRepository { + + private final JdbcTemplate jdbcTemplate; + + @Override + public long bumpNextVersion(RankPeriodType type, String periodKey) { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_publication (period_type, period_key, next_version, published_version, updated_at) " + + "VALUES (?, ?, 1, 0, NOW(6)) " + + "ON DUPLICATE KEY UPDATE next_version = next_version + 1, updated_at = NOW(6)", + type.name(), periodKey + ); + Long version = jdbcTemplate.queryForObject( + "SELECT next_version FROM mv_product_rank_publication WHERE period_type = ? AND period_key = ?", + Long.class, type.name(), periodKey + ); + if (version == null) { + throw new IllegalStateException("publication row 누락: type=" + type + " periodKey=" + periodKey); + } + return version; + } + + @Override + public long findPublishedVersion(RankPeriodType type, String periodKey) { + Long v = jdbcTemplate.queryForObject( + "SELECT published_version FROM mv_product_rank_publication WHERE period_type = ? AND period_key = ?", + Long.class, type.name(), periodKey + ); + return v == null ? 0L : v; + } + + @Override + public boolean casPublishIfGreater(RankPeriodType type, String periodKey, long newVersion) { + int updated = jdbcTemplate.update( + "UPDATE mv_product_rank_publication SET published_version = ?, updated_at = NOW(6) " + + "WHERE period_type = ? AND period_key = ? AND published_version < ?", + newVersion, type.name(), periodKey, newVersion + ); + return updated > 0; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankQuarterlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankQuarterlyJpaRepository.java new file mode 100644 index 0000000000..ec64996b1a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankQuarterlyJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.MvProductRankId; +import com.loopers.domain.rank.MvProductRankQuarterlyModel; +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 MvProductRankQuarterlyJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM MvProductRankQuarterlyModel m WHERE m.id.periodKey = :periodKey") + void deleteByPeriodKey(@Param("periodKey") String periodKey); + + @Query("SELECT COUNT(m) FROM MvProductRankQuarterlyModel m WHERE m.id.periodKey = :periodKey") + long countByPeriodKey(@Param("periodKey") String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankRepositoryImpl.java new file mode 100644 index 0000000000..78fb881c21 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankRepositoryImpl.java @@ -0,0 +1,84 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.MvProductRankRepository; +import com.loopers.domain.rank.MvProductRankRow; +import com.loopers.domain.rank.RankPeriodType; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class MvProductRankRepositoryImpl implements MvProductRankRepository { + + private final JdbcTemplate jdbcTemplate; + private final MvProductRankWeeklyJpaRepository weeklyJpaRepository; + private final MvProductRankMonthlyJpaRepository monthlyJpaRepository; + private final MvProductRankQuarterlyJpaRepository quarterlyJpaRepository; + + @Override + @Transactional + public void deleteByPeriodKey(RankPeriodType type, String periodKey) { + switch (type) { + case WEEKLY -> weeklyJpaRepository.deleteByPeriodKey(periodKey); + case MONTHLY -> monthlyJpaRepository.deleteByPeriodKey(periodKey); + case QUARTERLY -> quarterlyJpaRepository.deleteByPeriodKey(periodKey); + } + } + + @Override + public void batchInsert(RankPeriodType type, List rows) { + String sql = "INSERT INTO " + type.getTableName() + + " (period_key, version, rank_no, ref_product_id, score, view_count, like_count, order_amount, created_at, updated_at)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + Timestamp now = Timestamp.from(Instant.now()); + jdbcTemplate.batchUpdate(sql, rows, rows.size(), + (ps, row) -> { + ps.setString(1, row.periodKey()); + ps.setLong(2, row.version()); + ps.setInt(3, row.rankNo()); + ps.setLong(4, row.refProductId()); + ps.setDouble(5, row.score()); + ps.setLong(6, row.viewCount()); + ps.setLong(7, row.likeCount()); + ps.setBigDecimal(8, row.orderAmount()); + ps.setTimestamp(9, now); + ps.setTimestamp(10, now); + }); + } + + @Override + public List findByPeriodKey(RankPeriodType type, String periodKey, long offset, long size) { + String sql = "SELECT period_key, version, rank_no, ref_product_id, score, view_count, like_count, order_amount" + + " FROM " + type.getTableName() + + " WHERE period_key = ? ORDER BY version DESC, rank_no ASC LIMIT ? OFFSET ?"; + + return jdbcTemplate.query(sql, + (rs, rowNum) -> new MvProductRankRow( + rs.getString("period_key"), + rs.getInt("rank_no"), + rs.getLong("ref_product_id"), + rs.getDouble("score"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getBigDecimal("order_amount"), + rs.getLong("version") + ), + periodKey, size, offset); + } + + @Override + public long countByPeriodKey(RankPeriodType type, String periodKey) { + return switch (type) { + case WEEKLY -> weeklyJpaRepository.countByPeriodKey(periodKey); + case MONTHLY -> monthlyJpaRepository.countByPeriodKey(periodKey); + case QUARTERLY -> quarterlyJpaRepository.countByPeriodKey(periodKey); + }; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..b2810bc233 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.MvProductRankId; +import com.loopers.domain.rank.MvProductRankWeeklyModel; +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 MvProductRankWeeklyJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM MvProductRankWeeklyModel m WHERE m.id.periodKey = :periodKey") + void deleteByPeriodKey(@Param("periodKey") String periodKey); + + @Query("SELECT COUNT(m) FROM MvProductRankWeeklyModel m WHERE m.id.periodKey = :periodKey") + long countByPeriodKey(@Param("periodKey") String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/score/MvProductScoreDailyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/score/MvProductScoreDailyJpaRepository.java new file mode 100644 index 0000000000..da6410f464 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/score/MvProductScoreDailyJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.score; + +import com.loopers.domain.score.MvProductScoreDailyId; +import com.loopers.domain.score.MvProductScoreDailyModel; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MvProductScoreDailyJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/score/MvProductScoreDailyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/score/MvProductScoreDailyRepositoryImpl.java new file mode 100644 index 0000000000..73ddf226c9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/score/MvProductScoreDailyRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.score; + +import com.loopers.domain.score.MvProductScoreDailyRepository; +import com.loopers.domain.score.MvProductScoreDailyRow; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class MvProductScoreDailyRepositoryImpl implements MvProductScoreDailyRepository { + + private static final String UPSERT_SQL = """ + INSERT INTO mv_product_score_daily + (product_db_id, score_date, score, view_count, like_count, order_amount, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + score = VALUES(score), + view_count = VALUES(view_count), + like_count = VALUES(like_count), + order_amount = VALUES(order_amount), + updated_at = VALUES(updated_at) + """; + + private final JdbcTemplate jdbcTemplate; + + @Override + public void batchUpsert(List rows) { + Timestamp now = Timestamp.from(Instant.now()); + jdbcTemplate.batchUpdate(UPSERT_SQL, rows, rows.size(), + (ps, row) -> { + ps.setLong(1, row.productDbId()); + ps.setObject(2, row.scoreDate()); + ps.setDouble(3, row.score()); + ps.setLong(4, row.viewCount()); + ps.setLong(5, row.likeCount()); + ps.setBigDecimal(6, row.orderAmount()); + ps.setTimestamp(7, now); + ps.setTimestamp(8, now); + }); + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml index 9aa0d760a6..50a05ff202 100644 --- a/apps/commerce-batch/src/main/resources/application.yml +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -17,6 +17,25 @@ spring: jdbc: initialize-schema: never +ranking: + weight: + view: 0.1 + like: 0.2 + order: 0.7 + +batch: + rank: + top-n: 100 + chunk-size: 20 + validation: + fail-on-incomplete: false + health-check: + min-rows: 1 + max-variance-pct: 0.5 + fail-on-anomaly: false + cleanup: + batch-limit: 1000 + management: health: defaults: diff --git a/apps/commerce-batch/src/main/resources/schema-batch.sql b/apps/commerce-batch/src/main/resources/schema-batch.sql new file mode 100644 index 0000000000..d2d86cb74b --- /dev/null +++ b/apps/commerce-batch/src/main/resources/schema-batch.sql @@ -0,0 +1,71 @@ +-- MV: Phase 1 산출물 — 일별 상품 점수 +CREATE TABLE IF NOT EXISTS mv_product_score_daily ( + product_db_id BIGINT NOT NULL, + score_date DATE NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + order_amount DECIMAL(15,2) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (product_db_id, score_date), + INDEX idx_score_date (score_date) +); + +-- MV: Phase 2 산출물 — 주간 랭킹 TOP 100 (S2 version-based) +CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( + period_key VARCHAR(8) NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + rank_no INT NOT NULL, + ref_product_id BIGINT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + order_amount DECIMAL(15,2) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (period_key, version, rank_no), + UNIQUE KEY uk_period_version_product (period_key, version, ref_product_id) +); + +-- MV: Phase 2 산출물 — 월간 랭킹 TOP 100 (S2 version-based) +CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( + period_key VARCHAR(8) NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + rank_no INT NOT NULL, + ref_product_id BIGINT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + order_amount DECIMAL(15,2) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (period_key, version, rank_no), + UNIQUE KEY uk_period_version_product_monthly (period_key, version, ref_product_id) +); + +-- MV: Phase 2 산출물 — 3개월 롤링 랭킹 TOP 100 (version-based) +CREATE TABLE IF NOT EXISTS mv_product_rank_quarterly ( + period_key VARCHAR(8) NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + rank_no INT NOT NULL, + ref_product_id BIGINT NOT NULL, + score DOUBLE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + order_amount DECIMAL(15,2) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (period_key, version, rank_no), + UNIQUE KEY uk_period_version_product_quarterly (period_key, version, ref_product_id) +); + +-- Publication pointer — periodKey별 current published_version 추적 (S2 원자 발행) +CREATE TABLE IF NOT EXISTS mv_product_rank_publication ( + period_type VARCHAR(20) NOT NULL, + period_key VARCHAR(8) NOT NULL, + published_version BIGINT NOT NULL DEFAULT 0, + next_version BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (period_type, period_key) +); diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/FullPipelineE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/FullPipelineE2ETest.java new file mode 100644 index 0000000000..57cba6314c --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/FullPipelineE2ETest.java @@ -0,0 +1,184 @@ +package com.loopers.batch.job; + +import com.loopers.batch.job.rank.WeeklyRankJobConfig; +import com.loopers.domain.rank.MvProductRankRepository; +import com.loopers.domain.rank.MvProductRankRow; +import com.loopers.domain.rank.RankPeriodType; +import com.loopers.ranking.ScoreCalculator; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = { + "spring.batch.job.name=" + WeeklyRankJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class FullPipelineE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(WeeklyRankJobConfig.JOB_NAME) + private Job weeklyRankJob; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private MvProductRankRepository mvProductRankRepository; + + @Autowired + private ScoreCalculator rankScoreCalculator; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(weeklyRankJob); + databaseCleanUp.truncateAllTables(); + cleanBatchMetaTables(); + } + + private void cleanBatchMetaTables() { + jdbcTemplate.execute("DELETE FROM BATCH_STEP_EXECUTION_CONTEXT"); + jdbcTemplate.execute("DELETE FROM BATCH_STEP_EXECUTION"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION_CONTEXT"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION_PARAMS"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_INSTANCE"); + } + + @DisplayName("E2E: score_daily 시드 → weeklyRankJob → MV TOP 10") + @Test + void fullPipeline() throws Exception { + LocalDate monday = LocalDate.of(2026, 4, 6); + for (int day = 0; day < 7; day++) { + LocalDate date = monday.plusDays(day); + for (long productId = 1; productId <= 10; productId++) { + long view = productId * 10; + long like = productId * 5; + BigDecimal order = BigDecimal.valueOf(productId * 100); + double score = rankScoreCalculator.calculateTotal(view, like, order); + insertScoreDaily(productId, date, score, view, like, order); + } + } + + JobExecution execution = jobLauncherTestUtils.launchJob(dateParams("20260408")); + + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + List results = mvProductRankRepository.findByPeriodKey( + RankPeriodType.WEEKLY, "2026W15", 0, 100); + assertThat(results).hasSize(10); + assertThat(results.get(0).rankNo()).isEqualTo(1); + assertThat(results.get(0).refProductId()).isEqualTo(10L); + } + + @DisplayName("알고리즘 변경 시뮬레이션: score UPDATE 후 재실행 → 순위 역전") + @Test + void algorithmChangeSimulation() throws Exception { + LocalDate date = LocalDate.of(2026, 4, 6); + insertScoreDaily(1L, date, 100.0, 1000, 0, BigDecimal.ZERO); + insertScoreDaily(2L, date, 7000.0, 0, 0, BigDecimal.valueOf(10000)); + + jobLauncherTestUtils.launchJob(runParams("20260408", 1L)); + List before = mvProductRankRepository.findByPeriodKey( + RankPeriodType.WEEKLY, "2026W15", 0, 10); + assertThat(before.get(0).refProductId()).isEqualTo(2L); + + jdbcTemplate.update("UPDATE mv_product_score_daily SET score = 900.0 WHERE product_db_id = 1"); + jdbcTemplate.update("UPDATE mv_product_score_daily SET score = 500.0 WHERE product_db_id = 2"); + + cleanBatchMetaTables(); + jobLauncherTestUtils.launchJob(runParams("20260408", 2L)); + + List after = mvProductRankRepository.findByPeriodKey( + RankPeriodType.WEEKLY, "2026W15", 0, 10); + assertThat(after.get(0).refProductId()).isEqualTo(1L); + assertThat(after.get(0).score()).isCloseTo(900.0, within(0.01)); + } + + @DisplayName("score_daily 빈 상태 → MV 빈 상태, COMPLETED") + @Test + void weeklyRankWithoutDailyScore() throws Exception { + JobExecution execution = jobLauncherTestUtils.launchJob(dateParams("20260408")); + + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + assertThat(mvProductRankRepository.countByPeriodKey(RankPeriodType.WEEKLY, "2026W15")).isZero(); + } + + @DisplayName("대량 데이터: 1000상품 × 7일 → TOP 100 정확 적재") + @Test + void largeDataset_top100() throws Exception { + LocalDate monday = LocalDate.of(2026, 4, 6); + seedScoreDaily(1000, 7, monday); + + JobExecution execution = jobLauncherTestUtils.launchJob(dateParams("20260408")); + + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + List results = mvProductRankRepository.findByPeriodKey( + RankPeriodType.WEEKLY, "2026W15", 0, 200); + assertThat(results).hasSize(100); + assertThat(results.get(0).rankNo()).isEqualTo(1); + assertThat(results.get(0).refProductId()).isEqualTo(1000L); + } + + private JobParameters dateParams(String date) { + return new JobParametersBuilder().addString("date", date).toJobParameters(); + } + + private JobParameters runParams(String date, long runId) { + return new JobParametersBuilder() + .addString("date", date) + .addLong("run.id", runId) + .toJobParameters(); + } + + private void insertScoreDaily(Long productDbId, LocalDate date, double score, + long viewCount, long likeCount, BigDecimal orderAmount) { + jdbcTemplate.update( + "INSERT INTO mv_product_score_daily (product_db_id, score_date, score, view_count, like_count, order_amount, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())", + productDbId, date, score, viewCount, likeCount, orderAmount + ); + } + + private void seedScoreDaily(int productCount, int days, LocalDate startDate) { + StringBuilder sb = new StringBuilder(); + sb.append("INSERT INTO mv_product_score_daily (product_db_id, score_date, score, view_count, like_count, order_amount, created_at, updated_at) VALUES "); + boolean first = true; + for (int day = 0; day < days; day++) { + LocalDate date = startDate.plusDays(day); + for (int productId = 1; productId <= productCount; productId++) { + if (!first) sb.append(","); + first = false; + double score = productId * 10.0 + day; + sb.append(String.format("(%d,'%s',%.1f,%d,%d,%.2f,NOW(),NOW())", + productId, date, score, productId * 10L, productId * 5L, productId * 100.0)); + } + } + jdbcTemplate.execute(sb.toString()); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/MonthlyRankJobIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/MonthlyRankJobIntegrationTest.java new file mode 100644 index 0000000000..b86766d795 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/MonthlyRankJobIntegrationTest.java @@ -0,0 +1,145 @@ +package com.loopers.batch.job.rank; + +import com.loopers.domain.rank.MvProductRankRepository; +import com.loopers.domain.rank.MvProductRankRow; +import com.loopers.domain.rank.RankPeriodType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = { + "spring.batch.job.name=" + MonthlyRankJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class MonthlyRankJobIntegrationTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(MonthlyRankJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private MvProductRankRepository mvProductRankRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + databaseCleanUp.truncateAllTables(); + cleanBatchMetaTables(); + } + + private void cleanBatchMetaTables() { + jdbcTemplate.execute("DELETE FROM BATCH_STEP_EXECUTION_CONTEXT"); + jdbcTemplate.execute("DELETE FROM BATCH_STEP_EXECUTION"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION_CONTEXT"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION_PARAMS"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_INSTANCE"); + } + + @DisplayName("정상 실행: 30일 × 3상품의 월간 랭킹이 MV에 적재된다") + @Test + void normalExecution() throws Exception { + // arrange — 2026년 4월 (1일~30일) + for (int day = 1; day <= 30; day++) { + LocalDate date = LocalDate.of(2026, 4, day); + for (long productId = 1; productId <= 3; productId++) { + insertScoreDaily(productId, date, productId * 10.0, productId, productId, BigDecimal.valueOf(productId * 100)); + } + } + + JobParameters params = new JobParametersBuilder() + .addString("date", "20260415") + .toJobParameters(); + + // act + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + String periodKey = "202604"; + List results = mvProductRankRepository.findByPeriodKey(RankPeriodType.MONTHLY, periodKey, 0, 100); + assertThat(results).hasSize(3); + assertThat(results.get(0).rankNo()).isEqualTo(1); + assertThat(results.get(0).refProductId()).isEqualTo(3L); + assertThat(results.get(0).score()).isCloseTo(3 * 10.0 * 30, within(0.01)); + } + + @DisplayName("월 경계: 3월 데이터와 4월 데이터가 분리된다") + @Test + void monthBoundary() throws Exception { + // arrange + insertScoreDaily(1L, LocalDate.of(2026, 3, 31), 999.0, 99, 99, BigDecimal.valueOf(9999)); + insertScoreDaily(1L, LocalDate.of(2026, 4, 1), 100.0, 10, 5, BigDecimal.valueOf(1000)); + + JobParameters params = new JobParametersBuilder() + .addString("date", "20260415") + .toJobParameters(); + + // act + jobLauncherTestUtils.launchJob(params); + + // assert — 4월 데이터만 집계 + List results = mvProductRankRepository.findByPeriodKey(RankPeriodType.MONTHLY, "202604", 0, 10); + assertThat(results).hasSize(1); + assertThat(results.get(0).score()).isCloseTo(100.0, within(0.01)); + } + + @DisplayName("윤년 2월: monthEnd가 29일이다") + @Test + void leapYearFebruary() throws Exception { + // arrange — 2028년 2월 (윤년) + insertScoreDaily(1L, LocalDate.of(2028, 2, 28), 100.0, 10, 5, BigDecimal.valueOf(1000)); + insertScoreDaily(1L, LocalDate.of(2028, 2, 29), 200.0, 20, 10, BigDecimal.valueOf(2000)); + + JobParameters params = new JobParametersBuilder() + .addString("date", "20280215") + .toJobParameters(); + + // act + jobLauncherTestUtils.launchJob(params); + + // assert — 28일 + 29일 모두 포함 + List results = mvProductRankRepository.findByPeriodKey(RankPeriodType.MONTHLY, "202802", 0, 10); + assertThat(results).hasSize(1); + assertThat(results.get(0).score()).isCloseTo(300.0, within(0.01)); + } + + private void insertScoreDaily(Long productDbId, LocalDate date, double score, long viewCount, long likeCount, BigDecimal orderAmount) { + jdbcTemplate.update( + "INSERT INTO mv_product_score_daily (product_db_id, score_date, score, view_count, like_count, order_amount, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())", + productDbId, date, score, viewCount, likeCount, orderAmount + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/QuarterlyRankJobIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/QuarterlyRankJobIntegrationTest.java new file mode 100644 index 0000000000..bd10b650be --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/QuarterlyRankJobIntegrationTest.java @@ -0,0 +1,176 @@ +package com.loopers.batch.job.rank; + +import com.loopers.domain.rank.MvProductRankRepository; +import com.loopers.domain.rank.MvProductRankRow; +import com.loopers.domain.rank.RankPeriodType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = { + "spring.batch.job.name=" + QuarterlyRankJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class QuarterlyRankJobIntegrationTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(QuarterlyRankJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private MvProductRankRepository mvProductRankRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + databaseCleanUp.truncateAllTables(); + cleanBatchMetaTables(); + } + + private void cleanBatchMetaTables() { + jdbcTemplate.execute("DELETE FROM BATCH_STEP_EXECUTION_CONTEXT"); + jdbcTemplate.execute("DELETE FROM BATCH_STEP_EXECUTION"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION_CONTEXT"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION_PARAMS"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_INSTANCE"); + } + + @DisplayName("Approach A 정상 실행: 90일 × 3상품의 분기 랭킹이 MV에 적재된다") + @Test + void normalExecution() throws Exception { + LocalDate endDate = LocalDate.of(2026, 4, 16); + LocalDate startDate = endDate.minusDays(89); + + for (int day = 0; day < 90; day++) { + LocalDate date = startDate.plusDays(day); + for (long productId = 1; productId <= 3; productId++) { + insertScoreDaily(productId, date, productId * 10.0, productId, productId, BigDecimal.valueOf(productId * 100)); + } + } + + JobParameters params = new JobParametersBuilder() + .addString("date", "20260416") + .toJobParameters(); + + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + String periodKey = "20260416"; + List results = mvProductRankRepository.findByPeriodKey(RankPeriodType.QUARTERLY, periodKey, 0, 100); + assertThat(results).hasSize(3); + assertThat(results.get(0).rankNo()).isEqualTo(1); + assertThat(results.get(0).refProductId()).isEqualTo(3L); + assertThat(results.get(0).score()).isCloseTo(3 * 10.0 * 90, within(0.01)); + } + + @DisplayName("Approach A 롤링 윈도우: 91일 전 데이터는 제외된다") + @Test + void rollingWindowExclusion() throws Exception { + LocalDate endDate = LocalDate.of(2026, 4, 16); + + insertScoreDaily(1L, endDate.minusDays(90), 999.0, 99, 99, BigDecimal.valueOf(9999)); + insertScoreDaily(1L, endDate.minusDays(89), 100.0, 10, 5, BigDecimal.valueOf(1000)); + insertScoreDaily(2L, endDate, 50.0, 5, 2, BigDecimal.valueOf(500)); + + JobParameters params = new JobParametersBuilder() + .addString("date", "20260416") + .toJobParameters(); + + jobLauncherTestUtils.launchJob(params); + + List results = mvProductRankRepository.findByPeriodKey(RankPeriodType.QUARTERLY, "20260416", 0, 10); + assertThat(results).hasSize(2); + assertThat(results.get(0).refProductId()).isEqualTo(1L); + assertThat(results.get(0).score()).isCloseTo(100.0, within(0.01)); + } + + @DisplayName("Approach A 멱등성: 2회 실행 후 version만 증가하고 product·score는 동일") + @Test + void idempotency() throws Exception { + LocalDate endDate = LocalDate.of(2026, 4, 16); + for (int day = 0; day < 7; day++) { + insertScoreDaily(1L, endDate.minusDays(day), 100.0, 10, 5, BigDecimal.valueOf(1000)); + } + double expectedScore = 100.0 * 7; + + JobParameters params1 = new JobParametersBuilder() + .addString("date", "20260416") + .toJobParameters(); + jobLauncherTestUtils.launchJob(params1); + + List afterRun1 = mvProductRankRepository.findByPeriodKey(RankPeriodType.QUARTERLY, "20260416", 0, 10); + assertThat(afterRun1).hasSize(1); + assertThat(afterRun1.get(0).refProductId()).isEqualTo(1L); + assertThat(afterRun1.get(0).score()).isCloseTo(expectedScore, within(0.01)); + long versionAfterRun1 = afterRun1.get(0).version(); + + cleanBatchMetaTables(); + + JobParameters params2 = new JobParametersBuilder() + .addString("date", "20260416") + .toJobParameters(); + jobLauncherTestUtils.launchJob(params2); + + List afterRun2 = mvProductRankRepository.findByPeriodKey(RankPeriodType.QUARTERLY, "20260416", 0, 10); + assertThat(afterRun2).hasSize(2); + assertThat(afterRun2).extracting(MvProductRankRow::refProductId).containsOnly(1L); + assertThat(afterRun2).extracting(MvProductRankRow::score) + .allSatisfy(score -> assertThat(score).isCloseTo(expectedScore, within(0.01))); + assertThat(afterRun2).extracting(MvProductRankRow::version) + .anyMatch(v -> v > versionAfterRun1); + } + + @DisplayName("Approach A 빈 데이터: 스코어 없으면 COMPLETED, MV 0행") + @Test + void emptyData() throws Exception { + JobParameters params = new JobParametersBuilder() + .addString("date", "20260416") + .toJobParameters(); + + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + long count = mvProductRankRepository.countByPeriodKey(RankPeriodType.QUARTERLY, "20260416"); + assertThat(count).isZero(); + } + + private void insertScoreDaily(Long productDbId, LocalDate date, double score, long viewCount, long likeCount, BigDecimal orderAmount) { + jdbcTemplate.update( + "INSERT INTO mv_product_score_daily (product_db_id, score_date, score, view_count, like_count, order_amount, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())", + productDbId, date, score, viewCount, likeCount, orderAmount + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/WeeklyRankJobIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/WeeklyRankJobIntegrationTest.java new file mode 100644 index 0000000000..9b4d965c40 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/WeeklyRankJobIntegrationTest.java @@ -0,0 +1,193 @@ +package com.loopers.batch.job.rank; + +import com.loopers.domain.rank.MvProductRankRepository; +import com.loopers.domain.rank.MvProductRankRow; +import com.loopers.domain.rank.RankPeriodType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = { + "spring.batch.job.name=" + WeeklyRankJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class WeeklyRankJobIntegrationTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(WeeklyRankJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private MvProductRankRepository mvProductRankRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + databaseCleanUp.truncateAllTables(); + cleanBatchMetaTables(); + } + + private void cleanBatchMetaTables() { + jdbcTemplate.execute("DELETE FROM BATCH_STEP_EXECUTION_CONTEXT"); + jdbcTemplate.execute("DELETE FROM BATCH_STEP_EXECUTION"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION_CONTEXT"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION_PARAMS"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_INSTANCE"); + } + + @DisplayName("정상 실행: 7일 × 5상품의 주간 랭킹이 MV에 적재된다") + @Test + void normalExecution() throws Exception { + // arrange — 2026-04-06(월) ~ 2026-04-12(일) = 2026W16 + LocalDate monday = LocalDate.of(2026, 4, 6); + for (int day = 0; day < 7; day++) { + LocalDate date = monday.plusDays(day); + for (long productId = 1; productId <= 5; productId++) { + insertScoreDaily(productId, date, productId * 100.0, productId * 10, productId * 5, BigDecimal.valueOf(productId * 1000)); + } + } + + JobParameters params = new JobParametersBuilder() + .addString("date", "20260408") + .toJobParameters(); + + // act + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + String periodKey = "2026W15"; + List results = mvProductRankRepository.findByPeriodKey(RankPeriodType.WEEKLY, periodKey, 0, 100); + assertThat(results).hasSize(5); + assertThat(results.get(0).rankNo()).isEqualTo(1); + assertThat(results.get(0).refProductId()).isEqualTo(5L); + assertThat(results.get(0).score()).isCloseTo(5 * 100.0 * 7, within(0.01)); + } + + @DisplayName("멱등성: 같은 date 2회 실행 시 결과 동일") + @Test + void idempotency() throws Exception { + // arrange + insertScoreDaily(1L, LocalDate.of(2026, 4, 6), 100.0, 10, 5, BigDecimal.valueOf(1000)); + + JobParameters params1 = new JobParametersBuilder() + .addString("date", "20260408") + .addLong("run.id", 1L) + .toJobParameters(); + JobParameters params2 = new JobParametersBuilder() + .addString("date", "20260408") + .addLong("run.id", 2L) + .toJobParameters(); + + // act + jobLauncherTestUtils.launchJob(params1); + JobExecution execution2 = jobLauncherTestUtils.launchJob(params2); + + // assert + assertThat(execution2.getStatus()).isEqualTo(BatchStatus.COMPLETED); + assertThat(mvProductRankRepository.countByPeriodKey(RankPeriodType.WEEKLY, "2026W15")) + .as("A2 적용 후: cleanup이 별도 mvRankCleanupJob로 분리 — Weekly Job 2회 실행 시 두 version 공존").isEqualTo(2); + } + + @DisplayName("score_daily가 비어있으면 MV 0행, COMPLETED") + @Test + void emptyScoreDaily() throws Exception { + // arrange + JobParameters params = new JobParametersBuilder() + .addString("date", "20260408") + .toJobParameters(); + + // act + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + assertThat(mvProductRankRepository.countByPeriodKey(RankPeriodType.WEEKLY, "2026W15")).isZero(); + } + + @DisplayName("deleteByPeriodKey는 다른 period_key의 행을 건드리지 않는다") + @Test + void cleanupIsolation() throws Exception { + // arrange — W15 데이터를 미리 적재 + List week15 = List.of( + new MvProductRankRow("2026W15", 1, 99L, 9999.0, 999, 999, BigDecimal.valueOf(9999)) + ); + mvProductRankRepository.batchInsert(RankPeriodType.WEEKLY, week15); + + // W16 배치 실행 + insertScoreDaily(1L, LocalDate.of(2026, 4, 6), 100.0, 10, 5, BigDecimal.valueOf(1000)); + JobParameters params = new JobParametersBuilder() + .addString("date", "20260408") + .toJobParameters(); + + // act + jobLauncherTestUtils.launchJob(params); + + // assert — W15 데이터 유지됨 + assertThat(mvProductRankRepository.countByPeriodKey(RankPeriodType.WEEKLY, "2026W15")).isEqualTo(1); + assertThat(mvProductRankRepository.countByPeriodKey(RankPeriodType.WEEKLY, "2026W15")).isEqualTo(1); + } + + @DisplayName("Reader SQL에 가중치 파라미터가 없음을 검증 — SUM(score)만 사용") + @Test + void readerHasNoWeightParameters() throws Exception { + // arrange — 같은 score로 시드하고 결과의 score가 단순 SUM인지 검증 + LocalDate date1 = LocalDate.of(2026, 4, 6); + LocalDate date2 = LocalDate.of(2026, 4, 7); + insertScoreDaily(1L, date1, 100.0, 10, 5, BigDecimal.valueOf(1000)); + insertScoreDaily(1L, date2, 200.0, 20, 10, BigDecimal.valueOf(2000)); + + JobParameters params = new JobParametersBuilder() + .addString("date", "20260408") + .toJobParameters(); + + // act + jobLauncherTestUtils.launchJob(params); + + // assert — score = SUM(100 + 200) = 300, 가중치 재계산 아님 + List results = mvProductRankRepository.findByPeriodKey(RankPeriodType.WEEKLY, "2026W15", 0, 10); + assertThat(results).hasSize(1); + assertThat(results.get(0).score()).isCloseTo(300.0, within(0.01)); + assertThat(results.get(0).viewCount()).isEqualTo(30); + } + + private void insertScoreDaily(Long productDbId, LocalDate date, double score, long viewCount, long likeCount, BigDecimal orderAmount) { + jdbcTemplate.update( + "INSERT INTO mv_product_score_daily (product_db_id, score_date, score, view_count, like_count, order_amount, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())", + productDbId, date, score, viewCount, likeCount, orderAmount + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/MvOutputHealthCheckTaskletTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/MvOutputHealthCheckTaskletTest.java new file mode 100644 index 0000000000..d042740b98 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/MvOutputHealthCheckTaskletTest.java @@ -0,0 +1,92 @@ +package com.loopers.batch.job.rank.step; + +import com.loopers.domain.rank.MvProductRankRepository; +import com.loopers.domain.rank.MvProductRankRow; +import com.loopers.domain.rank.RankPeriodType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") +@DisplayName("MV 출력 헬스체크 Tasklet") +class MvOutputHealthCheckTaskletTest { + + @Autowired MvProductRankRepository repository; + @Autowired JdbcTemplate jdbc; + @Autowired DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("row 수가 min 미만이면 FAILED 전파") + @Test + void rowsBelowMin_throws() { + repository.batchInsert(RankPeriodType.WEEKLY, buildRows("2026W15", 5)); + MvOutputHealthCheckTasklet tasklet = new MvOutputHealthCheckTasklet( + jdbc, RankPeriodType.WEEKLY, "2026W15", null, 10L, 0.5, true + ); + assertThatThrownBy(() -> tasklet.execute(null, null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("MV row 부족"); + } + + @DisplayName("전주 대비 변동폭이 임계 초과 시 FAILED 전파") + @Test + void varianceExceedsThreshold_throws() { + repository.batchInsert(RankPeriodType.WEEKLY, buildRows("2026W14", 100)); + repository.batchInsert(RankPeriodType.WEEKLY, buildRows("2026W15", 20)); + MvOutputHealthCheckTasklet tasklet = new MvOutputHealthCheckTasklet( + jdbc, RankPeriodType.WEEKLY, "2026W15", "2026W14", 10L, 0.5, true + ); + assertThatThrownBy(() -> tasklet.execute(null, null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("변동폭 초과"); + } + + @DisplayName("정상 범위에서 통과") + @Test + void normalRange_passes() { + repository.batchInsert(RankPeriodType.WEEKLY, buildRows("2026W14", 100)); + repository.batchInsert(RankPeriodType.WEEKLY, buildRows("2026W15", 95)); + MvOutputHealthCheckTasklet tasklet = new MvOutputHealthCheckTasklet( + jdbc, RankPeriodType.WEEKLY, "2026W15", "2026W14", 10L, 0.5, true + ); + assertThatCode(() -> tasklet.execute(null, null)).doesNotThrowAnyException(); + } + + @DisplayName("failOnAnomaly=false면 예외 없이 경고 로그") + @Test + void failFalse_warnsOnly() { + repository.batchInsert(RankPeriodType.WEEKLY, buildRows("2026W15", 5)); + MvOutputHealthCheckTasklet tasklet = new MvOutputHealthCheckTasklet( + jdbc, RankPeriodType.WEEKLY, "2026W15", null, 10L, 0.5, false + ); + assertThatCode(() -> tasklet.execute(null, null)).doesNotThrowAnyException(); + } + + private List buildRows(String periodKey, int count) { + List rows = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + rows.add(new MvProductRankRow( + periodKey, i + 1, (long) (i + 1), (double) (count - i), + 10L, 5L, BigDecimal.valueOf(100) + )); + } + return rows; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/MvRankCleanupTaskletTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/MvRankCleanupTaskletTest.java new file mode 100644 index 0000000000..fd1c62a23d --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/MvRankCleanupTaskletTest.java @@ -0,0 +1,122 @@ +package com.loopers.batch.job.rank.step; + +import com.loopers.domain.rank.MvProductRankPublicationRepository; +import com.loopers.domain.rank.MvProductRankRepository; +import com.loopers.domain.rank.MvProductRankRow; +import com.loopers.domain.rank.RankPeriodType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") +@DisplayName("MV Cleanup Tasklet — version < published_version 고아 삭제") +class MvRankCleanupTaskletTest { + + private static final String KEY = "2026W15"; + + @Autowired MvProductRankRepository rankRepository; + @Autowired MvProductRankPublicationRepository publicationRepository; + @Autowired JdbcTemplate jdbc; + @Autowired DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("published_version 미만 행만 삭제, published_version 유지") + @Test + void deletesOrphansOnly() { + rankRepository.batchInsert(RankPeriodType.WEEKLY, buildRows(10, 1L)); + rankRepository.batchInsert(RankPeriodType.WEEKLY, buildRows(10, 2L)); + rankRepository.batchInsert(RankPeriodType.WEEKLY, buildRows(10, 3L)); + seedPublication(3L, 3L); + + MvRankCleanupTasklet tasklet = new MvRankCleanupTasklet( + jdbc, RankPeriodType.WEEKLY, KEY, 1000 + ); + tasklet.execute(null, null); + + Integer remainingRows = jdbc.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key=?", + Integer.class, KEY + ); + assertThat(remainingRows).as("published version(3)만 남음").isEqualTo(10); + + Integer orphanRows = jdbc.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key=? AND version < 3", + Integer.class, KEY + ); + assertThat(orphanRows).isZero(); + } + + @DisplayName("published_version 부재 시 no-op") + @Test + void noPublishedVersion_noop() { + rankRepository.batchInsert(RankPeriodType.WEEKLY, buildRows(5, 1L)); + + MvRankCleanupTasklet tasklet = new MvRankCleanupTasklet( + jdbc, RankPeriodType.WEEKLY, KEY, 1000 + ); + tasklet.execute(null, null); + + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key=?", + Integer.class, KEY + ); + assertThat(count).isEqualTo(5); + } + + @DisplayName("batchLimit보다 많은 orphan — 반복 삭제로 전량 제거") + @Test + void manyOrphans_repeatedDeletionClearsAll() { + rankRepository.batchInsert(RankPeriodType.WEEKLY, buildRows(100, 1L)); + rankRepository.batchInsert(RankPeriodType.WEEKLY, buildRows(100, 2L)); + seedPublication(2L, 2L); + + MvRankCleanupTasklet tasklet = new MvRankCleanupTasklet( + jdbc, RankPeriodType.WEEKLY, KEY, 30 + ); + tasklet.execute(null, null); + + Integer orphanRows = jdbc.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key=? AND version < 2", + Integer.class, KEY + ); + assertThat(orphanRows).isZero(); + Integer remainingRows = jdbc.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key=?", + Integer.class, KEY + ); + assertThat(remainingRows).isEqualTo(100); + } + + private List buildRows(int count, long version) { + List rows = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + rows.add(new MvProductRankRow( + KEY, i + 1, (version * 10_000L) + i + 1, + (double) (count - i), 10L, 5L, BigDecimal.valueOf(100), version + )); + } + return rows; + } + + private void seedPublication(long nextV, long publishedV) { + jdbc.update("INSERT INTO mv_product_rank_publication " + + "(period_type, period_key, next_version, published_version, updated_at) " + + "VALUES (?, ?, ?, ?, NOW(6))", "WEEKLY", KEY, nextV, publishedV); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/ScoreCompletenessTaskletTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/ScoreCompletenessTaskletTest.java new file mode 100644 index 0000000000..4e1a7fb486 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/ScoreCompletenessTaskletTest.java @@ -0,0 +1,90 @@ +package com.loopers.batch.job.rank.step; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("ScoreCompletenessTasklet") +class ScoreCompletenessTaskletTest { + + private JdbcTemplate jdbcTemplate; + private final LocalDate start = LocalDate.of(2026, 4, 7); + private final LocalDate end = LocalDate.of(2026, 4, 13); + + @BeforeEach + void setUp() { + jdbcTemplate = mock(JdbcTemplate.class); + } + + @Test + @DisplayName("기간 내 모든 날짜의 score 데이터가 존재하면 FINISHED 반환") + void allDatesPresent_returnsFinished() { + when(jdbcTemplate.queryForObject(any(String.class), eq(Long.class), eq(start), eq(end))) + .thenReturn(7L); + ScoreCompletenessTasklet tasklet = new ScoreCompletenessTasklet(jdbcTemplate, start, end, true); + + RepeatStatus result = tasklet.execute(null, null); + + assertThat(result).isEqualTo(RepeatStatus.FINISHED); + } + + @Test + @DisplayName("일부 날짜 누락 + failOnIncomplete=true → IllegalStateException") + void partialDatesMissing_failModeOn_throws() { + when(jdbcTemplate.queryForObject(any(String.class), eq(Long.class), eq(start), eq(end))) + .thenReturn(5L); + ScoreCompletenessTasklet tasklet = new ScoreCompletenessTasklet(jdbcTemplate, start, end, true); + + assertThatThrownBy(() -> tasklet.execute(null, null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("dates=5/7") + .hasMessageContaining("누락 2일"); + } + + @Test + @DisplayName("일부 날짜 누락 + failOnIncomplete=false → WARN 후 FINISHED") + void partialDatesMissing_failModeOff_returnsFinished() { + when(jdbcTemplate.queryForObject(any(String.class), eq(Long.class), eq(start), eq(end))) + .thenReturn(5L); + ScoreCompletenessTasklet tasklet = new ScoreCompletenessTasklet(jdbcTemplate, start, end, false); + + RepeatStatus result = tasklet.execute(null, null); + + assertThat(result).isEqualTo(RepeatStatus.FINISHED); + } + + @Test + @DisplayName("score 데이터 0건 + failOnIncomplete=true → 즉시 실패") + void noDataAtAll_failModeOn_throws() { + when(jdbcTemplate.queryForObject(any(String.class), eq(Long.class), eq(start), eq(end))) + .thenReturn(0L); + ScoreCompletenessTasklet tasklet = new ScoreCompletenessTasklet(jdbcTemplate, start, end, true); + + assertThatThrownBy(() -> tasklet.execute(null, null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("dates=0/7"); + } + + @Test + @DisplayName("queryForObject가 null 반환 시에도 안전하게 0으로 처리") + void nullCount_treatedAsZero() { + when(jdbcTemplate.queryForObject(any(String.class), eq(Long.class), eq(start), eq(end))) + .thenReturn(null); + ScoreCompletenessTasklet tasklet = new ScoreCompletenessTasklet(jdbcTemplate, start, end, true); + + assertThatThrownBy(() -> tasklet.execute(null, null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("dates=0/7"); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/score/DailyScoreJobIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/score/DailyScoreJobIntegrationTest.java new file mode 100644 index 0000000000..d591f6d4c8 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/score/DailyScoreJobIntegrationTest.java @@ -0,0 +1,173 @@ +package com.loopers.batch.job.score; + +import com.loopers.domain.score.MvProductScoreDailyId; +import com.loopers.domain.score.MvProductScoreDailyModel; +import com.loopers.infrastructure.score.MvProductScoreDailyJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = { + "spring.batch.job.name=" + DailyScoreJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class DailyScoreJobIntegrationTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(DailyScoreJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private MvProductScoreDailyJpaRepository scoreDailyJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + databaseCleanUp.truncateAllTables(); + cleanBatchMetaTables(); + } + + private void cleanBatchMetaTables() { + jdbcTemplate.execute("DELETE FROM BATCH_STEP_EXECUTION_CONTEXT"); + jdbcTemplate.execute("DELETE FROM BATCH_STEP_EXECUTION"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION_CONTEXT"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION_PARAMS"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION"); + jdbcTemplate.execute("DELETE FROM BATCH_JOB_INSTANCE"); + } + + @DisplayName("정상 실행: 3개 상품의 일간 score가 mv_product_score_daily에 적재된다") + @Test + void normalExecution() throws Exception { + // arrange + LocalDate date = LocalDate.of(2026, 4, 11); + insertSignal(1L, date, 100, 50, BigDecimal.valueOf(1000)); + insertSignal(2L, date, 50, 25, BigDecimal.valueOf(500)); + insertSignal(3L, date, 200, 100, BigDecimal.valueOf(2000)); + + JobParameters params = new JobParametersBuilder() + .addString("date", "20260411") + .toJobParameters(); + + // act + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List results = scoreDailyJpaRepository.findAll(); + assertThat(results).hasSize(3); + + MvProductScoreDailyModel first = scoreDailyJpaRepository + .findById(new MvProductScoreDailyId(1L, date)).orElseThrow(); + assertThat(first.getScore()).isCloseTo(720.0, within(0.001)); + + StepExecution stepExecution = execution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getReadCount()).isEqualTo(3); + assertThat(stepExecution.getWriteCount()).isEqualTo(3); + } + + @DisplayName("멱등성: 동일 date로 2회 실행 시 결과가 동일하다 (UPSERT)") + @Test + void idempotency() throws Exception { + // arrange + LocalDate date = LocalDate.of(2026, 4, 11); + insertSignal(1L, date, 100, 50, BigDecimal.valueOf(1000)); + + JobParameters params1 = new JobParametersBuilder() + .addString("date", "20260411") + .addLong("run.id", 1L) + .toJobParameters(); + JobParameters params2 = new JobParametersBuilder() + .addString("date", "20260411") + .addLong("run.id", 2L) + .toJobParameters(); + + // act + jobLauncherTestUtils.launchJob(params1); + JobExecution execution2 = jobLauncherTestUtils.launchJob(params2); + + // assert + assertThat(execution2.getStatus()).isEqualTo(BatchStatus.COMPLETED); + assertThat(scoreDailyJpaRepository.findAll()).hasSize(1); + } + + @DisplayName("score 0인 상품은 필터링된다") + @Test + void zeroScoreFiltered() throws Exception { + // arrange + LocalDate date = LocalDate.of(2026, 4, 11); + insertSignal(1L, date, 100, 50, BigDecimal.valueOf(1000)); + insertSignal(2L, date, 0, 0, BigDecimal.ZERO); + + JobParameters params = new JobParametersBuilder() + .addString("date", "20260411") + .toJobParameters(); + + // act + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + assertThat(scoreDailyJpaRepository.findAll()).hasSize(1); + + StepExecution stepExecution = execution.getStepExecutions().iterator().next(); + assertThat(stepExecution.getFilterCount()).isEqualTo(1); + } + + @DisplayName("해당 날짜에 signal이 없으면 0행 적재, COMPLETED") + @Test + void noSignals() throws Exception { + // arrange + JobParameters params = new JobParametersBuilder() + .addString("date", "20260411") + .toJobParameters(); + + // act + JobExecution execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + assertThat(scoreDailyJpaRepository.findAll()).isEmpty(); + } + + private void insertSignal(Long productDbId, LocalDate date, long viewCount, long likeCount, BigDecimal orderAmount) { + jdbcTemplate.update( + "INSERT INTO product_daily_signals (product_db_id, signal_date, view_count, like_count, order_amount, created_at, updated_at) VALUES (?, ?, ?, ?, ?, NOW(), NOW())", + productDbId, date, viewCount, likeCount, orderAmount + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/score/DailyScoreProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/score/DailyScoreProcessorTest.java new file mode 100644 index 0000000000..a442775bfc --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/score/DailyScoreProcessorTest.java @@ -0,0 +1,90 @@ +package com.loopers.batch.job.score; + +import com.loopers.batch.job.score.step.DailyScoreProcessor; +import com.loopers.batch.job.score.step.MvProductScoreDailyRow; +import com.loopers.ranking.ScoreCalculator; +import com.loopers.ranking.RankingWeightProperties; +import com.loopers.domain.signal.ProductDailySignalModel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +class DailyScoreProcessorTest { + + private final ScoreCalculator calculator = new ScoreCalculator( + new RankingWeightProperties(0.1, 0.2, 0.7) + ); + private final LocalDate targetDate = LocalDate.of(2026, 4, 11); + private final DailyScoreProcessor processor = new DailyScoreProcessor(calculator, targetDate); + + @DisplayName("정상 입력 시 score가 계산된 Row를 반환한다") + @Test + void process_normalInput() { + // arrange + ProductDailySignalModel signal = createSignal(1L, 100, 50, BigDecimal.valueOf(1000)); + + // act + MvProductScoreDailyRow result = processor.process(signal); + + // assert + assertThat(result).isNotNull(); + assertThat(result.productDbId()).isEqualTo(1L); + assertThat(result.scoreDate()).isEqualTo(targetDate); + assertThat(result.score()).isCloseTo(720.0, within(0.001)); + assertThat(result.viewCount()).isEqualTo(100); + assertThat(result.likeCount()).isEqualTo(50); + assertThat(result.orderAmount()).isEqualByComparingTo(BigDecimal.valueOf(1000)); + } + + @DisplayName("score가 0이면 null을 반환한다 (필터링)") + @Test + void process_zeroScore_returnsNull() { + // arrange + ProductDailySignalModel signal = createSignal(1L, 0, 0, BigDecimal.ZERO); + + // act + MvProductScoreDailyRow result = processor.process(signal); + + // assert + assertThat(result).isNull(); + } + + @DisplayName("targetDate가 Row에 정확히 전달된다") + @Test + void process_targetDatePropagated() { + // arrange + LocalDate customDate = LocalDate.of(2026, 1, 15); + DailyScoreProcessor customProcessor = new DailyScoreProcessor(calculator, customDate); + ProductDailySignalModel signal = createSignal(1L, 10, 5, BigDecimal.valueOf(100)); + + // act + MvProductScoreDailyRow result = customProcessor.process(signal); + + // assert + assertThat(result).isNotNull(); + assertThat(result.scoreDate()).isEqualTo(customDate); + } + + private ProductDailySignalModel createSignal(Long productDbId, long viewCount, long likeCount, BigDecimal orderAmount) { + try { + java.lang.reflect.Constructor constructor = + ProductDailySignalModel.class.getDeclaredConstructor(); + constructor.setAccessible(true); + ProductDailySignalModel signal = constructor.newInstance(); + ReflectionTestUtils.setField(signal, "productDbId", productDbId); + ReflectionTestUtils.setField(signal, "signalDate", targetDate); + ReflectionTestUtils.setField(signal, "viewCount", viewCount); + ReflectionTestUtils.setField(signal, "likeCount", likeCount); + ReflectionTestUtils.setField(signal, "orderAmount", orderAmount); + return signal; + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvAtomicSwapSemanticsTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvAtomicSwapSemanticsTest.java new file mode 100644 index 0000000000..79b0195747 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvAtomicSwapSemanticsTest.java @@ -0,0 +1,135 @@ +package com.loopers.domain.rank; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") +@DisplayName("S2 원자 스왑 시맨틱 — CAS 이전 실패 / 알고리즘 전환 / 재실행 멱등") +class MvAtomicSwapSemanticsTest { + + private static final String PERIOD_KEY = "2026W15"; + + @Autowired JdbcTemplate jdbc; + @Autowired PlatformTransactionManager transactionManager; + @Autowired DatabaseCleanUp databaseCleanUp; + + private TransactionTemplate tx; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + tx = new TransactionTemplate(transactionManager); + } + + @DisplayName("F1: INSERT 후 CAS 이전 실패 → published_version 구 버전 유지") + @Test + void f1_preCasFailure_keepsOldPublished() { + seedPublication(1L, 1L); + seedVersionedRows(1L, 100, 1.5); + + try { + tx.executeWithoutResult(status -> { + jdbc.update("UPDATE mv_product_rank_publication SET next_version = next_version + 1, updated_at = NOW(6) " + + "WHERE period_type = 'WEEKLY' AND period_key = ?", PERIOD_KEY); + throw new RuntimeException("crash before CAS"); + }); + } catch (RuntimeException ignore) { + } + + assertThat(queryPublishedVersion()).isEqualTo(1L); + } + + @DisplayName("F3: 새 version 전체 INSERT 후 CAS → reader는 version=2만 관찰") + @Test + void f3_versionCasFlip_atomic() { + seedPublication(1L, 1L); + seedVersionedRows(1L, 100, 1.5); + seedVersionedRows(2L, 100, 2.4); + + tx.executeWithoutResult(status -> + jdbc.update("UPDATE mv_product_rank_publication SET published_version = ?, updated_at = NOW(6) " + + "WHERE period_type = 'WEEKLY' AND period_key = ? AND published_version < ?", + 2L, PERIOD_KEY, 2L)); + + assertThat(queryPublishedVersion()).isEqualTo(2L); + assertThat(countPublishedRows()).isEqualTo(100); + assertThat(countPublishedRowsOfOtherVersion(2L)) + .as("published 기준 조회에 version=1 (OLD) 혼재 없음").isZero(); + } + + @DisplayName("F5: 재실행 시 next_version bump → CAS로 최종 published=3") + @Test + void f5_rerunIdempotent_bumpsVersion() { + seedPublication(1L, 1L); + seedVersionedRows(1L, 100, 1.5); + seedVersionedRows(2L, 30, 2.4); + + jdbc.update("UPDATE mv_product_rank_publication SET next_version = 3, updated_at = NOW(6) " + + "WHERE period_type='WEEKLY' AND period_key=?", PERIOD_KEY); + seedVersionedRows(3L, 100, 2.4); + jdbc.update("UPDATE mv_product_rank_publication SET published_version = 3, updated_at = NOW(6) " + + "WHERE period_type='WEEKLY' AND period_key=? AND published_version < 3", PERIOD_KEY); + + assertThat(queryPublishedVersion()).isEqualTo(3L); + } + + private long queryPublishedVersion() { + Long v = jdbc.queryForObject( + "SELECT published_version FROM mv_product_rank_publication WHERE period_type='WEEKLY' AND period_key=?", + Long.class, PERIOD_KEY); + return v == null ? 0L : v; + } + + private int countPublishedRows() { + Integer n = jdbc.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly mv " + + "JOIN mv_product_rank_publication p " + + " ON p.period_type='WEEKLY' AND p.period_key=mv.period_key AND p.published_version=mv.version " + + "WHERE mv.period_key=?", + Integer.class, PERIOD_KEY); + return n == null ? 0 : n; + } + + private int countPublishedRowsOfOtherVersion(long excludedVersion) { + Integer n = jdbc.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly mv " + + "JOIN mv_product_rank_publication p " + + " ON p.period_type='WEEKLY' AND p.period_key=mv.period_key AND p.published_version=mv.version " + + "WHERE mv.period_key=? AND mv.version != ?", + Integer.class, PERIOD_KEY, excludedVersion); + return n == null ? 0 : n; + } + + private void seedPublication(long nextV, long publishedV) { + jdbc.update("INSERT INTO mv_product_rank_publication (period_type, period_key, next_version, published_version, updated_at) " + + "VALUES (?, ?, ?, ?, NOW(6))", "WEEKLY", PERIOD_KEY, nextV, publishedV); + } + + private void seedVersionedRows(long version, int count, double weight) { + String sql = "INSERT INTO mv_product_rank_weekly " + + "(period_key, version, rank_no, ref_product_id, score, view_count, like_count, order_amount, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())"; + List params = new ArrayList<>(count); + long base = version * 1_000_000L; + for (int i = 0; i < count; i++) { + params.add(new Object[]{PERIOD_KEY, version, i + 1, base + i + 1, + weight * (count - i), 10L, 5L, BigDecimal.valueOf(100)}); + } + jdbc.batchUpdate(sql, params); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankAtomicSwapTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankAtomicSwapTest.java new file mode 100644 index 0000000000..4f3ef62225 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankAtomicSwapTest.java @@ -0,0 +1,159 @@ +package com.loopers.domain.rank; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") +@DisplayName("MV 원자 스왑 — 동시성 reader가 반쪽짜리 랭킹을 보지 않아야 한다") +class MvProductRankAtomicSwapTest { + + private static final String PERIOD_KEY = "2026W15"; + private static final int OLD_ROW_COUNT = 50; + private static final int NEW_ROW_COUNT = 100; + + @Autowired + private MvProductRankRepository repository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + repository.batchInsert(RankPeriodType.WEEKLY, buildRows(PERIOD_KEY, OLD_ROW_COUNT, 1)); + } + + @Test + @DisplayName("DELETE와 INSERT 사이 시점에 reader가 관찰해도 OLD 또는 NEW 상태만 본다 (빈 상태 불가)") + void atomicSwap_readerNeverSeesEmpty() throws Exception { + CountDownLatch writerInsideTx = new CountDownLatch(1); + CountDownLatch readerObserved = new CountDownLatch(1); + AtomicReference observedCountDuringTx = new AtomicReference<>(); + AtomicBoolean writerCompleted = new AtomicBoolean(false); + + TransactionTemplate tx = new TransactionTemplate(transactionManager); + + Thread writer = new Thread(() -> { + tx.executeWithoutResult(status -> { + repository.deleteByPeriodKey(RankPeriodType.WEEKLY, PERIOD_KEY); + writerInsideTx.countDown(); + try { + readerObserved.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + repository.batchInsert(RankPeriodType.WEEKLY, buildRows(PERIOD_KEY, NEW_ROW_COUNT, 1000)); + }); + writerCompleted.set(true); + }, "mv-atomic-writer"); + + Thread reader = new Thread(() -> { + try { + assertThat(writerInsideTx.await(5, TimeUnit.SECONDS)).isTrue(); + long count = repository.countByPeriodKey(RankPeriodType.WEEKLY, PERIOD_KEY); + observedCountDuringTx.set(count); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + readerObserved.countDown(); + } + }, "mv-atomic-reader"); + + writer.start(); + reader.start(); + reader.join(10_000); + writer.join(10_000); + + assertThat(writerCompleted).as("writer 트랜잭션 커밋 완료").isTrue(); + assertThat(observedCountDuringTx.get()) + .as("DELETE 완료 후 INSERT 전 시점의 reader 관찰값 — 빈 상태(0)가 되면 원자성 깨짐") + .isEqualTo(OLD_ROW_COUNT); + + long finalCount = repository.countByPeriodKey(RankPeriodType.WEEKLY, PERIOD_KEY); + assertThat(finalCount).as("commit 후 NEW 상태로 전환").isEqualTo(NEW_ROW_COUNT); + } + + @Test + @DisplayName("writer 커밋 전 reader는 OLD 행의 내용까지 일관되게 본다 (gap 없이)") + void atomicSwap_readerSeesConsistentOldSnapshot() throws Exception { + CountDownLatch writerInsideTx = new CountDownLatch(1); + CountDownLatch readerObserved = new CountDownLatch(1); + AtomicReference> observed = new AtomicReference<>(); + + TransactionTemplate tx = new TransactionTemplate(transactionManager); + + Thread writer = new Thread(() -> { + tx.executeWithoutResult(status -> { + repository.deleteByPeriodKey(RankPeriodType.WEEKLY, PERIOD_KEY); + writerInsideTx.countDown(); + try { + readerObserved.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + repository.batchInsert(RankPeriodType.WEEKLY, buildRows(PERIOD_KEY, NEW_ROW_COUNT, 1000)); + }); + }, "mv-atomic-writer"); + + Thread reader = new Thread(() -> { + try { + assertThat(writerInsideTx.await(5, TimeUnit.SECONDS)).isTrue(); + observed.set(repository.findByPeriodKey(RankPeriodType.WEEKLY, PERIOD_KEY, 0, 200)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + readerObserved.countDown(); + } + }, "mv-atomic-reader"); + + writer.start(); + reader.start(); + reader.join(10_000); + writer.join(10_000); + + List snapshot = observed.get(); + assertThat(snapshot).as("reader snapshot").hasSize(OLD_ROW_COUNT); + assertThat(snapshot.stream().allMatch(r -> r.refProductId() < 1000)) + .as("전부 OLD row (refProductId < 1000)이어야 함 — NEW row 섞이면 계약 위반") + .isTrue(); + } + + private List buildRows(String periodKey, int count, long refIdBase) { + List rows = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + rows.add(new MvProductRankRow( + periodKey, + i + 1, + refIdBase + i, + (double) (count - i), + 10L, + 5L, + BigDecimal.valueOf(100) + )); + } + return rows; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankConcurrentWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankConcurrentWriterTest.java new file mode 100644 index 0000000000..01ea14e601 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankConcurrentWriterTest.java @@ -0,0 +1,142 @@ +package com.loopers.domain.rank; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") +@DisplayName("MV 동시 writer — 같은 periodKey로 2 writer 동시 실행 시 최종 상태는 한 쪽만") +class MvProductRankConcurrentWriterTest { + + private static final String PERIOD_KEY = "2026W15"; + + @Autowired + private MvProductRankRepository repository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("동일 periodKey로 두 writer 동시 실행 — 한 쪽 성공 / 다른 쪽 deadlock (현재 운영 위험 노출)") + void concurrentWriters_oneSucceedsOneDeadlocks() throws Exception { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + + int setASize = 50; + int setBSize = 100; + long setAIdBase = 1L; + long setBIdBase = 1000L; + + ExecutorService executor = Executors.newFixedThreadPool(2); + AtomicLong writerAElapsed = new AtomicLong(); + AtomicLong writerBElapsed = new AtomicLong(); + + Runnable writerA = () -> { + long start = System.nanoTime(); + try { + tx.executeWithoutResult(status -> { + repository.deleteByPeriodKey(RankPeriodType.WEEKLY, PERIOD_KEY); + repository.batchInsert(RankPeriodType.WEEKLY, buildRows(setASize, setAIdBase)); + }); + } finally { + writerAElapsed.set((System.nanoTime() - start) / 1_000_000L); + } + }; + Runnable writerB = () -> { + long start = System.nanoTime(); + try { + tx.executeWithoutResult(status -> { + repository.deleteByPeriodKey(RankPeriodType.WEEKLY, PERIOD_KEY); + repository.batchInsert(RankPeriodType.WEEKLY, buildRows(setBSize, setBIdBase)); + }); + } finally { + writerBElapsed.set((System.nanoTime() - start) / 1_000_000L); + } + }; + + Future fa = executor.submit(writerA); + Future fb = executor.submit(writerB); + + int failures = 0; + int successes = 0; + Throwable deadlockCause = null; + for (Future f : List.of(fa, fb)) { + try { + f.get(15, TimeUnit.SECONDS); + successes++; + } catch (ExecutionException ee) { + failures++; + deadlockCause = ee.getCause(); + } + } + executor.shutdown(); + + assertThat(successes) + .as("현재 구현은 동시 writer 시 한 쪽만 성공 — 재진입 가드 없음") + .isEqualTo(1); + assertThat(failures) + .as("다른 쪽은 MySQL deadlock으로 실패 (운영 위험: 중복 Job 기동 방지 필요)") + .isEqualTo(1); + assertThat(deadlockCause) + .isInstanceOf(org.springframework.dao.CannotAcquireLockException.class); + + long finalCount = repository.countByPeriodKey(RankPeriodType.WEEKLY, PERIOD_KEY); + List finalRows = repository.findByPeriodKey( + RankPeriodType.WEEKLY, PERIOD_KEY, 0, 200 + ); + + assertThat(finalCount).as("최종 count는 50 또는 100 (성공한 쪽 set 전체)").isIn(50L, 100L); + + boolean allFromA = !finalRows.isEmpty() && finalRows.stream() + .allMatch(r -> r.refProductId() >= setAIdBase && r.refProductId() < setAIdBase + setASize); + boolean allFromB = !finalRows.isEmpty() && finalRows.stream() + .allMatch(r -> r.refProductId() >= setBIdBase && r.refProductId() < setBIdBase + setBSize); + + assertThat(allFromA || allFromB) + .as("최종 행들은 오로지 A 또는 B 한 쪽 set에서만 나와야 한다 (혼재 금지)") + .isTrue(); + + System.out.printf("writerA elapsed=%dms, writerB elapsed=%dms, finalCount=%d, winner=%s%n", + writerAElapsed.get(), writerBElapsed.get(), finalCount, allFromA ? "A" : "B"); + } + + private List buildRows(int count, long refIdBase) { + List rows = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + rows.add(new MvProductRankRow( + PERIOD_KEY, + i + 1, + refIdBase + i, + (double) (count - i), + 10L, 5L, BigDecimal.valueOf(100) + )); + } + return rows; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankPublicationRepositoryImplTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankPublicationRepositoryImplTest.java new file mode 100644 index 0000000000..59b52f6b60 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankPublicationRepositoryImplTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.rank; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") +@DisplayName("Publication 레포지토리 — bump/find/CAS") +class MvProductRankPublicationRepositoryImplTest { + + private static final String KEY = "2026W15"; + + @Autowired MvProductRankPublicationRepository repository; + @Autowired DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("bumpNextVersion 최초 호출 시 row 생성 + version=1") + @Test + void bumpFirst_createsRow() { + long v = repository.bumpNextVersion(RankPeriodType.WEEKLY, KEY); + assertThat(v).isEqualTo(1L); + assertThat(repository.findPublishedVersion(RankPeriodType.WEEKLY, KEY)).isZero(); + } + + @DisplayName("bumpNextVersion 반복 호출 시 version 단조 증가") + @Test + void bumpRepeat_monotonic() { + long v1 = repository.bumpNextVersion(RankPeriodType.WEEKLY, KEY); + long v2 = repository.bumpNextVersion(RankPeriodType.WEEKLY, KEY); + long v3 = repository.bumpNextVersion(RankPeriodType.WEEKLY, KEY); + assertThat(v1).isEqualTo(1L); + assertThat(v2).isEqualTo(2L); + assertThat(v3).isEqualTo(3L); + } + + @DisplayName("CAS publish — 더 큰 version일 때만 반영") + @Test + void casPublish_onlyIfGreater() { + repository.bumpNextVersion(RankPeriodType.WEEKLY, KEY); + repository.bumpNextVersion(RankPeriodType.WEEKLY, KEY); + boolean applied = repository.casPublishIfGreater(RankPeriodType.WEEKLY, KEY, 2L); + assertThat(applied).isTrue(); + assertThat(repository.findPublishedVersion(RankPeriodType.WEEKLY, KEY)).isEqualTo(2L); + + boolean staleApplied = repository.casPublishIfGreater(RankPeriodType.WEEKLY, KEY, 1L); + assertThat(staleApplied).as("현 published(2) 보다 작은 값은 무시").isFalse(); + assertThat(repository.findPublishedVersion(RankPeriodType.WEEKLY, KEY)).isEqualTo(2L); + + boolean laterApplied = repository.casPublishIfGreater(RankPeriodType.WEEKLY, KEY, 3L); + assertThat(laterApplied).isTrue(); + assertThat(repository.findPublishedVersion(RankPeriodType.WEEKLY, KEY)).isEqualTo(3L); + } + + @DisplayName("periodType별 독립 — WEEKLY와 MONTHLY는 각자의 version 관리") + @Test + void perPeriodType_isolated() { + repository.bumpNextVersion(RankPeriodType.WEEKLY, KEY); + long monthlyV = repository.bumpNextVersion(RankPeriodType.MONTHLY, KEY); + assertThat(monthlyV).isEqualTo(1L); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankRepositoryImplTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankRepositoryImplTest.java new file mode 100644 index 0000000000..5f4e95cb3c --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankRepositoryImplTest.java @@ -0,0 +1,136 @@ +package com.loopers.domain.rank; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") +class MvProductRankRepositoryImplTest { + + @Autowired + private MvProductRankRepository repository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("batchInsert로 주간 랭킹을 적재하고 findByPeriodKey로 조회할 수 있다") + @Test + void batchInsert_and_findByPeriodKey() { + // arrange + String periodKey = "2026W15"; + List rows = List.of( + new MvProductRankRow(periodKey, 1, 42L, 5040.0, 700, 350, BigDecimal.valueOf(7000)), + new MvProductRankRow(periodKey, 2, 43L, 3600.0, 500, 250, BigDecimal.valueOf(5000)), + new MvProductRankRow(periodKey, 3, 44L, 1800.0, 250, 125, BigDecimal.valueOf(2500)) + ); + + // act + repository.batchInsert(RankPeriodType.WEEKLY, rows); + + // assert + List result = repository.findByPeriodKey(RankPeriodType.WEEKLY, periodKey, 0, 10); + assertThat(result).hasSize(3); + assertThat(result.get(0).rankNo()).isEqualTo(1); + assertThat(result.get(0).refProductId()).isEqualTo(42L); + assertThat(result.get(0).score()).isEqualTo(5040.0); + } + + @DisplayName("deleteByPeriodKey로 해당 기간의 랭킹만 삭제한다") + @Test + void deleteByPeriodKey_deletesOnlyTargetPeriod() { + // arrange + List week15 = List.of( + new MvProductRankRow("2026W15", 1, 42L, 5040.0, 700, 350, BigDecimal.valueOf(7000)) + ); + List week16 = List.of( + new MvProductRankRow("2026W16", 1, 43L, 3600.0, 500, 250, BigDecimal.valueOf(5000)) + ); + repository.batchInsert(RankPeriodType.WEEKLY, week15); + repository.batchInsert(RankPeriodType.WEEKLY, week16); + + // act + repository.deleteByPeriodKey(RankPeriodType.WEEKLY, "2026W15"); + + // assert + assertThat(repository.countByPeriodKey(RankPeriodType.WEEKLY, "2026W15")).isZero(); + assertThat(repository.countByPeriodKey(RankPeriodType.WEEKLY, "2026W16")).isEqualTo(1); + } + + @DisplayName("findByPeriodKey는 rank_no 순으로 정렬되어 반환한다") + @Test + void findByPeriodKey_orderedByRankNo() { + // arrange + String periodKey = "2026W15"; + List rows = List.of( + new MvProductRankRow(periodKey, 3, 44L, 1800.0, 250, 125, BigDecimal.valueOf(2500)), + new MvProductRankRow(periodKey, 1, 42L, 5040.0, 700, 350, BigDecimal.valueOf(7000)), + new MvProductRankRow(periodKey, 2, 43L, 3600.0, 500, 250, BigDecimal.valueOf(5000)) + ); + repository.batchInsert(RankPeriodType.WEEKLY, rows); + + // act + List result = repository.findByPeriodKey(RankPeriodType.WEEKLY, periodKey, 0, 10); + + // assert + assertThat(result).extracting(MvProductRankRow::rankNo).containsExactly(1, 2, 3); + } + + @DisplayName("findByPeriodKey 페이징이 정상 동작한다") + @Test + void findByPeriodKey_pagination() { + // arrange + String periodKey = "2026W15"; + List rows = List.of( + new MvProductRankRow(periodKey, 1, 42L, 5040.0, 700, 350, BigDecimal.valueOf(7000)), + new MvProductRankRow(periodKey, 2, 43L, 3600.0, 500, 250, BigDecimal.valueOf(5000)), + new MvProductRankRow(periodKey, 3, 44L, 1800.0, 250, 125, BigDecimal.valueOf(2500)) + ); + repository.batchInsert(RankPeriodType.WEEKLY, rows); + + // act + List page0 = repository.findByPeriodKey(RankPeriodType.WEEKLY, periodKey, 0, 2); + List page1 = repository.findByPeriodKey(RankPeriodType.WEEKLY, periodKey, 2, 2); + + // assert + assertThat(page0).hasSize(2); + assertThat(page0).extracting(MvProductRankRow::rankNo).containsExactly(1, 2); + assertThat(page1).hasSize(1); + assertThat(page1).extracting(MvProductRankRow::rankNo).containsExactly(3); + } + + @DisplayName("월간 랭킹도 동일하게 동작한다") + @Test + void monthlyRank_works() { + // arrange + String periodKey = "202604"; + List rows = List.of( + new MvProductRankRow(periodKey, 1, 42L, 15000.0, 2100, 1050, BigDecimal.valueOf(21000)) + ); + + // act + repository.batchInsert(RankPeriodType.MONTHLY, rows); + + // assert + List result = repository.findByPeriodKey(RankPeriodType.MONTHLY, periodKey, 0, 10); + assertThat(result).hasSize(1); + assertThat(result.get(0).refProductId()).isEqualTo(42L); + + long count = repository.countByPeriodKey(RankPeriodType.MONTHLY, periodKey); + assertThat(count).isEqualTo(1); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvPublicationAtomicityTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvPublicationAtomicityTest.java new file mode 100644 index 0000000000..ad3e3b7c98 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvPublicationAtomicityTest.java @@ -0,0 +1,108 @@ +package com.loopers.domain.rank; + +import com.loopers.batch.job.rank.step.MvRankCleanupTasklet; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") +@DisplayName("Publication 원자성 — bump rollback, cleanup 멱등성") +class MvPublicationAtomicityTest { + + private static final String KEY = "2026W15"; + + @Autowired MvProductRankRepository rankRepository; + @Autowired MvProductRankPublicationRepository publicationRepository; + @Autowired JdbcTemplate jdbc; + @Autowired PlatformTransactionManager transactionManager; + @Autowired DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("bump 후 tx rollback 시 next_version 복원 — publication 원자성") + @Test + void bumpRollback_restoresNextVersion() { + publicationRepository.bumpNextVersion(RankPeriodType.WEEKLY, KEY); + publicationRepository.bumpNextVersion(RankPeriodType.WEEKLY, KEY); + Long beforeNext = jdbc.queryForObject( + "SELECT next_version FROM mv_product_rank_publication WHERE period_type=? AND period_key=?", + Long.class, "WEEKLY", KEY); + assertThat(beforeNext).isEqualTo(2L); + + TransactionTemplate tx = new TransactionTemplate(transactionManager); + assertThatThrownBy(() -> tx.executeWithoutResult(status -> { + publicationRepository.bumpNextVersion(RankPeriodType.WEEKLY, KEY); + throw new RuntimeException("simulated insertTx failure"); + })).hasMessageContaining("simulated"); + + Long afterNext = jdbc.queryForObject( + "SELECT next_version FROM mv_product_rank_publication WHERE period_type=? AND period_key=?", + Long.class, "WEEKLY", KEY); + assertThat(afterNext).as("rollback 후 next_version 되돌아가야 함").isEqualTo(2L); + } + + @DisplayName("Cleanup 멱등성 — 같은 periodKey로 2회 실행 시 결과 동일 + 예외 없음") + @Test + void cleanupIdempotent() { + rankRepository.batchInsert(RankPeriodType.WEEKLY, buildRows(10, 1L)); + rankRepository.batchInsert(RankPeriodType.WEEKLY, buildRows(10, 2L)); + jdbc.update("INSERT INTO mv_product_rank_publication (period_type, period_key, next_version, published_version, updated_at) " + + "VALUES ('WEEKLY', ?, 2, 2, NOW(6))", KEY); + + MvRankCleanupTasklet tasklet = new MvRankCleanupTasklet( + jdbc, RankPeriodType.WEEKLY, KEY, 1000 + ); + tasklet.execute(null, null); + tasklet.execute(null, null); + tasklet.execute(null, null); + + Integer rowCount = jdbc.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key=?", + Integer.class, KEY + ); + assertThat(rowCount).as("3회 실행해도 published(v2) 행 10개만 유지").isEqualTo(10); + } + + @DisplayName("CAS publish 단조성 — 동일 version 재시도는 실패, 하위 version 시도도 실패") + @Test + void casMonotonic() { + publicationRepository.bumpNextVersion(RankPeriodType.WEEKLY, KEY); + publicationRepository.bumpNextVersion(RankPeriodType.WEEKLY, KEY); + + assertThat(publicationRepository.casPublishIfGreater(RankPeriodType.WEEKLY, KEY, 2L)).isTrue(); + assertThat(publicationRepository.casPublishIfGreater(RankPeriodType.WEEKLY, KEY, 2L)) + .as("동일 version 재시도 — 이미 published=2라 false").isFalse(); + assertThat(publicationRepository.casPublishIfGreater(RankPeriodType.WEEKLY, KEY, 1L)) + .as("하위 version 시도 실패").isFalse(); + assertThat(publicationRepository.findPublishedVersion(RankPeriodType.WEEKLY, KEY)).isEqualTo(2L); + } + + private List buildRows(int count, long version) { + List rows = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + rows.add(new MvProductRankRow( + KEY, i + 1, (version * 10_000L) + i + 1, + (double) (count - i), 10L, 5L, BigDecimal.valueOf(100), version + )); + } + return rows; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvPublishingConcurrentWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvPublishingConcurrentWriterTest.java new file mode 100644 index 0000000000..bbd743dacd --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvPublishingConcurrentWriterTest.java @@ -0,0 +1,100 @@ +package com.loopers.domain.rank; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") +@DisplayName("S2 동시 writer — bump/INSERT/CAS 흐름에서 둘 다 성공, 최종 published는 1건") +class MvPublishingConcurrentWriterTest { + + private static final String PERIOD_KEY = "2026W15"; + + @Autowired MvProductRankRepository rankRepository; + @Autowired MvProductRankPublicationRepository publicationRepository; + @Autowired JdbcTemplate jdbcTemplate; + @Autowired PlatformTransactionManager transactionManager; + @Autowired DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("동일 periodKey로 두 writer 동시 실행 — 둘 다 성공, 최종 published version은 후행 writer") + @Test + void bothSucceed_publishedIsLatest() throws Exception { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + + int rowsA = 50; + int rowsB = 100; + long baseA = 1L; + long baseB = 1_000L; + + ExecutorService executor = Executors.newFixedThreadPool(2); + Runnable writerA = () -> runWriter(tx, rowsA, baseA); + Runnable writerB = () -> runWriter(tx, rowsB, baseB); + + Future fa = executor.submit(writerA); + Future fb = executor.submit(writerB); + + fa.get(15, TimeUnit.SECONDS); + fb.get(15, TimeUnit.SECONDS); + executor.shutdown(); + + long published = publicationRepository.findPublishedVersion(RankPeriodType.WEEKLY, PERIOD_KEY); + assertThat(published).as("둘 중 하나의 version이 published").isIn(1L, 2L); + + Integer publishedRowCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key=? AND version=?", + Integer.class, PERIOD_KEY, published + ); + assertThat(publishedRowCount).as("published version 행수는 그 writer가 insert한 만큼").isIn(rowsA, rowsB); + + Integer totalRows = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key=?", + Integer.class, PERIOD_KEY + ); + assertThat(totalRows).as("두 version 모두 테이블에 공존 (cleanup 별도)").isEqualTo(rowsA + rowsB); + } + + private void runWriter(TransactionTemplate tx, int count, long idBase) { + Long myVersion = tx.execute(status -> { + long v = publicationRepository.bumpNextVersion(RankPeriodType.WEEKLY, PERIOD_KEY); + rankRepository.batchInsert(RankPeriodType.WEEKLY, buildRows(count, idBase, v)); + return v; + }); + if (myVersion != null) { + publicationRepository.casPublishIfGreater(RankPeriodType.WEEKLY, PERIOD_KEY, myVersion); + } + } + + private List buildRows(int count, long refIdBase, long version) { + List rows = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + rows.add(new MvProductRankRow( + PERIOD_KEY, i + 1, refIdBase + i, (double) (count - i), + 10L, 5L, BigDecimal.valueOf(100), version + )); + } + return rows; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvRankCleanupPublishRaceTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvRankCleanupPublishRaceTest.java new file mode 100644 index 0000000000..b823dac45f --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvRankCleanupPublishRaceTest.java @@ -0,0 +1,124 @@ +package com.loopers.domain.rank; + +import com.loopers.batch.job.rank.step.MvRankCleanupTasklet; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") +@DisplayName("Cleanup ↔ Publish race — 교차 실행 시 published 행 보존 + orphan 정리") +class MvRankCleanupPublishRaceTest { + + private static final String PERIOD_KEY = "2026W15"; + + @Autowired MvProductRankRepository rankRepository; + @Autowired MvProductRankPublicationRepository publicationRepository; + @Autowired JdbcTemplate jdbcTemplate; + @Autowired PlatformTransactionManager transactionManager; + @Autowired DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("Cleanup 진행 중 새 Publish 발생 → 새 published version 보호, 구 orphan 제거") + @Test + void concurrentCleanupAndPublish_preservesPublished() throws Exception { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + long v1 = publicationRepository.bumpNextVersion(RankPeriodType.WEEKLY, PERIOD_KEY); + rankRepository.batchInsert(RankPeriodType.WEEKLY, buildRows(50, 1_000L, v1)); + publicationRepository.casPublishIfGreater(RankPeriodType.WEEKLY, PERIOD_KEY, v1); + + long v2 = publicationRepository.bumpNextVersion(RankPeriodType.WEEKLY, PERIOD_KEY); + rankRepository.batchInsert(RankPeriodType.WEEKLY, buildRows(50, 2_000L, v2)); + publicationRepository.casPublishIfGreater(RankPeriodType.WEEKLY, PERIOD_KEY, v2); + + CountDownLatch readyLatch = new CountDownLatch(2); + CountDownLatch startLatch = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + + Runnable cleanupTask = () -> { + readyLatch.countDown(); + await(startLatch); + MvRankCleanupTasklet tasklet = new MvRankCleanupTasklet( + jdbcTemplate, RankPeriodType.WEEKLY, PERIOD_KEY, 10 + ); + tasklet.execute(null, null); + }; + Runnable publishTask = () -> { + readyLatch.countDown(); + await(startLatch); + tx.executeWithoutResult(status -> { + long v3 = publicationRepository.bumpNextVersion(RankPeriodType.WEEKLY, PERIOD_KEY); + rankRepository.batchInsert(RankPeriodType.WEEKLY, buildRows(50, 3_000L, v3)); + publicationRepository.casPublishIfGreater(RankPeriodType.WEEKLY, PERIOD_KEY, v3); + }); + }; + + executor.submit(cleanupTask); + executor.submit(publishTask); + readyLatch.await(); + startLatch.countDown(); + executor.shutdown(); + executor.awaitTermination(30, TimeUnit.SECONDS); + + long published = publicationRepository.findPublishedVersion(RankPeriodType.WEEKLY, PERIOD_KEY); + assertThat(published).as("published는 단조 증가 — v2 또는 v3").isIn(v2, 3L); + + Integer publishedRows = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key=? AND version=?", + Integer.class, PERIOD_KEY, published + ); + assertThat(publishedRows).as("현재 published version 행은 온전 — cleanup이 지우지 않음").isEqualTo(50); + + MvRankCleanupTasklet finalCleanup = new MvRankCleanupTasklet( + jdbcTemplate, RankPeriodType.WEEKLY, PERIOD_KEY, 1000 + ); + finalCleanup.execute(null, null); + + Integer remaining = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE period_key=?", + Integer.class, PERIOD_KEY + ); + assertThat(remaining).as("최종 cleanup 후 published version 행만 50개 남음").isEqualTo(50); + } + + private List buildRows(int count, long refIdBase, long version) { + List rows = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + rows.add(new MvProductRankRow( + PERIOD_KEY, i + 1, refIdBase + i, + (double) (count - i), 10L, 5L, BigDecimal.valueOf(100), version + )); + } + return rows; + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvSwapStrategyBenchmarkTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvSwapStrategyBenchmarkTest.java new file mode 100644 index 0000000000..15cb117813 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvSwapStrategyBenchmarkTest.java @@ -0,0 +1,185 @@ +package com.loopers.domain.rank; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") +@DisplayName("S1 vs S2 스왑 전략 벤치마크 — 순수 SWAP 단계 비용 비교") +class MvSwapStrategyBenchmarkTest { + + private static final String PERIOD_KEY = "2026W15"; + private static final int ROW_COUNT = 10_000; + + @Autowired + private MvProductRankRepository repository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("S1 SWAP 단계: DELETE by period_key(10K rows) — tx 지속 시간 실측") + @Test + void s1_swap_delete_phase_duration() { + repository.batchInsert(RankPeriodType.WEEKLY, buildRows(ROW_COUNT)); + + TransactionTemplate tx = new TransactionTemplate(transactionManager); + long insertStart = System.nanoTime(); + long[] phase = new long[2]; + tx.executeWithoutResult(status -> { + long deleteStart = System.nanoTime(); + repository.deleteByPeriodKey(RankPeriodType.WEEKLY, PERIOD_KEY); + phase[0] = (System.nanoTime() - deleteStart) / 1_000_000L; + long insertPhaseStart = System.nanoTime(); + repository.batchInsert(RankPeriodType.WEEKLY, buildRows(ROW_COUNT)); + phase[1] = (System.nanoTime() - insertPhaseStart) / 1_000_000L; + }); + long totalMs = (System.nanoTime() - insertStart) / 1_000_000L; + + System.out.printf("[S1-BENCHMARK] rows=%d delete_ms=%d insert_ms=%d total_tx_ms=%d%n", + ROW_COUNT, phase[0], phase[1], totalMs); + } + + @DisplayName("S2 SWAP 단계: bump + INSERT(with version) + CAS publish UPDATE — tx 지속 시간 실측") + @Test + void s2_swap_publication_phase_duration() { + long seedVersion = 1L; + jdbcTemplate.update( + "INSERT INTO mv_product_rank_publication (period_type, period_key, next_version, published_version, updated_at) " + + "VALUES (?, ?, ?, ?, NOW(6))", + "WEEKLY", PERIOD_KEY, seedVersion, seedVersion + ); + seedS2Rows(seedVersion, ROW_COUNT); + + TransactionTemplate tx = new TransactionTemplate(transactionManager); + long[] phase = new long[3]; + long txStart = System.nanoTime(); + tx.executeWithoutResult(status -> { + long bumpStart = System.nanoTime(); + jdbcTemplate.update( + "UPDATE mv_product_rank_publication SET next_version = next_version + 1, updated_at = NOW(6) " + + "WHERE period_type = ? AND period_key = ?", + "WEEKLY", PERIOD_KEY + ); + Long newVersion = jdbcTemplate.queryForObject( + "SELECT next_version FROM mv_product_rank_publication WHERE period_type = ? AND period_key = ?", + Long.class, "WEEKLY", PERIOD_KEY + ); + phase[0] = (System.nanoTime() - bumpStart) / 1_000_000L; + + long insertStart = System.nanoTime(); + seedS2Rows(newVersion == null ? 2L : newVersion, ROW_COUNT); + phase[1] = (System.nanoTime() - insertStart) / 1_000_000L; + + long casStart = System.nanoTime(); + jdbcTemplate.update( + "UPDATE mv_product_rank_publication SET published_version = ?, updated_at = NOW(6) " + + "WHERE period_type = ? AND period_key = ? AND published_version < ?", + newVersion, "WEEKLY", PERIOD_KEY, newVersion + ); + phase[2] = (System.nanoTime() - casStart) / 1_000_000L; + }); + long totalMs = (System.nanoTime() - txStart) / 1_000_000L; + + System.out.printf("[S2-BENCHMARK] rows=%d bump_ms=%d insert_ms=%d cas_ms=%d total_tx_ms=%d%n", + ROW_COUNT, phase[0], phase[1], phase[2], totalMs); + } + + @DisplayName("S1-only SWAP 최소 비용: DELETE by period_key (INSERT 제외)") + @Test + void s1_delete_only_cost() { + repository.batchInsert(RankPeriodType.WEEKLY, buildRows(ROW_COUNT)); + + TransactionTemplate tx = new TransactionTemplate(transactionManager); + long[] elapsed = new long[1]; + tx.executeWithoutResult(status -> { + long start = System.nanoTime(); + repository.deleteByPeriodKey(RankPeriodType.WEEKLY, PERIOD_KEY); + elapsed[0] = (System.nanoTime() - start) / 1_000_000L; + }); + System.out.printf("[S1-DELETE-ONLY] rows=%d delete_tx_ms=%d%n", ROW_COUNT, elapsed[0]); + } + + @DisplayName("S2-only SWAP 최소 비용: 단일 CAS UPDATE (INSERT 제외)") + @Test + void s2_cas_only_cost() { + long seedVersion = 1L; + jdbcTemplate.update( + "INSERT INTO mv_product_rank_publication (period_type, period_key, next_version, published_version, updated_at) " + + "VALUES (?, ?, ?, ?, NOW(6))", + "WEEKLY", PERIOD_KEY, 2L, seedVersion + ); + + TransactionTemplate tx = new TransactionTemplate(transactionManager); + long[] elapsed = new long[1]; + tx.executeWithoutResult(status -> { + long start = System.nanoTime(); + jdbcTemplate.update( + "UPDATE mv_product_rank_publication SET published_version = ?, updated_at = NOW(6) " + + "WHERE period_type = ? AND period_key = ? AND published_version < ?", + 2L, "WEEKLY", PERIOD_KEY, 2L + ); + elapsed[0] = (System.nanoTime() - start) / 1_000_000L; + }); + System.out.printf("[S2-CAS-ONLY] cas_tx_ms=%d%n", elapsed[0]); + } + + private void seedS2Rows(long version, int count) { + String sql = "INSERT INTO mv_product_rank_weekly " + + "(period_key, version, rank_no, ref_product_id, score, view_count, like_count, order_amount, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + Timestamp now = Timestamp.from(Instant.now()); + List params = new ArrayList<>(count); + long rankOffset = (version - 1) * 1_000_000L; + for (int i = 0; i < count; i++) { + params.add(new Object[]{ + PERIOD_KEY, + version, + i + 1, + rankOffset + i + 1, + (double) (count - i), + 10L, 5L, BigDecimal.valueOf(100), + now, now + }); + } + jdbcTemplate.batchUpdate(sql, params); + } + + private List buildRows(int count) { + List rows = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + rows.add(new MvProductRankRow( + PERIOD_KEY, + i + 1, + (long) (i + 1), + (double) (count - i), + 10L, 5L, BigDecimal.valueOf(100) + )); + } + return rows; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/RankScoreCalculatorTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/RankScoreCalculatorTest.java new file mode 100644 index 0000000000..dd04655507 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/RankScoreCalculatorTest.java @@ -0,0 +1,58 @@ +package com.loopers.domain.rank; + +import com.loopers.ranking.RankingWeightProperties; +import com.loopers.ranking.ScoreCalculator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +class ScoreCalculatorTest { + + private final ScoreCalculator calculator = new ScoreCalculator( + new RankingWeightProperties(0.1, 0.2, 0.7) + ); + + @DisplayName("기본 가중치로 점수를 계산한다") + @Test + void calculate_withDefaultWeights() { + // act + double score = calculator.calculateTotal(100, 50, BigDecimal.valueOf(1000)); + + // assert — 0.1*100 + 0.2*50 + 0.7*1000 = 10 + 10 + 700 = 720.0 + assertThat(score).isCloseTo(720.0, within(0.001)); + } + + @DisplayName("모든 값이 0이면 score는 0이다") + @Test + void calculate_allZero() { + double score = calculator.calculateTotal(0, 0, BigDecimal.ZERO); + + assertThat(score).isEqualTo(0.0); + } + + @DisplayName("BigDecimal 소수점 정밀도가 유지된다") + @Test + void calculate_decimalPrecision() { + double score = calculator.calculateTotal(0, 0, BigDecimal.valueOf(99.99)); + + // 0.7 * 99.99 = 69.993 + assertThat(score).isCloseTo(69.993, within(0.001)); + } + + @DisplayName("가중치 변경 시 결과가 달라진다") + @Test + void calculate_withDifferentWeights() { + ScoreCalculator customCalculator = new ScoreCalculator( + new RankingWeightProperties(0.5, 0.3, 0.2) + ); + + double score = customCalculator.calculateTotal(100, 50, BigDecimal.valueOf(1000)); + + // 0.5*100 + 0.3*50 + 0.2*1000 = 50 + 15 + 200 = 265.0 + assertThat(score).isCloseTo(265.0, within(0.001)); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/RankingKeyGeneratorTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/RankingKeyGeneratorTest.java new file mode 100644 index 0000000000..7da51ce77b --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/RankingKeyGeneratorTest.java @@ -0,0 +1,123 @@ +package com.loopers.domain.rank; + +import com.loopers.ranking.RankingKeyGenerator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class RankingKeyGeneratorTest { + + @DisplayName("월요일의 weeklyPeriodKey를 생성한다") + @Test + void weeklyPeriodKey_monday() { + LocalDate monday = LocalDate.of(2026, 4, 13); + assertThat(RankingKeyGenerator.weeklyPeriodKey(monday)).isEqualTo("2026W16"); + } + + @DisplayName("일요일은 로케일 기반 주 번호로 다음 주에 속한다") + @Test + void weeklyPeriodKey_sunday() { + LocalDate sunday = LocalDate.of(2026, 4, 12); + assertThat(RankingKeyGenerator.weeklyPeriodKey(sunday)).isEqualTo("2026W16"); + } + + @DisplayName("연말 경계: 12/29(월)는 ISO week 기준 다음 해 첫째 주에 속할 수 있다") + @Test + void weeklyPeriodKey_yearBoundary() { + LocalDate dec29 = LocalDate.of(2025, 12, 29); + String key = RankingKeyGenerator.weeklyPeriodKey(dec29); + assertThat(key).isEqualTo("2026W01"); + } + + @DisplayName("monthlyPeriodKey를 생성한다") + @Test + void monthlyPeriodKey() { + assertThat(RankingKeyGenerator.monthlyPeriodKey(LocalDate.of(2026, 4, 1))).isEqualTo("202604"); + assertThat(RankingKeyGenerator.monthlyPeriodKey(LocalDate.of(2026, 12, 31))).isEqualTo("202612"); + } + + @DisplayName("weekStart는 해당 주 월요일을 반환한다") + @Test + void weekStart() { + LocalDate wednesday = LocalDate.of(2026, 4, 15); + assertThat(RankingKeyGenerator.weekStart(wednesday)).isEqualTo(LocalDate.of(2026, 4, 13)); + } + + @DisplayName("weekEnd는 해당 주 일요일을 반환한다") + @Test + void weekEnd() { + LocalDate wednesday = LocalDate.of(2026, 4, 15); + assertThat(RankingKeyGenerator.weekEnd(wednesday)).isEqualTo(LocalDate.of(2026, 4, 19)); + } + + @DisplayName("monthStart는 1일을 반환한다") + @Test + void monthStart() { + assertThat(RankingKeyGenerator.monthStart(LocalDate.of(2026, 4, 15))).isEqualTo(LocalDate.of(2026, 4, 1)); + } + + @DisplayName("monthEnd는 말일을 반환한다") + @Test + void monthEnd() { + assertThat(RankingKeyGenerator.monthEnd(LocalDate.of(2026, 2, 15))).isEqualTo(LocalDate.of(2026, 2, 28)); + } + + @DisplayName("윤년 2월 monthEnd는 29일이다") + @Test + void monthEnd_leapYear() { + assertThat(RankingKeyGenerator.monthEnd(LocalDate.of(2028, 2, 15))).isEqualTo(LocalDate.of(2028, 2, 29)); + } + + @DisplayName("quarterlyPeriodKey는 종료일 yyyyMMdd 형식이다") + @Test + void quarterlyPeriodKey() { + assertThat(RankingKeyGenerator.quarterlyPeriodKey(LocalDate.of(2026, 4, 16))).isEqualTo("20260416"); + assertThat(RankingKeyGenerator.quarterlyPeriodKey(LocalDate.of(2026, 1, 1))).isEqualTo("20260101"); + assertThat(RankingKeyGenerator.quarterlyPeriodKey(LocalDate.of(2025, 12, 31))).isEqualTo("20251231"); + } + + @DisplayName("quarterlyStart는 종료일 포함 90일 윈도우의 시작일(종료일 -89일)이다") + @Test + void quarterlyStart() { + LocalDate endDate = LocalDate.of(2026, 4, 16); + LocalDate start = RankingKeyGenerator.quarterlyStart(endDate); + assertThat(start).isEqualTo(LocalDate.of(2026, 1, 17)); + assertThat(ChronoUnit.DAYS.between(start, endDate)).isEqualTo(89); + } + + @DisplayName("quarterlyEnd는 입력 date를 그대로 반환한다") + @Test + void quarterlyEnd() { + LocalDate date = LocalDate.of(2026, 4, 16); + assertThat(RankingKeyGenerator.quarterlyEnd(date)).isEqualTo(date); + } + + @DisplayName("quarterly 시작·종료일 사이 일수는 90일을 포함한다") + @Test + void quarterlyWindow_isExactly90Days() { + LocalDate endDate = LocalDate.of(2026, 4, 16); + LocalDate start = RankingKeyGenerator.quarterlyStart(endDate); + LocalDate end = RankingKeyGenerator.quarterlyEnd(endDate); + assertThat(ChronoUnit.DAYS.between(start, end) + 1).isEqualTo(90); + } + + @DisplayName("윤년 경계를 가로지르는 quarterly 윈도우도 정확히 90일이다") + @Test + void quarterlyWindow_acrossLeapYear() { + LocalDate endDate = LocalDate.of(2028, 3, 31); + LocalDate start = RankingKeyGenerator.quarterlyStart(endDate); + assertThat(start).isEqualTo(LocalDate.of(2028, 1, 2)); + assertThat(ChronoUnit.DAYS.between(start, endDate) + 1).isEqualTo(90); + } + + @DisplayName("previousQuarterlyPeriodKey는 어제 날짜의 quarterly key이다") + @Test + void previousQuarterlyPeriodKey() { + assertThat(RankingKeyGenerator.previousQuarterlyPeriodKey(LocalDate.of(2026, 4, 16))).isEqualTo("20260415"); + assertThat(RankingKeyGenerator.previousQuarterlyPeriodKey(LocalDate.of(2026, 1, 1))).isEqualTo("20251231"); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/score/MvProductScoreDailyRepositoryImplTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/score/MvProductScoreDailyRepositoryImplTest.java new file mode 100644 index 0000000000..4de77afb63 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/score/MvProductScoreDailyRepositoryImplTest.java @@ -0,0 +1,95 @@ +package com.loopers.domain.score; + +import com.loopers.infrastructure.score.MvProductScoreDailyJpaRepository; +import com.loopers.infrastructure.score.MvProductScoreDailyRepositoryImpl; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") +class MvProductScoreDailyRepositoryImplTest { + + @Autowired + private MvProductScoreDailyRepository repository; + + @Autowired + private MvProductScoreDailyJpaRepository jpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("batchUpsert로 일간 점수를 적재하고 조회할 수 있다") + @Test + void batchUpsert_insertsRows() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 11); + List rows = List.of( + new MvProductScoreDailyRow(1L, date, 720.0, 100, 50, BigDecimal.valueOf(1000)), + new MvProductScoreDailyRow(2L, date, 360.0, 50, 25, BigDecimal.valueOf(500)) + ); + + // act + repository.batchUpsert(rows); + + // assert + List result = jpaRepository.findAll(); + assertThat(result).hasSize(2); + assertThat(result.get(0).getScore()).isEqualTo(720.0); + assertThat(result.get(0).getId().getProductDbId()).isEqualTo(1L); + assertThat(result.get(0).getId().getScoreDate()).isEqualTo(date); + } + + @DisplayName("동일 PK로 batchUpsert 시 ON DUPLICATE KEY UPDATE로 덮어쓴다") + @Test + void batchUpsert_upsertOnDuplicate() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 11); + List initial = List.of( + new MvProductScoreDailyRow(1L, date, 100.0, 10, 5, BigDecimal.valueOf(100)) + ); + repository.batchUpsert(initial); + + List updated = List.of( + new MvProductScoreDailyRow(1L, date, 720.0, 100, 50, BigDecimal.valueOf(1000)) + ); + + // act + repository.batchUpsert(updated); + + // assert + List result = jpaRepository.findAll(); + assertThat(result).hasSize(1); + assertThat(result.get(0).getScore()).isEqualTo(720.0); + assertThat(result.get(0).getViewCount()).isEqualTo(100); + } + + @DisplayName("EmbeddedId의 equals/hashCode가 정상 동작한다") + @Test + void embeddedId_equalsAndHashCode() { + // arrange + MvProductScoreDailyId id1 = new MvProductScoreDailyId(1L, LocalDate.of(2026, 4, 11)); + MvProductScoreDailyId id2 = new MvProductScoreDailyId(1L, LocalDate.of(2026, 4, 11)); + MvProductScoreDailyId id3 = new MvProductScoreDailyId(2L, LocalDate.of(2026, 4, 11)); + + // assert + assertThat(id1).isEqualTo(id2); + assertThat(id1).isNotEqualTo(id3); + assertThat(id1.hashCode()).isEqualTo(id2.hashCode()); + } +} diff --git a/apps/commerce-streamer/build.gradle.kts b/apps/commerce-streamer/build.gradle.kts index 3054cbb29b..e2a619b95f 100644 --- a/apps/commerce-streamer/build.gradle.kts +++ b/apps/commerce-streamer/build.gradle.kts @@ -6,6 +6,7 @@ dependencies { implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + implementation(project(":supports:ranking")) // web implementation("org.springframework.boot:spring-boot-starter-web") diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingApp.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingApp.java index 0525c23906..07eee8f606 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingApp.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingApp.java @@ -2,7 +2,7 @@ import com.loopers.domain.ranking.ProductDailySignalRepository; import com.loopers.domain.ranking.RankingRepository; -import com.loopers.domain.ranking.ScoreAggregator; +import com.loopers.ranking.ScoreCalculator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -19,14 +19,14 @@ public class RankingApp { private static final double SECONDS_IN_DAY = 86400.0; private final RankingRepository rankingRepository; - private final ScoreAggregator scoreAggregator; + private final ScoreCalculator scoreCalculator; private final ProductDailySignalRepository productDailySignalRepository; public void applyLikeDelta(Long productDbId, int delta, LocalDate date) { if (delta <= 0) { return; } - double score = scoreAggregator.scoreForLike(delta) + tieBreakFraction(); + double score = scoreCalculator.scoreForLike(delta) + tieBreakFraction(); rankingRepository.incrementScore(date, productDbId, score); try { productDailySignalRepository.upsertLikeCount(productDbId, date, delta); @@ -36,7 +36,7 @@ public void applyLikeDelta(Long productDbId, int delta, LocalDate date) { } public void applyViewScore(Long productDbId, LocalDate date) { - double score = scoreAggregator.scoreForView() + tieBreakFraction(); + double score = scoreCalculator.scoreForView() + tieBreakFraction(); rankingRepository.incrementScore(date, productDbId, score); try { productDailySignalRepository.upsertViewCount(productDbId, date, 1); @@ -46,7 +46,7 @@ public void applyViewScore(Long productDbId, LocalDate date) { } public void applyOrderScore(Long productDbId, BigDecimal price, int quantity, LocalDate date) { - double score = scoreAggregator.scoreForOrder(price, quantity) + tieBreakFraction(); + double score = scoreCalculator.scoreForOrder(price, quantity) + tieBreakFraction(); rankingRepository.incrementScore(date, productDbId, score); try { double amount = price.doubleValue() * quantity; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingRecalculationApp.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingRecalculationApp.java index dfb88ecfc7..0e1d68e358 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingRecalculationApp.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingRecalculationApp.java @@ -3,7 +3,7 @@ import com.loopers.domain.ranking.ProductDailySignalModel; import com.loopers.domain.ranking.ProductDailySignalRepository; import com.loopers.domain.ranking.RankingRepository; -import com.loopers.domain.ranking.ScoreAggregator; +import com.loopers.ranking.ScoreCalculator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -19,7 +19,7 @@ public class RankingRecalculationApp { private final ProductDailySignalRepository productDailySignalRepository; - private final ScoreAggregator scoreAggregator; + private final ScoreCalculator scoreCalculator; private final RankingRepository rankingRepository; public long recalculate(LocalDate date) { @@ -31,7 +31,7 @@ public long recalculate(LocalDate date) { Map productScores = new HashMap<>(); for (ProductDailySignalModel signal : signals) { - double score = scoreAggregator.calculateTotal( + double score = scoreCalculator.calculateTotal( signal.getViewCount(), signal.getLikeCount(), signal.getOrderAmount().doubleValue() diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingKeyGenerator.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingKeyGenerator.java deleted file mode 100644 index 8bddc526af..0000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingKeyGenerator.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.loopers.domain.ranking; - -import lombok.NoArgsConstructor; - -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; - -@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) -public final class RankingKeyGenerator { - - private static final String KEY_PREFIX = "rank:all:"; - private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); - - public static String dailyKey(LocalDate date) { - return KEY_PREFIX + date.format(FORMATTER); - } - - public static String shadowKey(LocalDate date) { - return KEY_PREFIX + date.format(FORMATTER) + ":shadow"; - } - - public static String hourlyKey(LocalDate date, int hour) { - return KEY_PREFIX + date.format(FORMATTER) + ":" + String.format("%02d", hour); - } -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java index f28493d01a..46246e5455 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.ranking; -import com.loopers.domain.ranking.RankingKeyGenerator; +import com.loopers.ranking.RankingKeyGenerator; import com.loopers.domain.ranking.RankingRepository; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.io.ClassPathResource; diff --git a/docs/operation/s2-deploy-rollback.md b/docs/operation/s2-deploy-rollback.md new file mode 100644 index 0000000000..62b69e323c --- /dev/null +++ b/docs/operation/s2-deploy-rollback.md @@ -0,0 +1,91 @@ +# S2 배포·롤백 Runbook + +> 2026-04-15 / feat/week10-batch +> 변경 범위: 스키마 + commerce-api (reader JOIN) + commerce-batch (PublishingRankWriter) +> SLA: 배포 창 < 30분, 롤백 창 < 15분 + +## 사전 체크리스트 + +- [ ] `docs/operation/s2-migration.sql` DBA 리뷰 완료 서명 +- [ ] 스테이징에서 전체 회귀 테스트 PASS (batch + api) +- [ ] `NEW-T6` 부하 테스트 (S2 reader JOIN p95 < 10ms) PASS +- [ ] Publication 테이블 초기 seed 대상 periodKey 리스트 확보 +- [ ] 배포 창 고지 (주간 Job 비실행 시간대 — 새벽 2~4시) +- [ ] 롤백 담당자 on-call 확인 + +## 배포 순서 + +### Step 1. 스키마 마이그레이션 (무중단) +```bash +mysql -h prod-db -u migrator -p loopers < docs/operation/s2-migration.sql +``` +**검증 쿼리**: +```sql +SELECT COUNT(*) AS pub_rows FROM mv_product_rank_publication; +SELECT period_type, COUNT(*) FROM mv_product_rank_publication GROUP BY period_type; +``` +기존 주간/월간 periodKey 수와 일치 확인. 0건이면 STOP — seed 실패. + +### Step 2. commerce-api 배포 +- 구 reader(`WHERE period_key=?` 단독)는 `version` 컬럼 추가에도 호환 — 기존 행(version=1)만 읽어도 무해 +- 신 reader는 Publication JOIN 추가, published_version=1 기준으로 기존 데이터 그대로 서빙 +- **검증**: `/api/v1/rankings?period=weekly&date=…` 호출 시 응답 이전과 동일 + +### Step 3. commerce-batch 배포 +- 다음 주기 Job이 PublishingRankWriter로 version=2 발행 +- **검증**: batch 로그에서 `mvPublishDurationMs` 기록 확인 +- 한 번의 주간/월간 Job 성공 후 `mv_product_rank_publication.published_version`이 2로 증가 확인 + +### Step 4. 모니터링 (배포 후 24시간) +- `/api/v1/rankings` 500/빈응답 비율 < 0.1% +- `mv_product_rank_publication` row count 유지 +- `mv_product_rank_weekly` row count: published + orphan. Cleanup Job 1회 후 published만. + +## 롤백 순서 + +### 트리거 조건 (아래 중 하나) +- reader 빈 응답률 > 1% +- API p95 > 50ms (기존 대비 10배 이상) +- batch Job 연속 2회 FAILED +- `mv_product_rank_publication.updated_at` 최근 2h 없음 (publish 중단) + +### Step R1. commerce-batch 이전 버전으로 롤백 +- Writer가 PublishingRankWriter → AtomicMvRankWriter (구 Writer) 되는 이전 태그로 배포 +- **주의**: 구 Writer는 DELETE+INSERT 단일 tx. 현 스키마 PK `(period_key, version, rank_no)` 하에서는 DELETE가 전 version 제거. 즉시 publication 엉클해짐 → **Step R2 스키마 원복과 한 세트로 실행** + +### Step R2. 스키마 원복 (선택 — 구 reader가 version 컬럼에 무관하면 생략 가능) +```sql +-- version 컬럼 자체는 구 코드에 무해하나 PK 복원은 필요 +ALTER TABLE mv_product_rank_weekly DROP PRIMARY KEY, + ADD PRIMARY KEY (period_key, rank_no); +ALTER TABLE mv_product_rank_monthly DROP PRIMARY KEY, + ADD PRIMARY KEY (period_key, rank_no); +-- version이 중복인 행 정리 (구 Writer는 version 모르므로 중복 발생 가능 — 실제로는 PK 재정의 전 버전별 집계 필요) +-- 최악의 경우: 모든 version>1 행 삭제 후 PK 복원 +DELETE FROM mv_product_rank_weekly WHERE version > 1; +DELETE FROM mv_product_rank_monthly WHERE version > 1; +``` + +### Step R3. commerce-api 이전 버전으로 롤백 +- 신 reader(JOIN publication) → 구 reader +- Publication 테이블은 보존 — 재도전 시 재사용 + +### Step R4. 검증 +- `/api/v1/rankings` 응답 정상 복원 +- 주간 배치 재실행 (`--date=…`) → 구 DELETE+INSERT 패턴으로 정상 갱신 + +## 파싱 가능한 알람 조건 + +| 조건 | 심각도 | 대응 | +|---|---|---| +| `publication.updated_at` 최근 2h 없음 | HIGH | batch 로그 확인, 재실행 | +| MV reader empty rate > 1% | HIGH | 롤백 트리거 | +| `mvPublishDurationMs p95 > 1000` | MEDIUM | Cleanup Job 수동 실행, Publication hotspot 확인 | +| Cleanup Job 미실행 24h+ | LOW | orphan version 누적 점검 | + +## 배포 담당/책임 + +- DBA 마이그레이션: {이름} +- commerce-api 배포: {이름} +- commerce-batch 배포: {이름} +- 모니터링 on-call: {이름} — 배포 후 24h diff --git a/docs/operation/s2-migration.sql b/docs/operation/s2-migration.sql new file mode 100644 index 0000000000..be5f1f7f92 --- /dev/null +++ b/docs/operation/s2-migration.sql @@ -0,0 +1,71 @@ +-- ============================================================================ +-- S2 Atomic Swap 전환 마이그레이션 (2026-04-15) +-- Target: 기존 프로덕션 환경 (기존 mv_product_rank_weekly/_monthly 데이터 보존) +-- Precondition: commerce-api / commerce-batch 신 버전 배포 **이전** 실행 +-- ============================================================================ + +-- 1. MV 테이블에 version 컬럼 추가 (기존 행은 DEFAULT 1로 채움) +ALTER TABLE mv_product_rank_weekly + ADD COLUMN version BIGINT NOT NULL DEFAULT 1 AFTER period_key; + +ALTER TABLE mv_product_rank_monthly + ADD COLUMN version BIGINT NOT NULL DEFAULT 1 AFTER period_key; + +-- 2. PK 재정의: (period_key, rank_no) -> (period_key, version, rank_no) +ALTER TABLE mv_product_rank_weekly + DROP PRIMARY KEY, + ADD PRIMARY KEY (period_key, version, rank_no); + +ALTER TABLE mv_product_rank_monthly + DROP PRIMARY KEY, + ADD PRIMARY KEY (period_key, version, rank_no); + +-- 3. Unique key 재정의: version 포함 +ALTER TABLE mv_product_rank_weekly + DROP INDEX uk_period_product, + ADD UNIQUE KEY uk_period_version_product (period_key, version, ref_product_id); + +ALTER TABLE mv_product_rank_monthly + DROP INDEX uk_period_product_monthly, + ADD UNIQUE KEY uk_period_version_product_monthly (period_key, version, ref_product_id); + +-- 4. Publication 테이블 생성 +CREATE TABLE IF NOT EXISTS mv_product_rank_publication ( + period_type VARCHAR(20) NOT NULL, + period_key VARCHAR(8) NOT NULL, + published_version BIGINT NOT NULL DEFAULT 0, + next_version BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (period_type, period_key) +); + +-- 5. 기존 periodKey들에 대해 Publication 초기 row seed (기존 행은 version=1이므로 published=1) +INSERT INTO mv_product_rank_publication (period_type, period_key, published_version, next_version, updated_at) +SELECT 'WEEKLY', period_key, 1, 1, NOW(6) +FROM (SELECT DISTINCT period_key FROM mv_product_rank_weekly) w +ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at); + +INSERT INTO mv_product_rank_publication (period_type, period_key, published_version, next_version, updated_at) +SELECT 'MONTHLY', period_key, 1, 1, NOW(6) +FROM (SELECT DISTINCT period_key FROM mv_product_rank_monthly) m +ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at); + +-- 6. 검증 쿼리 (실행 후 결과 확인) +-- SELECT 'weekly rows', COUNT(*) FROM mv_product_rank_weekly; +-- SELECT 'monthly rows', COUNT(*) FROM mv_product_rank_monthly; +-- SELECT 'publication rows', COUNT(*) FROM mv_product_rank_publication; +-- SELECT period_type, COUNT(*) FROM mv_product_rank_publication GROUP BY period_type; +-- SELECT * FROM mv_product_rank_publication ORDER BY period_type, period_key LIMIT 10; + +-- ============================================================================ +-- 롤백 SQL (신 배포 실패 시 구버전으로 되돌릴 때 실행) +-- 주의: version 컬럼을 보존해도 구 reader는 `SELECT FROM mv_product_rank_weekly WHERE period_key=?` +-- 형태라 무해. PK는 구조 복원 필요 (version 포함된 상태로 두면 rank_no 중복 가능). +-- ============================================================================ +-- ALTER TABLE mv_product_rank_weekly +-- DROP PRIMARY KEY, +-- ADD PRIMARY KEY (period_key, rank_no); +-- ALTER TABLE mv_product_rank_monthly +-- DROP PRIMARY KEY, +-- ADD PRIMARY KEY (period_key, rank_no); +-- -- Publication 테이블은 보존 (다음 재시도용). 필요 시 DROP TABLE. diff --git a/settings.gradle.kts b/settings.gradle.kts index ff027592f4..4e91abf5f0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,7 @@ include( ":supports:jackson", ":supports:logging", ":supports:monitoring", + ":supports:ranking", ) // configurations diff --git a/supports/ranking/build.gradle.kts b/supports/ranking/build.gradle.kts new file mode 100644 index 0000000000..b278b3e59f --- /dev/null +++ b/supports/ranking/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + `java-library` +} + +dependencies { + implementation("org.springframework.boot:spring-boot-autoconfigure") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") +} diff --git a/supports/ranking/src/main/java/com/loopers/ranking/RankingAutoConfiguration.java b/supports/ranking/src/main/java/com/loopers/ranking/RankingAutoConfiguration.java new file mode 100644 index 0000000000..375de00715 --- /dev/null +++ b/supports/ranking/src/main/java/com/loopers/ranking/RankingAutoConfiguration.java @@ -0,0 +1,17 @@ +package com.loopers.ranking; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConditionalOnProperty(prefix = "ranking.weight", name = "view") +@EnableConfigurationProperties(RankingWeightProperties.class) +public class RankingAutoConfiguration { + + @Bean + public ScoreCalculator scoreCalculator(RankingWeightProperties properties) { + return new ScoreCalculator(properties); + } +} diff --git a/supports/ranking/src/main/java/com/loopers/ranking/RankingKeyGenerator.java b/supports/ranking/src/main/java/com/loopers/ranking/RankingKeyGenerator.java new file mode 100644 index 0000000000..ecb9aa56a0 --- /dev/null +++ b/supports/ranking/src/main/java/com/loopers/ranking/RankingKeyGenerator.java @@ -0,0 +1,77 @@ +package com.loopers.ranking; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; + +public final class RankingKeyGenerator { + + private static final String KEY_PREFIX = "rank:all:"; + private static final DateTimeFormatter DAILY_FMT = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter WEEK_FMT = DateTimeFormatter.ofPattern("YYYY'W'ww"); + private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyyMM"); + private static final int QUARTERLY_WINDOW_DAYS = 90; + + private RankingKeyGenerator() {} + + public static String dailyKey(LocalDate date) { + return KEY_PREFIX + date.format(DAILY_FMT); + } + + public static String shadowKey(LocalDate date) { + return KEY_PREFIX + date.format(DAILY_FMT) + ":shadow"; + } + + public static String hourlyKey(LocalDate date, int hour) { + return KEY_PREFIX + date.format(DAILY_FMT) + ":" + String.format("%02d", hour); + } + + public static String weeklyPeriodKey(LocalDate date) { + return date.format(WEEK_FMT); + } + + public static String monthlyPeriodKey(LocalDate date) { + return date.format(MONTH_FMT); + } + + public static LocalDate weekStart(LocalDate date) { + return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + } + + public static LocalDate weekEnd(LocalDate date) { + return weekStart(date).plusDays(6); + } + + public static LocalDate monthStart(LocalDate date) { + return date.withDayOfMonth(1); + } + + public static LocalDate monthEnd(LocalDate date) { + return date.withDayOfMonth(date.lengthOfMonth()); + } + + public static String previousWeeklyPeriodKey(LocalDate date) { + return weeklyPeriodKey(date.minusWeeks(1)); + } + + public static String previousMonthlyPeriodKey(LocalDate date) { + return monthlyPeriodKey(date.minusMonths(1)); + } + + public static String quarterlyPeriodKey(LocalDate date) { + return date.format(DAILY_FMT); + } + + public static LocalDate quarterlyStart(LocalDate date) { + return date.minusDays(QUARTERLY_WINDOW_DAYS - 1); + } + + public static LocalDate quarterlyEnd(LocalDate date) { + return date; + } + + public static String previousQuarterlyPeriodKey(LocalDate date) { + return quarterlyPeriodKey(date.minusDays(1)); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeightProperties.java b/supports/ranking/src/main/java/com/loopers/ranking/RankingWeightProperties.java similarity index 95% rename from apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeightProperties.java rename to supports/ranking/src/main/java/com/loopers/ranking/RankingWeightProperties.java index 4b665b8b33..20f22e3889 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeightProperties.java +++ b/supports/ranking/src/main/java/com/loopers/ranking/RankingWeightProperties.java @@ -1,4 +1,4 @@ -package com.loopers.domain.ranking; +package com.loopers.ranking; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/ScoreAggregator.java b/supports/ranking/src/main/java/com/loopers/ranking/ScoreCalculator.java similarity index 64% rename from apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/ScoreAggregator.java rename to supports/ranking/src/main/java/com/loopers/ranking/ScoreCalculator.java index eb9d443bec..8a75f984bc 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/ScoreAggregator.java +++ b/supports/ranking/src/main/java/com/loopers/ranking/ScoreCalculator.java @@ -1,16 +1,15 @@ -package com.loopers.domain.ranking; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; +package com.loopers.ranking; import java.math.BigDecimal; -@Service -@RequiredArgsConstructor -public class ScoreAggregator { +public class ScoreCalculator { private final RankingWeightProperties weights; + public ScoreCalculator(RankingWeightProperties weights) { + this.weights = weights; + } + public double scoreForView() { return weights.view(); } @@ -28,4 +27,8 @@ public double calculateTotal(long viewCount, long likeCount, double orderAmount) + weights.like() * likeCount + weights.order() * orderAmount; } + + public double calculateTotal(long viewCount, long likeCount, BigDecimal orderAmount) { + return calculateTotal(viewCount, likeCount, orderAmount.doubleValue()); + } }