티스토리 뷰

반응형
SMALL

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로 여러 개의 파일을 전송하는 법에 대해 이미지 파일 전송을 예로 들어 살펴보았습니다.

도움이 많이 되었길 바랍니다.

반응형
LIST
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
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
글 보관함