정산 집계 스케줄링 성능 최적화 (feat. stream, parallelStream, bulk) & DeadLock, 처리량과 동시성 관점

gminnimk·2025년 9월 23일
0

문제 해결

목록 보기
18/18

50만 건 정산 배치, 20초에서 0.7초로 개선 - ParallelStream의 함정과 Bulk Insert 최적화

결제 내역에 대한 정산 집계 스케줄링에서 대용량 데이터를 다루는 배치 작업의 성능을 단계적으로 최적화했던 경험을 기록하고자 합니다.

50만 건의 결제 데이터를 정산하는 스케줄링 로직을 stream에서 ParallelStream, 그리고 최종적으로 Bulk Insert로 개선하며 20초가 넘던 실행 시간을 0.7초까지 단축시켰습니다.

이 과정에서 "만능인 줄 알았던" ParallelStream이 왜 효과가 없었는지, 그리고 잘못 사용하면 어떤 Deadlock 위험이 있는지, 마지막으로 I/O-bound 작업에 병렬 처리를 도입하는 진짜 이유는 무엇인지 깊이 있게 알아보겠습니다.



1. 최초의 정산 로직: Stream을 이용한 순차 처리

매일 새벽, 전날 발생한 모든 결제(Payment) 내역을 파트너별로 집계하여 정산(Settlement) 데이터를 생성하는 스케줄링 배치가 있습니다.

파트너 10만 명, 총 결제 건수 50만 건을 기준으로 테스트를 시작했습니다.

초기 로직

  1. paymentRepository에서 50만 건의 Payment 엔티티를 모두 조회합니다.
  2. Stream을 사용해 메모리에서 파트너 ID(partnerId)로 그룹핑하고, 금액을 합산합니다.
  3. saveAll 메서드로 10만 건의 Settlement 데이터를 저장합니다.
// SettlementScheduledTasks.java - 초기 버전
@Scheduled(cron = "0 * * * * ?")
@Transactional
public void dailySettlement() {
    long startTime = System.currentTimeMillis();
    log.info("일일 정산 배치 작업이 시작되었습니다.");

    // ... 날짜 설정 ...

    // 1. 50만 건의 Payment 엔티티를 메모리로 로드
    List<Payment> payments = paymentRepository.findAllByStatusAndPaymentDateBetween(...);

    // 2. 메모리에서 Stream으로 그룹핑 및 합산
    Map<Long, BigDecimal> partnerTotalAmounts = payments.stream()
            .filter(p -> p.getPartnerId() != null)
            .collect(Collectors.groupingBy(
                    Payment::getPartnerId,
                    Collectors.mapping(Payment::getPaymentAmount, Collectors.reducing(BigDecimal.ZERO, BigDecimal::add))
            ));

    // ... 정산 엔티티 생성 ...

    // 3. 10만 건의 Settlement 데이터를 하나씩 저장 (내부적으로)
    settlementRepository.saveAll(settlementsToSave);

    long duration = System.currentTimeMillis() - startTime;
    log.info("실행 시간: {}ms", duration);
}
  • 결과: 평균 20,259ms (약 20.3초)


2. 1차 시도: ParallelStream 도입

가장 먼저 떠오른 생각은 "간단하게 parallelStream()으로 바꾸면 빨라지지 않을까?" 였습니다.

// partnerTotalAmounts를 구하는 부분을 parallelStream()으로 변경
Map<Long, BigDecimal> partnerTotalAmounts = payments.parallelStream()
// ...
  • 결과: 평균 20,144ms (약 20.1초)
  • 결론: 성능 개선 효과는 없었습니다.

왜 효과가 없었을까?

ParallelStream은 CPU를 여러 개 사용하여 CPU-bound(계산 집약적) 작업을 병렬로 처리할 때 강력한 성능을 발휘합니다. 하지만 현재 로직의 진짜 병목은 CPU 계산이 아니었습니다.

  • 진짜 병목 (I/O-bound):
    1. DB 조회: 50만 건의 엔티티를 DB에서 조회하여 메모리에 올리는 시간
    2. DB 저장: saveAll이 10만 건의 INSERT 쿼리를 실행하는 시간

메모리에서 데이터를 그룹핑하는 작업은 전체 실행 시간에 비하면 아주 가벼운 작업이었습니다. 주방의 오븐(DB)이 느린데, 요리사(CPU)를 10명으로 늘려봐야 피자가 빨리 구워지지 않는 것과 같은 이치였습니다.

💡 잠깐, I/O-Bound 작업에 병렬 처리가 의미 있을 때도 있지 않나요?

"I/O-bound 작업은 병렬 처리가 크게 효과적이지 않다"는 말은 "하나의 작업을 더 빠르게 만드는가?"라는 관점에서는 맞지만, "단위 시간당 더 많은 작업을 처리할 수 있는가?" 라는 처리량 관점에서는 다릅니다.

I/O-bound 작업에 병렬 처리를 도입하는 진짜 이유는, '기다리는 시간'을 놀리지 않고 다른 작업을 동시에 처리하여 전체 시스템의 처리량을 높이기 위함입니다. 즉, 진정한 의미의 병렬성보다는 동시성을 통해 효율을 높이는 것에 가깝습니다.

  1. I/O 대기 시간을 다른 작업에 활용
    단일 스레드는 DB 응답을 기다리는 동안 CPU가 아무 일도 하지 않고 쉬지만, 멀티 스레드(동시성) 환경에서는 Thread 1이 DB 응답을 기다릴 때 Thread 2는 이미 받아온 데이터로 CPU 작업을 처리할 수 있습니다. 즉, 여러 스레드가 I/O 작업을 요청하고 기다리는 시간을 서로 겹치게 만들어 CPU가 쉬는 시간을 최소화하고 전체 작업 시간을 단축시킵니다.

  2. 데이터베이스 커넥션 풀의 효율적 사용
    멀티 스레드는 커넥션 풀의 여러 커넥션을 동시에 사용하여 DB에 병렬로 요청을 보낼 수 있습니다. 현대의 DB는 대부분 여러 요청을 동시에 처리할 수 있으므로, 여러 스레드에서 동시에 데이터를 읽어오는 것만으로도 전체 조회 시간을 크게 줄일 수 있습니다.

  3. CPU-Bound 작업과의 혼합
    대부분의 작업은 100% I/O-bound가 아닙니다. 데이터를 읽고 쓰는 사이에 복잡한 계산, 데이터 변환, 객체 매핑 등 CPU 연산이 포함됩니다. 이런 CPU 연산 부분은 병렬 처리의 효과를 직접적으로 받습니다.

그럼에도 불구하고 제 코드에서 ParallelStream이 효과가 없었던 이유는, 압도적인 병목이 saveAll 메서드가 실행하는 10만 건의 INSERT I/O였고, 메모리에서 수행하는 그룹핑(CPU 작업)은 전체의 1%도 차지하지 않는 미미한 작업이었기 때문입니다. 병목의 본질을 해결하지 않고는 어떠한 최적화도 무의미했습니다.



3. 2차 시도: Bulk Insert

병목 지점이 DB I/O라는 것을 명확히 인지하고, DB와의 통신 자체를 줄이는 방향으로 접근했습니다.

  1. 조회 최적화: 50만 건의 데이터를 모두 메모리에 올리는 대신, DB가 잘하는 **그룹핑과 집계(GROUP BY, SUM)**를 DB에 맡깁니다. JPQL 쿼리를 수정하여 처음부터 파트너별 합산 금액만 가져옵니다.

  2. 저장 최적화: JPA의 saveAll은 내부적으로 여러 INSERT 쿼리를 보내는 경우가 많습니다. 이를 JdbcTemplatebatchUpdate를 사용한 Bulk Insert로 변경하여, 단 한 번의 네트워크 통신으로 모든 데이터를 삽입합니다.

개선된 코드

// PaymentRepository.java - JPQL로 DB에서 직접 집계
@Query("SELECT p.partnerId, SUM(p.paymentAmount) " +
       "FROM Payment p " +
       "WHERE p.status = :status AND p.paymentDate BETWEEN :start AND :end AND p.partnerId IS NOT NULL " +
       "GROUP BY p.partnerId")
List<Object[]> findPartnerTotalsByStatusAndPaymentDateBetween(...);

// SettlementScheduledTasks.java - Bulk Insert 로직
private void bulkInsertSettlements(List<Settlement> settlements) {
    String sql = "INSERT INTO settlements (partner_id, total_amount, payment_date, status) VALUES (?, ?, ?, ?)";

    jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
        @Override
        public void setValues(PreparedStatement ps, int i) throws SQLException {
            // ... PreparedStatement에 값 설정 ...
        }

        @Override
        public int getBatchSize() {
            return settlements.size(); // 한 번에 모든 데이터를 처리
        }
    });
}
  • 결과: 평균 676ms (약 0.7초)
  • 결론: 병목을 정확히 파악하고 DB I/O를 최소화하자 성능이 약 30배 향상되었습니다.


4. 심화 학습: ParallelStream과 Deadlock의 위험성

ParallelStream이 효과가 없었지만, 만약 CPU-bound 작업이라 사용해야 한다면 반드시 알아야 할 내용이 있습니다. 바로 Deadlock(교착 상태)의 위험성입니다.

ParallelStream은 어떻게 동작하는가?

parallelStream()을 호출하면, Java는 내부적으로 ForkJoinPool.commonPool()이라는 공용 스레드 풀을 사용합니다. 이 풀은 JVM 전역에서 공유되며, 기본적으로 내 컴퓨터의 CPU 코어 수만큼의 스레드를 가집니다.


I/O 작업 병렬 처리: 왜 안티패턴(Anti-Pattern)인가?

parallelStream의 작업 내에서 Blocking I/O (네트워크 호출, DB 조회 등)가 발생하면 Deadlock이 발생할 수 있습니다. 이는 parallelStream의 가장 대표적인 안티패턴입니다.

공용 스레드 풀의 모든 스레드가 I/O를 기다리며 Blocked 상태가 되면, 다른 어떤 작업도 해당 풀을 사용할 수 없게 되는 스레드 고갈(Thread Starvation) 현상이 발생합니다. 이는 시스템 전체의 응답성을 저해하는 심각한 문제입니다.

⚠️ 애플리케이션 vs 데이터베이스 데드락

여기서 말하는 데드락은 애플리케이션 레벨의 스레드 고갈을 의미합니다. 이와 별개로 데이터베이스 레벨의 데드락도 주의해야 합니다.

예를 들어, 여러 스레드가 서로 다른 순서로 테이블의 행에 UPDATE를 시도하며 각자의 Lock이 해제되기를 기다리는 상황이 발생할 수 있습니다. 이런 문제는 Partitioning(데이터 범위를 나눠 처리)과 같은 기법으로 처리할 데이터의 경계를 명확히 하여 충돌 위험을 줄일 수 있습니다.


해결책: 별도의 ForkJoinPool 사용

이런 위험을 피하려면, 중요한 병렬 작업에는 공용 풀이 아닌 별도의 전용 스레드 풀을 만들어 사용해야 합니다.

// 전용 스레드 풀을 생성하여 parallelStream 실행
ForkJoinPool customPool = new ForkJoinPool(4); // 스레드 4개짜리 풀 생성
try {
    customPool.submit(() ->
        myList.parallelStream().forEach(item -> {
            // 병렬 작업 수행
        })
    ).get(); // 작업이 끝날 때까지 대기
} finally {
    customPool.shutdown(); // 반드시 풀을 종료해야 합니다.
}
  • 장점: 다른 작업에 영향을 주지 않고 격리되어 안정적이며, 작업 성격에 맞게 스레드 개수를 제어할 수 있습니다.
  • 단점: 리소스 오버헤드와 직접 풀을 관리해야 하는 복잡성이 따릅니다.


결론: 새벽 배치에 굳이 전용 풀이 필요할까?

만약 이 작업이 CPU-bound라 ParallelStream을 써야 한다면, 전용 풀을 만들어야 할까요?

정답은 '아니오, 그럴 가능성이 높다' 입니다.

새벽에 실행되는 단독 배치 작업은 웹 요청처럼 동시에 다른 작업과 경쟁할 가능성이 매우 낮습니다. 즉, 공용 풀을 혼자서 여유롭게 사용할 수 있으므로 Deadlock 위험이 거의 없습니다. 이런 상황에서는 굳이 코드를 복잡하게 만들면서까지 전용 풀을 사용할 필요가 없습니다.

전용 ForkJoinPool은 웹 서버 환경처럼 여러 요청이 동시에 공용 풀을 놓고 경쟁하며, 병렬 작업 내에서 Blocking I/O를 반드시 해야 하는 경우에 사용하는 것이 좋습니다.



다음 단계: Spring Batch 도입 고려하기

배치 작업이 더 복잡해지고 안정성이 중요해지는 운영 환경에서는 Spring Batch 도입을 고려하는 방안을 생각하고 있습니다.

@ScheduledJdbcTemplate 조합이 '잘 만든 수레'라면, Spring Batch는 '컨베이어 벨트를 갖춘 자동화 공장'입니다.

언제 Spring Batch를 도입해야 할까?

  • 대용량 데이터 처리: OutOfMemoryError 없이 안정적으로 처리해야 할 때 (Chunk 기반 처리)
  • 실패 후 재시작: 수 시간 걸리는 작업이 실패했을 때, 처음부터가 아닌 실패 지점부터 재개해야 할 때
  • 정교한 에러 처리: 특정 데이터만 건너뛰거나(Skip), 재시도(Retry)하는 등 복잡한 예외 처리가 필요할 때
  • 모니터링 및 관리: 모든 배치 작업의 이력을 DB에 기록하고 관리하고 싶을 때

결론적으로, 간단한 스케줄링은 @Scheduled로 충분하지만, 운영 환경에서 대용량 데이터를 안정적으로 다루고 실패에 대한 자동 복구가 필요하다면 Spring Batch는 선택이 아닌 필수입니다.


이번 성능 개선 프로젝트를 통해 기술을 적용하기 전, "내 애플리케이션의 진짜 병목은 어디인가?"를 먼저 분석하는 것이 얼마나 중요한지 다시 한번 깨닫게 되었습니다.

0개의 댓글