This commit is contained in:
mindol1004
2024-10-31 17:50:03 +09:00
parent df373d5d27
commit 23e1641644
18 changed files with 857 additions and 116 deletions

View File

@@ -42,6 +42,14 @@
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>

View File

@@ -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);
}
}

View File

@@ -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()
);
}
}

View File

@@ -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<String, PathConfig> 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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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<Step> 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);
}

View File

@@ -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);
}
}

View File

@@ -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;
// });
// }
}

View File

@@ -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<Post> list = new ArrayList<>();
// private List<Post> 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)
.<Post, PostBackUp>chunk(5)
.reader(testReader())
.processor(testProcessor())
.writer(testWriter())
.build();
}
// private Step processStep() {
// return new StepBuilder("processStep")
// .repository(jobRepository)
// .transactionManager(transactionManager)
// .<Post, PostBackUp>chunk(5)
// .reader(testReader())
// .processor(testProcessor())
// .writer(testWriter())
// .build();
// }
private JpaPagingItemReader<Post> testReader() {
return new JpaPagingItemReaderBuilder<Post>()
.name("testReader")
.entityManagerFactory(entityManagerFactory)
.pageSize(5)
.queryString("select p from Post p")
.build();
}
// private JpaPagingItemReader<Post> testReader() {
// return new JpaPagingItemReaderBuilder<Post>()
// .name("testReader")
// .entityManagerFactory(entityManagerFactory)
// .pageSize(5)
// .queryString("select p from Post p")
// .build();
// }
private ItemProcessor<Post, PostBackUp> testProcessor() {
return post ->
PostBackUp.builder().postId(post.getPostId()).content(post.getContent()).title(post.getTitle()).build();
}
// private ItemProcessor<Post, PostBackUp> testProcessor() {
// return post ->
// PostBackUp.builder().postId(post.getPostId()).content(post.getContent()).title(post.getTitle()).build();
// }
private ItemWriter<PostBackUp> testWriter() {
return postBackUpRepository::saveAll;
}
// private ItemWriter<PostBackUp> 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;
// };
// }
}

View File

@@ -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();
}

View File

@@ -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<Throwable> 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());
}
}

View File

@@ -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);
}

View File

@@ -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<String, Collection<String>> convertHeaders(okhttp3.Headers headers) {
Map<String, Collection<String>> 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()));
}
}

View File

@@ -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<String, ClientConfig> 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<String, String> 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<String, String> 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<>();
}
}
}

View File

@@ -0,0 +1,9 @@
package com.spring.infra.feign.dto;
import lombok.Getter;
@Getter
public class TestRequest {
private String id;
private String name;
}

View File

@@ -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;
}

View File

@@ -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 스케줄러의 안전한 종료를 관리하는 리스너 클래스
*
* <p>이 클래스는 Spring 애플리케이션 컨텍스트가 종료될 때 Quartz 스케줄러의 모든 작업을
* 안전하게 종료하고 정리하는 역할을 합니다. 실행 중인 작업이 정상적으로 완료될 때까지
* 대기하며, 모든 트리거를 정리합니다.</p>
*
* <p>주요 기능:</p>
* <ul>
* <li>실행 중인 작업의 안전한 종료 처리</li>
* <li>트리거 상태 모니터링 및 정리</li>
* <li>스케줄러 리소스의 정상적인 해제</li>
* </ul>
*
* @author [작성자 이름]
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class QuartzShutdownListener implements ApplicationListener<ContextClosedEvent> {
/** 작업 완료 대기 최대 시간 (밀리초) */
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 스케줄러의 안전한 종료를 처리하는 메서드
*
* <p>다음의 단계를 순차적으로 실행합니다:</p>
* <ol>
* <li>새로운 작업 스케줄링 중지</li>
* <li>현재 실행 중인 작업 목록 확인</li>
* <li>실행 중인 작업의 상태 로깅</li>
* <li>작업 완료 대기</li>
* <li>트리거 정리</li>
* <li>스케줄러 종료</li>
* </ol>
*/
private void shutdownQuartzGracefully() {
try {
// 1. 새로운 작업 스케줄링 중지
scheduler.standby();
log.info("Scheduler를 대기 모드로 전환했습니다.");
// 2. 현재 실행 중인 작업 목록 확인
List<JobExecutionContext> 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<JobExecutionContext> 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<JobExecutionContext> 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<String> groups = scheduler.getTriggerGroupNames();
for (String group : groups) {
Set<TriggerKey> 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);
}
}
}

View File

@@ -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: