(Android/Retrofit) Retrofit 파일 업로드 — Multipart, ProgressBody


1. Multipart란?

파일과 텍스트 필드를 하나의 HTTP 요청에 여러 파트로 나눠 보내는 형식입니다.

POST /upload
Content-Type: multipart/form-data; boundary=xxx

--xxx
Content-Disposition: form-data; name="title"
내 사진
--xxx
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: image/jpeg
(바이너리 데이터)
--xxx--

2. API 선언

interface UploadApi {

    @Multipart
    @POST("upload")
    suspend fun upload(
        @Part file: MultipartBody.Part,                       // 파일 파트
        @Part("title") title: RequestBody                     // 텍스트 파트
    ): UploadResponse

    // 여러 파일
    @Multipart
    @POST("upload/multi")
    suspend fun uploadMany(
        @Part files: List<MultipartBody.Part>
    ): UploadResponse
}

3. 파일을 Part로 만들기

fun File.toPart(partName: String = "file"): MultipartBody.Part {
    val body = asRequestBody("image/jpeg".toMediaType())      // 파일 → RequestBody
    return MultipartBody.Part.createFormData(partName, name, body)
}

val file = File(cacheDir, "photo.jpg")
val title = "내 사진".toRequestBody("text/plain".toMediaType())

api.upload(file.toPart(), title)

MIME 타입은 확장자에서 유추할 수 있습니다. MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)


4. Uri(갤러리 선택)에서 업로드

Android 10+에서는 파일 경로가 아닌 Uri를 받습니다. 경로 변환 대신 ContentResolver로 스트림을 읽는 RequestBody를 만듭니다.

class UriRequestBody(
    private val contentResolver: ContentResolver,
    private val uri: Uri,
    private val mimeType: String?
) : RequestBody() {

    override fun contentType() = mimeType?.toMediaTypeOrNull()

    override fun contentLength(): Long =
        contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use {
            if (it.moveToFirst()) it.getLong(0) else -1L
        } ?: -1L

    override fun writeTo(sink: BufferedSink) {
        contentResolver.openInputStream(uri)?.source()?.use { sink.writeAll(it) }
    }
}

fun Uri.toPart(resolver: ContentResolver, name: String = "file"): MultipartBody.Part {
    val mime = resolver.getType(this)
    val fileName = "upload_${System.currentTimeMillis()}"
    return MultipartBody.Part.createFormData(name, fileName, UriRequestBody(resolver, this, mime))
}

메모리에 전체를 올리지 않고 스트리밍으로 전송하므로 큰 파일에도 안전합니다.


5. 업로드 진행률 — ProgressRequestBody

RequestBody를 감싸 전송된 바이트를 추적합니다.

class ProgressRequestBody(
    private val delegate: RequestBody,
    private val onProgress: (written: Long, total: Long) -> Unit
) : RequestBody() {

    override fun contentType() = delegate.contentType()
    override fun contentLength() = delegate.contentLength()

    override fun writeTo(sink: BufferedSink) {
        val total = contentLength()
        val countingSink = object : ForwardingSink(sink) {
            private var written = 0L
            override fun write(source: Buffer, byteCount: Long) {
                super.write(source, byteCount)
                written += byteCount
                onProgress(written, total)
            }
        }
        val buffered = countingSink.buffer()
        delegate.writeTo(buffered)
        buffered.flush()                     // 남은 버퍼 전송
    }
}
val body = ProgressRequestBody(file.asRequestBody(mediaType)) { written, total ->
    if (total > 0) progressFlow.value = (written * 100 / total).toInt()
}
val part = MultipartBody.Part.createFormData("file", file.name, body)

진행률 콜백은 OkHttp 스레드에서 호출됩니다. UI 갱신은 StateFlow에 값을 쓰거나 메인 스레드로 전달하세요. 또한 HttpLoggingInterceptor(Level.BODY)가 켜져 있으면 본문을 두 번 쓰게 되어 진행률이 이중으로 올라가므로 업로드 시에는 로그 레벨을 낮추세요.


6. 대용량·이미지 업로드 팁


7. 정리



Related Posts