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 – 데이터 추출 명령어(쿼리)
    • 데이터 조회를 위해 사용할 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 관련 공식문서 레퍼런스