80. 스레드보다는 실행자, 태스크, 스트림을 애용하라

📌 1. 발표 전 알아야 할 개념

스레드 풀(Thread Pool)

  • 미리 생성한 스레드(일정한 개수)를 재사용하여 작업을 처리하는 구조

  • 매 작업마다 new Thread()를 하지 않고, 풀에 등록된 스레드를 재사용

Q. 사용 이유?

  1. 스레드 생성 비용 감소

  2. 자원 낭비 방지 - 과도한 스레드 생성 방지

  3. 최대 스레드 수를 제한하여 성능 예측 가능

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() : 현재 작업 완료 후 종료 - 새 작업 X

  • shutdownNow() : 즉시 종료 시도 - 실행 중인 작업 중단

  • awaitTermination() : 지정 시간 내 종료 대기

4-3. 데드락 방지

  • 스레드 풀에서 다른 작업의 완료를 기다리는 작업을 제출하면 데드락 발생 가능


👍🏼 향후 발전 포인트

  • ForkJoinPool : 병렬 연산을 최적화 할 수 있다 > 공부를 해봅시다


🤖 최종 결론

스레드를 직접 생성하기 보다는 실행자를 사용하여 작업을 구조화하자


😶‍🌫️ 느낀점

  • 스레드에 대한 개념을 좀 더 공부하고 사용해보자

Last updated