스프링 배치를 실행하기 위해서는 job이라는 설계도를 먼저 만들고 내부를 step이라는 task로 분할하여 구성해야 합니다. 그리고 나서 해당 job을 필요한 시간이나 특정 조건에 맞춰 실행되게하는 trigger를 설정하여 실행되도록합니다. 이때, job-step 계층(scope)에 따라서 @JobScope, @StepScope 를 사용하여 빈의 라이프사이클을 조절할 수 있습니다.
스프링 배치는 일반적인 웹 애플리케이션(Spring MVC)과 달리 요청-응답 스레드가 존재하지 않으며, 특정 트리거에 의해 구동되는 동적 파이프라인 구조를 가집니다.
핵심은 지연 바인딩(Late Binding)과 Chunk 지향 처리를 통해 대용량 데이터 처리 시 발생할 수 있는 메모리(OOM) 이슈와 트랜잭션 오버헤드를 제어하는 것입니다.
일반적인 스프링 싱글톤 빈은 애플리케이션 구동(Context Refresh) 시점에 생성되지만, 배치의 핵심 컴포넌트들은 배치가 실제로 구동되는 시점(런타임)에 필요한 파라미터를 받아 동적으로 빈을 생성하는 지연 바인딩(Late Binding) 전략을 사용합니다. 이 영역을 제한하는 것이 @JobScope와 @StepScope입니다.
#{jobParameters}를 주입받을 수 있습니다. (Step 선언 메서드에 주로 사용)#{jobParameters} 뿐만 아니라 이전 Step에서 저장한 #{stepExecutionContext} 데이터까지 스코프 내에서 안전하게 주입(late injection)받을 수 있습니다. (Reader, Processor, Writer에 필수적으로 사용)+------------------------------------------------------------------------------------+
| Application Context (애플리케이션 구동 시 Singleton 빈 생성 및 상주) |
| |
| +------------------------------------------------------------------------------+
| | @JobScope (Job이 실행되는 시점에 빈 생성 / Job 종료 시 소멸) |
| | - JobContext 영역 |
| | - 이 범위 내에서 #{jobParameters['key']} 접근 가능 |
| | |
| | +------------------------------------------------------------------------+
| | | @StepScope (Step이 실행되는 시점에 빈 생성 / Step 종료 시 소멸) |
| | | - StepContext 영역 |
| | | - 이 범위 내에서 #{jobParameters['key']} 접근 가능 |
| | | - 이 범위 내에서 #{jobExecutionContext['key']} 접근 가능 |
| | | - 이 범위 내에서 #{stepExecutionContext['key']} 접근 가능 |
| | | |
| | | * 예시: ItemReader, ItemProcessor, ItemWriter 빈 등록 |
| | +------------------------------------------------------------------------+
| +------------------------------------------------------------------------------+
+------------------------------------------------------------------------------------+일반적인 스프링 빈은 애플리케이션 컴파일 및 구동 시점에 실행 매개변수를 주입받을 수 없습니다. 하지만 @StepScope를 명시하면 배치가 구동되어 해당 Step이 실행되는 시점(런타임)에 외부에서 유입된 JobParameters나 이전 단계에서 ExecutionContext에 임시 저장해 둔 동적 변수값들을 쏙 빼와서 빈의 생성자나 필드에 안전하게 바인딩(Late Binding)할 수 있게 됩니다.
이는 멀티스레드 환경에서 각 스레드가 자신만의 배치 컴포넌트 인스턴스를 가지므로 **상태가 꼬이지 않도록 격리(Thread-safe)**해 주며, 매개변수에 따른 동적 빈 구성을 통해 배치 컴포넌트의 재사용성을 극대화하는 역할을 합니다.
스프링 배치의 실제 런타임 실행 순서는 외부 트리거부터 시작하여 최하단 데이터 처리 컴포넌트까지 순차적으로 내려갑니다. 배치 프로세스가 기동되어 종료될 때까지 데이터가 하향식으로 흐르는 동적 파이프라인 구조입니다.
BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION)을 조회하여 중복 실행 여부를 검증하고 현재 상태를 STARTING으로 기록합니다.BATCH_STEP_EXECUTION)에 물리적인 실행 기록이 커밋됩니다. 작업 성격에 따라 단발성 태스크인 Tasklet 방식 또는 대용량 처리를 위한 Chunk 방식으로 분기됩니다.Chunk 지향 Step 내부에서는 Read -> Process -> Write 루프가 트랜잭션 범위 내에서 반복 수행됩니다.
[Step Transaction 시작]
│
├─── 루프 (Commit Interval 크기만큼 반복)
│ ├── 1. ItemReader.read() ────> [데이터 1건 리턴]
│ └── 2. ItemProcessor.process() ──> [비즈니스 가공/필터링]
│
├─── Chunk Size만큼 List 데이터가 모이면 루프 탈출
│
├─── 3. ItemWriter.write(List) ──> [Bulk Insert/Update 일괄 반영]
│
[Step Transaction 커밋 및 메타데이터 업데이트]List<T> 형태로 한 번에 전달받습니다. 영속성 컨텍스트의 쓰기 지연 기술이나 데이터베이스의 Bulk Insert/Update를 활용하여 I/O 네트워크 비용을 최소화하고 일괄 저장을 수행한 뒤, 부모 트랜잭션을 최종 COMMIT합니다. (CursorWriter나 PageWriter 등이 사용됩니다.)package com.example.batch.job;
import com.example.batch.domain.User;
import com.example.batch.domain.ProcessedUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.JobScope;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.database.JdbcBatchItemWriter;
import org.springframework.batch.item.database.JdbcPagingItemReader;
import org.springframework.batch.item.database.Order;
import org.springframework.batch.item.database.support.SqlPagingQueryProviderFactoryBean;
import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Configuration
@RequiredArgsConstructor
public class UserProcessingJobConfig {
private final DataSource dataSource;
private static final int CHUNK_SIZE = 100; // Chunk Size와 Fetch/Page Size를 일치시킴
// [1] Job 빌드: 인프라의 중심인 JobRepository를 주입합니다.
@Bean
public Job userProcessingJob(JobRepository jobRepository, Step userProcessingStep) {
return new JobBuilder("userProcessingJob", jobRepository)
.start(userProcessingStep)
.build();
}
// [2] Step 빌드: @JobScope를 통해 Job 실행 시점에 할당됩니다.
// 트랜잭션 매니저와 Chunk Size(Commit Interval)를 여기서 설정합니다.
@Bean
@JobScope
public Step userProcessingStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager,
JdbcPagingItemReader<User> userReader,
ItemProcessor<User, ProcessedUser> userProcessor,
JdbcBatchItemWriter<ProcessedUser> userWriter) {
return new StepBuilder("userProcessingStep", jobRepository)
.<User, ProcessedUser>chunk(CHUNK_SIZE, transactionManager) // 트랜잭션 범위 확정
.reader(userReader)
.processor(userProcessor)
.writer(userWriter)
.build();
}
// [3] ItemReader: @StepScope를 사용하여 런타임에 주입된 JobParameter를 Late Binding 합니다.
@Bean
@StepScope
public JdbcPagingItemReader<User> userReader(
@Value("#{jobParameters['requestDate']}") String requestDate) throws Exception {
log.info("--> [Reader 빈 생성] JobParameters로부터 requestDate 수신: {}", requestDate);
JdbcPagingItemReader<User> reader = new JdbcPagingItemReader<>();
reader.setDataSource(dataSource);
reader.setPageSize(CHUNK_SIZE); // 성능 최적화: Chunk Size와 동일하게 설정
reader.setRowMapper(new BeanPropertyRowMapper<>(User.class));
// 쿼리 동적 생성 및 파라미터 매핑
SqlPagingQueryProviderFactoryBean queryProvider = new SqlPagingQueryProviderFactoryBean();
queryProvider.setDataSource(dataSource);
queryProvider.setSelectClause("id, name, status, update_date");
queryProvider.setFromClause("from users");
queryProvider.setWhereClause("where update_date = :requestDate");
Map<String, Object> sortKeys = new HashMap<>();
sortKeys.add("id", Order.ASCENDING);
queryProvider.setSortKeys(sortKeys);
reader.setQueryProvider(queryProvider.getObject());
Map<String, Object> parameterValues = new HashMap<>();
parameterValues.put("requestDate", requestDate);
reader.setParameterValues(parameterValues);
return reader;
}
// [4] ItemProcessor: 데이터 1건씩 가공 및 필터링을 수행합니다.
@Bean
@StepScope
public ItemProcessor<User, ProcessedUser> userProcessor() {
return user -> {
// 비즈니스 필터링 로직: 특정 조건의 유저는 Writer로 넘기지 않고 제외
if ("BLACK_LIST".equals(user.getStatus())) {
log.info("--> [Processor] 블랙리스트 유저 필터링 (Skip): {}", user.getId());
return null; // null을 반환하면 Writer로 전달되지 않습니다.
}
// 데이터 변환 (DTO 가공)
return new ProcessedUser(user.getId(), user.getName(), "PROCESSED");
};
}
// [5] ItemWriter: Chunk 단위(100건)로 모인 List를 받아 Bulk DB 반영을 처리합니다.
@Bean
@StepScope
public JdbcBatchItemWriter<ProcessedUser> userWriter() {
log.info("--> [Writer 빈 생성] Bulk Insert/Update 내장 래퍼 구동");
return new JdbcBatchItemWriterBuilder<ProcessedUser>()
.dataSource(dataSource)
.sql("UPDATE users SET status = :status WHERE id = :id")
.beanMapped()
.build();
}
}위 코드가 컴파일된 후 스케줄러에 의해 실행되면 내부 엔진에서는 다음 순서로 스레드가 런타임 파이프라인을 관통합니다.
requestDate=2026-06-16 파라미터와 함께 배치가 호출되면 JobLauncher가 구동됩니다.BATCH_JOB_INSTANCE에 새로운 로우를 파고, BATCH_JOB_EXECUTION에 현재 이 배치가 STARTING 상태임을 마킹하며 트랜잭션을 실행합니다.userProcessingStep이 시작되면서 @JobScope와 @StepScope가 활성화됩니다.userReader 메서드가 이때 비로소 호출됩니다.@Value("#{jobParameters['requestDate']}")가 트리거되어 DB 메타데이터에 저장되어 있던 2026-06-16 문자열을 정확히 가로채 자바 변수에 바인딩한 뒤 Reader 인스턴스를 메모리에 올립니다.chunk(100, transactionManager) 설정에 의해 비즈니스 DB의 트랜잭션이 시작되고 아래 루프가 작동합니다.read()가 호출될 때마다 버퍼에서 유저 오브젝트를 1건씩 꺼내어 반환합니다.ProcessedUser DTO로 가공하여 청크 메모리 내부 List에 누적합니다.List<ProcessedUser>를 컴포넌트에 통째로 던집니다.JdbcBatchItemWriter는 자바의 addBatch()와 executeBatch()를 사용하여 100건의 UPDATE 쿼리를 네트워크 파이프라인을 통해 DB로 한 번에 날립니다(Bulk 연산).BATCH_STEP_EXECUTION 테이블의 READ_COUNT, WRITE_COUNT, COMMIT_COUNT 컬럼을 실시간으로 UPDATE 쿼리를 날려 정산 기록을 물리적으로 동기화합니다.