Java/병렬성

Future 와 CompletableFuture 의 동작에 대한 이해와 비교

김관현 2024. 7. 30. 18:45

 

사실 원래 주제는 CompletableFuture 였는데 Future 의 get( ) 과 CompletableFuture 의 get( ) 을 각각 돌려보면서 정말 신기한 점을 발견해서 주제를 바꾸게 되었다.

 

간단히 요약하면 

Future.get( ) 과 Completable.get( ) 은 완전히 다르다!!!

 

일단 차근차근 비동기가 무엇인지부터 시작하자

 

비동기 처리

멀티 스레드 상황에서 스레드들은 각자의 코드를 각자의 페이스대로 수행한다. 특정 시점에서 어떤 스레드가 먼저 실행되는지는 알 수 없으며 공유자원에 대한 문제가 생길 수 있다.

 

공유자원에 대한 안전성을 제공하기 위해 임계영역에 대한 처리를 하다보면 비효율적인 처리가 생길 수 있다.

 

예를 들어, 임계 영역에 오랜 시간이 걸리는 작업이 있고, 다른 스레드가 그 작업의 결과물을 얻어야 하는 경우 멀티 스레드지만 차례차례 하나씩 실행되는 경우가 생길 수 있다는 것이다.

 

그런 비효율을 해결하는 것이 비동기 처리이다. 해당 작업의 결과물이 필요할지라도 그 작업이 끝날때까지 기다리지 않고 각자의 작업을 하는 것이다. 

 

 

Future 와 Executors 의 경우

우리가 이 주제에 대해서 중요한 것은 문법이 아닌 '동작' 이기에 문법은 넘어가겠다.

우리의 주제인 'Future.get( )' 을 확인해보자

 

참고로 MyLogger 는 결과를 확인하기 편하도록 직접 구현한 클래스이다.

 

public class MultiFuture {

    public static void main(String[] args) throws ExecutionException, InterruptedException {

        ExecutorService first_es = Executors.newCachedThreadPool();
        ExecutorService second_es = Executors.newFixedThreadPool(3);

        Future<String> first_future = first_es.submit(() -> {
            for (int i = 1; i <= 4; i++) {
                MyLogger.log(i);
            }
            return "first_future 종료되었습니다.";
        });

        Future<String> second_future = second_es.submit(() -> {
            for (int i = 1; i <= 4; i++) {
                MyLogger.log(i * (-1));
            }
            return "second_future 종료되었습니다.";
        });

       // System.out.println(first_future.get());
        System.out.println("get을 안날렸는데도 실행이 되네??");
    }
}

 

<결과>

get을 안날렸는데도 실행이 되네??
12:15:25.094 [pool-2-thread-1] -1
12:15:25.094 [pool-1-thread-1] 1
12:15:25.099 [pool-2-thread-1] -2
12:15:25.099 [pool-1-thread-1] 2
12:15:25.099 [pool-2-thread-1] -3
12:15:25.099 [pool-1-thread-1] 3
12:15:25.099 [pool-2-thread-1] -4
12:15:25.099 [pool-1-thread-1] 4

 

 

어찌보면 이러한 결과는 당연하다고 느낄 수 있다.

멀티스레드로 동작하도록 정의해주었고, future.get() 으로 결과물을 꺼내지 않았기에 (future.get() 을 호출하지 않았기에) 결과물'만' 안나왔고 내부적인 동작은 실행이 되었다.

 

그렇다면, Future 의 진화체인 CompletableFuture 는 어떨까

 

CompletableFuture 의 경우

public class CFTest {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
            for (int i = 1; i <= 4; i++) {
                MyLogger.log(i);
            }
            return "cf 가 종료되었습니다.";
        });
        //System.out.println(cf.get());
        System.out.println("실행되니??");
    }
}

 

<결과>

실행되니??

 

 

???? 놀랍게도 메인 스레드만 동작하였고 CompletableFuture 안에 있는 로직들은 싸그리 무시되었다.

물론 주석을 지우고 cf.get() 을 호출해버리면 정상적으로 멀티스레딩 동작을 시행한다.

 

CompletableFuture.get() 호출 전까지 저 로직들이 Lazy 하게 작동하는 셈이다.

 

 

그런데 '멀티스레딩이 필요한 상황에서 이렇게 Lazy 한 동작이 과연 이득일까?' 하는 의문이 들었다.

 

예를 들어, 시간이 매우매우 오래 걸리는 거대한 작업을 스레드에게 할당시켜놓고 main 스레드는 다른 작업을 하고 싶을 때 Future 의 경우에는 할당받은 스레드가 일을 하고 있고 get( ) 의 호출을 기다리게 된다.

( get() 이 작업 완료 이전에 호출되는 경우에는 작업이 완료될 때 까지 하염없이 기다려야 한다.)

 

하지만 CompletableFuture 의 경우에는 진짜 작업의 결과물이 필요한 그 순간에 일을 시작하기 때문에 시간이 지연될 수 있고, 그렇다고 처음부터 get() 을 호출하자니 역시 blocking 문제 때문에 비효율적이라 생각한다.

 

 

 

하지만 이러한 문제의 해법이 있다. 

 

thenAccept() 적용

import static java.lang.Thread.sleep;

import java.util.concurrent.CompletableFuture;

public class Solution {

    public static void main(String[] args) throws Exception {
        CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {

            MyLogger.log("CompletableFuture 로직 실행중");
            for (int i = 1; i <= 3; i++) {
                MyLogger.log(i);
            }
            return "이건 진짜 호출되니??";
        });

        cf.thenAccept(result -> {
            MyLogger.log("thenAccept 로직 실행중");
        });

        for (int i = 1; i<= 4; i++) {
            MyLogger.log("메인 스레드 실행중..");
        }
    //    System.out.println(cf.get());
        SleepUtility.Sleep(3000);
    }
}

 

<결과>

14:27:06.456 [     main] 메인 스레드 실행중..
14:27:06.456 [ForkJoinPool.commonPool-worker-1] CompletableFuture 로직 실행중
14:27:06.459 [     main] 메인 스레드 실행중..
14:27:06.459 [ForkJoinPool.commonPool-worker-1] 1
14:27:06.459 [     main] 메인 스레드 실행중..
14:27:06.459 [ForkJoinPool.commonPool-worker-1] 2
14:27:06.459 [     main] 메인 스레드 실행중..
14:27:06.459 [ForkJoinPool.commonPool-worker-1] 3
14:27:06.460 [ForkJoinPool.commonPool-worker-1] thenAccept 로직 실행중

 

 

thenAccept() 를 사용하니 Future 와 동일하게 작동한다.

 

 

thenAccept() 적용 + get()

import static java.lang.Thread.sleep;

import java.util.concurrent.CompletableFuture;

public class Solution {
    
    public static void main(String[] args) throws Exception {
        CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {

            MyLogger.log("CompletableFuture 로직 실행중");
            for (int i = 1; i <= 3; i++) {
                MyLogger.log(i);
            }
            return "이건 진짜 호출되니??";
        });

        cf.thenAccept(result -> {
            MyLogger.log("thenAccept 로직 실행중");
        });

        for (int i = 1; i<= 4; i++) {
            MyLogger.log("메인 스레드 실행중..");
        }
        System.out.println(cf.get());
        SleepUtility.Sleep(3000);
    }
}

 

<결과>

14:25:03.657 [ForkJoinPool.commonPool-worker-1] CompletableFuture 로직 실행중
14:25:03.657 [     main] 메인 스레드 실행중..
14:25:03.661 [ForkJoinPool.commonPool-worker-1] 1
14:25:03.661 [     main] 메인 스레드 실행중..
14:25:03.661 [ForkJoinPool.commonPool-worker-1] 2
14:25:03.661 [     main] 메인 스레드 실행중..
14:25:03.661 [ForkJoinPool.commonPool-worker-1] 3
14:25:03.661 [     main] 메인 스레드 실행중..
이건 진짜 호출되니??
14:25:03.663 [ForkJoinPool.commonPool-worker-1] thenAccept 로직 실행중

 

 

Future 의 get( ) 처럼 동작하게 되는데 얼핏 봐도 더 활용도가 높음을 알 수 있다.

 

대표적으로 blocking 코드 (get) 을 사용하기 이전에 콜백을 적용할 수 있다!!

 

Future.get() 을 적용한다면 특수한 상황에서 비효율이 생긴다

ex) 작업을 다 하기 전에 get() 호출을 당해서 해당 작업이 끝날 때 까지 하염없이 기다려야 하는 경우

 

하지만 CompletableFuture 의 then~ 시리즈를 사용한다면 이러한 문제들을 해결할 수 있을 것이다.

 

 

Future 와 CompletableFuture 의 간략한 비교

Java 8 이전, 우리는 ExecutorService 와 Future 를 사용할 수 밖에 없었다.

Future 로 비동기적인 처리를 해줄수는 있지만, 몇가지 문제점이 있다.

 

1. 블럭킹 없이 작업이 끝났을 때 콜백을 실행할 수 없다.

2. Future 를 여러개 조합하는 것이 어렵다

3. Future 를 외부에서 완료 시킬 수 없다.

4. 예외 처리 API 가 없다. 

 

이러한 문제들이 CompletableFuture 로 해결되었고 이 포스팅은 1번에 대한 내용이다.

 

2,3,4 번 및 CompletableFuture 의 사용법들은 다른 포스팅을 참고하도록 하자 

 

 

번외)

사실과 다른 내용 혹은 잘못된 해석이 있을 수 있다..!

이번 내용은 개인적으로 너무나도 어려웠기에 나의 능력 부족이다.. 미리 미안합니다