Spring Batch & Scheduler

Spring Batch vs Spring Scheduler 실무 비교

2026. 08. 05.
SpringBatchSchedulerBackend

Spring 진영에는 "주기적으로 뭔가 실행"하는 도구가 두 개 있다.

Spring Scheduler (@Scheduled)
Spring Batch

이름은 비슷한데 역할은 완전히 다르다.

예시 시나리오 외부 API를 주기적으로 호출해서 JSON으로 저장하는 데이터 수집기를 Spring Boot + Spring Batch로 만든다고 하자. 이하 이 시나리오를 "예시 수집기"라고 부른다.


1. 각각의 목적

Spring Scheduler가 해결하는 문제

"시간을 기준으로 코드를 실행하고 싶다."

이게 전부다. Scheduler는 "언제"만 담당한다.

@Scheduled(cron = "0 0/10 * * * *") // 10분마다
public void collect() {
    // 뭔가 실행
}

Cron 파싱, 스레드 풀 관리, 다음 실행 시각 계산 — 이 반복적이고 자잘한 걸 Spring이 대신 해준다.

즉 Scheduler는 트리거 도구다. 무엇을 어떻게 처리하는지는 관심이 없다. 안에서 무슨 짓을 하든 그건 개발자 책임이다.

Spring Batch가 해결하는 문제

"대량의 데이터를, 실패해도 안전하게, 재시작 가능하게 처리하고 싶다."

Batch가 진짜로 풀려는 문제는 이런 것들이다.

  • 100만 건 처리 중 70만 건에서 죽으면 처음부터 다시 돌리기 싫다
  • 실패한 건과 성공한 건이 뒤섞이면 안 된다
  • 어제 몇 시에 실행됐고, 몇 건 처리했고, 왜 실패했는지 기록이 남아야 한다
  • 같은 배치가 두 번 돌면 안 된다 (돈 두 번 정산되면 큰일)
  • 100만 건을 한 트랜잭션에 넣으면 DB가 죽으니 N건씩 끊어서 커밋해야 한다

이 요구를 매번 손으로 짜면 결국 미니 Batch 프레임워크가 하나 만들어진다. Spring Batch는 그걸 미리 만들어둔 것이다.

왜 같이 쓰는가

두 도구는 경쟁이 아니라 조합이다.

[@Scheduled]  ── 10분마다 트리거 ──▶  [Spring Batch Job]
   "언제"                                    "무엇을, 어떻게"

예시 수집기 시나리오로 옮기면:

  • @Scheduled(cron = "0 0/10 * * * *") → "10분마다 실행해라"
  • Batch Job → "API 100개를 호출하고, JSON으로 저장하고, 실패한 건은 재시도하고, 이력 남겨라"

Scheduler가 "10분마다 두드리는 방아쇠", Batch가 "정교하게 데이터를 처리하는 총알"이라고 보면 된다.


2. 실행 구조 비교

Scheduler 실행 흐름

@EnableScheduling을 켜면 Spring이 TaskScheduler(기본은 ThreadPoolTaskScheduler) 하나를 띄운다. 그리고 @Scheduled가 붙은 메서드를 스캔해서, cron 표현식이나 fixedRate에 맞춰 스레드 풀에서 호출한다.

[TaskScheduler]
   │
   ├── cron 시간이 되면
   ▼
[Worker Thread] → collect() 실행
                    ↳ 예외? → 그냥 로그만, 다음 주기까지 아무것도 안 함
                    ↳ 성공? → 아무 기록 없음
  • 상태는 JVM 메모리 안에만 있다.
  • 실행 이력, 성공/실패 여부, 다음 재시도 — 저장하는 것이 하나도 없다.
  • 애플리케이션이 죽으면 스케줄도 같이 죽는다. 재시작하면 다음 주기부터 다시 시작한다.

Batch 실행 흐름

Batch는 훨씬 복잡하다. 하지만 이 복잡함이 곧 안전장치다.

[JobLauncher]
   │  Job + JobParameters 를 넘김
   ▼
[JobRepository] ── 이 Job이 이미 실행됐는지 확인
   │              (같은 파라미터로는 중복 실행 불가)
   │  새 JobExecution 생성 (DB에 기록)
   ▼
[Job]
   ├── [Step 1]  ── 실행 시작 (StepExecution 기록)
   │      ├─ Reader: 데이터 N건 읽기
   │      ├─ Processor: 가공
   │      └─ Writer: N건 쓰기 → COMMIT
   │      (Chunk 단위로 반복, 각 커밋마다 진행 상황 저장)
   │
   ├── [Step 2] ...
   │
   └── 종료 → JobExecution 상태 업데이트 (COMPLETED/FAILED)

각 구성 요소의 역할을 정리하면:

컴포넌트역할예시 수집기에서는
Job하나의 배치 작업 단위"API 데이터 수집" 전체 작업
StepJob을 이루는 단계"API 호출 Step", "JSON 저장 Step" 등
TaskletStep 안에서 단일 작업을 실행하는 인터페이스"임시 폴더 생성", "완료 신호 파일 만들기" 같은 단발성 작업
Chunk 지향 StepReader → Processor → Writer 흐름을 N건씩 반복"API 응답 100건씩 읽어서 → 가공해서 → 파일로 쓰기"
JobLauncherJob을 실제로 실행시키는 진입점Scheduler가 여기를 호출
JobRepositoryJob/Step 실행 이력을 DB(메타 테이블)에 저장어제 몇 시에 성공했는지, 지금 어디까지 처리했는지 저장

핵심은 JobRepository에 모든 상태가 저장된다는 점이다. Scheduler가 메모리 위에서 논다면, Batch는 DB 위에서 논다. 그래서 서버가 죽어도 상태가 살아있다.


3. 데이터 처리 관점 비교

관점SchedulerBatch
단순 반복 작업최적. 3줄이면 끝오버스펙
대량 데이터 처리메모리 폭발 위험. 트랜잭션 통제 어려움Chunk 단위 처리로 안전
실패 재처리다음 주기까지 방치. 직접 구현해야 함Retry / Skip을 선언적으로 설정
트랜잭션 관리개발자가 @Transactional 붙여서 직접Chunk 단위 자동 커밋
체크포인트없음. 어디까지 했는지 스스로 기억 안 함ExecutionContext에 자동 저장
재시작처음부터 다시실패한 Chunk 다음부터 재개

예시 수집기로 예를 들어보자. API 100개를 호출해서 100개의 JSON을 저장한다고 하자.

Scheduler만 쓴다면:

@Scheduled(cron = "0 0/10 * * * *")
public void collect() {
    for (Api api : apis) {              // 100개
        Response res = api.call();      // 70번째에서 예외 발생
        save(res);
    }
}
// → 70번째에서 죽으면 다음 10분 뒤에 다시 처음부터
// → 어디까지 했는지 모름. 71번부터 재개 불가
// → 이미 저장된 1~69번은 어떻게 처리? 중복 저장?

Batch를 쓴다면:

// Chunk size = 10 인 Step
// → API 10개 호출 → 저장 → COMMIT
// → API 다음 10개 호출 → 저장 → COMMIT
// → 70번째에서 실패해도 60번까지는 커밋됨
// → 재실행하면 61번부터 재개
// → Retry(3): 일시적 네트워크 오류는 자동 재시도
// → Skip(5): 특정 API가 계속 실패해도 5건까지는 건너뜀

이 차이가 10만 건, 100만 건이 되면 재앙 vs 정상 운영을 가른다.


4. 장애 상황 비교

각 시나리오를 예시 수집기 관점에서 본다.

시나리오 A: 배치 실행 중 서버가 죽었다

  • Scheduler: 실행 중이던 메서드는 그냥 중단된다. 어디까지 처리됐는지 아무도 모른다. 재시작 후 다음 cron까지 대기.
  • Batch: JobExecution 상태가 STARTED인 채로 DB에 남는다. 재실행 시 JobRepository가 이 상태를 보고 마지막으로 COMMIT된 Chunk의 다음부터 재개할 수 있다.

시나리오 B: 100개 중 30번째 API 호출에서 실패

  • Scheduler: try/catch로 감싸지 않으면 for 루프 자체가 죽는다. 감쌌다 해도 실패 건을 재시도할 로직을 직접 짜야 한다.
  • Batch: Reader/Processor/Writer 어디서 실패했는지 정확히 기록된다. @Retryable 또는 .faultTolerant().retry(...) 설정만 하면 자동 재시도. 계속 실패하면 Skip 대상으로 넘겨 배치 자체는 완주시킬 수 있다.

시나리오 C: 일부 데이터만 처리되고 종료됐다

  • Scheduler: "일부 처리"가 어디까지인지 모른다. DB 저장은 됐는데 파일은 안 만들어졌을 수도, 반대일 수도. 부분 실패의 상태를 알 방법이 없다.
  • Batch: Chunk가 트랜잭션 단위다. Chunk 안에서 실패하면 그 Chunk는 통째로 롤백된다. 커밋된 것과 안 된 것이 명확히 분리된다.

시나리오 D: 중복 실행 (예: Pod가 3개라서 스케줄이 3번 트리거됨)

  • Scheduler: 그냥 3번 다 실행된다. API를 3번 호출하고, 파일 3번 저장한다. 원본 데이터가 오염될 수 있다.
  • Batch: JobRepository동일한 JobParameters로 실행된 Job은 중복 실행을 막는다. 같은 파라미터로 다시 실행하면 JobInstanceAlreadyCompleteException이 뜬다. (단, 스케줄러가 3번 호출하는 것 자체는 여전히 문제. 다중 인스턴스는 뒤에서 다룬다.)

시나리오 E: API 호출이 계속 타임아웃

  • Scheduler: 다음 주기까지 방치. 재시도하려면 직접 구현.
  • Batch: .retry(TimeoutException.class).retryLimit(3).backOffPolicy(...) 로 지수 백오프까지 선언적으로 처리.

5. 선택 기준표

상황선택이유
매일 새벽 통계 생성 (10만 건 집계)Batch대량 데이터 + 실패 재시작 필요 + 이력 관리
5분마다 캐시 워밍Scheduler실패해도 5분 뒤 다시 하면 됨. 상태 관리 불필요
10만 건 회원 데이터 처리 (마이그레이션)BatchChunk 커밋으로 메모리 안정, 중단 시 재개
외부 API 100개 호출해서 저장 (예시 수집기)Batch (Scheduler로 트리거)실패 재시도, 부분 재개, 중복 방지 모두 필요
30일 지난 로그 삭제Scheduler단순 SQL 한 방. 실패해도 다음 날 지우면 됨
CSV 100MB → DB 적재Batch대량 + 트랜잭션 관리 필수
파일 포맷 변환 (수십만 파일)Batch중단 재개, 실패 격리 필요
헬스체크 pingScheduler극단적으로 가벼움
이메일 대량 발송 (100만 건)Batch부분 성공 관리, 재시도, Skip 정책 필수
자정에 사용자에게 푸시 알림 1건Scheduler단발성, 실패해도 재시도 큐로 넘기면 됨

판단 기준 세 가지만 기억하면 된다.

  1. 데이터 건수가 수천 이상인가? → Batch
  2. 실패 시 처음부터 다시 돌리기 곤란한가? → Batch
  3. 누가 언제 뭘 했는지 이력이 필요한가? → Batch

셋 다 아니면 Scheduler로 충분하다.


6. Scheduler만 썼을 때 실제로 터지는 문제

이론이 아니라 운영에서 실제로 겪는 것들이다.

문제 1: 중단된 지점을 알 수 없다

수집기가 API 100개 중 70번째에서 죽었다. 새벽 3시에 알림이 왔다. 재시작하면 되나? 1번부터 다시 돌면 중복 저장되나? 71번부터만 돌리려면 어디 손댈까?

Scheduler는 아무것도 안 알려준다. 결국 로그를 뒤져서 마지막으로 성공한 API가 뭔지 찾아야 하고, 로그가 부족하면 처음부터 다시 돌리는 수밖에 없다.

문제 2: 중복 실행 방어를 직접 짜야 한다

Pod 2개로 배포했더니 새벽 3시에 수집이 2번 돌았다. 같은 API를 2번 호출했고, 같은 파일을 2번 덮어썼다. 다행히 덮어써서 오염은 없었지만, 외부 API rate limit이 걸려서 다음 날 종일 실패했다.

방어하려면 DB 락, Redis 락, ShedLock, K8s CronJob 등 뭐라도 붙여야 한다. Batch도 이걸 완전히 해결해주진 않지만, 적어도 동일 파라미터 재실행은 자동으로 막아준다.

문제 3: 실패 이력이 남지 않는다

"지난주 화요일 새벽 수집 왜 실패했어요?" 질문에 답할 방법이 없다. 로그를 3일치 뒤져야 한다. 로그 보존 기간이 짧으면 그마저도 불가능.

Batch는 BATCH_JOB_EXECUTION 테이블에 실행 시각, 종료 시각, 상태, 실패 이유가 다 남는다.

문제 4: 트랜잭션 경계가 모호해진다

수집 로직 안에서 DB 저장 + 파일 저장 + 캐시 갱신을 한다. Scheduler에서는 @Transactional을 어디에 어떻게 붙여야 할지 애매하다. 파일 저장은 트랜잭션 밖에서 도는데, DB는 롤백됐지만 파일은 남는 상황이 자주 생긴다.

Batch의 Chunk 트랜잭션은 이런 경계를 강제로 명확하게 만든다.

문제 5: 부분 실패 처리를 매번 새로 짠다

  • "5번 실패한 API는 건너뛰자" → 카운터 어디에 저장?
  • "일시적 오류는 재시도, 영구 오류는 건너뛰자" → 예외 분류 로직 직접 구현
  • "실패한 것만 따로 모아서 나중에 재처리하자" → 별도 테이블 설계

Batch는 이 세 가지를 각각 Retry, Skip, Restart로 이미 만들어놨다.

문제 6: 모니터링·알림 붙이기 어렵다

"실행 시간이 평소보다 2배 걸리면 알림"을 만들려면, 어딘가에 실행 시간이 기록돼야 한다. Scheduler는 그런 게 없다. Batch는 메타 테이블만 조회하면 끝.


7. Spring Batch 핵심 개념 (초보 개발자용)

예시 수집기 시나리오로 개념을 하나씩 매핑해보자.

Job

한 번의 배치 작업 전체. 가장 큰 단위다.

이 시나리오에서는 apiDataCollectionJob이라는 Job 하나가 있다. 이 Job은 "API 100개 호출 → JSON 저장 → 요약 로그 기록" 전체 흐름을 담는다.

@Bean
public Job apiDataCollectionJob(JobRepository jobRepository, Step collectStep, Step summaryStep) {
    return new JobBuilder("apiDataCollectionJob", jobRepository)
        .start(collectStep)
        .next(summaryStep)
        .build();
}

Step

Job을 구성하는 단계. Job은 하나 이상의 Step으로 이루어진다.

이 시나리오에서는:

  • collectStep: API를 호출하고 JSON을 저장하는 단계
  • summaryStep: 오늘 수집된 파일 개수를 로그에 남기는 단계

Step은 두 가지 방식으로 만들 수 있다.

(1) Chunk 지향 Step — 데이터를 N건씩 읽고, 가공하고, 쓴다.

new StepBuilder("collectStep", jobRepository)
    .<ApiEndpoint, ApiResponse>chunk(10, tx)
    .reader(apiEndpointReader())   // API 목록에서 하나씩 읽기
    .processor(apiCallProcessor()) // 실제 호출
    .writer(jsonFileWriter())      // JSON 파일로 저장
    .build();

(2) Tasklet Step — 그냥 한 덩어리 로직을 실행한다.

new StepBuilder("summaryStep", jobRepository)
    .tasklet((contribution, chunkContext) -> {
        log.info("오늘 수집 완료: {}건", countTodayFiles());
        return RepeatStatus.FINISHED;
    }, tx)
    .build();

Tasklet

"그냥 한 번 실행되고 끝나는" 단순 작업용 인터페이스.

Chunk 모델이 안 어울리는 작업 — 폴더 생성, 파일 이동, 완료 마커 파일 쓰기, 알림 발송 — 에 쓴다.

예시 수집기에서는:

  • 수집 시작 시 오늘 날짜의 저장 폴더 만들기
  • 수집 완료 후 _SUCCESS 마커 파일 만들기

이런 게 Tasklet 후보다.

Chunk

Chunk 지향 Step에서 "N건씩 끊어 처리하는" 그 N.

Chunk size가 10이면:

API 10개 호출 → 처리 → 저장 → COMMIT
API 10개 호출 → 처리 → 저장 → COMMIT
...

50번째 API에서 실패하면 → 4150번 Chunk가 롤백되고 → **140번은 이미 COMMIT돼 있으므로 재실행 시 41번부터 재개**된다.

Chunk size는 실무의 튜닝 포인트다. 너무 작으면 커밋이 잦아 느리고, 너무 크면 롤백 비용이 커진다. 예시 수집기 같은 외부 API 호출은 10~50 정도가 무난하다.

JobParameters

"이 Job 실행을 다른 실행과 구분해주는 파라미터."

같은 Job이라도 파라미터가 다르면 다른 실행이다. 예시 수집기에서는 보통 수집 대상 날짜를 파라미터로 넣는다.

JobParameters params = new JobParametersBuilder()
    .addString("targetDate", "2026-08-05")
    .toJobParameters();
 
jobLauncher.run(apiDataCollectionJob, params);

이 덕분에:

  • 2026-08-05 수집이 실패했다 → 같은 파라미터로 재실행하면 중단 지점부터 재개
  • 2026-08-05 수집이 이미 성공했다 → 같은 파라미터로 실행 시 중복 실행 방지 예외

만약 매번 새 실행으로 취급하고 싶으면 runAt=현재시각처럼 매번 바뀌는 값을 하나 넣으면 된다.

ExecutionContext

"Job이나 Step 실행 중간에 저장해두는 상태 저장소."

Batch가 어디까지 처리했는지 기억하는 곳이 여기다. JobRepository가 DB에 자동으로 저장·복원한다.

두 가지 스코프가 있다.

  • StepExecutionContext: 해당 Step 안에서만 유지. 예: "이번 Step에서 지금까지 읽은 offset"
  • JobExecutionContext: Job 전체에서 공유. 예: Step 1에서 계산한 값을 Step 2로 넘길 때

예시 수집기에서는 "지금까지 호출한 API의 마지막 ID"를 StepExecutionContext에 저장해두면, 재시작 시 Reader가 그 지점부터 다시 읽게 만들 수 있다.

JobRepository

Batch의 심장. Job/Step 실행 상태를 저장하는 저장소다.

내부적으로는 이런 메타 테이블들을 사용한다.

BATCH_JOB_INSTANCE     ── Job 이름 + JobParameters의 유일한 조합
BATCH_JOB_EXECUTION    ── 실제 실행 1회 (시작/종료 시각, 상태)
BATCH_STEP_EXECUTION   ── 각 Step 실행 (읽은 건수, 커밋 수, 실패 수)
BATCH_JOB_EXECUTION_CONTEXT
BATCH_STEP_EXECUTION_CONTEXT

Spring Boot Batch를 쓰면 이 테이블들은 스타트업 시 자동으로 만들어진다.

예시 수집기 관점에서 JobRepository가 없다고 상상해보자.

  • 어제 수집이 성공했는지 → 모름
  • 지금 실행 중인 수집이 어디까지 했는지 → 모름
  • 같은 날짜 수집이 이미 돌았는지 → 모름
  • 재시작하면 어디부터 재개할지 → 모름

JobRepository가 있기 때문에 Batch가 "안전한 배치"가 된다. 이걸 걷어내면 Batch는 그냥 무거운 Scheduler다.


예시 수집기에 적용한다면

지금까지 얘기를 하나로 묶으면 이런 구조가 된다.

[@Scheduled(cron = "0 0/10 * * * *")]
        │  10분마다 트리거
        ▼
[JobLauncher.run(apiDataCollectionJob, {targetDate: 오늘})]
        │
        ▼
[Job: apiDataCollectionJob]
   ├── Tasklet Step: 오늘 저장 폴더 준비
   ├── Chunk Step: API 목록 Reader → 호출 Processor → JSON Writer
   │      • Chunk size = 20
   │      • Retry: HttpTimeoutException 3회, 지수 백오프
   │      • Skip: 특정 4xx는 최대 10건까지
   └── Tasklet Step: _SUCCESS 마커 파일 생성

이 구조에서 얻는 것:

  • API 300개 중 150번째에서 서버가 죽어도 → 재실행 시 141번(마지막 커밋 지점)부터 재개
  • 같은 날짜에 두 번 트리거돼도 → 두 번째는 자동 차단
  • 어제 실행이 뭐가 얼마나 실패했는지 → BATCH_STEP_EXECUTION 한 줄 조회면 끝
  • 일시적 네트워크 오류 → 자동 재시도. 계속 실패하는 특정 API는 Skip 처리 후 배치 완주

Scheduler만으로 이걸 다 구현하려면 결국 미니 Batch를 새로 짜야 한다.


정리

  • Scheduler는 트리거, Batch는 실행 엔진. 역할이 다르다.
  • 데이터가 크거나, 실패 관리가 필요하거나, 이력이 필요하면 Batch.
  • 셋 다 아니면 Scheduler로 충분하다.
  • 실무에서는 Scheduler가 Batch Job을 kick하는 조합이 표준.
  • Batch의 안전함은 결국 JobRepository(=DB에 저장되는 상태) 에서 나온다.
  • 수집기처럼 "외부 자원 + 부분 실패 + 재시작"이 필요한 작업은 Batch가 훨씬 유리하다.

다음 글에서는 Job / Step / Chunk 모델을 코드 레벨에서 더 깊이 파보자.