Bldev's Blog

[스프링/배치] JDBC 배치와 스프링 배치: 효율적인 대용량 데이터 배치 처리

2026. 10. 6.

대용량 데이터 배치 처리

수십만 건에서 수백만 건에 달하는 대용량 데이터를 데이터베이스에 반영하거나 마이그레이션할 때 단순 루프 기반의 단일 트랜잭션 로직은 시스템 장애를 초래할 가능성이 높아 위험하다. 이 장애의 원인은 크게 두 가지로 분류해볼 수 있다.

첫 번째는 데이터베이스와의 네트워크 왕복 횟수(round-trip) 증가로 인한 네트워크 I/O 병목이다. 쿼리 호출이 매번 발생하여 페이로드 데이터가 데이터베이스로 전송될 경우 데이터베이스 서버 자체의 성능이 좋더라도 소켓 통신 오버헤드와 패킷 왕복 시간으로 인해 처리 시간은 데이터 건수에 비례하여 늘어나게 된다.

두 번째는 JVM 힙 메모리 증가와 장기 트랜잭션으로 인한 리소스 고갈이다. 대량의 데이터를 한꺼번에 애플리케이션 메모리에 적재하여 가비지 컬렉터(GC)가 감당하지 못할 경우 OutOfMemoryError가 발생하게 된다. 이 경우 트랜잭션이 오랜 기간 유지되면서 데이터베이스 커넥션 풀을 독점하고 테이블 락을 장기간 점유하게 된다.

이 두 가지 문제는 발생 계층과 성격 측면에서 구분해 볼 필요가 있다. 두 문제를 해결하기 위한 기술인 JDBC 배치와 스프링 배치는 기술이 책임과 역할이 근본적으로 구분되어 있어 적절한 요구사항에 맞게 적용이 가능하다.

JDBC 배치

JDBC 배치는 JDBC 드라이버 수준에서 제공하는 네트워크 전송 최적화 기술이다. 개별 쿼리를 매번 전송하는 대신 한 번에 전송하여 네트워크 부하를 줄여주기 때문에 대용량 데이터를 처리할 때 개별 실행 방식보다 훨씬 빠르고 효율적인 장점을 제공한다.

일반적인 쿼리 실행 방식의 경우 PreparedStatement에 파라미터를 바인딩한 후 executeUpdate() 메서드를 호출할 때마다 매번 데이터베이스로 네트워크 패킷이 전송된다. 반면 JDBC 배치는 PreparedStatement의 addBatch() 메서드를 호출하여 SQL 실행 명령과 파라미터들을 드라이버 내부 버퍼에 모아두었다가 executeBatch() 메서드가 호출되는 시점에 묶어서 전송한다. 이를 통해 네트워크 왕복 횟수를 줄여 통신 지연 시간을 줄일 수 있다.

다만 실제 전송 방식은 드라이버마다 다르며 설정에 따라 달라진다. MySQL Connector/J는 rewriteBatchedStatements=true일 때 쿼리를 개별 전송하지 않고 묶어서 처리하려 하며, 설정하지 않으면 배치에 담긴 쿼리가 각각 실행된다. PostgreSQL JDBC 드라이버의 reWriteBatchedInserts는 기본값이 false이다. Oracle JDBC 드라이버는 큰 배치를 내부적으로 더 작은 단위로 나누어 전송하며 단위마다 서버 왕복이 한 번 발생한다. 따라서 사용하는 드라이버와 설정을 확인하지 않으면 기대한 만큼의 네트워크 최적화 효과를 얻지 못할 수 있다.

또한 JDBC 배치 자체는 드라이버 수준의 네트워크 최적화일 뿐이므로 호출자가 보내는 데이터의 양을 줄여주거나 커밋 단위를 나누어 주지 않는다. 100만 건의 데이터를 리스트에 담아 addBatch()를 100만 번 호출한다면 해당 객체와 바인딩 데이터가 JVM 힙 메모리에 상주하므로 행의 크기와 힙 크기에 따라 OOM이 발생할 위험이 크다. 또한 이 트랜잭션 작업이 종료될 때까지 애플리케이션은 하나의 데이터베이스 커넥션을 계속 점유하므로 커넥션 풀 고갈과 장기 트랜잭션 문제가 남게 된다. 이는 JDBC 배치의 기술적 한계라기보다 호출자가 executeBatch()와 커밋을 일정 건수마다 나누어 수행하지 않았기 때문이며, 이러한 분할 로직을 직접 구현하지 않기 위해 스프링 배치를 사용한다.

스프링 배치

스프링 배치(Spring Batch)는 JDBC 배치 같은 하위 기술이 안전하고 효율적으로 동작할 수 있도록 메모리를 관리하고 트랜잭션의 생명주기를 제어하는 오케스트레이션 프레임워크이다. JDBC 배치처럼 네트워크 패킷을 묶어주는 역할은 하지 않으며 배치 작업을 특정 단위의 트랜잭션으로 분할하고 커밋을 제어하는 역할을 수행한다.

스프링 배치의 핵심은 청크 지향 처리(chunk-oriented processing) 모델이다. 전체 데이터를 한 번에 메모리에 올리는 대신 지정된 청크(chunk) 크기 단위로 일련의 읽기(ItemReader), 처리(ItemProcessor), 쓰기(ItemWriter) 작업을 반복 수행한다.

대용량 데이터를 특정 단위로 나누어 반복적으로 트랜잭션로 관리하는 것은 스프링 배치를 사용하지 않더라도 가능한 일반적인 방법이다. 스프링 배치는 이 청크 기반 분할 처리를 추상화하고 인터페이스로 표준화하여 반복문, 트랜잭션 관리, 예외 처리 등 청크 기반 분할 처리를 위해 필요한 보일러플레이트 코드 작성을 줄여주는 장점을 제공한다.

스프링 배치의 청크 분할은 JVM 힙 메모리 사용량을 제한하는 역할을 수행한다. JpaPagingItemReader나 JdbcPagingItemReader 등의 페이징 리더를 사용하면 데이터베이스에서 페이지 단위로 읽기 때문에 전체 데이터 크기와 관계없이 애플리케이션 힙 메모리에는 페이지 크기(pageSize)와 청크 크기 단위의 객체만 적재된다. 다만 pageSize와 청크 크기는 별개의 설정이므로 두 값을 함께 고려해야 하며, 프로세서가 객체를 늘리는 경우 등 처리 방식에 따라 실제 메모리 사용량은 달라질 수 있다.

스프링 배치는 트랜잭션의 범위를 청크 단위로 분할함으로써 커넥션 점유 시간을 줄인다. 청크 단위로 처리와 쓰기 작업이 완료되면 PlatformTransactionManager를 통해 즉시 트랜잭션을 커밋한 후 해당 청크 객체의 참조를 해제하여 메모리를 회수한다. JPA를 사용하는 경우 JpaItemWriter가 청크를 쓴 직후 flush()한 뒤 영속성 컨텍스트를 clear()하므로(5.1부터 clearPersistenceContext 기본값 true) 청크 단위로 1차 캐시가 비워진다. 이를 통해 하나의 배치가 실행되는 동안 쓰기 트랜잭션이 커넥션과 락을 장시간 점유하지 않도록 할 수 있다. 다만 일부 자료에 따르면 JdbcCursorItemReader는 별도의 커넥션으로 커서를 열어 유지하므로 이 설명은 페이징 리더를 사용하는 경우를 전제로 한다. 작업 도중 실패가 발생하더라도 전체가 롤백되는 대신 실패한 청크 이전까지의 결과는 안전하게 커밋되어 보존된다.

스프링 배치의 또다른 특징은 배치 처리 작업과 관련된 메타데이터를 별도의 테이블로 관리하여 작업을 재시작(restart)하거나 특정 아이템 처리 시 에러가 발생하는 경우 해당 아이템을 건너뛰기(skip)할 수 있도록 하여 내결함성을 제공하는 것이다.

재시작은 ItemStream을 구현한 리더가 현재 읽기 위치를 ExecutionContext에 저장하고 복원하는 방식으로 동작한다. 따라서 리더의 saveState가 활성화되어 있어야 하고(빌더 기본값 true) 상태 저장 키로 쓰이는 리더 이름이 있어야 한다. JdbcPagingItemReader 같은 페이징 리더는 재시작 시 마지막 정렬 키 값으로 첫 페이지를 찾으므로 정렬 키에 유일성이 보장되어야 데이터 유실이 없다. 재개 지점은 실패한 아이템이 아니라 마지막으로 커밋된 청크의 끝이다.

스킵은 아이템 단위로 동작한다. 라이터에서 스킵 가능한 예외가 발생하면 청크 트랜잭션이 롤백되고 스프링 배치는 청크를 아이템 하나씩 각각의 트랜잭션으로 다시 처리하는 스캔(scan) 모드로 진입한다. 이때 기본 설정에서는 프로세서도 아이템마다 다시 호출되므로 프로세서는 멱등하게 작성되어야 하며, 스캔 구간에서는 청크 단위 배치 전송의 이점도 줄어든다.

비교

비교 영역 JDBC 배치 스프링 배치
동작 계층 데이터베이스 드라이버 계층 (전송 계층) 애플리케이션 오케스트레이션 계층 (애플리케이션 계층)
핵심 해결 과제 네트워크 패킷 왕복 횟수 절감 (드라이버·설정에 따라 효과 상이) JVM 메모리 고갈(OOM) 방지 및 장기 트랜잭션 관리
주된 동작 방식 드라이버 버퍼에 SQL을 적재 후 executeBatch() 시점에 전송 데이터를 청크 단위로 분할하여 반복 커밋
메모리 관리 직접 제공하지 않음 (호출자가 전달한 데이터를 그대로 적재하므로 호출자가 분할해야 함) 페이지·청크 크기 단위로 적재량을 제한 (프로세서 처리 방식에 따라 달라짐)
실패 대응 BatchUpdateException 발생, 이후 처리는 드라이버와 트랜잭션 설정에 따라 상이 (재시작 기능 없음) 메타데이터 기반 재시작(리더 상태 저장 필요), 아이템 단위 스킵/재시도

아키텍처 선택

실무 대용량 배치 파이프라인에서 두 기술은 경쟁이나 대체제로 선택을 고민하기 보다는 상호보완적으로 활용할 수 있다. 스프링 배치가 대용량 배치 처리 작업을 작은 청크 트랜잭션으로 분할하여 애플리케이션의 힙 메모리 한도를 관리하는 역할을 수행하고 JDBC는 청크 단위로 데이터가 데이터베이스로 전달되는 순간에 네트워크 전송량을 최적화한다.

스프링 배치가 제공하는 JdbcBatchItemWriter는 바로 이러한 목적을 위한 구현체이다. ItemReader가 청크 단위로 데이터를 읽어온 후 JdbcBatchItemWriter는 내부적으로 NamedParameterJdbcTemplate의 batchUpdate() 메서드를 호출하여 해당 청크에 속한 데이터를 JDBC 배치로 데이터베이스에 전송한다. 전송이 완료되면 스프링 배치는 즉시 트랜잭션을 커밋하고 리소스를 정리한 뒤 다음 청크 처리를 수행한다.

이러한 처리를 통해 애플리케이션은 힙 메모리 사용량을 청크 단위로 제한하면서 대용량 데이터를 반복 처리할 수 있으며 동시에 데이터베이스와의 네트워크 통신 비용을 줄일 수 있다. 다만 실제 효과는 드라이버 설정(예: MySQL의 rewriteBatchedStatements)에 따라 달라진다. 또한 JPA(Hibernate)를 쓰기에 사용하는 경우 IDENTITY 식별자 생성 전략에서는 hibernate.jdbc.batch_size를 설정해도 JDBC 배치가 비활성화되므로 JdbcBatchItemWriter를 사용하거나 SEQUENCE 전략을 사용하는 등의 고려가 필요하다.

참고