diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankingAppService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankingAppService.java new file mode 100644 index 0000000000..8d1cf6c887 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankingAppService.java @@ -0,0 +1,31 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.ProductRankMvRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MvRankingAppService { + + private final ProductRankMvRepository productRankMvRepository; + + @Transactional(readOnly = true) + public List getWeeklyRankings(String yearWeek, int page, int size) { + return productRankMvRepository.findWeeklyRankings(yearWeek, page, size).stream() + .map(mv -> new RankingEntry(mv.getRanking(), mv.getProductId(), mv.getScore())) + .toList(); + } + + @Transactional(readOnly = true) + public List getMonthlyRankings(String yearMonth, int page, int size) { + return productRankMvRepository.findMonthlyRankings(yearMonth, page, size).stream() + .map(mv -> new RankingEntry(mv.getRanking(), mv.getProductId(), mv.getScore())) + .toList(); + } +} 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 e7c090adfc..5400d7267b 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 @@ -13,30 +13,34 @@ public class RankingFacade { private final RankingAppService rankingAppService; + private final MvRankingAppService mvRankingAppService; private final ProductAppService productAppService; public List getTopRankings(String date, int page, int size) { List entries = rankingAppService.getTopRankings(date, page, size); + return enrichWithProductInfo(entries); + } - if (entries.isEmpty()) { - return List.of(); - } - - List productIds = entries.stream() - .map(RankingEntry::productId) - .toList(); - - Map productMap = productAppService.getByIds(productIds); + public List getWeeklyTopRankings(String yearWeek, int page, int size) { + List entries = mvRankingAppService.getWeeklyRankings(yearWeek, page, size); + return enrichWithProductInfo(entries); + } - return entries.stream() - .filter(entry -> productMap.containsKey(entry.productId())) - .map(entry -> RankingInfo.of(entry, productMap.get(entry.productId()))) - .toList(); + public List getMonthlyTopRankings(String yearMonth, int page, int size) { + List entries = mvRankingAppService.getMonthlyRankings(yearMonth, page, size); + return enrichWithProductInfo(entries); } public List getHourlyTopRankings(String hour, int page, int size) { List entries = rankingAppService.getHourlyTopRankings(hour, page, size); + return enrichWithProductInfo(entries); + } + + public Long getProductRank(String date, Long productId) { + return rankingAppService.getProductRank(date, productId); + } + private List enrichWithProductInfo(List entries) { if (entries.isEmpty()) { return List.of(); } @@ -52,8 +56,4 @@ public List getHourlyTopRankings(String hour, int page, int size) { .map(entry -> RankingInfo.of(entry, productMap.get(entry.productId()))) .toList(); } - - public Long getProductRank(String date, Long productId) { - return rankingAppService.getProductRank(date, productId); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..05196eed17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.MvProductRankMonthlyId; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + List findByYearMonthOrderByRankingAsc(String yearMonth, Pageable pageable); + + List findByYearMonthOrderByRankingAsc(String yearMonth); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..efab1028df --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.MvProductRankWeeklyId; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + List findByYearWeekOrderByRankingAsc(String yearWeek, Pageable pageable); + + List findByYearWeekOrderByRankingAsc(String yearWeek); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMvRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMvRepositoryImpl.java new file mode 100644 index 0000000000..ff1140cc1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMvRepositoryImpl.java @@ -0,0 +1,50 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.ProductRankMvRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class ProductRankMvRepositoryImpl implements ProductRankMvRepository { + + private final MvProductRankWeeklyJpaRepository weeklyJpaRepository; + private final MvProductRankMonthlyJpaRepository monthlyJpaRepository; + + @Override + public List findWeeklyRankings(String yearWeek, int page, int size) { + return weeklyJpaRepository.findByYearWeekOrderByRankingAsc(yearWeek, PageRequest.of(page, size)); + } + + @Override + public List findMonthlyRankings(String yearMonth, int page, int size) { + return monthlyJpaRepository.findByYearMonthOrderByRankingAsc(yearMonth, PageRequest.of(page, size)); + } + + @Override + public void saveAllWeekly(List rankings) { + weeklyJpaRepository.saveAll(rankings); + } + + @Override + public void saveAllMonthly(List rankings) { + monthlyJpaRepository.saveAll(rankings); + } + + @Override + public void deleteWeeklyByYearWeek(String yearWeek) { + List existing = weeklyJpaRepository.findByYearWeekOrderByRankingAsc(yearWeek); + weeklyJpaRepository.deleteAll(existing); + } + + @Override + public void deleteMonthlyByYearMonth(String yearMonth) { + List existing = monthlyJpaRepository.findByYearMonthOrderByRankingAsc(yearMonth); + monthlyJpaRepository.deleteAll(existing); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java index 1610c6bc00..201f57e81d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java @@ -13,6 +13,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.time.temporal.IsoFields; import java.util.List; @RestController @@ -28,15 +29,30 @@ public class RankingController { @GetMapping("/api/v1/rankings") public ApiResponse getRankings( + @RequestParam(defaultValue = "daily") String period, @RequestParam(required = false) String date, @RequestParam(defaultValue = "" + DEFAULT_SIZE) int size, @RequestParam(defaultValue = "1") int page ) { - String rankingDate = validateDate(date); int validatedSize = validatePageSize(size); int zeroBasedPage = validatePage(page) - 1; - List rankings = rankingFacade.getTopRankings(rankingDate, zeroBasedPage, validatedSize); + List rankings = switch (period) { + case "daily" -> { + String rankingDate = validateDate(date); + yield rankingFacade.getTopRankings(rankingDate, zeroBasedPage, validatedSize); + } + case "weekly" -> { + String yearWeek = toYearWeek(date); + yield rankingFacade.getWeeklyTopRankings(yearWeek, zeroBasedPage, validatedSize); + } + case "monthly" -> { + String yearMonth = toYearMonth(date); + yield rankingFacade.getMonthlyTopRankings(yearMonth, zeroBasedPage, validatedSize); + } + default -> throw new CoreException(ErrorType.BAD_REQUEST, "period는 daily, weekly, monthly 중 하나여야 합니다."); + }; + List responses = rankings.stream() .map(RankingDto.RankingResponse::from) .toList(); @@ -62,6 +78,26 @@ public ApiResponse getHourlyRankings( return ApiResponse.success(new RankingDto.RankingListResponse(responses, page, validatedSize)); } + private String toYearWeek(String date) { + LocalDate targetDate = date != null ? parseDate(date) : LocalDate.now(); + int year = targetDate.get(IsoFields.WEEK_BASED_YEAR); + int week = targetDate.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + return String.format("%d-W%02d", year, week); + } + + private String toYearMonth(String date) { + LocalDate targetDate = date != null ? parseDate(date) : LocalDate.now(); + return String.format("%d-%02d", targetDate.getYear(), targetDate.getMonthValue()); + } + + private LocalDate parseDate(String date) { + try { + return LocalDate.parse(date, DATE_FORMAT); + } catch (Exception e) { + throw new CoreException(ErrorType.BAD_REQUEST, "date는 yyyyMMdd 형식이어야 합니다."); + } + } + private int validatePage(int page) { if (page < 1) { throw new CoreException(ErrorType.BAD_REQUEST, "page는 1 이상이어야 합니다."); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/concurrency/ConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/concurrency/ConcurrencyTest.java index f3a90ac078..45a73a8340 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/concurrency/ConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/concurrency/ConcurrencyTest.java @@ -278,8 +278,27 @@ void concurrentLike() throws InterruptedException { assertThat(successCount.get()).isEqualTo(10); assertThat(failCount.get()).isZero(); - Product updatedProduct = productRepository.findById(productId).orElseThrow(); - assertThat(updatedProduct.getLikeCount()).isEqualTo(successCount.get()); + // LikeCountEventListener가 @Async + AFTER_COMMIT으로 비동기 처리되므로 polling 대기 + int expectedLikeCount = successCount.get(); + long timeoutMillis = 10_000; + long pollIntervalMillis = 100; + long deadline = System.currentTimeMillis() + timeoutMillis; + + Product updatedProduct; + while (true) { + updatedProduct = productRepository.findById(productId).orElseThrow(); + if (updatedProduct.getLikeCount() == expectedLikeCount) { + break; + } + if (System.currentTimeMillis() >= deadline) { + assertThat(updatedProduct.getLikeCount()) + .as("likeCount가 %d초 내에 %d에 도달하지 못함 (현재: %d)", + timeoutMillis / 1000, expectedLikeCount, updatedProduct.getLikeCount()) + .isEqualTo(expectedLikeCount); + } + Thread.sleep(pollIntervalMillis); + } + assertThat(updatedProduct.getLikeCount()).isEqualTo(expectedLikeCount); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueSchedulerTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueSchedulerTest.java index 940219f8dc..0114916b7d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueSchedulerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueSchedulerTest.java @@ -9,10 +9,13 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -26,6 +29,12 @@ class QueueSchedulerTest { @Mock private TokenService tokenService; + @Mock + private RedissonClient redissonClient; + + @Mock + private RLock lock; + @InjectMocks private QueueScheduler queueScheduler; @@ -33,6 +42,8 @@ class QueueSchedulerTest { void setUp() { ReflectionTestUtils.setField(queueScheduler, "batchSize", 14); ReflectionTestUtils.setField(queueScheduler, "fixedRate", 1L); // jitter = 0ms 고정 + given(redissonClient.getLock(anyString())).willReturn(lock); + given(lock.isHeldByCurrentThread()).willReturn(true); } @DisplayName("큐가 비어있으면 아무것도 실행하지 않는다.") diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingApiE2ETest.java new file mode 100644 index 0000000000..9b72f12476 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingApiE2ETest.java @@ -0,0 +1,288 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.ProductRankMvRepository; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.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.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RankingApiE2ETest { + + private static final String RANKINGS_ENDPOINT = "/api/v1/rankings"; + private static final String HOURLY_RANKINGS_ENDPOINT = "/api/v1/rankings/hourly"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private ProductRankMvRepository productRankMvRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Long productId1; + private Long productId2; + + @BeforeEach + void setUp() { + Brand brand = brandRepository.save(Brand.create("테스트 브랜드")); + Product product1 = Product.create(brand.getId(), "상품A", Money.of(10000L)); + Product product2 = Product.create(brand.getId(), "상품B", Money.of(20000L)); + productId1 = productRepository.save(product1).getId(); + productId2 = productRepository.save(product2).getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("GET /api/v1/rankings — 입력 검증") + class GetRankingsValidation { + + @Test + @DisplayName("page가 0이면 400 응답을 받는다") + void getRankings_fail_whenPageIsZero() { + ResponseEntity> response = testRestTemplate.exchange( + RANKINGS_ENDPOINT + "?page=0&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL), + () -> assertThat(response.getBody().meta().errorCode()).isEqualTo(ErrorType.BAD_REQUEST.getCode()) + ); + } + + @Test + @DisplayName("size가 너무 크면 400 응답을 받는다") + void getRankings_fail_whenSizeExceedsMax() { + ResponseEntity> response = testRestTemplate.exchange( + RANKINGS_ENDPOINT + "?page=1&size=101", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL), + () -> assertThat(response.getBody().meta().errorCode()).isEqualTo(ErrorType.BAD_REQUEST.getCode()) + ); + } + + @Test + @DisplayName("date 형식이 잘못되면 400 응답을 받는다") + void getRankings_fail_whenDateIsMalformed() { + ResponseEntity> response = testRestTemplate.exchange( + RANKINGS_ENDPOINT + "?date=2026-04-09&page=1&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL), + () -> assertThat(response.getBody().meta().errorCode()).isEqualTo(ErrorType.BAD_REQUEST.getCode()) + ); + } + + @Test + @DisplayName("잘못된 period 값이면 400 응답을 받는다") + void getRankings_fail_whenPeriodIsInvalid() { + ResponseEntity> response = testRestTemplate.exchange( + RANKINGS_ENDPOINT + "?period=yearly&page=1&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL) + ); + } + } + + @Nested + @DisplayName("GET /api/v1/rankings?period=weekly") + class GetWeeklyRankings { + + @Test + @DisplayName("주간 랭킹을 MV 테이블에서 조회하여 반환한다") + void getWeeklyRankings_success() { + // arrange — MV 데이터 직접 적재 + productRankMvRepository.saveAllWeekly(List.of( + MvProductRankWeekly.create(productId1, "2026-W15", 100, 50, 10000, 85.5, 1), + MvProductRankWeekly.create(productId2, "2026-W15", 50, 20, 5000, 42.3, 2) + )); + + // act + ResponseEntity> response = testRestTemplate.exchange( + RANKINGS_ENDPOINT + "?period=weekly&date=20260408&page=1&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> { + Map body = response.getBody().data(); + List rankings = (List) body.get("rankings"); + assertThat(rankings).hasSize(2); + } + ); + } + + @Test + @DisplayName("데이터가 없으면 빈 목록을 반환한다") + void getWeeklyRankings_empty() { + ResponseEntity> response = testRestTemplate.exchange( + RANKINGS_ENDPOINT + "?period=weekly&date=20260101&page=1&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map body = response.getBody().data(); + List rankings = (List) body.get("rankings"); + assertThat(rankings).isEmpty(); + } + ); + } + } + + @Nested + @DisplayName("GET /api/v1/rankings?period=monthly") + class GetMonthlyRankings { + + @Test + @DisplayName("월간 랭킹을 MV 테이블에서 조회하여 반환한다") + void getMonthlyRankings_success() { + // arrange + productRankMvRepository.saveAllMonthly(List.of( + MvProductRankMonthly.create(productId1, "2026-04", 200, 100, 50000, 120.7, 1), + MvProductRankMonthly.create(productId2, "2026-04", 80, 30, 8000, 55.2, 2) + )); + + // act + ResponseEntity> response = testRestTemplate.exchange( + RANKINGS_ENDPOINT + "?period=monthly&date=20260415&page=1&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> { + Map body = response.getBody().data(); + List rankings = (List) body.get("rankings"); + assertThat(rankings).hasSize(2); + } + ); + } + + @Test + @DisplayName("데이터가 없으면 빈 목록을 반환한다") + void getMonthlyRankings_empty() { + ResponseEntity> response = testRestTemplate.exchange( + RANKINGS_ENDPOINT + "?period=monthly&date=20260101&page=1&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + Map body = response.getBody().data(); + List rankings = (List) body.get("rankings"); + assertThat(rankings).isEmpty(); + } + ); + } + } + + @Nested + @DisplayName("GET /api/v1/rankings/hourly") + class GetHourlyRankings { + + @Test + @DisplayName("page가 0이면 400 응답을 받는다") + void getHourlyRankings_fail_whenPageIsZero() { + ResponseEntity> response = testRestTemplate.exchange( + HOURLY_RANKINGS_ENDPOINT + "?page=0&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL), + () -> assertThat(response.getBody().meta().errorCode()).isEqualTo(ErrorType.BAD_REQUEST.getCode()) + ); + } + + @Test + @DisplayName("hour 형식이 잘못되면 400 응답을 받는다") + void getHourlyRankings_fail_whenHourIsMalformed() { + ResponseEntity> response = testRestTemplate.exchange( + HOURLY_RANKINGS_ENDPOINT + "?hour=2026-04-0912&page=1&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL), + () -> assertThat(response.getBody().meta().errorCode()).isEqualTo(ErrorType.BAD_REQUEST.getCode()) + ); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java new file mode 100644 index 0000000000..faf7fbd1f7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -0,0 +1,152 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.step.AggregatedMetricRow; +import com.loopers.batch.job.ranking.step.RankingScoreProcessor; +import com.loopers.batch.job.ranking.step.RankingScoreRow; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.sql.Date; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.time.temporal.TemporalAdjusters; + +@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_NAME = "monthlyRankingStep"; + private static final int CHUNK_SIZE = 100; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + + @Bean(JOB_NAME) + public Job monthlyRankingJob(Step monthlyRankingStep) { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(monthlyRankingStep) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step monthlyRankingStep( + PlatformTransactionManager txManager, + JdbcCursorItemReader monthlyReader, + ItemProcessor monthlyProcessor, + ItemWriter monthlyWriter + ) { + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, txManager) + .reader(monthlyReader) + .processor(monthlyProcessor) + .writer(monthlyWriter) + .listener(stepMonitorListener) + .build(); + } + + @StepScope + @Bean + public JdbcCursorItemReader monthlyReader( + DataSource dataSource, + @Value("#{jobParameters['targetDate']}") LocalDate targetDate + ) { + LocalDate firstDay = targetDate.withDayOfMonth(1); + LocalDate lastDay = targetDate.with(TemporalAdjusters.lastDayOfMonth()); + + return new JdbcCursorItemReaderBuilder() + .name("monthlyMetricsReader") + .dataSource(dataSource) + .sql(""" + SELECT + product_id, + SUM(view_count) AS total_views, + SUM(like_count) AS total_likes, + SUM(order_amount) AS total_amount + FROM product_daily_metrics + WHERE metric_date BETWEEN ? AND ? + GROUP BY product_id + ORDER BY (SUM(view_count) * 0.1 + SUM(like_count) * 0.2 + LOG(1 + SUM(order_amount)) * 0.7) DESC, product_id ASC + LIMIT 100 + """) + .preparedStatementSetter(ps -> { + ps.setDate(1, Date.valueOf(firstDay)); + ps.setDate(2, Date.valueOf(lastDay)); + }) + .rowMapper((rs, rowNum) -> new AggregatedMetricRow( + rs.getLong("product_id"), + rs.getLong("total_views"), + rs.getLong("total_likes"), + rs.getLong("total_amount") + )) + .build(); + } + + @StepScope + @Bean + public ItemProcessor monthlyProcessor() { + return new RankingScoreProcessor(); + } + + @StepScope + @Bean + public ItemWriter monthlyWriter( + NamedParameterJdbcTemplate jdbcTemplate, + @Value("#{jobParameters['targetDate']}") LocalDate targetDate + ) { + String yearMonth = String.format("%d-%02d", targetDate.getYear(), targetDate.getMonthValue()); + + return items -> { + jdbcTemplate.update( + "DELETE FROM mv_product_rank_monthly WHERE ranking_month = :yearMonth", + new org.springframework.jdbc.core.namedparam.MapSqlParameterSource("yearMonth", yearMonth) + ); + + String sql = """ + INSERT INTO mv_product_rank_monthly + (product_id, ranking_month, view_count, like_count, order_amount, score, ranking, updated_at) + VALUES + (:productId, :yearMonth, :viewCount, :likeCount, :orderAmount, :score, :ranking, :updatedAt) + """; + + SqlParameterSource[] batchParams = items.getItems().stream() + .map(row -> new org.springframework.jdbc.core.namedparam.MapSqlParameterSource() + .addValue("productId", row.productId()) + .addValue("yearMonth", yearMonth) + .addValue("viewCount", row.viewCount()) + .addValue("likeCount", row.likeCount()) + .addValue("orderAmount", row.orderAmount()) + .addValue("score", row.score()) + .addValue("ranking", row.ranking()) + .addValue("updatedAt", ZonedDateTime.now())) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, batchParams); + }; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java new file mode 100644 index 0000000000..63f3a788f7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -0,0 +1,155 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.step.AggregatedMetricRow; +import com.loopers.batch.job.ranking.step.RankingScoreProcessor; +import com.loopers.batch.job.ranking.step.RankingScoreRow; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.sql.Date; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.time.temporal.IsoFields; + +@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_NAME = "weeklyRankingStep"; + private static final int CHUNK_SIZE = 100; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + + @Bean(JOB_NAME) + public Job weeklyRankingJob(Step weeklyRankingStep) { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(weeklyRankingStep) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step weeklyRankingStep( + PlatformTransactionManager txManager, + JdbcCursorItemReader weeklyReader, + ItemProcessor weeklyProcessor, + ItemWriter weeklyWriter + ) { + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, txManager) + .reader(weeklyReader) + .processor(weeklyProcessor) + .writer(weeklyWriter) + .listener(stepMonitorListener) + .build(); + } + + @StepScope + @Bean + public JdbcCursorItemReader weeklyReader( + DataSource dataSource, + @Value("#{jobParameters['targetDate']}") LocalDate targetDate + ) { + LocalDate monday = targetDate.with(DayOfWeek.MONDAY); + LocalDate sunday = targetDate.with(DayOfWeek.SUNDAY); + + return new JdbcCursorItemReaderBuilder() + .name("weeklyMetricsReader") + .dataSource(dataSource) + .sql(""" + SELECT + product_id, + SUM(view_count) AS total_views, + SUM(like_count) AS total_likes, + SUM(order_amount) AS total_amount + FROM product_daily_metrics + WHERE metric_date BETWEEN ? AND ? + GROUP BY product_id + ORDER BY (SUM(view_count) * 0.1 + SUM(like_count) * 0.2 + LOG(1 + SUM(order_amount)) * 0.7) DESC, product_id ASC + LIMIT 100 + """) + .preparedStatementSetter(ps -> { + ps.setDate(1, Date.valueOf(monday)); + ps.setDate(2, Date.valueOf(sunday)); + }) + .rowMapper((rs, rowNum) -> new AggregatedMetricRow( + rs.getLong("product_id"), + rs.getLong("total_views"), + rs.getLong("total_likes"), + rs.getLong("total_amount") + )) + .build(); + } + + @StepScope + @Bean + public ItemProcessor weeklyProcessor() { + return new RankingScoreProcessor(); + } + + @StepScope + @Bean + public ItemWriter weeklyWriter( + NamedParameterJdbcTemplate jdbcTemplate, + @Value("#{jobParameters['targetDate']}") LocalDate targetDate + ) { + int year = targetDate.get(IsoFields.WEEK_BASED_YEAR); + int week = targetDate.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + String yearWeek = String.format("%d-W%02d", year, week); + + return items -> { + jdbcTemplate.update( + "DELETE FROM mv_product_rank_weekly WHERE ranking_week = :yearWeek", + new org.springframework.jdbc.core.namedparam.MapSqlParameterSource("yearWeek", yearWeek) + ); + + String sql = """ + INSERT INTO mv_product_rank_weekly + (product_id, ranking_week, view_count, like_count, order_amount, score, ranking, updated_at) + VALUES + (:productId, :yearWeek, :viewCount, :likeCount, :orderAmount, :score, :ranking, :updatedAt) + """; + + SqlParameterSource[] batchParams = items.getItems().stream() + .map(row -> new org.springframework.jdbc.core.namedparam.MapSqlParameterSource() + .addValue("productId", row.productId()) + .addValue("yearWeek", yearWeek) + .addValue("viewCount", row.viewCount()) + .addValue("likeCount", row.likeCount()) + .addValue("orderAmount", row.orderAmount()) + .addValue("score", row.score()) + .addValue("ranking", row.ranking()) + .addValue("updatedAt", ZonedDateTime.now())) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, batchParams); + }; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/AggregatedMetricRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/AggregatedMetricRow.java new file mode 100644 index 0000000000..42c0626584 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/AggregatedMetricRow.java @@ -0,0 +1,9 @@ +package com.loopers.batch.job.ranking.step; + +public record AggregatedMetricRow( + Long productId, + long viewCount, + long likeCount, + long orderAmount +) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingScoreProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingScoreProcessor.java new file mode 100644 index 0000000000..ec1759f84b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingScoreProcessor.java @@ -0,0 +1,32 @@ +package com.loopers.batch.job.ranking.step; + +import org.springframework.batch.item.ItemProcessor; + +import java.util.concurrent.atomic.AtomicInteger; + +public class RankingScoreProcessor implements ItemProcessor { + + private static final double VIEW_WEIGHT = 0.1; + private static final double LIKE_WEIGHT = 0.2; + private static final double ORDER_WEIGHT = 0.7; + + private final AtomicInteger rankCounter = new AtomicInteger(0); + + @Override + public RankingScoreRow process(AggregatedMetricRow item) { + double score = item.viewCount() * VIEW_WEIGHT + + item.likeCount() * LIKE_WEIGHT + + Math.log1p(item.orderAmount()) * ORDER_WEIGHT; + + int ranking = rankCounter.incrementAndGet(); + + return new RankingScoreRow( + item.productId(), + item.viewCount(), + item.likeCount(), + item.orderAmount(), + score, + ranking + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingScoreRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingScoreRow.java new file mode 100644 index 0000000000..f8be396780 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingScoreRow.java @@ -0,0 +1,11 @@ +package com.loopers.batch.job.ranking.step; + +public record RankingScoreRow( + Long productId, + long viewCount, + long likeCount, + long orderAmount, + double score, + int ranking +) { +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java deleted file mode 100644 index c5e3bc7a35..0000000000 --- a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.loopers; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -public class CommerceBatchApplicationTest { - @Test - void contextLoads() {} -} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java new file mode 100644 index 0000000000..9aa2a1269b --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java @@ -0,0 +1,269 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.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.util.List; +import java.util.Map; + +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) +class MonthlyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(MonthlyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS product_daily_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + 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_amount BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME(6) NOT NULL, + UNIQUE KEY uk_product_date (product_id, metric_date) + ) + """); + + jdbcTemplate.execute("TRUNCATE TABLE product_daily_metrics"); + jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + } + + @DisplayName("월간 랭킹 배치가 product_daily_metrics를 집계하여 mv_product_rank_monthly에 저장한다") + @Test + void monthlyRankingJob_aggregatesMetricsAndSavesToMv() throws Exception { + // arrange — 2026년 4월 + LocalDate targetDate = LocalDate.of(2026, 4, 15); + + insertMetrics(1L, LocalDate.of(2026, 4, 1), 50, 20, 5000); + insertMetrics(1L, LocalDate.of(2026, 4, 10), 50, 20, 5000); + insertMetrics(1L, LocalDate.of(2026, 4, 15), 50, 20, 5000); + insertMetrics(2L, LocalDate.of(2026, 4, 5), 300, 100, 50000); + insertMetrics(3L, LocalDate.of(2026, 4, 20), 10, 5, 1000); + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 100L) + .toJobParameters(); + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List> results = jdbcTemplate.queryForList( + "SELECT * FROM mv_product_rank_monthly WHERE ranking_month = '2026-04' ORDER BY ranking" + ); + + assertAll( + () -> assertThat(results).hasSize(3), + () -> assertThat(results.get(0).get("ranking")).isEqualTo(1), + () -> assertThat(results.get(1).get("ranking")).isEqualTo(2), + () -> assertThat(results.get(2).get("ranking")).isEqualTo(3) + ); + + double score1 = ((Number) results.get(0).get("score")).doubleValue(); + double score2 = ((Number) results.get(1).get("score")).doubleValue(); + assertThat(score1).isGreaterThanOrEqualTo(score2); + } + + @DisplayName("월간 범위 밖의 데이터는 집계에 포함되지 않는다") + @Test + void monthlyRankingJob_excludesOutOfRangeData() throws Exception { + // arrange — 2026년 3월 + LocalDate targetDate = LocalDate.of(2026, 3, 15); + + insertMetrics(1L, LocalDate.of(2026, 3, 1), 100, 50, 10000); + insertMetrics(2L, LocalDate.of(2026, 2, 28), 999, 999, 999999); // 2월 (범위 밖) + insertMetrics(3L, LocalDate.of(2026, 4, 1), 999, 999, 999999); // 4월 (범위 밖) + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 110L) + .toJobParameters(); + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List> results = jdbcTemplate.queryForList( + "SELECT * FROM mv_product_rank_monthly WHERE ranking_month = '2026-03'" + ); + + assertAll( + () -> assertThat(results).hasSize(1), + () -> assertThat(results.get(0).get("product_id")).isEqualTo(1L) + ); + } + + @DisplayName("동일 월에 배치를 두 번 실행해도 멱등성이 보장된다 (UPSERT)") + @Test + void monthlyRankingJob_idempotent() throws Exception { + // arrange — 2026년 1월 + LocalDate targetDate = LocalDate.of(2026, 1, 15); + insertMetrics(1L, LocalDate.of(2026, 1, 1), 100, 50, 10000); + + // act — 1차 실행 + var jobParameters1 = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 120L) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters1); + + // 2차 실행 + var jobParameters2 = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 121L) + .toJobParameters(); + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters2); + + // assert — 중복 없이 1건만 존재 + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List> results = jdbcTemplate.queryForList( + "SELECT * FROM mv_product_rank_monthly WHERE ranking_month = '2026-01'" + ); + assertThat(results).hasSize(1); + } + + @DisplayName("월간 집계에서 여러 날의 metrics가 정확히 합산된다") + @Test + void monthlyRankingJob_correctAggregation() throws Exception { + // arrange — 2026년 2월 + LocalDate targetDate = LocalDate.of(2026, 2, 15); + insertMetrics(1L, LocalDate.of(2026, 2, 1), 10, 5, 1000); + insertMetrics(1L, LocalDate.of(2026, 2, 10), 20, 10, 2000); + insertMetrics(1L, LocalDate.of(2026, 2, 20), 30, 15, 3000); + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 130L) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters); + + // assert — 합산 검증 (10+20+30=60, 5+10+15=30, 1000+2000+3000=6000) + List> results = jdbcTemplate.queryForList( + "SELECT * FROM mv_product_rank_monthly WHERE ranking_month = '2026-02'" + ); + + assertAll( + () -> assertThat(results).hasSize(1), + () -> assertThat(((Number) results.get(0).get("view_count")).longValue()).isEqualTo(60L), + () -> assertThat(((Number) results.get(0).get("like_count")).longValue()).isEqualTo(30L), + () -> assertThat(((Number) results.get(0).get("order_amount")).longValue()).isEqualTo(6000L), + () -> assertThat(((Number) results.get(0).get("ranking")).intValue()).isEqualTo(1) + ); + } + + @DisplayName("동일 점수의 상품은 product_id 오름차순으로 안정적 정렬된다") + @Test + void monthlyRankingJob_tieBreakByProductId() throws Exception { + LocalDate targetDate = LocalDate.of(2026, 5, 15); + + insertMetrics(99L, LocalDate.of(2026, 5, 1), 100, 50, 10000); + insertMetrics(11L, LocalDate.of(2026, 5, 1), 100, 50, 10000); + insertMetrics(55L, LocalDate.of(2026, 5, 1), 100, 50, 10000); + + var jobParameters = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 140L) + .toJobParameters(); + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List> results = jdbcTemplate.queryForList( + "SELECT * FROM mv_product_rank_monthly WHERE ranking_month = '2026-05' ORDER BY ranking" + ); + + assertAll( + () -> assertThat(results).hasSize(3), + () -> assertThat(results.get(0).get("product_id")).isEqualTo(11L), + () -> assertThat(results.get(1).get("product_id")).isEqualTo(55L), + () -> assertThat(results.get(2).get("product_id")).isEqualTo(99L) + ); + } + + @DisplayName("재실행 시 이전 실행의 탈락 상품이 제거된다") + @Test + void monthlyRankingJob_removesStaleEntries() throws Exception { + LocalDate targetDate = LocalDate.of(2026, 6, 15); + + // 1차: 상품 A, B + insertMetrics(1L, LocalDate.of(2026, 6, 1), 100, 50, 10000); + insertMetrics(2L, LocalDate.of(2026, 6, 1), 80, 40, 8000); + + var jobParameters1 = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 150L) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters1); + + List> firstRun = jdbcTemplate.queryForList( + "SELECT * FROM mv_product_rank_monthly WHERE ranking_month = '2026-06'" + ); + assertThat(firstRun).hasSize(2); + + // 2차: 상품 B의 metrics 제거 → 상품 A만 남아야 함 + jdbcTemplate.execute("TRUNCATE TABLE product_daily_metrics"); + insertMetrics(1L, LocalDate.of(2026, 6, 1), 100, 50, 10000); + + var jobParameters2 = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 151L) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters2); + + List> secondRun = jdbcTemplate.queryForList( + "SELECT * FROM mv_product_rank_monthly WHERE ranking_month = '2026-06'" + ); + assertAll( + () -> assertThat(secondRun).hasSize(1), + () -> assertThat(secondRun.get(0).get("product_id")).isEqualTo(1L) + ); + } + + private void insertMetrics(Long productId, LocalDate date, long views, long likes, long orderAmount) { + jdbcTemplate.update(""" + INSERT INTO product_daily_metrics (product_id, metric_date, view_count, like_count, order_amount, updated_at) + VALUES (?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE + view_count = view_count + VALUES(view_count), + like_count = like_count + VALUES(like_count), + order_amount = order_amount + VALUES(order_amount), + updated_at = NOW() + """, productId, date, views, likes, orderAmount); + } +} 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 0000000000..d3e2d0c218 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java @@ -0,0 +1,243 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.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.util.List; +import java.util.Map; + +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) +class WeeklyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(WeeklyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS product_daily_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + 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_amount BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME(6) NOT NULL, + UNIQUE KEY uk_product_date (product_id, metric_date) + ) + """); + + jdbcTemplate.execute("TRUNCATE TABLE product_daily_metrics"); + jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + } + + @DisplayName("주간 랭킹 배치가 product_daily_metrics를 집계하여 mv_product_rank_weekly에 저장한다") + @Test + void weeklyRankingJob_aggregatesMetricsAndSavesToMv() throws Exception { + // arrange — 2026-04-06(월) ~ 2026-04-12(일) 주간 데이터 + LocalDate targetDate = LocalDate.of(2026, 4, 8); + LocalDate monday = LocalDate.of(2026, 4, 6); + + insertMetrics(1L, monday, 40, 20, 4000); + insertMetrics(1L, monday.plusDays(1), 30, 15, 3000); + insertMetrics(1L, monday.plusDays(2), 30, 15, 3000); + insertMetrics(2L, monday, 200, 30, 5000); + insertMetrics(3L, monday.plusDays(1), 10, 5, 50000); + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 1L) + .toJobParameters(); + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List> results = jdbcTemplate.queryForList( + "SELECT * FROM mv_product_rank_weekly WHERE ranking_week = '2026-W15' ORDER BY ranking" + ); + + assertAll( + () -> assertThat(results).hasSize(3), + () -> assertThat(results.get(0).get("ranking")).isEqualTo(1), + () -> assertThat(results.get(1).get("ranking")).isEqualTo(2), + () -> assertThat(results.get(2).get("ranking")).isEqualTo(3), + () -> assertThat(results.stream().map(r -> r.get("product_id")).toList()) + .containsExactlyInAnyOrder(1L, 2L, 3L) + ); + + Map top1 = results.get(0); + assertThat(((Number) top1.get("score")).doubleValue()).isGreaterThan(0); + } + + @DisplayName("주간 범위 밖의 데이터는 집계에 포함되지 않는다") + @Test + void weeklyRankingJob_excludesOutOfRangeData() throws Exception { + // arrange — 2026-03-30(월) ~ 2026-04-05(일) 주간 + LocalDate targetDate = LocalDate.of(2026, 4, 1); + + insertMetrics(1L, LocalDate.of(2026, 3, 30), 100, 50, 10000); + insertMetrics(2L, LocalDate.of(2026, 3, 29), 999, 999, 999999); // 이전 주 + insertMetrics(3L, LocalDate.of(2026, 4, 6), 999, 999, 999999); // 다음 주 + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 10L) + .toJobParameters(); + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List> results = jdbcTemplate.queryForList( + "SELECT * FROM mv_product_rank_weekly WHERE ranking_week = '2026-W14'" + ); + + assertAll( + () -> assertThat(results).hasSize(1), + () -> assertThat(results.get(0).get("product_id")).isEqualTo(1L) + ); + } + + @DisplayName("동일 주간에 배치를 두 번 실행해도 멱등성이 보장된다 (UPSERT)") + @Test + void weeklyRankingJob_idempotent() throws Exception { + // arrange — 2026-01-05(월) ~ 2026-01-11(일) + LocalDate targetDate = LocalDate.of(2026, 1, 7); + insertMetrics(1L, LocalDate.of(2026, 1, 5), 100, 50, 10000); + + // act — 1차 실행 + var jobParameters1 = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 20L) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters1); + + // 2차 실행 (run.id 변경으로 재실행 가능) + var jobParameters2 = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 21L) + .toJobParameters(); + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters2); + + // assert — 중복 없이 1건만 존재 + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List> results = jdbcTemplate.queryForList( + "SELECT * FROM mv_product_rank_weekly WHERE ranking_week = '2026-W02'" + ); + assertThat(results).hasSize(1); + } + + @DisplayName("동일 점수의 상품은 product_id 오름차순으로 안정적 정렬된다") + @Test + void weeklyRankingJob_tieBreakByProductId() throws Exception { + LocalDate targetDate = LocalDate.of(2026, 5, 6); // 2026-W19 (월) + LocalDate monday = LocalDate.of(2026, 5, 4); + + insertMetrics(99L, monday, 100, 50, 10000); + insertMetrics(11L, monday, 100, 50, 10000); + insertMetrics(55L, monday, 100, 50, 10000); + + var jobParameters = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 30L) + .toJobParameters(); + JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List> results = jdbcTemplate.queryForList( + "SELECT * FROM mv_product_rank_weekly WHERE ranking_week = '2026-W19' ORDER BY ranking" + ); + + assertAll( + () -> assertThat(results).hasSize(3), + () -> assertThat(results.get(0).get("product_id")).isEqualTo(11L), + () -> assertThat(results.get(1).get("product_id")).isEqualTo(55L), + () -> assertThat(results.get(2).get("product_id")).isEqualTo(99L) + ); + } + + @DisplayName("재실행 시 이전 실행의 탈락 상품이 제거된다") + @Test + void weeklyRankingJob_removesStaleEntries() throws Exception { + LocalDate targetDate = LocalDate.of(2026, 6, 3); // 2026-W23 (수) + LocalDate monday = LocalDate.of(2026, 6, 1); + + // 1차: 상품 A, B + insertMetrics(1L, monday, 100, 50, 10000); + insertMetrics(2L, monday, 80, 40, 8000); + + var jobParameters1 = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 40L) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters1); + + List> firstRun = jdbcTemplate.queryForList( + "SELECT * FROM mv_product_rank_weekly WHERE ranking_week = '2026-W23'" + ); + assertThat(firstRun).hasSize(2); + + // 2차: 상품 B의 metrics 제거 → 상품 A만 남아야 함 + jdbcTemplate.execute("TRUNCATE TABLE product_daily_metrics"); + insertMetrics(1L, monday, 100, 50, 10000); + + var jobParameters2 = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 41L) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters2); + + List> secondRun = jdbcTemplate.queryForList( + "SELECT * FROM mv_product_rank_weekly WHERE ranking_week = '2026-W23'" + ); + assertAll( + () -> assertThat(secondRun).hasSize(1), + () -> assertThat(secondRun.get(0).get("product_id")).isEqualTo(1L) + ); + } + + private void insertMetrics(Long productId, LocalDate date, long views, long likes, long orderAmount) { + jdbcTemplate.update(""" + INSERT INTO product_daily_metrics (product_id, metric_date, view_count, like_count, order_amount, updated_at) + VALUES (?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE + view_count = view_count + VALUES(view_count), + like_count = like_count + VALUES(like_count), + order_amount = order_amount + VALUES(order_amount), + updated_at = NOW() + """, productId, date, views, likes, orderAmount); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java new file mode 100644 index 0000000000..5b92cce71f --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -0,0 +1,71 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table( + name = "mv_product_rank_monthly", + indexes = @Index(name = "idx_monthly_rank", columnList = "ranking_month, ranking") +) +@IdClass(MvProductRankMonthlyId.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankMonthly { + + @Id + @Column(name = "product_id") + private Long productId; + + @Id + @Column(name = "ranking_month", length = 7) + private String yearMonth; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_amount", nullable = false) + private long orderAmount; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "ranking", nullable = false) + private int ranking; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + public static MvProductRankMonthly create( + Long productId, + String yearMonth, + long viewCount, + long likeCount, + long orderAmount, + double score, + int ranking + ) { + MvProductRankMonthly entity = new MvProductRankMonthly(); + entity.productId = productId; + entity.yearMonth = yearMonth; + entity.viewCount = viewCount; + entity.likeCount = likeCount; + entity.orderAmount = orderAmount; + entity.score = score; + entity.ranking = ranking; + entity.updatedAt = ZonedDateTime.now(); + return entity; + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyId.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyId.java new file mode 100644 index 0000000000..c668146517 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyId.java @@ -0,0 +1,15 @@ +package com.loopers.domain.ranking; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class MvProductRankMonthlyId implements Serializable { + private Long productId; + private String yearMonth; +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java new file mode 100644 index 0000000000..bc14a7baf7 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -0,0 +1,71 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table( + name = "mv_product_rank_weekly", + indexes = @Index(name = "idx_weekly_rank", columnList = "ranking_week, ranking") +) +@IdClass(MvProductRankWeeklyId.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankWeekly { + + @Id + @Column(name = "product_id") + private Long productId; + + @Id + @Column(name = "ranking_week", length = 8) + private String yearWeek; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_amount", nullable = false) + private long orderAmount; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "ranking", nullable = false) + private int ranking; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + public static MvProductRankWeekly create( + Long productId, + String yearWeek, + long viewCount, + long likeCount, + long orderAmount, + double score, + int ranking + ) { + MvProductRankWeekly entity = new MvProductRankWeekly(); + entity.productId = productId; + entity.yearWeek = yearWeek; + entity.viewCount = viewCount; + entity.likeCount = likeCount; + entity.orderAmount = orderAmount; + entity.score = score; + entity.ranking = ranking; + entity.updatedAt = ZonedDateTime.now(); + return entity; + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyId.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyId.java new file mode 100644 index 0000000000..0f262e33d9 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyId.java @@ -0,0 +1,15 @@ +package com.loopers.domain.ranking; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class MvProductRankWeeklyId implements Serializable { + private Long productId; + private String yearWeek; +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankMvRepository.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankMvRepository.java new file mode 100644 index 0000000000..2f01868100 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankMvRepository.java @@ -0,0 +1,18 @@ +package com.loopers.domain.ranking; + +import java.util.List; + +public interface ProductRankMvRepository { + + List findWeeklyRankings(String yearWeek, int page, int size); + + List findMonthlyRankings(String yearMonth, int page, int size); + + void saveAllWeekly(List rankings); + + void saveAllMonthly(List rankings); + + void deleteWeeklyByYearWeek(String yearWeek); + + void deleteMonthlyByYearMonth(String yearMonth); +}