Spring Batch 가이드 시리즈

01 배치 기초

- 배치 처리란 무엇인가

- Job과 Step 구조

02 Chunk 처리

- Chunk 기반 처리 ← 현재 편

- 다양한 Reader와 Writer

03 실행 제어

- 실행 제어와 흐름

- 재시도와 건너뛰기

04 운영과 면접

- 스케줄링과 운영

- 면접 대비

이전 편에서 Job과 Step의 구조를 살펴봤다. Step의 두 가지 모델 중 Chunk 모델이 Spring Batch의 핵심이다. 대량 데이터를 안전하고 효율적으로 처리하는 구조를 이해하면 Spring Batch를 제대로 활용할 수 있다.

Chunk란

Chunk는 한 번에 처리할 데이터의 묶음이다. 100만 건을 처리할 때 전부 메모리에 올리지 않고, 1,000건씩 나눠서 읽기 → 가공 → 쓰기를 반복한다. 이 1,000건이 하나의 chunk다.

공장의 컨베이어 벨트를 생각하면 된다. 원재료 창고에서 부품을 꺼내고(Read), 조립하고(Process), 완성품 창고에 넣는다(Write). 이 과정을 하나씩이 아니라, 상자 단위로 묶어서 처리한다. 상자가 chunk다.

flowchart LR subgraph Chunk["Chunk (1,000건)"] direction LR R["ItemReader
읽기"] --> P["ItemProcessor
가공"] P --> W["ItemWriter
쓰기"] end DB1[("원본 DB")] -->|"1,000건씩
페이징 조회"| R W -->|"1,000건
일괄 저장"| DB2[("대상 DB")] style Chunk fill:#E8F4F8,stroke:#2196F3 style R fill:#E8F8E8,stroke:#4CAF50 style P fill:#FFF3E0,stroke:#FF9800 style W fill:#E8F8E8,stroke:#4CAF50

chunk 크기가 1,000이고 전체 데이터가 10만 건이면, 이 사이클이 100번 반복된다. 각 사이클마다 트랜잭션이 커밋되므로, 50번째 chunk에서 실패해도 1~49번째 chunk의 결과는 이미 저장되어 있다.

Reader → Processor → Writer

Chunk 모델의 세 가지 구성 요소를 하나씩 살펴보자.

ItemReader

데이터 소스에서 데이터를 하나씩 읽어오는 역할이다. DB, 파일, 메시지 큐 등 다양한 소스에서 읽을 수 있다.

핵심은 read() 메서드다. 호출할 때마다 데이터를 하나씩 반환하고, 더 이상 데이터가 없으면 null을 반환한다.

public interface ItemReader<T> {
    T read() throws Exception;
}

"하나씩"이라고 했지만, 내부적으로는 페이징 쿼리로 chunk 크기만큼 미리 가져와서 메모리에 올려둔다. 호출자 입장에서는 하나씩 받는 것처럼 보이지만, 실제로는 1,000건을 한 번에 조회해서 하나씩 꺼내주는 구조다.

sequenceDiagram autonumber participant Step as Step participant Reader as ItemReader participant DB as DB rect rgb(232, 248, 232) Note over Step,DB: 첫 번째 페이지 조회 Step->>Reader: read() Reader->>DB: SELECT ... LIMIT 1000 OFFSET 0 DB-->>Reader: 1,000건 반환 Reader-->>Step: item #1 end rect rgb(240, 248, 255) Note over Step,Reader: 메모리에서 순차 반환 Step->>Reader: read() Reader-->>Step: item #2 Step->>Reader: read() Reader-->>Step: item #3 Note right of Reader: ... (item #1000까지) end rect rgb(232, 248, 232) Note over Step,DB: 두 번째 페이지 조회 Step->>Reader: read() Reader->>DB: SELECT ... LIMIT 1000 OFFSET 1000 DB-->>Reader: 1,000건 반환 Reader-->>Step: item #1001 end

초록 영역에서 DB 쿼리가 나가고, 파란 영역에서는 이미 조회한 데이터를 메모리에서 꺼내준다. DB 쿼리 횟수를 최소화하면서도 메모리 사용량을 chunk 크기로 제한하는 것이다.

ItemProcessor

읽은 데이터를 가공하거나 필터링하는 역할이다. 선택적 구성 요소로, 가공이 필요 없으면 생략할 수 있다.

public interface ItemProcessor<I, O> {
    O process(I item) throws Exception;
}

입력 타입 I를 받아서 출력 타입 O로 변환한다. null을 반환하면 해당 아이템은 필터링되어 Writer에 전달되지 않는다.

public class SettlementProcessor implements ItemProcessor<Order, Settlement> {

    @Override
    public Settlement process(Order order) {
        if (order.isCancelled()) {
            return null; // 취소된 주문은 정산에서 제외
        }
        return Settlement.from(order);
    }
}

취소된 주문은 null을 반환해서 필터링하고, 유효한 주문만 Settlement 객체로 변환해서 Writer에 넘긴다.

Processor는 언제 생략할까

Reader에서 읽은 데이터를 그대로 저장하면 되는 경우에 생략한다. 데이터 마이그레이션에서 포맷 변환 없이 그대로 복사하는 경우가 대표적이다.

ItemWriter

가공된 데이터를 chunk 단위로 일괄 저장하는 역할이다. Reader와 Processor가 아이템을 하나씩 처리하는 것과 달리, Writer는 chunk에 속한 모든 아이템을 한 번에 받는다.

public interface ItemWriter<T> {
    void write(Chunk<? extends T> items) throws Exception;
}

파라미터가 단일 아이템이 아니라 Chunk<T>다. 1,000건의 데이터가 리스트로 한 번에 전달된다.

public class SettlementWriter implements ItemWriter<Settlement> {

    private final SettlementRepository repository;

    @Override
    public void write(Chunk<? extends Settlement> items) {
        repository.saveAll(items.getItems());
    }
}

saveAll()로 1,000건을 한 번에 저장한다. 건 by 건으로 save()를 호출하는 것보다 훨씬 효율적이다. JDBC Batch Insert가 적용되면 네트워크 왕복도 줄어든다.

Chunk 처리의 내부 동작

세 구성 요소가 어떻게 협력하는지, 트랜잭션과 함께 전체 흐름을 보자.

sequenceDiagram autonumber participant TX as 트랜잭션 participant R as ItemReader participant P as ItemProcessor participant W as ItemWriter loop 데이터가 남아있는 동안 TX->>TX: BEGIN rect rgb(232, 248, 232) Note over R,P: 읽기 + 가공 (chunk 크기만큼) loop chunk 크기만큼 반복 R->>R: read() R->>P: item 전달 P->>P: process() Note right of P: null이면 필터링 end end rect rgb(240, 248, 255) Note over W: 일괄 쓰기 W->>W: write(processedItems) end TX->>TX: COMMIT end
  1. 트랜잭션이 시작된다
  2. Reader가 chunk 크기만큼 데이터를 읽는다
  3. Processor가 각 아이템을 가공한다 (null이면 필터링)
  4. Writer가 가공된 아이템을 일괄 저장한다
  5. 트랜잭션이 커밋된다
  6. 데이터가 남아있으면 1번부터 반복한다

chunk 단위로 트랜잭션이 관리된다는 점이 핵심이다. chunk 하나가 하나의 트랜잭션이다.

Chunk 크기 결정

chunk 크기는 성능에 직접적인 영향을 미친다. 너무 작으면 커밋 횟수가 많아지고, 너무 크면 메모리 부담이 커진다.

flowchart TD subgraph Small["chunk = 10"] direction LR A1["10건 읽기"] --> B1["10건 쓰기"] --> C1["커밋"] Note1["10만건 → 10,000번 커밋
오버헤드 큼"] end subgraph Medium["chunk = 1,000"] direction LR A2["1,000건 읽기"] --> B2["1,000건 쓰기"] --> C2["커밋"] Note2["10만건 → 100번 커밋
적절한 균형"] end subgraph Large["chunk = 50,000"] direction LR A3["50,000건 읽기"] --> B3["50,000건 쓰기"] --> C3["커밋"] Note3["10만건 → 2번 커밋
메모리 부담, 롤백 비용 큼"] end style Small fill:#FFF3E0,stroke:#FF9800 style Medium fill:#E8F8E8,stroke:#4CAF50 style Large fill:#FFE6E6,stroke:#F44336

크기별 트레이드오프

  • 너무 작으면 (10~100) : 커밋 빈도가 높아져 DB 부하가 증가한다. 트랜잭션 시작/종료의 오버헤드가 무시할 수 없다.
  • 너무 크면 (10,000~) : 메모리에 올려야 할 데이터가 많아진다. 실패 시 롤백 범위가 커서 재처리 비용이 크다.
  • 적절한 범위 (100~5,000) : 대부분의 상황에서 적절하다. 1,000이 가장 흔하게 사용되는 기본값이다.
chunk 크기 결정 가이드라인

- 기본값으로 1,000부터 시작한다

- 아이템 하나의 크기가 크면 (이미지, 대용량 텍스트) chunk 크기를 줄인다

- Writer가 JDBC Batch Insert를 사용하면 chunk 크기를 batch size와 맞춘다

- 실제 환경에서 측정해보고 조정한다. 성능은 데이터 특성에 따라 달라진다

Step 정의 코드

Chunk 기반 Step을 정의하는 전체 코드를 보자.

@Configuration
@RequiredArgsConstructor
public class SettlementStepConfig {

    private final EntityManagerFactory entityManagerFactory;

    @Bean
    public Step settlementStep(JobRepository jobRepository,
                                PlatformTransactionManager transactionManager) {
        return new StepBuilder("settlementStep", jobRepository)
                .<Order, Settlement>chunk(1000, transactionManager)
                .reader(orderReader(null))  // @StepScope에 의해 런타임에 주입
                .processor(settlementProcessor())
                .writer(settlementWriter())
                .build();
    }

    @Bean
    @StepScope
    public JpaPagingItemReader<Order> orderReader(
            @Value("#{jobParameters['targetDate']}") String targetDate) {
        return new JpaPagingItemReaderBuilder<Order>()
                .name("orderReader")
                .entityManagerFactory(entityManagerFactory)
                .queryString("SELECT o FROM Order o WHERE o.orderDate = :date")
                .parameterValues(Map.of("date", targetDate))
                .pageSize(1000)
                .build();
    }

    @Bean
    public ItemProcessor<Order, Settlement> settlementProcessor() {
        return order -> {
            if (order.isCancelled()) return null;
            return Settlement.from(order);
        };
    }

    @Bean
    public ItemWriter<Settlement> settlementWriter() {
        return items -> {
            // JPA saveAll 또는 JDBC Batch Insert
        };
    }
}

<Order, Settlement>chunk(1000, transactionManager) — 이 제네릭 타입이 데이터 흐름을 명확히 보여준다. Reader가 Order를 읽고, Processor가 Settlement로 변환하고, Writer가 Settlement를 저장한다.

pageSize와 chunk 크기를 맞춰라

Reader의 pageSize와 Step의 chunk 크기를 같은 값으로 설정하는 것이 일반적이다. 다르면 동작은 하지만, chunk 처리 도중에 추가 DB 쿼리가 발생할 수 있어 비효율적이다. 1,000건씩 chunk 처리하면서 pageSize도 1,000으로 맞추면 chunk당 정확히 1번의 DB 쿼리가 나간다.

Chunk 실패 시 동작

chunk 처리 중 예외가 발생하면 해당 chunk 전체가 롤백된다.

sequenceDiagram autonumber participant TX as 트랜잭션 participant R as ItemReader participant P as ItemProcessor participant W as ItemWriter rect rgb(232, 248, 232) Note over TX,W: Chunk #1 — 성공 TX->>TX: BEGIN R->>P: 1,000건 P->>W: 가공 후 전달 W->>W: 저장 TX->>TX: COMMIT ✓ end rect rgb(255, 230, 230) Note over TX,W: Chunk #2 — 실패 TX->>TX: BEGIN R->>P: 1,000건 P--xP: 500번째 아이템에서 예외 발생 TX->>TX: ROLLBACK ✗ Note right of TX: Chunk #2 전체 롤백
Chunk #1은 안전 end

Chunk #1은 이미 커밋되었으므로 영향을 받지 않는다. Chunk #2만 롤백된다. 이것이 chunk 기반 트랜잭션의 핵심 장점이다.

실패 후 재시작하면 Spring Batch는 메타데이터를 확인해서 Chunk #2부터 다시 처리한다. 개별 아이템의 실패를 건너뛰고 싶다면 Skip 정책을 설정할 수 있는데, 이는 재시도와 건너뛰기 편에서 다룬다.

자주 하는 실수

Processor에서 외부 API를 호출하기

Processor는 chunk 크기만큼 반복 호출된다. chunk가 1,000이면 외부 API를 1,000번 호출한다. 대부분의 외부 API는 이런 빈도의 호출을 허용하지 않는다. 외부 API 호출은 별도의 Step이나 Tasklet으로 분리하고, Processor는 순수한 데이터 변환 로직만 담아라.

[!DANGER] Writer에서 건 by 건으로 저장하기

Writer에 items.forEach(repository::save)처럼 작성하면 chunk의 이점이 사라진다. 1,000건이면 INSERT 쿼리가 1,000번 나간다. 반드시 saveAll() 또는 JDBC Batch Insert를 사용해서 일괄 저장하라.

[!DANGER] Reader에서 데이터를 수정하기

Reader는 읽기 전용이어야 한다. Reader 안에서 엔티티를 수정하면 트랜잭션 경계가 예상과 달라져서 데이터 정합성 문제가 발생할 수 있다. 데이터 수정은 반드시 Processor 또는 Writer에서 하라.