Spring Batch 입문 20편. Job 의 Step Flow 제어 — sequential / conditional / decision / split / stop & restart 다섯 패턴. ExitStatus 와 BatchStatus 차이, on() 와일드카드, JobExecutionDecider, JobStep 까지 정리한 학습 노트.
이 글은 Spring Batch 입문에서 운영까지 시리즈 48편 중 20편이에요. 19편 의 TaskletStep 까지 Step 1개 만들기 에 집중했다면, 이번 20편은 여러 Step 을 어떻게 엮는가 — Flow Control.
Flow Control 의 5가지 패턴
| 패턴 | 한 줄 설명 |
|---|---|
| Sequential | step A → B → C 순차 |
| Conditional | A 결과에 따라 B 또는 C 분기 |
| Decision | 코드 로직으로 분기 (JobExecutionDecider) |
| Split | 여러 Flow 병렬 실행 |
| Stop & Restart | 중간에 멈추고 재시작 시 이어서 |
전부 Job 설계 레벨 의 도구예요. 한 Step 안 로직이 아니라 Step 들의 흐름 을 결정합니다.
Sequential Flow — 가장 단순한 흐름
@Bean
public Job job(JobRepository repo, Step stepA, Step stepB, Step stepC) {
return new JobBuilder("job", repo)
.start(stepA)
.next(stepB)
.next(stepC)
.build();
}
규칙은 단순해요. stepA 가 COMPLETED 면 stepB 가 실행되고, FAILED 면 Job 전체가 FAILED 로 끝나면서 stepB 는 돌지 않습니다.
.next() 만 쓰면 성공/실패 분기 X — 모든 실패는 곧 Job 실패입니다.
Conditional Flow — 결과에 따라 분기
@Bean
public Job job(JobRepository repo, Step stepA, Step stepB, Step stepC) {
return new JobBuilder("job", repo)
.start(stepA)
.on("*").to(stepB)
.from(stepA)
.on("FAILED").to(stepC)
.end()
.build();
}
읽어보면 stepA 의 ExitStatus(Step 종료 후 결정되는 상태 문자열) 가 FAILED 면 stepC 로, 그 외 모든 경우 (*) 는 stepB 로 갑니다.
프레임워크가 자동으로 specific → general 순으로 정렬 해줘서, * 와 FAILED 의 순서를 반대로 써도 FAILED 가 우선 매칭됩니다.
on() pattern 매칭 규칙
| 문자 | 의미 |
|---|---|
* |
0개 이상의 문자 |
? |
정확히 1개 문자 |
예제로 보면 c*t 는 cat·count 둘 다 매치되고, c?t 는 cat 만 매치되고 count 는 안 됩니다. COMPLETED* 는 COMPLETED 와 COMPLETED WITH SKIPS 를 모두 잡아냅니다.
ExitStatus 가 모든 가능성을 커버하지 못하면
→ 프레임워크가 예외 throw + Job FAILED. 모든 경로 커버 필수 입니다.
대체로 .on("*").to(...) 한 줄을 기본값 으로 박아두는 게 안전합니다.
BatchStatus vs ExitStatus — 가장 자주 헷갈리는 자리
여기서 시험 함정이 하나 있어요.
.from(stepA).on("FAILED").to(stepC)
on() 이 가리키는 게 BatchStatus 일까 ExitStatus 일까?
답은 ExitStatus.exitCode 입니다.
두 status 의 차이
| 항목 | BatchStatus | ExitStatus |
|---|---|---|
| 타입 | enum | String code (커스텀 가능) |
| 값 | COMPLETED, STARTING, STARTED, STOPPING, STOPPED, FAILED, ABANDONED, UNKNOWN | "COMPLETED", "FAILED", "COMPLETED WITH SKIPS" 등 (자유) |
| 결정 | 프레임워크 내부 | 코드·Listener 로 변경 가능 |
| Flow Control 매칭 | X | ✓ |
기본 상태에서는 ExitStatus.exitCode 가 BatchStatus 와 같습니다. 그래서 on("FAILED") 가 그대로 작동해요.
다만 18편에서 다룬 StepExecutionListener(Step 시작·종료 시점에 끼어드는 콜백) 의 afterStep 으로 ExitStatus 를 동적으로 바꿀 수 있고, 그러면 새 exitCode 가 on() 매칭 키가 됩니다.
예제 — Skip 발생 시 별도 분기
18편의 SkipCheckingListener 패턴 + Flow Control:
public class SkipCheckingListener implements StepExecutionListener {
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
String exitCode = stepExecution.getExitStatus().getExitCode();
if (!exitCode.equals(ExitStatus.FAILED.getExitCode())
&& stepExecution.getSkipCount() > 0) {
return new ExitStatus("COMPLETED WITH SKIPS");
}
return null; // 변경 안 함
}
}
.start(step1).on("FAILED").end()
.from(step1).on("COMPLETED WITH SKIPS").to(errorPrint1)
.from(step1).on("*").to(step2)
.end()
→ skip 발생 시 별도 errorPrint1 Step 으로 분기합니다.
Job 종료 — end · fail · stopAndRestart
.on() 의 destination 자리에 세 가지 종료 방식을 골라 넣을 수 있어요.
end() — COMPLETED 로 종료
.start(step1)
.next(step2).on("FAILED").end()
.from(step2).on("*").to(step3)
.end()
.build();
step2 가 FAILED 면 Job 은 BatchStatus COMPLETED + ExitStatus COMPLETED 로 끝납니다.
중요 — end() 로 종료된 Job 은 재시작이 안 됩니다. 다시 launch 하면 JobInstanceAlreadyCompleteException(이미 완료된 JobInstance 재실행 예외) 가 터져요.
end() 의 인자로 custom ExitStatus 도 줄 수 있어요. 예를 들면 .end("PARTIALLY_COMPLETED").
fail() — FAILED 로 종료
.start(step1)
.next(step2).on("FAILED").fail()
.from(step2).on("*").to(step3)
.end()
.build();
step2 가 FAILED 면 Job 은 BatchStatus FAILED + ExitStatus FAILED (또는 custom) 로 끝납니다.
중요 — fail() 로 종료된 Job 은 재시작이 됩니다. 같은 JobInstance 로 재실행하면 step2 부터 재개 돼요.
stopAndRestart(step) — STOPPED 로 종료
.start(step1).on("COMPLETED").stopAndRestart(step2)
.end()
.build();
step1 이 COMPLETED 면 Job 이 BatchStatus STOPPED 로 멈추고, 운영자가 수동 개입 한 뒤 재시작 할 수 있어요. 재시작 시점에는 step2 부터 실행됩니다.
세 종료 방식 한 줄 비교:
| 종료 | BatchStatus | 재시작 가능? | 사용 케이스 |
|---|---|---|---|
end() |
COMPLETED | X | 정상 종료 (다시 돌리면 안 됨) |
fail() |
FAILED | ✓ | 비정상 종료, 다시 시도 가능 |
stopAndRestart() |
STOPPED | ✓ | 의도적 일시 정지, 수동 개입 |
Programmatic Decision — JobExecutionDecider
ExitStatus 만으로 부족한 분기 — 예를 들면 외부 데이터 상태에 따라 다음 Step 을 결정해야 하는 케이스 — 가 있습니다. 이때 쓰는 게 JobExecutionDecider(Step 결과와 별개로 코드 로직으로 다음 Flow 를 정하는 인터페이스) 예요.
public class MyDecider implements JobExecutionDecider {
@Override
public FlowExecutionStatus decide(JobExecution jobExecution,
StepExecution stepExecution) {
if (someCondition()) {
return new FlowExecutionStatus("FAILED");
}
return new FlowExecutionStatus("COMPLETED");
}
}
FlowExecutionStatus(Flow 분기용 상태 객체) 를 return 하는 게 핵심입니다.
사용
@Bean
public Job job(JobRepository repo, MyDecider decider,
Step step1, Step step2, Step step3) {
return new JobBuilder("job", repo)
.start(step1)
.next(decider).on("FAILED").to(step2)
.from(decider).on("COMPLETED").to(step3)
.end()
.build();
}
.next(decider) 로 Step 자리에 decider 를 두면, decider 가 돌려준 FlowExecutionStatus 가 on() 매칭 키가 됩니다.
Decider 활용 케이스
- 외부 시스템 상태 (예: 파일 존재 여부) 에 따라 분기
- 날짜·요일 분기 (예: 주말이면 Step skip)
- 이전 Step 의 ExecutionContext(Step·Job 단위로 값을 주고받는 컨텍스트) 값 으로 분기
- 환경 변수·flag 분기
stepExecution 인자는 직전 Step 의 StepExecution 이라서 ExecutionContext 도 그대로 들여다볼 수 있어요.
Split — 병렬 Flow
@Bean
public Flow flow1(Step step1, Step step2) {
return new FlowBuilder<SimpleFlow>("flow1")
.start(step1)
.next(step2)
.build();
}
@Bean
public Flow flow2(Step step3) {
return new FlowBuilder<SimpleFlow>("flow2")
.start(step3)
.build();
}
@Bean
public Job job(JobRepository repo, Flow flow1, Flow flow2, Step step4) {
return new JobBuilder("job", repo)
.start(flow1)
.split(new SimpleAsyncTaskExecutor())
.add(flow2)
.next(step4)
.end()
.build();
}
이 구조에서는 flow1 (step1→step2) 과 flow2 (step3) 가 병렬로 실행 되고, 두 Flow 가 모두 끝나야 step4 로 넘어갑니다. TaskExecutor 가 각 Flow 를 다른 스레드 로 돌려요. 여기서 쓴 SimpleAsyncTaskExecutor 는 매 작업마다 새 스레드를 띄우는 가장 단순한 비동기 실행기입니다.
Split 의 함정
- 각 Flow 의 transaction 이 독립 — Flow 간 데이터 공유 X (또는 신중)
- 한 Flow 가 FAILED = Job FAILED (다른 Flow 도 종료 시도)
- TaskExecutor 가 동기 면 (
SyncTaskExecutor) 병렬 X — 반드시 async
Externalized Flow — 재사용 가능한 Flow 정의
@Bean
public Flow flow1(Step step1, Step step2) {
return new FlowBuilder<SimpleFlow>("flow1")
.start(step1)
.next(step2)
.build();
}
@Bean
public Job job(JobRepository repo, Flow flow1, Step step3) {
return new JobBuilder("job", repo)
.start(flow1) // Step 자리에 Flow
.next(step3)
.end()
.build();
}
Flow 를 별도 bean 으로 분리 해두면 여러 Job 에서 재사용할 수 있어요. Sub-Flow 모듈화 패턴 입니다.
JobStep — Job 안 Job
@Bean
public Step jobStepJobStep1(JobRepository repo, JobLauncher launcher,
Job innerJob, JobParametersExtractor extractor) {
return new StepBuilder("jobStepJobStep1", repo)
.job(innerJob)
.launcher(launcher)
.parametersExtractor(extractor)
.build();
}
@Bean
public Job jobStepJob(JobRepository repo, Step jobStepJobStep1) {
return new JobBuilder("jobStepJob", repo)
.start(jobStepJobStep1)
.build();
}
@Bean
public DefaultJobParametersExtractor jobParametersExtractor() {
DefaultJobParametersExtractor extractor = new DefaultJobParametersExtractor();
extractor.setKeys(new String[]{"input.file"});
return extractor;
}
JobStep 은 Step 안에서 또 다른 Job 을 새 JobExecution 으로 launch 하는 구조입니다. 이때 부모 Job 의 파라미터를 자식 Job 에 넘기는 다리가 JobParametersExtractor(부모 JobParameters 에서 자식이 쓸 키만 골라 전달하는 추출기) 예요.
JobStep vs Externalized Flow
| 항목 | Externalized Flow | JobStep |
|---|---|---|
| 실행 단위 | Step (현재 Job 안에서) | Job (독립 JobExecution) |
| JobRepository 기록 | 현재 Job 의 Step | 별도 Job 기록 |
| 모니터링 | Step 단위 | Job 단위 (granular) |
| Job 간 의존성 표현 | X | ✓ |
| JobParameters 전파 | 자동 | JobParametersExtractor 로 명시 |
JobStep 은 Job 간 dependency 를 표현하는 대표 패턴이에요. 대형 시스템을 작은 Job 모듈로 분해 할 때 유용합니다.
Step Bean Method Proxying — 의존성 주입 함정
Flow definition 안에서 같은 Step 인스턴스를 여러 번 참조 할 때 함정이 하나 있어요.
@Configuration
public class MyConfig {
@Bean
public Step stepA() { ... }
@Bean
public Job job() {
return new JobBuilder("job", repo)
.start(stepA())
.on("FAILED").to(stepA()) // 같은 Step?
// ...
.build();
}
}
@Configuration(proxyBeanMethods = false)(@Bean 메서드 호출 시 프록시로 캐시하지 않는 옵션) 로 설정돼 있으면 stepA() 호출이 매번 새 인스턴스 를 만듭니다. Flow 안에서 서로 다른 Step 으로 인식 돼서 예상 못 한 동작으로 이어져요.
해결 방법은 두 가지입니다.
proxyBeanMethods = true(default) 유지- Step 을 메서드 파라미터 로 주입 (권장)
@Bean
public Job job(JobRepository repo, Step stepA, Step stepB) { // 주입
return new JobBuilder("job", repo)
.start(stepA)
.next(stepB)
.build();
}
후자가 더 안전하고, 공식 문서에서도 이 방식을 권장합니다.
자주 만나는 사고
사고 1: ExitStatus 가 매칭 안 됨
원인 — Custom ExitStatus 설정 후 on() 패턴이 예전 값 그대로.
해결 — Listener afterStep 의 return 값과 on() pattern 이 동기화돼 있는지 확인.
사고 2: 모든 경로 안 커버
원인 — on("COMPLETED") 만 있고 on("*") 또는 on("FAILED") 누락.
해결 — 기본값 .on("*") 한 줄 박기.
사고 3: end() 후 재시작 불가
원인 — end() 가 BatchStatus COMPLETED 로 닫아서 JobInstance 가 이미 완료 상태.
해결 — 재시작 가능한 종료가 필요하면 fail() 또는 stopAndRestart().
사고 4: Split 안 데이터 공유
원인 — Flow 간 transaction 이 독립이라 한 Flow 의 commit 이 다른 Flow 에 가시화 안 됨.
해결 — Flow 간 공유 데이터는 DB 영구 저장 후 후속 Step 에서 read.
사고 5: Decider 가 호출 안 됨
원인 — .next(decider) 가 아닌 .next(step) 으로 잘못 작성.
해결 — decider 는 Step 자리에 위치 — .next(decider).
사고 6: SyncTaskExecutor 로 split
원인 — .split(new SyncTaskExecutor()) = 동기 실행 = 병렬 X.
해결 — SimpleAsyncTaskExecutor·ThreadPoolTaskExecutor(스레드 풀 기반 비동기 실행기).
운영 권장 패턴
Pattern 1: Skip 처리 분기
.start(mainStep).on("FAILED").fail()
.from(mainStep).on("COMPLETED WITH SKIPS").to(skipReportStep)
.from(mainStep).on("*").to(successReportStep)
.end()
skip 발생 시 별도 처리 Step, 성공 시 성공 리포트 로 갈라집니다.
Pattern 2: 조건부 데이터 처리
@Bean
public Job conditionalJob(JobRepository repo, JobExecutionDecider decider,
Step processStep, Step skipStep) {
return new JobBuilder("conditionalJob", repo)
.start(decider)
.on("PROCESS").to(processStep)
.from(decider).on("SKIP").to(skipStep)
.end()
.build();
}
decider 가 주말 / 휴일 여부를 판단해 Step 을 가릅니다.
Pattern 3: 병렬 + 동기 결합
.start(prepareStep)
.next(parallelFlow) // Split 으로 병렬
.split(executor).add(otherFlow)
.next(reportStep) // 병렬 후 단일 Step
.end()
준비 → 병렬 처리 → 통합 리포트 패턴이에요.
Pattern 4: 다단계 Job dependency
@Bean
public Step etlJobStep(JobRepository repo, JobLauncher launcher, Job etlJob) {
return new StepBuilder("etlJobStep", repo)
.job(etlJob)
.launcher(launcher)
.build();
}
@Bean
public Step reportJobStep(JobRepository repo, JobLauncher launcher, Job reportJob) {
return new StepBuilder("reportJobStep", repo)
.job(reportJob)
.launcher(launcher)
.build();
}
@Bean
public Job masterJob(JobRepository repo, Step etlJobStep, Step reportJobStep) {
return new JobBuilder("masterJob", repo)
.start(etlJobStep)
.next(reportJobStep)
.build();
}
ETL Job → Report Job 순서가 보장되고, 각 Job 을 독립적으로 모니터링할 수 있어요.
시험 직전 한 번 더 — Flow Control 함정 압축 노트
- Flow 패턴 5종 = Sequential · Conditional · Decision · Split · Stop&Restart
.next(step)= Sequential, FAILED 시 Job 즉시 종료.on(pattern).to(step)= Conditional, ExitStatus 기반on()매칭 키 = ExitStatus.exitCode (BatchStatus 아님)on()pattern =*(0+ 문자) ·?(1 문자)- 모든 경로 커버 필수 — 누락 시 예외 + Job FAILED
- 프레임워크가 자동 specific → general 정렬 (순서 무관)
- BatchStatus vs ExitStatus = enum vs String code, 후자가 Flow Control 매칭
- ExitStatus 변경 =
StepExecutionListener.afterStep의 return - 종료 3종 =
end()(COMPLETED, 재시작 X) ·fail()(FAILED, 재시작 ✓) ·stopAndRestart()(STOPPED, 수동 개입) end()의 인자 = custom ExitStatus- JobExecutionDecider = 코드 로직 분기,
decide(jobExec, stepExec)메서드,FlowExecutionStatusreturn .next(decider)로 Step 자리에 위치- decider 의 stepExecution = 직전 Step 참조
- Split = 병렬 Flow,
SimpleAsyncTaskExecutor·ThreadPoolTaskExecutor필수 - Split 의 Flow 간 transaction 독립 — 데이터 공유 X
- 모든 Flow 끝나야 다음 Step
- Externalized Flow =
FlowBuilder로 Flow bean 분리, 여러 Job 재사용 - JobStep = Step 자리에 다른 Job 새 JobExecution 실행,
JobParametersExtractor로 parameter 전파 - JobStep = 대표 Job 간 dependency 표현 패턴
- 함정 —
proxyBeanMethods = false시 Step 중복 인스턴스 = Flow 오작동 - 권장 — Step 을 메서드 파라미터 주입
- 함정 —
end()후 재시작 시JobInstanceAlreadyCompleteException - 함정 — Split 안 SyncTaskExecutor = 병렬 X
공식 문서: Controlling Step Flow 에서 원문을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 15편 — Retry Logic (일시적 실패 자동 재시도)
- 16편 — Transaction Attributes (Isolation · Propagation · Timeout)
- 17편 — ItemStream 등록 (재시작 안전성의 핵심)
- 18편 — Step 라이프사이클 Listener 종합
- 19편 — TaskletStep (단발 작업의 정석)
다음 글: