80. 스레드보다는 실행자, 태스크, 스트림을 애용하라
📌 1. 발표 전 알아야 할 개념
스레드 풀(Thread Pool)
미리 생성한 스레드(일정한 개수)를 재사용하여 작업을 처리하는 구조
매 작업마다
new Thread()
를 하지 않고, 풀에 등록된 스레드를 재사용
Q. 사용 이유?
스레드 생성 비용 감소
자원 낭비 방지 - 과도한 스레드 생성 방지
최대 스레드 수를 제한하여 성능 예측 가능
Runnable
Runnable
은 반환값 없이 실행만 하는 작업을 정의할 때 사용오직
run()
메서드만 가지고 있으며, 예외를 던질 수 없음Thread나 ExecutorService로 실행할 수 있음
예외 처리 불가
오래전부터 사용되던 방식(Java 1.0)
Runnable task = () -> {
System.out.println("작업 실행 중");
};
executor.execute(task);
Callable
Callable<T>
은 결과를 반환하는 작업을 정의할 때 사용call()
메서드를 통해 값을 반환하고 예외를 던질 수 있음ExecutorService.submit()을 통해 실행하며, Future를 반환받음
Callable<Integer> task = () -> {
return 10 + 20;
};
Future<Integer> result = executor.submit(task);
System.out.println(result.get()); // 30
📕 2. 스레드를 직접 만들지 마라
2-1. Thread의 사용과 한계
Thread t = new Thread(() -> {
doWork();
});
t.start();
직접 스레드를 생성하고 관리해야 함
new Thread()
는 여러 개 만들면 자원을 과도하게 소비함예외가 발생해도 잡히지 않는 경우가 있다
작업 큐와 병렬 실행 제어가 어려워 확장성과 안정성이 낮다
=> 요청이 많아질수록 스레드 수 관리가 어렵고, 예외 처리와 종료 관리가 복잡하고, 자원 사용이 최적화되지 않는다
2-2. Executor를 사용하라
🔧 Executor
작업(Runnable)을 실행하는 역할을 하는 인터페이스
new Thread()
를 직접 만들지 않고, 작업만 넘기면 대신 실행해주는 구조
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.execute(() -> {
doWork();
});
executor.shutdown(); // 꼭 종료해야 함!
작업을 스레드에 직접 할당하는 대신 큐에 넣고 처리함
실행자(Executor)가 스레드 수를 관리해줌
다양한 종류의 풀 전략이 내장되어 있어, 유연한 확장이 가능함
(주의) ExecutorService 사용 시 꼭
shutdown()
이나shutdownNow()
를 호출해 스레드를 종료시켜줘야 한다
주요 Execuor 구현 클래스
newSingleThreadExecutor()
단일 스레드로 순차 실행
순서를 보장해야 할 때
newFixedThreadPool(n)
n개의 스레드 고정 풀
처리량이 안정적일 때
newCachedThreadPool()
요청마다 스레드 생성 (유휴 풀 유지)
짧고 많은 요청, 빠른 반응
newScheduledThreadPool()
지연/주기 작업 실행
예약 작업 처리
ForkJoinPool
작업 분할/병합 기반 병렬 처리
대규모 데이터 병렬 연산
2-3. Task란?
태스트
= 실행할 작업 단위Runnable이나 Callable 자체가 태스크의 구현체
Executor는 Runnable이나, Callable을 태스크로 받아 내부에서 실행해주는 구조
2-4. 단순 반복 작업은 스트림(Stream)을 활용하자
병렬 작업이라고 반드시 Executor를 사용해야 하는 것은 아님
반복적으로 처리, 단순 작업이면 Stream을 사용하자
특히 앞전에 언급되었던
parallelStream()
을 사용하면 병렬 처리 가능
List<String> names = List.of("Alice", "Bob", "Charlie");
names.parallelStream()
.forEach(name -> System.out.println(name + " 처리 중"));
3. Future와 CompletableFuture
3-1. Future
비동기 작업의 결과를 나타내는 인터페이스
ExecutorService로 작업을 제출하면, Future를 받고 작업결과를 기다리거나 관리할 수 있음음
get()
메서드로 결과를 얻을 수 있고, 작업이 완료될 때까지 블로킹타임아웃 설정, 작업 취소 등의 기능을 제공함
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(1000);
return "작업 완료";
});
try {
String result = future.get(2, TimeUnit.SECONDS); // 최대 2초 대기
System.out.println(result);
} catch (TimeoutException e) {
future.cancel(true); // 작업 취소
}
executor.shutdown();
3-2. CompletableFuture
Java 8에서 도입된 향상된 Future 구현체
콜백 기반의 비동기 프로그래밍 지원
작업 완료 시 다른 작업을 연결하는 체이닝 기능
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return "첫 번째 작업";
}).thenApply(result -> {
return result + " → 두 번째 작업";
}).thenApply(result -> {
return result + " → 세 번째 작업";
});
System.out.println(future.get()); // 첫 번째 작업 → 두 번째 작업 → 세 번째 작업
4. 실행자 사용 시 주의 사항
4-1. 스레드 풀 크기 설정
CPU 바운드 작업 : 코어 수 +1 정도의 스레드 수 권장
I/O 바운드 작업 : 코어 수보다 많은 스레드 사용 가능
4-2. 스레드 풀의 생명주기 관리
어플리케이션 종료 전
ExecutorService
종료 필수!shutdown()
: 현재 작업 완료 후 종료 - 새 작업 XshutdownNow()
: 즉시 종료 시도 - 실행 중인 작업 중단awaitTermination()
: 지정 시간 내 종료 대기
4-3. 데드락 방지
스레드 풀에서 다른 작업의 완료를 기다리는 작업을 제출하면 데드락 발생 가능
👍🏼 향후 발전 포인트
ForkJoinPool
: 병렬 연산을 최적화 할 수 있다 > 공부를 해봅시다
🤖 최종 결론
스레드를 직접 생성하기 보다는 실행자를 사용하여 작업을 구조화하자
😶🌫️ 느낀점
스레드에 대한 개념을 좀 더 공부하고 사용해보자
Last updated