앞선 포스트에서 정기 결제를 구현할 때 스프링에서 제공하는 스케쥴러를 사용했습니다.
하지만 정기 결제 로직을 구현하는데 있어서 런타임에 동적으로 스케쥴러의 주기를 바꿀 수 없는 문제가 있어서 Quartz 라이브러리를 사용하게 되었습니다.
일단, 주기를 설정하는데 스프링 스케쥴러보다 훨씬 더 디테일하고 자세하게 설정이 가능합니다.
그리고 런타임 환경에서 주기를 바꾸는 것이 훨씬 편리했습니다.
SpringBoot 2.7.5, Quartz는 2.3.2 버전입니다.
우선 Quartz는 스프링 스케쥴러보다 더 자세한 기능을 제공합니다.. (참고)
저는 Quartz의 가장 기본인 스케쥴링을 하는 것에 초점을 맞췄습니다.
자세하고 구체적인 Quartz 사용법은
https://blog.advenoh.pe.kr/spring/Quartz-Job-Scheduler%EB%9E%80/을 참고하면 좋을 듯 합니다.
API | 설명 |
---|---|
Scheduler | 스케쥴을 실행시킨다. |
Job | 스케쥴이 진행될 동안 수행할 일. |
JobDetail | Job의 정보를 정의할 때 사용. |
JobBuilder | Job을 정의할 때 사용. |
TriggerBuilder | Trigger를 설정할 때 사용. |
이 뿐만아니라 JobListener, TriggerListener를 통해 job이 수행되기 전과 후에 실행시킬 수 있습니다.출처: https://www.javarticles.com/2016/03/quartz-scheduler-model.html
Quartz로 스케쥴링하기 위해서는 라이브러리의 의존성을 추가해 줘야 합니다.
implementation 'org.springframework.boot:spring-boot-starter-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
@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;
}
}
execute(JobExecutionContext context)
에 수행되어야할 작업을 구현합니다.@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);
}
}
JobDataMap
은 Job에 대한 정보를 담습니다.newJob()
을 통해 실행시킬 Job을 설정합니다.withIdentity(jobKey)
은 JobKey 객체를 받습니다. 이것으로 Job을 식별할 수 있다.storeDurably(true)
은 트리거가 없을 때에도 Job을 저장하고 있을 지를 설정합니다.usingJobData(jobDataMap)
으로 JobDataMap을 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();
}
}
forJob(jobKey)
은 jobKey를 이용해서 해당 jobkey를 가지고 있는 Job에 Trigger를 설정합니다.withSchedule()
로 실행 주기, 반복 횟수 등을 설정할 수 있습니다.startAt()
으로 Job을 수행할 시기를 지정할 수 있습니다. startNow()
를 사용해서 바로 스케줄을 시작하게 할 수도 있습니다.@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();
}
}
JobListener를 통해 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);
}
}
}
jobkey("이름","그룹")
으로 jobkey를 설정했습니다. 이 jobkey로 특정 job을 관리할 수 있습니다.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를 가지고 있는 스케쥴을 삭제할 수 있습니다.앞서 스프링 스케줄러를 사용해서 주기를 변경하던 방식과 동일합니다. 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