스프링은 어떻게 동시에 수많은 요청을 처리할까요?

지난 포스팅에서는 동시 요청 처리에 대해 알아보기 전에 Tomcat 의 Servlet Container 역할을 살펴보았습니다.

 

이번 포스팅에서는 Thread Pool 의 개념과 Tomcat 에서 유사하게 사용되는 Java 의 Thread Pool 클래스에 대해 알아보도록 하겠습니다.

Tomcat 알아보기 두 번째 포스팅 Java Thread Pool 입니다.

 


 

Thread Pool

 

Java 에서 유저 수준 스레드는 커널 수준 스레드에 매핑됩니다. (참고) [Java] JVM 알아보기 - (6) Thread, Java Thread Model

그렇기 때문에 제어할 수 없이 스레드를 많이 생성하면 리소스가 빠르게 고갈될 수 있습니다. 또한 스레드가 늘어날수록 컨텍스트 스위칭 비용도 늘어나게 됩니다. 이러한 문제를 해결하기 위해 Thread Pool 패턴이 도입되었습니다.

 

동작 과정

Thread Pool 의 동작 과정은 다음과 같습니다.

  1. Thread pool 에 미리 설정한 사이즈 만큼의 스레드를 생성합니다.
  2. Task(Connection, 소켓 객체 등) 처리 요청이 오면 Task Queue 에 Task 를 넣어둡니다.
  3. idle 상태인 스레드가 있다면, Task 큐에서 Task 를 꺼내 해당 스레드에 할당합니다.
    1. 만약 idle 상태인 스레드가 없다면, 작업은 Task Queue 에서 대기합니다.
    2. 그 상태가 지속되어 Task Queue 가 꽉 차면 스레드를 새로 생성합니다.
    3. 이 과정을 반복하다가 스레드 최대 사이즈에 도달하고 작업큐도 꽉 차게 되면, 추가 요청에 대해서 connection-refused 오류를 리턴합니다.
  4. Task 가 완료되면 스레드는 다시 idle 상태로 돌아갑니다.
    1. Task Queue 가 비어있고 core size 이상의 스레드가 생성되어 있다면 스레드를 destory 합니다.

 

정리

스레드 풀은 일정량의 스레드를 미리 만들어두고 Task Queue 를 이용해 Task 를 처리하는 패턴입니다.

 

스레드를 미리 만들어두고 사용하기 때문에 새로운 스레드를 생성하는 비용을 줄일 수 있고,

사용할 스레드의 개수를 제한하기 때문에 CPU 오버헤드를 방지할 수 있습니다.

 

즉, 스레드 풀을 사용하면 여러 개의 작업을 동시에 안정적으로 처리할 수 있게 됩니다.

 

 

Java 의 Thread Pool

Tomcat 은 Java 기반이기 때문에 Java 의 Thread Pool 클래스와 매우 유사한 Thread Pool 구현체를 가지고 있습니다.

Java 의 Thread Pool 사용법에 대해 간략하게 알아보겠습니다.

 

기본 용어 정리

corePoolSize & maximumPoolSize

새로운 task 가 submit 될 때 스레드 풀에서 corePoolSize 미만의 스레드가 실행 중인 경우, 다른 스레드가 idle 상태인 경우에도 요청을 처리하기 위해 새 스레드가 생성됩니다. 실행 중인 스레드가 corePoolSize 보다는 크고 maximumPoolSize 보다 작은 경우, task 큐가 가득 찬 경우에만 새 스레드가 생성됩니다.

 

keepAliveTime

풀에 현재 corePoolSize 보다 많은 스레드가 있는 경우, keepAliveTime 보다 더 오래 idle 상태인 경우 초과 스레드가 종료됩니다.

 

Executors, Executor 및 ExecutorService

복잡한 애플리케이션에서 직접 Thread 를 만들고 관리하는 것은 매우 어려운 일이기 때문에 Java5 는 스레드 관리를 추상화하는 Executor 를 도입했습니다. Executor 는 개발자 대신 스레드의 라이프 사이클, 사용 및 스케줄링 등을 관리합니다.

Executor 의 상속 관계는 다음과 같습니다.

 

 

Executors 라는 유틸 클래스는 Execute, ExecutorService, ScheduledExecutorService 등의 인스턴스 또는 스레드 풀을 생성하는 여러 메서드를 제공합니다. 사용자가 세부 설정을 할 필요가 없는 경우 사용합니다.

 

Executor

Executor 인터페이스에는 Runnable 인스턴스를 submit 하는 단일 실행 메서드가 있습니다.

Task 를 처리하는 단일 작업자 스레드로, 어떠한 이유로 인해 스레드가 죽으면 교체합니다.

그렇기 때문에 10개의 작업이 있는 대기열이 있다면, 작업은 순서에 따라 차례대로 실행됩니다.

 

다음은 간단하게 "Hello World" 를 출력하는 단일 작업을 실행하는 예제입니다.

Executor executor = Executors.newSingleThreadExecutor();
executor.execute(() -> System.out.println("Hello World"));

 

ExecutorService

ExecutorService 에는 Task 진행을 컨트롤하고 서비스의 종료를 관리하는 여러 메서드가 포함되어 있습니다.

이 인터페이스를 사용하여 실행할 작업을 제출하고 반환된 Future 인스턴스를 사용하여 실행을 제어할 수 있습니다.

 

다음과 같이 executorService 를 사용해서 task 를 submit 하고, task 의 리턴 값을 get() 으로 받아올 수 있습니다.

ExecutorService executorService = Executors.newFixedThreadPool(10);
Future<String> future = executorService.submit(() -> "Hello World");
// some operations
String result = future.get();

 

ThreadPoolExecutor

세부 설정이 가능한, 많은 매개 변수가 있는 스레드풀 구현 클래스입니다. 가장 일반적인 구성은 Executors 에 정의되어 있습니다.

 

Executors.newFixedThreadPool()

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}

 

이 타입의 풀에는 항상 지정된 수의 스레드가 실행됩니다. 풀의 스레드가 종료되면, 자동으로 새 스레드로 대체됩니다.

동시에 실행되는 task 의 수가 지정한 스레드 수보다 작거나 같으면 즉시 실행되지만, 그렇지 않으면 대기열에 놓이게 됩니다.

 

Executors.newCachedThreadPool()

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

 

사용 가능한 경우 이전에 생성된 스레드를 재사용하는 스레드 풀을 생성합니다. 

corePoolSize 를 0 으로 지정하고, maximumPoolSize 를 Interger 최대 값으로 생성합니다. keepAliveTime 은 60초 입니다.

이러한 매개변수 값은 캐시된 스레드 풀이 submit 된 task 를 수용하기 위해서 제한 없이 커질 수 있음을 의미합니다.

그러나 스레드가 60초 동안 활동이 없으면 destroy 되기 때문에, 애플리케이션에 수명이 짧은 작업이 많은 경우 사용합니다.

 

ScheduledThreadPoolExecutor

일정한 딜레이 후에 실행하거나 주기적으로 실행할 명령을 예약할 수 있는 클래스입니다.

 

schedule()

public ScheduledFuture<?> schedule(Runnable command,long delay, TimeUnit unit);

public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit);

지정된 delay 후에 작업을 한 번 실행할 수 있습니다.

 

scheduleAtFixedRate()

public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
                                                  long initialDelay,
                                                  long period,
                                                  TimeUnit unit);

지정된 initialDelay 후에 작업을 실행하고, 일정 기간 동안 주기적으로 실행할 수 있습니다. 

period 매개 변수는 연속 실행 사이의 기간을 나타냅니다.

 

scheduleWithFixedDelay()

public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
                                                     long initialDelay,
                                                     long delay,
                                                     TimeUnit unit);

주어진 작업을 반복적으로 실행한다는 점에서 scheduleAtFixedRate 와 유사하지만, initialDelay 후에 작업을 시작하고 실행이 종료 된 후에 delay 만큼의 지연이 발생합니다.

 

 

 

마치며

이번 포스팅에서는 선수 지식으로 Thread Pool 의 동작 과정과 구현 클래스들에 대해서 알아보았습니다.

다음 포스팅은 드디어 Tomcat 의 Thread Pool 을 알아볼 수 있게 되었습니다.

 

 

Reference

 

복사했습니다!