OpenAI API 스트리밍 구현과 실전 활용법

Photo of author

By 요담

OpenAI API로 긴 답변을 받을 때 전체 완료를 기다리면 화면이 멈춘 것처럼 보입니다. stream=true를 켜면 모델 출력이 SSE로 점진 전송되어 첫 토큰부터 그릴 수 있습니다. 채팅형 제품에서는 이 차이가 대기 체감을 바꿉니다.

이 글은 Chat Completions의 delta와 Responses API의 이벤트 타입을 구분해 설명합니다. 프론트엔드에서 토큰을 이어 붙이는 방법과 취소, 재연결 주의점도 함께 다룹니다. 구현 전에 스트림 종료 신호와 usage 청크 위치를 먼저 확인하는 것이 안전합니다.

OpenAI API 스트리밍이 필요한 이유

OpenAI API 스트리밍 관련 화면
(사진 출처: mac4ever.com)

공식 안내에 따르면 기본은 전체 HTTP 응답 하나이고, 스트리밍은 stream=true로 SSE를 엽니다. 공식 문서도 기본을 한 번의 전체 응답으로 두고 스트리밍을 선택 옵션으로 둡니다. 사용자는 첫 토큰이 보이기 전까지 요청이 먹혔는지 판단하기 어렵습니다. 채팅형 화면에서는 이 빈 구간이 이탈로 이어지기 쉽습니다.

긴 요약, 코드 초안, 단계 안내처럼 생성 시간이 긴 작업에서 체감 지연이 커집니다. 스트리밍은 출력을 조각으로 밀어 넣어 진행 중임을 바로 보여 줍니다. 다만 완료 전에 결과를 확정 저장하면 잘린 문장이 남을 수 있습니다. 토큰 과금은 스트림이어도 최종 생성량을 따르므로 미리보기 호출을 남발하지 마십시오.

스트리밍 응답의 동작 방식과 SSE 이해

(출처: Adam Thometz)

SSE는 data만 있는 줄이 이어지는 텍스트 스트림입니다. Chat Completions에서 stream=True를 켜면 data-only SSE로 청크가 오고, 본문은 message가 아니라 delta에서 꺼냅니다. 서버는 연결을 유지한 채 조각을 밀어 넣으므로 한 번에 닫히는 JSON 응답과 파서가 다릅니다. 클라이언트는 개행으로 이벤트를 나누고 data: 접두만 제거한 뒤 JSON.parse를 호출합니다.

스트림 도중 usage는 보통 비어 있습니다. include_usage=true이면 마지막 청크에만 전체 요청 token usage가 오고, 그 전 청크 usage는 null입니다. 중단되거나 취소되면 이 최종 usage를 못 받을 수 있습니다.

청크와 finish_reason 읽는 법

Cookbook 예시를 보면 delta는 role, content 토큰, 스트림 종료 시 빈 객체 {}일 수 있습니다. 역할은 보통 첫 청크에만 오고, 이후에는 content 조각이 붙습니다. finish_reason은 생성이 끝난 청크에서 stop, length 같은 값으로 채워집니다. 빈 delta만 보고 연결을 닫으면 마지막 신호를 놓칠 수 있습니다.

include_usage가 켜진 마지막 청크는 choices가 비어 있을 수 있습니다. 그래서 usage만 있는 이벤트와 텍스트 델타를 같은 분기로 처리하면 안 됩니다. 종료 판정은 finish_reason과 [DONE], 빈 choices를 함께 봐야 안전합니다. 역할 필드가 없는 중간 청크는 오류가 아닙니다.

Chat Completions와 Responses API에서 스트림 받기

두 API는 이벤트 모양이 다릅니다. Chat Completions는 incremental chunk에 delta가 붙습니다. Responses API는 type이 있는 semantic SSE 이벤트를 씁니다. 같은 파서로 둘을 받으면 필드가 비어 보이는 오류가 납니다.

텍스트 스트림에서 흔히 듣는 이벤트는 response.created, response.output_text.delta, response.completed, error입니다. created로 응답 id를 잡고, response.output_text.delta로 글자를 붙이며, completed에서 종료를 확정합니다. error가 오면 부분 텍스트를 폐기할지 사용자에게 알릴지를 정책으로 정해야 합니다.

구분Chat CompletionsResponses API
스트림 형식delta 청크와 data-only SSEtype이 있는 semantic 이벤트
텍스트 위치choices[].delta.contentresponse.output_text.delta
종료 신호finish_reason, [DONE], 빈 choicesresponse.completed 또는 error
usageinclude_usage 시 마지막 청크completed 쪽 메타데이터

프론트엔드에서 토큰 단위로 보여주기

브라우저에서는 EventSource 또는 fetch ReadableStream으로 SSE를 읽습니다. 도착한 data 줄을 JSON으로 파싱한 뒤 content 조각을 버퍼에 붙입니다. 화면은 버퍼 전체를 다시 그리지 말고, 마지막에 붙은 글자만 이어 쓰는 편이 낫습니다. 마크다운을 매 토큰마다 완전 파싱하면 커서 위치가 흔들릴 수 있습니다.

취소 버튼을 누르면 서버 스트림과 UI 타이머를 같이 멈춰야 합니다. 부분 응답을 확정 메시지로 올리면 사용자가 잘린 답을 사실로 받아들일 수 있습니다. 진행 중 배지와 완료 배지를 구분해 두면 재시도와 복사가 안전해집니다. 스크롤은 사용자가 위로 올린 뒤에는 자동 추적을 멈추는 것이 읽기 방해를 줄입니다.

오류·재연결·취소 처리 실무 팁

네트워크가 끊기면 SSE는 조용히 멈출 수 있습니다. 재연결 시 같은 프롬프트를 다시 보내면 토큰이 두 번 과금될 수 있습니다. 응답 id를 저장해 이어받기가 되는 API만 재개하고, 아니면 사용자에게 재시도를 물으십시오. 부분 텍스트는 초안으로 남기고 완료 플래그가 없으면 복사 안내를 약하게 하십시오.

5xx나 rate limit은 즉시 재시도보다 지수 대기가 안전합니다. 클라이언트만 재연결하고 서버 취소를 빼먹으면 GPU 쪽 작업이 남을 수 있습니다. 로그에는 요청 id와 마지막 이벤트 type을 남겨 원인 분리를 쉽게 하십시오. 부분 JSON이 깨진 줄은 조용히 버리고 다음 data 줄을 기다리는 편이 안전합니다.

타임아웃과 AbortController 활용

fetch 기반 스트림은 AbortController로 시그널을 넘깁니다. 사용자가 멈춰도 브라우저 기본 타임아웃만 믿으면 연결이 길게 남을 수 있습니다. 첫 토큰 대기와 전체 생성 한도를 나눠 두는 편이 안전합니다. 첫 토큰이 수 초 안에 없으면 모델이 아니라 게이트웨이 문제일 수 있습니다.

abort 후에는 reader 루프를 빠져나오고, include_usage 최종 청크는 오지 않을 수 있다고 가정하십시오. UI는 취소를 실패와 같은 빨간 오류로 보이지 않게 구분하는 것이 좋습니다. 서버에도 동일 취소를 전파해야 과금을 줄일 수 있습니다. 생성 한도를 넘기면 abort 후 재시도 안내를 띄우면 됩니다.

OpenAI API 스트리밍 정리와 적용 체크리스트

구현 전에 stream=true와 파서 종류부터 고정하십시오. Chat Completions는 delta, Responses는 type 이벤트를 전제로 분기합니다. include_usage를 켜면 마지막에 빈 choices와 usage만 올 수 있으니 종료 조건을 분리하십시오. 프론트는 토큰 이어 붙이기, 취소 시 AbortController, 재연결 시 이중 과금을 점검하면 됩니다.

배포 전에는 첫 토큰 지연, 중단 시 잘린 문장, error 이벤트 세 화면을 직접 눌러 보십시오. 완료 전에는 복사를 약하게, 완료 후에만 확정 저장을 여는 규칙이 사고 예방에 도움이 됩니다. 이 체크만 지나도 스트리밍 UX는 안정적으로 유지됩니다. 체크리스트는 코드 리뷰 항목으로 고정해 두면 누락이 줄어듭니다.

요담

글쓴이

요담

AI를 실무에 활용할 수 있도록 도움드리는 요담입니다.