Backend/spring (Boot)

Spring 스케줄러 — Quartz 예제로 따라하기

dddzr 2026. 10. 27. 21:33

Spring 스케줄러 — Quartz 예제로 따라하기

예제: 정해진 시간에 로그 한 줄을 찍는 SampleBatch (업무 내용은 신경 쓰지 않아도 됨)


0. 한 줄 요약

스케줄러 = "정해진 시간에 자동으로 돌아가는 알람 시계"
Job = 알람이 울릴 때 실행되는 코드

이 글에서는 "무슨 일을 하느냐"는 중요하지 않습니다. 언제, 어떻게 자동 실행되느냐만 다룹니다.


1. 스케줄러가 왜 필요한가?

어떤 작업이든 사람이 매번 버튼을 누르기 어렵습니다.

  • 매일 새벽 2시
  • 30분마다
  • 월요일 오전 9시

이럴 때 시간만 맞으면 알아서 실행해 주는 게 스케줄러입니다.

[Cron: 매분 0초] ──► [Quartz] ──► [SampleBatchJob] ──► [Service] ──► 로그 출력

2. Spring에서 스케줄링 방법 2가지

방식 어디에 쓰나 장점 단점
@Scheduled 간단한 작업, 학습용 코드 5줄로 가능 Job이 많아지면 관리 어려움
Quartz + Spring 실무 (우리 예제) Cron·여러 Job·동시 실행 제어 설정 파일이 조금 더 많음

아래 예제는 Quartz 기준입니다.


3. 우리가 만들 프로젝트 구조

독립 실행 가능한 미니 Spring Boot 프로젝트입니다.

batch-scheduler-demo/
├── pom.xml
└── src/main/
    ├── java/com/example/batchdemo/
    │   ├── BatchDemoApplication.java           ← 시작점
    │   └── batch/
    │       ├── config/
    │       │   ├── BatchSchedulerConfig.java   ← Quartz 엔진 ON
    │       │   └── SampleBatchConfig.java      ← Cron(실행 시각) 정의
    │       ├── job/
    │       │   └── SampleBatchJob.java         ← 알람이 울리면 Service 호출
    │       └── service/
    │           ├── SampleBatchService.java
    │           └── SampleBatchServiceImpl.java ← 실제 실행할 코드
    └── resources/
        └── application.yml

역할 한 줄 정리

파일 비유
SampleBatchConfig "몇 시에 울릴지" 알람 설정
SampleBatchJob 알람이 울리면 딱 한 번 Service를 부르는 전화기
SampleBatchServiceImpl 실제로 실행할 코드 (여기는 로그만 찍음)
BatchSchedulerConfig Quartz 스케줄러 전원 스위치

4. 사전 준비

  • JDK 17
  • Maven 3.8+
  • IDE (IntelliJ, VS Code 등)

빈 폴더 batch-scheduler-demo를 만들고 아래 파일을 경로 그대로 생성하세요.


5. pom.xml — 의존성

경로: batch-scheduler-demo/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.5</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>batch-scheduler-demo</artifactId>
    <version>1.0.0</version>
    <name>batch-scheduler-demo</name>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- Quartz 스케줄러 (Spring 연동) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-quartz</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

6. application.yml — Cron 설정

경로: batch-scheduler-demo/src/main/resources/application.yml

# 학습용: 매분 0초에 실행 (눈으로 확인하기 쉬움)
app:
  batch:
    sample-cron: "0 * * * * ?"
    # 운영 예시: "0 0 2 * * ?"  → 매일 02:00 KST

Tip
처음엔 매분 돌리면 스케줄러가 도는지 바로 확인할 수 있습니다.
익숙해지면 0 0 2 * * ?(매일 새벽 2시)로 바꾸세요.


7. BatchDemoApplication.java — 시작 클래스

경로: batch-scheduler-demo/src/main/java/com/example/batchdemo/BatchDemoApplication.java

package com.example.batchdemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BatchDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(BatchDemoApplication.class, args);
    }
}

8. SampleBatchService / SampleBatchServiceImpl

경로: batch-scheduler-demo/src/main/java/com/example/batchdemo/batch/service/SampleBatchService.java

package com.example.batchdemo.batch.service;

public interface SampleBatchService {

    /** Quartz Job이 호출하는 진입점 (중복 실행 방지 포함) */
    void executeScheduledSampleBatchTask();

    /** 실제로 실행할 작업 — 예제에서는 로그만 출력 */
    void executeSampleBatchTask();
}

경로: batch-scheduler-demo/src/main/java/com/example/batchdemo/batch/service/SampleBatchServiceImpl.java

package com.example.batchdemo.batch.service;

import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

@Service
public class SampleBatchServiceImpl implements SampleBatchService {

    private static final Logger log = LoggerFactory.getLogger(SampleBatchServiceImpl.class);

    /** 같은 Job이 겹쳐 돌지 않게 막는 플래그 */
    private final AtomicBoolean running = new AtomicBoolean(false);

    @Override
    public void executeScheduledSampleBatchTask() {
        if (!running.compareAndSet(false, true)) {
            log.warn("sampleBatch skipped. already running.");
            return;
        }
        try {
            executeSampleBatchTask();
        } finally {
            running.set(false);
        }
    }

    @Override
    public void executeSampleBatchTask() {
        String runId = UUID.randomUUID().toString();
        log.info("sampleBatch executed. run_id={} at={}", runId, LocalDateTime.now());
        // ↑ 실무에서는 여기에 "하고 싶은 일"을 넣으면 됩니다.
        //   (DB 처리, 파일 생성, API 호출 등 — 이 글 범위 밖)
    }
}

왜 메서드를 2개로 나눴나?

메서드 역할
executeScheduledSampleBatchTask() 스케줄러 전용 문지기 (중복 실행 방지)
executeSampleBatchTask() 실제 작업 (Job이 여러 개면 executeXxxBatchTask처럼 이름 구분)

9. SampleBatchJob.java — Quartz가 부르는 Job

경로: batch-scheduler-demo/src/main/java/com/example/batchdemo/batch/job/SampleBatchJob.java

package com.example.batchdemo.batch.job;

import org.quartz.DisallowConcurrentExecution;
import org.quartz.JobExecutionContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.quartz.QuartzJobBean;

import com.example.batchdemo.batch.service.SampleBatchService;

/**
 * @DisallowConcurrentExecution
 * = "이 Job은 동시에 2개 돌리지 마" (Quartz 레벨 보호)
 */
@DisallowConcurrentExecution
public class SampleBatchJob extends QuartzJobBean {

    @Autowired
    private SampleBatchService sampleBatchService;

    @Override
    protected void executeInternal(JobExecutionContext context) {
        sampleBatchService.executeScheduledSampleBatchTask();
    }
}

Job에는 비즈니스 로직을 넣지 않습니다. Service만 호출하는 얇은 껍데기로 유지하세요.


10. SampleBatchConfig.java — Cron(실행 시각) 정의

경로: batch-scheduler-demo/src/main/java/com/example/batchdemo/batch/config/SampleBatchConfig.java

package com.example.batchdemo.batch.config;

import java.util.TimeZone;

import org.quartz.CronScheduleBuilder;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.example.batchdemo.batch.job.SampleBatchJob;

@Configuration
public class SampleBatchConfig {

    public static final String JOB_NAME = "sampleBatchJob";
    public static final String TRIGGER_NAME = "sampleBatchTrigger";

    @Value("${app.batch.sample-cron}")
    private String cronExpression;
    // public static final String CRON_EXPRESSION = "0 0 3 * * ?";

    /** Job 정의: "무엇을 실행할지" */
    @Bean
    public JobDetail sampleBatchJobDetail() {
        return JobBuilder.newJob(SampleBatchJob.class)
                .withIdentity(JOB_NAME)
                .storeDurably()
                .build();
    }

    /** Trigger 정의: "언제 실행할지" */
    @Bean
    public Trigger sampleBatchTrigger(JobDetail sampleBatchJobDetail) {
        return TriggerBuilder.newTrigger()
                .forJob(sampleBatchJobDetail)
                .withIdentity(TRIGGER_NAME)
                .withSchedule(CronScheduleBuilder.cronSchedule(cronExpression)
                        .inTimeZone(TimeZone.getTimeZone("Asia/Seoul"))
                        .withMisfireHandlingInstructionFireAndProceed())
                .build();
    }
}

Cron 표현식 치트시트 (Quartz 6~7필드)

초  분  시  일  월  요일  [연도]
0   0   2   *   *   ?        → 매일 02:00:00
0   *   *   *   *   ?        → 매분 0초 (학습용)
0   0/30 *  *   *   ?        → 30분마다
필드 허용 값 * 의미
초 0~59 매 초
분 0~59 매 분
시 0~23 매 시
일 1~31 매일
월 1~12 매월
요일 SUN~SAT, ? 요일 무관

11. BatchSchedulerConfig.java — Quartz 엔진 켜기

경로: batch-scheduler-demo/src/main/java/com/example/batchdemo/batch/config/BatchSchedulerConfig.java

package com.example.batchdemo.batch.config;

import org.quartz.JobDetail;
import org.quartz.Trigger;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.scheduling.quartz.SpringBeanJobFactory;

@Configuration
public class BatchSchedulerConfig {

    /**
     * SchedulerFactoryBean = Quartz 스케줄러 본체.
     * JobDetail + Trigger를 등록해야 실제로 돌아간다.
     */
    @Bean
    public SchedulerFactoryBean batchSchedulerFactoryBean(
            ApplicationContext applicationContext,
            JobDetail sampleBatchJobDetail, Trigger sampleBatchTrigger
            // JobDetail sampleBatchJobDetail2, Trigger sampleBatchTrigger2
            ) {

        SchedulerFactoryBean factory = new SchedulerFactoryBean();

        // Job 안에서 @Autowired가 되도록 Spring과 연결
        AutowiringSpringBeanJobFactory jobFactory = new AutowiringSpringBeanJobFactory();
        jobFactory.setApplicationContext(applicationContext);
        factory.setJobFactory(jobFactory);

        factory.setJobDetails(sampleBatchJobDetail); // JobDetails 여러개 등록 가능.
        factory.setTriggers(sampleBatchTrigger); // Triggers 여러개 등록 가능.
        factory.setAutoStartup(true);
        return factory;
    }

    /**
     * Quartz가 Job을 new 할 때 Spring 빈 주입을 해 주는 팩토리
     */
    static class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory {
        private ApplicationContext applicationContext;

        @Override
        public void setApplicationContext(ApplicationContext applicationContext) {
            super.setApplicationContext(applicationContext);
            this.applicationContext = applicationContext;
        }

        @Override
        protected Object createJobInstance(org.quartz.spi.TriggerFiredBundle bundle) throws Exception {
            Object job = super.createJobInstance(bundle);
            applicationContext.getAutowireCapableBeanFactory().autowireBean(job);
            return job;
        }
    }
}

12. 실행 방법

터미널에서 프로젝트 루트로 이동 후:

cd batch-scheduler-demo
mvn spring-boot:run

성공 로그 예시

1분 이내(테스트 Cron 기준) 콘솔에 아래와 비슷한 로그가 보이면 스케줄러가 정상 동작하는 것입니다.

sampleBatch executed. run_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx at=2026-06-26T14:01:00

13. 전체 호출 흐름

[Quartz Scheduler]
        │  Cron 시각 도래 (예: 매분 0초)
        ▼
[SampleBatchJob.executeInternal()]
        │
        ▼
[SampleBatchServiceImpl.executeScheduledSampleBatchTask()]
        │  running 플래그 검사 (이미 돌고 있으면 스킵)
        ▼
[SampleBatchServiceImpl.executeSampleBatchTask()]
        │
        └─ log.info("sampleBatch executed ...")

Job 2개 이상일 때

BatchSchedulerConfig에서 Job·Trigger를 배열로 추가하면 됩니다.

factory.setJobDetails(sampleBatchJobDetail, anotherBatchJobDetail);
factory.setTriggers(sampleBatchTrigger, anotherBatchTrigger);

각 Job마다 XxxBatchConfig를 하나씩 두는 패턴이 일반적입니다.


14. 실무에서 스케줄러에 자주 붙이는 것

항목 스케줄러 관점에서 하는 일
배치 서버 1대만 등록 WAS 여러 대여도 Job이 한 곳에서만 돌게
수동 실행 API Cron 기다리지 않고 즉시 1회 실행
@DisallowConcurrentExecution 같은 Job 중복 기동 방지 (Quartz)
AtomicBoolean running Service 레벨 중복 기동 방지 (이중 안전장치)

15. 자주 하는 실수 TOP 5

# 증상 원인 해결
1 Job이 안 돈다 Config가 @SpringBootApplication 패키지 밖 com.example.batchdemo 하위에 두기
2 Job에서 @Autowired null AutowiringSpringBeanJobFactory 미설정 BatchSchedulerConfig 확인
3 Cron이 이상한 시간에 돈다 타임존 미지정 .inTimeZone(Asia/Seoul)
4 Job이 2번 돈다 WAS 2대 모두 Scheduler 기동 배치 서버 1대만 setAutoStartup(true)
5 Cron 바꿨는데 반영 안 됨 앱 재기동 안 함 spring-boot:run 재시작

16. @Scheduled로 30초 만에 맛보기 (보너스)

Quartz 전에 가장 단순한 형태도 알아 두면 좋습니다.

BatchDemoApplication.java에 @EnableScheduling 추가:

@SpringBootApplication
@EnableScheduling
public class BatchDemoApplication { ... }

HelloScheduler.java:

package com.example.batchdemo.batch;

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class HelloScheduler {

    @Scheduled(fixedRate = 5000)  // 5초마다
    public void tick() {
        System.out.println("tick: " + System.currentTimeMillis());
    }
}
  • 장점: 바로 이해됨
  • 한계: Cron·Job 관리·동시 실행 제어가 약함 → Job이 늘어나면 Quartz로 갑니다.

17. 정리 — 새 Job 추가 체크리스트

  1. Service — executeXxxBatchTask() (실제 할 일)
  2. Job — Service 호출만 (QuartzJobBean)
  3. XxxBatchConfig — JobDetail + Trigger(Cron)
  4. BatchSchedulerConfig — setJobDetails / setTriggers에 등록
  5. application.yml — Cron 값
  6. 앱 실행 → 로그로 실행 시각 확인

SampleBatch 하나만 이해하면, 두 번째·세 번째 Job도 같은 뼈대에 Cron과 Service 이름만 바꿔 끼우면 됩니다.


18. (선택) 수동 실행 — Controller·스크립트 원리

Cron만으로도 배치는 돌아갑니다. Controller는 “지금 당장 한 번 돌려줘” 를 HTTP로 받기 위한 선택 구성입니다.

[자동]  Cron → SampleBatchJob → executeScheduledSampleBatchTask() → executeSampleBatchTask()
                                              ↑ Controller 없음

[수동]  curl / 스크립트 / Postman
              → SampleBatchController (POST /batch/sample/run.json)
              → acceptManualRun()
              → (비동기) executeSampleBatchTask()
구분 Controller 필요?
Quartz 스케줄만 사용 불필요
운영자가 즉시 1회 실행 필요 (또는 동일 API를 curl로 직접 호출)

스크립트는 필수가 아닙니다. 스크립트는 POST + Cookie 헤더를 대신 보내 주는 편의 도구일 뿐입니다.


18.1 응답 DTO

경로: batch-scheduler-demo/src/main/java/com/example/batchdemo/batch/dto/SampleBatchManualRunResponse.java

package com.example.batchdemo.batch.dto;

public class SampleBatchManualRunResponse {

    private boolean accepted;
    private String reason;
    private String run_id;
    private String accepted_at;
    private String trigger_type;

    public boolean isAccepted() { return accepted; }
    public void setAccepted(boolean accepted) { this.accepted = accepted; }
    public String getReason() { return reason; }
    public void setReason(String reason) { this.reason = reason; }
    public String getRun_id() { return run_id; }
    public void setRun_id(String run_id) { this.run_id = run_id; }
    public String getAccepted_at() { return accepted_at; }
    public void setAccepted_at(String accepted_at) { this.accepted_at = accepted_at; }
    public String getTrigger_type() { return trigger_type; }
    public void setTrigger_type(String trigger_type) { this.trigger_type = trigger_type; }
}

18.2 Service에 수동 접수 메서드 추가

SampleBatchService 인터페이스에 추가:

SampleBatchManualRunResponse acceptManualRun();

SampleBatchServiceImpl 예시 (기존 executeSampleBatchTask 재사용):

import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
import java.util.concurrent.Executor;

import org.springframework.beans.factory.annotation.Qualifier;

import com.example.batchdemo.batch.dto.SampleBatchManualRunResponse;

// 필드 추가
@Qualifier("sampleBatchExecutor")
private final Executor sampleBatchExecutor;

public SampleBatchServiceImpl(@Qualifier("sampleBatchExecutor") Executor sampleBatchExecutor) {
    this.sampleBatchExecutor = sampleBatchExecutor;
}

private static final DateTimeFormatter ACCEPTED_AT =
        DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX");

@Override
public SampleBatchManualRunResponse acceptManualRun() {
    SampleBatchManualRunResponse response = new SampleBatchManualRunResponse();
    response.setAccepted_at(OffsetDateTime.now(ZoneId.of("Asia/Seoul")).format(ACCEPTED_AT));
    response.setTrigger_type("MANUAL");

    if (!running.compareAndSet(false, true)) {
        response.setAccepted(false);
        response.setReason("ALREADY_RUNNING");
        return response;
    }

    String runId = UUID.randomUUID().toString();
    response.setAccepted(true);
    response.setRun_id(runId);

    // HTTP는 여기서 바로 끝(202). 실제 작업은 백그라운드 스레드에서 실행
    sampleBatchExecutor.execute(() -> {
        try {
            executeSampleBatchTask();
        } finally {
            running.set(false);
        }
    });
    return response;
}

SampleBatchConfig에 Executor 빈 추가 (수동 실행용, core=1):

@Bean(name = "sampleBatchExecutor")
public Executor sampleBatchExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(1);
    executor.setMaxPoolSize(1);
    executor.setQueueCapacity(10);
    executor.setThreadNamePrefix("sample-batch-");
    executor.initialize();
    return executor;
}

18.3 Controller — HTTP 진입점

경로: batch-scheduler-demo/src/main/java/com/example/batchdemo/batch/controller/SampleBatchController.java

package com.example.batchdemo.batch.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.batchdemo.batch.dto.SampleBatchManualRunResponse;
import com.example.batchdemo.batch.service.SampleBatchService;

@RestController
@RequestMapping("/batch/sample")
public class SampleBatchController {

    private final SampleBatchService sampleBatchService;

    public SampleBatchController(SampleBatchService sampleBatchService) {
        this.sampleBatchService = sampleBatchService;
    }

    @PostMapping("/run.json")
    public ResponseEntity<SampleBatchManualRunResponse> runManual(
            @RequestBody(required = false) Object body) {
        SampleBatchManualRunResponse response = sampleBatchService.acceptManualRun();
        return ResponseEntity.status(HttpStatus.ACCEPTED).body(response);
    }
}
  • HTTP 202 Accepted = “접수됨” (배치 완료가 아님)
  • 완료 여부는 로그 sampleBatch executed 로 확인

18.4 스크립트 없이 수동 호출 — 가능합니다

스크립트는 curl을 예쁘게 감싼 것입니다. 아래만으로 동일합니다.

curl (Git Bash / macOS / Linux)

curl -k -X POST "https://localhost:8080/batch/sample/run.json" \
  -H "Content-Type: application/json" \
  -d '{}'

PowerShell

Invoke-RestMethod -Method Post `
  -Uri "https://localhost:8080/batch/sample/run.json" `
  -ContentType "application/json" `
  -Body "{}" `
  -SkipCertificateCheck

Postman / Insomnia

항목 값
Method POST
URL https://localhost:8080/batch/sample/run.json
Body {} (raw JSON)

실무 포털처럼 로그인 세션이 필요하면 Header에 Cookie 추가:

Cookie: JSESSIONID=로그인_후_복사한_값

18.5 수동 스크립트 원리 (PowerShell 예시)

경로: docs/apps/batch/scripts/run-sample-batch.ps1

param(
    [string]$BaseUrl = $env:PORTAL_BASE_URL,
    [string]$SessionCookie = $env:PORTAL_SESSION_COOKIE
)

$Uri = "$($BaseUrl.TrimEnd('/'))/batch/sample/run.json"

$response = Invoke-RestMethod `
    -Method Post -Uri $Uri `
    -ContentType "application/json" `
    -Body "{}" `
    -Headers @{ Cookie = $SessionCookie } `
    -SkipCertificateCheck

$response | ConvertTo-Json

동작 순서:

  1. 환경 변수에서 URL·쿠키 읽기
  2. POST /batch/sample/run.json 호출
  3. JSON에서 accepted 확인
  4. true이면 서버 로그에서 sampleBatch executed 검색

스크립트에 배치 로직은 없습니다. 전부 서버 Controller → Service가 처리합니다.


18.6 응답 예시

접수 성공

{
  "accepted": true,
  "run_id": "a1b2c3d4-....",
  "accepted_at": "2026-06-26T14:00:00+09:00",
  "trigger_type": "MANUAL"
}

이미 실행 중

{
  "accepted": false,
  "reason": "ALREADY_RUNNING",
  "run_id": null,
  "trigger_type": "MANUAL"
}

18.7 수동 실행 체크리스트 (선택 기능)

  1. Service — acceptManualRun() + Executor로 비동기 실행
  2. DTO — SampleBatchManualRunResponse
  3. Controller — POST /batch/sample/run.json → HTTP 202
  4. (선택) 스크립트 — curl 래퍼
  5. curl 또는 Postman으로 accepted: true 확인 → 로그 확인

Cron만 쓸 거면 1~4번 전부 생략해도 §9~§13 스케줄러 예제는 그대로 동작합니다.


끝.
batch-scheduler-demo 폴더를 만들고 §5~§13 파일을 붙여 넣은 뒤 mvn spring-boot:run 하세요.
수동 실행까지 붙이려면 §18을 추가합니다. 스크립트 없이 curl·Postman만으로도 Controller 수동 호출이 가능합니다.