광고
~ / posts / developer

스프링 배치 retry·skip 고르는 법, 예외 분류표와 재시도 비용

스프링 배치 6 에서 예외를 retry·skip·즉시 실패 중 어디로 보낼지 판단표로 정리했어요. RetryPolicy 로 지연·한도 정하는 법, 재시도가 트랜잭션을 쥐는 시간과 중복 호출 수 계산, skipLimit 산정, skip 기록이 사라지는 이유까지 다뤄요.

배치에 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 여부는 데이터의 의미를 아는 사람이 정할 일이라고 적어요. 같은 예외라도 업무에 따라 갈래가 달라져요.

같은 입력을 다시 하면 성공할 수 있으면 retry, 한 건만 빼고 커밋해도 결과가 맞으면 skip, 둘 다 아니면 즉시 실패로 보내는 판단 순서도
위에서부터 물어 처음 '예'가 나온 칸으로 보내요

DB 예외는 스프링이 번역한 DataAccessException 계열 이름이에요.

예외갈래이유
외부 API 의 503·429·연결 끊김retry잠시 뒤 성공 가능, DB 트랜잭션과 무관
QueryTimeoutException, TransientDataAccessResourceExceptionretry 후보일시적이지만 오류 뒤 트랜잭션 상태가 DB 마다 다름(아래 절)
PessimisticLockingFailureException(교착·락 획득 실패)조건부 retry같은 트랜잭션 재시도가 막히는 DB 가 있음
OptimisticLockingFailureExceptionretry 말고 재읽기같은 낡은 버전으로 다시 써도 또 충돌
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 에서 잘려요.

계속 실패하는 청크 하나가 트랜잭션을 쥐는 시간 (쓰기 1회 0.3초 가정)
6.0 maxRetries 2 + 100ms6.0 maxRetries 2 + 100ms · 1.1초1.1초6.0 maxRetries 3 + 100ms x26.0 maxRetries 3 + 100ms x2 · 1.9초1.9초6.0 maxRetries 5 + 200ms x2 + 최대 500ms6.0 maxRetries 5 + 200ms x2 + 최대 500ms · 3.9초3.9초6.0 retryLimit(3) 기본6.0 retryLimit(3) 기본 · 4.2초4.2초6.0 maxRetries 5 + 200ms x26.0 maxRetries 5 + 200ms x2 · 8초8초

같은 5회 재시도라도 maxDelay 500ms 를 걸면 8.0초가 3.9초로 줄어요

단위: 초 · 자료: (maxRetries+1)×0.3초 + 대기 합으로 직접 계산. 5.x retryLimit(3)·대기 없음이면 시도마다 롤백돼 트랜잭션 하나는 0.3초
표로 보기
구분트랜잭션 길이
6.0 maxRetries 2 + 100ms1.1
6.0 maxRetries 3 + 100ms x21.9
6.0 maxRetries 5 + 200ms x2 + 최대 500ms3.9
6.0 retryLimit(3) 기본4.2
6.0 maxRetries 5 + 200ms x28

그동안 청크가 쓴 행의 락과 커넥션이 묶여요. 풀이 10개인데 파티션 10개가 같은 장애로 재시도에 들어가면 풀 전체가 최대 4.2초씩 잠겨요. 락 오류를 재시도하면 락 보유 시간이 늘어나는 셈이라, 교착이 잦은 테이블은 재시도보다 쓰는 순서를 키 순으로 맞추고 청크를 줄이는 쪽이 먼저예요. 격리 수준과 재시도의 관계는 트랜잭션 격리 수준 고르는 법에 정리했어요.

JPA 도 flush 가 실패하면 트랜잭션이 rollback-only 가 되고, 재시도한 write() 가 성공해도 6.0.6 은 청크를 실패로 처리해요(ChunkOrientedStep 소스의 isRollbackOnly 검사). flush 동작은 JPA 더티 체킹과 플러시 모드 글에 정리했어요.

라이터 재시도의 중복 호출

외부 API 를 한 건씩 부르는 라이터는 retry 가 맞는 곳이지만, 재시도가 청크 전체로 write() 를 다시 부르니 실패한 건 앞의 성공한 호출이 매번 다시 나가요.

청크 마지막 건이 계속 실패할 때 이미 성공한 건에 다시 나가는 호출 수
maxRetries 1maxRetries 2maxRetries 3
02505007501,0001,2501,500청크 10 · maxRetries 1 9회청크 10 · maxRetries 2 18회청크 10 · maxRetries 3 27회청크 10청크 50 · maxRetries 1 49회청크 50 · maxRetries 2 98회청크 50 · maxRetries 3 147회청크 50청크 100 · maxRetries 1 99회청크 100 · maxRetries 2 198회청크 100 · maxRetries 3 297회청크 100청크 500 · maxRetries 1 499회청크 500 · maxRetries 2 998회청크 500 · maxRetries 3 1,497회청크 500

실패 위치가 고르게 흩어지면 평균은 이 값의 절반이에요

단위: 회 · 자료: maxRetries × (청크 크기 − 1) 로 직접 계산. 재시도를 다 쓴 뒤 skip 으로 스캔에 들어가면 각 건이 한 번씩 더 호출됨
표로 보기
구분maxRetries 1maxRetries 2maxRetries 3
청크 1091827
청크 504998147
청크 10099198297
청크 5004999981,497

청크 100, 재시도 3회면 이미 끝난 99건이 297번 더 호출돼요. 두 번 나가면 안 되는 호출이면 셋 중 하나를 골라요.

  1. 멱등 키: (잡 인스턴스, 아이템 키)로 만든 키를 보내 API 가 중복을 무시하게 해요.
  2. 재시도를 라이터 안으로: 스텝 retry 는 끄고, 라이터에서 호출 한 건만 RetryTemplate 으로 감싸요. 앞 건이 다시 나가지 않아요.
  3. 청크를 작게: 중복 범위가 청크 크기에 비례하니 외부 호출 라이터는 10~50 이 무난해요.

skipLimit 정하는 법, 예상 불량 수로 계산

skipLimit 기본값은 10이고 읽기·가공·쓰기 skip 을 합쳐 세요. 너무 크면 데이터가 통째로 깨져도 COMPLETED 로 끝나요. 지난 실행들의 평균 skip 수를 λ 로 잡고 불량이 서로 독립이라고 보면, skip 수는 대략 푸아송 분포를 따라요.

예상 불량 100건인 배치가 skipLimit 을 넘겨 멈출 확률
01020304050100110120130140100 · 정상 변동으로 멈출 확률 47.3%110 · 정상 변동으로 멈출 확률 14.7%120 · 정상 변동으로 멈출 확률 2.3%130 · 정상 변동으로 멈출 확률 0.17%140 · 정상 변동으로 멈출 확률 0.006%0.006

한도를 예상값과 같게 두면 두 번에 한 번꼴로 멈춰요. 130 이면 0.2% 아래예요

단위: % · 자료: 평균 100 인 푸아송 분포의 P(skip 수 > 한도) 를 직접 계산
표로 보기
구분정상 변동으로 멈출 확률
10047.3
11014.7
1202.3
1300.17
1400.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 된 건을 테이블에 남겨 고치는 운영이 흔한데, 콜백이 도는 트랜잭션 때문에 기록이 빠지기 쉬워요. 스텝 실행 가로채기 문서가 직접 설명하는 부분이에요.

onSkipInRead·onSkipInProcess 는 청크 트랜잭션 안에서 불려 청크가 롤백되면 기록이 사라지고, onSkipInWrite 는 곧 롤백되는 스캔 트랜잭션 안에서 불려 항상 사라지므로 REQUIRES_NEW 나 별도 저장소로 기록하라는 정리 그림
같은 DataSource 로 쓰면 두 줄 다 기록이 사라질 수 있어요

그래서 기록 메서드를 REQUIRES_NEW 트랜잭션으로 분리하거나 로그 파일·큐에 써요. 콜백은 아이템당 한 번이 보장되지만, 실패 후 재시작으로 같은 청크를 다시 하면 또 불릴 수 있어 (잡 인스턴스 ID, 아이템 키) 유일 제약으로 중복을 막아요.

REQUIRES_NEW 는 청크 트랜잭션의 커넥션을 쥔 채 하나를 더 빌려요. 풀이 스레드 수만큼만 있으면 서로 기다리며 멈추니, 풀은 최소 "동시 스텝 스레드 × 2" 로 잡아요.

광고
광고

5.x 에서 6.0 으로 옮길 때 retry·skip 체크리스트

옛 chunk(n, transactionManager).faultTolerant() 는 6.0 에서 제거 예정이에요. 6.0 마이그레이션 가이드와 소스를 대조했어요.

확인할 것5.x6.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 를 안 켜는 게 나은 경우

광고
광고

자주 묻는 질문

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 이에요. 처음 돌리는 잡은 작게 두고 몇 번 실행한 뒤 맞춰요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 스프링 배치 Skip·Retry 원리 — 제자리 재시도와 스캔 모드에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#스프링 배치 retry skip#RetryPolicy 설정#skipLimit 정하는 법#SkipListener 기록 안 남음#Spring Batch 6 마이그레이션#faultTolerant 설정#배치 예외 분류
← 이전 글LLM 서빙 엔진별 샘플링 차이, vLLM·llama.cpp 기본값과 적용 순서다음 글 →스프링 시큐리티 CSRF 403 확인 순서, SPA 쿠키 설정 판단표
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →