Java 길찾기/Java의 정석

[Java] 입출력 I/O - 바이트기반 스트림

Kindbeeeear_ 2022. 7. 11. 18:09

InputStream과 OutputStream

앞서 얘기한 바와 같이 InputStream과 OutputStream은 모든 바이트기반의 스트림의 조상이며 다음과 같은 메서드가 선언되어 있다.

 

InputStream의 메서드

메서드명 설명
int available() 스트림으로부터 읽어 올 수 있는 데이터의 크기를 반환한다.
void close() 스트림을 닫음으로써 사용하고 있던 자원을 반환한다.
void mark(int readlimit) 현재위치를 표시해 놓는다. 후에 reset()에 의해서 표시해 놓은 위치로 다시 돌아갈 수 있다. readlimit은 되돌아갈 수 있는 byte의 수이다.
boolean markSupported() mark()와 reset()을 지원하는지를 알려준다. mark()와 reset()기능을 지원하는 것은 선택적이므로, mark()와 reset()을 사용하기 전에 markSupported()를 호출해서 지원여부를 확인해야 한다.
abstract int read() 1 byte를 읽어온다.(0~255 사이의 값). 더 이상 읽어올 데이터가 없으면 -1을 반환한다. abstract메서드라서 InputStream의 자손들은 자신의 상황에 알맞게 구현해야한다.
int read(byte[] b) 배열 b의 크기만큼 읽어서 배열을 채우고 읽어 온 데이터의 수를 반환한다. 반환하는 값은 항상 배열의 크기보다 작거나 같다.
int read(byte[] b, int off, int len) 최대 len개의 byte를 읽어서, 배열 b의 지정된 위치(off)부터 저장한다. 실제로 읽어 올 수 있는 데이터가 len개보다 적을 수 있다.
void reset() 스트림에서 위치를 마지막으로 mark()이 호출되었던 위치로 되돌린다.
long skip(long n) 스트림에서 주어진 길이(n)만큼을 건너뛴다.

 

OutputStream의 메서드

메서드명 설명
void close() 입력소스를 닫음으로써 사용하고 있던 자원을 반환한다.
void flush() 스트림의 버퍼에 있는 모든 내용을 출력소스에 쓴다.
abstract void write(int b) 주어진 값을 출력소스에 쓴다.
void write(byte[] b) 주어진 배열 b에 저장된 모든 내용을 출력소스에 쓴다.
void write(byte[] b, int off, int len) 주어진 배열 b에 저장된 내용 중에서 off번째부터 len개 만큼을 읽어서 출력소스에 쓴다.

 

스트림의 종류에 따라서 mark()와 reset()을 사용하여 이미 읽은 데이터를 되돌려서 다시 읽을 수 있다. 이 기능을 지원하는 스트림인지 확인하는 markSupported()를 통해서 알 수 있다.

flush()는 버퍼가 있는 출력스트림의 경우에만 의미가 있으며, OutputStream에 정의도니 flush()는 아무런 일도 하지 않는다.

프로그램이 종료될 때, 사용하고 닫지 않은 스트림을 JVM이 자동으로 닫아 주기는 하지만, 스트림을 사용해서 모든 작업을 마치고 난 후에는 close()를 호출해서 반드시 닫아 주어야 한다. 그러나 ByteArrayInputStream과 같이 메모리를 사용하는 스트림과 System.in, System.out과 같은 표준 입출력 스트림은 닫아 주지 않아도 된다.

 

ByteArrayInputStream과 ByteArrayOutputStream

ByteArrayInputStream과 ByteArrayOutputStream은 메모리, 즉 바이트배열에 데이터를 입출력 하는데 사용되는 스트림이다. 주로 다른 곳에 입출력하기 전에 데이터를 임시로 바이트배열에 담아서 변환 등의 작업을 하는데 사용된다.

자주 사용되지 않지만 스트림을 이용한 입출력방법을 보여주는 예제를 작성하기에는 적합해서, 이 스트림을 이용해서 읽고 쓰는 여러 바업을 보여주는 예제들을 작성해 보았다.

|참고| 스트림의 종류가 달라도 읽고 쓰는 방법은 동일하므로 이 에제들을 통해서 스트림에 읽고 쓰는 방법을 잘 익혀두기 바란다.

 

import java.io.*;
import java.util.Arrays;

class IOEx1 {
    public static void main(String[] args) {
        byte[] inSrc = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
        byte[] outSrc = null;
        
        ByteArrayInputStream input = null;
        ByteArrayOutputStream output = null;
        
        input = new ByteArrayInputStream(inSrc);
        output = new ByteArrayOutputStream();
        
        int data = 0;
        while((data = input.read())!=-1){
            output.write(data); // void write(int b) 
        }
        
        outSrc = output.toByteArray(); // 스트림의 내용을 byet배열로 변환한다.
        System.out.println("Input Source : " + Arrays.toString(inSrc));
        System.out.println("Output Soutce : " + Arrays.toString(outSrc));
    }
}

ByteArrayInputStream / ByteArrayOutputStream을 이용해서 바이트배열 inSrc의 데이터를 outSrc로 복사하는 예제인데, read()와  write()를 사용하는 가장 기본적인 방법을 보여준다. while문의 조건식이 조금 복잡한데, 이 조건식은 아래와 같은 순서로 처리된다.

더보기

(data = input.read()) != -1

1. data = input.read()  // read()를 호출한 반환값을 변수 data에 저장한다.(괄호 먼저)

2. data != -1  // data에 저장된 값이 -1d이 아닌지 비교한다.

 

바이트 배열은 사용하는 자원이 메모리 밖에 없으므로 가비지컬렉터에 의해 자동적으로 자원을 반환하므로 close()를 이용해서 스트림을 닫지 않아도 된다. read() 와 write(int b)를 사용하기 때문에 한 번에 1byte만 읽고 쓰므로 작업효율이 떨어진다.

다음 예제는 배열을 사용해서 입출력 작업이 보다 효율적으로 이루어지도록 했다.

import java.io.*;
import java.util.Arrays;

class IOEx2 {
    public static void main(String[] args) {
        byte[] inSrc = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
        byte[] outSrc = null;
        byte[] temp = new byte[10];

        ByteArrayInputStream input = null;
        ByteArrayOutputStream output = null;

        input = new ByteArrayInputStream(inSrc);
        output = new ByteArrayOutputStream();

        input.read(temp, 0, temp.length); // 읽어 온 데이터를 배열 temp에 담는다.
        output.write(temp, 5, 5); // temp[5]부터 5개의 데이터를 write 한다.
        
        outSrc = output.toByteArray();
        System.out.println("Input Source : " + Arrays.toString(inSrc));
        System.out.println("temp : " + Arrays.toString(temp));
        System.out.println("Output Soutce : " + Arrays.toString(outSrc));
    }
}

int read(byte[] b, int off, int len)와 void write(byte[] b, int off, int len)을 사용해서 입출력하는 방법을 보여주는 예제이다. 이전 예제와는 달리 byte 배열을 사용해서 한 번에 배열의 크기만큼 읽고 쓸 수 있다. 바구니(배열 temp)를 이용하면 한 번에 더 많은 물건을 옮길 수 있는 것과 같다고 이해하면 좋을 것이다.

byte 배열 temp의 크기(temp.length)가 10이라서 10 byte를 읽어왔지만 output에 출력할 때는 temp[5]부터 5 byte만 출력하였다.

배열을 이용한 입출력은 작업의 효율을 증가시키므로 가능하면 입출력 대상에 따라 알맞은 크기의 배열을 사용하는 것이 좋다.

 

import java.io.*;
import java.util.Arrays;

class IOEx3 {
    public static void main(String[] args) {
        byte[] inSrc = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
        byte[] outSrc = null;
        byte[] temp = new byte[4]; // 이전 예제와 배열의 크기가 다르다.

        ByteArrayInputStream input = null;
        ByteArrayOutputStream output = null;

        input = new ByteArrayInputStream(inSrc);
        output = new ByteArrayOutputStream();
        
        System.out.println("Input Source : " + Arrays.toString(inSrc));

        try {
            while(input.available() > 0) {
                input.read(temp);
                output.write(temp);
                // System.out.println("temp : " + Arrays.toString(inSrc));
                
                outSrc = output.toByteArray();
                printArrays(temp, outSrc);
            }
        } catch(IOException e) {}
        
        static void printArrays(byte[] temp, byte[] outSrc) {
            System.out.println("temp : " + Arrays.toString(temp));
            System.out.println("Output Soutce : " + Arrays.toString(outSrc));
        }
    }
}

read()나 write()가 IOException을 발생시킬 수 있기 때문에 try-catch문으로 감싸주었다. available()은 블락킹(blocking)없이 읽어 올 수 있는 바이트의 수를 반환한다.

아마도 예상과 다른 결과가 나왔을 텐데 그 이유는 마지막에 읽은 배열의 9번째와 10번째 요소값인 8과 9만을 출력해야 하는데 temp에 남아 있던 6, 7까지 출력했기 때문이다.

보다 나은 성능을 위해 temp에 담긴 내용을 지우고 쓰는 것이 아니라 그냥 기존의 내용 위에 덮어쓴다. 그래서 temp의 내용은 '[4, 5, 6, 7]' 이었는데 8과 9를 읽고 난 후에는 '[8, 9, 6, 7]'이 된다.

원하는 결과를 얻기 위해 배열의 내용전체를 출력하지 않고 읽어온 만큼만 출력하면 된다.

|참고| 블락킹이란 데이터를 읽어 올 때 데이터를 기다리기 위해 멈춰있는 것을 뜻한다. 예를 들어 사용자가 데이터를 입력하기 전까지 기다리고 있을 때 블락킹 상태라고 한다.

 

import java.io.*;
import java.util.Arrays;

class IOEx3 {
    public static void main(String[] args) {
        byte[] inSrc = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
        byte[] outSrc = null;
        byte[] temp = new byte[4]; // 이전 예제와 배열의 크기가 다르다.

        ByteArrayInputStream input = null;
        ByteArrayOutputStream output = null;

        input = new ByteArrayInputStream(inSrc);
        output = new ByteArrayOutputStream();

        System.out.println("Input Source : " + Arrays.toString(inSrc));

        try {
            while(input.available() > 0) {
                int len = input.read(temp); // 읽어 온 데이터의 개수를 반환한다.
                output.write(temp, 0, len); // 읽어 온 만큼만 write 한다.
            }
        } catch(IOException e) {}
        
        outSrc =output.toByteArray();
        System.out.println("temp : " + Arrays.toString(inSrc));
        System.out.println("temp : " + Arrays.toString(temp));
        System.out.println("Output Soutce : " + Arrays.toString(outSrc));
    }
}

이전 예제의 문제점을 수정한 예제이다. 출력할 때, temp에 저장된 모든 내용을 출력하는 대신 값을 읽어 온 만큼만 출력하도록 변경하였다. 그래서 이전 예제와 달리 올바른 결과를 얻은 것을 확인할 수 있다.

 

FileInputStream과 FileOutputStream

FileInputStream과 FileOutputStream은 파일에 입출력을 하기 위한 스트림이다. 실제 프로그래밍에서 많이 사용되는 스트림 중의 하나이다.

생성자 설명
FileInputStream(String name) 지정된 파일이름(name)을 가진 실제 파일과 연결된 FileInputStream을 생성한다.
FileInputStream(File file) 파일의 이름이 String이 아닌 File 인스턴스로 지정해주어야 하는 점을 제외하고 FileInputStream(String name)과 같다.
FileInputStream(FileDescriptor fdObj) 파일 디스트립터(fdObj)로 FileInputStream을 생성한다.
FileOutputStream(String name) 지정된 파일이름(name)을 가진 실제 파일과 연결된 FileOutputStream을 생성한다.
FileOutputStream(String name, boolean append) 지정된 파일이름(name)을 가진 실제 파일과 연결된 FileOutputStream을 생성한다.두번째 인자인 append를 true로 하면, 출력 시 기존의 파일내용의 마지막에 덧붙인다, false면, 기존의 파일내용을 덮어쓰게 된다.
FileOutputStream(File file) 파일의 이름을 String이 아닌 File인스턴스로 지정해주어야 하는 점을 제외하고 FileOutputStream(String name)과 같다.
FileOutputStream(File file, boolean append) 파일의 이름을 String이 아닌 File 인스턴스로 지정해주어야 하는 점을 제외하고 FileOutputStream(String name, boolean append)와 같다.
FileOutputStream(FileDescriptor fdObj) 파일 디스크립터(fdObj)로 FileObjectStream을 생성한다.

 

import java.io.*;

class FileViewer {
    public static void main(String[] args) {
        FileInputStream fis = new FileInputStream(args[0]);
        int data = 0;
        
        while((data = fis.read()) ! = -1) {
            char c = (char) data;
            System.out.print(c);
        }
    }
}

커멘드라인으로부터 입력받은 파일의 내용을 읽어서 그대로 화면에 출력하는 간단한 예제이다. read()의 반환값이 int형(4 byte)이긴 하지만, 더 이상 입력값이 없음을 알리는 -1을 제외하고는 0~255(1 byte)범위의 정수이기 때문에, char형(2 byte)으로 변환한다 해도 손실되는 값은 없다.

read()가 한 번에 1 byte씩 파일로부터 데이터를 읽어 들이긴 하지만, 데이터의 범위가 십진수로 0~255범위의 정수값이고, 또 읽을 수 있는 입력값이 더 이상 없음을 알릴 수 있는 값 -1 도 필요하다. 그래서 다소 크긴 하지만 정수형 중에서는 연산이 가장 효율적이고 빠른 int 형 값을 반환하도록 한 것이다.

 

import java.io.*;

class FileCopy {
    public static void main(String[] args) {
        try {
            FileInputStream fis = new FileInputStream(args[0]);
            FileOuputStream fos = new FileOutputStream(args[1]);
            
            int data = 0;
            while((data = fis.read()) != -1) {
                fos.write(data); // void write(int b)
            }
            
            fis.close();
            fos.close();
        } catch (IOException e) {   
            e.printStackTrace();
        }
    }
}

FileInputStream과 FileOutputStream을 사용해서 FileCopy.java 파일의 내용을 그래도 FileCopy.bak로 복사하는 일을 한다.

단순히 FileCopy.java의 내용을 read()로 읽어서, write(int b)로 FileCopy.bak에 출력한다. 이처럼 텍스트파일을 다루는 경우에는 FileInputStream과 FileOutputStream보다 문자기반의 스트림인 FileReader와 FileWriter를 사용하는 것이 더 좋다.