커스텀 Non-Blocking I/O 서버 아키텍처 전체 해부

Selector와 이벤트 루프로 HTTP를 처리하는 방식

#spring#sprout#nio#bio#netty#tomcat

이 글은 직접 만든 Sprout 프레임워크에 내장된 HTTP 서버가 Java NIO(New I/O)를 기반으로 어떻게 Non-Blocking I/O를 구현하고, 다수의 동시 연결을 어떻게 처리하는지에 대해 상세히 설명해 볼 예정이다. 특히 Selector를 활용한 이벤트 루프 모델과 HTTP 요청 처리 흐름을 중심으로 서술해 보려고 한다.

어디까지나 해당 프로젝트는 개인의 학습에 목적이 있습니다.
추가적인 개선 사항 및 확장, 건의는 언제나 환영 😆


💡 진짜 간단한 요약

  • I/O 스레드 = 이벤트 감시자 (Selector 루프에서 OP_ACCEPT, OP_READ, OP_WRITE 관찰)
  • 워커 스레드 = 요청 처리자 (비즈니스 로직 실행, 응답 생성)

Sprout는 I/O 스레드는 한 개만 사용하고,
요청 처리는 워커 스레드로 분리됨 (가상 스레드 or 플랫폼 스레드 선택 가능)

  • HTTP는 워커 스레드를 통해 처리되며, 블로킹 / 논블로킹 방식 선택 가능
  • WebSocket은 항상 논블로킹 (NIO) 으로 고정 처리됨

1. Non-Blocking I/O와 이벤트 루프의 필요성

전통적인 Blocking I/O 모델에서는 클라이언트 연결마다 하나의 스레드를 할당한다. 이 방식은 구현이 간단하지만, 수천, 수만 개의 동시 연결이 발생하면 스레드 생성 및 컨텍스트 스위칭 비용이 기하급수적으로 증가하여 서버의 성능 저하를 유발하기도 한다.

이 문제를 해결하기 위해 상용 서버 프레임워크들은 주로 Java NIO의 핵심 컴포넌트를 사용한 이벤트 기반 Non-Blocking I/O 모델을 채택한다. 이 모델은 단일 스레드(또는 소수의 스레드)가 Selector를 통해 다수의 Channel(연결)에서 발생하는 I/O 이벤트를 감지하고 처리한다. 이를 통해 스레드 자원을 매우 효율적으로 사용하며 높은 동시성(Concurrency)을 달성할 수 있게 된다.

나 또한 이러한 구조를 깊이 있게 알기 위해 이러한 프로젝트를 진행하였다.

  • ServerSocketChannel: 서버가 클라이언트의 연결 요청을 수신하는 채널이다. Non-blocking 모드로 설정된다.
  • SocketChannel: 각 클라이언트와 서버 간의 데이터 통신을 위한 채널이다. 역시 Non-blocking 모드로 작동.
  • Selector: 여러 Channel을 등록하고, 그중 I/O 이벤트가 발생한 채널을 알려주는 역할을 한다. 이벤트 루프의 심장과도 같다. 단순하게, I/O 스레드라고 생각해도 된다.
  • SelectionKey: Selector가 특정 Channel의 이벤트를 감지할 때 사용하는 키이다. 이벤트의 종류(ACCEPT, READ, WRITE)와 상태를 담고 있다.

2. 서버의 핵심 아키텍처 컴포넌트

Sprout에서 구현할 때 Non-Blocking 서버는 여러 컴포넌트의 유기적인 상호작용으로 구성하였다.

  • NioHybridServerStrategy: Selector를 사용하여 메인 이벤트 루프를 실행하는 핵심 전략 클래스이다. 서버의 심장부 역할. 사실 전략 중 하나라 이 부분도 확장이 가능하지만, 현재는 모든 모드에서 동일하게 사용할 수 있어서 추가적인 다른 구현체는 없다.
  • ConnectionManager: 새로운 클라이언트 연결을 수락하고, 해당 연결이 어떤 프로토콜(HTTP, WebSocket 등)을 사용하는지 감지하여 적절한 핸들러에게 전달한다.
  • ProtocolDetector: 채널에서 읽은 초기 바이트를 분석하여 프로토콜을 식별한다. (HttpProtocolDetector가 대표적입니다.)
  • ProtocolHandler: 특정 프로토콜의 요청을 처리하는 핸들러. Sprout은 NioHttpProtocolHandler (Pure NIO)와 BioHttpProtocolHandler (Hybrid) 두 가지 HTTP 처리 방식을 제공한다. 이 문서에서는 NioHttpProtocolHandler를 중심으로 설명한다.
  • HttpConnectionHandler: Pure NIO 모드에서 개별 HTTP 연결의 상태(읽기, 처리, 쓰기)를 관리하는 상태 기반(stateful) 핸들러. SelectionKey에 첨부(attachment)되어 이벤트 발생 시 호출된다.
  • RequestExecutorService: I/O 작업과 실제 비즈니스 로직 처리를 분리하기 위한 스레드 서비스이다. 이는 인터페이스이고, 이에 대한 구현체로 각각 가상 스레드 모드, 플랫폼 스레드 모드가 있는 것이다. I/O 스레드가 비즈니스 로직 처리로 인해 블로킹되는 것을 방지한다.

3. 프로토콜 감지(Protocol Detection)와 라우팅

Selector가 새로운 연결을 수락한 직후, 서버는 이 연결을 통해 어떤 프로토콜(HTTP, WebSocket 등)의 데이터가 오고 갈지 결정해야 한다. 이 중요한 역할을 ConnectionManagerProtocolDetector가 담당한다.

DefaultConnectionManager는 등록된 ProtocolDetector들의 리스트를 가지고 순서대로 실행하여 프로토콜을 식별한다.

// DefaultConnectionManager.java @Override public void acceptConnection(SelectionKey selectionKey, Selector selector) throws Exception { // ... 연결 수락 및 초기 바이트 읽기 ... // 3. 프로토콜 감지 String detectedProtocol = "UNKNOWN"; for (ProtocolDetector detector : detectors) { detectedProtocol = detector.detect(buffer); if (!"UNKNOWN".equals(detectedProtocol)) { break; // 프로토콜이 식별되면 중단 } } // 4. 적절한 핸들러로 라우팅 for (ProtocolHandler handler : handlers) { if (handler.supports(detectedProtocol)) { // ... 핸들러 실행 ... } } }

WebSocket 감지 및 NIO 처리 강제

여기서 중요한 점은 서버의 기본 동작 모드가 hybrid(BIO)로 설정되어 있더라도, WebSocket 연결은 항상 NIO 방식으로 처리된다는 것이다.

WebSocket 프로토콜은 클라이언트와 서버 간의 연결이 계속 유지되는(stateful) 양방향 통신을 전제로 한다. 이러한 특성 때문에 연결마다 스레드를 블로킹시키는 BIO 모델보다는, 이벤트 기반으로 다수의 연결을 관리하는 NIO 모델이 훨씬 효율적이고 적합하다. 실제로 톰캣도 이와 같이 구성되어 있기 때문에 해당 모델을 따르도록 설계하였다.

이 점을 고려하여 다음과 같이 프로토콜을 지능적으로 감지하고 라우팅 한다.

  1. WebSocket 업그레이드 요청 식별: WebSocket 연결은 표준 HTTP GET 요청으로 시작된다. 다만, 헤더에 Upgrade: websocketConnection: Upgrade 같은 특정 정보를 포함하고 있다.
  2. WebSocketProtocolDetector: ProtocolDetector 체인에는 일반 HttpProtocolDetector보다 우선순위가 높은 WebSocketProtocolDetector가 존재한다. 이 탐지기는 초기 데이터에서 위의 Upgrade 헤더가 있는지를 먼저 확인한다.
  3. 프로토콜 라우팅: Upgrade 헤더가 발견되면, 프로토콜은 HTTP/1.1이 아닌 WEBSOCKET으로 식별된다.
  4. NIO 핸들러 매핑: ConnectionManagerWEBSOCKET 프로토콜을 지원하는 핸들러를 찾는다. 이 핸들러는 서버의 동작 모드와 관계없이 항상 Non-blocking I/O를 기반으로 구현된 NioWebSocketProtocolHandler이다.

결과적으로, hybrid 모드에서 일반적인 HTTP 요청은 BioHttpProtocolHandler로 전달되어 BIO 방식으로 처리되지만, WebSocket 연결 요청은 BioHttpProtocolHandler를 우회하여 NioWebSocketProtocolHandler로 직접 전달된다. 이를 통해 각 프로토콜의 특성에 가장 적합한 I/O 모델로 처리하는 유연하고 효율적인 아키텍처를 구현하고자 하였다.

4. 하이브리드 모드(Hybrid Mode): NIO와 BIO의 결합

Sprout은 Pure NIO 모드 외에도, NIO의 장점(이벤트 기반 연결 수락)과 전통적인 BIO(Blocking I/O)의 장점(단순한 프로그래밍 모델)을 결합한 하이브리드 모드를 지원하도록 하였다. 이 모드는 특히 Java 21의 가상 스레드(Virtual Threads)와 함께 사용할 때 높은 처리량과 개발 편의성을 동시에 제공하고자 하였다.

하이브리드 모드의 핵심 아이디어는 다음과 같다.

  1. 연결 수락은 Non-blocking NIO로 처리한다. (SelectorOP_ACCEPT 처리)
  2. 연결이 수립되고 HTTP 프로토콜이 감지되면, 해당 SocketChannelNIO Selector의 관리에서 분리한다.
  3. 채널을 Blocking 모드로 전환(channel.configureBlocking(true))한다.
  4. 이후의 모든 I/O(요청 읽기, 응답 쓰기)는 별도의 워커 스레드에서 Blocking 방식으로 처리한다.

하이브리드 모드의 처리 흐름

  1. 1~2단계 (연결 수락 및 프로토콜 감지) Pure NIO 모드와 동일하게 SelectorOP_ACCEPT 이벤트를 받아 ConnectionManager가 연결을 수락하고 프로토콜을 감지.
  2. 핸들러 선택: 서버 설정이 하이브리드 모드일 경우, ConnectionManagerBioHttpProtocolHandler를 선택하여 accept() 메소드를 호출. 걍 내부적으로 인터페이스로 주입받는데, 모드를 미리 감지해서 해당 인터페이스에 맞는 구현체를 하나만 생성해둠.
  3. BioHttpProtocolHandler의 동작
    • SocketChannelSelector에서 분리한다.
    • channel.configureBlocking(true)를 호출하여 채널을 블로킹 모드로 만든다.
    • RequestExecutorService를 통해 워커 스레드(주로 가상 스레드)에게 이후의 모든 처리 과정을 위임한다.
  4. 워커 스레드의 Blocking I/O 처리
    • 워커 스레드는 socket.getInputStream()socket.getOutputStream()을 사용하여 전통적인 방식으로 요청을 전부 읽고 응답을 전부 보낸다
    • 각 I/O 작업은 데이터가 준비될 때까지 해당 스레드를 블로킹하지만, 가상 스레드를 사용하면 이 블로킹 비용이 매우 저렴하므로 전체 서버의 성능 저하 없이 수많은 동시 요청을 처리할 수 있을것이다.
// BioHttpProtocolHandler.java @Override public void accept(SocketChannel channel, Selector selector, ByteBuffer initialBuffer) throws Exception { // 1. NIO selector에서 분리 detachFromSelector(channel, selector); // 2. Blocking 모드로 전환 channel.configureBlocking(true); Socket socket = channel.socket(); // 3. 워커 스레드(가상 스레드)에 작업 위임 requestExecutorService.execute(() -> { try (InputStream in = socket.getInputStream(); BufferedWriter out = new BufferedWriter( new OutputStreamWriter(socket.getOutputStream()))) { // 4. 요청을 끝까지 읽음 (Blocking) String rawRequest = HttpUtils.readRawRequest(initialBuffer, in); // 5. 파싱 및 디스패치 HttpRequest<?> req = parser.parse(rawRequest); HttpResponse res = new HttpResponse(); dispatcher.dispatch(req, res); // 6. 응답을 씀 (Blocking) writeResponse(out, res.getResponseEntity()); } catch (Exception e) { e.printStackTrace(); } }); }

이 방식은 Non-blocking NIO의 복잡한 상태 관리(state machine) 없이, 동기적이고 순차적인 코드로 비즈니스 로직을 작성할 수 있게 해주어 코드의 가독성과 유지보수성을 크게 향상시켰다.

5. HTTP 요청 처리 흐름 (Pure NIO 모드)

이제 클라이언트가 HTTP 요청을 보내고 서버가 응답을 완료하기까지의 전체 과정을 단계별로 따라가 보자.

1단계: 서버 시작 및 연결 대기

  1. SproutApplication이 실행되면 HttpServerNioHybridServerStrategy를 사용하여 서버를 시작한다.
  2. NioHybridServerStrategySelectorServerSocketChannel을 생성하고, ServerSocketChannel을 Non-blocking 모드로 설정한다.
  3. ServerSocketChannelSelectorSelectionKey.OP_ACCEPT 이벤트로 등록한다. 이는 "새로운 클라이언트 연결 요청이 오면 알려달라"는 의미다.
  4. eventLoop() 메소드가 별도의 스레드에서 실행되며, selector.select()를 호출하여 이벤트가 발생할 때까지 대기한다.
// NioHybridServerStrategy.java private void eventLoop() { while (running) { selector.select(); // 이벤트가 발생할 때까지 블로킹 for (Iterator<SelectionKey> it = selector.selectedKeys().iterator(); it.hasNext();) { SelectionKey key = it.next(); it.remove(); // ... 이벤트 종류에 따라 처리 로직 분기 ... } } }

2단계: 새로운 연결 수락 (OP_ACCEPT)

  1. 클라이언트가 서버에 연결을 시도하면, SelectorOP_ACCEPT 이벤트를 감지하고 select() 메소드의 대기 상태를 해제한다.
  2. 이벤트 루프는 해당 SelectionKeyisAcceptable()인지 확인하고, ConnectionManager.acceptConnection()을 호출.
  3. ConnectionManagerserverChannel.accept()를 통해 클라이언트와의 SocketChannel을 생성하고, 이 채널 역시 Non-blocking 모드로 설정함.

3단계: 프로토콜 감지 및 핸들러 등록

  1. ConnectionManager는 생성된 SocketChannel에서 약간의 데이터를 읽어 ProtocolDetector에게 전달한다. (이때, 버퍼로 읽은 데이터는 한번 읽으면 소실되는데, 추후 재사용(응답 파싱)을 위해 복구하는 로직도 포함됨)
  2. HttpProtocolDetector는 읽어들인 데이터의 시작 부분이 "GET ", "POST " 등과 같은 HTTP 메소드 패턴과 일치하는지 확인하여 "HTTP/1.1" 프로토콜을 감지한다.
  3. ConnectionManager는 감지된 프로토콜을 지원하는 NioHttpProtocolHandler를 찾아 accept() 메소드를 호출한다.
  4. (핵심) NioHttpProtocolHandler는 요청을 직접 처리하지 않는다. 대신, 이 연결의 상태를 전담하여 관리할 HttpConnectionHandler 인스턴스를 생성한다.
  5. 생성된 HttpConnectionHandlerSocketChannelSelectionKey에 **첨부(attachment)**하고, SelectorSelectionKey.OP_READ 이벤트를 등록한다. 이는 "이 채널을 통해 데이터가 들어오면 알려달라"는 의미임.
// NioHttpProtocolHandler.java @Override public void accept(SocketChannel channel, Selector selector, ByteBuffer initialBuffer) throws Exception { // 1. 상태 기반 연결 핸들러 생성 HttpConnectionHandler handler = new HttpConnectionHandler(...); // 2. 핸들러를 첨부하여 READ 이벤트 등록 channel.register(selector, SelectionKey.OP_READ, handler); // 3. 초기 읽기 트리거 handler.read(channel.keyFor(selector)); }

4단계: 요청 읽기 (OP_READ)

  1. 클라이언트가 HTTP 요청 데이터를 전송하면, SelectorOP_READ 이벤트를 감지한다.
  2. 이벤트 루프는 key.isReadable()을 확인하고, SelectionKey에 첨부된 HttpConnectionHandlerread() 메소드를 호출.
  3. read() 메소드는 Non-blocking 방식으로 channel.read(readBuffer)를 호출하여 소켓 버퍼의 데이터를 ByteBuffer로 읽어들인다.
  4. HttpUtils.isRequestComplete()를 통해 현재까지 읽은 데이터가 완전한 HTTP 요청(헤더의 끝을 나타내는 CRLF(CR + LF)과 Content-Length 또는 chunked 인코딩 기반의 바디 전체)인지 반복해서 확인한다.
  5. 요청이 아직 불완전하다면, 메소드는 종료되고 이벤트 루프는 다음 이벤트를 기다린다. Selector는 데이터가 더 들어오면 다시 OP_READ 이벤트를 발생시킬 것이다.

5단계: 요청 처리 및 비즈니스 로직 실행

  1. 요청이 완전해지면, HttpConnectionHandler는 상태를 PROCESSING으로 변경하고 key.interestOps(0)을 호출하여 더 이상 Selector가 이 채널의 이벤트를 감지하지 않도록 임시 중단시킨다.
  2. (중요) I/O 스레드(이벤트 루프)의 블로킹을 막기 위해, 파싱된 요청 데이터(rawRequest)를 RequestExecutorService(별도의 워커 스레드 풀)에 태스크로 제출하여 비즈니스 로직을 비동기적으로 실행한다.
  3. 워커 스레드는 다음을 수행한다.
    • HttpRequestParser를 통해 원시 요청 문자열을 HttpRequest 객체로 파싱
    • RequestDispatcher를 통해 요청을 처리할 적절한 컨트롤러 메소드를 찾아 실행
    • 컨트롤러의 실행 결과를 HttpResponse 객체에 담기

6단계: 응답 준비 및 쓰기 상태 전환

  1. 워커 스레드에서 HttpResponse가 준비되면, HttpUtils.createResponseBuffer()를 통해 응답 데이터를 ByteBuffer(writeBuffer)로 변환한다.
  2. HttpConnectionHandler의 상태를 WRITING으로 변경한다.
  3. SelectionKey의 관심 이벤트를 SelectionKey.OP_WRITE로 다시 등록하고, selector.wakeup()을 호출하여 select()에서 대기 중인 이벤트 루프를 즉시 깨운다. 이는 관심 이벤트가 변경되었음을 Selector에게 바로 알리기 위함이다.
// HttpConnectionHandler.java - 워커 스레드 내부 requestExecutorService.execute(() -> { try { // ... 요청 파싱 및 디스패치 ... HttpRequest<?> req = parser.parse(rawRequest); HttpResponse res = new HttpResponse(); dispatcher.dispatch(req, res); // 5. 응답 준비 및 상태 전환 this.writeBuffer = HttpUtils.createResponseBuffer(res.getResponseEntity()); this.currentState = HttpConnectionStatus.WRITING; // 6. WRITE 이벤트 등록 및 Selector 깨우기 key.interestOps(SelectionKey.OP_WRITE); selector.wakeup(); } catch (Exception e) { // ... } });

7단계: 응답 쓰기 (OP_WRITE)

  1. selector.wakeup() 호출과 OP_WRITE 등록으로 인해, Selector는 해당 채널의 소켓 버퍼가 쓸 준비가 되면 즉시 OP_WRITE 이벤트를 발생시킨다.
  2. 이벤트 루프는 key.isWritable()을 확인하고, 첨부된 HttpConnectionHandlerwrite() 메소드를 호출한다.
  3. write() 메소드는 channel.write(writeBuffer)를 호출하여 준비된 응답 데이터를 클라이언트에게 전송한다.
  4. 한 번의 write 호출로 writeBuffer의 모든 데이터가 전송되지 않을 수 있다(TCP 버퍼 크기 제한 등). 버퍼에 데이터가 남아있다면(writeBuffer.hasRemaining()), 메소드는 종료. Selector는 소켓 버퍼가 다시 비워지면 또다시 OP_WRITE 이벤트를 발생시켜 나머지 데이터가 전송되도록 보장하도록 한다.

8단계: 연결 종료

  1. writeBuffer의 모든 데이터가 성공적으로 전송되면, HttpConnectionHandler는 상태를 DONE으로 변경하고 closeConnection()을 호출하여 SocketChannel을 닫고 SelectionKey를 무효화한다.
  2. 이로써 하나의 HTTP 요청-응답 사이클이 완료되었다.

6. 플러그인 구조를 통한 확장성 (Extensibility through Pluggable Architecture)

Sprout 서버 아키텍처의 또 다른 핵심적인 특징은 바로 높은 확장성이다. 실제로 이 구조를 구축하는데에 신경을 많이 썼다. 서버의 주요 구성 요소들은 하드코딩되어 있지 않으며, IoC/DI 컨테이너가 관리하는 교체 가능한 부품(Bean) 들의 조합으로 이루어져 있다.

이러한 플러그인 구조 덕분에 개발자는 새로운 프로토콜, 다른 스레드 실행 모델, 또는 완전히 새로운 I/O 처리 방식을 원할 때, 프레임워크의 핵심 코드를 수정하지 않고도 자신만의 구현체를 만들어 손쉽게 통합할 수 있게 된다.

설정 기반의 컴포넌트 교체

이 확장성은 @Configuration 어노테이션을 사용한 설정 클래스와 application.yml 파일을 통해 명확하게 드러난다. 예를 들어, ServerConfiguration 클래스는 다음과 같이 서버의 핵심 동작을 결정하는 Bean들을 정의한다.

// ServerConfiguration.java @Configuration public class ServerConfiguration { @Bean public RequestExecutorService executorService(AppConfig appConfig, List<ContextPropagator> contextPropagators) { // "server.thread-type" 프로퍼티 값에 따라 스레드 실행 모드를 결정 String threadType = appConfig.getStringProperty("server.thread-type", "virtual"); if (threadType.equals("virtual")) { return new VirtualRequestExecutorService(contextPropagators); } return new RequestExecutorPoolService(appConfig.getIntProperty("server.thread-pool-size", 100)); } @Bean public AcceptableProtocolHandler httpProtocolHandler(AppConfig appConfig, /*...dependencies...*/) { // "server.execution-mode" 프로퍼티 값에 따라 I/O 처리 핸들러를 결정 String executionMode = appConfig.getStringProperty("server.execution-mode", "hybrid"); if (executionMode.equals("hybrid")) { return new BioHttpProtocolHandler(...); } return new NioHttpProtocolHandler(...); } }

개발자는 application.yml 파일의 프로퍼티 값을 변경하는 것만으로 서버의 동작 방식을 근본적으로 바꿀 수 있게 된다.

# application.yml server: execution-mode: nio # nio 또는 hybrid thread-type: virtual # virtual 또는 platform
  • execution-modenio로 바꾸면 NioHttpProtocolHandler가 주입
  • thread-typeplatform으로 바꾸면 RequestExecutorPoolService가 주입

개발자에 의한 커스텀 구현체 통합

이 구조의 진정한 강점은 개발자가 자신만의 컴포넌트를 만들어 시스템에 자연스럽게 통합할 수 있다는 것이다.(그래서 학습용으로 좋을듯?)

예를 들어, 개발자가 MyCustomProtocolHandler라는 새로운 프로토콜 핸들러를 만들었다고 가정해 보자. 이 핸들러를 시스템에 통합하는 방법은 매우 간단하다.

  1. AcceptableProtocolHandler 인터페이스를 구현하는 MyCustomProtocolHandler 클래스를 작성한다.
  2. @Configuration 클래스에 새로운 @Bean 메소드를 추가하여 옵션별로 분기시키거나, 하나만 사용한다면 @Component를 달면 된다.
// 예시: 기존 httpProtocolHandler 메소드 수정 @Bean public AcceptableProtocolHandler httpProtocolHandler(AppConfig appConfig, ...) { String executionMode = appConfig.getStringProperty("server.execution-mode", "hybrid"); if (executionMode.equals("hybrid")) { return new BioHttpProtocolHandler(...); } else if (executionMode.equals("my-custom-mode")) { // 개발자가 만든 커스텀 핸들러 주입 return new MyCustomProtocolHandler(...); } return new NioHttpProtocolHandler(...); }

또한, Sprout의 DI 컨테이너는 특정 타입의 모든 Bean을 List로 주입하는 기능을 지원한다. ConnectionManagerList<ProtocolDetector>List<ProtocolHandler>를 주입받도록 설계되어 있으므로, 개발자가 새로운 프로토콜 탐지기나 핸들러를 단순히 Bean으로 등록하기만 하면 ConnectionManager가 이를 자동으로 인식하고 자신의 로직에 포함시킨다.

이처럼 서버는 핵심 기능들이 모듈화되어 있고 DI를 통해 유연하게 조립되는 구조를 가짐으로써, 변화하는 요구사항과 새로운 기술에 쉽게 적응할 수 있는 높은 수준의 확장성을 제공할 수 있을 것이다.

사실 그냥 이 부분은 스프링 체계를 그대로 모방해온 것이라 당연하다.

7. 아키텍처 비교: Sprout vs. Tomcat vs. Netty

Sprout 서버의 아키텍처는 기존의 유명한 서버 구현체인 톰캣(Tomcat)과 네티(Netty)의 특징을 모두 일부 가지고 있지만, 한쪽에 더 강하게 기울어 있다. 각 프레임워크와의 비교를 통해 Sprout의 아키텍처적 위치를 더 명확히 이해할 수 있다.

톰캣(Tomcat)과의 비교

톰캣은 자바 서블릿(Servlet) 컨테이너의 표준 구현체로, Sprout와는 '웹 애플리케이션 서버'라는 목표를 공유하지만 내부 동작 원리와 추상화 수준에서 큰 차이를 보인다.

유사점

  • 하이브리드 모드의 개념: Sprout의 '하이브리드 모드'는 톰캣의 최신 커넥터(Coyote)가 동작하는 방식과 개념적으로 유사하다. 즉, 소수의 I/O 스레드(Selector 사용)가 논블로킹으로 연결을 수락하고, 실제 요청 처리는 다수의 워커 스레드 풀에 위임하여 블로킹 방식으로 처리하는 모델인 것이다.

차이점

  • 핵심 추상화: 톰캣의 핵심 추상화는 서블릿 API이다. 개발자는 HttpServlet을 상속하고 doGet, doPost 메소드를 구현하며, 네트워크의 저수준 동작(NIO, 이벤트 루프 등)은 톰캣이 완전히 감춘다. 반면, Sprout는 ProtocolHandlerHttpConnectionHandler 같이 네트워크 이벤트에 더 가까운, 더 낮은 수준의 추상화를 제공한다.
  • 프로그래밍 모델: 톰캣은 개발자에게 동기/블로킹 프로그래밍 모델을 제공한다. Sprout의 Pure NIO 모드는 비동기/이벤트 기반 프로그래밍 모델을 채택하고 있어, 이 부분에서 근본적인 차이가 있다.

Sprout의 “Hybrid + Platform Thread” 모델은 톰캣의 NIO 커넥터(http-nio)와 구조적으로 거의 동일한 패턴이다. 스프링을 구동하면 [nio] 또는 "http-nio-8080" 로그를 볼 수 있는데 이는 톰캣이 내부적으로 org.apache.coyote.http11.Http11NioProtocol 를 선택했다는 뜻이고, 이게 기본 값이다. 해당 구현체도 실제로 Selector로 요청을 받고 내부적으로 워커 스레드에 할당해주는 패턴이다.

네티(Netty)와의 비교

Netty는 고성능 네트워크 애플리케이션을 개발하기 위한 비동기 이벤트 기반 프레임워크로, Sprout 아키텍처는 Netty와 매우 강력한 유사성이 있다.

유사점

  • 핵심 아키텍처: Sprout의 Pure NIO 모드는 Netty의 핵심 아키텍처를 거의 그대로 반영한다. 이벤트 루프(EventLoop), Selector, 논블로킹 채널(Channel), 그리고 핸들러 파이프라인(Pipeline) 개념은 두 프레임워크의 심장을 이루는 동일한 설계 사상이라고 볼 수 있다.
  • 프로그래밍 모델: Sprout의 HttpConnectionHandlerREADING -> PROCESSING -> WRITING 상태를 관리하며 비동기적으로 동작하는 것은 Netty의 ChannelHandler가 I/O 이벤트를 처리하는 방식과 굉장히 유사하긴 하다.
  • 저수준 제어: ByteBuffer를 직접 다루고, 프로토콜을 파싱하며, 데이터 수신 완료를 체크하는 등 저수준의 네트워크 제어권을 개발자에게 제공한다는 점에서 비슷하기도 하다.

차이점

  • 범위와 목적: Netty는 HTTP, TCP, UDP 등 다양한 프로토콜을 처리할 수 있는 범용 네트워크 프레임워크이다. 반면, Sprout은 Netty의 아키텍처를 차용하여 웹 서버 및 MVC 프레임워크라는 특정 목적에 맞게 특화시킨 구현체라고 볼 수 있다.
  • 완성도와 기능: Netty는 수많은 프로토콜 코덱, 스레딩 모델, 메모리 관리 기법 등 방대하고 고도로 최적화된 기능을 제공하는 아주 성숙한 프레임워크이다. Sprout은 해당 아키텍처의 핵심 개념을 일부 차용한 것 뿐이다.
항목SproutTomcatNetty
주 용도학습용 경량 웹 서버 / 프레임워크표준 Java 웹 앱 서버 (Servlet 기반)범용 비동기 네트워크 프레임워크
핵심 아키텍처Selector 기반 이벤트 루프 + Hybrid 처리 모델 (BIO+NIO)Coyote 커넥터 + 워커스레드 풀 + Servlet APIEventLoopGroup + ChannelPipeline
I/O 모델Hybrid (NIO+BIO) / Pure NIO 지원Hybrid (NIO+BIO)Pure NIO (이벤트 기반 비동기 처리)
프로그래밍 모델비동기 상태 머신 or 동기 블로킹 (선택 가능)동기/블로킹 위주 (Servlet 기반)완전한 비동기/이벤트 기반
확장성플러그인 아키텍처 기반, DI로 유연성 확보정형화된 Servlet 컨테이너 구조코어 자체가 확장 가능, 유연성 최고
스레드 모델Virtual Threads / Platform Threads 선택 가능플랫폼 스레드 기반 워커풀커스텀 가능 (스레드 그룹 지정)

8. 결론

결론적으로, Sprout 서버는 'Netty의 아키텍처를 기반으로 만들어진 경량 웹 프레임워크' 라고 정의할 수 있을 것이다. 톰캣처럼 최종 사용자(웹 개발자)를 위한 편의 기능을 제공하면서도, 내부적으로는 Netty와 같은 비동기 이벤트 처리 엔진을 보유하는 형태다.

9. 현재의 한계 및 개선 방향

지금까지 직접 만든 프레임워크가 가진 아키텍처의 특징과 가능성에 대해 서술했지만, 현재 버전은 학습과 실험의 산물로서 상용 프레임워크에 비하면 아직 부족한 점이 많다(사실 당연하다). 앞으로 다음과 같은 개선 과제들을 고민해볼 수 있을 것 같다.

  • HTTP/2 및 HTTP/3 지원: 현재 구현은 HTTP/1.1을 중심으로 하고 있다. 더 나은 성능과 효율을 위해 최신 프로토콜인 HTTP/2의 스트림 멀티플렉싱이나 HTTP/3의 QUIC 프로토콜을 지원하는 것은 중요한 장기 목표가 될 수 있다.

  • 고급 성능 최적화: Netty와 같은 성숙한 프레임워크는 ByteBuf의 풀링(Pooling)을 통해 GC(Garbage Collection) 부담을 최소화하고, 객체 재사용 등 다양한 기법으로 성능을 극한까지 끌어올린다. Sprout 역시 메모리 할당을 줄이고 CPU 사용을 최적화하기 위한 더 깊이 있는 최적화 작업이 필요하다.

  • 안정성 및 예외 처리 고도화: 현재의 예외 처리 로직을 더 정교하게 다듬어, 특정 연결에서 발생한 얘기치 못한 오류가 전체 이벤트 루프나 서버에 영향을 미치지 않도록 격리하고, 더 안정적인 '우아한 종료(Graceful Shutdown)'를 구현하여 진행 중이던 요청을 안전하게 마무리하는 기능의 보강이 필요하다.

  • 보안 강화: 웹 서버로서 갖춰야 할 기본적인 보안 기능(예: 특정 HTTP 헤더 처리, 경로 조작 방지, 요청 크기 제한 등)을 강화하고, 알려진 웹 취약점에 대한 방어 로직을 지속적으로 추가해야 한다.

이러한 과제들을 해결해 나가는 과정은 네트워크와 서버 아키텍처에 대한 더 깊은 이해로 이끌어 줄 것이라 생각한다.


이로써 서버 아키텍처에 대한 전반적인 설명이 끝났다. 이는 HTTP 중심이고, 추후에 다른 프로토콜(Websocket) 처리 구현에 대해서, 혹은 HTTP의 구체적인 파싱에 대해서도 이야기해 볼 수 있을 것 같다.

지난 번에 해당 서버의 여러 모드의 안정성 및 성능 테스트를 진행한 바 있는데, 이에 대한 구체적인 해결법과 다른 확장 방법 및 포인트에 대해서 생각해둔 것들이 있어서 이를 먼저 풀어보고자 한다.

실제 구동 모습은 이 포스트로 이동하셔서 벤치마킹 테스트를 참고하세요. 영상도 있음.

https://github.com/yyubin/sprout