본문 바로가기
Spring/Batch

[Spring Batch] Listener를 통한 전처리 및 후처리

by coding_whale 2026. 5. 8.
반응형

배치 시스템을 이해하기 위해서는 데이터의 흐름뿐만 아니라, 그 흐름이 발생하는 '순간'을 처리해야 한다. 스프링 배치는 잡의 시작과 종료, 스텝의 실행 전후, 그리고 아이템이 처리되는 찰나의 순간마다 로직을 끼워 넣을 수 있는 강력한 콜백 메커니즘인 리스너(Listener)를 제공한다.

 

1. 리스너의 계층 구조와 감시 포인트

스프링 배치는 잡의 실행 단위에 따라 감시 영역을 세밀하게 분리한다. 크게 Job 레벨Step 레벨로 나뉘며, Step 레벨 아래에는 청크와 아이템 단위의 세부 리스너들이 존재한다.

 

 

2. Job 레벨 리스너: JobExecutionListener

Job의 생명주기 전체를 장악하는 리스너로, 실행 직전과 직후에 개입한다.

public interface JobExecutionListener {
    // Job 실행 직전 호출: 초기화 로직에 활용
    default void beforeJob(JobExecution jobExecution) { }
    
    // Job 실행 직후 호출: 결과 알림(Email, Slack)이나 리소스 정리에 활용
    default void afterJob(JobExecution jobExecution) { }
}
  • 특징: afterJob은 Job의 성공/실패 여부와 관계없이 무조건 호출된다. 이를 통해 실패한 Job의 상태를 변경하거나 실패 알림을 안전하게 보낼 수 있다.

 

🔍 StepExecutionListener: 단계별 감시

Step의 시작(beforeStep)과 종료(afterStep) 시점에 개입한다.

  • afterStep: ExitStatus를 반환할 수 있어, 로직에 따라 Step의 최종 상태를 변경하거나 다음 Step으로의 흐름을 동적으로 제어할 수 있다.
public interface StepExecutionListener extends StepListener {
    default void beforeStep(StepExecution stepExecution) {}

    // Step 종료 시 호출되며 ExitStatus를 반환하여 종료 상태를 변경 가능
    default ExitStatus afterStep(StepExecution stepExecution) {
        return stepExecution.getExitStatus();
    }
}

 

🔍 ChunkListener: 트랜잭션 단위 제어

public interface ChunkListener extends StepListener {
    default void beforeChunk(ChunkContext context) {} // 아이템 읽기 전
    default void afterChunk(ChunkContext context) {}  // 쓰기 완료 후 커밋 직전
    default void onChunkError(Exception ex, ChunkContext context) {} // 예외 발생 시
}
  • beforeChunk: 아이템 읽기 전, 청크 주기가 시작될 때 호출.
  • afterChunk: 쓰기 완료 후, 트랜잭션 커밋 직전에 호출.
  • onChunkError: 예외 발생 시 호출되며, 롤백 전이므로 트랜잭션 작업 시 PROPAGATION_REQUIRES_NEW 전파 속성이 필요하다.

 

🔍 Item[Read|Process|Write]Listener: 데이터 처리 감시

  • Read: 아이템을 읽기 전후 개입. (ItemReader가 null을 반환하면 afterRead는 호출되지 않음)
  • Process: 가공 로직 전후 개입.
  • Write: Chunk 단위로 입력을 받는다. ItemWriter가 리스트 형태의 청크를 일괄 처리하기 때문이다.

 

 

3. 리스너 구현 전략 (Interface vs Annotation)

🛠️ 방식 1. 인터페이스 직접 구현

가장 명시적이고 직관적인 방법이다. 필요한 리스너 인터페이스를 상속받아 원하는 메서드만 오버라이드한다.

@Slf4j
@Component
public class SecurityAuditJobListener implements JobExecutionListener {
    @Override
    public void beforeJob(JobExecution jobExecution) {
        log.info("[AUDIT] 작전명: {} | 침투 시작", jobExecution.getJobInstance().getJobName());
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        log.info("[AUDIT] 종료 상태: {}", jobExecution.getStatus());
    }
}

 

🛠️ 방식 2. 어노테이션 기반 구현

인터페이스 없이 일반 메서드에 @BeforeJob, @AfterStep 등을 붙인다. 코드가 간결해지고 가독성이 높아지며, 한 클래스에 여러 시점의 로직을 모아둘 수 있다. 단, @AfterStep 사용 시 인터페이스 규약에 따라 반드시 ExitStatus를 반환해야 함을 주의하자.

@Slf4j
@Component
public class AnnotationInfiltrationListener {
    @BeforeStep
    public void scanTarget(StepExecution stepExecution) {
        log.info("[SCAN] 영역 스캔: {}", stepExecution.getStepName());
    }
    
    @AfterStep
    public ExitStatus completeStep(StepExecution stepExecution) {
        log.info("[SCAN] 완료");
        return stepExecution.getExitStatus();
    }
}

 

 

4. 리스너 등록과 자동 등록의 비밀

스프링 배치는 개발자의 편의를 위해 강력한 자동 감지 및 등록 메커니즘을 제공한다.

🛠️ 여러 리스너 등록하기

빌더의 .listener() 메서드를 반복 호출하여 여러 개의 리스너를 순차적으로 등록할 수 있다. 등록된 순서대로 실행되므로 로직의 선후 관계가 중요하다면 순서에 신경 써야 한다.

 

🛠️ 자동 등록 메커니즘 1: 다중 리스너 구현

한 클래스가 ItemReadListener와 StepExecutionListener를 동시에 구현했다면, 빌더는 이를 자동으로 감지해 각 타입에 맞는 시점에 모두 등록해준다.

  • ⚠️ 주의사항: 반드시 StepListener 타입(부모 인터페이스)으로 객체를 넘겨야 자동 감지가 동작한다. 만약 특정 하위 타입으로 캐스팅하여 전달하면 프레임워크가 다른 인터페이스의 존재를 파악하지 못할 수 있다.

 

🛠️ 자동 등록 메커니즘 2: 컴포넌트 자동 감지

reader(), processor(), writer() 메서드에 전달된 빈(Bean) 객체가 리스너 인터페이스를 구현하고 있다면, 별도로 .listener()를 호출하지 않아도 스프링 배치가 이를 자동으로 리스너로 등록한다.

 

5. 리스너와 배치 스코프(Scope)의 통합

리스너를 빈으로 정의하고 @JobScope나 @StepScope를 적용하면 잡 파라미터를 직접 주입받아 사용할 수 있다.

📦 실전 사례: POJO 기반 파라미터 리스너 주입

파라미터가 많을 때 객체(DTO)로 묶어 리스너에서 사용하는 방식이다.

// 1. 파라미터 관리 POJO (DTO)
@Component @StepScope
public class MissionParams {
    @Value("#{jobParameters['victim']}") String victim;
}

// 2. 리스너에서 POJO 주입받아 사용
@Component @JobScope
public class DeathWitnessListener implements JobExecutionListener {
    private final MissionParams params; // DI를 통해 주입받음

    public DeathWitnessListener(MissionParams params) { this.params = params; }

    @Override
    public void beforeJob(JobExecution jobExecution) {
        log.info("처형 대상 식별 완료: {}", params.getVictim());
    }
}

 

 

6. 마무리하며: 성능과 예외 처리에 대한 조언

리스너는 강력한 무기지만 신중하게 다뤄야 한다.

  1. 예외 처리: 리스너 내부 예외는 전체 배치를 중단시킬 수 있다. (단, afterJob/afterStep 예외는 실행 결과에 영향을 주지 않는다.)
  2. 실행 빈도: Job/StepListener는 실행 당 한 번만 호출되지만, ItemListener는 데이터 건수만큼 호출된다. 이곳에 무거운 로직을 넣는 것은 시스템에 과부하가 걸리게 된다.
반응형