@Scheduled / Virtual Thread, @Async, @Synchronized

English Version is Here.
서비스를 운영하다 보면 배치(Batch)를 이용해 주기적으로 실행되어야 하는 작업을 자동화할 일이 생긴다.
주기적으로 데이터의 상태를 확인하고 업데이트를 한다던가,
로그를 쌓거나 보고서를 작성하는 등 서비스의 특성에 따라 다양한 작업이 생길 수 있다.
아무튼 이런 시스템 배치 작업을 하기 위해...
Spring Boot에서는 Spring Batch 또는 스케줄러(@Scheduled 어노테이션)를 이용해 시스템 배치를 이용할 수 있다.
일반적으로 대량 데이터 처리, 단계별 로직과 같은 복잡한 작업을 하는 경우에는 Spring Batch(의존성 추가 필요),
비교적 단순하고 반복 작업인 경우에는 스케줄러 기반으로 구성한다.(스프링 기본제공)
Spring Batch is a lightweight, comprehensive batch framework designed to enable the development of robust batch applications that are vital for the daily operations of enterprise systems.
...
Spring Batch is not a scheduling framework. There are many good enterprise schedulers (such as Quartz, Tivoli, Control-M, and others) available in both the commercial and open source spaces. Spring Batch is intended to work in conjunction with a scheduler rather than replace a scheduler.
https://docs.spring.io/spring-batch/reference/spring-batch-intro.html
하지만! 배치는 대량의 데이터를 일괄적으로 처리할 뿐,
특정 주기마다 자동으로 돌아가는 스케줄러와는 성격이 조금 다르다.
흔히 함께 쓰이기는 하나 자동화 여부가 시스템 배치의 정의 조건은 아니다.
Spring Batch는 스케줄러를 대체하기보다는 스케줄러와 함께 쓰이기 위해 만들어졌다고 볼 수 있다.
아무튼 이 글에서는 @Scheduled 어노테이션을 쓰는 것을 기준으로 설명할 것이다.
매일매일 정해진 시간에 진행되는 작업들을 여러 개 운영하고 있다고 해 보자.
그 중에 한 가지의 작업이 굉장히 오래 걸릴 수 있는 함수를 포함하고 있다면 어떻게 될까?
@Log4j2
@Component
@EnableScheduling
public class SystemSchedule {
@Scheduled(cron = "0 * * * * *")
public void sleeping() throws InterruptedException {
log.info("*---------65초 동안 sleep--------*");
Thread.sleep(Duration.ofSeconds(65));
log.info("*-----------sleep 끝--------*");
}
@Scheduled(cron = "*/5 * * * * *")
public void shouting() {
log.info("* !!!!!!!!!!!!!!!!!!!!!! * ");
}
}
위 코드는 스케줄러를 이용해 5초마다 shouting() 이 실행되고, 1분마다 65초가 걸리는 작업을 진행한다.
그럼 결과는 아래처럼 나오게 된다.
[2025-08-12 14:36:15.016] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:36:20.002] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:36:25.005] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:36:30.004] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:36:35.003] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:36:40.004] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:36:45.005] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:36:50.003] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:36:55.005] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:37:00.001] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:37:00.002] [scheduling-1] : *---------65초 동안 sleep--------*
65초동안 sleep하느라 5초마다 실행되는 코드가 멈추고 다른 작업은 아무 것도 진행하지 않게 된다.
왜냐면, Spring 의 기본 TaskScheduler가 단일 스레드로 동작하기 때문이다.
그럼... 빨리빨리 끝나는데 반드시 주기적으로 꼬박꼬박 진행되어야 하는 작업이 예정되어 있다면 곤란해진다.
이걸 어떻게 해결할 수 있을까? 에서 시작해서 이것저것 시도해 본 결과를 정리해 보려고 한다.
1. 스레드풀 크기 늘리기
SchedulingConfigurer를 구현해 ThreadPoolTaskScheduler의 풀 크기를 늘리는 방법이다.
@Configuration
public class SchedulerConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("lilo-batch-");
scheduler.initialize();
taskRegistrar.setTaskScheduler(scheduler);
}
}
간단하고, 확장도 간편하고, 동시에 여러 작업을 알아서 스레드를 배분해 진행해 준다.
[2025-08-12 14:37:55.001] [lilo-batch-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:38:00.004] [lilo-batch-3] : *---------65초 동안 sleep--------*
[2025-08-12 14:38:00.006] [lilo-batch-2] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:38:05.003] [lilo-batch-1] : * !!!!!!!!!!!!!!!!!!!!!! *
자기가 알아서 여러 개의 스레드를 돌아가며 작업을 멈춤 없이 진행하는 것을 볼 수 있다.
단, 스레드의 수가 고정되기 때문에 스레드 수가 많은데 작업이 별로 없으면 메모리 사용량이 낭비될 수 있다.
2. 버추얼 스레드 사용하도록 설정하기
버추얼 스레드(Virtual Thread)란?
JDK 21부터 정식 지원된 새로운 스레드 구현 방식이다.
기존의 플랫폼 스레드(Platform Thread)는 운영체제의 스레드와 1:1로 매핑된다.
그래서 생성·전환 비용이 높고 수천 개 이상 만들기 어렵다.
버추얼 스레드는 JVM이 관리하는 경량 스레드로, OS 스레드와 1:N 방식으로 매핑된다.
덕분에 수십만 개의 스레드를 동시에 생성·실행할 수 있으며, 특히 I/O 대기 시간이 긴 작업에서 효율적이다.
CPU를 많이 쓰는 연산보다는 네트워크 호출, 파일 읽기/쓰기처럼 기다림이 많은 작업에 적합하다.
즉, 스레드 생성/전환 비용을 줄여서 많은 작업을 동시에 처리할 수 있게 하는 기술이라고 볼 수 있다.
@Configuration
@EnableAsync
public class VirtualThreadConfig {
@Bean
public TaskScheduler virtualThreadScheduler() {
return new ConcurrentTaskScheduler(
Executors.newScheduledThreadPool(
Runtime.getRuntime().availableProcessors(),
Thread.ofVirtual().factory()
)
);
}
}
이렇게 기본 TaskScheduler를 등록해 주면 버추얼 스레드를 사용하도록 설정할 수 있다.
3. @Async 사용하기
@Async는 메서드를 호출할 때 다른 스레드 풀의 스레드에서 비동기 실행한다.
기본 TaskExecutor 빈이 없는 경우 내부적으로 SimpleAsyncTaskExecutor를 기본 실행기로 사용한다.
SimpleAsyncTaskExecutor는 실제로는 새로운 스레드를 매번 생성해서 사용하고, 재사용하지 않는다.
@Log4j2
@Component
@EnableAsync
public class SystemSchedule {
@Async
@Scheduled(cron = "0 * * * * *")
public void sleeping() throws InterruptedException {
log.info("*---------65초 동안 sleep--------*");
Thread.sleep(Duration.ofSeconds(65));
log.info("*-----------sleep 끝--------*");
}
@Async
@Scheduled(cron = "*/5 * * * * *")
public void shouting() {
log.info("* !!!!!!!!!!!!!!!!!!!!!! * ");
}
}
@EnableAsync도 붙여 줘야 함
그러니까... 동일 메서드를 여러 번 호출하면 각기 다른 스레드에서 동시에 실행될 수 있다.
따라서 기본적으로 동시 실행(병렬 실행)이 허용된다.
즉, 이 작업이 빠르게 끝나지 않을 경우 다음 차례가 왔을 때 아래처럼 중복해서 다시 실행될 수 있다는 뜻이다.
[2025-08-14 11:16:50.005] [task-4] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-14 11:16:55.001] [task-5] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-14 11:17:00.007] [task-6] : *---------65초 동안 sleep--------*
[2025-08-14 11:17:00.008] [task-7] : * !!!!!!!!!!!!!!!!!!!!!! *
...
[2025-08-14 11:18:00.001] [task-5] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-14 11:18:00.001] [task-4] : *---------65초 동안 sleep--------*
[2025-08-14 11:18:05.001] [task-7] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-14 11:18:05.010] [task-6] : *-----------sleep 끝--------*
[2025-08-14 11:18:10.001] [task-8] : * !!!!!!!!!!!!!!!!!!!!!! *
sleep이 끝나지 않았는데도 다른 스레드에서 또다른 sleep이 실행되어 버렸다.
만약에 중복 실행되는 경우 데이터가 꼬일 수 있는 작업이라면 반드시 막아야 한다.
이건 @Async만의 단점이 아니다.
모두 각기 다른 스레드에서 같은 스케줄이 병렬 실행될 수 있다.
그럼 이런 중복 실행을 방지하려면 어떻게 해야 할까?
중복 실행 방지 1 - @Synchronized 사용하기
@Synchronized는 한 번에 하나의 스레드만 해당 메서드/블록에 진입하도록 락을 건다.
@Async가 붙어 있어도, @Synchronized 블록 안이라면 다른 스레드는 해당 메서드가 끝날 때까지 대기한다.
@Log4j2
@Component
@EnableAsync
public class SystemSchedule {
@Async
@Synchronized
@Scheduled(cron = "0 * * * * *")
public void sleeping() throws InterruptedException {
log.info("*---------65초 동안 sleep--------*");
Thread.sleep(Duration.ofSeconds(65));
log.info("*-----------sleep 끝--------*");
}
@Async
@Scheduled(cron = "*/5 * * * * *")
public void shouting() {
log.info("* !!!!!!!!!!!!!!!!!!!!!! * ");
}
}
@EnableAsync도 붙여 줘야 함
[2025-08-12 14:41:55.001] [task-8] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:42:00.001] [task-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:42:00.002] [task-2] : *---------65초 동안 sleep--------*
[2025-08-12 14:42:05.005] [task-3] : * !!!!!!!!!!!!!!!!!!!!!! *
...
[2025-08-12 14:43:05.002] [task-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-12 14:43:05.004] [task-2] : *-------------sleep 끝-----------*
[2025-08-12 14:43:05.004] [task-8] : *---------65초 동안 sleep--------*
[2025-08-12 14:43:10.001] [task-3] : * !!!!!!!!!!!!!!!!!!!!!! *
하지만 예시로서 작성했을 뿐, @Async와 같이 쓰기에는 올바르지 않다.
@Async의 의도는 동시 실행을 가능하게 하는 것이지만, @Synchronized는 이를 직렬화한다.
결과적으로 "멀티스레드 풀에서 돌지만 실제 실행은 순차적"이 되는 구조가 된다.
이런 경우라면 애초에 @Async를 쓰지 않고 스케줄러에서 직렬 실행 보장을 하는 것이 더 명확하다.
중복 실행 방지하기 2 - 오래 걸리는 작업용 스레드 분리하기
오래 걸리는 작업에만 사용할 스레드를 분리하는 방법이다.
Executor 빈을 등록하고, @Async 어노테이션을 통해 실행기를 지정해 줄 수 있다.
@Component
public class TaskExecutor {
@Bean("longTimeExecutor")
public Executor longTimeExecutor() {
return Executors.newSingleThreadExecutor();
}
}
@Async("fileZipExecutor")
@Scheduled(cron = "0 * * * * *")
public void sleeping() throws InterruptedException {
log.info("*---------65초 동안 sleep--------*");
Thread.sleep(Duration.ofSeconds(65));
log.info("*-----------sleep 끝--------*");
}
@Scheduled(cron = "*/5 * * * * *")
public void shouting() {
log.info("* !!!!!!!!!!!!!!!!!!!!!! * ");
}
그럼 실제로는 매 분 0초에 정확히 실행되어야 하지만,
아래처럼 1분이 지나도 추가로 5초 동안 잘 기다렸다가 차근차근 실행하는 걸 확인할 수 있다.
[2025-08-14 11:23:55.006] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-14 11:24:00.024] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-14 11:24:00.024] [pool-2-thread-1] : *---------65초 동안 sleep--------*
[2025-08-14 11:24:05.005] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
...
[2025-08-14 11:25:05.004] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-14 11:25:05.029] [pool-2-thread-1] : *-----------sleep 끝--------*
[2025-08-14 11:25:05.030] [pool-2-thread-1] : *---------65초 동안 sleep--------*
[2025-08-14 11:25:10.000] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
[2025-08-14 11:25:15.003] [scheduling-1] : * !!!!!!!!!!!!!!!!!!!!!! *
중복 실행 방지하기 3 - 락 걸기
@Scheduled 작업이 중복 실행되지 않도록 도와주는 대표적인 라이브러리는 ShedLock이 있다.
이를 활용하면 분산 환경에서도 단일 실행되도록 보장할 수 있다!
일단 의존성을 추가하고..
implementation("net.javacrumbs.shedlock:shedlock-spring:6.9.2")
락을 설정할 파일을 만들어 빈을 등록하고..
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m") // 잠금 유지 최대 시간
public class SchedulerConfig {
@Bean
public LockProvider lockProvider() {
// JVM 메모리 기반 Lock
return new SimpleLockProvider();
}
}
락을 걸고 싶은 메서드에 어노테이션을 붙이면 끝~
이 때 꼭 스케줄러가 지정되어 있지 않아도 메서드에 걸려 있으면 이 메서드를 직접 호출하는 경우에도 적용된다는 점!
// lockAtMostFor : 잠금 최대 시간 → 장애 시 자동 해제
// lockAtLeastFor : 최소 잠금 시간 → 작업이 빨리 끝나도 중복 실행 방지
@Scheduled(cron = "0 * * * * *")
@SchedulerLock(name = "sleepingTask", lockAtMostFor = "5m", lockAtLeastFor = "1m")
public void sleeping() throws InterruptedException {
log.info("*---------65초 동안 sleep--------*");
Thread.sleep(Duration.ofSeconds(65));
log.info("*-----------sleep 끝--------*");
}
@Scheduled(cron = "*/5 * * * * *")
public void shouting() {
log.info("* !!!!!!!!!!!!!!!!!!!!!! * ");
}
여기서 name은 잠금을 구분하는 식별자 역할을 한다.
하나의 어플리케이션에서 여러 개의 스케줄 작업이 있을 수 있는데,
ShedLock은 이 이름을 기준으로 잠금을 관리하기 때문에... 각 작업마다 고유한 이름을 지정해서 사용해야 한다.
참고 링크
SpringBoot 공식문서 - Virtual Threads
[카카오페이 기슬블로그] Project Loom - Virtual Thread에 봄(Spring)은 왔는가
[우아한형제들 기술블로그] Java의 미래, Virtual Thread
https://dkswnkk.tistory.com/728
'TIL > Java' 카테고리의 다른 글
| @Conditional 로 특정 빈 등록 조건 만들기 (0) | 2025.03.21 |
|---|---|
| JPAㅠ (0) | 2025.03.14 |
| [Gradle] --project-prop 옵션으로 특정 파일 제외하여 빌드하기 (2) | 2025.03.07 |




