1. 도입부 (Introduction)
대용량 데이터를 다루는 배치 프로세스에서 가장 세밀한 설계와 견고성이 요구되는 컴포넌트는 단연 스텝(Step)이다. 개발자는 흔히 스프링 배치가 제공하는 StepBuilder를 호출하고 chunk() 설정과 reader, processor, writer 빈을 주입하는 것만으로 배치가 마법처럼 구동될 것이라 기대한다.
하지만 실제 엔터프라이즈 환경의 가동 로그를 뜯어보면, 스텝의 구동 단계에서 스레드 격리 붕괴, 자원 누수, 예외 전파에 의한 청크 트랜잭션 롤백 등 예상치 못한 수많은 문제에 직면하게 된다. 이러한 동작상의 병목과 장애에 영리하게 대응하기 위해서는 프레임워크가 추상화해 둔 스텝 내부의 블랙박스를 완전하게 걷어내야 한다.
[Spring Batch] ItemProcessor 동작 원리와 4대 데이터 처리 전략
1. 도입부웹 애플리케이션 개발에 익숙한 사람들에게 "데이터를 가져와서 가공한 뒤 저장한다"는 로직은 지극히 당연하고 단순한 흐름이다. 보통은 하나의 서비스 레이어에서 이 모든 과정이 이
myblog01150.tistory.com
2. 주요 특징 및 핵심 흐름 (Main Features & Architecture)
스프링 배치의 스텝은 조립(Assembly) 단계와 가동(Execution) 단계로 철저하게 구획되어 작동한다.

- 조립 단계(Build Phase): StepBuilder가 각 스텝의 특성에 맞는 서브 빌더(ChunkOrientedStepBuilder 등)로 위임하고, 빈 초기화 메서드(afterPropertiesSet())를 통해 런타임에 필요한 인프라 템플릿(RetryTemplate, TransactionTemplate)을 완성한다.
- 가동 단계(Execution Phase): 배치 구동 체계가 SimpleStepHandler를 통해 메타데이터를 저장할 StepExecution을 개시하면, AbstractStep 계층에서 스텝 스코프 활성화와 자원 획득(open)을 선제 수행한 뒤 실질적인 청크 실행 루프를 위임한다.
이 단계별 흐름 속에서 다양한 컴포넌트들이 어떻게 유기적으로 연쇄 반응을 일으키는지 다음 아키텍처 레이아웃을 통해 직관적으로 조망할 수 있다.
3. 1단계: Step 생성 및 조립 과정 해부 (Build Phase)
3.1 StepBuilder의 빌더 라우팅 원리
스프링 배치 구성 코드에서 가장 먼저 만나게 되는 StepBuilder는 실제 물리적인 스텝을 직접 생성하는 빌더가 아니다. 이 클래스는 들어오는 입력 파라미터(chunkSize 혹은 tasklet)에 따라 적합한 하위 구현 전용 빌더를 판단하여 돌려주는 Entry Point이자 Router의 성격을 띤다.
// StepBuilder 내부 분기 코드
public <I, O> ChunkOrientedStepBuilder<I, O> chunk(int chunkSize) {
return new ChunkOrientedStepBuilder<>(this, chunkSize);
}
public TaskletStepBuilder tasklet(Tasklet tasklet) {
return new TaskletStepBuilder(this).tasklet(tasklet);
}
- ChunkOrientedStepBuilder: 청크 지향 Step을 구성하기 위한 전용 빌더 객체다.
- TaskletStepBuilder: 단일 작업 대상인 Tasklet 지향 Step을 전담하는 빌더 객체다.
이처럼 StepBuilder는 편리한 단일 진입점 역할을 제공하고, 내부적으로 팩토리 메서드처럼 실제 생성을 처리하는 특화된 빌더 객체들을 생성하여 반환하는 정교한 책임을 가지고 있다.
3.2 ChunkOrientedStepBuilder.build() 단계별 분해
스텝 구성을 종결하는 build() 메서드는 장황해 보이지만 크게 7개 단계의 검증 및 융합 정책을 거쳐 동작한다.
Phase 1: 필수 컴포넌트 검증 (Validation Check)
첫 진입점에서는 구성할 스텝의 최소한의 생존 조건을 확인한다.
Assert.notNull(this.reader, "Item reader must not be null");
Assert.notNull(this.writer, "Item writer must not be null");
청크 아키텍처의 필수 규칙에 따라 데이터를 가져오는 ItemReader와 내보내는 ItemWriter는 널 체크 검증을 엄격하게 거치지만, 가공을 담당하는 ItemProcessor는 선택적(Optional) 요소이므로 유효성 검사 대상에서 제외되어 있다.
Phase 2: Step 본체 생성 및 기본 속성 장착
스텝의 핵심 뼈대이자 구현체인 ChunkOrientedStep 인스턴스가 할당되며 부모 빌더인 StepBuilderHelper의 enhance()에 의해 부가 생명주기 제어 속성(allowStartIfComplete, startLimit)들이 수혈된다.
ChunkOrientedStep<I, O> chunkOrientedStep = new ChunkOrientedStep<>(
this.getName(),
this.chunkSize,
this.reader,
this.writer,
this.getJobRepository()
);
super.enhance(chunkOrientedStep);
Phase 3: ItemStream 및 StepListener 자동 감지 (Auto-Detection)
전달된 핸들러(Reader, Processor, Writer) 내부를 리플렉션과 인터페이스 타입 상속 구조를 대조해 뜯어본다. 만약 이들이 ItemStream이나 StepListener 명세를 상속하고 있다면, 명시적 설정이 누락되었어도 내부 컬렉션(streams, stepListeners)에 알아서 매핑해 준다.
private void addAsStreamAndListener(Object itemHandler) {
// 1. 자원 제어 명세(ItemStream) 자동 감지 및 등록 후보군 적재
if (itemHandler instanceof ItemStream itemStream) {
this.streams.add(itemStream);
}
// 2. 인터페이스 기반 StepListener 감지
if (itemHandler instanceof StepListener listener) {
this.stepListeners.add(listener);
}
// 3. 애노테이션(@BeforeStep 등)을 붙인 커스텀 리스너 감지 및 동적 팩토리 프록시 변환
if (StepListenerFactoryBean.isListener(itemHandler)) {
StepListener listener = StepListenerFactoryBean.getListener(itemHandler);
this.stepListeners.add(listener);
}
}
이 유연한 장치 덕분에 개발자가 명시적으로 stream()이나 listener()를 직접 주입하지 않아도 내부적으로 라이프사이클에 맞물려 자원이 열리고 이벤트가 수신된다.
Phase 4: 청크 트랜잭션 환경 구축
트랜잭션 처리에 필요한 인프라를 등록한다. 별도로 명시하지 않은 경우에는 내부에 기본값이 세팅된다.
chunkOrientedStep.setTransactionManager(this.transactionManager);
chunkOrientedStep.setTransactionAttribute(this.transactionAttribute);
지정하지 않았을 때 할당되는 기본 트랜잭션 관리자는 실제 데이터베이스의 롤백 처리를 수행하지 않는 무연산 객체인 ResourcelessTransactionManager다.
Phase 5: 내결함성(Fault Tolerance) 시스템 구성
재시도(RetryPolicy)와 건너뛰기(SkipPolicy) 정책의 장착 여부를 검사해 적용한다.
// 기본 RetryPolicy 자동 조립
if (this.retryPolicy == null) {
if (!this.retryableExceptions.isEmpty() || this.retryLimit > 0) {
Set<Class<? extends Throwable>> exceptions = this.retryableExceptions.isEmpty()
? Set.of(Exception.class) : this.retryableExceptions;
this.retryPolicy = RetryPolicy.builder().maxRetries(this.retryLimit).includes(exceptions).build();
} else {
this.retryPolicy = throwable -> false;
}
}
chunkOrientedStep.setRetryPolicy(this.retryPolicy);
// 기본 SkipPolicy 자동 조립
if (this.skipPolicy == null) {
if (!this.skippableExceptions.isEmpty() || this.skipLimit > 0) {
this.skipPolicy = new LimitCheckingExceptionHierarchySkipPolicy(this.skippableExceptions, this.skipLimit);
} else {
this.skipPolicy = new NeverSkipItemSkipPolicy(); // 절대 스킵을 수용하지 않는 예외망
}
}
chunkOrientedStep.setSkipPolicy(this.skipPolicy);
chunkOrientedStep.setFaultTolerant(this.faultTolerant);
만약 건너뛰기 설정을 전혀 정의하지 않았다면, 스프링 배치는 절대로 스킵 동작을 수행하지 않는 엄격한 보수 정책인 NeverSkipItemSkipPolicy를 기본 탑재하여 가동 안정성을 보호한다.
Phase 6: ItemStream 일괄 등록
streams.forEach(chunkOrientedStep::registerItemStream);
앞서 Phase 3의 자동 감지 과정 및 명시적 선언으로 streams 세트에 채집되었던 자원 제어 도구들이 최종적으로 스텝에 등록된다.
Phase 7: 리스너 최종 등록
수집된 리스너 컬렉션을 돌며 다중 인터페이스 구현 여부를 각각 확인하여 타입별로 스텝에 밀어 넣는다.
stepListeners.forEach(stepListener -> {
if (stepListener instanceof ItemReadListener listener) {
chunkOrientedStep.registerItemReadListener(listener);
}
if (stepListener instanceof ItemProcessListener listener) {
chunkOrientedStep.registerItemProcessListener(listener);
}
if (stepListener instanceof ItemWriteListener listener) {
chunkOrientedStep.registerItemWriteListener(listener);
}
if (stepListener instanceof ChunkListener listener) {
chunkOrientedStep.registerChunkListener(listener);
}
if (stepListener instanceof StepExecutionListener listener) {
chunkOrientedStep.registerStepExecutionListener(listener);
}
});
- 동적 애노테이션 스캔: 인터페이스 구현체가 아닌 일반 Object에 리스너 애노테이션(@BeforeChunk 등)만 붙여 넘겼을 경우에도 StepListenerFactoryBean을 활용해 동적으로 적절한 리스너 프록시로 변환한 뒤 통합 등록한다.
3.3 ChunkOrientedStep.afterPropertiesSet(): 빌더를 우회하는 정면 승부와 인프라 템플릿의 완성
빌더 조립 과정이 정상 종결되면 최종적으로 chunkOrientedStep.afterPropertiesSet() 훅 메서드가 기동된다. 이 초기화 메서드의 핵심 역할은 "빌더를 우회한 수동 인스턴스화 시에도 최소한의 구조적 무결성을 증명하는 이중 검증"과 "실제 런타임에 청크 트랜잭션을 지배할 전용 엔진 컴포넌트의 생성"이다.
@Override
public void afterPropertiesSet() throws Exception {
super.afterPropertiesSet(); // 부모 수준의 JobRepository 할당성 확보
if (this.transactionManager == null) {
this.transactionManager = new ResourcelessTransactionManager();
}
if (this.transactionAttribute == null) {
this.transactionAttribute = new DefaultTransactionAttribute();
}
// 이중 유효성 검증
Assert.isTrue(this.chunkSize > 0, "Chunk size must be greater than 0");
Assert.notNull(this.itemReader, "Item reader must not be null");
Assert.notNull(this.itemWriter, "Item writer must not be null");
// 빌더 우회 호출 경로 시에도 자원 복구(ItemStream) 자동 등록 누락 없도록 재검증 적재
if (this.itemReader instanceof ItemStream itemStream) {
this.compositeItemStream.register(itemStream);
}
if (this.itemWriter instanceof ItemStream itemStream) {
this.compositeItemStream.register(itemStream);
}
if (this.itemProcessor instanceof ItemStream itemStream) {
this.compositeItemStream.register(itemStream);
}
// 실질적인 트랜잭션 통제 템플릿 최종 조립
this.transactionTemplate = new TransactionTemplate(
this.transactionManager, this.transactionAttribute
);
// 내결함성 처리용 엔진 구성 완료
if (this.faultTolerant) {
this.retryTemplate.setRetryPolicy(this.retryPolicy);
this.retryTemplate.setRetryListener(this.compositeRetryListener);
}
}
- 이중 검증의 설계 의도: 만약 개발자가 스프링의 빌더 편의 사양을 전혀 거치지 않고, new ChunkOrientedStep<>(...) 생성자를 직접 밀어 호출하여 스프링 컨테이너에 올렸더라도, 라이프사이클 훅이 도는 시점에 ItemStream 등록 누락을 교정하고 필수 조건인 TransactionTemplate과 RetryTemplate의 조립을 누락 없이 완결해 낸다.
4. 2단계: Step 실행 흐름 추적 (Execution Phase)
런타임에 스텝의 구동은 Step 인터페이스 계층에 선언된 execute(StepExecution)를 통해 개시된다. 그 전처리 과정과 내부 연쇄 동작의 맥락을 코드의 호출 흐름으로 따라가 보겠다.
4.1 진입 장막: SimpleStepHandler
// SimpleStepHandler.handleStep() 핵심 호출부
currentStepExecution = jobRepository.createStepExecution(step.getName(), execution);
currentStepExecution.setExecutionContext(new ExecutionContext(executionContext));
step.execute(currentStepExecution); // 실질적인 스텝 개시 호출
SimpleStepHandler는 DB에 스텝 실행 기록을 선점 기재(createStepExecution)한 뒤 영속 상태 관리 도구인 ExecutionContext를 동기화하여 스텝 본체의 인프라를 구동한다.
여기서 아주 중요한 실무적 교훈이 등장한다. 바로 @StepScope와 Step 빈 등록 사이의 상관관계다. createStepExecution() 메서드를 자세히 뜯어보면 스텝의 이름을 매개변수로 넘기기 위해 step.getName()을 호출하는 시퀀스가 먼저 동작한다. 이 단계는 아직 실질적인 스텝의 실행이 시작되기 직전이므로 스프링 배치의 StepScope 활성 영역 바깥에 위치해 있다.
만약 우리가 스텝 빈 자체에 무심코 @StepScope 어노테이션을 부착했다면 어떻게 될까? SimpleStepHandler가 step.getName()을 조회하려는 찰나, 아직 StepScope 활성 플래그가 전위되지 않은 상태에서 프록시 객체의 실제 대상 조회가 일어나므로 즉각 ScopeNotActiveException이 터지며 전체 배치가 붕괴한다. 이것이 스텝 선언 빈 자체에는 절대 @StepScope를 결합하면 안 되는 이유다.
4.2 실행의 뼈대: AbstractStep.execute() (템플릿 메서드 패턴)
실제 런타임 가동을 시작하면 모든 스텝의 공통 부모 계층인 AbstractStep.execute()가 호출되어 인프라 성격의 전/후처리를 진두지휘한다.
// AbstractStep.execute()의 논리적 7줄 축약 흐름
doExecutionRegistration(stepExecution); // 1. StepScope 활성화
getCompositeListener().beforeStep(stepExecution); // 2. Step Listener 실행
open(stepExecution.getExecutionContext()); // 3. 자원 활성화 (ItemStream.open)
doExecute(stepExecution); // 4. 자식 구현체에 실제 청크 흐름 위임
exitStatus = exitStatus.and(getCompositeListener().afterStep(stepExecution)); // 5. Step Listener 후처리
close(stepExecution.getExecutionContext()); // 6. 자원 닫기 (ItemStream.close)
doExecutionRelease(); // 7. StepScope 리소스 해제
- StepScope 활성화 (doExecutionRegistration): 이 시점부터 스프링 컨테이너에서 @StepScope로 어노테이션된 빈(Proxy)들의 실제 인스턴스 접근 및 지연 바인딩 환경이 유효하게 개방된다.
- ItemStream.open() 콜백: 다중 등록된 compositeItemStream을 순회하며 데이터 복구 앵커 포인트나 RDB 커넥션 스트리밍, 파일 파일 시스템 자원을 개방하고 이전 실패 지점의 시퀀스 값을 로드한다.
- 자원 정리 예외 누수 차단 전략 (close): 스프링 배치는 close() 과정 중 한 곳에서 에러가 터져 뒷순위 스트림들이 닫히지 못해 메모리 좀비 커넥션이 생기는 사태를 방지하기 위해 예외를 모아두었다가 순회가 끝난 후 한꺼번에 상위로 던지는 엄격한 방어구(Suppressed Exception) 기법을 사용한다.
4.3 청크의 심장: ChunkOrientedStep.doExecute()와 트랜잭션 전개
기본 무장 구성이 끝난 상태에서 제어권은 마침내 ChunkOrientedStep.doExecute() 자식 위임 메서드로 낙하한다.
@Override
protected void doExecute(StepExecution stepExecution) throws Exception {
// 1. 더 읽을 수 있는 청크 조각이 존재하는지 검증 루프
while (this.chunkTracker.get().moreItems() && !interrupted(stepExecution)) {
// 2. 청크 단위마다 개별 독립 트랜잭션 전개
this.transactionTemplate.executeWithoutResult(transactionStatus -> {
StepContribution contribution = stepExecution.createStepContribution();
processNextChunk(transactionStatus, contribution, stepExecution);
});
getJobRepository().update(stepExecution); // DB 메타 테이블에 중간 진척율 동기화
}
}
- StepContribution: 해당 루프 회차(청크 단위)에서 발생한 순수 통계값(readCount, filterCount 등)을 격리 보관하는 영리한 통계 바구니다.
- 통계 합산 정책: 청크 처리가 안전하게 종료되면 finally 블록 영역에서 stepExecution.apply(contribution)를 만나 부모의 누적 메타데이터에 온전하게 가산 및 동기화 처리된다.
5. 청크 내부 실행 상세 해부 (Chunk Pipeline)
이제 트랜잭션 보호 장막 안에서 실제 데이터를 핸들링하는 processNextChunk 내부의 순차적인 흐름을 코드 수준에서 쪼개어 해부해보자.

5.1 데이터 읽기 단계 (Read Pipeline)
readChunk()는 스텝에 설정된 chunkSize만큼 readItem() 호출을 반복 루프하여 청크 아이템 풀을 빌드한다.
private Chunk<I> readChunk(StepContribution contribution) throws Exception {
Chunk<I> chunk = new Chunk<>();
// 설정된 chunkSize에 도달할 때까지 또는 더 이상 읽을 데이터가 없을 때까지 반복
for (int i = 0; i < chunkSize && this.chunkTracker.get().moreItems(); i++) {
I item = readItem(contribution);
if (item != null) {
chunk.add(item); // 정상적으로 반환된 데이터를 청크 배열에 수집
}
}
return chunk;
}
실제 개별 아이템을 끄집어내는 readItem() 내부에서는 ItemReadListener의 콜백 라이프사이클과 타겟 리더의 실제 호출이 매핑된다.
private @Nullable I readItem(StepContribution contribution) throws Exception {
I item = null;
try {
this.compositeItemReadListener.beforeRead(); // beforeRead 리스너 개시
item = this.itemReader.read(); // 실제 데이터 저장소에서 레코드 획득
if (item == null) {
// 더 이상 조회할 데이터가 존재하지 않는 종단점 상태 전이
this.chunkTracker.get().noMoreItems();
} else {
contribution.incrementReadCount(); // 현재 청크의 readCount 1 가산
this.compositeItemReadListener.afterRead(item); // afterRead 리스너 전개
}
}
catch (Exception exception) {
this.compositeItemReadListener.onReadError(exception); // 에러 발생 콜백
throw exception; // 청크 트랜잭션 롤백을 위해 예외 전파
}
return item;
}
- noMoreItems()의 전이 의미: 데이터가 고갈되어 itemReader.read()가 null을 반환하면 chunkTracker가 무상태 앵커를 갱신한다. 이로 인해 상위의 전체 청크 반복 while문 조건이 깨지며 스텝은 비로소 후처리 단계로 평화롭게 나아갈 수 있게 된다.
5.2 데이터 가공 단계 (Process Pipeline)
수집된 inputChunk를 순회하며 비즈니스 정책에 맞게 데이터를 여과하고 가공한다.
private Chunk<O> processChunk(Chunk<I> chunk, StepContribution contribution) throws Exception {
Chunk<O> processedChunk = new Chunk<>();
for (I item : chunk) {
O processedItem = processItem(item, contribution);
if (processedItem != null) {
processedChunk.add(processedItem); // 가공 완료된 최종 산출물 수집
}
}
return processedChunk;
}
가공 세부 제어 메서드인 processItem()은 필터링(Filtering) 동작에 따른 통계 변화를 밀접하게 추적한다.
private @Nullable O processItem(I item, StepContribution contribution) throws Exception {
O processedItem = null;
try {
this.compositeItemProcessListener.beforeProcess(item);
processedItem = this.itemProcessor.process(item); // 비즈니스 로직 가동
if (processedItem == null) {
// process 결과가 null이라는 것은 쓰기 대상에서 필터링(제외)하겠다는 선언
contribution.incrementFilterCount();
}
this.compositeItemProcessListener.afterProcess(item, processedItem);
}
catch (Exception exception) {
this.compositeItemProcessListener.onProcessError(item, exception);
throw exception; // 롤백 전파를 위한 예외 송출
}
return processedItem;
}
5.3 데이터 쓰기 및 커밋 단계 (Write Pipeline)
가공이 완료된 processedChunk를 영속화 레이어에 대용량 주입한다. 쓰기 작업은 단건의 반복이 아닌 벌크(Bulk) 처리가 원칙이므로 청크 단위의 일괄 가동이 수행된다.
private void writeChunk(Chunk<O> chunk, StepContribution contribution) throws Exception {
try {
this.compositeItemWriteListener.beforeWrite(chunk);
this.itemWriter.write(chunk); // 영속 데이터베이스에 청크 크기만큼 벌크 인서트/업데이트
contribution.incrementWriteCount(chunk.size()); // 정상 반영된 크기만큼 통계 합산
this.compositeItemWriteListener.afterWrite(chunk);
}
catch (Exception exception) {
this.compositeItemWriteListener.onWriteError(exception, chunk);
throw exception;
}
}
쓰기가 안전하게 종료되면 finally 블록 영역에서 상태 복구 앵커 포인트 업데이트가 이루어진다.
finally {
stepExecution.apply(contribution); // 개별 통계를 부모의 메타데이터 테이블 정보로 동기화
compositeItemStream.update(stepExecution.getExecutionContext()); // 트랜잭션 커밋 직전 최종 앵커링 정보 직렬화 보전
getJobRepository().updateExecutionContext(stepExecution);
}
6. 현대적 실무 트렌드: 내결함성 모드(Fault-Tolerant Engine) 해체 및 JSON/RDB 청크 스캐닝 동작 원리
실무 가동 환경의 진정한 진가는 비정상 상황 속에서 스텝이 자폭하지 않고 내결함성 필터링을 동적으로 처리하는 능력에 달려 있다. 스프링 배치가 제공하는 복원 로직의 진실을 깊이 있게 분해해 보겠다.
6.1 RetryTemplate과 SkipPolicy의 협업 메커니즘
내결함 모드(faultTolerant())가 발동되는 순간, 아이템 읽기(readItem)와 처리(processItem) 메서드는 내부적으로 Retryable 명세 구현체를 감싼 뒤 RetryTemplate.execute()에 의해 감금 보호 상태로 호출된다.
// RetryTemplate.execute()의 예외 가로채기 및 BackOff 처리 메커니즘 간소화
try {
return retryable.execute(); // 최초 수행 시도
} catch (Throwable initialException) {
// 재시도 정책 및 타겟 예외 적합성 대조
while (this.retryPolicy.shouldRetry(lastException)) {
long duration = backOffExecution.nextBackOff();
if (duration == BackOffExecution.STOP) { // 재시도 최대 횟수 한계 소진 감지
break;
}
Thread.sleep(duration); // 지수 백오프 대기 기동
try {
return retryable.execute(); // 재시도 실행
} catch (Throwable currentException) {
lastException = currentException; // 갱신 후 루프 유지
}
}
throw new RetryException("Retry exhausted", lastException); // 최종 고갈 전파
}
- 재시도 고갈 시 건너뛰기 단계 전이: 재시도가 최종 실패하여 RetryException이 호출 컨텍스트에 전파되면, 바깥쪽 catch 블록이 이를 가로채 doSkipInRead()를 호스트한다.
// doSkipInRead() 내부 구조
if (this.skipPolicy.shouldSkip(cause, contribution.getStepSkipCount())) {
this.compositeSkipListener.onSkipInRead(cause); // 스킵 리스너 발행
contribution.incrementReadSkipCount(); // 누적 스킵 횟수 증가
// ⚠️ 중요: 이때 chunkTracker.noMoreItems()는 절대로 호출되지 않는다!
}
- 동작적 진실: 건너뛰기 성공 시 readItem()은 최종 null을 반환하지만, noMoreItems 플래그가 전위되지 않았기 때문에 읽기 루프는 중단되지 않고 다음 번 데이터를 이어서 계속 획득한다.
6.2 JSON/RDB 청크 쓰기 예외 발생 시의 최후의 보루: 청크 스캐닝(Scanning) 원리
아이템 쓰기(write)는 청크 크기로 단번에 영속 처리를 위임하기 때문에, 단 하나의 레코드에서만 외래키 무결성 위반이나 JSON 포맷 파싱 에러가 발생해도 청크 전체가 롤백 전파 대상이 된다.

이때 스프링 배치는 "범인 색출 작전"인 청크 스캐닝(Chunk Scanning)을 동적으로 시작하여, 정상적인 아이템만 가려내고 에러 유발 건만 정교하게 도려낸다.
청크 스캐닝 동작 코드 분석 (scan)
private void scan(Chunk<O> chunk, StepContribution contribution) {
for (O item : chunk) {
Chunk<O> singleItemChunk = new Chunk<>(item); // 오직 1건짜리 초미니 청크 생성
try {
this.compositeItemWriteListener.beforeWrite(singleItemChunk);
this.itemWriter.write(singleItemChunk); // 1건 단위 격리 쓰기
contribution.incrementWriteCount(singleItemChunk.size());
this.compositeItemWriteListener.afterWrite(singleItemChunk);
}
catch (Exception exception) {
// 스캐닝 중 발생한 예외가 스킵 정책 조건에 부합하는지 꼼꼼히 재검증
if (this.skipPolicy.shouldSkip(exception, contribution.getStepSkipCount())) {
this.compositeSkipListener.onSkipInWrite(item, exception); // 리스너 알림
contribution.incrementWriteSkipCount(); // 스킵 카운트 누적
}
else {
// 스킵할 수 없는 예외라면 즉시 쓰기 중단 처리 및 포렌식 전파
throw new NonSkippableWriteException("Skip policy rejected skipping item", exception);
}
}
}
}
- 실무적 고찰: 청크 스캐닝이 전개되면 100건짜리 대용량 단일 트랜잭션 인서트가 실패했을 경우, 99번의 개별 인서트와 1번의 롤백이 동적으로 파티셔닝 처리된다. 따라서 대용량 성능 지연을 수반하므로, 정말 스킵해야만 하는 타겟 예외(예: 단순 데이터 미스매치)와 즉각 작동을 멈춰야 하는 인프라 에러(예: 네트워크 유실)의 SkippableException 스펙을 칼날처럼 예리하게 구획해 두어야만 한다.
7. 실무 팁 및 주의사항 (Tips & Notes)
- @StepScope 다중 리스너 유실 트랩: @StepScope 선언을 커스텀 구현 클래스 대신 인터페이스 형태로 반환하면 스프링은 JDK Dynamic Proxy를 기반으로 프록시 객체를 만들어낸다. 이 경우 원본 클래스가 구현하고 있던 다중 리스너 인터페이스 명세가 프록시 단계에서 유실되어 스텝 빌더의 "다중 리스너 자동 감지 정책"이 완전히 침묵하게 된다. 반드시 Bean 메서드 반환 타입은 인터페이스 대신 구체 클래스명으로 선언하자.
- ResourcelessTransactionManager의 무서운 침묵: 트랜잭션 관리자를 수동으로 지정하지 않았을 때 스프링 배치가 할당해 주는 ResourcelessTransactionManager는 커밋과 롤백 신호에 오직 빈 로그만 찍을 뿐, 어떠한 실제 DB 물리 롤백도 전개하지 않는다. 데이터 안정성이 필요한 스텝이라면 반드시 실질적인 데이터 소스 트랜잭션 관리자를 수동으로 설정해야 한다.
8. 마무리 (Conclusion)
| 상항 및 예외 현상 | 아키테처적 원인 분석 | 최적의 설계 대안 및 해결책 |
| @StepScope 리더가 리스너 동작에 응답하지 않음 | JDK Dynamic Proxy 프록시 생성에 의한 인터페이스 유실 트랩 | Bean 메서드 반환 형식을 인터페이스에서 구체 구현 클래스로 변경 |
| 스킵 설정이 누락되어 가벼운 에러에도 스텝 전체 자폭 | 사용자가 스킵을 명시하지 않아 NeverSkipItemSkipPolicy 적용됨 | skipPolicy() 설정 부여 혹은 skip(CustomException.class) 보완 |
| 쓰기 예외 발생 시 지수적인 성능 병목 유발 | 롤백 발생 시 청크 스캐닝(scan()) 모드 가동에 따른 1건씩 인서트 전개 | 무거운 네트워크 유실 예외 등은 스킵 불허(noSkip()) 처리하여 조기 중단 실현 |
| DB 예외가 났으나 테이블이 전혀 롤백되지 않음 | 스텝 구동 환경에 ResourcelessTransactionManager 기본 할당됨 | 실질적인 DB 데이터소스에 결합된 PlatformTransactionManager 명시 주입 |
| 스텝 가동 전 지연 바인딩 에러 터짐 | 스텝 선언 빈 자체에 무리하게 @StepScope를 다이렉트로 결합함 | 스텝의 빈 선언은 싱글톤으로 유지하되 하부 컴포넌트(Reader 등)에만 좁게 부여 |
스프링 배치의 내부 소스 코드를 심도 있게 뜯어보는 과정은 비록 거칠고 방대하지만, 한 번 그 심장부의 동작 주체와 위임 흐름을 완전하게 매칭하고 나면 예기치 못한 비상 상황 속에서도 문제를 능숙하게 파헤쳐 복원해 낼 수 있는 설계 지배력이 생긴다.
스텝의 단단한 라이프사이클 뼈대와 예외 복원 정책을 무기로, 고성능 대규모 비즈니스 배치를 안심하고 구축해 보길 바란다.
'Spring > Batch' 카테고리의 다른 글
| [Spring Batch] ItemProcessor 동작 원리와 4대 데이터 처리 전략 (0) | 2026.05.18 |
|---|---|
| [Spring Batch] ItemStream의 자원 관리와 상태 복구 (0) | 2026.05.17 |
| [Spring Batch] 위임과 복합 컴포넌트 정리: Composite, Classifier, Mapping (0) | 2026.05.16 |
| [Spring Batch] NoSQL : MongoDB 커서 방식부터 Redis SCAN 정리 (0) | 2026.05.16 |
| [Spring Batch] RDB 대용량 데이터 처리의 정석: JDBC vs JPA 분석 (1) | 2026.05.14 |