useSSEStream์ Server-Sent Events(SSE)๋ฅผ ํตํ ์ค์๊ฐ ๋ฐ์ดํฐ ์คํธ๋ฆฌ๋ฐ์ ๊ด๋ฆฌํ๋ ํ
์
๋๋ค. ์๋ ์ฌ์ฐ๊ฒฐ, ํ์
์์ ํ ์ด๋ฒคํธ ํธ๋ค๋ฌ, ์ฐ๊ฒฐ ์ํ ์ถ์ ๊ธฐ๋ฅ์ ์ ๊ณตํฉ๋๋ค.
ํต์ฌ ๊ธฐ๋ฅ
์ค์๊ฐ ์คํธ๋ฆฌ๋ฐ
์๋ฒ์์ ํด๋ผ์ด์ธํธ๋ก๋จ๋ฐฉํฅ ์ค์๊ฐ ๋ฐ์ดํฐ
์๋ ์ฌ์ฐ๊ฒฐ
์ฐ๊ฒฐ ์คํจ ์ ์๋ ์ฌ์๋์ค์ ๊ฐ๋ฅํ ํ์/๊ฐ๊ฒฉ
ํ์
์์
์ด๋ฒคํธ๋ณ ํ์
์ง์ ์ ๋ค๋ฆญ ๊ธฐ๋ฐ
์ฐ๊ฒฐ ์ํ
isConnected, error ์ถ์ UI ํผ๋๋ฐฑ ๊ฐ๋ฅ
SSE๋?
Server-Sent Events๋ ์๋ฒ์์ ํด๋ผ์ด์ธํธ๋ก ์ค์๊ฐ ๋ฐ์ดํฐ๋ฅผ ๋จ๋ฐฉํฅ์ผ๋ก ์ ์กํ๋ ๊ธฐ์ ์
๋๋ค.
WebSocket vs SSE
| ํน์ง | WebSocket | SSE |
|---|
| ํต์ ๋ฐฉํฅ | ์๋ฐฉํฅ (Full-duplex) | ๋จ๋ฐฉํฅ (Server โ Client) |
| ํ๋กํ ์ฝ | WS/WSS | HTTP/HTTPS |
| ์ฌ์ฐ๊ฒฐ | ์๋ ๊ตฌํ ํ์ | ๋ธ๋ผ์ฐ์ ์๋ ์ฌ์ฐ๊ฒฐ |
| ๋ณต์ก๋ | ๋์ | ๋ฎ์ |
| ์ฌ์ฉ ์ผ์ด์ค | ์ฑํ
, ๊ฒ์ | ์๋ฆผ, ๋ก๊ทธ, ์งํ ์ํฉ |
SSE๋ฅผ ์ฌ์ฉํด์ผ ํ๋ ๊ฒฝ์ฐ
- ์๋ฒ์์ ํด๋ผ์ด์ธํธ๋ก๋ง ๋ฐ์ดํฐ๋ฅผ ๋ณด๋ด๋ ๊ฒฝ์ฐ
- AI ์๋ต ์คํธ๋ฆฌ๋ฐ (ChatGPT ์คํ์ผ)
- ์ค์๊ฐ ๋ก๊ทธ ๋ชจ๋ํฐ๋ง
- ํ์ผ ์
๋ก๋/์ฒ๋ฆฌ ์งํ ์ํฉ
- ์๋ฒ ์ด๋ฒคํธ ์๋ฆผ
๊ธฐ๋ณธ ์ฌ์ฉ๋ฒ
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
ํ๋ผ๋ฏธํฐ
SSE ์๋ํฌ์ธํธ URL์
๋๋ค.
useSSEStream("/api/ai/stream", params, handlers);
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
| ์ต์
| ํ์
| ๊ธฐ๋ณธ๊ฐ | ์ค๋ช
|
|---|
enabled | boolean | true | ์๋ ์ฐ๊ฒฐ ์ฌ๋ถ |
retry | number | 3 | ์ต๋ ์ฌ์๋ ํ์ |
retryInterval | number | 3000 | ์ฌ์๋ ๊ฐ๊ฒฉ (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๊ฐ์ฒด } ํ์์ ์ฌ์ฉํ์ธ์.
๊ด๋ จ ๋ฌธ์