동시 10K 사용자에게 LLM 토큰 스트리밍

발행: (2026년 5월 11일 PM 04:15 GMT+9)
9 분 소요
원문: Dev.to

Source: Dev.to

위에 제공된 링크에 있는 전체 텍스트를 번역하려면, 번역하고자 하는 실제 내용(본문)을 알려주시면 한국어로 번역해 드리겠습니다.
본문을 복사해서 여기에 붙여 주시면 원본 형식과 마크다운을 유지한 채로 번역해 드릴 수 있습니다.

우리가 만들고 있는 것

10,000개의 동시 SSE 연결을 유지하면서 LLM 토큰을 스트리밍하는 아키텍처를 보여드리겠습니다 — 서버가 과부하되지 않도록. 우리는 코루틴‑당 연결 팬‑아웃, 백프레셔를 위한 제한된 채널 버퍼, 무중단 배포를 위한 연결 드레인, 그리고 4 GB 컨테이너에서 실제 한계를 결정하는 연결‑당 메모리 계산을 살펴볼 것입니다.

사전 요구 사항

  • Kotlin 코루틴 및 Channel 기본
  • Server‑Sent Events (SSE)에 대한 친숙함
  • Ktor 또는 Netty 기반 HTTP 서버
  • Kubernetes pod 수명 주기에 대한 이해 (있으면 도움이 되며, 필수는 아님)

Step 1: Understand the Problem

LLM API는 20–80 ms마다 토큰을 내보냅니다. 이러한 토큰을 SSE를 통해 수천 명의 사용자에게 프록시하면, 각 연결은 열린 HTTP 응답을 유지하는 장기 코루틴이 됩니다. 충분히 빠르게 소비하지 못하는 느린 클라이언트 하나가 버퍼를 부풀리고, 백프레셔가 없으면 GC 일시정지 한 번으로 OOM 킬이 발생합니다.

단순한 접근 방식 — 무제한 리스트, 배출 전략 없음, fire‑and‑forget 쓰기 — 은 약 2,000개의 연결에서 붕괴됩니다. 아래는 대규모로 작동하도록 하는 최소 설정입니다.

단계 2: 팬‑아웃을 위한 제한된 채널 연결

핵심 패턴은 SSE 연결당 하나의 제한된 Channel이며, LLM 스트림을 소비하는 공유 업스트림 코루틴에 의해 공급됩니다:

fun fanOut(clients: List<Channel<String>>, token: String) {
    clients.forEach { channel ->
        // Non‑blocking send; drops the token for a slow client
        channel.trySend(token).isSuccess
    }
}

각 클라이언트는 자체 제한된 채널을 갖습니다 (저는 32–128 슬롯을 권장합니다). 느린 클라이언트가 버퍼를 가득 채우면 trySend가 즉시 실패합니다. 업스트림을 차단하지 않으며, 연쇄적인 정체도 발생하지 않습니다.

접근 방식부하 시 메모리느린 클라이언트 영향실패 모드
클라이언트당 무제한 리스트제한 없이 증가힙 고갈OOM 킬, 모든 클라이언트 종료
단일 공유 채널제한됨가장 느린 클라이언트가 모두 차단라인 앞부분 차단
클라이언트당 제한된 채널예측 가능한 상한해당 클라이언트만 영향받음우아한 연결 해제

Step 3: Run the Memory Math

Here is the gotcha that will save you hours. This arithmetic determines your actual concurrency ceiling:

구성 요소연결당 비용10K 연결 시
코루틴 스택~1–2 KB10–20 MB
제한된 채널 (64 슬롯 × 40 B)~2.5 KB25 MB
Ktor/Netty 응답 버퍼~8 KB80 MB
연결 메타데이터 + 헤더~1 KB10 MB
연결당 총합~13 KB~130 MB

4 GB 컨테이너에서 JVM 오버헤드, 메타스페이스, GC 여유 공간을 제외하고 약 2.5 GB의 힙을 사용할 수 있을 때, 압력이 올라가기 전 대략 12,000개의 연결을 처리할 수 있습니다. 실제로는 버스트 트래픽과 GC 여유를 위해 8,000–10,000개 정도를 목표로 잡으세요. 더 많은 연결이 필요하면 수평 확장을 고려하십시오. 버퍼 크기를 늘리지 마세요.

Step 4: 연결 드레이닝 구현

롤링 배포 중에는 10,000개의 열린 SSE 연결을 그냥 끊을 수 없습니다. 신뢰할 수 있는 패턴:

  1. 새로운 연결을 받지 않도록 차단합니다. 포드를 로드 밸런서에서 제거합니다.
  2. 커스텀 SSE 이벤트(event: reconnect)를 보내 클라이언트에게 정상적인 포드에 다시 연결하도록 알립니다.
  3. 드레인 마감시간을 설정합니다(예: 30 초) 그리고 마감시간이 지나면 남아 있는 연결을 강제로 종료합니다.
  4. 구조화된 동시성을 사용해 coroutineScope가 모든 자식 코루틴이 정상적으로 완료되거나 취소되도록 보장합니다.
suspend fun handleSse(call: ApplicationCall) = coroutineScope {
    val channel = Channel<String>(capacity = 64)
    // Launch a producer that reads from the LLM stream
    launch {
        llmStream.collect { token -> fanOut(listOf(channel), token) }
    }
    // Consumer that writes to the HTTP response
    launch {
        for (msg in channel) {
            call.respondText(msg, ContentType.Text.EventStream)
        }
    }
}

이 작업을 하지 않으면 Kubernetes가 포드에 SIGTERM을 보내고, TCP 연결이 재설정되며, 사용자는 재시도 힌트 없이 끊어진 스트림을 보게 됩니다.

Gotchas

  • 무제한 큐는 조용한 살인자입니다. 한 명의 정체된 클라이언트가 약 40 바이트씩 50,000개의 토큰을 축적하면 약 2 MB를 차지합니다. 이를 수백 명의 느린 모바일 클라이언트에 곱하면 전체 힙을 모두 사용하게 됩니다.
  • 느린 클라이언트를 끊는 것이 공격적으로 보일 수 있지만, 대안은 모두를 끊어버리는 OOM입니다. 수천 명을 구하기 위해 하나를 끊는 것이 올바른 트레이드‑오프입니다.
  • 구조화된 동시성은 협상 불가입니다. 모든 SSE 연결은 요청 라이프사이클에 연결된 coroutineScope 내부에서 실행되어야 합니다. 클라이언트가 연결을 끊으면 코루틴이 취소됩니다. 서버가 드레인될 때 모든 자식 코루틴이 협력적으로 취소됩니다. 코루틴 누수도, 좀비 연결도 없습니다.
  • 사고 후 Retrofit을 드레인하는 것은 고통스럽습니다. 첫날부터 구현하세요. 부하가 걸린 상태에서 첫 핫픽스를 적용할 때 스스로에게 감사하게 될 것입니다.

Wrapping Up

예산은 SSE 연결당 약 13–15 KB입니다. 클라이언트당 (32–128 슬롯) 크기의 bounded channelstrySend와 함께 사용하여 non‑blocking fan‑out을 구현하세요. 첫날부터 reconnect eventhard deadline을 사용해 connection draining을 구현합니다. 4 GB 컨테이너에서는 최대 8K–10K 연결을 계획하고, 이후에는 scale horizontally합니다.

문서에는 언급되지 않지만, 아키텍처는 복잡하지 않습니다 — 체계적이기 때문입니다. Bounded buffers, predictable memory, cooperative cancellation. 이것이 서버가 10K 동시 스트림을 유지할 수 있게 하는 핵심입니다.

0 조회
Back to Blog

관련 글

더 보기 »

시스템 설계 트레이드오프

스케일링 - 수직 스케일링 vs 수평 스케일링 - 확장성 vs 성능 일관성 및 가용성 - 일관성 vs 가용성 CAP - 강한 일관성 vs 최종 일관성