💡 Intro
서버에 이미지 파일을 업로드 하는 기능은 이전에 구현해본적 있기 때문에 수월하게 잘 할 수 있을 것이라고 생각했다. 그런데 생각보다 헷갈리고 모르는 것이 많더라. 딱 한 번 쓰고 기억을 하겠다는 것도 욕심이겠지만 처음하는 것과 다름없을 정도로 기억이 안나니 당황스럽다.
그 당시에는 기능이 정상동작하면 오! 된다! 하고 지나갔었는데 아마 그래서이지 않을까? 하지만 이번에는 그 때와는 다르게, 구현하면서 얘는 왜 이렇게쓰지? 쟤는 왜 이렇게쓰지? 이놈은 되고 저놈은 왜 안되는거야😩 와 같은 궁금증들이 계속 생겼고, 이 궁금증을 해결하고 싶어 상당히 많은 시간을 투자했다. 이번에 알게된 내용이 꽤나 많아서 정리하는 시간을 갖고 나중에 봐도 단번에 기억날 수 있기를 바라며 글을 시작한다.
❓ Multipart
일단 Multipart가 무엇인지 간단하게 살펴보자.
multipart/form-data는 HTTP 요청에서 사용되는 컨텐트 타입(content type) 중 하나로, 웹 양식(form) 데이터나 파일 업로드와 같은 경우에 사용된다. 이 컨텐트 타입은 폼 데이터나 파일 데이터를 여러 부분으로 나누어 전송할 수 있도록 하며, 각 부분은 분리된 컨텐트 타입을 가지고 있다.
multipart/form-data는 클라이언트가 서버로 다양한 타입의 데이터를 전송할 수 있게 해준다. 이를 통해 텍스트 데이터 뿐만 아니라 파일 업로드 등 다양한 데이터를 보낼 수 있다.
당근마켓을 생각해보면 글 제목+사진+설명 등 여러 데이터를 하나의 뷰에서 작성하고 완료 버튼을 클릭하여 뷰를 나가게 된 경험이 있을 것이다. 이렇듯 멀티파트는 여러 데이터를 한 번에 묶어 보낼 수 있도록 도와준다.
간단하게 설명하면 여러 종류의 데이터를 구별해서 Body에 넣어주기 위한 방법이 멀티파트라고 보면된다.
우리 프로젝트에서도 앞선 당근마켓 예시와 비슷하게 서버에 전송하는 데이터가 글+사진이다. 그래서 멀티파트를 사용하게 되었다.
❓ RequestBody
RequestBody는 OkHttp 및 Retrofit과 같은 네트워킹 라이브러리에서 사용되는 클래스로, 서버로 데이터를 전송하기 위한 바이트 데이터와 데이터의 MIME 타입을 포함한다. 이 클래스는 텍스트, 이미지, 파일 등 다양한 유형의 데이터를 HTTP 요청 본문에 담아서 서버로 전송하는 데 사용된다.
일반적으로 텍스트 데이터나 바이너리 데이터를 전송해야 할 때 사용되며, JSON, XML, 이미지, 파일 등을 전송할 수 있다.
RequestBody는 주로 POST 또는 PUT 요청을 할 때 요청 본문에 데이터를 넣을 때 사용된다. 예를 들어 JSON 데이터를 서버로 전송해야 할 때는 RequestBody를 사용하여 JSON 문자열을 요청 본문에 담아서 전송할 수 있다.
val json = """{"key": "value"}"""
val mediaType = "application/json".toMediaTypeOrNull()
val requestBody = json.toRequestBody(mediaType)
위 코드에서 json은 전송하려는 JSON 문자열이며, toMediaTypeOrNull() 함수를 사용하여 MediaType을 생성한 후 toRequestBody() 함수로 RequestBody 객체를 생성한다.
데이터의 형식이나 MIME 타입에 따라 MediaType을 적절히 지정하고 RequestBody를 생성하여 서버로 데이터를 전송할 수 있다. RequestBody는 텍스트 데이터 또는 바이너리 데이터를 다루는 데 사용되며, 서버와의 통신에서 요청 본문에 데이터를 담을 때 사용한다.
여기서 MIME 타입이란?
데이터의 종류를 나타내기 위해 사용되는 식별자다. 주로 인터넷에서 데이터를 교환하거나 전송할 때 사용되며, 특정 데이터가 어떤 종류인지를 명시한다. MIME 타입은 문자열로 구성되며 주로 확장자나 특정 형식에 대해 정의된 문자열로 사용된다.
MIME 타입은 "type/subtype" 형식으로 구성되며 type은 큰 범주를, subtype은 그 안에서 세부적인 유형을 나타낸다.
예를들어 "text/plain" 은 텍스트 데이터의 일반적인 MIME 타입을 나타내고, "image/jpeg"는 JPEG 이미지의 MIME 타입을 나타낸다.
MIME 타입은 데이터의 형식을 식별하여 서버와 클라이언트 간에 데이터를 알맞게 처리할 수 있도록 도와준다.
👍 Retrofit 공식 문서
Retrofit 공식문서를 참고해보면 다음과 같은 설명이 있다.
아니.. 근데 너무하게도 자바만 있고 코틀린은 없다.
그래서 한 번 바꿔봤다.
@FormUrlEncoded
@POST("user/edit")
fun updateUser(
@Field("first_name") first: String,
@Field("last_name") last: String,
) : Call<User>
@Multipart
@PUT("user/photo")
fun updateUser(
@Part("photo") photo: RequestBody,
@Part("description") description: RequestBody
) : Call<User>
@FormUrlEncoded 설명을 보면 이 어노테이션을 메서드에 명시하면 form-encoded 데이터로 전송된다고 한다.
Multipart도 역시 @Multipart 어노테이션을 메서드에 명시하면 Retrofit이 자동으로 요청의 Content-Type을 multipart/form-data 로 설정해준다. (내부적으로 해주는게 많은 굉장한 친구다.)
❗️ API Call
백엔드 친구들과 함께 짠 POST API 명세는 다음과 같다.
그리고 그 명세에 맞도록 포스트맨을 통해 POST 요청에 필요한 값들을 채운 것은 위와 같다. name, description, latitude, longitude 모두 text를 보내줘야 하고, imageFile만 File 형태로 보내야 한다.
내가 이 부분에서 헷갈렸던 것이 있는데 백엔드 친구들과 짠 API 명세에서는 latitude와 longitude가 double이다. 그런데 포스트맨으로 보내는 값은 text인데... 이러면 타입이 안맞는 것이 아닌가? 생각했지만 form-data로 전송하게 되면 프레임워크가 키 값으로 파싱하고 형변환을 하여 전달해준다고 한다. (전해들은 것이라 100% 맞다고 보장할 수는 없지만 대략 그렇다고 이해하자.)
아래 코드는 백엔드 친구들이 POST를 받는 코드의 일부분이다.
안드로이드에서 이러한 데이터를 보내는 방법은 3가지다. 각 방법에 따라 Retrofit Service와 Retrofit Service의 메서드를 사용하는 곳의 구현이 약간씩 다르기 때문에 Retrofit Service와 Retrofit Service의 메서드를 사용하는 곳에 대한 코드를 함께볼 것이다.
1️⃣ Multipart.Part
서버로 전송하는 값들을 모두 Multipart.Part로 보내는 방법이다. 앞에서도 말했던 것처럼 멀티파트는 다양한 타입의 데이터를 서버로 전송할 때 사용하는 데이터 타입이다. 각 파트들을 모아 멀티파트의 형태로 보내는 것이기 때문에 보내는 각각의 요소를 Part로 변환하여 서버에 요청할 수 있다.
// Service
@Multipart
@POST("/places")
fun registerPlace(
@Part name: MultipartBody.Part,
@Part description: MultipartBody.Part,
@Part latitude: MultipartBody.Part,
@Part longitude: MultipartBody.Part,
@Part imageFile: MultipartBody.Part,
): Call<PlaceDto>
Retrofit Service 인터페이스다. 멀티파트로 보낼 것이기 때문에 @Multipart 어노테이션을 붙인다. 그리고 보내야 하는 모든 값들에 @Part 어노테이션을 붙이고 타입을 Mutilpart.Part로 지정해준다.
// Repository 구현체
override fun postPlace(
name: String,
description: String,
coordinate: Coordinate,
image: String,
callback: (Result<Place>) -> Unit,
) {
val file = File(image)
val requestFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
val imagePart = MultipartBody.Part.createFormData(
"imageFile",
file.name,
requestFile,
)
val namePart = MultipartBody.Part.createFormData("name", name)
val descriptionPart = MultipartBody.Part.createFormData("description", description)
val latitudePart = MultipartBody.Part.createFormData("latitude", coordinate.latitude.toString())
val longitudePart = MultipartBody.Part.createFormData("longitude", coordinate.longitude.toString())
val call = placeService.registerPlace(namePart, descriptionPart, latitudePart, longitudePart, imagePart)
}
Multipart.Part.createFormData() 메서드를 이용하여 Multipart.Part로 만들어줄 수 있다. 서버로 보내줘야 하는 name, description, latitude, longitude를 모두 Multipart.Part로 만든다. 이 때 Double로 예상되는 coordinate.latitude, coordinate.longitude 값도 String 형태로 바꿔주는 것을 볼 수 있는데 이러한 이유는 다음과 같다.
Multipart.Part는 Headers와 RequestBody를 가지고 있다.
그리고 Multipart.Part.createFormData() 메서드를 확인해보면 위와 같다. createFormData()는 다른 파라미터가 들어가는 2개의 메서드처럼 보이지만 실제로는 filename이 nullable하여 createFormData(name: String, value: String) 메서드에서 createFormData(name: String, filename: String?, value: String) 메서드를 호출하고 있다.
그래서 Double로 예상되는 값들도 모두 String으로 변환해준 것이다.
2️⃣ RequestBody
서버로 전송하는 값들을 모두 RequestBody로 보내는 방법이다.
// Service
@Multipart
@POST("/places")
fun registerPlace2(
@Part("name") name: RequestBody,
@Part("description") description: RequestBody,
@Part("latitude") latitude: RequestBody,
@Part("longitude") longitude: RequestBody,
@Part imageFile: MultipartBody.Part,
): Call<PlaceDto>
서버에 보내줘야 하는 값들을 모두 RequestBody로 보내는 방법이다. RequestBody로 보내주기 위해서는 Retrofit Service에서 @Part(”Key”)안에 서버에서 요청하는 Key값을 넣어줘야 한다.
@Multipart 어노테이션을 사용하여 멀티파트(form-data) 형식의 데이터를 전송할 때, 각 파트의 이름 또는 키(Key)를 지정해줘야 하는데 이렇게 지정된 파트 이름은 서버에서 해당 파트를 어떤 데이터로 처리해야 하는지를 알려준다. 이를 위해 @Part() 안에 키값을 넣어주는 것이다.
위의 코드에서 @Part("name") name: RequestBody 부분에서 "name"이 파트의 이름 또는 키값을 나타낸다. 이렇게 파트의 이름을 지정하면 서버는 클라이언트에서 보낸 데이터를 어떻게 해석해야 하는지 알 수 있게 되고 파트 이름에 맞춰 데이터를 처리하고 파싱할 수 있다. 클라이언트에서는 @Part 어노테이션을 사용하여 해당 파트의 값을 지정할 수 있고, 이 값은 서버에서 예상하는 파트의 이름과 일치해야 한다.
🤔 왜 @Multipart.Part로 보낼때는 키 값을 지정하지 않아도 되는 것일까?
1번 방법을 잘 보면 키 값이 없다.
MultipartBody.Part로 파일을 업로드하는 경우에는 실제로 파일의 데이터를 RequestBody로 포장하고, 이를 @Part 어노테이션에 전달하는 것이다. 파일 업로드 시 MultipartBody.Part로 전달되는 RequestBody에는 파일의 데이터와 파일 이름이 이미 내장되어 있다.
따라서 파일을 @Part 어노테이션에 전달할 때 별도로 파트의 이름(Key)을 지정할 필요가 없다. Retrofit은 MultipartBody.Part의 내부에서 이미 파일 이름과 내용을 처리하고 업로드할 준비를 한다.
하지만 문자열이나 다른 데이터를 RequestBody로 변환하여 @Part 어노테이션에 전달하는 경우에는 해당 데이터의 파트 이름(Key)을 명시적으로 지정해주어야 한다. 이렇게 하는 이유는 서버에서 어떤 파트로 해석되어야 하는지를 명확히 하기 위해서다.
// Repository 구현체
override fun postPlace(
name: String,
description: String,
coordinate: Coordinate,
image: String,
callback: (Result<Place>) -> Unit,
) {
val file = File(image)
val requestFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
val imagePart = MultipartBody.Part.createFormData(
"imageFile",
file.name,
requestFile,
)
val nameRequestBody = name.toRequestBody("text/plain".toMediaTypeOrNull())
val descriptionRequestBody = description.toRequestBody("text/plain".toMediaTypeOrNull())
val latitudeRequestBody = coordinate.longitude.toString().toRequestBody("text/plain".toMediaTypeOrNull())
val longitudeRequestBody = coordinate.latitude.toString().toRequestBody("text/plain".toMediaTypeOrNull())
val call = placeService.registerPlace2(
nameRequestBody,
descriptionRequestBody,
latitudeRequestBody,
longitudeRequestBody,
imagePart,
)
}
🤔 ”text/plain”.toMediaTypeOrNull()을 넣어주는 것은 어떤 차이일까?
Repository 구현체를 보면 어딘가 익숙한 메서드가 하나 있다. String.toRequestBody()다. 1번 방법을 사용할 때도 createFormData() 메서드에서 String.toRequestBody() 가 내부적으로 사용되었는데 사용하는 방법이 뭔가 다르다. 여기서는 toRequestBody() 메서드 안에 들어가는 파라미터가 없다. 어라? 그런데 2번 방법에서는 "text/plain".toMediaTypeOrNull() 라는 파라미터를 넣어주고 있다.
- String.toRequestBody()
- 이 방식은 toRequestBody() 메서드를 호출할 때 MediaType을 생략하는 방식이다.
- 이 경우 데이터의 형식은 자동으로 판별되며 “text/plain”과 같은 기본적은 텍스트 데이터의 경우에는 문제없이 잘 동작한다.
- String.toRequestBody(”text/plain”.toMediaTypeOrNull())
- 이 방식은 toRequestBody() 메서드에 MediaType을 명시적으로 전달하는 방식이다.
- “text/plain” 은 텍스트 데이터임을 나타내며, 이를 MediaType 객체로 변환하여 전달한다.
- 이렇게 MediaType을 명시적으로 전달하여 데이터의 형식을 정확히 지정할 수 있다.
두 방식 모두 RequestBody를 생성하는데 사용되며, 데이터의 형식을 명시적으로 지정하느냐 하지 않느냐의 차이가 있다. 만약 기본적인 텍스트 데이터를 다루는 경우에는 MediaType을 생략해도 문제가 없다.
🤔 만약 기본적인 텍스트 데이터가 아니라면 어떻게 될까?
특정한 형식의 데이터를 RequestBody로 전달해야 하는 경우, 해당 데이터의 형식에 맞는 MediaType을 명시적으로 전달해줘야 한다.
val data = """{"key": "value"}"""
val mediaType = "application/json".toMediaTypeOrNull()
val requestBody = data.toRequestBody(mediaType)
위 코드에서 “application/json”은 JSON 데이터임을 나타내는 MediaType이다. toMediaTypeOrNull() 메서드를 사용하여 문자열 형태의 MediaType을 생성한다. 그 다음 toRequestBody() 메서드를 호출하여 JSON 데이터와 MediaType을 사용하여 RequestBody를 생성한다. 이와 같은 방식으로 다른 데이터 형식에 맞는 MediaType을 사용하여 RequestBody를 생성할 수 있다. 이렇게 하면 데이터의 형식을 명시적으로 지정하여 서버로 전송할 수 있다.
3️⃣ PartMap
// Service
@Multipart
@POST("/places")
fun registerPlace3(
@PartMap postData: HashMap<String, RequestBody>,
@Part imageFile: MultipartBody.Part,
): Call<PlaceDto>
2번 방법과 똑같이 서버에 보내줘야 하는 값들을 모두 RequestBody로 보내는 방법이지만 이를 Map에 저장해서 넘겨주는 방식이다. 크게 다른 것이 없다고 생각할 수 있겠지만(내가 그랬다.) 분명한 이점은 있다.
지금 서버에서 요구하는 값이 name, description, latitude, longitude, imageFile이지만 만약 여기서 nickname이 추가되었다면 어떻게 될까?
2번 방식을 사용한다면 Retrofit Service에 @Part(”nickname”) nickname: RequestBody를 추가해줘야 하고, 이 메서드를 호출하는 곳에서 파라미터를 하나 더 넣어줘야 한다. 최소 2개 이상의 파일에 변경을 적용해줘야 한다.
여기서 3번 방식을 사용했을 때 이점이 생긴다. Retrofit Service는 Map 형태로 서버에 보낼 값들을 받고있다. 따라서 Map에는 어떤 값들이 들어갔는지, 몇 개가 들어갔는지는 관심을 갖지 않아도 되고 단순히 Map<String, RequestBody>의 형태로 왔는지만 보면된다. 그렇다는 것은 nickname을 추가로 보내야 한다고 해서 Retrofit Service의 코드를 변경할 필요는 없다는 뜻이다. Repository 구현체에서 해당 메서드를 호출하는 곳에서 Map에 추가된 내용을 넣어주기만 하면 되기 때문이다.
// Repository 구현체
override fun postPlace(
name: String,
description: String,
coordinate: Coordinate,
image: String,
callback: (Result<Place>) -> Unit,
) {
val file = File(image)
val requestFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
val imagePart = MultipartBody.Part.createFormData(
"imageFile",
file.name,
requestFile,
)
val postHashMap = HashMap<String, RequestBody>()
val nameRequestBody = name.toRequestBody("text/plain".toMediaTypeOrNull())
val descriptionRequestBody = description.toRequestBody("text/plain".toMediaTypeOrNull())
val latitudeRequestBody = coordinate.longitude.toString().toRequestBody("text/plain".toMediaTypeOrNull())
val longitudeRequestBody = coordinate.latitude.toString().toRequestBody("text/plain".toMediaTypeOrNull())
postHashMap["name"] = nameRequestBody
postHashMap["description"] = descriptionRequestBody
postHashMap["latitude"] = latitudeRequestBody
postHashMap["longitude"] = longitudeRequestBody
val call = placeService.registerPlace3(
postHashMap,
imagePart,
)
}
🚨 에러
UnknownServiceException CLEARTEXT communication not permitted
와 같은 에러 메시지와 함께 onFailure 메서드가 호출될 수 있다. 이러한 이유는 Android Pie(9.0)부터 네트워크 보안 정책이 변경되었기 때문이다. https 가 아닌 http 주소는 보안에 취약하기 때문에 사용하기 위해서는 따로 설정을 해줘야 한다.
res/xml 아래 network_security_config.xml 파일을 만든다.
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">jsonplaceholder.typicode.com</domain>
</domain-config>
</network-security-config>
cleartextTrafficPermitted를 true로 설정하여 http를 사용할 수 있도록 선언하면 된다.
그리고 Manifest.xml 에 <application>에 config.xml 파일을 사용한다고 선언하면 된다.
<application
android:networkSecurityConfig="@xml/network_security_config"
...
❗️ 정리
멀티파트에 대해 찾아보면 어디서는 Multipart.Part를, 어디서는 RequestBody를, 또 다른 곳에서는 Map을 사용하는 방법에 대해서만 설명해주고 어떤 차이가 있는지, 어떤 상황에 어떤 방법을 사용해야 하는가에 대한 정보는 많이 나와있지 않았다. 그래서 이번에 직접 프로젝트에 적용해보며 갖고있던 궁금증을 해결하기 위해 노력했고, 어느정도는 풀린 것 같다. 적어도 언제 어떤 자료형을 사용할 수 있는가는 명확해졌다. 근데 가장 좋은 방법은 3번이라고 생각되고 그 외의 방법과 어떤 차이가 있는지만 알고 있어도 될 것 같다. 나와 같은 궁금증을 가진 사람들이 해결할 수 있는 글이 되었으면 좋겠다.
참고