안녕하세요, 하원입니다.
하반기 공채와 막학기를 마치고 다시 글을 쓰러 왔습니다.
오늘은 @Scheduled와 다양한 @Scheduled의 특징들에 대해 간단하게 소개해 보겠습니다.
@Scheduled란?
메서드를 일정 시간 간격 또는 특정 시간에 실행하고 싶을 때 사용하는 어노테이션입니다.
반복 작업, 로그 작업, 통계 등 특정 시간대나 일정한 시간을 간격으로 작업이 필요할 때 주로 사용됩니다.
간단하게 일상생활에 비유하자면 타이머나 알림 서비스라고 말할 수 있습니다.
저는 프로젝트 내에서 @Scheduled를 통해 행사의 종료 날짜에 따라 해당 홍보 포스터가 종료 상태로 바뀔 수 있도록 구현하였습니다.
그러면 @Scheduled를 어떻게 사용하는지 간단하게 소개해 보겠습니다.
@Scheduled 사용법
@Scheduled를 사용하기 위해서 스케줄링 기능을 활성화해 줍니다.
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
public class SchedulingConfig {
}
위와 같이 Config 파일을 하나 생성해 주면 이제 @Scheduled를 사용할 수 있게 됩니다.
이 Config 파일을 생성하지 않으면 @Scheduled가 동작하지 않게 됩니다. 경험담입니다..!
이제 스케줄링 메서드를 작성해 주면 됩니다.
@Scheduled를 지정해 줄 메서드를 작성하고, 알맞은 @Scheduled 속성을 부여해 보겠습니다.
// 매일 정오마다 실행
@Scheduled(cron = "0 0 12 * * ?")
@Transactional
public void checkEndedPoster() {
// 종료된 포스터 조회
List<Poster> endedPosters = posterRepository.findEndDateBeforeNow(LocalDateTime.now());
// 종료된 포스터의 status를 END로 변경
for (Poster poster : endedPosters) {
Poster.endPoster(poster);
}
}
저는 위 코드와 같이 매일 정오마다 종료된 포스터를 검사하여 포스터의 상태를 END로 변경하는 메서드를 작성하였습니다.
여기서 핵심 코드는 @Scheduled(cron = "0 0 12 * * ?") 이 부분인데요, @Scheduled 어노테이션 안에 속성을 지정해서 특정 시간 또는 일정한 시간 간격으로 스케줄링을 설정할 수 있습니다.
저는 cron 표현식을 사용해서 매일 정오 때마다 해당 메서드가 실행되도록 설정하였습니다.
그럼 @Scheduled에 어떤 속성이 존재하는지 살펴보겠습니다.
@Scheduled 속성들
@Scheduled(fixedRate = 3000)
@Scheduled(fixedDelay = 3000)
@Scheduled(cron = "0 0/30 * * * ?")
cron 표현식
- 초 (0-59) 분 (0-59) 시 (0-23) 일 (1-31) 월 (1-12) 요일 (0{일}-6{토})
- * : 모든 값 (= 매일, 매초, 매분)
- ? : 특정하지 않음을 뜻함
- / : 간격을 지정, ex) 0 0/30 * * * * = 매 30분마다 실행
- L : 마지막을 의미, ex) 0 0 12 L * ? = 매월 마지막 날 12시에 실행
- 이외에도 더 많은 표현식이 있으니 찾아보시길 바랍니다.
위 3가지 속성 이외에도 fixedRateString, fixedDelayString, initialDelay, initialDelayString, 등 다양한 속성들이 존재합니다.
String이 붙은 속성들은 3000과 같은 숫자 표현이 아닌 "3000"과 같은 String 표현으로 값을 지정해 준다는 의미입니다.
@Scheduled 주의사항
기본적으로 @Scheduled는 단일 스레드 내에서 실행됩니다. 즉, 진행 중인 메서드가 완료되지 않으면 다른 스케줄링 메서드가 동작하지 않는다는 의미입니다.
@Scheduled(fixedRate = 5000)
public void task() throws InterruptedException {
Thread.sleep(10000); // 10초 소요
System.out.println("작업 실행");
}
위와 같은 예시를 들어보겠습니다.
fixedRate=5000로 지정했기 때문에 task라는 메서드는 5초마다 실행되어야 합니다. 그럼 위의 코드는 어떻게 실행될까요?
실행 결과
- 첫 번째 task 시작 (0초)
Thread.sleep(10000)으로 인해 10초 동안 작업 진행- 첫 번째 task 종료 (10초)
- 두 번째 task 시작 (10초)
-> 결과적으로 의도했던 것과는 다르게 10초마다 메서드가 실행됩니다.
원인
@Scheduled가 기본적으로 단일 스레드 내에서 실행되기 때문에 이전 메서드가 끝나지 않으면 다음 메서드가 실행되지 않습니다.
결과적으로 @Scheduled는 기본적으로 단일 스레드 내에서 동작하기 때문에, 메서드 내의 작업 상황 또는 다른 메서드 간의 관계로 인해 의도한 대로 동작하지 않을 수 있습니다.
해결 방법 - TaskScheduler
@Configuration @EnableScheduling public class SchedulerConfig { @Bean public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(5); // 스레드 풀 크기 설정 return scheduler; } }
TaskScheduler를 스프링 빈으로 등록해 줍니다.- 위 코드와 같이
ThreadPoolTaskScheduler객체를 생성합니다.- 스레드 풀의 크기를 지정해 줍니다.
- 지정된 수만큼 스레드를 미리 만들어 놓고, 요청이 들어올 때 해당 스레드들을 사용하여 실행하게 됩니다.
- 5개로 지정하면 스케줄 작업을 5개까지 병렬로 진행할 수 있음을 의미합니다.
- 스레드 풀을 5개로 지정했기 때문에, 최대 5개까지 스케줄 작업을 동시에 진행할 수 있습니다.
스케줄링 작업 도중, 예외가 발생하면 해당 스케줄링 메서드의 스레드가 더 이상 실행되지 않고 중단됩니다.
다시 말해, 해당 스레드는 더 이상 작동되지 않고 멈춤 상태를 유지하게 됩니다.
해당 오류가 반복적으로 발생하여 서버의 리소스를 고갈시킬 수 있다는 이유로 자동으로 복구되지 않고 중단된 상태로 남는다고 합니다.
그러면 어떻게 처리를 해줘야 할까요?
try-catch 예외 처리
@Scheduled(fixedRate = 5000)
public void task() {
try {
System.out.println("작업 시작");
if (예외 발생 조건) {
throw new Exception("예외 발생");
}
} catch (Exception e) {
System.err.println("작업 중 예외 발생: " + e.getMessage());
}
}
위 코드와 같이 예외가 발생하는 코드를 try-catch로 처리해 주면, 스레드가 중단되지 않고 계속 실행됩니다.
장단점
- 장점 : 예외로 인해 스레드가 중단되는 상황을 방지할 수 있습니다.
- 단점 : 예외가 발생해도 빠르게 알아차리기 어렵기 때문에, 로그를 통해 예외사항을 주기적으로 관찰하고 대처해야 합니다.
JVM 종료 시, 발생하는 플로우
@Scheduled 작업은 앞에서 언급한 ThreadPoolTaskScheduler와 같은 스레드 풀에서 실행됩니다.JVM이 일반 스레드의 작업 종료를 기다려 준다고 하던데요?
@Scheduled에서 사용되는 스레드는 일반적으로 데몬 스레드가 아닌 일반 스레드입니다.- 데몬 스레드는 JVM 종료 시 강제 종료되지만, 일반 스레드는 JVM이 종료를 기다려 줍니다.
- 하지만 스프링 애플리케이션이 JVM의 종료 신호를 받게 되면, 앞서 말했듯이 컨텍스트를 종료시키면서 스레드 풀도 함께 종료시키게 됩니다.
- 따라서 일반 스레드도 실행 중에 종료될 수 있습니다.
해결 방법 - Grateful Shutdown
# application.yml server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s
Grateful Shutdown은 현재 실행 중인 작업을 안전하게 종료할 수 있는 시간을 제공해 줍니다.- 위 예시에서는 종료 전 30초 시간을 제공하여 실행 중인 작업의 종료를 기다려 줍니다.
- 하지만 30초를 초과하면 작업이 강제로 중단되고 애플리케이션이 종료됩니다.
기능의 단점도 살펴보기
사실 @Scheduled에 대한 포스팅을 작성하면서 앞서 말했던 주의사항들을 처음 접하게 되었습니다. 실제로 프로젝트 내에서 @Scheduled를 사용하고 있음에도 불구하고 주의사항들을 숙지하지 않고 사용하고 있었습니다.
그저 기능 구현에만 집중했었기 때문에 해당 기능이 잘 돌아가기만 하면 그 이외의 경우들을 고려하지 않았던 것 같습니다.
앞으로는 새로운 기능들을 사용하더라도 해당 기능의 단점이나 주의사항을 잘 파악하며 적용해야겠습니다.
마무리
기능 구현에만 집중하다 보면 예외사항들을 고려하지 못하는 경우가 발생하는 것 같다. 실제로 배포 이후, 예외나 오류를 해결하는 과정에 기능 구현보다 더 많은 시간을 소요하는데 말이다.
기능 구현을 하면서 해당 기능에서 발생할 수 있는 예외사항을 고려하는 습관이 굉장히 중요하다는 것을 느끼게 되었다.