java.util.concurrent
패키지에는 인터페이스 기반의 유연한 태스크 실행 기능을 제공하는 실행자 프레임워크(Executor Framework)가 있다. 이 프레임워크는 스레드와 작업 큐를 직접 다룰 필요 없이 효율적으로 태스크를 관리하고 실행할 수 있게 도와준다.
과거에는 단순한 작업 큐를 만들기 위해 수많은 코드를 작성해야 했지만, 실행자 프레임워크를 사용하면 아래와 같이 간단하게 작업 큐를 생성하고 사용할 수 있다.
이전에 단순한 작업 큐(Work queue)의 경우,
그 클래스는 클라이언트가 요청한 작업을 백그라운드 스레드에 위임해 비동기적으로 처리해줬다. 작업 큐가 필요 없어지면 클라이언트는 큐에 중단을 요청할 수 있고, 그러면 큐는 남아 있는 작업을 마저 완료한 후 스스로 종료한다. 예시용 의 간단한 코드였지만 책 한 페이지를 가득 메웠는데, 안전 실패나 웅답 불가가 될 여지를 없애는 코드를 추가해야 했기 때문이다.
// 큐를 생성한다.
ExecutorService exec = Executors.newSingleThreadExecutor();
// 태스크 실행
exec.execute(runnable);
// 실행자 종료
exec.shutdown();
이 단순한 코드 한 줄로도 안정적이고 신뢰할 수 있는 작업 큐를 생성할 수 있으며, 작업의 종료 시점까지의 관리도 가능하다.
submit()
메서드와 get()
을 사용하면 특정 태스크의 완료를 기다릴 수 있다. 이 메서드는 결과를 반환하며, 태스크가 끝날 때까지 호출자는 대기 상태에 놓이게 된다.
ExecutorService exec = Executors.newSingleThreadExecutor();
exec.submit(() -> System.out.println("Task Done")).get(); // 끝날 때까지 기다린다.
이 방법은 주로 결과를 필요로 하는 작업이나 반드시 완료되어야 하는 선행 작업이 있을 때 유용하다. 하지만 대기 시간이 길어질 경우, 애플리케이션의 응답성이 저하될 수 있으므로 주의가 필요하다.
실행자 프레임워크는 여러 개의 태스크를 동시에 실행하고 결과를 관리할 수 있는 메서드를 제공한다.
모든 태스크의 완료를 기다리기 (invokeAll
)
invokeAll()
은 제출된 모든 태스크가 완료될 때까지 기다린다.
List<Callable<String>> tasks = Arrays.asList(
() -> "Task 1",
() -> "Task 2"
);
List<Future<String>> futures = exec.invokeAll(tasks);
System.out.println("All Tasks done");
태스크 중 하나라도 완료되면 반환 (invokeAny
)
invokeAny()
는 여러 태스크 중 가장 먼저 완료된 태스크의 결과만 반환하고 나머지 태스크는 취소한다.
String result = exec.invokeAny(tasks);
System.out.println("Any Task done: " + result);
이 방법은 실행 시간이 불확실한 태스크에서 유용하게 사용된다.
awaitTermination()
을 사용하면 실행자 서비스가 종료될 때까지 대기할 수 있다. 특정 시간 동안만 대기할 수도 있다.
Future<String> future = exec.submit(() -> "Task Result");
exec.shutdown();
exec.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("Executor terminated");
이 방식은 실행자 서비스의 종료를 명확하게 제어할 수 있으므로 리소스를 효과적으로 관리할 수 있다.
ExecutorCompletionService
는 태스크가 완료된 순서대로 결과를 반환한다. 이는 태스크 완료 시점을 예측할 수 없는 상황에서 매우 유용하다.
final int MAX_SIZE = 3;
ExecutorService executorService = Executors.newFixedThreadPool(MAX_SIZE);
ExecutorCompletionService<String> ecs = new ExecutorCompletionService<>(executorService);
// 태스크 제출
List<Future<String>> futures = new ArrayList<>();
futures.add(ecs.submit(() -> "Task 1"));
futures.add(ecs.submit(() -> "Task 2"));
futures.add(ecs.submit(() -> "Task 3"));
// 완료된 결과 받기
for (int i = 0; i < MAX_SIZE; i++) {
try {
String result = ecs.take().get();
System.out.println(result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
executorService.shutdown();
ScheduledThreadPoolExecutor
는 특정 시간 이후 또는 주기적으로 태스크를 실행한다.
지정 시간 이후 태스크 실행
ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1);
executor.schedule(() -> System.out.println("Task after delay"), 5, TimeUnit.SECONDS);
주기적으로 태스크 실행
executor.scheduleAtFixedRate(() -> {
System.out.println(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.format(LocalDateTime.now()));
}, 0, 2, TimeUnit.SECONDS);
출력 예시:
2019-09-30 23:11:22
2019-09-30 23:11:24
2019-09-30 23:11:26
...
큐를 둘 이상의 스레드가 처리하게 하고 싶다면 간단히 다른 정적 팩터 리를 이 용하여 다른 종류의 실행자 서비스(스레드 풀)를 생성하면 된다.
스레드 풀의 스레드 개수는 고정할 수도 있고 필요에 따라 늘어나거나 줄어들게 설정할 수 도 있다. 여러분에게 필요한 실행자 대부분은 java.util.concurrent.Executors 의 정적 팩터리들을 이용해 생성할 수 있을 것이다. 평범하지 않은 실행자를 원한다면 ThreadPoolExecutor 클래스
를 직접 사용해도 된다. 이 클래스로는 스 레드 풀 동작을 결정하는 거의 모든 속성을 설정할 수 있다.
- 작은 프로그 램이나 가벼운 서버
- 특별히 설정할 게 없고 일반적인 용도에 적합하게 동작한다.
- CachedThreadPool은 무거운 프로덕션 서버에는 좋지 못하다!
- Cached ThreadPool에서는 요청받은 태스크들이 큐에 쌓이지 않고 즉시 스레드에 위 임 돼 실행된다.
가용한 스레드가 없다면 새로 하나를 생성한다. 서버가 아주 무 겁다면 CPU 이용률이 100%로 치닫고, 새로운 태스크가 도착하는 족족 또 다 른 스레드를 생성하며 상황을 더욱 악화시킨다
- 가벼운 작업에 적합하며 스레드를 유연하게 재사용한다.
- 큐를 사용하지 않고 스레드를 즉시 할당하며, 가용 스레드가 없으면 새 스레드를 생성한다.
- 무거운 서버에서는 스레드 수가 급격히 늘어날 수 있으므로 주의해야 한다.
- 스레드 개수를 고정해 CPU 자원 낭비를 최소화한다.
- 무거운 프로덕션 서버에 적합하며 예측 가능한 성능을 보장한다.
- 스레드 풀의 동작을 세부적으로 제어할 수 있다.
- 작업 큐, 스레드 생성 정책 등을 필요에 맞게 커스터마이징할 수 있다.
java.util.concurrent
패키지의 실행자 프레임워크(Executor Framework)는 스레드 관리와 작업 실행을 효율적으로 분리하고 관리하는 기능을 제공한다. 이를 통해 스레드를 직접 다루는 복잡성을 줄이고, 태스크 실행을 유연하게 관리할 수 있다.
스레드를 직접 다루면 Thread
가 작업 단위와 실행 메커니즘을 모두 담당하게 되어 코드가 복잡해진다. 반면, 실행자 프레임워크를 사용하면 작업 단위와 실행 메커니즘이 분리된다.
- 작업 단위를 나타내는 핵심 추상 개념이 바로 태스크(Task)이다.
- 태스크에는 두 가지 유형이 있다:
Runnable
: 값을 반환하지 않는 태스크Callable
: 값을 반환하며 예외를 던질 수 있는 태스크
- 태스크를 수행하는 일반적인 메커니즘이 실행자 서비스(ExecutorService)이다.
- 실행자 서비스에 태스크를 넘기면 수행 정책을 유연하게 선택할 수 있다.
- 필요에 따라 언제든지 태스크 수행 방식을 변경할 수 있다.
핵심: 컬렉션 프레임워크가 데이터 관리를 담당하듯, 실행자 프레임워크는 작업 수행을 담당한다.
자바 7부터 실행자 프레임워크는 포크-조인(Fork-Join) 기능을 지원하도록 확장되었다.
- 큰 작업을 여러 개의 작은 하위 태스크로 나누어 실행할 수 있다.
- 태스크를 포크(Fork) 하여 나눈 후, 완료된 결과를 조인(Join) 하는 방식으로 동작한다.
- 포크-조인 태스크를 실행하기 위해 ForkJoinPool이라는 특별한 실행자 서비스가 사용된다.
- 워크 스틸링(Work-Stealing) 기법을 사용하여 효율적으로 태스크를 분산 처리한다.
- 먼저 작업을 끝낸 스레드는 다른 스레드의 남은 작업을 가져와 처리한다.
- 모든 스레드가 바쁘게 움직여 CPU 활용도를 극대화한다.
- 높은 처리량과 낮은 지연 시간을 달성할 수 있다.
ForkJoinPool pool = new ForkJoinPool();
// 포크-조인 태스크를 실행
pool.invoke(new RecursiveTask<Integer>() {
@Override
protected Integer compute() {
return 1; // 태스크 예제
}
});
- 병렬 스트림(
Stream.parallel()
)은 내부적으로 포크-조인 풀을 사용하여 병렬 작업을 처리한다. - 포크-조인 프레임워크를 직접 작성하는 것은 복잡할 수 있지만, 병렬 스트림을 사용하면 적은 노력으로 그 이점을 얻을 수 있다.
단, 포크-조인 프레임워크는 포크-조인에 적합한 작업 구조에서만 효과적이다.
실행자 프레임워크는 스레드 관리와 태스크 실행을 분리하여 복잡성을 줄이고 효율적으로 작업을 관리할 수 있도록 돕는다.
- 스레드 관리와 작업 단위를 분리하여 코드 유지보수가 용이해진다.
- 다양한 스레드 풀 옵션을 제공해 상황에 맞게 선택할 수 있다.
- 포크-조인 프레임워크를 통해 CPU를 최대한 활용하는 병렬 작업이 가능하다. 큰 작업을 나누어 병렬로 처리하고 CPU 활용도를 극대화한다.
- 태스크의 유형:
Runnable
과Callable
을 사용해 작업을 정의하고 실행자 서비스에 전달한다.4102..102 - 스레드를 직접 다루지 말자: 실행자 프레임워크를 사용하면 작업 단위와 실행 메커니즘이 분리되어 유지보수성과 유연성이 높아진다.
이외에도 실행자 프레임워크는 다양한 기능을 제공하며, 필요에 따라 정책과 실행 방식을 유연하게 조정할 수 있다. 스레드를 직접 다루는 대신 실행자 프레임워크를 사용하여 더 안전하고 효율적인 프로그램을 작성하자.
더 깊이 있는 내용은 『자바 병렬 프로그래밍』 (Goetz, 2008)을 참고하기 바란다.