Skip to content

Spring Batch 병렬 처리와 청크 크기 조정 (2편)

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

2 / 2편
  1. 11편 - 문제 진단과 병목 분석
  2. 22편 - 병렬 처리와 청크 크기 조정지금 읽는 중
다른 시리즈 전체 보기 →
Spring Batch 병렬 처리와 청크 크기 조정 (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);  // 즉시 예외 전파
    }
});

예외는 호출자에게 전파될 수 있지만, 이미 실행 중인 다른 작업의 즉각 중단이나 독립 트랜잭션의 전체 롤백을 보장하지 않습니다. 위 코드는 Spring Batch Job 상태 기록도 직접 보장하지 않습니다. 각 작업의 완료·실패를 수집하고, 이미 커밋된 결과의 멱등성·보상·재시작 정책을 별도로 검증해야 합니다.



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이 최적이었습니다.



성능 수치 정정

본문에 남아 있는 단계별 시간은 177분 → 38분 → 30분 → 25분이며, 청크 20,000에서는 23분, 50,000에서는 OOM을 기록했습니다. 그런데 이전 결론은 추가 측정 과정 없이 8분·22배를 제시했습니다. 그 연결 근거를 확인할 수 없어 최종 8분, 22배와 해당 처리량 주장은 철회합니다.

서로 다른 설정의 개선 배수를 더하거나, 조건이 다른 실험의 배수를 단순 곱해 최종 성능을 만들면 안 됩니다. 데이터 건수, 하드웨어, 캐시 상태, 병렬도, 청크 크기, 쿼리와 트랜잭션 범위를 고정해 전체 실행 시간을 다시 비교해야 합니다. 이 글의 과거 로그를 이번 수정에서 재실행한 벤치마크로 제시하지 않습니다.

경력 기록의 접속 이력 적재 3~4분 → 10초 사례는 데이터 규모와 구현 경로가 다른 기록이므로 이 글의 177분 실험과 합쳐 계산하지 않습니다.

운영 중 만난 문제들

실제 운영하면서 예상치 못한 문제들이 몇 가지 터졌습니다.


문제 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 객체를 재사용한 뒤 문제가 나아졌다는 관찰만으로 객체 생성이 메모리 누수의 원인이라고 확정할 수는 없습니다. 힙 덤프에서 retaining path를 확인하고 Job 정의와 JobInstance·JobExecution 메타데이터, 캐시·스레드 보유 참조를 구분해야 합니다. 같은 파라미터 재실행은 JobRepository의 인스턴스·재시작 규칙을 따릅니다. 위 코드는 개념 예시이며 그대로 실행 가능한 완성 설정이 아닙니다.



문제 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;

아, 잘못 생각했습니다. 서로 다른 테이블이어도 외래 키 확인이나 다른 쿼리로 공통 자원에 접근할 수 있습니다. 다만 아래 SQL 두 개만으로 순환 대기나 실제 원인을 증명할 수는 없습니다. 원본 deadlock report가 없으므로 당시 원인을 단정하는 설명은 정정합니다.

해결:

당시 외래 키를 제거한 선택을 일반적인 해결책으로 권장하지 않습니다. 아래 변경은 고아 데이터와 동시 삭제·삽입의 무결성 위험이 있으며 앱 코드만으로 DB 제약과 동일한 보장을 자동으로 얻지 못합니다. 데드락 보고서의 각 트랜잭션 보유 락과 대기 락, 인덱스·실행 계획·접근 순서를 먼저 확인하고 락 순서 통일, 트랜잭션 단축, 재시도 등을 검토해야 합니다. 외래 키를 제거해야 한다면 무결성·삭제 정책과 대사 방안을 별도 검증해야 합니다.

ALTER TABLE weekly_access_stat DROP FOREIGN KEY fk_user_id;
ALTER TABLE monthly_access_stat DROP FOREIGN KEY fk_user_id;

통계 테이블이니까 참조 무결성이 크게 중요하지 않았습니다. 사용자가 삭제돼도 통계는 남아있는 게 오히려 나았죠.





적용할 때 확인할 것

독립 작업을 병렬 실행해도 DB 연결 수와 쓰기 경합이 새 병목이 될 수 있습니다. 풀 크기와 동시 실행 수를 따로 설정하고, 한 작업이 실패했을 때 다른 작업의 커밋을 어떻게 취급할지 정해야 합니다. ExecutorService를 사용했다는 이유만으로 Spring Batch의 재시작 메타데이터와 트랜잭션 경계가 자동으로 맞춰지지는 않습니다.

청크 크기는 DB 왕복 횟수와 메모리·락 유지 시간 사이의 선택입니다. “가장 빨랐던 값”만 남기기보다 데이터 증가와 실패 재시작 비용까지 확인해야 합니다. 조건을 남기지 않은 개선 배수보다, 같은 조건에서 반복해서 비교할 수 있는 측정 기록이 다음 개선에 더 도움이 됩니다.


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

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