스프링 배치(Spring Batch) 애플리케이션을 개발하다 보면 하나의 스텝(Step)에서 여러 개의 데이터 소스로부터 데이터를 읽어오거나, 읽어온 데이터를 여러 목적지에 동시에 저장해야 하는 요구사항을 마주하게 된다. 스프링 배치는 단일 Step에 하나의 ItemReader와 하나의 ItemWriter를 설정하는 것이 기본 구조이지만, 위임(Delegation) 패턴을 활용한 복합 컴포넌트들을 제공하여 이러한 한계를 깔끔하게 해결한다.
이 글에서는 여러 Reader와 Writer를 하나로 묶어 다루는 CompositeItemReader, CompositeItemWriter부터 데이터의 특성에 따라 분기 처리하는 ClassifierCompositeItemWriter, 그리고 스프링 배치 6에 새롭게 추가된 타입 변환 병기인 MappingItemWriter까지 상세히 분석한다.
[Spring Batch] NoSQL : MongoDB 커서 방식부터 Redis SCAN 정리
1. 도입부 (Introduction)현대적인 아키텍처에서 관계형 데이터베이스(RDBMS)의 스키마 제약을 벗어난 NoSQL의 활용도는 매우 높다. 특히 대규모 로그 분석이나 빠른 응답이 필요한 캐시 데이터 처리에
myblog01150.tistory.com
1. CompositeItemReader: 여러 데이터 소스의 순차적 실행
CompositeItemReader는 이름 그대로 여러 개의 ItemReader를 내부적으로 보유하고, 이를 순차적으로 실행해 주는 어댑터 컴포넌트다.

1.1 동작 원리 및 특징
- 순차적 탐색: 등록된 첫 번째 위임 대상(Delegate) Reader가 데이터를 모두 읽어 null을 반환하면, 자동으로 다음 순서의 Reader로 넘어가서 읽기 작업을 이어간다.
- 타입 일치 필요: 내부 리스트에 담기는 모든 Reader들은 최종적으로 동일한 자바 객체 타입(T)을 반환해야 한다.
- 빌더 미지원: 아쉽게도 스프링 배치에서 CompositeItemReader를 위한 전용 빌더 클래스를 제공하지 않는다. 따라서 아래와 같이 직접 객체를 생성하고 생성자나 수정자(Setter)를 통해 위임 대상을 주입해야 한다.
List<ItemStreamReader<UserLog>> readers = List.of(
coreDumpReader,
crashLogReader
);
CompositeItemReader<UserLog> compositeReader = new CompositeItemReader<>(readers);
1.2 주요 활용 상황
- 샤딩(Sharding) 데이터베이스 조회: 데이터가 여러 데이터베이스 인스턴스(샤드)로 분산되어 있을 때, 각 샤드를 바라보는 개별 ItemReader들을 하나의 CompositeItemReader로 묶어 단일 스텝에서 순차적으로 처리할 수 있다.
- 데이터 이관 및 통합: 레거시 시스템의 데이터 파일과 신규 시스템의 DB 테이블 데이터를 연달아 읽어와 하나의 비즈니스 로직으로 적재해야 할 때 유용하다.
2. CompositeItemWriter: 전방위적 멀티 데이터 저장
CompositeItemWriter는 하나의 청크(Chunk) 프로세싱이 끝난 데이터를 여러 목적지에 동시에 기록하고자 할 때 사용하는 복합 컴포넌트다.
2.1 동작 원리
CompositeItemWriter 내부의 write() 메서드는 아래와 같이 단순한 루프 구조로 짜여 있다. 청크 단위로 넘어온 아이템 뭉치를 내부에 등록된 모든 위임 대상 Writer들에게 순서대로 전부 전달하는 방식이다.
public void write(Chunk<? extends T> chunk) throws Exception {
for (ItemWriter<? super T> writer : delegates) {
writer.write(chunk);
}
}
2.2 구현 방식 (생성자 vs 빌더)
CompositeItemReader와 달리 CompositeItemWriter는 직접 생성자를 사용하는 방식과 전용 빌더(CompositeItemWriterBuilder)를 사용하는 방식 모두 지원한다.
// 1. 생성자를 사용한 직접 주입 방식
CompositeItemWriter<TargetData> writer = new CompositeItemWriter<>(
List.of(primaryDbWriter, secondaryDbWriter, fileBackupWriter)
);
// 2. CompositeItemWriterBuilder를 활용한 방식
CompositeItemWriter<TargetData> writer = new CompositeItemWriterBuilder<TargetData>()
.delegates(List.of(primaryDbWriter, secondaryDbWriter, fileBackupWriter))
.build();
2.3 실전 리팩토링 예시: 역할의 분리
종종 개발 과정에서 하나의 스텝 안에서 '데이터 집계'와 '기존 데이터 삭제/수정'을 동시에 해야 할 때가 있다. CompositeItemWriter를 모르면 ItemProcessor 내부에 지저분하게 집계나 저장 로직을 임시로 집어넣는 악수를 두게 된다.
// 잘못된 구현 예시: Processor가 집계/저장 역할까지 대리하는 경우
.processor(log -> {
logCounter.record(log); // 고유 역할을 벗어난 부수 효과(Side Effect) 발생
return log;
})
.writer(deleteLogWriter)
이는 배치 아키텍처 관점에서 명백한 오류다. ItemProcessor는 오직 데이터의 변환(Transform)과 필터링(Filter)에만 집중해야 한다. CompositeItemWriter를 도입하면 집계 영역과 삭제 영역을 완벽하게 분리할 수 있다.
@Bean
public Step logProcessingStep(
RedisItemReader<String, AttackLog> attackLogReader,
AggregateHackerAttackItemWriter aggregateWriter,
RedisItemWriter<String, AttackLog> deleteLogWriter
) {
return new StepBuilder("logProcessingStep", jobRepository)
.<AttackLog, AttackLog>chunk(10, transactionManager)
.reader(attackLogReader)
.writer(new CompositeItemWriterBuilder<AttackLog>()
.delegates(
aggregateWriter, // 1. 데이터 패턴 집계 및 기록
deleteLogWriter // 2. 처리가 완료된 원본 데이터 소각/삭제
)
.build())
.build();
}
💡 실무 딥다이브: 다중 Writer와 트랜잭션 동기화의 비밀
FlatFileItemWriter나 MongoItemWriter 등 일부 Writer들은 내부 성능 최적화를 위해 데이터를 버퍼에 쌓아두었다가 파일 쓰기나 네트워크 전송을 미루는 지연 쓰기(Buffering) 전략을 취한다.
만약 CompositeItemWriter에 FlatFileItemWriter(파일 출력)와 JdbcBatchItemWriter(DB 저장)를 함께 묶어서 실행하는 도중, JdbcBatchItemWriter에서 예외가 발생하면 어떻게 될까?
- 버퍼링이 없는 경우: 파일에는 이미 데이터가 쓰였는데, DB 작업은 런타임 예외로 인해 트랜잭션이 롤백된다. 결과적으로 파일 데이터와 데이터베이스 간의 치명적인 데이터 불일치가 발생한다.
- 버퍼링이 작동하는 경우: 스프링 배치는 트랜잭션 커밋 직전 단계인 beforeCommit() 시점까지 실제 파일 쓰기 작업을 미룬다. 따라서 앞선 다른 Writer 비즈니스 로직이 완전히 성공하여 커밋이 보장되는 순간에 파일 버퍼를 비우게 된다. 이를 통해 분산 소스 간의 트랜잭션 동기화 효과를 안전하게 누릴 수 있다.
3. ClassifierCompositeItemWriter: 데이터별 선택적 라우팅
모든 데이터를 일괄적으로 여러 위임 Writer에게 다 쏘아 보내는 CompositeItemWriter와 달리, ClassifierCompositeItemWriter는 아이템의 내부 상태나 값에 따라 서로 다른 목적지를 동적으로 선택하고자 할 때 사용한다.
3.1 라우팅 메커니즘
청크 단위로 들어온 데이터 뭉치는 내부 내부 심판관 역할을 하는 Classifier 구현체에 의해 하나씩 분석된다. 개별 아이템 단위로 분류 작업이 완료되면, 최종적으로 분류된 결과군에 맞게 위임 Writer들에게 분배되어 최종 실행된다.

3.2 실전 구현 코드 코드 예시
아래 예시는 데이터의 위험도(SeverityLevel) 필드 값에 따라 실시간 알림/통계용 Writer와 단순 보관용 로그 Writer로 분기하는 구조다.
// 1. 도메인 모델 정의
public enum SeverityLevel { CRITICAL, NORMAL }
@Data
public class AttackLog {
private long id;
private String targetIp;
private SeverityLevel severityLevel;
}
// 2. 핵심 분류기(Classifier) 구현
@RequiredArgsConstructor
public class AttackSeverityClassifier implements Classifier<AttackLog, ItemWriter<? super AttackLog>> {
private final ItemWriter<AttackLog> criticalWriter;
private final ItemWriter<AttackLog> normalWriter;
@Override
public ItemWriter<AttackLog> classify(AttackLog attackLog) {
// 데이터 내부 필드 값에 따라 타겟 Writer를 동적으로 결정하여 반환
if (attackLog.getSeverityLevel() == SeverityLevel.CRITICAL) {
return criticalWriter;
}
return normalWriter;
}
}
// 3. 스프링 빈 및 스텝 설정
@Bean
public ClassifierCompositeItemWriter<AttackLog> attackLogClassifierWriter(
AggregateHackerAttackItemWriter criticalAttackWriter,
NormalAttackLogWriter normalAttackWriter
) {
ClassifierCompositeItemWriter<AttackLog> writer = new ClassifierCompositeItemWriter<>();
// 준비된 커스텀 분류기를 어댑터에 장착
writer.setClassifier(new AttackSeverityClassifier(criticalAttackWriter, normalAttackWriter));
return writer;
}
@Bean
public Step classifyHackerAttackStep(
RedisItemReader<String, AttackLog> attackLogReader,
ClassifierCompositeItemWriter<AttackLog> attackLogClassifierWriter
) {
return new StepBuilder("classifyHackerAttackStep", jobRepository)
.<AttackLog, AttackLog>chunk(10, transactionManager)
.reader(attackLogReader)
.writer(attackLogClassifierWriter)
.build();
}
3.3 객체 타입 기반 분기(Polymorphic Routing) 팁
필드 값 분석뿐만 아니라 자바 객체의 다형성을 활용하여 instanceof 연산자로 라우팅을 칠 수도 있다. 상위 클래스(AttackLog)를 상속받은 하위 클래스들(CriticalAttackLog, NormalAttackLog)이 믹스되어 들어올 때 유용하다.
이 경우, 직접 Classifier 인터페이스를 상속하여 하드코딩하기보다 스프링 배치에서 기본 제공하는 상속 구조 기반 분류기인 SubclassClassifier 구현체를 가져다 쓰면 훨씬 정돈된 코드를 작성할 수 있다.
4. MappingItemWriter: 타입의 장벽을 허무는 아키텍처 (Spring Batch 6+)
과거 스프링 배치 5 버전까지는 CompositeItemWriter나 ClassifierCompositeItemWriter에 묶이는 모든 내부 위임 자식 컴포넌트들이 전부 완전히 동일한 입력 데이터 타입(T)을 바라보아야만 했다. 이 제약 조건 때문에 실무에서 큰 비효율이 발생하곤 했다.
4.1 기존 컴포넌트 아키텍처의 한계성
만약 동일한 원본 엔티티 객체(AttackLog)를 읽어서 다음 두 가지 처리를 한 번에 하고 싶다고 가정해 보자.
- Writer A: AttackLog 객체 전체 통계를 내기 위해 원본 그대로 수용 (ItemWriter<AttackLog>)
- Writer B: 내부에 깊숙이 박힌 핵심 공격자 메타 정보만 따로 추출해 Redis 블랙리스트 키로 캐싱 (ItemWriter<AttackerInfo>)
- CompositeItemWriter를 쓰면? 두 Writer의 수용 제네릭 타입이 달라 아예 컴파일 에러가 난다.
- ClassifierCompositeItemWriter를 쓰면? 타입도 일치해야 할 뿐만 아니라, 조건문 분기 구조라 데이터 당 어느 한쪽 Writer로만 흐르기 때문에 두 연산을 동시에 수행할 수 없다.
- 결국 예전에는 동일한 데이터를 두 번 읽어 들이도록 무의미하게 Step을 2개로 분리하는 아키텍처 비용을 지불해야 했다.
4.2 MappingItemWriter의 등장과 구조적 해결
스프링 배치 6에 새롭게 추가된 MappingItemWriter는 일종의 타입 변환 프록시/어댑터 역할을 담당한다. 입력 타입 T 데이터가 진입하면 지정된 매핑 함수(Function<T, U>)를 거쳐 가공된 타입 U로 변환한 다음, 하위 스트림의 ItemWriter<U>에게 밀어 넣어주는 혁신적인 구조다.

4.3 구현 소스 코드
아래와 같이 구성을 완료하면, 단 한 번의 청크 플러시 과정 안에서 멀티 도메인 객체 저장을 깔끔하게 처리해 낼 수 있다.
// 1. 변화된 도메인 모델 관계 설정
@Data
public class AttackLog {
private long id;
private String payload;
private AttackerInfo attackerInfo; // 내포된 서브 메타 객체
}
@Data
public class AttackerInfo {
private String attackerId;
private String sourceIp;
}
// 2. MappingItemWriter를 래핑한 Composite 구성의 핵심
@Bean
public CompositeItemWriter<AttackLog> compositeAttackLogWriter(
AggregateHackerAttackItemWriter aggregateHackerAttackItemWriter,
RedisItemWriter<String, AttackerInfo> blacklistAttackerWriter
) {
return new CompositeItemWriterBuilder<AttackLog>()
.delegates(
// [루트 1]: 원본 AttackLog 객체를 변환 없이(Function.identity()) 통계 Writer로 전송
new MappingItemWriter<>(Function.identity(), aggregateHackerAttackItemWriter),
// [루트 2]: 원본 객체에서 오직 AttackerInfo만 낚아채서(AttackLog::getAttackerInfo) 전용 레디스 적재 Writer로 가공 전송
new MappingItemWriter<>(AttackLog::getAttackerInfo, blacklistAttackerWriter)
)
.build();
}
// 3. 서브 목적지인 RedisItemWriter 인프라 설정
@Bean
public RedisItemWriter<String, AttackerInfo> blacklistAttackerWriter(
RedisConnectionFactory redisConnectionFactory
) {
RedisTemplate<String, AttackerInfo> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new JacksonJsonRedisSerializer<>(AttackerInfo.class));
template.afterPropertiesSet();
return new RedisItemWriterBuilder<String, AttackerInfo>()
.redisTemplate(template)
.itemKeyMapper(info -> "blacklist:" + info.getSourceIp())
.build();
}
5. 핵심 요약 및 컴포넌트 비교 테이블
스프링 배치가 제공하는 네 가지 위임 복합 무기는 비즈니스 상황에 맞춰 적절하게 조합했을 때 진가를 발휘한다.
| 컴포넌트 명칭 | 처리 모델 구분 | 다중 위임 대상(Delegates) 실행 방식 | 주 목적 및 활용 시나리오 |
| CompositeItemReader | Read | 등록된 순서대로 다 읽을 때까지(null 반환) 순차적으로 실행 | 분산 샤드 데이터 통합, 서로 다른 스토리지 순차적 마이그레이션 |
| CompositeItemWriter | Write | 하나의 동일한 청크를 등록된 모든 자식에게 일괄 순차적으로 배포 | 동일 데이터를 RDB 데이터 정합 및 타 저장소 백업에 동시 기록 |
| ClassifierCompositeItemWriter | Write | Classifier의 판단 결과에 따라 단 하나의 자식 Writer를 골라 분기 | 아이템 필드 상태값(Severity 등)에 따른 목적지 분기 라우팅 처리 |
| MappingItemWriter | Write | 매핑 함수로 입력 형 변환 후 하위 스트림 한 곳으로 토스 | (Spring 6 신규) 이종 타입 간 어댑터, Composite 하위 배치로 데이터 멀티 가공 |
6. 비하인드 스토리: 왜 자꾸 ItemStream이 보일까?
이번 포스팅에서 다룬 다양한 위임형 복합 컴포넌트(MultiResourceItemReader, CompositeItemReader, MappingItemWriter 등)의 내부 코드를 열어보면, 클래스 상속 및 인터페이스 구현 명세마다 유독 ItemStream이라는 단어가 반복해서 등장하는 것을 알 수 있다.
// 스프링 배치 프레임워크 내부 시그니처 설계 예시
public class CompositeItemReader<T> implements ItemStreamReader<T>
public class MappingItemWriter<T, U> implements ItemStreamWriter<T>
단순히 비즈니스 데이터를 읽고 쓰는 ItemReader, ItemWriter 마커 인터페이스 설계 뒤편에 배치 프레임워크는 왜 이 ItemStream 구조를 그토록 촘촘하게 엮어 두었을까?
대용량 배치 프로세싱 도중 서버가 셧다운 되었을 때 실패 지점을 정확히 기억하고 복구해내는 배치 상태 관리 아키텍처의 정답이 바로 이 인터페이스 계층에 숨어 있다. 다음 포스팅에서는 스프링 배치의 숨겨진 척추 역할을 하는 ItemStream 구조의 정체와 대용량 컨텍스트 관리의 비밀에 대해 상세히 다뤄보도록 하겠다.
'Spring > Batch' 카테고리의 다른 글
| [Spring Batch] ItemProcessor 동작 원리와 4대 데이터 처리 전략 (0) | 2026.05.18 |
|---|---|
| [Spring Batch] ItemStream의 자원 관리와 상태 복구 (0) | 2026.05.17 |
| [Spring Batch] NoSQL : MongoDB 커서 방식부터 Redis SCAN 정리 (0) | 2026.05.16 |
| [Spring Batch] RDB 대용량 데이터 처리의 정석: JDBC vs JPA 분석 (1) | 2026.05.14 |
| [Spring Batch] 스프링 배치 JSON 읽기/쓰기 전략 (0) | 2026.05.11 |