ItemReader 마스터 — CSV·JdbcCursor·Paging

2026-05-03확률과 통계 마스터 노트

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가 거울처럼 따라옵니다.

공식 문서: Spring Batch ItemReaders Reference에서 모든 구현체의 옵션을 확인할 수 있어요.

다음 글(5편)에서는 ItemWriter의 거울 구현 — FlatFileItemWriter의 LineAggregator·FieldExtractor, JdbcBatchItemWriter의 PreparedStatement·UPSERT, CompositeItemWriter로 여러 곳 동시 출력까지 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!