Spring Batch 오류 처리 — Skip·Retry·SkipPolicy

2026-05-03확률과 통계 마스터 노트

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();
}

처리 순서

  1. 예외가 Retry 대상? → 맞으면 retryLimit 남으면 재시도
  2. retryLimit 소진 또는 Retry 대상 X → Skip 대상 확인
  3. Skip 대상이면 스킵 + SkipListener
  4. 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 단계는 아이템 객체 XFlatFileParseException.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() 혼동 주의

시리즈 다른 편

공식 문서: Spring Batch Fault Tolerance에서 모든 옵션을 볼 수 있어요.

다음 글(8편)에서는 시리즈 마지막 — Spring Batch 4.x → 5.x 마이그레이션. @EnableBatchProcessing 제거, JobBuilder/StepBuilder 직접 생성, jakarta 네임스페이스, chunk(size, transactionManager), MySQL 커넥터 변경까지 풀어 갑니다.

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

답글 남기기

error: Content is protected !!