내 프로젝트를 WebFlux 로 Async-Non Blocking 하게 만들어보자!

WebFlux 는 Reactive Programming 을 지원하는 프레임워크로, 디폴트로 Netty 라는 NIO 클라이언트 서버 프레임워크를 사용합니다.

Webflux 적용하기 첫 번째 포스팅으로 Java I/O 와 NIO 를 알아보도록 하겠습니다.

 


 

느린 Java 의 I/O

자바에 New I/O 가 추가 된 이유는 무엇일까요? 그 이유는 기존 I/O 의 속도가 느리기 때문입니다.

Java 의 I/O 가 어떤 이유로 느린지, 그리고 NIO 는 어떤 점을 개선했는지 알아봅시다.

 

Java I/O 의 동작 원리(파일 읽기)

 

[하드디스크 -> 프로세스 내부의 메모리 영역] 논리적 다이어그램

 

  1. 프로세스는 커널에 read() 시스템 콜을 통해 버퍼를 채우도록 요청합니다.
  2. read() 가 호출되면 커널은 디스크에서 데이터를 가져오도록 디스크 컨트롤러에 명령을 내립니다.
  3. 디스크 컨트롤러는 메인 CPU 의 지원 없이 DMA(Direct Memory Access) 를 통해 커널의 버퍼에 직접 데이터를 기록합니다.
  4. 디스크 컨트롤러가 버퍼를 채우면 커널은 커널의 임시 버퍼에서 프로세스가 지정한 버퍼로 데이터를 복사합니다.

 

⤹ 간략한 OS 개념 정리

더보기

OS 는 크게 User mode, Kernel mode 두 가지로 구분해서 프로세스를 동작시킵니다.

간략하게 설명하면 Kernel 은 OS 의 핵심적인 역할을 하기 때문에 User 레벨에서 직접 접근할 수 없습니다.

 

그렇다면 애플리케이션(프로세스)에서 하드웨어에 접근해서 데이터를 읽고 싶을 때는 어떻게 해야할까요?

이 때 System call 이 사용됩니다. 시스템 콜은 사용자 레벨 프로세스가 OS와 상호작용하는 방법으로, 인터페이스를 통해 애플리케이션에게 OS 의 서비스를 요청할 수 있도록 합니다.

 

read() 시스템 콜을 통해 커널에게 I/O 수행을 요청하면 User mode 에서 Kernal mode 로 스위칭이 발생합니다.

 

💥 JVM 버퍼 복사로 인한 CPU 오버헤드

 

C언어에서는 포인터를 통해 메모리에 직접 접근할 수 있기 때문에 시스템 콜을 직접 사용할 수 있습니다.

C 프로세스 ⇨ 시스템 콜 ⇨ 커널 ⇨ 디스크 컨트롤러 ⇨ 데이터 복사

 

하지만 Java 는 메모리에 직접 접근할 수 없기 때문에 커널 버퍼에서 JVM 버퍼로 데이터를 복사하는 추가적인 과정이 발생합니다.

JVM ⇨ 시스템 콜 ⇨ 커널 ⇨ 디스크 컨트롤러 ⇨ 커널 버퍼 복사 ⇨ JVM 버퍼 복사

 

디스크 컨트롤러는 DMA 를 통해 CPU 연산 없이 데이터를 복사합니다.

그러나 JVM 내부의 버퍼로 복사할 때는 CPU 연산이 필요하기 때문에 속도가 느려지는 원인이 됩니다. 

 

 

💥 Blocking 방식의 I/O

 

 

read() 시스템 콜을 통해 커널에게 I/O 수행을 요청하면 User mode 에서 Kernel mode 로 스위칭이 발생합니다.

디스크에서부터 JVM 내 메모리 영역에 복사하는 과정을 거친 후에 User mode 로 제어권을 넘기기 때문에, 데이터를 읽는 동안에 Blocking 이 발생하여 속도가 느려지는 원인이 됩니다.

 

이렇게 하나의 스레드에 하나의 클라이언트가 붙는 Blocking 방식은 다음과 같은 단점이 있습니다.

  1. 여러 스레드가 입력이나 출력 데이터가 들어오기를 기다리며 무한정 대기 상태로 유지 될 수 있습니다.
  2. 각 스레드가 스택 메모리를 할당해야하기 때문에 많은 메모리를 차지합니다.
  3. JVM 은 동시 접속이 한계에 이르기 훨씬 전부터(약 1만개만 되어도) 컨텍스트 스위칭에 따른 오버헤드가 문제가 될 수 있습니다.

 

Java NIO

Java NIO 는 표준 Java I/O 및 Java Networking API 의 대안이 될 수 있는 API 입니다.

기존 I/O 와 다른 방식의 프로그래밍 모델을 제공하고, Blocking 또는 Non-Blocking 방식 모두 사용할 수 있습니다.

 

NIO 는 내용이 매우 매우 많기 때문에, 중요한 내용을 위주로 기재하도록 하겠습니다.

 

NIO Components

Java NIO 의 주요한 컴포넌트에는 채널, 버퍼, 셀렉터가 있습니다.

주요 컴포넌트에 대해서 먼저 간략하게 알아보고, 동작 방식을 통해 각 컴포넌트들이 어떻게 쓰이는지 이해해보도록 하겠습니다.

 

NIO Channels

단방향 데이터 전송 방식인 스트림과 다르게 채널은 양방향 데이터 전송 기능을 제공합니다.

채널은 데이터를 버퍼에서 읽고, 버퍼는 데이터를 채널에 씁니다.

 

 

채널의 타입은 다음과 같습니다.

  • FileChannel - 파일에서 데이터 읽기
  • DatagramChannel - UDP 를 통해 네트워크에서 데이터 읽고 쓰기
  • SocketChannel - TCP 를 통해 네트워크에서 데이터 읽고 쓰기
  • ServerSocketChannel - 웹 서버와 같이 들어오는 TCP 연결을 수신, 들어오는 각 연결에 대해 SocketChannel 생성

 

채널은 네이티브 코드를 사용해서 작업을 수행하기 때문에 I/O 서비스에 직접 액세스할 수 있습니다.

I/O 매커니즘에 액세스하기 위해 Java NIO 에서 제공하는 게이트웨이 역할을 합니다.

 

채널은 데이터를 읽고 쓰는데 Scatter/gather (분산/수집) 방식을 사용합니다.

Scattering 이란, 둘 이상의 버퍼에서 단일 채널로 데이터를 쓰는 쓰기 작업을 의미합니다. 여러 버퍼를 하나의 채널로 수집하려고 할 때, 매버퍼마다 비용이 비싼 시스템 콜을 호출하지 않고 프로세스에서 사용하는 버퍼의 목록을 한 번에 넘겨주는 방식입니다.

 

NIO Buffers

NIO 버퍼는 NIO 채널과 상호 작용할 때 사용됩니다. 앞서 말한 것처럼 데이터는 채널에서 버퍼로 읽혀지고, 버퍼에서 채널로 쓰여집니다.

버퍼는 다음과 같은 유형을 제공합니다.

 

버퍼는 사용하는 메모리의 위치에 따라서 Non Direct Buffer 와 Direct Buffer 로 나뉩니다.

 

채널을 통해 다이렉트 버퍼(e.g. ByteBuffer)를 사용하면 OS 의 메모리에 직접 접근(하는 듯)하여 다음과 같이 파일을 읽을 수 있게 됩니다.

JVM ⇨ 시스템 콜 ⇨ 커널 ⇨ 디스크 컨트롤러 ⇨ DMA 복사

디스크 컨트롤러가 DMA 를 통해 직접 운영체제 메모리에 접근하고, 응용 프로그램은 다이렉트 버퍼를 사용해서 운영체제 메모리에 직접 접근합니다. 이 때 논 다이렉트 버퍼를 사용하게 되면 채널의 대상이 되지 않아 기존 I/O 와 같이 데이터 복사의 단계를 한 번 더 거치게 됩니다.

 

버퍼를 사용하는 간단한 예제는 다음과 같습니다.

RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
FileChannel inChannel = aFile.getChannel();

//create buffer with capacity of 48 bytes
ByteBuffer buf = ByteBuffer.allocate(48);

int bytesRead = inChannel.read(buf); //read into buffer.
while (bytesRead != -1) {

  buf.flip();  //make buffer ready for read

  while(buf.hasRemaining()){
      System.out.print((char) buf.get()); // read 1 byte at a time
  }

  buf.clear(); //make buffer ready for writing
  bytesRead = inChannel.read(buf);
}
aFile.close();

 

NIO Selector

셀렉터는 단일 스레드를 사용해서 여러 채널을 처리하는 데 사용됩니다. 

하나의 스레드로 여러 I/O 를 처리할 수 있기 때문에, 이전에 설명한 블로킹 방식의 단점들이 해결됩니다.

 

 

Non Blocking 구현의 핵심으로, 등록 된 채널들이 발생시킨 이벤트에 대해서 요청을 매핑하는 컨트롤러 역할을 합니다.

 

 

Selector 생성 및 채널 등록

채널을 셀렉터와 사용하기 위해서는 채널을 셀렉터에 등록해야합니다. 소스코드로 보면 다음과 같습니다.

 

// Creating a Selector
Selector selector = Selector.open();

// Registering Channels with the Selector
channel.configureBlocking(false);

SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

 

 

SelectionKey

셀렉터에 채널을 등록할 때마다 셀렉션 키가 생성됩니다. 셀렉션 키는 셀렉터를 통해 모니터링되는 채널에서 수신하려는 이벤트를 의미하는 키입니다. 셀렉션 키를 기준으로 채널들이 발생시킨 이벤트에 대해 적절한 핸들러로 요청을 분기시킬 수 있습니다.

 

수신할 수 있는 이벤트는 4가지가 있으며, 각각은 SelectionKey 클래스의 상수로 표시됩니다.

이벤트를 발생시키는 채널은 해당 이벤트에 대해 ready 되었다고 표현합니다.

  1. SelectionKey.OP_CONNECT: 클라이언트가 서버에 연결을 시도할 때
  2. SelectionKey.OP_ACCEPT: 서버가 클라이언트의 연결을 수락할 때
  3. SelectionKey.OP_READ: 데이터를 read 할 ready 된 채널
  4. SelectionKey.OP_WRITE: 데이터를 write 할 ready 된 채널

 

ready 된 채널에 다음과 같이 액세스 할 수 있습니다. 전체 코드는 여기를 참고 해주세요.

// Server 에서 사용 예시
while (true) {
    selector.select();
    Set<SelectionKey> selectedKeys = selector.selectedKeys();
    Iterator<SelectionKey> iter = selectedKeys.iterator();
    while (iter.hasNext()) {

        SelectionKey key = iter.next();

        if (key.isAcceptable()) {
            register(selector, serverSocket);
        }

        if (key.isReadable()) {
            answerWithEcho(buffer, key);
        }
        iter.remove();
    }
}

 

NIO Server Architecture

NIO 서버가 어떻게 데이터를 주고받는지 동작 순서를 알아보겠습니다.

 

 

  1. ready 된 채널들이 셀렉터에 등록됩니다.
  2. 셀렉터는 단일 스레드지만 동시에 여러 이벤트를 처리할 수 있는 멀티 스레드처럼 동작합니다.
  3. 클라이언트에게 요청이 오면 셀렉터는 셀렉션 키를 보고 적절한 채널을 찾아 매핑합니다.
  4. 채널은 버퍼를 통해 클라이언트가 요청한 이벤트를 수행합니다.
  5. 새로운 커넥션이 생성되면 셀렉터에 등록하고, 동일한 처리를 반복합니다.

 

NIO Virtual Memory

Java I/O 가 느린 이유 중 하나는 메모리에 직접 접근할 수 없어서 JVM 내부에 데이터를 한번 더 복사해야한다는 것입니다.

그 점을 NIO 에서는 가상 메모리를 통해 개선했습니다. 가상 메모리를 사용해서 커널 버퍼와 프로세스의 버퍼가 같은 물리적 메모리 공간을 바라보게하면, 마치 프로세스 내의 버퍼가 커널 버퍼에 직접 접근하는 것처럼 동작이 가능합니다.

 

 

 

마치며

Netty 에 대해 알아보기 전에 기존 I/O 의 문제점과 NIO 를 알아보았습니다.

NIO 는 다소 복잡하지만 가상 메모리를 사용함으로써 컨텍스트 스위칭 비용이 적고 Non Blocking 방식이 가능하다는 장점이 있습니다.

하지만, 커넥션되는 클라이언트 수가 적고 대용량의 데이터를 전송하는 경우에는 기존 I/O 방식이 더 유리할 수 있기 때문에 용도에 맞게 선택해야합니다.

다음 포스팅은 Netty 에 대해서 알아보도록 하겠습니다.

 

Reference

 

 

 

복사했습니다!