AI 수익화 실전

Java 쿼리 병렬 처리로 성능 개선하기

AI연구 2025. 11. 14. 12:58
반응형

 

Java 쿼리 병렬 처리로 성능 개선하기

작성일: 2025-11-14
주제: Java 병렬 처리, CompletableFuture, 성능 최적화
난이도: 중급

1. 문제 상황

🚨 성능 병목 현상

사용자가 선택한 여러 항목에 대한 데이터를 조회하는 API가 있었습니다. 각 항목별로 개별 쿼리를 순차적으로 실행하니 총 소요 시간이 12초에 달했습니다.

  • 항목당 쿼리 실행 시간: 약 0.3초
  • 총 항목 수: 40개
  • 총 소요 시간: 0.3초 × 40 = 약 12초

사용자는 첫 데이터를 보기까지 12초를 기다려야 했고, 이는 매우 나쁜 사용자 경험을 제공했습니다.

2. 문제 분석

2.1 순차 처리의 문제점

// ❌ 순차 처리 방식 (느림)
public List<DataDTO> getDataList(List<Item> items) {
    List<DataDTO> allData = new ArrayList<>();
    
    for(Item item : items) {
        Long itemId = item.getId();
        
        // 각 항목을 하나씩 순차적으로 조회
        DataDTO result = getDataById(itemId);
        allData.add(result);
    }
    
    return allData;
}

위 코드의 문제점:

  • 블로킹 처리: 각 쿼리가 완료될 때까지 다음 쿼리를 실행하지 못함
  • CPU 유휴 시간: I/O 대기 중 CPU가 놀고 있음
  • 확장성 부족: 현장 수가 늘어날수록 시간이 선형적으로 증가

2.2 시간 복잡도 분석

처리 방식 시간 복잡도 40개 항목 기준
순차 처리 O(n) 12초 (40개 항목 기준)
병렬 처리 (10 스레드) O(n/10) 약 1.2초
병렬 처리 (무제한) O(1) 약 0.3초

3. 병렬 처리 솔루션

✅ 해결 방법: CompletableFuture + ExecutorService

Java 8의 CompletableFutureExecutorService를 활용하여 여러 쿼리를 동시에 실행하고, 모든 결과를 기다린 후 합치는 방식으로 개선했습니다.

3.1 핵심 개념

  • CompletableFuture: 비동기 작업을 표현하고 조합할 수 있는 클래스
  • ExecutorService: 스레드 풀을 관리하여 병렬 작업을 실행
  • supplyAsync: 비동기로 작업을 실행하고 CompletableFuture 반환
  • join: 모든 작업이 완료될 때까지 대기하고 결과 수집

4. 구현 방법

4.1 의존성 추가

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;

4.2 병렬 처리 구현

Step 1: 스레드 풀 생성

ExecutorService executor = Executors.newFixedThreadPool(10);
// 최대 10개의 스레드로 병렬 처리
// 항목 수가 많을 경우 스레드 수를 조정 가능

Step 2: 각 항목별 비동기 작업 생성

List<CompletableFuture<DataDTO>> futures = new ArrayList<>();

for(Item item : itemList) {
    Long itemId = item.getId();
    
    // 각 항목 조회를 비동기 작업으로 생성
    CompletableFuture<DataDTO> future = 
        CompletableFuture.supplyAsync(() -> {
            try {
                // 개별 항목에 대한 데이터 조회
                DataDTO result = getDataById(itemId);
                return result != null ? result : new DataDTO();
            } catch (Exception e) {
                logger.error("데이터 조회 중 오류 발생 - itemId: " + itemId, e);
                return new DataDTO();
            }
        }, executor);
    
    futures.add(future);
}

Step 3: 모든 작업 완료 대기 및 결과 합치기

// 모든 비동기 작업이 완료될 때까지 대기하고 결과 수집
List<DataDTO> allData = futures.stream()
    .map(CompletableFuture::join)  // 각 Future의 결과를 가져옴
    .filter(data -> data != null)  // null 제거
    .collect(Collectors.toList());

Step 4: 리소스 정리

finally {
    executor.shutdown();  // 스레드 풀 종료
}

4.3 전체 코드

public List<DataDTO> getDataListParallel(List<Item> items) {
    ExecutorService executor = Executors.newFixedThreadPool(10);
    
    try {
        if(items == null || items.isEmpty()) {
            return new ArrayList<>();
        }
        
        // 각 항목별로 병렬 조회
        List<CompletableFuture<DataDTO>> futures = new ArrayList<>();
        
        for(Item item : items) {
            Long itemId = item.getId();
            if(itemId == null || itemId <= 0) {
                continue;
            }
            
            CompletableFuture<DataDTO> future = 
                CompletableFuture.supplyAsync(() -> {
                    try {
                        // 개별 항목에 대한 데이터 조회
                        DataDTO data = getDataById(itemId);
                        return data != null ? data : new DataDTO();
                    } catch (Exception e) {
                        logger.error("데이터 조회 중 오류 발생 - itemId: " + itemId, e);
                        return new DataDTO();
                    }
                }, executor);
            
            futures.add(future);
        }
        
        // 모든 조회가 완료될 때까지 대기하고 결과 합치기
        List<DataDTO> allData = futures.stream()
            .map(CompletableFuture::join)
            .filter(data -> data != null)
            .collect(Collectors.toList());
        
        return allData;
        
    } catch (Exception e) {
        logger.error("데이터 조회 중 오류 발생", e);
        return new ArrayList<>();
    } finally {
        executor.shutdown();
    }
}

5. 성능 개선 결과

❌ 개선 전 (순차 처리)

  • 총 소요 시간: 12초
  • 처리 방식: 순차 실행
  • CPU 활용률: 낮음
  • 사용자 대기 시간: 12초

✅ 개선 후 (병렬 처리)

  • 총 소요 시간: 약 1.2초
  • 처리 방식: 병렬 실행 (10 스레드)
  • CPU 활용률: 높음
  • 사용자 대기 시간: 1.2초

5.1 성능 개선 지표

지표 개선 전 개선 후 개선율
총 실행 시간 12초 1.2초 90% 개선
처리량 (TPS) 3.25 TPS 32.5 TPS 10배 증가
사용자 대기 시간 12초 1.2초 90% 감소

5.2 시각적 비교

순차 처리 (12초):
항목1 ──────────┐
항목2           └──────────┐
항목3                       └──────────┐
...                                   └──────────┐
항목40                                             └──────────┘
                                                   총 12초

병렬 처리 (1.2초):
항목1 ──────────┐
항목2 ──────────┤
항목3 ──────────┤
...             ├──────────┘
항목10 ─────────┘
항목11 ──────────┐
항목12 ──────────┤
...              ├──────────┘
항목20 ──────────┘
...
                                                   총 1.2초

6. 베스트 프랙티스

6.1 스레드 풀 크기 결정

스레드 풀 크기는 다음과 같은 공식을 참고할 수 있습니다:

// I/O 바운드 작업의 경우
스레드 수 = CPU 코어 수 × (1 + I/O 대기 시간 / CPU 처리 시간)

// 예시: CPU 코어 4개, I/O 대기 90%, CPU 처리 10%
스레드 수 = 4 × (1 + 0.9 / 0.1) = 4 × 10 = 40

// 일반적인 데이터베이스 쿼리의 경우
스레드 수 = 10 ~ 20 (과도하게 많으면 오히려 성능 저하)

6.2 예외 처리

  • 개별 작업의 예외: 각 비동기 작업 내에서 예외를 처리하여 하나의 실패가 전체를 막지 않도록 함
  • 타임아웃 설정: CompletableFuture.get(timeout, TimeUnit.SECONDS) 사용으로 무한 대기 방지

6.3 리소스 관리

  • 반드시 shutdown 호출: finally 블록에서 executor.shutdown() 호출
  • 재사용 고려: 매번 새로운 ExecutorService를 생성하지 않고, 애플리케이션 레벨에서 공유하는 것을 고려

6.4 주의사항

  • 데이터베이스 연결 풀: 병렬 처리 시 동시 연결 수가 증가하므로 DB 연결 풀 크기를 충분히 설정해야 함 (예: 최소 20개 이상)
  • 메모리 사용량: 모든 결과를 메모리에 보관하므로 대용량 데이터의 경우 주의 필요 (스트리밍 처리 고려)
  • 트랜잭션: 각 쿼리가 독립적인 트랜잭션이어야 함 (공유 트랜잭션은 병렬 처리 불가)
  • 에러 핸들링: 일부 작업이 실패해도 나머지 작업은 계속 진행되도록 각 작업 내부에서 예외를 처리해야 함

7. 결론

핵심 요약

  1. 문제: 순차 처리로 인한 긴 대기 시간 (12초)
  2. 해결: CompletableFuture와 ExecutorService를 활용한 병렬 처리
  3. 결과: 90% 성능 개선 (12초 → 1.2초)

적용 가능한 시나리오

  • ✅ 독립적인 여러 쿼리를 실행해야 하는 경우
  • ✅ I/O 바운드 작업 (데이터베이스 쿼리, API 호출 등)
  • ✅ 각 작업이 서로 의존성이 없는 경우
  • ✅ 순서가 중요하지 않은 경우

적용 불가능한 시나리오

  • ❌ 작업 간 의존성이 있는 경우
  • ❌ 순서가 중요한 경우
  • ❌ 공유 트랜잭션이 필요한 경우
  • ❌ CPU 바운드 작업 (이 경우 parallelStream 고려)

마무리

병렬 처리는 성능 개선의 강력한 도구이지만, 올바르게 사용해야 합니다. 스레드 풀 크기, 예외 처리, 리소스 관리 등을 신중히 고려하여 구현하면 10배 이상의 성능 향상을 달성할 수 있습니다.

이번 사례에서는 단순히 순차 처리를 병렬 처리로 변경하는 것만으로도 90%의 성능 개선을 달성할 수 있었습니다. 여러분의 프로젝트에서도 비슷한 병목 지점을 찾아 병렬 처리로 개선해보시기 바랍니다.

반응형