[강의] Java 비동기 처리하기

Jerry·2025년 11월 26일

비동기 처리의 개념과 필요성

동기(Synchronous) vs 비동기(Asynchronous) 처리

동기 및 비동기 처리는 시스템이 작업을 수행하는 흐름 제어 방식을 나타냅니다.

동기(Synchronous) 처리의 개념과 특징

동기 처리는 요청이 시작된 시점부터 해당 작업이 완료될 때까지 프로그램의 흐름이 차례대로 (순차적으로) 진행되는 실행 방식입니다.

  • 개념: 현재 작업이 완전히 끝나야만 다음 작업이 수행될 수 있습니다.
  • 의존성: 호출 간의 순차적 의존성이 강력하게 유지됩니다.
특징설명
직관성 및 단순성실행 흐름이 직관적이며 제어 구조가 단순하여 디버깅이 쉽습니다.
성능 제약작업 A의 대기 시간(I/O, 외부 API 호출 등)이 길어지면, 뒤따르는 작업 B, C는 무작정 기다려야 하므로 전체 처리 시간이 증가합니다.
자원 비효율단일 스레드 기반 환경에서는 하나의 긴 작업이 스레드를 차단(Blocking)하여 다른 요청 처리를 막으므로, 응답 지연과 시스템 자원(스레드) 비효율이 발생하기 쉽습니다.

비동기(Asynchronous) 처리의 개념과 특징

비동기 처리는 작업 요청을 보낸 후 해당 작업의 결과를 즉시 기다리지 않고 다음 작업을 이어서 수행하는 실행 방식입니다. 작업을 백그라운드에서 처리함으로써 메인 스레드의 블로킹을 방지합니다.

  • 개념: 요청을 보낸 후, 결과를 기다리는 동안 메인 스레드를 해제하고 다른 작업을 수행합니다.
  • 결과 전달: 작업이 완료되면 콜백(Callback), 이벤트(Event), 또는 Future/Promise 객체 등을 통해 결과를 비동기적으로 전달받습니다.
특징설명
자원 활용 효율 증대"대기 시간이 긴 작업(I/O 작업, 네트워크 통신)에서 스레드가 대기 상태에 빠지지 않고 다른 요청을 처리할 수 있으므로, 시스템 자원 활용 효율이 극대화됩니다."
응답성 향상여러 작업을 동시(Concurrency)에 처리하여 사용자 요청에 대한 응답 시간(Latency)을 단축하고 전반적인 처리량(Throughput)을 향상시킵니다.
복잡한 흐름 제어"작업 완료 시점을 예측하기 어렵고, 결과 처리 순서를 보장하기 위해 콜백 지옥(Callback Hell) 등의 문제가 발생할 수 있어 흐름 제어가 복잡해지고 디버깅 난이도가 증가합니다."
고성능 처리멀티 스레드 환경에서 동시성(Concurrency)을 효과적으로 활용하여 고성능 처리가 가능합니다.

비동기 처리의 필요성

Spring Boot와 같은 웹 백엔드 환경에서는 대부분의 요청이 외부 I/O 작업(데이터베이스 조회, 외부 API 통신 등)을 포함하고 있어 대기 시간이 깁니다.

이러한 환경에서 동기 처리를 사용하면 제한된 스레드 풀의 스레드가 I/O 작업 완료를 기다리느라 낭비되어, 결국 서비스의 응답성 저하와 동시 접속자 수 처리 능력(Capacity) 제한이라는 병목 현상을 초래하게 됩니다.

따라서, 시스템의 확장성과 처리량을 높이기 위해 비동기 처리는 현대적인 백엔드 시스템에서 필수적인 요소입니다.

블로킹(Blocking)의 개념

블로킹은 작업을 호출한 함수가 해당 작업의 완료를 보장할 때까지 자신의 제어권을 호출된 함수에게 넘겨주고 대기하는 처리 방식입니다.

  • 제어권 반환: 작업이 완전히 완료될 때까지 제어권을 반환하지 않고 멈춰서 기다립니다.
  • 특성: I/O 작업처럼 시간이 오래 걸리는 경우, 호출한 쪽의 전체 실행 흐름이 멈춥니다(Blocking).

블로킹 예시 (java.io.InputStream.read())
전형적인 블로킹 I/O의 예시입니다. read() 메서드가 데이터를 읽을 수 있을 때까지 스레드의 실행을 멈춥니다.

public class BlockingExample {
    public static void main(String[] args) throws Exception {
        InputStream in = System.in;
        System.out.println("블로킹: 입력을 기다리는 중...");
        
        int data = in.read(); // 데이터가 들어올 때까지 '블록'(스레드 대기)
        
        System.out.println("읽은 데이터: " + (char) data);
    }
}

논블로킹(Non-Blocking)의 개념

논블로킹은 작업을 호출한 함수가 작업을 요청한 후, 완료 여부와 관계없이 호출된 함수로부터 즉시 제어권을 돌려받아 자신의 다음 작업을 계속 수행하는 방식입니다.

  • 제어권 반환: 작업 요청 후 즉시 호출자에게 제어권을 반환합니다.
  • 특성: 완료되지 않은 경우 에러 코드(EAGAIN)나 상태 값을 반환하며, 호출자는 블록되지 않고 흐름을 계속 진행합니다.

논블로킹 예시 (데이터 폴링 방식)
데이터를 읽을 준비가 되었는지 반복적으로 확인(폴링)하며 블록되지 않고 다른 작업을 수행할 수 있습니다.

public class NonBlockingExample {
    public static void main(String[] args) throws Exception {
        InputStream in = System.in;
        System.out.println("논블로킹: 데이터 체크 중...");
        
        // 데이터가 들어올 때까지 poll 방식으로 확인
        while (in.available() == 0) {
            System.out.println("데이터 없음 -> 계속 진행 중...");
            Thread.sleep(300); // 구현 상 sleep이지만, 이 시간에 다른 작업 진행 가능
        }
        
        int data = in.read(); // 이 시점에는 데이터가 들어있으므로 블록되지 않음
        System.out.println("읽은 데이터: " + (char) data);
    }
}

동기/비동기 vs 블로킹/논블로킹

  • 동기/비동기 (Synchronous/Asynchronous)

    • 관점: "작업의 완료 여부(결과)를 누가 신경 쓰는가?"
    • 동기: 호출한 함수(Caller)가 호출된 함수(Callee)의 작업 완료를 계속 신경 씁니다.
    • 비동기: 호출된 함수(Callee)가 작업이 끝나면 콜백(Callback) 등을 통해 알려주므로, 호출한 함수는 작업 완료 여부를 신경 쓰지 않습니다.
  • 블로킹/논블로킹 (Blocking/Non-Blocking)

    • 관점: "제어권(Control)을 누가 가지고 있는가?"
    • 블로킹: 호출된 함수가 작업을 마칠 때까지 제어권을 가지고 놓아주지 않습니다. (호출한 쪽은 멈춤)
    • 논블로킹: 호출된 함수가 제어권을 바로 반환합니다. (호출한 쪽은 계속 다른 일을 할 수 있음)

논블로킹 + 동기 (Non-Blocking + Synchronous)

이 조합은 "다른 일을 할 수는 있지만(Non-blocking), 결과는 계속 확인하는(Sync)" 상황입니다.

  • 상황 예시:
    팀장(Caller)이 사원(Callee)에게 업무를 시킵니다.

    1. 팀장: "이거 처리해주세요." (호출)
    2. 사원: "네, 알겠습니다." (즉시 제어권 반환 - Non-blocking)
    3. 팀장: (자기 할 일을 하다가) "다 했나요?" (결과 확인 - Sync)
    4. 사원: "아직이요."
    5. 팀장: (또 자기 할 일을 하다가) "다 했나요?"
    6. 사원: "네, 여기 있습니다."
  • 기술적 예시 (Polling):
    Java나 JavaScript에서 특정 작업이 끝났는지 반복적으로 확인하는 폴링(Polling) 방식이 대표적입니다. 제어권은 바로 리턴받았지만, 결과가 나올 때까지 계속 물어보는 형태입니다.

    // Java Future의 isDone()을 루프 돌며 확인하는 경우
    Future<String> future = executor.submit(task);
    
    while(!future.isDone()) {
        // 작업이 안 끝났으면 다른 작업을 수행 (Non-blocking)
        doSomethingElse(); 
    }
    // 결국 결과를 직접 확인해서 가져옴 (Synchronous)
    String result = future.get();

블로킹 + 비동기 (Blocking + Asynchronous)

이 조합은 "결과가 오면 알아서 처리되도록 맡겨놨는데(Async), 정작 나는 아무것도 안 하고 기다리는(Blocking)" 다소 비효율적인 상황입니다.

  • 상황 예시:
    팀장(Caller)이 사원(Callee)에게 업무를 시킵니다.

    1. 팀장: "이거 처리되면 메일로 결과 보내주세요." (결과는 나중에 받겠다 - Async)
    2. 팀장: (그런데 전화를 끊지 않고 멍하니 사원이 일하는 걸 지켜봄) (...) (제어권 뺏김 - Blocking)
    3. 사원: (일 다 함) "메일 보냈습니다."
    4. 팀장: (그제서야) "아, 네." (전화 끊음)
  • 기술적 예시 (의도치 않은 실수):
    보통 개발자의 실수나, 기술적인 제약으로 인해 발생합니다. 비동기 메서드를 호출해 놓고 바로 결과를 달라고 기다리는 경우입니다.

    // 1. 비동기로 작업을 시작함 (Asynchronous)
    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
        return lengthyTask();
    });
    
    // 2. 하지만 get()을 호출하는 순간, 결과가 나올 때까지 스레드는 대기(Blocking)함
    // 비동기의 이점을 살리지 못한 케이스
    String result = future.get(); 

    참고: Node.js와 MySQL을 연동할 때도, 코드 자체는 비동기 콜백 패턴이지만 내부 드라이버가 블로킹으로 동작하는 경우 이런 현상이 발생하기도 했습니다.

비동기 처리의 구현 메커니즘

1. 스레드 기반 비동기 처리

기존의 Spring MVC와 같은 전통적인 서버 모델에서 가장 많이 사용되는 방식입니다.

1-1. 멀티스레딩과 스레드 풀

  • 멀티스레딩(Multi-threading):
    • 하나의 프로세스 내에서 여러 스레드를 생성하여 작업을 병렬로 분할 처리합니다.
    • 목적: CPU 코어를 최대한 활용하여 대기 시간이 긴 작업(I/O 등)을 분리하고, 전체적인 응답성과 처리량을 높입니다.
  • 스레드 풀(Thread Pool):
    • 스레드를 매번 생성/소멸시키는 비용(Context Switching Overhead 등)을 줄이기 위해, 미리 일정 개수의 스레드를 만들어 놓고 재사용하는 기법입니다.
    • 장점: 작업이 폭증해도 스레드 개수가 무한정 늘어나는 것을 방지하여 시스템 안정성을 보장합니다.

1-2. Java의 대표적인 구현체

자바에서는 비동기 처리를 위해 다음과 같은 도구를 제공합니다.

도구특징
ExecutorService스레드 풀을 생성하고 관리하는 추상화된 실행 서비스입니다. 작업(Task) 제출과 실행을 분리하여 안정적인 제어가 가능합니다.
CompletableFutureJava 8부터 도입된 고수준 비동기 프로그래밍 도구입니다. 비동기 작업의 결과 처리, 체이닝(Chaining), 예외 처리 등을 선언적으로 작성할 수 있습니다.

2. 이벤트 루프 기반 비동기 처리 (Event Loop-Based)

Node.js나 Spring WebFlux에서 사용하는 방식으로, 적은 리소스로 엄청난 동시성을 처리하기 위해 고안되었습니다.

2-1. 단일 스레드와 이벤트 루프

  • 작동 원리:
    • 단일 스레드(혹은 코어 당 1개의 스레드)가 무한 루프를 돌며 작업 큐(Queue)를 확인합니다.
    • 작업을 직접 실행하며 대기(Blocking)하는 것이 아니라, "이거 처리해 줘"라고 등록만 해두고 바로 다음 작업을 가져옵니다.
  • 특징:
    • 스레드를 많이 만들지 않아도 되므로 메모리 사용량이 적고 컨텍스트 스위칭 비용이 낮습니다.
    • I/O 작업이 많은(Network, DB 통신 등) 서비스에서 압도적인 효율을 보여줍니다.

2-2. Spring 생태계의 사례

Spring 5.0부터는 이러한 리액티브 프로그래밍을 공식 지원합니다.

  • Spring WebFlux: 논블로킹(Non-blocking) I/O를 지원하는 웹 프레임워크입니다. Netty나 Undertow 같은 서버 엔진 위에서 적은 수의 스레드로 대량의 요청을 처리합니다.
  • Project Reactor: JVM 위에서 동작하는 리액티브 스트림 라이브러리입니다. Mono(0~1개 데이터)와 Flux(N개 데이터)라는 타입을 통해 데이터 흐름을 비동기적으로 다룹니다.

3. 메시지 기반 비동기 처리 (Message-Based)

시스템 간의 결합도를 낮추고(Decoupling), 대용량 트래픽을 처리하기 위한 아키텍처 레벨의 비동기 방식입니다.

3-1. 메시지 큐와 Pub-Sub 패턴

  • 메시지 큐(Message Queue):
    • 생산자(Producer)가 보낸 데이터를 큐에 쌓아두면, 소비자(Consumer)가 여유가 될 때 가져가서 처리합니다.
    • 장점: 트래픽이 폭주해도 큐가 완충 작용(Buffering)을 해주어 서버가 다운되는 것을 막고, 장애 전파를 차단(Isolation)합니다.
  • 발행-구독(Pub-Sub) 패턴:
    • 메시지를 특정 '채널(Topic)'에 발행하면, 해당 채널을 구독하고 있는 모든 시스템이 메시지를 수신하는 구조입니다. 시스템끼리 서로 몰라도 되므로 확장성이 매우 뛰어납니다.

3-2. 대표적인 메시지 브로커: Kafka vs RabbitMQ

구분Kafka (카프카)RabbitMQ (래빗MQ)
핵심 컨셉분산 로그 기반 스트리밍 플랫폼전통적인 메시지 브로커
특징- 압도적인 처리량(Throughput)
- 대용량 데이터의 실시간 로그 수집/분석에 최적화
- 복잡하고 유연한 라우팅(Routing)
- 메시지 우선순위, 지연 발송 등 정교한 기능 제공
용도이벤트 스트리밍, 로그 집계, 빅데이터 파이프라인업무 복잡도가 높은 마이크로서비스 간 통신

Java 스레드의 이해

1. 스레드의 기본 개념

1-1. 스레드(Thread)란?

스레드는 프로세스 내부에서 실행 흐름을 담당하는 가장 작은 실행 단위입니다.
하나의 프로세스는 여러 스레드를 가질 수 있으며(Multi-thread), 이들은 메모리를 공유하며 동시에 작업을 수행합니다.

1-2. 프로세스 vs 스레드 (핵심 비교)

이 둘의 가장 큰 차이는 "메모리 공유 여부"입니다.

구분프로세스 (Process)스레드 (Thread)
정의운영체제로부터 자원을 할당받은 작업 단위프로세스 내부의 실행 흐름 단위
메모리독립된 메모리 공간 (Code, Data, Heap, Stack)Stack만 독립적, Code/Data/Heap은 공유
통신IPC(Inter-Process Communication) 필요 (어려움)공유 메모리(Heap)를 통해 통신 (쉬움)
안정성하나가 죽어도 다른 프로세스에 영향 없음하나의 스레드 오류가 프로세스 전체를 종료시킬 수 있음
오버헤드생성/전환(Context Switching) 비용이 큼생성/전환 비용이 비교적 적음 (Lightweight)

1-3. JVM과 스레드

자바 애플리케이션은 JVM(Java Virtual Machine) 위에서 돌아갑니다.

  • 자바 스레드는 OS의 네이티브 스레드(Native Thread)와 1:1로 매핑되어 수행됩니다.
  • 메인 스레드(Main Thread): main() 메서드를 실행하며 시작되는 최초의 스레드입니다.
  • 작업 스레드(Worker Thread): 메인 스레드에서 파생되어 비동기 작업이나 병렬 처리를 수행하는 스레드입니다.

2. 스레드 생성과 실행 방법

2-1. Thread 클래스 상속

Thread 클래스를 직접 상속받아 run() 메서드를 오버라이딩합니다.

class MyThread extends Thread {
    private final String taskName;

    public MyThread(String taskName) {
        this.taskName = taskName;
    }

    @Override
    public void run() {
        System.out.println(taskName + " 시작");
        try {
            Thread.sleep(500); // 0.5초 대기
        } catch (InterruptedException e) {
            System.out.println(taskName + " 인터럽트 발생");
        }
        System.out.println(taskName + " 종료");
    }
}

// 실행 코드
public static void main(String[] args) {
    Thread t1 = new MyThread("작업1");
    Thread t2 = new MyThread("작업2");
    
    // start()를 호출해야 새로운 스택이 할당되어 병렬 실행됩니다.
    t1.start(); 
    t2.start();
}

2-1. Runnable 인터페이스 활용

Runnable은 실행할 작업 내용만 정의하는 인터페이스입니다. 다중 상속이 불가능한 자바에서 더 유연하게 사용할 수 있습니다.

Runnable task = () -> {
    System.out.println("Runnable 작업 시작");
    try {
        Thread.sleep(300);
    } catch (InterruptedException e) {
        System.out.println("Runnable 작업 인터럽트");
    }
    System.out.println("Runnable 작업 종료");
};

// Runnable 객체를 Thread 생성자에 전달
Thread thread = new Thread(task);
thread.start();

주의: run() vs start()

  • run(): 단순한 메서드 호출입니다. 새로운 스레드가 생기지 않고, 현재 스레드에서 순차적으로 실행됩니다.
  • start(): 새로운 호출 스택(Call Stack)을 생성하고, OS 스케줄러에게 실행을 요청하여 멀티 스레드로 동작하게 합니다.

3. 스레드 제어

3.1. sleep()과 join()

  • sleep(ms): 현재 실행중인 스레드를 잠시 멈춥니다.
  • join(): 해당 스레드가 끝날 때까지 기다립니다. (순서를 보장해야 할 때 사용)
public class StartSleepJoinExample {
    public static void main(String[] args) {
        Thread worker = new Thread(() -> {
            try {
                System.out.println("작업 시작");
                Thread.sleep(1000); // 1초간 작업 수행
                System.out.println("작업 완료");
            } catch (InterruptedException e) {
                // 예외 처리
            }
        });

        worker.start();

        try {
            worker.join(); // 메인 스레드는 worker가 끝날 때까지 여기서 멈춤
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        System.out.println("메인 스레드 종료 (작업 완료 후 실행됨)");
    }
}

3.2 interrupt() 메서드와 인터럽트 처리

  • interrupt(): 스레드에 작업 중단 요청을 보내는 신호이며, 스레드가 sleep(), join() 상태일 때 InterruptedException을 발생시켜 깨어나게 합니다.
  • 루프 기반 작업에서는 isInterrupted() 등을 확인해 안전하게 종료하는 패턴을 사용합니다.
class InterruptWorker extends Thread {
	@Override
	public void run() {
		try {
			System.out.println("대기 중...");
			Thread.sleep(1000); // interrupt() 시 여기서 예외 발생
		} catch (InterruptedException e) {
			System.out.println("인터럽트 감지! 안전 종료");
			return;
		}
	}
}

public class InterruptExample {
	public static void main(String[] args) throws Exception {
		InterruptWorker w = new InterruptWorker();
		w.start();

		Thread.sleep(300);
		w.interrupt(); // 스레드에 인터럽트 요청
	}
}

3.3 주요 예외 상황 처리

  • InterruptedExceptionsleep(), join() 도중 인터럽트가 발생했을 때 반드시 처리해야 하는 체크 예외입니다.
  • 스레드 종료 시 적절한 정리 작업(로그 출력, 자원 해제 등)을 수행하고 안전하게 종료하도록 설계해야 합니다.
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    System.out.println("작업 중 인터럽트 발생 - 자원 정리 수행");
    
    // 중요: 인터럽트 상태를 다시 설정하여 상위 호출자에게 알림
    Thread.currentThread().interrupt(); 
}

스레드 안전성과 동기화 기초

1. 멀티스레드 프로그래밍의 위험성

1-1. 공유 자원 접근 문제와 경쟁 상태 (Race Condition)

멀티스레드 환경의 가장 큰 문제는 "여러 스레드가 하나의 자원(변수, 객체, 파일 등)을 동시에 건드릴 때" 발생합니다.

  • 경쟁 상태 (Race Condition): 두 개 이상의 스레드가 공유 자원에 동시에 접근하여, 접근 순서에 따라 실행 결과가 달라지는 현상입니다.
  • 문제점: 테스트 환경에서는 잘 동작하다가, 트래픽이 몰리는 운영 환경에서만 간헐적으로 데이터가 깨지는 현상이 발생하여 디버깅이 매우 어렵습니다.

1-2. 실제 발생 가능한 문제 사례

문제 유형설명 및 예시발생 결과
Race Condition
(경쟁 상태)
count++ 같은 연산이 동시에 수행됨.
예) 조회수 증가, 재고 감소
100명이 동시에 눌렀는데 조회수는 1 증가하는 누락(Loss) 발생
Shared Mutable State
(공유 자원 수정)
HashMap 같은 비동기화 객체를 여러 스레드가 동시에 수정함.데이터 구조가 깨지거나 뜬금없는 NullPointerException 발생
Lost Update
(갱신 손실)
A와 B가 동시에 수정 요청을 보냈는데, A의 수정 사항이 B에 의해 덮어씌워져 사라짐."내가 수정한 내용이 저장이 안 됐어요"
Deadlock
(교착 상태)
스레드 A는 자원 1을 잡고 2를 기다리고, 스레드 B는 자원 2를 잡고 1을 기다림.서로 무한 대기 상태에 빠져 서버가 멈춤 (Hang)
DB Lock Contention
(잠금 경합)
DB의 특정 Row를 수정하려고 너무 많은 트랜잭션이 대기함.쿼리 응답 속도가 급격히 느려짐

2. 기본 동기화 기법

2-1. 임계 영역(Critical Section)과 락(Lock)

  • 임계 영역 (Critical Section): 공유 자원에 접근하는 코드 영역. 이곳은 동시에 여러 스레드가 실행하면 안 됩니다.
  • 뮤텍스 (Mutex/Lock): 임계 영역에 들어가기 위한 '열쇠'입니다. 열쇠를 가진 스레드만 들어갈 수 있고, 나올 때 열쇠를 반납해야 다른 스레드가 들어갈 수 있습니다.

2-2. 자바의 synchronized 키워드

자바는 synchronized 키워드를 통해 언어 차원에서 가장 기본적인 락을 제공합니다.

1) 메서드 동기화 (Method Synchronization)

메서드 전체를 임계 영역으로 지정합니다. 인스턴스 단위(this)로 락이 걸립니다.

public synchronized void increaseCount() {
    this.count++; // 한 번에 하나의 스레드만 이 코드를 실행 가능
}

2) 블록 동기화 (Block Synchronization) (권장)

메서드 전체를 막으면 성능이 떨어질 수 있습니다. 필요한 부분만 딱 잘라서 막는 것이 효율적입니다.

public void updateInfo() {
    // 동기화가 필요 없는 코드 (병렬 실행 가능)
    String data = makeData(); 

    // 꼭 필요한 부분만 잠금 (this 또는 별도의 lock 객체 사용)
    synchronized (this) {
        this.sharedData = data;
    }
}

성능 주의: 동기화 범위(Scope)가 넓을수록 병렬 처리가 안 되어 성능이 떨어집니다. "최소한의 영역"만 동기화하는 것이 기술입니다.


3. 스레드 안전한 컬렉션 (Thread-Safe Collections)

3-1. ConcurrentHashMap

  • HashMap의 멀티스레드 버전입니다.
  • 특징: 맵 전체에 락을 거는(Hashtable 방식) 비효율적인 방식이 아니라, 데이터가 들어있는 '버킷' 단위로 쪼개서 락(Lock Striping)을 걸거나 CAS 알고리즘을 사용합니다.
  • 장점: 여러 스레드가 동시에 읽고 써도 성능 저하가 거의 없습니다.

3-2. BlockingQueue

  • 큐(Queue)인데, 꽉 차거나 비었을 때 자동으로 대기(Block)하는 기능이 있습니다.
  • 활용: 생산자-소비자(Producer-Consumer) 패턴을 구현할 때, 개발자가 직접 wait(), notify()를 구현할 필요 없이 이 큐만 쓰면 해결됩니다.

3-3. 실무에서 사용하는 Thread-safe Collection 정리

분류클래스 이름특징 및 추천 상황
MapConcurrentHashMap(가장 권장) 읽기/쓰기 동시성이 매우 뛰어남.
ListCopyOnWriteArrayList읽기는 많고 쓰기는 적을 때 (예: 이벤트 리스너 목록). 쓸 때마다 복사본을 만들어서 락 없이 읽기 가능.
QueueLinkedBlockingQueue(가장 권장) 일반적인 큐 작업에 사용. 크기 제한 가능.
QueueConcurrentLinkedQueue락을 쓰지 않는(Lock-Free) 큐. 대기 없이 엄청나게 빠른 처리가 필요할 때 사용.
SetConcurrentSkipListSet정렬이 필요한 멀티스레드 Set. (ConcurrentHashSet은 별도로 없으므로 ConcurrentHashMap을 응용해서 씀)

Executor 프레임워크와 스레드 풀 (Thread Pool)

앞서 배운 new Thread(...).start() 방식은 간단하지만, 실제 운영 환경에서 그대로 쓰기엔 위험합니다. 요청이 올 때마다 스레드를 무한정 생성하면 메모리 부족(OOM)이나 CPU 과부하로 서버가 죽을 수 있기 때문입니다.

이 문제를 해결하기 위해 등장한 것이 바로 스레드 풀(Thread Pool)Executor 프레임워크입니다.

1. 스레드 풀(Thread Pool)의 개념

스레드 풀은 말 그대로 "스레드를 미리 만들어 놓은 수영장(Pool)"입니다.

  • 작동 원리:
    1. 서버가 켜질 때 미리 일정 개수(Limit)의 스레드를 생성해 둡니다.
    2. 작업 요청이 들어오면 대기 중인 스레드를 빌려줍니다.
    3. 작업이 끝나면 스레드를 버리는 게 아니라, 다시 풀로 반납하여 재사용합니다.
    4. 만약 모든 스레드가 바쁘다면? 작업은 큐(Queue)에서 대기합니다.

1-1. 스레드 풀을 사용하는 이유 (이점)

왜 굳이 복잡하게 풀을 만들어 쓸까요? 가장 큰 이유는 안정성효율성입니다.

구분효과 및 장점
스레드 재사용스레드 생성과 삭제는 OS 입장에서 매우 비싼 작업입니다. 이를 재사용하여 생성 비용을 아끼고 GC(Garbage Collection) 부담을 줄입니다.
동시성 제어동시에 실행되는 스레드 개수를 제한합니다. 트래픽이 폭주해도 스레드가 무한정 늘어나 서버가 다운되는 것을 방지합니다.
작업 큐 관리당장 처리할 수 없는 요청은 큐(Queue)에 안전하게 쌓아둡니다(Buffering). 요청을 유실하지 않고 순차적으로 처리할 수 있게 합니다.
책임 분리"비즈니스 로직(무엇을 할지)"과 "실행 메커니즘(어떻게 실행할지)"을 분리하여 코드가 깔끔해지고 유지보수가 쉬워집니다.

2. 적정 스레드 개수의 중요성 (Tuning)

  • 스레드가 너무 많으면:
    • CPU가 이 스레드 저 스레드를 왔다 갔다 하느라 시간을 다 씁니다. (Context Switching 오버헤드 증가)
    • 메모리를 과도하게 점유하여 OutOfMemoryError가 발생할 수 있습니다.
  • 스레드가 너무 적으면:
    • CPU가 펑펑 놀고 있는데도, 일할 스레드가 없어서 요청이 큐에서 하염없이 기다립니다. (처리량 저하)

3. Executor 프레임워크

자바 5부터는 개발자가 직접 스레드를 new로 만들고 관리하는 것을 권장하지 않습니다. 대신 Executor 프레임워크를 사용합니다.

3-1. Executor(실행기)란?

"작업의 등록(Submission)""작업의 실행(Execution)"을 분리해 주는 표준 인터페이스입니다.

  • 기존 방식: 개발자가 직접 스레드를 만들고 start() 함. (작업과 실행이 강결합)
  • Executor 방식: 개발자는 Executor에게 작업을 넘기기만(submit) 하면, Executor가 알아서 스레드 풀을 쓰든, 큐에 넣든 처리해 줌.

3-2. Java 비동기 실행의 핵심 인터페이스

자바의 java.util.concurrent 패키지는 스레드 관리를 위해 3단계의 인터페이스 상속 구조를 제공합니다. 갈수록 기능이 강력해지는 구조입니다.

계층 구조(Hierarchy)
Executor (기본 실행)
⬇️
ExecutorService (라이프사이클 관리 + Future)
⬇️
ScheduledExecutorService (스케줄링 기능 추가)

1. Executor 인터페이스

가장 단순하고 기본적인 인터페이스입니다. "작업을 등록하는 곳"과 "작업을 실행하는 곳"을 분리하는 데 의의가 있습니다.

  • 특징: execute(Runnable) 메서드 딱 하나만 가지고 있습니다.
  • 한계: 작업이 끝났는지 알 수 없고, 스레드 풀을 종료시키는 기능도 없습니다.
public class ExecutorExample {
    public static void main(String[] args) {
        // Executor 구현: 단순히 요청마다 새로운 스레드를 만드는 방식
        Executor executor = command -> {
            System.out.println("[Executor] 새로운 스레드 생성하여 실행");
            new Thread(command).start();
        };

        // 호출자는 내부가 어떻게 도는지 모른 채 작업만 던짐(Decoupling)
        executor.execute(() -> System.out.println("작업 실행 1"));
        executor.execute(() -> System.out.println("작업 실행 2"));
    }
}

2. ExecutorService 인터페이스

Executor를 상속받아 비동기 작업의 관제탑 역할을 하는 인터페이스입니다. 실제 개발에서 가장 많이 사용됩니다.

  • 핵심 기능:
    • 라이프사이클 관리: shutdown(), shutdownNow() 등을 통해 스레드 풀을 안전하게 종료할 수 있습니다.
    • 결과 반환: submit()을 통해 작업의 결과를 담은 Future 객체를 받을 수 있습니다.
public class ExecutorServiceExample {
    public static void main(String[] args) throws Exception {
        // 1. 고정 크기(2개) 스레드 풀 생성
        ExecutorService service = Executors.newFixedThreadPool(2);

        // 2-1. execute(): 결과가 필요 없는 작업 (Runnable)
        service.execute(() -> System.out.println("Runnable 작업 실행 (리턴 없음)"));

        // 2-2. submit(): 결과가 필요한 작업 (Callable) -> Future 반환
        Future<Integer> result = service.submit(() -> {
            System.out.println("Callable 작업 실행 (계산 중...)");
            Thread.sleep(1000); // 1초 소요 가정
            return 100;
        });

        // 3. 결과 확인 (블로킹)
        // get()은 작업이 끝날 때까지 메인 스레드를 대기시킴
        Integer value = result.get(); 
        System.out.println("Callable 결과: " + value);

        // 4. 종료 (필수! 안 하면 앱이 안 꺼짐)
        service.shutdown();
    }
}

execute() vs submit()

  • execute(): Runnable만 받으며, 리턴값이 없습니다. 예외 발생 시 스레드가 종료될 수 있습니다.
  • submit(): RunnableCallable을 모두 받으며, Future를 반환합니다. 예외가 발생해도 Future.get() 호출 시점에 알 수 있어 더 안전합니다.

3. ScheduledExecutorService 인터페이스

특정 시간 뒤에 실행하거나, 일정 간격으로 반복 실행해야 할 때 사용합니다. 과거의 Timer 클래스를 대체하는 더 강력한 도구입니다.

  • 특징: 스레드 풀 기반이므로 하나의 작업이 오래 걸려도 다른 예정된 작업에 영향을 덜 줍니다. (Timer는 단일 스레드라 영향받음)
public class ScheduledExecutorExample {
    public static void main(String[] args) {
        // 1개의 스레드를 가진 스케줄러 생성
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

        // 1) 지연 실행 (Delay): 2초 뒤에 딱 한 번 실행
        scheduler.schedule(
            () -> System.out.println("▶ 2초 후 단발성 실행"), 
            2, TimeUnit.SECONDS
        );

        // 2) 주기적 실행 (Fixed Rate): 1초 대기 후, 3초'마다' 반복
        // 주의: 이전 작업이 3초보다 오래 걸려도 시작 시간을 기준으로 실행하려 함
        scheduler.scheduleAtFixedRate(
            () -> System.out.println("주기 실행 중..."),
            1, // 초기 대기 시간 (Initial Delay)
            3, // 반복 주기 (Period)
            TimeUnit.SECONDS
        );

        // 테스트를 위해 10초 뒤에 스케줄러 자체를 종료
        scheduler.schedule(() -> {
            System.out.println("⏹ 스케줄러 종료");
            scheduler.shutdown();
        }, 10, TimeUnit.SECONDS);
    }
}

4. 자주 사용되는 스레드 풀 유형 4가지

4-1. 고정 크기 스레드 풀 (Fixed Thread Pool)

가장 일반적으로 사용되는 형태입니다.

  • 특징: 스레드 개수를 미리 지정(nThreads)해 두고, 그 안에서만 작업을 돌립니다.
  • 장점: 서버의 CPU, 메모리 사용량을 예측 가능하게 제한할 수 있어 안정적입니다.
  • 주의: 작업 큐(Queue)의 크기가 무제한이라, 작업이 처리를 못 따라갈 정도로 쌓이면 메모리 부족(OOM)이 발생할 수 있습니다.
public class FixedThreadPoolExample {
    public static void main(String[] args) {
        // 스레드 2개로 고정
        ExecutorService pool = Executors.newFixedThreadPool(2);

        for (int i = 1; i <= 5; i++) {
            int taskId = i;
            pool.submit(() -> 
                System.out.println("FixedPool 작업 " + taskId + 
                " 실행 스레드: " + Thread.currentThread().getName())
            );
        }
        pool.shutdown();
    }
}

4-2. 캐싱 스레드 풀 (Cached Thread Pool)

유동적으로 스레드를 관리하는 방식입니다.

  • 특징:
    • 작업이 들어왔는데 놀고 있는 스레드가 있다면 그걸 재사용합니다.
    • 없다면? 즉시 새 스레드를 생성합니다. (개수 제한 없음)
    • 60초 동안 안 쓰인 스레드는 제거합니다.
  • 추천 상황: 실행 시간이 짧은 작업이 간헐적으로 폭발적으로 들어올 때 유리합니다.
  • 주의: 작업 요청이 끊임없이 몰리면 스레드가 무한정 생성되어 서버가 다운될 수 있습니다.
public class CachedThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newCachedThreadPool();

        for (int i = 1; i <= 5; i++) {
            int taskId = i;
            pool.submit(() -> 
                System.out.println("CachedPool 작업 " + taskId + 
                " 실행 스레드: " + Thread.currentThread().getName())
            );
        }
        pool.shutdown();
    }
}

4-3. 단일 스레드 실행자 (Single Thread Executor)

스레드 풀인데 스레드가 딱 1개뿐인 독특한 녀석입니다.

  • 특징: 무조건 한 번에 하나씩 작업을 처리합니다.
  • 용도: "순서가 보장되어야 하는 작업" 이나, 동기화 문제 없이 안전하게 처리해야 하는 로직에 사용합니다.
ExecutorService pool = Executors.newSingleThreadExecutor();
// 작업 1, 2, 3, 4, 5가 반드시 순서대로 실행됨이 보장됨

4-4. 예약 스레드 풀 (Scheduled Thread Pool)

  • 특징: ScheduledExcecutorService 구현체를 생성합니다.
  • 용도: 배치 작업, 주기적인 상태 체크, 로그 전송 등.
public class ScheduledThreadPoolExample {
    public static void main(String[] args) {
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);

        // 1) 3초 뒤에 딱 한 번 실행
        scheduler.schedule(
            () -> System.out.println("3초 후 실행"), 
            3, TimeUnit.SECONDS
        );

        // 2) 1초 대기 후, 2초마다 반복 실행
        scheduler.scheduleAtFixedRate(
            () -> System.out.println("2초마다 반복"),
            1, 2, TimeUnit.SECONDS
        );
    }
}

5. 스레드 풀 구성 및 관리

5-2. 적정 스레드 풀 크기 결정

  • CPU 바운드 작업 (계산 위주): CPU 코어 수 + 1 권장. (컨텍스트 스위칭 최소화)
  • I/O 바운드 작업 (DB, 외부 API): 코어 수보다 넉넉하게 설정. (대기 시간에 다른 스레드가 일하도록)
public class ThreadPoolSizeExample {
    public static void main(String[] args) {
        int cores = Runtime.getRuntime().availableProcessors();
        System.out.println("내 컴퓨터 코어 수: " + cores);

        // CPU 작업 위주라면 코어 수에 맞추는 게 효율적
        ExecutorService cpuPool = Executors.newFixedThreadPool(cores + 1);
        
        cpuPool.shutdown();
    }
}

5-2. 우아한 종료 (Graceful Shutdown)

shutdown()만 호출하고 끝내면, 아직 돌고 있는 작업이 강제로 끊기거나 큐에 남은 작업이 유실될 수 있습니다.

pool.shutdown(); // 1. 종료 요청

try {
    // 2. 5초간 대기
    if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
        System.out.println("시간 초과! 강제 종료 시도");
        pool.shutdownNow(); // 3. 강제 종료
    }
} catch (InterruptedException e) {
    pool.shutdownNow();
}

Future와 Callable

1. Runnable의 한계와 Callable의 등장

1-1. Runnable: "일은 하는데 보고를 못 함"

Runnable 인터페이스는 치명적인 단점이 있었습니다. 바로 void 반환 타입입니다.

  • 문제점: 작업을 시켰는데 결과를 돌려받을 방법이 없습니다. 예외가 터져도 호출한 쪽에서는 알 길이 없습니다.
Runnable task = () -> {
    System.out.println("작업 실행 중...");
    // return 100; // ❌ 컴파일 에러! 값을 반환할 수 없음
};
new Thread(task).start();

1-2. Callable: "일하고 결과도 보고함"

이 문제를 해결하기 위해 Java 5부터 Callable이 등장했습니다.

  • 특징: 제네릭을 통해 반환 타입을 지정할 수 있고, Exception을 던질 수도 있습니다.
특징RunnableCallable
반환 값없음 (void)있음 (제네릭 V)
예외 처리체크 예외 던질 수 없음 (내부 처리 필수)throws Exception 가능
주 사용처단순 실행 (Thread, Executor)결과가 필요한 실행 (ExecutorService)
// 반환 타입을 String으로 지정
Callable<String> task = () -> {
    System.out.println("Callable 실행");
    return "작업 완료!";
};

ExecutorService service = Executors.newSingleThreadExecutor();
// submit()하면 Future 객체를 바로 줌 (교환권 같은 개념)
Future<String> future = service.submit(task);

2. Future 인터페이스: "비동기 결과의 교환권"

Future는 말 그대로 "미래에 완료될 작업의 결과"를 담고 있는 객체입니다. 식당에서 주문하고 받은 진동벨과 똑같습니다.

2-1. 핵심 기능

  • get(): "음식 나왔나요?" 하고 진동벨을 확인하는 것과 같습니다.
    • 아직 안 끝났으면? 끝날 때까지 기다립니다(Blocking).
    • 끝났으면? 결과를 바로 줍니다.
  • isDone(): "아직 안 됐나요?" 하고 확인만 하는 메서드입니다(Non-blocking).
  • cancel(): "주문 취소할게요"라고 요청합니다.

2-2. 사용 예시

public class FutureExample {
    public static void main(String[] args) throws Exception {
        ExecutorService service = Executors.newSingleThreadExecutor();

        System.out.println("[메인] 작업 제출");
        Future<Integer> future = service.submit(() -> {
            Thread.sleep(2000); // 2초 걸리는 작업
            return 100;
        });

        System.out.println("[메인] 다른 일 처리 중...");

        // 결과가 필요할 때 get() 호출
        // 만약 작업이 안 끝났다면 여기서 멈춤 (Blocking)
        Integer result = future.get(); 
        
        System.out.println("[메인] 결과 수신: " + result);
        service.shutdown();
    }
}

타임아웃 활용
future.get()을 그냥 쓰면 무한정 기다릴 수 있어 위험합니다.
future.get(1, TimeUnit.SECONDS) 처럼 시간 제한을 두는 것이 실무에서의 안전한 패턴입니다.


3. ExecutorService의 강력한 기능들

ExecutorService는 단순히 작업 하나만 처리하는 게 아니라, 여러 작업을 효율적으로 관리하는 기능도 제공합니다.

3-1. invokeAll()과 invokeAny()

  • invokeAll(): 여러 작업을 동시에 시키고, "모두 끝날 때까지" 기다립니다. (모든 결과가 필요할 때)
  • invokeAny(): 여러 작업을 동시에 시키고, "가장 먼저 끝난 놈 하나"만 받습니다. (나머지는 취소함. 빠른 응답이 필요할 때)
List<Callable<String>> tasks = Arrays.asList(
    () -> { Thread.sleep(3000); return "느린 작업"; },
    () -> { Thread.sleep(1000); return "빠른 작업"; }
);

// 가장 빨리 끝난 "빠른 작업" 결과만 리턴됨
String firstResult = service.invokeAny(tasks);

4. Future의 한계

4-1. 외부에서 강제로 완료시킬 수 없다.

진동벨이 고장 나면 손님이 직접 가서 받아와야 하는데, Future는 그게 안 됩니다. (예외 처리나 값 설정을 외부에서 개입 불가)

4-2. 블로킹 코드(get)를 피할 수 없다.

이게 가장 큽니다. 비동기로 실행은 했지만, 결국 결과를 보려면 get()을 호출해서 기다려야 합니다. 진정한 의미의 Non-blocking이 아닙니다.

4-3. 여러 작업을 엮기가 너무 어렵다. (Chaining 불가)

"A 작업 끝나면, 그 결과로 B 작업 하고, 그 다음에 C 작업 해줘" 같은 시나리오를 짜려면, 코드가 지저분해집니다.

// Future로 연쇄 작업을 하려면...
Future<Integer> f1 = service.submit(task1);
Integer result1 = f1.get(); // 여기서 멈춤 (Blocking)

Future<Integer> f2 = service.submit(() -> task2(result1)); // 다시 제출
Integer result2 = f2.get(); // 또 멈춤 (Blocking)

CompletableFuture 기초

1. 등장 배경

1-1. Future의 한계 극복

  • Non-blocking: get()을 호출해서 멍하니 기다릴 필요 없이, "작업이 끝나면 이거 해줘"라고 할 일을 미리 등록할 수 있습니다.
  • Callback Chain: 작업 A가 끝나면 B를 하고, 그 결과로 C를 하라는 식의 연쇄 작업을 직관적으로 작성할 수 있습니다.

1-2. 함수형 프로그래밍 스타일 지원

람다(Lambda) 표현식을 사용하여 비동기 로직을 깔끔하게 작성할 수 있습니다.

// Future와 달리 get() 없이 흐름을 연결함
CompletableFuture.supplyAsync(() -> "Hello")
    .thenApply(s -> s + " World")
    .thenAccept(System.out::println);

2. CompletableFuture 생성 방법

가장 먼저 해야 할 일은 비동기 작업을 시작하는 것입니다. 크게 두 가지 메서드를 사용합니다.

2-1. runAsync() vs supplyAsync()

반환값이 있냐 없냐에 따라 선택하면 됩니다.

메서드반환 타입설명
runAsyncCompletableFuture<Void>반환값이 없는 작업 (Runnable) 실행
supplyAsyncCompletableFuture<T>반환값이 있는 작업 (Supplier<T>) 실행
public class AsyncCreateExample {
    public static void main(String[] args) {
        // 1. 반환값이 없는 경우 (Runnable)
        CompletableFuture<Void> f1 = CompletableFuture.runAsync(() -> 
            System.out.println("runAsync: 단순히 실행만 함")
        );

        // 2. 반환값이 있는 경우 (Supplier)
        CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(() -> {
            System.out.println("supplyAsync: 계산 후 리턴");
            return 100;
        });

        // 결과 확인 (join은 예외 처리가 필요 없는 get)
        System.out.println("결과: " + f2.join());
    }
}

2-2. 스레드 풀 지정하기

기본적으로 CompletableFutureForkJoinPool.commonPool()이라는 공유 스레드 풀을 사용합니다.
하지만 실무에서 DB 연결 등 I/O 작업이 많다면, 반드시 커스텀 스레드 풀(Executor)을 별도로 넘겨줘야 성능 저하를 막을 수 있습니다.

ExecutorService myPool = Executors.newFixedThreadPool(10);

CompletableFuture.supplyAsync(() -> {
    return "커스텀 스레드 풀에서 실행";
}, myPool); // 두 번째 인자로 Executor 전달

3. 작업 흐름 만들기 (Chaining)

비동기 작업의 결과를 받아 다음 단계로 넘기는 메서드들입니다.

3-1. 핵심 메서드

메서드입력반환역할
thenApplyOO결과를 받아서 변환 후 반환 (Map)
thenAcceptOX결과를 받아서 소비만 하고 끝냄 (Consumer)
thenRunXX결과 상관없이 다음 작업 실행 (Runnable)

3-2. 체이닝 예제 코드

CompletableFuture.supplyAsync(() -> 10) // 1. 10을 생성 (시작)
    .thenApply(n -> n * 2)              // 2. 20으로 변환 (thenApply)
    .thenApply(n -> "결과: " + n)       // 3. 문자열로 변환 (thenApply)
    .thenAccept(s ->                    // 4. 출력하고 끝 (thenAccept)
        System.out.println(s) 
    )
    .thenRun(() ->                      // 5. 마무리 작업 (thenRun)
        System.out.println("모든 작업 완료!") 
    );

4. 결과 가져오기: get() vs join()

최종적으로 결과를 꺼내야 할 때 사용하는 메서드입니다.

4-1. 차이점 비교 (면접 단골)

구분get()join()getNow(default)
정의Future 인터페이스의 메서드CompletableFuture의 메서드즉시 반환 메서드
예외 처리Checked Exception
(try-catch 필수)
Unchecked Exception
(try-catch 불필요)
예외 없음
특징코드가 지저분해짐람다 식에서 쓰기 편함 (권장)아직 안 끝났으면 기본값 반환

4-2. 사용 예시

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 10);

// 1. get(): try-catch 강제
try {
    Integer result = future.get();
} catch (ExecutionException | InterruptedException e) {
    e.printStackTrace();
}

// 2. join(): 코드가 깔끔함 (권장)
Integer result2 = future.join();

// 3. getNow(): 안 기다리고 바로 값 확인 (없으면 기본값)
Integer result3 = future.getNow(0);

요약
CompletableFuture"비동기 작업을 파이프라인처럼 연결하는 것"이 핵심입니다.
supplyAsync로 시작해서 thenApply로 가공하고, thenAccept로 마무리하는 패턴을 기억하세요!


Java CompletableFuture 실전: 조합, 예외 처리, 그리고 디자인 패턴

1. 비동기 작업의 조합 (Combination)

비동기 작업은 혼자 돌 때보다, 다른 작업과 합쳐질 때 더 강력합니다.

1-1. 순차 연결: thenCompose()

  • 상황: A 작업의 결과를 받아서 B 작업을 실행해야 할 때 (의존성 O)
  • 특징: Future 안에 Future가 중첩되는 것을 방지해 줍니다 (FlatMap과 유사).
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "userId_123")
    .thenCompose(userId -> {
        // 첫 번째 결과(userId)를 받아서 새로운 비동기 작업 시작
        return CompletableFuture.supplyAsync(() -> "User Info: " + userId);
    });

System.out.println(future.join());

1-2. 독립 실행 후 결합: thenCombine()

  • 상황: A와 B를 동시에 시키고, 둘 다 끝나면 결과를 합쳐야 할 때 (의존성 X)
  • 특징: 병렬 처리의 이점을 극대화할 수 있습니다.
CompletableFuture<Integer> priceTask = CompletableFuture.supplyAsync(() -> 1000); // 가격 조회
CompletableFuture<Double> rateTask = CompletableFuture.supplyAsync(() -> 0.1);    // 할인율 조회

CompletableFuture<Double> result = priceTask.thenCombine(rateTask, (price, rate) -> {
    return price * (1 - rate); // 두 결과가 모두 오면 계산
});

System.out.println("최종 가격: " + result.join());

1-3. 다수 작업 집계: allOf() vs anyOf()

  • allOf(Future...): 모든 작업이 끝날 때까지 대기. (반환값은 Void이므로, 개별 Future에서 get()으로 값을 꺼내야 함)
  • anyOf(Future...): 가장 먼저 끝난 하나의 결과만 반환.

2. 견고한 예외 처리 (Exception Handling)

비동기 작업 중 에러가 발생했을 때, 시스템이 멈추지 않고 대체 값을 반환하거나 로그를 남기게 해야 합니다.

메서드역할반환값 변경실행 시점
exceptionally예외 발생 시 대체값 반환 (Catch)가능예외 발생 시
handle정상/예외 모두 처리 (Try-Catch-Finally)가능항상
whenComplete결과 기록, 리소스 정리 (Peek)불가능항상

2-1. 예시 코드

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    if (true) throw new RuntimeException("API 호출 실패!");
    return "정상 결과";
});

// 1. exceptionally: 에러가 났을 때만 실행 (복구)
future.exceptionally(ex -> {
    System.out.println("에러 발생: " + ex.getMessage());
    return "기본값(Fallback)";
});

// 2. handle: 결과(res)와 에러(ex)를 모두 받아서 처리
future.handle((res, ex) -> {
    if (ex != null) return "에러 복구";
    return res;
});

3. 타임아웃 처리 (Java 9+)

비동기 작업이 무한정 길어지는 것을 방지하기 위해 타임아웃은 필수입니다.

  • orTimeout(시간): 시간 내 안 끝나면 TimeoutException 발생 (Fail-fast)
  • completeOnTimeout(값, 시간): 시간 내 안 끝나면 기본값 반환 (Fallback)
CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> {
    try { Thread.sleep(3000); } catch (InterruptedException e) {}
    return "느린 응답";
});

// 1초 안에 안 끝나면 "기본값"을 반환하고 종료
f.completeOnTimeout("기본값", 1, TimeUnit.SECONDS);

4. 실전 비동기 패턴 (Best Practices)

4-1. 대용량 데이터 배치 처리 (Batch Processing)

데이터가 10만 개인데 동시에 10만 개의 비동기 요청을 날리면 서버가 터집니다. 데이터를 쪼개서(Chunk) 처리해야 합니다.

public void processLargeData(List<Item> items, ExecutorService pool) {
    int batchSize = 1000; // 1000개씩 끊어서 처리
    List<CompletableFuture<Void>> futures = new ArrayList<>();

    for (int i = 0; i < items.size(); i += batchSize) {
        int end = Math.min(i + batchSize, items.size());
        List<Item> batch = items.subList(i, end);

        // 배치 단위로 비동기 작업 생성
        CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
            processBatch(batch); // DB 저장 등
        }, pool);
        
        futures.add(future);
    }

    // 모든 배치가 끝날 때까지 대기
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}

4-2. Parallel Stream vs CompletableFuture

  • Parallel Stream: CPU 바운드 작업에 유리. ForkJoinPool.commonPool()을 공유하므로 I/O 작업 시 전체 성능 저하 위험.
  • CompletableFuture: I/O 바운드 작업에 강력 추천. 전용 스레드 풀을 할당하여 제어 가능.

4-3. 스레드 풀 전략 (핵심)

  • CPU 작업 위주: FixedThreadPool(코어 수 + 1)
  • I/O 작업 위주 (DB, API): CachedThreadPool 또는 FixedThreadPool(넉넉한 개수)
  • : 웹 서버의 요청 처리 풀과, 백그라운드 작업용 비동기 풀을 분리해야 장애 전파를 막을 수 있습니다.
profile
Backend engineer

0개의 댓글