스트리밍 vs JSON: AI 기반 애플리케이션에서의 트레이드오프 (Trade-offs)
요약
AI 애플리케이션 개발 시 스트리밍 방식과 JSON 응답 방식 사이의 트레이드오프를 분석합니다. 데이터 구조화와 사용자 경험(UX) 사이의 충돌을 해결하기 위한 실무적인 접근법을 다룹니다.
핵심 포인트
- JSON은 파싱을 위해 전체 데이터가 필요하므로 스트리밍 구현이 어려움
- 스트리밍은 즉각적인 피드백을 제공하여 사용자 체감 속도를 높임
- 구조화된 데이터가 필요할 경우 JSON 형식을 사용하되 UX 저하를 고려해야 함
- 로딩 스피너 등 시각적 요소를 통해 JSON 응답의 지연 시간을 보완 가능
이 글은 일반적인 "스트리밍 vs JSON" 가이드가 아니라, 제가 LogicVisor를 구축하면서 내린 결정에 대한 분석입니다. AI API 응답은 텍스트에 국한되지 않습니다. 일부는 이미지, 오디오, 구조화된 데이터(structured data)를 반환합니다. 저는 텍스트만 필요했기 때문에, 스트리밍(streaming)과 JSON 사이의 선택이 실제 갈림길이 되었습니다. 만약 이미지 응답도 필요했다면, 이 계산 방식은 완전히 달라졌을 것입니다.
처음부터 두 가지 제약 사항이 서로 충돌했습니다:
- AI 평가 결과가 데이터베이스(database)에 저장되어야 합니다.
- 동일한 정보가 제출 시점에 사용자에게 즉시 제공되어야 합니다.
JSON을 안정적으로 스트리밍할 수 없는 이유
JSON은 사용하기 전에 파싱(parse)되어야 하며, 데이터가 도착하는 대로 조각조각 파싱할 수는 없습니다. JSON.parse()가 작업을 수행하기 전에 처음부터 끝까지 완전한 상태여야 합니다. 이는 스트리밍을 할 것인지, 하지 않을 것인지에 대한 이분법적인 선택을 강요합니다. 저의 경우, 스트리밍은 가공되지 않은 텍스트(raw text)를 의미했는데, 왜냐하면 기반이 되는 API들이 스트리밍하는 것이 오직 텍스트뿐이기 때문입니다.
저는 스트리밍으로 시작했습니다. 사용자 측면에서의 피드백은 거의 즉각적이었고 느낌이 매우 좋았습니다. 그러다 사용자 뷰와 데이터베이스 모두를 위해 더 구조화된 응답이 필요해졌고, 요청 방식을 자유 형식의 텍스트 대신 JSON을 요청하도록 변경했습니다:
const groqParams: ChatCompletionCreateParamsNonStreaming = {
messages: [{ role: "user", content: prompt.content }],
model: groqModel,
...
stream: false와 response_format: json_object를 사용하는 것이 핵심 비결입니다. 모델은 무엇을 요청하든 절반 정도의 확률로 답변을 마크다운 코드 펜스(markdown code fence)로 감싸기 때문에, 파싱하기 전에 여전히 이를 제거해야 합니다:
export function extractJSONFromMarkdown(markdownString: string) {
let jsonString = markdownString.trim();
if (jsonString.startsWith("```json")) {
jsonString = jsonString.substring(7);
}
if (jsonString.endsWith("```")) {
jsonString = jsonString.substring(0, jsonString.length - 3);
}
...
원하던 데이터 형태를 얻었습니다. 하지만 잃은 것은 사용자 경험(User Experience)이었습니다. 이제 사용자는 텍스트가 스트리밍되는 것을 보는 대신, 전체 응답이 완료될 때까지 로딩 상태를 바라보게 되었습니다.
실제로 배운 점
스트리밍 버전이 절대적인 관점에서 더 빨랐던 것은 결코 아닙니다. 단지 더 빨라진 것처럼 느껴졌을 뿐입니다. 진행 상황이 눈에 보일 때 사용자는 더 인내심을 갖기 때문입니다. 스트리밍은 그 점을 활용했고, JSON은 그 점을 제거했습니다.
JSON은 스트리밍할 수 없고(파싱을 위해서는 완전한 페이로드가 필요함), 가공되지 않은 스트리밍 텍스트에서 사후에 무언가를 해킹하여 조합하지 않고서는 구조화된 데이터(Structured Data)를 추출할 수 없었기에, 저는 하나의 방식이 두 가지 역할을 모두 수행하도록 강요하는 것을 그만두었습니다. 대신 UX 문제를 직접 해결했습니다. 로딩 스피너(Loading spinners), 펄서(Pulsers), 회전하는 상태 텍스트 등을 도입했습니다. 실제적인 진행 상황은 아니지만, 진행 중인 것처럼 읽히며, 사용자가 실제로 반응하는 것은 바로 그것입니다.
두 가지 경로, 두 가지 역할
익명 무료 체험 리뷰 기능을 추가했을 때, 저장 요구 사항이 줄어들었습니다. 익명 사용자는 인증된 사용자만큼 깊이 있는 데이터를 영구적으로 저장할 필요가 없었습니다. 따라서 그 경로는 가장 빠른 체감 응답 속도를 위해 스트리밍을 기본값으로 사용합니다. 이는 무료 체험 기간 동안 새로운 사용자를 사로잡으려 할 때 정확히 원하는 방식입니다.
const groqStream = await groq.chat.completions.create({
messages: [{ role: "user", content: prompt.content }],
model: groqModel,
...
이전과 동일한 groq.chat.completions.create 호출이지만, false 대신 stream: true를 사용하고 response_format은 지정하지 않습니다. 각 청크(Chunk)는 도착하는 즉시 서버 전송 이벤트(Server-sent events)를 통해 클라이언트로 푸시되며, 저는 서버 측에서 fullText를 누적하여 스트림이 종료된 후 로깅을 위해 전달할 수 있는 완전한 응답을 보유합니다.
(실제 구현에서는 503 오류 발생 시 백오프(Backoff)와 함께 재시도하며, 콜드 스타트(Cold starts) 시에는 "깨어나는 중" 알림을 보냅니다. 이는 스트리밍 대 JSON 결정과는 무관한, 서버리스 AI 제공업체에 대한 일반적인 회복 탄력성(Resilience)에 관한 내용이므로 여기서는 제외했습니다.)
완전한 구조화 데이터(Structured data)를 영구적으로 저장해야 하는 인증된 사용자(Authenticated users)는 첫 번째 섹션에서 다룬 비-스트리밍 (non-streaming) JSON 경로를 사용하여 데이터베이스 쓰기(Database write)로 직접 연결합니다:
const result = extractJSONFromMarkdown(aiReviewText ?? "");
const review = await createAIReview({
...
두 개의 코드 경로와 하나의 아키텍처(Architecture)가 존재하며, 각각은 서로 다른 제약 조건에 따라 제 역할을 수행합니다. 익명 체험 사용자에게는 속도를, 인증된 사용자에게는 구조화된 데이터를 제공합니다.
핵심 요약 (The takeaway)
AI 기반 제품(또는 솔직히 말해 어떤 제품이든)을 구축하는 것은 서류상으로 가장 깔끔해 보이는 방식이 아니라, 실제 목표에 부합하는 트레이드오프 (Trade-off)를 선택하는 문제로 귀결됩니다. 스트리밍 (Streaming)과 JSON은 여기서 서로 경쟁하는 솔루션이 아니라, 동일한 앱 내에서 서로 다른 두 가지 문제를 해결하는 두 가지 도구입니다.
커버 사진: Unsplash의 Myles Bloomfield
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기