배치 처리의 핵심은 '대량의 데이터를 얼마나 안전하고 효율적으로 다루는가'에 있다. 일반적인 웹 애플리케이션의 단건 조회 방식으로는 수천만 건의 데이터를 감당할 수 없으며, 무턱대고 데이터를 한꺼번에 조회했다가는 OutOfMemoryError로 인해 시스템 전체가 마비될 수 있다.
스프링 배치는 이러한 위험을 방지하기 위해 데이터를 정교하게 쪼개서 읽고 쓰는 강력한 도구들을 제공한다. 오늘은 JDBC와 JPA를 활용한 읽기/쓰기 전략과 실무에서 반드시 피해야 할 성능 함정들을 상세히 정리해 본다.
2. 대용량 처리를 위한 두 가지 읽기 전략: Cursor vs Paging
데이터베이스에서 대량의 데이터를 가져올 때 스프링 배치는 크게 커서(Cursor) 기반과 페이징(Paging) 기반이라는 두 가지 전략을 사용한다.

2.1 커서 기반 읽기 (Cursor-based)
데이터베이스와 연결을 유지한 채 커서를 한 칸씩 옮기며 데이터를 스트리밍 방식으로 읽어온다.
- 특징: 하나의 커넥션으로 전체 결과셋을 순차적으로 처리한다.
- 장점: 메모리 사용량이 매우 적고 읽기 속도가 우수하다.
- 단점: 작업 시간이 너무 길어지면 DB 커넥션을 너무 오래 점유하게 되어 다른 프로세스에 영향을 주거나, 네트워크 단절 시 커서가 닫힐 위험이 있다.
2.2 페이징 기반 읽기 (Paging-based)
데이터를 일정 크기(Page Size)로 잘라서 여러 번의 쿼리를 통해 나눠 읽는다.
- 특징: 각 페이지를 읽을 때마다 새로운 쿼리를 실행한다.
- 장점: 커넥션 점유 시간이 짧아 안정적이고, 여러 스레드에서 병렬 처리를 하기에 유리하다.
- 단점: 페이지 번호가 뒤로 갈수록 DB 성능이 저하될 수 있다 (주로 Offset 방식의 한계).
3. JDBC ItemReader: 가장 원시적이지만 강력한 무기
3.1 JdbcCursorItemReader (스트리밍의 정석)
ResultSet을 통해 데이터를 한 행씩 가져오며, RowMapper를 통해 객체로 변환한다.
구현 예제 및 분석
@Bean
public JdbcCursorItemReader<Victim> terminatedVictimReader() {
return new JdbcCursorItemReaderBuilder<Victim>()
.name("terminatedVictimReader") // 리더 이름 (로깅용)
.dataSource(dataSource) // 주입받은 DB 연결 정보
// 파라미터는 '?'로 마스킹 처리하여 SQL 인젝션 방지
.sql("SELECT * FROM victims WHERE status = ? AND terminated_at <= ? ORDER BY id ASC")
// ? 자리에 들어갈 파라미터를 순서대로 바인딩
.queryArguments(List.of("TERMINATED", LocalDateTime.now()))
// DB 컬럼명과 객체 필드명을 자동으로 매핑 (CamelCase 자동 변환)
.beanRowMapper(Victim.class)
// 핵심 최적화: 한 번의 통신으로 가져올 로우(row) 수
.fetchSize(1000)
.build();
}
💡 실무 팁: Fetch Size의 비밀 커서 기반이라고 정말 네트워크를 통해 한 줄씩 가져올까? 아니다. JDBC 드라이버는 내부적으로 fetchSize만큼 데이터를 버퍼에 미리 적재한다. 이 값을 적절히 설정해야 네트워크 왕복 횟수를 줄여 성능을 최적화할 수 있다.
- MySQL 주의: MySQL은 기본적으로 쿼리 결과를 한 번에 메모리로 다 가져온다. useCursorFetch=true 옵션을 설정해야 실제 스트리밍 방식이 작동한다.
3.2 JdbcPagingItemReader (안정적인 데이터 분할)
페이지 단위로 끊어 읽을 때, 성능을 위해 반드시 Keyset 기반 페이징을 사용해야 한다.
Keyset 페이징 vs Offset 페이징

- Offset 방식: LIMIT 10 OFFSET 100만은 DB가 앞의 100만 개를 다 읽고 버리는 비효율을 낳는다.
- Keyset 방식: WHERE id > 100만 LIMIT 10은 인덱스를 타고 즉시 필요한 데이터로 점프한다.
구현 예제
@Bean
public JdbcPagingItemReader<Victim> terminatedVictimReader() throws Exception {
return new JdbcPagingItemReaderBuilder<Victim>()
.name("terminatedVictimReader")
.dataSource(dataSource)
.pageSize(10) // Chunk Size와 동일하게 맞추는 것을 권장
.selectClause("SELECT id, name, process_id, status")
.fromClause("FROM victims")
.whereClause("WHERE status = :status")
// Keyset 페이징의 핵심: 유니크한 정렬 키가 필수적이다.
.sortKeys(Map.of("id", Order.ASCENDING))
.parameterValues(Map.of("status", "TERMINATED"))
.beanRowMapper(Victim.class)
.build();
}
4. JdbcBatchItemWriter: DB에 INSERT 폭격을 날리는 법
읽기가 끝났다면 저장은 효율적이어야 한다. JdbcBatchItemWriter는 JDBC의 batchUpdate 기능을 사용한다.

- Batch Update의 위력: 일반적인 INSERT가 매번 DB와 통신한다면, Batch Update는 쿼리 템플릿 하나에 파라미터 뭉치를 실어 한 번에 보낸다. 청크 크기가 100이라면 통신 횟수는 1/100로 줄어든다.
@Bean
public JdbcBatchItemWriter<HijackedOrder> rescuedOrderWriter() {
return new JdbcBatchItemWriterBuilder<HijackedOrder>()
.dataSource(dataSource)
// 네임드 파라미터(:status, :id)를 사용하여 가독성 확보
.sql("UPDATE orders SET status = :status WHERE id = :id")
// HijackedOrder 객체의 필드를 SQL 파라미터명에 자동으로 매핑
.beanMapped()
// 업데이트 결과가 0건일 경우 예외를 발생시켜 데이터 무결성 보장
.assertUpdates(true)
.build();
}
5. JPA ItemReader/Writer: 객체 지향과 배치의 조화 (혹은 함정)
JPA는 ORM의 표준이지만, 대용량 처리 시 내부 메커니즘을 모르면 성능 함정에 빠지기 쉽다.
5.1 JpaPagingItemReader의 치명적 약점
JpaPagingItemReader는 내부적으로 setFirstResult()와 setMaxResults()를 사용하는데, 이는 DB 레벨에서 OFFSET 기반 페이징으로 변환된다. 따라서 수천만 건 이상의 데이터를 처리할 때는 성능이 처참하게 떨어진다.
5.2 JPA 페이징 시 N+1 문제와 @BatchSize
페이징 쿼리 시 연관된 데이터를 함께 가져오려면 Fetch Join을 생각하기 쉽지만, JPA 페이징과 Fetch Join은 함께 쓰면 안 된다. 하이버네이트가 페이징을 처리하기 위해 전체 데이터를 메모리에 다 올려버리기 때문이다.
- 해결책: 지연 로딩을 쓰되, @BatchSize를 활용하여 IN 쿼리로 연관 데이터를 일괄 조회하자.
@Entity
@Table(name = "posts")
@Getter
public class Post {
@Id private Long id;
// 지연 로딩을 유지하면서, 배치 처리를 위해 @BatchSize로 최적화
@OneToMany(mappedBy = "post", fetch = FetchType.EAGER)
@BatchSize(size = 100)
private List<Report> reports = new ArrayList<>();
}
5.3 transacted(false) 옵션의 중요성
JpaPagingItemReader는 페이지를 읽을 때마다 독자적으로 트랜잭션을 시작하고 entityManager.clear()를 호출한다. 이는 두 가지 심각한 문제를 일으킨다.

- @BatchSize 무력화: 트랜잭션이 페이지 로딩 직후 종료되므로, 프로세서에서 연관 데이터 접근 시 LazyInitializationException이 발생하거나 최적화가 작동하지 않는다.
- 의도치 않은 Flush: 페이지 조회 전 flush()를 호출하여 프로세서에서 수정한 데이터가 미리 반영될 위험이 있다.
해결책: 반드시 .transacted(false) 옵션을 고려하라. 단, 이 경우 엔티티가 영속성 컨텍스트에서 분리(detach)되므로 필요한 데이터는 EAGER로 가져오거나 DTO로 프로젝션하는 것이 안전하다.
6. [긴급] ID 생성 전략과 배치 성능
배치 작업에서 저장 성능을 결정짓는 핵심 중 하나는 ID 생성 전략이다.
- IDENTITY 전략: DB에서 ID를 생성하기 전까지 하이버네이트는 ID를 모른다. 따라서 Batch Update가 불가능하고 매번 단건 INSERT를 날린다. 배치 성능의 주적이다.
- SEQUENCE 전략: 시퀀스 값을 미리 대량 할당받아(allocationSize) 메모리에서 사용하기 때문에 Batch INSERT가 가능하다.
💡 권고: 배치 전용 테이블이라면 무조건 SEQUENCE 전략을 사용하라. TABLE 전략은 행(row) 잠금으로 인해 성능이 더 나쁘다.
7. 마무리하며: 상황별 최적의 무기 선택 가이드
상황추천 도구핵심 전략
| 압도적인 읽기 성능 | JdbcCursorItemReader | fetchSize 최적화 필수, MySQL 옵션 주의 |
| 작업 안정성 중시 | JdbcPagingItemReader | Keyset 방식 구성, Unique Sort Key 필수 |
| 객체 중심 비즈니스 | JpaCursorItemReader | 스트리밍 지원 여부 확인, Fetch Join 활용 |
| JPA 대량 저장 | JpaItemWriter | SEQUENCE 전략 사용, merge() 오버헤드 주의 |
배치 처리에서 '적당히'란 없다. 한 번의 설계 실수가 데이터 오염이나 시스템 가동 중단으로 이어진다. 언제나 정렬(ORDER BY)을 잊지 말고, 실패 시 재시작 지점을 보장할 수 있는 설계를 습관화하자.
'Spring > Batch' 카테고리의 다른 글
| [Spring Batch] 위임과 복합 컴포넌트 정리: Composite, Classifier, Mapping (0) | 2026.05.16 |
|---|---|
| [Spring Batch] NoSQL : MongoDB 커서 방식부터 Redis SCAN 정리 (0) | 2026.05.16 |
| [Spring Batch] 스프링 배치 JSON 읽기/쓰기 전략 (0) | 2026.05.11 |
| [Spring Batch] 객체를 파일로 변환시키는 FlatFileItemWriter (0) | 2026.05.10 |
| [Spring Batch] 시스템의 기록을 읽어내는 FlatFileItemReader (0) | 2026.05.09 |