LiteLLM(Python) 대신 Go로 SSE 중계를 고민할 때 먼저 봐야 할 차이
2026-07-29
LLM 스트리밍 서버가 버거워지는 이유는 연결 수보다 연결을 오래 붙잡는 방식에 있습니다
LLM API를 프록시처럼 중계할 때 가장 먼저 부딪히는 질문은 이것입니다. “응답 생성은 느린데, 왜 서버 자원이 이렇게 빨리 차지?”
핵심 원인은 SSE(Server-Sent Events) 자체에 있습니다. SSE는 응답을 한 번에 보내지 않고, 토큰이 나올 때마다 같은 HTTP 연결을 계속 유지한 채 조금씩 흘려보냅니다.
채팅창에서 답변이 한 글자씩 타이핑되듯 보이는 경험이 바로 이 방식입니다.
Client ──── HTTP 연결 유지 ────► Server ◄── data: {"token": "안"} ◄── data: {"token": "녕"} ◄── data: {"token": "하"} ...
이 구조에서는 **요청 수 자체보다 “동시에 몇 개의 연결을 오래 들고 있을 수 있느냐”**가 더 중요합니다.
LiteLLM처럼 Python 기반 프록시는 이 지점에서 concurrency overhead가 커질 수 있고, Go는 이런 장기 연결·소량 전송 패턴에 더 유리합니다.
Python에서 LiteLLM이 불리해지는 지점은 한 번에 많은 SSE 연결을 다루는 방식입니다
Python에는 GIL(Global Interpreter Lock)이 있습니다. 쉽게 말해, Python 인터프리터는 한 시점에 하나의 스레드만 실행하도록 강하게 제약합니다. 멀티코어 CPU가 있어도 Python 코드가 자연스럽게 여러 코어에서 병렬로 쭉 퍼지는 구조는 아닙니다.
이 한계를 우회하려고 Python은 asyncio를 많이 씁니다. LiteLLM도 이런 비동기 방식으로 I/O 대기 시간을 활용할 수 있습니다. 네트워크를 기다리는 동안 다른 작업을 처리하는 점은 분명 장점입니다.
문제는 연결 수가 커질수록 이벤트 루프가 감당해야 할 관리 비용도 함께 커진다는 점입니다.
| 오버헤드 종류 | 실무에서 체감되는 문제 |
|---|---|
| Task 스케줄링 비용 | 수많은 asyncio.Task를 이벤트 루프가 계속 관리하면서 CPU 사용이 늘 수 있음 |
| 메모리 비용 | 연결마다 Python 객체, 코루틴 상태 등이 유지되어 연결 수가 많아질수록 부담이 커짐 |
| Context switching | GIL 획득/반납, 코루틴 전환 비용이 누적될 수 있음 |
| 스트리밍 청크 처리 | SSE 청크를 Python 레벨에서 계속 파싱·가공하면 토큰마다 비용이 붙음 |
예를 들어, LiteLLM이 단순 호출기가 아니라 “클라이언트와 LLM 사이에서 스트리밍을 받아 다시 흘려보내는 중계 서버” 역할을 하면 상황이 달라집니다. 요청 하나를 짧게 처리하고 끝내는 API보다, 연결을 오래 붙잡고 자주 깨우는 작업이 훨씬 많아지기 때문입니다.
Go가 유리한 이유는 문법보다 런타임 구조에 있습니다
Go의 강점은 “빠르다”는 막연한 인상이 아니라, 동시 연결을 많이 다루는 방식이 애초에 다르다는 데 있습니다. Go는 goroutine이라는 경량 실행 단위를 쓰고, 런타임이 이를 자체 스케줄러로 관리합니다.
비교의 핵심은 이렇습니다.
- Python 쪽은 많은 비동기 작업을 이벤트 루프가 집중 관리
- Go 쪽은 많은 goroutine을 런타임이 여러 OS 스레드 위에 분산 실행
- Go에는 GIL이 없어서 멀티코어 활용이 더 직접적임
제공된 정보 기준으로 보면, goroutine은 시작 시 메모리 사용량이 매우 작고, Python 스레드보다 훨씬 가볍습니다. 그래서 **“연결 하나당 유지 비용”**이 중요한 SSE에서 특히 강점을 보입니다.
OS Thread 1: [G1]→[G5]→[G9]... OS Thread 2: [G2]→[G6]→[G10]... OS Thread 3: [G3]→[G7]→[G11]... OS Thread 4: [G4]→[G8]→[G12]...
이 그림이 의미하는 바는 단순합니다.
각 SSE 연결을 goroutine 하나가 맡아도 부담이 비교적 작고, 여러 코어에 자연스럽게 분산되기 쉽다는 것입니다.
SSE는 “작은 데이터를 자주 보내면서 연결은 오래 유지”하는 패턴입니다. 이 패턴에서는 요청 처리 속도보다 연결 유지 비용과 스케줄링 비용이 더 중요해집니다.
“LiteLLM을 계속 써도 되나, Go로 옮겨야 하나”는 이 기준으로 보면 됩니다
둘 중 무엇이 더 낫다고 단정하기보다, 어떤 병목을 해결하려는지가 먼저입니다.
Python/LiteLLM 쪽이 맞는 경우
- 지금 병목이 SSE 동시 연결 수가 아니라 기능 개발 속도인 경우
- 프록시가 복잡한 중계기보다 비교적 단순한 API 래퍼에 가까운 경우
- 실제로 CPU·메모리 사용량이 아직 문제로 확인되지 않은 경우
이 경우에는 굳이 언어를 바꾸기보다, 현재 구조에서 어디서 비용이 커지는지 먼저 측정하는 편이 낫습니다.
Go 재구현을 진지하게 볼 때
- 서버가 대규모 SSE 중계기 역할을 하는 경우
- 클라이언트 수가 많아질수록 이벤트 루프 부담과 메모리 사용이 빠르게 커지는 경우
- 같은 하드웨어에서 더 많은 연결을 더 낮은 비용으로 유지해야 하는 경우
특히 LiteLLM이 “LLM API 호출 도구”를 넘어 수많은 스트리밍 연결을 동시에 붙잡는 프록시 계층이 되면, Python의 비동기 모델이 감당해야 할 관리 비용이 눈에 띄게 커질 가능성이 있습니다. 반대로 Go는 이 문제를 구조적으로 더 잘 받아내는 쪽에 가깝습니다.
실무에서 기억할 판단 기준은 토큰 생성 속도보다 연결 유지 비용입니다
이 비교에서 중요한 건 모델이 몇 토큰을 만들었는지가 아닙니다. 프록시 서버 입장에서는 각 연결을 얼마나 싸게 오래 유지할 수 있는가가 더 중요합니다.
정리하면 이렇습니다.
LiteLLM(Python)은
asyncio로 SSE를 처리할 수 있지만, 연결 수가 커질수록 스케줄링·메모리·전환 비용이 함께 커질 수 있습니다.
Go는 goroutine과 멀티코어 스케줄링 덕분에, 대규모 SSE 중계 서버에서 더 높은 처리량과 자원 효율을 기대할 수 있습니다.
그래서 선택 기준은 단순합니다.
“우리는 Python으로 빨리 만들고 싶은가?” 아니면 “같은 머신에서 더 많은 SSE 연결을 안정적으로 붙잡아야 하는가?”
후자라면 Go 기반 재구현은 충분히 검토할 가치가 있습니다. 다만 실제 전환 전에는 현재 트래픽 패턴에서 CPU, 메모리, 연결 수를 직접 측정해 보는 것이 가장 안전합니다.
참고 자료
- LiteLLM GitHub: https://github.com/BerriAI/litellm
- LiteLLM 공식 문서: https://docs.litellm.ai/
- Python asyncio 문서: https://docs.python.org/3/library/asyncio.html
- Go 공식 문서: https://go.dev/doc/
- Go concurrency patterns: https://go.dev/tour/concurrency
- MDN SSE 문서: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events