Java

[ Java ] Java의 I/O (feat. Stream, Buffer, Channel)

자바니또 2021. 2. 20. 12:57

목차

  • 스트림(Stream), 버퍼(Buffer), 채널(Channel) 기반의 I/O
  • InputStream, OutputStream
  • Byte와 Character 스트림
  • 표준스트림(System.in, System.out, System.err)
  • 파일 읽고 쓰기

개요

I/O란 Input/Output으로 데이터를 입력하고 출력하는 것을 의미한다. 예를 들면, 우리가 컴퓨터에게 데이터를 전달하기 위해 키보드를 입력하는 것을 Input이라 할 수 있고 그 Input을 적적히 처리하여 화면에 출력하여 보여주는 것이 Output이라 할 수있다.

그렇다면 데이터는 어떤 방법으로 전달될 수 있을까? 오늘 포스팅하는 Stream과 Channel, Buffer는 데이터를 전달하기 위해 사용하는 그릇과 통로라고 할 수있다. 더 자세히 알아보도록 하자.

Stream, Buffer, Channel

Stream

Stream이란, 데이터가 들어온 순서대로 흘러다니는 단방향통로이다. 입구와 출구가 존재하며 입구를 InputStream, 출구를 OutputStream이라 한다. Stream을 통해 데이터는 기본적으로 byte또는 byte[] 형태로 흘러다닌다.

Stream은 동기적, blocking방식으로 동작한다. 데이터를 읽거나 쓰기 위해 스트림에 요청하면 스트림은 자신의 역할에 맞춰 다시 데이터를 읽거나 쓸 수 있을 때까지 다른 작업을 하지 못하고 무한정 기다린다.

Java에서 모든 기본 I/O는 Stream을 기반으로 하기 때문에 빈번하게 사용되는데, 사용을 끝내고 닫아주지 않으면 심각한 메모리 누수가 발생할 수 있기 때문에 예외처리에 주의를 기울여서 사용하여야 한다.

Buffer

Buffer란, 임시로 데이터를 담아둘 수있는 일종의 큐이다. 바이트 단위의 데이터가 입력될 때마다 Stream은 즉시 전송하게 되는데 이것은 디스크 접근이나 네트워크 접근같은 오버헤드가 발생하기 때문에 매우 비효율적인 방법이다. Buffer는 중간에서 입력을 모아서 한번에 출력함으로써 I/O 의 성능을 향상시키는 역할을 한다.

// Buffer를 사용하지 않고 출력
   public static void nonBufferIO() {  
       for (int i = 0; i < 100000; i++) {  
           System.out.print(i);  //입력이 있을 때마다 출력
       }  
       System.out.println();  
   }  

// Buffer를 사용하여 출력
   public static void bufferIO() {  
       StringBuffer sb = new StringBuffer();  
       for (int i = 0; i < 100000; i++) {  
           sb.append(i);      //1. 버퍼에 모두 담고
       }  
       sb.append("\n");  
       System.out.print(sb);  //2. 출력
   }  
}
<수행속도>
nonBufferIO() : 281 (ms)
bufferIO() : 15    (ms)

버퍼의 성능향상 효과를 체험해 보기위해 간단하게 코드를 만들어 보았다. 0부터 10만까지의 숫자를 nonBufferIO()는 매번 화면에 출력하고, bufferIO()는 입력들을 모두 버퍼에 담은 후 입력이 끝나면 버퍼에 담아둔 데이터를 출력한다.

밀리초로 각 메서드의 수행시간을 측정한 결과 nonBufferIO()는 281밀리초 bufferIO()는 15밀리초로 10배가 넘는 속도차이가 났다. 입력이 많아지고 데이터의 크기가 커질 수록 이 속도의 차이는 커질 것이다. 버퍼의 장점을 스트림에 적용하여 Java에서는 BufferedInputStream과 BufferedOutputStream을 제공한다.

Channel

자바의 기본 입출력 방식이었던 Stream은 blocking방식과 Non-Buffer의 특징으로 인해 입출력 속도가 느릴 수 밖에 없었다. 자바 4부터 그러한 문제점을 해결하고자 NIO(New Input Output)가 java.nio패키지에 포함되어 등장하였는데, Channel이 그 NIO의 기본 입출력 방식이다.

Channel이란, 데이터가 흘러다니는 양방향통로이다. Channel은 Stream과 유사하지만 동작 방식이 다르다. 양방향이기 때문에 input/output을 구분하지 않는다. Stream은 입력과 출력을 위해 InputStream과 OutputStream을 만들어야 했지만 Channel은 그럴 필요가 없다.

또, Channel은 Stream과 다르게 기본적으로 Buffer를 통해서만 read와 write를 할 수 있는 buffer방식이고, blocking방식과 non-blocking방식 모두 가능하다. NIO는 Non-blocking방식으로 데이터를 처리 할 수 있어서 과도한 스레드 생성을 피하고 스레드를 효과적으로 재사용 할수 있다.

그렇다면 IO대신 무조건 NIO를 사용해야 할까? 그건 아니다. 입출력 처리가 오래걸리는 작업일 경우 스레드를 재사용하여 Non-blocking방식으로 처리하는 NIO는 좋은 효율을 내지 못할 수 있다. 또한 대용량 데이터를 처리해야할 경우 NIO의 버퍼 할당 크기가 문제가 되고, 모든 입출력 작업에 버퍼를 무조건 사용해야 하므로 즉시 처리하는 IO보다 복잡하다.

정리해보자면 NIO는 불특정 다수의 클라이언트를 연결하거나 하나의 입출력 처리작업이 오래걸리지 않는 경우에 사용하는 것이 좋고, IO는 연결 클라이언트 수가 적고 전송되는 데이터가 대용량이면서 순차적으로 처리될 필요성이 있는 경우 유리하다.

InputStream & OutputStream

앞에서 잠깐 언급했던 대로 InputStream 은 Stream중 데이터를 read하는 입구이고, OutputStream은 데이터를 write하는 출구이다. 서로 다른 두 클라이언트가 Stream 을 사용하면 그림과 같이 진행 될 것이다.

InputStream과 OutputStream은 Stream을 이해하는 개념적인 의미이기도 하지만 Java에서는 바이트 시퀀스를 읽거나 쓰기위한 기본 기능을 정의 해놓은 추상클래스 이기도 하다. 즉, Java의 모든 바이트 스트림은 InputStream또는 OutputStream위에 빌드된다. Java에서 제공하는 스트림 클래스들은 다형성을 이용해 스트림을 계층화가 가능하여, 더 큰 데이터 유형처리와 같은 더 높은 수준의 기능을 제공 할 수도 있다.

출처 : http://web.deu.edu.tr/doc/oreily/java/exp/ch08_01.htm

Byte와 Character 스트림

지금까지는 바이트 스트림에 대하여 알아보았다. Java 1.0.2의 InputStream과 OutputStream은 문자열을 읽고 쓰는 메서드를 포함하고 있었지만, 이것은 스트림에서 16비트 유니코드 문자와 8비트의 바이트가 동등하다고 가정하고 동작하였다. 그래서 오직 Latin-1(ISO8859-1) 문자에서만 작동했고, Java 1.1부터 이러한 문제를 해결한 문자 스트림 클래스인 Reader와 Writer가 도입되었다.

InputStreamReaderOutputStreamWriter가 그것이다. 이 두 클래스는 문자 스트림의 세계와 바이트 스트림 세계를 연결해주는 bridge역할을 해주는 특수한 클래스이다.

출처 : https://hyeonstorage.tistory.com/247

하드디스크는 데이터를 바이트 단위로 읽는다. 그래서 사용자가 입력한 문자나 기호들을 컴퓨터가 이용할 수 있게 변환해주는 작업이 필요한데 그것이 바로 인코딩이고 그 반대가 디코딩이다. InputStreamReaderOuputStreamWriter는 각각 인코딩과 디코딩을 해주어 문자스트림의 세계와 바이트 스트림 세계를 연결해주는 Bridge역할을 한다.

public static void main(String[] args) throws IOException {  
    try(BufferedReader br = new BufferedReader(new InputStreamReader(System.in))){  
        String s = br.readLine();  
        System.out.println(s);  
    }  
}

위의 코드는 문자열을 키보드로부터 입력받아 화면에 출력해주는 간단한 코드이다. 여기에는 아까 말한 Stream의 계층화가 적용되었는데 System.in이라는 Stream을 통해 입력된 문자들을 InputStreamReader로 인코딩을 한번 하고 또 인코딩된 바이트들을 "\n"을 만날때 까지 버퍼에 담아두는 BufferedReader로 감쌌다. 결국 우리는 BufferedReader가 제공하는 readLine()하나로 한줄의 문자열을 입력받아 출력할 수 있는 것이다.

InputStreamReader와 OutputStreamReader는 인자로 인코딩방식을 주입할 수 있으며 기본적으로 System 의 기본 인코딩 체계를 사용한다.

표준스트림(System.in, System.out, System.err)

public class Main(){
    public static void main(String[] args) throws IOException {  
        Scanner sc = new Scanner(System.in);  //입력받고
        System.out.println(sc.next());  //출력한다.
    }
}

Java개념서들의 첫 장에서 많이 볼 수 있는 코드이다. 이때 부터 굉장히 많이 사용되지만 잘 모를 수 있는 것이 System.outSystem.in이다. 여기서 System은 JVM을 구성하고 있는 표준 장치를 뜻하는 클래스로서 이 클래스에 정적 멤버변수로 선언되어있는 스트림인 in, out, err표준 스트림이라 한다.

// System 클래스 내부
public final class System {  
    public static final InputStream in;  
    public static final PrintStream out;  
    public static final PrintStream err;
    /*
    * ...
    */

우리가 콘솔에 출력하기 위하여 자주 사용하는 System.out.println()에서 println()은 out개체 클래스인 PrintStream클래스의 메서드이다. PrintStream은 OutputStream을 상속하여 메서드들의 예외처리를 한 클래스이다.

err는 out과 마찬가지로 PrintStream타입이며 표준 에러 출력장치를 의미한다. err와 out은 특별한 설정을 하지 않는 이상 기본적으로 모니터를 출력장치로 한다.

파일 읽고 쓰기

    public static void main(String[] args) throws IOException {

        BufferedReader br = null;
        PrintWriter pw = null;

        try {
            br = new BufferedReader(new FileReader("input.txt"));
            pw = new PrintWriter(new FileWriter("output.txt"));

            String s;
            while ((s = br.readLine()) != null) {
                pw.println(s);
            }
        } finally {
            if (br != null) {
                br.close();
            }
            if (pw != null) {
                pw.close();
            }
        }
    }

간단하게 작성한 코드이다. "input.text"파일의 내용을 읽어와서 "output.txt"파일에 출력하는 코드인데 앞에 설명을 이해하였다면 충분히 읽을 수 있을 것이다. 우선 처음 보는 FileReaderFileWriter 가 보이는데, Reader와 Writer가 붙는 것으로 보아 문자 스트림이라는 것을 유추 할 수 있다.

그렇다면 바이트 스트림은 왜 안보일까? 그것은 내부에 감춰져 있기 때문이다. FileReader로 예를들면 인자로 파일이름을 받은 후 내부에서 FileInputStream을 생성하여 인자로 파일이름을 보낸다. 또 FileInputStream은 인자로 받은 파일이름으로 File객체를 만들어 파일의 바이트 스트림을 읽어들인다. PrintWriter는 라인단위로 끊어서 출력하기 위해 사용되었다.