ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Retrofit2 @Streaming 제대로 알기(내부 원리부터 파일 다운로드까지)-고슴도치 군단
    카테고리 없음 2026. 4. 28. 09:31

    @Streaming 어노테이션 개요

    Retrofit2에서 HTTP 응답을 처리하는 기본 방식은 응답 전체를 메모리에 버퍼링하는 것임.

    작은 JSON 응답이라면 문제없지만, 대용량 파일이나 동영상을 다운로드할 때는 메모리 부족이나 OOM(Out Of Memory) 오류가 발생할 수 있음.

    @Streaming 어노테이션은 이 버퍼링 동작을 비활성화하고, 서버 소켓에서 직접 데이터를 스트리밍 방식으로 읽을 수 있게 해주는 핵심 기능임.



    기본 사용법

    @Streaming은 인터페이스 메서드 선언 위에 붙이며, 반환 타입은 반드시 ResponseBody여야 의미가 있음.

    Converter가 붙어있는 타입(예: Call<User>)을 반환하면 Converter 내부에서 어차피 전체 본문을 읽기 때문에 스트리밍 효과가 없음.

    아래 예시처럼 선언하면 응답 바디를 소켓 스트림 그대로 전달받을 수 있음.

    interface FileDownloadService {
    
        @Streaming
        @GET("files/{filename}")
        fun downloadFile(@Path("filename") String filename): Call<ResponseBody>
    
        // Kotlin suspend 버전
        @Streaming
        @GET("files/{filename}")
        suspend fun downloadFileSuspend(@Path("filename") filename: String): Response<ResponseBody>
    }


    @Streaming 없을 때 vs 있을 때 비교

    두 모드의 차이는 단순히 편의성이 아니라 메모리 사용량과 커넥션 관리 방식 자체가 달라짐.

    특히 100MB 이상의 파일 다운로드에서 @Streaming 없이 처리하면 앱이 강제 종료되는 경우가 빈번함.

    아래 표는 두 모드를 명확하게 비교한 것임.

    항목 @Streaming 없음 (기본) @Streaming 있음
    응답 처리 방식 전체를 메모리에 버퍼링 소켓 스트림 직접 노출
    메모리 사용 응답 크기 전체 청크 버퍼(~8KB) 수준
    적합한 상황 소형 JSON, 텍스트 대용량 파일, 동영상, 이미지
    소켓 반환 시점 수신 완료 후 자동 close() 호출 시
    OOM 위험 대용량에서 높음 없음


    내부 동작 원리

    Retrofit2는 OkHttp 위에서 동작하며, 실제 스트리밍 처리는 OkHttpCall.parseResponse() 내부에서 결정됨.

    @Streaming이 없으면 Utils.buffer(rawBody)를 호출해 전체 바디를 byte[]로 메모리에 올린 뒤 소켓을 즉시 반환함.

    @Streaming이 있으면 이 버퍼링 단계를 건너뛰고, OkHttp의 BufferedSource(Okio 라이브러리)가 소켓과 연결된 상태 그대로 ResponseBody를 호출자에게 전달함.

    [클라이언트 요청]
            │
            ▼
      OkHttp TCP 소켓 연결 → 서버 응답 수신
            │
            ├─ @Streaming 없음 ─────────────────────────────────┐
            │     Utils.buffer(rawBody)                         │
            │     → 전체 body를 byte[]로 메모리 로드              │
            │     → BufferedSource 즉시 닫힘 (소켓 반환)          │
            │     → Converter가 byte[]를 변환                    │
            │                                                   │
            └─ @Streaming 있음 ─────────────────────────────────┘
                  버퍼링 없음
                  → BufferedSource(소켓 연결 유지) 그대로 노출
                  → 호출자가 InputStream.read()로 청크 단위 읽기
                  → body.close() 호출 시 소켓 반환

    내부적으로 @Streaming 어노테이션은 ServiceMethod 파싱 단계에서 isStreaming 플래그로 저장되고, OkHttpCall이 이 플래그를 확인해 버퍼링 여부를 결정하는 구조임.



    실제 파일 다운로드 구현

    스트리밍 응답을 받아 파일로 저장하는 것이 가장 일반적인 활용 패턴임.

    핵심은 byteStream()으로 InputStream을 얻고, 고정 크기 버퍼(통상 8KB)로 반복해서 읽으며 파일에 쓰는 것임.

    use 블록을 활용하면 body.close()가 자동으로 호출되어 소켓 누수를 방지할 수 있음.

    // CoroutineScope 내에서 실행 (IO Dispatcher)
    suspend fun downloadAndSave(filename: String, destFile: File) {
        val response = service.downloadFileSuspend(filename)
    
        if (!response.isSuccessful) {
            throw IOException("다운로드 실패: ${response.code()}")
        }
    
        response.body()?.use { body ->
            val inputStream = body.byteStream()
            val totalBytes = body.contentLength() // -1이면 알 수 없음
    
            FileOutputStream(destFile).use { output ->
                val buffer = ByteArray(8192)
                var bytesRead: Int
                var downloadedBytes = 0L
    
                while (inputStream.read(buffer).also { bytesRead = it } != -1) {
                    output.write(buffer, 0, bytesRead)
                    downloadedBytes += bytesRead
    
                    // 진행률 계산 (totalBytes가 -1이면 불가)
                    if (totalBytes > 0) {
                        val progress = (downloadedBytes * 100 / totalBytes).toInt()
                        // LiveData나 Flow로 UI 업데이트
                    }
                }
                output.flush()
            }
        } ?: throw IOException("응답 body가 null임")
    }


    OkHttp 커넥션 풀과 close() 관리

    @Streaming 사용 시 가장 중요한 주의사항은 ResponseBody.close() 호출임.

    닫지 않으면 OkHttp 커넥션 풀이 해당 커넥션을 회수하지 못해 누수가 발생하고, 일정 시간 후 새로운 요청이 커넥션을 할당받지 못하는 상황이 생길 수 있음.

    아래 타임라인은 커넥션 생명주기를 보여줌.

    요청 시작
      → 커넥션 풀에서 소켓 할당
      → 응답 헤더 수신
      → body 스트림 오픈 (소켓 유지 중)
      → read() 반복 호출
      → body.close() 호출
      → 소켓 커넥션 풀로 반환 ← 반드시 여기까지 도달해야 함

    예외 상황에서도 반드시 close()가 호출되도록 try-finally 또는 Kotlin use 블록을 사용하는 것이 원칙임.

    // try-finally 방식 (Java 스타일)
    val body = response.body()
    try {
        body?.byteStream()?.use { stream ->
            // 처리
        }
    } finally {
        body?.close()
    }


    주요 주의사항 정리

    @Streaming 사용 시 흔히 겪는 실수들을 정리한 것임.

    메인 스레드에서 byteStream().read()를 호출하면 NetworkOnMainThreadException이 발생하므로 반드시 백그라운드에서 처리해야 함.

    아래 체크리스트를 참고해 안전하게 구현할 수 있음.

    • 반환 타입: Call<ResponseBody> 또는 suspend fun: Response<ResponseBody> 사용
    • Converter 주의: Gson, Moshi 등 Converter와 함께 쓰면 스트리밍 효과 없음
    • 백그라운드 처리: Dispatchers.IO 또는 별도 스레드에서 실행
    • close() 필수: use 블록 또는 try-finally로 반드시 자원 해제
    • 진행률 표시: body.contentLength()-1이면 Content-Length 헤더 없음을 의미함
    • 대용량 전용: 소형 JSON이나 텍스트에는 굳이 사용할 필요 없음


    마무리

    @Streaming은 단순한 편의 기능이 아니라 OkHttp의 소켓 스트림 구조를 직접 활용하는 저수준 기능임.

    내부적으로는 Retrofit의 OkHttpCall이 버퍼링 단계를 건너뛰고, Okio의 BufferedSource를 그대로 노출하는 방식으로 동작함.

    대용량 파일 다운로드나 미디어 스트리밍이 필요한 Android 앱 개발 시 @Streaming과 커넥션 관리 원리를 정확히 이해하고 있으면 OOM과 커넥션 누수 없이 안정적인 구현이 가능함.

Designed by Tistory.