동기, 비동기, 그리고 Batch의 thread-pool : thread-pool은 다다익선이 아닐까? (1)
동기, 비동기, 그리고 Batch에서 사용되는 thread의 지식을 정리한 글 입니다.
내용이 긴 관계로 part를 나누어 업로드합니다.
부족한 부분, 또는 잘못된 부분이 있으면 피드백을 남겨주시면 감사드리겠습니다. 🙆🏻♀️🙇🏻♀️
목차
- 서두
- thread와 thread-pool
- ThreadPoolExecutor와 Connector
- 참고 자료
0. 서두
일반적으로 java
를 사용하는 spring boot
에서 동기 방식의 API 콜이 한 번 호출되고 결과값이 반환될 때 까지 thread
1개가 필요된다.
비동기로 호출 될 경우, 비동기로 호출되는 메서드의 개수 만큼 thread
이 더 필요된다.
그렇다면 이왕이면 더 큰 값의 thread-pool
을 설정하면 되지 않을까? 그런 호기심에서 thread pool
에 대한 탐구가 시작됐다.
1. thread와 thread-pool
먼저 thread
와 thread-pool
의 관계를 정리하자.thread
는 프로그램 내에서 실행되는 작업의 단위다. 쉬운 예시로, 위에 언급했던 예시처럼 동기 방식의 API를 한 번 호출해서 값을 반환하는데 일반적으로 1개의 thread
가 필요하다.
thread-pool
은 이러한 thread
를 미리 만들어두고 필요할 때 재사용하는 thread
의 모음이다. 새로운 작업이 들어오면 thread-pool
에서 사용 가능한 thread
를 찾아 작업을 할당하고, 작업이 끝나면 thread
를 다시 thread-pool
로 반환한다.
우선 spring boot
가 다중 요청을 처리할 때, 요청 하나당 생성되는 thread
는 spring 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의 ThreadPoolExecutor
와 Connector
에 줄 수 있는 옵션이다.
다시 말해 application.yml
에 설정된 스레드 수는 서버가 클라이언트의 HTTP 요청을 처리할 때 사용하는 Tomcat 서버의 thread-pool
이다.
좀 더 간단히 요약하면, HTTP
요청이 동기 요청이건 비동기 요청이건 Tomcat
의 thread-pool
이 할당된다.
동기일 경우 Tomcat
에서 할당된 thread
가 처음부터 끝까지 요청을 처리한다.
그리고 여기서 비동기 thread
의 관리 역할은 SpringBoot
, 즉, JVM
의 역할이 된다.
비동기로 동작하는 thread
가 어떻게 관리되고 사용되는지를 이해하기 위해 아래 2번에서 ThreadPoolExecutor
를 들여다보자.
2. ThreadPoolExecutor와 Connector
java
에서 connector
는 클라이언트의 요청을 받아들이는 인터페이스 역할을 한다. 웹 서버나 어플리케이션 서버에서 클라이언트의 요청이 들어오면 connector
가 그 요청을 수신하고 ThreadPoolExecutor
에게 요청을 넘겨준다.
그리고 ThreadPoolExecutor
는 thread-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()
만약 앞서 언급된 ThreadPoolExecutor
와 application.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());
ThreadPoolExecutor.AbortPolicy
--> 기본전략- Reject 발생 시 RejectedExecutionException 발생
ThreadPoolExecutor.CallerRunsPolicy
- Reject된 task를 실행중인 main thread에서 동작
ThreadPoolExecutor.DiscardPolicy
- Reject된 task는 버려짐
- Exception도 발생하지 않음
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
때문에 헷깔릴 수 있는데, AcceptCount
와 MaxConnections
를 초과했을때의 디테일한 차이점은 아래 시나리오를 확인하자.
예시 1: 정상 처리
- 상황: 150개의 요청이 동시에 들어옴
- 동작:
- 150개의 요청이 모두 연결되어, 각 요청에 대해 스레드가 할당
- 스레드 개수(150)는
MaxThreads
(200) 이내이므로 모든 요청이 즉시 처리 AcceptCount
와 관련된 대기 요청은 발생하지 않음
예시 2: MaxThreads
초과
- 상황: 250개의 요청이 동시에 들어옴
- 동작:
- 처음 200개의 요청은
MaxThreads
내에서 각 요청마다 스레드를 할당받아 처리 - 나머지 50개의 요청은 처리할 스레드가 없으므로 대기 큐에 저장 (
AcceptCount
는 100이므로, 최대 100개의 대기 요청이 가능) - 대기 중인 요청은 스레드가 하나라도 비어야 순서대로 처리
- 처음 200개의 요청은
예시 3: AcceptCount
초과
- 상황: 400개의 요청이 동시에 들어옴
- 동작:
- 처음 200개의 요청은 스레드가 할당되어 처리
- 다음 100개의 요청은 대기 큐에 저장됩니다. (대기 큐는
AcceptCount
가 100이므로 더 이상의 대기는 불가능) - 나머지 100개의 요청은 연결이 거부되며 클라이언트는 503 Service Unavailable 오류를 받게 됨
예시 4: MaxConnections
초과
- 상황: 9000개의 요청이 동시에 들어옴
- 동작:
- 처음 8192개의 요청은 서버와 연결할 수 있으며, 그중 200개의 요청이 스레드에 할당되고, 나머지는 대기 큐에 들어감
MaxConnections
(8192)를 초과한 808개의 요청은 서버와 연결할 수 없고 503 Service Unavailable 오류 반환