3 min read

Part 5. Spring Batch 확장: Partition과 Multi-threaded Step의 트레이드오프

Spring Batch 병렬 처리 전략을 처리량, 순서 보장, 운영 복잡도 관점에서 비교하고 선택 기준을 제시한다.

Series: Spring Boot 배치 전략 완전 정복

12편 구성. 현재 5편을 보고 있습니다.

썸네일 - 분산된 서버 랙
썸네일 - 분산된 서버 랙

출처: Pexels - Server racks on data center

버전 기준

  • Java 21
  • Spring Boot 3.3.x
  • Spring Batch 5.2.x
  • Quartz 2.3.x
  • PostgreSQL 15
  • OpenSearch 2.x

1) 문제 제기

처리량 병목이 생기면 팀은 거의 본능적으로 스레드를 늘린다. 하지만 배치에서 병렬화는 단순 가속 버튼이 아니다. 병렬도를 올리면 다음 문제가 동시에 커진다.

  • 순서 보장 붕괴
  • 락 경쟁 증가
  • 실패 시 재처리 범위 확대
  • 메모리 사용량 급증

그래서 병렬화는 "최대 TPS"가 아니라 "실패했을 때 복구 가능한 구조"를 기준으로 선택해야 한다.

2) 핵심 개념 정리

병렬화 방식 비교

방식장점단점적합한 데이터
Multi-threaded Step구현 단순, 빠른 적용Reader/Writer thread-safe 요구순서 비중 낮은 독립 레코드
Partition Step구간 분리로 충돌 감소파티션 설계 복잡ID 범위 분할 가능한 대량 데이터
Remote Chunking수평 확장 유리메시지 인프라/운영 복잡초대용량, 장시간 처리

파티션 기준

  • ID Range: 가장 단순하고 재시작이 쉽다.
  • Hash Partition: 키 분산은 좋지만 디버깅 난이도 상승.
  • Time Window: 로그성 데이터에 유리.

병렬 처리 다이어그램

Mermaid diagram rendering...

본문 이미지 - 컨테이너 분할 운용
본문 이미지 - 컨테이너 분할 운용

출처: Pexels - Drone shot of cargo containers

3) 코드 예시

예시 A: ID Range Partitioner

public class IdRangePartitioner implements Partitioner {

    private final JdbcTemplate jdbcTemplate;

    public IdRangePartitioner(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public Map<String, ExecutionContext> partition(int gridSize) {
        Long minId = jdbcTemplate.queryForObject("SELECT COALESCE(MIN(id),0) FROM orders WHERE status='PENDING'", Long.class);
        Long maxId = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id),0) FROM orders WHERE status='PENDING'", Long.class);

        long targetSize = ((maxId - minId) / gridSize) + 1;
        Map<String, ExecutionContext> result = new HashMap<>();

        long start = minId;
        long end = start + targetSize - 1;
        int partition = 0;
        while (start <= maxId) {
            ExecutionContext context = new ExecutionContext();
            context.putLong("startId", start);
            context.putLong("endId", Math.min(end, maxId));
            result.put("partition" + partition, context);
            start += targetSize;
            end += targetSize;
            partition++;
        }
        return result;
    }
}

예시 B: Partition Step 설정

@Bean
public Step masterStep(JobRepository jobRepository,
                       Step workerStep,
                       Partitioner partitioner,
                       TaskExecutor taskExecutor) {
    return new StepBuilder("masterStep", jobRepository)
        .partitioner("workerStep", partitioner)
        .step(workerStep)
        .gridSize(8)
        .taskExecutor(taskExecutor)
        .build();
}

예시 C: 파티션 대상 조회 SQL

SELECT id, customer_id, total_amount
FROM orders
WHERE status = 'PENDING'
  AND id BETWEEN :start_id AND :end_id
ORDER BY id ASC
LIMIT :chunk_size;

예시 D: 인덱스와 락 경합 완화

CREATE INDEX idx_orders_status_id ON orders (status, id);

-- 워커별로 잠긴 레코드를 건너뛰어 경합 최소화
SELECT id
FROM orders
WHERE status = 'PENDING'
  AND id BETWEEN :start_id AND :end_id
FOR UPDATE SKIP LOCKED
LIMIT :chunk_size;

4) 실제 장애/운영 시나리오

상황: 16개 파티션으로 주문 집계를 병렬화했지만, 결과 테이블에 동일 (order_id, stat_date)가 중복 저장됐다.

원인:

  • Reader는 파티션 범위를 나눴지만 Writer는 공통 집계 키를 사용했다.
  • 결과 테이블에 유니크 인덱스가 없어 경쟁 조건이 그대로 반영됐다.
  • REPEATABLE READ를 사용하면서 장시간 트랜잭션이 유지되어 락 대기가 급증했다.

조치:

  1. 결과 테이블에 UNIQUE(order_id, stat_date) 추가.
  2. Writer를 INSERT ... ON CONFLICT DO UPDATE로 변경.
  3. 파티션 크기를 균등 분할이 아닌 히스토리 기반 가중 분할로 조정.

5) 설계 체크리스트

  • 병렬화 목적이 처리량인지 지연시간인지 명확한가?
  • 파티션 키가 충돌 없는 쓰기 경계를 보장하는가?
  • Reader/Writer가 thread-safe한가?
  • 파티션 실패 시 재시작 범위를 좁게 복구할 수 있는가?
  • JVM 힙 사용량(청크 x 스레드 x 객체 크기)을 계산했는가?
  • 유니크 인덱스와 멱등 Writer로 중복 쓰기를 차단했는가?

6) 요약

병렬 처리는 속도를 주지만, 설계가 약하면 장애를 증폭한다. 파티션 경계, 쓰기 멱등성, 재시작 범위를 먼저 설계한 뒤 스레드를 늘려야 운영 가능한 성능이 나온다.

7) 다음 편 예고

다음 편에서는 수동 배치 전략을 다룬다. REST 트리거, Admin UI 실행, 파라미터 재처리, 롤백/감사 추적까지 포함해 "운영자가 안전하게 개입하는 시스템"을 설계한다.

참고 링크

시리즈 네비게이션

댓글