본문 바로가기

Java/Java Language

[Java] I/O Stream (Byte Stream, Character Stream)

I/O란?

I/O(Input/Output)는 컴퓨터 내부 또는 외부 장치 프로그램간의 데이터를 주고 받는 것을 말한다. 예를 들어, 프로그램에서 메모리나 디스크에 있는 데이터를 읽고 쓰기,  System.out.prinln()도 호출하여 화면(콘솔)에 출력하는 것이 입출력 작업이다. 그리고 네트워크를 통해서 외부의 장치와 데이터를 송수신 하는것도 마찬가지이다.


I/O Stream

Java에서 입출력(I/O)을 수행할려면, 어느 한쪽에서 다른 쪽으로 데이터를 전달하기 위해서 두 대상을 연결하고 데이터를 전송할 수 있는 통로가 필요하다. 이를 스트림(stream)이라고 하며, 데이터를 운반하는 연결 통로이다.

 

왜 이렇게 Java Application에서 입출력(I/O)을 수행하기 위해서 Java API인 I/O Stream를 써야되냐면, 바로 OS에서 관리하는 I/O자원들을 사용하기 위해서는 커널영역에 저장된 코드를 실행시켜야되기 때문이다. 그래서 InputStream/ OuputStream read()/write()와 같은 메서드를 호출하면 내부적으로 native code로 작성된 system call을 통해 유저모드에서 커널모드로 전환되어 커널영역에 코드를 호출할수 있는 권한이 생긴다.

 

스트림은 연속적인 데이터의 흐름이다. 그리고 단방향 통신만 가능하기에 입출력을 동시에 하지 못한다. 따라서 입출력을 모두 하기 위해서는 입력 스트림(Input stream)과 출력 스트림(Output Stream)이 2개의 스트림이 필요하다. 

 

위의 그림에서 알수 있듯이 Input Stream은 읽기이고, Ouput Stream은 쓰기이다. 또한 스트림은 연속적으로 데이터를 전송하여, 먼저 보낸 데이터를 먼저 받게된다. 마치 큐(Queue)와 같은 FIFO(First In First Out) 구조로 되어 있다고 생각하면된다.


바이트 기반 스트림 (InputStream, OutputStream)

입력 스트림(Input stream)과 출력 스트림(Output Stream)  바이트 단위로 데이터를 전송한다. 입출력 대상에 따라 아래와 같이 여러 입출력 스트림이 존재한다.

  • 파일: FileInputStream, FileOutputStream
  • 메모리(byte 배열): ByteArrayInputStream, ByteArrayOutputStream
  • 프로세스(프로세스간의 통신): PipedInputStream, PipedOutputStream
  • 오디오 장치: AudioInputStream, AudioOutputStream

위의 입출력 스트림 모두 InputStream과 OutputStream의 자손들이며, 각각 읽고 쓰는데 필요한 추상 메서드를 자신에 맞게 구현해놓았다.

 

Java에서는 java.io패키지를 통해 많은 종류의 입출력 관련 클래스들을 제공하고 있다. 이 클래스들은 입출력을 처리할수 있는 표준화된 방법을 제공함으로써 입출력의 대상이 달라져도 동일한 방법으로 입출력이 가능하다. 그러므로 보다 편하게 프로그래밍을 할수 있도록 해준다.

다음 메서드의 사용법만 잘 알고 있어도 데이터를 읽고 쓰는 것은 입출력 대상의 종류와 관계없이 간단한 일이된다.

  • InputStream 클래스의 메서드
    • abstract int read()
    • int read(byte[] b)
    • int read(byte[] b, int off, int len)
  • OutputStream 클래스의 메서드
    • abstract void write(int b)
    • void write(byte[] b)
    • void write(byte[] b, int off, int len)

InputStream의 read()와 OutputStream의 write(int b)는 입출력 대상에 따라 읽고 쓰는 방법이 다를 것이기 때문에, 각 상황에 알맞게 구현하라는 의미해서 추상메서드로 정의되어 있다.

 

각 클래스에서 read()와 write(int b)를 제외한 나머지 메서드들은 추상 메서드가 아니기에, 굳이 추상 메서드인 read()와 write(int b)를 구현하지 않아도 된다고 생각이 들수 있다.

 

그러나 나머지 메서들도 추상메서드인 read()와 write(int b)를 사용해서 구현한 메서드이므로, read()와 write(int b)를 오버라이드해서 반드시 구현해야한다.

 

다음, 실제 InputStream 클래스의 코드에서 int read(byte[] b)의 구현부에서 int read(byte[] b, int off, int len)을 호출한다. 그리고 int read(byte[] b, int off, int len) 메서드에서 추상메서드인 abstract int read() 메서드를 호출하도록 작성되어있는 것을 알수 있다.

package java.io;  

import java.util.ArrayList;  
import java.util.Arrays;  
import java.util.List;  
import java.util.Objects;

public abstract class InputStream implements Closeable {  

    private static final int MAX_SKIP_BUFFER_SIZE = 2048;  

    private static final int DEFAULT_BUFFER_SIZE = 16384;  

    public InputStream() {}

    public abstract int read() throws IOException;

    public int read(byte[] b) throws IOException {  
        return read(b, 0, b.length);  
    }

    public int read(byte[] b, int off, int len) throws IOException {  
        Objects.checkFromIndexSize(off, len, b.length);  
        if (len == 0) {  
            return 0;  
        }  

        int c = read();  
        if (c == -1) {  
            return -1;  
        }  
        b[off] = (byte)c;  

        int i = 1;  
        try {  
            for (; i < len ; i++) {  
                c = read();  
                if (c == -1) {  
                    break;  
                }  
                b[off + i] = (byte)c;  
            }  
        } catch (IOException ee) {  }  
        return i;  
    }

}

 

메서드는 선언부만 알고 있어도 호출이 가능하므로, 클래스 내부에서 추상 메서드를 호출하는 코드를 작성할수 있다. 이렇게 추상 클래스인 InputStream와 OutputStream룰 상속하여, 추상메서드를 각 입출력 대상(파일, 메모리, 프로세스 간)에 따라 알맞게 읽고 쓰는 클래스를 구현하기 위해 존재한다.

// 파일 입력 스트림
public class FileInputStream extends InputStream {
    private native int read0() throws IOException; 

    @Override  
    public int read() throws IOException {  
        long comp = Blocker.begin();  
        try {  
            return read0();  
        } finally {  
            Blocker.end(comp);  
        }  
    }
}

// 메모리 입력 스트림
public class ByteArrayInputStream extends InputStream {
    @Override  
    public synchronized int read() {  
        return (pos < count) ? (buf[pos++] & 0xff) : -1;  
    }
}

// 프로세스간 입려 스트림
public class PipedInputStream extends InputStream {
    @Override  
    public synchronized int read()  throws IOException {  
        if (!connected) {  
            throw new IOException("Pipe not connected");  
        } else if (closedByReader) {  
            throw new IOException("Pipe closed");  
        } else if (writeSide != null && !writeSide.isAlive()  
                   && !closedByWriter && (in < 0)) {  
            throw new IOException("Write end dead");  
        }  

        readSide = Thread.currentThread();  
        int trials = 2;  
        while (in < 0) {  
            if (closedByWriter) {  
                /* closed by writer, return EOF */  
                return -1;  
            }  
            if ((writeSide != null) && (!writeSide.isAlive()) && (--trials < 0)) {  
                throw new IOException("Pipe broken");  
            }  
            /* might be a writer waiting */  
            notifyAll();  
            try {  
                wait(1000);  
            } catch (InterruptedException ex) {  
                throw new java.io.InterruptedIOException();  
            }  
        }  
        int ret = buffer[out++] & 0xFF;  
        if (out >= buffer.length) {  
            out = 0;  
        }  
        if (in == out) {  
            /* now empty */  
            in = -1;  
        }  

        return ret;  
    }
}

보조 스트림 (FilterInputStream, FilterOutputStream)

지금까지 언급한 스트림의 기능을 보완하기 위한 보조 스트림이 제공된다. 보조 스트림은 실제 데이터를 주고 받는 스트림이 아니기 때문에 데이터를 입출력할수 있는 기능은 없고, 스트림의 기능을 향상 시키거나 새로운 기능을 추가할 수 있다. 그래서 보조 스트림만으로는 입출력을 처리할 수 없고, 스트림을 먼저 생성한 다음에 이를 이용해서 보조 스트림을 생성한다.

 

예를 들어, hello.txt라는 파일을 읽기 위해 FileInputStream을 사용할 때, 입력 성능을 향상시키기 위해서 버퍼를 사용하는 보조 스트림 BufferedInputStream을 사용하는 코드이다. 실제 입력 기능은 BufferedInputStream 객체가 참조하고 있는 FileInputStream 객체에서 수행된다.

import java.io.BufferedInputStream;  
import java.io.FileInputStream;  
import java.io.IOException;  

public class FileReadExample {  
    public static void main(String[] args) {  
        // 파일 경로 설정  
        String filePath = "hello.txt";  

        // FileInputStream을 사용하여 파일을 읽기 위한 스트림을 생성  
        try (FileInputStream fileInputStream = new FileInputStream(filePath);  
             // BufferedInputStream을 사용하여 성능 향상  
             BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream)) {  

            int byteData;  
            // 파일의 끝까지 바이트 단위로 읽기  
            while ((byteData = bufferedInputStream.read()) != -1) {  
                // 읽은 바이트를 출력 (문자 변환 후 출력)  
                System.out.print((char) byteData);  
            }  
        } catch (IOException e) {  
            // 예외 처리  
            System.err.println("File reading failed: " + e.getMessage());  
        }  
    }  
}

 

BufferedInputStream는 내부적으로 버퍼(buffer)를 사용해서 데이터를 한 번에 큰 블록(예를 들어, 8KB)으로 읽어와 메모리의 임시 공간에 저장해 둔다. 이를 '버퍼링(buffering)'이라고한다. 이를 통해 데이터가 필요할 때마다 디스크에 접근하는 대신, 미리 읽어온 데이터를 메모리에서 빠르게 처리할 수 있게 된다. 

  1. BufferedInputStream이 처음 데이터를 읽으면, 바이트 배열인 버퍼를 사용해서 디스크에서 8KB 정도의 데이터를 FileInputStream을 통해 가져와서 메모리의 버퍼에 저장한다. 이 작업은 한 번만 디스크에 접근하여 이루어진다.
  2. 이후 사용자가 데이터를 읽을 때마다, BufferedInputStream은 디스크가 아닌 메모리 버퍼에서 데이터를 바이트 단위로 하나씩 제공한다. 이는 디스크 접근 없이 빠르게 데이터를 가져올수 있는것이다.
  3. 그리고나서 버퍼의 모든 데이터가 읽히면, 버퍼는 빈 상태가 된다. 즉, 버퍼에 더 이상 읽을 데이터가 없게 된다. 만일, 디스크에 더 가져올 데이터가 남았다면, 이전까지의 과정 반복한다.

만약 버퍼링을 사용하지 않는다면, FileInputStream과 같은 입출력 스트림 클래스는 디스크에서 데이터를 바이트 단위로 읽어오는 방법을 제공하기 때문에, 바이트 단위로 데이터를 읽을때마다 디스크에 접근이 필요하다.

 

BufferedInputStream이 디스크에서 데이터를 한 번에 8KB 정도의 블록으로 읽어오는 부분은 fill() 메서드에서 수행된다.

private void fill() throws IOException {  
    byte[] buffer = getBufIfOpen();  
    if (markpos == -1)  
        pos = 0;            /* no mark: throw away the buffer */  
    else if (pos >= buffer.length) { /* no room left in buffer */  
        if (markpos > 0) {  /* can throw away early part of the buffer */  
            int sz = pos - markpos;  
            System.arraycopy(buffer, markpos, buffer, 0, sz);  
            pos = sz;  
            markpos = 0;  
        } else if (buffer.length >= marklimit) {  
            markpos = -1;   /* buffer got too big, invalidate mark */  
            pos = 0;        /* drop buffer contents */  
        } else {            /* grow buffer */  
            int nsz = ArraysSupport.newLength(pos,  
                    1,  /* minimum growth */  
                    pos /* preferred growth */);  
            if (nsz > marklimit)  
                nsz = marklimit;  
            byte[] nbuf = new byte[nsz];  
            System.arraycopy(buffer, 0, nbuf, 0, pos);  
            if (!U.compareAndSetReference(this, BUF_OFFSET, buffer, nbuf)) {  
            }  
            buffer = nbuf;  
        }  
    }  
    count = pos;  
    int n = getInIfOpen().read(buffer, pos, buffer.length - pos);  
    if (n > 0)  
        count = n + pos;  
}

 

  • buf는 바이트(버퍼) 배열이며, 기본 크기는 8KB이다. 바이트 배열의 크기는 BufferedInputStream 생성 시 명시적으로 설정하지 않으면, DEFAULT_BUFFER_SIZE에 의해 기본값으로 8192 바이트(8KB)가 된다.
  • in.read(buf, pos, buf.length - pos) 부분에서 입출력 장치에서 데이터를 한 번에 8KB 크기로 읽어온다.
    • buf.length - pos: 남은 버퍼 크기만큼 데이터를 읽어온다.
    • in.read()는 입력 대상에서 데이터를 읽어오고, buf 배열에 그 데이터를 저장한다.
    • 읽어온 데이터의 바이트 수가 n으로 반환되며, 이 값은 count에 반영되어 버퍼에 남은 데이터를 추적하게 된다.

정리하자면, BufferedInputStream은 버퍼링을 통해 디스크 접근 횟수를 최소화하고 메모리에서 데이터를 빠르게 처리하여 입력 성능을 향상시킨다. 그래서 버퍼를 사용한 입출력과 사용하지 않은 입출력간의 성능차이는 상당하기 때문에 대부분의 경우에 버퍼를 이용한 보조스트림을 사용한다.

 

BufferedInputStream 를 포함하여 아래의 보조 스트림들은 FilterInputStream또는 FilterOutputStream의 자손들이다. 그리고 이 두 클래스도 InputStream 또는 OutputStream의 자손들이다.

  • BurfferedInputStream, BurfferedOutputStream: 버퍼를 이용한 입출력 성능 향상
  • DataInputStream, DataOutputStream: int, float와 같은 primitive type로 데이터를 처리하는 기능
  • PrintStream: 버퍼를 이용하며, 추가적인 print 관련 기능
  • 등등

위의 코드와 아래의 FilterInputStream 클래스의 코드를 참고해보자. FilterInputStream는 InputStream을 상속하고, BurfferedInputStream으로부터 전달받은 FileInputStream 객체를 참조한다. 이를 통해, BurfferedInputStream 객체에서 조상 클래스인 FilterInputStream을 통해 FileInputStream의 메서드를 호출하고, BurfferedInputStream 객체는 자신의 추가 기능(버퍼링)을 수행할수 있게 된다.

 

다음은 FilterInputStream의 일부 코드이다. 이전의 FileReadExample 코드에서 보조 스트림인BufferedInputStream은 조상 클래스인 FilterInputStream와 어떻게 상호작용해서 동작하는지 살펴보자.

public class FilterInputStream extends InputStream {  
    protected volatile InputStream in;

    protected FilterInputStream(InputStream in) {  
        this.in = in;  
    }

    @Override  
    public int read() throws IOException {  
        return in.read();  
    }

    @Override  
    public int read(byte[] b) throws IOException {  
        return read(b, 0, b.length);  
    }

    @Override  
    public int read(byte[] b, int off, int len) throws IOException {  
        return in.read(b, off, len);  
    }

    @Override  
    public void close() throws IOException {  
        in.close();  
    }
    //...
}

 

FilterInputStream는 InputStream을 상속하고 BurfferedInputStream의 생성자로부터 전달받은 FileInputStream 객체를 참조한다. 이를 통해, BurfferedInputStream 객체에서 조상 클래스인 FilterInputStream을 통해 FileInputStream의 메서드를 호출하고, BurfferedInputStream 객체는 fill() 메서드로 자신의 추가 기능(버퍼링)을 수행할수 있게 된다.

 

결국, FilterInput/OutputStream는 클래스는 기본 입출력 스트림을 상속하고, 자식 클래스이자 보조 스트림 클래스로부터 전달받은 특정 입출력 대상의 스트림 객체를 참조한다. 그래서 특정 입출력 대상의 스트림의 데이터 read/write 관련된 메서드를 호출하고, 자식 클래스가 추가 기능을 수행한다. 이는 다양한 필터링 기능을 구현할 수 있도록 설계되어 있으므로 FilterInput/OutputStream라고 불린다. 즉, 기본 스트림에 추가 기능을 '데코레이션(Decoration)'할수 있도록 데코레이터 패턴(Decorator Pattern)으로 구현되어 있다.

 

다음은 바이트기반 스트림 클래스들의 의존성을 나타낸것이다. 

 

정리하자면, 보조스트림(Auxiliary Stream)은 기반 스트림(Base Stream)의 기능을 향상 시키거나 새로운 기능을 추가하기위해서 구현된 클래스이다. 보조 스트림은 자체적으로 입출력을 수행할 수 없기때문에 생성자를 통해 전달받은 기반 스트림을 참조해서 특정 I/O 자원을 대상으로 read나 write를 수행할수 있다.


문자기반 스트림 (Reader, Writer)

지금까지 알아본 스트림은 모두 바이트 기반의 스트림이었다. 바이트 기반은 입출력 단위가 1byte인것이다. Java에서는 C언어와 달리 char가 2byte이다. 그러므로 바이트기반 스트림으로 처리하는데 어려움이 있다. 이러한 점을 보안하기 위해 2byte 단위의 입출력하는 문자기반의 스트림이 제공된다.

 

바이트기반 스트림에서 문자 기반 스트림은 다음 문자만 바꾸면 클래스명이 매칭된다. 다만, 이름에서 Byte는 Char로 변경해야한다. 매칭되는 각 클래스별로 사용되는 쓰임새는 같다고 보면된다.

  • InputStream (바이트 기반 입력 스트림) -> Reader (문자 기반 입력 스트림)
  • OutputStream (바이트 기반 출력 스트림) -> Writer (문자 기반 출력 스트림)
입출력 대상 바이트 기반 입출력 스트림 문자 기반 입출력 스트림
파일 FileInputStream, FileOutputStream FileReader, FileWriter
메모리 ByteArrayInputStream, ByteArrayOutputStream CharArrayReader, CharArrayWriter
프로세스 PipedInputStream, PipedOutputStream PipedReader, PipedWriter
오디오 장치: AudioInputStream, AudioOutputStream AudioReader, AudioWriter
문자열 없음  

 

위에서 문자기반 스트림의 의존성을 표현한것이며, 바이트기반 스트림과 유사한것을 알수있다. 

 

아래서 부터는 바이트 기반 스트림과 문자기반 스트림의 메서드 비교이다. 바이트 기반 스트림과 문자기반 스트림의 byte 배열 대신 char 배열을 사용하는 것과 추상메서드가 달라졌다. Reader와 Writer에서도 추상 메서드가 아닌 메서드들은 추상메서드를 이용해서 작성되어 있지만, int read(char[] cbuf, int off, int len)메서드가 추상메서드이다. 결국, 바이트 기반 스트림과 문자 기반 스트림의 메서드 사용 목적과 방식은 거의 동일하다.

InputStream Reader
abstract int read() int read()
int read(byte[] b) int read(char[] cbuf)
int read(byte[] b, int off, int len) abstract int read(char[] cbuf, int off, int len)

 

OutputStream Writer
abstract void write(int b) void write(int c)
void write(byte[] b) void write(char[] cbuf)
void write(byte[] b, int off, int len) abstract void write(char[] cbuf, int off, int len)
  void write(String str)
  void write(String str, int off, int len)

 

보조스트림도 문자 기반 보조스트림이 존재하며, 사용 목적과 방식은 바이트 기반 스트림과 같다.

바이트 기반 보조 스트림 문자 기반 보조 스트림
FilterInputStream, FilterOutputStream FilterWriter, FilterWriter
BufferedInputStream, BufferedOutputStream BufferedReader, BufferedWriter
PrintStream PrintWriter

챰고 자료

  • 자바의 정석 (남궁성 지음)

'Java > Java Language' 카테고리의 다른 글

[Java] 바이트 기반의 보조 스트림  (0) 2024.09.19
[Java] Byte Stream의 사용법  (0) 2024.09.17
[Java] Single Thread와 Multi Thread  (0) 2024.09.13
[Java] Thread 구현과 실행  (0) 2024.09.12
[Java] Process와 Thread  (2) 2024.09.10