본문 바로가기
Old Posts/Java

Java NIO - Channel, Buffer 그리고 Selector

by A6K 2020. 12. 29.

자바는 Java 4 부터 새로운 입출력 API를 지원하기 시작했다. NIO라는 이름으로 불리는 API는 New IO 혹은 Non-blocking IO라는 뜻을 가지고 있으며 java.nio 패키지로 포함되었다. 이후 Java 7으로 버전을 올리면서 Java IO와 Java NIO 사이의 일관성 없는 클래스 설계를 바로잡고, 비동기 채널 등의 네트워크 지원을 대폭 강화한 NIO 2 API가 추가되었다.

Java NIO는 3가지 컴포넌트로 구성되어 있다.

  • Channel
  • Buffers
  • Selectors

실제로는 이것보다 더 많은 컴포넌트들이 있지만 가장 핵심적으로는 저 3가지를 꼽을 수 있다.

채널(Channel)

Java NIO의 모든 IO는 '채널(Channel)'에서부터 시작한다. 채널은 스트림과 유사하다. 파일에서 데이터를 읽을 수 있는 '파일채널(FileChannel)'이 있고, 네트워크를 통해 데이터를 주고 받을 수 있는 '소켓채널(SocketChannel)'도 있다.

Java NIO를 이용해 프로그래밍을 할 때 사용하는 대표적인 채널들은 다음과 같다.

  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel

이름에서 알 수 있듯이 파일 입출력을 위한 채널이거나 UDP 네트워크, TCP 네트워크를 위한 소켓 혹은 TCP 네트워크에서 서버 소켓을 위한 채널이 있다. 이 밖에도 다양한 채널들이 존재할 수 있다. 자세한 것은 JavaDoc을 참고하길...

Java에서는 입출력을 위해 '스트림(Stream)'이라는 개념이 있다. 스트림(Stream)과 채널(Channel)은 매우 유사하다. 하지만 몇 가지에 특성에서 차이점이 있다.

  • 스트림은 One-way이지만 채널은 양방향이다
  • 채널은 비동기적으로 읽거나 쓸 수 있다
  • 채널은 항상 버퍼로 데이터를 읽어들이거나 버퍼의 데이터를 쓰게 된다

우선 Java IO에서 제공되는 스트림은 입력 스트림과 출력 스트림이 구분되어 있다. 데이터를 읽기 위해서는 입력 스트림을 생성해야하고 데이터를 쓰기 위해서는 출력 스트림을 생성해야한다. 반면 Java NIO에서 제공되는 채널은 양방향 입출력이 가능하다. 하나의 채널에 읽기와 쓰기가 모두 가능하다는 의미다.

채널은 비동기적으로 데이터를 읽거나 쓸 수 있다. 뿐만 아니라 입출력 수행시 블로킹(Blocking)과 넌블로킹(Non-blocking) 특징을 모두 지원한다.

마지막으로 채널은 항상 버퍼라는 NIO 컴포넌트와 함께 사용되며, 채널에서 데이터를 버퍼로 읽어 들이거나 버퍼에 있는 데이터를 채널로 쓰게 된다.

파일로 입출력을 하는 채널(Channel)과 버퍼(Buffer)

버퍼(Buffers)

Java NIO에서 제공되는 API를 이용해 데이터를 채널로 전송하거나 채널에서 데이터를 읽어오기 위해서는 '버퍼(Buffer)'라는 컴포넌트를 이용해야 한다. 채널 객체에서 제공하는 read(), write() 메소드는 항상 버퍼를 사용한다.

Java NIO에서 사용할 수 있는 주요 버퍼는 다음과 같다.

  • ByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

입출력에 사용될 데이터의 타입에 따라 사용할 버퍼들이 구현되어 있다.

이와 별도로 Java NIO는 MappedByteBuffer라는 것도 만들어 놓았다. MappedByteBuffer는 Memory Mapped File과 연계해서 사용할 수 있는 버퍼를 의미한다. mmap에 대한 내용은 나중에 시간나면 정리하겠다.

간단한 채널과 버퍼를 이용한 입출력 예제를 살펴보자.

// open file 'testFile.txt' for read
RandomAccessFile file = new RandomAccessFile("testFile.txt", "r");

// get file channel
FileChannel channel = file.getChannel();

// allocate buffer
ByteBuffer buffer = ByteBuffer.allocate(64);

// read some data
int bytesRead = channel.read(buffer);
while (bytesRead != -1) {

	buffer.flip();

	while(buffer.hasRemaining()){
		System.out.print((char) buffer.get());
	}

	buffer.clear();
	bytesRead = channel.read(buffer);
}
file.close();

'testFile.txt'라는 파일로부터 데이터를 읽기 위한 예제 코드다. 우선 RandomAccessFile 객체를 만든다. 파일 이름과 읽기 모드로 열겠다는 파일 모드를 입력한다. 만들어진 RandomAccessFile 객체로부터 FileChannel을 얻어온다.

ByteBuffer.allocate(64)를 통해 64바이트 바이트 데이터를 다룰 수 있는 버퍼를 할당받는다. 그리고 read(), flip(), hasRemaining(), get(), clear() 등의 메소드를 이용해서 데이터를 읽고 처리한다. 자세한 내용은 NIO 버퍼에 대한 포스트에서 다루겠다.

셀렉터(Selectors)

Java NIO를 구성하는 3가지 핵심 컴포넌트 중 마지막은 '셀렉터(Selector)'다. selector는 하나의 스레드가 여러개의 채널을 동시에 다룰 수 있도록 해주는 컴포넌트다. 

애플리케이션을 실행하는 스레드는 원래 한번에 하나의 채널을 다룰 수 있다. 하나의 스레드가 3개의 채널에 대해서 입출력을 실행해야하는 경우를 생각해보자. 예를 들어 서버가 3개의 클라이언트 연결을 받아서 SocketChannel을 만들어 두고 새로운 요청을 기다린다고 해보자. 3개의 채널에서 넘어오는 데이터는 독립적으로 데이터를 전송한다. 따라서 스레드는 3개의 채널 중 어떤 것이 먼저 데이터를 보낼지 알 수 없다.

가장 쉬운 방법은 3개중 하나의 채널에서 데이터가 올 때까지 기다리는 것이다. 이 경우 데이터를 기다리는 채널말고 다른 채널에서 먼저 데이터가 들어온 경우, 다른 채널에서 들어온 요청을 처리할 수 없다. 데이터를 기다리는 그 채널에서 데이터가 들어올 때까지 블러킹되기 때문이다. 나중에 기다리는 채널에서 데이터가 들어오고, 그 채널의 데이터를 처리한 다음에야 다른 채널의 데이터가 처리된다.

다른 방법으로는 각 채널에 데이터가 도착했는지 주기적으로 체크하는 방법이다. 1번 채널에 데이터가 왔는지 체크, 2번 채널 체크, 3번 채널 체크, 다시 1번 채널 체크.... 이런식으로 각 채널을 확인하는 방법을 '폴링(Polling)'이라고 한다. 폴링은 불필요하게 채널들을 확인하는 단점이 있다.

가장 효과적인 방법은 '3개 중에 읽을 데이터가 있으면 알려줘...'라고 말하고 기다리는 것이다. 이 때 사용할 수 있는 NIO 컴포넌트가 '셀렉터(Selector)'다. 좀 더 전문적인 용어로 말하자면, 입출력을 멀티플렉싱(Multiplexing)한다고 한다. POSIX 시스템 호출 중, select 라는 시스템 콜이 있는데, 이와 같은 역할을 하는 NIO 컴포넌트다.

Selector에 대한 자세한 내용은 별도의 포스트를 할당해서 설명하겠다.

댓글