Spring Batch 입문 14편. Skip Logic — 일부 실패를 무시하고 계속 진행하는 패턴. faultTolerant·skipLimit·skip·noSkip·SkipPolicy·SkipListener·재시작 시 skip count 누적까지 풀어쓴 학습 노트.
이 글은 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 = FAILEDskip(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건씩 재시도 (매우 비쌈)
- SkipListener —
onSkipInRead·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 에서 원문을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 9편 — Running a Job (JobLauncher · Sync/Async · Scheduler)
- 10편 — Advanced Metadata (JobExplorer · JobRegistry · 운영 대시보드)
- 11편 — Step 종합 + Chunk-oriented vs TaskletStep
- 12편 — Chunk Step 설정 + Commit Interval 튜닝
- 13편 — Step Restart + 부모 Step 상속
다음 글: