🧪

Spring Batch에서 도중 종료시 BATCH_JOB_EXECUTION의 STATUS가 업데이트 되지 않는 문제

최민석·2025-11-03

Spring Batch에서 도중 종료시 BATCH_JOB_EXECUTION의 STATUS가 업데이트 되지 않는 문제

이 문서는 Spring Batch 애플리케이션에서 Graceful Shutdown(안전한 종료)을 시도했음에도 불구하고, BATCH_JOB_EXECUTION 테이블의 STATUSSTOPPED로 변경되지 않고 STARTED 상태로 남아 재시작이 불가능한 문제에 대해 실제로 진행했던 트러블슈팅 과정을 중심으로 설명합니다.


1. 문제 상황 발생

배치 Job이 실행 중일 때 Graceful Shutdown을 시도했습니다. 예상대로라면 BATCH_JOB_EXECUTION 테이블의 STATUS 컬럼이 STOPPED로 변경되어야 하지만, 실제로는 STARTED 상태로 남아 있었습니다.

  • 예상 결과: STATUS = STOPPED
  • 실제 결과: STATUS = STARTED

이 상태에서 동일한 파라미터로 Job을 재실행하면 Spring Batch는 해당 Job Instance가 이미 실행 중(STARTED)이라고 판단하여 JobInstanceAlreadyCompleteException 예외를 발생시키며 실패합니다.


2. 문제 상황 재현

spring.batch.job.enabled = true로 하여, 애플리케이션이 실행될때 처리하도록 하려 했습니다. 이는 k8s에서 Job 파드로 처리하기 위함이었는데요,

spring:
  batch:
    job:
      enabled: true

문제는 Graceful Shutdown 요청 시점에 BATCH_JOB_EXECUTION의 상태가 정상적으로 변경되지 않는 상황에서 재현되었습니다.

재현 순서

  1. 어플리케이션 실행을 통해 배치 Job을 자동 트리거합니다.
  2. 실행 중에 curl -X POST http://localhost:8080/actuator/shutdown 명령으로 Graceful Shutdown을 요청합니다.
  3. DB의 BATCH_JOB_EXECUTION 테이블을 확인하면 STATUSSTARTED로 남아 있는 것을 확인합니다.

3. 트러블슈팅

시도1: @StepScope 의심

초기에는 ItemReader Bean에 @StepScope가 없어서 발생하는 문제라고 추정했습니다. @StepScope가 없으면 Singleton Bean으로 생성되어 상태 관리가 어려울 수 있다는 점 때문입니다.

@Configuration
public class OriginalSentenceItemReaderConfig {

    @Bean
    // @StepScope // <- 이 어노테이션이 없는 상황
    @Qualifier("originalSentenceItemReaderConfig")
    public JpaPagingItemReader<OriginalSentence> originalSentenceRepositoryItemReader(
        EntityManagerFactory emf
    ) {
        return new JpaPagingItemReaderBuilder<OriginalSentence>()
            .name("originalSentenceRepositoryItemReader")
            .entityManagerFactory(emf)
            .queryString("SELECT o FROM OriginalSentence o ...")
            .pageSize(100)
            .build();
    }
}

하지만 @StepScope를 적용해도 문제가 해결되지 않아 원인이 다른 곳에 있음을 확인했습니다.


시도2: BatchShutdownHook 시도

Spring Batch의 JobOperator를 활용하여 JVM 종료 시점에 배치 작업을 안전하게 중지하는 ShutdownHook을 등록해 보았습니다.

@Component
public class BatchShutdownHook implements DisposableBean {

    private final JobOperator jobOperator;

    public BatchShutdownHook(JobOperator jobOperator) {
        this.jobOperator = jobOperator;
    }

    @Override
    public void destroy() throws Exception {
        jobOperator.stopAll();
    }
}

하지만 이 방법도 STATUSSTOPPED로 변경되지 않고, 여전히 재시작이 불가능한 문제를 해결하지 못했습니다.


시도3: SmartLifecycle + stop 대기 로직 추가

배치 종료 시점을 명확히 제어하기 위해 SmartLifecycle 인터페이스를 구현하고, stop() 메서드에서 배치 작업이 완전히 종료될 때까지 대기하는 로직을 추가했습니다.

@Component
public class BatchDrainLifecycle implements SmartLifecycle {

    private final JobOperator jobOperator;
    private boolean running = false;

    public BatchDrainLifecycle(JobOperator jobOperator) {
        this.jobOperator = jobOperator;
    }

    @Override
    public void start() {
        running = true;
    }

    @Override
    public void stop() {
        try {
            jobOperator.stopAll();
            // 배치 작업이 완전히 종료될 때까지 대기
        } catch (Exception e) {
        }
        running = false;
    }

    @Override
    public boolean isRunning() {
        return running;
    }
}

이 방법을 적용하자, 애플리케이션 종료 시점에 배치 작업이 완전히 종료될 때까지 기다리면서 BATCH_JOB_EXECUTIONSTATUS가 정상적으로 STOPPED로 변경되는 것을 확인할 수 있었습니다.


4. 검증된 해결방법 (BatchDrainLifecycle)

최종적으로 BatchDrainLifecycle을 도입하여, Spring의 SmartLifecycle 인터페이스를 활용해 애플리케이션 종료 시점에 배치 Job이 완전히 종료될 때까지 기다리도록 했습니다.

이 방식은 다음과 같은 장점이 있습니다:

  • Spring 컨테이너가 종료될 때 BatchDrainLifecyclestop() 메서드가 호출됩니다.
  • stop() 메서드에서 JobOperator.stopAll()을 호출하여 실행 중인 모든 배치 Job을 중지합니다.
  • 배치 Job이 완전히 종료될 때까지 대기함으로써, JPA(EntityManager)나 HikariCP 커넥션 풀 등이 먼저 종료되는 것을 방지합니다.
  • 결과적으로 BATCH_JOB_EXECUTION 테이블의 STATUS가 정상적으로 STOPPED로 변경되어, Job 재시작이 가능해집니다.

이 방법이 문제 해결에 가장 효과적인 것으로 검증되었습니다.


5. 오류 이유 정리

이번 문제의 근본 원인은 @StepScope와 같은 Bean 스코프 설정이 아니라, 애플리케이션 종료 시점에서의 리소스 종료 순서에 있었습니다.

  • JPA(EntityManager)와 HikariCP 커넥션 풀이 Spring Batch Job이 완전히 종료되기 전에 먼저 종료되어, Batch가 정상적으로 STOPPED 상태로 상태를 업데이트하지 못했습니다.
  • 이로 인해 BATCH_JOB_EXECUTION 테이블의 STATUSSTARTED 상태로 남아, Job 재시작이 불가능한 상황이 발생했습니다.
  • BatchDrainLifecycle과 같은 SmartLifecycle 구현체를 통해 Batch Job이 종료될 때까지 애플리케이션 종료를 지연시키는 방식으로 문제를 해결할 수 있었습니다.

따라서, Spring Batch에서 안전한 종료와 재시작을 보장하려면, Batch Job 종료 시점을 명확히 제어하고 리소스 종료 순서를 적절히 관리하는 것이 중요합니다.


추가 분석: spring.batch.job.enabled=true 인 경우에만 오류가 발생한 이유

자동으로 배치 Job이 애플리케이션 시작 시점에 실행되는 경우(spring.batch.job.enabled=true), 문제가 발생하는 반면, 수동으로 컨트롤러 등을 통해 실행하는 경우(spring.batch.job.enabled=false + 수동 트리거)에는 이러한 문제가 발생하지 않는 이유에 대해 추가로 분석해보았습니다.

자동 시작 모드에서는 JobLauncherApplicationRunner가 애플리케이션 컨텍스트가 완전히 초기화되기 전에 배치 Job을 실행합니다. 이로 인해 애플리케이션 종료 시점에서는 Spring의 라이프사이클 종료 순서가 역전되어, JPA(EntityManager)와 HikariCP 커넥션 풀이 먼저 종료되고, 그 후에 배치 Job이 종료됩니다. 결과적으로 배치 Job이 아직 실행 중인 상태에서 데이터베이스 연결이 끊기면서 BATCH_JOB_EXECUTIONSTATUS 업데이트가 실패하게 됩니다.

반면, 수동 실행 모드에서는 애플리케이션 컨텍스트가 완전히 초기화된 이후에 Job이 실행되기 때문에, 라이프사이클 종료 순서가 일관되게 유지됩니다. 따라서 애플리케이션 종료 시점에 배치 Job이 먼저 종료되고, 그 후에 JPA 및 커넥션 풀이 정상적으로 닫히므로, STATUS 업데이트가 정상적으로 이루어집니다.

이러한 차이로 인해 spring.batch.job.enabled=true 설정에서만 문제가 발생하며, 수동 실행 시에는 문제가 재현되지 않는 것입니다. 따라서 자동 시작 배치 Job의 경우에는 종료 시점의 라이프사이클 관리에 특별히 주의를 기울여야 합니다.

이미 문제를 해결하여, 직접 실험해보진 않았지만 아마도 종료 시점에 순서를 결정하는 대신, 시작 시점에 라이프사이클 순서를 조정하는 방법으로도 이 문제를 해결할 수 있을 것처럼 보입니다. 이는 추후에 시간이 남으면 시도해보기로 하겠습니다.