배치 처리 완벽 심화 가이드 1편: 배치 기본 원칙과 Spring Batch 완전 정복

배치 처리 완벽 심화 가이드 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.STATUSSTARTED인 채로 남은 레코드가 있으면 해당 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 비교, 그리고 배치 장애 트러블슈팅을 다룬다.