스프링부트

spring scheduler task에 shedlock + redis 적용방법

알쓸개잡 2023. 11. 20.

Spring 스케줄러를 사용하여 배치 작업 및 예약된 작업을 손쉽게 할 수 있지만 AWS ECS, EKS와 같은 클라우드 기반 분산 컴퓨팅 환경에서는 멀티 인스턴스로 서비스를 하는 경우가 많은데 이러한 경우 각 인스턴스의 스케줄 태스크를 동기화할 수 없어서 같은 태스크가 중복 실행이 되어 예기치 않은 문제를 만날 수 있다.

ShedLock은 이러한 중복 실행 문제에 대해서 각 인스턴스 간의 잠금 처리를 제공하여 하나의 인스턴스에서만 태스크를 실행할 수 있도록 한다. (태스크에 잠금 이름을 지정하여 동일한 이름에 대해서 잠금이 동작하므로 더 좁은 의미로는 태스크 간의 잠금 처리라고 하는 게 맞을 것 같다.)

 

  • ShedLock은 태스크 간의 락 처리를 위해서 MongoDB, JDBC, Redis, Hazelcast와 같은 외부 저장소를 지원한다.
  • ShedLock은 병렬로 실행할 준비가 되지 않았지만 안전하게 반복 실행할 수 있는 예약된 작업이 있는 상황에서 사용하도록 설계되었다.
  • 잠금은 시간 기반이며 ShedLock은 노드의 시간이 서로 동기화 되어 있다고 가정한다.

 

ShedLock 구성

ShedLock은 세 파트로 구성되어 있다.

  • Core - locking mechanism
  • Integration - Spring AOP 또는 수동 코드를 사용하여 애플리케이션과 통합
  • LockProvider - SQL DB, Mongo, Redis와 같은 외부 프로세스를 사용하여 잠금을 제공

 

Usage

Dependency

ShedLock 을 사용하기 위한 디펜던시는 다음과 같다.

<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-spring</artifactId>
    <version>5.10.0</version>
</dependency>

 

Annotation

Spring에서 스케줄 잠금을 활성화하려면 @EnableSchedulerLock 어노테이션을 사용한다.

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
class MySpringConfiguration {
    ...
}

 

스케줄 작업(Task)에 @SchedulerLock 어노테이션을 지정한다.

import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;

...

@Scheduled(...)
@SchedulerLock(name = "scheduledTaskName")
public void scheduledTask() {
    // To assert that the lock is held (prevents misconfiguration errors)
    LockAssert.assertLocked();
    // do something
}

SchedulerLock 어노테이션에는 다음의 속성이 있다.

  • name : 고유한 이름이 사용되어야 한다. 동일한 이름의 태스크는 여러 인스턴스에 대해서 잠금처리로 인해 한 번만 수행된다.
  • lockAtLeastFor : 잠금이 유지되어야 하는 최소 시간을 지정한다. Task 작업이 완료된 시간보다 지정된 시간이 길면 지정된 시간 동안 lock 된다. Task 작업 소요 시간이 지정된 시간보다 길면 lockAtMostFor에 지정된 시간까지 잠금이 유지되지만 lockAtMostFor duration 이전에 작업이 완료되면 잠금은 해제된다.
  • lockAtMostFor : 실행 노드가 죽었을 때 잠금을 얼마나 오래 유지해야 하는지 지정하는 속성이다. 정상적인 상황에서는 Task 작업이 완료되는 즉시 잠금이 해제된다. lockAtLeastFor 속성이 지정되지 않은 경우 정상적인 실행 시간보다 훨씬 긴 값으로 lockAtMost 속성을 지정하는 해야 한다. lockAtLeastFor > lockAtMostFor 인 경우 예외가 발생한다.

 

LockProvider Redis

Lock 데이터 저장소를 Redis를 사용할 것이다. 그 외 저장소에 대해서는 포스팅 하단에 있는 참고링크를 확인해 보기 바란다.

LockProvider에 대한 디펜던시는 다음과 같다.

<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-redis-spring</artifactId>
    <version>5.10.0</version>
</dependency>

 

LockProvider에 RedisConnectionFactory 를 전달하기 위한 구성이 필요하다.

import net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider;
import org.springframework.data.redis.connection.RedisConnectionFactory;

...

@Bean
public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
    return new RedisLockProvider(connectionFactory, ENV);
}

application.yml(. properties)에 Redis에 대한 연결 설정이 되어 있다면 RedisConnectionFactory는 자동 구성에 의해서 빈으로 자동 등록된다.

 

RedisLockProvider 클래스의 생성자는 다음과 같다.

public RedisLockProvider(@NonNull RedisConnectionFactory redisConn) {
    this(redisConn, "default");
}

public RedisLockProvider(@NonNull RedisConnectionFactory redisConn, @NonNull String environment) {
    this(redisConn, environment, "job-lock");
}

public RedisLockProvider(@NonNull RedisConnectionFactory redisConn, @NonNull String environment, @NonNull String keyPrefix) {
    this(new StringRedisTemplate(redisConn), environment, keyPrefix);
}
...

 

Redis에 Lock 데이터가 저장될 때 Key 형식은 다음과 같다.

{key-prefix}:{environment}:{SchedulerLock-name}

RedisLockProvider 클래스에서 keyPrefix, environment 가 전달되지 않을 경우에는 디폴트로 다음 값이 사용된다.

  • keyPrefix default : job-lock
  • environment default : default

 

기간 설정 형식

관련 어노테이션에 지정되는 duration 속성 형식은 다음과 같은 형식이 지원된다.

  • duration + unit - ex) 1s, 5ms, 5m, 1d (4.0.0부터)
  • duration in ms - ex) 100 (Spring integration 만)
  • ISO-8601 - ex) PT15M
Examples:

    "PT20.345S" -- parses as "20.345 seconds"
    "PT15M"     -- parses as "15 minutes" (where a minute is 60 seconds)
    "PT10H"     -- parses as "10 hours" (where an hour is 3600 seconds)
    "P2D"       -- parses as "2 days" (where a day is 24 hours or 86400 seconds)
    "P2DT3H4M"  -- parses as "2 days, 3 hours and 4 minutes"
    "P-6H3M"    -- parses as "-6 hours and +3 minutes"
    "-P6H3M"    -- parses as "-6 hours and -3 minutes"
    "-P-6H+3M"  -- parses as "+6 hours and -3 minutes"

 

 

 

ShedLock + Redis 샘플 코드

ShedLock을 이용하여 두 개의 Task가 한 타임에 한 번씩 실행하도록 간단한 샘플코드를 구현해 보았다.

 

  • spring boot docker compose를 이용하여 애플리케이션 실행 시 redis를 docker에 올리도록 구성하였다. spring boot docker compose에 대한 내용은 아래 포스팅을 참고하기 바란다.
  • spring boot docker compose를 사용하므로 로컬에 docker 엔진이 구동되어 있어야 한다.
  • docker compose CLI를 사용할 수 있어야 한다.

2023.10.22 - [스프링부트] - spring boot 3.1 docker compose support

 

spring boot 3.1 docker compose support

spring boot 3.1부터 docker compose를 지원한다. docker compose를 수행하기 위한 yaml 정의 파일이 있다면 spring boot에서 docker compose를 자동으로 실행하여 container를 실행시키고 ConnectionDetails 추상화를 통해서

devel-repository.tistory.com

 

dependency

ShedLock + Redis를 사용하기 위한 dependency는 다음과 같다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-redis-spring</artifactId>
    <version>5.10.0</version>
</dependency>
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-spring</artifactId>
    <version>5.10.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-docker-compose</artifactId>
    <scope>runtime</scope>
    <optional>true</optional>
</dependency>

spring boot docker compose를 사용하기 위해서 spring-boot-docker-compose를 디펜던시로 추가하였다.

start.spring.io에서 디펜던시로 spring-boot-docker-compose와 spring-boot-starter-data-redis 디펜던시를 포함하면 자동으로 compose.yaml 파일을 생성해 준다. 다음은 자동 생성된 compose.yaml 내용이다.

services:
  redis:
    image: 'redis:latest'
    ports:
      - '6379'

 

Scheduler

매분마다 실행되는 두 개의 Task를 정의하였다.

@Service
@EnableScheduling
@Slf4j
public class MyScheduler {

	@Scheduled(cron = "0 * * * * *")
	@SchedulerLock(name = "perMinuteScheduler", lockAtLeastFor = "50s", lockAtMostFor = "55s")
	public void task1() {
		log.info("task1 run");
	}

	@Scheduled(cron = "0 * * * * *")
	@SchedulerLock(name = "perMinuteScheduler", lockAtLeastFor = "50s", lockAtMostFor = "55s")
	public void task2() {
		log.info("task2 run");
	}
}

실제로는 두 개의 컨테이너 인스턴스로 테스트를 해봐야겠지만 여건상 동일한 ShedLock name (perMinuteScheduler)을 가진 두 개의 Task를 가지고 시험해 보았다.

 

LockProvider Configuration

Lock 데이터를 저장할 LockProvider 구성을 정의해야 한다.

@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "PT1H")
public class ShedLockConfig {

	@Bean
	public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
		return new RedisLockProvider(connectionFactory);
	}
}

@EnableSchedulerLock(defaultLockAtMostFor = "PT1H")에서 defaultLockAtMostFor 속성은 @SchedulerLock 어노테이션의 lockAtMostFor 어노테이션이 지정되지 않을 경우 적용될 디폴트 duration이다.

Redis를 잠금 데이터 저장소로 사용할 것이므로 RedisLockProvider를 빈드로 등록해야 한다. RedisConnectionFactory는 auto configuration에 의해서 자동 주입된다.

 

ShedLock logging

ShedLock 동작 확인을 위해서 net.javacrumbs.shedlock 패키지를 debug 레벨로 설정하였다.

application.yml

logging:
  level:
    net.javacrumbs.shedlock: debug

 

애플리케이션 실행

애플리케이션을 실행하면 spring boot docker compose를 통해서 compose.yaml 이 docker compose에 의해서 실행된다.

INFO 10030 --- [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-shedlock-redis-1  Created
INFO 10030 --- [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-shedlock-redis-1  Starting
INFO 10030 --- [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-shedlock-redis-1  Started
INFO 10030 --- [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-shedlock-redis-1  Waiting
INFO 10030 --- [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-shedlock-redis-1  Healthy

 

task1과 task2가 매분마다 둘 중에 한 번만 실행됨을 알 수 있다.

//task2가 lock을 획득하여 실행하며, 잠금이 유지될 수 있는 최대 시간도 표시된다. (lockAtMostFor 55초)
2023-11-20T22:56:00.005+09:00 DEBUG : Locked 'perMinuteScheduler', lock will be held at most until 2023-11-20T13:56:55.001Z
2023-11-20T22:56:00.006+09:00  INFO : task2 run
//작업이 완료되었고 lockAtLeastFor에 의해서 50초간 lock은 유지됨을 알 수 있다.
2023-11-20T22:56:00.009+09:00 DEBUG : Task finished, lock 'perMinuteScheduler' will be released at 2023-11-20T13:56:50.001Z
//task1은 lock을 획득하지 못하여 실행되지 않는다.
2023-11-20T22:56:00.012+09:00 DEBUG : Not executing 'perMinuteScheduler'. It's locked.
...
//task1이 lock을 획득하여 실행하며, 잠금이 유지될 수 있는 최대 시간도 표시된다. (lockAtMostFor 55초)
2023-11-20T23:08:00.007+09:00 DEBUG : Locked 'perMinuteScheduler', lock will be held at most until 2023-11-20T14:08:55.002Z
2023-11-20T23:08:00.007+09:00  INFO : task1 run
//작업이 완료되었고 lockAtLeastFor에 의해서 50초간 lock은 유지됨을 알 수 있다.
2023-11-20T23:08:00.010+09:00 DEBUG : Task finished, lock 'perMinuteScheduler' will be released at 2023-11-20T14:08:50.002Z
//task2는 lock을 획득하지 못하여 실행되지 않는다.
2023-11-20T23:08:00.016+09:00 DEBUG : Not executing 'perMinuteScheduler'. It's locked.
...

 

redis-cli를 통해서 lock 데이터가 redis에 저장된 것을 확인해 보자.

ShedLock 데이터

  • 로그 기록 시간은 KST 시간이지만 Redis에 기록된 시간은 UTC임을 주의하자.
  • 잠금 데이터 Key는 {key-prefix}:{environment}:{SchedulerLock-name} 포맷으로 생성되므로 job-lock:default:preMinuteScheduler 이름으로 저장되었음을 알 수 있다.
  • 잠금 데이터는 잠금이 해제되면 삭제된다.
lockAtLeastFor 속성을 지정하지 않으면 디폴트 PT0S가 적용된다.
-> lockAtLeastFor 속성을 지정하지 않으면 태스크 작업이 완료되면 lock은 해제된다.

 

 

전체 샘플 코드는 gitlab에서 확인할 수 있다.


참고링크

https://github.com/lukas-krecan/ShedLock

 

GitHub - lukas-krecan/ShedLock: Distributed lock for your scheduled tasks

Distributed lock for your scheduled tasks. Contribute to lukas-krecan/ShedLock development by creating an account on GitHub.

github.com

 

댓글

💲 추천 글