이번에 UMC에서 주관하는 "모여봐요 해커톤"에 참가하게 되었다.
해커톤을 진행하며 많은 트러블을 겪었는데, 그 중 가장 기억에 남는 multipart/form-data 이슈에 대해 소개하고, 또 공부한 내용을 기록하고자 한다. (마지막에 해커톤 후기도 포함되어 있다.)
기존 나의 multipart/form-data 지식 ?
이미지와 같은 큰 사이즈의 요청을 보낼 때 사용하는 하나의 프로토콜? 정도로 알고 있었고, multipart/form-data 전송 방식 및 키워드에 대한 지식은 전혀 없었다.
구글링을 통해 Retrofit2 @Multipart를 활용하여 단순히 이미지를 전송하는 방식만 알고 있었다.
(사실 단일 이미지 전송만을 위해서만 주로 사용했고, 사용 빈도가 높지 않아 지식의 필요성을 못 느낀것 같다.)
그리고 해커톤 당일이 되었고, 회원가입 요청으로 다음과 같은 형식을 받았다.
해당 형식에 대해 설명하자면,
두 개의 Parts, info와 image로 구분되어 있으며, 내용은 아래와 같다.
- image : 프로필 이미지의 사진
- info : 아이디, 패스워드, 사용자 이름, 중독 이름과 중독 정도가 매핑된 사용자 중독 정보 리스트
해당 회원가입 형식을 보자마자, 정말 당황했다. multipart/form-data 방식에 대해 지식이 없는 상황에서 다음과 형식을 구현할 방법을 구상할 수 없었다.
급한대로 온갖 구글링을 하며 비슷한 예제를 찾을려고 하였지만, 해커톤의 긴박한 분위기와 기능의 시작인 회원가입을 구현 못한다는 생각은 내 이성을 빠르게 빼았아 갔다. 이성을 잃은 나는 금방 패닉상태에 빠져버렸고, 그 상태로 귀중한 2시간을 날렸다. (어쩌면, 더 많이 날렸을 수도,,)
그 이후로 백엔드 개발자분께 자초지종을 설명하고, 내가 할 수 있는 방법으로 바꾸어 주셨다.
해당 방식은 단일 이미지만을 multipart/form-data로 전송하고, 저장된 이미지 url을 반환을 받는다. 반환된 url 정보와 사용자 정보를 application-json 방식으로 보내는 방식이다.
당시에 2시간을 소비하고 백엔드 개발자분이 형식 수정까지 했으니, multipart/form-data 때문에 정말 많은 시간이 지나갔다. 돌이켜보면 정말 부끄럽고, 아쉬운 순간이였다.
이러한 경험을 기반으로 multipart/form-data 방식에 대해 공부하고, Retrofit2 @Multipart를 활용하는 방법을 기록하고자 한다.
Multipart 왜 쓰는 걸까 ?
HTTP Request는 보통 Body에 데이터를 넣고, Header에 Body에 들어간 데이터의 타입을 명시한다.
이 데이터의 타입을 명시하는 곳을 Content-Type이라고 하며, 서버는 Content-Type을 확인해 데이터 타입에 맞게 처리할 수 있다.
그런데 다음과 같은 상황이 발생할 수 있다.
data class Input (
val image: ByteArray, // JPEG Image
val description: String, // Plain Text Image Desription
)
위와 같은 Input을 HTTP Request를 통해 서버로 전송한다고 가정하자.
Image의 Content-Type은 image/jpeg이고, description의 Content-Type은 text/plain이다. 그럼 Header에 어떤 Content-Type을 명시해야 할까?
Content-Type은 하나로만 지정이 가능한데, Body에 서로 다른 두 개의 Content-Type이 담기게 된 것이다.
이러한 경우 사용할 수 있는게 Multipart 방식이다.
Multipart는 하나의 Body안에 서로 다른 Content-Type의 데이터가 포함되는 것을 가능하게 하는 새로운 Content-Type이다.
Multipart로 전송하면, 서버는 메세지에 대해 각 Part 별로 분리하여 다룰 수 있게 된다.
그럼 mulitpart/form-data 란 ?
서로 다른 Content-Type의 Body의 Content-Type을 multipart라고 하였다.
Form은 하나의 Content-Type을 대표하는 입력 형식이며, 다음과 같은 구조로 구성된다.
- name : form의 이름, 서버로 보내질 때 이름의 값으로 데이터 전송한다.
- action : 서버 url 또는 html 링크
- method : 전송 방법 설정. get은 default, post는 데이터를 url에 공개하지 않고 숨겨서 전송하는 방법이다.
- autocomplete : 자동 완성. on으로 하면 form 전체에 자동 완성 허용한다.
- enctype : 폼 데이터(form data)가 서버로 제출될 때, 해당 데이터가 인코딩되는 방법을 명시한다.
enctype에 세 가지 타입을 명시할 수 있다.
- text/plain : 공백 문자(space)는 “+” 기호로 치환하지만, 나머지 문자는 모두 인코딩되지 않음을 명시한다.
- x-www-form-urlencode : default 값으로, 모든 문자들을 서버로 보내기 전에 인코딩됨을 명시한다.
- multipart/form-data : 모든 문자를 인코딩 하지 않음을 명시한다.
multipart/form-data는 바이너리 파일을 업로드할 때 사용할 수 있는 Content-Type이다.
x-www-form-urlencode와 multipart/form-data은 둘다 폼 형태이지만, x-www-form-urlencode은 대용량 바이너리 테이터를 전송하기에 비능률적이기 때문에 대부분 첨부파일은 multipart/form-data를 사용한다.
Android에서 multipart-form/data 는 ?
Android에서 서버와의 통신을 위해 Retrofit2를 주로 활용하고 있다. Retrofit2의 Mulitpart 어노테이션을 활용하면, 손쉽게 Multipart 형식으로 전송할 수 있다.
interface AuthService {
@POST("/users/profile")
@Multipart
suspend fun registerProfile(
@Part file: MultipartBody.Part,
): RegisterProfileResponse
}
Multipart 어노테이션을 사용하면, 꼭 Part 어노테이션을 함께 사용해야 한다. 아니면 컴파일 오류가 발생한다.
또한 각 필드의 상속 타입을 MultipartBody.Part 혹은 RequestBody로 명시해야 한다.
RequestBody로 명시할 경우, 단일 항목에 대한 전송만 가능하기 때문에, 다중 항목 전송은 MultipartBody.Part를 사용해야 한다.
(이처럼 명시하지 않을 경우 컴파일 오류가 발생한다.)
MultipartBody.Part 객체 생성은 다음과 같이 구현할 수 있다.
class AuthRepositoryImpl @Inject constructor(
private val service: AuthService,
): AuthRepository {
override suspend fun registerProfile(imageByteArray: ByteArray): Result<RegisterProfileResponse> {
return runCatching {
val requestFile = imageByteArray.toRequestBody("image/*". toMediaTypeOrNull())
service.registerProfile(
file = MultipartBody.Part.createFormData(
name = "profile",
filename = "image.jpg",
requestFile
)
)
}
}
}
입력으로 받은 이미지의 ByteArray를 Content-Type을 포함하는 RequestBody 객체를 생성한다.
해당 RequestBody 객체를 createFormData() 메소드에 인자로 전달하여, Form 데이터를 생성한다.
위의 createFormData 메소드는 다음과 같다.
@JvmStatic
fun createFormData(name: String, filename: String?, body: RequestBody): Part {
val disposition = buildString {
append("form-data; name=")
appendQuotedString(name)
if (filename != null) {
append("; filename=")
appendQuotedString(filename)
}
}
val headers = Headers.Builder()
.addUnsafeNonAscii("Content-Disposition", disposition)
.build()
return create(headers, body)
}
입력받은 인자에 따라 Header의 Content-Type을 명시하는 것을 확인할 수 있다.
처음에 소개한 회원가입 형식은 어떻게 구현하는데요 ?
위의 저 형식은 지금은 수정되어 테스트할 수 없다. 그래도 생각한 방법은 다음과 같다.
@POST("/users/signup")
suspend fun register(
@Part file: MultipartBody.Part,
@Part info: MultipartBody.Part
)
두 개의 Part로 구분한, Retrofit2 Interface를 구현한다.
@Provides
@Singleton
fun provideJson(): Json = Json {
ignoreUnknownKeys = true
prettyPrint = true
}
Object To Json을 구현하기 위해, Kotlin-Serialization의 Json 객체 주입을 구현한다.
private suspend fun register(imageByteArray: ByteArray, registerRequest: RegisterRequest) {
runCatching {
// File Form Data 만들기
val fileRequestBody = imageByteArray.toRequestBody("image/*". toMediaTypeOrNull())
val fileFormData = MultipartBody.Part.createFormData(
name = "profile",
filename = "image.jpg",
fileRequestBody
)
// application-json Content-Type의 Data 만들기
val infoRequestBody = json
.encodeToString(registerRequest)
.toRequestBody(contentType = "application/json; charset=utf-8".toMediaTypeOrNull())
val infoFormData = MultipartBody.Part.create(infoRequestBody)
service.register(
file = fileFormData,
info = infoFormData
)
}
}
위 코드는 다음과 같은 구조를 가지고 있다.
- Info 객체를 Json형태의 문자열로 인코딩한다.
- 해당 인코딩된 문자열을 application-json Cotent-Type을 포함한 Request Body를 생성한다.
- RequestBody를 활용하여, MultipartBody의 하나의 Part를 구현한다.
테스트는 해볼 수 없지만, 제대로 구현한 느낌이 든다 ㅎㅎ,,,
해커톤을 끝낼 때, 이번 포스팅은 무조건 Multipart에 대해서 해야겠다고 생각했다. 정말 경험만큼 완벽한 동기는 없는 것 같다. ㅠㅠ
이번 기회에 Multipart에 대해서 알아볼 수 있었다. 비록 이번 해커톤에는 Multipart 방식을 실패했지만, 다음번에 협업에서도 Multipart 방식을 도입한다면, 문제 없이 도입할 수 있을 것 같다.
해커톤 후기
해커톤을 하기 전, 나는 안드로이드 개발자로 취업을 위해 이력서와 포트폴리오를 오랜 기간 동안 준비했고, 마침내 가고 싶었던 회사에 지원했다. 그러나 결과는 모두 서류 불합격이였다. 나는 크게 불합이나 실패에 크게 신경 쓰지 않는 편이지만, 이번에는 예상치 못한 충격으로 다가왔다. 그래도 면접은 볼 수 있다고 생각했는데, 그마저도 아니였다.
그러던 와중 해커톤을 우연치 않게 참가하게 되었다. 해커톤을 진행하면서, 많은 실패와 트러블을 경험했고 그럴 때마다 포기하고 싶다는 생각이 들었고, 실제로도 끝이 보이지 않는 마라톤을 하는 느낌이였다.
이런 상황에서 나는 현재 내가 할 수 있는 최선과 노력을 다했던 것 같다. 다들 야식을 먹을 때도, 피곤해서 자고 있을 때도, 기능 하나라도 더 구현하기 위해 쉼 없이 달렸던 것 같다. 이런 해커톤을 진행하고 끝 마치며, 내가 할 수 있는 최선과 노력에 대해 다시 생각해 볼 수 있었고, 끝이 보이지 않는 마라톤이라도, 내가 할 일을 묵묵히 하면 끝이 점점 보인다는 것을 깨달을 수 있었다.
취업도 하나의 끝이 보이지 않는 마라톤이라고 생각하며, 지금 겨우 한번 허들에 걸려 넘어졌다고 생각한다. 앞으로도 계속 걸려 넘어지겠지만, 다시 일어서서 끝까지 달리면 결승선에 도착할 수 있을 것이라고 생각한다.
현재는 취업 성공했습니다 :)
해커톡 기록
https://github.com/jeongjaino/B-Addicts-Frontend
GitHub - jeongjaino/B-Addicts-Frontend: 함께 중독을 이겨내는 서비스 "컷 톡(Cut Toxic)"
함께 중독을 이겨내는 서비스 "컷 톡(Cut Toxic)". Contribute to jeongjaino/B-Addicts-Frontend development by creating an account on GitHub.
github.com
참고자료
https://yunzema.tistory.com/186
https://lena-chamna.netlify.app/post/http_multipart_form-data/