본문 바로가기

[Android] Scoped Storage에서 MediaStore를 활용한 이미지 다운로드 기능 구현하기

@Marchbreeze2025. 5. 26. 06:45

Android 11(API 30)부터 강제된 Scoped Storage 환경에서, MediaStore API를 사용해 이미지를 다운로드하는 기능을 구현합니다.

 

 

1. Scoped Storage란

해당 글을 참고하였으며, 더 자세한 내용을 담고있으니 필요 시 참고하세요!

 

Android Storage #2 - Internal Storage & External Storage

안드로이드 내부 저장소와 외부 저장소에 대해서 자세히 알아봅니다!

velog.io

Scoped Storage란 Android 11(API 30)부터 모든 앱에 적용되는 외부 저장소 접근 모델로, 앱별 분리된 공간을 통해 사용자 개인정보를 보호하고, 앱 간 간섭을 최소화합니다.

출처 : https://velog.io/@kwan_hee/Android-Storage-2-Internal-Storage-External-Storage

  1. 기존 Legacy Storage
    • 외부 저장소가 하나의 공용 저장소로 동작하여, 읽고 쓸 수 있는 권한이 있으면 모든 파일에 접근이 가능했습니다.
    • READ/WRITE_EXTERNAL_STORAGE 권한만으로 임의 경로에 자유롭게 접근할 수 있었습니다.
    • 따라서 개별 앱들이 간접적인 방법으로 다른 앱의 정보를 확인할 수 있는 위험성을 갖고 있었습니다.
  2. 현재 Scoped Storage
    • 기존 외부 저장소의 Public한 공간이 완전히 사라진 저장소입니다.
    • 외부 저장소의 이미지, 동영상, 오디오, 다운로드에 대해서만 제한적인 접근이 가능하도록 변경되었습니다.
    • 미디어 파일은 MediaStore API를, 일반 파일은 SAF(Storage Access Framework)를 통해서 접근할 수 있습니다.
    • 개인정보 보호와 앱 간 간섭 최소화라는 큰 이점을 제공합니다.

 

 


2. MediaStore를 활용한 이미지 다운로드 구현

Scoped Storage 환경에서 미디어(사진·영상)는 MediaStore를 통해 삽입·조회·삭제합니다.

ContentResolver로 새 항목을 만들고, 스트림에 쓰기 후 IS_PENDING 플래그를 해제하는 패턴이 핵심입니다.

 

다음과 같은 로직으로 구현했습니다.

suspend fun saveImageToStorage(id: Long, imageUrl: String) =
    runCatching {
        withContext(Dispatchers.IO) {
            val resolver = context.contentResolver
            val bitmap = downloadBitmapFromUrl(imageUrl) ?: throw Exception()
            val imageUri = resolver.insert(setContentUri(), setMetaData(id)) ?: throw Exception()
            resolver.openOutputStream(imageUri).saveBitmapToFile(bitmap)
            resetPendingState(imageUri)
        }
    }

 

(1) ContentResolver 설정

ContentResolver는 앱과 ContentProvider 사이의 통신 중개자 역할을 수행하는 Android 시스템 API입니다.

파일·미디어·연락처·설정 등 다양한 공용 데이터 소스에 대한 CRUD(생성·조회·업데이트·삭제) 요청을 추상화된 메서드로 제공합니다.

(2) 이미지 비트맵 생성

coil3 라이브러리의 imageLoader를 활용하여, URL에서 이미지를 다운로드하고 디코딩하여 비트맵으로 결과를 가져옵니다.

private suspend fun downloadBitmapFromUrl(imageUrl: String): Bitmap? =
    // 1) Coil ImageLoader 초기화
    val coilImageLoader = ImageLoader.Builder(context).build()
    
    // 2) 요청 생성 및 실행
    val request = ImageRequest.Builder(appContext)
        .data(imageUrl)
        .allowHardware(false) // 비트맵 처리 안전성
        .build()
    coilImageLoader.execute(
        ImageRequest.Builder(appContext).data(imageUrl).build()
    ).image?.toBitmap()
    
    // 3) 결과에서 비트맵 추출
    loader.execute(request).image?.toBitmap()
}

(3) ContentResolver에 새 이미지 항목 생성

ContentResolver.insert를 통해 MediaStore에 새로운 이미지 항목을 생성한 후, 해당 이미지의 Content URI를 받아옵니다.

이때 insert에 ContentUri와 MetaData를 설정하여 구현합니다.

1) Content URI 설정 : 기본 외부 저장소의 이미지 데이터를 관리하는 URI를 반환합니다.

private fun setContentUri() =
    MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

2) MetaData 설정 : 이미지 저장에 필요한 파일명, MIME 타입, 상대 경로를 설정하며, 파일 저장 시 파일이 완전히 작성되기 전까지 다른 앱에서 접근하지 못하도록 하기 위해 IS_PENDING 플래그를 1로 설정합니다.

private fun setMetaData(id: Long) =
    ContentValues().apply {
        put(DISPLAY_NAME, "img_genti_${id}.jpeg")
        put(MIME_TYPE, "image/jpeg")
        put(RELATIVE_PATH, DIRECTORY_PICTURES)
        put(MediaStore.Images.Media.IS_PENDING, 1)
    }

(4) 비트맵을 변환한 후 파일에 저장

FileOutputStream을 사용하여 비트맵 이미지를 File에 저장합니다.

이때 use를 활용해서 스트림을 자동으로 클로즈하도록 보장하며, 비트맵 객체를 JPEG 형태로 압축하여 바이트로 기록합니다.

private fun OutputStream?.saveBitmapToFile(bitmap: Bitmap) {
    this?.use { outputStream ->
        bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
    } ?: throw Exception()
}

 

(5) 보류 상태 해제

MediaStore에 새로 삽입한 이미지 파일의 보류 상태를 해제한 후 업데이트합니다.

private fun resetPendingState(uri: Uri) {
    val values = ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }
    resolver.update(uri, values, null, null)
}

 

 


3. min SDK가 28 이하인 경우 대응

API 29에서 도입된 RELATIVE_PATH·IS_PENDING 플래그를 사용할 수 없는 기기에서는, 직접 외부 퍼미션을 획득해 직접 경로 접근 방식으로 구현해야 합니다.

(1) 외부 저장소 접근 권한 획득

1) manifest 선언

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

2) 권한 동의 여부 확인

fun checkExternalStoragePermission(): Boolean {
    return if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
        ContextCompat.checkSelfPermission(
            appContext, WRITE_EXTERNAL_STORAGE,
        ) == PackageManager.PERMISSION_GRANTED
    } else {
        true
    }
}

3) 런처 설정 및 실행

// Route에 변수 설정    
val writePermissionLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
    if (isGranted) viewModel.onIntent(ProfileIntent.SaveBtnClick)
}

// 권한 요청 필요한 경우
writePermissionLauncher.launch(WRITE_EXTERNAL_STORAGE)

(2) 로직에서 분기처리 추가

1) Content URI를 설정하는 과정에서, EXTERNAL_CONTENT_URI 상수를 사용하여 외부 저장소의 이미지 데이터에 접근합니다.

private fun setContentUri() =
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
    } else {
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    }

2) MetaData를 설정하는 과정에서, 상대 경로 설정 대신 외부 저장소의 Pictures 디렉토리가 존재하는지 확인하고, 없으면 생성합니다.

private fun setMetaData(id: Long) =
    ContentValues().apply {
        put(DISPLAY_NAME, "img_genti_${id}_${System.currentTimeMillis()}.jpeg")
        put(MIME_TYPE, "image/jpeg")
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            put(RELATIVE_PATH, DIRECTORY_PICTURES)
            put(MediaStore.Images.Media.IS_PENDING, 1)
        } else {
            Environment.getExternalStoragePublicDirectory(DIRECTORY_PICTURES).mkdirs()
        }
    }

3) 보류 상태 기능을 28 이상만 가능하도록 설정합니다.

private fun resetPendingState(uri: Uri) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        val values = ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }
        resolver.update(uri, values, null, null)
    }
}

 

 

다음과 같은 방법을 활용하여, 모든 안드로이드 버전에서 가능한 이미지 다운로드 로직을 구현했습니다.


참고 자료:

공유 저장소 미디어 파일에 액세스  |  App data and files  |  Android Developers

Android Storage #2 - Internal Storage & External Storage

[안드로이드] 저장소 사용하기 - 2. Scoped Storage

Marchbreeze
Marchbreeze

안드로이드 개발자, 김상호입니다.

https://github.com/Marchbreeze

목차