[Android] OkHttp가 동시 요청을 기본 5개로 걸어 잠그는 이유

Kame·3일 전

Android

목록 보기
15/15
post-thumbnail

들어가며

OkHttp의 Dispatcher가 동시성의 실제 한계를 결정합니다.

프로젝트를 진행하던 중 n(> 5)개의 링크를 실행함으로써 파일을 다운로드하는 코드를 작성하였습니다. async를 활용하여 동시다발적으로 여러 파일들을 다운로드 받도록 하였음에도, 실제 로그를 찍어보니 요청이 딱 5개씩만 진행되고 있었습니다.

val deferreds = links.map { link ->
    async {
        apiService.download(link) // suspend fun, main-safe
    }
}

deferreds.awaitAll()

처음 이 현상을 맞닥뜨렸을 때는, 코루틴들의 스케줄링 문제라 생각하곤 했습니다. 하지만 원인은 코루틴 쪽이 아니라, 전혀 예상치 못한 OkHttp 측에 있었습니다.

이 글은 그 원인을 실험으로 증명하고, 원인이 되는 OkHttp Dispatcher 소스 코드를 직접 분석합니다. 더 나아가 Retrofit/OkHttp 관련 컴포넌트들이 내부적으로 어떻게 동작하는지 알아보도록 하겠습니다.

선행 지식

  • 코루틴 suspend/resume: suspend 함수가 호출되면 현재 코루틴은 일시 중단되고 스레드를 반환합니다. 결과가 준비되면 continuation.resume()으로 깨어납니다.
  • Dispatchers.IO: I/O 작업에 최적화된 스레드 풀입니다. 64개와 CPU 코어 수 중 더 큰 값을 기본 병렬 처리 한도로 사용합니다(kotlinx.coroutines.io.parallelism 시스템 프로퍼티로 조정 가능). 대부분의 환경에서는 최대 64개라고 이해해도 무방합니다.
  • OkHttp: Retrofit 내부에서 실제 HTTP 통신을 담당하는 라이브러리입니다.
  • Retrofit suspend fun: Retrofit 2.6.0부터 suspend 키워드를 지원합니다. 내부적으로는 Call.enqueue()를 사용하며, suspendCancellableCoroutine을 통해 코루틴과 연결됩니다.

실험 환경

간단한 프로젝트를 실행해보며 직접 확인해보도록 하겠습니다.

okhttp-dispatcher-demo/
├── settings.gradle.kts          ← 루트: 서브모듈 선언
├── build.gradle.kts             ← 루트: 공통 플러그인 선언
├── server/
│   ├── build.gradle.kts         ← 서버 의존성
│   └── src/main/kotlin/
│       └── Server.kt
└── client/
    ├── build.gradle.kts         ← 클라이언트 의존성
    └── src/main/kotlin/
        ├── ApiService.kt
        └── Main.kt

서버 측

클라이언트로부터 요청이 들어오면 2초 후에 응답을 보내주도록 하였습니다.

fun main() {
    embeddedServer(Netty, port = 8080) {
        routing {
            get("/download/{id}") {
                val id = call.parameters["id"] ?: "unknown"
                delay(2000) // 동시 실행 개수 관찰을 위한 의도적인 지연
                call.respondText("id=$id")
            }
        }
    }.start(wait = true)
}

클라이언트 측

코루틴을 활용하여, 사용자가 원하는 n개의 네트워크 요청을 동시에 진행하도록 구현하였습니다.

fun main() = runBlocking {

    // ...

    val deferreds = (1..requestCount).map { id ->
        async {
            val result = service.download(id)  // 실제 요청 완료 후
            println("[${elapsed()}ms] #$id 요청 완료")
            result
        }
    }

    deferreds.awaitAll()
    println("전체 완료: ${elapsed()}ms")
}

실행 방법

  • IntelliJ로 해당 프로젝트를 엽니다.
  • 서버 측의 메인 함수를 실행합니다.
  • 이후 클라이언트 측의 메인 함수를 실행합니다. 아래 변수 값들을 조절하여 실험 환경을 변경할 수 있습니다.
// client의 main()
val maxRequestsPerHost: Int? = null // null이라면 okhttp의 기본 설정 사용
val requestCount = 20

상황

만약 네트워크를 통해 1000개의 파일을 병렬로 다운로드해야 한다면, 코루틴을 사용하여 아래와 같이 작성하고자 할 것입니다.

val deferreds = (1..1000).map { id ->
    async {
        apiService.download(id)
    }
}

val results = deferreds.awaitAll()

직관적으로 이 코드는 1000개의 async 코루틴이 각자 네트워크 요청을 던질 것으로 보입니다. 하지만 실제로 로그를 확인해 보면, 아래와 같이 예상과는 다른 동작을 보이게 됩니다.

maxRequestsPerHost=5, requestCount=20
[2281ms] #2 요청 완료
[2346ms] #5 요청 완료
[2346ms] #3 요청 완료
[2347ms] #1 요청 완료
[2347ms] #4 요청 완료
[4343ms] #9 요청 완료
[4343ms] #6 요청 완료
[4343ms] #7 요청 완료
[4343ms] #8 요청 완료
[4343ms] #10 요청 완료
[6398ms] #14 요청 완료
[6398ms] #15 요청 완료
[6401ms] #12 요청 완료
[6404ms] #11 요청 완료
[6407ms] #13 요청 완료
[8475ms] #18 요청 완료
[8476ms] #17 요청 완료
[8483ms] #16 요청 완료
[8515ms] #19 요청 완료
[8515ms] #20 요청 완료
전체 완료: 8522ms

왜 그런 것인가?

답은 OkHttp 측에서 여러 요청을 처리하는 메커니즘에 있습니다. 그 핵심은 바로 자체적으로 관리하는 Dispatcher에 있습니다.

코드 원본은 자바로 작성되어 있지만, 본문에서는 코틀린으로 번역하였습니다.

Dispatcher 클래스의 구조

OkHttp Dispatcher.kt 소스를 통해 핵심 필드들을 살펴보겠습니다.

// okhttp3/Dispatcher.kt
class Dispatcher {

    // 전체 동시 요청 최대 개수 (기본값 64)
    var maxRequests = 64

    // 단일 호스트에 대한 동시 요청 최대 개수 (기본값 5)
    // 우리가 관찰한 "5개" 제한이 여기서 나옵니다
    var maxRequestsPerHost = 5
        set(maxRequestsPerHost) {
            synchronized(this) { field = maxRequestsPerHost }
            promoteAndExecute() // 설정 변경 즉시 대기 큐를 재평가합니다
        }

    // 실행 대기 중인 비동기 요청들
    private val readyAsyncCalls = ArrayDeque<AsyncCall>()

    // 현재 실행 중인 비동기 요청들
    private val runningAsyncCalls = ArrayDeque<AsyncCall>()
}

여기서 핵심은 두 개의 큐입니다.

역할
readyAsyncCalls실행을 기다리는 대기열
runningAsyncCalls현재 실행 중인 요청들

async 1000개를 띄운다고 가정하면, 초반에는 처음 5개만 runningAsyncCalls로 이동하고 나머지 995개는 readyAsyncCalls에서 대기합니다.

maxRequestsmaxRequestsPerHost는 서로 다른 기준을 가집니다.

설정기본값기준
maxRequests64전체 동시 요청 합계
maxRequestsPerHost5단일 호스트 기준

여기서 호스트는 요청 URL의 hostname을 기준으로 합니다.

https://api.example.com/usershttps://api.example.com/posts처럼 엔드포인트가 달라도, hostname이 api.example.com으로 같다면 같은 5개 슬롯을 나눠 쓴다는 뜻입니다.

참고로 WebSocket 연결은 이 제한에 포함되지 않습니다. OkHttp는 WebSocket과 일반 HTTP 요청을 별도의 경로로 처리하기 때문입니다.

단일 서버에 1000개를 요청하는 시나리오라면, maxRequestsPerHost = 5가 적용되어 한 번에 5개까지만 요청되며, maxRequests = 64 설정과 관계없이 나머지 요청들은 대기 상태로 들어가게 됩니다.


promoteAndExecute()

대기 중인 요청을 실행 큐로 올리는 함수로, 이것이 maxRequests에 의한 제한을 구현하는 역할을 합니다.

// okhttp3/Dispatcher.kt
private fun promoteAndExecute(): Boolean {
    val executableCalls = mutableListOf<AsyncCall>()

    synchronized(this) {
        val i = readyAsyncCalls.iterator()
        while (i.hasNext()) {
            val asyncCall = i.next()

            // 조건 1: 전체 동시 실행이 maxRequests에 달하면 순회 자체를 멈춥니다
            if (runningAsyncCalls.size >= maxRequests) break

            // 조건 2: 이 호스트의 동시 실행이 maxRequestsPerHost에 달하면 이 요청만 건너뜁니다
            if (asyncCall.callsPerHost.get() >= maxRequestsPerHost) continue

            // 두 조건을 모두 통과한 요청만 실행 큐로 이동합니다
            i.remove()
            asyncCall.callsPerHost.incrementAndGet()
            executableCalls.add(asyncCall)
            runningAsyncCalls.add(asyncCall)
        }
    }

    // 실행 큐로 이동된 요청들을 스레드 풀에 제출합니다
    for (asyncCall in executableCalls) {
        asyncCall.executeOn(executorService)
    }

    return runningAsyncCalls.isNotEmpty()
}
  • maxRequests 초과: break가 호출되며, 전체 한도에 달하면 순회 자체를 중단합니다. 더 이상 어떤 요청도 꺼내지 않습니다.
  • maxRequestsPerHost 초과: continue가 호출되며, 해당 호스트 한도에 달하면 이 요청만 건너뛰고 다음으로 넘어갑니다. 단일 호스트의 요청이 막혀 있어도 다른 호스트의 요청은 계속 꺼낼 수 있습니다. 예를 들어 api1.example.com의 슬롯이 가득 차도 api2.example.com의 요청은 영향을 받지 않습니다.

여기서 한 가지 짚어볼 부분이 asyncCall.callsPerHost입니다. 이름 때문에 각 AsyncCall마다 자기만의 카운터를 갖고 있다고 오해하기 쉽지만, 실제로는 같은 호스트로 향하는 모든 AsyncCall이 동일한 카운터(AtomicInteger)를 참조로 공유합니다. 그래서 1000개의 요청이 제각각 다른 코루틴, 다른 스레드에서 enqueue()되더라도, Dispatcher는 이 공유 카운터 하나만 보고 "지금 이 호스트로 몇 개가 나가 있는지"를 정확히 셀 수 있습니다.


finished()

특정 요청의 완료와 동시에 대기 중이던 요청이 즉시 시작될 수 있도록 합니다.

// okhttp3/Dispatcher.kt
internal fun finished(call: AsyncCall) {
    call.callsPerHost.decrementAndGet()  // 호스트 카운터 감소
    finished(runningAsyncCalls, call)
}

private fun <T> finished(calls: Deque<T>, call: T) {
    synchronized(this) {
        if (!calls.remove(call)) throw AssertionError("Call wasn't in-flight!")
    }
    promoteAndExecute()  // 완료될 때마다 대기 큐를 즉시 재평가합니다
}

요청 하나가 끝나면 다음 순서로 진행됩니다.

  1. callsPerHost 카운터를 1 감소시킵니다.
  2. runningAsyncCalls에서 해당 요청을 제거합니다.
  3. 즉시 promoteAndExecute()를 호출합니다.

promoteAndExecute()가 다시 호출되는 시점에는 callsPerHost가 이미 감소해 있으므로, 대기 중이던 다음 요청이 조건을 통과해 실행 큐로 올라옵니다. 완료 → 카운터 감소 → 재평가 → 다음 요청 시작이 하나의 흐름으로 이어집니다. 이 덕분에 슬롯이 빈 즉시 다음 요청이 채워지는 모습을 확인할 수 있습니다.

전체 흐름을 정리하면 이렇습니다.


오해: maxRequestsPerHost를 올리면 무조건 빨라진다?

실제 동시 요청 수를 조절하고 싶다면, 코루틴 쪽이 아니라 OkHttp 빌더 측의 설정을 변경해야 합니다.

// ✅ OkHttp Dispatcher에서 직접 제어
val dispatcher = Dispatcher().apply {
    maxRequestsPerHost = 10
    maxRequests = 64
}

val okHttpClient = OkHttpClient.Builder()
    .dispatcher(dispatcher)
    .build()

수치를 올리면 더 많은 요청이 동시에 나가는 건 맞지만, 무조건 빨라지지는 않습니다.

사실 기본값 5는 서버와 클라이언트 모두를 고려한 현실적인 상한선입니다. 예를 들어 서버가 rate limit을 걸고 있다면 429 응답이 쏟아질 것이고, 이에 대해 재시도 로직이 없으면 요청은 실패로 끝날 것입니다. 또한 과도한 네트워크 요청은 모바일 기기의 안테나/칩셋 레이어에서 패킷 손실로 이어질 수도 있습니다.

왜 기본값이 5인가

maxRequestsPerHost = 5는 임의의 숫자라고 볼 수는 없습니다. 실제로 OkHttp 개발자 측은 GitHub 이슈에서 이 기본값에 대해 다음과 같이 짧게 답했습니다.

"Historical decision plus inertia."

공식 이슈에서 메인테이너는 이 기본값이 역사적 결정과 관성의 산물이라 답했습니다. 명시적인 설계 문서를 제시하진 않았지만, 아래 관점에서 그 이유를 설명할 수 있습니다.

TCP slow start

HTTP/1.1에서 각 연결은 독립적인 TCP 소켓입니다. TCP는 새 연결을 열 때마다 slow start 알고리즘을 실행합니다. 초기에는 혼잡 윈도우(congestion window, cwnd)가 매우 작게 시작되고, ACK를 받을 때마다 지수적으로 커집니다. 즉 TCP는 언제나 작은 윈도우에서 출발하며, 실제 처리량에 도달하기까지 여러 RTT가 필요합니다.

여기서 연결 수를 무한정 늘리면 각 연결이 개별적으로 slow start를 시작하며 대역폭을 경쟁적으로 탐색합니다. 연결이 많아질수록 각 연결이 받는 실질 대역폭 몫이 줄어들고, 전체 처리량이 개선되기는커녕 오히려 혼잡만 유발할 수 있습니다. RFC 6928은 웹 브라우저가 도메인당 최대 6개 연결을 사용하는 관행이 HTTP의 직렬 다운로드 문제를 극복하고 병렬성을 얻기 위한 것이지만, 동시에 접속 링크가 심각하게 과부하될 수 있다는 점을 지적합니다.

스레드 경합

스레드 관점에서도 마찬가지입니다. 만약 OkHttp에 호스트당 제한(maxRequestsPerHost)이 없다면, 애플리케이션이 요청을 던지는 대로 executorService가 스레드를 폭발적으로 생성했을 것입니다. 수백 개의 스레드가 동시에 자원을 선점하려고 하면 OS 스케줄러는 극심한 컨텍스트 스위칭 비용을 지불하게 됩니다. 따라서 OkHttp는 기본적으로 이 값을 5로 통제함으로써, 활성화된 소수 스레드의 CPU 집중도를 높여 전체 처리량을 안정적으로 유지하고자 하는 것입니다.

브라우저 관행과 일치

HTTP/1.1 환경에서 주요 브라우저들은 통상적으로 오리진(hostname + port)당 6개 연결을 사용합니다. 이는 HTTP/1.1이 TCP를 사용하는 방식의 문제, 즉 각 연결이 상당 시간 대기 상태에 놓인다는 점을 극복하기 위한 것입니다. 이미 폐기된 RFC 2616(HTTP/1.1)의 8.1.4절은 단일 사용자 클라이언트가 서버 또는 프록시와 2개를 초과하는 연결을 유지하지 말 것을 권고했는데, 브라우저들은 이 기준을 실용성의 이유로 초과해 왔으며 현대 브라우저는 도메인당 4~6개 연결을 사용합니다. OkHttp의 기본값 5는 이 관행과 같은 범위에 있습니다.

소켓과 OS 자원의 현실적 한계

각 TCP 소켓은 커널 내부에 송신 큐와 수신 큐를 유지하며, 커널은 큐 하나당 수십 KB~100KB 수준의 메모리를 할당합니다. 하나의 소켓이 사용하는 커널 메모리는 이 두 큐의 합으로 추산됩니다. 안드로이드에서는 프로세스 하나가 열 수 있는 파일 디스크립터(fd)의 수가 최대 1024개로 제한되며, 일부 기기에서는 512개까지 낮아집니다. 네트워크 소켓을 포함한 모든 I/O 연결은 fd를 하나씩 소비합니다.

소켓을 수백 개 동시에 열면 커널 메모리 압박과 fd 소진 위험이 현실이 됩니다. 5개라는 기본값은 이 자원 한계를 자연스럽게 회피하는 보수적인 상한선이기도 합니다.

결국 maxRequestsPerHost = 5는 단순히 우연에 의해 도출된 것이 아님을 추측해볼 수 있습니다. 따라서 이 값을 올리기 전에 서버의 rate limit 정책과 기기 자원 한계를 함께 확인하는 등 신중한 판단이 필요할 것입니다.


대안

maxRequestsPerHost를 직접 올리는 것 외에도, OkHttp Dispatcher의 동작 특성을 활용하거나 프로토콜 수준에서 문제를 우회하는 방법이 있습니다.

도메인 분할

서버가 사내 전용 대용량 파일 서버이고 시스템 자원이 충분하다면?

OkHttp 설정을 바꾸는 것도 방법이지만, 서버 인프라를 활용한 도메인 분할 기법을 적용할 수 있습니다. OkHttp의 제한 기준은 hostname입니다.

  • https://cdn.example.com/file1
  • https://cdn.example.com/file2
    : 동시 5개 슬롯 공유

만약 DNS 설정이나 CDN을 통해 파일 서버의 도메인을 다중화(Multi-origin)할 수 있다면 다음과 같은 구성이 가능할 것입니다.

  • https://cdn1.example.com/file1
  • https://cdn2.example.com/file2
  • https://cdn3.example.com/file3
    : 각각 독립적인 5개 슬롯 획득 (총 15개 동시 다운로드 가능)

이렇게 호스트별 격리 특성을 이용하여 다운로드 대역폭을 몇 배로 확장할 수 있습니다.

다만 이 방법도 만능은 아닙니다. 도메인을 늘릴수록 TCP 소켓 수도 배수로 증가하기 때문에, 앞서 언급한 fd 소진과 커널 메모리 압박 문제가 더 빠르게 찾아올 수 있습니다. 또한 서버 인프라 변경이 수반되므로, 실제 적용 전에 서버 팀과의 협의와 부하 테스트가 필요합니다.

HTTP/2 멀티플렉싱

현대 네트워크에서 호스트당 5개 제한을 가장 근본적으로 해소하는 방법은 서버와 클라이언트를 HTTP/2로 연결하는 것입니다. HTTP/2는 하나의 TCP 소켓 안에 수백 개의 스트림을 열어 데이터를 동시에 실어 나르는 멀티플렉싱을 지원합니다. 이 경우 OkHttp는 하나의 커넥션으로 수많은 요청을 처리할 수 있어, maxRequestsPerHost 제한이 사실상 무의미해집니다.

멀티플렉싱이 깨지는 경우

OkHttp는 HTTP/2로 연결되더라도, 다음과 같은 경우 멀티플렉싱을 해제하고 HTTP/1.1처럼 독립적인 소켓을 새로 열어버립니다.

  1. 요청마다 다른 커스텀 소켓 팩토리 사용: 보안 정책상의 이유로 소켓 생성 로직을 건드리면 커넥션 재사용이 불가능해집니다.
  2. Connection: close 헤더 주입: 응답이나 요청 헤더에 이 값이 들어가면 OkHttp는 통신이 끝난 후 소켓을 즉시 폐기합니다.
  3. 서버 측의 동시 스트림 제한 (SETTINGS_MAX_CONCURRENT_STREAMS): HTTP/2 프로토콜 레벨에서 서버가 소켓당 동시 스트림 수를 제한하면, OkHttp는 그 한도를 초과하는 요청부터 어쩔 수 없이 새로운 TCP 소켓을 열게 되므로 레이턴시가 급증할 수 있습니다.

고려 사항

  • 대량 다운로드를 수행하는 인프라를 구축할 때는 서버 아키텍처 팀과 협의하여 MAX_CONCURRENT_STREAMS 값을 충분히 확보(예: 100 이상)하는 것이 좋습니다.
  • 클라이언트 단에서는 무의미하게 소켓 설정을 변형하거나 Connection 헤더를 조작하지 않도록 인터셉터 체인을 점검해야 합니다.

마치며

  • 코루틴 개수는 OkHttp 측의 동시 요청 메커니즘에 아무런 영향을 미치지 않습니다. 아무리 많은 수의 코루틴을 실행시킨다고 하더라도, OkHttp Dispatcher는 자신의 기준으로 동시 요청 수를 독립적으로 제어합니다.
    • Dispatcher.maxRequestsPerHost: 기본값은 5이며, hostname이 같으면 엔드포인트가 달라도 같은 슬롯을 공유합니다.
  • 동시 요청 수를 조정하고자 할 때는, OkHttp 레이어에서 maxRequestsPerHost 값을 조정해야 합니다.
    • 이 과정에서 서버의 rate limit 정책과 기기 자원 한계를 함께 고려해야 합니다.
  • maxRequestsPerHost를 올리는 것 외에도, 도메인 분할이나 HTTP/2 멀티플렉싱을 통해 다운로드 병렬성을 높일 수 있습니다. 다만 각각 서버 인프라 변경과 프로토콜 지원이 전제되므로, 상황에 맞는 방법을 선택해야 합니다.

참고 자료

profile
Software Engineer

0개의 댓글