본문 바로가기

Programming/Java & Spring 관련 내용 정리

우리 정산 시스템은 왜 Chunk와 Tasklet을 반반 썼을까

 

 

우리 회사 정산 시스템에서 Spring Batch를 쓰고 있다.

코드를 보다 보니 어떤 Job은 Chunk로, 어떤 Job은 Tasklet으로 구현되어 있었다.

 

왜 나눠 쓴 걸까?

직접 코드를 뜯어보고 정리해봤다.

 

 


🔍 실제로 어떻게 나뉘어 있었나

 

정산 서버의 배치 Job 60여 개를 확인해보니, Chunk와 Tasklet이 거의 반반이었다.

 

작업 방식 왜?
거래 이력 10만 건 읽어서
정산 정책 적용 → 정산 테이블에 저장
Chunk
size=1000
행마다 동일한 변환 규칙 적용.
1000건씩 커밋해서 실패해도 부분 복구 가능
송금 대상 1건씩 처리
성공 → 송금 / 실패 → 에러 이벤트 / 취소 → 취소 이벤트
Chunk
size=1
파이프라인은 필요하지만,
한 건 실패가 다른 건에 전파되면 위험해서 건건이 커밋
보험료 집계 결과 읽어서
원단위 반올림 → 저장
Chunk Reader가 GROUP BY로 집계 완료.
Processor는 단순 가공만
월별 집계 생성
INSERT ... SELECT ... GROUP BY
Tasklet SQL이 알아서 집계.
Reader/Writer로 쪼갤 게 없음
보정 데이터 삭제
특정 seq 범위 DELETE 1회
Tasklet 삭제 쿼리 한 방. 파이프라인 불필요
세금계산서 상태 동기화
전체 조회 → 필터 → Kafka 발행
Tasklet 외부 시스템 메시지 발행이 핵심.
흐름 제어가 더 중요
마감 상태에 따라
다른 배치 잡 활성화/비활성화
Tasklet 데이터 처리가 아니라 상태 제어.
배치 실행 가능 여부를 조정하는 작업

 

패턴이 보인다.

그럼 Chunk와 Tasklet이 정확히 뭔지 정리해보자.

 

 


📐 먼저 알아야 할 것: Job → Step → ?

 

📦 Job
Step 1
Chunk 또는 Tasklet
Step 2
Chunk 또는 Tasklet
Step N
...

 

Job은 하나 이상의 Step으로 구성된다.

각 Step 안에서 데이터를 처리하는 방식이 Chunk 또는 Tasklet이다.

 

 


1) Chunk — 컨베이어 벨트

 

데이터를 읽고 → 가공하고 → 쓰는 과정을 N건 단위로 반복한다.

 

📖 ItemReader
1건씩 읽음
⚙️ ItemProcessor
1건씩 가공 (선택)
✏️ ItemWriter
chunk 단위로 일괄 쓰기
↑ chunk-size만큼 모이면 Writer 실행 → 커밋 → 다시 Reader부터 반복

 

예를 들어 chunk-size가 10이고 데이터가 100건이면:

 

1
Reader가 10건을 1건씩 읽음 → Processor가 1건씩 가공 → Writer가 10건 일괄 저장커밋
2
다음 10건 읽음 → 가공 → 일괄 저장커밋
총 10번 반복 (100건 / 10 = 10 chunk)

 

💡 핵심은 트랜잭션 단위다.
chunk-size가 10이면 10건마다 커밋한다.
7번째 chunk에서 실패하면 1~6번째 chunk는 이미 커밋되어 있고, 7번째 chunk만 롤백된다.

 

코드 예시

 

@Bean public Step chunkBaseStep() { return stepBuilderFactory.get("chunkBaseStep") .<String, String>chunk(10) // chunk-size = 10 .reader(itemReader()) // 1건씩 읽기 .processor(itemProcessor()) // 1건씩 가공 .writer(itemWriter()) // 10건 모이면 일괄 쓰기 .build(); } private ItemReader<String> itemReader() { return new ListItemReader<>(getItems()); } private ItemProcessor<String, String> itemProcessor() { return item -> item + ", Spring Batch"; } private ItemWriter<String> itemWriter() { return items -> log.info("chunk item size : {}", items.size()); // 출력: "chunk item size : 10" (10번 반복) }

 

구성요소 제네릭 설명
Reader INPUT 반환 데이터 소스에서 1건씩 읽어옴
Processor INPUTOUTPUT 가공/변환/필터링 (생략 가능)
Writer List<OUTPUT> 수신 chunk-size만큼 모인 리스트를 일괄 처리

 

.<String, String>chunk(10)에서 첫 번째 String이 INPUT(Reader 반환 타입),

두 번째 String이 OUTPUT(Processor 반환 → Writer 수신 타입)이다.

 

 


2) Tasklet — 수작업

 

Reader/Processor/Writer 구분 없이 하나의 메서드에서 모든 로직을 직접 처리한다.

 

🔧 Tasklet.execute()

읽기 + 가공 + 쓰기를 전부 여기서 직접 구현

RepeatStatus.FINISHED → 종료 RepeatStatus.CONTINUABLE → 재호출

 

코드 예시

 

@Bean public Step taskBaseStep() { return stepBuilderFactory.get("taskBaseStep") .tasklet(this.tasklet()) .build(); } private Tasklet tasklet() { return (stepContribution, chunkContext) -> { List<String> items = getItems(); // 100건 전부 읽기 log.info("task item size : {}", items.size()); // 출력: "task item size : 100" (1번만 실행) return RepeatStatus.FINISHED; }; }

 

⚠️ Tasklet의 DB 작업은 Step 트랜잭션으로 묶인다.
하지만 외부 API 호출이나 Kafka 메시지 발행 같은 부작용은 DB 롤백으로 되돌릴 수 없다.
이런 경우 별도의 보상/멱등 처리가 필요하다.

 

 


3) 같은 100건, 다른 결과

 

Chunk (chunk-size=10)
chunk item size : 10
chunk item size : 10
... (10번 반복)
chunk item size : 10
트랜잭션 10번 커밋
Tasklet
task item size : 100
트랜잭션 1번 커밋

 

 


📝 결론: 왜 나눠 쓴 걸까

 

Chunk
행마다 같은 규칙을 반복 적용하는
변환/판단 작업
= 컨베이어 벨트
 
Tasklet
SQL 집계 한 방, 외부 API, 상태 관리처럼
파이프라인으로 안 쪼개지는 작업
= 수작업

 

처음에는 "Chunk = 대량, Tasklet = 소량"이라고 단순하게 생각했다.

하지만 실제 코드를 보니 데이터 양이 아니라 처리 구조가 기준이었다.

 

"이 작업이 Reader → Processor → Writer로 자연스럽게 쪼개지는가?"

이 질문에 Yes면 Chunk, No면 Tasklet.

 

 


📎 전체 코드

 

package com.ot.schedule.Taskletandchunk; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.*; import org.springframework.batch.core.configuration.annotation.*; import org.springframework.batch.core.launch.support.RunIdIncrementer; import org.springframework.batch.core.step.tasklet.Tasklet; import org.springframework.batch.item.*; import org.springframework.batch.item.support.ListItemReader; import org.springframework.batch.repeat.RepeatStatus; import org.springframework.context.annotation.*; import java.util.*; @Configuration @Slf4j public class ChunkProcessingConfiguration { private final JobBuilderFactory jobBuilderFactory; private final StepBuilderFactory stepBuilderFactory; public ChunkProcessingConfiguration(JobBuilderFactory jobBuilderFactory, StepBuilderFactory stepBuilderFactory) { this.jobBuilderFactory = jobBuilderFactory; this.stepBuilderFactory = stepBuilderFactory; } @Bean public Job chunkProcessingJob() { return jobBuilderFactory.get("chunkProcessingJob") .incrementer(new RunIdIncrementer()) .start(this.taskBaseStep()) .next(this.chunkBaseStep()) .build(); } // =============================== Tasklet Step =============================== @Bean public Step taskBaseStep() { return stepBuilderFactory.get("taskBaseStep") .tasklet(this.tasklet()) .build(); } private Tasklet tasklet() { return (stepContribution, chunkContext) -> { List<String> items = getItems(); log.info("task item size : {}", items.size()); return RepeatStatus.FINISHED; }; } // =============================== Chunk Step =============================== @Bean public Step chunkBaseStep() { return stepBuilderFactory.get("chunkBaseStep") .<String, String>chunk(10) .reader(itemReader()) .processor(itemProcessor()) .writer(itemWriter()) .build(); } private ItemReader<String> itemReader() { return new ListItemReader<>(getItems()); } private ItemProcessor<String, String> itemProcessor() { return item -> item + ", Spring Batch"; } private ItemWriter<String> itemWriter() { return items -> items.forEach(log::info); } // =============================== 공통 =============================== private List<String> getItems() { List<String> items = new ArrayList<>(); for (int i = 0; i < 100; i++) { items.add(i + "Hello"); } return items; } }