Spring Batch 입문 13편. Step Restart 메커니즘 — startLimit·allowStartIfComplete·재시작 시 COMPLETED Step skip 동작, StepBuilder 의 부모 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 라 사실상 무제한입니다.
여기서 시험 함정이 하나 있어요. startLimit 은 re-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 에서 원문을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 8편 — JobOperator (실행 · 중지 · 재시작 · CommandLine)
- 9편 — Running a Job (JobLauncher · Sync/Async · Scheduler)
- 10편 — Advanced Metadata (JobExplorer · JobRegistry · 운영 대시보드)
- 11편 — Step 종합 + Chunk-oriented vs TaskletStep
- 12편 — Chunk Step 설정 + Commit Interval 튜닝
다음 글: