01 배치 기초
02 Chunk 처리
- 다양한 Reader와 Writer ← 현재 편
03 실행 제어
04 운영과 면접
- 스케줄링과 운영
- 면접 대비
이전 편에서 Reader → Processor → Writer의 흐름을 살펴봤다. Spring Batch는 DB, 파일, 메시지 큐 등 다양한 데이터 소스에 대한 Reader와 Writer 구현체를 기본 제공한다. 직접 구현할 필요 없이 빌더 패턴으로 간편하게 설정할 수 있다.
Reader 구현체 한눈에 보기
가장 자주 쓰는 것은 DB 기반 Reader다. 그중에서도 페이징 방식이 가장 일반적이다.
JpaPagingItemReader
JPA를 사용해서 페이지 단위로 데이터를 조회하는 Reader다. Spring Data JPA를 사용하는 프로젝트에서 가장 많이 쓴다.
@Bean
@StepScope
public JpaPagingItemReader<Order> orderReader(
@Value("#{jobParameters['targetDate']}") String targetDate) {
return new JpaPagingItemReaderBuilder<Order>()
.name("orderReader")
.entityManagerFactory(entityManagerFactory)
.queryString("SELECT o FROM Order o WHERE o.orderDate = :date ORDER BY o.id")
.parameterValues(Map.of("date", targetDate))
.pageSize(1000)
.build();
}
내부적으로 LIMIT/OFFSET 기반의 페이징 쿼리를 실행한다. pageSize(1000)이면 OFFSET 0 LIMIT 1000, OFFSET 1000 LIMIT 1000, ... 순서로 조회한다.
각 페이지 조회 시 새로운 트랜잭션으로 EntityManager를 열고 닫는다.
메모리 누수 방지 end
페이징 쿼리에 ORDER BY가 없으면 DB가 매 페이지마다 다른 순서로 데이터를 반환할 수 있다. 데이터 누락이나 중복 조회가 발생한다. ORDER BY id 같은 결정적 정렬을 반드시 추가하라.
페이징 방식의 한계
OFFSET 기반 페이징은 데이터가 많아질수록 느려진다. OFFSET 900000이면 DB가 90만 건을 스캔한 후 버려야 한다.
또 하나의 문제는 읽는 도중에 데이터가 변경되면 누락이나 중복이 발생할 수 있다는 점이다. 배치가 데이터를 수정하면서 읽는 경우, 수정된 데이터가 다음 페이지로 밀려나거나 사라질 수 있다.
JdbcPagingItemReader
JDBC를 직접 사용해서 페이징하는 Reader다. JPA의 오버헤드 없이 순수 SQL로 데이터를 조회한다.
@Bean
@StepScope
public JdbcPagingItemReader<Order> jdbcOrderReader(
DataSource dataSource,
@Value("#{jobParameters['targetDate']}") String targetDate) {
Map<String, Order> sortKeys = new LinkedHashMap<>();
sortKeys.put("id", Order.ASCENDING);
return new JdbcPagingItemReaderBuilder<Order>()
.name("jdbcOrderReader")
.dataSource(dataSource)
.selectClause("SELECT id, user_id, amount, order_date")
.fromClause("FROM orders")
.whereClause("WHERE order_date = :targetDate")
.sortKeys(sortKeys)
.parameterValues(Map.of("targetDate", targetDate))
.pageSize(1000)
.rowMapper((rs, rowNum) -> new Order(
rs.getLong("id"),
rs.getLong("user_id"),
rs.getBigDecimal("amount"),
rs.getString("order_date")
))
.build();
}
JPA와 달리 select, from, where, sort를 분리해서 지정한다. Spring Batch가 내부적으로 이들을 조합해서 DB별 최적화된 페이징 쿼리를 생성한다.
- 프로젝트에서 JPA를 사용하고, 엔티티 매핑이 필요하다 → JpaPagingItemReader
- 순수 성능이 중요하고, 복잡한 SQL이 필요하다 → JdbcPagingItemReader
- 데이터 건수가 수백만 이상이다 → JdbcPagingItemReader (JPA의 영속성 컨텍스트 오버헤드를 피할 수 있다)
Cursor 기반 Reader
페이징과 달리 DB 커서를 열어두고 한 건씩 스트리밍하는 방식이다. OFFSET 기반 페이징의 성능 문제를 회피할 수 있다.
@Bean
@StepScope
public JpaCursorItemReader<Order> cursorReader() {
return new JpaCursorItemReaderBuilder<Order>()
.name("cursorReader")
.entityManagerFactory(entityManagerFactory)
.queryString("SELECT o FROM Order o WHERE o.status = 'PENDING'")
.build();
}
페이징 vs 커서 비교
| 구분 | 페이징 | 커서 |
|---|---|---|
| DB 쿼리 횟수 | 페이지 수만큼 (N회) | 1회 |
| OFFSET 성능 | 뒤로 갈수록 느림 | 영향 없음 |
| DB 커넥션 | 페이지마다 열고 닫음 | 전체 처리 동안 유지 |
| 멀티스레드 | 안전 | 불가 (커서 공유 불가) |
| 메모리 | 페이지 크기만큼 | fetch size만큼 |
커서는 처리가 끝날 때까지 DB 커넥션을 점유한다. 처리 시간이 길면 커넥션 풀이 고갈될 수 있다. 커넥션 풀 크기와 처리 시간을 고려해서 사용해야 한다.
FlatFileItemReader
CSV, TSV 같은 구분자 기반 텍스트 파일을 읽는 Reader다. 외부 시스템에서 파일로 데이터를 주고받는 경우에 사용한다.
@Bean
public FlatFileItemReader<Order> fileReader() {
return new FlatFileItemReaderBuilder<Order>()
.name("fileReader")
.resource(new ClassPathResource("orders.csv"))
.delimited()
.delimiter(",")
.names("id", "userId", "amount", "orderDate")
.targetType(Order.class)
.linesToSkip(1) // 헤더 행 건너뛰기
.build();
}
linesToSkip(1)으로 CSV 헤더를 건너뛰고, names()에 지정한 필드명으로 자동 매핑한다.
고정 너비 파일도 읽을 수 있다.
.fixedLength()
.columns(new Range(1, 10), new Range(11, 20), new Range(21, 30))
.names("id", "name", "amount")
Writer 구현체
JdbcBatchItemWriter
JDBC Batch Insert로 대량 데이터를 효율적으로 저장하는 Writer다. JPA의 saveAll()보다 빠르다.
@Bean
public JdbcBatchItemWriter<Settlement> jdbcWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<Settlement>()
.dataSource(dataSource)
.sql("INSERT INTO settlement (seller_id, amount, target_date) " +
"VALUES (:sellerId, :amount, :targetDate)")
.beanMapped()
.build();
}
beanMapped()는 Settlement 객체의 필드명과 SQL의 :파라미터명을 자동으로 매핑한다.
JDBC Batch는 여러 INSERT를 하나의 네트워크 요청으로 묶어서 보낸다. 1,000건을 저장할 때 네트워크 왕복이 1,000번이 아니라 1번이다.
JpaItemWriter
JPA의 persist() 또는 merge()로 저장하는 Writer다.
@Bean
public JpaItemWriter<Settlement> jpaWriter() {
JpaItemWriter<Settlement> writer = new JpaItemWriter<>();
writer.setEntityManagerFactory(entityManagerFactory);
return writer;
}
JPA를 사용하므로 영속성 컨텍스트의 이점(변경 감지, 관계 매핑 등)을 활용할 수 있다. 하지만 대량 INSERT 성능은 JDBC Batch보다 떨어진다.
JPA는 영속성 컨텍스트 관리, 변경 감지 등의 오버헤드가 있다. 단순 INSERT가 주 목적이라면 JdbcBatchItemWriter가 2~5배 빠르다. 관계 매핑이나 엔티티 라이프사이클이 필요한 경우에만 JpaItemWriter를 사용하라.
FlatFileItemWriter
결과를 파일로 출력하는 Writer다. 배치 처리 결과를 CSV로 내보내거나, 외부 시스템에 전달할 파일을 생성할 때 사용한다.
@Bean
public FlatFileItemWriter<Settlement> fileWriter() {
return new FlatFileItemWriterBuilder<Settlement>()
.name("fileWriter")
.resource(new FileSystemResource("output/settlement.csv"))
.delimited()
.delimiter(",")
.names("sellerId", "amount", "targetDate")
.headerCallback(writer -> writer.write("seller_id,amount,target_date"))
.build();
}
커스텀 Reader/Writer
기본 제공 구현체로 커버되지 않는 경우, 직접 구현할 수 있다.
커스텀 Reader
public class ApiItemReader implements ItemReader<ExternalData> {
private final ExternalApiClient apiClient;
private Iterator<ExternalData> iterator;
@Override
public ExternalData read() {
if (iterator == null) {
List<ExternalData> data = apiClient.fetchAll();
iterator = data.iterator();
}
return iterator.hasNext() ? iterator.next() : null;
}
}
외부 API에서 데이터를 가져오는 커스텀 Reader다. null을 반환하면 읽기가 종료된다.
CompositeItemWriter
여러 Writer를 순서대로 실행하고 싶을 때 사용한다.
@Bean
public CompositeItemWriter<Settlement> compositeWriter() {
return new CompositeItemWriterBuilder<Settlement>()
.delegates(List.of(
jdbcWriter(),
fileWriter()
))
.build();
}
DB에 저장하면서 동시에 파일로도 출력하는 패턴이다.
DB 저장"] CW --> W2["FlatFileItemWriter
파일 출력"] style CW fill:#E8F4F8,stroke:#2196F3 style W1 fill:#E8F8E8,stroke:#4CAF50 style W2 fill:#E8F8E8,stroke:#4CAF50
Reader/Writer 선택 가이드
(성능 우선)"] I{"저장 대상?"} -->|DB (성능)| J["JdbcBatchItemWriter"] I -->|DB (JPA)| K["JpaItemWriter"] I -->|파일| L["FlatFileItemWriter"] I -->|복합| M["CompositeItemWriter"]
자주 하는 실수
페이징 쿼리에 정렬이 없으면 DB가 매 페이지 쿼리마다 다른 순서로 반환할 수 있다. 이미 처리한 데이터가 다시 나타나거나, 아예 누락되는 문제가 발생한다. JPQL에 반드시 ORDER BY를 명시하라.
[!DANGER] Reader에서 읽는 동안 같은 테이블을 수정하기
Reader가 orders 테이블을 읽으면서 Writer가 같은 orders 테이블의 status를 변경하면, OFFSET 기반 페이징에서 데이터가 밀려나 누락이 발생한다. 읽기 테이블과 쓰기 테이블을 분리하거나, 커서 기반 Reader를 사용하라.
[!DANGER] Reader 이름을 지정하지 않기
name()을 빼먹으면 재시작 시 어디까지 읽었는지 추적이 불가능하다. Spring Batch는 Reader 이름과 현재 위치를 ExecutionContext에 저장해서 재시작을 지원한다. 반드시 고유한 이름을 지정하라.