본문 바로 가기

스프링 직접 구현하기 #5: 톰캣 - I/O Multiplexing 구현

들어가며

지난 시간에는 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 관련 자료를 더 확인해본 결과, 해답은 SelectionKeyinterestOps에 있었습니다. 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 이벤트 수신 시, 해당 SelectionKeyOP_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