관리 메뉴

나만을 위한 블로그

[Android] 레트로핏으로 서버에 이미지 전송하는 법 본문

Android

[Android] 레트로핏으로 서버에 이미지 전송하는 법

참깨빵위에참깨빵 2023. 7. 29. 01:20
728x90
반응형

레트로핏을 사용한다면 인터페이스에 추상 메서드를 선언하고 필요한 매개변수들을 선언할 것이다. 예를 들어 GET 요청을 전송해야 하는 경우 아래 형태와 같이 작성할 수 있다.

 

interface RxGithubApi {
    @GET("users/{user}/repos")
    fun getRepositories(
        @Path("user") user: String
    ): Observable<List<Repository>>
}

 

POST 요청을 보내야 하는 경우 아래처럼 작성할 수 있다.

 

@FormUrlEncoded
@POST(SEND_FCM_TOKEN_URL)
suspend fun insertFcmToken(
    @Field("osCode") mobileOsCode: String,
    @Field("fcmToken") fcmToken: String
): Response<FcmTokenResponse>

 

그러나 이미지, 동영상의 경우라면 이야기는 다르다. 먼저 사용해야 하는 어노테이션의 종류가 일반적인 CRUD 요청 시 사용하는 어노테이션과는 조금 다르다.

아래는 한 장의 이미지 파일을 서버로 전송하기 위해 만든 예시 함수다.

 

@Multipart
@POST(UPLOAD_IMAGE_URL)
suspend fun uploadImage(
    @Part image: MultipartBody.Part
): Response<MyImageDataClass>

 

 

하나씩 확인하자. 먼저 맨 위의 어노테이션인 @Multipart가 무엇인지부터 알아본다. 하지만 그 전에 MIME부터 확인해 보자. 왜냐면 Multipart란 용어는 MIME 표준에서 나온 단어기 때문이다.

 

https://en.wikipedia.org/wiki/MIME

 

MIME - Wikipedia

From Wikipedia, the free encyclopedia Multipurpose Internet Mail Extensions Multipurpose Internet Mail Extensions (MIME) is an Internet standard that extends the format of email messages to support text in character sets other than ASCII, as well as attach

en.wikipedia.org

MIME은 이메일 메시지 형식을 확장해서 오디오, 비디오, 이미지, 응용 프로그램의 첨부 파일과 아스키 이외의 문자 집합으로 된 텍스트를 지원하는 인터넷 표준이다. 메시지 본문은 여러 부분으로 구성될 수 있으며 헤더 정보는 아스키가 아닌 문자 집합으로 지정될 수 있다...(중략)...WWW용 HTTP에서 서버는 웹 전송 시작 부분에 MIME 헤더 필드를 삽입한다. 클라이언트는 Content-type 또는 media type 헤더를 써서 표시된 데이터 유형에 적합한 뷰어 애플리케이션을 선택한다

 

MIME은 아스키 이외의 문자 인코딩(유니코드, EUC-KR 등)으로 이메일을 보낼 수 있는 방식을 정의한 인터넷 표준이다. MIME의 각 부분은 자기 자신만의 고유한 헤더, 본문을 가질 수 있다. 헤더에는 그 부분의 컨텐츠 타입이나 파일명 등의 값들이 들어갈 수 있다. 이 부분들은 서로 구분되어 있으며 이 형태가 경계로 구분된 것 같다고 해서 boundary라고 부른다.

MIME에 대해서만 다루는 게 아니기 때문에 대충 뭔지만 확인하고 넘어간다.

 

아래는 Multipart에 대한 내용이다.

 

https://www.section.io/engineering-education/making-multipart-requests-in-android-with-retrofit/

 

How to Make Multipart Requests in Android with Retrofit

This tutorial will help the reader understand how to make a multipart request in Android using Kotlin and Retrofit.

www.section.io

Multipart 요청은 하나 이상의 데이터 집합을 경계로 구분된 단일 본문으로 결합한다. 이런 요청은 일반적으로 단일 요청(JSON 객체와 파일이 같이 존재하는 경우)에서 파일 업로드 및 여러 유형의 데이터 전송에 쓰인다. Multipart 요청은 HTTP 클라이언트가 파일, 데이터를 HTTP 서버로 보내기 위해 만드는 HTTP 요청이다. 브라우저와 HTTP 클라이언트는 자주 이를 써서 파일을 서버에 업로드한다

 

쉽게 말해서 서버로 요청 한 번 보낼 때 문자 데이터와 이미지 등의 파일을 같이 보낼 수 있도록 하는 요청 방식이란 뜻이다. 인스타그램을 생각해 보면 사진이나 동영상을 올리고 본문과 해시태그 등을 같이 써서 업로드할 수 있다. 페이스북도 마찬가지고, 대부분의 SNS들에서 구현돼 있는 기능이다. 이것을 가능하게 하는 요청 방식이 Multipart다.

안드로이드에서 이미지를 보내려면 별도의 어노테이션을 명시해줘야 하기 때문에 @Multipart라는 어노테이션을 엔드 포인트 어노테이션과 같이 써서 이미지를 보낼 준비를 해야 한다.

square의 github.io에서의 @Multipart 설명을 보고 @Multipart는 넘어간다.

 

https://square.github.io/retrofit/2.x/retrofit/retrofit2/http/Multipart.html

 

Multipart (Retrofit 2.7.1 API)

 

square.github.io

요청 본문이 다중 부분(multi-part)임을 나타낸다. Part는 매개변수로 선언하고 @Part 어노테이션을 추가해야 한다
@Documented
@Target(value=METHOD)
@Retention(value=RUNTIME)
public @interface Multipart

 

다음은 @Part다.

 

https://square.github.io/retrofit/2.x/retrofit/index.html?retrofit2/http/Part.html 

 

Retrofit 2.7.1 API

 

square.github.io

Multipart 요청의 단일 부분을 나타낸다. 이 어노테이션이 있는 매개변수 타입은 아래 3가지 중 하나로 처리된다

1. 타입이 Multipart.Part면 content가 직접 사용된다. 어노테이션에서 이름을 생략한다(@Part MultipartBody.Part part 형태)
2. 타입이 RequestBody인 경우 값은 content type과 같이 사용된다. 어노테이션에 Part 이름을 써야 한다(@Part("foo") RequestBody foo)
3. 다른 객체 타입은 converter를 써서 적절한 표현(representation)으로 변환된다. 어노테이션에 Part 이름을 써야 한다(@Part("foo") Image photo)

value는 요청 본문에서 생략되는 null일 수 있다
@Documented
@Target(value=PARAMETER)
@Retention(value=RUNTIME)
public @interface Part

 

@Multipart와 같이 쓰이며 mutlipart 요청의 각 부분을 표현하는 어노테이션이다. 이 어노테이션이 붙은 매개변수의 타입은 위에서 말한 3가지 중 하나여야 한다. MultipartBody.Part 이외의 타입이라면 해당 객체는 HTTP 본문으로 바뀌어서 요청에 포함된다.

multipart 요청의 각 부분을 표현한다는 게 무슨 말인지 이해가 안 갈 수 있다. 다시 인터페이스에 선언했던 메서드를 보자.

 

@Multipart
@POST(UPLOAD_IMAGE_URL)
suspend fun uploadImage(
    @Part image: MultipartBody.Part
): Response<MyImageDataClass>

 

지금은 image 매개변수 하나만 정의되어 있지만 필요하다면 @Header로 헤더를 설정하거나, 다른 값들도 같이 설정해서 서버로 보낼 요청을 구성할 수 있다. 이렇게 서버로 보낼 요청의 image, header 등 요청을 구성하는 부분들을 정의하고 사용하기 위해 @Part를 사용하는 것이다. 참고로 @Multipart, @Part의 구현을 보면 @Retention(value=RUNTIME)이라고 쓰여 있다. @Retention은 어노테이션의 생명주기를 지정할 때 사용하는 메타 어노테이션이다. RUNTIME을 명시한 경우, 앱이 컴파일되서 런타임(메모리)에 올라가도 남아있어야 한다는 걸 의미한다. 이외에 2가지 정책이 더 있으니 궁금하면 찾아보자.

이 함수를 HttpLoggingInterceptor를 설정해서 호출하면 로그캣에서 아래와 비슷한 형태의 로그를 볼 수 있다.

 

POST /your-upload-endpoint HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="image"; filename="file.jpg"
Content-Type: image/jpeg

 

POST 방식으로 이미지를 보낼 경우 일반적으로 form-data 형식의 POST 요청으로 보내기 때문에, Content-Type 우측에 multipart/form-data라고 표시되는 걸 볼 수 있다. 왜 많고 많은 것 중에 form-data냐면 이 방식이 텍스트와 이미지(=바이너리 데이터)를 같이 보낼 수 있는 방식이기 때문이다. 이미지 파일은 바이너리 파일로 간주됨을 참고한다.

 

그리고 Content-Disposition 우측에는 내가 정의한 변수명이 name의 value로 표시되고, 앱 내 로직에서 만들어내거나 변환한 이미지 파일의 이름이 fileName의 value로 표시된다. 변환하지 않더라도 다운받은 사진의 확장자가 특이하지 않는 이상, 사진 촬영을 통해 안드로이드 단말에 저장되는 모든 이미지들은 JPG 확장자로 저장된다. 한 번 자기 핸드폰의 갤러리를 열어서 사진들의 확장자를 확인해 보자.

 

이제 인터페이스에 메서드를 만들었다면 남은 건 이 함수를 구현해서 자신의 상황에 맞게 비즈니스 로직을 짜는 것이다.

아래는 갤러리에서 선택한 사진들의 uri를 리스트 형태로 가져온 다음 리스트를 순회하며 서버로 보낼 이미지들을 리스트에 담는 예시 로직이다.

 

uriList.forEachIndexed { index, uri ->
    val filePath = uri.path ?: return@forEachIndexed
    val imageFile = File(filePath).createJpgFile()
    val requestFile: RequestBody = RequestBody.create(
        IMAGE_TYPE_JPEG.toMediaTypeOrNull(),
        imageFile
    )
    bgImageList.add(MultipartBody.Part.createFormData(
        "bgImages",
        "bg_${index + 1}.jpg",
        requestFile
    ))
}

 

리스트로 여러 이미지들을 받은 경우를 가정한 예시 코드기 때문에, 하나의 파일만 받는다면 forEachIndexed가 사라지고 안의 로직이 적절히 변형된 형태로 코드가 작성될 것이다. 리사이징이 필요하면 리사이징 함수 만들어서 적절한 곳에 끼워넣으면 된다.

RequestBody.create()는 꼭 써야 하는 것은 아니고, 필요한 경우에 파일의 content, media type을 명시하기 위해 사용할 수 있다. asRequestBody()를 써서 RequestBody를 만드는 게 좀 더 간편하다. 아래 예시를 참고한다.

 

suspend fun uploadImage(file: File): Flow<ApiState<ResultDTO<UploadDTO>>> =
    flow {
        emit(safeObjectFlowCall {
            val requestBody: RequestBody = file.asRequestBody("image/jpg".toMediaTypeOrNull())
            val filePart = createFormData("file", file.name, requestBody)
            uploadApiInterface.postImageUpload(filePart)
        })
    }.flowOn((Dispatchers.IO))

 

또는 아래 형태의 코드도 만들어질 수 있다.

 

val file = File("path_to_your_file")
val requestFile = RequestBody.create(MediaType.parse("image/jpeg"), file)
val body = MultipartBody.Part.createFormData("picture", file.name, requestFile)

 

결국 안드로이드에서 레트로핏을 써서 이미지를 전송하려면 위의 코드들과 비슷한 형태의 코드를 작성해야 한다. 귀찮다

그래서 서버로 이미지를 보내기 위한 코드들을 유틸 함수나 확장 함수로 만들어 두면 필요한 때 유용하게 사용할 수 있다.

이후 이어지는 로직은 실제로 API 호출해서 데이터를 받아올 것이고, 뷰모델 안에 위 로직들을 구성했다면 LiveData나 Flow를 만들어서 적절하게 값 세팅하고 액티비티 / 프래그먼트에서 observe하게 해 뷰 로직을 작성하는 정도일 것이다.

 

실제로 작동하는 예제 코드를 만들어서 실행 화면을 보여주지는 않았다. 당장 서버도 없고 API도 만들 줄 모를 뿐더러 이미지, 동영상을 전송하는 비즈니스 로직은 정말 다양한 형태로 존재해서, 이미 기존에 작성된 좋은 코드들이 구글에 깔렸기 때문이다.

이 포스팅에선 이미지를 위주로 작성했는데 동영상이라고 별반 다를 것 없다. RequestBody를 만들 때 명시하는 타입 같은 세부적인 내용만 다를 뿐 큰 틀은 이미지와 똑같다.

반응형
Comments