본문 바로가기
Language/Java

[Java 8] CompletableFuture - 1 (개요 및 기본)

by ocwokocw 2021. 2. 11.

- 출처: 자바 8 인 액션

- Future

최근에는 멀티코어 프로세서가 등장하면서 멀티태스크 프로그래밍 처리를 어떻게 하느냐고 큰 관심사가 되고 있다. 일단 멀티태스크 프로그래밍에는 두 가지 특성이 있다. 하나는 병렬성이고 하나는 동시성 개념이다. 

 

병렬성은 하나의 작업을 여러 작업으로 분할 및 여러 작업을 다른 코어로 할당하여 처리하는 개념이다. 반면 동시성은 CPU 하나를 최대한 사용하는 개념이다.

 

Future는 자바 5때 등장하였다. 미래의 어느 시점에 결과를 얻기 위한 모델에 활용하도록 제공하는 인터페이스이다. 시간이 좀 걸리는 작업을 Future 내부로 설정하면 호출자 스레드가 결과를 기다리는 동안 다른 작업을 수행한다. Java 8 이전의 Future 코드를 살펴보자.

 

public static void main(String[] args) {

	ExecutorService executor = Executors.newCachedThreadPool();
	
	
	Future<Long> future = executor.submit(new Callable<Long>() {

		@Override
		public Long call() throws Exception {

			System.out.println("Callable called.");
			System.out.println("Callable thread: " + Thread.currentThread());
			
			long sum = 0;

			for (long i = 0; i < 200000000; i += 1) {
				sum += i;
			}

			return sum;
		}
	});
	
	try {
		System.out.println("Main thread: " + Thread.currentThread());
		Thread.sleep(1000);
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
	
	System.out.println("execute something else");
	
	try {
		
		Long result = future.get(2000, TimeUnit.MILLISECONDS);
		
	} catch (ExecutionException ee) {
		ee.printStackTrace();
	} catch (InterruptedException ie) {
		ie.printStackTrace();
	} catch (TimeoutException te) {
		te.printStackTrace();
	}
	
}

 

위의 코드를 실행하면 아래와 같은 실행결과가 나타난다.

 

Main thread: Thread[main,5,main]
Callable called.
Callable thread: Thread[pool-1-thread-1,5,main]
execute something else
19999999900000000

 

Main thread를 sleep 할 동안 Callable 코드가 수행되었다. 두 스레드는 다른 스레드임을 보여주었고, get을 호출하겨 결과값을 도출하였다. Main thread를 잠시 멈추어도 Callable thread가 실행됨을 알 수 있다.


- Future 제한

Future는 여러개의 Future가 있을때 이들간의 의존성을 표현하기가 어렵다. Future#1, Future#2, Future#3, Future#4 를 통해 비동기로 수행한 후 다음과 같은 요구사항이 있다고 해보자.

 

"Future#1 의 계산결과를 Future#2로 전달하라. 그리고 Future#2의 계산이 끝나면 Future#3, Future#4의 결과와 조합하라."

 

자바 8에서 새로 제공하는 CompletableFuture는 이런 기능을 선언형으로 이용할 수 있다. Future와 CompletableFuture는 Collections와 Stream의 관계와 같다고 할 수 있다.


- 동기와 비동기

더 깊이 알아보기전에 동기와 비동기에 대해서 알아보는것이 좋겠다. 동기 API에서는 호출자와 피 호출자가 각각 다른 스레드에서 실행되는 상황이라도 호출자는 피 호출자의 동작 완료를 기다려야 한다. 이런 호출은 블락 호출이다.

 

비동기 API 에서는 메서드가 바로 반환되며, 끝내지 못한 호출을 다른 스레드에 할당한다. 이와 같은 비동기 API 사용은 비동기 호출이다.


- 비동기 API 생성

우리는 출발지와 목적지를 입력받아 사용자에게 티켓 가격정보를 알려주는 항공사다.

 

public class Airline {

	public double getTicketPrice(String from, String to) {

		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		
		Random random = new Random();
		
		int fromToLength = 0;
		if(from.isEmpty() || to.isEmpty() || from.isEmpty() - to.isEmpty() == 0) {
			fromToLength = 1;
		}
		else {
			fromToLength = Math.abs(from.length() - to.length());
		}
		
		return random.nextDouble() * fromToLength;
	}
}

 

가장 저렴한 티켓값을 찾기 위한 시간이 소요된다는것을 표현하기 위해 1초 동안 sleep을 주었다. 따라서 사용자가 이 API를 호출하면 동작완료까지 1초 동안 블록된다. 이제 이 동기 메서드를 비동기 메서드로 전환해보자. 비동기라는것을 알려주기위해 메서드 시그니처부터 새로 만들어야겠다.

 

public Future<Double> getTicketPriceAsync(String from, String to)

 

getTicketPriceAsync 메소드는 즉시 반환되므로 호출자 스레드는 다른작업을 수행할 수 있다. CompletableFuture를 이용하여 기존 메소드를 변경해보자.

 

public Future<Double> getTicketPriceAsync(String from, String to){
	
	CompletableFuture<Double> futurePrices = new CompletableFuture<>();
	
	new Thread(() ->  {
		
		double price = getTicketPrice(from, to);
		futurePrices.complete(price);
	}).start();
		
	return futurePrices;
}

 

비동기 가격을 반환할 CompletableFuture 인스턴스를 생성하였다. 그리고 실제로 가격을 계산하는 익명 스레드(new Thread())를 생성한 다음 Future 인스턴스를 바로 반환하였다. 사용하는쪽에서는 이를 어떻게 사용할까?


- 비동기 API 사용

위에서 만든 비동기 API를 사용해보자.

 

public static void main(String[] args) {

	Airline airline = new Airline();

	long startTime = System.currentTimeMillis();
	
	Future<Double> futureTicketPrice = airline.getTicketPriceAsync("KOR", "JPN");
	
	long futureReturnTime = System.currentTimeMillis();
	
	System.out.println("Future returned after: " + (futureReturnTime - startTime) / 1000.0);
	
	System.out.println("Do Something Else");
	
	try {
		
		double ticketPrices = futureTicketPrice.get();
		System.out.println("Prices is " + ticketPrices);
		
	} catch (InterruptedException e) {
		e.printStackTrace();
	} catch (ExecutionException e) {
		e.printStackTrace();
	}
	
	long endTime = System.currentTimeMillis();
	
	System.out.println("Searching time: " + (endTime - startTime) / 1000.0);
}

 

- 실행결과

 

Future returned after: 0.046
Do Something Else
Prices is 0.24768232753561692
Searching time: 1.047

 

우리는 Ticket 가격검색이 오래 걸린다는 가정하에 1초의 sleep 시간을 주었다. 만약 getTicketPriceAsync 함수가 블락킹 방식으로 동작했다면 Future가 반환된 시간이 1초 미만일수가 없을 것이다. 이로서 Future가 곧바로 반환되었다는것을 알 수 있다.

 

그 후 ticketPrices 를 얻기 위해 futureTicketPrice 에서 get() 메소드를 호출하였다. 계산이 완료되었다면 결과가 곧바로 반환되며 계산이 아직 완료되지 않았다면 계산이 될 때 까지 블락된다.


- 에러 처리

위의 코드는 잘 동작함을 확인해보았다. 그런데 항공사 에서 가격을 계산하다가 에러가 발생하면 곤란한 상황이 발생한다. 클라이언트는 get 메서드가 반환될때까지 영원히 기다리게 될 수도 있다. 그러므로 프로그램이 나의 통제권 안에 없다면 해당 코드 수행시 timeout으로 제한을 두는것이 좋다.

 

단순히 timeout으로 문제는 해결되지 않는다. 왜 에러가 발생했는지 알 수 없는것이다. CompletableFuture 내부에서 발생한 예외를 사용자에게 전달하는 방법을 알아보자.

 

Ticket 가격을 구할 때 Validation 로직을 추가한것이다. Validation 도중 특정 조건에서 RuntimeException을 발생시키게 되어있다.

 

public Future<Double> getTicketPriceAsync(String from, String to){
		
	CompletableFuture<Double> futurePrices = new CompletableFuture<>();
		
	new Thread(() ->  {
			
		try {
			
			validateTicket(from, to);
				
			double price = getTicketPrice(from, to);
			futurePrices.complete(price);
		} catch (Exception e) {
			futurePrices.completeExceptionally(e);
		}
	}).start();
	
	return futurePrices;
}
	
private void validateTicket(String from, String to) {
		
	if(from == null || from.isEmpty()) {
		throw new RuntimeException("from place is empty");
	}
	else if(to == null || to.isEmpty()) {
		throw new RuntimeException("to place is empty");
	}
	else if(from.equals(to)) {
		throw new RuntimeException("from-to place must be not same.");
	}
}

 

from, to를 둘다 똑같은 KOR로 설정하여 Exception을 발생시켜보자.

 

Future<Double> futureTicketPrice = airline.getTicketPriceAsync("KOR", "KOR");

 

결과는 아래와 같다.

 

Future returned after: 0.048
Do Something Else
java.util.concurrent.ExecutionException: java.lang.RuntimeException: from-to place must be not same.
	at java.util.concurrent.CompletableFuture.reportGet(CompletableFuture.java:357)
	at java.util.concurrent.CompletableFuture.get(CompletableFuture.java:1895)
	at java8.JavaTest.main(JavaTest.java:26)
Caused by: java.lang.RuntimeException: from-to place must be not same.
	at java8.future.Airline.validateTicket(Airline.java:38)
	at java8.future.Airline.lambda$0(Airline.java:17)
	at java.lang.Thread.run(Thread.java:748)
Searching time: 0.051

 

- supplyAsync 사용

CompletableFuture를 더 간단하게 만들수도 있다. 메소드이름에 구현방식이 들어간것이 좋은 네이밍은 아니지만 앞의 만든 메소드와 구분을 위해서 아래처럼 네이밍을 하였다. CompletableFuture의 supplyAsync 메소드를 이용하면 ForkJoinPool의 Executor중 하나가 Supplier를 실행한다. supplyAsync 메소드로 수행해도 에러 처리를 한것처럼 관리를 해준다.

 

public Future<Double> getTicketPriceAsyncViaSupplyAsync(String from, String to){
	return CompletableFuture.supplyAsync(() -> {			
		validateTicket(from, to);			
		return getTicketPrice(from, to);
	});
}

 

API 사용 메소드를 변경하자.

 

Future<Double> futureTicketPrice = airline.getTicketPriceAsyncViaSupplyAsync("KOR", "KOR");

 

실행결과는 아래와 같다.

Future returned after: 0.079
Do Something Else
java.util.concurrent.ExecutionException: java.lang.RuntimeException: from-to place must be not same.
	at java.util.concurrent.CompletableFuture.reportGet(CompletableFuture.java:357)
	at java.util.concurrent.CompletableFuture.get(CompletableFuture.java:1895)
	at java8.JavaTest.main(JavaTest.java:26)
Caused by: java.lang.RuntimeException: from-to place must be not same.
	at java8.future.Airline.validateTicket(Airline.java:47)
	at java8.future.Airline.lambda$0(Airline.java:12)
	at java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1590)
	at java.util.concurrent.CompletableFuture$AsyncSupply.exec(CompletableFuture.java:1582)
	at java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289)
	at java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:1056)
	at java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1692)
	at java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:157)
Searching time: 0.086

댓글