티스토리 뷰

Spring

[SpringBoot] 멀티 스레드 기반으로 다중 요청을 처리하는 톰캣(Tomcat)의 구조와 동작 방식

망나니개발자 2025. 4. 8. 10:00
반응형



 

1. 멀티 스레드 기반으로 다중 요청을 처리하는 톰캣(Tomcat)의 구조와 동작 방식


[ 웹 애플리케이션 서버(WAS, Web Application Server)과 톰캣 ]

스프링 MVC 프레임워크는 자바 엔터프라이즈 개발을 편리하게 해주는 경량급 오픈소스 애플리케이션 프레임워크이며, 대량의 동시 요청 처리를 수행행할 수 있도록 멀티 스레드 모델을 기반으로 하고 있다. 이때 클라이언트와의 요청을 수립하고 이를 받는 부분은 웹 애플리케이션 서버(WAS, Web Application Server)가 수행하며, 대표적으로 톰캣(Tomcat)이 이를 구현하고 있다.

 

 

 

[ NIO 기반의 톰캣의 동작 방식 ]

사용자 요청이 들어오면 OS 계층에서 TCP 3-way handshake가 발생하고, TCP 연결이 완료된다. 이때 TCP 연결이 완료되면 서버는 운영체제의 백로그 큐(backlog queue)라는 곳에 클라이언트와 서버의 TCP 연결 정보를 보관한다. 백로그 큐는 연결 요청을 잠시 대기시키는데, 이 큐에 담긴 연결은 서버 애플리케이션이 accept 할 때까지 대기하게 된다.

 

 

 

톰캣 내부 구현 상으로는 서버 소켓으로써 serverSocket.accept()를 수행하는 Acceptor 컴포넌트가 존재하며, 그 결과로 NIO 방식으로 소켓 연결을 처리하는 채널 중 하나인 NIO Channel(Non-blocking I/O 방식으로 클라이언트와의 네트워크 연결을 처리하는 객체)를 얻게 된다. 이후 Acceptor는 이를 내부적으로 PollerEvent라는 이벤트로 캡슐화하여 이벤트 큐에 이를 등록한다.

그러면 Poller 컴포넌트는 이벤트를 탐지하고 추가적인 처리를 진행한다. 이는 기본적으로 NIO 방식이기 때문에 하나의 스레드가 여러 클라이언트와의 연결을 비동기적으로 처리할 수 있다. 만약 클라이언트가 데이터를 보냈다면 읽기 이벤트를 감지하고, 서버가 데이터를 보낼 준비가 되면쓰기 이벤트를 처리하는 방식으로 Poller는 소켓 채널의 상태 변화를 감지하여 읽기/쓰기 작업을 비동기적으로 수행할 수 있도록 한다.

Poller가 감지하는 읽기/쓰기 작업은 실질적으로 작업 스레드(Worker)에 의해 수행된다. 작업 스레드는 우리의 비즈니스 로직을 실행하는 스레드이며, 앞서 얘기한대로 스레드 풀로 관리된다. 스프링 애플리케이션 로그에 출력되는 http-nio-8080-exec-? 가 이 Worker 스레드를 의미한다.

 

 

 

이러한 구조로 인해 톰캣 서버 설정 중에 accept-count라는 것이 존재한다. 일반적으로 연결이 수립되어 톰캣이 관리하는 커넥션의 수는 실제 처리 가능한 작업의 수보다 많다. 따라서 대기하는 요청들이 생길 수 있는데, 톰캣은 최대로 대기 가능한 연결의 수를 지정하고, 그 이상의 연결이 맺어지려고 하면 정책에 맞게 처리가 된다. 만약 모든 작업 스레드들이 지나치게 오래 처리중이라면, 요청 대기열에서 요청들이 계속해서 대기하게 되고, 요청이 밀리면서 서비스에 문제가 상황이 생길 수 있는 것이다. 따라서 올바른 톰캣 설정이 매우 중요한데, 관련한 자세한 내용은 다음 포스팅에서 다루니 반드시 참고하도록 하자.

 

 

 

 

[ 스레드 풀을 통한 작업 스레드 관리의 필요성 ]

톰캣은 기본적으로 멀티 스레드 모델로 구현되어 있으며, 이는 각각의 사용자 요청을 1개의 스레드가 처리하는 thread-per-request 방식으로 동작함을 의미한다. 수립 가능한 연결의 수와 별개로 작업들을 처리하는 작업 스레드가 여러 개 존재하여 하나의 요청이 하나의 작업 스레드에 의해 처리되는 것이다. 자바에서 스레드란 기본적으로 운영 체제 스레드에 해당하기 때문에, 매번 요청을 처리하기 위해 새로운 스레드를 생성하게 되면 여러 가지 문제가 생길 수 있다.

  • 스레드 라이프 사이클 문제: 스레드의 생성과 종료에 많은 시간이 소요됨
  • 자원 낭비: 스레드마다 각자 독립적인 스택 영역이 필요한데, 많은 수의 스레드를 생성하면 지나치게 많은 메모리와 기타 시스템 리소스를 사용하며, 스레드 수가 많으면 스레드 간 컨텍스트 스위칭에 따른 비용이 증가함
  • 안정성 문제: 모든 시스템에는 생성할 수 있는 스레드의 개수가 제한되어 있으며, 만약 제한된 양을 모두 사용하고 나면 OutOfMemoryError가 발생하여 안정성이 떨어질 수 있음

 

 

따라서 톰캣은 여러 개의 스레드를 미리 생성해두고, 스레드가 처리할 작업이 생기면 해당 스레드에 처리를 위임하는 스레드 풀을 활용한다. 여러 스레드가 미리 생성되어 있기 때문에 스레드의 생성과 종료에 대한 시간을 줄일 수 있으며, 스레드 풀 내의 스레드 수도 일정하게 관리되기 때문에 불필요하게 많은 메모리를 소비하지 않게 된다. 즉, 스레드의 재사용을 통해 여러 가지 문제를 해결할 수 있는 것이다. 톰캣은 내부적으로 이를 위해 ThreadPoolExecutor라는 도구를 통해 처리하고 있다.

 

 

 

 

2. 멀티 스레드를 관리하는 ThreadPoolExecutor


[ 스택 트레이스를 통한 ThreadPoolExecutor의 개략적 이해 ]

다음은 톰캣의 작업 스레드에 대한 정보에 해당한다. 작업 스레드의 이름은 http-nio-8080-exec-8이며, 현재 WAITING 상태인 것으로 보아 처리할 요청이 없어서 대기중임을 알 수 있다. 그 외에도 스택 트레이스 정보를 통해 ThreadPoolExecutor라는 도구를 통해 스레드 풀 뿐만 아니라 TaskQueue 라는 것이 활용됨 역시 확인할 수 있다.

"http-nio-8080-exec-8" #163 daemon prio=5 os_prio=31 tid=0x0000000136041000 nid=0x16003 waiting on condition [0x000000031fbbe000]
   java.lang.Thread.State: WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
	- parking to wait for  <0x000000068d6536c0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
	at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
	at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
	at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:141)
	at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:33)
	at org.apache.tomcat.util.threads.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1114)
	at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1176)
	at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
	at java.lang.Thread.run(Thread.java:750)

 

 

ThreadPoolExecutor는 내부적으로 크게 2가지로 구성되는데, 이를 그림으로 표현하면 다음과 같다.

 

 

이 중에서 먼저 스레드 풀은 스레드를 재사용하기 위해 사용됨을 앞서 확인하였다. 그렇다면 여러 개의 스레드가 스레드 풀에 존재하는 상황에서, 특정한 작업을 처리해야 할 때 어떤 스레드에게 해당 작업을 할당해야 할까? ThreadPoolExecutor는 생산자-소비자 패턴(Consumer-Producer Pattern)을 기반으로 이 문제를 해결하고자 중간에 작업을 보관하는 작업 큐(Task Queue)를 도입하게 되었다. 이때 사용되는 큐의 구현체는 BlockingQueue 큐 중 하나인 LinkedBlockingQueue이다. BlockingQueue는 스레드가 작업을 꺼내서 소비하려고 할 때, 작업이 없으면 작업이 생산될 때까지 블로킹하며 대기하는 형태로 동작한다. 그러다가 톰캣의 Poller를 통해 작업이 ThreadPoolExecutor로 생산(produce)되면, 내부의 스레드 풀의 특정 스레드는 이를 소비(consume)하여 처리하게 된다. 블로킹 큐는 애플리케이션이 안정적으로 동작하도록 만들고자 할 때 요긴하게 사용할 수 있는 도구이다. 블로킹 큐를 통해 처리할 수 있는 양보다 훨씬 많은 작업이 생겨 부하가 걸리는 상황에서도 작업량을 조절해 애플리케이션이 안정적으로 동작하도록 유도할 수가 있다.

참고로 이때의 상황은 멀티 스레드 기반이기 때문에 작업 큐에 동시성 문제가 생길 수 있는데, BlockingQueue는 기본적으로 동시성이 보장되는 자료구조이며, LinkedBlockingQueue는 여러 BlockingQueue들 중에서 크기가 무한대이거나 고정된 구조를 가진다는 점에서 차이가 있다. 이러한 구조를 그림으로 표현하면 다음과 같다.

 

 

대량의 요청을 처리하는 경우에는 ThreadPoolExecutor 설정이 매우 중요해진다. 따라서 ThreadPoolExecutor에 대한 내부 구현을 정확하게 이해하고 올바른 값을 설정할 수 있어야 하는데, 다음 포스팅에서는 ThreadPoolExecutor에 대해 자세히 알아보도록 하자.

자바는 기본적으로 ThreadPoolExecutor 구현을 제공하지만, 톰캣은 몇 가지 기능 확장을 위해 별도의 ThreadPoolExecutor를 자체적으로 구현한다. 하지만 내부적으로 정책이 거의 동일하기 때문에 큰 차이는 없다고 봐도 된다.

 

 

 

[ ThreadPoolExecutor의 동작 방식 이해하기 ]

앞서 설명하였듯 ThreadPoolExecutor는 크게 작업 큐와 스레드 풀로 구성되며, 이를 위한 5개의 기본 속성들을 생성자로 받고 있다.

  • corePoolSize : 스레드 풀의 기본 스레드의 수
  • maximumPoolSize : 스레드 풀의 최대 스레드 수
  • keepAliveTime , TimeUnit unit : 기본 스레드 수를 초과해서 만들어진 스레드의 생존 대기 시간, 해당 시간 동안 처리할 작업이 없으면 초과 스레드는 제거됨
  • workQueue: 작업을 보관할 블로킹 큐(BlockingQueue)

 

 

먼저 다음과 같은 파라미터로 생성된 ThreadPoolExecutor가 존재한다고 하자. 이때 LinkedBlockingQueue는 여러 BlockingQueue들 중에서 크기가 무한대이거나 고정된 구조를 가지는데, 크기를 1로 지정하였으므로 최대 1개의 작업을 보관할 수 있다.

new ThreadPoolExecutor(2, 3, 0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(1));

 

 

이러한 상황에서 A~F 6개의 작업이 ThreadPoolExecutor에 생산되면(produce) ThreadPoolExecutor는 어떠한 방식으로 작업을 처리하게 될까? ThreadPoolExecutor의 기본적인 동작 방식을 요약 및 정리하면 다음과 같다.

  1. A 작업을 처리하기 위한 스레드를 스레드 풀에 생성하고, 해당 스레드가 작업의 처리를 진행함
  2. B 작업을 처리하기 위한 스레드를 스레드 풀에 생성하고, 해당 스레드가 작업의 처리를 진행함
  3. C 작업에 대해 corePoolSize 크기 만큼의 스레드가 스레드 풀에 생성되었으므로, 기본적으로 추가적 스레드를 생성하지는 않고 큐에 작업을 보관 시도함. 큐가 현재 비어있으므로 C 작업은 큐에 보관됨
  4. D 작업에 대해 corePoolSize 크기 만큼의 스레드가 스레드 풀에 생성되었고, 큐 역시 꽉찬 상태이므로 초과 스레드를 생성하고, 해당 스레드가 작업의 처리를 진행함
  5. E 작업에 대해 corePoolSize 크기 만큼의 스레드가 스레드 풀에 생성되었고, 큐 역시 꽉찼으며, maximumPoolSize 만큼 스레드가 생성되었으므로 작업을 처리할 수 없으므로, 예외 정책에 따라 RejectedExecutionException 에러를 발생시킴
  6. F 작업에 대해 corePoolSize 크기 만큼의 스레드가 스레드 풀에 생성되었고, 큐 역시 꽉찼으며, maximumPoolSize 만큼 스레드가 생성되었으므로 작업을 처리할 수 없으므로, 예외 정책에 따라 RejectedExecutionException 에러를 발생시킴
  7. 시간이 지나고 A~D 모든 작업이 처리 완료됨, 이때 생성된 초과 스레드는 설정된 시간 만큼이 지날 동안 추가적인 작업을 처리하지 않으면 소멸됨(단, 작업을 처리할 때 마다 시간은 계속 초기화됨)

 

 

이러한 동작 방식을 정리하면 다음과 같다.

  1. 작업 요청이 들어오면, core 사이즈 만큼 스레드를 생성함
  2. core 사이즈를 초과하면 큐에 작업을 넣음
  3. 큐를 초과하면 max 사이즈 만큼 스레드(초과 스레드)를 만들어 실행시키는데, 이는 설정된 시간을 넘어서도 활용되지 않으면 소멸됨
  4. 스레드의 수가 max 사이즈를 초과하면 정책에 따라 처리함(기본적으로 예외를 발생시킴)

 

 

 

하지만 톰캣은 ThreadPoolExecutor를 생성할 때, 자체적으로 개발한 TaskQueue라는 클래스를 workQueue 파라미터로 전달한다. TaskQueue의 구현에 의해 처리 방식이 조금 다른데, 이를 정리하면 다음과 같다.

  1. 작업 요청이 들어오면, core 사이즈 만큼 스레드를 생성함
  2. core 사이즈를 초과하면 max 사이즈 만큼 스레드(초과 스레드)를 만들어 실행시키는데, 이는 설정된 시간을 넘어서도 활용되지 않으면 소멸됨
  3. 스레드의 수가 max 사이즈를 큐에 작업을 넣음
  4. 큐가 꽉 차면 정책에 따라 처리함(기본적으로 예외를 발생시킴)

 

 

 

이러한 차이를 불러오는 부분은 작업을 제출하는 ThreadPoolExecutor의 execute 내부에서 TaskQueue를 활용하는 부분에 있다. 톰캣의 TaskQueue가 아닌 다른 일반적인 작업 큐를 사용했다면, 다음의 workQueue.offer() 부분에서 작업의 제출에 성공하게 된다. 따라서 maxPoolSize를 참고하여 초과 스레드를 생성하기 이전에 먼저 작업 큐를 채우는 것이다.

하지만 톰캣의 TaskQueue offer 내부 구현은 아직 초과 스레드를 생성할 수 있는 상황이라면, 작업의 제출을 거부한다. 따라서 먼저 초과 스레드를 생성하여 활용하게 되고, 이후에 초과 스레드를 더 이상 생성할 수 없는 상황이 되면 작업 큐에 작업을 쌓아두게 된다. 따라서 이러한 차이에 대해 반드시 이해하고 있어야 한다. 그리고 초과 스레드를 사용하게 되면 자바의 스레드 상태가 바뀌는 부분 역시 중요한데, 해당 부분은 해당 포스팅에서 참고하도록 하자.

 

 

 

 

그 외에도 중요한 부분이 있는데, ThreadPoolExecutor는 기본적으로 스레드 풀의 초기화 등을 진행하지 않는다. 따라서 작업이 제출되면 먼저 스레드를 생성하고 작업의 처리가 이후에 진행되는데, 요청량이 많은 서버라면 이러한 부분이 응답 지연을 일으킬 수 있다. 따라서 다음의 코드를 통해 기본 스레드를 스레드 풀에 미리 생성해둘 필요가 있다.

threadPoolExecutor.prestartAllCoreThreads()

 

 

또한 corePoolSize 크기 만큼의 스레드가 스레드 풀에 생성되었고, 큐 역시 꽉찼으며 maximumPoolSize 만큼 스레드가 생성되었다면 설정된 예외 정책(RejectedExecutionHandler)에 따라 이를 처리해야 하는데, 크게 3가지 방향이 존재한다. 참고로 기본적인 설정은 예외가 발생하는 것이다.

  • 예외 발생(AbortPolicy, RejectPolicy): 새로운 작업에 대해 예외(RejectedExecutionException) 을 발생시키며, 기본 정책에 해당함
  • 버림(DiscardPolicy, DiscardOldestPolicy): 새로운 작업을 조용히 버림
  • 직접 실행(CallerRunsPolicy): : 새로운 작업을 제출한 스레드가 해당 작업을 직접 실행함

 

 

 

 

관련 포스팅

  1. 멀티 스레드 기반으로 다중 요청을 처리하는 톰캣(Tomcat)의 구조와 동작 방식
  2. 올바른 스프링 부트 톰캣 애플리케이션 설정 가이드(SpringBoot Tomcat Configuration)
  3. 스프링 부트 톰캣 애플리케이션의 Graceful Shutdown 동작 방식(Spring Boot Tomcat Graceful Shutdown)

 

 

 

 

참고 자료

 

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함