Android

[Android] Retrofit Multipart로 이미지 전송하기

Sangyoon98 2023. 2. 5. 16:46

Multipart

HTTP 요청의 한 종류로 HTTP 서버로 파일 혹은 데이터를 보내기 위한 요청 방식이다.

위와 같이 백엔드에서 REST API 명세서를 작성해주면 해당 명세서에 맞게 통신 코드를 구현해야 한다.

상황에 따라 Multipart 데이터 내용은 달라질 수 있기 때문에 명세서를 잘 보고 명세서에 맞게 값을 넣어야 한다.

이미지가 여러개일 수도 있고, 여러개의 String 값이 들어올 수도 있고, 하나의 이미지에 JSON 값을 요청할 수도 있다.

위 상황에서는 하나의 이미지에 JSON 값을 요청하는 경우이다.

 

포스트맨에서 Multipart 요청을 보내기 위해서는 다음과 같이 사용된다.

 

회원가입을 할 때 사진과 아이디, 닉네임 등 여러 데이터를 전달하는 상황이다. Body에 form-data로 각각의 key와 value값을 넣어서 통신을 요청하게 된다.

간단하게 통신하는 방법에 대해 알아보면, Key값이 "photo"인 데이터에는 File 형태의 Value가 들어가야 하고, Content type이 image/png인 이미지가 들어가야 한다고 서버에서 명시해줬다.

또한 Key 값이 "info"인 데이터에는 File이 아닌 Value가 들어가야 하고, Content type이 application/json인 JSON이 들어가야 한다고 서버에서 명시해줬다.

 

이때 주의해야 할 점이 있는데, 이미지를 전송할 때 이미지의 파일 크기가 크면 안된다는 것이다.

이미지 용량이 크면 해당 키값을 제외하고 보내는 듯 하다. (401 AUTHENTICATION 실패코드가 뜨는데 Key 값이 없거나 부족하면 뜨기 때문이다.)

서버마다 다르겠지만 파일을 전송할 수 있는 용량은 제한되어 있고 안드로이드 내에서도 큰 이미지 파일을 전송하는 것은 메모리와 네트워크에 그닥 좋은 일은 아니다. 따라서 안드로이드에서 서버에 보낼 이미지 용량을 줄인 후 서버에 이미지를 전송할 것이다. 서버에서 파일 용량 크기를 늘리는 방법도 있지만 시간이 지나면 서버 용량도 커질 것이고 이미지도 높은 화질이 굳이 필요없는 확인용 데이터인데다가 백엔드쪽에 할 일을 만드는 격이기에 굳이...

 

안드로이드에서 레트로핏을 사용해서 통신을 구현할 때, 위와 같은 Multipart 요청은 어떻게 전송하는지 알아 볼 것이다.

  1. 이미지 용량 줄이기 (선택)
  2. File 객체 생성
  3. RequestBody 객체 생성
  4. MultipartBody 객체 생성
  5. Retrofit 통신

1. 이미지 용량 줄이기 (선택)

레트로핏으로 멀티파트 데이터를 보내기 전에 해야 할 어쩌면 가장 중요한 작업이다.

물론 용량이 작은 이미지라면 이 과정은 필요가 없다. 용량이 작은 이미지를 가지고 있다면 바로 File 객체로 생성하면 된다.

 

사전 작업으로 사진을 촬영하거나 앨범에서 사진을 선택해서 해당 이미지의 Path를 구했다고 가정한다. Path는 이미지가 위치한 절대 경로를 말한다. (ex) PATH = /data/user/0/com.mackerel_frontend_aos/files/image/20230205_144017.jpg

 

1-1. Path To Bitmap

이미지의 Path를 가지고 우리는 비트맵으로 변경시켜줄 것이다.

비트맵으로 변경하는 이유는 이미지의 용량을 줄이기 위해서는 리사이징을 해야 하는데 리사이징을 하려면 비트맵이여야만 한다.

//Path to Bitmap
private fun pathToBitmap(path: String?): Bitmap? {
	return try {
		val f = File(path)
		val options = BitmapFactory.Options()
		options.inPreferredConfig = Bitmap.Config.ARGB_8888
		BitmapFactory.decodeStream(FileInputStream(f), null, options)
	} catch (e: Exception) {
		e.printStackTrace()
		null
	}
}

Path 를 Bitmap으로 변경하는 함수이다.

String 형태의 Path를 File 객체로 만들고 BitmapFactory를 통해 비트맵을 제작한다.

 

1-2. Bitmap Rotate

이미지 Path를 비트맵으로 변경시켰으면 이미지를 회전해야 한다.

회전을 하는 이유는 안드로이드에서 비트맵을 생성하면 이상하게 이미지가 회전되어 거꾸로 나온다.

그 원인은 아직 아무도 잘 모른다.

//Bitmap Orientation
private fun getOrientationOfImage(filepath : String): Int? {
	var exif :ExifInterface? = null
	var result: Int? = null

	try{
		exif = ExifInterface(filepath)
	}catch (e: Exception){
		e.printStackTrace()
		return -1
	}

	val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1)
	if(orientation != -1){
		result = when(orientation){
			ExifInterface.ORIENTATION_ROTATE_90 -> 90
			ExifInterface.ORIENTATION_ROTATE_180 -> 180
			ExifInterface.ORIENTATION_ROTATE_270 -> 270
			else -> 0
		}
	}
	return result
}

//Bitmap Rotate
private fun rotatedBitmap(bitmap: Bitmap?, filepath: String): Bitmap? {
	val matrix = Matrix()
	var resultBitmap : Bitmap? = null

	when(getOrientationOfImage(filepath)){
		0 -> matrix.setRotate(0F)
		90 -> matrix.setRotate(90F)
		180 -> matrix.setRotate(180F)
		270 -> matrix.setRotate(270F)
	}

	resultBitmap = try{
		bitmap?.let { Bitmap.createBitmap(it, 0, 0, bitmap.width, bitmap.height, matrix, true) }
	}catch (e: Exception){
		e.printStackTrace()
		null
	}
	return resultBitmap
}

getOrientationOfImage 함수를 통해 비트맵 이미지의 회전 정보를 가져온다. ExifInterface 클래스를 사용해서 Exif 메타 데이터를 읽어온 후 회전 정보를 TAG_ORIENTATION 태그를 통해 가져온다.

가져온 회전 정보를 이용해 rotatedBitmap 함수를 통해 올바르게 회전된 비트맵을 구한다. Matrix 클래스를 통해 회전된 비트맵을 가져오고 해당 비트맵을 createBitmap 메서드를 사용해서 회전된 비트맵을 생성한다.

 

1-3. Bitmap Resize

회전이 끝난 비트맵을 이제 리사이징을 통해 용량을 줄인다.

//Bitmap Resize
private fun resizeBitmapImage(source: Bitmap, maxResolution: Int): Bitmap {
	val width = source.width
	val height = source.height
	var newWidth = width
	var newHeight = height
	var rate = 0.0f
	if (width > height) {
		if (maxResolution < width) {
			rate = maxResolution / width.toFloat()
			newHeight = (height * rate).toInt()
			newWidth = maxResolution
		}
	} else {
		if (maxResolution < height) {
			rate = maxResolution / height.toFloat()
			newWidth = (width * rate).toInt()
			newHeight = maxResolution
		}
	}
	return Bitmap.createScaledBitmap(source, newWidth, newHeight, true)
}

resizeBitmapImage 함수를 통해 비트맵의 너비와 높이를 구해 최대 해상도에 맞춰 너비와 높이를 재구성한다.

최대 해상도는 상황에 따라 적당하게 넣으면 되는데 1080이면 적당히 FHD 해상도를 얻을 수 있다.

 

1-4. Bitmap To URI

리사이징된 비트맵을 이제 통신에 사용하기 위해 비트맵의 URI를 구해줘야 한다.

기존 이미지의 URI도 있지만 우리는 용량을 줄인 비트맵 이미지의 경로를 다시 구해야 하기 때문에 URI를 구하는 것이다.

이 과정에서 비트맵의 이미지가 외부 저장소에 저장이 된다.

//Bitmap to Uri
private fun getImageUri(inContext: Context?, inImage: Bitmap?): Uri? {
	val bytes = ByteArrayOutputStream()
	inImage?.compress(Bitmap.CompressFormat.JPEG, 100, bytes)
	val path = MediaStore.Images.Media.insertImage(inContext?.contentResolver, inImage, newJpgFileName(), null)
	return Uri.parse(path)
}

//이미지 시간 포맷으로 변경
private fun newJpgFileName() : String {
	val sdf = SimpleDateFormat("yyyyMMdd_HHmmss")
	val filename = sdf.format(System.currentTimeMillis())
	return "${filename}.jpg"
}

getImageUri 함수를 통해 비트맵의 Path를 외부 저장소에 newJpgFileName() 이름으로 저장해서 저장된 경로의 Path를 반환한다.

newJpgFileName 함수는 저장되는 이미지들의 이름이 중복되지 않게 시간 포맷으로 만들어주는 함수이다.

 

1-5. URI To Absolute Path

위에서 비트맵의 URI를 구했지만 해당 URI를 String으로 출력해보면 다음과 같은 경로가 추출된다.

실제로 내부 저장소에는 저런 경로가 없고 URI는 content의 경로만 추출 해주기 때문에 해당 URI로 File 객체로 생성을 하려고 하면 생성이 안된다.

따라서 비트맵이 실제로 저장되어 있는 실제 경로를 구해야 한다.

//RealPath.kt
object RealPath {
    fun getRealPath(context: Context, uri: Uri): String? {
        // SDK < API11
        val realPath: String? = if (Build.VERSION.SDK_INT < 11) {
            getRealPathFromURI_BelowAPI11(context, uri)
        } else if (Build.VERSION.SDK_INT < 19) {
            getRealPathFromURI_API11to18(context, uri)
        } else {
            getRealPathFromURI_API19(context, uri)
        }
        return realPath
    }

    @SuppressLint("NewApi")
    fun getRealPathFromURI_API19(context: Context, uri: Uri): String? {

        // check here to KITKAT or new version
        val isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT

        // DocumentProvider
        if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) {

            // ExternalStorageProvider
            if (isExternalStorageDocument(uri)) {
                val docId = DocumentsContract.getDocumentId(uri)
                val split = docId.split(":").toTypedArray()
                val type = split[0]
                if ("primary".equals(type, ignoreCase = true)) {
                    return (Environment.getExternalStorageDirectory().toString() + "/"
                            + split[1])
                }
            } else if (isDownloadsDocument(uri)) {
                val id = DocumentsContract.getDocumentId(uri)
                val contentUri: Uri = ContentUris.withAppendedId(
                    Uri.parse("content://downloads/public_downloads"),
                    java.lang.Long.valueOf(id)
                )
                return getDataColumn(context, contentUri, null, null)
            } else if (isMediaDocument(uri)) {
                val docId = DocumentsContract.getDocumentId(uri)
                val split = docId.split(":").toTypedArray()
                val type = split[0]
                var contentUri: Uri? = null
                if ("image" == type) {
                    contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
                } else if ("video" == type) {
                    contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
                } else if ("audio" == type) {
                    contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
                }
                val selection = "_id=?"
                val selectionArgs = arrayOf(split[1])
                return getDataColumn(
                    context, contentUri, selection,
                    selectionArgs
                )
            }
        } else if ("content".equals(uri.getScheme(), ignoreCase = true)) {

            // Return the remote address
            return if (isGooglePhotosUri(uri)) uri.lastPathSegment else getDataColumn(
                context,
                uri,
                null,
                null
            )
        } else if ("file".equals(uri.scheme, ignoreCase = true)) {
            return uri.path
        }
        return null
    }

    /**
     * Get the value of the data column for this Uri. This is useful for
     * MediaStore Uris, and other file-based ContentProviders.
     *
     * @param context
     * The context.
     * @param uri
     * The Uri to query.
     * @param selection
     * (Optional) Filter used in the query.
     * @param selectionArgs
     * (Optional) Selection arguments used in the query.
     * @return The value of the _data column, which is typically a file path.
     */
    fun getDataColumn(
        context: Context, uri: Uri?,
        selection: String?, selectionArgs: Array<String>?
    ): String? {
        var cursor: Cursor? = null
        val column = "_data"
        val projection = arrayOf(column)
        try {
            cursor = uri?.let {
                context.contentResolver.query(
                    it, projection,
                    selection, selectionArgs, null
                )
            }
            if (cursor != null && cursor.moveToFirst()) {
                val index: Int = cursor.getColumnIndexOrThrow(column)
                return cursor.getString(index)
            }
        } finally {
            cursor?.close()
        }
        return null
    }

    /**
     * @param uri
     * The Uri to check.
     * @return Whether the Uri authority is ExternalStorageProvider.
     */
    fun isExternalStorageDocument(uri: Uri): Boolean {
        return "com.android.externalstorage.documents" == uri.authority
    }

    /**
     * @param uri
     * The Uri to check.
     * @return Whether the Uri authority is DownloadsProvider.
     */
    fun isDownloadsDocument(uri: Uri): Boolean {
        return "com.android.providers.downloads.documents" == uri.authority
    }

    /**
     * @param uri
     * The Uri to check.
     * @return Whether the Uri authority is MediaProvider.
     */
    fun isMediaDocument(uri: Uri): Boolean {
        return "com.android.providers.media.documents" == uri.authority
    }

    /**
     * @param uri
     * The Uri to check.
     * @return Whether the Uri authority is Google Photos.
     */
    fun isGooglePhotosUri(uri: Uri): Boolean {
        return "com.google.android.apps.photos.content" == uri.authority
    }

    @SuppressLint("NewApi")
    fun getRealPathFromURI_API11to18(context: Context?, contentUri: Uri?): String? {
        val proj = arrayOf(MediaStore.Images.Media.DATA)
        var result: String? = null
        val cursorLoader = CursorLoader(
            context,
            contentUri, proj, null, null, null
        )
        val cursor: Cursor = cursorLoader.loadInBackground()
        if (cursor != null) {
            val column_index: Int = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
            cursor.moveToFirst()
            result = cursor.getString(column_index)
        }
        return result
    }

    fun getRealPathFromURI_BelowAPI11(context: Context, contentUri: Uri?): String? {
        val proj = arrayOf(MediaStore.Images.Media.DATA)
        val cursor: Cursor? =
            contentUri?.let { context.contentResolver.query(it, proj, null, null, null) }
        val column_index: Int = (cursor?.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) ?: cursor?.moveToFirst()) as Int
        if (cursor != null) {
            return cursor.getString(column_index)
        }
        return null
    }
}

 

절대 경로를 구하는 일은 이미지를 다루는 코드가 많으면 쓸 곳이 많으니 위와 같이 Object 클래스로 따로 만들고 쓰는 것이 좋다.

위 클래스를 사용해서 절대 경로를 구하면 다음과 같이 절대 경로가 나오는 것을 확인할 수 있다.


위 과정을 절대 경로가 필요한 곳에 적절하게 함수 호출해서 사용하면 된다.

//Path To Bitmap
val mBitmap = pathToBitmap(imageUriPath)
//Bitmap Rotate
val mBitmapRotate = mBitmap?.let { rotatedBitmap(mBitmap, imageUriPath) }
//Bitmap Resize
val mBitmapResize = mBitmapRotate?.let { resizeBitmapImage(it, 1080) }
//Bitmap To URI
val mBitmapUri = mBitmapResize?.let { getImageUri(this, it) }
//URI To Absolute Path
val realPath = mBitmapUri?.let { RealPath.getRealPath(this, it) }

실제로 이미지 용량 줄이기를 해서 이미지의 용량이 얼마나 줄었냐! 궁금해할까봐 첨부해본다.

용량 줄이기 전 원본 이미지(4MB)
용량 줄이기 후 이미지(252.4KB)


2. File 객체 생성

이미지의 절대 경로를 사전 작업으로 사진을 촬영하거나 앨범에서 사진을 선택해서 또는 1번 이미지 용량 줄이기를 통해 해당 이미지의 Path를 구했다고 가정한다. Path는 이미지가 위치한 절대 경로를 말한다.

(ex) PATH = /data/user/0/com.mackerel_frontend_aos/files/image/20230205_144017.jpg

//File 객체 생성
val file = realPath?.let { File(it) }

절대 경로는 String 형태이며 File 형태에 담아놓게 되면 해당 경로의 파일을 File객체로 담아준다.


3. RequsetBody 객체 생성

생성한 File 객체를 RequestBody 객체로 만들어준다.

//RequestBody 객체로 생성(photo)
val request = RequestBody.create(MediaType.parse("image/png"), file)

MediaType을 "image/png"로 설정해 이미지 파일이라는 것을 명시해준다.

 

추가로 이 글에서는 "info"라는 Key를 가진 JSON 데이터도 포함되어 있기에 해당 데이터도 RequestBody 객체로 제작해준다.

//RequestBody 객체로 생성(info)
val info = RequestBody.create(MediaType.parse("application/json"), "{\"memberId\" : \"$_memberId\"," +
	" \"password\" : \"$_password\"," +
	" \"nickname\" : \"$_nickname\"," +
	" \"school\" : \"$_school\"," +
	" \"grade\" : \"$_grade\"," +
	" \"name\" : \"$_name\"," +
	" \"phone\" : \"$_phone\"," +
	"\"agreements\" : {" +
	" \"adNotifications\" : $_adNotifications}}")

String과 다르게 JSON 데이터는 Value 값에 여러 값이 한번에 들어가야해서 하나의 Value로 보이도록 위와 같이 작성했다.

DTO를 제작해서 넣어도 봤지만 Multipart 데이터는 Part 객체만 존재해야한다고 통신 거부 당했다.

중요한건 이미지든 JSON 이든 String이든 뭐든 모조리 Part 객체로 만들어서 통신해줘야 한다.


4. MultipartBody 생성

이미지를 담은 photo 키값만 해당된다.

//MultipartBody 객체로 생성
val photo = MultipartBody.Part.createFormData("photo", file?.name, request)

info 객체는 File형태가 아니기 때문에 RequestBody로 보내면 되지만, File인것들(이미지)은 MultipartBody 객체로 만들어줘야 한다.

이때 "photo"라고 써있는 부분은 Key값과 완전히 동일해야 한다.


5. Retrofit 통신

5-1. Interface 작성

인터페이스에서는 @Multipart Annotation을 사용해서 Multipart 통신이라고 명시해줘야 한다.

interface RetrofitInterface {
	@Multipart
	@POST("/api/v1/join")
	suspend fun postJoin(@Part photo: MultipartBody.Part, @Part("info") info: RequestBody): SignupResponse
}

Multipart 통신에는 무조건 @Part만 사용할 수 있으며, Part가 아닌 객체는 사용할 수 없다.(@Path 제외)

 

5-2. Retrofit 통신

실제 통신하는 코드를 작성해서 서버와 통신한다.

lifecycleScope.launch(exceptionHandler) {
	val result = retrofitInterface.postJoin(photo ,info)
}

Coroutine을 사용해서 통신 코드가 위와 같지만, 인터페이스의 postJoin 함수를 참조해 photo와 info 값을 삽입해 통신하는 것은 동일하다.


마치며

Multipart로 서버에 이미지 보내는 작업은 이번이 두번째이다. 처음에는 Java로 해봤고 이번에는 Kotlin으로 했다.

중요한건 둘 다 이미지에서 진짜 애를 먹었다.

서버로 통신하는 것은 Java로 개발할 때 몇주동안 삽질하면서 성공해서 무난했지만 이미지 용량 줄이는 사전 작업이 진짜,.,..,.ㅅ.,.,ㅅㅂ

이 글 양만 봐도 이미지 줄이는게 이 글을 다 차지할 정도로 양이 너무 많다.

어쨌든 성공해서 포스팅해본다ㅎ

 

참고

https://juahnpop.tistory.com/234

https://velog.io/@dev_thk28/Android-Retrofit2-Multipart%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-Java