📚FOS Study
홈카테고리
홈카테고리

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agents 페이지로 이동
    • custom-agents 페이지로 이동
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • 멀티모달 LLM (Multimodal Large Language Model)
  • architecture 페이지로 이동
    • 디자인 패턴
    • 분산 트랜잭션
    • 슬롯 게임 엔진 고도화 — 2025년 회고
  • css 페이지로 이동
    • FlexBox 페이지로 이동
  • database 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • 커넥션 풀 크기는 얼마나 조정해야할까?
    • 인덱스 - DB 성능 최적화의 핵심
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • monitoring 페이지로 이동
  • go 페이지로 이동
    • Go 언어 기본 학습
  • http 페이지로 이동
    • HTTP Connection Pool
  • interview 페이지로 이동
    • 210812 페이지로 이동
    • 뱅크샐러드 AI Native Server Engineer
    • CJ 올리브영 지원 문항
    • CJ 올리브영 커머스플랫폼유닛 Back-End 개발 지원 자료
    • 마이리얼트립 - Platform Solutions실 회원주문개발 Product Engineer
    • NHN 서비스개발센터 AI서비스개발팀
    • nhn gameenvil console backend 직무 인터뷰 준비
    • 면접을 대비해봅시다
    • Tossplace Node.js Developer
    • 토스플레이스 Node.js 백엔드 컬처핏
  • java 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • OpenTelemetry 란 무엇인가?
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • Data_Structures_and_Algorithms 페이지로 이동
    • Heap 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node.js
    • npm vs pnpm 선택기준은 무엇인가요?
    • `setImmediate()`
  • kafka 페이지로 이동
    • Kafka 기본
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • 메시지 전송 신뢰성
  • network 페이지로 이동
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • react 페이지로 이동
    • JSX 페이지로 이동
    • VirtualDOM 페이지로 이동
    • v16 페이지로 이동
  • redis 페이지로 이동
    • Redis
    • Redis Hash와 Lua 스크립트로 잭팟 누적 구현하기
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
📚FOS Study

개발 학습 기록을 정리하는 블로그입니다.

바로가기

  • 홈
  • 카테고리

소셜

  • GitHub
  • Source Repository

© 2025 FOS Study. Built with Next.js & Tailwind CSS

목록으로 돌아가기
☕java/ spring-batch

FaultTolerant (작성 중..)

약 14분
2026년 2월 25일
2026년 3월 22일 수정
GitHub에서 보기

FaultTolerant (작성 중..)

배치 작업에서 실패는 피할 수 없는 현실이다. 파일에 일부 잘못된 데이터가 저장되었을 수도 있고, 데이터베이스와의 통신이 일시적으로 실패할 수도이 있다. 그렇다고 해서 배치 작업이 중단되어도 괜찮을까?

Spring Batch의 잔인한 기본 오류 처리

스텝 실행 중 단 하나의 예외라도 발생하면?

ItemReader에서 읽는 중이든, ItemProcessor에서 처리 중이든, ItemWriter에서 쓰는 중이든 Spring Batch는 즉시 모든 실행을 중단하고 배치 잡 전체를 실패로 처리한다.

하지만 생각해보자.

  • 1,000만 개의 데이터를 처리하다 단 한 건의 오류로 작업을 실패시켜야 한다면?
  • 일시적인 네트워크 오류나 잘못된 형식의 데이터 하나 때문에 전체 배치를 다시 실행해야 한다면?

적절한 예외 처리 방법으로 재시도 가능한 예외는 재시도하고, 무시해도 되는 예외는 무시할 수 있어야 한다. 그러나 이게 구조적으로 쉽지만은 않다. 우리가 지금까지 살펴본 청크 지향 처리의 구조를 생각해보자.

청크 지향 처리의 구조적 한계

개발자는 ItemReader, ItemProcessor, ItemWriter 구현체를 스텝에 구성만할 뿐, 실제 실행은 Spring Batch의 Step이 담당한다. 즉, 아이템 처리 중 예외가 발생해도 우리가 직접 개입할 기회가 없다.

이런 구조적 한계를 보완하기 위해 Spring Batch는 내결함성 (FaultTolerance) 기능을 제공한다. 이를 활용하면 간단한 구성만으로도 재시도 (Retry)와 건너뛰기 (Skip)로 청크 지향 처리에서 발생할 수 있는 다양한 예외 상황을 다룰 수 있다.

예를 들어,

  • 일시적 네트워크 오류로 인한 데이터베이스 쓰기 실패는 재시도로 해결할 수 있고,
  • 잘못된 형식의 데이터를 읽어 예외가 발생한 경우에는 건너뛰기로 깔끔하게 무시해버릴 수 있다.

참고 : 태스크릿 지향 처리는 내결함성 (FaultTolerance)를 지원하지 않는다. 우리가 작성한 코드 내에서 try-catch를 사용해 발생 가능한 예외를 원하는 대로 처리할 수 있기 때문이다.

Spring Batch의 내결함성 (FaultTolerance) 기능은 크게 두 가지 무기를 제공한다. 재시도와 건너뛰기이다. 먼저 재시도부터 살펴보자.

재시도 (Retry)

말 그대로 실패한 작업을 다시 시도하는 것이다. 예를 들어 쓰기 중 데이터베이스 커넥션이 순간적으로 끊기거나 ItemProcessor의 외부 API 호출에서 일시적으로 타임아웃이 발생했을 떄, 잠시 후 다시 시도하면 정상적으로 처리될 가능성이 높다. 이런 일시적인 오류 상황에서 재시도는 매우 효과적인 해결책이 된다.

내결함성 기능의 핵심 무기 - RetryTemplate

내결함성 기능을 활성화하면 스텝은 RetryTemplate이라는 컴포넌트를 사용한다. RetryTemplate은 Spring Retry 프로젝트의 핵심 컴포넌트로, 작업이 실패하면 정해진 정책에 따라 다시 시도하는 컴포넌트다.

RetryTemplate의 메커니즘을 들여다보자. 다음 다이어 그램은 RetryTemplate.execute() 메서드가 내부적으로 어떻게 동작하는지를 단순화해서 보여준다.

RetryTemplate.execute()
        |
        v
    canRetry()?   <-- RetryPolicy 판단
       / \
      /   \
   YES     NO
    |       |
    v       v
retryCallback()   recoveryCallback()
 (재시도 수행)     (복구 로직 수행)
  • 재시도 가능 여부 판단 - canRetry()
    • 먼저 **canRetry()**를 통해 재시도 가능 여부를 판단한다.
    • 이 메서드는 사전에 정해진 재시도 정책 (RetryPolicy)을 기반으로 **"이 작업을 다시 시도해도 되는가?"**를 결정한다.
  • 핵심 로직 실행 - retryCallback
    • 재시도가 가능하다고 판단되면 retryCallback을 호출한다.
    • 여기에는 우리가 실행하고자하는 핵심 비즈니스 로직이 담겨있다.
    • 중요한 점은 이 콜백이 재시도만을 위한 것이 아니라는 점이다.
      • 내결함성 기능이 활성화되면 최초 실행부터 재시도까지 모든 시도가 이 retryCallback을 통해 수행된다.
      • 즉, 정상적인 첫 실행도, 실패 후 재시도도 모두 RetryTemplate의 관할 하에 있다는 뜻이다.
  • 최후의 수단 - recoveryCallback
    • 만약 **canRetry()**가 "더 이상 재시도는 불가능하다"고 판단하면 어떻게 될까?
    • 이 떄 호출되는 것이 바로 recoveryCallback이다.
    • 최후의 수단으로, 기본적으로는 발생한 예외를 그대로 전파하거나 대체 로직을 수행한다.

이 메커니즘이 Spring Batch Step에 장착되면 어떤 일이 벌어질까?

                         Rollback
                           ↺
                        +-------+
                        | Step  |
                        +-------+
                           ^
                           |  예외 발생 / 전파
                           |
        실패없이 청크처리 재개   |  input chunk
                           |
                           v
        +----------------------------------------------------+
        |                  RetryTemplate                     |
        |                                                    |
        |                 +-------------+                    |
        |                 |  canRetry?  |                    |
        |                 +-------------+                    |
        |                       |                            |
        |                 +-----+-----+                      |
        |                 |           |                      |
        |               YES           NO                     |
        |                 |           |                      |
        |                 v           v                      |
        |        +----------------+  +--------------------+  |
        |        | retryCallback  |  | recoveryCallback   |  |
        |        +----------------+  +--------------------+  |
        |                 |                                  |
        |                 v                                  |
        |     +-------------------------------------------+  |
        |     | ItemProcessor.process()                   |  |
        |     | ItemWriter.write()                        |  |
        |     +-------------------------------------------+  |
        +----------------------------------------------------+
                          |
                          v
                 step 실패 (별도 구성 없을 경우)

  • 내결함성 모드가 활성화되면, ItemProcessor와 ItemWriter 호출 로직이 RetryTemplate의 retryCallback안으로 패키징된다.
  • 이 말인 즉, ItemProcessor와 ItemWriter 호출의 재시도뿐만 아니라 최초 실행 또한 이 RetryTemplate을 통해 수행된다는 뜻이다.

그렇다면 canRetry() 메서드에서는 재시도 가능 여부를 어떻게 판단할까?

재시도의 심판관 - RetryPolicy

바로 RetryPolicy라는 재시도 정책을 사용해 재시도 가능 여부를 결정한다. 별도 설정이 없을 경우 Spring Batch는 SimpleRetryPolicy라는 재시도 정책을 사용한다.

SimpleRetryPolicy는 다음의 두 조건을 바탕으로 재시도 가능 여부를 결정한다.

  • 발생한 예외가 사전에 지정된 예외 유형에 해당하는가
  • 현재 재시도 횟수가 최대 허용 횟수를 초과하지 않았는가

ItemReader? 재시도는 없다

ItemReader는 재시도 기능의 보호 대상이 아니다. 다시 말해, ItemReader에서 발생한 예외는 재시도되지 않는다. 그 이유는 Spring Batch가 mutable한 데이터소스로부터 데이터를 읽는 상황까지 고려했기 때문이다.

Mutable한 데이터 소스란? 읽으연 데이터가 사라지는 데이터 소스를 의미한다. 대표적으로 메시지 큐 (RabbitMQ, SQS 등)를 예로 들 수 있다. 이러한 상황에서는 재시도가 불가능하다.

아이러니하게도, 대부분의 데이터소스 (파일, DB, Kafka)는 immutable 하다. 즉, 원본이 그대로 보존되며 재시도 가능한 구조를 가지고 있다. Spring Batch에서도 v6 부터는 ItemReader에서도 재시도가 가능해질 예정이다.

내결함성 최적화 - Input Chunk 재활용

앞선 다이어그램을 다시 살펴보자. Step에서 RetryTemplate으로 향하는 화살표를 보면, 롤백 후 청크 처리를 재개한 Step이 RetryTemplate에 input chunk를 전달하고 있는 것이 보인다.

여기서 반드시 알아야 할 ItemReader의 중요한 설계 원칙이 있다. ItemReader의 기본 규약은 forward only 방식이다. 즉 데이터를 단방향으로만 순차적으로 읽어나가는 것이 기본 원칙이다. 따라서 과거로 되돌아가 아이템을 다시 읽는 것은 ItemReader의 기본 설계 원칙에 위배된다.

그렇다면 어떻게 매번 재시도 마다 input chunk를 전달할 수 있을까?

답은 내결함성 기능의 청크 버퍼링에 있다. Spring Batch는 내결함성 기능이 활성화된 경우 ItemReader가 읽어들인 input Chunk를 별도로 저장해둔다.

자, 지금까지 RetryTemplate이 Spring Batch Step과 어떻게 결합되는지 이해했다. 이제 실제로 우리 스텝에 이 강력한 재시도 기능을 장착하는 방법을 알아보자.

예제 1 - 재시도 설정

@Bean
public Step terminationRetryStep() {
  return new StepBuilder("terminationRetryStep", jobRepository)
            .<Scream, Scream>chunk(3, transactionManager)
            .reader(terminationRetryReader())
            .processor(terminationRetryProcessor())
            .writer(terminationRetryWriter())
            .faultTolerant() // 내결함성 기능 ON
            .retry(TerminationFailedException.class)
}

이 코드가 바로 재시도 구성의 핵심이다. 스텝 빌더의 .faultTolerant() 부터 시작해서 재시도를 위한 무기가 장착된다. 하나씩 살펴보자.

  • faultTolerant():
    • 재시도 기능을 활성화하는 메소드이다.
    • Spring Batch에게 **"이제부터 재시도나 스킵 같은 내결함성 기능을 사용하겠다"**라고 선언하는 것이다.

이제 재시도 조건을 설정할 차례다.

앞서 설명한 대로, 기본적으로 사용되는 재시도 정책인 SimpleRetryPolicy는 다음을 기준으로 재시도 가능 여부를 판단한다.

  • 발생한 예외가 사전에 지정된 예외 유형에 해당하는지
  • 현재 재시도 횟수가 최대 허용 횟수를 초과하지 않았는지

다음 설정 메서드들 (retry(), retryLimit())을 사용하면 이를 구성할 수 있다.

  • retry():
    • 어떤 예외가 발생했을 때 재시도를 수행할지를 지정한다.
    • 예제에서는 TerminationFailedException이 발생하면 재시도하도록 설정했다.
      • 이렇게 예외를 지정하면 TerminationFailedException을 상속한 모든 예외들도 자동으로 재시도 대상에 포함된다.
    • 여러 예외를 지정하고 싶다면 retry() 메서드를 연속해서 호출하면 된다.

때로는 상위 예외 클래스의 특정 하위 예외만 재시도에서 제외하고 싶을 수 있다. 이럴 떄 사용하는 것이 noRetry() 메서드다.

예시:

  • HttpServerErrorException 클래스를 지정해 모든 5xx 서버 에러는 재시도
  • 하지만 501 Not Implemented 응답에 해당하는 HttpServerErrorException.NotImplemented는 재시도하고 싶지 않다면 다음과 같이 설정한다.
.retry(HttpServerErrorException.class)
.noRetry(HttpServerErrorException.NotImplemented.class)
  • retryLimit():

    • 허용 가능한 총 시도 횟수를 지정한다.
    • 앞서 얘기했듯이 내결함성 기능이 활성화되면 최초 실행부터 재시도까지 모든 시도가 retryCallback을 통해 수행된다.
      • 따라서 retryLimit()에 재시도 횟수가 아닌 총 허용 가능한 retryCallback 호출 횟수를 지정해야 한다.
      • 예를 들어 retryLimit이 3이면, 재시도 횟수는 3회가 아닌 2회 이다.
    • 별도로 값을 지정하지 앟ㄴ을 경우 기본 retryLimit 값은 0이다.
      • 단, 이 값이 0인 경우에도 최초 1번의 시도는 반드시 실행된다.
      • 또한 retryLimit이 0보다 큰 값으로 설정되어 있을 경우, 반드시 retry() 메서드에 재시도 대상 예외를 지정해야 한다.
  • listener():

    • 재시도 과정을 모니터링할 수 있는 리스너를 등록한다.
    • 여기에 전달하는 리스너는 Spring Retry 프로젝트의 RetryListener 인터페이스 구현체로, 다음과 같은 재시도의 전 과정을 추적하는 메서드들을 제공한다.
public interface RetryListener {
    // 재시도 시작 전에 호출. false를 반환하면 재시도를 중단한다.
    default <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
        return true;
    }

    // 재시도 중 오류 발생할 때마다 호출
    default <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback,
            Throwable throwable) {
    }

    // 재시도 성공 시 호출
    default <T, E extends Throwable> void onSuccess(RetryContext context, RetryCallback<T, E> callback, T result) {
    }

    // 모든 재시도가 끝난 후 호출 (성공/실패 여부와 무관)
    default <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback,
            Throwable throwable) {
    }
}

이제 기본적인 재시도 설정 방법을 살펴봤다. 실제로 예외가 발생했을 때 Spring Batch가 어떻게 재시도를 수행하는지 알아볼 차례다.

지금부터 ItemProcessor와 ItemWriter에서 예외 발생 시 각각 어떤 방식으로 재시도가 이뤄지는지 구체적으로 알아보자. 여기서 반드시 알아야할 중요한 사실은 ItemProcessor와 ItemWriter에서의 재시도는 완전히 다른 방식으로 동작한다는 것이다.

내결함성 동작 검증 코드 - ItemProcessor에서의 예외 발생 후 재시도

@Configuration
@RequiredArgsConstructor
public class TerminationRetryConfig {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;


    @Bean
    public Job terminationRetryJob() {
        return new JobBuilder("terminationRetryJob", jobRepository)
            .start(terminationRetryStep())
            .build();
    }


    @Bean
    public Step terminationRetryStep() {
        return new StepBuilder("terminationRetryStep", jobRepository)
            .<Scream, Scream>chunk(3, transactionManager)
            .reader(terminationRetryItemReader())
            .processor(terminationRetryProcessor())
            .writer(terminationRetryItemWriter())
            .faultTolerant()
            .retry(TerminationFailedException.class)
            .retryLimit(3)
            .listener(terminationRetryListener())
            .build();
    }


    @Bean
    public ListItemReader<Scream> terminationRetryItemReader() {
        return new ListItemReader<>(List.of(
            Scream.builder()
                  .id(1)
                  .scream("멈춰")
                  .processMsg("멈추라고 했는데 안 들음.")
                  .build(),
            Scream.builder()
                  .id(2)
                  .scream("제발")
                  .processMsg("애원 소리 귀찮네.")
                  .build(),
            Scream.builder()
                  .id(3)
                  .scream("살려줘")
                  .processMsg("구조 요청 무시.")
                  .build(),
            Scream.builder()
                  .id(4)
                  .scream("으악")
                  .processMsg("디스크 터지며 울부짖음.")
                  .build(),
            Scream.builder()
                  .id(5)
                  .scream("끄아악")
                  .processMsg("메모리 붕괴 비명.")
                  .build(),
            Scream.builder()
                  .id(6)
                  .scream("System.exit(-666)")
                  .processMsg("초살 프로토콜 발동.")
                  .build())) {
            @Override
            public Scream read() {
                Scream scream = super.read();
                if (scream == null) {
                    return null;
                }
                System.out.println("[ItemReader]: 처형 대상 = " + scream);
                return scream;
            }
        };
    }


    @Bean
    public ItemProcessor<Scream, Scream> terminationRetryProcessor() {
        return new ItemProcessor<>() {
            private static final int MAX_PATIENCE = 1;
            private int mercy = 0; // 자비 카운트


            @Override
            public @NonNull Scream process(Scream item) {
                System.out.println("[ItemProcessor]: 처형 대상 = " + item);

                if (item.getId() == 3 && mercy < MAX_PATIENCE) {
                    mercy++;
                    System.out.println("-> 처형 실패");
                    throw new TerminationFailedException("처형 거부자 = " + item);
                } else {
                    System.out.println("-> 처형 완료 (" + item.getProcessMsg() + ")");
                }

                return item;
            }
        };
    }


    @Bean
    public ItemWriter<Scream> terminationRetryItemWriter() {
        return chunk -> {
            System.out.println("[ItemWriter]: 처형 기록 시작. 기록 대상 = " + chunk.getItems());

            for (Scream scream : chunk) {
                System.out.println("[ItemWriter]: 기록 완료. 처형된 아이템 = " + scream);
            }
        };
    }


    @Bean
    public RetryListener terminationRetryListener() {
        return new RetryListener() {
            @Override
            public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
                System.out.println("SYSTEM: 이것 봐라? 안 죽네? " + throwable + " (현재 총 시도 횟수=" + context.getRetryCount() + "). 다시 처형한다.\n");
            }
        };
    }


    @Getter
    @Builder
    public static class Scream {
        private int id;
        private String scream;
        private String processMsg;


        @Override
        public String toString() {
            return id + "_" + scream;
        }
    }

    public static class TerminationFailedException extends RuntimeException {
        public TerminationFailedException(String message) {
            super(message);
        }
    }
}

실제 실패 로직을 살펴보자.

ItemProcessor에서의 재시도 동작은 다음과 같다.

terminationRetryProcessor는 id가 3인 아이템(살려줘)에서 의도적으로 예외를 발생시키도록 설계했다.
다만 자비 카운트(mercy)가 MAX_PATIENCE(1) 보다 같거나 커지면 예외를 발생시키지 않는다.
첫 번째 시도에서는 예외를 발생시키지만, 두 번째 시도부터는 성공하게 된다.

실행 결과는 다음과 같다.

[ItemReader]: 처형 대상 = 1_멈춰
[ItemReader]: 처형 대상 = 2_제발
[ItemReader]: 처형 대상 = 3_살려줘
[ItemProcessor]: 처형 대상 = 1_멈춰
-> 처형 완료 (멈추라고 했는데 안 들음.)
[ItemProcessor]: 처형 대상 = 2_제발
-> 처형 완료 (애원 소리 귀찮네.)
[ItemProcessor]: 처형 대상 = 3_살려줘
-> 처형 실패
SYSTEM: 이것 봐라? 안 죽네? com.bifos.batch.step.faulttolerant.TerminationRetryConfig$TerminationFailedException: 처형 거부자 = 3_살려줘 (현재 총 시도 횟수=1). 다시 처형한다.

[ItemProcessor]: 처형 대상 = 1_멈춰
-> 처형 완료 (멈추라고 했는데 안 들음.)
[ItemProcessor]: 처형 대상 = 2_제발
-> 처형 완료 (애원 소리 귀찮네.)
[ItemProcessor]: 처형 대상 = 3_살려줘
-> 처형 완료 (구조 요청 무시.)
[ItemWriter]: 처형 기록 시작. 기록 대상 = [1_멈춰, 2_제발, 3_살려줘]
[ItemWriter]: 기록 완료. 처형된 아이템 = 1_멈춰
[ItemWriter]: 기록 완료. 처형된 아이템 = 2_제발
[ItemWriter]: 기록 완료. 처형된 아이템 = 3_살려줘
[ItemReader]: 처형 대상 = 4_으악
[ItemReader]: 처형 대상 = 5_끄아악
[ItemReader]: 처형 대상 = 6_System.exit(-666)
[ItemProcessor]: 처형 대상 = 4_으악
-> 처형 완료 (디스크 터지며 울부짖음.)
[ItemProcessor]: 처형 대상 = 5_끄아악
-> 처형 완료 (메모리 붕괴 비명.)
[ItemProcessor]: 처형 대상 = 6_System.exit(-666)
-> 처형 완료 (초살 프로토콜 발동.)
[ItemWriter]: 처형 기록 시작. 기록 대상 = [4_으악, 5_끄아악, 6_System.exit(-666)]
[ItemWriter]: 기록 완료. 처형된 아이템 = 4_으악
[ItemWriter]: 기록 완료. 처형된 아이템 = 5_끄아악
[ItemWriter]: 기록 완료. 처형된 아이템 = 6_System.exit(-666)

이 실행 결과를 분석해보면, ItemProcessor에서의 재시도 동작을 명확히 확인할 수 있다.

  • 1. ItemReader가 3개의 아이템을 읽어들인다
  • 2. ItemProcessor 시작
    • 첫 번쨰, 두 번째 아이템은 처리되지만, 세 번째 아이템에서 예외가 발생한다.
  • 3. 트랜잭션 롤백 및 청크 처리 재개
    • 예외가 전파되면 Step은 청크 트랜잭션을 롤백시킨다.
    • 내결함성 기능 덕분에 스텝은 재시도를 시작한다.
  • 4. 청크 재처리 시작
    • 이미 읽어둔 아이템들로 첫 번째 아이템부터 다시 처리를 시작한다.
      • 즉 ItemReader를 다시 호출하지 않는다.
  • 5. 재시도 성공
    • 자비 카운트가 증가하여, 이번엔 예외 없이 처리된다.
  • 6. 청크 완료
    • 모든 아이템들이 성공적으로 처리되어 ItemWriter가 기록을 시작한다.
  • 7. 다음 청크로 이동
    • 기록 완료 후 다음 청크 처리를 진행한다.

만약 retryLimit 동안 재시도해도 실패한다면 어떻게 될까?

기존에는 MAX_PATIENCE 가 1로 설정되어 있어서, mercy 카운트가 1이 되면, 예외를 발생시키지 않았다.
하지만 MAX_PATIENCE를 3으로 바꾸면 mercy 카운트가 3이 될 때 까지 계속 예외를 발생시킨다.
즉, 세 번째 아이템은 총 4번의 시도가 있어야 처리할 수 있다.

그런데 우리 retryLimit은 3이다. 따라서 총 3번의 허용 시도 모두에서 예외가 발생하여 재시도 제한데 도달하게 되고, 결국 스텝이 실패할 것이다.

실행해보면 다음과 같은 결과가 나온다.

[ItemReader]: 처형 대상 = 1_멈춰
[ItemReader]: 처형 대상 = 2_제발
[ItemReader]: 처형 대상 = 3_살려줘
[ItemProcessor]: 처형 대상 = 1_멈춰
-> 처형 완료 (멈추라고 했는데 안 들음.)
[ItemProcessor]: 처형 대상 = 2_제발
-> 처형 완료 (애원 소리 귀찮네.)
[ItemProcessor]: 처형 대상 = 3_살려줘
-> 처형 실패
SYSTEM: 이것 봐라? 안 죽네? com.bifos.batch.step.faulttolerant.TerminationRetryConfig$TerminationFailedException: 처형 거부자 = 3_살려줘 (현재 총 시도 횟수=1). 다시 처형한다.

[ItemProcessor]: 처형 대상 = 1_멈춰
-> 처형 완료 (멈추라고 했는데 안 들음.)
[ItemProcessor]: 처형 대상 = 2_제발
-> 처형 완료 (애원 소리 귀찮네.)
[ItemProcessor]: 처형 대상 = 3_살려줘
-> 처형 실패
SYSTEM: 이것 봐라? 안 죽네? com.bifos.batch.step.faulttolerant.TerminationRetryConfig$TerminationFailedException: 처형 거부자 = 3_살려줘 (현재 총 시도 횟수=2). 다시 처형한다.

[ItemProcessor]: 처형 대상 = 1_멈춰
-> 처형 완료 (멈추라고 했는데 안 들음.)
[ItemProcessor]: 처형 대상 = 2_제발
-> 처형 완료 (애원 소리 귀찮네.)
[ItemProcessor]: 처형 대상 = 3_살려줘
-> 처형 실패
SYSTEM: 이것 봐라? 안 죽네? com.bifos.batch.step.faulttolerant.TerminationRetryConfig$TerminationFailedException: 처형 거부자 = 3_살려줘 (현재 총 시도 횟수=3). 다시 처형한다.

[ItemProcessor]: 처형 대상 = 1_멈춰
-> 처형 완료 (멈추라고 했는데 안 들음.)
[ItemProcessor]: 처형 대상 = 2_제발
-> 처형 완료 (애원 소리 귀찮네.)
2026-02-25T11:44:19.935+09:00 ERROR 66639 --- [           main] o.s.batch.core.step.AbstractStep         : Encountered an error executing step terminationRetryStep in job terminationRetryJob

ItemProcessor에서의 재시도 분석

재시도할 때 마다 청크 전체가 처음부터 다시 처리되니까 청크 단위로 재시도 횟수가 관리될 것이라고 착각하기 쉽다.

ItemProcessor에서의 재시도는 아이템 단위로 재시도 컨텍스트가 관리된다. Spring Batch는 각 대상 아이템별로 얼마나 재시도했는지 따로따로 기록한다.

결론: 청크 전체가 다시 처리되지만, 재시도 횟수는 아이템 단위로 개별 관리된다.

이 지점에서 한 가지 의문이 생길 수 있다.
이미 성공적으로 처리된 아이템들 까지 다시 처리되면 비효율적이지 않을까?

예를 들어, 청크 사이즈가 1,000이고 마지막 아이템만 계속 실패한다고 가정한다면, 이미 성공한 다른 아이템들까지 매번 다시 처리되어 불필요한 호출이 발생한다.
만약 process()에서 외부 API를 호출한다면? 엄청난 참사가 벌어질 수 있다.

Spring Batch에서는 이렇게 불필요한 재처리를 방지하기 위해 processorNonTransactional()이라는 설정을 제공한다. 이 설정을 사용하면 ItemProcessor를 비트랜잭션 상태로 표시하여 한 번 처리된 아이템의 결과를 캐시에 저장한다.

덕분에 재시도가 발생할 떄 처리에 성공한 아이템들은 캐시된 결과를 사용하고, 실패한 아이템에 대해서만 process()를 다시 호출한다.

주의:

processorNonTransactional 이라는 이름만 보면 ItemProcessor의 실패가 청크 트랜잭션에 영향을 안 미치나? 라고 오해할 수 있다.

하지만 그렇지 않다. ItemProcessor에서 예외가 발생하면 여전히 청크 단위의 트랜잭션은 롤백된다.

이 설정의 진짜 의미는 ItemProcessor가 현재 트랜잭션 상태에 영향을 받지 않고 동일한 입력에 대해 항상 동일한 결과를 반환한다는 뜻이다.

Spring Batch의 공식 문서를 보면 내결함성 기능을 사용할 땐 ItemProcessor가 멱등하게 동작해야 한다고 명시되어 있다. 만약 멱등하지 않은 ItemProcessor를 사용할 경우 재시도 대상 판별 과정에서 재시도마다 결과가 달라져 버릴 수 있기 때문이다.

따라서 멱등하지 않은 ItemProcessor를 내결함성 기능과 함꼐 사용하는 경우에는, 반드시 processorNonTransactional를 설정하도록 하자.

다만, 이 경우 최초 처리 결과가 재사용되므로, 멱등하지 않은 로직의 원래 의도와 결과가 달라질 수 있다는 점을 유의해야 한다.

여기까지 ItemProcessor에서의 재시도 동작을 살펴보았다.
이제 ItemWriter에서 예외가 발생한 경우의 재시도 동작을 알아보자.


ItemWriter에서 예외 발생후 재시도 - 청크 단위로 재시도 관리

  • ItemWriter에서 예외 발생 시, ItemProcessor 부터 처리가 재개된다.
  • ItemProcessor에서와 달리, ItemWriter에서의 재시도 횟수는 청크 단위로 관리된다.

ItemWriter는 아이템을 하나씩 쓰지 않고 청크 단위로 한 번에 쓴다.
즉, 청크 레벨에서 쓰기를 다시 수행해야 한다.

위의 예제를 다음과 같이 수정해보자.


@Bean
public ItemProcessor<Scream, Scream> terminationRetryProcessor() {
    return scream -> {
        System.out.print("[ItemProcessor]: 처형 대상 = " + scream + "\n");
        return scream;
    };
}

@Bean
public ItemWriter<Scream> terminationRetryWriter() {
    return new ItemWriter<>() {
        private static final int MAX_PATIENCE = 2;
        private int mercy = 0;  // 자비 카운트

        @Override
        public void write(Chunk<? extends Scream> screams) {
            System.out.println("[ItemWriter]: 기록 시작. 처형된 아이템들 = " + screams);

            for (Scream scream : screams) {
                if (scream.getId() == 3 && mercy < MAX_PATIENCE) {
                    mercy ++;
                    System.out.println("[ItemWriter]: ❌ 기록 실패. 저항하는 아이템 발견 = " + scream);
                    throw new TerminationFailedException("기록 거부자 = " + scream);
                }
                System.out.println("[ItemWriter]: ✅ 기록 완료. 처형된 아이템 = " + scream);
            }
        }
    };
}

이번에는 ItemWriter에서 예외를 발생시킨다.

java 카테고리의 다른 글 보기수정 제안하기

댓글

댓글을 불러오는 중...
목차
  • FaultTolerant (작성 중..)
  • Spring Batch의 잔인한 기본 오류 처리
  • 청크 지향 처리의 구조적 한계
  • 재시도 (Retry)
  • 내결함성 기능의 핵심 무기 - RetryTemplate
  • 재시도의 심판관 - RetryPolicy
  • ItemReader? 재시도는 없다
  • 내결함성 최적화 - Input Chunk 재활용
  • 예제 1 - 재시도 설정
  • 내결함성 동작 검증 코드 - ItemProcessor에서의 예외 발생 후 재시도
  • ItemProcessor에서의 재시도 분석
  • ItemWriter에서 예외 발생후 재시도 - 청크 단위로 재시도 관리