diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..1f80db6bf --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,13 @@ +name: PR Agent +on: + pull_request: + types: [opened, synchronize] +jobs: + pr_agent_job: + runs-on: ubuntu-latest + steps: + - name: PR Agent action step + uses: Codium-ai/pr-agent@main + env: + OPENAI_KEY: ${{ secrets.OPENAI_KEY }} + GITHUB_TOKEN: ${{ secrets.G_TOKEN }} diff --git a/README.md b/README.md index 04950f29d..f86e4dd8a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ docker-compose -f ./docker/monitoring-compose.yml up Root ├── apps ( spring-applications ) │ ├── 📦 commerce-api +│ ├── 📦 commerce-batch │ └── 📦 commerce-streamer ├── modules ( reusable-configurations ) │ ├── 📦 jpa diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 6d38938f3..30005b88b 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -34,4 +34,22 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) + + // Resilience4j (Spring Boot 3.x 기준) + implementation("io.github.resilience4j:resilience4j-spring-boot3") + + // AOP + implementation("org.springframework.boot:spring-boot-starter-aop") + + // actuator + implementation("org.springframework.boot:spring-boot-starter-actuator") + + //Micrometer Prometheus + implementation("io.micrometer:micrometer-registry-prometheus") + + //Spring Cloud OpenFeign + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + + //Spring Cloud CircuitBreaker + implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j") } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java index cc0d3fb62..f1f4a55c7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.function.Function; import java.util.stream.Collectors; public record OrderCreatedEvent( @@ -22,7 +21,7 @@ public record OrderCreatedEvent( Long couponId ) { - public record OrderItemInfo(Long productId, int quantity, long price, int remainStock) { + public record OrderItemInfo(Long productId, String productName, int quantity, long price, int remainStock) { } @@ -46,6 +45,7 @@ public static OrderCreatedEvent of( Product product = productMap.get(item.getProductId()); return new OrderItemInfo( item.getProductId(), + product != null ? product.getName() : "Unknown", item.getQuantity(), product != null ? product.getPrice().getValue() : 0, product != null ? product.getStock() : 0 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java index 35290afb1..a06283d1d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java @@ -24,6 +24,7 @@ public void handleOrderCreated(OrderCreatedEvent event) { ProductStockEvent kafkaEvent = ProductStockEvent.of( item.productId(), + item.productName(), item.quantity(), item.remainStock(), item.price() diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 28425a26f..01916ddc0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -3,8 +3,8 @@ import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; -import com.loopers.infrastructure.rank.RankingService; import com.loopers.event.ProductViewEvent; +import com.loopers.infrastructure.rank.RankingService; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java index f5c573459..88b063499 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java @@ -39,4 +39,12 @@ public List getTopRankings(String date, int page, int size) { }) .toList(); } + + public List getRankings(String type, String date, int page, int size) { + return switch (type.toUpperCase()) { + case "WEEKLY" -> rankingService.getWeeklyRankings(date, page, size); + case "MONTHLY" -> rankingService.getMonthlyRankings(date, page, size); + default -> getTopRankings(date, page, size); + }; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java index 0fb5e5ec9..90810b6f5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java @@ -1,12 +1,14 @@ package com.loopers.application.rank; import com.loopers.domain.product.Product; +import com.loopers.domain.rank.monthly.MonthlyRankingMV; +import com.loopers.domain.rank.weekly.WeeklyRankingMV; public record RankingInfo( Long productId, String productName, Long price, - int stock, + boolean isSoldOut, int currentRank ) { @@ -15,8 +17,28 @@ public static RankingInfo of(Product product, int currentRank) { product.getId(), product.getName(), product.getPrice().getValue(), - product.getStock(), + product.getStock() <= 0, currentRank ); } + + public static RankingInfo from(WeeklyRankingMV mv) { + return new RankingInfo( + mv.getProductId(), + mv.getProductName(), + mv.getPrice(), + mv.isSoldOut(), + mv.getCurrentRank() + ); + } + + public static RankingInfo from(MonthlyRankingMV mv) { + return new RankingInfo( + mv.getProductId(), + mv.getProductName(), + mv.getPrice(), + mv.isSoldOut(), + mv.getCurrentRank() + ); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java index 9c8b33cf2..276204504 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java @@ -1,54 +1,15 @@ package com.loopers.infrastructure.rank; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; +import com.loopers.application.rank.RankingInfo; import java.util.List; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.connection.zset.Aggregate; -import org.springframework.data.redis.connection.zset.Weights; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; -@Component -@RequiredArgsConstructor -public class RankingService { +public interface RankingService { - private final RedisTemplate redisTemplate; + List getTopRankingIds(String date, int page, int size); - public List getTopRankingIds(String date, int page, int size) { - String key = "ranking:all:" + date; - int start = (page - 1) * size; - int end = start + size - 1; + List getWeeklyRankings(String date, int page, int size); - Set rankedIds = redisTemplate.opsForZSet().reverseRange(key, start, end); + List getMonthlyRankings(String date, int page, int size); - if (rankedIds == null || rankedIds.isEmpty()) { - return List.of(); - } - - return rankedIds.stream() - .map(Long::valueOf) - .toList(); - } - - public Integer getProductRank(Long productId) { - String today = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); - String key = "ranking:all:" + today; - - Long rank = redisTemplate.opsForZSet().reverseRank(key, String.valueOf(productId)); - - return (rank != null) ? rank.intValue() + 1 : null; - } - - public void carryOverRanking(String sourceDate, String targetDate, double weight) { - String sourceKey = "ranking:all:" + sourceDate; - String targetKey = "ranking:all:" + targetDate; - - redisTemplate.opsForZSet().unionAndStore(sourceKey, List.of(), targetKey, - Aggregate.SUM, Weights.of(weight)); - - redisTemplate.expire(targetKey, 2, TimeUnit.DAYS); - } + Integer getProductRank(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingServiceImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingServiceImpl.java new file mode 100644 index 000000000..589896bfb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingServiceImpl.java @@ -0,0 +1,65 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.application.rank.RankingInfo; +import com.loopers.domain.rank.monthly.MonthlyRankingMVRepository; +import com.loopers.domain.rank.weekly.WeeklyRankingMVRepository; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class RankingServiceImpl implements RankingService { + + private final RedisTemplate redisTemplate; + private final WeeklyRankingMVRepository weeklyRepository; + private final MonthlyRankingMVRepository monthlyRepository; + + @Override + public List getTopRankingIds(String date, int page, int size) { + String key = "ranking:all:" + date; + int start = (page - 1) * size; + int end = start + size - 1; + + Set rankedIds = redisTemplate.opsForZSet().reverseRange(key, start, end); + + if (rankedIds == null || rankedIds.isEmpty()) { + return List.of(); + } + + return rankedIds.stream() + .map(Long::valueOf) + .toList(); + } + + @Override + public List getWeeklyRankings(String date, int page, int size) { + return weeklyRepository.findByBaseDateOrderByCurrentRankAsc(date, PageRequest.of(page - 1, size)) + .stream() + .map(RankingInfo::from) + .toList(); + } + + @Override + public List getMonthlyRankings(String date, int page, int size) { + return monthlyRepository.findByBaseDateOrderByCurrentRankAsc(date, PageRequest.of(page - 1, size)) + .stream() + .map(RankingInfo::from) + .toList(); + } + + @Override + public Integer getProductRank(Long productId) { + String today = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String key = "ranking:all:" + today; + + Long rank = redisTemplate.opsForZSet().reverseRank(key, String.valueOf(productId)); + + return (rank != null) ? rank.intValue() + 1 : null; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java index 15f1144f8..52419f988 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java @@ -12,6 +12,7 @@ public interface RankingV1ApiSpec { @Operation(summary = "실시간 랭킹 조회", description = "특정 날짜의 인기 상품 랭킹을 조회합니다.") ApiResponse> getRankings( + @Parameter(description = "랭킹 타입 (DAILY, WEEKLY, MONTHLY)", example = "WEEKLY") String type, @Parameter(description = "조회 날짜 (yyyyMMdd)", example = "20251225") String date, @Parameter(description = "페이지 번호") int page, @Parameter(description = "페이지 크기") int size diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java index 14dfee93d..ba5c547fa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java @@ -21,11 +21,12 @@ public class RankingV1Controller implements RankingV1ApiSpec { @GetMapping @Override public ApiResponse> getRankings( + @RequestParam(value = "type", defaultValue = "DAILY") String type, @RequestParam(value = "date") String date, @RequestParam(value = "page", defaultValue = "1") int page, @RequestParam(value = "size", defaultValue = "20") int size ) { - List infos = rankingFacade.getTopRankings(date, page, size); + List infos = rankingFacade.getRankings(type, date, page, size); List response = infos.stream() .map(RankingV1Dto.RankingResponse::from) .toList(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java index 7413b94cb..6a48c34ea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java @@ -8,7 +8,7 @@ public record RankingResponse( Long productId, String productName, Long price, - int stock, + boolean isSoldOut, int currentRank ) { @@ -17,7 +17,7 @@ public static RankingResponse from(RankingInfo info) { info.productId(), info.productName(), info.price(), - info.stock(), + info.isSoldOut(), info.currentRank() ); } diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 3b17b4781..2de09b65d 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -32,7 +32,6 @@ spring: connectTimeout: 1000 readTimeout: 1000 - resilience4j: retry: instances: diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..b22b6477c --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,21 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java new file mode 100644 index 000000000..d99b05238 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,26 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +import java.util.TimeZone; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceBatchApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); + System.exit(exitCode); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java new file mode 100644 index 000000000..7c486483f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java @@ -0,0 +1,48 @@ +package com.loopers.batch.job.demo; + +import com.loopers.batch.job.demo.step.DemoTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class DemoJobConfig { + public static final String JOB_NAME = "demoJob"; + private static final String STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DemoTasklet demoTasklet; + + @Bean(JOB_NAME) + public Job demoJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(categorySyncStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_DEMO_SIMPLE_TASK_NAME) + public Step categorySyncStep() { + return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) + .tasklet(demoTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java new file mode 100644 index 000000000..800fe5a03 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java @@ -0,0 +1,32 @@ +package com.loopers.batch.job.demo.step; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class DemoTasklet implements Tasklet { + @Value("#{jobParameters['requestDate']}") + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + if (requestDate == null) { + throw new RuntimeException("requestDate is null"); + } + System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); + Thread.sleep(1000); + System.out.println("Demo Tasklet 작업 완료"); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java new file mode 100644 index 000000000..e7f2e6bf8 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -0,0 +1,70 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.step.RankingPrepareTasklet; +import com.loopers.batch.job.ranking.step.monthly.MonthlyRankingTableSwapTasklet; +import com.loopers.domain.ProductMetrics; +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@Configuration +@RequiredArgsConstructor +public class MonthlyRankingJobConfig { + public static final String JOB_NAME = "monthlyRankingJob"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final RankingPrepareTasklet prepareTasklet; + private final MonthlyRankingTableSwapTasklet tableSwapTasklet; // 월간 전용 스왑 + + private final JpaPagingItemReader monthlyRankingReader; + private final ItemProcessor rankingProcessor; + private final JpaItemWriter rankingWriter; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(monthlyPrepareStep()) + .next(monthlyCalculationStep()) + .next(monthlyTableSwapStep()) + .build(); + } + + @Bean + public Step monthlyPrepareStep() { + return new StepBuilder("monthlyPrepareStep", jobRepository) + .tasklet(prepareTasklet, transactionManager) + .build(); + } + + @Bean + public Step monthlyCalculationStep() { + return new StepBuilder("monthlyCalculationStep", jobRepository) + .chunk(100, transactionManager) + .reader(monthlyRankingReader) // 기간을 30일로 설정한 Reader + .processor(rankingProcessor) + .writer(rankingWriter) + .build(); + } + + @Bean + public Step monthlyTableSwapStep() { + return new StepBuilder("monthlyTableSwapStep", jobRepository) + .tasklet(tableSwapTasklet, transactionManager) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingChunkConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingChunkConfig.java new file mode 100644 index 000000000..f78d8d4f6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingChunkConfig.java @@ -0,0 +1,65 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.step.weekly.WeeklyRankingProcessor; +import com.loopers.domain.ProductMetrics; +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import jakarta.persistence.EntityManagerFactory; +import java.time.LocalDateTime; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class RankingChunkConfig { + + private final WeeklyRankingProcessor weeklyRankingProcessor; + private final EntityManagerFactory emf; + + @Bean + @StepScope + public JpaPagingItemReader rankingReader() { + return new JpaPagingItemReaderBuilder() + .name("rankingReader") + .entityManagerFactory(emf) + .queryString("SELECT m FROM ProductMetrics m WHERE m.updatedAt >= :startDate") + .parameterValues(Map.of("startDate", LocalDateTime.now().minusDays(7))) + .pageSize(100) + .build(); + } + + @Bean + public ItemProcessor rankingProcessor() { + return weeklyRankingProcessor; + } + + @Bean + @StepScope + public JpaItemWriter rankingWriter() { + return new JpaItemWriterBuilder() + .entityManagerFactory(emf) + .build(); + } + + @Bean + @StepScope + public JpaPagingItemReader monthlyRankingReader( + @Value("#{jobParameters['startDate']}") String startDate + ) { + return new JpaPagingItemReaderBuilder() + .name("monthlyRankingReader") + .entityManagerFactory(emf) + .queryString("SELECT m FROM ProductMetrics m WHERE m.updatedAt >= :startDate") + .parameterValues(Map.of("startDate", LocalDateTime.parse(startDate))) + .pageSize(100) + .build(); + } +} 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..c87e7a46c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -0,0 +1,75 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.step.RankingPrepareTasklet; +import com.loopers.batch.job.ranking.step.RankingTableSwapTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.domain.ProductMetrics; +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class WeeklyRankingJobConfig { + + public static final String JOB_NAME = "weeklyRankingJob"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final PlatformTransactionManager transactionManager; + + private final RankingPrepareTasklet prepareTasklet; + private final RankingTableSwapTasklet tableSwapTasklet; + + private final ItemReader rankingReader; + private final ItemProcessor rankingProcessor; + private final ItemWriter rankingWriter; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(prepareStep()) + .next(calculationStep()) + .next(tableSwapStep()) + .listener(jobListener) + .build(); + } + + @Bean + public Step prepareStep() { + return new StepBuilder("prepareStep", jobRepository) + .tasklet(prepareTasklet, transactionManager) + .build(); + } + + @Bean + public Step calculationStep() { + return new StepBuilder("calculationStep", jobRepository) + .chunk(100, transactionManager) + .reader(rankingReader) + .processor(rankingProcessor) + .writer(rankingWriter) + .build(); + } + + @Bean + public Step tableSwapStep() { + return new StepBuilder("tableSwapStep", jobRepository) + .tasklet(tableSwapTasklet, transactionManager) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/scheduler/RankingScheduler.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/scheduler/RankingScheduler.java new file mode 100644 index 000000000..c5afe7104 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/scheduler/RankingScheduler.java @@ -0,0 +1,59 @@ +package com.loopers.batch.job.ranking.scheduler; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingScheduler { + + private final JobLauncher jobLauncher; + + @Qualifier("weeklyRankingJob") + private final Job weeklyRankingJob; + + @Qualifier("monthlyRankingJob") + private final Job monthlyRankingJob; + + @Scheduled(cron = "0 0 2 * * MON") + public void runWeeklyRankingJob() { + String requestDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + log.info(">>> Weekly Ranking Job Scheduler Start: {}", requestDate); + + try { + jobLauncher.run(weeklyRankingJob, new JobParametersBuilder() + .addString("requestDate", requestDate) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters()); + } catch (Exception e) { + log.error(">>> Weekly Ranking Job Error: {}", e.getMessage()); + } + } + + @Scheduled(cron = "0 0 3 1 * *") + public void runMonthlyRankingJob() { + String requestDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String startDate = LocalDateTime.now().minusMonths(1).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + + log.info(">>> Monthly Ranking Job Scheduler Start: {}", requestDate); + + try { + jobLauncher.run(monthlyRankingJob, new JobParametersBuilder() + .addString("requestDate", requestDate) + .addString("startDate", startDate) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters()); + } catch (Exception e) { + log.error(">>> Monthly Ranking Job Error: {}", e.getMessage()); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingPrepareTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingPrepareTasklet.java new file mode 100644 index 000000000..9fe8c066a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingPrepareTasklet.java @@ -0,0 +1,24 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.domain.rank.weekly.WeeklyRankingWorkRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +@Component +@StepScope +@RequiredArgsConstructor +public class RankingPrepareTasklet implements Tasklet { + + private final WeeklyRankingWorkRepository workingRepository; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + workingRepository.deleteAllInBatch(); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingTableSwapTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingTableSwapTasklet.java new file mode 100644 index 000000000..4f0ebce0d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingTableSwapTasklet.java @@ -0,0 +1,40 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.domain.rank.weekly.WeeklyRankingMV; +import com.loopers.domain.rank.weekly.WeeklyRankingMVRepository; +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import com.loopers.domain.rank.weekly.WeeklyRankingWorkRepository; +import java.time.LocalDate; +import java.util.List; +import lombok.AllArgsConstructor; +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.stereotype.Component; + +@Component +@StepScope +@AllArgsConstructor +public class RankingTableSwapTasklet implements Tasklet { + + private final WeeklyRankingMVRepository mvRepository; + private final WeeklyRankingWorkRepository workRepository; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String baseDate = LocalDate.now().toString(); + + mvRepository.deleteAllInBatch(); + + List workData = workRepository.findAll(); + + List newData = workData.stream() + .map(work -> WeeklyRankingMV.createFromWork(work, baseDate)) + .toList(); + + mvRepository.saveAll(newData); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/monthly/MonthlyRankingTableSwapTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/monthly/MonthlyRankingTableSwapTasklet.java new file mode 100644 index 000000000..6b95e15b2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/monthly/MonthlyRankingTableSwapTasklet.java @@ -0,0 +1,63 @@ +package com.loopers.batch.job.ranking.step.monthly; + +import com.loopers.domain.rank.monthly.MonthlyRankingMV; +import com.loopers.domain.rank.monthly.MonthlyRankingMVRepository; +import com.loopers.domain.rank.monthly.ProductSnapshot; +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import com.loopers.domain.rank.weekly.WeeklyRankingWorkRepository; +import java.util.List; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MonthlyRankingTableSwapTasklet implements Tasklet { + + private final MonthlyRankingMVRepository monthlyMvRepository; + private final WeeklyRankingWorkRepository workRepository; + private final RedisTemplate redisTemplate; // Redis 사용 + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + monthlyMvRepository.deleteAllInBatch(); + List workData = workRepository.findAll(); + if (workData.isEmpty()) return RepeatStatus.FINISHED; + + List keys = workData.stream() + .map(work -> "product:snapshot:" + work.getProductId()) + .toList(); + + List snapshots = redisTemplate.opsForValue().multiGet(keys); + + String baseDate = (String) chunkContext.getStepContext().getJobParameters().get("requestDate"); + if (baseDate == null) baseDate = "2026-01"; + + String finalBaseDate = baseDate; + List newData = IntStream.range(0, workData.size()) + .mapToObj(i -> { + WeeklyRankingWork work = workData.get(i); + ProductSnapshot snapshot = (ProductSnapshot) snapshots.get(i); // Redis에서 가져온 스냅샷 + + if (snapshot == null) { + return MonthlyRankingMV.createFromWork(work, finalBaseDate, "Unknown", 0L, true); + } + + return MonthlyRankingMV.createFromWork( + work, + "2026-01", + snapshot.getName(), + snapshot.getPrice(), + snapshot.isSoldOut() + ); + }).toList(); + + monthlyMvRepository.saveAll(newData); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/weekly/WeeklyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/weekly/WeeklyRankingProcessor.java new file mode 100644 index 000000000..1ac276b26 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/weekly/WeeklyRankingProcessor.java @@ -0,0 +1,30 @@ +package com.loopers.batch.job.ranking.step.weekly; + +import com.loopers.domain.ProductMetrics; +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +@Component +@StepScope +public class WeeklyRankingProcessor implements ItemProcessor { + + private int rankCounter = 0; + + @Override + public WeeklyRankingWork process(ProductMetrics item) { + rankCounter++; + if (rankCounter > 100) { + return null; + } + + Double score = (item.getViewCount() * 0.1) + (item.getLikeCount() * 0.2) + (item.getSalesCount() * 0.6); + + return new WeeklyRankingWork( + item.getProductId(), + score, + rankCounter + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java new file mode 100644 index 000000000..10b09b8fc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java @@ -0,0 +1,21 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.annotation.AfterChunk; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ChunkListener { + + @AfterChunk + void afterChunk(ChunkContext chunkContext) { + log.info( + "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + + "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java new file mode 100644 index 000000000..cb5c8bebd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -0,0 +1,53 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.annotation.AfterJob; +import org.springframework.batch.core.annotation.BeforeJob; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JobListener { + + @BeforeJob + void beforeJob(JobExecution jobExecution) { + log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); + jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); + } + + @AfterJob + void afterJob(JobExecution jobExecution) { + var startTime = jobExecution.getExecutionContext().getLong("startTime"); + var endTime = System.currentTimeMillis(); + + var startDateTime = Instant.ofEpochMilli(startTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + var endDateTime = Instant.ofEpochMilli(endTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + var totalTime = endTime - startTime; + var duration = Duration.ofMillis(totalTime); + var hours = duration.toHours(); + var minutes = duration.toMinutes() % 60; + var seconds = duration.getSeconds() % 60; + + var message = String.format( + """ + *Start Time:* %s + *End Time:* %s + *Total Time:* %d시간 %d분 %d초 + """, startDateTime, endDateTime, hours, minutes, seconds + ).trim(); + + log.info(message); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java new file mode 100644 index 000000000..4f22f40b0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -0,0 +1,44 @@ +package com.loopers.batch.listener; + +import jakarta.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Component +public class StepMonitorListener implements StepExecutionListener { + + @Override + public void beforeStep(@Nonnull StepExecution stepExecution) { + log.info("Step '{}' 시작", stepExecution.getStepName()); + } + + @Override + public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); + var exceptions = stepExecution.getFailureExceptions().stream() + .map(Throwable::getMessage) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + log.info( + """ + [에러 발생] + jobName: {} + exceptions: + {} + """.trim(), jobName, exceptions + ); + // error 발생 시 slack 등 다른 채널로 모니터 전송 + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..9aa0d760a --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,54 @@ +spring: + main: + web-application-type: none + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + batch: + job: + name: ${job.name:NONE} + jdbc: + initialize-schema: never + +management: + health: + defaults: + enabled: false + +--- +spring: + config: + activate: + on-profile: local, test + batch: + jdbc: + initialize-schema: always + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java new file mode 100644 index 000000000..c5e3bc7a3 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -0,0 +1,10 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class CommerceBatchApplicationTest { + @Test + void contextLoads() {} +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java new file mode 100644 index 000000000..dafe59a18 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java @@ -0,0 +1,76 @@ +package com.loopers.job.demo; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) +class DemoJobE2ETest { + + // IDE 정적 분석 상 [SpringBatchTest] 의 주입보다 [SpringBootTest] 의 주입이 우선되어, 해당 컴포넌트는 없으므로 오류처럼 보일 수 있음. + // [SpringBatchTest] 자체가 Scope 기반으로 주입하기 때문에 정상 동작함. + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(DemoJobConfig.JOB_NAME) + private Job job; + + @BeforeEach + void beforeEach() { + + } + + @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") + @Test + void shouldNotSaveCategories_whenApiError() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) + ); + } + + @DisplayName("demoJob 배치가 정상적으로 실행된다.") + @Test + void success() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.now()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()) + ); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/rank/RankingScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/rank/RankingScheduler.java new file mode 100644 index 000000000..3c60aab26 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/rank/RankingScheduler.java @@ -0,0 +1,35 @@ +package com.loopers.application.rank; + +import com.loopers.domain.rank.RankingService; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingScheduler { + + private final RankingService rankingService; + + @Scheduled(cron = "0 50 23 * * *") + public void scheduleRankingCarryOver() { + LocalDateTime now = LocalDateTime.now(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd"); + + String today = now.format(formatter); + String tomorrow = now.plusDays(1).format(formatter); + + log.info("Starting Ranking Carry-Over: {} -> {}", today, tomorrow); + + try { + rankingService.carryOverRanking(today, tomorrow, 0.1); + log.info("Ranking Carry-Over completed successfully."); + } catch (Exception e) { + log.error("Ranking Carry-Over failed", e); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java index b259518c2..3b9dff8cc 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java @@ -1,6 +1,7 @@ package com.loopers.domain.metrics; import com.loopers.core.cache.RedisCacheHandler; +import com.loopers.domain.ProductMetrics; import com.loopers.domain.event.EventHandled; import com.loopers.event.LikeCountEvent; import com.loopers.event.ProductStockEvent; @@ -50,6 +51,12 @@ public void processSalesCountEvent(ProductStockEvent event) { metrics.addSalesCount(event.sellQuantity()); + metrics.updateProductSnapshot( + event.productName(), + event.price(), + event.currentStock() + ); + if (event.currentStock() <= 0) { redisCacheHandler.delete("product:detail:" + event.productId()); redisCacheHandler.deleteByPattern("product:list"); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingKeyGenerator.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingKeyGenerator.java new file mode 100644 index 000000000..91dc4b64f --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingKeyGenerator.java @@ -0,0 +1,9 @@ +package com.loopers.domain.rank; + +import org.springframework.stereotype.Component; + +public interface RankingKeyGenerator { + + String generateDailyKey(String date); +} + diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java index 9d01b7609..0be59501b 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java @@ -2,9 +2,12 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.connection.zset.Aggregate; +import org.springframework.data.redis.connection.zset.Weights; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; @@ -13,14 +16,13 @@ public class RankingService { private final RedisTemplate redisTemplate; - - private static final String KEY_PREFIX = "ranking:all:"; + private final RankingKeyGenerator rankingKeyGenerator; private static final double VIEW_WEIGHT = 0.1; private static final double LIKE_WEIGHT = 0.2; private static final double ORDER_WEIGHT = 0.6; public void addScore(Long productId, double baseScore, double weight, LocalDateTime dateTime) { - String dateKey = KEY_PREFIX + dateTime.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String dateKey = rankingKeyGenerator.generateDailyKey(dateTime.format(DateTimeFormatter.ofPattern("yyyyMMdd"))); double finalScore = baseScore * weight; redisTemplate.opsForZSet().incrementScore(dateKey, productId.toString(), finalScore); @@ -35,4 +37,14 @@ public void addOrderScoresBatch(Map> updates) { redisTemplate.expire(dateKey, 2, TimeUnit.DAYS); }); } + + public void carryOverRanking(String sourceDate, String targetDate, double weight) { + String sourceKey = rankingKeyGenerator.generateDailyKey(sourceDate); + String targetKey = rankingKeyGenerator.generateDailyKey(targetDate); + + redisTemplate.opsForZSet().unionAndStore(sourceKey, List.of(), targetKey, + Aggregate.SUM, Weights.of(weight)); + + redisTemplate.expire(targetKey, 2, TimeUnit.DAYS); + } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RedisRankingKeyGenerator.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RedisRankingKeyGenerator.java new file mode 100644 index 000000000..d44cc4606 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RedisRankingKeyGenerator.java @@ -0,0 +1,14 @@ +package com.loopers.domain.rank; + +import org.springframework.stereotype.Component; + +@Component +public class RedisRankingKeyGenerator implements RankingKeyGenerator { + + private static final String DAILY_RANKING_PREFIX = "ranking:all:"; + + @Override + public String generateDailyKey(String date) { + return DAILY_RANKING_PREFIX + date; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java index b8732b5fd..ef35ff6da 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure; -import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.ProductMetrics; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; diff --git a/apps/pg-simulator/src/main/resources/application.yml b/apps/pg-simulator/src/main/resources/application.yml index addf0e29c..caae22ccb 100644 --- a/apps/pg-simulator/src/main/resources/application.yml +++ b/apps/pg-simulator/src/main/resources/application.yml @@ -23,6 +23,13 @@ spring: - redis.yml - logging.yml - monitoring.yml + cloud: + openfeign: + client: + config: + default: + connectTimeout: 1000 + readTimeout: 1000 datasource: mysql-jpa: diff --git a/modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.java b/modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.java new file mode 100644 index 000000000..8c3bec8b5 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.java @@ -0,0 +1,57 @@ +package com.loopers.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "product_metrics") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetrics { + + @Id + private Long productId; + + private int likeCount = 0; + private int viewCount = 0; + private int salesCount = 0; + + private String productName; + private Long price; + private boolean isSoldOut; + private LocalDateTime updatedAt; + + public ProductMetrics(Long productId) { + this.productId = productId; + } + + public void updateLikeCount(int newCount, LocalDateTime eventTime) { + if (this.updatedAt != null && eventTime.isBefore(this.updatedAt)) { + return; + } + this.likeCount = newCount; + this.updatedAt = eventTime; + } + + public void incrementViewCount() { + this.viewCount += 1; + this.updatedAt = LocalDateTime.now(); + } + + public void addSalesCount(int quantity) { + this.salesCount += quantity; + this.updatedAt = LocalDateTime.now(); + } + + public void updateProductSnapshot(String productName, long price, int currentStock) { + this.productName = productName; + this.price = price; + this.isSoldOut = (currentStock <= 0); + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMV.java b/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMV.java new file mode 100644 index 000000000..6bf2e1f01 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMV.java @@ -0,0 +1,44 @@ +package com.loopers.domain.rank.monthly; + +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "mv_product_rank_monthly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MonthlyRankingMV { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String baseDate; + private Long productId; + private Double totalScore; + private Integer currentRank; + + private String productName; + private Long price; + private boolean isSoldOut; + + public static MonthlyRankingMV createFromWork(WeeklyRankingWork work, String baseDate, String productName, Long price, boolean isSoldOut) { + MonthlyRankingMV mv = new MonthlyRankingMV(); + mv.baseDate = baseDate; + mv.productId = work.getProductId(); + mv.totalScore = work.getScore(); + mv.currentRank = work.getRanking(); + + mv.productName = productName; + mv.price = price; + mv.isSoldOut = isSoldOut; + + return mv; + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMVRepository.java b/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMVRepository.java new file mode 100644 index 000000000..78214afcd --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMVRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.rank.monthly; + +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MonthlyRankingMVRepository extends JpaRepository { + + List findByBaseDateOrderByCurrentRankAsc(String baseDate, Pageable pageable); +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/ProductSnapshot.java b/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/ProductSnapshot.java new file mode 100644 index 000000000..7f9ce3b9b --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/ProductSnapshot.java @@ -0,0 +1,19 @@ +package com.loopers.domain.rank.monthly; + +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ProductSnapshot implements Serializable { + private String name; + private long price; + private boolean isSoldOut; + + public static ProductSnapshot of(String name, long price, boolean isSoldOut) { + return new ProductSnapshot(name, price, isSoldOut); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMV.java b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMV.java new file mode 100644 index 000000000..4c8dabf03 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMV.java @@ -0,0 +1,53 @@ +package com.loopers.domain.rank.weekly; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_weekly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class WeeklyRankingMV { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String baseDate; + private Long productId; + private Double totalScore; + private Integer currentRank; + + private String productName; + private Long price; + private boolean isSoldOut; + + private WeeklyRankingMV(String baseDate, Long productId, Double totalScore, Integer currentRank, + String productName, Long price, boolean isSoldOut) { + this.baseDate = baseDate; + this.productId = productId; + this.totalScore = totalScore; + this.currentRank = currentRank; + this.productName = productName; + this.price = price; + this.isSoldOut = isSoldOut; + } + + // 정적 팩토리 메서드 (의미 있는 생성 방식 제공) + public static WeeklyRankingMV createFromWork(WeeklyRankingWork work, String baseDate) { + return new WeeklyRankingMV( + baseDate, + work.getProductId(), + work.getScore(), + work.getRanking(), + "상품명 임시", // 실제 구현 시 Product 정보 결합 필요 + 0L, // 실제 구현 시 Product 정보 결합 필요 + false // 실제 구현 시 Product 정보 결합 필요 + ); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMVRepository.java b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMVRepository.java new file mode 100644 index 000000000..2cb21d63c --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMVRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.rank.weekly; + +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface WeeklyRankingMVRepository extends JpaRepository { + + List findByBaseDateOrderByCurrentRankAsc(String baseDate, Pageable pageable); +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWork.java b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWork.java new file mode 100644 index 000000000..afef5c3f0 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWork.java @@ -0,0 +1,30 @@ +package com.loopers.domain.rank.weekly; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "weekly_ranking_work") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WeeklyRankingWork { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private Long productId; + private Double score; + private Integer ranking; + + public WeeklyRankingWork(Long productId, Double score, Integer ranking) { + this.productId = productId; + this.score = score; + this.ranking = ranking; + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWorkRepository.java b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWorkRepository.java new file mode 100644 index 000000000..b1eb439fd --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWorkRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.rank.weekly; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface WeeklyRankingWorkRepository extends JpaRepository { + +} diff --git a/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java b/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java index 73810226d..3b0678d16 100644 --- a/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java +++ b/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java @@ -6,15 +6,17 @@ public record ProductStockEvent( String eventId, Long productId, + String productName, int sellQuantity, int currentStock, long price, LocalDateTime createdAt ) { - public static ProductStockEvent of(Long productId, int sellQuantity, int currentStock, long price) { + public static ProductStockEvent of(Long productId, String productName, int sellQuantity, int currentStock, long price) { return new ProductStockEvent( UUID.randomUUID().toString(), productId, + productName, sellQuantity, currentStock, price, diff --git a/settings.gradle.kts b/settings.gradle.kts index bc65fd714..11ea75232 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,6 +4,7 @@ include( ":apps:commerce-api", ":apps:commerce-streamer", ":apps:pg-simulator", + ":apps:commerce-batch", ":modules:jpa", ":modules:redis", ":modules:kafka",