👩🏻‍💻 Programming/Java

동기, 비동기, 그리고 Batch의 thread-pool : thread-pool은 다다익선이 아닐까? (1)

한국의 메타몽 2024. 10. 21. 00:32

동기, 비동기, 그리고 Batch에서 사용되는 thread의 지식을 정리한 글 입니다.
내용이 긴 관계로 part를 나누어 업로드합니다.
부족한 부분, 또는 잘못된 부분이 있으면 피드백을 남겨주시면 감사드리겠습니다. 🙆🏻‍♀️🙇🏻‍♀️



목차

  1. 서두
  2. thread와 thread-pool
  3. ThreadPoolExecutor와 Connector
  4. 참고 자료


0. 서두

일반적으로 java를 사용하는 spring boot에서 동기 방식의 API 콜이 한 번 호출되고 결과값이 반환될 때 까지 thread 1개가 필요된다.
비동기로 호출 될 경우, 비동기로 호출되는 메서드의 개수 만큼 thread이 더 필요된다.

그렇다면 이왕이면 더 큰 값의 thread-pool을 설정하면 되지 않을까? 그런 호기심에서 thread pool에 대한 탐구가 시작됐다.



1. thread와 thread-pool

먼저 threadthread-pool의 관계를 정리하자.
thread는 프로그램 내에서 실행되는 작업의 단위다. 쉬운 예시로, 위에 언급했던 예시처럼 동기 방식의 API를 한 번 호출해서 값을 반환하는데 일반적으로 1개의 thread가 필요하다.

thread-pool은 이러한 thread를 미리 만들어두고 필요할 때 재사용하는 thread의 모음이다. 새로운 작업이 들어오면 thread-pool에서 사용 가능한 thread를 찾아 작업을 할당하고, 작업이 끝나면 thread를 다시 thread-pool로 반환한다.

우선 spring boot가 다중 요청을 처리할 때, 요청 하나당 생성되는 threadspring boot에서 생성 <-> 소멸을 관리해주는게 아니라 내장 서블릿 컨테이너 == 내장형 Tomcat, 즉, WAS 에서 처리를 해준다.

참고로 현재 1번 항목에서 다루는 내용은 HTTP + 동기 요청에 대해서 언급한다.
이는 @RestController@Controller를 통해 호출되는 일반적인 동기 API를 말한다.
(비동기에 쓰이는 thread-pool이나 batch에 쓰이는 thread-pool은 후술한다.)

기본적으로 아래의 루트를 거쳐 thread-pool의 생성과 소멸이 이루어진다.

1. Tomcat은 다중 요청을 처리하기 위해서, 부팅할 때 Thread의 컬렉션인 Thread Pool을 생성한다.
2. 유저 요청(HttpServletRequest)이 들어오면 Thread Pool에서 하나씩 Thread를 할당한다. 
3. 해당 Thread에서 스프링부트에서 작성한 Dispathcer Servlet을 거쳐 유저 요청 을 처리한다.
4. 작업을 모두 수행하고 나면 Thread는 Thread Pool로 반환한다.

보통 application.yml에서 아래 처럼 thread-pool에 대한 지정이 가능하다.
참고로 아래 설정값은 Tomcat 서버, 즉, WAS에서 관리되는 설정이다.

# application.yml (적어놓은 값은 default) 
server: 
    tomcat: 
        threads: 
            max: 200 # 생성할 수 있는 thread의 총 개수 
            min-spare: 10 # 항상 활성화 되어있는(idle) thread의 개수 
            max-connections: 8192 # 수립가능한 connection의 총 개수 
            accept-count: 100 # 작업큐의 사이즈 
            connection-timeout: 20000 # timeout 판단 기준 시간, 20초 
    port: 8080 # 서버를 띄울 포트번호

위에서 설정한 값은 Tomcat의 ThreadPoolExecutorConnector에 줄 수 있는 옵션이다.
다시 말해 application.yml에 설정된 스레드 수는 서버가 클라이언트의 HTTP 요청을 처리할 때 사용하는 Tomcat 서버의 thread-pool 이다.

좀 더 간단히 요약하면, HTTP 요청이 동기 요청이건 비동기 요청이건 Tomcatthread-pool이 할당된다.
동기일 경우 Tomcat에서 할당된 thread가 처음부터 끝까지 요청을 처리한다.

그리고 여기서 비동기 thread의 관리 역할은 SpringBoot, 즉, JVM의 역할이 된다.

비동기로 동작하는 thread가 어떻게 관리되고 사용되는지를 이해하기 위해 아래 2번에서 ThreadPoolExecutor를 들여다보자.



2. ThreadPoolExecutor와 Connector

java에서 connector클라이언트의 요청을 받아들이는 인터페이스 역할을 한다. 웹 서버나 어플리케이션 서버에서 클라이언트의 요청이 들어오면 connector가 그 요청을 수신하고 ThreadPoolExecutor에게 요청을 넘겨준다.

그리고 ThreadPoolExecutorthread-pool을 관리하는 클래스이다.

ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue)

ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler)

ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory)

ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)

공통적으로 corePoolSize maximumPoolSize keepAliveTime unit workQueue의 5가지 파라미터가 존재하는데, 이들은 다음과 같은 역할을 한다.

  • corePoolSize : 기본 풀 size를 의미. 최초 스레드는 corePoolSize만큼 생성
  • maximumPoolSize : workQueue가 가득 찼을 때 추가적인 theead-pool을 생성하는데, 이때 허용 가능한 thread의 수
  • keepAliveTime : corePoolSize보다 스레드가 많아졌을 경우, maximumPoolSize까지 스레드가 생성되는데 keepAliveTime까지 유지했다가 다시 corePoolSize로 유지되는 시간
  • unit : keepAliveTime의 시간 단위 (ex : SECONDS)
  • workQueue : corePoolSize보다 스레드가 많아졌을 경우, 남는 스레드가 없을 경우 해당 큐에 저장

예를 들어 아래와 같은 코드가 있다고 가정하자.

public static void main(String args[]) throws Exception {  
  //해당 큐는 7개까지 쓰레드 저장이 가능하다.  
  LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(7);  

  ThreadPoolExecutor executorService =  
      new ThreadPoolExecutor(1,5,3, SECONDS, queue);  

  for (int i = 0; i < 10; i++) {  
    //10개의 Task를 실행시킨다.  
    executorService.execute(new Task());  
  }  

  executorService.awaitTermination(5, SECONDS);  
  executorService.shutdown();  
}  

private static class Task implements Runnable {  
  @Override  
  public void run() {  
    try {  
      //쓰레드 번호를 출력해 준다.  
      System.out.println(Thread.currentThread().getName());  
      SECONDS.sleep(1);  
    } catch (InterruptedException e) {  
    }  
  }  
}

실행 결과

결론적으로 3개씩 스레드가 생성된 것을 볼 수 있다.
이 외에도 아래와 같은 메서드들을 통해 목적에 맞게 스레드를 생성하고 관리할 수 있다.

Executors.newSingleThreadExecutor()
Executors.newFixedThreadPool()
Executors.newCachedThreadPool()
Executors.newWorkStealingPool()

만약 앞서 언급된 ThreadPoolExecutorapplication.yml에서 설정한 thread-pool의 한계를 뛰어넘는 상황이 발생하면 어떻게될까?
다음과 같은 상황을 예측할 수 있다.



(1) ThreadPoolExecutor의 한계를 넘는 경우

위의 코드에서는 아래와 같이 ThreadPoolExecutor의 값을 설정했다.

LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(7);
new ThreadPoolExecutor(1,5,3, SECONDS, queue);  
/*
corePoolSize : 1 = 최초 스레드의 생성 사이즈 
maximumPoolSize : 5 = workQueue가 가득 찼을때 추가적으로 허용하는 thread 수
keepAliveTime : 3 = maximumPoolSize에서 coolPoolSize가 될때까지 유지되는 시간
unit : SECONDS = keepAliveTime의 시간 단위
workQueue : 7 = corePoolSize보다 스레드가 많아질 경우, 남는 스레드가 없을 때 해당 큐에 저장
*/

ThreadPoolExecutor에는 최대 5개의 스레드로 동작하며, 큐에는 7개의 작업이 대기할 수 있다.
때문에 동시에 12개의 작업 (5개의 스레드 + 7개의 큐 대기 작업)까지는 처리가 가능하다.

이 상황에 13번째 작업이 들어오면 해당 작업은 처리가 불가능하므로, 기본적인 거부 정책이 발생된다.
이는 RejectedExecutionException을 발생시킨다.



(1) - (2) RejectedExecutionException의 정책

RejectedExecutionException의 정책은 아래 4가지가 있는데,
이는 RejectedExecutionHandler의 구현체 rejectedExecution에서 확인 가능하다.

// 정책 적용 예시 코드
ThreadPoolExecutor executor = new ThreadPoolExecutor( corePoolSize, maximumPoolSize, keepAliveTime, timeUnit, workQueue, new ThreadPoolExecutor.AbortPolicy());
  1. ThreadPoolExecutor.AbortPolicy --> 기본전략
    • Reject 발생 시 RejectedExecutionException 발생
  2. ThreadPoolExecutor.CallerRunsPolicy
    • Reject된 task를 실행중인 main thread에서 동작
  3. ThreadPoolExecutor.DiscardPolicy
    • Reject된 task는 버려짐
    • Exception도 발생하지 않음
  4. ThreadPoolExecutor.DiscardOldestPolicy
    • 가장 오래된 처리되지 않은 요청을 삭제하고 다시 시도
    • DiscardPolicy와 마찬가지로 데이터가 유실될 수 있음


(2) Tomcat의 스레드 한계를 넘는 경우

# application.yml (적어놓은 값은 default) 
server: 
    tomcat: 
        threads: 
            max: 200 # 생성할 수 있는 thread의 총 개수 
            min-spare: 10 # 항상 활성화 되어있는(idle) thread의 개수 
            max-connections: 8192 # 수립가능한 connection의 총 개수 
            accept-count: 100 # 작업큐의 사이즈 
            connection-timeout: 20000 # timeout 판단 기준 시간, 20초 
    port: 8080 # 서버를 띄울 포트번호

Tomcat의 설정값들 중 핵심 내용을 이해해보자.

MaxThreads (200)
- Tomcat에서 동시에 실행할 수 있는 최대 스레드 수
- 서버가 요청을 처리할 때 각 요청은 스레드에 할당되므로, 이 값이 초과되면 더 이상 요청을 처리할 스레드가 없는 상태가 됨

MaxConnections (8192)
- Tomcat이 수용할 수 있는 최대 연결 수
- 동시에 수립할 수 있는 모든 연결의 총량을 나타냄
- 단, 요청을 처리할 스레드가 없으면 연결만 유지된 상태로 대기

AcceptCount (100)
- 최대 스레드 수가 초된 경우 대기 큐에 보관할 수 있는 최대 요청 수 
- 이 값은 이미 할당된 스레드가 사용 중인 동안 추가 요청을 얼마나 대기시킬 수 있는지를 결정

참고 이미지

Tomcat최대 200개의 스레드를 생성할 수 있다.
또한 accept-count의 값은 100이므로, 스레드 모두가 바쁠 때 최대 100개의 작업을 큐에 저장할 수 있다.
만약 동시에 200개를 초과한 HTTP 요청이 들어오게 되고, 100개의 대기열이 가득 차는 상황이 발생할 경우, 추가적인 요청은 거부된다.
이 경우, 클라이언트는 끊기거나 503 Service Unavailable의 오류를 받게 된다.

MaxConnections때문에 헷깔릴 수 있는데, AcceptCountMaxConnections를 초과했을때의 디테일한 차이점은 아래 시나리오를 확인하자.


예시 1: 정상 처리

  • 상황: 150개의 요청이 동시에 들어옴
  • 동작:
    • 150개의 요청이 모두 연결되어, 각 요청에 대해 스레드가 할당
    • 스레드 개수(150)는 MaxThreads(200) 이내이므로 모든 요청이 즉시 처리
    • AcceptCount와 관련된 대기 요청은 발생하지 않음

예시 2: MaxThreads 초과

  • 상황: 250개의 요청이 동시에 들어옴
  • 동작:
    • 처음 200개의 요청은 MaxThreads 내에서 각 요청마다 스레드를 할당받아 처리
    • 나머지 50개의 요청은 처리할 스레드가 없으므로 대기 큐에 저장 (AcceptCount는 100이므로, 최대 100개의 대기 요청이 가능)
    • 대기 중인 요청은 스레드가 하나라도 비어야 순서대로 처리

예시 3: AcceptCount 초과

  • 상황: 400개의 요청이 동시에 들어옴
  • 동작:
    • 처음 200개의 요청은 스레드가 할당되어 처리
    • 다음 100개의 요청은 대기 큐에 저장됩니다. (대기 큐는 AcceptCount가 100이므로 더 이상의 대기는 불가능)
    • 나머지 100개의 요청은 연결이 거부되며 클라이언트는 503 Service Unavailable 오류를 받게 됨

예시 4: MaxConnections 초과

  • 상황: 9000개의 요청이 동시에 들어옴
  • 동작:
    • 처음 8192개의 요청은 서버와 연결할 수 있으며, 그중 200개의 요청이 스레드에 할당되고, 나머지는 대기 큐에 들어감
    • MaxConnections(8192)를 초과한 808개의 요청은 서버와 연결할 수 없고 503 Service Unavailable 오류 반환


3. 참고 자료