스케줄링 전략

개요

Spring Boot 환경에서 주기적인 작업(배치, 정산, 리포트 등)을 실행하기 위한 3가지 주요 전략


1. @Scheduled (Spring 기본)

개념

Spring Framework가 제공하는 가장 간단한 스케줄링 방식

  • 애플리케이션 내장
  • 어노테이션 기반
  • 코드만으로 스케줄 정의

구현

@Configuration
@EnableScheduling  // 필수
class SchedulingConfig

@Component
class ScheduledTasks {
    
    // 고정 딜레이: 이전 작업 종료 후 5초 대기
    @Scheduled(fixedDelay = 5000)
    fun fixedDelayTask() {
        println("5초마다 실행 (이전 작업 완료 기준)")
    }
    
    // 고정 간격: 이전 작업 시작 후 5초마다
    @Scheduled(fixedRate = 5000)
    fun fixedRateTask() {
        println("5초마다 실행 (시작 시간 기준)")
    }
    
    // Cron 표현식: 매일 새벽 2시
    @Scheduled(cron = "0 0 2 * * *")
    fun cronTask() {
        println("매일 새벽 2시 실행")
    }
    
    // 초기 지연: 앱 시작 10초 후 첫 실행
    @Scheduled(initialDelay = 10000, fixedRate = 60000)
    fun initialDelayTask() {
        println("앱 시작 10초 후, 이후 1분마다")
    }
}

Cron 표현식

 ┌─── 초 (0-59)
 │ ┌─── 분 (0-59)
 │ │ ┌─── 시 (0-23)
 │ │ │ ┌─── 일 (1-31)
 │ │ │ │ ┌─── 월 (1-12)
 │ │ │ │ │ ┌─── 요일 (0-7, 0과 7은 일요일)
 │ │ │ │ │ │
 * * * * * *

예시

"0 0 2 * * *"        // 매일 새벽 2시
"0 */30 * * * *"     // 30분마다
"0 0 9-18 * * MON-FRI"  // 평일 9시~18시 매 정시
"0 0 0 1 * *"        // 매월 1일 자정

설정

# application.yml
spring:
  task:
    scheduling:
      pool:
        size: 5  # 스레드 풀 크기
      thread-name-prefix: scheduling-

장점

  • 설정 간단 (어노테이션만)
  • 별도 인프라 불필요
  • 가벼운 작업에 적합

단점

  • 앱 재시작 필요 (스케줄 변경 시)
  • 실행 이력 없음
  • 수동 실행 불가 (API 직접 구현 필요)
  • 클러스터 환경에서 중복 실행 (별도 처리 필요)
  • 동적 스케줄 변경 어려움

클러스터 환경 문제

App 1 (인스턴스 1)  →  @Scheduled 실행
App 2 (인스턴스 2)  →  @Scheduled 실행
App 3 (인스턴스 3)  →  @Scheduled 실행

→ 3개 모두 실행! (중복)

해결책

// ShedLock 사용
@Component
class ScheduledTasks {
    
    @Scheduled(cron = "0 0 2 * * *")
    @SchedulerLock(
        name = "settlementTask",
        lockAtMostFor = "30m",
        lockAtLeastFor = "5m"
    )
    fun settlement() {
        // DB 락으로 한 인스턴스만 실행
    }
}

사용 시나리오

  • 단순 스케줄 작업 (캐시 갱신, 헬스체크)
  • 단일 서버 환경
  • 배치 1-2개 정도

2. Quartz

개념

Java 기반 오픈소스 스케줄링 라이브러리

  • 애플리케이션 내장
  • 강력한 스케줄링 기능
  • DB 기반 클러스터링 지원

의존성

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-quartz")
}

구현

// Job 정의
class SettlementJob : Job {
    override fun execute(context: JobExecutionContext) {
        println("정산 배치 실행: ${context.fireTime}")
        // 배치 로직
    }
}

// 설정
@Configuration
class QuartzConfig {
    
    @Bean
    fun settlementJobDetail(): JobDetail {
        return JobBuilder.newJob(SettlementJob::class.java)
            .withIdentity("settlementJob", "batch")
            .withDescription("일일 정산 Job")
            .storeDurably()
            .build()
    }
    
    @Bean
    fun settlementTrigger(): Trigger {
        return TriggerBuilder.newTrigger()
            .forJob(settlementJobDetail())
            .withIdentity("settlementTrigger", "batch")
            .withSchedule(
                CronScheduleBuilder.cronSchedule("0 0 2 * * ?")
                    .withMisfireHandlingInstructionFireAndProceed()
            )
            .build()
    }
}

클러스터링 설정

# application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/app
    username: root
    password: password
  
  quartz:
    job-store-type: jdbc  # DB 기반
    properties:
      org:
        quartz:
          scheduler:
            instanceId: AUTO
            instanceName: MyScheduler
          jobStore:
            class: org.quartz.impl.jdbcjobstore.JobStoreTX
            driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
            tablePrefix: QRTZ_
            isClustered: true  # 클러스터 모드
            clusterCheckinInterval: 20000  # 20초마다 체크
          threadPool:
            threadCount: 10

DB 테이블 (11개 필요)

-- Quartz 전용 테이블
QRTZ_JOB_DETAILS          -- Job 정보
QRTZ_TRIGGERS             -- Trigger 정보
QRTZ_SIMPLE_TRIGGERS      -- Simple Trigger
QRTZ_CRON_TRIGGERS        -- Cron Trigger
QRTZ_BLOB_TRIGGERS        -- Blob Trigger
QRTZ_CALENDARS            -- Calendar 정보
QRTZ_PAUSED_TRIGGER_GRPS  -- 일시정지된 Trigger
QRTZ_FIRED_TRIGGERS       -- 실행 중인 Trigger
QRTZ_SCHEDULER_STATE      -- 스케줄러 상태 (클러스터 동기화)
QRTZ_LOCKS                -- 락 관리
QRTZ_SIMPROP_TRIGGERS     -- Simple Property Trigger

수동 실행 API

@RestController
@RequestMapping("/api/batch")
class BatchController(private val scheduler: Scheduler) {
    
    @PostMapping("/trigger/{jobName}")
    fun triggerJob(
        @PathVariable jobName: String,
        @RequestParam params: Map<String, String>
    ): ResponseEntity<*> {
        
        val jobKey = JobKey.jobKey(jobName, "batch")
        
        // Job 존재 확인
        if (!scheduler.checkExists(jobKey)) {
            return ResponseEntity.badRequest().body("Job not found")
        }
        
        // 파라미터와 함께 실행
        val jobDataMap = JobDataMap(params)
        scheduler.triggerJob(jobKey, jobDataMap)
        
        return ResponseEntity.ok("Triggered: $jobName")
    }
}

클러스터 동작 방식

새벽 2시 도착

App 1: SELECT * FROM QRTZ_LOCKS FOR UPDATE
       → 락 획득! 배치 실행

App 2: SELECT * FROM QRTZ_LOCKS FOR UPDATE  
       → 대기...

App 3: SELECT * FROM QRTZ_LOCKS FOR UPDATE
       → 대기...

App 1: 배치 완료 → COMMIT (락 해제)
App 2: 이미 실행됐는지 체크 → SKIP
App 3: 이미 실행됐는지 체크 → SKIP

장점

  • 강력한 스케줄링 기능
  • DB 기반 클러스터링
  • Job 체인, 리스너 등 고급 기능
  • 실행 이력 DB 저장 가능

단점

  • 복잡한 설정 (11개 DB 테이블)
  • DB 락 경합 (성능 이슈)
  • 높은 러닝커브
  • 클러스터 환경에서 복잡도 급증
  • 스케줄 변경 시 재배포 필요
  • GUI 없음 (별도 Admin 구축 필요)

리소스 낭비

API 서버 3대 (로드밸런싱)

┌─────────────────┐
│ App 1           │
│ - API (1GB)     │
│ - Quartz (500MB)│ ← 항상 떠있음
└─────────────────┘

┌─────────────────┐
│ App 2           │
│ - API (1GB)     │
│ - Quartz (500MB)│ ← 항상 떠있음 (대기만)
└─────────────────┘

┌─────────────────┐
│ App 3           │
│ - API (1GB)     │
│ - Quartz (500MB)│ ← 항상 떠있음 (대기만)
└─────────────────┘

총 리소스: 4.5GB
실제 작업: 1개만, 나머지는 대기

사용 시나리오

  • 앱 내부 통합 필요 (주문 후 30분 뒤 취소 등)
  • 복잡한 Job 체인
  • 단순 환경 (서버 1대)

3. Jenkins

개념

외부 독립 스케줄러

  • 애플리케이션
  • 웹 기반 GUI
  • 중앙화된 배치 관리

구조

┌──────────────┐
│  Jenkins     │ (독립 서버, 스케줄러)
└──────┬───────┘
       │
       │ 스케줄 트리거
       │
       └──→ K8s Job / ECS Task 생성
            → 배치 실행 → 완료 후 종료

실행 방식

One-time

// Jenkinsfile
pipeline {
    agent any
    
    triggers {
        cron('0 2 * * *')  // 매일 새벽 2시
    }
    
    parameters {
        string(name: 'TARGET_DATE', defaultValue: '2025-10-05')
        choice(name: 'JOB_TYPE', choices: ['settlement', 'report'])
    }
    
    stages {
        stage('배치 실행') {
            steps {
                sh """
                    kubectl create job ${params.JOB_TYPE}-\${BUILD_NUMBER} \\
                    --image=ecr/batch:latest \\
                    -- --job.name=${params.JOB_TYPE} --date=${params.TARGET_DATE}
                """
            }
        }
    }
    
    post {
        failure {
            slackSend(channel: '#batch-alerts', 
                      message: "배치 실패: ${env.JOB_NAME}")
        }
        success {
            slackSend(message: "배치 성공")
        }
    }
}

동작

평소: 아무것도 안 떠있음 (리소스 0)
  ↓
Jenkins 트리거
  ↓
K8s Job/ECS Task 생성
  ↓
배치 실행 (10분)
  ↓
완료 후 자동 삭제
  ↓
다시 리소스 0

Long-running

배치 서버 (항상 실행 중)
    ↑
    | HTTP POST
    |
Jenkins → curl 호출
pipeline {
    triggers { cron('0 2 * * *') }
    stages {
        stage('트리거') {
            steps {
                sh '''
                    curl -X POST \
                    http://batch-server:8080/api/batch/trigger/settlement
                '''
            }
        }
    }
}

장점

  • 간단한 구조 (스케줄러 1개)
  • 웹 GUI (버튼 클릭으로 실행)
  • 자동 이력 관리
  • 자동 알림 (Slack, 이메일)
  • 파라미터 입력 UI
  • 클러스터 동기화 불필요
  • 리소스 효율적 (One-time 방식)
  • 배치 격리 (장애 영향 최소화)

단점

  • Jenkins 서버 별도 구축 필요
  • 인프라 관리 필요

리소스 효율

┌─────────────────┐
│ API 서버 3대    │
│ - API (1GB)     │ ← 스케줄러 없음
│ - API (1GB)     │
│ - API (1GB)     │
└─────────────────┘

┌─────────────────┐
│ Jenkins (512MB) │ ← 단일 스케줄러
└─────────────────┘

┌─────────────────┐
│ K8s Job         │ ← 실행 시에만 생성
│ - 배치 (2GB)    │
└─────────────────┘
    ↓ 완료 후
   자동 삭제

평소: 3.5GB
실행 시: 5.5GB

사용 시나리오

  • 여러 배치 중앙 관리 (배치 5개 이상)
  • MSA/클라우드 환경 (K8s, ECS)
  • 운영 편의성 중요 (비개발자도 사용)
  • 이력/모니터링 필수

전략 비교

항목 @Scheduled Quartz Jenkins
위치 앱 내장 앱 내장 외부 독립
설정 난이도 쉬움 어려움 중간
스케줄 변경 재배포 재배포 웹 UI
수동 실행 API 직접 구현 API 구현 버튼 클릭
실행 이력 없음 직접 구현 자동 제공
알림 직접 구현 직접 구현 자동 제공
GUI 없음 없음 기본 제공
클러스터 중복 실행 DB 락 (복잡) 동기화 불필요
리소스 앱과 공유 앱과 공유 (낭비) 독립 (효율적)
DB 테이블 불필요 11개 필요 불필요

선택 가이드

@Scheduled 사용

✅ 배치 1-2개
✅ 단순 스케줄 (캐시 갱신, 헬스체크)
✅ 단일 서버
✅ 별도 인프라 부담

Quartz 사용

✅ 앱 내부 통합 필요
   (주문 30분 후 자동 취소 등)
✅ 복잡한 Job 체인
✅ 단순 환경 (서버 1대)

Jenkins 사용

✅ 배치 5개 이상
✅ MSA/클라우드 (K8s, ECS)
✅ 운영 편의성 중요
✅ 중앙 관리 필요
✅ 이력/모니터링 필수

실무 혼합 패턴

// @Scheduled: 가벼운 실시간 작업
@Component
class RealtimeScheduler {
    
    @Scheduled(fixedDelay = 300000)
    fun refreshCache() {
        // 5분마다 캐시 갱신
    }
}

// Quartz: 앱 로직과 밀접한 작업
@Service
class OrderService(private val scheduler: Scheduler) {
    
    fun createOrder(order: Order) {
        orderRepository.save(order)
        
        // 30분 후 자동 취소 스케줄
        scheduler.scheduleJob(
            CancelOrderJob(order.id),
            Date(System.currentTimeMillis() + 1800000)
        )
    }
}
// Jenkins: 무거운 배치
// 매일 새벽 2시 정산
pipeline {
    triggers { cron('0 2 * * *') }
    stages {
        stage('정산') {
            steps {
                sh 'kubectl create job settlement'
            }
        }
    }
}

권장 아키텍처 (Spring Boot + K8s/ECS)

┌─────────────────────────────────┐
│  API 서버 (3대)                 │
│  - @Scheduled (캐시 갱신)       │  ← 가벼운 작업만
│  - Quartz (주문 취소 스케줄)    │  ← 앱 로직 통합
└─────────────────────────────────┘

┌─────────────────────────────────┐
│  Jenkins                        │
│  - 정산 배치 (매일 2시)         │
│  - 리포트 배치 (매주 월요일)    │  ← 무거운 배치
│  - 데이터 동기화 (매시간)       │
└─────────────────────────────────┘
         ↓
┌─────────────────────────────────┐
│  K8s Job / ECS Task             │
│  - 실행 시에만 생성             │
│  - 완료 후 자동 삭제            │
└─────────────────────────────────┘

장점

  • 역할 분리 명확
  • 리소스 효율적
  • 운영 편의성
  • 확장 용이

핵심 요약

@Scheduled

  • 가장 간단
  • 가벼운 작업
  • 단일 서버

Quartz

  • 강력하지만 복잡
  • 클러스터링 어려움
  • 앱 내부 통합

Jenkins

  • 중앙 관리
  • 운영 편의성
  • MSA/클라우드 최적

Spring Boot + K8s/ECS 환경 → Jenkins 권장