사전적 의미 : 일정 시간 동안 대량의 데이터를 한 번에 처리하는 방식
사용 목적 : 아주 많은 데이터를 처리하는 중간에 프로그램이 멈출 수 있는 상황 대비해 안전 장치 마련
예시로 급여 혹은 은행 이자 시스템의 경우 특정일에 지급한 이자를 지급하다 중간에 멈춘 경우 이미 지급한 사람에게도 다시 지급 하는 중복 불상사를 막아야 할때 사용

JobLauncher : 하나의 배치 작업(Job)을 실행 시키는 시작점
Job : "읽기 -> 처리 -> 쓰기" 과정을 정의한 배치 작업
JobRepository : Batch가 얼마나 했는지, 특정 일자 배치를 이미 했는지 메타 데이터에 기록하는 부분
Job → Step → (Reader → Processor → Writer)
| 방식 | 특징 | 사용 시점 |
|---|---|---|
| Tasklet 방식 | 단일 실행(파일 삭제, 폴더 초기화 등 단순 로직) | 전처리/후처리, 단발성 유지보수 작업, 청크가 과한 단순 로직 |
| Chunk 방식 | read → process → write 흐름을 반복, 대량 데이터 처리에 적합 | 대량 데이터 ETL, 파일/DB 배치, 재시작·커밋 단위 중요할 때 |
Tasklet은 “한 번 수행하고 끝나는” 절차형 작업에 적합
단일 실행 후 종료되며, 반복(Chunk) 개념이 없음
┌───────────────────────────────────┐
│ STEP │
│ │
│ [tasklet.execute()] │
│ ─ 단일/일괄 동작 한 번 수행 ─ │
│ (예: 폴더 정리, 파일 이동) │
│ │
└───────────────────────────────────┘
chunkSize = 10이면 아래와 같이 동작┌───────────────────────────────────┐
│ STEP │
│ │
│ ┌───────────── 청크 루프 ───────┐ │
│ │ │ │
│ │ [read] 아이템 1 │ │
│ │ [read] 아이템 2 │ │
│ │ [read] 아이템 3 │ │
│ │ ... │ │
│ │ [read] 아이템 10 │ │
│ │ │ │
│ │ [process] 아이템 1 │ │
│ │ [process] 아이템 2 │ │
│ │ ... │ │
│ │ [process] 아이템 10 │ │
│ │ │ │
│ │ [write] 10개 묶음 저장 │ │
│ │ [commit] 트랜잭션 커밋 │ │
│ └─────────────── 반복 ──────────┘ │
│ │
└───────────────────────────────────┘
List items = new Arraylist();
for(int i = 0; i < commitInterval; i++){
Object item = itemReader.read();
if (item != null) {
items.add(item);
}
}
List processedItems = new Arraylist();
for(Object item: items){
Object processedItem = itemProcessor.process(item);
if (processedItem != null) {
processedItems.add(processedItem);
}
}
itemWriter.write(processedItems);
| 구분 | Tasklet | Chunk-Oriented |
|---|---|---|
| 처리 방식 | 단일 실행 | 청크 단위 반복 |
| 트랜잭션 | 한 번 수행 | chunkSize 단위 커밋 |
| 적합한 작업 | 단순/단발성 (파일정리, 초기화) | 대용량 처리 (ETL, DB↔파일) |
| 상태 저장(ExecutionContext) | 거의 불필요 | 커밋마다 업데이트 가능 |
중간 실패 시 차이점
- Tasklet: 실패하면 처음부터 다시 시작
- Chunk-Oriented: 실패한 청크의 이전 커밋 시점부터 재시작 가능
Spring Batch는 Job 이름 + JobParameters 조합으로 하나의 JobInstance 식별
“무엇을 처리할 실행”을 파라미터로 규정해야 중복 실행을 막고 재시작 올바르게 가능
예시
같은 Job + 동일 JobParameters로 다시 시작하면
Spring Batch는 메타 테이블에서 해당 인스턴스를 찾아 이전 커밋 지점부터 재시작하게 합니다.
- 해당 Job 내에 작업이 모두 성공한 경우 재실행 하지 않음
┌──────────────────────────────────────────────┐
│ Chunk-Oriented Processing │
│ (chunkSize = 10 예시) │
├──────────────────────────────────────────────┤
│ 1) [read] 아이템 1..10 │
│ 2) [process] 아이템 1..10 │
│ 3) [write] 아이템 10개 타깃 DB에 저장 │
│ 4) [commit] 트랜잭션 커밋(✅ DB 영속) │
│ 5) [update] ExecutionContext 갱신(체크포인트)│
│ └─ ❌ 여기서 "앱 크래시" 발생 │
│ (예: 커밋 직후 ~ update 전후 크래시) │
├──────────────────────────────────────────────┤
│ 6) 재시작 (동일 Job + 동일 JobParameters) │
│ └─ 이전 실행 상태 로드: │
│ - DB: 1~10은 이미 저장(커밋 완료) │
│ - ExecutionContext: 갱신 실패 ⇒ │
│ "1~10을 아직 처리 안 한 것처럼 보임" │
│ │
│ 7) [read] 다시 아이템 1..10 │
│ 8) [process] 아이템 1..10 │
│ 9) [write] 아이템 10개 재저장 시도 │
│ └─ 결과 │
│ - 중복 INSERT 발생(UNIQUE 없으면) │
│ - or 업서트면 "무해하게 갱신" │
└──────────────────────────────────────────────┘
핵심: 4)에서 커밋은 이미 끝나 DB는 영속 상태지만,
5)의 ExecutionContext 체크포인트가 갱신되지 못하면 재시작 시 같은 청크를 다시 처리하려 든다
→ 아이템 레벨 멱등성(UNIQUE+UPSERT / processed 플래그)이 없으면 중복 작업 문제 발생
비즈니스 키 기반 : upsert/merge
INSERT ... ON DUPLICATE KEY UPDATE(MySQL), MERGE INTO(Oracle/PG)상태 컬럼 플래그
스냅샷/해시 비교
소스 오프셋 저장(ExecutionContext)
private final String CURRENT_ROW_KEY = "current.row.number";
@Override
public void update(ExecutionContext executionContext) throws ItemStreamException {
executionContext.putInt(CURRENT_ROW_KEY, currentRowNumber);
}
중요: Spring Batch 메타 테이블은 “배치 엔진의 진행 상태”를 저장할 뿐,
비즈니스 중복 방지(이미 처리된 주문/유저 등)는 도메인 레벨에서 설계해야 합니다.
- 어디까지 읽었는가 = ExecutionContext
- 이미 처리했는가 = 비즈니스 키/저장소
BATCH_JOB_INSTANCE / BATCH_JOB_EXECUTION : 잡 식별/실행 이력 BATCH_STEP_EXECUTION : 스텝 실행 이력 BATCH_JOB_EXECUTION_PARAMS : JobParameters BATCH_JOB_EXECUTION_CONTEXT / BATCH_STEP_EXECUTION_CONTEXT : 🔑 재시작용 상태(예: “현재 70행까지 읽음”)Job 실행
└─ 이전 실행 이력/Context 로드
└─ Step open() 시 전달
└─ 청크 처리(read→process→write)
└─ commit 후 update()로 현재 위치 저장
spring:
batch:
jdbc:
initialize-schema: always # embedded | always | never
(MySQL 등 비-임베디드면 'always'가 편리. 운영은 보통 never)
embedded : H2/HSQL/Derby 같은 임베디드 DB에서만 스키마 스크립트 실행
always : 항상 스키마 스크립트 실행(없으면 생성). 기존 테이블을 드롭하지는 않음
never : 직접 스키마를 준비(DDL 수동 적용/마이그레이션 도구 사용)
운영에서는 보통 never(또는 마이그레이션 툴로 관리), 로컬/테스트에서는 always 로 설정하는 편
MongoTemplate 이용하여 Reader, Writer 구현
public interface ItemStreamReader<T> extends ItemStream, ItemReader<T> {
}
read() : 배치 작업 시 데이터를 읽기 위한 부분으로 하나의 데이터를 읽어올 때 read() 메소드가 호출됨@FunctionalInterface
public interface ItemReader<T> {
@Nullable
T read() throws Exception, UnexpectedInputException, ParseException, NonTransientResourceException;
}
open() : 배치 처리가 시작되고 Step에서 처음 reader를 부르면 시작되는 부분으로 초기화나 이미 했던 작업의 경우 중단점 까지 건너 뛰도록 설계하는 부분
update() : 배치 작업 시 read()와 함께 불러지는 메소드로 read() 호출 후 바로 호출되기 때문에 read()에서 처리한 작업 단위 기록하는 용도로 사용
close() : 배치 작업이 완료되고 불러지는 메소드로 파일을 저장하거나 필드 변수를 초기화 하는 메소드로 사용
public interface ItemStream {
default void open(ExecutionContext executionContext) throws ItemStreamException {
}
default void update(ExecutionContext executionContext) throws ItemStreamException {
}
default void close() throws ItemStreamException {
}
}
BATCH_JOB_EXECUTION_CONTEXT : Job 실행 상태 Context 저장BATCH_STEP_EXECUTION_CONTEXT : Step 실행 상태 Context 저장open() 메서드 내에서 context.containsKey() 메서드 써서 어디까지 읽었는지 정보 안가져오는 경우가 많기에 이 부분은 주의해서 참고해야 함public class CustomItemStreamReaderImpl implements ItemStreamReader<String> {
private final RestTemplate restTemplate;
private int currentId;
private final String CURRENT_ID_KEY = "current.call.id";
private final String API_URL = "https://www.devyummi.com/page?id=";
public CustomItemStreamReaderImpl(RestTemplate restTemplate) {
this.currentId = 0;
this.restTemplate = restTemplate;
}
@Override
public void open(ExecutionContext executionContext) throws ItemStreamException {
/** 이 부분처럼 open 메서드에서 어디까지 읽었는지 가져오는거 있는지 주의해서 참고 자료 참고**/
if (executionContext.containsKey(CURRENT_ID_KEY)) {
currentId = executionContext.getInt(CURRENT_ID_KEY);
}
}
@Override
public String read() throws Exception, UnexpectedInputException, ParseException, NonTransientResourceException {
currentId++;
String url = API_URL + currentId;
String response = restTemplate.getForObject(url, String.class);
if (response == null) {
return null;
}
return response;
}
@Override
public void update(ExecutionContext executionContext) throws ItemStreamException {
executionContext.putInt(CURRENT_ID_KEY, currentId);
}
@Override
public void close() throws ItemStreamException {
}
}
faultTolerant() 선언 + skip + noSkip + skipLimit 설정 Exception으로 크게 skip 잡아두고, noSkip으로 명시하는 방향 추천@Bean
public Step sixthStep() {
return new StepBuilder("sixthStep", jobRepository)
.<BeforeEntity, AfterEntity> chunk(10, platformTransactionManager)
.reader(beforeSixthReader())
.processor(middleSixthProcessor())
.writer(afterSixthWriter())
.faultTolerant() // 선언 필수
.skip(Exception.class) // 어떤 예외를 건너뛸지
.noSkip(FileNotFoundException.class) // 어떤 예외는 스킵하지 않을지
.noSkip(IOException.class)
.skipLimit(10) // 10번까지 예외 넘어간다.
.build();
}
skipPolicy에 커스텀한 스킵 정책 매개변수 넘겨줌shouldSkip 메서드 구현에서 항상 true로 반환 @Bean
public Step sixthStep() {
return new StepBuilder("sixthStep", jobRepository)
.<BeforeEntity, AfterEntity> chunk(10, platformTransactionManager)
.reader(beforeSixthReader())
.processor(middleSixthProcessor())
.writer(afterSixthWriter())
.faultTolerant()
.skipPolicy(customSkipPolicy)
.noSkip(FileNotFoundException.class)
.noSkip(IOException.class)
.build();
}
@Configuration
public class CustomSkipPolicy implements SkipPolicy {
@Override
public boolean shouldSkip(Throwable t, long skipCount) throws SkipLimitExceededException {
return true;
}
}
faultTolerant() 명시 필수retry() 예외 지정 / noRetry() 재시도 안할 예외 지정retryLimit(3) 으로 횟수 지정 가능@Bean
public Step sixthStep() {
return new StepBuilder("sixthStep", jobRepository)
.<BeforeEntity, AfterEntity> chunk(10, platformTransactionManager)
.reader(beforeSixthReader())
.processor(middleSixthProcessor())
.writer(afterSixthWriter())
.faultTolerant()
.retryLimit(3) // 1~3 정도 줘서 아래 예외가 발생할 수 있기에 달아두면 좋을 듯
.retry(SQLException.class) // data 읽어들일 때
.retry(IOException.class) // 특정 파일 IO 할때
.noRetry(FileNotFoundException.class)
.build();
}
@Bean
public Step step1(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
return new StepBuilder("step1", jobRepository)
.<String, String>chunk(2, transactionManager)
.reader(itemReader())
.writer(itemWriter())
.faultTolerant()
.noRollback(ValidationException.class) // 특정 예외 지정
.build();
}
@Bean
public StepExecutionListener stepExecutionListener() {
return new StepExecutionListener() {
@Override
public void beforeStep(StepExecution stepExecution) {
StepExecutionListener.super.beforeStep(stepExecution);
}
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
return StepExecutionListener.super.afterStep(stepExecution);
}
};
}
listener() 매개변수에 함수 호출@Bean
public Step sixthStep() {
return new StepBuilder("sixthStep", jobRepository)
.<BeforeEntity, AfterEntity> chunk(10, platformTransactionManager)
.reader(beforeSixthReader())
.processor(middleSixthProcessor())
.writer(afterSixthWriter())
.listener(stepExecutionListener())
.build();
}
@Bean
public Job footballJob(JobRepository jobRepository) {
return new JobBuilder("footballJob", jobRepository)
.start(playerLoad())
.next(gameLoad())
.next(playerSummarization())
.build();
}
"*" 문자의 경우 어떠한 상태는 다 매칭됨FAILED, COMPLETED 등 상태 조건을 명시한 경우 해당 조건에 맞춰서만 매칭됨@Bean
public Job job(JobRepository jobRepository, Step stepA, Step stepB, Step stepC, Step stepD) {
return new JobBuilder("job", jobRepository)
.start(stepA)
.on("*") // 이 step이 끝났을 때 "FAILED"가 오든"COMPLETED"가 오든 다음 스텝을 실행 할 수 있음
.to(stepB)
.from(stepA).on("FAILED").to(stepC)
.from(stepA).on("COMPLETED").to(stepD)
.end()
.build();
}
┌────────────┐
│ job() │
└──────┬─────┘
│
▼
┌──────────┐
│ stepA │
└──────────┘
/ | \
(FAILED)/ |(any) \ (COMPLETED)
/ | \
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ stepC │ │ stepB │ │ stepD │
└──────────┘ └──────────┘ └──────────┘
| 상태 | 의미 |
|---|---|
| COMPLETED | 정상 종료 |
| FAILED | 실패 종료 |
| STOPPED | 중단됨 |
| UNKNOWN | 알 수 없음 |
| NOOP | 수행할 대상 없음 |
| CUSTOM (예: SKIPPED, PARTIAL 등) | 직접 정의 가능 |
@Bean
public JobExecutionListener jobExecutionListener() {
return new JobExecutionListener() {
@Override
public void beforeJob(JobExecution jobExecution) {
JobExecutionListener.super.beforeJob(jobExecution);
}
@Override
public void afterJob(JobExecution jobExecution) {
JobExecutionListener.super.afterJob(jobExecution);
}
};
}
@Bean
public Job sixthBatch() {
return new JobBuilder("sixthBatch", jobRepository)
.start(sixthStep())
.listener(jobExecutionListener())
.build();
}
save() 수행 시 DB 테이블을 조회하여 가장 마지막 값 보다 1을 증가시킨 값을 저장데이터 100개를 Before -> After table로 저장
RepositoryItem 기반
JDBC 기반
JPA를 사용할 경우 Reader에서 읽은 Entity를 영속성 컨텍스트에 Entity를 유지하여 메모리를 계속 점유하고 있는 문제
그리고 사실 단순히 메모리만 차지하는 문제가 아니라, 변경 감지(Dirty Checking) 자체가 오버헤드를 만든다.
이런 구조는 2,000만 건처럼 대용량 데이터를 처리해야 하는 배치 환경에는 부적합하다.
clear()를 20만 번 호출해야 한다는 뜻이다.💡 결론: JPA 기반 Reader는 엔티티 변경 감지 중심의 ORM 구조라, 대용량 I/O 배치에는 JDBC나 MyBatis 기반 Reader로 교체하는 것이 훨씬 효율적이다.
✅ “한 번에 처리하고 커밋할 아이템 개수”
예를 들어 chunkSize = 10 이면,
1. 10개의 데이터를 읽고(read)
2. 10개를 처리(process)
3. 10개를 한번에 저장(write)
4. 트랜잭션 1회 커밋
✅ “한 번의 DB 쿼리로 읽어올 데이터 개수”
JpaPagingItemReader, JdbcPagingItemReader, MyBatisPagingItemReader 등에서 사용예를 들어 pageSize = 10 이면,
Reader는 한 번의 select 쿼리로 10개의 row를 가져온다.
SELECT * FROM member ORDER BY id LIMIT 10 OFFSET 0;
- Page Size는 “DB 접근 단위”
- Chunk Size는 “처리/커밋 단위”이다.
| 구분 | Chunk Size | Page Size |
|---|---|---|
| 역할 | 커밋 단위 | DB 조회 단위 |
| 책임 | 트랜잭션 처리 / write 커밋 | 데이터 읽기 성능 |
| 관련 객체 | Step / ChunkOrientedTasklet | ItemReader (Paging 기반) |
| 메모리 영향 | 처리 중 누적된 데이터 수 | 한 번에 읽는 DB row 수 |
| 조정 기준 | 트랜잭션 비용 / 실패 복구 단위 | DB I/O 효율 / 네트워크 부하 |
chunkSize = pageSize
→ 한 번의 쿼리로 읽은 데이터를 한 번의 커밋 단위로 처리.
Reader 캐시: [1~100]
Processor: [1~10] commit → [11~20] commit → ... → [91~100] commit
예: pageSize=10, chunkSize=100
Reader는 10개씩 여러 번 DB 쿼리 (총 10번)
Chunk가 100개 찰 때까지 반복 후 커밋
SELECT 10개 → process 10개
SELECT 10개 → process 10개
(10번 반복 후) → write 100개 → commit
| 설정 | 특징 | 복구 단위 | 메모리 영향 | 성능 |
|---|---|---|---|---|
| pageSize = chunkSize | ✅ 권장. 단순하고 예측 쉬움 | Chunk 단위 | 중간 | 중간 |
| pageSize > chunkSize | Reader 캐시 많음, DB I/O 효율 ↑ | Chunk 단위 | ↑ 메모리 높음 | ↑ 빠름 |
| pageSize < chunkSize | Reader 자주 쿼리 | Chunk 단위 | ↓ 메모리 낮음 | ↓ 느림 |
| 구분 | 설명 |
|---|---|
| pageSize | Reader가 DB에서 한 번에 읽는 개수 (fetch 단위) |
| chunkSize | Processor/Writer가 한 번에 처리하고 커밋하는 단위 |
| 복구 단위 | 커밋된 Chunk까지만 완료로 간주, 나머지는 재실행 |
| 권장 조합 | pageSize = chunkSize (대부분 케이스) |
📘 비유로 이해하기
| 역할 | 비유 |
|---|---|
| Page Size | “마트에서 한 번에 장바구니에 담는 물건 개수” |
| Chunk Size | “계산대에서 한 번에 결제할 물건 개수” |
장바구니(pageSize)가 너무 크면 무겁고 버겁고,
계산대(chunkSize)가 너무 크면 결제 중 장애 시 처음부터 다시 해야 함.
결국 둘의 균형(pageSize = chunkSize) 이 가장 효율적