배치에 faultTolerant() 를 붙이면 어떤 예외를 retry·skip 에 넣을지, 몇 번·몇 초 간격으로 다시 할지, skipLimit 은 몇으로 둘지 정해야 해요. 결론부터 말하면 retry 는 외부 API·네트워크처럼 트랜잭션 밖의 일시 장애에만 쓰고, skip 은 "이 한 건을 빼도 결과가 맞는" 데이터 오류에만 쓰며, 둘 다 아니면 빨리 실패시키는 게 기본이에요. Spring Batch 6 에서는 재시도가 롤백 없이 같은 트랜잭션 안에서 일어나서, 5.x 시절 감각으로 DB 오류를 retry 에 넣으면 기대와 다르게 움직여요.
이 글은 값을 고르는 기준과 운영에서 볼 순서를 다뤄요. 기준은 Spring Batch 6.0.6, Spring Framework 7.0.10 소스와 Spring Batch 레퍼런스의 retry 설정 장이고, 시간·호출 수는 조건을 밝힌 직접 계산이에요(2026-11-06). 청크 크기와 재시작 위치는 스프링 배치 청크 크기 정하는 법에서 따로 다뤘어요.
예외 분류표, retry·skip·즉시 실패 중 어디로 보낼까
skip 설정 문서도 skip 여부는 데이터의 의미를 아는 사람이 정할 일이라고 적어요. 같은 예외라도 업무에 따라 갈래가 달라져요.
DB 예외는 스프링이 번역한 DataAccessException 계열 이름이에요.
| 예외 | 갈래 | 이유 |
|---|---|---|
| 외부 API 의 503·429·연결 끊김 | retry | 잠시 뒤 성공 가능, DB 트랜잭션과 무관 |
QueryTimeoutException, TransientDataAccessResourceException | retry 후보 | 일시적이지만 오류 뒤 트랜잭션 상태가 DB 마다 다름(아래 절) |
PessimisticLockingFailureException(교착·락 획득 실패) | 조건부 retry | 같은 트랜잭션 재시도가 막히는 DB 가 있음 |
OptimisticLockingFailureException | retry 말고 재읽기 | 같은 낡은 버전으로 다시 써도 또 충돌 |
FlatFileParseException, 검증 실패 | skip | 몇 번 해도 같은 결과 |
DataIntegrityViolationException(중복 키 등) | skip 또는 실패 | 중복이 정상 상황인지 업무로 판단 |
| 외부 API 400·401·403·404 | 실패(400·404 는 skip 가능) | 인증·설정 오류는 다음 건도 똑같이 실패 |
OutOfMemoryError 등 Error | 실패 | 빌더 기본 정책도 Exception 만 대상 |
TransientDataAccessException 문서에 일시적 DB 예외의 하위 클래스 목록이 있어요. DeadlockLoserDataAccessException 은 스프링 6.0.3 부터 deprecated 라 새 코드에는 부모인 PessimisticLockingFailureException 을 적어요. 외부 API 의 상태 코드가 어느 쪽인지 헷갈리면 HTTP 상태 코드 조회에서 뜻과 흔한 원인을 확인하고 갈래를 정해요.
retry 설정값 고르는 법, retryLimit 대신 RetryPolicy
6.0 빌더의 retry()·retryLimit() 은 결국 Spring Framework 7 의 RetryPolicy 하나를 만드는 편의 메서드예요. 조합별 동작은 ChunkOrientedStepBuilder 소스의 build() 에서 확인할 수 있어요.
| 설정 | 실제 호출 횟수 | 대기 | 대상 예외 |
|---|---|---|---|
| 아무것도 안 줌 | 1번 | 없음 | 없음(첫 실패가 곧 skip 판정) |
retry(X.class) 만 | 1번 | 없음 | X 지만 재시도 0회 |
retryLimit(3) 만 | 4번 | 1초 고정 | Exception 전부 |
retry(X.class).retryLimit(3) | 4번 | 1초 고정 | X 와 원인 체인 |
RetryPolicy.builder().includes(X).build() | 4번 | 1초 고정 | X (maxRetries 기본 3) |
빌더에 maxRetries·delay·multiplier 지정 | maxRetries+1 | 직접 지정 | 직접 지정 |
두 번째 줄이 가장 흔한 실수예요. 예외만 적고 횟수를 빼면 재시도 0회예요. RetryPolicy 소스는 "총 시도 = 첫 시도 1 + maxRetries" 라고 주석에 적고 있고, includes 는 예외의 원인(cause)까지 따라가며 맞춰 봐요.
출발점으로는 재시도 2회, 100ms 에서 두 배씩, 대기 최대 500ms, 전체 3초 상한을 권해요.
RetryPolicy retryPolicy = RetryPolicy.builder()
.includes(TransientApiException.class, QueryTimeoutException.class)
.excludes(OptimisticLockingFailureException.class)
.maxRetries(2) // 총 3번 호출
.delay(Duration.ofMillis(100))
.multiplier(2)
.maxDelay(Duration.ofMillis(500))
.timeout(Duration.ofSeconds(3)) // 대기 포함 상한
.build();
Step step = new StepBuilder("importStep", jobRepository)
.<Row, Order>chunk(200)
.transactionManager(transactionManager)
.reader(reader).processor(processor).writer(writer)
.faultTolerant()
.retryPolicy(retryPolicy)
.skipPolicy(new DataErrorSkipPolicy(150))
.skipListener(skipRecorder)
.build();
timeout 은 첫 호출부터 흐른 시간 기준이고, RetryTemplate 이 대기 전에 "기다리면 넘는지" 미리 계산해 포기해요. 느린 호출이 섞여도 트랜잭션 길이에 상한이 생겨요. retryPolicy() 를 주면 retry()·retryLimit() 은 무시돼요.
재시도 비용 직접 계산, 트랜잭션 길이와 중복 호출
재시도가 트랜잭션을 쥐는 시간
5.x 는 쓰기 실패 때 롤백하고 다음 트랜잭션에서 다시 시도했지만, 6.0 은 같은 트랜잭션 안에서 잠들었다가 다시 불러요. 실패가 트랜잭션 하나의 길이로 그대로 더해져요.
계산식은 트랜잭션 길이 = (maxRetries + 1) × 쓰기 1회 시간 + 대기 시간 합 이에요. 대기 합은 고정 간격이면 maxRetries × delay, 배수면 delay × (1 + m + m² + …) 이고 maxDelay 에서 잘려요.
그동안 청크가 쓴 행의 락과 커넥션이 묶여요. 풀이 10개인데 파티션 10개가 같은 장애로 재시도에 들어가면 풀 전체가 최대 4.2초씩 잠겨요. 락 오류를 재시도하면 락 보유 시간이 늘어나는 셈이라, 교착이 잦은 테이블은 재시도보다 쓰는 순서를 키 순으로 맞추고 청크를 줄이는 쪽이 먼저예요. 격리 수준과 재시도의 관계는 트랜잭션 격리 수준 고르는 법에 정리했어요.
JPA 도 flush 가 실패하면 트랜잭션이 rollback-only 가 되고, 재시도한 write() 가 성공해도 6.0.6 은 청크를 실패로 처리해요(ChunkOrientedStep 소스의 isRollbackOnly 검사). flush 동작은 JPA 더티 체킹과 플러시 모드 글에 정리했어요.
라이터 재시도의 중복 호출
외부 API 를 한 건씩 부르는 라이터는 retry 가 맞는 곳이지만, 재시도가 청크 전체로 write() 를 다시 부르니 실패한 건 앞의 성공한 호출이 매번 다시 나가요.
청크 100, 재시도 3회면 이미 끝난 99건이 297번 더 호출돼요. 두 번 나가면 안 되는 호출이면 셋 중 하나를 골라요.
- 멱등 키: (잡 인스턴스, 아이템 키)로 만든 키를 보내 API 가 중복을 무시하게 해요.
- 재시도를 라이터 안으로: 스텝 retry 는 끄고, 라이터에서 호출 한 건만
RetryTemplate으로 감싸요. 앞 건이 다시 나가지 않아요. - 청크를 작게: 중복 범위가 청크 크기에 비례하니 외부 호출 라이터는 10~50 이 무난해요.
skipLimit 정하는 법, 예상 불량 수로 계산
skipLimit 기본값은 10이고 읽기·가공·쓰기 skip 을 합쳐 세요. 너무 크면 데이터가 통째로 깨져도 COMPLETED 로 끝나요. 지난 실행들의 평균 skip 수를 λ 로 잡고 불량이 서로 독립이라고 보면, skip 수는 대략 푸아송 분포를 따라요.
한도를 예상값과 같게 두면 두 번에 한 번꼴로 멈춰요. 130 이면 0.2% 아래예요
표로 보기
| 구분 | 정상 변동으로 멈출 확률 |
|---|---|
| 100 | 47.3 |
| 110 | 14.7 |
| 120 | 2.3 |
| 130 | 0.17 |
| 140 | 0.006 |
그래서 한도 ≈ λ + 3√λ 가 출발점이에요. λ=100 이면 130, λ=10 이면 약 20 이에요. 이걸 넘으면 원본이 깨졌을 가능성이 커서 멈추는 게 맞아요.
6.0 빌더에는 5.x 의 noSkip() 이 없어서, 일시 장애를 skip 에서 빼려면 SkipPolicy 를 직접 만들어요.
class DataErrorSkipPolicy implements SkipPolicy {
private final long limit;
DataErrorSkipPolicy(long limit) { this.limit = limit; }
@Override
public boolean shouldSkip(Throwable t, long skipCount) {
if (has(t, TransientDataAccessException.class)) return false; // 일시 장애는 skip 금지
if (!has(t, FlatFileParseException.class) && !has(t, ValidationException.class)) return false;
if (skipCount < 0) return true; // 청크 쓰기 단계: 종류만 판단
if (skipCount >= limit) throw new SkipLimitExceededException(limit, t);
return true;
}
private static boolean has(Throwable t, Class<? extends Throwable> type) {
for (Throwable c = t; c != null; c = c.getCause()) if (type.isInstance(c)) return true;
return false;
}
}
skipCount < 0 줄이 중요해요. 6.0.6 은 청크 쓰기 실패 때 스캔 여부를 정하려고 정책을 -1 로 한 번 부르고, 스캔에서 아이템마다 실제 skip 수로 다시 불러요. 정책 안에서 호출을 직접 세면 쓰기 skip 이 두 번 세어져요.
비율로 보려면 afterStep 에서 getSkipCount() / getReadCount() 를 계산해 스텝 흐름 제어 문서의 SkipCheckingListener 예제처럼 "COMPLETED WITH SKIPS" 를 돌려주고, 알림 스텝으로 분기해요.
skip 기록 남기는 법, SkipListener 트랜잭션 함정
skip 된 건을 테이블에 남겨 고치는 운영이 흔한데, 콜백이 도는 트랜잭션 때문에 기록이 빠지기 쉬워요. 스텝 실행 가로채기 문서가 직접 설명하는 부분이에요.
onSkipInWrite: 스캔의 한 건짜리 트랜잭션 안에서 불리고, 콜백이 끝나면 그 트랜잭션은 항상 롤백돼요. 같은 DataSource 로 INSERT 하면 반드시 사라져요.onSkipInRead·onSkipInProcess: 청크 트랜잭션 안에서 불려요. 그 청크가 쓰기 실패로 롤백되면 기록도 같이 사라지고, 스캔 때 이 건은 다시 가공되지 않아 콜백도 다시 불리지 않아요.
그래서 기록 메서드를 REQUIRES_NEW 트랜잭션으로 분리하거나 로그 파일·큐에 써요. 콜백은 아이템당 한 번이 보장되지만, 실패 후 재시작으로 같은 청크를 다시 하면 또 불릴 수 있어 (잡 인스턴스 ID, 아이템 키) 유일 제약으로 중복을 막아요.
REQUIRES_NEW 는 청크 트랜잭션의 커넥션을 쥔 채 하나를 더 빌려요. 풀이 스레드 수만큼만 있으면 서로 기다리며 멈추니, 풀은 최소 "동시 스텝 스레드 × 2" 로 잡아요.
5.x 에서 6.0 으로 옮길 때 retry·skip 체크리스트
옛 chunk(n, transactionManager).faultTolerant() 는 6.0 에서 제거 예정이에요. 6.0 마이그레이션 가이드와 소스를 대조했어요.
| 확인할 것 | 5.x | 6.0 | 할 일 |
|---|---|---|---|
retryLimit(3) | 총 3번 | 총 4번 | 같은 횟수를 원하면 2로 |
| 기본 대기 | 없음 | 1초 | delay·maxDelay 직접 지정 |
| 쓰기 재시도 위치 | 롤백 후 다음 트랜잭션 | 같은 트랜잭션 | DB 예외를 retry 목록에서 다시 검토 |
noSkip()·noRetry() | 있음 | 빌더에 없음 | excludes(), 직접 만든 SkipPolicy |
| 리스너 예외 | 아이템 실패로 retry·skip | 항상 스텝 실패 | 리스너 안에서 잡아 로그로 |
backOffPolicy() | Spring Retry | 없음 | RetryPolicy 의 delay·multiplier |
리스너 줄이 사고로 이어지기 쉬워요. 5.x 에서 리스너 예외가 skip 으로 넘어가던 잡이 6.0 에서는 바로 실패하니, 옮기기 전에 리스너마다 try-catch 가 있는지 훑어요.
운영 장애 확인 순서, 증상별 체크리스트
| 증상 | 흔한 원인 | 확인·조치 |
|---|---|---|
| 업그레이드 뒤 배치가 눈에 띄게 느려짐 | 6.0 기본 1초 대기 | 재시도 로그 수 × 1초가 늘어난 시간과 맞는지 |
| 외부 API 호출 수가 건수보다 많음 | 청크 단위 제자리 재시도 | 위 중복 호출 계산, 멱등 키 |
SkipLimitExceededException | 한도가 예상 변동보다 작음, 원본 이상 | 지난 실행 skip 수와 비교 후 한도 또는 원본 |
스캔 중 NonSkippableWriteException | 스캔은 재시도 없이 한 번만 씀 | 일시 장애가 스캔과 겹쳤는지, 재시작 |
| "rollback-only" 로그와 함께 청크 실패 | JPA flush 실패 뒤 재시도 성공 | 해당 예외를 retry 에서 빼고 skip 또는 실패로 |
| skip 수는 있는데 기록 테이블이 빔 | 롤백되는 트랜잭션 안에서 기록 | REQUIRES_NEW 또는 트랜잭션 밖 저장소 |
| 커넥션 획득 타임아웃 | 재시도 대기 중 커넥션 보유, REQUIRES_NEW 추가 | 대기 상한·풀 크기 재계산 |
BATCH_STEP_EXECUTION 의 단계별 skip 수와 ROLLBACK_COUNT 를 먼저 보면 어느 단계 문제인지 좁혀져요. 6.0.6 은 스캔에 들어가는 청크마다, 쓰기 skip 마다 롤백을 하나씩 세서 둘의 차이가 대략 스캔한 청크 수예요.
도입 전에 따질 것, faultTolerant 를 안 켜는 게 나은 경우
- 한 건도 빠지면 안 되는 데이터: 정산·송금은 skip 없이, 실패하면 고친 뒤 재시작해요.
- 불량을 미리 걸러낼 수 있을 때: 프로세서에서
null을 돌려주는 필터는 롤백·스캔이 없어요. - DB 쓰기만 하는 스텝: 제자리 재시도는 DB 오류에 약하니 실패 후 재시작에 맡기는 편이 단순해요.
- 장애가 몇 분씩 가는 의존 시스템: 초 단위 재시도로는 못 버텨요. 실패시키고 스케줄러가 몇 분 뒤 재시작하게 해요.
자주 묻는 질문
retryLimit(3) 이면 몇 번 호출되나요?
6.0 에서는 4번이에요. 이 값은 RetryPolicy 의 maxRetries, 즉 첫 시도를 뺀 재시도 횟수예요. 5.x 처럼 총 3번이면 2로 바꿔요.
retry 를 설정했는데 한 번도 재시도하지 않아요.
retry(예외) 만 적고 retryLimit 을 빠뜨렸는지 보세요. 기본값 0이라 재시도 0회 정책이 돼요. faultTolerant() 를 빠뜨려도 retry·skip 이 모두 무시돼요.
skip 과 retry 에 같은 예외를 넣으면 어떻게 되나요?
먼저 재시도하고, 다 써도 실패하면 skip 판정을 해요. 일시 장애를 두 목록에 다 넣으면 장애가 길 때 정상 데이터가 skip 으로 빠지니 skip 목록에서는 빼요.
SkipListener 에서 DB 에 기록했는데 남지 않아요.
onSkipInWrite 는 곧 롤백될 트랜잭션 안에서 불려요. 기록을 REQUIRES_NEW 로 분리하거나 파일·큐에 남기고, 재시작 중복은 유일 키로 막아요.
skipLimit 은 몇으로 잡아야 하나요?
지난 실행 평균 skip 수 λ 에 3√λ 를 더한 값에서 시작해요. 평균 100이면 130 이에요. 처음 돌리는 잡은 작게 두고 몇 번 실행한 뒤 맞춰요.
정리
- 예외는 "다시 하면 나을까 → 빼도 결과가 맞을까" 순서로 retry·skip·즉시 실패에 나눠요.
- 6.0 은
retryLimit(n)이 총 n+1번, 기본 대기 1초, 같은 트랜잭션 재시도예요.RetryPolicy로delay·maxDelay·timeout을 직접 주고, DB 오류보다 외부 API 에 써요. - 라이터 재시도는 청크 전체를 다시 부르니
maxRetries × (청크 − 1)만큼 중복 호출을 각오하거나 멱등 키·라이터 안 재시도로 막아요. - skipLimit 은 λ + 3√λ, skip 기록은 REQUIRES_NEW 와 유일 키로 남겨요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 스프링 배치 Skip·Retry 원리 — 제자리 재시도와 스캔 모드에서 도식과 코드로 단계별로 풀었어요.