배치 운영 — 재시작, 스킵, 리트라이 전략과 모니터링
배치 작업이 중간에 실패했는데 처음부터 다시 돌려야 한다면, 이미 처리한 수십만 건은 어떻게 되는 걸까요?
운영 환경에서 배치는 반드시 실패합니다. 네트워크 장애, DB 타임아웃, 잘못된 데이터 등 원인은 다양합니다. Spring Batch는 이런 상황에서 재시작, 스킵, 리트라이를 통해 배치 작업을 안정적으로 운영할 수 있는 메커니즘을 제공합니다.
재시작 (Restartable)
재시작의 동작 원리
Spring Batch는 Job/Step의 실행 상태를 메타데이터 테이블에 저장합니다. 같은 JobParameters로 Job을 다시 실행하면, 프레임워크가 이전 실행 상태를 확인합니다.
- 이전 실행이 COMPLETED: 이미 완료된 것으로 간주, 실행하지 않음
- ** 이전 실행이 FAILED**: 실패 지점부터 재시작
Job("정산") + Params("2026-03-19")
└─ JobExecution#1: FAILED
├─ Step1 (initStep): COMPLETED ← 건너뜀
├─ Step2 (processStep): FAILED ← 여기서부터 재시작
└─ Step3 (reportStep): NOT STARTED
allowStartIfComplete
기본적으로 COMPLETED된 Step은 재시작 시 건너뜁니다. 하지만 매번 실행해야 하는 Step이 있다면 allowStartIfComplete를 설정합니다.
@Bean
public Step initStep(JobRepository jobRepository,
PlatformTransactionManager txManager) {
return new StepBuilder("initStep", jobRepository)
.tasklet(initTasklet(), txManager)
.allowStartIfComplete(true) // 완료되었어도 재실행
.build();
}
재시작 비활성화
특정 Job이 재시작되면 안 되는 경우(예: 중복 발송 위험) preventRestart()를 설정합니다.
@Bean
public Job oneTimeJob(JobRepository jobRepository, Step step) {
return new JobBuilder("oneTimeJob", jobRepository)
.preventRestart() // 재시작 금지
.start(step)
.build();
}
ExecutionContext — 상태 저장소
ExecutionContext는 배치 실행 중 상태를 저장하는 키-값 저장소입니다. chunk 커밋 시마다 DB에 영속화되어, 재시작 시 이전 상태를 복원할 수 있습니다.
Step 레벨 ExecutionContext
@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 간 데이터 공유
// 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이 저장한 값을 읽어올 수 있습니다.
// 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)
특정 예외가 발생했을 때 해당 아이템을 건너뛰고 계속 처리할 수 있습니다. 데이터 품질이 완벽하지 않은 상황에서 유용합니다.
@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으로 최대 스킵 가능 건수를 제한합니다.
.noSkip(DatabaseException.class) // 이 예외는 스킵하지 않음
.skipLimit(100) // 최대 100건까지 스킵 허용
.skipPolicy(new AlwaysSkipItemSkipPolicy()) // 또는 커스텀 정책
.build();
}
스킵된 아이템 추적
SkipListener로 스킵된 아이템을 로깅하거나 별도 테이블에 저장할 수 있습니다.
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());
}
}
// Step에 리스너 등록
.listener(new SkipLoggingListener(skipItemRepository))
리트라이 (Retry)
일시적인 장애(네트워크 타임아웃 등)에 대해 자동 재시도를 설정할 수 있습니다.
@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번 재시도해보고, 그래도 실패하면 스킵"하는 패턴입니다.
@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 사용
@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을 실행하고 결과 상태를 로깅합니다.
JobExecution execution = jobLauncher.run(settlementJob, params);
log.info("배치 완료 — 상태: {}", execution.getStatus());
} catch (Exception e) {
log.error("배치 실행 실패", e);
}
}
}
Kubernetes CronJob (운영 환경)
대규모 운영에서는 K8s CronJob으로 배치 애플리케이션을 실행하는 것이 일반적입니다.
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로 실행 이력 조회
@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();
}
}
배치 완료/실패 알림
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));
실패 시에는 예외 메시지를 수집하여 알림을 발송합니다.
} 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 커밋마다 영속화되어 재시작 시 복원됩니다
- 스킵 은 불량 데이터를 건너뛰고, 리트라이 는 일시적 장애를 자동 재시도합니다
- 운영에서는 스킵 로깅, 완료/실패 알림, 메타데이터 정리가 필수입니다