C10K 문제 해결을 위한 Non-Blocking I/O 기반 처리 구조
서론
스프링부트의 동작 원리 등에 대해 공부를 하면 톰캣에 관한 이야기는 빼놓을 수가 없는 주제이다. 필자도 이전에 WAS(Web Application Server)인 톰캣이 어떠한 방법과 원리로 동작하는지 궁금해서 찾아본 적이 있다. Java NIO와 Non-Blocking I/O 방식을 기반으로 동작하며, 쓰레드풀을 통해 자원을 효율적으로 사용한다는 이야기를 듣기는 하였지만 이에 관해 더 깊이 찾아보지는 않았었다. 최근 스프링과 스프링부트의 기본 동작 원리 등을 되돌아보며 스프링부트 프로젝트 시작 시 웹 서버가 만들어지는 과정을 찾아보며 자연스럽게 톰캣에 대해서도 다시 찾아볼 수 있는 기회가 생겼다.
예전에 찾아보았을 때 모호했던 개념이었던 NIO, Non-Blocking I/O 등에 대해 자세히 알아보고, 이것이 왜 적용이 되는 것인지에 대해 계속 꼬리 질문을 이어나갔다. 따로 태블릿에 코드와 동작을 정리하였고, 이번 포스팅에서는 가장 근본적인 동작 원리와 왜 이러한 방식으로 동작하는지에 관한 내용을 서술하고자 한다.
C10K Problem
C10K(Concurrent 10K) 문제는 ‘하나의 서버가 동시에 1만명의 클라이언트 요청을 처리하여야할 때 발생하는 문제’에 관한 내용이다.
1999년, 개발자 Dan Kegel이 해당 문제를 처음 제기하였으며, 당시 서버 환경에서 극복하여야 했던 I/O 및 쓰레드 관련 문제들과 이를 해결하기 위한 방법들을 제시하였다. 현재는 처리 기술 외에도 하드웨어 자체의 성능이 매우 좋아지며 1만명 클라이언트 정도는 무리가 없다. 그러나, 1999년 당시에는 인터넷 붐이 일어나며 트래픽이 급격하게 증가하던 시기로 서버의 성능이 이를 극복하지 못하는 문제가 발생하였다.
예를 들어 클라이언트의 요청마다 쓰레드가 배치되어 1:1 구조로 처리하게 된다면, 1만명의 클라이언트 동시 요청을 처리하기 위해 1만개의 쓰레드가 필요하게 된다. 또한, Java 64bit VM의 경우에는 1개의 쓰레드 당 1MB(1024k byte)의 메모리를 사용한다고 한다. 따라서 단순 쓰레드가 할당받아야하는 메모리의 크기만 계산하더라도 10GB가 넘는다. 최근에는 하드웨어 성능이 좋아지며 64GB 혹은 그 이상의 메모리도 출시되기 때문에 이는 괜찮다고 느낄 수 있지만, 쓰레드 자체가 1만개가 있다는 것이 문제다. 우선, 소프트웨어적으로 1만개의 쓰레드를 허용하지 않을 수도 있으며, 각 쓰레드가 CPU 연산을 수행하기 위해서는 Context Switching이 발생하게 된다. Context Switching 자체의 오버헤드와 요청을 처리하는 여러 쓰레드가 자신이 Execution Time을 얻기 위해 다른 쓰레드와 경쟁 상태(Race Condition)에 빠지게 되며 발생하는 오버헤드 등 여러 가지 성능 저하가 발생하게 된다.
위 그림은 Apache httpd와 nginx 웹 서버에서 동시 사용자 수에 따른 처리 가능한 RPS(Request Per Second)를 나타낸 그림이다. 동시 사용자 수가 급격히 증가할 수록 httpd는 성능이 급격하게 저하하지만, nginx는 성능에 큰 변화가 없다. 동시 사용자 수 증가에 따른 RPS 저하는 곧 서비스의 성능과 사용자 경험의 저하로 이어지게 될 것이다.
이 때, nginx가 안정적인 트래픽을 보장하기 위해 도입한 방법이 곧 C10K 문제의 해결책과 동일하다.
I/O
C10K 문제와 해결 방법에 대해 자세히 알아보기 전에 I/O에 대해 정리해보고자 한다. I/O라고 하면 되게 추상적인 개념이자, 흔히 키보드 입력과 모니터 출력과 같은 단순한 예시가 먼저 생각나기 때문에 Non-Blocking I/O나 네트워크 I/O 등에 관해 알아보기 전에 I/O에 대한 개념을 먼저 확립하고자 한다.
I/O는 주로 File System이 관여하는 작업으로, 크게 종류는 아래와 같다.
- Network(Socket): 서로 다른 노드에 존재하는 프로세스 간 통신(애플리케이션 레벨)으로 진행되는 I/O 작업
- File: 하드 디스크에 존재하는 파일을 메모리로 로드해 파일을 기준으로 진행되는 I/O 작업
- Pipe: 프로세스 간 통신으로 진행되는 I/O 작업
- Device: 모니터, 키보드 등의 외부 장치로부터 진행되는 I/O 작업
일반적으로 I/O 작업은 사용자 정의 프로세스(User mode)가 단독적으로 처리할 수 없으며, read/write와 같은 OS System Call의 도움이 필요하다.
시스템 콜의 도움을 받아야하기 때문에 I/O 작업을 요청한 Task는 대기(block) 상태에 들어가게 되며, CPU는 유저 모드에서 커널 모드로 전환이 되어 I/O 작업을 수행하게 된다. 커널 프로세스가 I/O 작업을 완료하게 되면 다시 CPU의 상태는 커널 모드에서 유저 모드로 전환된다. 이후, I/O 결과 데이터는 기존 요청 Task로 반환되게 되며 대기(block)하고 있던 쓰레드는 깨어나게 된다.
Task: Process or Thread
이 때, 시스템 콜을 요청한 뒤 I/O 작업의 완료를 기다리는 방식에 따라 Blocking I/O와 Non-Blocking I/O가 나뉘게 된다.
Blocking I/O는 작업을 요청한 Task가 I/O 작업이 완료될 때까지 대기(block)한다. 이와 반대로 작업 완료를 기다리지 않고 즉시 상태를 반환하여 작업 요청 쓰레드가 다음 작업을 할 수 있도록 하는 것이 Non-Blocking I/O이다.
현재 단순히 I/O 작업을 호출한 쓰레드의 작업 완료 대기 여부에 따라 Blocking I/O와 Non-Blocking I/O를 구분하였다. 이는 두 방식을 비교하기 위한 개념적인 설명일 뿐이며, Nginx, Tomcat 등에서 사용되는 비동기 Non-Blocking I/O 방식을 이해하기 위해서는 네트워크 I/O (Socket I/O)에서 Blocking 개념에 대해 자세히 알아봐야한다.
Socket I/O에서의 Blocking
Socket I/O에서 각 소켓은 TCP 연결을 위하여 우선은 3-way handshake를 진행하게 된다. 3-way handshake 과정을 통하여 두 노드간 소켓으로 연결(connection)이 되게 된다.
이후, 실제 요청 데이터를 보내게 된다. 이 때, 클라이언트는 write() 시스템 콜을 호출하여 송신 버퍼에 데이터를 삽입하게 될 것이다. 서버에서는 해당 소켓의 File Descriptior(FD)를 통해 read() 시스템 콜을 호출하여 수신 버퍼에 들어온 데이터를 읽게 된다.
이 때, Socket I/O에서 Blocking 방식이란 클라이언트 측에서도 write() 시스템콜을 호출하는 동안 해당 쓰기 작업을 호출한 쓰레드가 멈추게 되고, 서버 측에서도 read() 시스템콜을 호출한 쓰레드는 데이터가 수신 버퍼에 도착하여 읽기 작업이 완료되기까지 대기하게 되는 방식을 의미한다.
만약, Socket I/O에서 C10K + Blocking 상황을 가정한다면 다음과 같을 것이다.
-
1만명의 클라이언트와 TCP 커넥션이 맺어지며, 1만개의 쓰레드가 서버에 생성
⇒ Context Switching 오버헤드 -
1만명의 클라이언트의 요청으로 인한 수신 버퍼 내 부하(워크로드) 급증
⇒ 연쇄적인 병목현상 발생 -
Blocking I/O이기 때문에 TCP 커넥션 시점부터 데이터를 수신하고, 이를 처리하여 응답하기까지 쓰레드가 점유
⇒ 다수 쓰레드에서 IDLE한 시간 발생
위와 같은 문제들이 예상되며, 이는 당연히 C10K 문제에서 비동기, Non-Blocking I/O 구조가 해결책인 이유인 것이다. 결국 앞선 2가지 문제도, 커넥션부터 요청의 처리까지 요청 당 하나의 쓰레드가 완전히 점유하게 되어 발생하는 쓰레드의 IDLE한 시간 낭비 문제로 이어진다.
그러나, 처음에 필자는 “3-handshake 이후 실제 요청이 도착하는 사이 발생하는 IDLE한 시간이 과연 영향이 클까?”라는 생각을 하였다.
위 그림은 Github Pull Request에 접속 시 API 응답 타이밍 내역을 확인한 결과이다. 물론, PR Description의 일부 컴포넌트에 대한 요청이지만 단순 계산을 위하여 해당 요청을 예시로 선택한다.
요청/응답 섹션에서 ‘서버 응답을 기다리는 중’의 소요된 시간은 675.74ms이다. 이는 3-way handshake로 TCP Connection을 맺은 후 실제 요청 후 응답이 온 시간을 기록한 것이다. 따라서, 단순 편도 시간을 계산하면 675.74 / 2 = 337.87ms 이다.
깃허브는 CDN 등의 방법으로 개선된 응답 속도를 가지고 있지만 우리가 운영하는 서버는 이보다 더욱 느릴 것이다. 이 때, 클라이언트의 동시 요청이 급격하게 몰리는 상황이 온다면 약 300ms의 IDLE한 시간이 병목 현상과 겹쳐 서버의 성능을 더욱 악화시킬 것이다.
Non-Blocking I/O
앞서 알아본 Socket I/O에서 Blocking 방식으로 동작할 경우 발생할 수 있는 문제점을 해결하기 위하여 등장한 것이 Non-Blocking I/O이다.
Non-Blocking I/O는 I/O 작업을 요청한 쓰레드를 대기(block)시키지 않고 I/O 요청에 대한 현재 상태를 바로 응답한다. 예를 들어 서버 측 소켓의 수신 버퍼에 대한 read() 시스템콜을 Non-Blocking 모드로 호출하게 되면 커널 모드로 Context Switching이 되고, 커널은 I/O 작업을 수행할 것이다. 여기까지는 Blocking 방식과 동일하나, I/O 작업이 시작함과 동시에 즉시 결과값을 반환하며 다시 CPU는 유저 모드로 스위칭하게 된다. 리눅스의 경우에는 데이터 처리 작업이 완료되지 않았을 경우 -1을 리턴한다.
이렇게 된다면, 원래 I/O 요청한 쓰레드는 이어서 다른 작업을 수행할 수 있게 되며, 이후 커널에서는 소켓의 수신 버퍼에 요청온 데이터를 위치시킨 뒤 I/O 작업이 완료되었음을 알리게 된다.
여기서 "I/O 작업 요청을 보낸 쓰레드는 이미 다른 작업을 수행하고 있을텐데 어떻게 I/O 작업이 완료되었음을 알릴 수 있는거지?"라는 의문이 생긴다.
I/O 작업의 완료를 확인하는 방법은 여러가지가 있지만 크게 Polling 방식과 이벤트 기반 처리 방식이 있다.
Polling 방식은 주기적으로 커널에 read 시스템 콜을 보내(polling) 데이터가 준비되었는지 확인하는 방식이다. 그러나, 간단한 무한루프로 Polling 방식 구현 시 Busy Waiting 문제가 발생할 수 있다.
따라서, 효율적인 폴링 방식 구현을 위해 이벤트 기반 처리 방식이 사용된다. 이벤트 기반 처리 방식은 read/write가 준비된 경우에 이벤트를 발생시키고, 이를 쓰레드에서 감지해 그 다음 작업을 이어 나갈 수 있도록 하는 방법이다.
이벤트 기반 처리 방식에는 I/O Multiplexing과 Callback/Signal가 존재한다.
Multiplexing(다중화)은 여러 전송 신호들을 하나의 회선 또는 매체를 사용하여 한 번에 전송하여 전송 속도와 효율을 높이는 기술을 의미한다. 이를 기반으로 Socket I/O에서 I/O Multiplexing을 생각해보면 I/O 작업을 진행하는 여러 소켓의 상태를 하나의 쓰레드에서 감지할 수 있게되는 것을 의미한다. 즉, 멀티플렉싱 방식으로 2개 이상의 소켓에 대한 read 시스템 콜을 호출하게 되는 것이다. 이 때, 멀티플렉싱 시스템 콜을 요청한 쓰레드는 Block 또는 Non-Block 방식 모두 동작이 가능하다. 이후, 여러 소켓에 대한 이벤트(read/write)가 발생한 경우에 OS 커널에서 여러 이벤트를 응답하게 된다.
더 나아가 I/O를 담당하는 쓰레드를 따로 두고, 쓰레드풀에 여러 개의 쓰레드를 미리 만들어두고 I/O 작업 완료 이벤트 발생 시에만 각 요청마다 쓰레드를 할당하여 처리한다면 쓰레드의 수도 제한할 수 있을 뿐만 아니라 불필요한 IDLE 시간 낭비를 막을 수 있다.
이렇듯 I/O Multiplexing 방식은 Tomcat, Netty(WebFlux), NodeJS, Nginx와 같은 서버사이드 프로그램에서 적용되어있다.
최근 사용되는 I/O Multiplexing 시스템콜의 종류는 다음과 같다.
- epoll: Linux에서 사용
- kqueue: MacOS(BSD Unix 기반)에서 사용
- IOCP(I/O Completion Port): Windows에서 사용
필자는 해당 포스팅을 작성하기 전에 Tomcat의 코드를 디버깅해보면 저수준의 코드들과 톰캣에서 사용되는 Acceptor, Poller, Selector 등의 구현체 코드들을 찾아보던 중 poll()
네이티브 메서드를 발견하였다.

디버깅을 하며 고수준 → 저수준의 코드를 찾아보며 “그래서 I/O 준비가 완료된 Channel(Socket)은 어떻게 애플리케이션에서 아는건데?”라는 의문을 가지게 되었고, 네이티브 메서드로 제공되는 KQueueSelectorImpl.poll(...)
을 발견하여 이를 이해할 수 있었다.
epoll 시스템콜에 대한 설명을 확인해보면 이벤트 발생(ready) 시 이를 알려(return) 동작한다는 내용을 알 수 있다.
MacOS에서 사용하는 kqueue 방식을 사용하는데 이벤트를 알리기 위한 시스템 콜로는 kevent 시스템 콜을 사용하게 된다. kevent 시스템 콜을 사용한다. 이 또한, 이벤트 큐에서 이벤트를 감지할 요소를 등록하고, 이후 이벤트를 알려 동작한다는 내용이 명시되어 있다.
Callback/Signal 방식은 유저 쓰레드가 I/O 작업 요청을 Non-Blocking 방식으로 시스템 콜을 보내면 응답을 받지 않은 채 바로 다른 로직을 수행하게 된다. 이후, OS 커널에서 I/O 작업 완료 응답을 받게 되면 콜백(Callback)이나 시그널(Signal) 유저 쓰레드로 보내 이를 실행시키는 등으로 이후 작업을 처리하게 된다. 이는 자바스크립트 기반인 NodeJS에서 주로 사용된다고 한다.
Synchronous / Asynchronous
블로킹과 논블로킹을 이야기하면 동기와 비동기에 대한 내용은 빠짐없이 나오게 된다. 앞서 블로킹-논블로킹의 구분에 관한 설명에서는 I/O 작업 같은 특정 요청을 보낸 쓰레드가 해당 작업의 ‘완료’ 응답이 올 때까지 대기하는지 여부에 따라 나뉜다고 하였다.
동기와 비동기는 I/O 요청 완료 응답의 결과를 어떤 쓰레드가 처리하냐에 따라 나뉜다.
동기(Synchronous) 방식은 I/O 작업을 요청(호출)한 쓰레드가 직접 응답 처리를 수행하는 경우이며, 작업의 순서 보장이 되어야한다.
비동기(Asynchronous) 방식은 커널로부터 notify를 받거나 callback을 통해 통해 알림을 받게 되면, I/O 작업을 요청한 쓰레드가 직접 결과를 처리하지 않고, 다른 쓰레드가 처리를 담당하는 방식을 의미한다.
따라서, read/write 시스템콜을 블로킹 모드로 호출하든, 논블로킹 모드로 호출하든 결국 요청을 한 쓰레드에서 직접 응답을 받아 처리를 해야하기 때문에 동기(Synchronous)에 해당하게 된다.
Java NIO
Java로 코딩 테스트를 해본 경험이 있거나, 파일 읽기/쓰기 등의 작업을 진행해 본 경험이 있다면 java.io
패키지는 익숙할 것이다.
그러나, 컴퓨팅 기술이 발전함에 따라 멀티쓰레딩이 기본적인 스펙이 되며, 기존의 I/O 패키지에서 여러가지 문제점이 발견되었다. 이를 개선하기 위해서 기존의 java.io
패키지를 수정하는 것이 아니라, 새로운(new) I/O 관련 패키지를 만들어 Java NIO(New I/O)라는 이름이 붙게된 것이다.
흔히 원래의 I/O 방식은 read()
를 호출한 경우 블로킹 형태로 동작하기 때문에 BIO로 불리며, NIO는 논블로킹 방식의 동작을 지원하기 때문에 Non-Blocking I/O라는 의미로 해석되지만, 정확한 의미로는 New I/O를 의미한다. NIO에서도 Blocking 형태로 동작을 하도록 지원하기 때문에 NIO != Non-Blocking I/O 인 것이다.
기존의 BIO 방식에서는 I/O 작업 시에 요청 쓰레드가 블로킹되는 문제와 더불어, 스트림(Stream) 기반으로 동작하기 때문에 입력 스트림에서 N Byte가 입력되면, 출력 스트림에서도 동일하게 N Byte를 읽게되어 성능 상의 문제도 존재하였다. 이외에도 멀티쓰레딩 환경에서 논블로킹 지원을 위한 몇 가지 방법들이 추가되어 NIO가 등장하게 되었다.
Java NIO에서 제공하는 핵심 추상화 기술은 Buffer, Encoder/Decoder, Channel, Selector와 SelectionKey를 활용한 Multiplexing이다.
우선, 톰캣에서 사용하는 NioChannel
은 다음과 같다.
/**
* Base class for a SocketChannel wrapper used by the endpoint.
* This way, logic for an SSL socket channel remains the same as for
* a non SSL, making sure we don't need to code for any exception cases.
*/
public class NioChannel implements ByteChannel, ScatteringByteChannel, GatheringByteChannel {
protected SocketChannel sc = null;
/**
* Reads a sequence of bytes from this channel into the given buffer.
*
* @param dst The buffer into which bytes are to be transferred
* @return The number of bytes read, possibly zero, or <code>-1</code> if
* the channel has reached end-of-stream
* @throws IOException If some other I/O error occurs
*/
@Override
public int read(ByteBuffer dst) throws IOException {
return sc.read(dst);
}
/**
* Writes a sequence of bytes to this channel from the given buffer.
*
* @param src The buffer from which bytes are to be retrieved
* @return The number of bytes written, possibly zero
* @throws IOException If some other I/O error occurs
*/
@Override
public int write(ByteBuffer src) throws IOException {
checkInterruptStatus();
if (!src.hasRemaining()) {
// Nothing left to write
return 0;
}
return sc.write(src);
}
}
NioChannel
은 톰캣에서 사용되며 클라이언트와 연결을 나타내는 객체인 SocketChannel
을 감싸는 SocketChannel Wrapper이다.
해당 클래스 내부에서 SocketChannel
의 read(ByteBuffer)
와 write(ByteBuffer)
를 사용한다는 것을 알 수 있다.
즉, read를 수행할 때는 소켓의 수신 버퍼에 도착한 데이터를 ByteBuffer를 통해 읽는 것이고 write를 수행할 때는 ByteBuffer의 내용을 소켓의 송신 버퍼에 쓰게되는 것이다.
package java.nio.channels;
public interface ReadableByteChannel extends Channel {
/**
* Reads a sequence of bytes from this channel into the given buffer.
*
* ...생략...
*
*/
public int read(ByteBuffer dst) throws IOException;
}
package java.nio.channels;
public interface WritableByteChannel
extends Channel
{
/**
* Writes a sequence of bytes to this channel from the given buffer.
*
* ... 생략 ...
*
*/
public int write(ByteBuffer src) throws IOException;
}
SocketChannel
은 WritableByteChannel
, ReadableByteChannel
을 구현하고 있는 추상 클래스로 read(ByteBuffer dst)
, write(ButeBuffer src)
의 Javadoc 주석을 읽어보면 ByteBuffer를 통해 데이터를 읽고 쓰는 것을 알 수 있다.
이렇듯 채널과 버퍼를 통해 추상화 기술을 제공하여, 하나의 채널에서 읽기와 쓰기가 가능하도록 하여 기존의 스트림 방식을 사용하던 BIO의 문제를 극복해내었다.
Tomcat의 동작 원리
Tomcat은 6.0 버전부터 NIO 방식이 도입되어 BIO와 동시에 사용되었으며, 8.0부터는 NIO에서 부가기능이 더해진 NIO2를 도입, 9.0부터는 BIO가 완전히 삭제되었다.
SpringBoot로 REST API 서버 구축 시 일반적으로 톰캣을 WAS로 사용하게 된다. 필자가 해당 포스팅을 작성하게 된 계기도 톰캣의 동작 원리에 대한 궁금증에 있었다.
이번 포스팅에서는 톰캣의 동작 원리에 대한 개념적인 이해를 위주로 작성하고, 차후 코드를 더욱 자세히 분석할 계획이다.
위 그림은 기존의 톰캣 동작 원리를 나타내는 포스팅에 많이 사용되는 그림이다. 아래 그림은 필자가 직접 톰캣에 정의된 Acceptor
, Poller
, Selector
코드를 찾아보며 재구성한 그림이다.
간단한 동작 흐름은 다음과 같다.
- Acceptor에서는 지속적으로 클라이언트의 요청이 있는지 확인한다.
- 클라이언트의 요청이 수락(accept)되면 해당 클라이언트와 연결을 담당하는
SocketChannel
객체를 생성한다. SocketChannel
객체에 추상화된 기술을 적용한NioChannel
,NioSocketWrapper
로 감싼다.NioSocketWrapper
를 Poller에 등록(register)한다.- Poller는
NioSocketWrapper
를PollerEvent
로 감싸 내부의 Poller Event Queue에 등록한다. - Poller에서는 소켓의 이벤트 감시를 위하여 Poller Event Queue의 채널들을 Selector에 등록한다.
- Selector는 I/O 준비 작업 완료 채널을 감시한다.
- I/O 준비 작업이 완료된 채널이 발생하면 Selector를 통해 해당 채널들의 SelectionKey 알아낸다.
- I/O 준비 작업 완료된 각 채널(요청)마다 Worker Thread를 할당하여 처리를 위임한다.
해당 설명은 코드 흐름을 단순하게 나열한 것으로 이해를 돕기 위하여 아래에 코드를 추가적으로 서술하였다. 코드에 대해서는 차후 포스팅에서 더욱 자세하게 다뤄볼 예정이다.
1. Acceptor
package org.apache.tomcat.util.net;
public class Acceptor<U> implements Runnable {
// ...
private final AbstractEndpoint<?,U> endpoint;
@Override
public void run() {
int errorDelay = 0;
long pauseStart = 0;
try {
// Loop until we receive a shutdown command
while (!stopCalled) {
while (endpoint.isPaused() && !stopCalled) {
if (state != AcceptorState.PAUSED) {
pauseStart = System.nanoTime();
// Entered pause state
state = AcceptorState.PAUSED;
}
if ((System.nanoTime() - pauseStart) > 1_000_000) {
// Paused for more than 1ms
try {
if ((System.nanoTime() - pauseStart) > 10_000_000) {
Thread.sleep(10);
} else {
Thread.sleep(1);
}
} catch (InterruptedException e) {
// Ignore
}
}
}
if (stopCalled) {
break;
}
state = AcceptorState.RUNNING;
try {
//if we have reached max connections, wait
endpoint.countUpOrAwaitConnection();
// Endpoint might have been paused while waiting for latch
// If that is the case, don't accept new connections
if (endpoint.isPaused()) {
continue;
}
U socket = null;
try {
// 📌 Accept the next incoming connection from the server
// socket
socket = endpoint.serverSocketAccept();
} catch (Exception ioe) {
// We didn't get a socket
endpoint.countDownConnection();
if (endpoint.isRunning()) {
// Introduce delay if necessary
errorDelay = handleExceptionWithDelay(errorDelay);
// re-throw
throw ioe;
} else {
break;
}
}
// Successful accept, reset the error delay
errorDelay = 0;
// Configure the socket
if (!stopCalled && !endpoint.isPaused()) {
// 📌 setSocketOptions() will hand the socket off to
// an appropriate processor if successful
if (!endpoint.setSocketOptions(socket)) {
endpoint.closeSocket(socket);
}
} else {
endpoint.destroySocket(socket);
}
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
String msg = sm.getString("endpoint.accept.fail");
log.error(msg, t);
}
}
} finally {
stopLatch.countDown();
}
state = AcceptorState.ENDED;
}
// ...
}
위 코드는 톰캣의 Acceptor 클래스와 내부의 run()
메서드 코드이다.
Acceptor는 3-way handshake 성공 후 연결이 수립된 소켓을 SocketChannel
객체로 바인딩한다. 이후 해당 채널에 대한 몇 가지 추가 설정 후 Poller 쓰레드로 객체를 넘겨(등록)준다. Acceptor는 Runnable
의 구현체로 하나의 쓰레드이다.
run()
메서드에서 중요하게 볼 부분은 아래 두 코드이다.
1) AbstractEndpoint.serverSocketAccept()
// Accept the next incoming connection from the server
// socket
socket = endpoint.serverSocketAccept();
endPoint
는 Acceptor의 멤버 변수로 NioEndpoint
, Nio2Endpoint
의 상위 추상 클래스인 AbstractEndpoint
타입이다. 예를 들어 NioEndpoint.serverSocketAccept()
메서드를 살펴보면 다음과 같다.
public class NioEndpoint extends AbstractJsseEndpoint<NioChannel, SocketChannel> {
/**
* Server socket "pointer".
*/
private volatile ServerSocketChannel serverSock = null;
// ...
@Override
protected SocketChannel serverSocketAccept() throws Exception {
SocketChannel result = serverSock.accept();
// Bug does not affect Windows platform and Unix Domain Socket. Skip the check.
if (!JrePlatform.IS_WINDOWS && getUnixDomainSocketPath() == null) {
SocketAddress currentRemoteAddress = result.getRemoteAddress();
long currentNanoTime = System.nanoTime();
if (currentRemoteAddress.equals(previousAcceptedSocketRemoteAddress) &&
currentNanoTime - previousAcceptedSocketNanoTime < 1000) {
throw new IOException(sm.getString("endpoint.err.duplicateAccept"));
}
previousAcceptedSocketRemoteAddress = currentRemoteAddress;
previousAcceptedSocketNanoTime = currentNanoTime;
}
return result;
}
}
이 때, ServerSocketChannel
은 WAS 서버 포트와 연결을 담당하는 리스닝 소켓을 의미한다. 즉, 일반적인 톰캣 사용 시 8080포트로 바인딩된 소켓 채널이다.
serverSocketAccept()
메서드 내부에서는 serverSock.accept()
를 호출하여 클라이언트와 연결 후, 해당 소켓에 대한 연결을 나타내는 SocketChannel
객체를 반환한다. 결과적으로 해당 메서드에서 클라이언트와 연결을 나타내는 SocketChannel
객체를 반환하는 것이다.
2) AbstractEndpoint.setSocketOptions(U Socket)
// setSocketOptions() will hand the socket off to
// an appropriate processor if successful
if (!endpoint.setSocketOptions(socket)) {
..,
}
이 때도 endPoint
는 Acceptor
의 멤버 변수를 의미하며, 그 구현체 중 하나인 NioEndpoint
를 통해 추상 메서드인 setSocketOptions()
구현부를 살펴본다.
/**
* Process the specified connection.
* @param socket The socket channel
* @return <code>true</code> if the socket was correctly configured
* and processing may continue, <code>false</code> if the socket needs to be
* close immediately
*/
@Override
protected boolean setSocketOptions(SocketChannel socket) {
NioSocketWrapper socketWrapper = null;
try {
// Allocate channel and wrapper
NioChannel channel = null;
if (nioChannels != null) {
channel = nioChannels.pop();
}
if (channel == null) {
SocketBufferHandler bufhandler = new SocketBufferHandler(
socketProperties.getAppReadBufSize(),
socketProperties.getAppWriteBufSize(),
socketProperties.getDirectBuffer());
if (isSSLEnabled()) {
channel = new SecureNioChannel(bufhandler, this);
} else {
// 📌 NioChannel을 사용
channel = new NioChannel(bufhandler);
}
}
// 📌 NioChannel을 감싸기 위한 NioSocketWrapper
NioSocketWrapper newWrapper = new NioSocketWrapper(channel, this);
channel.reset(socket, newWrapper);
connections.put(socket, newWrapper);
socketWrapper = newWrapper;
// 📌 Non-Blocking 처리
// Set socket properties
// Disable blocking, polling will be used
socket.configureBlocking(false);
if (getUnixDomainSocketPath() == null) {
socketProperties.setProperties(socket.socket());
}
socketWrapper.setReadTimeout(getConnectionTimeout());
socketWrapper.setWriteTimeout(getConnectionTimeout());
socketWrapper.setKeepAliveLeft(NioEndpoint.this.getMaxKeepAliveRequests());
// 📌 Poller로 전달
poller.register(socketWrapper);
return true;
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
try {
log.error(sm.getString("endpoint.socketOptionsError"), t);
} catch (Throwable tt) {
ExceptionUtils.handleThrowable(tt);
}
if (socketWrapper == null) {
destroySocket(socket);
}
}
// Tell to close the socket if needed
return false;
}
NioEndpoint.setSocketOptions(SocketChannel socket)
메서드에서는 SocketChannel
을 NioSocketChannel
로 감싸고, 한 번 더 NioSocketWrapper
로 감싸 Poller로 전달하는 역할을 수행한다. 그 외에도 소켓에 논블로킹을 적용하는 과정이 해당 메서드에서 이루어진다.
SocketChannel.configureBlocking(false)
메서드를 설정하여 SocketChannel을 논블로킹 모드로 설정한다. 따라서, 해당 채널에 read등의 요청을 보내더라도 즉시 상태값을 반환하게 되어, 요청한 쓰레드는 대기하지 않고 다음 동작을 수행할 수 있게 된다.또한, 이후 채널을 Selector에 등록하는 과정에서 블로킹 모드로 설정되어 있으면 IllegalBlockingModeException
이 발생하게 된다.
이는 차후 Selector에서 select()
호출 시 해당 채널에 대한 이벤트 감지 시스템콜을 호출해 상태를 바로 확인하고, 나중에 해당 채널에 이벤트 발생 시 OS 커널에서 이를 알려 Selector에서 감지할 수 있도록 하기 위함이다.
package org.apache.tomcat.util.net;
public class NioEndpoint extends AbstractJsseEndpoint<NioChannel,SocketChannel> {
private Poller poller = null;
public class Poller implements Runnable {
/* ... */
}
}
NioEndpoint 내부에는 Poller
를 멤버 변수로 가지고 있다. Poller는 NioEndpoint
의 내부에 정의된 이너 클래스로 Runnable
의 구현체이다. 즉 Poller도 별개의 쓰레드인 것이다.
Poller는 톰캣이 시작할 때 NioEndpoint.startInternal()
메서드가 실행되며 할당되게 된다. 또한, 해당 메서드에서는 Worker Thread의 Thread Pool을 할당하는 작업도 이루어진다.
// Acceptor.run()
poller.register(socketWrapper);
다시 Acceptor.run()
메서드로 돌아와 poller.register(socketWrapper)
메서드를 호출해 소켓 채널을 Poller로 전달(등록)하게 된다.
2. Poller & Selector
Poller
는 NioEndpoint
의 내부 클래스로, Runnable
의 구현체인 쓰레드이다.
public class Poller implements Runnable {
private Selector selector;
private final SynchronizedQueue<PollerEvent> events = new SynchronizedQueue<>();
/* ... */
}
Poller
는 멤버 변수로 I/O Multiplexing을 위한 이벤트 감시자인 Selector
와 Poller Event Queue를 나타내는 SynchronizedQueue<PollerEvent>
를 가지고 있다. 이와 같은 코드로 구성되어 있기 때문에 톰캣 동작 원리 구조 그림을 다르게 표시한 것이다.
Poller
에서는 크게 4가지의 메서드를 살펴볼 것이다.
- public void register(final NioSocketWrapper socketWrapper)
- private void addEvent(PollerEvent event)
- public void run()
- public boolean events()
앞선, 2가지 메서드는 Acceptor
에서 호출한 poller.register(socketWrapper)
와 연관된 채널을 Poller Event Queue에 등록하는 작업과 관련이 있다.
아래의 2가지 메서드는 Poller
에서 실제로 PollerEvent
를 처리하는 과정과 채널이 Selector에 등록되고 감시되는 과정과 관련이 있다.
1) Poller.register(final NioSocketWrapper socketWrapper)
// NioEndpoint
public static final int OP_REGISTER = 0x100; //register interest op
// Poller: NioEndpoint의 이너 클래스
public void register(final NioSocketWrapper socketWrapper) {
socketWrapper.interestOps(SelectionKey.OP_READ);//this is what OP_REGISTER turns into.
PollerEvent pollerEvent = createPollerEvent(socketWrapper, OP_REGISTER);
addEvent(pollerEvent);
}
register(...)
메서드 내부에서는 전달받은 NioSocketWrapper
객체에 대하여 관심 연산(interestOps)을 등록한다.
처음 들어온 클라이언트의 요청은 PollerEventQueue에 들어갈 때 OP_REGISTER
상태로 들어가게 된다. 이후, 해당 채널의 OP_READ 이벤트를 감시해야하기 때문에 관심사로 SelectionKey.OP_READ
를 설정하는 것이다.
이후, NioSocketWrapper
객체를 PollerEvent
객체로 감싸 Poller Event Queue에 등록한다.
참고로, Poller Event Queue에는 처막 연결이 완료된 상태의 채널과 요청 처리 후 소켓에 응답 데이터 쓰기 연산을 수행해야하는 채널이 들어간다.
해당 코드는 초기에 Acceptor
에서 연결된 요청을 Poller에 등록하는 부분이므로 OP_REGISTER
로 등록되게 된다.
이후 addEvent(pollerEvent)
를 호출하여 해당 객체를 등록한다.
2) Poller.addEvent(PollerEvent event)
private void addEvent(PollerEvent event) {
events.offer(event);
if (wakeupCounter.incrementAndGet() == 0) {
selector.wakeup();
}
}
addEvent(PollerEvent event)
메서드에서는 Poller Event Queue에 해당 요청을 삽입한 뒤 wakeUpCounter
를 증가시킨다. 이 때, 처리해야할 요청이 없다가 생겨난 경우에 Selector(Poller)를 깨운다.
3) Poller.run()
/**
* The background thread that adds sockets to the Poller, checks the
* poller for triggered events and hands the associated socket off to an
* appropriate processor as events occur.
*/
@Override
public void run() {
// Loop until destroy() is called
while (true) {
boolean hasEvents = false;
try {
if (!close) {
// 📌 처리해야할 이벤트가 있는지 확인 & Selector에 등록
hasEvents = events();
if (wakeupCounter.getAndSet(-1) > 0) {
// If we are here, means we have other stuff to do
// Do a non blocking select
// 📌 처리해야할 요청이 있어 wakeUpCount가 0보다 큰 경우 Non-Blocking 모드로 처리 가능한 채널이 있는지 확인한다.
keyCount = selector.selectNow();
} else {
// 📌 우선은 Poller 쓰레드를 대기 시킨 후, Selector를 통해 I/O 작업이 완료 이벤트가 발생한 경우 깨어남
keyCount = selector.select(selectorTimeout);
}
wakeupCounter.set(0);
}
if (close) {
events();
timeout(0, false);
try {
selector.close();
} catch (IOException ioe) {
log.error(sm.getString("endpoint.nio.selectorCloseFail"), ioe);
}
break;
}
// Either we timed out or we woke up, process events first
if (keyCount == 0) {
hasEvents = (hasEvents | events());
}
} catch (Throwable x) {
ExceptionUtils.handleThrowable(x);
log.error(sm.getString("endpoint.nio.selectorLoopError"), x);
continue;
}
// 📌 I/O 작업 준비가 완료된 채널의 SelectionKey들을 순회
Iterator<SelectionKey> iterator =
keyCount > 0 ? selector.selectedKeys().iterator() : null;
// Walk through the collection of ready keys and dispatch
// any active event.
while (iterator != null && iterator.hasNext()) {
SelectionKey sk = iterator.next();
iterator.remove();
NioSocketWrapper socketWrapper = (NioSocketWrapper) sk.attachment();
// Attachment may be null if another thread has called
// cancelledKey()
if (socketWrapper != null) {
// 📌 요청 처리
processKey(sk, socketWrapper);
}
}
// Process timeouts
timeout(keyCount,hasEvents);
}
getStopLatch().countDown();
}
Poller.run()
메서드 내부에서는 무한 루프를 통해 서버가 종료될 때까지 계속해서 PollerEvent 및 I/O 준비가 완료된 채널을 Selector를 통해서 처리한다.
반복문 내부에서는 먼저 Poller.events()
메서드를 호출하여 PollerEventQueue에 등록된 PollerEvent
객체를 Selector에 등록한다.
3-1) Poller.events()
/**
* Processes events in the event queue of the Poller.
*
* @return <code>true</code> if some events were processed,
* <code>false</code> if queue was empty
*/
public boolean events() {
boolean result = false;
PollerEvent pe = null;
for (int i = 0, size = events.size(); i < size && (pe = events.poll()) != null; i++ ) {
result = true;
NioSocketWrapper socketWrapper = pe.getSocketWrapper();
SocketChannel sc = socketWrapper.getSocket().getIOChannel();
int interestOps = pe.getInterestOps();
if (sc == null) {
log.warn(sm.getString("endpoint.nio.nullSocketChannel"));
socketWrapper.close();
} else if (interestOps == OP_REGISTER) { // 📌 클라이언트와 채널이 처음 연결된 경우
try {
// 📌 OP_READ 이벤트를 감지하도록 Selector에 등록
sc.register(getSelector(), SelectionKey.OP_READ, socketWrapper);
} catch (Exception x) {
log.error(sm.getString("endpoint.nio.registerFail"), x);
}
} else {
final SelectionKey key = sc.keyFor(getSelector());
if (key == null) {
// The key was cancelled (e.g. due to socket closure)
// and removed from the selector while it was being
// processed. Count down the connections at this point
// since it won't have been counted down when the socket
// closed.
socketWrapper.close();
} else {
final NioSocketWrapper attachment = (NioSocketWrapper) key.attachment();
if (attachment != null) {
// We are registering the key to start with, reset the fairness counter.
try {
int ops = key.interestOps() | interestOps;
attachment.interestOps(ops);
key.interestOps(ops);
} catch (CancelledKeyException ckx) {
socketWrapper.close();
}
} else {
socketWrapper.close();
}
}
}
if (running && eventCache != null) {
pe.reset();
eventCache.push(pe);
}
}
return result;
}
events()
내부에서는 Poller Event Queue에 등록된 채널들을 순회하며 Selector에 등록하는 과정을 수행한다.
이번 포스팅에서는 클라이언트와 채널이 처음 연결(OP_REGISTER)된 경우를 나타내기 때문에 아래 조건문에 의해 Selector에 채널을 등록하게 된다.
else if (interestOps == OP_REGISTER) { // 📌 클라이언트와 채널이 처음 연결된 경우
try {
// 📌 OP_READ 이벤트를 감지하도록 Selector에 등록
sc.register(getSelector(), SelectionKey.OP_READ, socketWrapper);
} catch (Exception x) {
log.error(sm.getString("endpoint.nio.registerFail"), x);
}
} // ...
sc.register(...)
를 호출하여 인자로 넘겨준 Selector
객체에 해당 채널을 등록한다.
OP_REGISTER
상태였던 채널을 SelectionKey.OP_READ
로 바꾸어 Selector
에 등록한다.
OP_REGISTER
는 Tomcat의 Poller
클래스에 정의된 상수값이며, SelectionKey.OP_READ
는 java.nio에서 제공하는 상수값이다.
// AbstractSelectableChannel
public final SelectionKey register(Selector sel, int ops, Object att) throws ClosedChannelException
{
if ((ops & ~validOps()) != 0)
throw new IllegalArgumentException();
if (!isOpen())
throw new ClosedChannelException();
synchronized (regLock) {
if (isBlocking())
throw new IllegalBlockingModeException();
synchronized (keyLock) {
// re-check if channel has been closed
if (!isOpen())
throw new ClosedChannelException();
SelectionKey k = findKey(sel);
if (k != null) {
k.attach(att);
k.interestOps(ops);
} else {
// 📌 New registration
k = ((AbstractSelector)sel).register(this, ops, att);
addKey(k);
}
return k;
}
}
}
결과적으로 AbstractSelector
로 타입 캐스팅을 하여 인자로 전달된 Selector 객체에 해당 채널과 관심사(감시할 이벤트 - OP_READ, OP_WRITE)를 등록하게 된다.
이후 자세한 코드는 차후 포스팅에서 다루고자 한다.
다시 Poller.run()
메서드로 돌아와서 이후 I/O 작업 준비 완료된 채널을 감시하는 과정을 살펴볼 것이다.
// Poller
/**
* The background thread that adds sockets to the Poller, checks the
* poller for triggered events and hands the associated socket off to an
* appropriate processor as events occur.
*/
@Override
public void run() {
// Loop until destroy() is called
while (true) {
boolean hasEvents = false;
try {
if (!close) {
// 📌 처리해야할 이벤트가 있는지 확인 & Selector에 등록
hasEvents = events();
if (wakeupCounter.getAndSet(-1) > 0) {
// If we are here, means we have other stuff to do
// Do a non blocking select
// 📌 처리해야할 요청이 있어 wakeUpCount가 0보다 큰 경우 Non-Blocking 모드로 처리 가능한 채널이 있는지 확인한다.
keyCount = selector.selectNow();
} else {
// 📌 우선은 Poller 쓰레드를 대기 시킨 후, Selector를 통해 I/O 작업이 완료 이벤트가 발생한 경우 깨어남
keyCount = selector.select(selectorTimeout);
}
wakeupCounter.set(0);
}
if (close) {
events();
timeout(0, false);
try {
selector.close();
} catch (IOException ioe) {
log.error(sm.getString("endpoint.nio.selectorCloseFail"), ioe);
}
break;
}
// Either we timed out or we woke up, process events first
if (keyCount == 0) {
hasEvents = (hasEvents | events());
}
} catch (Throwable x) {
ExceptionUtils.handleThrowable(x);
log.error(sm.getString("endpoint.nio.selectorLoopError"), x);
continue;
}
// 📌 I/O 작업 준비가 완료된 채널의 SelectionKey들을 순회
Iterator<SelectionKey> iterator =
keyCount > 0 ? selector.selectedKeys().iterator() : null;
// Walk through the collection of ready keys and dispatch
// any active event.
while (iterator != null && iterator.hasNext()) {
SelectionKey sk = iterator.next();
iterator.remove();
NioSocketWrapper socketWrapper = (NioSocketWrapper) sk.attachment();
// Attachment may be null if another thread has called
// cancelledKey()
if (socketWrapper != null) {
// 📌 요청 처리
processKey(sk, socketWrapper);
}
}
// Process timeouts
timeout(keyCount,hasEvents);
}
getStopLatch().countDown();
}
events()
메서드를 호출하며 Selector에 채널을 등록하게 된다.
이후, wakeUpCounter
의 값을 확인해서 처리해야할 채널이 있다면 Non-Blocking 방식으로 I/O 준비 완료된 채널의 SelectionKey
갯수를 조회하는 selector.selectNow()
메서드를 호출한다.
그렇지 않다면 selector.select(selectorTimeout)
을 호출하여 Poller
쓰레드를 대기시킨다. 이는 처리해야할 채널이 없을 때는 Poller
쓰레드를 대기시켜 불필요한 busy waiting을 방지하기 위함이다.
앞서 Poller.addEvent(PollerEvent event)
메서드에서 wakeUpCounter
의 값을 증가시킨 뒤 처리해야할 채널이 생긴 경우 selector.wakeUp()
을 호출한 이유가 selector.select(selectorTimeout)
메서드로 인하여 Poller 쓰레드가 대기 중인 경우 깨우기 위함이라는 것도 알 수 있다.
이 때, selector.select()
, selector.selectNow()
메서드를 따라 더욱 저수준의 코드로 접근하면, 앞서 보았던 poll(...)
네이티브 메서드를 호출하게 된다. 즉, Selector를 통해 I/O 작업이 완료된 채널을 감지하여 이후 요청에 대한 처리를 진행할 수 있게 되는 것이다.
필자는 MacOS를 사용하기 때문에 JDK17에서 KQueueSelectorImpl과 PollSelectorImpl이 SelectorImpl
의 구현체로 존재한다.
별개로 윈도우에는 WindowsSelectorImpl과 WEPollSelectorImpl 클래스가 존재한다.
BSD Unix 계열의 MacOS를 기준으로 하였을 때 KQueueSelectorImpl.doSelect()
→ KQueue.poll(...)
네이티브 메서드를 호출하게 된다.
KQueue.c 파일에서 poll(...)
메서드를 찾아보면 kevent 시스템콜을 호출하는 것을 확인할 수 있다.
이후에는 select()
또는 selectNow()
로 현재 I/O 준비 작업이 완료된 채널이 존재할 경우에 Selector.selectedKeys()
메서드를 호출하여 I/O 작업 준비가 완료된 채널들의 SelectionKey
의 반복자를 획득한다.
이후, 반복자를 순회하며 각 요청을 processKey(sk, socketWrapper)
메서드를 통해 처리하게 되는 것이다. 즉, I/O 준비 작업이 완료된 채널은 요청마다 Worker Thread가 할당되어 이후 후처리를 진행하게 된다.
해당 포스팅에서는 간단한 동작 원리만 알아보기 때문에 select()
, selectNow()
의 구체적인 과정이나 Selector
의 구현체들에 대해 자세한 설명은 생략하였다.
요약
톰캣의 동작 원리(방식)을 간단하게 요약하면 다음과 같을 것이다.
- 연결이 수립된 요청을 받는 Acceptor, 연결이 수립된 요청(채널)을 관리하는 Poller, 채널(소켓)의 I/O 작업 완료 이벤트를 감지하는 Selector가 존재한다.
- 요청에 대한 실제 처리는 Thread pool의 Worker Thread에서 처리한다.
- WAS의 최전선에서 클라이언트와의 연결 요청을 받는 리스닝 소켓은
ServerSocketChannel
이다. - 연결이 수락된 클라이언트의 요청에 대한 소켓은
SocketChannel
로 바인딩된다. Acceptor
는 클라이언트의 연결 요청을 받아SocketChannel
을 생성한다.Acceptor
에서는SocketChannel
을NioChannel
,NiSocketWrapper
로 감싸Poller
로 넘긴다.Poller
에서는OP_REGISTER
상태의NioSocketWrapper
를 전달받으면 이를PolerEvent
로 감싸 Poller Event Queue에 넣는다.Poller
에서는events()
메서드를 호출해 Poller Event Queue의 요청을Selector
에 삽입한다.Poller
는 등록된 여러 SocketChannel들 중 I/O 작업이 준비된 채널이 있는지Selector
를 통해 감시한다.Poller
쓰레드는Selector.selectNow()
를 호출하면 Non-Blocking,Selector.select()
를 호출하면 blocking 방식으로 동작한다.- I/O 준비 작업 완료는
epoll
,kqueue
와 같은 시스템콜을 통해 감지할 수 있다. Selector
의 구현체는 OS마다 다르다. 이는 이벤트 감지를 위한 시스템콜이 다르기 때문이다.Poller
에서는 I/O 준비 작업이 완료된 채널이 있을 경우Selector.selectedKeys()
를 통해 해당 채널들의SelectionKey
를 얻는다.- I/O 준비 작업이 완료된 채널은 쓰레드 풀에서 Worker Thread를 할당받아 이후 처리를 수행하게 된다.
위 내용이 톰캣이 기본적인 동작 원리를 요약한 것이다. 어떻게보면 많이 생략된 내용이 많다고 생각할 수 있지만, 오히려 내구 구현을 자세히 파고들면 톰캣의 기본 동작 원리라는 주제가 희미해질 수 있기에 큰 동작만 정리하였다.
Tomcat은 왜 Non-Blocking인가?
결국 우리가 보았던 Selector.select()
는 Blocking 모드로 동작하지만, Selector.selectNow()
는 Non-Blocking 모드로 동작한다는 것을 알 수 있었다.
톰캣은 이렇듯 poll
, epoll
, kqueue
와 같은 시스템 콜을 사용하여 등록된 채널의 I/O 작업 준비 완료 이벤트를 감지할 수 있기 떄문에 Non-Blocking 방식으로 동작하며 성능을 개선시켰다.
Tomcat은 왜 굳이 Poller 클래스를 두었는가?
앞서 OP_REGISTER
는 톰캣의 Poller
클래스에 정의된 상수값이고, SelectionKeys.OP_READ
나 SelectionKeys.OP_WRITE
는 java.nio에 정의된 상수값이라 설명하였다.
그렇다면 톰캣에서는 결국 Java NIO에서 정의하는 Selector에 채널을 등록하게 될텐데 왜 Poller라는 쓰레드를 두어 OP_REGISTER
인 상태를 거치도록 하였을까?
구조적으로 소켓 연결 / 채널 관리 / 이벤트 감시 / 처리 진행 등의 책임(역할)을 나눈 것외에도 Selector는 쓰레드 안전하지 않기(non-thread safe)때문에 Poller 쓰레드로만 접근 가능하도록 하는 목적도 존재한다.
Selector.selectedKeys()
메서드에는 아래와 같은 주석이 표기되어 있다.

등록된 채널의 SelectionKey를 저장하는 key-set이 쓰레드 안전하지 않다고 명시되어 있다. 따라서, 여러 쓰레드에서 Selector에 무분별한 동시 접근 시 동시성으로 인한 문제가 발생할 수 있다.
따라서, 톰캣에서는 Poller
쓰레드를 두어 해당 쓰레드에서만 Selector
에 접근 가능하도록 설계한 것이다.
결론
톰캣의 기본적인 동작 원리를 탐구하며 추상적인 내용으로만 알고있던 NIO와 Non-Blocking I/O 방식에 대해 다시금 실제 사례를 통해 알 수 있는 기회가 되었다.
흔히 Lock을 제어하거나 메시징큐 등을 사용하는 방법으로 동시성과 부하 문제를 개선하긴 하지만, 결국 WAS인 톰캣에서 해당 요청들을 감당할 수 있어야 그 이후 문제까지 도달할 수 있다. 따라서, 톰캣은 어떻게 수많은 요청을 감당할 수 있을까라는 주제로 해당 포스팅을 준비하며 어려움도 있었지만, 동작 원리를 하나씩 이해할 때마다 시선이 더욱 넓어진다는 것을 느꼈다.
자바의 네이티브 메서드에 대해 개념적인 의미만 알고 있었는데 이번 기회를 통해 시스템콜과 연결되어 실제 동작 제어에 사용되는 사례를 확인할 수 있어 기본적인 자바 개념에도 도움이 된 것 같다.
톰캣에 관한 내용을 찾아보면 글로된 설명은 너무 추상적이고, 실제 내부 코드는 매우 복잡하다. 따라서, 이 2가지를 병행하여 이해하고 분석하여야지만 해당 내용을 이해할 수 있다. 이번 포스팅을 작성하기까지 많은 시간이 걸렸지만 한 줄의 코드를 계속해서 따라가며 탐구하다보니 기본적인 동작 원리 개념을 더 잘 이해할 수 있게 된 것 같다.
특히, Selector를 통한 이벤트 감시 방식으로 I/O Multiplexing을 구현한 것이 어떻게 보면 간단한 해결 방법같지만, 연결 요청을 수락하는 클래스 / 채널을 관리하는 클래스 / 채널의 이벤트를 감시하는 클래스 / 요청을 수행하는 쓰레드를 별도로 두어 효율적인 Non-Blocking I/O를 구현한 것이
다음에는 톰캣의 실제 코드를 더욱 자세히 살펴보고, 어떠한 방식으로 웹 서버가 생성되고 동작하게 되는지 서술할 예정이다.