파트 5. 대용량 데이터 처리 최적화 및 확장 (Scaling)
대용량 배치 성능을 끌어올리기 위해 가장 많이 비교되는 두 아키텍처 패턴은 Multi-thread Step과 Partitioning입니다. 두 방식은 병렬로 작업을 처리한다는 점은 같지만, 데이터를 분할하는 시점과 스레드 간 자원 공유 방식에서 완전히 다른 메커니즘을 가집니다.
1. Multi-thread Step: 단일 데이터 소스의 청크 병렬 처리
Multi-thread Step은 하나의 Step 내에서 단일 ItemReader를 여러 스레드가 공유하며, 청크(Chunk) 단위로 작업을 번갈아 가며 병렬 처리하는 방식입니다.
- 동작 원리: 스레드 A가 Reader를 호출해 1번째 청크(1~1000번)를 가져와 처리하는 동안, 스레드 B가 동시에 Reader를 호출해 2번째 청크(1001~2000번)를 가져와 처리합니다.
- 핵심 장점: 설정이 매우 간단하며, 단일 프로세스 내에서 CPU 자원을 효율적으로 극대화할 수 있습니다.
- 주의점 (Thread-safe): 여러 스레드가 동시에
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;
}2. Partitioning: 독립된 영역의 마스터-워커 분산 처리
Partitioning은 데이터를 논리적 범위(예: ID 1~10000, 10001~20000)로 먼저 쪼개어 파티션을 생성한 뒤, 각 파티션마다 독립된 Worker Step(스레드 또는 개별 서버)을 할당하여 처리하는 방식입니다.
- 동작 원리: Master Step이 실행되면 Partitioner가 데이터 범위를 계산하여 Grid Size만큼 파티션 메타데이터를 생성합니다. 이후 각 Worker Step은 자신에게 할당된 범위 정보(
StepExecutionContext)를 바탕으로 고유한 Reader 인스턴스를 생성해 독립적으로 실행됩니다. - 핵심 장점 (Thread-safe 해제): 각 Worker 스레드는 서로 다른 데이터를 격리된 상태로 읽기 때문에,
ItemReader가 Thread-safe할 필요가 없습니다. 심지어 성능이 가장 빠른 Cursor 기반 리더도 병렬로 안전하게 사용할 수 있습니다. - 원격 확장성: 로컬 스레드 분산에 갇히는 Multi-thread Step과 달리, Message Queue(RabbitMQ, Kafka)를 연동하면 Worker 단계를 별도의 물리 서버 노드들로 전송하여 실행하는 Remote Partitioning으로 무한히 확장할 수 있습니다.
자바 구현 코드 예시
// 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();
}3. 핵심 차이점 및 실무 선택 가이드 (Comparison Table)
| 비교 항목 | Multi-thread Step 방식 | Partitioning (마스터-워커) 방식 |
|---|---|---|
| 데이터 분할 방식 | 청크 단위 런타임 분할 (스레드 간 레이스 컨디션 유발) | 사전 논리적 범위 분할 (ID, 날짜, 부서 등) |
| Reader 제약 조건 | 반드시 Thread-safe한 Paging 리더 계열만 사용 가능 | 제약 없음 (Cursor 기반 고속 리더 사용 가능) |
| DB 커넥션 효율 | 하나의 Step 내에서 스레드 풀만큼 풀(Pool) 소모 | 각 Worker Step 인스턴스마다 전용 커넥션 점유 |
| 확장 한계 | 단일 JVM 프로세스 내부 (상한선 명확) | 원격 분산(Remote Partitioning)을 통해 다중 서버 확장 가능 |
| 구현 난이도 | 매우 낮음 (TaskExecutor 주입만으로 끝) | 보통~높음 (Partitioner 및 ExecutionContext 바인딩 필요) |
4. 실무 아키텍처 관점의 병목 포인트와 결론
두 병렬화 전략을 선택할 때 가장 주의해야 하는 실무적 관점은 "데이터베이스 Connection Pool(HikariCP)과 락(Lock) 경쟁"입니다.
Multi-thread Step은 하나의 DB 세션 풀 내에서 여러 스레드가 빠르게 커밋을 치고 빠지기 때문에 커넥션 효율이 좋습니다. 다만, 가독성이 낮은 무작위 청크 스캔으로 인해 인덱스 정렬이 고르지 못하면 비즈니스 테이블에 데드락(Deadlock)이 발생할 위험이 Partitioning에 비해 상대적으로 높습니다.
Partitioning은 각 Worker가 독립된 커넥션을 잡고 할당된 구역만 뚝심 있게 밀고 나가기 때문에 데드락 위험이 현저히 줄어듭니다. 대신 Grid Size를 너무 크게 잡으면 순간적으로 배치가 HikariCP의 커넥션을 고갈시켜 웹 애플리케이션 등 다른 서비스 시스템을 마비시키는 인프라 병목을 유발할 수 있습니다.
따라서 단일 인스턴스 내에서 자원을 빠르게 쥐어짜야 하고 구현 일정이 촉박하다면 Multi-thread Step을 도입하되 청크 사이즈와 인덱스를 정밀 튜닝해야 하며, 처리해야 할 데이터가 수천만 건 이상이거나 향후 인프라 스케일 아웃(Scale-out)을 염두에 둔 크리티컬한 핵심 배치라면 처음부터 데이터 분할 정책을 수립하여 Partitioning 구조로 아키텍처를 설계하는 것이 고가용성 배치 인프라를 구축하는 정석입니다.
참고: MQ 기반 원격 파티셔닝 활용한다면?
1. MQ 기반 원격 파티셔닝 아키텍처 개요
로컬 파티셔닝은 단일 JVM 내부의 메모리와 스레드풀만 공유하지만, 원격 파티셔닝은 스레드풀의 자리에 MQ(RabbitMQ, ActiveMQ, Kafka 등)를 배치합니다. 마스터 노드와 워커 노드는 오직 MQ의 메시지와 공유 메타데이터 DB를 통해서만 소통합니다.
핵심 구성 요소
- Manager Node (마스터) 전체 데이터의 파티션 범위(예: ID 1~10만, 10만~20만...)를 계산하고, 이 분할 정보를 MQ의 Request Queue로 발행(Publish)합니다. 실제 비즈니스 로직은 수행하지 않고 지휘만 맡습니다.
- Message Queue (MQ)
분할된 파티션 작업 명세서(
StepExecutionRequest)가 담긴 메시지를 중계합니다. - Worker Nodes (워커 서버들) 여러 대의 독립된 서버 인스턴스들입니다. MQ의 Request Queue를 리스닝하고 있다가, 메시지가 들어오면 각자 자기에게 할당된 구역의 데이터를 DB에서 읽어와 비즈니스 로직을 수행합니다.
- Shared Database
마스터와 워커가 동일하게 바라보는 스프링 배치 메타 테이블(
BATCH_*) 및 비즈니스 데이터베이스입니다.
2. 세부 동작 흐름 (Step-by-Step)
- 파티션 분할: 마스터 노드의 Partitioner가 가동되어 전체 대용량 데이터를 논리적 구역으로 쪼갭니다. (예: 4개 구역)
- 메시지 발행: 마스터의 PartitionHandler가 쪼개진 4개의 파티션 정보(시작 ID, 끝 ID 등)를 각각 하나의 메시지로 패킹하여 MQ의 request-queue로 발송합니다.
- 워커의 경쟁 수신: 대기 중이던 워커 서버 노드들이 MQ로부터 메시지를 1개씩 선점(Consume)하여 가져갑니다. 워커 서버가 2대라면 각각 2개씩 가져가게 됩니다.
- 독립 비즈니스 수행: 워커 노드는 메시지에 적힌 범위(예: "너는 ID 10만번부터 20만번까지 처리해라")를 확인하고, 자체 리더/프로세서/라이터를 가동하여 DB 연산을 수행합니다.
- 결과 보고: 작업을 마친 워커 노드는 공유 DB의
BATCH_STEP_EXECUTION테이블에 자신의 작업 상태를 완료(COMPLETED)로 직접 업데이트하거나, 별도의 reply-queue를 통해 마스터에게 완료 통보를 보냅니다. - 마스터의 최종 집계: 마스터 노드는 모든 워커가 작업을 마칠 때까지 대기(Polling)하다가, 전원 완료가 확인되면 전체 Job을 최종 성공 처리합니다.
3. Spring Batch Integration 활용 코드 명세 (Spring Batch 5.x 기준)
현대적인 스프링 배치에서는 spring-batch-integration 모듈의 RemotePartitioningManagerStepBuilder와 RemotePartitioningWorkerStepBuilder를 사용하여 이를 깔끔하게 구현합니다.
① Manager Node (마스터 서버 설정)
@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();
}
}② Worker Node (독립된 워커 서버 설정)
@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 어댑터 설정을 바인딩하여 네트워크 통신을 연결하게 됩니다.
4. 실무 도입 시 핵심 고려사항 및 한계점
이 방식은 수억 건의 데이터를 다루는 글로벌 빅테크 기업에서 애용하는 최강의 확장성을 자랑하지만, 그만큼 혹독한 인프라 트레이드오프가 존재합니다.
-
① 공유 메타 DB 병목 (가장 중요) 아무리 MQ를 통해 워커 서버 노드를 10대, 20대로 확장하더라도, 결국 모든 워커 노드가 스프링 배치의 상태를 기록하기 위해 단 하나의 중앙 메타데이터 데이터베이스(
BATCH_*)로 동시에 접근합니다. 워커 노드가 늘어날수록 메타 DB의 커넥션 풀이 고갈되고 쓰기 락(Lock) 경쟁이 심화되어, 오히려 시스템 전체가 느려지는 역효과가 날 수 있습니다. 이 때문에 메타 DB만큼은 반드시 고성능 독립 인스턴스로 격리해야 합니다. -
② 분산 환경의 네트워크 단선 및 멱등성 문제 네트워크 장애로 인해 특정 워커 노드가 메시지를 받아 처리를 완료했음에도 마스터에게 완료 신호(ACK)를 보내지 못하고 무너질 수 있습니다. MQ의 재전송 매커니즘에 의해 다른 워커 노드가 동일한 파티션 메시지를 중복 수신하여 다시 실행할 수 있으므로, 비즈니스 로직(Writer)은 반드시 중복 처리되어도 문제가 없는 멱등성(Idempotency)이 완벽히 보장되도록 튜닝되어야 합니다.
-
③ 오케스트레이션 및 배포 관리의 복잡도 마스터 코드와 워커 코드를 하나의 애플리케이션 안에서 프로파일(Profile)로 분리할지, 아니면 아예 별도의 프로젝트 파일로 쪼갤지 결정해야 합니다. 배포 시점에도 MQ의 큐 생성 상태, 컨테이너 스케일링 정책(K8s 오토스케일링 등)이 유기적으로 맞물려야 하므로 인프라 관리 공수가 급격히 상승합니다.
댓글
GitHub 계정으로 의견이나 질문을 남길 수 있습니다.