대용량 응답, 점진적 렌더링, AI 토큰 스트림 등에서는 “기다리지 말고 조금씩 보내기”가 핵심입니다. React 18의 스트리밍 SSR과 서버의 ReadableStream/SSE를 조합하면 초기 구독(First Paint)을 앞당기고 체감 속도를 크게 개선할 수 있습니다. 실무에서 바로 쓸 수 있는 패턴과 코드로 정리합니다.
1. 언제 스트리밍이 유리한가
- API가 느리거나 토큰 단위로 생성되는 경우(예: LLM) - 페이지의 일부 섹션만 느린 경우(Suspense로 조각 렌더링) - 모바일/저대역에서 FCP, INP, TTFB를 개선해야 하는 경우
2. HTML 스트리밍: React 18 + Suspense(Next.js App Router)
App Router(서버 컴포넌트)를 사용하면 Suspense 경계 단위로 HTML을 스트리밍합니다. 셸을 먼저 보내고, 느린 섹션은 준비되는 즉시 이어서 전송합니다.
// app/page.jsx (Next.js 13+ App Router)
import { Suspense } from 'react';
async function SlowSection() {
const data = await fetch('https://example.com/slow', { cache: 'no-store' })
.then((r) => r.json());
return (
<section>
<h2>느린 섹션</h2>
<p>메시지: {data.message}</p>
</section>
);
}
export default function Page() {
return (
<main>
<h1>스트리밍 SSR 데모</h1>
<Suspense fallback={<div>섹션 로딩 중...</div>}>
<SlowSection />
</Suspense>
</main>
);
}
팁: Suspense 경계는 작게 쪼개고, <head>나 크리티컬 CSS/메타는 블로킹하지 않도록 분리합니다. 캐시는 cache: 'no-store' 혹은 revalidate로 명확히 선언합니다.
3. 서버 데이터 스트리밍: SSE Route Handler(Edge 권장)
SSE(Server-Sent Events)는 텍스트 기반 단방향 스트리밍으로 브라우저의 EventSource로 쉽게 소비할 수 있습니다. Next.js Route Handler에서 ReadableStream으로 구현합니다.
// app/api/stream/route.js
export const runtime = 'edge'; // 지리적으로 가까운 PoP에서 응답(지연 최소화)
export async function GET() {
const encoder = new TextEncoder();
let timer = null;
let heartbeat = null;
const stream = new ReadableStream({
start(controller) {
// SSE 프로토콜 헤더: 'data: ...\n\n' / 주석 하트비트 ':...\n\n'
function send(obj) {
const chunk = `data: ${JSON.stringify(obj)}\n\n`;
controller.enqueue(encoder.encode(chunk));
}
// 초기 청크
send({ hello: 'world', ts: Date.now() });
// 주기적 데이터 푸시(예시)
timer = setInterval(() => {
send({ ts: Date.now() });
}, 1000);
// 프록시/중간 캐시 유지를 위한 하트비트
heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(`:keep-alive ${Date.now()}\n\n`));
}, 15000);
},
cancel() {
clearInterval(timer);
clearInterval(heartbeat);
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive'
}
});
}
중요: Edge 런타임을 사용하면 지리적으로 가까운 리전에 배치되어 지연이 크게 줄어듭니다. 인증이 필요하면 쿠키 기반 세션 또는 Bearer 토큰을 검증하고, CORS가 필요한 경우 Origin/credentials를 명확히 허용하세요.
4. 클라이언트: EventSource 훅으로 간단하게
브라우저는 SSE를 기본 지원합니다. 커스텀 훅으로 구독/리트라이를 캡슐화합니다.
// hooks/useSSE.js
import { useEffect, useRef, useState } from 'react';
export function useSSE(url, { withCredentials = false } = {}) {
const [messages, setMessages] = useState([]);
const esRef = useRef(null);
useEffect(() => {
let retryId = null;
function connect() {
const es = new EventSource(url, { withCredentials });
esRef.current = es;
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
setMessages((prev) => [...prev, data]);
} catch {}
};
es.onerror = () => {
es.close();
// 지수 백오프(간단 버전)
retryId = setTimeout(connect, 2000);
};
}
connect();
return () => {
clearTimeout(retryId);
esRef.current?.close();
};
}, [url, withCredentials]);
return messages;
}
// 사용 예시
// function StreamView() {
// const data = useSSE('/api/stream');
// return (<ul>{data.map((d, i) => <li key={i}>{d.ts}</li>)}</ul>);
// }
팁: 목록 렌더링에는 안정적인 key를 사용하세요(id, 서버에서 부여). 모바일 사파리에서 백그라운드 탭은 연결이 끊길 수 있으니 재접속 로직을 둡니다.
5. 대안: fetch + NDJSON 스트리밍(커스텀 파서)
양방향 제어가 필요 없고, 브라우저 지원 범위를 넓히려면 NDJSON 스트리밍도 실용적입니다.
// app/api/ndjson/route.js
export const runtime = 'edge';
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const write = (obj) => controller.enqueue(encoder.encode(JSON.stringify(obj) + '\n'));
write({ step: 1 });
await new Promise((r) => setTimeout(r, 500));
write({ step: 2 });
await new Promise((r) => setTimeout(r, 500));
write({ done: true });
controller.close();
}
});
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson; charset=utf-8',
'Cache-Control': 'no-cache'
}
});
}
// 클라이언트: Fetch Stream 파서
async function streamFetch(url, onItem, signal) {
const res = await fetch(url, { signal });
if (!res.body) throw new Error('ReadableStream 미지원');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line) continue;
onItem(JSON.parse(line));
}
}
if (buf.trim()) onItem(JSON.parse(buf));
}
// 사용 예시
// const ac = new AbortController();
// streamFetch('/api/ndjson', item => setState(p => [...p, item]), ac.signal);
6. 에러, 중단, 보안, 캐싱 체크리스트
- 서버: try/catch로 오류 시 controller.error(e) 호출, 마지막으로 종료 청크를 보내 상태를 명확히 합니다. - 중단: ReadableStream.cancel에서 타이머/리소스를 해제합니다. - 하트비트: 15~30초 간격으로 주석 프레임(:keep-alive) 또는 ping을 보내 중간 프록시 타임아웃을 방지합니다. - 재연결: SSE는 자동 재접속 기능이 있으나 onerror에서 백오프를 커스터마이즈하세요. event id/Last-Event-ID로 재전송 지점을 복구할 수 있습니다. - 인증: 토큰은 짧게, 회전 키 사용. 쿠키 기반이면 SameSite와 CSRF 고려. CORS는 Origin 화이트리스트와 credentials 플래그 일치 필수. - 캐싱: 스트림은 대개 no-cache. Cloudflare/NGINX는 response buffering을 끄거나 줄여야 즉시 전송됩니다. - GEO: Edge 런타임, 지역별 데이터 소스 근접 배치, DNS 레이턴시를 줄이기 위한 preconnect(리소스 힌트) 적용.
7. 측정과 최적화
- Core Web Vitals: FCP/TTFB를 모니터링해 스트리밍 전/후를 비교합니다. - Server-Timing 헤더로 백엔드 단계별 시간을 노출하여 병목을 식별합니다. - 청크 크기: 너무 작으면 오버헤드 증가, 너무 크면 체감 저하. 1~8KB 단위로 실측 후 조정합니다. - Suspense 경계: UX 의미 단위로 쪼개고, 폴백 UI를 skeleton으로 통일합니다.
8. 마무리: 실무 적용 가이드
초기 렌더는 Suspense로 스트리밍 SSR, 이후 실시간 업데이트는 SSE/NDJSON으로 이어받는 구성은 단순하면서도 효과가 큽니다. 먼저 가장 느린 API부터 스트리밍으로 바꾸고, Edge에 배포해 지연을 줄인 다음, 하트비트/재연결/측정 체계를 넣으면 유지보수 비용 대비 ROI가 높습니다.
'React' 카테고리의 다른 글
| React에서 이미지 스프라이트(Sprite) 기법 적용하기 (0) | 2026.06.30 |
|---|---|
| React 앱에서 사용자 입력 예측(Auto-complete) 시스템 구현 (0) | 2026.06.29 |
| React 앱에서 브라우저 Battery Status API 활용하기 (0) | 2026.06.26 |
| React에서 멀티 윈도우 브라우징 데이터 동기화하기 (0) | 2026.06.25 |
| React 앱에서 CSS Grid를 활용한 반응형 레이아웃 구현 (0) | 2026.06.25 |