Spring Boot @Scheduled 실전 운영 가이드: 다중 서버 중복 실행과 ShedLock
Spring Boot에서 캐시를 갱신하거나 만료 데이터를 정리하기 위해 @Scheduled를 사용하는 것은 전혀 이상한 일이 아니다. 설정이 간단하고 애플리케이션 코드와 자연스럽게 연결되기 때문에 실무에서도 자주 사용한다.
문제는 애플리케이션이 한 대일 때 작성한 스케줄러를 그대로 여러 대로 확장했을 때 시작된다.
예를 들어 매일 오전 9시에 안내 메일을 보내는 작업이 있다고 하자. 서버가 한 대일 때는 메일이 한 번만 발송된다. 그러나 서버를 세 대로 늘리면 세 인스턴스가 같은 메서드를 각각 실행한다. 같은 사용자에게 메일이 세 통 발송되거나, 같은 데이터를 세 번 적재하는 문제가 생길 수 있다.
반대로 CPU·메모리 확인이나 임시 파일 정리처럼 각 서버의 상태를 확인하는 작업은 모든 인스턴스에서 실행되어야 한다. 이런 작업에 분산 락을 걸면 여러 서버 가운데 한 대의 상태만 수집하게 된다.
따라서 핵심은 스케줄러를 무조건 잠그는 것이 아니다.
작업이 어느 범위에서 실행되어야 하는지 먼저 구분하고, 클러스터 전체에서 동시 실행을 막아야 하는 작업에만 분산 락을 적용해야 한다.
이 글에서는 @Scheduled의 실행 방식부터 스레드 풀, PostgreSQL 기반 ShedLock, 로컬 모니터링, Slack·이메일 알림의 중복 방지까지 차례대로 살펴본다.
실습 환경과 버전
예제는 다음 환경을 기준으로 한다.
- Java 17 이상
- Spring Boot 3.4·3.5 또는 4.x
- Gradle Groovy DSL
- PostgreSQL
- ShedLock 7.9.0
2026년 8월 29일 기준 ShedLock의 최신 안정 버전은 7.9.0이다. ShedLock 7.x는 공식 호환표에서 Java 17 이상과 Spring Boot 3.4·3.5 및 4.x 조합을 테스트 대상으로 안내한다. Spring Boot 3.0~3.3을 사용한다면 버전을 그대로 복사하지 말고 ShedLock 호환성 표에서 프로젝트에 맞는 계열을 선택해야 한다.
@Scheduled는 언제 사용하면 좋을까?
@Scheduled는 짧고 단순하며, 실패하더라도 다시 실행할 수 있도록 설계된 반복 작업에 잘 맞는다.
대표적인 예는 다음과 같다.
- 로컬 캐시 갱신 또는 정리
- 상태 점검과 간단한 통계 집계
- 임시 파일 정리
- 만료 데이터의 소규모 정리
- 짧은 외부 API 동기화
반면 다음 요구사항이 중요하다면 Spring Batch나 영속형 작업 스케줄러를 검토하는 편이 낫다.
- 수십만 건 이상을 청크 단위로 처리해야 한다.
- 실패한 위치부터 다시 시작해야 한다.
- 실행 이력과 성공·실패 상태가 반드시 남아야 한다.
- 재시도, 보상 처리, 누락 작업 보충이 필요하다.
- 작업 유실 없이 반드시 처리해야 한다.
@Scheduled 자체에는 영속적인 JobRepository, 중단 지점 재시작, 정확히 한 번 실행 보장이 없다. 비즈니스 로직에는 멱등성, 고유 실행 키, 데이터베이스 제약 조건 또는 중복 제거 로직을 별도로 설계해야 한다.
fixedDelay, fixedRate, cron의 차이
스케줄을 정하는 방식은 비슷해 보이지만 기준 시점이 다르다.
fixedDelay
이전 실행이 끝난 뒤 지정한 시간이 지나면 다음 실행을 시작한다.
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.MINUTES)
public void collectMetrics() {
// 이전 실행 완료 후 1분 뒤 다시 시작
}
작업 시간이 일정하지 않고 같은 작업이 밀리는 것을 피하고 싶을 때 이해하기 쉽다.
fixedRate
연속 실행의 시작 시점을 기준으로 주기를 계산한다.
@Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES)
public void refreshCache() {
// 시작 시점을 기준으로 1분 주기
}
스케줄러 풀과 실행 방식에 따라 다른 작업과 병렬로 실행될 수 있으므로, 공유 자원과 실제 실행 시간을 함께 고려해야 한다.
cron
벽시계의 특정 시각에 실행한다. Spring의 cron 표현식은 초를 포함한 6개 필드를 사용한다.
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Seoul")
public void runAtTwoAm() {
// 매일 한국 시간 오전 2시
}
운영 서버의 기본 시간대가 UTC일 수 있으므로, 정해진 현지 시각에 실행해야 한다면 zone을 명시하는 것이 안전하다.
운영에서 먼저 만나는 네 가지 문제
1. 다중 인스턴스의 중복 실행
@EnableScheduling은 애플리케이션 컨텍스트마다 스케줄을 등록한다. Docker 컨테이너나 Kubernetes Pod가 세 개라면 세 곳에서 각각 실행된다.
스레드 풀을 늘리는 것은 한 인스턴스 안의 동시 처리량을 바꾸는 설정일 뿐, 서버 간 중복 실행을 막아주지 않는다.
2. 기본 스케줄러 스레드의 병목
가상 스레드를 사용하지 않는 일반적인 Spring Boot 구성에서 ThreadPoolTaskScheduler의 기본 풀 크기는 1이다. 오래 걸리는 작업이 스레드를 점유하면 다른 스케줄이 늦어질 수 있다.
단, Java 21 이상에서 spring.threads.virtual.enabled=true를 사용하면 Spring Boot는 SimpleAsyncTaskScheduler를 구성하며 풀 크기 속성은 적용되지 않는다. 따라서 “Spring 스케줄러는 언제나 단일 스레드”라고 단정하면 정확하지 않다.
3. API와 자원 경쟁
스케줄러는 웹 애플리케이션과 CPU, 메모리, DB 커넥션, 외부 API 할당량을 함께 사용한다. 풀 크기만 크게 늘리면 DB 커넥션 풀이 먼저 고갈되거나 외부 서비스의 요청 제한에 걸릴 수 있다.
풀 크기는 작업 수가 아니라 다음 요소를 측정해 결정해야 한다.
- 작업별 최대 실행 시간
- 동시에 실행될 수 있는 작업 수
- CPU 중심인지 I/O 대기 중심인지
- DB 커넥션 풀과 외부 API의 허용량
- 지연돼도 되는 작업과 반드시 제시간에 실행해야 하는 작업
4. 실패 이력과 재시작 기능의 부재
반복 @Scheduled 작업에서 발생한 예외는 기본 오류 처리기가 로그를 남기고 억제하므로 보통 다음 주기는 계속 실행된다. 그러나 성공·실패 이력, 재시도 횟수, 중단 지점은 자동으로 저장되지 않는다.
예외를 잡은 뒤 System.err.println()만 호출하면 운영 모니터링에서 실패를 놓치기 쉽다. 구조화된 로그와 메트릭을 남기고, 반드시 처리해야 하는 작업은 별도의 실행 이력이나 배치 시스템으로 관리해야 한다.
스케줄러 풀 설정
간단한 Spring Boot 애플리케이션이라면 별도의 SchedulingConfigurer를 직접 만드는 것보다 설정 속성으로 시작하는 편이 관리하기 쉽다.
spring:
task:
scheduling:
thread-name-prefix: "mlog-scheduler-"
pool:
size: 4
shutdown:
await-termination: true
await-termination-period: 30s
spring.task.scheduling은 @Scheduled용 설정이다. spring.task.execution은 주로 @Async 등에 사용되는 실행기 설정이므로 서로 혼동하면 안 된다.
풀 크기 4는 정답이 아니라 예시다. 실제 운영에서는 실행 시간과 DB 사용량을 관찰한 뒤 조정해야 한다. 서로 성격이 크게 다른 작업은 Spring Framework의 작업별 scheduler 지정이나 별도 애플리케이션 분리도 고려할 수 있다.
작업을 로컬과 전역으로 구분하기
모든 인스턴스에서 실행할 로컬 작업
- 각 인스턴스의 CPU·JVM·컨테이너 지표 수집
- 인스턴스 로컬 캐시 정리
- 해당 서버의 임시 파일 정리
- 로컬 파일 또는 프로세스 상태 확인
이 작업에는 분산 락을 붙이지 않는다. 대신 각 데이터에 안정적인 instance_id를 포함해야 한다.
클러스터에서 동시 실행을 막을 전역 작업
- 회원 상태나 등급의 일괄 변경
- 중복되면 안 되는 이메일·푸시 알림 생성
- 외부 API 데이터를 가져와 공용 DB에 적재
- 만료 데이터의 일괄 삭제
- 정산 대상 생성
이 작업은 여러 인스턴스가 동시에 수행하지 않도록 ShedLock 같은 조정 장치를 사용할 수 있다. 다만 락만으로 정확히 한 번 처리가 보장되는 것은 아니다.
PostgreSQL과 ShedLock 설정
1. Gradle 의존성 추가
def shedLockVersion = '7.9.0'
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation "net.javacrumbs.shedlock:shedlock-spring:${shedLockVersion}"
implementation "net.javacrumbs.shedlock:shedlock-provider-jdbc-template:${shedLockVersion}"
runtimeOnly 'org.postgresql:postgresql'
}
JPA나 JDBC, PostgreSQL 드라이버가 이미 있다면 중복 의존성은 생략한다. ShedLock의 두 모듈은 같은 버전으로 맞춘다.
2. 락 테이블 생성
운영 환경에서는 애플리케이션 시작 시 임의로 만들기보다 Flyway나 Liquibase 마이그레이션으로 관리하는 것이 좋다.
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)
);
모든 애플리케이션 인스턴스가 같은 쓰기 가능한 PostgreSQL Primary와 같은 스키마를 바라봐야 한다. Read/Write 라우팅 DataSource를 사용한다면 LockProvider에는 반드시 쓰기 Primary로 연결되는 DataSource를 주입해야 한다.
ShedLock 행을 운영 중 수동으로 삭제하는 것도 피해야 한다. ShedLock은 이미 존재했던 락 행을 메모리에 캐시하므로, 행을 삭제하면 애플리케이션을 재시작하기 전까지 자동으로 다시 생성되지 않을 수 있다.
3. LockProvider 구성
import javax.sql.DataSource;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration(proxyBeanMethods = false)
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulingConfig {
@Bean
LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime()
.build()
);
}
}
usingDbTime()은 애플리케이션 서버가 아니라 DB 서버의 UTC 시간을 사용한다. 노드별 시계 차이로 생길 수 있는 문제를 줄이고, JDBC Provider의 DB별 SQL로 INSERT 충돌을 방지하기 때문에 공식 문서에서도 사용을 강하게 권장한다.
로컬 작업과 전역 작업을 함께 구현하기
스케줄러 메서드 안에 모든 비즈니스 로직을 넣기보다, 실행 시점만 결정하고 실제 처리는 별도 서비스에 위임하는 편이 테스트와 장애 대응에 유리하다.
로컬 작업: 모든 인스턴스에서 실행
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class LocalResourceMetricScheduler {
private final ResourceMetricService resourceMetricService;
public LocalResourceMetricScheduler(
ResourceMetricService resourceMetricService
) {
this.resourceMetricService = resourceMetricService;
}
@Scheduled(
fixedDelayString = "${monitoring.interval-ms:60000}"
)
public void collectLocalMetrics() {
resourceMetricService.collectAndStore();
}
}
@SchedulerLock이 없으므로 모든 인스턴스가 자신의 지표를 수집한다.
전역 작업: 같은 시점의 동시 실행 방지
import net.javacrumbs.shedlock.core.LockAssert;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class GlobalBusinessScheduler {
private final ExpiredSessionService expiredSessionService;
public GlobalBusinessScheduler(
ExpiredSessionService expiredSessionService
) {
this.expiredSessionService = expiredSessionService;
}
@Scheduled(
cron = "0 */5 * * * *",
zone = "Asia/Seoul"
)
@SchedulerLock(
name = "cleanupExpiredSessions",
lockAtLeastFor = "30s",
lockAtMostFor = "10m"
)
public void cleanupExpiredSessions() {
LockAssert.assertLocked();
expiredSessionService.cleanupIdempotently();
}
}
ShedLock의 기본 Spring 연동은 AOP 프록시를 사용하므로 대상 메서드는 public이고 final이 아니어야 한다. LockAssert.assertLocked()를 넣으면 애너테이션 누락이나 프록시 구성 오류를 조기에 발견하는 데 도움이 된다.
lockAtMostFor의 의미
작업이 정상적으로 끝나면 락은 일찍 해제된다. 프로세스가 강제 종료돼 정상 해제가 불가능할 때 lockAtMostFor가 안전장치로 동작한다.
이 값은 일반적인 최대 실행 시간보다 충분히 길어야 한다. 실제 작업이 lockAtMostFor를 넘기면 기존 작업이 아직 실행 중이어도 다른 인스턴스가 락을 얻을 수 있어 중복 실행이 발생할 수 있다.
lockAtLeastFor의 의미
작업이 빨리 끝나더라도 락을 획득한 시점부터 지정한 최소 시간까지 유지한다. 짧은 작업이 여러 노드에서 연이어 실행되는 상황을 줄이는 용도다.
“작업이 끝난 뒤 추가로 30초”가 아니라 “락을 잡은 시점부터 최소 30초”라는 점을 기억해야 한다.
락을 얻지 못하면 어떻게 될까?
다른 인스턴스가 같은 이름의 락을 보유하고 있으면 ShedLock은 기다리거나 큐에 넣지 않는다. 해당 시점의 실행을 그냥 건너뛴다.
따라서 ShedLock은 완전한 분산 스케줄러가 아니라 시간 기반 분산 락이다. 작업 유실 없는 실행, 자동 재시도, 누락 작업 보충이 필요하다면 Spring Batch, Quartz의 영속 JobStore, JobRunr, db-scheduler 같은 도구를 별도로 검토해야 한다.
서버 모니터링 예제에서 꼭 바로잡아야 할 부분
간단한 예제에서 자주 다음 코드를 볼 수 있다.
Runtime runtime = Runtime.getRuntime();
long total = runtime.totalMemory();
long free = runtime.freeMemory();
long used = total - free;
이 값은 서버 전체 RAM이 아니다. JVM이 현재 확보한 힙의 committed 영역과 그 안에서 사용 가능한 메모리에 가깝다. 따라서 컬럼 이름을 server_memory_used로 저장하면 의미가 틀어진다.
JVM 힙을 수집하려면 MemoryMXBean의 getHeapMemoryUsage()를 사용하고, 호스트 또는 컨테이너가 JVM에 제공하는 운영 환경의 CPU·메모리를 보려면 문서화된 JDK 확장 API인 com.sun.management.OperatingSystemMXBean을 구분해서 사용한다.
import java.lang.management.ManagementFactory;
import com.sun.management.OperatingSystemMXBean;
public record HostMetric(
Double cpuUsagePercent,
long memoryUsedBytes,
long memoryTotalBytes
) {}
public HostMetric readHostMetric() {
OperatingSystemMXBean osBean =
ManagementFactory.getPlatformMXBean(
OperatingSystemMXBean.class
);
if (osBean == null) {
throw new IllegalStateException(
"OperatingSystemMXBean is not available"
);
}
double cpuLoad = osBean.getCpuLoad();
Double cpuPercent =
cpuLoad < 0 ? null : cpuLoad * 100.0;
long total = osBean.getTotalMemorySize();
long free = osBean.getFreeMemorySize();
return new HostMetric(
cpuPercent,
Math.max(0, total - free),
total
);
}
CPU 값을 얻을 수 없을 때 API는 음수를 반환할 수 있다. 이를 0%로 저장하면 “정상”으로 오해할 수 있으므로 NULL 또는 unavailable 상태로 처리해야 한다.
또한 이 코드는 원격 서버가 아니라 해당 JVM이 실행 중인 native·virtualized·container-managed 환경을 측정한다. 여러 서버를 관측하려면 각 서버에서 수집기를 실행하거나 node_exporter 같은 에이전트를 사용해야 한다.
Docker와 Kubernetes에서는 JVM이 인식한 CPU·메모리 제한 범위를 반드시 검증해야 한다. 컨테이너 hostname은 재배포 때 바뀔 수 있으므로 다음 정보를 명시적으로 저장하는 것이 좋다.
- 배포 환경
- 서비스 이름
- 안정적인 인스턴스 ID 또는 Pod 이름
- 지표 범위: host·container·process·JVM heap
- 수집 시각과 DB 유입 시각
PostgreSQL에 지표를 저장한다면
학습용이나 소규모 내부 화면에서 직접 저장할 수 있다. 컬럼의 단위와 범위를 이름에 명확히 표시한다.
CREATE TABLE server_metric (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
instance_id VARCHAR(255) NOT NULL,
cpu_usage_percent NUMERIC(5, 2),
memory_used_bytes BIGINT NOT NULL,
memory_total_bytes BIGINT NOT NULL,
measured_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_server_metric_latest
ON server_metric (
instance_id,
measured_at DESC,
id DESC
);
전체 인스턴스의 최신 한 건씩을 조회할 때 PostgreSQL의 DISTINCT ON을 사용할 수 있다.
SELECT DISTINCT ON (instance_id)
instance_id,
cpu_usage_percent,
memory_used_bytes,
memory_total_bytes,
measured_at
FROM server_metric
ORDER BY instance_id, measured_at DESC, id DESC;
id DESC를 함께 사용하면 수집 시각이 같은 데이터가 있어도 결과가 결정적이다. API 응답에는 마지막 값만 내려주지 말고 데이터 나이와 stale 여부도 포함해야 한다. 수집기가 멈췄는데 오래된 30% 값을 계속 정상으로 표시하는 것이 더 위험할 수 있다.
보존 기간에 따른 삭제나 파티셔닝도 미리 계획해야 한다. 1분마다 10개 인스턴스가 데이터를 남기면 하루 14,400건, 1년이면 500만 건이 넘는다.
Slack·이메일 알림에서 알림 폭탄 막기
CPU가 80%를 넘는 동안 1분마다 Slack과 이메일을 보내면 장애보다 알림 피로가 더 큰 문제가 된다. 가장 단순한 예제에서는 ConcurrentHashMap으로 쿨다운을 구현할 수 있다.
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean;
public class InMemoryAlertThrottle {
private final ConcurrentMap<String, Instant>
nextAllowedAt = new ConcurrentHashMap<>();
public boolean tryAcquire(
String key,
Instant now,
Duration cooldown
) {
AtomicBoolean acquired = new AtomicBoolean(false);
nextAllowedAt.compute(key, (ignored, current) -> {
if (current == null || !current.isAfter(now)) {
acquired.set(true);
return now.plus(cooldown);
}
return current;
});
return acquired.get();
}
public void reset(String key) {
nextAllowedAt.remove(key);
}
}
compute()로 한 JVM 안의 확인과 갱신을 원자적으로 묶었지만, 이것은 단일 JVM 학습 예제일 뿐이다.
- 애플리케이션을 재시작하면 쿨다운 상태가 사라진다.
- 인스턴스가 여러 개면 JVM마다 별도의 Map이 생긴다.
- Slack 전송과 Map 갱신을 하나의 트랜잭션으로 만들 수 없다.
- 정상·경고·위험·복구 상태를 표현하기 어렵다.
운영 환경에서는 PostgreSQL이나 Redis에 알림 상태를 저장하는 편이 안전하다. PostgreSQL을 이미 사용한다면 다음 구조가 자연스럽다.
metric_alert_state: 인스턴스·지표별 현재 상태, 마지막 성공 알림 시각, 다음 재알림 시각notification_outbox: Slack·이메일 채널별 전송 대기, 성공, 실패, 재시도 횟수- 고유 제약 조건: 같은 incident·event type·channel의 중복 생성 차단
- 별도 worker:
FOR UPDATE SKIP LOCKED로 전송 대상을 선점
마지막 시도 시각과 마지막 성공 시각도 분리해야 한다. Slack이나 SMTP 전송이 실패하기 전에 성공 시각을 갱신하면 쿨다운 동안 재시도가 막힌다. Slack 성공·이메일 실패 같은 부분 성공도 채널별로 기록해야 한다.
쿨다운만으로 부족한 이유
실무 알림은 단일 샘플보다 상태 변화로 판단하는 것이 좋다.
- CPU 90% 이상이 5분 지속되면 경고
- 80% 미만이 5분 지속되면 복구
- 80~90% 구간에서는 현재 상태 유지
- 경고가 지속되면 30분 뒤 재알림
- WARNING에서 CRITICAL로 상승하면 쿨다운과 별개로 즉시 알림
- 데이터가 일정 시간 들어오지 않으면 stale 또는 no-data 알림
경고 기준과 복구 기준을 다르게 두는 히스테리시스는 값이 임계치 주변을 오갈 때 알림이 반복되는 현상을 줄여준다.
비밀정보와 타임아웃
SMTP 비밀번호와 Slack Incoming Webhook URL은 소스나 application.yml에 직접 적지 않는다.
spring:
mail:
host: ${SMTP_HOST}
port: ${SMTP_PORT:587}
username: ${SMTP_USERNAME}
password: ${SMTP_PASSWORD}
properties:
"[mail.smtp.connectiontimeout]": 5000
"[mail.smtp.timeout]": 3000
"[mail.smtp.writetimeout]": 5000
notification:
admin-email: ${ADMIN_EMAIL}
slack-webhook-url: ${SLACK_WEBHOOK_URL}
환경 변수나 Secret Manager로 주입하고, 로그에 Webhook URL을 남기지 않아야 한다. Slack은 Incoming Webhook URL 자체를 비밀정보로 취급하며 유출된 URL을 폐기할 수 있다고 안내한다.
외부 전송을 지표 수집 스케줄러에서 동기적으로 오래 기다리는 것도 피해야 한다. 작은 시스템에서는 반드시 연결·응답 타임아웃을 설정하고, 규모가 커지면 outbox와 별도 전송 worker로 분리한다.
직접 만든 모니터링과 Actuator 중 무엇을 선택할까?
직접 구현은 수집·저장·조회·알림 흐름을 학습하거나 소규모 내부 화면을 만들 때 유용하다.
일반적인 운영 모니터링이라면 다음 구성이 더 검증된 경로다.
- Spring Boot Actuator와 Micrometer: JVM, 프로세스, 애플리케이션 지표
- Prometheus: 시계열 수집과 규칙 평가
- Alertmanager: 그룹화, 재알림, silence, 채널 전송
- node_exporter: Linux 호스트 CPU·메모리·디스크
- cAdvisor 또는 Kubernetes 계층 도구: 컨테이너 지표
- Grafana: 대시보드
Spring Boot는 jvm., system., process., disk. 계열의 여러 메트릭을 자동 구성한다. 직접 만든 수집기가 필요하지 않다면 먼저 Actuator와 Micrometer를 검토하는 편이 유지보수 비용을 줄일 수 있다.
단, Actuator 엔드포인트를 인터넷에 그대로 노출하면 안 된다. 필요한 엔드포인트만 제한적으로 열고 방화벽과 Spring Security로 보호해야 한다.
무엇을 선택해야 할까?
단일 서버의 가벼운 반복 작업
@Scheduled로 충분하다. 실행 시간이 길어질 수 있다면 풀과 자원 사용량을 관찰하고 멱등성을 확보한다.
다중 서버에서 동시 실행을 막아야 하는 가벼운 작업
@Scheduled + ShedLock을 사용할 수 있다. 락 미획득 실행은 건너뛰며, 정확히 한 번을 보장하지 않는다는 전제에서 비즈니스 로직을 설계한다.
대용량·재시작·이력·재시도가 필요한 작업
Spring Batch나 영속형 스케줄러를 선택한다. 단순히 ShedLock을 추가한다고 배치 플랫폼이 되는 것은 아니다.
운영 배포 전 체크리스트
- 이 작업은 모든 인스턴스에서 실행해야 하는가, 클러스터에서 한 번만 실행해야 하는가?
- 같은 작업이 다시 실행돼도 결과가 안전한가?
- 고유 키나 DB 제약 조건으로 중복을 막고 있는가?
- ShedLock 이름이 고유하고 64자를 넘지 않는가?
lockAtMostFor가 최악 실행 시간보다 충분히 긴가?- Cron 시간대를 명시했는가?
- 스케줄러 풀과 DB 커넥션 풀의 용량을 함께 검토했는가?
- 인스턴스 두 개를 동시에 띄워 락 동작을 테스트했는가?
- DB 장애와 애플리케이션 강제 종료 상황을 테스트했는가?
- 실행 시간, 성공, 실패, 락 미획득 횟수를 관찰할 수 있는가?
- 배포 종료 중 실행되는 작업을 어떻게 처리할지 정했는가?
- 알림 전송 실패와 부분 성공을 채널별로 기록하는가?
- 오래된 지표를 stale로 표시하고 있는가?
자주 묻는 질문
서버가 세 대면 @Scheduled도 세 번 실행되나요?
그렇다. 같은 애플리케이션이 세 인스턴스로 실행되면 각 컨텍스트가 자신의 스케줄을 등록하고 실행한다. 분산 조정 장치가 없다면 같은 시각에 세 번 실행될 수 있다.
ShedLock을 사용하면 놓친 실행을 나중에 다시 처리하나요?
아니다. 다른 노드가 락을 보유하고 있으면 해당 실행은 대기하지 않고 건너뛴다. 누락 작업 보충이 필요하면 별도의 영속 큐나 배치 설계가 필요하다.
스레드 풀 크기는 몇 개가 적당한가요?
고정된 정답은 없다. 동시에 실행될 작업 수, 실행 시간, CPU·I/O 특성, DB 커넥션과 외부 API 한도를 측정해 정해야 한다. 풀을 키우기 전에 오래 걸리는 작업을 분리할 수 있는지도 확인한다.
lockAtMostFor는 작업 주기와 같게 두면 되나요?
그렇지 않다. 이 값은 프로세스 장애 시 락이 영원히 남지 않게 하는 최대 만료시간이다. 작업의 최악 실행 시간보다 충분히 길어야 한다. 작업이 이 값을 넘으면 중복 실행될 수 있다.
ShedLock을 사용해도 멱등성이 필요한가요?
필요하다. 락 만료, 프로세스 장애, 외부 시스템의 응답 유실 등으로 중복 부수효과가 발생할 수 있다. ShedLock은 동시 실행 가능성을 줄여줄 뿐 exactly-once를 보장하지 않는다.
언제 Spring Batch로 분리해야 하나요?
대용량 청크 처리, 실행 이력, 재시작 지점, 재시도와 skip 정책이 중요해지는 시점이다. 작업이 API 서버의 응답 성능과 자원을 심하게 경쟁하기 시작했다면 분리도 함께 검토한다.
마무리
Spring Boot의 @Scheduled는 문제가 있는 기능이 아니다. 문제는 실행 범위와 실패 조건을 구분하지 않은 채 단일 서버용 코드를 다중 서버에 그대로 배포하는 데서 시작한다.
안전한 설계 순서는 다음과 같다.
- 작업이 로컬인지 전역인지 구분한다.
- 짧고 단순한 작업인지, 재시작과 이력이 필요한 배치인지 판단한다.
- 한 인스턴스 안의 병목은 스케줄러 풀과 작업 분리로 해결한다.
- 다중 인스턴스의 동시 실행은 필요한 작업에만 ShedLock을 적용한다.
- 락을 사용해도 멱등성과 DB 제약 조건을 유지한다.
- 모니터링 값의 범위를 JVM·프로세스·컨테이너·호스트로 정확히 구분한다.
- 알림은 지속 조건, 복구, 히스테리시스, 영속 상태와 재시도를 함께 설계한다.
이 원칙을 지키면 같은 애플리케이션 안에서 “모든 서버가 실행해야 하는 작업”과 “클러스터에서 동시에 하나만 실행해야 하는 작업”을 명확하게 공존시킬 수 있다.
참고 자료
- Spring Framework Task Execution and Scheduling
- Spring Boot Task Execution and Scheduling
- Spring Boot Common Application Properties
- ShedLock 공식 문서와 PostgreSQL JDBC Provider
- Oracle JDK OperatingSystemMXBean
- Spring Boot Actuator Metrics
- Spring Framework Email
- Slack Incoming Webhooks
- PostgreSQL DISTINCT ON