안드로이드 바이너리(binary) 파일 입출력 2. [Example] (Android Binary File I/O 2)

2017. 2. 20. 17:09


1. 안드로이드에서 바이너리(binary) 파일 읽고 쓰기.

이전 글 [안드로이드 바이너리(binary) 파일 입출력 1]에서 Java의 파일 입출력 방법에 대해 살펴보았습니다. 바이너리 데이터를 읽고 쓰기 위한 byte 단위 입출력에 대해 설명하였으며, 특히, 버퍼를 사용하여 성능을 향상시키는 방법에 대해서도 알아보았습니다.


안드로이드 앱에서, 파일로부터 데이터를 읽거나 파일에 데이터를 쓰는 기능은 매우 빈번하게 사용됩니다. 앱의 실행 상태를 결정하기 위해 단순한 형태의 설정 정보를 읽는다거나, 사용자로부터 입력받은 데이터를 저장한다든지, 또는 네트워크를 통해 서버로부터 전달받은 파일의 내용을 표시하는 기능 등, 많은 경우에서 파일 입출력이 문제를 해결하는 방법으로 제시되거나, 필수적으로 사용되어야만 하죠.


안드로이드는 Java 언어를 사용하고 있고, 파일 입출력 기능 또한 "java.io" 패키지에서 제공하는 API를 그대로 사용하도록 만들어져 있습니다. 즉, [안드로이드 바이너리(binary) 파일 입출력 1]에서 설명한 방법과 소스 코드를 사용하여 파일 입출력 기능을 구현할 수 있습니다.


그런데 안드로이드 프로젝트를 생성한 다음, [안드로이드 바이너리(binary) 파일 입출력 1]에서 설명한 소스를 작성하여 실행하면 Exception이 발생합니다. 예를 들어, 파일에 데이터를 쓰기 위해 "file.bin" 이라는 이름의 파일을 생성하는 코드를 아래와 같이 작성해보도록 하죠.

    File file = new File("file.bin") ;
    FileOutputStream fos = null ;

    try {
        // open file. (Exception 발생)
        fos = new FileOutputStream(file) ;
        
        // ...
        // ...

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

앱을 실행해보면, 다음과 같은 Exception이 발생합니다.

java.io.FileNotFoundException: file.bin: open failed: EROFS (Read-only file system)

이 에러 메시지가 의미하는 것은, 파일 open이 실패("open failed")했다는 것이고, 그 이유가 읽기 전용 파일 시스템("EROFS (Read-only file system)")에 "file.bin" 파일을 열려고 했다는 것입니다.


아니, 이것 참... 코드에서는 단지 "file.bin" 이라는 파일을 FileOutputStream으로 열려고 했을 뿐인데, 왜 에러가 발생한 걸까요? 그것도 "읽기 전용" 이라니요!! 그럼 안드로이드에서는 파일을 쓰는 것은 불가능하단 말입니까? ... 라고 흥분하기 전에, FileOutputStream(file) 으로 파일을 열기 전에 아래의 코드를 추가한 다음 출력되는 메시지를 확인해보시기 바랍니다.

    System.out.println("save FILE : " + file.getAbsolutePath()) ;

출력 결과인 파일의 절대 경로는 아래와 같습니다.

    I/System.out: save FILE : /file.bin

음, 파일을 생성하려고 했던 경로가 루트(Root, "/") 디렉토리였군요. 그렇다면 Exception이 발생할 수 밖에 없죠. 안드로이드 앱에서는 루트 디렉토리에 파일을 쓸 수 없고, 읽기만 가능하니까요.

1.1 안드로이드 앱에서 접근 가능한 디렉토리 경로.

안드로이드 시스템은 여러 가지 보안 이슈로 인해, 앱이 접근할 수 있는 경로를 제한하고 있습니다. 특히, 앱이 자신만의 데이터 관리를 위해 파일을 생성하거나, 수정, 삭제할 수 있는 기본 경로는 "/data/user/[USER-NO]/[PACKAGE-NAME]/files/" 입니다. 루트 디렉토리를 포함한 그 외 경로들은 거의 대부분 파일을 읽는 것만 가능하죠.


그렇다면 위에서 발생한 Exception을 해결하기 위해서는 기본 파일 저장 경로와 앱의 패키지 이름, 그리고 만들고자 하는 파일 이름을 조합하여 아래와 같이 파일을 생성하면 되겠네요.

        // 패키지 이름이 "com.recipes4dev.examples.filebinaryioexample1"이고 사용자 NO가 0인 경우,
        File file = new File("/data/user/0/com.recipes4dev.examples.filebinaryioexample1/files/file.bin") ;

        // open file.
        fos = new FileOutputStream(file) ;

이제 Exception이 발생하지 않네요. 하지만 이와 같이 전체 경로를 직접 지정하는 것은 매우 좋지 않은 방법입니다. 파일을 여는 코드마다 자신의 패키지 이름을 대입해줘야 하는 번거로움은 감수하더라도, 앱의 기본 파일 저장 경로("/data/user/[USER-NO]/[PACKAGE-NAME]/files/")가 앞으로도 영원히 변하지 않으리라는 보장이 없기 때문이죠.

실제로, Android 6.0(API23)가 설치된 디바이스에서는 파일 저장 경로가 "/data/user/[USER-NO]/[PACKAGE-NAME]/files/"이지만, 4.x가 설치된 디바이스에서는 "/data/data/[PACKAGE-NAME]/files/"인 것을 통해 파일 저장 경로가 변하는 것을 확인할 수 있습니다.

안드로이드 버전이 올라감에 따라 안드로이드 시스템이 제공하는 기능이나 서비스가 복잡해지고, 그에 따라 안드로이드 시스템도 변해왔습니다. 디렉토리 구조 또한 예외는 아니죠. 그래서 최신 버전의 안드로이드에서도 안정적으로 동작하게 만드려면, 전체 경로를 직접 지정하여 파일에 접근하는 것은 절대적으로 피해야 하는 방법입니다.


대신, 안드로이드 SDK에서 제공하는 API 함수를 사용하여 앱의 기본 파일 저장 경로를 가져올 수 있습니다. 바로 Context(android.content.Context) 클래스에서 제공하는 getFilesDir() 함수입니다.

    File getFilesDir() ;

getFilesDir() 함수를 호출하면, 앱의 기본 파일 저장 디렉토리의 전체 경로를 가져올 수 있습니다. 최종적으로, 앱에서 사용하는 데이터를 저장하기 위해 파일을 여는 경우, 아래와 같이 코드를 작성할 수 있습니다.

    File file = new File(getFilesDir(), "file.bin") ;
    FileOutputStream fos = null ;

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

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

2. 안드로이드 바이너리(binary) 파일 입출력 예제

이제, 안드로이드 앱에서 파일에 바이너리(binary) 데이터를 쓰거나 읽는 예제를 작성해 보도록 하겠습니다. 예제에서는 단순히 정수(int) 타입의 4 바이트 데이터를 쓰거나 읽는 과정을 보여주지만, 데이터 타입에 따라 파일 입출력 방법이 달라지는 것은 아니므로, 다양한 타입의 데이터를 읽고 쓰도록 확장하는 것은 그리 어렵지 않을 것이라 생각됩니다.


예제는 화면의 버튼 클릭에 따라 카운트 값을 증가하거나 감소 또는 초기화하여 화면에 표시하고 파일에 저장합니다. 생성된 파일은 안드로이드 시스템애서 앱이 삭제되거나, 앱 내부에서 명시적으로 파일을 삭제하지 않는 한 유지되므로, 앱을 다시 실행하면 파일에 저장된 카운트 값을 읽어 화면에 표시하도록 만들겠습니다.


예제의 화면 구성은 아래와 같습니다.

예제 화면 구성도


2.1 MainActivity의 Layout 구성.

가장 처음, MainActivity의 Layout 리소스 XML을 작성합니다. 프로젝트의 "activity_main.xml" 또는 "content_main.xml" 파일에 아래의 코드(진하게 표시된)를 추가합니다.

[STEP-1] "activity_main.xml" - MainActivity의 Layout 구성.
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/content_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context="com.recipes4dev.examples.filebinaryioexample1.MainActivity"
    tools:showIn="@layout/activity_main">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#0000FF"
        android:textColor="#FFFFFF"
        android:fontFamily="sans-serif-black"
        android:gravity="center"
        android:textSize="40sp"
        android:id="@+id/textViewCount"
        android:text="T"/>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@id/textViewCount">

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:id="@+id/buttonInc"
            android:text="+" />

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:id="@+id/buttonDec"
            android:text="-" />

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:id="@+id/buttonClear"
            android:text="CLEAR" />

    </LinearLayout>

</RelativeLayout>

2.2 파일 이름과 카운트 값을 위한 변수 선언.

파일 이름과 카운트 값을 위한 변수를 선언합니다. 이 때 파일 이름은 앱 실행 중에 변경될 가능성이 없으므로 final 키워드를 사용하여 상수로 선언합니다.

[STEP-2] "MainActivity.java" - 파일 이름과 카운트 값을 위한 변수 선언.
public class MainActivity extends AppCompatActivity {
    private final String fileName = "access.cnt" ;
    private int accessCount = 0 ;

    // ... 코드 계속
}

2.3 파일로부터 카운트 값을 읽는 함수 추가.

[STEP-3] "MainActivity.java" - 카운트 값을 파일로부터 읽는 함수 추가. (loadAccessCount() 함수)
public class MainActivity extends AppCompatActivity {
    
    // 코드 계속 ...

    private int loadAccessCount() {
        FileInputStream fis = null ;
        BufferedInputStream bufis = null ;
        byte[] buf = new byte[4] ;
        int size = 0 ;
        int count = 0 ;

        File file = new File(getFilesDir(), fileName) ;

        System.out.println("load FILE : " + file.getAbsolutePath()) ;

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

                // read data from bufis's buffer.
                if ((size = bufis.read(buf)) != -1) {
                    // convert byte array to int.
                    for (int i = 0; i < size; i++) {
                        count |= (int) (buf[i] << (24 - (i * 8)));
                    }
                }

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

        return count ;
    }

    // ... 코드 계속
}

2.4 파일에 카운트 값을 쓰는 함수 추가.

[STEP-4] "MainActivity.java" - 카운트 값을 파일에 쓰는 함수 추가. (saveAccessCount() 함수)
public class MainActivity extends AppCompatActivity {
    
    // 코드 계속 ...

    private void saveAccessCount(int count) {
        FileOutputStream fos = null ;
        BufferedOutputStream bufos = null ;

        byte[] buf = new byte[4] ;

        // convert int to byte array.
        buf[0] = (byte)((count >> 24) & 0xFF) ;
        buf[1] = (byte)((count >> 16) & 0xFF) ;
        buf[2] = (byte)((count >> 8) & 0xFF) ;
        buf[3] = (byte)(count & 0xFF) ;

        File file = new File(getFilesDir(), fileName) ;

        System.out.println("save FILE : " + file.getAbsolutePath()) ;

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

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

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

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

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

    // ... 코드 계속
}

2.5 카운트 증가(+) 버튼 클릭 이벤트 처리.

[STEP-5] "MainActivity.java" - 카운트 증가(+) 버튼 클릭 시, 카운트 값 증가.
public class MainActivity extends AppCompatActivity {
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        
        // 코드 계속 ...

        Button buttonInc = (Button) findViewById(R.id.buttonInc) ;
        buttonInc.setOnClickListener(new Button.OnClickListener() {
            @Override
            public void onClick(View v) {
                TextView textViewCount = (TextView) findViewById(R.id.textViewCount) ;

                accessCount++ ;
                saveAccessCount(accessCount) ;

                textViewCount.setText(Integer.toString(accessCount)) ;
            }
        });

        // ... 코드 계속
    }
}

2.6 카운트 감소(-) 버튼 클릭 이벤트 처리.

[STEP-6] "MainActivity.java" - 카운트 감소(-) 버튼 클릭 시, 카운트 값 증가.
public class MainActivity extends AppCompatActivity {
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        
        // 코드 계속 ...

        Button buttonDec = (Button) findViewById(R.id.buttonDec) ;
        buttonDec.setOnClickListener(new Button.OnClickListener() {
            @Override
            public void onClick(View v) {
                TextView textViewCount = (TextView) findViewById(R.id.textViewCount) ;

                accessCount-- ;
                saveAccessCount(accessCount) ;

                textViewCount.setText(Integer.toString(accessCount)) ;
            }
        });

        // ... 코드 계속
    }
}

2.7 카운트 초기화(CLEAR) 버튼 클릭 이벤트 처리.

[STEP-7] "MainActivity.java" - 카운트 초기화(CLEAR) 버튼 클릭 시, 카운트 값 초기화.
public class MainActivity extends AppCompatActivity {
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        
        // 코드 계속 ...

        Button buttonClear = (Button) findViewById(R.id.buttonClear) ;
        buttonClear.setOnClickListener(new Button.OnClickListener() {
            @Override
            public void onClick(View v) {
                TextView textViewCount = (TextView) findViewById(R.id.textViewCount) ;

                accessCount = 0 ;
                saveAccessCount(accessCount) ;

                textViewCount.setText(Integer.toString(accessCount)) ;
            }
        });

        // ... 코드 계속
    }
}

2.8 앱 실행 시, 카운트 값 읽어서 표시하는 코드 추가.

[STEP-8] "MainActivity.java" - 카운트 초기화(CLEAR) 버튼 클릭 시, 카운트 값 초기화.
public class MainActivity extends AppCompatActivity {
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        
        // 코드 계속 ...

        accessCount = loadAccessCount() ;
        TextView textViewCount = (TextView) findViewById(R.id.textViewCount) ;
        textViewCount.setText(Integer.toString(accessCount)) ;

        // ... 코드 계속
    }
}

3. 예제 실행 결과.

예제 코드를 작성한 다음 앱을 실행하면, 아래와 같은 화면이 표시됩니다.

예제 실행 화면 1


"+" 버튼을 누르면 카운트 값을 1 증가시키고 증가시킨 값을 파일에 저장합니다.

예제 실행 화면 2


이 때 앱을 종료한 다음, 다시 실행하면 파일에 마지막으로 저장된 카운트 값이 화면에 표시됩니다.


마찬가지로 "-" 버튼을 누르면 카운트 값을 1 감소시키고 감소시킨 값을 파일에 저장합니다. 그리고 "Clear" 버튼을 누르면 카운트 값을 0으로 초기화한 다음, 초기화된 값을 파일에 저장합니다.

예제 실행 화면 3


4. 참고.

.END.


ANDROID 프로그래밍/FILE