백엔드 개발을 하다 보면 이런 작업이 필요해진다. "매일 자정에 지난달 구매 내역을 집계해서 월 정산을 만들어야 한다." "100만 건의 사용자 데이터를 읽어서 등급을 재산정해야 한다." "DB에 쌓인 오래된 로그 데이터를 정기적으로 아카이빙해야 한다."
이런 작업들의 공통점이 있다. 대용량 데이터를 읽어서 처리하고 저장하는 일을 정해진 시간이나 조건에 반복 실행해야 한다는 것이다. 이걸 배치 처리(Batch Processing)라고 하고, Spring에서 이를 위한 표준 프레임워크가 Spring Batch다.
Spring Batch는 단순 반복 작업 실행기가 아니다. 실패한 지점부터 재시작, 청크 단위 트랜잭션, 실행 이력 관리, 병렬 처리 같은 대용량 처리에 필요한 기능을 구조적으로 제공한다.
일반 REST API 방식으로 배치 처리를 구현하면 어떤 문제가 생길까?
// 단순하게 만든 배치 처리 — 문제가 많음
@Scheduled(cron = "0 0 0 * * *")
public void monthlySettlement() {
List<Order> orders = orderRepository.findAllLastMonth(); // 100만 건?
for (Order order : orders) {
settlementService.process(order); // 중간에 실패하면?
}
// 에러 나면 처음부터 다시? 어디서 실패했는지 알 수 있나?
}
Spring Batch는 이 문제들을 구조적으로 해결하는 프레임워크다. 청크 단위로 데이터를 읽어서 처리하고, 각 청크를 하나의 트랜잭션으로 묶어서 실패 시 해당 청크만 롤백하고 재시작이 가능하게 한다.
"100만 명의 사용자를 읽어서 등급을 재산정하는 배치"가 어떻게 실행되는지 보자.
[JobLauncher]
|
| Job 실행 요청 (스케줄러 또는 수동)
↓
[Job: "userGradeUpdateJob"]
|
| JobRepository에 실행 시작 기록 저장
| Job Parameters로 이번 실행 구분 (예: executionDate=2025-01-01)
↓
[Step 1: "gradeCalculationStep"]
|
| ┌─────────── Chunk 처리 반복 ─────────────┐
| │ │
| │ [ItemReader] │
| │ DB에서 사용자 100명씩 읽기 (chunkSize=100) │
| │ ↓ │
| │ [ItemProcessor] │
| │ 각 사용자의 구매 이력 집계 → 등급 계산 │
| │ ↓ │
| │ [ItemWriter] │
| │ 100명분 등급 업데이트 → DB 저장 │
| │ 트랜잭션 커밋 (100건 단위) │
| │ │
| │ → 실패 시 이 100건만 롤백, 재시작 가능 │
| └──────────────────────────────────────────┘
| (1만 번 반복 = 100만 건 처리)
↓
[Step 2: "notificationStep"]
|
| 등급이 변경된 사용자에게 알림 발송
↓
[JobRepository]
|
| 실행 완료 기록 저장
| (실행 시간, 처리 건수, 성공/실패 상태)
핵심은 "청크(Chunk)" 단위로 처리된다는 것이다. 100만 건을 한 번에 메모리에 올리는 게 아니라, 100건씩 나눠서 읽고 처리하고 저장한다. 각 청크가 독립적인 트랜잭션이라 중간에 실패해도 그 청크만 롤백되고, 다음 실행 시 마지막으로 성공한 청크 이후부터 재시작할 수 있다.
하나의 배치 작업 전체를 나타내는 최상위 개념이다. 여러 Step을 순서대로 실행한다. 같은 Job을 여러 번 실행할 수 있고, 실행할 때마다 Job Parameters로 구분한다. (같은 Parameters로 이미 성공한 Job은 재실행되지 않음)
Job을 구성하는 독립적인 처리 단위다. Step은 두 가지 방식으로 만들 수 있다.
데이터를 읽어오는 역할이다. Spring Batch는 다양한 기본 구현체를 제공한다.
JdbcPagingItemReader — DB를 페이징으로 읽음JpaPagingItemReader — JPA로 DB를 페이징으로 읽음FlatFileItemReader — CSV, 텍스트 파일 읽음JsonItemReader — JSON 파일 읽음
읽어온 데이터를 변환하거나 처리하는 역할이다. 선택 사항이다.
null을 반환하면 해당 아이템이 ItemWriter에 전달되지 않아서 필터링 효과가 있다.
처리된 데이터를 저장하는 역할이다. 청크 단위로 묶어서 한꺼번에 받기 때문에, JDBC Batch Insert 등으로 성능 최적화를 하기에 적합하다.
Job과 Step의 실행 이력을 DB에 저장하는 컴포넌트이다. 언제 실행됐는지, 성공/실패 여부, 처리 건수, 현재 Step의 진행 상황 등이 기록된다. 이 메타데이터 덕분에 실패 시 재시작, 실행 이력 조회가 가능하다.
Job을 실행하는 진입점이다.
스케줄러(@Scheduled), REST API 호출, CI/CD 파이프라인 등에서 JobLauncher를 통해 Job을 실행한다.
spring-boot-starter-batch를 추가하면 Spring Batch가 활성화된다.
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-batch'
# application.yml
spring:
batch:
job:
enabled: false # 애플리케이션 시작 시 자동 실행 방지 (스케줄러로 제어)
jdbc:
initialize-schema: always # Spring Batch 메타 테이블 자동 생성 (개발용)
# 운영에서는 never로 설정하고 별도 DDL 스크립트로 관리
datasource:
url: jdbc:mysql://localhost:3306/mydb
Spring Batch는 실행 이력을 저장할 메타 테이블이 필요하다.
BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION, BATCH_STEP_EXECUTION 등의 테이블이 자동으로 생성되거나
별도 DDL로 미리 만들어두어야 한다.
@Configuration
@RequiredArgsConstructor
public class UserGradeBatchConfig {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final EntityManagerFactory entityManagerFactory;
private final UserRepository userRepository;
@Bean
public Job userGradeUpdateJob() {
return new JobBuilder("userGradeUpdateJob", jobRepository)
.start(gradeCalculationStep())
.build();
}
@Bean
public Step gradeCalculationStep() {
return new StepBuilder("gradeCalculationStep", jobRepository)
.<User, User>chunk(100, transactionManager) // 100건씩 청크 처리
.reader(userItemReader())
.processor(userGradeProcessor())
.writer(userItemWriter())
.build();
}
@Bean
public JpaPagingItemReader<User> userItemReader() {
return new JpaPagingItemReaderBuilder<User>()
.name("userItemReader")
.entityManagerFactory(entityManagerFactory)
.pageSize(100) // 한 번에 100건씩 DB에서 읽음
.queryString("SELECT u FROM User u WHERE u.status = 'ACTIVE' ORDER BY u.id")
.build();
}
@Bean
public ItemProcessor<User, User> userGradeProcessor() {
return user -> {
int totalPurchase = user.calculateTotalPurchaseAmount();
// 비즈니스 로직: 구매 금액에 따른 등급 산정
UserGrade newGrade;
if (totalPurchase >= 1_000_000) {
newGrade = UserGrade.VIP;
} else if (totalPurchase >= 300_000) {
newGrade = UserGrade.GOLD;
} else {
newGrade = UserGrade.SILVER;
}
user.updateGrade(newGrade);
return user;
// null을 반환하면 Writer로 전달되지 않음 (필터링)
};
}
@Bean
public JpaItemWriter<User> userItemWriter() {
return new JpaItemWriterBuilder<User>()
.entityManagerFactory(entityManagerFactory)
.build();
}
}
@Component
@RequiredArgsConstructor
public class BatchScheduler {
private final JobLauncher jobLauncher;
private final Job userGradeUpdateJob;
// 매월 1일 새벽 2시에 실행
@Scheduled(cron = "0 0 2 1 * *")
public void runUserGradeUpdateJob() throws Exception {
JobParameters params = new JobParametersBuilder()
.addLocalDateTime("executionTime", LocalDateTime.now()) // 매 실행마다 다른 파라미터로 구분
.toJobParameters();
JobExecution execution = jobLauncher.run(userGradeUpdateJob, params);
log.info("배치 실행 완료. Status={}, 처리 건수={}",
execution.getStatus(),
execution.getStepExecutions().iterator().next().getWriteCount());
}
}
// 테이블 초기화처럼 청크가 필요 없는 단순 작업
@Bean
public Step clearTempTableStep() {
return new StepBuilder("clearTempTableStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
jdbcTemplate.execute("TRUNCATE TABLE temp_settlement");
log.info("임시 정산 테이블 초기화 완료");
return RepeatStatus.FINISHED;
}, transactionManager)
.build();
}
@Bean
public Job monthlySettlementJob() {
return new JobBuilder("monthlySettlementJob", jobRepository)
.start(clearTempTableStep()) // Step 1: 임시 테이블 초기화
.next(aggregateOrdersStep()) // Step 2: 주문 집계
.next(calculateSettlementStep()) // Step 3: 정산 계산
.next(sendReportStep()) // Step 4: 리포트 발송
.build();
}
# 배치 메타 테이블에서 실행 이력 확인
SELECT job_instance_id, job_name, create_time, status
FROM BATCH_JOB_EXECUTION
ORDER BY create_time DESC;
# 실패한 Step의 진행 상황 확인
SELECT step_name, status, read_count, write_count, commit_count, skip_count
FROM BATCH_STEP_EXECUTION
WHERE job_execution_id = ?;
Spring Batch는 동일한 Job Parameters로 이미 성공한 Job은 재실행을 막는다.
매번 다르게 실행하려면 LocalDateTime.now()처럼 매 실행마다 달라지는 값을 파라미터에 포함해야 한다.
실패한 Job은 같은 Parameters로 재실행하면 실패한 Step부터 이어서 실행된다.
JpaPagingItemReader의 pageSize와 chunk의 size가 다르면
여러 번 페이징 쿼리가 나가거나 데이터가 남아도는 비효율이 생긴다.
두 값을 동일하게 맞추는 게 일반적인 권장 사항이다.
페이징을 하면서 데이터를 읽을 때 정렬 기준이 없으면 페이지마다 다른 데이터가 올 수 있다.
(DB 내부 정렬 순서는 보장되지 않음)
반드시 ORDER BY를 명시해야 중복 읽기나 누락 없이 전체 데이터를 처리할 수 있다.
Spring Boot 3.x + Spring Batch 5.x에서는
기존 @EnableBatchProcessing, StepBuilderFactory, JobBuilderFactory 방식이 deprecated되고
JobBuilder, StepBuilder를 직접 사용하는 방식으로 변경됐다.
구버전 코드를 그대로 쓰면 빌드는 되지만 경고가 뜨거나 동작이 달라질 수 있다.
청크 사이즈가 너무 작으면 커밋이 자주 일어나고, 너무 크면 메모리 부담과 실패 시 롤백 범위가 커진다. 일반적으로 50~500 사이에서 시작해서 실제 데이터로 성능 테스트를 해보고 결정하는 게 맞다.
initialize-schema: always는 개발 편의용이다.
운영에서는 initialize-schema: never로 두고, Spring Batch가 제공하는 DDL 스크립트를
Flyway나 Liquibase로 버전 관리해서 적용하는 게 안전하다.
배치 처리는 CPU와 메모리를 많이 쓴다. API 서버와 같은 JVM에서 실행하면 배치 실행 중 API 응답이 느려질 수 있다. 별도의 배치 전용 서버나 컨테이너로 분리해서 운영 서비스에 영향을 주지 않게 하는 게 좋다.
데이터 품질 문제로 일부 데이터가 처리 중 실패할 때, 전체 배치를 중단시키지 않고 일정 수 이하로 실패한 건은 스킵하고 나머지를 계속 처리하도록 설정할 수 있다.
@Bean
public Step gradeCalculationStep() {
return new StepBuilder("gradeCalculationStep", jobRepository)
.<User, User>chunk(100, transactionManager)
.reader(userItemReader())
.processor(userGradeProcessor())
.writer(userItemWriter())
.faultTolerant()
.skip(DataIntegrityViolationException.class) // 이 예외는 스킵
.skipLimit(10) // 최대 10건까지만 스킵 허용
.retry(TransientDataAccessException.class) // 이 예외는 재시도
.retryLimit(3) // 최대 3회 재시도
.build();
}
initialize-schema: always는 개발용이며, 운영에서는 직접 DDL을 관리해야 한다
처음엔 "그냥 @Scheduled에 for문 돌리면 안 되나?"라고 생각했는데,
100만 건을 실제로 처리해보는 상황을 상상해보니 왜 Spring Batch 같은 게 필요한지 바로 납득이 됐다.
메모리 터지고, 중간에 죽으면 어디서부터 다시 시작할지 모르고, 몇 건 처리됐는지도 알 수 없는 상황.
Spring Batch가 제공하는 청크 처리와 재시작 가능성은 결국 "대용량 처리를 안전하게 하기 위한 설계"라는 걸 느꼈다. 복잡해 보이는 Job/Step/Reader/Processor/Writer 구조도 각자의 역할이 분명하고, 그 덕분에 각 단계를 독립적으로 테스트하고 교체할 수 있다는 게 납득이 됐다. 배치는 실시간 API와 다른 요구사항이 있고, Spring Batch는 그 요구사항에 딱 맞게 설계된 프레임워크라는 걸 이해했다.