[개발지식] Web Application의 상호보완적 Eventually Consistency #18 - Spring Batch Job Architecturing(Job pipeline)

이효균·2026년 1월 9일

개발지식

목록 보기
129/131

1. 개요

지금까지 분석한 내용은 job, step의 구성, 특히 세밀한 구성이 필요하다면 주로 step의 내용을 다듬는 형태로 이루어졌다.

만약 Job에 대해 더욱 세밀하고, 정확한 제어가 필요하다면 어떨까?

batch job을 제어한다는 것은, 결국 "실행흐름" 그 자체를, 분기에 따라 step을 선별하여 실행할 수 있도록 복잡한 파이프라인을 구성할 수 있다는 의미로 귀결된다.

Spring Batch Job을 Architecturing하여, 복잡한 요구사항에 대해 pipeline을 구성하고, 실행흐름을 명확하고 세밀하게 다듬고 가공할 수 있는 과정에 대해 분석해보고자 한다.

2. Job Pipleline - Job flow

일전에 살펴보았던 spring batch의 흐름은 1개의 job에 대해 여러 step이 순차적으로 구성되어, 각 step을 말그대로 순서대로 진행하는 것이 전부였다.

Job 실행
	Step 1
    Step 2
    Step 3
Job 종료

이 순차적인 흐름에서 단 한곳이라도 예외가 발생한다면, 상태전파로 인해 해당 job을 failure할 것인지, 혹은 step 자체적으로 재시도를 하거나 건너뛰기를 할 것인지 등 사실상 step 내부적인 요소에 의한 구성이 이루어졌다.

또한, 단순히 tasklet에서의 ExitStatus에 따라 다음 step의 실행 혹은 중지가 결정되는, 일방향적 흐름이 이루어졌기에 실패에 대한 후처리에 대한 고민이 주(主)가 되었다.

하지만, 이러한 단순 순차적 흐름을 넘어서서, spring batch flow를 통해 job 레벨에서의 batch 설계 및 구성이 이루어질 수 있고, 특정 상황(조건)에 따른 step 최초 진입 분기, 이에 따른 복잡한 실행 경로 설계 및 실패하였을 경우 다른 상태로의 전이 및 대체작업 진행 등 매우 효과적인 job 설계가 가능해진다.

예를 들어,

case1.
	- 컴플라이언스 이상거래내역 집계
    	- 집계 Step 성공 시 집계 Report 진행
        - 집계 Step 실패 시 오류 분석 진행
case2.
	- 대규모 데이터 샘플링 작업
    	- 초기 5% 데이터 샘플링 작업
        	- 성공 시 end to end
            - 실패 시 데이터 포맷 변경 및 이상 데이터 확인 작업 진행

등, 단순 순차적인 흐름(재시도, 건너뛰기 포함)이 아니라 Step의 상태에 따라 완전히 다른 조건, 상태, 흐름으로 step을 "지속"해야 한다면 이 경우 실무에서 Spring Batch Flow의 도입을 적극 고려해볼 수 있을 것이다.

Spring Batch Flow는 이처럼, spring batch에서 제공하는, 복잡한 요구사항에 대한 pipleine 정립을 위해 강력한 설계/아키텍칭 도구이며 이에 따른 세밀하고 정밀한 요구사항 spring job 구성이 가능해진다.

2-1. spring batch flow

flow에 대한 본질을 좀 더 세밀하게 살펴보도록 하자.

Flow란, 쉽게 말해 Job 레벨에서 Job의 흐름을 총괄하고 관리하는 것이다.

마치 대표 혹은 공장장의 입장에서 실무자들을 총괄하고 관리하듯이, Job, 그리고 그 안에서의 Step들이 어느 조건 하에 어떤 분기 혹은 흐름으로 실행을 할지 이러한 전체적인 흐름도(pipeline)를 직접 구성하고 아키텍칭할 수 있는 방안을 제공하는 것이 바로 spring batch flow이다.

상태머신, State Machine

Machine이라는 개념은 수학에서의 function처럼, input - output같이 명확한 상태 혹은 경계가 존재하는 것이라 볼 수 있다.

flow의 본질을 이해하기 위해서는 CS에서의 "상태머신", State Machine에 대해 먼저 이해해야 하는데, 말 그대로 지금의 상태에서 어떠한 특정 조건을 만족하였을 경우 혹은 분기조건을 부합하였을 경우, 다른 상태로의 전이(이동)이 이루어지는 시스템(체계)을 의미한다.

Job : 이상거래내역 집계
	- Step : 이상내역 샘플릿
    - Condition1 : 이상내역이 10% 이상일 경우
    	- ExitStatus = "Emergency"
    	- Step : Emergency Report
    - Condition2 : 이상내역이 10% 미만일 경우
    	- ExitStatus = "Abnormally"
    	- Step : Abnormally Report

위와 같이, condition에 따라 실행해야할 step이 달라져야 한다면, 그에 따른 상태가 바뀌고, 전이되며, 흐름도 달라진다.

상태(State)

spring batch flow에서 조건에 따라 분기되는, 흐름이 진행되는 각 step의 상태, 로직을 수행하는 논리적인 지점을 의미한다.

세부적으로, step이 진행하는 상태 그 자체 및 로직을 current (step) state, 최종적으로 flow를 종료하여, job의 종료지점을 나타내는 endState로 볼 수 있겠다.

전이조건(ExitCode)

그렇다면, 위의 상태"변경", 즉 "전이"의 분기를 나누는 상태조건, 기준점이 되는 지점을 전이조건(ExitCode)라 한다.

각 step 상태에서 변환하는 상태, current state에서 어떠한 ExitStatus, ExitStatus에서 관리하는 어떠한 exitCode를 반환하느냐에 따라 다음 전이를 결정하는 요인이 되겠다.

전이규칙(Transition)

전이조건에 따라 어떠한 전이상태로 변경할 것인지에 대한 pipeline을 정의한 명세이다.

즉, 분기에 따른 흐름(전이조건에 기반한 전이의 흐름)을 구성하는, 로직을 진행하는 정의서라 보면 되겠다.

Spring batch flow는 이러한 요소들이 적절하게 조합하여 이루어진다.

[Flow 시작]
     |
     v
[state: stepA]
     |
     |-- ExitStatus = COMPLETED --------------+
     |                                        |
     |-- ExitStatus = FAILED --> [EndState: FAILED_END]
     |                                        |
     |-- ExitStatus = CUSTOM_EXIT ------------+
                                              v
                                    [state: stepB]
                                              |
                                              |-- ExitStatus = COMPLETED --> [EndState: COMPLETED_END]
                                              |
                                              |-- ExitStatus = FAILED ----> [EndState: FAILED_END]

위와 같이, 1차적으로 최초 start step 지점에서 exitStatus(exitCode) 및 transition 조건에 따라 다음의 step에 해당하는 State으로 상태 전이 및 flow를 진행하게 된다.

[current state: stepA]
   |
   |  stepA.execute()
   |        |
   |        v
   |   ExitStatus 결정
   |
   +--> Transition 매칭
            |
            v
      다음 State 결정

내부적으로 다음에 진행할 step 단계(State)를 판단하는 기준은, 해당 state에서 반환하는 exitStatus와 transition 규칙이 되겠다.

참고로, endState는 job을 최종적으로 종료하는 지점으로, 모든 step의 진행을 종료하는 마지막 지점에서만 통용하는 상태개념이다.

(stepA)
   ├─(COMPLETED)──▶ (stepB)
   │                   └─(COMPLETED)──▶ EndState(COMPLETED)
   │                   └─(FAILED)─────▶ EndState(FAILED)
   │
   ├─(FAILED)────────▶ EndState(FAILED)
   │
   └─(CUSTOM_EXIT)───▶ (stepC)

3. 조건분기 및 이에 따른 flow 처리

먼저 가장 중요한 것은 명세, 즉 흐름에 대한 정의를 해주는 것이다.

각 step에서 처리결과에 따라 exitStatus(exitCode)를 정의하고, 해당 상태에 따라 어떠한 상태로 전이할 것인지 분기처리(transition)를 구성하는 것이다.

쉽게 말하면 아키텍칭을 위한 설계도를 먼저 작성하라는 것.

사실 transition은 조건(상태값, exitStatus/exitCode)에 따라 분기하여 어떠한 step으로 진행이 되는지 그 진행분기 자체를 의미하기에, spring batch flow에 정의되어있는 api를 활용하여 어떤 step으로 진행이 되는지 파이프라인을 구성하는 것이 가장 중요할 수 있겠다.

먼저, 어떠한 step을 최초 시작점으로 둘 것인지 startStep을 지정해준다.

.start(analyzeContentStep)

그 후, 어떠한 exitStatus에 따라 어떠한 상태로 전이할 것인지(step 이동) 분기를 정의해준다(transition1)

.on("COMPLETED")
.to(publishLectureStep)

다시 기준점을 정의해주는데, 위 상태전이 이외 어떠한 상태전이로 흐름을 분기할 것인지 그 기준점을 지정해준다.

.from(analyzeContentStep)

그 후, 또다른 상태전이를 정의해준다.

.on("FAILED")
.to(summarizeFailureStep)

모든 flow에 대한 명세를 마쳤다면 endState를 지정해준다.

.end()

이 내용들을 종합해보면,

start
	- step(from)
    	<on condition1>
			- to step 1
        <on condition2>
        	- to step 2
    - step
    ..
end

start step에서 특정 step을 기준으로 분기처리를 해야한다면, 해당 step은 from step으로 구성이 된다.

from 기준점 step을 기반으로, 특정 조건(on)에 따라 어떠한 step을 진행할 것인지(상태전이, transition) 분기지점 및 분기에 따른 flow(흐름)을 구성해준다.

이때 다른 상태전이가 이루어지는 조건(on)에는 ExitStatus가 제공하는 기본적인 exit code로 구성이 되지만, 이에 대한 customized한 status를 구성하여 확장성있게 batch flow를 구성할 수도 있다.

분기가 되는 지점에서는 반드시 from api를 통해 분기지점을 지정해주는 것이 필요하며, 각 step의 exit code가 어떠한 상태값을 가지느냐에 따라 다음으로 진행하는 step의 state를 결정한다.

최종적으로 end 지점을 지정하여, flow 구성을 종료한다.

일전에 구성하였던 단순 절차지향적, 단순흐름으로 이어지는 spring batch에 비해 복잡한 요구사항에 따라 흐름을 정의하고, 정교한 아키텍칭이 가능해졌다는 것을 느낄 수 있을 것이다.

4. Customized Exit Status

ExitStatus를 Customized하여 더욱 정밀하고 세밀하게 flow transition을 작성할 수 있겠다.

단순 Tasklet 지향처리의 경우, ExitStatus 객체 생성자 및 ExitCodeGenerator를 통해 ExitStatus 및 ExitCode를 정의해주었다.

public class BrutalizedSystemJobCustomizedExitCodeGenerator implements JobExecutionListener, ExitCodeGenerator {
    private static int CASE1 = 1;
    private static int CASE2 = 2;
    private static int CASE3 = 3;
    private static int UNKNOWN = 4;

    private final SimpleJvmExitCodeMapper exitCodeMapper = new SimpleJvmExitCodeMapper();

    private int exitCode = 0;

    public BrutalizedSystemJobCustomizedExitCodeGenerator() {
        exitCodeMapper.setMapping(Map.of(
                "CASE1", CASE1,
                "CASE2", CASE2,
                "CASE3", CASE3,
                "UNKNOWN", UNKNOWN));
    }
    ...

이것이 가능한 이유는,

contribution.setExitStatus(new ExitStatus("CASE1", e.getMessage()));

tasklet 지향처리에서는 위와 같이 stepContribution 객체를 전달받아 exitStatus를 직접 지정하는 것이 가능하였기 때문이다.

청크지향 처리의 경우, contribution을 직접 전달받지는 않기에 다른 방향의 접근이 필요한데,

AbstractStep의 execute에서,

getCompositeListener().beforeStep(stepExecution);
exitStatus = exitStatus.and(getCompositeListener().afterStep(stepExecution));

이때 getCompositeListener의 경우,

protected StepExecutionListener getCompositeListener() {
		return stepExecutionListener;
	}

위와 같이 StepExecutionListener의 beforeStep/afterStep에서 ExitStatus를 구성할 수 있는 힌트를 얻을 수 있으며, 아무래도 다음 상태전이를 위한 transition을 구성하기 위해서는 step의 시작전보다는 step의 실행 후인 afterStep에 구성해주는 것이 가장 적절한 방안이 될 것이다.

@Component
public class LectureScanConditionalStepListener implements StepExecutionListener {
..

	@Override
    public ExitStatus afterStep(StepExecution stepExecution) {
        long skipCount = stepExecution.getSkipCount();

        if (skipCount >= CRITICAL_SKIP_THRESHOLD) {
            return new ExitStatus("CRITICAL",
                    String.format("CRITICAL STEP IS NEEDED : %d", skipCount));
}

참고로, afterStep 메소드에서의 반환형태는 exitStatus이며 이를 활용하여, ExitStatus의 객체 생성자를 통해 전이상태를 구성해줄 수 있다.

return new ExitStatus 형태는 위에서 기술한 exitCodeGenerator에게 상태를 넘겨주기 위한 ExitStatus의 빌더패턴으로 정의된 객체형태이다.

이 경우, 상태 확장이 가능해지며 start/on/to ~ from/on/to의 구성으로 각 상태전이 및 분기처리를 정확하게 구성해주어야 한다.

.start(analyzeLectureStep) // start
.on("CASE1").to(approveImmediatelyStep)

.from(analyzeLectureStep) // from - on - to
.on("CASE2").to(initiateContainmentProtocolStep)

.from(analyzeLectureStep) //  from - on - to
.on("CASE3").to(priceGougerPunishmentStep)

위에서 기술하였던 transition의 경우 분기가 2개만 존재하였지만, 위와 같이 그 이상이 존재한다면 최초 start부터 구성하여 on/to로 이어지는 분기처리, 그 이후에는 from(기준)/on/to로 이어지는 분기처리로 진행이 되는 구성이 이루어져야 한다.

else if (lecture.content().contains("CASE4") ||
                    lecture.title().contains("CASE4")) {
                log.warn("state 4");
                log.warn("Current step is for state 4 transition");

                contribution.setExitStatus(new ExitStatus("CASE4",
                        "CASE4 Exception Occured"));
            }
            else {
                log.info("end state");

                contribution.setExitStatus(new ExitStatus("APPROVED",
                        "Look Good To All of Us"));
            }

참고로, ExitStatus에 대한 Customized는 당연히 분기 기준점인 from step으로부터 정의 및, 이에 대한 분기처리(transition)가 이루어져야 하겠다.

4-1. ExitStatus의 누락

만약, ExitStatus의 누락, 이에 따른 ExitCode에 따른 상태전이 처리가 누락이 된다면 어떠한 결과를 예상할 수 있을까?

spring batch flow에서 상태전이할 transition이 기준 step(from)에서 정의되어있지 않은 상태라면, FlowExecutionException이 발생하며, 구체적으로는 해당 ExitStatus에 대한 state가 정의되어있지 않다는 오류메시지가 발생하게 된다.

쉽게 말해, 해당 ExitStatus(exitCode)에 대해 다음 진행할 step(on/to)가 지정되어있지 않으므로 시스템에서는 어떠한 규칙을 적용할지 모르고, 이에 대한 Exception이 발생한다.

따라서, 기준 step에서 발생한 모든 상태에 대해서 전이조건 및 전이상태를 반드시 정의해주어야 한다.

4-2. from step을 제외한 나머지 step의 묵시적인 transition 처리

여기서 들 수 있는 한가지 의문점은, 기준점은 from step에 대해 모든 가능한 ExitCode에 대해 반드시 state 전이처리(on/to)를 해주어야 한다면, 왜 상태가 전이된 다른 step에 대해서는 전이처리를 별도로 구성하지 않아도 정상적으로 작동이 되느냐이다.

그 이유가 바로 Spring batch flow에서 처리하는 기본적인, 묵시적 transition 처리방법에 있다.

tasklet
	- CONTINUOUS 
    	- next tasklet
	- FINISHED
      stepExecution.setExitStatus
          - FINISHED(ExitCode : "COMPLETED")
          - NOT FINSIHED(ExitCode : "FAILED")

tasklet의 기본적인 반환값은 RepeatStatus에 정의되어있는 상태값이며, 이 값이 만약 FINISHED이라면 최종적으로 abstractStep의 execute 진행과정에서 stepExecution의 exitStatus 상태를 COMPLETED로 변경, 반영하게 된다.

Tasklet.execute()RepeatStatus.FINISHED 반환
   ↓
RepeatTemplate 반복 종료
   ↓
TaskletStep.doExecute()AbstractStep.execute()ExitStatus 결정

위 과정으로 exitStatus 객체의 exitCode를 변경하며, 만약 이외의 상태라면 COMPLETED가 아닌 FAILED 혹은 그 이외의 exitCode로 변경 반영하게 된다.

spring batch flow에서는 COMPLETED, FAILED에 대한 상태에 대해서는 기본적인 상태전이를 구성해주고 있으며,

각각

  • COMPLETED -> COMPLTED endState
  • NOT COMPLETED -> FAILED endState

최종 endState에 대한 전이를 COMPLETED, FALIED로 일단 기본구성하고 있기에 전이 transition이 구성되어 있지 않다면 위 상태전이를 활용하게 된다.

만약, 일전에 구성하였던 것처럼 한개라도 전이상태에 대한 구성이 되어있다면, spring batch flow는 암묵적인 기본 상태전이를 진행하지 않고 customized한 상태전이를 따르게 된다.

만약, from 이외의 기준점 step을 또 마련하였다면, 다른 특정 step으로 상태전이되는 모든 exitStatus에 대한 transition을 구성해주어야 한다.

그것이 기본 상태값인 COMPLETED, FAILED가 될 수 있고, 다른 Customized한 상태값이 될 수도 있다.

중요한 점은 하나라도 상태전이에 대한 transition을 구성해주었다면, 그 step이 전파가능한 모든 exitStatus/exitCode에 대해 transition을 구성해주어야 한다는 점이다.

4-3. ExitCode wild card

spring batch flow 구성간, exit code에 대해 패턴이 존재한다면 해당 패턴을 적용하는 wild card 형태의 transition 구성이 가능해진다.

와일드카드의 강력한 점은, 위에서 모든 exit code에 대한 상태전이 transition을 구성할때 예외가 없는 분기처리를 가능하도록 해준다는 것이다.

  • on("*")

모든 exitCode에 대해 적용한다.

  • on("EXITCODE_*")

EXITCODE_0, EXITCODE_1, .. 등 exitCode의 정해진 패턴에 기반하여 특정 step으로 상태를 전이한다.

정립한 Error에 대한 errorHandler로 이동하는 방법이 가장 대표적이다.

  • on("EXITCODE_??")

해당 와일드카드에 매칭되는 문자/숫자의 개수와 일치하는 exitCode에 맞추어 분기처리를 가능하도록 한다(위의 경우 두글자 매칭 시 적용).

4-4. 상태전이의 우선순위

만약, 와일드카드 등 모든 ExitCode에 대해 transition을 구성하되, 공통된 패턴이 적용되어 적용가능한 분기가 2개이상이 된다면, spring batch flow는 어떠한 흐름을 선택할까?

.from(step).on("ERR_*").to..
.from(step).on("ERR_?").to...

flow 입장에서는, 패턴일치의 "정확도" 및 "일치도"를 우선적으로 판단한다.

즉,

ERR?이 flow입장에서는 "한글자 매칭"이므로 아스타(ERR*)보다는 더 구체적이다. 나아가, ???의 물음표 배열은 모든 아스타 규칙보다 더 구체적이다.

이와 같이 구체의 정도, 일치도, 정확도를 우선순위로 하여, on에 기재되어있는 exitCode와의 부합여부를 판단하고 다른 상태로 step을 전이시킨다.

5. 상태전이 과정

spring batch flow 구성시 유의해야하는 기본적인 사항들에 대해 알아보았는데, 더 유연하고 정밀한 적용을 위해 spring batch flow에서 step의 상태전이가 어떠한 과정으로 발생하는지 알아보도록 하자.

일반적인 spring batch job을 실행한다면,

SimpleJobLauncher
        |
        v
Job.execute()
        |
        v
AbstractJob.execute()
        |
        +--> JobExecution 생성 (STARTING)
        |
        +--> doExecute()
                |
                v
            SimpleJob.doExecute()
                |
                v
            Step 순차 실행
                |
                v
            Step.execute()
                |
                v
            AbstractStep.execute()

위와 같이 job.execute, AbstractJob.execute, SimpleJob.doExecute, step.execute, AbstractStep.execute, SimpleStep.doExecute 순으로 진행이 된다.

만약, flow가 적용이 된다면 Job의 구현체가 SimpleJob이 아닌 FlowJob으로 변경이 된다.

그 이후, Job.execute, AbstractJob.execute 하는 단계까지는 동일하며, 그 구현체가 SimpleJob이 아닌 FlowJob이고 이에 대한 doExecute()를 진행하는 것의 차이가 있다.

SimpleJobLauncher
  |
  v
Job.execute()
  |
  v
AbstractJob.execute()
  |
  +--> JobExecution 생성 (STARTING)
  |
  +--> doExecute()
        |
        v
    FlowJob.doExecute()
        |
        v
    flow.start(flowExecutor)
        |
        v
    Flow.execute()
        |
        v
    State.handle()
        |
        +--> StepState.handle()
        |       |
        |       v
        |   Step.execute()
        |       |
        |   flowExecutor.executeStep(step)
        |       v
        |   AbstractStep.execute()
        |
        +--> DecisionState.handle()
        |
        +--> FlowState.handle()

이와 같이 FlowJob의 doExecute를 진행하게 되고, 내부적으로 flowExecutor의 구현체를 호출하여 flow.start를 진행, 이후 flow.execute를 거쳐 StepState의 handle를 호출한다.

StepState 구현체의 handle을 호출하면, 그 이후에는 step을 진행하는 단계로 AbstractStep.execute를 거치는데, 이때 내부적으로 StepState의 handler을 통해 flowExecutor.executeStep을 호출, step을 진행하게 된다.

나아가, 내부적으로 DecisionState/FlowState의 handle()를 진행하게 되며, 이후 step의 구현체인 DecisionState, FlowState의 적절한 호출을 통해 step 진행이 진행, 최종적으로 종료된다.

이때 중요한 부분은 flowExecutor의 executeStep을 호출하여 step을 진행하는 것이며, 이때 flowExecutor의 구현체는 JobFlowExecutor이다.

FlowJob에서 flow.execute를 호출하여 StepState의 handle을 호출하는 지점부터 살펴보자.

@Override
	public FlowExecutionStatus handle(FlowExecutor executor) throws Exception {
		/*
		 * On starting a new step, possibly upgrade the last execution to make sure it is
		 * abandoned on restart if it failed.
		 */
		executor.abandonStepExecution();
		return new FlowExecutionStatus(executor.executeStep(step));
	}

위와 같이, StepState의 handle을 호출하게 되면 FlowExecutor의 executeStep을 통해 step을 실행하며, 전달받은 문자열 형태의 exitCode를 FlowExecutionStatus 객체의 생성자로 전달한다.

익숙한 형태인데, step에서 exitCode처리를 ExitStatus 객체의 생성자로 전달하는 것과 유사한 형태이다.

즉, flowExecute, 그 구현체인 JobFlowExecutor의 executeStep을 통해, step을 실행하면서 최종적으로 해당 step의 exitCode 문자열을 반환한다는 것이다.

이후 StepState 구현체는 FlowExecutionStatus 객체의 생성자로 전달하면서 FlowExecutionStatus 구현체를 구성하며, 이 구현체의 exitCode에 따라 다음 상태로 전이할 transition을 찾고, 이를 기준으로 분기처리를 진행한다.

public class FlowExecutionStatus implements Comparable<FlowExecutionStatus> {
	...
    private enum Status {

		COMPLETED, STOPPED, FAILED, UNKNOWN;

		static Status match(String value) {
			for (int i = 0; i < values().length; i++) {
				Status status = values()[i];
				if (value.startsWith(status.toString())) {
					return status;
				}
			}
			// Default match should be the lowest priority
			return COMPLETED;
		}

	}
    ...

FlowExecutionStatus 구현체를 살펴보면 위와 같이 ExitStatus와 비슷한 양상으로 exitCode를 관리하는 것을 알 수 있는데, step을 마친 후에 최종적으로 flowStep의 ExitStatus exitCode를 반환하게 된다.

Customized하게 구성한 Status라면, 해당 status 구현체의 exitCode는 Customized하게 구성된 상태일 것이고, on에서 지정한 패턴에 따라 to에 지정한 step으로 적절하게 상태전이를 진행한다.

참고로, FlowExecutionStatus의 name 필드와 비교하여 매칭여부를 판단한다.

그 후, flow는 최종적으로, 반드시, endState로의 상태전이를 진행한다.

EndState는 말 그대로 모든 Flow의 끝자락, 최종적은 state로 더이상 전이될 다음 StepState가 존재하지 않는다는 의미이며, endState로 도달하였다면 flow 진행 및 job의 진행이 모두 종료된다.

각 상태전이를 StepState.handle을 통해 조절하였다면, 최종적으로는 EndState의 handle()을 통해 최종적인 flow 종료를 진행한다.

			if (status.isStop()) {
				if (!executor.isRestart()) {
					/*
					 * If there are step executions, then we are not at the beginning of a
					 * restart.
					 */
					if (abandon) {
						/*
						 * Only if instructed to do so, upgrade the status of last step
						 * execution so it is not replayed on a restart...
						 */
						executor.abandonStepExecution();
					}
				}
				else {
					/*
					 * If we are a stop state and we got this far then it must be a
					 * restart, so return COMPLETED.
					 */
					return FlowExecutionStatus.COMPLETED;
				}
			}

			setExitStatus(executor, code);

			return status;

endState에서 만약 status의 중지상태가 아니라면, FlowExecutionStatus의 상태를 COMPLETED로 변경하여 그 객체를 전달하거나, 혹은 이전에 전달받은 exitCode를 그대로 FlowExecutionStatus 객체 생성자에 반영하게 된다.

결국, stepExecution에서 전달받은 exitCode, COMPLETED 혹은 FAILED 상태를 FlowExecutionStatus 객체로 생성, EndState 객체 전달로 최종 이루어진다는 것이다.

상태전이의 분기점인 from 지점을 제외하고, 나머지 step에서 이루어지는 기본적인 flow 처리가 바로 여기서 발생하는 것이다.

5-1. FlowJob.doExecute()의 EndState 변환 과정 및 EndState 상태전이 Customizing

FlowJob 구현체의 doExecute를 살펴보면 아래와 같다.

@Override
	protected void doExecute(final JobExecution execution) throws JobExecutionException {
		try {
			JobFlowExecutor executor = new JobFlowExecutor(getJobRepository(),
					new SimpleStepHandler(getJobRepository()), execution);
			executor.updateJobExecutionStatus(flow.start(executor).getStatus());
		}
		catch (FlowExecutionException e) {
			if (e.getCause() instanceof JobExecutionException) {
				throw (JobExecutionException) e.getCause();
			}
			throw new JobExecutionException("Flow execution ended unexpectedly", e);
		}
	}

exception이 발생한 catch 로직을 제외하고, try 로직을 살펴보면

executor(구현체 JobFlowExecutor)의 updateJobExecutionStatus로 executionStatus 및 exitCode를 변경해주는 것을 확인할 수 있다.

이때, 다음 stepState로 진행하기 위한 상태전이든 flow를 종료하기 위한 endState든, 다음 flow를 진행(start)하고, 여기서 얻은 getStatus 상태를 전달받아 status 객체 생성자로 전달하는 것을 확인할 수 있다.

JobFlowExecutor의 updateJobExecutionStatus의 로직은,

@Override
	public void updateJobExecutionStatus(FlowExecutionStatus status) {
		execution.setStatus(findBatchStatus(status));
		exitStatus = exitStatus.and(new ExitStatus(status.getName()));
		execution.setExitStatus(exitStatus);
	}

StepState 혹은 EndState로부터 전달받은 FlowExecutionStatus객체를 BatchStatus로 변환하여, JobExecution의 status 상태를 변경하는 것으로 이루어져 있다.

이때 StepState로 상태전이를 하거나, EndState라면 COMPLETED/FAILED 등의 상태를 판단하여 최종 Batch Job/Flow의 상태전이 및 flow 종료까지 상태전이를 진행하게 된다.

이를 활용하여, EndState로의 상태전이를 기본적인 flow transition이 아닌, cusotmized한 EndState 상태전이를 구성해줄 수도 있다.

  • COMPLETED
from(A)
.on....to(A1).on("*").end()

FlowExecutionStatus의 EndState를 항상 COMPLETED 상태로 전이하기 위해선, 해당 step의 다음 state(on)을 end()로 구성해준다.

위와 같이, A1 step으로 진행한 후 어떠한 exitCode가 반환되든지 무조건 A1 step의 최종 상태반환코드는 COMPLETED이다.

  • STOPPED
from(A)
.on...to(A2).on("*").stop()

만약 특정 step의 실행결과와 상관없이 중지 하고 싶다면, 해당 step의 다음 state를 stop()으로 구성해준다.

이 경우 해당 step은 STOPPED의 exit code로 EndState의 상태전이가 이루어진다.

  • FAILED
.on...to(A3).on("*").fail()

특정 step의 처리결과에 상관없이, 해당 step에 대한 상태전이 자체를 "실패"처리로 규정하여, EndState의 상태값을 FAILED로 설정하고자 한다면, 위와 같이 fail() api를 사용하여 구성해주도록 한다.

  • Customized

이외, 다른 EndState에 대한 상태전이를 기본 구성이 아닌 cusotmized하여 확장할 수도 있다.

.on...to(A4).on("*").end("COMPLTED_CUSTOMIZED")

이 경우, flow를 COMPLTED_CUSTOMIZED라는 EndState로 최종 전이해줄 수 있다.

다만,

endState에 대한 상태전이 구성과는 별도로, 반드시 .end().build()를 통한 빌더패턴 구성은 별도로 구성해야 한다는 점을 기억하자.

이때 유의해야할 점은 cusotmized하게 작성한 exit code가 BatchStatus enum에서 관리하는 문자열 기반의 관리가 이루어져야 한다는 점이다.

updateJobExecutionStatus를 다시 살펴보면,

@Override
	public void updateJobExecutionStatus(FlowExecutionStatus status) {
		execution.setStatus(findBatchStatus(status));
		exitStatus = exitStatus.and(new ExitStatus(status.getName()));
		execution.setExitStatus(exitStatus);
	}

ExecutionStatus 상태로부터 BatchStatus로 전환하고, 해당 상태값을 execution에 반영하는 것을 알 수 있었다.

protected BatchStatus findBatchStatus(FlowExecutionStatus status) {
		for (BatchStatus batchStatus : BatchStatus.values()) {
			if (status.getName().startsWith(batchStatus.toString())) {
				return batchStatus;
			}
		}
		return BatchStatus.UNKNOWN;
	}

이때, batchStatus로 상태변환하는 과정을 살펴보면 위와 같이, status의 customized한 exitCode를 BatchStatus에서 관리하는 문자열(BatchStatus.toString())의 시작문자열(startsWith)과 일치하는지 확인하고, batchStatus 객체를 반환하는 것을 확인할 수 있다.

만약, BatchStatus에서 관리하는 문자열이 아니라면 UNKNOWN의 상태를 반환한다.

따라서, BatchStatus에서 관리하는

COMPLETED, STARTING, STARTED, STOPPING, STOPPED, FAILED, ABANDONED, UNKNOWN;

에 등록된 문자열 기반의 ExitCode를 customized하여야만, 최종적으로 BatchStatus 객체를 변환하고 생성할 수 있다는 점을 유의하자.

위와 같은 과정을 통하여, BatchStatus 객체를 생성하는데, 이와 같이 EndState에 대한 최종 ExitCode를 cusotmized할 경우에 구성 문자열에 주의해야 하고, 이외 ExitStatus 객체를 생성하여 다음 상태전이를 시도하는 경우라면 ExitStatus에 대한 규칙은 별도 존재하지 않으므로 transition의 구성만 누락하지 않으면 된다(ExitStatus에 대한 exitCode 구성은 자유).

참고로, ExitCode는 Customized하게 작성한 code그대로 메타테이블에 저장되고 status는 지정된 batchStatus/ExitStatus 그대로 저장이 된다는 점 항상 기억해두자.

6. JobExecutionDecider

위에서 살펴보았던 Job의 흐름제어는 사실상 ExitCode 기준으로 transition을 구성하였다고 간주하여도 무방하다.

하지만 규모가 커진다면, 이러한 ExitCode에 의한 단순 분기처리는 보완해야 할 점이 무수히 많아지게 된다.

예를 들어,

  • 위에서 살펴보았듯이 Step 내부에, 특히 기준이 되는 from step 내부에 ExitStatus 처리 및 이에 대한 분기처리가 비대해진다. Step 본연의 로직보다 분기로직이 더 비대해지는 비정상적인 상황, 또한 CheckedException에 대한 세부처리가 그만큼 더 어려워지는 상황이 만들어진다.
  • 더불어 tasklet 지향처리의 경우 contribution에 의한 exitcode설정이 상대적으로 용이하였지만, chunk 지향처리의 경우 StepExecutionListener의 afterStep에 기반한 exitStatus/exitCode 설정이 필요해지며, 이 경우 책임분리에 의해 불필요한 유지관리점 증가가 일어날 수 있다(한번에 batch 로직을 이해하기 어려울 수 있음).

이외 말고도 여러가지 불리한 지점이 많아지는데, 이에 대해 Spring batch는 JobExecutionDecider라는 특별한 컴포넌트를 제공한다.

이 컴포넌트를 활용하면, 단순 status/code에 기반한 분기처리가 아닌 flowExecutionStatus 기반의 정확하고 명확한 상태전이를 구성할 수 있게 된다.

JobExecutionDecider를 통해 Step은 step 본연의 로직에 집중하고, jobExecutionDecider는 상태전이를 결정(flowExecutionStatus)하고 이에 따른 분기처리에 집중할 수 있다(transition 구성).

말 그대로, 상태전이의 분기와 이에 대한 구성에 온전히 집중할 수 있는 방안이 제공이 되는 것이다.

public class ReviewDecider implements JobExecutionDecider{


    @Override
    public FlowExecutionStatus decide(JobExecution jobExecution, StepExecution stepExecution) {
        return null;
    }
}

만약 Decider 인터페이스를 구현한다고 하면, 위와 같이 decide라는 분기처리 부분에 대해 오버라이드하여 구성해주면 되겠다.

if (con1) {
		...
                contribution.setExitStatus(new ExitStatus("EXITCODE_1",
                        "EXIT_MESSAGE"));
            }
            else if (con2)                    
                        
                        contribution.setExitStatus(new ExitStatus("EXITCODE_2",
                        "EXIT_MESSAGE"));
            }
            else if (con3)
                contribution.setExitStatus(new ExitStatus("EXITCODE_3",
                        "EXIT_MESSAGE"));
            }
            else if (con4) {
				contribution.setExitStatus(new ExitStatus("EXITCODE_4",
                        "EXIT_MESSAGE"));
            }
            else {

                contribution.setExitStatus(new ExitStatus("APPROVED",
                        "EXIT_MESSAGE"));

            return RepeatStatus.FINISHED;
        };

기존에는 위와 같이 from step 내부에 복잡한 분기처리를 한번에 지정해둔 형태였다면, 이제는 이 분기처리를 모두 지우고

@Override
@Component
    public FlowExecutionStatus decide(JobExecution jobExecution, StepExecution stepExecution) {
        ExecutionContext executionContext = stepExecution.getExecutionContext();
        int con = executionContext.getInt("condition");
        
        switch(con){
        	case "con1" : return FlowExecutionStatus(exitCode);
            case "con2" : return FLowExecutionStatus(exitCode);
        }

위와 같이 stepExecution의 context를 활용하여 분기처리에 대해서만 별도로 구성해줄 수 있다. 해당 decider의 경우 나중에 메인로직에서 빈객체를 주입받아 사용할 것이므로, 반드시 component를 통해 빈객체를 생성하도록 구성하자.

@Bean
    public Job lectureReviewJobDecider(JobRepository jobRepository,
                                ReviewDecider reviewDecider,
                                ....)
    return new JobBuilder("lectureReviewJobDecider", jobRepository)
                .start(analyzeLectureStep)
                .next(reviewDecider)                            

이후 메인로직에서 jobBuilder의 next를 decider로 지정해준다. 그리고 가장 중요한 것은,

...
.start(step)
.next(decider)
.on(...).to(...)
.from(reviewDecider)
.on
...

이와 같이 분기기준을 특정 step이 아닌, decider 컴포넌트 단일 구성으로 지정해주면 된다는 점이다. 참고로, 최초 분기에 대해서는 on/to로 직접 명시해주어 구성해주었다는 점에 유의하자.

7. 동일한 flow 패턴의 구조화 - Flow/FlowBuilder

Spring batch flow가 제공하는 유지관리 친화적인 도구는 바로 flowBuilder를 통해, Flow 빈객체를 생성하여 이를 다수의 job에서 일괄적으로 사용할 수 있다는 점이다.

이는 곧, 동일한 flow 패턴에 대해 빈객체로 정의하여, 단순 복사/붙여넣기가 아닌 이미 정의되어있는 빈객체를 그대로 가져다 사용할 수 있다는 의미이다.

이 활용방법은 다양하게 존재한다.

@Component
public class LectureValidationFlow {
    @Bean
    public Flow lectureValidationFlow() {

Flow 빈객체를 정의하기 위한, 마치 팩토리 패턴과 같은 객체를 생성한 후

private final Flow lectureValidationFlow;

    @Bean
    public Job newCourseReviewJob(JobRepository jobRepository,
                                  Flow lectureValidationFlow,

위에서 지정해준 flow 빈객체를 그대로 주입받아 사용하는 것이다.

return new StepBuilder("newCourseReviewJob", jobRepository)
                .flow(lectureValidationFlow)

만약 모든 step에 대해 적용하지 않고, 특정 step에만 flow를 적용하고자 한다면 stepBuilder에 해당 flow를 적용할 수도 있다.

.start(notifyInstructorStep)    // Flow 완료 후 추가 Step 실행
                .on("FLOW_STEP").to(lectureValidationFlow)

아니면, job에서 일단 step을 실행하여 해당 step의 결과에 따라 flow를 추가적으로 진행하거나, 결과가 만족하지 않는다면 flow를 아예 실행하지 않을 수도 있는 상황이라면 일단 start를 통해 step을 진행하고, 특정 상태에 따라 Flow로 상태전이를 시도할 수 있는 방향도 존재한다.

다만, 우리가 frontend를 다룰때 공통적인 jsp/css 컴포넌트를 구성하는 부분에 대해 생각해보자.

단순히 공통 컴포넌트에 대해 무작위로 생성한다고 유지관리성이 좋아졌는가?

절대 아니다. 오히려, 세부적인 로직처리가 어려워지고 공통으로 구성한 컴포넌트의 관리 및 인수인계적인 관점에서 편의가 매우 줄어든다. 이는 공통 컴포넌트를 통한 유지관리 편의보다는, 공통 컴포넌트로 인한 불필요한 결합도가 증가하여 관리가 역설적이게도 더 어려워지는 반대의 상황이 펼쳐졌다.

flow도 마찬가지이다. 프로젝트 규모가 더 커지고 이를 관리해야하는 인원, 책임이 커진다면 공통 컴포넌트인 flow는 결코 좋은 대안이 될 수 없다.

따라서, flow는 웬만하면 권장하지 않는다.

8. 결론

지난번 Step 내부 로직에 대해 깊게 파헤치고 분석하였던 기억이 남아있는가?

이러한 기본적인 구조, 흐름을 바탕으로 Job Level에서의 실행, 중단, 나아가 원격 JobOperator를 통한 실행, 중단, 재실행까지 살펴보았고, Job level에서의 step 흐름과 분기를, 상태전이라는 또 하나의 매력적인 설계사상을 기반으로 Spring batch flow를 통해 정밀하고 세부적으로 구성하는 방법까지 분석해보았다.

Spring batch의 기본적인 컴포넌트들, 구조적 요소에 대해서는 사실상 마무리하였다고 보아도 무방하다.

18단계에 걸쳐 엄청난 깊이와 넓이의 분석을 통해, 그나마 spring batch를 실무에 더 체계적으로, 적극적으로 활용하기 위한 준비단계를 끝마칠 수 있었다는 것이다.

물론, 이 상태에 안주하지 않고 다양한 실무적인 상황과 경험적 근거를 마련하여 spring batch 컴포넌트를 얼마나 효율적으로, 생산성있게 활용할 수 있을지는 온전히 개발자의 몫일 것이다.

대규모 데이터를 처리하기 위한 spring batch라는 도구에 대해 더 깊이 이해하고 파악하여, 항상 batch 컴포넌트를 적용할 수 있는 준비를 해두도록 하자.

profile
저의 고민, 설계 과정을 담고 있습니다.

0개의 댓글