이전 글에서, NaiveHttpServer -> ThreadCreateHttpServer 로의 구현 과정을 통해서, 바닐라 Java를 이용해서 어떻게 클라이언트 요청을 처리할 수 있는 지, 그리고 각각의 서버의 한계는 무엇인지 간단하게 알아보았다.
이번에는, 저번 글 마지막에 소개한 스레드 풀의 개념에 대해서, 톰캣 서버의 동작 방식과 함께 설명해 보려 한다.
Tomcat의 서버 구조를 설명하려다 보니, 양이 방대해 모든 코드를 직접 작성할 수 없음을 양해해 줬으면 한다.
스레드 풀은 말 그대로 “풀(pool)” 안에 미리 일정 개수의 스레드를 만들어 두고, 요청이 들어왔을 때마다 새로운 스레드를 생성하는 대신 이미 풀에 있는 스레드를 재사용하는 개념이다. 스레드 풀을 사용하면 스레드를 동적으로 생성·소멸할 때 드는 오버헤드를 상당히 줄일 수 있다. 스레드 풀에는 다음과 같은 두 가지 중요한 특징이 있다.
이를 구현하는 방식에는 여러 방식이 있는 것으로 있지만, 먼저 Tomcat의 초기 모델이었던 BIO + 스레드 풀 구조를 상정해서 설명해 보겠다.
스레드 풀을 이용하는 서버 정도의 프로젝트를 설계할 때, Java로 HTTP 서버 구현 - (1) 글의 예시와 같이 코드를 적는 건 바람직 하지 못하다. 각자의 역할별로 구분하여 클래스로 구현하는 것이 바람직하다.
HTTP 서버를 실제로 구현한다고 할 때, 어떤 작업들이 필수적으로 수행되어야 하는 지를 알아보고, Tomcat에서 이 작업들이 각각 어떤 클래스에서 수행되는 지 알아보자.
JIoEndpoint의 Acceptor가 수행JIoEndPoint의 ThreadPoolExecutor가 수행ThreadPoolExecutor 의 Worker가 수행HttpServletRequest 형태로 바꿔 줘야 한다. Servlet이란 Java에서 HTTP 요청을 처리하는 객체를 의미한다.Http11Processor 가 해당 과정을 담당한다. DispatcherServlet이 모든 요청을 처리한다.여기에서, DispatcherServlet 이라는 개념이 등장하는데, 이는 스프링 컨테이너를 이용해서 HTTP 요청을 처리해 응답하는 클래스이다. DispatcherServlet이 어떻게 동작하는 지는 나중에 따로 설명하도록 하고, 어찌됐든 지금은 URL에 대응되는 동적인 응답이 잘 생성된다고 가정하고 넘어가자.
BIO 기반 톰캣에서 Acceptor는 말 그대로 클라이언트 연결을 수락(accept)하는 전담 스레드다. Acceptor는 서버가 구동되면, Listen 중인 포트의 ServerSocket으로부터 accept를 호출해서, 클라이언트 연결이 들어오면 스레드 풀에 전달해 주는 역할을 한다.
public class BioEndpointAcceptor implements Runnable {
private ServerSocket serverSocket;
private volatile boolean running;
@Override
public void run() {
while (running) {
try {
// 1) 새 클라이언트 연결 대기 (Blocking)
Socket socket = serverSocket.accept();
// 2) socket을 처리할 워커(Worker)에게 넘김
// 스레드 풀에서 처리되거나, 새로운 스레드가 생성되거나,
// 유휴 스레드가 없다면 작업 큐에 들어감.
workerThreadPool.submit(new BioWorker(socket));
} catch (IOException e) {
// 에러 처리, 로그 등
}
}
}
}
실제 BIO 로 구현된 Tomcat 7.x 버전을 찾아보아 구현을 가져오려 했지만, 코드가 너무 길고 핵심을 벗어나는 내용들이 많아 간략하게 요약해 보았다.
여기서의 핵심은 serverSocket.accept() 를 호출하는 Acceptor 스레드를 따로 두고, Acceptor 스레드는 계속 accept() 를 호출하면서 클라이언트 연결이 들어온 것이 있다면 스레드 풀에 전달해준다는 것이다.
스레드 풀로 전달된 소켓을 가지고 실제로 요청을 처리해야 한다. ThreadPoolExecutor 클래스는 여러 스레드에서 공유되며 전달받은 작업을 순차적으로 처리한다.
크게 execute(), addWorker, runWorker 파트로 나눌 수 있다.
BlockingQueue 에 해당 작업을 큐잉한다.BlockingQueue<Runnable>while (task = getTask()) != null 구조로 큐를 감시하면서, 작업이 들어오면 현재 스레드에서 직접 작업을 실행 public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
/*
* Proceed in 3 steps:
*
* 1. If fewer than corePoolSize threads are running, try to
* start a new thread with the given command as its first
* task. The call to addWorker atomically checks runState and
* workerCount, and so prevents false alarms that would add
* threads when it shouldn't, by returning false.
*
* 2. If a task can be successfully queued, then we still need
* to double-check whether we should have added a thread
* (because existing ones died since last checking) or that
* the pool shut down since entry into this method. So we
* recheck state and if necessary roll back the enqueuing if
* stopped, or start a new thread if there are none.
*
* 3. If we cannot queue task, then we try to add a new
* thread. If it fails, we know we are shut down or saturated
* and so reject the task.
*/
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {
w.lock();
// If pool is stopping, ensure thread is interrupted;
// if not, ensure thread is not interrupted. This
// requires a recheck in second case to deal with
// shutdownNow race while clearing interrupt
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
beforeExecute(wt, task);
try {
task.run();
afterExecute(task, null);
} catch (Throwable ex) {
afterExecute(task, ex);
throw ex;
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}
java.util.concurrent.ThreadPoolExecutor의 구현을 가져왔다. 설명까지 자세히 적혀있어서 코드를 첨부했다. Tomcat의 BIO 방식의 ThreadPoolExecutor는 이 클래스를 extends한다.
전달된 소켓으로부터 read()를 호출하여 클라이언트가 요청한 HTTP 메시지에 대한 바이트 스트림을 가져오고, 해당 메시지를 Request, Response 형태로 변환한다. 이후, 요청을 DispathcerServlet 으로 전달하여 동적인 응답을 생성한다.
Http11Processor의 Http 메시지 파싱 과정은 핵심이 아니기에 코드 첨부는 생략하겠다.
Tomcat 서버가 시작되면, Acceptor 스레드가 생성되고, Worker 스레드가 corePoolSize만큼 생성된다.
Acceptor 스레드는 지정된 포트에서 꾸준히 accept()를 호출함으로써 클라이언트의 연결을 기다린다. Worker 스레드는 while 문을 돌면서 자신에게 주어진 작업이 있나, 즉 작업 큐에 작업이 들어오는지를 확인한다. Acceptor 스레드는 ThreadPoolExecutor#execute()를 통해서 작업을 전달해 준다.maxPoolSize보다 작다면, 새 스레드를 생성해 작업을 할당한다.maxPoolSize라면, 작업 큐에 작업을 큐잉한다.maxQueueSize라면, 해당 작업을 거부한다(HTTP 503).Worker 스레드는 Http11Processor을 이용해 메시지를 파싱하고, DispatcherServlet으로 요청을 전달해 응답을 생성한다.자, 이제 복잡한 Tomcat BIO 서버의 구조와 간략한 구현방법에 대해서 알아 보았다. 그런데, 아까부터 적혀 있는 "BIO"란 무엇이고, 이렇게 잘 구현된 서버가 무슨 문제점을 가지고 있다는 것일까?
먼저, BIO는 Blocking I/O를 뜻한다. Blocking I/O란, syscall을 통해서 I/O를 커널에 요청할 때, 해당 스레드는 I/O 작업이 완료될 때까지 Block(대기)한다는 뜻이다.
예를 들어, 스레드가 read()를 요청한 다음에는, 해당 read()에 대한 응답을 커널이 주기 전까지는 스레드가 다음 동작을 하지 못하고 기다리게 된다.
Tomcat의 BIO 서버는 스레드 풀을 사용함으로써 Java로 HTTP 서버 구현 - (1) 글에서의 ThreadCreateHttpServer 에 비해서 스레드 생성 작업에 대한 리소스를 크게 줄일 수 있었다.
그런데 실제 웹 환경에서 트래픽이 많아지자 Tomcat의 BIO 서버에도 문제점이 드러나기 시작했다.
accept() 호출마다 소켓을 스레드가 가져가다 보니, 생성된 스레드 개수 이상의 클라이언트와는 동시에 연결을 맺을 수가 없었다. read()를 호출해도, read()에 대한 응답이 오기까지 스레드는 Block되는 문제점이 있었음. 이 문제를 극단적으로 보여주는 예가 바로 “C10K 문제(C10K Problem)”다. 동시에 1만 개의 연결(10K)을 처리하기가 어렵다는 지적에서 비롯된 용어다. BIO 모델처럼 연결당 스레드를 할당하는 구조는 스레드 수와 처리할 수 있는 동시 연결 수가 사실상 1:1로 대응하므로, 대량 트래픽 환경에서 큰 성능 저하가 발생한다.
C10K 문제를 해결하기 위해서는 블로킹되지 않는 I/O(Non-blocking I/O) 모델을 적용해야 한다. 자바는 NIO(New I/O) 라이브러리를 통해서, 커널에 I/O를 요청해놓고 바로 리턴 받은 뒤, I/O가 준비된 소켓만 골라서 처리할 수 있는 Selector 기반 방식을 지원한다.
톰캣도 BIO 한계를 인식하고, NIO 커넥터(Connector)나 그 이후 AIO(Async I/O) 커넥터 등을 발전시키면서 고성능 서버를 구축할 수 있게 했다. 다음 글에서는 톰캣이 NIO를 어떻게 활용하고 있는지 살펴보도록 하겠다.