티스토리 뷰

spring 에서는 멀티스레드 활용을 위해 jdk 에서 제공하는 Executor 인터페이스를 확장한 TaskExecutor 인터페이스를 근간으로 활용하고있다. 아마도 개발자들은 TaskExecutor 구현체로 ThreadPoolTaskExecutor 를 사용하고, spring 에서 제공하는 @Async 애노테이션을 활용한 멀티스레드 프로그래밍을 많이 할 것 같다. 

 

보통 spring-batch 로 spring 을 처음 접하는 경우는 흔치 않을 것이고, spring-mvc 와 같은 web 프로젝트로 spring 에 대해 접한 후 spring-batch 도 접하게 되는 경우가 많을텐데, 이때 기존에 알고있던 spring 의 멀티스레드 프로그래밍 경험으로 코드를 작성하면 job 실행 이후 jvm 이 종료되지 않는 현상을 만날 수 있다. 이번 포스팅에서는 이 현상의 원인과 해결법에 대해 알아본다.

 

# 왜 JVM 이 종료되지 않는가

ThreadPoolTaskExecutor 는 내부적으로 jdk 에서 제공하는 ThreadPoolExecutor 를 활용해서 병렬성을 제공하게된다. ThreadPoolExecutor 는 파라미터로 전달된 task 를 실행하게 되는데, 이때 작업을 다 마치면 명시적으로 shutdown() 메서드를 호출해줘야 pool 에 있는 스레드들을 정리하게된다. ThreadPoolExecutor 를 래핑하고있는 ThreadPoolTaskExecutor 역시 shutdown() 메서드를 제공하고있으며, 이를 호출하면 내부 ThreadPoolExecutor 의 shutdown() 메서드를 호출하게 된다. spring-batch 에서 jvm 이 종료되지 않는 이유는 shutdown() 이 호출되지 않기 때문이다. 참고로 jvm 은 자식 스레드들이 살아있으면 종료되지 않는다. 사실 web 과 같은 spring 애플리케이션에서도 shutdown() 은 호출되지 않지만 이 애플리케이션은 특정 태스크만 실행한 후 종료되는 애플리케이션이 아니므로 문제가 없었던 것이다.

 

# shutdown() 없이 JVM 종료하도록 ThreadPoolTaskExecutor 설정

shutdown() 메서드를 호출하지 않아도 JVM 을 종료시킬 수 있는 상황이 몇가지 있긴하다. 길게 썼다가 포스팅 주제를 벗어나는 내용인것 같아 싹 지우고 짧게 줄였다.

 

- ThreadFactory 를 직접 구현하여 현재 스레드를 그대로 사용

ThreadPoolTaskExecutor 에 ThreadFactory 인터페이스의 구현체를 전달할 수 있는데 이때 이 구현체에서 신규 스레드를 생성하지 않는 방식이다. 이렇게 구현하면 멀티스레드를 활용하지 않게 되므로 당연히 무쓸모인 방법이다.

 

- 스레드를 daemon 으로 생성

위에서 jvm 은 자식 스레드들이 살아있으면 종료되지 않는다. 라고 얘기했는데 반만 맞는 얘기다. 자식 스레드가 daemon 스레드라면 jvm 은 종료된다.

 

- corePoolSize 0으로 설정

corePoolSize 는 필수적으로 유지할 스레드 갯수다. 이를 0으로 설정하면 태스크가 없을때 스레드를 모두 죽이게되고, 자식스레드가 없으므로 jvm 이 종료된다.

 

- allowCoreThreadTimeOut 을 true 로 설정

ThreadPoolTaskExecutor 는 keepAliveSeconds 로 설정된 시간만큼 태스크가 할당되지 않은 스레드를 유지하게 되는데(default 60s), 해당 시간이 지난 이후 core thread 로 정리할지 말지 여부를 해당 속성으로 정한다. default 값은 false 이기 때문에 keepAliveSeconds 가 초과되는 동안 core thread 에 태스크를 할당하지 않더라도 core thread 는 정리되지 않는다. 이 값을 true 로 설정하면 core thread 가 일정시간 태스크를 받지 않을 경우 pool 에서 정리되게 되고, 모든 자식 스레드가 정리되면 jvm 도 종료된다.

 

위 네가지 방법 외에도 방법이 있을 수 있겠으나 내가 아는건 일단 저 네가지다. 사실 1~3 방법은 네번째를 위한 구색맞추기 정도의 억지라고 생각하는데, 첫번째는 멀티스레드를 활용하지 않으므로 애초에 의미가 없고, 두번째는 자식 스레드들이 작업을 완료하지 않은 상태에서 jvm 을 종료시킬 수 있으므로 위험하다. 세번째는 waiting queue 를 어떻게 사용하느냐에 따라 태스크를 아예 실행시키지 않을 수 있다( https://multifrontgarden.tistory.com/276 ).

 

spring-batch 에서 자동 생성되는 ThreadPoolTaskExecutor 활용시 job 이 종료된 이후 1분 후에 jvm 이 종료되는 경우를 만날 수 있는데 이게 네번째 이유때문이다. ThreadPoolTaskExecutor 는 keepAliveSeconds 속성이 디폴트 60초이고, allowCoreThreadTimeOut 의 디폴트 값은 false 이지만 자동생성되는 executor 의 allowCoreThreadTimeOut 는 true 로 설정되기 때문에 60초 후 자식 스레드가 정리되면서 jvm 이 종료되는 것이다.

 

# Step 에서 TaskExecutor 사용시

spring-batch 는 몇가지 멀티스레드 활용법을 지원하고 있다. 이 포스팅에서는 multithread-step 에 대해서 알아본다.

@Bean                                               
public Step executorStep1() {                       
    return stepBuilderFactory.get("executorStep1")   
            .chunk(100)                              
            .taskExecutor(executor)                  
            .build();                               
}                                                   

위와 같이 설정하면 청크단위로 병렬적으로 처리하게 된다. 물론 job 이 종료된 후 jvm 은 종료되지 않는다. 처음에 이 방식에서 jvm 이 종료되지 않는걸 알았을때는 적잖이 당황스러웠는데(자기들이 제공하는 방법대로 했는데도 종료가 안된다니?!) taskExecutor() 메서드를 통해 전달받는 타입은 ThreadPoolTaskExecutor 가 아니라 TaskExecutor 이다. 애초에 shutdown() 메서드가 없는 인터페이스를 받고있기 때문에 shutdown() 메서드를 호출하고 싶어도 호출할 수 없다. 아무래도 executor 에 대한 핸들링은 외부에 맡기는 것 같다.

 

이 상황에서 spring boot main() 메서드를 아래와 같이 작성하면 job 종료 후 jvm 을 종료할 수 있다.

System.exit(SpringApplication.exit(SpringApplication.run(BatchApplication.class, args)));

위 코드대로 작성하면 main 스레드가 종료되면 jvm 을 강제로 종료하게 된다. 하지만 Step 에서 제공하는 taskExecutor 를 이용하게되면 코드 내부에서 메인 스레드가 자식 스레드들이 작업을 종료할때까지 대기하게된다. 그러므로 메인 스레드가 종료됐다는건 자식 스레드가 모든 태스크를 완료했다는걸 보장할 수 있다. spring-batch 가이드에도 해당 방식을 소개하고 있다.

 

# @Async 를 이용하는 경우

@Async                                                                            
@Component                                                                        
public static class AsyncClass {                                                  
    public void asyncMethod() {                       
        System.out.println("hello async :: " + Thread.currentThread().getName()); 
    }                                                                             
}                                                                                 

위와 같이 @Async 를 이용하는 경우 spring-batch 와는 무관하게 비동기 작업을 하게된다. 이때 ThreadPoolTaskExecutor 는 spring bean 으로 관리하게 되는데, 해당 bean 을 직접 주입받아 shutdown() 메서드를 호출해야 한다.

@Component
@RequiredArgsConstructor
public class ThreadPoolTaskExecutorShutdownJobExecutionListener implements JobExecutionListener {
    private final ThreadPoolTaskExecutor executor;

    @Override
    public void beforeJob(JobExecution jobExecution) {
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        executor.shutdown();
    }
}

// job 생성
private final ThreadPoolTaskExecutorShutdownJobExecutionListener threadPoolTaskExecutorShutdownJobExecutionListener; 
                                                                                                                     
@Bean                                                                                                                
public Job sampleJob21() {                                                                                           
    return jobBuilderFactory.get("sampleJob21")                                                                       
            .incrementer(new RunIdIncrementer())                                                                     
            .start(asyncStep1())                                                                                      
            .listener(threadPoolTaskExecutorShutdownJobExecutionListener)                                            
            .build();                                                                                                
}                                                                                                                    

위와 같이 JobExecutionListener 를 구현해서 afterJob() 에서 shutdown() 을 호출할 수도 있겠다. 특히 정의한 ThreadPoolTaskExecutor 가 여러개일 경우 executor 마다 listener 를 구현하는건 성가시고, 실수를 유발할 수 있으므로 아래처럼 컬렉션을 이용하면 Job 이나 executor 상관없이 사용할 수 있다.

@Component                                                                                        
@RequiredArgsConstructor                                                                          
public class ThreadPoolTaskExecutorShutdownJobExecutionListener implements JobExecutionListener { 
    // 컬렉션으로 관리
    private final Collection<ThreadPoolTaskExecutor> executors;                                         
                                                                                                  
    @Override                                                                                     
    public void beforeJob(JobExecution jobExecution) {                                            
    }                                                                                             
                                                                                                  
    @Override                                                                                     
    public void afterJob(JobExecution jobExecution) {                                             
        executors.forEach(ThreadPoolTaskExecutor::shutdown);                                      
    }                                                                                             
}                                                                                                 

# ThreadPoolTaskExecutor 설정을 통해 종료

위에서 얘기한 allowCoreThreadTimeOut 속성을 이용한 것이다. spring 이 자동으로 생성해주는 ThreadPoolTaskExecutor 는 해당값이 true 이지만, 해당 부분에서 얘기한대로 default 값은 false 이기때문에 ThreadPoolTaskExecutor 생성시 해당 속성을 변경해준다.

 

보통 spring 이 자동으로 만드는것보다 직접 구현해서 사용하기 때문에 놓치지않고 변경해줘야한다.

 

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);                                  
executor.setMaxPoolSize(10);                                   
// default 값은 60 이므로 적절히 설정한다                                  
executor.setKeepAliveSeconds(30);                              
// 이 속성을 꼭 true 로 넣어준다                                         
executor.setAllowCoreThreadTimeOut(true);                      

 

댓글
댓글쓰기 폼