[스프링/Spring] Quartz 스케줄러(2)

dongbrown·2025년 7월 15일

Spring

목록 보기
21/23

Quartz 스케줄러 관리 시스템 구축하기 - 2편: 구현과 운영

들어가며

1편에서 Quartz 스케줄러의 개념과 전체적인 아키텍처를 살펴보았습니다. 이번 편에서는 실제 ManageController 코드를 중심으로 각 기능의 상세한 구현 방법과 운영 환경에서 고려해야 할 사항들을 깊이 있게 다뤄보겠습니다.

코드 구조 분석

클래스 구성과 의존성

@Slf4j
@RestController
@RequestMapping(value = "/api/v1/scheduler", produces = MediaType.APPLICATION_JSON_VALUE)
public class SchedulerManagementController {
    private final Scheduler quartzScheduler;
    private final SchedulerProperties schedulerProps;
    private final SchedulerStateService stateService;
    private final AuditService auditService;
}

핵심 구성 요소

  1. Scheduler: Quartz의 핵심 인터페이스로 모든 스케줄링 작업을 관리
  2. 설정 메시지들: 각 상태 변경 시 로깅 및 사용자 피드백용 메시지
  3. @Slf4j: Lombok을 통한 로깅 기능 자동 생성

생성자 기반 의존성 주입의 장점

public SchedulerManagementController(
        SchedulerFactoryBean schedulerFactory,
        SchedulerProperties schedulerProps,
        SchedulerStateService stateService,
        AuditService auditService) {
    this.quartzScheduler = schedulerFactory.getScheduler();
    this.schedulerProps = schedulerProps;
    this.stateService = stateService;
    this.auditService = auditService;
}
  • 불변성 보장: final 필드를 통한 객체 상태 보호
  • 명시적 의존성: 필요한 모든 의존성이 생성 시점에 명확히 드러남
  • 테스트 용이성: Mock 객체 주입이 간단

상태 조회 기능 구현

@GetMapping("/status")
@PreAuthorize("hasRole('SCHEDULER_VIEWER')")
public ResponseEntity<SchedulerStatusResponse> getSchedulerStatus()
        throws SchedulerException {

    SchedulerStatusResponse status = SchedulerStatusResponse.builder()
            .schedulerName(schedulerProps.getName())
            .isShutdown(quartzScheduler.isShutdown())
            .isStarted(quartzScheduler.isStarted())
            .isInStandbyMode(quartzScheduler.isInStandbyMode())
            .currentJobs(quartzScheduler.getCurrentlyExecutingJobs().size())
            .lastUpdateTime(LocalDateTime.now())
            .build();

    return ResponseEntity.ok(status);
}

@GetMapping("/dashboard")
@PreAuthorize("hasRole('SCHEDULER_ADMIN')")
public String showDashboard(Model model) throws SchedulerException {
    model.addAttribute("schedulerStatus", stateService.getCurrentStatus());
    model.addAttribute("recentActions", auditService.getRecentActions(10));
    return "scheduler/dashboard";
}

상태 조회의 중요성

운영 환경에서 스케줄러의 현재 상태를 정확히 파악하는 것은 매우 중요합니다:

  • isShutdown(): 스케줄러가 완전히 종료된 상태인지 확인
  • isStarted(): 스케줄러가 한 번이라도 시작된 적이 있는지 확인
  • isInStandbyMode(): 현재 대기 모드인지 확인

상태 조합의 의미

isShutdownisStartedisInStandbyMode실제 상태
falsefalsetrue초기화됨, 아직 시작 안됨
falsetruetrue시작되었다가 대기 상태
falsetruefalse정상 실행 중
truetruefalse종료됨

스케줄러 시작 기능

@PostMapping("/actions/start")
@PreAuthorize("hasRole('SCHEDULER_ADMIN')")
@ValidateSchedulerState
public ResponseEntity<ApiResponse> startScheduler(
        @RequestBody SchedulerActionRequest request,
        Authentication authentication) throws SchedulerException {

    String currentUser = authentication.getName();

    // 상태 검증
    if (!quartzScheduler.isInStandbyMode()) {
        log.warn("Start operation rejected - Scheduler not in standby mode. User: {}", currentUser);
        return ResponseEntity.badRequest()
                .body(ApiResponse.error("Scheduler is not in standby mode"));
    }

    try {
        // 스케줄러 시작
        quartzScheduler.start();

        // 감사 로그 기록
        auditService.recordAction(AuditAction.builder()
                .actionType(ActionType.SCHEDULER_START)
                .performedBy(currentUser)
                .schedulerName(schedulerProps.getName())
                .timestamp(LocalDateTime.now())
                .reason(request.getReason())
                .build());

        // 성공 로그
        log.info("Scheduler started successfully by user: {} | Scheduler: {} | Reason: {}",
                currentUser, schedulerProps.getName(), request.getReason());

        return ResponseEntity.ok(ApiResponse.success(
                String.format("Scheduler '%s' started successfully", schedulerProps.getName())
        ));

    } catch (Exception e) {
        log.error("Failed to start scheduler. User: {}, Error: {}", currentUser, e.getMessage(), e);
        return ResponseEntity.internalServerError()
                .body(ApiResponse.error("Failed to start scheduler: " + e.getMessage()));
    }
}

구현의 핵심 포인트

1. 상태 검증 로직

  • 대기 모드일 때만 시작 허용
  • 이미 실행 중인 경우 중복 시작 방지
  • 명확한 실패 사유 제공

2. 로깅 전략

log.info("\n### ======================= "+ schedulerName +" =======================" + this.startMsg);
  • 시각적으로 구분되는 로그 포맷
  • 스케줄러명과 커스텀 메시지 포함
  • 운영 모니터링 시 쉬운 식별 가능

3. 응답 객체 설계

QuartzMsg.Builder 패턴을 사용하여:

  • 일관된 응답 형식 보장
  • 확장 가능한 메시지 구조
  • 클라이언트 친화적인 피드백

대기 모드 전환 기능

@PostMapping("/actions/standby")
@PreAuthorize("hasRole('SCHEDULER_ADMIN')")
@ValidateSchedulerState
public ResponseEntity<ApiResponse> pauseScheduler(
        @RequestBody SchedulerActionRequest request,
        Authentication authentication) throws SchedulerException {

    String currentUser = authentication.getName();

    // 현재 실행 중인 작업 확인
    List<JobExecutionContext> runningJobs = quartzScheduler.getCurrentlyExecutingJobs();

    if (quartzScheduler.isInStandbyMode()) {
        return ResponseEntity.badRequest()
                .body(ApiResponse.error("Scheduler is already in standby mode"));
    }

    try {
        // 실행 중인 작업이 있을 경우 경고 로그
        if (!runningJobs.isEmpty()) {
            log.warn("Putting scheduler into standby mode with {} running jobs. User: {}",
                    runningJobs.size(), currentUser);
        }

        quartzScheduler.standby();

        // 감사 로그
        auditService.recordAction(AuditAction.builder()
                .actionType(ActionType.SCHEDULER_STANDBY)
                .performedBy(currentUser)
                .schedulerName(schedulerProps.getName())
                .timestamp(LocalDateTime.now())
                .reason(request.getReason())
                .affectedJobs(runningJobs.size())
                .build());

        log.info("Scheduler paused successfully by user: {} | Running jobs: {} | Reason: {}",
                currentUser, runningJobs.size(), request.getReason());

        return ResponseEntity.ok(ApiResponse.success(
                String.format("Scheduler '%s' is now in standby mode", schedulerProps.getName())
        ));

    } catch (Exception e) {
        log.error("Failed to pause scheduler. User: {}, Error: {}", currentUser, e.getMessage(), e);
        return ResponseEntity.internalServerError()
                .body(ApiResponse.error("Failed to pause scheduler: " + e.getMessage()));
    }
}

대기 모드의 운영상 가치

점검 모드

  • 시스템 점검 시 작업 실행 일시 중단
  • 스케줄러 설정 변경 시 안전한 환경 제공
  • 데이터베이스 마이그레이션 등 작업 시 활용

트래픽 제어

  • 서버 부하가 높을 때 스케줄링 작업 임시 중단
  • 피크 시간대 리소스 확보
  • 우아한(Graceful) 서비스 제어

종료 기능 구현

@PostMapping("/actions/shutdown")
@PreAuthorize("hasRole('SCHEDULER_ADMIN')")
@ValidateSchedulerState
public ResponseEntity<ApiResponse> shutdownScheduler(
        @RequestBody @Valid SchedulerShutdownRequest request,
        Authentication authentication) throws SchedulerException {

    String currentUser = authentication.getName();

    if (!quartzScheduler.isStarted()) {
        return ResponseEntity.badRequest()
                .body(ApiResponse.error("Scheduler has not been started yet"));
    }

    if (quartzScheduler.isShutdown()) {
        return ResponseEntity.badRequest()
                .body(ApiResponse.error("Scheduler is already shutdown"));
    }

    try {
        List<JobExecutionContext> runningJobs = quartzScheduler.getCurrentlyExecutingJobs();
        boolean waitForCompletion = request.isWaitForJobsToComplete();

        log.info("Initiating scheduler shutdown. User: {} | Wait for completion: {} | Running jobs: {}",
                currentUser, waitForCompletion, runningJobs.size());

        // 우아한 종료 또는 강제 종료
        quartzScheduler.shutdown(waitForCompletion);

        // 감사 로그
        auditService.recordAction(AuditAction.builder()
                .actionType(ActionType.SCHEDULER_SHUTDOWN)
                .performedBy(currentUser)
                .schedulerName(schedulerProps.getName())
                .timestamp(LocalDateTime.now())
                .reason(request.getReason())
                .waitForCompletion(waitForCompletion)
                .affectedJobs(runningJobs.size())
                .build());

        String message = String.format("Scheduler '%s' shutdown %s",
                schedulerProps.getName(),
                waitForCompletion ? "gracefully" : "immediately");

        log.info("Scheduler shutdown completed successfully. User: {} | Message: {}",
                currentUser, message);

        return ResponseEntity.ok(ApiResponse.success(message));

    } catch (Exception e) {
        log.error("Failed to shutdown scheduler. User: {}, Error: {}", currentUser, e.getMessage(), e);
        return ResponseEntity.internalServerError()
                .body(ApiResponse.error("Failed to shutdown scheduler: " + e.getMessage()));
    }
}

shutdown 파라미터의 의미

// 요청 객체에서 옵션을 받아 처리
boolean waitForCompletion = request.isWaitForJobsToComplete();
quartzScheduler.shutdown(waitForCompletion);
  • true: 현재 실행 중인 작업이 완료될 때까지 대기 후 종료
  • false: 즉시 종료 (실행 중인 작업 강제 중단)

우아한 종료 (Graceful Shutdown)

운영 환경에서는 true 파라미터 사용을 권장:

  • 데이터 무결성 보장
  • 진행 중인 작업의 완전성 확보
  • 예상치 못한 부작용 방지

@ValidateSchedulerState 어노테이션 활용

@ValidateSchedulerState
@PostMapping("/actions/start")

AOP를 통한 횡단 관심사 처리

이 커스텀 어노테이션은 다음과 같은 기능을 제공할 것으로 예상됩니다:

@Component
@Aspect
public class SchedulerValidationAspect {

    @Around("@annotation(ValidateSchedulerState)")
    public Object validateSchedulerState(ProceedingJoinPoint joinPoint) throws Throwable {

        SchedulerManagementController controller =
            (SchedulerManagementController) joinPoint.getTarget();
        Scheduler scheduler = controller.getQuartzScheduler();

        // 기본 유효성 검사
        if (scheduler == null) {
            return ResponseEntity.badRequest()
                .body(ApiResponse.error("Scheduler is not initialized"));
        }

        // 클러스터 환경에서의 추가 검증
        if (scheduler.getMetaData().isJobStoreClustered()) {
            if (!isClusterHealthy()) {
                return ResponseEntity.serviceUnavailable()
                    .body(ApiResponse.error("Cluster is not healthy"));
            }
        }

        return joinPoint.proceed();
    }

    private boolean isClusterHealthy() {
        // 클러스터 상태 확인 로직
        return true;
    }
}

AOP 활용의 장점

  • 코드 중복 제거: 모든 메서드에 동일한 검증 로직 반복 방지
  • 관심사 분리: 비즈니스 로직과 검증 로직의 명확한 분리
  • 유지보수성: 검증 규칙 변경 시 한 곳에서만 수정

예외 처리 전략

SchedulerException 처리

public ResponseEntity<ApiResponse> startScheduler(...) throws SchedulerException

모든 메서드에서 SchedulerException을 선언하고 있지만, 실제 운영에서는 더 세밀한 예외 처리가 필요합니다:

@RestControllerAdvice
public class SchedulerExceptionHandler {

    @ExceptionHandler(SchedulerException.class)
    public ResponseEntity<ApiResponse> handleSchedulerException(
            SchedulerException e, HttpServletRequest request) {

        log.error("Scheduler operation failed on {} {}: {}",
            request.getMethod(), request.getRequestURI(), e.getMessage(), e);

        return ResponseEntity.internalServerError()
                .body(ApiResponse.error("Scheduler operation failed", e.getMessage()));
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ApiResponse> handleAccessDenied(AccessDeniedException e) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(ApiResponse.error("Access denied", "Insufficient privileges"));
    }
}

예외 상황별 대응 전략

  1. 네트워크 오류: 데이터베이스 연결 실패 등
  2. 리소스 부족: 메모리, 스레드 풀 고갈
  3. 설정 오류: 잘못된 스케줄러 설정
  4. 동시성 문제: 클러스터 환경에서의 충돌

운영 모니터링 및 로깅

구조화된 로깅

현재 코드의 로그를 더 구조화하면:

@Component
public class SchedulerAuditLogger {

    public void logStateChange(String schedulerName, String fromState,
                              String toState, String user, String reason) {

        // MDC를 활용한 구조화된 로깅
        MDC.put("schedulerName", schedulerName);
        MDC.put("user", user);
        MDC.put("action", "state_change");

        try {
            log.info("Scheduler state changed: from={}, to={}, reason={}, timestamp={}",
                    fromState, toState, reason, Instant.now());
        } finally {
            MDC.clear();
        }
    }

    public void logJobsAffected(int runningJobs, int pendingJobs) {
        log.info("Jobs affected by scheduler state change: running={}, pending={}",
                runningJobs, pendingJobs);
    }
}

메트릭 수집

운영 환경에서는 다음 메트릭 수집이 중요합니다:

@Component
public class SchedulerMetricsCollector {

    private final MeterRegistry meterRegistry;
    private final Counter stateChangeCounter;
    private final Timer responseTimeTimer;

    public SchedulerMetricsCollector(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.stateChangeCounter = Counter.builder("scheduler.state.changes")
                .description("Number of scheduler state changes")
                .register(meterRegistry);
        this.responseTimeTimer = Timer.builder("scheduler.api.response.time")
                .description("API response time")
                .register(meterRegistry);
    }

    public void recordStateChange(String fromState, String toState) {
        stateChangeCounter.increment(Tags.of(
                "from", fromState,
                "to", toState
        ));
    }

    @EventListener
    public void handleSchedulerEvent(SchedulerStateChangedEvent event) {
        Gauge.builder("scheduler.running.jobs")
                .description("Number of currently running jobs")
                .register(meterRegistry, event, e -> e.getRunningJobsCount());
    }
}

알람 설정

중요한 상태 변경이나 오류 발생 시 알람:

@Component
public class SchedulerAlertService {

    private final NotificationService notificationService;

    @EventListener
    @Async
    public void handleSchedulerStateChange(SchedulerStateChangedEvent event) {
        if (event.getNewState() == SchedulerState.SHUTDOWN) {
            AlertMessage alert = AlertMessage.builder()
                    .severity(AlertSeverity.HIGH)
                    .title("Scheduler Shutdown Detected")
                    .message(String.format("Scheduler '%s' has been shutdown by user '%s'",
                            event.getSchedulerName(), event.getPerformedBy()))
                    .timestamp(event.getTimestamp())
                    .build();

            notificationService.sendAlert(alert);
        }
    }

    @EventListener
    public void handleSchedulerError(SchedulerErrorEvent event) {
        if (event.isCritical()) {
            notificationService.sendUrgentAlert(
                    "Critical Scheduler Error: " + event.getErrorMessage()
            );
        }
    }
}

보안 고려사항

인증 및 권한 부여

스케줄러 관리는 민감한 작업이므로 적절한 보안 조치가 필요합니다:

@Configuration
@EnableMethodSecurity
public class SchedulerSecurityConfig {

    @Bean
    public MethodSecurityExpressionHandler methodSecurityExpressionHandler() {
        DefaultMethodSecurityExpressionHandler handler =
                new DefaultMethodSecurityExpressionHandler();
        handler.setPermissionEvaluator(new SchedulerPermissionEvaluator());
        return handler;
    }
}

// 커스텀 권한 평가자
public class SchedulerPermissionEvaluator implements PermissionEvaluator {

    @Override
    public boolean hasPermission(Authentication auth, Object targetDomainObject,
                               Object permission) {

        if ("SCHEDULER_CRITICAL_ACTION".equals(permission)) {
            return auth.getAuthorities().stream()
                    .anyMatch(a -> "ROLE_SCHEDULER_ADMIN".equals(a.getAuthority())
                            || "ROLE_SYSTEM_ADMIN".equals(a.getAuthority()));
        }

        return false;
    }
}

// 사용 예시
@PreAuthorize("hasPermission(null, 'SCHEDULER_CRITICAL_ACTION')")
@PostMapping("/actions/shutdown")
public ResponseEntity<ApiResponse> shutdownScheduler(...) {
    // ...
}

감사 로깅

모든 상태 변경 작업에 대한 감사 로그:

@Component
public class SchedulerAuditService {

    private final AuditRepository auditRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Async
    public void recordAction(AuditAction action) {
        try {
            // 데이터베이스에 감사 로그 저장
            AuditRecord record = AuditRecord.builder()
                    .actionType(action.getActionType())
                    .performedBy(action.getPerformedBy())
                    .schedulerName(action.getSchedulerName())
                    .timestamp(action.getTimestamp())
                    .reason(action.getReason())
                    .ipAddress(action.getIpAddress())
                    .userAgent(action.getUserAgent())
                    .success(action.isSuccess())
                    .build();

            auditRepository.save(record);

            // 이벤트 발행 (알림 등을 위해)
            eventPublisher.publishEvent(new AuditActionRecordedEvent(action));

        } catch (Exception e) {
            log.error("Failed to record audit action: {}", action, e);
        }
    }

    public Page<AuditRecord> getActionHistory(String schedulerName,
                                            LocalDateTime from,
                                            LocalDateTime to,
                                            Pageable pageable) {
        return auditRepository.findBySchedulerNameAndTimestampBetween(
                schedulerName, from, to, pageable);
    }
}

// Controller에서의 사용
@PostMapping("/actions/start")
public ResponseEntity<ApiResponse> startScheduler(
        @RequestBody SchedulerActionRequest request,
        Authentication authentication,
        HttpServletRequest httpRequest) {

    String currentUser = authentication.getName();
    String ipAddress = getClientIpAddress(httpRequest);
    String userAgent = httpRequest.getHeader("User-Agent");

    try {
        // 스케줄러 작업 수행
        quartzScheduler.start();

        // 감사 로그 기록
        auditService.recordAction(AuditAction.builder()
                .actionType(ActionType.SCHEDULER_START)
                .performedBy(currentUser)
                .schedulerName(schedulerProps.getName())
                .timestamp(LocalDateTime.now())
                .reason(request.getReason())
                .ipAddress(ipAddress)
                .userAgent(userAgent)
                .success(true)
                .build());

        return ResponseEntity.ok(ApiResponse.success("Scheduler started"));

    } catch (Exception e) {
        // 실패 시에도 감사 로그 기록
        auditService.recordAction(AuditAction.builder()
                .actionType(ActionType.SCHEDULER_START)
                .performedBy(currentUser)
                .schedulerName(schedulerProps.getName())
                .timestamp(LocalDateTime.now())
                .reason(request.getReason())
                .ipAddress(ipAddress)
                .userAgent(userAgent)
                .success(false)
                .errorMessage(e.getMessage())
                .build());

        throw e;
    }
}

@PostMapping("/start")
public ResponseEntity start(HttpServletRequest request) {
auditService.logAction(
"SCHEDULER_START",
getCurrentUser(),
request.getRemoteAddr(),
schedulerName
);
// ... 기존 로직
}


## 성능 최적화

### 비동기 처리

대용량 작업이 있는 스케줄러의 경우 상태 변경을 비동기로 처리:

```java
@Service
public class AsyncSchedulerService {

    @Async("schedulerTaskExecutor")
    public CompletableFuture<SchedulerOperationResult> startSchedulerAsync(
            String reason, String performedBy) {

        return CompletableFuture.supplyAsync(() -> {
            try {
                // 사전 검증
                if (!quartzScheduler.isInStandbyMode()) {
                    throw new IllegalStateException("Scheduler is not in standby mode");
                }

                // 스케줄러 시작
                quartzScheduler.start();

                // 후처리 작업들
                postStartupTasks();

                return SchedulerOperationResult.success("Scheduler started successfully");

            } catch (Exception e) {
                log.error("Async scheduler start failed", e);
                return SchedulerOperationResult.failure(e.getMessage());
            }
        });
    }

    private void postStartupTasks() {
        // 헬스체크 스케줄링
        scheduleHealthCheck();

        // 메트릭 초기화
        initializeMetrics();

        // 알림 발송
        notifySchedulerStarted();
    }
}

// 비동기 설정
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "schedulerTaskExecutor")
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("scheduler-async-");
        executor.setRejectedExecutionHandler(new CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

캐싱 전략

자주 조회되는 스케줄러 상태 정보 캐싱:

@Service
public class CachedSchedulerStatusService {

    private final RedisTemplate<String, SchedulerStatusCache> redisTemplate;
    private static final String CACHE_KEY_PREFIX = "scheduler:status:";

    @Cacheable(value = "schedulerStatus", key = "#schedulerName")
    public SchedulerStatusResponse getSchedulerStatus(String schedulerName)
            throws SchedulerException {

        return SchedulerStatusResponse.builder()
                .schedulerName(schedulerName)
                .isShutdown(quartzScheduler.isShutdown())
                .isStarted(quartzScheduler.isStarted())
                .isInStandbyMode(quartzScheduler.isInStandbyMode())
                .currentJobs(quartzScheduler.getCurrentlyExecutingJobs().size())
                .lastUpdateTime(LocalDateTime.now())
                .build();
    }

    @CacheEvict(value = "schedulerStatus", key = "#schedulerName")
    public void invalidateStatusCache(String schedulerName) {
        log.debug("Scheduler status cache invalidated for: {}", schedulerName);
    }

    // Redis를 이용한 분산 캐시
    public void cacheSchedulerMetrics(String schedulerName, SchedulerMetrics metrics) {
        String cacheKey = CACHE_KEY_PREFIX + schedulerName + ":metrics";

        SchedulerMetricsCache cache = SchedulerMetricsCache.builder()
                .totalJobs(metrics.getTotalJobs())
                .runningJobs(metrics.getRunningJobs())
                .completedJobs(metrics.getCompletedJobs())
                .failedJobs(metrics.getFailedJobs())
                .lastUpdated(LocalDateTime.now())
                .build();

        redisTemplate.opsForValue().set(cacheKey, cache, Duration.ofMinutes(5));
    }
}

// 캐시 설정
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        RedisCacheManager.Builder builder = RedisCacheManager
                .RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory())
                .cacheDefaults(cacheConfiguration());

        return builder.build();
    }

    private RedisCacheConfiguration cacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }
}

테스트 전략

단위 테스트

@ExtendWith(MockitoExtension.class)
class SchedulerManagementControllerTest {

    @Mock
    private Scheduler mockScheduler;

    @Mock
    private SchedulerProperties mockProps;

    @Mock
    private SchedulerStateService mockStateService;

    @Mock
    private AuditService mockAuditService;

    @Mock
    private Authentication mockAuthentication;

    @InjectMocks
    private SchedulerManagementController controller;

    @Test
    @DisplayName("스케줄러가 대기 모드일 때 시작이 성공해야 한다")
    void shouldStartSchedulerWhenInStandbyMode() throws SchedulerException {
        // Given
        when(mockScheduler.isInStandbyMode()).thenReturn(true);
        when(mockAuthentication.getName()).thenReturn("testUser");
        when(mockProps.getName()).thenReturn("testScheduler");

        SchedulerActionRequest request = new SchedulerActionRequest("System maintenance");

        // When
        ResponseEntity<ApiResponse> response = controller.startScheduler(request, mockAuthentication);

        // Then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody().isSuccess()).isTrue();
        assertThat(response.getBody().getMessage()).contains("started successfully");

        verify(mockScheduler).start();
        verify(mockAuditService).recordAction(any(AuditAction.class));
    }

    @Test
    @DisplayName("이미 시작된 스케줄러는 시작 요청을 거부해야 한다")
    void shouldRejectStartWhenAlreadyRunning() throws SchedulerException {
        // Given
        when(mockScheduler.isInStandbyMode()).thenReturn(false);
        when(mockAuthentication.getName()).thenReturn("testUser");

        SchedulerActionRequest request = new SchedulerActionRequest("Test start");

        // When
        ResponseEntity<ApiResponse> response = controller.startScheduler(request, mockAuthentication);

        // Then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
        assertThat(response.getBody().isSuccess()).isFalse();

        verify(mockScheduler, never()).start();
    }

    @Test
    @DisplayName("스케줄러 예외 발생 시 적절한 에러 응답을 반환해야 한다")
    void shouldHandleSchedulerException() throws SchedulerException {
        // Given
        when(mockScheduler.isInStandbyMode()).thenReturn(true);
        when(mockAuthentication.getName()).thenReturn("testUser");
        doThrow(new SchedulerException("Database connection failed"))
                .when(mockScheduler).start();

        SchedulerActionRequest request = new SchedulerActionRequest("Test exception");

        // When
        ResponseEntity<ApiResponse> response = controller.startScheduler(request, mockAuthentication);

        // Then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
        assertThat(response.getBody().isSuccess()).isFalse();
        assertThat(response.getBody().getMessage()).contains("Failed to start scheduler");
    }
}

통합 테스트

@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestMethodOrder(OrderAnnotation.class)
class SchedulerManagementIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private Scheduler scheduler;

    @Autowired
    private AuditRepository auditRepository;

    @MockBean
    private NotificationService notificationService;

    @Test
    @Order(1)
    @WithMockUser(username = "admin", roles = {"SCHEDULER_ADMIN"})
    @DisplayName("전체 스케줄러 생명주기 테스트")
    void shouldManageCompleteSchedulerLifecycle() throws Exception {
        // 초기 상태 확인
        ResponseEntity<SchedulerStatusResponse> statusResponse =
                restTemplate.exchange("/api/v1/scheduler/status",
                        HttpMethod.GET, null, SchedulerStatusResponse.class);

        assertThat(statusResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(statusResponse.getBody().isInStandbyMode()).isTrue();

        // 1. 스케줄러 시작
        SchedulerActionRequest startRequest = new SchedulerActionRequest("Integration test start");
        ResponseEntity<ApiResponse> startResponse =
                restTemplate.postForEntity("/api/v1/scheduler/actions/start",
                        startRequest, ApiResponse.class);

        assertThat(startResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(startResponse.getBody().isSuccess()).isTrue();

        // 상태 변경 확인
        await().atMost(5, SECONDS).untilAsserted(() -> {
            assertThat(scheduler.isStarted()).isTrue();
            assertThat(scheduler.isInStandbyMode()).isFalse();
        });

        // 2. 대기 모드로 전환
        SchedulerActionRequest standbyRequest = new SchedulerActionRequest("Test standby");
        ResponseEntity<ApiResponse> standbyResponse =
                restTemplate.postForEntity("/api/v1/scheduler/actions/standby",
                        standbyRequest, ApiResponse.class);

        assertThat(standbyResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        await().atMost(5, SECONDS).untilAsserted(() -> {
            assertThat(scheduler.isInStandbyMode()).isTrue();
        });

        // 3. 다시 시작
        ResponseEntity<ApiResponse> restartResponse =
                restTemplate.postForEntity("/api/v1/scheduler/actions/start",
                        startRequest, ApiResponse.class);

        assertThat(restartResponse.getStatusCode()).isEqualTo(HttpStatus.OK);

        // 4. 종료
        SchedulerShutdownRequest shutdownRequest = SchedulerShutdownRequest.builder()
                .reason("Integration test complete")
                .waitForJobsToComplete(true)
                .build();

        ResponseEntity<ApiResponse> shutdownResponse =
                restTemplate.postForEntity("/api/v1/scheduler/actions/shutdown",
                        shutdownRequest, ApiResponse.class);

        assertThat(shutdownResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        await().atMost(10, SECONDS).untilAsserted(() -> {
            assertThat(scheduler.isShutdown()).isTrue();
        });

        // 감사 로그 검증
        List<AuditRecord> auditRecords = auditRepository.findAll();
        assertThat(auditRecords).hasSize(4); // start, standby, restart, shutdown

        long startActions = auditRecords.stream()
                .filter(r -> r.getActionType() == ActionType.SCHEDULER_START)
                .count();
        assertThat(startActions).isEqualTo(2);
    }

    @Test
    @WithMockUser(username = "user", roles = {"SCHEDULER_VIEWER"})
    @DisplayName("권한이 없는 사용자의 제어 요청은 거부되어야 한다")
    void shouldDenyAccessForUnauthorizedUser() {
        SchedulerActionRequest request = new SchedulerActionRequest("Unauthorized test");

        ResponseEntity<ApiResponse> response =
                restTemplate.postForEntity("/api/v1/scheduler/actions/start",
                        request, ApiResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
    }

    @Test
    @DisplayName("동시성 테스트 - 여러 사용자가 동시에 상태 변경 요청")
    @WithMockUser(username = "admin1", roles = {"SCHEDULER_ADMIN"})
    void shouldHandleConcurrentStateChanges() throws InterruptedException {
        int numberOfThreads = 5;
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        List<ResponseEntity<ApiResponse>> responses =
                Collections.synchronizedList(new ArrayList<>());

        // 동시에 스케줄러 시작 요청
        for (int i = 0; i < numberOfThreads; i++) {
            executorService.execute(() -> {
                try {
                    SchedulerActionRequest request =
                            new SchedulerActionRequest("Concurrent test");
                    ResponseEntity<ApiResponse> response =
                            restTemplate.postForEntity("/api/v1/scheduler/actions/start",
                                    request, ApiResponse.class);
                    responses.add(response);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await(30, TimeUnit.SECONDS);

        // 하나만 성공하고 나머지는 실패해야 함
        long successCount = responses.stream()
                .filter(r -> r.getStatusCode() == HttpStatus.OK)
                .filter(r -> r.getBody().isSuccess())
                .count();

        assertThat(successCount).isEqualTo(1);
        assertThat(responses).hasSize(numberOfThreads);
    }
}

마무리

Quartz 스케줄러 관리 시스템의 구현을 통해 다음과 같은 핵심 가치를 얻을 수 있습니다:

운영 안정성

  • 명확한 상태 관리와 전환 로직
  • 예외 상황에 대한 적절한 처리
  • 구조화된 로깅과 모니터링

사용자 경험

  • 직관적인 REST API 인터페이스
  • 명확한 에러 메시지와 상태 피드백
  • 실시간 상태 조회 기능

유지보수성

  • 관심사의 명확한 분리
  • 확장 가능한 아키텍처
  • 테스트 가능한 코드 구조

확장 포인트

향후 다음과 같은 기능 확장을 고려할 수 있습니다:

  • 작업별 세부 관리: 개별 Job의 일시정지/재개
  • 클러스터 관리: 다중 인스턴스 환경에서의 통합 관리
  • 히스토리 관리: 상태 변경 이력과 통계
  • 알림 시스템: Slack, 이메일 등을 통한 상태 알림

실제 운영 환경에서는 이러한 요소들을 종합적으로 고려하여 안정적이고 효율적인 스케줄러 관리 시스템을 구축할 수 있습니다.


0개의 댓글