Android 앱 이미지 다운로드와 파일 저장 완벽 가이드 — MediaStore, Photo Picker, SAF와 Android 13~16 저장소 정책

Android 앱을 개발하다 보면 이미지나 파일을 저장하고 불러오는 기능이 필요합니다.

예를 들어 조류 도감 앱에서는 사용자가 새 사진을 갤러리에 저장하고 싶을 수 있습니다. 지도 앱에서는 안내도 이미지를 다운로드하거나, GPS 정보를 포함한 사진을 불러와 위치를 확인해야 할 수도 있습니다.

과거에는 외부 저장소에 접근하기 위해 READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE 권한을 요청하는 방식이 일반적이었습니다.

하지만 Android 10부터 Scoped Storage가 도입되면서 파일 접근 방식이 크게 달라졌습니다.

Android 13에서는 이미지·동영상·오디오 권한이 분리되었고, Android 14에서는 사용자가 특정 사진만 선택해 접근을 허용할 수 있게 되었습니다.

이제는 파일을 어디에 저장하고, 누구의 파일을 읽으며, 사용자가 직접 파일을 선택하는지에 따라 적절한 API를 선택해야 합니다.

이번 글에서는 Kotlin + XML 기반 Android 앱을 기준으로 다음 기능을 구현하겠습니다.

  • 이미지 파일을 갤러리에 저장하기

  • 인터넷에서 다운로드한 이미지를 MediaStore에 저장하기

  • Photo Picker로 사용자 사진 선택하기

  • SAF로 PDF·CSV·JSON 파일 저장하기

  • FileProvider로 파일 공유하기

  • EXIF 메타데이터와 GPS 정보 읽기

  • Android 13~16 저장소 권한 정책 대응하기

개발 환경: Kotlin, XML ViewBinding, MVVM, Coroutine, OkHttp, AndroidX Activity Result API, ExifInterface. minSdk 24, targetSdk 35~36을 고려합니다.


1. Android 저장소 구조 이해하기

Android에서 파일을 저장하는 공간은 크게 앱 전용 저장소와 공유 저장소로 나눌 수 있습니다.

저장 방식특징활용
내부 앱 저장소앱 전용 데이터 저장설정, 내부 이미지, DB
외부 앱 전용 저장소앱 전용 외부 저장 공간캐시, 임시 다운로드
MediaStore공유 이미지·동영상·음악 관리갤러리 저장
Storage Access Framework사용자 지정 문서 선택·저장PDF, CSV, ZIP
Photo Picker사용자 선택 사진·동영상 접근사진 첨부, 이미지 가져오기

중요한 점은 공유 저장소를 사용한다고 항상 저장소 권한이 필요한 것은 아니라는 사실입니다.

Android 10 이상에서 앱이 MediaStore에 직접 생성한 미디어 파일은 일반적으로 저장소 권한 없이 접근하고 수정할 수 있습니다.


2. 어떤 API를 선택해야 할까?

다음 기준으로 판단하면 됩니다.

원하는 기능권장 방식
앱 내부에서만 이미지 사용filesDir, cacheDir
갤러리에 이미지 저장MediaStore.Images
사용자가 사진 한 장 선택Photo Picker
사용자가 여러 사진 선택Photo Picker
사용자가 저장 위치 지정SAF CreateDocument
PDF·CSV 파일 가져오기SAF OpenDocument
다른 앱으로 파일 공유FileProvider + Sharesheet
갤러리 전체 검색·관리MediaStore + 적절한 권한

사진 한 장을 선택하는 기능을 구현하기 위해 전체 사진 접근 권한을 요청하는 것은 피하는 것이 좋습니다.


Android 버전별 저장소 정책

3. Android 10~16 주요 변경사항

AndroidAPI주요 변화
Android 1029Scoped Storage 도입
Android 1130Scoped Storage 적용 강화
Android 1231~32기존 저장소 모델 유지
Android 1333READ_MEDIA_IMAGES, READ_MEDIA_VIDEO 등 분리
Android 1434선택한 사진·동영상 접근 지원
Android 1535기존 선택적 미디어 접근 정책 고려
Android 1636앱 소유 사진의 선택 접근 동작 등 추가 변경

Android 14 이상에서 사용자는 전체 사진 접근 대신 일부 사진만 허용할 수 있습니다. 자체 갤러리 UI를 운영하는 앱은 이 상황을 처리해야 합니다.

Android 16에서는 API 36 이상을 타겟팅하는 앱의 일부 사진 접근 선택 화면에서 앱 소유 사진이 미리 선택되는 동작도 고려해야 합니다. <Cite refs={["turn111339search0","turn111339search14"]}/>


MediaStore 이미지 저장

4. 갤러리에 이미지를 저장하는 기본 구조

Android 10 이상에서는 MediaStore를 통해 공유 이미지 저장소에 파일을 생성할 수 있습니다.

Android 앱
   │
   ▼
ContentResolver
   │
   ▼
MediaStore.Images
   │
   ▼
Pictures/MyBirdApp/
   │
   ▼
갤러리 앱에서 확인

앱이 직접 생성하는 이미지에는 일반적으로 별도의 저장소 쓰기 권한이 필요하지 않습니다.


5. Bitmap을 갤러리에 저장하기

다음은 Bitmap을 JPEG로 저장하는 함수입니다.

suspend fun saveBitmapToGallery(
    context: Context,
    bitmap: Bitmap,
    fileName: String
): Uri = withContext(Dispatchers.IO) {

    require(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)

    val resolver = context.contentResolver

    val values = ContentValues().apply {
        put(
            MediaStore.Images.Media.DISPLAY_NAME,
            fileName
        )
        put(
            MediaStore.Images.Media.MIME_TYPE,
            "image/jpeg"
        )
        put(
            MediaStore.Images.Media.RELATIVE_PATH,
            "${Environment.DIRECTORY_PICTURES}/MyBirdApp"
        )
        put(
            MediaStore.Images.Media.IS_PENDING,
            1
        )
    }

    val collection =
        MediaStore.Images.Media.getContentUri(
            MediaStore.VOLUME_EXTERNAL_PRIMARY
        )

    val uri = resolver.insert(collection, values)
        ?: error("이미지 생성 실패")

    try {
        resolver.openOutputStream(uri)?.use { stream ->
            check(
                bitmap.compress(
                    Bitmap.CompressFormat.JPEG,
                    95,
                    stream
                )
            ) {
                "이미지 인코딩 실패"
            }
        } ?: error("출력 스트림 생성 실패")

        val completed = ContentValues().apply {
            put(MediaStore.Images.Media.IS_PENDING, 0)
        }

        check(resolver.update(uri, completed, null, null) > 0)

        uri

    } catch (e: Exception) {
        resolver.delete(uri, null, null)
        throw e
    }
}

이 함수는 Android 10 이상을 대상으로 합니다.

Android 9 이하까지 지원하려면 별도의 저장 로직과 해당 버전의 저장소 권한 처리가 필요합니다.


6. IS_PENDING이 필요한 이유

MediaStore의 IS_PENDING은 파일 저장이 완료되기 전 다른 앱에 미완성 파일이 노출되는 것을 방지하는 데 도움이 됩니다.

일반적인 처리 순서는 다음과 같습니다.

MediaStore 항목 생성
    ↓
IS_PENDING = 1
    ↓
이미지 데이터 기록
    ↓
기록 완료
    ↓
IS_PENDING = 0
    ↓
다른 앱에서 접근 가능

파일 기록 중 오류가 발생했다면 생성한 MediaStore 항목을 삭제하는 것이 좋습니다.

IS_PENDING은 Android 10 이상에서 사용할 수 있습니다. <Cite refs={["turn111339search1","turn111339search2"]}/>


인터넷 이미지 다운로드

7. 이미지 URL을 다운로드해 저장하기

도감 앱에서 원격 서버의 이미지를 갤러리에 저장하는 상황을 가정하겠습니다.

이미지를 Bitmap으로 모두 디코딩한 뒤 다시 저장할 수도 있지만, 원본 파일을 그대로 보관하려면 스트림을 복사하는 편이 적합할 수 있습니다.

다음 예제는 OkHttp를 이용합니다.

suspend fun downloadJpeg(
    context: Context,
    client: OkHttpClient,
    imageUrl: String,
    fileName: String
): Uri = withContext(Dispatchers.IO) {

    require(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)

    val request = Request.Builder()
        .url(imageUrl)
        .build()

    client.newCall(request).execute().use { response ->

        if (!response.isSuccessful) {
            error("다운로드 실패: ${response.code}")
        }

        val body = response.body
            ?: error("응답 데이터 없음")

        val mimeType = body.contentType()
            ?.toString()
            ?.substringBefore(';')

        require(mimeType == "image/jpeg") {
            "지원하지 않는 이미지 형식"
        }

        val resolver = context.contentResolver

        val values = ContentValues().apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
            put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
            put(
                MediaStore.Images.Media.RELATIVE_PATH,
                "${Environment.DIRECTORY_PICTURES}/MyBirdApp"
            )
            put(MediaStore.Images.Media.IS_PENDING, 1)
        }

        val uri = resolver.insert(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            values
        ) ?: error("저장 항목 생성 실패")

        try {
            resolver.openOutputStream(uri)?.use { output ->
                body.byteStream().use { input ->
                    input.copyTo(output)
                }
            } ?: error("저장 스트림 생성 실패")

            val completed = ContentValues().apply {
                put(MediaStore.Images.Media.IS_PENDING, 0)
            }

            check(resolver.update(uri, completed, null, null) > 0)

            uri

        } catch (e: Exception) {
            resolver.delete(uri, null, null)
            throw e
        }
    }
}

위 코드는 JPEG 이미지 전용 예제입니다.

PNG나 WebP를 지원하려면 응답 MIME 유형에 맞춰 파일 확장자와 저장 형식을 결정해야 합니다.

또한 외부 URL을 무조건 신뢰하지 말고 파일 크기 제한, 응답 유형 검증, HTTPS 사용 여부를 확인해야 합니다.


8. 원본 이미지와 재인코딩 이미지의 차이

이미지를 저장하는 방식에 따라 결과가 달라집니다.

저장 방식특징
원본 스트림 복사원본 바이트와 메타데이터 보존 가능
Bitmap 디코딩 후 JPEG 저장이미지 재압축, 메타데이터 손실 가능
크기 조정 후 저장용량 감소, 화질 및 메타데이터 변화
PNG 재저장무손실 픽셀 저장 가능, 원본 메타데이터는 별도 처리 필요

GPS가 포함된 사진이나 카메라 촬영 정보를 보존해야 한다면 원본 바이트를 그대로 복사하는 방식이 유리합니다.

단, 원본 복사도 다운로드 과정에서 서버가 이미지를 변환했다면 원래 카메라 파일과 동일하다는 의미는 아닙니다.


Photo Picker

9. Photo Picker란?

Photo Picker는 사용자가 사진이나 동영상을 직접 선택하도록 제공하는 시스템 기반 선택 도구입니다.

일반적인 사진 첨부 기능에서는 전체 갤러리 접근 권한 없이 구현할 수 있습니다.

예를 들어 조류 관찰 메모에 사진을 첨부할 때 활용할 수 있습니다.


10. Photo Picker 의존성

AndroidX Activity 라이브러리를 사용합니다.

dependencies {
    implementation(
        "androidx.activity:activity-ktx:<version>"
    )
}

프로젝트에서 사용하는 AndroidX Activity 버전은 공식 릴리스 문서를 확인합니다.


11. 사진 한 장 선택하기

Fragment에서 다음과 같이 구현할 수 있습니다.

private val pickPhoto =
    registerForActivityResult(
        ActivityResultContracts.PickVisualMedia()
    ) { uri ->

        if (uri != null) {
            binding.imagePreview.setImageURI(uri)
        }
    }

버튼 클릭 시 선택 화면을 실행합니다.

binding.buttonSelectPhoto.setOnClickListener {

    pickPhoto.launch(
        PickVisualMediaRequest(
            ActivityResultContracts.PickVisualMedia.ImageOnly
        )
    )
}

이 방식에서는 일반적으로 READ_MEDIA_IMAGES 권한을 요청할 필요가 없습니다.

지원 기기와 AndroidX 버전에 따라 시스템 Photo Picker 또는 호환되는 선택 방식이 사용될 수 있습니다.


12. 여러 장의 사진 선택하기

여러 장의 사진을 선택하려면 다음과 같이 구현합니다.

private val pickPhotos =
    registerForActivityResult(
        ActivityResultContracts.PickMultipleVisualMedia(5)
    ) { uris ->

        uris.forEach { uri ->
            Log.d("PhotoPicker", "Selected photo")
        }

        // 선택된 URI를 ViewModel에 전달
    }

선택 화면 실행:

pickPhotos.launch(
    PickVisualMediaRequest(
        ActivityResultContracts.PickVisualMedia.ImageOnly
    )
)

예제에서는 최대 5장의 사진을 요청합니다.

실제 선택 가능 개수와 동작은 시스템 및 대체 선택 도구의 지원 범위에 영향을 받을 수 있습니다.


URI와 파일 경로

13. content:// URI를 사용해야 하는 이유

Photo Picker와 SAF는 일반적으로 파일 경로가 아니라 URI를 반환합니다.

예를 들어 다음과 같은 형태입니다.

content://media/picker/...

과거에는 파일의 실제 경로를 얻으려고 다음과 같은 접근을 사용하기도 했습니다.

/storage/emulated/0/DCIM/Camera/photo.jpg

하지만 최신 Android에서는 URI를 실제 파일 경로로 변환하려고 하지 않는 것이 좋습니다.

ContentResolver를 통해 파일을 읽어야 합니다.


14. URI에서 이미지 읽기

fun readImageBytes(
    context: Context,
    uri: Uri
): ByteArray {

    return context.contentResolver
        .openInputStream(uri)
        ?.use { it.readBytes() }
        ?: error("이미지를 열 수 없습니다")
}

이 코드는 작은 파일에 대한 단순 예제입니다.

고해상도 사진처럼 파일 크기가 큰 경우에는 readBytes()로 전체 데이터를 메모리에 올리지 않는 것이 좋습니다.

파일 크기가 큰 경우 스트림 복사나 이미지 디코딩 크기 조절을 사용해야 합니다.


Photo Picker URI 접근 유지

15. 선택한 사진을 나중에도 사용할 수 있을까?

Photo Picker에서 반환된 URI의 접근 권한은 영구적이라고 가정하면 안 됩니다.

장기간 사용할 이미지라면 다음 두 방법 중 하나를 고려합니다.

  1. 지원되는 URI 권한을 지속적으로 유지

  2. 필요한 파일을 앱 전용 저장소에 복사

사용자가 관찰 기록에 사진을 등록하고 몇 달 뒤에도 다시 확인해야 한다면 앱 전용 저장소로 복사하는 방식이 관리하기 쉬울 수 있습니다.

다만 원본 복사본을 앱 내부에 저장하면 저장 공간 사용량이 증가합니다.


Storage Access Framework

16. SAF란?

Storage Access Framework(SAF)는 사용자가 파일이나 저장 위치를 직접 선택할 수 있도록 제공하는 Android 파일 접근 체계입니다.

대표적인 기능은 다음과 같습니다.

기능API
새 파일 저장ACTION_CREATE_DOCUMENT
기존 파일 열기ACTION_OPEN_DOCUMENT
폴더 선택ACTION_OPEN_DOCUMENT_TREE

예를 들어 관찰 기록을 CSV 파일로 내보내거나 JSON 백업 파일을 가져올 때 유용합니다.

SAF는 사용자가 선택한 문서에 대한 접근을 제공하므로 일반적인 저장소 전체 권한을 요청할 필요가 없습니다. <Cite refs={["turn111339search13","turn111339search15"]}/>


17. CSV 파일 저장하기

Activity Result API의 CreateDocument를 사용할 수 있습니다.

private val createCsv =
    registerForActivityResult(
        ActivityResultContracts.CreateDocument("text/csv")
    ) { uri ->

        if (uri != null) {
            saveCsv(uri)
        }
    }

저장 버튼:

binding.buttonExport.setOnClickListener {

    createCsv.launch(
        "bird_memos.csv"
    )
}

18. CSV 데이터 기록

private fun saveCsv(uri: Uri) {

    viewLifecycleOwner.lifecycleScope.launch {

        try {

            withContext(Dispatchers.IO) {

                requireContext().contentResolver
                    .openOutputStream(uri)
                    ?.bufferedWriter(Charsets.UTF_8)
                    ?.use { writer ->

                        writer.write("KTSN,Name,Memo")
                        writer.newLine()

                        writer.write("12345,참새,관찰 기록")
                        writer.newLine()

                    } ?: error("CSV 파일을 열 수 없습니다")
            }

            Toast.makeText(
                requireContext(),
                "CSV 저장 완료",
                Toast.LENGTH_SHORT
            ).show()

        } catch (e: Exception) {

            Toast.makeText(
                requireContext(),
                "저장 실패",
                Toast.LENGTH_SHORT
            ).show()
        }
    }
}

실제 CSV를 만들 때는 쉼표, 큰따옴표, 줄바꿈이 포함된 값을 올바르게 이스케이프해야 합니다.

또한 외부 스프레드시트 프로그램에서 CSV를 열 때 수식으로 해석될 수 있는 값도 고려해야 합니다.


JSON 백업 파일 가져오기

19. OpenDocument 사용

사용자가 이전에 저장한 JSON 파일을 선택하도록 구현할 수 있습니다.

private val openJson =
    registerForActivityResult(
        ActivityResultContracts.OpenDocument()
    ) { uri ->

        if (uri != null) {
            importJson(uri)
        }
    }

실행:

openJson.launch(
    arrayOf("application/json")
)

선택한 파일은 ContentResolver로 읽습니다.

private fun importJson(uri: Uri) {

    viewLifecycleOwner.lifecycleScope.launch {

        val json = withContext(Dispatchers.IO) {

            requireContext().contentResolver
                .openInputStream(uri)
                ?.bufferedReader()
                ?.use { it.readText() }
                ?: error("JSON 파일을 열 수 없습니다")
        }

        // JSON 검증 후 DB에 반영
    }
}

실제 앱에서는 파일 크기 제한, JSON 스키마 검증, 데이터 중복 처리, 트랜잭션 및 오류 복구를 구현해야 합니다.


FileProvider로 파일 공유하기

20. 파일 공유에 FileProvider가 필요한 이유

앱 내부 파일을 다른 앱으로 공유할 때 일반적인 파일 경로를 직접 전달하는 방식은 적절하지 않습니다.

FileProvider를 이용하면 앱 파일을 content:// URI 형태로 공유할 수 있습니다.


21. AndroidManifest.xml 설정

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">

    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />

</provider>

file_paths.xml을 생성합니다.

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">

    <cache-path
        name="shared_images"
        path="shared_images/" />

</paths>

필요한 디렉터리만 공유 대상으로 지정하는 것이 좋습니다.


22. 이미지 공유 Intent

val uri = FileProvider.getUriForFile(
    context,
    "${context.packageName}.fileprovider",
    imageFile
)

val intent = Intent(Intent.ACTION_SEND).apply {

    type = "image/jpeg"

    putExtra(
        Intent.EXTRA_STREAM,
        uri
    )

    addFlags(
        Intent.FLAG_GRANT_READ_URI_PERMISSION
    )
}

context.startActivity(
    Intent.createChooser(
        intent,
        "이미지 공유"
    )
)

여기서 imageFile은 FileProvider에 설정한 공유 가능 경로에 있는 파일이어야 합니다.

다른 앱에서 접근할 수 있도록 읽기 권한을 부여해야 합니다.


EXIF 메타데이터

23. EXIF란?

EXIF는 이미지 파일에 저장될 수 있는 촬영 관련 메타데이터입니다.

대표적인 정보는 다음과 같습니다.

  • 카메라 제조사

  • 카메라 모델

  • 촬영 날짜

  • 셔터 속도

  • 조리개

  • ISO

  • 초점거리

  • GPS 위도·경도

예를 들어 드론 사진에는 촬영 위치가 GPS 메타데이터로 기록될 수 있습니다.

이 정보를 이용하면 사진의 촬영 위치를 지도에 표시하는 기능을 구현할 수 있습니다.


24. ExifInterface 의존성

dependencies {
    implementation(
        "androidx.exifinterface:exifinterface:<version>"
    )
}

25. 이미지 EXIF 읽기

fun readExif(
    context: Context,
    uri: Uri
) {

    context.contentResolver
        .openInputStream(uri)
        ?.use { input ->

            val exif = ExifInterface(input)

            val model = exif.getAttribute(
                ExifInterface.TAG_MODEL
            )

            val date = exif.getAttribute(
                ExifInterface.TAG_DATETIME_ORIGINAL
            )

            Log.d("Exif", "Model: $model")
            Log.d("Exif", "Date: $date")
        }
}

실제 앱에서는 촬영 정보를 화면에 표시할 수 있습니다.

단, 메타데이터에는 개인정보나 위치 정보가 포함될 수 있으므로 로그에 민감한 정보를 남기지 않도록 주의해야 합니다.


GPS 좌표 읽기

26. EXIF GPS 추출

fun readGps(
    context: Context,
    uri: Uri
): Pair<Double, Double>? {

    context.contentResolver
        .openInputStream(uri)
        ?.use { input ->

            val exif = ExifInterface(input)

            val latLong = exif.latLong
                ?: return null

            return Pair(
                latLong[0],
                latLong[1]
            )
        }

    return null
}

반환되는 값은 위도와 경도입니다.

latitude  = 36.7845
longitude = 127.1931

위 좌표는 설명을 위한 예시입니다.

실제 이미지에 GPS 정보가 없거나 접근이 제한된 경우 null을 반환할 수 있습니다.


27. ACCESS_MEDIA_LOCATION 권한

Android 10 이상에서는 다른 앱이 생성한 사진의 수정되지 않은 위치 메타데이터에 접근하려면 ACCESS_MEDIA_LOCATION 권한과 사용자 동의가 필요할 수 있습니다.

Manifest:

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

런타임 권한 요청도 고려해야 합니다.

다만 사진을 선택했다고 해서 모든 위치 메타데이터에 접근할 수 있는 것은 아닙니다.

URI의 출처, 접근 권한, 시스템 정책에 따라 EXIF 위치 정보가 제공되지 않을 수 있습니다. <Cite refs={["turn111339search10","turn111339search11"]}/>


Android 13~16 사진 접근 권한

28. READ_MEDIA_IMAGES

Android 13부터 다른 앱이 생성한 이미지에 광범위하게 접근해야 하는 경우 READ_MEDIA_IMAGES 권한을 사용할 수 있습니다.

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

하지만 일반적인 사진 첨부 기능에서는 Photo Picker를 사용하는 것이 좋습니다.

Google Play에서도 사진 및 동영상 접근 권한 사용 목적을 제한하고 있으므로, 전체 미디어 접근이 정말 필요한지 먼저 검토해야 합니다.


29. Android 14 선택한 사진 접근

Android 14부터 사용자가 일부 사진만 허용하는 상황을 처리해야 할 수 있습니다.

자체 갤러리 UI를 유지하면서 직접 MediaStore를 조회하는 앱이라면 다음 권한을 검토합니다.

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

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

실제 권한 요청과 처리에서는 전체 접근, 일부 접근, 거부를 구분해야 합니다.

반면 시스템 Photo Picker만 사용하는 앱이라면 일반적으로 이러한 광범위한 미디어 권한이 필요하지 않습니다. <Cite refs={["turn111339search4","turn111339search5"]}/>


minSdk 24 앱에서 주의할 점

30. Android 9 이하 이미지 저장

minSdk 24인 앱은 Android 7.0부터 실행될 수 있습니다.

따라서 Android 10 이상 전용인 RELATIVE_PATH, IS_PENDING 등을 모든 기기에서 바로 사용하면 안 됩니다.

구버전 저장을 지원하려면 다음 사항을 검토해야 합니다.

  • Android 9 이하의 저장소 권한

  • 공유 외부 저장소에 파일 생성

  • MediaScanner 연동

  • 저장 실패 및 공간 부족 처리

  • API별 분기

새 프로젝트에서 구버전 지원 비중이 낮다면 지원 범위를 먼저 검토하는 것이 좋습니다.


이미지 다운로드와 메모리 관리

31. 큰 이미지 처리 시 주의사항

고해상도 사진을 Bitmap으로 불러오면 메모리 사용량이 크게 증가할 수 있습니다.

예를 들어 6000×4000 픽셀 이미지를 ARGB_8888로 디코딩하면 픽셀 버퍼만 약 96MB가 필요합니다.

따라서 이미지 처리에서는 다음 방법을 고려합니다.

  • 필요한 크기로 다운샘플링

  • Glide 등 이미지 로딩 라이브러리 활용

  • 원본 파일은 스트림 복사

  • 불필요한 Bitmap 복제 방지

  • 백그라운드 스레드에서 파일 I/O

  • 큰 파일의 다운로드 크기 제한

이미지를 단순히 저장하는 작업이라면 Bitmap으로 디코딩하지 않고 원본 스트림을 복사하는 방식이 더 효율적일 수 있습니다.


MVVM 구조에 적용하기

32. 파일 저장 Repository

파일 저장 기능을 Fragment에 모두 작성하면 코드가 복잡해집니다.

다음과 같이 역할을 분리할 수 있습니다.

BirdDetailFragment
       │
       ▼
BirdDetailViewModel
       │
       ▼
ImageRepository
       │
       ├── OkHttp
       │
       ├── MediaStore
       │
       └── ContentResolver

Repository에서는 파일 저장 작업을 처리하고, ViewModel에서는 로딩·성공·실패 상태를 관리합니다.

Fragment는 사용자에게 저장 결과를 표시하는 역할을 담당합니다.


33. 파일 저장 결과 모델

sealed interface SaveImageResult {

    data class Success(
        val uri: Uri
    ) : SaveImageResult

    data class Failure(
        val message: String
    ) : SaveImageResult
}

ViewModel에서는 다음과 같은 상태를 사용할 수 있습니다.

data class ImageSaveUiState(
    val isSaving: Boolean = false,
    val result: SaveImageResult? = null
)

이 구조를 이용하면 저장 중 ProgressBar를 표시하고, 완료 후 Snackbar나 Toast를 보여줄 수 있습니다.


자주 발생하는 오류

34. Permission Denial

대표적인 원인은 다음과 같습니다.

  • 접근 권한이 없는 URI 사용

  • 사용자가 사진 접근 권한 철회

  • 잘못된 FileProvider 설정

  • URI 접근 권한 유지 실패

  • Android 버전별 권한 처리 누락

URI 접근에 실패하면 무조건 저장소 전체 권한을 요청하기보다 해당 파일에 대한 접근 권한을 다시 얻을 수 있도록 안내하는 것이 좋습니다.


35. 저장한 이미지가 갤러리에 표시되지 않는 문제

다음 항목을 확인합니다.

  1. MediaStore에 정상적으로 등록했는가?

  2. MIME 유형이 올바른가?

  3. 실제 이미지 데이터 기록이 완료되었는가?

  4. IS_PENDING을 0으로 변경했는가?

  5. 이미지 파일이 손상되지 않았는가?

  6. 갤러리 앱의 미디어 목록이 갱신되었는가?

MediaStore에 저장했다고 해서 모든 갤러리 앱에 즉시 동일한 방식으로 표시되는 것은 아닙니다.


36. EXIF GPS 정보가 사라지는 문제

대표적인 원인은 다음과 같습니다.

  • Bitmap으로 다시 저장

  • 이미지 압축 과정

  • 서버의 이미지 변환

  • 사진 공유 앱의 메타데이터 제거

  • 미디어 위치 접근 제한

GPS 정보가 중요한 이미지라면 원본 파일을 그대로 복사하고 저장 전후 EXIF 정보를 비교하는 것이 좋습니다.


실제 앱 적용 사례

37. 조류 도감 앱

조류 도감 앱에서는 다음 기능을 구현할 수 있습니다.

  • 상세 화면 이미지 갤러리 저장

  • 관찰 기록에 사진 첨부

  • 사용자가 선택한 사진 미리보기

  • 관찰 기록 CSV 내보내기

  • JSON 백업 파일 가져오기

  • 사진 공유

이미지 저장에는 MediaStore, 사진 선택에는 Photo Picker, CSV·JSON 파일에는 SAF를 사용하는 구조가 적합합니다.


38. 지도 및 드론 사진 앱

지도 제작이나 드론 사진 관리 앱에서는 다음 기능을 고려할 수 있습니다.

  • 촬영 사진 선택

  • EXIF GPS 읽기

  • 사진의 촬영 위치 지도 표시

  • 원본 사진 복사

  • 지도 이미지 저장

  • 좌표 목록 CSV 내보내기

특히 GPS 메타데이터를 활용하는 앱에서는 원본 이미지 보존과 위치 정보 접근 권한을 신중하게 처리해야 합니다.


저장소 기능 체크리스트

39. 출시 전 점검

  • 앱 전용 저장소와 공유 저장소 구분

  • MediaStore 저장 구현

  • IS_PENDING 처리

  • 저장 실패 시 미완성 항목 삭제

  • MIME 유형 확인

  • 파일 확장자 확인

  • Photo Picker 적용

  • 불필요한 저장소 권한 제거

  • SAF 파일 선택 구현

  • SAF 파일 저장 구현

  • ContentResolver 사용

  • URI 접근 권한 처리

  • FileProvider 설정

  • 공유 URI 읽기 권한 부여

  • 큰 이미지 메모리 사용량 확인

  • EXIF 보존 여부 확인

  • GPS 위치 정보 접근 정책 검토

  • Android 9 이하 분기

  • Android 13 미디어 권한 검토

  • Android 14 일부 사진 접근 처리

  • Android 16 동작 확인

  • 네트워크 오류 처리

  • 저장 공간 부족 처리

  • 실제 기기 테스트

  • Google Play 권한 정책 확인


40. 마무리

Android의 파일 저장 정책은 과거와 비교해 크게 달라졌습니다.

이제는 외부 저장소에 접근하기 위해 무조건 저장소 권한을 요청하는 방식보다, 작업 목적에 맞는 API를 선택하는 것이 중요합니다.

이번 글의 핵심을 정리하면 다음과 같습니다.

  1. 갤러리에 이미지를 저장할 때는 MediaStore를 사용합니다.

  2. Android 10 이상에서는 앱이 생성한 미디어를 저장할 때 일반적으로 저장소 권한이 필요하지 않습니다.

  3. 사진을 선택할 때는 Photo Picker를 우선 검토합니다.

  4. PDF·CSV·JSON 같은 문서는 SAF를 활용합니다.

  5. URI를 실제 파일 경로로 변환하려고 하지 않는 것이 좋습니다.

  6. 앱 내부 파일 공유에는 FileProvider를 사용할 수 있습니다.

  7. EXIF 메타데이터는 이미지 변환 과정에서 손실될 수 있습니다.

  8. GPS 정보 접근에는 별도의 개인정보 및 권한 고려가 필요합니다.

  9. Android 13~16의 사진 접근 정책을 구분해야 합니다.

  10. 큰 이미지 파일은 스트림 기반으로 처리해 메모리 사용량을 줄이는 것이 좋습니다.

조류 도감처럼 이미지를 자주 사용하는 앱에서는 MediaStore와 Photo Picker를 먼저 적용하는 것이 좋습니다.

이후 관찰 기록 내보내기, 백업 파일 관리, EXIF 분석 기능으로 확장할 수 있습니다.

Android 파일 저장 기능의 핵심은 모든 파일에 접근하는 것이 아니라, 사용자에게 필요한 파일만 안전하게 저장하고 읽는 것입니다.


참고 자료

태그: Android, Kotlin, MediaStore, Photo Picker, Storage Access Framework, SAF, Scoped Storage, Android 13, Android 14, Android 15, Android 16, 이미지 다운로드, 갤러리 저장, EXIF, GPS, FileProvider, Android 저장소 권한