Java ExecutorService

 

목차

     

     

    Java의 멀티스레드 프로그래밍은 강력한 기능이지만, 각 작업마다 Thread를 직접 생성하고 관리하는 방식은 유지보수나 성능 측면에서 분명한 한계가 있다. 이런 문제를 더 간단하고 효율적으로 처리할 수 있도록 도와주는 도구가 바로ExecutorService이다.

    이 글에서는 ExecutorService의 기본 개념부터 활용 방식, 스레드 풀의 종류, 주요 메서드, 실전 예제, 사용 시 주의사항까지 종합적으로 정리해보려 한다.

     

    1. ExecutorService란?

    ExecutorService는 Java에서 제공하는 스레드 풀 기반 작업 실행 인터페이스다. 단순히 Thread를 직접 생성하고 실행하는 방식보다 더 안정적이고 효율적으로 다수의 작업을 병렬 처리할 수 있도록 도와준다.

     

    왜 ExecutorService를 써야 할까?

     

    • 스레드 재사용: 매번 새로운 스레드를 생성하지 않고 기존 스레드를 재사용하여 자원을 절약
    • 작업 큐 지원: 요청이 많을 경우 작업을 큐에 쌓아 순차적으로 처리 가능
    • 스레드 생명주기 관리: 시작부터 종료까지 명확하게 제어 가능

     

    2. 스레드 풀(Thread Pool) 이해

    스레드 풀은 작업 요청이 들어올 때마다 새로 스레드를 생성하는 방식이 아니라, 미리 생성해둔 스레드를 반복적으로 재사용하는 구조다. 이 구조는 불필요한 리소스 낭비를 줄이고, 다수의 작업을 안정적으로 처리할 수 있게 도와준다.

     

    장점

     

    • 자원 절약: 스레드를 반복 생성/제거하지 않아 메모리와 CPU 부담이 적다.
    • 성능 향상: 스레드 생성 비용이 없기 때문에 응답 속도가 빨라진다.
    • 제어 가능성: 최대 동시 작업 수를 제한할 수 있어 예측 가능한 시스템 운영이 가능하다.
    • 코드 관리 용이: 중앙 집중식 스레드 관리를 통해 유지보수와 디버깅이 쉬워진다.

     

    3. 생성과 종류

    Java는 Executors 유틸리티 클래스를 통해 다양한 스레드 풀을 제공한다.

    ExecutorService executor = Executors.newFixedThreadPool(4);
    종류 설명
    newFixedThreadPool(n) 고정된 수의 스레드를 사용하는 풀
    newCachedThreadPool() 필요한 만큼 스레드를 생성하고, 사용되지 않으면 제거
    newScheduledThreadPool(n) 지연 실행 또는 주기적 작업에 적합
    newSingleThreadExecutor() 단일 스레드로 순차적 작업 처리 보장

     

    4. 주요 메서드

    ExecutorService는 다양한 상황에 맞춰 작업을 제출하고 제어할 수 있도록 여러 메서드를 제공한다. 각각의 메서드는 용도와 반환값이 다르기 때문에, 목적에 맞게 선택하여 사용하는 것이 중요하다.

     

    execute(Runnable command)

    • 가장 단순한 작업 제출 방식으로, Runnable을 인자로 받아 실행한다.
    • 반환값이 없으며, 예외가 발생해도 외부에서 확인할 방법이 없다.
    executor.execute(() -> System.out.println("Runnable 실행"));

     

    submit()

    • Runnable 또는 Callable을 인자로 받아 실행하며, Future 객체를 반환한다.
    • Future를 통해 작업의 완료 여부 확인, 결과 조회, 취소 등이 가능하다.
    Future<String> result = executor.submit(() -> "Callable 결과");
    System.out.println(result.get()); // 결과 출력

     

    invokeAll(Collection<? extends Callable>)

    • 여러 개의 Callable 작업을 한꺼번에 실행하고, 모든 작업이 끝날 때까지 대기한 후 List<Future>를 반환한다.
    • 병렬 처리된 결과들을 순서대로 가져올 수 있다.
    List<Callable<String>> tasks = Arrays.asList(
        () -> "첫 번째", () -> "두 번째", () -> "세 번째"
    );
    List<Future<String>> results = executor.invokeAll(tasks);
    for (Future<String> f : results) {
        System.out.println(f.get());
    }

     

    invokeAny(Collection<? extends Callable>)

    • 여러 개의 Callable 작업 중 가장 먼저 완료된 하나의 결과만 반환한다.
    • 나머지 작업은 취소되며, 빠르게 응답만 필요할 때 유용하다.
    String fastest = executor.invokeAny(tasks);
    System.out.println("가장 빠른 결과: " + fastest);

     

    shutdown() / shutdownNow()

    • shutdown()은 더 이상 새로운 작업을 받지 않고, 대기 중인 작업까지 모두 마친 후 종료한다.
    • shutdownNow()는 실행 중인 작업도 중단을 시도하며, 가능한 빨리 종료를 유도한다.
    executor.shutdown();
    if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
        executor.shutdownNow();
    }

     

    awaitTermination()

    • 지정한 시간 동안 ExecutorService의 종료를 기다린다.
    • 반환값이 false이면 여전히 종료되지 않은 상태이므로 추가 조치가 필요하다.

     

    5. 실전 예제

    각 스레드 풀 생성 방식에 따라 어떻게 동작이 다른지 이해할 수 있도록, 대표적인 팩토리 메서드 4가지를 각각 사용하는 예제를 아래에 정리했다.

     

    newFixedThreadPool

    ExecutorService executor = Executors.newFixedThreadPool(2);
    for (int i = 1; i <= 4; i++) {
        int taskId = i;
        executor.execute(() -> {
            System.out.println("[Fixed] 작업 " + taskId + " 실행: " + Thread.currentThread().getName());
        });
    }
    executor.shutdown();
    [Fixed] 작업 1 실행: pool-1-thread-1
    [Fixed] 작업 2 실행: pool-1-thread-2
    [Fixed] 작업 3 실행: pool-1-thread-1
    [Fixed] 작업 4 실행: pool-1-thread-2

     

    newCachedThreadPool

    ExecutorService executor = Executors.newCachedThreadPool();
    for (int i = 1; i <= 4; i++) {
        int taskId = i;
        try {
            Thread.sleep(100); // 작업 간 시간 차이로 동시 실행 유도
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        executor.execute(() -> {
            System.out.println("[Cached] 작업 " + taskId + " 실행: " + Thread.currentThread().getName());
        });
    }
    executor.shutdown();
    [Cached] 작업 1 실행: pool-1-thread-1
    [Cached] 작업 2 실행: pool-1-thread-2
    [Cached] 작업 3 실행: pool-1-thread-3
    [Cached] 작업 4 실행: pool-1-thread-4

     

    newSingleThreadExecutor

    ExecutorService executor = Executors.newSingleThreadExecutor();
    for (int i = 1; i <= 4; i++) {
        int taskId = i;
        executor.execute(() -> {
            try {
                Thread.sleep(500); // 각 작업이 순차적으로 실행되도록 명확히 함
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            System.out.println("[Single] 작업 " + taskId + " 실행: " + Thread.currentThread().getName());
        });
    }
    executor.shutdown();
    [Single] 작업 1 실행: pool-1-thread-1
    [Single] 작업 2 실행: pool-1-thread-1
    [Single] 작업 3 실행: pool-1-thread-1
    [Single] 작업 4 실행: pool-1-thread-1

     

    newScheduledThreadPool

    ScheduledExecutorService executor = Executors.newScheduledThreadPool(3);
    
    // 5초 후 시작해서 이전 작업 종료 후 10초마다 실행 (작업 자체가 4초 걸림)
    executor.scheduleWithFixedDelay(() -> {
        System.out.println("[Scheduled] 5초 후 시작, 10초 지연 실행 시작: " + LocalTime.now() + " - " + Thread.currentThread().getName());
        try {
            Thread.sleep(4000); // 작업 자체가 오래 걸림
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println("[Scheduled] 지연 작업 종료: " + LocalTime.now());
    }, 5, 10, TimeUnit.SECONDS);
    
    // 즉시 시작해서 3초마다 고정 주기로 실행
    executor.scheduleAtFixedRate(() -> {
        System.out.println("[Scheduled] 즉시 시작, 3초 주기 실행: " + LocalTime.now() + " - " + Thread.currentThread().getName());
    }, 0, 3, TimeUnit.SECONDS);
    
    // 30초 후 종료 예약
    executor.schedule(() -> {
        System.out.println("30초 후 shutdown 호출");
        executor.shutdown();
    }, 30, TimeUnit.SECONDS);
    [Scheduled] 즉시 시작, 3초 주기 실행: 12:00:00 - pool-1-thread-1
    [Scheduled] 즉시 시작, 3초 주기 실행: 12:00:03 - pool-1-thread-1
    [Scheduled] 5초 후 시작, 10초 지연 실행 시작: 12:00:05 - pool-1-thread-2
    [Scheduled] 지연 작업 종료: 12:00:09
    [Scheduled] 즉시 시작, 3초 주기 실행: 12:00:06 - pool-1-thread-1
    [Scheduled] 즉시 시작, 3초 주기 실행: 12:00:09 - pool-1-thread-1
    [Scheduled] 즉시 시작, 3초 주기 실행: 12:00:12 - pool-1-thread-1
    [Scheduled] 즉시 시작, 3초 주기 실행: 12:00:15 - pool-1-thread-1
    [Scheduled] 5초 후 시작, 10초 지연 실행 시작: 12:00:19 - pool-1-thread-2
    [Scheduled] 지연 작업 종료: 12:00:23
    [Scheduled] 즉시 시작, 3초 주기 실행: 12:00:18 - pool-1-thread-1
    [Scheduled] 즉시 시작, 3초 주기 실행: 12:00:21 - pool-1-thread-1
    [Scheduled] 즉시 시작, 3초 주기 실행: 12:00:24 - pool-1-thread-1
    30초 후 shutdown 호출

     

    6. 사용 시 주의할 점

     

    항목 설명
    shutdown() 호출 누락 프로그램이 계속 종료되지 않거나 리소스가 해제되지 않을 수 있음. 반드시 shutdown() 또는 shutdownNow()를 호출해야 함.
    예외 처리 Runnable은 예외를 반환하지 않기 때문에 내부에서 직접 예외 처리를 해야 함. 그렇지 않으면 예외가 무시되어 디버깅이 어려움.
    ThreadLocal 스레드 풀에서 스레드가 재사용되므로, ThreadLocal 값을 명시적으로 초기화하지 않으면 이전 작업의 값이 남아 있을 수 있음.
    과도한 스레드 생성 newCachedThreadPool()은 제한 없이 스레드를 생성하므로, 과도한 요청 시 시스템 자원이 고갈될 수 있음.
    작업 무한 대기 Future.get() 호출 시 작업이 끝나지 않으면 무기한 블로킹될 수 있음. 타임아웃 설정을 고려해야 함.
    스레드 풀 설정 미흡 기본 설정만 사용하는 경우 병목이나 과도한 큐 대기가 발생할 수 있으므로, 필요에 따라 ThreadPoolExecutor를 직접 구성하는 것이 좋음.

     

    마치며

    ExecutorService는 단순히 Thread를 대체하는 수준이 아니라, 다양한 방식으로 작업을 분산하고 관리할 수 있는 구조적인 도구다. 스레드 풀을 직접 구성하거나, 기본 제공되는 팩토리를 쓰는 것만으로도 꽤 유연한 멀티스레드 환경을 만들 수 있다.

     

    공부하면서 느끼는 건, 단순히 어떤 메서드가 있다는 걸 아는 것보다도 그게 어떤 상황에서 적절한지 판단할 수 있는 게 더 중요하다는 점이다. FixedThreadPool은 안정적이지만 유휴 스레드가 생길 수 있고, CachedThreadPool은 빠르지만 과하게 스레드가 생성될 수 있다. SingleThreadExecutor는 순차 작업에 유용하고, ScheduledThreadPool은 주기 작업을 처리할 때 확실히 편하다. 단순히 작업을 던지는 것에서 끝나는 게 아니라, 어떻게 종료시킬지, 예외는 어디서 잡을지, 이런 부분까지 챙겨야 제대로 사용하는 거라 생각한.

     

    완벽하게 익숙하진 않아도, 이제는 어떤 작업에 어떤 풀을 쓰면 좋을지, 어떤 방식으로 종료를 처리해야 안전할지 정도는 고민하게 된다. 앞으로 멀티스레드 작업이 필요한 상황이 오면, ExecutorService는 꽤 든든한 선택지가 될 것 같다.