배치 처리 완벽 심화 가이드 1편: 배치 기본 원칙과 Spring Batch 완전 정복
이 글은 배치 처리 심화 가이드 2부작 중 1편이다. 배치 처리의 본질적 개념부터 Spring Batch의 내부 아키텍처, 핵심 기능, 그리고 실전 운영까지 한 편에 담는다. 단순히 “스케줄러로 돌리면 배치”가 아니라, 왜 배치가 필요하고, 어떤 원칙을 지켜야 하며, Spring Batch는 내부적으로 어떻게 동작하는지 깊이 있게 정리한다.
- 1편 (현재): 배치 기본 원칙 + Spring Batch 아키텍처 / 핵심 기능 / 실전 운영
- 2편: Python 배치 + Airflow 아키텍처·운영 + Spring Batch vs Airflow 비교 + 트러블슈팅
1. 배치 처리란 무엇인가
1.1 배치 처리의 정의와 역사
배치 처리(Batch Processing) 란 데이터를 모아서(batch) 한꺼번에 처리하는 방식이다. 사용자의 실시간 요청에 즉시 응답하는 온라인 트랜잭션 처리(OLTP)와 대비되는 개념이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| ★ 배치 처리의 본질
┌─────────────────────────────────────────────────────┐
│ 온라인 처리 (OLTP) │
│ 요청 → 즉시 처리 → 즉시 응답 │
│ 예: 사용자가 "결제" 버튼 클릭 → 즉시 결제 완료 │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 배치 처리 (Batch) │
│ 데이터 축적 → 특정 시점에 일괄 처리 → 결과 저장 │
│ 예: 하루치 주문 데이터 → 새벽 2시 정산 → 정산 결과 저장 │
└─────────────────────────────────────────────────────┘
|
배치 처리의 역사는 컴퓨터의 역사와 함께한다.
| 시대 |
배치 형태 |
특징 |
| 1950~60년대 |
펀치카드 배치 |
작업을 카드에 기록 → 순차 투입 → 결과 출력 |
| 1970~80년대 |
메인프레임 COBOL |
JCL(Job Control Language)로 배치 정의, 야간 윈도우 |
| 1990~2000년대 |
cron + 쉘 스크립트 / SQL 프로시저 |
Unix cron 기반, 저장 프로시저 대량 처리 |
| 2000년대 후반 |
Hadoop MapReduce |
분산 배치의 시작, TB~PB 단위 처리 |
| 2010년대~ |
Spring Batch / Airflow / Spark |
프레임워크화, 오케스트레이션, 클라우드 네이티브 |
실무 팁: 놀랍게도 2026년 현재에도 많은 금융권 시스템은 COBOL 배치를 운영하고 있다. “구식”이라는 이유만으로 배치 자체를 경시하면 안 된다 — 배치는 기업 IT의 근간이다.
1.2 실시간 vs 배치 vs 마이크로배치 비교
1
2
3
4
5
6
7
8
9
| ★ 처리 방식 스펙트럼
실시간(Real-time) 마이크로배치(Micro-batch) 배치(Batch)
│ │ │
▼ ▼ ▼
이벤트 단위 즉시 수초~수분 단위 모아서 수시간~일 단위 대량
지연: ms~초 지연: 초~분 지연: 분~시간
예: Kafka Streams 예: Spark Streaming 예: Spring Batch
Flink (mini-batch) Airflow
|
| 비교 항목 |
실시간 |
마이크로배치 |
배치 |
| 처리 단위 |
이벤트 1건 |
수초~수분 윈도우 |
전체 데이터셋/큰 덩어리 |
| 지연 시간 |
ms ~ 초 |
초 ~ 분 |
분 ~ 시간 |
| 처리량 |
중간 |
높음 |
매우 높음 (벌크 최적화) |
| 리소스 사용 |
상시 점유 |
간헐적 피크 |
집중 사용 후 해제 |
| 복잡도 |
높음 (상태 관리, exactly-once) |
중간 |
상대적으로 낮음 |
| 적합 사례 |
실시간 대시보드, 사기 탐지 |
준실시간 분석, 로그 집계 |
정산, ETL, 보고서 |
| 장애 복구 |
복잡 (오프셋 관리) |
중간 (체크포인트) |
단순 (재실행) |
⚠️ 주의: “배치가 느리니까 다 실시간으로 바꿔야 한다”는 잘못된 믿음이다. 정산, 대사(reconciliation), 대량 보고서 등은 배치가 훨씬 효율적이고 안전하다.
1.3 배치 처리가 필요한 대표 시나리오
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| ★ 배치가 빛나는 4대 시나리오
┌──────────────────┐ ┌──────────────────┐
│ 1. 정산/대사 │ │ 2. ETL/ELT │
│ 하루치 주문 → │ │ 운영 DB → │
│ 판매자별 정산금 │ │ 분석 DW로 이관 │
│ 계산/입금 │ │ 변환/적재 │
└──────────────────┘ └──────────────────┘
┌──────────────────┐ ┌──────────────────┐
│ 3. 보고서 생성 │ │ 4. 대량 알림/처리 │
│ 월간 매출 리포트 │ │ 100만 사용자에게 │
│ 통계 집계 │ │ 프로모션 알림 발송 │
│ PDF/Excel 생성 │ │ 쿠폰 일괄 지급 │
└──────────────────┘ └──────────────────┘
|
시나리오 1: 결제 정산
1
2
3
4
5
6
| [하루치 주문 데이터] → [배치 Job 실행 (새벽 2시)]
│
├─ 판매자별 매출 집계
├─ 수수료 계산
├─ 부가세 분리
└─ 정산 결과 → 은행 이체 파일 생성
|
시나리오 2: ETL(Extract-Transform-Load)
1
2
3
| [운영 DB] ──Extract──→ [Staging Area] ──Transform──→ [Data Warehouse]
MySQL 임시 테이블 변환/정제 BigQuery, Redshift
PostgreSQL CSV/Parquet 분석용 테이블
|
1.4 배치 아키텍처 패턴
전통적 vs 현대적 배치
1
2
3
4
5
6
7
8
9
10
11
12
13
| ★ 전통적 배치 파이프라인
[소스 DB] ──→ [배치 Job] ──→ [타겟 DB/파일]
│
cron 스케줄링, 단일 서버 실행, 순차 처리
★ 현대적 배치 파이프라인
┌─ [Job A] ──→ [DW]
[Scheduler] ──→ [DAG] ── [Job B] ──→ [S3] ← 의존 관계 관리
Airflow │ └─ [Job C] ──→ [API] ← 병렬 실행
(오케스트레이터) │ ← 재시도/알림
└─ 모니터링 / 로깅 / 알림
|
Lambda vs Kappa 아키텍처
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| ★ Lambda Architecture
┌─── Batch Layer ──→ [Batch View] ───┐
│ (전체 재계산, (정확함) │
[Data Source] ──┤ 높은 지연) ├──→ [Query]
│ │
└─── Speed Layer ──→ [Real-time View] ─┘
(실시간 근사, (빠름)
낮은 지연)
→ 쿼리 시 두 뷰를 합쳐서 결과 제공
→ 단점: 같은 로직을 두 번 구현해야 함
★ Kappa Architecture
[Data Source] ──→ [Stream Processing] ──→ [Serving Layer] ──→ [Query]
(Kafka + Flink) (DB/Cache)
모든 것을 스트림으로
배치가 필요하면 스트림을 처음부터 재생(replay)
→ 단일 코드 경로
→ 단점: 대규모 재처리 시 비용 높음
|
| 아키텍처 |
장점 |
단점 |
적합 케이스 |
| 전통적 배치 |
단순, 이해 쉬움 |
확장 어려움, SLA 한계 |
소규모, 단순 ETL |
| 현대적 배치 |
의존 관리, 모니터링 |
오케스트레이터 운영 부담 |
복잡한 데이터 파이프라인 |
| Lambda |
정확성 + 실시간 |
이중 로직 유지보수 |
실시간+배치 모두 필요 |
| Kappa |
단일 코드 경로 |
재처리 비용 |
스트림 중심 시스템 |
2. 배치 처리 핵심 설계 원칙
2.1 멱등성 (Idempotency)
멱등성이란 같은 배치를 여러 번 실행해도 결과가 동일한 성질이다. 배치 처리에서 가장 중요한 원칙이라 해도 과언이 아니다.
1
2
3
4
5
6
7
8
9
10
11
| ★ 멱등성이 없는 배치 (위험!)
1차 실행: 매출 = 1,000,000 → INSERT 정산 레코드
2차 실행: 매출 = 1,000,000 → INSERT 정산 레코드 ← 중복!
→ 정산금이 2배로 잡힘 💀
★ 멱등성이 있는 배치 (안전!)
1차 실행: 매출 = 1,000,000 → UPSERT 정산 레코드 (key: 날짜+판매자)
2차 실행: 매출 = 1,000,000 → UPSERT 정산 레코드 → 같은 key → UPDATE
→ 결과 동일 ✓
|
멱등성 확보 전략:
| 전략 |
설명 |
예시 |
| UPSERT |
INSERT + 중복 시 UPDATE |
INSERT ... ON DUPLICATE KEY UPDATE |
| DELETE-INSERT |
대상 범위 삭제 후 재삽입 |
DELETE WHERE date = '2026-04-13' → INSERT |
| 처리 ID 기반 |
고유 배치 실행 ID로 중복 체크 |
batch_execution_id 컬럼 활용 |
| 타겟 테이블 파티션 교체 |
파티션 단위 SWAP |
새 파티션 생성 → EXCHANGE PARTITION |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // ★ UPSERT 기반 멱등성 예시 (Spring Batch ItemWriter)
@Bean
public JdbcBatchItemWriter<Settlement> settlementWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<Settlement>()
.dataSource(dataSource)
.sql("""
INSERT INTO settlement (seller_id, settle_date, amount, fee)
VALUES (:sellerId, :settleDate, :amount, :fee)
ON DUPLICATE KEY UPDATE
amount = VALUES(amount),
fee = VALUES(fee),
updated_at = NOW()
""")
.beanMapped()
.build();
}
|
2.2 재시작 가능성과 체크포인트
배치 Job이 중간에 실패했을 때 처음부터 다시 시작하지 않고 실패 지점부터 재개할 수 있어야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| ★ 체크포인트 없는 배치
[100만 건 처리] ──→ 80만 건째 실패 💥
→ 처음부터 다시 100만 건 처리 😱
★ 체크포인트 있는 배치 (Spring Batch 기본!)
[100만 건 처리] ──→ 80만 건째 실패 💥
→ ExecutionContext에 마지막 처리 위치 저장
→ 재시작 시 80만 건째부터 이어서 처리 ✓
Chunk 1 (1~1000건) → 처리 완료 → ExecutionContext 업데이트 → COMMIT
Chunk 2 (1001~2000건) → 처리 완료 → ExecutionContext 업데이트 → COMMIT
Chunk 3 (2001~3000건) → 2500건째 실패 💥
→ 마지막 성공 Chunk: 2 (2000건)
재시작 →
Chunk 3 (2001~3000건) → 처리 완료 ✓ ← 2001건부터 재개
|
2.3 Chunk 처리와 대용량 데이터 전략
대용량 데이터를 한 번에 메모리에 올리면 OOM이 발생한다. Chunk 단위로 나눠서 처리하는 것이 핵심이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| ★ Chunk 기반 처리 흐름
┌──────────────────────────────────────────────────┐
│ 하나의 Chunk (1000건) │
│ │
│ [Read 1000건] → [Process 1000건] → [Write 1000건] │
│ ↑ │ │
│ │ 하나의 트랜잭션 │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
│ │
│ COMMIT │
▼ ▼
┌──────────────────────────────────────────────────┐
│ 다음 Chunk (1000건) │
│ [Read 1000건] → [Process 1000건] → [Write 1000건] │
└──────────────────────────────────────────────────┘
|
커서(Cursor) vs 페이징(Paging) 비교:
| 항목 |
커서 기반 |
페이징 기반 |
| 동작 |
DB 커서를 열어두고 fetchSize만큼 가져옴 |
LIMIT/OFFSET으로 매번 쿼리 |
| DB 연결 |
처리 완료까지 연결 유지 |
쿼리마다 연결 획득/반납 |
| 정렬 보장 |
커서가 보장 |
ORDER BY 필수 |
| 다중 스레드 |
불가 (커서 공유 불가) |
가능 |
| 대표 구현체 |
JdbcCursorItemReader |
JdbcPagingItemReader |
실무 팁: 싱글 스레드 + 안정적 순서 보장이 필요하면 커서, 멀티스레드 + 높은 처리량이 필요하면 페이징을 선택한다.
2.4 에러 핸들링 전략
1
2
3
4
5
6
7
8
9
10
11
| ★ 배치 에러 핸들링 전략 피라미드
┌───────────┐
│ Skip │ ← 무시 가능한 에러 (데이터 포맷 오류 등)
├───────────┤
│ Retry │ ← 일시적 에러 (네트워크 타임아웃 등)
├───────────┤
│ DLQ │ ← 처리 불가 데이터 → 별도 큐/테이블에 격리
├───────────┤
│ 보상 TX │ ← 부분 실패 → 이전 처리 롤백/보상
└───────────┘
|
| 전략 |
설명 |
사용 시점 |
| Skip |
에러 건을 건너뛰고 계속 진행 |
일부 불량 데이터가 전체를 막으면 안 될 때 |
| Retry |
지정 횟수만큼 재시도 |
네트워크 오류, 일시적 DB 락 등 |
| DLQ |
실패 건을 별도 저장소에 격리 |
나중에 수동 확인/재처리 필요 시 |
| 보상 트랜잭션 |
이미 처리된 부분을 되돌림 |
정산/송금 등 부분 성공이 허용 안 될 때 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // ★ Spring Batch faultTolerant 설정 예시
@Bean
public Step settlementStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager) {
return new StepBuilder("settlementStep", jobRepository)
.<Order, Settlement>chunk(1000, transactionManager)
.reader(orderReader())
.processor(settlementProcessor())
.writer(settlementWriter())
.faultTolerant()
.skipLimit(100) // 최대 100건 스킵
.skip(DataFormatException.class) // 이 예외는 스킵
.retryLimit(3) // 최대 3회 재시도
.retry(TransientDataAccessException.class) // 이 예외는 재시도
.noSkip(SettlementAmountException.class) // 이 예외는 절대 스킵 불가
.listener(skipListener()) // 스킵된 건 로깅
.build();
}
|
2.5 배치 윈도우와 SLA
배치 윈도우(Batch Window) 란 배치가 실행될 수 있는 시간대를 말한다. SLA(Service Level Agreement) 는 배치가 반드시 완료되어야 하는 시한이다.
1
2
3
4
5
6
7
8
| ★ 배치 윈도우 예시 (금융 시스템)
00:00 02:00 04:00 06:00 08:00 09:00
│ │ │ │ │ │
│ │◀── 배치 윈도우 ──▶│ │ │
│ │ 정산 배치 실행 │ │ │
│ │ │ ◀ SLA: 06:00까지 완료 ▶
│ │ │ │ 영업 시작 │
|
| 개념 |
설명 |
예시 |
| 배치 윈도우 |
배치가 실행 가능한 시간대 |
02:00 ~ 06:00 (4시간) |
| SLA |
반드시 완료해야 하는 시한 |
06:00까지 정산 완료 |
| 런타임 |
실제 실행 시간 |
평균 1.5시간, 최대 3시간 |
| 여유 시간 |
윈도우 - 최대 런타임 |
4시간 - 3시간 = 1시간 |
실무 팁: 배치 윈도우에 최소 30% 이상 여유를 확보하라. 데이터 증가, DB 부하 등으로 런타임은 점진적으로 늘어난다. 여유 없이 꽉 찬 배치 윈도우는 언젠가 반드시 SLA를 위반한다.
3. Spring Batch 아키텍처 Deep Dive
3.1 전체 아키텍처
Spring Batch는 JSR 352(Java Batch Processing) 표준을 참고하여 설계된 배치 프레임워크이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| ★ Spring Batch 전체 아키텍처
┌──────────────────────────────────────────────────────────┐
│ Application │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ JobLauncher │ │
│ │ (Job 실행 진입점, run() 호출) │ │
│ └─────────────────┬────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Job │ │
│ │ (하나의 배치 작업 단위) │ │
│ │ │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ Step 1 │→│ Step 2 │→│ Step 3 │ │ │
│ │ └────────┘ └────────┘ └────────┘ │ │
│ └──────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ JobRepository │ │
│ │ (메타데이터 저장 — 실행 이력, 상태) │ │
│ │ H2 / MySQL / PostgreSQL 등 │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
|
| 구성 요소 |
역할 |
비유 |
| JobLauncher |
Job을 실행하는 진입점 |
공장의 시작 버튼 |
| Job |
하나의 배치 작업 전체 |
하나의 조립 라인 |
| Step |
Job 내의 개별 처리 단계 |
조립 라인의 각 공정 |
| JobRepository |
실행 이력·상태 메타데이터 저장 |
공장의 생산 일지 |
3.2 Job / JobInstance / JobExecution 관계
Spring Batch에서 가장 혼동하기 쉬운 개념이 이 세 가지의 관계이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| ★ Job → JobInstance → JobExecution 관계
Job: "일일정산Job"
│
├─ JobInstance: "일일정산Job + date=2026-04-13" (논리적 실행 단위)
│ ├─ JobExecution #1: FAILED (04-13 02:00 실행, 에러로 실패)
│ └─ JobExecution #2: COMPLETED (04-13 02:30 재실행, 성공)
│
├─ JobInstance: "일일정산Job + date=2026-04-14"
│ └─ JobExecution #1: COMPLETED (04-14 02:00 실행, 성공)
│
└─ JobInstance: "일일정산Job + date=2026-04-15"
└─ JobExecution #1: STARTED (현재 실행 중...)
|
| 개념 |
설명 |
식별 기준 |
| Job |
배치 작업의 정의 (설계도) |
Job 이름 |
| JobInstance |
특정 파라미터로의 논리적 실행 |
Job 이름 + JobParameters |
| JobExecution |
실제 물리적 실행 시도 |
JobInstance + 실행 시각 |
| JobParameters |
실행 파라미터 (날짜, 파일명 등) |
key-value 쌍 |
⚠️ 주의: 같은 JobParameters로 이미 COMPLETED된 JobInstance는 다시 실행할 수 없다. 재실행이 필요하면 파라미터를 변경하거나 allowStartIfComplete(true) 설정이 필요하다.
3.3 Step과 StepExecution (Tasklet vs Chunk)
Step은 Job 내의 실제 처리 단위이다. Tasklet 방식과 Chunk 방식 두 가지가 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| ★ Tasklet vs Chunk 방식 비교
┌─────────────────────────────────────┐
│ Tasklet 방식 │
│ execute() { │
│ // 모든 로직을 한 메서드에 │
│ // 파일 삭제, API 호출 등 단순 작업 │
│ return RepeatStatus.FINISHED; │
│ } │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Chunk 방식 │
│ Reader → Processor → Writer │
│ │ │ │ │
│ │ chunk-size만큼 │ │
│ │ 반복하여 처리 │ │
│ └──── 하나의 트랜잭션 ───┘ │
│ 대량 데이터 처리에 최적화 │
└─────────────────────────────────────┘
|
| 비교 항목 |
Tasklet |
Chunk |
| 처리 단위 |
전체를 한 번에 |
chunk-size만큼 반복 |
| 적합 케이스 |
파일 삭제, 초기화, API 호출 |
대량 데이터 Read → Process → Write |
| 트랜잭션 |
execute() 전체가 하나의 TX |
chunk 단위로 TX |
| 재시작 |
직접 구현 필요 |
프레임워크가 chunk 단위로 관리 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // ★ Tasklet 방식 예시 — 임시 파일 정리
@Bean
public Step cleanupStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager) {
return new StepBuilder("cleanupStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
File tempDir = new File("/data/temp/batch");
FileUtils.cleanDirectory(tempDir);
log.info("임시 디렉토리 정리 완료: {}", tempDir.getAbsolutePath());
return RepeatStatus.FINISHED;
}, transactionManager)
.build();
}
// ★ Chunk 방식 예시 — 주문 데이터 정산
@Bean
public Step settlementStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager) {
return new StepBuilder("settlementStep", jobRepository)
.<Order, Settlement>chunk(1000, transactionManager) // 1000건씩 처리
.reader(orderReader())
.processor(settlementProcessor())
.writer(settlementWriter())
.build();
}
|
StepExecution은 Step의 실제 실행을 나타내며, 다양한 상태 정보를 추적한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| ★ StepExecution이 추적하는 정보
┌─────────────────────────────────────────┐
│ StepExecution │
│ status: COMPLETED │
│ readCount: 1,000,000 │
│ writeCount: 998,750 │
│ filterCount: 1,250 │
│ skipCount: 0 │
│ commitCount: 1,000 │
│ rollbackCount: 0 │
│ startTime: 2026-04-13 02:00:00 │
│ endTime: 2026-04-13 03:27:00 │
└─────────────────────────────────────────┘
|
3.4 ExecutionContext와 상태 저장/복원
ExecutionContext는 배치 실행 중 상태를 저장하는 key-value 저장소이다. JSON으로 직렬화되어 JobRepository에 저장된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| ★ ExecutionContext의 두 가지 범위
┌──────────────────────────────────────────────┐
│ Job ExecutionContext │
│ - Job 전체에서 공유 │
│ - Step 간 데이터 전달에 사용 │
│ 예: {"totalProcessedCount": 50000} │
├──────────────────────────────────────────────┤
│ Step ExecutionContext │
│ - 해당 Step에서만 사용 │
│ - 재시작 시 복원 지점으로 활용 │
│ 예: {"currentPage": 85, "lastId": 85000} │
└──────────────────────────────────────────────┘
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| // ★ ExecutionContext 활용 예시
public class OrderItemReader implements ItemReader<Order>, StepExecutionListener {
private long lastProcessedId = 0;
@Override
public void beforeStep(StepExecution stepExecution) {
// 재시작 시 마지막 처리 ID 복원
ExecutionContext ctx = stepExecution.getExecutionContext();
if (ctx.containsKey("lastProcessedId")) {
this.lastProcessedId = ctx.getLong("lastProcessedId");
log.info("재시작 감지 — lastProcessedId={}", lastProcessedId);
}
}
@Override
public Order read() {
Order order = fetchNextOrder(lastProcessedId);
if (order != null) {
lastProcessedId = order.getId();
}
return order; // null 반환 시 읽기 종료
}
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
stepExecution.getExecutionContext()
.putLong("lastProcessedId", lastProcessedId);
return ExitStatus.COMPLETED;
}
}
|
3.5 JobRepository 메타데이터 테이블 상세
Spring Batch는 실행 이력과 상태를 관리하기 위해 6개의 메타데이터 테이블을 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| ★ Spring Batch 메타데이터 ER 다이어그램
┌─────────────────────┐ ┌──────────────────────┐
│ BATCH_JOB_INSTANCE │ │ BATCH_JOB_EXECUTION │
│─────────────────────│ │──────────────────────│
│ JOB_INSTANCE_ID (PK) │ 1 N │ JOB_EXECUTION_ID (PK) │
│ JOB_NAME │────────│ JOB_INSTANCE_ID (FK) │
│ JOB_KEY │ │ START_TIME / END_TIME │
│ VERSION │ │ STATUS / EXIT_CODE │
└─────────────────────┘ └──────┬───────────────┘
│ 1
┌─────────┴──────────┐
N ▼ N ▼
┌──────────────────────┐ ┌──────────────────────────────┐
│ BATCH_JOB_EXECUTION │ │ BATCH_STEP_EXECUTION │
│ _PARAMS │ │──────────────────────────────│
│──────────────────────│ │ STEP_EXECUTION_ID (PK) │
│ JOB_EXECUTION_ID(FK) │ │ JOB_EXECUTION_ID (FK) │
│ PARAMETER_NAME │ │ STEP_NAME │
│ PARAMETER_TYPE │ │ READ_COUNT / WRITE_COUNT │
│ PARAMETER_VALUE │ │ SKIP_COUNT / COMMIT_COUNT │
└──────────────────────┘ └──────────┬─────────────────┘
│ 1
N ▼
┌───────────────────────────┐
│ BATCH_STEP_EXECUTION │
│ _CONTEXT │
│───────────────────────────│
│ STEP_EXECUTION_ID (FK) │
│ SHORT_CONTEXT (VARCHAR) │
│ SERIALIZED_CONTEXT (TEXT) │
└───────────────────────────┘
+ BATCH_JOB_EXECUTION_CONTEXT (Job 레벨 ExecutionContext)
|
| 테이블 |
역할 |
주요 컬럼 |
BATCH_JOB_INSTANCE |
Job의 논리적 실행 단위 |
JOB_NAME, JOB_KEY |
BATCH_JOB_EXECUTION |
실제 실행 시도 |
STATUS, START/END_TIME |
BATCH_JOB_EXECUTION_PARAMS |
실행 파라미터 |
PARAMETER_NAME, VALUE |
BATCH_JOB_EXECUTION_CONTEXT |
Job 레벨 상태 저장 |
SERIALIZED_CONTEXT |
BATCH_STEP_EXECUTION |
Step 실행 상태 |
READ/WRITE/SKIP_COUNT |
BATCH_STEP_EXECUTION_CONTEXT |
Step 레벨 상태 저장 |
SERIALIZED_CONTEXT |
⚠️ 주의: 이 메타데이터 테이블은 운영 환경에서 빠르게 커진다. 주기적으로 오래된 실행 이력을 정리하는 배치(!)를 별도로 운영해야 한다.
실무 팁: BATCH_JOB_EXECUTION.STATUS가 STARTED인 채로 남은 레코드가 있으면 해당 JobInstance는 재실행이 불가능하다. 비정상 종료 시 수동으로 FAILED로 변경해야 한다.
4. Spring Batch 핵심 기능
4.1 ItemReader 패턴
ItemReader는 데이터를 한 건씩 읽어오는 인터페이스이다. read() 메서드가 null을 반환하면 읽기가 종료된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| ★ ItemReader 계층 구조
ItemReader<T> ← 최상위 인터페이스
│
├─ AbstractItemCountingItemStreamReader ← 카운트 기반 재시작 지원
│ ├─ FlatFileItemReader ← CSV/고정길이 파일
│ ├─ JdbcCursorItemReader ← DB 커서
│ └─ StaxEventItemReader ← XML
│
├─ AbstractPagingItemReader ← 페이징 기반
│ ├─ JdbcPagingItemReader ← JDBC 페이징
│ ├─ JpaPagingItemReader ← JPA 페이징
│ └─ MongoItemReader ← MongoDB
│
└─ 기타
├─ KafkaItemReader ← Kafka 토픽 읽기
├─ JsonItemReader ← JSON 파일
└─ RepositoryItemReader ← Spring Data Repository
|
| Reader |
데이터 소스 |
스레드 안전 |
적합 케이스 |
JdbcCursorItemReader |
RDBMS |
✗ |
싱글 스레드, 대량 순차 읽기 |
JdbcPagingItemReader |
RDBMS |
✓ |
멀티 스레드, 페이징 |
JpaPagingItemReader |
JPA |
✓ |
JPA 엔티티 활용 |
FlatFileItemReader |
CSV/파일 |
✗ |
파일 기반 배치 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // ★ JdbcPagingItemReader 설정 예시
@Bean
public JdbcPagingItemReader<Order> orderReader(DataSource dataSource) {
Map<String, Object> parameterValues = new HashMap<>();
parameterValues.put("status", "COMPLETED");
parameterValues.put("date", LocalDate.of(2026, 4, 13));
return new JdbcPagingItemReaderBuilder<Order>()
.name("orderReader")
.dataSource(dataSource)
.queryProvider(createQueryProvider())
.parameterValues(parameterValues)
.pageSize(1000) // 한 번에 1000건 조회
.rowMapper(new BeanPropertyRowMapper<>(Order.class))
.build();
}
// ★ FlatFileItemReader 설정 예시 (CSV)
@Bean
public FlatFileItemReader<Transaction> csvReader() {
return new FlatFileItemReaderBuilder<Transaction>()
.name("transactionCsvReader")
.resource(new ClassPathResource("transactions.csv"))
.linesToSkip(1) // 헤더 스킵
.delimited().delimiter(",")
.names("txId", "amount", "date", "type")
.targetType(Transaction.class)
.build();
}
|
4.2 ItemProcessor 패턴
ItemProcessor는 읽은 데이터를 변환하거나 필터링하는 단계이다. null을 반환하면 해당 아이템은 Writer에 전달되지 않는다(필터링).
1
2
3
4
5
6
| ★ ItemProcessor의 역할
[Read] [Process] [Write]
│ │ │
│ Order ──→ Settlement │ ← 변환
│ Order ──→ null │ ← 필터링 (Writer에 전달 안 됨)
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| // ★ 정산 Processor 예시
@Component
public class SettlementProcessor implements ItemProcessor<Order, Settlement> {
private final FeePolicy feePolicy;
@Override
public Settlement process(Order order) throws Exception {
// 필터링 — 취소된 주문은 제외
if (order.getStatus() == OrderStatus.CANCELLED) {
return null;
}
// 변환 — 주문 → 정산
BigDecimal fee = feePolicy.calculateFee(order.getAmount(), order.getSellerId());
BigDecimal settleAmount = order.getAmount().subtract(fee);
return Settlement.builder()
.sellerId(order.getSellerId())
.settleDate(order.getOrderDate())
.orderAmount(order.getAmount())
.fee(fee)
.settleAmount(settleAmount)
.build();
}
}
// ★ CompositeItemProcessor — 여러 처리 단계 체이닝
@Bean
public CompositeItemProcessor<Order, Settlement> compositeProcessor() {
CompositeItemProcessor<Order, Settlement> processor = new CompositeItemProcessor<>();
processor.setDelegates(List.of(
validationProcessor(), // 1단계: 데이터 검증
enrichmentProcessor(), // 2단계: 외부 데이터 보강
settlementProcessor() // 3단계: 정산 변환
));
return processor;
}
|
4.3 ItemWriter 패턴
ItemWriter는 chunk-size만큼 모인 데이터를 한꺼번에 저장한다. Reader/Processor가 한 건씩 처리하는 것과 달리, Writer는 List로 받아 벌크 처리한다.
1
2
3
4
5
| ★ Writer는 벌크 처리
Reader: item1, item2, ..., item1000 (한 건씩)
Processor: item1, item2, ..., item1000 (한 건씩)
Writer: [item1, item2, ..., item1000] (리스트로 한번에!)
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // ★ JdbcBatchItemWriter — 벌크 INSERT
@Bean
public JdbcBatchItemWriter<Settlement> settlementWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<Settlement>()
.dataSource(dataSource)
.sql("""
INSERT INTO settlement (seller_id, settle_date, order_amount, fee, settle_amount)
VALUES (:sellerId, :settleDate, :orderAmount, :fee, :settleAmount)
""")
.beanMapped()
.build();
}
// ★ CompositeItemWriter — 여러 대상에 동시 쓰기
@Bean
public CompositeItemWriter<Settlement> compositeWriter() {
CompositeItemWriter<Settlement> writer = new CompositeItemWriter<>();
writer.setDelegates(List.of(
dbWriter(), // 1. DB 저장
fileWriter(), // 2. 정산 파일 생성
kafkaWriter() // 3. Kafka 이벤트 발행
));
return writer;
}
|
4.4 Chunk 지향 처리 내부 동작
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| ★ Chunk 처리 내부 동작 시퀀스
┌─────────┐ ┌──────────┐ ┌───────────┐ ┌──────────┐ ┌────────────┐
│ChunkStep │ │ Reader │ │ Processor │ │ Writer │ │ TX │
└────┬─────┘ └─────┬────┘ └─────┬─────┘ └─────┬────┘ └──────┬─────┘
│ │ │ │ │
│ ① TX 시작 │ │ │ beginTx│
│────────────────┼───────────────┼────────────────┼──────────────▶│
│ │ │ │ │
│ ② read() │ │ │ │
│───────────────▶│ item1 │ │ │
│◀───────────────│ │ │ │
│ ③ process() │ │ │ │
│────────────────┼──────────────▶│ result1 │ │
│◀───────────────┼───────────────│ │ │
│ │ │ │ │
│ ... ②③ 반복 (chunk-size만큼) ... │ │
│ │ │ │ │
│ ④ write([results]) │ │ │
│────────────────┼───────────────┼───────────────▶│ │
│ │ │ │ │
│ ⑤ TX 커밋 │ │ │ commit │
│────────────────┼───────────────┼────────────────┼──────────────▶│
│ │ │ │ │
│ ⑥ 다음 chunk로 반복... │ │ │
|
Chunk 실패 시 동작:
1
2
3
4
5
6
7
8
| ★ faultTolerant + skip 설정 시 Write 에러 발생
Chunk 2: [Read 1000건] → [Process] → [Write 에러 💥]
│
└→ Chunk 전체 ROLLBACK
→ 1건씩 재시도 (scan mode)
→ 에러 건만 skip
→ 나머지 999건 COMMIT
|
⚠️ 주의: scan mode에서 1000건을 1건씩 쓰므로 성능이 급격히 저하된다. skip이 빈번하다면 데이터 품질 문제를 근본적으로 해결해야 한다.
4.5 Skip과 Retry 메커니즘
1
2
3
4
5
6
7
8
9
10
| ★ Skip/Retry 동작 흐름
Skip (Read): Read 에러 💥 → 해당 건 Skip → 다음 Read
Skip (Process): Read → Process 에러 💥 → 해당 건 Skip → 다음 Read
Skip (Write): Read → Process → Write 에러 💥 → 청크 롤백 → 1건씩 재시도 → 에러 건 Skip
Retry:
1차: Process/Write → TransientException 💥 → 재시도 (backoff 대기)
2차: Process/Write → TransientException 💥 → 재시도
3차: Process/Write → 성공 ✓ (또는 retryLimit 도달 → Skip 또는 Step 실패)
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // ★ Skip + Retry 고급 설정
@Bean
public Step orderProcessingStep(JobRepository jobRepository,
PlatformTransactionManager tm) {
return new StepBuilder("orderProcessingStep", jobRepository)
.<Order, Settlement>chunk(500, tm)
.reader(orderReader())
.processor(settlementProcessor())
.writer(settlementWriter())
.faultTolerant()
// Skip 설정
.skipLimit(200)
.skip(InvalidDataException.class)
.skip(DataFormatException.class)
.noSkip(SettlementCalculationException.class) // 정산 오류는 절대 스킵 불가
// Retry 설정
.retryLimit(3)
.retry(DeadlockLoserDataAccessException.class)
.retry(OptimisticLockingFailureException.class)
.backOffPolicy(new ExponentialBackOffPolicy()) // 지수 백오프
.noRetry(InvalidDataException.class) // 데이터 오류는 재시도 무의미
.listener(new CustomSkipListener())
.build();
}
|
4.6 리스너 (Listener)
Spring Batch는 Job, Step, Chunk, Item 각 레벨에서 이벤트를 가로챌 수 있는 리스너를 제공한다.
1
2
3
4
5
6
7
8
9
| ★ 리스너 실행 시점
Job: beforeJob() → [Job 실행] → afterJob()
Step: beforeStep() → [Step 실행] → afterStep()
Chunk: beforeChunk() → [Chunk 처리] → afterChunk()
├─ beforeRead() → [Read] → afterRead() / onReadError()
├─ beforeProcess()→ [Process] → afterProcess()/ onProcessError()
└─ beforeWrite() → [Write] → afterWrite() / onWriteError()
Skip: onSkipInRead() / onSkipInProcess() / onSkipInWrite()
|
| 리스너 |
주요 용도 |
| JobExecutionListener |
전체 Job 시작/종료 알림, 리소스 초기화 |
| StepExecutionListener |
Step별 통계, ExecutionContext 관리 |
| ChunkListener |
청크 단위 로깅 |
| SkipListener |
스킵된 건 로깅/알림 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| // ★ 실무에서 자주 쓰는 리스너 — Job 완료 알림
@Component
public class BatchNotificationListener implements JobExecutionListener {
private final SlackNotifier slackNotifier;
@Override
public void afterJob(JobExecution jobExecution) {
String jobName = jobExecution.getJobInstance().getJobName();
BatchStatus status = jobExecution.getStatus();
long duration = Duration.between(
jobExecution.getStartTime(), jobExecution.getEndTime()
).toMinutes();
if (status == BatchStatus.COMPLETED) {
slackNotifier.send(String.format(
"✅ [%s] 배치 완료 (%d분 소요)", jobName, duration));
} else if (status == BatchStatus.FAILED) {
String errorMsg = jobExecution.getAllFailureExceptions()
.stream().map(Throwable::getMessage)
.collect(Collectors.joining(", "));
slackNotifier.send(String.format(
"🚨 [%s] 배치 실패! 원인: %s", jobName, errorMsg));
}
}
}
// ★ Skip 로깅 리스너
@Component
public class SettlementSkipListener implements SkipListener<Order, Settlement> {
private final SkipRecordRepository skipRecordRepository;
@Override
public void onSkipInProcess(Order order, Throwable t) {
log.warn("처리 스킵 — orderId={}, reason={}", order.getId(), t.getMessage());
skipRecordRepository.save(new SkipRecord(
"PROCESS", order.getId().toString(), t.getMessage()));
}
@Override
public void onSkipInWrite(Settlement settlement, Throwable t) {
log.warn("쓰기 스킵 — sellerId={}, reason={}",
settlement.getSellerId(), t.getMessage());
skipRecordRepository.save(new SkipRecord(
"WRITE", settlement.getSellerId().toString(), t.getMessage()));
}
}
|
5. Spring Batch 실전 운영
5.1 멀티스레드 Step
기본적으로 Step은 싱글 스레드로 동작한다. TaskExecutor를 설정하면 멀티스레드로 전환할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
| ★ 싱글스레드 vs 멀티스레드 Step
싱글스레드:
Thread-1: [Chunk1] → [Chunk2] → [Chunk3] → [Chunk4] → 완료
멀티스레드:
Thread-1: [Chunk1] → [Chunk4] → [Chunk7] → ...
Thread-2: [Chunk2] → [Chunk5] → [Chunk8] → ...
Thread-3: [Chunk3] → [Chunk6] → [Chunk9] → ...
→ 처리 순서는 보장되지 않음!
→ 처리 속도는 N배 향상 가능
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| @Bean
public Step multiThreadStep(JobRepository jobRepository,
PlatformTransactionManager tm) {
return new StepBuilder("multiThreadStep", jobRepository)
.<Order, Settlement>chunk(1000, tm)
.reader(pagingReader()) // ⚠️ thread-safe Reader 필수!
.processor(processor())
.writer(writer())
.taskExecutor(taskExecutor())
.throttleLimit(4) // 동시 스레드 수 제한
.build();
}
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setThreadNamePrefix("batch-thread-");
executor.initialize();
return executor;
}
|
⚠️ Thread-safety 체크리스트:
| 구성 요소 |
Thread-safe? |
멀티스레드 사용 시 |
JdbcCursorItemReader |
✗ |
사용 불가 |
JdbcPagingItemReader |
✓ |
synchronized 내장 |
JpaPagingItemReader |
✓ |
synchronized 내장 |
FlatFileItemReader |
✗ |
SynchronizedItemStreamReader로 래핑 |
실무 팁: 멀티스레드 Step은 처리 순서가 보장되지 않고 재시작 시 정확한 지점 복원이 어렵다. 순서가 중요하면 Partitioning을 권장한다.
5.2 Partitioning (데이터 범위 분할)
Partitioning은 데이터를 범위별로 나눠 여러 Slave Step에 분배하는 방식이다. 멀티스레드 Step보다 안전하고 확장성이 높다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| ★ Partitioning 아키텍처
┌─────────────────┐
│ Master Step │
│ (Partitioner) │
│ 데이터를 N개 │
│ 파티션으로 분할 │
└────────┬────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Slave Step │ │ Slave Step │ │ Slave Step │
│ Partition 0 │ │ Partition 1 │ │ Partition 2 │
│ ID: 1~10000 │ │ ID:10001~20000│ │ ID:20001~30000│
│ R → P → W │ │ R → P → W │ │ R → P → W │
└──────────────┘ └──────────────┘ └──────────────┘
Thread-1 Thread-2 Thread-3
→ 각 파티션은 독립적인 StepExecution → thread-safety 문제 없음
→ 한 파티션 실패 시 해당 파티션만 재시도 가능
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| // ★ Partitioner 구현 — ID 범위 기반 분할
@Component
public class OrderRangePartitioner implements Partitioner {
private final JdbcTemplate jdbcTemplate;
@Override
public Map<String, ExecutionContext> partition(int gridSize) {
Long minId = jdbcTemplate.queryForObject(
"SELECT MIN(order_id) FROM orders WHERE order_date = CURDATE()", Long.class);
Long maxId = jdbcTemplate.queryForObject(
"SELECT MAX(order_id) FROM orders WHERE order_date = CURDATE()", Long.class);
long range = (maxId - minId) / gridSize + 1;
Map<String, ExecutionContext> partitions = new HashMap<>();
for (int i = 0; i < gridSize; i++) {
ExecutionContext ctx = new ExecutionContext();
long start = minId + (i * range);
long end = Math.min(start + range - 1, maxId);
ctx.putLong("minId", start);
ctx.putLong("maxId", end);
partitions.put("partition" + i, ctx);
}
return partitions;
}
}
// ★ Partitioned Step 설정
@Bean
public Step masterStep(JobRepository jobRepository, Step slaveStep) {
return new StepBuilder("masterStep", jobRepository)
.partitioner("slaveStep", orderRangePartitioner())
.step(slaveStep)
.gridSize(8) // 8개 파티션
.taskExecutor(taskExecutor())
.build();
}
@Bean
@StepScope
public JdbcPagingItemReader<Order> partitionedReader(
@Value("#{stepExecutionContext['minId']}") Long minId,
@Value("#{stepExecutionContext['maxId']}") Long maxId) {
return new JdbcPagingItemReaderBuilder<Order>()
.name("partitionedOrderReader")
.dataSource(dataSource)
.selectClause("SELECT *")
.fromClause("FROM orders")
.whereClause("WHERE order_id BETWEEN :minId AND :maxId")
.sortKeys(Map.of("order_id", Order.ASCENDING))
.parameterValues(Map.of("minId", minId, "maxId", maxId))
.pageSize(1000)
.rowMapper(new BeanPropertyRowMapper<>(Order.class))
.build();
}
|
5.3 Flow 제어와 조건 분기
1
2
3
4
5
6
7
8
9
10
| ★ Flow 패턴
순차 실행: [Step A] → [Step B] → [Step C]
조건 분기: [Step A] ──COMPLETED──→ [Step B]
──FAILED──────→ [Error Step]
병렬 실행: ┌→ [Step B]
[Step A] → Split ──┼→ [Step C] → [Step D]
└→ [Step E]
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| // ★ 조건 분기 예시
@Bean
public Job settlementJob(JobRepository jobRepository) {
return new JobBuilder("settlementJob", jobRepository)
.start(validateStep())
.on("COMPLETED").to(processStep())
.from(validateStep())
.on("FAILED").to(errorNotifyStep())
.from(validateStep())
.on("*").to(errorNotifyStep())
.from(processStep())
.on("COMPLETED").to(reportStep())
.from(processStep())
.on("FAILED").to(compensateStep())
.end()
.build();
}
// ★ 병렬 실행 (Split) 예시
@Bean
public Job parallelJob(JobRepository jobRepository) {
Flow flow1 = new FlowBuilder<SimpleFlow>("flow1")
.start(sellerSettleStep()).build();
Flow flow2 = new FlowBuilder<SimpleFlow>("flow2")
.start(platformFeeStep()).build();
Flow flow3 = new FlowBuilder<SimpleFlow>("flow3")
.start(taxCalculateStep()).build();
return new JobBuilder("parallelJob", jobRepository)
.start(validateStep())
.next(new FlowBuilder<SimpleFlow>("splitFlow")
.split(taskExecutor())
.add(flow1, flow2, flow3) // 3개 Flow 병렬 실행
.build())
.next(reportStep()) // 모두 완료 후 보고서
.end()
.build();
}
|
5.4 스케줄링
Spring Batch 자체에는 스케줄러가 없다. 외부 스케줄러와 연동한다.
| 스케줄러 |
장점 |
단점 |
적합 케이스 |
@Scheduled |
간단 |
클러스터 중복 실행 위험 |
소규모 단일 인스턴스 |
| Quartz |
클러스터 지원, 미스파이어 |
설정 복잡 |
중규모 엔터프라이즈 |
| Airflow |
DAG 의존 관리, 웹 UI |
별도 인프라 운영 |
대규모 파이프라인 |
| K8s CronJob |
컨테이너 격리, 자원 관리 |
K8s 필요 |
클라우드 네이티브 |
1
2
3
4
5
6
7
8
9
10
11
12
| // ★ @Scheduled + ShedLock으로 다중 인스턴스 중복 방지
@Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시
@SchedulerLock(name = "settlementJob",
lockAtMostFor = "4h",
lockAtLeastFor = "5m")
public void runSettlementJob() {
JobParameters params = new JobParametersBuilder()
.addLocalDate("date", LocalDate.now().minusDays(1))
.addLong("timestamp", System.currentTimeMillis())
.toJobParameters();
jobLauncher.run(settlementJob, params);
}
|
⚠️ 주의: @Scheduled를 다중 인스턴스 환경에서 사용하면 모든 인스턴스가 동시에 같은 Job을 실행한다. ShedLock, Quartz 클러스터링, 또는 리더 선출 메커니즘을 반드시 적용해야 한다.
5.5 모니터링과 관리
1
2
3
4
5
6
7
8
9
10
11
12
13
| ★ Spring Batch 모니터링 아키텍처
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Spring Batch │ │ Prometheus │ │ Grafana │
│ Application │────▶│ (메트릭 수집) │────▶│ (대시보드) │
│ Micrometer │ │ │ │ 배치 현황 │
│ + Actuator │ │ │ │ 실행 시간 │
└──────┬───────┘ └──────────────┘ │ 에러율 │
│ └──────────────┘
▼
┌──────────────┐
│ AlertManager │──→ Slack / PagerDuty
└──────────────┘
|
| 메트릭 |
설명 |
알림 기준 예시 |
spring.batch.job.duration |
Job 실행 시간 |
SLA의 80% 초과 시 경고 |
spring.batch.step.read.count |
읽기 건수 |
예상 대비 ±20% 이상 시 확인 |
spring.batch.step.skip.count |
스킵 건수 |
10건 이상 시 경고 |
spring.batch.job.status |
실행 상태 |
FAILED 시 즉시 알림 |
5.6 대용량 처리 최적화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| ★ Spring Batch 튜닝 체크리스트 (순서대로!)
1단계: SQL 최적화 ← 가장 효과 큼!
├─ 인덱스 확인, 불필요한 JOIN 제거, SELECT 컬럼 최소화
2단계: chunk-size 튜닝
├─ 너무 작으면: COMMIT 오버헤드 ↑
├─ 너무 크면: 메모리 ↑, 롤백 범위 ↑
└─ 일반적으로 100~5000 사이 (벤치마크 필수)
3단계: Writer 벌크 최적화
├─ JDBC Batch (rewriteBatchedStatements=true)
└─ JPA → JDBC 전환 고려
4단계: 병렬화
├─ 멀티스레드 Step (간단하지만 제약)
└─ Partitioning (안전, 확장성 좋음)
5단계: JVM/인프라 튜닝
├─ 힙 크기 조정 (-Xmx)
└─ DB 커넥션 풀 크기
|
| 최적화 항목 |
Before |
After |
효과 |
| fetchSize 조정 |
fetchSize=10(기본) |
fetchSize=1000 |
DB 라운드트립 100배 감소 |
| rewriteBatchedStatements |
개별 INSERT |
벌크 INSERT |
쓰기 성능 3~10배 향상 |
| 멀티스레드 (4 threads) |
싱글 스레드 |
4 스레드 |
처리량 2~3배 향상 |
| Partitioning (8 파티션) |
싱글 스레드 |
8 파티션 |
처리량 5~7배 향상 |
| JPA → JDBC 전환 |
JPA persist |
JdbcBatchItemWriter |
쓰기 성능 5~20배 향상 |
실무 팁: 성능 최적화의 80%는 SQL과 chunk-size 튜닝에서 나온다. 느린 배치를 만나면 먼저 EXPLAIN ANALYZE로 쿼리 실행 계획을 확인하라.
마무리: 1편 핵심 요약
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| ★ Part 1 핵심 요약
┌─────────────────────────────────────────────────────┐
│ 배치 기본: │
│ ① 배치 = 데이터를 모아서 한꺼번에 처리 │
│ ② 핵심 원칙: 멱등성, 재시작, Chunk, Skip/Retry │
│ ③ 배치 윈도우와 SLA로 시간 관리 │
│ │
│ Spring Batch 아키텍처: │
│ ④ JobLauncher → Job → Step → Chunk(R/P/W) │
│ ⑤ JobRepository 6개 메타데이터 테이블로 상태 관리 │
│ ⑥ ExecutionContext로 재시작 지점 저장 │
│ │
│ Spring Batch 핵심 기능: │
│ ⑦ Reader(Cursor/Paging) → Processor → Writer │
│ ⑧ faultTolerant (Skip + Retry + 지수 백오프) │
│ ⑨ 리스너로 알림/로깅 이벤트 가로채기 │
│ │
│ Spring Batch 운영: │
│ ⑩ 멀티스레드 / Partitioning 병렬화 │
│ ⑪ Flow 조건 분기와 병렬 실행 │
│ ⑫ ShedLock / 모니터링 / 튜닝 체크리스트 │
└─────────────────────────────────────────────────────┘
|
다음 2편에서는 Python 배치 처리, Apache Airflow의 아키텍처·운영, Spring Batch vs Airflow 비교, 그리고 배치 장애 트러블슈팅을 다룬다.