[Java] IO와 NIO

rvlwldev·2023년 3월 23일
0

Java

목록 보기
4/8

IO(Input/Output)란 그 의미에서 알 수 있듯 파일 시스템, 네트워크, 기타 장치 등 다양한 소스와 대상으로 데이터를 읽고 쓰는 기능을 제공하는 자바 API이다.

java.io 패키지에서 Byte 기반이라면 InputStream과 OutputStream을, 문자 기반이라면 Reader와 Writer를 시스템의 파일들을 읽고 쓰는데 자주 사용된다.

이후 비동기식 입출력을 지원하는 NIO(New Input/Output)가 자바4에서 등장하게 되고
기존의 IO에서 업그레이드된 기능을 지원하게 된다.
이후에도 java.nio 패키지가 계속 업데이트 되며 자바7부터 NIO2라고 불리지만 기존 NIO와 따로 구분짓지는 않는듯 하다.

기존의 IO

거의 초창기 부터 지원했던 API이며 NIO가 나온 이후 Classic IO 라고도 불린다.
크게 Byte 입출력 기반의 Stream과 문자 입출력 기반의 Stream으로 나뉘어진다. 참고
기본적으로 Stream을 기반으로 하고 있다.
NIO가 나온 지금도 자주 사용되는 클래스들이며 BufferedInputStream 등의 보조 스트림을 활용하여 데이터 처리 속도를 높일 수도 있다.

Stream이란? 참고

사전적으로 시냇물, 흐르다 라는 의미를 가진다.
일상 생활에서의 인터넷 방송할 때 Streaming한다는 의미와 같다.
이 글에서 의미는 운영체제에 의해 생성되는 데이터의 이동되는 흐름, 데이터가 이동되는 통로의 개념을 뜻하며 선입선출(FIFO)의 흐름을 가진다.

Byte 입출력 기반의 Stream

주로 이진 데이터(이미지, 음악 파일, 동영상 파일 등)를 읽고 쓰는데 사용된다.
파일의 경로와 File객체를 활용해 파일을 불러와 FileInputStream을 활용해서
파일을 다룬다. (또는 생성되는 파일의 경로를 설정한다.)
외부자원을 사용할 때 발생할 수 있는 메모리 누수 문제를 방지하기 위해 close() 메소드를 사용해줘야 한다.

// 간단한 예시
public boolean saveAsLogFile() {
	try {
            FileInputStream inputStream = new FileInputStream("./testInput.txt");
            FileOutputStream outputStream = new FileOutputStream("./testOutput.txt");

            // 보조 스트림 
            BufferedInputStream bufferedInputStream = new BufferedInputStream(inputStream);
            BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(outputStream);

            // 입력 스트림에서 데이터를 읽어 출력 스트림으로 데이터를 쓰기
            int data;
            while ((data = bufferedInputStream.read()) != -1) {
                bufferedOutputStream.write(line);
            }

            // 메모리 누수 방지를 위한 스트림 닫기
            bufferedInputStream.close();
            bufferedOutputStream.close();
            
			return true;
        } catch (IOException e) {
            e.printStackTrace();
            return false;
        }
}        

위 코드는 단순히 testInput.txt 파일의 내용을 testOutput.txt에 옮겨 적는 코드이다.
출력 경로에 파일을 생성하고 이미 존재한다면 덮어씌운다.

txt파일이 아닌 동영상이나 오디오 파일이라면AudioInputStream, AudioOutputStream을 사용하고 좀 더 복잡한 기능이 요구되지만 위 코드와 전체적인 흐름은 다르지 않다.

문자 입출력 기반의 Stream

문자 기반이라면 Reader와 Writer를 사용한다.

// 예시

public boolean saveAsLogFile(LogResult result) {
	try {
			File file = new File(OUTPUT_FILE_PATH.toString());

			// 파일 입력
			FileWriter fileWriter = new FileWriter(file);
            
            // 보조스트림
			BufferedWriter bufferedWriter = new BufferedWriter(fileWriter);

			// 파일에 로그결과 문자열 입력
			bufferedWriter.write(result.getMostCalledApikeyString());
        	bufferedWriter.write(result.getMostRequestedServicesString());
			bufferedWriter.write(result.getBrowserUsageRateString());
			// ... 생략

            // 메모리 누수 방지를 위한 스트림 닫기
			bufferedWriter.close();
			fileWriter.close();

			return true;
        } catch (IOException e) {
            return false;
        }
}

위 예시에서는 문자 기반 출력 스트림인 FileWriterBufferedWriter 보조 스트림을 활용해서
OUTPUT_FILE_PATH파일에 문자열을 입력하는 토이프로젝트 코드이다.

보조스트림의 경우 필수는 아니지만 보조 스트림 없이 fileWriter만을 사용하며 작성한다면
매번 입출력 작업을 처리해야 되서 로그 결과의 데이터가 많아지면 느려질 수 있다.

BufferedWriter를 사용함으로써 내부의 데이터를 버퍼에 저장하고 있다가 flush(), close() 메소드 또는 버퍼가 가득찼을때만 입출력 작업을 처리하기에 효율적이다.

NIO

위 Stream기반의 기존 I/O API는 동기식이라는 단점이 있다.
멀티쓰레드환경으로 구성하더라도 I/O API 자체가 비동기 처리를 지원하지 않기 때문에 입출력 작업을 완료할 때까지 쓰레드들 사이의 대기시간이 길어져 효율적으로 쓰레드를 활용하기 힘들다.

NIO를 사용하면 대기시간을 최소한으로 줄이고 비동기 처리를 지원하기에 훨씬 효율적인 멀티쓰레딩을 구현할 수 있다.

이 외에도 다른 차이점이 존재한다.

기존의 IO와 다른점

Stream 기반에서 Buffer 기반으로

Buffer의 의미는 데이터를 저장하거나 읽고 쓰는 임시적인 데이터 저장소 또는 컨테이너이다. ByteBuffer, IntBuffer, FloatBuffer 등이 있으며 추상클래스 Buffer를 상속한다.

버퍼 사용법 참고 https://jamssoft.tistory.com/221

Stream 기반의 IO는 기본적으로 한번 읽은 데이터를 다시 읽거나 건너뛸 수 없다.
또한 출력스트림이 만약 1바이트의 데이터를 쓰면 입력스트림이 1바이트를 읽기는 방식으로 동작하기에 속도가 느릴 수 있다.
때문에 위 예시처럼 버퍼를 만들어 캐싱해주는 기능을 따로 구현해주어야 한다.

하지만 Buffer 기반의 NIO라면 이미 처리된 buffer로부터 데이터를 읽기에 따로 구현할 필요가 없는 유연성을 제공한다.

Non-Blocking

기존의 IO와 가장 큰 차이점이다. 멀티쓰레드 환경이 아니더라도 입출력 시 Channel을 통해 다른 작업을 처리할 수 있으며 여러개의 IO작업을 동시에 처리할 수 있다.

이런 비동기식의 기능을 위해 NIO는 2가지의 핵심요소를 담고있다.

Channel

Channel은 입출력 작업을 수행하는 객체이며 대상이 되는 데이터를 직접 Buffer에 읽고 쓰는 역할을 한다. 직접 Buffer와 상호작용하는 객체이기 때문에 Buffer의 상태(position, limit, capacity)를 변경한다.

또한 Channel은 FileChannel,AsynchronousFileChannel(NIO2), SocketChannel, DatagramChannel 등 다양한 타입이 존재하며,
입출력 방식에 따라 사용하는 채널 타입이 달라진다.

Selector (네트워크 I/O의 경우)

NIO를 활용할 때, 비동기식으로 여러개의 입출력 작업을 처리하기 위해
새로운 쓰레드를 생성하는 대신 Selector를 활용한다.

Selector는 위 Channel들을 활용해 실질적으로 Non-Blocking 입출력 작업을 가능하게 하는 객체이다.
다시 말해 Selector는 여러 개의 채널을 모니터링하면서, 해당 채널에서 입출력 처리를 담당한다.
이 과정에서 보통 SelectionKey 객체와 함께 사용되며 Selector가 이벤트를 감지하면 이벤트의 종류를 판별할 수 있다.
이는 곧 여러개의 쓰레드를 활용하지 않고 하나의 Selector로 IO작업을 동시에 처리하는데 있어서 멀티쓰레드 환경에서의 동시 IO처리보다 효율성이 높다.

SelectionKey의 주요 메소드

  1. isAcceptable() : 현재 채널에서 클라이언트의 연결 요청의 처리 가능 여부를 확인한다.

  2. isWritable() : 현재 채널에서 쓸 수 있는 공간의 여부를 확인한다.

  3. isReadable() : 현재 채널에서 읽을 수 있는 데이터의 존재 여부를 확인한다.

  4. isConnectable() : 현재 채널이 클라이언트와 연결 여부를 확인한다.

NIO를 활용한 비동기 처리예시 (Reactor 패턴)

public class SocketChannelTest {

    public static void main(String[] args) throws IOException {
    	// channel 생성
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(8080));
        serverSocketChannel.configureBlocking(false); // 비동기 설정

		// selector 생성
        Selector selector = Selector.open();
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        System.out.println("비동기 테스트 시작");

        while (true) {
            int readyChannels = selector.select();
            if (readyChannels == 0) continue; // 클라이언트를 기다리기 위해 무한루프

            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> keyIterator = selectedKeys.iterator();

            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();

                if (key.isAcceptable()) { 
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    socketChannel.configureBlocking(false);
                    socketChannel.register(selector, SelectionKey.OP_READ);

                    System.out.println("접속주소 : " + socketChannel.getRemoteAddress());
                } 
                else if (key.isReadable()) {
                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    
                    // 버퍼생성
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    int bytesRead = socketChannel.read(buffer);

                    if (bytesRead == -1) {
                        socketChannel.close();

                        System.out.println(socketChannel.getRemoteAddress() + "접속종료\n");
                    } else if (bytesRead > 0) {
                        buffer.flip();
                        byte[] bytes = new byte[buffer.remaining()];
                        buffer.get(bytes);

                        System.out.println(socketChannel.getRemoteAddress() + "의 비동기 수신 메세지 :  " + new String(bytes));
                    }
                }

                keyIterator.remove();
            }
        }
    }
}

실행 예시

// telnet을 사용해 접속
telnet localhost 8080

Trying ::1...
Connected to localhost.

test1
test2
... 생략
비동기 테스트 시작
접속주소 : /0:0:0:0:0:0:0:1:55495

/0:0:0:0:0:0:0:1:55495의 비동기 수신 메세지 :  test1
/0:0:0:0:0:0:0:1:55495의 비동기 수신 메세지 :  test2
... 생략

AsynchronousFileChannel을 이용한 파일입/출력 예시

public class asyncFileChannelTest {

    static long beforeTime = System.currentTimeMillis();

    public static void main(String[] args) throws IOException, InterruptedException {
        Path path1 = Paths.get("./programmers/input1.txt");
        readAsync(path1);

        Path path2 = Paths.get("./programmers/input2.txt");
        readAsync(path2);

        System.out.println("비동기식으로 동작한다면 이 문자열이 먼저 출력됨!");

        // 비동기 파일읽기 전 프로그램 종료 방지
        Thread.sleep(2000);
    }

    private static void readAsync(Path filePath) throws IOException {
        // 채널 생성
        AsynchronousFileChannel channel = AsynchronousFileChannel.open(filePath);

        ByteBuffer buffer = ByteBuffer.allocate((int) channel.size());
        long position = 0;

        channel.read(buffer, position, null, new CompletionHandler<Integer, Void>() {
            @Override
            public void completed(Integer result, Void attachment) {
                buffer.flip();

                byte[] data = new byte[buffer.limit()];
                buffer.get(data);

                // 1초의 작업시간이 걸린다고 가정한다.
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                System.out.println(new String(data));
                buffer.clear();

                System.out.println("처리시간 : " + (System.currentTimeMillis() - beforeTime));
            }

            @Override
            public void failed(Throwable exc, Void attachment) {
                exc.printStackTrace();
            }
        });
    }
}

실행 예시

비동기식으로 동작한다면 이 문자열이 먼저 출력됨!
테스트 로그 문자열 002
테스트 로그 문자열 001
처리시간 : 1011
처리시간 : 1011

비동기 방식으로 동시에 처리되기 때문에 처리되는 시간차이가 (거의)같으며
readAsync메소드에서 블로킹되지 않기에
"비동기식으로 동작한다면 이 문자열이 먼저 출력됨!"
문장이 먼저 출력되었다.

왜 IO가 여전히 자주 쓰일까

호환성과 단순함

비동기로 처리할 필요가 없는 상황 이외에도 기존의 IO API는 여전히 자주 쓰인다고 한다.
NIO는 기존보다 비교적 복잡한 편이기에 작은 서비스를 구현한다고 하면 IO가 더 적합할 수도 있다.
또한 JDK1.0부터 사용되어온 IO API 이기에, 호환성을 고려한다면 기존 IO를 사용해야 할 것이며 이외에도 다른 이유가 있다.

특정상황에서의 성능

특정상황에서 Buffer를 사용하는 NIO보다 기존 IO에서 성능의 이점을 보일때가 있다.

예를 들어 작은 단위의 데이터를 반복적으로 입/출력을 하는 상황이라면 Buffer를 사용하는 NIO에서는 너무 잦은 Buffer의 flip과 clear 메소드가 반복, 또는 생성될 수 있기에 성능에 악영향을 미칠 수 있다.

또 다른 예시로 로그파일처럼 Line by Line 단위의 처리가 필요할 경우,
NIO에서는 라인을 나누는 로직이 포함이 되어야 하기 때문에 기존 IO에서 지원하는 bufferedReader를 사용하는 방법이 더 빠르게 처리될 수 있다.
테스트 결과 참고1
테스트 결과 참고2

0개의 댓글