스레드(2) - SpringBoot (Tomcat) 에서의 멀티스레딩
스레드(1) : https://shnowball.tistory.com/entry/%EC%8A%A4%EB%A0%88%EB%93%9CThread
스레드(Thread)
스레드에 대해 공부해 보자.스레드를 알기 위해선 프로세스(Process) 의 개념을 먼저 알아야 한다. 프로세스는 간단하게 실행중인 프로그램을 일컫는데, 실행중이라는 것은 프로그램이 운영체제
shnowball.tistory.com
앞서 스레드의 전반적인 개요를 공부해 봤다면, 이번 글에서는 현재 사용중인 프레임워크인 SpringBoot 와 Java에서 멀티스레딩을 어떻게 구현하는지 정리해 보려고 한다.
우리가 개발하는 웹 애플리케이션(SpringBoot)에서 요청이 어떻게 멀티스레딩 되는지 알아보려면, 우선 스레드 풀에 대해 다시 짚어볼 필요가 있다. 스레드 풀은 응답시간과 TPS(Transaction Per Sec) 를 결정짓는 중요한 요소 중 하나로, 적절하게 설정된 스레드 풀은 최적의 CPU 사용률을 끌어낼 수 있다. 스레드 풀이 등장하게 된 배경과 다수의 스레드를 관리하는 스레드 풀의 전략, 그리고 Spring에서의 스레드 처리에 대해 알아보자.
스레드 풀
스레드 풀이란, 요청이 발생할 때마다 스레드를 생성, 소멸시키는 것이 아니라 스레드를 미리 만들어 놓는 방식이다. 요청이 오면 만들어진 스레드에 작업을 할당하고, 작업이 끝나면 스레드를 스레드 풀에 반환한다. 왜 이렇게 스레드를 만들어 놓는 것이 유리할까?
- 요청이 발생할 때마다 스레드를 생성하면, 그 과정 자체로 OS 에 많은 오버헤드가 걸린다. 특히 동시에 여러 요청이 발생하는 경우는 동시에 여러 스레드를 생성해야 하므로, 당연히 성능 이슈가 발생한다.
- 요청이 무한정으로 발생한다고 가정해 볼 때, 스레드 풀로 관리하지 않으면 요청 수 만큼 스레드가 생성된다. 즉 스레드가 무한히 많아진다. 스레드는 병렬적으로 작업을 처리할 수 있고 CPU 자원을 최적으로 활용할 수 있다는 이점이 있지만, 스레드가 많아지면 동기화 문제가 발생하고 컨텍스트 스위칭 비용이 늘어나 오히려 성능을 떨어뜨리고 OS 에 많은 부하를 줄 수 있다. 따라서 적정 선에서 최대 스레드 개수를 유지하는 것이 필요한데, 스레드 풀로 스레드를 관리함으로써 스레드가 무한정 늘어나는 것을 방지할 수 있다.
정리하자면, 만들어진 스레드를 재사용함으로써 요청이 발생할 때마다 스레드를 생성하는 비용 부담을 덜고, 스레드 개수가 무한히 늘어나는 것을 방지하기 위해 스레드 풀을 사용한다고 볼 수 있다.
Java에서는?
Java 의 스레드는 프로그램 레벨에서 구현한 사용자 스레드이다. 사용자 레벨 스레드는 OS 레벨 스레드와 매핑되며, 특히 Java 스레드는 OS 스레드와 One to One Threading-Model (하나의 Java 스레드 → 하나의 OS 스레드)로 매핑되기 때문에 스레드를 생성하고 삭제하는 데 많은 오버헤드가 발생한다. 이 문제를 해결하기 위해, Java 에서 역시 스레드 풀 구현체(ThreadPoolExecuter)를 가지고 있다.
ThreadPoolExecuter 는 maximumPoolSize, corePoolSize, workQueue, keepAliveTime 이라는 개념을 가지고 스레드를 핸들링한다. 각각의 의미를 알아보면 다음과 같다.
- maximumPoolSize : 스레드 풀에서 가지고 있을 수 있는 스레드의 최대 개수
- corePoolSize : 스레드 풀에서 가지고 있는 스레드의 최소 개수
- workQueue : 스레드에게 할당되기 전 작업이 저장되는 대기열.
- keepAliveTime : corePoolSize 이상의 스레드가 생성되었을 때, 다시 corePoolSize 개수로 스레드가 유지되기 시작하기까지의 시간. 즉, 추가 생성된 스레드는 keepAliveTime 동안 workQueue 로부터 작업을 할당받지 않으면 소멸.
전체 플로우는 위 그림과 같다.
1. 요청이 발생하면 요청은 먼저 workQueue에 저장된다.
2. 저장된 작업들은 Thread Pool 에 유휴 스레드(idle Thread) 가 있다면 작업을 스레드에게 할당한다.
3. corePoolSize 만큼의 모든 스레드가 이미 작업을 할당받았다면, 할당을 기다리는 작업들은 workQueue 에 쌓인다.
4. workQueue 가 꽉 차면 maximumPoolSize 까지 스레드가 추가로 생성되고, maximumPoolSize 만큼의 스레드가 모두 생성되고 workQueue 까지 다 차 있는 경우 이후의 요청은 거부된다.
5. 추가로 생성된 스레드들은 keepAliveTime 이 지나면 소멸되고, 스레드 개수는 corePoolSize 를 유지한다.
ThreadPoolExecuter 는 이런 전략으로 스레드를 관리한다고 정리할 수 있다.
SpringBoot 에서는?
SpringBoot 는 다수의 요청을 동시에 받아들일 수 있는 웹 애플리케이션이다. 당연히 우리는 요청을 처리하는 컨트롤러와 엔드포인트만 개발할 뿐, 멀티스레딩을 통해 요청을 처리하는 코드는 한번도 작성해 본 적이 없다. 스프링 부트에서는 내장 WAS 인 Tomcat 이 이를 처리하기 때문이다.(내장 Tomcat 을 사용하지 않더라도 웹 애플리케이션에서 요청을 스레드에 할당하는 일은 WAS 의 일이기도 하다) 그렇다면 Tomcat 이 어떻게 스레드를 관리하고 작업을 할당하는지 알아보자.
Tomcat 은 Java 의 것과 매우 유사한 자체 스레드 풀 구현체를 가지고 있다. 달라진 것은 maxConnections 가 있다는 것인데, maxConnections 는 acceptCount 와 스레드 풀 사이에 위치하여, acceptCount 보다 먼저 요청을 저장하는 대기열의 역할을 한다.acceptCount 는 요청 개수가 maxConnection 에 도달하면 그 이후에 요청을 저장하는 역할을 한다.(그렇지만 이 큐에 메세지가 쌓여있다는 것은 이미 장애가 발생햇을 확률이 높다는 의미이다)
Tomcat 의 스레드 풀 구조를 그림으로 나타내면 다음과 같다.
여기서 궁금한 점이 발생할 수 있다. "Java 에서는 workQueue 로 대기열을 관리했는데, Tomcat 의 스레드 풀 에서는 workQueue 가 보이지 않고 대신 acceptCount 와 maxConnections 라는 두 큐가 있다. 저것들이 workQueue 의 역할을 대신하는 건가?" 나는 이 그림을 처음 봤을때 이같은 의문이 들었다.
결론부터 말하면 maxConnections 가 work Queue 의 역할을 대신한다고 볼 수 있겠고, acceptCount 는 maxConnections 보다 많은 양의 요청이 발생했을 때 요청을 담아두기 위한 큐 라고 볼 수 있다. 단, maxConnection 이후의 요청은 톰캣이 받아들이지는 않고, acceptCount 개수만큼 운영체제의 queue 에서 관리되고 있다.( acceptCount에 저장된 요청들은 OS 수준에서 무시될 수 있다고 한다)
여기서 잠깐 SpringBoot 내장 Tomcat 의 설정 정보를 보고 가자. 설정된 값은 모두 기본 설정값(default) 이다.
# application.yml
server:
tomcat:
threads:
max: 200 # maxPoolSize
min-spare: 10 # corePoolSize
max-connections: 8192 # maxConnections - 실질적으로 동시에 수용가능한 요청 개수
accept-count: 100 # maxConnections 이상의 요청이 발생했을 때 요청을 담는 대기열
connection-timeout: 20000 # timeout 판단 기준 시간, 20초
port: 8080 # 서버를 띄울 포트번호
maxConnections 의 기본값이 8192 로 설정되어있다.
ThreadPoolExecuter 는 작업 큐( work Queue) 가 다 찼을 때 추가로 스레드를 생성한다고 했다. 그런데 작업 큐 사이즈가 8192..? 스레드 풀 사이즈와 어울리지 않는 숫자다.
Tomcat에서 8192개나 되는 요청을 동시 수용할 수 있는 까닭은 (대기열 size가 커도 되는 이유는) 바로 Connector 에 있다.
Connector 는 소켓으로부터 데이터 패킷을 얻어 Servlet Request 객체로 변환한 후, 알맞는 Servlet Container 에게 전달해주는 역할을 하는 컴포넌트이다. Tomcat에서는 8.5 버전까지 BIO Connector 를 사용하였고, 이후 버전부터는 NIO Connector 를 기본으로 채택하였다. BIO(Blocking I/O) Connector 는 connection 하나를 스레드 하나에 할당한다. 즉, 위에서 설명한 ThreadPoolExecuter 와 정확히 동일한 방법으로 동작한다. NIO(Non-Blocking I/O = New I/O) Connector 는 내부적으로 Selector 를 사용해서 하나의 스레드가 여러 채널(connection) 로부터 작업 가능한(바로 처리될 준비가 되어 있는) 데이터를 처리하는 방식을 사용한다. 즉 비동기적으로 여러 개의 connection 을 하나의 스레드가 처리할 수 있기 때문에, maxConnections 설정값을 어느정도는 크게 유지해야 유리하다.
NIO 커넥터와 비동기 프로그래밍은 더 깊고 많은 내용이 있기 때문에 이 글에서는 다루지 않고 다른 글로 정리해 볼 예정이다. 자세한 것은 Java NIO 와 NIO Connector 키워드로 검색해 보길 추천한다.
ref :
https://sihyung92.oopy.io/spring/1
https://e-una.tistory.com/70
https://hudi.blog/tomcat-tuning-exercise/
https://giron.tistory.com/155
https://www.youtube.com/watch?v=um4rYmQIeRE
https://www.youtube.com/watch?v=prniILbdOYA