Spring Batch 설계를 제대로 이해하지 못하면, 수백만 건 데이터를 처리하다 중간에 터진 배치가 처음부터 다시 돌면서 이미 처리된 데이터를 또 처리하거나, 오류 한 건 때문에 전체 작업이 롤백되어 새벽 내내 재실행을 반복하는 지옥을 경험하게 됩니다. Spring Batch는 “그냥 for 루프를 스케줄러로 돌리면 되지 않나?”라는 생각을 완전히 뒤집는 엔터프라이즈급 배치 프레임워크입니다. 청크(Chunk) 기반 처리, 재시작(Restart), Skip·Retry 정책, 병렬화 전략까지 – 이 글에서는 Spring Batch의 핵심 개념부터 실무에서 안전하게 대용량 배치를 설계하는 방법까지 빠짐없이 정리합니다.
목차
- Spring Batch란? 단순 스케줄러와 근본적으로 다른 이유
- Spring Batch 핵심 아키텍처 – Job·Step·Chunk의 동작 원리
- ItemReader·ItemProcessor·ItemWriter – 배치의 세 기둥
- 안전한 배치 설계 – 재시작·Skip·Retry 전략
- 성능 극대화 – 병렬 처리와 파티셔닝 전략
- 전문가 관점 – 모니터링·테스트·운영 설계 원칙
1. Spring Batch란? 단순 스케줄러와 근본적으로 다른 이유
배치 처리가 필요한 이유
배치 처리(Batch Processing)는 대량의 데이터를 일정 시간에 일괄로 처리하는 방식입니다. 실시간으로 사용자 요청에 응답하는 온라인 처리(OLTP)와 달리, 배치 처리는 사람의 개입 없이 자동으로 대량 작업을 수행합니다.
실무에서 배치 처리가 필요한 대표적인 사례는 다음과 같습니다.
| 사용 사례 | 처리 규모 | 실행 주기 |
|---|---|---|
| 월말 정산 및 청구서 발행 | 수백만 건 | 월 1회 |
| 일일 매출 집계·통계 생성 | 수십만 건 | 매일 새벽 |
| 이메일·SMS 대량 발송 | 수백만 건 | 주기적 |
| 외부 시스템 데이터 동기화 | 수만~수백만 건 | 시간별 |
| 만료 데이터 정리·아카이빙 | 수백만 건 | 매일/주별 |
| 머신러닝 학습용 데이터 전처리 | 수억 건 | 비정기 |
단순 스케줄러(@Scheduled)가 한계에 부딪히는 순간
많은 팀이 처음에는 Spring의 @Scheduled 어노테이션으로 배치 작업을 구현합니다. 소규모 데이터에서는 잘 동작하지만, 다음 상황이 찾아오면 반드시 한계에 부딪힙니다.
[단순 @Scheduled 배치의 문제 시나리오]
새벽 2시, 500만 건 정산 배치 실행 시작
↓
200만 건 처리 완료
↓
서버 장애 또는 외부 API 타임아웃 발생 → 배치 강제 종료
↓
다음날 새벽 2시 배치 재실행
↓
❌ 처음(1번)부터 다시 시작! → 200만 건 중복 처리
❌ 어디까지 처리됐는지 기록 없음
❌ 오류 원인 추적 불가
❌ 중복 처리된 데이터 수동 정리 필요 → 운영팀 새벽 비상
Spring Batch가 이 문제를 해결하는 방식:
[Spring Batch 동일 시나리오]
새벽 2시, 500만 건 정산 배치 실행 시작
↓
JobRepository에 실행 상태 실시간 기록 (200만 건 완료 체크포인트 저장)
↓
서버 장애 발생 → 배치 강제 종료
↓
다음날 새벽 2시 배치 재실행
↓
✅ 201만 번째 건부터 재시작! (처음부터 X)
✅ 처리 이력, 소요 시간, 성공/실패 건수 모두 DB에 저장
✅ 오류 발생 지점·스택트레이스 추적 가능
✅ Skip 정책에 따라 오류 건 건너뛰고 나머지 처리 가능
Spring Batch의 4가지 핵심 특성
① 재시작 가능성 (Restartability)
실패 지점부터 재시작 → 중복 처리 방지
② 재시도 가능성 (Retryability)
일시적 오류(네트워크 타임아웃 등) 발생 시 자동 재시도
③ Skip 처리
특정 오류 건 건너뛰고 나머지 계속 처리
④ 청크 기반 트랜잭션
대량 데이터를 N건 단위로 나눠 트랜잭션 처리
→ 메모리 효율 + 장애 시 손실 최소화
2. Spring Batch 핵심 아키텍처 – Job·Step·Chunk의 동작 원리
Spring Batch 전체 구조 한눈에 보기
[Spring Batch 아키텍처 전체 구조]
┌─────────────────────────────────────────────────────────┐
│ JobLauncher │
│ (배치 Job 실행 진입점, 파라미터 전달) │
└───────────────────┬─────────────────────────────────────┘
│ Job 실행 요청
▼
┌─────────────────────────────────────────────────────────┐
│ Job │
│ (하나의 완결된 배치 작업 단위, 여러 Step으로 구성) │
│ │
│ Step 1 ──→ Step 2 ──→ Step 3 ──→ Step 4(조건부) │
└───────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Step │
│ (독립적으로 실행 가능한 작업 단위, 두 가지 타입) │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Chunk-Oriented Step (권장) │ │
│ │ ItemReader → ItemProcessor │ │
│ │ → ItemWriter │ │
│ │ (N건 단위로 읽기·처리·쓰기 반복) │ │
│ └──────────────────────────────────┘ │
│ ┌──────────────────────────────────┐ │
│ │ Tasklet Step │ │
│ │ (단순 작업: 파일 삭제, 알림 발송 │ │
│ └──────────────────────────────────┘ │
└───────────────────┬─────────────────────────────────────┘
│ 실행 이력 저장
▼
┌─────────────────────────────────────────────────────────┐
│ JobRepository │
│ (메타데이터 DB: 실행 이력·상태·파라미터·통계 저장) │
│ │
│ BATCH_JOB_INSTANCE BATCH_JOB_EXECUTION │
│ BATCH_STEP_EXECUTION BATCH_JOB_EXECUTION_PARAMS │
└─────────────────────────────────────────────────────────┘
Job – 배치 작업의 최상위 단위
Job은 전체 배치 작업을 나타내는 최상위 컨테이너입니다. 하나의 Job은 순서가 있는 여러 Step으로 구성됩니다.
java
@Configuration
@RequiredArgsConstructor
public class DailySettlementJobConfig {
private final JobRepository jobRepository;
private final Step validateStep;
private final Step calculateStep;
private final Step writeReportStep;
private final Step notifyStep;
@Bean
public Job dailySettlementJob() {
return new JobBuilder("dailySettlementJob", jobRepository)
// Step 순서 정의
.start(validateStep) // 1단계: 데이터 검증
.next(calculateStep) // 2단계: 정산 계산
.next(writeReportStep) // 3단계: 리포트 저장
// 조건부 Step 분기
.next(new JobExecutionDecider() {
@Override
public FlowExecutionStatus decide(
JobExecution jobExecution,
StepExecution stepExecution) {
long failCount = jobExecution
.getStepExecutions().stream()
.mapToLong(StepExecution::getSkipCount)
.sum();
return failCount > 0
? new FlowExecutionStatus("NEEDS_ALERT")
: new FlowExecutionStatus("NO_ALERT");
}
})
.on("NEEDS_ALERT").to(notifyStep) // 오류 있으면 알림
.on("NO_ALERT").end() // 오류 없으면 종료
.end()
// 재시작 허용 (기본값 true)
.preventRestart() // 이 설정 시 재시작 불가 (1회성 Job에 사용)
.build();
}
}
JobInstance와 JobExecution – 실행 단위의 개념 구분
Spring Batch에서 혼동하기 쉬운 개념이 JobInstance와 JobExecution입니다.
[JobInstance vs JobExecution 관계]
Job: "dailySettlementJob" (설계도)
│
├── JobInstance: dailySettlementJob + date=2026-05-18 (실행 계획)
│ ├── JobExecution #1: 2026-05-18 02:00, FAILED (1차 실행 실패)
│ └── JobExecution #2: 2026-05-18 03:00, COMPLETED (2차 재시작 성공)
│
└── JobInstance: dailySettlementJob + date=2026-05-19 (다음 날 새 인스턴스)
└── JobExecution #3: 2026-05-19 02:00, COMPLETED
→ JobInstance = Job + 고유 파라미터 조합 (매일 새로운 인스턴스)
→ JobExecution = 하나의 JobInstance 실행 시도 (실패해도 새 Execution 생성)
→ 같은 파라미터로 COMPLETED된 JobInstance는 재실행 불가 (중복 방지)
Step – 독립적인 작업 단위
Step은 Job을 구성하는 독립적인 작업 단위입니다. 각 Step은 독립적인 트랜잭션과 실행 이력을 가집니다.
java
@Configuration
@RequiredArgsConstructor
public class CalculateStepConfig {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
@Bean
public Step calculateStep(
ItemReader<RawTransaction> transactionReader,
ItemProcessor<RawTransaction, Settlement> settlementProcessor,
ItemWriter<Settlement> settlementWriter) {
return new StepBuilder("calculateStep", jobRepository)
.<RawTransaction, Settlement>chunk(1000, transactionManager)
// 읽기: DB에서 RawTransaction 청크 단위로 읽기
.reader(transactionReader)
// 처리: RawTransaction → Settlement 변환
.processor(settlementProcessor)
// 쓰기: Settlement 청크 단위로 DB 저장
.writer(settlementWriter)
// Skip 정책: 변환 오류는 건너뛰기 (최대 100건)
.faultTolerant()
.skip(DataConversionException.class)
.skipLimit(100)
// Retry 정책: 네트워크 오류는 3번 재시도
.retry(TransientDataAccessException.class)
.retryLimit(3)
// 리스너 등록
.listener(new StepExecutionLoggingListener())
.build();
}
}
JobRepository – Spring Batch의 두뇌
JobRepository는 모든 배치 실행 이력을 데이터베이스에 저장하는 핵심 컴포넌트입니다. 이것이 단순 스케줄러와 가장 크게 다른 점입니다.
sql
-- Spring Batch가 자동 생성하는 메타데이터 테이블
-- (spring.batch.jdbc.initialize-schema: always 설정 시)
BATCH_JOB_INSTANCE -- Job 인스턴스 정보 (Job명 + 파라미터 해시)
BATCH_JOB_EXECUTION -- Job 실행 이력 (시작/종료 시각, 상태, 종료 코드)
BATCH_JOB_EXECUTION_PARAMS -- Job 실행 파라미터
BATCH_STEP_EXECUTION -- Step 실행 이력 (읽기/처리/쓰기/Skip 건수, 실행 시간)
BATCH_STEP_EXECUTION_CONTEXT -- Step 실행 컨텍스트 (재시작 시 이전 상태 복원용)
BATCH_JOB_EXECUTION_CONTEXT -- Job 실행 컨텍스트 (Step 간 데이터 공유용)
java
// JobRepository 직접 설정 (In-Memory가 아닌 DB 기반 권장)
@Configuration
public class BatchConfig {
@Bean
public JobRepository jobRepository(DataSource dataSource,
PlatformTransactionManager transactionManager) throws Exception {
JobRepositoryFactoryBean factory = new JobRepositoryFactoryBean();
factory.setDataSource(dataSource);
factory.setTransactionManager(transactionManager);
factory.setIsolationLevelForCreate("ISOLATION_SERIALIZABLE");
factory.setTablePrefix("BATCH_"); // 메타데이터 테이블 접두사
factory.setMaxVarCharLength(2500);
factory.afterPropertiesSet();
return factory.getObject();
}
}
3. ItemReader·ItemProcessor·ItemWriter – 배치의 세 기둥
Chunk 기반 Step의 핵심은 ItemReader, ItemProcessor, ItemWriter 세 컴포넌트의 협력입니다. 이 세 가지를 명확히 이해해야 올바른 Spring Batch 설계가 가능합니다.
Chunk 기반 처리 흐름
[Chunk 크기 1000으로 설정된 처리 흐름]
트랜잭션 시작
├── ItemReader.read() × 1000번 (1건씩 읽어 청크 채우기)
├── ItemProcessor.process() × 1000번 (각 아이템 변환·필터링)
└── ItemWriter.write(List<1000건>) (한 번에 저장)
트랜잭션 커밋 → 다음 청크 시작
청크 처리 중 예외 발생:
→ 해당 청크 트랜잭션 롤백 (1000건 전체)
→ Skip 설정 시: 오류 건 제외하고 재처리
→ Retry 설정 시: 청크 전체 재시도
ItemReader – 데이터를 읽는 컴포넌트
Spring Batch는 다양한 ItemReader 구현체를 기본 제공합니다.
① JpaCursorItemReader – JPA 기반 대용량 조회 (권장)
java
@Bean
@StepScope // Step 실행 시마다 새 인스턴스 생성 (JobParameter 주입 가능)
public JpaCursorItemReader<RawTransaction> transactionReader(
EntityManagerFactory emf,
@Value("#{jobParameters['targetDate']}") String targetDate) {
return new JpaCursorItemReaderBuilder<RawTransaction>()
.name("transactionReader")
.entityManagerFactory(emf)
.queryString("""
SELECT t FROM RawTransaction t
WHERE t.transactionDate = :targetDate
AND t.status = 'PENDING'
ORDER BY t.id
""")
.parameterValues(Map.of("targetDate", LocalDate.parse(targetDate)))
// 커서 방식: 전체를 메모리에 올리지 않고 스트리밍으로 읽음
// JpaPagingItemReader 대비 성능 우수 (N번 쿼리 vs 1번 커서)
.build();
}
② JdbcPagingItemReader – JDBC 기반 페이징 조회
java
@Bean
@StepScope
public JdbcPagingItemReader<OrderDto> orderReader(
DataSource dataSource,
@Value("#{jobParameters['targetDate']}") String targetDate) {
Map<String, Object> params = new HashMap<>();
params.put("targetDate", targetDate);
params.put("status", "COMPLETED");
return new JdbcPagingItemReaderBuilder<OrderDto>()
.name("orderReader")
.dataSource(dataSource)
.selectClause("SELECT order_id, user_id, amount, created_at")
.fromClause("FROM orders")
.whereClause("WHERE DATE(created_at) = :targetDate AND status = :status")
.sortKeys(Map.of("order_id", Order.ASCENDING)) // 정렬 키 필수!
.parameterValues(params)
.pageSize(1000) // 한 번에 읽는 페이지 크기
.rowMapper(new BeanPropertyRowMapper<>(OrderDto.class))
.build();
}
③ FlatFileItemReader – CSV·텍스트 파일 읽기
java
@Bean
@StepScope
public FlatFileItemReader<ProductCsv> csvReader(
@Value("#{jobParameters['filePath']}") String filePath) {
return new FlatFileItemReaderBuilder<ProductCsv>()
.name("csvReader")
.resource(new FileSystemResource(filePath))
.linesToSkip(1) // 헤더 행 건너뜀
.delimited()
.delimiter(",")
.names("productId", "name", "price", "stock")
.targetType(ProductCsv.class)
.encoding("UTF-8")
.build();
}
ItemProcessor – 데이터를 변환·필터링하는 컴포넌트
java
@Component
@StepScope
@RequiredArgsConstructor
public class SettlementProcessor
implements ItemProcessor<RawTransaction, Settlement> {
private final ExchangeRateService exchangeRateService;
@Override
public Settlement process(RawTransaction transaction) throws Exception {
// null 반환 시 해당 아이템 Writer에 전달되지 않음 (필터링)
if (transaction.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
log.warn("유효하지 않은 금액, 건너뜀: txId={}", transaction.getId());
return null; // Writer에 전달 X → 처리 건수에서 제외
}
// 환율 변환 처리
BigDecimal krwAmount = exchangeRateService
.convertToKrw(transaction.getCurrency(), transaction.getAmount());
// Settlement 객체 생성 및 반환
return Settlement.builder()
.transactionId(transaction.getId())
.userId(transaction.getUserId())
.originalAmount(transaction.getAmount())
.krwAmount(krwAmount)
.settledAt(LocalDateTime.now())
.status(SettlementStatus.CALCULATED)
.build();
}
}
// 여러 Processor를 체인으로 연결
@Bean
public ItemProcessor<RawTransaction, Settlement> compositeProcessor() {
return new CompositeItemProcessorBuilder<RawTransaction, Settlement>()
.delegates(
validationProcessor(), // 1단계: 유효성 검사
enrichmentProcessor(), // 2단계: 데이터 보강
settlementProcessor() // 3단계: 정산 변환
)
.build();
}
ItemWriter – 처리된 데이터를 저장하는 컴포넌트
java
// ① JpaItemWriter – JPA 기반 저장
@Bean
public JpaItemWriter<Settlement> settlementJpaWriter(EntityManagerFactory emf) {
return new JpaItemWriterBuilder<Settlement>()
.entityManagerFactory(emf)
.usePersist(true) // true: persist() / false: merge() (업데이트 시)
.build();
}
// ② JdbcBatchItemWriter – JDBC 배치 INSERT (JPA 대비 성능 우수)
@Bean
public JdbcBatchItemWriter<Settlement> settlementJdbcWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<Settlement>()
.dataSource(dataSource)
.sql("""
INSERT INTO settlements (transaction_id, user_id, krw_amount, settled_at, status)
VALUES (:transactionId, :userId, :krwAmount, :settledAt, :status)
ON DUPLICATE KEY UPDATE
krw_amount = :krwAmount,
status = :status
""")
.beanMapped() // Settlement 객체 필드와 SQL 파라미터 자동 매핑
.assertUpdates(true) // 모든 건이 실제로 저장됐는지 검증
.build();
}
// ③ CompositeItemWriter – 여러 Writer를 동시에 실행
@Bean
public CompositeItemWriter<Settlement> compositeWriter() {
CompositeItemWriter<Settlement> writer = new CompositeItemWriter<>();
writer.setDelegates(List.of(
settlementJdbcWriter(dataSource), // DB 저장
settlementAuditWriter(), // 감사 로그 저장
settlementCacheWriter() // 캐시 업데이트
));
return writer;
}
4. 안전한 배치 설계 – 재시작·Skip·Retry 전략
이 섹션이 Spring Batch 설계에서 가장 중요한 부분입니다. 배치가 중간에 실패했을 때 어떻게 동작하느냐가 운영 안정성을 결정합니다.
재시작(Restart) 전략 – 중단 지점부터 이어서
Spring Batch의 재시작은 BATCH_STEP_EXECUTION_CONTEXT 테이블에 저장된 마지막 처리 상태를 기반으로 동작합니다.
java
// JobParameters에 날짜를 포함하면 매일 새로운 JobInstance 생성
// → 같은 날 재실행 시 이전 실패 지점부터 재시작
@Component
@RequiredArgsConstructor
public class BatchJobRunner {
private final JobLauncher jobLauncher;
private final Job dailySettlementJob;
public void run(LocalDate targetDate) throws Exception {
JobParameters params = new JobParametersBuilder()
.addLocalDate("targetDate", targetDate) // 날짜별 고유 인스턴스 생성
.addLong("timestamp", System.currentTimeMillis()) // 같은 날 재실행 허용 시
.toJobParameters();
JobExecution execution = jobLauncher.run(dailySettlementJob, params);
// COMPLETED: 정상 완료 (재실행 불가)
// FAILED: 실패 (동일 파라미터로 재실행 시 마지막 실패 지점부터 재시작)
// STOPPED: 수동 중지 (재시작 가능)
log.info("배치 실행 결과: {}", execution.getStatus());
}
}
[재시작 동작 상세 시나리오]
Step 1 (COMPLETED) → Step 2 (FAILED at 250,000건) → Step 3 (미실행)
재시작 시:
Step 1: COMPLETED 상태이므로 건너뜀 ✅
Step 2: 250,001번째 건부터 재시작 ✅
Step 3: Step 2 완료 후 실행 ✅
→ 단, ItemReader가 재시작 가능(Restartable)해야 함
→ JdbcPagingItemReader, JpaCursorItemReader 모두 재시작 지원
Skip 전략 – 오류 건 건너뛰고 계속 처리
java
@Bean
public Step settlementStep(/* ... */) {
return new StepBuilder("settlementStep", jobRepository)
.<RawTransaction, Settlement>chunk(1000, transactionManager)
.reader(reader)
.processor(processor)
.writer(writer)
// ─── Skip 설정 ───────────────────────────────────────
.faultTolerant()
// 건너뛸 예외 지정 (데이터 변환 오류는 Skip)
.skip(DataConversionException.class)
.skip(ConstraintViolationException.class)
// 건너뛰면 안 되는 예외 (치명적 오류는 즉시 실패)
.noSkip(CriticalBusinessException.class)
// 최대 Skip 허용 건수 (초과 시 Step 실패 처리)
.skipLimit(500)
// Skip 발생 시 후처리 리스너
.listener(new SkipLoggingListener())
.build();
}
// Skip 발생 시 로깅 및 별도 테이블 저장
@Component
@Slf4j
public class SkipLoggingListener
implements SkipListener<RawTransaction, Settlement> {
private final SkipRecordRepository skipRecordRepository;
@Override
public void onSkipInRead(Throwable t) {
log.warn("[SKIP] 읽기 단계 Skip 발생: {}", t.getMessage());
}
@Override
public void onSkipInProcess(RawTransaction item, Throwable t) {
log.warn("[SKIP] 처리 단계 Skip: itemId={}, error={}",
item.getId(), t.getMessage());
// Skip된 건을 별도 테이블에 저장 → 나중에 수동 재처리 가능
skipRecordRepository.save(SkipRecord.of("PROCESS", item.getId(), t));
}
@Override
public void onSkipInWrite(Settlement item, Throwable t) {
log.warn("[SKIP] 쓰기 단계 Skip: settlementId={}, error={}",
item.getTransactionId(), t.getMessage());
skipRecordRepository.save(SkipRecord.of("WRITE", item.getTransactionId(), t));
}
}
Retry 전략 – 일시적 오류 자동 재시도
java
@Bean
public Step externalApiStep(/* ... */) {
return new StepBuilder("externalApiStep", jobRepository)
.<UserDto, EnrichedUser>chunk(100, transactionManager)
.reader(userReader)
.processor(externalApiProcessor) // 외부 API 호출 포함
.writer(enrichedUserWriter)
.faultTolerant()
// Retry 설정: 네트워크·DB 일시 오류는 재시도
.retry(TransientDataAccessException.class) // DB 일시 오류
.retry(ResourceAccessException.class) // 외부 API 타임아웃
.retryLimit(3) // 최대 3번 재시도
// 재시도 간격 설정 (지수 백오프)
.retryPolicy(new ExponentialBackoffRetryPolicy()
.withInitialInterval(1000L) // 첫 재시도: 1초 후
.withMultiplier(2.0) // 이후 2배씩 증가 (1s → 2s → 4s)
.withMaxInterval(10000L)) // 최대 10초
.build();
}
멱등성(Idempotency) 설계 – 재실행해도 안전한 배치
java
// ✅ 멱등성 보장 설계 – UPSERT 패턴
@Bean
public JdbcBatchItemWriter<Settlement> idempotentWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<Settlement>()
.dataSource(dataSource)
.sql("""
INSERT INTO settlements
(transaction_id, user_id, krw_amount, status, batch_run_date)
VALUES
(:transactionId, :userId, :krwAmount, :status, :batchRunDate)
ON DUPLICATE KEY UPDATE
krw_amount = VALUES(krw_amount),
status = VALUES(status),
updated_at = NOW()
-- transaction_id에 UNIQUE 제약 → 중복 실행 시 UPDATE로 처리
""")
.beanMapped()
.build();
}
// ✅ 처리 전 상태 확인으로 멱등성 보장
@Component
public class IdempotentSettlementProcessor
implements ItemProcessor<RawTransaction, Settlement> {
private final SettlementRepository settlementRepository;
@Override
public Settlement process(RawTransaction tx) {
// 이미 처리된 건은 null 반환 (Writer 전달 X)
if (settlementRepository.existsByTransactionId(tx.getId())) {
log.debug("이미 처리된 거래, 건너뜀: txId={}", tx.getId());
return null;
}
return convertToSettlement(tx);
}
}
5. 성능 극대화 – 병렬 처리와 파티셔닝 전략
단일 스레드 배치로 감당할 수 없는 대용량 처리에서는 병렬화가 필수입니다. Spring Batch는 여러 수준의 병렬 처리를 지원합니다.
병렬 Step – 독립적인 Step 동시 실행
java
@Bean
public Job parallelStepsJob() {
// 독립적인 두 Step을 동시에 실행
Flow parallelFlow = new FlowBuilder<SimpleFlow>("parallelFlow")
.split(new SimpleAsyncTaskExecutor()) // 별도 스레드에서 실행
.add(
new FlowBuilder<SimpleFlow>("flow1")
.start(orderSettlementStep()) // 주문 정산
.build(),
new FlowBuilder<SimpleFlow>("flow2")
.start(refundSettlementStep()) // 환불 정산
.build(),
new FlowBuilder<SimpleFlow>("flow3")
.start(pointSettlementStep()) // 포인트 정산
.build()
)
.end();
return new JobBuilder("parallelStepsJob", jobRepository)
.start(parallelFlow)
.next(aggregationStep()) // 병렬 처리 완료 후 집계
.end()
.build();
}
Multi-Thread Step – 하나의 Step 내 멀티스레드 처리
java
@Bean
public Step multiThreadedSettlementStep(
ItemReader<RawTransaction> reader,
ItemProcessor<RawTransaction, Settlement> processor,
ItemWriter<Settlement> writer) {
// 스레드 풀 설정
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4); // 기본 스레드 수
executor.setMaxPoolSize(8); // 최대 스레드 수
executor.setQueueCapacity(100); // 대기 큐 크기
executor.setThreadNamePrefix("batch-thread-");
executor.initialize();
return new StepBuilder("multiThreadedStep", jobRepository)
.<RawTransaction, Settlement>chunk(1000, transactionManager)
.reader(reader)
.processor(processor)
.writer(writer)
.taskExecutor(executor) // 멀티스레드 실행
.throttleLimit(4) // 동시 실행 스레드 수 제한 (Deprecated → maxPoolSize 활용)
.build();
// ⚠️ 주의: ItemReader가 Thread-Safe해야 함!
// JpaCursorItemReader → Thread-Unsafe (단일 스레드만 가능)
// JdbcPagingItemReader → Thread-Safe (멀티스레드 사용 가능)
// SynchronizedItemStreamReader로 래핑하면 Thread-Safe 보장
}
Partitioner – 데이터 분할 병렬 처리 (가장 강력한 패턴)
파티셔닝은 전체 데이터를 N개의 파티션으로 나누고 각 파티션을 독립적인 Worker Step이 처리하는 방식입니다.
java
// 파티셔너: 데이터를 어떻게 분할할지 정의
@Component
public class DateRangePartitioner implements Partitioner {
@Override
public Map<String, ExecutionContext> partition(int gridSize) {
Map<String, ExecutionContext> partitions = new HashMap<>();
// 예: 1일치 데이터를 ID 범위로 N개 파티션으로 분할
long totalCount = transactionRepository.countByDate(targetDate);
long partitionSize = (totalCount / gridSize) + 1;
for (int i = 0; i < gridSize; i++) {
ExecutionContext context = new ExecutionContext();
context.putLong("minId", i * partitionSize + 1);
context.putLong("maxId", (i + 1) * partitionSize);
context.putString("partitionId", "partition-" + i);
partitions.put("partition-" + i, context);
}
return partitions;
}
}
// Worker Step: 각 파티션 데이터를 처리하는 Step
@Bean
@StepScope
public JdbcPagingItemReader<RawTransaction> partitionedReader(
DataSource dataSource,
@Value("#{stepExecutionContext['minId']}") Long minId,
@Value("#{stepExecutionContext['maxId']}") Long maxId) {
return new JdbcPagingItemReaderBuilder<RawTransaction>()
.name("partitionedReader")
.dataSource(dataSource)
.selectClause("SELECT *")
.fromClause("FROM raw_transactions")
.whereClause("WHERE id BETWEEN :minId AND :maxId")
.sortKeys(Map.of("id", Order.ASCENDING))
.parameterValues(Map.of("minId", minId, "maxId", maxId))
.pageSize(1000)
.rowMapper(new BeanPropertyRowMapper<>(RawTransaction.class))
.build();
}
// Manager Step: 파티셔너 + Worker Step 조합
@Bean
public Step partitionedSettlementStep(Step workerStep) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.initialize();
return new StepBuilder("partitionedSettlementStep", jobRepository)
.partitioner("workerStep", dateRangePartitioner())
.step(workerStep) // 각 파티션에서 실행할 Worker Step
.gridSize(8) // 파티션 수 (= 병렬 스레드 수)
.taskExecutor(executor) // 파티션 병렬 실행
.build();
}
파티셔닝 성능 효과:
[단일 스레드 vs 파티셔닝 비교]
데이터: 1,000만 건, 청크 크기: 1,000건
단일 스레드:
처리량: 약 10,000건/초
소요 시간: 1,000초 (약 17분)
파티셔닝 (8개 파티션, 8스레드):
처리량: 약 80,000건/초
소요 시간: 125초 (약 2분)
→ 약 8배 성능 향상 (스레드 수에 비례)
6. 전문가 관점 – 모니터링·테스트·운영 설계 원칙
배치 모니터링 – Spring Batch Admin & Micrometer
java
// Spring Batch 실행 이력 조회 서비스
@Service
@RequiredArgsConstructor
public class BatchMonitorService {
private final JobExplorer jobExplorer;
// 특정 Job의 최근 실행 이력 조회
public List<JobExecutionInfo> getRecentExecutions(String jobName, int count) {
return jobExplorer.findJobInstancesByJobName(jobName, 0, count)
.stream()
.flatMap(instance ->
jobExplorer.getJobExecutions(instance).stream())
.sorted(Comparator.comparing(
JobExecution::getStartTime).reversed())
.limit(count)
.map(exec -> JobExecutionInfo.builder()
.jobName(jobName)
.executionId(exec.getId())
.status(exec.getStatus().name())
.startTime(exec.getStartTime())
.endTime(exec.getEndTime())
.readCount(getTotalReadCount(exec))
.writeCount(getTotalWriteCount(exec))
.skipCount(getTotalSkipCount(exec))
.build())
.collect(Collectors.toList());
}
private long getTotalReadCount(JobExecution exec) {
return exec.getStepExecutions().stream()
.mapToLong(StepExecution::getReadCount).sum();
}
}
// Micrometer로 배치 메트릭 Prometheus에 노출
@Component
public class BatchMetricsListener
implements JobExecutionListener {
private final MeterRegistry meterRegistry;
@Override
public void afterJob(JobExecution jobExecution) {
String jobName = jobExecution.getJobInstance().getJobName();
String status = jobExecution.getStatus().name();
// 실행 횟수 카운터
meterRegistry.counter("batch.job.execution",
"job", jobName, "status", status).increment();
// 실행 시간 타이머
long durationMs = Duration.between(
jobExecution.getStartTime(),
jobExecution.getEndTime()).toMillis();
meterRegistry.gauge("batch.job.duration.ms",
Tags.of("job", jobName), durationMs);
// 처리 건수 Gauge
long totalWriteCount = jobExecution.getStepExecutions()
.stream().mapToLong(StepExecution::getWriteCount).sum();
meterRegistry.gauge("batch.job.write.count",
Tags.of("job", jobName), totalWriteCount);
}
}
Spring Batch 테스트 전략
java
// Spring Batch 통합 테스트 – @SpringBatchTest 활용
@SpringBatchTest
@SpringBootTest
@ActiveProfiles("test")
class SettlementJobTest {
@Autowired private JobLauncherTestUtils jobLauncherTestUtils;
@Autowired private JobRepositoryTestUtils jobRepositoryTestUtils;
@Autowired private Job dailySettlementJob;
@BeforeEach
void setUp() {
// 이전 테스트 실행 이력 초기화
jobRepositoryTestUtils.removeJobExecutions();
}
@Test
void 정산_Job_전체_실행_성공() throws Exception {
// given: 테스트 데이터 준비
testDataSetup.insertRawTransactions(1000);
// when: Job 실행
JobExecution execution = jobLauncherTestUtils.launchJob(
new JobParametersBuilder()
.addLocalDate("targetDate", LocalDate.of(2026, 5, 18))
.toJobParameters()
);
// then: 결과 검증
assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
assertThat(settlementRepository.count()).isEqualTo(1000);
}
@Test
void 특정_Step만_단독_테스트() throws Exception {
// Step만 격리해서 테스트 (빠른 단위 테스트)
JobExecution execution = jobLauncherTestUtils
.launchStep("calculateStep",
new JobParametersBuilder()
.addLocalDate("targetDate", LocalDate.now())
.toJobParameters());
StepExecution stepExecution = execution.getStepExecutions()
.iterator().next();
assertThat(stepExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
assertThat(stepExecution.getWriteCount()).isGreaterThan(0);
assertThat(stepExecution.getSkipCount()).isLessThan(10);
}
}
배치 운영 설계 원칙 체크리스트
[Spring Batch 운영 배포 전 체크리스트]
□ JobRepository DB 설정: In-Memory 대신 실제 DB 사용?
□ 멱등성 보장: 동일 파라미터로 재실행 시 데이터 중복 없음?
□ 청크 크기 최적화: 메모리·성능·롤백 범위 균형 검토 완료?
□ Skip/Retry 정책: 오류 종류별 전략 수립 완료?
□ Graceful Shutdown: 배포 시 실행 중 배치 안전 종료 설정?
□ 타임아웃 설정: Step 레벨 타임아웃 및 외부 API 타임아웃 설정?
□ 모니터링: 실행 이력·소요 시간·Skip 건수 알림 설정?
□ Dead Letter: Skip된 건 별도 저장 및 수동 재처리 방안 마련?
□ 파라미터 설계: 매일/매시 고유 JobInstance 생성 파라미터 전략 수립?
□ 로그 설계: 배치 전용 로그 파일 분리 및 처리 건수 로깅 포함?
□ 테스트: 전체 Job 통합 테스트 및 Step 단위 테스트 작성 완료?
□ 성능 검증: 운영 데이터 규모 기준 처리 시간 사전 측정?
청크 크기 최적화 가이드
[청크 크기 선택 기준]
데이터 규모 및 처리 복잡도에 따른 경험적 권장값:
외부 API 호출 포함: 10 ~ 50건
→ API 호출이 느리므로 작게 설정, Retry 범위 최소화
단순 DB 읽기·저장: 500 ~ 2,000건
→ 대부분의 정산·집계 배치
대용량 집계·통계 처리: 2,000 ~ 5,000건
→ 단순 연산, 메모리 충분한 환경
파일 읽기·쓰기: 1,000 ~ 10,000건
→ I/O 중심, 메모리 매핑 고려
⚠️ 청크가 클수록: 처리 속도 ↑, 장애 시 롤백 범위 ↑, 메모리 사용량 ↑
⚠️ 청크가 작을수록: 장애 손실 최소 ↑, 오버헤드 ↑ (커밋 횟수 증가)
결론
Spring Batch 설계의 핵심은 세 가지입니다. 첫째, JobRepository 기반의 실행 이력 관리와 체크포인트 저장을 통해 중단 지점 재시작을 보장해야 합니다. 둘째, 청크 기반 처리와 Skip·Retry 정책으로 일부 오류가 발생해도 전체 배치가 멈추지 않는 탄력적 구조를 설계해야 합니다. 셋째, 멱등성 보장과 파티셔닝·병렬 처리를 통해 언제 재실행해도 안전하고, 대용량 데이터도 제한된 시간 안에 처리할 수 있는 구조를 갖춰야 합니다. 단순 @Scheduled for 루프와 Spring Batch의 차이는 규모가 커질수록 운영 안정성에서 극명하게 드러납니다.
지금 바로 본문의 운영 배포 전 체크리스트를 현재 진행 중인 배치 프로젝트에 적용해보고, JobRepository를 In-Memory에서 실제 DB로 전환하는 것부터 시작해 보세요.
답글 남기기