[DB처리] 3. JPA 기반 Reader

y001·2026년 3월 15일

Spring Batch Guide

목록 보기
18/19
post-thumbnail

1. 시작하면서

Spring Batch에서 데이터를 조회하는 방법은 크게 JDBC 기반 Reader와 JPA 기반 Reader로 나눌 수 있다. JDBC Reader는 ResultSet을 기반으로 데이터를 읽으며 데이터베이스 row를 순차적으로 가져오는 구조를 가진다. ResultSet을 한 줄씩 소비하는 방식이기 때문에 대량 데이터를 처리하더라도 메모리 사용량을 비교적 안정적으로 유지할 수 있다.

반면 JPA 기반 Reader는 단순 조회 이상의 동작을 포함한다. JPA는 조회된 엔티티를 영속성 컨텍스트(Persistence Context) 에 저장하고 엔티티 상태를 관리한다. 이러한 특성은 일반적인 웹 애플리케이션에서는 유용하지만 배치 환경에서는 다른 문제가 발생할 수 있다.

예를 들어 수십만 건 이상의 데이터를 조회하는 작업에서는 엔티티가 영속성 컨텍스트에 계속 누적될 수 있다. 이 상태가 유지되면 JVM 메모리 사용량이 증가하고 GC 부담도 커질 수 있다. 또한 연관 관계가 포함된 엔티티를 조회할 경우 Lazy Loading이나 N+1 문제가 발생하기도 한다.

Spring Batch는 이러한 상황을 고려하여 두 가지 JPA 기반 Reader를 제공한다. 하나는 커서를 기반으로 데이터를 순차적으로 읽는 JpaCursorItemReader이고, 다른 하나는 페이지 단위로 데이터를 조회하는 JpaPagingItemReader이다. 두 Reader는 모두 JPA를 기반으로 동작하지만 내부 동작 방식에는 차이가 있다.

2. JpaCursorItemReader

2-1. 내부 구조

JpaCursorItemReader는 하나의 JPQL 쿼리를 실행한 뒤 결과를 순차적으로 읽는 방식으로 동작한다. Reader 초기화 시점에 JPQL이 실행되고 이후 read() 호출마다 하나의 엔티티가 반환된다.

Reader의 주요 구성 요소는 다음과 같다.

구성 요소설명
EntityManagerFactoryReader 실행 시 EntityManager를 생성한다
EntityManagerJPQL 실행과 엔티티 조회를 담당한다
QueryStringReader가 실행할 JPQL
QueryEntityManager가 생성하는 JPA Query 객체
Result Stream조회 결과를 순차적으로 반환하는 구조

Cursor Reader는 하나의 쿼리를 실행한 뒤 결과를 계속 소비하는 구조를 가진다. 이 때문에 Reader가 실행되는 동안 데이터베이스 커넥션이 유지된다. 이러한 동작 방식은 JDBC Cursor Reader와 유사하다.

또 하나 고려해야 할 부분은 영속성 컨텍스트이다. JPA는 조회된 엔티티를 영속성 컨텍스트에 저장하기 때문에 Reader가 많은 데이터를 읽으면 엔티티가 계속 누적될 수 있다. Spring Batch는 이를 방지하기 위해 일정 단위 처리가 끝날 때마다 EntityManager.clear()를 호출하여 이미 처리된 엔티티를 detach 상태로 만든다.

2-2. 예제

다음은 JpaCursorItemReader 설정 예제이다.

@Bean
public JpaCursorItemReader<Order> orderReader(EntityManagerFactory emf) {

    JpaCursorItemReader<Order> reader = new JpaCursorItemReader<>();

    reader.setEntityManagerFactory(emf);

    reader.setQueryString("""
        SELECT o
        FROM Order o
        ORDER BY o.id
    """);

    return reader;
}

이 Reader는 Order 엔티티를 조회하는 JPQL을 실행하고 read() 호출마다 하나의 엔티티를 반환한다. JPQL은 Reader 초기화 시점에 실행되며 이후 결과를 순차적으로 소비한다.

3. JpaPagingItemReader

3-1. 내부 구조

JpaPagingItemReader는 데이터를 페이지 단위로 조회하는 방식으로 동작한다. Cursor Reader처럼 하나의 쿼리를 유지하는 구조가 아니라 일정 개수의 데이터를 조회한 뒤 다음 페이지를 다시 조회하는 구조를 가진다.

Reader는 먼저 pageSize 만큼 데이터를 조회해 내부 버퍼에 저장한다. 이후 read()가 호출될 때마다 버퍼의 데이터를 하나씩 반환하며, 버퍼가 모두 소비되면 다음 페이지를 조회한다.

Reader의 주요 구성 요소는 다음과 같다.

구성 요소설명
EntityManagerFactoryEntityManager 생성
EntityManagerJPQL 실행과 엔티티 조회
pageSize한 번에 조회할 데이터 개수
Query실제 조회를 수행하는 JPA Query
setFirstResult조회 시작 위치 지정
setMaxResults조회 최대 결과 수 지정
내부 버퍼조회된 엔티티를 저장하고 read() 호출 시 반환

Paging Reader는 setFirstResult()setMaxResults()를 사용해 페이징을 수행한다. 예를 들어 pageSize가 1000이면 첫 번째 조회는 0번째 위치부터 1000개의 데이터를 조회하고, 다음 조회에서는 시작 위치를 1000으로 이동하여 다음 데이터를 조회한다.

Cursor 방식과 달리 각 페이지 조회가 독립적으로 실행되기 때문에 데이터베이스 커넥션을 장시간 점유하지 않는다는 특징이 있다.

3-2. JpaQueryProvider

JpaPagingItemReader는 JPQL 문자열을 직접 지정할 수도 있지만 JpaQueryProvider를 통해 쿼리 생성을 위임할 수도 있다. JpaQueryProvider는 Reader가 실행할 JPQL을 생성하는 역할을 수행한다.

대표적인 구현체로 JpaNamedQueryProviderJpaNativeQueryProvider가 있다. 이 방식을 사용하면 쿼리 정의와 Reader 설정을 분리할 수 있어 복잡한 쿼리를 관리하기 쉬워진다.

@Bean
public JpaPagingItemReader<Order> orderReader(EntityManagerFactory emf) {

    JpaPagingItemReader<Order> reader = new JpaPagingItemReader<>();
    reader.setEntityManagerFactory(emf);
    reader.setPageSize(1000);

    JpaNamedQueryProvider<Order> queryProvider = new JpaNamedQueryProvider<>();
    queryProvider.setNamedQuery("Order.findAll");

    reader.setQueryProvider(queryProvider);

    return reader;
}

@Entity
@NamedQuery(
    name = "Order.findAll",
    query = "SELECT o FROM Order o ORDER BY o.id"
)
public class Order {

    @Id
    private Long id;

}

3-3. offset 기반 페이징

JpaPagingItemReader는 OFFSET 기반 페이징을 사용한다. OFFSET 방식은 조회 시작 위치를 지정하여 데이터를 가져오는 방식이다.

예를 들어 pageSize가 1000일 때 10번째 페이지를 조회하면 데이터베이스는 앞의 9000개의 데이터를 스캔한 뒤 결과를 버리고 다음 1000개의 데이터를 반환한다.

SELECT *
FROM orders
ORDER BY id
LIMIT 1000 OFFSET ?

페이지가 뒤로 갈수록 OFFSET 값이 증가하게 된다. 예를 들어 100만 번째 데이터부터 조회하려면 데이터베이스는 앞의 100만 건을 스캔한 뒤 결과를 버려야 한다. 이 때문에 OFFSET 기반 페이징은 데이터 규모가 커질수록 조회 비용이 증가하는 특징이 있다.

3-4. Fetch Join, N+1, 그리고 @BatchSize

JPA Reader를 사용할 때는 연관 관계 조회도 함께 고려해야 한다. 예를 들어 Order 엔티티가 Member 엔티티와 연관 관계를 가지고 있다고 가정해 보자. Member가 Lazy Loading으로 설정되어 있다면 Order 조회 이후 Member를 접근하는 시점에 추가 쿼리가 발생할 수 있다. 이러한 상황이 반복되면 N+1 문제가 발생한다.

이를 해결하기 위해 Fetch Join을 사용할 수 있지만 Paging 환경에서는 Fetch Join이 항상 적합한 것은 아니다. 특히 컬렉션 Fetch Join이 포함되면 페이징이 정상적으로 동작하지 않을 수 있다.

이러한 경우 @BatchSize를 이용해 Lazy Loading을 묶어서 조회할 수 있다. 예를 들어 batch size를 100으로 설정하면 Lazy Loading이 발생할 때 여러 엔티티를 한 번의 쿼리로 조회하게 된다.

다만 @BatchSize는 항상 기대한 방식으로 동작하는 것은 아니다. JpaPagingItemReader는 페이지 조회 직후 트랜잭션을 종료하는 구조를 가지기 때문에 Processor나 Writer에서 연관 엔티티에 접근하는 시점에는 이미 조회 트랜잭션이 끝난 상태일 수 있다. 이 경우 @BatchSize가 기대한 형태로 동작하지 않아 N+1 문제가 그대로 발생할 수 있다.

0개의 댓글