From ca2333550d74bad99c8faf3edc0f26ec9659c523 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Sun, 31 Aug 2025 16:40:50 +0900 Subject: [PATCH 01/14] =?UTF-8?q?chore:=20Spring=20Batch=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/user-service/build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/user-service/build.gradle b/apps/user-service/build.gradle index 4da4d368..19479f54 100644 --- a/apps/user-service/build.gradle +++ b/apps/user-service/build.gradle @@ -39,6 +39,9 @@ dependencies { // MyBatis implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5' + // batch + implementation 'org.springframework.boot:spring-boot-starter-batch' + // Log4j2 - 모든 모듈을 2.22.1로 통일 implementation 'org.springframework.boot:spring-boot-starter-log4j2' implementation 'org.apache.logging.log4j:log4j-core:2.22.1' From 59208870e13e62576bb00f17f3a8728aeb847673 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Sun, 31 Aug 2025 16:48:03 +0900 Subject: [PATCH 02/14] =?UTF-8?q?chore:=20Batch=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=B4=88=EA=B8=B0=20DTO=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductCrawlingData: 배치 작업의 Reader -> Processor -> Writer 단계 사이에서 데이터를 실어 나를 내부용 데이터 상자 - FastApiCrawlingResponse: RestTemplate이 FastAPI 서버에 크롤링을 요청한 후, 그 응답(JSON)을 담아올 외부용 데이터 상자 --- .../icebang/dto/FastApiCrawlingResponse.java | 11 +++++++++++ .../gltkorea/icebang/dto/ProductCrawlingData.java | 14 ++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/dto/FastApiCrawlingResponse.java create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/dto/ProductCrawlingData.java diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/dto/FastApiCrawlingResponse.java b/apps/user-service/src/main/java/com/gltkorea/icebang/dto/FastApiCrawlingResponse.java new file mode 100644 index 00000000..78a0a526 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/dto/FastApiCrawlingResponse.java @@ -0,0 +1,11 @@ +package com.gltkorea.icebang.dto; + +import lombok.Data; + +@Data +public class FastApiCrawlingResponse { + // FastAPI가 반환하는 JSON의 필드명과 일치해야 함 + private int price; + private String stockStatus; + private String productName; // 기타 필요한 정보 +} \ No newline at end of file diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/dto/ProductCrawlingData.java b/apps/user-service/src/main/java/com/gltkorea/icebang/dto/ProductCrawlingData.java new file mode 100644 index 00000000..42d953b5 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/dto/ProductCrawlingData.java @@ -0,0 +1,14 @@ +package com.gltkorea.icebang.dto; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data // Lombok +public class ProductCrawlingData { + private Long id; + private String name; + private String urlToCrawl; // DB 컬럼명은 url_to_crawl + private int price; + private String stockStatus; // DB 컬럼명은 stock_status + private LocalDateTime lastCrawledAt; // DB 컬럼명은 last_crawled_at +} \ No newline at end of file From acd0f33804532f795aa89254b5d000dbd8828432 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Sun, 31 Aug 2025 16:49:12 +0900 Subject: [PATCH 03/14] =?UTF-8?q?chore:=20Batch=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=B4=88=EA=B8=B0=20Mapper=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductCrawlingData라는 상자에 DB 데이터를 담거나, 상자의 내용물로 DB를 업데이트할 수 있도록 실제 SQL 쿼리를 CrawlingMapper.xml에 작성하고, 이를 Java에서 호출할 수 있도록 CrawlingMapper.java 인터페이스로 연결 --- .../icebang/mapper/CrawlingMapper.java | 12 ++++++++++ .../mybatis/mapper/CrawlingMapper.xml | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/mapper/CrawlingMapper.java create mode 100644 apps/user-service/src/main/resources/mybatis/mapper/CrawlingMapper.xml diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/CrawlingMapper.java b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/CrawlingMapper.java new file mode 100644 index 00000000..3287c26b --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/CrawlingMapper.java @@ -0,0 +1,12 @@ +package com.gltkorea.icebang.mapper; + +import com.gltkorea.icebang.dto.ProductCrawlingData; +import org.apache.ibatis.annotations.Mapper; +import java.util.List; +import java.util.Map; + +@Mapper +public interface CrawlingMapper { + List findProductsToCrawl(Map parameters); + void updateCrawledProduct(ProductCrawlingData productData); +} \ No newline at end of file diff --git a/apps/user-service/src/main/resources/mybatis/mapper/CrawlingMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/CrawlingMapper.xml new file mode 100644 index 00000000..08e5767b --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/CrawlingMapper.xml @@ -0,0 +1,24 @@ + + + + + + + + UPDATE product + SET + price = #{price}, + stock_status = #{stockStatus}, + last_crawled_at = #{lastCrawledAt} + WHERE + id = #{id} + + \ No newline at end of file From cb95104114aba1cf2b37f0c3f54ad76b31eac679 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Sun, 31 Aug 2025 16:50:50 +0900 Subject: [PATCH 04/14] =?UTF-8?q?chore:=20Batch=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=B4=88=EA=B8=B0=20Scheduler?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../icebang/UserServiceApplication.java | 2 ++ .../icebang/scheduler/CrawlingScheduler.java | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/scheduler/CrawlingScheduler.java diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/UserServiceApplication.java b/apps/user-service/src/main/java/com/gltkorea/icebang/UserServiceApplication.java index c69c1773..cdfbac39 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/UserServiceApplication.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/UserServiceApplication.java @@ -3,7 +3,9 @@ import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @SpringBootApplication @MapperScan("com.gltkorea.icebang.mapper") public class UserServiceApplication { diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/scheduler/CrawlingScheduler.java b/apps/user-service/src/main/java/com/gltkorea/icebang/scheduler/CrawlingScheduler.java new file mode 100644 index 00000000..dff3fdd8 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/scheduler/CrawlingScheduler.java @@ -0,0 +1,26 @@ +package com.gltkorea.icebang.scheduler; + +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +public class CrawlingScheduler { + + private final JobLauncher jobLauncher; + private final Job crawlingJob; // CrawlingBatchConfig에 정의된 Job Bean 주입 + + // 매일 오전 8시에 실행 -> 추 후 application.yml로 설정 이동 예정 + @Scheduled(cron = "0 0 8 * * *") + public void runCrawlingJob() throws Exception { + jobLauncher.run(crawlingJob, new JobParametersBuilder() + .addString("runAt", LocalDateTime.now().toString()) + .toJobParameters()); + } +} \ No newline at end of file From e56e0171cd621651d58e793e7c30d98355425423 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Sun, 31 Aug 2025 16:52:58 +0900 Subject: [PATCH 05/14] =?UTF-8?q?chore:=20=EC=B4=88=EA=B8=B0=20Batch=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/batch/CrawlingBatchConfig.java | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java new file mode 100644 index 00000000..c14e1d55 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java @@ -0,0 +1,175 @@ +package com.gltkorea.icebang.config.batch; + +import com.gltkorea.icebang.dto.ProductCrawlingData; +import com.gltkorea.icebang.dto.FastApiCrawlingResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.batch.MyBatisBatchItemWriter; +import org.mybatis.spring.batch.MyBatisPagingItemReader; +import org.mybatis.spring.batch.builder.MyBatisBatchItemWriterBuilder; +import org.mybatis.spring.batch.builder.MyBatisPagingItemReaderBuilder; +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.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.support.SynchronizedItemStreamReader; +import org.springframework.batch.item.support.builder.SynchronizedItemStreamReaderBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDateTime; + +/** + * 외부 크롤링 API를 호출하여 상품 정보를 업데이트하는 배치(Batch) 작업을 설정 + * Job은 병렬 처리와 내결함성(Fault Tolerance) 정책을 적용해 안정적인 운영 환경을 고려 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class CrawlingBatchConfig { + + // Spring Batch의 메타데이터(Job 실행 기록, 상태 등)를 관리하는 리포지토리 + private final JobRepository jobRepository; + // 배치 Step 내에서 트랜잭션을 관리하기 위한 트랜잭션 매니저 + private final PlatformTransactionManager transactionManager; + // MyBatis 쿼리를 실행하기 위한 SqlSessionFactory + private final SqlSessionFactory sqlSessionFactory; + // 외부 API(FastAPI)와 통신하기 위한 RestTemplate + private final RestTemplate restTemplate; + + /** + * 'crawlingJob'이라는 이름의 Batch Job을 정의 + * Job은 배치 작업의 가장 큰 단위이며, 하나 이상의 Step으로 구성 + */ + @Bean + public Job crawlingJob() { + return new JobBuilder("crawlingJob", jobRepository) + .start(crawlingStep()) // 'crawlingStep'이라는 Step으로 Job을 시작 + .build(); + } + + /** + * 'crawlingStep'이라는 이름의 Batch Step을 정의 + * Step은 Reader, Processor, Writer의 흐름으로 구성되며, + * 이 Step은 병렬 처리와 실패 처리 정책이 적용 + */ + @Bean + public Step crawlingStep() { + return new StepBuilder("crawlingStep", jobRepository) + // <읽어올 데이터 타입, 처리 후 데이터 타입>을 지정합니다. + // chunk(10): 10개의 아이템을 하나의 트랜잭션 단위로 묶어서 처리합니다. + .chunk(10, transactionManager) + .reader(synchronizedProductReader()) // 데이터 읽기 + .processor(crawlingProcessor()) // 데이터 가공 + .writer(productWriter()) // 데이터 쓰기 + + // --- 실패 처리 정책 (Fault Tolerance) --- + .faultTolerant() + // 네트워크 오류(ResourceAccessException)는 아이템당 3번까지 재시도합니다. + .retry(ResourceAccessException.class) + .retryLimit(3) + // 4xx 에러(HttpClientErrorException)는 영구 실패로 간주하고 아이템당 100번까지 건너뜀 + .skip(HttpClientErrorException.class) + .skipLimit(100) + + // --- 병렬 처리 설정 --- + .taskExecutor(taskExecutor()) + .build(); + } + + /** + * 병렬 처리를 위한 TaskExecutor(쓰레드 풀)를 정의 + */ + @Bean + public TaskExecutor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(10); // 동시에 실행할 기본 쓰레드 수 + executor.setMaxPoolSize(10); // 최대 쓰레드 수 + executor.setThreadNamePrefix("batch-thread-"); // 쓰레드 이름 접두사 + executor.initialize(); + return executor; + } + + /** + * ItemReader를 스레드로부터 안전하게 만들기 위해 SynchronizedItemStreamReader로 감쌈 + * 병렬 처리 시 여러 스레드가 Reader에 동시에 접근하는 것을 방지 + */ + @Bean + public SynchronizedItemStreamReader synchronizedProductReader() { + // 실제 데이터를 읽는 Reader는 private 메서드로 정의하여 외부 노출을 최소화합니다. + MyBatisPagingItemReader reader = productReader(); + + return new SynchronizedItemStreamReaderBuilder() + .delegate(reader) + .build(); + } + + /** + * 실제 DB에서 크롤링할 상품 목록을 읽어오는 ItemReader + * MyBatisPagingItemReader는 MyBatis를 사용하여 페이징 기반으로 대용량 데이터를 안전하게 읽어옴 + */ + private MyBatisPagingItemReader productReader() { + return new MyBatisPagingItemReaderBuilder() + .sqlSessionFactory(sqlSessionFactory) + // CrawlingMapper.xml에 정의된 쿼리의 전체 경로(namespace + id)를 지정합니다. + .queryId("com.gltkorea.icebang.mapper.CrawlingMapper.findProductsToCrawl") + .pageSize(10) // 한 번에 DB에서 조회할 데이터 수. chunk 사이즈와 맞추는 것이 좋습니다. + .build(); + } + + /** + * ItemProcessor: Reader가 읽어온 각 아이템을 가공 + * 여기서는 FastAPI 크롤링 서버를 호출하여 추가 정보를 얻어옴 + * 예외를 직접 처리(try-catch)하지 않고 Step으로 던져서, Step의 재시도/건너뛰기 정책이 동작하도록 함 + */ + @Bean + public ItemProcessor crawlingProcessor() { + return product -> { + log.info("Requesting crawl for product: {}", product.getName()); + + // FastAPI URL을 코드에 직접 작성 (추후 외부 설정으로 분리 권장) + String fastApiUrl = "http://your-fastapi-server.com/crawl?url=" + product.getUrlToCrawl(); + + // RestTemplate이 예외를 던지면 Step의 retry/skip 정책이 이를 감지하고 처리 + ResponseEntity response = restTemplate.getForEntity(fastApiUrl, FastApiCrawlingResponse.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + FastApiCrawlingResponse crawledData = response.getBody(); + + // API 응답 결과로 ProductCrawlingData 객체 업데이트 + product.setPrice(crawledData.getPrice()); + product.setStockStatus(crawledData.getStockStatus()); + product.setLastCrawledAt(LocalDateTime.now()); + + return product; // 가공이 완료된 데이터를 Writer로 전달 + } + + // API 호출은 성공했으나, 응답이 비정상적인 경우 (예: body가 null) + log.warn("Crawling API call returned non-successful status for product {}: {}", product.getName(), response.getStatusCode()); + return null; // 이 아이템만 건너뜀 (Writer로 전달되지 않음) + }; + } + + /** + * ItemWriter: 가공된 데이터 묶음(Chunk)을 DB에 일괄 저장 + * MyBatisBatchItemWriter는 내부적으로 JDBC Batch Update를 사용하여 성능이 좋음 + */ + @Bean + public MyBatisBatchItemWriter productWriter() { + return new MyBatisBatchItemWriterBuilder() + .sqlSessionFactory(sqlSessionFactory) + // CrawlingMapper.xml에 정의된 update 쿼리의 전체 경로를 지정 + .statementId("com.gltkorea.icebang.mapper.CrawlingMapper.updateCrawledProduct") + .build(); + } +} \ No newline at end of file From 8a6484fb57891b42e87e8b63a082dcf3b2061568 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Sun, 31 Aug 2025 17:19:45 +0900 Subject: [PATCH 06/14] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/batch/CrawlingBatchConfig.java | 281 +++++++++--------- .../icebang/dto/FastApiCrawlingResponse.java | 10 +- .../icebang/dto/ProductCrawlingData.java | 17 +- .../icebang/mapper/CrawlingMapper.java | 13 +- .../icebang/scheduler/CrawlingScheduler.java | 27 +- 5 files changed, 176 insertions(+), 172 deletions(-) diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java index c14e1d55..d237a38c 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java @@ -1,9 +1,7 @@ package com.gltkorea.icebang.config.batch; -import com.gltkorea.icebang.dto.ProductCrawlingData; -import com.gltkorea.icebang.dto.FastApiCrawlingResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import java.time.LocalDateTime; + import org.apache.ibatis.session.SqlSessionFactory; import org.mybatis.spring.batch.MyBatisBatchItemWriter; import org.mybatis.spring.batch.MyBatisPagingItemReader; @@ -27,149 +25,148 @@ import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; -import java.time.LocalDateTime; +import com.gltkorea.icebang.dto.FastApiCrawlingResponse; +import com.gltkorea.icebang.dto.ProductCrawlingData; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; /** - * 외부 크롤링 API를 호출하여 상품 정보를 업데이트하는 배치(Batch) 작업을 설정 - * Job은 병렬 처리와 내결함성(Fault Tolerance) 정책을 적용해 안정적인 운영 환경을 고려 + * 외부 크롤링 API를 호출하여 상품 정보를 업데이트하는 배치(Batch) 작업을 설정 Job은 병렬 처리와 내결함성(Fault Tolerance) 정책을 적용해 안정적인 운영 + * 환경을 고려 */ @Slf4j @Configuration @RequiredArgsConstructor public class CrawlingBatchConfig { - // Spring Batch의 메타데이터(Job 실행 기록, 상태 등)를 관리하는 리포지토리 - private final JobRepository jobRepository; - // 배치 Step 내에서 트랜잭션을 관리하기 위한 트랜잭션 매니저 - private final PlatformTransactionManager transactionManager; - // MyBatis 쿼리를 실행하기 위한 SqlSessionFactory - private final SqlSessionFactory sqlSessionFactory; - // 외부 API(FastAPI)와 통신하기 위한 RestTemplate - private final RestTemplate restTemplate; - - /** - * 'crawlingJob'이라는 이름의 Batch Job을 정의 - * Job은 배치 작업의 가장 큰 단위이며, 하나 이상의 Step으로 구성 - */ - @Bean - public Job crawlingJob() { - return new JobBuilder("crawlingJob", jobRepository) - .start(crawlingStep()) // 'crawlingStep'이라는 Step으로 Job을 시작 - .build(); - } - - /** - * 'crawlingStep'이라는 이름의 Batch Step을 정의 - * Step은 Reader, Processor, Writer의 흐름으로 구성되며, - * 이 Step은 병렬 처리와 실패 처리 정책이 적용 - */ - @Bean - public Step crawlingStep() { - return new StepBuilder("crawlingStep", jobRepository) - // <읽어올 데이터 타입, 처리 후 데이터 타입>을 지정합니다. - // chunk(10): 10개의 아이템을 하나의 트랜잭션 단위로 묶어서 처리합니다. - .chunk(10, transactionManager) - .reader(synchronizedProductReader()) // 데이터 읽기 - .processor(crawlingProcessor()) // 데이터 가공 - .writer(productWriter()) // 데이터 쓰기 - - // --- 실패 처리 정책 (Fault Tolerance) --- - .faultTolerant() - // 네트워크 오류(ResourceAccessException)는 아이템당 3번까지 재시도합니다. - .retry(ResourceAccessException.class) - .retryLimit(3) - // 4xx 에러(HttpClientErrorException)는 영구 실패로 간주하고 아이템당 100번까지 건너뜀 - .skip(HttpClientErrorException.class) - .skipLimit(100) - - // --- 병렬 처리 설정 --- - .taskExecutor(taskExecutor()) - .build(); - } - - /** - * 병렬 처리를 위한 TaskExecutor(쓰레드 풀)를 정의 - */ - @Bean - public TaskExecutor taskExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(10); // 동시에 실행할 기본 쓰레드 수 - executor.setMaxPoolSize(10); // 최대 쓰레드 수 - executor.setThreadNamePrefix("batch-thread-"); // 쓰레드 이름 접두사 - executor.initialize(); - return executor; - } - - /** - * ItemReader를 스레드로부터 안전하게 만들기 위해 SynchronizedItemStreamReader로 감쌈 - * 병렬 처리 시 여러 스레드가 Reader에 동시에 접근하는 것을 방지 - */ - @Bean - public SynchronizedItemStreamReader synchronizedProductReader() { - // 실제 데이터를 읽는 Reader는 private 메서드로 정의하여 외부 노출을 최소화합니다. - MyBatisPagingItemReader reader = productReader(); - - return new SynchronizedItemStreamReaderBuilder() - .delegate(reader) - .build(); - } - - /** - * 실제 DB에서 크롤링할 상품 목록을 읽어오는 ItemReader - * MyBatisPagingItemReader는 MyBatis를 사용하여 페이징 기반으로 대용량 데이터를 안전하게 읽어옴 - */ - private MyBatisPagingItemReader productReader() { - return new MyBatisPagingItemReaderBuilder() - .sqlSessionFactory(sqlSessionFactory) - // CrawlingMapper.xml에 정의된 쿼리의 전체 경로(namespace + id)를 지정합니다. - .queryId("com.gltkorea.icebang.mapper.CrawlingMapper.findProductsToCrawl") - .pageSize(10) // 한 번에 DB에서 조회할 데이터 수. chunk 사이즈와 맞추는 것이 좋습니다. - .build(); - } - - /** - * ItemProcessor: Reader가 읽어온 각 아이템을 가공 - * 여기서는 FastAPI 크롤링 서버를 호출하여 추가 정보를 얻어옴 - * 예외를 직접 처리(try-catch)하지 않고 Step으로 던져서, Step의 재시도/건너뛰기 정책이 동작하도록 함 - */ - @Bean - public ItemProcessor crawlingProcessor() { - return product -> { - log.info("Requesting crawl for product: {}", product.getName()); - - // FastAPI URL을 코드에 직접 작성 (추후 외부 설정으로 분리 권장) - String fastApiUrl = "http://your-fastapi-server.com/crawl?url=" + product.getUrlToCrawl(); - - // RestTemplate이 예외를 던지면 Step의 retry/skip 정책이 이를 감지하고 처리 - ResponseEntity response = restTemplate.getForEntity(fastApiUrl, FastApiCrawlingResponse.class); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - FastApiCrawlingResponse crawledData = response.getBody(); - - // API 응답 결과로 ProductCrawlingData 객체 업데이트 - product.setPrice(crawledData.getPrice()); - product.setStockStatus(crawledData.getStockStatus()); - product.setLastCrawledAt(LocalDateTime.now()); - - return product; // 가공이 완료된 데이터를 Writer로 전달 - } - - // API 호출은 성공했으나, 응답이 비정상적인 경우 (예: body가 null) - log.warn("Crawling API call returned non-successful status for product {}: {}", product.getName(), response.getStatusCode()); - return null; // 이 아이템만 건너뜀 (Writer로 전달되지 않음) - }; - } - - /** - * ItemWriter: 가공된 데이터 묶음(Chunk)을 DB에 일괄 저장 - * MyBatisBatchItemWriter는 내부적으로 JDBC Batch Update를 사용하여 성능이 좋음 - */ - @Bean - public MyBatisBatchItemWriter productWriter() { - return new MyBatisBatchItemWriterBuilder() - .sqlSessionFactory(sqlSessionFactory) - // CrawlingMapper.xml에 정의된 update 쿼리의 전체 경로를 지정 - .statementId("com.gltkorea.icebang.mapper.CrawlingMapper.updateCrawledProduct") - .build(); - } -} \ No newline at end of file + // Spring Batch의 메타데이터(Job 실행 기록, 상태 등)를 관리하는 리포지토리 + private final JobRepository jobRepository; + // 배치 Step 내에서 트랜잭션을 관리하기 위한 트랜잭션 매니저 + private final PlatformTransactionManager transactionManager; + // MyBatis 쿼리를 실행하기 위한 SqlSessionFactory + private final SqlSessionFactory sqlSessionFactory; + // 외부 API(FastAPI)와 통신하기 위한 RestTemplate + private final RestTemplate restTemplate; + + /** 'crawlingJob'이라는 이름의 Batch Job을 정의 Job은 배치 작업의 가장 큰 단위이며, 하나 이상의 Step으로 구성 */ + @Bean + public Job crawlingJob() { + return new JobBuilder("crawlingJob", jobRepository) + .start(crawlingStep()) // 'crawlingStep'이라는 Step으로 Job을 시작 + .build(); + } + + /** + * 'crawlingStep'이라는 이름의 Batch Step을 정의 Step은 Reader, Processor, Writer의 흐름으로 구성되며, 이 Step은 병렬 처리와 + * 실패 처리 정책이 적용 + */ + @Bean + public Step crawlingStep() { + return new StepBuilder("crawlingStep", jobRepository) + // <읽어올 데이터 타입, 처리 후 데이터 타입>을 지정합니다. + // chunk(10): 10개의 아이템을 하나의 트랜잭션 단위로 묶어서 처리합니다. + .chunk(10, transactionManager) + .reader(synchronizedProductReader()) // 데이터 읽기 + .processor(crawlingProcessor()) // 데이터 가공 + .writer(productWriter()) // 데이터 쓰기 + + // --- 실패 처리 정책 (Fault Tolerance) --- + .faultTolerant() + // 네트워크 오류(ResourceAccessException)는 아이템당 3번까지 재시도합니다. + .retry(ResourceAccessException.class) + .retryLimit(3) + // 4xx 에러(HttpClientErrorException)는 영구 실패로 간주하고 아이템당 100번까지 건너뜀 + .skip(HttpClientErrorException.class) + .skipLimit(100) + + // --- 병렬 처리 설정 --- + .taskExecutor(taskExecutor()) + .build(); + } + + /** 병렬 처리를 위한 TaskExecutor(쓰레드 풀)를 정의 */ + @Bean + public TaskExecutor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(10); // 동시에 실행할 기본 쓰레드 수 + executor.setMaxPoolSize(10); // 최대 쓰레드 수 + executor.setThreadNamePrefix("batch-thread-"); // 쓰레드 이름 접두사 + executor.initialize(); + return executor; + } + + /** + * ItemReader를 스레드로부터 안전하게 만들기 위해 SynchronizedItemStreamReader로 감쌈 병렬 처리 시 여러 스레드가 Reader에 동시에 + * 접근하는 것을 방지 + */ + @Bean + public SynchronizedItemStreamReader synchronizedProductReader() { + // 실제 데이터를 읽는 Reader는 private 메서드로 정의하여 외부 노출을 최소화합니다. + MyBatisPagingItemReader reader = productReader(); + + return new SynchronizedItemStreamReaderBuilder().delegate(reader).build(); + } + + /** + * 실제 DB에서 크롤링할 상품 목록을 읽어오는 ItemReader MyBatisPagingItemReader는 MyBatis를 사용하여 페이징 기반으로 대용량 데이터를 + * 안전하게 읽어옴 + */ + private MyBatisPagingItemReader productReader() { + return new MyBatisPagingItemReaderBuilder() + .sqlSessionFactory(sqlSessionFactory) + // CrawlingMapper.xml에 정의된 쿼리의 전체 경로(namespace + id)를 지정합니다. + .queryId("com.gltkorea.icebang.mapper.CrawlingMapper.findProductsToCrawl") + .pageSize(10) // 한 번에 DB에서 조회할 데이터 수. chunk 사이즈와 맞추는 것이 좋습니다. + .build(); + } + + /** + * ItemProcessor: Reader가 읽어온 각 아이템을 가공 여기서는 FastAPI 크롤링 서버를 호출하여 추가 정보를 얻어옴 예외를 직접 + * 처리(try-catch)하지 않고 Step으로 던져서, Step의 재시도/건너뛰기 정책이 동작하도록 함 + */ + @Bean + public ItemProcessor crawlingProcessor() { + return product -> { + log.info("Requesting crawl for product: {}", product.getName()); + + // FastAPI URL을 코드에 직접 작성 (추후 외부 설정으로 분리 권장) + String fastApiUrl = "http://your-fastapi-server.com/crawl?url=" + product.getUrlToCrawl(); + + // RestTemplate이 예외를 던지면 Step의 retry/skip 정책이 이를 감지하고 처리 + ResponseEntity response = + restTemplate.getForEntity(fastApiUrl, FastApiCrawlingResponse.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + FastApiCrawlingResponse crawledData = response.getBody(); + + // API 응답 결과로 ProductCrawlingData 객체 업데이트 + product.setPrice(crawledData.getPrice()); + product.setStockStatus(crawledData.getStockStatus()); + product.setLastCrawledAt(LocalDateTime.now()); + + return product; // 가공이 완료된 데이터를 Writer로 전달 + } + + // API 호출은 성공했으나, 응답이 비정상적인 경우 (예: body가 null) + log.warn( + "Crawling API call returned non-successful status for product {}: {}", + product.getName(), + response.getStatusCode()); + return null; // 이 아이템만 건너뜀 (Writer로 전달되지 않음) + }; + } + + /** + * ItemWriter: 가공된 데이터 묶음(Chunk)을 DB에 일괄 저장 MyBatisBatchItemWriter는 내부적으로 JDBC Batch Update를 사용하여 + * 성능이 좋음 + */ + @Bean + public MyBatisBatchItemWriter productWriter() { + return new MyBatisBatchItemWriterBuilder() + .sqlSessionFactory(sqlSessionFactory) + // CrawlingMapper.xml에 정의된 update 쿼리의 전체 경로를 지정 + .statementId("com.gltkorea.icebang.mapper.CrawlingMapper.updateCrawledProduct") + .build(); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/dto/FastApiCrawlingResponse.java b/apps/user-service/src/main/java/com/gltkorea/icebang/dto/FastApiCrawlingResponse.java index 78a0a526..db80a6bb 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/dto/FastApiCrawlingResponse.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/dto/FastApiCrawlingResponse.java @@ -4,8 +4,8 @@ @Data public class FastApiCrawlingResponse { - // FastAPI가 반환하는 JSON의 필드명과 일치해야 함 - private int price; - private String stockStatus; - private String productName; // 기타 필요한 정보 -} \ No newline at end of file + // FastAPI가 반환하는 JSON의 필드명과 일치해야 함 + private int price; + private String stockStatus; + private String productName; // 기타 필요한 정보 +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/dto/ProductCrawlingData.java b/apps/user-service/src/main/java/com/gltkorea/icebang/dto/ProductCrawlingData.java index 42d953b5..83041b55 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/dto/ProductCrawlingData.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/dto/ProductCrawlingData.java @@ -1,14 +1,15 @@ package com.gltkorea.icebang.dto; -import lombok.Data; import java.time.LocalDateTime; +import lombok.Data; + @Data // Lombok public class ProductCrawlingData { - private Long id; - private String name; - private String urlToCrawl; // DB 컬럼명은 url_to_crawl - private int price; - private String stockStatus; // DB 컬럼명은 stock_status - private LocalDateTime lastCrawledAt; // DB 컬럼명은 last_crawled_at -} \ No newline at end of file + private Long id; + private String name; + private String urlToCrawl; // DB 컬럼명은 url_to_crawl + private int price; + private String stockStatus; // DB 컬럼명은 stock_status + private LocalDateTime lastCrawledAt; // DB 컬럼명은 last_crawled_at +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/CrawlingMapper.java b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/CrawlingMapper.java index 3287c26b..52d472eb 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/CrawlingMapper.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/CrawlingMapper.java @@ -1,12 +1,15 @@ package com.gltkorea.icebang.mapper; -import com.gltkorea.icebang.dto.ProductCrawlingData; -import org.apache.ibatis.annotations.Mapper; import java.util.List; import java.util.Map; +import org.apache.ibatis.annotations.Mapper; + +import com.gltkorea.icebang.dto.ProductCrawlingData; + @Mapper public interface CrawlingMapper { - List findProductsToCrawl(Map parameters); - void updateCrawledProduct(ProductCrawlingData productData); -} \ No newline at end of file + List findProductsToCrawl(Map parameters); + + void updateCrawledProduct(ProductCrawlingData productData); +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/scheduler/CrawlingScheduler.java b/apps/user-service/src/main/java/com/gltkorea/icebang/scheduler/CrawlingScheduler.java index dff3fdd8..c62699db 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/scheduler/CrawlingScheduler.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/scheduler/CrawlingScheduler.java @@ -1,26 +1,29 @@ package com.gltkorea.icebang.scheduler; -import lombok.RequiredArgsConstructor; +import java.time.LocalDateTime; + import org.springframework.batch.core.Job; import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.core.launch.JobLauncher; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; @Component @RequiredArgsConstructor public class CrawlingScheduler { - private final JobLauncher jobLauncher; - private final Job crawlingJob; // CrawlingBatchConfig에 정의된 Job Bean 주입 + private final JobLauncher jobLauncher; + private final Job crawlingJob; // CrawlingBatchConfig에 정의된 Job Bean 주입 - // 매일 오전 8시에 실행 -> 추 후 application.yml로 설정 이동 예정 - @Scheduled(cron = "0 0 8 * * *") - public void runCrawlingJob() throws Exception { - jobLauncher.run(crawlingJob, new JobParametersBuilder() - .addString("runAt", LocalDateTime.now().toString()) - .toJobParameters()); - } -} \ No newline at end of file + // 매일 오전 8시에 실행 -> 추 후 application.yml로 설정 이동 예정 + @Scheduled(cron = "0 0 8 * * *") + public void runCrawlingJob() throws Exception { + jobLauncher.run( + crawlingJob, + new JobParametersBuilder() + .addString("runAt", LocalDateTime.now().toString()) + .toJobParameters()); + } +} From 887efbba9690065a9255c5195f858475bb183418 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Fri, 5 Sep 2025 16:31:24 +0900 Subject: [PATCH 07/14] =?UTF-8?q?refactor:=20DynamicSchedule=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../icebang/UserServiceApplication.java | 2 + .../batch/job/BlogContentJobConfig.java | 44 +++ .../tasklet/ContentGenerationTasklet.java | 48 +++ .../tasklet/KeywordExtractionTasklet.java | 46 +++ .../config/batch/CrawlingBatchConfig.java | 344 +++++++++--------- .../config/scheduler/SchedulerConfig.java | 30 ++ .../domain/schedule/model/Schedule.java | 14 + .../icebang/mapper/ScheduleMapper.java | 10 + .../schedule/runner/SchedulerInitializer.java | 28 ++ .../service/DynamicSchedulerService.java | 58 +++ .../icebang/scheduler/CrawlingScheduler.java | 29 -- .../mybatis/mapper/ScheduleMapper.xml | 17 + 12 files changed, 469 insertions(+), 201 deletions(-) create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/config/scheduler/SchedulerConfig.java create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/model/Schedule.java create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/mapper/ScheduleMapper.java create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/schedule/runner/SchedulerInitializer.java create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/schedule/service/DynamicSchedulerService.java delete mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/scheduler/CrawlingScheduler.java create mode 100644 apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/UserServiceApplication.java b/apps/user-service/src/main/java/com/gltkorea/icebang/UserServiceApplication.java index cdfbac39..002a6bc4 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/UserServiceApplication.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/UserServiceApplication.java @@ -1,11 +1,13 @@ package com.gltkorea.icebang; import org.mybatis.spring.annotation.MapperScan; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; @EnableScheduling +@EnableBatchProcessing @SpringBootApplication @MapperScan("com.gltkorea.icebang.mapper") public class UserServiceApplication { diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java new file mode 100644 index 00000000..7a9950c5 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java @@ -0,0 +1,44 @@ +package com.gltkorea.icebang.batch.job; + +import com.gltkorea.icebang.batch.tasklet.ContentGenerationTasklet; +import com.gltkorea.icebang.batch.tasklet.KeywordExtractionTasklet; +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.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class BlogContentJobConfig { + + // 변경점 1: Factory 대신 실제 Tasklet만 필드로 주입받습니다. + private final KeywordExtractionTasklet keywordExtractionTasklet; + private final ContentGenerationTasklet contentGenerationTasklet; + + @Bean + public Job blogContentJob(JobRepository jobRepository, Step keywordExtractionStep, Step contentGenerationStep) { + return new JobBuilder("blogContentJob", jobRepository) // 변경점 2: JobBuilder를 직접 생성합니다. + .start(keywordExtractionStep) + .next(contentGenerationStep) + .build(); + } + + @Bean + public Step keywordExtractionStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("keywordExtractionStep", jobRepository) // 변경점 3: StepBuilder를 직접 생성합니다. + .tasklet(keywordExtractionTasklet, transactionManager) // 변경점 4: tasklet에 transactionManager를 함께 전달합니다. + .build(); + } + + @Bean + public Step contentGenerationStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("contentGenerationStep", jobRepository) + .tasklet(contentGenerationTasklet, transactionManager) + .build(); + } +} \ No newline at end of file diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java new file mode 100644 index 00000000..2c706461 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java @@ -0,0 +1,48 @@ +package com.gltkorea.icebang.batch.tasklet; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ContentGenerationTasklet implements Tasklet { + + // private final ContentService contentService; // 비즈니스 로직을 담은 서비스 + // private final FastApiClient fastApiClient; // FastAPI 통신을 위한 클라이언트 + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + log.info(">>>> [Step 2] ContentGenerationTasklet executed."); + + // --- 핵심: JobExecutionContext에서 이전 Step의 결과물 가져오기 --- + ExecutionContext jobExecutionContext = chunkContext.getStepContext() + .getStepExecution() + .getJobExecution() + .getExecutionContext(); + + // KeywordExtractionTasklet이 저장한 "extractedKeywordIds" Key로 데이터 조회 + List keywordIds = (List) jobExecutionContext.get("extractedKeywordIds"); + + if (keywordIds == null || keywordIds.isEmpty()) { + log.warn(">>>> No keyword IDs found from previous step. Skipping content generation."); + return RepeatStatus.FINISHED; + } + + log.info(">>>> Received Keyword IDs for content generation: {}", keywordIds); + + // TODO: 1. 전달받은 키워드 ID 목록으로 DB에서 상세 정보 조회 + // TODO: 2. 각 키워드/상품 정보에 대해 외부 AI 서비스(FastAPI/LangChain)를 호출하여 콘텐츠 생성을 요청 + // TODO: 3. 생성된 콘텐츠를 DB에 저장 + + log.info(">>>> [Step 2] ContentGenerationTasklet finished."); + return RepeatStatus.FINISHED; + } +} \ No newline at end of file diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java new file mode 100644 index 00000000..b4f275e7 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java @@ -0,0 +1,46 @@ +package com.gltkorea.icebang.batch.tasklet; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KeywordExtractionTasklet implements Tasklet { + + // private final TrendKeywordService trendKeywordService; // 비즈니스 로직을 담은 서비스 + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + log.info(">>>> [Step 1] KeywordExtractionTasklet executed."); + + // TODO: 1. DB에서 카테고리 정보 조회 + // TODO: 2. 외부 API 또는 내부 로직을 통해 트렌드 키워드 추출 + // TODO: 3. 추출된 키워드를 DB에 저장 + + // --- 핵심: 다음 Step에 전달할 데이터 생성 --- + // 예시: 새로 생성된 키워드 ID 목록을 가져왔다고 가정 + List extractedKeywordIds = List.of(1L, 2L, 3L); // 실제로는 DB 저장 후 반환된 ID 목록 + log.info(">>>> Extracted Keyword IDs: {}", extractedKeywordIds); + + // --- 핵심: JobExecutionContext에 결과물 저장 --- + // JobExecution 전체에서 공유되는 컨텍스트를 가져옵니다. + ExecutionContext jobExecutionContext = chunkContext.getStepContext() + .getStepExecution() + .getJobExecution() + .getExecutionContext(); + + // "extractedKeywordIds" 라는 Key로 데이터 저장 + jobExecutionContext.put("extractedKeywordIds", extractedKeywordIds); + + log.info(">>>> [Step 1] KeywordExtractionTasklet finished."); + return RepeatStatus.FINISHED; + } +} \ No newline at end of file diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java index d237a38c..2f3b9568 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java @@ -1,172 +1,172 @@ -package com.gltkorea.icebang.config.batch; - -import java.time.LocalDateTime; - -import org.apache.ibatis.session.SqlSessionFactory; -import org.mybatis.spring.batch.MyBatisBatchItemWriter; -import org.mybatis.spring.batch.MyBatisPagingItemReader; -import org.mybatis.spring.batch.builder.MyBatisBatchItemWriterBuilder; -import org.mybatis.spring.batch.builder.MyBatisPagingItemReaderBuilder; -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.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.item.ItemProcessor; -import org.springframework.batch.item.support.SynchronizedItemStreamReader; -import org.springframework.batch.item.support.builder.SynchronizedItemStreamReaderBuilder; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.task.TaskExecutor; -import org.springframework.http.ResponseEntity; -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.client.ResourceAccessException; -import org.springframework.web.client.RestTemplate; - -import com.gltkorea.icebang.dto.FastApiCrawlingResponse; -import com.gltkorea.icebang.dto.ProductCrawlingData; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * 외부 크롤링 API를 호출하여 상품 정보를 업데이트하는 배치(Batch) 작업을 설정 Job은 병렬 처리와 내결함성(Fault Tolerance) 정책을 적용해 안정적인 운영 - * 환경을 고려 - */ -@Slf4j -@Configuration -@RequiredArgsConstructor -public class CrawlingBatchConfig { - - // Spring Batch의 메타데이터(Job 실행 기록, 상태 등)를 관리하는 리포지토리 - private final JobRepository jobRepository; - // 배치 Step 내에서 트랜잭션을 관리하기 위한 트랜잭션 매니저 - private final PlatformTransactionManager transactionManager; - // MyBatis 쿼리를 실행하기 위한 SqlSessionFactory - private final SqlSessionFactory sqlSessionFactory; - // 외부 API(FastAPI)와 통신하기 위한 RestTemplate - private final RestTemplate restTemplate; - - /** 'crawlingJob'이라는 이름의 Batch Job을 정의 Job은 배치 작업의 가장 큰 단위이며, 하나 이상의 Step으로 구성 */ - @Bean - public Job crawlingJob() { - return new JobBuilder("crawlingJob", jobRepository) - .start(crawlingStep()) // 'crawlingStep'이라는 Step으로 Job을 시작 - .build(); - } - - /** - * 'crawlingStep'이라는 이름의 Batch Step을 정의 Step은 Reader, Processor, Writer의 흐름으로 구성되며, 이 Step은 병렬 처리와 - * 실패 처리 정책이 적용 - */ - @Bean - public Step crawlingStep() { - return new StepBuilder("crawlingStep", jobRepository) - // <읽어올 데이터 타입, 처리 후 데이터 타입>을 지정합니다. - // chunk(10): 10개의 아이템을 하나의 트랜잭션 단위로 묶어서 처리합니다. - .chunk(10, transactionManager) - .reader(synchronizedProductReader()) // 데이터 읽기 - .processor(crawlingProcessor()) // 데이터 가공 - .writer(productWriter()) // 데이터 쓰기 - - // --- 실패 처리 정책 (Fault Tolerance) --- - .faultTolerant() - // 네트워크 오류(ResourceAccessException)는 아이템당 3번까지 재시도합니다. - .retry(ResourceAccessException.class) - .retryLimit(3) - // 4xx 에러(HttpClientErrorException)는 영구 실패로 간주하고 아이템당 100번까지 건너뜀 - .skip(HttpClientErrorException.class) - .skipLimit(100) - - // --- 병렬 처리 설정 --- - .taskExecutor(taskExecutor()) - .build(); - } - - /** 병렬 처리를 위한 TaskExecutor(쓰레드 풀)를 정의 */ - @Bean - public TaskExecutor taskExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(10); // 동시에 실행할 기본 쓰레드 수 - executor.setMaxPoolSize(10); // 최대 쓰레드 수 - executor.setThreadNamePrefix("batch-thread-"); // 쓰레드 이름 접두사 - executor.initialize(); - return executor; - } - - /** - * ItemReader를 스레드로부터 안전하게 만들기 위해 SynchronizedItemStreamReader로 감쌈 병렬 처리 시 여러 스레드가 Reader에 동시에 - * 접근하는 것을 방지 - */ - @Bean - public SynchronizedItemStreamReader synchronizedProductReader() { - // 실제 데이터를 읽는 Reader는 private 메서드로 정의하여 외부 노출을 최소화합니다. - MyBatisPagingItemReader reader = productReader(); - - return new SynchronizedItemStreamReaderBuilder().delegate(reader).build(); - } - - /** - * 실제 DB에서 크롤링할 상품 목록을 읽어오는 ItemReader MyBatisPagingItemReader는 MyBatis를 사용하여 페이징 기반으로 대용량 데이터를 - * 안전하게 읽어옴 - */ - private MyBatisPagingItemReader productReader() { - return new MyBatisPagingItemReaderBuilder() - .sqlSessionFactory(sqlSessionFactory) - // CrawlingMapper.xml에 정의된 쿼리의 전체 경로(namespace + id)를 지정합니다. - .queryId("com.gltkorea.icebang.mapper.CrawlingMapper.findProductsToCrawl") - .pageSize(10) // 한 번에 DB에서 조회할 데이터 수. chunk 사이즈와 맞추는 것이 좋습니다. - .build(); - } - - /** - * ItemProcessor: Reader가 읽어온 각 아이템을 가공 여기서는 FastAPI 크롤링 서버를 호출하여 추가 정보를 얻어옴 예외를 직접 - * 처리(try-catch)하지 않고 Step으로 던져서, Step의 재시도/건너뛰기 정책이 동작하도록 함 - */ - @Bean - public ItemProcessor crawlingProcessor() { - return product -> { - log.info("Requesting crawl for product: {}", product.getName()); - - // FastAPI URL을 코드에 직접 작성 (추후 외부 설정으로 분리 권장) - String fastApiUrl = "http://your-fastapi-server.com/crawl?url=" + product.getUrlToCrawl(); - - // RestTemplate이 예외를 던지면 Step의 retry/skip 정책이 이를 감지하고 처리 - ResponseEntity response = - restTemplate.getForEntity(fastApiUrl, FastApiCrawlingResponse.class); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - FastApiCrawlingResponse crawledData = response.getBody(); - - // API 응답 결과로 ProductCrawlingData 객체 업데이트 - product.setPrice(crawledData.getPrice()); - product.setStockStatus(crawledData.getStockStatus()); - product.setLastCrawledAt(LocalDateTime.now()); - - return product; // 가공이 완료된 데이터를 Writer로 전달 - } - - // API 호출은 성공했으나, 응답이 비정상적인 경우 (예: body가 null) - log.warn( - "Crawling API call returned non-successful status for product {}: {}", - product.getName(), - response.getStatusCode()); - return null; // 이 아이템만 건너뜀 (Writer로 전달되지 않음) - }; - } - - /** - * ItemWriter: 가공된 데이터 묶음(Chunk)을 DB에 일괄 저장 MyBatisBatchItemWriter는 내부적으로 JDBC Batch Update를 사용하여 - * 성능이 좋음 - */ - @Bean - public MyBatisBatchItemWriter productWriter() { - return new MyBatisBatchItemWriterBuilder() - .sqlSessionFactory(sqlSessionFactory) - // CrawlingMapper.xml에 정의된 update 쿼리의 전체 경로를 지정 - .statementId("com.gltkorea.icebang.mapper.CrawlingMapper.updateCrawledProduct") - .build(); - } -} +//package com.gltkorea.icebang.config.batch; +// +//import java.time.LocalDateTime; +// +//import org.apache.ibatis.session.SqlSessionFactory; +//import org.mybatis.spring.batch.MyBatisBatchItemWriter; +//import org.mybatis.spring.batch.MyBatisPagingItemReader; +//import org.mybatis.spring.batch.builder.MyBatisBatchItemWriterBuilder; +//import org.mybatis.spring.batch.builder.MyBatisPagingItemReaderBuilder; +//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.repository.JobRepository; +//import org.springframework.batch.core.step.builder.StepBuilder; +//import org.springframework.batch.item.ItemProcessor; +//import org.springframework.batch.item.support.SynchronizedItemStreamReader; +//import org.springframework.batch.item.support.builder.SynchronizedItemStreamReaderBuilder; +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +//import org.springframework.core.task.TaskExecutor; +//import org.springframework.http.ResponseEntity; +//import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +//import org.springframework.transaction.PlatformTransactionManager; +//import org.springframework.web.client.HttpClientErrorException; +//import org.springframework.web.client.ResourceAccessException; +//import org.springframework.web.client.RestTemplate; +// +//import com.gltkorea.icebang.dto.FastApiCrawlingResponse; +//import com.gltkorea.icebang.dto.ProductCrawlingData; +// +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +// +///** +// * 외부 크롤링 API를 호출하여 상품 정보를 업데이트하는 배치(Batch) 작업을 설정 Job은 병렬 처리와 내결함성(Fault Tolerance) 정책을 적용해 안정적인 운영 +// * 환경을 고려 +// */ +//@Slf4j +//@Configuration +//@RequiredArgsConstructor +//public class CrawlingBatchConfig { +// +// // Spring Batch의 메타데이터(Job 실행 기록, 상태 등)를 관리하는 리포지토리 +// private final JobRepository jobRepository; +// // 배치 Step 내에서 트랜잭션을 관리하기 위한 트랜잭션 매니저 +// private final PlatformTransactionManager transactionManager; +// // MyBatis 쿼리를 실행하기 위한 SqlSessionFactory +// private final SqlSessionFactory sqlSessionFactory; +// // 외부 API(FastAPI)와 통신하기 위한 RestTemplate +// private final RestTemplate restTemplate; +// +// /** 'crawlingJob'이라는 이름의 Batch Job을 정의 Job은 배치 작업의 가장 큰 단위이며, 하나 이상의 Step으로 구성 */ +// @Bean +// public Job crawlingJob() { +// return new JobBuilder("crawlingJob", jobRepository) +// .start(crawlingStep()) // 'crawlingStep'이라는 Step으로 Job을 시작 +// .build(); +// } +// +// /** +// * 'crawlingStep'이라는 이름의 Batch Step을 정의 Step은 Reader, Processor, Writer의 흐름으로 구성되며, 이 Step은 병렬 처리와 +// * 실패 처리 정책이 적용 +// */ +// @Bean +// public Step crawlingStep() { +// return new StepBuilder("crawlingStep", jobRepository) +// // <읽어올 데이터 타입, 처리 후 데이터 타입>을 지정합니다. +// // chunk(10): 10개의 아이템을 하나의 트랜잭션 단위로 묶어서 처리합니다. +// .chunk(10, transactionManager) +// .reader(synchronizedProductReader()) // 데이터 읽기 +// .processor(crawlingProcessor()) // 데이터 가공 +// .writer(productWriter()) // 데이터 쓰기 +// +// // --- 실패 처리 정책 (Fault Tolerance) --- +// .faultTolerant() +// // 네트워크 오류(ResourceAccessException)는 아이템당 3번까지 재시도합니다. +// .retry(ResourceAccessException.class) +// .retryLimit(3) +// // 4xx 에러(HttpClientErrorException)는 영구 실패로 간주하고 아이템당 100번까지 건너뜀 +// .skip(HttpClientErrorException.class) +// .skipLimit(100) +// +// // --- 병렬 처리 설정 --- +// .taskExecutor(taskExecutor()) +// .build(); +// } +// +// /** 병렬 처리를 위한 TaskExecutor(쓰레드 풀)를 정의 */ +// @Bean +// public TaskExecutor taskExecutor() { +// ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); +// executor.setCorePoolSize(10); // 동시에 실행할 기본 쓰레드 수 +// executor.setMaxPoolSize(10); // 최대 쓰레드 수 +// executor.setThreadNamePrefix("batch-thread-"); // 쓰레드 이름 접두사 +// executor.initialize(); +// return executor; +// } +// +// /** +// * ItemReader를 스레드로부터 안전하게 만들기 위해 SynchronizedItemStreamReader로 감쌈 병렬 처리 시 여러 스레드가 Reader에 동시에 +// * 접근하는 것을 방지 +// */ +// @Bean +// public SynchronizedItemStreamReader synchronizedProductReader() { +// // 실제 데이터를 읽는 Reader는 private 메서드로 정의하여 외부 노출을 최소화합니다. +// MyBatisPagingItemReader reader = productReader(); +// +// return new SynchronizedItemStreamReaderBuilder().delegate(reader).build(); +// } +// +// /** +// * 실제 DB에서 크롤링할 상품 목록을 읽어오는 ItemReader MyBatisPagingItemReader는 MyBatis를 사용하여 페이징 기반으로 대용량 데이터를 +// * 안전하게 읽어옴 +// */ +// private MyBatisPagingItemReader productReader() { +// return new MyBatisPagingItemReaderBuilder() +// .sqlSessionFactory(sqlSessionFactory) +// // CrawlingMapper.xml에 정의된 쿼리의 전체 경로(namespace + id)를 지정합니다. +// .queryId("com.gltkorea.icebang.mapper.CrawlingMapper.findProductsToCrawl") +// .pageSize(10) // 한 번에 DB에서 조회할 데이터 수. chunk 사이즈와 맞추는 것이 좋습니다. +// .build(); +// } +// +// /** +// * ItemProcessor: Reader가 읽어온 각 아이템을 가공 여기서는 FastAPI 크롤링 서버를 호출하여 추가 정보를 얻어옴 예외를 직접 +// * 처리(try-catch)하지 않고 Step으로 던져서, Step의 재시도/건너뛰기 정책이 동작하도록 함 +// */ +// @Bean +// public ItemProcessor crawlingProcessor() { +// return product -> { +// log.info("Requesting crawl for product: {}", product.getName()); +// +// // FastAPI URL을 코드에 직접 작성 (추후 외부 설정으로 분리 권장) +// String fastApiUrl = "http://your-fastapi-server.com/crawl?url=" + product.getUrlToCrawl(); +// +// // RestTemplate이 예외를 던지면 Step의 retry/skip 정책이 이를 감지하고 처리 +// ResponseEntity response = +// restTemplate.getForEntity(fastApiUrl, FastApiCrawlingResponse.class); +// +// if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { +// FastApiCrawlingResponse crawledData = response.getBody(); +// +// // API 응답 결과로 ProductCrawlingData 객체 업데이트 +// product.setPrice(crawledData.getPrice()); +// product.setStockStatus(crawledData.getStockStatus()); +// product.setLastCrawledAt(LocalDateTime.now()); +// +// return product; // 가공이 완료된 데이터를 Writer로 전달 +// } +// +// // API 호출은 성공했으나, 응답이 비정상적인 경우 (예: body가 null) +// log.warn( +// "Crawling API call returned non-successful status for product {}: {}", +// product.getName(), +// response.getStatusCode()); +// return null; // 이 아이템만 건너뜀 (Writer로 전달되지 않음) +// }; +// } +// +// /** +// * ItemWriter: 가공된 데이터 묶음(Chunk)을 DB에 일괄 저장 MyBatisBatchItemWriter는 내부적으로 JDBC Batch Update를 사용하여 +// * 성능이 좋음 +// */ +// @Bean +// public MyBatisBatchItemWriter productWriter() { +// return new MyBatisBatchItemWriterBuilder() +// .sqlSessionFactory(sqlSessionFactory) +// // CrawlingMapper.xml에 정의된 update 쿼리의 전체 경로를 지정 +// .statementId("com.gltkorea.icebang.mapper.CrawlingMapper.updateCrawledProduct") +// .build(); +// } +//} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/scheduler/SchedulerConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/scheduler/SchedulerConfig.java new file mode 100644 index 00000000..06ca63d4 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/config/scheduler/SchedulerConfig.java @@ -0,0 +1,30 @@ +package com.gltkorea.icebang.config.scheduler; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +/** + * 동적 스케줄링을 위한 TaskScheduler Bean을 설정하는 클래스 + */ +@Configuration +public class SchedulerConfig { + + @Bean + public TaskScheduler taskScheduler() { + // ThreadPool 기반의 TaskScheduler를 생성합니다. + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + + // 스케줄러가 사용할 스레드 풀의 크기를 설정합니다. + // 동시에 실행될 수 있는 스케줄 작업의 최대 개수입니다. + scheduler.setPoolSize(10); + + // 스레드 이름의 접두사를 설정하여 로그 추적을 용이하게 합니다. + scheduler.setThreadNamePrefix("dynamic-scheduler-"); + + // 스케줄러를 초기화합니다. + scheduler.initialize(); + return scheduler; + } +} \ No newline at end of file diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/model/Schedule.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/model/Schedule.java new file mode 100644 index 00000000..3ad4c7e0 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/model/Schedule.java @@ -0,0 +1,14 @@ +package com.gltkorea.icebang.domain.schedule.model; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Schedule { + private Long scheduleId; + private Long workflowId; + private String cronExpression; + private boolean isActive; + // ... 기타 필요한 컬럼 +} \ No newline at end of file diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/ScheduleMapper.java b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/ScheduleMapper.java new file mode 100644 index 00000000..0d70e1da --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/ScheduleMapper.java @@ -0,0 +1,10 @@ +package com.gltkorea.icebang.mapper; + +import com.gltkorea.icebang.domain.schedule.model.Schedule; // import 경로 변경 +import java.util.List; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ScheduleMapper { + List findAllByIsActive(boolean isActive); +} \ No newline at end of file diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/runner/SchedulerInitializer.java b/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/runner/SchedulerInitializer.java new file mode 100644 index 00000000..ed7117f5 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/runner/SchedulerInitializer.java @@ -0,0 +1,28 @@ +package com.gltkorea.icebang.schedule.runner; + +import com.gltkorea.icebang.domain.schedule.model.Schedule; +import com.gltkorea.icebang.mapper.ScheduleMapper; +import com.gltkorea.icebang.schedule.service.DynamicSchedulerService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SchedulerInitializer implements ApplicationRunner { + + private final ScheduleMapper scheduleMapper; + private final DynamicSchedulerService dynamicSchedulerService; + + @Override + public void run(ApplicationArguments args) { + log.info(">>>> Initializing schedules from database..."); + List activeSchedules = scheduleMapper.findAllByIsActive(true); + activeSchedules.forEach(dynamicSchedulerService::register); + log.info(">>>> {} active schedules have been registered.", activeSchedules.size()); + } +} \ No newline at end of file diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/service/DynamicSchedulerService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/service/DynamicSchedulerService.java new file mode 100644 index 00000000..6ac0d1f5 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/service/DynamicSchedulerService.java @@ -0,0 +1,58 @@ +package com.gltkorea.icebang.schedule.service; + +import com.gltkorea.icebang.domain.schedule.model.Schedule; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; +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.context.ApplicationContext; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.support.CronTrigger; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DynamicSchedulerService { + + private final TaskScheduler taskScheduler; + private final JobLauncher jobLauncher; + private final ApplicationContext applicationContext; + private final Map> scheduledTasks = new ConcurrentHashMap<>(); + + public void register(Schedule schedule) { + // TODO: schedule.getWorkflowId()를 기반으로 실행할 Job의 이름을 DB에서 조회 + String jobName = "blogContentJob"; // 예시 + Job jobToRun = applicationContext.getBean(jobName, Job.class); + + Runnable runnable = () -> { + try { + JobParametersBuilder paramsBuilder = new JobParametersBuilder(); + paramsBuilder.addString("runAt", LocalDateTime.now().toString()); + paramsBuilder.addLong("scheduleId", schedule.getScheduleId()); + jobLauncher.run(jobToRun, paramsBuilder.toJobParameters()); + } catch (Exception e) { + log.error("Failed to run scheduled job for scheduleId: {}", schedule.getScheduleId(), e); + } + }; + + CronTrigger trigger = new CronTrigger(schedule.getCronExpression()); + ScheduledFuture future = taskScheduler.schedule(runnable, trigger); + scheduledTasks.put(schedule.getScheduleId(), future); + log.info(">>>> Schedule registered: id={}, cron={}", schedule.getScheduleId(), schedule.getCronExpression()); + } + + public void remove(Long scheduleId) { + ScheduledFuture future = scheduledTasks.get(scheduleId); + if (future != null) { + future.cancel(true); + scheduledTasks.remove(scheduleId); + log.info(">>>> Schedule removed: id={}", scheduleId); + } + } +} \ No newline at end of file diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/scheduler/CrawlingScheduler.java b/apps/user-service/src/main/java/com/gltkorea/icebang/scheduler/CrawlingScheduler.java deleted file mode 100644 index c62699db..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/scheduler/CrawlingScheduler.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.gltkorea.icebang.scheduler; - -import java.time.LocalDateTime; - -import org.springframework.batch.core.Job; -import org.springframework.batch.core.JobParametersBuilder; -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class CrawlingScheduler { - - private final JobLauncher jobLauncher; - private final Job crawlingJob; // CrawlingBatchConfig에 정의된 Job Bean 주입 - - // 매일 오전 8시에 실행 -> 추 후 application.yml로 설정 이동 예정 - @Scheduled(cron = "0 0 8 * * *") - public void runCrawlingJob() throws Exception { - jobLauncher.run( - crawlingJob, - new JobParametersBuilder() - .addString("runAt", LocalDateTime.now().toString()) - .toJobParameters()); - } -} diff --git a/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml new file mode 100644 index 00000000..f85de8b5 --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml @@ -0,0 +1,17 @@ + + + + + + + + \ No newline at end of file From ecf6b04063ff630bf2fb424fdfc724dd0d0d2aa5 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Fri, 5 Sep 2025 16:43:30 +0900 Subject: [PATCH 08/14] =?UTF-8?q?refactor:=20DynamicSchedule=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=ED=9B=84=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/batch/CrawlingBatchConfig.java | 172 ------------------ .../icebang/dto/FastApiCrawlingResponse.java | 11 -- .../icebang/mapper/CrawlingMapper.java | 15 -- .../mybatis/mapper/CrawlingMapper.xml | 24 --- 4 files changed, 222 deletions(-) delete mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java delete mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/dto/FastApiCrawlingResponse.java delete mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/mapper/CrawlingMapper.java delete mode 100644 apps/user-service/src/main/resources/mybatis/mapper/CrawlingMapper.xml diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java deleted file mode 100644 index 2f3b9568..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/config/batch/CrawlingBatchConfig.java +++ /dev/null @@ -1,172 +0,0 @@ -//package com.gltkorea.icebang.config.batch; -// -//import java.time.LocalDateTime; -// -//import org.apache.ibatis.session.SqlSessionFactory; -//import org.mybatis.spring.batch.MyBatisBatchItemWriter; -//import org.mybatis.spring.batch.MyBatisPagingItemReader; -//import org.mybatis.spring.batch.builder.MyBatisBatchItemWriterBuilder; -//import org.mybatis.spring.batch.builder.MyBatisPagingItemReaderBuilder; -//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.repository.JobRepository; -//import org.springframework.batch.core.step.builder.StepBuilder; -//import org.springframework.batch.item.ItemProcessor; -//import org.springframework.batch.item.support.SynchronizedItemStreamReader; -//import org.springframework.batch.item.support.builder.SynchronizedItemStreamReaderBuilder; -//import org.springframework.context.annotation.Bean; -//import org.springframework.context.annotation.Configuration; -//import org.springframework.core.task.TaskExecutor; -//import org.springframework.http.ResponseEntity; -//import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; -//import org.springframework.transaction.PlatformTransactionManager; -//import org.springframework.web.client.HttpClientErrorException; -//import org.springframework.web.client.ResourceAccessException; -//import org.springframework.web.client.RestTemplate; -// -//import com.gltkorea.icebang.dto.FastApiCrawlingResponse; -//import com.gltkorea.icebang.dto.ProductCrawlingData; -// -//import lombok.RequiredArgsConstructor; -//import lombok.extern.slf4j.Slf4j; -// -///** -// * 외부 크롤링 API를 호출하여 상품 정보를 업데이트하는 배치(Batch) 작업을 설정 Job은 병렬 처리와 내결함성(Fault Tolerance) 정책을 적용해 안정적인 운영 -// * 환경을 고려 -// */ -//@Slf4j -//@Configuration -//@RequiredArgsConstructor -//public class CrawlingBatchConfig { -// -// // Spring Batch의 메타데이터(Job 실행 기록, 상태 등)를 관리하는 리포지토리 -// private final JobRepository jobRepository; -// // 배치 Step 내에서 트랜잭션을 관리하기 위한 트랜잭션 매니저 -// private final PlatformTransactionManager transactionManager; -// // MyBatis 쿼리를 실행하기 위한 SqlSessionFactory -// private final SqlSessionFactory sqlSessionFactory; -// // 외부 API(FastAPI)와 통신하기 위한 RestTemplate -// private final RestTemplate restTemplate; -// -// /** 'crawlingJob'이라는 이름의 Batch Job을 정의 Job은 배치 작업의 가장 큰 단위이며, 하나 이상의 Step으로 구성 */ -// @Bean -// public Job crawlingJob() { -// return new JobBuilder("crawlingJob", jobRepository) -// .start(crawlingStep()) // 'crawlingStep'이라는 Step으로 Job을 시작 -// .build(); -// } -// -// /** -// * 'crawlingStep'이라는 이름의 Batch Step을 정의 Step은 Reader, Processor, Writer의 흐름으로 구성되며, 이 Step은 병렬 처리와 -// * 실패 처리 정책이 적용 -// */ -// @Bean -// public Step crawlingStep() { -// return new StepBuilder("crawlingStep", jobRepository) -// // <읽어올 데이터 타입, 처리 후 데이터 타입>을 지정합니다. -// // chunk(10): 10개의 아이템을 하나의 트랜잭션 단위로 묶어서 처리합니다. -// .chunk(10, transactionManager) -// .reader(synchronizedProductReader()) // 데이터 읽기 -// .processor(crawlingProcessor()) // 데이터 가공 -// .writer(productWriter()) // 데이터 쓰기 -// -// // --- 실패 처리 정책 (Fault Tolerance) --- -// .faultTolerant() -// // 네트워크 오류(ResourceAccessException)는 아이템당 3번까지 재시도합니다. -// .retry(ResourceAccessException.class) -// .retryLimit(3) -// // 4xx 에러(HttpClientErrorException)는 영구 실패로 간주하고 아이템당 100번까지 건너뜀 -// .skip(HttpClientErrorException.class) -// .skipLimit(100) -// -// // --- 병렬 처리 설정 --- -// .taskExecutor(taskExecutor()) -// .build(); -// } -// -// /** 병렬 처리를 위한 TaskExecutor(쓰레드 풀)를 정의 */ -// @Bean -// public TaskExecutor taskExecutor() { -// ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); -// executor.setCorePoolSize(10); // 동시에 실행할 기본 쓰레드 수 -// executor.setMaxPoolSize(10); // 최대 쓰레드 수 -// executor.setThreadNamePrefix("batch-thread-"); // 쓰레드 이름 접두사 -// executor.initialize(); -// return executor; -// } -// -// /** -// * ItemReader를 스레드로부터 안전하게 만들기 위해 SynchronizedItemStreamReader로 감쌈 병렬 처리 시 여러 스레드가 Reader에 동시에 -// * 접근하는 것을 방지 -// */ -// @Bean -// public SynchronizedItemStreamReader synchronizedProductReader() { -// // 실제 데이터를 읽는 Reader는 private 메서드로 정의하여 외부 노출을 최소화합니다. -// MyBatisPagingItemReader reader = productReader(); -// -// return new SynchronizedItemStreamReaderBuilder().delegate(reader).build(); -// } -// -// /** -// * 실제 DB에서 크롤링할 상품 목록을 읽어오는 ItemReader MyBatisPagingItemReader는 MyBatis를 사용하여 페이징 기반으로 대용량 데이터를 -// * 안전하게 읽어옴 -// */ -// private MyBatisPagingItemReader productReader() { -// return new MyBatisPagingItemReaderBuilder() -// .sqlSessionFactory(sqlSessionFactory) -// // CrawlingMapper.xml에 정의된 쿼리의 전체 경로(namespace + id)를 지정합니다. -// .queryId("com.gltkorea.icebang.mapper.CrawlingMapper.findProductsToCrawl") -// .pageSize(10) // 한 번에 DB에서 조회할 데이터 수. chunk 사이즈와 맞추는 것이 좋습니다. -// .build(); -// } -// -// /** -// * ItemProcessor: Reader가 읽어온 각 아이템을 가공 여기서는 FastAPI 크롤링 서버를 호출하여 추가 정보를 얻어옴 예외를 직접 -// * 처리(try-catch)하지 않고 Step으로 던져서, Step의 재시도/건너뛰기 정책이 동작하도록 함 -// */ -// @Bean -// public ItemProcessor crawlingProcessor() { -// return product -> { -// log.info("Requesting crawl for product: {}", product.getName()); -// -// // FastAPI URL을 코드에 직접 작성 (추후 외부 설정으로 분리 권장) -// String fastApiUrl = "http://your-fastapi-server.com/crawl?url=" + product.getUrlToCrawl(); -// -// // RestTemplate이 예외를 던지면 Step의 retry/skip 정책이 이를 감지하고 처리 -// ResponseEntity response = -// restTemplate.getForEntity(fastApiUrl, FastApiCrawlingResponse.class); -// -// if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { -// FastApiCrawlingResponse crawledData = response.getBody(); -// -// // API 응답 결과로 ProductCrawlingData 객체 업데이트 -// product.setPrice(crawledData.getPrice()); -// product.setStockStatus(crawledData.getStockStatus()); -// product.setLastCrawledAt(LocalDateTime.now()); -// -// return product; // 가공이 완료된 데이터를 Writer로 전달 -// } -// -// // API 호출은 성공했으나, 응답이 비정상적인 경우 (예: body가 null) -// log.warn( -// "Crawling API call returned non-successful status for product {}: {}", -// product.getName(), -// response.getStatusCode()); -// return null; // 이 아이템만 건너뜀 (Writer로 전달되지 않음) -// }; -// } -// -// /** -// * ItemWriter: 가공된 데이터 묶음(Chunk)을 DB에 일괄 저장 MyBatisBatchItemWriter는 내부적으로 JDBC Batch Update를 사용하여 -// * 성능이 좋음 -// */ -// @Bean -// public MyBatisBatchItemWriter productWriter() { -// return new MyBatisBatchItemWriterBuilder() -// .sqlSessionFactory(sqlSessionFactory) -// // CrawlingMapper.xml에 정의된 update 쿼리의 전체 경로를 지정 -// .statementId("com.gltkorea.icebang.mapper.CrawlingMapper.updateCrawledProduct") -// .build(); -// } -//} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/dto/FastApiCrawlingResponse.java b/apps/user-service/src/main/java/com/gltkorea/icebang/dto/FastApiCrawlingResponse.java deleted file mode 100644 index db80a6bb..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/dto/FastApiCrawlingResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.gltkorea.icebang.dto; - -import lombok.Data; - -@Data -public class FastApiCrawlingResponse { - // FastAPI가 반환하는 JSON의 필드명과 일치해야 함 - private int price; - private String stockStatus; - private String productName; // 기타 필요한 정보 -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/CrawlingMapper.java b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/CrawlingMapper.java deleted file mode 100644 index 52d472eb..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/CrawlingMapper.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.gltkorea.icebang.mapper; - -import java.util.List; -import java.util.Map; - -import org.apache.ibatis.annotations.Mapper; - -import com.gltkorea.icebang.dto.ProductCrawlingData; - -@Mapper -public interface CrawlingMapper { - List findProductsToCrawl(Map parameters); - - void updateCrawledProduct(ProductCrawlingData productData); -} diff --git a/apps/user-service/src/main/resources/mybatis/mapper/CrawlingMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/CrawlingMapper.xml deleted file mode 100644 index 08e5767b..00000000 --- a/apps/user-service/src/main/resources/mybatis/mapper/CrawlingMapper.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - UPDATE product - SET - price = #{price}, - stock_status = #{stockStatus}, - last_crawled_at = #{lastCrawledAt} - WHERE - id = #{id} - - \ No newline at end of file From 95968cc6112b4f508c7bdf7855ee6c20be7ca20d Mon Sep 17 00:00:00 2001 From: jihukimme Date: Fri, 5 Sep 2025 16:45:46 +0900 Subject: [PATCH 09/14] refactor: Code Formmating --- .../batch/job/BlogContentJobConfig.java | 65 +++++++++------- .../tasklet/ContentGenerationTasklet.java | 53 ++++++------- .../tasklet/KeywordExtractionTasklet.java | 51 ++++++------ .../config/scheduler/SchedulerConfig.java | 32 ++++---- .../domain/schedule/model/Schedule.java | 12 +-- .../icebang/mapper/ScheduleMapper.java | 8 +- .../schedule/runner/SchedulerInitializer.java | 31 ++++---- .../service/DynamicSchedulerService.java | 78 ++++++++++--------- 8 files changed, 175 insertions(+), 155 deletions(-) diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java index 7a9950c5..61626411 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java @@ -1,8 +1,5 @@ package com.gltkorea.icebang.batch.job; -import com.gltkorea.icebang.batch.tasklet.ContentGenerationTasklet; -import com.gltkorea.icebang.batch.tasklet.KeywordExtractionTasklet; -import lombok.RequiredArgsConstructor; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; import org.springframework.batch.core.job.builder.JobBuilder; @@ -12,33 +9,43 @@ import org.springframework.context.annotation.Configuration; import org.springframework.transaction.PlatformTransactionManager; +import com.gltkorea.icebang.batch.tasklet.ContentGenerationTasklet; +import com.gltkorea.icebang.batch.tasklet.KeywordExtractionTasklet; + +import lombok.RequiredArgsConstructor; + @Configuration @RequiredArgsConstructor public class BlogContentJobConfig { - // 변경점 1: Factory 대신 실제 Tasklet만 필드로 주입받습니다. - private final KeywordExtractionTasklet keywordExtractionTasklet; - private final ContentGenerationTasklet contentGenerationTasklet; - - @Bean - public Job blogContentJob(JobRepository jobRepository, Step keywordExtractionStep, Step contentGenerationStep) { - return new JobBuilder("blogContentJob", jobRepository) // 변경점 2: JobBuilder를 직접 생성합니다. - .start(keywordExtractionStep) - .next(contentGenerationStep) - .build(); - } - - @Bean - public Step keywordExtractionStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { - return new StepBuilder("keywordExtractionStep", jobRepository) // 변경점 3: StepBuilder를 직접 생성합니다. - .tasklet(keywordExtractionTasklet, transactionManager) // 변경점 4: tasklet에 transactionManager를 함께 전달합니다. - .build(); - } - - @Bean - public Step contentGenerationStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { - return new StepBuilder("contentGenerationStep", jobRepository) - .tasklet(contentGenerationTasklet, transactionManager) - .build(); - } -} \ No newline at end of file + // 변경점 1: Factory 대신 실제 Tasklet만 필드로 주입받습니다. + private final KeywordExtractionTasklet keywordExtractionTasklet; + private final ContentGenerationTasklet contentGenerationTasklet; + + @Bean + public Job blogContentJob( + JobRepository jobRepository, Step keywordExtractionStep, Step contentGenerationStep) { + return new JobBuilder("blogContentJob", jobRepository) // 변경점 2: JobBuilder를 직접 생성합니다. + .start(keywordExtractionStep) + .next(contentGenerationStep) + .build(); + } + + @Bean + public Step keywordExtractionStep( + JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("keywordExtractionStep", jobRepository) // 변경점 3: StepBuilder를 직접 생성합니다. + .tasklet( + keywordExtractionTasklet, + transactionManager) // 변경점 4: tasklet에 transactionManager를 함께 전달합니다. + .build(); + } + + @Bean + public Step contentGenerationStep( + JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("contentGenerationStep", jobRepository) + .tasklet(contentGenerationTasklet, transactionManager) + .build(); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java index 2c706461..5cc8918a 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java @@ -1,8 +1,7 @@ package com.gltkorea.icebang.batch.tasklet; import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; + import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.scope.context.ChunkContext; import org.springframework.batch.core.step.tasklet.Tasklet; @@ -10,39 +9,41 @@ import org.springframework.batch.repeat.RepeatStatus; import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + @Slf4j @Component @RequiredArgsConstructor public class ContentGenerationTasklet implements Tasklet { - // private final ContentService contentService; // 비즈니스 로직을 담은 서비스 - // private final FastApiClient fastApiClient; // FastAPI 통신을 위한 클라이언트 + // private final ContentService contentService; // 비즈니스 로직을 담은 서비스 + // private final FastApiClient fastApiClient; // FastAPI 통신을 위한 클라이언트 - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { - log.info(">>>> [Step 2] ContentGenerationTasklet executed."); + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) + throws Exception { + log.info(">>>> [Step 2] ContentGenerationTasklet executed."); - // --- 핵심: JobExecutionContext에서 이전 Step의 결과물 가져오기 --- - ExecutionContext jobExecutionContext = chunkContext.getStepContext() - .getStepExecution() - .getJobExecution() - .getExecutionContext(); + // --- 핵심: JobExecutionContext에서 이전 Step의 결과물 가져오기 --- + ExecutionContext jobExecutionContext = + chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); - // KeywordExtractionTasklet이 저장한 "extractedKeywordIds" Key로 데이터 조회 - List keywordIds = (List) jobExecutionContext.get("extractedKeywordIds"); + // KeywordExtractionTasklet이 저장한 "extractedKeywordIds" Key로 데이터 조회 + List keywordIds = (List) jobExecutionContext.get("extractedKeywordIds"); - if (keywordIds == null || keywordIds.isEmpty()) { - log.warn(">>>> No keyword IDs found from previous step. Skipping content generation."); - return RepeatStatus.FINISHED; - } + if (keywordIds == null || keywordIds.isEmpty()) { + log.warn(">>>> No keyword IDs found from previous step. Skipping content generation."); + return RepeatStatus.FINISHED; + } - log.info(">>>> Received Keyword IDs for content generation: {}", keywordIds); + log.info(">>>> Received Keyword IDs for content generation: {}", keywordIds); - // TODO: 1. 전달받은 키워드 ID 목록으로 DB에서 상세 정보 조회 - // TODO: 2. 각 키워드/상품 정보에 대해 외부 AI 서비스(FastAPI/LangChain)를 호출하여 콘텐츠 생성을 요청 - // TODO: 3. 생성된 콘텐츠를 DB에 저장 + // TODO: 1. 전달받은 키워드 ID 목록으로 DB에서 상세 정보 조회 + // TODO: 2. 각 키워드/상품 정보에 대해 외부 AI 서비스(FastAPI/LangChain)를 호출하여 콘텐츠 생성을 요청 + // TODO: 3. 생성된 콘텐츠를 DB에 저장 - log.info(">>>> [Step 2] ContentGenerationTasklet finished."); - return RepeatStatus.FINISHED; - } -} \ No newline at end of file + log.info(">>>> [Step 2] ContentGenerationTasklet finished."); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java index b4f275e7..520403b3 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java @@ -1,8 +1,7 @@ package com.gltkorea.icebang.batch.tasklet; import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; + import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.scope.context.ChunkContext; import org.springframework.batch.core.step.tasklet.Tasklet; @@ -10,37 +9,39 @@ import org.springframework.batch.repeat.RepeatStatus; import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + @Slf4j @Component @RequiredArgsConstructor public class KeywordExtractionTasklet implements Tasklet { - // private final TrendKeywordService trendKeywordService; // 비즈니스 로직을 담은 서비스 + // private final TrendKeywordService trendKeywordService; // 비즈니스 로직을 담은 서비스 - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { - log.info(">>>> [Step 1] KeywordExtractionTasklet executed."); + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) + throws Exception { + log.info(">>>> [Step 1] KeywordExtractionTasklet executed."); - // TODO: 1. DB에서 카테고리 정보 조회 - // TODO: 2. 외부 API 또는 내부 로직을 통해 트렌드 키워드 추출 - // TODO: 3. 추출된 키워드를 DB에 저장 + // TODO: 1. DB에서 카테고리 정보 조회 + // TODO: 2. 외부 API 또는 내부 로직을 통해 트렌드 키워드 추출 + // TODO: 3. 추출된 키워드를 DB에 저장 - // --- 핵심: 다음 Step에 전달할 데이터 생성 --- - // 예시: 새로 생성된 키워드 ID 목록을 가져왔다고 가정 - List extractedKeywordIds = List.of(1L, 2L, 3L); // 실제로는 DB 저장 후 반환된 ID 목록 - log.info(">>>> Extracted Keyword IDs: {}", extractedKeywordIds); + // --- 핵심: 다음 Step에 전달할 데이터 생성 --- + // 예시: 새로 생성된 키워드 ID 목록을 가져왔다고 가정 + List extractedKeywordIds = List.of(1L, 2L, 3L); // 실제로는 DB 저장 후 반환된 ID 목록 + log.info(">>>> Extracted Keyword IDs: {}", extractedKeywordIds); - // --- 핵심: JobExecutionContext에 결과물 저장 --- - // JobExecution 전체에서 공유되는 컨텍스트를 가져옵니다. - ExecutionContext jobExecutionContext = chunkContext.getStepContext() - .getStepExecution() - .getJobExecution() - .getExecutionContext(); + // --- 핵심: JobExecutionContext에 결과물 저장 --- + // JobExecution 전체에서 공유되는 컨텍스트를 가져옵니다. + ExecutionContext jobExecutionContext = + chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); - // "extractedKeywordIds" 라는 Key로 데이터 저장 - jobExecutionContext.put("extractedKeywordIds", extractedKeywordIds); + // "extractedKeywordIds" 라는 Key로 데이터 저장 + jobExecutionContext.put("extractedKeywordIds", extractedKeywordIds); - log.info(">>>> [Step 1] KeywordExtractionTasklet finished."); - return RepeatStatus.FINISHED; - } -} \ No newline at end of file + log.info(">>>> [Step 1] KeywordExtractionTasklet finished."); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/scheduler/SchedulerConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/scheduler/SchedulerConfig.java index 06ca63d4..592eb0d7 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/config/scheduler/SchedulerConfig.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/config/scheduler/SchedulerConfig.java @@ -5,26 +5,24 @@ import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; -/** - * 동적 스케줄링을 위한 TaskScheduler Bean을 설정하는 클래스 - */ +/** 동적 스케줄링을 위한 TaskScheduler Bean을 설정하는 클래스 */ @Configuration public class SchedulerConfig { - @Bean - public TaskScheduler taskScheduler() { - // ThreadPool 기반의 TaskScheduler를 생성합니다. - ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + @Bean + public TaskScheduler taskScheduler() { + // ThreadPool 기반의 TaskScheduler를 생성합니다. + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); - // 스케줄러가 사용할 스레드 풀의 크기를 설정합니다. - // 동시에 실행될 수 있는 스케줄 작업의 최대 개수입니다. - scheduler.setPoolSize(10); + // 스케줄러가 사용할 스레드 풀의 크기를 설정합니다. + // 동시에 실행될 수 있는 스케줄 작업의 최대 개수입니다. + scheduler.setPoolSize(10); - // 스레드 이름의 접두사를 설정하여 로그 추적을 용이하게 합니다. - scheduler.setThreadNamePrefix("dynamic-scheduler-"); + // 스레드 이름의 접두사를 설정하여 로그 추적을 용이하게 합니다. + scheduler.setThreadNamePrefix("dynamic-scheduler-"); - // 스케줄러를 초기화합니다. - scheduler.initialize(); - return scheduler; - } -} \ No newline at end of file + // 스케줄러를 초기화합니다. + scheduler.initialize(); + return scheduler; + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/model/Schedule.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/model/Schedule.java index 3ad4c7e0..b9400b88 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/model/Schedule.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/model/Schedule.java @@ -6,9 +6,9 @@ @Getter @Setter public class Schedule { - private Long scheduleId; - private Long workflowId; - private String cronExpression; - private boolean isActive; - // ... 기타 필요한 컬럼 -} \ No newline at end of file + private Long scheduleId; + private Long workflowId; + private String cronExpression; + private boolean isActive; + // ... 기타 필요한 컬럼 +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/ScheduleMapper.java b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/ScheduleMapper.java index 0d70e1da..7220dc9e 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/ScheduleMapper.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/ScheduleMapper.java @@ -1,10 +1,12 @@ package com.gltkorea.icebang.mapper; -import com.gltkorea.icebang.domain.schedule.model.Schedule; // import 경로 변경 import java.util.List; + import org.apache.ibatis.annotations.Mapper; +import com.gltkorea.icebang.domain.schedule.model.Schedule; + @Mapper public interface ScheduleMapper { - List findAllByIsActive(boolean isActive); -} \ No newline at end of file + List findAllByIsActive(boolean isActive); +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/runner/SchedulerInitializer.java b/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/runner/SchedulerInitializer.java index ed7117f5..c772a434 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/runner/SchedulerInitializer.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/runner/SchedulerInitializer.java @@ -1,28 +1,31 @@ package com.gltkorea.icebang.schedule.runner; +import java.util.List; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + import com.gltkorea.icebang.domain.schedule.model.Schedule; import com.gltkorea.icebang.mapper.ScheduleMapper; import com.gltkorea.icebang.schedule.service.DynamicSchedulerService; -import java.util.List; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.stereotype.Component; @Slf4j @Component @RequiredArgsConstructor public class SchedulerInitializer implements ApplicationRunner { - private final ScheduleMapper scheduleMapper; - private final DynamicSchedulerService dynamicSchedulerService; + private final ScheduleMapper scheduleMapper; + private final DynamicSchedulerService dynamicSchedulerService; - @Override - public void run(ApplicationArguments args) { - log.info(">>>> Initializing schedules from database..."); - List activeSchedules = scheduleMapper.findAllByIsActive(true); - activeSchedules.forEach(dynamicSchedulerService::register); - log.info(">>>> {} active schedules have been registered.", activeSchedules.size()); - } -} \ No newline at end of file + @Override + public void run(ApplicationArguments args) { + log.info(">>>> Initializing schedules from database..."); + List activeSchedules = scheduleMapper.findAllByIsActive(true); + activeSchedules.forEach(dynamicSchedulerService::register); + log.info(">>>> {} active schedules have been registered.", activeSchedules.size()); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/service/DynamicSchedulerService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/service/DynamicSchedulerService.java index 6ac0d1f5..5bdb92c2 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/service/DynamicSchedulerService.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/service/DynamicSchedulerService.java @@ -1,12 +1,10 @@ package com.gltkorea.icebang.schedule.service; -import com.gltkorea.icebang.domain.schedule.model.Schedule; import java.time.LocalDateTime; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; -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; @@ -15,44 +13,54 @@ import org.springframework.scheduling.support.CronTrigger; import org.springframework.stereotype.Service; +import com.gltkorea.icebang.domain.schedule.model.Schedule; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + @Slf4j @Service @RequiredArgsConstructor public class DynamicSchedulerService { - private final TaskScheduler taskScheduler; - private final JobLauncher jobLauncher; - private final ApplicationContext applicationContext; - private final Map> scheduledTasks = new ConcurrentHashMap<>(); - - public void register(Schedule schedule) { - // TODO: schedule.getWorkflowId()를 기반으로 실행할 Job의 이름을 DB에서 조회 - String jobName = "blogContentJob"; // 예시 - Job jobToRun = applicationContext.getBean(jobName, Job.class); - - Runnable runnable = () -> { - try { - JobParametersBuilder paramsBuilder = new JobParametersBuilder(); - paramsBuilder.addString("runAt", LocalDateTime.now().toString()); - paramsBuilder.addLong("scheduleId", schedule.getScheduleId()); - jobLauncher.run(jobToRun, paramsBuilder.toJobParameters()); - } catch (Exception e) { - log.error("Failed to run scheduled job for scheduleId: {}", schedule.getScheduleId(), e); - } + private final TaskScheduler taskScheduler; + private final JobLauncher jobLauncher; + private final ApplicationContext applicationContext; + private final Map> scheduledTasks = new ConcurrentHashMap<>(); + + public void register(Schedule schedule) { + // TODO: schedule.getWorkflowId()를 기반으로 실행할 Job의 이름을 DB에서 조회 + String jobName = "blogContentJob"; // 예시 + Job jobToRun = applicationContext.getBean(jobName, Job.class); + + Runnable runnable = + () -> { + try { + JobParametersBuilder paramsBuilder = new JobParametersBuilder(); + paramsBuilder.addString("runAt", LocalDateTime.now().toString()); + paramsBuilder.addLong("scheduleId", schedule.getScheduleId()); + jobLauncher.run(jobToRun, paramsBuilder.toJobParameters()); + } catch (Exception e) { + log.error( + "Failed to run scheduled job for scheduleId: {}", schedule.getScheduleId(), e); + } }; - CronTrigger trigger = new CronTrigger(schedule.getCronExpression()); - ScheduledFuture future = taskScheduler.schedule(runnable, trigger); - scheduledTasks.put(schedule.getScheduleId(), future); - log.info(">>>> Schedule registered: id={}, cron={}", schedule.getScheduleId(), schedule.getCronExpression()); - } + CronTrigger trigger = new CronTrigger(schedule.getCronExpression()); + ScheduledFuture future = taskScheduler.schedule(runnable, trigger); + scheduledTasks.put(schedule.getScheduleId(), future); + log.info( + ">>>> Schedule registered: id={}, cron={}", + schedule.getScheduleId(), + schedule.getCronExpression()); + } - public void remove(Long scheduleId) { - ScheduledFuture future = scheduledTasks.get(scheduleId); - if (future != null) { - future.cancel(true); - scheduledTasks.remove(scheduleId); - log.info(">>>> Schedule removed: id={}", scheduleId); - } + public void remove(Long scheduleId) { + ScheduledFuture future = scheduledTasks.get(scheduleId); + if (future != null) { + future.cancel(true); + scheduledTasks.remove(scheduleId); + log.info(">>>> Schedule removed: id={}", scheduleId); } -} \ No newline at end of file + } +} From 56721cf262d98f7369091325bcba51a1076cff78 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Fri, 5 Sep 2025 18:14:07 +0900 Subject: [PATCH 10/14] =?UTF-8?q?refactor:=20=EB=94=94=EB=A0=89=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{ => domain}/schedule/runner/SchedulerInitializer.java | 4 ++-- .../schedule/service/DynamicSchedulerService.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename apps/user-service/src/main/java/com/gltkorea/icebang/{ => domain}/schedule/runner/SchedulerInitializer.java (87%) rename apps/user-service/src/main/java/com/gltkorea/icebang/{ => domain}/schedule/service/DynamicSchedulerService.java (97%) diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/runner/SchedulerInitializer.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/runner/SchedulerInitializer.java similarity index 87% rename from apps/user-service/src/main/java/com/gltkorea/icebang/schedule/runner/SchedulerInitializer.java rename to apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/runner/SchedulerInitializer.java index c772a434..861c5343 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/runner/SchedulerInitializer.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/runner/SchedulerInitializer.java @@ -1,4 +1,4 @@ -package com.gltkorea.icebang.schedule.runner; +package com.gltkorea.icebang.domain.schedule.runner; import java.util.List; @@ -8,7 +8,7 @@ import com.gltkorea.icebang.domain.schedule.model.Schedule; import com.gltkorea.icebang.mapper.ScheduleMapper; -import com.gltkorea.icebang.schedule.service.DynamicSchedulerService; +import com.gltkorea.icebang.domain.schedule.service.DynamicSchedulerService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/service/DynamicSchedulerService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/service/DynamicSchedulerService.java similarity index 97% rename from apps/user-service/src/main/java/com/gltkorea/icebang/schedule/service/DynamicSchedulerService.java rename to apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/service/DynamicSchedulerService.java index 5bdb92c2..a8bbeff1 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/schedule/service/DynamicSchedulerService.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/service/DynamicSchedulerService.java @@ -1,4 +1,4 @@ -package com.gltkorea.icebang.schedule.service; +package com.gltkorea.icebang.domain.schedule.service; import java.time.LocalDateTime; import java.util.Map; From 2677e1ef10566dae9d05c6348aba9aa51b2e9d0b Mon Sep 17 00:00:00 2001 From: jihukimme Date: Fri, 5 Sep 2025 18:18:08 +0900 Subject: [PATCH 11/14] refactor: Code Formatting --- .../icebang/domain/schedule/runner/SchedulerInitializer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/runner/SchedulerInitializer.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/runner/SchedulerInitializer.java index 861c5343..7f96bba8 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/runner/SchedulerInitializer.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/runner/SchedulerInitializer.java @@ -7,8 +7,8 @@ import org.springframework.stereotype.Component; import com.gltkorea.icebang.domain.schedule.model.Schedule; -import com.gltkorea.icebang.mapper.ScheduleMapper; import com.gltkorea.icebang.domain.schedule.service.DynamicSchedulerService; +import com.gltkorea.icebang.mapper.ScheduleMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; From 0613dc7e95f7d663bba6df3431fe1c27d0221047 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Fri, 5 Sep 2025 18:27:01 +0900 Subject: [PATCH 12/14] =?UTF-8?q?chore:=20batch=20domain=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/job/BlogContentJobConfig.java | 51 +++++++++++++++++++ .../tasklet/ContentGenerationTasklet.java | 49 ++++++++++++++++++ .../tasklet/KeywordExtractionTasklet.java | 47 +++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/domain/batch/job/BlogContentJobConfig.java create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/domain/batch/tasklet/ContentGenerationTasklet.java create mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/domain/batch/tasklet/KeywordExtractionTasklet.java diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/batch/job/BlogContentJobConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/batch/job/BlogContentJobConfig.java new file mode 100644 index 00000000..6646c9dc --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/batch/job/BlogContentJobConfig.java @@ -0,0 +1,51 @@ +package com.gltkorea.icebang.domain.batch.job; + +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.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import com.gltkorea.icebang.domain.batch.tasklet.ContentGenerationTasklet; +import com.gltkorea.icebang.domain.batch.tasklet.KeywordExtractionTasklet; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class BlogContentJobConfig { + + // 변경점 1: Factory 대신 실제 Tasklet만 필드로 주입받습니다. + private final KeywordExtractionTasklet keywordExtractionTasklet; + private final ContentGenerationTasklet contentGenerationTasklet; + + @Bean + public Job blogContentJob( + JobRepository jobRepository, Step keywordExtractionStep, Step contentGenerationStep) { + return new JobBuilder("blogContentJob", jobRepository) // 변경점 2: JobBuilder를 직접 생성합니다. + .start(keywordExtractionStep) + .next(contentGenerationStep) + .build(); + } + + @Bean + public Step keywordExtractionStep( + JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("keywordExtractionStep", jobRepository) // 변경점 3: StepBuilder를 직접 생성합니다. + .tasklet( + keywordExtractionTasklet, + transactionManager) // 변경점 4: tasklet에 transactionManager를 함께 전달합니다. + .build(); + } + + @Bean + public Step contentGenerationStep( + JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("contentGenerationStep", jobRepository) + .tasklet(contentGenerationTasklet, transactionManager) + .build(); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/batch/tasklet/ContentGenerationTasklet.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/batch/tasklet/ContentGenerationTasklet.java new file mode 100644 index 00000000..c445cc21 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/batch/tasklet/ContentGenerationTasklet.java @@ -0,0 +1,49 @@ +package com.gltkorea.icebang.domain.batch.tasklet; + +import java.util.List; + +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.item.ExecutionContext; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ContentGenerationTasklet implements Tasklet { + + // private final ContentService contentService; // 비즈니스 로직을 담은 서비스 + // private final FastApiClient fastApiClient; // FastAPI 통신을 위한 클라이언트 + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) + throws Exception { + log.info(">>>> [Step 2] ContentGenerationTasklet executed."); + + // --- 핵심: JobExecutionContext에서 이전 Step의 결과물 가져오기 --- + ExecutionContext jobExecutionContext = + chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); + + // KeywordExtractionTasklet이 저장한 "extractedKeywordIds" Key로 데이터 조회 + List keywordIds = (List) jobExecutionContext.get("extractedKeywordIds"); + + if (keywordIds == null || keywordIds.isEmpty()) { + log.warn(">>>> No keyword IDs found from previous step. Skipping content generation."); + return RepeatStatus.FINISHED; + } + + log.info(">>>> Received Keyword IDs for content generation: {}", keywordIds); + + // TODO: 1. 전달받은 키워드 ID 목록으로 DB에서 상세 정보 조회 + // TODO: 2. 각 키워드/상품 정보에 대해 외부 AI 서비스(FastAPI/LangChain)를 호출하여 콘텐츠 생성을 요청 + // TODO: 3. 생성된 콘텐츠를 DB에 저장 + + log.info(">>>> [Step 2] ContentGenerationTasklet finished."); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/batch/tasklet/KeywordExtractionTasklet.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/batch/tasklet/KeywordExtractionTasklet.java new file mode 100644 index 00000000..4dc544b9 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/batch/tasklet/KeywordExtractionTasklet.java @@ -0,0 +1,47 @@ +package com.gltkorea.icebang.domain.batch.tasklet; + +import java.util.List; + +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.item.ExecutionContext; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KeywordExtractionTasklet implements Tasklet { + + // private final TrendKeywordService trendKeywordService; // 비즈니스 로직을 담은 서비스 + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) + throws Exception { + log.info(">>>> [Step 1] KeywordExtractionTasklet executed."); + + // TODO: 1. DB에서 카테고리 정보 조회 + // TODO: 2. 외부 API 또는 내부 로직을 통해 트렌드 키워드 추출 + // TODO: 3. 추출된 키워드를 DB에 저장 + + // --- 핵심: 다음 Step에 전달할 데이터 생성 --- + // 예시: 새로 생성된 키워드 ID 목록을 가져왔다고 가정 + List extractedKeywordIds = List.of(1L, 2L, 3L); // 실제로는 DB 저장 후 반환된 ID 목록 + log.info(">>>> Extracted Keyword IDs: {}", extractedKeywordIds); + + // --- 핵심: JobExecutionContext에 결과물 저장 --- + // JobExecution 전체에서 공유되는 컨텍스트를 가져옵니다. + ExecutionContext jobExecutionContext = + chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); + + // "extractedKeywordIds" 라는 Key로 데이터 저장 + jobExecutionContext.put("extractedKeywordIds", extractedKeywordIds); + + log.info(">>>> [Step 1] KeywordExtractionTasklet finished."); + return RepeatStatus.FINISHED; + } +} From b8914b57d8e8c381f6b219f058d7c00faa366fcf Mon Sep 17 00:00:00 2001 From: jihukimme Date: Fri, 5 Sep 2025 18:30:25 +0900 Subject: [PATCH 13/14] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20DTO=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gltkorea/icebang/dto/ProductCrawlingData.java | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/dto/ProductCrawlingData.java diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/dto/ProductCrawlingData.java b/apps/user-service/src/main/java/com/gltkorea/icebang/dto/ProductCrawlingData.java deleted file mode 100644 index 83041b55..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/dto/ProductCrawlingData.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.gltkorea.icebang.dto; - -import java.time.LocalDateTime; - -import lombok.Data; - -@Data // Lombok -public class ProductCrawlingData { - private Long id; - private String name; - private String urlToCrawl; // DB 컬럼명은 url_to_crawl - private int price; - private String stockStatus; // DB 컬럼명은 stock_status - private LocalDateTime lastCrawledAt; // DB 컬럼명은 last_crawled_at -} From 5f868d8fbbdc11b9df0a5224e1d14d8133a923ac Mon Sep 17 00:00:00 2001 From: jihukimme Date: Sat, 6 Sep 2025 17:13:35 +0900 Subject: [PATCH 14/14] =?UTF-8?q?refactor:=20batch=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=9C=84=EC=B9=98=EB=A5=BC=20domain=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/job/BlogContentJobConfig.java | 51 ------------------- .../tasklet/ContentGenerationTasklet.java | 49 ------------------ .../tasklet/KeywordExtractionTasklet.java | 47 ----------------- 3 files changed, 147 deletions(-) delete mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java delete mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java delete mode 100644 apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java deleted file mode 100644 index 61626411..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.gltkorea.icebang.batch.job; - -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.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -import com.gltkorea.icebang.batch.tasklet.ContentGenerationTasklet; -import com.gltkorea.icebang.batch.tasklet.KeywordExtractionTasklet; - -import lombok.RequiredArgsConstructor; - -@Configuration -@RequiredArgsConstructor -public class BlogContentJobConfig { - - // 변경점 1: Factory 대신 실제 Tasklet만 필드로 주입받습니다. - private final KeywordExtractionTasklet keywordExtractionTasklet; - private final ContentGenerationTasklet contentGenerationTasklet; - - @Bean - public Job blogContentJob( - JobRepository jobRepository, Step keywordExtractionStep, Step contentGenerationStep) { - return new JobBuilder("blogContentJob", jobRepository) // 변경점 2: JobBuilder를 직접 생성합니다. - .start(keywordExtractionStep) - .next(contentGenerationStep) - .build(); - } - - @Bean - public Step keywordExtractionStep( - JobRepository jobRepository, PlatformTransactionManager transactionManager) { - return new StepBuilder("keywordExtractionStep", jobRepository) // 변경점 3: StepBuilder를 직접 생성합니다. - .tasklet( - keywordExtractionTasklet, - transactionManager) // 변경점 4: tasklet에 transactionManager를 함께 전달합니다. - .build(); - } - - @Bean - public Step contentGenerationStep( - JobRepository jobRepository, PlatformTransactionManager transactionManager) { - return new StepBuilder("contentGenerationStep", jobRepository) - .tasklet(contentGenerationTasklet, transactionManager) - .build(); - } -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java deleted file mode 100644 index 5cc8918a..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.gltkorea.icebang.batch.tasklet; - -import java.util.List; - -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.item.ExecutionContext; -import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -@RequiredArgsConstructor -public class ContentGenerationTasklet implements Tasklet { - - // private final ContentService contentService; // 비즈니스 로직을 담은 서비스 - // private final FastApiClient fastApiClient; // FastAPI 통신을 위한 클라이언트 - - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) - throws Exception { - log.info(">>>> [Step 2] ContentGenerationTasklet executed."); - - // --- 핵심: JobExecutionContext에서 이전 Step의 결과물 가져오기 --- - ExecutionContext jobExecutionContext = - chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); - - // KeywordExtractionTasklet이 저장한 "extractedKeywordIds" Key로 데이터 조회 - List keywordIds = (List) jobExecutionContext.get("extractedKeywordIds"); - - if (keywordIds == null || keywordIds.isEmpty()) { - log.warn(">>>> No keyword IDs found from previous step. Skipping content generation."); - return RepeatStatus.FINISHED; - } - - log.info(">>>> Received Keyword IDs for content generation: {}", keywordIds); - - // TODO: 1. 전달받은 키워드 ID 목록으로 DB에서 상세 정보 조회 - // TODO: 2. 각 키워드/상품 정보에 대해 외부 AI 서비스(FastAPI/LangChain)를 호출하여 콘텐츠 생성을 요청 - // TODO: 3. 생성된 콘텐츠를 DB에 저장 - - log.info(">>>> [Step 2] ContentGenerationTasklet finished."); - return RepeatStatus.FINISHED; - } -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java deleted file mode 100644 index 520403b3..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.gltkorea.icebang.batch.tasklet; - -import java.util.List; - -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.item.ExecutionContext; -import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -@RequiredArgsConstructor -public class KeywordExtractionTasklet implements Tasklet { - - // private final TrendKeywordService trendKeywordService; // 비즈니스 로직을 담은 서비스 - - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) - throws Exception { - log.info(">>>> [Step 1] KeywordExtractionTasklet executed."); - - // TODO: 1. DB에서 카테고리 정보 조회 - // TODO: 2. 외부 API 또는 내부 로직을 통해 트렌드 키워드 추출 - // TODO: 3. 추출된 키워드를 DB에 저장 - - // --- 핵심: 다음 Step에 전달할 데이터 생성 --- - // 예시: 새로 생성된 키워드 ID 목록을 가져왔다고 가정 - List extractedKeywordIds = List.of(1L, 2L, 3L); // 실제로는 DB 저장 후 반환된 ID 목록 - log.info(">>>> Extracted Keyword IDs: {}", extractedKeywordIds); - - // --- 핵심: JobExecutionContext에 결과물 저장 --- - // JobExecution 전체에서 공유되는 컨텍스트를 가져옵니다. - ExecutionContext jobExecutionContext = - chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); - - // "extractedKeywordIds" 라는 Key로 데이터 저장 - jobExecutionContext.put("extractedKeywordIds", extractedKeywordIds); - - log.info(">>>> [Step 1] KeywordExtractionTasklet finished."); - return RepeatStatus.FINISHED; - } -}