Backend/Java

Java 비동기 처리 (1) 기본편

호됴! 2022. 5. 23. 03:19

Contents

0. Intro
1. 비동기란
2. 자바 비동기 구성요소
    2.1  Future
    2.2  ExecutorService & Executors
3. 스프링을 사용한다면?
     3.1  @EnableSync
     3.2  Executor Configuration
     3.3  @Async
             3.3.1  주의할 점

 

0. Intro

개인적으로 '비동기'는 그동안 내가 상당히 압도되고 깊게 공부하기 부담스러웠던 주제였다.

왜 그랬을까 생각해보면 아래와 같이 요약해볼 수 있을 거 같다.

비동기를 공부하려고 하면 맞닥뜨리는 현상

1. 비동기 컨셉 근본 글들이 대부분 Javascript 로 이야기한다.

2. 자바로 공부하는 순간 쏟아져나오는 개념... 심지어 자바를 잘 알아야 할거 같은 너낌
     Future (미래?), Executor, Thread Pool 설정, Completable Future, Functional Interface...
     : 자바를 정복하기 전까지 비동기를 알 수 없는걸까 하는 의심까지 듦.

3. 어디서부터 자바 이야기고 어디서부터 스프링 이야기인지...

 

이러한 이유로 비동기를 공부해야지! 하고 패기 넘치게 공부를 시작하다가도 끝도 없이 보이는 문서들에 압도되어 굴복하고는 했다.

하지만 이제는 일할 때 설계에 비동기 처리를 할 필요가 있는지 검토해야 하기에,.. 더 물러날 곳이 없었다.

문서만 몇십개의 글을 본거 같은데,.. 여전히 잘 아는지는 모르겠다.

하지만 나와 비슷하게 비동기가 두려웠던 분들께 도움이 되길 바라는 마음으로 작성하려 한다.

물론 내 글로 쉽게 비동기에 대한 거부감을 한방에 해소할 거라는 생각은 하지 않는다 ㅎㅎ

 

 

1. 비동기란

이 개념은 사실 말로 설명하는 것보다, 그림을 보면 어느 정도 감을 쉽게 잡을 수 있다.

비동기 (Asynchronous) 는 말그대로 sync 를 맞추지 않아도 되는(a-) 것을 의미한다.

sync 를 맞춘다는 개념 또는 동기적 처리는 어떻게 이해하면 될까?

좌측 그림처럼 A 작업 > B 작업 > C 작업 순차적으로 작업이 진행되는 경우를 동기적이라고 한다.

한편 우측과 같이 A 작업, B 작업, C 작업이 동시에 실행되고 있다면 비동기로 처리된다고 말할 수 있다.

(=B 작업의 시작이 A 작업이 끝나는 이후로 sync를 맞추지 않아도 된다면)

 

 

좀 더 깊고 자세히 설명할 수도 있겠지만, 이 정도면 감 잡는데는 충분할 것이라 생각한다.

동기 비동기는 블로킹 논블로킹 개념과 함께 설명되는 글 또한 많은데, 대략적인 차이점을 알되 어느 정도 혼용하며 사용된다.

뭐가 답이다 라고 말하기에는 어려워서 이쯤하고 넘어가겠다.

 

2. Java 비동기 구성요소

2.1 Future

Future 는 미래에 나오는 비동기 연산 결과를 나타내는 객체다. 처리가 완료되면 미래에 Future 에 값이 나온다는 것이 이름에 함축되어있다. 자바 1.5 버전 에 java.util.concurrent 패키지에 인터페이스 자체를 지칭하기도 하지만, 자바 뿐만 아니라 언어 상관없이 비동기 작업의 결과를 받는 방식으로 넓게 이해할 수 있다.

 

그럼 동기적으로 돌아가던 다음과 같은 코드가 있다고 가정해보자.

public class Shop {
  public static void delay() {
    try {
      Thread.sleep(1000L);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
  }
  
  public double getPrice(String product) {
    return calculatePrice(product);
  }
  
  private double calculatePrice(String product) {
    delay();
    return random.nextDouble() * product.charAt(0) + product.charAt(1);
  }
}

 

위 작업을 비동기 메서드로 변환한다면 어떻게 해야 할까?

우선 return 값은 일반 객체 타입이 아니라, Future 로 감싸주어야 한다고 했다.

class Shop{
   //...
    private ExecutorService executor = Executors.newSingleThreadExecutor();
    
    public Future<Double> getPriceAsync(String product) {
    	return executor.submit(() -> {
            double price = calculatePrice(product); //비동기 계산 스레드에서 계산
            futurePrice.complete(price); //오래걸리는 작업 값 Future에 설정
            return futurePrice;
         });
    }
}

우선 return 형태가 기존 반환 객체타입을 Future로 감싸주어야 하는 형태로 바뀐 것을 확인할 수 있다.

그런데 새로운 필드가 추가되며 ExecutorService, Executors 형태의 클래스가 보인다. 

이 클래스는 어떤 역할을 하는 것일까?

 

2.2 ExecutorService & Executors

ExecutorService는 비동기로 작업(task)을 간단하게 돌리도록 하는 JDK API로, 스레드 풀을 제공하고 task를 스레드에 배정하는 데 사용된다. ExecutorService는 스레드 풀을 제공하는 역할을 하며, task를 할당받을 수 있도록 API를 제공한다.

ExecutorService 를 만드는 방법은 대표적으로 두가지인데, 하나는 ExecutorService의 생성자를 호출하는 방식이고, 다른 하나는 Executors 클래스의 팩토리 메소드를 사용하는 것이다.

Executors는 ExecutorService 객체를 생성하는 정적 팩토리 메소드를 제공하는 일종의 utility 클래스다. 메소드로 쓰레드 풀 관리 방식을 선택할 수 있으며, 쓰레드 풀 개수 또한 인자로 지정 가능하다.

더보기

참고) Executors 가 제공하는 정적 팩토리 메소드

  • newFixedThreadPool(int) : 인자 개수만큼 고정된 쓰레드풀 생성
  • newCachedThreadPool(): 필요할 때 필요한 만큼 쓰레드풀 생성. 이미 생성된 쓰레드를 재활용하여 성능상 이점 존재
  • newScheduledThreadPool(int): 일정 시간 뒤에 실행되는 작업이나, 주기적으로 수행되는 작업 등에 적합
  • newSingleThreadExecutor(): 쓰레드 1개인 ExecutorService를 반환, 싱글 쓰레드에서 동작해야 하는 작업 처리 시 사용

 

이제 다시 비동기로 변환한 코드를 보자.

class Shop{
   //...
    private ExecutorService executor = Executors.newSingleThreadExecutor();
    
    public Future<Double> getPriceAsync(String product) {
    	return executor.submit(() -> {
            double price = calculatePrice(product); //비동기 계산 스레드에서 계산
            futurePrice.complete(price); //오래걸리는 작업 값 Future에 설정
            return futurePrice;
         });
    }
}

즉, Executors 의 팩토리 메소드를 사용하여 인스턴스 변수 executor 로 만든 것을 확인할 수 있다.

그리고 앞에서 ExecutorService 는 task를 할당받아 실행시키도록 하는 API를 제공한다고 언급했다.

execute(), submit(), invokeAny(), invokeAll() 등 다양한 API를 제공하는데,

위 예제에서는 submit() 을 사용하여  ExecutorService에 task를 할당하여 실행시키고 그 결과값을 반환받는다.

 

주요 API에 대한 설명은 아래와 같다.

ExecutorService API 설명
execute() - 반환값이 없는 메소드를 실행시키는 메소드
- task의 실행 결과를 얻거나 돌고 있는지와 같은 상태 체크 불가

executorService.execute(runnableTask);
submit() Callable 또는 Runnable task를 ExecutorService에 등록해서 결과값을 반환

Future<String> future = executorService.submit(callableTask);
invokeAny() ExecutorService에 task를 collection으로 등록하면 각각을 돌게 한 후
성공적으로 실행된 태스크 결과 중 하나를 아무거나 반환.

사용예시)
String result = executorService.invokeAny(callableTasks);
invokeAll() ExecutorService에 task를 collection으로 등록하면 각각을 돌게 한 후
모든 태스크 실행결과들을 퓨처 객체들의 리스트 형태로 반환.


List<Future<String>> futures = executorService.invokeAll(callableTasks);
더보기

Runnable? Callable?

멀티 스레딩은 예전부터 자바의 주요 특징으로 자리잡았는데,
Runnable은 멀티스레드로 돌아가는 task를 나타내기 위한 주요 interface였다.
Callable은 Runnable가 발전된 버전으로 Java1.5에 추가되었다.

 

Runnable

public interface Runnable {
    public abstract void run();
}
  • 인자와 결과값이 모두 없는 형태의 functional interface 
  • 예외를 발생시키지 않는다.
  • Thread 클래스와 ExecutorService 클래스 모두 사용 가능

Callable

public interface Callable<V> {
    V call() throws Exception;
}
  • 인자는 없고 결과값이 Future 객체에 감싸져 반환되는 형태의 functional interface
  • throws Exception 구문에서 알 수 있듯이 예외 전파 가능
  • ExecutorService만 사용 가능

 

앞서 비동기 작업(task)으로 만드는 코드를 짰다면, 이제 비동기 작업 결과가 필요한 스레드에서 이 결과를 어떻게 활용하는지 확인해보자.

class AsyncHandlingEx{
	Shop shop = new Shop("BestShop");
    long start = System.nanoTime();
    
    Future<Double> futurePrice = shop.getPriceAsync("my favorite product"); //상정에 가격 요청
    
    long invocationTime = ((System.nanoTime() - start) / 1_000_000);
    System.out.println("Invocation returned after " + invocationTime + " msecs");
    //가격계산하는 동안 다른작업 수행
    doSomethingElse();

    try {
        double price = futurePrice.get(); //가격정보 받을때까지 블럭
        System.out.printf("Price is %.2f%n", price);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
    long retrievalTime = ((System.nanoTime() - start) / 1_000_000);
    System.out.println("Price returned after " + retrievalTime + " msecs");
}

 

Client 스레드에서 해당 결과를 가져오려면 Blocking으로 작동하는 get()를 사용한다.

(Blocking method 라는 건, task 수행이 끝날 때까지 클라이언트 스레드에서 아무 일도 하지 않고 대기하다가 결과가 나오면 값을 받아온다는 의미이다. 물론 인자로 최대 대기 시간을 주면 그 시간동안만 기다리고 초과시 TimeoutException을 뱉는다.)

이외에 Future에서 제공하는 다양한 메소드는 공식문서를 참고하도록 하자. 

또한 이 예제의 경우 예외 처리가 간단하게 되어있는데, 예외 처리도 필수적이다. 자세한 건 다음 포스팅 때 다루겠다.

 

 

다음으로 넘어가기 전에, 잠시 정리해보자.

비동기로 만들고 싶은 작업이 있다면,

  1. 작업의 결과값이 존재하는지 여부를 체크 -> 있다면 리턴 타입을 Future로 감싸준다.
  2. ExecutorService 객체를 만들어 스레드 풀 관리 설정 완료
  3. ExecutorService 객체에 task를 배정하여 실행시킨다.
  4. 실행 결과가 필요한 경우 필요로 하는 client 쪽 코드에서 예외처리와 함께 결과를 처리하는 코드를 작성한다.

 

3. 스프링을 사용한다면?

스프링에서의 비동기 처리를 검색해보면 우선 냅다 @Async 를 붙이고 시작하는 경우를 심심찮게 볼 수 있다.

필자 또한 @Async 어노테이션을 우선 적어두면 괜히 모든 게 비동기로 뚝딱뚝딱 돌아갈 거 같은 심적 안정감을 얻는다.

실제로 @Async 어노테이션을 통해 스프링에서 비동기 처리가 훨씬 편리하고 깔끔하게 이뤄질 수 있다.

하지만 @Async 어노테이션 뿐만 아니라 스프링이 적용되며 몇 가지 변경점들이 생기는데, 하나씩 보자.

 

3.1 @EnableAsync

다만 그 전에 가장 먼저 해야 할 것은 @EnableSync 어노테이션을 달아 비동기를 지원 받을 수 있도록 해줘야 한다. 설정 클래스 또는 Spring Boot를 사용할 경우 SpringBoolApplication 이 실행하는 클래스 위에 @EnableSync를 달면 된다.

 

3.2 Executor Configuration

앞서 자바 로만 구현되었던 경우 ExecutorService 를 인스턴스 변수로 만들어 사용했다.

이를 스프링으로 전환한다면 싱글톤으로 스프링이 관리할 수 있도록 설정 파일에 등록해서 의존성 주입을 받지 않을까? 라고 생각해볼 수 있다.

실제로 어떤 식으로 설정하는지 코드를 보며 이야기해보자 :

import java.util.concurrent.Executor;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurerSupport;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
@EnableAsync
public class AsyncConfig extends AsyncConfigurerSupport {
    
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(50);
        executor.initialize();
        return executor;
    }
}

 

스프링에서는 ExecutorService 대신 Executor 를 정의하면 된다.

스프링은 디폴트로 SimpleAsyncTaskExecutor를 사용해 메소드들을 비동기로 돌리는데, 

당연히 상황에 따라 필요한 스레드 풀 개수가 달라지는 등 상황에 맞는 Executor를 구성하기 위해서는 이를 재정의해야 한다.

재정의는 크게 어플리케이션 레벨과 개별 메소드 레벨에서 가능한데, 하나씩 확인해보자.

 

Option 1. 메소드 레벨에서 Executor를 재정의(Override)

우리는 설정(configuration) 클래스에서 필요한 executor를 정의하는데,

@Configuration
@EnableAsync
public class SpringAsyncConfig{
    
    @Bean(name = "threadPoolTaskExecutor")
    public Executor threadPoolTaskExecutor(){
        return new ThreadPoolTaskExecutor();
    }
}

@Async의 인자로 executor 빈의 이름을 넣어주는 방식이 있다.

@Async("threadPoolTaskExecutor")
public void asyncMethodWithConfiguredExecutor(){
    System.out.println("Execute method with configured executor - "
      + Thread.currentThread().getName());
}

이렇게 되면 메소드 별로 필요한 Executor를 매핑하여 사용할 수 있게 된다.

 

 

Option 2. 어플리케이션 레벨에서 Executor를 재정의 (Override)

설정 클래스는 AsyncConfigurer 인터페이스를 구현해야 하고, 어플리케이션 전체를 위한 executor를 반환하는 데 사용되는 getAsyncExecutor() 를 재정의할 수 있다. 이제 이 executor가 @Async 어노테이션이 달린 모든 메소드에 대한 디폴트 executor로 작동할 것이다.

@Configuration
@EnableAsync
public class SpringAsyncConfig implements AsyncConfigurer{
    
    @Override
    public Executor getAsyncExecutor(){ 
    	ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(50);
        executor.initialize();
        return executor;
    }
    
}

 

3.2 @Async

그리고 나서 비동기로 돌리고자 하는 메소드 위에 @Async를 달면,

Spring Context에 등록된 빈 객체의 메소드가 호출되면 스프링이 확인해서 @Async가 달려 있으면 스프링이 메소드를 가로채 다른 스레드에서 실행시켜준다. 자바에서는 ExecutorService 를 만들어 직접 submit() 등의 메소드에 일일이 함수를 넣어주었다면, 그러한 번거로운 작업을 이제 스프링이 해주는 것이다.

이렇게 스프링이 개입되며 보다 편하게 비동기 코드를 작성할 수 있다.

 

다만 아래의 점만 유의하여서 사용하자.

  1. public 메소드에만 적용되어야 한다.
    : proxy로 만들어져야 하기 때문에
  2. 같은 클래스의 비동기 메소드를 호출하는 경우 (self-invocation) 하면 동작하지 않는다.
    : proxy를 거쳐 근거가 되는 메소드를 직접 호출하므로

 

 

 

Reference

http://www.yes24.com/Product/Goods/77125987

 

모던 자바 인 액션 - YES24

자바 1.0이 나온 이후 18년을 통틀어 가장 큰 변화가 자바 8 이후 이어지고 있다. 자바 8 이후 모던 자바를 이용하면 기존의 자바 코드 모두 그대로 쓸 수 있으며, 새로운 기능과 문법, 디자인 패턴

www.yes24.com

https://www.baeldung.com/java-future#more-multithreading-with-thread-pools

 

Guide to java.util.concurrent.Future | Baeldung

A guide to java.util.concurrent.Future with an overview of its several implementations

www.baeldung.com

https://www.baeldung.com/java-completablefuture

 

Guide To CompletableFuture | Baeldung

Quick and practical guide to Java 8's CompletableFuture.

www.baeldung.com

https://www.baeldung.com/java-executor-service-tutorial

 

A Guide to the Java ExecutorService | Baeldung

An intro and guide to the ExecutorService framework provided by the JDK - which simplifies the execution of tasks in asynchronous mode.

www.baeldung.com

https://www.baeldung.com/java-runnable-callable

 

Runnable vs. Callable in Java | Baeldung

Learn the difference between Runnable and Callable interfaces in Java.

www.baeldung.com

 

'Backend > Java' 카테고리의 다른 글

Java 비동기 처리 (2) - CompletableFuture 사용해보기  (0) 2022.06.26
단위 테스트 기초  (0) 2022.04.24