4. Spring Batch 도메인 이해 - Step

이종찬·2025년 7월 15일
post-thumbnail

Spring Batch의 Job이 '어떤 일을 할 것인가'를 정의한다면, Step은 그 안에서 '실질적인 일'을 수행하는 역할을 맡고 있습니다.

Job의 가장 중요한 구성 요소인 Step 도메인을 깊이 있게 알아보고, 하나의 Step이 실행될 때 내부적으로 어떤 과정을 거치는지 자세히 살펴보겠습니다.

1. Step이란? - Job을 구성하는 최소 작업 단위

StepJob을 구성하는 독립적인 하나의 실행 단계를 의미합니다. 모든 Job은 하나 이상의 Step으로 이루어져야 하며, Step은 그 자체로 배치의 개별적인 단계를 표현합니다.

Step의 핵심적인 특징은 다음과 같습니다.

  • 독립적인 실행 단위: 각 Step은 특정 기능을 수행하는 완전한 하나의 모듈입니다.
  • 트랜잭션의 경계: 기본적으로 Step은 하나의 트랜잭션 안에서 모든 로직이 실행됩니다. Step이 성공적으로 끝나야 해당 트랜잭션이 커밋됩니다. (물론 Chunk 기반 처리는 예외)
  • 재시작의 단위: 만약 Job이 여러 Step으로 구성되어 있고 중간에 실패했다면, Spring Batch는 이미 성공적으로 완료된 Step은 건너뛰고 실패한 Step부터 재시작을 시도합니다. 이 특징 덕분에 Step은 배치의 안정성을 보장하는 핵심 단위가 됩니다.

2. Step의 실행 라이프사이클

2.1 StepExecution: 생성과 시작

Step의 비즈니스 로직이 실행되기 직전, 프레임워크가 가장 먼저 하는 일은 Step의 실행 정보를 담을 StepExecution 객체를 생성하는 것입니다.

StepExecutionStep의 한 번의 실행 시도에 대한 모든 정보를 담는 도메인 객체입니다. 여기에는 시작/종료 시간, 처리 통계(readCount, writeCount 등), 그리고 가장 중요한 실행 상태(BatchStatus)가 포함됩니다.

이 객체가 생성되면, JobRepository를 통해 BATCH_STEP_EXECUTION 테이블에 STATUS'STARTING'으로 기록되며 Step의 생명주기가 공식적으로 시작됩니다.

2.2 비즈니스 로직: 위임과 실행

StepExecution이 준비되면, Step은 실제 비즈니스 로직을 수행할 구현체(Tasklet 또는 ItemReader/Processor/Writer)에게 제어권을 위임합니다. 데이터를 읽고 가공하거나, 특정 쿼리를 실행하는 등의 실질적인 작업이 이 단계에서 이루어집니다.

2.3 ExecutionContext: 상태 관리와 재시작

Step이 실행되는 도중, 만약의 사태(서버 다운, 에러 발생 등)로 중단되었다가 재시작될 때 이전 상태를 기억해야 할 수 있습니다. 이때 사용되는 것이 바로 ExecutionContext입니다.

ExecutionContextStep의 '메모장' 또는 '상태 저장소' 역할을 하는 Key-Value 형태의 객체입니다. 여기에 저장된 데이터는 JobRepository를 통해 영속화되어 다음 실행에서 참조할 수 있습니다.

실제 코드를 통해 ExecutionContext가 어떻게 동작하는지 살펴보겠습니다. 예제 프로젝트는 batchJob이라는 하나의 Job과 4개의 Step(step1 -> step2 -> step3 -> step4)이 순차적으로 실행되는 간단한 구조로 이루어져 있습니다. 각 단계를 따라가며 ExecutionContext에 저장된 데이터가 어떻게 변하고 공유되는지 추적해보겠습니다.

Step1,2 : Scope 차이 확인

@Configuration  
public class ExecutionContextConfig {  
// ...
    @Bean  
    public Job batchJob() {  
        return new JobBuilder("batchJob", jobRepository)  
                .start(step1())  
                .next(step2())  
                .next(step3())  
                .next(step4())  
                .build();  
    }  
// ...
}

ExecutionContext에는 JobExecutionContextStepExecutionContext, 두 가지 종류가 있습니다.

  • JobExecutionContext: Job 전체의 생명주기 동안 유지되며, Job에 속한 모든 Step에서 데이터를 읽고 쓸 수 있는 '전역 저장소'입니다.
  • StepExecutionContext: 각 Step의 생명주기 동안만 유지되는 '지역 저장소'입니다. 즉, step1에서 저장한 데이터는 step2에서 직접 접근할 수 없습니다.
// ExecutionContextTasklet1.java
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
    log.info("step1 execute");

    ExecutionContext jobExecutionContext = contribution.getStepExecution().getJobExecution().getExecutionContext(); // Job 범위
    ExecutionContext stepExecutionContext = contribution.getStepExecution().getExecutionContext(); // Step 범위

    String jobName = chunkContext.getStepContext().getStepExecution().getJobExecution().getJobInstance().getJobName();
    String stepName = chunkContext.getStepContext().getStepExecution().getStepName();

    if (jobExecutionContext.get("jobName") == null) {
        jobExecutionContext.put("jobName", jobName); // JobExecutionContext에 저장
    }
    if (stepExecutionContext.get("stepName") == null) {
        stepExecutionContext.put("stepName", stepName); // StepExecutionContext에 저장
    }
    // ...
    return RepeatStatus.FINISHED;
}

ExecutionContextTasklet1에서는 JobExecutionContextStepExecutionContext에 각각 jobNamestepName을 저장합니다. put() 메서드를 통해 Key-Value 형태로 데이터가 저장되는 것을 확인할 수 있습니다.

//ExecutionContextTasklet2.java
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
    log.info("step2 execute");

    ExecutionContext jobExecutionContext = contribution.getStepExecution().getJobExecution().getExecutionContext();
    ExecutionContext stepExecutionContext = contribution.getStepExecution().getExecutionContext();

    log.info("jobName: {}", jobExecutionContext.get("jobName")); // step1에서 저장한 값 조회 성공
    log.info("stepName: {}", stepExecutionContext.get("stepName")); // step1에서 저장한 값 조회 실패 (null)
    // ...
    return RepeatStatus.FINISHED;
}

ExecutionContextTasklet2에서는 jobName은 정상적으로 조회되지만, stepNamenull로 나오게 됩니다.

즉, StepExecutionContext의 데이터는 해당 Step이 종료되면 사라지기 때문에 step2에서는 step1stepName을 읽을 수 없다는 것을 확인할 수 있습니다.

Step3,4 : 실패와 재시작 시나리오

먼저 ExecutionContextTasklet3JobExecutionContext에 "name"이라는 키가 없으면 값을 저장하고, 의도적으로 예외를 발생시켜 Job을 실패시킵니다

//ExecutionContextTasklet3.java
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
    log.info("step3 execute");

    ExecutionContext context = chunkContext.getStepContext()
            .getStepExecution().getJobExecution()
            .getExecutionContext();

    if (Objects.isNull(context.get("name"))) {
        context.put("name", "user1");
        throw new RuntimeException("step3에서 의도적으로 실패!"); // 예외 발생
    }
    return RepeatStatus.FINISHED;
}

Job이 처음 실행되면 step3에서 RuntimeException이 발생하여 Job은 FAILED 상태가 됩니다.

하지만 여기서 중요한 점은, 예외가 발생하기 직전에 context.put("name", "user1")으로 저장한 데이터는 Job이 실패했음에도 불구하고 JobRepository를 통해 데이터베이스에 영속화된다는 것입니다.

해당 데이터는 BATCH_JOB_EXECUTION_CONTEXT에서 확인할 수 있으며, Spring Batch 5의 경우 Base64 인코딩 되어있기 때문에 평문으로 저장하길 원한다면 다음과 같은 설정이 필요합니다.

@Configuration  
public class Jackson2ExecutionConfig {  
    @Bean  
    public ExecutionContextSerializer executionContextSerializer() {  
        return new Jackson2ExecutionContextStringSerializer();  
    }  
}

BATCH_JOB_EXECUTION_CONTEXT

{
   "@class":"java.util.HashMap",
   "jobName":"batchJob",
   "name":"user1",
   "batch.version":"5.2.2"
}

이제 실패한 Job을 재시작해 보겠습니다. Spring Batch는 마지막 실패 지점인 step3부터 실행을 재개합니다. 이때 데이터베이스에 저장되었던 JobExecutionContext의 내용("name" : "user1")이 자동으로 복구됩니다.

그 결과, step3if문은 false가 되어 이번에는 예외 없이 통과하고, 마침내 ExecutionContextTasklet4가 실행됩니다.

//ExecutionContextTasklet4.java
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
    log.info("step4 execute");
    // JobExecutionContext에서 "name" 키의 값을 조회합니다.
    log.info("name : {}", chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext().get("name"));
    log.info("step4 end");
    return RepeatStatus.FINISHED;
}

Job의 상태가 실패 후에도 성공적으로 유지되어 step4의 로그에서는 name : user1이 정상적으로 출력되는 것을 볼 수 있습니다.

2.4 StepContribution: 트랜잭션 내 결과 취합

StepContribution은 하나의 트랜잭션 범위 내에서 발생한 Step의 실행 결과를 임시로 취합하는 객체입니다.

ExecutionContext가 재시작을 위한 '상태' 정보 저장에 가깝다면, StepContribution은 현재 실행 중인 트랜잭션의 '성과'를 기록하는 데 가깝습니다. 예를 들어, Chunk 처리 중 1000개의 아이템을 쓰고 커밋하기 직전, "이번 청크에서 1000개를 썼다"는 정보를 이 객체에 기록합니다.

트랜잭션이 성공적으로 커밋되는 순간, StepContribution에 기록된 정보(writeCount 등)는 StepExecution의 전체 통계에 안전하게 합산(merge)됩니다.

2.5 StepExecution: 상태 업데이트와 종료

Step의 모든 로직이 끝나면, StepExecutionSTATUSCOMPLETED로, 실패했다면 FAILED 로 업데이트됩니다. 최종 통계와 종료 시간이 기록되고 JobRepository를 통해 데이터베이스에 영속화되면, Step의 라이프사이클은 비로소 마무리됩니다.

3. Step과 메타데이터 테이블의 관계

위에서 설명한 객체들은 결국 데이터베이스 테이블에 기록되어 관리됩니다. Step과 직접적으로 연관된 핵심 테이블은 두 가지입니다.

BATCH_STEP_EXECUTION 테이블

StepExecution 객체의 정보가 저장되는 곳입니다. Step 실행의 모든 물리적인 기록이 담깁니다.

  • 주요 컬럼:
    • STEP_NAME: 실행된 Step의 이름
    • STATUS: COMPLETED, FAILED 등 최종 실행 상태
    • COMMIT_COUNT, READ_COUNT, FILTER_COUNT, WRITE_COUNT: 각 Step의 처리 통계
    • START_TIME, END_TIME: 시작 및 종료 시간

BATCH_STEP_EXECUTION_CONTEXT 테이블

ExecutionContext의 정보가 저장되는 곳입니다. 재시작을 위한 상태 정보가 여기에 기록됩니다.

  • 주요 컬럼:
    • SHORT_CONTEXT: ExecutionContext에 저장된 Key-Value 데이터가 직렬화(Serialized)되어 저장됩니다. Spring Batch는 재시작 시 이 정보를 역직렬화하여 Step의 이전 상태를 복원합니다.

정리

  • Step은 Job을 구성하는 트랜잭션 단위의 핵심 작업이며, StepExecution을 통해 실행의 전 과정이 기록됩니다.
  • ExecutionContext는 재시작을 위한 상태 저장소의 역할을, StepContribution은 트랜잭션 내의 결과 취합 도구의 역할을 수행합니다.
  • 이러한 핵심 객체들과 메타데이터 테이블의 유기적인 관계는 Spring Batch의 안정성과 재시작성을 보장하는 가장 중요한 원리입니다.
profile
왜? 라는 질문이 사라질 때까지

0개의 댓글