Spring Batch Job Flow는 어떻게 실행을 제어하는가
SimpleJob의 순차 실행부터 Conditional Flow, JobExecutionDecider, 병렬 Split, Flow 외부화, Listener까지 — Spring Batch가 배치 단계를 제어하는 일관된 설계를 추적한다.
- 01 Spring Batch는 왜 Job·Step·Tasklet으로 나누는가
- 02 Spring Batch의 Chunk는 왜 Read→Process→Write인가
- 03 Spring Batch Job Flow는 어떻게 실행을 제어하는가
- 04 Spring Batch는 실패한 1건을 어떻게 격리하는가
- 05 Spring Batch Partitioning은 어떻게 1,000만 건을 나누는가
- 06 Spring Batch의 병렬 처리는 어떻게 설계되는가
Spring Batch에서 배치 단계는 순서대로 실행된다. 그런데 왜 앞 Step이 실패하면 뒤 Step이 자동으로 차단되는가? 그리고 “실패해도 계속 진행해야 하는” 시나리오는 어떻게 설계하는가? 이 두 질문이 Spring Batch의 Job Flow 전체를 관통하는 핵심이다.
순차 실행의 출발점: SimpleJob
SimpleJob은 Step 목록을 순서대로 실행하되, 한 Step이 COMPLETED로 끝나야만 다음 Step으로 넘어간다. doExecute() 내부는 단순하다 — 이전 Step의 BatchStatus를 확인하고, COMPLETED가 아니면 즉시 반환한다.
for (Step step : steps) {
if (stepExecution != null
&& stepExecution.getStatus() != BatchStatus.COMPLETED) {
execution.upgradeStatus(stepExecution.getStatus());
return; // 나머지 Step 실행 안 함
}
stepExecution = handleStep(step, execution);
}
이 설계는 “배치 단계는 의존 관계가 있다”는 전제에서 출발한다. FTP 다운로드가 실패하면 CSV 파싱이 불가능하고, CSV 파싱이 실패하면 정산 계산은 잘못된 결과를 낸다. 앞 Step 실패 시 즉시 종료하는 것은 부분 성공 상태에서 잘못된 데이터가 흘러 내려가는 사고를 막는 안전장치다.
재시작도 같은 원칙을 따른다. COMPLETED 상태의 Step은 기본적으로 건너뛰고, FAILED나 STOPPED Step부터 새 StepExecution으로 재실행한다. 완료된 Step을 매 재시작마다 다시 실행해야 한다면 allowStartIfComplete(true)를 설정한다 — 임시 파일 삭제처럼 멱등한 Step에만 적용하는 것이 원칙이다.
조건 분기: FlowJob과 ExitStatus
순차 실행으로는 “실패해도 후속 Step을 실행”하는 시나리오를 처리할 수 없다. 검증 Step이 실패해도 에러 알림 Step은 반드시 실행해야 하는 경우가 그렇다. FlowJob은 ExitStatus 패턴 매칭으로 이 분기를 표현한다.
jobBuilderFactory.get("conditionalJob")
.start(validationStep())
.on("COMPLETED").to(processStep())
.on("PARTIAL_*").to(partialStep()) // 와일드카드
.on("FAILED").fail()
.from(processStep())
.on("*").to(reportStep())
.from(reportStep()).on("*").end()
.end()
.build();
.on()이 비교하는 것은 ExitStatus.exitCode다. BatchStatus(프레임워크 상태 머신)와 ExitStatus(비즈니스 결과 코드)는 다르다. BatchStatus는 STARTING → STARTED → COMPLETED/FAILED/STOPPED의 내부 상태이고, ExitStatus는 Step이 끝났을 때 “어떤 결과였는가”를 나타내는 사용자 정의 코드다. on("FAILED")는 ExitStatus.exitCode == "FAILED"를 매칭한다.
패턴 우선순위는 구체성으로 결정된다. 정확 일치가 와일드카드보다 먼저 매칭되고, "*" 단독이 가장 낮은 우선순위다. 종료 전이는 세 가지다: .end()는 Job COMPLETED, .fail()은 Job FAILED(재시작 가능), .stopAndRestart(step)은 Job STOPPED(지정 Step부터 재시작).
FlowJob은 복잡한 분기를 표현할 수 있지만 .from().on().to() 체인이 길어질수록 가독성이 떨어진다. 모든 분기 경로의 최종 노드에 종료 선언(.end() / .fail())이 없으면 런타임에 JobBuilderException이 발생한다. 분기 수에 비례해 테스트 케이스 수도 증가한다.
런타임 조건 분기: JobExecutionDecider
ExitStatus는 Step 실행 결과로만 결정된다. “오늘이 월말인가?”, “파일이 존재하는가?” 같은 외부 조건을 Step Tasklet 내부에서 ExitStatus로 표현하면 처리 로직과 흐름 제어 로직이 섞인다. JobExecutionDecider는 이 흐름 결정을 독립 컴포넌트로 분리한다.
@Component
public class CalendarDecider implements JobExecutionDecider {
@Override
public FlowExecutionStatus decide(JobExecution jobExecution,
StepExecution stepExecution) {
LocalDate today = LocalDate.now();
if (today.equals(today.with(TemporalAdjusters.lastDayOfMonth()))) {
return new FlowExecutionStatus("MONTH_END");
}
return FlowExecutionStatus.COMPLETED;
}
}
Decider는 DecisionState로 Flow 그래프에 삽입된다. StepExecution을 생성하지 않으므로 BATCH_STEP_EXECUTION에 행이 남지 않는다. 두 번째 파라미터 StepExecution은 직전에 실행된 Step의 결과다 — getWriteCount()나 ExecutionContext로 처리 결과 기반 분기가 가능하다. Decider가 첫 번째 노드면 StepExecution이 null이므로 null 방어 코드가 필요하다.
병렬 실행: Split
독립적인 집계 작업을 순차로 실행하면 시간 낭비다. 이메일·SMS·쿠폰 집계가 서로 의존하지 않는다면 병렬로 실행할 수 있다. split(TaskExecutor)가 이를 처리한다.
Job 시작 → initStep (순차)
→ [emailFlow ‖ smsFlow ‖ couponFlow] (병렬)
→ aggregateStep (모든 Branch 완료 후)
→ Job 종료
SplitState는 각 Flow를 Callable로 제출하고 모든 Future.get()이 완료될 때까지 대기한다. MaxValueFlowExecutionAggregator가 Branch 결과를 집계하는데, FAILED > STOPPED > COMPLETED 우선순위로 하나라도 FAILED면 전체가 FAILED다. 이미 시작된 Branch는 다른 Branch가 실패해도 완료까지 실행되므로, Branch 간 공유 자원 충돌과 독립성이 중요하다.
Split과 Partitioning의 차이는 명확하다. Split은 이종 작업의 병렬화이고, Partitioning은 동종 작업에 데이터를 나눠 병렬화하는 방식이다.
재사용과 Listener
여러 Job에서 공통 에러 처리 패턴이 반복되면 Flow Bean으로 외부화해 재사용한다. FlowStep은 Flow를 하나의 Step처럼 다루어 Job 설정을 간결하게 만든다. 공통 Flow 내 Step에 allowStartIfComplete(true)를 설정하지 않으면 재시작 시 에러 알림이 발송되지 않는 문제가 생긴다.
StepExecutionListener.afterStep()은 Step의 최종 ExitStatus를 변경하는 훅이다. 처리 건수가 0이면 ExitStatus("NO_DATA")를 반환해 후속 Flow를 분기시키는 패턴이 대표적이다. afterJob()은 알림/모니터링 목적으로만 사용해야 한다 — 이 시점에는 이미 JobRepository.update()가 완료돼 ExitStatus 변경이 DB에 반영되지 않는다.
// afterStep()에서 Job EC에 저장 → 다음 Step에서 활용
stepExecution.getJobExecution().getExecutionContext()
.putLong("step1.processedCount", stepExecution.getWriteCount());
정리
SimpleJob은 앞 Step이COMPLETED여야 다음 Step을 실행한다. 실패 시 즉시 차단이 기본이며, 이는 데이터 의존성 보호를 위한 설계다.FlowJob의.on()은ExitStatus.exitCode를 패턴 매칭한다.BatchStatus와 혼동하지 말 것.JobExecutionDecider는 Step 처리 결과가 아닌 외부 조건으로 분기할 때 사용한다.StepExecution을 생성하지 않으므로 DB에 기록이 남지 않는다.- Split은 독립적인 이종 작업을 병렬화하며, Branch 하나라도 실패하면 전체 Job이 실패한다.
afterStep()의 반환값이 Step의 최종 ExitStatus를 결정한다.afterJob()은 DB 반영이 없으므로 상태 변경이 아닌 알림/감사 목적으로만 사용한다.
다음 글에서는 Step 내부로 들어가, Chunk 처리 중 오류가 발생했을 때 Skip과 Retry가 어떤 기준으로 동작하는지 추적한다.