[Android] 서버에 이미지 업로드하기(feat. Android 10, Compose)


개요

Android에서 Jetpack Compose 기반 UI를 통해 서버에서 이미지를 업로드하고자 합니다. 이때 Android 10(버전 코드 Q) 이상에서 저장소 접근 정책이 달라짐에 따라 이미지를 불러오고 접근하는 과정에서 이슈를 겪었습니다. 그리고 파일을 직접 업로드 하기 위해서는 multipart/form-data 형식의 통신을 사용하여야 합니다. 이에 대한 전반적인 내용을 다뤄보고 서버에 이미지를 업로드를 하는 과정을 코드를 통해 살펴보겠습니다.


Compose에서 이미지 선택

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Composable
fun UploadImage(
    imageUri: Uri?
    onUpload: (Uri?) -> Unit
) {
    // contentResolver를 불러오기 위한 context
    val context = LocalContext.current

    // 이미지 선택 액티비티 launcher
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.GetContent(),
        onResult = onUpload
    )

    // API level 28 이하는 MediaStore.Images.Media.getBitmap 사용 (deprecated)
    // 그 이상부터 ImageDecoder.createSource 사용
    val bitmap = imageUri?.let {
        if (Build.VERSION.SDK_INT < 28) {
            MediaStore.Images
                .Media.getBitmap(context.contentResolver, it)
        } else {
            val source = ImageDecoder
                .createSource(context.contentResolver, it)
            ImageDecoder.decodeBitmap(source)
        }
    }

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) { 
        // 이미지 표시 영역
        if(bitmap != null) {
            Image(
                bitmap = bitmap.asImageBitmap(),
                contentDescription = null
            )
        }
        // 이미지 선택 버튼
        Button(
            onClick = { launcher.launch("image/*") }
        ) {
            Text("이미지 선택")
        }
    }
}

이미지를 선택하는 액티비티 런처를 통해 컴포저블의 onUpload를 통해 imageUri를 업데이트하고, 이를 Image 컴포넌트에 그리는 방식입니다.

코드 주석에서 확인할 수 있듯이 Android 10 미만의 버전에서는 deprecated된 API인 getBitmap을 사용합니다. 그 이상부터는 ImageDecoder 클래스를 사용합니다. 다만 여러 버전의 기기 호환성을 고려해서, 위 코드와 같이 API level 28 이하 버전에서의 동작 코드 또한 작성해주셔야 합니다.

이미지를 선택하는 액티비티 런처에서는 결괏값으로 URI를 반환합니다. 따라서 onResult 프로퍼티에는 (Uri?) -> Unit 타입의 고차 함수를 넣어주어야 합니다. 위 코드의 UploadImage 컴포저블의 매개변수인 imageUrionUpload는 각각 실시간으로 선택한 이미지를 표시하고 저장하기 위해 ViewModel에 선언된 state와 메서드임을 가정합니다


URI to File

이미지를 서버에 업로드하기 위해서는 URI가 아닌 File 형태가 필요합니다. 기존에는 URI를 통해 File의 실제 경로를 가져오는 방식으로 서버에 올릴 이미지를 특정했습니다. 그런데 Android 10 이상에서는 저장소 접근 정책이 변경됨에 따라, 사용자가 선택한 파일의 URI에서 파일의 절대 경로를 얻어내는 것이 불가능해졌습니다. 따라서 불러온 이미지의 URI로부터 임시 파일로 복사본을 생성한 뒤, 해당 복사본을 서버에 업로드하는 방식으로 구현해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
object FileUtil {
    // 임시 파일 생성
    fun createTempFile(context: Context, fileName: String): File {
        val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
        return File(storageDir, fileName)
    }

    // 파일 내용 스트림 복사
    fun copyToFile(context: Context, uri: Uri, file: File) {
        val inputStream = context.contentResolver.openInputStream(uri)
        val outputStream = FileOutputStream(file)

        val buffer = ByteArray(4 * 1024)
        while (true) {
            val byteCount = inputStream!!.read(buffer)
            if (byteCount < 0) break
            outputStream.write(buffer, 0, byteCount)
        }

        outputStream.flush()
    }
}

object UriUtil {
    // URI -> File
    fun toFile(context: Context, uri: Uri): File {
        val fileName = getFileName(context, uri)

        val file = FileUtil.createTempFile(context, fileName)
        FileUtil.copyToFile(context, uri, file)

        return File(file.absolutePath)
    }

    // get file name & extension
    fun getFileName(context: Context, uri: Uri): String {
        val name = uri.toString().split("/").last()
        val ext = context.contentResolver.getType(uri)!!.split("/").last()

        return "$name.$ext"
    }
}

File 처리를 맡는 FileUtil과 Uri를 담당하는 UriUtil 2가지 파일로 구분하여 작성했습니다. 코드를 보시다시피, 경로를 정확히 알고있는 임시 파일을 생성한 후 해당 파일에 바이트 스트림을 열어 불러온 이미지의 URI를 통해 내용을 복사하는 방식입니다. 이미지를 실제로 업로드할 때에는 이 임시 파일을 올리게 됩니다.


Retrofit에서 Multipart 전송하기

HTTP 통신으로 파일을 직접 전송하기 위해서는 application/json이 아닌 multipart/form-data 방식을 사용합니다. 이때 안드로이드에서 가장 흔하게 사용하는 Retrofit에서 이를 지원합니다. 기존의 방식에서 데이터 소스에 직접 접근하면 API 인터페이스만 약간 다를 뿐 전체적은 틀은 동일합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
object FormDataUtil {
    // File -> Multipart
    fun getImageMultipart(key: String, file: File): MultipartBody.Part {
        return MultipartBody.Part.createFormData(
            name = key,
            filename = file.name,
            body = file.asRequestBody("image/*".toMediaType())
        )
    }
    // String -> RequestBody
    fun getTextRequestBody(string: String): RequestBody {
        return string.toRequestBody("text/plain".toMediaType())
    }
}

/* . . .
 * Repository 레이어 및 Retrofit instance 생성 코드 생략
 * . . .
 */


// API 인터페이스
interface ImageService {
    @Multipart
    @POST("upload")
    suspend fun uploadImage(
        @PartMap imageInfo: MutableMap<String, RequestBody>,
        @Part image: MultipartBody.Part
    )

}

우선 인터페이스 메서드에 @Multipart 어노테이션을 추가합니다. 이후 multipart 타입의 이미지 파일은 @Part 어노테이션을, key-value 형식의 부가 정보 텍스트는 @PartMap 어노테이션을 사용합니다. 이때 유의할 점은 imageInfo의 타입으로 immutable한 Map을 사용할 시 작동하지 않으며, HashMap 또는 MutableMap 을 사용해야 정상 작동합니다.


마치며

Compose를 통해 Android 10 버전 이상에서 프로젝트를 진행하며 겪었던 이미지 업로드 이슈 해결 과정에 대해 종합적으로 정리해보았습니다. 많은 분들께 도움이 되었기를 바랍니다.

0%