Spring Batch 마스터 노트 시리즈 2편. @EnableBatchProcessing부터 JobBuilderFactory·StepBuilderFactory, Tasklet 람다·인터페이스 구현, Chunk Step 빌드, REST API로 Job 실행, JobParameters 활용, 조건부 흐름의 on/to/from/end 패턴, 커스텀 ExitStatus와 JobExecutionDecider까지 코드와 함께.
이 글은 Spring Batch 마스터 노트 시리즈의 두 번째 편입니다. 1편(입문)에서 모델을 잡았다면, 이번엔 그 모델을 실제 코드로 빚어내는 자리예요.
@EnableBatchProcessing 한 줄로 시작해서 Tasklet·Chunk Step을 만들고, REST API로 Job을 실행하고, 조건부 흐름까지 — Spring Batch 4.x 기준의 본격 코드입니다. 5.x 마이그레이션은 8편에서 따로 다뤄요.
처음 Job 설정이 어렵게 느껴지는 이유
이유는 두 가지예요.
첫째, 빌더 메서드가 많고 순서에 의미가 있습니다. .start() → .next() → .on() → .to() → .from() → .end() — 메서드 체인이 길어지면 어디서 흐름이 분기되는지 한눈에 안 들어와요. 빠뜨리면 컴파일 오류는 안 나는데 런타임에 이상한 동작.
둘째, JobParameters와 @StepScope의 관계가 헷갈립니다. 동적으로 파라미터를 받아 Step을 구성하려면 @StepScope + @Value("#{jobParameters['xxx']}") 조합이 필요한데, 이 패턴이 처음엔 어디서 어떻게 동작하는지 안 보여요.
해결법은 한 가지예요. Job 설정 = "흐름(start/next/on/to/from)을 그래프로 먼저 그리고 그 그래프를 코드로 옮기는 것" 으로 접근하세요. 종이에 Step 박스 그리고 화살표 그리면 코드가 자연스럽게 나옵니다.
@EnableBatchProcessing — Spring Batch 4.x의 시작
@Configuration
@EnableBatchProcessing // Spring Batch 4.x
public class BatchConfiguration {
@Autowired
private JobBuilderFactory jobBuilderFactory;
@Autowired
private StepBuilderFactory stepBuilderFactory;
// Job과 Step 빈 정의...
}
이 어노테이션이 자동 제공하는 핵심 빈:
JobBuilderFactoryStepBuilderFactoryJobLauncherJobRepositoryJobExplorerPlatformTransactionManager
여기서 시험 함정이 하나 있어요. Spring Batch 5.x에서는 @EnableBatchProcessing을 제거해야 합니다. Spring Boot 자동 설정으로 대체됐어요. 8편에서 자세히.
JobBuilderFactory와 StepBuilderFactory
// JobBuilderFactory — Job 빈 생성
@Bean
public Job myJob() {
return jobBuilderFactory.get("jobName")
.start(firstStep())
.next(secondStep())
.build();
}
// StepBuilderFactory — Step 빈 생성
@Bean
public Step myStep() {
return stepBuilderFactory.get("stepName")
.tasklet(...)
.build();
}
get(String name) 으로 빌더를 얻고, 메서드 체이닝으로 설정.
여기서 시험 함정이 하나 있어요. Job/Step 이름은 DB의 BATCH_JOB_INSTANCE에 저장됩니다. 이름을 변경하면 이전 실행 이력과 연결이 끊겨요. 운영에서 이름 변경에 주의 — 명명 규칙을 일찍 정하고 일관되게.
Tasklet 기반 Step — 두 가지 구현
인라인 람다
가장 단순. 간단한 로직에 적합.
@Bean
public Step firstStep() {
return stepBuilderFactory.get("step one")
.tasklet((contribution, chunkContext) -> {
System.out.println("첫 번째 Step 실행 중...");
return RepeatStatus.FINISHED;
})
.build();
}
인터페이스 구현 클래스
복잡한 로직, DI 필요할 때.
@Component
public class FileCleanupTasklet implements Tasklet {
private final String outputPath;
public FileCleanupTasklet(@Value("${batch.output.path}") String outputPath) {
this.outputPath = outputPath;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
File outputDir = new File(outputPath);
if (outputDir.exists()) {
for (File file : outputDir.listFiles()) {
file.delete();
}
}
return RepeatStatus.FINISHED;
}
}
테스트 작성·재사용 면에서 인터페이스 구현이 더 유연해요.
Chunk 기반 Step — 대량 데이터의 핵심
@Bean
public Step chunkStep() {
return stepBuilderFactory.get("chunkStep")
.<Product, Product>chunk(10) // 청크 크기 10
.reader(itemReader()) // 필수
.processor(itemProcessor()) // 선택사항
.writer(itemWriter()) // 필수
.build();
}
<I, O>chunk(size) — I는 입력 타입, O는 출력 타입. 둘이 다르면 ItemProcessor가 변환.
청크 크기 선택 가이드
| 청크 크기 | 트랜잭션 수 | 메모리 | 실패 시 재처리 | 권장 상황 |
|---|---|---|---|---|
| 1 | 매우 많음 | 최소 | 1개 | 트랜잭션 세밀 제어 |
| 10 | 많음 | 낮음 | 최대 10개 | 일반 배치 |
| 100 | 보통 | 중간 | 최대 100개 | 대량 처리 권장 |
| 1000 | 적음 | 높음 | 최대 1000개 | 단순 대량 INSERT |
| 10000+ | 매우 적음 | 매우 높음 | 매우 넓음 | 주의 |
여기서 시험 함정이 하나 있어요. 청크 안에서 예외 발생 시 청크 전체가 롤백됩니다. 99개가 성공했어도 1개 실패면 100개 모두 롤백. 7편(오류 처리) Skip 기능으로 해결.
Job 기본 설정
단순 순차 Job
@Bean
public Job sequentialJob() {
return jobBuilderFactory.get("sequentialJob")
.start(step1())
.next(step2())
.next(step3())
.build();
}
이전 Step이 실패하면 다음 Step은 실행되지 않고 Job이 FAILED.
RunIdIncrementer — 반복 실행 가능
@Bean
public Job myJob() {
return jobBuilderFactory.get("myJob")
.incrementer(new RunIdIncrementer()) // 자동 run.id 증가
.start(myStep())
.build();
}
매번 다른 JobInstance를 생성. 개발/테스트에 필수.
preventRestart() — 재시작 방지
@Bean
public Job nonRestartableJob() {
return jobBuilderFactory.get("nonRestartableJob")
.preventRestart()
.start(myStep())
.build();
}
멱등성이 없는 Job, "오늘 한 번만" 실행해야 하는 Job에 사용.
REST API로 Job 실행
자동 실행 비활성화
# application.properties
spring.batch.job.enabled=false
이거 안 끄면 앱 시작할 때마다 Job이 자동 실행됩니다.
Job 실행 컨트롤러
@RestController
public class JobController {
@Autowired
private JobLauncher jobLauncher;
@Autowired
@Qualifier("myJob")
private Job myJob;
@GetMapping("/run-job")
public String runJob() {
try {
JobParameters params = new JobParametersBuilder()
.addLong("startedAt", System.currentTimeMillis())
.toJobParameters();
JobExecution jobExecution = jobLauncher.run(myJob, params);
return "Job Status: " + jobExecution.getStatus();
} catch (Exception e) {
return "Job Failed: " + e.getMessage();
}
}
@GetMapping("/run-job-with-param")
public String runJobWithParam(@RequestParam String reportDate) {
try {
JobParameters params = new JobParametersBuilder()
.addString("reportDate", reportDate)
.addLong("startedAt", System.currentTimeMillis())
.toJobParameters();
JobExecution jobExecution = jobLauncher.run(myJob, params);
return "Job executed: " + jobExecution.getStatus();
} catch (Exception e) {
return "Error: " + e.getMessage();
}
}
}
@Qualifier — 여러 Job이 있을 때
@Autowired
@Qualifier("reportJob")
private Job reportJob;
@Autowired
@Qualifier("etlJob")
private Job etlJob;
@Autowired 단독으로는 NoUniqueBeanDefinitionException이 떠요.
JobParameters 상세 활용
파라미터 타입
JobParameters params = new JobParametersBuilder()
.addString("reportType", "DAILY")
.addLong("processCount", 1000L)
.addDouble("threshold", 0.85)
.addDate("reportDate", new Date())
.toJobParameters();
Step 내에서 접근
// Tasklet에서
.tasklet((contribution, chunkContext) -> {
String reportType = chunkContext
.getStepContext()
.getJobParameters()
.get("reportType")
.toString();
return RepeatStatus.FINISHED;
})
// StepExecutionListener에서
public void beforeStep(StepExecution stepExecution) {
String reportType = stepExecution
.getJobParameters()
.getString("reportType");
}
Job Flow — 순차 흐름
@Bean
public Job sequentialJob() {
return jobBuilderFactory.get("sequentialJob")
.start(validateStep())
.next(processStep())
.next(cleanupStep())
.build();
}
가장 단순. 한 Step 실패 → Job 즉시 FAILED.
Job Flow — 조건부 흐름
on/to/from 패턴
@Bean
public Job conditionalJob() {
return jobBuilderFactory.get("conditionalJob")
.start(step1())
.on("COMPLETED").to(step2()) // step1 완료 → step2
.on("FAILED").to(step3()) // step1 실패 → step3
.from(step2())
.on("COMPLETED").end() // step2 완료 → Job 완료
.from(step3())
.on("*").fail() // step3 결과 무관 → Job 실패
.end()
.build();
}
| 메서드 | 역할 |
|---|---|
.on("EXIT_CODE") |
Exit Status 조건 (* = 모든 상태) |
.to(step) |
조건 충족 시 다음 Step |
.from(step) |
특정 Step에서 분기 추가 |
.end() |
Job COMPLETED 상태 종료 |
.fail() |
Job FAILED 상태 종료 |
.stopAndRestart(step) |
STOPPED 상태, 재시작 시 지정 Step부터 |
패턴 매칭
"COMPLETED"— 정확 매칭"*"— 모든 문자열 (와일드카드)"CUSTOM_*"— 접두사 매칭"?OMPLETED"—?단일 문자
여기서 정말 중요한 시험 함정 — .end() 호출 위치가 관건입니다. 조건부 흐름에서 from()/on()/to() 체인의 끝이 아니라, build() 직전에 .end()를 호출. 빠뜨리면 런타임 JobBuilderException.
ABANDONED 상태 처리
@Bean
public Job abandonedJob() {
return jobBuilderFactory.get("abandonedJob")
.start(step1())
.on("FAILED").to(step2())
.from(step2())
.on("COMPLETED").to(step3())
.on("FAILED").stopAndRestart(step3())
.end()
.build();
}
FAILED 와 ABANDONED 의 차이:
| 상태 | 재시작 시 동작 |
|---|---|
| FAILED | 해당 Step 다시 실행 |
| ABANDONED | 해당 Step 건너뛰고 다음 Step부터 |
부분 실패 후 해당 Step을 다시 실행하면 안 되는 자리에서 사용.
커스텀 ExitStatus
기본 ExitStatus 외 커스텀 상태로 세밀한 흐름 제어.
StepExecutionListener 구현
@Component
public class ProductStepListener implements StepExecutionListener {
@Override
public void beforeStep(StepExecution stepExecution) {
System.out.println("Step 시작: " + stepExecution.getStepName());
}
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
if (stepExecution.getWriteCount() == 0) {
return new ExitStatus("NO_DATA");
} else if (stepExecution.getWriteCount() < 100) {
return new ExitStatus("FEW_DATA");
}
return ExitStatus.COMPLETED;
}
}
커스텀 ExitStatus를 활용한 Flow
@Bean
public Step productStep() {
return stepBuilderFactory.get("productStep")
.<Product, Product>chunk(10)
.reader(reader())
.writer(writer())
.listener(productStepListener())
.build();
}
@Bean
public Job productJob() {
return jobBuilderFactory.get("productJob")
.start(productStep())
.on("NO_DATA").end()
.on("FEW_DATA").to(notificationStep())
.on("COMPLETED").to(reportStep())
.from(notificationStep())
.on("*").end()
.from(reportStep())
.on("*").end()
.end()
.build();
}
JobExecutionDecider — 동적 흐름 결정
Step의 Exit Status와 무관하게 런타임에 동적으로 흐름 결정.
@Component
public class ProductDecider implements JobExecutionDecider {
@Override
public FlowExecutionStatus decide(JobExecution jobExecution, StepExecution stepExecution) {
String source = jobExecution.getExecutionContext().getString("dataSource", "");
if (source.equals("DB")) {
return new FlowExecutionStatus("DB_SOURCE");
} else if (source.equals("FILE")) {
return new FlowExecutionStatus("FILE_SOURCE");
}
return new FlowExecutionStatus("UNKNOWN_SOURCE");
}
}
@Bean
public Job deciderJob() {
return jobBuilderFactory.get("deciderJob")
.start(initStep())
.next(productDecider())
.on("DB_SOURCE").to(dbProcessStep())
.on("FILE_SOURCE").to(fileProcessStep())
.on("UNKNOWN_SOURCE").fail()
.from(dbProcessStep())
.on("*").end()
.from(fileProcessStep())
.on("*").end()
.end()
.build();
}
ExecutionContext의 값 읽어 동적 분기 가능. StepExecutionListener보다 더 유연.
FlowBuilder — 재사용 가능한 Flow
같은 Step 시퀀스를 여러 Job에서 재사용.
@Bean
public Flow validationFlow() {
return new FlowBuilder<Flow>("validationFlow")
.start(validateFormatStep())
.next(validateRangeStep())
.next(validateBusinessStep())
.end();
}
// Job A
@Bean
public Job jobA() {
return jobBuilderFactory.get("jobA")
.start(validationFlow())
.next(processStep())
.end()
.build();
}
// Job B — 동일 Flow 재사용
@Bean
public Job jobB() {
return jobBuilderFactory.get("jobB")
.start(validationFlow())
.next(reportStep())
.end()
.build();
}
Nested Job — JobStep 패턴
큰 배치를 여러 독립 Job으로 분리.
@Bean
public Step jobOneStep(Job childJob) {
return stepBuilderFactory.get("jobOneStep")
.job(childJob)
.parametersExtractor(new DefaultJobParametersExtractor())
.build();
}
@Bean
public Job parentJob() {
return jobBuilderFactory.get("parentJob")
.start(jobOneStep(childJobA()))
.next(jobOneStep(childJobB()))
.build();
}
각 자식 Job은 독립 JobInstance/JobExecution. 자체 재시작 가능.
Parallel Steps — 병렬 실행
@Bean
public Job parallelJob() {
return jobBuilderFactory.get("parallelJob")
.start(splitFlow())
.end()
.build();
}
@Bean
public Flow splitFlow() {
return new FlowBuilder<SimpleFlow>("splitFlow")
.split(new SimpleAsyncTaskExecutor())
.add(flow1(), flow2(), flow3())
.build();
}
@Bean
public Flow flow1() {
return new FlowBuilder<SimpleFlow>("flow1")
.start(step1())
.build();
}
여기서 시험 함정이 하나 있어요. 병렬 처리에서 JdbcCursorItemReader 사용 금지. thread-safe 아님. JdbcPagingItemReader 사용. 4편에서 자세히.
종합 예시
@Configuration
@EnableBatchProcessing
public class BatchConfiguration {
@Autowired private JobBuilderFactory jobBuilderFactory;
@Autowired private StepBuilderFactory stepBuilderFactory;
@Autowired private DataSource dataSource;
@Bean
public Step validateStep() {
return stepBuilderFactory.get("validateStep")
.tasklet((contribution, chunkContext) -> {
System.out.println("데이터 검증 중...");
return RepeatStatus.FINISHED;
})
.build();
}
@Bean
public Step processStep() {
return stepBuilderFactory.get("processStep")
.<Product, Product>chunk(10)
.reader(csvReader())
.processor(productProcessor())
.writer(dbWriter())
.build();
}
@Bean
public Step reportStep() {
return stepBuilderFactory.get("reportStep")
.tasklet((contribution, chunkContext) -> {
System.out.println("보고서 생성 중...");
return RepeatStatus.FINISHED;
})
.build();
}
@Bean
public Job fullProcessingJob() {
return jobBuilderFactory.get("fullProcessingJob")
.incrementer(new RunIdIncrementer())
.start(validateStep())
.on("COMPLETED").to(processStep())
.on("FAILED").fail()
.from(processStep())
.on("COMPLETED").to(reportStep())
.on("FAILED").fail()
.from(reportStep())
.on("*").end()
.end()
.build();
}
}
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 2편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
@EnableBatchProcessing= Spring Batch 4.x의 시작- 자동 제공 빈 — JobBuilderFactory / StepBuilderFactory / JobLauncher / JobRepository
- 5.x에서는
@EnableBatchProcessing제거 (8편) JobBuilderFactory.get("name")/StepBuilderFactory.get("name")패턴- Job/Step 이름 변경 시 이전 이력과 연결 끊김 — 운영 주의
- Tasklet — 인라인 람다 vs 인터페이스 구현
- 람다 — 단순 로직 / 인터페이스 — DI·테스트 유리
RepeatStatus.FINISHED반환이 표준- Chunk Step —
<I, O>chunk(size).reader().processor().writer() - 청크 크기 일반적으로 10~1000, 시작 100 권장
- 청크 내 예외 = 청크 전체 롤백 (7편 Skip으로 해결)
RunIdIncrementer= 매번 새 JobInstance (개발 필수)preventRestart()= 재시작 방지 (멱등 X 작업)- REST API 실행 =
spring.batch.job.enabled=false필수 @Qualifier= 여러 Job 중 특정 Job 명시- JobParameters 타입 — String / Long / Double / Date
- 조건부 흐름 5메서드 — on / to / from / end / fail
- 패턴 매칭 —
"*"와일드카드,"CUSTOM_*"접두사 .end()는 build() 직전 — 빠뜨리면 런타임 예외- ABANDONED — 재시작 시 건너뜀 (FAILED는 재실행)
- 커스텀 ExitStatus —
new ExitStatus("CUSTOM_CODE") - StepExecutionListener.afterStep() = 커스텀 ExitStatus 반환
- JobExecutionDecider = 동적 흐름 결정 (ExecutionContext 활용)
- FlowBuilder = 재사용 가능한 Flow
- Nested Job = JobStep으로 자식 Job 실행
- Parallel Steps =
split()+SimpleAsyncTaskExecutor - 병렬 처리 시 JdbcCursorItemReader 금지 (thread-safe X)
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 톤으로 묶어 정리되어 있어요. 2편 Job 설정이 잡히면 3편 청크 처리에서 ItemReader/Processor/Writer 패턴이 자연스럽게 따라옵니다.
- 1편 — Spring Batch 입문 (Job·Step·Chunk 모델)
- 2편 — Spring Batch Job 설정 (현재 글)
- 3편 — 청크 처리 (Reader·Processor·Writer)
- 4편 — ItemReader 마스터 (CSV·JdbcCursor·Paging)
- 5편 — ItemWriter 마스터 (FlatFile·JdbcBatch·Composite)
- 6편 — Job Flow와 리스너
- 7편 — 오류 처리 (Skip·Retry·SkipPolicy)
- 8편 — Spring Batch 5 마이그레이션
공식 문서: Spring Batch JobBuilder API에서 모든 빌더 메서드의 시그니처를 확인할 수 있어요.
다음 글(3편)에서는 청크 처리의 본격 — ItemReader/ItemProcessor/ItemWriter 인터페이스, 변환·필터링·검증 패턴, CompositeItemProcessor, 데이터 흐름 상세를 풀어 갑니다.