본문으로 건너뛰기

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배치 작업 전체 단위
StepJob의 독립적인 실행 단계
ItemReader데이터 소스에서 한 건씩 읽기
ItemProcessor읽은 데이터 변환/검증
ItemWriter가공된 데이터 저장
JobRepository실행 이력·상태 메타데이터 저장 (DB)
JobLauncherJob 실행 진입점

의존성 추가

<!-- 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와의 차이:

구분@ScheduledQuartz
클러스터 지원❌ 불가✅ 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 조합을 사용하세요.