Theme:

배치 작업이 중간에 실패했는데 처음부터 다시 돌려야 한다면, 이미 처리한 수십만 건은 어떻게 되는 걸까요?

운영 환경에서 배치는 반드시 실패합니다. 네트워크 장애, DB 타임아웃, 잘못된 데이터 등 원인은 다양합니다. Spring Batch는 이런 상황에서 재시작, 스킵, 리트라이를 통해 배치 작업을 안정적으로 운영할 수 있는 메커니즘을 제공합니다.

재시작 (Restartable)

재시작의 동작 원리

Spring Batch는 Job/Step의 실행 상태를 메타데이터 테이블에 저장합니다. 같은 JobParameters로 Job을 다시 실행하면, 프레임워크가 이전 실행 상태를 확인합니다.

  • 이전 실행이 COMPLETED: 이미 완료된 것으로 간주, 실행하지 않음
  • ** 이전 실행이 FAILED**: 실패 지점부터 재시작
PLAINTEXT
Job("정산") + Params("2026-03-19")
  └─ JobExecution#1: FAILED
       ├─ Step1 (initStep): COMPLETED ← 건너뜀
       ├─ Step2 (processStep): FAILED  ← 여기서부터 재시작
       └─ Step3 (reportStep): NOT STARTED

allowStartIfComplete

기본적으로 COMPLETED된 Step은 재시작 시 건너뜁니다. 하지만 매번 실행해야 하는 Step이 있다면 allowStartIfComplete를 설정합니다.

JAVA
@Bean
public Step initStep(JobRepository jobRepository,
                      PlatformTransactionManager txManager) {
    return new StepBuilder("initStep", jobRepository)
            .tasklet(initTasklet(), txManager)
            .allowStartIfComplete(true)  // 완료되었어도 재실행
            .build();
}

재시작 비활성화

특정 Job이 재시작되면 안 되는 경우(예: 중복 발송 위험) preventRestart()를 설정합니다.

JAVA
@Bean
public Job oneTimeJob(JobRepository jobRepository, Step step) {
    return new JobBuilder("oneTimeJob", jobRepository)
            .preventRestart()  // 재시작 금지
            .start(step)
            .build();
}

ExecutionContext — 상태 저장소

ExecutionContext는 배치 실행 중 상태를 저장하는 키-값 저장소입니다. chunk 커밋 시마다 DB에 영속화되어, 재시작 시 이전 상태를 복원할 수 있습니다.

Step 레벨 ExecutionContext

JAVA
@Bean
public ItemReader<Order> statefulReader() {
    return new ItemReader<>() {
        @BeforeStep
        public void beforeStep(StepExecution stepExecution) {
            ExecutionContext ctx = stepExecution.getExecutionContext();
            // 재시작 시 이전 상태 복원
            if (ctx.containsKey("lastProcessedId")) {
                long lastId = ctx.getLong("lastProcessedId");
                log.info("이전 처리 위치부터 재개: {}", lastId);
            }
        }

        @Override
        public Order read() {
            // 읽기 로직
            return null;
        }
    };
}

Job 레벨 ExecutionContext — Step 간 데이터 공유

JAVA
// Step1에서 값 저장
@Bean
public Step step1(JobRepository jobRepository, PlatformTransactionManager txManager) {
    return new StepBuilder("step1", jobRepository)
            .tasklet((contribution, chunkContext) -> {
                ExecutionContext jobContext = chunkContext.getStepContext()
                        .getStepExecution().getJobExecution().getExecutionContext();
                jobContext.putLong("totalCount", 50000L);
                return RepeatStatus.FINISHED;
            }, txManager)
            .build();
}

Step2에서는 Job 레벨 ExecutionContext에서 Step1이 저장한 값을 읽어올 수 있습니다.

JAVA
// Step2에서 값 읽기
@Bean
public Step step2(JobRepository jobRepository, PlatformTransactionManager txManager) {
    return new StepBuilder("step2", jobRepository)
            .tasklet((contribution, chunkContext) -> {
                ExecutionContext jobContext = chunkContext.getStepContext()
                        .getStepExecution().getJobExecution().getExecutionContext();
                long totalCount = jobContext.getLong("totalCount");
                log.info("Step1에서 처리한 총 건수: {}", totalCount);
                return RepeatStatus.FINISHED;
            }, txManager)
            .build();
}

스킵 (Skip)

특정 예외가 발생했을 때 해당 아이템을 건너뛰고 계속 처리할 수 있습니다. 데이터 품질이 완벽하지 않은 상황에서 유용합니다.

JAVA
@Bean
public Step processStep(JobRepository jobRepository,
                          PlatformTransactionManager txManager,
                          ItemReader<Order> reader,
                          ItemProcessor<Order, Settlement> processor,
                          ItemWriter<Settlement> writer) {
    return new StepBuilder("processStep", jobRepository)
            .<Order, Settlement>chunk(100, txManager)
            .reader(reader)
            .processor(processor)
            .writer(writer)
            .faultTolerant()
            .skip(DataFormatException.class)       // 이 예외는 스킵
            .skip(ValidationException.class)        // 이 예외도 스킵

스킵하면 안 되는 예외를 noSkip()으로 지정하고, skipLimit으로 최대 스킵 가능 건수를 제한합니다.

JAVA
            .noSkip(DatabaseException.class)         // 이 예외는 스킵하지 않음
            .skipLimit(100)                          // 최대 100건까지 스킵 허용
            .skipPolicy(new AlwaysSkipItemSkipPolicy()) // 또는 커스텀 정책
            .build();
}

스킵된 아이템 추적

SkipListener로 스킵된 아이템을 로깅하거나 별도 테이블에 저장할 수 있습니다.

JAVA
public class SkipLoggingListener implements SkipListener<Order, Settlement> {

    private final SkipItemRepository skipItemRepository;

    @Override
    public void onSkipInRead(Throwable t) {
        log.warn("읽기 중 스킵 발생: {}", t.getMessage());
    }

    @Override
    public void onSkipInProcess(Order item, Throwable t) {
        log.warn("처리 중 스킵 — 주문 ID: {}, 원인: {}", item.getId(), t.getMessage());
        skipItemRepository.save(new SkipRecord(item.getId(), "PROCESS", t.getMessage()));
    }

    @Override
    public void onSkipInWrite(Settlement item, Throwable t) {
        log.warn("쓰기 중 스킵 — 정산 ID: {}, 원인: {}", item.getOrderId(), t.getMessage());
    }
}
JAVA
// Step에 리스너 등록
.listener(new SkipLoggingListener(skipItemRepository))

리트라이 (Retry)

일시적인 장애(네트워크 타임아웃 등)에 대해 자동 재시도를 설정할 수 있습니다.

JAVA
@Bean
public Step retryableStep(JobRepository jobRepository,
                            PlatformTransactionManager txManager,
                            ItemReader<Order> reader,
                            ItemProcessor<Order, Settlement> processor,
                            ItemWriter<Settlement> writer) {
    return new StepBuilder("retryableStep", jobRepository)
            .<Order, Settlement>chunk(100, txManager)
            .reader(reader)
            .processor(processor)
            .writer(writer)
            .faultTolerant()
            .retry(TransientDataAccessException.class)  // 일시적 DB 오류
            .retry(ConnectTimeoutException.class)        // 연결 타임아웃
            .retryLimit(3)                                // 최대 3번 시도
            .noRetry(NonTransientDataAccessException.class) // 영구적 오류는 재시도 안 함
            .build();
}

스킵 + 리트라이 조합

실무에서는 리트라이와 스킵을 함께 사용하는 경우가 많습니다. "3번 재시도해보고, 그래도 실패하면 스킵"하는 패턴입니다.

JAVA
@Bean
public Step resilientStep(JobRepository jobRepository,
                            PlatformTransactionManager txManager) {
    return new StepBuilder("resilientStep", jobRepository)
            .<Order, Settlement>chunk(100, txManager)
            .reader(reader())
            .processor(processor())
            .writer(writer())
            .faultTolerant()
            // 리트라이: 3번까지 재시도
            .retry(TransientException.class)
            .retryLimit(3)
            // 스킵: 리트라이 소진 후에도 실패하면 스킵
            .skip(TransientException.class)
            .skip(DataFormatException.class)
            .skipLimit(50)
            // 리스너로 추적
            .listener(skipListener())
            .build();
}

스케줄러 연동

Spring Batch는 스케줄링 기능이 없으므로 외부 스케줄러와 연동해야 합니다.

@Scheduled 사용

JAVA
@Component
public class BatchScheduler {

    private final JobLauncher jobLauncher;
    private final Job settlementJob;

    @Scheduled(cron = "0 0 2 * * *")  // 매일 새벽 2시
    public void runSettlement() {
        try {
            JobParameters params = new JobParametersBuilder()
                    .addString("targetDate", LocalDate.now().minusDays(1).toString())
                    .addLong("timestamp", System.currentTimeMillis())
                    .toJobParameters();

jobLauncher.run()으로 Job을 실행하고 결과 상태를 로깅합니다.

JAVA
            JobExecution execution = jobLauncher.run(settlementJob, params);
            log.info("배치 완료 — 상태: {}", execution.getStatus());
        } catch (Exception e) {
            log.error("배치 실행 실패", e);
        }
    }
}

Kubernetes CronJob (운영 환경)

대규모 운영에서는 K8s CronJob으로 배치 애플리케이션을 실행하는 것이 일반적입니다.

YAML
apiVersion: batch/v1
kind: CronJob
metadata:
  name: settlement-batch
spec:
  schedule: "0 2 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: batch
              image: my-registry/settlement-batch:latest
              args: ["--targetDate=$(date -d yesterday +%Y-%m-%d)"]
          restartPolicy: OnFailure

모니터링

JobExplorer로 실행 이력 조회

JAVA
@RestController
public class BatchMonitorController {

    private final JobExplorer jobExplorer;

    @GetMapping("/batch/history/{jobName}")
    public List<Map<String, Object>> getHistory(@PathVariable String jobName) {
        return jobExplorer.findJobInstancesByJobName(jobName, 0, 10).stream()
                .flatMap(instance -> jobExplorer.getJobExecutions(instance).stream())
                .map(execution -> Map.<String, Object>of(
                        "executionId", execution.getId(),
                        "status", execution.getStatus().name(),
                        "startTime", execution.getStartTime(),
                        "endTime", execution.getEndTime(),
                        "exitStatus", execution.getExitStatus().getExitCode()
                ))
                .toList();
    }
}

배치 완료/실패 알림

JAVA
public class JobCompletionListener implements JobExecutionListener {

    private final NotificationService notificationService;

    @Override
    public void afterJob(JobExecution jobExecution) {
        String jobName = jobExecution.getJobInstance().getJobName();
        BatchStatus status = jobExecution.getStatus();

        if (status == BatchStatus.COMPLETED) {
            long totalWritten = jobExecution.getStepExecutions().stream()
                    .mapToLong(StepExecution::getWriteCount)
                    .sum();
            notificationService.send(String.format(
                    "[배치 완료] %s — 처리 건수: %d", jobName, totalWritten));

실패 시에는 예외 메시지를 수집하여 알림을 발송합니다.

JAVA
        } else if (status == BatchStatus.FAILED) {
            String errorMsg = jobExecution.getAllFailureExceptions().stream()
                    .map(Throwable::getMessage)
                    .collect(Collectors.joining(", "));
            notificationService.sendAlert(String.format(
                    "[배치 실패] %s — 원인: %s", jobName, errorMsg));
        }
    }
}

실무 운영 체크리스트

  • preventRestart() 설정 여부 확인 (중복 실행 방지가 필요한 Job)
  • 스킵된 아이템을 별도 테이블에 기록 하고, 추후 수동 처리할 수 있도록 준비
  • skipLimit을 적절히 설정 — 너무 크면 대량 오류를 감지하지 못함
  • 배치 완료/실패 시 ** 슬랙/이메일 알림** 설정
  • ** 메타데이터 테이블 정리** — 오래된 실행 이력은 주기적으로 삭제
  • JobParameters에 timestamp를 포함 하여 동일 파라미터 재실행 시 고유성 보장

주의할 점

1. skipLimit을 너무 크게 설정하면 대량 오류를 감지하지 못한다

skipLimit을 10000으로 설정하면 1만 건의 데이터가 스킵되어도 배치는 정상 완료(COMPLETED)로 처리됩니다. 실제로는 절반 이상의 데이터가 처리되지 않았는데도 성공으로 보고될 수 있습니다. skipLimit은 전체 데이터 대비 합리적인 비율로 설정하고, 스킵 건수를 모니터링하세요.

2. 재시작 시 데이터가 변경되었으면 중복 처리나 누락이 발생할 수 있다

배치가 실패 후 재시작하기까지의 시간 동안 원본 데이터가 변경(추가, 삭제, 수정)되면, 재시작 시 이전에 처리한 데이터를 다시 처리하거나 새로 추가된 데이터를 건너뛸 수 있습니다. 재시작 가능한 배치를 설계할 때는 데이터 변경 가능성을 고려한 Reader 전략(예: 처리 상태 컬럼 활용)이 필요합니다.

3. 메타데이터 테이블을 정리하지 않으면 조회 성능이 점점 떨어진다

Spring Batch는 모든 Job/Step 실행 이력을 메타데이터 테이블에 저장합니다. 수개월간 정리하지 않으면 테이블이 수십만 건 이상 쌓여 JobExplorer 조회나 재시작 판단이 느려집니다. 주기적으로 오래된 실행 이력을 정리하는 별도 배치를 운영하세요.

정리

  • 재시작 은 FAILED된 Step부터 이어서 실행하며, allowStartIfComplete로 세부 제어 가능합니다
  • ExecutionContext 에 상태를 저장하면 chunk 커밋마다 영속화되어 재시작 시 복원됩니다
  • 스킵 은 불량 데이터를 건너뛰고, 리트라이 는 일시적 장애를 자동 재시도합니다
  • 운영에서는 스킵 로깅, 완료/실패 알림, 메타데이터 정리가 필수입니다
댓글 로딩 중...