From dc52f3ecf9a4481000dfed343685385a8274bde0 Mon Sep 17 00:00:00 2001 From: simoncho91 Date: Wed, 15 Apr 2026 17:07:19 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=209=EC=A3=BC=EC=B0=A8=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20=E2=80=94=20Kafka=20=ED=81=B4=EB=9F=AC=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EC=84=A4=EC=A0=95,=20fail-open=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4,=20timezone=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AS-IS: - Kafka 토픽 설정이 단일 브로커/클러스터 구분 없이 하나로 존재 - VIEWED 이벤트 발행/랭킹 조회 실패 시 상품 조회 전체 실패 - ORDERED 이벤트 Outbox 저장 실패 시 주문 전체 롤백 - LocalDate.now() 시스템 기본 타임존 사용 (서버 UTC 시 날짜 불일치) - RankingService safeOrderValue overflow 가능성 TO-BE: - @Profile("cluster") 분리 — replicas=3, min.isr=2 (클러스터 전용) - acks-one 프로파일 추가 (메시지 유실 실험용) - 비핵심 부수효과 fail-open 처리 (log.warn + 정상 응답) - ZoneId.of("Asia/Seoul") 명시적 적용 (RankingService, CarryOver) - Math.log1p + double 캐스팅으로 안전한 점수 계산 - CatalogEvent.of()에 ORDERED 가드 추가 - BYPASS_OUTBOX, SLOW_ACK 실험 플래그 추가 Co-Authored-By: Claude Opus 4.6 --- .../application/coupon/CouponService.java | 40 +++++++++++++++++++ .../application/order/OrderService.java | 27 ++++++++----- .../api/product/ProductV1Controller.java | 13 +++++- .../RankingCarryOverScheduler.java | 4 +- .../loopers/application/RankingService.java | 14 ++++--- .../consumer/CouponIssueConsumer.java | 9 +++++ .../src/main/resources/application.yml | 3 ++ docker/kafka-cluster-compose.yml | 9 +++-- .../com/loopers/confg/kafka/KafkaConfig.java | 26 +++++++++++- .../com/loopers/kafka/event/CatalogEvent.java | 3 ++ modules/kafka/src/main/resources/kafka.yml | 12 ++++++ 11 files changed, 137 insertions(+), 23 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java index 969ab2d09..22ad5067f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java @@ -13,19 +13,36 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.Instant; import java.util.UUID; +/** + * 실험용 플래그 + * BYPASS_OUTBOX=true → Outbox 없이 TX commit 후 직접 Kafka 발행 + * CRASH_BEFORE_SEND=true → TX commit 완료 후, Kafka send 직전 크래시 시뮬레이션 → 이벤트 유실 재현 + */ +@Slf4j @RequiredArgsConstructor @Component public class CouponService { + private static final boolean BYPASS_OUTBOX = + Boolean.parseBoolean(System.getenv().getOrDefault("BYPASS_OUTBOX", "false")); + + private static final boolean CRASH_BEFORE_SEND = + Boolean.parseBoolean(System.getenv().getOrDefault("CRASH_BEFORE_SEND", "false")); + private final CouponTemplateRepository couponTemplateRepository; private final IssuedCouponRepository issuedCouponRepository; private final OutboxEventRepository outboxEventRepository; + private final KafkaTemplate kafkaTemplate; private final ObjectMapper objectMapper; // 기존 동기 발급 (주문 플로우에서 사용) @@ -53,6 +70,29 @@ public CouponInfo.IssuedCouponInfo issue(Long memberId, Long couponTemplateId) { public String requestIssue(Long memberId, Long couponTemplateId) { String eventId = UUID.randomUUID().toString(); CouponIssueRequestEvent event = new CouponIssueRequestEvent(eventId, memberId, couponTemplateId, Instant.now().toEpochMilli()); + + if (BYPASS_OUTBOX) { + // [실험 3] Outbox 없이 직접 발행 — TX commit 후 afterCommit()에서 Kafka 발행 시도 + // DB commit과 Kafka send가 원자적이지 않음 → 그 사이 크래시 시 이벤트 유실 + String payload = objectMapper.writeValueAsString(event); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + if (CRASH_BEFORE_SEND) { + log.error("[Bypass] CRASH_BEFORE_SEND — DB commit 완료, Kafka send 전 크래시! eventId={}", eventId); + throw new RuntimeException("simulated crash before Kafka send"); + } + try { + kafkaTemplate.send(KafkaTopics.COUPON_ISSUE_REQUESTS, String.valueOf(couponTemplateId), objectMapper.readTree(payload)); + log.info("[Bypass] direct Kafka send eventId={}", eventId); + } catch (Exception e) { + log.error("[Bypass] Kafka send failed eventId={}", eventId, e); + } + } + }); + return eventId; + } + outboxEventRepository.save(OutboxEvent.create(eventId, KafkaTopics.COUPON_ISSUE_REQUESTS, String.valueOf(couponTemplateId), objectMapper.writeValueAsString(event))); return eventId; // 클라이언트가 결과 polling 시 사용 } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index 22eff4f48..dcf2b1bc8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -18,6 +18,7 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; @@ -28,6 +29,7 @@ import java.util.UUID; import java.util.stream.Collectors; +@Slf4j @RequiredArgsConstructor @Component public class OrderService { @@ -138,16 +140,21 @@ public OrderResult placeOrder(Long memberId, List items, Long .collect(Collectors.toList()); // Outbox에 ORDERED 이벤트 저장 (주문 라인별 1건씩 — 랭킹은 상품 단위) - for (Order.OrderLineEntity ol : order.getOrderLines()) { - String eventId = UUID.randomUUID().toString(); - CatalogEvent event = CatalogEvent.ordered( - eventId, ol.getProductId(), memberId, Instant.now().toEpochMilli(), - ol.getUnitPrice(), ol.getQuantity() - ); - outboxEventRepository.save(OutboxEvent.create( - eventId, KafkaTopics.CATALOG_EVENTS, String.valueOf(ol.getProductId()), - objectMapper.writeValueAsString(event) - )); + // 랭킹은 보조 지표이므로 실패해도 주문을 롤백하지 않는다 (fail-open) + try { + for (Order.OrderLineEntity ol : order.getOrderLines()) { + String eventId = UUID.randomUUID().toString(); + CatalogEvent event = CatalogEvent.ordered( + eventId, ol.getProductId(), memberId, Instant.now().toEpochMilli(), + ol.getUnitPrice(), ol.getQuantity() + ); + outboxEventRepository.save(OutboxEvent.create( + eventId, KafkaTopics.CATALOG_EVENTS, String.valueOf(ol.getProductId()), + objectMapper.writeValueAsString(event) + )); + } + } catch (Exception e) { + log.warn("[Order] ORDERED 이벤트 Outbox 저장 실패 orderId={}", order.getId(), e); } // 주문 완료 후 entered 키 삭제 — 슬롯 즉시 반환 (TTL 만료 대기 없이) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 37b20e238..e59c35dc4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -7,6 +7,7 @@ import com.loopers.domain.product.SortCondition; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -15,6 +16,7 @@ import java.util.List; +@Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/products") @@ -41,8 +43,15 @@ public ApiResponse getProductDetail( @PathVariable Long productId ) { ProductDetailInfo info = productFacade.getProductDetail(productId); - productFacade.publishViewedEvent(productId); - Long rank = rankingFacade.getRank(productId); + + // 비핵심 부수효과 — 실패해도 상품 조회 응답은 정상 반환 (fail-open) + try { productFacade.publishViewedEvent(productId); } + catch (Exception e) { log.warn("[ProductDetail] VIEWED 이벤트 발행 실패 productId={}", productId, e); } + + Long rank = null; + try { rank = rankingFacade.getRank(productId); } + catch (Exception e) { log.warn("[ProductDetail] 랭킹 조회 실패 productId={}", productId, e); } + return ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(info, rank)); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/RankingCarryOverScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/RankingCarryOverScheduler.java index f2247da5a..959c29c02 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/RankingCarryOverScheduler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/RankingCarryOverScheduler.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Component; import java.time.LocalDate; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.List; import java.util.concurrent.TimeUnit; @@ -25,6 +26,7 @@ public class RankingCarryOverScheduler { private static final DateTimeFormatter KEY_DATE_FMT = DateTimeFormatter.BASIC_ISO_DATE; + private static final ZoneId ZONE = ZoneId.of("Asia/Seoul"); private static final long TTL_DAYS = 2; private final RedisTemplate redisTemplate; @@ -37,7 +39,7 @@ public RankingCarryOverScheduler( @Scheduled(cron = "0 50 23 * * *") public void carryOver() { - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.now(ZONE); String todayKey = rankingKey(today); String tomorrowKey = rankingKey(today.plusDays(1)); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/RankingService.java index eb8e97aea..e628741c1 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/RankingService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/RankingService.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Component; import java.time.LocalDate; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.concurrent.TimeUnit; @@ -17,6 +18,7 @@ public class RankingService { private static final DateTimeFormatter KEY_DATE_FMT = DateTimeFormatter.BASIC_ISO_DATE; // yyyyMMdd + private static final ZoneId ZONE = ZoneId.of("Asia/Seoul"); private static final long TTL_DAYS = 2; private final RedisTemplate redisTemplate; @@ -50,17 +52,17 @@ private double calculateScore(CatalogEvent event) { case VIEWED -> 1; case LIKED -> 2; case UNLIKED -> -2; - case ORDERED -> 7 * Math.log(safeOrderValue(event) + 1); + case ORDERED -> 7 * Math.log1p(safeOrderValue(event)); }; } - private long safeOrderValue(CatalogEvent event) { - long price = event.price() != null ? event.price() : 0; - int quantity = event.quantity() != null ? event.quantity() : 0; - return price * quantity; + private double safeOrderValue(CatalogEvent event) { + long price = Math.max(event.price() != null ? event.price() : 0, 0); + int quantity = Math.max(event.quantity() != null ? event.quantity() : 0, 0); + return (double) price * quantity; } private String rankingKey() { - return "ranking:all:" + LocalDate.now().format(KEY_DATE_FMT); + return "ranking:all:" + LocalDate.now(ZONE).format(KEY_DATE_FMT); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java index e89509216..01f7128b3 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java @@ -38,10 +38,14 @@ public class CouponIssueConsumer { * 실험용 플래그 * USE_ATOMIC_LONG=true → AtomicLong (재시작 시 카운트 리셋 → 초과발급 재현) * USE_ATOMIC_LONG=false → DB 카운터 (재시작 후에도 유지 → 정확히 100장) + * SLOW_ACK=true → ack 전 10초 대기 (kill 타이밍 확보 → 재전달 재현) */ private static final boolean USE_ATOMIC_LONG = Boolean.parseBoolean(System.getenv().getOrDefault("USE_ATOMIC_LONG", "false")); + private static final boolean SLOW_ACK = + Boolean.parseBoolean(System.getenv().getOrDefault("SLOW_ACK", "false")); + private final AtomicLong atomicCount = new AtomicLong(0); private final EventHandledJpaRepository eventHandledRepository; @@ -92,6 +96,11 @@ public void consume(List> records, Acknowledgment log.error("[CouponIssue] failed record={} cause={}", record, e.getMessage()); } } + // [실험 2] SLOW_ACK: ack 전 10초 대기 → 이 사이에 kill → 재시작 후 같은 메시지 재전달 재현 + if (SLOW_ACK) { + log.info("[CouponIssue][SlowAck] sleeping 10s before ack — kill me now!"); + try { Thread.sleep(10_000); } catch (InterruptedException ignored) {} + } ack.acknowledge(); } } diff --git a/apps/commerce-streamer/src/main/resources/application.yml b/apps/commerce-streamer/src/main/resources/application.yml index 9a0d46281..92c42804f 100644 --- a/apps/commerce-streamer/src/main/resources/application.yml +++ b/apps/commerce-streamer/src/main/resources/application.yml @@ -34,6 +34,9 @@ spring: config: activate: on-profile: local, test + jpa: + hibernate: + ddl-auto: update --- spring: diff --git a/docker/kafka-cluster-compose.yml b/docker/kafka-cluster-compose.yml index d2a243088..ffd381295 100644 --- a/docker/kafka-cluster-compose.yml +++ b/docker/kafka-cluster-compose.yml @@ -1,11 +1,12 @@ version: '3' services: kafka-1: - image: bitnami/kafka:3.5.1 + image: bitnamilegacy/kafka:3.5.1 container_name: kafka-1 ports: - "19092:19092" environment: + - KAFKA_KRAFT_CLUSTER_ID=abcdefghijklmnopqrstuv - KAFKA_CFG_NODE_ID=1 - KAFKA_CFG_PROCESS_ROLES=broker,controller - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,PLAINTEXT_HOST://:19092,CONTROLLER://:9093 @@ -26,11 +27,12 @@ services: retries: 10 kafka-2: - image: bitnami/kafka:3.5.1 + image: bitnamilegacy/kafka:3.5.1 container_name: kafka-2 ports: - "29092:29092" environment: + - KAFKA_KRAFT_CLUSTER_ID=abcdefghijklmnopqrstuv - KAFKA_CFG_NODE_ID=2 - KAFKA_CFG_PROCESS_ROLES=broker,controller - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,PLAINTEXT_HOST://:29092,CONTROLLER://:9093 @@ -51,11 +53,12 @@ services: retries: 10 kafka-3: - image: bitnami/kafka:3.5.1 + image: bitnamilegacy/kafka:3.5.1 container_name: kafka-3 ports: - "39092:39092" environment: + - KAFKA_KRAFT_CLUSTER_ID=abcdefghijklmnopqrstuv - KAFKA_CFG_NODE_ID=3 - KAFKA_CFG_PROCESS_ROLES=broker,controller - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,PLAINTEXT_HOST://:39092,CONTROLLER://:9093 diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java index 90fcb80f9..287197b73 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java @@ -8,6 +8,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.kafka.annotation.EnableKafka; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.config.TopicBuilder; @@ -51,15 +52,17 @@ public KafkaTemplate kafkaTemplate(ProducerFactory Date: Wed, 15 Apr 2026 17:07:43 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20product=5Fmetrics=5Fdaily=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20Consumer=20=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AS-IS: - CatalogEventConsumer가 product_metrics(실시간)만 갱신 - 일별 스냅샷 데이터가 없어 주간/월간 배치 집계 불가 TO-BE: - ProductMetricsDaily 엔티티 신규 (@IdClass 복합 PK: productId + metricDate) - record() 메서드로 이벤트 타입별 카운트 증감 (VIEWED/LIKED/UNLIKED/ORDERED) - CatalogEventConsumer에 upsertDailyMetrics() 추가 — 이벤트 처리 시 daily 테이블 동시 적재 - 원천 데이터(카운트)만 저장, 점수 가공은 배치에서 수행 (원본/가공 분리) Co-Authored-By: Claude Opus 4.6 --- .../loopers/domain/ProductMetricsDaily.java | 71 +++++++++++++++++++ .../loopers/domain/ProductMetricsDailyId.java | 30 ++++++++ .../ProductMetricsDailyJpaRepository.java | 8 +++ .../consumer/CatalogEventConsumer.java | 21 ++++++ 4 files changed, 130 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyJpaRepository.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java new file mode 100644 index 000000000..2cea7f28d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java @@ -0,0 +1,71 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; + +import java.time.LocalDate; + +/** + * 상품별 일간 이벤트 메트릭 집계 테이블. + * + * Kafka Consumer가 이벤트를 수신할 때마다 해당 날짜의 카운트를 증감한다. + * 복합 PK (product_id + metric_date) 로 상품·날짜 단위 유일성을 보장한다. + */ +@Entity +@Table(name = "product_metrics_daily") +@IdClass(ProductMetricsDailyId.class) +public class ProductMetricsDaily { + + @Id + @Column(name = "product_id") + private Long productId; + + @Id + @Column(name = "metric_date") + private LocalDate metricDate; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_count", nullable = false) + private long orderCount; + + protected ProductMetricsDaily() {} + + public static ProductMetricsDaily init(Long productId, LocalDate metricDate) { + ProductMetricsDaily m = new ProductMetricsDaily(); + m.productId = productId; + m.metricDate = metricDate; + m.viewCount = 0; + m.likeCount = 0; + m.orderCount = 0; + return m; + } + + /** + * 이벤트 타입에 따라 해당 카운터를 증감한다. + * + * @param eventType VIEWED, LIKED, UNLIKED, ORDERED 중 하나 + */ + public void record(String eventType) { + switch (eventType) { + case "VIEWED" -> this.viewCount++; + case "LIKED" -> this.likeCount++; + case "UNLIKED" -> { if (this.likeCount > 0) this.likeCount--; } + case "ORDERED" -> this.orderCount++; + default -> throw new IllegalArgumentException("알 수 없는 이벤트 타입: " + eventType); + } + } + + public Long getProductId() { return productId; } + public LocalDate getMetricDate() { return metricDate; } + public long getViewCount() { return viewCount; } + public long getLikeCount() { return likeCount; } + public long getOrderCount() { return orderCount; } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java new file mode 100644 index 000000000..840f561c4 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java @@ -0,0 +1,30 @@ +package com.loopers.domain; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Objects; + +public class ProductMetricsDailyId implements Serializable { + + private Long productId; + private LocalDate metricDate; + + protected ProductMetricsDailyId() {} + + public ProductMetricsDailyId(Long productId, LocalDate metricDate) { + this.productId = productId; + this.metricDate = metricDate; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ProductMetricsDailyId that)) return false; + return Objects.equals(productId, that.productId) && Objects.equals(metricDate, that.metricDate); + } + + @Override + public int hashCode() { + return Objects.hash(productId, metricDate); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyJpaRepository.java new file mode 100644 index 000000000..b9b07c791 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.ProductMetricsDaily; +import com.loopers.domain.ProductMetricsDailyId; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductMetricsDailyJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java index 784b09f03..4f854dbf7 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java @@ -5,7 +5,10 @@ import com.loopers.confg.kafka.KafkaConfig; import com.loopers.domain.EventHandled; import com.loopers.domain.ProductMetrics; +import com.loopers.domain.ProductMetricsDaily; +import com.loopers.domain.ProductMetricsDailyId; import com.loopers.infrastructure.EventHandledJpaRepository; +import com.loopers.infrastructure.ProductMetricsDailyJpaRepository; import com.loopers.infrastructure.ProductMetricsJpaRepository; import com.loopers.kafka.event.CatalogEvent; import com.loopers.kafka.topic.KafkaTopics; @@ -17,6 +20,9 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; @Slf4j @@ -24,8 +30,11 @@ @RequiredArgsConstructor public class CatalogEventConsumer { + private static final ZoneId ZONE = ZoneId.of("Asia/Seoul"); + private final EventHandledJpaRepository eventHandledRepository; private final ProductMetricsJpaRepository productMetricsRepository; + private final ProductMetricsDailyJpaRepository productMetricsDailyRepository; private final RankingService rankingService; private final ObjectMapper objectMapper; @@ -43,6 +52,7 @@ public void consume(List> records, Acknowledgment } upsertMetrics(event); + upsertDailyMetrics(event); rankingService.updateRanking(event); eventHandledRepository.save(EventHandled.of(event.eventId())); log.info("[CatalogEvent] handled eventId={} type={} productId={}", event.eventId(), event.eventType(), event.productId()); @@ -54,6 +64,17 @@ public void consume(List> records, Acknowledgment ack.acknowledge(); } + private void upsertDailyMetrics(CatalogEvent event) { + LocalDate metricDate = Instant.ofEpochMilli(event.occurredAt()).atZone(ZONE).toLocalDate(); + ProductMetricsDailyId id = new ProductMetricsDailyId(event.productId(), metricDate); + + ProductMetricsDaily daily = productMetricsDailyRepository.findById(id) + .orElseGet(() -> productMetricsDailyRepository.save( + ProductMetricsDaily.init(event.productId(), metricDate))); + + daily.record(event.eventType()); + } + private void upsertMetrics(CatalogEvent event) { ProductMetrics metrics = productMetricsRepository.findById(event.productId()) .orElseGet(() -> productMetricsRepository.save(ProductMetrics.init(event.productId()))); From 94abd3de8fc296a470febd921fd47e730856eea5 Mon Sep 17 00:00:00 2001 From: simoncho91 Date: Wed, 15 Apr 2026 17:08:07 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0=EC=B9=98=20Job=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(Spring=20Batch=20Chunk-Oriented)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AS-IS: - 일간 랭킹만 Redis ZSET으로 제공 - 주간/월간 집계 데이터 없음 TO-BE: - weeklyRankingJob: product_metrics_daily 7일치 합산 → mv_product_rank_weekly TOP 100 적재 - monthlyRankingJob: product_metrics_daily 월간 합산 → mv_product_rank_monthly TOP 100 적재 - 2-Step 구조: Cleanup(Tasklet, 기존 데이터 삭제) → Aggregate(Chunk, 집계 + 적재) - JdbcCursorItemReader로 SQL 직접 작성 (모듈 간 JPA 엔티티 의존 회피) - @ConditionalOnProperty로 Job별 빈 생성 격리 - @StepScope + JobParameter로 실행 날짜 외부 주입 - 점수 산출: view*1 + like*2 + order*7 - 재실행 시 멱등 보장 (DELETE → INSERT) Co-Authored-By: Claude Opus 4.6 --- .../job/ranking/MonthlyRankingJobConfig.java | 124 ++++++++++++++++++ .../batch/job/ranking/ProductScoreRow.java | 25 ++++ .../job/ranking/WeeklyRankingJobConfig.java | 123 +++++++++++++++++ .../step/MonthlyRankingCleanupTasklet.java | 38 ++++++ .../ranking/step/MonthlyRankingProcessor.java | 34 +++++ .../step/WeeklyRankingCleanupTasklet.java | 38 ++++++ .../ranking/step/WeeklyRankingProcessor.java | 34 +++++ .../domain/ranking/MvProductRankMonthly.java | 69 ++++++++++ .../ranking/MvProductRankMonthlyId.java | 30 +++++ .../domain/ranking/MvProductRankWeekly.java | 69 ++++++++++ .../domain/ranking/MvProductRankWeeklyId.java | 30 +++++ .../MvProductRankMonthlyJpaRepository.java | 17 +++ .../MvProductRankWeeklyJpaRepository.java | 17 +++ 13 files changed, 648 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductScoreRow.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingCleanupTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingCleanupTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyId.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyId.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java new file mode 100644 index 000000000..7c119fee3 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -0,0 +1,124 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.step.MonthlyRankingCleanupTasklet; +import com.loopers.batch.job.ranking.step.MonthlyRankingProcessor; +import com.loopers.batch.listener.ChunkListener; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.MvProductRankMonthly; +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.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; +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.YearMonth; +import java.time.format.DateTimeFormatter; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class MonthlyRankingJobConfig { + + public static final String JOB_NAME = "monthlyRankingJob"; + private static final String STEP_CLEANUP = "monthlyRankingCleanupStep"; + private static final String STEP_AGGREGATE = "monthlyRankingAggregateStep"; + private static final int CHUNK_SIZE = 500; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ChunkListener chunkListener; + private final MonthlyRankingCleanupTasklet cleanupTasklet; + private final MonthlyRankingProcessor processor; + private final DataSource dataSource; + private final EntityManagerFactory entityManagerFactory; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(cleanupStep()) + .next(aggregateStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_CLEANUP) + public Step cleanupStep() { + return new StepBuilder(STEP_CLEANUP, jobRepository) + .tasklet(cleanupTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(STEP_AGGREGATE) + public Step aggregateStep() { + return new StepBuilder(STEP_AGGREGATE, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyReader(null)) + .processor(processor) + .writer(monthlyWriter()) + .listener(stepMonitorListener) + .listener(chunkListener) + .build(); + } + + @StepScope + @Bean + public JdbcCursorItemReader monthlyReader( + @Value("#{jobParameters['monthStartDate']}") String monthStartDate + ) { + LocalDate start = LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE); + LocalDate end = YearMonth.from(start).atEndOfMonth(); + + return new JdbcCursorItemReaderBuilder() + .name("monthlyRankingReader") + .dataSource(dataSource) + .sql(""" + SELECT product_id, + SUM(view_count) AS view_count, + SUM(like_count) AS like_count, + SUM(order_count) AS order_count, + (SUM(view_count) * 1 + SUM(like_count) * 2 + SUM(order_count) * 7) AS total_score + FROM product_metrics_daily + WHERE metric_date BETWEEN ? AND ? + GROUP BY product_id + ORDER BY total_score DESC + LIMIT 100 + """) + .preparedStatementSetter(ps -> { + ps.setObject(1, start); + ps.setObject(2, end); + }) + .beanRowMapper(ProductScoreRow.class) + .build(); + } + + @StepScope + @Bean + public JpaItemWriter monthlyWriter() { + return new JpaItemWriterBuilder() + .entityManagerFactory(entityManagerFactory) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductScoreRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductScoreRow.java new file mode 100644 index 000000000..a13bf8932 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductScoreRow.java @@ -0,0 +1,25 @@ +package com.loopers.batch.job.ranking; + +/** + * JdbcCursorItemReader 집계 결과를 담는 DTO. + * product_metrics_daily GROUP BY 결과 → (productId, counts, totalScore) + */ +public class ProductScoreRow { + + private Long productId; + private long viewCount; + private long likeCount; + private long orderCount; + private double totalScore; + + public Long getProductId() { return productId; } + public void setProductId(Long productId) { this.productId = productId; } + public long getViewCount() { return viewCount; } + public void setViewCount(long viewCount) { this.viewCount = viewCount; } + public long getLikeCount() { return likeCount; } + public void setLikeCount(long likeCount) { this.likeCount = likeCount; } + public long getOrderCount() { return orderCount; } + public void setOrderCount(long orderCount) { this.orderCount = orderCount; } + public double getTotalScore() { return totalScore; } + public void setTotalScore(double totalScore) { this.totalScore = totalScore; } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java new file mode 100644 index 000000000..634981123 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -0,0 +1,123 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.step.WeeklyRankingCleanupTasklet; +import com.loopers.batch.job.ranking.step.WeeklyRankingProcessor; +import com.loopers.batch.listener.ChunkListener; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.MvProductRankWeekly; +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.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; +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; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class WeeklyRankingJobConfig { + + public static final String JOB_NAME = "weeklyRankingJob"; + private static final String STEP_CLEANUP = "weeklyRankingCleanupStep"; + private static final String STEP_AGGREGATE = "weeklyRankingAggregateStep"; + private static final int CHUNK_SIZE = 500; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ChunkListener chunkListener; + private final WeeklyRankingCleanupTasklet cleanupTasklet; + private final WeeklyRankingProcessor processor; + private final DataSource dataSource; + private final EntityManagerFactory entityManagerFactory; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(cleanupStep()) + .next(aggregateStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_CLEANUP) + public Step cleanupStep() { + return new StepBuilder(STEP_CLEANUP, jobRepository) + .tasklet(cleanupTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(STEP_AGGREGATE) + public Step aggregateStep() { + return new StepBuilder(STEP_AGGREGATE, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyReader(null)) + .processor(processor) + .writer(weeklyWriter()) + .listener(stepMonitorListener) + .listener(chunkListener) + .build(); + } + + @StepScope + @Bean + public JdbcCursorItemReader weeklyReader( + @Value("#{jobParameters['weekStartDate']}") String weekStartDate + ) { + LocalDate start = LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE); + LocalDate end = start.plusDays(6); + + return new JdbcCursorItemReaderBuilder() + .name("weeklyRankingReader") + .dataSource(dataSource) + .sql(""" + SELECT product_id, + SUM(view_count) AS view_count, + SUM(like_count) AS like_count, + SUM(order_count) AS order_count, + (SUM(view_count) * 1 + SUM(like_count) * 2 + SUM(order_count) * 7) AS total_score + FROM product_metrics_daily + WHERE metric_date BETWEEN ? AND ? + GROUP BY product_id + ORDER BY total_score DESC + LIMIT 100 + """) + .preparedStatementSetter(ps -> { + ps.setObject(1, start); + ps.setObject(2, end); + }) + .beanRowMapper(ProductScoreRow.class) + .build(); + } + + @StepScope + @Bean + public JpaItemWriter weeklyWriter() { + return new JpaItemWriterBuilder() + .entityManagerFactory(entityManagerFactory) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingCleanupTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingCleanupTasklet.java new file mode 100644 index 000000000..2b8e0694c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingCleanupTasklet.java @@ -0,0 +1,38 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class MonthlyRankingCleanupTasklet implements Tasklet { + + private final MvProductRankMonthlyJpaRepository repository; + + @Value("#{jobParameters['monthStartDate']}") + private String monthStartDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + LocalDate startDate = LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE); + repository.deleteByMonthStartDate(startDate); + log.info("[MonthlyCleanup] deleted monthStartDate={}", startDate); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.java new file mode 100644 index 000000000..379c1e5ef --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.java @@ -0,0 +1,34 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import com.loopers.batch.job.ranking.ProductScoreRow; +import com.loopers.domain.ranking.MvProductRankMonthly; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@Component +public class MonthlyRankingProcessor implements ItemProcessor { + + private int rankCounter; + + @Value("#{jobParameters['monthStartDate']}") + private String monthStartDate; + + @Override + public MvProductRankMonthly process(ProductScoreRow item) { + LocalDate startDate = LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE); + return MvProductRankMonthly.of( + item.getProductId(), startDate, item.getTotalScore(), + item.getViewCount(), item.getLikeCount(), item.getOrderCount(), + ++rankCounter + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingCleanupTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingCleanupTasklet.java new file mode 100644 index 000000000..d8b1a2623 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingCleanupTasklet.java @@ -0,0 +1,38 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class WeeklyRankingCleanupTasklet implements Tasklet { + + private final MvProductRankWeeklyJpaRepository repository; + + @Value("#{jobParameters['weekStartDate']}") + private String weekStartDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + LocalDate startDate = LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE); + repository.deleteByWeekStartDate(startDate); + log.info("[WeeklyCleanup] deleted weekStartDate={}", startDate); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingProcessor.java new file mode 100644 index 000000000..a49e30816 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingProcessor.java @@ -0,0 +1,34 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.batch.job.ranking.ProductScoreRow; +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import com.loopers.domain.ranking.MvProductRankWeekly; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@Component +public class WeeklyRankingProcessor implements ItemProcessor { + + private int rankCounter; + + @Value("#{jobParameters['weekStartDate']}") + private String weekStartDate; + + @Override + public MvProductRankWeekly process(ProductScoreRow item) { + LocalDate startDate = LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE); + return MvProductRankWeekly.of( + item.getProductId(), startDate, item.getTotalScore(), + item.getViewCount(), item.getLikeCount(), item.getOrderCount(), + ++rankCounter + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java new file mode 100644 index 000000000..542eedda2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -0,0 +1,69 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "mv_product_rank_monthly") +@IdClass(MvProductRankMonthlyId.class) +public class MvProductRankMonthly { + + @Id + @Column(name = "product_id") + private Long productId; + + @Id + @Column(name = "month_start_date") + private LocalDate monthStartDate; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_count", nullable = false) + private long orderCount; + + @Column(name = "total_score", nullable = false) + private double totalScore; + + @Column(name = "rank_position", nullable = false) + private int rankPosition; + + @Column(name = "aggregated_at", nullable = false) + private LocalDateTime aggregatedAt; + + protected MvProductRankMonthly() {} + + public static MvProductRankMonthly of( + Long productId, LocalDate monthStartDate, double totalScore, + long viewCount, long likeCount, long orderCount, int rankPosition + ) { + MvProductRankMonthly entity = new MvProductRankMonthly(); + entity.productId = productId; + entity.monthStartDate = monthStartDate; + entity.viewCount = viewCount; + entity.likeCount = likeCount; + entity.orderCount = orderCount; + entity.totalScore = totalScore; + entity.rankPosition = rankPosition; + entity.aggregatedAt = LocalDateTime.now(); + return entity; + } + + public Long getProductId() { return productId; } + public LocalDate getMonthStartDate() { return monthStartDate; } + public long getViewCount() { return viewCount; } + public long getLikeCount() { return likeCount; } + public long getOrderCount() { return orderCount; } + public double getTotalScore() { return totalScore; } + public int getRankPosition() { return rankPosition; } + public LocalDateTime getAggregatedAt() { return aggregatedAt; } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyId.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyId.java new file mode 100644 index 000000000..d21802218 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyId.java @@ -0,0 +1,30 @@ +package com.loopers.domain.ranking; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Objects; + +public class MvProductRankMonthlyId implements Serializable { + + private Long productId; + private LocalDate monthStartDate; + + protected MvProductRankMonthlyId() {} + + public MvProductRankMonthlyId(Long productId, LocalDate monthStartDate) { + this.productId = productId; + this.monthStartDate = monthStartDate; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MvProductRankMonthlyId that)) return false; + return Objects.equals(productId, that.productId) && Objects.equals(monthStartDate, that.monthStartDate); + } + + @Override + public int hashCode() { + return Objects.hash(productId, monthStartDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java new file mode 100644 index 000000000..0211f04da --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -0,0 +1,69 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "mv_product_rank_weekly") +@IdClass(MvProductRankWeeklyId.class) +public class MvProductRankWeekly { + + @Id + @Column(name = "product_id") + private Long productId; + + @Id + @Column(name = "week_start_date") + private LocalDate weekStartDate; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_count", nullable = false) + private long orderCount; + + @Column(name = "total_score", nullable = false) + private double totalScore; + + @Column(name = "rank_position", nullable = false) + private int rankPosition; + + @Column(name = "aggregated_at", nullable = false) + private LocalDateTime aggregatedAt; + + protected MvProductRankWeekly() {} + + public static MvProductRankWeekly of( + Long productId, LocalDate weekStartDate, double totalScore, + long viewCount, long likeCount, long orderCount, int rankPosition + ) { + MvProductRankWeekly entity = new MvProductRankWeekly(); + entity.productId = productId; + entity.weekStartDate = weekStartDate; + entity.viewCount = viewCount; + entity.likeCount = likeCount; + entity.orderCount = orderCount; + entity.totalScore = totalScore; + entity.rankPosition = rankPosition; + entity.aggregatedAt = LocalDateTime.now(); + return entity; + } + + public Long getProductId() { return productId; } + public LocalDate getWeekStartDate() { return weekStartDate; } + public long getViewCount() { return viewCount; } + public long getLikeCount() { return likeCount; } + public long getOrderCount() { return orderCount; } + public double getTotalScore() { return totalScore; } + public int getRankPosition() { return rankPosition; } + public LocalDateTime getAggregatedAt() { return aggregatedAt; } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyId.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyId.java new file mode 100644 index 000000000..9ce5d3796 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyId.java @@ -0,0 +1,30 @@ +package com.loopers.domain.ranking; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Objects; + +public class MvProductRankWeeklyId implements Serializable { + + private Long productId; + private LocalDate weekStartDate; + + protected MvProductRankWeeklyId() {} + + public MvProductRankWeeklyId(Long productId, LocalDate weekStartDate) { + this.productId = productId; + this.weekStartDate = weekStartDate; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MvProductRankWeeklyId that)) return false; + return Objects.equals(productId, that.productId) && Objects.equals(weekStartDate, that.weekStartDate); + } + + @Override + public int hashCode() { + return Objects.hash(productId, weekStartDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 000000000..48239854f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.MvProductRankMonthlyId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM MvProductRankMonthly m WHERE m.monthStartDate = :monthStartDate") + void deleteByMonthStartDate(@Param("monthStartDate") LocalDate monthStartDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 000000000..2860ed439 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.MvProductRankWeeklyId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM MvProductRankWeekly m WHERE m.weekStartDate = :weekStartDate") + void deleteByWeekStartDate(@Param("weekStartDate") LocalDate weekStartDate); +} From 24b2722fcf8cd4ae3162135bab54bd6f2fbe1d82 Mon Sep 17 00:00:00 2001 From: simoncho91 Date: Wed, 15 Apr 2026 17:08:43 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20Ranking=20API=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20=E2=80=94=20period=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=EB=A1=9C=20=EC=9D=BC=EA=B0=84/=EC=A3=BC=EA=B0=84/?= =?UTF-8?q?=EC=9B=94=EA=B0=84=20=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AS-IS: - GET /api/v1/rankings → 일간(Redis ZSET)만 조회 가능 TO-BE: - GET /api/v1/rankings?period=daily → Redis ZSET (기존) - GET /api/v1/rankings?period=weekly → mv_product_rank_weekly (MV 테이블) - GET /api/v1/rankings?period=monthly → mv_product_rank_monthly (MV 테이블) - RankingPeriod enum, ProductRankEntry VO 신규 - commerce-api 전용 조회 엔티티 (@Immutable) — commerce-batch 의존 없이 독립 - RankingRepositoryImpl로 MV 테이블 조회 → 도메인 VO 변환 - RankingFacade에 enrichRankEntries() 헬퍼로 상품/브랜드 정보 공통 조합 Co-Authored-By: Claude Opus 4.6 --- .../application/ranking/RankingFacade.java | 86 ++++++++++++++++--- .../domain/ranking/ProductRankEntry.java | 11 +++ .../loopers/domain/ranking/RankingPeriod.java | 5 ++ .../domain/ranking/RankingRepository.java | 11 +++ .../ranking/MvProductRankMonthlyEntity.java | 59 +++++++++++++ .../MvProductRankMonthlyJpaRepository.java | 12 +++ .../ranking/MvProductRankWeeklyEntity.java | 59 +++++++++++++ .../MvProductRankWeeklyJpaRepository.java | 12 +++ .../ranking/RankingRepositoryImpl.java | 36 ++++++++ .../api/ranking/RankingV1ApiSpec.java | 6 +- .../api/ranking/RankingV1Controller.java | 43 +++++++++- 11 files changed, 324 insertions(+), 16 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankEntry.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index 80d846222..f2f5514bb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -4,13 +4,18 @@ import com.loopers.domain.product.BrandRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.ranking.ProductRankEntry; +import com.loopers.domain.ranking.RankingRepository; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ZSetOperations.TypedTuple; import org.springframework.stereotype.Component; +import java.time.DayOfWeek; import java.time.LocalDate; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -21,24 +26,28 @@ public class RankingFacade { private static final DateTimeFormatter KEY_DATE_FMT = DateTimeFormatter.BASIC_ISO_DATE; + private static final ZoneId ZONE = ZoneId.of("Asia/Seoul"); private static final String DEFAULT_REDIS_TEMPLATE = "defaultRedisTemplate"; private final RedisTemplate redisTemplate; private final ProductRepository productRepository; private final BrandRepository brandRepository; + private final RankingRepository rankingRepository; public RankingFacade( @Qualifier(DEFAULT_REDIS_TEMPLATE) RedisTemplate redisTemplate, ProductRepository productRepository, - BrandRepository brandRepository + BrandRepository brandRepository, + RankingRepository rankingRepository ) { this.redisTemplate = redisTemplate; this.productRepository = productRepository; this.brandRepository = brandRepository; + this.rankingRepository = rankingRepository; } /** - * Top-N 랭킹 조회 (ZREVRANGE + 상품 정보 Aggregation) + * 일간 Top-N 랭킹 조회 (Redis ZSET) */ public List getTopRankings(String date, int page, int size) { String key = rankingKey(date); @@ -54,18 +63,11 @@ public List getTopRankings(String date, int page, int size) { .map(t -> Long.parseLong(t.getValue())) .toList(); - Map productMap = productRepository.findAllByIds(productIds).stream() - .collect(Collectors.toMap(Product::getId, p -> p)); - - Map brandMap = productMap.values().stream() - .map(Product::getBrandId) - .filter(id -> id != null) - .distinct() - .flatMap(id -> brandRepository.findById(id).stream()) - .collect(Collectors.toMap(Brand::getId, b -> b)); + Map productMap = fetchProductMap(productIds); + Map brandMap = fetchBrandMap(productMap); long rank = start + 1; - List result = new java.util.ArrayList<>(); + List result = new ArrayList<>(); for (TypedTuple tuple : tuples) { Long productId = Long.parseLong(tuple.getValue()); Product product = productMap.get(productId); @@ -86,15 +88,73 @@ public List getTopRankings(String date, int page, int size) { return result; } + /** + * 주간 랭킹 조회 (MV 테이블) + */ + public List getWeeklyRankings(LocalDate date, int page, int size) { + LocalDate weekStart = date.with(DayOfWeek.MONDAY); + List entries = rankingRepository.findWeeklyRankings(weekStart, page, size); + return enrichRankEntries(entries); + } + + /** + * 월간 랭킹 조회 (MV 테이블) + */ + public List getMonthlyRankings(LocalDate date, int page, int size) { + LocalDate monthStart = date.withDayOfMonth(1); + List entries = rankingRepository.findMonthlyRankings(monthStart, page, size); + return enrichRankEntries(entries); + } + /** * 개별 상품 순위 조회 (ZREVRANK, 0-based → 1-based 변환) */ public Long getRank(Long productId) { - String key = rankingKey(LocalDate.now().format(KEY_DATE_FMT)); + String key = rankingKey(LocalDate.now(ZONE).format(KEY_DATE_FMT)); Long rank = redisTemplate.opsForZSet().reverseRank(key, String.valueOf(productId)); return rank != null ? rank + 1 : null; } + private List enrichRankEntries(List entries) { + if (entries.isEmpty()) { + return Collections.emptyList(); + } + + List productIds = entries.stream().map(ProductRankEntry::productId).toList(); + Map productMap = fetchProductMap(productIds); + Map brandMap = fetchBrandMap(productMap); + + List result = new ArrayList<>(); + for (ProductRankEntry entry : entries) { + Product product = productMap.get(entry.productId()); + if (product == null) continue; + Brand brand = product.getBrandId() != null ? brandMap.get(product.getBrandId()) : null; + result.add(new RankingInfo( + entry.rankPosition(), + entry.productId(), + product.getName(), + product.getPrice(), + brand != null ? brand.getName() : null, + entry.totalScore() + )); + } + return result; + } + + private Map fetchProductMap(List productIds) { + return productRepository.findAllByIds(productIds).stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + } + + private Map fetchBrandMap(Map productMap) { + return productMap.values().stream() + .map(Product::getBrandId) + .filter(id -> id != null) + .distinct() + .flatMap(id -> brandRepository.findById(id).stream()) + .collect(Collectors.toMap(Brand::getId, b -> b)); + } + private String rankingKey(String date) { return "ranking:all:" + date; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankEntry.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankEntry.java new file mode 100644 index 000000000..1beacb1e5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankEntry.java @@ -0,0 +1,11 @@ +package com.loopers.domain.ranking; + +/** + * MV 테이블 조회 결과를 담는 도메인 VO. + * weekly/monthly 공통으로 사용한다. + */ +public record ProductRankEntry( + Long productId, + double totalScore, + int rankPosition +) {} 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 000000000..05aaa4f02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java @@ -0,0 +1,5 @@ +package com.loopers.domain.ranking; + +public enum RankingPeriod { + DAILY, WEEKLY, MONTHLY +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java new file mode 100644 index 000000000..e8fbca53d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +public interface RankingRepository { + + List findWeeklyRankings(LocalDate weekStartDate, int page, int size); + + List findMonthlyRankings(LocalDate monthStartDate, int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java new file mode 100644 index 000000000..ae7fdc4d3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java @@ -0,0 +1,59 @@ +package com.loopers.infrastructure.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import org.hibernate.annotations.Immutable; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Objects; + +@Immutable +@Entity +@Table(name = "mv_product_rank_monthly") +@IdClass(MvProductRankMonthlyEntity.PK.class) +public class MvProductRankMonthlyEntity { + + @Id + @Column(name = "product_id") + private Long productId; + + @Id + @Column(name = "month_start_date") + private LocalDate monthStartDate; + + @Column(name = "total_score") + private double totalScore; + + @Column(name = "rank_position") + private int rankPosition; + + @Column(name = "aggregated_at") + private LocalDateTime aggregatedAt; + + public Long getProductId() { return productId; } + public LocalDate getMonthStartDate() { return monthStartDate; } + public double getTotalScore() { return totalScore; } + public int getRankPosition() { return rankPosition; } + + public static class PK implements Serializable { + private Long productId; + private LocalDate monthStartDate; + + protected PK() {} + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PK pk)) return false; + return Objects.equals(productId, pk.productId) && Objects.equals(monthStartDate, pk.monthStartDate); + } + + @Override + public int hashCode() { return Objects.hash(productId, monthStartDate); } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 000000000..cf7b23242 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.ranking; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + List findByMonthStartDateOrderByRankPositionAsc(LocalDate monthStartDate, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java new file mode 100644 index 000000000..4261a8726 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java @@ -0,0 +1,59 @@ +package com.loopers.infrastructure.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import org.hibernate.annotations.Immutable; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Objects; + +@Immutable +@Entity +@Table(name = "mv_product_rank_weekly") +@IdClass(MvProductRankWeeklyEntity.PK.class) +public class MvProductRankWeeklyEntity { + + @Id + @Column(name = "product_id") + private Long productId; + + @Id + @Column(name = "week_start_date") + private LocalDate weekStartDate; + + @Column(name = "total_score") + private double totalScore; + + @Column(name = "rank_position") + private int rankPosition; + + @Column(name = "aggregated_at") + private LocalDateTime aggregatedAt; + + public Long getProductId() { return productId; } + public LocalDate getWeekStartDate() { return weekStartDate; } + public double getTotalScore() { return totalScore; } + public int getRankPosition() { return rankPosition; } + + public static class PK implements Serializable { + private Long productId; + private LocalDate weekStartDate; + + protected PK() {} + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PK pk)) return false; + return Objects.equals(productId, pk.productId) && Objects.equals(weekStartDate, pk.weekStartDate); + } + + @Override + public int hashCode() { return Objects.hash(productId, weekStartDate); } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 000000000..eec70998c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.ranking; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + List findByWeekStartDateOrderByRankPositionAsc(LocalDate weekStartDate, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java new file mode 100644 index 000000000..2ee82caa6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRankEntry; +import com.loopers.domain.ranking.RankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Repository +public class RankingRepositoryImpl implements RankingRepository { + + private final MvProductRankWeeklyJpaRepository weeklyJpaRepository; + private final MvProductRankMonthlyJpaRepository monthlyJpaRepository; + + @Override + public List findWeeklyRankings(LocalDate weekStartDate, int page, int size) { + return weeklyJpaRepository + .findByWeekStartDateOrderByRankPositionAsc(weekStartDate, PageRequest.of(page - 1, size)) + .stream() + .map(e -> new ProductRankEntry(e.getProductId(), e.getTotalScore(), e.getRankPosition())) + .toList(); + } + + @Override + public List findMonthlyRankings(LocalDate monthStartDate, int page, int size) { + return monthlyJpaRepository + .findByMonthStartDateOrderByRankPositionAsc(monthStartDate, PageRequest.of(page - 1, size)) + .stream() + .map(e -> new ProductRankEntry(e.getProductId(), e.getTotalScore(), e.getRankPosition())) + .toList(); + } +} 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 3449b918d..b640060d4 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 @@ -10,8 +10,12 @@ @Tag(name = "Ranking V1 API", description = "상품 랭킹 API") public interface RankingV1ApiSpec { - @Operation(summary = "일별 상품 랭킹 조회", description = "Redis ZSET 기반 일별 인기 상품 Top-N을 조회합니다.") + @Operation( + summary = "상품 랭킹 조회", + description = "기간별 인기 상품 랭킹을 조회합니다. daily=Redis ZSET, weekly/monthly=배치 집계 MV 테이블." + ) ApiResponse> getTopRankings( + @Parameter(description = "집계 기간 (daily, weekly, monthly)") String period, @Parameter(description = "조회 날짜 (yyyyMMdd)") String date, @Parameter(description = "페이지 크기") int size, @Parameter(description = "페이지 번호 (1부터 시작)") int page diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index a6ea25538..57f3c98e8 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 @@ -2,7 +2,10 @@ import com.loopers.application.ranking.RankingFacade; import com.loopers.application.ranking.RankingInfo; +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.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -11,6 +14,7 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.List; @RequiredArgsConstructor @@ -18,20 +22,55 @@ @RequestMapping("/api/v1/rankings") public class RankingV1Controller implements RankingV1ApiSpec { + private static final int MAX_SIZE = 100; private final RankingFacade rankingFacade; @GetMapping @Override public ApiResponse> getTopRankings( + @RequestParam(defaultValue = "daily") String period, @RequestParam(required = false) String date, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "1") int page ) { - String resolvedDate = date != null ? date : LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - List rankings = rankingFacade.getTopRankings(resolvedDate, page, size); + if (page < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "page는 1 이상이어야 합니다."); + } + if (size < 1 || size > MAX_SIZE) { + throw new CoreException(ErrorType.BAD_REQUEST, "size는 1~" + MAX_SIZE + " 범위여야 합니다."); + } + + RankingPeriod rankingPeriod = parseRankingPeriod(period); + LocalDate resolvedDate = resolveDate(date); + + List rankings = switch (rankingPeriod) { + case DAILY -> rankingFacade.getTopRankings(resolvedDate.format(DateTimeFormatter.BASIC_ISO_DATE), page, size); + case WEEKLY -> rankingFacade.getWeeklyRankings(resolvedDate, page, size); + case MONTHLY -> rankingFacade.getMonthlyRankings(resolvedDate, page, size); + }; + List response = rankings.stream() .map(RankingV1Dto.RankingResponse::from) .toList(); return ApiResponse.success(response); } + + private RankingPeriod parseRankingPeriod(String period) { + try { + return RankingPeriod.valueOf(period.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "period는 daily, weekly, monthly 중 하나여야 합니다."); + } + } + + private LocalDate resolveDate(String date) { + if (date == null) { + return LocalDate.now(java.time.ZoneId.of("Asia/Seoul")); + } + try { + return LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "date 형식은 yyyyMMdd여야 합니다."); + } + } } From 6d467afd12e41c1271c557ea4757b4b329c1cc09 Mon Sep 17 00:00:00 2001 From: simoncho91 Date: Thu, 16 Apr 2026 13:30:18 +0900 Subject: [PATCH 05/10] =?UTF-8?q?test:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0=EC=B9=98=20+=20Ranki?= =?UTF-8?q?ng=20API=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AS-IS - 주간/월간 배치 Job과 period 확장 API가 단위 테스트만 존재 TO-BE - WeeklyRankingJobE2ETest: 7일치 daily 데이터 집계 + 멱등성 검증 (2건) - MonthlyRankingJobE2ETest: 월간 daily 집계 + 다른 달 배제 + 멱등성 검증 (2건) - RankingV1ApiE2ETest: weekly/monthly 조회 + period/date 파라미터 검증 (4건) 설계 포인트 - @TestPropertySource에 spring.batch.job.enabled=false 추가 → auto-launch가 weekStartDate 없이 job을 먼저 실행해 NPE 내는 것을 차단 (@ConditionalOnProperty 는 spring.batch.job.name만 보므로 Job 빈 로딩에는 영향 없음) - product_metrics_daily 엔티티는 streamer 모듈에 있으므로 배치 테스트에서 @BeforeAll로 CREATE TABLE 수동 생성 + @AfterEach로 DELETE - commerce-api의 @Immutable MV 엔티티에는 count 컬럼이 없으므로 API 테스트 INSERT에서 view/like/order count 컬럼 제거 Co-Authored-By: Claude Opus 4.6 --- .../interfaces/api/RankingV1ApiE2ETest.java | 167 +++++++++++++++++ .../job/ranking/MonthlyRankingJobE2ETest.java | 161 +++++++++++++++++ .../job/ranking/WeeklyRankingJobE2ETest.java | 171 ++++++++++++++++++ 3 files changed, 499 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java new file mode 100644 index 000000000..09d0ff389 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java @@ -0,0 +1,167 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.Product; +import com.loopers.infrastructure.product.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ranking.RankingV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RankingV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/rankings"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/rankings?period=weekly") + @Nested + class WeeklyRanking { + + @DisplayName("주간 랭킹을 조회하면, MV 테이블에서 순위 데이터를 반환한다") + @Test + void returnsWeeklyRankings() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("나이키")); + Product product1 = productJpaRepository.save(new Product(brand.getId(), "에어맥스", 150000L, 100)); + Product product2 = productJpaRepository.save(new Product(brand.getId(), "조던", 200000L, 50)); + + LocalDate weekStart = LocalDate.of(2026, 4, 13); + insertWeeklyMv(weekStart, product1.getId(), 1, 565.0); + insertWeeklyMv(weekStart, product2.getId(), 2, 210.0); + + // act + String url = ENDPOINT + "?period=weekly&date=20260415&size=10&page=1"; + var response = exchange(url); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data()).hasSize(2), + () -> assertThat(response.getBody().data().get(0).rank()).isEqualTo(1), + () -> assertThat(response.getBody().data().get(0).productName()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().get(0).score()).isEqualTo(565.0), + () -> assertThat(response.getBody().data().get(1).rank()).isEqualTo(2), + () -> assertThat(response.getBody().data().get(1).productName()).isEqualTo("조던") + ); + } + } + + @DisplayName("GET /api/v1/rankings?period=monthly") + @Nested + class MonthlyRanking { + + @DisplayName("월간 랭킹을 조회하면, MV 테이블에서 순위 데이터를 반환한다") + @Test + void returnsMonthlyRankings() { + // arrange + Brand brand = brandJpaRepository.save(new Brand("아디다스")); + Product product1 = productJpaRepository.save(new Product(brand.getId(), "울트라부스트", 180000L, 80)); + + LocalDate monthStart = LocalDate.of(2026, 4, 1); + insertMonthlyMv(monthStart, product1.getId(), 1, 1200.0); + + // act + String url = ENDPOINT + "?period=monthly&date=20260416&size=10&page=1"; + var response = exchange(url); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data()).hasSize(1), + () -> assertThat(response.getBody().data().get(0).rank()).isEqualTo(1), + () -> assertThat(response.getBody().data().get(0).productName()).isEqualTo("울트라부스트"), + () -> assertThat(response.getBody().data().get(0).brandName()).isEqualTo("아디다스") + ); + } + } + + @DisplayName("GET /api/v1/rankings 파라미터 검증") + @Nested + class Validation { + + @DisplayName("잘못된 period를 주면 400을 반환한다") + @Test + void returnsBadRequest_whenInvalidPeriod() { + String url = ENDPOINT + "?period=yearly"; + var response = exchange(url); + + assertTrue(response.getStatusCode().is4xxClientError()); + } + + @DisplayName("잘못된 date 형식을 주면 400을 반환한다") + @Test + void returnsBadRequest_whenInvalidDateFormat() { + String url = ENDPOINT + "?period=weekly&date=2026-04-13"; + var response = exchange(url); + + assertTrue(response.getStatusCode().is4xxClientError()); + } + } + + private ResponseEntity>> exchange(String url) { + return testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + } + + private void insertWeeklyMv(LocalDate weekStart, Long productId, int rankPosition, double totalScore) { + jdbcTemplate.update( + """ + INSERT INTO mv_product_rank_weekly + (week_start_date, product_id, rank_position, total_score, aggregated_at) + VALUES (?, ?, ?, ?, NOW()) + """, + weekStart, productId, rankPosition, totalScore + ); + } + + private void insertMonthlyMv(LocalDate monthStart, Long productId, int rankPosition, double totalScore) { + jdbcTemplate.update( + """ + INSERT INTO mv_product_rank_monthly + (month_start_date, product_id, rank_position, total_score, aggregated_at) + VALUES (?, ?, ?, ?, NOW()) + """, + monthStart, productId, rankPosition, totalScore + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java new file mode 100644 index 000000000..8c6e25167 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java @@ -0,0 +1,161 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = { + "spring.batch.job.name=" + MonthlyRankingJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class MonthlyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(MonthlyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private MvProductRankMonthlyJpaRepository mvRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeAll + static void createTable(@Autowired JdbcTemplate jdbcTemplate) { + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS product_metrics_daily ( + product_id BIGINT NOT NULL, + metric_date DATE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + order_count BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (product_id, metric_date) + ) ENGINE=InnoDB + """); + } + + @AfterEach + void tearDown() { + jdbcTemplate.execute("DELETE FROM product_metrics_daily"); + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("월간 랭킹 배치 — 해당 월 daily 데이터를 집계하여 TOP 순위를 생성한다") + @Test + void monthlyRankingJob_aggregatesDailyMetrics() throws Exception { + // arrange + LocalDate monthStart = LocalDate.of(2026, 4, 1); + insertDailyMetrics(1L, monthStart, 100, 50, 10); + insertDailyMetrics(1L, monthStart.plusDays(15), 200, 30, 5); + insertDailyMetrics(2L, monthStart.plusDays(10), 50, 10, 20); + // 다른 달 데이터 — 집계에 포함되면 안 됨 + insertDailyMetrics(3L, LocalDate.of(2026, 3, 31), 999, 999, 999); + + jobLauncherTestUtils.setJob(job); + + var jobParameters = new JobParametersBuilder() + .addString("monthStartDate", monthStart.format(DateTimeFormatter.BASIC_ISO_DATE)) + .addLong("run.id", System.nanoTime()) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + List results = mvRepository.findAll(); + + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(results).hasSize(2), + () -> assertThat(results.stream().noneMatch(r -> r.getProductId().equals(3L))).isTrue() + ); + + // 상품1: view(300)*1 + like(80)*2 + order(15)*7 = 565 + // 상품2: view(50)*1 + like(10)*2 + order(20)*7 = 210 + MvProductRankMonthly rank1 = results.stream() + .filter(r -> r.getRankPosition() == 1).findFirst().orElseThrow(); + + assertAll( + () -> assertThat(rank1.getProductId()).isEqualTo(1L), + () -> assertThat(rank1.getTotalScore()).isEqualTo(565.0), + () -> assertThat(rank1.getMonthStartDate()).isEqualTo(monthStart) + ); + } + + @DisplayName("월간 랭킹 배치 — 재실행 시 기존 데이터를 삭제 후 재적재한다 (멱등)") + @Test + void monthlyRankingJob_isIdempotent() throws Exception { + // arrange + LocalDate monthStart = LocalDate.of(2026, 4, 1); + insertDailyMetrics(1L, monthStart, 100, 50, 10); + + jobLauncherTestUtils.setJob(job); + + var firstParams = new JobParametersBuilder() + .addString("monthStartDate", monthStart.format(DateTimeFormatter.BASIC_ISO_DATE)) + .addLong("run.id", System.nanoTime()) + .toJobParameters(); + + // act — 2번 실행 + jobLauncherTestUtils.launchJob(firstParams); + + var secondParams = new JobParametersBuilder() + .addString("monthStartDate", monthStart.format(DateTimeFormatter.BASIC_ISO_DATE)) + .addLong("run.id", System.nanoTime() + 1) + .toJobParameters(); + var secondExecution = jobLauncherTestUtils.launchJob(secondParams); + + // assert — 중복 없이 1건만 존재 + assertAll( + () -> assertThat(secondExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(mvRepository.findAll()).hasSize(1) + ); + } + + private void insertDailyMetrics(Long productId, LocalDate metricDate, + long viewCount, long likeCount, long orderCount) { + jdbcTemplate.update( + """ + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_count) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + view_count = view_count + VALUES(view_count), + like_count = like_count + VALUES(like_count), + order_count = order_count + VALUES(order_count) + """, + productId, metricDate, viewCount, likeCount, orderCount + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java new file mode 100644 index 000000000..8d05713e9 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java @@ -0,0 +1,171 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +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.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = { + "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class WeeklyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(WeeklyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private MvProductRankWeeklyJpaRepository mvRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static boolean tableCreated = false; + + @BeforeAll + static void createTable(@Autowired JdbcTemplate jdbcTemplate) { + if (!tableCreated) { + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS product_metrics_daily ( + product_id BIGINT NOT NULL, + metric_date DATE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + order_count BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (product_id, metric_date) + ) ENGINE=InnoDB + """); + tableCreated = true; + } + } + + @AfterEach + void tearDown() { + jdbcTemplate.execute("DELETE FROM product_metrics_daily"); + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("주간 랭킹 배치 — 7일치 daily 데이터를 집계하여 TOP 순위를 생성한다") + @Test + void weeklyRankingJob_aggregatesDailyMetrics() throws Exception { + // arrange + LocalDate weekStart = LocalDate.of(2026, 4, 13); // 월요일 + insertDailyMetrics(1L, weekStart, 100, 50, 10); + insertDailyMetrics(1L, weekStart.plusDays(1), 200, 30, 5); + insertDailyMetrics(2L, weekStart, 50, 10, 20); + insertDailyMetrics(3L, weekStart.plusDays(2), 10, 5, 1); + + jobLauncherTestUtils.setJob(job); + + var jobParameters = new JobParametersBuilder() + .addString("weekStartDate", weekStart.format(DateTimeFormatter.BASIC_ISO_DATE)) + .addLong("run.id", System.nanoTime()) + .toJobParameters(); + + // act + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + List results = mvRepository.findAll(); + + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(results).hasSize(3), + () -> assertThat(results.get(0).getRankPosition()).isEqualTo(1), + () -> assertThat(results.get(0).getWeekStartDate()).isEqualTo(weekStart) + ); + + // 상품1: view(300)*1 + like(80)*2 + order(15)*7 = 300+160+105 = 565 + // 상품2: view(50)*1 + like(10)*2 + order(20)*7 = 50+20+140 = 210 + // 상품3: view(10)*1 + like(5)*2 + order(1)*7 = 10+10+7 = 27 + MvProductRankWeekly rank1 = results.stream() + .filter(r -> r.getRankPosition() == 1).findFirst().orElseThrow(); + MvProductRankWeekly rank2 = results.stream() + .filter(r -> r.getRankPosition() == 2).findFirst().orElseThrow(); + + assertAll( + () -> assertThat(rank1.getProductId()).isEqualTo(1L), + () -> assertThat(rank1.getTotalScore()).isEqualTo(565.0), + () -> assertThat(rank2.getProductId()).isEqualTo(2L), + () -> assertThat(rank2.getTotalScore()).isEqualTo(210.0) + ); + } + + @DisplayName("주간 랭킹 배치 — 재실행 시 기존 데이터를 삭제 후 재적재한다 (멱등)") + @Test + void weeklyRankingJob_isIdempotent() throws Exception { + // arrange + LocalDate weekStart = LocalDate.of(2026, 4, 13); + insertDailyMetrics(1L, weekStart, 100, 50, 10); + + jobLauncherTestUtils.setJob(job); + + var firstParams = new JobParametersBuilder() + .addString("weekStartDate", weekStart.format(DateTimeFormatter.BASIC_ISO_DATE)) + .addLong("run.id", System.nanoTime()) + .toJobParameters(); + + // act — 2번 실행 + jobLauncherTestUtils.launchJob(firstParams); + + var secondParams = new JobParametersBuilder() + .addString("weekStartDate", weekStart.format(DateTimeFormatter.BASIC_ISO_DATE)) + .addLong("run.id", System.nanoTime() + 1) + .toJobParameters(); + var secondExecution = jobLauncherTestUtils.launchJob(secondParams); + + // assert — 중복 없이 1건만 존재 + assertAll( + () -> assertThat(secondExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(mvRepository.findAll()).hasSize(1) + ); + } + + private void insertDailyMetrics(Long productId, LocalDate metricDate, + long viewCount, long likeCount, long orderCount) { + jdbcTemplate.update( + """ + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_count) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + view_count = view_count + VALUES(view_count), + like_count = like_count + VALUES(like_count), + order_count = order_count + VALUES(order_count) + """, + productId, metricDate, viewCount, likeCount, orderCount + ); + } +} From 2551073b602ed2fb3f09be10d2335d3b655e39a6 Mon Sep 17 00:00:00 2001 From: simoncho91 Date: Thu, 16 Apr 2026 13:30:32 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=EC=84=A0=ED=96=89=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=B3=B4=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AS-IS - FakeProductRepository 3곳에 findAllByIds() 미구현 (9주차 랭킹 조회 API 도입 시 인터페이스 확장됨) - OrderServiceTest가 OutboxEventRepository/ObjectMapper 없이 OrderService 생성자 호출 (7주차 Outbox 도입 시 시그니처 변경됨) TO-BE - FakeProductRepository (OrderServiceTest/LikeServiceTest/OrderDomainServiceTest) 에 findAllByIds 스텁 추가 - OrderServiceTest setUp에 FakeOutboxEventRepository + ObjectMapper 주입 Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/like/LikeServiceTest.java | 8 ++++++++ .../application/order/OrderServiceTest.java | 14 +++++++++++++- .../domain/order/OrderDomainServiceTest.java | 8 ++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java index d42d51437..b853b7c44 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java @@ -150,6 +150,14 @@ public java.util.List findAll(com.loopers.domain.product.SortCondition public boolean existsById(Long id) { return store.containsKey(id); } + + @Override + public java.util.List findAllByIds(java.util.List ids) { + return ids.stream() + .map(store::get) + .filter(java.util.Objects::nonNull) + .toList(); + } } static class FakeLikeRepository implements LikeRepository { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java index 7d9ce2845..2a6bc3c50 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java @@ -49,7 +49,11 @@ void setUp() { OrderDomainService orderDomainService = new OrderDomainService(fakeProductRepository, fakeOrderRepository); // FakeQueueRepository: isEntered()가 항상 true → 테스트에서 대기열 검증 통과 QueueService queueService = new QueueService(new FakeQueueRepository(), new QueueSseRegistry()); - orderService = new OrderService(orderDomainService, fakeIssuedCouponRepository, fakeCouponTemplateRepository, queueService); + com.loopers.domain.outbox.OutboxEventRepository fakeOutboxEventRepository = new com.loopers.domain.outbox.OutboxEventRepository() { + @Override public com.loopers.domain.outbox.OutboxEvent save(com.loopers.domain.outbox.OutboxEvent event) { return event; } + @Override public List findPending() { return List.of(); } + }; + orderService = new OrderService(orderDomainService, fakeIssuedCouponRepository, fakeCouponTemplateRepository, queueService, fakeOutboxEventRepository, new com.fasterxml.jackson.databind.ObjectMapper()); } @DisplayName("주문 생성") @@ -200,6 +204,14 @@ public List findAll(SortCondition sort) { public boolean existsById(Long id) { return store.containsKey(id); } + + @Override + public List findAllByIds(List ids) { + return ids.stream() + .map(store::get) + .filter(java.util.Objects::nonNull) + .toList(); + } } static class FakeOrderRepository implements OrderRepository { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java index b9408d800..b84be8a57 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java @@ -121,6 +121,14 @@ public List findAll(SortCondition sort) { public boolean existsById(Long id) { return store.containsKey(id); } + + @Override + public List findAllByIds(List ids) { + return ids.stream() + .map(store::get) + .filter(java.util.Objects::nonNull) + .toList(); + } } static class FakeOrderRepository implements OrderRepository { From bf1d672606f95e9862625e8774b85ad7c7b60f6e Mon Sep 17 00:00:00 2001 From: simoncho91 Date: Thu, 16 Apr 2026 13:31:00 +0900 Subject: [PATCH 07/10] =?UTF-8?q?chore:=20GET=20/api/v1/rankings=20k6=20?= =?UTF-8?q?=EB=B6=80=ED=95=98=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AS-IS - 일간/주간/월간 랭킹 조회 API의 성능 비교 수단 없음 TO-BE - tests/k6/get-rankings.js: period 파라미터로 daily/weekly/monthly 각각 부하 - 프로필 4종 (smoke/load/stress/spike) 기준 정의 - 로드 프로필(VU 100, 30s)로 p95<500ms, p99<1000ms, 에러율<1% threshold Co-Authored-By: Claude Opus 4.6 --- tests/k6/get-rankings.js | 90 ++++++++++++++++++++++++++++++++++++++++ tests/k6/profiles.json | 28 +++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tests/k6/get-rankings.js create mode 100644 tests/k6/profiles.json diff --git a/tests/k6/get-rankings.js b/tests/k6/get-rankings.js new file mode 100644 index 000000000..87853a5cb --- /dev/null +++ b/tests/k6/get-rankings.js @@ -0,0 +1,90 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +// ── 커스텀 메트릭 ── +const errorRate = new Rate('errors'); +const latency = new Trend('request_latency'); + +// ── 파라미터 ── +// PERIOD: daily | weekly | monthly (기본 daily) +// BASE_URL: http://localhost:8080 (기본) +// TARGET_DATE: yyyyMMdd (기본 20260416) +// SIZE: 페이지 크기 (기본 20) +const PERIOD = __ENV.PERIOD || 'daily'; +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const TARGET_DATE = __ENV.TARGET_DATE || '20260416'; +const SIZE = __ENV.SIZE || '20'; + +// ── 부하 설정 (load profile — VUs 100, 30s, stages) ── +export const options = { + stages: [ + { duration: '9s', target: 50 }, // ramp-up + { duration: '15s', target: 100 }, // sustain + { duration: '6s', target: 0 }, // ramp-down + ], + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + errors: ['rate<0.01'], + }, + tags: { period: PERIOD }, +}; + +// ── 시나리오 ── +export default function () { + const url = `${BASE_URL}/api/v1/rankings?period=${PERIOD}&date=${TARGET_DATE}&size=${SIZE}&page=1`; + + const res = http.get(url, { + headers: { 'Accept': 'application/json' }, + tags: { endpoint: 'GET /api/v1/rankings', period: PERIOD }, + }); + + check(res, { + 'status is 200': (r) => r.status === 200, + 'response time < 500ms': (r) => r.timings.duration < 500, + 'body has data field': (r) => { + try { + const body = JSON.parse(r.body); + return body.meta && body.meta.result === 'SUCCESS'; + } catch (e) { + return false; + } + }, + }); + + errorRate.add(res.status !== 200); + latency.add(res.timings.duration); + + sleep(1); +} + +// ── 실행 요약 (teardown) ── +export function handleSummary(data) { + const metrics = data.metrics; + const p95 = metrics.http_req_duration.values['p(95)'].toFixed(2); + const p99 = metrics.http_req_duration.values['p(99)'].toFixed(2); + const avg = metrics.http_req_duration.values.avg.toFixed(2); + const errRate = ((metrics.errors?.values?.rate || 0) * 100).toFixed(3); + const totalReqs = metrics.http_reqs.values.count; + const tps = metrics.http_reqs.values.rate.toFixed(2); + + const summary = ` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +부하테스트 — GET /api/v1/rankings?period=${PERIOD} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + VUs: 100 (load profile) + Duration: 30s (9s ramp + 15s sustain + 6s down) + Total Requests: ${totalReqs} + Effective TPS: ${tps} + Error Rate: ${errRate}% + Latency: + - avg: ${avg}ms + - p95: ${p95}ms + - p99: ${p99}ms +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +`; + + return { + stdout: summary, + }; +} diff --git a/tests/k6/profiles.json b/tests/k6/profiles.json new file mode 100644 index 000000000..c1b8b8e69 --- /dev/null +++ b/tests/k6/profiles.json @@ -0,0 +1,28 @@ +{ + "profiles": { + "smoke": { + "vus": 5, + "duration": "10s", + "pattern": "constant", + "thresholds": { "p95": 500, "p99": 1000, "errorRate": 0.01 } + }, + "load": { + "vus": 100, + "duration": "30s", + "pattern": "stages", + "thresholds": { "p95": 500, "p99": 1000, "errorRate": 0.01 } + }, + "stress": { + "vus": 500, + "duration": "60s", + "pattern": "stages", + "thresholds": { "p95": 1000, "p99": 2000, "errorRate": 0.05 } + }, + "spike": { + "vus": 1000, + "duration": "30s", + "pattern": "spike", + "thresholds": { "p95": 2000, "p99": 5000, "errorRate": 0.10 } + } + } +} From d415f39326029af453cf06b626eaf2101d13ffce Mon Sep 17 00:00:00 2001 From: simoncho91 Date: Thu, 16 Apr 2026 17:24:18 +0900 Subject: [PATCH 08/10] =?UTF-8?q?perf:=20Brand=20=EC=A1=B0=ED=9A=8C=20N+1?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20(findAllByIds=20=EB=8F=84=EC=9E=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 랭킹 조회 시 Brand 정보를 채우는 fetchBrandMap이 distinct brand_id마다 findById를 개별 호출하여 N+1 발생 (요청당 5회 round trip). findAllByIds로 IN 절 배치 조회로 변경. [k6 부하테스트 결과 (warmup 동일 적용, 100 VU 30s)] - weekly p99: 74.10ms → 11.82ms (-84%) - monthly p99: 59.47ms → 14.26ms (-76%) - daily p99: 45.45ms → 30.95ms (-32%) AS-IS: brandRepository.findById(id) × N TO-BE: brandRepository.findAllByIds(brandIds) × 1 --- .../com/loopers/application/ranking/RankingFacade.java | 8 ++++++-- .../java/com/loopers/domain/product/BrandRepository.java | 3 +++ .../infrastructure/product/BrandRepositoryImpl.java | 6 ++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index f2f5514bb..a962eea00 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -147,11 +147,15 @@ private Map fetchProductMap(List productIds) { } private Map fetchBrandMap(Map productMap) { - return productMap.values().stream() + List brandIds = productMap.values().stream() .map(Product::getBrandId) .filter(id -> id != null) .distinct() - .flatMap(id -> brandRepository.findById(id).stream()) + .toList(); + if (brandIds.isEmpty()) { + return Collections.emptyMap(); + } + return brandRepository.findAllByIds(brandIds).stream() .collect(Collectors.toMap(Brand::getId, b -> b)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java index 3491f67d4..f5c7c5415 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java @@ -1,5 +1,6 @@ package com.loopers.domain.product; +import java.util.List; import java.util.Optional; public interface BrandRepository { @@ -7,4 +8,6 @@ public interface BrandRepository { Brand save(Brand brand); Optional findById(Long id); + + List findAllByIds(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandRepositoryImpl.java index ff9fedafc..409b5d63c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandRepositoryImpl.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.List; import java.util.Optional; @RequiredArgsConstructor @@ -22,4 +23,9 @@ public Brand save(Brand brand) { public Optional findById(Long id) { return brandJpaRepository.findById(id); } + + @Override + public List findAllByIds(List ids) { + return brandJpaRepository.findAllById(ids); + } } From 1af5bd985f8b9e033daeacafea1c628b9ba08dfb Mon Sep 17 00:00:00 2001 From: simoncho91 Date: Thu, 16 Apr 2026 17:25:06 +0900 Subject: [PATCH 09/10] =?UTF-8?q?chore:=20k6=20=EB=B6=80=ED=95=98=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=90=EB=8F=99=ED=99=94=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(seed/run-al?= =?UTF-8?q?l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배치로 생성한 MV 테이블 + Redis ZSET을 일괄 시드하고, daily/weekly/monthly 3개 period를 순차 측정하는 자동화 스크립트. - seed.sh: Brand 5 + Product 100 UPSERT + MV 100 INSERT + ZSET ZADD - run-all.sh: health 체크 후 ORDER/LABEL/WARMUP 옵션으로 k6 일괄 실행 - ORDER: 실행 순서 변경 (cold start 영향 격리 실험용) - LABEL: 결과 파일 접미사 (실험 구분) - WARMUP: 메인 측정 전 warmup 단계 (cold start 제거 실험용) .gitignore: tests/k6/results/, .claude/, infra.md 제외 --- .gitignore | 7 ++++ tests/k6/run-all.sh | 73 ++++++++++++++++++++++++++++++++++++ tests/k6/seed.sh | 90 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100755 tests/k6/run-all.sh create mode 100755 tests/k6/seed.sh diff --git a/.gitignore b/.gitignore index 5a979af6f..c61e8e6ec 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,10 @@ out/ ### Kotlin ### .kotlin + +### k6 load test outputs ### +tests/k6/results/ + +### Claude Code workspace ### +.claude/ +infra.md diff --git a/tests/k6/run-all.sh b/tests/k6/run-all.sh new file mode 100755 index 000000000..e29c62c0b --- /dev/null +++ b/tests/k6/run-all.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# k6 부하테스트 일괄 실행 — daily / weekly / monthly +# 전제조건: +# 1) commerce-api 서버가 :8080 에서 기동 중 +# 2) seed.sh로 시드 데이터 생성 완료 + +set -e + +BASE_URL="${BASE_URL:-http://localhost:8080}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RESULT_DIR="${SCRIPT_DIR}/results" +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +# ORDER: 실행 순서 (콤마 구분). 예: ORDER=monthly,weekly,daily +ORDER="${ORDER:-daily,weekly,monthly}" +# LABEL: 결과 파일 접미사 (실험 구분용). 예: LABEL=reversed +LABEL="${LABEL:-}" +# WARMUP: 메인 측정 전 warmup 여부 (true|false). 기본 false +WARMUP="${WARMUP:-false}" +# WARMUP_SEC: warmup 지속 시간 (초). 기본 10 +WARMUP_SEC="${WARMUP_SEC:-10}" + +mkdir -p "${RESULT_DIR}" + +echo "=== 서버 health 체크 ===" +HEALTH_URL="${BASE_URL}/api/v1/rankings?period=daily&date=20260416&size=1&page=1" +if ! curl -s -f -o /dev/null "${HEALTH_URL}"; then + echo "ERROR: ${HEALTH_URL} 응답 없음. 서버를 먼저 기동하세요." + exit 1 +fi +echo "OK" + +SUFFIX="" +[ -n "${LABEL}" ] && SUFFIX="-${LABEL}" + +echo "=== 실행 순서: ${ORDER} ${LABEL:+(label=${LABEL})} ===" + +IFS=',' read -ra PERIODS <<< "${ORDER}" +for PERIOD in "${PERIODS[@]}"; do + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " k6 실행 — period=${PERIOD}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + if [ "${WARMUP}" = "true" ]; then + WARMUP_URL="${BASE_URL}/api/v1/rankings?period=${PERIOD}&date=20260416&size=20&page=1" + echo ">> warmup ${WARMUP_SEC}s — period=${PERIOD}" + WARMUP_END=$(( $(date +%s) + WARMUP_SEC )) + WARMUP_COUNT=0 + while [ $(date +%s) -lt ${WARMUP_END} ]; do + curl -s -o /dev/null "${WARMUP_URL}" & + curl -s -o /dev/null "${WARMUP_URL}" & + curl -s -o /dev/null "${WARMUP_URL}" & + curl -s -o /dev/null "${WARMUP_URL}" + WARMUP_COUNT=$(( WARMUP_COUNT + 4 )) + done + wait + echo ">> warmup 완료 (총 ${WARMUP_COUNT} 요청)" + fi + + OUTPUT_FILE="${RESULT_DIR}/get-rankings-${PERIOD}${SUFFIX}-${TIMESTAMP}.txt" + k6 run \ + -e PERIOD="${PERIOD}" \ + -e BASE_URL="${BASE_URL}" \ + --summary-trend-stats="avg,min,med,max,p(90),p(95),p(99)" \ + "${SCRIPT_DIR}/get-rankings.js" \ + 2>&1 | tee "${OUTPUT_FILE}" + + echo "-> 결과 저장: ${OUTPUT_FILE}" +done + +echo "" +echo "=== 완료 ===" +ls -lh "${RESULT_DIR}" | tail -3 diff --git a/tests/k6/seed.sh b/tests/k6/seed.sh new file mode 100755 index 000000000..4d414de3b --- /dev/null +++ b/tests/k6/seed.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# k6 부하테스트 시드 데이터 생성 스크립트 +# 전제조건: commerce-api 서버가 기동되어 MV 테이블이 Hibernate DDL로 생성된 상태 + +set -e + +MYSQL="docker exec docker-mysql-1 mysql -uroot -proot -N -e" +REDIS="docker exec redis-master redis-cli" + +TODAY="${TODAY:-2026-04-16}" # daily ZSET 대상 +WEEK_START="${WEEK_START:-2026-04-13}" # 월요일 (ISO-8601) +MONTH_START="${MONTH_START:-2026-04-01}" +TOP_N="${TOP_N:-100}" + +echo "=== 선행 체크 ===" +${MYSQL} "USE loopers; SHOW TABLES LIKE 'mv_product_rank_weekly';" loopers 2>/dev/null | grep -q mv_product_rank_weekly \ + || { echo "ERROR: mv_product_rank_weekly 테이블이 없습니다. commerce-api를 먼저 기동하세요."; exit 1; } + +echo "=== 1) Brand 시드 (UPSERT) ===" +${MYSQL} " +USE loopers; +INSERT INTO brand (id, name, created_at, updated_at) +VALUES + (1, 'Nike', NOW(), NOW()), + (2, 'Adidas', NOW(), NOW()), + (3, 'New Balance', NOW(), NOW()), + (4, 'Puma', NOW(), NOW()), + (5, 'Asics', NOW(), NOW()) +ON DUPLICATE KEY UPDATE name=VALUES(name), updated_at=NOW(); +" 2>/dev/null + +echo "=== 2) Product 시드 (TOP_N 개) ===" +# 기존 product 지우지 않고 UPSERT — id 1..TOP_N +# brand_id는 1..5 순환, price 10000~300000 랜덤 느낌, stock 100 +SQL="USE loopers; " +for i in $(seq 1 ${TOP_N}); do + BRAND_ID=$(( (i - 1) % 5 + 1 )) + PRICE=$(( 10000 + (i * 2971) % 290000 )) + SQL+="INSERT INTO product (id, name, price, stock_quantity, brand_id, likes_count, created_at, updated_at) " + SQL+="VALUES (${i}, 'Product-${i}', ${PRICE}, 100, ${BRAND_ID}, 0, NOW(), NOW()) " + SQL+="ON DUPLICATE KEY UPDATE name=VALUES(name), price=VALUES(price), updated_at=NOW();" +done +${MYSQL} "${SQL}" 2>/dev/null + +echo "=== 3) mv_product_rank_weekly 시드 (TOP_N) ===" +${MYSQL} "USE loopers; DELETE FROM mv_product_rank_weekly WHERE week_start_date = '${WEEK_START}';" 2>/dev/null +SQL="USE loopers; INSERT INTO mv_product_rank_weekly (week_start_date, product_id, rank_position, total_score, aggregated_at) VALUES " +for i in $(seq 1 ${TOP_N}); do + SCORE=$(( (TOP_N - i + 1) * 10 )) + SQL+="('${WEEK_START}', ${i}, ${i}, ${SCORE}.0, NOW())" + [ ${i} -lt ${TOP_N} ] && SQL+=", " +done +SQL+=";" +${MYSQL} "${SQL}" 2>/dev/null + +echo "=== 4) mv_product_rank_monthly 시드 (TOP_N) ===" +${MYSQL} "USE loopers; DELETE FROM mv_product_rank_monthly WHERE month_start_date = '${MONTH_START}';" 2>/dev/null +SQL="USE loopers; INSERT INTO mv_product_rank_monthly (month_start_date, product_id, rank_position, total_score, aggregated_at) VALUES " +for i in $(seq 1 ${TOP_N}); do + SCORE=$(( (TOP_N - i + 1) * 10 )) + SQL+="('${MONTH_START}', ${i}, ${i}, ${SCORE}.0, NOW())" + [ ${i} -lt ${TOP_N} ] && SQL+=", " +done +SQL+=";" +${MYSQL} "${SQL}" 2>/dev/null + +echo "=== 5) Redis ZSET 시드 (daily ranking:all:${TODAY//-/}) ===" +ZSET_KEY="ranking:all:${TODAY//-/}" +${REDIS} DEL "${ZSET_KEY}" > /dev/null +ZADD_CMD="ZADD ${ZSET_KEY}" +for i in $(seq 1 ${TOP_N}); do + SCORE=$(( (TOP_N - i + 1) * 10 )) + ZADD_CMD+=" ${SCORE} ${i}" +done +${REDIS} ${ZADD_CMD} > /dev/null +${REDIS} EXPIRE "${ZSET_KEY}" 172800 > /dev/null + +echo "" +echo "=== 결과 요약 ===" +WEEKLY_CNT=$(${MYSQL} "USE loopers; SELECT COUNT(*) FROM mv_product_rank_weekly WHERE week_start_date='${WEEK_START}';" 2>/dev/null) +MONTHLY_CNT=$(${MYSQL} "USE loopers; SELECT COUNT(*) FROM mv_product_rank_monthly WHERE month_start_date='${MONTH_START}';" 2>/dev/null) +ZSET_CNT=$(${REDIS} ZCARD "${ZSET_KEY}") + +echo " brand : $(${MYSQL} "USE loopers; SELECT COUNT(*) FROM brand;" 2>/dev/null)" +echo " product : $(${MYSQL} "USE loopers; SELECT COUNT(*) FROM product;" 2>/dev/null)" +echo " mv_weekly[${WEEK_START}] : ${WEEKLY_CNT}" +echo " mv_monthly[${MONTH_START}] : ${MONTHLY_CNT}" +echo " ZSET[${ZSET_KEY}]: ${ZSET_CNT}" +echo "" +echo "OK" From 256b46b708723f887e15298a389fe04df14113f7 Mon Sep 17 00:00:00 2001 From: simoncho91 Date: Thu, 16 Apr 2026 23:02:40 +0900 Subject: [PATCH 10/10] =?UTF-8?q?refactor:=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=A0=90=EC=88=98=20=EA=B0=80=EC=A4=91=EC=B9=98=EB=A5=BC=20Ran?= =?UTF-8?q?kingScoreCalculator=EB=A1=9C=20=EC=9D=91=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AS-IS - Weekly/Monthly Reader SQL에 가중치(1, 2, 7) 리터럴이 흩어져 있어 공식 변경 시 두 곳을 동시에 고쳐야 했고, 한쪽만 바뀌면 SQL과 Java 계산값이 어긋나도 적재까지 통과되는 구조였음. TO-BE - VIEW/LIKE/ORDER 가중치 상수와 calculate()를 RankingScoreCalculator에 응집. - Reader SQL은 .formatted()로 동일한 상수를 주입해 가중치 단일 소스 확보. - Processor에서 assertConsistent()로 SQL 결과와 Calculator 공식이 drift되면 즉시 실패시켜 잘못된 랭킹 적재를 차단. --- .../job/ranking/MonthlyRankingJobConfig.java | 9 +- .../job/ranking/WeeklyRankingJobConfig.java | 9 +- .../ranking/step/MonthlyRankingProcessor.java | 6 ++ .../ranking/step/WeeklyRankingProcessor.java | 6 ++ .../ranking/RankingScoreCalculator.java | 36 +++++++ .../ranking/RankingScoreCalculatorTest.java | 100 ++++++++++++++++++ 6 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingScoreCalculator.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/ranking/RankingScoreCalculatorTest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java index 7c119fee3..7d4530199 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -6,6 +6,7 @@ import com.loopers.batch.listener.JobListener; import com.loopers.batch.listener.StepMonitorListener; import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.RankingScoreCalculator; import jakarta.persistence.EntityManagerFactory; import lombok.RequiredArgsConstructor; import org.springframework.batch.core.Job; @@ -99,13 +100,17 @@ public JdbcCursorItemReader monthlyReader( SUM(view_count) AS view_count, SUM(like_count) AS like_count, SUM(order_count) AS order_count, - (SUM(view_count) * 1 + SUM(like_count) * 2 + SUM(order_count) * 7) AS total_score + (SUM(view_count) * %d + SUM(like_count) * %d + SUM(order_count) * %d) AS total_score FROM product_metrics_daily WHERE metric_date BETWEEN ? AND ? GROUP BY product_id ORDER BY total_score DESC LIMIT 100 - """) + """.formatted( + RankingScoreCalculator.VIEW_WEIGHT, + RankingScoreCalculator.LIKE_WEIGHT, + RankingScoreCalculator.ORDER_WEIGHT + )) .preparedStatementSetter(ps -> { ps.setObject(1, start); ps.setObject(2, end); diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java index 634981123..70b0803c3 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -6,6 +6,7 @@ import com.loopers.batch.listener.JobListener; import com.loopers.batch.listener.StepMonitorListener; import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.RankingScoreCalculator; import jakarta.persistence.EntityManagerFactory; import lombok.RequiredArgsConstructor; import org.springframework.batch.core.Job; @@ -98,13 +99,17 @@ public JdbcCursorItemReader weeklyReader( SUM(view_count) AS view_count, SUM(like_count) AS like_count, SUM(order_count) AS order_count, - (SUM(view_count) * 1 + SUM(like_count) * 2 + SUM(order_count) * 7) AS total_score + (SUM(view_count) * %d + SUM(like_count) * %d + SUM(order_count) * %d) AS total_score FROM product_metrics_daily WHERE metric_date BETWEEN ? AND ? GROUP BY product_id ORDER BY total_score DESC LIMIT 100 - """) + """.formatted( + RankingScoreCalculator.VIEW_WEIGHT, + RankingScoreCalculator.LIKE_WEIGHT, + RankingScoreCalculator.ORDER_WEIGHT + )) .preparedStatementSetter(ps -> { ps.setObject(1, start); ps.setObject(2, end); diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.java index 379c1e5ef..583c563b0 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.java @@ -3,6 +3,7 @@ import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; import com.loopers.batch.job.ranking.ProductScoreRow; import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.RankingScoreCalculator; import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.ItemProcessor; import org.springframework.beans.factory.annotation.Value; @@ -24,6 +25,11 @@ public class MonthlyRankingProcessor implements ItemProcessor + RankingScoreCalculator.assertConsistent(driftedSqlScore, 300, 80, 15) + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("drift"); + } + } + + @DisplayName("복합 시나리오") + @Nested + class Composite { + + @DisplayName("view 300 + like 80 + order 15 → 300*1 + 80*2 + 15*7 = 565 (E2E 샘플과 동일)") + @Test + void weeklyScenarioSample() { + double score = RankingScoreCalculator.calculate(300, 80, 15); + + assertThat(score).isEqualTo(565.0); + } + + @DisplayName("view 50 + like 10 + order 20 → 50*1 + 10*2 + 20*7 = 210") + @Test + void weeklyScenarioSecondPlace() { + double score = RankingScoreCalculator.calculate(50, 10, 20); + + assertThat(score).isEqualTo(210.0); + } + } +}