동시 10K 사용자에게 LLM 토큰 스트리밍
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 KB | 10–20 MB |
| 제한된 채널 (64 슬롯 × 40 B) | ~2.5 KB | 25 MB |
| Ktor/Netty 응답 버퍼 | ~8 KB | 80 MB |
| 연결 메타데이터 + 헤더 | ~1 KB | 10 MB |
| 연결당 총합 | ~13 KB | ~130 MB |
4 GB 컨테이너에서 JVM 오버헤드, 메타스페이스, GC 여유 공간을 제외하고 약 2.5 GB의 힙을 사용할 수 있을 때, 압력이 올라가기 전 대략 12,000개의 연결을 처리할 수 있습니다. 실제로는 버스트 트래픽과 GC 여유를 위해 8,000–10,000개 정도를 목표로 잡으세요. 더 많은 연결이 필요하면 수평 확장을 고려하십시오. 버퍼 크기를 늘리지 마세요.
Step 4: 연결 드레이닝 구현
롤링 배포 중에는 10,000개의 열린 SSE 연결을 그냥 끊을 수 없습니다. 신뢰할 수 있는 패턴:
- 새로운 연결을 받지 않도록 차단합니다. 포드를 로드 밸런서에서 제거합니다.
- 커스텀 SSE 이벤트(
event: reconnect)를 보내 클라이언트에게 정상적인 포드에 다시 연결하도록 알립니다. - 드레인 마감시간을 설정합니다(예: 30 초) 그리고 마감시간이 지나면 남아 있는 연결을 강제로 종료합니다.
- 구조화된 동시성을 사용해
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 channels를 trySend와 함께 사용하여 non‑blocking fan‑out을 구현하세요. 첫날부터 reconnect event와 hard deadline을 사용해 connection draining을 구현합니다. 4 GB 컨테이너에서는 최대 8K–10K 연결을 계획하고, 이후에는 scale horizontally합니다.
문서에는 언급되지 않지만, 아키텍처는 복잡하지 않습니다 — 체계적이기 때문입니다. Bounded buffers, predictable memory, cooperative cancellation. 이것이 서버가 10K 동시 스트림을 유지할 수 있게 하는 핵심입니다.