8.4 Spring Batch & Quartz 스케줄링
대용량 데이터 처리, 실패 재시도, 진행 이력 관리가 필요한 배치 작업은 Spring Batch 프레임워크로, 정기 실행 스케줄링은 Quartz Scheduler로 구성하는 실전 패턴을 다룹니다.
1. Spring Batch 핵심 아키텍처
전체 구조
Job
└── Step 1 (Chunk-Oriented)
│ ├── ItemReader → DB/파일/API에서 데이터 읽기
│ ├── ItemProcessor → 비즈니스 로직 적용 (선택)
│ └── ItemWriter → DB/파일에 결과 쓰기
└── Step 2 (Tasklet)
└── 단순 작업 (파일 이동, 알림 발송 등)
| 구성 요소 | 역할 |
|---|---|
Job | 배치 작업 전체 단위 |
Step | Job의 독립적인 실행 단계 |
ItemReader | 데이터 소스에서 한 건씩 읽기 |
ItemProcessor | 읽은 데이터 변환/검증 |
ItemWriter | 가공된 데이터 저장 |
JobRepository | 실행 이력·상태 메타데이터 저장 (DB) |
JobLauncher | Job 실행 진입점 |
의존성 추가
<!-- build.gradle -->
implementation 'org.springframework.boot:spring-boot-starter-batch'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-quartz'
runtimeOnly 'com.h2database:h2' // 메타 테이블용 (개발)
runtimeOnly 'org.postgresql:postgresql' // 운영 DB
2. Chunk 기반 배치 — CSV → DB 적재 실전 예제
매일 외부 시스템에서 받은 orders.csv 파일을 읽어 DB에 저장하는 배치입니다.
도메인 모델
// 읽기용 DTO
@Getter
@Setter
public class OrderCsvDto {
private String orderId;
private String customerId;
private String productCode;
private int quantity;
private BigDecimal price;
private String orderDate;
}
// 저장 엔티티
@Entity
@Table(name = "orders")
@Getter
@NoArgsConstructor
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String orderId;
private String customerId;
private String productCode;
private int quantity;
private BigDecimal totalAmount;
private LocalDateTime orderedAt;
public static Order from(OrderCsvDto dto) {
Order order = new Order();
order.orderId = dto.getOrderId();
order.customerId = dto.getCustomerId();
order.productCode = dto.getProductCode();
order.quantity = dto.getQuantity();
order.totalAmount = dto.getPrice().multiply(BigDecimal.valueOf(dto.getQuantity()));
order.orderedAt = LocalDateTime.parse(dto.getOrderDate(),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return order;
}
}
ItemReader — FlatFileItemReader (CSV)
@Configuration
@RequiredArgsConstructor
public class OrderBatchConfig {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final EntityManagerFactory entityManagerFactory;
// ── ItemReader ──────────────────────────────────────────────
@Bean
@StepScope // Step 실행 시점에 빈 생성 (JobParameter 주입 가능)
public FlatFileItemReader<OrderCsvDto> orderCsvReader(
@Value("#{jobParameters['filePath']}") String filePath) {
return new FlatFileItemReaderBuilder<OrderCsvDto>()
.name("orderCsvReader")
.resource(new FileSystemResource(filePath))
.delimited()
.delimiter(",")
.names("orderId", "customerId", "productCode", "quantity", "price", "orderDate")
.fieldSetMapper(new BeanWrapperFieldSetMapper<>() {{
setTargetType(OrderCsvDto.class);
}})
.linesToSkip(1) // 헤더 행 스킵
.encoding("UTF-8")
.build();
}
// ── ItemProcessor ───────────────────────────────────────────
@Bean
@StepScope
public ItemProcessor<OrderCsvDto, Order> orderProcessor() {
return dto -> {
// 유효성 검사: 수량이 0 이하면 스킵
if (dto.getQuantity() <= 0) {
return null; // null 반환 시 해당 아이템 건너뜀
}
return Order.from(dto);
};
}
// ── ItemWriter — JPA 배치 저장 ───────────────────────────────
@Bean
public JpaItemWriter<Order> orderJpaWriter() {
JpaItemWriter<Order> writer = new JpaItemWriter<>();
writer.setEntityManagerFactory(entityManagerFactory);
return writer;
}
// ── Step ────────────────────────────────────────────────────
@Bean
public Step orderImportStep() {
return new StepBuilder("orderImportStep", jobRepository)
.<OrderCsvDto, Order>chunk(500, transactionManager) // 500건씩 커밋
.reader(orderCsvReader(null))
.processor(orderProcessor())
.writer(orderJpaWriter())
.faultTolerant()
.skip(FlatFileParseException.class) // 파싱 오류는 건너뜀
.skipLimit(10) // 최대 10건까지 스킵 허용
.retry(DataAccessException.class) // DB 일시 오류는 재시도
.retryLimit(3)
.build();
}
// ── Job ─────────────────────────────────────────────────────
@Bean
public Job orderImportJob(Step orderImportStep, JobExecutionListener listener) {
return new JobBuilder("orderImportJob", jobRepository)
.start(orderImportStep)
.listener(listener)
.incrementer(new RunIdIncrementer()) // 매번 새 JobInstance 생성
.build();
}
}
JobExecutionListener — 실행 전후 처리
@Slf4j
@Component
public class OrderJobListener implements JobExecutionListener {
@Override
public void beforeJob(JobExecution jobExecution) {
log.info("=== 배치 시작: {} / 파라미터: {}",
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();
long skipCount = jobExecution.getStepExecutions().stream()
.mapToLong(StepExecution::getSkipCount).sum();
log.info("=== 배치 완료: {} | 읽기: {} | 쓰기: {} | 스킵: {} | 상태: {}",
jobExecution.getJobInstance().getJobName(),
readCount, writeCount, skipCount, status);
if (status == BatchStatus.FAILED) {
// 알림 발송, 장애 처리 로직
log.error("배치 실패! 원인: {}", jobExecution.getAllFailureExceptions());
}
}
}
3. Tasklet 기반 Step — 파일 정리 후처리
Chunk 방식이 아닌 단순 작업(파일 이동, 디렉토리 정리 등)에는 Tasklet을 사용합니다.
@Slf4j
@Component
@StepScope
@RequiredArgsConstructor
public class ArchiveFileTasklet implements Tasklet {
@Value("#{jobParameters['filePath']}")
private String filePath;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
Path source = Path.of(filePath);
Path archive = Path.of(filePath.replace("/incoming/", "/archive/"));
try {
Files.createDirectories(archive.getParent());
Files.move(source, archive, StandardCopyOption.REPLACE_EXISTING);
log.info("파일 아카이빙 완료: {} → {}", source, archive);
} catch (IOException e) {
throw new RuntimeException("파일 아카이빙 실패", e);
}
return RepeatStatus.FINISHED; // 작업 완료
}
}
// OrderBatchConfig에 Step 추가
@Bean
public Step archiveFileStep(ArchiveFileTasklet archiveFileTasklet) {
return new StepBuilder("archiveFileStep", jobRepository)
.tasklet(archiveFileTasklet, transactionManager)
.build();
}
// Job에 Step 체이닝
@Bean
public Job orderImportJob(Step orderImportStep, Step archiveFileStep,
JobExecutionListener listener) {
return new JobBuilder("orderImportJob", jobRepository)
.start(orderImportStep)
.next(archiveFileStep) // Step1 성공 후 Step2 실행
.listener(listener)
.incrementer(new RunIdIncrementer())
.build();
}
4. DB → DB 배치 — JpaPagingItemReader
DB에서 읽어 가공 후 다시 DB에 저장하는 패턴 (정산, 집계 등).
// 월별 주문 집계 배치
@Bean
@StepScope
public JpaPagingItemReader<Order> monthlyOrderReader(
@Value("#{jobParameters['targetMonth']}") String targetMonth) {
LocalDateTime start = YearMonth.parse(targetMonth).atDay(1).atStartOfDay();
LocalDateTime end = YearMonth.parse(targetMonth).atEndOfMonth().atTime(23, 59, 59);
Map<String, Object> params = new HashMap<>();
params.put("start", start);
params.put("end", end);
return new JpaPagingItemReaderBuilder<Order>()
.name("monthlyOrderReader")
.entityManagerFactory(entityManagerFactory)
.queryString("""
SELECT o FROM Order o
WHERE o.orderedAt BETWEEN :start AND :end
ORDER BY o.customerId
""")
.parameterValues(params)
.pageSize(1000) // 1000건씩 페이징 조회
.build();
}
@Bean
@StepScope
public ItemProcessor<Order, MonthlySummary> summaryProcessor() {
Map<String, MonthlySummary> summaryMap = new HashMap<>();
return order -> {
summaryMap.merge(
order.getCustomerId(),
MonthlySummary.of(order.getCustomerId(), order.getTotalAmount(), 1),
(existing, newItem) -> existing.addAmount(order.getTotalAmount())
);
return summaryMap.get(order.getCustomerId());
};
}
5. 배치 메타데이터 DB 설정
Spring Batch는 Job 실행 이력을 DB에 저장합니다. 운영 환경에서는 반드시 실제 DB를 사용해야 합니다.
# application.yml
spring:
batch:
job:
enabled: false # 애플리케이션 시작 시 자동 실행 방지 (Quartz가 실행)
jdbc:
initialize-schema: always # 메타 테이블 자동 생성 (개발)
# initialize-schema: never # 운영 환경에서는 never로 변경
datasource:
url: jdbc:postgresql://localhost:5432/batchdb
username: batch_user
password: secret
-- 운영 DB에 메타 테이블 수동 생성 시
-- org/springframework/batch/core/schema-postgresql.sql 파일 실행
-- (spring-batch-core jar 내부 포함)
6. Quartz Scheduler 연동
Quartz란?
Quartz는 Java 생태계에서 가장 널리 쓰이는 엔터프라이즈급 스케줄링 라이브러리입니다.
@Scheduled와의 차이:
| 구분 | @Scheduled | Quartz |
|---|---|---|
| 클러스터 지원 | ❌ 불가 | ✅ DB 기반 클러스터 |
| 실행 이력 | ❌ 없음 | ✅ DB 저장 |
| 동적 등록/수정 | ❌ 불가 | ✅ 런타임 변경 가능 |
| 재시작 시 Misfire | ❌ 누락 | ✅ 정책 설정 가능 |
| 복잡한 Cron | △ 제한적 | ✅ 완전 지원 |
Quartz 설정
# application.yml
spring:
quartz:
job-store-type: jdbc # 실행 이력을 DB에 저장 (클러스터 필수)
# job-store-type: memory # 메모리 저장 (단일 인스턴스 개발용)
jdbc:
initialize-schema: always # Quartz 메타 테이블 자동 생성
properties:
org.quartz.scheduler.instanceName: BatchScheduler
org.quartz.scheduler.instanceId: AUTO
org.quartz.jobStore.isClustered: true # 클러스터 모드
org.quartz.jobStore.clusterCheckinInterval: 10000
org.quartz.threadPool.threadCount: 5 # 동시 실행 스레드 수
Spring Batch Job을 실행하는 Quartz Job 구현
/**
* Quartz Job → Spring Batch Job 실행 브리지
* ApplicationContext에서 JobLauncher와 Job 빈을 꺼내서 실행
*/
@Slf4j
public class SpringBatchQuartzJob implements Job {
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
try {
// Quartz JobDataMap에서 스프링 빈 참조 획득
ApplicationContext appContext = (ApplicationContext)
context.getScheduler().getContext().get("applicationContext");
JobLauncher jobLauncher = appContext.getBean(JobLauncher.class);
org.springframework.batch.core.Job batchJob =
appContext.getBean(context.getMergedJobDataMap().getString("jobName"),
org.springframework.batch.core.Job.class);
// JobParameters 구성 (매번 고유값 필요)
JobParameters params = new JobParametersBuilder()
.addString("filePath", resolveFilePath())
.addString("targetMonth", YearMonth.now().minusMonths(1).toString())
.addLong("timestamp", System.currentTimeMillis()) // 고유 파라미터
.toJobParameters();
JobExecution execution = jobLauncher.run(batchJob, params);
log.info("배치 실행 완료: {} → {}", batchJob.getName(), execution.getStatus());
} catch (Exception e) {
log.error("Quartz Job 실행 실패", e);
throw new JobExecutionException(e);
}
}
private String resolveFilePath() {
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
return "/data/incoming/orders_" + today + ".csv";
}
}
Quartz SchedulerContext에 ApplicationContext 주입
@Configuration
public class QuartzConfig {
/**
* Quartz Scheduler에 Spring ApplicationContext를 주입해
* SpringBatchQuartzJob 내부에서 스프링 빈을 사용할 수 있게 합니다.
*/
@Bean
public SchedulerFactoryBeanCustomizer schedulerFactoryBeanCustomizer(
ApplicationContext applicationContext) {
return schedulerFactoryBean ->
schedulerFactoryBean.setApplicationContextSchedulerContextKey("applicationContext");
}
}
JobDetail & Trigger 등록
@Configuration
public class OrderBatchSchedulerConfig {
// ── JobDetail: 실행할 Job 정보 ─────────────────────────────
@Bean
public JobDetail orderImportJobDetail() {
JobDataMap dataMap = new JobDataMap();
dataMap.put("jobName", "orderImportJob");
return JobBuilder.newJob(SpringBatchQuartzJob.class)
.withIdentity("orderImportJobDetail", "batchGroup")
.withDescription("일별 주문 CSV 적재 배치")
.usingJobData(dataMap)
.storeDurably() // Trigger가 없어도 JobDetail 유지
.build();
}
// ── Trigger: 실행 시점 정의 ────────────────────────────────
@Bean
public Trigger orderImportTrigger(JobDetail orderImportJobDetail) {
return TriggerBuilder.newTrigger()
.forJob(orderImportJobDetail)
.withIdentity("orderImportTrigger", "batchGroup")
.withDescription("매일 새벽 2시 실행")
.withSchedule(
CronScheduleBuilder
.cronSchedule("0 0 2 * * ?") // 초 분 시 일 월 요일
.withMisfireHandlingInstructionFireAndProceed() // 누락 시 즉시 1회 실행
)
.build();
}
// ── 월별 정산 배치 (매월 1일 새벽 3시) ──────────────────────
@Bean
public JobDetail monthlySettlementJobDetail() {
JobDataMap dataMap = new JobDataMap();
dataMap.put("jobName", "monthlySettlementJob");
return JobBuilder.newJob(SpringBatchQuartzJob.class)
.withIdentity("monthlySettlementJobDetail", "batchGroup")
.usingJobData(dataMap)
.storeDurably()
.build();
}
@Bean
public Trigger monthlySettlementTrigger(JobDetail monthlySettlementJobDetail) {
return TriggerBuilder.newTrigger()
.forJob(monthlySettlementJobDetail)
.withIdentity("monthlySettlementTrigger", "batchGroup")
.withSchedule(CronScheduleBuilder.cronSchedule("0 0 3 1 * ?"))
.build();
}
}
7. Quartz Cron 표현식 빠른 참조
┌───── 초 (0-59)
│ ┌──── 분 (0-59)
│ │ ┌─── 시 (0-23)
│ │ │ ┌── 일 (1-31)
│ │ │ │ ┌─ 월 (1-12 or JAN-DEC)
│ │ │ │ │ ┌ 요일 (1-7 or SUN-SAT)
│ │ │ │ │ │
* * * * * *
| 표현식 | 설명 |
|---|---|
0 0 2 * * ? | 매일 새벽 2시 |
0 30 8 * * MON-FRI | 평일 오전 8시 30분 |
0 0 3 1 * ? | 매월 1일 새벽 3시 |
0 0/30 9-18 * * ? | 오전 9시~오후 6시, 30분마다 |
0 0 12 ? * SUN | 매주 일요일 정오 |
0 0 0 L * ? | 매월 마지막 날 자정 |
8. 수동 Job 실행 (REST API 트리거)
운영 중 긴급 재처리가 필요할 때 API로 배치를 즉시 실행합니다.
@RestController
@RequestMapping("/admin/batch")
@RequiredArgsConstructor
public class BatchAdminController {
private final JobLauncher jobLauncher;
private final Job orderImportJob;
private final Scheduler quartzScheduler;
// Spring Batch 직접 실행
@PostMapping("/order-import")
public ResponseEntity<String> runOrderImport(
@RequestParam String filePath) throws Exception {
JobParameters params = new JobParametersBuilder()
.addString("filePath", filePath)
.addLong("timestamp", System.currentTimeMillis())
.toJobParameters();
JobExecution execution = jobLauncher.run(orderImportJob, params);
return ResponseEntity.ok("배치 실행: " + execution.getStatus());
}
// Quartz Trigger 즉시 실행
@PostMapping("/trigger/{triggerName}")
public ResponseEntity<String> fireTrigger(
@PathVariable String triggerName) throws SchedulerException {
quartzScheduler.triggerJob(
new JobKey(triggerName + "Detail", "batchGroup")
);
return ResponseEntity.ok("Quartz Job 즉시 실행 완료");
}
// Quartz Job 목록 조회
@GetMapping("/jobs")
public ResponseEntity<List<Map<String, Object>>> listJobs() throws SchedulerException {
List<Map<String, Object>> result = new ArrayList<>();
for (String groupName : quartzScheduler.getJobGroupNames()) {
for (JobKey jobKey : quartzScheduler.getJobKeys(GroupMatcher.groupEquals(groupName))) {
List<? extends Trigger> triggers = quartzScheduler.getTriggersOfJob(jobKey);
triggers.forEach(trigger -> {
Map<String, Object> info = new LinkedHashMap<>();
info.put("job", jobKey.getName());
info.put("group", jobKey.getGroup());
info.put("nextFire", trigger.getNextFireTime());
info.put("previousFire", trigger.getPreviousFireTime());
result.add(info);
});
}
}
return ResponseEntity.ok(result);
}
}
9. 멀티 스레드 파티셔닝 (대용량 병렬 처리)
1억 건 처리 시 파티셔닝으로 데이터를 분할해 병렬 처리합니다.
@Bean
public Step partitionedOrderStep(Step orderImportStep) {
return new StepBuilder("partitionedOrderStep", jobRepository)
.partitioner("orderImportStep", new RangePartitioner(totalCount, 10)) // 10개 파티션
.step(orderImportStep)
.taskExecutor(new SimpleAsyncTaskExecutor()) // 비동기 병렬 실행
.gridSize(10)
.build();
}
// 파티션 범위 분할기
public class RangePartitioner implements Partitioner {
private final long totalCount;
private final int gridSize;
@Override
public Map<String, ExecutionContext> partition(int gridSize) {
Map<String, ExecutionContext> partitions = new HashMap<>();
long pageSize = totalCount / gridSize;
for (int i = 0; i < gridSize; i++) {
ExecutionContext context = new ExecutionContext();
context.putLong("minId", i * pageSize + 1);
context.putLong("maxId", (i + 1) * pageSize);
partitions.put("partition_" + i, context);
}
return partitions;
}
}
고수 팁: 운영 배치 체크리스트
배치 설계 원칙
- Chunk size는 메모리와 트랜잭션 크기를 고려해 500~2,000 사이로 설정
@StepScope/@JobScope빈은 Step/Job 실행 시점에 생성 — 멀티 스레드 안전JobParameters에 타임스탬프 추가 → 동일 Job 재실행 가능
Quartz 클러스터 운영
job-store-type: jdbc로 설정하면 여러 인스턴스가 DB를 통해 중복 실행을 방지instanceId: AUTO설정으로 각 노드에 고유 ID 자동 부여- Misfire 정책은 반드시 명시 —
MISFIRE_INSTRUCTION_FIRE_AND_PROCEED권장
모니터링
- Spring Batch Admin 또는 Spring Boot Actuator +
/actuator/metrics연동 - 배치 실패 시 Slack/메일 알림은
JobExecutionListener.afterJob()에 구현 - Quartz 실행 이력은
QRTZ_*테이블에서 직접 조회 가능
JDBC 단순 배치가 필요하다면 JdbcTemplate.batchUpdate()를, 복잡한 비즈니스 배치는 Spring Batch + Quartz 조합을 사용하세요.