Spring Batch 입문 14편 — Skip Logic (부분 실패 무시 · SkipPolicy · SkipListener)

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

Spring Batch 입문 14편. Skip Logic — 일부 실패를 무시하고 계속 진행하는 패턴. faultTolerant·skipLimit·skip·noSkip·SkipPolicy·SkipListener·재시작 시 skip count 누적까지 풀어쓴 학습 노트.

📚 Spring Batch 입문에서 운영까지 · 14편 — Skip Logic (부분 실패 무시 · SkipPolicy · SkipListener)

이 글은 Spring Batch 입문에서 운영까지 시리즈 48편 중 14편이에요. 13편 에서 Step Restart 를 잡았다면, 이번 14편은 부분 실패 허용 의 핵심 — Skip Logic.

Skip Logic 의 정체성

Skip = 일부 record 의 실패를 무시하고 다음 record 로 진행.

Step 처리 중 ...
  ├─ Record 1: 정상
  ├─ Record 2: 정상
  ├─ Record 3: FlatFileParseException ★ → SKIP + count
  ├─ Record 4: 정상
  ├─ ...
  └─ Record N: 정상
Step COMPLETED (with skipCount=5)

N건을 전부 처리하되 일부 실패는 통계로만 남긴다. Job 전체는 COMPLETED 로 끝나요.

언제 Skip 을 쓰나

공식 문서의 의견:

"Financial data, for example, may not be skippable because it results in money being transferred, which needs to be completely accurate. Loading a list of vendors, on the other hand, might allow for skips."

도메인 Skip 권장?
금융 거래 ❌ (정확성 필수)
의료 데이터
Vendor·고객 목록 적재
Log 파일 ETL (Extract·Transform·Load)
통계·집계
마스터 데이터 동기화 △ (요건에 따라)

비즈니스 판단이지 코드 문제가 아니에요. 기준은 데이터의 의미.

기본 설정

@Bean
public Step skipStep(JobRepository repo, PlatformTransactionManager tx) {
    return new StepBuilder("skipStep", repo)
        .<String, String>chunk(10, tx)
        .reader(flatFileItemReader())
        .writer(itemWriter())
        .faultTolerant()                              // ★ 필수
        .skipLimit(10)                                 // 최대 10번 skip
        .skip(FlatFileParseException.class)            // 어떤 exception 을 skip
        .build();
}

faultTolerant() — 필수

Skip·Retry 활성화의 gateway. 안 박으면 skip 설정 자체가 무시돼요.

skipLimit(N) — 최대 skip 횟수

전체 Step 의 누적 skip 한계. N+1번째 skip = Step FAILED.

skipLimit(10)
record 11 = exception → skip + count=1
record 22 = exception → skip + count=2
...
record 100 = exception → skip + count=10 (limit 도달)
record 105 = exception → ❌ Step FAILED

Integer.MAX_VALUE 도 가능해서 사실상 무제한이지만 권장하지 않아요. 데이터 품질을 모니터링하는 의미 자체가 사라지니까.

skip(ExceptionClass) — 어떤 exception 을 skip

.skip(FlatFileParseException.class)
.skip(ValidationException.class)
.skip(DataIntegrityViolationException.class)

여러 번 호출해서 여러 exception 을 등록할 수 있고, 자식 class 도 자동 포함돼요.

여기서 시험에 자주 나오는 함정 — 너무 광범위한 exception skip 위험.

.skip(Exception.class)        // ❌ 모든 exception skip = 진짜 버그 숨김

예상 가능한 exception 만 골라서 명시하는 게 안전해요.

LimitCheckingExceptionHierarchySkipPolicy — 코드 형태

builder 방식이 내부적으로 쓰는 policy 가 이거예요.

SkipPolicy skipPolicy = new LimitCheckingExceptionHierarchySkipPolicy(
    Set.of(FlatFileParseException.class, ValidationException.class),
    10                                                                  // skipLimit
);

return new StepBuilder("skipStep", repo)
    .<X, Y>chunk(10, tx)
    .reader(...).writer(...)
    .faultTolerant()
    .skipPolicy(skipPolicy)                                              // ★
    .build();

skipPolicy()skipLimit()+skip() 은 효과가 같고, 복잡한 정책이 필요하면 SkipPolicy 를 직접 구현하면 돼요.

자주 쓰는 SkipPolicy

AlwaysSkipItemSkipPolicy

.skipPolicy(new AlwaysSkipItemSkipPolicy())

모든 exception skip 에 무제한이라 위험. 진짜 사고가 터져도 안 멈춰요.

NeverSkipItemSkipPolicy

.skipPolicy(new NeverSkipItemSkipPolicy())

아무것도 skip 안 하니까 첫 exception 에서 바로 FAILED. faultTolerant() 안 박은 것과 비슷한 효과예요.

Custom SkipPolicy

public class BusinessSkipPolicy implements SkipPolicy {
    @Override
    public boolean shouldSkip(Throwable t, long skipCount) throws SkipLimitExceededException {
        // 비즈니스 로직 기반
        if (t instanceof CriticalException) {
            return false;          // 절대 skip X
        }
        if (skipCount >= 50) {
            throw new SkipLimitExceededException(50, t);
        }
        return t instanceof ValidationException;
    }
}

시간대·데이터 유형·exception cause 같은 동적이고 복잡한 조건을 걸 때 직접 짜요.

Skip 의 정확한 동작 — Read · Process · Write 별

.skip(SomeException.class)

Read·Process·Write 모든 단계에서 발생할 수 있고, 단계별로 동작이 달라요.

Read skip

chunk 처리 중...
  Reader.read() → SomeException
  ↓ SKIP (해당 record 무시)
  Reader.read() → 다음 record (정상)

해당 record 만 skip 되고 chunk 의 다른 record 에는 영향이 없어요.

Process skip

Reader.read() x N → chunk 완성
Processor.process(item) → SomeException 발생
  ↓ SKIP (해당 item 만)
Processor.process(다음 item) → 정상

Process 단계 skip 도 개별 item 단위라서 chunk 의 나머지는 그대로 진행돼요.

Write skip — 가장 복잡

Writer.write(100건) → SomeException 발생
  ↓
chunk 전체 rollback
  ↓
재시도: chunk 의 100건을 *1건씩 transaction 으로 재시도*
  - record 1 write → OK
  - record 2 write → OK
  - ...
  - record 50 write → SomeException → SKIP
  - record 51 write → OK
  ...

Writer skip 은 매우 비싸요. chunk 전체 rollback 후 1건씩 다시 돌리니까 성능이 폭락할 수 있어요.

그래서 대부분 환경에서 Read·Process skip 은 일반적으로 쓰지만, Write skip 은 신중하게 들어가요.

SkipListener — 무엇이 skip 됐나 추적

@Component
public class MySkipListener implements SkipListener<Customer, ValidatedCustomer> {

    @Override
    public void onSkipInRead(Throwable t) {
        log.warn("Skipped during read", t);
        // 또는 DLQ·로그 시스템에 전송
    }

    @Override
    public void onSkipInProcess(Customer item, Throwable t) {
        log.warn("Skipped during process: {}", item, t);
        skippedRepository.save(new SkippedRecord(item, t));
    }

    @Override
    public void onSkipInWrite(ValidatedCustomer item, Throwable t) {
        log.warn("Skipped during write: {}", item, t);
    }
}
.faultTolerant()
.skip(...)
.skipLimit(...)
.listener(new MySkipListener())

주로 이런 자리에서 써요.

  • DLQ (Dead Letter Queue) 패턴 — skip 된 record 를 별도 table·topic 에 저장
  • 알림 — skip 이 너무 많으면 Slack·email 로 호출
  • 통계 — 어떤 exception 이 자주 skip 되는지 분석

운영 환경 권장 — Skip 패턴

@Bean
public Step robustEtlStep(JobRepository repo, PlatformTransactionManager tx,
                          SkipListener<RawData, CleanData> skipListener) {
    return new StepBuilder("robustEtlStep", repo)
        .<RawData, CleanData>chunk(500, tx)
        .reader(reader)
        .processor(processor)
        .writer(writer)
        .faultTolerant()
        .skipLimit(100)
        .skip(FlatFileParseException.class)
        .skip(ValidationException.class)
        .noSkip(CriticalDataException.class)         // 명시적 skip X
        .listener(skipListener)
        .build();
}

noSkip(ExceptionClass) — 명시적 차단

.skip(Exception.class)                    // 광범위
.noSkip(CriticalDataException.class)       // 단 이건 X

광범위 skip 을 깔아두고 특정 exception 만 차단하는 식이에요. Exception hierarchy 를 정교하게 제어하고 싶을 때 유용해요.

재시작 시 Skip Count

첫 실행: skip 8 / 10 limit (FAILED, 다른 이유)
   ↓ 재시작
재시작: skip count = 8 부터 시작
   추가 3 skip = total 11 > limit 10 → FAILED

Skip count 는 JobInstance 전체에 누적되니까, 재시작으로 skip 한계를 우회할 수는 없어요.

이게 정상 동작이에요. 모든 시도의 skip 합을 limit 으로 보기 때문에 운영 자동 재시도가 무한 skip 우회 로 쓰이지 않도록 막아주는 셈이지요.

DLQ 패턴

@Component
public class DlqSkipListener implements SkipListener<Customer, ValidatedCustomer> {

    @Autowired
    private SkippedRecordRepository repo;

    @Override
    public void onSkipInRead(Throwable t) {
        repo.save(SkippedRecord.builder()
            .phase("READ")
            .exception(t.getClass().getName())
            .message(t.getMessage())
            .timestamp(LocalDateTime.now())
            .build());
    }

    @Override
    public void onSkipInProcess(Customer item, Throwable t) {
        repo.save(SkippedRecord.builder()
            .phase("PROCESS")
            .itemData(serializeItem(item))
            .exception(t.getClass().getName())
            .message(t.getMessage())
            .build());
    }
    // ...
}

별도 DB table 에 skip 된 record 를 저장해두면 나중에 이런 작업이 가능해져요.

  • 수동 검토·복구
  • 패턴 분석
  • 재처리 batch 작성

운영 환경에서 데이터 품질 관리 의 표준 패턴이고, 시리즈 2 Kafka 의 DLQ 패턴 (118편) 과 같은 사상이에요.

자주 헷갈리는 자리

faultTolerant() 안 박으면 skip 무효

.skip(FlatFileParseException.class)        // ❌ faultTolerant() 없음
.skipLimit(10)

faultTolerant() 는 필수예요. 빠뜨리면 Spring Batch 가 skip 설정을 통째로 무시해버려요.

skipLimit vs skip 의 의미

.skipLimit(10)         // 최대 skip 횟수
.skip(X.class)         // 어떤 exception 을 skip

둘 다 필요해요. skipLimit 만 있으면 skip 대상이 명시 안 돼서 어떤 exception 도 skip 되지 않고, skip 만 있으면 limit 없이 전부 skip 돼서 위험해요.

Read·Process·Write 의 skip count 분리

JobRepository 가 각 단계 별 skipCount 를 영속화해요.

stepExecution.getReadSkipCount()
stepExecution.getProcessSkipCount()
stepExecution.getWriteSkipCount()

운영 모니터링에서 어느 단계가 자주 skip 되는지 추적할 때 써요.

한계·실무 함정

1. 광범위한 skip = 진짜 버그 숨김

.skip(Exception.class) 는 모든 사고를 무시해서 데이터 손실로 이어져요. 명시적 type 만 골라요.

2. skipLimit 너무 큼 = 데이터 품질 무시

skipLimit(Integer.MAX_VALUE) 는 skip 의미가 사라져요. 전체 record 의 1% 같이 합리적인 한계를 둬요.

3. Write skip 의 성능

Writer skip 은 chunk rollback + 1건씩 재시도 구조라서, chunk size 100 에 writer skip 이 걸리면 100 transaction 으로 갈라져서 성능이 폭락해요.

해결책은 Writer 측 검증을 Process 단계로 옮기는 거예요. Process skip 이 훨씬 효율적이에요.

4. SkipListener 의 transaction

Listener 의 DB write 같은 작업이 같은 transaction 안에서 도는지 확인하세요. 별도 transaction (@Transactional(propagation=REQUIRES_NEW)) 으로 분리하는 쪽을 권해요.

5. 재시작 후 skip count 누적

위에서 강조했듯 총 누적이라 재시작이 skip 회피로 쓰일 수 없어요.

6. ItemReader 의 skip 동작

대부분 Reader 는 exception 이 난 record 만 skip 하는데, transaction 안에서 stream 기반으로 도는 Reader (JDBC Cursor) 는 chunk 전체에 영향을 줄 수 있어요.

시험 직전 한 번 더 — Skip Logic 함정 압축 노트

  • Skip = 일부 record 실패 무시 + Step 계속 진행
  • 비즈니스 판단 = 금융 X, vendor 목록·log ETL O
  • faultTolerant() = skip·retry 활성화 gateway (필수)
  • skipLimit(N) = 최대 skip 횟수, N+1 = FAILED
  • skip(ExceptionClass) = 어떤 exception 을 skip (여러 번 호출 가능)
  • noSkip(ExceptionClass) = 명시적 차단 (hierarchy 정교한 제어)
  • 광범위 skip = 진짜 버그 숨김 → 명시적 type 만
  • LimitCheckingExceptionHierarchySkipPolicy = builder 의 내부 구현
  • Custom SkipPolicy = 동적·복잡 조건
  • 3가지 단계 = Read · Process · Write 모두 skip 가능
  • Read skip = 해당 record 만, chunk 다른 OK
  • Process skip = 해당 item 만, chunk 다른 OK
  • Write skip = chunk rollback + 1건씩 재시도 (매우 비쌈)
  • SkipListeneronSkipInRead·onSkipInProcess·onSkipInWrite
  • DLQ 패턴 = SkipListener + 별도 DB table 저장
  • 재처리·패턴 분석·알림에 사용
  • 재시작 시 skip count 누적 (총 누적) — 재시작이 skip 회피 X
  • 운영 모니터링 = stepExecution.getReadSkipCount·getProcessSkipCount·getWriteSkipCount
  • 함정 — faultTolerant() 누락 → skip 무시
  • 함정 — skipLimit 너무 큼 → 데이터 품질 무시
  • 함정 — Write skip 의 성능 (chunk rollback + 1건씩)
  • 함정 — SkipListener transaction (REQUIRES_NEW 권장)
  • 함정 — ItemReader 의 stream 기반 = chunk 전체 영향 가능

공식 문서: Configuring Skip Logic 에서 원문을 확인할 수 있어요.

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

이전 글:

다음 글:

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

답글 남기기

error: Content is protected !!