티스토리 뷰

요청에 대한 응답 시간을 줄이거나, 좀더 효율적인 코드가 필요할때, 비동기 처리를 고려하는 것은 하나의 해결 방법이 될 수 있습니다. 이번에는 Spring에서 제공하는 Async annotation에 대해서 공부 해볼 것입니다.

Spring이 제공하는 @Async를 공부하기 앞서 순수한 Java에서 기본적으로 사용할 수 있는 비동기 처리 방법에 대해 살펴본 후, Spring @Async를 사용하는 이유를 알아보는 순서로 작성할 것입니다.

순수한 Java는 멀티 스레드로 비동기 처리를 할 수 있는데요, 코드로 살펴봅시다.

@Slf4j
@NoArgsConstructor
public class SyncService {

    public void execute() {
        for(int i=1; i<=10; i++) {
            log.info("Sync i = {}", i);
        }
    }
}

아주 간단한 출력문을 만들었습니다. 현재 execute 메서드는 별도의 처리를 하지 않았기 때문에 for문이 끝날때 까지 메소드를 종료하지 않습니다.

public static void main(String[] args) {
    SyncService syncService = new SyncService();
    syncService.execute();
    syncService.execute();
}

그러면, 위의 메소드를 실행했을때 결과는 어떨까요?

example.async.SyncService                : Sync i = 1
example.async.SyncService                : Sync i = 2
example.async.SyncService                : Sync i = 3
example.async.SyncService                : Sync i = 4
example.async.SyncService                : Sync i = 5
example.async.SyncService                : Sync i = 6
example.async.SyncService                : Sync i = 7
example.async.SyncService                : Sync i = 8
example.async.SyncService                : Sync i = 9
example.async.SyncService                : Sync i = 10
example.async.SyncService                : Sync i = 1
example.async.SyncService                : Sync i = 2
example.async.SyncService                : Sync i = 3
example.async.SyncService                : Sync i = 4
example.async.SyncService                : Sync i = 5
example.async.SyncService                : Sync i = 6
example.async.SyncService                : Sync i = 7
example.async.SyncService                : Sync i = 8
example.async.SyncService                : Sync i = 9
example.async.SyncService                : Sync i = 10

어렵지 않게 위의 결과를 예측할 수 있습니다. 이번에는 Thread를 생성하여 비동기로 메서드를 처리해봅니다.

@Slf4j
@NoArgsConstructor
public class AsyncService {

    public void execute() {
        new Thread(() -> {
            for(int i=1; i<=10; i++) {
                log.info("Async i ={}", i);
            }
        }).start();
    }
}

execute 메서드가 실행되면 1~10의 숫자를 로그로 출력하는 스레드를 생성하고 실행하도록 만들었습니다. 이제 이 메서드를 실행하면 출력 결과는 아래와 같습니다.

example.async.AsyncService               : Async i =1
example.async.AsyncService               : Async i =1
example.async.AsyncService               : Async i =2
example.async.AsyncService               : Async i =3
example.async.AsyncService               : Async i =4
example.async.AsyncService               : Async i =5
example.async.AsyncService               : Async i =2
example.async.AsyncService               : Async i =6
example.async.AsyncService               : Async i =7
example.async.AsyncService               : Async i =8
example.async.AsyncService               : Async i =9
example.async.AsyncService               : Async i =3
example.async.AsyncService               : Async i =4
example.async.AsyncService               : Async i =10
example.async.AsyncService               : Async i =5
example.async.AsyncService               : Async i =6
example.async.AsyncService               : Async i =7
example.async.AsyncService               : Async i =8
example.async.AsyncService               : Async i =9
example.async.AsyncService               : Async i =10

이전과 다르게 출력 결과가 매우 뒤죽박죽입니다. 두개의 스레드를 동시에 시켰으니 어쩌면 당연한 결과입니다.

이 순수한 스레드 생성 방식의 문제점은 명확합니다. 만약 execute 메서드가 비즈니스 로직이고, 사용자의 request가 들어오면 execute 메서드를 실행한다고 상상을 해봤을때, 스레드 개수는 사용자의 요청에 비례하여 증가할 것입니다.

이렇게 생성된 스레드들은 프로세스의 스택 영역에 할당됩니다. 그리고 스택 영역은 메모리에 올라가기 때문에 스레드가 할당될 수 있는 영역은 결국 한정적입니다. 만약 스레드가 할당된 메모리 보다 많이 생성된다면, 스택 영역이 힙 영역을 침범하게 되어 Stack Overflow를 만나고 프로세스는 종료되는 문제가 생길 수 있습니다.

이 문제는 운영체제에서도 똑같이 발생하는 문제인데요, 오늘날 운영체제는 이 문제를 해결하기 위해 '암묵적 스레딩'이라는 개념을 도입했습니다. 암묵적 스레딩은 미리 특정 개수만큼의 스레드를 만들어 놓고 만든 스레드만을 이용해 요청을 처리하는 방법입니다.

이 접근의 장점은 미리 스레드를 만들기 때문에 스레드를 생성하고 삭제하는데 소요되는 오버헤드를 감소시킬 수 있고, Stack Overflow 문제도 발생하지 않는다는 점입니다.

순수한 Java가 제공하는 ExecutorService.class는 암묵적 스레딩을 아주 쉽게 사용할 수 있는 API를 제공합니다.

@NoArgsConstructor
@Slf4j
public class ThreadPoolService {
    private final ExecutorService executorService = Executors.newFixedThreadPool(2);

    public void execute() {
        executorService.submit(() -> {
            for(int i=1; i<=10; i++) {
                log.info("Async i ={}", i);
            }
        });
    }
}

순수 Java에서 제공하는 스레드 풀을 이용하기 위해서는 위와같이 ExecutorService를 생성할 때 Thread의 개수를 지정하고, Thread.start()에 해당하는 submit() 메서드를 구현하면 됩니다.

public static void main(String[] args) {

    ThreadPoolService poolService = new ThreadPoolService();
    poolService.execute();
    poolService.execute();
    poolService.execute();
}

이제 위의 메서드를 실행하면 어떤 결과를 얻을까요?

example.async.ThreadPoolService          : Async i =1
example.async.ThreadPoolService          : Async i =1
example.async.ThreadPoolService          : Async i =2
example.async.ThreadPoolService          : Async i =2
example.async.ThreadPoolService          : Async i =3
example.async.ThreadPoolService          : Async i =3
example.async.ThreadPoolService          : Async i =4
example.async.ThreadPoolService          : Async i =4
example.async.ThreadPoolService          : Async i =5
example.async.ThreadPoolService          : Async i =5
example.async.ThreadPoolService          : Async i =6
example.async.ThreadPoolService          : Async i =6
example.async.ThreadPoolService          : Async i =7
example.async.ThreadPoolService          : Async i =7
example.async.ThreadPoolService          : Async i =8
example.async.ThreadPoolService          : Async i =8
example.async.ThreadPoolService          : Async i =9
example.async.ThreadPoolService          : Async i =9
example.async.ThreadPoolService          : Async i =10
example.async.ThreadPoolService          : Async i =10
example.async.ThreadPoolService          : Async i =1
example.async.ThreadPoolService          : Async i =2
example.async.ThreadPoolService          : Async i =3
example.async.ThreadPoolService          : Async i =4
example.async.ThreadPoolService          : Async i =5
example.async.ThreadPoolService          : Async i =6
example.async.ThreadPoolService          : Async i =7
example.async.ThreadPoolService          : Async i =8
example.async.ThreadPoolService          : Async i =9
example.async.ThreadPoolService          : Async i =10

로그를 보면, 동시에 2개의 스레드가 동작하고, 스레드 풀의 자리가 생긴 후 3번째 스레드가 동작하는 것을 볼 수 있습니다.

스레드 풀은 제한 없이 스레드를 생성했을때 문제를 모두 해결해줍니다. 하지만, 단점도 존재합니다. 비동기 방식을 적용하고 싶은 메소드마다 ExecutorService를 선언해야 한다는 점과 submit() 메소드 안으로 로직을 전부 옮겨야 한다는 점입니다. 이는 기존의 코드를 수정하지 않고 기능을 추가시켜야 하는 OCP의 원칙을 위반하는 것이기에 그리 좋은 방법은 아닙니다.

이제 Spring의 @Async annotation에 대해 알아보겠습니다.

@Async의 개념은 아주 간단한데요, 빈의 메서드에 @Async를 추가하면 해당 메서드는 별도의 스레드에서 실행됩니다. 즉, 호출자는 메서드가 완료될 때까지 기다리지 않는 것입니다.

@Configuration
@EnableAsync
public class SpringAsyncConfig {

    @Bean
    public Executor myPool() {
        ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
        threadPoolTaskExecutor.setCorePoolSize(3);    // 기본 스레드 수
        threadPoolTaskExecutor.setMaxPoolSize(10);     // 최대 스레드 수
        return threadPoolTaskExecutor;
    }
}

Config class를 보면 두가지 설정이 들어가 있는 것을 볼 수 있습니다. 기본적으로 생성할 스레드 수와 최대 스레드 수인데요, 여기서 직관적으로 사용 가능한 기본 스레드가 가득 차면 최대 스레드 수만큼 스레드를 증가시킬 것이라고 기대할 수 있습니다. 하지만 이는 잘못된 접근이고, 내부를 열어보면 좀더 자세한 동작 원리를 알 수 있습니다.

내부적으로 스레드를 기다리는 작업들이 들어가는 큐가 존재하는데, 만약 큐가 가득찬다면? 그때서야 스레드의 수를 최대 스레드 수로 증가시켜 작업을 처리합니다. 

Spring @Async는 위와같이 간단한 설정 파일만으로 사용할 수 있으나, 아래와 같은 2가지 사항을 주의해야 합니다.

  • public 메서드에만 적용할 수 있습니다.
  • 동일한 클래스 내에서 비동기 메서드를 호출하는 자체 호출은 작동하지 않습니다.

이 두가지 이유의 원인도 아주 간단한데요, @Async가 메서드를 Proxy로 만들기 때문입니다. Proxy로 만들기 위해서는 메서드가 public으로 공개 되어야 하고 자체 호출은 프록시를 우회하고 기본 메서드를 직접 호출하기 때문에 작동하지 않습니다.

실제로 @Async를 어떻게 사용하는지 보겠습니다. @Async를 사용할 수 있는 메서드로는 2가지 종류가 있는데 void 반환 유형인 메서드와 void가 아닌 반환 유형인 메서드입니다.

먼저 void를 반환하는 서비스 계층의 코드가 있다고 가정하겠습니다. 

@Slf4j
@Service
@NoArgsConstructor
public class AsyncService {

    @Async
    public void execute(int i) {
        log.info("Async = {}", i);

    }
}

그리고 요청을 받는 controller를 아래와 같이 만들겠습니다.

@RestController
@RequiredArgsConstructor
public class AsyncController {
    private final AsyncService asyncService;

    @GetMapping("/async")
    public String getAsync() {
        for(int i=1; i<=5; i++) {
            asyncService.execute(i);
        }
        return "ok";
    }
}

이제 /async로 접근하면 어떤일이 생길까요?

example.async.AsyncService               : Async = 2
example.async.AsyncService               : Async = 1
example.async.AsyncService               : Async = 5
example.async.AsyncService               : Async = 3
example.async.AsyncService               : Async = 4

위와같이 메서드가 비동기로 처리된 결과를 볼 수 있습니다. 정말 간단하네요.

이번에는 반환 유형이 있는 메서드를 보겠습니다. 반환 유형이 있는 메서드는 void와는 달리 요청에 대한 응답이 무엇인지 알아야 하는 경우에 많이 사용되는데요, 아래와 같이 구현할 수 있습니다.

@Async
public Future<String> executeWithString(int i) {
    return new AsyncResult<>("hello world " + i + "!!!\n");
}

여기서 등장한 Future는 비동기적인 연산의 결과를 표현하는 클래스입니다. Future를 이용하면 멀티스레드 환경에서 처리된 데이터를 다른 쓰레드에 전달할 수도 있습니다. AsyncResult는 비동기 실행을 위한 반환 형식 Future으로 선언된 메서드에 사용할 수 있는 핸들이라고 공식 문서에서 알려줍니다.

 

AsyncResult (Spring Framework 5.3.23 API)

A pass-through Future handle that can be used for method signatures which are declared with a Future return type for asynchronous execution. As of Spring 4.1, this class implements ListenableFuture, not just plain Future, along with the corresponding suppo

docs.spring.io

@GetMapping("/async2")
public String getAsync2() throws ExecutionException, InterruptedException {
    Future<String> future = asyncService.executeWithString(1);
    while(true) {
        if(future.isDone()) {
            System.out.println("Result from process = " + future.get());
            break;
        }
    }
    return "ok";
}

위의 컨트롤러는 비동기 메서드가 끝날때까지 계속 확인하는 아주 단순한 로직입니다. 운영체제 Mutex lock의 'spin lock' 방식과 같은 방식이라고 생각하시면 편합니다. 컨트롤러로 요청을 보내보면, 

Result from process = hello world 1!!!

위의 출력문을 정상적으로 얻을 수 있습니다. void가 아닌 값을 return하는 비동기 메서드여도 결과를 얻을 수 있다는 것을 보여줍니다.

'BackEnd > Spring' 카테고리의 다른 글

checkstyle로 코드 컨벤션 관리하기  (0) 2023.01.06
[Spring] Client IP와 Browser 정보 가져오기  (4) 2022.10.02
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함