이 포스팅에서는 Lettuce를 이해하기 위한 Netty를 이해하기 위한 Non-blocking I/O를 정리하고자 한다.
이 글은 Mark-Kim님의 사례를 통해 이해하는 네트워크 논블로킹 I/O와 Java NIO 기반으로 작성되었으며, 우선 사례는 구현해보지 않고 내가 이해한 내용을 기반으로 이론적인 내용을 정리해보고자 한다.
가장 먼저 Netwotk Socket은 크게 Blocking 모드와 Non-blocking 모드로 나뉜다.
간단하게 Blocking Mode는 요청이 끝나기 전까지 클라이언트에게 응답을 되돌려주지 않는 방식을 의미하고, Non-blocking Mode는 요청한 작업의 성공여부와 관계없이 바로 응답을 돌려주는 방식이다.
여기서 Blocking 모드는 위의 설명만으로 이해가 잘 되지만 Non-Blocking은 어떻게 동작하는지 이해가 잘 가지 않는 사람들이 많을 것이다.
그 이유는 Non-Blocking을 구현하는 방식이 다양하기 때문인데, I/O 관점에선 Multiplexing 방식이 가장 많이 사용된다.
Multiplexing I/O는 다른 포스팅에서 다뤘으니 넘어가고, 먼저 Blocking I/O에 관해 간단하게 알아보도록 하자.
Blocking Mode는 Main Thread를 하나만 사용하는 Single Thread, Multi Thread, Thread Pool을 활용한 Blocking Mode로 진화해왔다.
먼저 Single Thread는 한번에 한 사용자의 요청밖에 처리하지 못하기 때문에, 새로운 요청이 들어올 때마다 새로운 Thread를 생성하는 Multi Thread방식이 나오게 되었다. 하지만 이는 Thread의 생성 / 삭제 비용이 너무 크기도 하고, Connection이 많아짐에 따라 서버에 부하가 무한정으로 늘어나 서버가 죽을 가능성도 생긴다. 거기에 Context Switching 비용도 있어 여러모로 대용량 트래픽을 처리하기엔 부족함이 있다.
그래서 나온게 Thread Pool을 이용한 방식이다. 이는 웹 MVC의 기초가 되는 방식으로, 미리 Thread를 만들어 놓고 새로운 Connection이 생기면 할당해주는 식으로 사용하며, 앞의 Thread생성 / 삭제 비용을 획기적으로 줄일 수 있는 방식이다.
하지만 이 방식도 동시 접속자가 많아질 경우 Thread Pool을 넘어선 요청은 모두 대기해야한다는 문제점과, Blocking 방식으로 동작함에 따른 컴퓨터 리소스(CPU, 메모리)를 제대로 활용하지 못한다는 것이다.
또한, 동시 접속 수를 늘리기위해 Thread Pool의 크기를 자바 Heap이 혀용하는 최대치로 늘리는것이 합당한가? 라는 질문도 아래 두 관점에서 생각해 보아야 할 것이다.
이러한 문제들을 해결하기 위해 나온 해결책이 Non-blocking으로 소켓을 처리하는 것이다.
앞의 문제들을 해결하는 방법은 I/O 작업을 Non-blocking 방식으로 바꿔서 적은 수의 Thread로 여러 개의 커넥션을 처리하도록 하는 것이다.
자바도 I/O에 대한 Non-Blocking 기반의 Multiplexing 처리를 위해 New I/O (NIO)를 도입한 것이며, NIO는 현재까지도 Kafka나 Netty같은 여러 라이브러리와 프레임워크에서 사용되고있다.
이제부터는 NIO가 어떻게 Non-Blocking을 지원하게 되었는지 단계별로 살펴보겠다.
이는 Java I/O가 Blocking 방식으로 처리되는 문제도 있었지만, 또 다른 큰 이유는 JVM이 커널 버퍼 (Direct Buffer)를 직접 핸들링 할 수 없었기 때문이다.
소켓이나 파일에서 Stream이 들어오면 OS의 커널은 데이터를 커널 버퍼에 쓰게되는데, Java 코드상에서 이 커널 버퍼에 접근할 수 있는 방법이 없었다.
따라서 처음엔 JVM 내부 메모리에 커널 버퍼 데이터를 복사해서 접근할 수 있도록 했기 때문에, 커널에서 JVM 내부 메모리에 복사하는 오버헤드가 존재했다.
여기서 말하는 JVM 내부 메모리는 프로세스별로 할당되는 스택이나 힙 메모리를 의미한다.
위 블로그에서는 다음과 같이 설명이 되어있다.
💁♂️ ByteBuffer를 사용하는 NIO
JDK 1.4부터 JVM 내부 버퍼에 복사하는 문제를 해결하기위해 kernel 버퍼에 직접 접근할 수 있는 기능을 제공하기 시작했다.
바로 ByteBuffer 클래스다.
ByteBuffer는 직접 kernel 버퍼를 참조하고 있으므로, 위에서 발생한 복사문제로인해 CPU 자원의 비효율성과 I/O 요청 Thread가 Blocking 되는 문제점을 해결할 수 있다.
ByteBuffer는 말 그대로 내부에 byte[] 배열로 구성되면서 버퍼형태의 네 가지 포인터를 가진 클래스다.
하지만 이는 조금 틀린 부분이 있어 보인다.
먼저, user space에서는 Kernel 버퍼에 직접 접근하지 못하며,
ByteBuffer는 HeapByteBuffer와 DirectByteBuffer로 나눠져 있다.
ByteBuffer를 사용해도, Polling 방식으로는 하나의 스레드가 모든 소켓들을 돌며 작업이 있을 시 하나하나 작업을 해야해서 여전히 필요 이상의 Resourece를 소모한다는 단점이 있다.
이러한 단점을 해결하기 위해 나온것이 Event 주도방식을 활용하는 방식이다.
자바에서는 이 event driven 방식을 사용하기 위해 Selector라는 클래스를 만들었으며, 이 Selector는 Host OS에 맞게 epoll, kqueue, IOCP 등의 최적의 Multiplexing I/O 기법을 선택해 사용하게 된다.
Selector는 OS가 제공하는 I/O Multiplexing 기능을 활용하여
하나의 스레드로 여러 개의 Non-blocking Channel을 동시에 감시할 수 있도록 한다.
Selector는 Non-blocking Channel들을 등록해두고,
select()(또는 관련 메서드)를 호출해 OS로부터 각 Channel의 I/O 준비 여부를 감지한다.
클라이언트의 연결 요청이 오거나 읽기/쓰기 작업이 가능해지면
Selector는 준비된(Channel이 ready 상태인) SelectionKey들을 반환한다.
애플리케이션의 스레드는 이 SelectionKey들을 기반으로
해당 Channel에서 어떤 작업을 수행할지 결정하고,
직접 비즈니스 로직을 처리하거나 Worker Thread에 작업을 위임한다.
그럼 이제는 Multiplexing I/O기반 읽기, 쓰기가 실제로 어떻게 동작하는지 알아볼 것이다.
Multiplexing 기반 I/O는 단일 스레드가 여러 소켓을 동시에 처리할 수 있게 하는 구조로, Java NIO에서는 Selector + Non-blocking Channel 조합으로 구현된다. 아래는 클라이언트 연결부터 실제 데이터 읽기까지의 과정을 단계적으로 정리한 것이다.
클라이언트가 서버로 연결을 시도하면 운영체제(OS)는 해당 ServerSocketChannel이 새로운 연결을 받을 준비가 되었음을 감지하고, 이 채널을 ACCEPT-ready 상태로 표시한다.
Selector는 다음 select() 호출에서 이 상태를 반영하여 OP_ACCEPT 이벤트가 설정된 SelectionKey를 반환한다.
애플리케이션 스레드는 selectedKeys()를 순회하면서 OP_ACCEPT 키를 확인하고, accept() 메서드를 호출하여 새 연결을 처리한다.
이때 JVM 내부에서는 새로운 SocketChannel 객체가 생성되며,
스레드는 이 채널을 non-blocking 모드로 설정한 뒤 이후 읽기 작업에 사용할 ByteBuffer를 직접 생성하거나 재사용하여 준비해 둔다.
클라이언트가 소켓에 write()를 호출하면 데이터는 네트워크를 통해 서버로 전송되고, 서버 운영체제는 이 데이터를 우선 커널의 receive buffer에 저장한다.
이 시점에서 OS는 해당 SocketChannel을 READ-ready 상태로 표시하며, 이 readiness 정보는 Linux의 epoll, BSD/MacOS의 kqueue, Windows의 IOCP 같은 OS 멀티플렉서가 관리한다.
Selector가 다시 select()를 호출하면 OS로부터 “현재 읽을 수 있는 데이터가 있다”는 정보가 전달되고, Selector는 OP_READ 이벤트가 설정된 SelectionKey를 selectedKeys() 집합에 추가하게 된다.
애플리케이션 스레드는 selectedKeys()를 순회하면서 key.isReadable()로 해당 채널이 읽기 가능한 상태인지 판단하고, true일 경우 실제 읽기 로직을 수행한다.
Java NIO의 SocketChannel은 내부에 데이터를 저장하는 고정된 버퍼를 가지고 있지 않으므로, 데이터를 담을 ByteBuffer를 개발자가 직접 제공해야 한다.
다음과 같은 코드가 일반적이다:
SocketChannel channel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = channel.read(buffer);
channel.read(buffer)를 호출하면 다음과 같은 과정이 내부에서 수행된다.
JVM이 OS에게 read 시스템 콜을 요청한다.
OS는 커널 버퍼에서 JVM 네이티브 메모리로 데이터를 복사한다.
JVM은 네이티브 메모리에서 ByteBuffer가 가리키는 메모리로 다시 데이터를 복사한다.
ByteBuffer가 HeapByteBuffer이면 JVM heap으로 복사, ByteBuffer가 DirectByteBuffer이면 네이티브 메모리로 직접 복사한다.
이 과정이 끝나면 ByteBuffer의 position, limit 값이 갱신되어 애플리케이션이 읽어갈 준비가 완료된다.
위와 같은 복사 단계를 거친 후에야 비로소 애플리케이션 스레드가
Heap Memory 또는 DirectByteBuffer의 네이티브 메모리에서 데이터를 읽어 처리할 수 있게 된다.
쓰기 작업은 읽기 작업과 기본 흐름은 동일하며, 단지 데이터 흐름의 방향이 반대라는 점과 readiness 조건이 다르다는 점만 제외하면 구조적으로 매우 유사하다.