Spring Batch 완벽 가이드 - Job 구성부터 실전 활용까지

2023-10-26 18:01:33
#Spring#Spring Batch#Batch#Java

배치 처리가 필요한 이유

실시간 처리가 어려운 작업들이 있다.

  • 매일 밤 수백만 건의 정산 처리
  • 월말 대량 리포트 생성
  • 주기적인 데이터 마이그레이션
  • 대용량 파일 처리

이런 작업을 실시간 API로 처리하면 타임아웃도 나고, 서버 부하도 심하다. 그래서 배치 처리가 필요하다.

Spring Batch는 엔터프라이즈급 배치 처리를 위한 프레임워크다. 대용량 데이터 처리, 트랜잭션 관리, 재시작/재처리 기능 등을 제공한다.

Spring Batch 아키텍처

Spring Batch는 3개 계층으로 구성된다.

Spring Batch Architecture

계층 설명
Application 개발자가 작성하는 Job, Step, 비즈니스 로직
Batch Core Job 실행을 위한 핵심 클래스 (JobLauncher, Job, Step)
Batch Infrastructure 공통 Reader, Writer, RetryTemplate 등

핵심 구성요소

Spring Batch의 주요 컴포넌트와 흐름을 보자.

Spring Batch Components

Job

하나의 배치 작업 단위다. 여러 Step으로 구성된다.

Job
├── Step 1 (데이터 추출)
├── Step 2 (데이터 변환)
└── Step 3 (데이터 적재)

Step

Job 내의 독립적인 실행 단위다. 두 가지 방식이 있다.

방식 설명 사용 케이스
Tasklet 단일 작업 수행 파일 삭제, 프로시저 호출
Chunk 데이터를 청크 단위로 처리 대용량 데이터 처리

Chunk 처리 모델

대부분의 배치 처리는 Chunk 모델을 사용한다.

Chunk Processing

처리 흐름:

  1. ItemReader: 데이터를 한 건씩 읽음
  2. ItemProcessor: 읽은 데이터를 가공/변환 (선택)
  3. ItemWriter: chunk-size만큼 모아서 한번에 쓰기
// chunk-size=100이면
// 100건 읽기 → 100건 처리 → 100건 쓰기 (1 트랜잭션)
// 다음 100건 읽기 → ...

이렇게 하면 메모리 효율도 좋고, 트랜잭션 범위도 적절하게 유지된다.

Job 구현 (Spring Batch 5.x)

Spring Batch 5.0부터 JobBuilderFactory, StepBuilderFactory가 deprecated되었다. 이제 JobBuilder, StepBuilder를 직접 사용한다.

기본 Job 설정

@Configuration
@RequiredArgsConstructor
public class SimpleJobConfig {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;

    @Bean
    public Job simpleJob() {
        return new JobBuilder("simpleJob", jobRepository)
            .start(step1())
            .next(step2())
            .build();
    }

    @Bean
    public Step step1() {
        return new StepBuilder("step1", jobRepository)
            .tasklet((contribution, chunkContext) -> {
                System.out.println("Step 1 실행");
                return RepeatStatus.FINISHED;
            }, transactionManager)
            .build();
    }

    @Bean
    public Step step2() {
        return new StepBuilder("step2", jobRepository)
            .tasklet((contribution, chunkContext) -> {
                System.out.println("Step 2 실행");
                return RepeatStatus.FINISHED;
            }, transactionManager)
            .build();
    }
}

Chunk 기반 Step

대용량 데이터 처리에는 Chunk 방식을 사용한다.

@Bean
public Step chunkStep() {
    return new StepBuilder("chunkStep", jobRepository)
        .<User, UserDto>chunk(100, transactionManager)  // chunk-size: 100
        .reader(userReader())
        .processor(userProcessor())
        .writer(userWriter())
        .build();
}

@Bean
public JdbcCursorItemReader<User> userReader() {
    return new JdbcCursorItemReaderBuilder<User>()
        .name("userReader")
        .dataSource(dataSource)
        .sql("SELECT id, name, email FROM users WHERE status = 'ACTIVE'")
        .rowMapper(new BeanPropertyRowMapper<>(User.class))
        .build();
}

@Bean
public ItemProcessor<User, UserDto> userProcessor() {
    return user -> {
        // 비즈니스 로직: User -> UserDto 변환
        return new UserDto(user.getId(), user.getName().toUpperCase());
    };
}

@Bean
public JdbcBatchItemWriter<UserDto> userWriter() {
    return new JdbcBatchItemWriterBuilder<UserDto>()
        .dataSource(dataSource)
        .sql("INSERT INTO user_backup (id, name) VALUES (:id, :name)")
        .beanMapped()
        .build();
}

Job, JobInstance, JobExecution 관계

이 세 가지 개념이 헷갈리기 쉬운데, 명확히 구분해야 한다.

Job Hierarchy

Job

배치 작업의 정의다. "매일 정산 처리"라는 작업을 정의한 것.

JobInstance

Job + JobParameters의 조합이다. 논리적 실행 단위.

Job: DailySettlementJob
  └── JobInstance: DailySettlementJob + {date=2024-01-15}
  └── JobInstance: DailySettlementJob + {date=2024-01-16}
  └── JobInstance: DailySettlementJob + {date=2024-01-17}

같은 Job이라도 파라미터가 다르면 다른 JobInstance다.

JobExecution

JobInstance의 실제 실행. 한 번 실패하고 재시도하면 JobExecution이 2개 생긴다.

JobInstance: DailySettlementJob + {date=2024-01-15}
  └── JobExecution #1: FAILED (오류 발생)
  └── JobExecution #2: COMPLETED (재시도 성공)

핵심 공식

JobInstance = Job + identifying JobParameters

같은 Job + 같은 JobParameters = 같은 JobInstance

이미 COMPLETED된 JobInstance를 다시 실행하면?

JobInstanceAlreadyCompleteException: A job instance already exists
and is complete for parameters={date=2024-01-15}.

동일한 파라미터로 다시 돌리려면 파라미터를 바꾸거나, FAILED 상태여야 한다.

JobParameter

Job 실행 시 전달되는 파라미터다.

파라미터 전달 방법

1. 커맨드라인에서 전달

java -jar batch.jar date=2024-01-15 type=daily

2. 코드에서 생성

JobParameters params = new JobParametersBuilder()
    .addString("date", "2024-01-15")
    .addString("type", "daily")
    .addLong("timestamp", System.currentTimeMillis())  // 매번 다른 값으로 중복 방지
    .toJobParameters();

jobLauncher.run(job, params);

파라미터 사용

@Bean
public Step step1() {
    return new StepBuilder("step1", jobRepository)
        .tasklet((contribution, chunkContext) -> {
            // 방법 1: StepContribution에서
            JobParameters params = contribution.getStepExecution()
                .getJobExecution()
                .getJobParameters();
            String date = params.getString("date");

            // 방법 2: ChunkContext에서 (Map 형태)
            Map<String, Object> paramMap = chunkContext.getStepContext()
                .getJobParameters();

            return RepeatStatus.FINISHED;
        }, transactionManager)
        .build();
}

@Value로 주입 (Late Binding)

@Bean
@StepScope  // 필수! Step 실행 시점에 빈 생성
public Tasklet myTasklet(@Value("#{jobParameters['date']}") String date) {
    return (contribution, chunkContext) -> {
        System.out.println("처리 날짜: " + date);
        return RepeatStatus.FINISHED;
    };
}

@StepScope를 붙여야 Job 실행 시점에 파라미터가 주입된다.

JobLauncher

Job을 실행하는 역할이다.

JobLauncher Sequence

동기 실행 (기본)

@Component
@RequiredArgsConstructor
public class JobRunner implements ApplicationRunner {

    private final JobLauncher jobLauncher;
    private final Job myJob;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        JobParameters params = new JobParametersBuilder()
            .addLong("time", System.currentTimeMillis())
            .toJobParameters();

        // 동기 실행: Job이 끝날 때까지 대기
        JobExecution execution = jobLauncher.run(myJob, params);
        System.out.println("Job 상태: " + execution.getStatus());
    }
}

비동기 실행

@Configuration
public class AsyncJobConfig {

    @Bean
    public JobLauncher asyncJobLauncher(JobRepository jobRepository) {
        TaskExecutorJobLauncher launcher = new TaskExecutorJobLauncher();
        launcher.setJobRepository(jobRepository);
        launcher.setTaskExecutor(new SimpleAsyncTaskExecutor());
        return launcher;
    }
}

비동기로 실행하면 jobLauncher.run()이 바로 리턴된다. 웹 요청으로 배치를 트리거할 때 유용하다.

메타 테이블

Spring Batch는 실행 이력을 DB에 저장한다. 6개의 메타 테이블이 있다.

테이블 설명
BATCH_JOB_INSTANCE JobInstance 정보
BATCH_JOB_EXECUTION JobExecution 정보 (상태, 시작/종료 시간)
BATCH_JOB_EXECUTION_PARAMS JobParameter 정보
BATCH_STEP_EXECUTION StepExecution 정보
BATCH_JOB_EXECUTION_CONTEXT Job 레벨 ExecutionContext
BATCH_STEP_EXECUTION_CONTEXT Step 레벨 ExecutionContext

테이블 생성

Spring Boot에서는 자동 생성 설정이 가능하다.

spring:
  batch:
    jdbc:
      initialize-schema: always  # always, embedded, never
  • always: 항상 스키마 생성
  • embedded: 내장 DB(H2)일 때만 생성
  • never: 생성 안 함 (직접 관리)

운영 환경에서는 never로 설정하고 DDL을 직접 실행하는 게 안전하다.

메타 테이블 확인

-- Job 실행 이력
SELECT * FROM BATCH_JOB_EXECUTION ORDER BY JOB_EXECUTION_ID DESC;

-- 실패한 Job 찾기
SELECT * FROM BATCH_JOB_EXECUTION WHERE STATUS = 'FAILED';

-- 특정 Job의 파라미터 확인
SELECT * FROM BATCH_JOB_EXECUTION_PARAMS
WHERE JOB_EXECUTION_ID = 123;

Flow Job - 조건부 실행

Step 실행 결과에 따라 다음 Step을 분기할 수 있다.

@Bean
public Job flowJob() {
    return new JobBuilder("flowJob", jobRepository)
        .start(step1())
            .on("COMPLETED").to(step2())  // step1 성공 → step2
            .on("FAILED").to(errorStep()) // step1 실패 → errorStep
            .on("*").stop()               // 그 외 → 종료
        .from(step2())
            .on("*").to(step3())          // step2 완료 후 → step3
        .end()
        .build();
}

커스텀 ExitStatus로 더 세밀한 분기도 가능하다.

@Bean
public Step step1() {
    return new StepBuilder("step1", jobRepository)
        .tasklet((contribution, chunkContext) -> {
            // 비즈니스 로직
            if (someCondition) {
                contribution.setExitStatus(new ExitStatus("SKIP"));
            }
            return RepeatStatus.FINISHED;
        }, transactionManager)
        .build();
}

실전 예제: CSV → DB 적재

실무에서 많이 사용하는 패턴이다.

@Configuration
@RequiredArgsConstructor
public class CsvImportJobConfig {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;
    private final DataSource dataSource;

    @Bean
    public Job csvImportJob() {
        return new JobBuilder("csvImportJob", jobRepository)
            .start(csvImportStep())
            .build();
    }

    @Bean
    public Step csvImportStep() {
        return new StepBuilder("csvImportStep", jobRepository)
            .<UserCsv, User>chunk(1000, transactionManager)
            .reader(csvReader())
            .processor(csvProcessor())
            .writer(dbWriter())
            .faultTolerant()
            .skipLimit(10)                    // 최대 10건 스킵
            .skip(FlatFileParseException.class)  // 파싱 에러는 스킵
            .build();
    }

    @Bean
    @StepScope
    public FlatFileItemReader<UserCsv> csvReader() {
        return new FlatFileItemReaderBuilder<UserCsv>()
            .name("csvReader")
            .resource(new ClassPathResource("users.csv"))
            .delimited()
            .names("name", "email", "age")
            .targetType(UserCsv.class)
            .linesToSkip(1)  // 헤더 스킵
            .build();
    }

    @Bean
    public ItemProcessor<UserCsv, User> csvProcessor() {
        return csv -> User.builder()
            .name(csv.getName().trim())
            .email(csv.getEmail().toLowerCase())
            .age(csv.getAge())
            .createdAt(LocalDateTime.now())
            .build();
    }

    @Bean
    public JdbcBatchItemWriter<User> dbWriter() {
        return new JdbcBatchItemWriterBuilder<User>()
            .dataSource(dataSource)
            .sql("INSERT INTO users (name, email, age, created_at) " +
                 "VALUES (:name, :email, :age, :createdAt)")
            .beanMapped()
            .build();
    }
}

자주 묻는 질문

Job이 실패했을 때 어디서부터 재시작되나요?

기본적으로 실패한 Step부터 재시작된다. Spring Batch는 각 Step의 진행 상황을 BATCH_STEP_EXECUTION에 저장한다.

Chunk 기반 Step이라면 마지막으로 커밋된 chunk 다음부터 재시작된다.

같은 Job을 동시에 여러 번 실행할 수 있나요?

같은 JobParameters로는 안 된다. 다른 JobParameters를 사용하면 가능.

// timestamp 추가로 매번 다른 JobInstance 생성
JobParameters params = new JobParametersBuilder()
    .addString("date", "2024-01-15")
    .addLong("run.id", System.currentTimeMillis())  // 구분용
    .toJobParameters();

chunk-size는 어떻게 정하나요?

정답은 없지만, 보통 100~1000 사이에서 시작한다.

  • 너무 작으면: 커밋이 자주 발생해서 느림
  • 너무 크면: 메모리 사용량 증가, 실패 시 재처리 범위 커짐

실제 데이터로 테스트하면서 조정하는 게 좋다.

Reader에서 읽은 데이터를 Step 간에 공유하려면?

ExecutionContext를 사용한다.

// Step 1에서 저장
chunkContext.getStepContext()
    .getStepExecution()
    .getJobExecution()
    .getExecutionContext()
    .put("totalCount", count);

// Step 2에서 읽기
Integer totalCount = (Integer) chunkContext.getStepContext()
    .getStepExecution()
    .getJobExecution()
    .getExecutionContext()
    .get("totalCount");

스케줄링은 어떻게 하나요?

Spring Batch 자체는 스케줄링 기능이 없다. 별도 스케줄러와 연동해야 한다.

1. @Scheduled 사용 (간단)

@Component
@RequiredArgsConstructor
public class BatchScheduler {

    private final JobLauncher jobLauncher;
    private final Job dailyJob;

    @Scheduled(cron = "0 0 2 * * *")  // 매일 새벽 2시
    public void runDailyJob() throws Exception {
        JobParameters params = new JobParametersBuilder()
            .addLong("time", System.currentTimeMillis())
            .toJobParameters();
        jobLauncher.run(dailyJob, params);
    }
}

2. Jenkins, Airflow 같은 외부 스케줄러에서 REST API로 트리거하는 방식도 많이 쓴다.

Cursor Reader vs Paging Reader 차이는?

구분 JdbcCursorItemReader JdbcPagingItemReader
동작 DB 커서로 한 건씩 스트리밍 페이지 단위로 조회
커넥션 전체 처리 동안 유지 페이지마다 새 커넥션
메모리 낮음 페이지 크기만큼
재시작 어려움 쉬움 (페이지 번호 저장)
추천 단순/빠른 처리 대용량/재시작 필요 시

실무에서는 재시작이 중요하므로 PagingItemReader를 더 많이 쓴다.

정리

Spring Batch 핵심 개념:

  • Job: 배치 작업 정의, 여러 Step으로 구성
  • Step: 실제 작업 단위 (Tasklet 또는 Chunk)
  • Chunk: Reader → Processor → Writer, 청크 단위 트랜잭션
  • JobInstance: Job + JobParameters, 논리적 실행 단위
  • JobExecution: JobInstance의 실제 실행, 실패하면 여러 개 가능
  • JobLauncher: Job 실행 담당
  • JobRepository: 메타데이터 저장/관리

참고문헌

프로필 이미지
@chani
바둑, 스타크래프트 등 고전 게임을 좋아하는 내향인 개발자입니다

댓글