Part 2. @Scheduled 실전 운영: 단순함의 대가와 멀티 인스턴스 함정
@Scheduled를 운영 환경에 적용할 때 발생하는 중복 실행, 분산락, 장애 복구 트레이드오프를 아키텍처 기준으로 정리한다.
Series: Spring Boot 배치 전략 완전 정복
총 12편 구성. 현재 2편을 보고 있습니다.
- 01Part 1. 배치의 본질과 분류: 스케줄, 이벤트, 수동, 대량, Near-real-time
- 02Part 2. @Scheduled 실전 운영: 단순함의 대가와 멀티 인스턴스 함정CURRENT
- 03Part 3. Quartz 클러스터 아키텍처: JobStore, Misfire, 대규모 스케줄 관리
- 04Part 4. Spring Batch 핵심: Chunk, 트랜잭션 경계, Restart 가능한 Job 설계
- 05Part 5. Spring Batch 확장: Partition과 Multi-threaded Step의 트레이드오프
- 06Part 6. 수동 배치 전략: REST 트리거, Admin UI, 파라미터 재처리, 롤백
- 07Part 7. DB 대량 조회 전략: OFFSET/LIMIT 한계와 Keyset, ID Range, Covering Index
- 08Part 8. OpenSearch/Elasticsearch 배치 전략: Scroll, Search After, PIT, Bulk, Rollover
- 09Part 9. 분산 환경 배치: Leader Election, Kubernetes CronJob, 락 전략 비교
- 10Part 10. 성능 최적화: 배치 사이즈, 커밋 간격, JVM 메모리, Backpressure
- 11Part 11. 장애 대응 아키텍처: Partial Failure, Poison Data, DLQ, 재시도, 멱등성
- 12Part 12. 통합 레퍼런스 아키텍처와 최종 선택 가이드

출처: Pexels - Modern Workspace with Digital Clock
버전 기준
- Java 21
- Spring Boot 3.3.x
- Spring Batch 5.2.x
- Quartz 2.3.x
- PostgreSQL 15
- OpenSearch 2.x
1) 문제 제기
@Scheduled는 구현 비용이 가장 낮다. Spring Boot 애플리케이션 내부에서 즉시 동작하고, 러닝커브도 거의 없다. 그래서 초기에는 대부분 @Scheduled로 시작한다. 하지만 서비스 인스턴스가 1대에서 2대로 늘어나는 순간, 같은 cron이 동시에 두 번 실행되는 문제가 거의 반드시 발생한다.
운영에서 중요한 질문은 단순하다.
- 이 작업은 중복 실행돼도 안전한가?
- 실행 시간이 스케줄 간격보다 길어도 되는가?
- 인스턴스 장애 후 재기동 시 이전 작업과 겹쳐도 되는가?
@Scheduled 자체는 이 질문에 답해주지 않는다. 답은 락, 멱등성, 실행 이력 아키텍처에서 나온다.
2) 핵심 개념 정리
@Scheduled의 장점
- 코드 가독성이 높고 도입 속도가 빠르다.
- 애플리케이션 컨텍스트와 동일한 DI/트랜잭션 모델 사용 가능.
- 작은 팀에서 "작업 1~5개" 수준 운영에 효율적이다.
운영 한계
- 클러스터 환경에서 리더 개념이 없다.
- Misfire 정책(예: 정시 미실행 후 보정)을 기본 제공하지 않는다.
- 실행 이력, 재시도, 병렬 제어를 직접 구현해야 한다.
락 전략 비교(요약)
| 전략 | 장점 | 단점 | 권장 상황 |
|---|---|---|---|
DB 락 (FOR UPDATE/advisory lock) | 추가 인프라 불필요, 강한 일관성 | DB 부하 증가 | 이미 RDBMS 중심 운영 |
Redis 락 (SET NX PX) | 빠른 락 획득, 분산 환경 적합 | TTL/네트워크 분할 고려 필요 | 고빈도 스케줄, 다중 앱 |
| 무락 + 멱등성 | 구현 단순, 장애 내성 높음 | 중복 실행 자체는 발생 | 부작용 없는 집계/동기화 |
트랜잭션 격리 수준은 보통 READ COMMITTED로 시작하되, "동일 조건 재조회"가 있는 경우 REPEATABLE READ 또는 락 힌트를 함께 사용한다.
실행 흐름 다이어그램

출처: Pexels - A Man Looking at Multiple Monitors
3) 코드 예시
예시 A: @Scheduled + 분산락 래퍼
@Component
@RequiredArgsConstructor
public class SettlementScheduler {
private final DistributedLockService lockService;
private final SettlementService settlementService;
@Scheduled(cron = "0 */5 * * * *")
public void run() {
String lockKey = "batch:settlement:5m";
String lockToken = UUID.randomUUID().toString();
if (!lockService.tryLock(lockKey, lockToken, Duration.ofMinutes(4))) {
return;
}
try {
settlementService.processPending(LocalDate.now());
} finally {
lockService.unlock(lockKey, lockToken);
}
}
}
예시 B: Redis 락 구현 핵심
@Service
@RequiredArgsConstructor
public class RedisDistributedLockService implements DistributedLockService {
private final StringRedisTemplate redisTemplate;
@Override
public boolean tryLock(String key, String token, Duration ttl) {
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, token, ttl);
return Boolean.TRUE.equals(result);
}
@Override
public void unlock(String key, String token) {
String current = redisTemplate.opsForValue().get(key);
if (token.equals(current)) {
redisTemplate.delete(key);
}
}
}
예시 C: DB advisory lock 사용 SQL(PostgreSQL)
-- job 이름을 hash로 변환해 advisory lock 사용
SELECT pg_try_advisory_lock(hashtext('batch:settlement:5m')) AS acquired;
-- 처리 완료 후 해제
SELECT pg_advisory_unlock(hashtext('batch:settlement:5m'));
예시 D: 키셋 기반 실패 실행 이력 조회
SELECT id, job_name, started_at, ended_at, error_code
FROM batch_job_execution
WHERE job_name = 'settlement'
AND status = 'FAILED'
AND id > :last_id
ORDER BY id ASC
LIMIT 100;
4) 실제 장애/운영 시나리오
상황: 5분 주기 정산 배치가 4분 50초에 끝나는 환경에서, GC pause(약 20초)로 락 갱신 heartbeat가 끊겼다. Redis TTL이 만료되면서 다른 인스턴스가 같은 작업을 재실행해 중복 정산이 발생했다.
원인:
- 락 TTL을 "평균 실행 시간" 기준으로만 설정했다.
- 장시간 Stop-The-World(G1 mixed GC) 구간을 설계에서 고려하지 않았다.
- 비즈니스 레벨 멱등성 제약(
UNIQUE(order_id, settlement_date))이 없었다.
대응:
- TTL을 P99 실행시간 + GC 여유시간으로 재산정(예: 4m50s -> 8m).
- 하트비트 실패 시 즉시 작업 중단하고 재진입 방지 플래그 저장.
- 도메인 테이블에 멱등성 유니크 인덱스 추가.
5) 설계 체크리스트
- 멀티 인스턴스에서 중복 실행 방지 전략(락 또는 멱등성)을 명시했는가?
- 락 TTL을 평균이 아닌 P99 + GC 여유 기준으로 계산했는가?
- 장애 재기동 시 같은 윈도우를 재실행해도 안전한가?
- 스케줄 실행 이력(성공/실패/소요시간)을 저장하는가?
- DB 격리 수준과 락 힌트(
SKIP LOCKED등)를 의도적으로 선택했는가? - JVM 힙 사용량과 GC pause가 스케줄 간격에 미치는 영향을 측정했는가?
6) 요약
@Scheduled는 "작고 단순한 배치"에 매우 강력하지만, 클러스터 안정성은 별도 설계 없이는 보장되지 않는다. 락만으로는 충분하지 않고, 멱등성/실행 이력/장애 복구가 함께 있어야 운영 가능한 배치가 된다.
7) 다음 편 예고
다음 편에서는 Quartz를 다룬다. 클러스터 모드, JobStore(RAM vs JDBC), Misfire 정책, 수백~수천 스케줄 운영 시 어떤 기준으로 Quartz를 선택해야 하는지 실무 관점에서 정리한다.
참고 링크
- Spring Batch Reference
- Quartz Scheduler Documentation
- PostgreSQL Transaction Isolation
- 블로그: Idempotency Key API 설계