톰캣(Tomcat) 의 개념, 요청 처리 내부과정

Posted by , March 12, 2023
톰캣thread
Series ofHTTP 웹서버 Spring MVC 프레임워크 구현하기

Tomcat (톰캣)

스프링부트 애플리케이션은 다중 요청을 처리하기 위해 내장 서블릿 컨테이너인 Tomcat(톰캣) 을 이용합니다. Tomcat 은 다중 요청을 처리하기 위해, 부팅시 쓰레드의 집합소라 할 수 있는 쓰레드 풀(Thread Pool) 을 생성하죠. 즉, Tomcat 을 통해 쓰레드풀을 지원받을 수 있는것입니다.


요청에 대한 쓰레드 풀 활용방식

클라이언트에 요청이 들어왔을 때 어떤 메커니즘으로 쓰레드 풀을 활용하여 처리할까요?

톰캣(Tomcat) 은 앞서 언급했듯이 쓰레드 풀을 생성합니다. 클라이언트의 요청(HttpServletReqeust) 가 들어오면 쓰레드 풀에서 각 요청에 대해 처리해줄 하나씩 쓰레드를 할당하고, 스프링부트에서 작성한 Dispatcher Servlet 을 거쳐서 요청을 처리합니다. 그리고 작업을 모두 마쳤다면 쓰레드는 다시 쓰레드풀로 반환되죠.


그래서 쓰레드 풀이 뭔데!?

우선 쓰레드(Thread) 란 CPU 의 리소스를 이용해서 코드를 실행하는 하나의 단위 객체를 의미합니다. 또 쓰레드 풀은 이렇게 프로그램 실행에 필요한 쓰래드(Thread) 들을 미리 생성해놓은 집합 저장소를 의미합니다.

쓰레드 풀은 지난번에 학습했던 데이터베이스 커넥션 풀(Connection Pool),DataSource 의 커넥션 풀의 개념과 유사하다고 느꼈습니다. DB 에 원하는 리소스를 요청하기 위해 매번 커넥션을 생성하고 얻어오는 과정은 리소스를 많이 잡아먹게되므로, 일정량의 커넥션을 커넥션 풀에 미리 생성해두는 것과 동일한 이유입니다.

쓰레드 풀 개념 도입 이전의 Tomcat 의 요청 처리방식

Tomcat 3.2 이전 버전에서는 쓰레드 풀 개념이 존재하지 않았습니다. 매 요청이 들어올 때 마다 운영체제 자체에서 지원해주는 쓰레드를 매번 생성하고 요청을 처리했으며, 내용을 처리했다면 쓰레드가 제거되는 방식이었습니다.

그러나 운영체제의 쓰레드 숫자는 제한되어 있기에, 운영체제가 지원하는 쓰레드 수를 초과해서 사용시 애플리케이션에 장애(ex. 서버다운) 를 유발할 수 있습니다. 또 하나의 운영체제 쓰레드를 만드는 작업은 자원 낭비(과한 CPU와 메모리 자원 사용)가 심했으며, 매번 많은 요청에 대해 쓰레드를 생성하는 것은 OS 와 JVM 에 큰 부하를 안겨줄 수 있습니다.

동시간대에 대규모 트래픽이 발생할 경우 이를 감당하지 못하는 문제가 발생하게됩니다. 이런 한계를 극복하도록 해당 문제를 해결하기 위해, 톰캣에 스레드풀을 도입합니다.


요청에 대한 처리 메커니즘

그렇다면 쓰레드 풀을 도입시, 요청에 대한 처리방식이 어떠할지 알아봅시다.

첫 작업 내용이 요청으로 들어오면, application.yml 파일에서 별도의 설정이 없다면 기본적으로 CPU Core 의 갯수만큼 쓰레드를 생성합니다. 그리고 매 클라이언트로 부터 받은 요청에 대한 커넥션 객체(Connection) 를 작업 큐(Queue) 에 넣어두죠. 그리고 쓰레드 풀에서 작업 가능한 상태인 쓰레드를 꺼내서 작업 큐에 대한 커넥션들에 대해 작업을 처리하는 방식입니다.

만약 작업 가능한 쓰레드가 없다면 커넥션들은 계속 작업 큐에서 대기상태로 있어야합니다. 대기상태에 빠진 커넥션이 늘어가서 작업 큐가 꽉찬다면, 쓰레드를 새롭게 생성합니다. 이렇게 진행하다 최악의 경우 현재 요청이 더 들어오는데 작업 가능한 쓰레드도 없고 작업 큐가 완전 꽉찬 상태라면, 해당 요청은 refused 됩니다. (connection refused 오류 반환 ) (=> 추후 설명하겠지만, 이렇게 refused 되는 방식은 BIO Connector 에서나 그러는것입니다. 성능이 개선된 NIO Connector 에서는 요청이 refused 되지 않고 처리 가능합니다.)

마지막으로 쓰레드가 작업을 마쳤다면 다시 쓰레드 풀로 반환됩니다. 이때 쓰레드는 다시 바로 작업 가능한 상태로 반환되며, 작업 큐가 텅텅 비어있고 CPU Core 사이즈(또는 yml파일에 지정해준 갯수) 이상으로 쓰레드가 쓰레드 풀에 존재할 경우, 필요 이상으로 쓰레드가 많이 존재할 이유가 없기 때문에 해당 쓰레드를 제거시킵니다.


쓰레드풀 생성 (ThreadPoolExecutor)

ThreadPoolExecutor 는 쓰레드풀을 자바에서 구현한 구현체입니다. application.yml에서 쓰레드 풀 구현체인 ThreadPoolExecutor에 대한 자세한 설정이 가능한데, 어떤 옵션을 부여할 수 있는지 알아보겠습니다.

server:
  tomcat:
    threads:
      max: 200  # 생성할 수 있는 thread의 최대 총 개수
      min-spare: 10  # 항상 활성화 되어있는 thread의 개수
    max-connections: 8192 # max-connections : 수립가능한 connection의 총 개수
    accept-count: 100 # 작업 큐의 사이즈
    connection-timeout: 20000  # 커넥션 유지시간 (생명주기)
  port: 8080

우선 max 옵션으로 쓰레드 최대 사이드를 설정하고, min-spare 로 항상 활성화 되어있는 쓰레드의 갯수를 지정할 수 있습니다. 이들은 Tomcat 9.0 기준으로 디폴트 값이 200개, 10개 입니다. 당연한 말이지만, min-spare 에 지정한 갯수만큼 쓰레드 풀에 쓰레드가 생성되고 작업큐에 처리할 내용이 없어도 소멸되지 않고 활성화된 상태로 계속 존재합니다.

accept-count는 작업큐의 사이즈로, 디폴트로 Integer.MAX 값을 부여합니다. 이는 무한 대기열 전략 으로, 아무리 요청이 많이 들어와도 core size를 늘리지 않는다는 정책입니다.

무한 대기열 전략

그런데 무한 대기열 전략에선 작업큐가 꽉 찰 일이 없으므로, 스레드풀의 max사이즈가 의미가 없어지지 않을까요? 작업큐가 꽉 차고나서야 추가적인 쓰레드들이 계속 생성되며, 작업 큐의 사이즈는 굉장히 커서 쓰레드가 추가적으로 생성될 일이 없을테니까요. 그러나 설정을 주어 max값을 변경하는 건 아무 의미 없는것이 아닙니다. 이는 추후 설명드리겠습니다.


NIO Connector : Tomcat 9.0 부터 지원

저희는 작업 큐의 사이즈에 따라서 요청이 refused 될수도 있는 상황에 놓일 수 있습니다. 만약 accept-count 옵션을 1, max 옵션을 2 로 부여했을 때 동시간대에 5개의 요청이 온 경우를 가정해봅시다. 즉, 작업 큐의 사이즈가 1이며 최대 생성 가능한 쓰레드 개수가 2인 경우를 생각해봅시다. 이 경우 5개의 요청을 다 수락하지 못하고 거절될 수 있으나, NIO Connector 를 활용함에 따라 모든 요청을 수락할 수 있게됩니다.

  • 이때 몰론 수많은 트래픽이 발생시 Race Condition 등에 의해 동시성 이슈가 발생할 수 있으나, 이런 상황들은 멀티쓰레드의 주제에서 벗어나므로 제외하고 생각하겠습니다.

BIO Connector

톰캣 8.0부터 NIO(NonBlocking I/O) Connector 라는 커넥터가 기본으로 채택되고, 9.0부터는 NIO Connector 가 채택됨으로써 위 설명과는 다른 방식으로 진행되게 됩니다.

기존 방식이라면 특정 커넥션이 닫힐 때까지 하나의 Thread는 특정 커넥션에 계속 할당되어 있을 것입니다. 이러한 방식을 채택해서 사용할 경우 thread들이 충분히 사용되지 않고 idle(아무것도하지않는) 상태로 낭비되는 시간이 많이 발생합니다. 이러한 문제점을 해결하고 리소스(thread)를 효율적으로 사용하기 위해 NIO Connector가 등장했습니다.

NIO Connector

NIO Connector에선 Poller 라는 별도의 스레드가 커넥션을 처리합니다. Poller는 커넥션들을 잠시 캐시에 들고 있다가 해당 커넥션에서 데이터에 대한 처리가 가능한 순간에만 번쩍 thread를 할당하는 방식을 사용해서 thread이 idle 상태로 낭비되는 시간을 줄여줍니다.

Poller 쓰레드

Poller에선 max connection 까지 연결을 수락하고, 작업큐 사이즈와 관계 없이 캐싱 기능을 활용하므로 추가로 커넥션을 refuse하지 않고 받아놓을 수 있습니다.


쓰레드 풀의 적정크기

쓰레드 풀에 무조건 많은 스레드를 생성해두면 좋을까요? 쓰레드를 많이 생성해둔다고 그 스레드를 다 사용할 수 있는 것이 아닙니다.

따라서 쓸데없이 쓰레드를 많이 생성한다면 생성하는 데에 드는 자원과 비용이 낭비됩니다. 그렇다고 쓰레드를 부족하게 만들어둔다면 클라이언트에 대한 응답시간이 너무 느려지고, CPU 사용률이 낮아지게 될 것입니다.

1. CPU 코어의 개수

하나의 CPU 코어는 한 번에 하나의 스레드를 실행할 수 있습니다. 만약 쿼드 코어라면 4개의 스레드를 한 번에 실행할 수 있죠. 이러한 코어의 개수에 따라 적정 스레드 크기를 정하면 됩니다.

2. CPU Bound Tasks

하이퍼 쓰레딩이 지원되지 않는경우, 하나의 코어는 하나의 쓰레드만 실행가능합니다. 따라서 CPU를 점유하는 작업이 하나의 스레드에서 실행되고 있다면 다른 스레드는 그 시간 동안 아무것도 할 수 없습니다.

따라서 메모리를 낭비할 뿐, 성능 향상에는 아무런 도움이 되지 않습니다. 따라서 CPU bound 작업의 경우에는 CPU 코어 개수와 동일하게 스레드 풀의 크기를 정하는 게 좋습니다.

계산식

대게의 서적에서 권장하는 적정 쓰레드 개수 공식은 아래와 같습니다.

쓰레드 개수 = CPU 코어 수 * (1 + 대기시간/작업시간)


참고

스프링부트는 어떻게 다중 유저 요청을 처리할까? (Tomcat9.0 Thread Pool) SpringBoot에서 Thread 풀 건드려보기 이상적인 스레드 풀의 적정 크기에 대하여, 스레드 풀 크기 공식, 리틀의 법칙 스레드(Thread)의 적정 개수