← 목록으로
📅 2026.06.16
파트 2. 배치 실행 순서와 Scope 환경 프로세스
SpringBootSpringBatchscopejobstepTechStudy

스프링 배치를 실행하기 위해서는 job이라는 설계도를 먼저 만들고 내부를 step이라는 task로 분할하여 구성해야 합니다. 그리고 나서 해당 job을 필요한 시간이나 특정 조건에 맞춰 실행되게하는 trigger를 설정하여 실행되도록합니다. 이때, job-step 계층(scope)에 따라서 @JobScope, @StepScope 를 사용하여 빈의 라이프사이클을 조절할 수 있습니다.

스프링 배치는 일반적인 웹 애플리케이션(Spring MVC)과 달리 요청-응답 스레드가 존재하지 않으며, 특정 트리거에 의해 구동되는 동적 파이프라인 구조를 가집니다.

핵심은 지연 바인딩(Late Binding)과 Chunk 지향 처리를 통해 대용량 데이터 처리 시 발생할 수 있는 메모리(OOM) 이슈와 트랜잭션 오버헤드를 제어하는 것입니다.


1. 빈 생명주기와 스코프 격리 (@JobScope / @StepScope)

일반적인 스프링 싱글톤 빈은 애플리케이션 구동(Context Refresh) 시점에 생성되지만, 배치의 핵심 컴포넌트들은 배치가 실제로 구동되는 시점(런타임)에 필요한 파라미터를 받아 동적으로 빈을 생성하는 지연 바인딩(Late Binding) 전략을 사용합니다. 이 영역을 제한하는 것이 @JobScope@StepScope입니다.

  • @JobScope
    Job이 실행되는 시점에 빈이 동적으로 생성되며, 해당 스코프 내에서만 #{jobParameters}를 주입받을 수 있습니다. (Step 선언 메서드에 주로 사용)
  • @StepScope
    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)**해 주며, 매개변수에 따른 동적 빈 구성을 통해 배치 컴포넌트의 재사용성을 극대화하는 역할을 합니다.


2. 런타임 실행 파이프라인 (Runtime Architecture)

스프링 배치의 실제 런타임 실행 순서는 외부 트리거부터 시작하여 최하단 데이터 처리 컴포넌트까지 순차적으로 내려갑니다. 배치 프로세스가 기동되어 종료될 때까지 데이터가 하향식으로 흐르는 동적 파이프라인 구조입니다.

[Phase 1: 인프라 구동 및 전처리]

  • Trigger & Scheduler
    설정된 시간(예: Cron 표현식)에 맞춰 트리거가 발생하여 배치를 구동할 시그널을 보냅니다. (외부 스케줄러인 Jenkins, Quartz, Cron 등 활용)
  • JobLauncher
    스프링 컨텍스트에서 해당 배치를 구동할 엔진인 JobLauncher를 깨우고, 외부로부터 실행 인자인 Job과 JobParameters를 주입받아 배치를 가동합니다.
  • JobRepository
    인프라 전반의 데이터 영속성 계층입니다. JobLauncher가 구동되는 즉시 메타데이터 테이블(BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION)을 조회하여 중복 실행 여부를 검증하고 현재 상태를 STARTING으로 기록합니다.

[Phase 2: 비즈니스 오케스트레이션]

  • Job
    전체 배치의 시나리오를 정의하는 최상위 도메인입니다. 등록된 순서에 따라 배정된 Step들을 순차적으로 혹은 성공/실패 여부에 따른 조건별 분기 처리를 조율하며 실행합니다.
  • Step
    Job을 구성하는 개별 단계로, 실제 비즈니스 로직이 수행되는 독립적인 트랜잭션 경계입니다. 하나의 Step이 시작되면 메타데이터 테이블(BATCH_STEP_EXECUTION)에 물리적인 실행 기록이 커밋됩니다. 작업 성격에 따라 단발성 태스크인 Tasklet 방식 또는 대용량 처리를 위한 Chunk 방식으로 분기됩니다.

[Phase 3: 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 커밋 및 메타데이터 업데이트]
  • ItemReader
    데이터 소스(DB, 파일 등)에서 데이터를 1건씩 읽어오는 컴포넌트입니다. 대용량 조회 시 메모리 효율을 위해 대량의 로우를 메모리에 한 번에 올리지 않고, DB 커넥션을 유지하며 스트리밍하는 Cursor 방식(CursorReader)이나 세션 단위로 끊어서 쿼리를 날리는 Paging 방식(PageReader)을 채택합니다.
  • ItemProcessor
    읽어온 데이터를 가공하는 컴포넌트로, Reader가 넘겨준 데이터를 1건씩 전달받아 비즈니스 가공, 유효성 검증, DTO 변환 등을 처리합니다. 만약 특정 데이터를 최종 저장소에서 제외(필터링)하고 싶다면 null을 리턴하여 해당 데이터가 Writer로 넘어가지 않도록 제어합니다.
  • ItemWriter
    (핵심) 가공된 데이터를 최종 저장하는 컴포넌트입니다. 앞선 컴포넌트들과 달리, 설정된 Commit Interval(Chunk Size)만큼 데이터가 쌓였을 때 데이터 유실 없이 List<T> 형태로 한 번에 전달받습니다. 영속성 컨텍스트의 쓰기 지연 기술이나 데이터베이스의 Bulk Insert/Update를 활용하여 I/O 네트워크 비용을 최소화하고 일괄 저장을 수행한 뒤, 부모 트랜잭션을 최종 COMMIT합니다. (CursorWriter나 PageWriter 등이 사용됩니다.)
런타임 실행 파이프라인 예시 코드 및 추적

1. Spring Batch 5.x 표준 구현 코드

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();
    }
}

2. 코드 레벨에서의 실제 런타임 흐름 추적

위 코드가 컴파일된 후 스케줄러에 의해 실행되면 내부 엔진에서는 다음 순서로 스레드가 런타임 파이프라인을 관통합니다.

  • Step 1. 인프라 초기화 및 메타데이터 락 (JobLauncher & JobRepository)
    • 외부에서 requestDate=2026-06-16 파라미터와 함께 배치가 호출되면 JobLauncher가 구동됩니다.
    • JobRepository가 DB를 찔러 BATCH_JOB_INSTANCE에 새로운 로우를 파고, BATCH_JOB_EXECUTION에 현재 이 배치가 STARTING 상태임을 마킹하며 트랜잭션을 실행합니다.
  • Step 2. 스코프 격리와 빈 지연 생성 (Late Binding)
    • userProcessingStep이 시작되면서 @JobScope@StepScope가 활성화됩니다.
    • 애플리케이션 켜질 때는 생성되지 않던 userReader 메서드가 이때 비로소 호출됩니다.
    • @Value("#{jobParameters['requestDate']}")가 트리거되어 DB 메타데이터에 저장되어 있던 2026-06-16 문자열을 정확히 가로채 자바 변수에 바인딩한 뒤 Reader 인스턴스를 메모리에 올립니다.
  • Step 3. Chunk 단위의 트랜잭션 루프 제어
    • chunk(100, transactionManager) 설정에 의해 비즈니스 DB의 트랜잭션이 시작되고 아래 루프가 작동합니다.
    • Reader.read() 호출 (1건씩 100번 반복)
      • 페이징 기술에 의해 최초 1회 100건의 데이터를 DB에서 읽어와 메모리 버퍼에 적재합니다.
      • 이후 read()가 호출될 때마다 버퍼에서 유저 오브젝트를 1건씩 꺼내어 반환합니다.
    • Processor.process(user) 호출 (1건씩 100번 반복)
      • 넘겨받은 유저의 상태를 체크합니다. 블랙리스트 유저라면 null을 뱉어 파이프라인에서 탈락시키고, 정상 유저라면 ProcessedUser DTO로 가공하여 청크 메모리 내부 List에 누적합니다.
    • Writer.write(Chunk) 호출 (100건이 쌓인 순간 1회 호출)
      • 내부 카운터가 100에 도달하면 List<ProcessedUser>를 컴포넌트에 통째로 던집니다.
      • JdbcBatchItemWriter는 자바의 addBatch()executeBatch()를 사용하여 100건의 UPDATE 쿼리를 네트워크 파이프라인을 통해 DB로 한 번에 날립니다(Bulk 연산).
  • Step 4. 커밋 및 메타데이터 실시간 업데이트
    • Writer 작업이 예외 없이 완전히 끝나면 PlatformTransactionManager가 비즈니스 DB 커밋을 완료합니다.
    • 이와 동시에 JobRepository가 독립적인 격리 트랜잭션을 열어 BATCH_STEP_EXECUTION 테이블의 READ_COUNT, WRITE_COUNT, COMMIT_COUNT 컬럼을 실시간으로 UPDATE 쿼리를 날려 정산 기록을 물리적으로 동기화합니다.
    • 모든 데이터 소비가 끝날 때까지 Step 3와 Step 4의 과정이 100건 단위로 무한 반복됩니다.