๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
useSSEStream์€ Server-Sent Events(SSE)๋ฅผ ํ†ตํ•œ ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ์ŠคํŠธ๋ฆฌ๋ฐ์„ ๊ด€๋ฆฌํ•˜๋Š” ํ›…์ž…๋‹ˆ๋‹ค. ์ž๋™ ์žฌ์—ฐ๊ฒฐ, ํƒ€์ž… ์•ˆ์ „ํ•œ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ, ์—ฐ๊ฒฐ ์ƒํƒœ ์ถ”์  ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

ํ•ต์‹ฌ ๊ธฐ๋Šฅ

์‹ค์‹œ๊ฐ„ ์ŠคํŠธ๋ฆฌ๋ฐ

์„œ๋ฒ„์—์„œ ํด๋ผ์ด์–ธํŠธ๋กœ๋‹จ๋ฐฉํ–ฅ ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ

์ž๋™ ์žฌ์—ฐ๊ฒฐ

์—ฐ๊ฒฐ ์‹คํŒจ ์‹œ ์ž๋™ ์žฌ์‹œ๋„์„ค์ • ๊ฐ€๋Šฅํ•œ ํšŸ์ˆ˜/๊ฐ„๊ฒฉ

ํƒ€์ž… ์•ˆ์ „

์ด๋ฒคํŠธ๋ณ„ ํƒ€์ž… ์ง€์ •์ œ๋„ค๋ฆญ ๊ธฐ๋ฐ˜

์—ฐ๊ฒฐ ์ƒํƒœ

isConnected, error ์ถ”์ UI ํ”ผ๋“œ๋ฐฑ ๊ฐ€๋Šฅ

SSE๋ž€?

Server-Sent Events๋Š” ์„œ๋ฒ„์—์„œ ํด๋ผ์ด์–ธํŠธ๋กœ ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹จ๋ฐฉํ–ฅ์œผ๋กœ ์ „์†กํ•˜๋Š” ๊ธฐ์ˆ ์ž…๋‹ˆ๋‹ค.

WebSocket vs SSE

ํŠน์ง•WebSocketSSE
ํ†ต์‹  ๋ฐฉํ–ฅ์–‘๋ฐฉํ–ฅ (Full-duplex)๋‹จ๋ฐฉํ–ฅ (Server โ†’ Client)
ํ”„๋กœํ† ์ฝœWS/WSSHTTP/HTTPS
์žฌ์—ฐ๊ฒฐ์ˆ˜๋™ ๊ตฌํ˜„ ํ•„์š”๋ธŒ๋ผ์šฐ์ € ์ž๋™ ์žฌ์—ฐ๊ฒฐ
๋ณต์žก๋„๋†’์Œ๋‚ฎ์Œ
์‚ฌ์šฉ ์ผ€์ด์Šค์ฑ„ํŒ…, ๊ฒŒ์ž„์•Œ๋ฆผ, ๋กœ๊ทธ, ์ง„ํ–‰ ์ƒํ™ฉ
SSE๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•˜๋Š” ๊ฒฝ์šฐ
  • ์„œ๋ฒ„์—์„œ ํด๋ผ์ด์–ธํŠธ๋กœ๋งŒ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณด๋‚ด๋Š” ๊ฒฝ์šฐ
  • AI ์‘๋‹ต ์ŠคํŠธ๋ฆฌ๋ฐ (ChatGPT ์Šคํƒ€์ผ)
  • ์‹ค์‹œ๊ฐ„ ๋กœ๊ทธ ๋ชจ๋‹ˆํ„ฐ๋ง
  • ํŒŒ์ผ ์—…๋กœ๋“œ/์ฒ˜๋ฆฌ ์ง„ํ–‰ ์ƒํ™ฉ
  • ์„œ๋ฒ„ ์ด๋ฒคํŠธ ์•Œ๋ฆผ

๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

Import

import { useSSEStream } from "@sonamu-kit/react-components";
useSSEStream์€ services.generated.ts๊ฐ€ ์•„๋‹ˆ๋ผ @sonamu-kit/react-components์—์„œ importํ•ฉ๋‹ˆ๋‹ค.

ํƒ€์ž… ์ •์˜

// ์ด๋ฒคํŠธ ํƒ€์ž… ์ •์˜
type AIStreamEvents = {
  token: { text: string };     // token ์ด๋ฒคํŠธ: ํ…์ŠคํŠธ ์กฐ๊ฐ
  usage: { tokens: number };   // usage ์ด๋ฒคํŠธ: ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰
  end: string;                  // end ์ด๋ฒคํŠธ: ์™„๋ฃŒ ์‹ ํ˜ธ
};

๊ธฐ๋ณธ ์‚ฌ์šฉ

import { useState } from "react";
import { useSSEStream } from "@sonamu-kit/react-components";

export function AIChat() {
  const [response, setResponse] = useState("");
  const [totalTokens, setTotalTokens] = useState(0);

  const { isConnected, error, isEnded } = useSSEStream<AIStreamEvents>(
    "/api/ai/stream",  // SSE ์—”๋“œํฌ์ธํŠธ
    { prompt: "Hello, AI!" },  // ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ
    {
      // ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ
      token: (data) => {
        // ํ…์ŠคํŠธ ์กฐ๊ฐ์ด ์˜ฌ ๋•Œ๋งˆ๋‹ค ์ถ”๊ฐ€
        setResponse((prev) => prev + data.text);
      },
      usage: (data) => {
        // ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ์—…๋ฐ์ดํŠธ
        setTotalTokens(data.tokens);
      },
      end: () => {
        // ์ŠคํŠธ๋ฆฌ๋ฐ ์™„๋ฃŒ
        console.log("Stream ended");
      },
    },
    {
      enabled: true,      // ์ž๋™ ์—ฐ๊ฒฐ
      retry: 3,           // ์ตœ๋Œ€ 3๋ฒˆ ์žฌ์‹œ๋„
      retryInterval: 3000, // 3์ดˆ ๊ฐ„๊ฒฉ
    }
  );

  return (
    <div>
      {isConnected && <div className="text-green-500">์—ฐ๊ฒฐ๋จ</div>}
      {error && <div className="text-red-500">์—๋Ÿฌ: {error}</div>}
      <div className="whitespace-pre-wrap">{response}</div>
      {isEnded && <div>ํ† ํฐ ์‚ฌ์šฉ: {totalTokens}</div>}
    </div>
  );
}

API ๋ ˆํผ๋Ÿฐ์Šค

useSSEStream

function useSSEStream<T extends Record<string, any>>(
  url: string,
  params: Record<string, any>,
  handlers: {
    [K in keyof T]?: (data: T[K]) => void;
  },
  options?: SSEStreamOptions
): SSEStreamState

ํŒŒ๋ผ๋ฏธํ„ฐ

url

SSE ์—”๋“œํฌ์ธํŠธ URL์ž…๋‹ˆ๋‹ค.
useSSEStream("/api/ai/stream", params, handlers);

params

URL ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ „๋‹ฌ๋  ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค.
useSSEStream(
  "/api/ai/stream",
  {
    prompt: "Hello",
    model: "gpt-4",
    temperature: 0.7,
  },
  handlers
);

// ์‹ค์ œ ์š”์ฒญ URL:
// /api/ai/stream?prompt=Hello&model=gpt-4&temperature=0.7

handlers

์ด๋ฒคํŠธ๋ณ„ ํ•ธ๋“ค๋Ÿฌ ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค.
{
  token: (data: { text: string }) => {
    // token ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ
  },
  usage: (data: { tokens: number }) => {
    // usage ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ
  },
  end: () => {
    // end ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ
  },
}
ํŠน์ˆ˜ ์ด๋ฒคํŠธ:
  • end: ์„œ๋ฒ„๊ฐ€ ๋ช…์‹œ์ ์œผ๋กœ ์ŠคํŠธ๋ฆผ ์ข…๋ฃŒ๋ฅผ ์•Œ๋ฆด ๋•Œ
  • message: ์ด๋ฒคํŠธ ํƒ€์ž…์ด ์ง€์ •๋˜์ง€ ์•Š์€ ๊ธฐ๋ณธ ๋ฉ”์‹œ์ง€

options

์˜ต์…˜ํƒ€์ž…๊ธฐ๋ณธ๊ฐ’์„ค๋ช…
enabledbooleantrue์ž๋™ ์—ฐ๊ฒฐ ์—ฌ๋ถ€
retrynumber3์ตœ๋Œ€ ์žฌ์‹œ๋„ ํšŸ์ˆ˜
retryIntervalnumber3000์žฌ์‹œ๋„ ๊ฐ„๊ฒฉ (ms)

๋ฐ˜ํ™˜๊ฐ’ (SSEStreamState)

type SSEStreamState = {
  isConnected: boolean;  // ํ˜„์žฌ ์—ฐ๊ฒฐ ์ƒํƒœ
  error: string | null;  // ์—๋Ÿฌ ๋ฉ”์‹œ์ง€
  retryCount: number;    // ํ˜„์žฌ ์žฌ์‹œ๋„ ํšŸ์ˆ˜
  isEnded: boolean;      // ์ •์ƒ ์ข…๋ฃŒ ์—ฌ๋ถ€
};

์‹ค์ „ ์˜ˆ์ œ

AI ์‘๋‹ต ์ŠคํŠธ๋ฆฌ๋ฐ

import { useState } from "react";
import { useSSEStream } from "@sonamu-kit/react-components";
import { Button, Textarea } from "@sonamu-kit/react-components/components";

type AIStreamEvents = {
  token: { text: string };
  end: string;
};

export function AIChat() {
  const [prompt, setPrompt] = useState("");
  const [response, setResponse] = useState("");
  const [enabled, setEnabled] = useState(false);

  const { isConnected, error, isEnded } = useSSEStream<AIStreamEvents>(
    "/api/ai/chat",
    { prompt },
    {
      token: (data) => {
        setResponse((prev) => prev + data.text);
      },
      end: () => {
        console.log("AI ์‘๋‹ต ์™„๋ฃŒ");
        setEnabled(false);  // ์—ฐ๊ฒฐ ์ข…๋ฃŒ
      },
    },
    {
      enabled,
      retry: 1,  // AI ์š”์ฒญ์€ ์žฌ์‹œ๋„ 1ํšŒ๋งŒ
      retryInterval: 1000,
    }
  );

  const handleSubmit = () => {
    setResponse("");  // ์ด์ „ ์‘๋‹ต ์ดˆ๊ธฐํ™”
    setEnabled(true);  // ์ŠคํŠธ๋ฆผ ์‹œ์ž‘
  };

  return (
    <div className="space-y-4">
      <Textarea
        value={prompt}
        onValueChange={setPrompt}
        placeholder="์งˆ๋ฌธ์„ ์ž…๋ ฅํ•˜์„ธ์š”"
        rows={3}
      />

      <Button
        onClick={handleSubmit}
        disabled={isConnected || !prompt}
      >
        {isConnected ? "์‘๋‹ต ์ค‘..." : "์งˆ๋ฌธํ•˜๊ธฐ"}
      </Button>

      {error && (
        <div className="p-4 bg-red-50 text-red-700 rounded">
          {error}
        </div>
      )}

      {response && (
        <div className="p-4 bg-gray-50 rounded">
          <div className="whitespace-pre-wrap">{response}</div>
          {isConnected && <span className="animate-pulse">โ–Š</span>}
        </div>
      )}

      {isEnded && (
        <div className="text-sm text-gray-500">
          ์‘๋‹ต ์™„๋ฃŒ
        </div>
      )}
    </div>
  );
}

ํŒŒ์ผ ์—…๋กœ๋“œ ์ง„ํ–‰ ์ƒํ™ฉ

type UploadEvents = {
  progress: { percent: number; loaded: number; total: number };
  complete: { fileUrl: string };
  error: { message: string };
};

export function FileUploader() {
  const [file, setFile] = useState<File | null>(null);
  const [uploadId, setUploadId] = useState<string | null>(null);
  const [progress, setProgress] = useState(0);
  const [fileUrl, setFileUrl] = useState<string | null>(null);

  const { isConnected, error } = useSSEStream<UploadEvents>(
    "/api/file/upload/progress",
    { uploadId: uploadId ?? "" },
    {
      progress: (data) => {
        setProgress(data.percent);
      },
      complete: (data) => {
        setFileUrl(data.fileUrl);
      },
      error: (data) => {
        alert(data.message);
      },
    },
    {
      enabled: uploadId !== null,
      retry: 5,
      retryInterval: 2000,
    }
  );

  const handleUpload = async () => {
    if (!file) return;

    // 1. ์—…๋กœ๋“œ ์‹œ์ž‘ (uploadId ๋ฐœ๊ธ‰)
    const formData = new FormData();
    formData.append("file", file);

    const response = await fetch("/api/file/upload", {
      method: "POST",
      body: formData,
    });

    const { uploadId } = await response.json();

    // 2. SSE๋กœ ์ง„ํ–‰ ์ƒํ™ฉ ์ถ”์ 
    setUploadId(uploadId);
  };

  return (
    <div>
      <input
        type="file"
        onChange={(e) => setFile(e.target.files?.[0] ?? null)}
      />

      <Button onClick={handleUpload} disabled={!file || isConnected}>
        ์—…๋กœ๋“œ
      </Button>

      {isConnected && (
        <div>
          <div className="w-full bg-gray-200 rounded-full h-2">
            <div
              className="bg-blue-600 h-2 rounded-full transition-all"
              style={{ width: `${progress}%` }}
            />
          </div>
          <p>{progress}%</p>
        </div>
      )}

      {fileUrl && (
        <div>
          ์—…๋กœ๋“œ ์™„๋ฃŒ: <a href={fileUrl}>{fileUrl}</a>
        </div>
      )}

      {error && <div className="text-red-500">{error}</div>}
    </div>
  );
}

์‹ค์‹œ๊ฐ„ ๋กœ๊ทธ ๋ชจ๋‹ˆํ„ฐ๋ง

type LogEvents = {
  log: { level: string; message: string; timestamp: string };
};

export function LogViewer() {
  const [logs, setLogs] = useState<Array<{ level: string; message: string; timestamp: string }>>([]);
  const [enabled, setEnabled] = useState(true);

  const { isConnected, error } = useSSEStream<LogEvents>(
    "/api/logs/stream",
    { level: "info" },
    {
      log: (data) => {
        setLogs((prev) => [...prev, data].slice(-100));  // ์ตœ๊ทผ 100๊ฐœ๋งŒ ์œ ์ง€
      },
    },
    {
      enabled,
      retry: 10,  // ๋กœ๊ทธ๋Š” ๊ณ„์† ์žฌ์—ฐ๊ฒฐ
      retryInterval: 5000,
    }
  );

  return (
    <div>
      <div className="flex items-center gap-2 mb-4">
        <Button
          variant={enabled ? "red" : "default"}
          onClick={() => setEnabled(!enabled)}
        >
          {enabled ? "์ค‘์ง€" : "์‹œ์ž‘"}
        </Button>

        {isConnected && <span className="text-green-500">โ— ์—ฐ๊ฒฐ๋จ</span>}
        {error && <span className="text-red-500">โ— {error}</span>}
      </div>

      <div className="bg-black text-green-400 p-4 rounded font-mono text-sm h-96 overflow-y-auto">
        {logs.map((log, i) => (
          <div key={i} className="mb-1">
            <span className="text-gray-500">[{log.timestamp}]</span>{" "}
            <span className={log.level === "error" ? "text-red-400" : ""}>
              {log.level.toUpperCase()}
            </span>{" "}
            {log.message}
          </div>
        ))}
      </div>
    </div>
  );
}

๋ฐฑ์—”๋“œ ๊ตฌํ˜„

SSE ์—”๋“œํฌ์ธํŠธ๋Š” @api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ์˜ sse: true ์˜ต์…˜์œผ๋กœ ๋งŒ๋“ญ๋‹ˆ๋‹ค.

๊ธฐ๋ณธ SSE API

// ai.model.ts (๋ฐฑ์—”๋“œ)
import { api } from "sonamu";

class AIModelClass {
  @api({
    httpMethod: "GET",
    sse: true,  // SSE ํ™œ์„ฑํ™”
    guards: ["login"],
  })
  async *chat(params: { prompt: string }) {
    // ์ œ๋„ˆ๋ ˆ์ดํ„ฐ ํ•จ์ˆ˜๋กœ ์ŠคํŠธ๋ฆฌ๋ฐ
    const stream = await openai.chat.completions.create({
      model: "gpt-4",
      messages: [{ role: "user", content: params.prompt }],
      stream: true,
    });

    for await (const chunk of stream) {
      const text = chunk.choices[0]?.delta?.content ?? "";
      if (text) {
        // 'token' ์ด๋ฒคํŠธ ์ „์†ก
        yield { event: "token", data: { text } };
      }
    }

    // 'end' ์ด๋ฒคํŠธ ์ „์†ก
    yield { event: "end", data: "complete" };
  }
}

yield ํ˜•์‹

yield {
  event: "์ด๋ฒคํŠธ๋ช…",  // ํ•„์ˆ˜
  data: { /* ๋ฐ์ดํ„ฐ */ },  // ํ•„์ˆ˜
};
์˜ˆ์‹œ:
yield { event: "token", data: { text: "Hello" } };
yield { event: "usage", data: { tokens: 42 } };
yield { event: "end", data: "complete" };

๊ณ ๊ธ‰ ๊ธฐ๋Šฅ

์กฐ๊ฑด๋ถ€ ์—ฐ๊ฒฐ

const [shouldConnect, setShouldConnect] = useState(false);

const { isConnected } = useSSEStream(
  "/api/notifications",
  {},
  handlers,
  {
    enabled: shouldConnect,  // false๋ฉด ์—ฐ๊ฒฐํ•˜์ง€ ์•Š์Œ
  }
);

// ์‚ฌ์šฉ์ž ์•ก์…˜์œผ๋กœ ์—ฐ๊ฒฐ ์‹œ์ž‘
<Button onClick={() => setShouldConnect(true)}>
  ์•Œ๋ฆผ ๊ตฌ๋…
</Button>

๋™์  ํŒŒ๋ผ๋ฏธํ„ฐ

const [filter, setFilter] = useState("all");

useSSEStream(
  "/api/logs/stream",
  { level: filter },  // filter๊ฐ€ ๋ฐ”๋€Œ๋ฉด ์žฌ์—ฐ๊ฒฐ
  handlers
);

// filter๊ฐ€ ๋ณ€๊ฒฝ๋˜๋ฉด ์ž๋™์œผ๋กœ ์—ฐ๊ฒฐ์ด ๋Š๊ธฐ๊ณ  ์ƒˆ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์žฌ์—ฐ๊ฒฐ๋จ

์ˆ˜๋™ ์žฌ์—ฐ๊ฒฐ

const [reconnectTrigger, setReconnectTrigger] = useState(0);

useSSEStream(
  "/api/stream",
  { trigger: reconnectTrigger },  // trigger ๋ณ€๊ฒฝ ์‹œ ์žฌ์—ฐ๊ฒฐ
  handlers
);

// ์ˆ˜๋™ ์žฌ์—ฐ๊ฒฐ
<Button onClick={() => setReconnectTrigger(prev => prev + 1)}>
  ์žฌ์—ฐ๊ฒฐ
</Button>

์—ฌ๋Ÿฌ ์ŠคํŠธ๋ฆผ ๋™์‹œ ์‚ฌ์šฉ

// ์ŠคํŠธ๋ฆผ 1: AI ์‘๋‹ต
const stream1 = useSSEStream<AIEvents>(
  "/api/ai/chat",
  { prompt: "Hello" },
  {
    token: (data) => setAiResponse(prev => prev + data.text),
  }
);

// ์ŠคํŠธ๋ฆผ 2: ์•Œ๋ฆผ
const stream2 = useSSEStream<NotificationEvents>(
  "/api/notifications",
  {},
  {
    notification: (data) => showNotification(data),
  }
);

์ฃผ์˜์‚ฌํ•ญ

1. EventSource ์ œํ•œ

  • HTTP/2์—์„œ๋Š” ๋ธŒ๋ผ์šฐ์ €๋‹น ์ตœ๋Œ€ 6๊ฐœ ์—ฐ๊ฒฐ ์ œํ•œ
  • ๋„ˆ๋ฌด ๋งŽ์€ SSE ์—ฐ๊ฒฐ์„ ๋™์‹œ์— ์—ด์ง€ ๋งˆ์„ธ์š”

2. ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ

์ŠคํŠธ๋ฆฌ๋ฐ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฌดํ•œ์ • ๋ˆ„์ ํ•˜์ง€ ๋งˆ์„ธ์š”.
// โŒ ๋‚˜์œ ์˜ˆ: ๋ฉ”๋ชจ๋ฆฌ ๋ฌดํ•œ ์ฆ๊ฐ€
const [logs, setLogs] = useState<string[]>([]);

useSSEStream("/api/logs", {}, {
  log: (data) => {
    setLogs(prev => [...prev, data.message]);  // ๊ณ„์† ์ฆ๊ฐ€
  },
});

// โœ… ์ข‹์€ ์˜ˆ: ์ตœ๊ทผ N๊ฐœ๋งŒ ์œ ์ง€
useSSEStream("/api/logs", {}, {
  log: (data) => {
    setLogs(prev => [...prev, data.message].slice(-100));  // ์ตœ๊ทผ 100๊ฐœ๋งŒ
  },
});

3. ์—๋Ÿฌ ์ฒ˜๋ฆฌ

const { error, isEnded } = useSSEStream(url, params, {
  error: (data) => {
    // ์„œ๋ฒ„์—์„œ ๋ณด๋‚ธ ์—๋Ÿฌ ์ด๋ฒคํŠธ
    console.error("Server error:", data);
  },
});

// ์—ฐ๊ฒฐ ์‹คํŒจ ์—๋Ÿฌ
if (error) {
  console.error("Connection error:", error);
}

4. ์ปดํฌ๋„ŒํŠธ ์–ธ๋งˆ์šดํŠธ

์ปดํฌ๋„ŒํŠธ๊ฐ€ ์–ธ๋งˆ์šดํŠธ๋˜๋ฉด ์ž๋™์œผ๋กœ ์—ฐ๊ฒฐ์ด ๋‹ซํž™๋‹ˆ๋‹ค.
useEffect(() => {
  // useSSEStream์€ ์ž๋™์œผ๋กœ cleanup ์ฒ˜๋ฆฌ๋จ
  return () => {
    // ๋ณ„๋„ cleanup ๋ถˆํ•„์š”
  };
}, []);

๋ฌธ์ œ ํ•ด๊ฒฐ

SSE๊ฐ€ ์—ฐ๊ฒฐ๋˜์ง€ ์•Š์Œ

์›์ธ: CORS ์„ค์ • ๋˜๋Š” ์—”๋“œํฌ์ธํŠธ ์˜ค๋ฅ˜ ํ•ด๊ฒฐ:
// ๋ฐฑ์—”๋“œ: sonamu.config.ts
export default {
  api: {
    cors: {
      origin: "http://localhost:5173",
      credentials: true,
    },
  },
};

์žฌ์—ฐ๊ฒฐ์ด ๊ณ„์† ์‹คํŒจํ•จ

์›์ธ: ๋ฐฑ์—”๋“œ ์„œ๋ฒ„๊ฐ€ ๋‹ค์šด๋˜์—ˆ๊ฑฐ๋‚˜ ๋„คํŠธ์›Œํฌ ๋ฌธ์ œ ํ•ด๊ฒฐ: retry์™€ retryInterval์„ ์กฐ์ •ํ•˜๊ฑฐ๋‚˜, ์—๋Ÿฌ UI๋ฅผ ํ‘œ์‹œํ•˜์„ธ์š”.

๋ฐ์ดํ„ฐ๊ฐ€ ํŒŒ์‹ฑ๋˜์ง€ ์•Š์Œ

์›์ธ: ์„œ๋ฒ„์—์„œ JSON์ด ์•„๋‹Œ ํ˜•์‹์œผ๋กœ ๋ฐ์ดํ„ฐ ์ „์†ก ํ•ด๊ฒฐ: ๋ฐฑ์—”๋“œ์—์„œ yield { event, data: JSON๊ฐ์ฒด } ํ˜•์‹์„ ์‚ฌ์šฉํ•˜์„ธ์š”.

๊ด€๋ จ ๋ฌธ์„œ