| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 코딩테스트
- 코딩테스트준비
- 코딩테스트팁
- 메모리관리
- 자바개발
- 자바기초
- 백준
- JVM
- 코딩공부
- 정렬
- 자바
- 알고리즘
- 클린코드
- 프로그래머스
- 자료구조
- 예외처리
- HashMap
- 자바프로그래밍
- 멀티스레드
- 파이썬
- Java
- 가비지컬렉션
- 개발공부
- 개발자취업
- 프로그래밍기초
- 알고리즘공부
- 객체지향
- 코딩인터뷰
- 개발자팁
- 자바공부
- Today
- Total
코드 한 줄의 기록
Java Executors, Future, Callable의 개념과 활용법을 완벽히 이해하자 본문
안녕하세요. 오늘은 Java의 동시성(Concurrency) 관련해서 많은 개발자들이 헷갈려하는 Executors, Future, Callable에 대해 자세히 설명하려고 합니다. 저도 처음 이 주제를 공부할 때는 복잡해 보였지만, 개념을 제대로 이해하고 실제 코드로 작성하다 보니 생각보다 간단하고 매력적인 주제라는 걸 알게 되었습니다. 이 글을 읽으시는 여러분과 함께 차근차근 배워보겠습니다.
먼저, 비동기 프로그래밍이 왜 필요한가?
프로그램을 작성하면서 가장 기본적인 방식은 동기식 처리입니다. 작업 A가 끝나고 나서 작업 B가 시작되는 식입니다. 예를 들어 데이터베이스 조회에 3초가 걸린다면, 그동안 프로그램은 그 자리에서 가만히 기다려야 합니다. 사용자는 답답함을 느끼고, CPU는 할 일이 없어서 낭비됩니다.
이런 문제를 해결하기 위해 비동기 프로그래밍이 나왔습니다. 작업 A를 요청만 해두고 기다리지 않고 작업 B를 바로 시작할 수 있다는 뜻입니다. 여러 개의 스레드를 활용해서 동시에 여러 작업을 처리하는 방식입니다. 이렇게 되면 전체 작업 시간을 크게 단축할 수 있고, 시스템 자원도 효율적으로 사용할 수 있습니다.
Java에서는 이런 비동기 작업을 쉽게 구현할 수 있도록 Executor 프레임워크를 제공합니다. 직접 스레드를 만들고 관리하는 번거로움에서 해방되는 거죠.
Runnable vs Callable, 뭐가 다를까?
이 둘의 차이를 이해하는 것이 매우 중요합니다. 많은 사람들이 혼동하기 때문입니다.
Runnable은 실행할 작업을 정의하는 인터페이스입니다. run() 메서드를 구현해야 하는데, 이 메서드는 아무것도 반환하지 않습니다(void). 또한 검사 예외(Checked Exception)를 던질 수 없습니다. 따라서 예외 처리를 메서드 내부에서만 해야 합니다.
public interface Runnable {
void run();
}
Callable은 작업을 정의하면서 결과값을 반환할 수 있습니다. call() 메서드를 구현하고, 이 메서드는 제네릭 타입 T의 값을 반환합니다. 그리고 검사 예외를 던질 수 있습니다. 이것이 Callable의 강점입니다.
public interface Callable<V> {
V call() throws Exception;
}
실제로 Callable을 구현해보겠습니다.
public class SumCalculator implements Callable<Integer> {
private int a;
private int b;
public SumCalculator(int a, int b) {
this.a = a;
this.b = b;
}
@Override
public Integer call() throws Exception {
// 작업을 수행하고 결과를 반환
System.out.println(Thread.currentThread().getName() + "에서 계산 중...");
Thread.sleep(1000); // 시간이 걸리는 작업 시뮬레이션
return a + b;
}
}
또는 람다식으로 간단하게 작성할 수도 있습니다.
Callable<Integer> task = () -> {
Thread.sleep(1000);
return 5 + 10;
};
Executors와 ExecutorService, 이게 뭐죠?
Executor는 작업을 실행하는 객체를 나타내는 인터페이스입니다. execute() 메서드 하나만 가지고 있습니다.
public interface Executor {
void execute(Runnable command);
}
ExecutorService는 Executor를 확장한 인터페이스로, 더 많은 기능을 제공합니다. submit(), shutdown(), invokeAll() 등의 메서드를 가지고 있습니다.
그리고 Executors는 팩토리 클래스입니다. ExecutorService를 쉽게 생성해주는 정적 메서드들을 제공합니다. 이게 핵심입니다! 직접 스레드풀을 복잡하게 설정할 필요 없이 Executors의 메서드를 호출하면 됩니다.
Executors가 제공하는 주요 메서드들을 살펴보겠습니다.
1. newFixedThreadPool(int nThreads)
고정된 개수의 스레드를 가진 스레드풀을 생성합니다. 작업이 몰려도 스레드 수는 변하지 않습니다. 초과 작업은 대기열에 저장됩니다.
ExecutorService executor = Executors.newFixedThreadPool(3);
2. newSingleThreadExecutor()
단 하나의 스레드만 가진 스레드풀입니다. 순차적으로 작업을 처리해야 할 때 유용합니다.
ExecutorService executor = Executors.newSingleThreadExecutor();
3. newCachedThreadPool()
스레드를 필요한 만큼 동적으로 생성합니다. 60초 동안 사용되지 않으면 자동으로 삭제됩니다. 작업량이 불규칙할 때 좋습니다.
ExecutorService executor = Executors.newCachedThreadPool();
4. newScheduledThreadPool(int corePoolSize)
일정 시간 후에 작업을 실행하거나, 주기적으로 반복 실행할 수 있는 스레드풀입니다.
ScheduledExecutorService executor = Executors.newScheduledThreadPool(3);
Future, 비동기 작업의 결과를 어떻게 받지?
Future는 비동기 작업의 결과를 저장하고 관리하는 객체입니다. 작업이 현재 진행 중인지, 완료되었는지, 결과가 뭔지를 확인할 수 있습니다. Future의 주요 메서드를 알아봅시다.
get() - 결과 받기
작업의 결과를 가져옵니다. 만약 작업이 아직 완료되지 않았다면, 완료될 때까지 기다립니다(블로킹). 이것을 blocking call이라고 합니다.
String result = future.get(); // 작업이 끝날 때까지 기다림
타임아웃을 설정할 수도 있습니다. 지정한 시간 내에 작업이 끝나지 않으면 TimeoutException을 던집니다.
String result = future.get(5, TimeUnit.SECONDS); // 5초만 기다림
isDone() - 작업 완료 확인
작업이 완료되었으면 true를 반환합니다. 여기서 말하는 '완료'는 정상 완료, 예외 발생, 취소 등 모든 경우를 포함합니다.
if (future.isDone()) {
System.out.println("작업이 완료되었습니다.");
}
isCancelled() - 작업 취소 여부 확인
작업이 명시적으로 취소되었으면 true를 반환합니다.
if (future.isCancelled()) {
System.out.println("작업이 취소되었습니다.");
}
cancel(boolean mayInterruptIfRunning) - 작업 취소
작업을 취소합니다. 파라미터로 true를 넘기면 진행 중인 작업을 즉시 중단하고, false를 넘기면 현재 작업이 끝날 때까지 기다린 후 종료합니다. 이미 완료된 작업은 취소할 수 없으므로 false를 반환합니다.
boolean canceled = future.cancel(true); // true면 성공, false면 실패
실전 예제: submit()으로 작업 제출하기
이제 실제 코드를 작성해봅시다. ExecutorService의 submit() 메서드를 사용하면 Callable 작업을 제출하고 Future 객체를 받을 수 있습니다.
import java.util.concurrent.*;
public class ExecutorExample {
public static void main(String[] args) {
// 3개의 스레드를 가진 스레드풀 생성
ExecutorService executor = Executors.newFixedThreadPool(3);
// Callable 작업 생성
Callable<Integer> task1 = () -> {
System.out.println(Thread.currentThread().getName() + " - 작업1 시작");
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + " - 작업1 완료");
return 100;
};
Callable<Integer> task2 = () -> {
System.out.println(Thread.currentThread().getName() + " - 작업2 시작");
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + " - 작업2 완료");
return 200;
};
// 작업 제출
Future<Integer> future1 = executor.submit(task1);
Future<Integer> future2 = executor.submit(task2);
System.out.println("메인 스레드는 계속 실행됩니다.");
try {
// 결과 받기 (작업이 끝날 때까지 기다림)
int result1 = future1.get();
int result2 = future2.get();
System.out.println("작업1 결과: " + result1);
System.out.println("작업2 결과: " + result2);
System.out.println("합계: " + (result1 + result2));
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} finally {
executor.shutdown();
}
}
}
실행 결과
메인 스레드는 계속 실행됩니다.
pool-1-thread-1 - 작업1 시작
pool-1-thread-2 - 작업2 시작
pool-1-thread-2 - 작업2 완료
pool-1-thread-1 - 작업1 완료
작업1 결과: 100
작업2 결과: 200
합계: 300
보셨나요? 메인 스레드가 "메인 스레드는 계속 실행됩니다."를 출력한 후, 두 작업이 병렬로 진행됩니다. 이것이 비동기 처리의 강점입니다.
execute() vs submit(), 뭘 써야 할까?
execute()는 Runnable만 받을 수 있고, 반환값이 없습니다. 작업 결과에 관심이 없을 때 사용합니다.
executor.execute(() -> {
System.out.println("이 작업의 결과는 받지 않아요.");
});
submit()은 Runnable과 Callable을 모두 받을 수 있고, Future를 반환합니다. 작업 결과가 필요하거나 작업 상태를 추적해야 할 때 사용합니다.
Future<String> future = executor.submit(() -> {
return "이 작업의 결과를 받아요!";
});
대부분의 경우 submit()을 사용하는 게 낫습니다. 더 유연하니까요.
여러 작업을 한 번에 처리하기: invokeAll()과 invokeAny()
여러 개의 Callable 작업을 동시에 실행하고 싶을 때가 있습니다. 이럴 때 사용하는 메서드가 두 가지 있습니다.
invokeAll() - 모든 작업이 끝날 때까지 기다리기
모든 작업을 동시에 실행하고, 모두 완료될 때까지 기다린 후 각 작업의 Future를 List로 반환합니다.
import java.util.*;
import java.util.concurrent.*;
public class InvokeAllExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3);
List<Callable<Integer>> tasks = new ArrayList<>();
tasks.add(() -> {
Thread.sleep(1000);
return 10;
});
tasks.add(() -> {
Thread.sleep(2000);
return 20;
});
tasks.add(() -> {
Thread.sleep(1500);
return 30;
});
try {
// 모든 작업 실행
List<Future<Integer>> futures = executor.invokeAll(tasks);
// 각 결과 확인
int sum = 0;
for (Future<Integer> future : futures) {
sum += future.get();
}
System.out.println("합계: " + sum); // 60
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} finally {
executor.shutdown();
}
}
}
특징
- 3개 작업이 2초 걸린다면, 병렬 처리로 약 2초가 소요됩니다.
- 작업 중 하나가 예외를 던져도 나머지 작업은 계속 진행됩니다.
- 모든 작업이 완료되거나 인터럽트될 때까지 블로킹됩니다.
invokeAny() - 가장 빨리 완료된 작업 결과만 받기
여러 작업 중 가장 빨리 완료된 하나의 결과만 반환합니다. 나머지 작업들은 취소됩니다. 이는 경쟁 상황(Race Condition)을 활용할 때 유용합니다.
public class InvokeAnyExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3);
List<Callable<String>> tasks = new ArrayList<>();
tasks.add(() -> {
Thread.sleep(3000);
return "이 작업은 3초 걸립니다.";
});
tasks.add(() -> {
Thread.sleep(1000);
return "이 작업은 1초만 걸립니다!";
});
tasks.add(() -> {
Thread.sleep(2000);
return "이 작업은 2초 걸립니다.";
});
try {
// 가장 빨리 완료된 작업의 결과만 받음
String result = executor.invokeAny(tasks);
System.out.println("결과: " + result); // "이 작업은 1초만 걸립니다!"
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} finally {
executor.shutdown();
}
}
}
특징
- 약 1초 정도만 소요됩니다(가장 빠른 작업 시간).
- 첫 번째로 완료된 작업의 결과를 즉시 반환합니다.
- 나머지 작업들은 자동으로 취소됩니다.
주의사항: executor.shutdown() 반드시 호출하기
스레드풀 작업이 끝나면 꼭 shutdown()을 호출해야 합니다. 그렇지 않으면 스레드들이 계속 메모리를 차지하고 있습니다.
executor.shutdown(); // 더 이상 새로운 작업 받지 않음
// executor.shutdownNow(); // 진행 중인 작업까지 취소함
shutdown()은 이미 제출된 작업들은 완료하되, 새로운 작업은 받지 않습니다. shutdownNow()는 진행 중인 모든 작업을 취소합니다.
완전히 종료될 때까지 기다리려면
executor.shutdown();
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
예외 처리는 어떻게?
Callable에서 발생한 예외는 Future.get()을 호출할 때 ExecutionException으로 감싸져서 던져집니다.
Callable<Integer> failTask = () -> {
throw new IllegalArgumentException("계산 오류!");
};
Future<Integer> future = executor.submit(failTask);
try {
int result = future.get();
} catch (ExecutionException e) {
System.out.println("실행 중 오류 발생: " + e.getCause().getMessage());
} catch (InterruptedException e) {
System.out.println("작업이 중단되었습니다.");
}
ExecutionException을 잡아서 getCause()로 원래 예외를 꺼낼 수 있습니다.
실무 팁: 타임아웃 설정하기
무한 대기를 방지하기 위해 타임아웃을 반드시 설정하세요.
try {
int result = future.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
System.out.println("5초 안에 작업이 완료되지 않았습니다.");
future.cancel(true); // 작업 취소
}
Executors, Future, Callable의 조합은 Java에서 비동기 프로그래밍을 하는 가장 기본적이고 강력한 방법입니다. 처음에는 복잡해 보일 수 있지만, 실제로 사용하다 보면 매우 직관적입니다.
핵심은
- Callable로 작업을 정의합니다.
- ExecutorService(from Executors)로 작업을 실행합니다.
- Future로 결과를 받고 상태를 추적합니다.
이 패턴을 이해하면 Java의 동시성 프로그래밍이 훨씬 수월해집니다. 특히 네트워크 요청, 데이터베이스 조회, 파일 입출력 등 시간이 걸리는 작업들을 다룰 때 이 기술이 진가를 발휘합니다.
여러분도 이제 Executors를 자유자재로 사용할 수 있을 거예요. 처음에는 간단한 예제부터 시작해서 점차 복잡한 시나리오에 적용해보세요. 그러다 보면 자연스럽게 마스터하게 될 것입니다. 이 글이 도움이 되었길 바랍니다!
Java volatile과 happens-before 관계: 멀티스레드 메모리 가시성 이해하기
오늘은 Java 개발자라면 반드시 이해해야 하지만, 개념이 좀 복잡해서 넘어가기 쉬운 주제인 volatile 키워드와 happens-before 관계에 대해 우리가 함께 공부하고 정리해보려고 합니다. 저도 처음에는
byteandbit.tistory.com
'JAVA' 카테고리의 다른 글
| Java Concurrent 컬렉션과 병렬 스트림의 함정, 제대로 알고 사용하자 (0) | 2025.12.07 |
|---|---|
| Java 멀티스레드의 악몽, 데드락·라이블락·스타베이션 완벽 정리 (0) | 2025.12.06 |
| Java volatile과 happens-before 관계: 멀티스레드 메모리 가시성 이해하기 (0) | 2025.11.20 |
| Java 동시성 멀티스레드 제어: ReentrantLock, Semaphore, CountDownLatch (0) | 2025.11.19 |
| Java Synchronized·Volatile·원자성 완벽 이해하기 (0) | 2025.11.18 |