Java에서 고성능 애플리케이션을 개발하려면 멀티스레드(Multi-thread) 개념을 이해해야 한다.
스레드를 직접 생성하고 관리하는 일은 복잡하고 비용이 크기 때문에,
ExecutorService, ThreadPool, CountDownLatch 등 다양한 API를 함께 사용한다.
1. 멀티스레드
스레드(Thread)는 프로그램의 실행 흐름 단위이다.
멀티스레드란 이 실행 흐름을 여러 개 만들어 여러 작업을 동시에 실행하는 구조를 의미한다.
예:
- 웹 서버: 사용자 요청마다 새로운 스레드 처리
- 게임 서버: 몬스터 AI 스레드 + 네트워크 IO 스레드
- 병렬 계산: 여러 CPU 코어에서 계산 작업을 나눠 처리
단순 Thread 생성 방식
Thread thread = new Thread(() -> {
System.out.println("작업 실행");
});
thread.start();
문제점:
- 스레드를 직접 만들면 비용이 비싸고
- 너무 많이 만들면 OOM(OutOfMemory) 위험이 있고
- 스레드 수를 직접 관리하기 어렵다.
=> 스레드 풀(ThreadPool) 기반의 ExecutorService를 사용할 수 있다.
2. ThreadPool — 스레드를 재사용하는 구조
ThreadPool은 “미리” 일정 개수의 스레드를 만들어 두고,
작업이 들어올 때마다 스레드를 재사용하는 구조이다.
장점
- 스레드 생성 비용 절감
- 과도한 스레드 생성 방지
- 안정적인 성능 확보
- 여러 개의 작업을 큐(queue)에 넣고 효율적으로 배분
대부분 병렬 처리는 ThreadPool 기반으로 구성한다.
3. Executors — 스레드풀 생성 팩토리
Executors는 다양한 형태의 스레드 풀을 쉽게 만들도록 돕는 유틸 클래스이다.
3-1. 고정 크기 스레드풀 (Fixed Thread Pool)
ExecutorService executor = Executors.newFixedThreadPool(5);
- 항상 5개의 스레드를 유지
- CPU보다 I/O가 많은 경우 적합
3-2. 캐시 스레드풀 (Cached Thread Pool)
ExecutorService executor = Executors.newCachedThreadPool();
- 필요한 만큼 스레드를 만들고, 일정 시간 지나면 자동 종료
- 매우 빠른 단발성 task 처리에 적합
- burst traffic 처리에 유용
3-3. 단일 스레드풀 (Single Thread Executor)
ExecutorService executor = Executors.newSingleThreadExecutor();
- 스레드 1개만 사용
- 작업 순서를 보장해야 할 때 사용
4. ExecutorService — 스레드풀을 제어하는 핵심 인터페이스
ExecutorService는 스레드 풀을 제어하고, 작업을 실행할 수 있는 상위 인터페이스이다.
작업 제출 방법
4-1. 실행만 하고 결과가 필요 없을 때 — Runnable
executor.submit(() -> {
System.out.println("작업 실행");
});
4-2. 결과가 필요할 때 — Callable
Future<Integer> result = executor.submit(() -> {
return 10 + 20;
});
System.out.println(result.get()); // 30
스레드풀 종료
executor.shutdown(); // graceful shutdown
// executor.shutdownNow(); // 즉시 종료 시도
5. CountDownLatch — 병렬 작업의 “동기화 도구”
멀티스레드를 사용하면 “여러 스레드가 끝날 때까지 기다렸다가 다음 로직 실행”이 필요할 때가 있다.
이때 사용하는 대표적인 동기화 도구가 CountDownLatch이다.
역할
- 여러 스레드에 작업을 맡긴 뒤
- 모든 작업이 완료될 때까지 기다려야 할 때 사용
- 스레드 간 동기화를 위한 단순하고 강력한 메커니즘
5-1. 예시: 5개의 병렬 작업이 모두 끝날 때까지 기다리기
ExecutorService executor = Executors.newFixedThreadPool(5);
CountDownLatch latch = new CountDownLatch(5);
for (int i = 0; i < 5; i++) {
executor.submit(() -> {
try {
System.out.println("작업 시작");
Thread.sleep(1000);
} catch (Exception ignored) {
} finally {
latch.countDown(); // 작업 1개 완료
}
});
}
latch.await(); // 5개 작업 모두 끝날 때까지 대기
System.out.println("모든 작업 완료!");
executor.shutdown();
CountDownLatch 프로세스
- Latch 카운트는 5
- 스레드 하나 완료할 때마다 countDown() → 4 → 3 → 2 → 1 → 0
- 카운트가 0이 되면 await()가 풀림
- 메인 스레드가 계속 실행됨
6. 병렬 처리 예시
✔ 외부 API 여러 개 병렬로 호출
- 하나씩 API 호출하면 느리다
- 스레드 풀에서 병렬 호출 후
- 모든 응답이 모이면 조합하여 결과 생성
- CountDownLatch 또는 CompletableFuture를 많이 활용
✔ 대용량 데이터 처리 (Batch / ETL)
- 데이터를 여러 덩어리로 나눔
- 각 덩어리를 스레드에서 병렬 처리
- 모든 작업이 끝나면 후처리 실행
✔ 웹 서버에서 비동기 처리
- 이미지 처리
- 메일 전송
- 알림 메시지 발송
I/O 작업을 별도 스레드로 넘겨서 메인 요청 처리 시간을 줄인다.
7. 병렬 처리 시 꼭 알아야 할 주의사항
⚠ 스레드 수는 무조건 많다고 좋은 것이 아니다
- 너무 많으면 context switching 비용 증가
- CPU는 결국 한정됨
- 적절한 스레드풀 사이즈 필요
일반적인 기준:
CPU 바운드 작업: CPU 코어 수 ± 1
I/O 바운드 작업: CPU 코어 수 × 2 ~ 3배
⚠ 스레드풀은 절대 무한하게 만들면 안 된다
- Executors의 일부 메서드는 위험할 수 있다
(CachedThreadPool은 잘못 쓰면 스레드를 무제한 생성할 수 있음)
그래서 실무에서는 다음처럼 직접 ThreadPoolExecutor를 구성하기도 한다.
new ThreadPoolExecutor(
10, // core pool size
20, // max pool size
60, // idle thread timeout
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000)
);
⚠ 동기화 문제 주의
- 여러 스레드가 동시에 공유 자원을 접근하면 Race Condition 가능
- 공유 상태는 반드시 동기화하거나, 아예 상태를 공유하지 않는 구조를 설계해야 한다
| 개념 | 설명 |
| Thread | 작업 실행 단위 |
| ThreadPool | 스레드를 재사용하여 효율적 처리 |
| Executors | 다양한 형태의 스레드풀을 편하게 생성 |
| ExecutorService | 스레드풀 제어 및 작업 제출 |
| Runnable / Callable | 실행할 작업(반환값 유무) 표현 |
| CountDownLatch | 병렬 작업 완료를 기다리는 동기화 도구 |
반응형
'IT > JAVA' 카테고리의 다른 글
| Java 함수형 인터페이스 Supplier, Runnable (0) | 2025.12.07 |
|---|---|
| getOrDefault() vs computeIfAbsent() (0) | 2025.12.06 |
| ReentrantLock 락 제어 (0) | 2025.12.06 |
| Java 예외: RuntimeException (0) | 2025.12.06 |
| 동기화 vs 원자적 연산 (1) | 2025.07.24 |