⚙️
🌱
💻
🚀
Skip to content

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

📚 시리즈: 접속기록 배치 성능 최적화

2 / 2
  1. 11편 - 문제 진단과 병목 분석
  2. 22편 - 병렬 처리로 20배 빠르게지금 읽는 중
Spring Batch 병렬 처리로 20배 빠르게 만들기 (2편)




서론

1편에서 배치가 느린 이유를 분석했습니다.

문제의 핵심은 독립적인 5개 작업을 순차 실행하고 있다는 것이었습니다. 5개 Step이 서로 독립적인데도 순차적으로 실행되면서 약 3시간이 걸렸죠. 이론상으로는 병렬 실행하면 40~50분으로 줄일 수 있을 것 같았습니다.

그런데 실제로 구현하고 나니 20배 이상 빨라졌습니다.

이번 편에서는 병렬 처리를 구현한 과정과, 왜 예상보다 훨씬 더 빨라졌는지 공유합니다.





병렬 처리 전략

독립성 확인

병렬 처리를 하기 전에 각 Step의 독립성을 다시 한 번 확인했습니다.

java
// 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 (선택)

java
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

java
@Async
public CompletableFuture<Void> executeDailyStep() {
    // ...
}

장점: 스프링 통합이 편함 단점: 배치 Job 관리 기능(재시작, 이력 등)과 통합이 어려움


3. Parallel Step (Spring Batch 기본)

java
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로 감쌌습니다.

java
@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으로 등록합니다.

java
@Configuration
public class BatchExecutorConfig {

    @Bean(destroyMethod = "shutdown")
    public ExecutorService batchExecutorService() {
        return Executors.newFixedThreadPool(5);
    }
}

그리고 병렬 실행 메서드를 구현합니다.

java
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 설정)

왜 매번 생성하면 안 될까?

처음에는 매번 스레드 풀을 생성하고 종료하는 방식을 고민했습니다.

java
// Bad - 매번 생성
ExecutorService executor = Executors.newFixedThreadPool(5);
// ... 작업 실행 ...
executor.shutdown();

하지만 이 방식은 스레드 생성/종료 오버헤드가 큽니다. 5개 스레드를 매번 생성하고 종료하는 데 수백ms~수초가 걸릴 수 있거든요.

Bean으로 등록하면 애플리케이션 시작 시 한 번만 생성되고, 이후로는 재사용됩니다.



3단계: 예외 처리 전략

병렬 처리에서 가장 까다로운 부분이 예외 처리입니다.

시나리오:

  • Step 1, 2, 3은 성공
  • Step 4가 실패
  • Step 5는 아직 실행 중

Step 4가 실패하면 어떻게 해야 할까요?


선택지 1: 전체 중단

java
// Step 4 실패 → 즉시 예외 던짐 → Step 5도 중단

장점: 빠른 실패, 리소스 절약 단점: 성공한 Step들도 롤백해야 함


선택지 2: 끝까지 실행 후 실패 보고

java
// Step 4 실패 → 기록만 해두고 계속 → Step 5 실행 완료 → 최종 실패 보고

장점: 성공한 작업은 유지 단점: 실패한 작업만 재실행하기 복잡


저희는 선택지 1 (전체 중단)을 선택했습니다.

배치 작업은 통계 데이터를 만드는 것이고, 일부만 성공하면 데이터 일관성이 깨집니다. 차라리 전체를 재실행하는 게 안전하다고 판단했습니다.

java
tasks.parallelStream().forEach(task -> {
    try {
        task.run();
    } catch (Exception e) {
        log.error("작업 실행 중 오류 발생", e);
        throw new RuntimeException(e);  // 즉시 예외 전파
    }
});

ParallelStream에서 예외가 발생하면 즉시 상위로 전파됩니다. 다른 작업들도 중단되고, 전체 배치가 실패로 기록됩니다.



4단계: 트랜잭션 처리

병렬 처리에서 트랜잭션은 어떻게 될까요?

java
@Transactional
public void executeStep(String stepName, Step step) {
    // ...
}

executeStep()독립적인 트랜잭션을 가집니다. 5개 Step이 병렬로 실행되면 5개의 트랜잭션이 동시에 열립니다.

중요: 각 Step은 서로 다른 테이블에 쓰기 때문에 트랜잭션 충돌이 없습니다.

만약 같은 테이블에 쓴다면?

  • 데드락 가능성
  • 트랜잭션 타임아웃 가능성
  • 데이터 정합성 문제

이런 경우는 병렬 처리를 하면 안 됩니다.





성능 테스트

코드를 배포하고 실제로 돌려봤습니다.

Before: 순차 실행

text
=== 배치 시작: 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: 병렬 실행 (첫 시도)

text
=== 배치 시작: 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 사용률이 크게 올라가 있었습니다.

text
순차 실행 시:
- 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 크기를 조정해봤습니다.

java
// Before
.<AccessLog, DailyAccessStat>chunk(1000, transactionManager)

// After
.<AccessLog, DailyAccessStat>chunk(5000, transactionManager)

Chunk를 1,000개에서 5,000개로 늘렸습니다.

이유:

  • DB에서 한 번에 더 많은 데이터를 가져옴
  • 트랜잭션 커밋 횟수 감소
  • DB 왕복 횟수 감소

결과:

text
=== 배치 시작: 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 크기를 더 늘려봤습니다.

java
.<AccessLog, DailyAccessStat>chunk(10000, transactionManager)

결과:

text
총 실행 시간: 25분

더 빨라졌습니다!

Chunk 크기를 20,000으로 늘려봤습니다.

text
총 실행 시간: 23분

계속 빨라집니다!

50,000으로 늘려봤습니다.

text
[ERROR] java.lang.OutOfMemoryError: Java heap space

메모리가 터졌습니다.

Chunk 크기가 너무 크면 메모리에 한 번에 올라가는 데이터가 많아져서 OOM이 발생합니다.

결국 Chunk 크기 10,000이 최적이었습니다.



최종 성능

text
=== 최종 성능 ===

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 커넥션 풀 고갈

배포 후 이틀째 되는 날, 새벽에 배치가 멈췄습니다.

text
[ERROR] HikariPool - Connection is not available, request timed out after 30000ms

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

원인:

yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 20

커넥션 풀 크기를 20으로 늘렸지만, 부족했습니다.

각 Step이 Chunk 방식으로 돌면서 여러 개의 커넥션을 동시에 사용했습니다. 5개 Step × 4~5개 커넥션 = 20~25개 필요

해결:

yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 30 # 여유있게 30으로 증가
      connection-timeout: 60000 # 타임아웃도 1분으로 증가


문제 2: 메모리 누수

일주일 후, 배치 서버가 느려지기 시작했습니다.

text
[WARN] G1 Young Generation: 2.3초 소요 (이전: 0.1초)

GC 시간이 점점 늘어나고 있었습니다.

원인:

java
private void executeStep(String stepName, Step step) {
    // ...
    Job singleStepJob = new JobBuilder(stepName + "Job", jobRepository)
        .start(step)
        .build();

    jobLauncher.run(singleStepJob, jobParameters);
    // ← Job 인스턴스가 계속 쌓임
}

매번 새로운 Job 인스턴스를 만들어서 메모리에 쌓이고 있었습니다.

해결:

java
@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: 데드락

한 달 후, 새벽에 배치가 실패했습니다.

text
[ERROR] Deadlock found when trying to get lock; try restarting transaction

데드락이 발생했습니다.

원인:

Step 2와 Step 3이 동시에 같은 row를 업데이트하려고 했습니다.

sql
-- 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 테이블을 락 걸고 있었던 겁니다.

해결:

외래 키 제약 조건을 제거하고, 애플리케이션 레벨에서 무결성을 보장하도록 변경했습니다.

sql
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