[Tomcat] 톰캣의 소켓 I/O 방식 (Block/Non-Block, BIO/NIO)

2022. 4. 21. 21:04[ 백엔드 개발 ]/[ Spring ]

기본적으로 톰캣은 쓰레드 풀에 200개의 쓰레드를 두고 요청 당 할당한다고 알고 있었지만 여기서 의문점이 생겼다. 그렇다면 동시에 최대 200개의 요청만 처리할 수 있는 것일까? 이번을 기회로 지금까지 스프링 부트 개발을 하면서 사용해왔던 톰캣의 기능과 톰캣이 요청을 받아 서블릿 컨테이너로 위임하는 과정 / 톰캣의 I/O 방식을 주제로 작성했다.

 

Tomcat이란?

톰캣은 아파치 재단에서 관리되며 Java 표준 인터페이스인 서블릿을 지원하기 위한 미들웨어이다. 톰캣은 OS로부터 네트워크 요청 정보를 받아와 자바 객체로 만들고 이를 서블릿 컨테이너로 위임한다. 톰캣은 웹 애플리케이션의 다양한 스펙 사항(서블릿 스펙, JSP스펙, '웹소켓' 스펙 등)을 준수하며 개발되었다. 

 

https://tomcat.apache.org/whichversion.html

링크(https://tomcat.apache.org/whichversion.html)에 접속해보면 톰캣의 버전을 확인할 수 있다. 현재는 8.5, 9.0, 10.0 버전이 지원되고 있다.

 

서블릿 컨테이너와 톰켓을 따로 두는 이유는 OS와 직접적인 의존성을 해결하기 위함이라고 생각한다. WAS는 미들웨어이기 때문에 OS로부터 네트워크 요청을 읽어 자바 객체로 만들어야 한다. 이러한 과정을 WAS에서 처리하고 생성된 자바 객체를 서블릿 컨테이너로 위임함으로써 서블릿 컨테이너는 TCP 통신과 같은 OS레벨에서 신경 써야 할 부분을 모두 제거하고 들어오는 요청(자바 객체)만을 다룰 수 있게 된 것이라 생각한다.

 

톰캣의 구성

- Coyote(HTTP Component) : Tomcat에 TCP를 통한 프로토콜 지원하며 HTTP 요청을 자바 객체로 뽑아내서 Catalina로 위임한다. (버전별 웹소켓 명세 사항 참고)
- Catalina(Servlet Container) : Coyote로부터 HTTP 요청 객체를 받고 요청을 처리할 컨텍스트를 찾는다.(현재는 DispatcherServlet에서 일괄 처리)

 

 

 

톰캣 WAS의 주요 기능

1. OS로부터 데이터 패킷을 받아 서블릿 컨테이너에 요청을 넘긴다. 이 과정에서는 톰캣은 소켓 I/O를 OS의 도움일 받아 처리한다.

2. 동적인 컨텐츠를 제공하기 위해 데이터베이스 연결을 지원해주고 트랜잭션을 관리한다. (tomcat-jdbc-pool 및 hikari 구현체)

 

 

 

톰캣이 HTTP 요청을 받아들이는 I/O

1. 네트워크 패킷부터 스프링 컨테이너까지 요청이 도작하는 과정

출처: https://velog.io/@jihoson94/BIO-NIO-Connector-in-Tomcat

1) OS 시스템 콜을 통해 이벤트 발생에 대한 port를 listen(소켓통신에서 client-serer 간 통신 과정 중 일부)하고 Socket Connection을 획득

2) Socket Connection으로부터 패킷 획득하고 WAS가 해당 데이터를 파싱 해 HttpServletRequest(ServletRequest 인터페이스의 구현체) 객체 생성

3) 생성한 HttpServletRequest 객체를 서블릿 컨테이너로 위임

4) 서블릿 컨테이너에서 모든 요청을 Dispatcher Servlet이 요청을 받고 받은 요청에 해당하는 컨트롤러(핸들러)를 찾기 위한 과정 진행

5) Dispatcher Servlet은 HandlerMapping과 HandlerAdapter를 인터페이스로 가지고 있고, 스프링 프레임워크가 애플리케이션이 처음 시작할 때 해당 인터페이스에 미리 다양한 구현체들을 주입함. Dispatcher Servlet은 현재 요청에 맞는 HandlerMapping과 HandlerAdapter의 구현체를 찾아 요청을 위임할 컨트롤러 결정

6) 결정된 컨트롤러로 요청 위임

7) 스프링 컨텍스트(root application context)에서 비즈니스 로직 처리 및 DB connection 관리

 

 

2. 톰캣 Connector 란

톰캣으로 요청이 들어오는 첫 번째 문은 Connector이다. 톰캣의 Connector는 호스트 PC로 들어오는 특정 TCP 포트의 요청들을 listen 하여 요청이 해당 포트의 애플리케이션으로 들어올 수 있도록 한다. 즉, Connector는 소켓 연결을 listen 하면서 들어오는 패킷을 파싱 해 HttpServletRequest 객체로 변환하여 서블릿 컨테이너로 요청을 위임하는 역할을 담당한다. 그러면 서블릿 컨테이너에서는 핸들러 매핑 전략에 의해 알맞은 핸들러를 찾고 비즈니스 로직이 수행된다.

 

HTTP를 비롯한 다양한 애플리케이션 레이어의 프로토콜을 처리할 수 있도록 개발되었고 각 프로토콜마다 해당하는 Connector가 존재한다. 대표적으로 HTTP/1.1 Connector, HTTP/2 Connector 등이 있다.

 

3. Connector 종류

톰캣 버전마다 Connector의 동작 방식이 다양한데, 대표적으로 BIO, NIO, NIO2, APR로 총 4가지 종류가 있지만 APR방식은 톰캣 버전 10부터 삭제되었다.

 

cf. APR 방식은 C언어 라이브러리를 JNI 인터페이스를 통해 로딩해서 사용하기 때문에 가장 빠르지만 C 레벨에서 문제가 셍기면 자바 프로세스가 죽어버리기 때문에 안정성 측면에서 좋지 않다.

 

 

1) BIO Connector (Connection Per Thread, Request Per thread)

BIO Connector는 기본적인 자바 I/O(java.io)를 사용한다. 쓰레드 풀에 쓰레드들이 생성되어 있다가 클라이언트와의 TCP 커넥션(소켓 연결)이 생성되면 하나의 쓰레드가 할당된다. 그리고 요청에 대해 응답을 하기까지 하나의 쓰레드에서 처리되며 응답을 한 후에 쓰레드 풀에 회수되는 것이 아니라 소켓 연결이 닫힐 때까지 요청을 계속해서 받아들인다. 이러한 성질에 비추어 볼때, Request Per Thread보다 Connection Per Thread가 더 맞는 말이 아닌가 싶다.

 

이러한 방식의 BIO Connector는 동시에 처리 가능한 클라리언트의 수(최대 동시 소켓 연결의 개수)와 동시에 활성화된 쓰레드 개수(요청처리 개수)가 같을 것이다.

 

BIO Connector의 가장 큰 문제는 컴퓨팅 리소스를 제대로 활용하지 못한다는 것이다. 각 Connection 마다 쓰레드가 할당 되어 새로운 요청이 들어오기 전까지 쓰레드는 'Block' 되기 때문에 쓰레드들이 충분히 사용되지 않고 IDLE 상태로 남아있는 쓰레드가 많이 발생할 것이다. 이뿐만 아니라 커넥션이 만들어졌을 때 쓰레드 풀에 IDLE 쓰레드가 없다면 쓰레드 풀에 IDLE 쓰레드가 반납되기를 무한정 기다리는 상태(이 또한 Block)가 된다. 이러한 문제점을 어느 정도 해결하고자 NIO Connector가 등장한다.

 

 

 

2) NIO Connector (NIO의 Selector 기반 Multiplexing)

Buffer와 Channel 기반의 자바 I/O인 NIO(New I/O, java.nio)를 사용한 커넥터이다. "BIO Connector와 다르게, 커넥션과 쓰레드가 1대1로 매핑되는 모델이 아니며 Poller라는 쓰레드가 모든 소켓 연결을 담당한다." 마치 Selector를 이용한 Multiplexing과 비슷한 상황이다. 그러다가 특정 소켓으로 데이터가 들어오고 처리 가능한 순간에만 쓰레드 풀에서 쓰레드를 할당시켜 쓰레드의 IDLE 상태를 줄인다.

 

https://www.baeldung.com/spring-webflux-concurrency

 

위 그림은 Poller가 커넥션을 얻기까지의 절차를 도식화한 것이다. 여기서 Acceptor는 EventQueue의 공급자, Poller는 EventQueue의 사용자이다. Acceptor는 소켓 연결의 절차 중 하나인 accept를 진행한다. 소켓으로부터 SocketChannel객체를 얻어 NioChannel 객체로 변환한 뒤 Event Queue에 넣는다. 이때 NioChannel 객체는 PollerEvent라는 객체로 캡슐화된다. 

 

Poller는 자바 NIO의 Selector를 가지고 있는데, Selector는 다수의 I/O 이벤트 발생을 알기 위한 Multiplexing 역할을 담당한다. 즉, Poller는 Selector를 통해 요청을 받고 쓰레드 풀에서 워커 쓰레드를 할당 시켜 요청을 넘긴다. 

 

NIO Connector에서 가장 중요한 것은 자바 NIO의 'Selector'이다. Selector를 통해 Multiplexing을 활용함으로써 실질적인 요청이 들어왔을 때에만 쓰레드 풀에서 워커 쓰레드를 할당시킴으로써 쓰레드의 IDLE 시간을 줄인다. 또한 Poller에선 MaxConnections까지 클라이언트와의 소켓 연결을 수락하고 Selector를 통해 채널을 관리하므로 EventQueue의 사이즈와 관계없이 추가로 커넥션을 거절하지 않고 받아놓을 수 있다. (하지만 TIME_WAIT가 만료된다면 처리할 수 없다.)

 

 

cf. BIO, NIO Connector 모두 요청 당 쓰레드를 할당하는 것은 맞지만 BIO Connector의 경우 응답을 보낸 뒤에서 커넥션이 만료될 때까지 running 상태로 남아있는 것이고 NIO Connector의 경우 응답을 보내면 Pool로 반환된다는 차이가 있는 것 같다. 이러한 요청 당 쓰레드를 할당하는 방식의 문제점은 다음과 같다.

 

1) CPU 자원이 결국 제한적이기 때문에 실행중인 쓰레드가 많아져 잦은 Context Switching이 발생 (잦은 Concurrency에 대한 비용 증가)

2) 쓰레드 수가 많기 때문에 공유 자원에 대해 발생할 수 있는 경합 증가

 

비동기 통신을 지원하는 Netty와 같은 프레임워크를 사용하면 I/O가 발생하는 상황(DB접속 포함)에서 해당 쓰레드가 I/O 요청을 처리하는 동안 Blocking되지 않고 I/O요청을 큐에 남긴 후 바로 빠져나와(Non-Blocking I/O) 다음 요청을 처리하도록 동작한다. 따라서 요청 당 쓰레드 모델보다 훨씬 더 적은 양의 쓰레드로 요청을 처리할 수 있게 되면서, 한정된 CPU자원에서 발생하는 Concurrecy를 위한 Context Switching의 발생 빈도, 공유자원에 대한 쓰레드 간 경합 등을 줄일 수 있다. 

 

이러한 Non-Blocking I/O 기반의 서버 애플리케이션을 구동하기 위해서는 DB 역시 Non-Blocking으로 동작해야 한다. 만약 DB가 Blocking으로 동작한다면 DB는 서버 애플리케이션으로부터 요청을 받고 요청을 모두 처리하기 전까지 응답을 할 수 없게 된다. 만약 DB가 Non-Blocking을 지원하지 않는다면 Kafka와 같은 MQ를 사용하여 요청을 빠르게 임시저장하는 용도로 사용하여 Non-Blocking 스럽게 구현할 수 있다. 또는 비동기 RDBMS를 위한 R2DBC를 사용하거나 MongoDB를 사용하는 방법도 있다.

 

cf. Tomcat NIO 코드로 이해하기 : https://jh-labs.tistory.com/m/329

cf. Channel I/O와 Stream I/O : https://jh-labs.tistory.com/353

 


 

워크로드

보통 컴퓨터가 작업을 처리하는 것을 워크로드라 하며 워크로드는 'CPU 기반의 워크로드''I/O 기반의 워크로드'로 구분된다. CPU 기반의 워크로드는 프로세스의 Instruction을 처리하기는 작업이며 I/O 기반의 워크로드는 CPU와 별개로 데이터의 입출력을 담당하는 워크로드를 의미한다. 쓰레드는 CPU에 할당되어 작업이 처리되지만 I/O 작업은 쓰레드 작업 시간과 관여되지 않기 때문에 inactive time에 해당한다. 

 

cf. I/O의 종류 (대체로 File System에 관여됨)

- 네트워크(소켓) : 서로 다른 노드에 존재하는 프로세스 간 통신 시 애플리케이션 레벨에서의 I/O

- file : 하드디스크에 존재하는 파일을 메모리르 통해 I/O 작업 수행

- pipe(프로세스 간 통신)

- device(모니터, 키보드 등으로부터의 데이터 입출력)

 

일반적으로 I/O 작업은 유저가 정의한 프로세스가 단독적으로 처리할 수 없으며 OS의 도움(예를 들어 read/write라는 시스템 콜)이 필요하게 되는데, I/O 작업이 필요한 시점에서 프로세스가 시스템 콜을 요청했을 때 I/O 작업을 기다리는 방식에 대해 Blocking I/O와 Non-Blocking I/O로 구분된다. 위와 같이 I/O에 소요되는 시간을 쓰레드가 그대로 기다리게 된다면 비효율적일 것이다.(I/O가 오래걸리는 작업이 아니라면 그렇지 않을 수도 있다.) Blocking I/O는 작업을 요청한 task(process/thread)가 I/O 작업이 완료될 때까지 block 되는(아무 작업도 하지 않고 기다리는) I/O를 의미하며 Non-Blocking I/O는 이러한 I/O 시간에 대해 쓰레드가 기다리지 않는 개념이다.

 

시스템 콜을 통해 CPU의 상태는 커널 모드로 전환되며 커널 프로세스가 CPU를 점유해 해당 I/O 작업을 수행하게 된다. 커널 프로세스가 데이터 입출력을 완료 응답을 받게 되면 커널 모드에서 유저 모드로 전환하며 커널은 데이터를 기존 프로세스로 전달한다. 그리고 이때 block 되었던 task는 깨어나고 다음 로직을 수행하게 된다. 

 

 

Socket I/O에서의 Blocking이란?

Socket마다 송신 버퍼와 수신 버퍼를 가지는데 데이터를 전송하는 클라이언트 쪽에서의 송신 버퍼의 데이터가 수신 소켓의 수신 버퍼로 데이터가 들어오고 이때 수신 측이 해당 소켓의 FD(파일 디스크립터)에 대해 read 시스템 콜을 요청하면, 소켓의 수신 버퍼에 데이터가 들어올 때까지 read 시스템 콜을 호출한 쓰레드는 block 되어 작업을 중지한다.

 

데이터를 전송하는 클라이언트 입장에서도 마찬가지이다. 클라이언트 호스트가 소켓의 송신 버퍼에 write 시스템 콜을 요청하면 커널이 해당 버퍼에 데이터를 모두 넣을 때까지 block 된다. 만약 송신 버퍼가 비어있어 데이터를 바로 넣을 수 있다면 block 되는 시간을 짧겠지만 송신 버퍼가 가득 차 있을 경우에는 송신 버퍼가 비워질 때까지 blocking 될 것이다. 이와 같은 Blocking 기반의 I/O는 긴 I/O 작업이 요구되는 경우 유저 쓰레드가 오랫동안 block 된다는 문제점을 가진다. 

 

 

Non-Blocking I/O의 등장

Non-Blocking I/O는 task를 block 시키지 않고 I/O 요청에 대한 '현재 상태'를 바로 응답한다. 예를 들어 소켓 수신 버퍼에 read라는 시스템 콜을 Non-Blocking 모드로 호출하면 커널 모드로 context switching이 되고 커널은 I/O 작업을 수행한다. 여기서 커널은 I/O 작업을 시작함과 동시에(I/O 작업이 완료되기 전에), 즉시 -1이라는 값(리눅스 기준으로 데이터가 완료되지 않았을 경우에 해당)을 리턴하며 CPU는 유저 모드로 switching 된다. 따라서 커널로부터 응답을 받은 프로세스는 이어서 다른 작업을 수행할 수 있게 된다. 그리고 I/O 작업이 완료되었음을 커널로 응답이 오고 커널은 데이터를 수신 버퍼에 준비해둔다.

 

이때 프로세스는 주기적으로 read 시스템 콜을 커널로 보내고(polling) 데이터가 준비되었다면 커널은 해당 데이터를 프로세스로 전송한다. 이렇듯 Non-Blocking I/O는 유저 쓰레드를 block하지 않고 '즉시 응답'(데이터 준비가 완료되었든지 완료되지 않았던지) 하기 때문에 유저 쓰레드는 I/O 작업 중에 다른 워크로드를 실행할 수 있게 된다. 이렇듯 Non-Blocking I/O의 핵심은 CPU의 IDLE 시간을 줄임으로써 유저 쓰레드가 보다 빠른 응답을 할 수 있도록 한다. 

 

cf. polling 방식은 I/O 완료 작업을 확인하는 방법 중 일부일 뿐이며 이외에도 다양한 방법이 존재한다. Polling은 다른 장치(또는 프로그램)의 상태를 주기적으로 검사하여 일정한 조건을 만족할 때 송수신 등의 자료처리를 하는 방식을 의미하며 Non-Blocking I/O에서는 시스템 콜을 주기적으로 보내 I/O 작업이 완료되었는지를 확인하는 방식이다.

 

 

cf. Non-Blocking, Blocking I/O에서 쓰레드 할당 구조

Blocking I/O에서는 I/O 작업에 대해 쓰레드가 Blocked되기 때문에 요청 당 쓰레드를 두는 방식이 일반적이다. 하지만 Non-Blocking I/O에서는 I/O 작업 중에도 쓰레드가 Blocked되지 않기 때문에 하나의 쓰레드 만으로도 여러 요청을 처리할 수 있다. 따라서 일반적으로 위와 같은 구조로 구동되며 Non- Blocking I/O 기반에서는 쓰레드 자원(CPU)을 아낄 수 있다는 장점이 있다. (Non- Blocking I/O 기반이라고 해서 무조건 Single Thread가 작업을 처리하는 것은 아니며 Blocking I/O보다 더 적은 쓰레드를 기반으로 운영할 수 있다는 의미) 두 모델 중 어떤 것이 더 좋다, 나쁘다라고 말할 순 없다. I/O 작업이 많은 특성을 가진다면 Non-Blocking, 콜백 기반의 Node.js를, CPU 기반 워크로드가 많다면 Java와 같은 모델을 사용하는 것이 좋다.

 

 

 

Socket I/O에서의 Blocking I/O와 Non-blocking I/O

1) Blocking I/O의 경우

유저 쓰레드가 커널에 I/O에 해당하는 시스템 콜을 요청하면 데이터가 해당 소켓의 수신 버퍼에 준비가 되어있다면 바로 데이터가 완료되었음을 응답하지만, 그렇지 않다면 block 된 상태로 대기하는 상황에 빠진다. 

 

2) Non-Blocking I/O의 경우

유저 쓰레드가 커널에 시스템 콜을 요청하면 데이터가 준비되어 있지 않더라도 유저 쓰레드에 데이터가 준비되어 있지 않다는 상황을 즉시 응답하고 유저 쓰레드는 다른 작업을 이어 나간다.(시스템 콜에 대한 요청이 바로 종료됨)

 

클라이언트의 입장에서도 마찬가지이다. Blocking I/O의 경우, 송신 측 소켓의 송신 버퍼가 가득 차 있다면 유저 쓰레드는 송신 버퍼에 공간이 생길 때까지 block 되지만 Non-Blocking I/O의 경우, 송신 버퍼가 가득 차있더라도 write 시스템 콜을 호출한 쓰레드를 block 시키지 않고 송신 버퍼가 가득 차 있다는 상태를 바로 응답한다.

 

 

cf. Non-Blocking의 단점

- Non-Blocking은 무거운 I/O 작업이 있을 때에만 유용하다.

- 기본적으로 I/O 작업은 일반적인 CPU 작업보다 더 큰 워크로드가 필요로 한다.

- 워크로드의 대부분이 CPU 작업이라면 Non-Blocking을 사용하는 것은 비효율적이다.

- 프로세스의 특성을 파악하고 'I/O 기반의 워크로드 > CPU 기반의 워크로드'라면 Non-Blocking이 유리하지만 'I/O 기반의 워크로드 < CPU 기반의 워크로드'라면 Blocking I/O가 유리할 수도 있다. 

 

 

 


 

Non-Blocking I/O 작업의 이슈: I/O 작업의 완료를 어떻게 확인할 것인가?

1. I/O 작업이 완료되었는지 시스템 콜을 주기적으로 요청하여 확인하기 

[이 방식의 문제점]

1) I/O 작업이 완료된 시간과 완료를 확인하는 시점의 시간 차이로 인해 처리 속도가 느리게 보일 수 있다. 즉, I/O 작업은 이미 완료가 되었지만 이를 확인하는 시스템 콜이 I/O 작업이 완료된 이후에 도착한다면 두 시점 간의 시간처가 존재할 수밖에 없다.

 

2) 주기적으로 I/O 작업 완료를 확인하는 것은 반복적인 시스템 콜이 요구되므로 CPU 낭비가 발생(context switching 과다)

 

2. I/O Multiplexing(다중 입출력) **

관심 있는 I/O 작업을 동시에 모니터링하면서 그중에 완료된 I/O 작업들을 한 번에 알리는 방식이다. 예를 들어 2개의 소켓에 대해 Non-Blocking 모드로 Multiplexing 시스템 콜을 요청하면(2개의 소켓에 새로운 데이터가 존재하는지 알려달라는 시스템 콜) 커널은 2개의 소켓에 대해 read 작업을 수행한다. 이때 Multiplexing 시스템 콜을 요청한 쓰레드는 Block 될 수도 있고 Non-Block 될 수도 있다. 만약 Block 방식으로 대기를 하고 있었다고 가정하면 커널로부터 2개의 소켓에 데이터가 있다는 요청이 도착할 경우 유저 쓰레드는 이들을 순차적으로 처리할 수 있다.

 

이렇듯 I/O Multiplexing은 한 번의 시스템 콜을 통해 여러 이벤트(여러 개의 소켓에 대한 read/write 작업 등)를 응답받을 수 있다. 따라서 동시에 여러 개의 요청이 필요한 Socket I/O에서 I/O Multiplexing이 주로 사용된다. I/O Multiplexing을 활용하여 I/O를 담당하는 쓰레드를 따로 두고 쓰레드풀에 여러 개의 쓰레드를 미리 만들어 두었다면 각각의 소켓에 해당하는 요청을 각 쓰레드에 할당시켜 동시에 작업을 처리할 수 있으며 이러한 방식은 I/O Multiplexing은 톰캣(9.0 이후 NIO), Netty(WebFlux기반), NodeJS 등과 같은 서버사이드 프로그램이 구현하여 제공한다.

 

 

cf. I/O Multiplexing의 종류

- select : 성능상 이슈가 있어 사용하지 않음(시스템 콜을 보낸 쓰레드는 block/non-block모두 가능)

- polling : 성능상 이슈가 있어 사용하지 않음(시스템 콜을 보낸 쓰레드는 block/non-block모두 가능)

- epoll : 리눅스에서 사용됨

- kqueue : MacOS에서 사용됨

- IOCP(I/O completion port) : 윈도우에서 사용됨

 

 

cf. 리눅스에서 사용되는 I/O Multiplexing: 'epoll'

서버에 열려있는 여러 개의 소켓 중에서 최소 1개의 소켓의 수신 버퍼에 read 이벤트가 발생하면 유저 쓰레드가 알림을 받는 방식이다. 만약 3개의 클라이언트가 요청을 보내 서버에서 3개의 소켓에 이벤트가 발생했다고 가정하면 epoll을 호출한 쓰레드는 깨어나고 이에 대한 응답으로 요청이 들어온 3개의 소켓에 데이터가 있음을 알린다. 그러면 커널로부터 응답을 받은 유저 쓰레드는 3개의 소켓에 대해서 데이터를 읽어 처리하는 방식이다. 

 

 

 

3. Callback/Signal

유저 쓰레드가 I/O 요청을 Non-Blocking 모드로 시스템 콜을 보내고 응답을 받지 않은 채로 바로 다른 로직을 수행한다. 이후 커널이 I/O 작업 완료 응답을 받으면 커널은 callback이나 signal을 유저 쓰레드로 보내 처리가 된다. (자바스크립트 기반인 NodeJS에서 주로 사용됨)

 

[callback/signal 종류]

- POSIX AIO : 여러 OS에서 사용될 수 있는 명세 사항

- LINUX AIO : 리눅스 커널 자체의 AIO

 

 

 

cf. Synchronous / Asynchronous

- Synchronous: I/O 요청을 호출한 쪽이 결과까지 직접 처리하는 경우이며 순서가 보장되어야 한다.

- Asynchronous: 커널로부터 notify를 받거나 callback을 통해 알림을 받아 I/O를 요청한 쓰레드가 직접 결과를 처리하지 않는 상황을 의미한다. 즉, 결과 처리는 다른 쓰레드가 담당한다.

 

예를 들어 read/write 시스템 콜을 Blocking 모드로 호출하든 Non-Blocking 모드로 호출하든 Synchronous에 해당한다. 요청을 한쪽이 직접 응답을 받아 처리하기 때문이다.

 


 

톰캣 분석

톰캣 BIO, NIO 차이

https://tomcat.apache.org/tomcat-8.0-doc/config/http.html#Connector_Comparison

 

cf. 톰캣 9.0 버전부터는 BIO 방식이 일괄 삭제되었음

 

 

1) BIO (java.io 기반의 I/O)

하나의 쓰레드가 하나의 커넥션을 담당하는 방식으로 쓰레드 풀의 개수와 가능한 최대 커넥션 개수가 같다. BIO는 자바의 기본 IO 기술을 사용한다. BIO 방식은 기본적으로 쓰레드 풀에 쓰레드들을 미리 생성해 두고 소켓 연결을 받고 요청을 처리하며 응답이 오기까지 하나의 쓰레드에서 일괄 처리되는 방식이다. 따라서 이러한 BIO 방식에서는 쓰레드 풀의 개수가 동시에 처리되는 요청의 개수와 동일하다. 

 

BIO의 문제점은 CPU 자원을 효율적으로 사용하지 못한다는 점에 있다. BIO는 기본적으로 Blocking I/O 기반이기 때문에 요청이 들어오는 과정에서(소켓 I/O에서) 소켓의 버퍼에 새로운 데이터가 있는지 지속적으로 확인해야 하기 때문에 CPU가 IDLE 상태로 남아있게 될 것이다.

 

2) NIO (New I/O)

톰캣은 자바의 기존 I/O(java.io)의 성능상 이슈를 해결하고자 자바의 NIO(java.nio)를 채택했다. 자바의 특성상 OS위에 JVM이라는 가상 머신을 올리는 구조기이기 때문에 시스템 콜이 필요한 I/O 상황에 대해서는 성능이 좋지 못했다. 

 

cf. 기존 java.io의 문제점

- OS의 버퍼에서 JVM의 버퍼로 데이터를 이동하는데 추가적인 CPU 연산 사용

- 옮긴 JVM의 버퍼는 GC 대상에 해당함

- JVM의 버퍼로 데이터를 이동하는 중에 쓰레드는 block 됨(시스템 콜을 직접적으로 사용하지 못하기 때문에 Non-Block이 불가능한 상황)

 

위와 같은 문제를 해결하고자 자바는 NIO(New I/O, java.nio)를 도입한다. NIO와 기존 I/O의 차이점은 스트림이 아닌 '채널'을 사용한다는 점과 JVM이 관여하지 않고 OS와 바로 연관되는 Direct 버퍼를 도입했다는 점이다. 

 

ByteBuffer buf1 = ByteBuffer.allocate(1024); 

ByteBuffer buf2 = ByteBuffer.allocateDirect(1024);

 

위와 같이 java.nio의 ByteBuffer를 상속하는 클래스는 allocateDirect() 메소드를 지원하며 JVM에서 관리하는 버퍼를 거치지 않고 데이터 I/O를 이룰 수 있기 때문에(ZeroCopy) 성능상 이점을 가져갈 수 있다. 하지만 direct 버퍼의 할당과 해제에 필요한 오버헤드가 non-direct 버퍼보다 크기 때문에 입출력할 데이터가 별로 크지 않거나 버퍼를 자주 사용하고 해제해야 하는 경우라면 non-direct 버퍼(기존의 java.io)를 사용하는 것이 더 좋다.

 

 

자바의 NIO는 채널별로 Blocking과 Non-Blocking 방식을 모두 제공한다. 톰캣 버전 8.5에서 자바 NIO를 적용했으며 Blocking과 Non-Blocking 방식을 모두 사용하고 있음을 확인할 수 있다. NIO 기반의 톰캣에서 커넥션과 쓰레드의 Default 값은 각각 10,000과 200이다. 즉, I/O Multiplexing를 이용해 10,000개의 채널(커넥션)을 listen 하고 쓰레드풀에 200개의 쓰레드를 요청마다 할당한다. 

 

 

https://www.javatpoint.com/java-nio-socketchannel

 

 

 

톰캣의 NIO와 BIO의 주요 공통점

1) Request Queue

요청이 들어왔을 때 IDLE 쓰레드가 없다면 Queue에서 IDLE 쓰레드가 생길 때까지 대기한다. 톰캣 설정 시 acceptCount 속성을 통해 Queue 사이즈를 정할 수 있다. 만약 Queue에도 자리가 없다면 클라이언트 쪽으로 에러 메시지가 응답된다. Queue의 사이즈를 너무 크게 한다면 서버에 과부하가 발생할 수도 있고 메모리를 낭비할 수도 있기 때문에 Queue 사이즈를 적절하게 설정한다.

 

2) Max Connections(클라이언트와의 소켓 연결 수)

최대 커넥션 개수란 하나의 톰캣 인스턴스가 유지할 수 있는 TCP 커넥션 개수(소켓 파일 디스크립터 개수 == 최대로 수용할 수 있는 클라이언트 개수)를 의미한다. 여기서 커넥션이란 TCP 커넥션(소켓 개수)을 의미하는데, HTTP가 발전함에 따라 하나의 TCP 커넥션에 여러 개의 HTTP 통신을 하는 HTTP persistent connection 개념이 도입되었다. (HTTP/1.1부터 지원되며 다음과 같은 헤더를 사용한다. Connection: keep-alive) 따라서 요청의 개수와는 다른 개념으로 이해해야 한다.

 

TCP 커넥션을 종료할 때, 먼저 TCP 연결 종료를 요청한 곳에서 바로 socket을 닫는 것이 아니라 TIMED_WAIT 만큼의 커넥션을 유지시키며 가 남게 되며, TIMED_WAIT는 TCP 연결 종료 시 진행하는 4-way-handshake에서 마지막 ACK이 제대로 도착해야 상대방이 확실히 연결이 끝났음을 알 수 있다. 따라서 여러 개의 커넥션을 통해 요청이 들어온다 하더라도 특정 커넥션에는 TIMED_WAIT에 머물러있는 커넥션이 있기 때문에 maxConnections 설정을 크게 해 두는 것이 좋다.

 

즉, HTTP/1.1부터 지원되는 persistent connection으로 인해 요청을 처리하지 않는 커넥션이 남아 있을 수 있기 때문에 최대 커넥션 개수를 크게 설정해야 한다. (너무 크게 하면 FD 자원을 남발할 수 있다!)

 

3) maxKeepAliveRequests

maxKeepAliveRequests는 persistent connection에서 커넥션을 닫을 때까지 HTTP 파이프라인에 보낼 수 있는 요청의 최대 개수이다. 기본적으로 톰캣은 maxKeepAliveRequests 값을 100으로 설정한다. 즉, 하나의 TCP 연결의 HTTP 파이프라인에 100개의 요청을 한 번에 수용한다. keep-aplive 헤더(persistent connection) 설정의 단점은 커넥션을 계속 열어두기 때문에 컴퓨팅 리소스를 많이 사용한다는 것이다. 따라서 해당 값을 1로 설정하면 persistent connection을 사용하지 않는다. 즉, keep-alive 헤더를 사용하지 않으며 HTTP 파이프라인을 허용하지 않는다.

 

4) maxThreads

톰캣 내에서 동시에 요청을 처리하는 쓰레드의 개수이다. 기본적으로 200을 사용하며 스트레스 테스트 시 중요한 지표가 된다.

 

 

 

톰캣 NIO Connector의 동작 플로우

아래는 실제 톰캣의 Acceptor, PollerEvent, Poller의 코드를 확인해보면서 요청이 들어오는 과정을 확인해본 순서이다. NIO Connector는 톰캣 9버전 이상부터 사용된다.

 

cf. 링크(https://tomcat.apache.org/whichversion.html)를 참고한 결과, 톰캣 6.0부터는 NIO 방식이 도입되었고 BIO와 함께 사용되어 오다가 8.0부터는 NIO2가 도입되었으며 9.0 버전부터는 BIO가 완전히 삭제되었음을 확인할 수 있다. 

 

* 아래 코드는 tomcat 9.0.65 버전 기준(spring boot starter web 2.6.2는 9.0 버전의 톰캣을 기반으로 함)

 

1. Acceptor

Acceptor

 

 

- 위 코드에서 볼 수 있듯이 shutdown 커맨드가 발생하지 않는 한, loop를 돌면서 새로운 소켓 커넥션이 발생하는지 확인한다. (line 76)

- Acceptor는 Socket 연결 과정 중 서버에서 진행되는 단계인 'accept'를 진행한다. (line 129)

- stop이나 pause 상태가 아니면 소켓에 대한 설정을 진행한다. (line 149)

 

 

setSocketOptions

 

- Acceptor가 호출한 setSocketOptions 메소드에서는 Acceptor가 전달한 새로운 소켓인 SocketChannel 객체를 인자로 받고 톰캣의 NioChannel 객체로 감싼다. (line 493)

- socket Channel에 대한 설정을 하는 부분도 찾아볼 수 있는데, configureBlocking 메소드를 통해 소켓I/O 과정을 Non-Blocking으로 설정하고 있음을 확인할 수 있다. (line 500)

- 마지막 단계로 캡슐화한 SocketChannel(NioChannel) 객체를 Poller에 등록한다. (line 508)

 

 

- Channel의 Key를 이용하여 해당 Channel에 대해 OP_READ 연산을 등록한다. 현재 과정은 새로운 소켓을 등록하고 요청을 받기 위한 과정이기 때문에 READ 연산을 사용하는 것으로 생각된다. (line 753)

- Poller의 register에서는 캡슐화된 NioChannel 객체를 받고 PollerEvent 객체로 한번 더 캡슐화하여 EventQueue에 담는다. (line 755)

- 메소드 설명을 보니, 새롭게 열린 소켓(Channel)을 Poller에 등록하는 과정이라고 한다. 즉, Poller가 Selector를 관리하고 있으므로 소켓 Channel을 Selector에 등록하는 것으로 추측된다.

 

 

2. Poller

 

- 위 Acceptor에 의해 캡슐화된 PollerEvent 객체가 register 메소드 호출로 인해 Poller의 events라는 SynchronizedQueue에 들어간 상태이다. (line 621)

- Poller 쓰레드는 위와 같이 loop를 돌면서 새로운 이벤트를 확인한다. 따라서 Acceptor는 events Queue의 Producer이고 Poller 쓰레드는 events Queue의 Consumer이다. (line 799)

Poller는 위와 같이 Selector를 가지고 있으며(line 620) Selector에는 여러 Channel이 등록되어 있고, select 또는 selectNow 메소드를 통해 새로 추가된 소켓 Channel의 Key 개수를 획득하는 구조이다. (line 803, 805)

 

 

cf. Select Method

Selector는 JDK 1.4 NIO부터 추가되었으며 Selector에 Channel을 등록하면 새로운 이벤트가 Channel에서 발생할 경우 해당 Channel에 대한 접근이 가능해진다. 이는 Selector가 Single Thread 기반으로 multiplexing을 하는 방식으로 동작한다.

 

이때 등록한 Channel에서 어떤 타입의 이벤트를 알림 받을지에 따라 아래와 같이 3가지 타입으로 분류된다.

- accept: 새로운 TCP 커넥션이 발생

- read: 연결된 커넥션에서 read 요청이 발생했고 I/O 준비가 완료되었을 경우 발생

- write: 연결된 커넥션에서 write 요청이 발생했고 I/O 준비가 완료되었을 경우 발생

- connect: 새로운 TCP 커넥션이 닫힐 경우

 

Java NIO Selector의 selectNow()
Java NIO Selector의 select()
Java NIO Selector의 selectedKey()

1) select(): blocking기반의 select이며 I/O 준비가 완료된 Channel의 Key들을 select한다.

2) selectNow(): Non-Blocking기반의 select이며 I/O 준비가 완료된 Channel이 없다면 0을 반환한다.

3) selectedKeys(): Non-Blocking I/O가 준비된 Channel들의 Key집합을 반환한다. 결국 Non-Blocking I/O를 할 때에는 selectedKeys메소드를 통해 I/O 준비가 완료된 Channel들을 알 수 있게 되는 것이다. I/O가 준비된 Channel을 selected-key Set을 순회하며 I/O 연산을 진행한다. 반환되는 Set은 Thread-Safe하지 않다고 한다. 따라서 하나의 쓰레드(Poller)에서만 해당 Set에 접근해야 한다.

 

Poller의 run()

- 위에서 selectNow(), select()를 통해 key 개수를 획득했고 이때 keyCount가 0보다 크다면 실제로 소켓 Channel을 가져오는 연산을 진행한다. (line 830)

- selectedKeys() 메소드를 통해 I/O가 준비된 Channel들의 Key Set을 얻어온다. 여기서 Selector 기반의 (Java 레벨에서) Multiplexing이 진행된다.

- 그리고 아래 833 Line의 while문에서는 수집한 key들에 대해 event를 활성화한다. (아래 processKey 메소드 설명 참고) (line 831~833)

 

cf. SelectionKey

XXXChannel.register() 메소드를 통해 Selector에 Channel을 등록하면, Selector는 selectedKeys() 메소드를 통해 Channel에 대한 Key를 반환하는데, 여기서 Key는 'SelectionKey' 클래스로 표현한다. 즉, Selector에 등록된 Channel들은 각각 Key를 가지는 구조이다.

 

[SelectionKey에 포함된 정보]

1) Key를 의미하는 Channel

2) Channel이 등록된 Selector

3) 어떤 타입의 I/O 연산을 감지할 지

    - OP_READ = 1 << 0
    - OP_WRITE = 1 << 2
    - OP_CONNECT = 1 << 3
    - OP_ACCEPT = 1 << 4

4) Channel이 attatch된 Buffer

 

SelectionKey는 단 1곳의 Buffer에만 attached 될 수 있다. (Non-Blocking I/O를 하기 위해 Channel은 반드시 read/write할 Buffer를 필요로 함) 위 코드의 line 836의 attachment() 메소드는 해당 Channel(SelectionKey)이 attach된 Buffer를 반환한다. (Buffer를 포함하여 Wrapping된 객체를 반환하는 것 같다.)

 

위와 같이 SelectionKey 객체로 Channel 관련 정보를 한번에 묶음으로써 쉽게 관리할 수 있다. 예를들어 아래는 각각 SelectionKey를 기반으로 Channel, attachment를 가져오는 과정이다.

 

 

processKey()

Poller의 processKey()

processKey() 메소드에서는 소켓 Channel에 대해 실제 I/O 연산을 진행하는 부분이다. 첫 번째 블록에서는 Channel의 SelectionKey가 OP_READ 모드일 경우에 해당한다.Blocking, Non-Blocking I/O를 모두 지원하고 있음을 확인할 수 있다. write 모드일 경우도 같은 방식으로 동작하며 이는 Channel의 Buffer에 write할 경우에 해당하기 때문에 요청에 대한 응답을 처리할 때 사용될 것이다.

 

 

 

톰캣 NIO 요약

- 톰캣의 NIO Connector는 Selector를 활용하여 Java 레벨에서 Multiplexing 할 때, 데이터 처리가 가능할 때에만 Worker Thread를 사용하기 때문에 Idle 상태의 쓰레드를 줄일 수 있다.

- PollerEvent Queue는 소켓 Connection 당 하나의 PollerEvent(Socket Channel)가 담기게 된다. 즉, 동시에 서비싱 가능한 최대 클라이언트 수와 Worker Thread Pool의 사이즈는 같지만, Worker Thread Pool에 활성 가능한 쓰레드가 없더라도 PollerEvent Queue에서 새로운 클라이언트에 대한 소켓 Connection을 캐시와 비슷한 형태로 가지고 있기 때문에 Connection을 refuse하지 않고 받아 놓을 수 있다.

 

 


 

정리

- 톰캣이란 OS로부터 TCP 연결(소켓통신)을 맺고 클라이언트로부터 패킷을 받아 자바 객체로 만드는 역할을 담당하는 미들웨어이다.

- I/O에는 file, pipe, socket, device 등이 존재하며 이들은 FD(파일 디스크립터)를 기반으로 동작한다.

- 유저 레벨의 프로세스가 I/O를 하기 위해선 OS의 도움. 즉, 시스템 콜이 필요하다.

- OS에 요청한 시스템 콜을 '기다릴 것이냐/그렇지 않을 것이냐'에 따라 Blocking I/O, Non-Blocking I/O로 구분된다.

- 또한 OS로부터의 응답을 '요청한 task가 받아 처리할 것이냐/다른 task가 받아 처리할 것이냐'에 따라 Synchronous/Asynchronous로 구분된다.

- Multiplexing이란 하나의 시스템 콜이 하나의 소켓의 버퍼에 데이터를 기다리는 것이 아닌, 하나의 시스템 콜을 통해 여러 개의 소켓으로부터 I/O 이벤트를 요청받을 수 있는 방식이다.

- 프로세스의 작업 처리를 워크로드라 하며 CPU 기반의 워크로드와 I/O 기반의 워크로드로 구분된다.

- CPU 기반의 워크로드는 유저 프로세스가 직접 CPU에 할당되어 작업을 처리하기 때문에 컨텍스트 스위칭이 필요 없어 I/O 기반의 워크로드보다 빠르다. 또한 I/O 기반의 워크로드는 디바이스에 접근하는 시간이 소요되기 때문에 CPU 기반의 워크로드보다 느리다.

- 프로세스의 워크로드 특성을 파악하고 Blocking I/O, Non-Blocking I/O를 결정하는 것이 좋다.

 

 

 

Reference

- https://tomcat.apache.org/whichversion.html

- https://tomcat.apache.org/tomcat-8.0-doc/config/http.html#Connector_Comparison

- https://tomcat.apache.org/tomcat-9.0-doc/aio.html

- https://www.javatpoint.com/java-nio-socketchannel

- https://stackoverflow.com/questions/68243285/tomcat-maxthreads-is-config-for-acceptor-threads-or-request-processing-threads

- https://www.baeldung.com/spring-webflux-concurrency