Spring Batch 입문 21편. Job·Step 시작 시점에 결정되는 값을 Bean 안에 주입하는 Late Binding. `@StepScope`·`@JobScope`, `#{jobParameters['key']}`·`#{jobExecutionContext['key']}`·`#{stepExecutionContext['key']}` SpEL, ItemStream proxy 함정까지 정리한 학습 노트.
이 글은 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·JobScopebean 명시 - 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 함정 —
@StepScopeBean 반환 타입을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 에서 원문을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 16편 — Transaction Attributes (Isolation · Propagation · Timeout)
- 17편 — ItemStream 등록 (재시작 안전성의 핵심)
- 18편 — Step 라이프사이클 Listener 종합
- 19편 — TaskletStep (단발 작업의 정석)
- 20편 — Flow Control · Decision · Split · 조건 분기
다음 글: