ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Java] 모던 자바 스레딩의 시작: ExecutorService와 Future
    Java 2025. 12. 1. 16:26

    1. 레거시(Legacy)의 한계: 왜 new Thread()는 위험한가?

    실무 환경, 특히 높은 트래픽을 처리하는 백엔드 서버에서 new Thread().start()를 사용하여 온디맨드(On-demand)로 스레드를 생성하는 패턴은 시스템의 안정성을 해치는 안티 패턴(Anti-Pattern)입니다.

    그 이유는 단순히 객체를 생성하는 비용 때문이 아니라, 운영체제(OS) 리소스와의 1:1 매핑 구조에서 생기는 자원의 한계 때문입니다.

    1) 리소스 고갈 (Resource Exhaustion)

    ① Native Thread 생성 비용과 Latency

    Java의 스레드(HotSpot VM 기준)는 운영체제의 네이티브 스레드(Native Thread)와 1:1로 매핑됩니다. (Java 21 Virtual Thread 제외) 즉, new Thread().start()를 호출하면 JVM 내부에서 끝나는 것이 아니라, JNI(Java Native Interface)를 통해 OS 커널에 시스템 콜(System Call)을 요청합니다.

    • 비용: 커널 레벨에서 스레드를 생성하고, 초기화(Stack 할당, PC 레지스터 설정 등)하는 작업은 CPU 사이클을 많이 소모합니다.
    • 문제: HTTP 요청이 들어올 때마다 스레드를 생성하면, 실제 비즈니스 로직을 처리하는 시간보다 스레드를 만들고 없애는 오버헤드(Overhead) 시간이 더 길어질 수 있습니다. 이는 응답 지연(Latency)으로 이어집니다.

    ② 메모리 누수와 OutOfMemoryError

    스레드 생성 시 할당되는 메모리는 JVM의 Heap 영역이 아니라 Native Memory 영역의 Stack입니다.

    • 스택 사이즈: Java 스레드는 기본적으로 스레드당 약 1MB의 스택 메모리를 예약합니다.
    • 만약 스레드를 1,000개 생성하면, 힙 메모리와 별개로 약 1GB의 물리 메모리(RAM)를 즉시 점유하게 됩니다.
    • 결과: 힙 메모리에 여유가 있어도, 물리 메모리 부족이나 OS의 프로세스당 스레드 개수 제한(ulimit)에 도달하면 java.lang.OutOfMemoryError: unable to create new native thread가 발생하며 서버가 셧다운됩니다.

    ③ 컨텍스트 스위칭(Context Switching)과 Thrashing

    스레드 수가 CPU 코어(Core) 수보다 지나치게 많아지면 심각한 성능 저하가 발생합니다.

    • 스케줄링 비용: OS 스케줄러는 실행 대기 중인 스레드들에게 CPU 시간을 잘게 쪼개어 분배합니다. 스레드가 많을수록 CPU는 실제 작업보다 "누구를 실행할지 결정하고, 레지스터 상태를 저장/복구하는 작업"에 더 많은 리소스를 쓰게 됩니다.
    • Cache Locality 악화: 잦은 컨텍스트 스위칭은 CPU의 L1, L2 캐시 데이터를 무효화(Cache Miss)시킵니다. 메모리에서 다시 데이터를 읽어와야 하므로 처리 속도가 급격히 느려집니다.
    • 스레싱(Thrashing): 결국 CPU가 작업 처리는 못하고 스레드 교체만 하다가 멈춰버리는 상태에 이르게 됩니다.

    2) 관리의 부재 (Lack of Control)

    ① 스레드 개수 제어 불가

    new Thread() 방식은 생성 개수에 제한이 없습니다. 갑작스러운 트래픽 폭주(Traffic Spike) 시, 요청 개수만큼 스레드가 생성되어 서버의 리소스를 순식간에 고갈시킵니다. 서버가 감당할 수 있는 임계치(Threshold)를 넘어서면 요청을 거절하거나 대기시키는 보호 장치가 필요하지만, 직접 생성 방식은 이를 구현할 수 없습니다.

    ② 라이프사이클 관리의 어려움

    스레드는 생성 후 run() 메서드가 종료되면 사라집니다. 개발자가 직접 스레드 객체를 관리하지 않으면 다음과 같은 문제가 발생합니다.

    • 예외 처리 누락: run() 메서드 내부에서 발생한 언체크 예외(Unchecked Exception)는 적절한 UncaughtExceptionHandler가 없으면 조용히 스레드를 종료시킵니다. 에러 로그조차 남지 않아 디버깅이 불가능해집니다.
    • 좀비 스레드: 작업이 끝나지 않고 행(Hang)이 걸린 스레드를 외부에서 강제로 종료하거나 상태를 모니터링할 방법이 마땅치 않습니다.

    2. 해결책: 스레드 풀 (Thread Pool)과 Executor 프레임워크

    앞서 살펴본 new Thread().start() 방식의 문제점들(리소스 고갈, 컨텍스트 스위칭 오버헤드, 관리의 부재)을 해결하기 위해 Java 5부터는 Executor 프레임워크가 도입되었습니다.

    이 프레임워크의 핵심은 개발자가 더 이상 스레드를 직접 생성하거나 제어하지 않고, "스레드 풀(Thread Pool)"이라는 시스템에 작업을 맡긴다는 점입니다.

    1) 개념 정의

    이 해결책을 이해하기 위해서는 두 가지 핵심 용어, '스레드 풀'과 'Executor 프레임워크'를 명확히 구분해야 합니다.

    ① 스레드 풀 (Thread Pool)

    물리적인 리소스 관리 관점에서의 정의입니다.

     

    스레드 풀(Thread Pool)이란? 작업 처리에 사용되는 스레드를 제한된 개수만큼 미리 생성하여 확보(Pool)해 놓고, 작업 요청이 들어올 때마다 이 스레드들을 재사용하여 작업을 처리하는 프로그래밍 기법입니다.

    • 핵심: 스레드 생성/수거 비용(Overhead)을 제거하고, 동시에 실행되는 스레드 개수를 제한하여 시스템 과부하를 방지합니다.

    ② Executor 프레임워크

    소프트웨어 설계 및 아키텍처 관점에서의 정의입니다.

     

    Executor 프레임워크란? Java 5(java.util.concurrent)에서 도입된 비동기 작업 실행(Asynchronous Task Execution)과 스레드 생명주기 관리(Thread Lifecycle Management)를 표준화한 인터페이스 및 클래스의 집합입니다.

     

    이 프레임워크의 가장 큰 의의는 관심사의 분리(Decoupling)에 있습니다. 기존에는 개발자가작업(비즈니스 로직)"과 "실행(스레드 관리)"을 한 곳에서 뒤섞어 처리했습니다. 하지만 Executor 프레임워크는 이를 완벽히 분리합니다.

     

    • 작업의 등록 (Submission): 개발자는 Runnable이나 Callable로 정의된 작업을 submit() 하기만 합니다.
    • 작업의 실행 (Execution): 실제 스레드를 언제 생성할지, 어떻게 배분할지, 큐를 어떻게 관리할지는 프레임워크가 전담합니다.

    즉, Executor 프레임워크는 "스레드를 직접 다루는 저수준의 방식에서 벗어나, 작업을 효율적이고 안전하게 관리할 수 있도록 돕는 추상화 계층"입니다.

     

    Executor

    "작업 등록(Submission)과 실행(Execution)의 분리"

    가장 상위(Root)에 존재하는 인터페이스로, 단 하나의 메서드만을 가집니다. Executor의 핵심 역할은 "어떻게 실행할 것인가?"에 대한 구체적인 메커니즘을 추상화하여 감추는 것입니다.

    • 주요 역할:
      • 작업(Task)을 제출하는 코드와 작업을 실행하는 스레드 관리 코드를 분리(Decoupling)합니다.
      • 개발자는 new Thread(r).start() 대신 executor.execute(r)를 호출함으로써, 이 작업이 즉시 실행될지, 큐에 들어갈지, 스레드 풀에서 실행될지 신경 쓰지 않아도 됩니다.
    • 핵심 메서드:
      • void execute(Runnable command): 반환값이 없는 작업을 실행합니다.

    ExecutorService

    "스레드 풀의 라이프사이클(Lifecycle) 관리와 비동기 결과 제어"

    Executor를 상속받아 기능을 확장한 인터페이스입니다. 실무에서 가장 많이 참조하는 타입입니다. 단순 실행을 넘어, "스레드 풀을 종료하거나, 작업의 결과를 추적"하는 기능이 추가되었습니다.

    • 주요 역할:
      • 라이프사이클 관리: 스레드 풀을 우아하게 종료(Graceful Shutdown)하거나 강제로 종료하는 기능을 제공합니다.
      • 비동기 작업 제어: Callable을 실행하고 Future를 반환받아 작업 결과를 나중에 조회할 수 있게 합니다.
      • 일괄 처리: 여러 작업을 동시에 제출하고(invokeAll), 그중 가장 빨리 끝나는 작업의 결과를 받는(invokeAny) 기능을 제공합니다.
    • 핵심 메서드:
      • shutdown(): 새로운 작업 제출을 중단하고, 이미 대기 중인 작업은 모두 처리한 뒤 종료합니다.
      • shutdownNow(): 현재 실행 중인 스레드에 인터럽트를 걸고, 대기 중인 작업 목록을 반환하며 즉시 종료를 시도합니다.
      • submit(Callable<T> task): 작업을 제출하고 결과를 추적할 수 있는 Future<T>를 반환합니다.

     

    ThreadPoolExecutor

    "스레드 풀 동작의 모든 것을 결정하는 엔진"

    ExecutorService 인터페이스의 가장 대표적이고 강력한 구현체입니다. 스레드 풀이 어떻게 동작할지(스레드 개수, 큐의 종류, 거절 정책 등)를 세밀하게 설정할 수 있습니다.

    • 주요 역할:
      • 리소스 튜닝: 코어 스레드 수, 최대 스레드 수, 유휴 스레드 대기 시간 등을 설정하여 시스템 리소스를 최적화합니다.
      • 작업 큐 관리: 작업이 몰릴 때 사용할 대기열(BlockingQueue)의 종류(Linked, Array, Synchronous 등)를 결정합니다.
      • 거절 정책(Rejection Policy) 수행: 스레드 풀과 큐가 꽉 찼을 때 들어온 요청을 어떻게 처리할지(예외 발생, 호출자 실행, 폐기 등) 결정합니다.

     

    1) 핵심 파라미터 7가지 (Constructor)

    ThreadPoolExecutor의 생성자는 스레드 풀의 거동을 결정하는 7가지 핵심 파라미터를 받습니다.

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)

     

    1. corePoolSize (기본 스레드 수): 트래픽이 없어도 유지할 최소한의 스레드 개수입니다. (단, allowCoreThreadTimeOut 설정 시 수거 가능)
    2. maximumPoolSize (최대 스레드 수): 큐가 가득 찼을 때 확장 가능한 최대 스레드 개수입니다.
    3. keepAliveTime: corePoolSize를 초과하여 생성된 스레드가 작업을 마치고 유휴 상태일 때, 얼마나 기다리다가 제거될지 결정하는 시간입니다.
    4. unit: keepAliveTime의 시간 단위입니다.
    5. workQueue (작업 대기열): 스레드가 모두 바쁠 때 작업을 대기시킬 BlockingQueue 구현체입니다.
    6. threadFactory: 스레드를 생성할 때 사용하는 팩토리입니다. (스레드 이름 설정, 데몬 스레드 여부, 우선순위 설정 등에 사용)
    7. handler (거절 정책): 큐도 꽉 차고, 스레드도 최대치까지 생성된 상황에서 추가 요청이 오면 어떻게 처리할지 결정하는 전략입니다.

    2) 작업 처리 흐름 (Execution Flow)

    ThreadPoolExecutor에 작업이 submit() 되면, 내부는 철저한 3단계 우선순위에 따라 동작합니다.

    1. Step 1: Core Pool 확인
      • 현재 실행 중인 스레드 수 < corePoolSize 라면?
      • 큐를 확인하지 않고, 즉시 새로운 스레드를 생성하여 작업을 처리합니다.
    2. Step 2: Queue 적재 (Enqueue)
      • Core 스레드가 모두 바쁜 상태라면?
      • 작업을 workQueue에 넣습니다. (여기서 스레드는 생성되지 않고, 기존 스레드가 큐를 비워주길 기다립니다.)
    3. Step 3: Max Pool 확인 (확장)
      • 큐가 가득 차서 더 이상 넣을 수 없다면?
      • 현재 실행 중인 스레드 수 < maximumPoolSize 라면?
      • 👉 새로운 스레드(Non-core Thread)를 생성하여 즉시 작업을 처리합니다.
    4. Step 4: 거절 (Reject)
      • 큐도 꽉 차고, 스레드 수도 maximumPoolSize에 도달했다면?
      • 👉 RejectedExecutionHandler에 정의된 정책에 따라 예외를 던지거나 작업을 폐기합니다.

    ⚠️ 주의할 점:

    많은 개발자가 "스레드가 Max까지 늘어난 뒤에 큐에 쌓인다"라고 오해하지만, 실제로는 "큐가 꽉 차야만 스레드가 Core에서 Max로 늘어납니다."

    즉, 큐의 크기가 너무 크면(예: Integer.MAX_VALUE), maximumPoolSize 설정은 무시되고 스레드는 영원히 corePoolSize 개수만 유지하게 됩니다.

    3) 주요 BlockingQueue 구현체

    어떤 큐를 선택하느냐에 따라 스레드 풀의 성격이 완전히 달라집니다.

    • SynchronousQueue: 저장 공간이 없는 큐입니다. 작업을 넣으려는 스레드와 받으려는 스레드가 1:1로 만나야 합니다(Handoff). 즉시 처리가 필요할 때 사용하며, newCachedThreadPool의 기본값입니다.
    • LinkedBlockingQueue: 크기 제한이 없는(기본값) 연결 리스트 기반 큐입니다. 큐가 무한하므로 스레드는 corePoolSize 이상 늘어나지 않습니다. newFixedThreadPool의 기본값입니다.
    • ArrayBlockingQueue: 크기가 고정된 배열 기반 큐입니다. 메모리 사용량을 제한할 수 있어 대규모 트래픽 처리에 적합합니다.

    4) 거절 정책 (RejectedExecutionHandler)

    서버가 감당할 수 없는 부하가 걸렸을 때의 "비상 대처 매뉴얼"입니다.

    • AbortPolicy (기본값): RejectedExecutionException을 던집니다. 가장 안전하고 명확한 방식입니다.
    • CallerRunsPolicy: 작업을 스레드 풀에 맡기지 않고, 요청한 스레드(Main Thread 등)가 직접 실행하게 합니다. 요청자 스레드가 작업을 처리하는 동안 추가 요청을 보낼 수 없으므로, 자연스럽게 부하를 조절(Backpressure)하는 효과가 있습니다.
    • DiscardPolicy: 작업을 조용히 버립니다.
    • DiscardOldestPolicy: 큐에서 가장 오래 대기한 작업을 버리고, 현재 작업을 다시 시도합니다.

    5) 내부 동작: Worker Thread의 루프

    스레드 풀의 스레드들은 작업이 없다고 바로 죽지 않습니다. 내부는 무한 루프(runWorker())를 돌며 큐를 감시합니다.

    // ThreadPoolExecutor 내부 로직 (의사 코드)
    final void runWorker(Worker w) {
        Runnable task = w.firstTask;
        while (task != null || (task = getTask()) != null) { 
            // 1. task가 있으면 실행
            // 2. task가 없으면 getTask() 호출 -> workQueue.take() (Blocking)
            try {
                task.run(); // 실제 작업 실행
            } finally {
                task = null; // 초기화 후 다시 루프 진입
            }
        }
        // getTask()가 null을 반환하면(KeepAliveTime 초과 등) 스레드 종료
    }
    • 핵심: getTask() 메서드는 큐가 비어있으면 take() 메서드를 통해 대기(Blocking) 상태에 들어갑니다. 이것이 스레드 풀이 CPU를 낭비하지 않고 대기할 수 있는 원리입니다.

     

     

    Executors

    "복잡한 설정 없이 스레드 풀을 즉시 생성해주는 팩토리"

    ThreadPoolExecutor의 생성자는 파라미터가 많고 설정이 복잡합니다. Executors는 자주 사용되는 설정 조합을 미리 정의해 둔 정적 팩토리 메서드(Static Factory Method)들을 제공합니다.

    • 주요 역할:
      • 개발자가 복잡한 설정(new ThreadPoolExecutor(...))을 직접 작성하지 않고도, 메서드 호출 하나로 검증된 패턴의 스레드 풀을 생성하게 해줍니다.
    • 주요 팩토리 메서드:
      • newFixedThreadPool(int n): 고정된 크기의 스레드 풀 생성.
      • newCachedThreadPool(): 필요에 따라 스레드를 무제한 생성하고, 60초간 안 쓰면 수거하는 풀 생성.
      • newSingleThreadExecutor(): 단 하나의 스레드만 사용하여 작업을 순차적으로 처리하는 풀 생성.
      • newScheduledThreadPool(int corePoolSize): 일정 시간 뒤에 실행하거나 주기적으로 실행하는 스케줄링 기능을 가진 풀 생성.
    • 주의사항:
      • Executors가 제공하는 메서드들은 편리하지만, 대부분 무제한 큐(Unbounded Queue)나 무제한 스레드 생성을 허용합니다. 따라서 프로덕션 환경에서는 OOM(OutOfMemory) 방지를 위해 ThreadPoolExecutor를 직접 생성하여 사용하는 것을 권장합니다.

    2) 스레드 풀의 동작 원리 (Producer-Consumer 패턴)

    ThreadPoolExecutor 구현체는 내부적으로 Producer-Consumer 디자인 패턴을 따릅니다.

    • Task Submission (Producer): 클라이언트(Main Thread 등)는 작업을 수행할 스레드를 직접 생성하지 않습니다. 대신 Runnable이나 Callable 인터페이스로 정의된 작업(Task) 객체를 ExecutorService에 제출(submit)합니다.
    • Blocking Queue (Channel): 제출된 작업은 즉시 실행되지 않을 경우, 스레드 풀 내부의 BlockingQueue(작업 큐)에 적재(Enqueue)되어 대기합니다. 이 큐는 스레드 안전(Thread-safe)하게 관리됩니다.
    • Worker Threads (Consumer): 풀 내부에 미리 생성된 Worker Thread들은 루프를 돌며 큐에서 작업을 꺼내(Dequeue) 실행합니다. 작업이 완료된 스레드는 소멸되지 않고, 다시 큐를 모니터링하며 다음 작업을 대기합니다(Keep-Alive).

    2) ExecutorService 인터페이스와 사용 예시

    ExecutorService는 스레드 풀의 라이프사이클 관리와 비동기 작업 실행을 위한 메서드를 제공하는 최상위 인터페이스입니다. 실무에서는 다음과 같이 활용합니다.

    ① 기본 사용: 작업 제출 (Runnable)

    가장 기본적인 형태입니다. 반환값이 없는 작업(Logging, 알림 발송 등)을 비동기로 처리할 때 사용합니다.

    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    
    public class ThreadPoolExample {
        public static void main(String[] args) {
            // 1. 고정된 크기(10개)의 스레드 풀 생성 (LinkedBlockingQueue 사용)
            ExecutorService executor = Executors.newFixedThreadPool(10);
    
            // 2. 작업(Runnable) 정의 및 제출
            for (int i = 0; i < 5; i++) {
                final int taskId = i;
                executor.submit(() -> {
                    // 실제 작업 로직 (비즈니스 로직)
                    String threadName = Thread.currentThread().getName();
                    System.out.println("[" + threadName + "] 작업 " + taskId + " 실행 중...");
                });
            }
            
            // 3. 종료 처리 (필수)
            executor.shutdown();
        } 
    }
    

    ② 심화 사용: 결과 반환 및 예외 처리 (Callable & Future)

    실무에서는 작업의 결과가 필요하거나, 작업 중 발생한 예외(Exception)를 처리해야 하는 경우가 많습니다. 이때는 Callable을 사용합니다.

    import java.util.concurrent.*;
    
    public class FutureExample {
        public static void main(String[] args) {
            ExecutorService executor = Executors.newFixedThreadPool(5);
    
            // Callable: 결과를 반환하고, Exception을 던질 수 있음
            Callable<String> task = () -> {
                Thread.sleep(2000); // DB 조회 등의 I/O 작업 시뮬레이션
                return "Task Completed";
            };
    
            // submit()은 즉시 Future 객체(작업의 결과 교환권)를 반환함 (Non-blocking)
            Future<String> future = executor.submit(task);
    
            System.out.println("메인 스레드는 다른 작업 수행 중...");
    
            try {
                // get(): 작업이 완료될 때까지 메인 스레드는 대기(Blocking)함
                String result = future.get(); 
                System.out.println("결과 수신: " + result);
            } catch (InterruptedException | ExecutionException e) {
                // ExecutionException: 작업 도중 발생한 예외가 래핑되어 던져짐
                e.printStackTrace();
            } finally {
                executor.shutdown();
            }
        }
    }
    

    3. 작업의 진화: Runnable vs Callable

    스레드 풀(ExecutorService)의 등장과 함께, 작업(Task)을 정의하는 인터페이스도 실행 중심에서 결과 중심으로 고도화되었습니다.

    1) Runnable (Java 1.0 ~)

    java.lang 패키지에 속한 Runnable은 자바 초기부터 존재했던 인터페이스입니다. 함수형 인터페이스로서 다음과 같은 메서드 시그니처를 가집니다.

    @FunctionalInterface
    public interface Runnable {
        public abstract void run();
    }
    

    ① Void Return Type의 한계

    • 단방향성: 반환 타입이 void입니다. 실행 흐름이 호출자(Caller)에게 어떤 값도 되돌려줄 수 없습니다.
    • Side-effect 필수: 실행 결과를 외부로 전달하려면, 외부의 공유 변수(Shared Variable)를 수정하거나 System.out.println 같은 I/O를 수행하는 Side-effect에 의존해야 합니다.

    ② Checked Exception 전파 불가

    • run() 메서드 선언부에는 throws 절이 없습니다.
    • 따라서 실행 도중 IOException, SQLException 같은 Checked Exception이 발생해도 메서드 밖으로 던질 수 없습니다.
    • 강제된 예외 처리: 반드시 내부에서 try-catch 블록으로 예외를 삼키거나(swallow), RuntimeException으로 래핑해서 던져야만 합니다.
    Runnable task = () -> {
        try {
            // Checked Exception 발생 가능 코드
            Thread.sleep(1000); 
        } catch (InterruptedException e) {
            // 1. 반드시 내부에서 처리해야 함.
            // 2. 호출한 쪽(Main Thread)은 여기서 에러가 났는지 알 방법이 없음.
            e.printStackTrace(); 
        }
    };
    

    2) Callable (Java 5.0 ~)

    java.util.concurrent 패키지에 속한 Callable은 제네릭(Generic)을 도입하여 유연성을 확보했습니다.

    @FunctionalInterface
    public interface Callable<V> {
        V call() throws Exception;
    }
    

    ① Generic Return Type (값 반환)

    • Callable<V>의 제네릭 타입 파라미터 V를 통해 반환할 타입을 명시적으로 지정할 수 있습니다.
    • 작업 완료 후 값을 반환하면, 이를 호출자(Caller)가 Future<V> 객체를 통해 안전하게 수신할 수 있습니다. 공유 변수를 사용하지 않으므로 스레드 안전(Thread-safe)한 프로그래밍이 가능해집니다.

    ② Checked Exception 전파 (Exception Propagation)

    • call() 메서드는 throws Exception을 명시하고 있습니다.
    • 작업 도중 발생한 예외를 try-catch 없이 밖으로 던질 수 있습니다.
    • 예외 래핑: 던져진 예외는 ExecutorService가 받아 두었다가, 나중에 개발자가 future.get()을 호출하는 시점에 ExecutionException으로 래핑(Wrapping)하여 다시 던져줍니다. 즉, 메인 스레드에서 작업 스레드의 예외를 핸들링할 수 있게 됩니다.
    // 반환 타입: String, 예외 처리: 외부 전파 가능
    Callable<String> callableTask = () -> {
        if (someCondition) {
            throw new IOException("파일 읽기 실패"); // Checked Exception 던지기 가능
        }
        return "작업 성공 데이터";
    };
    
    ExecutorService executor = Executors.newFixedThreadPool(1);
    Future<String> future = executor.submit(callableTask);
    
    try {
        String result = future.get(); // 결과 수신
    } catch (ExecutionException e) {
        // 작업 스레드에서 던진 IOException이 여기서 잡힘
        Throwable cause = e.getCause(); // 실제 예외 원인 확인 가능
        System.err.println("작업 중 에러 발생: " + cause.getMessage());
    }
    

     

    특징 Runnable Callable
    패키지 java.lang java.util.concurrent
    도입 버전 Java 1.0 Java 5.0
    메서드 void run() V call() throws Exception
    반환값 없음 (void) 있음 (Generic V)
    예외 처리 내부 처리 필수 (try-catch) 외부 전파 가능 (throws)
    주요 용도 단순 실행 (스레드 생성 등) 결과 계산 및 비동기 처리

    4. Future 인터페이스

    Callable 객체를 스레드 풀에 제출(submit)하면, 메서드는 즉시 리턴되지만 결과값은 아직 생성되지 않은 상태입니다. 이때 ExecutorService는 비동기 연산의 결과에 대한 참조(Reference인 Future<T> 객체를 반환합니다.

    1) 개념: 비동기 작업의 상태 관리자

    java.util.concurrent.Future 인터페이스는 비동기 작업의 현재 상태(진행 중, 완료, 취소)를 확인하고, 결과를 조회할 수 있는 메서드를 제공합니다.

    • Pending (대기/진행 중): 작업이 아직 큐에 있거나 실행 중인 상태.
    • Completed (완료): 작업이 정상적으로 끝났거나 예외가 발생하여 종료된 상태.

    2) 주요 메서드와 동작 원리

    Future는 작업의 라이프사이클을 제어하기 위해 다음과 같은 핵심 메서드를 제공합니다.

    • boolean isDone() (Polling)
      • 작업이 완료되었는지 여부를 확인합니다. (정상 종료, 예외 발생, 취소 모두 '완료'로 간주)
      • Non-blocking 메서드이므로 즉시 true 또는 false를 반환합니다. 이를 이용해 루프를 돌며 완료를 기다리는 것(Polling)은 CPU 자원을 낭비하므로 권장되지 않습니다.
    • boolean cancel(boolean mayInterruptIfRunning)
      • 작업 취소를 시도합니다.
      • mayInterruptIfRunning = true: 작업이 이미 실행 중이라면, 해당 스레드에 인터럽트(Interrupt) 신호를 보내 강제 종료를 시도합니다.
      • mayInterruptIfRunning = false: 작업이 아직 실행되지 않았다면(큐에 대기 중), 실행하지 않고 취소합니다.
    • V get() (Blocking)
      • 작업이 완료될 때까지 호출한 스레드(Caller Thread)를 대기(WAITING) 상태로 만듭니다.
      • 작업이 완료되면 결과를 반환하고, 작업 도중 예외가 발생했다면 ExecutionException을 던집니다.
      • Overload: get(long timeout, TimeUnit unit)을 사용하여 무한 대기를 방지하고 일정 시간만 기다리게 할 수 있습니다.

    3) Future의 기술적 한계: Blocking과 Composability의 부재

    Java 5의 Future는 비동기 결과 조회라는 기능을 제공했지만, 모던 애플리케이션이 요구하는 Non-blocking 아키텍처를 구현하기에는 구조적인 한계가 명확합니다.

    ① 블로킹(Blocking) 문제

    초기에 논블로킹(Non-blocking)으로 작업을 시작했더라도, 결과를 얻으려는 시점(get())에는 결국 스레드가 멈추는 블로킹(Blocking) 상태가 됩니다. 즉, 결과가 나올 때까지 아무 작업도 수행하지 못하고 대기해야 합니다. 이는 리소스 활용 효율을 떨어뜨리는 주원인입니다.

    ExecutorService executor = Executors.newSingleThreadExecutor();
    
    // 1. 비동기 작업 제출 (Non-blocking)
    Future<String> future = executor.submit(() -> {
        Thread.sleep(3000); // 3초 소요
        return "Hello Java";
    });
    
    System.out.println("메인 스레드: 다른 작업 수행 중...");
    
    try {
        // 2. 결과 조회 (Blocking 발생!)
        // 작업이 끝날 때까지 메인 스레드는 여기서 멈춥니다(WAITING 상태).
        // 결과적으로 3초 동안 메인 스레드는 아무 일도 못하게 됩니다.
        String result = future.get(); 
        
        System.out.println("결과: " + result);
    } catch (InterruptedException | ExecutionException e) {
        e.printStackTrace();
    }
    
    • 리소스 낭비: get()을 호출한 스레드는 실제 CPU를 쓰지 않으면서도 메모리를 점유한 채 대기합니다.

    ② 조합(Composability) 불가능

    여러 비동기 작업을 연결하거나 조립할 수 없습니다.

    • "작업 A가 끝나면, 그 결과를 가지고 작업 B를 실행하고, 최종적으로 작업 C를 수행하라"와 같은 체이닝(Chaining) 로직을 구현할 수 없습니다.
    • 이를 Future로 구현하려면 get()으로 기다렸다가 다음 작업을 호출해야 하므로, 코드가 복잡해지고 블로킹 구간이 늘어납니다.

    ③ 예외 처리의 복잡성

    Future.get()은 예외가 발생했을 때 try-catch 블록을 강제합니다. 비동기 로직 내부의 예외를 깔끔하게 처리하거나 복구하는 선언적 방법이 제공되지 않습니다.

Designed by MSJ.