Virtual Threads 알아보기(4)

이월(0216tw)·2024년 5월 15일

가상 스레드 적용 가이드에 들어가기 전에..

가상 스레드 사용 이유

가상 스레드는 OS 보다는 자바런타임에 의해 구현된 자바스레드이다. (기존 플랫폼은 by OS)
가상 스레드를 사용함으로써 같은 자바프로세스에서도 수백만의 가상 스레드를 쉽게 만들 수 있다.

기존 스레드 기반 서버 애플리케이션은 각 요청마다 하나의 스레드를 생성해 사용한다.
스레드 풀을 활용하지만, 요청이 많아질 수록 스레드 수의 증가, 자원 사용량 증가가 발생하고
특히 IO작업이 많다면 그 동안 쉬고 있는 스레드가 자원을 낭비하게 된다.

가상스레드는 IO작업 처럼 블로킹이 되면 또다른 가상스레드를 매핑해 코드를 실행할 수 있으니
훨씬 서버 애플리케이션의 처리량이 향상되는 것이다.

새로운 지식을 습득해야 하나? NO

가상스레드도 java.lang.Thread의 구현체이므로 기존 스레드와 똑같은 룰을 사용한다.(since JAVA SE1.0)
그래서 프로그래머는 굳이 새로운 개념을 배울 필요는 없다.

하지만.. 기존 쓰레드의 관습과는 다를 것이다.

전통적인 스레드(플랫폼 스레드)는 많은 스레드 생성을 막기 위해 스레드 풀을 사용하거나 작업을 분할해 스레드 재사용을 최대화할 수 있게 관행을 만들었다. (많은 스레드 = 자원 낭비 증가)

하지만 가상 스레드는 수백만의 스레드를 쉽게 만들 수 있고 관리도 쉽고 비용도 낮기 때문에
기존의 스레드 관행을 따르는 것은 오히려 역효과일 수 있다.

즉, 개념등은 동일하지만 전통적인 스레드의 관행이 아닌 새로운 가상 스레드의 관행이 필요하다.


지금부터 보이는 가이드는 입문용 가이드입니다. (모든 기술을 포함하지 않음)

가상 스레드 적용 가이드

가상 스레드는 요청 당 스레드 방식으로 작성된 서버의 처리량을 크게 향상시킬 수 있다.
이런 방식은 해당 요청의 전체 수명 동안 하나 이상의 스레드를 할당해 요청을 처리한다.

먼저 비동기적으로 처리하는 코드를 구현해보자


CompletableFuture.supplyAsync(info::getUrl, pool)
   .thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofString()))
   .thenApply(info::findImage)
   .thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofByteArray()))
   .thenApply(info::setImageData)
   .thenAccept(this::process)
   .exceptionally(t -> { t.printStackTrace(); return null; });

[코드 해석]

  • CompletableFuture.supplyAsync(info::getUrl, pool)
    supplyAsync 메서드로 info::getUrl 메서드를 실행하고 CompletableFuture 생성.
    이 작업은 지정한 pool 에서 비동기적으로 실행된다.

    ```java
    또다른 예시 코드 
    Executor executor = Executors.newFixedThreadPool(3); //executor로 쓰레드풀 생성
    
    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            // 비동기 작업 , 2초간 대기하고 , 문자열 Hello, ~ 를 CompletableFuture 객체로 반환   
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "Hello, CompletableFuture!";
        }, executor); //이때 executor를 이용해 특정 pool 을 사용
        
         future.thenAccept(result -> { //실행한 결과 출력 (위의 문자열이 result에 저장됨) 
            System.out.println("비동기 작업 결과: " + result);
        });
    ```
  • .thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofString()))
    자바스크립트의 Promise와 비슷한 역할을 하며 위의 CompletableFuture의 결과를 기반으로 다음 작업을 지정하고 , 이 작업이 완료되면 새로운 CompletableFuture를 반환한다.

    즉, 여기서는 위에서 info.getUrl이라는 작업을 비동기로 수행을 pool 내부에서 진행했으며,
    해당 작업이 끝나고 리턴된 url로 getBodyAsync 비동기 작업을 연계하고 있다.
    (HttpsResponse.BodyHandlers.ofString() 은 HTTP본문을 문자열 형태로 가져오는 메서드)


  • .thenApply(info::findImage)
    HTTP본문 문자열에서 이미지 URL을 찾는 메서드 실행 및 새로운 CompletableFuture 반환

    또다른 예시 코드 
    CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 10); //10 반환
    
    CompletableFuture<String> resultFuture = future.thenApply(value -> {
       // 현재 CompletableFuture의 결과인 10을 가공하여 새로운 값을 반환하는 함수
       return "Result: " + (value * 2); //특정 값을 변환할 수 있음 
    });
    resultFuture.thenAccept(result -> {
       System.out.println("결과: " + result);  // "12" 
    });
    
  • .thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofByteArray()))
    위 이미지에서 따온 URL로 이미지 데이터를 바이트 배열 형태로 비동기로 가져온다.

  • .thenApply(info::setImageData)
    입력받은 이미지 정보를 세팅한다.

  • .thenAccept(this::process)
    thenAccept는 위에서 실행된 CompletableFuture 의 결과를 토대로 특정 작업을 수행할 수 있다. (값을 리턴해주지는 않는다.)
    여기서는 CompletableFuture (this) 객체의 process 메서드를 호출하고 있다.

  • .exceptionally(t -> { t.printStackTrace(); return null; });
    위의 모든 작업 중 예외가 발생하면 처리하는 메서드

작성을 하고나니 약간 자바스크립트에서 비동기 처리하는 Promise랑 거의 비슷하다.

이번에는 동기적으로 처리되는 코드이다.

try {
   String page = getBody(info.getUrl(), HttpResponse.BodyHandlers.ofString());
   String imageUrl = info.findImage(page);
   byte[] data = getBody(imageUrl, HttpResponse.BodyHandlers.ofByteArray());   
   info.setImageData(data);
   process(info);
} catch (Exception ex) {
   t.printStackTrace();
}

CompletableFuture와 달리 모든 코드가 동기적으로 수행이 된다. (IO작업까지 동기적으로 대기)
이런 동기적 + IO가 많은 코드에 가상 스레드를 활용하면 큰 이점을 얻을 수 있게 된다.

주의할 점

  • 작업당 하나의 스레드를 사용하지 않는 프로그램에서는 가상 스레드의 큰 이점을 기대하긴 어렵다. (그러니까 작업당 하나의 스레드를 사용하는 방식에서 효과적이다)
    ex) 각 요청마다 스레드를 할당하는 전통적인 서블릿 기반 웹 앱
    ex) 단일 스레드나 이벤트 루트 기반의 앱

  • 동기적이고 블로킹하는 코드와 비동기적인 프레임워크와의 혼합은 피해야한다.
    예) 비동기적인 서블릿 3.1스펙은 요청이 들어와도 다른 요청을 비동기적으로 처리할 수 있다.
    예) 그런데 동기적인 코드가 IO블로킹을 마주한다면 요청은 계속 들어오는데 해당 코드에서 병목 현상이 발생하는 것


가상 스레드를 사용할 때 권장사항

(1) 가상 스레드를 쓸거면 모든 작업을 가상스레드로 만들어라

(2) 가상스레드를 pool할 필요가 없다 (오히려 pool 때문에 성능 저하될 수 있음)

(3) 작고 짧은 동시 작업에 적합하다.

플랫폼 스레드는 희소한 자원이기에 pool등으로 관리가 되어야 하지만, 가상 스레드는 관리할 필요가 없다. 그래서 가상스레드는 공유, 풀링 , 자원 같은 것보다는 "처리할 작업"에 중점을 준다.

우리의 어플리케이션에서 사용되는 동시 작업의 수와 가상 스레드의 수는 같다.
만약 어플리케이션에서 10개의 작업이 동시에 수행이 된다면, 각 작업에 대한 가상스레드를 만들어
10개의 가상 스레드를 가지고 있는 것이다.
그리고나서 플랫폼스레드에 할당 및 해제가 반복되면서 사용이 되는 것이다.

pool 이 필요가 없다!

void handle(Request request, Response response) {
    var url1 = ...
    var url2 = ...
 
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var future1 = executor.submit(() -> fetchURL(url1));
        var future2 = executor.submit(() -> fetchURL(url2));
        response.send(future1.get() + future2.get());
    } catch (ExecutionException | InterruptedException e) {
        response.fail(e);
    }
}
 
String fetchURL(URL url) throws IOException {
    try (var in = url.openStream()) {
        return new String(in.readAllBytes(), StandardCharsets.UTF_8);
    }
}

[코드 해석]

  • Executors.newVirtualThreadPerTaskExecutor()
    Executors의 newVirtualThreadPerTaskExecutor()을 이용해 ExecutorService를 생성한다.
    그리고 필요할때마다 가상스레드를 submit하면서 새로운 가상스레드를 생성 및 실행한다.

  • future.get()
    특정 가상스레드의 실행 결과를 반환받을 때까지 호출한 스레드를 대기할 수 있다.

  • try () { } catch {} 로 감싸면 좋은 점
    위처럼 try-with-resources 구문으로 ExecutorService를 사용하면 , try 블록 끝부분에 close() 메서드가 암시적으로 호출된다.
    그래서 자원 해제를 자동으로 처리하기 때문에 코드를 더 안전하게 사용할 수 있다.


profile
#SQLD강사 #AI개발 #AI강사 #개발자 개발도 하고 강의도 하지만 고민을 제일 많이 합니다

0개의 댓글