Spring Batch 입문 21편 — Late Binding · @StepScope · @JobScope · SpEL

2026-05-17Spring Batch 입문에서 운영까지

Spring Batch 입문 21편. Job·Step 시작 시점에 결정되는 값을 Bean 안에 주입하는 Late Binding. `@StepScope`·`@JobScope`, `#{jobParameters['key']}`·`#{jobExecutionContext['key']}`·`#{stepExecutionContext['key']}` SpEL, ItemStream proxy 함정까지 정리한 학습 노트.

📚 Spring Batch 입문에서 운영까지 · 21편 — Late Binding · @StepScope · @JobScope · SpEL

이 글은 Spring Batch 입문에서 운영까지 시리즈 48편 중 21편이에요. 20편 까지 Step 흐름 제어 를 잡았다면, Part 4 의 마지막 21편은 시작 시점에 결정되는 값을 어떻게 Bean 안에 넣는가, 즉 Late Binding 이야기예요.

왜 Late Binding 이 필요한가

Spring Batch 의 Job 은 JobParameters (Job 실행 시 전달하는 입력값 묶음) 로 입력값을 받습니다 (6편·9편 참고):

java -jar my-batch.jar input.file.name=/data/file.txt run.date=2026-05-17

그런데 이 값을 ItemReader 의 resource 에 어떻게 주입할지가 문제입니다.

@Bean
public FlatFileItemReader<Foo> reader() {
    return new FlatFileItemReaderBuilder<Foo>()
        .resource(new FileSystemResource(???))    // ← 여기에 input.file.name
        // ...
        .build();
}

문제 — Bean 생성 시점은 애플리케이션 컨텍스트 초기화 시점이고, 이는 Job 시작 전이라 JobParameters 가 아직 없다는 점이에요.

해결 — Bean 생성을 Step 시작 시점까지 지연시키는 것, 즉 Late Binding 이에요. @StepScope·@JobScope 의 핵심이기도 하고요.

입력값 주입의 3단계 진화

단계 1: 컴파일 타임 고정 (Late Binding 없음)

@Bean
public FlatFileItemReader<Foo> reader() {
    return new FlatFileItemReaderBuilder<Foo>()
        .name("reader")
        .resource(new FileSystemResource("/data/file.txt"))   // 하드코딩
        // ...
        .build();
}

파일명이 컴파일 타임에 고정되어 매번 다른 파일을 처리할 수 없습니다.

단계 2: System Property 주입

@Bean
public FlatFileItemReader<Foo> reader(@Value("${input.file.name}") String name) {
    return new FlatFileItemReaderBuilder<Foo>()
        .name("reader")
        .resource(new FileSystemResource(name))
        // ...
        .build();
}
java -Dinput.file.name=/data/file.txt -jar my-batch.jar

진전 — runtime 에 값을 주입할 수 있게 됩니다. 한계 — JobParameters 가 아니라서 JobRepository (Job 실행 메타데이터 저장소) 에 parameter 가 기록되지 않습니다. 같은 JobInstance (동일 파라미터로 묶이는 Job 실행 단위) 로 식별되지 않고, 재시작·중복 방지도 작동하지 않습니다.

단계 3: JobParameters Late Binding (권장)

@StepScope
@Bean
public FlatFileItemReader<Foo> reader(
        @Value("#{jobParameters['input.file.name']}") String name) {
    return new FlatFileItemReaderBuilder<Foo>()
        .name("reader")
        .resource(new FileSystemResource(name))
        // ...
        .build();
}
java -jar my-batch.jar input.file.name=/data/file.txt

진전 — JobParameters 에 기록되어 JobInstance 식별과 재시작 안전성, 메타데이터까지 한 번에 챙길 수 있어요.

@StepScope — Bean Lifecycle 의 변형

기본 Spring bean 은 Singleton 이라 애플리케이션당 한 번 생성됩니다. 반면 @StepScope Bean 은 Step 시작마다 새로 생성 돼요.

Step 시작
  ↓
@StepScope bean 인스턴스 생성
  ↓
JobParameters·ExecutionContext 주입
  ↓
Step 실행
  ↓
Step 종료
  ↓
인스턴스 폐기

→ Step 마다 새 인스턴스가 만들어지고 최신 JobParameters 가 주입됩니다.

사용

@Bean
@StepScope
public ItemReader<Foo> reader(@Value("#{jobParameters['date']}") String date) {
    // ...
}

또는 메서드 인자 없이도 가능합니다.

@Bean
@StepScope
public Tasklet myTasklet(@Value("#{stepExecution.jobParameters['date']}") String date) {
    return (contribution, chunkContext) -> {
        // date 사용
        return RepeatStatus.FINISHED;
    };
}

@StepScope 등록

@EnableBatchProcessing 을 쓰면 자동으로 등록됩니다. 수동 등록이라면 다음처럼 해요.

@Bean
public static StepScope stepScope() {
    return new StepScope();
}

등록 방법은 세 가지 (XML batch namespace · 직접 bean · @EnableBatchProcessing) 인데, 이 중 하나만 쓰면 돼요.

SpEL — 세 가지 binding source

#{...} 안에서 접근 가능한 source 는 세 가지입니다.

1. jobParameters

@Value("#{jobParameters['input.file.name']}") String name
@Value("#{jobParameters['run.date']}") Date date
@Value("#{jobParameters['batch.size']}") long size

가장 흔한 패턴 이에요. JobParameters 의 key 로 직접 접근합니다.

타입 추론은 메서드 인자 타입에 따라 자동으로 변환돼요 (String·long·double·Date·LocalDate).

2. jobExecutionContext

@Value("#{jobExecutionContext['someKey']}") String value

Job 시작 후 이전 Step 이 ExecutionContext (Step·Job 단위로 값을 들고 다니는 상태 저장소) 에 저장한 값에 접근하는 방식이라, Step 간 데이터 전달 에 주로 씁니다.

// Step1 에서 저장
public ExitStatus afterStep(StepExecution stepExecution) {
    stepExecution.getJobExecution().getExecutionContext()
        .put("processedDate", LocalDate.now());
    return ExitStatus.COMPLETED;
}

// Step2 에서 주입
@Bean @StepScope
public Tasklet step2Tasklet(
        @Value("#{jobExecutionContext['processedDate']}") LocalDate date) {
    // date 사용
}

3. stepExecutionContext

@Value("#{stepExecutionContext['key']}") String value

현재 Step 의 ExecutionContext 에 접근합니다. 17편에서 다룬 재시작 안전성 메커니즘과 결합되는데, partition 환경에서 자주 씁니다.

37편 Partitioning 환경에서는 stepExecutionContext 가 각 partition 의 범위 (start·end) 를 주입하는 핵심 경로가 돼요.

SpEL 문법 주의 — quote 필수

@Value("#{jobParameters['input.file']}")     // ✓ 권장
@Value("#{jobParameters[input.file]}")        // ✗ Spring 3.0+ 에서 오류

Spring 3.0+ 부터 map key 에 quote 가 필수예요. 옛 Spring 2.5 의 quote 없는 문법은 호환성 차원에서 동작하긴 하지만 피하는 게 좋아요.

@JobScope — Job 전체 단위

Spring Batch 3.0+ 에 도입됐어요. @StepScope 와 비슷하지만 Job 시작마다 1번 생성 된다는 점이 다릅니다.

@Bean
@JobScope
public Step myStep(JobRepository repo,
                   @Value("#{jobParameters['chunkSize']}") int chunkSize) {
    return new StepBuilder("myStep", repo)
        .<Integer, Integer>chunk(chunkSize, tx)
        .reader(reader())
        .writer(writer())
        .build();
}

용도 — Step 자체에 JobParameters 기반의 동적 설정이 필요할 때 씁니다.

StepScope vs JobScope

항목 @StepScope @JobScope
생성 시점 Step 시작마다 Job 시작마다
인스턴스 수명 Step 종료까지 Job 종료까지
접근 가능 source jobParameters · jobExecutionContext · stepExecutionContext jobParameters · jobExecutionContext
주요 용도 ItemReader·Writer·Processor·Tasklet Step 자체, 공유 자원

Step bean 은 step-scoped 금지 입니다. Step 안의 컴포넌트만 step-scoped 가 되고, Step 자체가 동적 설정이 필요하다면 job-scoped 로 가야 해요.

ItemStream 컴포넌트의 proxy 함정

@StepScope Bean 은 CGLIB (런타임 바이트코드로 클래스 프록시를 만드는 라이브러리) proxy 로 감싸지는데, 이게 ItemStream 인터페이스와 결합될 때 함정이 됩니다.

잘못된 패턴

@Bean
@StepScope
public ItemReader<Foo> reader(@Value("#{jobParameters['name']}") String name) {
    return new FlatFileItemReaderBuilder<Foo>()
        .name("reader")
        .resource(new FileSystemResource(name))
        .build();
}

문제 — 반환 타입이 ItemReader 라서 Proxy 가 ItemReader 만 구현합니다. 그러면 ItemStream 메서드 (open·update·close) 가 호출되지 않아 재시작 안전성이 사라져요.

올바른 패턴

@Bean
@StepScope
public FlatFileItemReader<Foo> reader(@Value("#{jobParameters['name']}") String name) {
    return new FlatFileItemReaderBuilder<Foo>()
        .name("reader")
        .resource(new FileSystemResource(name))
        .build();
}

반환 타입을 가장 구체적인 타입 (FlatFileItemReader) 으로 두면 Proxy 가 ItemReader 와 ItemStream 을 모두 구현해서 정상 동작합니다.

또는 최소 ItemStreamReader<Foo> 로 반환해도 됩니다.

@Bean
@StepScope
public ItemStreamReader<Foo> reader(...) { ... }

권장 — return type 을 가장 구체적인 known implementation 으로 두는 것 (공식 reference).

Late Binding 실전 예제 모음

예제 1: 파일 경로 동적 주입

@Bean
@StepScope
public FlatFileItemReader<Customer> customerReader(
        @Value("#{jobParameters['input.file']}") Resource resource) {
    return new FlatFileItemReaderBuilder<Customer>()
        .name("customerReader")
        .resource(resource)
        .delimited()
        .names("id", "name", "email")
        .targetType(Customer.class)
        .build();
}

JobParameters 의 String 은 Spring 의 ResourceEditor 가 자동으로 Resource 로 변환해 줍니다.

예제 2: 날짜 기반 쿼리

@Bean
@StepScope
public JdbcCursorItemReader<Order> orderReader(
        DataSource dataSource,
        @Value("#{jobParameters['target.date']}") LocalDate date) {
    return new JdbcCursorItemReaderBuilder<Order>()
        .name("orderReader")
        .dataSource(dataSource)
        .sql("SELECT * FROM orders WHERE order_date = ?")
        .queryArguments(Date.valueOf(date))
        .rowMapper(new BeanPropertyRowMapper<>(Order.class))
        .build();
}

예제 3: Step 간 데이터 전달

// Step1 — 파일 line 수 카운트해서 context 에 저장
@Bean
public Step countStep(JobRepository repo, PlatformTransactionManager tx) {
    return new StepBuilder("countStep", repo)
        .tasklet((contribution, chunkContext) -> {
            long count = countLines();
            chunkContext.getStepContext()
                .getStepExecution()
                .getJobExecution()
                .getExecutionContext()
                .putLong("totalCount", count);
            return RepeatStatus.FINISHED;
        }, tx)
        .build();
}

// Step2 — 카운트 받아서 chunk size 동적 조정
@Bean
@JobScope
public Step processStep(JobRepository repo, PlatformTransactionManager tx,
        @Value("#{jobExecutionContext['totalCount']}") long totalCount) {
    int chunkSize = (int) Math.min(1000, totalCount / 10);
    return new StepBuilder("processStep", repo)
        .<Foo, Bar>chunk(chunkSize, tx)
        .reader(reader())
        .writer(writer())
        .build();
}

예제 4: 동적 chunk size

@Bean
@JobScope
public Step dynamicChunkStep(JobRepository repo,
        @Value("#{jobParameters['chunk.size']}") int chunkSize) {
    return new StepBuilder("dynamicChunkStep", repo)
        .<Foo, Bar>chunk(chunkSize, tx)
        .reader(reader())
        .writer(writer())
        .build();
}

운영자가 부하 상태에 따라 chunk size 를 조정할 수 있어요.

자주 만나는 사고

사고 1: IllegalStateException: No Scope registered

원인@StepScope 등록이 누락된 경우입니다.

해결@EnableBatchProcessing 을 쓰거나 StepScope bean 을 명시적으로 등록해 주세요.

사고 2: JobParameters 값이 null

원인 1 — Job 실행 시 parameter 가 누락된 경우. 원인 2 — Key 오타로 @Value("#{jobParameters['input.file']}") 의 key 와 실제 parameter key 가 어긋난 경우. 원인 3@StepScope 누락으로 singleton 생성 시점에 jobParameters 가 비어 있는 경우.

해결 — 셋 다 점검해 봐야 하는데, 특히 @StepScope 빠뜨리는 게 가장 흔해요.

사고 3: ItemStream 메서드 호출 안 됨

원인@StepScope Bean 의 반환 타입이 ItemReader 같은 상위 인터페이스인 경우입니다.

해결 — 반환 타입을 구체 클래스 (FlatFileItemReader 등) 또는 ItemStreamReader 로 바꿔 주세요.

사고 4: Multi-threaded · Partitioned Step 에서 JobScope bean 오작동

원인 — Spring Batch 가 해당 thread 의 scope 를 제어할 수 없기 때문입니다.

해결 — Multi-threaded · Partitioned 환경에서는 JobScope bean 을 피하고 StepScope 를 권장합니다 (각 partition 이 독립 Step 이라서).

사고 5: SpEL quote 빠짐

원인#{jobParameters[input.file]} 같이 quote 가 빠진 경우.

해결#{jobParameters['input.file']} 처럼 quote 를 꼭 넣어 주세요.

사고 6: @Value 가 작동 안 함

원인 — 메서드가 Bean 이 아니라 내부 호출로 불린 경우입니다.

해결@Value@Bean 메서드 인자에서만 동작합니다. 내부 헬퍼라면 ApplicationContext 에서 명시적으로 조회해야 해요.

권장 패턴

Pattern 1: 표준 StepScope reader

@Bean
@StepScope
public FlatFileItemReader<Foo> reader(
        @Value("#{jobParameters['input.file']}") Resource resource) {
    return new FlatFileItemReaderBuilder<Foo>()
        .name("fooReader")
        .resource(resource)
        .delimited()
        .names("col1", "col2")
        .targetType(Foo.class)
        .build();
}

가장 흔한 입문 패턴이에요.

Pattern 2: JobParameters Validator 와 결합

@Bean
public JobParametersValidator validator() {
    DefaultJobParametersValidator v = new DefaultJobParametersValidator();
    v.setRequiredKeys(new String[]{"input.file", "run.date"});
    return v;
}

@Bean
public Job myJob(JobRepository repo, Step step1, JobParametersValidator validator) {
    return new JobBuilder("myJob", repo)
        .validator(validator)
        .start(step1)
        .build();
}

필수 parameter 가 누락되면 fail-fast 로 떨어집니다.

Pattern 3: StepExecutionContext partition 범위

@Bean
@StepScope
public JdbcCursorItemReader<Foo> partitionReader(
        DataSource dataSource,
        @Value("#{stepExecutionContext['startId']}") Long startId,
        @Value("#{stepExecutionContext['endId']}") Long endId) {
    return new JdbcCursorItemReaderBuilder<Foo>()
        .name("partitionReader")
        .dataSource(dataSource)
        .sql("SELECT * FROM foo WHERE id BETWEEN ? AND ?")
        .queryArguments(startId, endId)
        .rowMapper(new BeanPropertyRowMapper<>(Foo.class))
        .build();
}

37편 Partitioning 의 핵심으로, 각 partition Step 에 범위를 주입하는 방식이에요.

Pattern 4: ExecutionContext 기반 incremental load

@Bean
@StepScope
public JdbcCursorItemReader<Foo> incrementalReader(
        DataSource dataSource,
        @Value("#{jobExecutionContext['lastProcessedId'] ?: 0L}") Long lastId) {
    return new JdbcCursorItemReaderBuilder<Foo>()
        .name("incrementalReader")
        .dataSource(dataSource)
        .sql("SELECT * FROM foo WHERE id > ? ORDER BY id")
        .queryArguments(lastId)
        .rowMapper(new BeanPropertyRowMapper<>(Foo.class))
        .build();
}

?: Elvis 연산자는 null 일 때 default 값을 주는 문법이라, 첫 실행에서는 0L 이 됩니다.

시험 직전 한 번 더 — Late Binding 함정 압축 노트

  • Late Binding = Bean 생성을 Step·Job 시작 시점까지 지연 → JobParameters·ExecutionContext 주입 가능
  • @StepScope = Step 시작마다 새 인스턴스 (가장 흔한 패턴)
  • @JobScope = Job 시작마다 새 인스턴스 (Spring Batch 3.0+)
  • 등록 — @EnableBatchProcessing 자동 또는 StepScope·JobScope bean 명시
  • SpEL source 3종 = jobParameters · jobExecutionContext · stepExecutionContext
  • jobParameters = Job launch 시 입력
  • jobExecutionContext = Job 전체 공유 (Step 간 데이터 전달)
  • stepExecutionContext = 현재 Step (partition 범위 등)
  • SpEL map key = quote 필수 (['key'])
  • 타입 자동 변환 — String·long·double·Date·LocalDate·Resource
  • Step bean 은 step-scoped 금지 — Step 안 컴포넌트만
  • Step 자체가 동적 설정 필요 = JobScope
  • ItemStream proxy 함정@StepScope Bean 반환 타입을 ItemReader 같은 상위 인터페이스로 하면 ItemStream 메서드 호출 안 됨
  • 반환 타입을 가장 구체적인 타입 (FlatFileItemReader 등) 또는 ItemStreamReader 권장
  • 함정 — @StepScope 누락 → JobParameters null
  • 함정 — @StepScope 등록 누락 → IllegalStateException
  • 함정 — Multi-threaded·Partitioned 에서 JobScope bean = 비권장
  • Multi-threaded → StepScope 권장 (각 partition 독립 Step)
  • Elvis 연산자 ?: = null default 값
  • StepScope vs JobScope 차이 = Step 단위 vs Job 단위
  • 패턴 — JobParametersValidator 와 결합한 fail-fast
  • 패턴 — Step 간 ExecutionContext 데이터 전달
  • 패턴 — partition 범위 stepExecutionContext 주입
  • 패턴 — incremental load 의 lastProcessedId

공식 문서: Late Binding of Job and Step Attributes 에서 원문을 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

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

답글 남기기

error: Content is protected !!