정상혁정상혁

Spring 3.0부터는 Quartz가 없이도 실행 스케쥴 설정이 가능합니다. 설정과 테스트 방법을 정리했습니다.

@Schedule 를 써서 크론 표현식 설정

실행을 하고 싶은 메소드를 @Component, @Schedule annotaion을 통해 지정을 합니다.

@Component
public class BaseballScheduledJobLauncher extends JobLaunchSupport {

.....

    @Scheduled(cron="45 * * * * MON-FRI")
    public void baseballJob() {
        JobParameters params = createTimeParameter();
        run("baseballJob", params);
    }


    @Scheduled(cron="45 10 5 * * MON-FRI")
    public void baseballExportJob() {
        JobParameters params = createTimeParameter();
        run("baseballExportJob", params);
    }

}

Java 파일에 설정된 Annotation을 인식하기 위해서 Application context의 xml파일에 component-scan과 annotation으로 schedule을 설정하겠다는 선언을 추가합니다. 그리고 schedule을 실행할 thread pool의 크기도 지정합니다.

    <context:component-scan base-package="edu.batch.baseball.schedule"/>
    <task:scheduler id="myScheduler" pool-size="10"/>
    <task:annotation-driven scheduler="myScheduler"/>

'task’와 'component’의 namespace가 xml에 추가되어 있어야 합니다.

테스트 코드

테스트 코드를 단순하게 만들기 위해서 크론표현식을 추출하고 검사하는 코드는 SpringCronExpressionTestUtils클래스로 분리했습니다.

package edu.batch.baseball.schedule;

import static edu.batch.support.launch.SpringcronExpressionTestUtils.*;

import java.util.Arrays;
import java.util.List;

import org.junit.Test;

import edu.batch.baseball.schedule.BaseballScheduledJobLauncher;

public class BaseballScheduledJobLauncherTest {

    private static final String DATE_PATTERN = "yyyy/MM/dd hh:mm:ss";

    @Test
    public void testBaseballJobSchedule() {
        String initialTime = "2010/09/01 09:00:00";
        List<String> expectedTimeList = Arrays.asList(
                "2010/09/01 09:00:45",
                "2010/09/01 09:01:45",
                "2010/09/01 09:02:45");
        String cronExpression = getcronExpressionOfMethod(BaseballScheduledJobLauncher.class, "baseballJob");
        assertSchedule(cronExpression, initialTime, expectedTimeList,DATE_PATTERN);
    }
}
SpringCronExpressionTestUtils
package edu.batch.support.launch;

import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;

import java.lang.reflect.Method;
import java.text.ParseException;
import java.util.Date;
import java.util.List;

import org.apache.commons.lang.time.DateFormatUtils;
import org.apache.commons.lang.time.DateUtils;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.support.CronTrigger;
import org.springframework.scheduling.support.SimpleTriggerContext;

public class SpringCronExpressionTestUtils {

    public static String getCronExpressionOfMethod(Class<?> targetClass,
            String methodName)  {
        Method scheduledMethod;
        try {
            scheduledMethod = targetClass.getDeclaredMethod(methodName,
                    new Class[] {});
        } catch (SecurityException e) {
            throw new IllegalArgumentException("cannot access the method : " + methodName, e);
        } catch (NoSuchMethodException e) {
            throw new IllegalArgumentException(e);
        }
        Scheduled scheduleInfo = scheduledMethod.getAnnotation(Scheduled.class);
        String cronExpression = scheduleInfo.cron();
        return cronExpression;
    }

    public static void assertSchedule(String cronExpression, String initialTime,
            List<String> expectedTimeList, String datePattern) {
        CronTrigger trigger = new CronTrigger(cronExpression);
        Date startTime;
        try {
            startTime = DateUtils.parseDate(initialTime,
                    new String[] { datePattern });
        } catch (ParseException e) {
            throw new IllegalArgumentException("wrong date format", e);
        }
        SimpleTriggerContext context = new SimpleTriggerContext();
        context.update(startTime, startTime, startTime);

        for (String exptectedTime : expectedTimeList) {
            Date nextExecutionTime = trigger.nextExecutionTime(context);
            String actualTime = DateFormatUtils.format(nextExecutionTime,
                    datePattern);
            assertThat("executed on expected time", actualTime,
                    is(exptectedTime));
            context.update(nextExecutionTime, nextExecutionTime,
                    nextExecutionTime);
        }
    }

}
정상혁정상혁

Cron expression은 실수하기가 쉽고, 오류가 뒤늦게서야 발견됩니다. 매일 5시 20분, 40분,60분에 실행될 일정을 지정하고자 했는데 "* 0/20 5 * * * ?"로 써야 할 표현식을 "* 0,20 5 * * * ?"으로 써놓고는 실운영 서버에 배포해서 하루가 지난 다음에 실행결과를 보고서야 실수를 발견한 경험을 해보신 분들이 많으실 것입니다.

Cron expression도 테스트 코드를 짜서 검증을 해본다면 치명적인 실수를 막을 기회가 더 많아집니다.

아래 예제는 ApplicationContext에 설정되어 있는 Quartz의 CronTrigger의 일정을 테스트하는 코드입니다.

ApplicationContext의 스케쥴링 설정 파일

<bean id="schedulerFactoryBean" class="org.springframework.scheduling.quartz.SchedulerFactoryBean">
     <property name="triggers" ref="triggers"/>
</bean>
<util:list id="triggers">
     <bean p:jobName="baseballJob" p:cronE-pression="0 * * * * ?" parent="jobTrigger"/>
     <bean p:jobName="baseballExportJob" p:cronE-pression="30 5-7 * * * ?" parent="jobTrigger"/>
</util:list>

위의 설정 예제에서는 반복되는 설정을 간편하게 해주는 FactoryBean을 썼습니다. parent="jobTrigger"라고 지정된 부분이 FactoryBean 클래스와 연결되는 bean id를 지정한 속성입니다. 여기에 쓰인 FactoryBean은 이"jobName"과 "cronExpression" 속성을 받아서 org.quartz.CronTrigger 타입의 bean을 생성해주는 역할을 합니다.

JobTriggerFactoryBean
package edu.batch.support.launch;

import java.util.HashMap;
import java.util.Map;

import org.quartz.Trigger;
import org.springframework.batch.core.configuration.JobLocator;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.batch.sample.quartz.JobLauncherDetails;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.scheduling.quartz.CronTriggerBean;
import org.springframework.scheduling.quartz.JobDetailBean;
import org.springframework.util.Assert;

/**
 * @author sanghyuk.jung
 */
public class JobTriggerFactoryBean implements FactoryBean<Trigger>, InitializingBean {
    private JobLocator jobLocator;
    private JobLauncher jobLauncher;
    private String jobName;
    private String cronExpression;
    private String triggerName;

    /**
     * @return
     * @throws Exception
     * @see org.springframework.beans.factory.FactoryBean#getObject()
     */
    public Trigger getObject() throws Exception {
        CronTriggerBean trigger = new CronTriggerBean();
        trigger.setCronExpression(cronExpression);
        JobDetailBean jobDetail = createJobDetail();
        trigger.setJobDetail(jobDetail);
        if(triggerName == null ){
        trigger.setName(jobName+"Trigger");
        } else {
            trigger.setName(triggerName);
        }
        trigger.afterPropertiesSet();
        return trigger;
    }

    private JobDetailBean createJobDetail() {
        JobDetailBean jobDetail = new JobDetailBean();
        jobDetail.setName(jobName);
        Map<String, Object> jobData = new HashMap<String, Object>();
        jobData.put("jobName", jobName);
        jobData.put("jobLocator", jobLocator);
        jobData.put("jobLauncher", jobLauncher);
        jobDetail.setJobDataAsMap(jobData);
        jobDetail.setJobClass(JobLauncherDetails.class);
        jobDetail.afterPropertiesSet();
        return jobDetail;
    }

    /**
     * @return
     * @see org.springframework.beans.factory.FactoryBean#getObjectType()
     */
    public Class<Trigger> getObjectType() {
        return Trigger.class;
    }

    /**
     * @return
     * @see org.springframework.beans.factory.FactoryBean#isSingleton()
     */
    public boolean isSingleton() {
        return false;
    }

    public void setJobLocator(JobLocator jobLocator) {
        this.jobLocator = jobLocator;
    }

    public void setJobLauncher(JobLauncher jobLauncher) {
        this.jobLauncher = jobLauncher;
    }

    public void setJobName(String jobName) {
        this.jobName = jobName;
    }

    public void setCronExpression(String cronExpression) {
        this.cronExpression = cronExpression;
    }

    /**
     * @throws Exception
     * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet()
     */
    public void afterPropertiesSet() throws Exception {
        Assert.notNull(jobName, "jobName must be provided");
        Assert.notNull(jobLocator, "jobLocator name must be provided");
        Assert.notNull(jobLauncher, "jobLauncher name must be provided");
    }

    public void setTriggerName(String triggerName) {
        this.triggerName = triggerName;

    }
}

테스트 코드

triggers라는 bean 이름으로 List<CronTrigger> type의 객체를 가지고 와서, List안에서 지정된 trigger의 이름을 탐색한다음에 그 안에서 그 trigger의 cron e-pression을 검사했습니다. 테스트 코드를 간결하게 유지하기 위해서 Cron e-pression을 검사하는 코드는 QuartzCronExpressionTestUtils라는 클래스로 분리해서 static import로 처리했습니다. QuartzCronExpressionTestUtils.findTriggerByName 메소드는 List<CronTrigger> 타입의 객체가 담고 있는 여러개의 CronTrigger에서 지정된 이름의 CronTrigger를 반환해줍니다.

package edu.batch.baseball.schedule;

import static edu.batch.support.launch.QuartzCronE-pressionTestUtils.*;

import java.text.ParseException;
import java.util.Arrays;
import java.util.List;

import javax.annotation.Resource;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.quartz.CronTrigger;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration( { "classpath:/launch-context.xml" })
public class BaseballQuartzScheduleTest {

    private static final String DATE_PATTERN = "yyyy/MM/dd hh:mm:ss";

    @Resource(name = "triggers")
    List<CronTrigger> triggers;

    @Test
    public void testbaseballJobTriggerSchedule() throws ParseException {
        CronTrigger trigger = findTriggerByName(triggers, "baseballJobTrigger");
        String initialTime = "2010/09/01 09:00:00";
        List<String> expectedTimeList = Arrays.asList(
                "2010/09/01 09:01:00",
                "2010/09/01 09:02:00",
                "2010/09/01 09:03:00",
                "2010/09/01 09:04:00");
        assertSchedule(trigger, initialTime, expectedTimeList, DATE_PATTERN);
    }

    @Test
    public void testbaseballExportJobTriggerSchedule() throws ParseException {
        CronTrigger trigger = findTriggerByName(triggers, "baseballExportJobTrigger");
        String initialTime = "2010/09/01 09:00:00";
        List<String> expectedTimeList = Arrays.asList(
                "2010/09/01 09:05:30",
                "2010/09/01 09:06:30",
                "2010/09/01 09:07:30",
                "2010/09/01 10:05:30",
                "2010/09/01 10:06:30",
                "2010/09/01 10:07:30");
        assertSchedule(trigger, initialTime, expectedTimeList, DATE_PATTERN);
    }
}
QuartzCronExpressionTestUtils
package edu.batch.support.launch;

import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;

import java.text.ParseException;
import java.util.Date;
import java.util.List;

import org.apache.commons.lang.time.DateFormatUtils;
import org.apache.commons.lang.time.DateUtils;
import org.quartz.CronTrigger;

public class QuartzCronExpressionTestUtils {

    public static void assertSchedule(CronTrigger trigger, String initialTime,
            List<String> expectedTimeList, String datePattern) throws ParseException {
            Date previousStartTime = DateUtils.parseDate(initialTime,   new String[]{datePattern});

            for(String expectedTime : expectedTimeList){
                trigger.setStartTime(previousStartTime);
                Date nextExecutionTime =  trigger.getFireTimeAfter(previousStartTime);
                String actualTime = DateFormatUtils.format(nextExecutionTime, datePattern);
                assertThat("executed on expected time", actualTime, is(expectedTime));
                previousStartTime = nextExecutionTime;
            }
        }

    public static CronTrigger findTriggerByName(List<CronTrigger> triggers, String triggerName) {
        for (CronTrigger trigger : triggers) {
            if (triggerName.equals(trigger.getName())) {
                return trigger;
            }
        }
        throw new IllegalArgumentException("cannot find trigger : "
                + triggerName);
    }
}
정상혁정상혁

SpringSource Tool Suite의 Java Agent based reloading은 개발할 때 .class파일을 고치면 그 파일만 리로딩을 시켜주는 기능입니다. JRebel과도 유사한데 아직 "Experimental' 표시가 붙어있고, 생긴지가 얼마안되는 기능이라서 JRebel만큼 성숙한 기술인지는 잘 모르겠습니다. 그래도 모든 경우에 다 reloading이 되지 않더라도 한번이라도 서버내렸다 올리는 시간을 절약할 수 있으면 그만큼 이득이기 때문에 없는 것보다는 도움이 됩니다.

이런 도구들이 있으면 좋기는 하지만 WAS올리기 전에 테스트 코드로 왠만한 건 다 검증을 하고, WAS 올려서는 JSP같은 View만 고치는 개발 방식이 바람직합니다. 테스트 코드를 잘 짜고 있다면 '개발 중에 WAS 리로딩한다고 시간이 많이 들어요..' 와 같은 이야기가 별로 안 나올 것입니다. 테스트 코드 작성은 익숙해지면 시간이 별로 안 걸리는 일입니다. 그리고 테스트 코드를 만들면 로직이 있는 모듈의 가까운 위치에서 다양한 케이스를 반복해서 테스트해 볼 수 있고, 에러 추적과 디버깅이 훨씬 편해집니다. 아무리 리로딩이 잘 지원된다고 해도, 화면을 띄어서 손으로 데이터를 매번 입력하는 시간을 없애줄 수는 없고, 리로딩이 시간보다 훨씬 긴 에러 추적과 디버깅 시간을 줄여줄 수는 없습니다. 그래서 결국에는 테스트코드 작성하는 것이 개발시간을 더 빠르게 합니다.

STS를 설치할 때 SpringSource Tool Suite를 한번에 받아서 설치했다면 Tc server도 같이 설치되지만, 이미 깔려진 Eclipse 위에 update를 했다면 tc Server는 아래 URL에서 별도로 받아야 합니다.

Java agent based Reloading 설정

  1. 'Servers' Tab에서 New메뉴로 새 서버를 추가한다. 1_new-server.jpg

  2. tc Server를 선택한다.

    • SpringSource tc Server v2.0 혹은 v2.1 또는 v2.5를 선택한다.

    • 참고로 SpringSource Tool Suite 2.6.1부터 tc server v2.5가 포함되어 있고, "VMware vFabric tc Server v2.5"라는 이름으로 "VM Ware" 분류 폴더 아래에 포함되어 있다.

    • 20_tc-server-select.jpg

  3. 처음 설정하는 것이라면, Tc sever가 설치된 위치를 지정합니다.

    • 21_tc-server-select.jpg

  4. 처음 설정하는 것이라면 "Create new instance"를 선택한다.

    • 4_new-server.jpg

  5. 같이 설치할 모듈을 지정합니다. 간단하게 'base’와 'nio’만 선택해 된다.

    • 5_new-server.jpg

  6. 설정된 Server의 "Overview" 탭에서 "Enable Java Agent-based reloading"을 선택한다.

    • 6_servers.jpg

  7. 시험 삼아서 서버를 시작해 본다..

    • 7_servers-start

  8. 서버가 시작되면서 "Agent based reloading is active"라는 메시지가 처음에 뜨면 제대로 설정이 된 것이다.

    • 8_start-log.jpg

Java agent based reloading 테스트 해보기

테스트용 프로젝트를 생성해서 reloading이 되는지 확인하는 과정이다.

  1. Ctrl +N을 누르고 "Spring Template Project"를 선택한다.

    • 9_new-spring-template.jpg 2."Spring MVC project"를 선택한다.

    • 10_new-spring-mvc-project.jpg 3.프로젝트명과 상위 패키지이름을 적는다.

    • 11_new-spring-mvc-project.jpg

  2. 생성된 프로젝트를 Run AS→ Run On Server로 실행한다.

    • 12_run-on-the-server.jpg

  3. 실행할 서버는 Java Agent Based Reloading을 설정한 tc Server로 지정한다.

    • 13_run-on-the-server.jpg

  4. 서버가 올라간 다음에 템플릿의 HomeController 클래스를 수정해보고, 메소드의 내용을 수정한 다음에 아래와 같이 전체 "Realoding…​" 메시지가 보이며 applicationContext loading 없이 해당 클래스만 리로딩 되는 것을 확인한다. 14_modify-controller

테스트 결과

  • Dynamic web project가 아니고 external web module로 추가한 경우 - 잘 됨

  • Servlet만으로 된 프로젝트 - 잘됨

  • Application Context 파일 수정 - 되기도 하고 안 되기도 함

  • Spring MVC의 controller에 @RequestMapping이 달린 새로운 메소드를 추가한 것을 인식 - 잘 안 됨