[JAVA] Stream I/O vs Channel I/O

2022. 5. 1. 23:26[ 백엔드 개발 ]/[ Java,Kotlin ]

이 글에서 말하는 Stream I/O는 일반적으로 자바에서 말하는, 필더링, 리덕션 등의 고차 함수를 이용하는 Stream과 다르다. I/O라는 용어가 붙었기 때문에 file, device, socket I/O 등 운영체제의 도움을 받아 처리되는 데이터의 입출력을 말하는 것이다.

 

 

Stream I/O

 

 

 

추상적으로 Stream은 '데이터의 흐름'이다. 좀 더 이해를 쉽게 하자면 '데이터가 이동하는 통로'라고 생각해도 좋다. 자바에서 Stream(java.io)은 단방향 통신만을 지원하기 때문에 InputStream과 OutputStream을 따로 둘 수밖에 없었다. 또한 Stream은 Blocking I/O 만을 지원하기 때문에 모든 I/O 작업이 완료되기 전까지 자바 프로세스가 다른 작업을 수행할 수 없다.

 

Stream I/O가 Blocking I/O 만을 지원하는 이유를 생각해봤다. 아래에서 설명하겠지만 Stream I/O는 Buffer를 사용하지 않기 때문에 입출력 데이터가 흘러가는 데로 바로 받아야 할 것(앞에서 또는 뒤에서 읽기 불가능)이다. 따라서 자바 프로세스가 데이터를 받기 위해 Block 될 수밖에 없을 것이다. 문득 이 글을 쓰면서 BufferedReader를 사용해서 데이터 입력을 받는 것이 System.out/in보다 빠르지 않을까라는 생각이 들었다.

 

 

cf. BufferedReader/BufferedWriter와 System.out/System.in(Scanner)의 차이점

- 버퍼에 임시 저장 후 전달이 되느냐, 데이터의 I/O가 발생하는 즉시 전달이 되느냐의 차이이다. 전자의 경우 매번 데이터를 보내는 것이 아니라 한 번에 모아서 보내기 때문에 후자보다 성능상 이점이 있다고 볼 수 있다. 반대로 후자의 경우 개행 라인이 발생할 때를 기준으로 I/O가 발생한다. 참고로 3가지 모두 java.io에 속하기 때문에 버퍼를 사용하더라도 JVM상에 올라간다.

 

 

 

 

 

Channel I/O

Channel I/O 역시 데이터가 흐르는 통로라는 점에서 Stream I/O와 같지만 차이점이 다소 존재한다. Channel I/O는 Stream I/O와 달리 bidirectional(양방향)으로 동작한다. 즉, 데이터의 방향을 신경 쓰지 않으며 이 말은 Input과 Output을 구분하지 않는다는 말이다. 

 

Channel은 Non-Blocking I/O를 구현하기 위해 항상 Buffer와 함께 사용된다. Channel을 통해 데이터를 쓰거나 읽기 전에 반드시 버퍼에 데이터를 쓰거나 읽어야 함을 의미한다.

 

ByteBuffer buf2 = ByteBuffer.allocateDirect(1024);

SocketChannel channel = SocketChannel.open();
int socket = channel.read(buf2); 
channel.write(buf2); // 버퍼를 통해 채널에 쓸 수 있다.

 

위와 같이 채널을 사용하기 위해 버퍼를 반드시 연결해줘야 한다.

 

 

 

 

위 그림은 File I/O를 예시로 Channel I/O와 Buffer를 적용한 그림이다. Channel I/O는 Stream I/O와 다르게 Input/Output을 따로 두지 않고 하나의 Channel로 해결된다. 이렇듯 Channel을 입력/출력용으로 따로 두지 않고 하나로 둘 수 있는 이유는 Buffer를 양방향(bidirectional)으로 동작시킬 수 있기 때문인데, flip() 메소드를 통해 버퍼에서의 커서 위치를 '맨 앞' 또는 '맨 뒤'로 변경시켜(buf2.flip()) input 모드 및 output 모드로 동작시킬 수 있다.

 

 

[java Channel Class Diagram]

java Channel Class Diagram

 

위 그림은 Channel Interface의 상속 구조를 보여준다. Stream I/O와 다르게 Channel I/O는 Non-Blocking I/O와 Blocking I/O 모두 가능하다. 특히, 아래와 같이SelectableChannel을 살펴본 결과 Blocking I/O와 NonBlocking I/O를 모두 지원하고 있음을 알 수 있다. (SelectableChannel에는 isBlocking()이라는 메소드도 가지고 있다!)

 

https://docs.oracle.com/javase/8/docs/api/java/nio/channels/SelectableChannel.html

 

하지만 FileChannel은 대부분이 Blocking I/O 방식으로 동작한다. File에 대해서도 Non-Blocking I/O로 데이터를 읽고 쓰고 싶다면 자바 NIO2의 AsynchronousFileChannel을 이용해야 한다.

 

 

cf. Selector

Selector는 자바 NIO의 Non-Blocking I/O를 위한 것이다. Socket I/O를 예시로 들자면 여러 개의 Socket에 대한 I/O 이벤트 알림을 받기 위해 Multiplexing을 사용한다.(사실 Input 경우에만 사용) Selector도 마찬가지이다. Selector를 사용하면 아래와 같이 단일 쓰레드가 여러 개의 Channel에 대해 Input을 모니터링한다.

 

https://velog.io/@jeb1225/Java-NIO

 

결국 이 Selector의 핵심은 자바 NIO를 기반으로 하여 여러개의 채널에 대해 Non-Blocking I/O API를 활용하고, 따라서 각 쓰레드가 각 채널을 담당하는 구조가 아니라, 하나의 Selector라는 존재가 다수의 채널을 담당(Multiplexing)할 수 있게 되는 구조가 된 것이다. 위 Selector 모델을 Blocking I/O를 기반으로 한다고 가정해보면, 첫 번째 채널에 대해 I/O를 수행한 뒤, 두 번째 채널에 대해 I/O를 수행할 수 밖에 없다. 그래서 고전적인 Blocking I/O를 기반으로 하는 WAS는 커넥션 당 쓰레드 모델을 가질 수 밖에 없게 된 것이다.

 

아래 그림은 Spring WebFlux에서 사용하는 WAS인 Netty의 구조이다. 클라이언트와 TCP 연결마다 SocketChannel을 만들고 Selector에 등록한다. 이과정에서 Selector는 단일 쓰레드, Non-Blocking I/O 기반으로 동작하는데 이는 Multiplexing과 매우 유사하다. 이후, Channel은 Buffer에 데이터를 write 한다. Buffer는 해당 이벤트를 notify 한 뒤 비즈니스 로직이 처리된다. 이때 Buffer는 Direct Buffer이며 커널 영역의 버퍼를 바로 사용하게 되어 성능상 이점이 있다. 뿐만 아니라 Buffer가 양방향으로 도착하기 때문에 응답 메시지 또한 같은 Buffer를 통한다.

 

 

 

 


 

Channel I/O에서 Buffer를 default로 채택한 이유에 대해 생각해봤다. 그리고 Buffer를 사용했을 때 Non-Blocking I/O의 구현과 성능적 개선점이 있다는 것을 찾을 수 있었다.

 

cf. Buffer를 사용했을 때 장점과 단점

- Buffer를 사용하면 Buffer 크기만큼 입출력 데이터를 받은 후 처리가 가능하다. 즉, Buffer에 데이터를 두었다가 나중에 처리가 가능하기 때문에 Buffer를 사용하면 Non-Blocking I/O를 구현할 수 있다. 또한 Buffer를 사용한다는 것은 입출력 데이터를 캐싱하는 것이기 때문에 데이터에 대한 cursor 연산을 할 수 있다. 이로써 하나의 버퍼를 입력용으로 사용할 수도 있고 출력용으로 사용할 수도 있는 것이다. 하지만 Buffer 크기만큼의 메모리가 필요하다는 것은 단점이 될 수 있다.
- Buffer를 사용하지 않으면 적은 메모리를 사용하고도 입출력 데이터를 처리할 순 있지만 I/O 이벤트가 그만큼 자주 발생한다는 것은 성능상 단점이 될 수 있다.

 

 

즉, Channel I/O는 메모리 사용률보다 성능적 측면을 선택했다고 볼 수 있다.

 

 


 

자바 NIO 등장

자바 NIO의 등장은 기존 I/O를 개선하고자 등장한 것이다. I/O 작업 특성상 유저 프로세스가 단독적으로 수행할 수 없으며 CPU가 커널 모드로 스위칭된 후에야 가능하다.(시스템 콜 필요) 유저 프로세스는 시스템 콜을 통해 I/O 데이터를 메모리로부터 읽을 수 있는데, 자바 특성상 JVM이라는 가상 머신에서 동작하기 때문에 I/O 작업을 위해 JVM상의 메모리로 데이터들을 옮겨야 하는 문제가 있다. 이렇듯 자바와 같이 가상머신 위에서 동작하는 프로세스는 다음과 같이 I/O 작업 시 문제점을 갖는다.

 

[Stream I/O기반의 java.io의 문제점]

1) 메모리의 버퍼에서 JVM의 버퍼로 데이터를 이동하는데 추가적인 CPU 연산 사용

2) 옮긴 JVM의 버퍼는 GC 대상에 포함되기 때문에 Stop-The-World로 인한 성능 저하

3) JVM의 버퍼로 데이터를 이동하는 중에 쓰레드는 block 됨(java.io는 모두 Blocking I/O임)

 

최근 GC성능은 많이 개선되어 자바의 고질적인 문제라고 보긴 어렵다. 자바 NIO의 등장은 1), 3) 문제를 다음과 같이 개선했다.

 

1) JVM상의 버퍼로 I/O 데이터를 옮기지 않고도 자바 프로세스가 메모리(버퍼)에서 데이터를 읽을 수 있도록 하는 라이브러리 지원** (DMA와 유사한 기능 지원)

2) Channel과 Buffer를 default로 채택함으로써 Blocking I/O와 더불어 Non-Blocking I/O 지원

 

 

cf. DMA(Direct Memory Access)란

CPU를 통하지 않고 주변기기의 인터페이스 장치에 '제어권을 주어 직접 메모리에 접근해 데이터를 읽고 쓸 수 있게' 하는 방법이다. NIO에서 direct buffer를 지원함으로써 자바 프로세스가 JVM 상의 메모리가 아닌 OS 상의 메모리(버퍼)에 접근해 I/O 데이터를 가져올 수 있게 되었다.

 

cf. Direct Buffer에 대한 자세한 내용: ZeroCopy

좀 더 자세한 내용은 'ZeroCopy'를 참고하자. ZeroCopy는 CPU가 User Mode로 모드 스위칭을 하지 않고(유저 레벨의 메모리로 데이터를 복사하지 않음) 바로 데이터를 다른 위치로 넘기는 방식이다. 따라서 CPU의 Copy연산을 줄일 수 있고 컨텍스트 스위칭의 횟수를 줄일 수 있다는 장점이 있다. 

 

 

[Direct 버퍼 예시]

ByteBuffer buf1 = ByteBuffer.allocate(1024); 

ByteBuffer buf2 = ByteBuffer.allocateDirect(1024);

 

buf1은 JVM의 메모리 상에 버퍼를 생성하고 I/O 결과를 OS의 버퍼에서 그대로 복사해오는 과정을 거친다. 하지만 buf2는 JVM상에 버퍼를 따로 두지 않으며 OS가 만들어둔 버퍼에 접근해 데이터를 가져올 수 있도록 한다.

 

 

Stream 기반 I/O와 Channel 기반 I/O 선택

Channel I/O와 Stream I/O는 좋고 나쁨의 문제가 아니라고 생각한다. I/O 특성에 따라 Stream I/O, Channel NIO 등을 맞게 사용하는 것이 중요하다. 한 가지 예를 들자면 NIO의 ByteBuffer에서 direct 버퍼의 할당과 해제에 필요한 오버헤드가 non-direct 버퍼보다 크기 때문에 입출력할 데이터가 별로 크지 않거나 버퍼를 자주 사용하고 해제해야 하는 경우라면 non-direct 버퍼(기존의 Stream 기반 java.io)를 사용하는 것이 더 좋을 수도 있다.

 

또한 버퍼의 크기는 한정되어 있기 때문에 대용량 데이터를 다루는 경우라면, 버퍼를 사용하는 것보다 바로바로 데이터를 운송하는 Stream I/O를 사용하는 것이 좋다. 

 

 

 

 

 

 

정리

- 자바에서의 I/O는 Stream I/O와 Channel I/O 두 가지로 처리된다.

- Stream I/O는 Blocking I/O만을 지원하기 때문에 FD하나 당 쓰레드 1개가 할당되어야 했다. NIO에서는 Non-Blocking I/O를 지원한다.

- Channel I/O에서는 Buffer를 사용해서 데이터를 읽고 쓸 수 있다.(Stream I/O에서도 Buffer를 사용할 순 있음)

- Buffer를 사용하는 이유는 Buffer의 크기만큼 데이터를 '모았두었다'가 처리가 가능한 순간에만 입출력 이벤트를 발생시키기 위함이다. 즉, Non-Blocking 기반으로 해결하기 위한 수단이다.

- 자바의 NIO(New I/O)에서는 default로 Channel을 사용하는데 Channel을 기본으로 사용하는 이유는 Direct Buffer를 도입할 수 있기 때문이다.

- 기존 IO(Old IO, OIO)에서는 JVM상의 힙 메모리에 데이터를 복사해서 사용했어야 했지만(Non-Direct Buffer), Direct Buffer를 도입함으로써 가상머신 기반 프로세스에서 존재하는 I/O 성능 이슈를 어느 정도 개선할 수 있었다.(데이터 copy 연산 축소)

- Selector를 통해 여러 개의 Channel 중에서 준비 완료된 Channel을 선택하는 방법을 제공 함으로써 Non-Blocking I/O를 구현할 수 있다. (Input의 경우에만 사용)

- Stream I/O와 Channel I/O는 (OIO와 NIO) 좋고 나쁨의 문제가 아니라 상황에 맞게 사용해야 하는 기술이다. 

 

 

IO NIO
Blocking I/O Blocking I/O + Non-Blocking I/O
Stream 기반 Channel + Buffer(Cache) 기반
  multiplexing(selector), Direct Buffer 지원

 

 

Reference

- https://examples.javacodegeeks.com/core-java/nio/java-nio-channels-example/

- https://avaldes.com/java-nio-channels/

- https://docs.oracle.com/javase/tutorial/essential/io/file.html

- https://mailinator.blogspot.com/2008/02/kill-myth-please-nio-is-not-faster-than.html

- http://eincs.com/2009/08/java-nio-bytebuffer-channel-file/

- https://medium.com/javarevisited/basics-of-non-blocking-io-buffers-c65c1aecd781

- https://jenkov.com/tutorials/java-nio/nio-vs-io.html