1. 여러 스케줄링 작업이 있을 때 싱글 스레드로 인한 문제
* 참고: https://olsohee.tistory.com/239
스프링은 스케줄링 작업을 위한 TaskScheduler라는 인터페이스를 제공하고, 구현체로는 다음 두가지가 사용된다.
- 가상 스레드가 활성화된 경우(Java 21 이상 및 spring.threads.virtual.enabled가 true로 설정된 경우), SimpleAsyncTaskScheduler
- 가상 스레드가 활성화되지 않은 경우, ThreadPoolTaskScheduler
그리고 ThreadPoolTaskScheduler는 기본적으로 하나의 스레드를 사용하며, spring.task.scheduling을 통해 설정 값을 변경할 수 있다.
If virtual threads are enabled (using Java 21+ and spring.threads.virtual.enabled set to true) this will be a SimpleAsyncTaskScheduler that uses virtual threads. This SimpleAsyncTaskScheduler will ignore any pooling related properties.
If virtual threads are not enabled, it will be a ThreadPoolTaskScheduler with sensible defaults. The ThreadPoolTaskScheduler uses one thread by default and its settings can be fine-tuned using the spring.task.scheduling namespace.
* 참고: https://docs.spring.io/spring-boot/reference/features/task-execution-and-scheduling.html
하나의 스레드로 스케줄링 작업이 진행되기 때문에 여러 스케줄링이 동작할 경우 주의해야 한다. 만약, 스케줄링 작업1과 작업2가 동시에 수행되어야 한다면, 하나의 스레드가 순차적으로 수행하기 때문에 작업1의 수행을 마친 후에 작업2를 수행할 것이다.
1-1. 문제 상황
프로젝트에서 수행되는 스케줄링 작업은 두가지이다.
- 인기글 데이터를 추출하는 작업 (정각마다)
- 좋아요 수를 갱신하는 작업 (1분 주기)
이를 코드로 간략히 표현하면 다음과 같다. (테스트를 위해 인기글 추출 작업은 2분 주기, 좋아요 수 갱신 작업은 1분 주기로 수정했다.)
@Scheduled(cron = "0 */2 * * * *")
public void cacheBestPostsDto() {
log.info("cacheBestPostsDto(인기글 추출) 스케줄링 작업 시작");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("cacheBestPostsDto(인기글 추출) 스케줄링 작업 종료");
}
@Scheduled(cron = "0 */1 * * * *")
public void updateLikeCount() {
log.info("updateLikeCount(게시글 좋아요 수 갱신) 스케줄링 작업 시작");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("updateLikeCount(게시글 좋아요 수 갱신) 스케줄링 작업 종료");
}
테스트 결과, 인기글 추출 작업이 먼저 수행되고 3초 뒤 작업이 끝나면, 좋아요 수 갱신 작업이 수행된다.
따라서 좋아요 수 갱신 작업은 다른 작업과 관계 없이 1분 주기로 수행되어야 하는데, 인기글 추출 작업의 수행 시간이 끝난 후 수행되므로, 인기글 추출 작업의 수행 시간에 의존하게 된다. 즉, 의도대로 동작하지 않을 수 있다.
1-2. 해결
다음과 같이 스케줄링 작업을 수행하는 스레드를 2개로 늘려주었다.
@Configuration
public class SchedulerConfig {
@Bean
public TaskScheduler schedulerThreadPool() {
ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
threadPoolTaskScheduler.setPoolSize(2);
threadPoolTaskScheduler.setThreadNamePrefix("scheduler-");
return threadPoolTaskScheduler;
}
}
테스트 결과, 이전과 달리 두 작업이 서로 다른 스레드에 의해 동시에 수행된다.
2. 멀티 인스턴스 환경에서 중복으로 실행되는 스케줄링 작업
가용성 및 성능을 위해 동일 서버를 여러 대 띄우는 경우, 스케줄링 작업도 여러 번 실행될 수 있다.
2-1. 문제 상황
테스트를 위해 Docker로 2개의 포트에 각각 애플리케이션을 띄워 확인했을 때 다음과 같이 스케줄링 작업이 중복으로 수행됐다.
2-2. 해결
ShedLock 라이브러리를 사용하면 이러한 중복 실행을 방지할 수 있다. ShedLock은 MongoDB, Redis, RDBMS 등의 외부 저장소를 사용하여 다른 노드나 스레드에서 동일한 스케줄러 작업이 중복 실행되지 않게 해주는 오픈소스 라이브러리이다.
- ShedLock은 스케줄링 작업이 최대 한 번만 실행되도록 한다. 만약 한 노드에서 스케줄링 작업을 수행 중이면, 해당 노드는 다른 노드가 동일 작업을 수행하지 않도록 락을 획득한다. 또한 이미 한 노드가 작업을 수행중이면, 다른 노드는 락을 대기하지 않고 건너뛴다.
- ShedLock은 coordination을 위해 Mongo, JDBC database, Redis, Hazelcast, ZooKeeper 등과 같은 외부 저장소를 사용한다.
- ShedLock은 분산형 스케줄러가 아니다. ShedLock은 병렬로 실행되지 않는 작업을 수행하며, 이를 안전하게 반복 실행할 수 있도록 설계되었다.
- 잠금은 time-based이여, 모든 서버 노드의 시간이 동기화되어 있다고 가정한다. 따라서 각 서버의 시간이 동기화되어 있어야 의도대로 동작한다.
2-2-1. 의존성 + @EnableSchedulerLock 추가
* 참고: https://github.com/lukas-krecan/ShedLock?tab=readme-ov-file#enable-and-configure-scheduled-locking-spring
먼저 ShedLock 의존성과 LockProvider를 위한 DB 의존성을 추가해야 한다.
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.15.1'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.15.1'
그리고 @EnableSchedulerLock을 붙여주어야 한다. 나는 이어서 나올 LockProvider를 구성하는 Config 파일에 붙여주었다.
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "30s")
public class ShedLockConfig {...}
2-2-2. LockProvider 구성
* 참고: https://github.com/lukas-krecan/ShedLock?tab=readme-ov-file#configure-lockprovider
사용하는 DB에 따라 여러 개의 LockProvider 구현체가 있다. 사용하는 DB에 ShedLock을 위한 테이블을 생성하고, LockProvider 설정을 해야 한다.
# 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);
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "30s")
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withTableName("shedlock") // shedlock 테이블 명
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime()
.build()
);
}
}
2-2-3. @SchedulerLock 적용
* 참고: https://github.com/lukas-krecan/ShedLock?tab=readme-ov-file#annotate-your-scheduled-tasks
마지막으로 ShedLock을 적용한 스케줄링 작업 메서드에 @SchedulerLock 어노테이션을 붙여주면 된다. 이때 @SchedulerLock 어노테이션이 붙은 스케줄링 메서드만 ShedLock이 적용된다. 즉, @SchedulerLock 어노테이션이 붙지 않은 @Scheduled 메서드는 ShedLock이 적용되지 않는다.
@Scheduled(cron = "0 */1 * * * *")
@SchedulerLock(name = "like_count_scheduler")
public void updateLikeCount() {
log.info("updateLikeCount(게시글 좋아요 수 갱신) 스케줄링 작업 시작");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("updateLikeCount(게시글 좋아요 수 갱신) 스케줄링 작업 종료");
}
@SchedulerLock 속성은 다음과 같다.
- name
- lock 이름
- 같은 이름을 가진 작업은 중복으로 수행되지 않고 딱 한 번만 수행된다.
- lockAtMostFor
- 락 지속 시간
- 작업의 평균적인 실행 시간보다 길게 설정해야 한다. 그렇지 않으면, 작업 수행 중에 락이 풀려, 두 개 이상의 노드가 락을 획득하는 등 문제가 발생할 수 있다.
- 설정하지 않으면, @EnableSchedulerLock에 설정된 값이 사용된다.
- 이 속성은 노드가 죽더라도 잠금이 해제되도록 해주는 안전 장치이다.
- lockAtLeastFor
- 잠금이 유지되는 최소 시간
- 이 속성은 노드 간 시간차가 있거나 작업 실행 시간이 매우 짧은 경우, 여러 노드에서 중복으로 스케줄링 작업을 수행하는 것을 방지한다. 즉, lockAtLeastFor 시간만큼 특정 노드가 잠금을 유지하고 있으므로, 그 사이 다른 노드는 스케줄링 작업을 수행할 수 없다.
위와 같이 설정하고 테스트한 결과, 이전과 다르게 하나의 노드에서만 스케줄링 작업이 수행됐다.
3. Graceful Shutdown 고려
스케줄링 작업이 수행되는 중에 서버가 죽으면 스케줄링 작업이 완료되지 않고 서버가 종료되는 문제가 발생한다. 이런 경우를 대비하여 Graceful Shutdown을 고려해야 한다.
다음과 같이 설정해주면 Graceful Shutdown이 적용된다.
@Configuration
public class SchedulerConfig {
@Bean
public TaskScheduler schedulerThreadPool() {
ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
...
threadPoolTaskScheduler.setWaitForTasksToCompleteOnShutdown(true);
threadPoolTaskScheduler.setAwaitTerminationSeconds(30);
return threadPoolTaskScheduler;
}
}
- setWaitForTasksToCompleteOnShutdown: 스케줄링 작업이 모두 종료될 때까지 기다린다는 옵션이다.
- setAwaitTerminationSeconds: 1번 옵션이 true인 경우, 모든 작업이 종료될 때까지 무한정 기다릴 수 없으니 30초까지만 기다리겠다는 옵션이다. 해당 옵션에 값을 지정하지 않으면 작업이 종료될 때까지 기다리지 않고 바로 종료된다.
Reference
'Backend > Spring' 카테고리의 다른 글
@Async, @Scheduled의 스레드에 대해 (0) | 2024.10.21 |
---|---|
스프링의 트랜잭션 전파(REQUIRED, REQUIRES_NEW) (0) | 2024.01.17 |
스프링의 트랜잭션 AOP(@Transactional)의 특징과 주의사항 (0) | 2024.01.16 |
스프링의 트랜잭션 (0) | 2024.01.16 |
JDBC(Java Database Connectivity), 커넥션 획득 방법 (0) | 2024.01.15 |