프로그래밍 PROGRAMMING/자바 JAVA AND FRAMEWORKS

Spring Batch 5로 대용량 데이터 처리하기

매운할라피뇨 2026. 3. 24. 08:30
반응형
Spring Batch 5 대용량 데이터 처리

Spring Batch 5는 대용량 데이터를 안정적으로 처리하기 위한 배치 프레임워크로, Spring Boot 3.x와 함께 Jakarta EE 9+ 기반으로 전면 재설계되었습니다. 수백만 건 이상의 레코드를 처리할 때 흔히 겪는 메모리 초과, 트랜잭션 경계 설정 오류, 성능 병목을 체계적으로 해결하는 방법을 운영 환경 경험을 바탕으로 다룹니다. 이 글을 통해 Spring Batch 5의 구조 변화를 이해하고, 대용량 조회·쓰기 최적화 전략과 병렬 처리 설계까지 적용할 수 있습니다.


Spring Batch 5의 핵심 구조 변화

Spring Batch의 처리 단위는 Job → Step → Chunk 구조입니다. Job은 전체 배치 프로세스를 나타내고, Step은 독립적인 처리 단계를, Chunk는 N개의 아이템을 묶어 하나의 트랜잭션으로 처리하는 단위를 의미합니다.

Spring Batch 5에서 가장 주목할 변화는 JobBuilderFactoryStepBuilderFactory완전히 제거된 점입니다. 기존에는 @Autowired로 팩토리를 주입받아 get("jobName")을 호출했지만, 5.x에서는 JobBuilder, StepBuilder 생성자에 JobRepository를 직접 전달하는 방식으로 바뀌었습니다. 이 변화의 핵심 이유는 의존성을 명시적으로 드러내 테스트와 디버깅을 용이하게 만들기 위함입니다.

또한 @EnableBatchProcessing의 동작 방식도 달라졌습니다. Spring Boot 3.x에서는 이 어노테이션을 사용하지 않는 것이 기본이며, 선언하면 오히려 자동 설정이 비활성화됩니다. Boot의 auto-configuration이 JobRepository, JobLauncher, PlatformTransactionManager를 자동으로 구성해주므로, 대부분의 프로젝트에서는 어노테이션 없이 시작하는 것이 올바른 접근입니다.

아래는 Spring Batch 5 기준으로 청크 기반 Step을 구성하는 기본 예제입니다.

@Configuration
public class LargeDataJobConfig {

    @Bean
    public Job largeDataJob(JobRepository jobRepository, Step processStep) {
        return new JobBuilder("largeDataJob", jobRepository)
                .start(processStep)
                .build();
    }

    @Bean
    public Step processStep(
            JobRepository jobRepository,
            PlatformTransactionManager transactionManager,
            ItemReader<Order> reader,
            ItemProcessor<Order, OrderResult> processor,
            ItemWriter<OrderResult> writer) {

        return new StepBuilder("processStep", jobRepository)
                .<Order, OrderResult>chunk(1000, transactionManager) // 1000건 단위 트랜잭션
                .reader(reader)
                .processor(processor)
                .writer(writer)
                .faultTolerant()
                .skipLimit(100)                      // 최대 100건 skip 허용
                .skip(DataIntegrityViolationException.class)
                .retryLimit(3)                       // 최대 3회 재시도
                .retry(TransientDataAccessException.class)
                .build();
    }
}

chunk(1000, transactionManager) 호출 한 줄로 청크 크기와 트랜잭션 관리자를 동시에 바인딩합니다. Spring Batch 4까지는 TransactionManager를 Step과 별도로 설정해야 했지만, 5에서는 시그니처가 명확해졌습니다.

청크 사이즈 1000은 하나의 트랜잭션 범위를 의미합니다. 너무 작으면(100 이하) 커밋 오버헤드가 크게 증가하여 전체 실행 시간이 늘어나고, 너무 크면(5,000 이상) 메모리에 올라간 객체 수가 많아져 GC 부담이 커지며 롤백 시 재처리 비용도 증가합니다. 운영 환경에서는 1,000~5,000 범위에서 시작해 벤치마크를 통해 조정하되, 네트워크 지연이 큰 환경(클라우드 DB)에서는 커밋 횟수를 줄이기 위해 좀 더 큰 값을 선택하는 것이 유리합니다.

faultTolerant() 체인의 skipretry는 운영 안정성의 핵심입니다. skip은 특정 예외 발생 시 해당 아이템을 건너뛰고 다음 아이템을 처리하게 하며, retry는 일시적 장애(네트워크 타임아웃, 락 충돌 등)에서 자동 재시도합니다. 다만 skipLimit을 너무 크게 잡으면 데이터 누락을 인지하지 못할 수 있으므로, SkipListener를 구현해 skip된 아이템을 별도 테이블에 기록하는 것을 반드시 함께 적용해야 합니다.


JdbcPagingItemReader로 대용량 조회 성능 최적화

대용량 데이터를 읽을 때 Reader 선택이 성능에 직접적인 영향을 미칩니다. Spring Batch는 크게 커서(Cursor) 방식과 페이징(Paging) 방식의 Reader를 제공합니다.

커서 방식 (JpaCursorItemReader, JdbcCursorItemReader)은 DB 커넥션을 유지한 채 ResultSet을 스트리밍으로 읽습니다. 메모리 효율은 높지만, Step 전체 실행 동안 하나의 커넥션을 점유하므로 커넥션 풀이 고갈될 위험이 있습니다. 특히 처리 시간이 긴 Step에서는 DB 커넥션 타임아웃이 발생할 수 있어 운영 환경에서 주의가 필요합니다.

페이징 방식 (JdbcPagingItemReader, JpaPagingItemReader)은 매 페이지마다 별도의 쿼리를 실행하므로 커넥션 점유 시간이 짧습니다. 하지만 일반적인 OFFSET 기반 페이징은 데이터가 많아질수록 성능이 급격히 저하됩니다. 예를 들어 1,000만 건 테이블에서 OFFSET 9,000,000을 사용하면 DB는 먼저 900만 행을 스캔한 뒤 버리고 이후 데이터만 반환하므로, 뒤쪽 페이지일수록 응답 시간이 선형적으로 증가합니다.

이 문제를 해결하기 위해 JdbcPagingItemReader정렬 키(Sort Key) 를 명시적으로 지정해 OFFSET 없이 커서처럼 동작하게 만드는 No-Offset 전략이 효과적입니다. PK를 정렬 키로 지정하면 Spring Batch가 내부적으로 WHERE order_id > :last_id 형태의 조건절을 생성해, 이전 페이지의 마지막 ID 이후부터만 스캔합니다.

@Bean
public JdbcPagingItemReader<Order> orderReader(DataSource dataSource) throws Exception {
    MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider();
    queryProvider.setSelectClause("SELECT order_id, customer_id, amount, status");
    queryProvider.setFromClause("FROM orders");
    queryProvider.setWhereClause("WHERE status = 'PENDING'");

    Map<String, Order> sortKeys = new LinkedHashMap<>();
    sortKeys.put("order_id", Order.ASCENDING); // PK 기준 정렬로 No-Offset 효과

    queryProvider.setSortKeys(sortKeys);

    return new JdbcPagingItemReaderBuilder<Order>()
            .name("orderReader")
            .dataSource(dataSource)
            .pageSize(1000)                    // 청크 사이즈와 일치시켜야 함
            .queryProvider(queryProvider)
            .rowMapper(new BeanPropertyRowMapper<>(Order.class))
            .build();
}

여기서 pageSize와 청크 사이즈를 반드시 동일하게 맞춰야 합니다. 두 값이 다르면 예상치 못한 동작이 발생합니다. 예를 들어 pageSize=500이고 chunk=1000이면 한 번의 청크를 채우기 위해 두 번의 페이지 조회가 실행되며, 반대로 pageSize=1000이고 chunk=500이면 읽어온 데이터의 절반이 다음 청크로 넘어가면서 트랜잭션 경계가 의도와 달라질 수 있습니다.

DB별로 PagingQueryProvider를 선택해야 한다는 점도 기억해야 합니다. MySQL은 MySqlPagingQueryProvider, PostgreSQL은 PostgresPagingQueryProvider, Oracle은 OraclePagingQueryProvider를 사용합니다. SqlPagingQueryProviderFactoryBean을 활용하면 DataSource의 메타데이터를 기반으로 자동 선택되므로 DB 변경에 유연하게 대응할 수 있습니다.


ItemWriter 성능 최적화 — 벌크 쓰기 전략

대용량 배치에서 Reader 최적화만큼 중요한 것이 Writer의 벌크 쓰기 성능입니다. 기본 JpaItemWriterEntityManager.merge()를 건별로 호출하기 때문에, 1,000건 청크 시 1,000번의 INSERT/UPDATE SQL이 개별 실행됩니다.

이를 개선하는 첫 번째 방법은 JdbcBatchItemWriter를 사용하는 것입니다. JDBC 배치 모드로 여러 SQL을 한 번의 네트워크 왕복으로 보내므로 I/O 오버헤드가 획기적으로 줄어듭니다.

@Bean
public JdbcBatchItemWriter<OrderResult> orderWriter(DataSource dataSource) {
    return new JdbcBatchItemWriterBuilder<OrderResult>()
            .dataSource(dataSource)
            .sql("INSERT INTO order_results (order_id, result_status, processed_at) " +
                 "VALUES (:orderId, :resultStatus, :processedAt)")
            .beanMapped()                      // OrderResult 필드명 자동 매핑
            .build();
}

두 번째 방법은 JPA를 유지하면서 Hibernate의 JDBC 배치 옵션을 활성화하는 것입니다. application.yml에 다음 설정을 추가하면 JPA에서도 배치 INSERT가 가능합니다.

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 1000              # 청크 사이즈와 동일하게
          order_inserts: true           # INSERT 문 정렬로 배치 효율 극대화
          order_updates: true           # UPDATE 문도 정렬

order_insertsorder_updates 옵션은 Hibernate가 엔티티 타입별로 SQL을 정렬해 동일한 SQL끼리 묶어 실행하도록 합니다. 이 설정 없이는 서로 다른 엔티티가 섞여 배치 처리가 깨질 수 있습니다. 운영 환경 벤치마크에서 이 설정만으로 쓰기 성능이 3~5배 향상되는 사례를 확인할 수 있습니다.


파티셔닝으로 병렬 처리 적용하기

단일 스레드 배치는 처리량에 한계가 있습니다. 1,000만 건을 단일 스레드로 처리하면 40분 이상 걸리는 작업도 8개 파티션으로 나누면 7분 이내로 단축할 수 있습니다. Spring Batch 5의 파티셔닝(Partitioning) 은 데이터를 N개의 범위로 분할해 각 범위를 독립적인 워커 Step으로 병렬 실행하는 전략입니다.
파티셔닝은 세 가지 구성 요소로 이루어집니다. Partitioner가 데이터를 범위별로 분할하고, PartitionHandler가 워커 Step의 실행을 관리하며, 워커 Step이 할당된 범위의 데이터만 처리합니다.

@Bean
public Step partitionedMasterStep(
        JobRepository jobRepository,
        PartitionHandler partitionHandler,
        Partitioner orderPartitioner) {

    return new StepBuilder("masterStep", jobRepository)
            .partitioner("workerStep", orderPartitioner)
            .partitionHandler(partitionHandler)
            .build();
}

@Bean
public TaskExecutorPartitionHandler partitionHandler(Step workerStep) {
    TaskExecutorPartitionHandler handler = new TaskExecutorPartitionHandler();
    handler.setTaskExecutor(new SimpleAsyncTaskExecutor());
    handler.setStep(workerStep);
    handler.setGridSize(8);               // 8개 파티션 병렬 처리
    return handler;
}

@Bean
public Partitioner orderPartitioner(JdbcTemplate jdbcTemplate) {
    return gridSize -> {
        Long minId = jdbcTemplate.queryForObject(
                "SELECT MIN(order_id) FROM orders WHERE status = 'PENDING'", Long.class);
        Long maxId = jdbcTemplate.queryForObject(
                "SELECT MAX(order_id) FROM orders WHERE status = 'PENDING'", Long.class);

        long range = (maxId - minId) / gridSize;
        Map<String, ExecutionContext> partitions = new HashMap<>();

        for (int i = 0; i < gridSize; i++) {
            ExecutionContext context = new ExecutionContext();
            context.putLong("minId", minId + (range * i));
            context.putLong("maxId", i == gridSize - 1 ? maxId : minId + (range * (i + 1)) - 1);
            partitions.put("partition" + i, context);
        }
        return partitions;
    };
}

파티셔닝에서 반드시 주의해야 할 점이 있습니다. 각 워커 Step의 ItemReader@StepScope로 선언해야 합니다. Step-scoped가 아니면 모든 파티션이 동일한 Reader 인스턴스를 공유하게 되어, ExecutionContextminId/maxId 값이 올바르게 주입되지 않습니다.

@Bean
@StepScope // 필수: 파티션별 독립 인스턴스 생성
public JdbcPagingItemReader<Order> workerReader(
        DataSource dataSource,
        @Value("#{stepExecutionContext['minId']}") Long minId,
        @Value("#{stepExecutionContext['maxId']}") Long maxId) throws Exception {

    MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider();
    queryProvider.setSelectClause("SELECT order_id, customer_id, amount, status");
    queryProvider.setFromClause("FROM orders");
    queryProvider.setWhereClause("WHERE order_id BETWEEN :minId AND :maxId AND status = 'PENDING'");

    Map<String, Order> sortKeys = new LinkedHashMap<>();
    sortKeys.put("order_id", Order.ASCENDING);
    queryProvider.setSortKeys(sortKeys);

    Map<String, Object> params = new HashMap<>();
    params.put("minId", minId);
    params.put("maxId", maxId);

    return new JdbcPagingItemReaderBuilder<Order>()
            .name("workerReader")
            .dataSource(dataSource)
            .pageSize(1000)
            .queryProvider(queryProvider)
            .parameterValues(params)
            .rowMapper(new BeanPropertyRowMapper<>(Order.class))
            .build();
}

gridSize는 CPU 코어 수와 DB 커넥션 풀 크기를 고려해 결정합니다. 8코어 서버에서 커넥션 풀이 20개라면 gridSize는 8~10이 적절합니다. gridSize가 커넥션 풀보다 크면 워커 스레드가 커넥션을 대기하면서 오히려 성능이 저하될 수 있습니다.

SimpleAsyncTaskExecutor는 요청마다 새 스레드를 생성하므로 운영 환경에서는 ThreadPoolTaskExecutor로 교체하는 것이 좋습니다. 스레드 풀을 사용하면 스레드 생성·소멸 오버헤드를 줄이고, 동시 실행 스레드 수를 제한해 시스템 안정성을 확보할 수 있습니다.


재시작(Restart) 메커니즘

배치 Job이 중간에 실패했을 때 어디서부터 다시 실행할 것인가는 운영 환경에서 가장 중요한 문제 중 하나입니다. Spring Batch는 작업 실행 상태를 데이터베이스의 메타데이터 테이블에 자동으로 기록합니다.

  • BATCH_JOB_INSTANCE: Job의 논리적 실행 단위 (Job 이름 + Job Parameters)
  • BATCH_JOB_EXECUTION: Job의 물리적 실행 기록 (시작 시간, 종료 시간, 상태)
  • BATCH_STEP_EXECUTION: Step별 실행 상태와 커밋 카운트, read/write/skip 카운트

이 메타데이터 덕분에 작업이 실패하더라도 어디까지 수행되었는지 정확하게 파악할 수 있습니다. 예를 들어 1,000,000건을 chunk-size 1,000으로 처리하다가 500,000번째에서 장애가 발생하면, Spring Batch는 마지막으로 커밋된 chunk 위치, Step 실행 상태, ExecutionContext 데이터를 저장합니다. 동일한 Job Parameter로 다시 실행하면 실패한 Step부터 자동으로 재시작됩니다.

재시작이 올바르게 동작하려면 Reader가 어디까지 읽었는지 상태를 저장해야 합니다. ItemStream 인터페이스를 구현하면 ExecutionContext를 통해 커스텀 상태를 저장하고 복원할 수 있습니다.

public class CustomItemReader implements ItemStreamReader<Order> {

    private int currentIndex;

    @Override
    public void open(ExecutionContext executionContext) {
        // 재시작 시 이전 위치에서 이어서 읽기
        if (executionContext.containsKey("currentIndex")) {
            currentIndex = executionContext.getInt("currentIndex");
        }
    }

    @Override
    public Order read() {
        // 데이터 읽기 로직
        currentIndex++;
        return fetchOrder(currentIndex);
    }

    @Override
    public void update(ExecutionContext executionContext) {
        // 매 chunk 커밋 시 현재 위치 저장
        executionContext.putInt("currentIndex", currentIndex);
    }
}

Spring Batch가 제공하는 JdbcPagingItemReader, FlatFileItemReader 등은 이미 ItemStream을 구현하고 있어 별도 처리 없이도 재시작을 지원합니다. 다만 커스텀 Reader를 만들 때는 반드시 open()update() 메서드를 구현해야 재시작이 정상 동작합니다.

allowStartIfComplete(true)를 Step에 설정하면 이미 완료된 Step도 재실행할 수 있습니다. 데이터 보정이 필요한 경우에 유용하지만, 기본값은 false이므로 의도적으로만 활성화해야 합니다.


멱등성(Idempotency) 설계

멱등성은 같은 작업을 여러 번 수행해도 결과가 동일하게 유지되는 성질입니다. 재시작 기능이 있다고 해서 멱등성이 필요 없는 것은 아닙니다. 운영 환경에서는 다음과 같은 상황이 빈번하게 발생합니다.

  • chunk 커밋 직전에 장애 발생 → DB에는 기록되지 않았지만 외부 시스템에는 이미 호출됨
  • 외부 API 호출 이후 장애 발생 → 결제는 완료되었지만 배치에서는 실패로 기록
  • Reader가 동일 데이터를 다시 읽는 상황 → 재시작 시 경계에 있던 데이터가 중복 처리

이 경우 이미 처리된 데이터가 다시 처리될 가능성이 있으므로, 재시작은 시스템 기능이고 멱등성은 데이터 설계 전략으로서 반드시 함께 적용해야 합니다.
멱등하지 않은 Writer의 대표적인 예시가 단순 INSERT입니다.

-- 멱등하지 않은 설계: 재실행 시 중복 정산 발생
INSERT INTO settlement (order_id, amount)
VALUES (?, ?)

이를 멱등하게 만드는 방법은 Upsert 패턴을 적용하는 것입니다.

@Bean
public JdbcBatchItemWriter<OrderResult> idempotentWriter(DataSource dataSource) {
    return new JdbcBatchItemWriterBuilder<OrderResult>()
            .dataSource(dataSource)
            .sql("INSERT INTO order_results (order_id, result_status, processed_at) " +
                 "VALUES (:orderId, :resultStatus, :processedAt) " +
                 "ON DUPLICATE KEY UPDATE result_status = :resultStatus, processed_at = :processedAt")
            .beanMapped()
            .build();
}

order_id에 UNIQUE KEY가 설정되어 있으므로, 같은 주문이 다시 처리되어도 INSERT 대신 UPDATE가 실행되어 결과가 동일하게 유지됩니다.


운영 환경에서 자주 사용하는 멱등성 패턴

1. 상태 기반 처리
데이터에 처리 상태 컬럼을 두고, 배치는 특정 상태의 데이터만 조회합니다.

READY → PROCESSING → DONE

Reader는 WHERE status = 'READY'로 조회하고, Processor에서 PROCESSING으로 변경, Writer에서 처리 완료 후 DONE으로 변경합니다. 재시작 시 이미 DONE 상태인 데이터는 조회 대상에서 자동으로 제외됩니다.

2. 처리 이력 테이블
별도의 이력 테이블을 만들어 처리된 항목을 기록합니다.

CREATE TABLE processed_event (
    event_id BIGINT PRIMARY KEY,     -- UNIQUE 제약으로 중복 방지
    processed_at TIMESTAMP NOT NULL
);

Processor에서 processed_event 테이블을 조회해 이미 처리된 이벤트는 null을 반환(필터링)합니다. 이 패턴은 원본 테이블을 수정할 수 없는 경우에 유용합니다.

3. 외부 API 호출 시 멱등 키(Idempotency Key)

결제, 알림 등 외부 시스템을 호출하는 배치에서는 멱등 키를 활용합니다. 요청 헤더에 고유한 키(주로 order_id + batch_date 조합)를 포함시키면, 외부 시스템이 동일 키의 중복 요청을 자동으로 무시합니다.

public class PaymentProcessor implements ItemProcessor<Order, PaymentResult> {

    private final PaymentClient paymentClient;

    @Override
    public PaymentResult process(Order order) {
        // 멱등 키: 주문ID + 배치실행일로 고유성 보장
        String idempotencyKey = order.getOrderId() + "_" + LocalDate.now();
        return paymentClient.charge(order.getAmount(), idempotencyKey);
    }
}

이 패턴이 중요한 이유는, 배치에서 API 호출은 성공했지만 응답을 받기 전에 장애가 발생하면 Spring Batch는 해당 chunk를 실패로 기록하기 때문입니다. 재시작 시 같은 주문에 대해 결제 API를 다시 호출하게 되는데, 멱등 키가 없으면 이중 결제가 발생합니다.


맺음말

Spring Batch 5로 대용량 데이터를 처리할 때 핵심은 다섯 가지입니다. 첫째, 청크 사이즈와 페이지 사이즈를 일치시켜 불필요한 DB 조회를 제거합니다. 둘째, OFFSET 기반 페이징 대신 정렬 키를 활용한 No-Offset 전략으로 조회 성능을 유지합니다. 셋째, JdbcBatchItemWriter나 Hibernate 배치 옵션으로 벌크 쓰기 성능을 최적화합니다. 넷째, 파티셔닝으로 병렬 처리를 적용해 전체 실행 시간을 단축합니다. 다섯째, Upsert 패턴과 상태 기반 처리, 멱등 키를 적용해 재시작 시에도 데이터 정합성을 보장합니다.

재시작은 Spring Batch가 제공하는 시스템 기능이고, 멱등성은 개발자가 설계해야 하는 데이터 전략입니다. 두 가지를 함께 적용해야 운영 환경에서 장애가 발생해도 안전하게 복구할 수 있습니다. faultTolerant()skip/retry 설정도 운영 안정성을 높여주지만, 무분별한 skip 허용은 데이터 누락으로 이어질 수 있으므로 skip된 항목을 별도 로그 테이블에 기록하는 SkipListener 구현을 함께 적용하시기 바랍니다.

더 심화된 내용은 Spring Batch 공식 문서Spring Batch 5 마이그레이션 가이드를 참고하시기 바랍니다.

반응형