서론
작년 3월, 사내 프로젝트에서 Spring Scheduler 개발을 진행했었습니다. 👉이전글 참고
그리고 사내 관리자용 웹 서버를 nodejs -> spring boot 서버로 이관하는 작업을 진행하면서
다시한번 Spring Scheduler 개발을 진행할 기회가 생겼습니다.
본 글을 통해 Spring Scheduler 개발을 이전보다 어떻게 더 세련되게(?) 진행했는지에 대해서 다뤄보고자 합니다.
이번 개발에는 java8 에서 제공하는 기능들을 적극 활용하였는데,
관련된 부분은 👉람다표현식과 함수형인터페이스글에서 확인 바랍니다.
개발목표
개발 목표는 비슷합니다.
- 관리자가 스케줄러의 동작을 조절할 수 있어야 함 (시작/중지/주기변경)
- 스케줄러 동작의 조절은 런타임환경에서도 가능해야 함.
어떻게 관리자가 스케줄러의 동작을 런타임 환경에서 조절할 수 있는지에 대해서는 👉이전글을 참고바랍니다.
그리고 추가된 목표는 코드의 중복을 최소화하고, 확장이 보다 유용한 형태로 구현하는 것이었습니다.
이를 위해 함수형 인터페이스에 대한 이해가 필요했습니다. 👉람다표현식과 함수형인터페이스글 참고
이전 scheduler
이전 Scheduler 코드에는 중대한 문제점이 있었습니다.
특정 기능을 위한 스케줄러가 추가될 때, 각각의 클래스로 구현을 진행했습니다.
그런데 이러한 구현은 많은 낭비를 낳게 됩니다.
스케줄러 하나가 추가될 때마다 별도의 클래스를 생성해야 했고, 해당 클래스에는 다른 스케줄러 클래스와의 중복 코드도 같이 작성해야 했습니다.
또한, 클래스를 추가할 뿐만 아니라 기존 스케줄러 관련 코드에도 많은 수정이 필요했습니다. 자세한 코드는 👉이전글을 참고해주세요.
새롭게 구현하는 Scheduler는 이러한 비효율을 만들고 싶지 않았습니다.
좀더 세련된 Scheduler 구현하기 (feat. 함수형 인터페이스)
새롭게 Scheduler를 구현할 수 있게된 계기는 👉람다표현식과 함수형인터페이스글에서도 확인할 수 있는 것처럼
java8 이후부턴 함수의 동작도 파라미터에 입력할 수 있는 변수로 관리할 수 있다는 것을 알게된 것이었습니다.
이러한 java8의 특징을 적극 활용하여 Scheduler가 실행해야할 핵심적인 비즈니스 로직을 변수화시켜서,
Scheduler의 공통로직(ex. Scheduler 시작/중지/주기변경, 초기화, API 등)과 분리시켰습니다.
- As-Was Scheduler 코드
@Slf4j
@Component
@RequiredArgsConstructor
public class Sftp1Scheduler {
// Spring Batch Bean
private final JobLauncher jobLauncher;
private final ThreadPoolTaskScheduler threadPoolTaskScheduler;
private final InterfaceFileReaderBatchJobConfig interfaceFileReaderBatchJobConfig;
private final Sftp1Reader sftp1Reader;
// Scheduler 설정 테이블 Jpa Repository
private final SchedulerConfigRepository schedulerConfigRepository;
// Scheduler 식별자
private String schedulerIdx = "3";
private String schedulerName = "Sftp1";
// Scheduler 설정 값
private String executionIp;
private String executionYn;
private String cron;
// Scheduler가 실행시킬 task
private ScheduledFuture<?> future;
// 초기화
@PostConstruct // AP가 실행되고 Spring 초기화 과정에서 메소드가 실행될 수 있도록 선언
public void init() {
try {
if (!this.startScheduler()) {
log.error();
}
}
catch (Exception e) {
log.error();
}
}
// 스케줄러 시작
public boolean startScheduler() throws Exception {
if (this.future != null)
return false;
SchedulerConfig schedulerConfig = schedulerConfigRepository.findBySchedulerIdxAndSchedulerName(this.schedulerIdx, this.schedulerName);
if (!validateExecution(schedulerConfig)) {
return false;
}
this.executionIp = schedulerConfig.getExecutionIp();
this.executionYn = schedulerConfig.getExecutionYn();
this.cron = schedulerConfig.getCron();
ScheduledFuture<?> future = this.threadPoolTaskScheduler.schedule(this.getRunnable(), this.getTrigger());
this.future = future;
return true;
}
// 스케줄러 종료
public void stopScheduler() {
if (this.future != null)
this.future.cancel(true);
this.future = null;
}
// 런타임 스케줄러 주기 변경
public void changeCron(String cron) throws Exception {
this.saveCron(cron);
this.stopScheduler();
this.cron = cron;
this.startScheduler();
}
// 여기부터 클래스 내부에서만 쓸 메소드들
private boolean validateExecution(SchedulerConfig schedulerConfig) throws UnknowsHostException {
if (schedulerConfig == null) {
return false;
}
String localIp = InetAddress.getLocalHost().getHostAddress(); // 현재 실행중인 서버 ip 조회
if ("N".equals(schedulerConfig.getExecutionYn()) || !localIp.equals(schedulerConfig.getExecutionIp())) {
return false;
}
return true;
}
// 스케줄러가 실행시킬 task
private Runnable getRunnable() {
return () -> {
try {
Step interfaceStep = interfaceFileReaderBatchJobConfig.interfaceStep(sftp1Reader.resultFlatFileItemReader);
Map<String, JobParameter> confMap = new HashMap<>();
confMap.put("time", new JobParameter(System.currentTimeMillis()));
JobParameters jobParameters = new JobParameters(confMap);
jobLauncher.run(interfaceFileReaderBatchJobConfig.interfaceJob(interfaceStep), jobParameters);
}
catch (JobExecutionAlreadyRunningException | JobInstanceAlreadyCompleteException | JobParametersInvalidException | JobRestartException e) {
log.error();
}
catch (Exception e) {
log.error();
}
}
}
// 스케줄러의 trigger
private Trigger getTrigger() {
return new CronTrigger(this.cron);
}
@Transactional
private void saveCron(String cron) throws Exception {
SchedulerConfig schedulerConfig = new SchedulerConfig();
schedulerConfig.setSchedulerIdx(this.schedulerIdx);
schedulerConfig.setSchedulerName(this.schedulerName);
schedulerConfig.setCron(cron);
schedulerConfig.setLastChgDt(new Date());
schedulerConfig.setExecutionIp(this.executionIp);
schedulerConfig.setExecutionYn(this.executionYn);
schedulerConfigRepository.save(schedulerConfig);
}
}
- To-Be Scheduler 코드
@Slf4j
@Component
@RequiredArgsConstructor
public class CommonScheduler {
private final CommonSchedulerService commonSchedulerService;
private final CommonSchedulerConfigRepository commonSchedulerConfigRepository;
private final ThreadPoolTaskScheduler threadPoolTaskScheduler;
private Map<String, ScheduledFuture<?>> futureMap = new HashMap<>();
private Map<String, Runnable> runnerbleMap = new HashMap<>();
// 초기화 메서드
@PostConstruct
public void init() {
try {
List<CommonSchedulerConfig> commonSchedulerConfigList = commonSchedulerConfigRepository.findAll();
for (CommonSchedulerConfig commonSchedulerConfig : commonSchedulerConfigList) {
switch (commonSchedulerConfig.getSchedulerName()) {
case "changeDateScheduler":
this.runnerbleMap.put("changeDateScheduler", commonSchedulerService::scheduleChangeDate);
if (!this.startScheduler("changeDateScheduler")) {
log.info("changeDateScheduler execution conditions not met");
}
break;
case "autoSendMailScheduler":
this.runnerbleMap.put("autoSendMailScheduler", commonSchedulerService::scheduleAutoSendMail);
if (!this.startScheduler("autoSendMailScheduler")) {
log.info("autoSendMailScheduler execution conditions not met");
}
break;
default:
break;
}
}
} catch (Exception e) {
log.error(e.getMessage());
e.printStackTrace();
}
}
// scheduler 시작 메서드
public boolean startScheduler(String scheduleerName) throws Exception {
CommonSchedulerConfig commonSchedulerConfig = commonSchedulerConfigRepository.findBySchedulerName(scheduleerName);
if (this.futureMap.get(schedulerName) != null)
return false;
if (!this.validateExecution(commonSchedulerConfig, schedulerName)) {
return false;
}
ScheduledFuture<?> future = this.threadPoolTaskScheduler.schedule(this.runnerbleMap.get(schedulerName), new CronTrigger(commonSchedulerConfig.getCron()));
this.futureMap.put(schedulerName, future);
return false;
}
public void stopScheduler(String schedulerName) {
if (this.futureMap.get(schedulerName) != null)
this.futureMap.get(schedulerName).cancel(true);
this.futureMap.remove(schedulerName);
}
public void changeCron(String schedulerName, String cron) throws Exception {
try {
this.saveCron(schedulerName, cron);
this.stopScheduler(schedulerName);
this.startScheduler(schedulerName);
} catch (Exception e) {
log.error(e.getMessage());
e.printStackTrace();
}
}
private boolean validateExecution(CommonSchedulerConfig commonSchedulerConfig, String schedulerName) {
if (commonSchedulerConfig == null) {
log.info("no config data, scheduler : {}", schedulerName);
return false;
}
if ("N".equals(commonSchedulerConfig.getExecutionYn())) {
log.info("execution conditions not met, scheduler : {}", schedulerName);
return false;
}
return true;
}
@Transsactional
private void saveCron(String schedulerName, String cron) throws Exception {
CommonSchedulerConfig commonSchedulerConfig = commonSchedulerConfigRepository.findBySchedulerName(scheduleerName);
commonSchedulerConfig.updateCron(cron);
commonSchedulerConfigRepository.save(commonSchedulerConfig);
}
}
As-Was에서 별개의 클래스로 관리되던 Scheduler를 To-Be에서는 하나의 Scheduler 클래스로 통일하였고,
각각의 클래스에서 관리되던 멤버 변수들은 모두 DB로 관리될 수 있도록 하여,
필요할 때마다 DB에서 조회하여 사용할 수 있도록 수정하였습니다.
그리고 Scheduler가 실행시킬 비즈니스 로직을 해당 클래스의 메서드 형태로 관리하고 있었는데,
이를 변수화 시켜서 Map 자료구조로 Scheduler의 이름과 동작 변수를 매핑시켜서
통합 Scheduler의 멤버변수로 관리하였습니다.
해당 멤버변수의 초기화는 통합 Scheduler 클래스의 초기화 과정이 진행될 때, 모든 Scheduler 목록을 DB로부터 가져오고
사전 작성된 Switch문에서 Scheduler의 이름과 동작을 매핑시키는 과정에서 이루어졌습니다.
이러한 구현에는 () -> void
형태의 함수형 인터페이스 Runnable
의 특징을 활용하였습니다.
이렇게 코드를 구성함으로써 스케줄러가 추가될 때마다 Scheduler가 실행시켜야할 비즈니스 로직을 별도 클래스로 구현하고,
통합 Scheduler 클래스의 초기화 메서드에서 Switch 조건만 추가하면 될수 있도록 구현되었습니다.
또한, 스케줄러 동작을 제어하는 API 서비스와 같은 Scheduler 관련 코드도 수정하지 않아도 될 정도로 유지보수의 효율성을 증대시켰습니다.
- As-Was SchedulerController 코드
@Controller
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/scheduler")
public class SchedulerController {
// 스케줄러 Bean 주입
private final Sftp1Scheduler sftp1Scheduler;
private final Sftp2Scheduler sftp2Scheduler;
// sheduler 작업 시작
@PostMapping("/start")
@ResponseBody
public ResponseEntity<?> requestStartScheduler(@RequestBody RequestSchedulerDto requestSchedulerDto) throws InterruptedException {
ResponseSchedulerDto responseSchedulerDto = new ResponseSchedulerDto();
String schedulerIdx = responseSchedulerDto.getSchedulerIdx();
String schedulerName = responseSchedulerDto.getSchedulerName();
boolean returnFlag = true;
if (schedulerIdx == null || schedulerName == null) {
responseSchedulerDto.setRsltCd("E1");
responseSchedulerDto.setErrMsg("no request data");
}
try {
if ("1".equals(schedulerIdx) && "InterfaceResult".equals(schedulerName)) {
returnFlag = sftp1Scheduler.startScheduler();
}
else if ("2".equals(schedulerIdx) && "InterfaceResult2".equals(schedulerName)) {
returnFlag = sftp2Scheduler.startScheduler();
}
else {
log.error("no target scheduler");
responseSchedulerDto.setRsltCd("E4");
responseSchedulerDto.setErrMsg("no target scheduler");
return new ResponseEntity<>(responseSchedulerDto, HttpStatus.OK);
}
if (returnFlag == false) {
responseSchedulerDto.setRsltCd("E3");
responseSchedulerDto.setErrMsg("already started scheduler or execution conditions not met");
}
responseSchedulerDto.setRsltCd("S");
responseSchedulerDto.setErrMsg("");
}
catch (Exception e) {
log.error(e.getMessage());
responseSchedulerDto.setRsltCd("E2");
responseSchedulerDto.setErrMsg("internal server error");
}
return new ResponseEntity<>(responseSchedulerDto, HttpStatus.OK);
}
... 코드 생략 ...
}
- To-Be ReleaseSchedulerConfigService
@Service
@Slf4j
@RequiredArgsConstructor
public class CommonSchedulerConfigService {
private final CommonScheduler commonScheduler;
public CommonResponse<String> requestStartScheduler(CommonSchedulerConfigReq commonSchedulerConfigReq) {
CommonResponse<String> response = new CommonResponse<>();
if (commonSchedulerConfigReq.getSchedulerName().isEmpty()) {
response.setSuccess(false);
response.setData("필수 파라미터가 누락되었습니다.");
return response;
}
try {
if (commonScheduler.startScheduler(commonSchedulerConfigReq.getSchedulerName())) {
log.error("{}, already started or execution conditions not met", commonSchedulerConfigReq.getSchedulerName());
response.setSuccess(false);
response.setData(commonSchedulerConfigReq.getSchedulerName() + ", already started or execution conditions not met");
}
} catch (Exception e) {
response.setSuccess(false);
response.setData(e.toString());
e.printStackTrace();
}
return response;
}
... 코드 생략 ...
}
위 코드들은 http 요청으로 런타임환경의 Scheduler 동작을 제어할 수 있게하는 Service 로직입니다.
To-Be 코드에서 요청 파라미터로 받은 Scheduler의 이름을 통합 Scheduler 클래스의 파라미터로 입력하여 제어할 수 있도록 하였습니다.
이를 통해, 해당 코드에서도 마찬가지로 Scheduler가 추가될 때에도 해당 로직들을 수정하지 않아도 됩니다.
'개발 > Spring Framework' 카테고리의 다른 글
[Spring] JPA, 효율적으로 데이터 누락없이 DB update 하기 (0) | 2024.01.13 |
---|---|
[Spring] Spring Scheduler - File to DB 개발 (3) (0) | 2023.05.31 |
[Spring] Spring Batch - File to DB 개발 (2) (0) | 2023.04.28 |
[Spring] Spring Batch - File to DB 개발 (1) (0) | 2023.04.19 |
[Spring] Spring Batch란? (0) | 2023.04.09 |