목차
들어가기 전에
여러가지 이유로 구동되고 있는 서버를 증설해야 하는 경우가 있습니다.
이렇게 서버가 증설되어 여러 대의 서버 인스턴스에서 동일한 서버 애플리케이션이 구동되고 있다면 스케쥴러를 통해 정해진 시간에 주기적으로 동작하는 로직은 여러 서버에서 동시에 실행되었을 때 문제가 발생할 수 있기 때문에 오직 하나의 서버에서만 스케쥴러 작업이 수행되도록 하는 것이 좋습니다.
이번 포스트에서는 오픈 소스 라이브러리인 ShedLock을 사용하여 스프링 애플리케이션의 스케쥴러가 중복으로 실행되지 않도록 방지하는 내용을 다루고자 합니다.
ShedLock?
ShedLock은 MongoDB, Redis, RDBMS 등의 외부 저장소를 사용하여 다른 노드나 스레드에서 동일한 스케쥴러 작업이 중복 실행되지 않도록 해주는 오픈소스 라이브러리이며, 간단한 설정만 해주면 손쉽게 적용할 수 있다는 장점이 있습니다.
다만 시간을 기반으로 동작되도록 설계되어 있기 때문에 각 서버 노드의 시간이 동기화되어 있어야 의도한대로 동작한다는 한계가 있습니다.
기본 설정
의존성 추가
먼저 의존성을 추가해줍니다.
스프링 또는 스프링부트 버전에 따라 추가해야 하는 의존성의 버전이 다르며, 구현하기 위해 사용하는 외부 저장소의 종류에 따라 추가해야 하는 의존성이 다르다는 점을 주의해야 합니다.
이 포스트에서는 JDBC를 사용합니다.
공통 의존성(build.gradle)
// Spring Framework 6 이상 또는 Spring Boot 3 이상인 경우
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.15.1'
// Spring Framework 6 미만 또는 Spring Boot 3 미만인 경우
implementation 'net.javacrumbs.shedlock:shedlock-spring:4.44.0'
공통 의존성(build.gradle.kts)
// Spring Framework 6 이상 또는 Spring Boot 3 이상인 경우
implementation("net.javacrumbs.shedlock:shedlock-spring:5.15.1")
// Spring Framework 6 미만 또는 Spring Boot 3 미만인 경우
implementation("net.javacrumbs.shedlock:shedlock-spring:4.44.0")
추가 의존성(build.gradle)
// JDBC 사용 시
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.15.1'
// MongoDB 사용 시
implementation 'net.javacrumbs.shedlock:shedlock-provider-mongo:5.15.1'
추가 의존성(build.gradle.kts)
// JDBC 사용 시
implementation("net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.15.1")
// MongoDB 사용 시
implementation("net.javacrumbs.shedlock:shedlock-provider-mongo:5.15.1")
추가 의존성도 공통 의존성과 마찬가지로 스프링 또는 스프링부트 버전에 따라 의존성 버전을 변경해야 하며, 그 외의 다양한 외부 저장소별 의존성은 공식 깃허브 레포지토리에서 확인하실 수 있습니다.
스케쥴러 잠금을 위한 테이블 생성
ShedLock에서 스케쥴러가 잠겨있는지 체크하려면 스케쥴러를 잠그기 위한 테이블이 필요합니다.
RDBMS의 종류에 따라서 아래 DDL을 실행하여 테이블을 생성해줍니다.
# MySQL, MariaDB
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));
# Postgres
CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until TIMESTAMP NOT NULL,
locked_at TIMESTAMP NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));
# Oracle
CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));
# MS SQL
CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until datetime2 NOT NULL,
locked_at datetime2 NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));
# DB2
CREATE TABLE shedlock(name VARCHAR(64) NOT NULL PRIMARY KEY, lock_until TIMESTAMP NOT NULL,
locked_at TIMESTAMP NOT NULL, locked_by VARCHAR(255) NOT NULL);
각 컬럼이 나타내는 값은 다음과 같습니다.
name
: 어떤 작업에 대한 잠금인지 구분하기 위한 이름lock_until
: 잠금이 풀리는 시간locked_at
: 잠금이 시작된 시간locked_by
: 스케쥴러를 잠근 주체(기본값 = 인스턴스의 호스트명)
위 DDL에 작성된 테이블 이름과 컬럼 이름은 기본값이며 커스텀이 가능합니다.
LockProvider 스프링 빈 등록
ShedLock이 정상적으로 동작하려면 LockProvider 객체가 스프링 빈으로 등록되어 있어야 합니다. 아래와 같이 스프링 빈 등록을 해줍니다.
@Configuration
class ShedLockConfig {
@Bean
fun lockProvider(dataSource: DataSource) = JdbcTemplateLockProvider(dataSource)
}
만약 ShedLock을 구현하기 위한 테이블 이름이나 컬럼 이름을 커스텀하였다면 아래와 같이 커스텀을 할 수도 있습니다.
@Configuration
class ShedLockConfig(
@Value("\${server.port}") private val port: String
) {
@Bean
fun lockProvider(dataSource: DataSource) = JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(JdbcTemplate(dataSource))
.withTableName("custom_shedlock")
.withColumnNames(
JdbcTemplateLockProvider.ColumnNames(
"custom_name",
"custom_lock_until",
"custom_locked_at",
"custom_locked_by"
)
)
.withLockedByValue("${InetAddress.getLocalHost().hostName}:$port")
.build()
)
}
withTableName
의 인자에 커스텀한 테이블의 이름을, JdbcTemplateLockProvide.ColumnNames
의 인자에 차례대로 name
, lock_until
, locked_at
, locked_by
에 해당하는 커스텀한 컬럼의 이름을 입력해주면 됩니다.
withLockedByValue
에는 locked_by
에 기록할 잠금을 얻은 주체를 설정해주면 됩니다. 기본값은 인스턴스의 호스트 이름이며, 위 예시에서는 호스트 이름 뒤에 애플리케이션이 가동되고 있는 포트를 추가로 붙여서 기록하도록 설정하고 있습니다.
이 때 DB에서 테이블 이름과 컬럼 이름의 대소문자를 구분한다면 해당 설정에서도 마찬가지로 대소문자를 구분하여야 합니다.
마지막으로 메인 클래스 또는 잠그고자 하는 스케쥴러가 있는 클래스에 @EnableSchedulerLock
를 설정하여 ShedLock 설정을 마무리해줍니다.
@EnableSchedulerLock(defaultLockAtMostFor = "30s")
@EnableScheduling
@SpringBootApplication
class DemoApplication
fun main(args: Array<String>) {
runApplication<DemoApplication>(*args)
}
여기서 defaultLockAtMostFor
의 값에는 스케쥴러 잠금이 유지될 최대 시간을 입력해줍니다. 위 예시는 모든 스케쥴러 잠금에 대해 기본값으로 최대 잠금 유지시간을 30초로 설정하여 스케쥴러 작업이 종료되지 않더라도 30초가 지나면 잠금을 해제하는 설정입니다.
이외에도 defaultLockAtLeastFor
의 값을 설정하여 잠금이 반드시 유지되는 최소 시간을 설정할 수도 있습니다. 이 값은 서버가 구동되는 인스턴스들의 시간이 완전히 동기화되지 않는 경우를 고려하여 어느정도 값을 정해주는 것이 좋습니다.
ShedLock으로 스케쥴러 잠그기
설정이 끝났으니 이제 스케쥴러에 잠금을 걸어봅시다.
스케쥴러를 잠그는 방법은 간단합니다. 스케쥴 작업이 설정된 메소드에 @SchedulerLock
을 달아주기만 하면 됩니다.
@Component
class DemoScheduler {
private val log = LoggerFactory.getLogger(this.javaClass)!!
@Scheduled(cron = "0/5 * * * * *")
@SchedulerLock(name = "loggingScheduler", lockAtLeastFor = "1s")
fun loggingScheduler() {
log.info("Logging every 5 seconds")
}
}
여기서 name
은 각 스케쥴러 작업을 구분하기 위한 고유 값, lockAtLeastFor
는 잠금을 유지할 최소 시간을 설정합니다.
lockAtLeastFor
의 기본값은 0초이며 위와 같이 설정할 경우 작업이 1초 이내에 끝나더라도 스케쥴러 잠금은 1초동안 유지됩니다.
이렇게 설정하고 두 개의 애플리케이션을 실행해봅시다.
스케쥴러가 동시에 실행되는 일 없이 둘 중 하나의 애플리케이션에서만 실행되는 모습을 볼 수 있습니다.
실제로 ShedLock 설정에서 지정한 테이블을 확인해보면 이렇게 나옵니다.
어노테이션을 사용하지 않고 잠그는 방법
@SchedulerLock
을 사용하지 않고 직접 잠금을 설정하는 방법도 있습니다.
이 방법을 사용할 경우 스프링 빈으로 등록한 LockProvider를 주입받아야 합니다.
@Component
class DemoScheduler(
private val lockProvider: LockProvider
) {
private val log = LoggerFactory.getLogger(this.javaClass)!!
@Scheduled(cron = "\${task.scheduler-cron}")
fun loggingScheduler() {
val lockConfig = LockConfiguration(
Instant.now(),
"loggingScheduler",
Duration.ofSeconds(30),
Duration.ofSeconds(1)
)
val lock = lockProvider.lock(lockConfig)
lock.ifPresent {
try {
log.info("Logging every 5 seconds")
} finally {
it.unlock()
}
}
}
}
LockConfiguration 생성자는 다음과 같이 정의되어 있습니다.
createdAt
은 스케쥴러를 잠그는 시간이므로, 스케쥴러가 실행되는 시점의 현재 시간을 설정하기 위해 Instant.now()
로 설정합니다.
name
은 @SchedulerLock
에서의 name
과 동일합니다.
lockAtMostFor
와 lockAtLeastFor
는 @SchedulerLock
에서의 값과 비슷하지만 문자열이 아닌 Duration 객체를 전달해야 합니다. 위 예시코드에서는 각각 30초, 1초에 해당하는 Duration 객체를 전달하고 있습니다.
이후 LockProvider의 lock
을 통해 위에서 생성한 LockConfiguration 객체에 대한 잠금을 획득하고 잠금을 획득한 경우에만 메소드의 로직이 실행되도록 설정하면 됩니다.
위 예시 코드에서는 finally
를 사용해서 로직의 성공 여부와 관계 없이 반드시 잠금을 해제하도록 했습니다.
안타깝게도 LockProvider의lock
으로 얻은 SimpleLock 객체는 AutoCloseable의 구현체가 아니어서 자바의try-with-resources
나 코틀린의use
메소드를 사용할 수 없습니다.
이렇게 실행했을 때도 정상적으로 스케쥴러가 잠기는 것을 확인할 수 있습니다.
잠금을 획득했을 때의 로직을 조금 수정하여, 이미 스케쥴러가 잠겨있는 경우에 대한 로직을 작성할 수도 있습니다.
lock.ifPresentOrElse({
try {
log.info("Logging every 5 seconds")
} finally {
it.unlock()
}
}, {
log.warn("Scheduler is locked")
})
위와 같이 변경해놓고 애플리케이션을 실행해보면 한쪽은 잠금을 획득해서 Logging every 5 seconds가 출력되고 다른쪽은 스케쥴러가 잠겨있어서 Scheduler is locked가 출력되는 것을 볼 수 있습니다.
참조 링크
'Development > Spring & Spring Boot' 카테고리의 다른 글
[Spring/Spring Boot] 파일 다운로드와 multipart/form-data 업로드 컨트롤러 메소드 테스트 코드를 작성해보자 (0) | 2024.08.14 |
---|---|
[Spring] Spring REST Docs를 사용해서 API 명세서를 작성해보자 (0) | 2024.07.08 |
[Spring Boot] 스프링 인터셉터(Spring Interceptor)를 활용해서 API 로그를 DB에 기록해보자 (0) | 2024.03.01 |
[Spring Boot] AOP를 이용해서 DB에 API 로그를 남겨보자 (2) | 2024.02.06 |
[Spring Boot] @Value 대신 생성자 바인딩을 통해 프로퍼티 값을 바인딩하고 관리해보자 (0) | 2023.12.21 |
댓글