This commit is contained in:
mindol1004
2024-10-08 16:20:54 +09:00
parent 630d615c8c
commit 698d7db4a2
24 changed files with 480 additions and 215 deletions

View File

@@ -20,12 +20,28 @@
<maven.compiler.release>11</maven.compiler.release>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2021.0.7</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>

View File

@@ -7,14 +7,13 @@ import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
import com.spring.domain.post.repository.PostRepository;
import com.spring.infra.batch.AbstractBatchTask;
import com.spring.infra.batch.BatchJobInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import com.spring.domain.post.repository.PostRepository;
@Slf4j
@Component
@BatchJobInfo(

View File

@@ -31,9 +31,7 @@ public class PostCreateBatch extends AbstractBatchTask {
@Autowired
@Override
public void setTransactionManager(
@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER) PlatformTransactionManager transactionManager
) {
public void setTransactionManager(@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER) PlatformTransactionManager transactionManager) {
super.setTransactionManager(transactionManager);
}

View File

@@ -13,7 +13,6 @@ import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.job.flow.FlowExecutionStatus;
import org.springframework.batch.core.job.flow.JobExecutionDecider;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.item.ItemProcessor;
@@ -21,15 +20,16 @@ import org.springframework.batch.item.ItemWriter;
import org.springframework.batch.item.database.JpaPagingItemReader;
import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import com.spring.domain.post.entity.Post;
import com.spring.domain.post.entity.PostBackUp;
import com.spring.domain.post.repository.PostBackUpRepository;
import com.spring.domain.post.repository.PostRepository;
import com.spring.infra.batch.AbstractBatchChunk;
import com.spring.infra.batch.BatchJobInfo;
import com.spring.infra.db.orm.jpa.SecondaryJpaConfig;
@@ -37,14 +37,21 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Configuration
@Component
@BatchJobInfo(
group = "${batch-info.post-create-batch.group}",
jobName = "${batch-info.post-create-batch.job-name}",
cronExpression = "${batch-info.post-create-batch.cron-expression}",
description = "${batch-info.post-create-batch.description}"
)
@RequiredArgsConstructor
public class PostCreateBatchChunk {
public class PostCreateBatchChunk extends AbstractBatchChunk {
private final JobRepository jobRepository;
@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER)
private final PlatformTransactionManager transactionManager;
@Autowired
@Override
public void setTransactionManager(@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER) PlatformTransactionManager transactionManager) {
super.setTransactionManager(transactionManager);
}
@Qualifier(SecondaryJpaConfig.ENTITY_MANAGER_FACTORY)
private final EntityManagerFactory entityManagerFactory;
@@ -54,15 +61,9 @@ public class PostCreateBatchChunk {
private List<Post> list = new ArrayList<>();
@Bean
@BatchJobInfo(
group = "${batch-info.post-create-batch.group}",
jobName = "${batch-info.post-create-batch.job-name}",
cronExpression = "${batch-info.post-create-batch.cron-expression}",
description = "${batch-info.post-create-batch.description}"
)
Job testPostJob() {
return new JobBuilder("testPostJob")
@Override
public Job createJob() {
return new JobBuilder(batchJobInfoData.getJobName())
.repository(jobRepository)
.incrementer(new RunIdIncrementer())
.start(readListStep())
@@ -136,5 +137,5 @@ public class PostCreateBatchChunk {
return RepeatStatus.FINISHED;
};
}
}

View File

@@ -0,0 +1,17 @@
package com.spring.infra.batch;
import org.springframework.batch.core.Job;
public interface AbstractBatch {
void initializeBatchJobInfo();
void registerJobBean();
Job createJob();
default String removeScopedTargetPrefix(String beanName) {
return beanName.startsWith("scopedTarget.") ? beanName.substring("scopedTarget.".length()) : beanName;
}
}

View File

@@ -0,0 +1,96 @@
package com.spring.infra.batch;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.lang.NonNull;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public abstract class AbstractBatchChunk implements AbstractBatch, ApplicationContextAware, InitializingBean {
private final BatchJobInfo batchJobInfo;
private BatchJobInfoService batchJobInfoService;
private ApplicationContext applicationContext;
protected BatchJobInfoData batchJobInfoData;
protected JobRepository jobRepository;
protected PlatformTransactionManager transactionManager;
/**
* 기본 생성자입니다.
* <p>
* BatchJobInfo 어노테이션을 찾고, 없으면 예외를 발생시킵니다.
* </p>
*/
protected AbstractBatchChunk() {
this.batchJobInfo = AnnotationUtils.findAnnotation(getClass(), BatchJobInfo.class);
if (this.batchJobInfo == null) {
throw new IllegalStateException("BatchJobInfo annotation is missing");
}
}
@Override
public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Override
public void afterPropertiesSet() throws Exception {
this.batchJobInfoService = applicationContext.getBean(BatchJobInfoService.class);
initializeBatchJobInfo();
registerJobBean();
}
@Override
public void initializeBatchJobInfo() {
String beanName = applicationContext.getBeanNamesForType(this.getClass())[0];
this.batchJobInfoData = batchJobInfoService.getBatchJobInfo(removeScopedTargetPrefix(beanName));
}
/**
* 배치 작업을 Spring의 Bean으로 등록합니다.
*/
@Override
public void registerJobBean() {
var beanFactory = ((ConfigurableApplicationContext) applicationContext).getBeanFactory();
var registry = (BeanDefinitionRegistry) beanFactory;
String jobBeanName = batchJobInfoData.getJobName();
if (!registry.containsBeanDefinition(jobBeanName)) {
var beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(Job.class, this::createJob);
registry.registerBeanDefinition(jobBeanName, beanDefinitionBuilder.getBeanDefinition());
}
}
/**
* JobRepository를 설정합니다.
*
* @param jobRepository 설정할 JobRepository
*/
@Autowired
public void setJobRepository(JobRepository jobRepository) {
this.jobRepository = jobRepository;
}
/**
* PlatformTransactionManager를 설정합니다.
*
* @param transactionManager 설정할 PlatformTransactionManager
*/
@Autowired
public void setTransactionManager(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
@Override
public abstract Job createJob();
}

View File

@@ -23,8 +23,6 @@ import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.lang.NonNull;
import org.springframework.transaction.PlatformTransactionManager;
import com.spring.infra.batch.BatchJobInfoBeanPostProcessor.BatchJobInfoData;
/**
* 배치 작업을 정의하는 추상 클래스입니다.
* <p>
@@ -35,10 +33,10 @@ import com.spring.infra.batch.BatchJobInfoBeanPostProcessor.BatchJobInfoData;
* @version 1.0
*/
@Configuration
public abstract class AbstractBatchTask implements ApplicationContextAware, InitializingBean {
public abstract class AbstractBatchTask implements AbstractBatch, ApplicationContextAware, InitializingBean {
private final BatchJobInfo batchJobInfo;
private BatchJobInfoBeanPostProcessor batchJobInfoProcessor;
private BatchJobInfoService batchJobInfoService;
private BatchJobInfoData batchJobInfoData;
private ApplicationContext applicationContext;
private JobRepository jobRepository;
@@ -60,28 +58,33 @@ public abstract class AbstractBatchTask implements ApplicationContextAware, Init
@Override
public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
this.batchJobInfoService = applicationContext.getBean(BatchJobInfoService.class);
}
@Override
public void afterPropertiesSet() throws Exception {
this.batchJobInfoProcessor = applicationContext.getBean(BatchJobInfoBeanPostProcessor.class);
initializeBatchJobInfo();
registerJobBean();
}
private void initializeBatchJobInfo() {
@Override
public void initializeBatchJobInfo() {
String beanName = applicationContext.getBeanNamesForType(this.getClass())[0];
this.batchJobInfoData = batchJobInfoProcessor.getBatchJobInfo(beanName);
this.batchJobInfoData = batchJobInfoService.getBatchJobInfo(removeScopedTargetPrefix(beanName));
}
/**
* 배치 작업을 Spring의 Bean으로 등록합니다.
*/
private void registerJobBean() {
@Override
public void registerJobBean() {
var beanFactory = ((ConfigurableApplicationContext) applicationContext).getBeanFactory();
var registry = (BeanDefinitionRegistry) beanFactory;
var beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(Job.class, this::createJob);
registry.registerBeanDefinition(batchJobInfoData.getJobName(), beanDefinitionBuilder.getBeanDefinition());
String jobBeanName = batchJobInfoData.getJobName();
if (!registry.containsBeanDefinition(jobBeanName)) {
var beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(Job.class, this::createJob);
registry.registerBeanDefinition(jobBeanName, beanDefinitionBuilder.getBeanDefinition());
}
}
/**
@@ -110,7 +113,8 @@ public abstract class AbstractBatchTask implements ApplicationContextAware, Init
* @return 생성된 Job 객체
* @throws IllegalStateException STEP이 정의되지 않은 경우 예외 발생
*/
private Job createJob() {
@Override
public Job createJob() {
List<Step> steps = createSteps();
if (steps.isEmpty()) {
throw new IllegalStateException("No steps defined for job: " + batchJobInfoData.getJobName());

View File

@@ -32,9 +32,9 @@ public class BatchConfig {
* @param jobRegistry JobRegistry 객체
* @return 설정된 JobRegistryBeanPostProcessor 객체
*/
@Bean
@Bean
JobRegistryBeanPostProcessor jobRegistryBeanPostProcessor(JobRegistry jobRegistry) {
final var processor = new JobRegistryBeanPostProcessor();
var processor = new JobRegistryBeanPostProcessor();
processor.setJobRegistry(jobRegistry);
return processor;
}

View File

@@ -5,13 +5,16 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.cloud.context.config.annotation.RefreshScope;
/**
* 배치 작업에 대한 메타데이터를 정의하는 어노테이션입니다.
* <p>
* 이 어노테이션은 배치 작업의 그룹, 이름 및 cron 표현식을 설정하는 데 사용됩니다.
* </p>
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@RefreshScope
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface BatchJobInfo {
/**

View File

@@ -1,79 +1,60 @@
package com.spring.infra.batch;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.configuration.DuplicateJobException;
import org.springframework.batch.core.configuration.JobRegistry;
import org.springframework.batch.core.configuration.support.ReferenceJobFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.InstantiationAwareBeanPostProcessor;
import org.springframework.core.env.Environment;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
@RequiredArgsConstructor
public class BatchJobInfoBeanPostProcessor implements InstantiationAwareBeanPostProcessor {
public class BatchJobInfoBeanPostProcessor implements BeanPostProcessor {
private final Environment environment;
private final Map<String, BatchJobInfoData> batchJobInfoMap = new ConcurrentHashMap<>();
private static final String BASE_PACKAGE = "com.spring";
private final BatchJobInfoService batchJobInfoService;
private final JobRegistry jobRegistry;
@Override
@Nullable
public Object postProcessBeforeInstantiation(@NonNull Class<?> beanClass, @NonNull String beanName) throws BeansException {
if (beanClass.getPackage().getName().startsWith(BASE_PACKAGE)) {
processAnnotations(beanClass, beanName);
}
return null;
public Object postProcessBeforeInitialization(@NonNull Object bean, @NonNull String beanName) throws BeansException {
processBatchJobInfoAnnotations(bean, beanName);
return bean;
}
private void processAnnotations(Class<?> beanClass, String beanName) {
if (beanClass.isAnnotationPresent(BatchJobInfo.class)) {
BatchJobInfo batchJobInfo = beanClass.getAnnotation(BatchJobInfo.class);
setBatchJobInfoData(batchJobInfo, beanName);
}
ReflectionUtils.doWithMethods(beanClass, method -> {
if (method.isAnnotationPresent(BatchJobInfo.class)) {
BatchJobInfo batchJobInfo = method.getAnnotation(BatchJobInfo.class);
String jobBeanName = method.getName();
setBatchJobInfoData(batchJobInfo, jobBeanName);
@Override
@Nullable
public Object postProcessAfterInitialization(@NonNull Object bean, @NonNull String beanName) throws BeansException {
if (bean instanceof AbstractBatch) {
AbstractBatch abstractBatch = (AbstractBatch) bean;
Job job = abstractBatch.createJob();
String jobName = job.getName();
if (!jobRegistry.getJobNames().contains(jobName)) {
try {
jobRegistry.register(new ReferenceJobFactory(job));
} catch (DuplicateJobException e) {
log.warn("Unexpected DuplicateJobException for job: {}", jobName, e);
}
}
});
}
return bean;
}
private void setBatchJobInfoData(BatchJobInfo batchJobInfo, String beanName) {
batchJobInfoMap.put(beanName, processBatchJobInfo(batchJobInfo));
private void processBatchJobInfoAnnotations(Object bean, String beanName) {
if (bean.getClass().isAnnotationPresent(BatchJobInfo.class)) {
BatchJobInfo batchJobInfo = bean.getClass().getAnnotation(BatchJobInfo.class);
batchJobInfoService.setBatchJobInfoData(removeScopedTargetPrefix(beanName), batchJobInfo);
}
}
private BatchJobInfoData processBatchJobInfo(BatchJobInfo batchJobInfo) {
return new BatchJobInfoData(
resolvePlaceHolder(batchJobInfo.group()),
resolvePlaceHolder(batchJobInfo.jobName()),
resolvePlaceHolder(batchJobInfo.cronExpression()),
resolvePlaceHolder(batchJobInfo.description())
);
private String removeScopedTargetPrefix(String beanName) {
return beanName.startsWith("scopedTarget.") ? beanName.substring("scopedTarget.".length()) : beanName;
}
private String resolvePlaceHolder(String value) {
return environment.resolvePlaceholders(value);
}
public BatchJobInfoData getBatchJobInfo(String beanName) {
return batchJobInfoMap.get(beanName);
}
@Getter
@RequiredArgsConstructor
public static class BatchJobInfoData {
private final String jobGroup;
private final String jobName;
private final String cronExpression;
private final String description;
}
}

View File

@@ -0,0 +1,13 @@
package com.spring.infra.batch;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class BatchJobInfoData {
private final String jobGroup;
private final String jobName;
private final String cronExpression;
private final String description;
}

View File

@@ -0,0 +1,39 @@
package com.spring.infra.batch;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class BatchJobInfoService {
private final Environment environment;
private final Map<String, BatchJobInfoData> batchJobInfoMap = new ConcurrentHashMap<>();
public void setBatchJobInfoData(String beanName, BatchJobInfo batchJobInfo) {
batchJobInfoMap.put(beanName, processBatchJobInfo(batchJobInfo));
}
public BatchJobInfoData getBatchJobInfo(String beanName) {
return batchJobInfoMap.get(beanName);
}
private BatchJobInfoData processBatchJobInfo(BatchJobInfo batchJobInfo) {
return new BatchJobInfoData(
resolvePlaceHolder(batchJobInfo.group()),
resolvePlaceHolder(batchJobInfo.jobName()),
resolvePlaceHolder(batchJobInfo.cronExpression()),
resolvePlaceHolder(batchJobInfo.description())
);
}
private String resolvePlaceHolder(String value) {
return environment.resolvePlaceholders(value);
}
}

View File

@@ -10,7 +10,6 @@ import org.springframework.batch.core.configuration.JobRegistry;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.lang.NonNull;
import org.springframework.scheduling.quartz.QuartzJobBean;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -34,7 +33,6 @@ import lombok.extern.slf4j.Slf4j;
* @see JobRegistry
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class QuartzJobLauncher extends QuartzJobBean {

View File

@@ -2,25 +2,16 @@ package com.spring.infra.quartz;
import java.util.Map;
import org.quartz.CronScheduleBuilder;
import org.quartz.CronTrigger;
import org.quartz.JobBuilder;
import org.quartz.JobDataMap;
import org.quartz.JobDetail;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.TriggerBuilder;
import org.springframework.aop.support.AopUtils;
import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;
import com.spring.infra.batch.AbstractBatchTask;
import com.spring.infra.batch.BatchJobInfo;
import com.spring.infra.batch.BatchJobInfoBeanPostProcessor;
import com.spring.infra.batch.BatchJobInfoBeanPostProcessor.BatchJobInfoData;
import com.spring.infra.batch.AbstractBatch;
import lombok.RequiredArgsConstructor;
@@ -46,9 +37,8 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedEvent> {
private final Scheduler scheduler;
private final ApplicationContext applicationContext;
private final BatchJobInfoBeanPostProcessor batchJobInfoProcessor;
private final QuartzScheduleService quartzScheduleService;
/**
* 애플리케이션 컨텍스트가 리프레시될 때 호출되는 메소드입니다.
@@ -58,96 +48,34 @@ public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedE
*/
@Override
public void onApplicationEvent(@NonNull ContextRefreshedEvent event) {
clearJobs();
refreshJobs();
}
@EventListener
public void onRefreshScopeRefreshed(@NonNull RefreshScopeRefreshedEvent event) {
refreshJobs();
}
private void refreshJobs() {
quartzScheduleService.clearJobs();
registerBatchJobs();
registerAbstractBatchJobs();
}
private void clearJobs() {
try {
scheduler.clear();
} catch (SchedulerException e) {
throw new IllegalStateException();
}
}
/**
* BatchJobInfo 어노테이션이 붙은 모든 메소드를 찾아 Quartz 스케줄러에 등록합니다.
*/
private void registerBatchJobs() {
for (String beanName : applicationContext.getBeanDefinitionNames()) {
Object bean = applicationContext.getBean(beanName);
Class<?> beanClass = bean.getClass();
ReflectionUtils.doWithMethods(beanClass, method -> {
if (method.isAnnotationPresent(BatchJobInfo.class)) {
BatchJobInfoData batchJobInfoData = batchJobInfoProcessor.getBatchJobInfo(beanName);
if (batchJobInfoData != null) {
scheduleQuartzJob(batchJobInfoData);
}
}
});
}
}
private void scheduleQuartzJob(BatchJobInfoData batchJobInfoData) {
JobDataMap jobDataMap = new JobDataMap();
jobDataMap.put("jobName", batchJobInfoData.getJobName());
JobDetail jobDetail = JobBuilder.newJob(QuartzJobLauncher.class)
.withIdentity(batchJobInfoData.getJobName(), batchJobInfoData.getJobGroup())
.setJobData(jobDataMap)
.storeDurably(true)
.withDescription(batchJobInfoData.getDescription())
.build();
CronTrigger trigger = TriggerBuilder.newTrigger()
.forJob(jobDetail)
.withIdentity(batchJobInfoData.getJobName() + "Trigger", batchJobInfoData.getJobGroup())
.withSchedule(CronScheduleBuilder.cronSchedule(batchJobInfoData.getCronExpression()))
.withDescription(batchJobInfoData.getDescription())
.build();
try {
scheduler.scheduleJob(jobDetail, trigger);
} catch (SchedulerException e) {
throw new IllegalStateException("Error scheduling Quartz job: " + batchJobInfoData.getJobName(), e);
}
}
/**
* AbstractBatchJob을 상속받은 모든 클래스를 찾아 Quartz 스케줄러에 등록합니다.
*/
private void registerAbstractBatchJobs() {
Map<String, AbstractBatchTask> batchJobs = applicationContext.getBeansOfType(AbstractBatchTask.class);
for (String beanName : batchJobs.keySet()) {
scheduleJob(batchJobInfoProcessor.getBatchJobInfo(beanName));
private void registerBatchJobs() {
Map<String, AbstractBatch> batchJobs = applicationContext.getBeansOfType(AbstractBatch.class);
for (Map.Entry<String, AbstractBatch> entry : batchJobs.entrySet()) {
AbstractBatch batch = entry.getValue();
if (!AopUtils.isAopProxy(batch)) {
quartzScheduleService.createJobTrigger(removeScopedTargetPrefix(entry.getKey()));
}
}
}
private void scheduleJob(BatchJobInfoData batchJobInfoData) {
JobDataMap jobDataMap = new JobDataMap();
jobDataMap.put("jobName", batchJobInfoData.getJobName());
JobDetail jobDetail = JobBuilder.newJob(QuartzJobLauncher.class)
.withIdentity(batchJobInfoData.getJobName(), batchJobInfoData.getJobGroup())
.setJobData(jobDataMap)
.storeDurably(true)
.withDescription(batchJobInfoData.getDescription())
.build();
CronTrigger trigger = TriggerBuilder.newTrigger()
.forJob(jobDetail)
.withIdentity(batchJobInfoData.getJobName() + "Trigger", batchJobInfoData.getJobGroup())
.withSchedule(CronScheduleBuilder.cronSchedule(batchJobInfoData.getCronExpression()))
.withDescription(batchJobInfoData.getDescription())
.build();
try {
scheduler.scheduleJob(jobDetail, trigger);
} catch (SchedulerException e) {
throw new IllegalStateException("Error scheduling AbstractBatchJob: " + batchJobInfoData.getJobName(), e);
}
private String removeScopedTargetPrefix(String beanName) {
return beanName.startsWith("scopedTarget.") ? beanName.substring("scopedTarget.".length()) : beanName;
}
}

View File

@@ -0,0 +1,60 @@
package com.spring.infra.quartz;
import org.quartz.CronScheduleBuilder;
import org.quartz.CronTrigger;
import org.quartz.JobBuilder;
import org.quartz.JobDataMap;
import org.quartz.JobDetail;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.TriggerBuilder;
import org.springframework.stereotype.Service;
import com.spring.infra.batch.BatchJobInfoData;
import com.spring.infra.batch.BatchJobInfoService;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class QuartzScheduleService {
private final Scheduler scheduler;
private final BatchJobInfoService batchJobInfoService;
public void clearJobs() {
try {
scheduler.clear();
} catch (SchedulerException e) {
throw new IllegalStateException();
}
}
public void createJobTrigger(String beanName) {
BatchJobInfoData batchJobInfoData = batchJobInfoService.getBatchJobInfo(beanName);
JobDataMap jobDataMap = new JobDataMap();
jobDataMap.put("jobName", batchJobInfoData.getJobName());
JobDetail jobDetail = JobBuilder.newJob(QuartzJobLauncher.class)
.withIdentity(batchJobInfoData.getJobName(), batchJobInfoData.getJobGroup())
.setJobData(jobDataMap)
.storeDurably(true)
.withDescription(batchJobInfoData.getDescription())
.build();
CronTrigger trigger = TriggerBuilder.newTrigger()
.forJob(jobDetail)
.withIdentity(batchJobInfoData.getJobName() + "Trigger", batchJobInfoData.getJobGroup())
.withSchedule(CronScheduleBuilder.cronSchedule(batchJobInfoData.getCronExpression()))
.withDescription(batchJobInfoData.getDescription())
.build();
try {
scheduler.scheduleJob(jobDetail, trigger);
} catch (SchedulerException e) {
throw new IllegalStateException("Error scheduling Quartz job: " + batchJobInfoData.getJobName(), e);
}
}
}

View File

@@ -163,5 +163,65 @@
"name": "front.base.timeout",
"type": "java.lang.String",
"description": "A description for 'front.base.timeout'"
},
{
"name": "batch-info.email-send-batch.group",
"type": "java.lang.String",
"description": "A description for 'batch-info.email-send-batch.group'"
},
{
"name": "batch-info.email-send-batch.job-name",
"type": "java.lang.String",
"description": "A description for 'batch-info.email-send-batch.job-name'"
},
{
"name": "batch-info.email-send-batch.cron-expression",
"type": "java.lang.String",
"description": "A description for 'batch-info.email-send-batch.cron-expression'"
},
{
"name": "batch-info.email-send-batch.description",
"type": "java.lang.String",
"description": "A description for 'batch-info.email-send-batch.description'"
},
{
"name": "batch-info.post-batch.group",
"type": "java.lang.String",
"description": "A description for 'batch-info.post-batch.group'"
},
{
"name": "batch-info.post-batch.job-name",
"type": "java.lang.String",
"description": "A description for 'batch-info.post-batch.job-name'"
},
{
"name": "batch-info.post-batch.cron-expression",
"type": "java.lang.String",
"description": "A description for 'batch-info.post-batch.cron-expression'"
},
{
"name": "batch-info.post-batch.description",
"type": "java.lang.String",
"description": "A description for 'batch-info.post-batch.description'"
},
{
"name": "batch-info.post-create-batch.group",
"type": "java.lang.String",
"description": "A description for 'batch-info.post-create-batch.group'"
},
{
"name": "batch-info.post-create-batch.job-name",
"type": "java.lang.String",
"description": "A description for 'batch-info.post-create-batch.job-name'"
},
{
"name": "batch-info.post-create-batch.cron-expression",
"type": "java.lang.String",
"description": "A description for 'batch-info.post-create-batch.cron-expression'"
},
{
"name": "batch-info.post-create-batch.description",
"type": "java.lang.String",
"description": "A description for 'batch-info.post-create-batch.description'"
}
]}

View File

@@ -2,6 +2,13 @@ server:
port: 8081
spring:
cloud:
refresh:
enabled: true
devtools:
restart:
enabled: false
add-properties: false
application:
name: spring-batch-quartz
datasource:
@@ -95,7 +102,7 @@ batch-info:
email-send-batch:
group: "EMAIL"
job-name: "emailSendJob"
cron-expression: "*/10 * * * * ?"
cron-expression: "*/20 * * * * ?"
description: "이메일배치작업"
post-batch:
group: "POST"
@@ -105,7 +112,7 @@ batch-info:
post-create-batch:
group: "POST"
job-name: "postCreateJob"
cron-expression: "0/50 * * * * ?"
cron-expression: "0/30 * * * * ?"
description: "테스트배치작업"
jwt:
@@ -116,6 +123,12 @@ jwt:
secret: bnhjdXMyLjAtcGxhdGZvcm0tcHJvamVjdC13aXRoLXNwcmluZy1ib290bnhjdF9zdHJvbmdfY29tcGxleF9zdHJvbmc=
expiration: 10080
management:
endpoints:
web:
exposure:
include: refresh
logging:
level:
org:

View File

@@ -34,6 +34,11 @@ const scheduleService = {
cronExpression
});
return response.data.data;
},
refreshJob: async () => {
await apiClient.post('/actuator/refresh');
return true;
}
};

View File

@@ -29,6 +29,11 @@ apiClient.interceptors.request.use(
apiClient.interceptors.response.use(
(response) => {
const authHeader = response.headers['authorization'];
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
saveAccessToken(token);
}
return response;
},
async (error) => {
@@ -49,6 +54,7 @@ apiClient.interceptors.response.use(
}
if (status === 401) {
removeTokens();
location.href = "/";
}
return new Promise(() => {});

View File

@@ -9,6 +9,11 @@ document.addEventListener('DOMContentLoaded', () => {
e.preventDefault();
fetchDataAndRender();
});
const refreshJobBtn = document.getElementById('refreshJobBtn');
refreshJobBtn.addEventListener('click', refreshJobs);
manageTooltips.init();
});
const fetchDataAndRender = async () => {
@@ -19,6 +24,15 @@ const fetchDataAndRender = async () => {
updateTable(response);
};
const refreshJobs = async () => {
const result = await scheduleService.refreshJob();
if (result) {
alert('스케줄이 재적용 되었습니다.');
manageTooltips.hideAll();
fetchDataAndRender();
}
};
const updateTable = (jobs) => {
const tableBody = document.querySelector('tbody');
tableBody.innerHTML = jobs.map(job => `
@@ -64,10 +78,10 @@ const showJobDetail = async (e) => {
updateJobControlButtons(jobDetail.status);
document.getElementById('pauseJobBtn').onclick = () => {
pauseJob(group, name).then(() => updateJobStatus(group, name, 'PAUSED'));
scheduleService.pauseJob(group, name).then(() => updateJobStatus(group, name, 'PAUSED'));
};
document.getElementById('resumeJobBtn').onclick = () => {
resumeJob(group, name).then(() => updateJobStatus(group, name, 'NORMAL'));
scheduleService.resumeJob(group, name).then(() => updateJobStatus(group, name, 'NORMAL'));
};
document.getElementById('updateCronBtn').onclick = () => {
updateCronExpression(group, name);
@@ -167,4 +181,15 @@ const updateTableJobStatus = (group, name, newStatus) => {
break;
}
}
};
const manageTooltips = {
init: () => {
const tooltipElements = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipElements.map(el => new bootstrap.Tooltip(el));
},
hideAll: () => {
const tooltip = bootstrap.Tooltip.getInstance('#refreshJobBtn');
setTimeout(() => { tooltip.hide(); }, 100);
}
};

View File

@@ -3,7 +3,7 @@
<header id="header" class="header fixed-top d-flex align-items-center">
<div class="d-flex align-items-center justify-content-between">
<a href="/" class="logo d-flex align-items-center">
<span class="d-none d-lg-block">NXCUS - Agent2.0</span>
<span class="d-none d-lg-block">NXCUS - Agent</span>
</a>
<button class="btn toggle-sidebar-btn" id="toggleSidebar" aria-label="Toggle Sidebar">
<i class="bi bi-list"></i>

View File

@@ -82,14 +82,14 @@
<i class="bi bi-clock-history me-2"></i>최근 실행된 작업
</div>
<div class="card-body">
<div class="table-responsive">
<div class="table-responsive" style="max-height: 300px; overflow-y: auto;">
<table class="table table-hover">
<thead>
<tr>
<th><i class="bi bi-people"></i> 그룹명</th>
<th><i class="bi bi-briefcase"></i> 잡 이름</th>
<th><i class="bi bi-calendar-event me-2"></i> 실행 시간</th>
<th><i class="bi bi-activity"></i> 상태</th>
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-people"></i> 그룹명</th>
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-briefcase"></i> 잡 이름</th>
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-calendar-event me-2"></i> 실행 시간</th>
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-activity"></i> 상태</th>
</tr>
</thead>
<tbody id="recentJobsTable">

View File

@@ -28,7 +28,7 @@
<div class="col-lg-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">
<h5 class="card-title fs-6 text-dark">
<i class="bi bi-search"></i> 스케줄 검색
</h5>
<form id="searchForm" class="row g-3">
@@ -54,18 +54,21 @@
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-list-ul"></i> 스케줄 목록
<h5 class="card-title d-flex justify-content-between align-items-center">
<span class="fs-6 text-dark"><i class="bi bi-list-ul"></i> 스케줄 목록</span>
<button id="refreshJobBtn" class="btn btn-sm btn-outline-primary" data-bs-toggle="tooltip" data-bs-placement="left" title="스케줄 재적용">
<i class="bi bi-arrow-clockwise"></i>
</button>
</h5>
<div class="table-responsive">
<div class="table-responsive" style="max-height: 450px; overflow-y: auto;">
<table class="table table-hover">
<thead>
<tr>
<th class="col-1 text-nowrap"><i class="bi bi-people"></i> 그룹</th>
<th class="col-1 text-nowrap"><i class="bi bi-briefcase"></i> 잡 이름</th>
<th class="col-1 text-nowrap"><i class="bi bi-calendar-event"></i> 스케줄</th>
<th class="col-1 text-nowrap"><i class="bi bi-activity"></i> 상태</th>
<th class="col-1 text-nowrap"><i class="bi bi-gear"></i> 액션</th>
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-people"></i> 그룹</th>
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-briefcase"></i> 잡 이름</th>
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-calendar-event"></i> 스케줄</th>
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-activity"></i> 상태</th>
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-gear"></i> 액션</th>
</tr>
</thead>
<tbody>

View File

@@ -14,7 +14,7 @@
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="bi bi-person-circle text-primary" style="font-size: 3rem;"></i>
<h2 class="card-title mt-3">로그인</h2>
<h2 class="card-title mt-3">Sign in to NXCUS-Agent</h2>
</div>
<div class="mb-3">
<div class="input-group">
@@ -34,10 +34,10 @@
</div>
<div class="d-grid gap-2">
<button id="signIn" type="button" class="btn btn-primary">
<i class="bi bi-box-arrow-in-right me-2"></i>로그인
<i class="bi bi-box-arrow-in-right me-2"></i>Sign in
</button>
<button id="signUp" type="button" class="btn btn-outline-secondary">
<i class="bi bi-person-plus-fill me-2"></i>회원가입
<i class="bi bi-person-plus-fill me-2"></i>Sign up
</button>
</div>
</div>
@@ -51,7 +51,7 @@
<div class="modal-dialog modal-dialog-scrollable modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-dark text-white">
<h5 class="modal-title" id="signupModalLabel">회원가입</h5>
<h5 class="modal-title" id="signupModalLabel">Join Membership</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="signupForm">
@@ -85,10 +85,10 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle me-2"></i>취소
<i class="bi bi-x-circle me-2"></i>Cancel
</button>
<button type="submit" class="btn btn-outline-primary" id="signupSubmit">
<i class="bi bi-check-circle me-2"></i>가입하기
<i class="bi bi-check-circle me-2"></i>Join
</button>
</div>
</form>