start() → next()로 이어지는 순차 실행 방식이었다.Step의 실행 결과(ExitStatus)에 따라 다음 Step이 달라져야 하는 경우가 있다.
Step 실패 시 오류 분석 Step으로 분기, 파일 포맷 검증 결과에 따라 변환 처리 또는 바로 후속 처리로 분기Job 안에서 각 Step이 어떤 조건으로 실행될지를 제어하는 메커니즘Step의 실행 결과(ExitStatus)에 따라 다음에 어떤 Step으로 진행할지를 결정StepExitStatus의 ExitCodeStep으로 이동 [ExitCode="COMPLETED"]
┌──────────────┐ ───────────────▶ ┌──────────────┐
│ 강의 분석 │ │ 강의 게시 │ ──▶ [작업 종료]
│ Step │ │ Step │
└──────────────┘ └──────────────┘
│ [ExitCode="FAILED"]
▼
┌──────────────┐
│ 오류 처리 │ ──▶ [작업 종료]
│ Step │
└──────────────┘
Step(상태)에서 ExitCode(조건)에 따라 다음 Step(상태)으로 전이되는 구조StepState, DecisionState 등Job의 최종 결과가 결정됨ExitStatus 객체의 exitCode 필드 값이 분기 기준"COMPLETED", "FAILED" 등public class ExitStatus {
public static final ExitStatus COMPLETED = new ExitStatus("COMPLETED");
public static final ExitStatus FAILED = new ExitStatus("FAILED");
private final String exitCode; // 이 값이 Flow의 전이 분기를 결정
}
ExitCode 조건에 따라 다음 상태로의 이동을 정의
ExitCode가 COMPLETED면 publishLectureStep으로 이동”ExitCode가 FAILED면 작업을 종료(EndState)”각 상태에서 ExitCode 결과 + 미리 정의된 Transition 규칙 = 다음 목적지 결정
용어 참고: “실행 상태(Execution State)”는 Spring Batch 공식 용어가 아니라 강의에서 설명 편의를 위해 사용하는 표현이다.
EndState가 아닌 모든 상태(StepState,DecisionState등)를 통칭한다.
StepState 등)를 거친다ExitCode + 사전 정의된 Transition 규칙에 따라 다음 상태가 결정된다EndState)에 도달하면 Flow가 끝난다┌─────────┐ ExitCode ┌─────────┐ ExitCode ┌─────────┐
│ 시작 상태 │ ─────────────▶ │실행 상태1 │ ───────────────▶ │실행 상태2 │
└─────────┘ + Transition └─────────┘ + Transition └─────────┘
│ │
│ ExitCode + Transition │ ExitCode + Transition
▼ ▼
┌─────────┐ ┌──────────┐
│실행 상태3 │ ───────────────▶ │ 종료 상태 │
└─────────┘ │(EndState)│
└──────────┘
return new JobBuilder("conditionalJob", jobRepository)
.start(analyzeStep) // 시작 상태 설정
.on("COMPLETED") // ExitCode 조건
.to(publishStep) // 전이: publishStep으로 이동
.from(analyzeStep) // 다시 analyzeStep 기준으로
.on("FAILED") // ExitCode 조건
.to(failureStep) // 전이: failureStep으로 이동
.end() // Flow 정의 종료
.build();
start(step): Flow의 시작 상태를 설정on(exitCode): 직전 상태의 ExitCode 조건을 지정, 조건이 일치할 때 다음 전이가 발생to(step): 조건 충족 시 전이할 다음 상태를 지정. on()과 쌍으로 사용되어 하나의 Transition 규칙을 완성한다.from(step): 전이의 기준점을 다시 설정. 하나의 Step에서 여러 ExitCode에 따라 각각 다른 경로로 분기할 때 사용end(): 모든 Flow 전이 규칙 정의가 완료
start(), to()에 전달되는 각 Step이 실행 상태에 해당on("COMPLETED"), on("FAILED") 등이 전이 발생 조건을 정의on() + to()의 조합이 하나의 전이 규칙을 구성ExitCode(COMPLETED, FAILED)만으로는 세밀한 분기가 어렵다.ExitCode를 정의하면 비즈니스 조건에 맞는 정교한 Flow 제어가 가능하다.StepContribution.setExitStatus()로 직접 설정contribution.setExitStatus(new ExitStatus("CUSTOM_CODE", "설명"));
StepContribution에 직접 접근하기 어려우므로 StepExecutionListener.afterStep()을 활용한다
AbstractStep.execute() 내부에서 Step 실행 후 afterStep()의 반환값으로 최종 ExitStatus가 결정된다// AbstractStep.execute() 내부
exitStatus = exitStatus.and(getCompositeListener().afterStep(stepExecution));
afterStep()에서 StepExecution의 상태(스킵 횟수, 읽은 아이템 수, 처리 시간 등)를 기준으로 커스텀 ExitStatus를 반환하면 된다@Override
public ExitStatus afterStep(StepExecution stepExecution) {
int skipCount = stepExecution.getSkipCount();
if (skipCount >= 10) {
return new ExitStatus("CRITICAL_SKIP"); // 커스텀 ExitCode
} else if (skipCount >= 5) {
return new ExitStatus("WARNING_SKIP");
}
return stepExecution.getExitStatus(); // 기존 ExitStatus 유지
}
ExitCode는 Flow DSL의 .on() 조건으로 활용하여 더 정교한 분기를 구성할 수 있다Step에서 비즈니스 조건에 따라 여러 커스텀 ExitCode를 반환하고, 각각 다른 후속 Step으로 분기하는 구조analyzeLectureStep의 결과에 따라 5가지 경로로 분기한다return new JobBuilder("lectureReviewJob", jobRepository)
.start(analyzeLectureStep)
.on("APPROVED").to(approveStep) // 승인 → 게시 처리
.from(analyzeLectureStep)
.on("PLAGIARISM_DETECTED").to(containmentStep) // 표절 → 격리 처리
.from(analyzeLectureStep)
.on("QUALITY_SUBSTANDARD").to(rejectionStep) // 품질 미달 → 반려
.from(analyzeLectureStep)
.on("TOO_EXPENSIVE").to(pricePunishStep) // 과다 가격 → 가격 조정 요구
.from(analyzeLectureStep)
.on("666_UNKNOWN_PANIC").to(adminCheckStep) // 미확인 위험 → 관리자 수동 검토
.end()
.build();
analyzeLectureStep의 Tasklet 내부에서 조건 분기에 따라 contribution.setExitStatus(new ExitStatus("APPROVED")) 등으로 커스텀 ExitCode를 설정하면, Flow DSL의 .on() 조건과 매칭되어 해당 경로의 Step이 실행된다Step에서 .from()으로 기준점을 반복 재설정하며, 각 커스텀 ExitCode마다 .on().to()로 전이 규칙을 정의QUALITY_SUBSTANDARD에 대한 전이를 정의하지 않은 채로 analyzeLectureStep이 해당 ExitCode를 반환하면 다음 예외가 발생한다FlowExecutionException: Next state not found in flow=lectureReviewJob
for state=...step0 with exit status=QUALITY_SUBSTANDARD
analyzeLectureStep은 .on()이 한 번이라도 사용된 상태이므로, 발생 가능한 모든 ExitCode에 대한 전이 규칙이 명시적으로 정의되어 있어야 한다. QUALITY_SUBSTANDARD에 대한 경로가 없어서 Flow가 다음 상태를 찾지 못한 것이다.on()을 사용한 상태는 발생 가능한 모든 ExitCode에 대한 전이를 반드시 정의해야 한다용어 참고: “터미널 상태(Terminal State)”는 강의에서 설명 편의를 위해 만든 용어로, Spring Batch 공식 문서에는 없다.
.on() 체인이 단 하나도 정의되지 않은 상태를 강의에서는 터미널 상태라 부른다
approveStep, rejectionStep 등 .to()로 도달만 하고 그 이후 경로를 정의하지 않은 상태ExitCode == "COMPLETED" → COMPLETED 상태의 EndState로 전이ExitCode != "COMPLETED" → FAILED 상태의 EndState로 전이반면 .on()을 한 번이라도 사용한 상태는 터미널 상태에서 제외되어 암시적 규칙이 적용되지 않는다
.on().to() 체인을 추가하는 순간 해당 상태는 비터미널 상태가 되고, 발생 가능한 모든 ExitCode에 대한 전이를 명시적으로 정의해야 한다// lowQualityRejectionStep에 .on()을 추가하는 순간 터미널 상태 아님
.on("QUALITY_SUBSTANDARD").to(lowQualityRejectionStep).on("GPT_DETECTED").to(gptAlertStep)
// 이제 lowQualityRejectionStep의 나머지 ExitCode도 반드시 정의해야 함
.from(lowQualityRejectionStep)
.on("COMPLETED").to(rejectionCompletedStep)
// 누락 시 → FlowExecutionException
ExitCode를 일일이 .on()으로 지정하는 것은 비현실적이다.* (애스터리스크)ExitCode를 한 번에 처리하거나, 특정 접두사로 시작하는 코드를 일괄 처리할 때 사용// 나머지 모든 ExitCode 처리
.from(analyzeLectureStep)
.on("*").to(processUnknownStateStep)
// ERROR_로 시작하는 모든 ExitCode 일괄 처리
.from(analyzeLectureStep)
.on("ERROR_*").to(systemErrorHandlingStep)
? (물음표)ExitCode를 그룹화하거나, 코드 길이가 고정된 패턴을 매칭할 때 사용.on("ERROR_?") // ERROR_1, ERROR_2 등 (한 글자)
.on("QUALITY_???") // QUALITY_LOW, QUALITY_BAD 등 (정확히 3글자)
*와 ?를 함께 사용하면 더 정교한 패턴을 만들 수 있다.on("LECTURE_??_*") // LECTURE_01_BASIC, LECTURE_02_ADVANCED 등
ExitCode가 여러 패턴에 동시에 매칭될 수 있다.높음 ◀──────────────────────────────────▶ 낮음
정확한 문자열 > ? 포함 패턴 > ? 전용 패턴 > * 포함 패턴 > *
예: ERROR_1 > ERROR_? > ??? > ERROR_* > *
ERROR_1): 완전 일치이므로 가장 높은 우선순위? 포함 패턴 (ERROR_?): 고정 접두사 + 제한된 글자 수로 구체적? 전용 패턴 (???): 글자 수만 제한하므로 ? 포함 패턴보다 덜 구체적* 포함 패턴 (ERROR_*): 접두사는 고정이지만 나머지는 무제한* 단독: 모든 값과 매칭되므로 가장 낮은 우선순위
?가 *보다 항상 우선한다는 것
?는 정확히 한 글자만 매칭하여 범위가 제한적인 반면, *는 어떤 문자열이든 매칭할 수 있어 ? 패턴은 * 패턴의 부분집합에 해당한다.?의 개수와 무관하게 *가 하나라도 포함된 패턴보다 항상 높은 우선순위를 가진다.StepState는 Step을 실행시키는 상태로, JobFlowExecutor를 통해 Step을 실행한다JobFlowExecutor.executeStep()은 내부에서 SimpleStepHandler.handleStep()을 호출
Step 실행 결과인 ExitStatus의 exitCode 문자열을 반환한다StepState.handle()은 이 exitCode 문자열을 받아 FlowExecutionStatus 객체로 변환하여 반환한다// StepState.handle()
public FlowExecutionStatus handle(FlowExecutor executor) {
return new FlowExecutionStatus(executor.executeStep(step)); // exitCode → FlowExecutionStatus로 변환
}
// JobFlowExecutor.executeStep()
public String executeStep(Step step) {
StepExecution stepExecution = stepHandler.handleStep(step, execution);
return stepExecution.getExitStatus().getExitCode(); // ExitStatus의 exitCode 문자열 반환
}
FlowExecutionStatus에 의해 전이가 결정된다name 필드 하나를 가진 객체이며, 미리 정의된 상수 4개가 존재한다public static final FlowExecutionStatus COMPLETED = new FlowExecutionStatus("COMPLETED");
public static final FlowExecutionStatus STOPPED = new FlowExecutionStatus("STOPPED");
public static final FlowExecutionStatus FAILED = new FlowExecutionStatus("FAILED");
public static final FlowExecutionStatus UNKNOWN = new FlowExecutionStatus("UNKNOWN");
public FlowExecutionStatus(String status) {
this.name = status; // exitCode 문자열이 그대로 name이 된다
}
Step 실행 완료 → ExitStatus의 exitCode 문자열 반환exitCode 문자열로 FlowExecutionStatus 객체 생성.on("패턴")에 지정한 문자열이 FlowExecutionStatus의 name 필드와 패턴 매칭.to()로 지정된 다음 상태로 전이.on() 메서드에 지정한 문자열은 실제로 FlowExecutionStatus의 name 필드와 매칭되는 패턴이다EndState로 전이된다.State가 없어도 FlowExecutionException이 발생하지 않는다EndState는 내부에 FlowExecutionStatus 타입의 status 필드를 가지며, handle() 호출 시 이를 그대로 반환한다.// EndState
private final FlowExecutionStatus status;
public FlowExecutionStatus handle(FlowExecutor executor) {
return status; // 이 값이 Job의 최종 상태를 결정
}
FlowJob.doExecute()에서 이 반환값이 JobFlowExecutor.updateJobExecutionStatus()로 전달되어 Job의 최종 상태가 설정된다.// FlowJob.doExecute()
executor.updateJobExecutionStatus(flow.start(executor).getStatus()); // EndState의 FlowExecutionStatus 전달
// JobFlowExecutor.updateJobExecutionStatus()
public void updateJobExecutionStatus(FlowExecutionStatus status) {
execution.setStatus(findBatchStatus(status)); // 1. BatchStatus 설정
execution.setExitStatus(exitStatus.and(new ExitStatus(status.getName()))); // 2. ExitStatus 설정
}
EndState의 status가 FlowExecutionStatus("COMPLETED") → Job은 완료 상태EndState의 status가 FlowExecutionStatus("FAILED") → Job은 실패 상태EndState의 status가 FlowExecutionStatus("STOPPED") → Job은 중단 상태앞서 설명한 터미널 상태의 암시적 전이 규칙이 이제 구체적으로 이해된다:
ExitCode가 "COMPLETED" → status 필드가 FlowExecutionStatus.COMPLETED인 EndState로 전이ExitCode가 "COMPLETED"가 아닌 경우 → status 필드가 FlowExecutionStatus.FAILED인 EndState로 전이end(), fail(), stop()암시적 전이 외에 직접 원하는 EndState로의 전이를 정의할 수 있다.
end() → COMPLETED 상태의 EndState로 전이stop() → STOPPED 상태의 EndState로 전이fail() → FAILED 상태의 EndState로 전이 (별도 Step 실행 없이 즉시 전이)// end(): 결과와 무관하게 COMPLETED EndState로 전이
.from(step).on("TOO_EXPENSIVE").to(punishmentStep).on("*").end()
// stop(): 결과와 무관하게 STOPPED EndState로 전이
.from(step).on("666_UNKNOWN_PANIC").to(adminCheckStep).on("*").stop()
// fail(): 즉시 FAILED EndState로 전이 (다음 Step 없음)
.from(step).on("PLAGIARISM_DETECTED").fail()
end() 메서드 사용 시 주의
EndState 전이를 의미하는 end()(FlowBuilder.TransitionBuilder.end())와 Flow DSL 구성 완료를 의미하는 end()(FlowBuilder.end())는 다른 메서드다..from(step).on("*").end() // → COMPLETED EndState로 전이 (TransitionBuilder.end())
.end() // → Flow 정의 종료 (FlowBuilder.end())
.build();
end(String status)end(String status) 오버로딩 메서드를 사용하면 커스텀 상태의 EndState로 전이시킬 수 있다..on("APPROVED").to(approveStep).on("*").end("COMPLETED_BY_SYSTEM")
BatchStatus와 ExitStatus의 결정 방식이 다르다는 점에 주의해야 한다.
ExitStatus: 커스텀 값이 그대로 ExitCode로 설정된다 (예: COMPLETED_BY_SYSTEM)BatchStatus: findBatchStatus() 메서드가 커스텀 값에서 매칭되는 BatchStatus 열거형을 찾아 변환한다// JobFlowExecutor.findBatchStatus()
protected BatchStatus findBatchStatus(FlowExecutionStatus status) {
for (BatchStatus batchStatus : BatchStatus.values()) {
if (status.getName().startsWith(batchStatus.toString())) {
return batchStatus; // name이 BatchStatus 열거형 값으로 시작하면 해당 값 반환
}
}
return BatchStatus.UNKNOWN; // 매칭 실패 시 UNKNOWN
}
EndState를 사용할 때는 반드시 BatchStatus 열거형 값(COMPLETED, FAILED, STOPPED 등)으로 시작하는 문자열을 사용해야 한다.| 커스텀 상태 | BatchStatus | ExitCode |
|---|---|---|
COMPLETED_BY_SYSTEM |
COMPLETED |
COMPLETED_BY_SYSTEM |
FAILED_VALIDATION |
FAILED |
FAILED_VALIDATION |
CUSTOM_STATUS |
UNKNOWN |
CUSTOM_STATUS |
ExitStatus와 BatchStatus의 근본적 차이는
ExitStatus는 비즈니스 요구에 맞게 자유롭게 확장 가능BatchStatus는 Spring Batch 내부의 상태 관리·재시작 로직에 직결되어 미리 정의된 열거형 값만 사용 가능ExitCode 결정 로직이 증가하면서 핵심 비즈니스 로직이 뒤로 밀려남ExitCode 설정을 위해 StepExecutionListener를 별도로 구현해야 하므로, 흐름 제어 로직이 리스너에 숨어 전체 흐름 파악이 어려움FlowExecutionStatus)을 결정하는 전용 컴포넌트Step은 데이터 처리에만 집중하고, 다음 전이를 위한 조건 생성은 JobExecutionDecider가 담당JobExecutionDecider가 등장하면 “여기서 Flow 분기가 결정된다”는 의도가 명시적으로 드러남
StepExecutionListener는 본래 Step 생명주기 이벤트 감지용이므로, Flow 분기에 관여한다는 사실이 코드에서 드러나지 않는다는 점에서 근본적으로 다름JobExecutionDecider를 구현하여 decide() 메서드에서 FlowExecutionStatus를 반환하next()로 연결한다.// 1. JobExecutionDecider 구현
public class StudentReviewDecider implements JobExecutionDecider {
@Override
public FlowExecutionStatus decide(JobExecution jobExecution, StepExecution stepExecution) {
int reviewScore = stepExecution.getExecutionContext().getInt("reviewScore");
if (reviewScore >= 8) return new FlowExecutionStatus("EXCELLENT_COURSE");
if (reviewScore >= 5) return new FlowExecutionStatus("AVERAGE_COURSE");
return new FlowExecutionStatus("NEEDS_IMPROVEMENT");
}
}
// 2. Flow DSL에서 next()로 Decider 연결
return new JobBuilder("studentReviewJob", jobRepository)
.start(analyzeStudentReviewStep)
.next(studentReviewDecider) // Decider가 전이 조건 결정
.on("EXCELLENT_COURSE").to(promoteCourseStep)
.from(studentReviewDecider).on("AVERAGE_COURSE").to(normalManagementStep)
.from(studentReviewDecider).on("NEEDS_IMPROVEMENT").to(improvementRequiredStep)
.end()
.build();
analyzeStudentReviewStep이 ExecutionContext에 데이터를 저장하면, studentReviewDecider가 이를 읽어 FlowExecutionStatus를 반환JobExecutionDecider를 사용한 전이 조건 결정을 담당하는 내부 상태를 DecisionState라고 한다
StepState와 마찬가지로 실행 상태(Execution State)의 한 종류JobExecutionDecider는 next()뿐 아니라 start()에도 배치할 수 있다
FlowExecutionStatus를 반환하여 전이를 결정한다는 역할FlowBuilder로 Flow를 구성하고 build()로 빈을 생성한다.@Bean
public Flow lectureValidationFlow(Step validateContentStep,
Step checkPlagiarismStep,
Step verifyPricingStep) {
return new FlowBuilder<Flow>("lectureValidationFlow")
.start(validateContentStep)
.next(checkPlagiarismStep)
.on("PLAGIARISM_DETECTED").fail()
.from(checkPlagiarismStep).on("COMPLETED").to(verifyPricingStep)
.from(verifyPricingStep).on("*").end()
.build();
}
1. Job에 Flow 직접 주입
return new JobBuilder("newCourseReviewJob", jobRepository)
.start(lectureValidationFlow) // Flow를 Job의 시작점으로 사용
.next(notifyInstructorStep)
.end().build();
2. FlowStep: Flow를 하나의 Step으로 래핑
return new StepBuilder("validationStep", jobRepository)
.flow(lectureValidationFlow) // 내부적으로 FlowStep 생성
.build();
Step으로 구성된 Flow를 단일 Step처럼 취급Step 단위의 리스너나 ItemStream을 적용할 때 유용3. Flow를 실행 상태(FlowState)로 활용
return new JobBuilder("courseUpdateJob", jobRepository)
.start(checkModificationStep)
.on("MAJOR_UPDATE").to(lectureValidationFlow) // 조건에 따라 Flow 실행
.from(checkModificationStep)
.on("MINOR_UPDATE").to(publishUpdateStep)
.end().build();
FlowState)로 취급하여 조건부 분기에 활용Job들이 모여 전체 시스템을 이루는 편이 더 강력할 수 있다
Job들을 엮고 관리하는 오케스트레이션의 복잡성이라는 별도 문제가 생길 수 있음