안녕하세요, 렌딧 Engineering 팀의 Sam입니다.

배포를 배포답게 시리즈 3번째 글에서는 앞서 소개했던 문제점 중 4번을 해결한 방법에 대해 적어보려고 합니다.

글에 앞서, 문제점을 해결하기 위해 만든 프로젝트의 규모가 작지 않아 전체 코드를 적을 수 없습니다. 기능을 이해하는데 필수적이지 않은 코드는 삭제하여, 복사 + 붙여넣기를 한다고 해도 정상 작동하지 않을 수 있습니다. 또한, 서버나 커밋 정보 등 외부에 공개할 수 없는 내용은 적당히 편집하였습니다.

또한, 새로 프로젝트를 시작한다면 이 글에서 제안한 방법보다 Spring Batch를 사용하는 것이 더 좋을 수 있습니다!

문제점 4 - Batch 서버

렌딧에서는 서로 다른 일을 수행하는 여러 개의 batch 서버를 사용하고 있습니다. Batch 서버에서 특정 작업이 수행되고 있거나, 수행이 임박했을 때는 배포를 막아야 합니다. 예를 들어, 매일 오후 1시 자동투자가 진행되는 동안 배포를 하게 된다면 특정 사용자만 자동투자가 되는 재앙이 발생할 수 있습니다. 또한, 오후 12시 59분 59초에 배포가 시작된다면, Spring Boot가 초기화되는 시간 동안 자동투자가 늦어질 것입니다. 하지만, 매분 수행되는 입금 확인이나 10분 정도의 주기로 실행되는 회계 검증 같은 작업은 배포로 인해 몇 분 정도 수행되지 않는다고 해도 특별한 문제가 되지 않습니다.

이 문제를 해결하기 위해 간단한 reflection을 사용하였습니다.

  1. 먼저, 다음과 같은 코드로 @Scheduled annotation이 붙어있는 모든 method의 이름과 cron expression을 가져올 수 있습니다.
    1
    @Service
    2
    public class BatchService {
    3
    4
        @Autowired
    5
        private ApplicationContext applicationContext;
    6
    7
        private static Map<LocalDateTime, List<String>> lookupAllBatchDateTimes(List<Object> beans, int limit) {
    8
            // 특정 시간에 수행되는 모든 배치의 메서드 이름입니다.
    9
            // 예: allBatchTimes.get(LocalDateTime.of(2018, 04, 09, 18, 30, 0)) 
    10
            //         == Arrays.asList("kr.co.lendit.leaveOffice.sayLoud", "kr.co.lendit.leaveOffice.dmToDirector");
    11
            Map<LocalDateTime, List<String>> allBatchTimes = new HashMap<>();
    12
    13
            for (Object bean: beans) {
    14
                Method[] methods = bean.getClass().getDeclaredMethods();
    15
                for (Method method : methods) {
    16
                    if (!method.isAnnotationPresent(Scheduled.class)) {
    17
                        continue;
    18
                    }
    19
                    Scheduled annotation = method.getAnnotation(Scheduled.class);
    20
                    String[] expressionFragments = annotation.cron().split(" ");
    21
                    if (expressionFragments.length < 5) {
    22
                        continue;
    23
                    } else if (Stream.of(0, 1, 2).anyMatch(x -> expressionFragments[x].contains("*"))) {
    24
                        continue; // 매초, 매분, 또는 매시간 수행되는 작업은 무시합니다.
    25
                    }
    26
                    String batchName = bean.getClass().getName() + "." + method.getName();
    27
                    log.info("Batch Info: ", batchName, expressionFragments);
    28
                    // (1) allBatchTimes에 데이터를 추가합니다.
    29
                }
    30
            }
    31
            return allBatchTimes;
    32
        }
    33
    34
        public void checkBatch() {
    35
            List<Object> beans = Arrays.stream(applicationContext.getBeanDefinitionNames())
    36
                .map(x -> applicationContext.getBean(x)).collect(Collectors.toList());
    37
            // 넉넉하게 20분 안에 실행될 작업만 가져옵니다.
    38
            Map<LocalDateTime, List<String>> allBatchDateTimes = lookupAllBatch(beans, 20);
    39
    40
            // (2) 앞으로 실행될 작업을 확인합니다.
    41
            // (3) 현재 실행중인 작업을 확인합니다.
    42
        }
    43
    }
  2. Cron expression과 정수 n을 입력하면 1일 전부터 최대 n분 뒤까지의 모든 실행 시각을 계산하는 코드를 작성합니다.
    1
    private static List<LocalDateTime> getAllTimeFromCronExpression(String cronExpression, int limit) {
    2
        CronSequenceGenerator cronSequenceGenerator = new CronSequenceGenerator(cronExpression);
    3
        // 24시간 이상 돌아가는 batch 작업은 없다고 가정합니다.
    4
        LocalDateTime startDateTime = LocalDateTime.now().minusDays(1);
    5
        LocalDateTime endDateTime = LocalDateTime.now().plusMinutes(limit);
    6
        Date iterateDate = Date.from(startDateTime.atZone(ZoneId.systemDefault()).toInstant());
    7
    8
        List<LocalDateTime> localDateTimes = new ArrayList<>();
    9
        while (true) {
    10
            iterateDate = cronSequenceGenerator.next(iterateDate);
    11
            LocalDateTime executedDateTime = LocalDateTime.ofInstant(iterateDate.toInstant(), ZoneId.systemDefault());
    12
            if (executedDateTime.isAfter(endDateTime)) {
    13
                break;
    14
            }
    15
            localDateTimes.add(executedDateTime);
    16
        }
    17
        return localDateTimes;
    18
    }
  3. 2번에서 만든 메서드를 1번의 (1)에 적용합니다.
    1
    for (LocalDateTime localDateTime : getAllTimeFromCronExpression(annotation.cron(), limit)) {
    2
        if (!allBatchTimes.containsKey(localDateTime)) {
    3
            allBatchTimes.put(localDateTime, new ArrayList<>());
    4
        }
    5
        allBatchTimes.get(localDateTime).add(batchName);
    6
    }
  4. 이제 20분 안에 실행될 작업을 확인하는 코드(1번의 (2))을 다음과 같이 작성할 수 있습니다.
    1
    LocalDateTime now = LocalDateTime.now();
    2
    List<LocalDateTime> futureDateTimes = allBatchDateTimes.keySet().stream()
    3
        .filter(x -> x.isAfter(now)).sorted().collect(Collectors.toList());
  5. 현재 실행 중인 작업을 확인하기 위해 모든 thread의 stack trace를 확인하는 메서드를 작성합니다.
    1
    private static List<String> lookupExecutingBatches(Set<String> allBatchNames) {
    2
        return Thread.getAllStackTraces().values().stream().flatMap(stacks ->
    3
            Arrays.stream(stacks).map(stack -> String.format("%s.%s", stack.getClassName(), stack.getMethodName()))
    4
                .filter(name -> allBatchNames.stream().anyMatch(x -> x.equals(name))))
    5
            .collect(Collectors.toList());
    6
    }
  6. 이제 현재 실행 중인 작업을 확인하는 코드(1번의 (3))를 다음과 같이 작성할 수 있습니다.
    1
    Set<String> allBatchNames = allBatchDateTimes.values().stream().flatMap(List::stream).collect(Collectors.toSet());
    2
    List<String> executingBatches = lookupExecutingBatches(allBatchNames);
  7. Bonus: AOP를 사용한다면, CGLIB를 사용하여 method proxy를 생성하기 때문에 1에서 bean.getClass().getDeclaredMethods()로 가져온 메서드에 @Scheduled가 없습니다. 다음 코드를 사용해서 proxy되지 않은 메서드를 가져올 수 있습니다.
    1
    private static Method getRealMethod(Object bean, Method method) {
    2
        try {
    3
            Class declaringClass = method.getDeclaringClass();
    4
            if (declaringClass.getName().contains("CGLIB")) {
    5
                return ((Advised) bean).getTargetSource().getTarget().getClass().getMethod(method.getName());
    6
            }
    7
        } catch (Exception e) {
    8
            // 발생하면 안됩니다.
    9
        }
    10
        return method;
    11
    }

위 코드를 사용하여 (i) n분 내에 실행 예정이거나 (ii) 현재 실행 중인 작업이 있으면 403을, 아닌 경우 200을 반환하는 http api를 만들었습니다. 그리고 Travis CI에 배포 요청을 하기 전, 해당 api 결과값을 확인합니다. 403인 경우 배포를 중지하고 다음과 같이 개발자에게 알려줍니다.

배치 배포 차단 데모

또한, 빌드가 끝나고 실제 instance에서 기존 instance를 죽이기 전 다음과 같이 한 번 더 확인합니다.

1
status_url=http://localhost:$current_port/api/tasks/
2
status=$(curl --write-out "%{http_code}\n" --silent --output /dev/null $status_url)
3
if [ $status == "403" ]; then
4
  echo "Error: batch has running or scheduled jobs"
5
  exit 1
6
fi

(눈치채신 분도 계시겠지만, 배포 시스템 코드를 적당히 고치고 environment variable을 사용하여 강제 배포 기능도 만들 수 있습니다)

이제 4번 문제가 해결되었습니다! 마지막 편인 배포를 배포답게! 배포 시스템 개선하기 (4)에서는 IDC에서 AWS CodeDeploy를 어떻게 사용했는지 다룰 예정입니다. 다음 글에서 뵙겠습니다><