이펙티브자바에서 동시성 부분을 다루다가 Future 인터페이스가 잠깐 언급됐었습니다.
실행자 서비스의 submit 메서드의 반환값이 Future 타입이었고, 이 반환값의 get 메서드를 호출하니 Runnable 또는 Callable 인자가 수행될 때까지 기다렸었습니다.
Future<?> submit(Runnable task);
당시에 "Future Interface는 get 메서드를 제공하는구나, 그리고 그 get 메서드를 사용하면 끝날때까지 기다리는구나" 라는 것까지만 알고 넘어갔었는데, 깔끔하게 정리되어있는 가이드 문서를 발견해서 정리해보고자 합니다. (추가로 가이드 문서와 더불어 도움이 될 수 있는 예제코드도 함께 추가합니다.)
이펙티브자바 동시성 파트 자바봄 포스팅을 먼저 읽으면 도움이 될 것 같습니다.
https://javabom.tistory.com/83?category=833277
https://javabom.tistory.com/84
https://javabom.tistory.com/85?category=833277
https://javabom.tistory.com/91
https://javabom.tistory.com/92
https://javabom.tistory.com/93
원문가이드는 https://stackabuse.com/guide-to-the-future-interface-in-java/에 있으며 오역에 대한 부분은 댓글로 자유롭게 남겨주시면 감사하겠습니다
Introduction
이 문서에서는 Java 동시성 구성 중 하나인 Future 인터페이스의 기능을 대략적으로 살펴봅니다. Future 인터페이스는 앞에서 말했듯 비동기 Task 를 수행항 뒤 반환되는 값 중 하나이기 때문에(submit) Future 인터페이스와 더불어 Async Task를 생성하는 여러 방법도 함께 살펴봅니다.
Java 5부터 java.util.concurrent 패키지가 추가되었는데요, 동시성 프로그래밍을 가능하게 해주는 여러 클래스가 포함되어있습니다. 동시성은 굉장히 복잡한 주제여서 어렵게 느껴질 수 있습니다.
Java Future 는 JavaScript의 Promise와 굉장히 유사합니다.(하지만 저는 Promise를 잘 모르네요 하하)
Motivation
비동기코드의 일반적인 작업은 비용이 많이 드는 계산이나 데이터 읽기/쓰기 작업을 실행하는 애플리케이션에 응답가능한 UI를 제공하는 것입니다.
계산을 하는 중에 화면이 멈추거나 프로세스가 진행 중임이 표시되지 않으면 굉장히 좋지 않은 User experience 를 얻게됩니다. 물론 계산이 매우 느린 애플리케이션도 마찬가지입니다.
Task Switching의 유휴시간을 최소화하는 것은 어떤 연산을 사용하느냐에 따라 다르지만 애플리케이션의 성능을 향상시킬 수 있습니다.
동기적 한경에서 웹 자원을 가져오는 것, 대용량 파일을 읽어오는것, 연속적인 Micro Service 의 결과를 기다리는 것은 느릴 수 있습니다. 이전 작업이 끝나야 다음 작업을 수행할 수 있기 때문입니다.
반면 비동기 환경에서는 이전 작업의 결과가 반환되지 않아도 다음 작업을 할 수 있습니다.
Implementation
시작하기전에 java.util.concurrent 패키지의 인터페이스와 클래스에 대해 살펴봅니다.
먼제 Callable Interface는 Runnable 의 개선된 버전입니다. 반환값이 없는 Runnable에 반환값과 Exception 을 허용할 수 있습니다.
@FunctionalInterface
public interface Callable<V> {
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
}
아래와 같이 call 메서드를 구현함으로써 사용할 수 있습니다.
@DisplayName("Callable")
@Test
void callable() throws Exception {
Callable<String> callable = () -> "Callable";
assertThat(callable.call()).isEqualTo("Callable");
}
Callable을 따로 구현해 사용하기 보다는 ExecutorService의 인자로 사용하는 경우가 더 많을 것 같습니다.
@DisplayName("ExecutorService And Callable")
@Test
void executor() throws ExecutionException, InterruptedException {
ExecutorService executorService = Executors.newSingleThreadExecutor();
Future<String> submit = executorService.submit(() -> "Callable");
assertThat(submit.get()).isEqualTo("Callable");
}
이렇게 사용했을 때 드디어 Future 인터페이스가 반환되는 것을 확인할 수 있습니다.
get 메서드를 통해 Callable 함수가 수행되기를 기다리고 값을 반환받는다.
The Future Interface
Future Interface 는 이름에서도 유추할 수 있듯 미래에 반환될 결과를 나타내는 인터페이스입니다. Future가 결과를 리턴받았는지, 또는 기다리고 있는지, 실패했는지를 확인할 수 있고 다음 섹션에서 다루도록 합니다.
Future Interface는 다음과 같이 구성되어있습니다.
public interface Future<V> {
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
boolean isCancelled();
boolean isDone();
boolean cancel(boolean mayInterruptIfRunning)
}
메서드를 하나씩 살펴보면,
get()
결과를 가져옵니다. 만약 결과가 아직 리턴되지 않았다면 기다립니다. 중요한 점은 get()은 결과가 반환되기 전까지 애플리케이션의 진행을 "block"한다는 것입니다. get에는 timeout 시간을 지정할 수 있는데, 이 시간동안 반환이되지 않으면 Exception을 던집니다.
cancel()
현재 task의 중단을 시도합니다. 이미 완료되었다면 cancle 명령은 동작하지 않습니다.
isDone()
isCancelled()
현재 Callable Task의 상태를 알 수 있습니다. 일반적으로 get() 과 isDone(), cancel()과 isCancelled() 이 짝지어 사용됩니다.
The Callable Interface
이번 챕터에서는 Callable 인터페이스를 구현하여 Task를 짜는 예제를 소개합니다.
아래 DataReader 클래스는 Callable 을 확장한 구현체입니다.
call 메서드를 살펴보면 5초 이후 Data reading finished 문자열을 반환하며 Task 를 끝내는 것을 알 수 있습니다.
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
public class DataReader implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("Reading data...");
TimeUnit.SECONDS.sleep(5);
return "Data reading finished";
}
}
역시나 5초의 작업을 소요하는 DataProcessor 클래스를 구현합니다.
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
public class DataProcessor implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("Processing data...");
TimeUnit.SECONDS.sleep(5);
return "Data is processed";
}
}
만약 이 두 Callable Task를 동기로 수행하면 총 10초가 걸릴것입니다.
Running Future Tasks
이제 위에서 작성한 Callable Task를 실행자 서비스로 수행해봅니다.
@DisplayName("두 Callable Task 를 수행한다")
@Test
void task() throws InterruptedException, ExecutionException {
ExecutorService executorService = Executors.newFixedThreadPool(2);
Future<String> dataReadFuture = executorService.submit(new DataReader());
Future<String> dataProcessFuture = executorService.submit(new DataProcessor());
while (!dataReadFuture.isDone() && !dataProcessFuture.isDone()) {
System.out.println("Reading and processing not yet finished.");
TimeUnit.SECONDS.sleep(1);
}
System.out.println(dataReadFuture.get());
System.out.println(dataProcessFuture.get());
}
Future의 isDone 메서드로 Task가 끝났는지 판단한 뒤, 끝나지 않았다면 1초를 기다립니다.
그리고 두 Task가 끝나면 get메서드로 반환값을 얻어옵니다.
이 전체 수행은 이변이 없다면 10초가 걸리지 않습니다.
isDone 메서드를 사용하지 않고 get메서드만 사용한다면 blocking 로직이 되기 때문에 isDone 메서드의 쓰임또한 중요하게 보고 넘어가야합니다.
Future Timeout
isDone 메서드를 사용하는 체크로직을 뺀 다음의 코드가 있습니다.
@DisplayName("두 Callable Task 를 수행한다2")
@Test
void taskBlocking() throws InterruptedException, ExecutionException {
ExecutorService executorService = Executors.newFixedThreadPool(2);
long startTime = System.currentTimeMillis();
Future<String> dataReadFuture = executorService.submit(new DataReader());
Future<String> dataProcessFuture = executorService.submit(new DataProcessor());
TimeUnit.SECONDS.sleep(1); // 다른 Task가 있다고 가정한다.
System.out.println(dataReadFuture.get());
System.out.println(dataProcessFuture.get());
System.out.println(" 2 - 소요시간: " + (System.currentTimeMillis() - startTime));
}
이 코드의 수행시간은 여전히 5초대입니다. 이 위의 메서드와 별 차이가 없습니다.
이 프로그램에서 다른 Task를 수행하는데 1초, 그리고 나머지 두 작업을 수행하는데 5초가 걸리는데 위의 프로그램과 다른점이 있습니다.
이 프로그램에서 5초 중 4초는 blocking 되어있습니다. dataReadFuture.get(), dataProcessFuture.get()을 하는동안 이 프로그램은 blocking 되어있는 것이 위의 프로그램과 차이점입니다.
isDone 메서드에서 추가 Task를 수행할 수 있었을 때와 다르게 이 로직에서는 dataProcessFuture 가 반환될 때까지 유휴스레드가 있어도 blocking 됩니다.
이번엔 get 메서드에 시간 제약을 함께 둬보겠습니다.
@DisplayName("Time Limit")
@Test
void timeLimit(){
ExecutorService executorService = Executors.newFixedThreadPool(10);
Future<String> dataReadFuture = executorService.submit(new DataReader());
Future<String> dataProcessFuture = executorService.submit(new DataProcessor());
try {
System.out.println(dataReadFuture.get(3, TimeUnit.SECONDS));
System.out.println(dataProcessFuture.get(10, TimeUnit.SECONDS));
} catch (InterruptedException | TimeoutException | ExecutionException e) {
e.printStackTrace();
}
}
이 테스트코드를 실행하면 TimeoutException이 출력됩니다. dataReadFuture는 5초를 필요로하는데 3초의 제한을 두었기 때문입니다. 물론 실 코드에서는 stackTrace가 아닌 별도의 Exception 대응이 필요합니다.
Cancelling Futures
이번엔 Future 를 취소하는 예시를 소개합니다.
클라이언트는 n초 이내로 결과가 반환되지 않으면 작업 자체를 취소할 수 있는데요, 그런 경우에 이 인터페이스를 사용할 수 있습니다. 아주 단순한 방법으로 리소스 공간을 채워줄 수 있는 방법이기도 합니다.
@DisplayName("cancel Method")
@Test
void cancel() {
ExecutorService executorService = Executors.newFixedThreadPool(10);
Future<String> dataReadFuture = executorService.submit(new DataReader());
String dataReadResult = null;
boolean cancelled = false;
if (dataReadFuture.isDone()) {
try {
dataReadResult = dataReadFuture.get();
} catch (ExecutionException | InterruptedException e) {
e.printStackTrace();
}
} else {
cancelled = dataReadFuture.cancel(true);
}
if (!cancelled) {
System.out.println(dataReadResult);
} else {
System.out.println("Task was cancelled.");
}
}
위 코드에서 dataReadFuture 는 5초가 지나야 반환값이 생기는데, 그 전에 isDone 메서드를 호출하여 반환여부를 확인합니다. 당연히 Task가 끝나지 않았으니 cancelled는 true가 되고 "Task was cancelled."가 출력됩니다.
cancelled = dataReadFuture.cancel(true);
여기서 cancel 메서드의 파라미터로 boolean 값을 받고 있는 것을 볼 수 있습니다. task 의 실행을 중단하도록 하는 파라미터인데요, false로 둔다면 작업이 취소되지 않을 수 있습니다.
그리고 반환값은 취소가 되었는지를 boolean 값으로 반환합니다.
또한, 취소된 상태에서 get을 수행하면 CancellationException이 발생한다는 사실에 주의해야합니다.
@DisplayName("cancel Method")
@Test
void cancel() {
ExecutorService executorService = Executors.newFixedThreadPool(10);
Future<String> dataReadFuture = executorService.submit(new DataReader());
String dataReadResult = null;
boolean cancelled = false;
if (dataReadFuture.isDone()) {
try {
dataReadResult = dataReadFuture.get();
} catch (ExecutionException | InterruptedException e) {
e.printStackTrace();
}
} else {
cancelled = dataReadFuture.cancel(false);
}
if (!cancelled) {
System.out.println(dataReadResult);
} else {
System.out.println("Task was cancelled.");
try {
dataReadFuture.get(); // Exception!
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}
Limitations of the Future
Future 는 비동기 프로그래밍을 하는데 아주 좋은 인터페이스입니다. 하지만 다음 몇가지 사항을 알아둬야합니다.
- Future는 명시적으로 세팅할 수 없다. 즉, 그 값과 상태를 우리가 직접 세팅할 수 없다.
- 또, 일련의 스탭을 만드는 매커니즘을 가지고있지 않다.
- 병렬로 수행하고 병합하는 매커니즘이 없다.
- 그리고 예외 처리 구조가 없다.
다행이 자바에서는 Future의 구현체로 CompletableFuture, CountedCompleter, ForkJoinTask, FutureTask 를 제공하고 있습니다.
Conclusion
Future 인터페이스는 blocking 하지 않게 Task를 수행하는데 도움을 줍니다. 자바는 동시성을 위한 여러 구조를 제공하는데요, Future 인터페이스는 비동기식 계산의 결과와 프로세스 처리를 위한 기본적인 방법(isDone, cancel)을 제공합니다.
다음 포스팅에서는 Future 인터페이스의 구현체를 정리해볼까합니다 !
'스터디 > 자바' 카테고리의 다른 글
HikariCp 설정 유의사항 (0) | 2020.08.10 |
---|---|
Long과 double의 원자성을 보장하기 위한 volatile 선언 (0) | 2020.07.15 |
JMH 사용해보기 (1) | 2020.06.28 |
자바의 제네릭 (1) | 2020.06.14 |
자바 인액션으로 보는 병렬스트림 (0) | 2020.05.31 |