본문 바로가기
Spring/Batch

[Spring Batch] RDB 대용량 데이터 처리의 정석: JDBC vs JPA 분석

by coding_whale 2026. 5. 14.
반응형

배치 처리의 핵심은 '대량의 데이터를 얼마나 안전하고 효율적으로 다루는가'에 있다. 일반적인 웹 애플리케이션의 단건 조회 방식으로는 수천만 건의 데이터를 감당할 수 없으며, 무턱대고 데이터를 한꺼번에 조회했다가는 OutOfMemoryError로 인해 시스템 전체가 마비될 수 있다.

스프링 배치는 이러한 위험을 방지하기 위해 데이터를 정교하게 쪼개서 읽고 쓰는 강력한 도구들을 제공한다. 오늘은 JDBC와 JPA를 활용한 읽기/쓰기 전략과 실무에서 반드시 피해야 할 성능 함정들을 상세히 정리해 본다.

 

2. 대용량 처리를 위한 두 가지 읽기 전략: Cursor vs Paging

데이터베이스에서 대량의 데이터를 가져올 때 스프링 배치는 크게 커서(Cursor) 기반과 페이징(Paging) 기반이라는 두 가지 전략을 사용한다.

2.1 커서 기반 읽기 (Cursor-based)

데이터베이스와 연결을 유지한 채 커서를 한 칸씩 옮기며 데이터를 스트리밍 방식으로 읽어온다.

  • 특징: 하나의 커넥션으로 전체 결과셋을 순차적으로 처리한다.
  • 장점: 메모리 사용량이 매우 적고 읽기 속도가 우수하다.
  • 단점: 작업 시간이 너무 길어지면 DB 커넥션을 너무 오래 점유하게 되어 다른 프로세스에 영향을 주거나, 네트워크 단절 시 커서가 닫힐 위험이 있다.

 

2.2 페이징 기반 읽기 (Paging-based)

데이터를 일정 크기(Page Size)로 잘라서 여러 번의 쿼리를 통해 나눠 읽는다.

  • 특징: 각 페이지를 읽을 때마다 새로운 쿼리를 실행한다.
  • 장점: 커넥션 점유 시간이 짧아 안정적이고, 여러 스레드에서 병렬 처리를 하기에 유리하다.
  • 단점: 페이지 번호가 뒤로 갈수록 DB 성능이 저하될 수 있다 (주로 Offset 방식의 한계).

 

 

3. JDBC ItemReader: 가장 원시적이지만 강력한 무기

3.1 JdbcCursorItemReader (스트리밍의 정석)

ResultSet을 통해 데이터를 한 행씩 가져오며, RowMapper를 통해 객체로 변환한다.

구현 예제 및 분석

@Bean
public JdbcCursorItemReader<Victim> terminatedVictimReader() {
    return new JdbcCursorItemReaderBuilder<Victim>()
            .name("terminatedVictimReader") // 리더 이름 (로깅용)
            .dataSource(dataSource)         // 주입받은 DB 연결 정보
            // 파라미터는 '?'로 마스킹 처리하여 SQL 인젝션 방지
            .sql("SELECT * FROM victims WHERE status = ? AND terminated_at <= ? ORDER BY id ASC")
            // ? 자리에 들어갈 파라미터를 순서대로 바인딩
            .queryArguments(List.of("TERMINATED", LocalDateTime.now()))
            // DB 컬럼명과 객체 필드명을 자동으로 매핑 (CamelCase 자동 변환)
            .beanRowMapper(Victim.class)
            // 핵심 최적화: 한 번의 통신으로 가져올 로우(row) 수
            .fetchSize(1000) 
            .build();
}

💡 실무 팁: Fetch Size의 비밀 커서 기반이라고 정말 네트워크를 통해 한 줄씩 가져올까? 아니다. JDBC 드라이버는 내부적으로 fetchSize만큼 데이터를 버퍼에 미리 적재한다. 이 값을 적절히 설정해야 네트워크 왕복 횟수를 줄여 성능을 최적화할 수 있다.

  • MySQL 주의: MySQL은 기본적으로 쿼리 결과를 한 번에 메모리로 다 가져온다. useCursorFetch=true 옵션을 설정해야 실제 스트리밍 방식이 작동한다.

 

3.2 JdbcPagingItemReader (안정적인 데이터 분할)

페이지 단위로 끊어 읽을 때, 성능을 위해 반드시 Keyset 기반 페이징을 사용해야 한다.

Keyset 페이징 vs Offset 페이징

  • Offset 방식: LIMIT 10 OFFSET 100만은 DB가 앞의 100만 개를 다 읽고 버리는 비효율을 낳는다.
  • Keyset 방식: WHERE id > 100만 LIMIT 10은 인덱스를 타고 즉시 필요한 데이터로 점프한다.

구현 예제

@Bean
public JdbcPagingItemReader<Victim> terminatedVictimReader() throws Exception {
    return new JdbcPagingItemReaderBuilder<Victim>()
            .name("terminatedVictimReader")
            .dataSource(dataSource)
            .pageSize(10) // Chunk Size와 동일하게 맞추는 것을 권장
            .selectClause("SELECT id, name, process_id, status")
            .fromClause("FROM victims")
            .whereClause("WHERE status = :status")
            // Keyset 페이징의 핵심: 유니크한 정렬 키가 필수적이다.
            .sortKeys(Map.of("id", Order.ASCENDING)) 
            .parameterValues(Map.of("status", "TERMINATED"))
            .beanRowMapper(Victim.class)
            .build();
}

 

 

4. JdbcBatchItemWriter: DB에 INSERT 폭격을 날리는 법

읽기가 끝났다면 저장은 효율적이어야 한다. JdbcBatchItemWriter는 JDBC의 batchUpdate 기능을 사용한다.

  • Batch Update의 위력: 일반적인 INSERT가 매번 DB와 통신한다면, Batch Update는 쿼리 템플릿 하나에 파라미터 뭉치를 실어 한 번에 보낸다. 청크 크기가 100이라면 통신 횟수는 1/100로 줄어든다.
@Bean
public JdbcBatchItemWriter<HijackedOrder> rescuedOrderWriter() {
    return new JdbcBatchItemWriterBuilder<HijackedOrder>()
            .dataSource(dataSource)
            // 네임드 파라미터(:status, :id)를 사용하여 가독성 확보
            .sql("UPDATE orders SET status = :status WHERE id = :id")
            // HijackedOrder 객체의 필드를 SQL 파라미터명에 자동으로 매핑
            .beanMapped()
            // 업데이트 결과가 0건일 경우 예외를 발생시켜 데이터 무결성 보장
            .assertUpdates(true) 
            .build();
}

 

 

5. JPA ItemReader/Writer: 객체 지향과 배치의 조화 (혹은 함정)

JPA는 ORM의 표준이지만, 대용량 처리 시 내부 메커니즘을 모르면 성능 함정에 빠지기 쉽다.

5.1 JpaPagingItemReader의 치명적 약점

JpaPagingItemReader는 내부적으로 setFirstResult()와 setMaxResults()를 사용하는데, 이는 DB 레벨에서 OFFSET 기반 페이징으로 변환된다. 따라서 수천만 건 이상의 데이터를 처리할 때는 성능이 처참하게 떨어진다.

 

5.2 JPA 페이징 시 N+1 문제와 @BatchSize

페이징 쿼리 시 연관된 데이터를 함께 가져오려면 Fetch Join을 생각하기 쉽지만, JPA 페이징과 Fetch Join은 함께 쓰면 안 된다. 하이버네이트가 페이징을 처리하기 위해 전체 데이터를 메모리에 다 올려버리기 때문이다.

  • 해결책: 지연 로딩을 쓰되, @BatchSize를 활용하여 IN 쿼리로 연관 데이터를 일괄 조회하자.
@Entity
@Table(name = "posts")
@Getter
public class Post {
    @Id private Long id;

    // 지연 로딩을 유지하면서, 배치 처리를 위해 @BatchSize로 최적화
    @OneToMany(mappedBy = "post", fetch = FetchType.EAGER)
    @BatchSize(size = 100) 
    private List<Report> reports = new ArrayList<>();
}

 

5.3 transacted(false) 옵션의 중요성

JpaPagingItemReader는 페이지를 읽을 때마다 독자적으로 트랜잭션을 시작하고 entityManager.clear()를 호출한다. 이는 두 가지 심각한 문제를 일으킨다.

  1. @BatchSize 무력화: 트랜잭션이 페이지 로딩 직후 종료되므로, 프로세서에서 연관 데이터 접근 시 LazyInitializationException이 발생하거나 최적화가 작동하지 않는다.
  2. 의도치 않은 Flush: 페이지 조회 전 flush()를 호출하여 프로세서에서 수정한 데이터가 미리 반영될 위험이 있다.

해결책: 반드시 .transacted(false) 옵션을 고려하라. 단, 이 경우 엔티티가 영속성 컨텍스트에서 분리(detach)되므로 필요한 데이터는 EAGER로 가져오거나 DTO로 프로젝션하는 것이 안전하다.

 

 

6. [긴급] ID 생성 전략과 배치 성능

배치 작업에서 저장 성능을 결정짓는 핵심 중 하나는 ID 생성 전략이다.

  • IDENTITY 전략: DB에서 ID를 생성하기 전까지 하이버네이트는 ID를 모른다. 따라서 Batch Update가 불가능하고 매번 단건 INSERT를 날린다. 배치 성능의 주적이다.
  • SEQUENCE 전략: 시퀀스 값을 미리 대량 할당받아(allocationSize) 메모리에서 사용하기 때문에 Batch INSERT가 가능하다.

💡 권고: 배치 전용 테이블이라면 무조건 SEQUENCE 전략을 사용하라. TABLE 전략은 행(row) 잠금으로 인해 성능이 더 나쁘다.

 

 

7. 마무리하며: 상황별 최적의 무기 선택 가이드

상황추천 도구핵심 전략

압도적인 읽기 성능 JdbcCursorItemReader fetchSize 최적화 필수, MySQL 옵션 주의
작업 안정성 중시 JdbcPagingItemReader Keyset 방식 구성, Unique Sort Key 필수
객체 중심 비즈니스 JpaCursorItemReader 스트리밍 지원 여부 확인, Fetch Join 활용
JPA 대량 저장 JpaItemWriter SEQUENCE 전략 사용, merge() 오버헤드 주의

배치 처리에서 '적당히'란 없다. 한 번의 설계 실수가 데이터 오염이나 시스템 가동 중단으로 이어진다. 언제나 정렬(ORDER BY)을 잊지 말고, 실패 시 재시작 지점을 보장할 수 있는 설계를 습관화하자.

 

 

반응형