Java로 HTTP 서버 구현 - (3) 톰캣 NIO 서버

Sunwoo Bae·2025년 3월 10일

스프링 부트

목록 보기
4/6

개요

이전 글(Java로 HTTP 서버 구현 - (2) 스레드 풀과 BIO, Java로 HTTP 서버 구현 - (1) Thread-per-Request 모델)에서, Tomcat BIO 로의 구현 과정을 통해서, Single-Thread -> Thread-Create-per-Request -> ThreadPool & BIO 방식으로 서버를 구현하는 과정에 대해 알아 보았다.
이번 글에서는 NIO(Non-Blocking I/O)란 무엇인지, 최신 톰캣 서버에서는 어떻게 요청을 처리하는 지에 대해 간단히 알아 보려 한다.
Accpetor, ThreadPoolExecutor, HTTP 요청을 처리하기 위해서는 어떤 과정을 거쳐야 하는 지 등에 대해서는 이전 글에서 설명했으니, 이전 글을 보지 않았다면 보고 오길 추천한다.

NIO 방식의 개요

BIO 방식과의 핵심적인 차이점은 길어지는 I/O 작업을 워커 스레드가 Block되며 기다릴 필요가 없다는 점이다.
Acceptor & ThreadPoolExecutor 구조의 BIO 서버와는 달리, NIO 서버는 Acceptor, Poller, ThreadPoolExecutor, 스레드 풀을 이용하여 실제 워커 스레드가 소켓의 data를 읽을 때, 읽을 정보가 있는 소켓만을 전달받아 처리하게 되어 스레드가 Block되는 시간을 현저히 단축시킬 수 있다.
Acceptoraccept()한 소켓을 Poller에 등록하고,
Poller는 등록된 소켓을 내부의 Selector의 이벤트에 등록한다.
Selector는 등록된 이벤트들을 감시하며, "read()할 바이트가 있다"는 정보를 OS로부터 전달받으면(Linux syscall epoll), 해당 소켓을 Worker에게 전달한다.
현재 글에서는 NioEndPoint의 구현을 중심으로 설명하겠다.

Acceptor

Acceptor의 동작 방식은 BIO 방식과 크게 다르지 않다.
다만, accept()로 가져온 소켓을 ThreadPoolExecutor에 전달하는 것이 아니라, Poller에 등록하는 것이 BIO와의 핵심적인 차이다.

// org.apache.tomcat.util.net.Acceptor
public void run() {
    while (!this.stopCalled) {
        /// ... 생략
        try {
            socket = this.endpoint.serverSocketAccept();
        } catch (Exception var12) {
            /// ... 생략
            if (!this.stopCalled && !this.endpoint.isPaused()) {
                if (!this.endpoint.setSocketOptions(socket)) {
                    this.endpoint.closeSocket(socket);
                }
            } else {
                this.endpoint.destroySocket(socket);
            }
        }
        /// ... 생략
        this.state = Acceptor.AcceptorState.ENDED;
    }
}
// org.apache.tomcat.util.net.NioEndPoint;
protected boolean setSocketOptions(SocketChannel socket) {
    /// ... 생략
        this.poller.register(socketWrapper);
    /// ... 생략
}

NioEndPoint#setSocketOptions()Acceptor#run()을 가져왔다. 핵심이 아닌 부분은 전부 생략했다.

Poller

PollerNIO의 핵심인 Selector를 이용해서 Acceptor로부터 전달받은 소켓을 등록하고, 등록한 소켓들의 I/O 이벤트를 감시한다.
다음의 과정으로 동작한다.

  1. 소켓 등록
  • Acceptor가 새로운 SocketChannelPoller에 넘겨주면,
    Poller는 내부적으로 Selector에 해당 소켓을 등록한다.
  • 등록 시, 감지할 이벤트(OP_READ, OP_WRITE 등)를 설정.
  1. 이벤트 감시
  • Poller 스레드는 루프를 돌며 selector.select()를 호출한다.
  • 이 메서드는 소켓들 중 I/O 이벤트가 발생할 때까지 Block.
  • 이벤트가 발생하면, Selector가 Ready 상태로 변환된 소켓 채널 목록을 반환.
  1. Worker에게 전달
  • Poller는 “읽을 준비가 된 소켓” 혹은 “쓸 준비가 된 소켓” 등을 확인하고, 해당 SocketChannel을 Worker(ThreadPoolExecutor)에게 전달하여 처리한다.
// org.apache.tomcat.util.net.Poller
public void run() {
    // 생략 ...
    // 현재 이벤트가 발생한 key가 있는 지를 확인
    Iterator<SelectionKey> iterator = this.keyCount > 0 ? this.selector.selectedKeys().iterator() : null;

    // 이벤트가 발생했다면, 해당 채널을 Worker에게 넘겨서 실행
    while(iterator != null && iterator.hasNext()) {
        SelectionKey sk = (SelectionKey)iterator.next();
        iterator.remove();
        NioEndpoint.NioSocketWrapper socketWrapper = (NioEndpoint.NioSocketWrapper)sk.attachment();
        if (socketWrapper != null) {
            this.processKey(sk, socketWrapper);
        }
    }
    // 생략 ...
}

코드를 읽을 때 참고할 점은, select()가 실제로 호출하는 syscall은 OS 종속적이기에, selectorImpl.doSelect(timeout) 형식으로 구현되어 있다는 점이다. JNI를 이용해서 OS마다 다른 syscall로 구현되어 있을 것이다.

Worker

Poller가 이벤트가 발생한 소켓에 대해 작업(Runnable)을 넘기면, Worker는 이를 실행한다.
NioEndPoint 에서는 소켓 I/O를 워커 스레드에서 직접 수행한다.

  1. 소켓 I/O 처리
  • 소켓에서 실제 HTTP 데이터를 읽고, 헤더와 바디를 파싱해서 HttpServletRequest 객체로 변환한다.
  • 만약 Worker가 데이터를 완전히 읽지 못했거나, 추가로 전송될 데이터가 있을 경우에는 해당 소켓 채널을 Poller에 재등록
  1. HTTP 요청 완성 시점
  • HttpServletRequest가 완성되면, CoyoteAdapter 등을 통해 서블릿 컨테이너나 Spring MVC와 같은 상위 레이어로 요청을 전달.
  • Spring MVC에서는 DispatcherServlet을 통해 컨트롤러를 찾고 비즈니스 로직을 수행한 뒤, 응답을 생성.
  1. 응답 전송
  • 응답 데이터를 소켓에 쓰고 완료되면, 소켓을 닫거나, keepp-alive인 경우 재사용을 위해 Poller에 다시 등록한다.

이 과정은 BIO Worker와 거의 비슷하기에, 코드 첨부는 생략하겠다. 하지만, BIO와 달리 “읽을 준비가 되었는지” 미리 확인하고 나서 워커가 일을 하기 때문에, 워커 스레드가 ‘빈 소켓 데이터’를 기다리느라 block되는 시간이 훨씬 줄어들게 된다.

실제 요청 처리 흐름

  1. Acceptor가 클라이언트 연결을 가져와서 SocketChannelPoller에 등록
  2. Poller는 해당 소켓 채널을 Selector에 등록하고, select()를 이용해서 I/O 이벤트 감지
  3. select()를 통해 "읽거나 쓸 준비가 완료되었다" 라는 응답을 OS로부터 받으면, 해당 소켓 채널을 Worker에게 전달
  4. Worker는 해당 소켓 채널을 받아 요청을 처리
  5. 연결이 keep-alive라면 해당 연결을 다시 Poller에 등록

실제 톰캣 서버에서는 요청을 위와 같은 흐름으로 처리하지만, 몇 가지를 추가로 설정할 수 있다.
예를 들어, 실행할 스레드 개수나, 최대 커넥션 개수 등을 설정할 수 있다. 이런 인자들에 대해서 알아보자.

톰캣 서버 설정

  1. maxConnections : 서버가 동시에 유지할 수 있는 최대 TCP 연결 수
  • 기본값 : 8092
  • 이 이상으로 TCP 연결이 들어오는 경우, Listen 중인 서버 소켓의 백 로그 큐에 저장된다.
  1. acceptCount : Listen 중인 서버 소켓의 백 로그 큐의 길이
  • 기본값 : 100
  • Listen syscall을 할 때, backlog 큐 길이를 얼마로 할 지 미리 설정할 수 있다. 해당 백 로그 큐도 꽉 찬 경우 CONNECTION_REFUSED 에러가 난다.
  1. maxThreads : 스레드 풀에 생성될 수 있는 최대 스레드의 개수
  • 기본값 : 200
  • 더 이상 서버가 스레드를 생성할 수 없는 경우, 작업 큐에 해당 작업을 넣는다.
  1. minSpareThreads : 서버가 실행되는 시점에 생성할, 최소한으로 유지되는 스레드의 개수
  • 기본값 : 10
  • minSpareThreads가 모두 요청을 처리하는 중이라면, maxThreads개 까지 스레드를 생성한다.
  1. maxIdleTime : 유휴 스레드가 제거되는 시간
  • 기본값 : 60초
  • minSpareThreads 개수를 초과하는 스레드들이 유휴 상태로 maxIdleTime 이상 유지되는 경우, 해당 스레드들은 폐기된다.
  1. KeepAliveTimeout : Keep-Alive 요청에 대한 Connection을 유지하는 시간
  • 기본값 : 20초

본인은 백엔드 작업을 하며 지금껏 이 값들을 실제로 튜닝해본 경험은 없다. 그러나 각 값들이 어떤 역할을 하는지, 톰캣 서버가 실제로 요청을 어떻게 serving하는 지를 이해한다면 실제 튜닝이 필요한 경우 큰 도움이 될 것이다.

실제로 스프링 부트 서버 동작 과정

지금까지는 소스 코드를 까 보고, 톰캣 docs나 인터넷의 블로그들을 찾아보며 톰캣 NIO 서버의 동작 과정에 대해 알아 보았다. 그렇다면 실제로 @SpringBootApplication이 내가 적은 글대로 동작할까?
IntellijDEBUG 기능을 이용해서 확인해보자!

Spring 기본 설정

스프링 기본 설정에 대한 자세한 내용들은 생략하겠다.
http://localhost:8080/info/test/ 요청을 받을 수 있도록,
Controller, Service 정도만 만들었고, 컨트롤러가 실행하는 Service 의 함수는 200 응답을 주도록 설정했다.

Debug 포인트

먼저, 톰캣 서버 코드에서 내가 지나갈 것으로 예상되는 곳에 디버그 포인트를 찍어 보았다.

  1. org.apache.tomcat.util.net.Acceptor#run()
  • socket = this.endpoint.serverSocketAccept();
  • 실제로 accept를 호출하는 파트
  1. org.apache.tomcat.util.net.NioEndpoint.Poller#run()
  • this.keyCount = this.selector.selectNow();
  • Iterator<SelectionKey> iterator = this.keyCount > 0 ? this.selector.selectedKeys().iterator() : null;
  • 실제로 select를 호출하고, 이벤트가 있으면 연결된 소켓들을 워커에게 전달하는 파트
    3.org.apache.tomcat.util.threads.ThreadPoolExecutor#executeInternal()
  • if (isRunning(c) && this.workQueue.offer(command)) {
  • execute 요청을 받은 작업을 작업을 작업 큐에 넣는 파트

서버 부팅 시 실행되는 파트(요청 이전)

org.apache.tomcat.util.net.Acceptor#run() 의 accept()하는 파트(예측 성공)
org.apache.tomcat.util.net.NioEndpoint.Poller#run() 의 select()를 호출하는 파트(예측 성공)
당연히, AccpetorPoller는 요청이 들어오기 전에도 무한 루프를 돌면서 이벤트를 감시할 것이다.

요청에 응답하는 파트(요청 시)

org.apache.tomcat.util.net.Acceptor#run() 의 accept()하는 파트 (예측 성공)
org.apache.tomcat.util.threads.ThreadPoolExecutor#executeInteral() 에서 exeucte()하는 파트 (예측 성공)

추가적으로 확인한 파트

org.apache.catalina.connector#service() 에서 톰캣 파이프라인을 거치는 파트
org.apache.catalina.core.ApplicationFilterChain#doFilter()에서 적용된 필터들을 순회하는 파트 (스프링 부트 로직)
org.springframework.web.servlet.DispatcherServlet#doDispatch()에서 DispatcherServlet을 이용해서 요청을 처리하는 파트(스프링 부트 로직)

DispatcherServlet에서 요청을 처리하는 방법에 대한 내용에 대해서도 글을 적어볼 생각이다. 다만, 이 글에서는 흐름에서 벗어나기에 생략하겠다.

NIO2 방식

지금까지 NIO 즉, Non-Blocking I/O 방식의 톰캣 서버의 동작 방식에 대해 알아보았다. 마지막에는 실제로 DEBUG를 찍어 보면서 동작 과정을 재확인했다.
그러나 OS에서 지원하는 비동기 I/O를 이용하여 만들어진 서버(커낵터 - Http11Nio2Protocol)도 존재한다. 이를 NIO2라고 하며, 개발자는 NIO 커낵터와 NIO2 커낵터를 선택하여 설정할 수 있다.

그러나, 톰캣의 기본 설정은 NIO2가 아니라 NIO이다. 이유에는 여러 이유가 있겠지만, NIO 또한 NIO2 방식과 비교해서 충분히 안정적이고 성능도 우수하기 때문이 아닐까 싶다. NIO2를 기본으로 채택하였을 때 얻는 이득이 일반적인 웹 환경에서 크지 않기 때문에 굳이 최신 방식을 채택하지 않는 것이라고 생각한다.

톰캣 서버에 대한 설명을 마치며

3개의 글 - Java로 HTTP 서버 구현 (1), Java로 HTTP 서버 구현 (2), 그리고 이 글 - 을 통해서 톰캣 서버의 동작 과정 및, HTTP 서버가 어떻게 발전해 왔는 지를 간략하게 알아 보았다.

그런데, 이 글의 발단은 백엔드 개발에 왜 스프링 부트를 사용하는지 를 설명하기 위해서였다. 지금까지의 설명을 통해서 톰캣 서버가 어떻게 동작하는 지, NIO - BIO 등이 무엇인 지는 충분히 이해했을 것이다. 그러나, 비즈니스 로직을 다루는 개발자가 서버에 대한 이해를 넘어서 진짜로 서버를 구현해야 한다면, 그 것이 진짜 바람직한 방향일까?

스프링 부트는 내부에 톰캣(또는 다른 컨테이너)를 내장하고 있어서, 개발자는 톰캣이 제공하는 설정(예: maxThreads, keepAliveTimeout 등)만 적절히 조정하면, 동시성 대응이 가능한 네트워크 서버를 손쉽게 활용할 수 있다.
즉, “개발자는 HttpServletRequest로 추상화된 비즈니스 로직 구현에 집중하고, 톰캣(서블릿 컨테이너)과 스프링 부트가 멀티스레드 & I/O 처리를 알아서 관리한다.” 라는 스프링의 객체지향적인 이상이 여기에도 녹아있는 것이다.

다음 글에서는?

스프링 부트가 제공하는 세 가지 기능 - 클라이언트 요청 관리, 응답 생성 과정 구현, DB 접근 - 중에서 클라이언트 요청 관리를 어떻게 구현했는 지에 대해서 알아 보았다.
그렇다면, 이제 만들어진 Request 객체로부터 실제로 응답을 생성하기 위해서는 어떤 과정이 필요하고, 그 과정을 스프링 부트는 어떻게 처리함으로써 개발자에게 편의를 제공하는 지에 대해서 알아보자.

참조

https://tomcat.apache.org/tomcat-9.0-doc/index.html
https://ttl-blog.tistory.com/1498
https://velog.io/@ejung803/-0bayh7qy
디버깅에 사용한 톰캣 버젼 : 9.0.102

profile
오늘보다 더 나은 내일

0개의 댓글