동기 및 비동기 처리는 시스템이 작업을 수행하는 흐름 제어 방식을 나타냅니다.
동기 처리는 요청이 시작된 시점부터 해당 작업이 완료될 때까지 프로그램의 흐름이 차례대로 (순차적으로) 진행되는 실행 방식입니다.
| 특징 | 설명 |
|---|---|
| 직관성 및 단순성 | 실행 흐름이 직관적이며 제어 구조가 단순하여 디버깅이 쉽습니다. |
| 성능 제약 | 작업 A의 대기 시간(I/O, 외부 API 호출 등)이 길어지면, 뒤따르는 작업 B, C는 무작정 기다려야 하므로 전체 처리 시간이 증가합니다. |
| 자원 비효율 | 단일 스레드 기반 환경에서는 하나의 긴 작업이 스레드를 차단(Blocking)하여 다른 요청 처리를 막으므로, 응답 지연과 시스템 자원(스레드) 비효율이 발생하기 쉽습니다. |
비동기 처리는 작업 요청을 보낸 후 해당 작업의 결과를 즉시 기다리지 않고 다음 작업을 이어서 수행하는 실행 방식입니다. 작업을 백그라운드에서 처리함으로써 메인 스레드의 블로킹을 방지합니다.
| 특징 | 설명 |
|---|---|
| 자원 활용 효율 증대 | "대기 시간이 긴 작업(I/O 작업, 네트워크 통신)에서 스레드가 대기 상태에 빠지지 않고 다른 요청을 처리할 수 있으므로, 시스템 자원 활용 효율이 극대화됩니다." |
| 응답성 향상 | 여러 작업을 동시(Concurrency)에 처리하여 사용자 요청에 대한 응답 시간(Latency)을 단축하고 전반적인 처리량(Throughput)을 향상시킵니다. |
| 복잡한 흐름 제어 | "작업 완료 시점을 예측하기 어렵고, 결과 처리 순서를 보장하기 위해 콜백 지옥(Callback Hell) 등의 문제가 발생할 수 있어 흐름 제어가 복잡해지고 디버깅 난이도가 증가합니다." |
| 고성능 처리 | 멀티 스레드 환경에서 동시성(Concurrency)을 효과적으로 활용하여 고성능 처리가 가능합니다. |
Spring Boot와 같은 웹 백엔드 환경에서는 대부분의 요청이 외부 I/O 작업(데이터베이스 조회, 외부 API 통신 등)을 포함하고 있어 대기 시간이 깁니다.
이러한 환경에서 동기 처리를 사용하면 제한된 스레드 풀의 스레드가 I/O 작업 완료를 기다리느라 낭비되어, 결국 서비스의 응답성 저하와 동시 접속자 수 처리 능력(Capacity) 제한이라는 병목 현상을 초래하게 됩니다.
따라서, 시스템의 확장성과 처리량을 높이기 위해 비동기 처리는 현대적인 백엔드 시스템에서 필수적인 요소입니다.
블로킹은 작업을 호출한 함수가 해당 작업의 완료를 보장할 때까지 자신의 제어권을 호출된 함수에게 넘겨주고 대기하는 처리 방식입니다.
블로킹 예시 (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);
}
}
논블로킹은 작업을 호출한 함수가 작업을 요청한 후, 완료 여부와 관계없이 호출된 함수로부터 즉시 제어권을 돌려받아 자신의 다음 작업을 계속 수행하는 방식입니다.
논블로킹 예시 (데이터 폴링 방식)
데이터를 읽을 준비가 되었는지 반복적으로 확인(폴링)하며 블록되지 않고 다른 작업을 수행할 수 있습니다.
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);
}
}
동기/비동기 (Synchronous/Asynchronous)
블로킹/논블로킹 (Blocking/Non-Blocking)
이 조합은 "다른 일을 할 수는 있지만(Non-blocking), 결과는 계속 확인하는(Sync)" 상황입니다.
상황 예시:
팀장(Caller)이 사원(Callee)에게 업무를 시킵니다.
기술적 예시 (Polling):
Java나 JavaScript에서 특정 작업이 끝났는지 반복적으로 확인하는 폴링(Polling) 방식이 대표적입니다. 제어권은 바로 리턴받았지만, 결과가 나올 때까지 계속 물어보는 형태입니다.
// Java Future의 isDone()을 루프 돌며 확인하는 경우
Future<String> future = executor.submit(task);
while(!future.isDone()) {
// 작업이 안 끝났으면 다른 작업을 수행 (Non-blocking)
doSomethingElse();
}
// 결국 결과를 직접 확인해서 가져옴 (Synchronous)
String result = future.get();
이 조합은 "결과가 오면 알아서 처리되도록 맡겨놨는데(Async), 정작 나는 아무것도 안 하고 기다리는(Blocking)" 다소 비효율적인 상황입니다.
상황 예시:
팀장(Caller)이 사원(Callee)에게 업무를 시킵니다.
기술적 예시 (의도치 않은 실수):
보통 개발자의 실수나, 기술적인 제약으로 인해 발생합니다. 비동기 메서드를 호출해 놓고 바로 결과를 달라고 기다리는 경우입니다.
// 1. 비동기로 작업을 시작함 (Asynchronous)
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return lengthyTask();
});
// 2. 하지만 get()을 호출하는 순간, 결과가 나올 때까지 스레드는 대기(Blocking)함
// 비동기의 이점을 살리지 못한 케이스
String result = future.get();
참고: Node.js와 MySQL을 연동할 때도, 코드 자체는 비동기 콜백 패턴이지만 내부 드라이버가 블로킹으로 동작하는 경우 이런 현상이 발생하기도 했습니다.
기존의 Spring MVC와 같은 전통적인 서버 모델에서 가장 많이 사용되는 방식입니다.
자바에서는 비동기 처리를 위해 다음과 같은 도구를 제공합니다.
| 도구 | 특징 |
|---|---|
| ExecutorService | 스레드 풀을 생성하고 관리하는 추상화된 실행 서비스입니다. 작업(Task) 제출과 실행을 분리하여 안정적인 제어가 가능합니다. |
| CompletableFuture | Java 8부터 도입된 고수준 비동기 프로그래밍 도구입니다. 비동기 작업의 결과 처리, 체이닝(Chaining), 예외 처리 등을 선언적으로 작성할 수 있습니다. |
Node.js나 Spring WebFlux에서 사용하는 방식으로, 적은 리소스로 엄청난 동시성을 처리하기 위해 고안되었습니다.
Spring 5.0부터는 이러한 리액티브 프로그래밍을 공식 지원합니다.
Mono(0~1개 데이터)와 Flux(N개 데이터)라는 타입을 통해 데이터 흐름을 비동기적으로 다룹니다.시스템 간의 결합도를 낮추고(Decoupling), 대용량 트래픽을 처리하기 위한 아키텍처 레벨의 비동기 방식입니다.
| 구분 | Kafka (카프카) | RabbitMQ (래빗MQ) |
|---|---|---|
| 핵심 컨셉 | 분산 로그 기반 스트리밍 플랫폼 | 전통적인 메시지 브로커 |
| 특징 | - 압도적인 처리량(Throughput) - 대용량 데이터의 실시간 로그 수집/분석에 최적화 | - 복잡하고 유연한 라우팅(Routing) - 메시지 우선순위, 지연 발송 등 정교한 기능 제공 |
| 용도 | 이벤트 스트리밍, 로그 집계, 빅데이터 파이프라인 | 업무 복잡도가 높은 마이크로서비스 간 통신 |
스레드는 프로세스 내부에서 실행 흐름을 담당하는 가장 작은 실행 단위입니다.
하나의 프로세스는 여러 스레드를 가질 수 있으며(Multi-thread), 이들은 메모리를 공유하며 동시에 작업을 수행합니다.
이 둘의 가장 큰 차이는 "메모리 공유 여부"입니다.
| 구분 | 프로세스 (Process) | 스레드 (Thread) |
|---|---|---|
| 정의 | 운영체제로부터 자원을 할당받은 작업 단위 | 프로세스 내부의 실행 흐름 단위 |
| 메모리 | 독립된 메모리 공간 (Code, Data, Heap, Stack) | Stack만 독립적, Code/Data/Heap은 공유 |
| 통신 | IPC(Inter-Process Communication) 필요 (어려움) | 공유 메모리(Heap)를 통해 통신 (쉬움) |
| 안정성 | 하나가 죽어도 다른 프로세스에 영향 없음 | 하나의 스레드 오류가 프로세스 전체를 종료시킬 수 있음 |
| 오버헤드 | 생성/전환(Context Switching) 비용이 큼 | 생성/전환 비용이 비교적 적음 (Lightweight) |
자바 애플리케이션은 JVM(Java Virtual Machine) 위에서 돌아갑니다.
main() 메서드를 실행하며 시작되는 최초의 스레드입니다.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();
}
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()vsstart()
run(): 단순한 메서드 호출입니다. 새로운 스레드가 생기지 않고, 현재 스레드에서 순차적으로 실행됩니다.start(): 새로운 호출 스택(Call Stack)을 생성하고, OS 스케줄러에게 실행을 요청하여 멀티 스레드로 동작하게 합니다.
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("메인 스레드 종료 (작업 완료 후 실행됨)");
}
}
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(); // 스레드에 인터럽트 요청
}
}
InterruptedException은 sleep(), join() 도중 인터럽트가 발생했을 때 반드시 처리해야 하는 체크 예외입니다.try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("작업 중 인터럽트 발생 - 자원 정리 수행");
// 중요: 인터럽트 상태를 다시 설정하여 상위 호출자에게 알림
Thread.currentThread().interrupt();
}
멀티스레드 환경의 가장 큰 문제는 "여러 스레드가 하나의 자원(변수, 객체, 파일 등)을 동시에 건드릴 때" 발생합니다.
| 문제 유형 | 설명 및 예시 | 발생 결과 |
|---|---|---|
| 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를 수정하려고 너무 많은 트랜잭션이 대기함. | 쿼리 응답 속도가 급격히 느려짐 |
synchronized 키워드자바는 synchronized 키워드를 통해 언어 차원에서 가장 기본적인 락을 제공합니다.
메서드 전체를 임계 영역으로 지정합니다. 인스턴스 단위(this)로 락이 걸립니다.
public synchronized void increaseCount() {
this.count++; // 한 번에 하나의 스레드만 이 코드를 실행 가능
}
메서드 전체를 막으면 성능이 떨어질 수 있습니다. 필요한 부분만 딱 잘라서 막는 것이 효율적입니다.
public void updateInfo() {
// 동기화가 필요 없는 코드 (병렬 실행 가능)
String data = makeData();
// 꼭 필요한 부분만 잠금 (this 또는 별도의 lock 객체 사용)
synchronized (this) {
this.sharedData = data;
}
}
성능 주의: 동기화 범위(Scope)가 넓을수록 병렬 처리가 안 되어 성능이 떨어집니다. "최소한의 영역"만 동기화하는 것이 기술입니다.
HashMap의 멀티스레드 버전입니다.wait(), notify()를 구현할 필요 없이 이 큐만 쓰면 해결됩니다.| 분류 | 클래스 이름 | 특징 및 추천 상황 |
|---|---|---|
| Map | ConcurrentHashMap | (가장 권장) 읽기/쓰기 동시성이 매우 뛰어남. |
| List | CopyOnWriteArrayList | 읽기는 많고 쓰기는 적을 때 (예: 이벤트 리스너 목록). 쓸 때마다 복사본을 만들어서 락 없이 읽기 가능. |
| Queue | LinkedBlockingQueue | (가장 권장) 일반적인 큐 작업에 사용. 크기 제한 가능. |
| Queue | ConcurrentLinkedQueue | 락을 쓰지 않는(Lock-Free) 큐. 대기 없이 엄청나게 빠른 처리가 필요할 때 사용. |
| Set | ConcurrentSkipListSet | 정렬이 필요한 멀티스레드 Set. (ConcurrentHashSet은 별도로 없으므로 ConcurrentHashMap을 응용해서 씀) |
앞서 배운 new Thread(...).start() 방식은 간단하지만, 실제 운영 환경에서 그대로 쓰기엔 위험합니다. 요청이 올 때마다 스레드를 무한정 생성하면 메모리 부족(OOM)이나 CPU 과부하로 서버가 죽을 수 있기 때문입니다.
이 문제를 해결하기 위해 등장한 것이 바로 스레드 풀(Thread Pool)과 Executor 프레임워크입니다.
스레드 풀은 말 그대로 "스레드를 미리 만들어 놓은 수영장(Pool)"입니다.
왜 굳이 복잡하게 풀을 만들어 쓸까요? 가장 큰 이유는 안정성과 효율성입니다.
| 구분 | 효과 및 장점 |
|---|---|
| 스레드 재사용 | 스레드 생성과 삭제는 OS 입장에서 매우 비싼 작업입니다. 이를 재사용하여 생성 비용을 아끼고 GC(Garbage Collection) 부담을 줄입니다. |
| 동시성 제어 | 동시에 실행되는 스레드 개수를 제한합니다. 트래픽이 폭주해도 스레드가 무한정 늘어나 서버가 다운되는 것을 방지합니다. |
| 작업 큐 관리 | 당장 처리할 수 없는 요청은 큐(Queue)에 안전하게 쌓아둡니다(Buffering). 요청을 유실하지 않고 순차적으로 처리할 수 있게 합니다. |
| 책임 분리 | "비즈니스 로직(무엇을 할지)"과 "실행 메커니즘(어떻게 실행할지)"을 분리하여 코드가 깔끔해지고 유지보수가 쉬워집니다. |
OutOfMemoryError가 발생할 수 있습니다.자바 5부터는 개발자가 직접 스레드를 new로 만들고 관리하는 것을 권장하지 않습니다. 대신 Executor 프레임워크를 사용합니다.
"작업의 등록(Submission)"과 "작업의 실행(Execution)"을 분리해 주는 표준 인터페이스입니다.
start() 함. (작업과 실행이 강결합)Executor에게 작업을 넘기기만(submit) 하면, Executor가 알아서 스레드 풀을 쓰든, 큐에 넣든 처리해 줌.자바의 java.util.concurrent 패키지는 스레드 관리를 위해 3단계의 인터페이스 상속 구조를 제공합니다. 갈수록 기능이 강력해지는 구조입니다.
계층 구조(Hierarchy)
Executor(기본 실행)
⬇️
ExecutorService(라이프사이클 관리 + Future)
⬇️
ScheduledExecutorService(스케줄링 기능 추가)
가장 단순하고 기본적인 인터페이스입니다. "작업을 등록하는 곳"과 "작업을 실행하는 곳"을 분리하는 데 의의가 있습니다.
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"));
}
}
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()vssubmit()
execute():Runnable만 받으며, 리턴값이 없습니다. 예외 발생 시 스레드가 종료될 수 있습니다.submit():Runnable과Callable을 모두 받으며,Future를 반환합니다. 예외가 발생해도Future.get()호출 시점에 알 수 있어 더 안전합니다.
특정 시간 뒤에 실행하거나, 일정 간격으로 반복 실행해야 할 때 사용합니다. 과거의 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);
}
}
가장 일반적으로 사용되는 형태입니다.
nThreads)해 두고, 그 안에서만 작업을 돌립니다.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();
}
}
유동적으로 스레드를 관리하는 방식입니다.
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();
}
}
스레드 풀인데 스레드가 딱 1개뿐인 독특한 녀석입니다.
ExecutorService pool = Executors.newSingleThreadExecutor();
// 작업 1, 2, 3, 4, 5가 반드시 순서대로 실행됨이 보장됨
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
);
}
}
CPU 코어 수 + 1 권장. (컨텍스트 스위칭 최소화)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();
}
}
shutdown()만 호출하고 끝내면, 아직 돌고 있는 작업이 강제로 끊기거나 큐에 남은 작업이 유실될 수 있습니다.
pool.shutdown(); // 1. 종료 요청
try {
// 2. 5초간 대기
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
System.out.println("시간 초과! 강제 종료 시도");
pool.shutdownNow(); // 3. 강제 종료
}
} catch (InterruptedException e) {
pool.shutdownNow();
}
Runnable 인터페이스는 치명적인 단점이 있었습니다. 바로 void 반환 타입입니다.
Runnable task = () -> {
System.out.println("작업 실행 중...");
// return 100; // ❌ 컴파일 에러! 값을 반환할 수 없음
};
new Thread(task).start();
이 문제를 해결하기 위해 Java 5부터 Callable이 등장했습니다.
Exception을 던질 수도 있습니다.| 특징 | Runnable | Callable |
|---|---|---|
| 반환 값 | 없음 (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);
Future는 말 그대로 "미래에 완료될 작업의 결과"를 담고 있는 객체입니다. 식당에서 주문하고 받은 진동벨과 똑같습니다.
get(): "음식 나왔나요?" 하고 진동벨을 확인하는 것과 같습니다.isDone(): "아직 안 됐나요?" 하고 확인만 하는 메서드입니다(Non-blocking).cancel(): "주문 취소할게요"라고 요청합니다.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)처럼 시간 제한을 두는 것이 실무에서의 안전한 패턴입니다.
ExecutorService는 단순히 작업 하나만 처리하는 게 아니라, 여러 작업을 효율적으로 관리하는 기능도 제공합니다.
invokeAll(): 여러 작업을 동시에 시키고, "모두 끝날 때까지" 기다립니다. (모든 결과가 필요할 때)invokeAny(): 여러 작업을 동시에 시키고, "가장 먼저 끝난 놈 하나"만 받습니다. (나머지는 취소함. 빠른 응답이 필요할 때)List<Callable<String>> tasks = Arrays.asList(
() -> { Thread.sleep(3000); return "느린 작업"; },
() -> { Thread.sleep(1000); return "빠른 작업"; }
);
// 가장 빨리 끝난 "빠른 작업" 결과만 리턴됨
String firstResult = service.invokeAny(tasks);
진동벨이 고장 나면 손님이 직접 가서 받아와야 하는데, Future는 그게 안 됩니다. (예외 처리나 값 설정을 외부에서 개입 불가)
이게 가장 큽니다. 비동기로 실행은 했지만, 결국 결과를 보려면 get()을 호출해서 기다려야 합니다. 진정한 의미의 Non-blocking이 아닙니다.
"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)
get()을 호출해서 멍하니 기다릴 필요 없이, "작업이 끝나면 이거 해줘"라고 할 일을 미리 등록할 수 있습니다.람다(Lambda) 표현식을 사용하여 비동기 로직을 깔끔하게 작성할 수 있습니다.
// Future와 달리 get() 없이 흐름을 연결함
CompletableFuture.supplyAsync(() -> "Hello")
.thenApply(s -> s + " World")
.thenAccept(System.out::println);
가장 먼저 해야 할 일은 비동기 작업을 시작하는 것입니다. 크게 두 가지 메서드를 사용합니다.
반환값이 있냐 없냐에 따라 선택하면 됩니다.
| 메서드 | 반환 타입 | 설명 |
|---|---|---|
| runAsync | CompletableFuture<Void> | 반환값이 없는 작업 (Runnable) 실행 |
| supplyAsync | CompletableFuture<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());
}
}
기본적으로 CompletableFuture는 ForkJoinPool.commonPool()이라는 공유 스레드 풀을 사용합니다.
하지만 실무에서 DB 연결 등 I/O 작업이 많다면, 반드시 커스텀 스레드 풀(Executor)을 별도로 넘겨줘야 성능 저하를 막을 수 있습니다.
ExecutorService myPool = Executors.newFixedThreadPool(10);
CompletableFuture.supplyAsync(() -> {
return "커스텀 스레드 풀에서 실행";
}, myPool); // 두 번째 인자로 Executor 전달
비동기 작업의 결과를 받아 다음 단계로 넘기는 메서드들입니다.
| 메서드 | 입력 | 반환 | 역할 |
|---|---|---|---|
| thenApply | O | O | 결과를 받아서 변환 후 반환 (Map) |
| thenAccept | O | X | 결과를 받아서 소비만 하고 끝냄 (Consumer) |
| thenRun | X | X | 결과 상관없이 다음 작업 실행 (Runnable) |
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("모든 작업 완료!")
);
최종적으로 결과를 꺼내야 할 때 사용하는 메서드입니다.
| 구분 | get() | join() | getNow(default) |
|---|---|---|---|
| 정의 | Future 인터페이스의 메서드 | CompletableFuture의 메서드 | 즉시 반환 메서드 |
| 예외 처리 | Checked Exception (try-catch 필수) | Unchecked Exception (try-catch 불필요) | 예외 없음 |
| 특징 | 코드가 지저분해짐 | 람다 식에서 쓰기 편함 (권장) | 아직 안 끝났으면 기본값 반환 |
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로 마무리하는 패턴을 기억하세요!
비동기 작업은 혼자 돌 때보다, 다른 작업과 합쳐질 때 더 강력합니다.
Future 안에 Future가 중첩되는 것을 방지해 줍니다 (FlatMap과 유사).CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "userId_123")
.thenCompose(userId -> {
// 첫 번째 결과(userId)를 받아서 새로운 비동기 작업 시작
return CompletableFuture.supplyAsync(() -> "User Info: " + userId);
});
System.out.println(future.join());
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());
allOf(Future...): 모든 작업이 끝날 때까지 대기. (반환값은 Void이므로, 개별 Future에서 get()으로 값을 꺼내야 함)anyOf(Future...): 가장 먼저 끝난 하나의 결과만 반환.비동기 작업 중 에러가 발생했을 때, 시스템이 멈추지 않고 대체 값을 반환하거나 로그를 남기게 해야 합니다.
| 메서드 | 역할 | 반환값 변경 | 실행 시점 |
|---|---|---|---|
| exceptionally | 예외 발생 시 대체값 반환 (Catch) | 가능 | 예외 발생 시 |
| handle | 정상/예외 모두 처리 (Try-Catch-Finally) | 가능 | 항상 |
| whenComplete | 결과 기록, 리소스 정리 (Peek) | 불가능 | 항상 |
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;
});
비동기 작업이 무한정 길어지는 것을 방지하기 위해 타임아웃은 필수입니다.
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);
데이터가 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();
}
ForkJoinPool.commonPool()을 공유하므로 I/O 작업 시 전체 성능 저하 위험.FixedThreadPool(코어 수 + 1)CachedThreadPool 또는 FixedThreadPool(넉넉한 개수)