비동기 프로그래밍

duckbill413·2024년 6월 1일
0

Spring boot

목록 보기
10/13
post-thumbnail

비동기(Async) 프로그래밍

비동기 프로그래밍 예제 Github

  • Async한 통신
  • 실시간성 응답을 필요로 하지 않는 상황에서 사용
  • ex) Notification, Email 전송, Push 알림
  • Main Thread가 Task를 처리하는 게 아니라
  • Sub Thread에게 Task를 위임하는 행위

Spring에서 비동기 프로그래밍을 하기 위해선 ThreadPool 정의가 필요

  • 비동기는 Main Thread가 아닌 Sub Thread에서 작업이 진행
  • Java에서는 ThreadPool을 생성하여 Async 작업을 처리

ThreadPool 생성 옵션

ThreadPoolExecutor 문서

ThreadPoolExecutor(int corePoolSize, // 최소 유지할 스레드의 개수
	int maximumPoolSize, // 최대 할당 가능한 스레드의 개수
	long keepAliveTime, // corePoolSize의 개수 보다 많이 할당된 경우 사용되지 않는 경우 keepAliveTime 시간 이후 반환
	TimeUnit unit, // keepAliveTime의 시간 단위
	BlockingQueue<Runnable> workQueue // 요청을 담아 놓을 Queue
)
  1. CorePoolSize만큼 Thread를 생성
  2. Queue에 Task를 담는다.
  3. Queue가 다 차게 되면 MaximumPoolSize만큼 Thread를 생성

예제)

new ThreadPoolExecutor(5, 10, 3, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(50));
  • 최소 5개의 Thread는 유지 Queue에 Thread가 찰 경우 10개 까지 Thread가 생성
  • Thread가 3초 동안 사용되지 않는 경우 자원을 반환
  • Queue에는 최대 50개까지의 요청이 담길 수 있음

주의 사항)

  • CorePoolSize 값을 너무 크게 생성한 경우 리소스의 낭비가 발생
  • IllegalArgumentException 에러의 발생
    • corePoolSize < 0, keepAliveTime < 0, maximumPoolSize <= 0, maximumPoolSize < corePoolSize
  • NullPointerException - workQueuenull

비동기 프로그래밍 예제

Async Configuration

  • AppConfig 클래스에 ThreadPool에 대한 설정

    @Bean(name = "defaultTaskExecutor", destroyMethod = "shutdown")
    public ThreadPoolTaskExecutor defaultTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(200);
        executor.setMaxPoolSize(300);
        return executor;
    }
    
    @Bean(name = "messagingTaskExecutor", destroyMethod = "shutdown")
    public ThreadPoolTaskExecutor messagingTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(200);
        executor.setMaxPoolSize(300);
        return executor;
    }
  • AsyncConfig 클래스에 *@EnableAsync* 선언

    @EnableAsync // 비동기 프로그래밍을 하기 위한 Annotation
    @Configuration
    public class AsyncConfig {
    }
  • EmailService 클래스를 생성하고 @Async를 사용하는 메서드 생성

    @Service
    @RequiredArgsConstructor
    public class EmailService {
        @Async("defaultTaskExecutor")
        public void sendMail() {
            System.out.println("[sendMail] :: " + Thread.currentThread().getName());
        }
        @Async("messagingTaskExecutor")
        public void sendMailWithCustomThreadPool() {
            System.out.println("[sendMailWithCustomThreadPool] :: " + Thread.currentThread().getName());
        }
    }
  • Async 활용해 보기

    1. EmailServiceBean 주입하여 사용하기

      private final EmailService emailService;
      
      public void asyncCall_1() {
          System.out.println("[asyncCall_1] ::" + Thread.currentThread().getName()); // 현재 메소드를 실행하는 Thread name 출력
          emailService.sendMail();
          emailService.sendMailWithCustomThreadPool();
      }
      • 비동기적으로 작동하는 것을 확인해 볼 수 있음
        [asyncCall_1] ::http-nio-8080-exec-10
        [sendMailWithCustomThreadPool] :: messagingTaskExecutor-3
        [sendMail] :: defaultTaskExecutor-3
        • 사용되는 Thread의 이름이 서로 다름
    2. EmailService를 인스턴스로 생성하여 사용

      public void asyncCall_2() {
          System.out.println("[asyncCall_2] ::" + Thread.currentThread().getName()); // 현재 메소드를 실행하는 Thread name 출력
          EmailService emailService = new EmailService();
          emailService.sendMail();
          emailService.sendMailWithCustomThreadPool();
      }
      • 비동기로 동작하지 않는 것을 확인할 수 있다.
      [asyncCall_2] ::http-nio-8080-exec-4
      [sendMail] :: http-nio-8080-exec-4
      [sendMailWithCustomThreadPool] :: http-nio-8080-exec-4
    3. AsyncService안에서 내부 메서드에 @Async를 사용한 메서드를 사용

      public void asyncCall_3() {
          System.out.println("[asyncCall_3] ::" + Thread.currentThread().getName()); // 현재 메소드를 실행하는 Thread name 출력
          sendMail();
      }
      
      @Async
      public void sendMail() {
          System.out.println("[sendMail] :: " + Thread.currentThread().getName());
      }
      • 비동기로 동작하지 않는 것을 확인할 수 있다.
      [asyncCall_3] ::http-nio-8080-exec-6
      [sendMail] :: http-nio-8080-exec-6
  • 비동기로 동작하지 않은 원인

    • 메소드를 비동기적으로 동작하기 위해서는 Main Thread에서 Sub Thread로 Task를 넘겨주는 과정이 필요한데 이때 Spring의 힘을 빌리게 된다. 이 과정에서 Spring의 Async Method를 Proxy 객체로 Wrapping하고 이것을 Sub Thread로 넘겨 실행하게 된다.

    • EmailService인스턴스를 직접 생성한 경우 Spring으로 부터 이러한 과정에 대한 도움을 받을 수 없기 때문에 비동기로 동작하지 않게 된다.

    • 또한, @Async한 내부 메서드를 사용할때도 이미 Spring은 AsyncService라는 Bean을 가져온 상태고 CallerAsync Method가 하나의 Bean안에서 존재하게 되므로 Spring으로 부터 역시 도움을 받을 수 없다.


Future & CompletableFuture

앞서 사용한 @Async 메소드들은 Response가 없는 Task들이다. 하지만, 비동기로 처리한 결과값이 필요한 경우가 있을 수 있다. 이 경우 사용할 수 있는 것이 Future이다.

Future은 비동기 작업의 결과를 나타내는 객체로 작업이 완료될 때까지 기다리거나 결과를 가져오는 방법을 제공한다. 기본적인 Future은 기능이 제한적이기 때문에 더 발전된 CompletableFuture이 주로 사용된다.

Future Interface

  • get(): 작업이 완료될 때까지 기다렸다가 결과를 반환합니다. 작업이 완료되지 않았으면 블로킹됩니다.
  • get(long timeout, TimeUnit unit): 작업이 완료될 때까지 주어진 시간만큼 기다렸다가 결과를 반환합니다. 시간이 초과되면 TimeoutException을 던집니다.
  • cancel(boolean mayInterruptIfRunning): 작업을 취소합니다. mayInterruptIfRunningtrue이면 실행 중인 작업도 인터럽트할 수 있습니다.
  • isCancelled(): 작업이 취소되었는지 여부를 반환합니다.
  • isDone(): 작업이 완료되었는지 여부를 반환합니다.

Future Interface의 한계점

  • Callback을 사용할 수 없고, Blocking 방식으로 결과를 기다려야 한다.
  • 여러 FutureChaning하기 어렵다.
  • 예외 처리가 복잡하다.
  • 이러한 단점을 해결하기 위해 CompletableFutre가 사용된다.

CompletableFuture

  1. 비동기 작업 생성 및 실행
    • CompletableFuture.supplyAsync(Supplier<U> supplier): 비동기적으로 작업을 실행하고 결과를 반환합니다.
    • CompletableFuture.runAsync(Runnable runnable): 비동기적으로 작업을 실행하지만 결과를 반환하지 않습니다.
  2. 후속 작업 정의
    • thenApply(Function<? super T,? extends U> fn): 작업이 완료된 후 결과를 다른 값으로 변환합니다.
    • thenAccept(Consumer<? super T> action): 작업이 완료된 후 결과를 소비합니다.
    • thenRun(Runnable action): 작업이 완료된 후 추가 작업을 실행합니다.
  3. 결합 및 합성
    • thenCombine(CompletionStage<? extends U> other, BiFunction<? super T,? super U,? extends V> fn): 두 작업의 결과를 결합합니다.
    • thenCompose(Function<? super T,? extends CompletionStage<U>> fn): 작업의 결과를 다른 비동기 작업으로 연결합니다.
    • allOf(CompletableFuture<?>... cfs): 여러 작업이 모두 완료될 때까지 기다립니다.
    • anyOf(CompletableFuture<?>... cfs): 여러 작업 중 하나라도 완료되면 반환합니다.
  4. 예외 처리
    • exceptionally(Function<Throwable,? extends T> fn): 예외가 발생했을 때 대체 값을 반환합니다.
    • handle(BiFunction<? super T, Throwable, ? extends U> fn): 작업이 정상적으로 완료되거나 예외가 발생했을 때 처리합니다.
    • whenComplete(BiConsumer<? super T,? super Throwable> action): 작업이 완료되거나 예외가 발생했을 때 추가 작업을 실행합니다.

CompletableFuture 사용 예제

CompletableFuture을 사용한 요청을 실행시켜보고 비동기 처리가 이루어지는 지와 @Async 메소드의 결과 확인이 가능한지 확인해 보겠습니다.

  • Controller

    @GetMapping("/future")
    public String completableFutureExample() throws ExecutionException, InterruptedException {
        Future<String> hello = asyncFutureService.runCompletableFuture();
        log.info("do start!");
        log.info(asyncFutureService.doDuring());
        log.info("do end!");
    
        var result = hello.get();
        log.info(result);
        return result;
    }
  • Service

    @Async("async-thread")
    public Future<String> runCompletableFuture() {
        log.info("async start running");
        var result = CompletableFuture.completedFuture(doLogic());
        log.info("async end!");
        return result;
    }
    
    public String doLogic() {
        for (int i = 0; i < 5; i++) {
            try {
                log.info("before " + (i + 1));
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    
        return "future " + LocalDateTime.now();
    }
    
    public String doDuring() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 5; i++) {
            sb.append("after ").append(i).append(", ");
        }
        return sb.toString();
    }
  • 결과

    [nio-8080-exec-3] c.e.s.controller.AsyncFutureController   : do start!
    [nio-8080-exec-3] c.e.s.controller.AsyncFutureController   : after 0, after 1, after 2, after 3, after 4, 
    [nio-8080-exec-3] c.e.s.controller.AsyncFutureController   : do end!
    [        Async-2] c.e.s.service.AsyncFutureService         : async start running
    [        Async-2] c.e.s.service.AsyncFutureService         : before 1
    [        Async-2] c.e.s.service.AsyncFutureService         : before 2
    [        Async-2] c.e.s.service.AsyncFutureService         : before 3
    [        Async-2] c.e.s.service.AsyncFutureService         : before 4
    [        Async-2] c.e.s.service.AsyncFutureService         : before 5
    [        Async-2] c.e.s.service.AsyncFutureService         : async end!
    [nio-8080-exec-3] c.e.s.controller.AsyncFutureController   : future 2024-06-02T01:10:47.886297900

    코드를 보았을때 비동기가 아니라면 beforeafter의 순서로 실행되어야하며 모두 같은 Thread를 사용해야 한다. 그러나, 결과를 보면 비동기 처리가 이루어짐을 확인할 수 있다.

    1. Thread 명을 보면 Main Thread는 nio-8080-exec-3에서 실행되었으며 비동기 메서드는 Async-2에서 실행된 것을 확인할 수 있다.
    2. Async Method가 순서상 먼저이지만 뒤에 있는 after 이 먼저 실행되었으며 Async Methodbefore은 비동기적으로 실행된 것을 로그를 통해 확인할 수 있다.
    3. 마지막으로, doLogic()의 결과값이 반환된 것을 확인할 수 있다.
profile
같이 공부합시다~

0개의 댓글