들어가며
자바 진영에는 비동기 프로그래밍을 위한 여러 도구가 존재하는데요, 오늘은 그중 가장 기본적인 Future에 대해 파헤쳐 보고자 합니다.
단순히 get()을 호출하면 블로킹된다 에서 그치는 것이 아니라, 대체 어떤 원리로 스레드가 잠들고(blocking), 또 어떻게 깨어나는지 그 내부 동작을 함께 엿보겠습니다. 이 글을 끝까지 읽으시면 Future의 동작 원리를 명확히 이해하고, 동시성 프로그래밍에 대한 자신감을 한 단계 더 높일 수 있을 겁니다.

🏡 Future란? - 미래에 받을 결과에 대한 약속
Future는 Java 5부터 java.util.concurrent 패키지에 포함된 인터페이스입니다. 이름 그대로 '미래'의 어떤 시점에 결과를 돌려주겠다는 '약속'과 같은 객체죠.
비유하자면, 카페에서 커피를 주문하고 받은 '진동벨'과 같습니다. 우리는 진동벨을 받는 즉시 커피를 얻지는 못하지만, 언젠가 커피가 준비되면 진동벨이 울릴 것이라는 약속을 받은 셈입니다. 이 진동벨이 바로 Future 객체이고, 우리는 진동벨이 울릴 때까지 다른 일을 할 수도, 혹은 카운터 앞에서 하염없이 기다릴 수도 있습니다.
하지만 이 클래식한 Future에는 몇 가지 아쉬운 점이 있습니다.
1. 블로킹(Blocking): 결과를 얻기 위해 get() 메서드를 호출하면, 작업이 완료될 때까지 해당 스레드는 다른 일을 전혀 하지 못하고 멈춰버립니다. (카운터 앞에서 커피만 기다리는 상황)
2. 콜백(Callback) 부재: 작업이 완료되었을 때, "작업 끝났으니 이 다음 일을 해줘!" 와 같이 능동적으로 다음 동작을 연결할 방법이 없습니다.
이런 단점들 때문에 Java 8에서는 CompletableFuture라는 훨씬 강력한 도구가 등장했지만, Future의 내부 동작 원리를 이해하는 것은 자바 동시성 프로그래밍의 기초를 다지는 데 매우 중요합니다.
Future 사용법: 간단한 예제
Future가 어떻게 사용되는지 간단한 예제를 통해 살펴보겠습니다. 보통 ExecutorService.submit()을 활용하는 것이 일반적이지만, 오늘은 내부 구조를 더 명확히 보기 위해 FutureTask를 직접 생성하는 방식을 사용하겠습니다. (사실 ExecutorService 내부에서도 동일한 방식으로 동작합니다.)
public class SampleCallable implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("Thread is Started");
Thread.sleep(2000);
return "Sample";
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
RunnableFuture<String> fTask = new FutureTask<>(new SampleCallable());
Thread thread = new Thread(fTask);
thread.start();
String result = fTask.get();
System.out.println(result);
}
- Callable<String>: Runnable과 비슷하지만, 작업의 결과를 반환하고 예외를 던질 수 있는 인터페이스입니다. 여기서는 2초간 대기한 후 "Sample"이라는 문자열을 반환합니다.
- FutureTask: Future와 Runnable을 모두 구현한 똑똑한 클래스입니다. Callable을 감싸서 Future의 기능을 쓸 수 있게 해줍니다.
- thread.start(): 새로운 스레드에서 FutureTask의 run() 메서드를 실행합니다. 이제 SampleCallable의 call() 메서드가 백그라운드에서 동작하기 시작합니다.
- fTask.get(): 바로 이 부분이 핵심입니다. 메인 스레드는 이 지점에서 SampleCallable의 작업이 끝날 때까지 꼼짝 않고 기다립니다. 2초 후에 call() 메서드가 "Sample"을 반환하면, get()은 그 값을 받아 result 변수에 할당하고 메인 스레드는 다시 움직이기 시작합니다.
자, 그럼 이제부터 fTask.get()을 호출했을 때 도대체 내부에서 어떤 일이 벌어지는지 코드를 파헤쳐 보겠습니다.
🚟 get()은 어떻게 스레드를 잠재우는가?
FutureTask의 get() 메서드 소스 코드를 열어봅시다. (OpenJDK 17 기준)
public V get() throws InterruptedException, ExecutionException {
int s = state;
if (s <= COMPLETING)
s = awaitDone(false, 0L);
return report(s);
}
코드는 생각보다 단순합니다. 가장 먼저 state라는 변수를 확인하죠. state는 FutureTask의 현재 상태를 나타내는 가장 중요한 값입니다.
// FutureTask의 상태 값들
private static final int NEW = 0; // 작업이 아직 시작되지 않음
private static final int COMPLETING = 1; // 작업이 완료되고 결과를 쓰는 중 (아주 짧은 순간)
private static final int NORMAL = 2; // 작업이 정상적으로 완료됨
private static final int EXCEPTIONAL = 3; // 작업 중 예외 발생
private static final int CANCELLED = 4; // 작업이 취소됨
// ... (취소와 관련된 상태들)
get() 메서드는 state가 NEW 또는 COMPLETING 상태, 즉 아직 작업이 완료되지 않았다면 awaitDone() 메서드를 호출합니다. 이름에서 알 수 있듯, '완료될 때까지 기다리는' 역할을 하는 핵심 메서드입니다.
awaitDone(): 잠들기 위한 대기실
private int awaitDone(boolean timed, long nanos)
throws InterruptedException {
WaitNode q = null;
boolean queued = false;
for (;;) { // 무한 루프
int s = state;
if (s > COMPLETING) { // 작업이 완료되었다면
if (q != null)
q.thread = null;
return s; // 현재 상태를 반환하고 즉시 종료
}
else if (q == null) {
// ... 타임아웃 처리 로직 ...
q = new WaitNode(); // 현재 스레드를 담을 대기 노드 생성
}
else if (!queued)
// 대기자 명단(waiters)에 현재 스레드 노드를 추가 (CAS 연산)
queued = WAITERS.weakCompareAndSet(this, q.next = waiters, q);
else
LockSupport.park(this); // 스레드를 잠재운다!
}
}
코드가 조금 복잡해 보이지만, 핵심 로직은 이렇습니다.
- 무한 루프를 돌면서 계속 state를 체크합니다.
- 만약 작업이 이미 완료(state > COMPLETING)되었다면, 루프를 탈출하고 즉시 상태 값을 반환합니다.
- 작업이 아직 안 끝났다면, WaitNode라는 객체를 만듭니다. 이 노드는 "나(get()을 호출한 스레드) 지금 이 작업 결과 기다리고 있어요!"라고 표시하는 명찰과 같습니다.
- 이 명찰(WaitNode)을 waiters라는 대기자 명단(정확히는 스택 구조의 링크드 리스트)에 추가합니다.
- 명단에 성공적으로 등록되고 나면, 마침내 **LockSupport.park(this)**를 호출합니다.
바로 이 LockSupport.park()가 현재 스레드를 블로킹시키는 주범입니다. 스레드는 이 코드를 만나는 순간 잠이 들고, 다른 누군가가 unpark()를 호출해 깨워줄 때까지 CPU 자원을 할당받지 못하는 대기 상태가 됩니다. 내부적으로는 리눅스 환경의 pthread_cond_wait() 같은 OS 레벨의 동기화 기능을 사용하여 효율적으로 스레드를 재웁니다.
자, 이제 스레드가 어떻게 잠드는지는 알았습니다. 그런데 awaitDone() 코드만 봐서는 영원히 깨어날 방법이 없어 보입니다. 이 스레드는 과연 누가, 언제 깨워주는 걸까요?
👨 run()은 어떻게 스레드를 깨우는가?
정답은 백그라운드에서 실행되던 바로 그 스레드에 있습니다. thread.start()로 시작된 작업 스레드가 자신의 임무를 마치면, 잠들어 있는 메인 스레드를 깨우러 갑니다. 그 여정은 run() 메서드에서 시작됩니다.
public void run() {
Callable<V> c = callable;
if (c != null && state == NEW) {
result = c.call();
boolean ran = true;
if (ran)
set(result);
}
}
run() 메서드의 로직은 간단합니다.
- state가 NEW(아직 시작 안 된 상태)인지 확인하고, 우리가 맨 처음 생성했던 SampleCallable의 call() 메서드를 실행합니다.
- call()이 성공적으로 실행되어 결과값(result)을 얻으면, 그 결과를 가지고 set(result) 메서드를 호출합니다.
set()과 finishCompletion(): 잠자는 스레드를 깨우는 알람
protected void set(V v) {
if (STATE.compareAndSet(this, NEW, COMPLETING)) {
outcome = v;
STATE.setRelease(this, NORMAL); // final state
finishCompletion();
}
}
set() 메서드는 먼저 CAS(Compare-And-Set) 연산을 통해 state를 NEW에서 COMPLETING으로 바꿉니다. 이 덕분에 여러 스레드가 동시에 결과를 설정하려 해도 단 한 번만 실행되는 것을 보장받습니다.
결과값(v)을 outcome이라는 변수에 저장하고, state를 최종적으로 NORMAL(정상 종료)로 설정한 뒤, finishCompletion()을 호출합니다.
private void finishCompletion() {
for (WaitNode q; (q = waiters) != null;) {
if (WAITERS.weakCompareAndSet(this, q, null)) {
for (;;) {
Thread t = q.thread;
if (t != null) {
q.thread = null;
LockSupport.unpark(t); // 잠들어 있던 스레드를 깨운다!
}
WaitNode next = q.next;
if (next == null)
break;
q.next = null;
q = next;
}
break;
}
}
}
드디어 마지막 퍼즐 조각이 맞춰졌습니다!
finishCompletion() 메서드는 아까 awaitDone()에서 만들어 둔 대기자 명단(waiters)을 순회합니다. 그리고 각 WaitNode에 기록된 스레드(t)를 향해 LockSupport.unpark(t)를 호출합니다.
unpark()는 park()로 잠들어 있던 스레드를 깨우는 알람입니다. 이 알람을 받은 스레드(우리의 예제에서는 메인 스레드)는 awaitDone()의 LockSupport.park(this) 라인 바로 다음부터 실행을 재개합니다. 깨어난 스레드는 다시 루프를 돌고, state가 NORMAL로 바뀐 것을 확인한 뒤 awaitDone()을 빠져나오게 되는 것이죠.
마지막으로 get() 메서드는 report()를 호출해 outcome 변수에 저장되어 있던 최종 결과를 반환하거나, 예외가 발생했다면 해당 예외를 던져주게 됩니다.
마치며: 아름다운 스레드 간의 협업
정리해 볼까요? Future.get()의 내부에서는 스레드 간의 아름다운 협업이 이루어지고 있었습니다.
1. 호출 스레드 (메인 스레드): get()을 호출하고, 작업이 아직 안 끝났으면 awaitDone()으로 진입합니다. WaitNode에 자신을 등록하고 LockSupport.park()를 통해 잠이 듭니다.
2. 작업 스레드 (백그라운드 스레드): run() 메서드에서 자신의 작업을 수행합니다. 작업이 끝나면 set()을 호출하고, finishCompletion()을 통해 대기자 명단을 확인합니다.
3. 깨우기: 작업 스레드는 LockSupport.unpark()를 이용해 잠들어 있던 호출 스레드를 깨웁니다.
4. 결과 반환: 잠에서 깨어난 호출 스레드는 작업 결과를 받아 다음 로직을 수행합니다.
Reference
https://ssddo-story.tistory.com/62
https://tech-monster.tistory.com/245
https://skasha.tistory.com/43
https://github.com/openjdk/jdk17/blob/master/src/java.base/share/classes/java/util/concurrent/FutureTask.java
'자바' 카테고리의 다른 글
| 자바 Serial Minor GC 동작 방식 (0) | 2025.06.27 |
|---|---|
| [자바] ReentrantLock : NonFairSync의 lock() 동작 원리 (0) | 2025.05.27 |
| [자바] AtomicInteger: incrementAndGet 구체적인 동작방식 (0) | 2025.05.15 |
| [자바] HashMap 파헤치기: put() 메소드는 어떻게 동작할까? (0) | 2025.05.15 |
| JVM Runtime Data Area - 쓰레드 영역 (0) | 2025.05.09 |