정상혁정상혁

Spring AOP에서 동일한 target클래스에 결합되는 Aspect의 우선순위는 Ordered 인터페이스를 구현하거나 @Order 애노테이션으로 지정합니다.

아래와 같은 방식입니다.

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.core.annotation.Order;

@Aspect
@Order(1)
public class OrderOneAspect {
    @Before("execution(void *.run())")
    public void printOrder(JoinPoint jp){
        System.out.println("order 1");
    }
}
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.core.Ordered;



@Aspect
public class OrderTwoAspect implements Ordered{
    @Before("execution(void *.run())")
    public void printOrder(JoinPoint jp){
        System.out.println("order 2");
    }
    public int getOrder() {
        return 2;
    }
}

OrderOneAspect 은 애노테이션으로, OrderTwoAspect은 인터페이스로 order 값을 선언했습니다.

Ordered 인터페이스의 javadoc에 보면 order의 숫자 값이 작을수록 우선 순위가 높다고 적혀 있습니다.

The actual order can be interpreted as prioritization, with the first object (with the lowest order value) having the highest priority.

그렇다면 AOP에서는 여러개의 Aspect를 엮을 때 order값을 낮게 선언한 Aspect가 먼저 실행될까요?

한번 테스트코드를 짜봤습니다. 위의 OrderOneAspect , OrderTwoAspect와 함께, 각각 0과 -1의 order를 가지는 Aspect를 더 추가했습니다.

@Aspect
@Order(0)
public class OrderZeroAspect {
    @Before("execution(void *.run())")
    public void printOrder(JoinPoint jp){
        System.out.println("order 0");
    }
}

@Aspect
@Order(-1)
public class OrderMinusOneAspect {
    @Before("execution(void *.run())")
    public void printOrder(JoinPoint jp){
        System.out.println("order -1");
    }
}

그리고 Target클래스와 Proxy를 만들어줄 설정을 javaConfig를 이용해서 추가합니다.

public class MainBeans {
    @Bean
    public ProxyConfig proxyCreator(){
        return new AspectJAwareAdvisorAutoProxyCreator();
    }
    @Bean
    public Runnable main(){
        return new Runnable(){
            public void run() {
                System.out.println("target executed");
            }
        };
    }
}

Aspect는 모두 "before" Advice로 그 Aspect의 order 값을 찍도록했고, 적용대상인 클래스는 단순히 "target executed"를 출력합니다. 출력창에 찍힌 내용을 보면 어떤 순서로 Aspect가 적용되는지 보입니다.

모두 ApplicationContext에 등록해서 Target클래스를 출력하는 테스트 코드를 만들었습니다.

public class AspectOrderTest {
    @Test
    public void checkOrder() throws Exception {
        GenericApplicationContext  context =
            new AnnotationConfigApplicationContext(
                OrderZeroAspect.class,
                OrderMinusOneAspect.class,
                OrderTwoAspect.class,
                OrderOneAspect.class,
                MainBeans.class);
        Runnable printer = context.getBean(Runnable.class);
        printer.run();
    }
}

출력 결과는 아래와 같습니다.

order 2 order 1 order 0 order -1 target executed

Order의 숫자가 큰 값이 먼저 나옵니다. 큰 Order 값인, 낮은 우선 순위인 Aspect가 먼저 실행되었다는 결과입니다. 제가 처음에 매뉴얼만 보고 이해한 의미와는 반대의 결과였습니다. 제가 설정을 잘못한 부분이 있는지도 모르겠지만, 이 결과만을 해석을 해보겠습니다.

Aspect에서 우선순위가 높다는 의미는 '안쪽의 proxy가 된다.'는 의미입니다. 즉, Proxy를 만들 때 Order값이 작은, 우선 순위가 높은 Aspect부터 먼저 Proxy를 만들고, 그 뒤에 다음 우선순위의 proxy들을 차례로 입혀갑니다. 그러니 'before' 나 'Around’Advice라면 가장 바깥 쪽에 Proxy로 입혀진 , order값이 큰, 우선순위가 낮은 Aspect의 코드가 먼저 실행됩니다.

정리하면, Order값 숫자가 작을수록 우선 순위가 높아지고, 우선 적용되어서 더 안 쪽에서 감싸지는 proxy가 되어서 타겟 Object의 코드와 붙어있는 Apspect가 된다는 의미입니다. 즉, Aspect 설정에서 order값의 숫자가 클 수록 target객체에서 더 멀어지는 바깥 쪽의 Proxy로 결합됩니다.

정상혁정상혁
  • 변경 이력

    • 2024.06.05 스프링 프레임워크 버전 5.3 이상에 맞는 코드로 수정


스프링에서는 @Schedule 애너테이션을 통해 크론 표현식으로 특정 메서드를 예약 실행할 수 있습니다. 그런데 실제로 잘 실행될지는 해당 시간이 되어야 알 수 있으므로 실수가 있어도 늦게 발견될 가능성이 높습니다. 예를 들면 매일 5시 20분, 40분, 60분에 실행될 일정을 지정하고자 했는데 "* 0/20 5 * * * ?"로 써야 할 표현식을 "* 0,20 5 * * * ?"으로 써놓고는 실운영 서버에 배포 한 후 하루가 지난 다음에 실수를 발견하는 경우입니다. 크론 표현식이 기대대로 해석되는지도 이 글을 참고하여 테스트 코드로 검증을 한다면 이런 사고를 막을 수 있습니다.

예제는 스프링 프레임워크 6.1.8 버전에 의존하여 작성되었으나 버전 5.3 이상에서는 동일한 방식으로 사용할 수 있을 것으로 예상합니다. 5.3버전을 기준으로 날짜 관련 클래스가 JDK 8의 API를 활용하는 방식으로 바뀌었습니다. 스프링 3.0 에서는 java.util.Date 등의 과거 API를 활용하는 방식으로 응용하실 수도 있습니다.

크론 표현식으로 예약 실행할 메서드 지정

대상이 되는 메소드에 @Schedule 애너테이션의 cron, zone 속성을 지정합니다. zone 속성은 필수는 아니지만 지정하면 명확성이 높아지고 시스템 기본값에 영향받지 않는다는 장점이 있습니다.

JobSchedule.java
import java.time.Instant;
import java.util.Properties;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@EnableScheduling
public class JobSchedule {

  @Scheduled(cron = "0 0,10 0 * * ?", zone = "Asia/Seoul")
  public void startHelloJob() {
    // 실행할 코드
  }
}

테스트 코드

크론 표현식을 추출하고 검사하는 코드는 ScheduleTestUtils 클래스로 분리했습니다.

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import org.junit.jupiter.api.Test;

class JobScheduleTest {
  @Test
  void scheduleHelloJob() {
    var initialTime = LocalDateTime.of(2024, 6, 10, 0, 5);
    List<LocalDateTime> expectedTimes = List.of(
        LocalDateTime.of(2024, 6, 10, 0, 10),
        LocalDateTime.of(2024, 6, 11, 0, 0),
        LocalDateTime.of(2024, 6, 11, 0, 10)
    );
    ScheduleTestUtils.assertCronExpression(
        JobSchedule.class, "startHelloJob",
        toInstant(initialTime),
        expectedTimes.stream().map(this::toInstant).toList()
    );
  }

  private Instant toInstant(LocalDateTime time) {
    return time.atZone(ZoneId.of("Asia/Seoul")).toInstant();
  }
}
ScheduleTestUtils.java
import static org.assertj.core.api.Assertions.assertThat;

import java.lang.reflect.Method;
import java.time.Instant;
import java.util.List;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.support.CronTrigger;
import org.springframework.scheduling.support.SimpleTriggerContext;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;

public class ScheduleTestUtils {
  public static void assertCronExpression(
      Class<?> targetClass, String methodName,
      Instant initialTime, List<Instant> expectedTimes
  ) {
    Method method = ReflectionUtils.findMethod(targetClass, methodName);
    assertThat(method).isNotNull();

    Scheduled scheduled = method.getAnnotation(Scheduled.class);
    CronTrigger trigger = getTrigger(scheduled);
    var context = new SimpleTriggerContext(initialTime, initialTime, initialTime);

    for (Instant expected : expectedTimes) {
      Instant actual = trigger.nextExecution(context);
      assertThat(actual).isEqualTo(expected);
      context.update(actual, actual, actual);
    }
  }

  private static CronTrigger getTrigger(Scheduled scheduled) {
    // 스프링의 ScheduledAnnotationBeanPostProcessor 코드를 참고함
    if (StringUtils.hasText(scheduled.zone())) {
      return new CronTrigger(scheduled.cron(), StringUtils.parseTimeZoneString(scheduled.zone()));
    } else {
      return new CronTrigger(scheduled.cron());
    }
  }
}
정상혁정상혁

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