Post

두 대 이상 서버에서 한 번만 스케쥴링 작업 호출하기

개요

특정 주기로 단 한 번만 호출되어야하는 기능이 있다고 해보자. 예를 들어 30분 마다 예약된 메시지가 있는지를 검사하고 예약된 메시지가 있다면 사용자에게 단 한번만 메시지를 전송해야한다.

이를 가볍게 표현하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional
void runReservation() {
    ReservationTime reservationTime = reservationTimeResolver.convertReservationTime(LocalDateTime.now());
	
    // 현재 시각기준으로 예약되어 있는 메시지 정보를 가져온다.
    List<ReservationMessage> reservationMessages = reservationMessageSelector.findTarget(reservationTime);
  
    for (ReservationMessage reservationMessage : reservationMessages) {
        // 메시지를 전송한다.
        reservationMessageExecutor.execute(reservationMessage);
    }
}

스프링 프레임워크를 쓰고 있다면 반복적으로 실행해야하는 작업을 지정된 시간 간격 또는 특정 시점에 자동으로 실행되도록 지원하는 기능인 Spring의 스케쥴링 기능을 사용해볼 수 있다.

사용법을 아주 간단히 소개하자면 먼저 @EnableScheduling 선언하여 스케쥴링 기능을 활성화한다.

1
2
3
4
@EnableScheduling
@Configuration
public class ScheduledConfig {
}

그리고 다음과 같이 반복적으로 수행되기를 원하는 메서드에 @Scheduled 를 선언해준다. 여러가지 옵션도 있고 , 스케쥴러를 실행할 스레드 풀 설정도 할 수 있는데 가벼운 기능이므로 기본으로 설정된 값을 사용할 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Scheduled(cron = "0 0,30 * * * *")
@Transactional
void runReservation() {
    ReservationTime reservationTime = reservationTimeResolver.convertReservationTime(LocalDateTime.now());
	
    // 현재 시각기준으로 예약되어 있는 메시지 정보를 가져온다.
    List<ReservationMessage> reservationMessages = reservationMessageSelector.findTarget(reservationTime);
  
    for (ReservationMessage reservationMessage : reservationMessages) {
        // 메시지를 전송한다.
        reservationMessageExecutor.execute(reservationMessage);
    }
}

위와 같이 선언하면 30분 마다 runReservation 메서드를 호출한다.

두 대 이상 서버에서 스케쥴링 문제

Spring의 스케줄링 기능을 사용함으로써 아주 간단하게 30분마다 호출되는 기능을 구현했다.

다음 요구사항은 단 한번만 호출되도록 구현해야한다. 비지니스에 따라 다르겠지만 메시지 전송이라고 가정해보면 두 번 메시지가 사용자에게 전송되어서는 안된다.

이 문제는 서버가 한 대일 경우에는 단 한번만 호출되는 것이 보장되나 스케일 아웃을 고려하면 서버가 여러 대일 가능성이 크다는 것이 문제이다.

스케쥴링하는 잡이 많은 비지니스의 경우에는 스케쥴링하는 서버를 단 한대만 구성하여 이 문제를 해결하는 것도 방법이겠지만 그렇지 않다면 이 기능 하나 때문에 서버를 분리하는 것은 투자 리소스 대비 얻는 이득이 적을 수 있다.

그렇다면 두 대 이상 서버에서 스프링 스케쥴링 기능을 사용하면서 어떻게 단 한번만 호출되도록 할 수 있을까 ?

shedlock 이용하기

정확히 이런 문제를 해결하기 위한 shedlock 라이브러리가 존재하는데

ShedLock makes sure that your scheduled tasks are executed at most once at the same time. If a task is being executed on one node, it acquires a lock which prevents execution of the same task from another node (or thread). Please note, that if one task is already being executed on one node, execution on other nodes does not wait, it is simply skipped.

스케쥴링 작업이 최대 한 번만 실행될 수 있도록 기능을 제공하는 라이브러리로 한 작업이 이미 수행 중이라면 다른 작업은 스케쥴링 작업을 기다리지 않고 건너뛴다고한다.

즉 , 분산 환경에서 스케쥴링 작업이 여러 인스턴스에서 동시에 실행되지 않도록 보장하는 라이브러리로 다양한 스토리지의 분산 잠금을 통해 이를 제어하고 있는 것으로 보인다.

shedlock 설정을 위한 단계를 알아보면 먼저 Spring Boot 3.3.2 버전을 기준으로 build.gradle 에 다음과 같이 추가해준다. (부트 버전에 따라 shedlock 버전도 다른데 여기서 확인할 수 있다.)

1
2
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.15.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.15.0'

그리고 스프링 스케쥴링과 비슷하게 @EnableSchedulerLock 을 선언해준다. 이때 defaultLockAtMostFor 를 반드시 설정해줘야하는데 가이드 주는 시간은 스케쥴링에서 수행되는 시간보다 훨씬 긴 값을 설정하라고 권하고 있다. 대게는 30분마다 실행시 29분 , 60초마다 실행 시 59초 이런 식인 것 같다. 이 값은 lockAtMostFor 에 대한 글로벌한 기본 설정을 의미하는데 lockAtMostFor 가 무엇인지는 후술하겠다.

1
2
3
4
5
6
@EnableSchedulerLock(defaultLockAtMostFor = "29m")
@EnableScheduling
@Configuration
public class ScheduledConfig {

}

그 다음은 분산 잠금을 이용하기 위한 스토리지를 설정해야하는데 스프링 부트 환경이라면 일반적으로 JDBC 를 사용하고 있을 확률이 크니 JDBC 로 설정해보자. (문서에서 스토리지마다 분산 잠금 설정에 대한 안내가 되어 있다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@EnableSchedulerLock(defaultLockAtMostFor = "29m")
@EnableScheduling
@Configuration
public class ScheduledConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
                JdbcTemplateLockProvider.Configuration.builder()
                        .withJdbcTemplate(new JdbcTemplate(dataSource))
                        .usingDbTime()
                        .build()
        );
    }
}

그리고 잠금 테이블도 생성해준다.

1
2
CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until TIMESTAMP(3) NOT NULL,
    locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));

이러면 shedLock 을 사용할 수 있는 준비는 모두 완료되었다. 스케쥴링 작업에 shedLock 을 적용해보자.

shedLock 기능을 스프링에 통합한 방법 중 하나로 AOP 를 사용한 것으로 기술되어 있어서 스케쥴링 메서드에 @SchedulerLock 를 선언해주면 바로 적용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Scheduled(cron = "0 0,30 * * * *")
@SchedulerLock(name = "reservation", lockAtLeastFor = "29m", lockAtMostFor = "29m")
@Transactional
void runReservation() {
	ReservationTime reservationTime = reservationTimeResolver.convertReservationTime(LocalDateTime.now());
	
	// 현재 시각기준으로 예약되어 있는 메시지 정보를 가져온다.
	List<ReservationMessage> reservationMessages = reservationMessageSelector.findTarget(reservationTime);
  
	for (ReservationMessage reservationMessage : reservationMessages) {
		// 메시지를 전송한다.
		reservationMessageExecutor.execute(reservationMessage);
  }
}
  • name 은 분산 잠금을 위해 사용되며 잠금 단위이다.
  • lockAtLeastFor 는 잠금이 유지되어야 하는 최소 시간을 의미하며 스케쥴링 작업이 너무 빨리 끝나서 잠금을 빠르게 릴리즈하는 경우 스케쥴링이 두 번 실행되는 것을 방지하기 위한 설정이다. 안전장치인 것 같다.
  • lockAtMostFor 는 스케쥴링 작업이 실패해도 얼마나 잠금을 유지할 것인지를 설정하는 옵션이다. 일반적으로 스케쥴링 작업이 끝나면 잠금을 해제한다고한다.( 단 , lockAtLeastFor 가 설정되어 있으면 lockAtLeastFor 만큼 잠금이 유지될 것이다.) 예상되는 최대 작업 시간보다 lockAtLeastFor 값을 크게 잡아야 예상하는대로 동작할 것이다.

아무튼 이 부분은 가이드한 설정을 유지하는 것이 좋아보이고 스케쥴링에서 작업 시간이 주기 혹은 lockAtLeastFor 값보다 오래 걸릴 것 같으면 스케쥴링 작업안에서 비동기 호출을 고려해보는 것도 방법이다. 그러면 스케쥴링 메서드 자체는 빠르게 끝날테니 우려하는 문제를 원천 차단할 수 있다.

FOR UPDATE SKIP LOCKED 이용하기

FOR UPDATE SKIP LOCKED 는 FOR UPDATE 로 인해 잠금이 걸린 레코드는 건너뛰고(SKIP) 잠금이 없는 레코드만 조회하는 기능으로 잠금이 있는 경우에 대기하지 않아서 성능 향상을 이뤄낼 수 있는 옵션이다.

이 기능을 잘 활용하면 shedlock 과 비슷한 동작이 되도록 할 수 있다.

  • 스케쥴링 호출 시 특정 레코드를 베타 잠금으로 읽는다. EX) shedLock 의 name 이 reservation 인 레코드
  • 단 , 베타 잠금으로 읽되 SKIP LOCKED 옵션을 준다.
  • 다른 인스턴스에서 특정 레코드를 읽으면 베타 잠금이 걸려있어 레코드를 건너뛴다. == 아무동작하지 않고 메서드를 빠져나온다.
  • 베타 잠금을 건 스레드가 트랜잭션이 끝날 때 잠금을 릴리즈한다.

이러면 스케쥴링 잡은 인스턴스 수 만큼 호출되지만 실제 동작은 단 한번만 발생하게 된다.

하지만 SKIP LOCKED 을 사용하는 경우에 커버되지 못하는 케이스가 있는데 최초에 베타 잠금을 건 스레드가 너무 빨리 트랜잭션이 끝나서 잠금이 릴리즈되고 , 이때 다른 인스턴스에서 스케쥴링 작업을 호출하는 경우에 두 번 수행될 수 있다. (shedLock 은 이 문제를 lockAtLeastFor 로 해결했다)

잘 일어날 수 없는 케이스이긴 하나 운영 중에는 어떤 일이 발생하지 아무도 모르는 일이기도 하다.

마무리

FOR UPDATE SKIP LOCKED 를 사용하여 제어한다면 더 손쉽게 구현이 가능하나 커버되지 못하는 케이스가 있고 shedlock 라이브러리를 사용하면 조금 더 설정해야하는 부분이 있는 반면 조금 더 안전하게 여러 인스턴스에서 스케쥴링 작업을 단 한번만 호출하게 할 수 있다.

외전 - shedlock 테스트 하기

shedLock 을 설정해서 구현은 했는데 코드로 어떻게 테스트할 수 있을까 고민한 내용을 적어본다!

테스트 방법은 실제 개발 후 배포해서 동작을 테스트해볼 수도 있다. (물론 베타같은 운영 환경이 아닌 환경에서)

단 , 지속 리팩토링이 가능하려면 코드 실행으로 테스트할 수 있으면 좋다.

테스트 시나리오는 다음과 같다.

  • 테스트 환경이 인메모리 h2 이므로 테스트 실행시 마다 shedLock 테이블을 생성해줘야한다.
  • AOP 이므로 @SpringBootTest 선언해서 스케쥴링 호출 작업을 수행하는 클래스를 빈으로 주입받을 수 있어야한다.
  • 동시에 호출되어야하므로 테스트 내에서 스케쥴링 호출 작업을 비동기로 2회 이상 호출해야한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
   @Autowired
    private ReservationMessageRepository reservationMessageRepository;

    @Autowired
    private ReservationScheduledTask reservationScheduledTask;

    @Autowired
    private JdbcTemplate jdbcTemplate;
    
    
    @Test
    @DisplayName("shed-lock 테스트")
    void reservationShedLock() throws Throwable {
        // given
        // 1.테이블 생성
        String sql = """
                 CREATE TABLE shedlock(
                     name VARCHAR(64) NOT NULL,
                     lock_until TIMESTAMP(3) NOT NULL,
                     locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
                     locked_by VARCHAR(255) NOT NULL, 
                     PRIMARY KEY (name)
                );
                 """;
        jdbcTemplate.execute(sql);
        // 2. 데이터 세팅
        reservationMessageRepository.saveAll(List.of(
                new ReservationMessage(
                        "hello world1",
                        "membernumber",
                        LocalDateTime.now()
                ),
                new ReservationMessage(
                        "hello world2",
                        "membernumber",
                        LocalDateTime.now()
                ))
        );
        // 3. 비동기 호출 준비
        int repeatCount = 2;
        CountDownLatch countDownLatch = new CountDownLatch(repeatCount);
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        // when
        for (int i = 0; i < repeatCount; i++) {
            // 4. 비동기 호출
            executorService.submit(() -> {
                try {
                    reservationScheduledTask.runReservation(now);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        //5. 모든 비동기 호출 완료를 기다림
        countDownLatch.await();

        // then
        assertThat(sentMessageRepository.count()).isEqualTo(2);
    }

실제로 테스트는 성공하였고 reservationScheduledTask#runReservation 안에서 로깅을 해보았는데 한 번만 로깅된 것을 확인할 수 있었다.

Reference

This post is licensed under CC BY 4.0 by the author.