스프링 비동기처리

들어가며

F-lab 프로젝트를 진행하며 글을 작성했을 때 작성자를 팔로우하고 있는 회원에 대한 피드를 생성하는 로직을 구현하는 일이 있었다. 피드 발행 기능을 개발하며 동기 처리를 했을 때와 비동기 처리를 했을 때 성능차이를 살펴보며 스프링의 비동기 처리에 대해 간략히 알아보고자 한다.

간단한 비동기 처리방법

@Configuration // componentScan이 되어야 하므로
@EnableAsync
public class AsyncConfig {
  ...
}

@Async // 비동기 처리를 하고자 하는 로직에 추가
public void save(Post post, User writer){
  ...
}

 

1. 먼저 루트 프로젝트 하위에 비동기처리 관련 설정 클래스를 만든다. 해당 클래스에 @Configuration, @EnableAsync를 추가한다. @Configuration는 컴포넌트 스캔 대상으로 등록하기 위함이고, @ EnableAsync가 비동기처리를 사용할 수 있게 하는 것이다.

 

2. 비동기 처리를 사용하고자 하는 메소드에 @Async를 추가한다.

 

이렇게 작업을 한 후 로직을 수행해보면 비동기 처리가 되는 것을 확인할 수 있다.

간단한 성능 확인

현재 사이드 프로젝트에서 글 작성 로직은 아래 순서로 진행된다.

 

1. 글 엔티티 저장

2. 글의 태그, 이미지들 저장

3. 이미지 파일 외부 스토리지에 업로드

4. 피드 발행(글 작성자를 팔로우하고 있는 유저 대해)

 

1~3 의 로직은 글이 생성되는 과정에서 동기적으로 처리되는 것이 자연스럽지만 피드 발행은 동기적으로 처리되는 것 보다는 비동기로 처리되는 것이 좋아보인다. 예를 들어 팔로우가 많은 작성자가 글을 썼을 때 동기적으로 처리하면 전체 팔로워에게 모두 피드를 발행할 때까지 글 작성이 지연되는 문제가 발생한다. 

 

먼저 동기로 처리했을 때 글 작성 시 얼만큼의 시간이 소요되는지 테스트 해봤다.

글 작성자는 997명의 팔로워를 가진 상태이다. 즉 글 하나를 작성하면 998개(997명의 팔로워 + 내 피드)의 피드가 생성되는 것이다.

 

로컬에서 확인했을 때 평균 2500ms가 소요됐다. 즉 하나의 글을 작성하는데 2.5초가 걸렸다는 의미이다. 요즘 웹 서비스 사용자에게 결코 짧지 않은 시간이다. 글을 작성하는데 2.5초씩 걸린다는 것은 그만큼 사용성이 떨어진다는 것을 의미한다. 그렇다면 1~3번까지는 동기로 처리하고 4번은 비동기처리로 한다면 얼마나 걸릴까? 

@Async 적용 전

 

@Async를 적용하고 시간을 확인해보니 403ms가 소요됐다. 즉 글 작성 요청에 0.4초가 걸렸다는 것이다. 비동기 처리를 적용하니 2.5초 -> 0.4초로 2.1초가 줄어들었다. @Async 추가로 간단하게 비동기 처리만 적용했음에도 사용성이 대폭 개선됐다. 

@Async  적용 후

 

이렇게 비동기 처리를 적용하고나니 사실 너무 간단하여 @Async가 어떻게 동작하는지 이론적인 내용을 알고 싶어졌다. 여러 자료를 찾아보던 중 토비의 스프링 저자로 잘 알려져 있는 이일민님의 강의가 있어서 해당 내용을 정리해보고자 한다. 강의는 링크를 참고하자.

@Async

public void main(){

  String msg = myservive.service();
  ...
}

@Async
public String service(){
  ... 
  return result;   
}

 

만약 위와 같은 코드가 있을 때 main 메소드를 실행하면 어떤 일이 발생할까?

Invalid return type for async method (only Future and void supported): class java.lang.String

 

바로 예외가 발생한다. 비동기 메소드의 리턴 타입에 해당하지 않는다는 것이다. 사실 이일민님 강의에서는 null이 반환된다고 했는데 좀 시간이 된 강의라 현재 스프링부트 버전에서는 예외처리가 나도록 변경된 것 같다. 참고로 나의 스프링부트 버전은 3.1.6이다.

 

AsyncExecutionAspectSupport 클래스를 보면 doSubmit 메소드에서 비동기 로직을 수행하는 것을 확인할 수 있는데 조건문에서 볼 수 있듯이 지정한 타입 이외의 리턴타입이면 예외를 던진다.

protected Object doSubmit(Callable<Object> task, AsyncTaskExecutor executor, Class<?> returnType) {
    if (CompletableFuture.class.isAssignableFrom(returnType)) {
      return executor.submitCompletable(task);
    } else if (ListenableFuture.class.isAssignableFrom(returnType)) {
      return ((AsyncListenableTaskExecutor)executor).submitListenable(task);
    } else if (Future.class.isAssignableFrom(returnType)) {
      return executor.submit(task);
    } else if (Void.TYPE == returnType) {
      executor.submit(task);
      return null;
    } else {
      throw new IllegalArgumentException("Invalid return type for async method (only Future and void supported): " + returnType);
    }
  }

 

@Async가 붙은 메소드의 리턴 타입은 CompletableFuture, ListenableFuture, Future 또는 void여야 한다. 따라서 위 테스트 코드를 아래와 같이 변경해야 한다. 강의에서는 AsyncResult, ListenableFuture 클래스로 반환하는 방식도 안내하지만 Spring 6버전부터 해당 클래스는 deprecate 되었다. CompletableFuture으로 사용하게끔 하는 이유는 ListenableFuture, Future의 경우 비동기 로직의 반환 값과 예외 발생 시 콜백 처리를 해주어야 하기 때문인 것 같다. 비동기 처리가 순차적으로 진행된다면 이른바 콜백 헬에 빠질 수 있다. 반면 CompletableFuture의 경우 chaining을 통해 콜백 처리 없이도 반환 값을 사용할 수 있으며 코드가 한결 간결해진다.

public void main(){

  myservice.service().thenAccept(s -> {
    System.out.println(s);
  });
  
  ...
}

@Async
public CompletableFuture<String> service(){
  ... 
  return CompletableFuture.supplyAsync(()-> "Hello!");   
}

@Aysnc 사용 시 주의할 점

비동기 처리를 적용할 때 주의해야할 점이 있다. 바로 Thread에 대한 부분이다. 기본적으로 비동기 처리란 로직을 수행하는 과정에서 비동기 로직이 있다면 해당 로직은 별도의 Thread에서 처리되는 것을 의미한다. Thread는 시스템 자원이 많이 사용되므로 기본적으로 ThreadPool을 이용해 Thread를 재활용하는 방식을 취한다. 그런데 spring에서 비동기 처리를 적용하고 아무런 설정을 하지 않으면 SimpleAsyncTaskExecutor를 사용한다. 

// spring-task.xsd
Specifies the java.util.Executor instance to use when invoking asynchronous methods.If not provided, an instance of org.springframework.core.task.SimpleAsyncTaskExecutorwill be used by default.Note that as of Spring 3.1.2, individual @Async methods may qualify which executor touse, meaning that the executor specified here acts as a default for all non-qualified@Async methods.

 

중요한 건 SimpleAsyncTaskExecutor는 ThreadPool이 아니라는 점이다. 아래 코드를 보면 알 수 있듯이 매 실행마다 Thread를 새로 만든다.

protected void doExecute(Runnable task) {
  Thread thread = this.threadFactory != null ? this.threadFactory.newThread(task) : this.createThread(task);
  thread.start();
}

 

이는 성능적인 측면에서 매우 비효율적이다. Spring은 Executor, ExecutorService, TaskExecutor 타입의 bean이 하나 있을 때 해당 ThreadPool을 비동기 처리에 사용한다. 따라서 아래와 같이 코드를 작성해야 한다.

@Configuration
@EnableAsync
public class AsyncConfig {

  @Bean
  public TaskExecutor taskExecutor(){
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5); // ThreadPool의 기본 Thread 수
    executor.setMaxPoolSize(100); // ThreadPool의 최대 Thread 수
    executor.setQueueCapacity(50); // ThreadPool에 사용 가능한 Thread가 없을 때 대기열 용량
    executor.initialize();
    return executor;
  }
}

 

만약 ThreadPool을 2개 이상 bean으로 관리해야 하는 상황이라면 아래와 같이 bean의 이름을 지정하고 @Async에 사용할 ThreadPool을 지정해주어야 한다.

@Bean("threadPool-1")
public TaskExecutor taskExecutor1(){
  ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
  executor.setCorePoolSize(5);
  executor.setMaxPoolSize(100);
  executor.setQueueCapacity(50);
  executor.initialize();
  return executor;
}

@Bean("threadPool-2")
public TaskExecutor taskExecutor2(){
  ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
  executor.setCorePoolSize(5);
  executor.setMaxPoolSize(100);
  executor.setQueueCapacity(50);
  executor.initialize();
  return executor;
}

@Async("threadPool-1")
  public CompletableFuture<String> service() {
    try {
      log.info("thread name: {}", Thread.currentThread().getName());
    }catch (Exception e){
      System.out.println(e.getMessage());
    }
    return CompletableFuture.supplyAsync(()-> "Hello World");
}

마치며

아주 간략하게 spring의 비동기 처리에 대해 살펴보았다. 사실 아직 비동기/동기, Blocking/Non-Blocking에 대해 완벽하게 이해한 것 같지는 않다. 추후 개념적인 정리도 해봐야겠다.