Spring Batch ‐ DB 읽고 쓰기 - dnwls16071/Backend_Summary GitHub Wiki
📚 Spring Batch - DB 읽고 쓰기
| 커서 기반 처리 (JdbcCursorItemReader) |
페이징 기반 처리 (JdbcPagingItemReader) |
| 데이터베이스와 연결을 유지하면서 데이터를 순차적으로 가져온다하나의 커넥션으로 데이터를 스트리밍하듯 처리한다메모리는 최소한으로 사용하면서 최대한의 성능을 뽑아낸다 |
데이터를 정확한 크기로 잘라서 차근차근 처리한다각 페이지마다 새로운 쿼리를 날려 안정성을 보장한다 |
📚 Spring Batch - JdbcCursorItemReader: 데이터를 스트리밍으로 제압
- 커서 기반 처리 방식은 데이터베이스와 끊김 없는 연결을 유지하면서 데이터를 ResultSet을 통해 순차적으로 가져오는 방식이다.
- JdbcCursorItemReader가
open()될 때 빌더를 통해 지정된 SQL 쿼리를 실행하고, 그 결과를 가리키는 커서를 생성한다.
- 이후
read()가 호출될 때마다 ResultSet.next()를 실행하며 한 행씩 데이터를 가져온다.
- 이 방식은 메모리 사용량을 최소화할 수 있지만, 데이터베이스와의 연결을 유지한 채 진행되므로, 긴 배치 작업 동안 커넥션이 너무 오래 유지된다는 단점이 있다.
- DataSource - 데이터베이스 접속 통로
- Spring Boot가 DataSource를 읽어서 HikariCP(커넥션 풀) 기반의 DataSource를 자동으로 만들어준다.
- sql – 데이터 추출 명령어(쿼리)
- rowMapper – 결과 데이터를 객체로 변환
- RowMapper는 데이터베이스에서 가져온 원시 데이터(ResultSet)를 우리가 다룰 수 있는 객체로 변환한다.
- BeanPropertyRowMapper : 전통적인 setter 기반 매핑 방식으로 자바빈 규약을 지킨 클래스를 대상으로, 데이터베이스 컬럼명과 객체의 필드명이 일치하면 자동으로 매핑한다.
- DataClassRowMapper : Java Record나 Kotlin Data Class 같은 불변 객체를 위해 설계된 RowMapper 구현체로 생성자 파라미터를 통해 매핑을 수행한다.
- Custom RowMapper : 별도의 복잡한 변환 로직이 필요할 때 직접 구현하여 사용한다.
- PreparedStatement - 쿼리 실행기
- JdbcCursorItemReader는 내부적으로 이 PreparedStatement를 사용해 SQL을 실행한다.
- PreparedStatement는 데이터베이스에 쿼리를 실행하고 그 결과를 ResultSet으로 가져오는 JDBC의 핵심 컴포넌트이다.
- PreparedStatementSetter (선택사항) - 동적 쿼리 파라미터 주입기
- 배치 작업에서는 잡 파라미터로 받은 값들을 SQL에 동적으로 주입해야 할 때가 많은데, 이럴 때 PreparedStatementSetter가 PreparedStatement와 함께 그 역할을 수행한다.
📚 Spring Batch - JdbcCursorItemReader: 데이터 가져오기, 정말 한 행씩일까?
- 커서 기반 처리 방식, 문득 보면 ResultSet.next()가 호출될 때마다 데이터베이스에서 한 행씩 가져오는 것처럼 보이지만, 실제로는 훨씬 더 똑똑하게 작동한다.
- 결론부터 말하자면 매번 한 줄씩 가져오는 것이 아니다.
- JDBC 드라이버의 내부 최적화가 되어있다.
ResultSet 내부 버퍼링
- JDBC 드라이버는 기본적으로 여러 개의 row를 미리 가져와 ResultSet의 내부 버퍼에 저장해둔다.
- 따라서
ResultSet.next()가 호출될 때마다 이 버퍼에서 데이터를 하나씩 꺼내는 구조가 된다.
- 실제로는 한 번에 여러 건을 가져와서 처리하는 방식
JdbcCursorItemReader의 read() 메서드가 호출
- 버퍼에 데이터가 있으면 데이터베이스 통신 없이 즉시 반환한다.
- 버퍼가 비었을 때만 데이터베이스에 새로운 데이터를 요청하고, 이때도 한 번에 여러 건의 데이터를 가져와 버퍼에 채운다.
Fetch Size로 네트워크 비용 최적화
- fetchSize는 JDBC 드라이버가 한 번에 가져올 row 개수를 지정하는 값이다.
- fetchSize=1000이면, 드라이버는 한 번의 네트워크 요청으로 최대 1000건의 데이터를 가져오려고 시도한다.
- 하지만 이 값은 JDBC 드라이버에게 주는 힌트일 뿐이며, 실제로 가져오는 건수는 드라이버 구현체와 데이터베이스의 정책에 따라 달라질 수 있다.
- 드라이버는 이 설정을 참고해 데이터를 한 번에 가져와 내부 버퍼에 저장하고, 이후 read()가 호출될 때마다 이 버퍼에서 하나씩 반환된다.
- 이를 통해 데이터베이스와의 네트워크 통신 횟수를 줄여 성능을 최적화할 수 있다.
📚 Spring Batch - JdbcPagingItemReader: 데이터를 페이지 단위로
Offset 기반 페이징
- 데이터베이스가 결과셋을 정렬한 후, OFFSET만큼 건너뛰고 LIMIT 개수만큼 데이터를 가져오는 방식이다.
- 하지만 이 방식은 치명적인 문제가 있다.
- OFFSET 20은 20개의 row를 읽고 버린다는 의미다. 즉, 데이터베이스는 OFFSET + LIMIT 만큼의 데이터를 먼저 스캔한 뒤, 앞의 OFFSET만큼을 버린다. 즉, 데이터의 규모가 커지면 커질수록 앞의 데이터를 의미없게 읽고 버려야 되는 양이 많아진다는 의미이다.
Keyset 기반 페이징
- SELECT * FROM victims WHERE id > 1000 ORDER BY id LIMIT 10 처럼 이전 페이지의 마지막 키(id) 값을 기준으로 그 다음 데이터를 가져온다.
- 즉, 정확히 이전에 가져온 마지막 값 이후부터 읽어오므로 성능이 일정하게 유지된다.
- DataSource
- JdbcCursorItemReader에서 살펴본 것과 동일하게, 데이터베이스 연결을 담당하는 객체이다.
- RowMapper
- 조회 결과를 Java 객체로 매핑하는 역할을 수행한다.
- NamedParameterJdbcTemplate - SQL의 우아한 파라미터 처리
- 내부적으로 NamedParameterJdbcTemplate을 사용해 쿼리를 실행한다.
- 위치 기반 파라미터
? 대신 이름을 가진 파라미터를 사용할 수 있게 해준다.
- PagingQueryProvider - 페이징 쿼리 생성의 전략
- Spring Batch는 각 데이터베이스에 최적화된 PagingQueryProvider 구현체를 제공한다.
- PostgreSQL에는 PostgresPagingQueryProvider가, MySQL에는 MySqlPagingQueryProvider가 제공된다.
📚 Spring Batch - JdbcBatchItemWriter
- JdbcBatchItemWriter는 Spring Batch에서 제공하는 가장 기본적인 관계형 데이터베이스 쓰기 도구이다.
- 내부적으로 NamedParameterJdbcTemplate을 사용하며, JdbcTemplate의 batchUpdate를 활용해 청크 단위로 모아진 아이템들을 효율적으로 데이터베이스에 저장한다.
- NamedParameterJdbcTemplate - SQL 실행의 핵심 엔진
- 네임드 파라미터 바인딩을 지원하는 JdbcTemplate, JdbcBatchItemWriter에서 SQL 실행과 결과 처리를 위해 사용된다.
- SQL - 데이터 처리를 위한 명령어
- 이 SQL은 INSERT, UPDATE, DELETE와 같은 DML 구문이 될 수 있으며, 파라미터 바인딩을 위해 두 가지 방식을 지원된다.
- 물음표(?) 플레이스홀더
- 네임드 파라미터(:name)
일반 INSERT vs Multi-value INSERT vs Batch Update
- 일반 INSERT: 매 쿼리마다 네트워크 패킷 발생된다.
- Multi-Value INSERT: 여러 값을 하나의 쿼리로 한 번에 전송된다.
- Batch Update: PreparedStatement를 재사용하여 쿼리 템플릿 하나와 여러 파라미터 세트를 함께 전송한다. 이렇게 전달된 배치 쿼리는 데이터베이스 서버에서 파싱되어 처리되며, 이 모든 작업은 하나의 트랜잭션 내에서 수행된다. 이를 통해 청크 단위로 묶인 수백 건의 데이터가 모두 성공하거나 모두 실패하는 원자성이 보장된다.
📚 Spring Batch - JPA ItemReader / ItemWriter
JpaCursorItemReader
- queryString - JPA의 질의 언어
- JpaCursorItemReader가 데이터를 조회하기 위한 JPQL(Java Persistence Query Language) 쿼리
- EntityManager가 이 queryString을 사용하여 실제 실행 가능한 Query 객체를 생성한다.
- queryString 대신 JpaQueryProvider를 사용해 쿼리를 생성하고 싶다면 JpaCursorItemReaderBuilder.queryProvider()를 통해 JpaQueryProvider 구현체를 설정할 수 있다.
- EntityManager - JPA의 심장
- EntityManager는 JPA의 핵심 컴포넌트로, 엔티티의 생명주기를 관리하고 실제 데이터베이스 작업을 수행한다.
- JpaCursorItemReader에서는 이 EntityManager를 통해 커서 기반의 데이터 조회를 실행한다.
- Query - 실행 가능한 쿼리 인스턴스
- JpaCursorItemReader는 EntityManager를 통해 실행 가능한 Query 객체를 생성하고, 이를 사용해 데이터를 스트리밍 방식으로 읽어온다.
- Query 객체의 getResultStream() 메서드를 호출해 수행한다.
- JpaCursorItemReader 초기화 시점에 호출되는
doOpen() 메서드에서는 EntityManager와 JpaQueryProvider가 협력하여 실행 가능한 Query 객체를 생성한다. 이 Query 객체는 getResultStream()을 호출하여 데이터베이스 커서를 순회할 Iterator를 준비한다.
- JpaCursorItemReader의
doRead() 메서드에서는 준비된 Iterator를 통해 실제 데이터를 한 건씩 읽어온다. iterator.hasNext()로 다음 데이터의 존재 여부를 확인하고, 데이터가 있다면 iterator.next()를 통해 한 건의 데이터를 반환한다. 더 이상 읽을 데이터가 없다면 null을 반환하여 읽기를 종료한다.
JpaQueryProvider를 사용한 쿼리 설정
- JpaNamedQueryProvider : 엔티티 등에 미리 정의된 Named Query를 사용한다.
- JpaNativeQueryProvider : Native SQL을 사용하여 데이터를 조회하도록 한다.
Fetch Join 정확히 알고 사용하기
- 페이징과 함께 Fetch Join을 사용하면 문제가 될 수 있다.
- Hibernate는 Fetch Join과 페이징(LIMIT/OFFSET)을 함께 사용하면 쿼리에서 페이징을 적용하지 않고 전체 데이터를 메모리에 로드한 후 애플리케이션에서 페이징을 수행한다.
- 이 과정이 페이지를 조회할 때마다 반복되어 심각한 메모리 낭비가 발생한다.
- FetchType을 EAGER(즉시 로딩)로 변경하고 @BatchSize를 적용한다. @BatchSize는 연관된 엔티티를 조회할 때 지정된 size만큼 IN 절로 한 번에 조회하는 최적화 어노테이션이다. IN 절을 사용해 효율적으로 데이터를 가져올 수 있다.
ORDER BY절을 사용하기
- 페이징 기반 ItemReader에서는 ORDER BY절 사용을 권장한다.
- ORDER BY가 없으면 매 페이지를 읽을 때마다 데이터의 순서가 보장되지 않아 일부 데이터가 누락되거나 중복될 수 있기 때문이다.
transacted 필드와 시스템 안정성 확보
@Override
@SuppressWarnings("unchecked")
protected void doReadPage() {
EntityTransaction tx = null;
if (transacted) {
tx = entityManager.getTransaction();
tx.begin();
entityManager.flush();
entityManager.clear();
} // end if
Query query = createQuery().setFirstResult(getPage() * getPageSize()).setMaxResults(getPageSize());
...
if (!transacted) {
...
}
else {
results.addAll(query.getResultList());
tx.commit();
} // end if
}
- 소스코드 원문을 보게 되면
transacted가 true이면, 트랜잭션 관리에 들어가게 된다.
- 또
transacted가 true이면, flush()가 호출되면서 쓰기 지연 SQL 저장소에 적재된 쿼리들이 DB에 반영되며 의도치 않게 변경사항이 반영될 수 있다.
- ItemReader는 데이터를 읽는 책임만을 가져야 하는데 실제로 이런 내부적인 문제로 인해 데이터 변경까지 일으킬 수 있다는 것이다.
- 따라서 JpaPagingItemReader를 사용할 때는 다음과 같이 transacted를 false로 설정하여 사용하도록 해야 한다.
transacted가 false라면?
if (!transacted) {
List<T> queryResult = query.getResultList();
for (T entity : queryResult) {
entityManager.detach(entity);
results.add(entity);
}
} else {
...
}
- transacted가 false라면
detech() 메서드를 사용해 엔티티의 상태가 준영속 상태로 전환된다. 이렇게 준영속 상태의 엔티티는 EntityManager에 의해 관리되지 않기 때문에 Lazy Loading이 불가능하다.
JpaItemWriter
@Bean
public JpaItemWriter<BlockedPost> postBlockWriter() {
return new JpaItemWriterBuilder<BlockedPost>()
.entityManagerFactory(entityManagerFactory)
.usePersist(true) // true로 설정하면 persist()가 사용되고 기본값인 false를 사용하면 merge()가 사용
.build();
}
IDENTITY 전략 사용 시 배치 처리 제약사항
- ID 생성 전략을 IDENTITY로 사용할 경우 하나의 중요한 제약사항이 있다.
- IDENTITY 전략에서는 엔티티의 ID가 데이터베이스에서 생성되기 때문에, Hibernate가 엔티티를 영속화하기 위해서는 반드시 INSERT를 먼저 실행해야 한다.
- 이는 결과적으로 모든 INSERT가 개별적으로 실행되어야 함을 의미한다. 따라서 Hibernate는 여러 개의 INSERT 문을 하나의 배치로 묶어서 처리할 수 없게 된다.
- 배치 처리 성능이 중요한 상황이라면 SEQUENCE 전략을 권장한다.
persist() vs merge()
merge() 호출 시 영속성 컨텍스트는 해당 데이터의 존재 여부를 알 수 없어 DB 조회를 강제하게 된다.
- 이는 청크 단위로 처리될 때마다 UPDATE 이전에 SELECT 쿼리가 청크 크기만큼 추가로 발생함을 의미한다.
- 이후 실행되는 UPDATE 자체는 batchUpdate로 처리되지만, 추가 조회는 JPA의
merge() 메커니즘상 피할 수 없는 부분이다.
📚 JdbcCursorItemReader/JdbcPagingItemReader 관련 공식문서 레퍼런스