Backend
home
⚙️

배치 처리 완전 정복 — Spring Batch 개념부터 성능 최적화까지

생성 일시
2026/04/28 13:35
태그
Java
SpringBoot
게시일
2026/04/28
최종 편집 일시
2026/04/28 13:35
GitHub 링크

1. 배치(Batch)란?

배치 처리는 대량의 데이터를 일괄적으로 처리하는 방식. 실시간 요청/응답 방식(온라인 처리)과 달리, 정해진 시간에 묶어서 처리함.

온라인 처리 vs 배치 처리

구분
온라인 처리
배치 처리
처리 시점
요청 즉시
예약된 시간(야간, 월말 등)
데이터 규모
소량 (건당)
대량 (수만~수억 건)
응답 속도
빠름 (ms~s)
상관없음 (분~시간)
사용자 상호작용
있음
없음 (무인 처리)
실패 처리
즉각 에러 반환
재시도, 스킵, 롤백 전략 필요

배치가 필요한 실무 시나리오

정산 처리: 매일 자정 전날 주문/결제 데이터를 집계해 정산 테이블 생성
알림 발송: 자정에 만료 예정 쿠폰 보유 회원 추출 → 푸시/이메일 발송
데이터 마이그레이션: 레거시 DB → 신규 DB 대량 이관
통계 집계: 시간별/일별/월별 통계 테이블 갱신
파일 처리: 외부 시스템에서 수신한 CSV/TXT 파일 파싱 후 DB 적재
데이터 정제: 휴면 회원 전환, 만료 데이터 삭제, 상태 업데이트

2. Spring Batch 핵심 아키텍처

Spring Batch는 엔터프라이즈 수준의 배치 처리를 위한 경량 프레임워크.

핵심 구성 요소

Job: 배치 작업의 최상위 단위. 여러 Step으로 구성.
Step: 실제 처리 단위. Chunk 방식 또는 Tasklet 방식으로 구현.
Chunk 방식: ItemReader → ItemProcessor → ItemWriter 를 chunk 단위로 반복.
Tasklet 방식: 단순한 단일 작업(파일 삭제, 알림 발송 등)에 사용.
JobRepository: 배치 실행 메타데이터(실행 이력, 상태, 파라미터)를 DB에 저장.
JobLauncher: Job을 실행시키는 인터페이스.

3. 기본 구조 — 의존성 및 설정

build.gradle

dependencies { implementation 'org.springframework.boot:spring-boot-starter-batch' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-jdbc' runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.batch:spring-batch-test' }
Groovy
복사

application.yml

spring: batch: job: enabled: false # 앱 시작 시 자동 실행 비활성화 jdbc: initialize-schema: always # 메타 테이블 자동 생성 (운영은 never) datasource: url: jdbc:mysql://localhost:3306/batchdb username: root password: password driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: false
YAML
복사

BatchConfig — 기본 뼈대

@Configuration @EnableBatchProcessing @RequiredArgsConstructor public class BatchConfig { private final JobRepository jobRepository; private final PlatformTransactionManager transactionManager; private final DataSource dataSource; // Job, Step 빈 등록은 이 클래스 또는 별도 JobConfig 클래스에서 관리 }
Java
복사

4. Chunk 기반 처리 — 실전 구현

시나리오: 주문 데이터 일별 정산 처리

하루치 주문을 읽어 → 정산 금액 계산 → 정산 테이블에 저장.

엔티티 정의

@Entity @Table(name = "orders") @Getter @NoArgsConstructor public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long userId; private Long productId; private Integer amount; // 주문 금액 private String status; // COMPLETED, CANCELLED private LocalDate orderDate; } @Entity @Table(name = "settlement") @Getter @Setter @NoArgsConstructor public class Settlement { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long userId; private Integer totalAmount; // 정산 금액 private LocalDate settlementDate; private LocalDateTime createdAt; }
Java
복사

ItemReader — JpaPagingItemReader

@Configuration @RequiredArgsConstructor public class OrderSettlementJobConfig { private final JobRepository jobRepository; private final PlatformTransactionManager transactionManager; private final EntityManagerFactory entityManagerFactory; private final SettlementRepository settlementRepository; private static final int CHUNK_SIZE = 1000; @Bean public Job orderSettlementJob() { return new JobBuilder("orderSettlementJob", jobRepository) .start(orderSettlementStep()) .build(); } @Bean public Step orderSettlementStep() { return new StepBuilder("orderSettlementStep", jobRepository) .<Order, Settlement>chunk(CHUNK_SIZE, transactionManager) .reader(orderItemReader(null)) .processor(orderItemProcessor()) .writer(settlementItemWriter()) .faultTolerant() .skip(Exception.class) .skipLimit(10) // 10건까지 스킵 허용 .retry(DataAccessException.class) .retryLimit(3) // DB 오류 시 3회 재시도 .build(); } // JobParameter로 날짜 받기 @Bean @StepScope public JpaPagingItemReader<Order> orderItemReader( @Value("#{jobParameters['targetDate']}") String targetDate) { LocalDate date = LocalDate.parse(targetDate); Map<String, Object> params = new HashMap<>(); params.put("orderDate", date); params.put("status", "COMPLETED"); return new JpaPagingItemReaderBuilder<Order>() .name("orderItemReader") .entityManagerFactory(entityManagerFactory) .pageSize(CHUNK_SIZE) .queryString( "SELECT o FROM Order o " + "WHERE o.orderDate = :orderDate " + "AND o.status = :status " + "ORDER BY o.id ASC" ) .parameterValues(params) .build(); } @Bean public ItemProcessor<Order, Settlement> orderItemProcessor() { return order -> { // null 반환 시 해당 아이템은 Writer에 전달되지 않음 (필터링) if (order.getAmount() <= 0) return null; Settlement settlement = new Settlement(); settlement.setUserId(order.getUserId()); settlement.setTotalAmount(order.getAmount()); settlement.setSettlementDate(order.getOrderDate()); settlement.setCreatedAt(LocalDateTime.now()); return settlement; }; } @Bean public ItemWriter<Settlement> settlementItemWriter() { return items -> settlementRepository.saveAll(items); } }
Java
복사

JobLauncher — 스케줄러로 실행

@Component @RequiredArgsConstructor @Slf4j public class SettlementScheduler { private final JobLauncher jobLauncher; private final Job orderSettlementJob; // 매일 새벽 1시 실행 @Scheduled(cron = "0 0 1 * * *") public void runSettlementJob() { String targetDate = LocalDate.now().minusDays(1).toString(); try { JobParameters params = new JobParametersBuilder() .addString("targetDate", targetDate) .addLong("timestamp", System.currentTimeMillis()) // 재실행 보장 .toJobParameters(); JobExecution execution = jobLauncher.run(orderSettlementJob, params); log.info("[Settlement] Job 완료 | status={} | date={}", execution.getStatus(), targetDate); } catch (Exception e) { log.error("[Settlement] Job 실행 실패 | date={}", targetDate, e); } } }
Java
복사

5. Tasklet 방식 — 단순 작업 처리

청크 기반이 맞지 않는 단순 작업(파일 삭제, 집계 후 플래그 업데이트 등)에 사용.
@Component @RequiredArgsConstructor @Slf4j public class CleanupTasklet implements Tasklet { private final OrderRepository orderRepository; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { // 90일 이상 된 취소 주문 삭제 LocalDate cutoff = LocalDate.now().minusDays(90); int deleted = orderRepository.deleteByStatusAndOrderDateBefore("CANCELLED", cutoff); log.info("[Cleanup] 취소 주문 {}건 삭제 완료", deleted); // FINISHED: 한 번 실행 후 종료 // CONTINUABLE: 계속 반복 실행 (페이징 처리 시 사용) return RepeatStatus.FINISHED; } } // Step 등록 @Bean public Step cleanupStep() { return new StepBuilder("cleanupStep", jobRepository) .tasklet(cleanupTasklet, transactionManager) .build(); }
Java
복사

6. 멀티 Step Job — Step 조합과 흐름 제어

실무에서는 여러 Step을 조합해 복잡한 워크플로를 구성.
@Bean public Job monthlyReportJob() { return new JobBuilder("monthlyReportJob", jobRepository) // Step 1: 원시 데이터 집계 .start(aggregateDataStep()) // Step 2: 집계 결과로 리포트 생성 .next(generateReportStep()) // Step 3: 생성된 리포트 이메일 발송 .next(sendReportStep()) // Step 3 성공 시 완료 .on("COMPLETED").end() // Step 3 실패 시 → 실패 알림 Step 실행 .from(sendReportStep()).on("FAILED").to(notifyFailureStep()) .end() .build(); }
Java
복사

조건부 흐름 — ExitStatus 활용

@Bean public Step aggregateDataStep() { return new StepBuilder("aggregateDataStep", jobRepository) .tasklet((contribution, chunkContext) -> { long count = orderRepository.countByOrderDate(LocalDate.now().minusDays(1)); if (count == 0) { // 처리할 데이터 없음 → 커스텀 ExitStatus contribution.setExitStatus(new ExitStatus("NO_DATA")); } else { contribution.setExitStatus(ExitStatus.COMPLETED); } return RepeatStatus.FINISHED; }, transactionManager) .build(); } @Bean public Job conditionalJob() { return new JobBuilder("conditionalJob", jobRepository) .start(aggregateDataStep()) .on("NO_DATA").end() // 데이터 없으면 즉시 종료 .on("COMPLETED").to(generateReportStep()) // 있으면 리포트 생성 .end() .build(); }
Java
복사

7. 성능 최적화 전략

7-1. Chunk Size 튜닝

Chunk Size는 한 트랜잭션 안에서 처리하는 건수. 너무 작으면 트랜잭션 오버헤드, 너무 크면 메모리 부족 및 롤백 비용 증가.
// 일반적인 기준점 // - 단순 읽기/쓰기: 500 ~ 2000 // - 복잡한 변환 로직 포함: 100 ~ 500 // - 대용량 필드(CLOB/BLOB 포함): 50 ~ 200 private static final int CHUNK_SIZE = 1000; // 실제 튜닝은 처리 시간 측정 후 결정 // Step 실행 시간 로그: StepExecutionListener 활용 @Bean public StepExecutionListener stepTimingListener() { return new StepExecutionListenerSupport() { @Override public void beforeStep(StepExecution stepExecution) { log.info("[Step 시작] {}", stepExecution.getStepName()); } @Override public ExitStatus afterStep(StepExecution stepExecution) { log.info("[Step 완료] {} | 읽기={} | 쓰기={} | 스킵={} | 소요={}ms", stepExecution.getStepName(), stepExecution.getReadCount(), stepExecution.getWriteCount(), stepExecution.getSkipCount(), stepExecution.getEndTime().getTime() - stepExecution.getStartTime().getTime()); return stepExecution.getExitStatus(); } }; }
Java
복사

7-2. Parallel Step — 독립 Step 병렬 실행

서로 의존성이 없는 Step은 병렬로 실행해 전체 처리 시간을 단축.
@Bean public Job parallelStepsJob() { // Flow 1: 사용자 정산 Flow userSettlementFlow = new FlowBuilder<Flow>("userSettlementFlow") .start(userSettlementStep()) .build(); // Flow 2: 상품 통계 Flow productStatsFlow = new FlowBuilder<Flow>("productStatsFlow") .start(productStatsStep()) .build(); // 두 Flow를 병렬 실행 Flow parallelFlow = new FlowBuilder<Flow>("parallelFlow") .split(new SimpleAsyncTaskExecutor()) // 병렬 실행 Executor .add(userSettlementFlow, productStatsFlow) .build(); return new JobBuilder("parallelStepsJob", jobRepository) .start(parallelFlow) .next(finalizeStep()) // 병렬 완료 후 최종 처리 .end() .build(); }
Java
복사

7-3. Multi-threaded Step — 청크 병렬 처리

하나의 Step 안에서 청크를 멀티스레드로 처리. 처리량을 N배 향상 가능.
@Bean public Step multiThreadedStep() { return new StepBuilder("multiThreadedStep", jobRepository) .<Order, Settlement>chunk(CHUNK_SIZE, transactionManager) .reader(orderItemReader(null)) .processor(orderItemProcessor()) .writer(settlementItemWriter()) .taskExecutor(taskExecutor()) // 멀티스레드 적용 .throttleLimit(4) // 동시 스레드 수 제한 .build(); } @Bean public TaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(25); executor.setThreadNamePrefix("batch-thread-"); executor.initialize(); return executor; }
Java
복사
주의: Multi-threaded Step은 ItemReader가 thread-safe해야 함. JpaPagingItemReader는 thread-safe하지 않으므로 SynchronizedItemStreamReader로 감싸거나, JdbcPagingItemReader 사용을 권장.
// thread-safe 래핑 @Bean @StepScope public SynchronizedItemStreamReader<Order> synchronizedOrderReader( @Value("#{jobParameters['targetDate']}") String targetDate) { return new SynchronizedItemStreamReaderBuilder<Order>() .delegate(orderItemReader(targetDate)) .build(); }
Java
복사

7-4. Partitioning — 데이터 분할 병렬 처리

전체 데이터를 여러 파티션으로 나눠 각 파티션을 독립된 Step에서 처리. 수억 건 처리에 적합.
// Partitioner: 데이터를 어떻게 나눌지 정의 @Component public class OrderPartitioner implements Partitioner { private final OrderRepository orderRepository; @Override public Map<String, ExecutionContext> partition(int gridSize) { Long minId = orderRepository.findMinId(); Long maxId = orderRepository.findMaxId(); long range = (maxId - minId) / gridSize + 1; Map<String, ExecutionContext> result = new HashMap<>(); for (int i = 0; i < gridSize; i++) { long startId = minId + (range * i); long endId = startId + range - 1; ExecutionContext context = new ExecutionContext(); context.putLong("startId", startId); context.putLong("endId", Math.min(endId, maxId)); result.put("partition-" + i, context); } return result; } } // Manager Step: 파티션을 조율하는 상위 Step @Bean public Step partitionManagerStep() { return new StepBuilder("partitionManagerStep", jobRepository) .partitioner("workerStep", orderPartitioner) .step(workerStep()) // 각 파티션이 실행할 Step .gridSize(8) // 파티션 수 .taskExecutor(taskExecutor()) .build(); } // Worker Step: 실제 처리 @Bean public Step workerStep() { return new StepBuilder("workerStep", jobRepository) .<Order, Settlement>chunk(CHUNK_SIZE, transactionManager) .reader(partitionedOrderReader(null, null)) .processor(orderItemProcessor()) .writer(settlementItemWriter()) .build(); } // 파티션 파라미터로 범위 제한 @Bean @StepScope public JdbcPagingItemReader<Order> partitionedOrderReader( @Value("#{stepExecutionContext['startId']}") Long startId, @Value("#{stepExecutionContext['endId']}") Long endId) { Map<String, Object> params = new HashMap<>(); params.put("startId", startId); params.put("endId", endId); SqlPagingQueryProviderFactoryBean queryProvider = new SqlPagingQueryProviderFactoryBean(); queryProvider.setSelectClause("SELECT id, user_id, product_id, amount, order_date"); queryProvider.setFromClause("FROM orders"); queryProvider.setWhereClause("WHERE id BETWEEN :startId AND :endId AND status = 'COMPLETED'"); queryProvider.setSortKey("id"); try { return new JdbcPagingItemReaderBuilder<Order>() .name("partitionedOrderReader") .dataSource(dataSource) .queryProvider(queryProvider.getObject()) .parameterValues(params) .pageSize(CHUNK_SIZE) .rowMapper(new BeanPropertyRowMapper<>(Order.class)) .build(); } catch (Exception e) { throw new RuntimeException(e); } }
Java
복사

7-5. JdbcBatchItemWriter — 대량 Insert 최적화

JPA saveAll() 대신 JDBC 벌크 Insert를 사용해 성능을 극적으로 개선.
@Bean public JdbcBatchItemWriter<Settlement> jdbcBatchSettlementWriter() { return new JdbcBatchItemWriterBuilder<Settlement>() .dataSource(dataSource) .sql( "INSERT INTO settlement (user_id, total_amount, settlement_date, created_at) " + "VALUES (:userId, :totalAmount, :settlementDate, :createdAt)" ) .beanMapped() // Settlement 필드명과 :파라미터 자동 매핑 .build(); } // MySQL rewriteBatchedStatements=true 설정 시 실제 배치 Insert 활성화 // url: jdbc:mysql://host/db?rewriteBatchedStatements=true
Java
복사
성능 비교 (100만 건 기준)
방식
소요 시간
JPA save() 건별
약 35분
JPA saveAll()
약 12분
JdbcBatchItemWriter
약 3분
JdbcBatchItemWriterrewriteBatchedStatements
약 1분

8. 오류 처리 전략 — Skip & Retry

@Bean public Step faultTolerantStep() { return new StepBuilder("faultTolerantStep", jobRepository) .<Order, Settlement>chunk(CHUNK_SIZE, transactionManager) .reader(orderItemReader(null)) .processor(orderItemProcessor()) .writer(settlementItemWriter()) .faultTolerant() // Skip: 특정 예외 발생 시 해당 아이템을 건너뜀 .skip(InvalidDataException.class) // 데이터 검증 실패 .skip(NullPointerException.class) .skipLimit(100) // 최대 100건까지만 스킵 (초과 시 Job 실패) // Retry: 일시적 오류 시 재시도 .retry(DataAccessException.class) // DB 커넥션 오류 등 .retryLimit(3) // 3회 재시도 // NoSkip: 절대 스킵하면 안 되는 예외 .noSkip(OutOfMemoryError.class) // 스킵된 아이템 로깅 .listener(skipListener()) .build(); } @Bean public SkipListener<Order, Settlement> skipListener() { return new SkipListenerSupport<>() { @Override public void onSkipInProcess(Order item, Throwable t) { log.warn("[Skip] 처리 중 스킵 | orderId={} | error={}", item.getId(), t.getMessage()); } @Override public void onSkipInWrite(Settlement item, Throwable t) { log.warn("[Skip] 저장 중 스킵 | userId={} | error={}", item.getUserId(), t.getMessage()); } }; }
Java
복사

9. JobExecutionListener — 전처리 / 후처리

@Component @RequiredArgsConstructor @Slf4j public class SettlementJobListener implements JobExecutionListener { private final SlackNotifier slackNotifier; @Override public void beforeJob(JobExecution jobExecution) { log.info("[Job 시작] {} | params={}", jobExecution.getJobInstance().getJobName(), jobExecution.getJobParameters()); } @Override public void afterJob(JobExecution jobExecution) { BatchStatus status = jobExecution.getStatus(); long readCount = jobExecution.getStepExecutions().stream() .mapToLong(StepExecution::getReadCount).sum(); long writeCount = jobExecution.getStepExecutions().stream() .mapToLong(StepExecution::getWriteCount).sum(); if (status == BatchStatus.COMPLETED) { log.info("[Job 완료] 읽기={} 쓰기={}", readCount, writeCount); } else if (status == BatchStatus.FAILED) { log.error("[Job 실패] status={}", status); // Slack 알림 발송 slackNotifier.sendAlert("정산 배치 실패!"); } } } // Job에 리스너 등록 @Bean public Job orderSettlementJob() { return new JobBuilder("orderSettlementJob", jobRepository) .listener(settlementJobListener) .start(orderSettlementStep()) .build(); }
Java
복사

10. ItemReader 선택 가이드

Reader
적합한 케이스
thread-safe
특징
JpaPagingItemReader
JPA 엔티티 기반 처리
JPQL 사용, 커서 없음
JdbcPagingItemReader
대용량, 멀티스레드
순수 JDBC, 빠름
JdbcCursorItemReader
단일스레드 대용량
커서 방식, 메모리 효율적
FlatFileItemReader
CSV/TXT 파일 읽기
구분자 기반 파싱
StaxEventItemReader
XML 파일 읽기
SAX 방식 스트리밍
MongoItemReader
MongoDB 컬렉션 읽기
Spring Data MongoDB

FlatFileItemReader — CSV 파일 처리 예시

@Bean @StepScope public FlatFileItemReader<OrderCsv> csvOrderReader( @Value("#{jobParameters['filePath']}") String filePath) { return new FlatFileItemReaderBuilder<OrderCsv>() .name("csvOrderReader") .resource(new FileSystemResource(filePath)) .linesToSkip(1) // 헤더 스킵 .delimited() .delimiter(",") .names("userId", "productId", "amount", "orderDate") .targetType(OrderCsv.class) .build(); }
Java
복사

11. 실무 운영 패턴

중복 실행 방지

// JobParameters에 날짜를 포함시켜 같은 날짜는 재실행 안 됨 // 단, FAILED 상태의 Job은 재시작 가능 (timestamp 없이) JobParameters params = new JobParametersBuilder() .addString("targetDate", "2026-04-28") // timestamp 제거 → 동일 파라미터로 완료된 Job은 재실행 불가 .toJobParameters();
Java
복사

재시작 제어

@Bean public Job nonRestartableJob() { return new JobBuilder("nonRestartableJob", jobRepository) .preventRestart() // 실패해도 재시작 불가 .start(step1()) .build(); }
Java
복사

배치 메타 테이블 관리

-- Spring Batch 핵심 메타 테이블 -- BATCH_JOB_INSTANCE : Job 인스턴스 목록 -- BATCH_JOB_EXECUTION : Job 실행 이력 (상태, 시작/종료 시간) -- BATCH_STEP_EXECUTION : Step 실행 이력 (읽기/쓰기/스킵 건수) -- BATCH_JOB_EXECUTION_PARAMS : Job 실행 시 파라미터 -- 실행 이력 조회 SELECT ji.JOB_NAME, je.START_TIME, je.END_TIME, je.STATUS, je.EXIT_CODE, TIMESTAMPDIFF(SECOND, je.START_TIME, je.END_TIME) AS duration_sec FROM BATCH_JOB_EXECUTION je JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID ORDER BY je.START_TIME DESC LIMIT 20; -- 오래된 이력 정리 (90일 이상) DELETE FROM BATCH_JOB_EXECUTION WHERE START_TIME < DATE_SUB(NOW(), INTERVAL 90 DAY) AND STATUS IN ('COMPLETED', 'FAILED');
SQL
복사

12. 성능 체크리스트

Reader 최적화
@StepScope로 지연 초기화 → JobParameter 주입 가능
Paging Reader 사용 시 정렬 기준 컬럼에 인덱스 필수
멀티스레드 환경에서는 JdbcPagingItemReader 또는 SynchronizedItemStreamReader 사용
Writer 최적화
JPA 대신 JdbcBatchItemWriter 사용
MySQL은 rewriteBatchedStatements=true JDBC URL 옵션 추가
대량 Insert 전 인덱스 비활성화 고려 (운영 환경 주의)
트랜잭션 최적화
Chunk Size를 크게 잡아 트랜잭션 오버헤드 감소
읽기 전용 처리는 @Transactional(readOnly = true) 적용
불필요한 영속성 컨텍스트 초기화: EntityManager.clear() 주기적 호출
병렬 처리 선택 기준
데이터 규모
전략
수만 건
단일 스레드 Chunk
수십만 건
Multi-threaded Step
수백만 건
Partitioning
수억 건
Partitioning + 원격 분산 처리

정리

배치는 "한 번에 많이 처리"가 핵심이지만, 실무에서는 언제 실패할지 모른다는 전제로 설계해야 함.
청크 단위 트랜잭션 → 장애 범위 최소화
Skip / Retry → 유연한 오류 허용
Partitioning → 수평적 확장
JobExecutionListener → 운영 가시성 확보
메타 테이블 → 실행 이력 추적 및 재시작 보장
Spring Batch는 이 모든 걸 프레임워크 레벨에서 지원하기 때문에, 비즈니스 로직에만 집중하면서도 엔터프라이즈급 안정성을 확보할 수 있음.