콘텐츠로 이동

Netty는 서블릿과 어떻게 다르게 요청을 처리할까

스레드 수를 줄인다는 결론부터 외우지 않고, 서버가 연결을 기다리는 방법이 어떻게 달라졌는지부터 따라갑니다.

예상 읽기 시간 8분 · 핵심 질문: 이벤트 루프는 무엇을 반복하고, 한 작업이 느리면 왜 여러 연결이 함께 멈추는가

Netty는 자바로 네트워크 서버와 클라이언트를 만드는 프레임워크다. 웹 서버만 만드는 도구는 아니다. TCP, HTTP, 웹소켓이나 자체 프로토콜의 연결을 받고, 들어온 바이트를 메시지로 해석하고, 응답 바이트를 내보내는 과정을 다룬다.

Netty의 핵심 차이는 연결 하나를 스레드 하나가 계속 기다리지 않는다는 것이다. 소수의 이벤트 루프가 많은 연결을 맡고, 실제로 처리할 일이 생긴 연결만 짧게 실행한다. 이 구조는 오래 열린 연결이 많을 때 스레드 비용을 줄이지만, 이벤트 루프 안의 한 작업이 오래 걸리면 같은 루프가 맡은 다른 연결까지 기다리게 만든다.

이 한 문장을 이해하면 뒤의 용어가 자리 잡는다. 연결 하나는 채널로 표현하고, 이벤트 루프는 준비된 채널을 번갈아 처리하며, 파이프라인은 한 채널에서 발생한 이벤트를 여러 처리기로 전달한다.

이벤트, 이벤트 기반과 이벤트 루프는 다르다

이벤트(Event)는 시스템에서 방금 일어난 일을 나타내는 신호다. Netty에서는 “새 연결이 들어왔다”, “이 소켓에서 데이터를 받을 수 있다”, “이제 소켓에 데이터를 쓸 수 있다” 같은 네트워크 상태 변화가 이벤트다.

이벤트 기반(Event-driven) 처리는 이런 사건이 생겼을 때 등록된 처리기를 실행하는 방식이다. 코드가 모든 연결을 붙잡고 “데이터가 왔나?”를 계속 기다리는 대신, 운영체제가 준비된 연결을 알려 주면 그때 필요한 작업을 시작한다. 이벤트 기반은 넓은 설계 방식이고, 이벤트 루프는 그 방식을 실제로 실행하는 장치다.

이벤트 루프(EventLoop)는 준비된 사건을 받아 짧은 처리기를 실행하고 다시 다음 사건을 기다리는 반복 실행자다. Netty 서버 전체에 하나만 있는 것이 아니라 여러 이벤트 루프가 연결을 나누어 맡는다. 지금은 “많은 연결 가운데 할 일이 생긴 연결만 번갈아 처리한다”는 그림만 잡으면 충분하다. 실제 반복 순서는 뒤에서 한 단계씩 본다.

이벤트 스트리밍(Event Streaming)은 다른 개념이다. 주문 생성이나 결제 완료 같은 업무 사건을 지속적인 기록으로 저장하고 여러 소비자가 나중에 처리하게 하는 구조를 말한다. Kafka가 다루는 이벤트 스트리밍과 Netty의 이벤트 루프는 모두 ‘이벤트’라는 단어를 쓰지만, 하나는 업무 기록을 전달하는 구조이고 다른 하나는 네트워크 작업을 실행하는 방식이다.

서블릿 방식에서는 요청이 스레드를 따라간다

일반적인 Spring MVC와 서블릿 애플리케이션을 떠올려 보자. 요청이 들어오면 서버의 작업자 스레드 하나가 컨트롤러를 실행한다. 컨트롤러가 데이터베이스에 질의하면 그 스레드는 결과가 올 때까지 기다린다. 결과가 오면 같은 코드의 다음 줄을 실행하고 응답을 보낸다.

이 모델의 큰 장점은 코드와 실행 순서가 같다는데 있다. 인증하고, 데이터베이스를 조회하고, 응답을 만드는 과정이 위에서 아래로 이어진다. 오류가 난 위치와 호출 스택도 비교적 쉽게 추적할 수 있다. 일반적인 요청·응답 서비스에서는 이 단순성이 매우 큰 이점이다.

문제는 스레드가 계산하지 않는 동안에도 자리를 차지한다는 데서 시작한다. 데이터베이스 응답을 기다리는 100밀리초 동안 해당 스레드는 다른 요청을 처리하지 못한다. 동시에 기다리는 요청이 많아지면 더 많은 스레드와 스택 메모리가 필요하고, 운영체제가 스레드를 번갈아 실행하는 비용도 커진다. 이것이 블로킹 입출력(Blocking I/O) 모델의 비용이다.

블로킹 자체가 나쁜 것은 아니다. 요청 수와 스레드 풀의 상한이 적절하고 의존 라이브러리가 모두 블로킹 방식이라면 가장 이해하기 쉬운 선택일 수 있다. 문제는 연결이 수만 개로 늘고 대부분이 데이터를 보내지 않은 채 오래 유지될 때다. 연결마다 기다리는 스레드를 배정하면 실제 작업보다 대기 비용이 커진다.

서블릿 컨테이너도 내부 소켓 처리에 비블로킹 기술을 사용할 수 있다. 여기서 비교하는 것은 내부 구현이 아니라 애플리케이션이 보는 실행 방식이다. 전통적인 서블릿 코드는 요청을 맡은 작업자 스레드가 동기적으로 끝까지 진행하는 모습이고, Netty 처리는 채널에서 발생한 이벤트가 여러 처리기 호출로 이어지는 모습이다.

비블로킹 입출력은 준비된 연결만 알려준다

NIO(Non-blocking I/O, 비블로킹 입출력)에서는 소켓에 지금 받을 데이터가 없다면 스레드를 세워 두지 않는다. 여러 소켓을 셀렉터(Selector)에 등록하고, 운영체제에 “받거나 쓸 준비가 된 소켓을 알려 달라”고 요청한다.

운영체제가 준비된 소켓 목록을 돌려주면 프로그램은 그 연결에 필요한 작업만 실행한다. 이런 구조를 흔히 리액터(Reactor) 방식이라고 부른다. 리액터는 준비 사건을 받고 적절한 처리기로 전달하는 전체 패턴이고, 셀렉터는 여러 소켓의 준비 상태를 확인하는 자바의 도구다.

이 방식에서는 연결 수와 스레드 수가 같은 비율로 늘지 않는다. 아무 데이터도 보내지 않는 연결은 셀렉터에 등록되어 있을 뿐 스레드를 계속 점유하지 않는다. 채팅, 웹소켓, 게임 서버, 프록시처럼 오래 열린 연결이 많은 환경에서 이 차이가 커진다.

Netty의 이벤트 루프는 무엇을 반복할까

이벤트 루프(EventLoop)는 이름 그대로 같은 순서를 반복하는 실행자다. Netty의 이벤트 루프 하나는 보통 하나의 스레드에서 다음 작업을 되풀이한다.

  1. 셀렉터에서 준비된 채널을 확인한다.
  2. 받을 데이터가 있으면 읽고, 보낼 수 있으면 대기 중인 데이터를 내보낸다.
  3. 채널의 파이프라인에 해당 이벤트를 전달한다.
  4. 다른 스레드가 맡겨 둔 작업과 예약 작업을 실행한다.
  5. 다시 준비된 채널을 기다린다.

서버 전체에 이벤트 루프가 하나뿐인 것은 아니다. 이벤트 루프 그룹(EventLoopGroup) 안에 여러 이벤트 루프가 있고, 새 연결은 그중 하나에 배정된다. 한 채널은 보통 연결이 끝날 때까지 같은 이벤트 루프에 머문다. 이 덕분에 같은 연결의 이벤트 순서를 이해하기 쉬워지고, 채널 상태를 바꿀 때마다 잠금을 거는 일도 줄어든다.

서블릿은 요청마다 작업자 스레드가 기다리고 Netty는 이벤트 루프가 준비된 여러 채널을 번갈아 처리하는 구조
서블릿 모델은 요청의 흐름을 작업자 스레드가 따라갑니다. Netty는 이벤트 루프가 준비된 채널만 골라 파이프라인을 짧게 실행합니다.

이 구조의 가장 중요한 제약도 여기서 나온다. 이벤트 루프가 채널 A의 처리기에서 데이터베이스 응답을 동기적으로 기다리면, 같은 이벤트 루프에 배정된 채널 B와 C는 이미 처리할 데이터가 있어도 실행될 수 없다. 적은 스레드로 많은 연결을 맡긴 대신, 한 작업의 지연이 영향을 주는 범위가 넓어진다.

채널은 연결 하나의 상태를 모은다

Netty의 채널(Channel)은 소켓 연결을 자바 객체로 표현한 것이다. 연결이 열렸는지, 현재 데이터를 받을 수 있는지, 어떤 파이프라인을 사용하는지, 아직 전송하지 못한 데이터가 얼마나 있는지를 한곳에서 다룬다.

채널의 쓰기 작업은 결과를 즉시 돌려주지 않을 수 있다. 네트워크가 실제로 전송을 마쳤을 때 성공하거나 실패하기 때문이다. 그래서 Netty는 퓨처(Future)를 통해 완료 결과를 나중에 알린다. 이 퓨처를 이벤트 루프 안에서 동기적으로 기다리면 비블로킹 구조를 다시 막을 수 있으므로, 완료 처리기를 연결해 다음 작업을 이어 가는 방식이 기본이다.

파이프라인은 연결과 대화하는 순서를 만든다

TCP는 메시지 목록이 아니라 끊김 없는 바이트 흐름을 전달한다. 한 번의 수신이 애플리케이션 메시지 한 개와 정확히 일치한다는 보장이 없다. 메시지 하나가 여러 번에 나뉘어 올 수도 있고, 여러 메시지가 한 번에 붙어 올 수도 있다.

채널 파이프라인(ChannelPipeline)은 이 바이트를 단계별로 처리하는 사슬이다. 들어오는 방향에서는 먼저 바이트를 완전한 메시지로 나누는 디코더가 실행된다. 그다음 인증 처리기와 업무 처리기가 메시지를 다룬다. 응답을 내보내는 방향에서는 인코더가 객체를 다시 바이트로 바꾸고 채널의 송신 버퍼로 전달한다.

서블릿에서는 프레임워크가 완성된 요청 객체를 컨트롤러 메서드에 넘겨준다. Netty에서는 연결 생성, 데이터 수신, 쓰기 가능, 연결 종료 같은 사건이 파이프라인의 처리기를 차례로 호출한다. 이것이 두 방식이 애플리케이션과 소통하는 형식의 핵심 차이다.

한 연결이 실제로 처리되는 흐름은 다음과 같다.

  1. 연결 수락을 담당하는 이벤트 루프가 새 소켓을 받는다.
  2. 새 채널을 작업 이벤트 루프 하나에 등록한다.
  3. 채널 초기화 과정에서 디코더, 인증, 업무 처리기와 인코더를 파이프라인에 넣는다.
  4. 데이터 수신 준비 사건이 오면 이벤트 루프가 바이트를 받고 파이프라인에 전달한다.
  5. 처리 결과를 채널에 쓰면 반대 방향의 인코더를 거쳐 송신 버퍼에 들어간다.
  6. 소켓이 쓸 수 있는 상태가 되면 대기 중인 바이트를 실제 네트워크로 보낸다.

ByteBuf는 바이트의 위치와 수명을 관리한다

ByteBuf(Byte Buffer, 바이트 버퍼)는 받은 바이트와 보낼 바이트를 담는 Netty의 메모리 그릇이다. 어디까지 조회했고 어디까지 썼는지를 별도 위치 값으로 관리해 버퍼를 매번 복사하지 않고도 단계별 처리를 이어 갈 수 있다.

복사를 줄이는 기능은 공짜가 아니다. 일부 ByteBuf는 참조 횟수로 수명을 관리한다. 다른 처리기로 넘기면서 소유권 규칙을 지키지 않으면 이미 반환된 메모리를 다시 사용하거나, 반대로 반환하지 못해 직접 메모리가 계속 늘 수 있다. 처음에는 디코더와 인코더가 제공하는 기본 수명 규칙을 따르고, retainrelease를 직접 사용할 때만 소유권을 명확히 기록하는 편이 안전하다.

역압은 느린 연결이 메모리를 삼키지 않게 한다

애플리케이션이 응답을 만드는 속도보다 클라이언트가 받아 가는 속도가 느리면 채널의 송신 버퍼가 계속 쌓인다. 아무 제한 없이 두면 느린 연결 몇 개가 서버 메모리를 소모해 다른 요청까지 실패하게 만든다.

역압(Backpressure)은 이 속도 차이를 앞 단계에 알리고 생산량을 줄이는 규칙이다. Netty에서는 채널의 쓰기 가능 상태와 송신 버퍼의 상·하한을 보고 새 메시지 생성을 잠시 멈출 수 있다. 그래도 한도를 넘으면 기다릴지, 요청을 거절할지, 오래된 메시지를 버릴지 정해야 한다. 역압은 자동으로 데이터를 지켜 주는 기능이 아니라, 용량을 넘었을 때 어디에서 어떤 손실을 허용할지 정하는 운영 정책이다.

운영체제 전용 전송 계층은 마지막 최적화다

Netty는 자바 NIO뿐 아니라 리눅스의 epoll과 BSD 계열의 kqueue 같은 운영체제 전용 전송 계층도 지원한다. 특정 환경에서 기능과 효율을 얻을 수 있지만 네이티브 라이브러리 배포와 플랫폼 차이를 관리해야 한다. 먼저 이벤트 루프 지연, 처리기 실행 시간과 버퍼 증가를 측정하고, 자바 NIO가 실제 병목일 때 검토하는 순서가 안전하다.

WebFlux를 쓴다고 모든 호출이 비블로킹은 아니다

Spring WebFlux는 리액티브 스트림 방식으로 요청을 연결하며 Reactor Netty 위에서 실행할 수 있다. 하지만 데이터베이스 드라이버나 외부 SDK가 블로킹 방식이면 그 호출이 사라지는 것은 아니다. 이벤트 루프에서 직접 실행하지 말고 상한이 있는 별도 작업자 풀에 넘겨야 한다.

단순히 다른 풀로 옮기는 것만으로도 부족하다. 작업자 풀이 가득 찼을 때 무제한 대기열에 계속 넣으면 과부하가 메모리 문제로 바뀐다. 풀 크기, 대기열 상한, 거절 방식과 요청 시간 제한을 함께 정해야 한다.

서블릿, Netty, 가상 스레드 중 무엇을 고를까

요청이 짧고 블로킹 라이브러리를 많이 사용하며 일반적인 HTTP API를 만든다면 서블릿 모델은 여전히 좋은 기본값이다. 코드의 흐름이 실행 흐름과 같아 개발과 장애 추적이 단순하다. Java 21 가상 스레드를 사용하면 이 코드 모양을 유지하면서 입출력 대기 중 운영체제 스레드 점유를 줄일 수 있다. 다만 데이터베이스 연결 수와 외부 API의 처리량 같은 실제 자원 상한은 그대로다.

Netty는 연결이 매우 많고 오래 유지되거나, 프로토콜의 바이트 경계와 연결별 상태, 송신 속도 제어를 세밀하게 다뤄야 할 때 강하다. 웹소켓 게이트웨이, 게임 서버, 프록시, 메시지 브로커와 자체 TCP 프로토콜이 대표적이다. 대신 모든 처리기를 짧게 유지하고, 블로킹 작업과 큰 계산을 분리하며, 버퍼 수명과 역압을 직접 관리할 책임이 생긴다.

선택 기준은 “비동기가 더 빠른가”가 아니다. 현재 서비스의 어려움이 기다리는 운영체제 스레드의 수인지, 오래 열린 연결의 상태 관리인지, 아니면 데이터베이스와 업무 로직 자체인지를 먼저 구분해야 한다. 병목이 데이터베이스 질의라면 Netty로 서버를 바꾸어도 질의 시간은 줄지 않는다.

이 글을 한 문장으로 설명한다면

서블릿 모델에서는 작업자 스레드가 요청의 시작부터 응답까지 흐름을 따라가고, Netty에서는 소수의 이벤트 루프가 준비된 여러 채널의 사건을 파이프라인에 전달한다. 이벤트 루프는 네트워크 준비 확인, 짧은 처리기 실행과 예약 작업을 반복한다.

이 구조는 오래 대기하는 연결이 많을 때 효율적이지만, 이벤트 루프 안에서 데이터베이스 호출이나 긴 계산을 기다리면 여러 연결이 함께 멈춘다. 따라서 Netty의 핵심은 비동기 문법이 아니라 이벤트 루프를 막지 않고, 느린 소비자가 만드는 대기열에 분명한 상한을 두는 것이다.

참고 자료

공식 문서

소스 코드