Spring Batch 입문 34편. Database 처리의 두 길 — Cursor 와 Paging. JdbcCursorItemReader vs JdbcPagingItemReader 정밀 비교, StoredProcedureItemReader, JpaPagingItemReader, RowMapper · PagingQueryProvider · SqlPagingQueryProviderFactoryBean, JdbcBatchItemWriter · JpaItemWriter, transaction 안 자동 commit, flush 시점 함정·N+1 까지 정리한 학습 노트. Part 6 마무리.
이 글은 Spring Batch 입문에서 운영까지 시리즈 48편 중 34편이에요. 33편 까지 file 시리즈 를 마쳤다면, 이번 34편은 batch 의 가장 흔한 데이터 소스 — Database. Part 6 의 마지막 편이에요.
왜 DB 가 batch 의 중심인가
대부분 enterprise 환경 의 데이터 = DB. — 공식 reference
배치가 다루는 건 대용량이에요. 그런데 단순 SQL 한 줄로 100만 row 를 읽으면 ResultSet(query 결과를 담는 객체) 이 전체를 메모리에 적재해 OOM(Out Of Memory) 으로 죽어요. Spring Batch 는 이걸 두 가지로 풀어요. 하나는 Cursor 기반 — DB cursor(결과를 한 행씩 가리키는 포인터) 를 한 번 열어 streaming(한 행씩 흘려보내는 방식) 으로 처리. 다른 하나는 Paging 기반 — 여러 query 로 page 단위로 잘라서 fetch.
| 전략 | 메커니즘 |
|---|---|
| Cursor-based | DB cursor 한 번 열어 streaming |
| Paging-based | 여러 query 로 page 단위 fetch |
Cursor-based — JdbcCursorItemReader
원리
ResultSet rs = stmt.executeQuery("SELECT * FROM CUSTOMER");
while (rs.next()) { // ← cursor 가 한 row 씩 전진
Customer c = mapRow(rs);
process(c);
}
DB cursor 가 streaming 의 표준 메커니즘이에요. ItemReader 의 read() 안에서 rs.next() + mapRow() 가 호출되는 구조죠.
사용 예제
@Bean
public JdbcCursorItemReader<CustomerCredit> reader(DataSource dataSource) {
return new JdbcCursorItemReaderBuilder<CustomerCredit>()
.name("creditReader")
.dataSource(dataSource)
.sql("SELECT id, name, credit FROM customer")
.rowMapper(new CustomerCreditRowMapper())
.build();
}
RowMapper
public class CustomerCreditRowMapper implements RowMapper<CustomerCredit> {
@Override
public CustomerCredit mapRow(ResultSet rs, int rowNum) throws SQLException {
CustomerCredit cc = new CustomerCredit();
cc.setId(rs.getInt("id"));
cc.setName(rs.getString("name"));
cc.setCredit(rs.getBigDecimal("credit"));
return cc;
}
}
Spring JDBC 의 표준 RowMapper 그대로예요. 컬럼명이 그대로 setter 와 매칭되면 BeanPropertyRowMapper.newInstance(CustomerCredit.class) 로 자동 매핑이 더 짧고요.
Cursor 의 핵심 옵션
| Property | 의미 |
|---|---|
fetchSize |
JDBC driver 에게 한 번에 fetch 할 row 수 힌트 |
maxRows |
ResultSet 최대 row 수 한도 |
queryTimeout |
query timeout (초) |
ignoreWarnings |
SQLWarning log vs 예외 |
verifyCursorPosition |
RowMapper 안 rs.next() 호출 검증 |
driverSupportsAbsolute |
ResultSet.absolute() 지원 driver → 큰 dataset 재시작 성능 ↑ |
useSharedExtendedConnection |
cursor connection 을 Step 처리에 공유 |
saveState |
ExecutionContext 저장 여부 |
fetchSize 의 의미
.fetchSize(500)
JDBC driver 가 한 번 round-trip 으로 가져올 row 수예요. 너무 작으면 network overhead 가 늘고, 너무 크면 메모리 부담이 커요. 운영 권장은 100~1000.
Cursor 의 함정
가장 큰 게 connection 점유예요. cursor 가 Step 전체 동안 connection 을 잡고 있어서, 큰 batch 면 connection 1개가 long-running 으로 묶여요. 그래서 DB 의 connection idle timeout 을 넘기는 사고가 자주 나죠. 다음은 thread-safety. JdbcCursorItemReader 는 thread-safe 가 아니라, multi-threaded Step 에서 쓰려면 SynchronizedItemStreamReader 로 감싸거나 partitioning 으로 분할해야 해요. 마지막으로 JDBC driver 마다 cursor 지원 방식이 달라요 — MySQL 은 Statement.setFetchSize(Integer.MIN_VALUE) 같은 특수 처리를 따로 해줘야 해요.
Paging-based — JdbcPagingItemReader
원리
-- 페이지 1
SELECT * FROM customer WHERE status='NEW' ORDER BY id LIMIT 1000 OFFSET 0;
-- 페이지 2
SELECT * FROM customer WHERE status='NEW' ORDER BY id LIMIT 1000 OFFSET 1000;
-- 페이지 3
...
매 페이지가 독립 query 예요. connection 을 매 페이지 open/close 하니까 DB 의 connection idle timeout 영향을 받지 않아요.
사용 예제
@Bean
public JdbcPagingItemReader<CustomerCredit> reader(
DataSource dataSource, PagingQueryProvider queryProvider) {
Map<String, Object> params = new HashMap<>();
params.put("status", "NEW");
return new JdbcPagingItemReaderBuilder<CustomerCredit>()
.name("creditReader")
.dataSource(dataSource)
.queryProvider(queryProvider)
.parameterValues(params)
.rowMapper(new CustomerCreditRowMapper())
.pageSize(1000)
.build();
}
PagingQueryProvider — DB 별 dialect
@Bean
public SqlPagingQueryProviderFactoryBean queryProvider() {
SqlPagingQueryProviderFactoryBean provider = new SqlPagingQueryProviderFactoryBean();
provider.setSelectClause("select id, name, credit");
provider.setFromClause("from customer");
provider.setWhereClause("where status = :status");
provider.setSortKey("id");
return provider;
}
DB 마다 LIMIT/OFFSET(페이지 잘라 가져오는 SQL 문법) 문법이 달라요. SqlPagingQueryProviderFactoryBean 이 DataSource 의 dialect(DB 별 SQL 방언) 를 자동 감지해서 맞춰줘요. 지원 DB 는 MySQL·PostgreSQL·H2 가 LIMIT ? OFFSET ?, Oracle 은 ROWNUM 또는 ROW_NUMBER() OVER, DB2 는 ROWNUMBER(), SQL Server 는 OFFSET ? ROWS FETCH NEXT ? ROWS ONLY. 거의 모든 주요 DB 가 들어 있어요.
sortKey 의 중요성
여기서 시험 함정이 하나 있어요.
provider.setSortKey("id");
sortKey 가 없거나 unique 하지 않으면 page 간 row 중복 또는 누락이 생겨요. 그래서 sortKey 는 unique 컬럼으로 — 보통 PK 가 안전해요. 비-unique 컬럼을 써야 한다면 Map<String, Order> 로 다중 정렬을 걸어주세요 (예: id DESC, createdAt ASC).
Paging 의 함정 — Deep Pagination
SELECT * FROM customer ORDER BY id LIMIT 1000 OFFSET 9999000;
OFFSET 이 아주 클 때, DB 는 9,999,000 row 를 정렬한 뒤 그만큼 skip 해요. 성능이 폭락하죠. 해결은 Keyset Pagination(마지막 키 이후만 가져오는 cursor 변형) 이에요.
SELECT * FROM customer WHERE id > :lastId ORDER BY id LIMIT 1000;
Spring Batch 6+ 가 일부 지원하고, 그 외엔 custom Paging Reader 로 직접 만들어요.
Cursor vs Paging — 정밀 비교
| 항목 | Cursor | Paging |
|---|---|---|
| Connection | 1개 long-running | 매 페이지 open/close |
| 메모리 | row 1개씩 (저점유) | page 크기만큼 |
| Long-running batch | timeout 위험 | ✓ 안전 |
| 재시작 안전성 | 위치 추적 | page 번호 |
| DB 부하 | 작음 (단일 query) | 많음 (page 마다 query) |
| Deep pagination | 영향 없음 | 성능 폭락 위험 |
| Thread-safety | X (synchronized wrap 필요) | ✓ |
| JDBC driver 의존성 | 큼 (cursor 지원) | 작음 |
대부분 운영은 Paging 권장이에요 — connection timeout 안전 + thread-safe + 단순. 예외는 진짜 streaming 이 필요하고 batch 가 길지 않은 경우, 이때만 Cursor 가 살아요.
StoredProcedureItemReader
@Bean
public StoredProcedureItemReader<CustomerCredit> spReader(DataSource ds) {
StoredProcedureItemReader<CustomerCredit> reader = new StoredProcedureItemReader<>();
reader.setDataSource(ds);
reader.setProcedureName("sp_customer_credit");
reader.setRowMapper(new CustomerCreditRowMapper());
return reader;
}
StoredProcedure(DB 에 미리 저장된 SQL 함수) 에서 cursor 를 받는 방식은 셋이에요. ResultSet 으로 돌려주는 방식은 SQL Server·Sybase·DB2·Derby·MySQL 이 쓰고, ref-cursor(out parameter 로 cursor 반환) 는 Oracle·PostgreSQL 이 쓰는데 이땐 setRefCursorPosition(1) 을 줘야 해요. stored function 의 반환값으로 받을 때는 setFunction(true) 로 알려줘요.
Parameter 전달
List<SqlParameter> parameters = List.of(
new SqlOutParameter("newId", OracleTypes.CURSOR),
new SqlParameter("amount", Types.INTEGER),
new SqlParameter("custId", Types.INTEGER)
);
reader.setParameters(parameters);
reader.setPreparedStatementSetter(parameterSetter());
쓸 일은 거의 legacy DB integration 에서예요. 신규 프로젝트라면 일반 SQL 이 무난해요.
JPA Reader — JpaPagingItemReader
@Bean
public JpaPagingItemReader<CustomerCredit> jpaReader(EntityManagerFactory emf) {
return new JpaPagingItemReaderBuilder<CustomerCredit>()
.name("creditReader")
.entityManagerFactory(emf)
.queryString("select c from CustomerCredit c where c.status = :status")
.parameterValues(Map.of("status", "NEW"))
.pageSize(1000)
.build();
}
JPA 는 Hibernate StatelessSession 같은 게 없어서, paging 이 자연스러운 선택이에요.
JPA의 detach + clear
After each page is read, the entities become detached and the persistence context is cleared, to allow the entities to be garbage collected once the page is processed. — 공식 reference
매 page 끝마다 persistence context(영속성 컨텍스트, JPA 1차 캐시) 를 clear 해서 GC 가 정상으로 돌아가게 만들어요. 1차 캐시 누적을 막는 JPA batch 의 고정 패턴이고, cursor 와 갈리는 지점이기도 해요.
JPA Reader — N+1 함정
.queryString("select o from Order o") // Order 만 select
Order 의 연관 collection (예: @OneToMany items) 이 lazy 로딩이면 각 Order 마다 추가 query 가 한 번씩 날아가서 N+1(질의 1번 + 행마다 추가 질의 N번) 이 터져요. 해결은 fetch join(연관 객체까지 한 query 로 끌어오는 JPQL) 으로 한 query 에 collection 까지 같이 가져오는 거예요.
.queryString("select o from Order o left join fetch o.items")
단 paging + fetch join 은 또 다른 함정이 있어요. Hibernate 가 메모리 페이징으로 처리한다는 경고가 떠요. 그래서 대량 batch 에서 fetch join 은 신중하게, 대안은 JdbcCursorItemReader 로 가거나 별도 ItemProcessor 단계에서 추가 query 를 거는 쪽이에요.
Database Writer — Transaction 자체가 답
Database 의 경우 별도 ItemWriter 가 필요 X (transaction 자체가 보장). — 공식 reference
Flat file 이나 XML 은 transaction 비슷한 동작을 흉내 내야 했는데, DB 는 이미 transaction 안에 있어요. 단순 INSERT 가 chunk(한 번에 처리하는 item 묶음) transaction 안에 자동으로 묶여요.
JdbcBatchItemWriter
@Bean
public JdbcBatchItemWriter<CustomerCredit> writer(DataSource ds) {
return new JdbcBatchItemWriterBuilder<CustomerCredit>()
.dataSource(ds)
.sql("INSERT INTO customer (id, name, credit) VALUES (:id, :name, :credit)")
.beanMapped()
.build();
}
NamedParameterJdbcTemplate.batchUpdate() 를 써서 chunk 전체를 1 batch 로 INSERT 해요. DB writer 중에선 가장 빨라요.
beanMapped() vs columnMapped()
| 옵션 | 동작 |
|---|---|
beanMapped() |
property 이름 (예: :id) → bean getter |
columnMapped() |
item 이 Map<String, Object> |
도메인 객체를 쓴다면 거의 beanMapped() 예요.
UPSERT 패턴
.sql("""
INSERT INTO customer (id, name, credit) VALUES (:id, :name, :credit)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
credit = EXCLUDED.credit
""")
UPSERT(없으면 INSERT, 있으면 UPDATE) 는 DB 마다 문법이 달라요. PostgreSQL 은 ON CONFLICT, MySQL 은 ON DUPLICATE KEY UPDATE, Oracle 은 MERGE INTO. 23편에서 본 idempotency(같은 작업을 여러 번 돌려도 결과가 같은 성질) 패턴이에요.
JpaItemWriter
@Bean
public JpaItemWriter<CustomerCredit> jpaWriter(EntityManagerFactory emf) {
return new JpaItemWriterBuilder<CustomerCredit>()
.entityManagerFactory(emf)
.build();
}
EntityManager.merge() 를 써서 persistence context 에 없는 entity 도 merge 해줘요. 다만 한계가 있어요. JdbcBatchItemWriter 보다 batch 성능이 떨어지고, 진짜 batch INSERT 가 나가려면 hibernate.jdbc.batch_size property 에 entity 의 @GeneratedValue(strategy = SEQUENCE) 조합이 맞아야 해요. IDENTITY 전략이면 batch insert 가 아예 불가능해요.
Flush 시점 함정 — Error on Flush vs Error on Write
여기서 시험 함정이 하나 있어요.
flush 전 buffer:
item 1, item 2, ..., item 14, [item 15 = bad], item 16, ..., item 20
session.flush() ← 여기서 DataIntegrityViolationException
문제는 이거예요. 15번째 item 이 bad 인데, Spring Batch 는 flush 전까지 그걸 몰라요. flush 시점에 20개 item 이 다 write 시도된 뒤에야 발견되니까 chunk 전체가 rollback 되고, skip 도 적용이 안 돼요.
The simple guideline for implementations of ItemWriter is to flush on each call to write(). — 공식 reference
해결은 Writer 가 매 write() 호출 끝에 flush 를 하는 거예요.
@Override
public void write(Chunk<? extends Foo> items) {
for (Foo item : items) {
session.save(item);
}
session.flush(); // ★ chunk 끝에 flush
}
이러면 Spring Batch 가 어느 item 이 실패했는지 정확히 추적하고, skip 로직도 정상 작동해요. JdbcBatchItemWriter 와 JpaItemWriter 는 내부에서 자동 flush 라 신경 안 써도 되고, custom Writer 만 조심하면 돼요.
End-to-end 예제
DB → DB 가공
@Bean
@StepScope
public JdbcPagingItemReader<Customer> sourceReader(DataSource ds) {
SqlPagingQueryProviderFactoryBean qp = new SqlPagingQueryProviderFactoryBean();
qp.setDataSource(ds);
qp.setSelectClause("select id, name, email");
qp.setFromClause("from customer_source");
qp.setSortKey("id");
return new JdbcPagingItemReaderBuilder<Customer>()
.name("sourceReader")
.dataSource(ds)
.queryProvider(qp.getObject())
.pageSize(1000)
.rowMapper(BeanPropertyRowMapper.newInstance(Customer.class))
.build();
}
@Bean
public JdbcBatchItemWriter<Customer> targetWriter(DataSource ds) {
return new JdbcBatchItemWriterBuilder<Customer>()
.dataSource(ds)
.sql("INSERT INTO customer_target (id, name, email) VALUES (:id, :name, :email)")
.beanMapped()
.build();
}
@Bean
public Step etlStep(JobRepository repo, PlatformTransactionManager tx,
JdbcPagingItemReader<Customer> reader,
JdbcBatchItemWriter<Customer> writer) {
return new StepBuilder("etlStep", repo)
.<Customer, Customer>chunk(500, tx)
.reader(reader)
.writer(writer)
.build();
}
다른 DataSource 환경
@Bean
public Step crossDbStep(JobRepository repo, PlatformTransactionManager tx,
JdbcPagingItemReader<Customer> sourceReader,
JdbcBatchItemWriter<Customer> targetWriter) { ... }
Source DataSource 와 Target DataSource 가 다르면 transaction 도 따로예요. XA(여러 DB 를 한 트랜잭션으로 묶는 2단계 커밋 프로토콜) 없이는 target 만 commit 되고 source 는 그대로 남는 상황이 나올 수 있어요. 멱등성을 보장해두고 retry 로 푸는 게 현실적이에요.
자주 만나는 사고
사고 1: 1M row OOM
RowMapper 가 단순 query 를 돌리면 전체 ResultSet 이 메모리에 적재돼요 (특히 MySQL). JdbcCursorItemReader 에 fetchSize(Integer.MIN_VALUE) 를 줘서 MySQL streaming 으로 가거나, JdbcPagingItemReader 로 갈아타면 풀려요.
사고 2: Connection idle timeout
Cursor 가 Step 내내 connection 을 점유하니까 DB 의 idle timeout (보통 30분~1시간) 을 넘겨요. Paging 으로 전환하거나, 정 안되면 connection timeout 을 늘려야 해요.
사고 3: Paging 의 duplicate / missing row
sortKey 가 non-unique 인 경우예요 (예: createdAt). PK 같은 unique sortKey 로 바꾸거나 multi-column sort 로 가요.
사고 4: Deep pagination 성능 폭락
OFFSET 이 10M 이상으로 커지면 폭락해요. Keyset pagination (WHERE id > :lastId LIMIT 1000) 으로 바꾸거나 partitioning 으로 잘게 쪼개세요.
사고 5: N+1 query
JPA Reader 에서 연관 관계가 lazy 일 때 터져요. fetch join 으로 풀되 paging 과 같이 쓸 땐 신중하고, 안되면 JdbcCursorItemReader 로 가서 명시 join SQL 을 직접 적는 쪽이 안전해요.
사고 6: JpaItemWriter batch insert 안 됨
@GeneratedValue(strategy = IDENTITY) 에 hibernate.jdbc.batch_size 가 안 잡혀 있어요. SEQUENCE 전략으로 바꾸고 spring.jpa.properties.hibernate.jdbc.batch_size=100 을 잡아주세요.
사고 7: Skip 적용 안 됨
Custom Writer 가 flush 를 안 해서 flush 시점에 chunk 전체가 rollback 돼요. Writer 의 매 write() 끝에 명시적으로 flush 를 걸어주세요.
사고 8: Multi-threaded JdbcCursorItemReader
Cursor reader 는 thread-safe 가 아니에요. SynchronizedItemStreamReader 로 감싸거나, JdbcPagingItemReader (thread-safe) 로 가거나, partitioning 으로 분할해요.
운영 권장 패턴
Pattern 1: 표준 Paging reader
@Bean
@StepScope
public JdbcPagingItemReader<Customer> customerReader(
DataSource ds, @Value("#{jobParameters['status']}") String status) throws Exception {
SqlPagingQueryProviderFactoryBean qp = new SqlPagingQueryProviderFactoryBean();
qp.setDataSource(ds);
qp.setSelectClause("select id, name, email");
qp.setFromClause("from customer");
qp.setWhereClause("where status = :status");
qp.setSortKey("id");
return new JdbcPagingItemReaderBuilder<Customer>()
.name("customerReader")
.dataSource(ds)
.queryProvider(qp.getObject())
.parameterValues(Map.of("status", status))
.pageSize(1000)
.rowMapper(BeanPropertyRowMapper.newInstance(Customer.class))
.build();
}
대부분 운영에서 — Paging + Late Binding parameter 조합이에요.
Pattern 2: 표준 JdbcBatchItemWriter + UPSERT
@Bean
public JdbcBatchItemWriter<Customer> customerWriter(DataSource ds) {
return new JdbcBatchItemWriterBuilder<Customer>()
.dataSource(ds)
.sql("""
INSERT INTO customer (id, name, email)
VALUES (:id, :name, :email)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
email = EXCLUDED.email
""")
.beanMapped()
.build();
}
23편의 idempotent design 그대로예요.
Pattern 3: Keyset pagination (custom)
public class KeysetPagingReader<T> extends AbstractItemCountingItemStreamItemReader<T> {
private long lastId = 0;
private Queue<T> buffer = new LinkedList<>();
@Override
protected T doRead() {
if (buffer.isEmpty()) {
buffer.addAll(jdbc.query(
"SELECT * FROM customer WHERE id > ? ORDER BY id LIMIT 1000",
rowMapper, lastId));
if (!buffer.isEmpty()) {
lastId = ((Customer) buffer.peek()).getId();
}
}
return buffer.poll();
}
// open / update / close 구현
}
Deep pagination 성능 문제를 회피해요. 26편의 Custom Reader 패턴이에요.
Pattern 4: Cross-DB ETL
@Bean
public Job etlJob(JobRepository repo, Step sourceToStaging, Step stagingToTarget) {
return new JobBuilder("etlJob", repo)
.start(sourceToStaging) // Source DB → Staging (idempotent)
.next(stagingToTarget) // Staging → Target DB
.build();
}
Source 와 Target 을 분리해 2 단계 ETL 로 만들고, 멱등성을 얹어 안전하게 돌려요.
Pattern 5: JPA cursor 대체
@Bean
public JpaCursorItemReader<Customer> jpaCursorReader(EntityManagerFactory emf) {
return new JpaCursorItemReaderBuilder<Customer>()
.name("jpaCursorReader")
.entityManagerFactory(emf)
.queryString("select c from Customer c where c.active = true")
.build();
}
JPA 5+ 부터 JpaCursorItemReader 가 들어와서 JPA 환경에서도 cursor 스타일이 돼요. 단 paging 처럼 자동 detach·clear 가 일어나지 않아서, 필요하면 수동으로 clear 를 걸어줘야 해요.
시험 직전 한 번 더 — Database Reader/Writer 함정 압축 노트
- 두 전략 = Cursor (streaming, 1 connection 유지) · Paging (page 단위 query)
- JdbcCursorItemReader =
DataSource·sql·RowMapper - Cursor 옵션 —
fetchSize·maxRows·queryTimeout·verifyCursorPosition·driverSupportsAbsolute·useSharedExtendedConnection - Cursor 함정 — Connection idle timeout · NOT thread-safe · JDBC driver 의존성
- JdbcPagingItemReader =
DataSource·PagingQueryProvider·pageSize·RowMapper - PagingQueryProvider = DB 별 LIMIT/OFFSET dialect
- SqlPagingQueryProviderFactoryBean = DB 자동 감지
- sortKey = unique 컬럼 (PK 권장) — non-unique 면 page 간 duplicate/missing
- multi-column sort 지원 (
Map<String, Order>) - Deep pagination 함정 — OFFSET 큼 → 성능 폭락 → Keyset pagination 권장
- Cursor vs Paging 비교 — connection · 메모리 · long-running · thread-safety · driver 의존성
- 대부분 운영 = Paging 권장
- StoredProcedureItemReader = 3 return 방식 (ResultSet · ref-cursor · function)
- JpaPagingItemReader =
entityManagerFactory·queryString·pageSize - JPA = 매 page 끝 detach + clear 자동 (1차 캐시 누적 방지)
- JPA N+1 함정 — fetch join + paging 신중 (Hibernate 메모리 페이징 경고)
- JpaCursorItemReader (Spring Batch 5+) = JPA 의 cursor 스타일
- Database Writer = transaction 자체가 답 (별도 file writer 없음)
- JdbcBatchItemWriter = NamedParameterJdbcTemplate batch update (최고 성능)
beanMapped()vscolumnMapped()- UPSERT 권장 (
ON CONFLICT·ON DUPLICATE KEY·MERGE INTO) - JpaItemWriter =
EntityManager.merge()— batch insert 약함 - batch insert 활성화 =
SEQUENCE전략 +hibernate.jdbc.batch_size - Flush 시점 함정 — Error on flush 시 chunk 전체 rollback → skip 불가
- 해결 = Writer 의 매 write 끝 flush (
JdbcBatchItemWriter·JpaItemWriter자동) - 함정 — 1M row OOM (Cursor +
fetchSize(Integer.MIN_VALUE)MySQL streaming) - 함정 — Connection idle timeout (Paging 으로 전환)
- 함정 — non-unique sortKey
- 함정 — Deep pagination (Keyset 권장)
- 함정 — N+1 (fetch join + paging 신중)
- 함정 — JpaItemWriter batch (IDENTITY 전략 = batch 불가)
- 함정 — Custom Writer flush 누락 → skip 적용 불가
- 함정 — Cursor multi-threaded → Synchronized wrap 또는 Paging
- 패턴 — 표준 Paging + Late Binding
- 패턴 — JdbcBatch + UPSERT
- 패턴 — Keyset custom (deep pagination)
- 패턴 — Cross-DB ETL (2단계 + 멱등성)
- 패턴 — JpaCursorItemReader (JPA 환경 cursor)
- Part 6 마무리 — 다음 글부터 Part 7 (ItemProcessor)
공식 문서: Database Item Readers and Writers 에서 원문을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 29편 — FlatFileItemReader 깊은 옵션
- 30편 — FlatFileItemWriter · LineAggregator · FieldExtractor
- 31편 — XML Reader · Writer · StAX 기반 streaming
- 32편 — JSON Reader · Writer · Jackson · Gson
- 33편 — Multi-File Input · MultiResourceItemReader
다음 글: