들어가며
지난 시간에는 DispatcherServlet 구현과 HandlerMapping, HandlerAdapter의 책임 분리에 대해 알아보며, 콘솔을 통해 요청과 응답을 주고받았습니다. 이번 여정에서는 한 걸음 더 나아가, 우리 애플리케이션의 요청을 가장 먼저 맞이하는 웹서버, 톰캣(Tomcat)의 핵심 기능을 직접 구현해보려 합니다.
기존의 콘솔 입출력 방식에서 벗어나, 소켓(Socket) 기반의 통신으로 시작하여 멀티쓰레딩, 쓰레드 풀, 그리고 톰캣 NIO Connector의 핵심인 I/O Multiplexing (Selector 활용)까지 단계별로 발전시켜 나가는 과정을 작성해보았습니다.
환경
JDK 17

🏚 WebServer v1: 소켓 기반 입출력의 첫걸음
가장 먼저, 기존 콘솔 기반 입출력을 소켓(Socket) 기반으로 변경하여 기본적인 웹 서버의 형태를 갖춰보겠습니다. 클라이언트는 이제 콘솔이 아닌 네트워크를 통해 우리 서버에 접속하게 됩니다.
public void listenSocket() {
// try-with-resources 구문을 사용하여 ServerSocket이 자동으로 닫히도록 합니다.
try (ServerSocket serverSocket = new ServerSocket(PORT)) {
System.out.println("서버가 " + PORT + " 포트에서 클라이언트 연결을 기다립니다...");
// serverSocket.accept()는 블로킹 메서드로, 클라이언트 연결이 올 때까지 대기합니다.
try (Socket clientSocket = serverSocket.accept();
InputStreamReader inputStreamReader = new InputStreamReader(clientSocket.getInputStream());
BufferedReader reader = new BufferedReader(inputStreamReader);
PrintWriter writer = new PrintWriter(clientSocket.getOutputStream(), true)) {
System.out.println("클라이언트 연결됨: " + clientSocket.getRemoteSocketAddress());
String inputLine;
while ((inputLine = reader.readLine()) != null) {
if ("exit".equalsIgnoreCase(inputLine.trim())) {
writer.println("서버에서 연결을 종료합니다. 안녕히 가세요!"); // 종료 메시지 개선
System.out.println("클라이언트가 'exit'을 요청하여 연결을 종료합니다.");
break;
}
String[] splitInput = inputLine.split(" ");
HttpMessage request = new HttpMessage(splitInput[0], splitInput[1]);
String output = dispatcherServlet.dispatch(request); // 이전 글에서 구현한 DispatcherServlet 활용
writer.println(output);
}
} catch (IOException e) {
System.err.println("클라이언트와의 통신 중 오류 발생: " + e.getMessage());
} finally {
System.out.println("클라이언트 연결이 종료되었습니다.");
}
} catch (IOException e) {
System.err.println("서버 소켓을 열 수 없습니다 (포트 " + PORT + " 사용 중일 수 있음): " + e.getMessage());
}
System.out.println("서버를 종료합니다.");
}
자, 이렇게 소켓 통신을 구현했습니다. Mac M1 환경에서 telnet localhost [PORT] 명령어로 간단히 테스트해볼 수 있습니다. 이 버전의 가장 큰 한계점은 한 번에 단 하나의 클라이언트 연결만 수락하고 처리한 뒤 서버가 종료된다는 점입니다. 또한, serverSocket.accept()와 reader.readLine() 모두 블로킹(blocking) 방식으로 동작하여, 특정 작업이 완료될 때까지 다른 작업을 수행하지 못합니다.
실제 웹 서버는 동시에 여러 클라이언트의 요청을 처리할 수 있어야겠죠? 이 문제를 해결하기 위해 다음 단계로 넘어가 봅시다.
🍄 WebServer v2: 멀티쓰레딩으로 동시 요청 처리하기
V1의 한계를 극복하기 위해, 클라이언트 연결이 발생할 때마다 새로운 쓰레드를 생성하여 요청을 처리하도록 변경해보겠습니다. "요청 당 쓰레드(Thread-per-request)" 모델입니다.
public void listenSocket() {
try (ServerSocket serverSocket = new ServerSocket(PORT)) {
System.out.println("서버 v2가 " + PORT + " 포트에서 클라이언트 연결을 기다립니다...");
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("새 클라이언트 연결됨: " + clientSocket.getRemoteSocketAddress());
// 각 클라이언트 요청을 별도의 쓰레드에서 처리
new Thread(new ClientRunner(clientSocket, dispatcherServlet)).start();
}
} catch (IOException e) {
System.err.println("서버 소켓 오류 (v2): " + e.getMessage());
}
System.out.println("서버 v2를 종료합니다.");
}
새로운 연결이 들어오면, ClientRunner라는 Runnable 구현체에게 요청 처리를 위임합니다.
public class ClientRunner implements Runnable {
private final Socket socket;
private final DispatcherServlet dispatcherServlet;
public ClientRunner(Socket socket, DispatcherServlet dispatcherServlet) {
this.socket = socket;
this.dispatcherServlet = dispatcherServlet;
}
@Override
public void run() {
try (InputStreamReader inputStreamReader = new InputStreamReader(socket.getInputStream());
BufferedReader reader = new BufferedReader(inputStreamReader);
PrintWriter writer = new PrintWriter(socket.getOutputStream(), true)) {
String inputLine;
while ((inputLine = reader.readLine()) != null) {
if ("exit".equalsIgnoreCase(inputLine.trim())) {
writer.println("서버에서 연결을 종료합니다. 안녕히 가세요!");
break;
}
String[] splitInput = inputLine.split(" ");
HttpMessage request = new HttpMessage(splitInput[0], splitInput[1]);
String output = dispatcherServlet.dispatch(request);
writer.println(output);
}
} catch (IOException e) {
System.err.println(Thread.currentThread().getName() + ": 통신 오류 - " + e.getMessage());
} finally {
try {
if (!socket.isClosed()) {
socket.close();
}
} catch (IOException e) {
System.err.println(Thread.currentThread().getName() + ": 소켓 정리 중 오류 - " + e.getMessage());
}
}
}
}
이제 여러 클라이언트가 동시에 접속해도 각자의 요청을 처리할 수 있게 되었습니다! 하지만 이 방식에도 문제가 있습니다. 클라이언트 요청이 폭주하여 수많은 쓰레드가 생성되면, 메모리 부족(OutOfMemoryError)으로 이어질 수 있고, 잦은 컨텍스트 스위칭(Context Switching)으로 인해 오히려 성능이 저하될 수 있습니다.
🎂 WebServer v3: 쓰레드 풀(Thread Pool)로 효율성 높이기
V2의 문제를 완화하기 위해 쓰레드 풀을 도입해봅시다. 쓰레드를 매번 생성하고 파괴하는 비용을 줄이고, 시스템 자원이 허용하는 범위 내에서 쓰레드 개수를 제어하여 안정성을 높일 수 있습니다.
public void listenSocket() {
ExecutorService executorService = Executors.newFixedThreadPool(10);
try (ServerSocket serverSocket = new ServerSocket(PORT)) {
System.out.println("서버 v3 (쓰레드 풀)가 " + PORT + " 포트에서 클라이언트 연결을 기다립니다...");
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("새 클라이언트 연결됨 (v3): " + clientSocket.getRemoteSocketAddress());
// 쓰레드 풀에 ClientRunner 작업 제출
executorService.execute(new ClientRunner(clientSocket, dispatcherServlet));
}
} catch (IOException e) {
System.err.println("서버 소켓 오류 (v3): " + e.getMessage());
}
System.out.println("서버 v3를 종료합니다.);
}
ClientRunner 코드는 V2와 동일합니다.
쓰레드 풀을 사용함으로써 무분별한 쓰레드 생성을 막고 자원을 보다 효율적으로 관리할 수 있게 되었습니다. 하지만 여전히 해결해야 할 문제가 남아있습니다.
블로킹 I/O의 한계
ClientRunner 내부의 reader.readLine()은 여전히 블로킹 방식입니다. 즉, 클라이언트가 데이터를 보내지 않으면 해당 쓰레드는 계속 대기 상태에 머무르게 됩니다. 만약 연결은 되었지만 아무런 요청도 보내지 않는 클라이언트가 많아진다면, 쓰레드 풀 내의 모든 쓰레드가 소진되어 새로운 요청을 처리하지 못하는 상황(쓰레드 고갈)이 발생할 수 있습니다.
하나의 쓰레드가 하나의 연결을 전담
각 연결은 쓰레드 풀의 쓰레드 하나를 점유합니다. 이는 유휴 연결(idle connection)이 많을 경우 비효율적입니다.
이러한 문제들을 근본적으로 해결하기 위해, 이제 Non-blocking I/O와 I/O Multiplexing 개념을 도입할 시간입니다.
🗼 WebServer v4: I/O Multiplexing (NIO Selector) 도입
드디어 현대적인 웹 서버의 핵심 기술 중 하나인 I/O Multiplexing을 구현해볼 차례입니다. 자바에서는 NIO(New I/O) 패키지의 Selector를 통해 이를 활용할 수 있습니다. Tomcat의 NIO Connector 아키텍처를 참고하여, Acceptor, Poller, Worker 세 가지 주요 컴포넌트로 구성된 시스템을 만들어보겠습니다.
혹시 Tomcat의 NIO Connector 동작 방식에 대해 더 자세히 알고 싶으시다면, 제가 이전에 작성한 글 "톰캣 NIOConnector는 어떻게 수많은 요청을 처리할까?"를 먼저 읽어보시는 것을 추천합니다. 이해에 큰 도움이 될 거예요!
구조는 다음과 같습니다:
- Acceptor 쓰레드 (1개): 클라이언트의 새로운 연결 요청을 수락(accept)하고, 이 연결(소켓 채널)을 Poller에게 등록 요청합니다.
- Poller 쓰레드 (1개): 여러 소켓 채널들을 Selector에 등록하고, I/O 이벤트(예: 데이터 읽기 가능)가 발생했는지 감시합니다. 이벤트가 발생한 채널의 작업을 Worker 쓰레드 풀에 위임합니다.
- Worker 쓰레드 풀: Poller로부터 전달받은 채널에서 실제 데이터 입출력 및 요청 처리를 담당합니다.
이제 각 컴포넌트의 코드를 살펴보겠습니다.
Acceptor
public class Acceptor implements Runnable {
private final Poller poller;
public Acceptor(Poller poller) {
this.poller = poller;
}
@Override
public void run() {
try(ServerSocketChannel server = ServerSocketChannel.open() ) {
server.bind(new InetSocketAddress(9999));
while(true) {
SocketChannel client = server.accept();
System.out.println("클라이언트 연결됨: " + client.getRemoteAddress());
//poller 이벤트큐에 소켓 등록 이벤트 발행
poller.getEventQueue().offer(client);
if(poller.getCount().incrementAndGet() == 0) {
poller.getSelector().wakeup();
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
새로운 연결이 들어오면 SocketChannel을 Poller에게 이벤트 형태로 전달합니다. poller.getSelector().wakeup() 호출은 Poller가 select()에서 대기 중일 때 깨워서 즉시 이벤트를 처리하도록 하기 위함입니다. count 변수를 통해서 이미 깨어있는경우에는 다시 깨우지 않도록 합니다.
Poller
public class Poller implements Runnable {
private final Queue<SocketChannel> eventQueue = new ConcurrentLinkedQueue<>();
private final Selector selector;
private final ExecutorService executorService;
private final DispatcherServlet dispatcherServlet;
private final AtomicInteger count = new AtomicInteger(-1);
public Poller(ExecutorService executorService, DispatcherServlet dispatcherServlet) throws IOException {
this.selector = Selector.open();
this.executorService = executorService;
this.dispatcherServlet = dispatcherServlet;
}
@Override
public void run() {
int selected = 0;
boolean hasEvents = false;
while(true) {
hasEvents = false;
selected = 0;
// 1. Acceptor가 전달한 이벤트 처리 (새로운 소켓 채널 등록)
while(!eventQueue.isEmpty()) {
hasEvents = true;
registerSocket();
}
// 2. Selector를 통해 I/O 이벤트 감시
try {
if(hasEvents) {
selected = selector.selectNow();
} else {
count.set(-1);
System.out.println("before sleep");
selected = selector.select();
count.incrementAndGet();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
if(selected == 0) {
continue;
}
// 3. 감지된 I/O 이벤트 처리
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while(iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if(key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
// 4. Worker에게 작업 위임
Worker worker = new Worker(channel, dispatcherServlet);
executorService.execute(worker);
}
}
}
}
private void registerSocket() {
SocketChannel socket = eventQueue.poll();
try {
socket.configureBlocking(false);
//socket 을 Selector에 등록
socket.register(selector, SelectionKey.OP_READ);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
핵심 로직입니다. Acceptor 에서 보낸 소켓 등록 이벤트를 처리합니다. 또한, Selector를 사용하여 등록된 SocketChannel들에서 I/O 이벤트(주로 OP_READ)를 감지합니다. 이벤트가 감지되면 Worker 쓰레드에 실제 처리를 위임합니다.
Worker
public class Worker implements Runnable {
private final SocketChannel channel;
private final DispatcherServlet dispatcherServlet;
public Worker(SocketChannel channel, DispatcherServlet dispatcherServlet) {
this.channel = channel;
this.dispatcherServlet = dispatcherServlet;
}
@Override
public void run() {
ByteBuffer buffer = ByteBuffer.allocate(1024);
try {
int read = channel.read(buffer);
if(read == -1) {
channel.close();
} else {
buffer.flip();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
String message = new String(data);
System.out.println("---------------------------------------------");
System.out.println("받은 데이터: " + message);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
Worker
Poller로부터 전달받은 SocketChannel에서 데이터를 읽고(non-blocking read), 요청을 입력받는 부분까지 구현되었습니다.
TroubleShooting: Selector와 Level-Triggered
위와 같이 Acceptor, Poller, Worker 구조로 구현하고 입력이 잘 되는지 테스트를 진행했습니다. 클라이언트 하나를 연결하고 "sample request"라는 메시지를 보냈더니, 다음과 같은 결과가 나왔습니다.
---------------------------------------------
받은 데이터: sample request // 정상 수신
---------------------------------------------
받은 데이터: // 빈 데이터?
---------------------------------------------
받은 데이터:
---------------------------------------------
받은 데이터:
... (계속 반복)
분명히 요청은 한 번 보냈는데, Worker가 여러 번 호출되면서 빈 데이터를 계속 읽어들이는 현상이 발생했습니다. 멀티쓰레딩 환경에서의 디버깅은 익숙하지 않아 처음에는 원인 파악조차 막막했습니다. 디버깅환경과 실행환경에서 결과가 계속 다르게 나와서 원인을 찾기 더욱 힘들었습니다.
가설 1: selector.selectedKeys()에 여러 개의 SelectionKey가 들어왔나?
디버깅 결과, 한 번의 요청에 selectedKeys()에는 해당 채널의 SelectionKey 하나만 존재했습니다. 이 가설은 기각.
가설 2: selector.select()가 너무 빨리 깨어나나?
여러 번의 System.out.println 디버깅을 통해, selector.select()가 한 번 OP_READ 이벤트를 감지한 후, Worker에게 작업을 넘겼음에도 불구하고 다음 select() 호출 시 즉시 동일 채널에 대해 또다시 OP_READ 이벤트를 감지하여 깨어나는 것을 확인했습니다.
이 현상의 원인은 Selector의 기본 동작 방식인 Level-Triggered 때문이었습니다. Level-Triggered 방식에서는 이벤트 발생 조건이 해소되지 않는 한 (즉, 소켓 버퍼에 읽을 데이터가 남아있는 한) select()는 계속해서 해당 이벤트를 보고합니다. Worker가 channel.read(buffer)를 통해 데이터를 읽어갔지만, 그 시점과 Poller가 다시 select()를 호출하는 시점 사이에 미세한 타이밍 차이나, 또는 버퍼에 아직 읽지 않은 (혹은 이후 즉시 도착한) 데이터가 남아있다면 Poller는 계속 "읽을 데이터 있음!"이라고 판단하는 것입니다.
임시 해결책: Poller가 직접 데이터를 읽어보자!
Poller의 while(iterator.hasNext()) 루프 안에서, OP_READ가 감지된 채널의 데이터를 Worker에게 넘기기 전에 직접 읽어보았습니다.
while(iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if(key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
Worker worker = new Worker(channel, dispatcherServlet);
ByteBuffer buffer = ByteBuffer.allocate(1024);
try {
int read = channel.read(buffer);
if(read == -1) {
channel.close();
} else {
buffer.flip();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
String message = new String(data);
System.out.println("---------------------------------------------");
System.out.println("받은 데이터: " + message);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
executorService.execute(worker);
}
}
이렇게 하니 일단 반복적인 빈 데이터 읽기 문제는 사라졌습니다. 하지만 이는 I/O Multiplexing의 핵심 설계 원칙을 위배하는 방식입니다. Poller는 이벤트 감지 및 분배 역할에 집중해야 하며, 실제 I/O 작업(데이터 읽기/쓰기)은 Worker 쓰레드가 담당해야 합니다. Poller가 직접 read()를 호출하면 Poller 쓰레드가 블로킹될 수 있고, 이는 전체 시스템의 반응성을 떨어뜨립니다.
진정한 해결책: interestOps를 활용한 이벤트 마스킹
Tomcat 코드를 다시 살펴보고 NIO 관련 자료를 더 확인해본 결과, 해답은 SelectionKey의 interestOps에 있었습니다. Worker에게 작업을 위임하기 전에 해당 채널의 OP_READ 이벤트에 대한 관심을 잠시 "끄고", Worker가 작업 처리를 완료한 후 다시 "켜는" 것입니다.
Poller: OP_READ 이벤트 감지 시, 해당 SelectionKey에서 OP_READ를 제거.
if(key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
key.interestOps(key.interestOps() & ~SelectionKey.OP_READ);
Worker worker = new Worker(channel, dispatcherServlet,this);
executorService.execute(worker);
}
Worker: 데이터 처리 완료 후, Poller에게 해당 채널의 OP_READ를 다시 등록해달라고 이벤트(PollerEvent.Type.OP_READ_REGISTER)를 보냄.
private void recoverReadOP() {
PollerEvent opRead = new PollerEvent(PollerEvent.Type.OP_READ_REGISTER, channel);
poller.getEventQueue().offer(opRead);
if(poller.getCount().incrementAndGet() ==0) {
poller.getSelector().wakeup();
}
}
Poller: OP_READ_REGISTER 이벤트 수신 시, 해당 SelectionKey에 OP_READ를 다시 추가 (sk.interestOps(sk.interestOps() | SelectionKey.OP_READ);).
이렇게 하면 Worker가 데이터를 완전히 처리하기 전까지 Poller는 해당 채널의 OP_READ 이벤트를 무시하게 되어, 불필요한 반복 호출을 막을 수 있습니다.
🏹 최종버전: 완성된 NIO 기반 웹 서버
위의 트러블슈팅에서 얻은 교훈을 반영한 최종 버전의 코드입니다.
Acceptor
public class Acceptor implements Runnable {
private final Poller poller;
public Acceptor(Poller poller) {
this.poller = poller;
}
@Override
public void run() {
try(ServerSocketChannel server = ServerSocketChannel.open() ) {
server.bind(new InetSocketAddress(9999));
while(true) {
SocketChannel client = server.accept();
System.out.println("클라이언트 연결됨: " + client.getRemoteAddress());
PollerEvent registerEvent = new PollerEvent(PollerEvent.Type.SOCK_REGISTER, client);
poller.getEventQueue().offer(registerEvent);
if(poller.getCount().incrementAndGet() == 0) {
poller.getSelector().wakeup();
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
Poller
public class Poller implements Runnable {
private final Queue<PollerEvent> eventQueue = new ConcurrentLinkedQueue<>();
private final Selector selector;
private final ExecutorService executorService;
private final DispatcherServlet dispatcherServlet;
private final AtomicInteger count = new AtomicInteger(-1);
public Poller(ExecutorService executorService, DispatcherServlet dispatcherServlet) throws IOException {
this.selector = Selector.open();
this.executorService = executorService;
this.dispatcherServlet = dispatcherServlet;
}
@Override
public void run() {
int selected = 0;
boolean hasEvents = false;
while(true) {
hasEvents = false;
selected = 0;
while(!eventQueue.isEmpty()) {
hasEvents = true;
handleEvents();
}
try {
if(hasEvents) {
selected = selector.selectNow();
} else {
count.set(-1);
selected = selector.select();
count.incrementAndGet();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
if(selected == 0) {
continue;
}
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while(iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if(key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
//selector 이벤트 키 제거
key.interestOps(key.interestOps() & ~SelectionKey.OP_READ);
Worker worker = new Worker(channel, dispatcherServlet,this);
executorService.execute(worker);
}
}
}
}
private void handleEvents() {
PollerEvent event = eventQueue.poll();
SocketChannel socket = event.getChannel();
//이벤트 별 소켓등록 , 이벤트 마스크 변경 추가
if(event.getType() == PollerEvent.Type.SOCK_REGISTER) {
try {
socket.configureBlocking(false);
socket.register(selector, SelectionKey.OP_READ);
} catch (IOException e) {
throw new RuntimeException(e);
}
} else if (event.getType() == PollerEvent.Type.OP_READ_REGISTER) {
final SelectionKey sk = socket.keyFor(getSelector());
sk.interestOps(sk.interestOps() | SelectionKey.OP_READ);
}
}
}
Worker
public class Worker implements Runnable {
private final SocketChannel channel;
private final DispatcherServlet dispatcherServlet;
private final Poller poller;
public Worker(SocketChannel channel, DispatcherServlet dispatcherServlet, Poller poller) {
this.channel = channel;
this.dispatcherServlet = dispatcherServlet;
this.poller = poller;
}
@Override
public void run() {
ByteBuffer buffer = ByteBuffer.allocate(1024);
try {
int read = channel.read(buffer);
if(read == -1) {
channel.close();
} else {
buffer.flip();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
String message = new String(data);
String[] splitted = message.split(" ");
HttpMessage request = new HttpMessage(splitted[0], splitted[1]);
String response = dispatcherServlet.dispatch(request);
response += "\n";
ByteBuffer responseBuffer = ByteBuffer.wrap(response.getBytes(StandardCharsets.UTF_8));
buffer.clear();
channel.write(responseBuffer);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
//이벤트 마스크 변경 이벤트 추가
recoverReadOP();
}
private void recoverReadOP() {
PollerEvent opRead = new PollerEvent(PollerEvent.Type.OP_READ_REGISTER, channel);
poller.getEventQueue().offer(opRead);
if(poller.getCount().incrementAndGet() ==0) {
poller.getSelector().wakeup();
}
}
}
main 메서드에서 웹 서버 시작
private static void initializeWebServer() {
final int maxThreads = 10;
DispatcherServlet dispatcherServlet = (DispatcherServlet) SpringContext.BEAN_MAP.get(DispatcherServlet.class);
//Worker 쓰레드풀
ExecutorService executorService = Executors.newFixedThreadPool(maxThreads);
try {
Poller poller = new Poller(executorService, dispatcherServlet);
Acceptor acceptor = new Acceptor(poller);
// Poller와 Acceptor를 별도의 쓰레드에서 실행
new Thread(poller).start();
new Thread(acceptor).start();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
이제 이 V4 웹 서버는 여러 TCP 연결을 동시에 관리하면서, 실제 데이터가 준비된 연결에 대해서만 Worker 쓰레드를 할당하여 효율적으로 요청을 처리할 수 있게 되었습니다! 한정된 쓰레드로 훨씬 많은 동시 연결을 다룰 수 있는 기반이 마련된 것입니다.
마치며
이번 글에서는 단순한 소켓 통신에서 시작하여 멀티쓰레딩, 쓰레드 풀, 그리고 최종적으로 Tomcat NIO Connector의 핵심 아이디어를 차용한 I/O Multiplexing (Selector) 기반 웹 서버까지 단계별로 구현해 보았습니다. 특히 Non-blocking I/O와 Selector를 다루면서 겪었던 트러블슈팅 과정은 멀티쓰레딩 및 비동기 프로그래밍에 대한 이해를 한층 높여주는 귀중한 경험이었습니다.
다음 시리즈에서는 트랜잭션과 관련한 스프링 내부 구현으로 찾아뵙겠습니다! 긴 글 읽어주셔서 감사합니다.
Reference
https://aplbly.tistory.com/24
https://jh-labs.tistory.com/329
'프로젝트 > 스프링 직접 구현하기' 카테고리의 다른 글
| 스프링 직접 구현하기 #6: AOP 구현을 위한 IoC 컨테이너 리팩토링 (0) | 2025.06.06 |
|---|---|
| 스프링 직접 구현하기 #4: DispatcherServlet v2, 그리고 실제 스프링과의 비교 (0) | 2025.05.25 |
| 스프링 직접 구현하기 #3: DispatcherServlet 구현 (0) | 2025.05.16 |
| 스프링 직접 구현하기 #2: 생성자 주입 (0) | 2025.05.10 |
| 스프링 직접 구현하기 #1: IoC와 DI 구현 (0) | 2025.05.10 |