@Scheduled를 대체한 Quartz (v2)

Choizz·2023년 1월 13일
0

회고

목록 보기
2/4
post-thumbnail

앞선 포스트에서 정기 결제를 구현할 때 스프링에서 제공하는 스케쥴러를 사용했습니다.
하지만 정기 결제 로직을 구현하는데 있어서 런타임에 동적으로 스케쥴러의 주기를 바꿀 수 없는 문제가 있어서 Quartz 라이브러리를 사용하게 되었습니다.

일단, 주기를 설정하는데 스프링 스케쥴러보다 훨씬 더 디테일하고 자세하게 설정이 가능합니다.
그리고 런타임 환경에서 주기를 바꾸는 것이 훨씬 편리했습니다.

SpringBoot 2.7.5, Quartz는 2.3.2 버전입니다.


우선 Quartz는 스프링 스케쥴러보다 더 자세한 기능을 제공합니다.. (참고)
저는 Quartz의 가장 기본인 스케쥴링을 하는 것에 초점을 맞췄습니다.

자세하고 구체적인 Quartz 사용법은
https://blog.advenoh.pe.kr/spring/Quartz-Job-Scheduler%EB%9E%80/을 참고하면 좋을 듯 합니다.

스케쥴링에 필요한 Quartz API

API설명
Scheduler스케쥴을 실행시킨다.
Job스케쥴이 진행될 동안 수행할 일.
JobDetailJob의 정보를 정의할 때 사용.
JobBuilderJob을 정의할 때 사용.
TriggerBuilderTrigger를 설정할 때 사용.

이 뿐만아니라 JobListener, TriggerListener를 통해 job이 수행되기 전과 후에 실행시킬 수 있습니다.출처: https://www.javarticles.com/2016/03/quartz-scheduler-model.html


job, Trigger 설정

1. 의존성 추가

Quartz로 스케쥴링하기 위해서는 라이브러리의 의존성을 추가해 줘야 합니다.

implementation 'org.springframework.boot:spring-boot-starter-quartz'

2. Quartz 설정

  • Quartz 설정은 yml을 통해서 진행을 했습니다.
  • quartz에서 제공하는 스키마를 이용할 수 있기 때문에 아래처럼 설정을 했고, 메모리에 저장하는 것도 가능합니다.
  • 이 외에도 더 많은 설정이 존재합니다.
spring:
  quartz:
    properties:
      org:
        quartz:
          scheduler-name: QRTZ_
          threadPool:
            threadCount: 5

          jobStore:
            class: org.quartz.impl.jdbcjobstore.JobStoreTX
            driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
            tablePrefix: QRTZ_
            isClustered: false
            misfireThreshold: 1000
            useProperties: true
            dataSource: quartz

          dataSource:
            quartz:
            driver-class-name: org.h2.Driver
            url: jdbc:h2:tcp://localhost/~/scheduler-test
            username: sa
            password:
            maxConnections: 10

    jdbc:
      initialize-schema: always
    job-store-type: jdbc
  • Datasource를 스케쥴러 빈에 등록시켜 주는 설정을 합니다.
@Slf4j
@RequiredArgsConstructor
@Configuration
public class QuartzConfig {

    private final DataSource dataSource;
    private final PlatformTransactionManager platformTransactionManager;

    @Bean
    public SchedulerFactoryBean schedulerFactoryBean() {
        SchedulerFactoryBean schedulerFactoryBean = new SchedulerFactoryBean();

        schedulerFactoryBean.setDataSource(dataSource);
        schedulerFactoryBean.setOverwriteExistingJobs(true);
        schedulerFactoryBean.setAutoStartup(true);
        schedulerFactoryBean.setTransactionManager(platformTransactionManager);
        schedulerFactoryBean.setQuartzProperties(quartzProperties());

        return schedulerFactoryBean;
    }

    private Properties quartzProperties() {
        PropertiesFactoryBean propertiesFactoryBean = new PropertiesFactoryBean();
        propertiesFactoryBean.setLocation(new ClassPathResource("application-quartz.yml"));
        Properties properties = null;
        try {
            propertiesFactoryBean.afterPropertiesSet();
            properties = propertiesFactoryBean.getObject();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return properties;
    }
}

3. Job 설정

  • Job은 스케쥴링이 진행되는 동안 수행돼야할 작업을 의미합니다.
  • Job 인터페이스를 구현하고 execute(JobExecutionContext context)에 수행되어야할 작업을 구현합니다.
  • 여기서는 주문일마다 카카오 api에 결제를 요청하는 로직을 구현했습니다.
    • @DisallowConcurrentExecution: 스케쥴러 중복 실행을 방지해 줍니다.
    • @PersistJobDataAfterExecution: jobDataMap에 영속성을 부여하여 정보 변경을 가능하게 합니다. (추후에 job 오류의 카운트를 세는데 사용합니다.)
@Slf4j
@RequiredArgsConstructor
@Component
@DisallowConcurrentExecution // 중복 실행 방지
@PersistJobDataAfterExecution //job data에 영속성 부여
public class KaKaoSubscriptionJob implements Job {

    private final RestTemplate restTemplate;

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {

        JobDataMap mergedJobDataMap = context.getMergedJobDataMap();

        ItemOrder itemOrder = (ItemOrder) mergedJobDataMap.get("itemOrder");
        log.info("start itemOrderId = {}", itemOrder.getItemOrderId());
        log.info("itemOrder title = {}", itemOrder.getItem().getTitle());

        Long orderId = (Long) mergedJobDataMap.get("orderId");
        log.info("start orderId = {}", orderId);

        connectKaKaoPay(orderId); //결제 요청
    }


    private void connectKaKaoPay(Long orderId) {

        MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();

        parameters.add("orderId", String.valueOf(orderId));

        URI uri = UriComponentsBuilder.newInstance()
            .scheme("http").host("localhost")
            .port(8080)
            .path("/payments/kakao/subscription")
            .queryParams(parameters)
            .build().toUri();

        restTemplate.getForObject(uri, String.class);
    }
}

4. JobDetail

  • Job을 실행시키기 위한 정보를 담고 있는 인터페이스입니다.
    • JobDataMap은 Job에 대한 정보를 담습니다.
      • orderId, itemOrder 객체, retry 횟수를 담습니다.
    • newJob()을 통해 실행시킬 Job을 설정합니다.
    • withIdentity(jobKey)은 JobKey 객체를 받습니다. 이것으로 Job을 식별할 수 있다.
    • storeDurably(true)은 트리거가 없을 때에도 Job을 저장하고 있을 지를 설정합니다.
    • usingJobData(jobDataMap)으로 JobDataMap을 job에 포함시킴니다.
  • 이것을 job에 대한 정보를 스케쥴러에 추가합니다.
@Slf4j
@RequiredArgsConstructor
@Component
public class JobDetailService {

    public JobDetail build(JobKey jobKey, Long orderId, ItemOrder itemOrder) {

        log.warn("job detail orderId= {}", orderId);
        log.warn("job datail itemOrderId = {}", itemOrder.getItemOrderId());

        JobDataMap jobDataMap = new JobDataMap();
        jobDataMap.put("orderId", orderId);
        jobDataMap.put("itemOrder", itemOrder);
        jobDataMap.put("retry", 0);

        return newJob(KaKaoSubscriptionJob.class)
            .withIdentity(jobKey.getName(), jobKey.getGroup())
            .storeDurably(true)
            .usingJobData(jobDataMap)
            .build();
    }
}

5. Trigger

  • 스케쥴 주기 정보 등등을 설정하는 인터페이스입니다.
    • forJob(jobKey)은 jobKey를 이용해서 해당 jobkey를 가지고 있는 Job에 Trigger를 설정합니다.
    • withSchedule()로 실행 주기, 반복 횟수 등을 설정할 수 있습니다.
    • startAt()으로 Job을 수행할 시기를 지정할 수 있습니다. startNow()를 사용해서 바로 스케줄을 시작하게 할 수도 있습니다.
    • withIdentity(new TriggerKey("이름","그룹")으로 특정 트리거를 선택할 수 있습니다.
@Slf4j
@Component
public class TriggerService {

    public Trigger build(JobKey jobKey, ItemOrder itemOrder) {
        log.info("trigger 설정");
        return newTrigger()
            .forJob(jobKey)
            .withSchedule(
            	calendarIntervalSchedule().withIntervalInDays(itemOrder.getPeriod())
            ) // 정기 결제 주기마다 실행을 시킨다.
            .withIdentity(new TriggerKey(jobKey.getName(), jobKey.getGroup()))
            .startAt(Date.from(itemOrder.getNextDelivery().toInstant())) //다음 배송일에 결제를 시작한다.
            .build();
    }

     public Trigger retryTrigger() {
        log.info("retry trigger 설정");
        return newTrigger()
            .withSchedule(simpleSchedule()
                .withIntervalInHours(24)
                .withRepeatCount(3)
            )
            .startAt(futureDate(10, MINUTE))
            .withIdentity(new TriggerKey("retry"))
            .build();
    }
}

6. JobListener

JobListener를 통해 job이 수행되기 전, 후, 중단되었을 경우에 발생하는 이벤트를 설정할 수 있습니다.

  • 저는 job이 수행되고 난 후 exception이 발생했을 때, 재시도를 하는 로직과 job이 온전히 수행되었을 경우 새로운 job을 등록하게 했습니다.
@Slf4j
@RequiredArgsConstructor
public class JobListeners implements JobListener {

    private final TriggerService triggerService;
    private final ItemOrderService itemOrderService;
    private final OrderService orderService;
    private final JobDetailService jobDetailService;

    private static final String PAYMENT_JOB = "payment Job";
    private static final String RETRY = "retry";

    @Override
    public String getName() {
        return PAYMENT_JOB;
    }

    /**
     * job 수행전
     *
     * @param context
     */
    @Override
    public void jobToBeExecuted(final JobExecutionContext context) {
        if (context.getJobDetail() == null) {
            log.info("start job");
        }
        JobKey key = context.getJobDetail().getKey();
        log.info("실행될 job의 jobkey = {}", key);
    }

    /**
     * job 중단 시
     *
     * @param context
     */
    @Override
    public void jobExecutionVetoed(final JobExecutionContext context) {
        JobKey key = context.getJobDetail().getKey();
        log.info("중단된 job의 jobkey = {}", key);
    }

    /**
     * job 실행 후 예외가 발생할 경우, 
     * - 첫 번째 예외 발생 시, 바로 job을 재실행한다. 
     * - 두 번째 예외 발생 시, 특정 시간을 간격으로 job을 재실행하고, 일정 시간이 지나도 되지 않으면 job을 삭제하고 런타임 예외를 던진다.
     * 예외가 발생하지 않은 경우, 다음 job을 등록한다.
     * @param context
     * @param jobException
     */
    @Override
    public void jobWasExecuted(
        final JobExecutionContext context,
        final JobExecutionException jobException
    ) {
        JobKey key = context.getJobDetail().getKey();
        JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
        int retryCount = (int) jobDataMap.get(RETRY);
        log.info("실행된 job의 jobkey = {}", key);

        retryOrDeleteIfJobException(context, jobException, jobDataMap, retryCount); // jobException 발생 시 재시도 로직
        updateSchedule(context, jobDataMap); //온전한 job 수행 시 새로운 job 생성 후 등록
    }

    private void updateSchedule(
        final JobExecutionContext context,
        final JobDataMap jobDataMap
    ) {
        log.info("새로운 job 업데이트");
        ItemOrder itemOrder = (ItemOrder) jobDataMap.get("itemOrder");
        Long orderId = (Long) jobDataMap.get("orderId");

        ItemOrder newItemOrder = updateDeliveryDate(itemOrder);
        JobDetail jobDetail = newJob(newItemOrder, orderId);
        replaceJob(context, jobDetail);
    }

    private void replaceJob(final JobExecutionContext context, final JobDetail jobDetail) {
        try {
            context.getScheduler().addJob(jobDetail, true);
            log.info("스케쥴 업데이트 완료");
        } catch (SchedulerException e) {
            JobExecutionException jobExecutionException = new JobExecutionException(e);
            jobExecutionException.setRefireImmediately(true);
        }
    }

    private JobDetail newJob(final ItemOrder itemOrder, final Long orderId) {
        Order newOrder = getOrder(orderId);
        ItemOrder newItemOrder = itemOrderService.itemOrderCopy(orderId, newOrder, itemOrder);
        return updateJobDetails(itemOrder, newOrder, newItemOrder);
    }

    private JobDetail updateJobDetails(
        ItemOrder itemOrder,
        Order newOrder,
        ItemOrder newItemOrder
    ) {
        User user = newOrder.getUser();
        JobKey jobkey =
            jobKey(user.getUserId() + itemOrder.getItem().getTitle(),
                String.valueOf(user.getUserId())
            );
        return jobDetailService.build(jobkey, newOrder.getOrderId(), newItemOrder);
    }

    private Order getOrder(Long orderId) {
        orderService.completeOrder(orderId);
        Order order = orderService.findOrder(orderId);
        return orderService.deepCopy(order);
    }

    private ItemOrder updateDeliveryDate(final ItemOrder itemOrder) {
        ZonedDateTime paymentDay = ZonedDateTime.now(ZoneId.of("Asia/Seoul"));
        log.info("payment = {}", paymentDay);
        ZonedDateTime nextDelivery = paymentDay.plusDays(itemOrder.getPeriod());
        return itemOrderService.updateDeliveryInfo(paymentDay, nextDelivery, itemOrder);
    }

    private void retryOrDeleteIfJobException(
        final JobExecutionContext context,
        final JobExecutionException jobException,
        final JobDataMap jobDataMap,
        final int retryCount
    ) {
        if (jobException != null) {
            log.warn("job exception = {}", jobException.getMessage());
            retryImmediately(jobException, jobDataMap, retryCount);
            retryIn24Hour(context, jobDataMap, retryCount);
            cancelSchedule(context, retryCount);
        }
    }

    private void cancelSchedule(final JobExecutionContext context, final int retryCount) {
        if (retryCount >= 4) {
            try {
                JobKey key = context.getJobDetail().getKey();
                context.getScheduler().deleteJob(key);
                throw new BusinessLogicException(ExceptionCode.PAYMENT_FAIL);
            } catch (SchedulerException e) {
                JobExecutionException jobExecutionException = new JobExecutionException(e);
                jobExecutionException.setUnscheduleFiringTrigger(true);
            }
        }
    }

    private void retryIn24Hour(
        final JobExecutionContext context,
        final JobDataMap jobDataMap,
        int retryCount
    ) {
        if (retryCount == 1) {
            log.warn("재시도");
            jobDataMap.put(RETRY, ++retryCount);
            Trigger trigger = triggerService.retryTrigger();
            reschedule(context, trigger);
        }
    }

    private void retryImmediately(
        final JobExecutionException jobException,
        final JobDataMap jobDataMap,
        int retryCount
    ) {
        if (retryCount == 0) {
            log.warn("최초 재시도");
            jobDataMap.put(RETRY, ++retryCount);
            jobException.setRefireImmediately(true);
        }
    }

    private void reschedule(final JobExecutionContext context, final Trigger trigger) {
        try {
            log.warn("재시도 스케쥴 설정");
            context.getScheduler().rescheduleJob(
                new TriggerKey(trigger.getJobKey().getName(), trigger.getJobKey().getGroup()),
                trigger
            );
        } catch (SchedulerException e) {
            JobExecutionException jobExecutionException = new JobExecutionException(e);
            jobExecutionException.setRefireImmediately(true);
        }
    }
}

스케쥴 설정

  • Quartz에서 제공하는 Scheduler를 DI받아 사용했습니다.
  • 스케쥴을 시작할 때 필요한 TriggerService와 JobDetailService를 DI해사용합니다.
    • jobkey("이름","그룹")으로 jobkey를 설정했습니다. 이 jobkey로 특정 job을 관리할 수 있습니다.
    • JobDetail 객체를 생성한다.
    • Trigger 객체를 생성한다.
    • scheduler.scheduleJob(JobDetail,Trigger)을 사용해서 스케쥴을 등록해 합니다.
@Slf4j
@RequiredArgsConstructor
@Service
public class SubscriptionService {

    private final Scheduler scheduler;
    private final TriggerService trigger;
    private final JobDetailService jobDetailService;
    private final OrderService orderService;
    private final ItemOrderService itemOrderService;


    public void startSchedule(Order order, ItemOrder itemOrder) {
        applySchedule(order, itemOrder);
        log.info("orderId = {}, itemOrderId ={} ==> 스케쥴 설정완료", order.getOrderId());
    }
    
    private void applySchedule(Order order, ItemOrder itemOrder) {
        User user = order.getUser();
        log.info("{} {}", order.getOrderId(), itemOrder.getItemOrderId());
        //jobkey를 만듭니다.
        JobKey jobkey = jobKey(
            user.getUserId() + itemOrder.getItem().getTitle(),
            String.valueOf(user.getUserId())
        ); 
        
		//jobDetail을 만듭니다.
        JobDetail payDay = jobDetailService.build(jobkey, order.getOrderId(), itemOrder); 

		//trigger를 만듭니다.
        Trigger lastTrigger = trigger.build(jobkey, itemOrder);
		
        schedule(payDay, lastTrigger);
    }
    
     private void schedule(JobDetail jobDetail, Trigger lastTrigger) {
        try {
            // jobListener를 등록합니다.
            ListenerManager listenerManager = scheduler.getListenerManager();
            listenerManager.addJobListener(
                new JobListeners(trigger, itemOrderService, orderService, jobDetailService)
            );
            listenerManager.addTriggerListener(new TriggerListeners());
            //스케줄을 등록합니다.
            scheduler.scheduleJob(jobDetail, lastTrigger);
        } catch (SchedulerException e) {
            JobExecutionException jobExecutionException = new JobExecutionException(e);
            jobExecutionException.setRefireImmediately(true);
        }
    }
}  

스케쥴 변경, 삭제

  • 런 타임 상황에서 주기를 변경하거나 삭제할 수 있게 됐습니다.
  • 스케쥴 삭제 시,
    • scheduler.deleteJob(jobKey)를 사용하여 해당 jobKey를 가지고 있는 스케쥴을 삭제할 수 있습니다.
  • 스케쥴 변경 시,
    • 기존에 있던 job삭제하고 새로운 trigger로 변경합니다.
    • Trigger로 Job이 시작되는 날짜를 설정할 수 있기 때문에 스케쥴을 삭제하고 다시 만들어도 문제가 되지 않습니다.

    앞서 스프링 스케줄러를 사용해서 주기를 변경하던 방식과 동일합니다. Quartz는 런 타임 환경에서 스케쥴을 취소하지 않고 Trigger를 변경할 수 있습니다(rescheduleJob 이용).(참고) 하지만 기존에 진행 중이던 스레드가 종료되지 않고 그 스레드가 job을 끝맞힌 후 Trigger가 업데이트 됩니다. 보통 주기가 긴 경우는 Job을 실행한 후 다시 Job을 실행하기 까지 텀이 길기 때문에 문제가 되지 않겠지만 만약 주기가 짧은 경우는 오작동을 일으킬 수 있으니 주의해야 합니다.

 private void resetSchedule(Long orderId, ItemOrder itemOrder) throws SchedulerException {
 	deleteSchedule(orderId, itemOrder);
 	startSchedule(orderId, itemOrder);
 }
 
 private void deleteSchedule(Long orderId, ItemOrder itemOrder) throws SchedulerException {
      log.info("delete schedule");
      User user = getUser(orderId);
      scheduler.deleteJob(jobKey(user.getUserId() + itemOrder.getItem().getTitle(),
            									String.valueOf(user.getUserId())));
 }

정리

이렇게 Quartz를 사용하면 런타임 환경에서 동적으로 스케쥴의 주기를 변경할 수 있게 됐습니다. Quartz는 스프링에서 제공하는 스케쥴러의 기능을 포함한 더 많은 기능을 제공한다.

일단 정기결제를 구현함에 있어서 Trigger를 더 구체적으로 설정할 수 있고, JobKey라는 것으로 스케쥴을 따로 인식할 수 있어서 스케쥴 변경에 용이했습니다.

보통 Quartz는 Spring Batch와 함께 사용하고 여기 프로젝트에서도 Batch 프로그램을 사용해서 같은 날짜에 결제되는 것을 한 번에 결제되게 하면 좋았겠지만 그렇지 못한점이 아쉬움이 남습니다.


Reference

profile
집중

0개의 댓글