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 주요 변경사항
| Android | API | 주요 변화 |
|---|---|---|
| Android 10 | 29 | Scoped Storage 도입 |
| Android 11 | 30 | Scoped Storage 적용 강화 |
| Android 12 | 31~32 | 기존 저장소 모델 유지 |
| Android 13 | 33 | READ_MEDIA_IMAGES, READ_MEDIA_VIDEO 등 분리 |
| Android 14 | 34 | 선택한 사진·동영상 접근 지원 |
| Android 15 | 35 | 기존 선택적 미디어 접근 정책 고려 |
| Android 16 | 36 | 앱 소유 사진의 선택 접근 동작 등 추가 변경 |
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의 접근 권한은 영구적이라고 가정하면 안 됩니다.
장기간 사용할 이미지라면 다음 두 방법 중 하나를 고려합니다.
지원되는 URI 권한을 지속적으로 유지
필요한 파일을 앱 전용 저장소에 복사
사용자가 관찰 기록에 사진을 등록하고 몇 달 뒤에도 다시 확인해야 한다면 앱 전용 저장소로 복사하는 방식이 관리하기 쉬울 수 있습니다.
다만 원본 복사본을 앱 내부에 저장하면 저장 공간 사용량이 증가합니다.
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
│
└── ContentResolverRepository에서는 파일 저장 작업을 처리하고, 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. 저장한 이미지가 갤러리에 표시되지 않는 문제
다음 항목을 확인합니다.
MediaStore에 정상적으로 등록했는가?
MIME 유형이 올바른가?
실제 이미지 데이터 기록이 완료되었는가?
IS_PENDING을 0으로 변경했는가?이미지 파일이 손상되지 않았는가?
갤러리 앱의 미디어 목록이 갱신되었는가?
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를 선택하는 것이 중요합니다.
이번 글의 핵심을 정리하면 다음과 같습니다.
갤러리에 이미지를 저장할 때는 MediaStore를 사용합니다.
Android 10 이상에서는 앱이 생성한 미디어를 저장할 때 일반적으로 저장소 권한이 필요하지 않습니다.
사진을 선택할 때는 Photo Picker를 우선 검토합니다.
PDF·CSV·JSON 같은 문서는 SAF를 활용합니다.
URI를 실제 파일 경로로 변환하려고 하지 않는 것이 좋습니다.
앱 내부 파일 공유에는 FileProvider를 사용할 수 있습니다.
EXIF 메타데이터는 이미지 변환 과정에서 손실될 수 있습니다.
GPS 정보 접근에는 별도의 개인정보 및 권한 고려가 필요합니다.
Android 13~16의 사진 접근 정책을 구분해야 합니다.
큰 이미지 파일은 스트림 기반으로 처리해 메모리 사용량을 줄이는 것이 좋습니다.
조류 도감처럼 이미지를 자주 사용하는 앱에서는 MediaStore와 Photo Picker를 먼저 적용하는 것이 좋습니다.
이후 관찰 기록 내보내기, 백업 파일 관리, EXIF 분석 기능으로 확장할 수 있습니다.
Android 파일 저장 기능의 핵심은 모든 파일에 접근하는 것이 아니라, 사용자에게 필요한 파일만 안전하게 저장하고 읽는 것입니다.
참고 자료
Android 데이터 및 파일 저장소 개요
https://developer.android.com/training/data-storageMediaStore 사용 방법
https://developer.android.com/training/data-storage/shared/mediaAndroid Photo Picker
https://developer.android.com/training/data-storage/shared/photo-pickerStorage Access Framework
https://developer.android.com/training/data-storage/shared/documents-filesAndroid 14 선택한 사진 접근
https://developer.android.com/about/versions/14/changes/partial-photo-video-accessFileProvider
https://developer.android.com/reference/androidx/core/content/FileProviderAndroidX ExifInterface
https://developer.android.com/reference/androidx/exifinterface/media/ExifInterface
태그: Android, Kotlin, MediaStore, Photo Picker, Storage Access Framework, SAF, Scoped Storage, Android 13, Android 14, Android 15, Android 16, 이미지 다운로드, 갤러리 저장, EXIF, GPS, FileProvider, Android 저장소 권한