본문 바로가기
Java

Java - 비동기 api 호출 (CompletableFuture 1편)

by 오늘부터개발시작 2022. 9. 4.

 

 

 

 

 

동기 API

Java 개발을 하다보면 loop 안에서 여러번의 api를 호출해야할 때가 있다. 예를 들어서 아래와 같이 10번의 api 호출을 해야하는 코드가 있다.

 

for (int i = 0; i < 10; i++) {
	blockingApiCall();
}

 

Java에서 이렇게 함수를 호출하면 동기적으로 처리가 된다. 동기적으로 처리 된다는 것은 현재 진행중인 작업이 완료되어야만 그 다음 작업이 진행될 수 있다는 것인데 아래 그림처럼 1번 요청이 완료되어야 2번 요청을 진행할 수 있다는 것을 뜻한다. 

 

만약 api를 한 번 호출하는데 1초가 걸린다고 하면 이 함수는 10번을 반복하기 때문에 무려 10초라는 시간을 기다려야 한다. 뿐만 아니라 이렇게 api의 응답을 기다리는데 걸리는 시간 동안 쓰레드가 block되기 때문에 CPU는 할 일 없이 놀게 되어 리소스도 낭비된다.

 

 

만약에 웹 서비스를 사용할 때 어떤 페이지를 들어가는데 10초가 걸린다면 엄청난 문제이다. 사용자들이 인터넷을 사용할 때 "왜 이렇게 느리지?"라는 생각이 들지 않는 마지노선은 2초라고 한다. 페이지를 로딩하는데 10초가 걸린다고 하면 보통의 사용자들은 수강 신청을 한다거나 이력서를 낸다는 등의 엄청나게 중요한 일이 아니면 페이지를 나가버리게 된다. 다시 말하면 잠재고객을 잃게 되는 것이다.

 

비동기 API - Future

다행스럽게도 Java에서는 비동기 api를 호출할 수 있는 방법을 제공한다. 이 기능은 Java5에서부터 지원되었는데 Future, Callable, ExecutorService를 사용해서 구현할 수 있다. 

 

ExecutorService executorService = Executors.newCachedThreadPool();
Future<Double> futureDouble = executorService.submit(() -> getDoubleValue());

doSomethingElse();

try {
    futureDouble.get(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
    throw new RuntimeException(e);
} catch (ExecutionException e) {
    throw new RuntimeException(e);
} catch (TimeoutException e) {
    throw new RuntimeException(e);
}

 

위와 같이 Future를 사용해서 비동기 api 호출을 하면 다른 스레드가 api 호출을 담당하게 된다. 동기 api 호출과 다르게 api 요청을 기다리지 않고 바로 doSomethingElse()를 실행함으로써 CPU가 놀지 않고 일하게 만든다.

 

doSomethingElse()가 완료되면 api 요청 결과를 .get() 메소드로 받아온다. 그런데 아직 api 요청이 완료되지 않았을 수도 있기 때문에 1초의 타임아웃 설정을 줘서 계속해서 api 요청을 기다리는 것을 방지한다. 만약 타임아웃이 나면 TimeoutException 예외처리에 걸리게 된다.

 

Java5에서 제공하는 Future를 사용해도 충분히 비동기 api를 사용할 수 있지만 Java8에서는 조금 더 간결한 비동기 api를 위해서 CompletableFuture라는 기능을 만들었다. Java8에서는 좀 더 간결한 실행코드, 선언적인 방법을 사용할 수 있도 CompletableFuture를 제공한다. 예를 들면 A요청에 이어지는 B 요청에서 A의 결과 값을 사용한다면 읽기 쉽고 구현하기 쉽게 기능을 제공한다.

 

비동기 API - CompletableFuture

CompletableFuture는 Future를 구현하고 있고 기본적인 사용 방법은 아래 코드처럼 Future와 거의 비슷하다. 값을 꺼내오는 방법은 동일하다.

 

CompletableFuture<Double> futureDouble = new CompletableFuture<>();
new Thread(() -> {
    double value = getDoubleValue();
   futureDouble.complete(value);
}).start();

try {
    futureDouble.get(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
    throw new RuntimeException(e);
} catch (ExecutionException e) {
    throw new RuntimeException(e);
} catch (TimeoutException e) {
    throw new RuntimeException(e);
}

 

 

위의 코드에서 api를 호출하는 부분을 CompletableFuture에서  제공하는 팩토리 메서드로 깔끔하게 변화시킬 수 있다. 필요하다면 람다 내부에서 더 많은 로직을 넣고 double 값만 return하면 된다.

 

CompletableFuture<Double> futureDouble = 
	CompletableFuture.supplyAsync(() -> getDoubleValue())

try {
    futureDouble.get(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
    throw new RuntimeException(e);
} catch (ExecutionException e) {
    throw new RuntimeException(e);
} catch (TimeoutException e) {
    throw new RuntimeException(e);
}

 

 

 

비동기 API - Loop에서 사용 방법

이제 처음으로 돌아가서 Loop에서 비동기 api를 사용하는 방법을 알아보자. Loop에서 가장 깔끔하게 사용하는 방법은 팩토리 메서드인 supplyAsync를 사용해서 CompletableFuture 리스트를 만들고 마지막에 join을 사용해서 결과값을 읽어들이는 방법이다. 

 

Stream<CompletableFuture<Double>> apiCallList = Stream.iterate(0, n -> n + 1)
                .limit(10)
                .map((i) -> CompletableFuture.supplyAsync(() -> getDoubleValue()))
                .collect(Collectors.toList())
                .stream();

List<Double> apiCallResultList = apiCallList
        .map(CompletableFuture::join)
        .collect(Collectors.toList());

 

이렇게 동기 호출에서 비동기 호출로 바꿈으로써 10초 -> 2초로 아주 획기적인 성능 향상을 이끌어낼 수 있다. 위의 코드는 10번 반복하면서 getDoubleValue()를 비동기로 호출한다. 그리고 그 결과를 join을 통해 List<Double>로 추출한다. 이렇게 비동기로 api를 호출하게 되면 1번에 1초가 걸리는 api더라도 보통 2초 이내에 처리가 완료될 수 있다. 

 

이번 시간에는 비동기로 api를 호출하는 방법에 대해서 알아봤다. 다음 시간에는 여러개의 api를 호출할 때 비동기 호출에서 사용할 스레드 개수를 지정해서 성능을 향상시키는 방법과 여러개의 비동기 api를 파이프라인화하는 방법에 대해서 알아보도록하겠다.

 

2편 링크 -> https://today-devstart.tistory.com/33