Wednesday, July 24, 2019

Retrofit에서 application/octet-stream 응답 처리: 난감한 바이너리 데이터 완벽 정복기

안드로이드 개발에서 서버와의 통신은 앱의 핵심 기능을 구성하는 필수적인 요소입니다. 그리고 이 영역에서 Retrofit2는 거의 표준 라이브러리로 자리 잡았습니다. 간결한 인터페이스 정의만으로 복잡한 HTTP 통신 로직을 구현해주고, DTO(Data Transfer Object) 모델과 자동으로 파싱해주는 편리함은 수많은 개발자의 생산성을 극대화해주었죠. 하지만 우리가 마주하는 모든 API가 항상 친절하게 JSON 형식의 데이터만 보내주는 것은 아닙니다.

때로는 문서를 제대로 받지 못했거나, 파일 다운로드 API를 연동하거나, 혹은 특수한 형태의 데이터를 전송하는 외부 API를 다루다 보면 Content-Type: application/octet-stream 이라는 생소한 응답 헤더를 마주하게 됩니다. 이 순간, Gson이나 Moshi 컨버터에 의존해왔던 우리의 깔끔한 Retrofit 코드는 예외를 뿜어내며 비정상적으로 종료됩니다. '분명 URL도 맞고 서버도 200 OK를 반환하는데, 왜 앱이 죽는 거지?' 하는 당혹감은 덤입니다.

이 글은 바로 그 당혹스러운 순간을 위한 안내서입니다. Retrofit을 사용하다 application/octet-stream이라는 예상치 못한 복병을 만났을 때, 어떻게 이 문제를 진단하고, 우아하게 해결하며, 나아가 파일 다운로드와 같은 실용적인 기능으로 구현할 수 있는지에 대한 모든 것을 상세하게 다룰 것입니다. 단순히 '이렇게 하세요'를 넘어, 왜 그렇게 동작하는지 원리를 파헤치고 다양한 예외 상황에 대처하는 노하우까지 공유합니다.


1. 이상적인 시나리오: Retrofit과 JSON의 아름다운 협업

문제를 해결하기에 앞서, 먼저 Retrofit이 가장 빛을 발하는 '이상적인' 상황을 복기해 보겠습니다. 바로 서버가 예측 가능한 JSON 형식으로 응답을 주는 경우입니다. 이 과정을 이해하면, 왜 application/octet-stream이 문제를 일으키는지 명확하게 알 수 있습니다.

1-1. Retrofit 기본 설정: 통신을 위한 준비

가장 먼저 `build.gradle` 파일에 Retrofit과 JSON 컨버터 라이브러리를 추가해야 합니다. 여기서는 가장 널리 쓰이는 Gson을 예시로 들겠습니다.


// app/build.gradle.kts (Kotlin DSL) 또는 app/build.gradle (Groovy)

dependencies {
    // Retrofit 라이브러리
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    
    // Gson 컨버터: JSON <-> Kotlin/Java Object 변환을 담당
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")

    // (선택사항) OkHttp 로깅 인터셉터: 통신 로그를 보기 위해 매우 유용
    implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
}

1-2. Retrofit 인스턴스 생성: 통신의 관문

라이브러리 추가가 끝났다면, 앱 전역에서 사용할 싱글턴(Singleton) 형태의 Retrofit 인스턴스를 생성합니다. 이 인스턴스는 통신의 기본 설정(Base URL, Converter Factory 등)을 담고 있습니다.


import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit

object ApiClient {
    private const val BASE_URL = "https://api.example.com/"

    private fun createOkHttpClient(): OkHttpClient {
        val loggingInterceptor = HttpLoggingInterceptor().apply {
            level = if (BuildConfig.DEBUG) {
                HttpLoggingInterceptor.Level.BODY // 개발 중에는 모든 로그를 확인
            } else {
                HttpLoggingInterceptor.Level.NONE // 배포 시에는 로그를 남기지 않음
            }
        }

        return OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS) // 연결 타임아웃
            .readTimeout(30, TimeUnit.SECONDS)    // 읽기 타임아웃
            .writeTimeout(30, TimeUnit.SECONDS)   // 쓰기 타임아웃
            .addInterceptor(loggingInterceptor)     // 위에서 만든 로깅 인터셉터 추가
            .build()
    }

    val instance: Retrofit by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(createOkHttpClient()) // 커스텀 OkHttpClient 설정
            .addConverterFactory(GsonConverterFactory.create()) // Gson 컨버터 추가!
            .build()
    }
}

여기서 가장 중요한 부분은 .addConverterFactory(GsonConverterFactory.create()) 입니다. 이 한 줄의 코드는 Retrofit에게 "서버로부터 응답이 오면, 그 내용을 Gson을 사용해서 내가 지정한 객체로 변환해줘"라고 지시하는 역할을 합니다.

1-3. API 인터페이스와 DTO 정의

이제 서버와 주고받을 약속, 즉 API 엔드포인트와 데이터 형식을 정의할 차례입니다. 예를 들어, 사용자 정보를 가져오는 API가 있다면 다음과 같이 정의할 수 있습니다.


import com.google.gson.annotations.SerializedName
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Path

// 1. 서버 응답 JSON을 담을 데이터 클래스 (DTO)
data class UserProfile(
    @SerializedName("id")
    val id: Long,
    
    @SerializedName("username")
    val username: String,
    
    @SerializedName("email")
    val email: String,

    @SerializedName("is_active")
    val isActive: Boolean
)

// 2. API 엔드포인트를 정의하는 인터페이스
interface UserService {
    // GET 요청, /users/{userId} 경로로 요청
    @GET("users/{userId}")
    fun getUserProfile(
        @Path("userId") userId: String
    ): Call<UserProfile> // 응답을 UserProfile 객체로 받겠다고 명시
}

핵심은 `Call` 입니다. 우리는 Retrofit에게 `users/{userId}` API를 호출하면 그 결과가 `UserProfile` 클래스 구조에 맞는 JSON일 것이라고 알려주었고, Retrofit은 약속대로 응답 바디를 파싱하여 `UserProfile` 객체로 변환해 줄 것입니다.

1-4. API 호출 및 결과 처리

실제 코드에서 API를 호출하는 부분은 매우 직관적입니다.


// UserService 인터페이스 구현체 생성
val userService = ApiClient.instance.create(UserService::class.java)

// API 호출 실행
fun fetchUserData(userId: String) {
    val call = userService.getUserProfile(userId)
    
    call.enqueue(object : retrofit2.Callback<UserProfile> {
        override fun onResponse(call: Call<UserProfile>, response: retrofit2.Response<UserProfile>) {
            if (response.isSuccessful) {
                // 성공! response.body()는 이미 UserProfile 객체입니다.
                val userProfile: UserProfile? = response.body()
                
                userProfile?.let {
                    println("사용자 이름: ${it.username}")
                    println("사용자 이메일: ${it.email}")
                }
            } else {
                // 서버 에러 (404, 500 등)
                println("에러 코드: ${response.code()}")
                println("에러 메시지: ${response.errorBody()?.string()}")
            }
        }

        override fun onFailure(call: Call<UserProfile>, t: Throwable) {
            // 네트워크 오류나 JSON 파싱 실패 등
            println("요청 실패: ${t.message}")
        }
    })
}

이것이 바로 Retrofit의 마법입니다. 개발자는 복잡한 네트워크 처리, 스트림 파싱, JSON 변환 로직에 신경 쓸 필요 없이 비즈니스 로직에만 집중할 수 있습니다. `response.body()`를 호출하는 것만으로 잘 가공된 `UserProfile` 객체를 얻게 됩니다.


2. 예상치 못한 방문객: application/octet-stream의 등장

앞서 본 '이상적인 시나리오'는 서버와 클라이언트가 `Content-Type: application/json` 이라는 명확한 약속 하에 통신하기에 가능했습니다. 그러나 이제, 이 약속이 깨지는 상황을 마주해봅시다.

2-1. `application/octet-stream`이란 무엇인가?

application/octet-stream은 MIME(Multipurpose Internet Mail Extensions) 타입의 일종으로, '8비트 단위의 바이너리 데이터 묶음'을 의미합니다. 쉽게 말해, '이 데이터가 구체적으로 어떤 형식인지는 나도 모른다. 그냥 바이트(byte) 덩어리일 뿐이다. 해석은 네(클라이언트)가 알아서 해라' 라는 메시지와 같습니다.

이 형식은 매우 범용적이어서 다양한 상황에서 사용됩니다.

  • 파일 다운로드: 이미지(jpg, png), 압축 파일(zip), 문서(pdf), 실행 파일(exe) 등 종류를 특정하지 않고 파일을 전송할 때.
  • 동적으로 생성된 데이터: 서버에서 실시간으로 생성한 리포트, 통계 자료 등 정해진 형식이 없는 데이터를 보낼 때.
  • 독자적인 바이너리 포맷: Protocol Buffers(Protobuf), FlatBuffers 등 JSON/XML이 아닌 고성능 직렬화 포맷을 사용하면서 별도의 Content-Type을 지정하지 않은 경우.
  • 잘못 구성된 서버 응답: 원래는 JSON이나 이미지였어야 하지만, 서버 측 설정 오류로 인해 범용적인 `octet-stream`으로 전송되는 경우.

2-2. 충돌의 순간: Gson 컨버터와 바이너리 데이터의 만남

자, 이제 문제가 발생한 상황을 가정해봅시다. 이미지 파일을 다운로드하는 API를 연동해야 합니다. API 문서는 부실해서 `https://api.example.com/download/image/my-avatar.png` 라는 URL만 알고 있습니다. 우리는 평소처럼 DTO를 만들 수 없으니, 일단 `JsonObject`로 받아보려고 시도할 수 있습니다.


import com.google.gson.JsonObject
import retrofit2.Call
import retrofit2.http.GET

interface FileDownloadService {
    @GET("download/image/my-avatar.png")
    fun downloadImageAsJson(): Call<JsonObject> // 이런! 잘못된 시도
}

이 코드를 실행하면 어떤 일이 벌어질까요? 100% `onFailure` 콜백이 호출됩니다. Logcat을 살펴보면 아마도 다음과 유사한 에러 메시지를 보게 될 것입니다.


com.google.gson.JsonSyntaxException: java.lang.IllegalStateException: Expected BEGIN_OBJECT but was ... at ...
// 또는
java.net.MalformedJsonException: Use JsonReader.setLenient(true) to accept malformed JSON at line 1 column 1 path $

이 에러의 의미는 명확합니다. "Gson 컨버터가 JSON 객체({로 시작하는)를 기대했는데, 엉뚱한 바이너리 데이터가 들어와서 파싱할 수 없다"는 뜻입니다.

서버는 정상적으로 이미지 파일의 바이트 데이터를 `200 OK` 상태 코드와 함께 보냈습니다. 하지만 Retrofit에 설정된 `GsonConverterFactory`가 이 응답을 가로채서, `Content-Type`이 `application/octet-stream`임에도 불구하고 `Call`라는 정의에 따라 무조건 JSON으로 변환하려고 시도하다가 실패한 것입니다. 즉, Retrofit의 편리한 자동 변환 기능이 오히려 독이 된 상황입니다.


3. 만능 열쇠: `ResponseBody`로 원시 데이터 잠금 해제하기

이 교착 상태를 해결할 열쇠는 자동 변환의 마법을 잠시 꺼두고, 서버가 보낸 원시(Raw) 데이터를 그대로 받는 것입니다. Retrofit은 이를 위한 완벽한 해결책인 `okhttp3.ResponseBody`를 제공합니다.

3-1. `ResponseBody`란 무엇인가?

`ResponseBody`는 Retrofit의 기반이 되는 OkHttp 라이브러리에 포함된 클래스입니다. 이름 그대로 HTTP 응답의 '몸통' 부분을 아무런 가공 없이 그대로 담고 있는 객체입니다. 여기에는 다음과 같은 정보와 기능이 포함됩니다.

  • 원시 데이터 접근: 응답 본문을 `InputStream`, `ByteArray`, `String` 등 다양한 형태로 얻을 수 있습니다.
  • 메타데이터: 응답의 `Content-Type`과 `Content-Length` 같은 유용한 정보를 포함하고 있습니다.

API 인터페이스의 반환 타입을 `Call`로 지정하면, 우리는 Retrofit에게 이렇게 말하는 것과 같습니다. "이 API 호출에 대해서는 Gson이든 Moshi든 어떤 컨버터도 작동시키지 마. 서버가 주는 응답 본문을 그대로 나에게 전달해줘."

3-2. 코드 수정: `Call<DTO>`에서 `Call`로

해결책은 놀랍도록 간단합니다. API 인터페이스만 수정하면 됩니다.


import okhttp3.ResponseBody // ★★★ OkHttp의 ResponseBody를 임포트
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Streaming // ★★★ 중요한 어노테이션!
import retrofit2.http.Url

interface FileDownloadService {
    
    // URL을 동적으로 받을 수 있도록 @Url 어노테이션 사용
    @GET
    @Streaming // ★★★ 필수! 대용량 파일을 위한 스트리밍 어노테이션
    fun downloadFile(@Url fileUrl: String): Call<ResponseBody> // 반환 타입을 ResponseBody로 변경
}

여기서 주목해야 할 두 가지 중요한 변경점이 있습니다.

  1. `Call<ResponseBody>`: 반환 타입을 우리가 원하는 DTO가 아닌, `okhttp3.ResponseBody`로 명시했습니다.
  2. `@Streaming`: 이것은 매우 중요한 어노테이션입니다. 만약 `@Streaming`을 붙이지 않으면, Retrofit은 응답으로 오는 바이너리 데이터 전체를 메모리에 한 번에 올리려고 시도합니다. 작은 텍스트 파일 정도는 괜찮겠지만, 수십 MB, 수 GB에 달하는 이미지나 동영상 파일을 다운로드한다면 십중팔구 `OutOfMemoryError`를 발생시키며 앱이 강제 종료될 것입니다. `@Streaming` 어노테이션은 Retrofit에게 데이터를 조금씩 나눠서 스트림 형태로 처리하라고 알려주어 메모리 문제를 방지하는 필수적인 장치입니다.

3-3. 응답 처리 로직의 변화

인터페이스가 바뀌었으니, 이제 응답을 처리하는 콜백 부분도 그에 맞게 수정해야 합니다. 더 이상 `response.body()`가 바로 사용할 수 있는 객체가 아닙니다.


// FileDownloadService 인터페이스 구현체 생성
val fileService = ApiClient.instance.create(FileDownloadService::class.java)

// 파일 다운로드 API 호출
fun startFileDownload(url: String) {
    val call = fileService.downloadFile(url)

    call.enqueue(object : retrofit2.Callback<ResponseBody> { // 제네릭 타입이 ResponseBody
        override fun onResponse(call: Call<ResponseBody>, response: retrofit2.Response<ResponseBody>) {
            if (response.isSuccessful) {
                // 성공! response.body()는 이제 ResponseBody 객체.
                // 이 객체를 가지고 실제 파일 저장 등의 작업을 해야 함.
                val responseBody: ResponseBody? = response.body()

                if (responseBody != null) {
                    println("다운로드 시작!")
                    println("파일 크기 (bytes): ${responseBody.contentLength()}")
                    println("파일 타입: ${responseBody.contentType()}")

                    // 이제 이 responseBody를 이용해 파일을 저장하는 로직을 호출
                    // 예: saveFile(responseBody)
                } else {
                    println("응답 본문이 비어있습니다.")
                }
                
            } else {
                println("서버 에러: ${response.code()}")
            }
        }

        override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
            println("네트워크 요청 실패: ${t.message}")
        }
    })
}

이제 `onResponse`에서 받은 `ResponseBody` 객체를 어떻게 유용하게 사용할 수 있을까요? 여기서부터가 진짜 실전입니다.


4. 실전 예제: `ResponseBody`로 파일 다운로드 기능 완성하기

이론은 충분합니다. 이제 `ResponseBody`에서 얻은 `InputStream`을 이용해 실제로 파일을 디바이스에 저장하는 전체 과정을 코드로 구현해 보겠습니다. 이 작업은 파일 I/O를 포함하므로 반드시 백그라운드 스레드에서 수행해야 합니다. 여기서는 코틀린 코루틴(Kotlin Coroutines)을 사용하여 비동기 작업을 처리하는 현대적인 방법을 보여드리겠습니다.

다음은 `ResponseBody`를 받아 파일을 저장하는 전체 로직을 담은 함수입니다. ViewModel이나 Repository 클래스 내부에 구현하면 좋습니다.


import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.ResponseBody
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream

/**
 * ResponseBody의 데이터를 파일로 저장하는 suspend 함수.
 * I/O 작업을 하므로 반드시 IO 디스패처에서 호출해야 합니다.
 * @param context 컨텍스트
 * @param body 파일 데이터가 담긴 ResponseBody
 * @param fileName 저장할 파일의 이름 (예: "my_awesome_image.jpg")
 * @return 저장된 파일 객체. 실패 시 null 반환.
 */
suspend fun saveFileFromResponseBody(
    context: Context,
    body: ResponseBody,
    fileName: String
): File? {
    // withContext를 사용하여 I/O 작업을 위한 스레드로 전환
    return withContext(Dispatchers.IO) {
        var inputStream: InputStream? = null
        var outputStream: FileOutputStream? = null
        
        try {
            // 저장할 파일 경로 설정 (앱 내부 저장소/files 경로)
            val file = File(context.filesDir, fileName)
            
            val fileReader = ByteArray(4096) // 4KB 버퍼
            val fileSize = body.contentLength()
            var fileSizeDownloaded: Long = 0

            inputStream = body.byteStream() // ResponseBody로부터 InputStream 얻기
            outputStream = FileOutputStream(file)

            while (true) {
                // inputStream에서 데이터를 읽어 버퍼에 저장
                val read = inputStream.read(fileReader)

                if (read == -1) {
                    break // 파일 끝에 도달하면 루프 종료
                }
                
                // 버퍼에 읽은 만큼 파일에 쓰기
                outputStream.write(fileReader, 0, read)
                
                fileSizeDownloaded += read

                // (선택사항) 다운로드 진행률 계산 및 UI 업데이트
                val progress = (fileSizeDownloaded * 100 / fileSize).toInt()
                // LiveData나 StateFlow 등으로 UI에 진행률 전달 가능
                // Log.d("Download", "Progress: $progress%")
            }

            outputStream.flush() // 버퍼에 남은 데이터 강제 출력
            
            return@withContext file // 성공적으로 저장된 파일 객체 반환

        } catch (e: Exception) {
            e.printStackTrace()
            return@withContext null // 에러 발생 시 null 반환
        } finally {
            // 자원 해제는 필수!
            inputStream?.close()
            outputStream?.close()
        }
    }
}

위 코드에 대한 상세 설명:

  1. `withContext(Dispatchers.IO)`: 코루틴 블록을 감싸서 이 함수 내의 모든 코드가 백그라운드 I/O 스레드에서 실행되도록 보장합니다. 이렇게 함으로써 메인 스레드를 블로킹하지 않아 앱의 버벅임(ANR)을 방지합니다.
  2. `File(context.filesDir, fileName)`: 다운로드한 파일을 저장할 위치를 지정합니다. `context.filesDir`는 앱 전용 내부 저장소 경로를 가리키므로 별도의 저장소 권한이 필요 없어 안전하고 편리합니다.
  3. `body.byteStream()`: `ResponseBody`에서 `InputStream`을 가져오는 핵심적인 부분입니다. 데이터를 한 번에 읽지 않고 조금씩 흘려보내는 파이프 역할을 합니다.
  4. `while` 루프: 이 부분이 실제 다운로드 로직입니다.
    • `inputStream.read(fileReader)`: 네트워크 스트림에서 최대 4KB의 데이터를 읽어 `fileReader` 바이트 배열에 채웁니다.
    • `read == -1`: `read()` 메소드는 파일의 끝에 도달하면 -1을 반환합니다. 이것이 루프의 종료 조건입니다.
    • `outputStream.write(...)`: `fileReader`에 읽어들인 만큼의 데이터를 `FileOutputStream`을 통해 실제 파일에 씁니다.
    • `fileSizeDownloaded += read`: 다운로드된 총량을 계속 누적하여 진행률을 계산하는 데 사용합니다. `body.contentLength()`로 전체 파일 크기를 알 수 있으므로, `(다운로드된 양 / 전체 크기) * 100` 공식으로 진행률(%)을 쉽게 계산할 수 있습니다.
  5. `finally` 블록: 아주 중요합니다. 성공하든, 예외가 발생하여 실패하든 `InputStream`과 `OutputStream`은 반드시 닫아주어야 합니다. 그렇지 않으면 메모리 누수나 파일 시스템 핸들 누수(File Descriptor Leak)의 원인이 됩니다. 코틀린의 `.use{}` 확장 함수를 사용하면 `finally` 블록을 직접 작성하지 않아도 자동으로 자원을 닫아주어 더 간결하게 코드를 작성할 수 있습니다.

이제 이 `saveFileFromResponseBody` 함수를 앞서 만든 Retrofit 콜백 안에서 호출하면 모든 과정이 완성됩니다.


// ... ViewModel 또는 Activity 내부 ...
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

// ... onResponse 콜백 내부 ...
override fun onResponse(call: Call<ResponseBody>, response: retrofit2.Response<ResponseBody>) {
    if (response.isSuccessful) {
        response.body()?.let { responseBody ->
            // 코루틴 스코프를 이용해 비동기로 파일 저장 함수 호출
            viewModelScope.launch {
                val savedFile = saveFileFromResponseBody(applicationContext, responseBody, "my-downloaded-image.png")
                
                if (savedFile != null) {
                    println("파일 저장 성공! 경로: ${savedFile.absolutePath}")
                    // TODO: UI에 다운로드 완료 상태 표시, 이미지 뷰에 로드 등
                } else {
                    println("파일 저장 실패.")
                    // TODO: UI에 다운로드 실패 상태 표시
                }
            }
        }
    } 
    // ...
}

5. 심화 과정: 동적 응답 처리 및 예외 상황 대처하기

때로는 API가 성공 시에는 파일을, 실패 시에는 에러 원인이 담긴 JSON을 보내는 등 동적으로 응답 형식이 바뀌는 경우가 있습니다. 이런 까다로운 상황은 어떻게 대처해야 할까요?

시나리오: 성공 시 `image/png`, 실패 시 `application/json`

어떤 API는 요청이 성공하면 `Content-Type: image/png`와 함께 이미지 데이터를, 요청이 실패(예: 권한 없음, 파일 없음)하면 `Content-Type: application/json`과 함께 `{ "error": "File not found" }` 와 같은 JSON을 반환할 수 있습니다.

이런 경우에도 우리의 시작점은 `Call` 입니다. 범용적인 `ResponseBody`로 응답을 받은 뒤, 내용물의 실제 타입을 확인하여 분기 처리를 하는 전략을 사용합니다.


// ... onResponse 콜백 ...
override fun onResponse(call: Call<ResponseBody>, response: retrofit2.Response<ResponseBody>) {
    // 1. isSuccessful은 HTTP 상태 코드가 200-299 범위인지만 확인.
    // 본문의 내용은 별도로 확인해야 함.
    if (response.isSuccessful) {
        response.body()?.let { body ->
            val contentType = body.contentType()
            
            // 2. Content-Type을 확인하여 분기
            if (contentType?.type == "image") { // "image/png", "image/jpeg" 등
                println("이미지 데이터 수신. 파일 저장 로직 실행.")
                // viewModelScope.launch { ... saveFile... }
            } else if (contentType?.type == "application" && contentType.subtype == "json") {
                // 서버가 200 OK를 보냈지만, 내용물은 JSON 에러일 경우
                val errorJson = body.string() // 주의: string()은 한 번만 호출 가능
                println("JSON 형식의 에러 메시지 수신: $errorJson")
                // 여기서 Gson으로 errorJson을 파싱하여 사용자에게 보여줄 수 있음
            } else {
                // 예상치 못한 Content-Type
                println("알 수 없는 형식의 응답: ${contentType}")
            }
        }
    } else {
        // HTTP 상태 코드가 실패인 경우 (4xx, 5xx)
        // 에러 응답 본문은 response.errorBody()로 접근
        response.errorBody()?.let { errorBody ->
            val errorJson = errorBody.string()
            println("HTTP 에러 ${response.code()}: $errorJson")
            // errorBody 역시 JSON일 수 있으므로 파싱 로직 추가 가능
        }
    }
}

이 접근법의 핵심은 `response.isSuccessful` 만을 맹신하지 않고, `body.contentType()`을 통해 응답의 '진짜' 내용을 확인하는 것입니다. 이를 통해 훨씬 더 유연하고 견고한 네트워크 로직을 만들 수 있습니다.

매우 중요한 주의사항: `response.body().string()`, `response.body().bytes()`, `response.body().byteStream()`과 같은 메소드는 응답 스트림을 소비(consume)합니다. 즉, 단 한 번만 호출할 수 있습니다. 한 번 호출하고 나면 스트림이 닫혀서 다시 읽을 수 없습니다. 만약 `body.string()`을 호출하여 내용을 확인한 뒤, 다시 `body.byteStream()`을 호출하여 파일을 저장하려고 하면 예외가 발생합니다. 따라서 `ResponseBody`를 다룰 때는 한 번의 처리 흐름에서 모든 작업을 끝내야 합니다.

결론: 바이너리 데이터를 두려워하지 말자

처음 `application/octet-stream`을 마주했을 때의 당혹감은, 사실 Retrofit의 편리함 뒤에 숨겨진 동작 원리를 이해할 좋은 기회입니다. 이 글을 통해 우리는 다음과 같은 핵심 사항들을 완벽하게 정복했습니다.

  1. 문제의 원인: JSON을 기대하는 `ConverterFactory`가 바이너리 데이터를 만나 파싱에 실패하는 것이 문제의 본질입니다.
  2. 핵심 해결책: 반환 타입을 `Call<ResponseBody>`로 변경하여 Retrofit의 자동 변환 기능을 우회하고 원시 데이터를 직접 받습니다.
  3. 메모리 관리: 대용량 데이터 처리를 위해 `@Streaming` 어노테이션을 사용하여 `OutOfMemoryError`를 반드시 방지해야 합니다.
  4. 실용적 구현: `ResponseBody`에서 `byteStream()`을 얻고, 백그라운드 스레드에서 `InputStream`을 `File`로 쓰는 방법을 코루틴과 함께 마스터했습니다.
  5. 고급 대처법: `Content-Type` 헤더를 직접 확인하여 성공/실패 응답 형식이 동적으로 변하는 까다로운 API에도 유연하게 대처하는 방법을 배웠습니다.

이제 여러분의 안드로이드 프로젝트에서 그 어떤 형태의 API 응답이 오더라도 자신감을 가질 수 있습니다. JSON이든, 이미지 파일이든, 정체불명의 바이너리 스트림이든, `ResponseBody`라는 만능 열쇠만 있다면 문을 열고 그 내용물을 원하는 대로 요리할 수 있을 것입니다. 행복한 코딩 하세요!


0 개의 댓글:

Post a Comment