Spring Batch 입문 20편 — Flow Control · Decision · Split · 조건 분기

2026-05-17Spring Batch 입문에서 운영까지

Spring Batch 입문 20편. Job 의 Step Flow 제어 — sequential / conditional / decision / split / stop & restart 다섯 패턴. ExitStatus 와 BatchStatus 차이, on() 와일드카드, JobExecutionDecider, JobStep 까지 정리한 학습 노트.

📚 Spring Batch 입문에서 운영까지 · 20편 — Flow Control · Decision · Split · 조건 분기

이 글은 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();
}

규칙은 단순해요. stepACOMPLETEDstepB 가 실행되고, 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 종료 후 결정되는 상태 문자열) 가 FAILEDstepC 로, 그 외 모든 경우 (*) 는 stepB 로 갑니다.

프레임워크가 자동으로 specific → general 순으로 정렬 해줘서, *FAILED 의 순서를 반대로 써도 FAILED 가 우선 매칭됩니다.

on() pattern 매칭 규칙

문자 의미
* 0개 이상의 문자
? 정확히 1개 문자

예제로 보면 c*tcat·count 둘 다 매치되고, c?tcat 만 매치되고 count 는 안 됩니다. COMPLETED*COMPLETEDCOMPLETED 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 가 돌려준 FlowExecutionStatuson() 매칭 키가 됩니다.

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;
}

JobStepStep 안에서 또 다른 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 으로 인식 돼서 예상 못 한 동작으로 이어져요.

해결 방법은 두 가지입니다.

  1. proxyBeanMethods = true (default) 유지
  2. 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 JobReport 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) 메서드, FlowExecutionStatus return
  • .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 에서 원문을 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!