이전 글(Java로 HTTP 서버 구현 - (2) 스레드 풀과 BIO, Java로 HTTP 서버 구현 - (1) Thread-per-Request 모델)에서, Tomcat BIO 로의 구현 과정을 통해서, Single-Thread -> Thread-Create-per-Request -> ThreadPool & BIO 방식으로 서버를 구현하는 과정에 대해 알아 보았다.
이번 글에서는 NIO(Non-Blocking I/O)란 무엇인지, 최신 톰캣 서버에서는 어떻게 요청을 처리하는 지에 대해 간단히 알아 보려 한다.
Accpetor, ThreadPoolExecutor, HTTP 요청을 처리하기 위해서는 어떤 과정을 거쳐야 하는 지 등에 대해서는 이전 글에서 설명했으니, 이전 글을 보지 않았다면 보고 오길 추천한다.
BIO 방식과의 핵심적인 차이점은 길어지는 I/O 작업을 워커 스레드가 Block되며 기다릴 필요가 없다는 점이다.
Acceptor & ThreadPoolExecutor 구조의 BIO 서버와는 달리, NIO 서버는 Acceptor, Poller, ThreadPoolExecutor, 스레드 풀을 이용하여 실제 워커 스레드가 소켓의 data를 읽을 때, 읽을 정보가 있는 소켓만을 전달받아 처리하게 되어 스레드가 Block되는 시간을 현저히 단축시킬 수 있다.
Acceptor는 accept()한 소켓을 Poller에 등록하고,
Poller는 등록된 소켓을 내부의 Selector의 이벤트에 등록한다.
Selector는 등록된 이벤트들을 감시하며, "read()할 바이트가 있다"는 정보를 OS로부터 전달받으면(Linux syscall epoll), 해당 소켓을 Worker에게 전달한다.
현재 글에서는 NioEndPoint의 구현을 중심으로 설명하겠다.
Acceptor의 동작 방식은 BIO 방식과 크게 다르지 않다.
다만, accept()로 가져온 소켓을 ThreadPoolExecutor에 전달하는 것이 아니라, Poller에 등록하는 것이 BIO와의 핵심적인 차이다.
// org.apache.tomcat.util.net.Acceptor
public void run() {
while (!this.stopCalled) {
/// ... 생략
try {
socket = this.endpoint.serverSocketAccept();
} catch (Exception var12) {
/// ... 생략
if (!this.stopCalled && !this.endpoint.isPaused()) {
if (!this.endpoint.setSocketOptions(socket)) {
this.endpoint.closeSocket(socket);
}
} else {
this.endpoint.destroySocket(socket);
}
}
/// ... 생략
this.state = Acceptor.AcceptorState.ENDED;
}
}
// org.apache.tomcat.util.net.NioEndPoint;
protected boolean setSocketOptions(SocketChannel socket) {
/// ... 생략
this.poller.register(socketWrapper);
/// ... 생략
}
NioEndPoint#setSocketOptions()와 Acceptor#run()을 가져왔다. 핵심이 아닌 부분은 전부 생략했다.
Poller는 NIO의 핵심인 Selector를 이용해서 Acceptor로부터 전달받은 소켓을 등록하고, 등록한 소켓들의 I/O 이벤트를 감시한다.
다음의 과정으로 동작한다.
Acceptor가 새로운 SocketChannel을 Poller에 넘겨주면,Poller는 내부적으로 Selector에 해당 소켓을 등록한다.SocketChannel을 Worker(ThreadPoolExecutor)에게 전달하여 처리한다. // org.apache.tomcat.util.net.Poller
public void run() {
// 생략 ...
// 현재 이벤트가 발생한 key가 있는 지를 확인
Iterator<SelectionKey> iterator = this.keyCount > 0 ? this.selector.selectedKeys().iterator() : null;
// 이벤트가 발생했다면, 해당 채널을 Worker에게 넘겨서 실행
while(iterator != null && iterator.hasNext()) {
SelectionKey sk = (SelectionKey)iterator.next();
iterator.remove();
NioEndpoint.NioSocketWrapper socketWrapper = (NioEndpoint.NioSocketWrapper)sk.attachment();
if (socketWrapper != null) {
this.processKey(sk, socketWrapper);
}
}
// 생략 ...
}
코드를 읽을 때 참고할 점은, select()가 실제로 호출하는 syscall은 OS 종속적이기에, selectorImpl.doSelect(timeout) 형식으로 구현되어 있다는 점이다. JNI를 이용해서 OS마다 다른 syscall로 구현되어 있을 것이다.
Poller가 이벤트가 발생한 소켓에 대해 작업(Runnable)을 넘기면, Worker는 이를 실행한다.
NioEndPoint 에서는 소켓 I/O를 워커 스레드에서 직접 수행한다.
HttpServletRequest 객체로 변환한다.Worker가 데이터를 완전히 읽지 못했거나, 추가로 전송될 데이터가 있을 경우에는 해당 소켓 채널을 Poller에 재등록HttpServletRequest가 완성되면, CoyoteAdapter 등을 통해 서블릿 컨테이너나 Spring MVC와 같은 상위 레이어로 요청을 전달.이 과정은 BIO Worker와 거의 비슷하기에, 코드 첨부는 생략하겠다. 하지만, BIO와 달리 “읽을 준비가 되었는지” 미리 확인하고 나서 워커가 일을 하기 때문에, 워커 스레드가 ‘빈 소켓 데이터’를 기다리느라 block되는 시간이 훨씬 줄어들게 된다.
Acceptor가 클라이언트 연결을 가져와서 SocketChannel을 Poller에 등록Poller는 해당 소켓 채널을 Selector에 등록하고, select()를 이용해서 I/O 이벤트 감지select()를 통해 "읽거나 쓸 준비가 완료되었다" 라는 응답을 OS로부터 받으면, 해당 소켓 채널을 Worker에게 전달Worker는 해당 소켓 채널을 받아 요청을 처리Poller에 등록실제 톰캣 서버에서는 요청을 위와 같은 흐름으로 처리하지만, 몇 가지를 추가로 설정할 수 있다.
예를 들어, 실행할 스레드 개수나, 최대 커넥션 개수 등을 설정할 수 있다. 이런 인자들에 대해서 알아보자.
maxConnections : 서버가 동시에 유지할 수 있는 최대 TCP 연결 수Listen 중인 서버 소켓의 백 로그 큐에 저장된다. acceptCount : Listen 중인 서버 소켓의 백 로그 큐의 길이Listen syscall을 할 때, backlog 큐 길이를 얼마로 할 지 미리 설정할 수 있다. 해당 백 로그 큐도 꽉 찬 경우 CONNECTION_REFUSED 에러가 난다. maxThreads : 스레드 풀에 생성될 수 있는 최대 스레드의 개수minSpareThreads : 서버가 실행되는 시점에 생성할, 최소한으로 유지되는 스레드의 개수minSpareThreads가 모두 요청을 처리하는 중이라면, maxThreads개 까지 스레드를 생성한다.maxIdleTime : 유휴 스레드가 제거되는 시간minSpareThreads 개수를 초과하는 스레드들이 유휴 상태로 maxIdleTime 이상 유지되는 경우, 해당 스레드들은 폐기된다. KeepAliveTimeout : Keep-Alive 요청에 대한 Connection을 유지하는 시간본인은 백엔드 작업을 하며 지금껏 이 값들을 실제로 튜닝해본 경험은 없다. 그러나 각 값들이 어떤 역할을 하는지, 톰캣 서버가 실제로 요청을 어떻게 serving하는 지를 이해한다면 실제 튜닝이 필요한 경우 큰 도움이 될 것이다.
지금까지는 소스 코드를 까 보고, 톰캣 docs나 인터넷의 블로그들을 찾아보며 톰캣 NIO 서버의 동작 과정에 대해 알아 보았다. 그렇다면 실제로 @SpringBootApplication이 내가 적은 글대로 동작할까?
Intellij의 DEBUG 기능을 이용해서 확인해보자!
스프링 기본 설정에 대한 자세한 내용들은 생략하겠다.
http://localhost:8080/info/test/ 요청을 받을 수 있도록,
Controller, Service 정도만 만들었고, 컨트롤러가 실행하는 Service 의 함수는 200 응답을 주도록 설정했다.
먼저, 톰캣 서버 코드에서 내가 지나갈 것으로 예상되는 곳에 디버그 포인트를 찍어 보았다.
org.apache.tomcat.util.net.Acceptor#run() socket = this.endpoint.serverSocketAccept(); org.apache.tomcat.util.net.NioEndpoint.Poller#run()this.keyCount = this.selector.selectNow();Iterator<SelectionKey> iterator = this.keyCount > 0 ? this.selector.selectedKeys().iterator() : null;org.apache.tomcat.util.threads.ThreadPoolExecutor#executeInternal()if (isRunning(c) && this.workQueue.offer(command)) {org.apache.tomcat.util.net.Acceptor#run() 의 accept()하는 파트(예측 성공)
org.apache.tomcat.util.net.NioEndpoint.Poller#run() 의 select()를 호출하는 파트(예측 성공)
당연히, Accpetor와 Poller는 요청이 들어오기 전에도 무한 루프를 돌면서 이벤트를 감시할 것이다.
org.apache.tomcat.util.net.Acceptor#run() 의 accept()하는 파트 (예측 성공)
org.apache.tomcat.util.threads.ThreadPoolExecutor#executeInteral() 에서 exeucte()하는 파트 (예측 성공)
org.apache.catalina.connector#service() 에서 톰캣 파이프라인을 거치는 파트
org.apache.catalina.core.ApplicationFilterChain#doFilter()에서 적용된 필터들을 순회하는 파트 (스프링 부트 로직)
org.springframework.web.servlet.DispatcherServlet#doDispatch()에서 DispatcherServlet을 이용해서 요청을 처리하는 파트(스프링 부트 로직)
DispatcherServlet에서 요청을 처리하는 방법에 대한 내용에 대해서도 글을 적어볼 생각이다. 다만, 이 글에서는 흐름에서 벗어나기에 생략하겠다.
지금까지 NIO 즉, Non-Blocking I/O 방식의 톰캣 서버의 동작 방식에 대해 알아보았다. 마지막에는 실제로 DEBUG를 찍어 보면서 동작 과정을 재확인했다.
그러나 OS에서 지원하는 비동기 I/O를 이용하여 만들어진 서버(커낵터 - Http11Nio2Protocol)도 존재한다. 이를 NIO2라고 하며, 개발자는 NIO 커낵터와 NIO2 커낵터를 선택하여 설정할 수 있다.
그러나, 톰캣의 기본 설정은 NIO2가 아니라 NIO이다. 이유에는 여러 이유가 있겠지만, NIO 또한 NIO2 방식과 비교해서 충분히 안정적이고 성능도 우수하기 때문이 아닐까 싶다. NIO2를 기본으로 채택하였을 때 얻는 이득이 일반적인 웹 환경에서 크지 않기 때문에 굳이 최신 방식을 채택하지 않는 것이라고 생각한다.
3개의 글 - Java로 HTTP 서버 구현 (1), Java로 HTTP 서버 구현 (2), 그리고 이 글 - 을 통해서 톰캣 서버의 동작 과정 및, HTTP 서버가 어떻게 발전해 왔는 지를 간략하게 알아 보았다.
그런데, 이 글의 발단은 백엔드 개발에 왜 스프링 부트를 사용하는지 를 설명하기 위해서였다. 지금까지의 설명을 통해서 톰캣 서버가 어떻게 동작하는 지, NIO - BIO 등이 무엇인 지는 충분히 이해했을 것이다. 그러나, 비즈니스 로직을 다루는 개발자가 서버에 대한 이해를 넘어서 진짜로 서버를 구현해야 한다면, 그 것이 진짜 바람직한 방향일까?
스프링 부트는 내부에 톰캣(또는 다른 컨테이너)를 내장하고 있어서, 개발자는 톰캣이 제공하는 설정(예: maxThreads, keepAliveTimeout 등)만 적절히 조정하면, 동시성 대응이 가능한 네트워크 서버를 손쉽게 활용할 수 있다.
즉, “개발자는 HttpServletRequest로 추상화된 비즈니스 로직 구현에 집중하고, 톰캣(서블릿 컨테이너)과 스프링 부트가 멀티스레드 & I/O 처리를 알아서 관리한다.” 라는 스프링의 객체지향적인 이상이 여기에도 녹아있는 것이다.
스프링 부트가 제공하는 세 가지 기능 - 클라이언트 요청 관리, 응답 생성 과정 구현, DB 접근 - 중에서 클라이언트 요청 관리를 어떻게 구현했는 지에 대해서 알아 보았다.
그렇다면, 이제 만들어진 Request 객체로부터 실제로 응답을 생성하기 위해서는 어떤 과정이 필요하고, 그 과정을 스프링 부트는 어떻게 처리함으로써 개발자에게 편의를 제공하는 지에 대해서 알아보자.
https://tomcat.apache.org/tomcat-9.0-doc/index.html
https://ttl-blog.tistory.com/1498
https://velog.io/@ejung803/-0bayh7qy
디버깅에 사용한 톰캣 버젼 : 9.0.102