이 글의 주된 목적은 NIO의 등장 배경과 어떤 점에서 IO와 차이가 있는지 알아보는 것이다. 참고한 블로그로 정리해보자!
기본적으로 IO가 느린 이유는 하드디스크, SSD와 같은 물리적인 장비 차원에서 CPU에 접근하는 것이 느리기 때문이다. 대표적인 예시로, 하드디스크에서 랜덤 액세스의 경우 특정 위치의 데이터를 읽기 위해 핀을 들고 옮기는 물리적인 행동이 동반된다. (But! 랜덤 액세스하지않고 시퀀스하게 할 경우 생각보다 빠르게 접근하긴함)
조금 더 자세히 알아보자. 하단의 이미지 프로세스를 생각해보자. 🥸
read()
)을 사용가장 먼저, 내부 버퍼로 복사할 때 CPU 연산이 필요하기 때문에 리소스를 크게 잡아먹는다. 디스크에서 디스크 컨트롤러가 커널 영역의 버퍼로 복사하는 것은 DMA(Direct Memory Access) 기능으로 CPU 연산이 필요없다. DMA 처럼 CPU 자원을 사용하지 않거나, 직접 커널 버퍼를 사용하게 된다면 CPU 자원을 다른 곳에서 사용하는 효율적인 프로그래밍이 가능해진다.
즉, DMA나 직접 커널 버퍼에 접근하지 않는 이상은 CPU 연산이 일어나 느려진다는 의미다!
자바 IO에서 내부 버퍼로 사용한 데이터 변수는 GC 대상이 된다. 그리고 GC는 자바에서 큰 오버헤드의 요소다. 마찬가지로 직접 커널 버퍼에 접근하여 사용한다면 내부 버퍼를 사용하지 않게되고 GC도 불필요해진다.
운영체제는 효율을 높이기 위해 최대한 많은 양의 데이터를 커널 영역의 버퍼에 저장하고 프로세스 영역의 버퍼로 전달한다. 따라서 디스크의 파일 데이터를 커널 영역 안의 버퍼로 모두 복사할 때 까지 IO 요청 스레드가 블록킹 된다.
자바 관점에서 바라봐보자. 자바는 IO 요청이 오면 매 요청마다 스레드를 만들고 socket을 accept()
할 때와 데이터를 read()
할 때마다 해당 스레드가 블록킹된다. 멀티 스레드를 지원하지만, 수 많은 요청이 들어오면 Context Switching에 따른 오버헤드가 커지게 된다. 심지어 작업이 완료된 스레드는 GC 대상이 된다.
즉, 모든 IO는 반드시 커널 영역을 거치게 되는데, 커널 내부 버퍼에 직접 접근하지 않는 이상 CPU 연산, 불필요한 GC, 스레드 블록킹이 발생하기 때문에 IO가 느리다고 생각하면 된다.
NIO(New Input Output)는 위에서 언급한 문제를 해결하기 위해 JDK 1.4에서 등장했다. 결론적으로 IO와 NIO의 차이는 다음과 같다.
구분 | IO | NIO |
---|---|---|
입출력 방식 | Stream | Channel |
버퍼 방식 | Non-Buffer | Buffer Oriented |
비동기 지원 | 지원 안함 | 지원 |
블록킹 방식 | Blocking만 지원 | Blocking, Non-Blocking 모두 지원 |
위 표에 있는 각 단어들의 개념을 이해해보자.
IO는 Non-Buffer, NIO는 Buffer Oriented라고 했다. 그럼 버퍼는 뭘까? 🤔
우리가 흔희 접하는 상황을 예로 들어보자. 우리가 영상을 시청한다고 했을 때, 영상 로딩을 버퍼링이라고 하고 실시간으로 송출할 때는 스트리밍이라고 한다. 즉, 영상을 재생할 수 있을 때까지의 데이터를 모으는 동작을 버퍼링이라고 하고 영상 데이터를 조금씩 전송하는 것을 스트리밍이라고 한다.
스트리밍에서도 버퍼링의 개념이 존재한다. 영상 데이터의 전송이 너무 느리다면 화면에 출력할 때까지 최소한의 데이터를 모아야하고, 영상 데이터가 재생 속도보다 빠르게 전송되어도 미리 전송받은 데이터를 저장할 공간이 필요하기 때문에 버퍼의 개념이 필요해진다.
버퍼를 사용하는 경우와 사용하지 않는 경우는 하단의 이미지에서 쉽게 이해할 수 있다.
그렇다고 버퍼 기반이 무조건적으로 좋다고 할 수는 없다. 버퍼를 사용하면 입출력 성능을 개선할 수는 있지만, 게임과 같은 입출력이 빠르게 이루어지는 곳에서는 버퍼 기반이 오히려 악영향을 끼칠 수 있기 때문이다.
그럼 스트림 개념도 이해해보자. 앞에서는 말했듯, 실시간으로 영상을 재생할 때 데이터를 모두 받고 재생하려면 딜레이가 발생하기에 데이터를 조금씩 전송하여 재생시켜야 한다고 했다. 우리는 이걸 흔히 스트리밍이라고 한다.
정확하게는, 스트림은 데이터가 들어온 순서대로 흘러다니는 단방향의 통로다. 입구를 InputStream, 출구를 OutputStream이라고 한다.
기본적으로 스트림 내부의 데이터는 바이트 형태로 흘러다니고 기본적으로 Blocking 방식으로 동작한다. 따라서 데이터를 읽거나 쓰기 위해 스트림에 요청하면, 해당 요청이 끝날 때까지 다른 작업을 하지 못하고 무한정 기다리게 된다.
따라서 스트림 기반 방식을 사용하는 IO는 사용을 하고 닫아주지 않으면 심각한 메모리 누수가 발생할 수 있어 예외처리를 신경써주어야 한다.
채널은 NIO의 기본 입출력 방식이다. 스트림이 단방향 통로라면, 채널은 데이터가 흘러다니는 양방향의 통로라고 볼 수 있다. 양방향이기에 입력과 출력을 구분하지 않고 스트림과 다르게 InputStream, OutputStream을 만들 필요가 없다.
채널은 기본적으로 버퍼를 통해서만 읽고 쓸 수 있는 버퍼 방식이고, Blocking과 Non-Blocking 방식 모두 가능하다. NIO는 Non-Blocking으로 데이터를 처리할 수 있어 과도한 스레드 생성을 피하고 스레드를 효과적으로 재사용할 수 있다.
결론은 아니다. 입출력이 오래 걸리는 작업일 수록 스레드를 재사용하여 Non-Blocking 방식으로 처리하는 NIO는 좋은 효율을 내지 못할 수 있다. 또한 대용량 데이터를 처리해야할 경우 NIO의 버퍼 할당 크기가 문제되고, 모든 입출력 작업에 버퍼를 반드시 사용해야하기에 즉시 처리하는 IO보다 복잡해진다.
NIO는 불특정 다수의 클라이언트를 연결하거나 하나의 입출력 처리작업이 오래 걸리지 않는 경우에 사용하는 것이 좋고 IO는 연결 클라이언트 수가 적고 전송되는 데이터가 대용량이면서 순차적으로 처리될 필요가 있을 경우에 좋다.