Spring Batch Job 설정 — Tasklet과 Chunk Step

2026-05-03확률과 통계 마스터 노트

Spring Batch 마스터 노트 시리즈 2편. @EnableBatchProcessing부터 JobBuilderFactory·StepBuilderFactory, Tasklet 람다·인터페이스 구현, Chunk Step 빌드, REST API로 Job 실행, JobParameters 활용, 조건부 흐름의 on/to/from/end 패턴, 커스텀 ExitStatus와 JobExecutionDecider까지 코드와 함께.

이 글은 Spring Batch 마스터 노트 시리즈의 두 번째 편입니다. 1편(입문)에서 모델을 잡았다면, 이번엔 그 모델을 실제 코드로 빚어내는 자리예요.

@EnableBatchProcessing 한 줄로 시작해서 Tasklet·Chunk Step을 만들고, REST API로 Job을 실행하고, 조건부 흐름까지 — Spring Batch 4.x 기준의 본격 코드입니다. 5.x 마이그레이션은 8편에서 따로 다뤄요.

처음 Job 설정이 어렵게 느껴지는 이유

이유는 두 가지예요.

첫째, 빌더 메서드가 많고 순서에 의미가 있습니다. .start().next().on().to().from().end() — 메서드 체인이 길어지면 어디서 흐름이 분기되는지 한눈에 안 들어와요. 빠뜨리면 컴파일 오류는 안 나는데 런타임에 이상한 동작.

둘째, JobParameters와 @StepScope의 관계가 헷갈립니다. 동적으로 파라미터를 받아 Step을 구성하려면 @StepScope + @Value("#{jobParameters['xxx']}") 조합이 필요한데, 이 패턴이 처음엔 어디서 어떻게 동작하는지 안 보여요.

해결법은 한 가지예요. Job 설정 = "흐름(start/next/on/to/from)을 그래프로 먼저 그리고 그 그래프를 코드로 옮기는 것" 으로 접근하세요. 종이에 Step 박스 그리고 화살표 그리면 코드가 자연스럽게 나옵니다.

@EnableBatchProcessing — Spring Batch 4.x의 시작

@Configuration
@EnableBatchProcessing  // Spring Batch 4.x
public class BatchConfiguration {
    
    @Autowired
    private JobBuilderFactory jobBuilderFactory;
    
    @Autowired
    private StepBuilderFactory stepBuilderFactory;
    
    // Job과 Step 빈 정의...
}

이 어노테이션이 자동 제공하는 핵심 빈:

  • JobBuilderFactory
  • StepBuilderFactory
  • JobLauncher
  • JobRepository
  • JobExplorer
  • PlatformTransactionManager

여기서 시험 함정이 하나 있어요. Spring Batch 5.x에서는 @EnableBatchProcessing을 제거해야 합니다. Spring Boot 자동 설정으로 대체됐어요. 8편에서 자세히.

JobBuilderFactory와 StepBuilderFactory

// JobBuilderFactory — Job 빈 생성
@Bean
public Job myJob() {
    return jobBuilderFactory.get("jobName")
        .start(firstStep())
        .next(secondStep())
        .build();
}

// StepBuilderFactory — Step 빈 생성
@Bean
public Step myStep() {
    return stepBuilderFactory.get("stepName")
        .tasklet(...)
        .build();
}

get(String name) 으로 빌더를 얻고, 메서드 체이닝으로 설정.

여기서 시험 함정이 하나 있어요. Job/Step 이름은 DB의 BATCH_JOB_INSTANCE에 저장됩니다. 이름을 변경하면 이전 실행 이력과 연결이 끊겨요. 운영에서 이름 변경에 주의 — 명명 규칙을 일찍 정하고 일관되게.

Tasklet 기반 Step — 두 가지 구현

인라인 람다

가장 단순. 간단한 로직에 적합.

@Bean
public Step firstStep() {
    return stepBuilderFactory.get("step one")
        .tasklet((contribution, chunkContext) -> {
            System.out.println("첫 번째 Step 실행 중...");
            return RepeatStatus.FINISHED;
        })
        .build();
}

인터페이스 구현 클래스

복잡한 로직, DI 필요할 때.

@Component
public class FileCleanupTasklet implements Tasklet {
    
    private final String outputPath;
    
    public FileCleanupTasklet(@Value("${batch.output.path}") String outputPath) {
        this.outputPath = outputPath;
    }
    
    @Override
    public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
        File outputDir = new File(outputPath);
        if (outputDir.exists()) {
            for (File file : outputDir.listFiles()) {
                file.delete();
            }
        }
        return RepeatStatus.FINISHED;
    }
}

테스트 작성·재사용 면에서 인터페이스 구현이 더 유연해요.

Chunk 기반 Step — 대량 데이터의 핵심

@Bean
public Step chunkStep() {
    return stepBuilderFactory.get("chunkStep")
        .<Product, Product>chunk(10)    // 청크 크기 10
        .reader(itemReader())           // 필수
        .processor(itemProcessor())     // 선택사항
        .writer(itemWriter())           // 필수
        .build();
}

<I, O>chunk(size) — I는 입력 타입, O는 출력 타입. 둘이 다르면 ItemProcessor가 변환.

청크 크기 선택 가이드

청크 크기 트랜잭션 수 메모리 실패 시 재처리 권장 상황
1 매우 많음 최소 1개 트랜잭션 세밀 제어
10 많음 낮음 최대 10개 일반 배치
100 보통 중간 최대 100개 대량 처리 권장
1000 적음 높음 최대 1000개 단순 대량 INSERT
10000+ 매우 적음 매우 높음 매우 넓음 주의

여기서 시험 함정이 하나 있어요. 청크 안에서 예외 발생 시 청크 전체가 롤백됩니다. 99개가 성공했어도 1개 실패면 100개 모두 롤백. 7편(오류 처리) Skip 기능으로 해결.

Job 기본 설정

단순 순차 Job

@Bean
public Job sequentialJob() {
    return jobBuilderFactory.get("sequentialJob")
        .start(step1())
        .next(step2())
        .next(step3())
        .build();
}

이전 Step이 실패하면 다음 Step은 실행되지 않고 Job이 FAILED.

RunIdIncrementer — 반복 실행 가능

@Bean
public Job myJob() {
    return jobBuilderFactory.get("myJob")
        .incrementer(new RunIdIncrementer())  // 자동 run.id 증가
        .start(myStep())
        .build();
}

매번 다른 JobInstance를 생성. 개발/테스트에 필수.

preventRestart() — 재시작 방지

@Bean
public Job nonRestartableJob() {
    return jobBuilderFactory.get("nonRestartableJob")
        .preventRestart()
        .start(myStep())
        .build();
}

멱등성이 없는 Job, "오늘 한 번만" 실행해야 하는 Job에 사용.

REST API로 Job 실행

자동 실행 비활성화

# application.properties
spring.batch.job.enabled=false

이거 안 끄면 앱 시작할 때마다 Job이 자동 실행됩니다.

Job 실행 컨트롤러

@RestController
public class JobController {
    
    @Autowired
    private JobLauncher jobLauncher;
    
    @Autowired
    @Qualifier("myJob")
    private Job myJob;
    
    @GetMapping("/run-job")
    public String runJob() {
        try {
            JobParameters params = new JobParametersBuilder()
                .addLong("startedAt", System.currentTimeMillis())
                .toJobParameters();
            
            JobExecution jobExecution = jobLauncher.run(myJob, params);
            return "Job Status: " + jobExecution.getStatus();
        } catch (Exception e) {
            return "Job Failed: " + e.getMessage();
        }
    }
    
    @GetMapping("/run-job-with-param")
    public String runJobWithParam(@RequestParam String reportDate) {
        try {
            JobParameters params = new JobParametersBuilder()
                .addString("reportDate", reportDate)
                .addLong("startedAt", System.currentTimeMillis())
                .toJobParameters();
            
            JobExecution jobExecution = jobLauncher.run(myJob, params);
            return "Job executed: " + jobExecution.getStatus();
        } catch (Exception e) {
            return "Error: " + e.getMessage();
        }
    }
}

@Qualifier — 여러 Job이 있을 때

@Autowired
@Qualifier("reportJob")
private Job reportJob;

@Autowired
@Qualifier("etlJob")
private Job etlJob;

@Autowired 단독으로는 NoUniqueBeanDefinitionException이 떠요.

JobParameters 상세 활용

파라미터 타입

JobParameters params = new JobParametersBuilder()
    .addString("reportType", "DAILY")
    .addLong("processCount", 1000L)
    .addDouble("threshold", 0.85)
    .addDate("reportDate", new Date())
    .toJobParameters();

Step 내에서 접근

// Tasklet에서
.tasklet((contribution, chunkContext) -> {
    String reportType = chunkContext
        .getStepContext()
        .getJobParameters()
        .get("reportType")
        .toString();
    return RepeatStatus.FINISHED;
})

// StepExecutionListener에서
public void beforeStep(StepExecution stepExecution) {
    String reportType = stepExecution
        .getJobParameters()
        .getString("reportType");
}

Job Flow — 순차 흐름

@Bean
public Job sequentialJob() {
    return jobBuilderFactory.get("sequentialJob")
        .start(validateStep())
        .next(processStep())
        .next(cleanupStep())
        .build();
}

가장 단순. 한 Step 실패 → Job 즉시 FAILED.

Job Flow — 조건부 흐름

on/to/from 패턴

@Bean
public Job conditionalJob() {
    return jobBuilderFactory.get("conditionalJob")
        .start(step1())
            .on("COMPLETED").to(step2())     // step1 완료 → step2
            .on("FAILED").to(step3())         // step1 실패 → step3
        .from(step2())
            .on("COMPLETED").end()            // step2 완료 → Job 완료
        .from(step3())
            .on("*").fail()                   // step3 결과 무관 → Job 실패
        .end()
        .build();
}
메서드 역할
.on("EXIT_CODE") Exit Status 조건 (* = 모든 상태)
.to(step) 조건 충족 시 다음 Step
.from(step) 특정 Step에서 분기 추가
.end() Job COMPLETED 상태 종료
.fail() Job FAILED 상태 종료
.stopAndRestart(step) STOPPED 상태, 재시작 시 지정 Step부터

패턴 매칭

  • "COMPLETED" — 정확 매칭
  • "*" — 모든 문자열 (와일드카드)
  • "CUSTOM_*" — 접두사 매칭
  • "?OMPLETED"? 단일 문자

여기서 정말 중요한 시험 함정 — .end() 호출 위치가 관건입니다. 조건부 흐름에서 from()/on()/to() 체인의 끝이 아니라, build() 직전.end()를 호출. 빠뜨리면 런타임 JobBuilderException.

ABANDONED 상태 처리

@Bean
public Job abandonedJob() {
    return jobBuilderFactory.get("abandonedJob")
        .start(step1())
            .on("FAILED").to(step2())
        .from(step2())
            .on("COMPLETED").to(step3())
            .on("FAILED").stopAndRestart(step3())
        .end()
        .build();
}

FAILEDABANDONED 의 차이:

상태 재시작 시 동작
FAILED 해당 Step 다시 실행
ABANDONED 해당 Step 건너뛰고 다음 Step부터

부분 실패 후 해당 Step을 다시 실행하면 안 되는 자리에서 사용.

커스텀 ExitStatus

기본 ExitStatus 외 커스텀 상태로 세밀한 흐름 제어.

StepExecutionListener 구현

@Component
public class ProductStepListener implements StepExecutionListener {
    
    @Override
    public void beforeStep(StepExecution stepExecution) {
        System.out.println("Step 시작: " + stepExecution.getStepName());
    }
    
    @Override
    public ExitStatus afterStep(StepExecution stepExecution) {
        if (stepExecution.getWriteCount() == 0) {
            return new ExitStatus("NO_DATA");
        } else if (stepExecution.getWriteCount() < 100) {
            return new ExitStatus("FEW_DATA");
        }
        return ExitStatus.COMPLETED;
    }
}

커스텀 ExitStatus를 활용한 Flow

@Bean
public Step productStep() {
    return stepBuilderFactory.get("productStep")
        .<Product, Product>chunk(10)
        .reader(reader())
        .writer(writer())
        .listener(productStepListener())
        .build();
}

@Bean
public Job productJob() {
    return jobBuilderFactory.get("productJob")
        .start(productStep())
            .on("NO_DATA").end()
            .on("FEW_DATA").to(notificationStep())
            .on("COMPLETED").to(reportStep())
        .from(notificationStep())
            .on("*").end()
        .from(reportStep())
            .on("*").end()
        .end()
        .build();
}

JobExecutionDecider — 동적 흐름 결정

Step의 Exit Status와 무관하게 런타임에 동적으로 흐름 결정.

@Component
public class ProductDecider implements JobExecutionDecider {
    
    @Override
    public FlowExecutionStatus decide(JobExecution jobExecution, StepExecution stepExecution) {
        String source = jobExecution.getExecutionContext().getString("dataSource", "");
        
        if (source.equals("DB")) {
            return new FlowExecutionStatus("DB_SOURCE");
        } else if (source.equals("FILE")) {
            return new FlowExecutionStatus("FILE_SOURCE");
        }
        return new FlowExecutionStatus("UNKNOWN_SOURCE");
    }
}
@Bean
public Job deciderJob() {
    return jobBuilderFactory.get("deciderJob")
        .start(initStep())
        .next(productDecider())
            .on("DB_SOURCE").to(dbProcessStep())
            .on("FILE_SOURCE").to(fileProcessStep())
            .on("UNKNOWN_SOURCE").fail()
        .from(dbProcessStep())
            .on("*").end()
        .from(fileProcessStep())
            .on("*").end()
        .end()
        .build();
}

ExecutionContext의 값 읽어 동적 분기 가능. StepExecutionListener보다 더 유연.

FlowBuilder — 재사용 가능한 Flow

같은 Step 시퀀스를 여러 Job에서 재사용.

@Bean
public Flow validationFlow() {
    return new FlowBuilder<Flow>("validationFlow")
        .start(validateFormatStep())
        .next(validateRangeStep())
        .next(validateBusinessStep())
        .end();
}

// Job A
@Bean
public Job jobA() {
    return jobBuilderFactory.get("jobA")
        .start(validationFlow())
        .next(processStep())
        .end()
        .build();
}

// Job B — 동일 Flow 재사용
@Bean
public Job jobB() {
    return jobBuilderFactory.get("jobB")
        .start(validationFlow())
        .next(reportStep())
        .end()
        .build();
}

Nested Job — JobStep 패턴

큰 배치를 여러 독립 Job으로 분리.

@Bean
public Step jobOneStep(Job childJob) {
    return stepBuilderFactory.get("jobOneStep")
        .job(childJob)
        .parametersExtractor(new DefaultJobParametersExtractor())
        .build();
}

@Bean
public Job parentJob() {
    return jobBuilderFactory.get("parentJob")
        .start(jobOneStep(childJobA()))
        .next(jobOneStep(childJobB()))
        .build();
}

각 자식 Job은 독립 JobInstance/JobExecution. 자체 재시작 가능.

Parallel Steps — 병렬 실행

@Bean
public Job parallelJob() {
    return jobBuilderFactory.get("parallelJob")
        .start(splitFlow())
        .end()
        .build();
}

@Bean
public Flow splitFlow() {
    return new FlowBuilder<SimpleFlow>("splitFlow")
        .split(new SimpleAsyncTaskExecutor())
        .add(flow1(), flow2(), flow3())
        .build();
}

@Bean
public Flow flow1() {
    return new FlowBuilder<SimpleFlow>("flow1")
        .start(step1())
        .build();
}

여기서 시험 함정이 하나 있어요. 병렬 처리에서 JdbcCursorItemReader 사용 금지. thread-safe 아님. JdbcPagingItemReader 사용. 4편에서 자세히.

종합 예시

@Configuration
@EnableBatchProcessing
public class BatchConfiguration {
    
    @Autowired private JobBuilderFactory jobBuilderFactory;
    @Autowired private StepBuilderFactory stepBuilderFactory;
    @Autowired private DataSource dataSource;
    
    @Bean
    public Step validateStep() {
        return stepBuilderFactory.get("validateStep")
            .tasklet((contribution, chunkContext) -> {
                System.out.println("데이터 검증 중...");
                return RepeatStatus.FINISHED;
            })
            .build();
    }
    
    @Bean
    public Step processStep() {
        return stepBuilderFactory.get("processStep")
            .<Product, Product>chunk(10)
            .reader(csvReader())
            .processor(productProcessor())
            .writer(dbWriter())
            .build();
    }
    
    @Bean
    public Step reportStep() {
        return stepBuilderFactory.get("reportStep")
            .tasklet((contribution, chunkContext) -> {
                System.out.println("보고서 생성 중...");
                return RepeatStatus.FINISHED;
            })
            .build();
    }
    
    @Bean
    public Job fullProcessingJob() {
        return jobBuilderFactory.get("fullProcessingJob")
            .incrementer(new RunIdIncrementer())
            .start(validateStep())
                .on("COMPLETED").to(processStep())
                .on("FAILED").fail()
            .from(processStep())
                .on("COMPLETED").to(reportStep())
                .on("FAILED").fail()
            .from(reportStep())
                .on("*").end()
            .end()
            .build();
    }
}

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 2편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • @EnableBatchProcessing = Spring Batch 4.x의 시작
  • 자동 제공 빈 — JobBuilderFactory / StepBuilderFactory / JobLauncher / JobRepository
  • 5.x에서는 @EnableBatchProcessing 제거 (8편)
  • JobBuilderFactory.get("name") / StepBuilderFactory.get("name") 패턴
  • Job/Step 이름 변경 시 이전 이력과 연결 끊김 — 운영 주의
  • Tasklet — 인라인 람다 vs 인터페이스 구현
  • 람다 — 단순 로직 / 인터페이스 — DI·테스트 유리
  • RepeatStatus.FINISHED 반환이 표준
  • Chunk Step — <I, O>chunk(size).reader().processor().writer()
  • 청크 크기 일반적으로 10~1000, 시작 100 권장
  • 청크 내 예외 = 청크 전체 롤백 (7편 Skip으로 해결)
  • RunIdIncrementer = 매번 새 JobInstance (개발 필수)
  • preventRestart() = 재시작 방지 (멱등 X 작업)
  • REST API 실행 = spring.batch.job.enabled=false 필수
  • @Qualifier = 여러 Job 중 특정 Job 명시
  • JobParameters 타입 — String / Long / Double / Date
  • 조건부 흐름 5메서드 — on / to / from / end / fail
  • 패턴 매칭 — "*" 와일드카드, "CUSTOM_*" 접두사
  • .end()는 build() 직전 — 빠뜨리면 런타임 예외
  • ABANDONED — 재시작 시 건너뜀 (FAILED는 재실행)
  • 커스텀 ExitStatus — new ExitStatus("CUSTOM_CODE")
  • StepExecutionListener.afterStep() = 커스텀 ExitStatus 반환
  • JobExecutionDecider = 동적 흐름 결정 (ExecutionContext 활용)
  • FlowBuilder = 재사용 가능한 Flow
  • Nested Job = JobStep으로 자식 Job 실행
  • Parallel Steps = split() + SimpleAsyncTaskExecutor
  • 병렬 처리 시 JdbcCursorItemReader 금지 (thread-safe X)

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 톤으로 묶어 정리되어 있어요. 2편 Job 설정이 잡히면 3편 청크 처리에서 ItemReader/Processor/Writer 패턴이 자연스럽게 따라옵니다.

공식 문서: Spring Batch JobBuilder API에서 모든 빌더 메서드의 시그니처를 확인할 수 있어요.

다음 글(3편)에서는 청크 처리의 본격 — ItemReader/ItemProcessor/ItemWriter 인터페이스, 변환·필터링·검증 패턴, CompositeItemProcessor, 데이터 흐름 상세를 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!