자바에서 비동기 구현하기

zwundzwzig·2023년 5월 20일
2

Java

목록 보기
5/9
post-thumbnail

해당 개념을 공부하게 된 이유는 내가 맡은 프로젝트에서 외부 API를 호출할 때 해당 서버의 처리와 관계없이 해당 요청을 안전하게 마무리하기 위함이다.

일전에 자바스크립트에서 비동기가 어떤 식으로 작동하는 지 공부한 적이 있다. 이번에는 자바 스프링 진영에서 어떤 식으로 비동기를 구현하면 좋을지 고민한 흔적을 기록하려 한다.

참고로 jdk 1.8, Spring Framework 5 버전에 적합한 모듈 및 클래스를 사용하기 위해 고민해봤다.

구현 클래스

Future

먼저 자바에서 비동기적 연산 처리 결과를 구현하기 위해 jdk1.5부터 제공하는 Future 인터페이스부터 알아봤다.

오토튠의 왕 퓨쳐가 생기기전에는 스레드풀을 다루는 등 스레드를 직접 관리하거나 콜백 패턴을 사용했으며 그것도 아니면 직접 퓨쳐와 유사한 기능들을 커스터마이징해 구현하곤 했다.

퓨쳐는 크게 1)비동기 작업을 요청하고 기다리는 동안 2)비동기 작업의 상태를 확인하다가 3)결과가 완료되면 해당 값을 가져와 반환하는 기능을 제공하는 등 자바 진영 비동기 작업의 표준화된 기능이다.

다음은 Future를 구현한 간단한 비동기 API 요청(POST) 구현이다.

public class AsyncRequestWithFuture {
    private static final ExecutorService executor = Executors.newFixedThreadPool(10);

    public static void main(String[] args) {
    
        // 비동기 작업 실행
        Future<String> future = executor.submit(new ApiRequestTask());

       // 작업 완료까지 대기하고 결과 가져오기
        try {
            // 작업이 완료될 때까지 2초 대기
            Thread.sleep(2000);

            // 작업이 완료되었는지 확인 후 결과 가져오기
            if (future.isDone()) {
                String result = future.get();
                System.out.println("API 응답: " + result);
            } else {
                System.out.println("작업이 아직 완료되지 않았습니다.");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }


        // Executor 종료
        executor.shutdown();
        
    }

    // 비동기 작업을 수행하는 Callable 태스크
    static class ApiRequestTask implements Callable<String> {
    
        @Override
        public String call() throws Exception {
            String uri = "http://localhost:3000/";
            String requestBody = "Mask off";

            URL url = new URL(uri);
            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
            connection.setRequestMethod("POST");
            connection.setDoOutput(true);

            // 요청 바디 작성
            try (OutputStream outputStream = connection.getOutputStream()) {
                outputStream.write(requestBody.getBytes());
            }

            // 응답 읽기
            StringBuilder res = new StringBuilder();
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    res.append(line);
                }
            }

            connection.disconnect();

            return res.toString();
            
        }
    }
    
}

혹은 위 main 메서드의 FutureTask를 활용해 다른 방식으로 사용할 수도 있다.

public static void main(String[] args) {
    
        // Future와 비동기를 하나의 오브젝트에 만들어버리기
        FutureTask<String> future = new FutureTask<String>(new ApiRequestTask()); 
        
        // Executor 종료
        executor.execute(future);

       // 작업 완료까지 대기하고 결과 가져오기
        try {
        	// ApiRequestTask가 2초 걸린다는 가정하에,
        	future.isDone(); // false
            Thread.sleep(2100);
            future.isDone(); // true
            String result = future.get();
            System.out.println("API 응답: " + result);
            System.out.println("작업이 아직 완료되지 않았습니다.");
        } catch (Exception e) {
            e.printStackTrace();
        }
        
    }

두 코드 모두 executor.submit나 FutureTask 생성자에 콜백 함수를 넣고 기다린다.

이제 퓨처의 단점을 알아보자.

비동기 작업을 확인하기 위해 제공되는 메서드로 Future.get()이 있는데 이는 결과가 준비될 때까지 스레드를 Blocking하기 때문에 다른 작업들은 모두 기다려야 한다. 이는 요청에 대한 응답의 속도를 늦추고 사용자로 하여금 불편함을 유발한다. 그렇다고 결과를 안 받자니 작업이 제대로 완료된 건지 알 수 있는 방법이 제한적이다.

또한 작업 간 의존성 관리에 어려움이 있다. 예를 들어, Future에선 작업 A의 결과가 작업 B에 필요하다면 B는 A가 완료될 때까지 기다리기 위해 블로킹 된다. 그런데 만약 작업이 복잡해지고 여러 작업이 진행된다면 작업 상태를 주기적으로 확인해야 하는데 이는 어렵기 때문에 문제가 생길 수 있다.

위 코드에서 스레드를 잠시 재웠는데, 이는 비동기 API 요청이 주로 해당 서버에 의존학 때문에 해당 작업이 늦어질 수도 있기 때문에 해당 값을 받아올 때까지 임의의 시간을 더 확보해 두는 것이다. 이 방법은 정확한 작업 완료 시점을 예측할 수 없고, 개발자가 정한 임의의 시간을 기다리는 것이기 때문에 역시 실제 완료되지 않은 응답값을 가져올 수도 있다.

ListenableFuture

구글의 Guava 라이브러리의 ListenableFuture에 대해 알아봤다. Future와 다른 점은 작업 완료 후 콜백을 등록해 결과를 처리할 수 있다는 점이다.

이를 통해 작업 완료까지 블로킹되지 않고 병렬적으로 비동기 로직을 구현할 수 있다.

	public static void main(String[] args) {
        // ListeningExecutorService 생성
        ListeningExecutorService executor = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10));

        // ListenableFuture 생성
        ListenableFuture<String> futureResult = executor.submit(new ApiRequestTask());

        // 작업 완료 후 호출될 콜백 설정
        Futures.addCallback(futureResult, new FutureCallback<String>() {
            @Override
            public void onSuccess(String response) {
                System.out.println("API 응답: " + response);
            }

            @Override
            public void onFailure(Throwable t) {
                System.out.println("작업 실패: " + t.getMessage());
            }
        }, executor);

        // Executor 종료
        executor.shutdown();
    }

그러나 이는 Guava 라이브러리에 대한 의존성이 있고, jdk 1.8부터 이를 보완한 클래스가 제공된다.

CompletableFuture

자바에서 직접 만든 클래스이며 비동기 작업에 대한 많은 기능을 제공한다. 역시 논블로킹 방식으로 사용할 수 있기 때문에 비동기 요청을 보내고 그동안 다른 작업을 진행하다가 완료되면 해당 결과를 받아오는 방식을 채택할 수 있다.

	public static void main(String[] args) {
        // ExecutorService 생성
        ExecutorService executor = Executors.newFixedThreadPool(10);

        // CompletableFuture 생성
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> 
        	// 비동기 요청 보내고 해당 결과 값을 변수에 담음
            // Supplier 객체를 비동기적으로 실행시키고 결과를 반환
        , executor);

        // 작업이 완료되면 결과를 처리하는 콜백 등록
        future.thenAccept(response -> {
        	// 이전 단계의 결과를 처리하는 콜백을 등록하며 매개변수인 Consumer 객체를 실행시킨다.
            // 비동기 작업 완료 후 실행될 로직...
            System.out.println("응답 본문: " + response);
        });
        
        // ExecutorService 종료
        executor.shutdown();
   }

위 코드 이외에도 runAsync 메서드를 활용하면 해당 값을 따로 받지 않고 요청만 보내며 매개변수로 받는 Runnalbe 객체를 비동기적으로 실행시키며 외부 api에 의존성을 줄일 수 있다.

그리고 exceptionally 메서드로 콜백 예외 처리할 수 있으며, thenCompose로 이전 단계의 결과로 새로운 CompletionStage를 반환하는 콜백을 등록해 중복된 비동기작업도 가능하다.

참고할 블로그

profile
개발이란?

0개의 댓글