Skip to content

Spring Batch 병렬 처리로 20배 빠르게 만들기 (2편)

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);
    }
}

코드 설명:

  1. ExecutorService를 Bean으로 등록해서 재사용 (매번 생성 X)
  2. CompletableFuture.runAsync()로 각 작업을 병렬 실행
  3. allOf().join()으로 모든 작업이 끝날 때까지 대기
  4. 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 30000ms

DB 커넥션 풀이 고갈된 겁니다.

원인:

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




읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site