티스토리 뷰
Retrofit2를 사용하여 Multipart Form-Data형태로 파일과 Body를 업로드하는 방법에 대해 정리해보고자 합니다.
MultiPart에 대해서 잘 모르는 분들을 위해 간단하게 MultiPart에 대해 정리해보고자 합니다.
Multipart/form-data
File Upload Feature를 구현할 때, Client가 만약 Web Browser라면 Form을 통해서 사용자로부터 파일을 받고 올리게 됩니다.
이때 Web Browser가 보내는 HTTP 메시지에서 Content-Type 속성이 multipart/form-data로 지정되며, 정해진 형식에 따라 메시지를 인코딩하여 전송합니다. 이를 처리하기 위한 서버는 멀티파트 메시지에 대해서 각 파트별로 분리하여 개별 파일의 정보를 얻게 됩니다.
제가 실제로 안드로이드 개발을 하면서 파일 업로드 시 사용했던 HTTP POST 요청 코드를 가지고 왔습니다.
POST /api/ HTTP/1.1
Host: (example).com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAAAAAAAAAAAAAAA
User-Agent: ~~~~~~
Accept: */*
Cache-Control: no-cache
Host: (example).com
Cookie: PHPSESSID = ~~~~~; ~~~~~=~~~~~
Accept-Encoding: gzip, deflate
Content-Length: 10660
Connection: keep-alive
cache-control: no-cache
Content-Disposition: form-data; name="files[]"; filename="/Users/terry/test1.png
------WebKitFormBoundaryAAAAAAAAAAAAAAA--,
Content-Disposition: form-data; name="files[]"; filename="/Users/terry/test2.png
------WebKitFormBoundaryAAAAAAAAAAAAAAA--
Content-Disposition: form-data; name="files[]"; filename="/Users/terry/test3.png
------WebKitFormBoundaryAAAAAAAAAAAAAAA--
Content-Disposition: form-data; name="action"
upload
------WebKitFormBoundaryAAAAAAAAAAAAAAA--
HTTP Message는 기본적으로 헤더(Header)와 본문(payload)으로 구성됩니다. 헤더(Header)는 기본적으로 아스키(ASCII) 코드로만 작성되는 것으로 간주하며, End Point에서는 해당 메시지의 앞 부분을 텍스트 데이터로 해석합니다.
기본적으로 Header와 Payload는 개행 한 줄(\n)로 구분되며, Header는 다시 각 Line으로 구분됩니다.
헤더는 맨처음 method 타입과 URI 그리고 규약의 종류(http/https/ftp….)를 명시합니다. 이후에 호스트, 사용자 에이전트, 인코딩, 타입 등의 여러 정보를 추가로 넣어줍니다. 그런 다음 빈 줄 다음 부터는 메시지의 payload로 해석합니다.
Web Browser가 Multpart Request를 보낼 때는 헤더에 Content-Type 필드 값이 “multipart/form-data”로 명시됩니다. 이때, 세미콜론으로 구분한 다음 Boundary값을 넣어줍니다. 이 바운더리 문자열은 다시 메시지 페이로드를 각 파트로 구분하는 구분자가 됩니다. 관례적으로 연속된 하이픈으로 시작하며, 임의의 데이터를 넣어주면 됩니다.
------WebKitFormBoundaryAAAAAAAAAAAAAAA--
Content-Disposition: form-data; name="files[]"; filename="/aaa/bbb/~~~~.png
...
이 때 유의할 것은 각 파트와 파트는 (개행된 후) Boundary String으로만 구분하며 파트 사이에는 빈 줄이 포함되지 않습니다.
마지막 파트의 끝에도 Boundary String이 들어가는데, 다른 경우와 달리 이 때는 Boundary String 뒤에 하이픈 2개(--)를 추가해줍니다.
Retrofit2 API Interface 설정
이제 Retrofit2를 사용하여 Multipart Form Data로 파일 업로드 기능을 구현해보도록 하겠습니다.
먼저 Retrofit API Interface를 설정해주어야 합니다. 저는 다음과 같이 진행하였습니다.
public interface FileUploadAPI {
@Multipart
@POST("/api/")
Call<FileResponse> uploadImages(@PartMap Map<String, RequestBody> map, @Part ArrayList<MultipartBody.Part> files);
}
MultiPart이기 때문에 @Field Annotation은 사용할 수 없습니다.
PartMap으로 Mapping해서 넣어주어야 합니다.
본 포스팅에서는 이미지 파일 업로드로 설명하도록 하겠습니다만, 다른 파일로 동일하기 때문에 따라하시면 될 것 같습니다.
이미지 파일은 Image Picker를 통해 가지고 왔다고 생각하고 그 다음 단계부터 설명하도록 하겠습니다.
설명에 앞서 전체 코드부터 살펴보겠습니다. submitToServer라는 이미지 업로드 메소드입니다.
모든 코드를 하나의 메소드에 담아내느라 코드가 깔끔하지는 않습니다. 짧은 라인으로 다 보여주기 위함이니 양해바랍니다.
public void submitToServer() {
ArrayList<MultipartBody.Part> imageList = new ArrayList<>();
for(Uri uri : postUriList) {
String path = FileUtil.getPath(uri, this);
File file = new File(path);
RequestBody requestFile = RequestBody.create(MediaType.parse("multipart/form-data"), file);
MultipartBody.Part uploadFile = MultipartBody.Part.createFormData("files[]", FileUtil.getPath(uri, this), requestFile);
imageList.add(uploadFile);
}
Map<String, RequestBody> map = new HashMap<>();
RequestBody act = RequestBody.create(MediaType.parse("text/plain"), "upload");
map.put("act", act);
final Call<FileResponse> upload = fileUploadAPI.uploadImages(map, imageList);
upload.enqueue(new Callback<FileResponse>() {
@Override
public void onResponse(Call<FileResponse> call, Response<FileResponse> response) {
if(response.body().getFiles() != null) {
List<FileItem> responseList = response.body().getFiles();
finalPostToServer(responseList);
} else {
Util.showToast("사진 업로드 도중 오류가 발생하였습니다.", PostStoryActivity.this);
}
}
@Override
public void onFailure(Call<FileResponse> call, Throwable t) {
Log.e("UPLOAD FAIL ::", t.toString());
}
});
}
우선 가지고온 이미지의 URI List를 통해 MultipartBody.Part List를 만들어 주어야 합니다.
uri를 통해 파일의 경로를 가지고 오고 이 경로를 통해 파일 객체를 생성합니다.
그리고 MultiPartBody.Part 객체를 생성하고 리스트에 추가하게 됩니다.
그런데 저의 경우는 서버에서 이미지를 업로드할 때 name을 "files[]"라는 이름으로 하여 업로드 하라고 했기 때문에 MultipartBody.Part.createFormData의 첫번째 Parameter로 "files[]"라고 작성하였습니다.
서버의 상황에 따라 달라지는 부분이니 참고만 하시기 바랍니다.
ArrayList<MultipartBody.Part> imageList = new ArrayList<>();
for(Uri uri : postUriList) {
String path = FileUtil.getPath(uri, this);
File file = new File(path);
RequestBody requestFile = RequestBody.create(MediaType.parse("multipart/form-data"), file);
MultipartBody.Part uploadFile = MultipartBody.Part.createFormData("files[]", FileUtil.getPath(uri, this), requestFile);
imageList.add(uploadFile);
}
여기서 FileUtil.getPath()라는 메소드에 대해서는 아래 코드를 살펴보시기 바랍니다.
아래 코드를 복사해서 추가하시면 됩니다.
public class FileUtil {
/*
* Gets the file path of the given Uri.
*/
@SuppressLint("NewApi")
public static String getPath(Uri uri, Context context) {
final boolean needToCheckUri = Build.VERSION.SDK_INT >= 19;
String selection = null;
String[] selectionArgs = null;
// Uri is different in versions after KITKAT (Android 4.4), we need to
// deal with different Uris.
if (needToCheckUri && DocumentsContract.isDocumentUri(context, uri)) {
if (isExternalStorageDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
return Environment.getExternalStorageDirectory() + "/" + split[1];
} else if (isDownloadsDocument(uri)) {
final String id = DocumentsContract.getDocumentId(uri);
if (id.startsWith("raw:")) {
return id.replaceFirst("raw:", "");
}
uri = ContentUris.withAppendedId(
Uri.parse("content://downloads/public_downloads"), Long.valueOf(id));
} else if (isMediaDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];
switch (type) {
case "image":
uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
break;
case "video":
uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
break;
case "audio":
uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
break;
}
selection = "_id=?";
selectionArgs = new String[]{
split[1]
};
}
}
if ("content".equalsIgnoreCase(uri.getScheme())) {
String[] projection = {
MediaStore.Images.Media.DATA
};
try (Cursor cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null)) {
if (cursor != null && cursor.moveToFirst()) {
int columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
return cursor.getString(columnIndex);
}
} catch (Exception e) {
Log.e("on getPath", "Exception", e);
}
} else if ("file".equalsIgnoreCase(uri.getScheme())) {
return uri.getPath();
}
return null;
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is ExternalStorageProvider.
*/
private static boolean isExternalStorageDocument(Uri uri) {
return "com.android.externalstorage.documents".equals(uri.getAuthority());
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is DownloadsProvider.
*/
private static boolean isDownloadsDocument(Uri uri) {
return "com.android.providers.downloads.documents".equals(uri.getAuthority());
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is MediaProvider.
*/
private static boolean isMediaDocument(Uri uri) {
return "com.android.providers.media.documents".equals(uri.getAuthority());
}
}
이미지를 MultiPartBody.Part 리스트에 다 추가하셨다면, 이제 추가적인 Body를 넣을 차례입니다.
저는 서버에서 action이라는 Parameter로 "upload"라는 값을 달라고 하여 아래처럼 구현하도록 하겠습니다.
Map<String, RequestBody> map = new HashMap<>();
RequestBody act = RequestBody.create(MediaType.parse("text/plain"), "upload");
map.put("act", act);
String, RequestBody 형태의 HashMap에 추가합니다.
"upload"는 텍스트 형식이므로 "text/plain"이라고 적어줍니다. 그리고 map에 put 해주게 됩니다.
이러면 이제 action="upload"의 값을 서버로 보낼 수 있게 되었습니다.
마지막으로 우리가 잘 쓸 줄 아는 형태로 요청하면 되겠습니다.
이번 포스트에서는 MultiPart로 여러 개의 파일을 전송하는 법에 대해 이미지 파일 전송을 예로 들어 살펴보았습니다.
도움이 많이 되었길 바랍니다.
'Frontend > Android' 카테고리의 다른 글
RecyclerView : Invalid Item Position (Data Set 설정 시 주의) (0) | 2019.08.05 |
---|---|
나의 앱에 날개를 - Lottie 사용해보기 (0) | 2019.07.31 |
RxJava를 사용한 Debounce 구현 (0) | 2019.07.30 |
Android Data Binding을 사용해보자 (0) | 2019.07.24 |
Fragment View Pager 간단하게 구현하기 (0) | 2019.07.16 |
- Total
- Today
- Yesterday
- android
- C++
- Kotlin
- 컬렉션
- Swift
- apple
- 아이폰
- Auto Layout
- 스위프트
- watchos
- Rxjava
- CloudComputing
- 함수형프로그래밍
- retrofit
- Notissu
- Reactive programming
- ios
- java
- XCode
- 애플워치
- 상속
- Elliotable
- databinding
- 코틀린
- Apple Watch
- SwiftUI
- 안드로이드
- 함수형
- 알고리즘
- 오토레이아웃
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |