JAVA/JAVA | Spring 학습기록

[Spring] @async 로직 실패 일대기, ThreadPoolTaskExecutor의 awaitTerminate와 Async

kth990303 2023. 9. 23. 17:03
반응형

애플리케이션을 만들다보면 비동기 로직을 호출할 때가 상당히 많다.

별도의 스레드에서 돌아가면서 추후에 응답값을 반환해도 되는 경우, 성능 향상을 위해 비동기를 쓰는 것.

하지만 그렇기 때문에 결과를 예상하기가 더 어려워 의도치 않은 현상이 종종 발생하곤 한다.

 

이번에 발생한 문제는 아래와 같다.

  • 스프링 애플리케이션 실행
  • 해당 로직에는 비동기 로직을 호출
  • 비동기 로직 수행 중, 스프링 애플리케이션 종료. 비동기 로직 중 일부 실패

스프링 애플리케이션이 비동기 async 로직을 다 수행하지 않았음에도 종료된 것이다.

 

참고로 보통 이런 문제는 애플리케이션 내 일반적인 로직 수행하는 경우보단, 스프링 배치를 수행할 때 발생할 수 있을 것으로 보인다.

보통 애플리케이션 서버를 직접 다운시키는 경우는 없을테니 말이다.

하지만, 배치는 한번 돌면 완료되고 job이 종료되면서 위와 같은 상황이 종종 발생할 수 있다.

실제로 해당 문제를 인식한 케이스도 배치 job이 종료됐지만 이벤트 전송이 일부 실패한 경우에 속한다.

 

이번 포스팅에서는 배치 job 대신, 위 사진 상황처럼 애플리케이션 로직 구성을 각색하여 문제 상황을 비슷하게 재연해보았다.

한번 살펴보도록 하자.

 

비동기 로직은 ThreadPoolTaskExecutor 에서 생성한 스레드에서 수행된다. 따라서 ThreadPoolTaskExecutor 관련 설정 및 Graceful Shutdown 쪽 코드를 살펴보면서 원인을 파악해보았다.


1. Graceful Shutdown 이 잘 걸려 있는가?

Graceful Shutdown이란, 우아한종료(...)를 의미한다.

단어 자체를 그대로 번역한 것이긴 한데, 표현이 꽤 괜찮다. 강제로 shutdown 하는게 아닌, 종료할 것 먼저 순서대로 종료한 후에 최종으로 shutdown 하는 상황이 우아하기 때문이다.

 

Graceful shutdown is supported with all four embedded web servers (Jetty, Reactor Netty, Tomcat, and Undertow) and with both reactive and Servlet-based web applications. When a grace period is configured, upon shutdown, the web server will no longer permit new requests and will wait for up to the grace period for active requests to complete.

4개의 내장 웹 서버(Jetty, Reactor Netty, Tomcat 및 Underow)와 반응형 웹 애플리케이션 및 서블릿 기반 웹 애플리케이션 모두에서 정상 종료가 지원됩니다. 유예 기간이 구성되면 종료 시 웹 서버는 더 이상 새 요청을 허용하지 않고 활성 요청이 완료될 때까지 유예 기간을 기다립니다.

출처: https://spring.io/blog/2020/05/15/spring-boot-2-3-0-available-now

 

스프링부트 2.3.0 업데이트 포스팅을 보면 Graceful Shutdown 관련하여 내용을 확인할 수 있다.

실제로 스프링부트 2.3 이상이면 아래와 같이 간단하게 graceful shutdown을 적용할 수 있다.

 

Spring Boot 2.3.0 이상

server:
  shutdown:graceful

 

그렇다고 스프링부트 2.3.0 미만이면 graceful shutdown을 적용하지 못하나?

그건 아니다.

 

Spring Boot 2.3.0 미만

GracefulShutdownHandler

1
2
3
4
5
6
7
8
9
10
@Override
public void stop() {
    log.info("ThreadPoolTaskExecutor 종료 시작");
    taskExecutors.keySet().forEach(beanName -> {
        beanFactory.destroySingleton(beanName);
    });
    log.info("ThreadPoolTaskExecutor 종료 완료");
 
    log.info("GracefulShutdown 완료");
}
cs

스프링 빈 사이클을 관리하는 SmartLifeCycle 인터페이스를 구현함으로써 우아하게(?) 애플리케이션을 종료하는 단계를 지정할 수 있다.

SmartLifeCycle의 구현체인 GracefulShutdownHandler를 생성하여, ThreadPoolTaskExecutor에서 관리되고 있는 빈들을 모두 종료시킨 후에 애플리케이션을 종료시키도록 코드를 작성했다.

 

ThreadPoolConfig

1
2
3
4
5
6
@Bean
public SmartLifecycle gracefulShutdownHandler(
        Map<String, ThreadPoolTaskExecutor> taskExecutors
) {
    return new GracefulShutdownHandler(taskExecutors);
}
cs

이후, 만들어준 GracefulShutdownHandler를 빈으로 등록해주면 된다.

 

그 외적인 Async 관련 설정에 해당되는 @EnableAsync 등은 이미 되어있다고 가정한다.

 

graceful shutdown이 적용돼 순서대로 종료되는 모습

일단, graceful shutdown이 잘 적용돼있는 모습은 확인할 수 있다.

하지만 그럼에도 불구하고 async 로직이 끝나기도 전에 스프링 애플리케이션이 종료되는 걸 확인할 수 있었다.

추가로 좀 더 확인해보자.


2. ThreadPoolTaskExecutor 옵션을 setWaitForTasksToCompleteOnShutdown(true)로 설정했는가?

ThreadPoolTaskExecutor에는 여러가지 설정들이 있다.

corePoolSize, queueCapacity, maxPoolSize 등.

그 외에도 위에서 언급한 waitForTasksToCompleteOnShutdown, keepAlive 등이 있다.

 

ThreadPoolTaskExecutor

기본값들은 위와 같다.

참고로, corePoolSize가 꽉차면 maxPoolSize까지 찬다음에 queue에 스레드가 대기하는 것이 아니다!

corePoolSize 수만큼 스레드가 꽉차면 queue에서 스레드가 대기한다. 그럼에도 queue까지 꽉 찼다면 maxPoolSize까지 스레드를 생성하고 넘치면 RejectPolicy가 발생하는 것이다.

 

처음에는 maxPoolSize 까지 스레드가 순간적으로 많이 생성돼서 RejectPolicy 정책으로 인해 비동기 로직이 수행되지 않았는지 의심해보았다. 하지만, maxPoolSize의 Default 값은 Integer.MAX_VALUE. 즉, 2,147,483,647에 해당되는 값.

이 값을 초과하여 스레드가 생성됐다는 것은 사실상 불가능에 가깝다.

 

 

이번에는 스레드풀 사이즈 대신 다른 값에 주목해보자.

위에서 언급한 setWaitForTasksToCompleteOnShutdown 값을 살펴볼 것이다.

 

Set whether to wait for scheduled tasks to complete on shutdown, not interrupting running tasks and executing all tasks in the queue

종료 시 예약된 작업이 완료될 때까지 대기하고 실행 중인 작업을 중단하지 않고 대기열에 있는 모든 작업을 실행할지 여부를 설정합니다

출처: setWaitForTasksToCompleteOnShutdown 메서드 Spring 공식문서

 

이 값은 기본적으로 false이다.

따라서 이 값을 별도로 지정하지 않고 async 로직 수행 중에 애플리케이션이 종료되면 예외가 발생하거나 비동기 로직이 호출되지 않는다.

위에서 잠깐 보여준 이 상황을 기억하는가?

일단 여기서는 임시티켓 생성 로직까지만 호출해보자.

TicketService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Async
public void generateDefaultMemberTicket(Long memberId) {
    try {
        log.info("회원 임시 티켓 생성 (약 3초 소요), memberId = {}", memberId);
        Thread.sleep(3000);
        Ticket tmpTicket = ticketRepository.save(
                new Ticket(
                        "ticket by" + memberId,
                        "임시티켓",
                        memberId
                )
        );
        log.info("회원 임시 티켓 생성 완료, memberId= {}", memberId);
        anotherTicketService.updateTicket(tmpTicket.getId(), memberId);
    } catch (InterruptedException e) {
        throw new IllegalStateException("티켓 생성 중 문제 발생", e);
    }
}
cs

임시티켓 생성하는 데에 3초가 걸린다.

 

setWaitForTasksToCompleteOnShutdown 값을 디폴트 값인 false로 두고, 임시티켓 생성 도중에 애플리케이션을 종료시키면 어떻게 될지 한번 실험해보겠다.

티켓 생성 중 문제가 발생하며, 임시티켓 생성 비동기 로직 실패

회원 임시 티켓 생성 로그가 찍힌 후, 3초 이내에 애플리케이션을 종료시켰다.

그랬더니 비동기 로직이 수행완료되기 전에 ThreadPoolTaskExecutor이 종료되고 Graceful Shutdown이 종료되며 애플리케이션이 종료됐다. 우아하게 종료는 했지만, 사실상 급발진(?)하며 종료한 셈...

 

ThreadPoolConfig 클래스에서 setWaitForTasksToCompleteOnShutdown 설정 변경

이번에는 setWaitForTasksToCompleteOnShutdown 값을 true로 두고, 만약 수행중인 스레드가 있다면 대기까지 60초 기다리게 설정해보았다. 한번 임시티켓 생성 로직 중에 종료해보겠다.

잘 기다려주는 것을 확인할 수 있다!

 

하지만 그럼에도 불구하고!! 아직도 문제상황은 남아있었다.

async 로직이 모두 끝나기도 전에 스프링 애플리케이션이 종료되는 걸 확인할 수 있었다.

조금만 더 살펴보자!


3. @Async 안에 @Async가 있으면?

우리의 문제 상황을 다시 한 번 보자.

분명 임시티켓 생성까지는 되는 상황이었는데, 정식으로 업그레이드되지 않고 임시티켓인 상태로 남은 채로 애플리케이션이 종료된 것이 문제였다.

 

즉, 임시티켓 생성까지는 문제없이 됐으므로 Graceful Shutdown 및 setWaitForTasksToCompleteOnShutdown 설정은 잘 남아있던 것.

 

하지만 가장 큰 문제가 있었다.

바로 @Async 내에 @Async가 있었던 것이다!

 

위 상황에서 임시티켓 생성하는 데에 3초, 정식 티켓 업그레이드까지는 10초가 걸린다.

그리고 각각의 로직은 모두 @Async로 작동하며, 임시티켓 생성 로직에서 비동기로 정식 티켓 업글 로직을 호출한다.

 

한번 회원 1명을 저장하는 경우의 테스트 코드를 작성해보았다.

 

MemberServiceTest

1
2
3
4
5
6
@Test
@DisplayName("회원 1명을 가입한다")
void signUpOnly() {
 
    memberService.signUp(1);
}
cs

 

뭐, 코드 자체야 매우 간단하다.

우리가 확인하려는 것은 테스트 통과 여부가 아니다.

임시 티켓 생성 로그, 정식 티켓 업그레이드 로그 까지 잘 남는지 확인하려는 것이다.

테스트 코드가 다 돌면 테스트 컨텍스트는 종료될 것이다. 이 때, 정식티켓 업그레이드까지 마치고 종료되는지 한번 확인해보자.

 

테스트 코드 결과.

허허... 눈을 씻고 봐도 정식티켓 업그레이드 관련 로그는 존재하지 않는다.

 

이번엔 애플리케이션을 직접 실행시켜보도록 하겠다.

 

1. 임시티켓 생성 도중 애플리케이션 종료

임시티켓 생성까지는 setWaitForTasksToCompleteOnShutdown 옵션이 true이므로 문제 없이 작동됐다.

하지만, 정식티켓 생성 async 로직은 아직 호출되지도 않아 스레드가 생성되지도 않았다.

따라서 ThreadPoolExecutor 입장에서는 응~ 대기중엔 스레드 없으니까 종료할거야~~~ 가 되는 것.

 

 

2. 정식티켓 업그레이드 도중 애플리케이션 종료

위처럼 정식티켓 업그레이드 도중에 종료시키면 잘 작동한다.

 

 

즉, async 내부 로직 중에 async 로직이 있다면?

async 로직 전체를 기다린 후에 graceful shutdown이 진행되는 것이 아니다.

async 내부 로직 중에 async 스레드가 아직 생성되지 않았다면... 해당 로직 수행 전에 종료가 된다. 따라서 일부 async 로직이나 이벤트 전송은 실패할 수 있는 것.


마치며

트랜잭션이 성공했는데 이벤트가 실패한 경우에는 수많은 원인이 존재한다.

그 중 하나가 위와 같은 케이스가 될 수도 있는 것이다. 이벤트는 특히 특성상 비동기로 전송하니까 말이다.

 

또, 배치 job은 CI/CD 과정에서 수행 후 바로 종료되므로 위와 같은 상황이 발생할 확률이 좀 더 높다.

Jenkins에서 배치 job을 수행하고, job 내에서 이벤트를 전송하는 로직이 존재한다면? 그리고 하필 그 이벤트가 async라면?

상황이 복잡해질 수 있다.

이처럼 async는 조심스럽게, 그리고 확실하게 파악하고 사용해야 될 듯하다!

반응형