Scoped Storage는 Android 10(API 29)에서 도입된 파일 시스템 액세스 모델로, 기기의 공유 저장소에 대한 앱의 임의 액세스를 제한합니다. Google Android Developer Documentation(2024)에 따르면 Scoped Storage는 READ_EXTERNAL_STORAGE 및 WRITE_EXTERNAL_STORAGE의 이전 권한 모델을 대체하며, 앱에 격리된 샌드박스의 자체 파일과 MediaStore를 통한 공유 컬렉션에만 액세스 권한을 부여합니다. 이 모델은 사용자 데이터 프라이버시를 강화하고 앱의 무단 정보 수집을 방지합니다.
핵심 사항
Scoped Storage는 Android 10(API 29)의 아키텍처 변경으로, 기기의 파일 시스템에 액세스하는 앱의 방식을 근본적으로 변경합니다. 공유 저장소에 대한 전체 액세스 대신 앱은 자체 샌드박스와 MediaStore를 통한 특정 미디어 컬렉션에만 액세스할 수 있습니다.
Scoped Storage 이전에는 READ_EXTERNAL_STORAGE 권한이 있는 모든 앱이 공유 저장소의 모든 파일(사진, 문서, 다운로드, 다른 앱의 데이터)을 읽을 수 있었습니다. 이는 프라이버시 위험을 초래했습니다. 신뢰할 수 없는 앱이 사용자 모르게 정보를 수집할 수 있었습니다.
Google I/O 2019에 따르면 Android 사용자의 65% 이상이 앱 선택 시 데이터 프라이버시를 중요한 요소로 고려합니다. Scoped Storage는 이러한 요구에 대한 직접적인 응답입니다. 각 앱은 격리된 환경에서 작동하며 사용자의 명시적 동의가 있을 때만 다른 앱의 데이터에 액세스할 수 있습니다.
중요: Android 10의 Scoped Storage는 선택 사항이었습니다(앱이 requestLegacyExternalStorage를 통해 비활성화할 수 있음). Android 11(API 30)부터 Scoped Storage는 target SDK에 관계없이 모든 앱에 필수가 되었습니다.
Google은 이전 파일 액세스 모델의 세 가지 근본적인 문제(프라이버시, 권한 관리, 잔여 파일 정리)를 해결하기 위해 Scoped Storage를 도입했습니다.
이전 모델에서 READ_EXTERNAL_STORAGE가 있는 앱은 전체 공유 저장소를 스캔하고 사용자 파일의 메타데이터(사진의 지리적 좌표, 문서 이름, 디렉토리 구조)를 수집할 수 있었습니다. Scoped Storage는 이러한 가능성을 제거합니다. 권한이 있어도 앱은 자신이 생성한 파일과 SAF를 통해 사용자가 명시적으로 선택한 파일만 볼 수 있습니다.
Scoped Storage 이전에는 앱이 제거된 후에도 공유 저장소에 파일을 남길 수 있었습니다. 시간이 지남에 따라 정크 디렉토리가 축적되었습니다. Scoped Storage는 이를 해결합니다. 앱 샌드박스의 모든 파일은 앱과 함께 삭제되고, MediaStore를 통한 파일은 소유자가 있으며 시스템에서 정리할 수 있습니다.
| 측면 | 이전 모델(Legacy) | Scoped Storage |
|---|---|---|
| 공유 저장소 액세스 | 전체(권한 필요) | 자체 샌드박스만 |
| 미디어 파일 | 직접 파일 경로 | MediaStore URI를 통해 |
| 다른 앱의 파일 | 항상 액세스 가능 | SAF를 통해서만 |
| 제거 시 정리 | 파일이 남음 | 샌드박스가 제거됨 |
| 사용자 제어 | 최소 | 파일별 명시적 동의 |
Scoped Storage는 Android에서 Google의 전반적인 프라이버시 전략의 일부로, "사용 중에만" 권한, 카메라 및 마이크 액세스 표시기, Privacy Dashboard도 포함합니다.
각 Android 앱은 Context.getFilesDir() 및 context.getCacheDir()을 통해 액세스할 수 있는 자체 개인 디렉토리를 받습니다. 외부(공유) 개인 디렉토리에 액세스하려면 Context.getExternalFilesDir()을 사용합니다.
Scoped Storage에서 앱은 권한 없이 외부 개인 디렉토리에 완전히 액세스할 수 있습니다. 이는 다른 앱이 액세스할 수 없어야 하는 파일이나 앱이 자체 사용을 위해 생성하는 파일을 저장하는 기본 위치입니다.
import android.os.Environment
import java.io.File
import java.io.IOException
class FileManager {
fun saveToAppStorage(context: Context, fileName: String, data: ByteArray) {
val appDir = context.getExternalFilesDir(null)
?: return
val file = File(appDir, fileName)
file.writeBytes(data)
}
fun readFromAppStorage(context: Context, fileName: String): ByteArray? {
val appDir = context.getExternalFilesDir(null)
?: return null
val file = File(appDir, fileName)
return file.takeIf { it.exists() }?.readBytes()
}
}
외부 개인 디렉토리 경로: /storage/emulated/0/Android/data/{packageName}/files/. Android 11부터 직접 파일 경로를 사용할 수 없습니다(API를 통해서만 가능). 이는 Scoped Storage의 또 다른 보안 강화입니다.
MediaStore는 Scoped Storage에서 공유 미디어 파일(이미지, 비디오, 오디오)에 액세스하기 위한 기본 API입니다. 앱은 파일 경로가 아닌 MediaStore 제공자가 제공하는 content URI로 작동합니다.
MediaStore는 Images, Video, Audio의 세 가지 주요 컬렉션으로 나뉩니다. 각 컬렉션은 ContentResolver를 통한 CRUD 작업을 지원합니다. Images 컬렉션에 새 파일을 삽입하려면 MediaStore.Images.Media를 사용하고, 기존 파일을 쿼리하려면 해당 URI로 query를 사용합니다.
import android.content.ContentValues
import android.provider.MediaStore
import android.os.Environment
import java.io.OutputStream
fun saveImageToGallery(context: Context, bitmap: Bitmap, title: String) {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "$title.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(
MediaStore.Images.Media.RELATIVE_PATH,
"${Environment.DIRECTORY_PICTURES}/MyApp"
)
}
val uri = context.contentResolver
.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: return
context.contentResolver.openOutputStream(uri)?.use { output: OutputStream ->
bitmap.compress(
Bitmap.CompressFormat.JPEG,
95,
output
)
}
}
중요: MediaStore는 임의의 파일 경로를 지원하지 않습니다 — content URI만 지원합니다. MediaStore.Files.getContentUri()를 통해 URI에서 파일 경로를 얻으려고 하면 Android 11+에서 파일에 대한 직접 경로를 제공하지 않습니다. 대신 ContentResolver.openInputStream() 및 openOutputStream()을 사용하여 콘텐츠를 작업하세요.
Storage Access Framework(SAF)는 앱 샌드박스 외부의 임의 파일 및 디렉토리에 액세스하기 위한 API입니다. SAF는 사용자에게 파일 또는 디렉토리 선택 인터페이스를 제공하며, 이후 앱은 임시 액세스 권한이 있는 content URI를 받습니다.
SAF는 MediaStore가 다루지 않는 시나리오에 사용됩니다. 임의 문서(PDF, ZIP, APK) 작업, SD 카드의 디렉토리 액세스, 다른 앱에서 파일 가져오기 및 내보내기. 사용자는 시스템 파일 선택기를 통해 명시적으로 파일을 선택합니다. 이는 액세스에 대한 동의를 보장합니다.
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
const val REQUEST_CODE_PICK_DIR = 1001
fun pickDirectory(activity: Activity) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
activity.startActivityForResult(intent, REQUEST_CODE_PICK_DIR)
}
fun handlePickResult(requestCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_PICK_DIR && data != null) {
val treeUri: Uri = data.data ?: return
// Take persistent URI permission
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
contentResolver.takePersistableUriPermission(treeUri, flags)
}
}
SAF는 takePersistableUriPermission을 사용할 때 기기 재부팅까지 사용자가 선택한 디렉토리에 대한 액세스를 앱에 제공합니다. 이는 Android 11+의 공유 저장소에서 임의 파일에 대한 액세스를 얻을 수 있는 유일한 방법입니다.
기존 Android 앱을 Scoped Storage로 마이그레이션하려면 여러 주요 영역에서 변경이 필요합니다. Google은 Android 11+에서 테스트하는 단계적 접근 방식을 권장합니다.
공유 저장소의 모든 직접 File 작업을 ContentResolver.openInputStream / openOutputStream으로 교체하세요. 앱 샌드박스(getExternalFilesDir)의 파일은 파일 경로가 계속 작동합니다.
특정 시나리오(예: 파일 관리 — 이를 위한 특별 권한 MANAGE_EXTERNAL_STORAGE가 있음)에 필요하지 않은 경우 READ_EXTERNAL_STORAGE 및 WRITE_EXTERNAL_STORAGE를 제거하세요.
| 시나리오 | 이전 방식 | Scoped Storage 방식 |
|---|---|---|
| 사진 저장 | File(path).writeBytes() | MediaStore + ContentResolver |
| PDF 읽기 | File(path).inputStream() | SAF ACTION_OPEN_DOCUMENT |
| 자체 파일 | Environment.getExternalStorageDirectory() | context.getExternalFilesDir() |
| 캐시 | File(cacheDir).writeBytes() | context.cacheDir(변경 없음) |
마이그레이션 후 build.gradle에서 targetSdk = 30 이상으로 설정하여 Android 11+(API 30)에서 앱을 테스트하세요. 공유 저장소의 모든 파일 작업이 직접 파일 경로가 아닌 MediaStore 또는 SAF를 통해 작동하는지 확인하세요.
import android.os.Build
fun isScopedStorage(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
}
fun compatReadFile(context: Context, uri: Uri): ByteArray? {
return if (isScopedStorage()) {
context.contentResolver
.openInputStream(uri)?.readBytes()
} else {
File(uri.path ?: return null)
.takeIf { it.exists() }
?.readBytes()
}
}
Google Play Console(2024)에 따르면 활성 Android 기기의 78% 이상이 Scoped Storage와 함께 Android 10+에서 실행됩니다. Google Play에 업데이트를 게시하려면 마이그레이션이 필수 단계입니다. 새 앱은 API 31+를, 업데이트는 API 30+를 대상으로 해야 합니다.
자주 묻는 질문
아니요. Android 11(API 30)부터 Scoped Storage는 모든 앱에 필수입니다. Android 10에서 사용 가능했던 requestLegacyExternalStorage 플래그는 API 30+에서 작동하지 않습니다. 파일 시스템에 대한 광범위한 액세스를 얻는 유일한 방법은 MANAGE_EXTERNAL_STORAGE 권한이지만, 이는 파일 관리자 및 바이러스 백신 앱만을 위한 것입니다.
MANAGE_EXTERNAL_STORAGE는 전체 파일 시스템 액세스가 필요한 앱(파일 관리자, 백업 도구)을 위한 특별 권한입니다. 권한을 요청하면 사용자에게 경고와 함께 시스템 화면이 열립니다. Google Play에 게시할 때 이 권한의 필요성에 대한 신고 양식을 작성해야 합니다.
Android 11+의 Scoped Storage에서는 content URI에서 직접 파일 경로를 얻을 수 없습니다. 대신 읽기에는 ContentResolver.openInputStream(), 쓰기에는 openOutputStream()을 사용하세요. 타사 라이브러리와의 호환성을 위해 파일 경로가 필요한 경우 getCacheDir()에 파일 복사본을 만들고 복사본으로 작업하세요.
FileProvider는 변경 없이 계속 작동합니다. 앱 샌드박스의 파일에 대한 액세스를 content URI를 통해 다른 앱에 제공하는 데 사용됩니다. FileProvider는 content URI 수준에서 작동하고 직접 파일 경로가 아니기 때문에 Scoped Storage의 영향을 받지 않습니다.
Build.VERSION.SDK_INT를 확인하세요. >= Build.VERSION_CODES.Q(29)이면 기기가 Scoped Storage를 지원합니다. 그러나 Android 10에서는 requestLegacyExternalStorage를 통해 Scoped Storage가 비활성화될 수 있습니다. Android 11+에서는 매니페스트 플래그 requestLegacyExternalStorage가 설정되어 있는지 확인하세요. 설정되어 있지 않으면 Scoped Storage가 활성화된 것입니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.