Spring Batch 마스터 노트 시리즈 4편. 데이터 소스별 ItemReader 구현체 — FlatFileItemReader의 LineMapper·LineTokenizer·FieldSetMapper 파이프라인, JdbcCursorItemReader의 커서 vs JdbcPagingItemReader의 페이지, thread-safe 차이로 병렬 처리 선택, @StepScope 동적 설정과 재시작까지.
이 글은 Spring Batch 마스터 노트 시리즈의 네 번째 편입니다. 3편(청크 처리)에서 ItemReader 인터페이스 자체를 잡았다면, 이번엔 실제 구현체 들이에요.
CSV 파일·관계형 DB·JPA·XML — 데이터 소스마다 최적화된 ItemReader가 따로 있어요. 올바른 구현체를 선택하면 코드량이 대폭 줄고 안정성이 높아집니다. 이번 편의 핵심 — FlatFileItemReader·JdbcCursorItemReader·JdbcPagingItemReader 세 구현체의 정확한 사용법과 선택 기준.
처음 ItemReader 선택이 어렵게 느껴지는 이유
이유는 두 가지예요.
첫째, JdbcCursor와 JdbcPaging의 차이가 미묘합니다. 둘 다 DB에서 읽는데 동작 방식·thread-safety·재시작 동작이 미묘하게 달라요. 잘못 선택하면 병렬 처리에서 데이터 누락이 발생하거나, 타임아웃에 걸릴 수 있습니다.
둘째, FlatFileItemReader의 컴포넌트가 한꺼번에 들어옵니다. LineMapper → LineTokenizer → FieldSetMapper — 세 단계 파이프라인이 한 번에 안 보여요. 어느 단계에서 무엇이 일어나는지 잡으면 디버깅이 쉬워집니다.
해결법은 한 가지예요. 데이터 소스별 한 줄짜리 선택 가이드를 외우세요. CSV/TXT = FlatFileItemReader, DB 단일 스레드 = JdbcCursorItemReader, DB 병렬 = JdbcPagingItemReader, 외부 API = 커스텀. 이 4가지만 잡으면 90% 결정이 끝납니다.
ItemReader 구현체 한눈에
| 구현체 | 데이터 소스 | 특징 |
|---|---|---|
FlatFileItemReader |
CSV, TXT 파일 | 구분자 또는 고정 폭 |
JdbcCursorItemReader |
JDBC DB | 커서 방식, thread-safe X |
JdbcPagingItemReader |
JDBC DB | 페이징, thread-safe ◯ |
HibernateCursorItemReader |
Hibernate | 커서 방식 |
HibernatePagingItemReader |
Hibernate | 페이징 방식 |
JpaPagingItemReader |
JPA | 페이징 방식 |
StaxEventItemReader |
XML | StAX 파서 |
JsonItemReader |
JSON | Jackson 기반 |
| 커스텀 ItemReader | 임의 | 직접 구현 |
커스텀 ItemReader
기본 패턴
데이터 양 적거나 데이터 소스가 특수할 때.
public class ProductNameItemReader implements ItemReader<String> {
private final List<String> productNames = Arrays.asList(
"Laptop", "Desktop", "Tablet", "Phone", "Printer",
"Monitor", "Keyboard", "Mouse", "Speaker", "Headphone"
);
private int currentIndex = 0;
@Override
public String read() {
if (currentIndex < productNames.size()) {
String productName = productNames.get(currentIndex);
currentIndex++;
return productName;
}
return null; // 종료 신호
}
}
API 호출 ItemReader
@Component
@StepScope
public class ApiItemReader implements ItemReader<Product> {
@Autowired
private ProductApiClient apiClient;
private List<Product> buffer = new ArrayList<>();
private int bufferIndex = 0;
private boolean apiCalled = false;
@Override
public Product read() throws Exception {
if (!apiCalled) {
buffer = apiClient.getAllProducts();
apiCalled = true;
}
if (bufferIndex < buffer.size()) {
return buffer.get(bufferIndex++);
}
return null;
}
}
FlatFileItemReader — CSV/TXT의 표준
처리 파이프라인
파일의 한 줄 텍스트
└→ LineMapper (LineTokenizer + FieldSetMapper 조합)
├→ LineTokenizer (텍스트 → FieldSet)
│ └→ DelimitedLineTokenizer (쉼표·탭)
│ └→ FixedLengthTokenizer (고정 폭)
└→ FieldSetMapper (FieldSet → Java 객체)
└→ BeanWrapperFieldSetMapper (자동)
└→ 커스텀 FieldSetMapper (수동)
기본 설정
@Bean
public FlatFileItemReader<Product> csvProductReader() {
FlatFileItemReader<Product> reader = new FlatFileItemReader<>();
// 1. 리소스
reader.setResource(new ClassPathResource("/data/product_details.csv"));
// 2. 헤더 스킵
reader.setLinesToSkip(1);
// 3. LineMapper
DefaultLineMapper<Product> lineMapper = new DefaultLineMapper<>();
// 4. DelimitedLineTokenizer
DelimitedLineTokenizer tokenizer = new DelimitedLineTokenizer();
tokenizer.setDelimiter(",");
tokenizer.setNames("product_ID", "product_name", "product_category", "product_price");
// 5. FieldSetMapper
lineMapper.setLineTokenizer(tokenizer);
lineMapper.setFieldSetMapper(new ProductFieldSetMapper());
reader.setLineMapper(lineMapper);
return reader;
}
FieldSetMapper
public class ProductFieldSetMapper implements FieldSetMapper<Product> {
@Override
public Product mapFieldSet(FieldSet fieldSet) throws BindException {
Product product = new Product();
product.setProductId(fieldSet.readInt("product_ID"));
product.setProductName(fieldSet.readString("product_name"));
product.setProductCategory(fieldSet.readString("product_category"));
product.setProductPrice(fieldSet.readDouble("product_price"));
return product;
}
}
fieldSet.readString("name"), readInt(...), readDouble(...) — 컬럼명 또는 인덱스(readInt(0))로 접근. 이름 기반이 더 명확.
람다 인라인
lineMapper.setFieldSetMapper(fieldSet -> {
Product product = new Product();
product.setProductId(fieldSet.readInt("product_ID"));
product.setProductName(fieldSet.readString("product_name"));
product.setProductCategory(fieldSet.readString("product_category"));
product.setProductPrice(fieldSet.readDouble("product_price"));
return product;
});
BeanWrapperFieldSetMapper — 자동 매핑
컬럼명과 필드명이 일치하면 자동.
BeanWrapperFieldSetMapper<Product> fieldSetMapper = new BeanWrapperFieldSetMapper<>();
fieldSetMapper.setTargetType(Product.class);
// 컬럼명을 필드명과 일치시키기
tokenizer.setNames("productId", "productName", "productCategory", "productPrice");
코드는 간결하지만 유연성 떨어져요.
다양한 형식
// TSV (탭 구분)
DelimitedLineTokenizer tsvTokenizer = new DelimitedLineTokenizer();
tsvTokenizer.setDelimiter("\t");
// 파이프(|) 구분
DelimitedLineTokenizer pipeTokenizer = new DelimitedLineTokenizer();
pipeTokenizer.setDelimiter("|");
// 고정 폭 (FixedLengthTokenizer)
FixedLengthTokenizer fixedTokenizer = new FixedLengthTokenizer();
fixedTokenizer.setNames("id", "name", "category");
fixedTokenizer.setColumns(
new Range(1, 5), // id: 1~5번 위치
new Range(6, 25), // name: 6~25번 위치
new Range(26, 50) // category: 26~50번 위치
);
Resource 선택
// ClassPathResource — JAR 내부 (읽기 전용)
reader.setResource(new ClassPathResource("/data/products.csv"));
// FileSystemResource — 외부 파일 시스템 (동적 경로)
reader.setResource(new FileSystemResource("C:/data/products.csv"));
reader.setResource(new FileSystemResource("input/products.csv"));
운영에서는 외부 경로가 일반적이라 FileSystemResource를 더 자주 사용.
여기서 시험 함정이 하나 있어요. 한글이 들어간 파일은 인코딩 설정 필요합니다. 기본값은 플랫폼 기본 인코딩.
reader.setEncoding("UTF-8"); // 또는 "EUC-KR"
JdbcCursorItemReader — 커서 방식
개요
JDBC 커서로 데이터 스트리밍. 한 번의 DB 연결로 전체 결과를 한 행씩 읽음. 메모리 효율 높음.
단, thread-safe 아님 — 단일 스레드 환경만.
설정
@Bean
public JdbcCursorItemReader<Product> dbCursorReader() {
JdbcCursorItemReader<Product> reader = new JdbcCursorItemReader<>();
reader.setDataSource(dataSource);
// ORDER BY 필수 — 커서 기반 읽기 순서 보장
reader.setSql("SELECT product_id, product_name, product_category, product_price " +
"FROM product_details " +
"ORDER BY product_id");
reader.setRowMapper(new ProductRowMapper());
return reader;
}
public class ProductRowMapper implements RowMapper<Product> {
@Override
public Product mapRow(ResultSet rs, int rowNum) throws SQLException {
Product product = new Product();
product.setProductId(rs.getInt("product_id"));
product.setProductName(rs.getString("product_name"));
product.setProductCategory(rs.getString("product_category"));
product.setProductPrice(rs.getDouble("product_price"));
return product;
}
}
람다 RowMapper
reader.setRowMapper((rs, rowNum) -> {
Product product = new Product();
product.setProductId(rs.getInt("product_id"));
product.setProductName(rs.getString("product_name"));
product.setProductCategory(rs.getString("product_category"));
product.setProductPrice(rs.getDouble("product_price"));
return product;
});
추가 옵션
reader.setFetchSize(100); // DB에서 한 번에 가져올 행 수 (힌트)
reader.setMaxItemCount(1000); // 최대 읽기 개수
reader.setCurrentItemCount(100); // 시작 위치 (100번째부터)
reader.setQueryTimeout(60); // 쿼리 타임아웃 (초)
reader.setSaveState(true); // 재시작 위한 상태 저장 (기본 true)
여기서 정말 중요한 시험 함정 — ORDER BY 빼먹으면 재시작 시 데이터 누락/중복 위험입니다. 정렬 없이 커서 읽으면 DB가 임의 순서로 반환할 수 있어요. 재시작 시 어디까지 읽었는지 추적이 안 됩니다. 항상 primary key로 ORDER BY.
JdbcPagingItemReader — 페이징 방식
개요
페이지 단위로 별도 SQL 쿼리 실행. thread-safe — 병렬 처리 가능.
페이지마다 새 쿼리 → DB 연결 부하는 약간 높지만 분산 환경에서 강력.
설정
@Bean
public JdbcPagingItemReader<Product> dbPagingReader() throws Exception {
JdbcPagingItemReader<Product> reader = new JdbcPagingItemReader<>();
reader.setDataSource(dataSource);
reader.setPageSize(3);
reader.setRowMapper(new ProductRowMapper());
reader.setQueryProvider(createQueryProvider().getObject());
return reader;
}
@Bean
public SqlPagingQueryProviderFactoryBean createQueryProvider() {
SqlPagingQueryProviderFactoryBean factory = new SqlPagingQueryProviderFactoryBean();
factory.setDataSource(dataSource);
factory.setSelectClause("select product_id, product_name, product_category, product_price");
factory.setFromClause("from product_details");
factory.setWhereClause("where product_price > :minPrice"); // 동적 조건
factory.setSortKey("product_id"); // 필수
Map<String, Object> parameterValues = new HashMap<>();
parameterValues.put("minPrice", 0);
return factory;
}
SqlPagingQueryProvider 상세
@Bean
public SqlPagingQueryProviderFactoryBean createQueryProvider() {
SqlPagingQueryProviderFactoryBean factory = new SqlPagingQueryProviderFactoryBean();
factory.setDataSource(dataSource);
factory.setSelectClause("select product_id, product_name, product_category, product_price");
factory.setFromClause("from product_details");
// WHERE / GROUP BY (선택)
// factory.setWhereClause("where product_category = 'Electronics'");
// factory.setGroupClause("group by product_category");
// 단일 정렬 키
factory.setSortKey("product_id");
// 다중 정렬 키 (필요 시)
// Map<String, Order> sortKeys = new LinkedHashMap<>();
// sortKeys.put("product_id", Order.DESCENDING);
// sortKeys.put("product_name", Order.ASCENDING);
// factory.setSortKeys(sortKeys);
return factory;
}
여기서 정말 중요한 시험 함정 — setSortKey() 빼먹으면 페이지 간 데이터 일관성 보장 X입니다. 데이터가 활발히 INSERT/UPDATE 되는 테이블에서 동일 데이터가 여러 페이지에 등장하거나 누락될 수 있어요. 반드시 유니크한 컬럼(보통 PK)을 정렬 키로.
// 잘못된 설정 — sortKey 없음
factory.setSelectClause("select *");
factory.setFromClause("from products");
// sortKey 없음! → 페이지 간 일관성 X
// 올바른 설정
factory.setSortKey("product_id"); // 유니크한 컬럼 필수
JdbcCursor vs JdbcPaging — 한 번 더 정리
| 구분 | JdbcCursorItemReader | JdbcPagingItemReader |
|---|---|---|
| DB 연결 | 하나 유지 | 페이지마다 새 쿼리 |
| 메모리 | 한 번에 한 행 | 한 페이지만큼 |
| 성능 | 빠름 (쿼리 1회) | 페이지 수만큼 쿼리 |
| Thread-Safe | 아님 | 됨 |
| 재시작 | 커서 위치부터 | 마지막 페이지부터 |
| ORDER BY | 필수 | 필수 (sortKey) |
| 적합 상황 | 단일 스레드 대용량 | 병렬, 분산 |
선택 기준
JdbcCursorItemReader — 단일 스레드 / DB 연결 최소화 / 메모리 효율 중시.
JdbcPagingItemReader — 병렬 처리 (Parallel Step) / 분산 처리 / 쿼리 타임아웃 회피 (페이지별 리셋).
ItemReader 공통 옵션
@StepScope와 동적 설정
@StepScope — Step 실행 시점에 ItemReader 생성. JobParameters 접근 가능.
@Bean
@StepScope
public FlatFileItemReader<Product> dynamicCsvReader(
@Value("#{jobParameters['inputFile']}") String inputFile) {
FlatFileItemReader<Product> reader = new FlatFileItemReader<>();
reader.setResource(new FileSystemResource(inputFile)); // 동적 경로
// ... 나머지 설정
return reader;
}
// Job 실행 시
JobParameters params = new JobParametersBuilder()
.addString("inputFile", "/data/2026-01-01-products.csv")
.toJobParameters();
여기서 시험 함정이 하나 있어요. @StepScope 없이 @Value("#{jobParameters[...]}") 쓰면 동작 X입니다. 빈 생성 시점에 jobParameters가 없거든요. @StepScope로 빈 생성을 Step 실행 시점으로 늦춰야 합니다.
saveState와 재시작
reader.setSaveState(false); // 재시작 시 처음부터
기본 true — DB에 현재 위치 저장. 재시작 시 중단점부터.
병렬 처리에서는 false 권장 — 여러 스레드가 같은 ItemReader 공유 시 상태 저장이 안전하지 않음.
실제 CSV 예시
product_details.csv
product_ID,product_name,product_category,product_price
1,IPhone 14 Pro,smartphone,120000
2,Samsung Galaxy S23,smartphone,110000
3,Sony WH-1000XM5,headphone,35000
4,Nike Air Max 270,sports accessories,15000
5,Adidas Ultraboost,sports accessories,18000
6,Dell XPS 15,laptop,200000
7,Apple MacBook Pro,laptop,250000
8,Samsung 4K TV,television,80000
9,LG OLED TV,television,120000
10,JBL Charge 5,speaker,15000
전체 코드
@Configuration
@EnableBatchProcessing
public class CsvBatchConfig {
@Autowired private JobBuilderFactory jobBuilderFactory;
@Autowired private StepBuilderFactory stepBuilderFactory;
@Bean
public FlatFileItemReader<Product> csvReader() {
FlatFileItemReader<Product> reader = new FlatFileItemReader<>();
reader.setLinesToSkip(1);
reader.setResource(new ClassPathResource("/data/product_details.csv"));
DefaultLineMapper<Product> lineMapper = new DefaultLineMapper<>();
DelimitedLineTokenizer tokenizer = new DelimitedLineTokenizer();
tokenizer.setNames("product_ID", "product_name", "product_category", "product_price");
lineMapper.setLineTokenizer(tokenizer);
lineMapper.setFieldSetMapper(fieldSet -> {
Product p = new Product();
p.setProductId(fieldSet.readInt("product_ID"));
p.setProductName(fieldSet.readString("product_name"));
p.setProductCategory(fieldSet.readString("product_category"));
p.setProductPrice(fieldSet.readDouble("product_price"));
return p;
});
reader.setLineMapper(lineMapper);
return reader;
}
@Bean
public Step csvStep() {
return stepBuilderFactory.get("csvStep")
.<Product, Product>chunk(3)
.reader(csvReader())
.writer(items -> items.forEach(System.out::println))
.build();
}
@Bean
public Job csvJob() {
return jobBuilderFactory.get("csvJob")
.incrementer(new RunIdIncrementer())
.start(csvStep())
.build();
}
}
일반적 실수와 주의사항
FlatFileItemReader 헤더 스킵 빠뜨림
setLinesToSkip(1) 안 박으면 헤더를 데이터로 파싱하다 오류.
DelimitedLineTokenizer 필드명 대소문자
tokenizer.setNames() 와 FieldSetMapper의 이름이 정확히 일치해야 함. "product_ID" vs "product_id" 다름.
JdbcCursorItemReader ORDER BY 필수
순서 보장 안 되면 재시작 시 데이터 누락/중복.
JdbcPagingItemReader sortKey 필수
선택처럼 보이지만 사실상 필수. 활성 테이블에서 데이터 일관성 깨짐.
인코딩 누락
한글 파일 = setEncoding("UTF-8") 명시.
ClassPathResource vs FileSystemResource
JAR 내부 = ClassPathResource (읽기 전용). 외부 동적 경로 = FileSystemResource (운영 일반적).
ItemReader 선택 가이드
데이터 소스가 CSV/TXT 파일?
→ FlatFileItemReader
데이터 소스가 관계형 DB?
→ 단일 스레드? → JdbcCursorItemReader
→ 병렬 처리? → JdbcPagingItemReader
데이터 소스가 특수? (API·메시지 큐 등)
→ ItemReader 직접 구현
시나리오별 추천
시나리오 1 — 매일 수신 CSV 처리: FlatFileItemReader + @StepScope + @Value("#{jobParameters['inputFile']}") (날짜별 동적 파일).
시나리오 2 — DB 단일 스레드 대량: JdbcCursorItemReader (메모리 효율).
시나리오 3 — 병렬 처리: JdbcPagingItemReader + Parallel Step (thread-safe).
시나리오 4 — REST API: 커스텀 ItemReader (내장 구현체로 처리 불가).
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 4편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- ItemReader 구현체 — Flat/JdbcCursor/JdbcPaging/Hibernate/Jpa/Stax/Json/커스텀
- 데이터 소스별 선택 가이드 — CSV→Flat / DB 단일→Cursor / DB 병렬→Paging
- 커스텀 ItemReader =
read()null 반환 = 종료 신호 - FlatFileItemReader 파이프라인 = LineMapper(LineTokenizer + FieldSetMapper)
- DelimitedLineTokenizer = 쉼표·탭·파이프 구분
- FixedLengthTokenizer = 고정 폭 (Range)
setLinesToSkip(1)= 헤더 스킵- BeanWrapperFieldSetMapper = 자동 매핑 (필드명 일치 시)
- 커스텀 FieldSetMapper = 수동 매핑 (유연)
- ClassPathResource (내부) vs FileSystemResource (외부)
- 한글 =
setEncoding("UTF-8")명시 - JdbcCursorItemReader thread-safe X — 단일 스레드만
- JdbcPagingItemReader thread-safe ◯ — 병렬 가능
- ORDER BY 필수 (Cursor) / sortKey 필수 (Paging)
- sortKey 없으면 페이지 간 일관성 X
- RowMapper —
mapRow(ResultSet, int)ResultSet → 객체 - 람다 RowMapper도 가능
- Cursor 옵션 — fetchSize·maxItemCount·queryTimeout·saveState
- Paging — pageSize ≈ chunkSize 일치 권장
@StepScope+@Value("#{jobParameters['xxx']}")= 동적 설정@StepScope없으면 jobParameters 접근 X- saveState — 기본 true (재시작 위치 저장), 병렬은 false 권장
- 시나리오 추천 — 매일 CSV / 단일 대량 / 병렬 / API
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 톤으로 묶어 정리되어 있어요. 4편 Reader 위에 5편 Writer가 거울처럼 따라옵니다.
- 1편 — Spring Batch 입문 (Job·Step·Chunk 모델)
- 2편 — Spring Batch Job 설정 (Tasklet과 Chunk Step)
- 3편 — 청크 처리 (Reader·Processor·Writer 패턴)
- 4편 — ItemReader 마스터 (현재 글)
- 5편 — ItemWriter 마스터 (FlatFile·JdbcBatch·Composite)
- 6편 — Job Flow와 리스너
- 7편 — 오류 처리 (Skip·Retry·SkipPolicy)
- 8편 — Spring Batch 5 마이그레이션
공식 문서: Spring Batch ItemReaders Reference에서 모든 구현체의 옵션을 확인할 수 있어요.
다음 글(5편)에서는 ItemWriter의 거울 구현 — FlatFileItemWriter의 LineAggregator·FieldExtractor, JdbcBatchItemWriter의 PreparedStatement·UPSERT, CompositeItemWriter로 여러 곳 동시 출력까지 풀어 갑니다.