반응형
Java 쿼리 병렬 처리로 성능 개선하기
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의 CompletableFuture와 ExecutorService를 활용하여 여러 쿼리를 동시에 실행하고, 모든 결과를 기다린 후 합치는 방식으로 개선했습니다.
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. 결론
핵심 요약
- 문제: 순차 처리로 인한 긴 대기 시간 (12초)
- 해결: CompletableFuture와 ExecutorService를 활용한 병렬 처리
- 결과: 90% 성능 개선 (12초 → 1.2초)
적용 가능한 시나리오
- ✅ 독립적인 여러 쿼리를 실행해야 하는 경우
- ✅ I/O 바운드 작업 (데이터베이스 쿼리, API 호출 등)
- ✅ 각 작업이 서로 의존성이 없는 경우
- ✅ 순서가 중요하지 않은 경우
적용 불가능한 시나리오
- ❌ 작업 간 의존성이 있는 경우
- ❌ 순서가 중요한 경우
- ❌ 공유 트랜잭션이 필요한 경우
- ❌ CPU 바운드 작업 (이 경우 parallelStream 고려)
마무리
병렬 처리는 성능 개선의 강력한 도구이지만, 올바르게 사용해야 합니다. 스레드 풀 크기, 예외 처리, 리소스 관리 등을 신중히 고려하여 구현하면 10배 이상의 성능 향상을 달성할 수 있습니다.
이번 사례에서는 단순히 순차 처리를 병렬 처리로 변경하는 것만으로도 90%의 성능 개선을 달성할 수 있었습니다. 여러분의 프로젝트에서도 비슷한 병목 지점을 찾아 병렬 처리로 개선해보시기 바랍니다.
반응형
'AI 수익화 실전' 카테고리의 다른 글
| 2026년 구글 애드센스 승인, AI 글로 가능할까? 최신 정책과 합격 전략 (0) | 2026.01.27 |
|---|---|
| 초보 블로거도 할 수 있는 AI 키워드 조사 방법 (네이버·구글 둘 다 잡기) (0) | 2026.01.27 |
| 마비노기 모바일 초대코드 - 초대 받은 사람 프리미엄 패션 티켓 패키지 10개 줌 (1) | 2025.09.16 |
| 넥슨 15분 자리비움, 근무시간 산정의 현실과 오토마우스 활용법 (1) | 2025.09.12 |
| 2025년 핫한 다이어트 트렌드 완전정복 - MZ 건강법 💪 (0) | 2025.09.04 |