From 23e1641644226269171affb2b6829af16b2fef2c Mon Sep 17 00:00:00 2001 From: mindol1004 Date: Thu, 31 Oct 2024 17:50:03 +0900 Subject: [PATCH] commit --- batch-quartz/pom.xml | 8 + .../spring/common/error/BizBaseException.java | 5 + .../common/error/GlobalExceptionHandler.java | 15 ++ .../common/properties/PathProperties.java | 34 ++++ .../com/spring/common/util/ProfileUtils.java | 97 +++++++++ .../domain/email/batch/EmailSendBatch.java | 9 +- .../email/service/EmailSendService.java | 25 +++ .../domain/post/batch/PostCreateBatch.java | 46 ++--- .../post/batch/PostCreateBatchChunk.java | 174 ++++++++-------- .../spring/infra/batch/AbstractBatchTask.java | 9 + .../infra/batch/BatchExceptionListener.java | 82 ++++++++ .../feign/client/Client1FeignClient.java | 17 ++ .../infra/feign/config/CommonFeignConfig.java | 160 +++++++++++++++ .../feign/config/FeignClientProperties.java | 50 +++++ .../spring/infra/feign/dto/TestRequest.java | 9 + .../com/spring/infra/quartz/QuartzConfig.java | 2 +- .../infra/quartz/QuartzShutdownListener.java | 190 ++++++++++++++++++ .../src/main/resources/application.yml | 41 +++- 18 files changed, 857 insertions(+), 116 deletions(-) create mode 100644 batch-quartz/src/main/java/com/spring/common/properties/PathProperties.java create mode 100644 batch-quartz/src/main/java/com/spring/common/util/ProfileUtils.java create mode 100644 batch-quartz/src/main/java/com/spring/domain/email/service/EmailSendService.java create mode 100644 batch-quartz/src/main/java/com/spring/infra/batch/BatchExceptionListener.java create mode 100644 batch-quartz/src/main/java/com/spring/infra/feign/client/Client1FeignClient.java create mode 100644 batch-quartz/src/main/java/com/spring/infra/feign/config/CommonFeignConfig.java create mode 100644 batch-quartz/src/main/java/com/spring/infra/feign/config/FeignClientProperties.java create mode 100644 batch-quartz/src/main/java/com/spring/infra/feign/dto/TestRequest.java create mode 100644 batch-quartz/src/main/java/com/spring/infra/quartz/QuartzShutdownListener.java diff --git a/batch-quartz/pom.xml b/batch-quartz/pom.xml index 64f715a..dc710bb 100644 --- a/batch-quartz/pom.xml +++ b/batch-quartz/pom.xml @@ -42,6 +42,14 @@ org.springframework.cloud spring-cloud-starter + + org.springframework.cloud + spring-cloud-starter-openfeign + + + io.github.openfeign + feign-okhttp + org.springframework.boot spring-boot-configuration-processor diff --git a/batch-quartz/src/main/java/com/spring/common/error/BizBaseException.java b/batch-quartz/src/main/java/com/spring/common/error/BizBaseException.java index fa372ea..9082e1a 100644 --- a/batch-quartz/src/main/java/com/spring/common/error/BizBaseException.java +++ b/batch-quartz/src/main/java/com/spring/common/error/BizBaseException.java @@ -43,5 +43,10 @@ public class BizBaseException extends RuntimeException { super(exceptionRule.getMessage()); this.errorRule = exceptionRule; } + + public BizBaseException(String message) { + super(message); + this.errorRule = ExceptionRule.SYSTEM_ERROR.message(message); + } } diff --git a/batch-quartz/src/main/java/com/spring/common/error/GlobalExceptionHandler.java b/batch-quartz/src/main/java/com/spring/common/error/GlobalExceptionHandler.java index dc4293c..e7087a2 100644 --- a/batch-quartz/src/main/java/com/spring/common/error/GlobalExceptionHandler.java +++ b/batch-quartz/src/main/java/com/spring/common/error/GlobalExceptionHandler.java @@ -1,5 +1,8 @@ package com.spring.common.error; +import java.util.Optional; + +import org.springframework.dao.DataAccessException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -45,5 +48,17 @@ public class GlobalExceptionHandler { public BizErrorResponse methodArgumentNotValidException(MethodArgumentNotValidException e) { return BizErrorResponse.fromFieldError(e.getFieldErrors()); } + + @ExceptionHandler(DataAccessException.class) + public BizErrorResponse handleDataAccessException(DataAccessException e) { + Throwable cause = e.getMostSpecificCause(); + return BizErrorResponse.valueOf( + new BizBaseException( + Optional.ofNullable(cause) + .map(Throwable::getMessage) + .orElse(ExceptionRule.SYSTEM_ERROR.getMessage())) + .getErrorRule() + ); + } } diff --git a/batch-quartz/src/main/java/com/spring/common/properties/PathProperties.java b/batch-quartz/src/main/java/com/spring/common/properties/PathProperties.java new file mode 100644 index 0000000..701ef91 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/properties/PathProperties.java @@ -0,0 +1,34 @@ +package com.spring.common.properties; + +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; +import org.springframework.boot.context.properties.bind.DefaultValue; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@ConstructorBinding +@ConfigurationProperties(prefix = "path") +@RequiredArgsConstructor +public class PathProperties { + + private final Map paths; + + @Getter + public static class PathConfig { + private final String upload; + private final String dowonload; + + public PathConfig( + @DefaultValue("") String upload, + @DefaultValue("") String dowonload + ) { + this.upload = upload; + this.dowonload = dowonload; + } + } + +} diff --git a/batch-quartz/src/main/java/com/spring/common/util/ProfileUtils.java b/batch-quartz/src/main/java/com/spring/common/util/ProfileUtils.java new file mode 100644 index 0000000..f06bb16 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/common/util/ProfileUtils.java @@ -0,0 +1,97 @@ +package com.spring.common.util; + +import java.util.Arrays; + +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ProfileUtils { + + private final Environment environment; + + /** + * 현재 활성화된 프로파일 목록을 반환합니다. + * + * @return 현재 활성화된 프로파일 배열 + */ + public String[] getActiveProfiles() { + return environment.getActiveProfiles(); + } + + /** + * 현재 프로파일이 'prod'인지 확인하는 메소드. + * + * @return true if the current profile is 'prod', false otherwise. + */ + public boolean isProdProfile() { + return isProfileActive("prod"); + } + + /** + * 현재 프로파일이 'dev'인지 확인하는 메소드. + * + * @return true if the current profile is 'dev', false otherwise. + */ + public boolean isDevProfile() { + return isProfileActive("dev"); + } + + /** + * 특정 프로파일이 활성화되어 있는지 확인하는 메소드. + * + * @param profile 확인할 프로파일 이름 + * @return true if the specified profile is active, false otherwise. + */ + public boolean isProfileActive(String profile) { + return Arrays.asList(environment.getActiveProfiles()).contains(profile); + } + + /** + * 현재 활성화된 프로파일이 여러 개일 경우, 그 중 하나라도 주어진 프로파일이 포함되어 있는지 확인하는 메소드. + * + * @param profiles 확인할 프로파일 이름 배열 + * @return true if any of the specified profiles are active, false otherwise. + */ + public boolean isAnyProfileActive(String... profiles) { + for (String profile : profiles) { + if (isProfileActive(profile)) { + return true; + } + } + return false; + } + + /** + * 현재 활성화된 프로파일 중 가장 우선순위가 높은 프로파일을 반환합니다. + * + * @return 가장 우선순위가 높은 프로파일 이름, 없으면 null + */ + public String getHighestPriorityProfile() { + String[] activeProfiles = getActiveProfiles(); + return activeProfiles.length > 0 ? activeProfiles[0] : null; // 첫 번째 프로파일이 가장 우선순위가 높다고 가정 + } + + /** + * 기본 프로파일이 설정되어 있는지 확인하는 메소드. + * + * @return true if the default profile is active, false otherwise. + */ + public boolean isDefaultProfileActive() { + return isProfileActive("default"); + } + + /** + * 주어진 프로파일이 활성화되어 있지 않은지 확인하는 메소드. + * + * @param profile 확인할 프로파일 이름 + * @return true if the specified profile is not active, false otherwise. + */ + public boolean isProfileInactive(String profile) { + return !isProfileActive(profile); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/domain/email/batch/EmailSendBatch.java b/batch-quartz/src/main/java/com/spring/domain/email/batch/EmailSendBatch.java index 4af4bc8..a702f8d 100644 --- a/batch-quartz/src/main/java/com/spring/domain/email/batch/EmailSendBatch.java +++ b/batch-quartz/src/main/java/com/spring/domain/email/batch/EmailSendBatch.java @@ -7,6 +7,7 @@ import org.springframework.batch.core.step.tasklet.Tasklet; import org.springframework.batch.repeat.RepeatStatus; import org.springframework.stereotype.Component; +import com.spring.domain.email.service.EmailSendService; import com.spring.domain.post.repository.PostRepository; import com.spring.infra.batch.AbstractBatchTask; import com.spring.infra.batch.BatchJobInfo; @@ -26,10 +27,11 @@ import lombok.extern.slf4j.Slf4j; public class EmailSendBatch extends AbstractBatchTask { private final PostRepository postRepository; + private final EmailSendService emailSendService; @Override protected List createSteps() { - log.info("EmailSendBatch -> createSteps"); + // log.info("EmailSendBatch -> createSteps"); return List.of( addStep("emailSendJobStep1", createTasklet()), addStep("emailSendJobStep2", createSendTasklet()) @@ -40,13 +42,14 @@ public class EmailSendBatch extends AbstractBatchTask { protected Tasklet createTasklet() { log.info("EmailSendBatch -> createTasklet"); return ((contribution, chunkContext) -> { - postRepository.findAll(); + emailSendService.sendEmail(); + // postRepository.findAll(); return RepeatStatus.FINISHED; }); } private Tasklet createSendTasklet() { - log.info("EmailSendBatch -> createSendTasklet"); + // log.info("EmailSendBatch -> createSendTasklet"); return ((contribution, chunkContext) -> RepeatStatus.FINISHED); } diff --git a/batch-quartz/src/main/java/com/spring/domain/email/service/EmailSendService.java b/batch-quartz/src/main/java/com/spring/domain/email/service/EmailSendService.java new file mode 100644 index 0000000..698d95a --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/domain/email/service/EmailSendService.java @@ -0,0 +1,25 @@ +package com.spring.domain.email.service; + +import org.springframework.stereotype.Service; + +import com.spring.common.properties.PathProperties; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailSendService { + + private final PathProperties pathProperties; + + public void sendEmail() { + log.info("pathProperties: {}", pathProperties); + log.info("paths: {}", pathProperties.getPaths()); + + String upload = pathProperties.getPaths().get("path1").getUpload(); + log.info(upload); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/domain/post/batch/PostCreateBatch.java b/batch-quartz/src/main/java/com/spring/domain/post/batch/PostCreateBatch.java index cdf209d..044a9bd 100644 --- a/batch-quartz/src/main/java/com/spring/domain/post/batch/PostCreateBatch.java +++ b/batch-quartz/src/main/java/com/spring/domain/post/batch/PostCreateBatch.java @@ -16,31 +16,31 @@ import com.spring.infra.db.orm.jpa.SecondaryJpaConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -@Slf4j -@Component -@BatchJobInfo( - group = "${batch-info.post-batch.group}", - jobName = "${batch-info.post-batch.job-name}", - cronExpression = "${batch-info.post-batch.cron-expression}", - description = "${batch-info.post-batch.description}" -) -@RequiredArgsConstructor -public class PostCreateBatch extends AbstractBatchTask { +// @Slf4j +// @Component +// @BatchJobInfo( +// group = "${batch-info.post-batch.group}", +// jobName = "${batch-info.post-batch.job-name}", +// cronExpression = "${batch-info.post-batch.cron-expression}", +// description = "${batch-info.post-batch.description}" +// ) +// @RequiredArgsConstructor +public class PostCreateBatch { //extends AbstractBatchTask { - private final PostMapper postMapper; + // private final PostMapper postMapper; - @Autowired - @Override - public void setTransactionManager(@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER) PlatformTransactionManager transactionManager) { - super.setTransactionManager(transactionManager); - } + // @Autowired + // @Override + // public void setTransactionManager(@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER) PlatformTransactionManager transactionManager) { + // super.setTransactionManager(transactionManager); + // } - @Override - protected Tasklet createTasklet() { - return ((contribution, chunkContext) -> { - postMapper.save(Post.builder().title("testTitle").content("testPost").build()); - return RepeatStatus.FINISHED; - }); - } + // @Override + // protected Tasklet createTasklet() { + // return ((contribution, chunkContext) -> { + // postMapper.save(Post.builder().title("testTitle").content("testPost").build()); + // return RepeatStatus.FINISHED; + // }); + // } } diff --git a/batch-quartz/src/main/java/com/spring/domain/post/batch/PostCreateBatchChunk.java b/batch-quartz/src/main/java/com/spring/domain/post/batch/PostCreateBatchChunk.java index 57f4435..4b1bc42 100644 --- a/batch-quartz/src/main/java/com/spring/domain/post/batch/PostCreateBatchChunk.java +++ b/batch-quartz/src/main/java/com/spring/domain/post/batch/PostCreateBatchChunk.java @@ -36,106 +36,106 @@ import com.spring.infra.db.orm.jpa.SecondaryJpaConfig; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -@Slf4j -@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 extends AbstractBatchChunk { +// @Slf4j +// @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 { //extends AbstractBatchChunk { - @Autowired - @Override - public void setTransactionManager(@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER) PlatformTransactionManager transactionManager) { - super.setTransactionManager(transactionManager); - } + // @Autowired + // @Override + // public void setTransactionManager(@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER) PlatformTransactionManager transactionManager) { + // super.setTransactionManager(transactionManager); + // } - @Qualifier(SecondaryJpaConfig.ENTITY_MANAGER_FACTORY) - private final EntityManagerFactory entityManagerFactory; + // @Qualifier(SecondaryJpaConfig.ENTITY_MANAGER_FACTORY) + // private final EntityManagerFactory entityManagerFactory; - private final PostRepository postRepository; - private final PostBackUpRepository postBackUpRepository; + // private final PostRepository postRepository; + // private final PostBackUpRepository postBackUpRepository; - private List list = new ArrayList<>(); + // private List list = new ArrayList<>(); - @Override - public Job createJob() { - return new JobBuilder(batchJobInfoData.getJobName()) - .repository(jobRepository) - .incrementer(new RunIdIncrementer()) - .start(readListStep()) - .next(decider()) - .from(decider()).on("PROCESS").to(processStep()) - .from(decider()).on("TERMINATE").to(terminateStep()) - .end() - .build(); - } + // @Override + // public Job createJob() { + // return new JobBuilder(batchJobInfoData.getJobName()) + // .repository(jobRepository) + // .incrementer(new RunIdIncrementer()) + // .start(readListStep()) + // .next(decider()) + // .from(decider()).on("PROCESS").to(processStep()) + // .from(decider()).on("TERMINATE").to(terminateStep()) + // .end() + // .build(); + // } - private JobExecutionDecider decider() { - return (JobExecution jobExecution, StepExecution stepExecution) -> - !list.isEmpty() ? new FlowExecutionStatus("PROCESS") : new FlowExecutionStatus("TERMINATE"); - } + // private JobExecutionDecider decider() { + // return (JobExecution jobExecution, StepExecution stepExecution) -> + // !list.isEmpty() ? new FlowExecutionStatus("PROCESS") : new FlowExecutionStatus("TERMINATE"); + // } - private Step readListStep() { - return new StepBuilder("readListStep") - .repository(jobRepository) - .transactionManager(transactionManager) - .tasklet(readListTasklet()) - .build(); - } + // private Step readListStep() { + // return new StepBuilder("readListStep") + // .repository(jobRepository) + // .transactionManager(transactionManager) + // .tasklet(readListTasklet()) + // .build(); + // } - private Tasklet readListTasklet() { - return (contribution, chunkContext) -> { - list = postRepository.findAll(); - return RepeatStatus.FINISHED; - }; - } + // private Tasklet readListTasklet() { + // return (contribution, chunkContext) -> { + // list = postRepository.findAll(); + // return RepeatStatus.FINISHED; + // }; + // } - private Step processStep() { - return new StepBuilder("processStep") - .repository(jobRepository) - .transactionManager(transactionManager) - .chunk(5) - .reader(testReader()) - .processor(testProcessor()) - .writer(testWriter()) - .build(); - } + // private Step processStep() { + // return new StepBuilder("processStep") + // .repository(jobRepository) + // .transactionManager(transactionManager) + // .chunk(5) + // .reader(testReader()) + // .processor(testProcessor()) + // .writer(testWriter()) + // .build(); + // } - private JpaPagingItemReader testReader() { - return new JpaPagingItemReaderBuilder() - .name("testReader") - .entityManagerFactory(entityManagerFactory) - .pageSize(5) - .queryString("select p from Post p") - .build(); - } + // private JpaPagingItemReader testReader() { + // return new JpaPagingItemReaderBuilder() + // .name("testReader") + // .entityManagerFactory(entityManagerFactory) + // .pageSize(5) + // .queryString("select p from Post p") + // .build(); + // } - private ItemProcessor testProcessor() { - return post -> - PostBackUp.builder().postId(post.getPostId()).content(post.getContent()).title(post.getTitle()).build(); - } + // private ItemProcessor testProcessor() { + // return post -> + // PostBackUp.builder().postId(post.getPostId()).content(post.getContent()).title(post.getTitle()).build(); + // } - private ItemWriter testWriter() { - return postBackUpRepository::saveAll; - } + // private ItemWriter testWriter() { + // return postBackUpRepository::saveAll; + // } - private Step terminateStep() { - return new StepBuilder("terminateStep") - .repository(jobRepository) - .transactionManager(transactionManager) - .tasklet(terminateTasklet()) - .build(); - } + // private Step terminateStep() { + // return new StepBuilder("terminateStep") + // .repository(jobRepository) + // .transactionManager(transactionManager) + // .tasklet(terminateTasklet()) + // .build(); + // } - private Tasklet terminateTasklet() { - return (contribution, chunkContext) -> { - log.error("List Read Error : List is null"); - return RepeatStatus.FINISHED; - }; - } + // private Tasklet terminateTasklet() { + // return (contribution, chunkContext) -> { + // log.error("List Read Error : List is null"); + // return RepeatStatus.FINISHED; + // }; + // } } diff --git a/batch-quartz/src/main/java/com/spring/infra/batch/AbstractBatchTask.java b/batch-quartz/src/main/java/com/spring/infra/batch/AbstractBatchTask.java index ca78c8f..bae9885 100644 --- a/batch-quartz/src/main/java/com/spring/infra/batch/AbstractBatchTask.java +++ b/batch-quartz/src/main/java/com/spring/infra/batch/AbstractBatchTask.java @@ -79,6 +79,8 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon */ private PlatformTransactionManager transactionManager; + private BatchExceptionListener batchExceptionListener; + /** * 기본 생성자입니다. * @@ -163,6 +165,11 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon this.transactionManager = transactionManager; } + @Autowired + public void setBatchExceptionListener(BatchExceptionListener batchExceptionListener) { + this.batchExceptionListener = batchExceptionListener; + } + /** * 배치 작업을 생성합니다. * @@ -180,6 +187,7 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon var jobBuilder = new JobBuilder(batchJobInfoData.getJobName()) .incrementer(new RunIdIncrementer()) .repository(jobRepository) + .listener(batchExceptionListener) .start(steps.get(0)); for (int i = 1; i < steps.size(); i++) { jobBuilder = jobBuilder.next(steps.get(i)); @@ -213,6 +221,7 @@ public abstract class AbstractBatchTask implements AbstractBatch, ApplicationCon return new StepBuilder(stepName) .repository(jobRepository) .transactionManager(transactionManager) + .listener(batchExceptionListener) .tasklet(tasklet) .build(); } diff --git a/batch-quartz/src/main/java/com/spring/infra/batch/BatchExceptionListener.java b/batch-quartz/src/main/java/com/spring/infra/batch/BatchExceptionListener.java new file mode 100644 index 0000000..529d38b --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/infra/batch/BatchExceptionListener.java @@ -0,0 +1,82 @@ +package com.spring.infra.batch; + +import java.util.List; + +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.dao.DataAccessException; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class BatchExceptionListener implements JobExecutionListener, StepExecutionListener { + + @Override + public void beforeJob(@NonNull JobExecution jobExecution) { + log.info("Job 시작: {}", jobExecution.getJobInstance().getJobName()); + } + + @Override + public void afterJob(@NonNull JobExecution jobExecution) { + if (jobExecution.getStatus() == BatchStatus.FAILED) { + handleJobFailure(jobExecution); + } + } + + @Override + public void beforeStep(@NonNull StepExecution stepExecution) { + log.info("Step 시작: {}", stepExecution.getStepName()); + } + + @Override + @Nullable + public ExitStatus afterStep(@NonNull StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + Throwable ex = stepExecution.getFailureExceptions().get(0); + + if (ex instanceof DataAccessException) { + // SQL 예외 처리 + DataAccessException sqlEx = (DataAccessException) ex; + log.error("SQL 오류 발생: {}", sqlEx.getMostSpecificCause().getMessage()); + return ExitStatus.FAILED; + } else { + // 기타 예외 처리 + log.error("예외 발생: ", ex); + return ExitStatus.FAILED; + } + } + return stepExecution.getExitStatus(); + } + + private void handleJobFailure(JobExecution jobExecution) { + List exceptions = jobExecution.getAllFailureExceptions(); + for (Throwable ex : exceptions) { + if (ex instanceof DataAccessException) { + handleSqlException((DataAccessException) ex, jobExecution); + } else { + handleGeneralException(ex, jobExecution); + } + } + } + + private void handleSqlException(DataAccessException ex, JobExecution jobExecution) { + log.error("Job {} 실행 중 SQL 오류 발생: {}", + jobExecution.getJobInstance().getJobName(), + ex.getMostSpecificCause().getMessage()); + } + + private void handleGeneralException(Throwable ex, JobExecution jobExecution) { + log.error("Job {} 실행 중 오류 발생: {}", + jobExecution.getJobInstance().getJobName(), + ex.getMessage()); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/infra/feign/client/Client1FeignClient.java b/batch-quartz/src/main/java/com/spring/infra/feign/client/Client1FeignClient.java new file mode 100644 index 0000000..5461768 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/infra/feign/client/Client1FeignClient.java @@ -0,0 +1,17 @@ +package com.spring.infra.feign.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.cloud.openfeign.SpringQueryMap; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; + +import com.spring.infra.feign.config.CommonFeignConfig; +import com.spring.infra.feign.dto.TestRequest; + +@FeignClient(name = "client1", url = "${feign.clients.client1.url}", configuration = CommonFeignConfig.class) +public interface Client1FeignClient { + + @PostMapping(value = "/test", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + String getItemById(@SpringQueryMap TestRequest request); + +} diff --git a/batch-quartz/src/main/java/com/spring/infra/feign/config/CommonFeignConfig.java b/batch-quartz/src/main/java/com/spring/infra/feign/config/CommonFeignConfig.java new file mode 100644 index 0000000..cb89862 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/infra/feign/config/CommonFeignConfig.java @@ -0,0 +1,160 @@ +package com.spring.infra.feign.config; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.springframework.cloud.openfeign.FeignFormatterRegistrar; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar; +import org.springframework.http.MediaType; + +import feign.Client; +import feign.Logger; +import feign.Request; +import feign.RequestInterceptor; +import feign.Response; +import feign.RetryableException; +import feign.Retryer; +import feign.codec.ErrorDecoder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.OkHttpClient; +import okhttp3.RequestBody; + +/** + * Feign 클라이언트에 대한 공통 설정을 제공하는 설정 클래스입니다. + * 이 클래스는 기본 Feign 빌더 설정과 클라이언트별 커스텀 설정을 제공합니다. + * + * @author mindol + * @version 1.0 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class CommonFeignConfig { + + private final FeignClientProperties properties; + + @Bean + Client feignClient() { + return new Client() { + @Override + public Response execute(Request request, Request.Options options) throws IOException { + String clientName = request.requestTemplate().feignTarget().name(); + FeignClientProperties.ClientConfig config = properties.getClients().get(clientName); + + OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder() + .connectTimeout(config.getConnectTimeout(), TimeUnit.MILLISECONDS) + .readTimeout(config.getReadTimeout(), TimeUnit.MILLISECONDS); + + if (config.isUseProxy()) { + clientBuilder.proxy(new Proxy( + Proxy.Type.HTTP, + new InetSocketAddress(config.getProxyHost(), config.getProxyPort()) + )); + } + + OkHttpClient okHttpClient = clientBuilder.build(); + // OkHttp 요청 생성 + okhttp3.Request.Builder okHttpRequestBuilder = new okhttp3.Request.Builder() + .url(request.url()) + .method(request.httpMethod().name(), getRequestBody(request)); // 요청 본문 설정 + + // Feign 요청의 헤더를 OkHttp 요청에 추가 + request.headers().forEach((key, values) -> { + for (String value : values) { + okHttpRequestBuilder.addHeader(key, value); + } + }); + + okhttp3.Request okHttpRequest = okHttpRequestBuilder.build(); + + try (okhttp3.Response okHttpResponse = okHttpClient.newCall(okHttpRequest).execute()) { + return Response.builder() + .status(okHttpResponse.code()) + .reason(okHttpResponse.message()) + .headers(convertHeaders(okHttpResponse.headers())) + .body(okHttpResponse.body().bytes()) + .request(request) + .build(); + } + } + }; + } + + private Map> convertHeaders(okhttp3.Headers headers) { + Map> convertedHeaders = new HashMap<>(); + for (String name : headers.names()) { + convertedHeaders.put(name, headers.values(name)); + } + return convertedHeaders; + } + + private RequestBody getRequestBody(Request request) { + String contentType = MediaType.APPLICATION_JSON_VALUE; + + if (request.requestTemplate().headers().containsKey("Content-Type")) { + contentType = request.requestTemplate().headers().get("Content-Type").iterator().next(); + } + + if (request.body() != null) { + return RequestBody.create(request.body(), okhttp3.MediaType.parse(contentType)); + } else { + return RequestBody.create("", okhttp3.MediaType.parse(contentType)); + } + } + + @Bean + RequestInterceptor headerInterceptor() { + return requestTemplate -> { + String clientName = requestTemplate.feignTarget().name(); + if (properties.getClients().containsKey(clientName)) { + var config = properties.getClients().get(clientName); + if (!config.getHeaders().isEmpty()) { + config.getHeaders().forEach(requestTemplate::header); + } + } + }; + } + + @Bean + Retryer retryer() { + // 0.1초의 간격으로 시작해 최대 3초의 간격으로 점점 증가하며, 최대5번 재시도한다. + return new Retryer.Default(100L, TimeUnit.SECONDS.toMillis(3L), 5); + } + + @Bean + Logger.Level feignLoggerLevel() { + return Logger.Level.FULL; + } + + @Bean + FeignFormatterRegistrar dateTimeFormatterRegistrar() { + return registry -> { + DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar(); + registrar.setUseIsoFormat(true); + registrar.registerFormatters(registry); + }; + } + + @Bean + ErrorDecoder errorDecoder() { + return (methodKey, response) -> + (response.status() >= 500 && response.status() <= 504) + ? new RetryableException( + response.status(), + String.format("Server error %d: %s, retrying...", response.status(), response.reason()), + response.request().httpMethod(), + null, + response.request() + ) + : new Exception(String.format("Error %d: %s", response.status(), response.reason())); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/infra/feign/config/FeignClientProperties.java b/batch-quartz/src/main/java/com/spring/infra/feign/config/FeignClientProperties.java new file mode 100644 index 0000000..0cbb58b --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/infra/feign/config/FeignClientProperties.java @@ -0,0 +1,50 @@ +package com.spring.infra.feign.config; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; +import org.springframework.boot.context.properties.bind.DefaultValue; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@ConstructorBinding +@ConfigurationProperties(prefix = "feign") +@RequiredArgsConstructor +public class FeignClientProperties { + + private final Map clients; + + @Getter + public static class ClientConfig { + private final String url; + private final int connectTimeout; + private final int readTimeout; + private final String proxyHost; + private final int proxyPort; + private final boolean useProxy; + private final Map headers; + + public ClientConfig( + String url, + @DefaultValue("5000") int connectTimeout, + @DefaultValue("5000") int readTimeout, + @DefaultValue("") String proxyHost, + @DefaultValue("0") int proxyPort, + @DefaultValue("false") boolean useProxy, + Map headers + ) { + this.url = url; + this.connectTimeout = connectTimeout; + this.readTimeout = readTimeout; + this.proxyHost = proxyHost; + this.proxyPort = proxyPort; + this.useProxy = useProxy; + this.headers = headers != null ? headers : new HashMap<>(); + } + } + +} diff --git a/batch-quartz/src/main/java/com/spring/infra/feign/dto/TestRequest.java b/batch-quartz/src/main/java/com/spring/infra/feign/dto/TestRequest.java new file mode 100644 index 0000000..99d6743 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/infra/feign/dto/TestRequest.java @@ -0,0 +1,9 @@ +package com.spring.infra.feign.dto; + +import lombok.Getter; + +@Getter +public class TestRequest { + private String id; + private String name; +} diff --git a/batch-quartz/src/main/java/com/spring/infra/quartz/QuartzConfig.java b/batch-quartz/src/main/java/com/spring/infra/quartz/QuartzConfig.java index 0801a41..b4b2923 100644 --- a/batch-quartz/src/main/java/com/spring/infra/quartz/QuartzConfig.java +++ b/batch-quartz/src/main/java/com/spring/infra/quartz/QuartzConfig.java @@ -94,7 +94,7 @@ public class QuartzConfig { factory.setDataSource(dataSource); factory.setTransactionManager(transactionManager); factory.setJobFactory(jobFactory); - factory.setAutoStartup(false); + factory.setAutoStartup(true); factory.setWaitForJobsToCompleteOnShutdown(true); return factory; } diff --git a/batch-quartz/src/main/java/com/spring/infra/quartz/QuartzShutdownListener.java b/batch-quartz/src/main/java/com/spring/infra/quartz/QuartzShutdownListener.java new file mode 100644 index 0000000..21d8981 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/infra/quartz/QuartzShutdownListener.java @@ -0,0 +1,190 @@ +package com.spring.infra.quartz; + +import java.util.List; +import java.util.Set; + +import javax.annotation.PreDestroy; + +import org.quartz.JobExecutionContext; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.Trigger; +import org.quartz.TriggerKey; +import org.quartz.impl.matchers.GroupMatcher; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** +* Quartz 스케줄러의 안전한 종료를 관리하는 리스너 클래스 +* +*

이 클래스는 Spring 애플리케이션 컨텍스트가 종료될 때 Quartz 스케줄러의 모든 작업을 +* 안전하게 종료하고 정리하는 역할을 합니다. 실행 중인 작업이 정상적으로 완료될 때까지 +* 대기하며, 모든 트리거를 정리합니다.

+* +*

주요 기능:

+*
    +*
  • 실행 중인 작업의 안전한 종료 처리
  • +*
  • 트리거 상태 모니터링 및 정리
  • +*
  • 스케줄러 리소스의 정상적인 해제
  • +*
+* +* @author [작성자 이름] +* @version 1.0 +*/ +@Slf4j +@Component +@RequiredArgsConstructor +public class QuartzShutdownListener implements ApplicationListener { + + /** 작업 완료 대기 최대 시간 (밀리초) */ + private static final long SHUTDOWN_WAIT_TIME = 30000L; // 30초 + + /** Quartz 스케줄러 인스턴스 */ + private final Scheduler scheduler; + + + /** + * Spring 컨텍스트 종료 이벤트 발생 시 실행되는 메서드 + * + * @param event 컨텍스트 종료 이벤트 + */ + @Override + public void onApplicationEvent(@NonNull ContextClosedEvent event) { + log.info("Quartz 셧다운 프로세스 시작..."); + shutdownQuartzGracefully(); + } + + /** + * Quartz 스케줄러의 안전한 종료를 처리하는 메서드 + * + *

다음의 단계를 순차적으로 실행합니다:

+ *
    + *
  1. 새로운 작업 스케줄링 중지
  2. + *
  3. 현재 실행 중인 작업 목록 확인
  4. + *
  5. 실행 중인 작업의 상태 로깅
  6. + *
  7. 작업 완료 대기
  8. + *
  9. 트리거 정리
  10. + *
  11. 스케줄러 종료
  12. + *
+ */ + private void shutdownQuartzGracefully() { + try { + // 1. 새로운 작업 스케줄링 중지 + scheduler.standby(); + log.info("Scheduler를 대기 모드로 전환했습니다."); + + // 2. 현재 실행 중인 작업 목록 확인 + List currentJobs = scheduler.getCurrentlyExecutingJobs(); + if (!currentJobs.isEmpty()) { + log.info("현재 실행 중인 작업 수: {}", currentJobs.size()); + + // 3. 실행 중인 작업의 정보 로깅 + logRunningJobs(currentJobs); + + // 4. 실행 중인 작업이 완료될 때까지 대기 + waitForJobCompletion(currentJobs); + } + + // 5. 모든 트리거 상태 확인 및 정리 + cleanupTriggers(); + + // 6. 스케줄러 종료 + performSchedulerShutdown(); + + } catch (SchedulerException e) { + log.error("Quartz 종료 중 오류 발생", e); + Thread.currentThread().interrupt(); + } catch (InterruptedException e) { + log.error("Quartz 종료 대기 중 인터럽트 발생", e); + Thread.currentThread().interrupt(); + } + } + + /** + * 실행 중인 작업들의 상세 정보를 로깅 + * + * @param currentJobs 현재 실행 중인 작업 목록 + */ + private void logRunningJobs(List currentJobs) { + for (JobExecutionContext job : currentJobs) { + log.info("실행 중인 작업: {}, 트리거: {}, 시작 시간: {}", + job.getJobDetail().getKey(), + job.getTrigger().getKey(), + job.getFireTime() + ); + } + } + + /** + * 실행 중인 작업이 완료될 때까지 대기 + * + * @param currentJobs 현재 실행 중인 작업 목록 + * @throws InterruptedException 대기 중 인터럽트 발생 시 + * @throws SchedulerException 스케줄러 관련 오류 발생 시 + */ + private void waitForJobCompletion(List currentJobs) + throws InterruptedException, SchedulerException { + long startTime = System.currentTimeMillis(); + while (!currentJobs.isEmpty() && + (System.currentTimeMillis() - startTime) < SHUTDOWN_WAIT_TIME) { + log.info("실행 중인 작업이 완료될 때까지 대기 중..."); + Thread.sleep(1000); + currentJobs = scheduler.getCurrentlyExecutingJobs(); + } + } + + /** + * 모든 트리거를 정리 + * + * @throws SchedulerException 스케줄러 관련 오류 발생 시 + */ + private void cleanupTriggers() throws SchedulerException { + List groups = scheduler.getTriggerGroupNames(); + for (String group : groups) { + Set triggerKeys = scheduler.getTriggerKeys(GroupMatcher.groupEquals(group)); + for (TriggerKey triggerKey : triggerKeys) { + try { + Trigger trigger = scheduler.getTrigger(triggerKey); + if (trigger != null) { + scheduler.unscheduleJob(triggerKey); + log.info("트리거 제거됨: {}", triggerKey); + } + } catch (Exception e) { + log.error("트리거 제거 중 오류 발생: {}", triggerKey, e); + } + } + } + } + + /** + * 스케줄러 종료 수행 + * + * @throws SchedulerException 스케줄러 관련 오류 발생 시 + */ + private void performSchedulerShutdown() throws SchedulerException { + scheduler.clear(); + scheduler.shutdown(true); // true = 실행 중인 작업이 완료될 때까지 대기 + log.info("Quartz 스케줄러가 정상적으로 종료되었습니다."); + } + + /** + * 애플리케이션 컨텍스트 종료 전 실행되는 정리 메서드 + */ + @PreDestroy + public void preDestroy() { + try { + if (!scheduler.isShutdown()) { + log.info("ApplicationContext 종료 전 Quartz 정리 작업 수행"); + shutdownQuartzGracefully(); + } + } catch (SchedulerException e) { + log.error("PreDestroy 단계에서 Quartz 종료 중 오류 발생", e); + } + } + +} diff --git a/batch-quartz/src/main/resources/application.yml b/batch-quartz/src/main/resources/application.yml index 1fca658..1a65d51 100644 --- a/batch-quartz/src/main/resources/application.yml +++ b/batch-quartz/src/main/resources/application.yml @@ -102,7 +102,7 @@ batch-info: email-send-batch: group: "EMAIL" job-name: "emailSendJob" - cron-expression: "*/20 * * * * ?" + cron-expression: "*/10 * * * * ?" description: "이메일배치작업" post-batch: group: "POST" @@ -113,7 +113,36 @@ batch-info: group: "POST" job-name: "postCreateJob" cron-expression: "0/30 * * * * ?" - description: "테스트배치작업" + description: "테스트배치작업" + +feign: + okhttp: + enabled: true + client: + config: + default: + connectTimeout: 5000 + readTimeout: 5000 + loggerLevel: full + compression: + request: + enabled: true + response: + enabled: true + + clients: + client1: + url: http://localhost:8082 + connectTimeout: 5000 + readTimeout: 5000 + useProxy: true + proxyHost: http://localhost:8083 + proxyPort: 8083 + client2: + url: http://api1.example.com + headers: + Client1-Specific-Header: Value1 + Another-Client1-Header: AnotherValue1 jwt: access-token: @@ -123,6 +152,14 @@ jwt: secret: bnhjdXMyLjAtcGxhdGZvcm0tcHJvamVjdC13aXRoLXNwcmluZy1ib290bnhjdF9zdHJvbmdfY29tcGxleF9zdHJvbmc= expiration: 10080 +path: + paths: + path1: + upload: /path1/upload + path2: + upload: /path2/upload + dowonload: /path2/dowonload + management: endpoints: web: