Spring Batch 마스터 노트 시리즈 7편. 운영 수준의 오류 처리 — faultTolerant 한 줄로 시작하는 Skip 기능, Read/Process/Write 단계별 동작 차이, SkipListener로 스킵 아이템 별도 기록, 데이터 분석 기반 SkipPolicy, 일시적 오류용 Retry 청크 재처리, 자동 Restart까지 한 흐름으로.
이 글은 Spring Batch 마스터 노트 시리즈의 일곱 번째 편입니다. 배치 처리는 대량 데이터를 다루니 일부 데이터 오류는 피할 수 없어요. 모든 오류에 Job 전체 실패시키면 한 개 불량 데이터로 수백만 건이 멈추고, 모든 오류 무시하면 중요 데이터 손실. 이번 편의 핵심 — Skip·Retry·Restart 세 메커니즘의 정확한 사용 자리.
처음 오류 처리가 어렵게 느껴지는 이유
이유는 두 가지예요.
첫째, Skip·Retry·Restart의 적합한 자리가 미묘하게 다릅니다. 일시적 네트워크 오류 = Retry, 영구적 데이터 오류 = Skip, Job 전체 실패 후 = Restart — 잘못 선택하면 무의미한 재시도가 반복되거나 중요 데이터가 사라져요.
둘째, Skip이 일어나는 자리가 3곳(Read·Process·Write)이고 각자 동작이 미묘하게 달라요. 특히 Write 단계 Skip은 청크 전체 롤백 후 개별 재시도라서 처음 보면 당황합니다.
해결법은 한 가지예요. 오류 유형 한 줄 분류. 일시적(네트워크·DB Deadlock) = Retry / 영구적(데이터 형식·검증) = Skip / Job 실패 = Restart (자동). 이 한 줄만 잡으면 90% 결정 끝.
오류 처리 4가지 메커니즘
| 전략 | 동작 | 적합 자리 |
|---|---|---|
| Skip | 오류 아이템 건너뛰고 계속 | 일부 데이터 오류 허용 |
| SkipPolicy | 커스텀 로직으로 스킵 결정 | 복잡한 스킵 조건 |
| Retry | 오류 시 재시도 | 일시적 오류 |
| Restart | Job 전체 재실행 | 중단점부터 재시작 |
Skip 기능
기본 설정
@Bean
public Step productStep() {
return stepBuilderFactory.get("productStep")
.<Product, Product>chunk(10)
.reader(csvReader())
.processor(productProcessor())
.writer(dbWriter())
// skip/retry 사용하려면 faultTolerant() 먼저
.faultTolerant()
// 스킵할 예외 (여러 개 가능)
.skip(ValidationException.class)
.skip(FlatFileParseException.class)
// 최대 스킵 허용 (초과 시 Job 실패)
.skipLimit(3)
.build();
}
여기서 정말 중요한 시험 함정 — faultTolerant() 없이 skip() 호출하면 컴파일/런타임 오류입니다. 이 한 줄을 빠뜨리면 Spring Batch가 fault tolerant 모드가 안 켜져서 skip 설정이 무시돼요.
noSkip()로 특정 예외 제외
.faultTolerant()
.skip(Exception.class) // 모든 Exception 스킵
.noSkip(CriticalException.class) // 단, CriticalException은 스킵 X
.skipLimit(Integer.MAX_VALUE)
단계별 Skip 동작
| 단계 | 동작 |
|---|---|
| Read | 예외 → 해당 아이템 스킵, 다음 아이템으로 |
| Process | 예외 → 청크에서 제거, 나머지 Write로 |
| Write | 예외 → 청크 전체 롤백, 각 아이템 개별 재시도, 실패한 것만 스킵 |
여기서 정말 중요한 시험 함정 — Write 단계 Skip은 가장 비싼 작업이에요. 청크 전체 롤백 → 아이템 개별 재처리. 청크 크기 100에서 1개 실패면 100개를 다시 1개씩 INSERT. 성능 영향 큼. Write 실패는 가능한 한 사전에 검증·필터링으로 막기.
SkipListener — 스킵 추가 처리
스킵된 아이템을 별도 파일·DB에 기록.
public interface SkipListener<T, S> extends StepListener {
void onSkipInRead(Throwable t);
void onSkipInProcess(T item, Throwable t);
void onSkipInWrite(S item, Throwable t);
}
구현 예시
@Component
public class ProductSkipListener implements SkipListener<Product, Product> {
private static final String SKIP_FILE = "output/skip_log.txt";
@Override
public void onSkipInProcess(Product item, Throwable t) {
System.out.println("Process 스킵: " + item.getProductName() + " - " + t.getMessage());
writeToFile("PROCESS_SKIP," + item + "," + t.getMessage());
}
@Override
public void onSkipInRead(Throwable t) {
System.out.println("Read 스킵: " + t.getMessage());
if (t instanceof FlatFileParseException) {
String failedLine = ((FlatFileParseException) t).getInput();
writeToFile("READ_SKIP," + failedLine + "," + t.getMessage());
}
}
@Override
public void onSkipInWrite(Product item, Throwable t) {
System.out.println("Write 스킵: " + item.getProductName() + " - " + t.getMessage());
writeToFile("WRITE_SKIP," + item + "," + t.getMessage());
}
private void writeToFile(String content) {
try (FileWriter fw = new FileWriter(SKIP_FILE, true);
BufferedWriter bw = new BufferedWriter(fw)) {
bw.write(content);
bw.newLine();
} catch (IOException e) {
System.err.println("스킵 로그 기록 실패: " + e.getMessage());
}
}
}
FlatFileParseException — 원본 데이터 추출
CSV 파싱 오류 시 원본 라인 정보를 얻을 수 있어요.
@Override
public void onSkipInRead(Throwable t) {
if (t instanceof FlatFileParseException) {
FlatFileParseException parseException = (FlatFileParseException) t;
String failedLine = parseException.getInput(); // 원본 라인
int lineNumber = parseException.getLineNumber(); // 라인 번호
System.out.println("파싱 오류:");
System.out.println(" 라인: " + lineNumber);
System.out.println(" 원본: " + failedLine);
System.out.println(" 원인: " + parseException.getCause().getMessage());
writeToFile(lineNumber + ": " + failedLine);
}
}
여기서 시험 함정이 하나 있어요. Read 단계 스킵은 아이템 객체가 없어요. ItemReader가 객체를 못 만들고 실패한 거니 onSkipInRead는 예외 정보만 받아요. 원본 데이터는 FlatFileParseException.getInput()로 추출.
등록
@Bean
public Step productStep() {
return stepBuilderFactory.get("productStep")
.<Product, Product>chunk(10)
.reader(csvReader())
.processor(productProcessor())
.writer(dbWriter())
.faultTolerant()
.skip(ValidationException.class)
.skip(FlatFileParseException.class)
.skipLimit(5)
.listener(productSkipListener())
.build();
}
커스텀 SkipPolicy
복잡한 조건의 스킵 결정.
public interface SkipPolicy {
boolean shouldSkip(Throwable t, int skipCount) throws SkipLimitExceededException;
}
기본 구현
public class MySkipPolicy implements SkipPolicy {
@Override
public boolean shouldSkip(Throwable t, int skipCount) throws SkipLimitExceededException {
// ValidationException + 3번 미만 스킵
if (t instanceof ValidationException && skipCount < 3) {
return true;
}
// FlatFileParseException + 3번 미만 스킵
if (t instanceof FlatFileParseException && skipCount < 3) {
return true;
}
// 그 외 = Job 실패
return false;
}
}
데이터 분석 기반 SkipPolicy
원본 데이터를 분석해 스킵 여부 결정.
public class DataAnalysisSkipPolicy implements SkipPolicy {
private static final int EXPECTED_FIELD_COUNT = 4;
@Override
public boolean shouldSkip(Throwable t, int skipCount) throws SkipLimitExceededException {
if (t instanceof FlatFileParseException) {
FlatFileParseException parseException = (FlatFileParseException) t;
String failedLine = parseException.getInput();
String[] fields = failedLine.split(",");
if (fields.length >= EXPECTED_FIELD_COUNT) {
// 필드 수 충분 — 사소한 오류 → 스킵
return true;
} else {
// 필드 수 부족 — 필수 데이터 누락 → 실패
return false;
}
}
if (t instanceof ValidationException) {
return skipCount < 5;
}
return false;
}
}
"필드 수가 예상보다 많으면 사소한 오류(스킵)" vs "예상보다 적으면 중요 데이터 누락(실패)" 전략.
등록
@Bean
public MySkipPolicy mySkipPolicy() {
return new MySkipPolicy();
}
@Bean
public Step productStep() {
return stepBuilderFactory.get("productStep")
.<Product, Product>chunk(10)
.reader(csvReader())
.processor(productProcessor())
.writer(dbWriter())
.faultTolerant()
// skip/skipLimit 대신 skipPolicy 사용
.skipPolicy(mySkipPolicy())
.listener(productSkipListener())
.build();
}
여기서 시험 함정이 하나 있어요. .skipPolicy() 설정하면 .skip() / .skipLimit() 무시됩니다. 두 방식 동시 설정은 혼란 — 하나만 선택.
Retry 기능
Retry vs Skip 선택
| 기준 | Retry | Skip |
|---|---|---|
| 오류 유형 | 일시적(Transient) | 영구적(데이터 오류) |
| 재시도 시 성공 가능성 | 높음 | 낮음 (고정적) |
| 예시 | 네트워크 타임아웃, API 다운, DB Deadlock | 잘못된 CSV 형식, NULL 검증 |
| 데이터 무결성 | 완전 처리 목표 | 일부 무시 허용 |
기본 설정
@Bean
public Step productStep() {
return stepBuilderFactory.get("productStep")
.<Product, Product>chunk(10)
.reader(csvReader())
.processor(productProcessor())
.writer(dbWriter())
.faultTolerant()
// 재시도할 예외
.retry(MyTransientException.class)
.retry(NetworkTimeoutException.class)
// 최대 재시도 (총 실행 = 1 + retryLimit)
// retryLimit(4) → 처음 1 + 재시도 3 = 최대 4회
.retryLimit(4)
.build();
}
Retry의 청크 단위 재처리
여기서 정말 중요한 시험 함정 — Retry가 발생하면 실패한 아이템 1개가 아니라 청크 전체가 재처리됩니다.
청크 [item1, item2, item3(예외), item4, item5]
1차:
- process(item1) ✓
- process(item2) ✓
- process(item3) → MyException 발생!
- 트랜잭션 롤백
- 청크 전체 재처리 시작
2차 (재시도):
- process(item1) ✓ (다시!)
- process(item2) ✓ (다시!)
- process(item3) → MyException 발생!
- ...
ItemReader의 read() 도 다시 호출되니 부작용 있는 read() 는 위험. 외부 API 호출 ItemReader에서 이중 호출 주의.
커스텀 예외
public class MyException extends Exception {
public MyException(String message) {
super(message);
}
public MyException(String message, Throwable cause) {
super(message, cause);
}
}
Retry 테스트 — 랜덤 예외
public class RandomFailureProcessor implements ItemProcessor<Product, Product> {
private final Random random = new Random();
@Override
public Product process(Product item) throws Exception {
// 33% 확률로 예외
if (random.nextInt(3) == 2) {
throw new MyException("Transient error");
}
return item;
}
}
실행 예시:
Processing: IPhone 14 Pro
Processing: Samsung Galaxy S23
Random exception for: Samsung Galaxy S23
[청크 롤백·재처리 시작]
Processing: IPhone 14 Pro ← 다시!
Processing: Samsung Galaxy S23 → 성공 (이번엔 랜덤 안 걸림)
Processing: Sony WH-1000XM5
[청크 커밋]
Retry는 ItemReader에 적용 X
여기서 정말 중요한 시험 함정 — Retry는 ItemProcessor·ItemWriter에서만 동작합니다. ItemReader 예외에는 적용 X. Read 단계 일시적 오류는 별도 재연결 로직이나 재시도 가능 ItemReader 직접 구현 필요.
Job Restart — 자동 재시작
시나리오: step1·step2·step3, step2 실패
1차 실행:
- step1: COMPLETED
- step2: FAILED (10개 청크 중 7개 커밋)
- step3: 실행 X
2차 실행 (재시작):
- step1: 건너뜀 (이미 COMPLETED)
- step2: 8번째 청크부터 재시작 (7개 청크는 이미 커밋)
- step3: step2 성공 시 실행
JobRepository의 메타데이터 기반. 청크 단위 재시작.
preventRestart()
@Bean
public Job nonRestartableJob() {
return jobBuilderFactory.get("nonRestartableJob")
.preventRestart()
.start(myStep())
.build();
}
이미 실패한 Job 재실행 시 JobRestartException. 멱등 X 작업·부분 실행 의미 없는 작업에 사용.
Skip + Retry 조합
@Bean
public Step combinedStep() {
return stepBuilderFactory.get("combinedStep")
.<Product, Product>chunk(10)
.reader(csvReader())
.processor(productProcessor())
.writer(dbWriter())
.faultTolerant()
// Retry — 일시적 오류
.retry(NetworkException.class)
.retry(DbConnectionException.class)
.retryLimit(3)
// Skip — 영구적 데이터 오류
.skip(ValidationException.class)
.skip(FlatFileParseException.class)
.skipLimit(10)
.listener(skipListener())
.build();
}
처리 순서
- 예외가 Retry 대상? → 맞으면 retryLimit 남으면 재시도
- retryLimit 소진 또는 Retry 대상 X → Skip 대상 확인
- Skip 대상이면 스킵 + SkipListener
- Skip도 아니거나 skipLimit 초과 → Job 실패
종합 예시
@Configuration
@EnableBatchProcessing
public class RobustBatchConfig {
@Autowired private JobBuilderFactory jobBuilderFactory;
@Autowired private StepBuilderFactory stepBuilderFactory;
@Autowired private DataSource dataSource;
@Bean
public SkipPolicy productSkipPolicy() {
return (throwable, skipCount) -> {
if (skipCount >= 10) {
return false;
}
if (throwable instanceof FlatFileParseException) {
String line = ((FlatFileParseException) throwable).getInput();
if (line == null || line.trim().isEmpty()) {
return true;
}
return line.split(",").length >= 4;
}
if (throwable instanceof ValidationException) {
return true;
}
return false;
};
}
@Bean
public SkipListener<Product, Product> productSkipListener() {
return new SkipListener<Product, Product>() {
@Override
public void onSkipInRead(Throwable t) {
System.err.println("[SKIP][READ] " + t.getMessage());
if (t instanceof FlatFileParseException) {
System.err.println("원본: " + ((FlatFileParseException) t).getInput());
}
}
@Override
public void onSkipInProcess(Product item, Throwable t) {
System.err.println("[SKIP][PROCESS] " + item + " - " + t.getMessage());
}
@Override
public void onSkipInWrite(Product item, Throwable t) {
System.err.println("[SKIP][WRITE] " + item + " - " + t.getMessage());
}
};
}
@Bean
public Step robustProductStep() {
return stepBuilderFactory.get("robustProductStep")
.<Product, Product>chunk(10)
.reader(csvReader())
.processor(productProcessor())
.writer(dbWriter())
.faultTolerant()
.retry(DbTransientException.class)
.retryLimit(3)
.skipPolicy(productSkipPolicy())
.listener(productSkipListener())
.build();
}
@Bean
public Job robustProductJob() {
return jobBuilderFactory.get("robustProductJob")
.incrementer(new RunIdIncrementer())
.start(robustProductStep())
.build();
}
}
일반적 실수와 주의사항
faultTolerant() 빠뜨림
skip/retry 사용 시 반드시 먼저 .faultTolerant() 호출.
Retry는 청크 전체 재처리
ItemReader도 다시 호출. 부작용 있는 read() 위험.
skipLimit vs retryLimit
skipLimit(3)— 스킵 3번까지 허용. 4번째 = 실패.retryLimit(4)— 총 4회 실행. 5번째 = 실패.
미묘한 차이.
SkipPolicy 단독 사용
.skipPolicy() 와 .skip()/.skipLimit() 동시 X — Policy가 우선.
Retry는 Reader에 X
Read 단계 일시 오류는 별도 처리.
항상 실패하는 예외에 Retry
데이터 형식 오류 같은 영구적 오류에 Retry = 무의미한 재처리. Skip 사용.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 7편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- 4가지 메커니즘 — Skip / SkipPolicy / Retry / Restart
- Retry = 일시적 / Skip = 영구적 / Restart = Job 실패 후
faultTolerant()먼저 — 빠뜨리면 skip/retry 무시.skip(Exception.class)+.skipLimit(N)noSkip()— 모든 스킵 + 특정 예외 제외- 스킵 단계 — Read / Process / Write (청크 전체 롤백)
- Write 스킵은 비싸다 — 사전 검증·필터링으로 회피
- SkipListener 3 메서드 — onSkipInRead/Process/Write
- Read 단계는 아이템 객체 X —
FlatFileParseException.getInput()으로 원본 추출 SkipPolicy.shouldSkip(Throwable, int)커스텀 결정.skipPolicy()설정하면.skip()무시- 데이터 분석 기반 Policy — 필드 수 등으로 분기 가능
- Retry는 청크 전체 재처리 — read()도 다시
- retryLimit(4) = 처음 1 + 재시도 3 = 최대 4회
- Retry는 ItemProcessor·ItemWriter만 — Reader X
- 일시 오류 vs 영구 오류 구분 필수
- Restart = JobRepository 기반 자동 (청크 단위)
- 이미 COMPLETED Step은 건너뜀
preventRestart()= 재시작 방지- Skip + Retry 조합 — Retry 우선, retryLimit 후 Skip
onWriteError에서 실패 아이템 로깅setStrict(true)(PromotionListener)와.faultTolerant()혼동 주의
시리즈 다른 편
- 1편 — Spring Batch 입문 (Job·Step·Chunk 모델)
- 2편 — Spring Batch Job 설정 (Tasklet과 Chunk Step)
- 3편 — 청크 처리 (Reader·Processor·Writer 패턴)
- 4편 — ItemReader 마스터 (CSV·JdbcCursor·Paging)
- 5편 — ItemWriter 마스터 (FlatFile·JdbcBatch·Composite)
- 6편 — Job Flow와 리스너
- 7편 — 오류 처리 (현재 글)
- 8편 — Spring Batch 5 마이그레이션
공식 문서: Spring Batch Fault Tolerance에서 모든 옵션을 볼 수 있어요.
다음 글(8편)에서는 시리즈 마지막 — Spring Batch 4.x → 5.x 마이그레이션. @EnableBatchProcessing 제거, JobBuilder/StepBuilder 직접 생성, jakarta 네임스페이스, chunk(size, transactionManager), MySQL 커넥터 변경까지 풀어 갑니다.