1. ChatGPT App 아키텍처에서 “스트림”은 어디에 있는가
SSE와 HTTP-stream 중 무엇이 더 좋은가를 논하기 전에, 우리 스택의 어디에 스트림이 존재하는지부터 이해하는 것이 좋습니다.
크게 세 가지 레벨이 있습니다.
첫째, ChatGPT와 모델 레벨. 모델은 자체적으로 이미 토큰 단위로 응답을 스트리밍합니다. 즉, 답변 텍스트가 한 글자씩 “타이핑”되듯 나타나는 것을 보게 됩니다. 이것도 스트림이지만, OpenAI가 전적으로 제어하며 여러분의 코드와는 직접적으로 연결되지 않습니다.
둘째, MCP 레벨. ChatGPT가 여러분의 MCP 서버에 연결될 때 보통 SSE 연결을 유지합니다: 서버는 MCP의 JSON‑RPC 메시지(응답과 알림)를 그 연결로 푸시하고, ChatGPT는 별도의 HTTP 엔드포인트로 요청을 보냅니다, 예를 들어 /messages. MCP 관점에서 이는 기본 트랜스포트입니다.
셋째, Apps SDK와 여러분의 백엔드 레벨. 여러분의 React 위젯 GiftGenius는 ChatGPT 샌드박스에서 실행되며 HTTP로 백엔드/MCP 게이트웨이와 통신합니다: 일반 fetch, 스트림을 사용하는 fetch(ReadableStream), 또는 SSE 구독(EventSource)을 사용할 수 있습니다.
이 레벨들을 한데 섞지 않는 것이 중요합니다. MCP 이벤트는 ChatGPT와 서버 사이의 “배선”이고, 위젯과 여러분의 HTTP 백엔드 사이의 SSE/HTTP-stream은 여러분이 책임지는 구간입니다.
다음 다이어그램을 참고하세요.
flowchart TD
subgraph ChatGPT
UI[ChatGPT UI + 모델]
W[GiftGenius Widget]
end
subgraph YourInfra[개발자 인프라]
GW[MCP Gateway / Backend]
MCP[MCP Server]
end
UI -- "tool-call / 응답\n(내부 토큰 스트림)" --> W
UI <-- "SSE 기반 MCP\n(/sse + /messages)" --> MCP
W <-- "HTTP / fetch / SSE / stream" --> GW
GW <-- "JSON-RPC MCP" --> MCP
오늘은 Widget ↔ Backend 화살표에 집중하고, MCP 트랜스포트가 자체적으로도 SSE를 기반으로 한다는 점을 상기하겠습니다.
바로 이 구간 — Widget ↔ Backend — 에서 우리는 통신 방식을 선택해야 합니다: 단순 HTTP 요청으로 할지, 스트림을 쓸지. 다음 섹션에서 왜 “일반” HTTP가 이 영역에서 금방 한계를 보이는지 살펴봅니다.
2. 왜 일반 HTTP 요청만으로는 충분하지 않은가
표준 HTTP 모델은 “요청 → 단일 응답”입니다. 클라이언트가 묻고, 서버가 한 번 답하면 연결이 닫힙니다.
많은 작업에는 이것으로 충분합니다: job의 현재 상태를 얻거나, 사용자 설정을 저장하거나, 이미 DB에 있는 선물 목록을 가져오는 등의 작업입니다.
하지만 오래 걸리는 작업을 하는 순간부터 모든 것이 삐걱거리기 시작합니다.
GiftGenius를 떠올려 봅시다. 이 앱이 다음을 수행한다고 가정해 보세요:
- 여러 소스에서 시그널을 수집합니다(구매 이력, 위시리스트, 소셜 네트워크 등),
- 이를 몇 번의 LLM 요청으로 처리하고,
- 수십 명의 후보에서 개인화된 랭킹을 만듭니다.
이 모든 과정은 수십 초가 걸릴 수 있습니다. 일반 HTTP 요청을 40초 동안 붙잡아 두고 아무것도 보내지 않으면, UX는 오래된 브라우저처럼 됩니다: 사용자는 스피너만 보면서 앱이 죽었는지 아직 “생각 중”인지 추측해야 합니다.
UX 외에도 순수 기술적 문제들이 있습니다:
- ChatGPT, Vercel, 프록시의 타임아웃;
- 진행률, partial results 등을 보낼 수 없음;
- 연결 끊김을 제대로 처리하고 복구하기 어려움.
따라서 자연스러운 해결책은 하나의 큰 응답에서 준비되는 대로 보내는 작은 조각들의 스트림으로 전환하는 것입니다.
이 “작은 조각”은 다음과 같을 수 있습니다:
- 이벤트(job.progress, job.completed) — 이는 SSE에 해당합니다;
- 하나의 큰 페이로드의 파편(보고서 텍스트, 선물 리스트의 NDJSON 라인) — 이는 HTTP-stream에 해당합니다.
3. SSE (Server‑Sent Events): 이벤트 구독
MCP 자체도 서버→클라이언트 이벤트 푸시를 위해 HTTP 위에서 SSE 연결을 사용하므로, 먼저 SSE부터 살펴보죠.
SSE를 한눈에 이해하기
SSE는 일반 HTTP 위의 프로토콜입니다:
- 클라이언트는 GET 요청으로 엔드포인트를 열고, 서버는 Content-Type: text/event-stream으로 응답합니다;
- 서버는 연결을 닫지 않고, 주기적으로 다음과 같은 라인을 씁니다:
event: job.progress
data: {"jobId":"123","percent":40}
event: job.completed
data: {"jobId":"123","resultCount":12}
- 브라우저 측에서는 EventSource를 사용합니다. 이 객체는:
- 연결이 끊기면 자동으로 재연결을 시도하고,
- event: + data: + 빈 줄 포맷을 파싱하며,
- onmessage나 addEventListener("job.progress", ...) 핸들러를 호출합니다.
핵심 포인트: 채널은 단방향입니다. 서버만 클라이언트로 이벤트를 보냅니다. 클라이언트는 이 연결로 데이터를 보내지 않습니다.
ChatGPT Apps에서는 위젯이 jobId로 이벤트를 “구독”하고, 진행률과 완료 상태에 반응할 때 이 모델이 매우 잘 맞습니다.
Next.js 16에서의 간단한 SSE 엔드포인트
Job 진행 이벤트를 위한 route handler가 있다고 합시다:
app/api/gift-jobs/[jobId]/events/route.ts
import { NextRequest } from "next/server";
export async function GET(req: NextRequest, { params }: { params: { jobId: string } }) {
const jobId = params.jobId;
const stream = new ReadableStream({
start(controller) {
// SSE 이벤트를 보내는 유틸리티
const send = (event: string, data: unknown) => {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(new TextEncoder().encode(payload));
};
send("job.started", { jobId });
let percent = 0;
const interval = setInterval(() => {
percent += 20;
if (percent >= 100) {
send("job.completed", { jobId, totalGifts: 10 });
clearInterval(interval);
controller.close();
} else {
send("job.progress", { jobId, percent });
}
}, 1000);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream", // 여기서 SSE 응답 헤더를 설정
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
이는 장난감 예제입니다. 1초마다 퍼센트가 증가하고 마지막에 job.completed가 도착합니다. 나중에 이 타이머를 워커/큐의 실제 이벤트로 바꾸더라도 구조 자체는 같습니다.
클라이언트: GiftGenius 위젯에서 SSE 구독
React 위젯 내부에서 jobId가 있으면 이 스트림을 구독할 수 있습니다. 위젯의 API는 ChatGPT 샌드박스에서 동작하지만, EventSource는 일반 브라우저와 동일하게 사용할 수 있습니다.
import { useEffect, useState } from "react";
export function GiftJobProgress({ jobId }: { jobId: string }) {
const [percent, setPercent] = useState(0);
useEffect(() => {
const url = `/api/gift-jobs/${jobId}/events`;
const es = new EventSource(url);
es.addEventListener("job.progress", (event) => {
const data = JSON.parse((event as MessageEvent).data);
setPercent(data.percent);
});
es.addEventListener("job.completed", () => {
setPercent(100);
es.close();
});
es.onerror = () => {
// 연결에 문제가 있습니다. 재연결을 시도 중이라고 표시하세요.
};
return () => es.close();
}, [jobId]);
return <div>선물 추천 진행률: {percent}%</div>;
}
이제 이를 MCP 도구와 연결할 수 있습니다. 도구 start_gift_job은 jobId를 반환하고, 위젯의 ToolOutput에서 GiftJobProgress를 렌더링하기만 하면 됩니다.
자동 재연결과 Last‑Event‑ID
표준상 EventSource는 연결이 끊기면 자동으로 재연결을 시도합니다. 서버는 SSE 표준 필드인 id:를 이벤트에 포함할 수 있고, 클라이언트는 Last-Event-ID 헤더를 사용해 재연결 후 놓친 이벤트를 따라잡을 수 있습니다.
간단한 GiftGenius의 경우 아직 id:나 별도의 이벤트 식별자를 구현하지 않고, 재연결 시 진행률에 약간의 “갭”이 생기는 것을 허용해도 됩니다. 하지만 프로덕션, 특히 고부하 환경에서는 다음이 필요합니다:
- 각 SSE 이벤트에 표준 필드 id:를 추가하여, 클라이언트가 재연결 시 Last-Event-ID를 보낼 수 있게 할 것;
- 페이로드에 애플리케이션 레벨의 event_id를 도입하고, 클라이언트/백엔드에서 멱등 처리에 이를 활용할 것.
이것은 멱등성과 직결됩니다. 동일한 job.progress가 두 번 도착해도, 처리기는 익숙한 event_id를 보고 부작용을 중복 실행하지 않게 됩니다.
결국 SSE는 jobId 주변 이벤트를 구독하기에 좋으며, 자동 재연결과 이벤트 ID를 통한 중복 제어를 제공합니다. 이제 두 번째 유형의 스트림 — 하나의 요청에 대해 매우 큰 응답을 부분적으로 보내는 경우 — 을 살펴봅시다.
4. HTTP‑스트리밍: 단일 요청에 점진적으로 응답하기
SSE가 “독립 이벤트 스트림 구독”이라면, HTTP 스트리밍은 “단일 요청‑단일 응답이지만, 그 응답을 시간에 걸쳐 청크로 전송”하는 것입니다.
이는 여러분이 stream : true로 OpenAI API를 사용할 때 보게 되는 바로 그 메커니즘입니다: 서버는 JSON 청크(종종 SSE 포맷이지만, 논리는 “단일 요청 ↔ 부분 응답 스트림”)를 보내고, 클라이언트는 이를 최종 텍스트로 모읍니다.
여러분의 API에서도 다음과 같은 경우 동일하게 적용할 수 있습니다:
- 긴 텍스트 보고서(예: 선택된 선물의 논리 설명),
- 긴 선물 목록(사용자를 기다리게 하지 않고 부분적으로 스트리밍).
가장 간단한 HTTP‑스트림 엔드포인트(Next.js)
LLM이 긴 텍스트를 작성하는 “설명” 결과를 생성해야 한다고 가정해 봅시다. 이를 생성되는 대로 위젯에 스트리밍하고 싶습니다.
app/api/gift-report/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode("분석을 시작합니다...\n"));
// 여기에는 청크 단위의 실제 LLM 생성이 들어갈 수 있습니다
for (const line of ["선호도를 수집하는 중...\n", "예산을 계산하는 중...\n", "최종 추천...\n"]) {
await new Promise((r) => setTimeout(r, 1000));
controller.enqueue(encoder.encode(line));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked", // 여기서 HTTP/stream임을 나타냅니다
},
});
}
기술적으로는 Next가 chunked 인코딩을 관리합니다. 여러분은 ReadableStream을 반환하기만 하면 됩니다.
fetch로 위젯에서 HTTP 스트림 읽기
클라이언트(위젯 내부)에서는 다음처럼 스트림을 읽을 수 있습니다:
async function fetchReport(setText: (s: string) => void) {
const res = await fetch("/api/gift-report", { method: "POST" });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let acc = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
acc += decoder.decode(value, { stream: true });
setText(acc); // 들어오는 대로 UI 업데이트
}
}
그리고 래퍼 컴포넌트:
import { useState } from "react";
export function GiftReport() {
const [text, setText] = useState("");
return (
<div>
<button onClick={() => fetchReport(setText)}>보고서 생성</button>
<pre style={{ whiteSpace: "pre-wrap" }}>{text}</pre>
</div>
);
}
이것이 전형적인 패턴입니다. 단일 POST 요청 /api/gift-report에 대해, 응답은 텍스트 스트림으로 오고, 이를 점진적으로 표시합니다.
텍스트 대신 JSON을 스트리밍하기
종종 문자열이 아니라 JSON 객체를 스트리밍하고 싶을 것입니다. 가장 널리 쓰이는 포맷은 NDJSON(Newline‑delimited JSON)입니다: 각 이벤트는 하나의 JSON 라인이며 \n으로 끝납니다.
서버 측 예시:
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
for (let i = 0; i < 5; i++) {
const chunk = { type: "gift", index: i, name: `선물 #${i}` };
controller.enqueue(encoder.encode(JSON.stringify(chunk) + "\n"));
await new Promise((r) => setTimeout(r, 500));
}
controller.close();
},
});
클라이언트는 TextDecoder로 읽고, \n 기준으로 나눈 뒤 각 JSON 객체를 파싱합니다.
5. SSE vs HTTP‑stream: 차이점과 선택 기준
지금까지의 내용으로 감이 오셨겠지만, 표로 정리해 보겠습니다.
| 특성 | SSE (Server‑Sent Events) | HTTP‑stream (chunked) |
|---|---|---|
| 시작자 | 클라이언트가 GET을 열고 구독 | 클라이언트가 요청(GET/POST)을 보내면 서버가 응답을 스트리밍 |
| 방향 | 오직 서버 → 클라이언트 | 특정 요청에 대한 서버 응답 |
| 시맨틱스 | 이벤트 스트림 구독(pub/sub) | 단일 요청에 대한 부분 응답 |
| 내장 프로토콜 | 있음(event:, data:, id: 등) | 없음. (문자열, NDJSON, JSON 등 포맷을 직접 정의) |
| 클라이언트 API | EventSource | fetch + ReadableStream / response.body |
| 재연결 지원 | 내장(EventSource, Last-Event-ID) | 직접 구현 필요 |
| 전형적인 사용 사례 | jobId 기준의 진행률, 상태, 알림 | 텍스트, 대형 JSON 응답, LLM 출력 스트리밍 |
“손에 잡히는 규칙”으로 아주 단순화하면:
- job 주변에 이벤트가 많다 → SSE;
- 단일 tool 호출이 큰 결과를 돌려주고 이를 부분적으로 보여주고 싶다 → HTTP‑stream.
GiftGenius에 적용하면, SSE는 라이브 진행률/상태 표시, HTTP 스트림은 긴 텍스트 요약이나 긴 선물 리스트의 점진적 로딩에 적합합니다.
6. MCP와 GiftGenius에 어떻게 접목되는가
강의 초반의 도식(모델 ↔ MCP ↔ 위젯 ↔ 백엔드)을 떠올려 봅시다. 위젯 ↔ 백엔드 레벨의 스트림을 살펴봤으니, 이제 한 단계 뒤로 돌아가, 어디까지가 MCP이고 어디부터가 “단순 HTTP”인지 구분해 봅시다.
MCP는 ChatGPT(MCP 클라이언트)가 여러분의 MCP 서버와 통신하는 방식을 정의합니다. 이를 위해 다음과 같은 트랜스포트가 있습니다:
- ChatGPT는 /sse로 SSE 연결을 열어 MCP 메시지(응답, 알림, 이벤트)를 수신합니다;
- ChatGPT는 MCP 요청(call_tool, list_tools 등)을 보통 POST JSON‑RPC로 /messages에 전송합니다.
GiftGenius를 ChatGPT에 연결할 때 이미 이 레벨을 경험했을 것입니다.
이제 비동기 작업과 위젯의 UX 스트림을 추가하면, 아키텍처에는 두 가지 선택지가 생깁니다.
옵션 1 — “순수 MCP”: MCP 서버가 job.progress, job.completed 이벤트를 생성하고; ChatGPT는 MCP‑SSE로 이를 받고; 그 뒤 모델이 업데이트된 컨텍스트로 여러분의 위젯을 호출합니다. 위젯은 백엔드와 직접 통신하지 않고 진행률을 렌더링합니다. MCP 이벤트를 가장 “정석”으로 사용하는 방식입니다.
옵션 2 — 하이브리드: MCP 도구 start_gift_job이 작업을 생성하여 jobId를 반환합니다. 위젯은 jobId를 받고 이후에는 HTTP로 백엔드와 직접 통신합니다. 즉, SSE 엔드포인트 /api/gift-jobs/{jobId}/events를 구독하고 필요하면 HTTP 스트림으로 보고서를 요청합니다. MCP 측에서는 별다른 추가 작업이 없습니다.
이 강의에서는 하이브리드 경로를 택합니다. App Router/Next에 더 잘 녹아들고 로컬 디버깅도 쉽기 때문입니다. 그 후 MCP 알림만으로 옮겨가도 됩니다.
7. 재연결, 타임아웃, 그리고 현실의 네트워크
여기까지는 이상적입니다. SSE나 스트림을 열고, 데이터가 흐르고, 이벤트가 오고, UX는 빛납니다. 현실에서는 네트워크가 예기치 않은 순간에 연결을 끊고, 인프라는 타임아웃을 겁니다.
무엇이 문제를 일으킬 수 있는가
SSE와 HTTP-stream을 쓰다 보면 결국 다음과 마주치게 됩니다:
- 프록시의 idle 타임아웃: “연결에 N초 동안 아무것도 흐르지 않으면 닫기”;
- 백엔드 재시작(배포, 장애);
- 사용자 쪽 불안정한 네트워크(특히 모바일).
정상적입니다. “설마”를 기대하기보다 대비하는 것이 중요합니다.
SSE 전략
SSE는 이 영역에서 장점이 많습니다:
- EventSource가 자체적으로 지연을 두고 재연결을 시도하며,
- id:와 Last-Event-ID로 누락 이벤트를 따라잡을 수 있습니다.
최소 실천 항목:
- 서버에서 주기적으로 하트비트 같은 것을 보내 idle로 간주되지 않도록 하세요. event: ping 같은 별도 이벤트나 : keep-alive 주석만으로도 충분합니다.
- 클라이언트의 onerror에서 “연결에 문제가 있어 재연결을 시도 중입니다…” 같은 명확한 상태를 보여주고, 위젯 전체를 망가뜨리지 마세요.
- 재연결 시 id:를 사용한다면, 해당 ID 이후의 새 이벤트만 서버에서 보내도록 하세요. GiftGenius 초기에는 id: 없이도 충분하며, 최근에 도착한 job.progress/job.completed로 상태를 “재구성”해도 됩니다.
HTTP‑스트림 전략
HTTP 스트림은 단일 요청이므로, 연결이 끊기면 사실상 다시 시작해야 합니다:
- 텍스트 보고서를 스트리밍한다면, “전체 보고서를 받지 못했습니다. 다시 시도해 주세요.”라고 안내하고 처음부터 다시 시작해도 됩니다.
- 구조화된 데이터(NDJSON)를 스트리밍한다면 resume 메커니즘을 고려하세요: 예를 들어 요청에 offset이나 cursor를 전달해 이어서 받도록 합니다.
초기에는 복잡하게 가지 말고 단순 정책으로 시작하세요: 응답 스트림이 끝나기 전에 끊기면 받은 부분만 보여주고, “보고서 이어 받기” 버튼으로 새 요청을 보내는 식입니다.
중요한 것은 사용자를 “영원한 대기 상태”에 두지 않는 것입니다.
8. GiftGenius에 적용: 처음부터 끝까지의 시나리오
이제 SSE, HTTP‑스트림, MCP와의 두 가지 아키텍처를 GiftGenius의 실제 시나리오로 묶어 보겠습니다 — 사용자의 요청부터 최종 보고서까지.
사용자가 ChatGPT에 이렇게 요청합니다: “보드게임 팬을 위한 선물을 골라줘, 예산은 100달러까지.” 모델은 GiftGenius를 호출하기로 결정합니다. 앱/에이전트는 여러분의 MCP 서버에 tool‑call start_gift_job을 보냅니다. 서버는 다음을 수행합니다:
- DB에 작업을 기록하고,
- 내부 큐로 보냅니다(큐와 워커의 자세한 내용은 다음 강의에서 다룹니다. 지금은 “누군가” 수행한다고 가정),
- tool‑call 응답으로 jobId를 동기적으로 반환합니다.
GiftGenius 위젯은 jobId가 담긴 ToolOutput을 받아 다음 컴포넌트를 렌더링합니다:
function GiftGeniusRoot({ jobId }: { jobId: string }) {
return (
<div>
<h2>완벽한 선물을 찾는 중...</h2>
<GiftJobProgress jobId={jobId} />
<GiftReport />
</div>
);
}
GiftJobProgress 컴포넌트는 /api/gift-jobs/{jobId}/events에 SSE로 구독하고 진행률을 그립니다. 각 job.progress는 퍼센트를 업데이트하고, job.completed는 100%로 설정하며, 필요하면 “자세한 보고서 보기” 버튼을 활성화합니다.
GiftReport 컴포넌트는 버튼 클릭 시 POST /api/gift-report(여기에 jobId를 전달)로 요청을 보내고, 서버가 HTTP 스트림을 보내는 동안 텍스트 보고서를 점진적으로 표시합니다.
SSE 연결이 끊기면 위젯은 부드러운 경고를 표시하고, EventSource는 재연결을 시도합니다. 보고서 스트림에 문제가 생기면 사용자는 일부 보고서를 본 상태에서 “생성 계속” 또는 “다시 시도” 버튼을 볼 수 있습니다.
ChatGPT와 MCP 관점에서는 다음과 같습니다:
- MCP는 start_gift_job tool 호출과(필요하다면) 작업 상태 알림을 봅니다;
- 스트림 중심 UX는 주로 위젯과 여러분의 백엔드 간 HTTP 레벨에서 구현됩니다.
9. SSE와 HTTP‑스트림에서 흔한 실수
오류 №1: SSE와 HTTP‑stream을 “같은 것”으로 간주하기.
내부적으로는 둘 다 HTTP와 chunked 응답을 사용하지만, 시맨틱스는 크게 다릅니다. SSE는 언제든 올 수 있는 독립 이벤트의 구독이고, 클라이언트는 미리 알지 못합니다. HTTP 스트림은 시간에 걸쳐 분할된 “특정 요청에 대한 단일 응답”입니다. 여러 jobId에 대한 구독을 하나의 HTTP 스트림으로 구현하려 들면, 바이트 위에 자체 프로토콜을 발명해야 하고, 결국 SSE의 절반을 다시 만들게 됩니다.
오류 №2: SSE의 자동 재연결을 무시하고 멱등성을 고려하지 않기.
많은 이들이 “단순한” SSE 서버를 작성합니다. data: ...만 보내고 표준 id:(Last-Event-ID용)도, 페이로드의 애플리케이션 event_id도 넣지 않습니다. 그러다 첫 연결 끊김과 재연결에서 이벤트 중복이 쌓입니다. 생각해 둔 event_id와 “이 이벤트를 이미 본 적 있다”는 로직 없이, 클라이언트 핸들러는 상태를 두 번 갱신하고 같은 job.completed를 두 번 보여주거나, 더 나쁘게는 결제를 두 번 차감/보너스를 두 번 적립할 위험이 있습니다.
오류 №3: 워커의 모든 자잘한 변화마다 SSE 이벤트를 보내기.
진행률을 매 밀리초마다 SSE로 보내면, 사용자를 부드러운 애니메이션으로 기쁘게 하기보다는 네트워크와 클라이언트를 먼저 지치게 만들 가능성이 큽니다. 업데이트를 집계해 200–500ms마다 보내거나, 단계 전환 시에만 보내는 편이 훨씬 합리적입니다. 스로틀링과 백프레셔는 추후 더 다루겠지만, 지금 단계에서도 이벤트 빈도를 고민해야 합니다.
오류 №4: 명시적 포맷 없이 HTTP 스트림 위에 복잡한 프로토콜을 만들기.
전형적 안티 패턴: 구분자 없이 JSON을 스트리밍하고, 어디서 객체가 끝나고 다음이 시작되는지 “추측”하려 합니다. 혹은 하나의 스트림에 텍스트와 JSON을 섞기도 합니다. 가장 좋은 방법은 단순하고 명확한 포맷을 선택하는 것입니다: 줄 단위 텍스트, NDJSON(한 줄에 한 JSON 객체), 혹은 명시적 구분자. 그러면 클라이언트 파서는 온전하게 유지됩니다.
오류 №5: 타임아웃과 “영원한” 스트림을 잊기.
가끔 개발자는 5–10분 동안 아무것도 보내지 않는 SSE 엔드포인트를 만들고는, 사용자와 서버 사이(로드밸런서, API 게이트웨이, 기업용 프록시 등) 어딘가에서 연결이 끊기는 것을 보고 놀랍니다. 주기적인 하트비트 이벤트나 주석은 연결을 살아 있게 유지하고, 끊김을 제때 감지하게 해 줍니다. 그리고 HTTP 스트림은 무한 응답이 되어서는 안 됩니다 — 영구 구독에는 SSE가 있습니다.
오류 №6: 제대로 된 이벤트 대신 HTTP 스트림으로 복잡한 pub/sub을 만들려 하기.
“스트림 하나로 진행률, partial results, 임의의 로그까지 다 보내자”는 유혹이 생길 수 있습니다. 결과적으로 클라이언트에는 각 청크를 분석해 어떤 jobId에 속하는지 결정하는 복잡한 멀티플렉서가 생깁니다. 대부분의 경우, job.progress, job.completed 같은 이벤트 타입과 작업별 채널이 있는 SSE를 사용하는 것이, HTTP 스트림 위에 자작 메가 프로토콜을 만드는 것보다 더 단순하고 안정적입니다.
오류 №7: “스트림은 절대 끊기지 않는다”는 가정에 UX를 묶어두기.
어떤 스트림이든 언젠가는 끊깁니다. 그때 위젯이 끝없이 도는 진행바만 남기고 아무 행동도 못 하게 된다면 — 사용자는 이를 “망가진” UX로 받아들입니다. “연결이 끊긴 것 같습니다. 선물 추천을 다시 시작해 보세요.”처럼 간단한 메시지와 “다시 시도” 버튼만 있어도, 침묵보다는 훨씬 낫습니다.
GO TO FULL VERSION