본문 바로가기

Capacitor 앱에서의 Android Scoped Storage

Capacitor 앱의 Android scoped storage 가이드: Android 10~14 변경 사항, 여전히 유효한 권한, MediaStore, SAF, 안전한 디렉터리.

Capacitor 앱의 파일에 적용되는 Android scoped storage 규칙

Android scoped storage는 Capacitor 앱이 자체 폴더 밖에서 읽고 쓸 수 있는 범위를 제한합니다. Android 10부터 적용되었고 Android 11부터 완전히 적용된 이 방식에서는 앱이 전용 디렉터리를 자유롭게 사용할 수 있고, MediaStore를 통해 공유 컬렉션에 파일을 추가할 수 있으며, 그 외의 파일은 Storage Access Framework를 통해 사용자가 직접 선택해야 합니다. 이 가이드에서는 Android 버전별로 무엇이 바뀌었는지, 어떤 Capacitor Directory 값이 여전히 유효한지, 그리고 Capacitor 8에서 지원되는 방식으로 파일을 저장, 가져오기, 내보내는 방법을 설명합니다.

버전별 변경 사항

Android API Capacitor 앱에 중요한 변경 사항
10 29 Scoped storage 도입. 앱은 다음을 통해 옵트아웃 가능 requestLegacyExternalStorage="true" API 29 타겟팅 시
11 30 30 이상 타겟팅하는 앱에 Scoped storage 강제 적용. requestLegacyExternalStorage 무시됩니다. WRITE_EXTERNAL_STORAGE 더 이상 아무것도 부여하지 않습니다. MANAGE_EXTERNAL_STORAGE 도입됨. 트리 피커로 Download 루트 또는 볼륨 루트를 선택할 수 없음
12 31-32 주요 스토리지 변경 없음
13 33 READ_EXTERNAL_STORAGE 로 대체됨 READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO. 다른 앱의 문서에 접근할 수 있는 권한 없음
14 34 로 사진 부분 접근 READ_MEDIA_VISUAL_USER_SELECTED
15 및 16 35-36 새로운 스토리지 모델 없음, 사진 및 비디오 권한에 대한 Play 정책 강제 적용

Capacitor 8은 API 36을 타겟으로 하므로, 최신 기기에서는 항상 전체 scoped storage로 실행됩니다. 이전 동작으로 되돌리는 플래그는 없습니다.

이전 Capacitor code에서 무엇이 깨졌나

Capacitor 2 또는 3을 위해 작성되었거나 Cordova에서 이식된 Code은 새 기기에서 조용히 실패하는 경우가 많습니다:

  • Filesystem.writeFile({ directory: Directory.Documents }) 또는 Directory.ExternalStorage “전화로 저장”을 위해. Android 11+에서는 이러한 경로가 공유 스토리지를 가리키며, 작성에 실패하거나 재설치 후 파일을 다시 읽을 수 없습니다.
  • 다음과 같은 임의 경로 읽기 /storage/emulated/0/Download/file.pdf 설정에 입력하거나 서버에서 받은 경로에서
  • 요청하기 READ_EXTERNAL_STORAGE 사용자의 문서를 나열하려면. Android 13 이상에서는 미디어 파일이 아닌 파일에는 이 권한이 아무 효과가 없습니다.
  • 사용하기 Filesystem.requestPermissions() 그리고 ~라고 가정하면 granted 앱이 어디에나 쓸 수 있다는 뜻입니다.
  • 파일 경로를 만들 때 content:// 피커에서 반환된 URI.

권한 없이도 여전히 작동하는 것

앱 자체 디렉터리는 scoped storage의 영향을 받지 않으며 권한도 필요 없습니다:

Capacitor Directory Android 경로 사용자에게 보임 앱 제거 시 삭제됨
Data, Library, LibraryNoCloud /data/data/<package>/files 아니요 지원
Cache /data/data/<package>/cache 아니요 지원
External /storage/emulated/0/Android/data/<package>/files USB 또는 일부 파일 관리자만 지원 지원
ExternalCache /storage/emulated/0/Android/data/<package>/cache 동일 지원
Documents 공유 Documents 폴더 지원 아니요
ExternalStorage 공용 저장소 루트 지원 아니요

첫 4행은 안전합니다. Documents 및 ExternalStorage 레거시 행입니다. Android 11부터 앱은 해당 위치에 있는 파일 중 자신이 생성한 파일만 볼 수 있으며, 재설치 후에는 그 파일에 대한 접근 권한도 잃습니다. 새로운 code에는 지원되지 않는다고 간주하세요.

External 대용량 파일(오프라인 비디오, 지도)에 유용합니다. 더 큰 파티션에 위치할 수 있으며, 해당 위치의 파일은 Auto Backup에 포함되지 않습니다. Android 11부터 다른 앱은 읽을 수 없습니다 Android/data따라서 실제로는 여전히 비공개입니다.

사용자가 찾을 수 있는 파일 저장: MediaStore

사용자가 청구서에서 “다운로드”를 탭하면 Downloads 폴더에 저장되기를 기대합니다. Android 10 이상에서는 앱이 권한 없이 공유 Downloads, Pictures, Movies, Music 및 Documents 컬렉션에 MediaStore를 통해 파일을 삽입할 수 있습니다.

@capgo/capacitor-file-sharer 이것을 save():

bun add @capgo/capacitor-file-sharer
bunx cap sync
import { FileSharer } from '@capgo/capacitor-file-sharer';
import { Filesystem, Directory } from '@capacitor/filesystem';

const { uri } = await Filesystem.getUri({ path: 'exports/invoice-1042.pdf', directory: Directory.Data });

const result = await FileSharer.save({
  filename: 'invoice-1042.pdf',
  path: uri,
  contentType: 'application/pdf',
  android: {
    saveDirectory: 'downloads',     // 'downloads' | 'pictures' | 'movies' | 'music' | 'documents'
    relativePath: 'MyApp/Invoices', // subfolder, Android 10+
  },
});

console.log(result.uri); // content:// URI of the saved file

iOS에서는 동일한 호출이 공유 시트를 열고 사용자가 “Files에 저장”을 선택하므로, 하나의 code 경로가 두 플랫폼을 모두 커버합니다.

Android 9 이하에서는 공유 저장소에 쓰려면 여전히 WRITE_EXTERNAL_STORAGE이 필요합니다. 해당 버전에만 선언하세요:

<uses-permission
    android:name="android.permission.WRITE_EXTERNAL_STORAGE"
    android:maxSdkVersion="28" />

Capacitor 8은 Android 7(API 24) 이상을 지원하므로, 여전히 Android 7~9 기기에 배포 중이라면 그 줄을 유지하세요.

사진과 동영상

미디어의 경우 파일을 직접 쓰는 것보다 플랫폼 플로우를 우선하세요. @capacitor/camera 은/는 saveToGallery: true으로/로 갤러리에 쓰며, 구형 Android 버전에서만 레거시 저장소 권한이 필요합니다. 가이드를 참고하세요: Capacitor에서 사진 촬영 및 편집.

사용자 파일 읽기: 권한 대신 피커

Scoped storage는 사용자가 파일을 선택하도록 기대합니다. Android 선택기는 content:// 저장소 권한이 없어도 앱이 읽을 수 있는 URI를 반환합니다.

import { CapgoFilePicker } from '@capgo/capacitor-file-picker';
import { Filesystem, Directory } from '@capacitor/filesystem';

const { files } = await CapgoFilePicker.pickFiles({ types: ['application/pdf'], limit: 1 });
const picked = files[0];

// Copy into app storage right away: picker grants can expire
await Filesystem.copy({
  from: picked.path!,
  to: `imports/${picked.name}`,
  toDirectory: Directory.Data,
});

사진과 비디오의 경우, @capgo/capacitor-file-picker의 pickMedia 및 @capacitor/camera의 chooseFromGallery 권한이 전혀 필요 없는 Android 13+의 Android Photo Picker를 사용하세요.

READ_MEDIA_* 권한 피하기

Google Play의 사진 및 비디오 권한 정책은 다음만 허용합니다 READ_MEDIA_IMAGES 및 READ_MEDIA_VIDEO 갤러리 앱이나 사진 편집기처럼 핵심 기능이 광범위한 갤러리 접근을 요구하는 앱에 해당합니다. 그 외의 앱은 반드시 피커를 사용해야 하며, Play Console에 이를 명시해야 합니다. 사용하는 플러그인이 이러한 권한을 선언하는 경우, 병합된 매니페스트에서 제거하세요:

<uses-permission
    android:name="android.permission.READ_MEDIA_IMAGES"
    tools:node="remove" />
<uses-permission
    android:name="android.permission.READ_MEDIA_VIDEO"
    tools:node="remove" />

추가 xmlns:tools="http://schemas.android.com/tools" 에 <manifest> 태그를 먼저 추가하세요.

Storage Access Framework로 폴더 접근

일부 앱은 폴더에 대한 지속적 접근이 필요합니다. 사용자 폴더로 동기화되는 메모 앱이나 현장 앱의 내보내기 폴더가 그 예입니다. Storage Access Framework(SAF)를 사용하면 사용자가 폴더를 ACTION_OPEN_DOCUMENT_TREE:

const { path } = await CapgoFilePicker.pickDirectory();
// On Android: a content://com.android.externalstorage.documents/tree/... URI

알아야 할 세 가지:

  1. 결과는 경로가 아닌 트리 URI입니다. 에 전달할 수 없습니다. Filesystem.writeFile폴더 내부의 파일은 DocumentsContract 또는 DocumentFile 을 통해 네이티브 code에서 생성 및 읽습니다.
  2. 재시작 후에도 접근 권한이 유지되지 않습니다 앱이 URI에 대해 takePersistableUriPermission 를 호출하지 않는 한. 해당 기능이 필요하면 작은 네이티브 메서드를 추가하세요:
@PluginMethod
public void persist(PluginCall call) {
    Uri uri = Uri.parse(call.getString("uri"));
    getContext().getContentResolver().takePersistableUriPermission(
        uri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
    call.resolve();
}
  1. 일부 폴더는 권한을 부여할 수 없습니다. Android 11 이상에서는 피커가 다음을 거부합니다: 내부 저장소 루트, 해당 Download 폴더의 루트, 그리고 Android/data 및 Android/obb. 사용자에게 하위 폴더를 만들거나 선택하도록 안내하세요.

앱당 영구화된 권한은 개수에 제한이 있으며, 사용자는 시스템 설정이나 앱 데이터 삭제 시 이를 취소할 수 있습니다. 접근 권한은 getPersistedUriPermissions() 권한이 없으면 다시 요청하세요.

모든 파일 액세스: MANAGE_EXTERNAL_STORAGE

<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />

이 권한은 예전의 “모든 파일 읽기 및 쓰기” 동작을 제공하지만, 런타임 프롬프트는 아닙니다. 사용자가 별도 설정 화면에서 직접 활성화해야 하며, Google Play에서 수동으로 검토합니다. 허용되는 사용 사례는 파일 관리자, 백업 및 복구, 백신, 문서 관리, 기기 내 파일 검색 등 광범위한 액세스가 핵심 기능인 몇 가지 경우입니다. 메모 앱, Downloads 폴더의 파일을 여는 PDF 뷰어, 또는 “내보내기 파일을 저장하기만 하면 되는” 앱은 거부됩니다.

Play 외부(기업용 MDM, 사이드로드된 키오스크 기기)로 배포한다면 선택지가 될 수 있습니다. 그렇지 않다면 MediaStore와 SAF를 기반으로 설계하세요.

다른 플러그인과의 연동

플러그인 Scoped storage의 영향
@capacitor/filesystem 앱 디렉터리에서는 문제없습니다. Documents 및 ExternalStorage Android 11+에서는 레거시입니다. requestPermissions() 오래된 Android에서는 해당 디렉터리에만 관련됩니다
@capacitor/camera 시스템 카메라와 Photo Picker를 사용하며, 다음을 제외하고는 저장소 권한이 필요하지 않습니다 saveToGallery Android 9 이하
@capacitor/share 다음으로 공유합니다 FileProvider. 다음에 나열된 경로만 res/xml/file_paths.xml 공유할 수 있습니다
@capgo/capacitor-file-sharer 자체 FileProvider 및 MediaStore를 save(), 별도 설정 불필요
@capgo/capacitor-downloader 앱 외부 저장소에 다운로드한 후 사용 save() 내보내려면
@capgo/capacitor-file-picker Picker 기반, Android 13+ 권한 불필요
@capgo/capacitor-photo-library 매니페스트에 추가하는 항목 READ_MEDIA_* 전체 라이브러리 읽기용, 위 Play 정책 적용 대상. pickMedia() 시스템 picker를 사용하며 권한이 필요 없으므로, 이것만 사용한다면 제거하세요 READ_MEDIA_* 포함 tools:node="remove"

어떤 디렉토리를 사용할까

  • 앱 데이터, 설정, 데이터베이스: Directory.Data
  • 임시 파일 및 공유 내보내기: Directory.Cache
  • 재다운로드 가능한 대용량 미디어: Directory.External Android에서, Directory.LibraryNoCloud iOS에서
  • 다운로드나 갤러리에 저장할 파일: MediaStore를 통해 FileSharer.save()
  • 앱으로 가져온 파일: 선택 후 복사 Directory.Data
  • 앱이 계속 작성할 폴더: SAF 트리 URI 및 영구 권한

완전한 읽기/쓰기 API에 대해, Capacitor 파일 처리 가이드.

기존 앱 마이그레이션

  1. code에서 검색 Directory.Documents, Directory.ExternalStorage, /storage/emulated, 그리고 requestLegacyExternalStorage.
  2. 앱 소유 파일을 다음 위치로 이동하세요: Directory.Data 또는 Directory.External. 새 버전을 처음 실행할 때, 아직 읽을 수 있는 기존 파일을 복사한 뒤 삭제하세요.
  3. “기기에 저장” 기능을 다음으로 교체하세요: FileSharer.save().
  4. 경로 입력을 피커로 교체하세요.
  5. 추가 android:maxSdkVersion 레거시 스토리지 권한으로 전환하고 제거하세요: READ_MEDIA_* 필요하지 않다면.
  6. Android 10, 11, 13, 15 또는 16 기기나 에뮬레이터에서 테스트하세요. 동작은 바로 그 경계에서 달라집니다.

preserveLegacyExternalStorage="true" 플러그인의 <application> 태그는 이를 사용하던 버전에서 업데이트된 앱에 레거시 접근을 유지합니다. requestLegacyExternalStorage, 앱이 재설치될 때까지 유지됩니다. 일회성 마이그레이션 단계에서만 도움이 되므로, 이 기능에 의존하지 마세요.

문제 해결

EACCES (Permission denied) 쓰기 /storage/emulated/0/...: 공유 저장소에 직접 쓰고 있습니다. MediaStore나 앱 디렉토리를 사용하세요.

다음으로 저장한 파일: Directory.Documents 재설치 후 사라짐: 앱의 소유권이 상실되었습니다. 사용자 파일에는 MediaStore를 사용하세요.

FileNotFoundException 파일 읽기 중: content:// 나중에 URI 접근 시: 임시 권한이 만료되었습니다. 파일을 선택할 때 복사하거나, tree URI의 경우 권한을 유지하세요.

사진 및 동영상 권한 관련 Play Console 경고: 플러그인이 추가한 READ_MEDIA_IMAGES 또는 READ_MEDIA_VIDEO. 제거하려면 다음을 사용하세요: tools:node="remove" 필요하지 않다면.

requestPermissions() granted를 반환하지만 쓰기 실패: Android 11+에서는 레거시 스토리지 권한이 granted 상태이지만 공유 스토리지에는 의미가 없습니다.

네이티브와 웹 파트 동기화 유지

스토리지 규칙은 네이티브 설정이므로 매니페스트나 플러그인 변경 시 스토어 릴리스가 필요하며, Capgo 빌드 클라우드에서 생성할 수 있습니다. 관련 파일 로직(경로, 폴더 이름, 내보내기 플로우)은 Live Update로 업데이트 가능한 JavaScript입니다. Capgo Live Update 사용하기.

Capacitor 앱용 Live updates

웹 레이어 버그가 라이브 상태라면, 앱 스토어 승인을 며칠씩 기다리지 말고 Capgo를 통해 수정 사항을 배포하세요. 네이티브 변경 사항은 정상 리뷰 절차를 그대로 따르는 반면, 사용자는 백그라운드에서 업데이트를 받습니다.

블로그 최신 글

Capgo는 정말 프로페셔널한 모바일 앱을 만드는 데 필요한 최고의 인사이트를 제공합니다.