배치

프로그램을 개발하다보면 종종 정해진 시간이나 정해진 간격으로 특정 일을 수행하는 기능을 구현해야 한다. *nix에 포함되어있는 cron을 사용하기도 하고, Spring과 같이 framework을 사용하는 경우에는 framework 내장 기능을 사용하기도 한다. Spring에는 @Scheduled가 있어 annotation 하나로 정해진 시간(cron format)이나 정해진 간격(fixedRate)로 특정 method를 실행하도록 할 수 있다.

1
@Scheduled(cron = "0 10 23 * *")
2
public void readWebtoon() {
3
    // 매일 밤 11시 10분에 웹툰을 읽는 코드
4
}
5
6
@Scheduled(fixedRate = 5 * 60 * 1000)
7
public void sayGoHome() {
8
    // 매 5분마다 퇴근을 외치는 코드
9
}

모니터링

서비스 서버에는 언제, 어떤 일이 발생할 지 모르기 때문에 method의 처음과 끝에 모니터링 코드를 작성하고 있다.

  1. 먼저, 배치 시작 시간과 종료 시간을 저장할 class와 repository를 만든다.
    1
    @Data @Entity
    2
    public class TaskExecutionInfo {
    3
    4
        private String name;
    5
        private String cronExpression;
    6
        private Integer fixedInterval;
    7
        private LocalDateTime lastStartAt;
    8
        private LocalDateTime lastEndAt;
    9
        private String lastException;
    10
    }
    11
    12
    public interface TaskExecutionInfoRepository extends JpaRepository<TaskExecutionInfo, Integer> {
    13
    14
        TaskExecutionInfo findByName(String name);
    15
    }
  2. 이제 모니터링을 원하는 method의 시작과 끝에 코드를 추가한다.
    1
    @Scheduled(cron = "0 10 23 * *")
    2
    public void readWebtoon() {
    3
        TaskExecutionInfo info = taskExecutionInfoRepository.findByName("readWebtoon");
    4
        info.setLastStartAt(LocalDateTime.now());
    5
        info.setLastEndAt(null);
    6
        info.setLastException(null);
    7
        taskExecutionInfoRepository.save(info);
    8
    9
        Exception error;
    10
        try {
    11
            // 매일 밤 11시 10분에 웹툰을 읽는 코드
    12
        } catch (Exception e) {
    13
            error = e;
    14
        }
    15
    16
        info.setLastEndAt(LocalDateTime.now());
    17
        info.setLastException(error);
    18
        taskExecutionInfoRepository.save(info);
    19
    }

문제

위 모니터링 방법은 몇 가지 문제가 있다.

  1. 같은 코드가 중복된다: 물론 TaskExecutionInfoService 같은 class를 하나 만들면 n줄을 1줄로 줄일 수 있다.
  2. 모든 method의 시작과 끝에 n줄의 코드가 들어간다: …?
  3. try-catch로 코드 복잡도가 증가한다: …?
  4. Method의 시작과 끝에 코드를 추가하는걸 잊어버릴 수 있다: …?

1번은 쉽게 해결할 수 있지만, 2 ~ 4번 문제는 해결하기 힘들다. 이 때, AOP를 사용하면 쉽게 해결할 수 있다.

AOP

  1. Spring은 AspectJ를 사용한 AOP를 지원한다. 먼저, class를 하나 만들고 @AspectJ를 붙여준다.
    1
    @AspectJ
    2
    public class TaskMonitor {
    3
    4
    }
  2. AspectJ를 사용하기 위해서는 method를 지정해야 한다. 특정 패키지 하위의 모든 method를 지정할 수도 있고, 특정 이름을 가지는 method를 지정할 수도 있고, 인자나 반환값이 특정 타입인 method를 지정할 수도 있다. 우리는 주기적으로 실행되는 method를 지정해야 하므로, 다음과 같이 @Scheduled가 있는 method를 모두 지정한다.
    1
    @Pointcut("@annotation(org.springframework.scheduling.annotation.Scheduled)")
    2
    public void isScheduled() {}
  3. 이제 @Around로 메서드의 control-flow를 가져올 수 있다.
    1
    @Around("isScheduled()")
    2
    public Object profile(ProceedingJoinPoint pjp) throws Throwable {
    3
        try {
    4
            return pjp.proceed();
    5
        } catch (Exception e) {
    6
            throw e;
    7
        }
    8
    }
  4. 메서드의 시작과 끝에 수행될 코드를 작성한다.
    1
    private void recordStart(String name, Method method) {
    2
        TaskExecutionInfo executionInfo = taskExecutionInfoRepository.findByName(name);
    3
        if (executionInfo == null) {
    4
            // 기존 실행 정보가 없으면 새로 만든다.
    5
            Scheduled scheduledInfo = method.getAnnotation(Scheduled.class);
    6
    7
            executionInfo = new TaskExecutionInfo();
    8
            // @Scheduled의 설정 값을 읽어 모니터링에 도움이 될 만한 정보를 추가로 저장할 수 있다.
    9
            if (scheduledInfo.fixedRate() != -1L) {
    10
                // fixedRate의 기본값은 -1L이다. 밀리초 단위이므로 / 1000을 해서 초 단위를 저장한다.
    11
                executionInfo.setFixedInterval(scheduledInfo.fixedRate() / 1000);
    12
            } else if (!scheduledInfo.fixedRateString().equals("")) {
    13
                // fixedRateString의 기본값은 ""이다. PropertyResolver를 사용해서 application.properties에서 값을 가져온다.
    14
                String interval = propertyResolver.resolvePlaceholders(scheduledInfo.fixedRateString());
    15
                executionInfo.setFixedInterval(Long.parseLong(interval) / 1000);
    16
            } else if (!scheduledInfo.cron().equals("")) {
    17
                // cron의 기본값은 ""이다. cron expression을 그대로 저장한다.
    18
                executionInfo.setCronExpression(scheduledInfo.cron());
    19
            } else {
    20
                throw new IllegalArgumentException("at least one of cron or fixedRate should be given: " + name);
    21
            }
    22
        }
    23
    24
        executionInfo.setLastStartAt(LocalDateTime.now());
    25
        executionInfo.setLastEndAt(null);
    26
        executionInfo.setLastException(null);
    27
        taskExecutionInfoRepository.save(executionInfo);
    28
    }
    29
    30
    private void recordEnd(String name, Exception e) {
    31
        TaskExecutionInfo executionInfo = taskExecutionInfoRepository.findByName(name);
    32
        if (executionInfo == null) {
    33
            throw new IllegalStateException("no such task execution info: " + name);
    34
        }
    35
    36
        executionInfo.setLastEndAt(LocalDateTime.now());
    37
        executionInfo.setLastException(e == null ? null : e.getMessage());
    38
        taskExecutionInfoRepository.save(executionInfo);
    39
    }
  5. 3과 4를 더한다.
    1
    @Around("isScheduled()")
    2
    public Object profile(ProceddingJoinPoint pjp) throws Throwable {
    3
        MethodSignature signature = (MethodSignature) pjp.getSignature();
    4
        Method method = signature.getMethod();
    5
        String name = method.getDeclaringClass().getName() + "." + method.getName();
    6
        recordStart(name, method);
    7
    8
        try {
    9
            Object result = pjp.proceed();
    10
            recordEnd(name, null);
    11
            return result;
    12
        } catch (Exception e) {
    13
            recordEnd(name, e);
    14
            throw e;
    15
        }
    16
    }

개발한 TaskMonitor class는 TaskExecutionInfo class와 repository를 제외하면 dependency가 없다. 따라서, 프로젝트 공통으로 사용되는 git repository가 있다면 모든 프로젝트에 모니터링을 바로 붙일 수 있다. 며칠간의 삽질 끝에 귀찮은 일이 해결되었다. 이제 서버에 문제가 발생하면 고치는 일만 남았다. 살려줘