서버 기능 업데이트를 위해 서버를 재시작해야 한다고 할 때, 가장 이상적인 방향은 새로운 주문 요청은 막고 이미 진행중인 주문은 모두 완료한 다음 서버를 재시작해야 할 것이다. 이렇게 문제 없이 우아하게 서버를 종료하는 방식을 graceful shutdown이라고 한다.
ExecutorService의 shutdown()메서드는 이미 제출된 작업을 모두 완료한 후에 종료한다.
논 블로킹 메서드로 이 메서드를 호출한 스레드는 대기하지 않고 다음으로 넘어간다.(shutdown의 결과를 끝까지 기다리지 않고 즉시 다음 코드 호출)
새로운 요청을 거절시 RejectedExecutionException예외가 발생한다.
실행중인 작업을 중단하고, 대기 중인 작업을 반환(List<Runnable>)하며 즉시 종료한다. 실행 중인 작업을 중단하기 위해 쓰레드 인터럽트를 사용한다.
역시 논 블로킹 메서드이다.
close()메서드는 shutdown()과 비슷하지만, 하루가 지나도 실행중이던 작업이 완료되지 않으면 shutdownNow()로 하여금 강제 종료시킨다.
close()로 하여금 우아한 종료로 그대로 쓰기엔 "하루가 지나면 shutdownNow()"라는 부분이 너무 길게 느껴진다. ExecutorService 공식 API에서는 다음과 같은shutdownAndAwaitTermination() 방식을 권장한다.
private static void shutdownAndAwaitTermination(ExecutorService es) {
es.shutdown(); // non-blocking, 새로운 작업 받지 않음, 큐에 이미 대기중인 작업 처리,
try {
if(!es.awaitTermination(60, TimeUnit.SECONDS)) {
log("서비스 정상 종료 실패 -> 강제 종료 시도");
es.shutdownNow();
if(!es.awaitTermination(60, TimeUnit.SECONDS)) {
log("서비스가 종료되지 않았습니다.");
}
}
} catch (InterruptedException e) {
es.shutdownNow();
}
}
우선 shutdown()을 통해 큐에 대기중인 작업을 처리하도록 한다. es.awaitTermination(10, TimeUnit.SECONDS)로 하여금 1분동안 모든 작업이 종료되었는지 확인하고, false라면 강제종료(shutdownNow())를 진행한다. 강제종료에도 불구하고 그다음 다시es.awaitTermination(10, TimeUnit.SECONDS)로 체크하는 이유는 인터럽트로 하여금 작업을 종료시키더라도 여러 이유로 자원을 정리하는 시간이 존재할 수 있기 때문이다.
이렇게 이중적으로 처리해도 인터럽트를 받을 수 없는 코드 때문에 종료되지 않는 경우가 있을 수 있기에 최종적으로 log처리하여 해당 문제를 확인가능하도록 한다.
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
ExecutorService es = new ThreadPoolExecutor(2, 4, 3000, TimeUnit.MILLISECONDS, workQueue);
다음과 같은 ThreadPoolExecutor를 생성했다고 해보자.
스레드 풀의 기본 스레드의 수는 2개이며 최대 스레드 수는 4개이다. 초과 스레드에 대해 생존할 수 있는 대기시간은 3초이며 BlockingQueue의 용량은 2이다.
이때 작업이 Executor에 들어온다면 스레드는 들어오는 수에 맞추어 최대 2개까지 늘어난다. 최대 스레드 수가 4개이지만 2개까지 일단 늘어난다. 그 후 더 작업이 들어온다면 이는 큐에서 대기한다. 현재 큐의 사이즈가 2이고 기본 스레드의 최대 수가 2이므로 4개의 작업까지 위 논리에서 커버가 가능하다.
만약 여기서 하나 더 스레드가 들어온다면 그제서야 최대 스레드 수가 활용된다. 스레드 수는 하나 더 늘어나게 되는 것이다. 그리고 이 최대 스레드 수에 대응해서 생성된 스레드는 아무일도 하지 않을 경우 3초라는 수명을 가지게 된다.
위 논리에 따르면 위 Executor에서 작업은 6개까지 커버가 가능하다. 만약 7개가 들어온다면(아무것도 es에서 처리(소비)되지 않았다고 가정), 해당 작업은 거절된다.(RejectedExecutionExeption 발생)

최대 스레드의 수는 대기 공간에도 작업이 가득 찼을 경우 활용된다는 것이다. 현실 비유로 하여금 쉽게 이해해보자. 병원에 현재 진료중인 의사가 두명이고 대기를 위한 의자는 2개가 존재한다. 의사들은 계속해서 진료중이고 대기실은 만석이다. 그런 상태에서 환자가 들어온다. 현재 두명의 체제에서 감당이 불가능하기에 의국에서 쉬던 의사 두명을 긴급하게 콜을 해서 진료 쓰레드를 늘려야한다. 이것이 최대쓰레드의 역할이다.
포인트는 대기실에도 환자가 꽉차있어야 최대쓰레드(쉬던 의사)가 투입된다는 것이다.
4명 체제가 된 병원은 계속해서 환자 손님들을 진료하고 내보낸다. 의국에서 쉬어야 할 의사들은 긴급상황이 아니라면 보장받은 쉬는 시간을 계속 쉬어야 하기 때문에 만약 더 이상 4명에서 커버를 안해도 될 상황(지원 온 2개의 스레드가 3초간 작업을 처리하지 않는 상태)라면 다시 병원은 의사 2명 체제로 돌아간다.
ThreadPoolExecutor의 생성자를 잘 조절하면 여러 전략을 만들 수 있다. 자바는 Executors 클래스를 통해 3가지 기본 전략을 제공한다.
newSingleThreadPool(): 단일 스레드 풀 전략 - 스레드 1개만 사용, 큐 사이즈 제한X, 테스트 용newFixedThreadPool(nThreads): 고정 스레드 풀 전략 - 아래 설명newCachedThreadPool(): 캐시 스레드 풀 전략 - 아래 설명nThreads만큼의 기본 스레드만 생성하고 초과 스레드(최대 스레드)를 활용하지 않는다. 즉 최대 스레드 수도 기본 스레드로 맞추어 최대 스레드로 가능한 추가 투입 로직을 동작하지 않도록 하는 것이다. 또한 큐 사이즈에 제한이 없다.
스레드 수가 기본 스레드 설정 값까지가 max이므로 CPU, 메모리 리소스가 어느정도 예측이 가능하다.
하지만 단점은 큐 사이즈에 제한이 없다는 것에 있다. 기본 스레드 수가 수많은 작업들을 처리하지 못하는 상황일 때 큐에는 제한이 없으므로 작업이 계속 쌓일 것이다. 사용자들의 요청에 대한 응답이 느려진다.(요청에 대한 작업이 큐에 쌓여 기다릴 것이기 때문이다.)
이 방식은 의사 2명이 전부인 병원인데 대기할 수 있는 의자가 무제한인 것과 같다. 환자 손님들이 많다면 뒷 순서로 온 환자들은 매우 많은 시간을 기다려야 한다. 병원은 의사 두명에게만 급료를 주면 되니 비용적으로 부담을 덜지도 모르겠다.
다만 환자 손님들의 이용경험성이 매우 떨어질 것이다. 장기적으로 방문객들은 떨어질 것이며 낮은 비용을 유지하는 대가로 이익의 감소세가 예측된다.(이 예측은 방문객들로 하여금 의사 두명에 대한 감당력보다 진료수요가 많아야 합리적이다.)
기본 스레드를 사용하지 않고 60초 생존 주기를 가진 초과 스레드만 사용한다. 초과 스레드 수에 제한은 없다. 큐에 작업을 저장하지 않기 위해 SynchronousQueue를 이용한다. 생산자의 요청을 소비자 스레드가 직접 받아서 바로 처리한다.
즉 이 방식은 환자 손님이 방문하면 곧 바로 의사 한 명이 나와 이 환자를 처리하는 것이다. 손님이 계속 오더라도 의사는 계속 바로 나오는 구조이다.
환자는 대기 큐에서 기다릴 일이 없다. 스레드 간의 직거래 인 것이다.
환자 입장(생산자)에서는 매우 훌륭한 이용경험성 개선으로 이어진다. 하지만 병원 입장에서는 그 모든 쓰레드 생성(의사 출격) 비용을 감당해야 한다.
앞서 두 전략은 양 극단의 전략이라고 생각한다. 고정 풀 전략은 생산자에게 극단적으로 비효율적일 수 있다. 캐시 풀 전략은 소비자에게 극단적으로 비효율적일 수 있다.
일반적인 상황에서는 마일드한 양의 스레드로 대응하다가 사용자 요청이 폭증할 때 긴급하게 스레드를 추가로 투입해서 작업을 빠르게 처리하는 방식으로 갈 수 있다.
ThreadPoolExecutor(기본 스레드 수, 최대 스레드 수, 최대 스레드 타임아웃, BlockingQueue)의 파라미터에 대해 비유적으로 다음과 같이 다시 해석할 수 있다.
기본 스레드 수 : 기본 의사 수
최대 스레드 수 : 의국에서 쉬는 의사 수 + 기본 의사 수
최대 스레드 타임아웃 : 쉬는 의사 긴급으로 투입시 손님이 없어진지 어느 시점에 다시 쉬러갈 지에 대한 시간
BlockingQueue : 대기 의자 수 + 대기 방식
기본 의사 수를 일반적인 상황에 맞추어 적절히 선택하고, 사용자 요청이 폭증할 때 병원 비용을 생각하여 적절하게 쉬는 의사 수도 확보해놓는다. 긴급 상황시 쉬는 의사까지 투입하여 운영하고, 다시 쉬러가는 방식을 타임아웃을 통해 정하고 최대 진료 의사수까지 고려하여 얼마나 환자 대기시킬 수 있을지에 대한 병원에 대기 의자를 마련한다. 그리고 최대 의사수 + 대기 의자수 이후의 환자 손님은 진료를 거부한다.
ExecutorService es = new ThreadPoolExecutor(100, 200, 60,
TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000));
위와 같은 ExecutorService에서 작업이 1100개까지는 긴급상황이 아니다. 그 이후 1101개 부터는 쉬던 의사들이 하나씩 나와야 할 것이다. 그리고 1200개를 넘어선다면 그 뒤의 작업들은 거부된다.
위의 예시에서 1200개를 넘어선다면 그 뒤의 작업들은 거부된다고 했다. 이에 대한 자세한 거절 정책을 결정할 수 있다.
ThreadPoolExecutor에는 마지막 인자로 RejectedExecutionHandler 타입을 받을 수 있다. 이 인터페이스를 구현한 3가지 구현체들이 바로 거절 정책에 관련한 구현체들이다.
AbortPolicy: 새로운 작업을 제출할 때 RejectedExecutionException 을 발생시킨다. 기본 정책이다.DiscardPolicy: 새로운 작업을 조용히 버린다.CallerRunsPolicy: 새로운 작업을 제출한 스레드가 대신해서 직접 작업을 실행한다.사용자 정의( RejectedExecutionHandler ): 개발자가 직접 정의한 거절 정책을 사용할 수 있다.다른 정책들은 설명만 읽어도 동작이 예상이 된다. 그러나CallerRunsPolicy는 굉장히 재밌고 특이한 방식이다. es.execute(RunnableTask)를 만약 main에서 실행했다면 이것은 main스레드가 생산자의 일을 한 것이다. 그런데 es(ExecutorService)가 긴급상태에서도 완전 꽉찬 상황이라면 보통 기본 거절정책은 거절되며 예외가 터진다, 만약 DiscardPolicy라면 작업은 그냥 사라진다.
하지만 CallerRunsPolicy 정책은 main스레드가 갑자기 소비자 스레드로 참여하는 것이다. 소비자 스레드로 참여하여 task들을 처리하도록 하는 것이다.
계속 병원 비유를 했었다 이에 맞추어 생각하면 굉장히 웃긴 상황이 되는데 병원이 긴급 운영 상태에서도 꽉 차서 더 이상 환자를 수용할 수 없지만 안내원이 갑자기 진료거부를 하지 않고 손님환자를 의사가운을 입혀 진료하도록 하는 것이다. 심지어 가져온 task만 처리하는 것도 아니고 최대 스레드 타임아웃에 의거해서 긴급상황이 끝날 때 까지 진료시킨다.
사용자 정의 풀 전략으로 적절히 쓰레드의 사용을 제어하는 것은 매우 합리적인 듯 보인다. 하지만 적절한 기본 쓰레드 수, 적절한 최대 쓰레드 수, 적절한 큐 사이즈등을 찾기 위해서는 여러 모니터링이 필요하다.
이렇게 최적화 과정에 쓰이는 시간은 고도의 트래픽을 받는 서비스이고 최적화에 들이는 시간 비용보다 뽑아내는 아웃풋이 좋을 경우 효과적일 수 있으나 보통의 프로젝트에서는 이에 쓸 시간을 비즈니스 로직을 처리하는 등 다른 생산성에 기여하는 것이 옳을 수 있다.
그러므로 초기 서비스라면, 소규모 개인 프로젝트라면, 아직 사용자가 많이 없을 서비스라면 이러한 최적화보다는 기본 전략들로 구성해서 사용하고 중요한 비즈니스 로직 구현에 집중하는 것이 옳다고 생각한다.