하나의 SSE 스트림, 7개의 LLM 제공자: Next.js 앱에 단일 스트리밍 코드 경로 제공하기
요약
OpenAI, Anthropic, Ollama 등 서로 다른 스트리밍 형식을 가진 7개의 LLM 제공자를 하나의 표준화된 SSE 프로토콜로 통합하는 방법을 설명합니다. Next.js 환경에서 클라이언트 측 파싱 로직을 단순화하고 확장성을 높이는 설계 패턴을 다룹니다.
핵심 포인트
- 다양한 LLM 제공자의 서로 다른 SSE/NDJSON 형식을 단일 계약(Contract)으로 통합
- 클라이언트에는 delta, error, [DONE] 세 가지 형태만 전달하여 복잡도 제거
- 비동기 제너레이터를 활용해 상위 API의 차이를 추상화하는 래퍼 구현
- 제공자가 추가되어도 클라이언트 코드 수정이 필요 없는 확장성 확보
데이터베이스도, 백엔드 비밀 키(backend secrets)도 없이, 사용자의 API 키가 단 한 번의 요청 동안만 머물도록 하여 OpenAI, Claude, Gemini, Ollama, Mistral, Groq, Azure를 브라우저에게 동일하게 보이게 만든 방법.
저는 방문자가 LLM 제공자를 선택하고, 자신의 API 키를 붙여넣으면 응답을 스트리밍하는 작은 오픈 소스 Next.js 앱을 만들었습니다. 재미있는 부분은 UI가 아니었습니다. 제가 초기에 설정한 제약 조건이었습니다:
7개의 제공자 중 어떤 것이 선택되더라도, 클라이언트는 스트리밍을 위한 정확히 하나의 코드 경로를 가져야 한다.
이러한 API들이 실제로 어떻게 스트리밍되는지 살펴보면 이는 당연한 소리처럼 들리지 않습니다. 이들은 전송 방식(transport), 청크 형태(chunk shape), 시스템 프롬프트(system prompt)의 위치, 그리고 스트림이 종료되는 방식 등 거의 모든 부분에서 서로 다릅니다. 이 포스트는 그 모든 것을 하나의 계약(contract)으로 통합한 방법에 관한 것입니다.
실제로 마주하게 되는 혼란
제가 지원하고자 했던 제공자들 사이에서만 해도 세 가지의 서로 다른 스트리밍 방언(dialects)이 존재합니다:
- OpenAI / Mistral / Groq / Azure — SSE를 사용하며, 각 라인은
data: {…}형태이고, 텍스트는choices[0].delta.content에 위치합니다. 스트림은 문자 그대로data: [DONE]으로 종료됩니다. - Anthropic — 역시 SSE를 사용하지만, 시스템 프롬프트가 (메시지가 아닌) 별도의 최상위 필드(top-level field)로 존재하며, 델타(deltas)는 타입이 지정된 이벤트로 도착합니다.
type === "content_block_delta"및delta.type === "text_delta"인 이벤트만 필요합니다. - Ollama (로컬) — SSE가 전혀 아닙니다. 이는 NDJSON입니다. 한 줄당 하나의 JSON 객체가 있으며, 텍스트는
message.content에 있고,{done:true}객체로 종료됩니다. API 키가 필요 없으며localhost:11434에서 실행됩니다.
이 중 어느 하나라도 클라이언트로 유출되게 두면, 브라우저에는 세 개의 파서(parser)가 생기고, 제공자를 추가할 때마다 늘어나는 if (provider === …) 계단식 조건문을 마주하게 됩니다. 저는 그 반대를 원했습니다. 브라우저가 영원히 단 하나의 형식만 파싱하는 것 말입니다.
계약 (The contract)
모든 제공자는 상위(upstream) 형태와 관계없이 브라우저에 다음과 같은 내용을 방출합니다:
data: {"delta":"<text chunk>"}\n\n ... 반복
data: {"error":"<message>"}\n\n ... 실패 시
data: [DONE]\n\n ... 항상 종료
브라우저는 오직 세 가지, 즉 delta, error, [DONE]만 이해하면 됩니다. 이것이 클라이언트 측 프로토콜의 전부입니다.
핵심 요소: "텍스트 생성기"를 "보장된 SSE 스트림"으로 전환하기
다른 모든 과정을 단순하게 만드는 비결은 각 제공자(provider)를 **일반 텍스트 델타(delta)를 생성하는 비동기 제너레이터(async generator)**로 표현하고, 이를 한 번 감싸는(wrap) 것입니다. 이 래퍼(wrapper)는 전송 형식(wire format)과, 상위(upstream)에서 전송 도중 오류가 발생하더라도 스트림이 항상 종료된다는 보장을 모두 책임집니다.
const encoder = new TextEncoder();
const frame = (payload: object) =>
encoder.encode(`data: ${JSON.stringify(payload)}\n\n`);
...
저 finally 구문은 조용하지만 매우 중요한 줄입니다. 제공자가 응답 중간에 속도 제한(rate limit), 소켓 끊김, 잘못된 청크(malformed chunk) 등의 이유로 중단되더라도, 브라우저는 여전히 깨끗한 error 프레임을 받은 뒤 [DONE]을 받게 됩니다. 클라이언트의 읽기 루프(read loop)는 결코 오지 않을 종료를 기다리며 무한 대기 상태에 빠지지 않습니다. 에러 처리(Error handling)는 모든 제공자가 매번 재구현해야 하는 것이 아니라, 전송 계층(transport)의 속성이 됩니다.
이제 각 제공자는 단 하나의 질문에만 답하면 됩니다: 상위(upstream) 응답이 주어졌을 때, 나는 어떤 텍스트를 생성(yield)할 것인가?
네 개의 제공자, 하나의 파일
OpenAI, Mistral, Groq, Azure는 모두 동일한 Chat Completions 방언을 사용하므로, 하나의 구현체를 공유합니다. 호출자는 엔드포인트(endpoint)와 인증 헤더(auth headers)만 전달하면 됩니다:
export async function openAICompatibleChat(
request: ChatRequest,
endpoint: string,
...
openaiProvider, mistralProvider, groqProvider, azureProvider는 이제 URL과 헤더를 제공하는 세 줄짜리 래퍼(wrapper)가 되었습니다. 다음에 추가될 OpenAI 호환 제공자를 추가하는 것은 단 한 줄이면 충분합니다.
두 개의 이례적인 사례 — 출력은 같지만 내부 구조는 다름
Anthropic은 시스템 프롬프트(system prompt)를 메시지 배열 밖으로 들어 올려야 하며, 단일 필드를 읽는 대신 타입이 지정된 이벤트(typed events)를 필터링해야 합니다:
return createSSEStream(async function* () {
for await (const data of readSSELines(res)) {
if (!data || data === "[DONE]") continue;
...
(만약 브라우저 오리진 프록시(browser-origin proxy) 역할을 하는 서버에서 Anthropic API를 호출한다면, 알아두어야 할 Anthropic 전용 주의사항이 하나 있습니다. anthropic-dangerous-direct-browser-access: true 헤더가 필요하며, 그렇지 않으면 요청을 거부합니다.)
Ollama는 SSE가 아니므로, 원시 바이트 청크(raw byte chunks)를 읽고 NDJSON 라인을 직접 분할합니다. 하지만 이 역시 정확히 동일한 createSSEStream에 일반 텍스트를 반환합니다:
return createSSEStream(async function* () {
let buffer = "";
for await (const chunk of readRawChunks(res)) { // 디코딩된 원시 바이트 (raw decoded bytes)
...
타입이 지정된 SSE 이벤트(typed SSE events)와 라인 구분 JSON(line-delimited JSON)이라는 완전히 다른 두 가지 전송 방식이 동일한 출력 규약(output contract)으로 수렴합니다. 브라우저는 이 둘을 구분할 수 없으며, 이것이 바로 전체 목표였습니다.
디스패치(Dispatch)는 의도적으로 지루하게 설계되었습니다
모든 제공자가 동일한 스트림 타입을 생성하므로, 라우터는 단순한 조회 테이블(lookup table)이 됩니다:
const PROVIDERS: Record<ProviderKey, Provider> = {
openai: openaiProvider, anthropic: anthropicProvider, gemini: geminiProvider,
ollama: ollamaProvider, mistral: mistralProvider, groq: groqProvider, azure: azureProvider,
...
지루한 디스패치는 변동성을 제너레이터(generators) 내부로 밀어 넣은 것에 대한 보상입니다. 제공자를 추가하려면 파일 하나와 테이블의 행 하나만 수정하면 되며, 클라이언트는 전혀 변경할 필요가 없습니다.
사람들을 놀라게 하는 부분: 딱히 언급할 만한 백엔드가 없습니다
핵심 제약 조건이 _사용자가 자신의 키를 직접 가져온다_는 것이었기 때문에, 데이터베이스도, 인증(auth)도, 서버 측 비밀(server-side secret)도 없습니다. 흐름은 다음과 같습니다:
- 키는 브라우저의
localStorage에 저장됩니다. - 단일
POST /api/run요청의 본문(body)에 담겨 전송됩니다. - 해당 경로는 이를 단 한 번의 업스트림
fetch에 사용하고, 결과를 스트리밍하여 다시 보낸 뒤 즉시 폐기합니다.
서버는 순수한 패스스루 프록시 (pass-through proxy)입니다. 사용자의 어떤 정보도 어디에도 저장되지 않습니다. 덕분에 이 앱은 매우 쉽게 셀프 호스팅이 가능하며 (원클릭 배포, 구성할 환경 변수 없음), "내 키를 어디에 저장하나요?"라는 질문의 범주 자체를 피해 갑니다. 정직한 답변은 "저장하지 않습니다"입니다.
보너스: 999개의 파일 없이 999개의 페이지 만들기
이 앱은 방대한 컨설턴트 프롬프트 템플릿 라이브러리를 제공합니다. 이것들은 수기로 작성된 파일이 아닙니다. 각 템플릿은 평면 메타데이터 테이블 (flat metadata table)의 한 행이며, 로드 시점에 전체 객체로 해석됩니다. 특정 ID에 대한 오버라이드 (override)가 수동으로 조정된 형태를 제공하지 않는 한, 일반적인 프롬프트 빌더 (prompt builder)로 대체됩니다.
function resolveAgent(meta: AgentMeta): Agent {
const override = AGENT_OVERRIDES[meta.id] ?? {};
const inputs = override.inputs ?? DEFAULT_INPUTS;
...
모든 페이지는 빌드 타임에 해당 테이블로부터 정적으로 사전 렌더링 (statically pre-rendered)됩니다. "필요할 때만 오버라이드한다"는 패턴은 중요도가 낮은 대다수의 항목은 비용이 들지 않게 하면서, 중요한 항목들은 맞춤형 처리를 받을 수 있음을 의미합니다. 이는 프로바이더 레이어 (provider layer)와 동일한 철학입니다: 하나의 일반적인 경로를 유지하되, 특수화(specialization)를 선택적으로 적용한다.
핵심 요약 (Takeaways)
- 각 통합 (integration)을 **실제로 원하는 것(텍스트 델타, text deltas)의 생성기 (generator)**로 모델링하고, 전송(transport) 및 종료(termination) 보장 기능을 갖춘 래퍼 (wrapper)로 단 한 번 감싸세요. 변동 사항은 생성기 내부에서 처리하며, 그 하위의 모든 것은 균일하게 유지됩니다.
- 에러 및 종료 처리를 래퍼의
finally블록에 배치하여, 오작동하는 업스트림 (upstream)이 클라이언트를 절대 중단(hang)시키지 않도록 하세요. - "자신의 키를 직접 사용하기 (Bring your own key)"는 단순한 개인정보 보호 입장이 아닙니다. 이는 비밀 정보 및 저장소 노출 영역(surface) 전체를 제거하며 셀프 호스팅을 무료로 만들어 줍니다.
- 형태가 동일한 통합 방식들은 하나의 구현체로 통합하세요. 진정으로 다른 것들에게는 별도의 파서 (parser)를 부여하되, 동일한 출력 계약 (output contract)을 따르도록 강제하세요.
코드는 오픈 소스 (MIT)입니다. 프로바이더 레이어나 데이터 기반 생성 (data-driven generation)에 대해 궁금한 점이 있다면 댓글로 무엇이든 물어봐 주세요.
Repo: https://github.com/mltech-ai-tw/agents-999 · Live demo: https://agents-999.vercel.app
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기