이전 챕터에서는 안드로이드 저장소인 SharedPreferences와 DataStore에 대해서 알아봤습니다. 이번 챕터는 내부 저장소와 외부 저장소에 대해서 배워보려고 합니다.
들어가기 앞서, 안드로이드 저장소의 패러다임 변화를 알아보고 내부 저장소와 외부 저장소에 대해서 더 자세히 알아보려고 합니다.
Android 10(API 29) 기준으로 안드로이드 저장소 패러다임이 바뀝니다!
기존 저장소 방식이 새로운 저장소 방식으로 바뀌는데, Legacy Storage 에서 Scope Storage로 변경되게 됩니다.
requestLegacyExternalStorage="true"
로 선언한 앱)에서 적용되는 기존(전통) 외부 저장소 접근 방식 입니다.따라서, Android 10(API 29) 이상의 기기(특히 11(API 30) 이상)에서는 “Scoped Storage”가 적용되어 경로 기반 접근이 어려워졌으며, “MediaStore / SAF”를 사용해 필요한 파일에만 제한적으로 접근하는 구조가 표준이 되었습니다. 이는 사용자 입장에서 개인정보 보호와 앱 간 간섭 최소화라는 큰 이점을 제공합니다.
Legacy Storage와 Scope Storage에 대해서 간단히 살펴보았고, 이제는 내부 저장소와 외부 저장소에 대해서 알아볼게요!
https://developer.android.com/training/data-storage
https://developer.android.com/training/data-storage/app-specific?hl=ko
내부 저장소는 앱에서 보유하는 내부적인 저장소입니다. 다른 앱에서 내부 저장소에 접근하지 못하고 Android 10(API 29)부터는 위치가 암호화되기 때문에 민감한 정보를 저장하기에 적절합니다! 보안성이 높은 저장소입니다.
내부 저장소에 액세스하기 위해서는 아래 코드로 가능합니다.
context.filesDir // 영구 파일 경로
context.cacheDir // 캐시 파일 경로
경로는 아래와 같은 경로에 저장되며, Device Explorer 에서 확인 가능합니다.
/data/data/com.example.appname/files/{fileName} // 영구 파일 경로
/data/data/com.example.appname/cache/{fileName} // 캐시 파일 경로
우선 영구 파일(persistent file) 액세스하는 방법은 다음과 같습니다.
// 1) 영구 파일에 접근하기
val file = File(context.filesDir, filename)
// 2) 스트림을 사용하여 파일 쓰기
val filename = "myfile"
val fileContents = "Hello world!"
context.openFileOutput(filename, Context.MODE_PRIVATE).use {
it.write(fileContents.toByteArray())
}
// 3) 스트림을 사용하여 파일 읽기
context.openFileInput(filename).bufferedReader().useLines { lines ->
lines.fold("") { some, text ->
"$some\n$text"
}
}
filesDir에 어떤 파일들이 존재하는지 목록을 살펴볼 수도 있습니다.
var files: Array<String> = context.fileList()
캐시 파일(cache file)에도 액세스할 수 있습니다.
// 1) 캐시 파일에 접근하기
val cacheFile = File(context.cacheDir, filename)
캐시 파일 사용할 때는 주의할 점이 있습니다.
바로 기기의 내부 저장소 공간이 부족해지면 캐시 파일을 삭제하여 공간을 복구한다고 합니다. 그래서 “캐시 파일에 저장했으니, 열어봐야지” 을 하기전에 “캐시 파일이 있나?” 를 물어본 뒤 확인하면 좋습니다.
if (cacheFile.exists()) // "캐시 파일 존재하나요?"
추가적으로 캐시 파일을 정리할 때, “안드로이드 시스템 너가 알아서 공간 부족하면 정리해!” 라는 관점보다는 “필요하지 않는 파일을 삭제해야겠다” 관점으로 사용하면 좋습니다.
그래서 아래 코드처럼 파일도 삭제할 수 있습니다.
cacheFile.delete()
Android 10(API 29)부터 위치 암호화는 어떻게 진행되나요?
Android 10 부터는 FBE(File-Based Encryption) 방식의 암호화가 필수 요건이 되며 파일 기반 암호화가 진행됩니다. (앱 개발자가 별도로 암호화 로직을 구현하지 않아도 파일들이 암호화됩니다.)
fscrypt(Linux 커널의 파일 암호화 기능)로 구현하며, 안드로이드에선 이를 Direct Boot 모드, CE/DE 구역 등과 결합해 사용한다고 합니다.
fscrypt : 리눅스 커널 4.x 이상에서 제공하는 파일 시스템 암호화(EXT4/F2FS 기반) 기능
Direct Boot 모드 : 기기가 재부팅된 후 “아직 사용자 인증 전” 상태에서도 필요한 최소한의 기능(알람, 전화 수신 등)을 동작시켜야 하므로, DE 파티션을 활용
CE 영역 : CE(Credential Encrypted, 자격 증명 암호화) 디렉터리, 사용자가 “기기를 재부팅하고 잠금화면 해제(로그인)” 해야 비로소 접근 가능한 영역
DE 영역 : DE(Device Encrypted, 기기 암호화) 디렉터리, 기기가 켜져 있기만 하면(잠금화면을 풀지 않아도) OS가 최소한으로 접근 가능한 영역.
자세한 부분은 공식 문서를 참고해주세요! https://source.android.com/docs/security/features/encryption/file-based?hl=ko
결론으로 Android 10(API 29)부터는 “내부 저장소가 암호화된다!” → “사용자가 별도로 설정을 하지 않아도 /data 파티션이 기본적으로 FBE 처리된다” 라고 알고 있으면 됩니다!
영구 파일(persistent file) 과 캐시 파일(cache file)의 차이점은 무엇인가요?
두 차이점은 크게 용도와 보존 기간이 다릅니다. (저장 위치도 다르구요 ☺️)
즉,
분리해서 적절한 상황에 사용하면 좋습니다! ☺️
Device Explorer 저장된 파일을 확인하려고 하니, 영구 파일과 캐시 파일 경로가 같은데요?
음 경로가 같지는 않아요! 혹시 이 부분을 지적할 수 있을 것 같아요!
openFileOutput API를 사용하지 않았나요?
해당 API는 기본적으로 filesDir 즉, 영구 파일로 저장됩니다!
openFileOutput(...).use { ... }
그렇기 때문에 아래와 같이 바꿔보세요!
FileOutputStream API를 사용해서 file name 이 아닌 File을 직접 파라미터로 넣어주면 됩니다!
val persistentFile = File(filesDir, "persistent file")
val cacheFile = File(cacheDir, "cache file")
FileOutputStream(persistentFile).use {
it.write("persistent".toByteArray())
}
FileOutputStream(cacheFile).use {
it.write("cache".toByteArray())
}
/data/data/<packagename>/
├── cache/
| └── cache file
└── files/
└── persistent file
Device Explorer를 살펴보면, 다른 경로를 나타내고 있는 것을 알 수 있습니다. ☺️
간단한 질문타임~!
외부 저장소도 내부 저장소와 마찬가지로 영구 파일과 캐시 파일로 나뉩니다. 파일을 저장하는데, 내부 저장소가 공간이 충분하지 않다면, 대신해서 외부 저장소를 사용할 수 있습니다!
TMI!
그런데 다른 앱에서 액세스할 수 있는 파일을 만들려면 앱에서 이러한 파일을 외부 저장소의 공유 저장공간 부분에 대신 저장해야한다고 합니다. 예를 들어, 사용자가 “아.. 앱 지울래” 하고 삭제한 경우, 앱의 저장소에 저장된 파일이 삭제됩니다. 앱에서 사용자가 사진 캡처를 허용하여 저장한 경우 앱을 제거한 후에도 사진을 액세스할 수 있다고 예상합니다. 이 경우 공유 저장소를 사용해 대신 저장해야한다고 합니다. ☺️
TMI를 한 이유는 공유 저장공간도 외부 저장소에 한 부분입니다. 하지만 말할 부분이 많아서 따로 섹션을 나누려고 해요!
(공유 저장소에 대한 자세한 언급은 다음에 할게요!)
들어가기 앞서, 외부 저장소는 사용하기 전에 사용할 수 있는지 확인하라고 권장하고 있습니다.
fun isExternalStorageWritable(): Boolean {
return Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED
}
fun isExternalStorageReadable(): Boolean {
return Environment.getExternalStorageState() in
setOf(Environment.MEDIA_MOUNTED, Environment.MEDIA_MOUNTED_READ_ONLY)
}
안드로이드 기기에서는 물리적인 SD 카드가 없더라도 /storage/emulated/0/
같은 가상(논리적) 외부 저장소가 존재하기 때문에, 일반적인 스마트폰·태블릿에서는 사실상 “외부 저장소가 항상 있다”라고 생각했습니다. 그렇기 때문에 위 함수로 구분을 해주어야하는가? 에 대한 고민을 해봤습니다.
하지만 실제로는 기기 종류나 OS 상태(마운트 여부 등)에 따라 다양한 예외 상황이 생길 수 있기 때문에, 안드로이드 공식 가이드에서도 “외부 저장소에 접근하기 전 마운트 상태를 확인하라”고 권장하고 있습니다.
구형 태블릿, TV, Wear OS 기기, 또는 물리적 SD 카드가 삽입되지 않은 기기 등 다양한 상황을 커버하기 위해서 권장된다고 합니다. ☺️
방어적 설계는 안전성 측면에서 좋은 설계 같습니다. ☺️
마운트란 무엇일까요?
운영체제(특히 리눅스/유닉스 계열)가 디스크 파티션이나 외부 저장 장치(SD 카드, USB 등)를 특정 디렉터리와 연결해, 파일 시스템에 접근할 수 있게 만드는 과정입니다. 안드로이드는 리눅스 기반이므로 파일 시스템(디렉토리도 파일이죠 ㅎ, 디렉토리 = 특수 파일!) 접근 시 마운트라는 과정이 필요합니다.
즉, 안드로이드에서 외부 저장소가 마운트되었다 말은
⇒ 운영체제가 ‘외부 저장소’ 를 정상적으로 인식했다! 라고 할 수 있습니다.
경로는 아래와 같습니다!
/storage/emulated/0/Android/data/{packageName}/files
또는 sdcard가 있는 경우, 다음과 같습니다.
/sdcard
실제로 외부 경로를 가져오는 방법은 아래와 같습니다.
context.getExternalFilesDir(null)
Enviroment.DIRECTORY_PICTURES, DIRECTORY_MUSIC, DIRECTORY_DOCUMENTS 등 인자로 줄 수도 있습니다. null 인경우 files/ 경로를 가져오게 되는 것입니다.
예를 들어, DIRECTORY_PICTURES 인자를 가진 경우 아래 경로를 가지게 됩니다.
/storage/emulated/0/Android/data/{packageName}/files/Pictures
외부 저장소 내 영구 파일을 추가하려면 아래 코드를 참고해주세요!
// 1) 영구 파일 액세스
val appSpecificExternalDir = File(context.getExternalFilesDir(null), filename)
외부 저장소 내 캐시에 앱 파일을 추가하려면 아래 코드를 참고해주세요.
// 1) 캐시 파일 액세스
val externalCacheFile = File(context.externalCacheDir, filename)
대부분 기기에 사용 가능한 저장공간이 충분하지 않으므로 앱에서 공간을 신중하게 사용해야 합니다!
저장하는 데이터 양을 미리 알면 여유 공간을 확인하고 판단할 수 있습니다.
// 1) 앱이 필요한 용량: 10MB
const val NUM_BYTES_NEEDED_FOR_MY_APP = 1024 * 1024 * 10L
// 2) StorageManager 인스턴스 가져오기
val storageManager = applicationContext.getSystemService<StorageManager>()!!
// 3) 'filesDir'가 속한 볼륨의 UUID 구하기
// (안드로이드 기기가 내부적으로 여러 파티션으로 관리하기 때문에 fileDir가 어디에
// 위치해야하는 지 알아야 정확한 할당 가능한 바이트를 계산할 수 있습니다.)
val appSpecificInternalDirUuid: UUID = storageManager.getUuidForPath(filesDir)
// 4) 현재 해당 볼륨에서 '할당 가능한 바이트(Allocatable Bytes)' 조회
val availableBytes: Long = storageManager.getAllocatableBytes(appSpecificInternalDirUuid)
// 5) 충분한 공간이 있으면 allocateBytes()로 예약(할당)
if (availableBytes >= NUM_BYTES_NEEDED_FOR_MY_APP) {
storageManager.allocateBytes(appSpecificInternalDirUuid, NUM_BYTES_NEEDED_FOR_MY_APP)
} else {
// 6) 부족하면 저장소 관리 화면으로 이동하는 Intent
// (또는 ACTION_CLEAR_APP_CACHE 등 다른 액션 사용 가능)
val storageIntent = Intent().apply {
action = ACTION_MANAGE_STORAGE
}
// startActivity(storageIntent) 등으로 사용자에게 안내 가능
}
매번 사용 가능한 공간을 확인해야할까요? 🤔
아닙니다. 대신 파일을 곧바로 쓴 후 IOException 을 발생하라고 공식문서에서 말하고 있습니다.
필요한 공간을 정확히 모르면 이 방법을 사용해야한다는 것입니다.
예시로 파일 저장 시, PNG 이미지를 JPEG로 변환하여 인코딩하는 경우 파일의 크기를 미리 알 수 없으니.. 이런 경우에는 IOException 을 발생시켜 알 수 없는 이슈에 대한 I/O 예외를 던져주면 됩니다!
안드로이드 버전별로 권한 관련해서 외부 저장소 접근 모델은 어떠한지? 주요 변환 내용은 어떻게 되는 지 살펴보면 좋습니다. 아래 표로 확인해보겠습니다!
(✅ : 권한 필요, ❌ : 권한 필요없음)
Android 버전이 올라가면서 저장소 접근이 강화되고 있습니다. 실제로 구글 보안 강화로 이미지를 가져오는 READ_MEDIA_IMAGES 권한도 Picker를 사용하도록 SAF 사용을 권장하고 있습니다. (권한 사용하면 허락 받아야 됩니다.. 😥)
https://support.google.com/googleplay/android-developer/answer/14115180?hl=ko
https://developer.android.com/training/data-storage/shared?hl=ko
외부 저장소의 한 부분입니다.
공유 저장소는 기기 내 /storage/emulated/0/ (또는 실제 SD 카드)의 공용 디렉터리들(예: DCIM, Pictures, Music, Downloads)을 가리킵니다.
하지만 안드로이드 10+(특히 11(API 30)+)부터는 기존처럼 READ/WRITE_EXTERNAL_STORAGE 권한만으로 임의 경로에 자유롭게 접근하기가 어렵습니다.
(Legacy → Scope Storage 적용 때문!!)
/Android/data/<패키지명>
등)는 공유 저장소와 구분되며, 해당 앱만 직접 접근 가능합니다(앱 삭제 시 함께 삭제).https://developer.android.com/training/data-storage/shared/media
미디어 파일(사진, 동영상, 오디오)을 관리하고, 시스템 갤러리(또는 음악 플레이어 등)에서 인덱싱하는 DB 테이블을 제공하는 안드로이드 컴포넌트입니다.
그렇기 때문에 Android 10+ 에서는 ContentResolver를 통해서 insert(), update(), delete(), query() 등을 수행하게 됩니다.
자세한 내용은 공식문서 봐주세요 ☺️ (내용이 너무 많아요 😅)
https://developer.android.com/training/data-storage/shared/documents-files
일반 파일(문서, PDF, ZIP, txt 등)이나 사용자가 임의로 보관한 폴더/파일에 접근하려면, Storage Access Framework를 사용해 시스템 파일 선택기(문서 선택·폴더 선택 등) 또는 문서 생성기를 띄우고, 사용자가 “어떤 파일(폴더)에 접근할지”를 직접 지정하면, 해당 URI에 대해 앱이 권한을 일시적으로 얻게 되는 모델입니다.
안드로이드 4.4(API 19) 에 처음 도입되었고, Scoped Storage가 강화된 Android 10+ 환경에서도 여전히 권장 방식입니다.
이 부분도 자세한 내용은 공식문서 참고!!
https://kimdabang.tistory.com/entry/안드로이드-저장소-사용하기-1-Legacy-Storage