본문 바로가기
Language/자바&코틀린

[I/O 이해하기-3] 톰캣의 요청 수신 방식

by 코딩공장공장장 2025. 1. 27.

이전의 [I/O 이해하기 1,2]를 통해 I/O 작업에 사용되는 개념에 대해 학습을 하였다.

개념에 대한 내용은 1,2편에서 설명하였으므로 이 글에서는 개념과 용어에 대해 세세한 설명은 하지 않을 것이다.

 

나는 개인적으로 톰캣이 어떻게 요청을 처리하는지 알고 싶어 동기와 비동기, Blocking과 Non Blocking, Stream과 Channel을 학습하였다. 톰캣이 비동기 NonBlocking으로 동작한다고 알고 있었기에 학습한 이론을 토대로 톰캣이 클라이언트 요청에 대한 모든 I/O 작업을 비동기-NonBlocking으로 동작할것이라 예상했지만 결과는 전혀 그렇지 않았다.

 

결론부터 말하면 톰캣 9의 NIO 방식에서도 클라이언트 요청마다 스레드가 할당된다.

BIO의 경우 클라이언트 소켓 연결 이후 스레드가 할당되지만, NIO는 클라이언트 http 헤더가 모두 도착한 이후 스레드가 할당되므로 대기시간을 줄일 수 있다.

클라이언트 소켓 연결과 http 헤더를 읽어들이는 방식은 비동기-NonBlocking방식으로 동작하지만, httpBody는 InputStream을 통해 동기-Blocking으로 읽어들인다.

디버깅을 통해 직접 확인해보자.

 

톰캣의 요청 수신 방식


톰캣은 전통적인 I/O 방식인 BIO방식에서 9버전 이후 부터는 default로 NIO 방식을 통해 I/O 작업을 진행한다.

NIO(New I/O)는 비동기-NonBlocking 방식으로 IO작업이 이루어지는 것을 말한다.

I/O 작업이 일어나는 부분은 Endpoint라 부르며 이 EndPoint에서 모든 요청-응답에 대한 IO 작업이 이루어진다.

이로 인해 병목현상이 일어날 수 있다.
따라서 BIO와 NIO를 통한 IO작업 방식의 차이가 어떤 효율의 차이를 일으키는지 이해하는 것이 중요하다.

 

BIO vs NIO


BIO 

BIO 방식에서 클라이언트 요청 처리에 사용되는 스레드는 Acceptor와 Worker 스레드가 있다.

Acceptor는 클라이언트 요청을 승인하고 반환받은 클라이언트 소켓을 worker 스레드에 넘기고

Worker 스레드는 요청 데이터를 읽어들이고 클라이언트의 요청 작업을 처리한다.

Blocking 방식으로 I/O를 진행하기에 worker 스레드는 클라이언트 커넥션마다 할당 되어 사용된다.

이로 인해 데이터가 전송되지 않은 유휴 시간에도 스레드를 점유하게 된다.

 

NIO 

NIO 방식에서 클라이언트 요청 처리에 사용되는 스레드가 3가지가 있다.

  1. Acceptor 스레드 : 하나의 스레드로 구성되며 클라이언트 요청을 수신하는 역할을 한다.
  2. Poller 스레드 : 하나의 스레도 구성되며 클라이언트의 요청 데이터가 도착했는지 여부(이벤트)를 확인하고 worker 스레드에 할당하는 역할을 한다.
  3. Worker 스레드 : 스레드 풀에서 관리되는 스레드로 실절적인 요청 처리를 담당한다.

위에서 톰캣이 NIO 방식의 입출력을 할 수 있게 하는 핵심은 Poller 스레드의 Selector에 있다.

Selector를 통해 비동기적으로 IO 이벤트를 수신받고 worker 스레드에 작업을 할당할 수 있다.

이벤트를 수신을 통해 대기(Block) 없이 곧장 요청 데이터를 읽어 작업을 처리 가능하다.

 

BIO 방식에서 worker 스레드는 IO 작업에서 클라이언트의 요청 데이터를 읽어들이기 위해 대기(Block) 되어 있고 한번 커넥션이 연결되면 스레드를 계속 점유하고 있는 구조를 가졌다.

허나, NIO 방식에서는 Selector를 통해 이벤트를 전달받아 곧바로 작업 처리를 진행한다.

즉, BIO는 데이터를 읽는 부분부터 스레드를 점유하고 NIO는 이미 데이터(http헤더)가 다 도착했음을 인지하고 worker스레드에 할당한다. 읽어들이는 작업은 둘다 공통으로 worker스레드에서 진행하지만 데이터를 읽기 위해 대기해야하냐, 하지않아도 되냐의 차이가 있다.

[참고] 버퍼 크기는 일반적으로 8kb
http 프로토콜 특성상 uri paramter가 너무 길지 않는 이상 헤더가 8kb를 넘기지 않는다고 한다.
헤더가 8kb를  넘어가는 요청의 경우 413 Request Entity Too Large 에러가 발생하게 되는데 이를 해결하기 위해 maxHttpHeaderSize의 크기를 늘리게 되면 channel의 버퍼 크기도 커지게 된다.
그렇게 되면 버퍼로 인해 점유하는 메모리가 늘어 다른 곳에 사용될 메모리가 부족해질 수 있다.

 

톰캣의 사용자 요청 처리과정


 1. Acceptor 스레드에서 클라이언트 요청 승인 

[Acceptor 클래스]

public void run() {

       // 서버 구동이 중지 되지 않는 이상 무한루프를 돔

       while(!this.stopCalled) {

           . . .

          

           // 클라이언트 요청 승인

           socket = this.endpoint.serverSocketAccept();

 

           . . .       

           // 클라이언트 소켓 옵션 설정

           if (!this.endpoint.setSocketOptions(socket)) {

                  this.endpoint.closeSocket(socket);

           }

      }

}

 

acceptor 스레드의 run 메서드를 보면 위와 같이 while문을 통해 무한루프를 돌며 클라이언트 요청을 승인한다.

이때 클라이언트 소켓이 반환되고 클라이언트 소켓의 옵션을 설정한다.

여기서 사용되는 endpoint의 구현체가 NioEndpoint이고 NioEndpoint의 serverSocket을 통해 요청을 승인하고 클라이언트 소켓을 반환다.

 

[NioEndpoint 클래스]

protected SocketChannel serverSocketAccept() throws Exception {
        // 서버소켓을 통해 요청을 승인하고 클라이언트 소켓 반환
        SocketChannel result = this.serverSock.accept();
        if (!JrePlatform.IS_WINDOWS && this.getUnixDomainSocketPath() == null) {
            SocketAddress currentRemoteAddress = result.getRemoteAddress();
            long currentNanoTime = System.nanoTime();
            if (currentRemoteAddress.equals(this.previousAcceptedSocketRemoteAddress) && currentNanoTime - this.previousAcceptedSocketNanoTime < 1000L) {
                throw new IOException(sm.getString("endpoint.err.duplicateAccept"));
            }

            this.previousAcceptedSocketRemoteAddress = currentRemoteAddress;
            this.previousAcceptedSocketNanoTime = currentNanoTime;
        }

        return result;
    }

 

잠깐. 서버소켓과 클라이언트 소켓의 차이
서버 소켓은 서버 프로세스에 한개 존재하는 소켓으로 클라이언트의 요청을 승인하는 역할을 한다.
위 예제의 ServerSocketChannel의 주요 메서드는 accept와 bind로 요청을 승인하고 주소를 바인딩하는 역할을 한다.
클라이언트 소켓은 서버로 요청한 클라이언트의 데이터를 읽어들이기 위해 서버측에서 생성한 소켓이다.
read, write, bind로 이루어져 있다.
서버 소켓은 요청을 승인하고 클라이언트 소켓을 생성하는 역할을 하고 클라이언트 소켓으로 부터 데이터를 읽고 쓴다.
bind를 통해 주소를 바인딩하는 이유는 소켓을 식별하기 위함이다. 
소켓 식별자는 (클라이언트 ip, port, 서버 ip, port)로 이루어져있다.

잠깐. 소켓 식별
소켓 또한 하나의 파일이기에 port를 할당받는다.
서버 소켓은 서버 프로세스 내에 위치하고 한개만 존재하기에 프로세스 port와 같다.
클라이언트에서 서버에 요청을 할 때, 여러 소켓을 생성하여 요청할 수 있다.(브라우저 방식처럼)
이때 각각의 소켓은 서로 다른 port번호를 할당받게 된다.
클라이언트 입장에서 서버와 연결하기 위한 서버 소켓은 하나이기에 목적지가 되는 ip와 port는 모두 같지만
클라이언트의 port가 소켓 마다 다르기에 서버에서 동일한 클라이언트에 대한 각기 다른 소켓을 port를 통해 구분할 수 있다.

 

2. NIO 방식으로 클라이언트 소켓 설정

accept를 통해 반환한 클라이언트 소켓을 Nio 소켓으로 래핑한다.

 

[NioEndpoint 클래스]

protected boolean setSocketOptions(SocketChannel socket) {
       . . .

       // NioChannel을 NioSocketWrapper로 래핑
       NioSocketWrapper newWrapper = new NioSocketWrapper((NioChannel)channel, this);

       // NioSocketWrapper에 등록한 NionChannel에 클라이언트 소켓을 주입함
       ((NioChannel)channel).reset(socket, newWrapper);

       this.connections.put(socket, newWrapper);

       // 클라이언트 소켓 NonBlocking으로 설정
       socket.configureBlocking(false);

       // poller에 NioSocketWrapper 등록
       this.poller.register(newWrapper);
       return true;

}

 

NioChannel을 만들어 NioSocketWrapper로 래핑하고 등록한 NioChannel에 클라이언트 소켓을 주입한다.

그리고 클라이언트 소켓을 NonBlocking으로 설정한 후, poller에 등록한다.

NioSocektWrapper는 비동기-NonBlocking방식으로 I/O 작업을 가능하게 하는 소켓 래퍼라고 생각하자.

 

3. Poller를 통한 IO 이벤트 등록

[Poller 클래스]

public class Poller implements Runnable {
   // 이벤트를 수신 받는 selector 객체
   private Selector selector = Selector.open();

   // 이벤트 큐 공간
   private final SynchronizedQueue<PollerEvent> events = new SynchronizedQueue();
   
   // 생략
   
   public void register(NioSocketWrapper socketWrapper) {
      // 데이터 읽기 이벤트를 수신 받겠다고 설정함
      socketWrapper.interestOps(1);

      // PollerEvent 등록 및 생성
      PollerEvent pollerEvent = this.createPollerEvent(socketWrapper, 256);
      this.addEvent(pollerEvent);
   }

   private void addEvent(PollerEvent event) {
      this.events.offer(event);
   }

   // 생략
}

 

Poller는 PollerEvent를 관리하며 event 발생시 해당 소켓에 이벤트를 전달한다.

PollerEvent는 단순히 NioSocket과 interestOps값을 가지고 있는데

interestOps는 데이터 읽기(1), 데이터 쓰기(4)과 같은 이벤트로 이루어져 있으며 이를 등록하여 해당 IO 이벤트 발생시 수신할 수 있다.

 

4. Poller를 통한 IO 이벤트 수신

[poller의 run 함수]

public void run() {

   // 무한 루프를 돔
   while(true) {

       . . .

       // 등록된 이벤트가 있는지 체크, 새로운 이벤트 등록
       hasEvents = this.events();

       // 이벤트 수신하고 이벤트 갯수를 keyCount에 저장
       this.keyCount = this.selector.selectNow();

       // 이벤트 존재시 selector를 통해 이벤트 가져옴
       Iterator<SelectionKey> iterator = this.keyCount > 0 ? this.selector.selectedKeys().iterator() : null;

       while(iterator != null && iterator.hasNext()) {
           SelectionKey sk = (SelectionKey)iterator.next();
           iterator.remove();

           NioSocketWrapper socketWrapper = (NioSocketWrapper)sk.attachment();
           if (socketWrapper != null) {
               // 이벤트 처리 요청
               this.processKey(sk, socketWrapper);
           }
       }
   }
}

 

[poller의 events 함수]

public boolean events() {
   boolean result = false;
   PollerEvent pe = null;

   int i = 0;

   // 등록된 이벤트 확인
   for(int size = this.events.size(); i < size && (pe = (PollerEvent)this.events.poll()) != null; ++i) {
       ...
       if (sc == null) {
        ...
       // 새로운 이벤트 등록
       } else if (interestOps == 256) {
           try {
               // 읽기 가능 이벤트 등록
               sc.register(this.getSelector(), 1, socketWrapper);
           } catch (Exception var12) {
               NioEndpoint.log.error(AbstractEndpoint.sm.getString("endpoint.nio.registerFail"), var12);
           }
       }
      ...
   }

   return result;
}

 

poller의 run 함수를 보면 무한루프를 돌며 Selector가 이벤트를 수신한다.

events라는 메서드를 통해 현재 등록된 소켓들의 IO 이벤트가 있는지 체크하고 새로운 소켓의 이벤트를 등록하기도 한다.

selector를 통해 수신 가능한 이벤트의 존재여부를 체크하고 이벤트가 있다면 processKey 메서드에 이벤트와 소켓을 넘긴다.

 

5. Worker 스레드 할당 및 request, response 생성

Poller.run()

-> processKey()

-> processSocket() 

-> executor.execute(sc);

 

Poller의 executor.execute를 통해 Worker 스레드에 소켓을 할당한다.

 

SocketProcessor.run()

-> doRun()

-> state = NioEndpoint.this.getHandler().process(this.socketWrapper, this.event);

-> Http11Processor.service()

 

Worker 쓰레드에 사용되는 구현체가 Http11Processor이다.

여기서 소켓을 통해 inputBuffer를 가져와 요청 데이터를 읽어들여 request와 response객체를 만든다.

여기서 읽어들이는 데이터는 http 헤더이다.

this.getAdapter().service(this.request, this.response);

위 메서드를 실행시켜 CoyoteAdapter에게 request, response를 전달한다.

 

6. Filter로 요청 전달

CoyoteAdapter.service()에서 아래 메서드를 통해 Valve들을 호출한다.

this.connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);


Valve는 필터처럼 체인형태의 파이프라인으로 구성되어있으며 request와 response의 전처리 작업을 수행한다.
에러를 기록하거나 SSL인증, 동시요청 스레드 제한 등의 Valve가 있다.
필터로 요청을 전달하는 Valve는 StandardWrapperValve이다.

filterChain.doFilter(request.getRequest(), response.getResponse());


StandardWrapperValve는 컨테이너에 서블릿이 등록되어있는지 검증하고 필터체인을 실행시킨다.

 

7. Servlet으로 요청 전달

필터체인의 마지막 필터인 ApplicationFilterChain이 서블릿을 실행시킨다.

this.servlet.service(request, response)

 

나의 경우 스프링 부트 프로젝트였기에 DispatcherServlet이 실행되는데

DispatcherServlet의 ArgumentResolver가 inputStream을 통해 request객체의 body를 읽어들이고

응답을 보낼땐 HttpMessageConverter를 통해 outputStream으로 응답을 보냄을 확인할 수 있다.

디버깅 자료가 필요하다면 [서블릿과 스프링 디스패처 서블릿] 역할과 동작 과정 글을 참고하면 된다.

 

이렇게 해서 톰캣의 요청 처리 방식에 대해 알아보았다.

톰캣은 클라이언트 요청 승인과 http header는 비동기 non-blocking 방식으로 동작하지만

body는 stream을 통해 동기-blocking으로 동작한다.

 

이전에 대용량데이터의 경우 스트림을 통한 io작업이 효율적일수 있다고 하였는데
톰캣은 상대적으로 적은 양의 데이터인 http header는 channel, 큰 용량인 body의 경우 stream을 통해 읽어들임을 확인하였다.

 

 

============================================================================

 



—------- 요청 처리 과정 —----------

스프링 부트의 톰캣 실행방법

 

구동 시점에 톰캣 웹서버 실행시킴

connector 생성함



channel의 비동기-NonBlocking을 위한 이벤트 처리 방식

 

멀티플렉싱



반응형