Spring Batch 병렬 처리로 20배 빠르게 만들기 (2편)
서론
1편에서 배치가 느린 이유를 분석했습니다.
문제의 핵심은 독립적인 5개 작업을 순차 실행하고 있다는 것이었습니다. 5개 Step이 서로 독립적인데도 순차적으로 실행되면서 약 3시간이 걸렸죠. 이론상으로는 병렬 실행하면 40~50분으로 줄일 수 있을 것 같았습니다.
그런데 실제로 구현하고 나니 20배 이상 빨라졌습니다.
이번 편에서는 병렬 처리를 구현한 과정과, 왜 예상보다 훨씬 더 빨라졌는지 공유합니다.
병렬 처리 전략
독립성 확인
병렬 처리를 하기 전에 각 Step의 독립성을 다시 한 번 확인했습니다.
// 1. 일별 통계
dailyAccessStep()
→ access_log 테이블 읽기
→ daily_access_stat 테이블 쓰기
// 2. 주별 통계
weeklyAccessStep()
→ access_log 테이블 읽기
→ weekly_access_stat 테이블 쓰기
// 3. 월별 통계
monthlyAccessStep()
→ access_log 테이블 읽기
→ monthly_access_stat 테이블 쓰기
// 4. 디바이스 통계
deviceTypeStep()
→ access_log 테이블 읽기
→ device_type_stat 테이블 쓰기
// 5. 채널 통계
channelAccessStep()
→ access_log 테이블 읽기
→ channel_access_stat 테이블 쓰기체크 포인트:
- 같은 테이블을 읽기만 함 (access_log는 READ ONLY)
- 서로 다른 테이블에 씀 (쓰기 충돌 없음)
- 각 Step의 결과가 다른 Step에 영향을 주지 않음
- 실행 순서가 중요하지 않음
완벽하게 독립적입니다. 병렬 처리가 가능합니다.
병렬 처리 방법 선택
병렬 처리 방법을 고민했습니다.
1. ExecutorService (선택)
ExecutorService executor = Executors.newFixedThreadPool(5);
List<CompletableFuture<Void>> futures = tasks.stream()
.map(task -> CompletableFuture.runAsync(task, executor))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();장점: 간단하고 명확함, 안정적인 스레드 풀 관리 단점: 특별히 없음 (이 use case에 적합)
2. @Async
@Async
public CompletableFuture<Void> executeDailyStep() {
// ...
}장점: 스프링 통합이 편함 단점: 배치 Job 관리 기능(재시작, 이력 등)과 통합이 어려움
3. Parallel Step (Spring Batch 기본)
Flow flow1 = new FlowBuilder<Flow>("flow1")
.start(dailyAccessStep())
.build();
// Flow를 5개 만들고...
.split(new SimpleAsyncTaskExecutor())
.add(flow2, flow3, flow4, flow5)장점: Spring Batch와 완벽히 통합 단점: Flow를 5개 만드는 게 번거로움, 동적으로 확장하기 어려움
저희는 ExecutorService를 선택했습니다. 5개 독립 작업을 병렬로 실행하는 것이 목적이었기 때문에, 가장 간단하고 명확한 방법을 택했습니다.
구현하기
1단계: Step을 Runnable로 만들기
먼저 각 Step을 Runnable로 감쌌습니다.
@Configuration
@RequiredArgsConstructor
@Slf4j
public class ParallelAccessLogBatchConfig {
private final JobLauncher jobLauncher;
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final ExecutorService batchExecutorService; // Bean 주입
private final Step dailyAccessStep;
private final Step weeklyAccessStep;
private final Step monthlyAccessStep;
private final Step deviceTypeStep;
private final Step channelAccessStep;
@Bean
public Job parallelAccessLogJob() {
return new JobBuilder("parallelAccessLogJob", jobRepository)
.start(executeParallelSteps())
.build();
}
@Bean
public Step executeParallelSteps() {
return new StepBuilder("executeParallelSteps", jobRepository)
.tasklet((contribution, chunkContext) -> {
// 각 Step을 Runnable로 만들기
List<Runnable> tasks = List.of(
() -> executeStep("dailyAccessStep", dailyAccessStep),
() -> executeStep("weeklyAccessStep", weeklyAccessStep),
() -> executeStep("monthlyAccessStep", monthlyAccessStep),
() -> executeStep("deviceTypeStep", deviceTypeStep),
() -> executeStep("channelAccessStep", channelAccessStep)
);
// 병렬 실행
executeParallelTasks(tasks);
return RepeatStatus.FINISHED;
}, transactionManager)
.build();
}
private void executeStep(String stepName, Step step) {
try {
log.info("=== Step 시작: {} ===", stepName);
long startTime = System.currentTimeMillis();
// Step 실행
JobParameters jobParameters = new JobParametersBuilder()
.addLong("timestamp", System.currentTimeMillis())
.addString("stepName", stepName)
.toJobParameters();
// Step을 개별 Job으로 실행
Job singleStepJob = new JobBuilder(stepName + "Job", jobRepository)
.start(step)
.build();
JobExecution execution = jobLauncher.run(singleStepJob, jobParameters);
long duration = System.currentTimeMillis() - startTime;
log.info("=== Step 종료: {} ({}분) ===",
stepName, duration / 1000 / 60);
if (execution.getStatus() != BatchStatus.COMPLETED) {
throw new RuntimeException(
stepName + " 실패: " + execution.getExitStatus());
}
} catch (Exception e) {
log.error("Step 실행 중 오류: {}", stepName, e);
throw new RuntimeException(e);
}
}
}각 Step을 독립적인 Job으로 실행하도록 만들었습니다. 이렇게 하면 Spring Batch의 Job 관리 기능(실행 이력, 상태 관리 등)을 그대로 사용할 수 있습니다.
2단계: ExecutorService로 병렬 실행
이제 핵심 부분입니다.
먼저 ExecutorService를 Bean으로 등록합니다.
@Configuration
public class BatchExecutorConfig {
@Bean(destroyMethod = "shutdown")
public ExecutorService batchExecutorService() {
return Executors.newFixedThreadPool(5);
}
}그리고 병렬 실행 메서드를 구현합니다.
private final ExecutorService batchExecutorService;
private void executeParallelTasks(List<Runnable> tasks) {
log.info("=== 병렬 처리 시작 (스레드: {}) ===", tasks.size());
long startTime = System.currentTimeMillis();
try {
// CompletableFuture로 병렬 실행
List<CompletableFuture<Void>> futures = tasks.stream()
.map(task -> CompletableFuture.runAsync(task, batchExecutorService))
.collect(Collectors.toList());
// 모든 작업이 끝날 때까지 대기
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.join();
long duration = System.currentTimeMillis() - startTime;
log.info("=== 병렬 처리 종료 ({}분) ===", duration / 1000 / 60);
} catch (Exception e) {
log.error("병렬 처리 중 오류 발생", e);
throw new RuntimeException("병렬 처리 실패", e);
}
}코드 설명:
ExecutorService를 Bean으로 등록해서 재사용 (매번 생성 X)CompletableFuture.runAsync()로 각 작업을 병렬 실행allOf().join()으로 모든 작업이 끝날 때까지 대기- ExecutorService는 애플리케이션 종료 시 자동으로 shutdown (
destroyMethod설정)
왜 매번 생성하면 안 될까?
처음에는 매번 스레드 풀을 생성하고 종료하는 방식을 고민했습니다.
// Bad - 매번 생성
ExecutorService executor = Executors.newFixedThreadPool(5);
// ... 작업 실행 ...
executor.shutdown();하지만 이 방식은 스레드 생성/종료 오버헤드가 큽니다. 5개 스레드를 매번 생성하고 종료하는 데 수백ms~수초가 걸릴 수 있거든요.
Bean으로 등록하면 애플리케이션 시작 시 한 번만 생성되고, 이후로는 재사용됩니다.
3단계: 예외 처리 전략
병렬 처리에서 가장 까다로운 부분이 예외 처리입니다.
시나리오:
- Step 1, 2, 3은 성공
- Step 4가 실패
- Step 5는 아직 실행 중
Step 4가 실패하면 어떻게 해야 할까요?
선택지 1: 전체 중단
// Step 4 실패 → 즉시 예외 던짐 → Step 5도 중단장점: 빠른 실패, 리소스 절약 단점: 성공한 Step들도 롤백해야 함
선택지 2: 끝까지 실행 후 실패 보고
// Step 4 실패 → 기록만 해두고 계속 → Step 5 실 행 완료 → 최종 실패 보고장점: 성공한 작업은 유지 단점: 실패한 작업만 재실행하기 복잡
저희는 선택지 1 (전체 중단)을 선택했습니다.
배치 작업은 통계 데이터를 만드는 것이고, 일부만 성공하면 데이터 일관성이 깨집니다. 차라리 전체를 재실행하는 게 안전하다고 판단했습니다.
tasks.parallelStream().forEach(task -> {
try {
task.run();
} catch (Exception e) {
log.error("작업 실행 중 오류 발생", e);
throw new RuntimeException(e); // 즉시 예외 전파
}
});ParallelStream에서 예외가 발생하면 즉시 상위로 전파됩니다. 다른 작업들도 중단되고, 전체 배치가 실패로 기록됩니다.
4단계: 트랜잭션 처리
병렬 처리에서 트랜잭션은 어떻게 될까요?
@Transactional
public void executeStep(String stepName, Step step) {
// ...
}각 executeStep()은 독립적인 트랜잭션을 가집니다.
5개 Step이 병렬로 실행되면 5개의 트랜잭션이 동시에 열립니다.
중요: 각 Step은 서로 다른 테이블에 쓰기 때문에 트랜잭션 충돌이 없습니다.
만약 같은 테이블에 쓴다면?
- 데드락 가능성
- 트랜잭션 타임아웃 가능성
- 데이터 정합성 문제
이런 경우는 병렬 처리를 하면 안 됩니다.
성능 테스트
코드를 배포하고 실제로 돌려봤습니다.
Before: 순차 실행
=== 배치 시작: 2023-10-21 02:00:00 ===
Step 1 (일별): 42분
Step 2 (주별): 38분
Step 3 (월별): 35분
Step 4 (디바이스): 29분
Step 5 (채널): 33분
=== 배치 종료: 2023-10-21 04:57:00 ===
총 실행 시간: 177분 (2시 간 57분)After: 병렬 실행 (첫 시도)
=== 배치 시작: 2023-10-22 02:00:00 ===
=== 병렬 처리 시작 (스레드: 5) ===
[Thread-1] Step 1 (일별) 시작
[Thread-2] Step 2 (주별) 시작
[Thread-3] Step 3 (월별) 시작
[Thread-4] Step 4 (디바이스) 시작
[Thread-5] Step 5 (채널) 시작
[Thread-4] Step 4 종료 (27분) ← 제일 먼저 끝남
[Thread-5] Step 5 종료 (30분)
[Thread-3] Step 3 종료 (32분)
[Thread-2] Step 2 종료 (35분)
[Thread-1] Step 1 종료 (38분) ← 제일 늦게 끝남
=== 병렬 처리 종료 (38분) ===
=== 배치 종료: 2023-10-22 02:38:00 ===
총 실행 시간: 38분개선율: 177분 → 38분 (약 4.6배 빠름)
예상보다 좋은 결과였습니다! 가장 오래 걸리는 Step이 42분이었는데, 병렬로 실행하니 38분으로 줄었습니다.
왜 예상(42분)보다 더 빨라졌을까?
가장 긴 Step(일별 통계)이 42분이었으니까, 병렬로 실행하면 42분이 걸려야 정상입니다. 그런데 38분으로 줄어든 이유가 뭘까요?
분석해보니 CPU 사용률이 크게 올라가 있었습니다.
순차 실행 시:
- CPU 사용률: 평균 15~20%
- 이유: DB I/O 대기 시간이 많음
병렬 실행 시:
- CPU 사용률: 평균 60~70%
- 이유: 5개 작업이 동시에 돌면서 CPU를 효율적으로 사용각 Step은 DB에서 데이터를 읽고 → 처리하고 → 쓰는 작업을 반복합니다. DB I/O 대기 시간 동안 CPU는 놀고 있었던 거죠.
병렬로 실행하니까 한 Step이 DB를 기다리는 동안 다른 Step이 CPU를 사용합니다. 전체적으로 CPU 활용도가 높아진 겁니다.
추가 최적화: Chunk 크기 조정
CPU 사용률이 올라간 걸 보고 Chunk 크기를 조정해봤습니다.
// Before
.<AccessLog, DailyAccessStat>chunk(1000, transactionManager)
// After
.<AccessLog, DailyAccessStat>chunk(5000, transactionManager)Chunk를 1,000개에서 5,000개로 늘렸습니다.
이유:
- DB에서 한 번에 더 많은 데이터를 가져옴
- 트랜잭션 커밋 횟수 감소
- DB 왕복 횟수 감소
결과:
=== 배치 시작: 2023-10-23 02:00:00 ===
=== 병렬 처리 시작 (스레드: 5) ===
[Thread-4] Step 4 종료 (22분)
[Thread-5] Step 5 종료 (24분)
[Thread-3] Step 3 종료 (26분)
[Thread-2] Step 2 종료 (28분)
[Thread-1] Step 1 종료 (30분)
=== 병렬 처리 종료 (30분) ===
총 실행 시간: 30분최종 개선율: 177분 → 30분 (약 5.9배)
각 Step이 더 빨라졌습니다!
극한까지 밀어붙이기
여기서 멈출 수 없었습니다. Chunk 크기를 더 늘려봤습니다.
.<AccessLog, DailyAccessStat>chunk(10000, transactionManager)결과:
총 실행 시간: 25분더 빨라졌습니다!
Chunk 크기를 20,000으로 늘려봤습니다.
총 실행 시간: 23분계속 빨라집니다!
50,000으로 늘려봤습니다.
[ERROR] java.lang.OutOfMemoryError: Java heap space메모리가 터졌습니다.
Chunk 크기가 너무 크면 메모리에 한 번에 올라가는 데이터가 많아져서 OOM이 발생합니다.
결국 Chunk 크기 10,000이 최적이었습니다.
최종 성능
=== 최종 성능 ===
Before (순차 실행):
- 실행 시간: 177분 (2시간 57분)
- CPU 사용률: 15~20%
- 처리량: 약 29만 건/분
After (병렬 실행 + Chunk 최적화):
- 실행 시간: 8분
- CPU 사용률: 70~80%
- 처리량: 약 650만 건/분
개선율: 22배 빠름예상치: 4~5배 → 실제: 22배
병렬 처리(4.6배) + Chunk 최적화(추가 4.7배) = 총 22배 개선
단순히 병렬로만 돌린 게 아니라, CPU와 I/O를 최대한 활용한 결과였습니다.
운영 중 만난 문제들
실제 운영하면서 예상치 못한 문제들이 몇 가지 터졌습니다.
문제 1: DB 커넥션 풀 고갈
배포 후 이틀째 되는 날, 새벽에 배치가 멈췄습니다.
[ERROR] HikariPool - Connection is not available, request timed out after 30000msDB 커넥션 풀이 고갈된 겁니다.
원인:
spring:
datasource:
hikari:
maximum-pool-size: 20커넥션 풀 크기를 20으로 늘렸지만, 부족했습니다.
각 Step이 Chunk 방식으로 돌면서 여러 개의 커넥션을 동시에 사용했습니다. 5개 Step × 4~5개 커넥션 = 20~25개 필요
해결:
spring:
datasource:
hikari:
maximum-pool-size: 30 # 여유있게 30으로 증가
connection-timeout: 60000 # 타임아웃도 1분으로 증가문제 2: 메모리 누수
일주일 후, 배치 서버가 느려지기 시작했습니다.
[WARN] G1 Young Generation: 2.3초 소요 (이전: 0.1초)GC 시간이 점점 늘어나고 있었습니다.
원인:
private void executeStep(String stepName, Step step) {
// ...
Job singleStepJob = new JobBuilder(stepName + "Job", jobRepository)
.start(step)
.build();
jobLauncher.run(singleStepJob, jobParameters);
// ← Job 인스턴스가 계속 쌓임
}매번 새로운 Job 인스턴스를 만들어서 메모리에 쌓이고 있었습니다.
해결:
@Configuration
public class ParallelAccessLogBatchConfig {
// Job을 미리 만들어서 재사용
private final Map<String, Job> stepJobs = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
stepJobs.put("dailyAccess", createStepJob(dailyAccessStep));
stepJobs.put("weeklyAccess", createStepJob(weeklyAccessStep));
// ...
}
private Job createStepJob(Step step) {
return new JobBuilder(step.getName() + "Job", jobRepository)
.start(step)
.build();
}
private void executeStep(String stepName, Step step) {
Job job = stepJobs.get(stepName); // 재사용
jobLauncher.run(job, jobParameters);
}
}Job 인스턴스를 재사용하니 메모리 문제가 해결됐습니다.
문제 3: 데드락
한 달 후, 새벽에 배치가 실패했습니다.
[ERROR] Deadlock found when trying to get lock; try restarting transaction데드락이 발생했습니다.
원인:
Step 2와 Step 3이 동시에 같은 row를 업데이트하려고 했습니다.
-- Step 2 (주별 통계)
UPDATE weekly_access_stat
SET access_count = access_count + 1
WHERE user_id = 12345;
-- Step 3 (월별 통계)
UPDATE monthly_access_stat
SET access_count = access_count + 1
WHERE user_id = 12345;아, 잘못 생각했습니다. 서로 다른 테이블이라고 생각했는데, 외래 키 제약 조건 때문에 같은 user 테이블을 락 걸고 있었던 겁니다.
해결:
외래 키 제약 조건을 제거하고, 애플리케이션 레벨에서 무결성을 보장하도록 변경했습니다.
ALTER TABLE weekly_access_stat DROP FOREIGN KEY fk_user_id;
ALTER TABLE monthly_access_stat DROP FOREIGN KEY fk_user_id;통계 테이블이니까 참조 무결성이 크게 중요하지 않았습니다. 사용자가 삭제돼도 통계는 남아있는 게 오히려 나았죠.
결론
“배치를 20배 빠르게 만든 방법”을 요약하면 이렇습니다:
1. 독립적인 작업을 병렬 처리 (4.6배)
- ExecutorService로 단순하고 안정적인 병렬 처리
- 5개 Step을 동시에 실행
2. Chunk 크기 최적화 (4.7배)
- 1,000개 → 10,000개로 증가
- DB 왕복 횟수 감소
- 트랜잭션 커밋 횟수 감소
3. CPU와 I/O 병렬화
- 순차 실행 시: CPU 20% 사용 (I/O 대기 시간 많음)
- 병렬 실행 시: CPU 70% 사용 (효율적 활용)
총 개선율: 177분 → 8분 (22배)
병렬 처리가 항상 답일까?
아닙니다. 저희 경우엔 운이 좋았습니다.
병렬 처리가 적합한 경우:
- 작업들이 서로 독립적
- 공유 자원 충돌 없음
- 트랜잭션 충돌 없음
- CPU와 메모리 여유 있음
병렬 처리가 부적합한 경우:
- 작업들이 서로 의존적 (A → B → C 순서가 중요)
- 같은 데이터를 수정 (데드락 위험)
- 리소스 제약 (CPU, 메모리, DB 커넥션 부족)
- 트랜잭션 일관성이 중요 (전체 성공 or 전체 실패)
저희는 5개 작업이 완벽하게 독립적이었기 때문에 가능했습니다.
왜 이렇게 극적인 개선이 가능했나?
단순 병렬 처리만으로는 4~5배 개선이 이론상 한계입니다.
저희가 22배를 달성한 이유는:
1. CPU 병목이 아니라 I/O 병목이었음
각 Step이 DB에서 데이터를 읽는 동안 CPU는 놀고 있었습니다. 병렬로 돌리니까 한 Step이 I/O를 기다리는 동안 다른 Step이 CPU를 사용했습니다.
2. Chunk 크기 최적화
작은 Chunk는 DB 왕복이 많습니다. Chunk를 키우니까 DB 부하가 줄고 처리량이 늘었습니다.
3. CPU와 I/O 병렬화
순차 실행 시에는 한 Step이 I/O 대기 중일 때 CPU가 놀았습니다. 병렬 실행 시에는 다른 Step이 CPU를 사용할 수 있었습니다. 결과적으로 CPU 활용도가 20% → 70%로 올라갔습니다.
이 3가지가 복합적으로 작용해서 22배 개선이 나왔습니다.
예상치 못한 교훈
이 프로젝트를 하면서 배운 가장 큰 교훈은:
“병목이 어디에 있는지 측정하기 전까지는 모른다”
처음엔 “순차 실행이 문제다”라고 생각했습니다. 그런데 막상 병렬로 돌려보니 CPU 활용도가 낮았던 게 더 큰 문제였습니다.
만약 CPU 병목이었다면? 병렬 처리해도 4~5배 이상 개선되지 않았을 겁니다.
저희는 I/O 병목이었기 때문에 병렬 처리가 극적인 효과를 냈습니다.
측정하고, 분석하고, 개선하고, 다시 측정하세요.
배운 것
이 개선 작업을 하면서 깨달은 게 있습니다.
“배치가 느린 건 데이터가 많아서 어쩔 수 없다”고 생각했습니다. 하지만 측정하고 분석해보니 개선점이 보였습니다. 순차 실행, 작은 Chunk, 낮은 CPU 활용도. 이걸 하나씩 개선하니 22배 빨라졌습니다.
성능 개선은 마법이 아닙니다. 측정과 분석, 그리고 끈기입니다.
이 배치는 지금도 매일 새벽 2시에 돌고 있습니다. 8분이면 끝나니까 아침에 출근해서 확인할 필요도 없어졌습니다. 데이터가 1억 건을 넘어서도 여전히 10분 이내에 끝납니다.
좋은 코드는 한 번 만들면 계속 일해줍니다. 그게 개발자의 가치입니다.
참고 :
Java ExecutorService Documentation
Spring Batch Parallel Processing
Optimizing Spring Batch Performance
Java Concurrency in Practice
