brunch

You can make anything
by writing

C.S.Lewis

by Chansuk Yang Apr 11. 2019

안드로이드 Q Scoped Storage에서 살아남기

개발자를 위한 안드로이드 Q #6

시작하기 전에

이 포스트는 안드로이드 Q 베타 2 패치 버전을 기준으로 작성되었습니다. Q 정식 버전에서는 기능 및 API가 변경될 수 있습니다. 기능에 관한 소감이나 의견은 개인적인 의견으로 회사의 공식 의견과는 다를 수 있습니다. 본 포스트는 개발자를 위한 안드로이드 Q #2편 내용을 기반으로 Scoped Storage에서 파일을 읽고 쓰는 방법과 앱 삭제 시 주의점을 정리해 보았습니다. 

2019년 4월 26일자로 안드로이드 개발자 블로그를 통해, 타깃 SDK 버전이 Q이상인 경우에만 Scoped Storage 적용을 받는것으로 수정되었습니다. Scoped Storage 적용 및 테스트는 타깃 SDK 버전을 올리는 시점에 맞춰 진행하면 될 것으로 보입니다. 공식 블로그 문서를 참고 부탁드립니다. 

Scoped Storage 다시 돌아보기

안드로이드 Q에서 새롭게 Scoped Storage가 적용됩니다. 외부 저장소는 용도가 명확히 정해진 공용 저장소(사진 및 동영상, 음악, 다운로드)와 개별 앱만 접근 가능한 샌드박스 영역으로 구분됩니다. 각 앱들은 자신의 샌드박스 영역에서 자유롭게 파일을 읽고 쓸 수 있습니다. MediaStore를 통해 공용 공간에 접근해 파일을 생성하고, 자신이 생성한 파일을 읽을 수 있습니다. 

기존 EXTERNAL_STORAGE 권한은 새로운 READ_MEDIA_XXX 권한으로 대체됩니다. 이 권한은 공용 저장소에 저장된 특정 종류의 콘텐츠 중 다른 앱이 생성한 콘텐츠에 접근할 때 필요합니다. 다만, 다운로드 공간은 프로그래밍적으로는 접근이 불가능하며 반드시 시스템 파일 선택기 혹은 저장소 액세스 프레임워크 (Storage Access Framework, 이하 SAF)를 통해야 합니다. 그럼 안드로이드 Q에서 권한 유무에 따라 어떤 작업을 할 수 있는지 살펴보겠습니다. 


아무 권한이 없음

샌드박스 영역에 자유롭게 파일을 읽고 쓸 수 있습니다.

MediaStore를 통해 공용 공간에 파일을 생성하고 생성한 파일을 읽고 쓸 수 있습니다.

ACTION_PICK 인텐트를 통해 파일 선택기를 불러, 사용자가 선택한 파일을 읽을 수 있습니다. 

GET_CONTENT, OPEN_DOCUMENTOPEN_DOCUMENT_TREE 인텐트를 통해 SAF를 불러 사용자가 선택한 파일 혹은 디렉터리를 읽고 쓸 수 있습니다. SAF를 통해 선택된 디렉터리 및 파일 접근 권한을 영구적으로 유지할 수 있습니다.

CREATE_DOCUMENT 인텐트를 통해 SAF를 불러 사용자가 새로운 파일을 만들도록 안내할 수 있습니다. SAF를 통해 생성된 파일은 자신이 생성한 파일로 간주되며 이후에도 해당 파일을 자유롭게 읽고 쓸 수 있습니다. 

READ_MEDIA_XXX 권한이 있음

MediaStore.Images.Media.EXTERNAL_CONTENT_URI (혹은 Video, Audio) 공용 저장소에 접근해 저장된 미디어 목록을 확인하고 읽을 수 있습니다.

기본 갤러리 혹은 동영상 앱으로 지정됨

부여받은 권한에 따라 MediaStore.Images.Media.EXTERNAL_CONTENT_URI (혹은 Video, Audio)등 공용 저장소에 접근해 저장된 미디어 목록을 확인하고 읽고 쓸 수 있습니다.

불가능

미디어 공용 저장소를 통하지 않고, 다른 앱이 저장한 파일을 읽고 쓸 수 없습니다. 다른 앱이 생성한 파일에 접근하기 위해서는 파일 선택기, FileProvider, SAF 등을 통해 특정 파일에 관한 접근 권한을 명시적으로 부여받아야 합니다.

정리하자면 대부분의 작업이 외부 저장소 권한 없이 가능합니다. 앱 동작에 필요한 파일을 관리하는 것은 샌드박스 영역을 활용할 수 있습니다. 다른 앱으로 콘텐츠를 전달하는 경우라면, 샌드박스 영역에 저장한 파일을 FileProvider로 전달하거나, 권한 요청 없이 공용 공간에 파일을 바로 저장할 수 있습니다. 많은 앱들이 습관적으로 외부 저장소 권한을 사용하고 있지만, 안드로이드 Q부터는 이런 부분이 많이 줄어들 것으로 예상됩니다. 


저장소 액세스 프레임워크 (Storage Access Framework - SAF)

저장소 액세스 프레임워크는 안드로이드 4.4 버전에서 처음 소개된 기능입니다 (새로운 안드로이드 기능을 소개하던 동영상을 찍으면서 관련된 내용을 언급했던 흑역사가 떠오르네요...) 매우 유용한 기능이고 Q 이후부터는 더욱 중요한 기능이 될 것으로 예상되지만, 생각보다 잘 알려지지 않은 기능입니다. 놀랍게도, 개발자 사이트에 한글로 내용이 잘 정리되어 있습니다. 자세한 내용이 궁금하신 분들은 한 번 살펴보시면 좋을 것 같습니다. 여기서는 핵심 기능과 특징을 간단히 짚고 넘어가도록 하겠습니다. 

SAF 기본 구조 - 문서 제공자, 선택기 UI, 클라이언트 앱으로 구성됩니다.

SAF는 사용자가 (혹은 클라이언트 앱이) 일관된 방식으로 파일을 탐색하고, 읽고 쓸 수 있는 환경을 제공하기 위해 만들어졌습니다. SAF는 크게 문서 제공자, 선택기 UI, 클라이언트 앱으로 구성됩니다. 

클라이언트 앱은 SAF를 통해 파일을 액세스 하려는 앱입니다. 파일을 읽고 쓰는 대부분의 앱이 클라이언트 앱의 범주에 속합니다. 

선택기 UI는 사용자가 파일을 선택하거나 새로운 파일을 만들 때 사용하는 시스템 파일 선택기입니다. 

문서 제공자(DocProvider)는 사용자가 자신의 콘텐츠를 SAF를 통해 접근할 수 있도록 콘텐츠를 제공하는 앱입니다. 예를 들어 구글 드라이브의 경우, SAF 문서 제공자를 구현하고 있습니다. 따라서, 사용자는 SAF 선택기 UI를 통해 구글 드라이브에 저장된 자신의 파일도 선택할 수 있습니다. 


SAF는 안드로이드 초기버전부터 존재했던 ACTION_PICK 인텐트를 사용하는 방법과 비교해서 몇 가지 차이점이 있습니다. 

SAF는 안드로이드 킷캣 4.4 이상 버전부터 사용 가능합니다 (OPEN_DOCUMENT_TREE는 5.0 이상).

SAF는 GET_CONTENT, OPEN_DOCUMENT, OPEN_DOCUMENT_TREE, CREATE_DOCUMENT 인텐트를 통해 실행 가능합니다.  

사용자에게 일관된 경험을 제공하기 위해, 선택기 UI가 시스템 앱(com.android.documentsui)으로 제공됩니다. 다른 앱은 해당 인텐트를 처리할 수 없습니다 (안드로이드 9부터 CDD에 명시된 것으로 보이며, 이전 버전에서는 동작이 다를 수 있습니다). 반면 ACTION_PICK은 어떤 앱이든 인텐트 필터를 추가해, 해당 인텐트를 처리할 수 있습니다. ACTION_PICK 인텐트를 처리할 수 있는 앱이 여러 개인 경우 그중 마음에 드는 것을 사용자가 선택할 수 있습니다.

OPEN_DOCUMENT, OPEN_DOCUMENT_TREE 인텐트를 통해, 클라이언트 앱은 선택된 파일에 대한 영구적인 접근 권한을 가질 수 있습니다 (앱 재시작 후에도 계속 접근 가능, 자세한 내용은 개발자 문서를 참고하세요).  

새로운 파일 생성 및 파일 선택을 위한 일관된 시스템 UI를 제공합니다.


Scoped Storage에서 파일 읽고 쓰기

그럼 실제로 Scoped Storage 환경에서 파일을 읽고 쓸 때 어떤 방식을 활용할 수 있는지 간단히 살펴보겠습니다. 


1. 샌드박스 공간에서 읽고 쓰기

샌드박스 환경에서는 기존 루트 디렉터리였던 '/sdcard' 경로가 개별 앱의 홈 디렉터리로 매핑됩니다. 디바이스 파일 탐색기로 살펴보니 정확한 경로는 '/sdcard/Android/sandbox/<package_name>' 형태로 확인됩니다. 만일, 외부 저장소 다운로드 폴더에 파일을 저장할 생각으로 아래와 같이 파일을 생성하면, 

File("/sdcard/Download/myfile.txt")

실재 파일은 '/sdcard/Android/sandbox/<package_name>/Download/myfile.txt' 형태로 생성됩니다.  추가로 Context#getExternalFileDir 메서드를 통해 외부 저장소에 접근하면, 기존과 유사하게 '/sdcard/Android/Data/<package_name>' 경로가 반환되며, 이 디렉터리 역시 샌드박스 형태로 격리되어 있는 개별 앱 저장소입니다. 굳이 두 곳의 샌드박스를 유지하는 이유는 아마도 하위 호환성을 위해 고려된 부분이 아닌가 예상됩니다 (기존 앱이 자신의 앱 파일을 절대 경로 형태로 참조하고 있거나 혹은 안드로이드 OS 업데이트 시에도 기존에 설치된 앱들의 동작을 계속 유지하기 위해).


기본적으로 개별 앱의 자신만의 격리된 공간을 갖기 때문에, 샌드박스 공간에 저장된 파일을 파일 절대 경로를 통해 다른 앱으로 전달하는 것은 더 이상 지원되지 않습니다. 


2. Uri를 통해 읽고 쓰기 

공용 저장소나 다른 앱의 샌드박스 공간에 저장된 파일을 주고받을 때는 Uri 형식이 사용됩니다. 기본적으로 해당 파일에 접근할 수 있는 권한이 있다면, ContentResolver 클래스에서 제공하는 openFileDescriptor, openInputStream, openOutputSteam 메서드를 통해 Uri가 가리키는 파일을 읽고 쓸 수 있습니다.


특정 Uri에 대해 읽기 쓰기 권한을 갖고 있는지 확인하고 싶은 경우 Context#checkUriPermission 메서드를 사용할 수 있습니다. 권한이 있는 경우 PackageManager.PERMISSION_GRANTED가 반환됩니다.

checkUriPermission(
    uri, 
    android.os.Process.myPid(),
    android.os.Process.myUid(), 
    Intent.FLAG_GRANT_READ_URI_PERMISSION)

OPEN_DOCUMENT, OPEN_DOCUMENT_TREE 인텐트를 통해 전달받은 Uri인 경우, 영구적으로 해당 파일에 접근할 수 있는 권한이 부여될 수 있습니다. ContentResolver#takePersistableUriPermission 메서드를 통해 영구적인 (폰 재부팅 후에도 유지되는) 권한을 요청할 수 있습니다. 다만, 이후에도 언제든지 다른 앱이 해당 권한을 뺏거나, 아니면 파일 자체가 삭제될 수 있음으로, 해당 uri를 접근하기 전에는 항상 권한 여부를 확인해야 합니다.


자신의 파일을 다른 앱으로 전달하는 경우도 Uri가 사용됩니다. 샌드박스 공간에 저장된 파일을 전달하는 경우 앱 매니페스트에 FileProvier를 정의한 후,  FileProvider#geUriForFile 메서드를 이용해 파일을 Uri 형식으로 변환합니다. 공용 저장소에 저장된 파일은 이미 Uri 형식으로 표현되어 있습니다. 이후 파일을 받을 앱이 해당 Uri에 접근할 수 있도록 권한을 부여합니다. 만일 Uri를 인텐트로 감싸 전달할 계획이면 FLAG_GRANT_READ_URI_PERMISSIONFLAG_GRANT_WRITE_URI_PERMISSION 등의 인텐트 플래그를 사용할 수 있습니다. 그렇지 않다면 명시적으로 Context#grantUriPermission 메서드를 호출해 특정 앱에게 Uri 접근 권한을 부여하고 revokeUriPermission 메서드를 호출해 부여한 권한을 다시 회수할 수 있습니다. 


3. 공용 저장소에 파일 생성하기

MediaStore에 새로운 파일을 생성할 때도 Uri가 사용됩니다. MediaStore에 새로운 열을 추가하고 새로운 Uri를 받습니다. Uri를 받은 후에는 ContentResolver를 통해 파일을 씁니다. MediaStore에서 제공하는 EXTERNAL_CONTENT_URI를 사용 ContentResolver의 insert 메서드를 호출해 새로운 열을 추가할 수 있습니다.

// 새로운 열에 추가된 메타데이터를 구성합니다.
val values = ContentValues()
values.put(MediaStore.Video.Media.DISPLAY_NAME, "test1")
values.put(MediaStore.Video.Media.DESCRIPTION, "test1 video")
values.put(MediaStore.Video.Media.MIME_TYPE, "video/*")
values.put(MediaStore.Video.Media.SECONDARY_DIRECTORY, "test")

// MediaStore에 열을 추가하고 해당 열을 가리키는 Uri를 받습니다.
val newUri 
        = contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)

// ContentResolver를 통해 해당 Uri가 가리키는 파일을 읽고 씁니다. 
contentResolver.openFileDescriptor(newUri, ...)
...

MediaStore에 파일을 생성할 때 한 가지 주의점이 있습니다. 이미 존재하는 파일을 바로 MediaStore에 추가하기 위해 insertImage 메서드나 혹은 MediaColumns.DATA 칼럼을 사용하는 것은 권장되지 않습니다. 샌드박스 환경을 제외하면, 절대 경로 기반으로 파일을 읽고 쓸 수 없습니다. 특정 앱이 직접 파일 경로를 지정한 경우, MediaStore에 저장된 파일 경로를 참조하는 앱은 해당 파일을 읽지 못할 수 있습니다. 파일 관리를 위해 디렉터리를 지정하고 싶은 경우, 새롭게 추가된 PRIMARY_DIRECTORY 혹은 SECONDARY_DIRECTORY 칼럼을 이용해, 공용 저장소 내 파일이 생성될 하위 디렉터리 명을 지정할 수 있습니다.


4. 앱이 삭제되면?

마지막으로 앱이 삭제되는 경우 주의할 점을 살펴보겠습니다. 기본적으로 앱이 삭제되면 샌드박스 공간에 저장된 모든 파일이 함께 삭제됩니다. 앱 삭제 후에도 남아있어야 하는 파일이 있다면 (예를 들어 용량이 아주 큰 동영상 파일 등), 해당 파일은 반드시 공용 저장소에 저장해두어야 합니다. 

공용 저장소에 파일을 저장한 앱 삭제  시, 미디어 파일 삭제 여부를 묻는 확인 팝업이 표시됩니다

공용 저장소에 저장된 파일이 있으면, 사용자가 앱 삭제 시, 해당 앱이 소유한 미디어 파일도 함께 삭제할지 여부를 확인하는 팝업 창이 표시됩니다. 


다만, 사용자가 미디어 파일을 보존하기로 선택한 경우라도, 앱이 삭제되면 해당 앱은 기존 파일에 대한 소유권을 잃게 됩니다. 다시 말해, 앱이 재설치된 후에는 다른 앱이 생성한 파일에 접근할 때와 마찬가지로, 필요한 권한을 갖고 접근하거나 파일 선택기 혹은 SAF를 통해 해당 파일을 선택하도록 사용자를 안내해야 합니다.


마무리

안드로이드 Q 베타 2 버전 공개를 기념하여, Scoped Storage 변경 사항을 조금 더 자세히 살펴보았습니다. 개인적으로 몇몇 앱들을 테스트해보니 크고 작은 문제를 여럿 발견할 수 있었습니다. 아직 Scoped Storage 호환성 테스트하지 않은 분들은 꼭 한 번 테스트를 해보시길 권장드립니다. 가장 최근에 업데이트된 안드로이드 Q 베타 2 패치 버전을 설치한 경우 기본으로 Scoped Storage 기능이 활성화됩니다. 


개발자 및 앱 생태계에 영향이 클 수 있는 만큼, 구글에서도 열심히 개발자 피드백을 모으고 있는 것으로 보입니다. 아직 Q의 모든 기능이 확정되기 전인 만큼, 호환성 테스트를 진행하고 문제가 발생하거나 구글에 전달할 피드백이 있는 경우 안드로이드 베타 페이지에 포함된 다음 링크로 의견을 전달하면 좋을 것 같습니다 (동시에 블로그 댓글로 남겨주셔도 좋습니다). 


마지막으로 개발자 주의가 예상되는 안드로이드 Q의 변경 사항을 정리해 '개발자를 위한 안드로이드 Q 정리'라는 브런치 매거진으로 발행하고 있습니다. 안드로이드 Q 버전이 정식 출시될 때까지, '개발자를 위한 안드로이드 Q 정리 매거진'에 많은 관심 부탁드립니다 : ) 이어질 다음 포스트에서는 안드로이드 앱 개발자를 기다리고 있는 또 하나의 큰 산 - '재설정 불가능한 기기 식별자 제한' 변경 사항을 한 번 더 살펴보겠습니다. 

브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari