대량 데이터 처리에서 Cursor·Batch·Bulk Insert 비교

· 약 12분 읽기 · 조회 1 #대량 처리#JDBC#배치#Spring Batch#데이터베이스#성능

수십만~수백만 건을 읽고 쓰는 야간 배치나 데이터 이관은 SQL보다 "몇 번 왕복하고 몇 번 커밋하느냐"가 시간을 정한다. 이 글은 읽는 쪽의 OFFSET 페이징·키셋 페이징·서버 커서와, 쓰는 쪽의 한 건씩 INSERT·JDBC 배치·벌크 로드를 같은 기준(왕복, 메모리, 커밋 단위, 재시작)으로 비교하고, DB와 드라이버마다 켜야 하는 설정을 정리한 것이다.

하역장에서 상자 하나, 손수레, 지게차 팔레트로 짐을 옮기는 세 가지 방식

한눈에 보기

방식 왕복 횟수 앱 메모리 잘 맞는 곳 주의할 점
OFFSET 페이징 페이지 수만큼 페이지 크기 화면 목록, 작은 표 뒤 페이지일수록 느려지고, 처리 중 데이터가 바뀌면 건너뛰거나 중복
키셋 페이징 페이지 수만큼 페이지 크기 대량 읽기 기본값 정렬 기준에 유일한 키와 인덱스가 필요
서버 커서·스트리밍 연결 하나로 계속 fetchSize 만큼 한 번에 끝까지 읽는 배치 읽는 동안 연결과 트랜잭션을 오래 잡음
한 건씩 INSERT 건수만큼 작음 수백 건 이하 100만 건이면 왕복 100만 번
JDBC 배치 건수 ÷ 배치 크기 배치 크기 앱에서 가공해 넣는 대량 쓰기 드라이버 옵션 없이는 묶이지 않을 수 있음
벌크 로드 명령 한 번 파일·스트림 이관, 초기 적재 제약조건·트리거·권한·보안 설정 확인

한 건씩 INSERT는 왕복과 커밋이 100만 번, 1,000건 JDBC 배치는 왕복 1,000번, 벌크 로드는 데이터를 연속으로 보내고 응답을 한 번 기다린다는 비교 그림

예시로 앱과 DB 사이 왕복이 1ms라면 100만 건을 한 건씩 넣을 때 네트워크 대기만 1,000초(약 16분 40초)다. 1,000건씩 배치로 묶으면 왕복이 1,000번, 대기는 약 1초로 줄어든다. DB가 쓰는 시간이 더해지므로 이 계산은 하한이다.

읽기 1: OFFSET 대신 키셋 페이징

LIMIT 1000 OFFSET 900000은 앞의 90만 건을 읽고 버린 뒤 1,000건을 돌려준다. 첫 페이지는 빠르지만 마지막 페이지는 표 전체를 훑는 것과 비슷해진다. 처리 중에 앞쪽 행이 지워지거나 추가되면 페이지 경계가 밀려 행을 건너뛰거나 두 번 읽는다.

키셋 페이징은 마지막으로 읽은 키부터 다시 시작한다.

-- 첫 페이지
SELECT id, member_id, amount FROM settlement
 WHERE settle_date = :d
 ORDER BY id
 LIMIT 1000;

-- 다음 페이지: 직전 페이지의 마지막 id 를 넘긴다
SELECT id, member_id, amount FROM settlement
 WHERE settle_date = :d AND id > :lastId
 ORDER BY id
 LIMIT 1000;
  • 정렬 열은 유일해야 한다. created_at처럼 같은 값이 여러 개면 (created_at, id)처럼 유일한 열을 덧붙인다.
  • (settle_date, id) 인덱스가 있어야 매 페이지가 인덱스 범위 검색으로 끝난다. 없으면 페이지마다 정렬이 일어난다.
  • 마지막 키를 저장해 두면 실패한 뒤 그 지점부터 다시 시작할 수 있다. 재시작이 공짜로 따라온다.

Spring Batch의 JdbcPagingItemReader는 정렬 키(sortKey)를 받아 두 번째 페이지부터 "마지막 키보다 큰 행"을 읽는 방식으로 쿼리를 만든다. 반면 JPA의 setFirstResult를 쓰는 리더는 OFFSET 방식이므로 큰 표에서는 생성 SQL을 확인한다.

읽기 2: 서버 커서와 fetchSize

한 번 열어 끝까지 읽는 배치라면 커서가 가장 단순하다. 쿼리는 한 번, 행은 fetchSize만큼씩 나눠 받는다. 함정은 드라이버마다 기본 동작이 다르다는 점이다. setFetchSize를 불렀는데도 결과 전체가 앱 메모리에 올라와 OutOfMemoryError가 나는 일이 흔하다.

DB·드라이버 기본 동작 나눠 받으려면
MySQL Connector/J 결과 전체를 메모리에 읽음 useCursorFetch=true + 양수 fetchSize(서버 커서), 또는 fetchSize = Integer.MIN_VALUE + 전진 전용·읽기 전용(한 행씩 스트리밍)
PostgreSQL JDBC 결과 전체를 메모리에 읽음 autocommit 끄기 + 전진 전용 ResultSet + fetchSize 지정
Oracle JDBC 기본 10행씩 가져옴 fetchSize를 수백~수천으로 올려 왕복을 줄임

MySQL 스트리밍 방식은 결과를 다 읽거나 닫기 전까지 같은 연결로 다른 쿼리를 보낼 수 없다. 읽으면서 같은 연결로 INSERT를 하려는 코드는 여기서 실패한다. 읽기와 쓰기 연결을 나누거나 서버 커서 방식을 쓴다. 옵션 이름과 조건은 쓰는 드라이버 버전의 문서를 확인한다.

MyBatis에서는 반환 타입을 Cursor<T>로 두거나 ResultHandler를 쓰고, <select>에 fetchSize를 준다.

@Transactional(readOnly = true)   // 커서를 다 읽을 때까지 세션과 연결이 열려 있어야 한다
public void export(LocalDate d, Consumer<Settlement> sink) {
    try (Cursor<Settlement> cursor = mapper.streamByDate(d)) {
        cursor.forEach(sink);
    }
}

커서의 대가는 시간이다. 처리하는 내내 연결과 트랜잭션이 열려 있어 커넥션 풀에서 한 자리가 빠지고, DB에 따라 오래된 읽기 시점을 유지하느라 언두나 버전 정리가 밀린다. Spring Batch JdbcCursorItemReader도 스텝 내내 연결 하나를 잡는다. 오래 걸리고 중간 재시작이 중요한 작업은 키셋 페이징이 더 다루기 쉽다.

쓰기 1: 한 건씩 INSERT에서 JDBC 배치로

addBatch로 쌓았다가 executeBatch로 한 번에 보낸다. 묶음마다 커밋하고, 쌓인 객체는 비운다.

conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(
        "INSERT INTO settlement_result (settle_id, amount, status) VALUES (?, ?, ?)")) {
    int n = 0;
    for (Result r : results) {
        ps.setLong(1, r.settleId());
        ps.setBigDecimal(2, r.amount());
        ps.setString(3, r.status());
        ps.addBatch();
        if (++n % 1000 == 0) {
            ps.executeBatch();
            conn.commit();
        }
    }
    ps.executeBatch();   // 남은 건
    conn.commit();
}

코드만 배치로 바꾸면 끝나지 않는다. 드라이버가 실제로 묶어 보내는지 확인해야 한다.

  • MySQL Connector/J: rewriteBatchedStatements=true를 켜야 배치 INSERT가 다중 VALUES 문으로 다시 쓰인다. 끄면 문장을 하나씩 보내 왕복이 거의 줄지 않는다. 켜면 executeBatch가 돌려주는 건수가 건별 값이 아닐 수 있으므로 반환값으로 성공 건수를 세는 코드는 확인한다.
  • PostgreSQL JDBC: reWriteBatchedInserts=true를 켜면 INSERT 배치를 다중 VALUES로 묶는다.
  • 배치 크기는 500~1,000건에서 시작해 직접 재어 보고 정한다. 너무 크면 한 묶음의 패킷이 커지고(MySQL max_allowed_packet), 실패했을 때 다시 할 양이 커진다.

JdbcTemplate.batchUpdate, MyBatis ExecutorType.BATCH도 같은 JDBC 배치라 위 옵션이 똑같이 필요하다.

쓰기 2: JPA 배치와 다중 VALUES

JPA로 대량 INSERT를 하면 세 가지가 동시에 맞아야 묶인다.

spring:
  jpa:
    properties:
      hibernate:
        jdbc.batch_size: 1000
        order_inserts: true     # 같은 테이블 INSERT 를 모아 배치가 끊기지 않게
        order_updates: true
  • ID 전략: GenerationType.IDENTITY면 Hibernate가 INSERT마다 생성된 키를 받아야 해서 INSERT 배치를 하지 않는다. 시퀀스 전략(필요하면 allocationSize로 번호를 미리 받음)이나 앱에서 만든 키를 쓴다.
  • flush·clear: batch_size마다 entityManager.flush()와 clear()를 부른다. 안 부르면 영속성 컨텍스트에 100만 개 엔티티가 쌓여 메모리와 변경 감지 비용이 함께 커진다.

다중 VALUES 문을 직접 만들어도 왕복은 줄지만, 문장 길이와 바인드 변수 개수에 상한이 있어 행 수를 나눠야 한다. 드라이버 옵션으로 배치를 다시 쓰게 하는 쪽이 단순하다.

쓰기 3: 벌크 로드

이관이나 초기 적재처럼 가공이 거의 없는 대량 쓰기는 DB의 전용 적재 기능이 가장 빠르다. SQL 문장 단위가 아니라 데이터 흐름으로 받아들이기 때문이다.

DB 기능 확인할 것
PostgreSQL COPY ... FROM STDIN (JDBC는 CopyManager) 한 행이라도 형식 오류면 그 COPY 전체가 실패
MySQL LOAD DATA [LOCAL] INFILE LOCAL은 서버 local_infile과 클라이언트 설정을 둘 다 켜야 하며, 켜면 파일 읽기 권한이 넓어진다
Oracle SQL*Loader 직접 경로, INSERT /*+ APPEND */ 직접 경로 적재는 대상 테이블을 배타적으로 잡고, 로그를 줄였다면 적재 뒤 백업 필요

벌크 로드 전에 정할 것이 있다.

  • 인덱스·제약조건: 큰 표에 처음 적재할 때는 보조 인덱스를 나중에 만드는 편이 빠른 경우가 많다. 다만 운영 중인 표에서는 지우지 않는다.
  • 트리거: 적재 방식에 따라 트리거가 돌지 않을 수 있다. 업무 규칙이 트리거에 있다면 먼저 확인한다.
  • 오류 행 처리: 잘못된 행을 버릴지, 별도 파일로 뺄지, 전체를 멈출지 정한다.

커밋 간격과 재시작

배치 속도와 안전은 커밋 간격에서 갈린다.

  • 너무 잦은 커밋: 커밋마다 DB가 로그를 디스크에 확정한다. 건마다 커밋하면 이 대기가 100만 번 생긴다.
  • 너무 드문 커밋: 100만 건을 한 트랜잭션에 넣으면 언두·리두와 락이 그만큼 쌓인다. 중간에 실패하면 롤백도 그만큼 걸리고, 그동안 같은 행을 쓰려는 온라인 거래가 기다린다.
  • 시작점: 1,000~10,000건마다 커밋하고, 락 대기와 언두 사용량을 보며 조정한다.

커밋을 나누면 "절반만 들어간 상태"가 생긴다. 그래서 재시작 방법을 같이 설계한다.

장치 하는 일 없으면 생기는 일
마지막 처리 키 기록 실패한 지점부터 다시 읽음 처음부터 다시 돌려 시간이 두 배
멱등한 쓰기(유일 키 + UPSERT) 같은 행을 두 번 넣어도 결과가 같음 재시작 때 중복 행
처리 상태 열·작업 이력 표 묶음별 성공·실패를 남김 어디까지 됐는지 아무도 모름
스테이징 표 후 교체 다 넣은 뒤 한 번에 반영 적재 중간 상태가 화면에 보임

Spring Batch에서는 청크(chunk(1000))가 커밋 단위이고, 메타 테이블 기록으로 실패한 스텝을 이어서 실행한다.

자주 하는 실수

  • fetchSize만 넣고 스트리밍이라고 믿는다. MySQL·PostgreSQL 드라이버는 조건이 맞지 않으면 결과 전체를 메모리에 올린다.
  • 배치 코드를 쓰고 드라이버 옵션을 안 켠다. rewriteBatchedStatements 없이 MySQL에 배치를 보내면 왕복이 거의 줄지 않는다.
  • IDENTITY 키로 JPA 배치를 기대한다. INSERT 배치가 꺼져 있어 한 건씩 나간다.
  • 100만 건을 한 트랜잭션에 넣는다. 실패 한 번에 전체 롤백과 긴 락 대기가 따라온다.
  • OFFSET으로 바뀌는 표를 끝까지 읽는다. 느려지는 것보다 행을 건너뛰는 쪽이 더 위험하다. 결과가 틀려도 아무 오류가 나지 않는다.

점검표

  • 대량 읽기에 OFFSET이 아니라 키셋 페이징이나 커서를 쓴다
  • 키셋 정렬 열이 유일하고, 조건과 정렬을 덮는 인덱스가 있다
  • 커서 사용 시 드라이버별 스트리밍 조건(autocommit, ResultSet 종류, 옵션)을 확인했다
  • 커서를 읽는 동안 같은 연결로 다른 쿼리를 보내지 않는다
  • MySQL은 rewriteBatchedStatements, PostgreSQL은 reWriteBatchedInserts 설정을 정했다
  • JPA 대량 INSERT는 IDENTITY가 아닌 키 전략과 flush·clear를 쓴다
  • 실제로 묶음이 나가는지 SQL·드라이버 로그로 확인했다
  • 커밋 간격을 정했고 락 대기·언두 사용량을 보며 조정했다
  • 실패하면 어디서부터 다시 시작하는지, 중복 행을 어떻게 막는지 정했다
  • 벌크 로드 전에 트리거·제약조건·오류 행 처리와 보안 설정을 확인했다

대량 처리는 SQL을 빠르게 만드는 문제가 아니라 왕복을 줄이고, 메모리를 일정하게 두고, 커밋을 적당히 나눈 뒤, 실패해도 이어서 돌 수 있게 만드는 문제다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기