Spring 14: 스케줄링
배치 작업과 스케줄러 구현
스케줄링 개념
배치 작업의 필요성
현대 애플리케이션에서 스케줄링은 필수적인 기능입니다. 비즈니스 요구사항이 복잡해지고 데이터 처리량이 증가하면서, 자동화된 배치 작업은 시스템 운영의 핵심 요소가 되었습니다.
🔄 데이터 처리 작업
- ETL 프로세스: 대용량 데이터 추출, 변환, 적재
- 데이터 정리: 오래된 로그, 임시 파일 삭제
- 데이터 집계: 일일/월간 통계 데이터 생성
- 데이터 검증: 무결성 체크 및 오류 데이터 정정
- 아카이빙: 오래된 데이터 압축 및 보관
📊 비즈니스 프로세스
- 리포트 생성: 매출, 사용자 분석 보고서
- 알림 발송: 이메일, SMS, 푸시 알림
- 정산 처리: 결제, 수수료 계산
- 재고 관리: 재고 수준 체크 및 발주
- 고객 관리: 휴면 계정 처리, 등급 업데이트
🔧 시스템 유지보수
- 백업 작업: 데이터베이스, 파일 시스템 백업
- 캐시 관리: 캐시 갱신, 만료 데이터 정리
- 로그 관리: 로그 로테이션, 압축
- 성능 모니터링: 시스템 리소스 체크
- 보안 스캔: 취약점 검사, 패치 적용
🌐 외부 연동
- API 동기화: 외부 시스템과 데이터 동기화
- 파일 전송: FTP, SFTP를 통한 파일 교환
- 웹 크롤링: 외부 사이트 데이터 수집
- 결제 처리: 정기 결제, 환불 처리
- 소셜 미디어: SNS 포스팅, 댓글 수집
스케줄링의 핵심 장점
- • 자동화: 수동 개입 없이 작업 실행
- • 일관성: 정해진 시간에 정확한 작업 수행
- • 효율성: 시스템 리소스 최적 활용
- • 안정성: 휴먼 에러 방지
- • 확장성: 대용량 데이터 처리 가능
- • 모니터링: 작업 실행 상태 추적
- • 복구: 실패 시 자동 재시도
- • 비용 절감: 운영 인력 최소화
스케줄링 설계 시 고려사항
성능 측면
- • 시스템 부하 분산
- • 메모리 사용량 최적화
- • 데이터베이스 락 최소화
- • 네트워크 대역폭 고려
안정성 측면
- • 예외 처리 전략
- • 재시도 메커니즘
- • 데이터 무결성 보장
- • 롤백 전략 수립
운영 측면
- • 로깅 및 모니터링
- • 알림 시스템 구축
- • 성능 지표 수집
- • 문제 해결 프로세스
Cron 표현식 완전 가이드
Cron 표현식은 Unix 시스템에서 시작된 시간 기반 작업 스케줄러의 표준 형식입니다. Spring에서는 6개 또는 7개 필드로 구성되며, 각 필드는 공백으로 구분됩니다:
기본 구조
필수 필드 (6개)
- 초: 0-59 (정확한 초 지정)
- 분: 0-59 (분 단위)
- 시: 0-23 (24시간 형식)
- 일: 1-31 (월의 날짜)
- 월: 1-12 또는 JAN-DEC
- 요일: 0-7 또는 SUN-SAT (0,7=일요일)
선택 필드
- 년도: 1970-2099 (생략 가능)
- 시간대: 별도 설정으로 지정
특수 문자 상세 설명
*모든 값을 의미. "매번" 실행
예: 분 필드에 * = 매분
?특정 값 없음. 일/요일 필드에만 사용
예: 매월 15일이면 요일은 ?
-범위 지정
예: 1-5 = 1,2,3,4,5
,여러 값 나열
예: 1,15,30 = 1일, 15일, 30일
/증분 값 (시작값/증분)
예: 0/15 = 0,15,30,45
고급 특수 문자
LLast - 마지막 값
예: 일 필드에 L = 월의 마지막 날
WWeekday - 가장 가까운 평일
예: 15W = 15일과 가장 가까운 평일
LW월의 마지막 평일
예: LW = 해당 월의 마지막 평일
#N번째 특정 요일
예: 2#1 = 첫 번째 월요일
실용적인 Cron 표현식 예제
일반적인 패턴
0 0 0 * * ?매일 자정0 0 9 * * MON-FRI평일 오전 9시0 0/30 * * * ?30분마다0 0 0 1 * ?매월 1일 자정0 0 0 ? * SUN매주 일요일 자정고급 패턴
0 0 2 L * ?매월 마지막 날 새벽 2시0 0 9 ? * MON#1매월 첫 번째 월요일 9시0 0 0 15W * ?15일과 가장 가까운 평일0 0 0 LW * ?매월 마지막 평일0 0 0 ? 1/3 ?분기별 (1,4,7,10월)Cron 표현식 작성 시 주의사항
필드 충돌 방지
- • 일(day-of-month)과 요일(day-of-week)은 동시 사용 불가
- • 둘 중 하나는 반드시 '?' 사용
- • 월과 요일에는 영문 약어 사용 가능
- • 대소문자 구분하지 않음
성능 고려사항
- • 너무 빈번한 실행은 시스템 부하 증가
- • 초 단위 스케줄링은 신중히 사용
- • 시간대 설정 확인 필수
- • 테스트 환경에서 충분한 검증
Cron 표현식 검증 도구
복잡한 Cron 표현식은 온라인 도구를 활용하여 검증하는 것이 좋습니다:
- • Cron Expression Generator: 시각적 인터페이스로 표현식 생성
- • Cron Parser: 표현식 해석 및 다음 실행 시간 확인
- • Spring Boot Actuator: 애플리케이션 내 스케줄 정보 확인
- • 로그 모니터링: 실제 실행 시간과 예상 시간 비교
실제 사용 사례
🏢 전자상거래 플랫폼
주문 및 재고 관리
- •
0 0 2 * * ?- 매일 새벽 2시: 주문 데이터 집계 - •
0 */10 * * * ?- 10분마다: 재고 수준 체크 - •
0 0 1 * * ?- 매일 새벽 1시: 재고 알림 발송 - •
0 0 0 1 * ?- 매월 1일: 월간 재고 리포트
마케팅 및 고객 관리
- •
0 0 18 * * ?- 매일 오후 6시: 장바구니 이탈 알림 - •
0 0 9 ? * MON- 매주 월요일: 주간 프로모션 이메일 - •
0 0 10 1 * ?- 매월 1일: 고객 등급 업데이트 - •
0 0 0 ? * SUN- 매주 일요일: 추천 상품 갱신
🏦 금융 시스템
거래 및 정산
- •
0 0 0 * * ?- 매일 자정: 일일 거래 내역 정산 - •
0 0 1 L * ?- 매월 말일 새벽 1시: 월간 이자 계산 - •
0 0 23 L * ?- 매월 말일 밤 11시: 월말 정산 - •
0 0 0 1 1,4,7,10 ?- 분기별: 분기 결산
시장 데이터 및 리스크
- •
0 */15 9-15 * * MON-FRI- 거래시간 15분마다: 환율 업데이트 - •
0 0 8 * * MON-FRI- 평일 오전 8시: 시장 개장 전 점검 - •
0 0 17 * * MON-FRI- 평일 오후 5시: 일일 리스크 분석 - •
0 0 6 * * MON-FRI- 평일 오전 6시: 해외 시장 데이터 동기화
📊 데이터 분석 플랫폼
ETL 및 데이터 처리
- •
0 0 * * * ?- 매시간: 실시간 로그 데이터 ETL - •
0 0 3 * * ?- 매일 새벽 3시: 대용량 배치 처리 - •
0 30 2 * * ?- 매일 새벽 2시 30분: 데이터 품질 검증 - •
0 0 4 ? * SUN- 매주 일요일 새벽 4시: 주간 데이터 아카이빙
리포팅 및 분석
- •
0 0 7 * * MON-FRI- 평일 오전 7시: 일일 대시보드 갱신 - •
0 0 8 ? * SUN- 매주 일요일: 주간 트렌드 분석 - •
0 0 6 1 * ?- 매월 1일: 월간 성과 리포트 - •
0 0 5 1 1,4,7,10 ?- 분기별: 분기 비즈니스 리뷰
🔧 시스템 운영 및 유지보수
백업 및 아카이빙
- •
0 0 1 * * ?- 매일 새벽 1시: 데이터베이스 백업 - •
0 0 2 ? * SUN- 매주 일요일: 전체 시스템 백업 - •
0 0 3 1 * ?- 매월 1일: 로그 파일 아카이빙 - •
0 0 0 1 1 ?- 매년 1월 1일: 연간 데이터 아카이빙
모니터링 및 정리
- •
0 */5 * * * ?- 5분마다: 시스템 헬스 체크 - •
0 0 */6 * * ?- 6시간마다: 캐시 정리 - •
0 0 4 * * ?- 매일 새벽 4시: 임시 파일 정리 - •
0 0 5 ? * SUN- 매주 일요일: 시스템 성능 분석
스케줄링 설계 베스트 프랙티스
시간 분산
- • 동일 시간대 작업 집중 방지
- • 시스템 부하 분산 고려
- • 의존성 있는 작업 순서 조정
- • 피크 시간대 회피
리소스 관리
- • 메모리 사용량 모니터링
- • 데이터베이스 커넥션 풀 관리
- • 네트워크 대역폭 고려
- • 디스크 I/O 최적화
안정성 확보
- • 예외 처리 및 로깅
- • 재시도 메커니즘 구현
- • 실행 시간 제한 설정
- • 알림 시스템 구축
주의해야 할 안티패턴
피해야 할 패턴
- • 너무 빈번한 실행 (초 단위 반복)
- • 동일 시간대 모든 작업 집중
- • 예외 처리 없는 스케줄 작업
- • 무한 루프 가능성 있는 로직
권장 대안
- • 적절한 실행 주기 설정
- • 시간대별 작업 분산
- • 포괄적인 예외 처리
- • 실행 시간 제한 및 모니터링
2. @Scheduled 기초
2.1 @EnableScheduling 설정
기본 설정
@Configuration
@EnableScheduling
public class SchedulingConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10);
scheduler.setThreadNamePrefix("scheduled-");
scheduler.setAwaitTerminationSeconds(60);
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.initialize();
return scheduler;
}
}2.2 fixedRate vs fixedDelay
| 속성 | 설명 | 사용 시점 |
|---|---|---|
| fixedRate | 이전 실행 시작 시점 기준 | 일정 간격 실행 필요 |
| fixedDelay | 이전 실행 완료 시점 기준 | 작업 완료 후 대기 필요 |
| cron | Cron 표현식으로 지정 | 특정 시간에 실행 필요 |
사용 예제
@Service
@Slf4j
public class ScheduledTaskService {
// 5초마다 실행 (이전 시작 기준)
@Scheduled(fixedRate = 5000)
public void fixedRateTask() {
log.info("Fixed rate task - {}", LocalDateTime.now());
}
// 이전 완료 후 5초 뒤 실행
@Scheduled(fixedDelay = 5000)
public void fixedDelayTask() {
log.info("Fixed delay task - {}", LocalDateTime.now());
}
// 초기 지연 후 실행
@Scheduled(fixedRate = 5000, initialDelay = 10000)
public void withInitialDelay() {
log.info("Task with initial delay");
}
// 매일 오전 9시 실행
@Scheduled(cron = "0 0 9 * * *")
public void dailyTask() {
log.info("Daily task at 9 AM");
}
// 매주 월요일 오전 10시 실행
@Scheduled(cron = "0 0 10 * * MON")
public void weeklyTask() {
log.info("Weekly task on Monday");
}
}2.3 Cron 표현식
Cron 표현식 형식
# 형식: 초 분 시 일 월 요일 # 예시: "0 0 * * * *" # 매시 정각 "0 0 9 * * *" # 매일 오전 9시 "0 0 9 * * MON" # 매주 월요일 오전 9시 "0 0 9 1 * *" # 매월 1일 오전 9시 "0 0/30 * * * *" # 30분마다 "0 0 9-18 * * *" # 오전 9시~오후 6시 매시 정각
- •
0 0 * * * *- 매시 정각 - •
0 0 0 * * *- 매일 자정 - •
0 0 9 * * MON-FRI- 평일 오전 9시 - •
0 0/10 * * * *- 10분마다
동적 스케줄링
SchedulingConfigurer 인터페이스
런타임에 스케줄을 동적으로 변경하거나 설정해야 할 때 SchedulingConfigurer를 사용합니다. 이는 정적인 @Scheduled 어노테이션의 한계를 극복하고, 비즈니스 요구사항에 따라 유연하게 스케줄링을 조정할 수 있게 해줍니다.
기본 SchedulingConfigurer 구현
@Configuration
@EnableScheduling
@Slf4j
public class DynamicSchedulingConfig implements SchedulingConfigurer {
@Autowired
private SchedulingProperties schedulingProperties;
@Autowired
private DynamicTaskService dynamicTaskService;
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
// 커스텀 스케줄러 설정
taskRegistrar.setScheduler(taskExecutor());
// 동적 Cron 작업 등록
taskRegistrar.addCronTask(
this::dynamicCronTask,
() -> schedulingProperties.getCronExpression() // 동적 Cron 표현식
);
// 동적 Fixed Rate 작업 등록
taskRegistrar.addFixedRateTask(
this::dynamicFixedRateTask,
() -> schedulingProperties.getFixedRate() // 동적 주기
);
// 동적 Fixed Delay 작업 등록
taskRegistrar.addFixedDelayTask(
this::dynamicFixedDelayTask,
() -> schedulingProperties.getFixedDelay()
);
// 초기 지연이 있는 동적 작업
taskRegistrar.addFixedRateTask(
this::delayedDynamicTask,
() -> schedulingProperties.getFixedRate(),
() -> schedulingProperties.getInitialDelay()
);
}
@Bean(destroyMethod = "shutdown")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("dynamic-scheduler-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
private void dynamicCronTask() {
try {
log.info("Dynamic cron task executed at {} with expression: {}",
LocalDateTime.now(), schedulingProperties.getCronExpression());
dynamicTaskService.executeCronBasedTask();
} catch (Exception e) {
log.error("Dynamic cron task failed", e);
}
}
private void dynamicFixedRateTask() {
try {
log.info("Dynamic fixed rate task executed with rate: {} ms",
schedulingProperties.getFixedRate());
dynamicTaskService.executeFixedRateTask();
} catch (Exception e) {
log.error("Dynamic fixed rate task failed", e);
}
}
private void dynamicFixedDelayTask() {
try {
log.info("Dynamic fixed delay task executed with delay: {} ms",
schedulingProperties.getFixedDelay());
dynamicTaskService.executeFixedDelayTask();
} catch (Exception e) {
log.error("Dynamic fixed delay task failed", e);
}
}
private void delayedDynamicTask() {
try {
log.info("Delayed dynamic task executed");
dynamicTaskService.executeDelayedTask();
} catch (Exception e) {
log.error("Delayed dynamic task failed", e);
}
}
}동적 스케줄링 설정 프로퍼티
@Component
@ConfigurationProperties(prefix = "app.scheduling")
@Data
@RefreshScope // Spring Cloud Config 사용 시 런타임 갱신 가능
public class SchedulingProperties {
private String cronExpression = "0 */5 * * * ?"; // 기본값: 5분마다
private Long fixedRate = 60000L; // 기본값: 1분
private Long fixedDelay = 30000L; // 기본값: 30초
private Long initialDelay = 10000L; // 기본값: 10초
private boolean enabled = true;
private int maxRetries = 3;
private long retryDelay = 5000L;
// 비즈니스 로직에 따른 동적 조정
public String getCronExpression() {
if (!enabled) {
return null; // 스케줄링 비활성화
}
// 시간대별 다른 주기 설정 예제
LocalTime now = LocalTime.now();
if (now.isAfter(LocalTime.of(22, 0)) || now.isBefore(LocalTime.of(6, 0))) {
// 야간에는 더 긴 주기
return "0 */15 * * * ?"; // 15분마다
} else {
// 주간에는 짧은 주기
return cronExpression;
}
}
public Long getFixedRate() {
if (!enabled) {
return null;
}
// 시스템 부하에 따른 동적 조정
double systemLoad = getSystemLoad();
if (systemLoad > 0.8) {
return fixedRate * 2; // 부하가 높으면 주기를 늘림
} else if (systemLoad < 0.3) {
return fixedRate / 2; // 부하가 낮으면 주기를 줄임
}
return fixedRate;
}
public Long getFixedDelay() {
return enabled ? fixedDelay : null;
}
public Long getInitialDelay() {
return enabled ? initialDelay : null;
}
private double getSystemLoad() {
// 실제 시스템 부하 측정 로직
return ManagementFactory.getOperatingSystemMXBean().getSystemLoadAverage();
}
}
// application.yml
app:
scheduling:
cron-expression: "0 */10 * * * ?"
fixed-rate: 120000
fixed-delay: 60000
initial-delay: 30000
enabled: true
max-retries: 5
retry-delay: 10000SchedulingConfigurer의 장점
- • 런타임 설정 변경: 애플리케이션 재시작 없이 스케줄 조정
- • 조건부 스케줄링: 비즈니스 로직에 따른 동적 활성화/비활성화
- • 환경별 설정: 개발/테스트/운영 환경별 다른 스케줄 적용
- • 부하 기반 조정: 시스템 상태에 따른 자동 주기 조정
동적 작업 등록/취소 REST API
실제 운영 환경에서는 관리자가 웹 인터페이스를 통해 스케줄을 관리할 수 있어야 합니다. 다음은 REST API를 통한 동적 스케줄링 관리 예제입니다.
스케줄링 관리 REST Controller
@RestController
@RequestMapping("/api/scheduling")
@Slf4j
@Validated
public class SchedulingController {
private final DynamicTaskSchedulingService schedulingService;
private final TaskDefinitionService taskDefinitionService;
public SchedulingController(
DynamicTaskSchedulingService schedulingService,
TaskDefinitionService taskDefinitionService) {
this.schedulingService = schedulingService;
this.taskDefinitionService = taskDefinitionService;
}
/**
* Cron 작업 등록
*/
@PostMapping("/cron")
public ResponseEntity<ScheduleResponse> scheduleCronTask(
@Valid @RequestBody CronTaskRequest request) {
try {
// 작업 정의 조회
TaskDefinition taskDef = taskDefinitionService.getTaskDefinition(request.getTaskType());
if (taskDef == null) {
return ResponseEntity.badRequest()
.body(new ScheduleResponse(false, "Unknown task type: " + request.getTaskType()));
}
// Cron 표현식 검증
if (!CronExpression.isValidExpression(request.getCronExpression())) {
return ResponseEntity.badRequest()
.body(new ScheduleResponse(false, "Invalid cron expression"));
}
// 작업 생성
Runnable task = createTask(taskDef, request.getParameters());
// 스케줄링
String taskId = schedulingService.scheduleCronTask(
request.getTaskId(),
task,
request.getCronExpression()
);
log.info("Cron task scheduled via API: {}", taskId);
return ResponseEntity.ok(new ScheduleResponse(true, "Task scheduled successfully", taskId));
} catch (Exception e) {
log.error("Failed to schedule cron task via API", e);
return ResponseEntity.internalServerError()
.body(new ScheduleResponse(false, "Failed to schedule task: " + e.getMessage()));
}
}
/**
* 고정 주기 작업 등록
*/
@PostMapping("/fixed-rate")
public ResponseEntity<ScheduleResponse> scheduleFixedRateTask(
@Valid @RequestBody FixedRateTaskRequest request) {
try {
TaskDefinition taskDef = taskDefinitionService.getTaskDefinition(request.getTaskType());
if (taskDef == null) {
return ResponseEntity.badRequest()
.body(new ScheduleResponse(false, "Unknown task type: " + request.getTaskType()));
}
Runnable task = createTask(taskDef, request.getParameters());
String taskId = schedulingService.scheduleFixedRateTask(
request.getTaskId(),
task,
request.getPeriod(),
request.getInitialDelay()
);
log.info("Fixed rate task scheduled via API: {}", taskId);
return ResponseEntity.ok(new ScheduleResponse(true, "Task scheduled successfully", taskId));
} catch (Exception e) {
log.error("Failed to schedule fixed rate task via API", e);
return ResponseEntity.internalServerError()
.body(new ScheduleResponse(false, "Failed to schedule task: " + e.getMessage()));
}
}
/**
* 작업 취소
*/
@DeleteMapping("/{taskId}")
public ResponseEntity<ScheduleResponse> cancelTask(@PathVariable String taskId) {
try {
boolean cancelled = schedulingService.cancelTask(taskId);
if (cancelled) {
log.info("Task cancelled via API: {}", taskId);
return ResponseEntity.ok(new ScheduleResponse(true, "Task cancelled successfully"));
} else {
return ResponseEntity.notFound().build();
}
} catch (Exception e) {
log.error("Failed to cancel task via API: {}", taskId, e);
return ResponseEntity.internalServerError()
.body(new ScheduleResponse(false, "Failed to cancel task: " + e.getMessage()));
}
}
/**
* 작업 상태 조회
*/
@GetMapping("/{taskId}/status")
public ResponseEntity<TaskStatusResponse> getTaskStatus(@PathVariable String taskId) {
try {
DynamicTaskSchedulingService.TaskStatus status = schedulingService.getTaskStatus(taskId);
if (status == DynamicTaskSchedulingService.TaskStatus.NOT_FOUND) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(new TaskStatusResponse(taskId, status));
} catch (Exception e) {
log.error("Failed to get task status via API: {}", taskId, e);
return ResponseEntity.internalServerError().build();
}
}
/**
* 모든 작업 상태 조회
*/
@GetMapping("/status")
public ResponseEntity<Map<String, DynamicTaskSchedulingService.TaskStatus>> getAllTaskStatuses() {
try {
Map<String, DynamicTaskSchedulingService.TaskStatus> statuses =
schedulingService.getAllTaskStatuses();
return ResponseEntity.ok(statuses);
} catch (Exception e) {
log.error("Failed to get all task statuses via API", e);
return ResponseEntity.internalServerError().build();
}
}
/**
* 모든 작업 취소
*/
@DeleteMapping
public ResponseEntity<ScheduleResponse> cancelAllTasks() {
try {
schedulingService.cancelAllTasks();
log.info("All tasks cancelled via API");
return ResponseEntity.ok(new ScheduleResponse(true, "All tasks cancelled successfully"));
} catch (Exception e) {
log.error("Failed to cancel all tasks via API", e);
return ResponseEntity.internalServerError()
.body(new ScheduleResponse(false, "Failed to cancel all tasks: " + e.getMessage()));
}
}
private Runnable createTask(TaskDefinition taskDef, Map<String, Object> parameters) {
return () -> {
try {
log.info("Executing dynamic task: {} with parameters: {}",
taskDef.getName(), parameters);
taskDef.execute(parameters);
} catch (Exception e) {
log.error("Dynamic task execution failed: {}", taskDef.getName(), e);
}
};
}
}
// DTO 클래스들
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CronTaskRequest {
@NotBlank
private String taskId;
@NotBlank
private String taskType;
@NotBlank
private String cronExpression;
private Map<String, Object> parameters = new HashMap<>();
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class FixedRateTaskRequest {
@NotBlank
private String taskId;
@NotBlank
private String taskType;
@Min(1000) // 최소 1초
private long period;
@Min(0)
private long initialDelay = 0;
private Map<String, Object> parameters = new HashMap<>();
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ScheduleResponse {
private boolean success;
private String message;
private String taskId;
public ScheduleResponse(boolean success, String message) {
this.success = success;
this.message = message;
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TaskStatusResponse {
private String taskId;
private DynamicTaskSchedulingService.TaskStatus status;
}작업 정의 서비스
@Service
@Slf4j
public class TaskDefinitionService {
private final Map<String, TaskDefinition> taskDefinitions = new HashMap<>();
@PostConstruct
public void initializeTaskDefinitions() {
// 데이터 정리 작업
taskDefinitions.put("DATA_CLEANUP", new TaskDefinition(
"DATA_CLEANUP",
"데이터 정리 작업",
this::executeDataCleanup
));
// 리포트 생성 작업
taskDefinitions.put("REPORT_GENERATION", new TaskDefinition(
"REPORT_GENERATION",
"리포트 생성 작업",
this::executeReportGeneration
));
// 이메일 발송 작업
taskDefinitions.put("EMAIL_NOTIFICATION", new TaskDefinition(
"EMAIL_NOTIFICATION",
"이메일 알림 발송",
this::executeEmailNotification
));
// 시스템 백업 작업
taskDefinitions.put("SYSTEM_BACKUP", new TaskDefinition(
"SYSTEM_BACKUP",
"시스템 백업",
this::executeSystemBackup
));
}
public TaskDefinition getTaskDefinition(String taskType) {
return taskDefinitions.get(taskType);
}
public Set<String> getAvailableTaskTypes() {
return taskDefinitions.keySet();
}
private void executeDataCleanup(Map<String, Object> parameters) {
int daysToKeep = (Integer) parameters.getOrDefault("daysToKeep", 30);
String dataType = (String) parameters.getOrDefault("dataType", "logs");
log.info("Executing data cleanup: type={}, daysToKeep={}", dataType, daysToKeep);
// 실제 데이터 정리 로직
switch (dataType) {
case "logs":
cleanupLogs(daysToKeep);
break;
case "temp":
cleanupTempFiles(daysToKeep);
break;
case "cache":
cleanupCache();
break;
default:
log.warn("Unknown data type for cleanup: {}", dataType);
}
}
private void executeReportGeneration(Map<String, Object> parameters) {
String reportType = (String) parameters.getOrDefault("reportType", "daily");
String format = (String) parameters.getOrDefault("format", "pdf");
log.info("Executing report generation: type={}, format={}", reportType, format);
// 실제 리포트 생성 로직
generateReport(reportType, format);
}
private void executeEmailNotification(Map<String, Object> parameters) {
String recipient = (String) parameters.get("recipient");
String subject = (String) parameters.get("subject");
String content = (String) parameters.get("content");
log.info("Executing email notification to: {}", recipient);
// 실제 이메일 발송 로직
sendEmail(recipient, subject, content);
}
private void executeSystemBackup(Map<String, Object> parameters) {
String backupType = (String) parameters.getOrDefault("backupType", "incremental");
String destination = (String) parameters.getOrDefault("destination", "/backup");
log.info("Executing system backup: type={}, destination={}", backupType, destination);
// 실제 백업 로직
performBackup(backupType, destination);
}
// 실제 구현 메서드들 (예시)
private void cleanupLogs(int daysToKeep) {
// 로그 정리 구현
}
private void cleanupTempFiles(int daysToKeep) {
// 임시 파일 정리 구현
}
private void cleanupCache() {
// 캐시 정리 구현
}
private void generateReport(String reportType, String format) {
// 리포트 생성 구현
}
private void sendEmail(String recipient, String subject, String content) {
// 이메일 발송 구현
}
private void performBackup(String backupType, String destination) {
// 백업 수행 구현
}
}
@Data
@AllArgsConstructor
public class TaskDefinition {
private String name;
private String description;
private Consumer<Map<String, Object>> executor;
public void execute(Map<String, Object> parameters) {
executor.accept(parameters);
}
}API 사용 예제
Cron 작업 등록
POST /api/scheduling/cron
{
"taskId": "daily-cleanup",
"taskType": "DATA_CLEANUP",
"cronExpression": "0 0 2 * * ?",
"parameters": {
"dataType": "logs",
"daysToKeep": 7
}
}고정 주기 작업 등록
POST /api/scheduling/fixed-rate
{
"taskId": "health-check",
"taskType": "SYSTEM_MONITORING",
"period": 300000,
"initialDelay": 60000
}동적 스케줄링 베스트 프랙티스
설계 원칙
- • 작업 분리: 스케줄링 로직과 비즈니스 로직 분리
- • 상태 관리: 작업 상태를 명확히 추적
- • 예외 처리: 각 작업별 독립적인 예외 처리
- • 리소스 관리: 스레드 풀 크기 적절히 설정
- • 모니터링: 작업 실행 이력 및 성능 추적
보안 고려사항
- • 인증/인가: API 접근 권한 제어
- • 입력 검증: Cron 표현식 및 매개변수 검증
- • 작업 제한: 동시 실행 작업 수 제한
- • 감사 로그: 스케줄 변경 이력 기록
- • 안전한 종료: 실행 중인 작업 안전하게 종료
주의사항 및 제한사항
메모리 관리
- • 취소된 작업 참조 정리
- • 스케줄 맵 크기 모니터링
- • 가비지 컬렉션 고려
동시성 이슈
- • 스레드 안전성 보장
- • 데드락 방지
- • 경쟁 조건 해결
성능 최적화
- • 스레드 풀 크기 조정
- • 작업 실행 시간 모니터링
- • 시스템 리소스 사용량 추적
분산 환경 스케줄링
분산 환경에서의 스케줄링 문제
마이크로서비스 아키텍처와 클라우드 환경에서는 동일한 애플리케이션이 여러 인스턴스로 실행됩니다. 이때 각 인스턴스에서 동일한 스케줄 작업이 실행되면 심각한 문제가 발생할 수 있습니다.
발생 가능한 문제들
- • 중복 실행: 동일한 배치 작업이 여러 인스턴스에서 동시 실행
- • 데이터 무결성: 동일한 데이터를 여러 번 처리하여 결과 왜곡
- • 리소스 경합: 데이터베이스, 파일 시스템 등 공유 리소스 충돌
- • 외부 시스템 부하: API 호출, 이메일 발송 등 중복 요청
- • 비용 증가: 불필요한 연산으로 인한 리소스 낭비
- • 로그 혼재: 여러 인스턴스의 로그가 섞여 디버깅 어려움
해결 방법 개요
- • ShedLock: 분산 락을 이용한 중복 실행 방지
- • 리더 선출: 하나의 인스턴스만 스케줄 실행
- • 데이터베이스 락: DB 레벨에서 중복 실행 방지
- • Redis 락: Redis를 이용한 분산 락 구현
- • 외부 스케줄러: Quartz Cluster, Spring Cloud Task
- • 메시지 큐: 작업을 큐에 넣고 하나씩 처리
분산 스케줄링 시나리오 예제
// 문제 상황: 3개 인스턴스에서 동일한 작업 실행
Instance A: @Scheduled(cron = "0 0 2 * * ?") // 매일 새벽 2시
Instance B: @Scheduled(cron = "0 0 2 * * ?") // 매일 새벽 2시
Instance C: @Scheduled(cron = "0 0 2 * * ?") // 매일 새벽 2시
public void dailyReportGeneration() {
// 일일 리포트 생성 - 3번 실행됨!
// 1. 동일한 데이터를 3번 처리
// 2. 리포트가 3개 생성됨
// 3. 이메일이 3번 발송됨
// 4. 데이터베이스에 중복 레코드 생성
}
// 해결 방법: ShedLock 적용
@Scheduled(cron = "0 0 2 * * ?")
@SchedulerLock(name = "dailyReportGeneration",
lockAtMostFor = "PT30M",
lockAtLeastFor = "PT5M")
public void dailyReportGeneration() {
// 오직 하나의 인스턴스에서만 실행됨
// 다른 인스턴스들은 락이 해제될 때까지 대기하거나 스킵
}ShedLock을 이용한 분산 락
ShedLock은 Spring 애플리케이션에서 스케줄된 작업의 중복 실행을 방지하는 라이브러리입니다. 데이터베이스, Redis, MongoDB 등 다양한 백엔드를 지원합니다.
ShedLock 의존성 및 설정
<!-- Maven 의존성 -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>4.42.0</version>
</dependency>
<!-- 데이터베이스 백엔드 (MySQL/PostgreSQL) -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>4.42.0</version>
</dependency>
<!-- Redis 백엔드 -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-redis-spring</artifactId>
<version>4.42.0</version>
</dependency>
// Gradle 의존성
implementation 'net.javacrumbs.shedlock:shedlock-spring:4.42.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:4.42.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:4.42.0'데이터베이스 기반 ShedLock 설정
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30M")
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime() // 데이터베이스 시간 사용 (권장)
.build()
);
}
}
// 데이터베이스 테이블 생성 (MySQL)
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)
);
// PostgreSQL
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)
);Redis 기반 ShedLock 설정
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30M")
public class RedisLockConfig {
@Bean
public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
return new RedisLockProvider(connectionFactory);
}
// 또는 RedisTemplate 사용
@Bean
public LockProvider lockProvider(RedisTemplate<String, String> redisTemplate) {
return new RedisLockProvider(redisTemplate);
}
}
// application.yml - Redis 설정
spring:
redis:
host: localhost
port: 6379
password: your-password
database: 0
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0ShedLock 백엔드 선택 가이드
데이터베이스
- • 이미 DB를 사용하는 경우
- • 트랜잭션 일관성 중요
- • 영구적인 락 이력 필요
- • 안정성 최우선
Redis
- • 고성능이 중요한 경우
- • 이미 Redis 인프라 보유
- • 락 만료 자동 처리
- • 메모리 기반 빠른 처리
기타
- • MongoDB: 문서 DB 사용 시
- • Hazelcast: 분산 캐시 환경
- • Zookeeper: 복잡한 분산 환경
- • Consul: 서비스 디스커버리 환경
@SchedulerLock 어노테이션 활용
@SchedulerLock 어노테이션을 사용하여 각 스케줄 메서드에 분산 락을 적용할 수 있습니다.
기본 사용법과 옵션
@Component
@Slf4j
public class DistributedScheduledTasks {
// 기본 사용법
@Scheduled(cron = "0 0 2 * * ?")
@SchedulerLock(name = "dailyDataCleanup")
public void dailyDataCleanup() {
log.info("Daily data cleanup started by instance: {}", getInstanceId());
// 데이터 정리 로직
cleanupOldData();
log.info("Daily data cleanup completed");
}
// 상세 옵션 설정
@Scheduled(fixedRate = 300000) // 5분마다
@SchedulerLock(
name = "systemHealthCheck",
lockAtMostFor = "PT4M", // 최대 4분간 락 유지
lockAtLeastFor = "PT1M" // 최소 1분간 락 유지
)
public void systemHealthCheck() {
log.info("System health check started by instance: {}", getInstanceId());
try {
// 시스템 상태 체크 (최대 4분 소요 예상)
performHealthCheck();
} catch (Exception e) {
log.error("Health check failed", e);
// 예외 발생 시에도 최소 1분간 락 유지
}
log.info("System health check completed");
}
// 동적 락 이름 사용
@Scheduled(cron = "0 0 1 * * ?")
@SchedulerLock(
name = "monthlyReport_#{T(java.time.LocalDate).now().getYear()}_#{T(java.time.LocalDate).now().getMonthValue()}",
lockAtMostFor = "PT2H"
)
public void monthlyReport() {
log.info("Monthly report generation started");
LocalDate now = LocalDate.now();
generateMonthlyReport(now.getYear(), now.getMonth());
log.info("Monthly report generation completed");
}
// 조건부 실행과 함께 사용
@Scheduled(cron = "0 */30 * * * ?") // 30분마다
@SchedulerLock(
name = "conditionalTask",
lockAtMostFor = "PT25M"
)
public void conditionalTask() {
// 특정 조건에서만 실행
if (shouldExecuteTask()) {
log.info("Conditional task started by instance: {}", getInstanceId());
executeConditionalLogic();
log.info("Conditional task completed");
} else {
log.debug("Conditional task skipped - conditions not met");
}
}
// 여러 스케줄이 같은 락을 공유
@Scheduled(cron = "0 0 3 * * ?")
@SchedulerLock(name = "sharedResourceTask", lockAtMostFor = "PT1H")
public void nightlyBackup() {
log.info("Nightly backup started");
performBackup();
}
@Scheduled(cron = "0 30 3 * * ?") // 30분 후
@SchedulerLock(name = "sharedResourceTask", lockAtMostFor = "PT1H")
public void nightlyCleanup() {
log.info("Nightly cleanup started");
performCleanup();
}
private String getInstanceId() {
return System.getProperty("instance.id", "unknown");
}
private void cleanupOldData() {
// 데이터 정리 구현
}
private void performHealthCheck() {
// 헬스 체크 구현
}
private void generateMonthlyReport(int year, Month month) {
// 월간 리포트 생성 구현
}
private boolean shouldExecuteTask() {
// 조건 확인 로직
return LocalTime.now().getHour() >= 9 && LocalTime.now().getHour() <= 18;
}
private void executeConditionalLogic() {
// 조건부 작업 구현
}
private void performBackup() {
// 백업 구현
}
private void performCleanup() {
// 정리 구현
}
}@SchedulerLock 속성 상세 설명
필수 속성
- name: 락의 고유 이름 (전역적으로 유일해야 함)
- • 동일한 name을 가진 작업들은 동시 실행 불가
- • SpEL 표현식 사용 가능
- • 예: "task_#{T(java.time.LocalDate).now()}"
시간 관련 속성
- lockAtMostFor: 최대 락 유지 시간
- • 작업이 비정상 종료되어도 이 시간 후 락 해제
- • ISO-8601 Duration 형식 (PT30M = 30분)
- lockAtLeastFor: 최소 락 유지 시간
- • 작업이 빨리 끝나도 이 시간까지는 락 유지
ShedLock 사용 시 주의사항
시간 설정
- • lockAtMostFor는 작업 예상 시간보다 길게 설정
- • lockAtLeastFor는 너무 길지 않게 설정
- • 시간대 동기화 필수 (NTP 사용 권장)
- • 데이터베이스 시간 사용 권장
네트워크 및 장애
- • 네트워크 분할 시 중복 실행 가능
- • 락 백엔드 장애 시 모든 작업 중단
- • 락 해제 실패 시 다음 실행 지연
- • 모니터링 및 알림 시스템 필수
ShedLock 설정 및 사용
의존성 추가
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>4.42.0</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>4.42.0</version>
</dependency>@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime() // DB 시간 사용
.build()
);
}
}
-- 테이블 생성 (MySQL)
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)
);@Component
public class DistributedScheduledTasks {
private static final Logger log = LoggerFactory.getLogger(DistributedScheduledTasks.class);
@Scheduled(cron = "0 0 2 * * ?")
@SchedulerLock(name = "dailyDataCleanup",
lockAtMostFor = "PT30M", // 최대 30분 락 유지
lockAtLeastFor = "PT5M") // 최소 5분 락 유지
public void dailyDataCleanup() {
log.info("Starting daily data cleanup - Instance: {}", getInstanceId());
try {
// 데이터 정리 로직
cleanupOldLogs();
cleanupTempFiles();
log.info("Daily data cleanup completed successfully");
} catch (Exception e) {
log.error("Daily data cleanup failed", e);
throw e; // 예외 발생 시 락 즉시 해제
}
}
@Scheduled(fixedRate = 300000) // 5분마다
@SchedulerLock(name = "systemHealthCheck",
lockAtMostFor = "PT2M",
lockAtLeastFor = "PT30S")
public void systemHealthCheck() {
log.info("Performing system health check - Instance: {}", getInstanceId());
// 시스템 상태 점검 로직
checkDatabaseConnection();
checkExternalServices();
checkDiskSpace();
}
@Scheduled(cron = "0 0 0 1 * ?") // 매월 1일
@SchedulerLock(name = "monthlyReport",
lockAtMostFor = "PT2H",
lockAtLeastFor = "PT10M")
public void generateMonthlyReport() {
log.info("Generating monthly report - Instance: {}", getInstanceId());
// 월간 리포트 생성 로직
generateSalesReport();
generateUserActivityReport();
sendReportToManagement();
}
private String getInstanceId() {
return System.getProperty("instance.id", "unknown");
}
}ShedLock 파라미터 설명
- •
name: 락의 고유 이름 (전역적으로 유일해야 함) - •
lockAtMostFor: 최대 락 유지 시간 (데드락 방지) - •
lockAtLeastFor: 최소 락 유지 시간 (너무 빠른 해제 방지)
리더 선출 방식
@Component
public class LeaderElectionScheduler {
private final LeaderElectionService leaderElectionService;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
@PostConstruct
public void startLeaderElection() {
// 리더 선출 프로세스 시작
scheduler.scheduleAtFixedRate(this::electLeader, 0, 30, TimeUnit.SECONDS);
}
private void electLeader() {
try {
if (leaderElectionService.tryBecomeLeader()) {
log.info("This instance is now the leader");
executeLeaderTasks();
} else {
log.debug("This instance is a follower");
}
} catch (Exception e) {
log.error("Leader election failed", e);
}
}
private void executeLeaderTasks() {
// 리더만 실행하는 스케줄 작업들
if (leaderElectionService.isLeader()) {
performCriticalBatchJob();
sendSystemNotifications();
updateGlobalConfiguration();
}
}
}
@Service
public class DatabaseLeaderElectionService implements LeaderElectionService {
private final JdbcTemplate jdbcTemplate;
private final String instanceId;
private volatile boolean isLeader = false;
public DatabaseLeaderElectionService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
this.instanceId = generateInstanceId();
}
@Override
public boolean tryBecomeLeader() {
try {
// 현재 리더 확인
String currentLeader = getCurrentLeader();
if (currentLeader == null || isLeaderExpired(currentLeader)) {
// 리더가 없거나 만료된 경우 리더 등록 시도
int updated = jdbcTemplate.update(
"INSERT INTO leader_election (id, instance_id, last_heartbeat) " +
"VALUES ('SCHEDULER_LEADER', ?, NOW()) " +
"ON DUPLICATE KEY UPDATE instance_id = ?, last_heartbeat = NOW()",
instanceId, instanceId
);
isLeader = updated > 0;
return isLeader;
} else if (currentLeader.equals(instanceId)) {
// 이미 리더인 경우 하트비트 업데이트
jdbcTemplate.update(
"UPDATE leader_election SET last_heartbeat = NOW() WHERE id = 'SCHEDULER_LEADER'");
isLeader = true;
return true;
}
isLeader = false;
return false;
} catch (Exception e) {
log.error("Leader election failed", e);
isLeader = false;
return false;
}
}
private String getCurrentLeader() {
try {
return jdbcTemplate.queryForObject(
"SELECT instance_id FROM leader_election WHERE id = 'SCHEDULER_LEADER'",
String.class
);
} catch (EmptyResultDataAccessException e) {
return null;
}
}
private boolean isLeaderExpired(String leaderId) {
try {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM leader_election " +
"WHERE id = 'SCHEDULER_LEADER' AND last_heartbeat > DATE_SUB(NOW(), INTERVAL 60 SECOND)",
Integer.class
);
return count == 0;
} catch (Exception e) {
return true; // 오류 시 만료된 것으로 간주
}
}
}Redis를 이용한 분산 락
@Configuration
public class RedisLockConfig {
@Bean
public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
return new RedisLockProvider(connectionFactory);
}
}
@Component
public class RedisDistributedScheduler {
private final RedisTemplate<String, String> redisTemplate;
private final String instanceId;
public RedisDistributedScheduler(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
this.instanceId = InetAddress.getLocalHost().getHostName() + "-" + UUID.randomUUID();
}
@Scheduled(fixedRate = 60000) // 1분마다
public void distributedTask() {
String lockKey = "scheduled:task:lock";
String lockValue = instanceId + ":" + System.currentTimeMillis();
try {
// Redis SET NX EX 명령으로 분산 락 획득
Boolean lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofMinutes(5));
if (Boolean.TRUE.equals(lockAcquired)) {
log.info("Lock acquired by instance: {}", instanceId);
try {
// 실제 작업 실행
executeScheduledTask();
} finally {
// 락 해제 (Lua 스크립트로 안전하게)
releaseLock(lockKey, lockValue);
}
} else {
log.debug("Lock not acquired, another instance is running the task");
}
} catch (Exception e) {
log.error("Distributed task execution failed", e);
}
}
private void releaseLock(String lockKey, String lockValue) {
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
redisTemplate.execute(
RedisScript.of(luaScript, Long.class),
Collections.singletonList(lockKey),
lockValue
);
}
private void executeScheduledTask() {
// 실제 스케줄 작업 로직
log.info("Executing distributed scheduled task");
// 예: 데이터 처리, 리포트 생성 등
processData();
generateReports();
cleanupResources();
}
}분산 스케줄링 모니터링
@Component
public class SchedulingMonitoringService {
private final MeterRegistry meterRegistry;
private final Counter scheduledTaskCounter;
private final Timer scheduledTaskTimer;
public SchedulingMonitoringService(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.scheduledTaskCounter = Counter.builder("scheduled.tasks.executed")
.description("Number of scheduled tasks executed")
.register(meterRegistry);
this.scheduledTaskTimer = Timer.builder("scheduled.tasks.duration")
.description("Duration of scheduled task execution")
.register(meterRegistry);
}
@EventListener
public void handleScheduledTaskExecution(ScheduledTaskExecutionEvent event) {
scheduledTaskCounter.increment(
Tags.of(
"task.name", event.getTaskName(),
"instance.id", event.getInstanceId(),
"status", event.getStatus().toString()
)
);
if (event.getDuration() != null) {
scheduledTaskTimer.record(event.getDuration(), TimeUnit.MILLISECONDS);
}
}
@Scheduled(fixedRate = 300000) // 5분마다
@SchedulerLock(name = "schedulingHealthCheck")
public void monitorSchedulingHealth() {
// 스케줄링 상태 모니터링
checkLockStatus();
checkTaskExecutionHistory();
alertOnAnomalies();
}
private void checkLockStatus() {
// ShedLock 테이블에서 락 상태 확인
List<LockInfo> activeLocks = getActiveLocks();
for (LockInfo lock : activeLocks) {
if (lock.isExpired()) {
log.warn("Expired lock detected: {}", lock.getName());
// 알림 발송 또는 자동 정리
}
}
}
private void checkTaskExecutionHistory() {
// 최근 실행 이력 확인
LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1);
List<String> criticalTasks = Arrays.asList(
"dailyDataCleanup",
"systemHealthCheck",
"monthlyReport"
);
for (String taskName : criticalTasks) {
boolean executed = wasTaskExecutedSince(taskName, oneHourAgo);
if (!executed) {
log.error("Critical task {} not executed in the last hour", taskName);
sendAlert("Critical task not executed: " + taskName);
}
}
}
}분산 스케줄링 베스트 프랙티스
- • 락 타임아웃을 작업 예상 시간보다 충분히 길게 설정
- • 데드락 방지를 위한 최대 락 시간 설정
- • 락 획득 실패 시 로깅 및 모니터링
- • 인스턴스 식별자를 명확하게 설정
- • 네트워크 분할 상황 고려
예외 처리와 모니터링
스케줄 작업 예외 처리
스케줄 작업에서 발생하는 예외는 적절히 처리하지 않으면 전체 스케줄링이 중단될 수 있습니다.
@Component
public class RobustScheduledTasks {
private static final Logger log = LoggerFactory.getLogger(RobustScheduledTasks.class);
private final EmailService emailService;
private final MetricsService metricsService;
@Scheduled(fixedRate = 300000) // 5분마다
public void robustDataProcessing() {
String taskName = "dataProcessing";
long startTime = System.currentTimeMillis();
try {
log.info("Starting {} task", taskName);
// 실제 작업 실행
processData();
// 성공 메트릭 기록
metricsService.recordTaskSuccess(taskName, System.currentTimeMillis() - startTime);
log.info("Task {} completed successfully", taskName);
} catch (RetryableException e) {
// 재시도 가능한 예외 (네트워크 오류, 일시적 DB 오류 등)
log.warn("Retryable error in task {}: {}", taskName, e.getMessage());
handleRetryableException(taskName, e);
} catch (NonRetryableException e) {
// 재시도 불가능한 예외 (설정 오류, 데이터 형식 오류 등)
log.error("Non-retryable error in task {}: {}", taskName, e.getMessage(), e);
handleNonRetryableException(taskName, e);
} catch (Exception e) {
// 예상치 못한 예외
log.error("Unexpected error in task {}: {}", taskName, e.getMessage(), e);
handleUnexpectedException(taskName, e);
} finally {
// 정리 작업
cleanupResources();
metricsService.recordTaskExecution(taskName);
}
}
private void handleRetryableException(String taskName, Exception e) {
// 재시도 로직 (별도 스레드에서 실행)
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(60000); // 1분 대기
retryTask(taskName);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
});
metricsService.recordTaskRetry(taskName);
}
private void handleNonRetryableException(String taskName, Exception e) {
// 즉시 알림 발송
String alertMessage = String.format(
"Task %s failed with non-retryable error: %s",
taskName, e.getMessage()
);
emailService.sendAlert("Critical Task Failure", alertMessage);
metricsService.recordTaskFailure(taskName, "non_retryable");
}
private void handleUnexpectedException(String taskName, Exception e) {
// 상세 정보와 함께 알림 발송
String alertMessage = String.format(
"Task %s failed with unexpected error: %s\nStack trace: %s",
taskName, e.getMessage(), getStackTrace(e)
);
emailService.sendAlert("Unexpected Task Failure", alertMessage);
metricsService.recordTaskFailure(taskName, "unexpected");
}
}예외 처리 원칙
- • 모든 스케줄 메서드에 try-catch 블록 필수
- • 예외 타입에 따른 차별화된 처리
- • 예외 발생 시에도 다음 스케줄 실행이 계속되도록 보장
- • 중요한 예외는 즉시 알림 발송
재시도 메커니즘
@Component
public class RetryableScheduledTasks {
private final RetryTemplate retryTemplate;
public RetryableScheduledTasks() {
this.retryTemplate = RetryTemplate.builder()
.maxAttempts(3)
.exponentialBackoff(1000, 2, 10000) // 1초부터 시작, 2배씩 증가, 최대 10초
.retryOn(RetryableException.class)
.build();
}
@Scheduled(cron = "0 */10 * * * ?") // 10분마다
public void externalApiCall() {
try {
retryTemplate.execute(context -> {
log.info("Attempting external API call (attempt: {})",
context.getRetryCount() + 1);
// 외부 API 호출
callExternalService();
return null;
});
} catch (Exception e) {
log.error("All retry attempts failed for external API call", e);
handleFinalFailure("externalApiCall", e);
}
}
@Scheduled(fixedDelay = 60000) // 1분 간격
public void databaseOperation() {
RetryTemplate dbRetryTemplate = RetryTemplate.builder()
.maxAttempts(5)
.fixedBackoff(2000) // 2초 고정 대기
.retryOn(DataAccessException.class, SQLException.class)
.build();
try {
dbRetryTemplate.execute(context -> {
if (context.getRetryCount() > 0) {
log.warn("Retrying database operation (attempt: {})",
context.getRetryCount() + 1);
}
performDatabaseOperation();
return null;
});
} catch (Exception e) {
log.error("Database operation failed after all retries", e);
// 데이터베이스 연결 문제 알림
notifyDatabaseIssue(e);
}
}
// 커스텀 재시도 로직
@Scheduled(fixedRate = 120000) // 2분마다
public void customRetryLogic() {
int maxRetries = 3;
int retryCount = 0;
boolean success = false;
while (retryCount < maxRetries && !success) {
try {
performCriticalTask();
success = true;
log.info("Critical task completed successfully");
} catch (Exception e) {
retryCount++;
log.warn("Critical task failed (attempt {}/{}): {}",
retryCount, maxRetries, e.getMessage());
if (retryCount < maxRetries) {
try {
// 지수 백오프
long delay = (long) Math.pow(2, retryCount) * 1000;
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
} else {
log.error("Critical task failed after {} attempts", maxRetries, e);
handleCriticalTaskFailure(e);
}
}
}
}
}실행 이력 관리
@Entity
@Table(name = "scheduled_task_execution_log")
public class ScheduledTaskExecutionLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String taskName;
@Column(nullable = false)
private String instanceId;
@Column(nullable = false)
private LocalDateTime startTime;
private LocalDateTime endTime;
@Enumerated(EnumType.STRING)
private TaskStatus status;
@Column(columnDefinition = "TEXT")
private String errorMessage;
private Long executionTimeMs;
@Column(columnDefinition = "TEXT")
private String additionalInfo;
// constructors, getters, setters
}
@Service
@Transactional
public class TaskExecutionLogService {
private final ScheduledTaskExecutionLogRepository logRepository;
public Long logTaskStart(String taskName, String instanceId) {
ScheduledTaskExecutionLog log = new ScheduledTaskExecutionLog();
log.setTaskName(taskName);
log.setInstanceId(instanceId);
log.setStartTime(LocalDateTime.now());
log.setStatus(TaskStatus.RUNNING);
ScheduledTaskExecutionLog saved = logRepository.save(log);
return saved.getId();
}
public void logTaskSuccess(Long logId, String additionalInfo) {
logRepository.findById(logId).ifPresent(log -> {
log.setEndTime(LocalDateTime.now());
log.setStatus(TaskStatus.SUCCESS);
log.setExecutionTimeMs(
Duration.between(log.getStartTime(), log.getEndTime()).toMillis()
);
log.setAdditionalInfo(additionalInfo);
logRepository.save(log);
});
}
public void logTaskFailure(Long logId, Exception e) {
logRepository.findById(logId).ifPresent(log -> {
log.setEndTime(LocalDateTime.now());
log.setStatus(TaskStatus.FAILED);
log.setExecutionTimeMs(
Duration.between(log.getStartTime(), log.getEndTime()).toMillis()
);
log.setErrorMessage(e.getMessage());
log.setAdditionalInfo(getStackTrace(e));
logRepository.save(log);
});
}
// 실행 이력 조회 메서드들
public List<ScheduledTaskExecutionLog> getRecentExecutions(String taskName, int limit) {
return logRepository.findByTaskNameOrderByStartTimeDesc(taskName,
PageRequest.of(0, limit));
}
public TaskExecutionStatistics getTaskStatistics(String taskName, LocalDateTime since) {
List<ScheduledTaskExecutionLog> logs = logRepository
.findByTaskNameAndStartTimeAfter(taskName, since);
return TaskExecutionStatistics.builder()
.totalExecutions(logs.size())
.successfulExecutions(countByStatus(logs, TaskStatus.SUCCESS))
.failedExecutions(countByStatus(logs, TaskStatus.FAILED))
.averageExecutionTime(calculateAverageExecutionTime(logs))
.build();
}
}실시간 모니터링 및 알림
@Component
public class SchedulingMonitor {
private final MeterRegistry meterRegistry;
private final NotificationService notificationService;
private final TaskExecutionLogService logService;
// 메트릭 정의
private final Counter taskExecutionCounter;
private final Timer taskExecutionTimer;
private final Gauge activeTasksGauge;
public SchedulingMonitor(MeterRegistry meterRegistry,
NotificationService notificationService,
TaskExecutionLogService logService) {
this.meterRegistry = meterRegistry;
this.notificationService = notificationService;
this.logService = logService;
this.taskExecutionCounter = Counter.builder("scheduled.tasks.total")
.description("Total number of scheduled task executions")
.register(meterRegistry);
this.taskExecutionTimer = Timer.builder("scheduled.tasks.duration")
.description("Duration of scheduled task executions")
.register(meterRegistry);
this.activeTasksGauge = Gauge.builder("scheduled.tasks.active")
.description("Number of currently active scheduled tasks")
.register(meterRegistry, this, SchedulingMonitor::getActiveTaskCount);
}
@EventListener
public void handleTaskExecution(TaskExecutionEvent event) {
// 메트릭 업데이트
taskExecutionCounter.increment(
Tags.of(
"task.name", event.getTaskName(),
"status", event.getStatus().toString(),
"instance.id", event.getInstanceId()
)
);
if (event.getExecutionTime() != null) {
taskExecutionTimer.record(
event.getExecutionTime(),
TimeUnit.MILLISECONDS,
Tags.of("task.name", event.getTaskName())
);
}
// 실패 시 알림 처리
if (event.getStatus() == TaskStatus.FAILED) {
handleTaskFailure(event);
}
// 실행 시간이 임계값을 초과한 경우
if (event.getExecutionTime() != null &&
event.getExecutionTime() > getThreshold(event.getTaskName())) {
handleSlowExecution(event);
}
}
private void handleTaskFailure(TaskExecutionEvent event) {
// 연속 실패 횟수 확인
int consecutiveFailures = getConsecutiveFailures(event.getTaskName());
if (consecutiveFailures >= 3) {
// 3회 연속 실패 시 긴급 알림
notificationService.sendUrgentAlert(
"Critical Task Failure",
String.format("Task %s has failed %d times consecutively",
event.getTaskName(), consecutiveFailures)
);
} else if (consecutiveFailures >= 1) {
// 일반 실패 알림
notificationService.sendAlert(
"Task Failure",
String.format("Task %s failed: %s",
event.getTaskName(), event.getErrorMessage())
);
}
}
private void handleSlowExecution(TaskExecutionEvent event) {
notificationService.sendWarning(
"Slow Task Execution",
String.format("Task %s took %d ms to execute (threshold: %d ms)",
event.getTaskName(),
event.getExecutionTime(),
getThreshold(event.getTaskName()))
);
}
@Scheduled(fixedRate = 300000) // 5분마다
public void performHealthCheck() {
LocalDateTime fiveMinutesAgo = LocalDateTime.now().minusMinutes(5);
// 중요한 작업들이 정상적으로 실행되고 있는지 확인
List<String> criticalTasks = Arrays.asList(
"dailyBackup", "systemHealthCheck", "dataSync"
);
for (String taskName : criticalTasks) {
TaskExecutionStatistics stats = logService.getTaskStatistics(taskName, fiveMinutesAgo);
if (stats.getTotalExecutions() == 0) {
notificationService.sendWarning(
"Missing Task Execution",
String.format("Critical task %s has not executed in the last 5 minutes", taskName)
);
}
double failureRate = (double) stats.getFailedExecutions() / stats.getTotalExecutions();
if (failureRate > 0.5) { // 50% 이상 실패율
notificationService.sendAlert(
"High Task Failure Rate",
String.format("Task %s has a failure rate of %.2f%%", taskName, failureRate * 100)
);
}
}
}
private double getActiveTaskCount() {
// 현재 실행 중인 작업 수 반환
return logService.getActiveTaskCount();
}
}대시보드 및 리포팅
@RestController
@RequestMapping("/api/scheduling")
public class SchedulingDashboardController {
private final TaskExecutionLogService logService;
private final MeterRegistry meterRegistry;
@GetMapping("/dashboard")
public ResponseEntity<SchedulingDashboard> getDashboard() {
LocalDateTime last24Hours = LocalDateTime.now().minusHours(24);
SchedulingDashboard dashboard = SchedulingDashboard.builder()
.totalTasks(getTotalTaskCount())
.activeTasks(getActiveTaskCount())
.last24HourExecutions(getExecutionCount(last24Hours))
.last24HourFailures(getFailureCount(last24Hours))
.taskStatistics(getTaskStatistics(last24Hours))
.systemHealth(calculateSystemHealth())
.build();
return ResponseEntity.ok(dashboard);
}
@GetMapping("/tasks/{taskName}/history")
public ResponseEntity<List<TaskExecutionSummary>> getTaskHistory(
@PathVariable String taskName,
@RequestParam(defaultValue = "100") int limit) {
List<ScheduledTaskExecutionLog> logs = logService.getRecentExecutions(taskName, limit);
List<TaskExecutionSummary> summaries = logs.stream()
.map(this::convertToSummary)
.collect(Collectors.toList());
return ResponseEntity.ok(summaries);
}
@GetMapping("/report/daily")
public ResponseEntity<DailySchedulingReport> getDailyReport(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
LocalDateTime startOfDay = date.atStartOfDay();
LocalDateTime endOfDay = date.atTime(23, 59, 59);
DailySchedulingReport report = DailySchedulingReport.builder()
.date(date)
.totalExecutions(getExecutionCountBetween(startOfDay, endOfDay))
.successfulExecutions(getSuccessCountBetween(startOfDay, endOfDay))
.failedExecutions(getFailureCountBetween(startOfDay, endOfDay))
.averageExecutionTime(getAverageExecutionTime(startOfDay, endOfDay))
.taskBreakdown(getTaskBreakdown(startOfDay, endOfDay))
.issues(getIssuesSummary(startOfDay, endOfDay))
.build();
return ResponseEntity.ok(report);
}
@GetMapping("/alerts/configuration")
public ResponseEntity<AlertConfiguration> getAlertConfiguration() {
AlertConfiguration config = AlertConfiguration.builder()
.failureThreshold(3) // 3회 연속 실패 시 알림
.executionTimeThresholds(getExecutionTimeThresholds())
.healthCheckInterval(Duration.ofMinutes(5))
.notificationChannels(getNotificationChannels())
.build();
return ResponseEntity.ok(config);
}
}모니터링 베스트 프랙티스
- • 모든 스케줄 작업의 실행 이력을 데이터베이스에 기록
- • 메트릭을 통한 실시간 모니터링 구현
- • 실패율, 실행 시간 등 핵심 지표 추적
- • 임계값 기반 자동 알림 시스템 구축
- • 대시보드를 통한 시각적 모니터링 제공
실전 활용
데이터 정리 자동화
시스템 운영 중 누적되는 불필요한 데이터를 자동으로 정리하는 스케줄 작업을 구현해보겠습니다.
@Service
public class DataCleanupService {
private final JdbcTemplate jdbcTemplate;
private final FileSystemService fileSystemService;
private final CacheManager cacheManager;
@Scheduled(cron = "0 0 2 * * ?") // 매일 새벽 2시
@SchedulerLock(name = "dailyDataCleanup", lockAtMostFor = "PT2H")
public void performDailyCleanup() {
log.info("Starting daily data cleanup process");
try {
// 1. 오래된 로그 데이터 삭제 (30일 이상)
cleanupOldLogs();
// 2. 임시 파일 정리
cleanupTempFiles();
// 3. 만료된 세션 데이터 삭제
cleanupExpiredSessions();
// 4. 캐시 정리
cleanupExpiredCache();
// 5. 데이터베이스 통계 업데이트
updateDatabaseStatistics();
log.info("Daily data cleanup completed successfully");
} catch (Exception e) {
log.error("Daily data cleanup failed", e);
throw e;
}
}
private void cleanupOldLogs() {
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(30);
// 애플리케이션 로그 삭제
int deletedAppLogs = jdbcTemplate.update(
"DELETE FROM application_logs WHERE created_at < ?",
cutoffDate
);
// 액세스 로그 삭제
int deletedAccessLogs = jdbcTemplate.update(
"DELETE FROM access_logs WHERE created_at < ?",
cutoffDate
);
// 에러 로그는 90일 보관
LocalDateTime errorLogCutoff = LocalDateTime.now().minusDays(90);
int deletedErrorLogs = jdbcTemplate.update(
"DELETE FROM error_logs WHERE created_at < ?",
errorLogCutoff
);
log.info("Cleaned up logs - App: {}, Access: {}, Error: {}",
deletedAppLogs, deletedAccessLogs, deletedErrorLogs);
}
private void cleanupTempFiles() {
try {
Path tempDir = Paths.get(System.getProperty("java.io.tmpdir"), "app-temp");
if (Files.exists(tempDir)) {
long deletedFiles = Files.walk(tempDir)
.filter(Files::isRegularFile)
.filter(path -> {
try {
FileTime lastModified = Files.getLastModifiedTime(path);
return lastModified.toInstant()
.isBefore(Instant.now().minus(1, ChronoUnit.DAYS));
} catch (IOException e) {
return false;
}
})
.peek(path -> {
try {
Files.delete(path);
} catch (IOException e) {
log.warn("Failed to delete temp file: {}", path, e);
}
})
.count();
log.info("Cleaned up {} temporary files", deletedFiles);
}
} catch (IOException e) {
log.error("Failed to cleanup temporary files", e);
}
}
private void cleanupExpiredSessions() {
// Redis 기반 세션 정리
LocalDateTime expiredBefore = LocalDateTime.now().minusHours(24);
int deletedSessions = jdbcTemplate.update(
"DELETE FROM user_sessions WHERE last_accessed < ? AND active = false",
expiredBefore
);
log.info("Cleaned up {} expired sessions", deletedSessions);
}
private void cleanupExpiredCache() {
// 수동으로 관리되는 캐시 정리
cacheManager.getCacheNames().forEach(cacheName -> {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.clear();
log.debug("Cleared cache: {}", cacheName);
}
});
}
@Scheduled(cron = "0 0 3 1 * ?") // 매월 1일 새벽 3시
@SchedulerLock(name = "monthlyArchiving", lockAtMostFor = "PT4H")
public void performMonthlyArchiving() {
log.info("Starting monthly data archiving");
try {
LocalDateTime archiveCutoff = LocalDateTime.now().minusMonths(6);
// 오래된 주문 데이터 아카이빙
archiveOldOrders(archiveCutoff);
// 사용자 활동 로그 아카이빙
archiveUserActivityLogs(archiveCutoff);
// 시스템 메트릭 데이터 압축
compressOldMetrics(archiveCutoff);
log.info("Monthly archiving completed successfully");
} catch (Exception e) {
log.error("Monthly archiving failed", e);
throw e;
}
}
private void archiveOldOrders(LocalDateTime cutoffDate) {
// 완료된 주문을 아카이브 테이블로 이동
jdbcTemplate.update("""
INSERT INTO archived_orders
SELECT *, NOW() as archived_at
FROM orders
WHERE status = 'COMPLETED' AND completed_at < ?
""", cutoffDate);
int archivedCount = jdbcTemplate.update(
"DELETE FROM orders WHERE status = 'COMPLETED' AND completed_at < ?",
cutoffDate
);
log.info("Archived {} old orders", archivedCount);
}
}리포트 생성 자동화
@Service
public class ReportGenerationService {
private final ReportRepository reportRepository;
private final EmailService emailService;
private final ExcelService excelService;
private final PdfService pdfService;
@Scheduled(cron = "0 0 8 * * MON") // 매주 월요일 오전 8시
@SchedulerLock(name = "weeklyReport", lockAtMostFor = "PT1H")
public void generateWeeklyReport() {
log.info("Starting weekly report generation");
try {
LocalDate endDate = LocalDate.now().minusDays(1); // 어제까지
LocalDate startDate = endDate.minusDays(6); // 지난 주
// 주간 매출 리포트
WeeklySalesReport salesReport = generateWeeklySalesReport(startDate, endDate);
// 사용자 활동 리포트
WeeklyUserActivityReport activityReport = generateWeeklyActivityReport(startDate, endDate);
// 시스템 성능 리포트
WeeklyPerformanceReport performanceReport = generateWeeklyPerformanceReport(startDate, endDate);
// 리포트 파일 생성
String reportFileName = String.format("weekly_report_%s_to_%s.xlsx", startDate, endDate);
byte[] excelData = excelService.createWeeklyReport(salesReport, activityReport, performanceReport);
// 이메일 발송
List<String> recipients = getReportRecipients("WEEKLY_REPORT");
emailService.sendReportEmail(
recipients,
"주간 운영 리포트 - " + startDate + " ~ " + endDate,
createReportEmailContent(salesReport, activityReport, performanceReport),
reportFileName,
excelData
);
// 리포트 이력 저장
saveReportHistory("WEEKLY", startDate, endDate, reportFileName);
log.info("Weekly report generated and sent successfully");
} catch (Exception e) {
log.error("Weekly report generation failed", e);
throw e;
}
}
@Scheduled(cron = "0 0 7 1 * ?") // 매월 1일 오전 7시
@SchedulerLock(name = "monthlyReport", lockAtMostFor = "PT2H")
public void generateMonthlyReport() {
log.info("Starting monthly report generation");
try {
LocalDate lastMonth = LocalDate.now().minusMonths(1);
LocalDate startDate = lastMonth.withDayOfMonth(1);
LocalDate endDate = lastMonth.withDayOfMonth(lastMonth.lengthOfMonth());
// 월간 종합 리포트 생성
MonthlyComprehensiveReport report = MonthlyComprehensiveReport.builder()
.period(startDate + " ~ " + endDate)
.salesSummary(generateMonthlySalesSummary(startDate, endDate))
.userGrowth(calculateUserGrowth(startDate, endDate))
.systemMetrics(aggregateSystemMetrics(startDate, endDate))
.topProducts(getTopProducts(startDate, endDate))
.customerInsights(analyzeCustomerBehavior(startDate, endDate))
.build();
// PDF 리포트 생성
byte[] pdfData = pdfService.createMonthlyReport(report);
String pdfFileName = String.format("monthly_report_%s.pdf",
lastMonth.format(DateTimeFormatter.ofPattern("yyyy_MM")));
// 경영진에게 발송
List<String> executiveRecipients = getReportRecipients("MONTHLY_EXECUTIVE");
emailService.sendReportEmail(
executiveRecipients,
String.format("%s월 월간 운영 리포트", lastMonth.getMonthValue()),
createExecutiveReportContent(report),
pdfFileName,
pdfData
);
// 상세 Excel 리포트도 생성
byte[] detailedExcelData = excelService.createDetailedMonthlyReport(report);
String excelFileName = String.format("monthly_detailed_report_%s.xlsx",
lastMonth.format(DateTimeFormatter.ofPattern("yyyy_MM")));
// 운영팀에게 발송
List<String> operationRecipients = getReportRecipients("MONTHLY_OPERATION");
emailService.sendReportEmail(
operationRecipients,
String.format("%s월 상세 운영 리포트", lastMonth.getMonthValue()),
createOperationReportContent(report),
excelFileName,
detailedExcelData
);
log.info("Monthly report generated and sent successfully");
} catch (Exception e) {
log.error("Monthly report generation failed", e);
throw e;
}
}
@Scheduled(cron = "0 30 23 * * ?") // 매일 오후 11시 30분
@SchedulerLock(name = "dailyDashboard", lockAtMostFor = "PT30M")
public void updateDailyDashboard() {
log.info("Updating daily dashboard data");
try {
LocalDate today = LocalDate.now();
// 일일 핵심 지표 계산
DailyMetrics metrics = DailyMetrics.builder()
.date(today)
.totalSales(calculateDailySales(today))
.newUsers(countNewUsers(today))
.activeUsers(countActiveUsers(today))
.orderCount(countOrders(today))
.averageOrderValue(calculateAverageOrderValue(today))
.conversionRate(calculateConversionRate(today))
.systemUptime(calculateSystemUptime(today))
.errorRate(calculateErrorRate(today))
.build();
// 대시보드 데이터 업데이트
updateDashboardCache(metrics);
// 실시간 알림이 필요한 지표 확인
checkCriticalMetrics(metrics);
log.info("Daily dashboard updated successfully");
} catch (Exception e) {
log.error("Daily dashboard update failed", e);
throw e;
}
}
private void checkCriticalMetrics(DailyMetrics metrics) {
// 매출이 전일 대비 20% 이상 감소한 경우
BigDecimal yesterdaySales = calculateDailySales(LocalDate.now().minusDays(1));
if (metrics.getTotalSales().compareTo(yesterdaySales.multiply(BigDecimal.valueOf(0.8))) < 0) {
emailService.sendAlert(
"매출 급감 알림",
String.format("오늘 매출이 전일 대비 %.2f%% 감소했습니다.",
calculatePercentageChange(yesterdaySales, metrics.getTotalSales()))
);
}
// 에러율이 5% 이상인 경우
if (metrics.getErrorRate() > 0.05) {
emailService.sendAlert(
"높은 에러율 감지",
String.format("현재 시스템 에러율이 %.2f%%입니다.", metrics.getErrorRate() * 100)
);
}
}
}알림 발송 시스템
@Service
public class NotificationSchedulingService {
private final NotificationRepository notificationRepository;
private final EmailService emailService;
private final SmsService smsService;
private final PushNotificationService pushService;
@Scheduled(fixedRate = 60000) // 1분마다
public void processPendingNotifications() {
List<PendingNotification> pendingNotifications =
notificationRepository.findPendingNotifications(LocalDateTime.now());
for (PendingNotification notification : pendingNotifications) {
try {
processNotification(notification);
notification.setStatus(NotificationStatus.SENT);
notification.setSentAt(LocalDateTime.now());
} catch (Exception e) {
log.error("Failed to send notification: {}", notification.getId(), e);
notification.setStatus(NotificationStatus.FAILED);
notification.setErrorMessage(e.getMessage());
notification.setRetryCount(notification.getRetryCount() + 1);
// 재시도 로직
if (notification.getRetryCount() < 3) {
notification.setScheduledAt(LocalDateTime.now().plusMinutes(5));
notification.setStatus(NotificationStatus.PENDING);
}
} finally {
notificationRepository.save(notification);
}
}
}
@Scheduled(cron = "0 0 9 * * ?") // 매일 오전 9시
@SchedulerLock(name = "dailyReminders", lockAtMostFor = "PT1H")
public void sendDailyReminders() {
log.info("Sending daily reminders");
try {
// 1. 장바구니 이탈 고객 리마인더
sendAbandonedCartReminders();
// 2. 구독 만료 예정 알림
sendSubscriptionExpiryReminders();
// 3. 미완료 주문 알림
sendIncompleteOrderReminders();
// 4. 생일 축하 메시지
sendBirthdayGreetings();
log.info("Daily reminders sent successfully");
} catch (Exception e) {
log.error("Failed to send daily reminders", e);
throw e;
}
}
private void sendAbandonedCartReminders() {
LocalDateTime cutoffTime = LocalDateTime.now().minusHours(24);
List<AbandonedCart> abandonedCarts = cartRepository
.findAbandonedCarts(cutoffTime);
for (AbandonedCart cart : abandonedCarts) {
try {
// 개인화된 이메일 생성
String emailContent = createAbandonedCartEmail(cart);
emailService.sendEmail(
cart.getUserEmail(),
"잊고 가신 상품이 있어요! 🛒",
emailContent
);
// 푸시 알림도 발송
if (cart.getUser().isPushEnabled()) {
pushService.sendPushNotification(
cart.getUser().getDeviceToken(),
"장바구니 상품이 기다리고 있어요!",
"지금 주문하면 특별 할인 혜택을 받을 수 있습니다."
);
}
// 발송 이력 기록
recordNotificationSent(cart.getUserId(), "ABANDONED_CART", "EMAIL");
} catch (Exception e) {
log.error("Failed to send abandoned cart reminder to user: {}",
cart.getUserId(), e);
}
}
log.info("Sent {} abandoned cart reminders", abandonedCarts.size());
}
private void sendSubscriptionExpiryReminders() {
// 7일 후 만료 예정
LocalDateTime sevenDaysLater = LocalDateTime.now().plusDays(7);
List<Subscription> expiringSoon = subscriptionRepository
.findExpiringBetween(LocalDateTime.now(), sevenDaysLater);
for (Subscription subscription : expiringSoon) {
try {
String emailContent = createSubscriptionRenewalEmail(subscription);
emailService.sendEmail(
subscription.getUser().getEmail(),
"구독 갱신 안내 - 7일 후 만료 예정",
emailContent
);
recordNotificationSent(subscription.getUserId(), "SUBSCRIPTION_EXPIRY", "EMAIL");
} catch (Exception e) {
log.error("Failed to send subscription expiry reminder to user: {}",
subscription.getUserId(), e);
}
}
log.info("Sent {} subscription expiry reminders", expiringSoon.size());
}
@Scheduled(cron = "0 0 20 * * ?") // 매일 오후 8시
@SchedulerLock(name = "eveningDigest", lockAtMostFor = "PT30M")
public void sendEveningDigest() {
log.info("Sending evening digest notifications");
try {
LocalDate today = LocalDate.now();
// 오늘의 주요 활동 요약
List<User> digestSubscribers = userRepository.findDigestSubscribers();
for (User user : digestSubscribers) {
try {
UserDailyDigest digest = createUserDailyDigest(user, today);
if (digest.hasContent()) {
String emailContent = createDigestEmail(digest);
emailService.sendEmail(
user.getEmail(),
"오늘의 활동 요약 📊",
emailContent
);
recordNotificationSent(user.getId(), "DAILY_DIGEST", "EMAIL");
}
} catch (Exception e) {
log.error("Failed to send evening digest to user: {}", user.getId(), e);
}
}
log.info("Evening digest sent to {} users", digestSubscribers.size());
} catch (Exception e) {
log.error("Failed to send evening digest", e);
throw e;
}
}
@Scheduled(cron = "0 0 10 * * SUN") // 매주 일요일 오전 10시
@SchedulerLock(name = "weeklyNewsletter", lockAtMostFor = "PT2H")
public void sendWeeklyNewsletter() {
log.info("Sending weekly newsletter");
try {
List<User> newsletterSubscribers = userRepository.findNewsletterSubscribers();
// 주간 뉴스레터 콘텐츠 생성
WeeklyNewsletter newsletter = createWeeklyNewsletter();
// 배치로 발송 (한 번에 100명씩)
int batchSize = 100;
for (int i = 0; i < newsletterSubscribers.size(); i += batchSize) {
List<User> batch = newsletterSubscribers.subList(
i, Math.min(i + batchSize, newsletterSubscribers.size())
);
sendNewsletterBatch(batch, newsletter);
// 배치 간 1초 대기 (이메일 서버 부하 방지)
Thread.sleep(1000);
}
log.info("Weekly newsletter sent to {} subscribers", newsletterSubscribers.size());
} catch (Exception e) {
log.error("Failed to send weekly newsletter", e);
throw e;
}
}
}알림 시스템 설계 원칙
- • 사용자 선호도 기반 개인화된 알림
- • 적절한 시간대 고려 (시간대별 발송)
- • 재시도 로직과 실패 처리
- • 발송 이력 추적 및 분석
- • 스팸 방지를 위한 발송 빈도 제한
시스템 유지보수 자동화
@Service
public class SystemMaintenanceService {
private final DatabaseMaintenanceService dbService;
private final CacheMaintenanceService cacheService;
private final FileSystemMaintenanceService fileService;
@Scheduled(cron = "0 0 3 * * SUN") // 매주 일요일 새벽 3시
@SchedulerLock(name = "weeklyMaintenance", lockAtMostFor = "PT4H")
public void performWeeklyMaintenance() {
log.info("Starting weekly system maintenance");
try {
// 1. 데이터베이스 최적화
optimizeDatabase();
// 2. 인덱스 재구성
rebuildIndexes();
// 3. 통계 정보 업데이트
updateDatabaseStatistics();
// 4. 로그 파일 압축 및 아카이빙
compressAndArchiveLogs();
// 5. 시스템 성능 분석
analyzeSystemPerformance();
log.info("Weekly system maintenance completed successfully");
} catch (Exception e) {
log.error("Weekly system maintenance failed", e);
throw e;
}
}
@Scheduled(cron = "0 0 4 1 * ?") // 매월 1일 새벽 4시
@SchedulerLock(name = "monthlyMaintenance", lockAtMostFor = "PT6H")
public void performMonthlyMaintenance() {
log.info("Starting monthly system maintenance");
try {
// 1. 전체 시스템 백업
performFullSystemBackup();
// 2. 보안 패치 확인 및 적용
checkAndApplySecurityPatches();
// 3. 라이센스 만료 확인
checkLicenseExpiry();
// 4. 시스템 리소스 사용량 분석
analyzeResourceUsage();
// 5. 성능 튜닝 권장사항 생성
generatePerformanceTuningRecommendations();
log.info("Monthly system maintenance completed successfully");
} catch (Exception e) {
log.error("Monthly system maintenance failed", e);
throw e;
}
}
@Scheduled(fixedRate = 300000) // 5분마다
public void monitorSystemHealth() {
try {
SystemHealthStatus status = SystemHealthStatus.builder()
.cpuUsage(getCpuUsage())
.memoryUsage(getMemoryUsage())
.diskUsage(getDiskUsage())
.databaseConnections(getDatabaseConnectionCount())
.activeThreads(getActiveThreadCount())
.queueSize(getTaskQueueSize())
.build();
// 임계값 확인 및 알림
checkHealthThresholds(status);
// 메트릭 기록
recordHealthMetrics(status);
} catch (Exception e) {
log.error("System health monitoring failed", e);
}
}
private void checkHealthThresholds(SystemHealthStatus status) {
// CPU 사용률이 80% 이상인 경우
if (status.getCpuUsage() > 0.8) {
sendAlert("High CPU Usage",
String.format("CPU usage is %.2f%%", status.getCpuUsage() * 100));
}
// 메모리 사용률이 85% 이상인 경우
if (status.getMemoryUsage() > 0.85) {
sendAlert("High Memory Usage",
String.format("Memory usage is %.2f%%", status.getMemoryUsage() * 100));
}
// 디스크 사용률이 90% 이상인 경우
if (status.getDiskUsage() > 0.9) {
sendAlert("High Disk Usage",
String.format("Disk usage is %.2f%%", status.getDiskUsage() * 100));
}
}
}실전 활용 팁
- • 비즈니스 요구사항에 맞는 스케줄 시간 설정
- • 시스템 부하가 적은 시간대 활용
- • 작업 실행 시간 모니터링 및 최적화
- • 실패 시 자동 복구 메커니즘 구현
- • 정기적인 스케줄 작업 성능 리뷰
정리
스케줄링 설계 가이드
📋 설계 단계
- 1. 요구사항 분석
- • 실행 주기 및 시간 결정
- • 작업 우선순위 설정
- • 의존성 관계 파악
- 2. 아키텍처 설계
- • 단일 vs 분산 환경 고려
- • 스케줄러 타입 선택
- • 리소스 할당 계획
- 3. 구현 및 테스트
- • 예외 처리 구현
- • 모니터링 시스템 구축
- • 성능 테스트 수행
⚙️ 기술 선택 기준
@Scheduled 어노테이션 사용
TaskScheduler + SchedulingConfigurer
ShedLock 또는 Quartz Cluster
Spring Batch + Spring Cloud Task
설계 체크리스트
- ☐ 실행 주기 정의
- ☐ 작업 우선순위 설정
- ☐ 의존성 관계 정의
- ☐ 실패 시 처리 방안
- ☐ 성능 요구사항
- ☐ 가용성 요구사항
- ☐ 확장성 고려
- ☐ 보안 요구사항
베스트 프랙티스
✅ 코딩 베스트 프랙티스
// ✅ 좋은 예시
@Component
public class WellDesignedScheduledTask {
private static final Logger log = LoggerFactory.getLogger(WellDesignedScheduledTask.class);
private final TaskService taskService;
private final MetricsService metricsService;
@Scheduled(cron = "0 0 2 * * ?")
@SchedulerLock(name = "dailyTask", lockAtMostFor = "PT2H")
public void executeDailyTask() {
String taskName = "dailyTask";
long startTime = System.currentTimeMillis();
try {
log.info("Starting {}", taskName);
// 실제 비즈니스 로직
taskService.performDailyOperation();
// 성공 메트릭 기록
metricsService.recordSuccess(taskName, System.currentTimeMillis() - startTime);
log.info("Completed {} successfully", taskName);
} catch (Exception e) {
log.error("Failed to execute {}", taskName, e);
metricsService.recordFailure(taskName, e);
// 예외를 다시 던지지 않음 (다음 스케줄 실행 보장)
}
}
}- • 예외 처리 없이 스케줄 메서드 작성
- • 긴 실행 시간을 고려하지 않은 스케줄 설정
- • 분산 환경에서 중복 실행 방지 미구현
- • 로깅 및 모니터링 부재
🔧 운영 베스트 프랙티스
- • 실행 이력 데이터베이스 저장
- • 메트릭 기반 실시간 모니터링
- • 임계값 기반 자동 알림
- • 대시보드를 통한 시각화
- • 적절한 스레드 풀 크기 설정
- • 배치 처리로 효율성 향상
- • 데이터베이스 연결 풀 관리
- • 메모리 사용량 최적화
🛡️ 보안 및 안정성
- • 민감한 데이터 처리 시 암호화
- • 외부 API 호출 시 인증 토큰 관리
- • 로그에 민감 정보 노출 방지
- • 권한 기반 스케줄 작업 실행
- • 재시도 메커니즘 구현
- • 서킷 브레이커 패턴 적용
- • 타임아웃 설정
- • 우아한 종료 처리
성능 최적화 전략
📊 성능 측정 및 분석
@Component
public class PerformanceOptimizedScheduler {
private final MeterRegistry meterRegistry;
private final Timer executionTimer;
private final Counter executionCounter;
@Scheduled(fixedRate = 60000)
public void optimizedTask() {
Timer.Sample sample = Timer.start(meterRegistry);
try {
// 배치 크기 동적 조정
int batchSize = calculateOptimalBatchSize();
// 병렬 처리
processDataInParallel(batchSize);
executionCounter.increment(Tags.of("status", "success"));
} catch (Exception e) {
executionCounter.increment(Tags.of("status", "failure"));
throw e;
} finally {
sample.stop(executionTimer);
}
}
private int calculateOptimalBatchSize() {
// 시스템 리소스 기반 배치 크기 계산
double cpuUsage = getCpuUsage();
double memoryUsage = getMemoryUsage();
if (cpuUsage > 0.8 || memoryUsage > 0.8) {
return 50; // 부하가 높을 때는 작은 배치
} else {
return 200; // 여유가 있을 때는 큰 배치
}
}
}⚡ 최적화 기법
- • 적절한 인덱스 설계
- • 배치 INSERT/UPDATE 사용
- • 커넥션 풀 최적화
- • 쿼리 성능 모니터링
- • 스트림 처리 활용
- • 대용량 데이터 청크 처리
- • 가비지 컬렉션 튜닝
- • 메모리 누수 방지
트러블슈팅 가이드
🚨 일반적인 문제와 해결책
- • @EnableScheduling 어노테이션 확인
- • Cron 표현식 문법 검증
- • 스케줄러 스레드 풀 상태 확인
- • 애플리케이션 컨텍스트 로딩 확인
- • ShedLock 설정 확인
- • 데이터베이스 락 테이블 상태 점검
- • 인스턴스 식별자 중복 확인
- • 네트워크 분할 상황 고려
- • 스레드 풀 크기 조정
- • 배치 크기 최적화
- • 데이터베이스 쿼리 튜닝
- • 메모리 사용량 분석
🎯 핵심 요약
기본 원칙
- • 단순함을 유지하라
- • 예외 처리를 필수로 하라
- • 모니터링을 구축하라
- • 문서화를 철저히 하라
확장성 고려
- • 분산 환경 대비
- • 동적 스케줄링 지원
- • 리소스 효율적 사용
- • 우아한 확장 설계
운영 관점
- • 실시간 모니터링
- • 자동 알림 시스템
- • 성능 지표 추적
- • 정기적인 리뷰
Spring 스케줄링은 단순한 정기 작업부터 복잡한 분산 배치 처리까지 다양한 요구사항을 만족시킬 수 있는 강력한 도구입니다. 적절한 설계와 구현을 통해 안정적이고 확장 가능한 스케줄링 시스템을 구축할 수 있습니다.