대용량 배치 성능을 끌어올리기 위해 가장 많이 비교되는 두 아키텍처 패턴은 Multi-thread Step과 Partitioning입니다. 두 방식은 병렬로 작업을 처리한다는 점은 같지만, 데이터를 분할하는 시점과 스레드 간 자원 공유 방식에서 완전히 다른 메커니즘을 가집니다.
Multi-thread Step은 하나의 Step 내에서 단일 ItemReader를 여러 스레드가 공유하며, 청크(Chunk) 단위로 작업을 번갈아 가며 병렬 처리하는 방식입니다.
Reader.read()를 호출하므로, Reader 내부의 상태(현재 읽은 로우 수 등)를 안전하게 보호해야 합니다. 상태를 공유하는 JdbcCursorItemReader 등은 사용할 수 없으며, 내부적으로 페이징 쿼리를 날려 스레드 안전성이 확보된 JdbcPagingItemReader나 JpaPagingItemReader를 사용해야 합니다.@Bean
public Step multiThreadStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
return new StepBuilder("multiThreadStep", jobRepository)
.<User, User>chunk(1000, transactionManager)
.reader(threadSafeUserReader())
.processor(userProcessor())
.writer(userWriter())
.taskExecutor(batchTaskExecutor()) // 멀티스레드 환경을 위한 TaskExecutor 주입
.build();
}
@Bean
public TaskExecutor batchTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("batch-thread-");
executor.initialize();
return executor;
}Partitioning은 데이터를 논리적 범위(예: ID 1~10000, 10001~20000)로 먼저 쪼개어 파티션을 생성한 뒤, 각 파티션마다 독립된 Worker Step(스레드 또는 개별 서버)을 할당하여 처리하는 방식입니다.
StepExecutionContext)를 바탕으로 고유한 Reader 인스턴스를 생성해 독립적으로 실행됩니다.ItemReader가 Thread-safe할 필요가 없습니다. 심지어 성능이 가장 빠른 Cursor 기반 리더도 병렬로 안전하게 사용할 수 있습니다.// 1. Master Step 설정 (파티션을 나누고 Worker들에게 분배)
@Bean
public Step masterStep(JobRepository jobRepository, Step workerStep, Partitioner idPartitioner) {
return new StepBuilder("masterStep", jobRepository)
.partitioner("workerStep", idPartitioner) // 데이터 분할기 등록
.step(workerStep)
.gridSize(4) // 생성할 파티션(Worker) 개수
.taskExecutor(batchTaskExecutor())
.build();
}
// 2. Worker Step 설정 (실제 비즈니스 로직을 수행하는 독립된 Step)
@Bean
public Step workerStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
return new StepBuilder("workerStep", jobRepository)
.<User, User>chunk(1000, transactionManager)
.reader(workerUserReader(null)) // @Value("#{stepExecutionContext[minValue]}") 로 격리된 범위 주입
.processor(userProcessor())
.writer(userWriter())
.build();
}두 병렬화 전략을 선택할 때 가장 주의해야 하는 실무적 관점은 "데이터베이스 Connection Pool(HikariCP)과 락(Lock) 경쟁"입니다.
Multi-thread Step은 하나의 DB 세션 풀 내에서 여러 스레드가 빠르게 커밋을 치고 빠지기 때문에 커넥션 효율이 좋습니다. 다만, 가독성이 낮은 무작위 청크 스캔으로 인해 인덱스 정렬이 고르지 못하면 비즈니스 테이블에 데드락(Deadlock)이 발생할 위험이 Partitioning에 비해 상대적으로 높습니다.
Partitioning은 각 Worker가 독립된 커넥션을 잡고 할당된 구역만 뚝심 있게 밀고 나가기 때문에 데드락 위험이 현저히 줄어듭니다. 대신 Grid Size를 너무 크게 잡으면 순간적으로 배치가 HikariCP의 커넥션을 고갈시켜 웹 애플리케이션 등 다른 서비스 시스템을 마비시키는 인프라 병목을 유발할 수 있습니다.
따라서 단일 인스턴스 내에서 자원을 빠르게 쥐어짜야 하고 구현 일정이 촉박하다면 Multi-thread Step을 도입하되 청크 사이즈와 인덱스를 정밀 튜닝해야 하며, 처리해야 할 데이터가 수천만 건 이상이거나 향후 인프라 스케일 아웃(Scale-out)을 염두에 둔 크리티컬한 핵심 배치라면 처음부터 데이터 분할 정책을 수립하여 Partitioning 구조로 아키텍처를 설계하는 것이 고가용성 배치 인프라를 구축하는 정석입니다.
로컬 파티셔닝은 단일 JVM 내부의 메모리와 스레드풀만 공유하지만, 원격 파티셔닝은 스레드풀의 자리에 MQ(RabbitMQ, ActiveMQ, Kafka 등)를 배치합니다. 마스터 노드와 워커 노드는 오직 MQ의 메시지와 공유 메타데이터 DB를 통해서만 소통합니다.
핵심 구성 요소StepExecutionRequest)가 담긴 메시지를 중계합니다.BATCH_*) 및 비즈니스 데이터베이스입니다.BATCH_STEP_EXECUTION 테이블에 자신의 작업 상태를 완료(COMPLETED)로 직접 업데이트하거나, 별도의 reply-queue를 통해 마스터에게 완료 통보를 보냅니다.현대적인 스프링 배치에서는 spring-batch-integration 모듈의 RemotePartitioningManagerStepBuilder와 RemotePartitioningWorkerStepBuilder를 사용하여 이를 깔끔하게 구현합니다.
@Configuration
public class MasterBatchConfig {
@Bean
public Step masterStep(JobRepository jobRepository,
Partitioner idPartitioner,
DirectChannel outboundChannel) { // MQ로 나가는 통로
return new RemotePartitioningManagerStepBuilder("masterStep", jobRepository)
.partitioner("workerStep", idPartitioner)
.gridSize(4) // 4개의 물리적 파티션 메시지 생성
.outputChannel(outboundChannel) // 스프링 인티그레이션을 통해 MQ로 메시지 전송
.build();
}
}@Configuration
public class WorkerBatchConfig {
@Bean
public Step workerStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager,
DirectChannel inboundChannel) { // MQ에서 들어오는 통로
return new RemotePartitioningWorkerStepBuilder("workerStep", jobRepository)
.inputChannel(inboundChannel) // MQ로부터 메시지를 수신하여 이 스텝을 트리거
.<User, User>chunk(1000, transactionManager)
.reader(workerUserReader(null)) // 매개변수로 파티션 범위 바인딩
.processor(userProcessor())
.writer(userWriter())
.build();
}
}※ 각 서버의
inboundChannel과outboundChannel레이어 하단에 RabbitMQ의AmqpInboundChannelAdapter나 Kafka 어댑터 설정을 바인딩하여 네트워크 통신을 연결하게 됩니다.
이 방식은 수억 건의 데이터를 다루는 글로벌 빅테크 기업에서 애용하는 최강의 확장성을 자랑하지만, 그만큼 혹독한 인프라 트레이드오프가 존재합니다.
① 공유 메타 DB 병목 (가장 중요)
아무리 MQ를 통해 워커 서버 노드를 10대, 20대로 확장하더라도, 결국 모든 워커 노드가 스프링 배치의 상태를 기록하기 위해 단 하나의 중앙 메타데이터 데이터베이스(BATCH_*)로 동시에 접근합니다. 워커 노드가 늘어날수록 메타 DB의 커넥션 풀이 고갈되고 쓰기 락(Lock) 경쟁이 심화되어, 오히려 시스템 전체가 느려지는 역효과가 날 수 있습니다. 이 때문에 메타 DB만큼은 반드시 고성능 독립 인스턴스로 격리해야 합니다.
② 분산 환경의 네트워크 단선 및 멱등성 문제 네트워크 장애로 인해 특정 워커 노드가 메시지를 받아 처리를 완료했음에도 마스터에게 완료 신호(ACK)를 보내지 못하고 무너질 수 있습니다. MQ의 재전송 매커니즘에 의해 다른 워커 노드가 동일한 파티션 메시지를 중복 수신하여 다시 실행할 수 있으므로, 비즈니스 로직(Writer)은 반드시 중복 처리되어도 문제가 없는 멱등성(Idempotency)이 완벽히 보장되도록 튜닝되어야 합니다.
③ 오케스트레이션 및 배포 관리의 복잡도 마스터 코드와 워커 코드를 하나의 애플리케이션 안에서 프로파일(Profile)로 분리할지, 아니면 아예 별도의 프로젝트 파일로 쪼갤지 결정해야 합니다. 배포 시점에도 MQ의 큐 생성 상태, 컨테이너 스케일링 정책(K8s 오토스케일링 등)이 유기적으로 맞물려야 하므로 인프라 관리 공수가 급격히 상승합니다.
댓글
GitHub 계정으로 의견이나 질문을 남길 수 있습니다.