본문 바로가기

React

React에서 서버사이드 데이터 스트리밍 처리하기

대용량 응답, 점진적 렌더링, 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가 높습니다.