스프링에서 scheduling annotation 활용해 비동기 구현하기

zwundzwzig·2023년 5월 27일
0

Java

목록 보기
6/9

지난 주 자바에서 비동기 통신하기 위해 필요한 CompletableFuture 클래스에 대해 알아봤다. 오늘은 스프링에서 제공하는 비동기 방식인 scheduling 어노테이션에 대해서 알아보자.

스프링에서 제공하는 어노테이션 중 scheduling.annotation 패키지에 있는 몇 가지 모듈로 비동기를 구현할 수 있다.

@Async 어노테이션을 비동기를 구현하고 싶은 메서드 위에 기입해 사용하는 것이다. 대충 이런식으로 짤 수 있겠다.

public class AsyncUtils {

@Async
public void postAsync() {

	CompletableFuture.runAsync(() -> {
	// ... 비동기로 구현하고 싶은 로직
    })

	}

}

그런데 스프링에서 기본적으로 비동기 통신은 스레드 기반으로 움직인다. 즉, 톰캣에서 기본적으로 제공하는 스레드 중에서 시스템이 사용하는 스레드풀과는 별도의 스레드가 해당 어노테이션이 관리하는 메서드의 호출 시 생성 후 작업하는 것이다.

그런데 만약 해당 비동기 메서드가 호출될 때마다 스레드가 연결돼서 작업하는 방식은 상당한 리소스 낭비가 될 것이다.

그래서 우리는 @Async 어노테이션이 관리하는 비동기 메서드를 위한 설정을 하나 해줘야 한다. 대충 다음과 같이 config class를 하나 짤 수 있을 것이다.

@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {

	@Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(100);
        executor.setQueueCapacity(10);
        executor.setThreadNamePrefix("나만의 비동기 메서드 시작-");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        // 비동기 작업에서 예외 처리를 담당하는 핸들러를 반환
        return new CustomAsyncExceptionHandler();
    }
    
    class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {

    @Override
    public void handleUncaughtException(Throwable throwable, Method method, Object... objects) {
      // 비동기 작업에서 발생한 예외를 처리하는 로직을 구현합니다.
      System.out.println("비동기 작업에서 예외가 발생했습니다: " + throwable.getMessage());
      System.out.println("메소드: " + method.getName());
      for (Object param : objects) {
        System.out.println("파라미터: " + param);
      }
    }
    
  }

}

@Async와 같은 scheduling.annotation 패키지에 있는 @EnableAsync 어노테이션을 통해 해당 클래스가 비동기 메서드 실행에 대한 관리를 하겠다는 의미이다. 물론 @Configuration 어노테이션과 함께 사용해 해당 클래스가 config 파일이라는 걸 스프링에게도 알려줄 필요가 있다.

하나 더 나아가 위 설정 클래스가 AsyncConfigurer 인터페이스를 구현하게 해봤다. 이를 통해 scheduling.annotation 내에서 통일성 있는 비동기 통신을 구현하기 위한 설정을 맞췄다.

위 인터페이스를 구현함으로써 두 가지 메서드를 override할 수 있다.

getAsyncExecutor와 getAsyncUncaughtExceptionHandler이다. 역시 수준높은 개발자들이 만든 메서드 이름이라 그런지 한눈에 어떤 역할을 하는 메서드인지 알 수 있다. 이렇게 구현만으로 비동기에 필요한 Executor와 예외 처리를 할 수 있는 틀을 갖췄다.

getAsyncExecutor를 통해 내가 사용하려는 스레드풀이 어떤 크기에 어떤 별명을 갖는 지 등 정보를 설정해 스프링에게 알려줬다.

여기서 주목할 건 ThreadPoolTaskExecutor라는 클래스이다. 이는 비동기를 위한 Executor를 구현한 클래스로 스레드풀 관리 및 비동기 실행에 있어 개발자를 도와주는 녀석이다.

예시보다 더 많은 setter가 있기 때문에 보다 정확한 설정이 가능하다. 내가 설정한 비동기 메서드가 얼마나 호출되는지 예상 호출량을 파악하고 그에 맞게 스레드풀 설정을 해줬다. 스레드풀 크기를 구하는데는 다음을 참고했다.

예외처리는 inner 클래스로 대충 설정해서 굳이 저 에러를 확인하기 위해 다른 폴더를 뒤지는 일이 없게 했다. 물론, 예외 클래스를 따로 빼야하는 지는 고민 중이다.

다시 @Async 메서드로 돌아가자.

나는 한 폴더에 RequestMapping과 비동기 구현 메서드를 같이 뒀다. 음.. 이 예시는 시간 관계상 생략

그런데 난 분명히 setThreadNamePrefix로 나만의 비동기 메서드 이름을 만들어줬다. 하지만 해당 스레드가 찍히지 않는 것이였다드라. 이는 곧 내가 설정한 config 클래스가 제대로 동작하고 있지 않다는 의미가 된다.

공식문서를 뒤져보니 해결을 찾았다. @Async 메서드가 동작하기 위한 두 가지 조건이 있다더라.

  • 하나는 해당 메서드가 public이어야 하는 것이고
  • 다른 하나는 self-invocation이면 안된다는 것이다.

즉, 해당 메서드가 같은 클래스 내의 다른 메서드를 통해 호출된 것이면 Async가 제대로 동작하지 않는다는 것이다.

그 이유는 바로 proxy 때문이다. 다시 거슬러 올라가보자.

나는 설정 클래스를 만들면서 @Configuration 어노테이션을 사용했다. 이는 스프링 aop에 의해 프록시 패턴 기반으로 동작하겠음을 동의한 것이다.

그렇게 스프링AOP는 @Async가 관리하는 메서드를 호출하는 시점에 가로채 프록시 객체를 만들어 별도의 스레드에 추가하며 즉시 리턴하는 방식으로 작동된다.

그런데 같은 클래스 내 비동기 메서드를 호출하면 스프링 AOP가 새로운 프록시 객체를 만들지 않는다. 프록시가 만들어지기도 전에 내부의 메서드로 가서 읽어버리면 되니까 말이다.

그렇기 때문에 프록시 패턴을 사용하는 AOP가 관리하는 메서드를 사용하고 싶으면 프록시 패턴을 따라 다른 클래스에서 호출해야 하는 것이다. 다른 클래스에서 호출하는 게 정 싫으면 @Async 메서드 내부에 비동기 로직을 집어 넣던가 인터페이스를 활용하는 방법이 있다고 한다.

무튼 다른 클래스로 분리하니까 제대로 prefix 이름이 스레드에 잘 찍혀 나오는 것을 확인했다.

또한, 별도의 스레드풀을 만들어 여러 비동기 작업마다 다른 스레드풀이 관리하는 방식으로도 구현해봤다.

아까 AsyncConfig 클래스를 다시 보자.

@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {

	@Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(100);
        executor.setQueueCapacity(10);
        executor.setThreadNamePrefix("나만의 비동기 메서드 시작-");
        executor.initialize();
        return executor;
    }
    
	@Bean(name = "NEWTHREAD")
    public ThreadPoolTaskExecutor newExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("custom-executor-");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        // 비동기 작업에서 예외 처리를 담당하는 핸들러를 반환
        return new CustomAsyncExceptionHandler();
    }
    
    class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {

    @Override
    public void handleUncaughtException(Throwable throwable, Method method, Object... objects) {
      // 비동기 작업에서 발생한 예외를 처리하는 로직을 구현합니다.
      System.out.println("비동기 작업에서 예외가 발생했습니다: " + throwable.getMessage());
      System.out.println("메소드: " + method.getName());
      for (Object param : objects) {
        System.out.println("파라미터: " + param);
      }
    }
    
  }

}

굳이X2 이렇게 또 다른 빈을 생성한 이유는 사실 이미 기존에 로직에 비동기 스레드풀이 만들어진 상태였는데 나만의 메서드가 새로운 서비스였고, 기존 비동기 로직보다 더 많은 호출량을 찍을 것으로 판단했다.

이를 통해 @Async 메서드에 각각의 Bean 이름을 달아주면 다른 스레드풀의 관리를 받게 된다. 이는 prefix 이름으로 확인했다.

반성할 점

위 코드에서 보면 스레드풀이 호출될 때마다 초기화되는 문제가 생긴다. 그리고 더 큰 문제가 있는데, 쓸데없이 스레드풀을 두개나 만든다는 점이다.

초기화 되는 문제는 사실 간단하다. AsyncConfig 클래스는 스레드풀을 구성하는 설정을 정의하고, @Bean 어노테이션을 사용해 해당 빈을 생성하는데, 스프링은 이 설정을 기반으로 스레드풀을 한 번만 초기화한다. 이후 이 풀은 어플리케이션 전체 수명 동안 재사용된다. 그렇기 때문에 이제 굳이 이니셜라이즈할 필요없다. 그래서 저 메서드 다 뺐다.

두 번째 문제는 조건부 빈 생성으로 해결할 수 있을 것이다. 위 코드를 대충 바꿔보면 다음과 같다.

	@Bean(name = "NEWTHREAD")
    @ConditionalOnProperty(name = "threadpool.enabled", havingValue = "true")
    public ThreadPoolTaskExecutor newExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("custom-executor-");
        executor.initialize();
        return executor;
    }

@ConditionalOnProperty 어노테이션을 사용해 application.properties 내 특정 프로퍼티가 true일 때만 빈을 생성하도록 설정하는 방법이 있다.

하지만, 보다 근본적으로 전체 비동기 api 호출량을 파악해 넉넉한 스레드풀 생성 방식도 있겠다. 이건 좀 더 고민할 문제이다.

참고

profile
개발이란?

0개의 댓글