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;
}
public SchedulerManagementController(
SchedulerFactoryBean schedulerFactory,
SchedulerProperties schedulerProps,
SchedulerStateService stateService,
AuditService auditService) {
this.quartzScheduler = schedulerFactory.getScheduler();
this.schedulerProps = schedulerProps;
this.stateService = stateService;
this.auditService = auditService;
}
@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 | 실제 상태 |
|---|---|---|---|
| false | false | true | 초기화됨, 아직 시작 안됨 |
| false | true | true | 시작되었다가 대기 상태 |
| false | true | false | 정상 실행 중 |
| true | true | false | 종료됨 |
@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()));
}
}
log.info("\n### ======================= "+ schedulerName +" =======================" + this.startMsg);
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()));
}
}
@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()));
}
}
// 요청 객체에서 옵션을 받아 처리
boolean waitForCompletion = request.isWaitForJobsToComplete();
quartzScheduler.shutdown(waitForCompletion);
운영 환경에서는 true 파라미터 사용을 권장:
@ValidateSchedulerState
@PostMapping("/actions/start")
이 커스텀 어노테이션은 다음과 같은 기능을 제공할 것으로 예상됩니다:
@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;
}
}
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"));
}
}
현재 코드의 로그를 더 구조화하면:
@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 스케줄러 관리 시스템의 구현을 통해 다음과 같은 핵심 가치를 얻을 수 있습니다:
향후 다음과 같은 기능 확장을 고려할 수 있습니다:
실제 운영 환경에서는 이러한 요소들을 종합적으로 고려하여 안정적이고 효율적인 스케줄러 관리 시스템을 구축할 수 있습니다.