가입하면 프리미엄 플랜 할인할인 받기

블로그/developers·2026년 8월 12일·3분·eroq 팀 작성

SSE로 AI 응답 스트리밍하기: 실전 가이드

서버 전송 이벤트(SSE)를 처음부터 끝까지. 청크 프레임 파싱, 내 백엔드를 통한 스트림 중계, 프로덕션에서만 나타나는 버퍼링 버그까지.


스트리밍은 살아 있는 캐릭터와 로딩 스피너를 가르는 차이예요. SSE의 원리는 단순하지만, 버그는 전부 디테일에 숨어 있어요. 이 가이드가 바로 그 디테일을 다뤄요.

와이어 포맷

stream: true를 넣으면 /v1/chat/completions가 text/event-stream으로 응답해요. 각 프레임은 data: 줄 하나와 빈 줄 하나로 이루어지고, 델타는 표준 청크 형태로 도착해요.

data: {"id":"cmpl_…","object":"chat.completion.chunk","choices":[{"delta":{"content":"That "}}]}

data: {"id":"cmpl_…","object":"chat.completion.chunk","choices":[{"delta":{"content":"noise"}}]}

data: {"object":"chat.completion.usage","usage":{"credits_spent":3,"credits_remaining":997}}

data: [DONE]

알아 두면 좋은 eroq 전용 프레임이 두 가지 있어요. [DONE] 바로 앞의 usage 이벤트에는 사용량이 담겨 있고, error 이벤트({"error":{…}})는 끊긴 연결을 보고 짐작해야 했을 오류를 대신 알려 줘요. 콘텐츠가 하나도 도착하기 전에 스트림에서 오류가 나면, 그 호출은 이미 자동으로 환불된 상태예요.

고전적인 버그 없이 파싱하기

고전적인 버그는 네트워크 청크 하나하나를 완전한 프레임으로 취급하는 거예요. TCP는 줄바꿈을 신경 쓰지 않아요. 프레임 하나가 여러 번의 읽기에 나뉘어 도착할 수 있어요. 버퍼에 쌓고, \n\n으로 나누고, 남은 조각은 그대로 두세요.

const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
  const { done, value } = await reader.read()
  if (done) break
  buffer += decoder.decode(value, { stream: true })   // stream: true matters for UTF-8
  const frames = buffer.split('\n\n')
  buffer = frames.pop() ?? ''                          // last piece may be incomplete
  for (const frame of frames) {
    const data = frame.replace(/^data: /, '')
    if (data === '[DONE]') continue
    const parsed = JSON.parse(data)
    if (parsed.error) throw new Error(parsed.error.message)
    const delta = parsed.choices?.[0]?.delta?.content
    if (delta) render(delta)
  }
}

decoder.decode의 { stream: true }는 장식이 아니에요. 이게 없으면 청크 경계에서 잘린 멀티바이트 문자(한글도 여기에 해당해요)가 깨진 글자가 돼요. 이모지를 많이 쓰는 롤플레이라면 한 시간 안에 이 버그를 만나요.

내 백엔드를 통해 중계하기

API 키를 절대 브라우저로 보내지 마세요. 이미 키와 캐릭터 시트를 가지고 있는 백엔드를 통해 스트림을 중계하세요. 중계 코드는 얇지만, 성패를 가르는 디테일이 두 가지 있어요.

// Node/Express-style relay
app.post('/chat', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.flushHeaders()                                   // 1. headers out immediately

  const upstream = await fetch('https://eroq.ai/v1/chat/completions', { /* … */ })
  for await (const chunk of upstream.body) {
    res.write(chunk)                                   // 2. relay bytes, re-frame nothing
  }
  res.end()
})
  1. 헤더를 즉시 플러시하세요. 그렇지 않으면 리버스 프록시가 응답 전체를 버퍼링했다가 한 번에 전달할 수 있어요. 한 덩어리로 도착하는 스트리밍이 되는 거죠. nginx라면 X-Accel-Buffering: no도 설정하세요.
  2. 바이트를 그대로 중계하세요. 중계 단계에서 프레임을 파싱하고 다시 직렬화하면, 얻는 건 없이 버그가 생길 곳만 두 배로 늘어요. 파싱은 렌더링하는 클라이언트에서 하세요.

좋은 UX와 훌륭한 UX를 가르는 디테일

  • 짧은 타이머로 렌더링하세요(30–50ms). 델타마다 렌더링하지 마세요. 토큰마다 DOM을 건드리면 모바일에서 버벅여요.
  • 첫 토큰은 빠르게 보여 주고, 그다음은 흘러가게 두세요. 체감 지연은 거의 전부 첫 토큰이 나오기까지의 시간에서 결정돼요.
  • 스트림 도중 실패하면 부분 텍스트를 남겨 두고 다시 시도할 수 있게 해 주세요. 사라지는 답장보다 남아 있는 반쪽 답장이 나아요. 게다가 첫 출력 이후에 끊긴 스트림도 생성은 된 것이니, 텍스트를 남겨 두는 건 이미 비용을 치른 결과를 존중하는 일이에요.
  • 사용자가 중단할 수 있게 하세요. 중계 응답을 닫으면 업스트림 요청도 닫혀야 해요. 버려진 스트림을 계속 받아 소비하면 아무도 보지 않는 렌더에 돈을 쓰는 셈이에요.

자주 묻는 질문

eroq API의 SSE 청크를 올바르게 파싱하려면 어떻게 하나요?

바이트를 버퍼에 쌓고, \n\n으로 나누고, 끝에 남은 조각은 다음 읽기를 위해 남겨 두세요. 프레임 하나가 여러 네트워크 청크에 나뉘어 도착할 수 있어요. TextDecoder.decode에 { stream: true }를 넘겨서 멀티바이트 문자가 잘려도 깨지지 않게 하고, JSON을 파싱하기 전에 [DONE] 신호는 건너뛰세요.

스트리밍은 일반 응답보다 비용이 더 드나요?

아니요. 크레딧은 토큰이 아니라 응답 단위로 청구돼요. 그래서 스트리밍한 답장이든 한 번에 받은 답장이든 RP mini에서는 똑같이 1 크레딧, RP+에서는 3 크레딧이에요. 답장이 50토큰이든 상한인 1,200토큰이든 마찬가지예요. stream: false로 둘 가격상의 이유는 없어요.

스트림 끝의 usage 이벤트는 뭔가요?

data: [DONE] 바로 앞에 오는 프레임으로, credits_spent와 credits_remaining을 담고 있어요. 덕분에 API를 한 번 더 호출하지 않고도 잔액 표시를 업데이트할 수 있어요. {"error":{…}} 형태의 error 프레임은 끊긴 연결을 보고 짐작해야 했을 오류를 대신 알려 주고, 콘텐츠가 오기 전에 실패한 스트림은 이미 자동으로 환불된 상태예요.

스트림이 토큰 단위가 아니라 한꺼번에 도착하는 이유는 뭔가요?

거의 항상 프록시가 응답을 버퍼링하기 때문이에요. 첫 쓰기 전에 헤더를 플러시하고, nginx라면 X-Accel-Buffering: no를 설정하세요. 두 번째로 흔한 원인은 중계 단계에서 프레임을 다시 직렬화하는 거예요. 바이트는 그대로 중계하고, 파싱은 렌더링하는 클라이언트에서 하세요.

고정 가격 덕분에 생기는 결과가 하나 더 있어요. 스트리밍 비용은 버퍼링 비용과 정확히 같아요. 똑같이 1 또는 3 크레딧이에요. 스트리밍하지 않을 이유가 없고, 그래서 문서의 모든 예제가 스트리밍을 써요.

태그streamingssechat api

이 글에 나온 모델로 직접 만들어 보세요 — 무료 크레딧 50개로 시작하거나, 모든 엔진과 가격도 살펴보세요.