java

비동기 처리 - @Async

Han_5ung 2024. 8. 16. 04:30

이전 글

이전 병렬 스트림을 이용한 병렬 처리에선 I/O 네트워크 작업이 포함되어 있었다. 이제 맞게 비동기 작업을 진행하도록 SpringBoot의 비동기 처리 방법을 알아보고 리팩토링을 진행해 보자.

비동기란?

사전적 정의로 '동시에 일어나지 않음'을 뜻하고 있다. 즉, 개발에 있어서 작업 완료 여부를 기다리지 않고 다른 작업을 실행하는 것을 의미한다고 볼 수 있다.(나의 업무를 다른 사람에게 넘겨버린다고 이해하면 조금은 편한다.) 이해를 돕기 위한 그림을 살펴보자.

출처 : https://dev-coco.tistory.com/46

위의 그림을 봤을 때, 동기 방식은 손님이 커피를 주문하고 나올 때까지 줄에서 그대로 기다리는 방식이다. 즉, 점원은 커피를 반환할 때까지 다른 요청 및 작업을 수행하지 않는다. 흔히 만드는 프로세스가 동기 방식일 테니 이해가 빠를 것이다.

아래 그림처럼 비동기는 점원이 커피를 주문받고 다음 손님의 주문을 받게 되며, 커피 반환은 다른 동료가 처리해 버린다. 즉 요청 들어온 커피가 반환되는 동안 다른 작업을 수행할 수 있어 훨씬 효율적으로 행동할 수 있다는 것이 된다.

@ Async

SpringBoot에서는 비동기 작업을 수행할 수 있는 어노테이션을 제공하고 있다. Java만을 사용하게 되었을 땐 Future, ComportableFuture 등을 사용할 수 있겠지만 SpringBoot에서는 훨씬 간단하고 간소화되어 쉽게 사용할 수 있다.(물론 간소화된 만큼 내부적으로는 지지고 볶는다.)

먼저, @Async를 사용하기 위해선 @EnableAsync를 어노테이션으로 Async 어노테이션을 사용할 것을 선언해야 한다.

@SpringBootApplication
@EnableAsync
public class PracticeApplication {

    public static void main(String[] args) {
        SpringApplication.run(PracticeApplication.class, args);
    }

}

다음으로 비동기를 진행시킬 메서드에 @Async 어노테이션을 달아주기만 하면 끝이다.

@Async
public void asyncTest(int num) {
    log.info("num : " + num + " 1번 메서드 스레드 이름 : " + Thread.currentThread().getName());
}

@Async
public void asyncTest2(int num) {
    log.info("num : " + num + " 2번 메서드 스레드 이름 : " + Thread.currentThread().getName());
}

간단하게 테스트를 진행해 보자.

@Test
@DisplayName("비동기 테스트")
public void asyncTest() {
    for(int i = 1; i <= 5; i++) {
        asyncService.asyncTest(i);
        asyncService.asyncTest2(i);
    }
}

실행 결과를 보았을 때 int 1 = 1 2 3 4 5 순으로 반복문이 진행되지만 log는 그렇지 않다. 해당 이유는 num = 1일 때, 비동기 요청을 하고 결과를 기다리지 않은 채 다음 반복문으로 넘어갔기 때문이다. 단순히 num = 1 요청 후 num = 2를 요청했지만 num = 2가 더 빠르게 처리되어 log가 나온 것뿐이다.

아직 이해가 되지 않는다면 스레드 이름 : task-의 번호와 위의 테스트 코드를 확인해 보면 된다.

반복문을 보면 asynctest(1) asynctest2(1) asynctest(2) asynctest2(2).... asynctest2(5) 순으로 요청이 진행될 것이다.

log로 나온 task-번호를 확인했을 때,

task-1 은 num = 1, 1번 메서드

taks-2는 num = 1, 2번 메서드

task-3 은 num = 2, 1번 메서드

task-4는 num = 2, 2번 메서드 ...으로 요청이 잘 정상 진행되었고 작업이 다 끝난 후 반환된 스레드를 받아 다시 비동기 작업을 진행한다.

즉, 비동기 통신을 진행할 때 작업의 호출 순서와 완료 순서가 일치하지 않을 수 있다.

@Async 사용 시 주의 사항

1. 같은 클래스 내에서 사용할 때

@Async을 사용한 메서드를 같은 클래스에서 호출하게 되면 비동기로 작동하지 않는다. 일단 코드랑 이미지 먼저 확인해 보자.

@Async
public void asyncTest(int num) {
    log.info("num : " + num + " 1번 메서드 스레드 이름 : " + Thread.currentThread().getName());
}

@Async
public void asyncTest2(int num) {
    log.info("num : " + num + " 2번 메서드 스레드 이름 : " + Thread.currentThread().getName());
}

public void sameClassAsyncTest() {
    for(int i = 1; i <= 5; i++) {
        asyncTest(i);
        asyncTest2(i);
    }
}

아까 코드에서 메서드 하나만 추가했다. sameClassAsyncTest() 메서드를 호출한 결과를 확인해 보면

num 1 2 3 4 5 순으로, 1, 2번 메서드 순차적으로 작동하게 된다. 즉, 동기적으로 코드가 실행되고 있는 것이다.

비동기가 정상적으로 작동하지 않는 이유를 알기 위해선 먼저 @Async의 동작 방식을 알아야 할 필요가 있다.

@Async은 Spring AOP를 통해 생성된 프록시 객체를 이용하여 비동기 메서드를 처리한다. 이는 @Async가 사용된 메서드를 직접 호출하는 것이 아닌 프록시 객체를 거쳐 해당 메서드를 호출하는 것이다.

하지만, 같은 클래스 내부에서 메서드를 호출하는 경우 프록시 객체를 거치지 않고 직접적으로 호출하게 되고, @Async을 무시하게 되고 메서드는 동기적으로 실행된다.

해당 문제를 해결하기 위해선 프록시 객체를 거쳐 메서드를 실행시킬 수 있도록 로직을 수정해야 한다.

클래스 분리

@Service
@Slf4j
public class AsyncService {

    @Async
    public void asyncTest(int num) {
        log.info("num : " + num + " 1번 메서드 스레드 이름 : " + Thread.currentThread().getName());
    }

    @Async
    public void asyncTest2(int num) {
        log.info("num : " + num + " 2번 메서드 스레드 이름 : " + Thread.currentThread().getName());
    }
}
...
@Service
@RequiredArgsConstructor
public class CallService {
    private final AsyncService asyncService;

    public void differentClassAsyncTest() {
        for(int i = 1; i <= 5; i++) {
            asyncService.asyncTest(i);
            asyncService.asyncTest2(i);
        }
    }
}

위의 방식처럼 비동기 호출 메서드를 다른 클래스로 분리하게 되면 AsyncService의 빈을 주입받아 사용하게 되므로 정상적으로 비동기 호출을 진행할 수 있다.

ps. 자기 참조 주입 (Self-Injection)을 진행하는 경우도 있으나 해당 방법의 경우 순환 참조 문제가 발생할 수 있으니 가능한 클래스 분리를 사용하는 것이 좋아 보인다. SpringBoot 2.6부터는 기본적으로 순환 참조를 허용하지 않고 있다.

2. 접근지정자가 private일 때

이 또한 Spring AOP와 프록시로 인해 불가능하다. Spring AOP가 적용되는 대상은 기본적으로 public 메서드이다.

private 메서드는 AOP 적용 대상이 아니며, 클래스 내부에서만 접근이 가능하기 때문에 프록시 객체가 메서드 호출을 가로챌 수 없다. 프록시는 클래스 외부에서 메서드를 호출할 때 사용이 되며, 이는 AOP 적용 대상인 @Async를 사용할 수 없다는 뜻이다.

3. 비동기 진행 중 예외 발생

@Async 적용 중인 메서드를 호출하는 경우 별도의 스레드에서 동작하기 때문에 기본적으로 예외를 캐치할 수 없다. 이를 방지하기 위해 추가적인 예외 핸들링이 필요하다. 해당 글에서는 AsyncUncaughtExceptionHandler를 사용하여 반환값이 없는 비동기 메서드의 예외 핸들링을 진행해 보겠다.

먼저, 예외를 고의로 발생시켰을 때를 확인해보자

@Service
@Slf4j
public class AsyncService {

    @Async
    public void asyncTest(int num) {
        log.info("num : " + num + " 1번 메서드 스레드 이름 : " + Thread.currentThread().getName());
        throw new RuntimeException("에러에러에러에러에러");
    }

    @Async
    public void asyncTest2(int num) {
        log.info("num : " + num + " 2번 메서드 스레드 이름 : " + Thread.currentThread().getName());
    }
}

1번 메서드가 동작할 때 런타임 에러를 발생시켰음에도 불구하고 그대로 진행되어 버린다.

이를 개선한 코드와 결과를 살펴보자. 먼저 예외 핸들러를 생성하자.

@Configuration
@Slf4j
public class ExceptionHandler implements AsyncUncaughtExceptionHandler {

    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        log.info("예외 메세지 : " + ex.getMessage());
        log.info("메서드 이름 : " + method.getName());
    }
}

예외 메세지와 추적 가능하도록 메서드 이름도 같이 가져온다.

@Configuration
public class AppConfig implements AsyncConfigurer {
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new ExceptionHandler();
    }
}

AsyncUncaughtExceptionHandler를 오버라이딩하여 비동기 처리 중 예외가 발생했을 때를 캐치한다.

예외 메세지와 어떤 메서드에서 예외가 발생했는지 확인이 가능하며 예외 발생 시 추가 로직을 통해 처리가 가능하다.

AsyncUncaughtExceptionHandler는 반환 값이 없는 void의 경우 사용하여 예외 상황을 처리하는 데 사용되며, 반환 값이 있는 경우 Future 또는 CompletableFuture 객체를 사용하여 반환하고 이를 통한 예외를 처리할 수 있다.

4. 스레드 풀 관리

별도의 세팅 없이 @Async를 사용하게 되면 항상 새로운 스레드를 생성하여 작업을 위임하게 된다. 이는 SpringBoot가 내부적으로 세팅된 스레드 생성 방식이 사용되며 비동기 작업마다 새로운 스레드를 생성하기 때문에 리소스 낭비와 오버헤드를 유발할 수 있다. 이를 해결하기 위해 ThreadPoolTaskExcutor를 사용하여 제한된 스레드 풀을 사용하는 것이 좋은 방식이다.

@Configuration
public class AppConfig implements AsyncConfigurer {
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new ExceptionHandler();
    }

    //추가된 부분
    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(3);  // 동시에 실행시킬 기본 스레드 수
        executor.setMaxPoolSize(10);  // 필요시 사용하게 되는 최대 스레드 수
        executor.setQueueCapacity(25); // 큐 크기
        executor.setThreadNamePrefix("MyExecutor-");
        executor.initialize();
        return executor;
    }
}

ThreadPoolTaskExcutor를 통해 3개의 스레드를 기본적으로 사용하게 설정해 두었으며 이름은 MyExcutor- 로 해뒀다.

  • setCorePollSize
    • 동시에 실행시킬 기본 스레드 수
  • setMaxPoolSize
    • 필요시 사용하게 되는 최대 스레드 수
    • 기본 스레드 수를 초과하는 작업이 생겼을 때 적용되는 것이 아닌 큐의 크기보다 더 많은 작업이 발생하게 되면 작동

큐의 크기보다 더 많은 작업을 요청했을 땐 MyExcutor-10까지 사용되는 것을 확인할 수 있다.

  • setQueueCapacity
    • corePollSize보다 많은 작업이 들어온 경우 큐에서 대기
  • setThreadNamePrefix
    • 스레드의 이름

리팩토링 된 프로젝트 코드

@Async
public void barcodeSave(int userId, int cardId, String barcodeNum) {
    log.info("바코드 세이브 시작");
    long start = System.currentTimeMillis();

    BarcodeKey barcodeKey = redisMapper.toBarcodeKey(userId, cardId, barcodeNum);

    barcodeKeyRepository.save(barcodeKey);
    long end = System.currentTimeMillis();
    log.info("바코드 세이브 끝 : {}", end - start);
    log.info("####### 스레드 이름 : " + Thread.currentThread().getName());
}
...
//메인페이지 바코드 번호 세팅
public List<MainCardDto> setBarcodeNum(List<MainCardDto> list, int userId) {
    Faker faker = new Faker(new Locale("ko"));
    long start = System.currentTimeMillis();

    for (MainCardDto mainCardDto : list) {
        String barcodeNum = faker.numerify("############");
        barcodeKeyService.barcodeSave(userId, mainCardDto.getId(), barcodeNum);
        mainCardDto.setBarcodeNum(barcodeNum);
    }

    long end = System.currentTimeMillis() - start;
    log.info("최종 end : {}", end);

    return list.stream()
            .sorted(Comparator.comparing(MainCardDto::getCardOrder))
            .toList();
}

참고 자료

https://dkswnkk.tistory.com/706

https://dev-coco.tistory.com/186

https://velog.io/@think2wice/Spring-Async-Thread-Pool%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC-Async