본문 바로가기
공부/프로그래밍

Spring scheduling 을 DB 기반으로 동적 스케줄링 하기

by demonic_ 2025. 3. 21.
반응형

1. 들어가며

스프링 프레임워크에서는 @Scheduled 어노테이션을 사용하여 정적인 스케줄링을 쉽게 구현할 수 있습니다. 하지만 이 방식은 코드에 하드코딩되어 있어 변경 시 재배포가 필요하다는 단점이 있습니다.

 

@Component
public class StaticScheduler {
    @Scheduled(cron = "0 0 12 * * ?") // 매일 정오에 실행
    public void dailyTask() {
        // 작업 내용
    }
}
 

대부분의 경우 이런 정적 스케줄링으로도 충분하지만, 다음과 같은 상황에서는 동적 스케줄링이 필요합니다:

  • 마케팅 캠페인 알림 시간을 유연하게 변경해야 할 때
  • 데이터 집계 작업의 주기를 운영 중에 조정해야 할 때
  • 환경별(개발, 테스트, 운영)로 다른 스케줄을 적용해야 할 때
  • 비개발자가 직접 스케줄을 관리해야 할 때

 

이 글에서는 데이터베이스 기반으로 동적 스케줄링을 구현하는 방법을 살펴보겠습니다.

 

 

 

DB 테이블 설계 및 엔터티 구성

먼저 스케줄 정보를 저장할 테이블을 설계합니다.

CREATE TABLE cron_task (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    task_name VARCHAR(100) NOT NULL UNIQUE,
    cron_expression VARCHAR(50) NOT NULL,
    description VARCHAR(255),
    enabled BOOLEAN DEFAULT TRUE,
    created_date TIMESTAMP,
    updated_date TIMESTAMP
);
 

 

다음으로 JPA 엔티티와 Repository를 만듭니다.

@Entity
@Table(name = "cron_task")
public class CronTask {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "task_name", nullable = false, unique = true)
    private String taskName;
    
    @Column(name = "cron_expression", nullable = false)
    private String cronExpression;
    
    @Column(name = "description")
    private String description;
    
    @Column(name = "enabled")
    private boolean enabled = true;
    
    // getter, setter 생략
}

@Repository
public interface CronTaskRepository extends JpaRepository<CronTask, Long> {
    Optional<CronTask> findByTaskName(String taskName);
    List<CronTask> findByEnabled(boolean enabled);
}
 

 

동적 스케줄링 구현

다음으로 핵심인 DynamicScheduleManager 클래스를 구현합니다:

@Slf4j
@Service
@RequiredArgsConstructor
public class DynamicScheduleManager {
  // 실행 객체
  private final List<ScheduleExecutor> executorList;
  private final TaskScheduler taskScheduler;
  // 스케줄 관리 Map
  private final Map<String, ScheduledFuture<?>> scheduledTasks = new HashMap<>();
  // 에러 메세지 발생 시 슬랙으로 알림
  private final SendSlackErrorManager sendSlackErrorManager;

  // 스케줄 초기화 및 등록
  public void configureTasks(List<CronTask> taskList) {
    for (CronTask cronTask : taskList) {
      try {
        addTask(cronTask);
        log.info(
            "스케줄이 등록되었습니다. ID={}, cronExp={}", cronTask.getTaskName(), cronTask.getCronExpression());
      } catch (Exception e) {
        log.error("스케줄 등록 중 오류 발생. ID={}", cronTask.getTaskName(), e);
      }
    }
  }

  // 스케줄 갱신
  public void refreshTask(CronTask cronTask) {
    addTask(cronTask);
  }

  // 개별 스케줄 등록
  private void addTask(CronTask cronTask) {
    boolean result = isValidCronByRegex(cronTask);
    if (result == false) {
      return;
    }

    // 등록된게 있는지 찾고 있으면 중단
    stopSchedule(cronTask.getTaskName());

    // CronTrigger 생성하여 등록
    Runnable runnable = getRunnableForTaskName(cronTask.getTaskName());
    ScheduledFuture<?> future =
        taskScheduler.schedule(runnable, new CronTrigger(cronTask.getCronExpression()));
    scheduledTasks.put(cronTask.getTaskName(), future);

    log.debug(
        "새로운 작업이 등록되었습니다. ID={}, cronExp={}", cronTask.getTaskName(), cronTask.getCronExpression());
  }

  // 등록된 스케줄 정지 및 삭제
  public void stopSchedule(String taskName) {
    ScheduledFuture<?> future = scheduledTasks.remove(taskName);
    if (future != null) {
      future.cancel(true);
    }
  }

  // Cron 표현식 유효성 검사
  public boolean isValidCronByRegex(CronTask cronTask) {
    try {
      new CronSequenceGenerator(cronTask.getCronExpression());
      return true;
    } catch (Exception e) {
      String message =
          String.format(
              "스케줄 설정이 잘못 되어있습니다. ID=%s, cronExp=%s",
              cronTask.getTaskName(), cronTask.getCronExpression());
      log.error(message);
      sendSlackErrorManager.sendMessageBySystem(message);
      return false;
    }
  }

  // 작업 식별자에 맞는 Runnable 가져오기
  private Runnable getRunnableForTaskName(String taskName) {
    return executorList.stream()
        .filter(o -> o.support(taskName))
        .findFirst()
        .orElseThrow(
            () -> new InvalidDataException("스케줄 ID에 해당하는 수행부가 없어 에러가 발생했습니다. ID=" + taskName))
        .getRunnable();
  }
}
 

 

4. 스케줄러 모니터링 구현

스케줄러의 상태를 주기적으로 확인하고 모니터링하는 기능도 중요합니다

// 현재 등록되어있는 스케줄 확인
@Scheduled(fixedRate = 10000, initialDelay = 1000) // 10초에 한번 (주석 오류 수정)
public void logActiveSchedules() {
  log.info("[Active Schedule] 스케줄러 상태 확인");

  // ThreadPoolTaskScheduler의 내부 상태 확인
  if (taskScheduler instanceof ThreadPoolTaskScheduler) {
    ThreadPoolTaskScheduler threadPoolScheduler = (ThreadPoolTaskScheduler) taskScheduler;
    ScheduledExecutorService executor = threadPoolScheduler.getScheduledExecutor();
    if (executor instanceof ScheduledThreadPoolExecutor) {
      ScheduledThreadPoolExecutor scheduledExecutor = (ScheduledThreadPoolExecutor) executor;
      log.info("[Active Schedule] 활성 스레드 수: {}", scheduledExecutor.getActiveCount());
      log.info("[Active Schedule] 풀 크기: {}", scheduledExecutor.getPoolSize());
      log.info(
          "[Active Schedule] 큐 대기 작업 수: {}",
          scheduledExecutor.getQueue().size());
    }
  }

  // scheduledTasks와 비교
  log.info("[Active Schedule] 관리중인 작업 수: {}", scheduledTasks.size());

  // 각 작업의 실제 실행 상태 확인
  for (String taskName : scheduledTasks.keySet()) {
    ScheduledFuture<?> future = scheduledTasks.get(taskName);
    boolean isActiveInScheduler = !future.isCancelled() && !future.isDone();
    long delay = -1;
    try {
      delay = future.getDelay(TimeUnit.MILLISECONDS);
    } catch (Exception e) {
      // getDelay 실패는 작업이 실제로 스케줄러에서 실행되지 않고 있을 수 있음을 의미
      isActiveInScheduler = false;
    }

    String nextExecutionTime = "Unknown";
    if (delay > 0) {
      LocalDateTime nextExecution = LocalDateTime.now().plusNanos(delay * 1_000_000); // 밀리초를 나노초로 변환
      nextExecutionTime = nextExecution.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    } else {
      nextExecutionTime = "곧 실행";
    }

    log.info(
        "[Active Schedule] ID: {}, 실행중: {}, 다음 실행: {}",
        taskName,
        isActiveInScheduler,
        nextExecutionTime);

    // 불일치 감지
    if (!isActiveInScheduler) {
      log.warn("스케줄러에서 실행되지 않는 작업 발견: {}", taskName);
      // 필요하다면 여기서 재등록 로직 수행
    }
  }

  log.info("[Active Schedule] End ===");
}

 

 

 

5. TaskScheduler 및 ScheduleExecutor 설정

DynamicScheduleManager에서 이미 TaskScheduler를 빈으로 정의했지만, 별도로 설정할 수도 있습니다:

@Configuration
public class SchedulerConfig {
  @Bean
  public TaskScheduler taskScheduler() {
    ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
    scheduler.setPoolSize(10); // 동시에 실행할 수 있는 작업 수
    scheduler.setThreadNamePrefix("dynamic-scheduler-");
    scheduler.initialize();
    return scheduler;
  }
}

 

 

 

스케줄링할 작업을 추가하는건 두 가지 방법이 있습니다:

1. Strategy 패턴을 활용한 ScheduleExecutor 인터페이스

public interface ScheduleExecutor {
    boolean support(String taskName);
    Runnable getRunnable();
}

@Component
@RequiredArgsConstructor
public class DailyReportExecutor implements ScheduleExecutor {
    private final ReportService reportService;
    private final Runnable runnable = createRunnable(); // 한번만 생성
    

    // runnable의 hash를 동일하게 구성하기 위함
    protected Runnable createRunnable() {
        return () -> {
            try {
                reportService.generateDailyReport();
            } catch (Exception e) {
                // 예외 처리
            }
        };
    }

    @Override
    public boolean support(String taskName) {
        return "dailyReport".equals(taskName);
    }
    
    @Override
    public Runnable getRunnable() {
        return runnable;
    }
}

 

2. switch문을 사용한 방법

private Runnable determineRunnableTask(String taskName) {
    switch (taskName) {
        case "dailyReport":
            return taskService::generateDailyReport;
        case "userNotification":
            return taskService::sendUserNotifications;
        case "dataCleanup":
            return taskService::cleanupOldData;
        default:
            return null;
    }
}

 

첫 번째 방법은 확장성이 좋고, 두 번째 방법은 간단하게 구현할 수 있다는 장점이 있습니다. 프로젝트의 복잡도와 요구사항에 따라 적절한 방법을 선택하면 됩니다.

 

 

즉시 반영 기능 구현

스케줄 변경을 즉시 반영하기 위한 REST API를 구현합니다.

@RestController
@RequestMapping("/api/schedules")
public class ScheduleController {

    @Autowired
    private CronTaskRepository cronTaskRepository;
    
    @Autowired
    private DynamicScheduleManager manager;
    
    // 모든 스케줄 목록 조회
    @GetMapping
    public List<CronTask> getAllTasks() {
        return cronTaskRepository.findAll();
    }
    
    // 특정 스케줄 조회
    @GetMapping("/{taskName}")
    public ResponseEntity<CronTask> getTask(@PathVariable String taskName) {
        return cronTaskRepository.findByTaskName(taskName)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
    
    // 스케줄 업데이트
    @PutMapping("/{taskName}")
    public ResponseEntity<CronTask> updateTask(
            @PathVariable String taskName,
            @RequestBody CronTask updatedTask) {
        
        return cronTaskRepository.findByTaskName(taskName)
            .map(task -> {
                // 유효성 검사
                if (!isValidCronExpression(updatedTask.getCronExpression())) {
                    throw new IllegalArgumentException("유효하지 않은 Cron 표현식입니다.");
                }
                
                task.setCronExpression(updatedTask.getCronExpression());
                task.setDescription(updatedTask.getDescription());
                task.setEnabled(updatedTask.isEnabled());
                
                CronTask saved = cronTaskRepository.save(task);
                
                // 스케줄 즉시 갱신
                manager.refreshTask(taskName);
                
                return ResponseEntity.ok(saved);
            })
            .orElse(ResponseEntity.notFound().build());
    }
    
    // Cron 표현식 유효성 검사
    private boolean isValidCronExpression(String cronExpression) {
        try {
            new CronTrigger(cronExpression);
            return true;
        } catch (IllegalArgumentException e) {
            return false;
        }
    }
}
 
 

이러한 동적 스케줄링 시스템은 다음과 같은 상황에서 특히 유용합니다:

 

사례: 온라인 쇼핑몰의 프로모션 알림 시스템

대형 온라인 쇼핑몰 A사는 다양한 프로모션과 할인 행사를 진행합니다. 마케팅팀은 사용자 활동 패턴에 따라 알림 발송 시간을 최적화하고 싶어합니다.

기존에는 알림 발송 시간을 변경할 때마다 개발팀에 요청하여 코드 수정 후 재배포해야 했습니다.

이로 인해:

    1. 개발팀의 불필요한 업무 부담 증가
    2. 배포 과정에서 다른 기능에 영향을 줄 위험
    3. 변경 적용까지 시간 지연 발생

 

DB 기반 동적 스케줄링을 도입한 후:

    1. 마케팅팀이 직접 관리자 페이지에서 알림 발송 시간을 변경
    2. 즉시 반영되어 변경 효과를 바로 확인 가능
    3. A/B 테스트를 위한 빠른 시간 조정 가능
    4. 지역별, 사용자 그룹별 다른 일정 설정 용이

 

이를 통해 마케팅팀은 사용자 참여율을 15% 향상시키고, 개발팀은 월 평균 5회 이상의 불필요한 배포를 줄일 수 있었습니다.

 

 

장단점 및 결론

장점

    • 애플리케이션 재배포 없이 스케줄 변경 가능
    • 운영 담당자가 직접 관리 가능
    • 스케줄 히스토리 관리 및 모니터링 용이
    • 다양한 환경(개발, 테스트, 운영)별 설정 분리 가능

단점

    • 구현 복잡도 증가
    • DB 의존성 생성 (DB 장애 시 영향)
    • 상대적으로 많은 메모리 사용 (모든 작업 참조 보관)
    • 재시작 시 기존 실행 중이던 작업 상태 유지 어려움

 

 

 

참고

TaskScheduler 와 ScheduledTaskRegister 는 차이가 있습니다. 두번째것은 스프링이 초기에 등록한 Task를 관리하며 이후 등록은 가능하나 제거 & 교체가 어렵습니다. 그래서 내 경우도 처음에 Spring에서 사용하고 있는 ScheduledTaskRegister를 사용할가 했지만 어려움이 많아 TaskScheduler로 변경했습니다.

 

둘 차이는 아래 참고.

항목
TaskScheduler
ScheduledTaskRegistrar
역할
실제 실행자 (스케줄 등록/실행)
스프링 초기 등록용 Task 모음집
직접 사용 가능?
✅ 가능 (주로 동적 등록에서 사용)
❌ 직접 실행 안 됨 (Spring이 내부에서 호출)
사용 시점
런타임 언제든지
초기 설정 시만 (ex. configureTasks)
등록 방식
schedule(Runnable, Trigger)
addCronTask, addTriggerTask, etc
반환값
ScheduledFuture<?> → 제어 가능
없음 → 제어 불가능
제어 기능
✅ cancel(), 상태 확인 등 가능
❌ 직접 제어 불가능
사용 대상
커스텀 스케줄링, 동적 등록/제거
@EnableScheduling 기반 구성

 

 

마무리

동적 스케줄링은 빈번한 일정 변경이 필요하거나, 비개발자가 스케줄을 관리해야 하는 시스템에 특히 유용합니다. 구현 복잡도가 증가하지만, 장기적으로는 유지보수 비용을 크게 절감할 수 있습니다.

 

추가 고려사항으로는 장애 처리, 로깅, 모니터링을 위한 전략을 함께 구현하는 것이 좋습니다. 또한 실행 이력을 저장하여 작업 성공/실패 여부를 추적하는 기능도 유용합니다.

 

 

 

 

 

반응형

댓글