https://recipes4dev.tistory.com/109


1. 파일 입출력 기본.

파일(File)은 컴퓨터에서 실행되는 응용 프로그램(Application Program)이 사용하는 데이터를 저장하기 위한 논리적 단위를 말합니다. 컴퓨터에 장착된(또는 연결된) 저장 장치에, 운영체제(OS)가 인식 가능한 파일 시스템(파일 관리를 위한 논리적 구조)이 설치되어 있다면, 프로그램에서 파일 시스템을 통해 파일을 읽고 쓰는 것이 가능합니다. 참고로, 파일을 읽고 쓰는 응용 프로그램 자체도 파일 시스템의 파일로 저장되어 있으며, 컴퓨터의 운영체제(OS)에 의해 읽혀진 다음, 실행되는 것이죠.


컴퓨터 시스템의 발전에 따라 많은 종류의 저장 장치(Flopply Disk, HDD, SSD, ...)가 개발되고, 다양한 파일 시스템(FAT, NTFS, EXT4, ...)이 고안되었지만, 파일에 데이터를 읽고 쓰는 과정은 크게 변하지 않았습니다. (마치... 수많은 빵집 브랜드가 생기고, 다양한 종류의 빵이 개발되었지만, 빵을 사먹는 과정은 거의 변하지 않은 것 처럼 말이죠.)


파일을 읽고 쓰는 과정을 간단히 요약하면 다음과 같습니다.

일반적인 파일 입출력 과정


파일을 읽고 쓸 때 가장 먼저 해야 할 일은, 읽고 쓰기를 수행할 파일을 여는(Open) 것입니다. 그리고 파일을 여는 과정에서 리턴되는 파일 핸들(Handle)을 확보하는 것입니다. 파일 핸들(Handle)이란, 파일을 읽고 쓰기 위해, 시스템에서 관리되는 파일 정보에 대한 참조를 말합니다. 프로그램이 연(Open) 파일에 대한 경로, 크기, 읽기 또는 쓰기 모드, 현재 입출력 위치 등의 파일 관리 정보를 운영체제(OS)가 만들어 관리하고, 그 정보를 핸들(Handle)이라는 참조 형태로 프로그램에 돌려주는 것입니다.

여기서 핸들(Handle)이라는 용어는 윈도우즈 프로그래밍에서 참조한 것이며, 리눅스 프로그래밍에서는 파일 기술자(File Descriptor) 라는 용어를 사용합니다. 개념 이해를 위해 참조한 용어이므로, 혼동 없으시길 바랍니다.

파일 핸들


일단 파일 핸들(Handle)을 확보하면, 파일 읽기(Read) API를 이용하여 파일의 내용을 읽거나, 파일 쓰기(Write) API를 이용하여 파일에 데이터를 쓸 수 있습니다.


파일에 데이터를 읽거나 쓰는 과정이 완료되면, 마지막으로 해야할 일은 파일을 닫는(Close) 것입니다. 파일을 닫는다(Close)는 것은, 파일을 열 때 확보한 파일 핸들(Handle)을 반환하여, 운영체제(OS)가 가지고 있는 파일 입출력 정보를 삭제한다는 것을 의미합니다. 그러므로 파일을 닫는(Close) 과정을 수행하지 않으면, 운영체제(OS)에서 관리하는 파일 입출력 정보가 남게 되어 시스템 동작에 심각한 문제를 초래할 수 있습니다.


이 모든 과정이 끝나면, 더 이상 해당 파일을 읽고 쓰는 것은  불가능합니다. 만약 다시 파일을 읽거나 쓰고자 한다면, 해당 파일을 다시 열어(Open) 파일 핸들을 확보해야만 합니다.

1.1 파일 입출력 에러 처리

파일 입출력 API를 사용하여 파일을 읽고 쓸 때, 가장 주의해야 할 점은, 파일을 읽고 쓰는 과정에서 언제든 에러가 발생할 수 있다는 것입니다. 그래서 API 함수의 리턴 값을 확인하여 입출력 결과가 정상인지를 확인하고, 만약 에러가 발생했다면, 에러 종류에 따라 입출력을 중단하고 에러에 대한 처리를 수행해야 합니다.


안드로이드에서 Java 입출력 API를 사용할 때도 마찬가지로 에러에 대한 처리는 필수적으로 수행해줘야 합니다. Java에서 파일 입출력 에러는 예외(Exception) 처리를 통해 수행합니다.

    try {
        // Java 파일 입출력
    } catch (Exception e) {
        // 파일 입출력 에러에 따른 예외 처리.
    }

2. File 클래스

본격적으로 파일 입출력 방법을 설명하기에 앞서, 파일과 그 파일의 디렉토리 경로에 대한 정보를 다루는 File(java.io.File) 클래스에 대해 간단히 살펴보겠습니다.

File 클래스


안드로이드에서 파일에 기록된 내용을 읽거나 파일에 새로운 내용을 쓸 때는, 뒤에서 설명할, Java API에서 제공하는 파일 Stream 관련 API 들을 사용하게 됩니다. 즉, 파일에 들어 있는 내용(contents)을 처리하기 위한 기능들이 파일 Stream 관련 API를 통해 제공되는 것이죠. 하지만, 파일의 이름, 경로 또는 속성과 같은 파일 자체의 정보를 다루거나, 파일 존재 여부 확인, 빈 파일 생성, 기존 파일 삭제 등의 파일 데이터의 외적인 기능이 필요할 때는 파일 Stream 관련 API가 아닌 별도의 API가 사용됩니다. File 클래스가 바로, 그런 역할을 수행하는 클래스입니다.


File 클래스를 사용하여 파일 또는 디렉토리를 다루는 방법은 간단합니다. 파일을 가리키는 경로(절대 경로 또는 상대 경로) 문자열을 지정하여 File 클래스의 객체를 생성한 다음, 원하는 기능을 수행하는 메소드를 호출하기만 하면 됩니다.


아래의 코드는 특정 경로의 파일을 삭제하는 예제 코드입니다.

    File file = new File("file.dat") ;

    file.delete() ;

File 클래스를 사용할 때 주의해야 할 한 가지는, File 클래스 객체가 가리키는 정보가 파일 또는 디렉토리에 대한 경로명일 뿐, 그 파일 자체 또는 파일의 내용(contents)은 아니라는 것입니다. 즉, 위의 코드에서, File 클래스 생성자를 호출한다고 해서 해당 경로에 파일 생성되는 것은 아니라는 것이죠. File 클래스의 객체는 단지, 문자열로 이루어진 파일 및 경로를 저장하는 것입니다.


일단 File 클래스 객체가 만들어지면, File 클래스의 메소드를 통해 파일을 처리하는 기능을 수행할 수 있습니다. File 클래스가 제공하는 몇 가지 중요 기능과 관련 메소드는 아래 표에서 확인할 수 있습니다.

메소드 이름기능
boolean canWrite()File 클래스 객체가 가리키는 경로의 파일이 쓰기 가능한지 검사.
int compareTo()File 클래스 객체 간 절대 경로 이름 비교.
boolean createNewFile()File 클래스 객체가 가리키는 경로에 새로운 빈 파일 생성.
boolean delete()File 클래스 객체가 가리키는 경로의 파일을 삭제.
boolean exists()File 클래스 객체가 가리키는 경로에 파일이 존재하는지 검사.
File getAbsoluteFile()File 클래스 객체가 가리키는 파일의 절대 경로 리턴.
String getName()File 클래스 객체가 가리키는 파일 또는 디렉토리 이름 리턴.
String getParent()File 클래스 객체가 가리키는 파일의 부모 경로 이름 리턴.
boolean isDirectory()File 클래스 객체가 가리키는 대상이 디렉토리인지 검사.
boolean isFile()File 클래스 객체가 가리키는 대상이 파일인지 검사.
String[] list()File 클래스 객체가 가리키는 디렉토리 내의 모든 파일과 디렉토리 리스트 리턴
boolean mkdir()File 클래스 객체가 가리키는 경로에 디렉토리 생성.
String toString()File 클래스 객체가 가리키는 경로를 문자열로 리턴.
URI toURI()File 클래스 객체가 가리키는 경로를 URI 형식으로 리턴.

2.1 File 클래스와 파일 입출력.

파일 입출력 방법에 대한 설명에 앞서 File 클래스를 먼저 언급한 이유는, File 클래스를 사용하면 파일 입출력 작업을 수행하기 전에 파일의 존재 여부 및 속성 정보를 미리 검사하여, 파일 입출력 과정에서 발생할 수 있는 여러 가지 에러 상황을 예방할 수 있기 때문입니다.


예를 들어, 파일의 내용을 읽어서 화면에 표시하는 기능을 구현한다고 가정해보죠. 만약, 읽으려고 하는 파일이 존재하지 않는다면, 파일을 여는(open) 코드는 Exception을 발생시킬 것입니다. 이 때 File 클래스를 사용하여 파일의 존재여부를 확인한 다음, 파일이 있을 때만 파일을 열면, Exception이 발생하는 것을 피할 수 있죠.

    File file = new File("file.in") ;

    if (file.exists()) { // "file.in" 파일이 존재한다면, 
        // TODO : 파일 읽기.
    }

또 다른 예로, 파일을 쓰는 경우를 생각해보죠. 안드로이드 시스템은 아주 복잡한 디렉토리와 많은 수의 파일로 구성되어 있습니다. 이 중 일부는 시스템 운영에 핵심이 되는 정보들을 저장하고 있기 때문에, 아무 곳에서나 파일의 내용을 수정하게 되면 시스템 동작에 심각한 문제를 일으킬 수 있습니다. 그래서 일반적인 앱에서는 시스템에서 관리되는 디렉토리에 파일을 읽을 수만 있고 쓰지는 못하게 되어있죠. 만약 이러한 디렉토리에 파일을 생성하려고 하면, 당연히 Exception이 발생할 것입니다. 이런 경우, File 클래스를 사용하여 파일이 쓰기 가능한지를 먼저 확인하고, 가능한 경우에만 파일의 내용을 수정하는 코드를 작성할 수 있습니다.

    File file = new File("file.out") ;

    if (file.canWrite()) { // "file.out" 파일이 쓰기 가능하다면,
        // TODO : 파일 쓰기
    }

File 클래스가 파일을 다루는 여러 가지 유용한 기능을 제공하지만, 파일과 관련된 모든 곳에서 필수적으로 사용해야 하는 것은 아닙니다. 어떤 경우에는 File 클래스를 전혀 사용하지 않아도, 아무런 불편함이 없는 경우도 있습니다.

하지만 File 클래스를 사용하면 파일 이름 및 경로 관리가 편해지고, 파일의 여러 속성을 검사할 수 있기 때문에, 될 수 있으면 File 클래스를 사용하여 파일을 관리하시기 바랍니다. (String 변수로 파일 이름과 경로를 관리하는 것보다 훨씬 편리합니다.)

3. 안드로이드(Java)에서의 파일 입출력. (FileOutputStream, FileInputStream)

안드로이드에서 파일을 읽고 쓰는 방법도, 본문의 첫 부분에서 설명한 파일 입출력 기본 과정이 그대로 적용됩니다. 단, 안드로이드 자체적인 파일 입출력 API가 별도로 존재하는 것은 아닙니다. 대신, 안드로이드가 Java언어를 사용함에따라, Java SDK에서 제공하는 기본적인 파일 입출력 API가 사용됩니다.


Java에서 파일을 다루기 위해 사용되는 클래스들은 "java.io" 패키지에 구현되어 있습니다. java.io 패키지의 클래스 중 파일 읽기에 사용되는 클래스는 FileInputStream이고 파일 쓰기에 사용되는 클래스는 FileOutputStream 클래스입니다.


먼저 FileOutputStream 클래스를 사용하여, 파일에 바이너리 데이터를 쓰는 방법부터 살펴보겠습니다.

3.1 파일에 바이너리(binary) 데이터 쓰기. (FileOutputStream)

FileOutputStream 클래스를 사용하여, 파일에 바이너리 데이터를 쓰는 방법은 다음과 같은 과정을 거칩니다.

FileOutputStream 파일 쓰기 과정

여기서 가장 중요한 과정은 데이터를 쓸 파일을 생성하는 것입니다. 이는 FileOutputStream 클래스의 객체를 생성하는 것으로 이루어지며, FileOutputStream의 생성자에 생성할 파일의 정보가 전달됩니다. 여기서는 File 클래스 객체를 전달했지만, String 타입의 문자열 파일 경로를 직접 전달할 수도 있습니다.

FileOutputStream 클래스로 파일에 바이너리(binary) 데이터 쓰기
    File file = new File("file.bin") ;
    FileOutputStream fos = null ;
    byte[] buf = new byte[5] ;

    // prepare data.
    buf[0] = 0x01 ;
    buf[1] = 0x02 ;
    buf[2] = 0x03 ;
    buf[3] = 0x04 ;
    buf[4] = 0x05 ;

    try {
        // open file.
        fos = new FileOutputStream(file) ;  // fos = new FileOutputStream("file.bin") ;

        // write file.
        fos.write(buf) ;

    } catch (Exception e) {
        e.printStackTrace() ;
    }

    // close file.
    if (fos != null) {
        // catch Exception here or throw.
        try {
            fos.close() ;
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

3.2 바이너리(binary) 파일 읽기. (FileInputStream)

Java에서 파일의 데이터를 읽기 위해 FileInputStream 클래스를 사용하는 과정은 아래와 같습니다.

FileInputStream 파일 읽기 과정

FileInputStream 클래스를 사용하는 과정 또한 FileOutputStream과 유사합니다.

FileInputStream 클래스를 사용하여 파일에서 바이너리(binary) 데이터 읽기. (1 Byte 단위)
    File file = new File("file.bin") ;
    FileInputStream fis = null ;
    int data = 0 ;

    if (file.exists() && file.canRead()) {
        try {
            // open file.
            fis = new FileInputStream(file) ;

            // read file.
            while ((data = fis.read()) != -1) {
                // TODO : use data
                System.out.println("data : " + data) ;
            }

            // close file.
            fis.close() ;
        } catch (Exception e) {
            e.printStackTrace() ;
        }
    }

위에서 작성한 코드는 파일("file.bin")을 연 다음, 파일의 끝에 도착할 때까지 루프를 돌며 1 Byte 씩 데이터를 읽도록 작성되어 있습니다. 하지만 이렇게 1 Byte 씩 읽어서 처리하는 방법은 기능 동작 관점에서 보면 큰 문제가 없지만, 성능 관점으로 봤을 때는 심각한 성능 저하를 유발할 수 있습니다. 이는 시스템이 저장 장치 I/O를 처리하는 방법과 성능에 관한 이슈 때문인데요, 아주 간단하게 설명하자면 이렇습니다.


저장 장치(SSD,HDD,USB 등등)로부터 데이터를 읽어들이거나, 쓰는 것은 시스템 관점에서 보자면 매우 많은 시간이 소요되는 작업입니다. 아주 빠르게 수행되는 CPU 연산 또는 메인 메모리(RAM) I/O에 비해, 저장 장치로의 I/O는 H/W적인 인터페이스 특성 상, 많은 시간이 소요될 수 밖에 없는 것이죠. 그래서 저장 장치에 대한 I/O는, 장치의 특성 및 시스템 I/O 모듈의 최적화에 따라, 한번에 많은(256B, 512B, 1K, 2K 또는 그 이상) 데이터를 읽거나 쓰도록 구현되어 있습니다. 즉, 1 Byte의 데이터를 파일로부터 읽더라도 시스템 I/O 모듈에서는 훨씬 많은 데이터를 읽어들이게 되고, read() 함수의 호출을 통해 전달할 1 Byte 외의 데이터는 버리게 되는 것이죠. (물론, 디스크 캐싱(cashing) 기법 등을 사용해서 H/W적인 I/O는 줄이더라도, 시스템 I/O 모듈에 대한 접근으로 인해 시간 비용은 증가할 수 밖에 없습니다.)

파일 I/O 성능 이슈


그래서 일반적으로, 파일에서 데이터를 읽는 코드를 작성할 때는 1 Byte 씩 읽지 않고, 충분한 크기의 버퍼 만큼 한번에 읽어서 처리하도록 구현합니다. 이를 위해 FileInputStream의 또 다른 read() 함수를 사용합니다.

    int read(byte[] b) ; // 배열 크기(b.length) 만큼의 데이터를 읽음. 

위의 read() 함수는 파일로부터 배열 b의 크기(b.length)만큼 데이터를 읽은 다음, 읽은 데이터의 크기를 리턴합니다. 만약 파일의 끝까지 읽어들여서 더 이상 읽을 데이터가 없다면, -1이 리턴됩니다. 배열 읽기를 사용하는 read() 함수를 사용하는 코드는 아래와 같습니다.

FileInputStream 클래스를 사용하여 파일에서 바이너리(binary) 데이터 읽기. (버퍼 크기 단위로 읽기)
    File file = new File("file.bin") ;
    FileInputStream fis = null ;
    byte[] buf = new byte[512] ;
    int size = 0 ;

    if (file.exists() && file.canRead()) {
        try {
            // open file.
            fis = new FileInputStream(file) ;

            // read file.
            while ((size = fis.read(buf)) != -1) {
                // TODO : use data
                System.out.println("read size : " + size) ;
                for (int i=0; i<size; i++) {
                    System.out.println("data : " + buf[i]) ;
                }
            }

            // close file.
            fis.close() ;
        } catch (Exception e) {
            e.printStackTrace() ;
        }
    }

위의 예제 코드는 한번에 최대 512 Bytes 크기(buf.length)만큼 데이터를 읽어서 처리하는 방법입니다.

4. 버퍼를 사용한 향상된 파일 입출력. (BufferedOutputStream, BufferedInputStream)

위에서 잠시 언급한대로, 파일 입출력에 소요되는 시간의 대부분은 실제 저장장치로부터 파일의 내용을 읽거나 쓰는 부분이 차지합니다. 이 말은 곧, 저장장치에서 파일을 읽고 쓰는 속도를 좀 더 빠르게 만들거나, 같은 양의 데이터를 처리하더라도 저장장치에 읽고 쓰는 횟수를 줄일 수 있다면, 파일 입출력에 소요되는 시간을 줄일 수 있다는 것을 의미하지요. 하지만 전자(입출력 속도 증가)는 주로 H/W의 성능에 영향을 받기 때문에, S/W 개발자는 후자(입출력 횟수 줄이기)의 방법을 선택하여 입출력 성능을 향상시킬 수 있습니다.


바로 위의 FileInputStream 예제 코드에서, 입출력 횟수를 줄일 수 있는 방법의 하나로 개발자가 정한 임의의 버퍼 크기만큼 파일의 내용을 읽는 코드를 살펴보았지만, 버퍼 관리를 개발자가 직접 해줘야 하는 번거로움이 있습니다. Java에서는 이런 번거로움을 해소할 수 있도록, 입출력 과정을 자체적인 버퍼링(Buffering)을 통해 수행하는 클래스를 제공합니다. 바로 BufferedOutputStream, BufferedInputStream 클래스입니다.

BufferedOutputStream, BufferedInputStream 클래스


BufferedOutputStream과 BufferedInputStream는 "Buffered"라는 prefix가 말해주듯, OutputStream과 InputStream에 버퍼 관리 기능이 추가된 클래스입니다.

BufferedOutputStream, BufferedInputStream 생성자


4.1 버퍼를 이용한 파일 쓰기. (BufferedOutputStream)

BufferedOutputStream 클래스를 사용하여 파일에 데이터를 쓰는 방법은 FileOutputStream을 사용하는 것과 크게 다르지 않습니다. BufferedOutputStream 클래스의 객체를 생성한 다음, write() 함수를 호출하여 데이터를 쓰기만 하면 되죠.


하지만 BufferedOutputStream 내부적으로 실질적인 파일 쓰기 동작은 다르게 수행됩니다. FileOutputStream의 경우, write() 함수를 호출하는 순간 파일 쓰기 동작이 수행되는 반면, BufferedOutputStream에서는 write() 함수를 호출한다고 해서 파일에 바로 쓰지 않고, 일단 내부에 있는 버퍼에 데이터를 옮깁니다. 그리고 파일에 쓰기 조건(버퍼가 가득참, flush() 함수 호출, close() 함수 호출)이 되면, 그 때서야 버퍼의 데이터가 파일에 쓰여지는 것입니다. (그런데 write() 함수로 전달되는 데이터의 크기가 내부의 버퍼 크기보다 크다면, 내부 버퍼로 복사되지 않고 바로 파일에 쓰여집니다.)

BufferedOutputStream 파일 쓰기 과정



아래는 BufferedOutputStream을 사용한 파일 쓰기 예제입니다.

BufferedOutputStream 클래스를 사용하여 파일에 바이너리(binary) 데이터 쓰기.
    File file = new File("file.bin") ;
    FileOutputStream fos = null ;
    BufferedOutputStream bufos = null ;

    byte[] buf = new byte[512] ;
    int size = 0 ;

    try {
        // open file.
        fos = new FileOutputStream(file) ;
        bufos = new BufferedOutputStream(fos) ;

        // write file.
        bufos.write(buf) ;

    } catch (Exception e) {
        e.printStackTrace() ;
    } 

    // close file.
    try {
        if (bufos != null)
            bufos.close() ;

        if (fos != null)
            fos.close() ;
    } catch (Exception e) {
        e.printStackTrace();
    }

4.2 버퍼를 이용한 파일 읽기. (BufferedInputStream)

버퍼를 이용하여 데이터를 읽을 때는 BufferedInputStream 클래스를 사용합니다. BufferedInputStream 클래스 또한 BufferedOutputStream과 마찬가지로 내부에서 관리되는 충분한 크기의 버퍼를 통해 데이터를 읽어들입니다. 그래서 BufferedOutputStream 클래스의 read() 함수를 호출하더라도 매번 저장 장치의 파일에서 데이터를 직접 읽지 않고, 내부 버퍼로부터 데이터를 전달받습니다.

BufferedInputStream 파일 쓰기 과정



아래의 소스 코드는 BufferedInputStream을 사용한 파일 읽기 예제입니다.

BufferedInputStream을 클래스를 사용하여 파일에서 바이너리(binary) 데이터 읽기.
    File file = new File("file.bin") ;
    FileInputStream fis = null ;
    BufferedInputStream bufis = null ;
    int data = 0 ;

    if (file.exists() && file.canRead()) {
        try {
            // open file.
            fis = new FileInputStream(file) ;
            bufis = new BufferedInputStream(fis) ;

            // read data from bufis's buffer.
            while ((data = bufis.read()) != -1) {
                // TODO : use data
                System.out.println("data : " + data) ;
            }

            // close file.
            bufis.close() ;
            fis.close() ;
        } catch (Exception e) {
            e.printStackTrace() ;
        }
    }

위의 예제 코드를 보면 BufferedInputStream 클래스의 read() 함수를 호출하여 1 byte 단위의 데이터를 읽어들이는 것을 확인할 수 있습니다. 하지만 FileInputStream의 read() 함수를 호출할 때와는 다르게 심각한 속도 저하가 나타나지 않습니다. 왜냐하면 BufferedInputStream 내부에서 버퍼 관리를 알아서 처리해주기 때문이죠.

5. 어떤 방법을 사용해야 하는가?

앞서 설명한 방법들 중에서 어떤 방법을 사용해야 하는지, 어떻게 코드를 작성해야 하는지는 사실, 파일에서 읽고 쓰는 데이터의 특성이나 크기에 따라 선택하면 됩니다. 정확한 해답이 정해진 것은 아니죠.


하지만 일반적인 경우라면, "4. 버퍼를 사용한 향상된 파일 입출력. (BufferedOutputStream, BufferedInputStream)"에서 설명한 방법을 사용하시길 권장합니다. 일단 버퍼 관리를 직접 할 필요가 없기 때문에 고민해야 할 사항이 줄어들기도 하고, 여러 형태의 read() 함수 또는 write() 함수를 사용하여 필요한 만큼의 데이터를 읽고 쓰는 것이 가능하기 때문입니다.

6. 참고.

.END.