본문 바로가기
깡샘 코틀린

17-2 파일에 보관하기

by 농농씨 2023. 7. 12.

앞 절에서 살펴본 데이터베이스와 다음 절에서 살펴본 프리퍼런스는 모두 내부적으로는 파일로 저장되지만 코드에서 직접 파일을 읽거나 쓰지는 않고 특정 API를 이용하는 방법이다. 그러나 이번 절에서는 앱에서 직접 파일을 만들어 데이터를 쓰거나 읽는 방법을 살펴보자.

 

안드로이드 앱에서 파일을 다룰 때는 대부분 java.io 패키지에서 제공하는 클래스를 이용하므로 파일을 읽거나 쓰는 코드는 일반 자바 프로그램과 차이가 없다.

  • File: 파일 및 디렉터리를 지칭하는 클래스이다.
  • FileInputStream / FileOutputStream: 파일에서 바이트 스트림으로 데이터를 읽거나 쓰는 클래스이다.
  • FileReader / FileWriter: 파일에서 문자열 스트림으로 데이터를 읽거나 쓰는 클래스이다.

안드로이드에서 파일 저장소는 내장 메모리와 외장 메모리 공간으로 구분되며 외장 메모리 공간은 다시 앱별 저장소와 공용 저장소로 나뉜다. 앱별 저장소에는 다른 앱이 접근할 수 없지만 공용 저장소에는 다른 앱도 접근할 수 있다.

 

 

내장 메모리의 파일 이용하기

내장 메모리는 앱이 설치되면 시스템에서 자동으로 할당하는 공간이다. 안드로이드 시스템은 앱에서 파일을 이용하지 않더라도 앱의 패키지명으로 디렉터리를 만들어 주는데, 이 디렉터리가 바로 앱의 내장 메모리 공간이다. 이처럼 디렉터리명을 패키지명으로 만드는 것은 해당 앱에서만 접근할 수 있다는 의미이며 다른 앱에서는 이 디렉터리에 접근할 수 없다.

따라서 앱은 민감한 데이터를 대부분 내장 메모리에 저장한다. 하지만 내장 메모리는 외장 메모리보다 용량이 작아서 크기가 큰 데이터는 외장 메모리를 이용해야 한다.

파일을 내장 메모리에 저장하려면 java.io의 File 클래스를 이용한다. 이때 File() 생성자의 첫 번째 매개변수에는 Context 객체의 filesDir 속성을 지정하고 두 번째 매개변수에는 파일명을 전달한다.

// 파일 객체 생성 후 데이터 쓰기
val file = File(filesDir, "text.txt") 
// File 생성자의 첫번째 매개변수의 Context 객체의 filesDir 속성을 지정하고 두번째 매개변수에 파일명 전달함
val writeStream: OutputStreamWriter = file.writer()
writeStream.write("hello world")
writeStreamflush()

 

이렇게 저장한 데이터를 읽어서 가져오는 코드는 다음과 같다.

// 파일의 데이터 읽기
val readStream: bufferedReader = file.reader().buffered()
readStream.forEachLine {
    Log.d("kkang", "$it")
}

 

또는 java.io의 File 클래스를 이용하지 않고 Context 객체가 제공하는 openFileOutput()과 openFileInput() 함수를 사용해 파일에 데이터를 쓰거나 읽을 수도 있다.

// Context 객체의 함수 사용
openFileOutput("test.txt", Context.MODE_PRIVATE).use { // 데이터 쓰기
    it.write("hello world!!".toByteArray())
}
openFileInput("text.txt").bufferedReader().forEachLine { // 데이터 읽기
    Log.d("kkang", "$it")
}

 

 

외장 메모리의 파일 이용하기

외장 메모리는 SD 카드와 같은 외부 저장 장치를 의미하지만 어떤 기기는 내부 저장소의 파티션을 나누어 외장 메모리로 제공할 수도 있다. 따라서 모든 기기가 외장 메모리를 제공한다고 보장할 수 없으므로 Environment.getExternalStorageState() 함수로 외장 메모리를 사용할 수 있는지부터 확인해야 한다.

// 외장 메모리 사용 가능 여부 판단
if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) {
    Log.d("kkang", "ExternalStorageState MOUNTED") // 외장메모리 사용 가능할 때
} else {
    Logd.("kkang", "ExternalStorageState UNMOUNTED") // 사용 불가능할 때
}

Environment.getExternalStorageState() 함수로 얻은 값이 MEDIA_MOUNTED이면 외장 메모리를 사용할 수 있지만, 아니라면 외장 메모리를 사용할 수 없다.

 

매니페스트 설정

외장 메모리의 파일을 이용할 때는 매니페스트에 android.permission.READ_EXTERNAL_STORAGE, android.permission.WRITE_EXTERNAL_STORAGE와 같은 퍼미션을 설정해 줘야 한다. 그런데 두 퍼미션은 때에 따라 필요할 수도 있고 그렇지 않을 수도 있다.

앱별 저장소든 공용 저장소든 파일 정보에 직접 접근하지 않고 ContentResolver에서 제공하는 InputStream 등을 이용한다면 두 퍼미션을 설정하지 않아도 된다. 하지만 File API를 이용한다면 안드로이드 10 버전부터는 두 퍼미션과 함께 requestLegacyExternalStorage값도 추가로 설정해 줘야 한다.

결국 1️⃣파일을 이용하는 방식과 2️⃣안드로이드 버전에 따라 다르다는 것인데, 여기에 API 호환성까지 생각한다면 외장 메모리를 사용할 때는 될 수 있으면 다음처럼 선언하는 것이 좋다.

// 매니페스트에 외장 메모리 사용 설정
<manifest ... 생략 ... >
    // 외장메모리의 파일 이용할 때 설정해야 하는 퍼미션들
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <application
        (... 생략 ...)
        // File API를 이용하면 안드로이드 10버전부터 설정해줘야하는 값
        android.requestLegacyExternalStorage="true"
        (... 생략 ...)
    </application>
</manifest>

 

앱별 저장소 이용

외장 메모리 공간은 앱별 저장소와 공용 저장소로 구분된다. 앱별 저장소개별 앱에 할당된 공간으로, 기본적으로 해당 앱에서만 접근할 수 있다. 만약 앱별 저장소의 파일을 외부 앱에서 접근하게 하려면 파일 프로바이더로 공개해야 한다.

외장 메모리의 앱별 저장소 위치는 getExternalFilesDir() 함수로 구한다.

// 앱별 저장소에 접근
val file: File? = getExternalFilesDir(null) // 외장 메모리의 앱별 저장소 위치 구하는 함수
Log.d("kkang", "${file?.absolutePath}")

기기에 따라 다를 수 있지만 getExternalFilesDir(null) 함수가 반환하는 위치는 다음과 같다.

  • /storage/emulated/0/Android/data/패키지명/files

외장 메모리의 Android 아래에 패키지명으로 디렉터리가 생기고 그 아래에 있는 files앱별 파일이 저장되는 곳이다. getExternalFilesDir()(외장메모리의 앱별 저장소 위치를 구하는 함수) 함수를 이용할 때 매개변수는 파일의 종류를 나타내며 null이 아닌 다음과 같은 Environment의 상수를 전달할 수도 있다.

  • Environment.DIRECTORY_PICTURES
  • Environment.DIRECTORY_DOCUMENTS
  • Environment.DIRECTORY_MUSIC
  • Environment.DIRECTORY_MOVIES

만약 Environment.DIRECTORY_PICTURES를 전달했다면 파일이 저장되는 위치는 다음과 같다.

  • /storage/emulated/0/Android/data/패키지명/files/Pictures

파일을 읽거나 쓰는 나머지 코드는 java.io의 API를 이용하는 것과 같다.

// 앱별 저장소에 파일 쓰기와 읽기

// 파일 쓰기
val file:File = File(getExternalFilesDir(null), "text.txt")
// 외장 메모리의 앱별 저장소의 위치 구하는 함수에 null 전달함...?
val writesStream: OutputStreamWriter = file.writer()
writeStream.write("hello world")
writeStream.flush()
// 파일 읽기
val readStream: BufferedReader = file.reader().buffered()
readStream.forEachLine {
    Log.d("kkang", "$it")
}

 

파일 프로바이더로 외부 공유

외장 메모리의 앱별 저장소에는 해당 앱에서만 접근할 수 있다. 그런데 다른 앱에서도 이 저장소에 접근할 수 있게 하려면 파일 프로바이더를 이용해야 한다. 파일 프로바이더를 이용하는 코드는 이미 16장 안드로이드의 기본 앱을 연동하는 부분에서 살펴봤다.

// 외장 메모리의 앱별 저장소 파일을 다른 앱에서 접근

// 파일 생성
val timeStamp: String = SimpleDateFormat("yyyyMMdd__HHmmss").format(Date())
val storageDir: File? = gefExternalFilesDir(Environment.DIRECTORY_PICTURES)
// 외장 메모리의 앱별 저장소의 위치 구하는 함수에 파일의 종류가 사진임을 나타내는 매개변수 전달함
val file = File.createTempFile(
    "JPEG_${timeStamp}_",
    ".jpg",
    storageDir
)
filePath = file.absolutePath
// 파일 Uri 획득
val photoURI: Uri = FileProvider.getUriForFile(
    this,
    "com.example.test17.fileprovider", file
)
// 카메라 앱 실행
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI);
requestCameraFileLauncher.launch(intent)

앱에서 파일을 만들고 이 파일의 경로를 카메라 앱에 전달하는 코드이다. 파일을 만들 때 getExternalFilesDir(Environment.DIRECTORY_PICTURES)으로 작성했으므로 파일은 외장 메모리의 앱별 저장소에 생성된다. 하지만 이 코드를 실행하면 다음 같은 오류가 발생한다.

java.lang.IllegalArgumentException: Couldn't find meta-data for provider with authority
com.example.test17_provider.fileprovider

오류 메시지는 파일을 공유하는 데 필요한 정보가 없다는 의미이다. 이 오류를 해결하려면 공유할 파일 정보를 파일 프로바이더가 알게 해줘야 한다. 그러려면 1️⃣프로젝트의 res/xml 디렉터리에 XML 파일을 만들고 2️⃣이 파일에서 외부 앱에 공개할 경로를 지정해야 한다.

// 외부에 공유할 경로 설정
<paths xmlns:android="http://schemas.android.com/apk/res/android"> // XML 파일 정보를 만듦
    <external-path name="myfiles"
        path="Android/data/com.example.text17/files/Pictures" />
        // path 속성에 지정한 디렉터리를 공개하겠다고 선언함(아마도 external)
</paths>

<external-path> 태그의 path 속성에 지정한 디렉터리를 공개하겠다고 선언했다. 이렇게 만든 XML 파일 정보를 매니페스트에 <provider> 태그로 등록한다. 이때 앞에서 만든 XML 파일을 <meta-data> 태그의 resource 속성에 명시한다.

// 매니페스트에 파일 프로바이더 등록
<provider 
// <external-path> 태그의 path 속성에 공개하겠다고 선언한 XML 파일 정보를 매니페스트에 <provider> 태그로 등록하면서
    android:name="androidx.core.content.FileProvider"
    android:authorities="com.example.test17.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths"></meta-data>
        // resource 속성에 앞에서 만든 XML 파일을 명시함
</provider>

 

공용 저장소 이용

외장 메모리의 앱별 저장소는 개별 앱에서 만든 파일을 저장하는 공간이다. 그런데 앱에서 만든 파일을 모든 앱에서 이용할 수 있게 하고 싶을 때도 있다. 대표적인 사례가 카메라 앱에서 촬영한 사진 파일을 모든 앱에서 이용할 수 있는 것이다. 따라서 카메라 앱은 파일을 앱별 저장소가 아닌 공용 저장소에 만든다.

지금껏 살펴본 내장 메모리, 외장 메모리의 앱별 저장소는 개별 앱을 위한 공간이므로 앱이 삭제되면 파일도 모두 삭제된다. 하지만 공용 저장소는 모든 앱을 위한 공간이므로 파일을 만든 앱을 삭제해도 파일은 삭제되지 않는다.

공용 저장소안드로이드 시스템에서 파일 종류에 따라 지정한 폴더이다. 즉, 사진, 음원, 문서 등 파일의 종류에 따라 저장하는 폴더가 지정되어 있다. 이 공용 저장소는 파일 경로로 직접 접근하지 않고 시스템이 제공하는 API를 이용한다.

// 공용 저장소에 접근
val projection = arrayOf(
    MediaStore.Images.Media._ID,
    MediaStore.Images.Media.DISPLAY_NAME
    // MediaStore.Images는 안드로이드폰의 이미지 파일이 저장되는 공용 저장소인 DCIM과 Pictures 디렉터리를 가리킴
    // 공용 저장소에 저장된 이미지 파일의 정보를 가져와서 배열로 저장한듯
)
val cursor = contentResolver.query(
// contentResolver.query() 함수의
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    // 첫 번째 매개변수에 Uri 값을 지정할 때 그 MediaStore.Images를 이용함
    projection,
    null,
    null,
    null
)
cursor?.let {
    while (cursor.moveToNext()) {
        Log.d("kkang", "_id : ${cursor.getLong(0)}, name : ${cursor.getString(1)}")
    }
}

이 코드는 공용 저장소에 저장된 이미지 파일의 정보를 가져와 로그로 출력한다. 외장 메모리의 파일 정보를 이용하지만 파일 경로를 직접 사용하지는 않았다. contentResolvdr.query() 함수의 첫 번째 매개변수의 Uri 값을 지정할 때 MediaStore.Images를 이용했는데, 이는 안드로이드폰의 이미지 파일이 저장되는 공용 저장소인 DCIM과 Pictures 디렉터리를 가리킨다. MediaStore.Vide는 DCIM, Movies, Pictures 디렉터리를 가리키며, MediaStore.Audio는 Alarms, Audiobooks, Music, Notification, Podcasts, Ringtones 디렉터리를 가리킨다.

앞의 코드는 MediaStore.Images를 이용해 공용 저장소의 이미지 파일 정보만 출력해 본 것인데, 이 정보를 이용해 앱에서 이미지 데이터를 가져와 화면에 출력하는 코드는 다음과 같다.

// 이미지 파일의 Uri 값 가져오기
val contentUri: Uri = ContentUris.withAppendedId(
    MediaStore.Images.Media.EXTERNAL_CONTENNT_URI,
    cursor.getLong(0) // 이미지의 식별값을 나타내는 매개변수
)

ContentUris.withAppendedId() 함수의 두 번째 매개변수가 가져온 이미지의 식별값이다. 이렇게 하면 이 이미지 파일을 이용할 수 있는 Uri 값이 반환되고, 이 Uri값으로 이미지를 읽을 수 있는 InputStream 객체를 얻는다.

// 이미지 데이터 가져오기
val resolver = applicationContext.contentResolver
resolver.openInputstream(contentUri).use { stream ->
// contentUri로 앞서 이미지 파일을 이용할 수 있는 Uri 값을 반환하면서 이미지를 읽을 수 있는 InputStream 객체도 얻음
그리고 그 Stream 객체로 이미지 데이터 가져옴
    // stream 객체에서 작업 수행
    val option = BitmapFactory.Options()
    option.inSampleSize = 10
    val bitmap = BitmapFactory.decodeStream(stream, null, option)
    binding.resultImageView.setImageBitmap(bitmap)
}

resolver.openInputstream(contentUri)를 이용해 매개변수에 지정한 Uri값이 가리키는 파일을 읽을 수 있는 Stream 객체를 얻고 이 객체로 이미지 데이터를 가져온다.

 

❓외장 메모리의 앱별 저장소나 공용 저장소가 아닌 임의의 폴더를 만들고, 그곳에 파일을 저장할 수는 없나요?

❗️Environment.getExternalStorageDirectory() 함수를 이용하면 가능하다. 파일의 절대 경로를 지정하고 이 경로의 File 객체를 얻어 직접 핸들링(?)하는 방식이다. 하지만 이 함수는 API레벨 29(안드로이드10)에서 deprecated 되었다.

안드로이드 11 버전부터는 반드시 MANAGE_EXTERNAL_STORAGE 퍼미션을 선언해야 하고 ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION을 액션 문자열로 해서 모든 파일에 접근할 수 있는 권한을 사용자로부터 얻어야 한다.