[DB처리] 1. JDBC 기반 Reader

y001·2026년 3월 15일

Spring Batch Guide

목록 보기
16/19
post-thumbnail

1. 시작하면서

웹 서비스에서는 특정 사용자나 주문과 같은 소량 데이터를 조회하는 경우가 많지만, 배치 작업은 수십만 건에서 수백만 건에 이르는 데이터를 한 번에 처리하는 상황이 빈번하다. 이러한 데이터를 List로 한 번에 조회하면 애플리케이션 메모리에 모든 객체가 적재되면서 메모리 사용량이 급격히 증가하고, 경우에 따라 OutOfMemoryError가 발생할 수 있다.

Spring Batch는 이러한 문제를 해결하기 위해 ItemReader라는 추상화를 제공한다. Reader는 데이터를 한 번에 반환하지 않고 read() 호출마다 하나의 데이터를 반환하는 방식으로 동작한다. 배치 프레임워크는 내부적으로 read → process → write 흐름을 반복하며 데이터를 순차적으로 처리한다. 이 구조 덕분에 데이터 규모가 매우 큰 상황에서도 메모리 사용량을 일정하게 유지할 수 있다.

JDBC 기반 Reader는 크게 두 가지 방식으로 나뉜다. 하나는 커서를 기반으로 데이터를 순차적으로 읽는 Cursor 방식이고, 다른 하나는 페이지 단위로 데이터를 조회하는 Paging 방식이다.

2. JdbcCursorItemReader

2-1. 내부 구조

JdbcCursorItemReader는 JDBC ResultSet을 기반으로 동작하는 Reader이다. SQL을 한 번 실행하여 ResultSet을 생성하고, 이후 커서를 이동시키면서 데이터를 순차적으로 읽는다.

전체 흐름은 다음과 같이 이루어진다.

  1. Reader가 열릴 때 SQL이 실행된다.
  2. 데이터베이스는 ResultSet을 생성한다.
  3. Spring Batch는 read() 호출마다 ResultSet의 다음 row를 읽는다.
  4. RowMapper가 ResultSet 데이터를 Java 객체로 변환한다.
  5. 더 이상 데이터가 없으면 null을 반환한다.

이 과정에서 Spring Batch는 ResultSet을 계속 유지한 상태로 데이터를 순차적으로 소비한다. 따라서 애플리케이션은 데이터를 스트리밍 방식으로 처리하게 된다. JdbcCursorItemReader 내부에서 중요한 구성 요소는 다음과 같다.

구성 요소설명
DataSource데이터베이스 커넥션을 생성하고 관리하는 역할을 담당한다. Reader가 실행될 때 데이터베이스에 접근하기 위한 커넥션을 제공한다.
SQLReader가 실행할 조회 쿼리이다. 배치에서 읽어올 대상 데이터를 정의하며 일반적으로 안정적인 처리를 위해 ORDER BY가 포함된다.
RowMapperResultSet의 각 row를 Java 객체로 변환하는 역할을 수행한다. JDBC 결과를 도메인 객체로 매핑하는 과정에서 사용된다.
fetchSizeJDBC 드라이버에게 한 번에 가져올 row 개수에 대한 힌트를 제공한다. 드라이버는 이 값을 기반으로 내부 버퍼를 구성해 네트워크 왕복 횟수를 줄이는 최적화를 수행할 수 있다.

특히 fetchSize는 JDBC 드라이버의 내부 동작과 밀접한 관련이 있다. 많은 드라이버는 ResultSet을 완전히 한 줄씩 가져오는 방식이 아니라 내부 버퍼를 사용한다. 예를 들어 fetchSize가 1000이라면 드라이버는 보통 1000개의 데이터를 한 번에 가져와 내부 버퍼에 저장하고, 애플리케이션이 read()를 호출할 때마다 버퍼에서 데이터를 하나씩 반환한다.

이러한 방식은 데이터베이스와 애플리케이션 사이의 네트워크 왕복 횟수를 줄이기 위한 JDBC 드라이버의 내부 최적화 전략이다. 애플리케이션 입장에서는 한 줄씩 데이터를 읽는 것처럼 보이지만 실제로는 드라이버가 여러 row를 미리 가져와 캐싱하고 있기 때문에 네트워크 비용을 줄일 수 있다.

Cursor 방식의 장점은 메모리 사용량이 매우 낮다는 점이다. 데이터 전체를 메모리에 적재하지 않고 ResultSet을 통해 순차적으로 접근하기 때문이다. 따라서 수백만 건 이상의 데이터를 처리할 때도 JVM 메모리 사용량이 크게 증가하지 않는다.

그러나 단점도 존재한다. ResultSet이 유지되는 동안 데이터베이스 커넥션이 계속 점유된다. 예를 들어 수백만 건의 데이터를 처리하는 작업이 수십 분 이상 지속된다면 해당 커넥션은 그 시간 동안 계속 유지된다. 커넥션 풀 크기가 제한된 환경에서는 이러한 점이 병목이 될 수 있다.

2-2. 예제

JdbcCursorItemReader는 SQL과 RowMapper만 정의하면 비교적 간단하게 사용할 수 있다.

다음은 주문 데이터를 조회하는 Reader 예제이다.

@Bean
public JdbcCursorItemReader<Order> orderReader(DataSource dataSource) {

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

    reader.setDataSource(dataSource);

    reader.setSql("""
        SELECT id, user_id, status
        FROM orders
        ORDER BY id
    """);

    reader.setRowMapper((rs, rowNum) -> {
        Order order = new Order();
        order.setId(rs.getLong("id"));
        order.setUserId(rs.getLong("user_id"));
        order.setStatus(rs.getString("status"));
        return order;
    });

    reader.setFetchSize(1000);

    return reader;
}

이 설정에서 중요한 부분은 ORDER BY이다. 배치는 실패 후 재시작이 가능해야 하므로 데이터 순서가 항상 동일하게 유지되어야 한다. 정렬 기준이 없으면 데이터베이스가 반환하는 순서가 매번 달라질 수 있으며, 이 경우 중복 처리나 데이터 누락이 발생할 수 있다.

3. JdbcPagingItemReader

3-1. 내부 구조

JdbcPagingItemReader는 데이터를 페이지 단위로 조회하는 방식이다. Cursor Reader처럼 ResultSet을 계속 유지하지 않고, 일정 크기의 데이터를 조회한 뒤 다음 페이지를 다시 조회한다.

예를 들어 pageSize가 1000이라면 다음과 같은 방식으로 데이터가 조회된다.

  1. 첫 번째 페이지 조회
  2. pageSize 만큼 데이터 반환
  3. 다음 페이지 SQL 실행
  4. 다시 pageSize 만큼 데이터 반환

이 방식은 ResultSet을 장시간 유지하지 않기 때문에 데이터베이스 커넥션을 오래 점유하지 않는다. 각 페이지 조회가 끝나면 ResultSet이 닫히고 새로운 쿼리가 실행된다.

Spring Batch의 JdbcPagingItemReader는 일반적인 OFFSET 기반 페이징이 아니라 Keyset Pagination 방식을 사용한다. OFFSET 방식은 페이지가 뒤로 갈수록 성능이 급격히 저하되는 문제가 있다. 예를 들어 OFFSET 900000 LIMIT 1000 같은 쿼리는 데이터베이스가 앞쪽 데이터를 모두 스캔한 뒤 버려야 하기 때문에 매우 비효율적이다.

Keyset Pagination은 이전 페이지의 마지막 값을 기준으로 다음 데이터를 조회한다.

예를 들어 첫 페이지 조회는 다음과 같이 수행된다.

SELECT *
FROM orders
ORDER BY id
LIMIT 1000

첫 페이지의 마지막 id가 1000이라면 다음 페이지는 다음과 같이 조회된다.

SELECT *
FROM orders
WHERE id > 1000
ORDER BY id
LIMIT 1000

이 방식은 인덱스를 효율적으로 활용할 수 있으며 페이지가 뒤로 갈수록 성능이 떨어지는 문제가 발생하지 않는다.

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

구성 요소설명
DataSource데이터베이스 연결을 담당한다. Reader가 실행될 때 데이터 조회를 위해 커넥션을 제공한다.
RowMapperResultSet의 데이터를 Java 객체로 변환하는 역할을 수행한다. 조회된 각 row를 도메인 객체로 매핑한다.
PagingQueryProvider데이터베이스 종류에 맞는 Paging SQL을 생성하는 역할을 한다. 페이지 조회에 필요한 SELECT, FROM, ORDER BY 등을 조합한다.
pageSize한 번에 조회할 데이터 수를 의미한다. 페이지 단위로 데이터를 나누어 읽을 때 기준이 되는 크기이다.
sortKeys페이징 기준이 되는 정렬 키이다. 이전 페이지의 마지막 값을 기준으로 다음 데이터를 조회하기 때문에 일반적으로 Primary Key가 사용된다.

특히 sortKeys는 Paging Reader의 동작을 결정하는 중요한 요소이다.

3-2. 예제

다음은 JdbcPagingItemReader 설정 예제이다.

@Bean
public JdbcPagingItemReader<Order> pagingReader(DataSource dataSource) {

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

    reader.setDataSource(dataSource);
    reader.setPageSize(1000);
    reader.setRowMapper(new BeanPropertyRowMapper<>(Order.class));

    MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider();

    queryProvider.setSelectClause("SELECT id, user_id, status");
    queryProvider.setFromClause("FROM orders");

    Map<String, Order> sortKeys = new HashMap<>();
    sortKeys.put("id", Order.ASCENDING);

    queryProvider.setSortKeys(sortKeys);

    reader.setQueryProvider(queryProvider);

    return reader;
}

Paging Reader에서 가장 중요한 설정은 sortKeys이다. Paging Reader는 마지막으로 읽은 데이터를 기준으로 다음 페이지를 조회하기 때문에 정렬 기준이 명확하게 정의되어야 한다. 일반적으로 Primary Key를 사용하며, 정렬 기준이 유니크하지 않으면 중복 처리나 데이터 누락이 발생할 수 있다.

Paging 방식은 Cursor 방식과 비교했을 때 커넥션을 오래 유지하지 않는다는 장점이 있다. 각 페이지 조회가 완료되면 ResultSet이 닫히고 새로운 쿼리가 실행되기 때문에 데이터베이스 리소스를 보다 안정적으로 사용할 수 있다. 또한 대규모 데이터 처리 환경에서는 Keyset Pagination 덕분에 성능이 일정하게 유지된다.

0개의 댓글