Spring Batch 입문 13편 — Step Restart + 부모 Step 상속

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

Spring Batch 입문 13편. Step Restart 메커니즘 — startLimit·allowStartIfComplete·재시작 시 COMPLETED Step skip 동작, StepBuilder 의 부모 Step 상속·재사용 패턴까지 풀어쓴 학습 노트.

📚 Spring Batch 입문에서 운영까지 · 13편 — Step Restart + 부모 Step 상속

이 글은 Spring Batch 입문에서 운영까지 시리즈 48편 중 13편이에요. 12편 에서 Chunk Step 설정·commit interval 을 잡았다면, 이번 13편에서는 실패 후 재시작의 핵심인 Step Restart 와 부모 Step 상속을 다뤄볼게요.

Step Restart — 큰 그림

8편 JobOperator (배치 잡을 외부에서 실행·중지·재시작시키는 운영용 인터페이스) 에서 본 Job restart 가 Step 레벨에서는 어떻게 동작하는지 살펴봐요.

재시작 시 Step 의 기본 동작

JobInstance "월말 결산 2026-01"
   ↓ 첫 실행 (FAILED)
   ├── Step 1 (COMPLETED) — 검증
   ├── Step 2 (COMPLETED) — 데이터 적재
   └── Step 3 (FAILED)     — 집계

JobOperator.restart(executionId)
   ↓ 새 JobExecution
   ├── Step 1 — *SKIP* (이미 COMPLETED)
   ├── Step 2 — *SKIP* (이미 COMPLETED)
   └── Step 3 — *재시도* (ExecutionContext 부터)

JobInstance (잡 정의·파라미터 조합 단위의 논리적 실행 식별자) 안에서 이전 JobExecution (한 번의 실제 실행 시도) 이 COMPLETED 로 끝난 Step 은 자동으로 skip 돼요. 재시작은 FAILED Step 부터 다시 시작합니다.

allowStartIfComplete — COMPLETED 도 다시

특정 Step 이 재시작 때마다 항상 다시 실행되어야 한다면 이렇게 잡아요.

@Bean
public Step validationStep(JobRepository repo, PlatformTransactionManager tx) {
    return new StepBuilder("validationStep", repo)
        .<Input, Output>chunk(100, tx)
        .reader(...).writer(...)
        .allowStartIfComplete(true)            // ★
        .build();
}

JobRepository (잡·스텝 메타데이터 저장소) 와 PlatformTransactionManager (Spring 트랜잭션 추상화) 를 받아 StepBuilder (스텝 정의용 빌더 API) 로 묶어주면 됩니다. 자주 쓰는 자리는 이래요.

  • 사전 검증 step — 매 시도마다 검증
  • 사전 cleanup — 외부 시스템 lock 해제 등
  • 시작 전 리소스 준비

이런 Step 은 항상 처음부터 다시 실행되고 COMPLETED 상태를 무시해요.

startLimit — 최대 시작 횟수 제한

@Bean
public Step manualFixStep(JobRepository repo, PlatformTransactionManager tx) {
    return new StepBuilder("manualFixStep", repo)
        .<Input, Output>chunk(100, tx)
        .reader(...).writer(...)
        .startLimit(1)                          // ★
        .build();
}

이렇게 잡으면 1번만 실행할 수 있고, 두 번째 시도에서 곧장 StartLimitExceededException (시작 횟수 초과 예외) 이 떨어져요. 자주 쓰는 자리는 이래요.

  • 수동 개입 필요한 작업 — 외부 리소스 무효화·일회성
  • DDL (스키마 정의 SQL)·index rebuild — 운영 환경에서 한 번만
  • 민감한 외부 호출 — 결제 같은 재시도 위험 작업

기본값은 Integer.MAX_VALUE 라 사실상 무제한입니다.

여기서 시험 함정이 하나 있어요. startLimitre-start 횟수가 아니라 start 횟수예요. 첫 실행도 1회로 카운트되니까 startLimit(1) 은 총 1회 실행이고, 재시작을 시도하면 즉시 fail 입니다.

재시작 동작 정리

설정 첫 실행 재시작 (이전 COMPLETED) 재시작 (이전 FAILED)
기본 (둘 다 안 설정) 실행 SKIP 재실행
allowStartIfComplete(true) 실행 재실행 재실행
startLimit(N) 실행 (count=1) SKIP 재실행 (count++)
둘 다 + count=N 실행 재실행 재실행 (count > N 면 fail)

재시작 시 ExecutionContext 의 역할

@Override
public void open(ExecutionContext context) {
    if (context.containsKey("currentItemCount")) {
        this.currentItemCount = context.getInt("currentItemCount");
    }
}

@Override
public void update(ExecutionContext context) {
    context.putInt("currentItemCount", this.currentItemCount);
}

ExecutionContext (스텝·잡 실행 상태를 키-값으로 저장하는 컨텍스트) 에 Reader·Writer 가 현재 위치를 저장해두면, 재시작 때 그 지점부터 이어갈 수 있어요.

대부분의 공식 ItemReader/Writer (스프링 배치가 제공하는 표준 입출력 컴포넌트) — 예컨대 FlatFileItemReader (플랫 파일 라인 단위 리더) 같은 것들 — 은 ItemStream (재시작 안전성을 위한 상태 저장 인터페이스) 을 자동으로 구현하고 있어서 개발자가 따로 코드를 짜지 않아도 재시작이 안전합니다. 깊이는 24편 ItemStream 에서 다시 봐요.

Restartable Job 설정 (6편 복습)

new JobBuilder("myJob", repo)
    .preventRestart()           // 재시작 차단 (idempotency X 환경)
    .start(step)
    .build();

기본값이 restartable = true 라서, idempotency (같은 작업을 여러 번 해도 결과가 같은 성질) 가 보장되지 않는 환경이라면 preventRestart() 를 박아서 Job 레벨에서 차단해요.

자주 만나는 패턴

패턴 1: 일반 ETL — 기본 동작 그대로

@Bean
public Job etlJob(JobRepository repo, Step extract, Step transform, Step load) {
    return new JobBuilder("etlJob", repo)
        .incrementer(new RunIdIncrementer())
        .start(extract)
        .next(transform)
        .next(load)
        .build();
}

ETL (Extract·Transform·Load 의 약자, 데이터 추출·변환·적재) 잡은 restartable=true 기본값에 각 Step 의 기본 동작이면 충분해요. FAILED 지점부터 알아서 재시작합니다. RunIdIncrementer (실행마다 run.id 파라미터를 증가시켜 새 JobInstance 를 만드는 헬퍼) 가 매 실행마다 새 인스턴스를 보장해줘요.

패턴 2: 사전 검증·정리는 항상

@Bean
public Step validationStep(...) {
    return new StepBuilder("validation", repo)
        .tasklet(validationTasklet, tx)
        .allowStartIfComplete(true)     // 매 재시작마다 검증
        .build();
}

패턴 3: 일회성 작업

@Bean
public Step ddlStep(...) {
    return new StepBuilder("ddl", repo)
        .tasklet(ddlTasklet, tx)
        .startLimit(1)                   // 1번만
        .build();
}

패턴 4: 검증 + 적재 + 집계

.start(validationStep)         // allowStartIfComplete(true) — 항상
.next(loadStep)                // 기본 — 한 번만
.next(aggregateStep)           // 기본 — 한 번만

부모 Step 상속

여러 Step 이 chunk size·skip policy·listener 같은 공통 설정을 공유할 때 쓰는 패턴이에요.

부모 Step 정의

@Bean
public Step parentStep(JobRepository repo, PlatformTransactionManager tx) {
    return new StepBuilder("parentStep", repo)
        .<Input, Output>chunk(100, tx)
        .reader(commonReader())
        .writer(commonWriter())
        .faultTolerant()
        .skipLimit(10)
        .skip(FlatFileParseException.class)
        .build();
}

XML 의 abstract Step (옛 방식)

<step id="parentStep" abstract="true">
    <tasklet>
        <chunk reader="reader" writer="writer" commit-interval="100" skip-limit="10">
            <skippable-exception-classes>
                <include class="org.springframework.batch.infrastructure.item.file.FlatFileParseException"/>
            </skippable-exception-classes>
        </chunk>
    </tasklet>
</step>

<step id="childStep" parent="parentStep">
    <tasklet>
        <chunk reader="childReader"/>      <!-- writer 만 부모 것 사용 -->
    </tasklet>
</step>

Java Config 의 패턴 — Builder 메서드 추출

Spring Batch 6 에서는 XML abstract 대신 builder 메서드를 공유하는 방식을 권장해요.

public class CommonStepBuilder {
    public static <I, O> SimpleStepBuilder<I, O> baseChunkStep(
            String name, JobRepository repo, PlatformTransactionManager tx,
            Class<I> input, Class<O> output) {
        return new StepBuilder(name, repo)
            .<I, O>chunk(100, tx)
            .faultTolerant()
            .skipLimit(10)
            .skip(FlatFileParseException.class);
    }
}

@Bean
public Step customersStep(...) {
    return CommonStepBuilder.<Customer, ValidatedCustomer>baseChunkStep(
            "customersStep", repo, tx, Customer.class, ValidatedCustomer.class)
        .reader(customerReader())
        .processor(customerProcessor())
        .writer(customerWriter())
        .build();
}

@Bean
public Step ordersStep(...) {
    return CommonStepBuilder.<Order, ValidatedOrder>baseChunkStep(
            "ordersStep", repo, tx, Order.class, Order.class)
        .reader(orderReader())
        .writer(orderWriter())
        .build();
}

SimpleStepBuilder (청크 지향 스텝용 빌더) 를 반환하는 메서드를 공유하면 Java 의 자연스러운 상속·재사용 패턴이 되고, XML abstract 보다 type-safe (컴파일 타임에 타입 검증) 합니다.

Listener·SkipPolicy 공유

public class CommonStepConfig {

    public static StepExecutionListener commonListener() {
        return new MyStepListener();
    }

    public static SkipPolicy commonSkipPolicy() {
        return new LimitCheckingExceptionHierarchySkipPolicy(
            Set.of(FlatFileParseException.class, ValidationException.class), 10);
    }
}

StepExecutionListener (스텝 시작·종료 시점 콜백 인터페이스) 와 SkipPolicy (예외별 skip 허용 여부 정책) 를 정적 메서드로 빼두고 각 Step 에서 재사용해요.

재시작 시나리오 — 실전

시나리오 1: 일반 ETL 의 재시작

[FAILED Execution]
  Step extract: COMPLETED (10000 read, 10000 write)
  Step transform: FAILED (5000 read, 5000 process, 4900 write, exception)
  Step load: NOT_STARTED

[Restart 호출]
  Step extract: SKIP (COMPLETED)
  Step transform: 재시작
    - ExecutionContext 부터 (5000 번째 record 이후)
    - 추가 5000 read·process·write
  Step load: 실행

대부분 자동으로 처리돼서 개발자가 따로 코드를 짤 일이 없어요.

시나리오 2: 검증 + 적재 — 매번 검증

@Bean
public Step validationStep(...) {
    return ...
        .tasklet(validateTasklet, tx)
        .allowStartIfComplete(true)
        .build();
}

@Bean
public Step loadStep(...) {
    return ...
        .reader(...).writer(...)
        .build();      // 기본 — COMPLETED skip
}
[FAILED Execution]
  validationStep: COMPLETED
  loadStep: FAILED (5000/10000)

[Restart]
  validationStep: 재실행 (검증 재확인) ★
  loadStep: 재시작 (5000 부터)

시나리오 3: startLimit 초과

.startLimit(3)
실행 1: COMPLETED → count=1
실행 2: COMPLETED → count=2
실행 3: COMPLETED → count=3
실행 4: ❌ StartLimitExceededException

한계·실무 함정

1. ExecutionContext 누락 = 처음부터

Reader·Writer 가 ItemStream 을 구현하지 않으면 재시작 때 처음부터 다시 돌아요. 대부분의 공식 구현체는 안전하지만, custom Reader 를 직접 만들 때는 이 부분을 꼭 챙겨야 합니다.

2. allowStartIfComplete(true) 의 비용

매 재시작마다 처음부터 다시 실행되니까 그만큼 I/O·시간 비용이 따라붙어요. 정말 필요한 자리에만 박아두세요.

3. preventRestart() 의 의미

Job 레벨에서 모든 재시도를 차단합니다. Step 의 startLimit 보다 상위에서 동작해요.

4. 중간 Step 의 ExitStatus 가 다음 흐름 영향

ExitStatus (스텝 종료 상태 문자열, 다음 분기 흐름을 결정) 가 어떻게 다음 Step 으로 이어지는지는 20편 flow control 영역에서 다시 봐요. 재시작 시에도 이전 ExitStatus 가 기억됩니다.

5. 부모 Step 상속의 가독성

상속 chain 이 너무 깊어지면 어떤 설정이 어디서 오는지 추적이 어려워져요. 최대 2단계 정도가 안전합니다.

6. Custom skip / retry policy 의 재사용

각 Step 이 별도 SkipPolicy 를 들고 있으면 통합 limit 을 걸 수 없어요. 공통 정책은 공유 bean 으로 빼두세요.

시험 직전 한 번 더 — Step Restart 함정 압축 노트

  • 재시작 기본 동작 = COMPLETED Step SKIP, FAILED Step 재시작 (ExecutionContext 부터)
  • allowStartIfComplete(true) = COMPLETED Step 도 매 재시작마다 다시 실행
  • 자주 자리 = 사전 검증·cleanup·리소스 준비
  • startLimit(N) = 총 N 회 시작 허용 (기본 Integer.MAX_VALUE)
  • 초과 시 = StartLimitExceededException
  • 자주 자리 = 일회성·민감한 외부 호출·DDL
  • 첫 실행도 카운트 (재시작 횟수 X)
  • 재시작 시 ExecutionContext 활용 = Reader·Writer 가 current position 저장·복구
  • 대부분 공식 ItemReader/Writer 는 ItemStream 자동 구현
  • Restartable 은 Job 레벨 (preventRestart() 박으면 차단)
  • 부모 Step 상속 — XML abstract="true" (옛) vs Java builder 메서드 공유 (권장)
  • Builder 메서드 = SimpleStepBuilder 반환, type-safe
  • Listener·SkipPolicy 도 공유 bean 으로
  • 시나리오 — 일반 ETL (기본) · 사전 검증 (allowStartIfComplete) · 일회성 (startLimit(1))
  • 함정 — ExecutionContext 누락 = 처음부터
  • 함정 — allowStartIfComplete(true) 의 매 재시작 비용
  • 함정 — preventRestart 가 startLimit 보다 상위
  • 함정 — 너무 깊은 상속 chain
  • 함정 — Custom SkipPolicy 통합 limit 불가 (공유 bean)

공식 문서: Configuring a Step for Restart · Inheriting from a Parent Step 에서 원문을 확인할 수 있어요.

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

이전 글:

다음 글:

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

답글 남기기

error: Content is protected !!