이전 글까지는 SpringBoot 가 부팅될 때 일어나는 톰캣 설정과 초기화를 알아보았습니다. 해당 글에서는 톰캣이 논블락킹으로 요청을 받아내고 처리하는 과정과 함께, 왜 톰캣이 NIO 여도 Spring MVC 는 블락킹 서버인지 알아보도록 하겠습니다.
Tomcat의 논블로킹(NIO) 소켓 처리
Tomcat은 Java NIO(Non-blocking I/O)를 사용하여 소켓을 처리함으로써 높은 성능과 효율적인 자원 관리를 실현합니다. 이번 글에서는 Tomcat이 어떻게 논블로킹 방식으로 요청을 처리하는지에 대해 코드 레벨에서 자세히 알아보겠습니다.
Tomcat 의 전체적인 구조를 한번 보도록 하겠습니다. 먼저, Coyote Connector 는 요청을 받아 프로토콜 스펙 (EndPoint) 에 맞게 프로세싱 해주고 (Processor) 그리고 Servlet Container 인 Catalina 에 넘기기 위한 전처리를 해줍니다.
이때 EndPoint 가 무엇으로 정의되어 있는지가 중요합니다. SpringBoot 는 이미 어떠한 앤드포인트를 쓸지 디폴트 정의를 해두었습니다. org.springframework.boot.web.embedded.tomcat 의 TomcatServletWebServerFactory 클래스를 확인해보면 Http11NioProtocol 를 디폴트 프로토콜로 사용함을 확인할 수 있습니다. 그리고 Http11NioProtocol 클래스는 내부적으로 NioEndPoint 를 주입받아 사용하기 때문에 우리는 이제 NioEndPoint 클래스를 중점적으로 보면 됩니다.
NioEndpoint 초기화 및 시작
먼저, NioEndpoint가 초기화되고 시작되는 과정을 살펴보겠습니다. NioEndpoint는 bind 메소드는 서버 소켓을 열고, startInternal 메소드는 Poller와 Acceptor를 초기화하여 시작합니다. 이 때 Poller는 I/O 이벤트를 감시하고, Acceptor는 새로운 연결을 수락합니다.
public class NioEndpoint extends AbstractJsseEndpoint<NioChannel, SocketChannel> {
private ServerSocketChannel serverSock;
private Poller[] pollers;
private Acceptor<NioChannel> acceptor;
protected void initServerSocket() throws Exception {
...
serverSock.bind(sa, getAcceptCount());
...
serverSock.configureBlocking(true); # 왜 True 인지는 이후에 설명
}
@Override
public void startInternal() throws Exception {
...
// Create worker collection
if (getExecutor() == null) {
createExecutor();
}
// Start poller thread
poller = new Poller();
Thread pollerThread = new Thread(poller, getName() + "-Poller");
startAcceptorThread();
}
}
Acceptor
Acceptor는 클라이언트로부터 새로운 연결을 수락하는 역할을 합니다. 수락된 연결은 NioChannel 객체로 래핑됩니다.
Acceptor는 새로운 소켓 연결을 받아들이고, 해당 소켓에 대한 설정을 완료한 뒤, Poller에 등록합니다.
protected class Acceptor<U> implements Runnable {
@Override
public void run() {
while (!stopCalled) {
try {
U socket = endpoint.serverSocketAccept();
if (endpoint.setSocketOptions(socket)) {
endpoint.closeSocket(socket);
}
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
}
}
}
}
class NioEndPoint {
@Override
protected boolean setSocketOptions(SocketChannel socket) {
NioSocketWrapper socketWrapper = null;
try {
// Set socket properties
// Disable blocking, polling will be used
socket.configureBlocking(false); // 왜 blocking false 인지 이후에 설명 예정
...
poller.register(socketWrapper);
return true;
} catch (Throwable t) {
...
}
// Tell to close the socket if needed
return false;
}
}
Poller
Poller는 비동기 I/O 이벤트를 감시하고 처리하는 역할을 합니다. Selector를 사용하여 소켓의 읽기/쓰기 이벤트를 감시합니다.
Poller는 Selector를 사용하여 소켓의 읽기/쓰기 이벤트를 감시합니다. 이벤트가 발생하면 processKey 메서드를 통해 적절한 처리를 수행합니다.
public class Poller implements Runnable {
private Selector selector;
private final SynchronizedQueue<PollerEvent> events = new SynchronizedQueue<>();
@Override
public void run() {
while (running) {
try {
// 이벤트 감시
if (!close) {
hasEvents = events();
}
if (selector.select(selectorTimeout) > 0) {
Iterator<SelectionKey> iterator = keyIterator();
while (iterator.hasNext()) {
SelectionKey sk = iterator.next();
NioSocketWrapper attachment = (NioSocketWrapper) sk.attachment();
processKey(sk, attachment);
}
}
} catch (Throwable x) {
// 예외 처리
}
}
}
protected void processKey(SelectionKey sk, NioSocketWrapper attachment) {
try {
if (sk.isReadable() || sk.isWritable()) {
processSocket(attachment, SocketEvent.OPEN_READ, true);
}
} catch (CancelledKeyException ckx) {
cancelledKey(sk);
}
}
}
Socket 처리
최종적으로 소켓 처리는 SocketProcessor에 의해 수행됩니다.
public boolean processSocket(SocketWrapperBase<S> socketWrapper,
SocketEvent event, boolean dispatch) {
try {
...
Executor executor = getExecutor();
if (dispatch && executor != null) {
executor.execute(sc);
}
...
}
SocketProcessor는 소켓 이벤트를 처리하고, 비즈니스 로직으로 넘기기 전까지의 모든 작업을 수행합니다. 이 과정을 통해 소켓의 비동기적인 읽기/쓰기를 처리합니다. Worker Thread 가 할당됩니다.
Worker 쓰레드가 요청을 받은 이후의 프로세스
1. Worker 쓰레드 할당
이전에 Acceptor 쓰레드가 요청을 받아 `Worker 쓰레드`로 할당한다고 언급했습니다. 서버에 요청이 오면 Worker 쓰레드를 생성하여 해당 요청을 처리합니다.
2. Coyote Adapter : Catalina 엔진으로 요청 전달
Tomcat 엔진은 Service 내부에서 동작하는 구성 요소로, 클라이언트 요청을 처리하고 적절한 서블릿으로 요청을 전달하는 역할을 합니다. 커넥터에서 엔진으로 바로 연결될 수 없기 때문에 중개자 역할을 `Coyote Adapter`가 합니다. Coyote 는 Http 컴포넌트로서 톰캣에 대한 TCP 프로토콜을 지원하는 역할을 하고 Catalina 는 서블릿 컨테이너입니다.
3. ApplicationFilterChain
package org.apache.catalina.core 패키지에 있는 클래스입니다.
filterChain.setServlet() 메소드를 통해 `DispatcherServlet`을 등록합니다. 이제 servlet.doService()를 하면 DispatcherServlet으로 요청이 전달됩니다.
4. DispatcherServlet
디스패처 서블릿은 요청을 처리할 수 있는 핸들러(스프링 컨트롤러)를 탐색하고 처리 가능한 핸들러로 요청을 전달하여 처리합니다.
그래서 Tomcat 이 논블락킹 IO 를 제공하는데 왜 MVC 는 블록킹 서버인가요?
지금까지의 일련의 과정들은 논블로킹으로 진행됐기 때문에 MVC가 블로킹이라고 말하는 것이 이상한 상황이 되었습니다. 하지만 그럼에도 불구하고 완전한 논블로킹이 아닌 이유는 Tomcat 이 Servlet Container 로써 구현이 되어있다는 점, 즉 결국엔 Tomcat 은 Servlet 를 사용하기 때문입니다.
Servlet 를 사용하는 것 그 자체가 블락킹인 근본적인 이유는 `InputStream` 과 `OutputStream` 를 사용하고 그 자체가 블록킹으로 동작하기 때문입니다. 그렇기 때문에 아무리 요청을 받아내는 쪽에서 비동기 논블락킹 처리를 해놓아도 Servlet 를 사용하는 순간 전체적으로는 블락킹으로 동작하게 됩니다.
결론
Tomcat은 NIO를 사용하여 소켓을 비동기적으로 처리함으로써 높은 성능을 유지하고 있습니다. NioEndpoint, Acceptor, Poller, NioChannel, NioSocketWrapper, 그리고 SocketProcessor가 각각의 역할을 분담하여 효율적으로 소켓을 관리합니다. 이러한 구조를 통해 Tomcat은 많은 동시 연결을 처리할 수 있으며, 이는 웹 애플리케이션의 성능 향상에 큰 기여를 합니다.
번외편
JioEndPoint 소스코드
왜 acceptor 는 blocking, worker 는 non-blocking?
'☘️Spring' 카테고리의 다른 글
[Spring] Tomcat 1편. 스프링부트가 시작될 때 일어나는 일 (1) | 2024.07.07 |
---|---|
[Spring] 스프링 시큐리티 인증/인가 및 에러 삽질 (3) | 2024.02.04 |
[Spring] 스프링 시큐리티 기초 정리 (1) | 2024.01.21 |
[Spring] JPA N+1 문제 해결 (35) | 2023.10.30 |
[Spring] MockMvcTest vs End-to-End Tests (1) | 2023.10.26 |